DXtrade sizing: volatility only (12h default hold); fees and every-day only for DXtrade

The symbol map's fixed notional per contract is gone: every DXtrade leg is
sized from the instrument's recent volatility for the firm's target hold
(blank = 12 h), and a candidate without a volatility isn't picked. Percent
commission and every-day trading are ignored on non-DXtrade firms, which keep
measured per-contract fees and the CME calendar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-26 17:29:32 -05:00
co-authored by Claude Opus 5.5
parent 28f465508d
commit d01f03b293
9 changed files with 87 additions and 100 deletions
+7 -8
View File
@@ -14,6 +14,7 @@ import { getAccountConfig } from './account-type-resolver';
import {
combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT,
obfuscationStopDollars, takeProfitPlan, percentFee, dllRoom, dllStopDollars, dllViable, stopTicks, sizeNotional,
DEFAULT_TARGET_HOLD_HOURS,
isNoTradeTime, isPastSessionClose,
BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY, POINT_VALUES,
FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP,
@@ -947,9 +948,9 @@ async function flattenAndFail(
* the batch and copy-trade, so every fee, stop and sizing rule lives once.
*
* Size: futures trade one contract per $1,000 of leg; a platform that prices
* positions (stepNotional) sizes by notional instead — volatility sizing for
* the firm's target hold, or the symbol map's fixed notional — under the
* account type's notional cap.
* positions (stepNotional) sizes by notional instead — whatever makes the
* leg's expected hold the firm's target, from the instrument's recent
* volatility — under the account type's notional cap.
*
* Fees: a percent-of-notional firm's fee is computed off the fill; otherwise
* it is read off the realized-P&L change a second after it.
@@ -1016,15 +1017,14 @@ async function fireLeg(
leg: legAmount,
stopDollarsAt,
volatilityPerHour: legContract.volatilityPerHour,
targetHoldHours: firm.targetHoldHours ?? 0,
targetHoldHours: firm.targetHoldHours || DEFAULT_TARGET_HOLD_HOURS,
commissionPct: pct,
spreadFraction: legContract.spreadFraction,
financingPctPerDay: firm.financingPctPerDay ?? 0,
fixedNotional: legContract.fixedNotional ?? 0,
maxNotional: cfg.maxNotional ?? 0,
});
if (!sizing) {
throw new Error(`no position size for ${legContract.name} — set a notional per contract in the symbol map, or a target hold on the firm`);
throw new Error(`no position size for ${legContract.name} — its recent volatility is unknown`);
}
contracts = Math.max(1, Math.round(sizing.notional / stepNotional));
const maxNotional = cfg.maxNotional ?? 0;
@@ -1118,8 +1118,7 @@ async function fireLeg(
const instrumentLabel = legContract.stepNotional || item.drain ? legContract.name : symbolLabel;
const sizeNote = sizing
? ` | notional $${Math.round(contracts * (legContract.stepNotional ?? 0))} (${sizing.mode}`
+ `${sizing.predictedHoldHours !== null ? `, hold ~${sizing.predictedHoldHours.toFixed(1)}h` : ''}`
? ` | 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
+5 -7
View File
@@ -31,7 +31,7 @@
*/
import axios from 'axios';
import { currentTradeDateCT, FIRM_RESET_UTC_HOUR } from './trading-logic';
import { currentTradeDateCT, FIRM_RESET_UTC_HOUR, DEFAULT_TARGET_HOLD_HOURS } from './trading-logic';
import {
saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions,
saveEndedCycleStart,
@@ -1102,8 +1102,8 @@ 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 when the
* firm sizes by target hold (see scoreCandidate). Sticky while an account
* 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.
*/
public async findFrontMonthContract(root: string): Promise<PlatformContract | null> {
@@ -1134,13 +1134,13 @@ export class DXTradeClient implements PlatformClient {
const ref = this.referenceAccount();
if (!ref) return null;
const firm = getCachedFirm(this.cfg.firmId);
const targetHold = firm?.targetHoldHours ?? 0;
const targetHold = firm?.targetHoldHours || DEFAULT_TARGET_HOLD_HOURS;
const symbols = [...new Set(candidates.map((c) => c.symbol))];
const [insts, dets, quotes, vols] = await Promise.all([
this.instruments(symbols),
this.accountInstruments(ref.code, symbols),
this.quotes(ref.code, symbols),
targetHold > 0 ? this.volatility(ref.code, symbols) : Promise.resolve(new Map<string, { vol: number | null; candles: number }>()),
this.volatility(ref.code, symbols),
]);
const now = new Date();
@@ -1178,7 +1178,6 @@ export class DXTradeClient implements PlatformClient {
quoteAgeMs: quote && Number.isFinite(quote.time) ? Date.now() - quote.time : null,
inHours: inTradingHours(inst.tradingHours, now),
candles: vol?.candles ?? 0,
needVolatility: targetHold > 0,
minContractsOk: maxContracts === undefined || maxContracts >= minContracts,
currency: typeof inst.currency === 'string' ? inst.currency : undefined,
accountCurrency: ref.currency,
@@ -1204,7 +1203,6 @@ export class DXTradeClient implements PlatformClient {
pointValue: step * multiplier,
commissionPct: pct,
stepNotional,
fixedNotional: cand.notionalPerContract,
volatilityPerHour: vol?.vol ?? undefined,
spreadFraction: spread,
maxContracts,
+10 -18
View File
@@ -212,16 +212,13 @@ export function realizedVolPerHour(closes: number[]): number | null {
/**
* A candidate's cost score — lower is better.
*
* Volatility sizing (targetHoldHours > 0): a leg sized for a hold of T hours
* needs notional ∝ 1/σ, so its cost — spread plus both commissions plus
* financing over the hold, all proportional to notional — is proportional to
* (spread + 2c + f·T/24) / σ. A volatile instrument reaches the take-profit
* with less notional, which is what makes a tight spread alone not enough.
* A leg sized for a hold of T hours needs notional ∝ 1/σ, so its cost —
* spread plus both commissions plus financing over the hold, all
* proportional to notional — is proportional to (spread + 2c + f·T/24) / σ.
* A volatile instrument reaches the take-profit with less notional, which is
* what makes a tight spread alone not enough.
*
* Fixed sizing: the notional is set by the symbol map, so only the costs per
* dollar of notional matter.
*
* null = can't be scored (volatility sizing without a σ).
* null = can't be scored (no σ).
*/
export function scoreCandidate(o: {
spreadFraction: number;
@@ -230,13 +227,9 @@ export function scoreCandidate(o: {
targetHoldHours: number;
volatilityPerHour: number | null;
}): number | null {
const perNotional = o.spreadFraction + 2 * (o.commissionPct / 100);
if (o.targetHoldHours > 0) {
if (!o.volatilityPerHour || !(o.volatilityPerHour > 0)) return null;
const financing = (o.financingPctPerDay / 100) * (o.targetHoldHours / 24);
return (perNotional + financing) / o.volatilityPerHour;
}
return perNotional;
if (!o.volatilityPerHour || !(o.volatilityPerHour > 0)) return null;
const financing = (o.financingPctPerDay / 100) * (Math.max(0, o.targetHoldHours) / 24);
return (o.spreadFraction + 2 * (o.commissionPct / 100) + financing) / o.volatilityPerHour;
}
/** Most a spread may be, as a share of the mid price, for a candidate to trade. */
@@ -252,7 +245,6 @@ export function candidateRejection(o: {
quoteAgeMs: number | null;
inHours: boolean;
candles: number;
needVolatility: boolean;
minContractsOk: boolean;
currency: string | undefined;
accountCurrency: string;
@@ -263,7 +255,7 @@ export function candidateRejection(o: {
if (o.quoteAgeMs !== null && o.quoteAgeMs > MAX_QUOTE_AGE_MS) return `quote ${Math.round(o.quoteAgeMs / 60_000)}m old`;
const mid = (o.bid + o.ask) / 2;
if ((o.ask - o.bid) / mid > MAX_SPREAD_FRACTION) return `spread ${(((o.ask - o.bid) / mid) * 100).toFixed(2)}%`;
if (o.needVolatility && o.candles < MIN_VOL_CANDLES + 1) return `only ${o.candles} hourly candles`;
if (o.candles < MIN_VOL_CANDLES + 1) return `only ${o.candles} hourly candles`;
if (!o.minContractsOk) return 'minimum order size out of reach';
if (o.currency && o.currency !== o.accountCurrency) return `priced in ${o.currency}`;
return null;
+6 -4
View File
@@ -156,10 +156,9 @@ function sanitizeSymbolMap(raw: unknown): SymbolMap {
if (typeof c !== 'object' || c === null) return [];
const cand = c as Record<string, unknown>;
if (typeof cand.symbol !== 'string' || !cand.symbol || cand.symbol.includes('|')) return [];
const notional = nonNegative(cand.notionalPerContract);
const pct = typeof cand.commissionPct === 'number' && Number.isFinite(cand.commissionPct) && cand.commissionPct >= 0
? cand.commissionPct : undefined;
return [{ symbol: cand.symbol, notionalPerContract: notional, ...(pct !== undefined ? { commissionPct: pct } : {}) }];
return [{ symbol: cand.symbol, ...(pct !== undefined ? { commissionPct: pct } : {}) }];
});
if (candidates.length > 0) map[root.toUpperCase()] = candidates;
}
@@ -200,7 +199,10 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
warnUnknownPlatform(firm.id, firm.name, platform);
return;
}
const tradesEveryDay = firm.tradesEveryDay === true;
// Percent commission and every-day trading are DXtrade settings;
// futures firms keep measured per-contract fees and the CME calendar.
const dx = platform === 'dxtrade';
const tradesEveryDay = dx && firm.tradesEveryDay === true;
if (!Array.isArray(firm.accountTypes)) throw new Error(`firm ${firm.name} is missing accountTypes`);
const accountTypes: CatalogAccountType[] = firm.accountTypes.map((t: unknown, j: number) => {
@@ -246,7 +248,7 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
accountTypes,
serverUrl: typeof firm.serverUrl === 'string' ? firm.serverUrl : '',
loginDomain: typeof firm.loginDomain === 'string' ? firm.loginDomain : '',
commissionPct: nonNegative(firm.commissionPct),
commissionPct: dx ? nonNegative(firm.commissionPct) : 0,
tradesEveryDay,
symbolMap: sanitizeSymbolMap(firm.symbolMap),
targetHoldHours: nonNegative(firm.targetHoldHours),
+5 -6
View File
@@ -228,13 +228,12 @@ export function isFirmPlatform(value: unknown): value is FirmPlatform {
return typeof value === 'string' && (FIRM_PLATFORMS as readonly string[]).includes(value);
}
/** One symbol a DXTrade firm may trade for a CME root. */
/** One symbol a DXTrade firm may trade for a CME root. Position size comes
* from volatility and the firm's target hold, never from the map (an older
* master's notionalPerContract is ignored). */
export interface SymbolMapCandidate {
/** The platform's symbol, e.g. "BTC/USD". */
symbol: string;
/** Fixed sizing: notional per $1,000 of leg (like futures' one contract
* per $1,000). Used when volatility sizing is off or can't run. */
notionalPerContract: number;
/** Per-side commission % for this symbol; absent = the firm's. */
commissionPct?: number;
}
@@ -263,8 +262,8 @@ export interface CatalogFirm {
tradesEveryDay?: boolean;
/** DXTrade: which symbols each CME root may trade as. */
symbolMap?: SymbolMap;
/** DXTrade volatility sizing: aim each leg's expected hold at this many
* hours (0 = fixed sizing from the symbol map). */
/** DXTrade sizing: aim each leg's expected hold at this many hours
* (0 = DEFAULT_TARGET_HOLD_HOURS, 12). */
targetHoldHours?: number;
/** Overnight financing the firm charges, % of notional per day (0 = none). */
financingPctPerDay?: number;
-2
View File
@@ -30,8 +30,6 @@ export interface PlatformContract {
/** Notional of one engine contract at the current mid — present where the
* platform prices positions, which is what enables notional sizing. */
stepNotional?: number;
/** Fixed sizing: notional per $1,000 of leg (the symbol map's setting). */
fixedNotional?: number;
/** σ of log returns per √hour, from recent candles. */
volatilityPerHour?: number;
/** (ask − bid) / mid when the contract was picked. */
+31 -38
View File
@@ -1266,6 +1266,9 @@ export function botStopsActive(cfg: { firstDayStop: boolean; minDayStop: boolean
// ── position size ────────────────────────────────────────────────────────────
/** The hold a leg is sized for when the firm doesn't set one. */
export const DEFAULT_TARGET_HOLD_HOURS = 12;
/**
* How much notional a leg should carry, on platforms that price it (DXTrade).
*
@@ -1278,63 +1281,53 @@ export function botStopsActive(cfg: { firstDayStop: boolean; minDayStop: boolean
* 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.
* A stopless leg (no daily loss limit, no obfuscation stop) is sized as if
* its stop sat as far away as its target. Returns null without a volatility.
*/
export function sizeNotional(o: {
leg: number;
/** Stop distance in dollars at a given notional (fees move with it). */
stopDollarsAt: (notional: number) => number;
volatilityPerHour?: number;
/** 0 = DEFAULT_TARGET_HOLD_HOURS. */
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 {
}): { notional: number; predictedHoldHours: number; winProbability: number | null } | null {
const sigma = o.volatilityPerHour ?? 0;
if (!(sigma > 0)) return null;
const hold = o.targetHoldHours > 0 ? o.targetHoldHours : DEFAULT_TARGET_HOLD_HOURS;
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 fh = Math.max(0, o.financingPctPerDay ?? 0) / 100 / 24;
const distances = (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) };
const stop = o.stopDollarsAt(n);
const stopped = Number.isFinite(stop) && stop > 0;
return { a, b: stopped ? stop : a, stopped };
};
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) };
}
// 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).
let n = o.leg * 20;
for (let pass = 0; pass < 3; pass++) {
const { a, b } = distances(n);
const nTime = Math.sqrt((a * b) / (sigma * sigma * hold));
const nCost = fh > 0 ? Math.sqrt((fh * a * b) / (sigma * sigma * (2 * c + s))) : 0;
n = Math.max(nTime, nCost);
}
if (fixed > 0) {
const notional = cap(fixed);
return { notional, mode: 'fixed', ...describe(notional) };
}
return null;
if (!Number.isFinite(n) || n <= 0) return null;
const notional = o.maxNotional > 0 ? Math.min(n, o.maxNotional) : n;
const { a, b, stopped } = distances(notional);
return {
notional,
predictedHoldHours: (a / notional) * (b / notional) / (sigma * sigma),
winProbability: stopped ? b / (a + b) : null,
};
}
// ── trading hours ────────────────────────────────────────────────────────────