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
+170 -1
View File
@@ -10,7 +10,7 @@
* safe.
*/
import type { FirmPlatform } from './master-protocol';
import type { FirmPlatform, FirmStats } from './master-protocol';
export interface PlatformAccount {
id: number;
@@ -100,6 +100,13 @@ export interface PlatformClient {
* read as a fresh stage-1 cycle, so such an account must not trade.
*/
historyReady(accountId: number): boolean;
/** historyReady() as it stands, for a reader that must not change it: no
* new hold, no baseline moved. Only where historyReady() has side effects
* (Tradovate's balance-drift check); elsewhere historyReady() is the read. */
peekHistoryReady?(accountId: number): boolean;
/** What the broker says about the account beyond active (DXTrade: its
* accountStatus and registration time, epoch ms or NaN). */
brokerAccountMeta?(accountId: number): { status: string; registrationTime: number } | undefined;
/**
* The account's closed balance at the firm's last daily reset — what a
* hard daily loss limit is measured from. Only platforms that know the
@@ -189,6 +196,168 @@ export function isClientReady(client: PlatformClient, now = Date.now()): boolean
return client.syncComplete && client.lastSyncAt > 0 && now - client.lastSyncAt < SYNC_STALE_MS;
}
// ── Account status, as the master's ledger reads it ──────────────────────────
//
// Pure, so scripts/check-account-status.mjs can load this file. The reporter
// is the only caller: the scheduler, the dashboards and the trading decisions
// keep their own copies of the dead test for now.
/** FirmStats.statusVersion: this build sends the status fields. The master
* reads a firm without it as an older build — unknown, never dead. */
export const REPORT_STATUS_VERSION = 1 as const;
export type ReportedAccount = NonNullable<FirmStats['accounts']>[number];
export type AccountReportStatus = NonNullable<ReportedAccount['status']>;
export interface AccountStatusInput {
/** The cash balance; null or undefined while the platform has none. */
cash: number | null | undefined;
/** Auto-liquidation floor; 0 = none known. */
floor: number;
active: boolean;
historyReady: boolean;
/** Why the platform has the account inactive, where it can say. */
inactiveReason?: string;
/** Why a dead account is dead, where the platform can say. */
deadReason?: string;
}
/** The dead test the reporter has always applied: a floor (0 = none, negative
* = a reconstructed TopstepX MLL) the cash is at or below. It read a missing
* balance as 0, so pass `cash ?? 0` for the old answer. */
export function legacyDead(cashOrZero: number, floor: number): boolean {
return floor !== 0 && cashOrZero <= floor;
}
/**
* The account's status, first match wins:
* 1. no balance → unknown (cash_missing) — never dead, never 0;
* 2. the legacy dead test → dead;
* 3. history not loaded → history_pending;
* 4. inactive → locked: the firm stopped it trading (DLL, liquidate-only,
* canTrade=false, CLOSE_ONLY — a pass lock looks the same);
* 5. live.
* So with a balance, 'dead' is exactly legacyDead(cash, floor).
*/
export function accountStatus(input: AccountStatusInput): { status: AccountReportStatus; reason?: string } {
const { cash, floor } = input;
if (cash === null || cash === undefined) return { status: 'unknown', reason: 'cash_missing' };
if (legacyDead(cash, floor)) return input.deadReason ? { status: 'dead', reason: input.deadReason } : { status: 'dead' };
if (!input.historyReady) return { status: 'history_pending' };
if (!input.active) return { status: 'locked', reason: input.inactiveReason || 'inactive' };
return { status: 'live' };
}
export interface BrokerFacts {
brokerStatus?: string;
inactiveReason?: string;
deadReason?: string;
registeredAt?: number;
}
/**
* The broker's own words for an account, per platform. Tradovate's list
* entries carry closed/archived/restricted, and an inactive one there is
* liquidate-only (a DLL lockout and a pass look alike). TopstepX's active is
* canTrade. DXTrade's accountStatus decides both — CLOSE_ONLY inactive, the
* terminal ones dead (dxtrade-class.ts DEAD_STATUSES) — and FULL_TRADING is
* the normal case, so it isn't repeated. Each field is left out when there is
* nothing to say.
*/
export function brokerFacts(
platform: FirmPlatform,
account: { active: boolean; closed?: unknown; archived?: unknown; restricted?: unknown },
meta?: { status: string; registrationTime: number },
): BrokerFacts {
if (platform === 'tradovate') {
const flags = (['closed', 'archived', 'restricted'] as const).filter((k) => account[k] === true);
return { inactiveReason: 'liquidate_only', ...(flags.length > 0 ? { brokerStatus: flags.join(',') } : {}) };
}
if (platform === 'topstepx') {
return { inactiveReason: 'cannot_trade', ...(account.active ? {} : { brokerStatus: 'canTrade:false' }) };
}
const status = meta?.status || undefined;
return {
inactiveReason: status === 'CLOSE_ONLY' ? 'close_only' : status ? `status:${status}` : 'inactive',
...(status ? { deadReason: `terminal:${status}` } : {}),
...(status && status !== 'FULL_TRADING' ? { brokerStatus: status } : {}),
...(meta && validTime(meta.registrationTime) ? { registeredAt: meta.registrationTime } : {}),
};
}
export interface AccountReportInput extends AccountStatusInput, BrokerFacts {
/** The account's resolved catalog type; null or undefined = unassigned. */
accountTypeId: string | null | undefined;
/** The account's fund rows; undefined until its fund history has loaded. */
fundTransactions: { date: string; amount: number }[] | undefined;
}
const CT_DATE = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Chicago', year: 'numeric', month: '2-digit', day: '2-digit' });
const cents = (n: number) => Math.round(n * 100) / 100;
/** Epoch ms a Date can hold — formatting anything else throws. */
const validTime = (ms: number | undefined): ms is number => ms !== undefined && Number.isFinite(ms) && Math.abs(ms) <= 8.64e15;
/**
* The status fields one accounts[] entry adds. Every one is left out when it
* is null or at its default — 2000 accounts must stay well under the master
* socket's 1 MB frame. firstFundDate is the earliest positive fund row (a
* DXTrade account with none falls back to its registration date, in Chicago);
* positiveFunds counts those rows, and both wait for the fund history.
*/
export function accountStatusFields(input: AccountReportInput): Omit<ReportedAccount, 'name' | 'size' | 'payoutable' | 'withdrawals'> {
const { status, reason } = accountStatus(input);
const funds = input.fundTransactions;
const positive = funds?.filter((f) => f.amount > 0);
let firstFundDate: string | undefined;
if (positive && positive.length > 0) {
firstFundDate = positive.reduce((min, f) => (f.date < min ? f.date : min), positive[0].date).slice(0, 10);
} else if (funds && validTime(input.registeredAt)) {
firstFundDate = CT_DATE.format(new Date(input.registeredAt));
}
return {
status,
...(reason ? { reason } : {}),
...(typeof input.cash === 'number' && Number.isFinite(input.cash) ? { balance: cents(input.cash) } : {}),
...(Number.isFinite(input.floor) && input.floor !== 0 ? { floor: cents(input.floor) } : {}),
...(input.accountTypeId ? { accountTypeId: input.accountTypeId } : {}),
...(firstFundDate ? { firstFundDate } : {}),
...(positive ? { positiveFunds: positive.length } : {}),
...(input.brokerStatus ? { brokerStatus: input.brokerStatus } : {}),
};
}
/** The firm-level keys every FirmStats from this build adds. syncedAt only
* once the client has synced: the master trusts the statuses while it is
* recent (SYNC_STALE_MS). */
export function firmStatusFields(firmId: string, syncComplete: boolean, lastSyncAt: number): Pick<FirmStats, 'statusVersion' | 'firmId' | 'syncedAt'> {
return {
statusVersion: REPORT_STATUS_VERSION,
firmId,
...(syncComplete && validTime(lastSyncAt) && lastSyncAt > 0 ? { syncedAt: new Date(lastSyncAt).toISOString() } : {}),
};
}
/**
* FirmStats for a firm with no accounts to walk — no client yet, or an empty
* broker list. accounts is [] only once synced: before that, a firm whose
* accounts haven't arrived would read as every saved account erased. A synced
* firm with none also says when it synced, so the master sees a fresh, empty
* list rather than an unknown one.
*/
export function emptyFirmStats(
firm: { id: string; name: string },
client: { syncComplete: boolean; lastSyncAt: number } | undefined,
): FirmStats {
const synced = client?.syncComplete === true;
return {
firm: firm.name, totalAccounts: 0, accountsTraded: 0, inTrade: 0,
targetHit: 0, partial: 0, loss: 0, personalDll: 0, completed: 0,
...(synced ? { accounts: [] } : {}),
accountTypes: [],
...firmStatusFields(firm.id, synced, client?.lastSyncAt ?? 0),
};
}
/**
* Thrown when a client KNOWS an order never reached the broker: no live
* connection, the broker refused it, or it failed before anything was sent.