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