/** * 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, resolveEffectiveConfig } 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, personalDll: 0, accountTypes: [], }; } let totalAccounts = 0; let accountsTraded = 0; let inTrade = 0; let targetHitCount = 0; let partialCount = 0; let lossCount = 0; let personalDllCount = 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; // Stage-resolved config, shared by the payout state and the // day-outcome check below — a drain stage swaps in its own // min-day and drops the profit target. const fundTxns = client.fundTransactions?.[acc.id] ?? []; const effective = cfg ? resolveEffectiveConfig( cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, client.priorProfit?.[acc.id] ?? 0, fundTxns, cfg.minDayPnL, ) : null; if (cfg && combined && acc.active && cash.amount - cfg.accountSize >= 0) { const hist = (slice.daysHist ??= {}); hist[combined.daysTraded] = (hist[combined.daysTraded] ?? 0) + 1; } // Exact payout state for every live account — negative ones // included, since the master sims their real required profit. // client.dailyPnL is already the active-cycle slice, so a // withdrawal resets days/maxDay/sum here automatically. if (cfg && combined && effective) { const r2 = (n: number) => Math.round(n * 100) / 100; const equityProfit = r2(cash.amount - cfg.accountSize); const qualifying = effective.minDayPnL === 0 ? combined.dailyPnL : combined.dailyPnL.filter((d) => d.pnl >= effective.minDayPnL); const sum = r2(combined.dailyPnL.reduce((s, d) => s + d.pnl, 0)); // End-of-day equity-profit high-water mark for this cycle, // floored at the $0 start: walk the P&L from the cycle base. let run = r2(equityProfit - sum); let highWater = Math.max(0, run); for (const d of combined.dailyPnL) { run = r2(run + d.pnl); if (run > highWater) highWater = run; } const traded = combined.dailyPnL.filter((d) => d.pnl !== 0); (slice.payoutStates ??= []).push({ days: qualifying.length, profit: equityProfit, maxDay: qualifying.length > 0 ? Math.max(...qualifying.map((d) => d.pnl)) : 0, sum, high: highWater, withdrawals: fundTxns.filter((f) => f.amount < 0).length, target: effective.profitTarget, consistency: effective.consistency, minDays: effective.minTradingDays, lastDay: traded.length > 0 ? traded.reduce((a, b) => (a.date > b.date ? a : b)).date : undefined, }); } 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 — on the stage-resolved config, so // a later stage's (or drain's) day counts the way it was traded. if (cfg && combined && effective) { const equityProfit = cash.amount - cfg.accountSize; const plan = computeDayPlan( acc.id, combined.tradeDate, effective.profitTarget, effective.consistency, combined.dailyPnL, effective.minDayPnL, effective.minTradingDays, equityProfit, combined.realizedPnL, cfg.splitEnabled, effective.drain ); const targetHit = !plan ? false : ((plan.dayTarget === 0 && Math.abs(combined.realizedPnL) > 0 && combined.daysTraded <= effective.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. // A red day on a stop-enabled account is its own personal // DLL, booked on purpose — not a genuine loss. (A firm DLL // lockout took the !acc.active branch above and stays a // loss.) if ((cfg.firstDayStop || cfg.minDayStop) && combined.realizedPnL <= -25) { personalDllCount++; slice.personalDll = (slice.personalDll ?? 0) + 1; } else 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, personalDll: personalDllCount, 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'); }