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
+124 -76
View File
@@ -13,8 +13,8 @@ import { getCachedFirm, getTradableFirms } from './firm-catalog';
import { getAccountConfig } from './account-type-resolver';
import {
combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT,
obfuscationStopDollars, takeProfitPlan, percentFee, dllRoom, dllStopDollars, dllViable, stopTicks, sizeNotional,
notionalCap, expectedSwapToWin,
obfuscationStopDollars, takeProfitPlan, percentFee, dllRoom, dllStopDollars, dllViable, stopTicks,
expectedSwapToWin, planLegSize, maxLossRoom, breachDistance,
DEFAULT_TARGET_HOLD_HOURS,
isNoTradeTime, isPastSessionClose,
BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY, POINT_VALUES,
@@ -22,7 +22,7 @@ import {
type DayPlan,
} from './trading-logic';
import { getSetting, setSetting } from './db';
import { errorMessage, isClientReady, isOrderNotPlaced, type PlatformClient, type PlatformContract } from './platform-client';
import { errorMessage, isClientReady, isOrderNotPlaced, type PlatformClient, type PlatformContract, type PickContext } from './platform-client';
// ── process-wide state ───────────────────────────────────────────────────────
@@ -757,6 +757,9 @@ interface LegItem {
dllRoom: number;
}
/** What sizing a leg needs before its instrument is known. */
type SizingItem = Omit<LegItem, 'contract' | 'pv'>;
async function runTradeLocked(
action: 'Buy' | 'Sell' | 'Auto',
symbol: string,
@@ -816,27 +819,31 @@ async function runTradeLocked(
// snapshot) skips that firm, never the batch — a rejection here would
// fail the Promise.all and every other firm would sit the tick out.
try {
const contract = await client.findFrontMonthContract(resolvedSymbol);
if (!contract) return;
const pv = contract.pointValue ?? basePointValue;
if (!pv) {
console.log(`[auto-trade] ${firm.name}: no point value for ${resolvedSymbol} — skipping firm`);
return;
}
const found: EligibleItem[] = [];
// The accounts first: a platform that chooses between
// instruments ranks them by how the batch's leg would size.
const evaluated: (SizingItem & { daysTraded: number; isExtraDay: boolean })[] = [];
for (const acc of client.accountList) {
const ev = evaluateAccountForTrade(client, acc, tradeDate);
if (!ev || !ev.eligible || !ev.plan) continue;
found.push({
firmName: firm.name, firm, client, acc, contract, pv,
evaluated.push({
firmName: firm.name, firm, client, acc,
cash: ev.cash, dailyPnL: ev.dailyPnL, daysTraded: ev.daysTraded,
plan: ev.plan, isExtraDay: ev.isExtraDay, drain: ev.drain,
prefix: getAccountConfig(acc.id).accountType?.prefix ?? '',
dllRoom: ev.dllRoom,
});
}
if (evaluated.length === 0) return;
const contract = await client.findFrontMonthContract(
resolvedSymbol, pickContextFor(evaluated.filter((it) => !it.isExtraDay && !it.drain), tradeDate));
if (!contract) return;
const pv = contract.pointValue ?? basePointValue;
if (!pv) {
console.log(`[auto-trade] ${firm.name}: no point value for ${resolvedSymbol} — skipping firm`);
return;
}
const found: EligibleItem[] = evaluated.map((it) => ({ ...it, contract, pv }));
allEligible.push(...found);
} catch (err) {
console.error(`[auto-trade] ${firm.name}: skipped — ${errorMessage(err)}`);
@@ -962,6 +969,73 @@ async function flattenAndFail(
throw new Error(`entry filled @ ${entryPrice}; flattened — ${why}`);
}
/**
* The leg an account trades next. Split-allowed big days carve a ≥$500
* piece; everything else trades the full remaining amount in one go. The
* carve fraction is seeded per (prefix, date, leg number) so like accounts in
* a batch split their targets the same way, or close to it when remainders
* differ. An account type's min-legs setting forces at least that many
* carves on BIG days only — a day at or below the threshold still trades as
* one leg. Set per prefix, so like accounts still split alike.
*/
function legAmountFor(item: SizingItem, tradeDate: string): number {
const cfg = getAccountConfig(item.acc.id).accountType!;
const minLegs = item.plan.dayTarget > BIG_DAY_THRESHOLD ? (cfg.minLegs ?? 0) : 0;
return item.plan.splitAllowed
? pickLegAmount(
item.plan.remainingToday,
() => groupLegFraction(item.prefix, tradeDate, legsToday(item.acc.id, tradeDate)),
minLegs - legsToday(item.acc.id, tradeDate),
)
: item.plan.remainingToday;
}
/** How this account's leg would size on `contract` (see planLegSize), with
* the account's live funds, its max-drawdown floor and its stops. */
function sizeLeg(item: SizingItem, contract: LegContract, legAmount: number, tradeDate: string) {
const cfg = getAccountConfig(item.acc.id).accountType!;
const live = item.client.accountCashBalances[item.acc.id] ?? item.cash;
const pct = contract.commissionPct ?? item.firm.commissionPct ?? 0;
const floorRoom = maxLossRoom({
balance: live.amount, accountSize: cfg.accountSize, maxLoss: cfg.maxLoss ?? 0,
autoLiqThreshold: item.client.autoLiqThresholds?.[item.acc.id],
});
const obfuscation = obfuscationStopDollars(cfg, item.dailyPnL, item.plan, tradeDate, legAmount);
const plan = planLegSize({
leg: legAmount,
contract,
commissionPct: pct,
targetHoldHours: item.firm.targetHoldHours || DEFAULT_TARGET_HOLD_HOURS,
financingPctPerDay: item.firm.financingPctPerDay ?? 0,
dailyLossLimit: cfg.dailyLossLimit ?? 0,
dllRoom: item.dllRoom,
maxLossRoom: floorRoom,
obfuscationDollars: obfuscation,
availableFunds: live.availableFunds ?? live.amount,
});
return { ...plan, pct, obfuscation, maxLossRoom: floorRoom };
}
/**
* The batch's leg, for a platform that chooses between instruments: the
* first account of the firm's largest prefix group — the group the batch
* will fire — sized on each candidate.
*/
function pickContextFor(items: SizingItem[], tradeDate: string): PickContext | undefined {
const groups = new Map<string, SizingItem[]>();
for (const it of items) groups.set(it.prefix, [...(groups.get(it.prefix) ?? []), it]);
const lead = [...groups.values()].sort((a, b) => b.length - a.length)[0]?.[0];
if (!lead) return undefined;
const legAmount = legAmountFor(lead, tradeDate);
return {
targetHoldHours: lead.firm.targetHoldHours || DEFAULT_TARGET_HOLD_HOURS,
evaluate: (contract) => {
const { sizing, cost } = sizeLeg(lead, contract, legAmount, tradeDate);
return sizing && cost !== null ? { notional: sizing.notional, holdHours: sizing.predictedHoldHours, cost } : null;
},
};
}
/**
* Trade one leg toward the account's remaining target for today — shared by
* the batch and copy-trade, so every fee, stop and sizing rule lives once.
@@ -988,24 +1062,10 @@ async function fireLeg(
symbolLabel: string,
tradeDate: string,
): Promise<LegResult> {
const { client, acc, firm } = item;
const { client, acc } = item;
const cfg = getAccountConfig(acc.id).accountType!;
// Trade one leg toward today's remaining target. Split-allowed big days carve a
// ≥$500 piece; everything else trades the full remaining amount in one go. The
// carve fraction is seeded per (prefix, date, leg number) so like accounts in a
// batch split their targets the same way, or close to it when remainders differ.
// An account type's min-legs setting forces at least that many carves
// on BIG days only — a day at or below the threshold still trades as
// one leg. Set per prefix, so like accounts still split alike.
const minLegs = item.plan.dayTarget > BIG_DAY_THRESHOLD ? (cfg.minLegs ?? 0) : 0;
const legAmount = item.plan.splitAllowed
? pickLegAmount(
item.plan.remainingToday,
() => groupLegFraction(item.prefix, tradeDate, legsToday(acc.id, tradeDate)),
minLegs - legsToday(acc.id, tradeDate),
)
: item.plan.remainingToday;
const legAmount = legAmountFor(item, tradeDate);
// Counted before anything is sent so the runaway guard sees it while
// the batch is in flight; taken back only if the entry never reached
// the broker (here, or in sendEntry).
@@ -1013,7 +1073,6 @@ async function fireLeg(
const dll = cfg.dailyLossLimit ?? 0;
const dllOn = Number.isFinite(item.dllRoom) && dll > 0;
const obfuscation = obfuscationStopDollars(cfg, item.dailyPnL, item.plan, tradeDate, legAmount);
// Everything before the entry goes out: a failure here never reached the
// broker, so the leg is taken back.
@@ -1021,48 +1080,26 @@ async function fireLeg(
let pv: number;
let pct: number;
let contracts: number;
let sizing: ReturnType<typeof sizeNotional> = null;
let plan: ReturnType<typeof sizeLeg>;
try {
const inst = await legInstrument(client, item.drain, item.contract, item.pv);
legContract = inst.contract;
pv = inst.pointValue;
pct = legContract.commissionPct ?? firm.commissionPct ?? 0;
plan = sizeLeg(item, legContract, legAmount, tradeDate);
pct = plan.pct;
const stepNotional = legContract.stepNotional ?? 0;
if (stepNotional > 0) {
const liveCash = client.accountCashBalances[acc.id] ?? item.cash;
const cap = notionalCap({
maxNotional: cfg.maxNotional ?? 0,
marginRate: legContract.marginRate,
availableFunds: liveCash.availableFunds ?? liveCash.amount,
});
const obfStop = Number.isFinite(obfuscation) && obfuscation > 0 ? obfuscation : Infinity;
const stopDollarsAt = (notional: number) => Math.min(
obfStop,
dllOn ? dllStopDollars({ room: item.dllRoom, dailyLossLimit: dll, entryFee: (pct / 100) * notional, commissionPct: pct }) : Infinity,
);
sizing = sizeNotional({
leg: legAmount,
stopDollarsAt,
volatilityPerHour: legContract.volatilityPerHour,
targetHoldHours: firm.targetHoldHours || DEFAULT_TARGET_HOLD_HOURS,
commissionPct: pct,
spreadFraction: legContract.spreadFraction,
financingPctPerDay: firm.financingPctPerDay ?? 0,
maxNotional: cap,
});
if (!sizing) {
if (!plan.sizing) {
throw new Error(`no position size for ${legContract.name} — its recent volatility is unknown`);
}
contracts = Math.max(1, Math.round(sizing.notional / stepNotional));
if (cap > 0) contracts = Math.min(contracts, Math.max(1, Math.floor(cap / stepNotional)));
contracts = Math.max(1, Math.round(plan.sizing.notional / stepNotional));
if (plan.cap > 0) contracts = Math.min(contracts, Math.max(1, Math.floor(plan.cap / stepNotional)));
if ((legContract.maxContracts ?? 0) > 0) contracts = Math.min(contracts, legContract.maxContracts!);
contracts = Math.max(contracts, legContract.minContracts ?? 1);
// The real size is known now: re-check the daily-loss room with it.
if (dllOn) {
const estimate = dllStopDollars({
room: item.dllRoom, dailyLossLimit: dll, entryFee: (pct / 100) * contracts * stepNotional, commissionPct: pct,
});
if (dllOn && !plan.lastStand) {
const estimate = plan.walkAt(contracts * stepNotional);
if (estimate < DLL_MIN_STOP) {
throw new Error(`daily loss limit: $${round2(item.dllRoom)} room leaves a $${round2(estimate)} stop at this size — not trading`);
}
@@ -1075,6 +1112,7 @@ async function fireLeg(
unrecordLeg(acc.id, tradeDate, legAmount);
throw err;
}
const { sizing, obfuscation } = plan;
const fill = await sendEntry(tag, client, acc, legContract.name, contracts, action, tradeDate, legAmount);
const exitAction: 'Buy' | 'Sell' = action === 'Buy' ? 'Sell' : 'Buy';
@@ -1095,9 +1133,14 @@ async function fireLeg(
entryFee = measured ?? FALLBACK_COMMISSION_PER_CONTRACT * contracts;
}
// The stop. On a measured-fee firm whose fee didn't show, the daily-loss
// stop reserves the worst per-contract fee, not the $2.50 fallback.
// stop reserves the worst per-contract fee, not the $2.50 fallback. A
// last stand carries no daily-loss stop: a stop-out would leave the
// account nothing to trade on, so the firm's own breach is the far side.
const dllFee = pct > 0 ? entryFee : (measured ?? MAX_COMMISSION_PER_CONTRACT * contracts);
const dllDollars = dllOn ? dllStopDollars({ room: item.dllRoom, dailyLossLimit: dll, entryFee: dllFee, commissionPct: pct }) : Infinity;
const notional = fill.price * pv * contracts;
const dllDollars = dllOn && !plan.lastStand
? dllStopDollars({ room: item.dllRoom, dailyLossLimit: dll, entryFee: dllFee, commissionPct: pct, notional })
: Infinity;
const ticks = stopTicks({ obfuscationDollars: obfuscation, dllDollars, pointValue: pv, contracts, tickSize: legContract.tickSize });
if (ticks === 0) {
await flattenAndFail(tag, client, acc, legContract.name, contracts, exitAction, fill.price,
@@ -1106,12 +1149,15 @@ async function fireLeg(
// The take-profit clears the leg, both fees and — where the firm charges
// overnight swap — what a winning leg is expected to pay in it.
const orderStop = ticks !== null ? ticks * legContract.tickSize * pv * contracts : Infinity;
const swapReserve = expectedSwapToWin({
distance: legAmount + 2 * entryFee,
stopDollars: ticks !== null ? ticks * legContract.tickSize * pv * contracts : Infinity,
notional: fill.price * pv * contracts,
stopDollars: plan.lastStand
? Math.min(orderStop, breachDistance({ dllRoom: item.dllRoom, maxLossRoom: plan.maxLossRoom, entryFee }))
: orderStop,
notional,
volatilityPerHour: legContract.volatilityPerHour,
financingPctPerDay: firm.financingPctPerDay ?? 0,
financingPctPerDay: item.firm.financingPctPerDay ?? 0,
});
const tp = takeProfitPlan({
side: action, entry: fill.price, leg: legAmount + swapReserve, pointValue: pv, contracts,
@@ -1157,10 +1203,11 @@ async function fireLeg(
? ` | notional $${Math.round(contracts * (legContract.stepNotional ?? 0))} (hold ~${sizing.predictedHoldHours.toFixed(1)}h`
+ `${sizing.winProbability !== null ? `, win ${Math.round(sizing.winProbability * 100)}%` : ''})`
: '';
const stopNote = stopPrice !== null
const stopNote = (stopPrice !== null
? ` / stop @ ${stopPrice}`
+ (dllOn ? ` (dll room $${round2(item.dllRoom)} → $${round2(dllDollars)}${Number.isFinite(obfuscation) && obfuscation > 0 ? `, obf $${round2(obfuscation)}` : ''})` : '')
: '';
+ (dllOn && !plan.lastStand ? ` (dll room $${round2(item.dllRoom)} → $${round2(dllDollars)}${Number.isFinite(obfuscation) && obfuscation > 0 ? `, obf $${round2(obfuscation)}` : ''})` : '')
: '')
+ (plan.lastStand ? ` | LAST STAND — no daily-loss stop: $${round2(plan.maxLossRoom ?? 0)} to the max-drawdown floor, $${round2(item.dllRoom)} of the day's` : '');
console.log(`${tag} ${acc.name} (${item.firmName}) ${action} ${contracts}x${instrumentLabel} @ ${fill.price} | leg $${legAmount} of day $${item.plan.dayTarget} (remaining $${item.plan.remainingToday}) [${item.plan.path}${item.drain ? ' drain' : ''}] (+$${tp.totalCommission.toFixed(2)} comm${swapReserve > 0 ? `, +$${swapReserve.toFixed(2)} expected swap` : ''})${sizeNote} | exit @ ${tp.exitPrice}${stopNote} (orderId=${exitOrder.orderId})`);
return {
@@ -1434,7 +1481,12 @@ export async function fireManualLeg(accountId: number, action: 'Buy' | 'Sell' |
const state = getState();
const symbol = state.symbol !== 'Auto' ? state.symbol : 'NQ';
const resolvedAction: 'Buy' | 'Sell' = heldSide ?? (action === 'Auto' ? (Math.random() < 0.5 ? 'Buy' : 'Sell') : action);
const contract = await client.findFrontMonthContract(symbol);
const sizingItem: SizingItem = {
firmName: firm.name, firm, client, acc,
cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan, drain: ev.drain,
prefix: cfg.prefix, dllRoom: room,
};
const contract = await client.findFrontMonthContract(symbol, ev.drain ? undefined : pickContextFor([sizingItem], tradeDate));
if (!contract) throw new Error(`no instrument for ${symbol}`);
if (held.length > 0 && String(contract.id) !== [...heldContracts][0]) {
throw new Error(`the ${laneName(lane)} lane holds ${[...heldContracts][0]}, not ${contract.id} — not adding to it`);
@@ -1445,11 +1497,7 @@ export async function fireManualLeg(accountId: number, action: 'Buy' | 'Sell' |
const lease = acquireTradeMutex('[manual-leg]', lane);
if (lease === null) throw new Error('another batch is in flight in this lane');
try {
const item: LegItem = {
firmName: firm.name, firm, client, acc, contract, pv,
cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan, drain: ev.drain,
prefix: cfg.prefix, dllRoom: room,
};
const item: LegItem = { ...sizingItem, contract, pv };
console.log(`[manual-leg] ${acc.name} (${firm.name}): firing one leg by hand`);
runtime().lastFireAt = Date.now();
return await withAccountInFlight(acc.id, () => fireLeg('[manual-leg]', item, resolvedAction, symbol, tradeDate));