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, []);
+});
|