Broker sessions rest while nothing needs them

A firm's client used to stay logged in around the clock, renewing on a fixed
timer overnight and at weekends. Now, once a firm has nothing open, nothing
pending and nothing left to trade before its next session for a random
15–45 minutes, its client disconnects and the dashboard shows it Resting.
It logs back in a random 20–50 minutes before the next session opens, at a
check-in every 3–7 Central daytime hours, or at once when trading starts, a
manual leg, copy trade, manual order or AutoBuyer run needs it. Token
renewals fall at random points (Tradovate 15–45 min before expiry, TopstepX
every 45–150 min). Settings → Trading → Rest idle broker sessions
(session_rest) turns it off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-10-21 13:28:47 -05:00
co-authored by Claude Opus 5.5
parent 6dd2528fe2
commit 5eb0ea7760
18 changed files with 872 additions and 41 deletions
+36 -5
View File
@@ -65,6 +65,10 @@ const SESSION_STALE_MS = 10 * 60_000;
const LOGIN_TIMEOUT_MS = 30_000;
const VALIDATE_TIMEOUT_MS = 15_000;
const TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1000;
/** The token is re-validated after a wait drawn between these each time, so
* the renewals keep no fixed rhythm; any of them is far inside its 24h. */
const TOKEN_RENEW_MIN_MS = 45 * 60_000;
const TOKEN_RENEW_MAX_MS = 150 * 60_000;
/** Hard cap on every other REST call. axios's own timeout is a socket-idle
* timer that only starts once the socket has connected, so a proxy CONNECT
* that never answers hangs the call — and any single-flight flag it holds,
@@ -210,7 +214,7 @@ export class TopstepXClient implements PlatformClient {
private lastInboundAt = 0;
private syncInterval: ReturnType<typeof setInterval> | null = null;
private tokenRenewalInterval: ReturnType<typeof setInterval> | null = null;
private tokenRenewalTimer: ReturnType<typeof setTimeout> | null = null;
private ocoPollInterval: ReturnType<typeof setInterval> | null = null;
private loginRetryTimer: ReturnType<typeof setTimeout> | null = null;
private watchdogInterval: ReturnType<typeof setInterval> | null = null;
@@ -378,9 +382,8 @@ export class TopstepXClient implements PlatformClient {
console.log(`[topstepx] logged in as ${this.name}`);
delete this.lastFetchErrors['login'];
// Session tokens live 24h; re-validate every hour to stay fresh.
if (this.tokenRenewalInterval) clearInterval(this.tokenRenewalInterval);
this.tokenRenewalInterval = setInterval(() => void this.renewToken(), 60 * 60 * 1000);
// Session tokens live 24h; re-validate every hour or two to stay fresh.
this.scheduleRenewal();
if (!this.syncInterval) {
// First login. The hub is never a hard dependency: REST polling
@@ -475,6 +478,22 @@ export class TopstepXClient implements PlatformClient {
return data.token;
}
/** Arms the one renewal timer at a fresh random wait (replacing any
* pending); each renewal arms the next. disconnect() stops the chain. */
private scheduleRenewal(): void {
if (this.tokenRenewalTimer) clearTimeout(this.tokenRenewalTimer);
this.tokenRenewalTimer = null;
if (this.intentionalDisconnect) return;
const wait = TOKEN_RENEW_MIN_MS + Math.random() * (TOKEN_RENEW_MAX_MS - TOKEN_RENEW_MIN_MS);
this.tokenRenewalTimer = setTimeout(() => {
this.tokenRenewalTimer = null;
// A re-login it starts arms its own timer; otherwise arm the next here.
void this.renewToken().finally(() => {
if (!this.tokenRenewalTimer) this.scheduleRenewal();
});
}, wait);
}
private async renewToken(): Promise<void> {
if (this.intentionalDisconnect || this.loginInFlight) return;
if (!this.token) { void this.connectAndAuth(); return; }
@@ -1937,7 +1956,7 @@ export class TopstepXClient implements PlatformClient {
public disconnect(): void {
this.intentionalDisconnect = true;
if (this.syncInterval) { clearInterval(this.syncInterval); this.syncInterval = null; }
if (this.tokenRenewalInterval) { clearInterval(this.tokenRenewalInterval); this.tokenRenewalInterval = null; }
if (this.tokenRenewalTimer) { clearTimeout(this.tokenRenewalTimer); this.tokenRenewalTimer = null; }
if (this.ocoPollInterval) { clearInterval(this.ocoPollInterval); this.ocoPollInterval = null; }
if (this.loginRetryTimer) { clearTimeout(this.loginRetryTimer); this.loginRetryTimer = null; }
if (this.watchdogInterval) { clearInterval(this.watchdogInterval); this.watchdogInterval = null; }
@@ -1946,5 +1965,17 @@ export class TopstepXClient implements PlatformClient {
this.daysFetchInFlightSince = 0;
this.nextDaysFetchRetryAt = 0;
this.discardHub();
// Logged out for good: a snapshot left in the pool (session rest)
// must not be able to reach the gateway.
this.token = null;
}
/** See PlatformClient: what the session still waits on besides positions
* and working orders. */
public restBlocker(): string | null {
if (this.ocoPairs.some((p) => !p.done)) return 'an exit pair is still tracked';
if (this.fillWaiters.length > 0) return `${this.fillWaiters.length} fill(s) awaited`;
if (this.daysFetchInFlight) return 'a history fetch is underway';
return null;
}
}