import assert from 'node:assert/strict'; import { TradeExecutor } from '../src/services/TradeExecutor.js'; import { TradeMonitor } from '../src/services/tradeMonitor.js'; import { SignalDirection } from '../src/strategies/rules/types.js'; import type { Candle, IExchangeConnector } from '../src/connectors/types.js'; import { supabaseService } from '../src/services/SupabaseService.js'; import { config } from '../src/config/index.js'; class MockLifecycleExchange implements IExchangeConnector { private positionQueue: Array = []; private latestPrice = 100; queuePositions(...positions: Array) { this.positionQueue.push(...positions); } setLatestPrice(price: number) { this.latestPrice = price; } async fetchOHLCV(_symbol: string, _timeframe: string, _limit?: number): Promise { return [{ timestamp: Date.now(), open: this.latestPrice, high: this.latestPrice, low: this.latestPrice, close: this.latestPrice, volume: 1 }]; } async placeOrder(_symbol: string, _side: 'buy' | 'sell', qty: number, _type: 'market' | 'limit', price?: number): Promise { return { id: `mock-order-${Date.now()}`, status: 'filled', filled_avg_price: price || this.latestPrice, filled_qty: qty }; } async getPosition(_symbol: string): Promise { if (this.positionQueue.length > 0) { return this.positionQueue.shift(); } return { side: 'long', qty: '1', avg_entry_price: this.latestPrice }; } cancelOrder = async (_orderId: string, _symbol?: string): Promise => true; 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 }); } class MockTradeExecutorForMonitor { public positions = new Map(); public exitCalls: Array<{ symbol: string; reason: string; price: number; tradeId?: string }> = []; public completeCalls: Array<{ symbol: string; reason: string; price: number; tradeId?: string }> = []; public manualReviewCalls: Array<{ symbol: string; reason: string; details?: string; tradeId?: string }> = []; public pendingOrders = new Map(); getActiveSymbols(): string[] { return Array.from(this.positions.keys()); } getActivePosition(symbol: string, tradeId?: string): any { const bySymbol = this.positions.get(symbol) || []; if (!Array.isArray(bySymbol) || bySymbol.length === 0) return null; if (tradeId) { return bySymbol.find((p: any) => p.tradeId === tradeId) || null; } return bySymbol[0] || null; } getActivePositions(symbol: string): any[] { const bySymbol = this.positions.get(symbol) || []; return Array.isArray(bySymbol) ? bySymbol : []; } async executeExit(symbol: string, currentPrice: number, reason: string, tradeId?: string): Promise<{ success: boolean }> { this.exitCalls.push({ symbol, reason, price: currentPrice, tradeId }); return { success: true }; } async markTradeComplete(symbol: string, exitPrice: number, reason: string, tradeId?: string): Promise<{ success: boolean }> { this.completeCalls.push({ symbol, reason, price: exitPrice, tradeId }); return { success: true }; } markExitManualReview(symbol: string, reason: string, details?: string, tradeId?: string): void { this.manualReviewCalls.push({ symbol, reason, details, tradeId }); } getAllActivePositions(): any[] { return []; } getProfileId(): string { return 'test-profile'; } getPendingOrders(): Map { return this.pendingOrders; } verifyCapability(_capability: string, _reason: string): boolean { return true; } } async function testZeroTakeProfitGuard() { const exchange = new MockLifecycleExchange(); exchange.setLatestPrice(105); const executor = new MockTradeExecutorForMonitor(); executor.positions.set('BTC/USD', [{ symbol: 'BTC/USD', side: 'BUY', entryPrice: 100, size: 1, stopLoss: 0, takeProfit: 0, peakPrice: 100, profitGuardActive: false }]); const monitor = new TradeMonitor(exchange, executor as any); await (monitor as any).checkOpenPositions(); assert.equal(executor.exitCalls.length, 0, 'Zero takeProfit must not activate trailing guard / exit.'); const posAfter = executor.getActivePosition('BTC/USD'); assert.equal(Boolean(posAfter?.profitGuardActive), false, 'profitGuardActive must remain false when takeProfit is zero.'); } async function testPartialExitReduction() { const exchange = new MockLifecycleExchange(); // Avoid DB writes in test mode. (supabaseService as any).logTransaction = async () => { }; const executor = new TradeExecutor(exchange, undefined, 'global', 'test-profile'); const activeMap = (executor as any).activeTraders as Map; activeMap.set('ETH/USD::TRD-test-partial', { symbol: 'ETH/USD', side: SignalDirection.BUY, entryPrice: 2000, size: 1, stopLoss: 1900, takeProfit: 2200, peakPrice: 2000, userId: 'global', profileId: 'test-profile', tradeId: 'TRD-test-partial' }); const partial = await executor.applyExitFill('ETH/USD', 2100, 0.4, 'Regression Partial Exit'); assert.equal(partial.success, true, 'Partial exit should apply successfully.'); assert.equal(partial.fullyClosed, false, 'Partial exit must not fully close the trade.'); assert.equal(partial.remainingSize, 0.6, 'Remaining size must be reduced immediately after partial exit.'); assert.equal(executor.getActivePosition('ETH/USD')?.size, 0.6, 'Active position size should be reduced in-memory.'); const final = await executor.applyExitFill('ETH/USD', 2110, 0.6, 'Regression Final Exit'); assert.equal(final.success, true, 'Final exit should apply successfully.'); assert.equal(final.fullyClosed, true, 'Second fill should close remaining quantity.'); assert.equal(executor.getActivePosition('ETH/USD'), null, 'Position must be removed after full exit fill.'); } async function testStalePositionReconciliation() { const previousStrictEvidenceSetting = config.REQUIRE_EXCHANGE_FILL_EVIDENCE_FOR_AUTO_CLOSE; (config as any).REQUIRE_EXCHANGE_FILL_EVIDENCE_FOR_AUTO_CLOSE = true; try { const exchange = new MockLifecycleExchange(); exchange.setLatestPrice(99); exchange.queuePositions( null, // first scan: miss #1 null, // second scan: miss #2 null // second scan confirmation: still missing ); const executor = new MockTradeExecutorForMonitor(); executor.positions.set('SOL/USD', [{ symbol: 'SOL/USD', side: 'BUY', entryPrice: 100, size: 2, stopLoss: 90, takeProfit: 110, peakPrice: 100 }]); const monitor = new TradeMonitor(exchange, executor as any); await (monitor as any).checkOpenPositions(); assert.equal(executor.completeCalls.length, 0, 'Single missing detection must not finalize trade.'); await (monitor as any).checkOpenPositions(); assert.equal(executor.completeCalls.length, 0, 'Strict evidence mode must not auto-finalize stale position without exchange fill evidence.'); assert.equal(executor.manualReviewCalls.length, 1, 'Second confirmed missing detection should route stale position to manual review.'); assert.equal(executor.manualReviewCalls[0].symbol, 'SOL/USD'); } finally { (config as any).REQUIRE_EXCHANGE_FILL_EVIDENCE_FOR_AUTO_CLOSE = previousStrictEvidenceSetting; } } async function run() { await testZeroTakeProfitGuard(); await testPartialExitReduction(); await testStalePositionReconciliation(); console.log('[lifecycle-regressions] OK: zero-TP, partial-exit, stale reconciliation checks passed'); } await run();