learning_ai_invt_trdg/backend/testOrderStatusSyncRegressions.ts

265 lines
10 KiB
TypeScript

import assert from 'node:assert/strict';
import { OrderStatusSyncService, type OrderStatusSyncEvent } from '../src/services/OrderStatusSyncService.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 MockOrderSyncExchange implements IExchangeConnector {
private orderResponses = new Map<string, any>();
setOrder(orderId: string, payload: any) {
this.orderResponses.set(orderId, payload);
}
async fetchOHLCV(_symbol: string, _timeframe: string, _limit?: number): Promise<Candle[]> {
return [{
timestamp: Date.now(),
open: 100,
high: 100,
low: 100,
close: 100,
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 || 100,
filled_qty: qty
};
}
async getPosition(_symbol: string): Promise<any> {
return null;
}
async getOrder(orderId: string): Promise<any> {
const response = this.orderResponses.get(orderId);
if (response instanceof Error) {
throw response;
}
return response ?? null;
}
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
});
}
async function run() {
const exchange = new MockOrderSyncExchange();
const seenEvents: OrderStatusSyncEvent[] = [];
const updateCalls: Array<{ orderId: string; status: string; fillQty?: number }> = [];
const previousMissingGrace = config.ORDER_SYNC_MISSING_GRACE_MINUTES;
const previousMissingConfirmations = config.ORDER_SYNC_MISSING_CONFIRMATION_COUNT;
const originalGetStaleOrders = (supabaseService as any).getStaleOrders;
const originalUpdateOrderStatus = (supabaseService as any).updateOrderStatus;
const originalIsTradeLifecycleClosed = (supabaseService as any).isTradeLifecycleClosed;
const originalGetVirtualOpenPosition = (supabaseService as any).getVirtualOpenPosition;
const staleRows = [
{
order_id: 'ord-partial',
symbol: 'BTC/USD',
status: 'pending_new',
action: 'EXIT',
trade_id: 'TRD-sync-1',
user_id: 'user-1',
profile_id: 'profile-1',
created_at: new Date(Date.now() - 15 * 60 * 1000).toISOString()
},
{
order_id: 'ord-missing-old',
symbol: 'ETH/USD',
status: 'pending_new',
action: 'ENTRY',
trade_id: 'TRD-sync-2',
user_id: 'user-1',
profile_id: 'profile-1',
created_at: new Date(Date.now() - 26 * 60 * 60 * 1000).toISOString()
},
{
order_id: 'ord-ghost-exit',
symbol: 'BTC/USD',
status: 'pending_new',
action: 'EXIT',
trade_id: 'TRD-sync-closed',
user_id: 'user-1',
profile_id: 'profile-1',
created_at: new Date(Date.now() - 15 * 60 * 1000).toISOString()
},
{
order_id: 'ord-legacy-closed-error',
symbol: 'ETH/USD',
status: 'pending_new',
trade_id: 'TRD-sync-closed-error',
user_id: 'user-1',
profile_id: 'profile-1',
created_at: new Date(Date.now() - 20 * 60 * 1000).toISOString()
},
{
order_id: 'ord-transient-failure',
symbol: 'ETH/USD',
status: 'pending_new',
action: 'EXIT',
trade_id: 'TRD-sync-timeout',
user_id: 'user-1',
profile_id: 'profile-1',
created_at: new Date(Date.now() - 20 * 60 * 1000).toISOString()
},
{
order_id: 'ord-legacy-no-trade',
symbol: 'SOL/USD',
side: 'SELL',
status: 'pending_new',
action: 'EXIT',
user_id: 'user-1',
profile_id: 'profile-1',
created_at: new Date(Date.now() - 30 * 60 * 1000).toISOString()
}
];
exchange.setOrder('ord-partial', {
id: 'ord-partial',
status: 'partially_filled',
filled_avg_price: 101.25,
filled_qty: '0.35'
});
exchange.setOrder('ord-missing-old', null);
exchange.setOrder('ord-legacy-closed-error', new Error('404 order not found'));
exchange.setOrder('ord-transient-failure', new Error('socket timeout'));
exchange.setOrder('ord-legacy-no-trade', null);
(supabaseService as any).getStaleOrders = async () => staleRows;
(supabaseService as any).isTradeLifecycleClosed = async (tradeId: string) =>
tradeId === 'TRD-sync-closed' || tradeId === 'TRD-sync-closed-error';
(supabaseService as any).getVirtualOpenPosition = async (profileId: string, symbol: string) => {
if (profileId === 'profile-1' && symbol === 'SOL/USD') {
return null;
}
return {
profileId,
symbol,
side: 'BUY',
qty: 1,
entryPrice: 100,
stopLoss: 90,
takeProfit: 110,
tradeId: 'TRD-open'
};
};
(supabaseService as any).updateOrderStatus = async (
orderId: string,
status: string,
_filledAt?: Date,
_price?: number,
qty?: number
) => {
updateCalls.push({ orderId, status, fillQty: qty });
};
try {
(config as any).ORDER_SYNC_MISSING_GRACE_MINUTES = 0;
(config as any).ORDER_SYNC_MISSING_CONFIRMATION_COUNT = 2;
const service = new OrderStatusSyncService(
exchange,
10_000,
'profile-1',
(event) => {
seenEvents.push(event);
}
);
await service.triggerSync();
const partialUpdate = updateCalls.find((c) => c.orderId === 'ord-partial');
assert(partialUpdate, 'Partial-fill stale order should be updated.');
assert.equal(partialUpdate.status, 'partially_filled');
assert.equal(Number(partialUpdate.fillQty), 0.35, 'Partial-fill quantity should be preserved in status update.');
const partialEvent = seenEvents.find((e) => e.orderId === 'ord-partial');
assert(partialEvent, 'Partial-fill stale order should emit lifecycle callback.');
assert.equal(partialEvent?.status, 'partially_filled');
assert.equal(Number(partialEvent?.fillQty), 0.35);
assert.equal(Boolean(partialEvent?.quarantined), false);
assert.equal(
Boolean(updateCalls.find((c) => c.orderId === 'ord-missing-old')),
false,
'Missing orders should not mutate on first detection in confirmation mode.'
);
await service.triggerSync();
const missingOldUpdate = updateCalls.find((c) => c.orderId === 'ord-missing-old');
assert(missingOldUpdate, 'Old missing order should be quarantined to unknown.');
assert.equal(missingOldUpdate.status, 'unknown');
const missingOldEvent = seenEvents.find((e) => e.orderId === 'ord-missing-old');
assert(missingOldEvent, 'Old missing order should emit quarantined callback.');
assert.equal(missingOldEvent?.status, 'unknown');
assert.equal(Boolean(missingOldEvent?.quarantined), true);
const ghostExitUpdate = updateCalls.find((c) => c.orderId === 'ord-ghost-exit');
assert(ghostExitUpdate, 'Closed lifecycle ghost EXIT should be auto-resolved.');
assert.equal(ghostExitUpdate.status, 'canceled');
const ghostExitEvent = seenEvents.find((e) => e.orderId === 'ord-ghost-exit');
assert(ghostExitEvent, 'Closed lifecycle ghost EXIT should emit callback.');
assert.equal(ghostExitEvent?.status, 'canceled');
assert.equal(Boolean(ghostExitEvent?.quarantined), false);
const legacyClosedUpdate = updateCalls.find((c) => c.orderId === 'ord-legacy-closed-error');
assert(legacyClosedUpdate, 'Legacy closed lifecycle with exchange not-found exception should be auto-resolved.');
assert.equal(legacyClosedUpdate.status, 'canceled');
const legacyClosedEvent = seenEvents.find((e) => e.orderId === 'ord-legacy-closed-error');
assert(legacyClosedEvent, 'Legacy closed lifecycle should emit callback.');
assert.equal(legacyClosedEvent?.status, 'canceled');
assert.equal(Boolean(legacyClosedEvent?.quarantined), false);
const transientFailureUpdate = updateCalls.find((c) => c.orderId === 'ord-transient-failure');
assert.equal(Boolean(transientFailureUpdate), false, 'Transient exchange failures must not mutate order status.');
const legacyNoTradeUpdate = updateCalls.find((c) => c.orderId === 'ord-legacy-no-trade');
assert(legacyNoTradeUpdate, 'Legacy EXIT-like order without trade_id should be auto-resolved when profile lifecycle is flat.');
assert.equal(legacyNoTradeUpdate.status, 'canceled');
const legacyNoTradeEvent = seenEvents.find((e) => e.orderId === 'ord-legacy-no-trade');
assert(legacyNoTradeEvent, 'Legacy EXIT-like order without trade_id should emit callback.');
assert.equal(legacyNoTradeEvent?.status, 'canceled');
assert.equal(Boolean(legacyNoTradeEvent?.quarantined), false);
} finally {
(config as any).ORDER_SYNC_MISSING_GRACE_MINUTES = previousMissingGrace;
(config as any).ORDER_SYNC_MISSING_CONFIRMATION_COUNT = previousMissingConfirmations;
(supabaseService as any).getStaleOrders = originalGetStaleOrders;
(supabaseService as any).updateOrderStatus = originalUpdateOrderStatus;
(supabaseService as any).isTradeLifecycleClosed = originalIsTradeLifecycleClosed;
(supabaseService as any).getVirtualOpenPosition = originalGetVirtualOpenPosition;
}
console.log('[order-status-sync-regressions] OK: stale partial fill, ghost EXIT resolution, and quarantine paths validated');
}
await run();