Instruments: each firm's list from the master, and a session-aware futures draw
- 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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
302e9b4911
commit
02544fbdf5
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Contract specs for the CME futures the bot trades, and the pure maths of
|
||||
* choosing between them — the session-aware Auto draw, the 12-hour hold cap,
|
||||
* the legs that always trade a micro — plus reading a firm's instrument list
|
||||
* off the catalog.
|
||||
*
|
||||
* Specs live here, not on the master: point value and tick size size real
|
||||
* orders, so they aren't editable remotely. The master only knows the names.
|
||||
*
|
||||
* No imports: scripts/check-instruments.mjs loads this file directly.
|
||||
*/
|
||||
|
||||
export interface CmeSpec {
|
||||
name: string;
|
||||
/** Dollars per 1.0 of price, per contract. */
|
||||
pointValue: number;
|
||||
tickSize: number;
|
||||
/** Typical bid/ask spread in ticks. There are no live futures quotes, and
|
||||
* a liquid front month is one tick wide. */
|
||||
spreadTicks: number;
|
||||
/** Yahoo's continuous-contract ticker, for hourly candles. */
|
||||
yahoo: string;
|
||||
micro?: boolean;
|
||||
}
|
||||
|
||||
const spec = (name: string, pointValue: number, tickSize: number, yahoo: string, micro = false): CmeSpec =>
|
||||
({ name, pointValue, tickSize, spreadTicks: 1, yahoo, ...(micro ? { micro } : {}) });
|
||||
|
||||
export const CME_SPECS: Readonly<Record<string, CmeSpec>> = {
|
||||
NQ: spec('Nasdaq-100', 20, 0.25, 'NQ=F'),
|
||||
MNQ: spec('Micro Nasdaq-100', 2, 0.25, 'MNQ=F', true),
|
||||
ES: spec('S&P 500', 50, 0.25, 'ES=F'),
|
||||
MES: spec('Micro S&P 500', 5, 0.25, 'MES=F', true),
|
||||
YM: spec('Dow', 5, 1, 'YM=F'),
|
||||
MYM: spec('Micro Dow', 0.5, 1, 'MYM=F', true),
|
||||
RTY: spec('Russell 2000', 50, 0.1, 'RTY=F'),
|
||||
M2K: spec('Micro Russell 2000', 5, 0.1, 'M2K=F', true),
|
||||
GC: spec('Gold', 100, 0.1, 'GC=F'),
|
||||
MGC: spec('Micro Gold', 10, 0.1, 'MGC=F', true),
|
||||
SI: spec('Silver', 5000, 0.005, 'SI=F'),
|
||||
CL: spec('Crude Oil', 1000, 0.01, 'CL=F'),
|
||||
MCL: spec('Micro Crude Oil', 100, 0.01, 'MCL=F', true),
|
||||
NG: spec('Natural Gas', 10000, 0.001, 'NG=F'),
|
||||
ZB: spec('30-Year T-Bond', 1000, 1 / 32, 'ZB=F'),
|
||||
ZN: spec('10-Year T-Note', 1000, 1 / 64, 'ZN=F'),
|
||||
ZF: spec('5-Year T-Note', 1000, 1 / 128, 'ZF=F'),
|
||||
'6E': spec('Euro FX', 125000, 0.00005, '6E=F'),
|
||||
'6J': spec('Japanese Yen', 12500000, 0.0000005, '6J=F'),
|
||||
'6B': spec('British Pound', 62500, 0.0001, '6B=F'),
|
||||
};
|
||||
|
||||
export const CME_ROOTS: readonly string[] = Object.keys(CME_SPECS);
|
||||
|
||||
/** $ per point, per contract — the table the engine has always used. */
|
||||
export const POINT_VALUES: Readonly<Record<string, number>> = Object.fromEntries(
|
||||
CME_ROOTS.map((root) => [root, CME_SPECS[root].pointValue]),
|
||||
);
|
||||
|
||||
/** Each full-size contract's micro. */
|
||||
export const MICRO_OF: Readonly<Record<string, string>> = { NQ: 'MNQ', ES: 'MES', YM: 'MYM', RTY: 'M2K', GC: 'MGC', CL: 'MCL' };
|
||||
|
||||
export function tickValue(root: string): number {
|
||||
const s = CME_SPECS[root];
|
||||
return s ? s.tickSize * s.pointValue : NaN;
|
||||
}
|
||||
|
||||
/** One contract crossing the typical spread, in dollars. */
|
||||
export function spreadDollars(root: string): number {
|
||||
const s = CME_SPECS[root];
|
||||
return s ? s.spreadTicks * s.tickSize * s.pointValue : NaN;
|
||||
}
|
||||
|
||||
/** Hold cap: an instrument whose next leg is expected to take this long or
|
||||
* longer is out of the draw. */
|
||||
export const MAX_HOLD_HOURS = 12;
|
||||
/** Every drawable instrument's minimum share at the session's open; it fades
|
||||
* to nothing by the last entry. */
|
||||
export const DRAW_FLOOR = 0.10;
|
||||
/** How hard the end of the session leans on the fastest instruments. */
|
||||
export const LATE_SHARPNESS = 8;
|
||||
/** Hourly bars in a CME trading day (23 of 24 hours trade). */
|
||||
export const BARS_PER_DAY = 23;
|
||||
const MIN_CLOSES = 25;
|
||||
|
||||
/**
|
||||
* One contract's typical daily move (1σ), in dollars, from hourly closes
|
||||
* oldest first: σ of the hourly log returns × √(bars per day) × the last
|
||||
* price × the point value. null on too few closes.
|
||||
*/
|
||||
export function dailyMoveDollars(closes: number[], pointValue: number, barsPerDay = BARS_PER_DAY): number | null {
|
||||
const clean = closes.filter((c) => Number.isFinite(c) && c > 0);
|
||||
if (clean.length < MIN_CLOSES || !(pointValue > 0)) return null;
|
||||
const r: number[] = [];
|
||||
for (let i = 1; i < clean.length; i++) r.push(Math.log(clean[i] / clean[i - 1]));
|
||||
const mean = r.reduce((s, x) => s + x, 0) / r.length;
|
||||
const sd = Math.sqrt(r.reduce((s, x) => s + (x - mean) ** 2, 0) / (r.length - 1));
|
||||
return sd * Math.sqrt(barsPerDay) * clean[clean.length - 1] * pointValue;
|
||||
}
|
||||
|
||||
/** Contracts a futures leg trades: one per $1,000, capped by the account
|
||||
* type's max contracts (0 = no cap) — the rule fireLeg sizes by. */
|
||||
export function futuresContracts(leg: number, maxPositionSize: number): number {
|
||||
const raw = Math.max(1, Math.ceil(leg / 1000));
|
||||
return maxPositionSize > 0 ? Math.min(raw, maxPositionSize) : raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expected hours for a leg to reach its take-profit or stop, as a fair walk:
|
||||
* (A / move)(B / move) days, with the move scaled by the contracts. A
|
||||
* stopless leg is taken as B = A. null without a move.
|
||||
*/
|
||||
export function expectedHoldHours(o: { leg: number; stopDollars: number; contracts: number; dailyMove: number | null }): number | null {
|
||||
if (o.dailyMove === null || !(o.dailyMove > 0) || !(o.leg > 0)) return null;
|
||||
const move = o.dailyMove * Math.max(1, o.contracts);
|
||||
const b = Number.isFinite(o.stopDollars) && o.stopDollars > 0 ? o.stopDollars : o.leg;
|
||||
return (o.leg / move) * (b / move) * 24;
|
||||
}
|
||||
|
||||
function centralMinutes(now: Date): { day: string; minutes: number } {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'America/Chicago', weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
|
||||
}).formatToParts(now);
|
||||
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '';
|
||||
return { day: get('weekday'), minutes: (parseInt(get('hour'), 10) % 24) * 60 + parseInt(get('minute'), 10) };
|
||||
}
|
||||
|
||||
/**
|
||||
* How far through the trading session `now` is: 0 at the open, 1 at the last
|
||||
* entry. full_cme runs 17:05 → 14:55 CT the next day; equity_hours 8:35 →
|
||||
* 14:55. null outside the session.
|
||||
*/
|
||||
export function sessionProgress(now: Date, mode: string): number | null {
|
||||
const { day, minutes } = centralMinutes(now);
|
||||
if (mode === 'equity_hours') {
|
||||
if (day === 'Sat' || day === 'Sun' || minutes < 515 || minutes > 895) return null;
|
||||
return (minutes - 515) / (895 - 515);
|
||||
}
|
||||
const open = 17 * 60 + 5;
|
||||
const length = 24 * 60 - open + 14 * 60 + 55; // 21h50m
|
||||
const since = (minutes - open + 1440) % 1440;
|
||||
if (since > length) return null;
|
||||
if (day === 'Sat' || (day === 'Fri' && minutes > 895) || (day === 'Sun' && minutes < open)) return null;
|
||||
return since / length;
|
||||
}
|
||||
|
||||
export interface DrawCandidate {
|
||||
symbol: string;
|
||||
spreadDollars: number;
|
||||
/** One contract's daily move in dollars; null = not measured. */
|
||||
dailyMove: number | null;
|
||||
/** The next leg's expected hold on it; null = unknown. */
|
||||
holdHours: number | null;
|
||||
micro?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Each instrument's share of the Auto draw at session progress `p`.
|
||||
*
|
||||
* Efficiency = daily move ÷ spread: movement per dollar paid to cross the
|
||||
* spread (commissions don't count). Speed ∝ move², since a fair walk's hold
|
||||
* time goes with 1/move². With q = p², the weight slides from efficiency
|
||||
* early to a sharp preference for the fastest late:
|
||||
* weight = efficiency^(1−q) × move^(sharpness·q)
|
||||
* and every drawable instrument gets a floor of DRAW_FLOOR × (1−q).
|
||||
*
|
||||
* Out of the draw: anything whose expected hold is MAX_HOLD_HOURS or more
|
||||
* (`disabled`), and micros unless nothing else is left. An unmeasured
|
||||
* instrument gets the median weight; if none are measured, the draw is even.
|
||||
*/
|
||||
export function drawChances(
|
||||
candidates: DrawCandidate[],
|
||||
p: number | null,
|
||||
opts: { floor?: number; sharpness?: number; maxHoldHours?: number } = {},
|
||||
): { chances: { symbol: string; chance: number }[]; disabled: string[] } {
|
||||
const floorBase = opts.floor ?? DRAW_FLOOR;
|
||||
const sharpness = opts.sharpness ?? LATE_SHARPNESS;
|
||||
const maxHold = opts.maxHoldHours ?? MAX_HOLD_HOURS;
|
||||
const progress = p === null ? 0 : Math.min(1, Math.max(0, p));
|
||||
const q = progress * progress;
|
||||
|
||||
const disabled = candidates.filter((c) => c.holdHours !== null && c.holdHours >= maxHold).map((c) => c.symbol);
|
||||
let pool = candidates.filter((c) => !disabled.includes(c.symbol));
|
||||
if (pool.some((c) => !c.micro)) pool = pool.filter((c) => !c.micro);
|
||||
if (pool.length === 0) return { chances: [], disabled };
|
||||
|
||||
const logWeight = (c: DrawCandidate): number | null => (c.dailyMove !== null && c.dailyMove > 0 && c.spreadDollars > 0
|
||||
? (1 - q) * Math.log(c.dailyMove / c.spreadDollars) + sharpness * q * Math.log(c.dailyMove)
|
||||
: null);
|
||||
const measured = pool.map(logWeight).filter((w): w is number => w !== null).sort((a, b) => a - b);
|
||||
const median = measured.length === 0 ? 0 : measured.length % 2
|
||||
? measured[(measured.length - 1) / 2]
|
||||
: (measured[measured.length / 2 - 1] + measured[measured.length / 2]) / 2;
|
||||
const logs = pool.map((c) => logWeight(c) ?? median);
|
||||
const top = Math.max(...logs);
|
||||
const weights = logs.map((l) => Math.exp(l - top));
|
||||
const total = weights.reduce((s, w) => s + w, 0);
|
||||
const n = pool.length;
|
||||
const floor = Math.min(floorBase * (1 - q), 1 / n);
|
||||
return {
|
||||
chances: pool.map((c, i) => ({ symbol: c.symbol, chance: floor + (1 - n * floor) * (weights[i] / total) })),
|
||||
disabled,
|
||||
};
|
||||
}
|
||||
|
||||
/** The instrument a uniform random number in [0, 1) lands on. */
|
||||
export function pickByChance(chances: { symbol: string; chance: number }[], rand: number): string | null {
|
||||
let acc = 0;
|
||||
for (const c of chances) {
|
||||
acc += c.chance;
|
||||
if (rand < acc) return c.symbol;
|
||||
}
|
||||
return chances.length > 0 ? chances[chances.length - 1].symbol : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a firm's drain and extra-day legs trade — tiny, so a micro: MNQ when
|
||||
* NQ or MNQ is listed (as always), else the micro of a listed full-size
|
||||
* contract, else a listed micro, else the listed root with the smallest tick
|
||||
* value. null for an empty list.
|
||||
*/
|
||||
export function housekeepingRoot(listed: readonly string[]): string | null {
|
||||
const roots = listed.filter((r) => CME_SPECS[r]);
|
||||
if (roots.length === 0) return null;
|
||||
if (roots.includes('NQ') || roots.includes('MNQ')) return 'MNQ';
|
||||
const twin = roots.find((r) => MICRO_OF[r]);
|
||||
if (twin) return MICRO_OF[twin];
|
||||
const micro = roots.find((r) => CME_SPECS[r].micro);
|
||||
if (micro) return micro;
|
||||
return [...roots].sort((a, b) => tickValue(a) - tickValue(b))[0];
|
||||
}
|
||||
|
||||
const DXTRADE_SYMBOL = /^[A-Za-z0-9./_:-]{1,32}$/;
|
||||
|
||||
/** A catalog list, cleaned for the firm's family: CME roots this build has
|
||||
* specs for, or valid DXtrade symbols. Deduplicated, in order. */
|
||||
export function sanitizeInstruments(platform: string, raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const futures = platform !== 'dxtrade';
|
||||
const out: string[] = [];
|
||||
for (const value of raw) {
|
||||
if (typeof value !== 'string') continue;
|
||||
const symbol = futures ? value.trim().toUpperCase() : value.trim();
|
||||
const ok = futures ? Boolean(CME_SPECS[symbol]) : DXTRADE_SYMBOL.test(symbol);
|
||||
if (ok && !out.includes(symbol)) out.push(symbol);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The instruments a firm trades. The master's list when the catalog carries
|
||||
* one; otherwise (a master from before the lists) the old rules: a futures
|
||||
* firm trades this machine's enabled instruments less its banned symbols; a
|
||||
* DXtrade firm every symbol in its map, the '*' row first.
|
||||
*/
|
||||
export function resolveAllowlist(
|
||||
firm: { platform: string; instruments?: string[]; bannedSymbols?: string[]; symbolMap?: Record<string, { symbol: string }[]> },
|
||||
localEnabled: readonly string[],
|
||||
): { roots: string[]; source: 'master' | 'legacy' } {
|
||||
if (Array.isArray(firm.instruments)) return { roots: sanitizeInstruments(firm.platform, firm.instruments), source: 'master' };
|
||||
if (firm.platform === 'dxtrade') {
|
||||
const map = firm.symbolMap ?? {};
|
||||
const keys = Object.keys(map).sort((a, b) => (a === '*' ? -1 : b === '*' ? 1 : a.localeCompare(b)));
|
||||
return { roots: sanitizeInstruments('dxtrade', keys.flatMap((k) => (map[k] ?? []).map((c) => c.symbol))), source: 'legacy' };
|
||||
}
|
||||
const banned = new Set(firm.bannedSymbols ?? []);
|
||||
return { roots: sanitizeInstruments(firm.platform, localEnabled.filter((r) => !banned.has(r))), source: 'legacy' };
|
||||
}
|
||||
Reference in New Issue
Block a user