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:
co-authored by
Claude Opus 5.5
parent
49c4725e0c
commit
28f465508d
@@ -0,0 +1,483 @@
|
||||
// Checks for the pure trading math in lib/trading-logic.ts.
|
||||
//
|
||||
// npm run check
|
||||
//
|
||||
// .mjs on purpose: Next's tsconfig includes every .ts file, and a .ts test
|
||||
// importing another .ts by extension would trip the build's type check. Node
|
||||
// strips the types from lib/trading-logic.ts at import time (it has no
|
||||
// imports of its own).
|
||||
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,
|
||||
projectPerfectPath, cycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP,
|
||||
} from '../lib/trading-logic.ts';
|
||||
|
||||
// Deterministic PRNG so a failure reproduces.
|
||||
function rng(seed) {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s + 0x6d2b79f5) >>> 0;
|
||||
let t = s;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
// ── parity with the pre-refactor inline code ────────────────────────────────
|
||||
|
||||
/** The take-profit exactly as auto-trade.ts computed it before takeProfitPlan. */
|
||||
function oldTakeProfit(side, entry, leg, pv, contracts, tickSize, entryCommission) {
|
||||
const totalCommission = entryCommission * 2;
|
||||
const grossTarget = leg + totalCommission;
|
||||
const targetPoints = grossTarget / (pv * contracts);
|
||||
const ticks = Math.ceil(targetPoints / tickSize);
|
||||
const exitPrice = side === 'Buy' ? entry + ticks * tickSize : entry - ticks * tickSize;
|
||||
return { exitPrice, ticks, totalCommission, grossTarget };
|
||||
}
|
||||
|
||||
test('takeProfitPlan reproduces the old formula when fees are measured', () => {
|
||||
const r = rng(1);
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
const side = r() < 0.5 ? 'Buy' : 'Sell';
|
||||
const [pv, tick] = [[20, 0.25], [2, 0.25], [100, 0.1], [1000, 0.01], [50, 0.25]][Math.floor(r() * 5)];
|
||||
const entry = Math.round((1000 + r() * 20000) / tick) * tick;
|
||||
const leg = Math.round(5 + r() * 2500);
|
||||
const contracts = 1 + Math.floor(r() * 4);
|
||||
const fee = Math.round(r() * 1000) / 100;
|
||||
const got = takeProfitPlan({ side, entry, leg, pointValue: pv, contracts, tickSize: tick, commissionPct: 0, entryCommission: fee });
|
||||
const want = oldTakeProfit(side, entry, leg, pv, contracts, tick, fee);
|
||||
assert.equal(got.exitPrice, want.exitPrice);
|
||||
assert.equal(got.ticks, want.ticks);
|
||||
assert.equal(got.totalCommission, want.totalCommission);
|
||||
assert.equal(got.grossTarget, want.grossTarget);
|
||||
}
|
||||
});
|
||||
|
||||
/** obfuscationStopDollars exactly as it lived in auto-trade.ts. */
|
||||
function oldObfuscation(cfg, dailyPnL, plan, tradeDate, legAmount) {
|
||||
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, 1000 + realizedToday);
|
||||
if (cfg.minDayStop && plan.dayTarget <= 1700) stop = Math.min(stop, 2 * legAmount);
|
||||
return stop;
|
||||
}
|
||||
|
||||
test('obfuscationStopDollars is unchanged by the move', () => {
|
||||
const r = rng(2);
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const cfg = { firstDayStop: r() < 0.5, minDayStop: r() < 0.5 };
|
||||
const days = Math.floor(r() * 4);
|
||||
const dailyPnL = Array.from({ length: days }, (_, d) => ({ date: `2026-09-0${d + 1}`, pnl: Math.round((r() - 0.4) * 2000) }));
|
||||
const dayTarget = Math.round(r() * 3000);
|
||||
const plan = { dayTarget, remainingToday: Math.round(dayTarget * r()), path: 'normal_day', splitAllowed: true };
|
||||
const leg = Math.round(100 + r() * 1500);
|
||||
assert.equal(obfuscationStopDollars(cfg, dailyPnL, plan, '2026-09-08', leg), oldObfuscation(cfg, dailyPnL, plan, '2026-09-08', leg));
|
||||
}
|
||||
});
|
||||
|
||||
/** isInNoTradeWindow as it was, with the clock and settings passed in. */
|
||||
function oldNoTrade(now, mode, jitter) {
|
||||
const centralParts = new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: 'numeric', hour12: false, timeZone: 'America/Chicago' }).formatToParts(now);
|
||||
const hour = parseInt(centralParts.find((p) => p.type === 'hour').value, 10);
|
||||
const minute = parseInt(centralParts.find((p) => p.type === 'minute').value, 10);
|
||||
const day = new Intl.DateTimeFormat('en-US', { weekday: 'short', timeZone: 'America/Chicago' }).format(now);
|
||||
const timeMinutes = hour * 60 + minute;
|
||||
if (mode === 'equity_hours') {
|
||||
if (day === 'Sat' || day === 'Sun') return true;
|
||||
const open = 8 * 60 + 35 + jitter;
|
||||
const close = 14 * 60 + 55;
|
||||
return timeMinutes < open || timeMinutes >= close;
|
||||
}
|
||||
if (day === 'Sat') return true;
|
||||
const cmeOpen = 17 * 60 + 5 + jitter;
|
||||
if (day === 'Sun') return timeMinutes < cmeOpen;
|
||||
const cmeClose = 14 * 60 + 55;
|
||||
if (day === 'Fri' && timeMinutes >= cmeClose) return true;
|
||||
if (timeMinutes >= cmeClose && timeMinutes < cmeOpen) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function oldAfterClose(now) {
|
||||
const parts = new Intl.DateTimeFormat('en-US', { weekday: 'short', hour: 'numeric', minute: 'numeric', hour12: false, timeZone: 'America/Chicago' }).formatToParts(now);
|
||||
const get = (type) => parts.find((p) => p.type === type)?.value ?? '';
|
||||
if (get('weekday') === 'Sat' || get('weekday') === 'Sun') return false;
|
||||
const minutes = (parseInt(get('hour'), 10) % 24) * 60 + parseInt(get('minute'), 10);
|
||||
return minutes >= 14 * 60 + 55 && minutes < 17 * 60;
|
||||
}
|
||||
|
||||
test('CME trading hours are unchanged, across both DST regimes', () => {
|
||||
for (const start of [Date.UTC(2026, 8, 20), Date.UTC(2026, 0, 11)]) { // a September and a January week
|
||||
for (let m = 0; m < 7 * 24 * 60; m += 7) {
|
||||
const now = new Date(start + m * 60_000);
|
||||
for (const mode of ['full_cme', 'equity_hours']) {
|
||||
for (const jitter of [1, 13, 20]) {
|
||||
assert.equal(isNoTradeTime(now, { everyDay: false, mode, jitterMin: jitter }), oldNoTrade(now, mode, jitter), `${now.toISOString()} ${mode} ${jitter}`);
|
||||
}
|
||||
}
|
||||
assert.equal(isPastSessionClose(now, false), oldAfterClose(now), now.toISOString());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── every-day hours ──────────────────────────────────────────────────────────
|
||||
|
||||
const ct = (iso, offsetHours) => new Date(Date.parse(`${iso}Z`) + offsetHours * 3600_000);
|
||||
const cdt = (iso) => ct(iso, 5); // Central daylight: UTC−5
|
||||
const cst = (iso) => ct(iso, 6); // Central standard: UTC−6
|
||||
|
||||
test('every-day firms close only around the roll (CDT)', () => {
|
||||
const o = { everyDay: true, mode: 'full_cme', jitterMin: 7 };
|
||||
assert.equal(isNoTradeTime(cdt('2026-09-26T12:00:00'), o), false); // Saturday noon
|
||||
assert.equal(isNoTradeTime(cdt('2026-09-27T09:00:00'), o), false); // Sunday morning
|
||||
assert.equal(isNoTradeTime(cdt('2026-09-28T16:54:00'), o), false);
|
||||
assert.equal(isNoTradeTime(cdt('2026-09-28T16:55:00'), o), true);
|
||||
assert.equal(isNoTradeTime(cdt('2026-09-28T17:11:00'), o), true);
|
||||
assert.equal(isNoTradeTime(cdt('2026-09-28T17:12:00'), o), false);
|
||||
assert.equal(isPastSessionClose(cdt('2026-09-26T16:56:00'), true), true);
|
||||
assert.equal(isPastSessionClose(cdt('2026-09-26T17:01:00'), true), false);
|
||||
});
|
||||
|
||||
test('every-day firms close from 3:55 PM in CST, covering the 4 PM firm reset', () => {
|
||||
const o = { everyDay: true, mode: 'equity_hours', jitterMin: 1 };
|
||||
assert.equal(isNoTradeTime(cst('2027-01-12T15:54:00'), o), false);
|
||||
assert.equal(isNoTradeTime(cst('2027-01-12T15:55:00'), o), true);
|
||||
assert.equal(isNoTradeTime(cst('2027-01-12T16:30:00'), o), true);
|
||||
assert.equal(isNoTradeTime(cst('2027-01-12T17:05:00'), o), true);
|
||||
assert.equal(isNoTradeTime(cst('2027-01-12T17:06:00'), o), false);
|
||||
assert.equal(isNoTradeTime(cst('2027-01-16T12:00:00'), o), false); // Saturday
|
||||
assert.equal(isPastSessionClose(cst('2027-01-12T16:10:00'), true), true);
|
||||
});
|
||||
|
||||
// ── fees ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
test('percentFee rounds each side up to the cent', () => {
|
||||
assert.equal(percentFee(0.04, 60000, 1, 1), 24);
|
||||
assert.equal(percentFee(0.04, 100000, 0.2, 1), 8);
|
||||
assert.equal(percentFee(0.04, 123.45, 0.001, 7), 0.01); // 0.00035 → a cent
|
||||
assert.equal(percentFee(0, 60000, 1, 1), 0);
|
||||
});
|
||||
|
||||
test('percent-fee take-profits net at least the leg, and one tick less does not', () => {
|
||||
const r = rng(3);
|
||||
for (let i = 0; i < 3000; i++) {
|
||||
const side = r() < 0.5 ? 'Buy' : 'Sell';
|
||||
const tick = [0.01, 0.1, 0.5, 1][Math.floor(r() * 4)];
|
||||
const entry = Math.round((50 + r() * 100000) / tick) * tick;
|
||||
const step = [0.001, 0.01, 1][Math.floor(r() * 3)];
|
||||
const contracts = 1 + Math.floor(r() * 400);
|
||||
const pct = [0.04, 0.02, 0.1][Math.floor(r() * 3)];
|
||||
const leg = Math.round(5 + r() * 2000);
|
||||
const entryFee = percentFee(pct, entry, step, contracts);
|
||||
const tp = takeProfitPlan({ side, entry, leg, pointValue: step, contracts, tickSize: tick, commissionPct: pct, entryCommission: entryFee });
|
||||
const k = step * contracts;
|
||||
const dir = side === 'Buy' ? 1 : -1;
|
||||
const net = (ticks) => {
|
||||
const x = entry + dir * ticks * tick;
|
||||
return ticks * tick * k - entryFee - percentFee(pct, x, step, contracts);
|
||||
};
|
||||
assert.ok(net(tp.ticks) >= leg - 1e-6, `short of the leg: ${JSON.stringify({ side, entry, tick, step, contracts, pct, leg, tp })}`);
|
||||
if (tp.ticks > 1) assert.ok(net(tp.ticks - 1) < leg + 0.02, `a tick further than needed: ${JSON.stringify(tp)}`);
|
||||
assert.equal(tp.totalCommission, Math.round((entryFee + tp.exitFee) * 100) / 100);
|
||||
}
|
||||
});
|
||||
|
||||
// ── daily loss limit ─────────────────────────────────────────────────────────
|
||||
|
||||
test('dllRoom: snapshot, fallback, green-day cap, no limit', () => {
|
||||
assert.equal(dllRoom({ dailyLossLimit: 0, closedBalance: 50000, realizedToday: -900 }), Infinity);
|
||||
assert.equal(dllRoom({ dailyLossLimit: 1500, closedBalance: 49600, dayStartBalance: 50000, realizedToday: 0 }), 1100);
|
||||
assert.equal(dllRoom({ dailyLossLimit: 1500, closedBalance: 50600, dayStartBalance: 50000, realizedToday: 0 }), 1500);
|
||||
assert.equal(dllRoom({ dailyLossLimit: 1000, closedBalance: 50000, dayStartBalance: null, realizedToday: -200 }), 800);
|
||||
assert.equal(dllRoom({ dailyLossLimit: 1000, closedBalance: 50000, realizedToday: 350 }), 1000);
|
||||
});
|
||||
|
||||
test('the worked futures example: $1,000 limit, down $200, NQ at $2.14/side', () => {
|
||||
const room = dllRoom({ dailyLossLimit: 1000, closedBalance: 49800, realizedToday: -200 });
|
||||
const d = dllStopDollars({ room, dailyLossLimit: 1000, entryFee: 2.14, commissionPct: 0 });
|
||||
const ticks = stopTicks({ obfuscationDollars: Infinity, dllDollars: d, pointValue: 20, contracts: 1, tickSize: 0.25 });
|
||||
assert.equal(ticks, 139);
|
||||
const loss = ticks * 0.25 * 20 + 2 * 2.14;
|
||||
assert.ok(-200 - loss > -1000 + 100 - 1e-9);
|
||||
});
|
||||
|
||||
test('stopTicks: obfuscation rounds out, the daily-loss stop rounds in, the tighter wins', () => {
|
||||
const base = { pointValue: 20, contracts: 1, tickSize: 0.25 };
|
||||
assert.equal(stopTicks({ ...base, obfuscationDollars: Infinity, dllDollars: Infinity }), null);
|
||||
assert.equal(stopTicks({ ...base, obfuscationDollars: 0, dllDollars: Infinity }), null);
|
||||
assert.equal(stopTicks({ ...base, obfuscationDollars: 1001, dllDollars: Infinity }), 201);
|
||||
assert.equal(stopTicks({ ...base, obfuscationDollars: Infinity, dllDollars: 1000 }), 200);
|
||||
assert.equal(stopTicks({ ...base, obfuscationDollars: Infinity, dllDollars: 999.99 }), 199);
|
||||
assert.equal(stopTicks({ ...base, obfuscationDollars: 600, dllDollars: 1000 }), 120);
|
||||
assert.equal(stopTicks({ ...base, obfuscationDollars: 1600, dllDollars: 1000 }), 200);
|
||||
assert.equal(stopTicks({ ...base, obfuscationDollars: Infinity, dllDollars: 3 }), 0);
|
||||
assert.equal(stopTicks({ ...base, obfuscationDollars: Infinity, dllDollars: -40 }), 0);
|
||||
});
|
||||
|
||||
test('worst case at the stop, both fees paid, never spends past room − reserve', () => {
|
||||
const r = rng(4);
|
||||
for (let i = 0; i < 20000; i++) {
|
||||
const side = r() < 0.5 ? 'Buy' : 'Sell';
|
||||
const pctFirm = r() < 0.6;
|
||||
const pct = pctFirm ? [0.04, 0.1][Math.floor(r() * 2)] : 0;
|
||||
const tick = pctFirm ? [0.01, 0.1, 1][Math.floor(r() * 3)] : 0.25;
|
||||
const pv = pctFirm ? [0.001, 0.01, 1][Math.floor(r() * 3)] : [2, 20, 50][Math.floor(r() * 3)];
|
||||
const contracts = pctFirm ? 1 + Math.floor(r() * 500) : 1 + Math.floor(r() * 3);
|
||||
const entry = Math.round((100 + r() * 90000) / tick) * tick;
|
||||
const dll = [300, 750, 1500, 3000][Math.floor(r() * 4)];
|
||||
const room = dllRoom({ dailyLossLimit: dll, closedBalance: 50000, dayStartBalance: 50000 + Math.round((r() - 0.3) * dll), realizedToday: 0 });
|
||||
const entryFee = pctFirm ? percentFee(pct, entry, pv, contracts) : Math.round(r() * 500) / 100 * contracts;
|
||||
const d = dllStopDollars({ room, dailyLossLimit: dll, entryFee, commissionPct: pct });
|
||||
const ticks = stopTicks({ obfuscationDollars: Infinity, dllDollars: d, pointValue: pv, contracts, tickSize: tick });
|
||||
if (ticks === 0) continue; // flattened instead
|
||||
const stopPrice = side === 'Buy' ? entry - ticks * tick : entry + ticks * tick;
|
||||
const exitFee = pctFirm ? percentFee(pct, stopPrice, pv, contracts) : entryFee;
|
||||
const loss = ticks * tick * pv * contracts + entryFee + exitFee;
|
||||
assert.ok(loss <= room - DLL_RESERVE_FRACTION * dll + 1e-6, JSON.stringify({ side, pct, tick, pv, contracts, entry, dll, room, ticks, loss }));
|
||||
}
|
||||
});
|
||||
|
||||
test('dllViable and botStopsActive', () => {
|
||||
assert.equal(dllViable(Infinity, 0, 0), true);
|
||||
assert.equal(dllViable(1500, 1500, 20), true);
|
||||
assert.equal(dllViable(150 + DLL_MIN_STOP + 20, 1500, 20), true);
|
||||
assert.equal(dllViable(150 + DLL_MIN_STOP + 19.99, 1500, 20), false);
|
||||
assert.equal(botStopsActive({ firstDayStop: false, minDayStop: false }), false);
|
||||
assert.equal(botStopsActive({ firstDayStop: false, minDayStop: false, dailyLossLimit: 1500 }), true);
|
||||
assert.equal(botStopsActive({ firstDayStop: true, minDayStop: false, dailyLossLimit: 0 }), true);
|
||||
});
|
||||
|
||||
// ── sizing ───────────────────────────────────────────────────────────────────
|
||||
|
||||
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 });
|
||||
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 };
|
||||
// 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 });
|
||||
const a = 600 + 2 * 0.0004 * s.notional;
|
||||
const b = stopDollarsAt(s.notional);
|
||||
assert.ok(Math.abs(s.predictedHoldHours - 6) < 0.05, `hold ${s.predictedHoldHours}`);
|
||||
assert.ok(Math.abs(s.winProbability - b / (a + b)) < 1e-9);
|
||||
|
||||
const capped = sizeNotional({ ...base, stopDollarsAt, maxNotional: 25000 });
|
||||
assert.equal(capped.notional, 25000);
|
||||
assert.ok(capped.predictedHoldHours > 6);
|
||||
|
||||
const financed = sizeNotional({ ...base, stopDollarsAt, targetHoldHours: 200, financingPctPerDay: 0.033 });
|
||||
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');
|
||||
});
|
||||
|
||||
test('random-walk simulation agrees with the predicted hold and win rate', () => {
|
||||
const r = rng(5);
|
||||
const notional = 60000;
|
||||
const A = 600;
|
||||
const B = 1350;
|
||||
const sigma = btcSigma;
|
||||
const dtHours = 1 / 60; // one-minute steps
|
||||
const stepDollars = notional * sigma * Math.sqrt(dtHours);
|
||||
const predicted = (A / notional) * (B / notional) / (sigma * sigma);
|
||||
let wins = 0;
|
||||
let totalHours = 0;
|
||||
const paths = 3000;
|
||||
for (let p = 0; p < paths; p++) {
|
||||
let x = 0;
|
||||
let steps = 0;
|
||||
while (x < A && x > -B) {
|
||||
x += r() < 0.5 ? stepDollars : -stepDollars;
|
||||
steps++;
|
||||
}
|
||||
if (x >= A) wins++;
|
||||
totalHours += steps * dtHours;
|
||||
}
|
||||
const meanHold = totalHours / paths;
|
||||
assert.ok(Math.abs(meanHold - predicted) / predicted < 0.1, `mean hold ${meanHold} vs predicted ${predicted}`);
|
||||
assert.ok(Math.abs(wins / paths - B / (A + B)) < 0.03, `win rate ${wins / paths}`);
|
||||
});
|
||||
|
||||
// ── every-day calendar ───────────────────────────────────────────────────────
|
||||
|
||||
test('nextTradingDate and effectiveCombine', () => {
|
||||
assert.equal(nextTradingDate('2026-09-25'), '2026-09-28');
|
||||
assert.equal(nextTradingDate('2026-09-25', true), '2026-09-26');
|
||||
assert.equal(nextTradingDate('2026-12-24', true), '2026-12-25');
|
||||
assert.equal(effectiveCombine({ combineCmeHolidays: true }), true);
|
||||
assert.equal(effectiveCombine({ combineCmeHolidays: true, tradesEveryDay: true }), false);
|
||||
});
|
||||
|
||||
const projCfg = {
|
||||
profitTarget: 3000, consistency: 0, minDayPnL: 0, minTradingDays: 5, accountSize: 50000,
|
||||
targetSameEquity: false, withdrawalStages: [], splitEnabled: true,
|
||||
};
|
||||
|
||||
test('every-day projections use consecutive dates; CME ones still skip weekends', () => {
|
||||
const now = cdt('2026-09-26T12:00:00'); // Saturday noon → trade date Saturday
|
||||
const common = { accountId: 7, cycleDailyPnL: [], fundTransactions: [], balance: 50000, realizedPnL: 0, now };
|
||||
const every = projectPerfectPath({ ...common, cfg: { ...projCfg, tradesEveryDay: true } });
|
||||
const cme = projectPerfectPath({ ...common, cfg: projCfg });
|
||||
assert.ok(every.length > 1);
|
||||
for (let i = 1; i < every.length; i++) assert.equal(every[i].date, nextTradingDate(every[i - 1].date, true));
|
||||
for (const d of cme) {
|
||||
const dow = new Date(`${d.date}T00:00:00Z`).getUTCDay();
|
||||
assert.ok(dow !== 0 && dow !== 6, `CME projection landed on ${d.date}`);
|
||||
}
|
||||
// Saturday itself is booked (silently) for the every-day firm: its first
|
||||
// projected day is Sunday, the CME one's is Monday.
|
||||
assert.equal(every[0].date, '2026-09-27');
|
||||
assert.equal(cme[0].date, '2026-09-28');
|
||||
});
|
||||
|
||||
test('an every-day firm can finish its cycle on a Saturday', () => {
|
||||
const now = cdt('2026-09-26T12:00:00');
|
||||
const cycleDailyPnL = [
|
||||
{ date: '2026-09-21', pnl: 700 }, { date: '2026-09-22', pnl: 700 },
|
||||
{ date: '2026-09-23', pnl: 700 }, { date: '2026-09-24', pnl: 700 },
|
||||
];
|
||||
const opts = {
|
||||
accountId: 7, rawTradeDate: '2026-09-26', cycleDailyPnL, rawRealizedPnL: 400, combineCmeHolidays: false,
|
||||
effective: { profitTarget: 3000, consistency: 0, minTradingDays: 5, minDayPnL: 0, drain: false },
|
||||
equityProfit: 3200, splitEnabled: true, now,
|
||||
};
|
||||
assert.equal(cycleCompletion({ ...opts, tradesEveryDay: true }), 'today');
|
||||
assert.equal(cycleCompletion(opts), 'none');
|
||||
});
|
||||
|
||||
// ── DXTrade helpers (lib/dxtrade-helpers.ts) ─────────────────────────────────
|
||||
|
||||
import {
|
||||
dxAccountId, accountDisplayName, accountCodeOf, encodeContractName, decodeContractName, roundToStep,
|
||||
lastUtcReset, ctTradeDayStart, parseDurationMs, ciNum, signedFundValue, inTradingHours,
|
||||
realizedVolPerHour, scoreCandidate, candidateRejection, MIN_VOL_CANDLES,
|
||||
} from '../lib/dxtrade-helpers.ts';
|
||||
|
||||
test('dxAccountId is stable, safe, far above broker ids, and host-scoped', () => {
|
||||
const a = dxAccountId('dx.tradeify247.co', 'default:TFY-1');
|
||||
assert.equal(a, dxAccountId('DX.Tradeify247.co', 'default:TFY-1'));
|
||||
assert.ok(Number.isSafeInteger(a) && a >= 2 ** 50 && a < 2 ** 53);
|
||||
assert.notEqual(a, dxAccountId('dx.other.co', 'default:TFY-1'));
|
||||
const seen = new Set();
|
||||
for (let i = 0; i < 20000; i++) seen.add(dxAccountId('dx.tradeify247.co', `default:${i}`));
|
||||
assert.equal(seen.size, 20000);
|
||||
});
|
||||
|
||||
test('account codes and contract names', () => {
|
||||
assert.equal(accountDisplayName('default:TFY-50K-12'), 'TFY-50K-12');
|
||||
assert.equal(accountDisplayName('plain'), 'plain');
|
||||
assert.equal(accountCodeOf({ clearing: 'default', account: 'x1' }), 'default:x1');
|
||||
assert.equal(accountCodeOf('default:x1'), 'default:x1');
|
||||
assert.deepEqual(decodeContractName(encodeContractName('BTC/USD', 0.001)), { symbol: 'BTC/USD', step: 0.001 });
|
||||
assert.equal(decodeContractName('NQZ5'), null);
|
||||
assert.equal(roundToStep(0.1 + 0.2, 0.1), 0.3);
|
||||
assert.equal(roundToStep(12 * 0.001, 0.001), 0.012);
|
||||
assert.equal(roundToStep(101.234, 0.5, 'up'), 101.5);
|
||||
assert.equal(roundToStep(101.234, 0.5, 'down'), 101);
|
||||
});
|
||||
|
||||
test('the firm reset and the CT trade-date start', () => {
|
||||
assert.equal(lastUtcReset(new Date('2026-09-26T21:59:00Z')).toISOString(), '2026-09-25T22:00:00.000Z');
|
||||
assert.equal(lastUtcReset(new Date('2026-09-26T22:00:00Z')).toISOString(), '2026-09-26T22:00:00.000Z');
|
||||
assert.equal(ctTradeDayStart(cdt('2026-09-26T18:30:00')).toISOString(), '2026-09-26T22:00:00.000Z');
|
||||
assert.equal(ctTradeDayStart(cdt('2026-09-27T09:00:00')).toISOString(), '2026-09-26T22:00:00.000Z');
|
||||
assert.equal(ctTradeDayStart(cst('2027-01-12T10:00:00')).toISOString(), '2027-01-11T23:00:00.000Z');
|
||||
});
|
||||
|
||||
test('loose parsing', () => {
|
||||
assert.equal(parseDurationMs('PT30M'), 30 * 60_000);
|
||||
assert.equal(parseDurationMs('01:05:08'), (3600 + 300 + 8) * 1000);
|
||||
assert.equal(parseDurationMs({ seconds: 900 }), 900_000);
|
||||
assert.equal(parseDurationMs(undefined), 30 * 60_000);
|
||||
assert.equal(ciNum({ openPL: 12.5 }, 'openPl'), 12.5);
|
||||
assert.equal(ciNum({ openPL: '12' }, 'openPl'), undefined);
|
||||
assert.equal(signedFundValue('WITHDRAWAL', 500), -500);
|
||||
assert.equal(signedFundValue('WITHDRAWAL', -500), -500);
|
||||
assert.equal(signedFundValue('DEPOSIT', 50000), 50000);
|
||||
});
|
||||
|
||||
test('trading hours from UTC week sessions', () => {
|
||||
const at = (dayOfWeek, hour, eventType) => ({ eventType, weekDay: { dayOfWeek, time: { hour, minute: 30 } } });
|
||||
const weekdays = [at('MONDAY', 13, 'SESSION_OPEN'), at('MONDAY', 20, 'SESSION_CLOSE'), at('TUESDAY', 13, 'SESSION_OPEN'), at('TUESDAY', 20, 'SESSION_CLOSE')];
|
||||
assert.equal(inTradingHours(weekdays, new Date('2026-09-28T15:00:00Z')), true); // Monday
|
||||
assert.equal(inTradingHours(weekdays, new Date('2026-09-28T21:00:00Z')), false);
|
||||
assert.equal(inTradingHours(weekdays, new Date('2026-09-27T15:00:00Z')), false); // Sunday wraps to Tuesday's close
|
||||
assert.equal(inTradingHours([], new Date()), true);
|
||||
assert.equal(inTradingHours([{ garbage: true }], new Date()), true);
|
||||
});
|
||||
|
||||
test('realized volatility of hourly closes', () => {
|
||||
assert.equal(realizedVolPerHour([100, 101, 102]), null);
|
||||
const r = rng(6);
|
||||
const sigma = 0.004;
|
||||
const closes = [30000];
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
// Box–Muller for a normal step
|
||||
const z = Math.sqrt(-2 * Math.log(1 - r())) * Math.cos(2 * Math.PI * r());
|
||||
closes.push(closes[closes.length - 1] * Math.exp(sigma * z));
|
||||
}
|
||||
const v = realizedVolPerHour(closes);
|
||||
assert.ok(Math.abs(v - sigma) / sigma < 0.08, `σ ${v}`);
|
||||
const flat = Array.from({ length: MIN_VOL_CANDLES + 1 }, () => 200);
|
||||
assert.equal(realizedVolPerHour(flat), 0);
|
||||
});
|
||||
|
||||
test('the selector ranks the plan example: ETH, BTC, XYZ100, AAPL', () => {
|
||||
const perHour = (daily) => daily / Math.sqrt(24);
|
||||
const score = (spread, daily) => scoreCandidate({
|
||||
spreadFraction: spread, commissionPct: 0.04, financingPctPerDay: 0, targetHoldHours: 6, volatilityPerHour: perHour(daily),
|
||||
});
|
||||
const board = [
|
||||
['BTC/USD', score(0.0002, 0.03)],
|
||||
['XYZ100', score(0.0001, 0.012)],
|
||||
['AAPL/USD (Sat)', score(0.0005, 0.004)],
|
||||
['ETH/USD', score(0.0003, 0.04)],
|
||||
['AAPL/USD (Tue)', score(0.0005, 0.015)],
|
||||
].sort((a, b) => a[1] - b[1]).map((b) => b[0]);
|
||||
assert.deepEqual(board, ['ETH/USD', 'BTC/USD', 'XYZ100', 'AAPL/USD (Tue)', 'AAPL/USD (Sat)']);
|
||||
// Cost per leg = score × √(A·B/T): the example's $50 and $60.
|
||||
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);
|
||||
assert.equal(scoreCandidate({ spreadFraction: 0.0002, commissionPct: 0.04, financingPctPerDay: 0, targetHoldHours: 6, volatilityPerHour: null }), null);
|
||||
});
|
||||
|
||||
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',
|
||||
};
|
||||
assert.equal(candidateRejection(ok), null);
|
||||
assert.match(candidateRejection({ ...ok, tradingStatus: 'CLOSE_ONLY' }), /CLOSE_ONLY/);
|
||||
assert.match(candidateRejection({ ...ok, inHours: false }), /hours/);
|
||||
assert.match(candidateRejection({ ...ok, bid: undefined }), /quote/);
|
||||
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/);
|
||||
});
|
||||
Reference in New Issue
Block a user