From 0c3a36845a7fd004b85256d1b6c97719fe0dcc29 Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Tue, 6 Oct 2026 14:36:55 -0500 Subject: [PATCH] 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 --- lib/dxtrade-class.ts | 6 + lib/master-protocol.ts | 37 ++++- lib/platform-client.ts | 171 ++++++++++++++++++++- lib/reporter.ts | 65 ++++++-- lib/tradovate-class.ts | 9 ++ package.json | 2 +- scripts/check-account-status.mjs | 255 +++++++++++++++++++++++++++++++ 7 files changed, 527 insertions(+), 18 deletions(-) create mode 100644 scripts/check-account-status.mjs diff --git a/lib/dxtrade-class.ts b/lib/dxtrade-class.ts index 2feb741..ec32bee 100644 --- a/lib/dxtrade-class.ts +++ b/lib/dxtrade-class.ts @@ -1018,6 +1018,12 @@ export class DXTradeClient implements PlatformClient { && this.fundTransactions[accountId] !== undefined; } + /** The account's accountStatus and registration time, for the report. */ + public brokerAccountMeta(accountId: number): { status: string; registrationTime: number } | undefined { + const meta = this.accountMeta.get(accountId); + return meta ? { status: meta.status, registrationTime: meta.registrationTime } : undefined; + } + public forgetHistory(accountId: number, accountName: string): void { this.historyLoadedIds.delete(accountId); this.booksHeld.delete(accountId); diff --git a/lib/master-protocol.ts b/lib/master-protocol.ts index 4fa15e1..9226858 100644 --- a/lib/master-protocol.ts +++ b/lib/master-protocol.ts @@ -107,8 +107,41 @@ export interface FirmStats { * withdrawals = how many times the balance has gone down for a payout * (negative fund transactions), which tells the master a completed * cycle's money is out. Omitted while the account's fund history hasn't - * loaded, so a restart can't read as zero withdrawals. */ - accounts?: { name: string; size: number; payoutable: boolean | null; withdrawals?: number }[]; + * loaded, so a restart can't read as zero withdrawals. + * + * The rest come from statusVersion builds (platform-client.ts + * accountStatus), each left out when null or at its default. status: + * unknown when the balance is missing (reason cash_missing — never dead), + * dead on the floor test, history_pending, locked when the firm stopped + * it trading (reason says how), else live. balance and floor (0 = none) + * in dollars; accountTypeId = the resolved catalog type (absent = + * unassigned); firstFundDate = the earliest positive fund row, YYYY-MM-DD + * (DXtrade: the registration date when there is none); positiveFunds + * counts those rows, absent until the fund history loads; brokerStatus = + * the broker's own flags (Tradovate closed/archived/restricted, DXtrade + * accountStatus, TopstepX canTrade:false). */ + accounts?: { + name: string; size: number; payoutable: boolean | null; withdrawals?: number; + status?: 'live' | 'locked' | 'dead' | 'history_pending' | 'unknown'; + reason?: string; + balance?: number; + floor?: number; + accountTypeId?: string; + firstFundDate?: string; + positiveFunds?: number; + brokerStatus?: string; + }[]; + /** 1 on every firm from a build that sends the account status fields, + * including one with no accounts. Absent = an older build: its accounts + * are unknown, never dead. An empty accounts[] from a statusVersion build + * is a synced firm with no accounts. Optional. */ + statusVersion?: 1; + /** The catalog firm's uuid (statusVersion builds), so the master needn't + * resolve the name. Optional. */ + firmId?: string; + /** ISO time the firm's last good sync finished (statusVersion builds), sent + * once synced. The statuses count only while it is recent. Optional. */ + syncedAt?: string; /** Per account type, summing to the firm's own totals. Optional so a master * on an older build simply ignores it. */ accountTypes?: AccountTypeStats[]; diff --git a/lib/platform-client.ts b/lib/platform-client.ts index 64f496f..778b94e 100644 --- a/lib/platform-client.ts +++ b/lib/platform-client.ts @@ -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[number]; +export type AccountReportStatus = NonNullable; + +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 { + 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 { + 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. diff --git a/lib/reporter.ts b/lib/reporter.ts index 7263df6..fa555fb 100644 --- a/lib/reporter.ts +++ b/lib/reporter.ts @@ -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): ReturnType { + 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 }; +const _g = globalThis as typeof globalThis & { + __reporterInterval?: ReturnType; + /** 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 { 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', diff --git a/lib/tradovate-class.ts b/lib/tradovate-class.ts index a678ed4..c1c2fe2 100644 --- a/lib/tradovate-class.ts +++ b/lib/tradovate-class.ts @@ -1544,6 +1544,15 @@ export class TradovateClient { return !this.balanceDrifted(accountId); } + /** historyReady() without its drift check, which can start a hold and + * moves the balance baseline — for the reporter, which must change + * neither. A drift not yet held reads ready until the scheduler looks. */ + public peekHistoryReady(accountId: number): boolean { + if (!this.historyLoadedIds.has(accountId)) return false; + if (this.dailyPnL[accountId] === undefined || this.fundTransactions[accountId] === undefined) return false; + return !this.historyHolds.has(accountId); + } + /** Drop the account's history from memory (a wipe): it reads unloaded, and * stays out of trading, until a Cash History rebuilds it — the wipe took * its fund transactions, which neither the Fills report nor the cache can diff --git a/package.json b/package.json index b08ab3e..b2f1f6c 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "build": "next build", "start": "next start", "lint": "eslint", - "check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs scripts/check-instruments.mjs" + "check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs scripts/check-instruments.mjs scripts/check-account-status.mjs" }, "dependencies": { "@microsoft/signalr": "^10.0.11", diff --git a/scripts/check-account-status.mjs b/scripts/check-account-status.mjs new file mode 100644 index 0000000..5995e8b --- /dev/null +++ b/scripts/check-account-status.mjs @@ -0,0 +1,255 @@ +// Checks for the account status the reporter sends the master's ledger +// (lib/platform-client.ts accountStatus and the helpers around it). +// +// npm run check +// +// .mjs on purpose: Next's tsconfig includes every .ts file, and a .ts test +// importing another .ts by extension would trip the build's type check. Node +// strips the types from lib/platform-client.ts at import time (its only +// imports are `import type`). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + accountStatus, accountStatusFields, brokerFacts, emptyFirmStats, firmStatusFields, legacyDead, + REPORT_STATUS_VERSION, SYNC_STALE_MS, +} from '../lib/platform-client.ts'; + +const FLOORS = { negative: -2000, zero: 0, positive: 48_000 }; +const OFFSETS = { above: 1500, at: 0, below: -1500 }; + +/** Every case of {cash present/missing, floor <0/0/>0, cash above/at/below the + * floor, active, history ready}. */ +function* matrix() { + for (const [floorName, floor] of Object.entries(FLOORS)) { + for (const [offsetName, offset] of Object.entries(OFFSETS)) { + for (const missing of [false, true]) { + for (const active of [true, false]) { + for (const historyReady of [true, false]) { + const cash = missing ? undefined : floor + offset; + yield { label: `${floorName} floor, cash ${missing ? 'missing' : offsetName}, active ${active}, history ${historyReady}`, cash, floor, active, historyReady }; + } + } + } + } + } +} + +test('legacyDead is the test reporter.ts:114 used before', () => { + for (const c of matrix()) { + const cashOrZero = c.cash ?? 0; + // Copied from reporter.ts at 8747dd6: autoLiqThreshold !== 0 && cash.amount <= autoLiqThreshold + assert.equal(legacyDead(cashOrZero, c.floor), c.floor !== 0 && cashOrZero <= c.floor, c.label); + } +}); + +test('dead is exactly the legacy test whenever there is a balance', () => { + let cases = 0; + for (const c of matrix()) { + if (c.cash === undefined) continue; + cases++; + const { status } = accountStatus(c); + assert.equal(status === 'dead', legacyDead(c.cash, c.floor), c.label); + } + assert.equal(cases, 36); + // A balance of exactly 0 is a balance, not a missing one. + assert.deepEqual(accountStatus({ cash: 0, floor: 48_000, active: true, historyReady: true }), { status: 'dead' }); + assert.deepEqual(accountStatus({ cash: 0, floor: 0, active: true, historyReady: true }), { status: 'live' }); +}); + +test('a missing balance is unknown, never dead and never 0', () => { + for (const c of matrix()) { + if (c.cash !== undefined) continue; + assert.deepEqual(accountStatus(c), { status: 'unknown', reason: 'cash_missing' }, c.label); + } + assert.deepEqual(accountStatus({ cash: null, floor: 48_000, active: true, historyReady: true }), { status: 'unknown', reason: 'cash_missing' }); +}); + +test('the legacy invariant: the old dead count = dead + cash-missing with a positive floor', () => { + for (const c of matrix()) { + const { status, reason } = accountStatus(c); + const oldDead = legacyDead(c.cash ?? 0, c.floor); + const newDead = status === 'dead' || (status === 'unknown' && reason === 'cash_missing' && c.floor > 0); + assert.equal(newDead, oldDead, c.label); + } +}); + +test('priority: dead, then history pending, then locked, then live', () => { + const base = { cash: 50_000, floor: 48_000, active: true, historyReady: true }; + assert.deepEqual(accountStatus(base), { status: 'live' }); + assert.deepEqual(accountStatus({ ...base, active: false }), { status: 'locked', reason: 'inactive' }); + assert.deepEqual(accountStatus({ ...base, active: false, inactiveReason: 'liquidate_only' }), { status: 'locked', reason: 'liquidate_only' }); + assert.deepEqual(accountStatus({ ...base, active: false, historyReady: false }), { status: 'history_pending' }); + assert.deepEqual(accountStatus({ ...base, cash: 47_000, active: false, historyReady: false }), { status: 'dead' }); + assert.deepEqual(accountStatus({ ...base, cash: 47_000, deadReason: 'terminal:TERMINATED' }), { status: 'dead', reason: 'terminal:TERMINATED' }); + // A floor of 0 is no floor: never dead, whatever the balance. + assert.equal(accountStatus({ ...base, cash: -10, floor: 0 }).status, 'live'); +}); + +test('broker facts per platform', () => { + assert.deepEqual(brokerFacts('tradovate', { active: true, closed: false, archived: false, restricted: false }), { inactiveReason: 'liquidate_only' }); + assert.deepEqual(brokerFacts('tradovate', { active: false, closed: true, archived: true, restricted: false }), + { inactiveReason: 'liquidate_only', brokerStatus: 'closed,archived' }); + assert.deepEqual(brokerFacts('topstepx', { active: true }), { inactiveReason: 'cannot_trade' }); + assert.deepEqual(brokerFacts('topstepx', { active: false }), { inactiveReason: 'cannot_trade', brokerStatus: 'canTrade:false' }); + const registered = Date.parse('2026-10-03T03:00:00Z'); + assert.deepEqual(brokerFacts('dxtrade', { active: true }, { status: 'FULL_TRADING', registrationTime: registered }), + { inactiveReason: 'status:FULL_TRADING', deadReason: 'terminal:FULL_TRADING', registeredAt: registered }); + assert.deepEqual(brokerFacts('dxtrade', { active: false }, { status: 'CLOSE_ONLY', registrationTime: NaN }), + { inactiveReason: 'close_only', deadReason: 'terminal:CLOSE_ONLY', brokerStatus: 'CLOSE_ONLY' }); + assert.deepEqual(brokerFacts('dxtrade', { active: false }, undefined), { inactiveReason: 'inactive' }); +}); + +test('a DXtrade account the firm terminated reads dead with the terminal status', () => { + // dxtrade-class.ts sets the floor to the balance for DEAD_STATUSES. + const facts = brokerFacts('dxtrade', { active: false }, { status: 'TERMINATED', registrationTime: NaN }); + const fields = accountStatusFields({ cash: 94_210.5, floor: 94_210.5, active: false, historyReady: true, accountTypeId: null, fundTransactions: [], ...facts }); + assert.equal(fields.status, 'dead'); + assert.equal(fields.reason, 'terminal:TERMINATED'); + assert.equal(fields.brokerStatus, 'TERMINATED'); + // A CLOSE_ONLY account is inactive (floor 0 there), not dead. + const closeOnly = accountStatusFields({ cash: 94_210.5, floor: 0, active: false, historyReady: true, accountTypeId: 't', fundTransactions: [], + ...brokerFacts('dxtrade', { active: false }, { status: 'CLOSE_ONLY', registrationTime: NaN }) }); + assert.deepEqual([closeOnly.status, closeOnly.reason], ['locked', 'close_only']); +}); + +test('per-account fields: defaults and nulls are left out', () => { + const fields = accountStatusFields({ cash: undefined, floor: 0, active: true, historyReady: false, accountTypeId: null, fundTransactions: undefined, inactiveReason: 'liquidate_only' }); + assert.deepEqual(fields, { status: 'unknown', reason: 'cash_missing' }); + + const live = accountStatusFields({ + cash: 50_123.456, floor: 47_623.451, active: true, historyReady: true, accountTypeId: 'cc9d7145-f85c-4b61-9e90-b7091b6f7dcc', + fundTransactions: [{ date: '2026-09-30', amount: -1000 }, { date: '2026-09-25', amount: 50_000 }, { date: '2026-10-02', amount: 25 }], + inactiveReason: 'liquidate_only', + }); + assert.deepEqual(live, { + status: 'live', balance: 50_123.46, floor: 47_623.45, accountTypeId: 'cc9d7145-f85c-4b61-9e90-b7091b6f7dcc', + firstFundDate: '2026-09-25', positiveFunds: 2, + }); + + // Fund history loaded with no positive rows: a count of 0, no date. + const none = accountStatusFields({ cash: 10, floor: 0, active: true, historyReady: true, accountTypeId: 't', fundTransactions: [] }); + assert.deepEqual([none.positiveFunds, 'firstFundDate' in none], [0, false]); +}); + +test('DXtrade: the registration date (Chicago) stands in for the first fund date', () => { + const registeredAt = Date.parse('2026-10-03T03:00:00Z'); // Oct 2, 22:00 CDT + const input = { cash: 100_000, floor: 0, active: true, historyReady: true, accountTypeId: 't', registeredAt }; + assert.equal(accountStatusFields({ ...input, fundTransactions: [] }).firstFundDate, '2026-10-02'); + // A real fund row wins; and nothing until the fund history has loaded. + assert.equal(accountStatusFields({ ...input, fundTransactions: [{ date: '2026-10-05', amount: 100_000 }] }).firstFundDate, '2026-10-05'); + assert.equal('firstFundDate' in accountStatusFields({ ...input, fundTransactions: undefined }), false); + // A time no Date can hold is no date — and must not throw. + for (const bad of [1.7e18, -1e16, NaN]) { + assert.equal('registeredAt' in brokerFacts('dxtrade', { active: true }, { status: 'FULL_TRADING', registrationTime: bad }), false); + assert.equal('firstFundDate' in accountStatusFields({ ...input, registeredAt: bad, fundTransactions: [] }), false); + } + assert.deepEqual(firmStatusFields('f', true, 1e17), { statusVersion: 1, firmId: 'f' }); +}); + +test('firm-level fields: statusVersion and firmId always, syncedAt once synced', () => { + const at = Date.parse('2026-10-06T14:00:00Z'); + assert.deepEqual(firmStatusFields('f-1', true, at), { statusVersion: 1, firmId: 'f-1', syncedAt: '2026-10-06T14:00:00.000Z' }); + assert.deepEqual(firmStatusFields('f-1', true, 0), { statusVersion: 1, firmId: 'f-1' }); + assert.deepEqual(firmStatusFields('f-1', false, at), { statusVersion: 1, firmId: 'f-1' }); + assert.equal(REPORT_STATUS_VERSION, 1); + assert.equal(SYNC_STALE_MS, 5 * 60 * 1000); +}); + +test('the no-accounts branch keeps every existing key and value', () => { + const firm = { id: '71016d02-752f-4078-9e24-e74c9d75d1c8', name: 'Tradeify' }; + // What reporter.ts sent before (its :46-56 branch), for each client state. + const legacy = (client) => ({ + firm: firm.name, totalAccounts: 0, accountsTraded: 0, inTrade: 0, + targetHit: 0, partial: 0, loss: 0, personalDll: 0, completed: 0, + ...(client?.syncComplete ? { accounts: [] } : {}), + accountTypes: [], + }); + const at = Date.parse('2026-10-06T14:00:00Z'); + const states = [undefined, { syncComplete: false, lastSyncAt: 0 }, { syncComplete: false, lastSyncAt: at }, { syncComplete: true, lastSyncAt: 0 }, { syncComplete: true, lastSyncAt: at }]; + for (const client of states) { + const got = emptyFirmStats(firm, client); + const old = legacy(client); + for (const [key, value] of Object.entries(old)) assert.deepEqual(got[key], value, `${JSON.stringify(client)}: ${key}`); + const added = Object.keys(got).filter((k) => !(k in old)).sort(); + const synced = client?.syncComplete === true; + assert.equal(got.statusVersion, 1); + assert.equal(got.firmId, firm.id); + assert.equal('accounts' in got, synced); + assert.equal('syncedAt' in got, synced && client.lastSyncAt > 0); + assert.deepEqual(added, ['firmId', 'statusVersion', ...(got.syncedAt ? ['syncedAt'] : [])].sort()); + // The old keys stay first and in their order: only new keys follow. + assert.deepEqual(Object.keys(got).slice(0, Object.keys(old).length), Object.keys(old)); + } +}); + +// ── Size: 4 firms × 500 accounts against the master socket's 1 MB frame ────── + +const TYPE_ID = 'f5ad1527-8bd7-4ca0-9e35-bcadf78e52e9'; +const pad = (n, w) => String(n).padStart(w, '0'); + +/** The entry reporter.ts sent before, with a realistic 14-character name. */ +function legacyEntry(i) { + return { name: `TDFY${pad(i, 10)}`, size: 150_000, payoutable: false, withdrawals: 2 }; +} + +/** Every new field populated, worst realistic case per platform. */ +function fullFields(platform, i) { + const balance = 149_823.45 - (i % 97); + if (platform === 'tradovate') { + return accountStatusFields({ cash: balance, floor: 147_500.12, active: false, historyReady: true, accountTypeId: TYPE_ID, + fundTransactions: [{ date: '2026-09-25', amount: 150_000 }, { date: '2026-10-01', amount: 25 }], + ...brokerFacts('tradovate', { active: false, closed: true, archived: true, restricted: true }) }); + } + if (platform === 'dxtrade') { + return accountStatusFields({ cash: balance, floor: balance, active: false, historyReady: true, accountTypeId: TYPE_ID, + fundTransactions: [{ date: '2026-09-25', amount: 150_000 }], + ...brokerFacts('dxtrade', { active: false }, { status: 'TERMINATED', registrationTime: Date.parse('2026-09-25T15:00:00Z') }) }); + } + return accountStatusFields({ cash: balance, floor: 147_500.12, active: false, historyReady: true, accountTypeId: TYPE_ID, + fundTransactions: [{ date: '2026-09-25', amount: 150_000 }], + ...brokerFacts('topstepx', { active: false }) }); +} + +/** A typical live account: no reason, no broker status. */ +function typicalFields(i) { + return accountStatusFields({ cash: 50_123.45 + (i % 89), floor: 47_623.45, active: true, historyReady: true, accountTypeId: TYPE_ID, + fundTransactions: [{ date: '2026-09-25', amount: 50_000 }], ...brokerFacts('tradovate', { active: true, closed: false, archived: false, restricted: false }) }); +} + +const added = (fields, i) => JSON.stringify({ ...legacyEntry(i), ...fields }).length - JSON.stringify(legacyEntry(i)).length; +const PLATFORMS = ['tradovate', 'tradovate', 'topstepx', 'dxtrade']; + +const ALL_FIELDS = ['status', 'reason', 'balance', 'floor', 'accountTypeId', 'firstFundDate', 'positiveFunds', 'brokerStatus']; + +test('size: the new fields add < 230 bytes per fully populated account, < 180 per typical live one', () => { + let worst = 0; + for (const platform of new Set(PLATFORMS)) { + assert.deepEqual(Object.keys(fullFields(platform, 0)).sort(), [...ALL_FIELDS].sort(), `${platform} entry is fully populated`); + assert.equal(fullFields(platform, 0).accountTypeId.length, 36); + for (let i = 0; i < 500; i++) worst = Math.max(worst, added(fullFields(platform, i), i)); + } + assert.deepEqual(Object.keys(typicalFields(0)).sort(), ['accountTypeId', 'balance', 'firstFundDate', 'floor', 'positiveFunds', 'status']); + let typical = 0; + for (let i = 0; i < 500; i++) typical = Math.max(typical, added(typicalFields(i), i)); + console.log(` # new fields per account: ${worst} B fully populated (worst platform), ${typical} B typical live`); + assert.ok(worst < 230, `fully populated adds ${worst} B`); + assert.ok(typical < 180, `typical live adds ${typical} B`); +}); + +test('size: 2000 fully populated accounts < 600 KB, the whole report body < 900 KB', () => { + const firms = PLATFORMS.map((platform, f) => ({ + firm: `Firm ${f}`, totalAccounts: 500, accountsTraded: 500, inTrade: 0, + targetHit: 0, partial: 0, loss: 500, personalDll: 0, completed: 0, + accounts: Array.from({ length: 500 }, (_, i) => ({ ...legacyEntry(f * 500 + i), ...fullFields(platform, i) })), + accountTypes: [{ accountTypeId: TYPE_ID, label: '150K Eval', accountSize: 150_000, totalAccounts: 500, accountsTraded: 500, inTrade: 0, targetHit: 0, partial: 0, loss: 500 }], + ...firmStatusFields(`00000000-0000-4000-8000-00000000000${f}`, true, Date.parse('2026-10-06T14:00:00Z')), + })); + const accountsBytes = JSON.stringify(firms.flatMap((f) => f.accounts)).length; + const body = JSON.stringify({ + instance: 'Someone', id: '00000000-0000-4000-8000-000000000000', firms, + scheduler: { running: true, lastTicks: [] }, version: '8747dd6', updater: null, + }).length; + console.log(` # 2000 accounts: ${accountsBytes} B; report body: ${body} B`); + assert.ok(accountsBytes < 600_000, `accounts serialize to ${accountsBytes} B`); + assert.ok(body < 900_000, `report body is ${body} B`); +});