Reconstruct MLL and reconcile funds against the real starting balance
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
fd6a2e33b6
commit
783e00755e
+27
-7
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user