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
+43
View File
@@ -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