Files
autofirmer-expanded/lib/reporter.ts
T
Brandon LiandClaude Fable 5 a088381749 Store the person uuid the master issues, so renames keep our identity
The welcome now carries a personId; it lands in the instance_id setting
and rides on every hello and HTTP report from then on. The master keys
the person by that uuid, so changing instance_name moves our history to
the new name instead of starting a stranger. Read-only in Settings on
purpose — editing it by hand would detach this machine from its person.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 12:07:25 -05:00

161 lines
6.3 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 { 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, accountTypes: [] };
}
let totalAccounts = 0;
let accountsTraded = 0;
let inTrade = 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,
};
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++;
if (client.positions[acc.id]) {
inTrade++;
accountsTraded++;
slice.inTrade++;
slice.accountsTraded++;
continue;
}
// Check if target was hit today
if (cfg) {
const dailyPnL = client.dailyPnL[acc.id] ?? [];
const equityProfit = cash.amount - cfg.accountSize;
const plan = computeDayPlan(
acc.id, currentTradeDateCT(), cfg.profitTarget, cfg.consistency,
dailyPnL, cfg.minDayPnL, cfg.minTradingDays, equityProfit, cash.realizedPnL
);
const targetHit = !plan ? false :
((plan.dayTarget === 0 && Math.abs(cash.realizedPnL) > 0 && (client.daysTraded[acc.id] ?? 0) <= cfg.minTradingDays) ||
(cash.realizedPnL > 0 && plan.remainingToday <= LEG_COMPLETION_EPSILON));
if (targetHit || cash.realizedPnL !== 0) {
accountsTraded++;
slice.accountsTraded++;
}
}
}
// 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, 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');
}