P1.1: Report each account's status to the master's ledger

Every FirmStats now carries statusVersion 1, the catalog firmId and, once
synced, syncedAt (from lastSyncAt), including the no-accounts branch, which
is now built by emptyFirmStats(). Each accounts[] entry adds status, reason,
balance, floor, accountTypeId, firstFundDate, positiveFunds and brokerStatus,
each left out when null or at its default.

accountStatus() in platform-client.ts is pure: a missing balance is unknown
(cash_missing), never dead; with a balance, dead is exactly the old floor
test (legacyDead); then history_pending, locked, live. Only the reporter
uses it. The aggregates, totals and every trading decision keep their own
dead test, unchanged.

The reporter reads history readiness through a new Tradovate
peekHistoryReady(), because historyReady() there can start a hold and move
the balance baseline. DXTrade exposes accountStatus and registrationTime via
brokerAccountMeta(). A throw in the new fields reports the account as
unknown instead of losing the report.

sendReport skips the socket for a firms payload over 900,000 chars (the
master's frame cap is 1,000,000 bytes) and POSTs it, logging once per switch.

scripts/check-account-status.mjs proves the legacy invariant over the full
matrix and the size budget (worst 228 B added per account, 154 B typical
live; 2000 accounts 592,501 B).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-10-06 14:36:55 -05:00
co-authored by Claude Opus 5.5
parent 8747dd6b08
commit 0c3a36845a
7 changed files with 527 additions and 18 deletions
+51 -14
View File
@@ -24,7 +24,7 @@ import { botStopsActive, combineCmeSessions, computeDayPlan, currentTradeDateCT,
import type { AccountTypeStats, FirmStats, InstrumentStats, SymbolStats } from './master-protocol';
import { accountNextLeg, accountLegShape } from './leg-estimate';
import { instrumentStatsFor, isFuturesFirm } from './instruments';
import { historyFresh } from './platform-client';
import { accountStatusFields, brokerFacts, emptyFirmStats, firmStatusFields, historyFresh } from './platform-client';
function collectFirmStats() {
// Only firms this machine has credentials for. A firm it cannot log into
@@ -44,16 +44,8 @@ function collectFirmStats() {
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, completed: 0,
// Empty only once synced — before that, a firm whose accounts
// haven't arrived would read as every saved account erased.
...(client?.syncComplete ? { accounts: [] } : {}),
accountTypes: [],
};
}
// accounts: [] only once synced (see emptyFirmStats).
if (!client || client.accountList.length === 0) return emptyFirmStats(f, client);
let totalAccounts = 0;
let accountsTraded = 0;
@@ -94,7 +86,8 @@ function collectFirmStats() {
};
for (const acc of client.accountList) {
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
const reportedCash = client.accountCashBalances[acc.id];
const cash = reportedCash ?? { amount: 0, realizedPnL: 0 };
const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0;
const cfg = getAccountConfig(acc.id).accountType;
// undefined until the account's fund history has loaded. The master
@@ -109,6 +102,17 @@ function collectFirmStats() {
size: cfg?.accountSize ?? 0,
payoutable: cfg ? loops(cfg) : null,
...(loadedFundTxns ? { withdrawals: loadedFundTxns.filter((f) => f.amount < 0).length } : {}),
// For the master's account ledger. Data only: nothing here
// trades on it, and the totals below keep their own test.
...statusFieldsOrUnknown(() => accountStatusFields({
cash: reportedCash?.amount,
floor: autoLiqThreshold,
active: acc.active,
historyReady: client.peekHistoryReady ? client.peekHistoryReady(acc.id) : client.historyReady(acc.id),
accountTypeId: cfg?.id,
fundTransactions: loadedFundTxns,
...brokerFacts(f.platform, acc, client.brokerAccountMeta?.(acc.id)),
})),
});
// 0 = no floor; negative floors are reconstructed TopstepX MLLs.
const isDead = autoLiqThreshold !== 0 && cash.amount <= autoLiqThreshold;
@@ -394,10 +398,25 @@ function collectFirmStats() {
...symbolStatsFor(f, client, rawToday),
...instrumentStatsBlock(f, client, rawToday),
mix: tradeMixFor(f, client),
...firmStatusFields(f.id, client.syncComplete, client.lastSyncAt),
};
});
}
/** An account's status fields, or unknown if working them out throws — the
* ledger's data must never cost the rest of the report. Logged once. */
function statusFieldsOrUnknown(fields: () => ReturnType<typeof accountStatusFields>): ReturnType<typeof accountStatusFields> {
try {
return fields();
} catch (err) {
if (!_g.__accountStatusFailed) {
_g.__accountStatusFailed = true;
console.error('[reporter] account status failed — reporting it as unknown', err);
}
return { status: 'unknown', reason: 'status_error' };
}
}
/** The live accounts of a firm's largest prefix group first — the group a
* batch fires — each with an account type. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -478,7 +497,18 @@ function symbolStatsFor(
}
// HMR-safe global to avoid duplicate intervals
const _g = globalThis as typeof globalThis & { __reporterInterval?: ReturnType<typeof setInterval> };
const _g = globalThis as typeof globalThis & {
__reporterInterval?: ReturnType<typeof setInterval>;
/** The last report was too big for the socket (logged on each change). */
__reportOverSocketLimit?: boolean;
/** accountStatusFields has thrown once (logged the first time only). */
__accountStatusFailed?: boolean;
};
/** The master's socket closes on a frame over 1,000,000 bytes (maxPayload in
* its server.mjs), and the link would resend it every tick — a report this
* big goes by POST instead. */
const SOCKET_REPORT_MAX_CHARS = 900_000;
/**
* Collect and send one report. Exported so the control link can fire an
@@ -495,7 +525,14 @@ export async function sendReport(): Promise<void> {
const firms = collectFirmStats();
const scheduler = getSchedulerStatus();
if (pushReport(firms, scheduler)) return;
const overSocketLimit = JSON.stringify(firms).length > SOCKET_REPORT_MAX_CHARS;
if (overSocketLimit !== (_g.__reportOverSocketLimit ?? false)) {
_g.__reportOverSocketLimit = overSocketLimit;
console.warn(overSocketLimit
? `[reporter] report over ${SOCKET_REPORT_MAX_CHARS} chars — sending it by POST, not the socket`
: '[reporter] report fits the socket again');
}
if (!overSocketLimit && pushReport(firms, scheduler)) return;
await fetch(new URL('/api/report', url), {
method: 'POST',