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