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
+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 };
}