Reconstruct TopstepX MLL floors from the EOD balance curve
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
4edb771701
commit
fc65b4340d
+89
-3
@@ -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<number, TsxTrade> } = {};
|
||||
|
||||
/** 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<string, number> } = {};
|
||||
|
||||
/** Front-month contract per product symbol, plus name→contract for order
|
||||
* placement (sendOrder receives the contract NAME, e.g. "NQZ5"). */
|
||||
private contractByProduct = new Map<string, { contract: TsxContract; resolvedAt: number }>();
|
||||
@@ -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<string, number>();
|
||||
|
||||
// One event per past date: booked P&L plus any fund transaction.
|
||||
const events = new Map<string, number>();
|
||||
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<string, number>();
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user