diff --git a/lib/tradovate-class.ts b/lib/tradovate-class.ts index ad2949b..6d8e31a 100644 --- a/lib/tradovate-class.ts +++ b/lib/tradovate-class.ts @@ -40,8 +40,11 @@ export class TradovateClient { /** True once fetchDaysTraded() has finished its last full run */ public fetchDaysComplete = false; - /** NodeJS.Timeout handle for the hourly dailyPnL refresh */ - private daysFetchInterval: ReturnType | null = null; + /** Prevent overlapping report fetches and throttle them to hourly cadence with retry backoff. */ + private daysFetchInFlight = false; + private lastDaysFetchStartedAt = 0; + private lastDaysFetchSucceededAt = 0; + private nextDaysFetchRetryAt = 0; /** Interval handles tracked so they can be cleared on reconnect */ private syncInterval: ReturnType | null = null; private heartbeatInterval: ReturnType | null = null; @@ -315,14 +318,7 @@ export class TradovateClient { console.log(`[requestSync] ${this.accountList.length} account(s), ${Object.keys(this.accountCashBalances).length} balance(s)`); - this.fetchDaysTraded(); - - // Refresh daily P&L once per hour — avoids hammering the reports API - if (this.daysFetchInterval) clearInterval(this.daysFetchInterval); - this.daysFetchInterval = setInterval( - () => this.fetchDaysTraded().catch((err) => console.error('[hourly fetchDaysTraded]', err)), - 60 * 60 * 1_000 - ); + this.maybeFetchDaysTraded(); if (this.products.length > 0) { this.syncComplete = true; @@ -347,9 +343,53 @@ export class TradovateClient { this.ws.send('user/syncrequest\n3\n\n{"splitResponses":false}'); } - public async fetchDaysTraded(): Promise { - if (!this.accessInfo?.accessToken) return; + private maybeFetchDaysTraded(): void { + if (this.daysFetchInFlight) return; + + const now = Date.now(); + const initialFetchDue = this.lastDaysFetchStartedAt === 0; + const retryPending = this.nextDaysFetchRetryAt > 0; + const retryDue = retryPending && now >= this.nextDaysFetchRetryAt; + const hourlyRefreshDue = + !retryPending && + this.lastDaysFetchSucceededAt > 0 && + now - this.lastDaysFetchSucceededAt >= 60 * 60 * 1_000; + + if (!initialFetchDue && !retryDue && !hourlyRefreshDue) return; + + const reason = retryDue + ? 'retry after failure' + : initialFetchDue + ? 'initial sync' + : 'hourly refresh'; + + this.daysFetchInFlight = true; + this.lastDaysFetchStartedAt = now; + if (retryDue) this.nextDaysFetchRetryAt = 0; + + this.fetchDaysTraded() + .then(({ failedAccounts }) => { + if (failedAccounts > 0) { + this.nextDaysFetchRetryAt = Date.now() + 5 * 60 * 1_000; + console.warn(`[fetchDaysTraded] ${failedAccounts} account(s) failed during ${reason} — retrying in 5 minutes`); + return; + } + + this.lastDaysFetchSucceededAt = Date.now(); + }) + .catch((err) => { + this.nextDaysFetchRetryAt = Date.now() + 5 * 60 * 1_000; + console.error(`[fetchDaysTraded] ${reason} failed — retrying in 5 minutes`, err); + }) + .finally(() => { + this.daysFetchInFlight = false; + }); + } + + public async fetchDaysTraded(): Promise<{ failedAccounts: number }> { + if (!this.accessInfo?.accessToken) return { failedAccounts: 0 }; this.fetchDaysComplete = false; + let failedAccounts = 0; const authHeaders = { Authorization: `Bearer ${this.accessInfo.accessToken}` }; @@ -533,6 +573,7 @@ export class TradovateClient { } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error(`[fetchDaysTraded] both reports failed for ${account.name}:`, msg); + failedAccounts++; // Cache already loaded and filtered at top of loop — just log the error if (cached.length > 0) { this.lastFetchErrors[account.name] = `${msg} (using ${cached.length} cached entries from database)`; @@ -545,6 +586,7 @@ export class TradovateClient { } } this.fetchDaysComplete = true; + return { failedAccounts }; } private async login(): Promise { @@ -798,7 +840,8 @@ export class TradovateClient { if (this.syncInterval) { clearInterval(this.syncInterval); this.syncInterval = null; } if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); this.heartbeatInterval = null; } if (this.tokenRenewalInterval) { clearInterval(this.tokenRenewalInterval); this.tokenRenewalInterval = null; } - if (this.daysFetchInterval) { clearInterval(this.daysFetchInterval); this.daysFetchInterval = null; } + this.daysFetchInFlight = false; + this.nextDaysFetchRetryAt = 0; try { this.ws?.close(); } catch { /* ignore */ } }