learning_ai_invt_trdg/backend/testTradeExecutorLifecycle.ts

270 lines
10 KiB
TypeScript

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<Candle[]> => {
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<any> => {
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<any> => this.syncedPosition;
getOrder = async (orderId: string): Promise<any> => {
const status = this.statuses.shift() || 'filled';
return {
id: orderId,
status,
filled_avg_price: 100,
filled_qty: '1'
};
}
cancelOrder = async (_orderId: string, _symbol?: string): Promise<boolean> => 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<any[]> = [];
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);
}