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
+5
View File
@@ -479,6 +479,11 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a
if (combined.realizedPnL !== 0) {
return { ...evaluated, eligible: false, reason: `already traded today (realizedPnL=${combined.realizedPnL})` };
}
// And any leg this process fired today, whatever the broker's
// realized P&L says (a record replaced under it, a fill not booked yet).
if (legsToday(acc.id, tradeDate) > 0) {
return { ...evaluated, eligible: false, reason: 'already traded today (leg fired)' };
}
if (!isExtraDay && plan.remainingToday <= 0) {
const reason = effective.drain && plan.dayTarget === 0
? 'drain days booked — waiting for withdrawal'
+19 -5
View File
@@ -242,6 +242,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<number>();
/** Settles when the sync in flight ends (requestSync hands it to a caller
* that arrives mid-pass). */
private syncRunning: Promise<void> = Promise.resolve();
/** Accounts a fetchDaysTraded call is working through right now, so the
* targeted pass doesn't fetch them again (a wipe fetches outside it). */
private fetchingIds = new Set<number>();
@@ -812,13 +815,16 @@ export class TopstepXClient implements PlatformClient {
const startedAt = Date.now();
if (this.syncInFlight) {
const runningMs = startedAt - this.syncInFlightSince;
if (runningMs < SYNC_WEDGED_MS) return;
// A caller that awaits gets the running pass's end.
if (runningMs < SYNC_WEDGED_MS) return this.syncRunning;
console.warn(`[topstepx] sync running for ${Math.round(runningMs / 1000)}s — looks wedged, starting a fresh one`);
}
const gen = ++this.syncGeneration;
const superseded = () => gen !== this.syncGeneration || this.intentionalDisconnect;
this.syncInFlight = true;
this.syncInFlightSince = startedAt;
let finished!: () => void;
this.syncRunning = new Promise<void>((resolve) => { finished = resolve; });
try {
const accRes = await this.post<{ accounts: { id: number; name: string; balance: number; canTrade: boolean; isVisible: boolean }[] }>(
'/api/Account/search', { onlyActiveAccounts: false });
@@ -890,7 +896,8 @@ export class TopstepXClient implements PlatformClient {
// A freshly fetched history is tradable once a fresh pass has
// actually checked the funds behind the balance (a skipped
// check — a position still open — waits for a later pass).
if (reconciled && this.reconcilePendingIds.delete(acc.id)) this.historyLoadedIds.add(acc.id);
const ours = Boolean(getAccountConfig(acc.id).accountType);
if ((reconciled || !ours) && this.reconcilePendingIds.delete(acc.id)) this.historyLoadedIds.add(acc.id);
try {
this.updateMllFloor(acc);
@@ -931,6 +938,7 @@ export class TopstepXClient implements PlatformClient {
this.syncInFlight = false;
this.syncInFlightSince = 0;
}
finished();
}
}
@@ -1264,7 +1272,10 @@ export class TopstepXClient implements PlatformClient {
this.dailyFees[account.id] = new Map(cached.map((e) => [e.date, e.fees ?? 0]));
this.applyCycleSlice(account.id);
this.lastFetchErrors[account.name] = `${msg} (using ${cached.length} cached entries from database)`;
this.historyLoadedIds.add(account.id);
// Not loaded before: no funds check has run on this cache,
// and none will while the fetch error stands — so it waits
// for a fetch that lands (reconcileFunds, requestSync).
if (!this.historyLoadedIds.has(account.id)) this.reconcilePendingIds.add(account.id);
} else {
// No history at all is not an empty one: the account
// stays out of trading until a fetch lands, instead of
@@ -1279,8 +1290,11 @@ export class TopstepXClient implements PlatformClient {
// rather than leave them out for up to a minute, and before returning,
// so a wipe route reads the result. (The firm's first pass syncs from
// maybeFetchDaysTraded once readiness allows.)
if (this.reconcilePendingIds.size > 0 && this.historyLoaded && !this.intentionalDisconnect) {
await this.requestSync();
const waiting = () => this.accountList.some((a) => this.reconcilePendingIds.has(a.id)
&& (!onlyAccountIds || onlyAccountIds.includes(a.id)));
if (waiting() && this.historyLoaded && !this.intentionalDisconnect) {
await this.requestSync(); // a pass already running may have gone past them
if (waiting()) await this.requestSync();
}
return { failedAccounts };
}
+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 $`