Files
autofirmer-expanded/lib/instruments.ts
T
Brandon LiandClaude Opus 5.5 58e7414849 Futures legs respect a firm's per-instrument contract limit
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>
2026-09-28 13:45:44 -05:00

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