learning_ai_invt_trdg/backend/testReconciliationExitBackfillEvidenceGuard.ts

249 lines
11 KiB
TypeScript

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<string, any>;
const mutableConfig = config as MutableConfig;
const restoreConfig = (snapshot: Record<string, unknown>) => {
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<void>
) => {
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<string>();
(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<string, unknown> = {
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);
});