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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b125257d4e
commit
58e7414849
+11
-4
@@ -23,7 +23,7 @@ import {
|
|||||||
} from './trading-logic';
|
} from './trading-logic';
|
||||||
import { getSetting, setSetting } from './db';
|
import { getSetting, setSetting } from './db';
|
||||||
import type { LaneTick, TickReason } from './master-protocol';
|
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 { firmInstruments, drawFuturesInstrument, spreadBlock, type LegShape } from './instruments';
|
||||||
import { recordEntry } from './trade-mix';
|
import { recordEntry } from './trade-mix';
|
||||||
import { errorMessage, isClientReady, isOrderNotPlaced, type PlatformClient, type PlatformContract, type PickContext } from './platform-client';
|
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
|
const hard = Number.isFinite(item.dllRoom) && dll > 0
|
||||||
? dllStopDollars({ room: item.dllRoom, dailyLossLimit: dll, entryFee: FALLBACK_COMMISSION_PER_CONTRACT, commissionPct: 0 })
|
? dllStopDollars({ room: item.dllRoom, dailyLossLimit: dll, entryFee: FALLBACK_COMMISSION_PER_CONTRACT, commissionPct: 0 })
|
||||||
: Infinity;
|
: 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. */
|
/** What the fire path needs of a contract — PlatformContract minus the id. */
|
||||||
@@ -1310,8 +1315,10 @@ async function fireLeg(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const rawContracts = Math.max(1, Math.ceil(legAmount / 1000));
|
// One per $1,000, capped by the account type's max contracts and
|
||||||
contracts = cfg.maxPositionSize > 0 ? Math.min(rawContracts, cfg.maxPositionSize) : rawContracts;
|
// 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) {
|
} catch (err) {
|
||||||
unrecordLeg(acc.id, tradeDate, legAmount);
|
unrecordLeg(acc.id, tradeDate, legAmount);
|
||||||
|
|||||||
+2
-1
@@ -18,7 +18,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { isFirmPlatform } from './master-protocol';
|
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 {
|
import type {
|
||||||
CatalogAccountType, CatalogFirm, CatalogTradingSettings, FirmCatalogPayload, FirmPlatform, SymbolMap, SymbolMapCandidate,
|
CatalogAccountType, CatalogFirm, CatalogTradingSettings, FirmCatalogPayload, FirmPlatform, SymbolMap, SymbolMapCandidate,
|
||||||
} from './master-protocol';
|
} from './master-protocol';
|
||||||
@@ -245,6 +245,7 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
|
|||||||
// The master's list when it sends one; absent from an older
|
// The master's list when it sends one; absent from an older
|
||||||
// master, whose bannedSymbols / symbolMap then apply.
|
// master, whose bannedSymbols / symbolMap then apply.
|
||||||
...(Array.isArray(firm.instruments) ? { instruments: sanitizeInstruments(platform, firm.instruments) } : {}),
|
...(Array.isArray(firm.instruments) ? { instruments: sanitizeInstruments(platform, firm.instruments) } : {}),
|
||||||
|
...(platform !== 'dxtrade' ? { instrumentLimits: sanitizeInstrumentLimits(firm.instrumentLimits) } : {}),
|
||||||
bannedSymbols: Array.isArray(firm.bannedSymbols) ? (firm.bannedSymbols as string[]) : [],
|
bannedSymbols: Array.isArray(firm.bannedSymbols) ? (firm.bannedSymbols as string[]) : [],
|
||||||
accountTypes,
|
accountTypes,
|
||||||
serverUrl: typeof firm.serverUrl === 'string' ? firm.serverUrl : '',
|
serverUrl: typeof firm.serverUrl === 'string' ? firm.serverUrl : '',
|
||||||
|
|||||||
+17
-3
@@ -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
|
/** 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. */
|
* type's max contracts and the firm's limit on the instrument (0 = no
|
||||||
export function futuresContracts(leg: number, maxPositionSize: number): number {
|
* 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));
|
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<string, number> {
|
||||||
|
const out: Record<string, number> = {};
|
||||||
|
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return out;
|
||||||
|
for (const [root, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||||
|
if (CME_SPECS[root] && typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 1000) out[root] = value;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+14
-4
@@ -36,6 +36,7 @@ export interface FirmLike {
|
|||||||
id?: string;
|
id?: string;
|
||||||
platform: string;
|
platform: string;
|
||||||
instruments?: string[];
|
instruments?: string[];
|
||||||
|
instrumentLimits?: Record<string, number>;
|
||||||
bannedSymbols?: string[];
|
bannedSymbols?: string[];
|
||||||
symbolMap?: Record<string, { symbol: string }[]>;
|
symbolMap?: Record<string, { symbol: string }[]>;
|
||||||
}
|
}
|
||||||
@@ -155,6 +156,8 @@ export interface LegShape {
|
|||||||
leg: number;
|
leg: number;
|
||||||
stopDollars: number;
|
stopDollars: number;
|
||||||
maxPositionSize: number;
|
maxPositionSize: number;
|
||||||
|
/** The firm's most contracts per instrument, by root (absent = none). */
|
||||||
|
limits?: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function progressNow(now: Date): number | null {
|
function progressNow(now: Date): number | null {
|
||||||
@@ -188,11 +191,13 @@ export function futuresBoard(roots: readonly string[], leg: LegShape | null, now
|
|||||||
disabled: string[];
|
disabled: string[];
|
||||||
wide: string[];
|
wide: string[];
|
||||||
} {
|
} {
|
||||||
const contracts = leg ? futuresContracts(leg.leg, leg.maxPositionSize) : 1;
|
|
||||||
const max = maxEntrySpread();
|
const max = maxEntrySpread();
|
||||||
const candidates = roots.filter((r) => CME_SPECS[r]).map((root) => {
|
const candidates = roots.filter((r) => CME_SPECS[r]).map((root) => {
|
||||||
const dailyMove = market.get(root)?.dailyMove ?? null;
|
const dailyMove = market.get(root)?.dailyMove ?? null;
|
||||||
const spread = entrySpread(root, now.getTime());
|
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 {
|
return {
|
||||||
symbol: root,
|
symbol: root,
|
||||||
spreadDollars: spread.dollars,
|
spreadDollars: spread.dollars,
|
||||||
@@ -254,10 +259,10 @@ export function instrumentStatsFor(
|
|||||||
lead: (LegShape & { account: string }) | null,
|
lead: (LegShape & { account: string }) | null,
|
||||||
): InstrumentStats {
|
): InstrumentStats {
|
||||||
const listed = firmInstruments(firm);
|
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 disabled = new Set(board.disabled);
|
||||||
const max = maxEntrySpread();
|
const max = maxEntrySpread();
|
||||||
const leg = lead ? { leg: lead.leg, stopDollars: lead.stopDollars, contracts: futuresContracts(lead.leg, lead.maxPositionSize) } : null;
|
|
||||||
return {
|
return {
|
||||||
at: new Date().toISOString(),
|
at: new Date().toISOString(),
|
||||||
account: lead?.account ?? null,
|
account: lead?.account ?? null,
|
||||||
@@ -268,7 +273,12 @@ export function instrumentStatsFor(
|
|||||||
quoteSource: quoteSource(),
|
quoteSource: quoteSource(),
|
||||||
instruments: CME_ROOTS.map((root) => {
|
instruments: CME_ROOTS.map((root) => {
|
||||||
const dailyMove = market.get(root)?.dailyMove ?? null;
|
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);
|
const spread = entrySpread(root);
|
||||||
return {
|
return {
|
||||||
symbol: root,
|
symbol: root,
|
||||||
|
|||||||
@@ -381,6 +381,10 @@ export interface CatalogFirm {
|
|||||||
* own symbols for DXtrade (see lib/instruments.ts). Absent from a master
|
* own symbols for DXtrade (see lib/instruments.ts). Absent from a master
|
||||||
* from before the lists — then bannedSymbols and symbolMap apply. */
|
* from before the lists — then bannedSymbols and symbolMap apply. */
|
||||||
instruments?: string[];
|
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<string, number>;
|
||||||
/** Older masters' way to exclude roots; newer ones derive it from
|
/** Older masters' way to exclude roots; newer ones derive it from
|
||||||
* `instruments`. */
|
* `instruments`. */
|
||||||
bannedSymbols: string[];
|
bannedSymbols: string[];
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import assert from 'node:assert/strict';
|
|||||||
import {
|
import {
|
||||||
CME_SPECS, CME_ROOTS, POINT_VALUES, MICRO_OF, tickValue, spreadDollars, dailyMoveDollars, futuresContracts,
|
CME_SPECS, CME_ROOTS, POINT_VALUES, MICRO_OF, tickValue, spreadDollars, dailyMoveDollars, futuresContracts,
|
||||||
expectedHoldHours, sessionProgress, drawChances, pickByChance, housekeepingRoot, sanitizeInstruments,
|
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';
|
} from '../lib/instrument-specs.ts';
|
||||||
import { mergeTopstepxQuote, parseTradovateFrame, bookSpread } from '../lib/quote-helpers.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.
|
// All of them wide: nothing to draw.
|
||||||
assert.deepEqual(drawChances(cands.map((c) => ({ ...c, wide: true })), 0.5).chances, []);
|
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']), {});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user