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:
co-authored by
Claude Opus 5.5
parent
a2d9df5ba4
commit
a2cd3a94c9
+124
-76
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user