- Removed incorrect exclusion of *.test.ts and *.test.js files - Added coverage/ folder to .gitignore - Removed accidentally committed coverage files Co-authored-by: mindesbunister <32161838+mindesbunister@users.noreply.github.com>
242 lines
8.8 KiB
TypeScript
242 lines
8.8 KiB
TypeScript
/**
|
|
* 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)
|
|
})
|
|
})
|
|
})
|