Trade only on loaded history; catch early TopstepX fills; fix SI/M2K point values

- Both clients track which accounts this process has loaded history for
  (historyReady). An account restarted, rebuilt, wiped or newly listed is
  ineligible until its fetch lands, instead of trading as a fresh stage-1
  first day; a failed fetch with no cache no longer reads as empty history.
  Waiting accounts get a targeted fetch on the next sync.
- Tradovate holds an account whose start-of-day balance moves without a fill
  (a payout between hourly Cash History runs) until a Cash History books it,
  so a payout-pending account isn't re-traded on its ended cycle.
- Wipe routes forget history through the client and count accounts whose
  history didn't come back as failures.
- TopstepX sendOrder picks up a fill that arrived before the place response,
  and its REST backstop uses the documented Trade/search and Order/search.
- POINT_VALUES: SI 5000 (was 50), M2K 5 (was 10).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-24 17:04:09 -05:00
co-authored by Claude Opus 5.5
parent 61384c9fa6
commit 77d8c2ecdb
7 changed files with 292 additions and 46 deletions
+131 -5
View File
@@ -103,6 +103,11 @@ const TOKEN_REUSE_MIN_MS = 5 * 60_000;
/** An order ack later than this is treated as lost (ambiguous — it may have been placed). */
const ACK_TIMEOUT_MS = 15_000;
const FILL_TIMEOUT_MS = 30_000;
/** How far the start-of-day balance may move without a fill before the
* account's history is treated as overtaken (balanceDrifted). Fills move
* amount and realizedPnL together, so this only has to absorb rounding;
* a payout is hundreds. */
const FUND_DRIFT_TOLERANCE = 25;
const CONTRACT_FIND_TIMEOUT_MS = 15_000;
/** On-demand front-month resolve (several Yahoo lookups, then Tradovate). */
const CONTRACT_RESOLVE_DEADLINE_MS = 30_000;
@@ -173,6 +178,18 @@ export class TradovateClient {
private lastDaysFetchStartedAt = 0;
private lastDaysFetchSucceededAt = 0;
private nextDaysFetchRetryAt = 0;
/** Accounts whose history this process has loaded: a report came back, or
* failed with a DB cache to fall back on. Until then the plan would read
* a fresh stage-1 cycle, so historyReady() keeps them out of trading. */
private historyLoadedIds = new Set<number>();
/** Accounts whose balance moved in a way the loaded history can't explain
* (a payout posting between the hourly reports): held out of trading
* until a Cash History books it. */
private historyRefreshIds = new Set<number>();
/** Per account, the start-of-day balance (amount − realizedPnL) the loaded
* history was taken at, and the day's realized P&L as last seen — what the
* daily reset folds into the balance. */
private balanceBaseline: { [accountId: number]: { dayStart: number; realized: 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;
@@ -854,6 +871,9 @@ export class TradovateClient {
this.consecutiveSocketFailures = 0;
console.log(`${this.tag} sync:${this.accountList.length} account(s), ${Object.keys(this.accountCashBalances).length} balance(s)`);
// Ahead of the fetch, so a balance change flagged here is
// fetched on this same pass.
this.observeBalances();
this.maybeFetchDaysTraded();
// The product list only feeds fetchInstrumentFees. Wait for it
@@ -897,20 +917,32 @@ export class TradovateClient {
!retryPending &&
this.lastDaysFetchSucceededAt > 0 &&
now - this.lastDaysFetchSucceededAt >= 60 * 60 * 1_000;
// Accounts held out of trading for want of history — new since the
// last pass, wiped, or flagged by a balance change — get their own
// fetch now rather than wait out the hour. A failure falls back to
// the 5-minute retry like any other.
const waiting = !initialFetchDue && !retryPending
? this.accountList
.filter((a) => !this.historyLoadedIds.has(a.id) || this.historyRefreshIds.has(a.id))
.map((a) => a.id)
: [];
const targeted = !retryDue && !hourlyRefreshDue && !initialFetchDue && waiting.length > 0;
if (!initialFetchDue && !retryDue && !hourlyRefreshDue) return;
if (!initialFetchDue && !retryDue && !hourlyRefreshDue && !targeted) return;
const reason = retryDue
? 'retry after failure'
: initialFetchDue
? 'initial sync'
: 'hourly refresh';
: hourlyRefreshDue
? 'hourly refresh'
: `history for ${waiting.length} waiting account(s)`;
this.daysFetchInFlight = true;
this.lastDaysFetchStartedAt = now;
if (retryDue) this.nextDaysFetchRetryAt = 0;
this.fetchDaysTraded()
this.fetchDaysTraded(targeted ? waiting : undefined)
.then(({ failedAccounts }) => {
if (failedAccounts > 0) {
this.nextDaysFetchRetryAt = Date.now() + 5 * 60 * 1_000;
@@ -918,7 +950,8 @@ export class TradovateClient {
return;
}
this.lastDaysFetchSucceededAt = Date.now();
// A partial pass doesn't restart the hourly clock.
if (!targeted) this.lastDaysFetchSucceededAt = Date.now();
})
.catch((err) => {
this.nextDaysFetchRetryAt = Date.now() + 5 * 60 * 1_000;
@@ -1178,6 +1211,7 @@ 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);
continue;
} catch {
// fall through to Fills fallback
@@ -1256,16 +1290,108 @@ export class TradovateClient {
this.lastFetchErrors[account.name] = `${msg} (using ${cached.length} cached entries from database)`;
console.log(`${this.tag} fetchDaysTraded: using ${cached.length} cached entries from DB for ${account.name}`);
} else {
this.dailyPnL[account.id] ??= [];
// No history at all is not an empty one: leave dailyPnL
// unset so the account stays out of trading until a fetch
// lands, instead of trading as a fresh stage-1 cycle.
this.daysTraded[account.id] ??= 0;
this.lastFetchErrors[account.name] = msg;
}
}
// The Fills fallback, or the DB cache after both reports failed.
this.markHistoryLoaded(account.id, false);
}
this.fetchDaysComplete = true;
return { failedAccounts };
}
/**
* Whether this account's history can be planned on — see PlatformClient.
* Loaded by this process, not wiped since, and no balance change the
* history can't explain.
*/
public historyReady(accountId: number): boolean {
if (!this.historyLoadedIds.has(accountId)) return false;
if (this.dailyPnL[accountId] === undefined || this.fundTransactions[accountId] === undefined) return false;
if (this.historyRefreshIds.has(accountId)) return false;
return !this.balanceDrifted(accountId);
}
/** Drop the account's history from memory (a wipe): it reads unloaded, and
* stays out of trading, until the next fetch rebuilds it. */
public forgetHistory(accountId: number, accountName: string): void {
this.historyLoadedIds.delete(accountId);
this.historyRefreshIds.delete(accountId);
delete this.balanceBaseline[accountId];
delete this.dailyPnL[accountId];
delete this.fullDailyPnL[accountId];
delete this.daysTraded[accountId];
delete this.priorProfit[accountId];
delete this.lastFundDates[accountId];
delete this.fundTransactions[accountId];
delete this.lastFetchErrors[accountName];
delete this.lastFetchRaw[accountName];
}
/**
* A fetch pass reached the account. Cash History is the only source that
* books fund transactions, so only it clears a balance-change hold; the
* Fills fallback and the DB cache still count as loaded otherwise.
*/
private markHistoryLoaded(accountId: number, fromCashHistory: boolean): void {
if (this.dailyPnL[accountId] === undefined || this.fundTransactions[accountId] === undefined) {
this.historyLoadedIds.delete(accountId);
return;
}
if (this.historyRefreshIds.has(accountId) && !fromCashHistory) return;
this.historyRefreshIds.delete(accountId);
this.historyLoadedIds.add(accountId);
const cash = this.accountCashBalances[accountId];
if (cash) this.balanceBaseline[accountId] = { dayStart: cash.amount - cash.realizedPnL, realized: cash.realizedPnL };
else delete this.balanceBaseline[accountId];
}
/**
* True when the balance moved in a way the loaded history can't explain —
* a payout or other fund transaction posting between the hourly Cash
* History runs. Planned on as is, the ended cycle's rows against the
* post-payout balance re-open a completed account. Today's trades move
* amount and realizedPnL together, and the daily reset folds the day's
* realized P&L into the balance; any other move past a few dollars holds
* the account until a Cash History books it (maybeFetchDaysTraded fetches
* it on the next sync).
*/
private balanceDrifted(accountId: number): boolean {
const cash = this.accountCashBalances[accountId];
if (!cash) return false;
const dayStart = cash.amount - cash.realizedPnL;
const base = this.balanceBaseline[accountId];
if (!base) {
this.balanceBaseline[accountId] = { dayStart, realized: cash.realizedPnL };
return false;
}
if (Math.abs(dayStart - base.dayStart) <= FUND_DRIFT_TOLERANCE) {
base.realized = cash.realizedPnL;
return false;
}
if (Math.abs(dayStart - (base.dayStart + base.realized)) <= FUND_DRIFT_TOLERANCE) {
this.balanceBaseline[accountId] = { dayStart, realized: cash.realizedPnL };
return false;
}
if (!this.historyRefreshIds.has(accountId)) {
this.historyRefreshIds.add(accountId);
const name = this.accountList.find((a) => a.id === accountId)?.name ?? accountId;
console.warn(`${this.tag} ${name}: balance moved $${Math.round((dayStart - base.dayStart) * 100) / 100}`
+ ' that its history does not explain — held out of trading until Cash History refreshes');
}
return true;
}
/** Run the drift check on every loaded account each sync, so the day's
* last realized P&L is on record before the daily reset folds it in. */
private observeBalances(): void {
for (const accountId of this.historyLoadedIds) this.balanceDrifted(accountId);
}
private async login(pTicket?: string): Promise<LoginResponseBody> {
const chl = '' + (Date.now() - 1581e9);
const sec = computeSec(this.name, this.password, this.deviceId, chl);