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:
co-authored by
Claude Opus 5.5
parent
61384c9fa6
commit
77d8c2ecdb
+131
-5
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user