Remove broker session rest

Reverts 062ec07 and 5eb0ea7: every firm's broker session stays logged in
around the clock again, renewing its token on the fixed timer as before.
The tree is back to 6dd2528's. A session_rest row left in settings is
unused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-10-21 23:39:53 -05:00
co-authored by Claude Opus 5.5
parent 062ec07f0a
commit 5ec5abde6e
19 changed files with 43 additions and 901 deletions
+2 -306
View File
@@ -14,10 +14,8 @@ import { applyBrokerHttpDefaults, applyBrokerProxy } from './outbound-proxy';
import axios from './http-client';
import { startMasterLink } from './master-link';
import { startUpdaterWatchdog } from './updater-watchdog';
import { getSchedulerStatus, nextSessionOpenFor, resumeSchedulerIfPersisted, sessionDemand, type SessionDemand } from './auto-trade';
import { resumeSchedulerIfPersisted } from './auto-trade';
import { superviseQuotes, SUPERVISE_INTERVAL_MS, type QuoteClient } from './live-quotes';
import { getSetting } from './db';
import { drawBetween, planRest, REST_LINGER_MAX_MS, REST_LINGER_MIN_MS } from './trading-logic';
// Use global to persist the client pool across HMR reloads in dev mode.
// Keyed by the master's firm id, which is a uuid string.
@@ -37,10 +35,6 @@ const g = global as typeof globalThis & {
__clientSupervisorTimer?: ReturnType<typeof setInterval>;
__clientSupervision?: Map<string, SupervisionEntry>;
__quoteSupervisorTimer?: ReturnType<typeof setInterval>;
__sessionGovernorTimer?: ReturnType<typeof setInterval>;
__sessionRest?: Map<string, RestEntry>;
__sessionIdle?: Map<string, IdleEntry>;
__sessionWaking?: Map<string, WakeEntry>;
};
function ensureMap(): Map<string, PlatformClient> {
@@ -228,9 +222,6 @@ export function getClients(): Map<string, PlatformClient> {
// Rebuild any client that has been wedged for too long.
startClientSupervisor();
// Log idle sessions out, and back in before they're needed.
startSessionGovernor();
// Live quotes off one futures client, for the spread every entry
// pays and the Auto draw.
startQuoteSupervisor();
@@ -300,13 +291,6 @@ function triggerContractResolve(): void {
try { authenticated = c.health().authenticated; } catch { /* no health: trust the token */ }
if (authenticated) { accessToken = token; break; }
}
if (!accessToken && tradovateAllResting(map)) {
// Nothing to resolve with until one logs back in, and every wake
// resolves (wakeFirm) — no half hour of retries meanwhile.
console.log('[contract-resolver] Tradovate sessions are resting — resolving at the next login');
g.__contractResolveRetries = 0;
return;
}
if (!accessToken) {
const retries = (g.__contractResolveRetries ?? 0) + 1;
if (retries > CONTRACT_RESOLVE_MAX_RETRIES) {
@@ -343,19 +327,6 @@ function triggerContractResolve(): void {
}
}
/** Every Tradovate client is a resting snapshot (and there is at least one). */
function tradovateAllResting(map: Map<string, PlatformClient>): boolean {
let resting = 0;
for (const [id, c] of map) {
let platform: string | undefined;
try { platform = c.health().platform; } catch { /* unknown: count it awake */ }
if (platform !== 'tradovate') continue;
if (!isResting(id)) return false;
resting++;
}
return resting > 0;
}
function scheduleDailyResolve(): void {
// Clear any existing timer (HMR safety)
if (g.__contractResolverTimer) clearInterval(g.__contractResolverTimer);
@@ -436,8 +407,7 @@ function superviseLiveQuotes(): void {
const map = ensureMap();
const candidates = getTradableFirms().filter(isFuturesFirm).flatMap((firm) => {
const client = map.get(firm.id);
// A resting session holds no token; its feed (if it had one) drops.
return client && !isResting(firm.id) ? [{ firmId: firm.id, firmName: firm.name, platform: firm.platform, client: client as unknown as QuoteClient }] : [];
return client ? [{ firmId: firm.id, firmName: firm.name, platform: firm.platform, client: client as unknown as QuoteClient }] : [];
});
superviseQuotes(candidates, resolveRootsHere())
.catch((err) => console.error(`[quotes] supervision failed: ${errorMessage(err)}`));
@@ -529,12 +499,6 @@ function superviseClients(): void {
entry.unhealthySince = 0;
}
// Logged out on purpose: rebuilding it would wake it.
if (isResting(id)) {
entry.unhealthySince = 0;
continue;
}
if (isClientReady(client, now)) {
entry.unhealthySince = 0;
continue;
@@ -577,271 +541,3 @@ function superviseClients(): void {
console.error(`[clients] supervisor tick failed: ${errorMessage(err)}`);
}
}
// ── Session rest ─────────────────────────────────────────────────────────────
/**
* Logs a firm's broker session out while nothing needs it, and back in before
* something does.
*
* A session used to stay up from boot to shutdown: logged in all night and all
* weekend, renewing its token on a timer throughout. Now a firm that has been
* idle for a random 15–45 minutes — nothing held, nothing pending, nothing
* left to trade before its next session (sessionDemand in lib/auto-trade.ts)
* — has its client disconnected: the client keeps its last snapshot for the
* dashboards and the report, but holds no token and runs no timers. A fresh
* client logs in a random 20–50 minutes before the firm's next session opens
* (planRest), on the same path a restart takes. A long rest is broken by a
* check-in every few hours of the Central daytime, which refreshes balances
* and finds new accounts: one that can still trade keeps the session up. A
* command that needs the firm — trading started, a manual leg, a copy, an
* AutoBuyer run — wakes it at once.
*
* Trading never sees a resting firm (isSessionAway, in firmClients): it holds
* nothing, so it neither trades nor gates, and its lane doesn't wait for it.
* A woken one stays out of sight until its first good sync (WAKE_GRACE_MS at
* most), so a check-in never holds up the firms still trading.
*
* Setting session_rest = '0' keeps every session up, as before.
*/
const GOVERNOR_INTERVAL_MS = 60_000;
const WAKE_GRACE_MS = 15 * 60_000;
interface RestEntry {
/** The disconnected client, left in the pool as a snapshot. */
client: PlatformClient;
since: number;
/** When its next session wants it back (null: only a command or a check-in will). */
wakeAt: number | null;
checkInAt: number | null;
/** How long before its next session's open it logs back in (drawn per rest). */
leadMs: number;
/** The trade date whose accounts were all done, or null: the market was closed. */
afterTradeDate: string | null;
/** What wakeAt was planned from: the Central day (its open delay may be
* drawn later) and whether the scheduler was running. */
plannedDay: string;
plannedRunning: boolean;
}
interface IdleEntry {
client: PlatformClient;
since: number;
lingerMs: number;
}
interface WakeEntry {
client: PlatformClient;
since: number;
}
const CENTRAL_DAY = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Chicago' });
const CENTRAL_TIME = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Chicago', weekday: 'short', hour: 'numeric', minute: '2-digit',
});
/** "Mon 6:12 PM CT". */
export function fmtCentralTime(ms: number): string {
return `${CENTRAL_TIME.format(ms)} CT`;
}
function restMap(): Map<string, RestEntry> {
if (!g.__sessionRest) g.__sessionRest = new Map();
return g.__sessionRest;
}
function idleMap(): Map<string, IdleEntry> {
if (!g.__sessionIdle) g.__sessionIdle = new Map();
return g.__sessionIdle;
}
function wakingMap(): Map<string, WakeEntry> {
if (!g.__sessionWaking) g.__sessionWaking = new Map();
return g.__sessionWaking;
}
export function sessionRestEnabled(): boolean {
return getSetting('session_rest') !== '0';
}
/** The firm's session is resting: its client is a disconnected snapshot. */
export function isResting(firmId: string): boolean {
const entry = g.__sessionRest?.get(firmId);
return !!entry && g.__tradovateClients?.get(firmId) === entry.client;
}
/** Logging back in after a rest, and not synced yet. */
export function isWaking(firmId: string, now = Date.now()): boolean {
const entry = g.__sessionWaking?.get(firmId);
if (!entry) return false;
if (g.__tradovateClients?.get(firmId) !== entry.client
|| isClientReady(entry.client, now)
|| now - entry.since >= WAKE_GRACE_MS) {
g.__sessionWaking?.delete(firmId);
return false;
}
return true;
}
/** Out of trading's sight: resting, or not yet back from a rest. */
export function isSessionAway(firmId: string, now = Date.now()): boolean {
return isResting(firmId) || isWaking(firmId, now);
}
/** A resting firm's rest, for /api/health and the dashboards; null when awake. */
export function sessionRestInfo(firmId: string): { since: number; wakeAt: number | null; checkInAt: number | null } | null {
if (!isResting(firmId)) return null;
const entry = restMap().get(firmId)!;
return { since: entry.since, wakeAt: entry.wakeAt, checkInAt: entry.checkInAt };
}
/** When a resting firm logs back in by itself (its wake or its check-in,
* whichever comes first); null when awake or resting until a command. */
export function restingWakeAt(firmId: string): number | null {
const info = sessionRestInfo(firmId);
if (!info) return null;
const times = [info.wakeAt, info.checkInAt].filter((t): t is number => t !== null);
return times.length > 0 ? Math.min(...times) : null;
}
/** Log a resting firm back in now. True if it was resting. */
export function wakeFirm(firmId: string, why: string): boolean {
const resting = isResting(firmId);
restMap().delete(firmId);
if (!resting) return false;
idleMap().delete(firmId);
const firm = getTradableFirms().find((f) => f.id === firmId);
if (!firm) return false; // no longer tradable: reconcileClients drops it
console.log(`[sessions] ${firm.name}: logging back in — ${why}`);
removeClient(firmId);
const client = initClient(firm.id, firm.name, firm.platform, firm);
wakingMap().set(firmId, { client, since: Date.now() });
// A rest can span the midnight contract resolve; catch up once logged in.
if (firm.platform === 'tradovate') setTimeout(() => triggerContractResolve(), 15_000);
return true;
}
/** Log every resting firm back in now; how many there were. */
export function wakeAllFirms(why: string): number {
let woken = 0;
for (const id of [...restMap().keys()]) {
if (wakeFirm(id, why)) woken++;
}
return woken;
}
function startSessionGovernor(): void {
if (g.__sessionGovernorTimer) clearInterval(g.__sessionGovernorTimer);
g.__sessionGovernorTimer = setInterval(governSessions, GOVERNOR_INTERVAL_MS);
}
function governSessions(): void {
// Runs inside setInterval: nothing may escape.
try {
const map = ensureMap();
const now = Date.now();
// Clients replaced or dropped since (a credentials change, a firm gone).
for (const [id, entry] of [...restMap()]) if (map.get(id) !== entry.client) restMap().delete(id);
for (const [id, entry] of [...idleMap()]) if (map.get(id) !== entry.client) idleMap().delete(id);
if (!sessionRestEnabled()) {
idleMap().clear();
if (restMap().size > 0) wakeAllFirms('session rest switched off');
return;
}
for (const firm of getTradableFirms()) {
const client = map.get(firm.id);
if (!client) continue;
try {
const rest = restMap().get(firm.id);
if (rest) governResting(firm, rest, now);
else governAwake(firm, client, now);
} catch (err) {
console.error(`[sessions] ${firm.name}: check failed: ${errorMessage(err)}`);
}
}
} catch (err) {
console.error(`[sessions] governor tick failed: ${errorMessage(err)}`);
}
}
type GovernedFirm = ReturnType<typeof getTradableFirms>[number];
function governResting(firm: GovernedFirm, rest: RestEntry, now: number): void {
if (rest.wakeAt !== null && now >= rest.wakeAt) {
wakeFirm(firm.id, 'its next session opens soon');
return;
}
if (rest.checkInAt !== null && now >= rest.checkInAt) {
wakeFirm(firm.id, 'check-in');
return;
}
// Plan again once the Central day turns (its open delay is drawn then)
// or the scheduler starts or stops.
const running = getSchedulerStatus().running;
const day = CENTRAL_DAY.format(now);
if (day === rest.plannedDay && running === rest.plannedRunning) return;
rest.plannedDay = day;
rest.plannedRunning = running;
const opensAt = running ? nextSessionOpenFor(firm, now, rest.afterTradeDate) : null;
const wakeAt = opensAt === null ? null : Math.max(now, opensAt - rest.leadMs);
if (wakeAt === rest.wakeAt) return;
rest.wakeAt = wakeAt;
if (wakeAt !== null && rest.checkInAt !== null && rest.checkInAt >= wakeAt) rest.checkInAt = null;
console.log(`[sessions] ${firm.name}: next login ${wakeAt !== null ? `moved to ${fmtCentralTime(wakeAt)}` : 'when trading starts'}`);
}
function governAwake(firm: GovernedFirm, client: PlatformClient, now: number): void {
const demand = sessionDemand(firm, client, now);
if (demand.needed) {
idleMap().delete(firm.id);
return;
}
const idle = idleMap().get(firm.id);
if (!idle) {
idleMap().set(firm.id, {
client, since: now, lingerMs: Math.round(drawBetween(REST_LINGER_MIN_MS, REST_LINGER_MAX_MS)),
});
return;
}
if (now - idle.since < idle.lingerMs || demand.wait) return;
const opensAt = demand.scheduled ? nextSessionOpenFor(firm, now, demand.afterTradeDate) : null;
// Scheduled, with nothing opening in a week: something is off — stay up.
if (demand.scheduled && opensAt === null) return;
const plan = planRest(now, opensAt);
if (!plan) return; // its session opens too soon for a rest to be worth a login
idleMap().delete(firm.id);
restClient(firm, client, plan, demand, now);
}
function restClient(
firm: GovernedFirm,
client: PlatformClient,
plan: NonNullable<ReturnType<typeof planRest>>,
demand: SessionDemand,
now: number,
): void {
try {
client.disconnect();
} catch (err) {
// Half torn down is still not trading; the wake builds a fresh one.
console.error(`[sessions] ${firm.name}: disconnect failed: ${errorMessage(err)}`);
}
restMap().set(firm.id, {
client,
since: now,
wakeAt: plan.wakeAt,
checkInAt: plan.checkInAt,
leadMs: plan.leadMs,
afterTradeDate: demand.afterTradeDate,
plannedDay: CENTRAL_DAY.format(now),
plannedRunning: demand.scheduled,
});
const why = demand.afterTradeDate ? `every account is done for ${demand.afterTradeDate}`
: demand.scheduled ? 'its market is closed' : 'trading is stopped';
const back = plan.wakeAt !== null ? `back at ${fmtCentralTime(plan.wakeAt)}` : 'back when trading starts';
const checkIn = plan.checkInAt !== null ? `, check-in ${fmtCentralTime(plan.checkInAt)}` : '';
console.log(`[sessions] ${firm.name}: idle (${why}) — logged out; ${back}${checkIn}`);
}