Files
autofirmer-expanded/scripts/check-instruments.mjs
T
Brandon LiandClaude Opus 5.5 ed62788480 Drain and extra-day legs always trade MNQ
They traded the micro of whatever the firm listed: MNQ with NQ, but MGC
for a firm listing only GC and CL. Now every futures firm's tiny legs go on
MNQ, the one micro the bot has always drained on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 18:54:41 -05:00

237 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: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, []);
});