Files
autofirmer-expanded/scripts/check-instruments.mjs
T
Brandon LiandClaude Opus 5.5 db1d12327a Live futures spreads off one client, and the master's max entry spread
One quote feed per machine: a TopstepX client's market hub, else a
Tradovate client's market-data socket, subscribed to every listed root's
front month and the drain micro. A supervisor keeps it on a working client,
dropping one that fails or goes quiet while the market trades.

The Auto draw weighs each instrument by its live spread (the typical tick
when no fresh quote is at hand). No futures entry pays more than the
master's max entry spread ($15 until set) to cross the book: the draw
leaves such an instrument out, an explicit symbol sits the tick out, and
fireLeg and the extra-day trade check it last before sending. The Settings
page and the master's stats show the live spreads and the feed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:06:44 -05:00

235 lines
14 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 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,
} from '../lib/instrument-specs.ts';
import { mergeTopstepxQuote, parseTradovateFrame, bookSpread } from '../lib/quote-helpers.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:05 → 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:05'), 'full_cme'), 0);
assert.ok(Math.abs(sessionProgress(ct('2026-09-28T04:00'), 'full_cme') - 655 / 1310) < 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: a micro of what is listed', () => {
assert.equal(housekeepingRoot(['NQ', 'GC', 'CL', '6E']), 'MNQ');
assert.equal(housekeepingRoot(['MNQ']), 'MNQ');
assert.equal(housekeepingRoot(['GC', 'CL']), 'MGC');
assert.equal(housekeepingRoot(['6E', 'ZN']), '6E'); // no micro: the smallest tick value
assert.equal(housekeepingRoot(['MCL', 'ZB']), 'MCL');
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, []);
});