From 49c4725e0c62224853d538c8c36b50b155ef7ca6 Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Fri, 25 Sep 2026 00:17:08 -0500 Subject: [PATCH] Reporter: name each account's withdrawal count The master clears a completed cycle once the payout it was waiting for has left the account, and the balance going down is only visible from here. Omitted rather than zeroed while an account's fund history hasn't loaded: undercounting leaves an entry standing, a wrong zero would clear one early. Co-Authored-By: Claude Opus 5 (1M context) --- lib/master-protocol.ts | 9 +++++++-- lib/reporter.ts | 17 ++++++++++++++--- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/lib/master-protocol.ts b/lib/master-protocol.ts index 1e99470..7527b42 100644 --- a/lib/master-protocol.ts +++ b/lib/master-protocol.ts @@ -102,8 +102,13 @@ export interface FirmStats { * payoutable flag or withdrawal stages, the test that reports its * completions as 'payout' — null while the account has no type. Absent * when the firm isn't synced (unknown, not empty). Optional: older - * instances omit it. */ - accounts?: { name: string; size: number; payoutable: boolean | null }[]; + * instances omit it. + * + * 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 }[]; /** 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/reporter.ts b/lib/reporter.ts index ae5ff7e..77ee0d9 100644 --- a/lib/reporter.ts +++ b/lib/reporter.ts @@ -93,7 +93,19 @@ function collectFirmStats() { const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 }; const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0; const cfg = getAccountConfig(acc.id).accountType; - accounts.push({ name: acc.name, size: cfg?.accountSize ?? 0, payoutable: cfg ? loops(cfg) : null }); + // undefined until the account's fund history has loaded. The master + // clears a completed cycle once the withdrawal it was waiting for + // shows up here, so an account that hasn't loaded reports no count + // at all rather than a zero: undercounting only leaves the entry + // standing, while a wrong zero would clear one early. + const loadedFundTxns = client.fundTransactions?.[acc.id]; + const fundTxns = loadedFundTxns ?? []; + accounts.push({ + name: acc.name, + size: cfg?.accountSize ?? 0, + payoutable: cfg ? loops(cfg) : null, + ...(loadedFundTxns ? { withdrawals: loadedFundTxns.filter((f) => f.amount < 0).length } : {}), + }); // 0 = no floor; negative floors are reconstructed TopstepX MLLs. const isDead = autoLiqThreshold !== 0 && cash.amount <= autoLiqThreshold; if (isDead) continue; @@ -117,7 +129,6 @@ function collectFirmStats() { // Stage-resolved config, shared by the payout state and the // day-outcome check below — a drain stage swaps in its own // min-day and drops the profit target. - const fundTxns = client.fundTransactions?.[acc.id] ?? []; const effective = cfg ? resolveEffectiveConfig( cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, @@ -227,7 +238,7 @@ function collectFirmStats() { // restart the missing rows and withdrawals would read as a fresh // stage-1 cycle. const historyLoaded = client.dailyPnL[acc.id] !== undefined - && client.fundTransactions?.[acc.id] !== undefined; + && loadedFundTxns !== undefined; // Catch-up for the cycle a fund transaction just ended: if it stood // complete, name it, since its final day may never have been