Session rest: a rest across the 5 PM roll reports unsynced; health excuses a wake

A firm resting across the trade-date roll kept reporting its last snapshot,
so yesterday's realized P&L and DLL lockouts counted as accounts already
traded today (Nik's Topstep read 5/5 traded at 5:31 PM from 3:29 PM data).
Its report is now the unsynced one until it logs back in; a rest within one
trade date reports as before.

/api/health also counts a firm logging back in after a rest as nothing
wrong, within its 15-minute grace, rather than reading not ok for the
seconds before its first sync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-10-21 20:12:52 -05:00
co-authored by Claude Opus 5.5
parent 5eb0ea7760
commit 062ec07f0a
4 changed files with 36 additions and 9 deletions
+8 -6
View File
@@ -1,7 +1,7 @@
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getClients, sessionRestEnabled, sessionRestInfo } from '@/lib/clients'; import { getClients, isWaking, sessionRestEnabled, sessionRestInfo } from '@/lib/clients';
import { getTradableFirms } from '@/lib/firm-catalog'; import { getTradableFirms } from '@/lib/firm-catalog';
import { getSchedulerHealth, getSchedulerStatus } from '@/lib/auto-trade'; import { getSchedulerHealth, getSchedulerStatus } from '@/lib/auto-trade';
import { getBuildVersion } from '@/lib/version'; import { getBuildVersion } from '@/lib/version';
@@ -41,23 +41,25 @@ export async function GET() {
const entries = getTradableFirms().map((firm) => { const entries = getTradableFirms().map((firm) => {
const base = { firmId: firm.id, firm: firm.name, platform: firm.platform }; const base = { firmId: firm.id, firm: firm.name, platform: firm.platform };
const client = clients.get(firm.id); const client = clients.get(firm.id);
if (!client) return { ...base, hasClient: false, ready: false, resting: null, accounts: 0 }; if (!client) return { ...base, hasClient: false, ready: false, resting: null, waking: false, accounts: 0 };
const ready = isClientReady(client, now); const ready = isClientReady(client, now);
const accounts = client.accountList.length; const accounts = client.accountList.length;
// Logged out on purpose while nothing needs it (lib/clients.ts): // Logged out on purpose while nothing needs it (lib/clients.ts):
// not ready, and nothing wrong. // 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 resting = sessionRestInfo(firm.id);
const waking = isWaking(firm.id, now);
try { try {
return { ...base, hasClient: true, ready, resting, accounts, ...client.health() }; return { ...base, hasClient: true, ready, resting, waking, accounts, ...client.health() };
} catch (err) { } catch (err) {
// One client's broken health() must not hide the rest. // One client's broken health() must not hide the rest.
return { ...base, hasClient: true, ready, resting, accounts, healthError: errorMessage(err) }; return { ...base, hasClient: true, ready, resting, waking, accounts, healthError: errorMessage(err) };
} }
}); });
return NextResponse.json({ return NextResponse.json({
ok: entries.every((e) => !e.hasClient || e.ready || !!e.resting), ok: entries.every((e) => !e.hasClient || e.ready || !!e.resting || e.waking),
sessionRest: sessionRestEnabled(), sessionRest: sessionRestEnabled(),
now, now,
uptimeSec: Math.round(process.uptime()), uptimeSec: Math.round(process.uptime()),
+5 -2
View File
@@ -16,11 +16,11 @@ import { pushReport } from './master-link';
import { getBuildVersion } from './version'; import { getBuildVersion } from './version';
import { tradeMixFor } from './trade-mix'; import { tradeMixFor } from './trade-mix';
import { updaterStatus } from './updater-status'; import { updaterStatus } from './updater-status';
import { getClients } from './clients'; import { getClients, isResting } from './clients';
import { getAccountTypeById, getKeptRuleIdsForFirm, getTradableFirms } from './firm-catalog'; import { getAccountTypeById, getKeptRuleIdsForFirm, getTradableFirms } from './firm-catalog';
import { getAccountConfig } from './account-type-resolver'; import { getAccountConfig } from './account-type-resolver';
import { getSchedulerStatus } from './auto-trade'; import { getSchedulerStatus } from './auto-trade';
import { botStopsActive, combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig } from './trading-logic'; import { botStopsActive, combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig, restSnapshotStale } from './trading-logic';
import type { AccountTypeStats, FirmStats, InstrumentStats, SymbolStats } from './master-protocol'; import type { AccountTypeStats, FirmStats, InstrumentStats, SymbolStats } from './master-protocol';
import { accountNextLeg, accountLegShape } from './leg-estimate'; import { accountNextLeg, accountLegShape } from './leg-estimate';
import { sizingHoldHours } from './person-size'; import { sizingHoldHours } from './person-size';
@@ -49,6 +49,9 @@ function collectFirmStats() {
const keptRuleIds = getKeptRuleIdsForFirm(f.id); const keptRuleIds = getKeptRuleIdsForFirm(f.id);
// accounts: [] only once synced (see emptyFirmStats). // accounts: [] only once synced (see emptyFirmStats).
if (!client || client.accountList.length === 0) return emptyFirmStats(f, client, keptRuleIds); 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 totalAccounts = 0;
let accountsTraded = 0; let accountsTraded = 0;
+11
View File
@@ -1889,6 +1889,17 @@ export function planRest(
return { wakeAt, checkInAt: planCheckIn(now, wakeAt, rand), leadMs }; 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 * 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 * machine's own max concurrent accounts: keeps `items` in order, at most
+12 -1
View File
@@ -6,7 +6,7 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
nextSessionOpenAt, planCheckIn, planRest, isNoTradeTime, currentTradeDateCT, 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, 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'; } from '../lib/trading-logic.ts';
@@ -168,3 +168,14 @@ test('rests drawn for the same open log back in at different times', () => {
for (let i = 0; i < 50; i++) wakes.add(Math.round(planRest(at('2026-10-24T17:00:00Z'), opensAt, rand).wakeAt / MIN)); 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`); 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);
});