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
+6 -3
View File
@@ -104,6 +104,9 @@ A DXtrade firm is set up on the master's **Firms** page like any other, plus:
- **Server URL** (`https://dx.tradeify247.co/dxsca-web`) and **login domain** - **Server URL** (`https://dx.tradeify247.co/dxsca-web`) and **login domain**
(usually `default`). Changing the URL re-keys the firm's accounts. (usually `default`). Changing the URL re-keys the firm's accounts.
These settings only exist for DXtrade firms; futures firms keep their measured
per-contract fees and the CME calendar.
- **Commission %** of notional per side (Tradeify: `0.04`). The engine prices - **Commission %** of notional per side (Tradeify: `0.04`). The engine prices
every fee off the fill with it. every fee off the fill with it.
- **Trades every day** — weekends and CME holidays included. Such a firm runs - **Trades every day** — weekends and CME holidays included. Such a firm runs
@@ -114,9 +117,9 @@ A DXtrade firm is set up on the master's **Firms** page like any other, plus:
covers every root (drain and extra-day legs trade `MNQ`, so map it or use covers every root (drain and extra-day legs trade `MNQ`, so map it or use
`*`). When a root lists several, the cheapest tradable one right now wins: `*`). When a root lists several, the cheapest tradable one right now wins:
spread plus both commissions, divided by its recent hourly volatility. spread plus both commissions, divided by its recent hourly volatility.
- **Target hold hours** — size each leg's notional so its expected hold is - **Target hold hours** — each leg's notional is sized from the instrument's
about this long (0 = the symbol map's fixed notional per $1,000 of leg), and recent volatility so its expected hold is about this long (blank = 12 h),
**financing %/day** if the firm charges swap. and **financing %/day** if the firm charges swap.
Per account type: **Daily loss limit** (Tradeify: 3% of size — every leg then 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 carries a stop that keeps the firm's day inside it, less a 10% reserve and
+7 -8
View File
@@ -14,6 +14,7 @@ import { getAccountConfig } from './account-type-resolver';
import { import {
combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT, combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT,
obfuscationStopDollars, takeProfitPlan, percentFee, dllRoom, dllStopDollars, dllViable, stopTicks, sizeNotional, obfuscationStopDollars, takeProfitPlan, percentFee, dllRoom, dllStopDollars, dllViable, stopTicks, sizeNotional,
DEFAULT_TARGET_HOLD_HOURS,
isNoTradeTime, isPastSessionClose, isNoTradeTime, isPastSessionClose,
BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY, POINT_VALUES, BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY, POINT_VALUES,
FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP, 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. * 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 * Size: futures trade one contract per $1,000 of leg; a platform that prices
* positions (stepNotional) sizes by notional instead — volatility sizing for * positions (stepNotional) sizes by notional instead — whatever makes the
* the firm's target hold, or the symbol map's fixed notional — under the * leg's expected hold the firm's target, from the instrument's recent
* account type's notional cap. * volatility — under the account type's notional cap.
* *
* Fees: a percent-of-notional firm's fee is computed off the fill; otherwise * 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. * it is read off the realized-P&L change a second after it.
@@ -1016,15 +1017,14 @@ async function fireLeg(
leg: legAmount, leg: legAmount,
stopDollarsAt, stopDollarsAt,
volatilityPerHour: legContract.volatilityPerHour, volatilityPerHour: legContract.volatilityPerHour,
targetHoldHours: firm.targetHoldHours ?? 0, targetHoldHours: firm.targetHoldHours || DEFAULT_TARGET_HOLD_HOURS,
commissionPct: pct, commissionPct: pct,
spreadFraction: legContract.spreadFraction, spreadFraction: legContract.spreadFraction,
financingPctPerDay: firm.financingPctPerDay ?? 0, financingPctPerDay: firm.financingPctPerDay ?? 0,
fixedNotional: legContract.fixedNotional ?? 0,
maxNotional: cfg.maxNotional ?? 0, maxNotional: cfg.maxNotional ?? 0,
}); });
if (!sizing) { 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)); contracts = Math.max(1, Math.round(sizing.notional / stepNotional));
const maxNotional = cfg.maxNotional ?? 0; const maxNotional = cfg.maxNotional ?? 0;
@@ -1118,8 +1118,7 @@ async function fireLeg(
const instrumentLabel = legContract.stepNotional || item.drain ? legContract.name : symbolLabel; const instrumentLabel = legContract.stepNotional || item.drain ? legContract.name : symbolLabel;
const sizeNote = sizing const sizeNote = sizing
? ` | notional $${Math.round(contracts * (legContract.stepNotional ?? 0))} (${sizing.mode}` ? ` | notional $${Math.round(contracts * (legContract.stepNotional ?? 0))} (hold ~${sizing.predictedHoldHours.toFixed(1)}h`
+ `${sizing.predictedHoldHours !== null ? `, hold ~${sizing.predictedHoldHours.toFixed(1)}h` : ''}`
+ `${sizing.winProbability !== null ? `, win ${Math.round(sizing.winProbability * 100)}%` : ''})` + `${sizing.winProbability !== null ? `, win ${Math.round(sizing.winProbability * 100)}%` : ''})`
: ''; : '';
const stopNote = stopPrice !== null const stopNote = stopPrice !== null
+5 -7
View File
@@ -31,7 +31,7 @@
*/ */
import axios from 'axios'; 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 { import {
saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions, saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions,
saveEndedCycleStart, saveEndedCycleStart,
@@ -1102,8 +1102,8 @@ export class DXTradeClient implements PlatformClient {
/** /**
* The contract a CME root trades as on this firm right now: the cheapest * 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 * tradable candidate from the symbol map, per unit of volatility (see
* firm sizes by target hold (see scoreCandidate). Sticky while an account * scoreCandidate). Sticky while an account
* holds one of the candidates, cached briefly so a batch's lookups agree. * holds one of the candidates, cached briefly so a batch's lookups agree.
*/ */
public async findFrontMonthContract(root: string): Promise<PlatformContract | null> { public async findFrontMonthContract(root: string): Promise<PlatformContract | null> {
@@ -1134,13 +1134,13 @@ export class DXTradeClient implements PlatformClient {
const ref = this.referenceAccount(); const ref = this.referenceAccount();
if (!ref) return null; if (!ref) return null;
const firm = getCachedFirm(this.cfg.firmId); 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 symbols = [...new Set(candidates.map((c) => c.symbol))];
const [insts, dets, quotes, vols] = await Promise.all([ const [insts, dets, quotes, vols] = await Promise.all([
this.instruments(symbols), this.instruments(symbols),
this.accountInstruments(ref.code, symbols), this.accountInstruments(ref.code, symbols),
this.quotes(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(); const now = new Date();
@@ -1178,7 +1178,6 @@ export class DXTradeClient implements PlatformClient {
quoteAgeMs: quote && Number.isFinite(quote.time) ? Date.now() - quote.time : null, quoteAgeMs: quote && Number.isFinite(quote.time) ? Date.now() - quote.time : null,
inHours: inTradingHours(inst.tradingHours, now), inHours: inTradingHours(inst.tradingHours, now),
candles: vol?.candles ?? 0, candles: vol?.candles ?? 0,
needVolatility: targetHold > 0,
minContractsOk: maxContracts === undefined || maxContracts >= minContracts, minContractsOk: maxContracts === undefined || maxContracts >= minContracts,
currency: typeof inst.currency === 'string' ? inst.currency : undefined, currency: typeof inst.currency === 'string' ? inst.currency : undefined,
accountCurrency: ref.currency, accountCurrency: ref.currency,
@@ -1204,7 +1203,6 @@ export class DXTradeClient implements PlatformClient {
pointValue: step * multiplier, pointValue: step * multiplier,
commissionPct: pct, commissionPct: pct,
stepNotional, stepNotional,
fixedNotional: cand.notionalPerContract,
volatilityPerHour: vol?.vol ?? undefined, volatilityPerHour: vol?.vol ?? undefined,
spreadFraction: spread, spreadFraction: spread,
maxContracts, maxContracts,
+9 -17
View File
@@ -212,16 +212,13 @@ export function realizedVolPerHour(closes: number[]): number | null {
/** /**
* A candidate's cost score — lower is better. * A candidate's cost score — lower is better.
* *
* Volatility sizing (targetHoldHours > 0): a leg sized for a hold of T hours * A leg sized for a hold of T hours needs notional ∝ 1/σ, so its cost —
* needs notional ∝ 1/σ, so its cost — spread plus both commissions plus * spread plus both commissions plus financing over the hold, all
* financing over the hold, all proportional to notional — is proportional to * proportional to notional — is proportional to (spread + 2c + f·T/24) / σ.
* (spread + 2c + f·T/24) / σ. A volatile instrument reaches the take-profit * A volatile instrument reaches the take-profit with less notional, which is
* with less notional, which is what makes a tight spread alone not enough. * what makes a tight spread alone not enough.
* *
* Fixed sizing: the notional is set by the symbol map, so only the costs per * null = can't be scored (no σ).
* dollar of notional matter.
*
* null = can't be scored (volatility sizing without a σ).
*/ */
export function scoreCandidate(o: { export function scoreCandidate(o: {
spreadFraction: number; spreadFraction: number;
@@ -230,13 +227,9 @@ export function scoreCandidate(o: {
targetHoldHours: number; targetHoldHours: number;
volatilityPerHour: number | null; volatilityPerHour: number | null;
}): number | null { }): number | null {
const perNotional = o.spreadFraction + 2 * (o.commissionPct / 100);
if (o.targetHoldHours > 0) {
if (!o.volatilityPerHour || !(o.volatilityPerHour > 0)) return null; if (!o.volatilityPerHour || !(o.volatilityPerHour > 0)) return null;
const financing = (o.financingPctPerDay / 100) * (o.targetHoldHours / 24); const financing = (o.financingPctPerDay / 100) * (Math.max(0, o.targetHoldHours) / 24);
return (perNotional + financing) / o.volatilityPerHour; return (o.spreadFraction + 2 * (o.commissionPct / 100) + financing) / o.volatilityPerHour;
}
return perNotional;
} }
/** Most a spread may be, as a share of the mid price, for a candidate to trade. */ /** 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; quoteAgeMs: number | null;
inHours: boolean; inHours: boolean;
candles: number; candles: number;
needVolatility: boolean;
minContractsOk: boolean; minContractsOk: boolean;
currency: string | undefined; currency: string | undefined;
accountCurrency: string; 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`; 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; 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.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.minContractsOk) return 'minimum order size out of reach';
if (o.currency && o.currency !== o.accountCurrency) return `priced in ${o.currency}`; if (o.currency && o.currency !== o.accountCurrency) return `priced in ${o.currency}`;
return null; return null;
+6 -4
View File
@@ -156,10 +156,9 @@ function sanitizeSymbolMap(raw: unknown): SymbolMap {
if (typeof c !== 'object' || c === null) return []; if (typeof c !== 'object' || c === null) return [];
const cand = c as Record<string, unknown>; const cand = c as Record<string, unknown>;
if (typeof cand.symbol !== 'string' || !cand.symbol || cand.symbol.includes('|')) return []; 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 const pct = typeof cand.commissionPct === 'number' && Number.isFinite(cand.commissionPct) && cand.commissionPct >= 0
? cand.commissionPct : undefined; ? 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; if (candidates.length > 0) map[root.toUpperCase()] = candidates;
} }
@@ -200,7 +199,10 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
warnUnknownPlatform(firm.id, firm.name, platform); warnUnknownPlatform(firm.id, firm.name, platform);
return; 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`); if (!Array.isArray(firm.accountTypes)) throw new Error(`firm ${firm.name} is missing accountTypes`);
const accountTypes: CatalogAccountType[] = firm.accountTypes.map((t: unknown, j: number) => { const accountTypes: CatalogAccountType[] = firm.accountTypes.map((t: unknown, j: number) => {
@@ -246,7 +248,7 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
accountTypes, accountTypes,
serverUrl: typeof firm.serverUrl === 'string' ? firm.serverUrl : '', serverUrl: typeof firm.serverUrl === 'string' ? firm.serverUrl : '',
loginDomain: typeof firm.loginDomain === 'string' ? firm.loginDomain : '', loginDomain: typeof firm.loginDomain === 'string' ? firm.loginDomain : '',
commissionPct: nonNegative(firm.commissionPct), commissionPct: dx ? nonNegative(firm.commissionPct) : 0,
tradesEveryDay, tradesEveryDay,
symbolMap: sanitizeSymbolMap(firm.symbolMap), symbolMap: sanitizeSymbolMap(firm.symbolMap),
targetHoldHours: nonNegative(firm.targetHoldHours), 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); 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 { export interface SymbolMapCandidate {
/** The platform's symbol, e.g. "BTC/USD". */ /** The platform's symbol, e.g. "BTC/USD". */
symbol: string; 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. */ /** Per-side commission % for this symbol; absent = the firm's. */
commissionPct?: number; commissionPct?: number;
} }
@@ -263,8 +262,8 @@ export interface CatalogFirm {
tradesEveryDay?: boolean; tradesEveryDay?: boolean;
/** DXTrade: which symbols each CME root may trade as. */ /** DXTrade: which symbols each CME root may trade as. */
symbolMap?: SymbolMap; symbolMap?: SymbolMap;
/** DXTrade volatility sizing: aim each leg's expected hold at this many /** DXTrade sizing: aim each leg's expected hold at this many hours
* hours (0 = fixed sizing from the symbol map). */ * (0 = DEFAULT_TARGET_HOLD_HOURS, 12). */
targetHoldHours?: number; targetHoldHours?: number;
/** Overnight financing the firm charges, % of notional per day (0 = none). */ /** Overnight financing the firm charges, % of notional per day (0 = none). */
financingPctPerDay?: number; financingPctPerDay?: number;
-2
View File
@@ -30,8 +30,6 @@ export interface PlatformContract {
/** Notional of one engine contract at the current mid — present where the /** Notional of one engine contract at the current mid — present where the
* platform prices positions, which is what enables notional sizing. */ * platform prices positions, which is what enables notional sizing. */
stepNotional?: number; 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. */ /** σ of log returns per √hour, from recent candles. */
volatilityPerHour?: number; volatilityPerHour?: number;
/** (ask − bid) / mid when the contract was picked. */ /** (ask − bid) / mid when the contract was picked. */
+26 -33
View File
@@ -1266,6 +1266,9 @@ export function botStopsActive(cfg: { firstDayStop: boolean; minDayStop: boolean
// ── position size ──────────────────────────────────────────────────────────── // ── 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). * 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 * financing adds a floor: below √(f·A·B/(σ²·(2c + s))), a bigger position is
* both cheaper and faster. * both cheaper and faster.
* *
* Fixed mode (no target hold, no σ, or a stopless leg): the symbol map's * A stopless leg (no daily loss limit, no obfuscation stop) is sized as if
* notional per $1,000 of leg, like futures' one contract per $1,000. * its stop sat as far away as its target. Returns null without a volatility.
* Returns null when neither mode can size the leg.
*/ */
export function sizeNotional(o: { export function sizeNotional(o: {
leg: number; leg: number;
/** Stop distance in dollars at a given notional (fees move with it). */ /** Stop distance in dollars at a given notional (fees move with it). */
stopDollarsAt: (notional: number) => number; stopDollarsAt: (notional: number) => number;
volatilityPerHour?: number; volatilityPerHour?: number;
/** 0 = DEFAULT_TARGET_HOLD_HOURS. */
targetHoldHours: number; targetHoldHours: number;
/** Per side, percent. */ /** Per side, percent. */
commissionPct: number; commissionPct: number;
spreadFraction?: number; spreadFraction?: number;
financingPctPerDay?: number; financingPctPerDay?: number;
/** Notional per $1,000 of leg for fixed sizing; 0 = none. */
fixedNotional: number;
/** Hard cap on the position; 0 = none. */ /** Hard cap on the position; 0 = none. */
maxNotional: number; 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 c = Math.max(0, o.commissionPct) / 100;
const s = Math.max(0, o.spreadFraction ?? 0); const s = Math.max(0, o.spreadFraction ?? 0);
const sigma = o.volatilityPerHour ?? 0; const fh = Math.max(0, o.financingPctPerDay ?? 0) / 100 / 24;
const fixed = o.fixedNotional > 0 ? Math.max(1, Math.ceil(o.leg / 1000)) * o.fixedNotional : 0; const distances = (n: number) => {
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 a = o.leg + 2 * c * n;
const b = o.stopDollarsAt(n); const stop = o.stopDollarsAt(n);
if (!(sigma > 0) || !Number.isFinite(b) || b <= 0 || n <= 0) { const stopped = Number.isFinite(stop) && stop > 0;
return { predictedHoldHours: null, winProbability: Number.isFinite(b) && b > 0 ? b / (a + b) : null }; return { a, b: stopped ? stop : a, stopped };
}
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 // 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). // fixed-point passes settle it (the correction is second order).
let n = o.leg * 20;
for (let pass = 0; pass < 3; pass++) { for (let pass = 0; pass < 3; pass++) {
const a = o.leg + 2 * c * n; const { a, b } = distances(n);
const b = o.stopDollarsAt(n); const nTime = Math.sqrt((a * b) / (sigma * sigma * hold));
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; const nCost = fh > 0 ? Math.sqrt((fh * a * b) / (sigma * sigma * (2 * c + s))) : 0;
n = Math.max(nTime, nCost); n = Math.max(nTime, nCost);
} }
if (ok && Number.isFinite(n) && n > 0) { if (!Number.isFinite(n) || n <= 0) return null;
const notional = cap(n); const notional = o.maxNotional > 0 ? Math.min(n, o.maxNotional) : n;
return { notional, mode: 'volatility', ...describe(notional) }; const { a, b, stopped } = distances(notional);
} return {
} notional,
if (fixed > 0) { predictedHoldHours: (a / notional) * (b / notional) / (sigma * sigma),
const notional = cap(fixed); winProbability: stopped ? b / (a + b) : null,
return { notional, mode: 'fixed', ...describe(notional) }; };
}
return null;
} }
// ── trading hours ──────────────────────────────────────────────────────────── // ── trading hours ────────────────────────────────────────────────────────────
+17 -14
View File
@@ -10,7 +10,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
percentFee, takeProfitPlan, obfuscationStopDollars, dllRoom, dllStopDollars, dllViable, stopTicks, percentFee, takeProfitPlan, obfuscationStopDollars, dllRoom, dllStopDollars, dllViable, stopTicks,
botStopsActive, sizeNotional, isNoTradeTime, isPastSessionClose, nextTradingDate, effectiveCombine, botStopsActive, sizeNotional, isNoTradeTime, isPastSessionClose, nextTradingDate, effectiveCombine, DEFAULT_TARGET_HOLD_HOURS,
projectPerfectPath, cycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, projectPerfectPath, cycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP,
} from '../lib/trading-logic.ts'; } from '../lib/trading-logic.ts';
@@ -258,18 +258,17 @@ const btcSigma = 0.03 / Math.sqrt(24); // 3%/day
test('volatility sizing hits the target hold, and the example table', () => { test('volatility sizing hits the target hold, and the example table', () => {
const stop = () => 1350; const stop = () => 1350;
const at = (hours) => sizeNotional({ leg: 600, stopDollarsAt: stop, volatilityPerHour: btcSigma, targetHoldHours: hours, commissionPct: 0, fixedNotional: 0, maxNotional: 0 }); const at = (hours) => sizeNotional({ leg: 600, stopDollarsAt: stop, volatilityPerHour: btcSigma, targetHoldHours: hours, commissionPct: 0, maxNotional: 0 });
for (const [hours, notional] of [[54, 20000], [24, 30000], [6, 60000]]) { for (const [hours, notional] of [[54, 20000], [24, 30000], [6, 60000]]) {
const s = at(hours); const s = at(hours);
assert.equal(s.mode, 'volatility');
assert.ok(Math.abs(s.notional - notional) / notional < 0.001, `${hours}h → ${s.notional}`); assert.ok(Math.abs(s.notional - notional) / notional < 0.001, `${hours}h → ${s.notional}`);
assert.ok(Math.abs(s.predictedHoldHours - hours) / hours < 0.001); assert.ok(Math.abs(s.predictedHoldHours - hours) / hours < 0.001);
assert.ok(Math.abs(s.winProbability - 1350 / 1950) < 1e-9); assert.ok(Math.abs(s.winProbability - 1350 / 1950) < 1e-9);
} }
}); });
test('sizing: fees feed back, the cap binds, financing only raises N, fixed fallback', () => { test('sizing: fees feed back, the cap binds, financing only raises N, defaults', () => {
const base = { leg: 600, volatilityPerHour: btcSigma, targetHoldHours: 6, commissionPct: 0.04, spreadFraction: 0.0002, fixedNotional: 20000, maxNotional: 0 }; const base = { leg: 600, volatilityPerHour: btcSigma, targetHoldHours: 6, commissionPct: 0.04, spreadFraction: 0.0002, maxNotional: 0 };
// Daily-loss stop shrinks as fees grow with N. // Daily-loss stop shrinks as fees grow with N.
const stopDollarsAt = (n) => dllStopDollars({ room: 1500, dailyLossLimit: 1500, entryFee: 0.0004 * n, commissionPct: 0.04 }); const stopDollarsAt = (n) => dllStopDollars({ room: 1500, dailyLossLimit: 1500, entryFee: 0.0004 * n, commissionPct: 0.04 });
const s = sizeNotional({ ...base, stopDollarsAt }); const s = sizeNotional({ ...base, stopDollarsAt });
@@ -286,11 +285,15 @@ test('sizing: fees feed back, the cap binds, financing only raises N, fixed fall
const unfinanced = sizeNotional({ ...base, stopDollarsAt, targetHoldHours: 200 }); const unfinanced = sizeNotional({ ...base, stopDollarsAt, targetHoldHours: 200 });
assert.ok(financed.notional > unfinanced.notional); assert.ok(financed.notional > unfinanced.notional);
const fixed = sizeNotional({ ...base, stopDollarsAt: () => Infinity, leg: 1700 }); // No target hold = 12 h.
assert.equal(fixed.mode, 'fixed'); const dflt = sizeNotional({ ...base, targetHoldHours: 0, stopDollarsAt });
assert.equal(fixed.notional, 40000); assert.ok(Math.abs(dflt.predictedHoldHours - DEFAULT_TARGET_HOLD_HOURS) < 0.1, `hold ${dflt.predictedHoldHours}`);
assert.equal(sizeNotional({ ...base, stopDollarsAt: () => Infinity, fixedNotional: 0 }), null); // A stopless leg is sized as if the stop sat as far as the target.
assert.equal(sizeNotional({ ...base, targetHoldHours: 0, stopDollarsAt }).mode, 'fixed'); const stopless = sizeNotional({ ...base, stopDollarsAt: () => Infinity });
assert.ok(Math.abs(stopless.predictedHoldHours - 6) < 0.05);
assert.equal(stopless.winProbability, null);
// No volatility, no size.
assert.equal(sizeNotional({ ...base, volatilityPerHour: undefined, stopDollarsAt }), null);
}); });
test('random-walk simulation agrees with the predicted hold and win rate', () => { test('random-walk simulation agrees with the predicted hold and win rate', () => {
@@ -461,15 +464,16 @@ test('the selector ranks the plan example: ETH, BTC, XYZ100, AAPL', () => {
const k = Math.sqrt((600 * 1350) / 6); const k = Math.sqrt((600 * 1350) / 6);
assert.ok(Math.abs(score(0.0003, 0.04) * k - 49.6) < 1); assert.ok(Math.abs(score(0.0003, 0.04) * k - 49.6) < 1);
assert.ok(Math.abs(score(0.0002, 0.03) * k - 60) < 1); assert.ok(Math.abs(score(0.0002, 0.03) * k - 60) < 1);
// Fixed sizing ignores volatility; volatility sizing needs it. // No volatility, no score; financing over the hold adds to the cost.
assert.equal(scoreCandidate({ spreadFraction: 0.0002, commissionPct: 0.04, financingPctPerDay: 0, targetHoldHours: 0, volatilityPerHour: null }), 0.001);
assert.equal(scoreCandidate({ spreadFraction: 0.0002, commissionPct: 0.04, financingPctPerDay: 0, targetHoldHours: 6, volatilityPerHour: null }), null); assert.equal(scoreCandidate({ spreadFraction: 0.0002, commissionPct: 0.04, financingPctPerDay: 0, targetHoldHours: 6, volatilityPerHour: null }), null);
const withFinancing = scoreCandidate({ spreadFraction: 0.0002, commissionPct: 0.04, financingPctPerDay: 0.033, targetHoldHours: 12, volatilityPerHour: perHour(0.03) });
assert.ok(withFinancing > score(0.0002, 0.03));
}); });
test('the selector drops what cannot trade right now', () => { test('the selector drops what cannot trade right now', () => {
const ok = { const ok = {
tradingStatus: 'FULL', bid: 100, ask: 100.02, quoteAgeMs: 1000, inHours: true, candles: 49, tradingStatus: 'FULL', bid: 100, ask: 100.02, quoteAgeMs: 1000, inHours: true, candles: 49,
needVolatility: true, minContractsOk: true, currency: 'USD', accountCurrency: 'USD', minContractsOk: true, currency: 'USD', accountCurrency: 'USD',
}; };
assert.equal(candidateRejection(ok), null); assert.equal(candidateRejection(ok), null);
assert.match(candidateRejection({ ...ok, tradingStatus: 'CLOSE_ONLY' }), /CLOSE_ONLY/); assert.match(candidateRejection({ ...ok, tradingStatus: 'CLOSE_ONLY' }), /CLOSE_ONLY/);
@@ -478,6 +482,5 @@ test('the selector drops what cannot trade right now', () => {
assert.match(candidateRejection({ ...ok, quoteAgeMs: 10 * 60_000 }), /old/); assert.match(candidateRejection({ ...ok, quoteAgeMs: 10 * 60_000 }), /old/);
assert.match(candidateRejection({ ...ok, ask: 101 }), /spread/); assert.match(candidateRejection({ ...ok, ask: 101 }), /spread/);
assert.match(candidateRejection({ ...ok, candles: 10 }), /candles/); assert.match(candidateRejection({ ...ok, candles: 10 }), /candles/);
assert.equal(candidateRejection({ ...ok, candles: 10, needVolatility: false }), null);
assert.match(candidateRejection({ ...ok, currency: 'EUR' }), /EUR/); assert.match(candidateRejection({ ...ok, currency: 'EUR' }), /EUR/);
}); });