From 58e74148492e963a0abc36ce08903550759787ce Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Mon, 28 Sep 2026 13:45:44 -0500 Subject: [PATCH] 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 --- lib/auto-trade.ts | 15 +++++++++++---- lib/firm-catalog.ts | 3 ++- lib/instrument-specs.ts | 20 +++++++++++++++++--- lib/instruments.ts | 18 ++++++++++++++---- lib/master-protocol.ts | 4 ++++ scripts/check-instruments.mjs | 24 +++++++++++++++++++++++- 6 files changed, 71 insertions(+), 13 deletions(-) diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 6c90d99..359531f 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -23,7 +23,7 @@ import { } from './trading-logic'; import { getSetting, setSetting } from './db'; import type { LaneTick, TickReason } from './master-protocol'; -import { POINT_VALUES, housekeepingRoot, HOUSEKEEPING_ROOT, MAX_HOLD_HOURS } from './instrument-specs'; +import { POINT_VALUES, housekeepingRoot, HOUSEKEEPING_ROOT, MAX_HOLD_HOURS, futuresContracts } from './instrument-specs'; import { firmInstruments, drawFuturesInstrument, spreadBlock, type LegShape } from './instruments'; import { recordEntry } from './trade-mix'; import { errorMessage, isClientReady, isOrderNotPlaced, type PlatformClient, type PlatformContract, type PickContext } from './platform-client'; @@ -486,7 +486,12 @@ function legShapeFor(item: SizingItem, tradeDate: string): LegShape { const hard = Number.isFinite(item.dllRoom) && dll > 0 ? dllStopDollars({ room: item.dllRoom, dailyLossLimit: dll, entryFee: FALLBACK_COMMISSION_PER_CONTRACT, commissionPct: 0 }) : Infinity; - return { leg, stopDollars: Math.min(Number.isFinite(obf) && obf > 0 ? obf : Infinity, hard), maxPositionSize: cfg.maxPositionSize }; + return { + leg, + stopDollars: Math.min(Number.isFinite(obf) && obf > 0 ? obf : Infinity, hard), + maxPositionSize: cfg.maxPositionSize, + limits: item.firm.instrumentLimits, + }; } /** What the fire path needs of a contract — PlatformContract minus the id. */ @@ -1310,8 +1315,10 @@ async function fireLeg( } } } else { - const rawContracts = Math.max(1, Math.ceil(legAmount / 1000)); - contracts = cfg.maxPositionSize > 0 ? Math.min(rawContracts, cfg.maxPositionSize) : rawContracts; + // One per $1,000, capped by the account type's max contracts and + // the firm's limit on this instrument. + const legRoot = item.drain ? housekeepingFor(item.firm) : symbolLabel; + contracts = futuresContracts(legAmount, cfg.maxPositionSize, item.firm.instrumentLimits?.[legRoot] ?? 0); } } catch (err) { unrecordLeg(acc.id, tradeDate, legAmount); diff --git a/lib/firm-catalog.ts b/lib/firm-catalog.ts index 4efbc1a..1a7f695 100644 --- a/lib/firm-catalog.ts +++ b/lib/firm-catalog.ts @@ -18,7 +18,7 @@ */ import { isFirmPlatform } from './master-protocol'; -import { sanitizeInstruments, DEFAULT_MAX_ENTRY_SPREAD } from './instrument-specs'; +import { sanitizeInstruments, sanitizeInstrumentLimits, DEFAULT_MAX_ENTRY_SPREAD } from './instrument-specs'; import type { CatalogAccountType, CatalogFirm, CatalogTradingSettings, FirmCatalogPayload, FirmPlatform, SymbolMap, SymbolMapCandidate, } from './master-protocol'; @@ -245,6 +245,7 @@ function validatePayload(raw: unknown): FirmCatalogPayload { // The master's list when it sends one; absent from an older // master, whose bannedSymbols / symbolMap then apply. ...(Array.isArray(firm.instruments) ? { instruments: sanitizeInstruments(platform, firm.instruments) } : {}), + ...(platform !== 'dxtrade' ? { instrumentLimits: sanitizeInstrumentLimits(firm.instrumentLimits) } : {}), bannedSymbols: Array.isArray(firm.bannedSymbols) ? (firm.bannedSymbols as string[]) : [], accountTypes, serverUrl: typeof firm.serverUrl === 'string' ? firm.serverUrl : '', diff --git a/lib/instrument-specs.ts b/lib/instrument-specs.ts index 73b963e..62e578c 100644 --- a/lib/instrument-specs.ts +++ b/lib/instrument-specs.ts @@ -107,10 +107,24 @@ export function dailyMoveDollars(closes: number[], pointValue: number, barsPerDa } /** Contracts a futures leg trades: one per $1,000, capped by the account - * type's max contracts (0 = no cap) — the rule fireLeg sizes by. */ -export function futuresContracts(leg: number, maxPositionSize: number): number { + * type's max contracts and the firm's limit on the instrument (0 = no + * cap) — the rule fireLeg sizes by. */ +export function futuresContracts(leg: number, maxPositionSize: number, instrumentLimit = 0): number { const raw = Math.max(1, Math.ceil(leg / 1000)); - return maxPositionSize > 0 ? Math.min(raw, maxPositionSize) : raw; + const caps = [maxPositionSize, instrumentLimit].filter((c) => c > 0); + return caps.length > 0 ? Math.min(raw, ...caps) : raw; +} + +/** The most contracts a firm allows on one instrument, as its catalog entry + * says: CME roots only, whole numbers from 1 to 1000. Anything else is + * dropped — no limit beyond the account type's max contracts. */ +export function sanitizeInstrumentLimits(raw: unknown): Record { + const out: Record = {}; + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return out; + for (const [root, value] of Object.entries(raw as Record)) { + if (CME_SPECS[root] && typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 1000) out[root] = value; + } + return out; } /** diff --git a/lib/instruments.ts b/lib/instruments.ts index ea65ff4..839b709 100644 --- a/lib/instruments.ts +++ b/lib/instruments.ts @@ -36,6 +36,7 @@ export interface FirmLike { id?: string; platform: string; instruments?: string[]; + instrumentLimits?: Record; bannedSymbols?: string[]; symbolMap?: Record; } @@ -155,6 +156,8 @@ export interface LegShape { leg: number; stopDollars: number; maxPositionSize: number; + /** The firm's most contracts per instrument, by root (absent = none). */ + limits?: Record; } function progressNow(now: Date): number | null { @@ -188,11 +191,13 @@ export function futuresBoard(roots: readonly string[], leg: LegShape | null, now 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()); + // 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, @@ -254,10 +259,10 @@ export function instrumentStatsFor( lead: (LegShape & { account: string }) | null, ): InstrumentStats { const listed = firmInstruments(firm); - const board = futuresBoard(listed, lead); + const limits = firm.instrumentLimits ?? {}; + const board = futuresBoard(listed, lead ? { ...lead, limits } : null); 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, @@ -268,7 +273,12 @@ export function instrumentStatsFor( quoteSource: quoteSource(), instruments: CME_ROOTS.map((root) => { const dailyMove = market.get(root)?.dailyMove ?? null; - const holdHours = leg ? expectedHoldHours({ ...leg, 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, diff --git a/lib/master-protocol.ts b/lib/master-protocol.ts index 75f84a3..598cb77 100644 --- a/lib/master-protocol.ts +++ b/lib/master-protocol.ts @@ -381,6 +381,10 @@ export interface CatalogFirm { * own symbols for DXtrade (see lib/instruments.ts). Absent from a master * from before the lists — then bannedSymbols and symbolMap apply. */ instruments?: string[]; + /** Futures firms: the most contracts the firm allows on an instrument, + * by root, for every account type (absent = no limit beyond the type's + * max contracts). */ + instrumentLimits?: Record; /** Older masters' way to exclude roots; newer ones derive it from * `instruments`. */ bannedSymbols: string[]; diff --git a/scripts/check-instruments.mjs b/scripts/check-instruments.mjs index 26e2a76..6492264 100644 --- a/scripts/check-instruments.mjs +++ b/scripts/check-instruments.mjs @@ -10,7 +10,7 @@ import assert from 'node:assert/strict'; import { CME_SPECS, CME_ROOTS, POINT_VALUES, MICRO_OF, tickValue, spreadDollars, dailyMoveDollars, futuresContracts, expectedHoldHours, sessionProgress, drawChances, pickByChance, housekeepingRoot, sanitizeInstruments, - resolveAllowlist, MAX_HOLD_HOURS, DRAW_FLOOR, DEFAULT_MAX_ENTRY_SPREAD, spreadTooWide, + resolveAllowlist, MAX_HOLD_HOURS, DRAW_FLOOR, DEFAULT_MAX_ENTRY_SPREAD, spreadTooWide, sanitizeInstrumentLimits, } from '../lib/instrument-specs.ts'; import { mergeTopstepxQuote, parseTradovateFrame, bookSpread } from '../lib/quote-helpers.ts'; @@ -234,3 +234,25 @@ test('the draw leaves out what is over the spread limit', () => { // All of them wide: nothing to draw. assert.deepEqual(drawChances(cands.map((c) => ({ ...c, wide: true })), 0.5).chances, []); }); + +test('a firm\'s per-instrument limit caps the contracts, with the account type\'s max', () => { + // One per $1,000 of leg ... + assert.equal(futuresContracts(4200, 0), 5); + // ... capped by the account type's max, the firm's limit, whichever is lower. + assert.equal(futuresContracts(4200, 0, 3), 3); + assert.equal(futuresContracts(4200, 4, 2), 2); + assert.equal(futuresContracts(4200, 2, 4), 2); + assert.equal(futuresContracts(4200, 0, 0), 5); + // Never under one contract. + assert.equal(futuresContracts(300, 0, 5), 1); + // A capped leg holds longer: 5 contracts on NQ vs a cap of 2. + const free = expectedHoldHours({ leg: 5000, stopDollars: Infinity, contracts: futuresContracts(5000, 0), dailyMove: 5227 }); + const capped = expectedHoldHours({ leg: 5000, stopDollars: Infinity, contracts: futuresContracts(5000, 0, 2), dailyMove: 5227 }); + assert.ok(Math.abs(capped / free - 6.25) < 1e-9, `${capped} vs ${free}`); +}); + +test('instrument limits off the catalog: CME roots, whole numbers 1-1000', () => { + assert.deepEqual(sanitizeInstrumentLimits({ NQ: 15, CL: 5, GC: 0, ES: 2.5, ZZ: 3, SI: 2000, YM: '4' }), { NQ: 15, CL: 5 }); + assert.deepEqual(sanitizeInstrumentLimits(null), {}); + assert.deepEqual(sanitizeInstrumentLimits(['NQ']), {}); +});