270 lines
10 KiB
TypeScript
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);
|
|
}
|