History holds: only a booked fund transaction lifts them; pace, persist, and cover wipes

Follow-up to 77d8c2e from its adversarial review:

- Stop-after-all no longer shuts down before an open while histories are
  still loading (a regression in 77d8c2e): a pending account counts as in
  play everywhere but the weekday 2:55-5:00 PM CT stretch after the close.
- Tradovate balance holds lift only when a Cash History books a fund
  transaction the account didn't have when the hold began; one that never
  does is released after 3 clean reports over 15 minutes, with a warning.
  The baseline comes from the balance when the report was requested, so a
  payout landing mid-report is held for the next one. Held accounts
  refetch every 5 minutes, not every sync.
- The Cash History baseline is kept in account_meta, so a restart onto a
  failing report server still catches a payout from the DB cache.
- A wipe holds the account until a Cash History rebuilds its fund
  transactions (the Fills report and the cache can't).
- A report still queued after its polls, or answered without data, is a
  failure, not an empty history.
- TopstepX: a newly fetched account (new, wiped, failed) waits for a sync
  that reconciles its funds, and the fetch starts that sync right away.
- Accounts a fetch is already working through aren't fetched again by the
  targeted pass; the TopstepX REST fill search spans ±15 minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-24 17:55:32 -05:00
co-authored by Claude Opus 5.5
parent e49640498a
commit 9b2d677662
3 changed files with 242 additions and 39 deletions
+18 -3
View File
@@ -528,6 +528,19 @@ export function todaysSessionOpenDelay(): number {
return openJitterMinutes(centralDay);
}
/** Weekdays 2:55–5:00 PM CT: the trade date's session has closed and the 5 PM
* roll hasn't opened the next one. The rest of the no-trade window — the
* roll to the open, and weekends — is waiting on a session to come. */
function isAfterSessionClose(now: Date = new Date()): boolean {
const parts = new Intl.DateTimeFormat('en-US', {
weekday: 'short', hour: 'numeric', minute: 'numeric', hour12: false, timeZone: 'America/Chicago',
}).formatToParts(now);
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '';
if (get('weekday') === 'Sat' || get('weekday') === 'Sun') return false;
const minutes = (parseInt(get('hour'), 10) % 24) * 60 + parseInt(get('minute'), 10);
return minutes >= 14 * 60 + 55 && minutes < 17 * 60;
}
/**
* Returns true when trading is not allowed based on the trading_hours setting.
*
@@ -1197,9 +1210,11 @@ function hasRemainingConfiguredAccounts(): boolean {
const ev = evaluateAccountForTrade(client, acc, tradeDate);
if (ev?.eligible) return true;
// Waiting on its history, not done: it may still owe today's trade.
// Only while the session is open, like the unready firms above.
if (ev?.reason === HISTORY_PENDING && !isInNoTradeWindow()) return true;
// Waiting on its history, not done: it may still owe today's trade —
// during the session and before it opens alike (a restart between
// the 5 PM roll and the open loads histories right then). Only
// once the session has closed is a still-loading account let go.
if (ev?.reason === HISTORY_PENDING && !isAfterSessionClose()) return true;
}
}