From fc65b4340d9f0e2f07d58a2e8d019be30b31162e Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Sun, 13 Sep 2026 13:57:06 -0500 Subject: [PATCH] Reconstruct TopstepX MLL floors from the EOD balance curve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TopstepX never reports the Maximum Loss Limit, but it's deterministic: floor = min(0, eodBalanceHigh − trailingDrawdown), locking at the $0 starting balance. The EOD curve rebuilds from daily P&L + fund events, with off-book fee drift spread across days proportionally to reported fees (validated against a platform-reported MLL to ~$0.10). Stored in autoLiqThresholds — negative, or +$0.01 once locked — so dead detection, dashboards, and the ruin model's startThreshold all use the real room. Guards move from '> 0' to '!== 0'; 0 still means no floor. canTrade=false accounts are skipped. daily_pnl gains a fees column for the weighting. Co-Authored-By: Claude Fable 5 --- app/accounts/[id]/page.tsx | 13 +++--- app/api/state/route.ts | 3 +- app/page.tsx | 3 +- lib/auto-trade.ts | 4 +- lib/db.ts | 19 +++++--- lib/reporter.ts | 5 ++- lib/topstepx-class.ts | 92 ++++++++++++++++++++++++++++++++++++-- 7 files changed, 121 insertions(+), 18 deletions(-) diff --git a/app/accounts/[id]/page.tsx b/app/accounts/[id]/page.tsx index 8bfc693..1a89859 100644 --- a/app/accounts/[id]/page.tsx +++ b/app/accounts/[id]/page.tsx @@ -411,9 +411,11 @@ export default function AccountPage() { const hasLossLimit = cfg != null && cfg.minDayPnL !== -999; const daysPassed = cfg != null && account.daysTraded >= cfg.minTradingDays; - // Dead when balance hits Tradovate's auto-liquidation floor - const isDead = account.autoLiqThreshold > 0 && account.amount <= account.autoLiqThreshold; - const liqFloor = account.autoLiqThreshold > 0 ? account.autoLiqThreshold : null; + // Dead when balance hits the liquidation floor — Tradovate reports its + // (positive) floor; TopstepX floors are reconstructed MLLs, negative or + // +$0.01 once locked at breakeven. 0 = no floor known. + const isDead = account.autoLiqThreshold !== 0 && account.amount <= account.autoLiqThreshold; + const liqFloor = account.autoLiqThreshold !== 0 ? account.autoLiqThreshold : null; // Ground-truth profit = balance minus funded account size. // Falls back to FIFO total when accountSize is unavailable. @@ -508,8 +510,9 @@ export default function AccountPage() { drawdown: (cfg.trailingDrawdown ?? 0) > 0 ? cfg.trailingDrawdown! : Math.abs(cfg.maxLoss) > 0 ? Math.abs(cfg.maxLoss) : 2000, - // The platform's real liquidation floor, when it reports one. - startThreshold: account.autoLiqThreshold > 0 + // The platform's real liquidation floor, when one is known — + // reported by Tradovate, reconstructed for TopstepX. + startThreshold: account.autoLiqThreshold !== 0 ? Math.round((account.autoLiqThreshold - cfg.accountSize) * 100) / 100 : undefined, }, diff --git a/app/api/state/route.ts b/app/api/state/route.ts index 65b30fb..93b2554 100644 --- a/app/api/state/route.ts +++ b/app/api/state/route.ts @@ -45,7 +45,8 @@ export async function GET() { const dailyPnL = combined.dailyPnL; const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0); const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0; - const isDead = autoLiqThreshold > 0 && cash.amount <= autoLiqThreshold; + // 0 = no floor; negative floors are reconstructed TopstepX MLLs. + const isDead = autoLiqThreshold !== 0 && cash.amount <= autoLiqThreshold; const allFundTxns = client.fundTransactions?.[acc.id] ?? []; const priorProfit = client.priorProfit?.[acc.id] ?? 0; // Stage = 1 + number of withdrawals (negative fund transactions) diff --git a/app/page.tsx b/app/page.tsx index fbab7e9..b9d0612 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -24,7 +24,8 @@ function fmt(value: number) { /** Returns true when an account's balance has breached Tradovate's auto-liquidation floor. */ function isAccountDead(account: AccountState): boolean { - return account.autoLiqThreshold > 0 && account.amount <= account.autoLiqThreshold; + // 0 = no floor; negative floors are reconstructed TopstepX MLLs. + return account.autoLiqThreshold !== 0 && account.amount <= account.autoLiqThreshold; } function SettingsIcon() { diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index a9fbbb0..ac32a71 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -22,7 +22,9 @@ import { getSetting, setSetting } from './db'; // ── helpers ────────────────────────────────────────────────────────────────── function isAccountDead(amount: number, autoLiqThreshold: number): boolean { - return autoLiqThreshold > 0 && amount <= autoLiqThreshold; + // Tradovate floors are positive balances; reconstructed TopstepX MLL + // floors are negative (or +$0.01 once locked at breakeven). 0 = no floor. + return autoLiqThreshold !== 0 && amount <= autoLiqThreshold; } // In-memory runaway guard: legs fired per account per trade date, plus the sum diff --git a/lib/db.ts b/lib/db.ts index 46eaea6..103c932 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -236,18 +236,27 @@ db.exec(` ); `); -export function saveDailyPnL(accountId: number, accountName: string, entries: { date: string; pnl: number }[]): void { - const ins = db.prepare('INSERT OR REPLACE INTO daily_pnl (account_id, account_name, date, pnl) VALUES (?, ?, ?, ?)'); +// Migration: the day's reported fees (TopstepX). The MLL reconstruction +// distributes off-book fee drift across days proportionally to these; rows +// from before the migration read 0 until the next fetch overwrites them. +try { + db.exec('ALTER TABLE daily_pnl ADD COLUMN fees REAL NOT NULL DEFAULT 0'); +} catch { + // Column already exists +} + +export function saveDailyPnL(accountId: number, accountName: string, entries: { date: string; pnl: number; fees?: number }[]): void { + const ins = db.prepare('INSERT OR REPLACE INTO daily_pnl (account_id, account_name, date, pnl, fees) VALUES (?, ?, ?, ?, ?)'); const txn = db.transaction(() => { for (const e of entries) { - ins.run(accountId, accountName, e.date, e.pnl); + ins.run(accountId, accountName, e.date, e.pnl, e.fees ?? 0); } }); txn(); } -export function loadDailyPnL(accountId: number): { date: string; pnl: number }[] { - return (db.prepare('SELECT date, pnl FROM daily_pnl WHERE account_id = ? ORDER BY date').all(accountId) as { date: string; pnl: number }[]); +export function loadDailyPnL(accountId: number): { date: string; pnl: number; fees: number }[] { + return (db.prepare('SELECT date, pnl, fees FROM daily_pnl WHERE account_id = ? ORDER BY date').all(accountId) as { date: string; pnl: number; fees: number }[]); } // ── Fund Transactions Cache ─────────────────────────────────────────────────── diff --git a/lib/reporter.ts b/lib/reporter.ts index 2216566..50c8b0f 100644 --- a/lib/reporter.ts +++ b/lib/reporter.ts @@ -73,7 +73,8 @@ function collectFirmStats() { for (const acc of client.accountList) { const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 }; const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0; - const isDead = autoLiqThreshold > 0 && cash.amount <= autoLiqThreshold; + // 0 = no floor; negative floors are reconstructed TopstepX MLLs. + const isDead = autoLiqThreshold !== 0 && cash.amount <= autoLiqThreshold; if (isDead) continue; const cfg = getAccountConfig(acc.id).accountType; @@ -155,7 +156,7 @@ function collectFirmStats() { startHigh: highWater, // Tradovate reports the account's REAL liquidation // floor — anchor the trail there for exact room. - startThreshold: autoLiqThreshold > 0 + startThreshold: autoLiqThreshold !== 0 ? r2(autoLiqThreshold - cfg.accountSize) : undefined, }, diff --git a/lib/topstepx-class.ts b/lib/topstepx-class.ts index c3a964a..03070d2 100644 --- a/lib/topstepx-class.ts +++ b/lib/topstepx-class.ts @@ -25,6 +25,7 @@ import * as signalR from '@microsoft/signalr'; import { currentTradeDateCT } from './trading-logic'; import { getCachedContract } from './contract-resolver'; import { saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions } from './db'; +import { getAccountConfig } from './account-type-resolver'; import type { PlatformClient, PlatformAccount, PlatformContract } from './platform-client'; const API_BASE = 'https://api.topstepx.com'; @@ -112,6 +113,10 @@ export class TopstepXClient implements PlatformClient { * recomputed from these on every REST sync and every live trade event. */ private todayTrades: { [accountId: number]: Map } = {}; + /** Reported fees per trade date, per account — weights for spreading the + * off-book fee drift when reconstructing the MLL floor. */ + private dailyFees: { [accountId: number]: Map } = {}; + /** Front-month contract per product symbol, plus name→contract for order * placement (sendOrder receives the contract NAME, e.g. "NQZ5"). */ private contractByProduct = new Map(); @@ -474,6 +479,12 @@ export class TopstepXClient implements PlatformClient { } catch (err) { console.error(`[topstepx] fund reconcile failed for ${acc.name}`, err instanceof Error ? err.message : err); } + + try { + this.updateMllFloor(acc); + } catch (err) { + console.error(`[topstepx] MLL reconstruction failed for ${acc.name}`, err instanceof Error ? err.message : err); + } } console.log(`[topstepx] sync — ${this.accountList.length} account(s), ${Object.keys(this.positions).length} open position(s)`); @@ -553,6 +564,66 @@ export class TopstepXClient implements PlatformClient { this.applyCycleSlice(account.id); } + /** + * Reverse-engineer the account's MLL floor — TopstepX never reports it. + * + * Topstep's Maximum Loss Limit sits `trailingDrawdown` below the END-OF-DAY + * balance high, ratchets up only, and locks at the starting balance ($0 for + * Express accounts) once the high has climbed the full drawdown: + * + * floor = min(0, eodBalanceHigh − trailingDrawdown) + * + * The EOD balance curve is rebuilt from daily P&L + recorded fund events. + * Balance the books don't explain (sub-threshold off-book fees) is spread + * across days proportionally to each day's reported fees — validated + * against a platform-reported MLL to within $0.11. + * + * Stored in autoLiqThresholds (negative, or +$0.01 once locked at + * breakeven — 0 must keep meaning "no floor"), which lights up the same + * dead-account handling Tradovate's reported floors use. canTrade=false + * accounts are skipped: the platform has already retired them. + */ + private updateMllFloor(account: PlatformAccount): void { + if (!account.active) { this.autoLiqThresholds[account.id] = 0; return; } + const dd = getAccountConfig(account.id).accountType?.trailingDrawdown ?? 0; + const history = this.fullDailyPnL[account.id]; + const cash = this.accountCashBalances[account.id]; + if (!(dd > 0) || !history || !cash) { this.autoLiqThresholds[account.id] = 0; return; } + + const today = currentTradeDateCT(); + const fees = this.dailyFees[account.id] ?? new Map(); + + // One event per past date: booked P&L plus any fund transaction. + const events = new Map(); + const add = (date: string, amount: number) => events.set(date, (events.get(date) ?? 0) + amount); + for (const d of history) if (d.date !== today) add(d.date, d.pnl); + for (const f of this.fundTransactions[account.id] ?? []) if (f.date !== today) add(f.date, f.amount); + const dates = [...events.keys()].sort(); + if (dates.length === 0) { this.autoLiqThresholds[account.id] = 0; return; } + + // What the books don't explain (bounded by the reconcile threshold) is + // off-book fee drift — attribute it in proportion to reported fees. + // Today is handled live: the balance carries today's realized P&L and + // 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; + 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 + 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" + : Math.round((high - dd) * 100) / 100; + this.autoLiqThresholds[account.id] = floor; + } + private maybeFetchDaysTraded(): void { if (this.daysFetchInFlight) return; @@ -629,18 +700,31 @@ export class TopstepXClient implements PlatformClient { const trades = (res.trades ?? []).filter((t) => !t.voided); this.lastFetchRaw[account.name] = trades.length > 0 ? JSON.stringify(trades[0]) : '(no trades)'; - const dailyMap: { [date: string]: number } = {}; + const dailyMap: { [date: string]: { pnl: number; fees: number } } = {}; for (const t of trades) { const date = currentTradeDateCT(new Date(t.creationTimestamp)); - dailyMap[date] = (dailyMap[date] ?? 0) + (t.profitAndLoss ?? 0) - (t.fees ?? 0); + const entry = (dailyMap[date] ??= { pnl: 0, fees: 0 }); + entry.pnl += (t.profitAndLoss ?? 0) - (t.fees ?? 0); + entry.fees += t.fees ?? 0; } const fresh = Object.entries(dailyMap) - .map(([date, pnl]) => ({ date, pnl: Math.round(pnl * 100) / 100 })) + .map(([date, e]) => ({ + date, + pnl: Math.round(e.pnl * 100) / 100, + fees: Math.round(e.fees * 10000) / 10000, + })) .sort((a, b) => a.date.localeCompare(b.date)); const merged = mergePnL(cached, fresh); + // Reported fees per day — the weights the MLL reconstruction + // uses to place off-book fee drift. Fresh overwrites cached. + const fees = new Map(); + for (const e of cached) fees.set(e.date, e.fees ?? 0); + for (const e of fresh) fees.set(e.date, e.fees); + this.dailyFees[account.id] = fees; + // The last fund transaction (usually one this client detected // itself — see reconcileFunds) splits cycles: withdrawals // exclude their day, deposits include it. @@ -648,12 +732,14 @@ export class TopstepXClient implements PlatformClient { this.applyCycleSlice(account.id); saveDailyPnL(account.id, account.name, fresh); delete this.lastFetchErrors[account.name]; + this.updateMllFloor(account); } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error(`[topstepx] trade history failed for ${account.name}:`, msg); failedAccounts++; if (cached.length > 0) { this.fullDailyPnL[account.id] = cached; + this.dailyFees[account.id] = new Map(cached.map((e) => [e.date, e.fees ?? 0])); this.applyCycleSlice(account.id); this.lastFetchErrors[account.name] = `${msg} (using ${cached.length} cached entries from database)`; } else {