/** * 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 — a liquid front month is one tick * wide. Used when no live quote is at hand (lib/live-quotes.ts). */ 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> = { 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> = Object.fromEntries( CME_ROOTS.map((root) => [root, CME_SPECS[root].pointValue]), ); /** Each full-size contract's micro. */ export const MICRO_OF: Readonly> = { 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; } /** The most an entry may pay to cross the spread, per contract, when the * master's catalog doesn't say (its Settings page sets it). */ export const DEFAULT_MAX_ENTRY_SPREAD = 15; /** Whether a spread is over the entry limit. A limit of 0 or less is off. */ export function spreadTooWide(spreadDollars: number, maxDollars: number): boolean { return maxDollars > 0 && Number.isFinite(spreadDollars) && spreadDollars > maxDollars + 1e-9; } /** 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; /** Its live spread is over the entry limit right now. */ wide?: 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`), anything whose live spread is over the entry limit (`wide`), * 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[]; wide: 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); const wide = candidates.filter((c) => c.wide === true).map((c) => c.symbol); let pool = candidates.filter((c) => !disabled.includes(c.symbol) && !wide.includes(c.symbol)); if (pool.some((c) => !c.micro)) pool = pool.filter((c) => !c.micro); if (pool.length === 0) return { chances: [], disabled, wide }; 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, wide, }; } /** 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 }, 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' }; }