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
+100
View File
@@ -1789,6 +1789,106 @@ export function isPastSessionClose(now: Date, everyDay: boolean): boolean {
return minutes >= 14 * 60 + 55 && minutes < 17 * 60;
}
// ── broker session rest ──────────────────────────────────────────────────────
//
// A firm's broker session is logged out while nothing can trade on it (the
// weekend, the daily break, a trade date whose accounts are all done) and
// logged back in ahead of the next session. Every wait below is drawn at
// random, so neither the logins nor the renewals in between keep a clock.
// lib/clients.ts decides when a session is idle; these are the times.
/** A rested session logs back in this long before its next session opens. */
export const REST_LEAD_MIN_MS = 20 * 60_000;
export const REST_LEAD_MAX_MS = 50 * 60_000;
/** An idle session stays up this long before it rests. */
export const REST_LINGER_MIN_MS = 15 * 60_000;
export const REST_LINGER_MAX_MS = 45 * 60_000;
/** A rest shorter than this isn't worth a fresh login. */
export const REST_MIN_MS = 45 * 60_000;
/** A long rest is broken by a check-in this long after the last login, in
* the Central daytime: it refreshes balances and finds new accounts. */
export const CHECK_IN_GAP_MIN_MS = 3 * 60 * 60_000;
export const CHECK_IN_GAP_MAX_MS = 7 * 60 * 60_000;
const CHECK_IN_DAY_START_MIN = 8 * 60;
const CHECK_IN_DAY_END_MIN = 22 * 60;
/** A check-in that would fall this close to the wake is left out. */
const CHECK_IN_WAKE_MARGIN_MS = 2 * 60 * 60_000;
/** How far ahead nextSessionOpenAt looks. */
const NEXT_OPEN_HORIZON_MS = 8 * 24 * 60 * 60_000;
/** A uniform draw in [min, max). */
export function drawBetween(min: number, max: number, rand: () => number = Math.random): number {
return min + rand() * (max - min);
}
/**
* The first whole minute at or after `from` when the firm may open a new
* trade (isNoTradeTime false), or null if none within 8 days. With
* `afterTradeDate`, only a minute on a later trade date counts: that day's
* accounts are all done. `jitterFor` is the open delay for the Central day of
* a moment; give 0 for a day whose delay isn't drawn yet, the earliest it
* could open.
*/
export function nextSessionOpenAt(
from: number,
o: { everyDay: boolean; mode: string; jitterFor: (at: Date) => number },
afterTradeDate: string | null = null,
): number | null {
const opens = (t: number): boolean => {
const at = new Date(t);
if (afterTradeDate !== null && currentTradeDateCT(at) <= afterTradeDate) return false;
return !isNoTradeTime(at, { everyDay: o.everyDay, mode: o.mode, jitterMin: o.jitterFor(at) });
};
const STEP = 5 * 60_000;
let t = Math.ceil(from / 60_000) * 60_000;
if (opens(t)) return t;
const limit = t + NEXT_OPEN_HORIZON_MS;
// Sessions and breaks last hours, so a 5-minute stride can't step over
// one; the minute it opened is then found inside the stride.
for (; t < limit; t += STEP) {
if (!opens(t + STEP)) continue;
for (let m = t + 60_000; m < t + STEP; m += 60_000) if (opens(m)) return m;
return t + STEP;
}
return null;
}
/**
* When a check-in should break a rest that starts `now` and ends at `wakeAt`
* (null: no planned end), or null for none: 3–7 hours on, moved to 8–11 AM
* Central when that lands at night, and never within 2 hours of the wake.
*/
export function planCheckIn(now: number, wakeAt: number | null, rand: () => number = Math.random): number | null {
let at = now + drawBetween(CHECK_IN_GAP_MIN_MS, CHECK_IN_GAP_MAX_MS, rand);
const { minutes } = centralClock(new Date(at));
if (minutes < CHECK_IN_DAY_START_MIN || minutes >= CHECK_IN_DAY_END_MIN) {
const toMorning = ((CHECK_IN_DAY_START_MIN - minutes + 1440) % 1440) * 60_000;
at += toMorning + drawBetween(0, 3 * 60 * 60_000, rand);
// A clock change overnight puts the morning an hour off.
const landed = centralClock(new Date(at)).minutes;
if (landed < CHECK_IN_DAY_START_MIN) at += (CHECK_IN_DAY_START_MIN - landed) * 60_000;
}
if (wakeAt !== null && at > wakeAt - CHECK_IN_WAKE_MARGIN_MS) return null;
return Math.round(at);
}
/**
* A rest starting `now` for a session whose next trading opens at `opensAt`
* (null: nothing trades until someone starts it): when to log back in, a
* check-in on the way, and the lead drawn — or null when the open is too
* close for a rest to be worth a fresh login.
*/
export function planRest(
now: number,
opensAt: number | null,
rand: () => number = Math.random,
): { wakeAt: number | null; checkInAt: number | null; leadMs: number } | null {
const leadMs = Math.round(drawBetween(REST_LEAD_MIN_MS, REST_LEAD_MAX_MS, rand));
const wakeAt = opensAt === null ? null : opensAt - leadMs;
if (wakeAt !== null && wakeAt - now < REST_MIN_MS) return null;
return { wakeAt, checkInAt: planCheckIn(now, wakeAt, rand), leadMs };
}
/**
* A firm's cap on how many of its accounts trade at once, on top of the
* machine's own max concurrent accounts: keeps `items` in order, at most