From 5ec5abde6e1044dc8d6ce4b22d1ae555be5de362 Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Wed, 21 Oct 2026 23:39:53 -0500 Subject: [PATCH] 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 --- README.md | 21 +- app/api/firms/[id]/orders/route.ts | 7 +- app/api/health/route.ts | 16 +- 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/reporter.ts | 7 +- lib/topstepx-class.ts | 41 +--- lib/trading-logic.ts | 111 ----------- lib/tradovate-class.ts | 44 +---- package.json | 2 +- scripts/check-session-rest.mjs | 181 ----------------- types.ts | 4 - 19 files changed, 43 insertions(+), 901 deletions(-) delete mode 100644 scripts/check-session-rest.mjs diff --git a/README.md b/README.md index 2cb869d..8c5b5cf 100644 --- a/README.md +++ b/README.md @@ -184,9 +184,8 @@ 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, session rest times, the DXtrade selector, the futures draw and -instrument lists), plus local HTTP/WebSocket checks against a temporary -database. +hours, the DXtrade selector, the futures draw and instrument lists), plus local +HTTP/WebSocket checks against a temporary database. ### Outbound user agent @@ -204,22 +203,6 @@ 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 567ba15..3d05785 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, isWaking, wakeFirm } from '@/lib/clients'; +import { getClients } from '@/lib/clients'; export async function POST( req: NextRequest, @@ -27,11 +27,6 @@ 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 cc50e6a..64f0718 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, isWaking, sessionRestEnabled, sessionRestInfo } from '@/lib/clients'; +import { getClients } from '@/lib/clients'; import { getTradableFirms } from '@/lib/firm-catalog'; import { getSchedulerHealth, getSchedulerStatus } from '@/lib/auto-trade'; import { getBuildVersion } from '@/lib/version'; @@ -41,26 +41,20 @@ 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, resting: null, waking: false, accounts: 0 }; + if (!client) return { ...base, hasClient: false, ready: false, 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. Nor is logging back in after it, - // within its grace — a login that fails stays not ready past that. - const resting = sessionRestInfo(firm.id); - const waking = isWaking(firm.id, now); try { - return { ...base, hasClient: true, ready, resting, waking, accounts, ...client.health() }; + return { ...base, hasClient: true, ready, accounts, ...client.health() }; } catch (err) { // One client's broken health() must not hide the rest. - return { ...base, hasClient: true, ready, resting, waking, accounts, healthError: errorMessage(err) }; + return { ...base, hasClient: true, ready, accounts, healthError: errorMessage(err) }; } }); return NextResponse.json({ - ok: entries.every((e) => !e.hasClient || e.ready || !!e.resting || e.waking), - sessionRest: sessionRestEnabled(), + ok: entries.every((e) => !e.hasClient || e.ready), now, uptimeSec: Math.round(process.uptime()), version: getBuildVersion(), diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index 732d5bf..ca8d50b 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', 'session_rest'] 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'] 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 0fbef2c..03f0f0e 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, restingWakeAt, sessionRestInfo } from '@/lib/clients'; +import { getClients } 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,10 +27,8 @@ 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, ...resting, accounts: [] }; + return { firmId: f.id, firm: f.name, connected, accounts: [] }; } // Accounts whose rules date was set by hand — one read per firm // rather than one per account. @@ -316,7 +314,7 @@ export async function GET() { keptRules, }; }); - return { firmId: f.id, firm: f.name, connected, ...resting, accounts }; + return { firmId: f.id, firm: f.name, connected, accounts }; }); return NextResponse.json(state); diff --git a/app/page.tsx b/app/page.tsx index 2b473ac..2fd82be 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -526,17 +526,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo Username missing )} - {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 && ( + {hasCredentials && !state.connected && ( Connecting… @@ -604,7 +594,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo state.accounts.length === 0 ? ( - {state.connected || state.resting ? 'No accounts at this firm' : 'Connecting…'} + {state.connected ? 'No accounts at this firm' : 'Connecting…'} ) : ( diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 5da3810..bd8e2a3 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -45,7 +45,6 @@ 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; @@ -65,7 +64,6 @@ 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; @@ -116,7 +114,6 @@ 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); } @@ -164,7 +161,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, session_rest: sessionRest ? '1' : '0', master_dashboard_url: masterUrl, instance_name: instanceName, master_backup_token: backupToken, proxy_url: proxyUrl }), + body: JSON.stringify({ max_concurrent_accounts: maxConcurrent, trading_hours: tradingHours, master_dashboard_url: masterUrl, instance_name: instanceName, master_backup_token: backupToken, proxy_url: proxyUrl }), }); setSaving(false); setSaved(true); @@ -283,23 +280,6 @@ 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 257f9ab..29533df 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 { fmtCentralTime, getClients, isResting, isSessionAway, isWaking, restingWakeAt, wakeAllFirms, wakeFirm } from './clients'; +import { getClients } 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, nextSessionOpenAt, + isNoTradeTime, isPastSessionClose, 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,9 +345,6 @@ 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 }; @@ -573,9 +570,6 @@ 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; } /** @@ -696,8 +690,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', doneForDay: true }; - if (!acc.active) return { ...base, eligible: false, reason: 'inactive (DLL)', doneForDay: true }; + if (isAccountDead(cash.amount, autoLiqThreshold)) return { ...base, eligible: false, reason: 'dead' }; + if (!acc.active) return { ...base, eligible: false, reason: 'inactive (DLL)' }; 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. @@ -715,11 +709,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', doneForDay: true }; + return { ...base, eligible: false, reason: 'exit refused — flattened, benched for the day' }; } const booked = obfuscationDayBooked(cfg, acc.id, tradeDate, combined.realizedPnL); - if (booked) return { ...base, eligible: false, reason: booked, doneForDay: true }; + if (booked) return { ...base, eligible: false, reason: booked }; const priorProfit = client.priorProfit?.[acc.id] ?? 0; const allFundTxns = client.fundTransactions?.[acc.id] ?? []; @@ -761,19 +755,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})`, doneForDay: true }; + return { ...evaluated, eligible: false, reason: `already traded today (realizedPnL=${combined.realizedPnL})` }; } // 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)', doneForDay: true }; + return { ...evaluated, eligible: false, reason: 'already traded today (entry filled)' }; } 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, doneForDay: true }; + return { ...evaluated, eligible: false, reason }; } } else { // Split-leg mode: keep trading until the day's remaining target is used up. @@ -783,10 +777,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, doneForDay: true }; + return { ...evaluated, eligible: false, reason }; } if (legsToday(acc.id, tradeDate) >= MAX_LEGS_PER_DAY) { - return { ...evaluated, eligible: false, reason: `leg cap reached (${MAX_LEGS_PER_DAY} today)`, doneForDay: true }; + return { ...evaluated, eligible: false, reason: `leg cap reached (${MAX_LEGS_PER_DAY} today)` }; } } @@ -795,12 +789,7 @@ 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) { - // 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 ('reason' in gate) return { ...evaluated, eligible: false, reason: gate.reason }; if (Number.isFinite(gate.room)) return { ...evaluated, eligible: true, reason: '', dllRoom: gate.room }; return { ...evaluated, eligible: true, reason: '' }; @@ -809,86 +798,6 @@ 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. */ @@ -1674,9 +1583,6 @@ 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) { @@ -1876,11 +1782,6 @@ 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; @@ -1984,10 +1885,6 @@ 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; @@ -2069,10 +1966,6 @@ 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(); @@ -2126,14 +2019,6 @@ 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 bf0e7e0..e08cc52 100644 --- a/lib/clients.ts +++ b/lib/clients.ts @@ -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; __clientSupervision?: Map; __quoteSupervisorTimer?: ReturnType; - __sessionGovernorTimer?: ReturnType; - __sessionRest?: Map; - __sessionIdle?: Map; - __sessionWaking?: Map; }; function ensureMap(): Map { @@ -228,9 +222,6 @@ 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(); @@ -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): 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 { - 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 21645df..cf963f2 100644 --- a/lib/dxtrade-class.ts +++ b/lib/dxtrade-class.ts @@ -1764,15 +1764,6 @@ 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 00d9fb7..fdd9197 100644 --- a/lib/platform-client.ts +++ b/lib/platform-client.ts @@ -85,10 +85,6 @@ 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 4592abf..1f038b1 100644 --- a/lib/queue-run.ts +++ b/lib/queue-run.ts @@ -13,7 +13,6 @@ import { createRun } from './db'; import { findAutomation, resolveSteps } from './automations'; -import { wakeAllFirms } from './clients'; export class QueueRunError extends Error { constructor(message: string, readonly status: number) { @@ -53,8 +52,5 @@ 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/reporter.ts b/lib/reporter.ts index 55c5747..ff379bf 100644 --- a/lib/reporter.ts +++ b/lib/reporter.ts @@ -16,11 +16,11 @@ import { pushReport } from './master-link'; import { getBuildVersion } from './version'; import { tradeMixFor } from './trade-mix'; import { updaterStatus } from './updater-status'; -import { getClients, isResting } from './clients'; +import { getClients } from './clients'; import { getAccountTypeById, getKeptRuleIdsForFirm, getTradableFirms } from './firm-catalog'; import { getAccountConfig } from './account-type-resolver'; import { getSchedulerStatus } from './auto-trade'; -import { botStopsActive, combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig, restSnapshotStale } from './trading-logic'; +import { botStopsActive, combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig } from './trading-logic'; import type { AccountTypeStats, FirmStats, InstrumentStats, SymbolStats } from './master-protocol'; import { accountNextLeg, accountLegShape } from './leg-estimate'; import { sizingHoldHours } from './person-size'; @@ -49,9 +49,6 @@ function collectFirmStats() { const keptRuleIds = getKeptRuleIdsForFirm(f.id); // accounts: [] only once synced (see emptyFirmStats). if (!client || client.accountList.length === 0) return emptyFirmStats(f, client, keptRuleIds); - // A rest that spans the 5 PM roll holds yesterday's snapshot: unsynced - // until it logs back in, not yesterday's trading counted as today's. - if (isResting(f.id) && restSnapshotStale(client.lastSyncAt)) return emptyFirmStats(f, undefined, keptRuleIds); let totalAccounts = 0; let accountsTraded = 0; diff --git a/lib/topstepx-class.ts b/lib/topstepx-class.ts index 712ce34..8fc4e23 100644 --- a/lib/topstepx-class.ts +++ b/lib/topstepx-class.ts @@ -65,10 +65,6 @@ 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, @@ -214,7 +210,7 @@ export class TopstepXClient implements PlatformClient { private lastInboundAt = 0; private syncInterval: ReturnType | null = null; - private tokenRenewalTimer: ReturnType | null = null; + private tokenRenewalInterval: ReturnType | null = null; private ocoPollInterval: ReturnType | null = null; private loginRetryTimer: ReturnType | null = null; private watchdogInterval: ReturnType | null = null; @@ -382,8 +378,9 @@ 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 or two to stay fresh. - this.scheduleRenewal(); + // 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); if (!this.syncInterval) { // First login. The hub is never a hard dependency: REST polling @@ -478,22 +475,6 @@ 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; } @@ -1956,7 +1937,7 @@ export class TopstepXClient implements PlatformClient { public disconnect(): void { this.intentionalDisconnect = true; if (this.syncInterval) { clearInterval(this.syncInterval); this.syncInterval = null; } - if (this.tokenRenewalTimer) { clearTimeout(this.tokenRenewalTimer); this.tokenRenewalTimer = null; } + if (this.tokenRenewalInterval) { clearInterval(this.tokenRenewalInterval); this.tokenRenewalInterval = 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; } @@ -1965,17 +1946,5 @@ 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 b610fc6..6e7ea2d 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -1789,117 +1789,6 @@ 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 resting session's snapshot, last synced at `lastSyncAt`, is from an - * earlier trade date than `now`: its realized P&L, DLL lockouts and legs are - * the old day's, and counted as today's they would read as accounts already - * traded. It reports as unsynced until the session logs back in. - */ -export function restSnapshotStale(lastSyncAt: number, now: number = Date.now()): boolean { - if (!(lastSyncAt > 0)) return true; - return currentTradeDateCT(new Date(lastSyncAt)) !== currentTradeDateCT(new Date(now)); -} - /** * 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 125fcac..a6a21c3 100644 --- a/lib/tradovate-class.ts +++ b/lib/tradovate-class.ts @@ -97,14 +97,6 @@ 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. */ @@ -434,8 +426,6 @@ 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; @@ -1853,26 +1843,17 @@ 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 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. + * 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. */ 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 - now < TOKEN_RENEW_WINDOW_MS; + const untrusted = Number.isFinite(t) && t - Date.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. */ @@ -1880,13 +1861,13 @@ export class TradovateClient { return !!this.accessInfo?.accessToken && this.tokenExpiresAt() > Date.now() + ms; } - /** The 2-minute renewal check: renews once the token's drawn renewal - * time (noteTokenExpiry) has passed. Never rejects, never overlaps itself. */ + /** The 2-minute renewal check. 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(); - if (now < this.renewAt) return; + // An unparseable expiry renews anyway — an extra renewal beats a silent expiry. + if (expiresAt > 0 && expiresAt - now > TOKEN_RENEW_WINDOW_MS) return; // Already expired: renewal can't save it; the watchdog / next connect logs in. if (expiresAt > 0 && expiresAt <= now) return; @@ -2297,19 +2278,6 @@ 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 ba3deb0..dedf19a 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 scripts/check-session-rest.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" }, "dependencies": { "@microsoft/signalr": "^10.0.11", diff --git a/scripts/check-session-rest.mjs b/scripts/check-session-rest.mjs deleted file mode 100644 index 33d4f13..0000000 --- a/scripts/check-session-rest.mjs +++ /dev/null @@ -1,181 +0,0 @@ -// 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, restSnapshotStale, 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`); -}); - -test("a resting snapshot from before the 5 PM roll is stale; one from today's trade date isn't", () => { - // 2026-10-21: Nik's Topstep last synced 3:29 PM CT, read at 5:31 PM CT. - assert.equal(restSnapshotStale(at('2026-10-21T20:29:00Z'), at('2026-10-21T22:31:00Z')), true); - // Either side of the roll, a minute apart. - assert.equal(restSnapshotStale(at('2026-10-21T21:59:00Z'), at('2026-10-21T22:01:00Z')), true); - // Rested after the evening open, read the next afternoon: the same trade date. - assert.equal(restSnapshotStale(at('2026-10-22T00:30:00Z'), at('2026-10-22T19:00:00Z')), false); - // Never synced. - assert.equal(restSnapshotStale(0, at('2026-10-22T19:00:00Z')), true); -}); diff --git a/types.ts b/types.ts index eb69015..f68e9f0 100644 --- a/types.ts +++ b/types.ts @@ -174,9 +174,5 @@ 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[]; }