Legs: cheapest symbol that reaches the target hold, last stands, a reserve that grows with size

- A batch sizes its leg on every mapped candidate, each at the leverage
  the broker states for it, and trades the cheapest (spread, commissions,
  expected swap) that reaches the target hold — else the quickest. The
  dashboard ranks the same way from a half-hourly candidate survey.
- Last stand: when a daily-loss stop-out would leave the account within
  15% of that loss above its max-drawdown floor (the account type's static
  max loss, or the broker's liquidation floor), the leg carries no
  daily-loss stop and is sized against the firm's own breach.
- The daily-loss reserve is 10% of the limit or 0.1% of the position,
  whichever is more, since a stop's slippage grows with the position.
- The account type's max notional is gone: the broker's margin rates cap
  each symbol. planLegSize holds the one sizing rule fireLeg, the pick and
  the dashboard share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-26 18:26:12 -05:00
co-authored by Claude Opus 5.5
parent a2d9df5ba4
commit a2cd3a94c9
12 changed files with 477 additions and 172 deletions
+137 -13
View File
@@ -1182,6 +1182,14 @@ export function obfuscationStopDollars(
/** Share of a hard daily loss limit the bot never spends — room for a stop
* that fills worse than its price (gaps, thin weekend books). */
export const DLL_RESERVE_FRACTION = 0.10;
/** …or this share of the position's notional, when that is more: a stop's
* slippage grows with the position, a flat reserve doesn't. */
export const DLL_SLIPPAGE_FRACTION = 0.001;
/** The part of the day's room a stop leaves unspent. */
export function dllReserve(dailyLossLimit: number, notional = 0): number {
return Math.max(DLL_RESERVE_FRACTION * dailyLossLimit, DLL_SLIPPAGE_FRACTION * Math.max(0, notional));
}
/** Smallest daily-loss stop worth trading. Under a fair walk a leg of L with a
* stop of S wins S/(S + L) of the time — about 9% for $50 against $500 — so
* anything tighter just spends the day's room on fees. */
@@ -1218,11 +1226,55 @@ export function dllRoom(o: {
* entryFee + r × (stop distance) — a short's stop sits above its entry — so
* the worst loss is d(1 + r) + 2·entryFee, plus a cent of fee rounding.
* Infinity when there is no limit. Can go negative: no room at all.
*
* Overnight swap needs no reserve of its own while a day's swap is below the
* commission rate (Tradeify: 0.033% < 0.04%): a position carried past the
* reset has its entry fee on the old day's books, and the swap takes no more
* than that slot on the new day's.
*/
export function dllStopDollars(o: { room: number; dailyLossLimit: number; entryFee: number; commissionPct: number }): number {
export function dllStopDollars(o: { room: number; dailyLossLimit: number; entryFee: number; commissionPct: number; notional?: number }): number {
if (!Number.isFinite(o.room)) return Infinity;
const r = Math.max(0, o.commissionPct) / 100;
return (o.room - DLL_RESERVE_FRACTION * o.dailyLossLimit - 2 * o.entryFee - 0.01) / (1 + r);
return (o.room - dllReserve(o.dailyLossLimit, o.notional) - 2 * o.entryFee - 0.01) / (1 + r);
}
/**
* How far the account's balance is above its max-drawdown floor, or null when
* it isn't known: the broker's liquidation floor where it reports one, else
* the account type's static max loss below the starting size — whichever is
* higher.
*/
export function maxLossRoom(o: { balance: number; accountSize: number; maxLoss: number; autoLiqThreshold?: number }): number | null {
const floors: number[] = [];
if ((o.autoLiqThreshold ?? 0) > 0) floors.push(o.autoLiqThreshold!);
if (Math.abs(o.maxLoss) > 0 && o.accountSize > 0) floors.push(o.accountSize - Math.abs(o.maxLoss));
return floors.length > 0 ? round2(o.balance - Math.max(...floors)) : null;
}
/** A daily-loss stop is kept only while a stop-out would leave at least this
* share of its loss above the max-drawdown floor. */
export const LAST_STAND_FRACTION = 0.15;
/**
* Whether a leg should ride without its daily-loss stop. The stop is there to
* keep the account alive for another day; when a stop-out would leave it
* within 15% of that loss of its max-drawdown floor — or the floor is nearer
* than the stop — there is no useful day after, and the stop only gives up
* room (the reserve, and whatever the floor allows past it) for nothing. The
* leg then runs until its take-profit or the firm's own breach.
*/
export function lastStand(o: { dllRoom: number; dailyLossLimit: number; maxLossRoom: number | null }): boolean {
if (o.maxLossRoom === null || !(o.dailyLossLimit > 0) || !Number.isFinite(o.dllRoom)) return false;
const stopOut = o.dllRoom - DLL_RESERVE_FRACTION * o.dailyLossLimit;
if (!(stopOut > 0)) return false;
return o.maxLossRoom - stopOut <= LAST_STAND_FRACTION * stopOut;
}
/** On a last stand: how far price can move against the leg before the firm's
* nearer floor breaches the account — its equity carries the entry fee, not
* the exit's. What sizing and the swap estimate take as the walk's far side. */
export function breachDistance(o: { dllRoom: number; maxLossRoom: number | null; entryFee: number }): number {
return Math.min(o.dllRoom, o.maxLossRoom ?? Infinity) - o.entryFee;
}
/** Is there enough daily-loss room left to trade at all? */
@@ -1274,18 +1326,13 @@ export const DEFAULT_TARGET_HOLD_HOURS = 12;
export const MARGIN_HEADROOM = 0.97;
/**
* The largest position an account may open: the account type's notional cap
* and, where the broker states the instrument's margin rate, what the
* account's available funds can margin (the firm's leverage — 5:1 on crypto,
* 2:1 on stocks at Tradeify247). 0 = no cap.
* The largest position an account may open: what its available funds can
* margin at the broker's rate for the instrument — the firm's leverage per
* symbol (5:1 crypto, 2:1 stocks at Tradeify247). 0 = no cap known.
*/
export function notionalCap(o: { maxNotional: number; marginRate?: number; availableFunds?: number }): number {
const caps: number[] = [];
if (o.maxNotional > 0) caps.push(o.maxNotional);
if ((o.marginRate ?? 0) > 0 && Number.isFinite(o.availableFunds) && o.availableFunds! > 0) {
caps.push((MARGIN_HEADROOM * o.availableFunds!) / o.marginRate!);
}
return caps.length > 0 ? Math.min(...caps) : 0;
export function notionalCap(o: { marginRate?: number; availableFunds?: number }): number {
if (!((o.marginRate ?? 0) > 0) || !Number.isFinite(o.availableFunds) || !(o.availableFunds! > 0)) return 0;
return (MARGIN_HEADROOM * o.availableFunds!) / o.marginRate!;
}
/** The swap reserve never grows past this share of the take-profit distance
@@ -1398,6 +1445,83 @@ export function sizeNotional(o: {
};
}
/**
* One leg's size on one instrument for one account — the rule fireLeg
* trades, the instrument pick ranks by and the dashboard shows.
*
* The walk's far side (what sizing, the win chance and the swap estimate
* use) is the stop the leg carries: the tighter of the obfuscation stop and
* the hard daily-loss stop. On a last stand there is no daily-loss stop and
* 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.
*/
export function planLegSize(o: {
leg: number;
contract: { stepNotional?: number; volatilityPerHour?: number; spreadFraction?: number; marginRate?: number };
/** Per side, percent. */
commissionPct: number;
targetHoldHours: number;
financingPctPerDay: number;
dailyLossLimit: number;
/** Infinity = no hard daily limit. */
dllRoom: number;
maxLossRoom: number | null;
/** Infinity (or ≤ 0) = no obfuscation stop. */
obfuscationDollars: number;
availableFunds?: number;
}): {
lastStand: boolean;
cap: number;
walkAt: (notional: number) => number;
sizing: ReturnType<typeof sizeNotional>;
cost: number | null;
} {
const dllOn = Number.isFinite(o.dllRoom) && o.dailyLossLimit > 0;
const stand = dllOn && lastStand({ dllRoom: o.dllRoom, dailyLossLimit: o.dailyLossLimit, maxLossRoom: o.maxLossRoom });
const obf = Number.isFinite(o.obfuscationDollars) && o.obfuscationDollars > 0 ? o.obfuscationDollars : Infinity;
const r = Math.max(0, o.commissionPct) / 100;
const walkAt = (notional: number) => Math.min(
obf,
!dllOn ? Infinity
: stand ? breachDistance({ dllRoom: o.dllRoom, maxLossRoom: o.maxLossRoom, entryFee: r * notional })
: 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
? sizeNotional({
leg: o.leg,
stopDollarsAt: walkAt,
volatilityPerHour: o.contract.volatilityPerHour,
targetHoldHours: o.targetHoldHours,
commissionPct: o.commissionPct,
spreadFraction: o.contract.spreadFraction,
financingPctPerDay: o.financingPctPerDay,
maxNotional: cap,
})
: null;
const cost = sizing
? sizing.notional * (Math.max(0, o.contract.spreadFraction ?? 0) + 2 * r)
+ (Math.max(0, o.financingPctPerDay) / 100) * sizing.notional * (sizing.predictedHoldHours / 24)
: null;
return { lastStand: stand, cap, walkAt, sizing, cost };
}
/**
* Order candidates for a leg: those that reach the target hold (within a
* few percent) first, cheapest first; then those whose leverage cap keeps
* them short of it, quickest first. Returns indices into `options`.
*/
export function rankForTarget(options: { holdHours: number; cost: number }[], targetHoldHours: number): number[] {
const reaches = (h: number) => h <= targetHoldHours * 1.02 + 0.01;
return options
.map((opt, i) => ({ ...opt, i, reach: reaches(opt.holdHours) }))
.sort((a, b) => (a.reach === b.reach
? (a.reach ? a.cost - b.cost : a.holdHours - b.holdHours) || a.i - b.i
: a.reach ? -1 : 1))
.map((x) => x.i);
}
// ── trading hours ────────────────────────────────────────────────────────────
/** Tradeify247 (and DXtrade brokers generally) roll the trading day at 22:00