Tradovate moves each account's cash record to the new trade date on its own schedule, an idle one perhaps not until it trades, and the bot holds any account still on the old day until 5:45. Opening at 5:05 fired the ready accounts alone (TopstepX, the early rollers) while the rest, released at 5:45, waited an hour or more for them to go flat. A 5:45 open puts every account in the first batch and skips the open's widest spreads. The day's 0–180 minute delay still comes on top: first entries land 5:45–8:45 PM. Every-day firms keep their own 5:05 PM end of the roll guard; the equity hours open is unchanged. The draw's session progress now starts at 5:45. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
236 lines
14 KiB
JavaScript
236 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: 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, []);
|
||
});
|