230 lines
8.4 KiB
TypeScript
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();
|