Hold a payout that isn't booked yet, instead of re-earning it

On 2026-10-06 two Topstep Express accounts sat finished, waiting for their
payouts. The hub moved their balances the moment the payouts landed, but
reconcileFunds books a withdrawal only in the next sync. 28 seconds later
the plan read the drop as lost profit, reopened the cycle as stage 1 and
fired a full 3xNQ leg with no stop, on what should have been a drain day.
Both were liquidated against the $0 floor a payout leaves.

- TopstepX: unbookedBalanceChange holds an account while its balance is
  $200+ off its books (today's realized read fresh off the trades, so the
  5 PM roll doesn't trip it); the sync books it as before.
- Every platform: a finished cycle marks its books in account_meta; a later
  drop of $200+ with no fund row booked since holds the account until the
  payout books and moves it to its next stage. Relative to the mark, so
  history missing from before the cache cancels out; a wipe clears it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-10-07 12:13:48 -05:00
co-authored by Claude Opus 5.5
parent 0c3a36845a
commit c3b58c8d8b
4 changed files with 191 additions and 31 deletions
+74 -29
View File
@@ -70,6 +70,10 @@ const TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1000;
* minutes after its caller gave up on it. Longer than the idle timeout, so
* it only bites where that one can't. */
const REST_DEADLINE_MS = 90_000;
/** Balance the books don't explain past this is a fund event (reconcileFunds
* books it); under it, fee noise and rounding. The smallest real withdrawal
* in the drain flow is well above it. */
const FUND_EVENT_MIN = 200;
/** Rejects if `promise` hasn't settled within `ms`. The underlying operation
* keeps running (callers discard whatever it belonged to); Promise.race
@@ -242,6 +246,9 @@ export class TopstepXClient implements PlatformClient {
* fetched without it (after a wipe took the fund rows) would plan the
* account as stage 1 until the next sync. */
private reconcilePendingIds = new Set<number>();
/** Accounts unbookedBalanceChange has warned about, until their books
* settle — one line per episode, not one per tick. */
private unbookedWarned = new Set<number>();
/** See PlatformClient: when the last good pass started reading. */
public lastSyncStartedAt = 0;
/** Accounts whose last order ended with its outcome unknown: each sync
@@ -789,6 +796,17 @@ export class TopstepXClient implements PlatformClient {
}
private recomputeRealized(accountId: number): void {
const existing = this.accountCashBalances[accountId];
this.accountCashBalances[accountId] = {
amount: existing?.amount ?? 0,
realizedPnL: this.realizedToday(accountId),
};
}
/** The current trade date's realized P&L, net of fees, off the trades
* held — read fresh, so it's 0 the moment the date rolls even before a
* sync recomputes cash.realizedPnL. */
private realizedToday(accountId: number): number {
const today = currentTradeDateCT();
const map = this.todayTrades[accountId];
let realized = 0;
@@ -799,11 +817,7 @@ export class TopstepXClient implements PlatformClient {
realized += (t.profitAndLoss ?? 0) - (t.fees ?? 0);
}
}
const existing = this.accountCashBalances[accountId];
this.accountCashBalances[accountId] = {
amount: existing?.amount ?? 0,
realizedPnL: Math.round(realized * 100) / 100,
};
return Math.round(realized * 100) / 100;
}
// ── sync ─────────────────────────────────────────────────────────────────
@@ -992,9 +1006,9 @@ export class TopstepXClient implements PlatformClient {
* and starts a new cycle; the very first check also books any historical
* net withdrawal in one lump (fine — the last stage repeats).
*
* $200 keeps fee noise and rounding out; the smallest real withdrawal in
* the drain flow is well above it. Skipped while a position is open (fills
* in flight) or while the trade history is known-stale.
* FUND_EVENT_MIN keeps fee noise and rounding out. Skipped while a
* position is open (fills in flight) or while the trade history is
* known-stale; until it books, unbookedBalanceChange holds the account.
*/
/** Books a withdrawal/deposit the trades can't explain. True once it has
* actually checked the books against the balance (false when it had to
@@ -1002,34 +1016,20 @@ export class TopstepXClient implements PlatformClient {
private reconcileFunds(account: PlatformAccount): boolean {
if (this.positions[account.id]) return false;
if (this.lastFetchErrors[account.name]) return false;
const history = this.fullDailyPnL[account.id];
if (!history) return false; // first daily-P&L fetch hasn't landed yet
const cash = this.accountCashBalances[account.id];
if (!cash) return false;
// 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 false;
const startBal = acctType.accountSize;
const delta = this.unbookedBalance(account.id);
if (delta === null) return false;
const cash = this.accountCashBalances[account.id]!;
const startBal = getAccountConfig(account.id).accountType!.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 - startBal - tradePnL - funds) * 100) / 100;
// The first check on this instance (or since a wipe) only has the
// fetch window to explain the balance with: a lump it books is unseen
// P&L and past payouts, not the end of a cycle this instance watched.
const firstCheck = loadAccountMeta(account.id, 'funds_reconciled') === null;
if (firstCheck) saveAccountMeta(account.id, 'funds_reconciled', today);
if (Math.abs(delta) < 200) return true;
if (Math.abs(delta) < FUND_EVENT_MIN) return true;
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((cash.amount - startBal - delta) * 100) / 100} — recording ${delta < 0 ? 'withdrawal' : 'deposit'} of ${delta}`);
// A new cycle starts today: note where the one it ends began, by
// applyCycleSlice's rule for that date's own row, for the reporter's
// catch-up. A second event today doesn't start another cycle.
@@ -1051,6 +1051,51 @@ export class TopstepXClient implements PlatformClient {
return true;
}
/**
* Balance the books don't explain: balance less the starting balance
* ($0 for Express, the funded size otherwise), all trade P&L (today's off
* the trades held) and all recorded fund transactions. null until the
* history, the balance and the account type are all known — without the
* type the starting balance is unknown, and guessing $0 would book a
* funded account's whole size as a phantom deposit.
*/
private unbookedBalance(accountId: number): number | null {
const history = this.fullDailyPnL[accountId];
const cash = this.accountCashBalances[accountId];
const acctType = getAccountConfig(accountId).accountType;
if (!history || !cash || !acctType) return null;
const today = currentTradeDateCT();
const tradePnL = history
.filter((d) => d.date !== today)
.reduce((s, d) => s + d.pnl, 0) + this.realizedToday(accountId);
const funds = (this.fundTransactions[accountId] ?? []).reduce((s, f) => s + f.amount, 0);
return Math.round((cash.amount - acctType.accountSize - tradePnL - funds) * 100) / 100;
}
/**
* See PlatformClient. The hub moves the balance the moment a payout
* lands, but reconcileFunds books it only in the next sync — and not
* while a position is open. Planned on in between, the payout reads as
* lost profit: on 2026-10-06 two Express accounts that had finished
* their cycle re-planned as stage 1, fired a full 3×NQ leg 28 seconds
* after their payouts instead of a drain day, and were liquidated
* against the $0 floor a payout leaves. Not checked with a position open
* (its fills are still landing, and the account can't trade anyway).
*/
public unbookedBalanceChange(accountId: number): number | null {
const delta = this.positions[accountId] ? null : this.unbookedBalance(accountId);
if (delta === null || Math.abs(delta) < FUND_EVENT_MIN) {
this.unbookedWarned.delete(accountId);
return null;
}
if (!this.unbookedWarned.has(accountId)) {
this.unbookedWarned.add(accountId);
const name = this.accountList.find((a) => a.id === accountId)?.name ?? accountId;
console.warn(`[topstepx] ${name}: balance is $${delta} off its books — held out of trading until a sync books it`);
}
return delta;
}
/**
* Reverse-engineer the account's MLL floor — TopstepX never reports it.
*
@@ -1116,7 +1161,7 @@ export class TopstepXClient implements PlatformClient {
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;
if (Math.abs(residual) >= FUND_EVENT_MIN) return;
const totalFees = dates.reduce((s, d) => s + (fees.get(d) ?? 0), 0);
let bal = startBal;