Live futures spreads off one client, and the master's max entry spread

One quote feed per machine: a TopstepX client's market hub, else a
Tradovate client's market-data socket, subscribed to every listed root's
front month and the drain micro. A supervisor keeps it on a working client,
dropping one that fails or goes quiet while the market trades.

The Auto draw weighs each instrument by its live spread (the typical tick
when no fresh quote is at hand). No futures entry pays more than the
master's max entry spread ($15 until set) to cross the book: the draw
leaves such an instrument out, an explicit symbol sits the tick out, and
fireLeg and the extra-day trade check it last before sending. The Settings
page and the master's stats show the live spreads and the feed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-27 15:06:44 -05:00
co-authored by Claude Opus 5.5
parent 1782c16145
commit db1d12327a
13 changed files with 747 additions and 33 deletions
+21 -7
View File
@@ -15,8 +15,8 @@ export interface CmeSpec {
/** 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. */
/** 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;
@@ -70,6 +70,15 @@ export function spreadDollars(root: string): number {
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;
@@ -151,6 +160,8 @@ export interface DrawCandidate {
/** 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;
}
/**
@@ -164,14 +175,15 @@ export interface DrawCandidate {
* 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.
* (`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[] } {
): { 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;
@@ -179,9 +191,10 @@ export function drawChances(
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));
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 };
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)
@@ -199,6 +212,7 @@ export function drawChances(
return {
chances: pool.map((c, i) => ({ symbol: c.symbol, chance: floor + (1 - n * floor) * (weights[i] / total) })),
disabled,
wide,
};
}