import assert from 'node:assert/strict'; import { config } from '../src/config/index.js'; import { supabaseService } from '../src/services/SupabaseService.js'; import { ReconciliationExitBackfillService } from '../src/services/reconciliationExitBackfillService.js'; type MutableConfig = typeof config & Record; const mutableConfig = config as MutableConfig; const restoreConfig = (snapshot: Record) => { for (const [key, value] of Object.entries(snapshot)) { mutableConfig[key] = value; } }; const buildLifecycleEntry = (timestampMs: number) => ({ profile_id: 'profile-1', user_id: 'user-1', symbol: 'BTC/USDT', trade_id: 'TRD-profile-1-BTCUSDT-BUY-000001', side: 'BUY', action: 'ENTRY', qty: 1, quantity: 1, price: 65000, status: 'filled', timestamp: timestampMs, created_at: new Date(timestampMs).toISOString(), sub_tag: 'BL:PAPER:P9999999999:T999999999999:ENTRY' }); const createMockExecutor = (closedOrders: any[], exchangePosition: any = null) => ({ fetchExchangePosition: async () => exchangePosition, fetchExchangeClosedOrders: async () => closedOrders }) as any; const withPatchedSupabase = async ( lifecycleRows: any[], run: (calls: { auditRows: any[]; upsertRows: any[]; }) => Promise ) => { const auditRows: any[] = []; const upsertRows: any[] = []; const originalIsAuditAvailable = supabaseService.isReconciliationBackfillAuditAvailable.bind(supabaseService); const originalGetFilledLifecycleOrdersForProfile = supabaseService.getFilledLifecycleOrdersForProfile.bind(supabaseService); const originalGetOpenOrdersForProfile = supabaseService.getOpenOrdersForProfile.bind(supabaseService); const originalGetExistingOrderIds = supabaseService.getExistingOrderIds.bind(supabaseService); const originalInsertAuditRows = supabaseService.insertReconciliationBackfillAuditRows.bind(supabaseService); const originalUpsertBackfillOrders = supabaseService.upsertReconciliationBackfillOrders.bind(supabaseService); try { (supabaseService as any).isReconciliationBackfillAuditAvailable = async () => true; (supabaseService as any).getFilledLifecycleOrdersForProfile = async () => lifecycleRows; (supabaseService as any).getOpenOrdersForProfile = async () => []; (supabaseService as any).getExistingOrderIds = async () => new Set(); (supabaseService as any).insertReconciliationBackfillAuditRows = async (rows: any[]) => { auditRows.push(...(rows || [])); return true; }; (supabaseService as any).upsertReconciliationBackfillOrders = async (rows: any[]) => { upsertRows.push(...(rows || [])); return true; }; await run({ auditRows, upsertRows }); } finally { (supabaseService as any).isReconciliationBackfillAuditAvailable = originalIsAuditAvailable; (supabaseService as any).getFilledLifecycleOrdersForProfile = originalGetFilledLifecycleOrdersForProfile; (supabaseService as any).getOpenOrdersForProfile = originalGetOpenOrdersForProfile; (supabaseService as any).getExistingOrderIds = originalGetExistingOrderIds; (supabaseService as any).insertReconciliationBackfillAuditRows = originalInsertAuditRows; (supabaseService as any).upsertReconciliationBackfillOrders = originalUpsertBackfillOrders; } }; const testUnattributedEvidenceBlocked = async () => { const now = Date.now(); const service = new ReconciliationExitBackfillService(); const executor = createMockExecutor([ { id: 'ex-001', symbol: 'BTC/USD', side: 'sell', status: 'filled', filled_qty: 1, filled_avg_price: 64000, filled_at: new Date(now + 60_000).toISOString(), client_order_id: '8f42d6d5-c35a-4c3f-9f5e-b2e3fdcbe0f7', sub_tag: null } ]); await withPatchedSupabase([buildLifecycleEntry(now)], async ({ upsertRows }) => { const result = await service.runProfile({ profileId: 'profile-1', userId: 'user-1', executor }); assert.equal(result.attempted, true); assert.equal(result.proposedRows, 0, 'Unattributed fills must not be proposed for auto-backfill.'); assert.equal(result.insertedRows, 0); assert.equal(result.noGoTrades, 1); assert.equal(result.noGoReasonCounts.missing_fill_evidence_for_large_remainder, 1); assert.equal(result.noGoSamples.length, 1); assert.equal(result.noGoSamples[0].reason, 'missing_fill_evidence_for_large_remainder'); assert.equal(upsertRows.length, 0, 'No synthetic EXIT rows should be inserted for unattributed fills.'); }); }; const testAttributedClientOrderEvidenceAccepted = async () => { const now = Date.now(); const service = new ReconciliationExitBackfillService(); const tradeId = 'TRD-profile-1-BTCUSDT-BUY-000001'; const executor = createMockExecutor([ { id: 'ex-002', symbol: 'BTC/USD', side: 'sell', status: 'filled', filled_qty: 1, filled_avg_price: 64250, filled_at: new Date(now + 60_000).toISOString(), client_order_id: `bytelyst-profile-1-${tradeId}-exit`, sub_tag: null } ]); await withPatchedSupabase([buildLifecycleEntry(now)], async ({ upsertRows }) => { const result = await service.runProfile({ profileId: 'profile-1', userId: 'user-1', executor }); assert.equal(result.attempted, true); assert.equal(result.proposedRows, 1, 'Attributed client_order_id should produce one backfill candidate.'); assert.equal(result.noGoTrades, 0); assert.deepEqual(result.noGoReasonCounts, {}); assert.equal(result.noGoSamples.length, 0); assert.equal(upsertRows.length, 0, 'Dry-run mode must not write rows.'); }); }; const testTemporalGateRejectsStaleEvidence = async () => { const now = Date.now(); const service = new ReconciliationExitBackfillService(); const tradeId = 'TRD-profile-1-BTCUSDT-BUY-000001'; const executor = createMockExecutor([ { id: 'ex-003', symbol: 'BTC/USD', side: 'sell', status: 'filled', filled_qty: 1, filled_avg_price: 64100, filled_at: new Date(now - (10 * 60_000)).toISOString(), client_order_id: `bytelyst-profile-1-${tradeId}-exit`, sub_tag: null } ]); await withPatchedSupabase([buildLifecycleEntry(now)], async ({ upsertRows }) => { const result = await service.runProfile({ profileId: 'profile-1', userId: 'user-1', executor }); assert.equal(result.attempted, true); assert.equal(result.proposedRows, 0, 'Stale fills before lifecycle timestamp must not be auto-assigned.'); assert.equal(result.noGoTrades, 1); assert.equal(result.noGoReasonCounts.missing_fill_evidence_for_large_remainder, 1); assert.equal(result.noGoSamples.length, 1); assert.equal(result.noGoSamples[0].reason, 'missing_fill_evidence_for_large_remainder'); assert.equal(upsertRows.length, 0, 'Dry-run mode must not write rows.'); }); }; const testOpenExchangePositionIsAdvisoryNotNoGo = async () => { const now = Date.now(); const service = new ReconciliationExitBackfillService(); const executor = createMockExecutor([], { qty: 1 }); await withPatchedSupabase([buildLifecycleEntry(now)], async ({ auditRows, upsertRows }) => { const result = await service.runProfile({ profileId: 'profile-1', userId: 'user-1', executor }); assert.equal(result.attempted, true); assert.equal(result.proposedRows, 0, 'Open exchange positions must not auto-propose backfill rows.'); assert.equal(result.noGoTrades, 0, 'Open exchange positions should be advisory, not NO_GO.'); assert.deepEqual(result.noGoReasonCounts, {}, 'Advisory blockers must not populate NO_GO reason counts.'); assert.equal(result.noGoSamples.length, 0); assert.equal(upsertRows.length, 0, 'Dry-run mode must not write rows.'); assert.ok( auditRows.some((row) => row?.decision === 'SKIP_ACTIVE_POSITION'), 'Expected advisory audit entry for active exchange position blocker.' ); }); }; async function main() { const snapshot: Record = { ENABLE_RECON_EXIT_BACKFILL: mutableConfig.ENABLE_RECON_EXIT_BACKFILL, RECON_EXIT_BACKFILL_DRY_RUN: mutableConfig.RECON_EXIT_BACKFILL_DRY_RUN, RECON_EXIT_BACKFILL_REQUIRE_PAUSE: mutableConfig.RECON_EXIT_BACKFILL_REQUIRE_PAUSE, RECON_EXIT_BACKFILL_DUST_ABS_QTY: mutableConfig.RECON_EXIT_BACKFILL_DUST_ABS_QTY, RECON_EXIT_BACKFILL_DUST_REL_PCT: mutableConfig.RECON_EXIT_BACKFILL_DUST_REL_PCT, RECON_EXIT_BACKFILL_LOOKBACK_HOURS: mutableConfig.RECON_EXIT_BACKFILL_LOOKBACK_HOURS, RECON_EXIT_BACKFILL_REQUIRE_STRONG_ATTRIBUTION: mutableConfig.RECON_EXIT_BACKFILL_REQUIRE_STRONG_ATTRIBUTION, RECON_EXIT_BACKFILL_ALLOW_HEURISTIC_MATCH: mutableConfig.RECON_EXIT_BACKFILL_ALLOW_HEURISTIC_MATCH, RECON_EXIT_BACKFILL_FILL_AFTER_TRADE_GRACE_MINUTES: mutableConfig.RECON_EXIT_BACKFILL_FILL_AFTER_TRADE_GRACE_MINUTES, RECON_ORDER_COVERAGE_FETCH_LIMIT_PER_PAGE: mutableConfig.RECON_ORDER_COVERAGE_FETCH_LIMIT_PER_PAGE, RECON_ORDER_COVERAGE_MAX_FETCH_PAGES: mutableConfig.RECON_ORDER_COVERAGE_MAX_FETCH_PAGES, SYMBOLS: mutableConfig.SYMBOLS }; try { mutableConfig.ENABLE_RECON_EXIT_BACKFILL = true; mutableConfig.RECON_EXIT_BACKFILL_DRY_RUN = true; mutableConfig.RECON_EXIT_BACKFILL_REQUIRE_PAUSE = false; mutableConfig.RECON_EXIT_BACKFILL_DUST_ABS_QTY = 0.0001; mutableConfig.RECON_EXIT_BACKFILL_DUST_REL_PCT = 0; mutableConfig.RECON_EXIT_BACKFILL_LOOKBACK_HOURS = 72; mutableConfig.RECON_EXIT_BACKFILL_REQUIRE_STRONG_ATTRIBUTION = true; mutableConfig.RECON_EXIT_BACKFILL_ALLOW_HEURISTIC_MATCH = false; mutableConfig.RECON_EXIT_BACKFILL_FILL_AFTER_TRADE_GRACE_MINUTES = 1; mutableConfig.RECON_ORDER_COVERAGE_FETCH_LIMIT_PER_PAGE = 500; mutableConfig.RECON_ORDER_COVERAGE_MAX_FETCH_PAGES = 8; mutableConfig.SYMBOLS = ['BTC/USDT']; await testUnattributedEvidenceBlocked(); await testAttributedClientOrderEvidenceAccepted(); await testTemporalGateRejectsStaleEvidence(); await testOpenExchangePositionIsAdvisoryNotNoGo(); console.log('[reconciliation-exit-backfill-evidence-guard] OK: attribution and temporal evidence guards validated'); } finally { restoreConfig(snapshot); } } main().catch((error) => { console.error('[reconciliation-exit-backfill-evidence-guard] failed', error); process.exit(1); });