learning_ai_invt_trdg/backend/testLifecycleRegressions.ts

230 lines
8.4 KiB
TypeScript

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<any | null> = [];
private latestPrice = 100;
queuePositions(...positions: Array<any | null>) {
this.positionQueue.push(...positions);
}
setLatestPrice(price: number) {
this.latestPrice = price;
}
async fetchOHLCV(_symbol: string, _timeframe: string, _limit?: number): Promise<Candle[]> {
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<any> {
return {
id: `mock-order-${Date.now()}`,
status: 'filled',
filled_avg_price: price || this.latestPrice,
filled_qty: qty
};
}
async getPosition(_symbol: string): Promise<any> {
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<boolean> => 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<string, any>();
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<string, any>();
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<string, any> {
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<string, any>;
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();