- lib/instrument-specs.ts (pure, tested): the CME specs — point value, tick size, one-tick spread, Yahoo ticker — replacing POINT_VALUES; the draw's maths; the 12-hour hold cap; the micro for drain and extra-day legs; reading a firm's list (the master's, or the old rules against an older master: this machine's enabled set less bans, the symbol map). - lib/instruments.ts: the one module the trading paths ask. Measures every future's daily move from Yahoo's hourly candles once per trade date, and shows entry cost (a tick of spread plus Tradovate's fee parameters or TopstepX's last measured fill) without ever choosing by it. - Futures Auto draws one root per batch from the batch firm's list: movement per dollar of spread early in the session with a 10% floor each, sliding to the fastest movers by the last entry; nothing expected to hold 12h or more; micros only if nothing else is listed. Every draw logs its odds. - The batch is chosen before its instrument, and a group that can't trade anything yields to the next. Copy trade matches the held contract against the listed roots and won't copy an instrument it can't identify. Manual legs, manual orders and drain/extra-day legs follow the lists. - DXtrade firms rank their own list whatever root was drawn. - Capabilities, the dashboard's trade bar and contract resolves follow the lists; the trade bar and scheduler default to Auto. The master link reconnects when master_dashboard_url changes, and the catalog drops ?since after a switch so two masters' versions can't collide. - Reports carry each futures firm's instrumentStats for the master's Firms page; GET /api/instruments shows them (?simulate=N runs the draw, no orders); the Settings page shows the lists read-only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
281 lines
13 KiB
TypeScript
281 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 one tick. Fees are shown, never used to choose:
|
|
* Tradovate's fee parameters, or TopstepX's last measured fill.
|
|
*/
|
|
|
|
import axios from 'axios';
|
|
import { getInstruments, getSetting } from './db';
|
|
import { getTradableFirms } from './firm-catalog';
|
|
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,
|
|
} 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>>;
|
|
__futuresFees?: Map<string, { tradeDate: string; fees: Record<string, number>; at: number }>;
|
|
__futuresMeasuredFees?: Map<string, number>;
|
|
};
|
|
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;
|
|
}
|
|
|
|
// ── fees (shown, never used to choose) ─────────────────────────────────────
|
|
|
|
const feeCache = (g.__futuresFees ??= new Map());
|
|
const measuredFees = (g.__futuresMeasuredFees ??= new Map());
|
|
|
|
/** A fill's commission per contract per side, for platforms with no fee
|
|
* lookup (TopstepX). */
|
|
export function recordMeasuredFee(firmId: string, root: string, perContract: number): void {
|
|
if (Number.isFinite(perContract) && perContract >= 0) measuredFees.set(`${firmId}|${root}`, Math.round(perContract * 100) / 100);
|
|
}
|
|
|
|
/** Tradovate's fee parameters, fetched once per trade date per firm. */
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
export function refreshFees(firm: { id: string; platform: string }, client: any): void {
|
|
if (firm.platform !== 'tradovate' || typeof client?.fetchInstrumentFees !== 'function') return;
|
|
const cached = feeCache.get(firm.id);
|
|
if (cached && cached.tradeDate === currentTradeDateCT() && (Object.keys(cached.fees).length > 0 || Date.now() - cached.at < RETRY_MS)) return;
|
|
feeCache.set(firm.id, { tradeDate: currentTradeDateCT(), fees: cached?.fees ?? {}, at: Date.now() });
|
|
void Promise.resolve(client.fetchInstrumentFees([...CME_ROOTS]))
|
|
.then((fees: Record<string, number>) => feeCache.set(firm.id, { tradeDate: currentTradeDateCT(), fees: fees ?? {}, at: Date.now() }))
|
|
.catch((err: unknown) => console.warn(`[instruments] fee lookup failed: ${errorMessage(err)}`));
|
|
}
|
|
|
|
export function feesPerSide(firmId: string, root: string): number | null {
|
|
return feeCache.get(firmId)?.fees[root] ?? measuredFees.get(`${firmId}|${root}`) ?? 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');
|
|
}
|
|
|
|
/** 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[];
|
|
chances: { symbol: string; chance: number }[];
|
|
disabled: string[];
|
|
} {
|
|
const contracts = leg ? futuresContracts(leg.leg, leg.maxPositionSize) : 1;
|
|
const candidates = roots.filter((r) => CME_SPECS[r]).map((root): DrawCandidate => {
|
|
const dailyMove = market.get(root)?.dailyMove ?? null;
|
|
return {
|
|
symbol: root,
|
|
spreadDollars: spreadDollars(root),
|
|
dailyMove,
|
|
holdHours: leg ? expectedHoldHours({ leg: leg.leg, stopDollars: leg.stopDollars, contracts, dailyMove }) : null,
|
|
micro: CME_SPECS[root].micro === true,
|
|
};
|
|
});
|
|
const progress = progressNow(now);
|
|
const { chances, disabled } = drawChances(candidates, progress);
|
|
return { progress, candidates, chances, disabled };
|
|
}
|
|
|
|
/**
|
|
* 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 hold = (s: string) => board.candidates.find((c) => c.symbol === s)?.holdHours;
|
|
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(', ');
|
|
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)}%)` : ''}`);
|
|
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 entry cost, 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 },
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
client: any,
|
|
lead: (LegShape & { account: string }) | null,
|
|
): InstrumentStats {
|
|
refreshFees({ id: firm.id, platform: firm.platform }, client);
|
|
const listed = firmInstruments(firm);
|
|
const board = futuresBoard(listed, lead);
|
|
const disabled = new Set(board.disabled);
|
|
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,
|
|
instruments: CME_ROOTS.map((root) => {
|
|
const dailyMove = market.get(root)?.dailyMove ?? null;
|
|
const holdHours = leg ? expectedHoldHours({ ...leg, dailyMove }) : null;
|
|
return {
|
|
symbol: root,
|
|
listed: listed.includes(root),
|
|
contract: getCachedContract(root)?.name ?? null,
|
|
spreadDollars: spreadDollars(root),
|
|
feesPerSide: feesPerSide(firm.id, root),
|
|
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,
|
|
};
|
|
}),
|
|
};
|
|
}
|