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
+69 -2
View File
@@ -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<number>();
/**
* 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.