diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 017ff1a..cc50e6a 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, sessionRestEnabled, sessionRestInfo } from '@/lib/clients'; +import { getClients, isWaking, sessionRestEnabled, sessionRestInfo } from '@/lib/clients'; import { getTradableFirms } from '@/lib/firm-catalog'; import { getSchedulerHealth, getSchedulerStatus } from '@/lib/auto-trade'; import { getBuildVersion } from '@/lib/version'; @@ -41,23 +41,25 @@ 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, accounts: 0 }; + if (!client) return { ...base, hasClient: false, ready: false, resting: null, waking: 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. + // 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, accounts, ...client.health() }; + return { ...base, hasClient: true, ready, resting, waking, accounts, ...client.health() }; } catch (err) { // 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({ - ok: entries.every((e) => !e.hasClient || e.ready || !!e.resting), + ok: entries.every((e) => !e.hasClient || e.ready || !!e.resting || e.waking), sessionRest: sessionRestEnabled(), now, uptimeSec: Math.round(process.uptime()), diff --git a/lib/reporter.ts b/lib/reporter.ts index ff379bf..55c5747 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 } from './clients'; +import { getClients, isResting } 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 } 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 { accountNextLeg, accountLegShape } from './leg-estimate'; import { sizingHoldHours } from './person-size'; @@ -49,6 +49,9 @@ 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/trading-logic.ts b/lib/trading-logic.ts index de8383e..b610fc6 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -1889,6 +1889,17 @@ export function planRest( 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/scripts/check-session-rest.mjs b/scripts/check-session-rest.mjs index 53c587c..33d4f13 100644 --- a/scripts/check-session-rest.mjs +++ b/scripts/check-session-rest.mjs @@ -6,7 +6,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; 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, } 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)); 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); +});