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>
283 lines
13 KiB
TypeScript
283 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 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' };
|
||
}
|