One quote feed per machine: a TopstepX client's market hub, else a Tradovate client's market-data socket, subscribed to every listed root's front month and the drain micro. A supervisor keeps it on a working client, dropping one that fails or goes quiet while the market trades. The Auto draw weighs each instrument by its live spread (the typical tick when no fresh quote is at hand). No futures entry pays more than the master's max entry spread ($15 until set) to cross the book: the draw leaves such an instrument out, an explicit symbol sits the tick out, and fireLeg and the extra-day trade check it last before sending. The Settings page and the master's stats show the live spreads and the feed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
288 lines
13 KiB
TypeScript
288 lines
13 KiB
TypeScript
/**
|
|
* 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<string, { symbol: string }[]>;
|
|
}
|
|
|
|
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<string, Market>;
|
|
__futuresMarketInflight?: Map<string, Promise<void>>;
|
|
};
|
|
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<void> {
|
|
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<void> {
|
|
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<string | null> {
|
|
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<string, number> {
|
|
const board = futuresBoard(roots, leg);
|
|
const counts: Record<string, number> = {};
|
|
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,
|
|
};
|
|
}),
|
|
};
|
|
}
|