/** * The instruments each firm trades, and the futures Auto draw — the one * module every trading path asks. * * A firm's list comes from the master's catalog (`instruments`); against a * master from before the lists, from the old rules (this machine's enabled * instruments less the firm's bans; a DXtrade firm's symbol map). Futures * "Auto" draws one listed root per batch, weighted by lib/instrument-specs' * drawChances: cheap-per-move with a floor early in the session, the fastest * 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 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, 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, dailyMoveDollars, futuresContracts, expectedHoldHours, sessionProgress, drawChances, pickByChance, resolveAllowlist, spreadTooWide, MAX_HOLD_HOURS, type DrawCandidate, } from './instrument-specs'; export interface FirmLike { id?: string; platform: string; instruments?: string[]; bannedSymbols?: string[]; symbolMap?: Record; } function localEnabled(): string[] { return getInstruments().filter((i) => i.enabled).map((i) => i.symbol); } /** What the firm trades (see resolveAllowlist). */ export function firmInstruments(firm: FirmLike): string[] { return resolveAllowlist(firm, localEnabled()).roots; } /** Whether the list came from the master or the pre-list fallback. */ export function instrumentsSource(firm: FirmLike): 'master' | 'legacy' { return resolveAllowlist(firm, localEnabled()).source; } export function isFuturesFirm(firm: { platform: string }): boolean { return firm.platform !== 'dxtrade'; } /** Every root a futures firm on this machine lists, in CME order — what the * master's trade bar may send here. */ export function futuresRootsHere(): string[] { const listed = new Set(getTradableFirms().filter(isFuturesFirm).flatMap((f) => firmInstruments(f))); return CME_ROOTS.filter((root) => listed.has(root)); } // ── market data (Yahoo hourly candles) ───────────────────────────────────── interface Market { tradeDate: string; price: number | null; dailyMove: number | null; at: number; error: string | null; } const RETRY_MS = 15 * 60_000; const FETCH_TIMEOUT_MS = 10_000; const CONCURRENCY = 4; const g = globalThis as typeof globalThis & { __futuresMarket?: Map; __futuresMarketInflight?: Map>; }; const market = (g.__futuresMarket ??= new Map()); const inflight = (g.__futuresMarketInflight ??= new Map()); function isFresh(m: Market | undefined): boolean { if (!m || m.tradeDate !== currentTradeDateCT()) return false; return m.error === null || Date.now() - m.at < RETRY_MS; } async function fetchMarket(root: string): Promise { const spec = CME_SPECS[root]; const tradeDate = currentTradeDateCT(); try { const res = await axios.get(`https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(spec.yahoo)}`, { params: { interval: '60m', range: '7d' }, headers: { 'User-Agent': 'Mozilla/5.0' }, timeout: FETCH_TIMEOUT_MS, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS + 5_000), }); const raw: unknown[] = res.data?.chart?.result?.[0]?.indicators?.quote?.[0]?.close ?? []; const closes = raw.filter((c): c is number => typeof c === 'number' && Number.isFinite(c) && c > 0); const dailyMove = dailyMoveDollars(closes, spec.pointValue); market.set(root, { tradeDate, price: closes.at(-1) ?? null, dailyMove, at: Date.now(), error: dailyMove === null ? `only ${closes.length} hourly closes` : null, }); } catch (err) { market.set(root, { tradeDate, price: null, dailyMove: null, at: Date.now(), error: errorMessage(err) }); } } /** Refresh whichever roots aren't measured for today, waiting at most * `deadlineMs` — a draw never stalls on Yahoo. */ export async function ensureMarket(roots: readonly string[], deadlineMs = 4_000): Promise { const stale = [...new Set(roots)].filter((r) => CME_SPECS[r] && !isFresh(market.get(r))); if (stale.length === 0) return; const run = (async () => { for (let i = 0; i < stale.length; i += CONCURRENCY) { await Promise.all(stale.slice(i, i + CONCURRENCY).map((root) => { let p = inflight.get(root); if (!p) { p = fetchMarket(root).finally(() => inflight.delete(root)); inflight.set(root, p); } return p; })); } })(); await Promise.race([run, new Promise((r) => setTimeout(r, deadlineMs))]); } /** Measure every root in the background — at boot and at the midnight * contract resolve — so the stats cover unlisted instruments too. */ export function prewarmMarket(): void { void ensureMarket(CME_ROOTS, 120_000).then(() => { const measured = CME_ROOTS.filter((r) => market.get(r)?.dailyMove != null).length; console.log(`[instruments] measured ${measured}/${CME_ROOTS.length} futures from hourly closes`); }); } export function marketOf(root: string): { price: number | null; dailyMove: number | null; error: string | null } | null { const m = market.get(root); return m ? { price: m.price, dailyMove: m.dailyMove, error: m.error } : null; } // ── the draw ──────────────────────────────────────────────────────────────── /** The batch's leg, for the hold estimate: its dollars, its stop (Infinity = * none) and the account type's max contracts. */ export interface LegShape { leg: number; stopDollars: number; maxPositionSize: number; } 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 & { spreadLive: boolean })[]; chances: { symbol: string; chance: number }[]; disabled: string[]; wide: string[]; } { const contracts = leg ? futuresContracts(leg.leg, leg.maxPositionSize) : 1; 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: 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, wide } = drawChances(candidates, progress); return { progress, candidates, chances, disabled, wide }; } /** * Draw one of `roots` for a batch whose leg is `leg`. null when nothing * listed fits (all over the hold cap) — the batch sits this tick out. */ export async function drawFuturesInstrument(roots: readonly string[], leg: LegShape | null, rand: () => number = Math.random): Promise { await ensureMarket(roots); const board = futuresBoard(roots, leg); const root = pickByChance(board.chances, rand()); 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(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}` : ''}` + `${wide ? `; spread over $${maxEntrySpread()}: ${wide}` : ''}` + ` (${live}/${board.candidates.length} live spreads` + `${board.progress !== null ? `, session ${Math.round(board.progress * 100)}%` : ''})`); return root; } /** The draw's odds over `n` simulated batches — the real draw code, no * orders — for GET /api/instruments?simulate=. */ export function simulateDraws(roots: readonly string[], leg: LegShape | null, n: number): Record { const board = futuresBoard(roots, leg); const counts: Record = {}; for (let i = 0; i < n; i++) { const s = pickByChance(board.chances, Math.random()); if (s) counts[s] = (counts[s] ?? 0) + 1; } return counts; } // ── stats for the master and the Settings page ───────────────────────────── /** * A futures firm's instruments as the draw sees them right now: every CME * root with its spread, movement and contract, and — sized for `lead`'s * next leg — the listed ones' holds, the ones over the cap, and each one's * chance. */ export function instrumentStatsFor( firm: FirmLike & { id: string }, lead: (LegShape & { account: string }) | null, ): InstrumentStats { 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(), account: lead?.account ?? null, 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: 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, chance: board.chances.find((c) => c.symbol === root)?.chance ?? null, }; }), }; }