186 lines
7.4 KiB
TypeScript
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();
|