/** * Edge Cases Tests * * Tests for edge cases and common pitfalls in Position Manager. * * Pitfalls tested: * - #24: Position.size as tokens, not USD * - #54: MAE/MFE as percentages, not dollars * - Phantom trade detection (< 50% expected size) * - Profit percent calculation for LONG and SHORT */ import { createLongTrade, createShortTrade, TEST_DEFAULTS, calculateExpectedProfitPercent } from '../../helpers/trade-factory' describe('Edge Cases', () => { describe('Position.size tokens vs USD (Pitfall #24)', () => { it('should convert token size to USD correctly', () => { // Drift SDK returns position.size in BASE ASSET TOKENS (e.g., 12.28 SOL) // NOT in USD ($1,950) const positionSizeTokens = 12.28 // SOL tokens const currentPrice = 159.12 // Current SOL price // CORRECT: Convert tokens to USD const positionSizeUSD = Math.abs(positionSizeTokens) * currentPrice expect(positionSizeUSD).toBeCloseTo(1953.59, 0) // WRONG: Using tokens directly as USD (off by 159x!) expect(positionSizeTokens).not.toBe(positionSizeUSD) }) it('should detect TP1 using USD values, not token values', () => { // Bug: Comparing tokens (12.28) to USD ($1,950) caused false TP1 detection // 12.28 < 1950 * 0.95 was always true! const trackedSizeUSD = 1950 const positionSizeTokens = 12.28 const currentPrice = 159.12 // WRONG: Direct comparison (would always think 95% was reduced) const wrongComparison = positionSizeTokens < trackedSizeUSD * 0.95 expect(wrongComparison).toBe(true) // BUG: False positive! // CORRECT: Convert to USD first const positionSizeUSD = Math.abs(positionSizeTokens) * currentPrice const correctComparison = positionSizeUSD < trackedSizeUSD * 0.95 expect(correctComparison).toBe(false) // Position is actually ~100%, not reduced }) it('should calculate size reduction correctly using USD', () => { const originalSizeUSD = 8000 const tp1SizePercent = 60 // After TP1, 60% closed, 40% remaining const expectedRemainingUSD = originalSizeUSD * (1 - tp1SizePercent / 100) expect(expectedRemainingUSD).toBe(3200) // Token equivalent at $140/SOL const tokensRemaining = expectedRemainingUSD / 140 expect(tokensRemaining).toBeCloseTo(22.86, 1) // Verify conversion back to USD const convertedBackUSD = tokensRemaining * 140 expect(convertedBackUSD).toBeCloseTo(expectedRemainingUSD, 0) }) }) describe('Phantom trade detection (< 50% expected size)', () => { it('should detect phantom when actual size < 50% of expected', () => { const expectedSizeUSD = 8000 const actualSizeUSD = 1370 // Only 17% filled const sizeRatio = actualSizeUSD / expectedSizeUSD const isPhantom = sizeRatio < 0.5 expect(sizeRatio).toBeCloseTo(0.171, 2) expect(isPhantom).toBe(true) }) it('should NOT detect phantom when size >= 50%', () => { const expectedSizeUSD = 8000 const actualSizeUSD = 4500 // 56% filled const sizeRatio = actualSizeUSD / expectedSizeUSD const isPhantom = sizeRatio < 0.5 expect(sizeRatio).toBeCloseTo(0.5625, 2) expect(isPhantom).toBe(false) }) it('should handle exact 50% boundary', () => { const expectedSizeUSD = 8000 const actualSizeUSD = 4000 // Exactly 50% const sizeRatio = actualSizeUSD / expectedSizeUSD const isPhantom = sizeRatio < 0.5 expect(sizeRatio).toBe(0.5) expect(isPhantom).toBe(false) // 50% is acceptable }) it('should NOT flag runner after TP1 as phantom', () => { // Bug: After TP1, currentSize is 40% of original // This should NOT be flagged as phantom const trade = createLongTrade({ tp1Hit: true }) trade.currentSize = trade.positionSize * 0.4 // 40% remaining // Phantom check should ONLY run on initial position, not after TP1 const isAfterTP1 = trade.tp1Hit const sizeRatio = trade.currentSize / trade.positionSize // Even though size is <50%, this is NOT a phantom - it's a runner const isPhantom = !isAfterTP1 && sizeRatio < 0.5 expect(isAfterTP1).toBe(true) expect(sizeRatio).toBe(0.4) expect(isPhantom).toBe(false) // Correctly NOT flagged as phantom }) }) describe('MAE/MFE as percentages (Pitfall #54)', () => { it('should track MFE as percentage, not dollars', () => { const trade = createLongTrade({ entryPrice: 140 }) const bestPrice = 141.20 // +0.86% // CORRECT: Store as percentage const mfePercent = ((bestPrice - trade.entryPrice) / trade.entryPrice) * 100 expect(mfePercent).toBeCloseTo(0.857, 1) // Database expects small % values like 0.86, not $68 expect(mfePercent).toBeLessThan(5) // Sanity check: percentage is small }) it('should track MAE as percentage, not dollars', () => { const trade = createLongTrade({ entryPrice: 140 }) const worstPrice = 138.60 // -1% // CORRECT: Store as percentage (negative for loss) const maePercent = ((worstPrice - trade.entryPrice) / trade.entryPrice) * 100 expect(maePercent).toBeCloseTo(-1.0, 1) // MAE should be negative for adverse movement expect(maePercent).toBeLessThan(0) }) it('should update MFE when profit increases', () => { const trade = createLongTrade({ entryPrice: 140 }) trade.maxFavorableExcursion = 0 // Price moves to +0.5%, then +1% const prices = [140.70, 141.40] for (const price of prices) { const profitPercent = ((price - trade.entryPrice) / trade.entryPrice) * 100 if (profitPercent > trade.maxFavorableExcursion) { trade.maxFavorableExcursion = profitPercent trade.maxFavorablePrice = price } } expect(trade.maxFavorableExcursion).toBeCloseTo(1.0, 1) // +1% expect(trade.maxFavorablePrice).toBe(141.40) }) it('should update MAE when loss increases', () => { const trade = createLongTrade({ entryPrice: 140 }) trade.maxAdverseExcursion = 0 // Price moves to -0.3%, then -0.5% const prices = [139.58, 139.30] for (const price of prices) { const profitPercent = ((price - trade.entryPrice) / trade.entryPrice) * 100 if (profitPercent < trade.maxAdverseExcursion) { trade.maxAdverseExcursion = profitPercent trade.maxAdversePrice = price } } expect(trade.maxAdverseExcursion).toBeCloseTo(-0.5, 1) // -0.5% expect(trade.maxAdversePrice).toBe(139.30) }) it('SHORT: should calculate MFE correctly (positive when price drops)', () => { const trade = createShortTrade({ entryPrice: 140 }) const bestPrice = 138.60 // -1% = good for SHORT // For SHORT, profit when price drops const mfePercent = ((trade.entryPrice - bestPrice) / trade.entryPrice) * 100 expect(mfePercent).toBeCloseTo(1.0, 1) // +1% profit for short expect(mfePercent).toBeGreaterThan(0) }) it('SHORT: should calculate MAE correctly (negative when price rises)', () => { const trade = createShortTrade({ entryPrice: 140 }) const worstPrice = 141.40 // +1% = bad for SHORT // For SHORT, loss when price rises const maePercent = ((trade.entryPrice - worstPrice) / trade.entryPrice) * 100 expect(maePercent).toBeCloseTo(-1.0, 1) // -1% loss for short expect(maePercent).toBeLessThan(0) }) }) describe('Profit percent calculation', () => { it('LONG: positive profit when price increases', () => { const profit = calculateExpectedProfitPercent(140, 141.20, 'long') expect(profit).toBeCloseTo(0.857, 1) expect(profit).toBeGreaterThan(0) }) it('LONG: negative profit when price decreases', () => { const profit = calculateExpectedProfitPercent(140, 138.80, 'long') expect(profit).toBeCloseTo(-0.857, 1) expect(profit).toBeLessThan(0) }) it('SHORT: positive profit when price decreases', () => { const profit = calculateExpectedProfitPercent(140, 138.80, 'short') expect(profit).toBeCloseTo(0.857, 1) expect(profit).toBeGreaterThan(0) }) it('SHORT: negative profit when price increases', () => { const profit = calculateExpectedProfitPercent(140, 141.20, 'short') expect(profit).toBeCloseTo(-0.857, 1) expect(profit).toBeLessThan(0) }) it('should return 0 when price equals entry', () => { expect(calculateExpectedProfitPercent(140, 140, 'long')).toBe(0) expect(calculateExpectedProfitPercent(140, 140, 'short')).toBe(0) }) }) })