// Checks for the futures instrument specs and the Auto draw // (lib/instrument-specs.ts). // // npm run check // // lib/instrument-specs.ts has no imports, so Node strips its types and loads // it directly; .mjs keeps this file out of Next's type check. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { CME_SPECS, CME_ROOTS, POINT_VALUES, MICRO_OF, tickValue, spreadDollars, dailyMoveDollars, futuresContracts, expectedHoldHours, sessionProgress, drawChances, pickByChance, housekeepingRoot, sanitizeInstruments, resolveAllowlist, MAX_HOLD_HOURS, DRAW_FLOOR, DEFAULT_MAX_ENTRY_SPREAD, spreadTooWide, sanitizeInstrumentLimits, } from '../lib/instrument-specs.ts'; import { mergeTopstepxQuote, parseTradovateFrame, bookSpread } from '../lib/quote-helpers.ts'; import { TOPSTEP_COMMISSION_PER_SIDE, topstepxProductCode, topstepxRoot, topstepCommission } from '../lib/topstep-fees.ts'; // 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; }; } test('specs: the old point-value table exactly, and the tick values', () => { // The table lib/trading-logic.ts carried until the specs replaced it. const old = { NQ: 20, MNQ: 2, ES: 50, MES: 5, YM: 5, MYM: 0.5, RTY: 50, M2K: 5, GC: 100, MGC: 10, SI: 5000, CL: 1000, MCL: 100, NG: 10000, ZB: 1000, ZN: 1000, ZF: 1000, '6E': 125000, '6J': 12500000, '6B': 62500, }; assert.deepEqual({ ...POINT_VALUES }, old); assert.equal(CME_ROOTS.length, 20); const ticks = { NQ: 5, MNQ: 0.5, ES: 12.5, GC: 10, MGC: 1, CL: 10, SI: 25, NG: 10, ZB: 31.25, ZN: 15.625, ZF: 7.8125, '6E': 6.25, '6J': 6.25, '6B': 6.25, YM: 5, RTY: 5 }; for (const [root, value] of Object.entries(ticks)) assert.ok(Math.abs(tickValue(root) - value) < 1e-9, `${root} ${tickValue(root)}`); assert.equal(spreadDollars('NQ'), 5); for (const [full, micro] of Object.entries(MICRO_OF)) { assert.ok(CME_SPECS[micro].micro && !CME_SPECS[full].micro); assert.equal(CME_SPECS[full].pointValue / CME_SPECS[micro].pointValue, 10); } }); test('daily move from hourly closes; contracts; expected hold', () => { // A constant 0.2% hourly swing: σ = 0.002, × √23 × price × pv. const closes = Array.from({ length: 60 }, (_, i) => (i % 2 ? 20000 * 1.002 : 20000)); const move = dailyMoveDollars(closes, 20); const r = Math.log(1.002); const sd = Math.sqrt((r * r * 59) / 58); // mean ≈ 0 over alternating ±r assert.ok(Math.abs(move - sd * Math.sqrt(23) * closes[59] * 20) / move < 0.01, `move ${move}`); assert.equal(dailyMoveDollars(closes.slice(0, 10), 20), null); assert.equal(futuresContracts(1000, 0), 1); assert.equal(futuresContracts(2400, 0), 3); assert.equal(futuresContracts(2400, 2), 2); // $1,000 leg, stopless, 1 contract: (1000/5227)² days ≈ 0.88 h on NQ; ≈ 8 days on 6E. assert.ok(Math.abs(expectedHoldHours({ leg: 1000, stopDollars: Infinity, contracts: 1, dailyMove: 5227 }) - 0.878) < 0.01); assert.ok(expectedHoldHours({ leg: 1000, stopDollars: Infinity, contracts: 1, dailyMove: 351 }) > 190); // More contracts, bigger move, shorter hold; a stop shortens it too. assert.ok(Math.abs(expectedHoldHours({ leg: 2000, stopDollars: Infinity, contracts: 2, dailyMove: 5227 }) - 0.878) < 0.01); assert.ok(expectedHoldHours({ leg: 1000, stopDollars: 500, contracts: 1, dailyMove: 5227 }) < 0.5); assert.equal(expectedHoldHours({ leg: 1000, stopDollars: Infinity, contracts: 1, dailyMove: null }), null); }); test('session progress: full CME 17:45 → 14:55 CT, equity hours 8:35 → 14:55', () => { // 2026-09-28 is a Monday; CDT is UTC−5. const ct = (iso) => new Date(`${iso}-05:00`); assert.equal(sessionProgress(ct('2026-09-27T17:45'), 'full_cme'), 0); assert.equal(sessionProgress(ct('2026-09-27T17:30'), 'full_cme'), null); // Sunday before the open assert.ok(Math.abs(sessionProgress(ct('2026-09-28T04:00'), 'full_cme') - 615 / 1270) < 1e-9); assert.equal(sessionProgress(ct('2026-09-28T14:55'), 'full_cme'), 1); assert.equal(sessionProgress(ct('2026-09-28T15:30'), 'full_cme'), null); // maintenance assert.equal(sessionProgress(ct('2026-09-26T12:00'), 'full_cme'), null); // Saturday assert.equal(sessionProgress(ct('2026-09-27T12:00'), 'full_cme'), null); // Sunday before the open assert.equal(sessionProgress(ct('2026-10-02T15:10'), 'full_cme'), null); // Friday after the close assert.equal(sessionProgress(ct('2026-09-28T08:35'), 'equity_hours'), 0); assert.equal(sessionProgress(ct('2026-09-28T14:55'), 'equity_hours'), 1); assert.equal(sessionProgress(ct('2026-09-28T07:00'), 'equity_hours'), null); }); // Measured 2026-09-26 from Yahoo hourly closes. const MEASURED = [ { symbol: 'NQ', spreadDollars: 5, dailyMove: 5227, holdHours: null }, { symbol: 'GC', spreadDollars: 10, dailyMove: 4843, holdHours: null }, { symbol: 'CL', spreadDollars: 10, dailyMove: 3550, holdHours: null }, { symbol: '6E', spreadDollars: 6.25, dailyMove: 351, holdHours: null }, ]; const pct = (chances) => Object.fromEntries(chances.map((c) => [c.symbol, Math.round(c.chance * 1000) / 10])); test('the draw reproduces the plan\'s table: a floor early, the fastest late', () => { const rows = [ [0, { NQ: 42.3, GC: 25.0, CL: 21.0, '6E': 11.7 }], [0.5, { NQ: 49.0, GC: 27.5, CL: 16.0, '6E': 7.5 }], [0.9, { NQ: 59.9, GC: 32.5, CL: 5.7, '6E': 1.9 }], [1, { NQ: 63.0, GC: 34.2, CL: 2.9, '6E': 0.0 }], ]; for (const [p, expected] of rows) { const { chances } = drawChances(MEASURED, p); const got = pct(chances); for (const [sym, v] of Object.entries(expected)) assert.ok(Math.abs(got[sym] - v) <= 0.15, `p=${p} ${sym} ${got[sym]} vs ${v}`); assert.ok(Math.abs(chances.reduce((s, c) => s + c.chance, 0) - 1) < 1e-9); } // At the open every instrument has at least the floor. for (const c of drawChances(MEASURED, 0).chances) assert.ok(c.chance >= DRAW_FLOOR - 1e-12); }); test('the 12-hour cap disables slow instruments; nothing fitting means no draw', () => { const withHolds = MEASURED.map((c) => ({ ...c, holdHours: c.symbol === '6E' ? 195 : 1 })); const { chances, disabled } = drawChances(withHolds, 0); assert.deepEqual(disabled, ['6E']); assert.deepEqual(chances.map((c) => c.symbol), ['NQ', 'GC', 'CL']); const got = pct(chances); assert.ok(Math.abs(got.NQ - 48.9) < 0.2 && Math.abs(got.GC - 28.0) < 0.2 && Math.abs(got.CL - 23.2) < 0.2, JSON.stringify(got)); assert.equal(drawChances([{ ...MEASURED[0], holdHours: MAX_HOLD_HOURS }], 0).chances.length, 0); assert.equal(drawChances([{ ...MEASURED[0], holdHours: MAX_HOLD_HOURS - 0.01 }], 0).chances.length, 1); }); test('fallbacks: median weight when unmeasured, even when nothing is; micros only as a last resort', () => { const one = drawChances([...MEASURED.slice(0, 3), { symbol: 'ES', spreadDollars: 12.5, dailyMove: null, holdHours: null }], 0); const es = one.chances.find((c) => c.symbol === 'ES').chance; const sorted = one.chances.filter((c) => c.symbol !== 'ES').map((c) => c.chance).sort(); assert.ok(es >= sorted[0] && es <= sorted[2], `ES ${es}`); const none = drawChances(MEASURED.map((c) => ({ ...c, dailyMove: null })), 0.7); for (const c of none.chances) assert.ok(Math.abs(c.chance - 0.25) < 1e-9); const micros = [{ symbol: 'MNQ', spreadDollars: 0.5, dailyMove: 522, holdHours: 5, micro: true }]; assert.deepEqual(drawChances([...micros, MEASURED[0]], 0).chances.map((c) => c.symbol), ['NQ']); assert.deepEqual(drawChances(micros, 0).chances.map((c) => c.symbol), ['MNQ']); }); test('10k seeded draws land on the chances', () => { const { chances } = drawChances(MEASURED, 0.5); const r = rng(7); const counts = {}; for (let i = 0; i < 10000; i++) { const s = pickByChance(chances, r()); counts[s] = (counts[s] ?? 0) + 1; } for (const c of chances) assert.ok(Math.abs(counts[c.symbol] / 10000 - c.chance) < 0.02, `${c.symbol} ${counts[c.symbol]} vs ${c.chance}`); assert.equal(pickByChance([], 0.5), null); }); test('drain and extra-day legs: always MNQ', () => { // Always MNQ, whatever the firm lists; nothing for a firm that lists nothing. assert.equal(housekeepingRoot(['NQ', 'GC', 'CL', '6E']), 'MNQ'); assert.equal(housekeepingRoot(['MNQ']), 'MNQ'); assert.equal(housekeepingRoot(['GC', 'CL']), 'MNQ'); assert.equal(housekeepingRoot(['6E', 'ZN']), 'MNQ'); assert.equal(housekeepingRoot(['MCL', 'ZB']), 'MNQ'); assert.equal(housekeepingRoot([]), null); }); test('reading lists: sanitize per family; master list first, the old rules otherwise', () => { assert.deepEqual(sanitizeInstruments('tradovate', ['nq', 'GC', 'XX', 'NQ', 5]), ['NQ', 'GC']); assert.deepEqual(sanitizeInstruments('dxtrade', ['ETH/USD', 'BAD|X', 'ETH/USD', 'btc/usd']), ['ETH/USD', 'btc/usd']); const local = ['CL', 'GC', 'NQ', '6E']; assert.deepEqual(resolveAllowlist({ platform: 'tradovate', instruments: ['NQ', 'ES'], bannedSymbols: ['NQ'] }, local), { roots: ['NQ', 'ES'], source: 'master' }); assert.deepEqual(resolveAllowlist({ platform: 'tradovate', bannedSymbols: ['GC'] }, local), { roots: ['CL', 'NQ', '6E'], source: 'legacy' }); assert.deepEqual(resolveAllowlist({ platform: 'topstepx', instruments: [] }, local), { roots: [], source: 'master' }); assert.deepEqual( resolveAllowlist({ platform: 'dxtrade', symbolMap: { NQ: [{ symbol: 'XYZ100/USD' }], '*': [{ symbol: 'ETH/USD' }, { symbol: 'XYZ100/USD' }] } }, local), { roots: ['ETH/USD', 'XYZ100/USD'], source: 'legacy' }, ); }); // ── live quotes and the entry spread limit ────────────────────────────────── test('TopstepX quotes: a side the hub leaves out keeps its last price', () => { let book = mergeTopstepxQuote(undefined, { symbol: 'F.US.ENQ', bestBid: 24000.25, bestAsk: 24000.5, lastPrice: 24000.5 }); assert.deepEqual(book, { bid: 24000.25, ask: 24000.5 }); book = mergeTopstepxQuote(book, { bestAsk: 24001 }); assert.deepEqual(book, { bid: 24000.25, ask: 24001 }); // Wrapped in { data }, as the user hub's events are; junk prices ignored. assert.deepEqual(mergeTopstepxQuote(book, { data: { bestBid: 24000.75, bestAsk: 0 } }), { bid: 24000.75, ask: 24001 }); assert.deepEqual(mergeTopstepxQuote(undefined, null), {}); }); test('Tradovate market-data frames: replies and quotes', () => { const frame = 'a' + JSON.stringify([ { s: 200, i: 1 }, { s: 404, i: 3, d: 'Symbol not found' }, { e: 'md', d: { quotes: [ { contractId: 3267315, timestamp: '2026-09-27T22:10:00Z', entries: { Bid: { price: 24000.25, size: 3 }, Offer: { price: 24000.5, size: 5 }, Trade: { price: 24000.5 } } }, { contractId: 3273057, entries: { Offer: { price: 71.24, size: 2 } } }, { entries: { Bid: { price: 1 } } }, ] } }, ]); const parsed = parseTradovateFrame(frame); assert.deepEqual(parsed.responses, [{ id: 1, status: 200, data: undefined }, { id: 3, status: 404, data: 'Symbol not found' }]); assert.deepEqual(parsed.quotes, [ { contractId: 3267315, bid: 24000.25, ask: 24000.5 }, { contractId: 3273057, ask: 71.24 }, ]); for (const other of ['o', 'h', 'c[1000,"bye"]', 'a[not json', 42]) assert.deepEqual(parseTradovateFrame(other), { responses: [], quotes: [] }); }); test('the spread off the book, in dollars and ticks', () => { const nq = CME_SPECS.NQ; assert.deepEqual(bookSpread({ bid: 24000.25, ask: 24000.5 }, nq.pointValue, nq.tickSize), { dollars: 5, ticks: 1 }); assert.deepEqual(bookSpread({ bid: 24000, ask: 24001 }, nq.pointValue, nq.tickSize), { dollars: 20, ticks: 4 }); const gc = CME_SPECS.GC; assert.deepEqual(bookSpread({ bid: 3650.1, ask: 3650.3 }, gc.pointValue, gc.tickSize), { dollars: 20, ticks: 2 }); // A locked book is zero wide; a crossed or one-sided one isn't a spread. assert.deepEqual(bookSpread({ bid: 71.2, ask: 71.2 }, 1000, 0.01), { dollars: 0, ticks: 0 }); assert.equal(bookSpread({ bid: 71.3, ask: 71.2 }, 1000, 0.01), null); assert.equal(bookSpread({ bid: 71.2 }, 1000, 0.01), null); assert.equal(bookSpread(undefined, 1000, 0.01), null); }); test('the entry limit: $15 by default; one tick of the listed roots fits under it', () => { assert.equal(DEFAULT_MAX_ENTRY_SPREAD, 15); for (const root of ['NQ', 'GC', 'CL', 'ES', 'MNQ']) assert.equal(spreadTooWide(spreadDollars(root), 15), false, root); assert.equal(spreadTooWide(15, 15), false); assert.equal(spreadTooWide(15.01, 15), true); assert.equal(spreadTooWide(20, 15), true); // 0 = no limit; a NaN spread never blocks. assert.equal(spreadTooWide(500, 0), false); assert.equal(spreadTooWide(NaN, 15), false); // One tick of these is over $15 by itself. for (const root of ['SI', 'ZB', 'ZN']) assert.equal(spreadTooWide(spreadDollars(root), 15), true, root); }); test('the draw leaves out what is over the spread limit', () => { const cands = [ { symbol: 'NQ', spreadDollars: 20, dailyMove: 5164, holdHours: 0.9, wide: true }, { symbol: 'GC', spreadDollars: 10, dailyMove: 4899, holdHours: 1.0 }, { symbol: 'CL', spreadDollars: 10, dailyMove: 3554, holdHours: 1.9 }, ]; const { chances, wide, disabled } = drawChances(cands, 0); assert.deepEqual(wide, ['NQ']); assert.deepEqual(disabled, []); assert.deepEqual(chances.map((c) => c.symbol), ['GC', 'CL']); assert.ok(Math.abs(chances.reduce((s, c) => s + c.chance, 0) - 1) < 1e-9); // All of them wide: nothing to draw. assert.deepEqual(drawChances(cands.map((c) => ({ ...c, wide: true })), 0.5).chances, []); }); test('a firm\'s per-instrument limit caps the contracts, with the account type\'s max', () => { // One per $1,000 of leg ... assert.equal(futuresContracts(4200, 0), 5); // ... capped by the account type's max, the firm's limit, whichever is lower. assert.equal(futuresContracts(4200, 0, 3), 3); assert.equal(futuresContracts(4200, 4, 2), 2); assert.equal(futuresContracts(4200, 2, 4), 2); assert.equal(futuresContracts(4200, 0, 0), 5); // Never under one contract. assert.equal(futuresContracts(300, 0, 5), 1); // A capped leg holds longer: 5 contracts on NQ vs a cap of 2. const free = expectedHoldHours({ leg: 5000, stopDollars: Infinity, contracts: futuresContracts(5000, 0), dailyMove: 5227 }); const capped = expectedHoldHours({ leg: 5000, stopDollars: Infinity, contracts: futuresContracts(5000, 0, 2), dailyMove: 5227 }); assert.ok(Math.abs(capped / free - 6.25) < 1e-9, `${capped} vs ${free}`); }); test('instrument limits off the catalog: CME roots, whole numbers 1-1000', () => { assert.deepEqual(sanitizeInstrumentLimits({ NQ: 15, CL: 5, GC: 0, ES: 2.5, ZZ: 3, SI: 2000, YM: '4' }), { NQ: 15, CL: 5 }); assert.deepEqual(sanitizeInstrumentLimits(null), {}); assert.deepEqual(sanitizeInstrumentLimits(['NQ']), {}); }); test('Topstep commission: every root, a micro at half the full-size rate', () => { assert.deepEqual(Object.keys(TOPSTEP_COMMISSION_PER_SIDE).sort(), [...CME_ROOTS].sort()); for (const root of CME_ROOTS) assert.equal(TOPSTEP_COMMISSION_PER_SIDE[root], CME_SPECS[root].micro ? 0.25 : 0.5, root); }); test('Topstep commission: TopstepX contract ids to roots', () => { // Codes seen on real fills. assert.equal(topstepxRoot('CON.F.US.ENQ.Z26'), 'NQ'); assert.equal(topstepxRoot('CON.F.US.MNQ.Z26'), 'MNQ'); assert.equal(topstepxRoot('CON.F.US.GCE.Z26'), 'GC'); assert.equal(topstepxRoot('CON.F.US.CLE.X26'), 'CL'); assert.equal(topstepxProductCode('CON.F.US.ENQ.Z26'), 'ENQ'); // Not a contract id, or a product with no entry. assert.equal(topstepxRoot('NQZ6'), null); assert.equal(topstepxRoot(undefined), null); assert.equal(topstepxRoot('CON.F.US.MBT.Z26'), null); // A contract the client resolved names its own code; a stray key doesn't. assert.equal(topstepxRoot('CON.F.US.MBT.Z26', new Map([['MBT', 'MES']])), 'MES'); assert.equal(topstepxRoot('CON.F.US.ENQ.Z26', new Map([['ENQ', '*']])), 'NQ'); }); test('Topstep commission: reported fees plus commission make the published round turn', () => { // TopstepX reports exchange + NFA a side; Topstep's page totals a round turn. const roundTurn = (root, reportedPerSide, contracts) => Math.round((2 * reportedPerSide * contracts + 2 * topstepCommission(root, contracts)) * 100) / 100; assert.equal(roundTurn('NQ', 1.39, 1), 3.78); assert.equal(roundTurn('MNQ', 0.36, 1), 1.22); assert.equal(roundTurn('GC', 1.66, 1), 4.32); assert.equal(roundTurn('CL', 1.51, 1), 4.02); assert.equal(roundTurn('NQ', 1.39, 3), 11.34); // A sell fill's size is still a cost; an unknown product pays the full-size rate. assert.equal(topstepCommission('NQ', -3), 1.5); assert.equal(topstepCommission(null, 2), 1); // 2026-10-18, an XFA $27.50 under its books: 26 full-size round turns and 3 MNQ. const missing = 26 * 2 * topstepCommission('NQ', 1) + 3 * 2 * topstepCommission('MNQ', 1); assert.equal(missing, 27.5); });