Mark TopstepX accounts dead only off landed fills and settled books

The MLL floor no longer recomputes while a position is open or while
the balance disagrees with the books beyond fee noise — the last
settled floor stands, so balance ≤ floor can only come true when a
booked fill crosses a settled floor (Topstep's own liquidation fill
included). The floor also survives canTrade flipping off, so a blown
account stays ☠ instead of demoting to inactive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-13 14:10:36 -05:00
co-authored by Claude Fable 5
parent 5e14daa669
commit 1996937ef2
2 changed files with 17 additions and 2 deletions
+1 -1
View File
@@ -592,7 +592,7 @@ export default function AccountPage() {
<div> <div>
<p className="font-bold text-sm">Account Blown</p> <p className="font-bold text-sm">Account Blown</p>
<p className="text-slate-300 text-xs mt-0.5"> <p className="text-slate-300 text-xs mt-0.5">
Balance ${fmt(account.amount)} has breached the Tradovate auto-liquidation floor Balance ${fmt(account.amount)} has breached the auto-liquidation floor
{liqFloor != null ? ` of $${fmt(liqFloor)}` : ''}. {liqFloor != null ? ` of $${fmt(liqFloor)}` : ''}.
</p> </p>
</div> </div>
+16 -1
View File
@@ -584,12 +584,24 @@ export class TopstepXClient implements PlatformClient {
* accounts are skipped: the platform has already retired them. * accounts are skipped: the platform has already retired them.
*/ */
private updateMllFloor(account: PlatformAccount): void { private updateMllFloor(account: PlatformAccount): void {
if (!account.active) { this.autoLiqThresholds[account.id] = 0; return; } // Deliberately computed for canTrade=false accounts too: when Topstep
// 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 dd = getAccountConfig(account.id).accountType?.trailingDrawdown ?? 0;
const history = this.fullDailyPnL[account.id]; const history = this.fullDailyPnL[account.id];
const cash = this.accountCashBalances[account.id]; const cash = this.accountCashBalances[account.id];
if (!(dd > 0) || !history || !cash) { this.autoLiqThresholds[account.id] = 0; return; } if (!(dd > 0) || !history || !cash) { this.autoLiqThresholds[account.id] = 0; return; }
// Only move the floor on SETTLED books: never mid-trade (the fill
// hasn't landed — the dead check must not fire off half-processed
// state), and never while the balance disagrees with the books by
// more than fee noise (a fill or fund event is still being absorbed;
// reconcileFunds or the next sync settles it). The previous floor
// stands until then, so `balance ≤ floor` can only come true off a
// landed, booked fill.
if (this.positions[account.id]) return;
const today = currentTradeDateCT(); const today = currentTradeDateCT();
const fees = this.dailyFees[account.id] ?? new Map<string, number>(); const fees = this.dailyFees[account.id] ?? new Map<string, number>();
@@ -608,6 +620,9 @@ export class TopstepXClient implements PlatformClient {
const booked = dates.reduce((s, d) => s + events.get(d)!, 0); 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 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 residual = Math.round((cash.amount - 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); const totalFees = dates.reduce((s, d) => s + (fees.get(d) ?? 0), 0);
let bal = 0; let bal = 0;