A firm can cap how many contracts of an instrument its accounts may hold (the catalog's instrumentLimits, set on the master's Firms page). A leg trades one contract per $1,000, capped by the account type's max contracts and that limit. The Auto draw sizes each instrument's hold with the capped count, so a limit that slows a leg past 12 hours takes it out of the draw. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
298 lines
13 KiB
TypeScript
298 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[];
|
|
instrumentLimits?: Record<string, number>;
|
|
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;
|
|
/** The firm's most contracts per instrument, by root (absent = none). */
|
|
limits?: Record<string, 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 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());
|
|
// The size this leg would trade on this root: a firm's cap on the
|
|
// instrument slows it, and may push it past the hold cap.
|
|
const contracts = leg ? futuresContracts(leg.leg, leg.maxPositionSize, leg.limits?.[root] ?? 0) : 1;
|
|
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 limits = firm.instrumentLimits ?? {};
|
|
const board = futuresBoard(listed, lead ? { ...lead, limits } : null);
|
|
const disabled = new Set(board.disabled);
|
|
const max = maxEntrySpread();
|
|
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 = lead
|
|
? expectedHoldHours({
|
|
leg: lead.leg, stopDollars: lead.stopDollars, dailyMove,
|
|
contracts: futuresContracts(lead.leg, lead.maxPositionSize, limits[root] ?? 0),
|
|
})
|
|
: 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,
|
|
};
|
|
}),
|
|
};
|
|
}
|