Files
autofirmer-expanded/scripts/check-session-rest.mjs
T
Brandon LiandClaude Opus 5.5 062ec07f0a 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>
2026-10-21 20:12:52 -05:00

182 lines
9.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
});