From 52ed9c75e48d2e69f5f2dae4a12492d92abd7adc Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Thu, 24 Sep 2026 19:37:14 -0500 Subject: [PATCH] Roll window instead of a roll-time base; transient outages keep payout holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/auto-trade.ts | 5 +++ lib/topstepx-class.ts | 24 +++++++++--- lib/tradovate-class.ts | 89 +++++++++++++++++++++++++++++------------- 3 files changed, 86 insertions(+), 32 deletions(-) diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 25533e3..75a2fac 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -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' diff --git a/lib/topstepx-class.ts b/lib/topstepx-class.ts index 91193b8..20da9dc 100644 --- a/lib/topstepx-class.ts +++ b/lib/topstepx-class.ts @@ -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(); + /** Settles when the sync in flight ends (requestSync hands it to a caller + * that arrives mid-pass). */ + private syncRunning: Promise = 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(); @@ -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((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 }; } diff --git a/lib/tradovate-class.ts b/lib/tradovate-class.ts index da384a4..f700c91 100644 --- a/lib/tradovate-class.ts +++ b/lib/tradovate-class.ts @@ -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(); + /** 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(); /** Interval handles tracked so they can be cleared on reconnect */ private syncInterval: ReturnType | null = null; private heartbeatInterval: ReturnType | 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 $`