From a2cd3a94c9784d4abfda8b1894245c2dd6d6a4bf Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Sat, 26 Sep 2026 18:26:12 -0500 Subject: [PATCH] Legs: cheapest symbol that reaches the target hold, last stands, a reserve that grows with size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 16 ++- app/api/firms/route.ts | 1 - app/api/state/route.ts | 83 +++++++------ app/page.tsx | 8 +- lib/auto-trade.ts | 200 ++++++++++++++++++++------------ lib/dxtrade-class.ts | 73 ++++++++---- lib/firm-catalog.ts | 1 - lib/master-protocol.ts | 3 - lib/platform-client.ts | 12 +- lib/trading-logic.ts | 150 +++++++++++++++++++++--- scripts/check-trading-logic.mjs | 91 +++++++++++++-- types.ts | 11 +- 12 files changed, 477 insertions(+), 172 deletions(-) diff --git a/README.md b/README.md index b17ca82..2159ada 100644 --- a/README.md +++ b/README.md @@ -121,15 +121,21 @@ per-contract fees and the CME calendar. - **Target hold hours** — each leg's notional is sized from that volatility so its expected hold is about this long (blank = 12 h). The position never exceeds what the account's available funds can margin at the broker's - rate for the symbol (Tradeify: 5:1 crypto, 2:1 stocks), so a quiet market or - a big leg can hold longer than the target. + rate for the symbol (Tradeify: 5:1 crypto, 2:1 stocks — read from DXtrade, + nothing to set). A batch sizes its leg on every candidate and trades the + cheapest (spread, commissions, swap) that reaches the target hold; if the + leverage caps keep all of them short of it, the quickest. - **Financing %/day** if the firm charges swap (Tradeify 1-Step: `0.033`). Each take-profit then also carries the swap a winning leg is expected to pay before it fills, so a win still clears the full leg. -Per account type: **Daily loss limit** (Tradeify: 3% of size — every leg then -carries a stop that keeps the firm's day inside it, less a 10% reserve and -fees) and **Max notional**, an optional ceiling on top of the broker's margin. +Per account type, in the **Hard Daily Loss** column: the **daily loss limit** +(Tradeify: 3% of size — every leg then carries a stop that keeps the firm's +day inside it, less fees and a reserve of 10% of the limit or 0.1% of the +position, whichever is more) and the **static max drawdown** (Tradeify +1-Step: $6,000 on 100K). When a daily-loss stop-out would leave the account +within 15% of that loss above its max-drawdown floor, the leg is a *last +stand*: no daily-loss stop, running to its take-profit or the firm's breach. Before the scheduler trades a DXtrade firm, run the smoke test against a real login (with this instance's client for the firm disconnected): diff --git a/app/api/firms/route.ts b/app/api/firms/route.ts index d0a221d..674da69 100644 --- a/app/api/firms/route.ts +++ b/app/api/firms/route.ts @@ -45,7 +45,6 @@ export async function GET() { combineCmeHolidays: a.combineCmeHolidays, withdrawalStages: a.withdrawalStages, dailyLossLimit: a.dailyLossLimit ?? 0, - maxNotional: a.maxNotional ?? 0, tradesEveryDay: a.tradesEveryDay === true, })), catalogUpdatedAt: firm.updatedAt, diff --git a/app/api/state/route.ts b/app/api/state/route.ts index 6d1134d..a7e6b2d 100644 --- a/app/api/state/route.ts +++ b/app/api/state/route.ts @@ -7,7 +7,8 @@ import { historyFresh, type PlatformContract } from '@/lib/platform-client'; import type { AccountState } from '@/types'; import { combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, currentTradeDateCT, - obfuscationStopDollars, dllRoom as computeDllRoom, dllStopDollars, botStopsActive, sizeNotional, notionalCap, + obfuscationStopDollars, dllRoom as computeDllRoom, dllStopDollars, botStopsActive, planLegSize, rankForTarget, + maxLossRoom, lastStand as isLastStand, DEFAULT_TARGET_HOLD_HOURS, LEG_COMPLETION_EPSILON, } from '@/lib/trading-logic'; @@ -89,6 +90,7 @@ export async function GET() { let nextStop: number | null = null; let dllRoom: number | null = null; let holdEstimate: AccountState['holdEstimate'] = null; + let lastStandNext = false; if (cfg && effective && !isDead) { const equityProfit = cash.amount - cfg.accountSize; const plan = computeDayPlan( @@ -115,54 +117,60 @@ export async function GET() { realizedToday: Math.min(cash.realizedPnL, combined.realizedPnL), }); } + // The max-drawdown floor, and whether the next leg is a last + // stand — no daily-loss stop — as fireLeg decides it. + const floorRoom = maxLossRoom({ + balance: cash.amount, accountSize: cfg.accountSize, maxLoss: cfg.maxLoss ?? 0, + autoLiqThreshold: client.autoLiqThresholds?.[acc.id], + }); + lastStandNext = dllRoom !== null && isLastStand({ dllRoom, dailyLossLimit: dll, maxLossRoom: floorRoom }); if (plan && (botStopsActive(cfg))) { const obf = obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday); - const hard = dllRoom !== null + const hard = dllRoom !== null && !lastStandNext ? dllStopDollars({ room: dllRoom, dailyLossLimit: dll, entryFee: 0, commissionPct: 0 }) : Infinity; const stop = Math.min(Number.isFinite(obf) && obf > 0 ? obf : Infinity, hard); if (Number.isFinite(stop) && stop > 0) nextStop = Math.round(stop * 100) / 100; } - // DXtrade: size the next leg the way fireLeg would, on the - // instrument the selector last picked and today's measured - // volatility — under the same notional and margin caps — - // to show how long it is expected to hold. - const pick = (client as { lastPickedContract?: PlatformContract | null }).lastPickedContract; - if (plan && plan.remainingToday > 0 && pick?.stepNotional && pick.volatilityPerHour) { - const pct = pick.commissionPct ?? f.commissionPct ?? 0; + // DXtrade: size the next leg the way fireLeg would on each + // instrument the client last surveyed — at its leverage and + // measured volatility — and take the one the batch would: + // the cheapest that reaches the target hold. The next leg + // opens flat, so while one is open (its margin tied up) it + // sizes against the balance. + const candidates = (client as { lastCandidates?: PlatformContract[] }).lastCandidates ?? []; + if (plan && plan.remainingToday > 0 && candidates.length > 0) { const obf = obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday); - const room = dllRoom; - const sizing = sizeNotional({ - leg: plan.remainingToday, - stopDollarsAt: (n) => Math.min( - Number.isFinite(obf) && obf > 0 ? obf : Infinity, - room !== null && dll > 0 - ? dllStopDollars({ room, dailyLossLimit: dll, entryFee: (pct / 100) * n, commissionPct: pct }) - : Infinity, - ), - volatilityPerHour: pick.volatilityPerHour, - targetHoldHours: f.targetHoldHours || DEFAULT_TARGET_HOLD_HOURS, - commissionPct: pct, - spreadFraction: pick.spreadFraction, - financingPctPerDay: f.financingPctPerDay ?? 0, - // The next leg opens flat: while this one is open its - // margin is tied up, so size against the balance. - maxNotional: notionalCap({ - maxNotional: cfg.maxNotional ?? 0, - marginRate: pick.marginRate, + const target = f.targetHoldHours || DEFAULT_TARGET_HOLD_HOURS; + const sized = candidates.flatMap((c) => { + const p = planLegSize({ + leg: plan.remainingToday, + contract: c, + commissionPct: c.commissionPct ?? f.commissionPct ?? 0, + targetHoldHours: target, + financingPctPerDay: f.financingPctPerDay ?? 0, + dailyLossLimit: dll, + dllRoom: dllRoom ?? Infinity, + maxLossRoom: floorRoom, + obfuscationDollars: obf, availableFunds: client.positions[acc.id] ? cash.amount : (cash.availableFunds ?? cash.amount), - }), + }); + return p.sizing && p.cost !== null && c.volatilityPerHour + ? [{ c, sizing: p.sizing, holdHours: p.sizing.predictedHoldHours, cost: p.cost, volatility: c.volatilityPerHour }] + : []; }); - if (sizing) { + const best = sized[rankForTarget(sized, target)[0]]; + if (best) { holdEstimate = { - hours: Math.round(sizing.predictedHoldHours * 10) / 10, - symbol: String(pick.id), - notional: Math.round(sizing.notional), - volatilityPerDay: pick.volatilityPerHour * Math.sqrt(24), - winProbability: sizing.winProbability, - swapReserve: sizing.swapReserve, - leverage: pick.marginRate ? Math.round(10 / pick.marginRate) / 10 : null, + hours: Math.round(best.holdHours * 10) / 10, + symbol: String(best.c.id), + notional: Math.round(best.sizing.notional), + volatilityPerDay: best.volatility * Math.sqrt(24), + winProbability: best.sizing.winProbability, + swapReserve: best.sizing.swapReserve, + leverage: best.c.marginRate ? Math.round(10 / best.c.marginRate) / 10 : null, + cost: Math.round(best.cost), }; } } @@ -275,6 +283,7 @@ export async function GET() { nextStop, dllRoom, holdEstimate, + lastStand: lastStandNext, remainingToday, effectiveProfitTarget, dailyPnL, diff --git a/app/page.tsx b/app/page.tsx index 94df943..53fe155 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -387,8 +387,11 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: { {account.nextStop !== null && ( · stop ${fmt(account.nextStop)} )} + {account.lastStand && ( + · no stop (last stand) + )} {typeof account.dllRoom === 'number' && ( - · DLL room ${fmt(account.dllRoom)} + · DLL room ${fmt(account.dllRoom)} )} {account.holdEstimate && ( 0 ? `; its take-profit carries ~$${fmt(account.holdEstimate.swapReserve)} of expected swap` : '')} + + (account.holdEstimate.swapReserve > 0 ? `; its take-profit carries ~$${fmt(account.holdEstimate.swapReserve)} of expected swap` : '') + + `. Expected cost ~$${fmt(account.holdEstimate.cost)} (spread, commissions, swap) — the cheapest candidate that reaches the target hold`} > {' · hold ~'}{account.holdEstimate.hours < 48 ? `${Math.round(account.holdEstimate.hours)}h` : `${(account.holdEstimate.hours / 24).toFixed(1)}d`} diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 9063031..f87319e 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -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; + 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(); + 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 { - 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 = null; + let plan: ReturnType; 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)); diff --git a/lib/dxtrade-class.ts b/lib/dxtrade-class.ts index 229b962..6853c87 100644 --- a/lib/dxtrade-class.ts +++ b/lib/dxtrade-class.ts @@ -31,7 +31,7 @@ */ import axios from 'axios'; -import { currentTradeDateCT, FIRM_RESET_UTC_HOUR, DEFAULT_TARGET_HOLD_HOURS } from './trading-logic'; +import { currentTradeDateCT, FIRM_RESET_UTC_HOUR, DEFAULT_TARGET_HOLD_HOURS, rankForTarget } from './trading-logic'; import { saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions, saveEndedCycleStart, @@ -39,7 +39,7 @@ import { import { getAccountConfig } from './account-type-resolver'; import { getCachedFirm } from './firm-catalog'; import type { SymbolMapCandidate } from './master-protocol'; -import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth } from './platform-client'; +import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth, PickContext } from './platform-client'; import { OrderNotPlacedError, isOrderNotPlaced, errorMessage } from './platform-client'; import { dxAccountId, accountDisplayName, accountCodeOf, encodeContractName, decodeContractName, roundToStep, @@ -78,6 +78,8 @@ const OCO_POLL_MS = 5_000; /** A pick is reused this long, so a batch's lookups (Phase 1, drain legs) * all land on the same symbol. */ const PICK_TTL_MS = 45_000; +/** How often the dashboard's candidates (quotes, spreads) are refreshed. */ +const SURVEY_INTERVAL_MS = 30 * 60_000; const INSTRUMENT_TTL_MS = 60 * 60_000; const ACCOUNT_INSTRUMENT_TTL_MS = 10 * 60_000; const CANDLE_TTL_MS = 15 * 60_000; @@ -304,10 +306,11 @@ export class DXTradeClient implements PlatformClient { private accountInstrumentCache = new Map; at: number }>(); /** Volatility per symbol, measured once per trade date (see volatility). */ private candleCache = new Map(); - /** The last instrument the selector picked — what the dashboard's hold - * estimate sizes against. */ - public lastPickedContract: PlatformContract | null = null; - private lastPickTradeDate = ''; + /** Every mapped instrument that could trade at the last full look — what + * the dashboard ranks each account's next leg across. */ + public lastCandidates: PlatformContract[] = []; + private lastSurveyTradeDate = ''; + private lastSurveyAt = 0; private unmappedLoggedAt = new Map(); constructor(cfg: DXTradeConfig, onSync: () => Promise) { @@ -676,12 +679,14 @@ export class DXTradeClient implements PlatformClient { console.log(`${this.tag} sync — ${list.length} account(s), ${Object.keys(this.positions).length} open position(s)`); this.maybeFetchDaysTraded(); - // Measure the day's volatility (and so the hold estimate) at the - // first good sync of each trade date, whether or not a batch fires. - if (this.historyLoaded && this.lastPickTradeDate !== currentTradeDateCT()) { - this.lastPickTradeDate = currentTradeDateCT(); - void this.findFrontMonthContract('*').catch((err) => - console.warn(`${this.tag} daily volatility check failed: ${errorMessage(err)}`)); + // Measure the day's volatility at the first good sync of each trade + // date, and refresh the candidates the dashboard sizes against + // every half hour, whether or not a batch fires. + if (this.historyLoaded && (this.lastSurveyTradeDate !== currentTradeDateCT() || Date.now() - this.lastSurveyAt >= SURVEY_INTERVAL_MS)) { + this.lastSurveyTradeDate = currentTradeDateCT(); + this.lastSurveyAt = Date.now(); + void this.surveyCandidates().catch((err) => + console.warn(`${this.tag} candidate survey failed: ${errorMessage(err)}`)); } this.syncComplete = true; @@ -1157,12 +1162,15 @@ export class DXTradeClient implements PlatformClient { } /** - * The contract a CME root trades as on this firm right now: the cheapest - * tradable candidate from the symbol map, per unit of volatility (see - * scoreCandidate). Sticky while an account - * holds one of the candidates, cached briefly so a batch's lookups agree. + * The contract a CME root trades as on this firm right now, from the + * symbol map. With a pick context (the batch's leg), each candidate is + * sized as the leg would be — at its own leverage — and the cheapest one + * that reaches the target hold wins (else the quickest); without one, the + * cheapest per unit of volatility (see scoreCandidate). Sticky while an + * account holds one of the candidates; a batch's pick is reused briefly + * so its other lookups (drain legs) agree. */ - public async findFrontMonthContract(root: string): Promise { + public async findFrontMonthContract(root: string, ctx?: PickContext): Promise { if (!this.token) return null; const firm = getCachedFirm(this.cfg.firmId); const map = firm?.symbolMap ?? {}; @@ -1177,16 +1185,26 @@ export class DXTradeClient implements PlatformClient { } const held = new Set([...this.positionsDetail.values()].flat().map((p) => p.symbol)); const sticky = candidates.find((c) => held.has(c.symbol)); - if (!sticky) { + if (!sticky && !ctx) { const cached = this.pickCache.get(root); if (cached && now - cached.at < PICK_TTL_MS) return cached.contract; } - const pick = await this.pickCandidate(root, sticky ? [sticky] : candidates, sticky !== undefined); + const pick = await this.pickCandidate(root, sticky ? [sticky] : candidates, sticky !== undefined, ctx); if (pick && !sticky) this.pickCache.set(root, { contract: pick, at: now }); return pick; } - private async pickCandidate(root: string, candidates: SymbolMapCandidate[], sticky: boolean): Promise { + /** Every mapped symbol, looked at afresh whatever the accounts hold: the + * day's volatility, and the candidates the dashboard ranks. */ + private async surveyCandidates(): Promise { + const all = new Map(); + for (const list of Object.values(getCachedFirm(this.cfg.firmId)?.symbolMap ?? {})) { + for (const c of list) if (!all.has(c.symbol)) all.set(c.symbol, c); + } + if (all.size > 0) await this.pickCandidate('survey', [...all.values()], false); + } + + private async pickCandidate(root: string, candidates: SymbolMapCandidate[], sticky: boolean, ctx?: PickContext): Promise { const ref = this.referenceAccount(); if (!ref) return null; const firm = getCachedFirm(this.cfg.firmId); @@ -1270,14 +1288,25 @@ export class DXTradeClient implements PlatformClient { }); } - const ranked = board.filter((b) => b.contract).sort((a, b) => a.score! - b.score!); + let ranked = board.filter((b) => b.contract).sort((a, b) => a.score! - b.score!); + if (ctx) { + // Size the batch's leg on each: cheapest that reaches the target + // hold, else quickest. + const sized = ranked.flatMap((b) => { + const ev = ctx.evaluate(b.contract!); + if (!ev) { b.note += ', can\'t size'; return []; } + b.note += `, $${Math.round(ev.notional / 1000)}k → ${ev.holdHours.toFixed(1)}h, ~$${Math.round(ev.cost)}`; + return [{ b, holdHours: ev.holdHours, cost: ev.cost }]; + }); + ranked = rankForTarget(sized, ctx.targetHoldHours).map((i) => sized[i].b); + } const winner = ranked[0]; const summary = board .map((b) => `${b.symbol}${b.score !== null ? ` ${b.score.toFixed(4)}` : ''} (${b.note})`) .join('; '); this.lastFetchRaw[`pick:${root}`] = `${new Date().toISOString()} → ${winner?.symbol ?? 'none'} — ${summary}`; console.log(`${this.tag} ${root}${sticky ? ' (held)' : ''} → ${winner?.symbol ?? 'nothing tradable'} — ${summary}`); - if (winner?.contract) this.lastPickedContract = winner.contract; + if (!sticky) this.lastCandidates = board.flatMap((b) => (b.contract ? [b.contract] : [])); return winner?.contract ?? null; } diff --git a/lib/firm-catalog.ts b/lib/firm-catalog.ts index 9b06a84..7522b1f 100644 --- a/lib/firm-catalog.ts +++ b/lib/firm-catalog.ts @@ -233,7 +233,6 @@ function validatePayload(raw: unknown): FirmCatalogPayload { minDayStop: type.minDayStop === true, // Lenient: no hard daily limit, no notional cap. dailyLossLimit: nonNegative(type.dailyLossLimit), - maxNotional: nonNegative(type.maxNotional), // The firm's calendar, stamped on every type so the cfg-based // paths (projections, completion, the dashboard) all see it. tradesEveryDay, diff --git a/lib/master-protocol.ts b/lib/master-protocol.ts index 7c074c6..fc5e0a3 100644 --- a/lib/master-protocol.ts +++ b/lib/master-protocol.ts @@ -209,9 +209,6 @@ export interface CatalogAccountType { * stop that keeps the firm's day from losing more than this, less a 10% * reserve and fees. Optional: an older master doesn't send it. */ dailyLossLimit?: number; - /** Cap on one position's notional in dollars (0 = none), where the - * platform prices positions (DXTrade). */ - maxNotional?: number; /** Copied from the firm by the catalog sync, so every cfg-based path sees * it: the firm trades every calendar day (24/7). */ tradesEveryDay?: boolean; diff --git a/lib/platform-client.ts b/lib/platform-client.ts index e42df3e..64f496f 100644 --- a/lib/platform-client.ts +++ b/lib/platform-client.ts @@ -18,6 +18,13 @@ export interface PlatformAccount { active: boolean; } +/** How the batch's leg would size on a candidate instrument (see planLegSize): + * null when it can't be sized there. */ +export interface PickContext { + targetHoldHours: number; + evaluate(contract: PlatformContract): { notional: number; holdHours: number; cost: number } | null; +} + export interface PlatformContract { id: number | string; name: string; @@ -105,7 +112,10 @@ export interface PlatformClient { /** Drop the account's history from memory (the wipe routes), so it reads * unloaded until the next fetch rebuilds it. */ forgetHistory(accountId: number, accountName: string): void; - findFrontMonthContract(productName: string): Promise; + /** The contract a product trades as. A platform that chooses between + * instruments (DXTrade's symbol map) ranks them with `pick` when given — + * how a leg would size on each — and platforms that don't, ignore it. */ + findFrontMonthContract(productName: string, pick?: PickContext): Promise; fetchInstrumentFees(symbols: string[]): Promise<{ [symbol: string]: number }>; sendOrder( diff --git a/lib/trading-logic.ts b/lib/trading-logic.ts index 88dfcf2..7a301c3 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -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; + 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 diff --git a/scripts/check-trading-logic.mjs b/scripts/check-trading-logic.mjs index 46e9ad6..fcb69bd 100644 --- a/scripts/check-trading-logic.mjs +++ b/scripts/check-trading-logic.mjs @@ -12,6 +12,7 @@ import { percentFee, takeProfitPlan, obfuscationStopDollars, dllRoom, dllStopDollars, dllViable, stopTicks, botStopsActive, sizeNotional, isNoTradeTime, isPastSessionClose, nextTradingDate, effectiveCombine, DEFAULT_TARGET_HOLD_HOURS, projectPerfectPath, cycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, notionalCap, expectedSwapToWin, MARGIN_HEADROOM, + dllReserve, DLL_SLIPPAGE_FRACTION, maxLossRoom, lastStand, breachDistance, LAST_STAND_FRACTION, planLegSize, rankForTarget, } from '../lib/trading-logic.ts'; // Deterministic PRNG so a failure reproduces. @@ -347,13 +348,89 @@ test('expected swap to win: a fixed point over the winners\' hold, zero without assert.equal(expectedSwapToWin({ ...o, volatilityPerHour: sigma / 50 }), 3100); }); -test('the notional cap: the account type, the margin the funds allow, the tighter', () => { - assert.equal(notionalCap({ maxNotional: 200000 }), 200000); - assert.equal(notionalCap({ maxNotional: 0 }), 0); - assert.equal(notionalCap({ maxNotional: 0, marginRate: 0.2, availableFunds: 100000 }), MARGIN_HEADROOM * 500000); - assert.equal(notionalCap({ maxNotional: 0, marginRate: 0.5, availableFunds: 99757.39 }), MARGIN_HEADROOM * 99757.39 * 2); - assert.equal(notionalCap({ maxNotional: 200000, marginRate: 0.2, availableFunds: 100000 }), 200000); - assert.equal(notionalCap({ maxNotional: 0, marginRate: 0.2, availableFunds: undefined }), 0); +test('the notional cap: what the available funds can margin at the symbol\'s rate', () => { + assert.equal(notionalCap({ marginRate: 0.2, availableFunds: 100000 }), MARGIN_HEADROOM * 500000); + assert.equal(notionalCap({ marginRate: 0.5, availableFunds: 99757.39 }), MARGIN_HEADROOM * 99757.39 * 2); + assert.equal(notionalCap({ marginRate: 0.2, availableFunds: undefined }), 0); + assert.equal(notionalCap({ availableFunds: 100000 }), 0); +}); + +test('the daily-loss reserve grows with the position past 10% of the limit', () => { + assert.equal(dllReserve(3000), 300); + assert.equal(dllReserve(3000, 200000), 300); + assert.equal(dllReserve(3000, 485000), 485000 * DLL_SLIPPAGE_FRACTION); + const at = (notional) => dllStopDollars({ room: 3000, dailyLossLimit: 3000, entryFee: 0.0004 * notional, commissionPct: 0.04, notional }); + // Below $300k the flat 10% binds; above, 0.1% of notional does. + assert.ok(Math.abs(at(200000) - (3000 - 300 - 160 - 0.01) / 1.0004) < 1e-9); + assert.ok(Math.abs(at(485000) - (3000 - 485 - 388 - 0.01) / 1.0004) < 1e-9); + // No notional given: the old flat reserve. + assert.equal(dllStopDollars({ room: 3000, dailyLossLimit: 3000, entryFee: 0, commissionPct: 0 }), 2699.99); +}); + +test('max-drawdown room: the static floor, the broker floor, the higher', () => { + assert.equal(maxLossRoom({ balance: 99757.39, accountSize: 100000, maxLoss: 6000 }), 5757.39); + assert.equal(maxLossRoom({ balance: 99757.39, accountSize: 100000, maxLoss: -6000 }), 5757.39); + assert.equal(maxLossRoom({ balance: 51000, accountSize: 50000, maxLoss: 0, autoLiqThreshold: 48500 }), 2500); + assert.equal(maxLossRoom({ balance: 51000, accountSize: 50000, maxLoss: 2000, autoLiqThreshold: 49000 }), 2000); + assert.equal(maxLossRoom({ balance: 99000, accountSize: 100000, maxLoss: 0 }), null); +}); + +test('last stand: no daily-loss stop once a stop-out would leave under 15% of it', () => { + // Full day: a stop-out spends $2,700 (room less the $300 reserve). + const at = (maxRoom) => lastStand({ dllRoom: 3000, dailyLossLimit: 3000, maxLossRoom: maxRoom }); + assert.equal(LAST_STAND_FRACTION, 0.15); + assert.equal(at(5757.39), false, 'a fresh 1-Step keeps its stop'); + assert.equal(at(2700 * 1.15 + 1), false, 'just over 15% left: keep the stop'); + assert.equal(at(2700 * 1.15), true, '15% left: last stand'); + assert.equal(at(3000), true, 'two stopped days in: last stand'); + assert.equal(at(800), true, 'floor nearer than the stop: last stand'); + assert.equal(at(null), false, 'no floor known'); + assert.equal(lastStand({ dllRoom: Infinity, dailyLossLimit: 0, maxLossRoom: 500 }), false, 'no daily limit, no daily stop to drop'); + // The far side of the walk is the nearer floor, net of the entry fee. + assert.equal(breachDistance({ dllRoom: 3000, maxLossRoom: 2900, entryFee: 120 }), 2780); + assert.equal(breachDistance({ dllRoom: 2757.39, maxLossRoom: null, entryFee: 0 }), 2757.39); +}); + +test('planLegSize: the stop, a last stand, the margin cap and the cost, as fireLeg trades', () => { + 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, obfuscationDollars: Infinity, availableFunds: 99757.39, + }; + const fresh = planLegSize({ ...base, maxLossRoom: 5757.39 }); + assert.equal(fresh.lastStand, false); + assert.ok(Math.abs(fresh.sizing.predictedHoldHours - 12) < 0.1, `hold ${fresh.sizing.predictedHoldHours}`); + const n = fresh.sizing.notional; + assert.ok(Math.abs(fresh.walkAt(n) - dllStopDollars({ room: 2757.39, dailyLossLimit: 3000, entryFee: 0.0004 * n, commissionPct: 0.04, notional: n })) < 1e-9); + const expectCost = n * (0.00004 + 0.0008) + 0.00033 * n * (fresh.sizing.predictedHoldHours / 24); + assert.ok(Math.abs(fresh.cost - expectCost) < 1e-6); + + // Two days stopped out: a stop-out would leave ~$300 above the floor. + const stand = planLegSize({ ...base, dllRoom: 3000, maxLossRoom: 3000 }); + assert.equal(stand.lastStand, true); + assert.equal(stand.walkAt(300000), 3000 - 120); + assert.ok(stand.sizing.winProbability > fresh.sizing.winProbability, 'the whole room, no reserve: a better chance'); + // An obfuscation stop still counts on a last stand. + assert.equal(planLegSize({ ...base, dllRoom: 3000, maxLossRoom: 3000, obfuscationDollars: 1000 }).walkAt(300000), 1000); + + // A 2:1 stock can't reach 12 h in a quiet market: the cap binds. + const stock = planLegSize({ ...base, maxLossRoom: 5757.39, contract: { ...eth, marginRate: 0.5, volatilityPerHour: 0.0076 / Math.sqrt(24) } }); + assert.ok(Math.abs(stock.sizing.notional - stock.cap) < 1e-6); + assert.ok(stock.sizing.predictedHoldHours > 12); + // Futures (no stepNotional) aren't sized by notional. + assert.equal(planLegSize({ ...base, maxLossRoom: null, contract: {} }).sizing, null); +}); + +test('rankForTarget: reach the target hold first, cheapest; else quickest', () => { + const order = rankForTarget([ + { holdHours: 12, cost: 330 }, // ETH + { holdHours: 12.1, cost: 300 }, // BTC — within the 2% tolerance + { holdHours: 40, cost: 250 }, // XYZ100, capped at 2:1 + { holdHours: 20, cost: 400 }, // SP500, capped + ], 12); + assert.deepEqual(order, [1, 0, 3, 2]); + assert.deepEqual(rankForTarget([{ holdHours: 30, cost: 1 }, { holdHours: 25, cost: 9 }], 12), [1, 0]); + assert.deepEqual(rankForTarget([], 12), []); }); test('sizing carries the swap reserve into the take-profit distance', () => { diff --git a/types.ts b/types.ts index 97bdae9..1572fba 100644 --- a/types.ts +++ b/types.ts @@ -10,7 +10,7 @@ export interface AccountConfig { minDayPnL: number; minTradingDays: number; accountSize: number; - maxLoss: number; // 0 = no limit; positive = account is "Dead" when loss exceeds this + maxLoss: number; // static max drawdown below the starting size (0 = none); with a hard daily loss, decides last stands maxPositionSize: number; // 0 = no limit; positive = max contracts per trade targetSameEquity: boolean; // if true, reduce profitTarget by priorProfit after withdrawal splitEnabled: boolean; // false = big days trade as one unsplit leg instead of random ≥$500 pieces @@ -30,10 +30,8 @@ export interface AccountConfig { profitSplit?: number; trailingDrawdown?: number; /** Hard daily loss limit in dollars (0/absent = none): every leg carries a - * stop that keeps the firm's day inside it, less a 10% reserve and fees. */ + * stop that keeps the firm's day inside it, less a reserve and fees. */ dailyLossLimit?: number; - /** Cap on one position's notional in dollars (0/absent = none). */ - maxNotional?: number; /** The firm trades every calendar day, weekends and CME holidays included. */ tradesEveryDay?: boolean; } @@ -107,7 +105,12 @@ export interface AccountState { swapReserve: number; /** The broker's leverage on the symbol (5 = 5:1); null when unstated. */ leverage: number | null; + /** Expected spread, commissions and swap for the leg. */ + cost: number; } | null; + /** The next leg rides without a daily-loss stop: a stop-out would leave + * the account too close to its max-drawdown floor to trade on. */ + lastStand: boolean; /** Actual profit target to hit — max(stage profit target, consistency realTarget = maxDay/consistency). */ effectiveProfitTarget: number | null; /** Current cycle daily P&L — used for target calculations. */