Files
autofirmer-expanded/scripts/check-account-status.mjs
T
Brandon LiandClaude Opus 5.5 0c3a36845a 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>
2026-10-06 14:36:55 -05:00

256 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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`);
});