From d01f03b293eff0356ca420447a05444c3ee30b79 Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Sat, 26 Sep 2026 17:29:32 -0500 Subject: [PATCH] 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 --- README.md | 9 +++-- lib/auto-trade.ts | 15 ++++--- lib/dxtrade-class.ts | 12 +++--- lib/dxtrade-helpers.ts | 28 +++++-------- lib/firm-catalog.ts | 10 +++-- lib/master-protocol.ts | 11 +++--- lib/platform-client.ts | 2 - lib/trading-logic.ts | 69 +++++++++++++++------------------ scripts/check-trading-logic.mjs | 31 ++++++++------- 9 files changed, 87 insertions(+), 100 deletions(-) diff --git a/README.md b/README.md index 781184f..f15942f 100644 --- a/README.md +++ b/README.md @@ -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** (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 every fee off the fill with it. - **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 `*`). When a root lists several, the cheapest tradable one right now wins: spread plus both commissions, divided by its recent hourly volatility. -- **Target hold hours** — size each leg's notional so its expected hold is - about this long (0 = the symbol map's fixed notional per $1,000 of leg), and - **financing %/day** if the firm charges swap. +- **Target hold hours** — each leg's notional is sized from the instrument's + recent volatility so its expected hold is about this long (blank = 12 h), + and **financing %/day** if the firm charges swap. 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 diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 0a4ce81..1c9cbd8 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -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 diff --git a/lib/dxtrade-class.ts b/lib/dxtrade-class.ts index e43f736..a60b52c 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 } 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 { @@ -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()), + 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, diff --git a/lib/dxtrade-helpers.ts b/lib/dxtrade-helpers.ts index 284b686..628c071 100644 --- a/lib/dxtrade-helpers.ts +++ b/lib/dxtrade-helpers.ts @@ -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; diff --git a/lib/firm-catalog.ts b/lib/firm-catalog.ts index 145972d..9b06a84 100644 --- a/lib/firm-catalog.ts +++ b/lib/firm-catalog.ts @@ -156,10 +156,9 @@ function sanitizeSymbolMap(raw: unknown): SymbolMap { if (typeof c !== 'object' || c === null) return []; const cand = c as Record; 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), diff --git a/lib/master-protocol.ts b/lib/master-protocol.ts index f9f61eb..7c074c6 100644 --- a/lib/master-protocol.ts +++ b/lib/master-protocol.ts @@ -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; diff --git a/lib/platform-client.ts b/lib/platform-client.ts index b8a959d..bb433b9 100644 --- a/lib/platform-client.ts +++ b/lib/platform-client.ts @@ -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. */ diff --git a/lib/trading-logic.ts b/lib/trading-logic.ts index f972946..b9e035b 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -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 ──────────────────────────────────────────────────────────── diff --git a/scripts/check-trading-logic.mjs b/scripts/check-trading-logic.mjs index a6aa66a..c354a11 100644 --- a/scripts/check-trading-logic.mjs +++ b/scripts/check-trading-logic.mjs @@ -10,7 +10,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { 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, } 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', () => { 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]]) { 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.predictedHoldHours - hours) / hours < 0.001); assert.ok(Math.abs(s.winProbability - 1350 / 1950) < 1e-9); } }); -test('sizing: fees feed back, the cap binds, financing only raises N, fixed fallback', () => { - const base = { leg: 600, volatilityPerHour: btcSigma, targetHoldHours: 6, commissionPct: 0.04, spreadFraction: 0.0002, fixedNotional: 20000, maxNotional: 0 }; +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, maxNotional: 0 }; // 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 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 }); assert.ok(financed.notional > unfinanced.notional); - const fixed = sizeNotional({ ...base, stopDollarsAt: () => Infinity, leg: 1700 }); - assert.equal(fixed.mode, 'fixed'); - assert.equal(fixed.notional, 40000); - assert.equal(sizeNotional({ ...base, stopDollarsAt: () => Infinity, fixedNotional: 0 }), null); - assert.equal(sizeNotional({ ...base, targetHoldHours: 0, stopDollarsAt }).mode, 'fixed'); + // No target hold = 12 h. + const dflt = sizeNotional({ ...base, targetHoldHours: 0, stopDollarsAt }); + assert.ok(Math.abs(dflt.predictedHoldHours - DEFAULT_TARGET_HOLD_HOURS) < 0.1, `hold ${dflt.predictedHoldHours}`); + // A stopless leg is sized as if the stop sat as far as the target. + 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', () => { @@ -461,15 +464,16 @@ test('the selector ranks the plan example: ETH, BTC, XYZ100, AAPL', () => { 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.0002, 0.03) * k - 60) < 1); - // Fixed sizing ignores volatility; volatility sizing needs it. - assert.equal(scoreCandidate({ spreadFraction: 0.0002, commissionPct: 0.04, financingPctPerDay: 0, targetHoldHours: 0, volatilityPerHour: null }), 0.001); + // No volatility, no score; financing over the hold adds to the cost. 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', () => { const ok = { 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.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, ask: 101 }), /spread/); assert.match(candidateRejection({ ...ok, candles: 10 }), /candles/); - assert.equal(candidateRejection({ ...ok, candles: 10, needVolatility: false }), null); assert.match(candidateRejection({ ...ok, currency: 'EUR' }), /EUR/); });