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
+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',
|
||||
|
||||
Reference in New Issue
Block a user