Files
autofirmer-expanded/scripts/check-instruments.mjs
T
Brandon LiandClaude Opus 5.5 58e7414849 Futures legs respect a firm's per-instrument contract limit
A firm can cap how many contracts of an instrument its accounts may hold
(the catalog's instrumentLimits, set on the master's Firms page). A leg
trades one contract per $1,000, capped by the account type's max contracts
and that limit. The Auto draw sizes each instrument's hold with the capped
count, so a limit that slows a leg past 12 hours takes it out of the draw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:45:44 -05:00

259 lines
15 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, sanitizeInstrumentLimits,
} 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: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']), {});
});