learning_ai_invt_trdg/backend/src/services/healthTracker.ts

186 lines
7.4 KiB
TypeScript

import { config } from '../config/index.js';
export interface TradingControlSnapshot {
mode: 'RUNNING' | 'PAUSED';
lastChangedBy: string;
lastChangedAt: number;
reason?: string;
}
export interface ReconciliationNoGoSample {
profileId: string;
symbol: string;
tradeId: string;
reason: string;
}
export interface HealthSnapshot {
tradingLoopHealthy: boolean;
tradingLoopLastRun: number | null;
monitorLoopHealthy: boolean;
monitorLoopLastRun: number | null;
orderSyncHealthy: boolean;
orderSyncLastRun: number | null;
lockContentionCount: number;
reconciliationLoopHealthy: boolean;
reconciliationLoopLastRun: number | null;
reconciliationMismatchCount: number;
reconciliationMissingFromExchange: number;
reconciliationMissingInDb: number;
reconciliationNoGoTrades: number;
reconciliationNoGoReasonCounts: Record<string, number>;
reconciliationNoGoSamples: ReconciliationNoGoSample[];
reconciliationIntegrityWatchdogTriggered: boolean;
reconciliationLockContentionCount: number;
tradingControl: TradingControlSnapshot;
}
export class HealthTracker {
private tradingLoopLastRun: number | null = null;
private tradingLoopLastSuccess = true;
private monitorLoopLastRun: number | null = null;
private orderSyncLastRun: number | null = null;
private lockContention = 0;
private reconciliationLockContention = 0;
private reconciliationLastRun: number | null = null;
private reconciliationLastSuccess = true;
private reconciliationMismatchCount = 0;
private reconciliationMissingFromExchange = 0;
private reconciliationMissingInDb = 0;
private reconciliationNoGoTrades = 0;
private reconciliationNoGoReasonCounts: Record<string, number> = {};
private reconciliationNoGoSamples: ReconciliationNoGoSample[] = [];
private reconciliationIntegrityWatchdogTriggered = false;
private tradingControlListeners = new Set<(update: TradingControlSnapshot) => void>();
private tradingControl: TradingControlSnapshot = {
mode: 'RUNNING',
lastChangedBy: 'system',
lastChangedAt: Date.now()
};
public recordTradingLoop(healthy: boolean) {
this.tradingLoopLastRun = Date.now();
this.tradingLoopLastSuccess = healthy;
}
public recordMonitorLoop() {
this.monitorLoopLastRun = Date.now();
}
public recordOrderSyncLoop() {
this.orderSyncLastRun = Date.now();
}
public incrementLockContention() {
this.lockContention += 1;
}
public incrementReconciliationLockContention() {
this.reconciliationLockContention += 1;
}
public recordTradingControl(update: TradingControlSnapshot) {
this.tradingControl = update;
for (const listener of this.tradingControlListeners) {
try {
listener(update);
} catch {
// Observers are best-effort. Health state remains authoritative.
}
}
}
public subscribeTradingControl(listener: (update: TradingControlSnapshot) => void): () => void {
this.tradingControlListeners.add(listener);
return () => {
this.tradingControlListeners.delete(listener);
};
}
public isPaused(): boolean {
return this.tradingControl.mode === 'PAUSED';
}
public isTradingPaused(): boolean {
return this.isPaused();
}
private isFresh(lastRun: number | null, intervalMs: number): boolean {
if (!lastRun) return false;
const now = Date.now();
const threshold = Math.max(intervalMs * 2, 120_000);
return (now - lastRun) <= threshold;
}
public getSnapshot(): HealthSnapshot {
return {
tradingLoopHealthy: this.tradingLoopLastSuccess && this.isFresh(this.tradingLoopLastRun, Math.max(config.POLLING_INTERVAL, 1)),
tradingLoopLastRun: this.tradingLoopLastRun,
monitorLoopHealthy: this.isFresh(this.monitorLoopLastRun, Math.max(config.MONITOR_INTERVAL_MS, 1)),
monitorLoopLastRun: this.monitorLoopLastRun,
orderSyncHealthy: this.isFresh(this.orderSyncLastRun, Math.max(config.ORDER_SYNC_INTERVAL_MS, 1)),
orderSyncLastRun: this.orderSyncLastRun,
lockContentionCount: this.lockContention,
reconciliationLoopHealthy: this.reconciliationLastSuccess && this.isFresh(this.reconciliationLastRun, Math.max(config.MONITOR_INTERVAL_MS, 1)),
reconciliationLoopLastRun: this.reconciliationLastRun,
reconciliationMismatchCount: this.reconciliationMismatchCount,
reconciliationMissingFromExchange: this.reconciliationMissingFromExchange,
reconciliationMissingInDb: this.reconciliationMissingInDb,
reconciliationNoGoTrades: this.reconciliationNoGoTrades,
reconciliationNoGoReasonCounts: this.reconciliationNoGoReasonCounts,
reconciliationNoGoSamples: this.reconciliationNoGoSamples,
reconciliationIntegrityWatchdogTriggered: this.reconciliationIntegrityWatchdogTriggered,
reconciliationLockContentionCount: this.reconciliationLockContention,
tradingControl: this.tradingControl
};
}
public recordReconciliationLoop(success: boolean, metrics?: {
mismatchCount?: number;
missingFromExchange?: number;
missingInDb?: number;
noGoTrades?: number;
noGoReasonCounts?: Record<string, number>;
noGoSamples?: ReconciliationNoGoSample[];
integrityWatchdogTriggered?: boolean;
}) {
this.reconciliationLastRun = Date.now();
this.reconciliationLastSuccess = success;
if (metrics?.mismatchCount !== undefined) {
this.reconciliationMismatchCount = metrics.mismatchCount;
}
if (metrics?.missingFromExchange !== undefined) {
this.reconciliationMissingFromExchange = metrics.missingFromExchange;
}
if (metrics?.missingInDb !== undefined) {
this.reconciliationMissingInDb = metrics.missingInDb;
}
if (metrics?.noGoTrades !== undefined) {
this.reconciliationNoGoTrades = metrics.noGoTrades;
}
if (metrics?.noGoReasonCounts !== undefined) {
this.reconciliationNoGoReasonCounts = { ...(metrics.noGoReasonCounts || {}) };
} else if (this.reconciliationNoGoTrades === 0) {
this.reconciliationNoGoReasonCounts = {};
}
if (metrics?.noGoSamples !== undefined) {
this.reconciliationNoGoSamples = (metrics.noGoSamples || []).slice(0, 10).map((sample) => ({
profileId: String(sample?.profileId || '').trim(),
symbol: String(sample?.symbol || '').trim(),
tradeId: String(sample?.tradeId || '').trim(),
reason: String(sample?.reason || '').trim() || 'unknown'
}));
} else if (this.reconciliationNoGoTrades === 0) {
this.reconciliationNoGoSamples = [];
}
if (metrics?.integrityWatchdogTriggered !== undefined) {
this.reconciliationIntegrityWatchdogTriggered = metrics.integrityWatchdogTriggered;
} else if (this.reconciliationMismatchCount === 0 && this.reconciliationMissingInDb === 0 && this.reconciliationNoGoTrades === 0) {
this.reconciliationIntegrityWatchdogTriggered = false;
}
}
}
export const healthTracker = new HealthTracker();