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
+54 -14
View File
@@ -10,20 +10,26 @@
* near the last entry, nothing expected to hold 12 hours or more.
*
* Movement comes from Yahoo's hourly candles (7 days, measured once per trade
* date); the spread is one tick. Commissions play no part — they barely
* differ between instruments.
* date); the spread is live off one futures client's quotes (lib/live-quotes)
* when a fresh one is at hand, else the typical one tick. Commissions play no
* part — they barely differ between instruments.
*
* No entry pays more than the master's max entry spread (its Settings page)
* to cross the book: an instrument over it is out of the draw, and every
* entry path asks spreadBlock before sending.
*/
import axios from 'axios';
import { getInstruments, getSetting } from './db';
import { getTradableFirms } from './firm-catalog';
import { getTradableFirms, getTradingSettings } from './firm-catalog';
import { entrySpread, quoteSource } from './live-quotes';
import { getCachedContract } from './contract-resolver';
import { currentTradeDateCT } from './trading-logic';
import { errorMessage } from './platform-client';
import type { InstrumentStats } from './master-protocol';
import {
CME_SPECS, CME_ROOTS, spreadDollars, dailyMoveDollars, futuresContracts, expectedHoldHours, sessionProgress,
drawChances, pickByChance, resolveAllowlist, MAX_HOLD_HOURS, type DrawCandidate,
CME_SPECS, CME_ROOTS, dailyMoveDollars, futuresContracts, expectedHoldHours, sessionProgress,
drawChances, pickByChance, resolveAllowlist, spreadTooWide, MAX_HOLD_HOURS, type DrawCandidate,
} from './instrument-specs';
export interface FirmLike {
@@ -155,27 +161,51 @@ function progressNow(now: Date): number | null {
return sessionProgress(now, getSetting('trading_hours') ?? 'full_cme');
}
/** The master's limit on what an entry pays to cross the spread (0 = none). */
export function maxEntrySpread(): number {
return getTradingSettings().maxEntrySpreadDollars;
}
/**
* Why an entry on `root` can't go out right now — its spread (live, else the
* typical tick) is over the master's limit — or null when it can. Every
* futures entry asks this before sending.
*/
export function spreadBlock(root: string): string | null {
if (!CME_SPECS[root]) return null;
const max = maxEntrySpread();
const spread = entrySpread(root);
if (!spreadTooWide(spread.dollars, max)) return null;
return `${root} spread $${spread.dollars.toFixed(2)} (${spread.ticks} tick${spread.ticks === 1 ? '' : 's'}${spread.live ? '' : ', typical'})`
+ ` is over the $${max} entry limit`;
}
/** Every root sized for the leg, and the draw's chances. */
export function futuresBoard(roots: readonly string[], leg: LegShape | null, now = new Date()): {
progress: number | null;
candidates: DrawCandidate[];
candidates: (DrawCandidate & { spreadLive: boolean })[];
chances: { symbol: string; chance: number }[];
disabled: string[];
wide: string[];
} {
const contracts = leg ? futuresContracts(leg.leg, leg.maxPositionSize) : 1;
const candidates = roots.filter((r) => CME_SPECS[r]).map((root): DrawCandidate => {
const max = maxEntrySpread();
const candidates = roots.filter((r) => CME_SPECS[r]).map((root) => {
const dailyMove = market.get(root)?.dailyMove ?? null;
const spread = entrySpread(root, now.getTime());
return {
symbol: root,
spreadDollars: spreadDollars(root),
spreadDollars: spread.dollars,
spreadLive: spread.live,
dailyMove,
holdHours: leg ? expectedHoldHours({ leg: leg.leg, stopDollars: leg.stopDollars, contracts, dailyMove }) : null,
micro: CME_SPECS[root].micro === true,
wide: spreadTooWide(spread.dollars, max),
};
});
const progress = progressNow(now);
const { chances, disabled } = drawChances(candidates, progress);
return { progress, candidates, chances, disabled };
const { chances, disabled, wide } = drawChances(candidates, progress);
return { progress, candidates, chances, disabled, wide };
}
/**
@@ -186,12 +216,16 @@ export async function drawFuturesInstrument(roots: readonly string[], leg: LegSh
await ensureMarket(roots);
const board = futuresBoard(roots, leg);
const root = pickByChance(board.chances, rand());
const hold = (s: string) => board.candidates.find((c) => c.symbol === s)?.holdHours;
const cand = (s: string) => board.candidates.find((c) => c.symbol === s);
const parts = board.chances.map((c) => `${c.symbol} ${Math.round(c.chance * 100)}%`).join(' · ');
const off = board.disabled.map((s) => `${s} (~${Math.round(hold(s) ?? 0)}h)`).join(', ');
const off = board.disabled.map((s) => `${s} (~${Math.round(cand(s)?.holdHours ?? 0)}h)`).join(', ');
const wide = board.wide.map((s) => `${s} $${cand(s)?.spreadDollars.toFixed(2)}`).join(', ');
const live = board.candidates.filter((c) => c.spreadLive).length;
console.log(`[instruments] futures: ${root ? `drew ${root}` : 'nothing fits'} — ${parts || 'none drawable'}`
+ `${off ? `; over ${MAX_HOLD_HOURS}h: ${off}` : ''}`
+ `${board.progress !== null ? ` (session ${Math.round(board.progress * 100)}%)` : ''}`);
+ `${wide ? `; spread over $${maxEntrySpread()}: ${wide}` : ''}`
+ ` (${live}/${board.candidates.length} live spreads`
+ `${board.progress !== null ? `, session ${Math.round(board.progress * 100)}%` : ''})`);
return root;
}
@@ -222,6 +256,7 @@ export function instrumentStatsFor(
const listed = firmInstruments(firm);
const board = futuresBoard(listed, lead);
const disabled = new Set(board.disabled);
const max = maxEntrySpread();
const leg = lead ? { leg: lead.leg, stopDollars: lead.stopDollars, contracts: futuresContracts(lead.leg, lead.maxPositionSize) } : null;
return {
at: new Date().toISOString(),
@@ -229,14 +264,19 @@ export function instrumentStatsFor(
leg: lead?.leg ?? null,
sessionProgress: board.progress,
maxHoldHours: MAX_HOLD_HOURS,
maxEntrySpreadDollars: max,
quoteSource: quoteSource(),
instruments: CME_ROOTS.map((root) => {
const dailyMove = market.get(root)?.dailyMove ?? null;
const holdHours = leg ? expectedHoldHours({ ...leg, dailyMove }) : null;
const spread = entrySpread(root);
return {
symbol: root,
listed: listed.includes(root),
contract: getCachedContract(root)?.name ?? null,
spreadDollars: spreadDollars(root),
spreadDollars: spread.dollars,
spreadLive: spread.live,
spreadBlocked: spreadTooWide(spread.dollars, max),
dailyMoveDollars: dailyMove === null ? null : Math.round(dailyMove),
holdHours: holdHours === null ? null : Math.round(holdHours * 10) / 10,
disabled: listed.includes(root) ? disabled.has(root) : holdHours !== null && holdHours >= MAX_HOLD_HOURS,