From 5eb0ea7760d782ffc4e193218d5e517f7a349f19 Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Wed, 21 Oct 2026 13:28:33 -0500 Subject: [PATCH] Broker sessions rest while nothing needs them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 21 +- app/api/firms/[id]/orders/route.ts | 7 +- app/api/health/route.ts | 14 +- app/api/settings/route.ts | 2 +- app/api/state/route.ts | 8 +- app/page.tsx | 14 +- app/settings/page.tsx | 22 ++- lib/auto-trade.ts | 139 +++++++++++-- lib/clients.ts | 308 ++++++++++++++++++++++++++++- lib/dxtrade-class.ts | 9 + lib/platform-client.ts | 4 + lib/queue-run.ts | 4 + lib/topstepx-class.ts | 41 +++- lib/trading-logic.ts | 100 ++++++++++ lib/tradovate-class.ts | 44 ++++- package.json | 2 +- scripts/check-session-rest.mjs | 170 ++++++++++++++++ types.ts | 4 + 18 files changed, 872 insertions(+), 41 deletions(-) create mode 100644 scripts/check-session-rest.mjs diff --git a/README.md b/README.md index 8c5b5cf..2cb869d 100644 --- a/README.md +++ b/README.md @@ -184,8 +184,9 @@ It is read-only unless `DX_CONFIRM=yes DX_ACCOUNT=` are set too, which adds one minimum-size live round trip. `npm run check` runs the pure-logic checks (fees, stops, sizing, trading -hours, the DXtrade selector, the futures draw and instrument lists), plus local -HTTP/WebSocket checks against a temporary database. +hours, session rest times, the DXtrade selector, the futures draw and +instrument lists), plus local HTTP/WebSocket checks against a temporary +database. ### Outbound user agent @@ -203,6 +204,22 @@ Use `brokerWebSocket` or `signalrWebSocket` from `lib/outbound-proxy.ts` for bro sockets, and `directWebSocket` for control sockets. All share the same user agent; the broker proxy still applies only to broker traffic. +### Broker session rest + +A firm's broker session is logged out while nothing needs it, rather than kept +up around the clock (`lib/clients.ts`, session rest). Once a firm has nothing +open, nothing pending and nothing left to trade before its next session (the +weekend, the daily break, or a trade date whose accounts are all done) for a +random 15–45 minutes, its client disconnects. The dashboard shows it as +*Resting* with its accounts as last synced. A fresh login comes a random 20–50 +minutes before the firm's next session opens, and a long rest is broken by a +check-in every 3–7 hours of the Central daytime. Starting trading, a manual leg, +a copy trade, a manual order or an AutoBuyer run logs a resting firm back in at +once. While logged in, tokens are renewed at random points (Tradovate 15–45 +minutes before expiry, TopstepX every 45–150 minutes) rather than on a fixed +timer. Settings → Trading → *Rest idle broker sessions* (`session_rest`) turns +it off; `/api/health` lists each resting firm and when it is back. + --- ## The master dashboard diff --git a/app/api/firms/[id]/orders/route.ts b/app/api/firms/[id]/orders/route.ts index 3d05785..567ba15 100644 --- a/app/api/firms/[id]/orders/route.ts +++ b/app/api/firms/[id]/orders/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getCachedFirm } from '@/lib/firm-catalog'; import { firmInstruments, isFuturesFirm } from '@/lib/instruments'; -import { getClients } from '@/lib/clients'; +import { getClients, isWaking, wakeFirm } from '@/lib/clients'; export async function POST( req: NextRequest, @@ -27,6 +27,11 @@ export async function POST( const clients = getClients(); const client = clients.get(firmId); if (!client) return NextResponse.json({ error: 'Client not found for firm' }, { status: 404 }); + // A resting session (lib/clients.ts) holds no login: start one, and + // let the caller come back once it's up. + if (wakeFirm(firmId, 'manual order') || isWaking(firmId)) { + return NextResponse.json({ error: "the firm's session is logging back in — try again in a minute" }, { status: 503 }); + } // A futures firm trades only what its instrument list (master Firms // page) names. diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 64f0718..017ff1a 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import path from 'node:path'; import { NextResponse } from 'next/server'; -import { getClients } from '@/lib/clients'; +import { getClients, sessionRestEnabled, sessionRestInfo } from '@/lib/clients'; import { getTradableFirms } from '@/lib/firm-catalog'; import { getSchedulerHealth, getSchedulerStatus } from '@/lib/auto-trade'; import { getBuildVersion } from '@/lib/version'; @@ -41,20 +41,24 @@ export async function GET() { const entries = getTradableFirms().map((firm) => { const base = { firmId: firm.id, firm: firm.name, platform: firm.platform }; const client = clients.get(firm.id); - if (!client) return { ...base, hasClient: false, ready: false, accounts: 0 }; + if (!client) return { ...base, hasClient: false, ready: false, resting: null, accounts: 0 }; const ready = isClientReady(client, now); const accounts = client.accountList.length; + // Logged out on purpose while nothing needs it (lib/clients.ts): + // not ready, and nothing wrong. + const resting = sessionRestInfo(firm.id); try { - return { ...base, hasClient: true, ready, accounts, ...client.health() }; + return { ...base, hasClient: true, ready, resting, accounts, ...client.health() }; } catch (err) { // One client's broken health() must not hide the rest. - return { ...base, hasClient: true, ready, accounts, healthError: errorMessage(err) }; + return { ...base, hasClient: true, ready, resting, accounts, healthError: errorMessage(err) }; } }); return NextResponse.json({ - ok: entries.every((e) => !e.hasClient || e.ready), + ok: entries.every((e) => !e.hasClient || e.ready || !!e.resting), + sessionRest: sessionRestEnabled(), now, uptimeSec: Math.round(process.uptime()), version: getBuildVersion(), diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index ca8d50b..732d5bf 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -3,7 +3,7 @@ import { getSetting, setSetting } from '@/lib/db'; import { scheduleBackupPush } from '@/lib/backup-sync'; import { getRequestUserAgent } from '@/lib/request-user-agent'; -const VALID_KEYS = ['max_concurrent_accounts', 'tick_interval_seconds', 'master_dashboard_url', 'instance_name', 'trading_hours', 'twocaptcha_api_key', 'twocaptcha_proxy', 'master_backup_token', 'proxy_url'] as const; +const VALID_KEYS = ['max_concurrent_accounts', 'tick_interval_seconds', 'master_dashboard_url', 'instance_name', 'trading_hours', 'twocaptcha_api_key', 'twocaptcha_proxy', 'master_backup_token', 'proxy_url', 'session_rest'] as const; type SettingKey = typeof VALID_KEYS[number]; export async function GET() { diff --git a/app/api/state/route.ts b/app/api/state/route.ts index 03f0f0e..0fbef2c 100644 --- a/app/api/state/route.ts +++ b/app/api/state/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server'; import { listAssignments, loadDailyPnL } from '@/lib/db'; -import { getClients } from '@/lib/clients'; +import { getClients, restingWakeAt, sessionRestInfo } from '@/lib/clients'; import { getAccountTypeById, getCachedCatalog } from '@/lib/firm-catalog'; import { ruleDiffs } from '@/lib/kept-rules'; import { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/account-type-resolver'; @@ -27,8 +27,10 @@ export async function GET() { // A firm with no accounts is a real, settled state and must not be // reported the same way as one that is still authenticating. const connected = !!client && client.syncComplete; + const rest = sessionRestInfo(f.id); + const resting = rest ? { resting: { since: rest.since, wakeAt: restingWakeAt(f.id) } } : {}; if (!client || client.accountList.length === 0) { - return { firmId: f.id, firm: f.name, connected, accounts: [] }; + return { firmId: f.id, firm: f.name, connected, ...resting, accounts: [] }; } // Accounts whose rules date was set by hand — one read per firm // rather than one per account. @@ -314,7 +316,7 @@ export async function GET() { keptRules, }; }); - return { firmId: f.id, firm: f.name, connected, accounts }; + return { firmId: f.id, firm: f.name, connected, ...resting, accounts }; }); return NextResponse.json(state); diff --git a/app/page.tsx b/app/page.tsx index 2fd82be..2b473ac 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -526,7 +526,17 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo Username missing )} - {hasCredentials && !state.connected && ( + {hasCredentials && state.resting && ( + + {state.resting.wakeAt !== null + ? `Resting · back ${new Date(state.resting.wakeAt).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' })}` + : 'Resting until trading starts'} + + )} + {hasCredentials && !state.connected && !state.resting && ( Connecting… @@ -594,7 +604,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo state.accounts.length === 0 ? ( - {state.connected ? 'No accounts at this firm' : 'Connecting…'} + {state.connected || state.resting ? 'No accounts at this firm' : 'Connecting…'} ) : ( diff --git a/app/settings/page.tsx b/app/settings/page.tsx index bd8e2a3..5da3810 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -45,6 +45,7 @@ interface AppSettings { instance_name: string | null; instance_id: string | null; trading_hours: string | null; + session_rest: string | null; master_backup_token: string | null; proxy_url: string | null; user_agent: string | null; @@ -64,6 +65,7 @@ export default function SettingsPage() { const [contracts, setContracts] = useState>({}); const [maxConcurrent, setMaxConcurrent] = useState('5'); const [tradingHours, setTradingHours] = useState('full_cme'); + const [sessionRest, setSessionRest] = useState(true); const [masterUrl, setMasterUrl] = useState(''); const [catalog, setCatalog] = useState<{ version: number; @@ -114,6 +116,7 @@ export default function SettingsPage() { if (s.max_concurrent_accounts != null) { setMaxConcurrent(s.max_concurrent_accounts); } + setSessionRest(s.session_rest !== '0'); if (s.trading_hours != null) { setTradingHours(s.trading_hours); } @@ -161,7 +164,7 @@ export default function SettingsPage() { await fetch('/api/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ max_concurrent_accounts: maxConcurrent, trading_hours: tradingHours, master_dashboard_url: masterUrl, instance_name: instanceName, master_backup_token: backupToken, proxy_url: proxyUrl }), + body: JSON.stringify({ max_concurrent_accounts: maxConcurrent, trading_hours: tradingHours, session_rest: sessionRest ? '1' : '0', master_dashboard_url: masterUrl, instance_name: instanceName, master_backup_token: backupToken, proxy_url: proxyUrl }), }); setSaving(false); setSaved(true); @@ -280,6 +283,23 @@ export default function SettingsPage() { +
+
+

Rest idle broker sessions

+

+ Log a firm out once nothing is open and nothing is left to trade before its next + session (weekends, the daily break, a finished day), and back in 20–50 min before + that session opens, with a check-in every few daytime hours. Off keeps every + session logged in around the clock. +

+
+ setSessionRest(e.target.checked)} + className="h-4 w-4 shrink-0 accent-blue-500" + /> +
{/* ── Master Dashboard ── */} diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 29533df..257f9ab 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -7,14 +7,14 @@ * signal but have since exited and are now eligible. */ -import { getClients } from './clients'; +import { fmtCentralTime, getClients, isResting, isSessionAway, isWaking, restingWakeAt, wakeAllFirms, wakeFirm } from './clients'; import { getCachedFirm, getTradableFirms } from './firm-catalog'; import { ensureRulesEvidence, getAccountConfig } from './account-type-resolver'; import { combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT, obfuscationStopDollars, takeProfitPlan, onTick, percentFee, dllRoom, dllStopDollars, dllViable, stopTicks, expectedSwapToWin, planLegSize, maxLossRoom, breachDistance, - isNoTradeTime, isPastSessionClose, + isNoTradeTime, isPastSessionClose, nextSessionOpenAt, BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY, FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP, CME_EVENING_OPEN_MIN, foldReason, withinFirmCaps, @@ -345,6 +345,9 @@ function firmClients(now = Date.now()): { ready: FirmClient[]; unready: FirmClie for (const firm of getTradableFirms()) { const client = clients.get(firm.id); if (!client) continue; + // A session resting (or just back and not synced yet) holds nothing: + // it neither trades nor gates (lib/clients.ts, session rest). + if (isSessionAway(firm.id, now)) continue; (isClientReady(client, now) ? ready : unready).push({ firm, client }); } return { ready, unready }; @@ -570,6 +573,9 @@ interface AccountEvaluation { /** What the firm's day may still lose before a hard daily-loss breach * (Infinity = no limit). */ dllRoom: number; + /** Ineligible until the next trade date at the earliest, whatever the + * broker says meanwhile: the session can rest (lib/clients.ts). */ + doneForDay?: true; } /** @@ -690,8 +696,8 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a const daysTraded = combined.daysTraded; const base = { plan: null, isExtraDay: false, drain: false, cash, dailyPnL, daysTraded, dllRoom: Infinity }; - if (isAccountDead(cash.amount, autoLiqThreshold)) return { ...base, eligible: false, reason: 'dead' }; - if (!acc.active) return { ...base, eligible: false, reason: 'inactive (DLL)' }; + if (isAccountDead(cash.amount, autoLiqThreshold)) return { ...base, eligible: false, reason: 'dead', doneForDay: true }; + if (!acc.active) return { ...base, eligible: false, reason: 'inactive (DLL)', doneForDay: true }; if (client.positions[acc.id]) return { ...base, eligible: false, reason: 'already in trade' }; // Another batch is placing this account's order right now; its position // won't show until the fill comes back, so the check above can't see it. @@ -709,11 +715,11 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a if (cash.pendingRoll) return { ...base, eligible: false, reason: ROLL_PENDING }; if (unknownEntryHolds(client, acc.id, tradeDate)) return { ...base, eligible: false, reason: ENTRY_UNKNOWN }; if (runtime().exitRefused?.get(acc.id) === tradeDate) { - return { ...base, eligible: false, reason: 'exit refused — flattened, benched for the day' }; + return { ...base, eligible: false, reason: 'exit refused — flattened, benched for the day', doneForDay: true }; } const booked = obfuscationDayBooked(cfg, acc.id, tradeDate, combined.realizedPnL); - if (booked) return { ...base, eligible: false, reason: booked }; + if (booked) return { ...base, eligible: false, reason: booked, doneForDay: true }; const priorProfit = client.priorProfit?.[acc.id] ?? 0; const allFundTxns = client.fundTransactions?.[acc.id] ?? []; @@ -755,19 +761,19 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a // Legacy one-and-done gate: any realized PnL today means this account is // finished — for a combining account, "today" spans the holiday stretch. if (combined.realizedPnL !== 0) { - return { ...evaluated, eligible: false, reason: `already traded today (realizedPnL=${combined.realizedPnL})` }; + return { ...evaluated, eligible: false, reason: `already traded today (realizedPnL=${combined.realizedPnL})`, doneForDay: true }; } // And any entry of today's the broker confirmed filled, whatever its // realized P&L says (a record replaced under it, a fill not booked // yet). One of unknown outcome is held above until settled. if (entryOutcomeToday(acc.id, tradeDate)?.state === 'filled') { - return { ...evaluated, eligible: false, reason: 'already traded today (entry filled)' }; + return { ...evaluated, eligible: false, reason: 'already traded today (entry filled)', doneForDay: true }; } if (!isExtraDay && plan.remainingToday <= 0) { const reason = effective.drain && plan.dayTarget === 0 ? 'drain days booked — waiting for withdrawal' : `target=$${plan.dayTarget} (challenge complete)`; - return { ...evaluated, eligible: false, reason }; + return { ...evaluated, eligible: false, reason, doneForDay: true }; } } else { // Split-leg mode: keep trading until the day's remaining target is used up. @@ -777,10 +783,10 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a const reason = combined.realizedPnL === 0 ? `target=$${plan.dayTarget} (challenge complete)` : `day done ($${plan.remainingToday} left of $${plan.dayTarget})`; - return { ...evaluated, eligible: false, reason }; + return { ...evaluated, eligible: false, reason, doneForDay: true }; } if (legsToday(acc.id, tradeDate) >= MAX_LEGS_PER_DAY) { - return { ...evaluated, eligible: false, reason: `leg cap reached (${MAX_LEGS_PER_DAY} today)` }; + return { ...evaluated, eligible: false, reason: `leg cap reached (${MAX_LEGS_PER_DAY} today)`, doneForDay: true }; } } @@ -789,7 +795,12 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a // leaves about the reserve plus fees, which is below what a trade needs — // so a stop-out benches the account for the rest of the day by itself. const gate = dailyLossGate(client, acc.id, cfg, cash, combined.realizedPnL, plan.remainingToday, isExtraDay || effective.drain); - if ('reason' in gate) return { ...evaluated, eligible: false, reason: gate.reason }; + if ('reason' in gate) { + // A booked day is done; a day-start balance still to come is not. + return gate.reason === DLL_BASE_PENDING + ? { ...evaluated, eligible: false, reason: gate.reason } + : { ...evaluated, eligible: false, reason: gate.reason, doneForDay: true }; + } if (Number.isFinite(gate.room)) return { ...evaluated, eligible: true, reason: '', dllRoom: gate.room }; return { ...evaluated, eligible: true, reason: '' }; @@ -798,6 +809,86 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a +// ── broker session demand (lib/clients.ts rests idle sessions) ────────────── + +const CENTRAL_DAY = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Chicago' }); + +/** + * The first minute the firm may open a trade at or after `now` — on a trade + * date after `afterTradeDate`, when given. Today's open delay is the + * scheduler's own (openJitterMinutes); a later day's isn't drawn yet, so it + * counts from that day's earliest open. + */ +export function nextSessionOpenFor(firm: { tradesEveryDay?: boolean }, now: number, afterTradeDate: string | null): number | null { + const today = CENTRAL_DAY.format(now); + const delay = todaysSessionOpenDelay(); + return nextSessionOpenAt(now, { + everyDay: firm.tradesEveryDay === true, + mode: getSetting('trading_hours') ?? 'full_cme', + jitterFor: (at) => (CENTRAL_DAY.format(at) === today ? delay : 0), + }, afterTradeDate); +} + +export interface SessionDemand { + /** Why the firm's session must stay up, or null when nothing needs it. */ + needed: string | null; + /** Not needed, but not to be logged out this minute either: something + * brief is underway — an answer awaited, a batch in its lane. */ + wait: string | null; + /** The trade date whose accounts are all done — the session waits for a + * later one — or null when the market is closed. */ + afterTradeDate: string | null; + /** The scheduler is running, so the next session wants the firm back. */ + scheduled: boolean; +} + +/** + * Whether the firm's broker session is needed now. Whatever the bot holds or + * is unsure of keeps it up: positions, working orders, orders in flight, an + * entry of unknown outcome. While the scheduler runs and the firm's market is + * open, so does every account that may still trade this trade date — only + * accounts done for the day (doneForDay) let it go. A client that isn't + * synced can't say what it holds, so it always counts as needed. What the + * client still waits on itself (restBlocker) and a batch running in its lane + * only put the logout off. + */ +export function sessionDemand(firm: TradableFirm, client: PlatformClient, now = Date.now()): SessionDemand { + const scheduled = getState().running; + const result = (needed: string | null, afterTradeDate: string | null = null): SessionDemand => { + if (needed) return { needed, wait: null, afterTradeDate: null, scheduled }; + const wait = client.restBlocker?.() + ?? (runtime().tradeInFlight?.has(laneOf(firm)) ? 'a batch is in flight in its lane' : null); + return { needed: null, wait, afterTradeDate, scheduled }; + }; + if (!isClientReady(client, now)) return result('not synced'); + const rt = runtime(); + const tradeDate = currentTradeDateCT(new Date(now)); + if (Object.keys(client.positions ?? {}).length > 0) return result('a position is open'); + for (const acc of client.accountList) { + if (client.hasWorkingOrders?.(acc.id)) return result(`${acc.name} has working orders`); + if (rt.accountsInFlight.has(acc.id)) return result(`${acc.name} has an order in flight`); + // Read only: settling it is the scheduler's job (unknownEntryHolds). + if (entryOutcomeToday(acc.id, tradeDate)?.state === 'unknown') return result(`${acc.name}: ${ENTRY_UNKNOWN}`); + } + + if (!scheduled || isInNoTradeWindow(firm, new Date(now))) return result(null); + for (const acc of client.accountList) { + const ev = evaluateAccountForTrade(client, acc, tradeDate); + if (ev && !ev.doneForDay) return result(`${acc.name} ${ev.eligible ? 'can still trade' : `waits: ${ev.reason}`}`); + } + return result(null, tradeDate); +} + +/** What the master reads for a lane whose every firm's session is resting. */ +function laneRestingDetail(lane: string): string | null { + const firms = getTradableFirms().filter((f) => laneOf(f) === lane); + if (firms.length === 0 || !firms.every((f) => isResting(f.id))) return null; + const wakes = firms.map((f) => restingWakeAt(f.id)).filter((t): t is number => t !== null); + return wakes.length > 0 + ? `sessions resting, every account done — next login ${fmtCentralTime(Math.min(...wakes))}` + : 'sessions resting until trading starts'; +} + // ── helpers ─────────────────────────────────────────────────────────────────── /** The longest random delay added to the session open, in minutes. */ @@ -1583,6 +1674,9 @@ async function fireLeg( */ export async function copyTrade() { console.log('[copy-trade] called'); + // Resting firms can't be copied to this time; they're back for the next. + const woken = wakeAllFirms('copy trade'); + if (woken > 0) console.log(`[copy-trade] ${woken} resting session(s) logging back in — copy again once they sync`); const positioned = firmClients().ready .find(({ client }) => client.accountList.some((acc) => !!client.positions[acc.id])); if (!positioned) { @@ -1782,6 +1876,11 @@ async function copyTradeLocked(lease: number, lane: string): Promise * instrument and side. */ export async function fireManualLeg(accountId: number, action: 'Buy' | 'Sell' | 'Auto'): Promise { + for (const [firmId, c] of getClients()) { + if (!c.accountList.some((a) => a.id === accountId)) continue; + if (wakeFirm(firmId, 'manual leg')) throw new Error("the firm's session was resting — logging in now; try again in a minute"); + if (isWaking(firmId)) throw new Error("the firm's session is logging back in — try again in a minute"); + } const found = firmClients().ready.find(({ client }) => client.accountList.some((a) => a.id === accountId)); if (!found) throw new Error('account not found on a synced client'); const { firm, client } = found; @@ -1885,6 +1984,10 @@ function hasRemainingConfiguredAccounts(): boolean { // the ready firms every session after — exactly what the operator opted // out of. (Before an open with every ready account already done, this // stops rather than wait on the unready firm: the safe side.) + // A firm logging back in from a rest (a check-in may have found a new + // account) is unknown until it syncs, like an unready one. + if (getTradableFirms().some((firm) => isWaking(firm.id) && !isInNoTradeWindow(firm))) return true; + for (const { firm, client } of unready) { if (isInNoTradeWindow(firm)) continue; if (client.lastSyncAt === 0 && client.accountList.length === 0) return true; @@ -1966,6 +2069,10 @@ export function startScheduler(action: 'Buy' | 'Sell' | 'Auto', symbol: string, state.running = true; state.stopAfterAll = stopAfterAll; + // Trading (re)started by hand: every resting session logs back in now. + // Idle ones log out again once they're seen to have nothing to do. + wakeAllFirms('trading started'); + const tick = async () => { if (!state.running) return; state.lastRun = new Date(); @@ -2019,6 +2126,14 @@ export function startScheduler(action: 'Buy' | 'Sell' | 'Auto', symbol: string, console.log(`[scheduler] ${laneName(lane)}: ${openPositions} position(s) still open — skipping tick`); continue; } + // Every firm here is done for the day and logged out — + // say so rather than "no eligible accounts". A closed + // market still reads as closed (runTradeBatch). + const resting = laneRestingDetail(lane); + if (resting && !getTradableFirms().filter((f) => laneOf(f) === lane).every((f) => isInNoTradeWindow(f))) { + record(lane, 'no-eligible', resting); + continue; + } const { outcome, results, detail, reasons } = await runTradeBatch(state.action, state.symbol, lane); record(lane, outcome, detail ?? outcome, reasons); filled += results.flatMap((r) => r.results).filter((r) => r.status === 'filled').length; diff --git a/lib/clients.ts b/lib/clients.ts index e08cc52..bf0e7e0 100644 --- a/lib/clients.ts +++ b/lib/clients.ts @@ -14,8 +14,10 @@ import { applyBrokerHttpDefaults, applyBrokerProxy } from './outbound-proxy'; import axios from './http-client'; import { startMasterLink } from './master-link'; import { startUpdaterWatchdog } from './updater-watchdog'; -import { resumeSchedulerIfPersisted } from './auto-trade'; +import { getSchedulerStatus, nextSessionOpenFor, resumeSchedulerIfPersisted, sessionDemand, type SessionDemand } 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. @@ -35,6 +37,10 @@ const g = global as typeof globalThis & { __clientSupervisorTimer?: ReturnType; __clientSupervision?: Map; __quoteSupervisorTimer?: ReturnType; + __sessionGovernorTimer?: ReturnType; + __sessionRest?: Map; + __sessionIdle?: Map; + __sessionWaking?: Map; }; function ensureMap(): Map { @@ -222,6 +228,9 @@ export function getClients(): Map { // 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(); @@ -291,6 +300,13 @@ 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) { @@ -327,6 +343,19 @@ function triggerContractResolve(): void { } } +/** Every Tradovate client is a resting snapshot (and there is at least one). */ +function tradovateAllResting(map: Map): 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); @@ -407,7 +436,8 @@ function superviseLiveQuotes(): void { const map = ensureMap(); const candidates = getTradableFirms().filter(isFuturesFirm).flatMap((firm) => { const client = map.get(firm.id); - return client ? [{ firmId: firm.id, firmName: firm.name, platform: firm.platform, client: client as unknown as QuoteClient }] : []; + // 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 }] : []; }); superviseQuotes(candidates, resolveRootsHere()) .catch((err) => console.error(`[quotes] supervision failed: ${errorMessage(err)}`)); @@ -499,6 +529,12 @@ 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; @@ -541,3 +577,271 @@ 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 { + if (!g.__sessionRest) g.__sessionRest = new Map(); + return g.__sessionRest; +} + +function idleMap(): Map { + if (!g.__sessionIdle) g.__sessionIdle = new Map(); + return g.__sessionIdle; +} + +function wakingMap(): Map { + 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[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>, + 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}`); +} diff --git a/lib/dxtrade-class.ts b/lib/dxtrade-class.ts index cf963f2..21645df 100644 --- a/lib/dxtrade-class.ts +++ b/lib/dxtrade-class.ts @@ -1764,6 +1764,15 @@ export class DXTradeClient implements PlatformClient { }; } + /** 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.pendingFills > 0) return `${this.pendingFills} fill(s) awaited`; + if (this.daysFetchInFlight) return 'a history fetch is underway'; + return null; + } + /** Whether a login can't happen until someone acts — the supervisor * doesn't rebuild those (a rebuild can't fix a URL or a password). */ public get needsAttention(): boolean { diff --git a/lib/platform-client.ts b/lib/platform-client.ts index fdd9197..00d9fb7 100644 --- a/lib/platform-client.ts +++ b/lib/platform-client.ts @@ -85,6 +85,10 @@ export interface PlatformClient { lastSyncStartedAt?: number; /** Whether the broker still shows a working order on the account. */ hasWorkingOrders?(accountId: number): boolean; + /** Why the session can't be logged out now beyond positions and working + * orders — exit pairs it watches, fills or answers it awaits, a report + * fetch underway — or null (session rest, lib/clients.ts). */ + restBlocker?(): string | null; lastFetchErrors: Record; lastFetchRaw: Record; recentEntityEvents: { entityType: string; eventType: string; entity: unknown; ts: number }[]; diff --git a/lib/queue-run.ts b/lib/queue-run.ts index 1f038b1..4592abf 100644 --- a/lib/queue-run.ts +++ b/lib/queue-run.ts @@ -13,6 +13,7 @@ import { createRun } from './db'; import { findAutomation, resolveSteps } from './automations'; +import { wakeAllFirms } from './clients'; export class QueueRunError extends Error { constructor(message: string, readonly status: number) { @@ -52,5 +53,8 @@ export function queueRun(automationId: string, supplied: Record } const run = createRun(automationId, resolved.length, inputs); + // A purchase brings a new account: a resting session (lib/clients.ts) + // logs back in now so it is seen, and traded if it can be. + wakeAllFirms('AutoBuyer run'); return { runId: run.id, totalSteps: run.total_steps, inputs }; } diff --git a/lib/topstepx-class.ts b/lib/topstepx-class.ts index 8fc4e23..712ce34 100644 --- a/lib/topstepx-class.ts +++ b/lib/topstepx-class.ts @@ -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 | null = null; - private tokenRenewalInterval: ReturnType | null = null; + private tokenRenewalTimer: ReturnType | null = null; private ocoPollInterval: ReturnType | null = null; private loginRetryTimer: ReturnType | null = null; private watchdogInterval: ReturnType | 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 { 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; } } diff --git a/lib/trading-logic.ts b/lib/trading-logic.ts index 6e7ea2d..de8383e 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -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 diff --git a/lib/tradovate-class.ts b/lib/tradovate-class.ts index a6a21c3..125fcac 100644 --- a/lib/tradovate-class.ts +++ b/lib/tradovate-class.ts @@ -97,6 +97,14 @@ const INBOUND_SILENCE_MS = 150_000; const STUCK_CLOSING_MS = 15_000; const TOKEN_RENEW_CHECK_MS = 2 * 60_000; const TOKEN_RENEW_WINDOW_MS = 15 * 60_000; +/** Each token is renewed at a point drawn between these two lengths before + * its expiry, so renewals keep no fixed rhythm (TOKEN_RENEW_WINDOW_MS is the + * latest one may wait). */ +const TOKEN_RENEW_EARLIEST_MS = 45 * 60_000; +/** With the expiry unknown or untrusted, renew this long after the token + * arrived instead (drawn) — well inside Tradovate's 90-minute tokens. */ +const TOKEN_BLIND_RENEW_MIN_MS = 10 * 60_000; +const TOKEN_BLIND_RENEW_MAX_MS = 25 * 60_000; /** A failed renewal this close to expiry logs in afresh instead of waiting. */ const TOKEN_RELOGIN_WINDOW_MS = 2 * 60_000; /** A reconnect reuses the current token only if it has at least this long left. */ @@ -426,6 +434,8 @@ export class TradovateClient { private workingOrders: { [accountId: number]: number } = {}; private livenessInterval: ReturnType | null = null; private renewInFlight = false; + /** When the current token is to be renewed (drawn in noteTokenExpiry()). */ + private renewAt = 0; /** The current token's expirationTime was (nearly) past on arrival: this * machine's clock is off, so the expiry is ignored (noteTokenExpiry()). */ private tokenExpiryUntrusted = false; @@ -1843,17 +1853,26 @@ export class TradovateClient { * Tradovate's, not that the token is dead. Trusting that expiry would have * the watchdog "expire" every fresh token and log in again every 15s (a * rate-limit penalty, then a captcha, for every client on this IP). - * Treat the expiry as unknown instead: renew every 2 minutes and log in on - * reconnect, as the code did before it read the expiry at all. + * Treat the expiry as unknown instead: renew 10–25 minutes after each + * token arrives (this clock's differences still hold) and log in on + * reconnect. + * + * Also draws when this token is renewed: a random 15–45 minutes before it + * expires, so the renewals don't tick like a clock. */ private noteTokenExpiry(): void { const raw = this.accessInfo?.expirationTime; + const now = Date.now(); const t = raw ? new Date(raw).getTime() : NaN; - const untrusted = Number.isFinite(t) && t - Date.now() < TOKEN_RENEW_WINDOW_MS; + const untrusted = Number.isFinite(t) && t - now < TOKEN_RENEW_WINDOW_MS; if (untrusted && !this.tokenExpiryUntrusted) { console.warn(`${this.tag} new token expires ${raw}, which is (nearly) past by this machine's clock — check the system clock; ignoring the expiry`); } this.tokenExpiryUntrusted = untrusted; + const expiresAt = this.tokenExpiresAt(); + this.renewAt = expiresAt > 0 + ? expiresAt - TOKEN_RENEW_WINDOW_MS - Math.random() * (TOKEN_RENEW_EARLIEST_MS - TOKEN_RENEW_WINDOW_MS) + : now + TOKEN_BLIND_RENEW_MIN_MS + Math.random() * (TOKEN_BLIND_RENEW_MAX_MS - TOKEN_BLIND_RENEW_MIN_MS); } /** Holding a token known to stay valid for at least `ms` more. */ @@ -1861,13 +1880,13 @@ export class TradovateClient { return !!this.accessInfo?.accessToken && this.tokenExpiresAt() > Date.now() + ms; } - /** The 2-minute renewal check. Never rejects, never overlaps itself. */ + /** The 2-minute renewal check: renews once the token's drawn renewal + * time (noteTokenExpiry) has passed. Never rejects, never overlaps itself. */ private maybeRenewToken(): void { if (this.intentionalDisconnect || this.connecting || this.renewInFlight || !this.accessInfo?.accessToken) return; const now = Date.now(); const expiresAt = this.tokenExpiresAt(); - // An unparseable expiry renews anyway — an extra renewal beats a silent expiry. - if (expiresAt > 0 && expiresAt - now > TOKEN_RENEW_WINDOW_MS) return; + if (now < this.renewAt) return; // Already expired: renewal can't save it; the watchdog / next connect logs in. if (expiresAt > 0 && expiresAt <= now) return; @@ -2278,6 +2297,19 @@ export class TradovateClient { for (const info of watchers) info.cancel?.('client disconnected'); this.daysFetchInFlight = false; this.nextDaysFetchRetryAt = 0; + // Logged out for good: a snapshot left in the pool (session rest) + // must not be able to reach the broker. Blank, not removed — late + // readers use accessInfo?.accessToken and see a logged-out client. + if (this.accessInfo) this.accessInfo = { ...this.accessInfo, accessToken: '', mdAccessToken: '' }; + } + + /** See PlatformClient: what the session still waits on besides positions + * and working orders. */ + public restBlocker(): string | null { + const pending = Object.keys(this.directEventCallbacks).length + this.indirectEventCallbacks.length; + if (pending > 0) return `${pending} request(s) awaiting an answer`; + if (this.daysFetchInFlight) return 'a report fetch is underway'; + return null; } /** Register a one-time callback for when a fill arrives for a given orderId. */ diff --git a/package.json b/package.json index dedf19a..ba3deb0 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "build": "next build", "start": "next start", "lint": "eslint", - "check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs scripts/check-instruments.mjs scripts/check-account-status.mjs scripts/check-kept-rules.mjs scripts/check-request-user-agent.mjs" + "check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs scripts/check-instruments.mjs scripts/check-account-status.mjs scripts/check-kept-rules.mjs scripts/check-request-user-agent.mjs scripts/check-session-rest.mjs" }, "dependencies": { "@microsoft/signalr": "^10.0.11", diff --git a/scripts/check-session-rest.mjs b/scripts/check-session-rest.mjs new file mode 100644 index 0000000..53c587c --- /dev/null +++ b/scripts/check-session-rest.mjs @@ -0,0 +1,170 @@ +// Checks for the broker session rest times in lib/trading-logic.ts: when a +// rested session's next session opens, when it logs back in, and its +// check-ins (lib/clients.ts decides when a session is idle). +// +// npm run check +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + nextSessionOpenAt, planCheckIn, planRest, isNoTradeTime, currentTradeDateCT, + REST_LEAD_MIN_MS, REST_LEAD_MAX_MS, REST_MIN_MS, CHECK_IN_GAP_MIN_MS, CHECK_IN_GAP_MAX_MS, +} from '../lib/trading-logic.ts'; + +const MIN = 60_000; +const HOUR = 60 * MIN; +const at = (iso) => Date.parse(iso); +const iso = (ms) => new Date(ms).toISOString(); +const fullCme = { everyDay: false, mode: 'full_cme', jitterFor: () => 0 }; +const equity = { everyDay: false, mode: 'equity_hours', jitterFor: () => 0 }; +const everyDay = { everyDay: true, mode: 'full_cme', jitterFor: () => 0 }; + +function centralMinutes(ms) { + const parts = new Intl.DateTimeFormat('en-US', { + hour: 'numeric', minute: 'numeric', hour12: false, timeZone: 'America/Chicago', + }).formatToParts(new Date(ms)); + const get = (t) => Number(parts.find((p) => p.type === t)?.value); + return (get('hour') % 24) * 60 + get('minute'); +} + +// Deterministic PRNG so a failure reproduces. +function rng(seed) { + let s = seed >>> 0; + return () => { + s = (s + 0x6d2b79f5) >>> 0; + let t = s; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// ── nextSessionOpenAt ─────────────────────────────────────────────────────── + +test('full CME: a Saturday rest opens Sunday 5:45 PM CT', () => { + // Sat 2026-10-24 12:00 CDT + assert.equal(iso(nextSessionOpenAt(at('2026-10-24T17:00:00Z'), fullCme)), '2026-10-25T22:45:00.000Z'); +}); + +test("full CME: the day's open delay moves the open", () => { + const jitterFor = () => 37; + assert.equal(iso(nextSessionOpenAt(at('2026-10-24T17:00:00Z'), { ...fullCme, jitterFor })), '2026-10-25T23:22:00.000Z'); +}); + +test('full CME: Friday after the close waits for Sunday', () => { + // Fri 2026-10-23 3:30 PM CDT + assert.equal(iso(nextSessionOpenAt(at('2026-10-23T20:30:00Z'), fullCme)), '2026-10-25T22:45:00.000Z'); +}); + +test('full CME: the daily break opens at 5:45 PM the same evening', () => { + // Mon 2026-10-19 3:00 PM CDT + assert.equal(iso(nextSessionOpenAt(at('2026-10-19T20:00:00Z'), fullCme)), '2026-10-19T22:45:00.000Z'); +}); + +test('full CME: an open market is open now (to the minute)', () => { + // Tue 2026-10-20 10:00:30 CDT + assert.equal(iso(nextSessionOpenAt(at('2026-10-20T15:00:30Z'), fullCme)), '2026-10-20T15:01:00.000Z'); +}); + +test('full CME: every account done for the trade date waits for the next one', () => { + // Mon 2026-10-19 11 PM CDT is trade date 10-20; done → Tue 5:45 PM (trade date 10-21). + const from = at('2026-10-20T04:00:00Z'); + assert.equal(currentTradeDateCT(new Date(from)), '2026-10-20'); + const open = nextSessionOpenAt(from, fullCme, '2026-10-20'); + assert.equal(iso(open), '2026-10-20T22:45:00.000Z'); + assert.equal(currentTradeDateCT(new Date(open)), '2026-10-21'); +}); + +test('full CME: a Friday trade date done waits for Sunday', () => { + // Fri 2026-10-23 9 AM CDT, trade date 10-23 done. + assert.equal(iso(nextSessionOpenAt(at('2026-10-23T14:00:00Z'), fullCme, '2026-10-23')), '2026-10-25T22:45:00.000Z'); +}); + +test('full CME: across the DST change the open stays 5:45 PM Central', () => { + // Sat 2026-10-31 noon CDT → Sun 2026-11-01 5:45 PM CST (23:45Z). + assert.equal(iso(nextSessionOpenAt(at('2026-10-31T17:00:00Z'), fullCme)), '2026-11-01T23:45:00.000Z'); +}); + +test('equity hours: Friday afternoon waits for Monday 8:35 AM', () => { + assert.equal(iso(nextSessionOpenAt(at('2026-10-23T20:30:00Z'), equity)), '2026-10-26T13:35:00.000Z'); +}); + +test('equity hours: a done day waits for the next morning', () => { + // Tue 2026-10-20 10 AM CDT, trade date 10-20 done → Wed 8:35 AM. + assert.equal(iso(nextSessionOpenAt(at('2026-10-20T15:00:00Z'), equity, '2026-10-20')), '2026-10-21T13:35:00.000Z'); +}); + +test('every-day firm: open on Saturday; done waits for 5:05 PM CT', () => { + const sat = at('2026-10-24T17:00:00Z'); + assert.equal(nextSessionOpenAt(sat, everyDay), sat); + assert.equal(iso(nextSessionOpenAt(sat, everyDay, '2026-10-24')), '2026-10-24T22:05:00.000Z'); + // In CST the firm resets at 4 PM, but entries still reopen at 5:05 PM. + assert.equal(iso(nextSessionOpenAt(at('2026-11-07T18:00:00Z'), everyDay, '2026-11-07')), '2026-11-07T23:05:00.000Z'); +}); + +test('nextSessionOpenAt lands on the first open minute, on a later trade date when asked', () => { + const rand = rng(7); + const start = at('2026-10-18T00:00:00Z'); + for (let i = 0; i < 120; i++) { + const from = start + Math.floor(rand() * 21 * 24 * HOUR); + for (const o of [fullCme, equity, everyDay]) { + const after = rand() < 0.5 ? currentTradeDateCT(new Date(from)) : null; + const open = nextSessionOpenAt(from, o, after); + assert.ok(open !== null && open >= from, `${iso(from)} → ${open}`); + const opens = (t) => !isNoTradeTime(new Date(t), { everyDay: o.everyDay, mode: o.mode, jitterMin: 0 }) + && (after === null || currentTradeDateCT(new Date(t)) > after); + assert.ok(opens(open), `${iso(from)} → ${iso(open)} isn't open`); + if (open - MIN >= from) assert.ok(!opens(open - MIN), `${iso(from)} → ${iso(open)} opened earlier`); + } + } +}); + +// ── planCheckIn / planRest ────────────────────────────────────────────────── + +test('a check-in comes 3–7 hours on, in the Central daytime, never close to the wake', () => { + const rand = rng(11); + const start = at('2026-10-19T00:00:00Z'); + for (let i = 0; i < 2000; i++) { + const now = start + Math.floor(rand() * 14 * 24 * HOUR); + const checkIn = planCheckIn(now, null, rand); + assert.ok(checkIn !== null); + assert.ok(checkIn >= now + CHECK_IN_GAP_MIN_MS, `${iso(now)} → ${iso(checkIn)} too soon`); + const minutes = centralMinutes(checkIn); + assert.ok(minutes >= 8 * 60 && minutes < 22 * 60, `${iso(checkIn)} is at night`); + // Within the gap, or moved to the next morning. + if (checkIn > now + CHECK_IN_GAP_MAX_MS) assert.ok(minutes < 11 * 60 + 1, `${iso(checkIn)} moved past the morning`); + } + const now = at('2026-10-19T15:00:00Z'); // Mon 10 AM CDT + assert.equal(planCheckIn(now, now + 4 * HOUR, () => 0), null); // 3h on is within 2h of the wake + assert.equal(planCheckIn(now, now + 6 * HOUR, () => 0), now + 3 * HOUR); +}); + +test('a check-in drawn at night moves to 8–11 AM Central', () => { + const now = at('2026-10-20T01:00:00Z'); // Mon 8 PM CDT + // +5h = 1 AM → next morning 8 AM + half of 3h = 9:30 AM CDT. + assert.equal(iso(planCheckIn(now, null, () => 0.5)), '2026-10-20T14:30:00.000Z'); +}); + +test('planRest: logs back in 20–50 min before the open, or not at all when it is too close', () => { + const now = at('2026-10-24T17:00:00Z'); + const opensAt = at('2026-10-25T22:45:00Z'); + const early = planRest(now, opensAt, () => 0); + assert.equal(early.wakeAt, opensAt - REST_LEAD_MIN_MS); + assert.equal(early.leadMs, REST_LEAD_MIN_MS); + const late = planRest(now, opensAt, () => 0.999999); + assert.ok(Math.abs(late.wakeAt - (opensAt - REST_LEAD_MAX_MS)) < 1_000); + // An open within lead + REST_MIN_MS isn't worth a rest. + assert.equal(planRest(now, now + REST_LEAD_MIN_MS + REST_MIN_MS - MIN, () => 0), null); + assert.ok(planRest(now, now + REST_LEAD_MIN_MS + REST_MIN_MS, () => 0) !== null); + // Nothing scheduled: rests until a command, with a check-in. + const open = planRest(now, null, () => 0); + assert.equal(open.wakeAt, null); + assert.ok(open.checkInAt !== null); +}); + +test('rests drawn for the same open log back in at different times', () => { + const rand = rng(3); + const opensAt = at('2026-10-25T22:45:00Z'); + const wakes = new Set(); + for (let i = 0; i < 50; i++) wakes.add(Math.round(planRest(at('2026-10-24T17:00:00Z'), opensAt, rand).wakeAt / MIN)); + assert.ok(wakes.size >= 20, `only ${wakes.size} distinct wake minutes`); +}); diff --git a/types.ts b/types.ts index f68e9f0..eb69015 100644 --- a/types.ts +++ b/types.ts @@ -174,5 +174,9 @@ export interface FirmState { firmId: string; firm: string; connected: boolean; + /** The broker session is logged out while nothing needs it (session rest, + * lib/clients.ts): accounts show as last synced. wakeAt is when it logs + * back in by itself, null when only trading starting will. */ + resting?: { since: number; wakeAt: number | null }; accounts: AccountState[]; }