From 783e00755ebec9b0e3b736bc2d166ed727b751c6 Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Sun, 13 Sep 2026 15:02:16 -0500 Subject: [PATCH] Reconstruct MLL and reconcile funds against the real starting balance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both the MLL floor and the books-invariant fund detector assumed a $0 starting balance (correct for Express, wrong for funded/eval sizes). A 150K account (accountSize 150000) built its balance curve from $0, so the floor came out near -$4,500 and a $145k balance never crossed it — the account never marked dead — while reconcileFunds saw the whole $150k as an unexplained deposit. Seed both with accountSize so the floor is an absolute balance (150K/$4,500 drawdown → 145,500, locked at the funded size once the EOD high climbs a drawdown above it) and the books invariant nets out the starting balance. Skip reconcile until the account type is known, since the start balance is unknowable before then. Co-Authored-By: Claude Opus 4.8 --- lib/topstepx-class.ts | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/lib/topstepx-class.ts b/lib/topstepx-class.ts index bb16541..f8e9b18 100644 --- a/lib/topstepx-class.ts +++ b/lib/topstepx-class.ts @@ -542,13 +542,21 @@ export class TopstepXClient implements PlatformClient { const cash = this.accountCashBalances[account.id]; if (!cash) return; + // The account's starting balance ($0 for Express, the funded size + // otherwise) is part of the balance the books must explain — without + // it a funded account books a phantom deposit for its whole size. Until + // the account type is resolved that number is unknown, so hold off + // rather than guess $0 and misfire on a funded account. + const acctType = getAccountConfig(account.id).accountType; + if (!acctType) return; + const startBal = acctType.accountSize; const today = currentTradeDateCT(); const tradePnL = history .filter((d) => d.date !== today) .reduce((s, d) => s + d.pnl, 0) + cash.realizedPnL; const txns = this.fundTransactions[account.id] ?? []; const funds = txns.reduce((s, f) => s + f.amount, 0); - const delta = Math.round((cash.amount - tradePnL - funds) * 100) / 100; + const delta = Math.round((cash.amount - startBal - tradePnL - funds) * 100) / 100; if (Math.abs(delta) < 200) return; console.log(`[topstepx] ${account.name}: balance ${cash.amount} vs books ${Math.round((tradePnL + funds) * 100) / 100} — recording ${delta < 0 ? 'withdrawal' : 'deposit'} of ${delta}`); @@ -588,7 +596,12 @@ export class TopstepXClient implements PlatformClient { // liquidates a breach and disables the account, the floor must SURVIVE // so the dashboards show it as blown (☠), not merely inactive. Retired // accounts without an assigned type fall out below via dd = 0. - const dd = getAccountConfig(account.id).accountType?.trailingDrawdown ?? 0; + const acctType = getAccountConfig(account.id).accountType; + const dd = acctType?.trailingDrawdown ?? 0; + // The account's real starting balance: $0 for Express, the funded size + // otherwise. Everything below is in ABSOLUTE balance terms so the floor + // is directly comparable to cash.amount. + const startBal = acctType?.accountSize ?? 0; const history = this.fullDailyPnL[account.id]; const cash = this.accountCashBalances[account.id]; if (!(dd > 0) || !history || !cash) { this.autoLiqThresholds[account.id] = 0; return; } @@ -619,23 +632,30 @@ export class TopstepXClient implements PlatformClient { // any fund event detected today, neither of which is in `events`. const booked = dates.reduce((s, d) => s + events.get(d)!, 0); const todayFund = (this.fundTransactions[account.id] ?? []).find((f) => f.date === today)?.amount ?? 0; - const residual = Math.round((cash.amount - cash.realizedPnL - todayFund - booked) * 100) / 100; + // Books invariant: cash.amount = startBal + Σ(booked P&L + funds) + + // today's realized + today's fund. The residual is off-book fee drift. + const residual = Math.round((cash.amount - startBal - cash.realizedPnL - todayFund - booked) * 100) / 100; // Books not settled (an unabsorbed fill or an unbooked fund event) — // keep the previous floor rather than skewing the curve with it. if (Math.abs(residual) >= 200) return; const totalFees = dates.reduce((s, d) => s + (fees.get(d) ?? 0), 0); - let bal = 0; - let high = 0; // the MLL starts a full drawdown below the STARTING balance + let bal = startBal; + let high = startBal; // the MLL trails EOD balance, starting a drawdown below startBal for (const d of dates) { const drift = totalFees > 0 ? residual * ((fees.get(d) ?? 0) / totalFees) : (d === dates[0] ? residual : 0); bal += events.get(d)! + drift; if (bal > high) high = bal; } - const floor = high >= dd - ? 0.01 // locked at breakeven; one cent keeps it distinct from "no floor" + // Absolute floor: a drawdown below the EOD high, locked at the starting + // balance once the high has climbed the full drawdown above it. + let floor = high >= startBal + dd + ? startBal : Math.round((high - dd) * 100) / 100; + // 0 is the "no floor" sentinel — a real $0 floor (Express, locked at + // breakeven) takes a cent so it still reads as dead-eligible. + if (floor === 0) floor = 0.01; this.autoLiqThresholds[account.id] = floor; }