DXtrade platform for Tradeify247: percent fees, hard daily-loss stops, volatility sizing, every-day lanes

- DXtrade client (lib/dxtrade-class.ts): REST session login with lockout-safe
  backoff, 60s sync of accounts/metrics/positions/orders/transfers, history and
  fund transactions from cash transactions, day-start balance at the 22:00 UTC
  reset, CLOSE-by-positionCode exits (native OCO group, else a stop-first
  emulated pair), a cross-account hedge guard, stray-order reaper.
- Instrument selector: the firm's symbol map lists candidate symbols per CME
  root; the cheapest tradable one per unit of recent hourly volatility wins
  (spread + 2x commission [+ financing] / sigma).
- Fees: a firm's commission % of notional prices every fee off the fill; the
  take-profit is solved in closed form so the leg nets its target.
- Hard daily loss limit per account type: every leg carries a stop keeping the
  firm's day inside the limit less a 10% reserve and both fees (rounded in);
  a green day never widens it; no room or a refused bracket flattens.
- Notional sizing where the platform prices positions: target-hold volatility
  sizing or the symbol map's fixed notional, under a per-type notional cap.
- Every-day firms trade weekends/holidays; the scheduler runs lanes (futures,
  one per DXtrade firm) with their own hours, flat gate, mutex and batches.
- The catalog sync skips platforms this build doesn't know instead of treating
  them as Tradovate. One shared fireLeg replaces the duplicated batch/copy code.
- npm run check: fee/stop/sizing/hours/selector checks; scripts/dxtrade-smoke.mjs
  confirms the API against a real login before trading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-26 16:04:15 -05:00
co-authored by Claude Opus 5.5
parent 49c4725e0c
commit 28f465508d
18 changed files with 3962 additions and 534 deletions
+389 -21
View File
@@ -366,13 +366,22 @@ export interface ProjectedDay {
net?: number;
}
/** Next weekday after d (YYYY-MM-DD). Trade dates never fall on Sat/Sun. */
function nextTradingDate(d: string): string {
/** Next trade date after d (YYYY-MM-DD): the next weekday on the CME calendar,
* where trade dates never fall on Sat/Sun — or simply the next calendar day
* for a firm that trades every day (24/7 crypto/CFD, e.g. Tradeify247). */
export function nextTradingDate(d: string, everyDay = false): string {
const dt = new Date(`${d}T00:00:00Z`);
do { dt.setUTCDate(dt.getUTCDate() + 1); } while (dt.getUTCDay() === 0 || dt.getUTCDay() === 6);
do { dt.setUTCDate(dt.getUTCDate() + 1); } while (!everyDay && (dt.getUTCDay() === 0 || dt.getUTCDay() === 6));
return dt.toISOString().slice(0, 10);
}
/** Whether CME holiday sessions fold into the next day for this account type.
* Never for an every-day firm: it has no CME calendar to fold by. The catalog
* sync already clears the flag for those firms; this is the backstop. */
export function effectiveCombine(cfg: { combineCmeHolidays?: boolean; tradesEveryDay?: boolean }): boolean {
return cfg.combineCmeHolidays === true && cfg.tradesEveryDay !== true;
}
/**
* Simulate the account's future assuming every day goes perfectly: each
* trading day books exactly its planned target (deterministic bumps included,
@@ -399,6 +408,9 @@ export function projectPerfectPath(opts: {
* (withdraw, reset, re-target). False = it just PASSES once (an
* eval) and the projection stops. */
payoutable?: boolean;
/** The firm trades every calendar day (24/7): weekends and CME
* holidays are ordinary projected days, and nothing combines. */
tradesEveryDay?: boolean;
};
/** Current-cycle daily P&L (account.dailyPnL). */
cycleDailyPnL: { date: string; pnl: number }[];
@@ -424,6 +436,8 @@ export function projectPerfectPath(opts: {
* so today's room is exact and only tightens going forward. */
startThreshold?: number;
};
/** Defaults to the current time. */
now?: Date;
}): ProjectedDay[] {
const { accountId, cfg } = opts;
const horizon = opts.horizonDays ?? 40;
@@ -432,30 +446,33 @@ export function projectPerfectPath(opts: {
const baseCap = opts.payout?.payoutCap ?? 0;
const profitSplit = opts.payout?.profitSplit ?? 0.9;
const dd = Math.max(0, opts.payout?.drawdown ?? 0);
const everyDay = cfg.tradesEveryDay === true;
const rawToday = currentTradeDateCT();
const rawToday = currentTradeDateCT(opts.now);
// The firm's own day view: a combining firm folds holiday-session P&L
// into its clearing date — a Labor-Day trade and the Tuesday after it are
// ONE trading day, exactly as the firm counts min days. Identity view
// when combining is off.
const combine = cfg.combineCmeHolidays === true;
const combine = effectiveCombine(cfg);
const view = combineCmeSessions(combine, opts.cycleDailyPnL, rawToday, opts.realizedPnL);
// A weekend "today" (Saturday, or Sunday before the evening open) has no
// session — the platform's realizedPnL is the LAST session's stale number,
// already booked in cycleDailyPnL under its own date. Adding it as a
// today row would fabricate an extra qualifying day.
// today row would fabricate an extra qualifying day. An every-day firm
// trades weekends, so its weekend today is a live session like any other.
const todayDow = new Date(`${rawToday}T00:00:00Z`).getUTCDay();
const todayHasSession = todayDow !== 0 && todayDow !== 6;
const todayHasSession = everyDay || (todayDow !== 0 && todayDow !== 6);
// Today's clearing date when a session exists; on weekends keep the raw
// date so the projection's first day is Monday, not the day after it
// (combinedTradeDate rolls weekend dates forward).
const today = todayHasSession ? view.tradeDate : rawToday;
let sim = view.dailyPnL.filter((d) => d.date !== today);
if (todayHasSession && view.realizedPnL !== 0) sim = [...sim, { date: today, pnl: view.realizedPnL }];
/** Next date the firm settles a session on: weekends always skip, and a
* combining firm's holiday sessions clear into the next full day. */
/** Next date the firm settles a session on: weekends skip (except for an
* every-day firm), and a combining firm's holiday sessions clear into
* the next full day. */
const nextDay = (d: string): string => {
let n = nextTradingDate(d);
let n = nextTradingDate(d, everyDay);
while (combine && combinedTradeDate(n) !== n) n = nextTradingDate(n);
return n;
};
@@ -662,12 +679,18 @@ function cycleOwesNothing(
* weekend (it is the last session's stale number, already booked under its
* own date), a Monday with a weekend-dated row — the live session filed under
* a weekend date, which would count it twice — and the first minutes after
* the 5 PM roll. */
function sessionDoubtful(rawTradeDate: string, cycleDailyPnL: { date: string; pnl: number }[], now: Date): boolean {
* the 5 PM roll. An every-day firm's weekends are real sessions, so only the
* roll window applies to it. */
function sessionDoubtful(
rawTradeDate: string,
cycleDailyPnL: { date: string; pnl: number }[],
now: Date,
everyDay = false,
): boolean {
const day = new Date(`${rawTradeDate}T00:00:00Z`);
const dow = day.getUTCDay();
if (dow === 0 || dow === 6) return true;
if (dow === 1) {
if (!everyDay && (dow === 0 || dow === 6)) return true;
if (!everyDay && dow === 1) {
const sunday = fmtUTC(new Date(day.getTime() - 24 * 60 * 60 * 1000));
const saturday = fmtUTC(new Date(day.getTime() - 2 * 24 * 60 * 60 * 1000));
if (cycleDailyPnL.some((d) => d.date === saturday || d.date === sunday)) return true;
@@ -698,6 +721,8 @@ export function cycleCompletion(opts: {
cycleDailyPnL: { date: string; pnl: number }[];
rawRealizedPnL: number;
combineCmeHolidays: boolean;
/** The firm trades every calendar day — weekends are real sessions. */
tradesEveryDay?: boolean;
effective: EffectiveConfig;
/** amount − accountSize — today's P&L included. */
equityProfit: number;
@@ -706,14 +731,18 @@ export function cycleCompletion(opts: {
now?: Date;
}): CycleCompletion {
const { accountId, rawTradeDate, effective: eff, equityProfit, splitEnabled } = opts;
const view = combineCmeSessions(opts.combineCmeHolidays, opts.cycleDailyPnL, rawTradeDate, opts.rawRealizedPnL);
const everyDay = opts.tradesEveryDay === true;
const view = combineCmeSessions(
effectiveCombine({ combineCmeHolidays: opts.combineCmeHolidays, tradesEveryDay: everyDay }),
opts.cycleDailyPnL, rawTradeDate, opts.rawRealizedPnL,
);
const prior = view.dailyPnL.filter((d) => d.date !== view.tradeDate);
if (cycleOwesNothing(accountId, eff, prior, view.tradeDate, equityProfit, view.realizedPnL, splitEnabled)) return 'before';
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date())) return 'none';
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay)) return 'none';
const withToday = [...prior, { date: view.tradeDate, pnl: view.realizedPnL }];
const probe = nextTradingDate(view.tradeDate);
const probe = nextTradingDate(view.tradeDate, everyDay);
if (view.realizedPnL !== 0 && cycleOwesNothing(accountId, eff, withToday, probe, equityProfit, 0, splitEnabled)) return 'today';
return 'none';
}
@@ -764,15 +793,18 @@ export function endedCycleCompletion(opts: {
profitTarget: number; consistency: number; minTradingDays: number; minDayPnL: number;
targetSameEquity: boolean; withdrawalStages: WithdrawalStage[];
combineCmeHolidays: boolean; splitEnabled: boolean;
tradesEveryDay?: boolean;
};
/** Defaults to the current time. */
now?: Date;
}): { date: string; stage: number; drain: boolean; cycle: string } | null {
const { accountId, rawTradeDate, lastFundDate, endedCycleStart: start, fundTransactions, cfg } = opts;
const everyDay = cfg.tradesEveryDay === true;
const combine = effectiveCombine(cfg);
if (lastFundDate === null || start === null) return null;
const ageDays = (Date.parse(`${rawTradeDate}T00:00:00Z`) - Date.parse(`${lastFundDate}T00:00:00Z`)) / (24 * 60 * 60 * 1000);
if (!(ageDays <= ENDED_CYCLE_WINDOW_DAYS)) return null; // NaN included: an unparseable date
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date())) return null;
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay)) return null;
const latest = (a: string, b: string) => (b > a ? b : a);
const earlierFunds = fundTransactions.filter((f) => f.date < lastFundDate);
@@ -789,7 +821,7 @@ export function endedCycleCompletion(opts: {
// Equity the ended cycle finished on, worked back from live equity: less
// everything the current cycle booked (today's live P&L included) and
// less the fund transaction between the two.
const cur = combineCmeSessions(cfg.combineCmeHolidays, opts.cycleDailyPnL, rawTradeDate, opts.rawRealizedPnL);
const cur = combineCmeSessions(combine, opts.cycleDailyPnL, rawTradeDate, opts.rawRealizedPnL);
const curTotal = cur.dailyPnL.filter((d) => d.date !== cur.tradeDate).reduce((s, d) => s + d.pnl, 0)
+ cur.realizedPnL;
const fundAmt = fundTransactions.filter((f) => f.date === lastFundDate).reduce((s, f) => s + f.amount, 0);
@@ -800,11 +832,11 @@ export function endedCycleCompletion(opts: {
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity,
cfg.withdrawalStages, 0, earlierFunds, cfg.minDayPnL,
);
const rows = combineCmeSessions(cfg.combineCmeHolidays, endedRaw, lastFundDate, 0).dailyPnL;
const rows = combineCmeSessions(combine, endedRaw, lastFundDate, 0).dailyPnL;
// A combining firm's folded day can net to 0 — nothing traded, as the firm counts it.
const traded = rows.filter((d) => d.pnl !== 0);
if (traded.length === 0) return null;
const probe = nextTradingDate(rows.map((d) => d.date).reduce(latest));
const probe = nextTradingDate(rows.map((d) => d.date).reduce(latest), everyDay);
if (!cycleOwesNothing(accountId, eff, rows, probe, equityBefore, 0, cfg.splitEnabled)) return null;
return {
@@ -1040,3 +1072,339 @@ export function computeDayPlan(
splitAllowed,
};
}
// ── fees ─────────────────────────────────────────────────────────────────────
/** An entry's fee when the balance update hasn't shown it yet — at least one
* extra tick on the exit. */
export const FALLBACK_COMMISSION_PER_CONTRACT = 2.5;
/** No real per-contract entry fee comes near this. Also what a daily-loss
* stop reserves per contract and side when the fee couldn't be measured —
* the $2.50 fallback is fine for a take-profit, but it would under-reserve
* a hard stop on NQ-class fees. */
export const MAX_COMMISSION_PER_CONTRACT = 10;
/** One side's commission on a percent-of-notional firm (e.g. Tradeify247's
* 0.04%), rounded UP to the cent so every consumer errs toward paying more.
* pointValue × contracts is the position's dollars per 1.0 of price, so
* price × that is its notional. */
export function percentFee(pct: number, price: number, pointValue: number, contracts: number): number {
if (!(pct > 0)) return 0;
const raw = (pct / 100) * Math.abs(price) * pointValue * contracts;
return Math.ceil(raw * 100 - 1e-9) / 100;
}
/**
* Where to put the take-profit so the leg nets at least `leg` after fees.
*
* Measured-fee firms (commissionPct 0): the exit fee is taken to equal the
* entry fee — the same formula the fire path always used, reproduced exactly.
*
* Percent-fee firms: the exit fee depends on the exit price itself, so solve
* for it. With r = pct/100 and k = pointValue × contracts, a long nets
* (X − entry)·k − entryFee − r·X·k at exit price X, which reaches `leg` at
* X = ((leg + entryFee)/k + entry) / (1 − r); a short mirrors it at
* X = (entry − (leg + entryFee)/k) / (1 + r). Rounded out to whole ticks and
* re-checked with the cent-rounded exit fee.
*/
export function takeProfitPlan(o: {
side: 'Buy' | 'Sell';
entry: number;
leg: number;
pointValue: number;
contracts: number;
tickSize: number;
commissionPct: number;
/** The entry fee: percentFee() on percent firms, the measured one otherwise. */
entryCommission: number;
}): { exitPrice: number; ticks: number; entryFee: number; exitFee: number; totalCommission: number; grossTarget: number } {
const k = o.pointValue * o.contracts;
const dir = o.side === 'Buy' ? 1 : -1;
if (!(o.commissionPct > 0)) {
const totalCommission = o.entryCommission * 2; // entry + exit round-trip
const grossTarget = o.leg + totalCommission;
const ticks = Math.ceil(grossTarget / k / o.tickSize);
return {
exitPrice: o.entry + dir * ticks * o.tickSize,
ticks,
entryFee: o.entryCommission,
exitFee: o.entryCommission,
totalCommission,
grossTarget,
};
}
const r = o.commissionPct / 100;
const need = (o.leg + o.entryCommission) / k;
const target = o.side === 'Buy' ? (need + o.entry) / (1 - r) : (o.entry - need) / (1 + r);
let ticks = Math.max(1, Math.ceil(Math.abs(target - o.entry) / o.tickSize - 1e-9));
const priceAt = (t: number) => Math.round((o.entry + dir * t * o.tickSize) * 1e10) / 1e10;
const netAt = (t: number) => t * o.tickSize * k - o.entryCommission - percentFee(o.commissionPct, priceAt(t), o.pointValue, o.contracts);
// Cent rounding on the fees can leave the closed form short — by several
// ticks when a tick is worth a fraction of a cent. Step out by the shortfall.
for (let i = 0; i < 10; i++) {
const short = o.leg - netAt(ticks);
if (short <= 1e-9) break;
ticks += Math.max(1, Math.ceil(short / (o.tickSize * k * (1 - r)) - 1e-9));
}
const exitPrice = priceAt(ticks);
const exitFee = percentFee(o.commissionPct, exitPrice, o.pointValue, o.contracts);
const totalCommission = Math.round((o.entryCommission + exitFee) * 100) / 100;
return { exitPrice, ticks, entryFee: o.entryCommission, exitFee, totalCommission, grossTarget: round2(o.leg + totalCommission) };
}
// ── stops ────────────────────────────────────────────────────────────────────
/** Obfuscation stop for the current leg, in dollars. Infinity = no stop.
* Day 1 of a cycle carries the $1,000 account stop (firstDayStop), min-day
* (small) trades a 2×-target stop (minDayStop) — and the day after a losing
* day always rides stopless, the make-back day. A hard daily loss limit is
* separate (dllStopDollars) and never rides stopless. */
export function obfuscationStopDollars(
cfg: { firstDayStop: boolean; minDayStop: boolean },
dailyPnL: { date: string; pnl: number }[],
plan: DayPlan,
tradeDate: string,
legAmount: number,
): number {
if (!cfg.firstDayStop && !cfg.minDayStop) return Infinity;
const prior = dailyPnL
.filter((d) => d.date < tradeDate && d.pnl !== 0)
.sort((a, b) => a.date.localeCompare(b.date));
if (prior.length > 0 && prior[prior.length - 1].pnl < 0) return Infinity;
let stop = Infinity;
const realizedToday = Math.round((plan.dayTarget - plan.remainingToday) * 100) / 100;
if (cfg.firstDayStop && prior.length === 0) stop = Math.max(0, FIRST_DAY_STOP + realizedToday);
if (cfg.minDayStop && plan.dayTarget <= BIG_DAY_THRESHOLD) stop = Math.min(stop, MIN_DAY_STOP_MULT * legAmount);
return stop;
}
/** 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;
/** 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. */
export const DLL_MIN_STOP = 50;
/**
* How much the firm's day may still lose before a hard daily-loss breach.
* Infinity when the account type has no limit.
*
* The day's P&L is the closed balance against the platform's snapshot at the
* firm's daily reset when the client knows it (DXTrade: 22:00 UTC), else the
* broker's realized P&L for the session. Only a red day counts: a green day
* does NOT widen the room past the limit, because a position still open at
* the next reset has its whole move counted against the NEW day — room
* borrowed from today's profit would be a breach tomorrow.
*/
export function dllRoom(o: {
dailyLossLimit: number;
closedBalance: number;
/** Platform snapshot at the firm's daily reset; undefined/null = unknown. */
dayStartBalance?: number | null;
/** Fallback when there's no snapshot: the day's realized P&L (pass the
* worse of the raw and combined figures). */
realizedToday: number;
}): number {
if (!(o.dailyLossLimit > 0)) return Infinity;
const dayPnL = typeof o.dayStartBalance === 'number' ? o.closedBalance - o.dayStartBalance : o.realizedToday;
return round2(o.dailyLossLimit + Math.min(0, dayPnL));
}
/**
* The widest stop, in dollars of price movement, that keeps a loss inside
* room − reserve with both fees paid. The exit fee at the stop is at most
* 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.
*/
export function dllStopDollars(o: { room: number; dailyLossLimit: number; entryFee: number; commissionPct: 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);
}
/** Is there enough daily-loss room left to trade at all? */
export function dllViable(room: number, dailyLossLimit: number, roundTripFeeEstimate: number): boolean {
if (!Number.isFinite(room)) return true;
return room - DLL_RESERVE_FRACTION * dailyLossLimit >= DLL_MIN_STOP + roundTripFeeEstimate;
}
/**
* The stop distance in ticks: the tighter of the obfuscation stop (rounded
* out, as it always was — it is a soft target) and the daily-loss stop
* (rounded IN — it is a hard limit). null = no stop at all (lone take-profit);
* 0 = the daily-loss room doesn't cover a single tick, so the position must
* not stay open. An obfuscation value of 0 or Infinity means no obfuscation stop.
*/
export function stopTicks(o: {
obfuscationDollars: number;
dllDollars: number;
pointValue: number;
contracts: number;
tickSize: number;
}): number | null {
const perTick = o.pointValue * o.contracts * o.tickSize;
let ticks: number | null = null;
if (Number.isFinite(o.obfuscationDollars) && o.obfuscationDollars > 0) {
ticks = Math.max(1, Math.ceil(o.obfuscationDollars / (o.pointValue * o.contracts) / o.tickSize));
}
if (Number.isFinite(o.dllDollars)) {
const dllTicks = Math.max(0, Math.floor(o.dllDollars / perTick + 1e-9));
ticks = ticks === null ? dllTicks : Math.min(ticks, dllTicks);
}
return ticks;
}
/** Whether the bot places its own stops for this account type — the
* obfuscation options or a hard daily loss limit. A red day on such an
* account is the bot's own stop (the personal DLL), not a firm lockout. */
export function botStopsActive(cfg: { firstDayStop: boolean; minDayStop: boolean; dailyLossLimit?: number }): boolean {
return cfg.firstDayStop || cfg.minDayStop || (cfg.dailyLossLimit ?? 0) > 0;
}
// ── position size ────────────────────────────────────────────────────────────
/**
* How much notional a leg should carry, on platforms that price it (DXTrade).
*
* Model price as a driftless random walk with σ per √hour. With the
* take-profit A dollars away (leg + fees) and the stop B dollars away, a
* position of notional N wins B/(A+B) of the time whatever N is, its expected
* hold is (A/N)(B/N)/σ² hours, and its expected P&L is minus fees and spread
* — proportional to N. Smaller is cheaper, bigger is faster, so the knob is
* the target hold T: N = √(A·B/(σ²·T)). A firm that charges overnight
* financing adds a floor: below √(f·A·B/(σ²·(2c + s))), a bigger position is
* both cheaper and faster.
*
* Fixed mode (no target hold, no σ, or a stopless leg): the symbol map's
* notional per $1,000 of leg, like futures' one contract per $1,000.
* Returns null when neither mode can size the leg.
*/
export function sizeNotional(o: {
leg: number;
/** Stop distance in dollars at a given notional (fees move with it). */
stopDollarsAt: (notional: number) => number;
volatilityPerHour?: number;
targetHoldHours: number;
/** Per side, percent. */
commissionPct: number;
spreadFraction?: number;
financingPctPerDay?: number;
/** Notional per $1,000 of leg for fixed sizing; 0 = none. */
fixedNotional: number;
/** Hard cap on the position; 0 = none. */
maxNotional: number;
}): { notional: number; mode: 'volatility' | 'fixed'; predictedHoldHours: number | null; winProbability: number | null } | null {
const c = Math.max(0, o.commissionPct) / 100;
const s = Math.max(0, o.spreadFraction ?? 0);
const sigma = o.volatilityPerHour ?? 0;
const fixed = o.fixedNotional > 0 ? Math.max(1, Math.ceil(o.leg / 1000)) * o.fixedNotional : 0;
const cap = (n: number) => (o.maxNotional > 0 ? Math.min(n, o.maxNotional) : n);
const describe = (n: number) => {
const a = o.leg + 2 * c * n;
const b = o.stopDollarsAt(n);
if (!(sigma > 0) || !Number.isFinite(b) || b <= 0 || n <= 0) {
return { predictedHoldHours: null, winProbability: Number.isFinite(b) && b > 0 ? b / (a + b) : null };
}
return { predictedHoldHours: (a / n) * (b / n) / (sigma * sigma), winProbability: b / (a + b) };
};
if (o.targetHoldHours > 0 && sigma > 0) {
let n = fixed > 0 ? fixed : o.leg * 20;
let ok = true;
// Fees depend on N and the daily-loss stop depends on fees: a couple of
// fixed-point passes settle it (the correction is second order).
for (let pass = 0; pass < 3; pass++) {
const a = o.leg + 2 * c * n;
const b = o.stopDollarsAt(n);
if (!Number.isFinite(b) || b <= 0) { ok = false; break; }
const nTime = Math.sqrt((a * b) / (sigma * sigma * o.targetHoldHours));
const fh = Math.max(0, o.financingPctPerDay ?? 0) / 100 / 24;
const nCost = fh > 0 ? Math.sqrt((fh * a * b) / (sigma * sigma * (2 * c + s))) : 0;
n = Math.max(nTime, nCost);
}
if (ok && Number.isFinite(n) && n > 0) {
const notional = cap(n);
return { notional, mode: 'volatility', ...describe(notional) };
}
}
if (fixed > 0) {
const notional = cap(fixed);
return { notional, mode: 'fixed', ...describe(notional) };
}
return null;
}
// ── trading hours ────────────────────────────────────────────────────────────
/** Tradeify247 (and DXtrade brokers generally) roll the trading day at 22:00
* UTC — 5 PM Central in summer, 4 PM in winter. */
export const FIRM_RESET_UTC_HOUR = 22;
/** Every-day firms stop opening new trades this long before the earlier of
* their reset and the 5 PM CT roll. */
const ROLL_GUARD_MIN = 5;
function centralClock(now: Date): { day: string; minutes: number; resetMinutes: number } {
const parts = new Intl.DateTimeFormat('en-US', {
weekday: 'short', hour: 'numeric', minute: 'numeric', hour12: false, timeZone: 'America/Chicago',
}).formatToParts(now);
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '';
const minutes = (parseInt(get('hour'), 10) % 24) * 60 + parseInt(get('minute'), 10);
// Central's offset from UTC right now (−300 in CDT, −360 in CST) places
// the firm's 22:00 UTC reset on the Central clock.
const utcMinutes = now.getUTCHours() * 60 + now.getUTCMinutes();
const offset = ((minutes - utcMinutes + 720 + 1440) % 1440) - 720;
const resetMinutes = (FIRM_RESET_UTC_HOUR * 60 + offset + 1440) % 1440;
return { day: get('weekday'), minutes, resetMinutes };
}
/**
* Whether no new trade may open right now.
*
* CME firms follow the trading_hours setting:
* "full_cme" — Sun 5:05 PM – Fri 2:55 PM Central (5 min buffer on each side)
* "equity_hours" — 8:35 AM – 2:55 PM Central, Mon–Fri (5 min buffer on each side)
* with the open pushed back by the day's 1–20 minute jitter.
*
* Every-day firms ignore trading_hours and close only around the day's roll:
* from 5 minutes before the earlier of their 22:00 UTC reset and 5 PM CT, to
* 5:05 PM CT + jitter. In CST that is 3:55 → 5:05 PM, which keeps new entries
* out of the hour where the firm's day (reset at 4 PM CT) and our trade date
* (5 PM CT) disagree.
*/
export function isNoTradeTime(now: Date, o: { everyDay: boolean; mode: string; jitterMin: number }): boolean {
const { day, minutes, resetMinutes } = centralClock(now);
const cmeOpen = 17 * 60 + 5 + o.jitterMin;
if (o.everyDay) {
const guardStart = Math.min(resetMinutes, 17 * 60) - ROLL_GUARD_MIN;
return minutes >= guardStart && minutes < cmeOpen;
}
if (o.mode === 'equity_hours') {
if (day === 'Sat' || day === 'Sun') return true;
const open = 8 * 60 + 35 + o.jitterMin;
const close = 14 * 60 + 55;
return minutes < open || minutes >= close;
}
// Full CME: Sun 5:00 PM – Fri 4:00 PM Central with 5 min buffer.
if (day === 'Sat') return true;
if (day === 'Sun') return minutes < cmeOpen;
const cmeClose = 14 * 60 + 55;
if (day === 'Fri' && minutes >= cmeClose) return true;
// Mon–Thu: block 2:55 PM – 5:05 PM Central (early stop + daily halt + buffer).
return minutes >= cmeClose && minutes < cmeOpen;
}
/** The trade date's session has closed and the 5 PM roll hasn't opened the
* next one: weekdays 2:55–5:00 PM CT on the CME calendar, the pre-roll guard
* for an every-day firm. */
export function isPastSessionClose(now: Date, everyDay: boolean): boolean {
const { day, minutes, resetMinutes } = centralClock(now);
if (everyDay) return minutes >= Math.min(resetMinutes, 17 * 60) - ROLL_GUARD_MIN && minutes < 17 * 60;
if (day === 'Sat' || day === 'Sun') return false;
return minutes >= 14 * 60 + 55 && minutes < 17 * 60;
}