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