249 lines
11 KiB
TypeScript
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);
|
|
});
|