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