298 lines
13 KiB
TypeScript
298 lines
13 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { config } from '../src/config/index.js';
|
|
import { healthTracker } from '../src/services/healthTracker.js';
|
|
import { supabaseService } from '../src/services/SupabaseService.js';
|
|
import {
|
|
ReconciliationParityHeartbeatService
|
|
} from '../src/services/reconciliationParityHeartbeatService.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 resetTradingControl = () => {
|
|
healthTracker.recordTradingControl({
|
|
mode: 'RUNNING',
|
|
lastChangedBy: 'test',
|
|
lastChangedAt: Date.now(),
|
|
reason: 'reset'
|
|
});
|
|
};
|
|
|
|
const createMockExecutor = () => {
|
|
const reconcileCalls: any[] = [];
|
|
return {
|
|
executor: {
|
|
fetchExchangePosition: async () => null,
|
|
reconcileExitFill: async (...args: any[]) => {
|
|
reconcileCalls.push(args);
|
|
}
|
|
} as any,
|
|
reconcileCalls
|
|
};
|
|
};
|
|
|
|
const withPatchedSupabase = async (run: (calls: {
|
|
logOrderCalls: any[];
|
|
existingOrderIds: Set<string>;
|
|
}) => Promise<void>) => {
|
|
const logOrderCalls: any[] = [];
|
|
const existingOrderIds = new Set<string>();
|
|
|
|
const originalGetProfileCapital = supabaseService.getProfileCapital.bind(supabaseService);
|
|
const originalGetVirtualOpenPosition = supabaseService.getVirtualOpenPosition.bind(supabaseService);
|
|
const originalGetVirtualOpenPositionForTrade = supabaseService.getVirtualOpenPositionForTrade.bind(supabaseService);
|
|
const originalHasLifecycleEntryOrder = supabaseService.hasLifecycleEntryOrder.bind(supabaseService);
|
|
const originalHasLifecycleEntryOrderWithProfileSubTag = supabaseService.hasLifecycleEntryOrderWithProfileSubTag.bind(supabaseService);
|
|
const originalGetExistingOrderIds = supabaseService.getExistingOrderIds.bind(supabaseService);
|
|
const originalLogOrder = supabaseService.logOrder.bind(supabaseService);
|
|
|
|
try {
|
|
(supabaseService as any).getProfileCapital = async () => ({
|
|
allocatedCapital: 1000,
|
|
isActive: true,
|
|
userId: 'user-1'
|
|
});
|
|
(supabaseService as any).getVirtualOpenPosition = async (_profileId: string, symbol: string) => {
|
|
if (symbol === 'BTC/USDT') {
|
|
return {
|
|
profileId: 'profile-1',
|
|
symbol,
|
|
side: 'BUY',
|
|
qty: 1,
|
|
entryPrice: 100,
|
|
stopLoss: 95,
|
|
takeProfit: 120,
|
|
userId: 'user-1',
|
|
tradeId: 'trade-1',
|
|
tradeIds: ['trade-1']
|
|
};
|
|
}
|
|
return null;
|
|
};
|
|
(supabaseService as any).getVirtualOpenPositionForTrade = async (_profileId: string, symbol: string, tradeId: string) => {
|
|
if (symbol === 'BTC/USDT' && tradeId === 'trade-1') {
|
|
return {
|
|
profileId: 'profile-1',
|
|
symbol,
|
|
side: 'BUY',
|
|
qty: 1,
|
|
entryPrice: 100,
|
|
stopLoss: 95,
|
|
takeProfit: 120,
|
|
userId: 'user-1',
|
|
tradeId,
|
|
tradeIds: [tradeId]
|
|
};
|
|
}
|
|
return null;
|
|
};
|
|
(supabaseService as any).hasLifecycleEntryOrder = async () => true;
|
|
(supabaseService as any).hasLifecycleEntryOrderWithProfileSubTag = async () => true;
|
|
(supabaseService as any).getExistingOrderIds = async (orderIds: string[]) => {
|
|
const out = new Set<string>();
|
|
for (const id of orderIds || []) {
|
|
if (existingOrderIds.has(String(id))) out.add(String(id));
|
|
}
|
|
return out;
|
|
};
|
|
(supabaseService as any).logOrder = async (payload: any) => {
|
|
logOrderCalls.push(payload);
|
|
existingOrderIds.add(String(payload?.order_id || ''));
|
|
};
|
|
|
|
await run({ logOrderCalls, existingOrderIds });
|
|
} finally {
|
|
(supabaseService as any).getProfileCapital = originalGetProfileCapital;
|
|
(supabaseService as any).getVirtualOpenPosition = originalGetVirtualOpenPosition;
|
|
(supabaseService as any).getVirtualOpenPositionForTrade = originalGetVirtualOpenPositionForTrade;
|
|
(supabaseService as any).hasLifecycleEntryOrder = originalHasLifecycleEntryOrder;
|
|
(supabaseService as any).hasLifecycleEntryOrderWithProfileSubTag = originalHasLifecycleEntryOrderWithProfileSubTag;
|
|
(supabaseService as any).getExistingOrderIds = originalGetExistingOrderIds;
|
|
(supabaseService as any).logOrder = originalLogOrder;
|
|
}
|
|
};
|
|
|
|
const testStreakAndIdempotency = async () => {
|
|
const service = new ReconciliationParityHeartbeatService();
|
|
const { executor, reconcileCalls } = createMockExecutor();
|
|
|
|
await withPatchedSupabase(async ({ logOrderCalls }) => {
|
|
const r1 = await service.runProfile({
|
|
profileId: 'profile-1',
|
|
userId: 'user-1',
|
|
executor,
|
|
monitoredSymbols: ['BTC/USDT']
|
|
});
|
|
const r2 = await service.runProfile({
|
|
profileId: 'profile-1',
|
|
userId: 'user-1',
|
|
executor,
|
|
monitoredSymbols: ['BTC/USDT']
|
|
});
|
|
const r3 = await service.runProfile({
|
|
profileId: 'profile-1',
|
|
userId: 'user-1',
|
|
executor,
|
|
monitoredSymbols: ['BTC/USDT']
|
|
});
|
|
const r4 = await service.runProfile({
|
|
profileId: 'profile-1',
|
|
userId: 'user-1',
|
|
executor,
|
|
monitoredSymbols: ['BTC/USDT']
|
|
});
|
|
|
|
assert.equal(r1.autoClosedTrades, 0);
|
|
assert.equal(r2.autoClosedTrades, 0);
|
|
assert.equal(r3.autoClosedTrades, 1, 'Third parity confirmation should auto-close once.');
|
|
assert.equal(r4.autoClosedTrades, 0, 'No duplicate close expected after idempotent synthetic row exists.');
|
|
assert.equal(r3.totalMismatchNotionalUsd, 100, 'Mismatch notional should capture the open trade exposure.');
|
|
assert.equal(logOrderCalls.length, 1, 'Synthetic close must be logged once.');
|
|
assert.equal(reconcileCalls.length, 1, 'Reconcile exit fill should run once.');
|
|
});
|
|
};
|
|
|
|
const testSubTagQuarantine = async () => {
|
|
const service = new ReconciliationParityHeartbeatService();
|
|
const { executor, reconcileCalls } = createMockExecutor();
|
|
|
|
await withPatchedSupabase(async ({ logOrderCalls }) => {
|
|
(supabaseService as any).hasLifecycleEntryOrderWithProfileSubTag = async () => false;
|
|
(supabaseService as any).hasLifecycleEntryOrder = async () => false;
|
|
const result = await service.runProfile({
|
|
profileId: 'profile-1',
|
|
userId: 'user-1',
|
|
executor,
|
|
monitoredSymbols: ['BTC/USDT']
|
|
});
|
|
assert.equal(result.autoClosedTrades, 0, 'Unattributed trades must never auto-close.');
|
|
assert.equal(result.quarantinedTrades, 1, 'Unattributed trades should be quarantined.');
|
|
assert.equal(result.totalMismatchNotionalUsd, 0, 'Unattributed quarantined trades should not contribute to watchdog mismatch notional.');
|
|
assert.equal(logOrderCalls.length, 0, 'No synthetic close rows should be logged.');
|
|
assert.equal(reconcileCalls.length, 0, 'No exit fill should be applied for quarantined trades.');
|
|
});
|
|
};
|
|
|
|
const testLegacyAttributionFallback = async () => {
|
|
const service = new ReconciliationParityHeartbeatService();
|
|
const { executor, reconcileCalls } = createMockExecutor();
|
|
|
|
await withPatchedSupabase(async ({ logOrderCalls }) => {
|
|
(supabaseService as any).hasLifecycleEntryOrderWithProfileSubTag = async () => false;
|
|
(supabaseService as any).hasLifecycleEntryOrder = async () => true;
|
|
const result = await service.runProfile({
|
|
profileId: 'profile-1',
|
|
userId: 'user-1',
|
|
executor,
|
|
monitoredSymbols: ['BTC/USDT']
|
|
});
|
|
assert.equal(result.quarantinedTrades, 0, 'Legacy fallback should keep attributable trades actionable.');
|
|
assert.equal(result.totalMismatchNotionalUsd, 100, 'Legacy-attributed trades should still count for mismatch notional.');
|
|
assert.equal(result.autoClosedTrades, 0, 'Single confirmation should not auto-close immediately.');
|
|
assert.equal(logOrderCalls.length, 0, 'No synthetic close should be logged on first confirmation.');
|
|
assert.equal(reconcileCalls.length, 0, 'No exit fill should be applied on first confirmation.');
|
|
});
|
|
};
|
|
|
|
const testWatchdogPause = async () => {
|
|
const service = new ReconciliationParityHeartbeatService();
|
|
const { executor } = createMockExecutor();
|
|
resetTradingControl();
|
|
|
|
await withPatchedSupabase(async () => {
|
|
(supabaseService as any).getProfileCapital = async () => ({
|
|
allocatedCapital: 1000,
|
|
isActive: true,
|
|
userId: 'user-1'
|
|
});
|
|
(supabaseService as any).getVirtualOpenPosition = async (_profileId: string, symbol: string) => {
|
|
if (symbol !== 'BTC/USDT') return null;
|
|
return {
|
|
profileId: 'profile-1',
|
|
symbol,
|
|
side: 'BUY',
|
|
qty: 10,
|
|
entryPrice: 100,
|
|
stopLoss: 95,
|
|
takeProfit: 120,
|
|
userId: 'user-1',
|
|
tradeId: 'trade-watchdog',
|
|
tradeIds: ['trade-watchdog']
|
|
};
|
|
};
|
|
(supabaseService as any).getVirtualOpenPositionForTrade = async (_profileId: string, symbol: string, tradeId: string) => ({
|
|
profileId: 'profile-1',
|
|
symbol,
|
|
side: 'BUY',
|
|
qty: 10,
|
|
entryPrice: 100,
|
|
stopLoss: 95,
|
|
takeProfit: 120,
|
|
userId: 'user-1',
|
|
tradeId,
|
|
tradeIds: [tradeId]
|
|
});
|
|
|
|
const result = await service.runProfile({
|
|
profileId: 'profile-1',
|
|
userId: 'user-1',
|
|
executor,
|
|
monitoredSymbols: ['BTC/USDT']
|
|
});
|
|
|
|
assert.equal(result.integrityWatchdogTriggered, true, 'Watchdog must trigger for large mismatch notional.');
|
|
assert.equal(result.totalMismatchNotionalUsd, 1000, 'Watchdog test should report cumulative mismatch notional.');
|
|
assert.equal(healthTracker.isPaused(), true, 'Trading should be paused by watchdog for high-risk mismatch.');
|
|
});
|
|
};
|
|
|
|
async function main() {
|
|
const configSnapshot: Record<string, unknown> = {
|
|
ENABLE_RECON_POSITION_PARITY_HEARTBEAT: mutableConfig.ENABLE_RECON_POSITION_PARITY_HEARTBEAT,
|
|
RECON_POSITION_PARITY_DRY_RUN: mutableConfig.RECON_POSITION_PARITY_DRY_RUN,
|
|
RECON_POSITION_PARITY_CONFIRMATIONS: mutableConfig.RECON_POSITION_PARITY_CONFIRMATIONS,
|
|
RECON_POSITION_PARITY_DUST_ABS_QTY: mutableConfig.RECON_POSITION_PARITY_DUST_ABS_QTY,
|
|
RECON_POSITION_PARITY_MAX_NOTIONAL_PCT: mutableConfig.RECON_POSITION_PARITY_MAX_NOTIONAL_PCT,
|
|
RECON_POSITION_PARITY_REQUIRE_SUBTAG_ATTRIBUTION: mutableConfig.RECON_POSITION_PARITY_REQUIRE_SUBTAG_ATTRIBUTION,
|
|
RECON_POSITION_PARITY_ALLOW_LEGACY_ENTRY_ATTRIBUTION: mutableConfig.RECON_POSITION_PARITY_ALLOW_LEGACY_ENTRY_ATTRIBUTION,
|
|
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,
|
|
ENABLE_RECON_INTEGRITY_WATCHDOG: mutableConfig.ENABLE_RECON_INTEGRITY_WATCHDOG,
|
|
RECON_INTEGRITY_WATCHDOG_THROTTLE_MS: mutableConfig.RECON_INTEGRITY_WATCHDOG_THROTTLE_MS
|
|
};
|
|
|
|
try {
|
|
mutableConfig.ENABLE_RECON_POSITION_PARITY_HEARTBEAT = true;
|
|
mutableConfig.RECON_POSITION_PARITY_DRY_RUN = false;
|
|
mutableConfig.RECON_POSITION_PARITY_CONFIRMATIONS = 3;
|
|
mutableConfig.RECON_POSITION_PARITY_DUST_ABS_QTY = 0.0001;
|
|
mutableConfig.RECON_POSITION_PARITY_MAX_NOTIONAL_PCT = 0.5;
|
|
mutableConfig.RECON_POSITION_PARITY_REQUIRE_SUBTAG_ATTRIBUTION = true;
|
|
mutableConfig.RECON_POSITION_PARITY_ALLOW_LEGACY_ENTRY_ATTRIBUTION = true;
|
|
mutableConfig.RECON_EXIT_BACKFILL_DUST_ABS_QTY = 0.0001;
|
|
mutableConfig.RECON_EXIT_BACKFILL_DUST_REL_PCT = 0;
|
|
mutableConfig.ENABLE_RECON_INTEGRITY_WATCHDOG = true;
|
|
mutableConfig.RECON_INTEGRITY_WATCHDOG_THROTTLE_MS = 0;
|
|
|
|
await testStreakAndIdempotency();
|
|
await testSubTagQuarantine();
|
|
await testLegacyAttributionFallback();
|
|
await testWatchdogPause();
|
|
console.log('[reconciliation-parity-heartbeat] OK: streak, idempotency, quarantine, and watchdog behaviors validated');
|
|
} finally {
|
|
restoreConfig(configSnapshot);
|
|
resetTradingControl();
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error('[reconciliation-parity-heartbeat] failed', error);
|
|
process.exit(1);
|
|
});
|