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:
co-authored by
Claude Opus 5.5
parent
8747dd6b08
commit
0c3a36845a
@@ -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);
|
||||
|
||||
+35
-2
@@ -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[];
|
||||
|
||||
+170
-1
@@ -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.
|
||||
|
||||
+51
-14
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user