- lib/instrument-specs.ts (pure, tested): the CME specs — point value, tick size, one-tick spread, Yahoo ticker — replacing POINT_VALUES; the draw's maths; the 12-hour hold cap; the micro for drain and extra-day legs; reading a firm's list (the master's, or the old rules against an older master: this machine's enabled set less bans, the symbol map). - lib/instruments.ts: the one module the trading paths ask. Measures every future's daily move from Yahoo's hourly candles once per trade date, and shows entry cost (a tick of spread plus Tradovate's fee parameters or TopstepX's last measured fill) without ever choosing by it. - Futures Auto draws one root per batch from the batch firm's list: movement per dollar of spread early in the session with a 10% floor each, sliding to the fastest movers by the last entry; nothing expected to hold 12h or more; micros only if nothing else is listed. Every draw logs its odds. - The batch is chosen before its instrument, and a group that can't trade anything yields to the next. Copy trade matches the held contract against the listed roots and won't copy an instrument it can't identify. Manual legs, manual orders and drain/extra-day legs follow the lists. - DXtrade firms rank their own list whatever root was drawn. - Capabilities, the dashboard's trade bar and contract resolves follow the lists; the trade bar and scheduler default to Auto. The master link reconnects when master_dashboard_url changes, and the catalog drops ?since after a switch so two masters' versions can't collide. - Reports carry each futures firm's instrumentStats for the master's Firms page; GET /api/instruments shows them (?simulate=N runs the draw, no orders); the Settings page shows the lists read-only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
162 lines
9.3 KiB
JavaScript
162 lines
9.3 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,
|
||
} from '../lib/instrument-specs.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' },
|
||
);
|
||
});
|