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