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>
182 lines
9.0 KiB
JavaScript
182 lines
9.0 KiB
JavaScript
// 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);
|
||
});
|