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];
|
const cash = this.accountCashBalances[account.id];
|
||||||
if (!cash) return;
|
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 today = currentTradeDateCT();
|
||||||
const tradePnL = history
|
const tradePnL = history
|
||||||
.filter((d) => d.date !== today)
|
.filter((d) => d.date !== today)
|
||||||
.reduce((s, d) => s + d.pnl, 0) + cash.realizedPnL;
|
.reduce((s, d) => s + d.pnl, 0) + cash.realizedPnL;
|
||||||
const txns = this.fundTransactions[account.id] ?? [];
|
const txns = this.fundTransactions[account.id] ?? [];
|
||||||
const funds = txns.reduce((s, f) => s + f.amount, 0);
|
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;
|
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}`);
|
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
|
// liquidates a breach and disables the account, the floor must SURVIVE
|
||||||
// so the dashboards show it as blown (☠), not merely inactive. Retired
|
// so the dashboards show it as blown (☠), not merely inactive. Retired
|
||||||
// accounts without an assigned type fall out below via dd = 0.
|
// 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 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; }
|
||||||
@@ -619,23 +632,30 @@ export class TopstepXClient implements PlatformClient {
|
|||||||
// any fund event detected today, neither of which is in `events`.
|
// any fund event detected today, neither of which is in `events`.
|
||||||
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;
|
// 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) —
|
// Books not settled (an unabsorbed fill or an unbooked fund event) —
|
||||||
// keep the previous floor rather than skewing the curve with it.
|
// keep the previous floor rather than skewing the curve with it.
|
||||||
if (Math.abs(residual) >= 200) return;
|
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 = startBal;
|
||||||
let high = 0; // the MLL starts a full drawdown below the STARTING balance
|
let high = startBal; // the MLL trails EOD balance, starting a drawdown below startBal
|
||||||
for (const d of dates) {
|
for (const d of dates) {
|
||||||
const drift = totalFees > 0 ? residual * ((fees.get(d) ?? 0) / totalFees) : (d === dates[0] ? residual : 0);
|
const drift = totalFees > 0 ? residual * ((fees.get(d) ?? 0) / totalFees) : (d === dates[0] ? residual : 0);
|
||||||
bal += events.get(d)! + drift;
|
bal += events.get(d)! + drift;
|
||||||
if (bal > high) high = bal;
|
if (bal > high) high = bal;
|
||||||
}
|
}
|
||||||
|
|
||||||
const floor = high >= dd
|
// Absolute floor: a drawdown below the EOD high, locked at the starting
|
||||||
? 0.01 // locked at breakeven; one cent keeps it distinct from "no floor"
|
// balance once the high has climbed the full drawdown above it.
|
||||||
|
let floor = high >= startBal + dd
|
||||||
|
? startBal
|
||||||
: Math.round((high - dd) * 100) / 100;
|
: 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;
|
this.autoLiqThresholds[account.id] = floor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user