From 1782c1614558a839cd652fa6230a66a7a0c104cd Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Sun, 27 Sep 2026 13:59:27 -0500 Subject: [PATCH] Sizing: the broker's largest and smallest order bind the leg's size fireLeg clamped the order to the instrument's max and min size, but the size the pick ranked by (and the Firms page shows) didn't: an instrument whose largest order falls short of the ideal notional ranked on a hold it can't reach. planLegSize now holds the notional to the margin cap and the broker's largest order, then its smallest, so the ranking, the stats and the trade agree. Co-Authored-By: Claude Opus 5.5 --- lib/trading-logic.ts | 23 +++++++++++++++++++---- scripts/check-trading-logic.mjs | 28 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/lib/trading-logic.ts b/lib/trading-logic.ts index cfacdb4..157758b 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -1398,6 +1398,9 @@ export function sizeNotional(o: { financingPctPerDay?: number; /** Hard cap on the position (see notionalCap); 0 = none. */ maxNotional: number; + /** The smallest order the broker takes; 0 = none. It wins over the cap, + * as the order does. */ + minNotional?: number; }): { notional: number; predictedHoldHours: number; winProbability: number | null; swapReserve: number } | null { const sigma = o.volatilityPerHour ?? 0; if (!(sigma > 0)) return null; @@ -1428,7 +1431,7 @@ export function sizeNotional(o: { n = Math.max(nTime, nCost); } if (!Number.isFinite(n) || n <= 0) return null; - const notional = o.maxNotional > 0 ? Math.min(n, o.maxNotional) : n; + const notional = Math.max(o.maxNotional > 0 ? Math.min(n, o.maxNotional) : n, o.minNotional ?? 0); const { a, b, stopped, swap } = distances(notional); return { notional, @@ -1448,10 +1451,19 @@ export function sizeNotional(o: { * the far side is the firm's own breach. `cost` is what the leg is expected * to spend: spread and both commissions on its notional, plus swap over its * expected hold. + * + * The size is held to what the order can be — the margin cap and the + * broker's largest order, then its smallest — so the pick ranks each + * instrument by the hold it can actually reach. `cap` is the margin cap + * alone. */ export function planLegSize(o: { leg: number; - contract: { stepNotional?: number; volatilityPerHour?: number; spreadFraction?: number; marginRate?: number }; + contract: { + stepNotional?: number; volatilityPerHour?: number; spreadFraction?: number; marginRate?: number; + /** The broker's largest and smallest order, in steps. */ + maxContracts?: number; minContracts?: number; + }; /** Per side, percent. */ commissionPct: number; targetHoldHours: number; @@ -1481,7 +1493,9 @@ export function planLegSize(o: { : dllStopDollars({ room: o.dllRoom, dailyLossLimit: o.dailyLossLimit, entryFee: r * notional, commissionPct: o.commissionPct, notional }), ); const cap = notionalCap({ marginRate: o.contract.marginRate, availableFunds: o.availableFunds }); - const sizing = (o.contract.stepNotional ?? 0) > 0 + const step = o.contract.stepNotional ?? 0; + const orderMax = (o.contract.maxContracts ?? 0) > 0 ? o.contract.maxContracts! * step : 0; + const sizing = step > 0 ? sizeNotional({ leg: o.leg, stopDollarsAt: walkAt, @@ -1490,7 +1504,8 @@ export function planLegSize(o: { commissionPct: o.commissionPct, spreadFraction: o.contract.spreadFraction, financingPctPerDay: o.financingPctPerDay, - maxNotional: cap, + maxNotional: cap > 0 && orderMax > 0 ? Math.min(cap, orderMax) : cap || orderMax, + minNotional: Math.max(1, o.contract.minContracts ?? 1) * step, }) : null; const cost = sizing diff --git a/scripts/check-trading-logic.mjs b/scripts/check-trading-logic.mjs index 42540e3..34e6654 100644 --- a/scripts/check-trading-logic.mjs +++ b/scripts/check-trading-logic.mjs @@ -425,6 +425,34 @@ test('planLegSize: the stop, a last stand, the margin cap and the cost, as fireL assert.equal(planLegSize({ ...base, maxLossRoom: null, contract: {} }).sizing, null); }); +test('planLegSize: the broker\'s order size limits bind the sizing, so the pick sees the real hold', () => { + const eth = { stepNotional: 26.86, volatilityPerHour: 0.022 / Math.sqrt(24), spreadFraction: 0.00004, marginRate: 0.2 }; + const base = { + leg: 12239, contract: eth, commissionPct: 0.04, targetHoldHours: 12, financingPctPerDay: 0.033, + dailyLossLimit: 3000, dllRoom: 2757.39, maxLossRoom: 5757.39, obfuscationDollars: Infinity, availableFunds: 99757.39, + }; + const free = planLegSize(base); + // A largest order well under the ideal size: the notional stops there and + // the hold grows past the target (the margin cap stays what it was). + const capped = planLegSize({ ...base, contract: { ...eth, maxContracts: 5000 } }); + assert.ok(Math.abs(capped.sizing.notional - 5000 * 26.86) < 1e-6, `notional ${capped.sizing.notional}`); + assert.ok(capped.sizing.notional < free.sizing.notional); + assert.ok(capped.sizing.predictedHoldHours > 12, `hold ${capped.sizing.predictedHoldHours}`); + assert.equal(capped.cap, free.cap); + // One that doesn't bind changes nothing. + assert.equal(planLegSize({ ...base, contract: { ...eth, maxContracts: 1e9 } }).sizing.notional, free.sizing.notional); + // A smallest order above the ideal size: the order is that big. + const floored = planLegSize({ ...base, contract: { ...eth, minContracts: 20000 } }); + assert.ok(Math.abs(floored.sizing.notional - 20000 * 26.86) < 1e-6); + assert.ok(floored.sizing.predictedHoldHours < 12); + // Ranked on the capped hold, a capped instrument no longer wins on cost. + const order = rankForTarget([ + { holdHours: capped.sizing.predictedHoldHours, cost: capped.cost }, + { holdHours: free.sizing.predictedHoldHours, cost: free.cost + 50 }, + ], 12); + assert.deepEqual(order, [1, 0]); +}); + test('rankForTarget: reach the target hold first, cheapest; else quickest', () => { const order = rankForTarget([ { holdHours: 12, cost: 330 }, // ETH