From db1d12327a57621594c6422e36f2828573d9daa2 Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Sun, 27 Sep 2026 15:06:44 -0500 Subject: [PATCH] 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 --- app/api/instruments/route.ts | 13 +- app/settings/page.tsx | 22 +- lib/auto-trade.ts | 19 +- lib/clients.ts | 30 +++ lib/firm-catalog.ts | 20 +- lib/instrument-specs.ts | 28 ++- lib/instruments.ts | 68 ++++-- lib/live-quotes.ts | 392 ++++++++++++++++++++++++++++++++++ lib/master-protocol.ts | 19 +- lib/quote-helpers.ts | 79 +++++++ lib/topstepx-class.ts | 6 + lib/tradovate-class.ts | 9 + scripts/check-instruments.mjs | 75 ++++++- 13 files changed, 747 insertions(+), 33 deletions(-) create mode 100644 lib/live-quotes.ts create mode 100644 lib/quote-helpers.ts diff --git a/app/api/instruments/route.ts b/app/api/instruments/route.ts index 7d17c05..5e754d6 100644 --- a/app/api/instruments/route.ts +++ b/app/api/instruments/route.ts @@ -1,8 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { getCachedCatalog } from '@/lib/firm-catalog'; import { - ensureMarket, firmInstruments, futuresRootsHere, instrumentStatsFor, instrumentsSource, isFuturesFirm, simulateDraws, + ensureMarket, firmInstruments, futuresRootsHere, instrumentStatsFor, instrumentsSource, isFuturesFirm, maxEntrySpread, simulateDraws, } from '@/lib/instruments'; +import { quoteStatus } from '@/lib/live-quotes'; import { CME_ROOTS } from '@/lib/instrument-specs'; export const dynamic = 'force-dynamic'; @@ -12,9 +13,11 @@ const EXAMPLE_LEG = { leg: 1000, stopDollars: Infinity, maxPositionSize: 0 }; /** * GET /api/instruments — what each firm trades (the master's lists) and, for - * futures firms, how the Auto draw sees every instrument right now: a tick - * of spread, one contract's daily move, the hold of a $1,000 leg, the chance. - * ?simulate=N adds N draws from the real draw code — no orders. + * futures firms, how the Auto draw sees every instrument right now: its + * spread (live off the quote feed, else a tick), one contract's daily move, + * the hold of a $1,000 leg, the chance. `quotes` is the feed itself and + * `maxEntrySpreadDollars` the master's entry limit. ?simulate=N adds N draws + * from the real draw code — no orders. */ export async function GET(req: NextRequest) { await ensureMarket(CME_ROOTS, 8_000); @@ -32,5 +35,5 @@ export async function GET(req: NextRequest) { ...(futures && n > 0 ? { simulated: simulateDraws(instruments, EXAMPLE_LEG, n) } : {}), }; }); - return NextResponse.json({ roots: futuresRootsHere(), firms }); + return NextResponse.json({ roots: futuresRootsHere(), maxEntrySpreadDollars: maxEntrySpread(), quotes: quoteStatus(), firms }); } diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 08d972c..83b5f42 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -7,6 +7,8 @@ import Link from 'next/link'; * futures firms, how the Auto draw sees each instrument. */ interface InstrumentsOverview { roots: string[]; + maxEntrySpreadDollars?: number; + quotes?: { source: string | null; platform: string | null; lastQuoteAt: string | null; lastError: string | null }; firms: { id: string; name: string; @@ -15,11 +17,14 @@ interface InstrumentsOverview { instruments: string[]; stats?: { maxHoldHours: number; + quoteSource?: string | null; sessionProgress: number | null; instruments: { symbol: string; listed: boolean; spreadDollars: number; + spreadLive?: boolean; + spreadBlocked?: boolean; dailyMoveDollars: number | null; holdHours: number | null; disabled: boolean; @@ -446,7 +451,17 @@ export default function SettingsPage() { {masterUrl ? Firms page : 'Firms page'}. Futures “Auto” draws one per batch: cheaper per move early in the session (every instrument gets a floor), the fastest near the last entry, never one expected to hold 12h or more. Holds below are for a $1,000 leg. + Spreads are live off one futures client's quotes (a tick when there are none); no entry pays more than + {overview?.maxEntrySpreadDollars ? ` $${overview.maxEntrySpreadDollars}` : ' the master\'s limit'} per contract to cross the spread.

+ {overview?.quotes && ( +

+ {overview.quotes.source + ? <>Live quotes from {overview.quotes.source} ({overview.quotes.platform}) + {overview.quotes.lastQuoteAt ? `, last ${new Date(overview.quotes.lastQuoteAt).toLocaleTimeString()}` : ', none yet'} + : No live quotes — spreads are the typical tick{overview.quotes.lastError ? ` (${overview.quotes.lastError})` : ''}} +

+ )} {!overview ? (
Loading…
@@ -486,8 +501,13 @@ export default function SettingsPage() { {c?.name ?? '—'} {c?.alternative && rolled from {c.alternative}} - + {x ? `$${x.spreadDollars.toFixed(2)}` : '—'} + {x && {x.spreadLive ? 'live' : 'typical'}} + {x?.spreadBlocked && ' · over limit'} {x?.dailyMoveDollars != null ? `$${x.dailyMoveDollars.toLocaleString()}` : '—'} diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 62b0c0d..5a8e471 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -22,7 +22,7 @@ import { } from './trading-logic'; import { getSetting, setSetting } from './db'; import { POINT_VALUES, housekeepingRoot } from './instrument-specs'; -import { firmInstruments, drawFuturesInstrument, type LegShape } from './instruments'; +import { firmInstruments, drawFuturesInstrument, spreadBlock, type LegShape } from './instruments'; import { errorMessage, isClientReady, isOrderNotPlaced, type PlatformClient, type PlatformContract, type PickContext } from './platform-client'; // ── process-wide state ─────────────────────────────────────────────────────── @@ -922,6 +922,15 @@ async function runTradeLocked( const root = explicit ?? await drawFuturesInstrument(allowed.filter((r) => !tried.includes(r)), shape); if (!root) break; tried.push(root); + // Paying more than the master's limit to cross the book: + // not this tick (the draw left it out already; an explicit + // symbol waits for the spread to come back in). + const wide = spreadBlock(root); + if (wide) { + console.log(`[auto-trade] ${wide} — not trading it this tick`); + if (explicit) break; + continue; + } const resolved: EligibleItem[] = []; for (const { firm, client } of [...new Map(pool.map((it) => [it.firm.id, it])).values()]) { try { @@ -991,6 +1000,8 @@ async function runTradeLocked( // immediately — P&L doesn't matter. if (item.isExtraDay) { const microRoot = housekeepingFor(item.firm); + const wide = spreadBlock(microRoot); + if (wide) throw new Error(`${wide} — no extra-day trade`); const mnqContract = await client.findFrontMonthContract(microRoot); if (!mnqContract) throw new Error(`${microRoot} contract not found for extra-day trade`); @@ -1185,6 +1196,12 @@ async function fireLeg( const inst = await legInstrument(client, item.firm, item.drain, item.contract, item.pv); legContract = inst.contract; pv = inst.pointValue; + // A futures entry never pays more than the master's limit to cross + // the book — the last word for every path (batch, copy, manual, drain). + if (!((legContract.stepNotional ?? 0) > 0)) { + const wide = spreadBlock(item.drain ? housekeepingFor(item.firm) : symbolLabel); + if (wide) throw new Error(`${wide} — not entering`); + } plan = sizeLeg(item, legContract, legAmount, tradeDate); pct = plan.pct; diff --git a/lib/clients.ts b/lib/clients.ts index 5040f79..534cea2 100644 --- a/lib/clients.ts +++ b/lib/clients.ts @@ -13,6 +13,7 @@ import { startBackupSync } from './backup-sync'; import { applyBrokerHttpDefaults, applyBrokerProxy } from './outbound-proxy'; import { startMasterLink } from './master-link'; import { resumeSchedulerIfPersisted } from './auto-trade'; +import { superviseQuotes, SUPERVISE_INTERVAL_MS, type QuoteClient } from './live-quotes'; // Use global to persist the client pool across HMR reloads in dev mode. // Keyed by the master's firm id, which is a uuid string. @@ -31,6 +32,7 @@ const g = global as typeof globalThis & { __contractResolvedRoots?: Set; __clientSupervisorTimer?: ReturnType; __clientSupervision?: Map; + __quoteSupervisorTimer?: ReturnType; }; function ensureMap(): Map { @@ -218,6 +220,10 @@ export function getClients(): Map { // Rebuild any client that has been wedged for too long. startClientSupervisor(); + // Live quotes off one futures client, for the spread every entry + // pays and the Auto draw. + startQuoteSupervisor(); + // Start master dashboard reporter startReporter(); @@ -388,6 +394,30 @@ function supervisionMap(): Map { return g.__clientSupervision; } +/** Keep the live quote feed on a futures client, subscribed to every root + * this machine may trade (lib/live-quotes.ts). */ +function superviseLiveQuotes(): void { + // Runs from a timer: nothing may escape. + try { + const map = ensureMap(); + const candidates = getTradableFirms().filter(isFuturesFirm).flatMap((firm) => { + const client = map.get(firm.id); + return client ? [{ firmId: firm.id, firmName: firm.name, platform: firm.platform, client: client as unknown as QuoteClient }] : []; + }); + superviseQuotes(candidates, resolveRootsHere()) + .catch((err) => console.error(`[quotes] supervision failed: ${errorMessage(err)}`)); + } catch (err) { + console.error(`[quotes] supervision failed: ${errorMessage(err)}`); + } +} + +function startQuoteSupervisor(): void { + if (g.__quoteSupervisorTimer) clearInterval(g.__quoteSupervisorTimer); + g.__quoteSupervisorTimer = setInterval(superviseLiveQuotes, SUPERVISE_INTERVAL_MS); + // The clients log in within seconds; the first look shouldn't wait 30. + setTimeout(superviseLiveQuotes, 10_000); +} + function startClientSupervisor(): void { // HMR-safe, and safe when resetClients() re-runs the bootstrap. if (g.__clientSupervisorTimer) clearInterval(g.__clientSupervisorTimer); diff --git a/lib/firm-catalog.ts b/lib/firm-catalog.ts index ce79c6e..4efbc1a 100644 --- a/lib/firm-catalog.ts +++ b/lib/firm-catalog.ts @@ -18,9 +18,9 @@ */ import { isFirmPlatform } from './master-protocol'; -import { sanitizeInstruments } from './instrument-specs'; +import { sanitizeInstruments, DEFAULT_MAX_ENTRY_SPREAD } from './instrument-specs'; import type { - CatalogAccountType, CatalogFirm, FirmCatalogPayload, FirmPlatform, SymbolMap, SymbolMapCandidate, + CatalogAccountType, CatalogFirm, CatalogTradingSettings, FirmCatalogPayload, FirmPlatform, SymbolMap, SymbolMapCandidate, } from './master-protocol'; import { getSetting, @@ -258,7 +258,21 @@ function validatePayload(raw: unknown): FirmCatalogPayload { }); }); - return { version: body.version, updatedAt: body.updatedAt, firms }; + const trading = parseTradingSettings(body.trading); + return { version: body.version, updatedAt: body.updatedAt, firms, ...(trading ? { trading } : {}) }; +} + +/** The master's trading settings; undefined when it sent none (or nothing + * usable), so the defaults apply. */ +function parseTradingSettings(raw: unknown): CatalogTradingSettings | undefined { + if (typeof raw !== 'object' || raw === null) return undefined; + const max = (raw as Record).maxEntrySpreadDollars; + return typeof max === 'number' && Number.isFinite(max) && max >= 0 ? { maxEntrySpreadDollars: max } : undefined; +} + +/** What the master's Settings page says, or the defaults from before it. */ +export function getTradingSettings(): CatalogTradingSettings { + return cache().payload?.trading ?? { maxEntrySpreadDollars: DEFAULT_MAX_ENTRY_SPREAD }; } function catalogUrl(): URL | null { diff --git a/lib/instrument-specs.ts b/lib/instrument-specs.ts index 710ef84..5458b9f 100644 --- a/lib/instrument-specs.ts +++ b/lib/instrument-specs.ts @@ -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, }; } diff --git a/lib/instruments.ts b/lib/instruments.ts index a9cbe78..ea65ff4 100644 --- a/lib/instruments.ts +++ b/lib/instruments.ts @@ -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, diff --git a/lib/live-quotes.ts b/lib/live-quotes.ts new file mode 100644 index 0000000..68a49d1 --- /dev/null +++ b/lib/live-quotes.ts @@ -0,0 +1,392 @@ +/** + * Live futures quotes: one feed per machine, on one futures client, and the + * spread each entry pays read off it. + * + * Every firm trades the same CME book, so one feed serves them all. It rides + * a TopstepX client's market hub when this machine has one, else a Tradovate + * client's market-data socket (when its login carries market data). Each + * listed root's front month is subscribed, plus the drain micro, and + * resubscribed when the front month rolls. + * + * A quote older than QUOTE_FRESH_MS isn't live: the spread falls back to the + * typical one tick (instrument-specs). A feed that fails, loses its client or + * goes quiet while the market trades is dropped, and the next supervision + * starts one on another client (the failed one rests RETRY_AFTER_MS). + */ + +import * as signalR from '@microsoft/signalr'; +import { brokerWebSocket, signalrWebSocket } from './outbound-proxy'; +import { errorMessage } from './platform-client'; +import { isNoTradeTime } from './trading-logic'; +import { CME_SPECS, spreadDollars, tickValue } from './instrument-specs'; +import { mergeTopstepxQuote, parseTradovateFrame, bookSpread, type BookTop } from './quote-helpers'; + +const MARKET_HUB = 'https://rtc.topstepx.com/hubs/market'; +const TRADOVATE_MD_WS = 'wss://md.tradovateapi.com/v1/websocket'; +/** A quote older than this is stale: the typical spread stands in. */ +export const QUOTE_FRESH_MS = 2 * 60_000; +const START_TIMEOUT_MS = 30_000; +const INVOKE_TIMEOUT_MS = 15_000; +/** Silent this long while the market trades: dead, or a login with no data. */ +const SILENT_MS = 5 * 60_000; +/** A client whose feed was dropped waits this long to be tried again. */ +const RETRY_AFTER_MS = 3 * 60_000; +export const SUPERVISE_INTERVAL_MS = 30_000; + +type Platform = 'topstepx' | 'tradovate'; +type Contract = { id: number | string; name: string }; +type QuoteSink = (root: string, contract: string, patch: BookTop) => void; + +/** What the feed needs of a futures client. */ +export interface QuoteClient { + quoteToken?: () => string | null; + findFrontMonthContract(root: string): Promise; +} + +interface Feed { + readonly platform: Platform; + readonly firmId: string; + readonly firmName: string; + readonly startedAt: number; + lastQuoteAt: number; + /** Why it gave up; null while it works. */ + failed: string | null; + /** The contract id `root` is subscribed under. */ + contractOf(root: string): string | null; + subscribe(root: string, contract: Contract): void; + stop(): void; +} + +interface LiveBook extends BookTop { + at: number; + contract: string; +} + +interface State { + books: Map; + feed: Feed | null; + droppedAt: Map; + lastError: string | null; + supervising: boolean; +} + +// On globalThis: instrumentation.ts boots the feed from its own bundle, and +// the route handlers (the Settings page, the draw) must read the same books. +const g = globalThis as typeof globalThis & { __liveQuotes?: State }; +function state(): State { + return (g.__liveQuotes ??= { books: new Map(), feed: null, droppedAt: new Map(), lastError: null, supervising: false }); +} + +function withTimeout(p: Promise, ms: number, what: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`${what} timed out after ${ms / 1000}s`)), ms); + p.then((v) => { clearTimeout(timer); resolve(v); }, (err) => { clearTimeout(timer); reject(err); }); + }); +} + +const onQuote: QuoteSink = (root, contract, patch) => { + const books = state().books; + const prev = books.get(root); + // A new front month starts a fresh book: the old one's sides don't carry. + books.set(root, { ...(prev?.contract === contract ? prev : {}), ...patch, contract, at: Date.now() }); +}; + +// ── TopstepX: the market hub ──────────────────────────────────────────────── + +class TopstepxFeed implements Feed { + readonly platform = 'topstepx' as const; + readonly startedAt = Date.now(); + lastQuoteAt = 0; + failed: string | null = null; + private ready = false; + private stopped = false; + private readonly hub: signalR.HubConnection; + private readonly byContract = new Map(); + private readonly byRoot = new Map(); + + constructor(readonly firmId: string, readonly firmName: string, token: () => string | null) { + // Like the user hub: one websocket, through the operator's proxy. + const proxiedWs = signalrWebSocket(); + this.hub = new signalR.HubConnectionBuilder() + .withUrl(MARKET_HUB, { + skipNegotiation: true, + transport: signalR.HttpTransportType.WebSockets, + accessTokenFactory: () => token() ?? '', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...(proxiedWs ? { WebSocket: proxiedWs as any } : {}), + }) + .withAutomaticReconnect() + .configureLogging(signalR.LogLevel.Warning) + .build(); + this.hub.on('GatewayQuote', (contractId: unknown, data: unknown) => { + const sub = typeof contractId === 'string' ? this.byContract.get(contractId) : undefined; + if (!sub || this.stopped) return; + this.lastQuoteAt = Date.now(); + onQuote(sub.root, sub.name, mergeTopstepxQuote(undefined, data)); + }); + this.hub.onreconnected(() => { + if (!this.stopped) for (const id of this.byContract.keys()) void this.send(id); + }); + this.hub.onclose((err) => { + if (!this.stopped) this.failed ??= `market hub closed${err ? `: ${errorMessage(err)}` : ''}`; + }); + withTimeout(this.hub.start(), START_TIMEOUT_MS, 'market hub start') + .then(() => { + if (this.stopped) return; + this.ready = true; + for (const id of this.byContract.keys()) void this.send(id); + }) + .catch((err) => { this.failed ??= `market hub: ${errorMessage(err)}`; }); + } + + contractOf(root: string): string | null { + return this.byRoot.get(root) ?? null; + } + + subscribe(root: string, contract: Contract): void { + const id = String(contract.id); + const old = this.byRoot.get(root); + if (old && old !== id) { + this.byContract.delete(old); + if (this.ready) void this.hub.invoke('UnsubscribeContractQuotes', old).catch(() => { /* rolled away anyway */ }); + } + this.byRoot.set(root, id); + this.byContract.set(id, { root, name: contract.name }); + if (this.ready) void this.send(id); + } + + private async send(id: string): Promise { + try { + await withTimeout(this.hub.invoke('SubscribeContractQuotes', id), INVOKE_TIMEOUT_MS, `subscribing ${id}`); + } catch (err) { + if (!this.stopped) this.failed ??= errorMessage(err); + } + } + + stop(): void { + this.stopped = true; + void this.hub.stop().catch(() => { /* discarded */ }); + } +} + +// ── Tradovate: the market-data socket ─────────────────────────────────────── + +class TradovateFeed implements Feed { + readonly platform = 'tradovate' as const; + readonly startedAt = Date.now(); + lastQuoteAt = 0; + failed: string | null = null; + private authorized = false; + private stopped = false; + private nextId = 2; + private heartbeat: ReturnType | null = null; + private readonly ws: WebSocket; + private readonly byContract = new Map(); + private readonly byRoot = new Map(); + private readonly pending = new Map(); + + constructor(readonly firmId: string, readonly firmName: string, token: () => string | null) { + const ws = brokerWebSocket(`${TRADOVATE_MD_WS}?r=${Math.random().toString(36).slice(2, 12)}`); + this.ws = ws; + ws.onopen = () => { + const t = token(); + if (!t) this.failed ??= 'no market-data token'; + else this.send(`authorize\n1\n\n${t}`); + }; + ws.onmessage = (event) => this.onFrame(event.data); + ws.onerror = () => { /* onclose follows */ }; + ws.onclose = (event) => { + if (this.heartbeat) clearInterval(this.heartbeat); + this.heartbeat = null; + if (!this.stopped) this.failed ??= `market-data socket closed (code ${event.code})`; + }; + } + + private send(frame: string): void { + if (this.ws.readyState === 1) this.ws.send(frame); + } + + private request(name: string): void { + const id = this.nextId++; + this.pending.set(id, name); + this.send(`md/subscribeQuote\n${id}\n\n${JSON.stringify({ symbol: name })}`); + } + + private onFrame(raw: unknown): void { + if (this.stopped) return; + const { responses, quotes } = parseTradovateFrame(raw); + for (const r of responses) { + if (r.id === 1) { + if (r.status !== 200) { this.failed ??= `market data refused (status ${r.status})`; continue; } + this.authorized = true; + this.heartbeat = setInterval(() => this.send('[]'), 2500); + for (const { name } of this.byRoot.values()) this.request(name); + continue; + } + const name = this.pending.get(r.id); + this.pending.delete(r.id); + if (name && r.status !== 200) { + const detail = typeof r.data === 'string' ? `: ${r.data.slice(0, 120)}` : ''; + this.failed ??= `quotes for ${name} refused (status ${r.status}${detail})`; + } + } + for (const q of quotes) { + const sub = this.byContract.get(q.contractId); + if (!sub) continue; + this.lastQuoteAt = Date.now(); + onQuote(sub.root, sub.name, { ...(q.bid !== undefined ? { bid: q.bid } : {}), ...(q.ask !== undefined ? { ask: q.ask } : {}) }); + } + } + + contractOf(root: string): string | null { + const sub = this.byRoot.get(root); + return sub ? String(sub.id) : null; + } + + subscribe(root: string, contract: Contract): void { + const id = Number(contract.id); + if (!Number.isFinite(id)) return; + const old = this.byRoot.get(root); + if (old && old.id !== id) { + this.byContract.delete(old.id); + if (this.authorized) this.send(`md/unsubscribeQuote\n${this.nextId++}\n\n${JSON.stringify({ symbol: old.name })}`); + } + this.byRoot.set(root, { id, name: contract.name }); + this.byContract.set(id, { root, name: contract.name }); + if (this.authorized) this.request(contract.name); + } + + stop(): void { + this.stopped = true; + if (this.heartbeat) clearInterval(this.heartbeat); + this.heartbeat = null; + try { this.ws.close(); } catch { /* already closed */ } + } +} + +// ── supervision ───────────────────────────────────────────────────────────── + +export interface QuoteCandidate { + firmId: string; + firmName: string; + platform: string; + client: QuoteClient; +} + +/** + * Keep one feed running and subscribed to `roots`: drop one that failed, + * lost its client or went quiet while the market trades; start one on the + * first client that can (TopstepX first, then Tradovate) and hasn't just + * been dropped; subscribe each root's current front month. + */ +export async function superviseQuotes(candidates: QuoteCandidate[], roots: readonly string[], now = Date.now()): Promise { + const st = state(); + if (st.supervising) return; + st.supervising = true; + try { + const marketOpen = !isNoTradeTime(new Date(now), { everyDay: false, mode: 'full_cme', jitterMin: 0 }); + let feed = st.feed; + if (feed) { + const owner = candidates.find((c) => c.firmId === feed!.firmId); + const silentMs = now - Math.max(feed.lastQuoteAt, feed.startedAt); + const reason = feed.failed + ?? (!owner ? 'its firm is gone' + : !owner.client.quoteToken?.() ? 'its client is logged out' + : marketOpen && silentMs > SILENT_MS ? `no quotes for ${Math.round(silentMs / 60_000)} min` + : null); + if (reason) { + console.warn(`[quotes] dropping the ${feed.platform} feed on ${feed.firmName} — ${reason}`); + feed.stop(); + st.droppedAt.set(feed.firmId, now); + st.lastError = `${feed.firmName}: ${reason}`; + st.feed = feed = null; + } + } + if (!feed) { + const byPreference = [ + ...candidates.filter((c) => c.platform === 'topstepx'), + ...candidates.filter((c) => c.platform === 'tradovate'), + ]; + const pick = byPreference.find((c) => !!c.client.quoteToken?.() && now - (st.droppedAt.get(c.firmId) ?? -Infinity) >= RETRY_AFTER_MS); + if (!pick) return; + const token = () => pick.client.quoteToken?.() ?? null; + feed = pick.platform === 'topstepx' + ? new TopstepxFeed(pick.firmId, pick.firmName, token) + : new TradovateFeed(pick.firmId, pick.firmName, token); + st.feed = feed; + console.log(`[quotes] live quotes from ${pick.firmName} (${pick.platform}) for ${roots.join(', ')}`); + } + const client = candidates.find((c) => c.firmId === feed!.firmId)?.client; + if (!client) return; + for (const root of roots) { + if (!CME_SPECS[root]) continue; + try { + const contract = await client.findFrontMonthContract(root); + if (contract && feed.contractOf(root) !== String(contract.id)) feed.subscribe(root, contract); + } catch (err) { + console.warn(`[quotes] ${root}: contract lookup failed — ${errorMessage(err)}`); + } + } + } finally { + st.supervising = false; + } +} + +// ── reading ───────────────────────────────────────────────────────────────── + +export interface LiveSpread { + dollars: number; + ticks: number; + bid: number; + ask: number; + ageMs: number; + contract: string; +} + +/** `root`'s spread off a fresh quote; null when there is none. */ +export function liveSpread(root: string, now = Date.now()): LiveSpread | null { + const book = state().books.get(root); + const spec = CME_SPECS[root]; + if (!book || !spec || now - book.at > QUOTE_FRESH_MS) return null; + const spread = bookSpread(book, spec.pointValue, spec.tickSize); + if (!spread) return null; + return { ...spread, bid: book.bid!, ask: book.ask!, ageMs: now - book.at, contract: book.contract }; +} + +/** + * The spread an entry on `root` pays, per contract: the live one when a + * fresh quote is at hand, else the typical one tick. Never under a tick — + * a locked book is momentary. + */ +export function entrySpread(root: string, now = Date.now()): { dollars: number; ticks: number; live: boolean } { + const live = liveSpread(root, now); + if (live) return { dollars: Math.max(live.dollars, tickValue(root)), ticks: Math.max(live.ticks, 1), live: true }; + return { dollars: spreadDollars(root), ticks: CME_SPECS[root]?.spreadTicks ?? 1, live: false }; +} + +/** The feed as the Settings page and /api/instruments show it. */ +export function quoteStatus(now = Date.now()): { + source: string | null; + platform: Platform | null; + since: string | null; + lastQuoteAt: string | null; + lastError: string | null; + spreads: Record; +} { + const st = state(); + const feed = st.feed; + return { + source: feed?.firmName ?? null, + platform: feed?.platform ?? null, + since: feed ? new Date(feed.startedAt).toISOString() : null, + lastQuoteAt: feed?.lastQuoteAt ? new Date(feed.lastQuoteAt).toISOString() : null, + lastError: feed?.failed ?? st.lastError, + spreads: Object.fromEntries([...st.books.keys()].map((root) => [root, liveSpread(root, now)])), + }; +} + +/** The firm the feed runs on, for the master's stats; null with no feed. */ +export function quoteSource(): string | null { + const feed = state().feed; + return feed ? `${feed.firmName} (${feed.platform})` : null; +} diff --git a/lib/master-protocol.ts b/lib/master-protocol.ts index dc935f7..5453137 100644 --- a/lib/master-protocol.ts +++ b/lib/master-protocol.ts @@ -132,12 +132,20 @@ export interface InstrumentStats { sessionProgress: number | null; /** Holds at or over this are disabled. */ maxHoldHours: number; + /** Entries paying more than this to cross the spread are skipped (0 = no limit). */ + maxEntrySpreadDollars?: number; + /** The firm whose client feeds the live quotes; null = none, typical spreads. */ + quoteSource?: string | null; instruments: { symbol: string; listed: boolean; contract: string | null; - /** One tick of spread on one contract, in dollars. */ + /** Crossing the spread on one contract, in dollars: live when + * `spreadLive`, else the typical one tick. */ spreadDollars: number; + spreadLive?: boolean; + /** Over the entry limit right now: out of the draw, not traded. */ + spreadBlocked?: boolean; /** One contract's typical daily move (1σ), in dollars. */ dailyMoveDollars: number | null; holdHours: number | null; @@ -335,10 +343,19 @@ export interface CatalogFirm { updatedAt: string; } +/** Settings the master applies to every instance (its Settings page). */ +export interface CatalogTradingSettings { + /** The most an entry may pay to cross the spread, per contract, in + * dollars (0 = no limit). */ + maxEntrySpreadDollars: number; +} + export interface FirmCatalogPayload { version: number; updatedAt: string; firms: CatalogFirm[]; + /** Absent from a master from before its Settings page. */ + trading?: CatalogTradingSettings; } export interface FirmCatalogUnchanged { diff --git a/lib/quote-helpers.ts b/lib/quote-helpers.ts new file mode 100644 index 0000000..cb50be2 --- /dev/null +++ b/lib/quote-helpers.ts @@ -0,0 +1,79 @@ +/** + * Pure helpers for the live futures quote feed (lib/live-quotes.ts): reading + * what TopstepX's market hub and Tradovate's market-data socket send, and + * turning the top of the book into the spread an entry pays. + * + * No imports, so scripts/check-instruments.mjs can test them directly. + */ + +export interface BookTop { + bid?: number; + ask?: number; +} + +const price = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined); + +/** + * A TopstepX GatewayQuote laid over the last one. The hub sends only the + * fields that changed, so a side it leaves out keeps its last price. + */ +export function mergeTopstepxQuote(prev: BookTop | undefined, raw: unknown): BookTop { + const r = raw as Record | null; + const data = r && typeof r === 'object' && 'data' in r && typeof r.data === 'object' && r.data !== null + ? (r.data as Record) + : r; + const bid = price(data?.bestBid); + const ask = price(data?.bestAsk); + return { ...prev, ...(bid !== undefined ? { bid } : {}), ...(ask !== undefined ? { ask } : {}) }; +} + +export interface TradovateFrame { + /** Replies to requests: request id, HTTP-like status, payload. */ + responses: { id: number; status: number; data?: unknown }[]; + /** Quote updates by contract id; a side left out didn't change. */ + quotes: { contractId: number; bid?: number; ask?: number }[]; +} + +/** One frame off Tradovate's market-data socket ('a[...]'; 'o', 'h' and 'c' + * frames carry nothing to read). */ +export function parseTradovateFrame(frame: unknown): TradovateFrame { + const out: TradovateFrame = { responses: [], quotes: [] }; + if (typeof frame !== 'string' || frame[0] !== 'a') return out; + let items: unknown; + try { items = JSON.parse(frame.slice(1)); } catch { return out; } + if (!Array.isArray(items)) return out; + for (const item of items) { + if (!item || typeof item !== 'object') continue; + const it = item as { e?: unknown; d?: unknown; i?: unknown; s?: unknown }; + if (typeof it.i === 'number' && typeof it.s === 'number') { + out.responses.push({ id: it.i, status: it.s, data: it.d }); + continue; + } + if (it.e !== 'md') continue; + const quotes = (it.d as { quotes?: unknown } | undefined)?.quotes; + if (!Array.isArray(quotes)) continue; + for (const q of quotes) { + const quote = q as { contractId?: unknown; entries?: Record } | null; + if (typeof quote?.contractId !== 'number') continue; + const bid = price(quote.entries?.Bid?.price); + const ask = price(quote.entries?.Offer?.price); + out.quotes.push({ contractId: quote.contractId, ...(bid !== undefined ? { bid } : {}), ...(ask !== undefined ? { ask } : {}) }); + } + } + return out; +} + +/** + * What crossing the book costs on one contract: the width between bid and + * ask, in dollars and in ticks. null for a book missing a side or crossed + * (ask below bid) — not a spread anyone can enter on. + */ +export function bookSpread(book: BookTop | undefined, pointValue: number, tickSize: number): { dollars: number; ticks: number } | null { + if (!book || book.bid === undefined || book.ask === undefined || book.ask < book.bid) return null; + if (!(pointValue > 0)) return null; + const width = book.ask - book.bid; + return { + dollars: Math.round(width * pointValue * 100) / 100, + ticks: tickSize > 0 ? Math.round(width / tickSize) : NaN, + }; +} diff --git a/lib/topstepx-class.ts b/lib/topstepx-class.ts index c7a19e5..5e9ac0a 100644 --- a/lib/topstepx-class.ts +++ b/lib/topstepx-class.ts @@ -1755,6 +1755,12 @@ export class TopstepXClient implements PlatformClient { // ── lifecycle ──────────────────────────────────────────────────────────── + /** The session token, for the live quote feed's market hub + * (lib/live-quotes.ts); null while logged out. */ + public quoteToken(): string | null { + return this.token; + } + public health(): ClientHealth { return { platform: 'topstepx', diff --git a/lib/tradovate-class.ts b/lib/tradovate-class.ts index 9bb2ecd..a678ed4 100644 --- a/lib/tradovate-class.ts +++ b/lib/tradovate-class.ts @@ -2215,6 +2215,15 @@ export class TradovateClient { ); } + /** The market-data token, for the live quote feed (lib/live-quotes.ts); + * null while logged out or when the user has no market data. */ + public quoteToken(): string | null { + const info = this.accessInfo; + if (!info?.mdAccessToken || info.hasMarketData === false) return null; + const expiresAt = this.tokenExpiresAt(); + return expiresAt === 0 || expiresAt > Date.now() ? info.mdAccessToken : null; + } + /** Liveness snapshot for /api/health and the registry supervisor. No tokens. */ public health(): ClientHealth { const now = Date.now(); diff --git a/scripts/check-instruments.mjs b/scripts/check-instruments.mjs index b85a3ad..f4c3759 100644 --- a/scripts/check-instruments.mjs +++ b/scripts/check-instruments.mjs @@ -10,8 +10,9 @@ import assert from 'node:assert/strict'; import { CME_SPECS, CME_ROOTS, POINT_VALUES, MICRO_OF, tickValue, spreadDollars, dailyMoveDollars, futuresContracts, expectedHoldHours, sessionProgress, drawChances, pickByChance, housekeepingRoot, sanitizeInstruments, - resolveAllowlist, MAX_HOLD_HOURS, DRAW_FLOOR, + resolveAllowlist, MAX_HOLD_HOURS, DRAW_FLOOR, DEFAULT_MAX_ENTRY_SPREAD, spreadTooWide, } from '../lib/instrument-specs.ts'; +import { mergeTopstepxQuote, parseTradovateFrame, bookSpread } from '../lib/quote-helpers.ts'; // Deterministic PRNG so a failure reproduces. function rng(seed) { @@ -159,3 +160,75 @@ test('reading lists: sanitize per family; master list first, the old rules other { roots: ['ETH/USD', 'XYZ100/USD'], source: 'legacy' }, ); }); + +// ── live quotes and the entry spread limit ────────────────────────────────── + +test('TopstepX quotes: a side the hub leaves out keeps its last price', () => { + let book = mergeTopstepxQuote(undefined, { symbol: 'F.US.ENQ', bestBid: 24000.25, bestAsk: 24000.5, lastPrice: 24000.5 }); + assert.deepEqual(book, { bid: 24000.25, ask: 24000.5 }); + book = mergeTopstepxQuote(book, { bestAsk: 24001 }); + assert.deepEqual(book, { bid: 24000.25, ask: 24001 }); + // Wrapped in { data }, as the user hub's events are; junk prices ignored. + assert.deepEqual(mergeTopstepxQuote(book, { data: { bestBid: 24000.75, bestAsk: 0 } }), { bid: 24000.75, ask: 24001 }); + assert.deepEqual(mergeTopstepxQuote(undefined, null), {}); +}); + +test('Tradovate market-data frames: replies and quotes', () => { + const frame = 'a' + JSON.stringify([ + { s: 200, i: 1 }, + { s: 404, i: 3, d: 'Symbol not found' }, + { e: 'md', d: { quotes: [ + { contractId: 3267315, timestamp: '2026-09-27T22:10:00Z', entries: { Bid: { price: 24000.25, size: 3 }, Offer: { price: 24000.5, size: 5 }, Trade: { price: 24000.5 } } }, + { contractId: 3273057, entries: { Offer: { price: 71.24, size: 2 } } }, + { entries: { Bid: { price: 1 } } }, + ] } }, + ]); + const parsed = parseTradovateFrame(frame); + assert.deepEqual(parsed.responses, [{ id: 1, status: 200, data: undefined }, { id: 3, status: 404, data: 'Symbol not found' }]); + assert.deepEqual(parsed.quotes, [ + { contractId: 3267315, bid: 24000.25, ask: 24000.5 }, + { contractId: 3273057, ask: 71.24 }, + ]); + for (const other of ['o', 'h', 'c[1000,"bye"]', 'a[not json', 42]) assert.deepEqual(parseTradovateFrame(other), { responses: [], quotes: [] }); +}); + +test('the spread off the book, in dollars and ticks', () => { + const nq = CME_SPECS.NQ; + assert.deepEqual(bookSpread({ bid: 24000.25, ask: 24000.5 }, nq.pointValue, nq.tickSize), { dollars: 5, ticks: 1 }); + assert.deepEqual(bookSpread({ bid: 24000, ask: 24001 }, nq.pointValue, nq.tickSize), { dollars: 20, ticks: 4 }); + const gc = CME_SPECS.GC; + assert.deepEqual(bookSpread({ bid: 3650.1, ask: 3650.3 }, gc.pointValue, gc.tickSize), { dollars: 20, ticks: 2 }); + // A locked book is zero wide; a crossed or one-sided one isn't a spread. + assert.deepEqual(bookSpread({ bid: 71.2, ask: 71.2 }, 1000, 0.01), { dollars: 0, ticks: 0 }); + assert.equal(bookSpread({ bid: 71.3, ask: 71.2 }, 1000, 0.01), null); + assert.equal(bookSpread({ bid: 71.2 }, 1000, 0.01), null); + assert.equal(bookSpread(undefined, 1000, 0.01), null); +}); + +test('the entry limit: $15 by default; one tick of the listed roots fits under it', () => { + assert.equal(DEFAULT_MAX_ENTRY_SPREAD, 15); + for (const root of ['NQ', 'GC', 'CL', 'ES', 'MNQ']) assert.equal(spreadTooWide(spreadDollars(root), 15), false, root); + assert.equal(spreadTooWide(15, 15), false); + assert.equal(spreadTooWide(15.01, 15), true); + assert.equal(spreadTooWide(20, 15), true); + // 0 = no limit; a NaN spread never blocks. + assert.equal(spreadTooWide(500, 0), false); + assert.equal(spreadTooWide(NaN, 15), false); + // One tick of these is over $15 by itself. + for (const root of ['SI', 'ZB', 'ZN']) assert.equal(spreadTooWide(spreadDollars(root), 15), true, root); +}); + +test('the draw leaves out what is over the spread limit', () => { + const cands = [ + { symbol: 'NQ', spreadDollars: 20, dailyMove: 5164, holdHours: 0.9, wide: true }, + { symbol: 'GC', spreadDollars: 10, dailyMove: 4899, holdHours: 1.0 }, + { symbol: 'CL', spreadDollars: 10, dailyMove: 3554, holdHours: 1.9 }, + ]; + const { chances, wide, disabled } = drawChances(cands, 0); + assert.deepEqual(wide, ['NQ']); + assert.deepEqual(disabled, []); + assert.deepEqual(chances.map((c) => c.symbol), ['GC', 'CL']); + assert.ok(Math.abs(chances.reduce((s, c) => s + c.chance, 0) - 1) < 1e-9); + // All of them wide: nothing to draw. + assert.deepEqual(drawChances(cands.map((c) => ({ ...c, wide: true })), 0.5).chances, []); +});