Roll window instead of a roll-time base; transient outages keep payout holds

Third review round (088886e):

- Tradovate: a record still on the ended trade date waits only inside
  17:00-17:45 CT (every account rolled 17:17-17:21 on 2026-09-24); nothing
  trades on it there, so no pre-roll fill can be lost or double-counted by
  the broker's roll. Past the window an unrolled record (an idle account)
  counts from its value when first read, so nothing waits indefinitely —
  across restarts, stale bases or accounts the broker rolls only on a fill.
- One-and-done accounts also stop once this process fired a leg today.
- A balance hold is released unchecked only when Cash History is refused
  outright (4xx / errorText, e.g. a passed account's 404s); a 5xx, timeout
  or queue outage keeps the payout held until it recovers.
- A Cash History is judged against the baseline as it stood when requested,
  so a fold observed while it was in flight is not taken for a payout.
- TopstepX: a cache fallback for an account not loaded before waits for a
  funds check; accounts with no account type (not ours) aren't left
  pending; a fetch awaits a sync already running and syncs again if its
  accounts still wait, so wipe routes report the real outcome.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-24 19:37:14 -05:00
co-authored by Claude Opus 5.5
parent 088886ed73
commit 52ed9c75e4
3 changed files with 86 additions and 32 deletions
+62 -27
View File
@@ -131,8 +131,8 @@ const HOLD_RETRY_MAX_MS = 60 * 60_000;
* many clean reports spanning at least HOLD_RELEASE_MS, with a warning. */
const HOLD_MAX_CHECKS = 3;
const HOLD_RELEASE_MS = 15 * 60_000;
/** One no Cash History can ever check (the account's report fails for good —
* a passed account's 404s) is let go after this long, with a warning. */
/** One no Cash History can ever check (the account's report is refused for
* good — a passed account's 404s) is let go after this long, with a warning. */
const HOLD_UNCHECKED_RELEASE_MS = 60 * 60_000;
/** account_meta keys: the baseline a Cash History vouched for, and a wipe's
* hold (so a restart onto a failing report keeps it). */
@@ -174,26 +174,43 @@ interface StoredCash {
realizedPnL: number;
tradeDate: string | null;
archived: boolean;
/** The record's realizedPnL as last seen while its trade date was the
* current one — the ended day's final figure once the 5 PM roll passes. */
/** For a record the broker hasn't rolled past its trade date: its
* realizedPnL when first read after the roll window — what later fills
* on the same record are counted from. */
rollBase: number | undefined;
/** Past the roll with no rollBase seen (the process started after it):
* today's share of the record can't be told apart, so the account waits
* for Tradovate to roll it. */
/** Inside the roll window with the ended day's record still in place:
* the account waits for the broker to roll it. */
pendingRoll: boolean;
}
/** Tradovate rolled every account seen on 2026-09-24 at 17:17–17:21 CT. Until
* this long after 5 PM, an account still on the ended day's record waits. */
const ROLL_WINDOW_END_MIN = 17 * 60 + 45;
function inRollWindow(now: Date = new Date()): boolean {
const parts = new Intl.DateTimeFormat('en-US', {
hour: 'numeric', minute: 'numeric', hour12: false, timeZone: 'America/Chicago',
}).formatToParts(now);
const get = (type: string) => parseInt(parts.find((p) => p.type === type)?.value ?? '0', 10);
const minutes = (get('hour') % 24) * 60 + get('minute');
return minutes >= 17 * 60 && minutes < ROLL_WINDOW_END_MIN;
}
/**
* An account's cash as the plan must read it. Tradovate rolls each account's
* cash record to the new trade date on its own schedule — archiving the old
* record and opening one with realizedPnL 0 — well after the 17:00 CT roll
* (17-21 minutes on 2026-09-24). Until then the latest record still carries
* the ended day's realizedPnL, which the plan would read as today's: legs
* sized off yesterday, one-and-done accounts read as already traded, the
* entry-commission delta swallowing a whole day's P&L. And tonight's own
* fills before the roll may be booked onto that same old record. So past the
* roll an old-dated record's realized P&L counts only what it gained since
* the roll (recorded - rollBase); its amount already holds the ended day.
* (17-21 minutes on 2026-09-24), and an idle account perhaps not until it
* trades. Until then the latest record still carries the ended day's
* realizedPnL, which the plan would read as today's: legs sized off
* yesterday, one-and-done accounts read as already traded, the
* entry-commission delta swallowing a whole day's P&L.
*
* So inside the roll window an account on the ended day's record waits
* (pendingRoll) — nothing is traded on it, so nothing is booked onto it that
* the roll could lose. Past the window, a record still not rolled counts from
* its value when first read then: 0 now, and any fill booked onto it later
* shows. Its amount already holds the ended day.
*/
function todaysCash(cb: CashBalanceEntity, prev: StoredCash | undefined): StoredCash {
const t = cb.tradeDate;
@@ -201,24 +218,23 @@ function todaysCash(cb: CashBalanceEntity, prev: StoredCash | undefined): Stored
? `${t.year}-${String(t.month).padStart(2, '0')}-${String(t.day).padStart(2, '0')}`
: null;
const recorded = cb.realizedPnL;
// Seen while current: this is the base. Seen past the roll: keep the base
// the same record carried before it, if this process saw one.
const current = tradeDate === null || tradeDate >= currentTradeDateCT();
const rollBase = current ? recorded : prev && prev.tradeDate === tradeDate ? prev.rollBase : undefined;
const ended = () => tradeDate !== null && tradeDate < currentTradeDateCT();
return {
const entry: StoredCash = {
amount: cb.amount,
tradeDate,
archived: cb.archived === true,
rollBase,
rollBase: prev && prev.tradeDate === tradeDate ? prev.rollBase : undefined,
get realizedPnL(): number {
if (!ended()) return recorded;
return rollBase === undefined ? 0 : Math.round((recorded - rollBase) * 100) / 100;
if (inRollWindow()) return 0;
entry.rollBase ??= recorded;
return Math.round((recorded - entry.rollBase) * 100) / 100;
},
get pendingRoll(): boolean {
return ended() && rollBase === undefined;
return ended() && inRollWindow();
},
};
return entry;
}
/**
@@ -319,6 +335,9 @@ export class TradovateClient {
* targeted pass doesn't request the same reports again (a wipe fetches
* outside maybeFetchDaysTraded). */
private fetchingIds = new Set<number>();
/** Accounts whose Cash History was refused outright on its last try (a
* 4xx or an errorText — a passed account's 404s), not merely failing. */
private cashHistoryUnavailable = new Set<number>();
/** Interval handles tracked so they can be cleared on reconnect */
private syncInterval: ReturnType<typeof setInterval> | null = null;
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
@@ -1278,6 +1297,7 @@ export class TradovateClient {
const cashNow = this.accountCashBalances[account.id];
const cashBefore = cashNow ? { amount: cashNow.amount, realizedPnL: cashNow.realizedPnL } : null;
const fundSigBefore = fundSignature(this.fundTransactions[account.id]);
const knownBefore = this.balanceBaseline[account.id] ? { ...this.balanceBaseline[account.id] } : null;
// --- Try Cash History first (true after-fee daily P&L) ---
try {
@@ -1363,9 +1383,18 @@ export class TradovateClient {
this.daysTraded[account.id] = active.filter((d) => d.pnl !== 0).length;
saveDailyPnL(account.id, account.name, fresh); // upsert only fresh entries — preserves older cached rows
delete this.lastFetchErrors[account.name];
this.markHistoryLoaded(account.id, true, cashBefore, fundSigBefore);
this.cashHistoryUnavailable.delete(account.id);
this.markHistoryLoaded(account.id, true, cashBefore, fundSigBefore, knownBefore);
continue;
} catch {
} catch (err) {
// Refused outright (a 4xx, an errorText) — a passed account's
// Cash History 404s for good — as against a server that may
// answer next time (5xx, a timeout, still queued).
const status = (err as { response?: { status?: number } } | null)?.response?.status;
const refused = (typeof status === 'number' && status >= 400 && status < 500 && status !== 408 && status !== 429)
|| /without data/.test(errorMessage(err));
if (refused) this.cashHistoryUnavailable.add(account.id);
else this.cashHistoryUnavailable.delete(account.id);
// fall through to Fills fallback
}
@@ -1513,6 +1542,7 @@ export class TradovateClient {
fromCashHistory: boolean,
cashBefore: { amount: number; realizedPnL: number } | null = null,
fundSigBefore = '',
knownBefore: CashBaseline | null = null,
): void {
if (this.dailyPnL[accountId] === undefined || this.fundTransactions[accountId] === undefined) {
this.historyLoadedIds.delete(accountId);
@@ -1533,9 +1563,12 @@ export class TradovateClient {
if (hold) {
hold.attempts++;
hold.retryAt = now + holdRetryMs(hold.attempts);
if (hold.reason === 'balance' && now - hold.since >= HOLD_UNCHECKED_RELEASE_MS) {
// Only when Cash History is refused for good (a passed account):
// an outage that may end keeps the payout held for it.
if (hold.reason === 'balance' && this.cashHistoryUnavailable.has(accountId)
&& now - hold.since >= HOLD_UNCHECKED_RELEASE_MS) {
console.warn(`${this.tag} ${this.accountName(accountId)}: its balance change stayed unchecked for`
+ ` ${Math.round((now - hold.since) / 60_000)}m — Cash History never came back; releasing it`);
+ ` ${Math.round((now - hold.since) / 60_000)}m — its Cash History is refused; releasing it`);
this.historyHolds.delete(accountId);
this.setBaseline(accountId, this.accountCashBalances[accountId] ?? null);
}
@@ -1576,7 +1609,9 @@ export class TradovateClient {
// A balance the last vouched-for baseline can't explain, with nothing
// booked by this report: a payout it hasn't caught up with yet.
const known = this.balanceBaseline[accountId];
// Judged against the baseline as it stood when the report was asked
// for: a fold the sync saw while it was in flight is not a payout.
const known = knownBefore ?? this.balanceBaseline[accountId];
if (known && before && cashExplained(known, before) === null && fundSigNow === fundSigBefore) {
this.hold(accountId, 'balance', fundSigBefore, now + 60_000);
console.warn(`${this.tag} ${this.accountName(accountId)}: balance moved $`