They traded the micro of whatever the firm listed: MNQ with NQ, but MGC for a firm listing only GC and CL. Now every futures firm's tiny legs go on MNQ, the one micro the bot has always drained on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
278 lines
13 KiB
TypeScript
278 lines
13 KiB
TypeScript
/**
|
||
* 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<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;
|
||
}
|
||
|
||
/** 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:45 → 14:55 CT the next day (trading-logic's
|
||
* CME_EVENING_OPEN_MIN — no imports here); 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 + 45;
|
||
const length = 24 * 60 - open + 14 * 60 + 55; // 21h10m
|
||
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 every futures firm's drain and extra-day legs trade. */
|
||
export const HOUSEKEEPING_ROOT = 'MNQ';
|
||
|
||
/**
|
||
* What a firm's drain and extra-day legs trade: always MNQ — tiny targets
|
||
* belong on a micro, whatever the firm lists for its full-size legs. null
|
||
* for an empty list: that firm trades nothing.
|
||
*/
|
||
export function housekeepingRoot(listed: readonly string[]): string | null {
|
||
return listed.some((r) => CME_SPECS[r]) ? HOUSEKEEPING_ROOT : null;
|
||
}
|
||
|
||
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' };
|
||
}
|