import fs from 'node:fs'; import path from 'node:path'; type MonitorOptions = { hours: number; intervalSec: number; outFile: string; healthUrl: string; stateFile: string; }; type HealthSnapshot = { tradingLoopHealthy?: boolean; monitorLoopHealthy?: boolean; reconciliationLoopHealthy?: boolean; reconciliationMismatchCount?: number; reconciliationMissingFromExchange?: number; reconciliationMissingInDb?: number; reconciliationNoGoTrades?: number; reconciliationIntegrityWatchdogTriggered?: boolean; tradingControl?: { mode?: string; lastChangedBy?: string; lastChangedAt?: number; reason?: string; }; }; const toNumber = (value: unknown, fallback: number): number => { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; }; const parseArgs = (argv: string[]): MonitorOptions => { let hours = 12; let intervalSec = 60; let outFile = ''; let healthUrl = 'http://127.0.0.1:5000/internal/health'; let stateFile = path.resolve(process.cwd(), 'bot_state.json'); for (const arg of argv) { if (arg.startsWith('--hours=')) { hours = Math.max(1, Math.floor(toNumber(arg.slice('--hours='.length), 12))); continue; } if (arg.startsWith('--interval-sec=')) { intervalSec = Math.max(15, Math.floor(toNumber(arg.slice('--interval-sec='.length), 60))); continue; } if (arg.startsWith('--out=')) { outFile = String(arg.slice('--out='.length) || '').trim(); continue; } if (arg.startsWith('--health-url=')) { healthUrl = String(arg.slice('--health-url='.length) || '').trim() || healthUrl; continue; } if (arg.startsWith('--state-file=')) { const candidate = String(arg.slice('--state-file='.length) || '').trim(); if (candidate) stateFile = path.resolve(process.cwd(), candidate); } } if (!outFile) { const stamp = new Date().toISOString().replace(/[:]/g, '-'); outFile = path.resolve(process.cwd(), `logs/fresh-window-monitor-${stamp}.jsonl`); } else { outFile = path.resolve(process.cwd(), outFile); } return { hours, intervalSec, outFile, healthUrl, stateFile }; }; const wait = async (ms: number): Promise => { await new Promise((resolve) => setTimeout(resolve, ms)); }; const readStateEventSummary = (stateFile: string, fromTimestampMs: number): { operationalEventsCount: number; latestEventType: string; latestEventSeverity: string; latestEventMessage: string; latestEventAt: number; } => { try { if (!fs.existsSync(stateFile)) { return { operationalEventsCount: 0, latestEventType: '', latestEventSeverity: '', latestEventMessage: '', latestEventAt: 0 }; } const raw = fs.readFileSync(stateFile, 'utf8'); const parsed = JSON.parse(raw); const allEvents = Array.isArray(parsed?.operationalEvents) ? parsed.operationalEvents : []; const events = allEvents.filter((row: any) => { const ts = Number(row?.timestamp || 0); return ts >= fromTimestampMs; }); const latest = events.length > 0 ? events[events.length - 1] : null; return { operationalEventsCount: events.length, latestEventType: String(latest?.type || '').trim(), latestEventSeverity: String(latest?.severity || '').trim(), latestEventMessage: String(latest?.message || '').trim(), latestEventAt: Number(latest?.timestamp || 0) || 0 }; } catch { return { operationalEventsCount: 0, latestEventType: 'STATE_READ_ERROR', latestEventSeverity: 'WARN', latestEventMessage: 'Failed to parse bot_state.json', latestEventAt: Date.now() }; } }; const appendJsonLine = (file: string, row: Record): void => { const dir = path.dirname(file); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } fs.appendFileSync(file, `${JSON.stringify(row)}\n`, 'utf8'); }; const fetchHealth = async (healthUrl: string): Promise => { try { const response = await fetch(healthUrl, { method: 'GET' }); if (!response.ok) return null; const payload = await response.json(); return payload as HealthSnapshot; } catch { return null; } }; const run = async (): Promise => { const options = parseArgs(process.argv.slice(2)); const startedAtMs = Date.now(); const endAtMs = startedAtMs + (options.hours * 60 * 60 * 1000); appendJsonLine(options.outFile, { type: 'monitor_started', at: startedAtMs, iso: new Date(startedAtMs).toISOString(), options }); while (Date.now() < endAtMs) { const now = Date.now(); const health = await fetchHealth(options.healthUrl); const stateSummary = readStateEventSummary(options.stateFile, startedAtMs); const mode = String(health?.tradingControl?.mode || 'UNKNOWN').toUpperCase(); const mismatch = Number(health?.reconciliationMismatchCount || 0); const missingDb = Number(health?.reconciliationMissingInDb || 0); const noGo = Number(health?.reconciliationNoGoTrades || 0); const watchdog = Boolean(health?.reconciliationIntegrityWatchdogTriggered); const healthyLoops = Boolean(health?.tradingLoopHealthy) && Boolean(health?.monitorLoopHealthy) && Boolean(health?.reconciliationLoopHealthy); const severity = (mode !== 'RUNNING' || mismatch > 0 || missingDb > 0 || noGo > 0 || watchdog || !healthyLoops) ? 'WARN' : 'INFO'; appendJsonLine(options.outFile, { type: 'monitor_tick', severity, at: now, iso: new Date(now).toISOString(), mode, tradingLoopHealthy: Boolean(health?.tradingLoopHealthy), monitorLoopHealthy: Boolean(health?.monitorLoopHealthy), reconciliationLoopHealthy: Boolean(health?.reconciliationLoopHealthy), reconciliationMismatchCount: mismatch, reconciliationMissingFromExchange: Number(health?.reconciliationMissingFromExchange || 0), reconciliationMissingInDb: missingDb, reconciliationNoGoTrades: noGo, reconciliationIntegrityWatchdogTriggered: watchdog, tradingControlChangedBy: String(health?.tradingControl?.lastChangedBy || ''), tradingControlReason: String(health?.tradingControl?.reason || ''), operationalEventsCount: stateSummary.operationalEventsCount, latestEventType: stateSummary.latestEventType, latestEventSeverity: stateSummary.latestEventSeverity, latestEventMessage: stateSummary.latestEventMessage, latestEventAt: stateSummary.latestEventAt }); await wait(options.intervalSec * 1000); } const endedAtMs = Date.now(); appendJsonLine(options.outFile, { type: 'monitor_finished', at: endedAtMs, iso: new Date(endedAtMs).toISOString(), elapsedSec: Math.floor((endedAtMs - startedAtMs) / 1000) }); console.log(JSON.stringify({ success: true, outFile: options.outFile, startedAt: new Date(startedAtMs).toISOString(), endedAt: new Date(endedAtMs).toISOString() }, null, 2)); }; run().catch((error) => { console.error(JSON.stringify({ success: false, error: error instanceof Error ? error.message : String(error) }, null, 2)); process.exit(1); });