From c3b58c8d8b6ac58b2157182a78439fcb78f69496 Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Wed, 7 Oct 2026 12:13:48 -0500 Subject: [PATCH] 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 --- lib/auto-trade.ts | 71 +++++++++++++++++++++++++++- lib/platform-client.ts | 5 ++ lib/topstepx-class.ts | 103 +++++++++++++++++++++++++++++------------ lib/trading-logic.ts | 43 +++++++++++++++++ 4 files changed, 191 insertions(+), 31 deletions(-) diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 10ebdf2..0116e14 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -19,9 +19,10 @@ import { BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY, FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP, CME_EVENING_OPEN_MIN, foldReason, - type DayPlan, + fundRowsKey, markFinishedCycle, unbookedPayoutDrop, + type DayPlan, type FinishedCycleMark, } from './trading-logic'; -import { getSetting, setSetting } from './db'; +import { getSetting, setSetting, loadAccountMeta, saveAccountMeta } from './db'; import type { LaneTick, TickReason } from './master-protocol'; import { POINT_VALUES, housekeepingRoot, HOUSEKEEPING_ROOT, MAX_HOLD_HOURS, futuresContracts } from './instrument-specs'; import { firmInstruments, drawFuturesInstrument, spreadBlock, type LegShape } from './instruments'; @@ -588,6 +589,53 @@ function dailyLossGate( return { room }; } +const FINISHED_CYCLE_META = 'finished_cycle'; +/** Accounts held by finishedCycleDrop and already logged — one line per hold. */ +const payoutHoldsLogged = new Set(); + +/** + * Marks the books while the account's cycle is finished (day target 0, not + * an extra day); once a plan wants to trade again, the dollars day-start + * equity has fallen below that mark with no fund row booked since — null + * when it hasn't (see unbookedPayoutDrop). Kept in account_meta, so a + * restart between the finish and the payout still holds; a wipe clears it. + * + * 2026-10-06: two Topstep Express accounts sat finished, waiting for their + * payouts. When the payouts landed, the plan read them as lost profit and + * fired a full 3×NQ stage-1 leg 28 seconds later, before the withdrawal was + * booked — on the day that should have been a drain day. Both were + * liquidated against the $0 floor the payout left. + */ +function finishedCycleDrop( + acc: { id: number; name: string }, + plan: DayPlan, + isExtraDay: boolean, + funds: string, + dayStartEquity: number, + bookedPnL: number, +): number | null { + if (!isExtraDay && plan.dayTarget <= 0) { + const mark = JSON.stringify(markFinishedCycle(funds, dayStartEquity, bookedPnL)); + if (loadAccountMeta(acc.id, FINISHED_CYCLE_META) !== mark) saveAccountMeta(acc.id, FINISHED_CYCLE_META, mark); + payoutHoldsLogged.delete(acc.id); + return null; + } + let mark: FinishedCycleMark | null = null; + try { + mark = JSON.parse(loadAccountMeta(acc.id, FINISHED_CYCLE_META) ?? 'null') as FinishedCycleMark | null; + } catch { + mark = null; + } + const drop = unbookedPayoutDrop(mark, funds, dayStartEquity, bookedPnL); + if (drop === null) { + payoutHoldsLogged.delete(acc.id); + } else if (!payoutHoldsLogged.has(acc.id)) { + payoutHoldsLogged.add(acc.id); + console.warn(`[auto-trade] ${acc.name}: balance fell $${drop} below its books after its cycle finished, with no fund transaction booked — held until the payout is booked`); + } + return drop; +} + /** * Evaluate whether an account can trade right now. Returns null when the account has no * matching config (not ours). Split-allowed accounts stay eligible mid-day until @@ -625,6 +673,12 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a // account) or that a payout has overtaken would plan the account as a // fresh stage-1 first day: an oversized leg, or a re-opened finished cycle. if (!client.historyReady(acc.id)) return { ...base, eligible: false, reason: HISTORY_PENDING }; + // A balance the books can't explain yet — a payout that platform books + // only later — would plan as lost profit to re-earn. + const unbooked: number | null = client.unbookedBalanceChange?.(acc.id) ?? null; + if (unbooked !== null) { + return { ...base, eligible: false, reason: `balance $${unbooked} off its books — waiting for a sync to book it` }; + } if (cash.pendingRoll) return { ...base, eligible: false, reason: ROLL_PENDING }; if (unknownEntryHolds(client, acc.id, tradeDate)) return { ...base, eligible: false, reason: ENTRY_UNKNOWN }; if (runtime().exitRefused?.get(acc.id) === tradeDate) { @@ -668,6 +722,19 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a const evaluated = { plan, isExtraDay, drain: effective.drain, cash, dailyPnL, daysTraded, dllRoom: Infinity }; + // A finished cycle that a balance drop reopens, with no fill or fund row + // to explain it, is a payout not booked yet: hold it until the booking + // moves the account to its next stage (a drain day, say). + const bookedPnL = priorProfit + dailyPnL + .filter((d) => d.date !== combined.tradeDate) + .reduce((sum, d) => sum + d.pnl, 0); + const payoutDrop = finishedCycleDrop( + acc, plan, isExtraDay, fundRowsKey(allFundTxns), equityProfit - combined.realizedPnL, bookedPnL, + ); + if (payoutDrop !== null) { + return { ...evaluated, eligible: false, reason: `balance $${payoutDrop} below its books since the cycle finished — waiting for the payout to be booked` }; + } + if (isExtraDay || !plan.splitAllowed) { // Legacy one-and-done gate: any realized PnL today means this account is // finished — for a combining account, "today" spans the holiday stretch. diff --git a/lib/platform-client.ts b/lib/platform-client.ts index 778b94e..a8291c6 100644 --- a/lib/platform-client.ts +++ b/lib/platform-client.ts @@ -104,6 +104,11 @@ export interface PlatformClient { * new hold, no baseline moved. Only where historyReady() has side effects * (Tradovate's balance-drift check); elsewhere historyReady() is the read. */ peekHistoryReady?(accountId: number): boolean; + /** Dollars of balance the account's books don't explain yet, or null when + * they do. Only where balances arrive live but fund events are booked + * later (TopstepX: the hub, then the next sync); the engine holds the + * account until they're booked, or a payout plans as lost profit. */ + unbookedBalanceChange?(accountId: number): number | null; /** What the broker says about the account beyond active (DXTrade: its * accountStatus and registration time, epoch ms or NaN). */ brokerAccountMeta?(accountId: number): { status: string; registrationTime: number } | undefined; diff --git a/lib/topstepx-class.ts b/lib/topstepx-class.ts index ea28766..30fb0ca 100644 --- a/lib/topstepx-class.ts +++ b/lib/topstepx-class.ts @@ -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(); + /** Accounts unbookedBalanceChange has warned about, until their books + * settle — one line per episode, not one per tick. */ + private unbookedWarned = new Set(); /** 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; diff --git a/lib/trading-logic.ts b/lib/trading-logic.ts index 61d3e5d..d613cbc 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -1064,6 +1064,49 @@ export function computeDayPlan( }; } +// ── payouts not yet booked ─────────────────────────────────────────────────── + +/** A drop at least this far below a finished cycle's books holds the account. */ +export const PAYOUT_HOLD_MIN = 200; + +/** + * Where a finished cycle (day target 0) left the books: its fund rows, and + * day-start equity less the P&L booked by then. Until a fund row is added, + * booked P&L is all that may move day-start equity — relative, so history + * missing from before the cache (or an initial deposit row some platforms + * book and others don't) cancels out. + */ +export interface FinishedCycleMark { funds: string; offset: number } + +/** Fund rows as one comparable value: a second payout on a date that already + * has one changes the amount, not the row count. */ +export function fundRowsKey(txns: { date: string; amount: number }[]): string { + return txns.map((f) => `${f.date}:${f.amount}`).join('|'); +} + +export function markFinishedCycle(funds: string, dayStartEquity: number, bookedPnL: number): FinishedCycleMark { + return { funds, offset: round2(dayStartEquity - bookedPnL) }; +} + +/** + * How far day-start equity has fallen below a finished cycle's books with no + * fund row booked since — a payout the platform hasn't booked yet. null when + * there's no mark, a fund row has moved on from it, or the drop is fee noise. + * Planned on, that drop reads as lost profit: the finished cycle reopens as + * the same stage and re-earns the payout at full size, instead of moving to + * the next stage (a drain day) once the payout books. + */ +export function unbookedPayoutDrop( + mark: FinishedCycleMark | null, + funds: string, + dayStartEquity: number, + bookedPnL: number, +): number | null { + if (!mark || mark.funds !== funds) return null; + const drop = round2(mark.offset - (dayStartEquity - bookedPnL)); + return drop >= PAYOUT_HOLD_MIN ? drop : null; +} + // ── fees ───────────────────────────────────────────────────────────────────── /** An entry's fee when the balance update hasn't shown it yet — at least one