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. */