/** * Reporter module — pushes aggregated state to the master dashboard. * * Every 30 seconds, collects firm-level stats + scheduler state and sends them * to the configured master_dashboard_url. Silently skips if not configured. * * Two transports, in that order of preference: the control WebSocket in * `master-link.ts` when it is up, and the original POST /api/report when it is * not. The socket already exists for commands, so reusing it saves a request * per tick — but a master on an older build has no socket to offer, and the * POST keeps those working unchanged. */ import { getSetting } from './db'; import { pushReport } from './master-link'; import { getClients } from './clients'; import { getTradableFirms } from './firm-catalog'; import { getAccountConfig } from './account-type-resolver'; import { getSchedulerStatus } from './auto-trade'; import { combineCmeSessions, computeDayPlan, currentTradeDateCT, LEG_COMPLETION_EPSILON } from './trading-logic'; import type { AccountTypeStats } from './master-protocol'; function collectFirmStats() { // Only firms this machine has credentials for. A firm it cannot log into // has nothing to say about, and reporting it as zero accounts would fill // the master's table with rows for firms it does not run. const firms = getTradableFirms(); const clients = getClients(); return firms.map((f) => { const client = clients.get(f.id); if (!client || client.accountList.length === 0) { return { firm: f.name, totalAccounts: 0, accountsTraded: 0, inTrade: 0, targetHit: 0, partial: 0, loss: 0, accountTypes: [], }; } let totalAccounts = 0; let accountsTraded = 0; let inTrade = 0; let targetHitCount = 0; let partialCount = 0; let lossCount = 0; // Per account type, so the master can say what the accounts still to // trade actually are. Keyed by account type id; unassigned accounts // share the null bucket — they inflate "left to trade" indefinitely, // which is precisely the thing worth being able to see. const byType = new Map(); const bucket = (cfg: { id: string; label: string; accountSize: number } | null): AccountTypeStats => { const key = cfg?.id ?? null; let entry = byType.get(key); if (!entry) { entry = { accountTypeId: key, label: cfg?.label ?? 'Unassigned', accountSize: cfg?.accountSize ?? 0, totalAccounts: 0, accountsTraded: 0, inTrade: 0, targetHit: 0, partial: 0, loss: 0, }; byType.set(key, entry); } return entry; }; for (const acc of client.accountList) { const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 }; const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0; const isDead = autoLiqThreshold > 0 && cash.amount <= autoLiqThreshold; if (isDead) continue; const cfg = getAccountConfig(acc.id).accountType; const slice = bucket(cfg); totalAccounts++; slice.totalAccounts++; // Same combined-day view as the trade path: for a combining firm, // "today" spans the holiday stretch. Computed for every live // account — including ones mid-trade — so the days-traded // histogram below covers the whole slice. Accounts in the red or // DLL-locked stay OUT of the histogram: the payout calendar // projects from "on the winning path", which they are not. // (Dead accounts never reach here — the auto-liq check above.) const combined = cfg ? combineCmeSessions( cfg.combineCmeHolidays, client.dailyPnL[acc.id] ?? [], currentTradeDateCT(), cash.realizedPnL, ) : null; if (cfg && combined && acc.active && cash.amount - cfg.accountSize >= 0) { const hist = (slice.daysHist ??= {}); hist[combined.daysTraded] = (hist[combined.daysTraded] ?? 0) + 1; } if (client.positions[acc.id]) { inTrade++; accountsTraded++; slice.inTrade++; slice.accountsTraded++; continue; } // DLL lockout: Tradovate flips the account to liquidate-only, so // its day is over at a loss even when the booked realizedPnL // hasn't caught up yet (or the account has no matching config). if (!acc.active) { accountsTraded++; slice.accountsTraded++; lossCount++; slice.loss = (slice.loss ?? 0) + 1; continue; } // Check if target was hit today if (cfg && combined) { const equityProfit = cash.amount - cfg.accountSize; const plan = computeDayPlan( acc.id, combined.tradeDate, cfg.profitTarget, cfg.consistency, combined.dailyPnL, cfg.minDayPnL, cfg.minTradingDays, equityProfit, combined.realizedPnL, cfg.splitEnabled ); const targetHit = !plan ? false : ((plan.dayTarget === 0 && Math.abs(combined.realizedPnL) > 0 && combined.daysTraded <= cfg.minTradingDays) || (combined.realizedPnL > 0 && plan.remainingToday <= LEG_COMPLETION_EPSILON)); if (targetHit || combined.realizedPnL !== 0) { accountsTraded++; slice.accountsTraded++; // A grind-day fill (dayTarget 0) can flag targetHit on a // negative day — a loss is still a loss, so red wins. if (combined.realizedPnL < 0) { lossCount++; slice.loss = (slice.loss ?? 0) + 1; } else if (targetHit) { targetHitCount++; slice.targetHit = (slice.targetHit ?? 0) + 1; } else { partialCount++; slice.partial = (slice.partial ?? 0) + 1; } } } } // Biggest shortfall first — the account types with the most left to do // are the ones worth reading, and Unassigned sinks to the bottom only // when it is genuinely small. const accountTypes = [...byType.values()].sort((a, b) => (b.totalAccounts - b.accountsTraded) - (a.totalAccounts - a.accountsTraded) || a.label.localeCompare(b.label)); return { firm: f.name, totalAccounts, accountsTraded, inTrade, targetHit: targetHitCount, partial: partialCount, loss: lossCount, accountTypes, }; }); } // HMR-safe global to avoid duplicate intervals const _g = globalThis as typeof globalThis & { __reporterInterval?: ReturnType }; /** * Collect and send one report. Exported so the control link can fire an * off-cycle one the moment a command has changed something — waiting up to 30s * to see whether your own button worked is the wrong feedback loop. */ export async function sendReport(): Promise { const url = getSetting('master_dashboard_url'); const instanceName = getSetting('instance_name'); if (!url || !instanceName) return; try { const firms = collectFirmStats(); const scheduler = getSchedulerStatus(); if (pushReport(firms, scheduler)) return; await fetch(new URL('/api/report', url), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ instance: instanceName, // Same identity the socket hello carries, so a rename resolves // to the same person whichever transport the report takes. id: getSetting('instance_id') || undefined, firms, scheduler, }), }); } catch { // Master might be down — silently continue } } export function startReporter() { if (_g.__reporterInterval) { clearInterval(_g.__reporterInterval); } _g.__reporterInterval = setInterval(sendReport, 30_000); // Fire first report after a short delay to let clients sync setTimeout(sendReport, 10_000); console.log('[reporter] started — reporting every 30s'); }