import assert from 'node:assert/strict'; import { TradeExecutor } from '../src/services/TradeExecutor.js'; import { SignalDirection } from '../src/strategies/rules/types.js'; import { Candle, IExchangeConnector } from '../src/connectors/types.js'; import { supabaseService } from '../src/services/SupabaseService.js'; import { distributedLockService } from '../src/services/distributedLockService.js'; import { observabilityService } from '../src/services/observabilityService.js'; import { capitalLedger } from '../src/services/CapitalLedger.js'; const normalizeError = (reason: unknown): Error => { if (reason instanceof Error) return reason; if (reason && typeof reason === 'object') return new Error(JSON.stringify(reason)); return new Error(String(reason ?? 'Unknown error')); }; const logAndRethrow = (kind: string, reason: unknown) => { const err = normalizeError(reason); console.error(`[testTradeExecutorLifecycle] ${kind}`, err); throw err; }; process.on('unhandledRejection', (reason) => logAndRethrow('unhandled rejection', reason)); process.on('uncaughtException', (error) => logAndRethrow('uncaught exception', error)); console.log('[testTradeExecutorLifecycle] script started'); class MockExchangeConnector implements IExchangeConnector { private statuses: string[] = []; private orderSeq = 0; private cancelSuccess = true; private syncedPosition: any | null = null; public setCancelResult(shouldSucceed: boolean) { this.cancelSuccess = shouldSucceed; } public setPosition(position: any | null) { this.syncedPosition = position; } public queueStatuses(...statuses: string[]) { this.statuses.push(...statuses.map((s) => s.toLowerCase())); } fetchOHLCV = async (_symbol: string, _timeframe: string, _limit?: number): Promise => { return [ { timestamp: Date.now(), open: 100, high: 101, low: 99, close: 100, volume: 1 } ]; }; placeOrder = async (symbol: string, side: 'buy' | 'sell', qty: number, type: 'market' | 'limit', price?: number): Promise => { this.orderSeq += 1; return { id: `MOCK-${this.orderSeq}`, symbol, side, qty, type, status: 'pending_new', filled_avg_price: price || 100, filled_qty: `${qty}` }; } getPosition = async (_symbol: string): Promise => this.syncedPosition; getOrder = async (orderId: string): Promise => { const status = this.statuses.shift() || 'filled'; return { id: orderId, status, filled_avg_price: 100, filled_qty: '1' }; } cancelOrder = async (_orderId: string, _symbol?: string): Promise => this.cancelSuccess; getCapabilities = () => ({ fetchOpenOrders: true, fetchOrders: true, fetchClosedOrders: true, fetchMyTrades: true, fetchOHLCV: true, cancelOrder: true, createOrder: true, editOrder: false, fetchBalance: true, fetchLedger: false, fetchTicker: true, fetchTickers: true, fetchPosition: true, fetchPositions: true, shorting: true }); } process.on('unhandledRejection', (reason) => { console.error('[trade-executor-lifecycle] unhandled rejection', reason); }); process.on('uncaughtException', (error) => { console.error('[trade-executor-lifecycle] uncaught exception', error); }); async function run() { // Keep this test deterministic and offline. let logOrderWrites = 0; let updateOrderWritesObservedAfterLog = false; const pushedPositionSnapshots: Array = []; const profileId = '00000000-0000-0000-0000-000000000000'; (supabaseService as any).getClient = () => null; (observabilityService as any).emitEvent = () => { }; (supabaseService as any).updateOrderStatus = async () => { if (logOrderWrites > 0) { updateOrderWritesObservedAfterLog = true; } }; (supabaseService as any).logOrder = async () => { await new Promise((resolve) => setTimeout(resolve, 15)); logOrderWrites += 1; }; (supabaseService as any).logTransaction = async () => { }; (supabaseService as any).getVirtualOpenPosition = async () => null; (supabaseService as any).getPendingOrdersForProfile = async () => []; (supabaseService as any).hasActiveOrderForTradeId = async () => false; (supabaseService as any).hasFinalizedTradeHistory = async () => false; (capitalLedger as any).getAvailableCapital = async () => 1_000_000; (capitalLedger as any).reserveForOrder = async () => true; (capitalLedger as any).releaseOrderReservation = async () => { }; (capitalLedger as any).adjustPositionReservation = async () => { }; (capitalLedger as any).recordRealizedPnl = async () => { }; (capitalLedger as any).rebuildLedger = async () => { }; (capitalLedger as any).getLedger = async () => ({ profile_id: profileId, allocated_capital: 1_000_000, reserved_for_orders: 0, reserved_for_positions: 0, realized_pnl: 0, updated_at: new Date().toISOString() }); const apiServerStub = { updatePositions: (positions: any[]) => { pushedPositionSnapshots.push(positions.map((position) => ({ ...position }))); }, updateOrders: () => { }, addHistory: () => { }, recordOrderFailure: () => { }, getState: () => ({ accountSnapshot: null }), updateAccountSnapshot: () => { }, updateAccountSnapshotCache: () => { } }; const connector = new MockExchangeConnector(); (distributedLockService as any).tryAcquireRowLock = async () => true; (distributedLockService as any).releaseRowLock = async () => true; (distributedLockService as any).isEntryInProgress = async () => false; const executor = new TradeExecutor(connector, apiServerStub as any, 'test-user', profileId); connector.queueStatuses('filled'); let openResult; try { openResult = await executor.openPosition('BTC/USD', SignalDirection.BUY, 1, 'market'); } catch (err) { console.error('[trade-executor-lifecycle] openPosition threw', err); throw err; } console.log('[trade-executor-lifecycle] openResult', openResult); assert.equal(openResult.success, true, 'Expected openPosition to succeed'); assert(executor.getActivePosition('BTC/USD'), 'Active position missing after filled entry'); assert( pushedPositionSnapshots.some((snapshot) => snapshot.some((position) => position.symbol === 'BTC/USD')), 'Dashboard position snapshot should be pushed immediately after entry fill.' ); connector.setPosition({ side: 'long', qty: '1', avg_entry_price: '100' }); connector.queueStatuses('rejected'); const rejectedExit = await executor.closePosition('BTC/USD', 'test-rejected-exit'); assert.equal(rejectedExit.success, false, 'Expected rejected exit to fail'); assert.equal(executor.getExitLifecycle('BTC/USD').state, 'failed', 'Exit lifecycle should be failed after rejected exit'); assert(executor.getActivePosition('BTC/USD'), 'Position should remain open after rejected exit'); connector.queueStatuses('filled'); const secondOpen = await executor.openPosition('ETH/USD', SignalDirection.BUY, 1, 'market'); assert.equal(secondOpen.success, true, 'Expected second openPosition to succeed'); connector.setCancelResult(false); connector.queueStatuses('filled'); const unknownOpen = await executor.openPosition('XRP/USD', SignalDirection.BUY, 1, 'market'); assert.equal(unknownOpen.success, true, 'Expected unknown-path openPosition to succeed'); // Unknown exit path connector.setCancelResult(false); let getPosCount = 0; const originalGetPosition = connector.getPosition; connector.getPosition = async () => { getPosCount++; if (getPosCount === 1) return { qty: 1, side: 'long', avg_entry_price: 100 }; return null; }; connector.queueStatuses('new', 'new', 'new'); const unknownExit = await executor.closePosition('XRP/USD', 'test-unknown-exit'); connector.getPosition = originalGetPosition; // Restore assert.equal(unknownExit.success, false, 'Expected unknown exit to fail'); assert.equal(executor.getExitLifecycle('XRP/USD').state, 'quarantined', 'Unknown exit should enter quarantined state'); assert(executor.getActivePosition('XRP/USD'), 'Position should remain open after unknown exit'); connector.setCancelResult(true); connector.setPosition({ side: 'long', qty: '1', avg_entry_price: '100' }); connector.queueStatuses('filled'); const filledExit = await executor.closePosition('ETH/USD', 'test-filled-exit'); assert.equal(filledExit.success, true, 'Expected filled exit to succeed'); assert.equal(executor.getExitLifecycle('ETH/USD').state, 'filled', 'Exit lifecycle should be marked filled after final close'); assert.equal(executor.getActivePosition('ETH/USD'), null, 'Position should be removed after successful exit'); const retainTradeId = 'TRD-retain-local'; const internalPositions = executor.getAllPositions(); internalPositions.set(`SOL/USD::${retainTradeId}`, { symbol: 'SOL/USD', side: SignalDirection.BUY, entryPrice: 100, size: 1, stopLoss: 95, takeProfit: 110, peakPrice: 100, userId: 'test-user', profileId, tradeId: retainTradeId }); connector.setPosition({ side: 'long', qty: '1', avg_entry_price: '100' }); await executor.syncPositions(['SOL/USD']); assert( executor.getActivePosition('SOL/USD', retainTradeId), 'Dedicated profile sync must retain local lifecycle state when exchange is open but virtual lookup is temporarily empty.' ); console.log('[trade-executor-lifecycle] OK: entry/exit terminal-state checks passed'); } try { await run(); } catch (error: any) { console.error('[trade-executor-lifecycle] Test failed', error?.message, error); process.exit(1); }