/** * Pyramiding/Position Stacking Integration Tests * * Tests for the pyramiding feature that allows scaling into winning positions * by adding to existing trades when confirmation signals arrive within a time window. * * Based on: PYRAMIDING_IMPLEMENTATION_PLAN.md * - Stacking Window: 4 hours (240 minutes / 48 bars on 5-min chart) * - Base Leverage: 7x (first entry) * - Stack Leverage: 7x (additional entry) * - Max Total Leverage: 14x (7x + 7x = 2 pyramid levels) * - Max Pyramid Levels: 2 (base + 1 stack) */ import { ActiveTrade } from '../../../lib/trading/position-manager' import { createMockTrade, createLongTrade, createShortTrade, TEST_DEFAULTS } from '../../helpers/trade-factory' // Mock dependencies jest.mock('../../../lib/drift/client', () => ({ getDriftService: jest.fn(() => ({ isInitialized: true, getClient: jest.fn(() => ({ getUser: jest.fn(() => ({ getPerpPosition: jest.fn(() => ({ baseAssetAmount: 1000000000n })), })), })), getPosition: jest.fn(() => ({ baseAssetAmount: 1000000000n, quoteAssetAmount: 140000000n, size: 1, notional: 140, })), getConnection: jest.fn(() => ({ confirmTransaction: jest.fn(() => ({ value: { err: null } })), })), openPosition: jest.fn(() => ({ signature: 'mock-open-tx', entry: { price: 140.00, size: 8000 } })), closePosition: jest.fn(() => ({ signature: 'mock-close-tx', exitPrice: 141.20 })), })), initializeDriftService: jest.fn(), })) jest.mock('../../../lib/pyth/price-monitor', () => ({ getPythPriceMonitor: jest.fn(() => ({ start: jest.fn(), stop: jest.fn(), getLatestPrice: jest.fn(() => 140.00), onPriceUpdate: jest.fn(), })), })) jest.mock('../../../lib/database/trades', () => ({ getPrismaClient: jest.fn(() => ({ trade: { findUnique: jest.fn(() => null), update: jest.fn(() => ({})), findMany: jest.fn(() => []), }, $disconnect: jest.fn(), })), createTrade: jest.fn(() => ({ id: 'mock-trade-id' })), updateTradeExit: jest.fn(() => ({})), updateTradeState: jest.fn(() => ({})), })) jest.mock('../../../lib/notifications/telegram', () => ({ sendPositionClosedNotification: jest.fn(), sendPyramidGroupClosedNotification: jest.fn(), sendTelegramMessage: jest.fn(), })) // Module-level helper to create pyramided trade function createPyramidTrade(options: Partial & { pyramidLevel?: number parentTradeId?: string | null stackedAt?: Date | null isStackedPosition?: boolean } = {}): ActiveTrade & { pyramidLevel: number parentTradeId: string | null stackedAt: Date | null isStackedPosition: boolean } { const base = createLongTrade() return { ...base, ...options, pyramidLevel: options.pyramidLevel ?? 1, parentTradeId: options.parentTradeId ?? null, stackedAt: options.stackedAt ?? null, isStackedPosition: options.isStackedPosition ?? false, } as ActiveTrade & { pyramidLevel: number parentTradeId: string | null stackedAt: Date | null isStackedPosition: boolean } } describe('Pyramiding/Position Stacking', () => { beforeEach(() => { jest.clearAllMocks() }) describe('Pyramid Detection - shouldStackPosition()', () => { it('should detect stacking opportunity when same direction + within window', () => { // Base position opened 2 hours ago (within 4-hour window) const basePosition = createPyramidTrade({ pyramidLevel: 1, isStackedPosition: false, }) basePosition.entryTime = Date.now() - (2 * 60 * 60 * 1000) // 2 hours ago const newSignal = { symbol: 'SOL-PERP', direction: 'long' as const, } // Within 4-hour window and same direction const timeSinceEntry = Date.now() - basePosition.entryTime const isWithinWindow = timeSinceEntry <= 4 * 60 * 60 * 1000 // 4 hours const isSameDirection = basePosition.direction === newSignal.direction const canStack = basePosition.pyramidLevel < 2 // Max 2 levels expect(isWithinWindow).toBe(true) expect(isSameDirection).toBe(true) expect(canStack).toBe(true) }) it('should NOT detect stacking when opposite direction', () => { const basePosition = createPyramidTrade({ pyramidLevel: 1, }) basePosition.direction = 'long' const newSignal = { symbol: 'SOL-PERP', direction: 'short' as const, // Opposite direction } const isSameDirection = basePosition.direction === newSignal.direction expect(isSameDirection).toBe(false) }) it('should NOT detect stacking when outside time window', () => { const basePosition = createPyramidTrade({ pyramidLevel: 1, }) basePosition.entryTime = Date.now() - (5 * 60 * 60 * 1000) // 5 hours ago (outside 4-hour window) const timeSinceEntry = Date.now() - basePosition.entryTime const isWithinWindow = timeSinceEntry <= 4 * 60 * 60 * 1000 expect(isWithinWindow).toBe(false) }) it('should NOT detect stacking when max pyramid levels reached', () => { const basePosition = createPyramidTrade({ pyramidLevel: 2, // Already at max }) const canStack = basePosition.pyramidLevel < 2 expect(canStack).toBe(false) }) it('should NOT stack on different symbols', () => { const basePosition = createPyramidTrade() basePosition.symbol = 'SOL-PERP' const newSignal = { symbol: 'ETH-PERP', // Different symbol direction: 'long' as const, } const isSameSymbol = basePosition.symbol === newSignal.symbol expect(isSameSymbol).toBe(false) }) }) describe('Pyramid Group Tracking', () => { it('should track base position as pyramid level 1', () => { const baseTrade = createPyramidTrade({ pyramidLevel: 1, isStackedPosition: false, parentTradeId: null, }) expect(baseTrade.pyramidLevel).toBe(1) expect(baseTrade.isStackedPosition).toBe(false) expect(baseTrade.parentTradeId).toBeNull() }) it('should track stacked position with correct parent reference', () => { const baseTrade = createPyramidTrade({ pyramidLevel: 1, isStackedPosition: false, }) baseTrade.id = 'base-trade-123' const stackedTrade = createPyramidTrade({ pyramidLevel: 2, isStackedPosition: true, parentTradeId: 'base-trade-123', stackedAt: new Date(), }) expect(stackedTrade.pyramidLevel).toBe(2) expect(stackedTrade.isStackedPosition).toBe(true) expect(stackedTrade.parentTradeId).toBe('base-trade-123') expect(stackedTrade.stackedAt).toBeDefined() }) it('should calculate combined size for pyramid group', () => { const baseSize = 8000 const stackSize = 8000 const combinedSize = baseSize + stackSize expect(combinedSize).toBe(16000) // 7x + 7x = 14x leverage equivalent }) it('should calculate weighted average entry for pyramid group', () => { const baseEntry = 140.00 const baseSize = 8000 const stackEntry = 141.50 const stackSize = 8000 // Weighted average entry const totalSize = baseSize + stackSize const avgEntry = (baseEntry * baseSize + stackEntry * stackSize) / totalSize expect(avgEntry).toBe(140.75) // Midpoint between entries }) }) describe('Unified Exit (Close Pyramid Group)', () => { it('should close base position and all stacked positions together', () => { const baseTrade = createPyramidTrade({ id: 'base-trade', pyramidLevel: 1, isStackedPosition: false, positionSize: 8000, }) const stackedTrade = createPyramidTrade({ id: 'stacked-trade', pyramidLevel: 2, parentTradeId: 'base-trade', isStackedPosition: true, positionSize: 8000, }) // Simulating unified exit - both trades should be marked for closure const pyramidGroup = [baseTrade, stackedTrade] const totalSize = pyramidGroup.reduce((sum, t) => sum + t.positionSize, 0) expect(pyramidGroup.length).toBe(2) expect(totalSize).toBe(16000) }) it('should calculate combined P&L for pyramid group', () => { const basePnL = 50.00 // $50 profit on base const stackedPnL = 25.00 // $25 profit on stack (entered later, less move) const combinedPnL = basePnL + stackedPnL expect(combinedPnL).toBe(75.00) }) it('should trigger unified exit when any position hits SL', () => { // When base position hits SL, both should close const baseHitsSL = true const shouldCloseGroup = baseHitsSL expect(shouldCloseGroup).toBe(true) }) it('should trigger unified exit when any position hits TP', () => { // When stacked position hits TP2, both should close const stackedHitsTP = true const shouldCloseGroup = stackedHitsTP expect(shouldCloseGroup).toBe(true) }) }) describe('Leverage Calculation', () => { it('should apply base leverage (7x) for first entry', () => { const baseLeverage = 7 const positionSize = 560 // $560 collateral const notional = positionSize * baseLeverage expect(notional).toBe(3920) // $3,920 notional }) it('should apply stack leverage (7x) for additional entry', () => { const stackLeverage = 7 const positionSize = 560 const notional = positionSize * stackLeverage expect(notional).toBe(3920) }) it('should respect max total leverage (14x)', () => { const baseLeverage = 7 const stackLeverage = 7 const totalLeverage = baseLeverage + stackLeverage const maxAllowed = 14 expect(totalLeverage).toBe(maxAllowed) expect(totalLeverage).toBeLessThanOrEqual(maxAllowed) }) it('should block stacking if would exceed max leverage', () => { const currentLeverage = 10 // Already at 10x const stackLeverage = 7 const maxAllowed = 14 const wouldExceed = (currentLeverage + stackLeverage) > maxAllowed expect(wouldExceed).toBe(true) }) }) describe('Notification Context', () => { it('should include pyramid level in individual trade notifications', () => { const notification = { symbol: 'SOL-PERP', direction: 'long', pnl: 50.00, pyramidLevel: 2, isStackedPosition: true, } expect(notification.pyramidLevel).toBe(2) expect(notification.isStackedPosition).toBe(true) }) it('should provide combined stats for group notifications', () => { const groupNotification = { symbol: 'SOL-PERP', direction: 'long', exitReason: 'TP2', totalPositions: 2, combinedPnL: 75.00, combinedSize: 16000, avgEntryPrice: 140.75, exitPrice: 142.50, pyramidLevels: [1, 2], } expect(groupNotification.totalPositions).toBe(2) expect(groupNotification.combinedPnL).toBe(75.00) expect(groupNotification.pyramidLevels).toContain(1) expect(groupNotification.pyramidLevels).toContain(2) }) }) describe('Edge Cases', () => { it('should handle stacking at exact window boundary (4 hours)', () => { const windowMs = 4 * 60 * 60 * 1000 // 4 hours in ms const entryTime = Date.now() - windowMs // Exactly 4 hours ago const timeSinceEntry = Date.now() - entryTime const isWithinWindow = timeSinceEntry <= windowMs // Boundary should be inclusive expect(isWithinWindow).toBe(true) }) it('should handle stacking just outside window (4h 1min)', () => { const windowMs = 4 * 60 * 60 * 1000 const entryTime = Date.now() - windowMs - (60 * 1000) // 4h 1min ago const timeSinceEntry = Date.now() - entryTime const isWithinWindow = timeSinceEntry <= windowMs expect(isWithinWindow).toBe(false) }) it('should prevent third pyramid level', () => { const currentLevel = 2 // Already at level 2 const maxLevels = 2 const canAddMore = currentLevel < maxLevels expect(canAddMore).toBe(false) }) it('should handle single position (no stack) gracefully', () => { const singleTrade = createLongTrade() ;(singleTrade as any).pyramidLevel = 1 ;(singleTrade as any).isStackedPosition = false ;(singleTrade as any).parentTradeId = null // Single position should still work, just with pyramidLevel 1 const isBaseTrade = (singleTrade as any).pyramidLevel === 1 && !(singleTrade as any).isStackedPosition expect(isBaseTrade).toBe(true) }) it('should calculate correct P&L when base wins and stack loses', () => { const basePnL = 80.00 // Base entered early, good entry const stackPnL = -30.00 // Stack entered late, worse entry const combinedPnL = basePnL + stackPnL expect(combinedPnL).toBe(50.00) // Net positive }) it('should calculate correct P&L when both lose', () => { const basePnL = -40.00 const stackPnL = -35.00 const combinedPnL = basePnL + stackPnL expect(combinedPnL).toBe(-75.00) // Net negative }) }) })