/** * 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, }; }