Files
autofirmer-expanded/lib/reporter.ts
T
Brandon LiandClaude Fable 5 abfb703f0e Keep negative and DLL-locked accounts out of the payout histogram
The payout calendar projects accounts still on the winning path, so the
daysHist now skips accounts below their starting balance or flipped to
liquidate-only, alongside the dead ones the report already drops. They
still count in the slice totals — only the calendar stops seeing them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 16:28:37 -05:00

211 lines
8.7 KiB
TypeScript

/**
* 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<string | null, AccountTypeStats>();
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<typeof setInterval> };
/**
* 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<void> {
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');
}