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>
237 lines
14 KiB
JavaScript
237 lines
14 KiB
JavaScript
// 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, []);
|
||
});
|