265 lines
10 KiB
TypeScript
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();
|