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
+44 -4
View File
@@ -236,6 +236,15 @@ export class TopstepXClient implements PlatformClient {
* is the firm's first pass; this is per account, so one that appears
* later, or is wiped, stays out of trading until its own fetch lands. */
private historyLoadedIds = new Set<number>();
/** Fetched, but not yet past a sync that reconciled its funds. TopstepX
* has no funds API: reconcileFunds, inside requestSync, is what books a
* withdrawal from the balance the trades don't explain, so a history
* 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>();
/** 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>();
/** Trades seen for the current CT trade date, per account — realizedPnL is
* recomputed from these on every REST sync and every live trade event. */
@@ -877,6 +886,9 @@ export class TopstepXClient implements PlatformClient {
} catch (err) {
console.error(`[topstepx] fund reconcile failed for ${acc.name}: ${errorMessage(err)}`);
}
// A freshly fetched history is tradable once a fresh pass has
// had its go at booking the funds behind the balance.
if (accountFresh && this.reconcilePendingIds.delete(acc.id)) this.historyLoadedIds.add(acc.id);
try {
this.updateMllFloor(acc);
@@ -1106,7 +1118,11 @@ export class TopstepXClient implements PlatformClient {
// last pass, or wiped) get their own fetch now rather than wait out
// the hour. A failure falls back to the 5-minute retry.
const waiting = !initialFetchDue && !retryPending
? this.accountList.filter((a) => !this.historyLoadedIds.has(a.id)).map((a) => a.id)
? this.accountList
.filter((a) => !this.historyLoadedIds.has(a.id)
&& !this.reconcilePendingIds.has(a.id)
&& !this.fetchingIds.has(a.id))
.map((a) => a.id)
: [];
const targeted = !retryDue && !hourlyRefreshDue && !initialFetchDue && waiting.length > 0;
@@ -1147,6 +1163,18 @@ export class TopstepXClient implements PlatformClient {
* Tradovate's Cash History. Merged over the DB cache so history older than
* the fetch window survives. */
public async fetchDaysTraded(onlyAccountIds?: number[]): Promise<{ failedAccounts: number }> {
const ids = this.accountList
.filter((a) => !onlyAccountIds || onlyAccountIds.includes(a.id))
.map((a) => a.id);
for (const id of ids) this.fetchingIds.add(id);
try {
return await this.fetchDaysTradedPass(onlyAccountIds);
} finally {
for (const id of ids) this.fetchingIds.delete(id);
}
}
private async fetchDaysTradedPass(onlyAccountIds?: number[]): Promise<{ failedAccounts: number }> {
if (!this.token) return { failedAccounts: 0 };
this.fetchDaysComplete = false;
let failedAccounts = 0;
@@ -1216,7 +1244,10 @@ export class TopstepXClient implements PlatformClient {
this.applyCycleSlice(account.id);
saveDailyPnL(account.id, account.name, fresh);
delete this.lastFetchErrors[account.name];
this.historyLoadedIds.add(account.id);
// One not loaded before (new, wiped, failed) is tradable after
// the next sync reconciles its funds (requestSync); a refresh
// of a loaded one keeps the funds it already has.
if (!this.historyLoadedIds.has(account.id)) this.reconcilePendingIds.add(account.id);
this.updateMllFloor(account);
} catch (err) {
const msg = errorMessage(err);
@@ -1238,6 +1269,12 @@ export class TopstepXClient implements PlatformClient {
}
}
this.fetchDaysComplete = true;
// The accounts just fetched wait on a reconciling sync: run one now
// rather than leave them out for up to a minute. (The firm's first
// pass syncs from maybeFetchDaysTraded once readiness allows.)
if (this.reconcilePendingIds.size > 0 && this.historyLoaded && !this.intentionalDisconnect) {
void this.requestSync();
}
return { failedAccounts };
}
@@ -1254,6 +1291,7 @@ export class TopstepXClient implements PlatformClient {
* stays out of trading, until the next fetch rebuilds it. */
public forgetHistory(accountId: number, accountName: string): void {
this.historyLoadedIds.delete(accountId);
this.reconcilePendingIds.delete(accountId);
delete this.dailyPnL[accountId];
delete this.fullDailyPnL[accountId];
delete this.daysTraded[accountId];
@@ -1421,7 +1459,9 @@ export class TopstepXClient implements PlatformClient {
console.log(`[topstepx] order ${orderId} filled @ ${early} (before the place response)`);
return { orderId, price: early, accountId, contractId: contract.id };
}
const since = new Date(Date.now() - 60_000).toISOString();
// Wide on purpose: the window is the local clock's, and a PC clock a
// few minutes off would otherwise search past the fill.
const since = new Date(Date.now() - 15 * 60_000).toISOString();
return new Promise((resolve, reject) => {
let settled = false;
@@ -1510,7 +1550,7 @@ export class TopstepXClient implements PlatformClient {
/** The same, from the Gateway's trade history (REST). */
private async searchOrderFillPrice(accountId: number, orderId: number, since: string): Promise<number | null> {
const res = await this.post<{ trades?: TsxTrade[] }>('/api/Trade/search', {
accountId, startTimestamp: since, endTimestamp: new Date(Date.now() + 60_000).toISOString(),
accountId, startTimestamp: since, endTimestamp: new Date(Date.now() + 15 * 60_000).toISOString(),
});
return firstFillPrice((res.trades ?? []).filter((t) => t.orderId === orderId));
}