Files
autofirmer-expanded/lib/instrument-specs.ts
T
Brandon LiandClaude Opus 5.5 58e7414849 Futures legs respect a firm's per-instrument contract limit
A firm can cap how many contracts of an instrument its accounts may hold
(the catalog's instrumentLimits, set on the master's Firms page). A leg
trades one contract per $1,000, capped by the account type's max contracts
and that limit. The Auto draw sizes each instrument's hold with the capped
count, so a limit that slows a leg past 12 hours takes it out of the draw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:45:44 -05:00

292 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 and the firm's limit on the instrument (0 = no
* cap) — the rule fireLeg sizes by. */
export function futuresContracts(leg: number, maxPositionSize: number, instrumentLimit = 0): number {
const raw = Math.max(1, Math.ceil(leg / 1000));
const caps = [maxPositionSize, instrumentLimit].filter((c) => c > 0);
return caps.length > 0 ? Math.min(raw, ...caps) : raw;
}
/** The most contracts a firm allows on one instrument, as its catalog entry
* says: CME roots only, whole numbers from 1 to 1000. Anything else is
* dropped — no limit beyond the account type's max contracts. */
export function sanitizeInstrumentLimits(raw: unknown): Record<string, number> {
const out: Record<string, number> = {};
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return out;
for (const [root, value] of Object.entries(raw as Record<string, unknown>)) {
if (CME_SPECS[root] && typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 1000) out[root] = value;
}
return out;
}
/**
* 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' };
}