History holds: only a booked fund transaction lifts them; pace, persist, and cover wipes
Follow-up to77d8c2efrom its adversarial review: - Stop-after-all no longer shuts down before an open while histories are still loading (a regression in77d8c2e): 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:
co-authored by
Claude Opus 5.5
parent
e49640498a
commit
9b2d677662
+44
-4
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user