- A batch sizes its leg on every mapped candidate, each at the leverage the broker states for it, and trades the cheapest (spread, commissions, expected swap) that reaches the target hold — else the quickest. The dashboard ranks the same way from a half-hourly candidate survey. - Last stand: when a daily-loss stop-out would leave the account within 15% of that loss above its max-drawdown floor (the account type's static max loss, or the broker's liquidation floor), the leg carries no daily-loss stop and is sized against the firm's own breach. - The daily-loss reserve is 10% of the limit or 0.1% of the position, whichever is more, since a stop's slippage grows with the position. - The account type's max notional is gone: the broker's margin rates cap each symbol. planLegSize holds the one sizing rule fireLeg, the pick and the dashboard share. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
670 lines
39 KiB
JavaScript
670 lines
39 KiB
JavaScript
// 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, DEFAULT_TARGET_HOLD_HOURS,
|
||
projectPerfectPath, cycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, notionalCap, expectedSwapToWin, MARGIN_HEADROOM,
|
||
dllReserve, DLL_SLIPPAGE_FRACTION, maxLossRoom, lastStand, breachDistance, LAST_STAND_FRACTION, planLegSize, rankForTarget,
|
||
} 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, maxNotional: 0 });
|
||
for (const [hours, notional] of [[54, 20000], [24, 30000], [6, 60000]]) {
|
||
const s = at(hours);
|
||
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, 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 });
|
||
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);
|
||
|
||
// 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', () => {
|
||
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;
|
||
let winHours = 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++; winHours += steps * dtHours; }
|
||
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}`);
|
||
// Winners take longer than the average leg: (A² + 2AB) / 3σ$² — what
|
||
// expectedSwapToWin charges swap over.
|
||
const predictedWin = (A * A + 2 * A * B) / (3 * (notional * sigma) ** 2);
|
||
assert.ok(Math.abs(winHours / wins - predictedWin) / predictedWin < 0.1, `winners ${winHours / wins}h vs ${predictedWin}h`);
|
||
});
|
||
|
||
test('expected swap to win: a fixed point over the winners\' hold, zero without swap, capped', () => {
|
||
const sigma = 0.022 / Math.sqrt(24); // ETH over a week, ~2.2%/day
|
||
const o = { distance: 12400, stopDollars: 2300, notional: 200000, volatilityPerHour: sigma, financingPctPerDay: 0.033 };
|
||
const swap = expectedSwapToWin(o);
|
||
const a = o.distance + swap;
|
||
const b = o.stopDollars;
|
||
const winHours = (a * a + 2 * a * b) / (3 * (o.notional * sigma) ** 2);
|
||
assert.ok(Math.abs(swap - 0.00033 * o.notional * (winHours / 24)) < 0.02, `swap ${swap} over ${winHours}h`);
|
||
assert.ok(swap > 200 && swap < 400, `swap ${swap}`);
|
||
// Quieter means a longer hold and more swap; none without a rate or σ.
|
||
assert.ok(expectedSwapToWin({ ...o, volatilityPerHour: sigma * 0.6 }) > swap);
|
||
assert.equal(expectedSwapToWin({ ...o, financingPctPerDay: 0 }), 0);
|
||
assert.equal(expectedSwapToWin({ ...o, volatilityPerHour: undefined }), 0);
|
||
// Stopless reads as B = A: a longer walk than with the stop.
|
||
assert.ok(expectedSwapToWin({ ...o, stopDollars: Infinity }) > swap);
|
||
// A leg that can barely move is capped at a quarter of its distance.
|
||
assert.equal(expectedSwapToWin({ ...o, volatilityPerHour: sigma / 50 }), 3100);
|
||
});
|
||
|
||
test('the notional cap: what the available funds can margin at the symbol\'s rate', () => {
|
||
assert.equal(notionalCap({ marginRate: 0.2, availableFunds: 100000 }), MARGIN_HEADROOM * 500000);
|
||
assert.equal(notionalCap({ marginRate: 0.5, availableFunds: 99757.39 }), MARGIN_HEADROOM * 99757.39 * 2);
|
||
assert.equal(notionalCap({ marginRate: 0.2, availableFunds: undefined }), 0);
|
||
assert.equal(notionalCap({ availableFunds: 100000 }), 0);
|
||
});
|
||
|
||
test('the daily-loss reserve grows with the position past 10% of the limit', () => {
|
||
assert.equal(dllReserve(3000), 300);
|
||
assert.equal(dllReserve(3000, 200000), 300);
|
||
assert.equal(dllReserve(3000, 485000), 485000 * DLL_SLIPPAGE_FRACTION);
|
||
const at = (notional) => dllStopDollars({ room: 3000, dailyLossLimit: 3000, entryFee: 0.0004 * notional, commissionPct: 0.04, notional });
|
||
// Below $300k the flat 10% binds; above, 0.1% of notional does.
|
||
assert.ok(Math.abs(at(200000) - (3000 - 300 - 160 - 0.01) / 1.0004) < 1e-9);
|
||
assert.ok(Math.abs(at(485000) - (3000 - 485 - 388 - 0.01) / 1.0004) < 1e-9);
|
||
// No notional given: the old flat reserve.
|
||
assert.equal(dllStopDollars({ room: 3000, dailyLossLimit: 3000, entryFee: 0, commissionPct: 0 }), 2699.99);
|
||
});
|
||
|
||
test('max-drawdown room: the static floor, the broker floor, the higher', () => {
|
||
assert.equal(maxLossRoom({ balance: 99757.39, accountSize: 100000, maxLoss: 6000 }), 5757.39);
|
||
assert.equal(maxLossRoom({ balance: 99757.39, accountSize: 100000, maxLoss: -6000 }), 5757.39);
|
||
assert.equal(maxLossRoom({ balance: 51000, accountSize: 50000, maxLoss: 0, autoLiqThreshold: 48500 }), 2500);
|
||
assert.equal(maxLossRoom({ balance: 51000, accountSize: 50000, maxLoss: 2000, autoLiqThreshold: 49000 }), 2000);
|
||
assert.equal(maxLossRoom({ balance: 99000, accountSize: 100000, maxLoss: 0 }), null);
|
||
});
|
||
|
||
test('last stand: no daily-loss stop once a stop-out would leave under 15% of it', () => {
|
||
// Full day: a stop-out spends $2,700 (room less the $300 reserve).
|
||
const at = (maxRoom) => lastStand({ dllRoom: 3000, dailyLossLimit: 3000, maxLossRoom: maxRoom });
|
||
assert.equal(LAST_STAND_FRACTION, 0.15);
|
||
assert.equal(at(5757.39), false, 'a fresh 1-Step keeps its stop');
|
||
assert.equal(at(2700 * 1.15 + 1), false, 'just over 15% left: keep the stop');
|
||
assert.equal(at(2700 * 1.15), true, '15% left: last stand');
|
||
assert.equal(at(3000), true, 'two stopped days in: last stand');
|
||
assert.equal(at(800), true, 'floor nearer than the stop: last stand');
|
||
assert.equal(at(null), false, 'no floor known');
|
||
assert.equal(lastStand({ dllRoom: Infinity, dailyLossLimit: 0, maxLossRoom: 500 }), false, 'no daily limit, no daily stop to drop');
|
||
// The far side of the walk is the nearer floor, net of the entry fee.
|
||
assert.equal(breachDistance({ dllRoom: 3000, maxLossRoom: 2900, entryFee: 120 }), 2780);
|
||
assert.equal(breachDistance({ dllRoom: 2757.39, maxLossRoom: null, entryFee: 0 }), 2757.39);
|
||
});
|
||
|
||
test('planLegSize: the stop, a last stand, the margin cap and the cost, as fireLeg trades', () => {
|
||
const eth = { stepNotional: 26.86, volatilityPerHour: 0.022 / Math.sqrt(24), spreadFraction: 0.00004, marginRate: 0.2 };
|
||
const base = {
|
||
leg: 12239, contract: eth, commissionPct: 0.04, targetHoldHours: 12, financingPctPerDay: 0.033,
|
||
dailyLossLimit: 3000, dllRoom: 2757.39, obfuscationDollars: Infinity, availableFunds: 99757.39,
|
||
};
|
||
const fresh = planLegSize({ ...base, maxLossRoom: 5757.39 });
|
||
assert.equal(fresh.lastStand, false);
|
||
assert.ok(Math.abs(fresh.sizing.predictedHoldHours - 12) < 0.1, `hold ${fresh.sizing.predictedHoldHours}`);
|
||
const n = fresh.sizing.notional;
|
||
assert.ok(Math.abs(fresh.walkAt(n) - dllStopDollars({ room: 2757.39, dailyLossLimit: 3000, entryFee: 0.0004 * n, commissionPct: 0.04, notional: n })) < 1e-9);
|
||
const expectCost = n * (0.00004 + 0.0008) + 0.00033 * n * (fresh.sizing.predictedHoldHours / 24);
|
||
assert.ok(Math.abs(fresh.cost - expectCost) < 1e-6);
|
||
|
||
// Two days stopped out: a stop-out would leave ~$300 above the floor.
|
||
const stand = planLegSize({ ...base, dllRoom: 3000, maxLossRoom: 3000 });
|
||
assert.equal(stand.lastStand, true);
|
||
assert.equal(stand.walkAt(300000), 3000 - 120);
|
||
assert.ok(stand.sizing.winProbability > fresh.sizing.winProbability, 'the whole room, no reserve: a better chance');
|
||
// An obfuscation stop still counts on a last stand.
|
||
assert.equal(planLegSize({ ...base, dllRoom: 3000, maxLossRoom: 3000, obfuscationDollars: 1000 }).walkAt(300000), 1000);
|
||
|
||
// A 2:1 stock can't reach 12 h in a quiet market: the cap binds.
|
||
const stock = planLegSize({ ...base, maxLossRoom: 5757.39, contract: { ...eth, marginRate: 0.5, volatilityPerHour: 0.0076 / Math.sqrt(24) } });
|
||
assert.ok(Math.abs(stock.sizing.notional - stock.cap) < 1e-6);
|
||
assert.ok(stock.sizing.predictedHoldHours > 12);
|
||
// Futures (no stepNotional) aren't sized by notional.
|
||
assert.equal(planLegSize({ ...base, maxLossRoom: null, contract: {} }).sizing, null);
|
||
});
|
||
|
||
test('rankForTarget: reach the target hold first, cheapest; else quickest', () => {
|
||
const order = rankForTarget([
|
||
{ holdHours: 12, cost: 330 }, // ETH
|
||
{ holdHours: 12.1, cost: 300 }, // BTC — within the 2% tolerance
|
||
{ holdHours: 40, cost: 250 }, // XYZ100, capped at 2:1
|
||
{ holdHours: 20, cost: 400 }, // SP500, capped
|
||
], 12);
|
||
assert.deepEqual(order, [1, 0, 3, 2]);
|
||
assert.deepEqual(rankForTarget([{ holdHours: 30, cost: 1 }, { holdHours: 25, cost: 9 }], 12), [1, 0]);
|
||
assert.deepEqual(rankForTarget([], 12), []);
|
||
});
|
||
|
||
test('sizing carries the swap reserve into the take-profit distance', () => {
|
||
const sigma = 0.0124 / Math.sqrt(24);
|
||
const stopDollarsAt = (n) => dllStopDollars({ room: 2760.15, dailyLossLimit: 3000, entryFee: 0.0004 * n, commissionPct: 0.04 });
|
||
const base = { leg: 12239, stopDollarsAt, volatilityPerHour: sigma, targetHoldHours: 12, commissionPct: 0.04, spreadFraction: 0.0001 };
|
||
const withSwap = sizeNotional({ ...base, financingPctPerDay: 0.033, maxNotional: 200000 });
|
||
const noSwap = sizeNotional({ ...base, financingPctPerDay: 0, maxNotional: 200000 });
|
||
assert.equal(noSwap.swapReserve, 0);
|
||
assert.ok(withSwap.swapReserve > 500, `swap ${withSwap.swapReserve}`);
|
||
const a = 12239 + 2 * 0.0004 * 200000 + withSwap.swapReserve;
|
||
const b = stopDollarsAt(200000);
|
||
assert.ok(Math.abs(withSwap.predictedHoldHours - (a / 200000) * (b / 200000) / (sigma * sigma)) < 1e-6);
|
||
assert.ok(withSwap.predictedHoldHours > noSwap.predictedHoldHours);
|
||
assert.ok(withSwap.winProbability < noSwap.winProbability);
|
||
});
|
||
|
||
// ── 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, parseTransfers, marginRateOf,
|
||
} from '../lib/dxtrade-helpers.ts';
|
||
|
||
test('transfers: a close\'s SETTLEMENT and COMMISSION share a code and both count', () => {
|
||
// As Tradeify247 answered for a flatten.
|
||
const body = { cashTransfers: [
|
||
{ account: 'default:1S100K2166398', transferCode: '2354051:1417097', transactionTime: '2026-09-26T22:33:39.615Z', cashTransactions: [
|
||
{ account: 'default:1S100K2166398', transactionCode: '2354051:1417097', type: 'COMMISSION', value: -79.95, transactionTime: '2026-09-26T22:33:39.615Z', orderCode: 'x:af-1-e' },
|
||
] },
|
||
{ account: 'default:1S100K2166398', transferCode: '2354051:1417102', transactionTime: '2026-09-26T22:33:42.197Z', cashTransactions: [
|
||
{ account: 'default:1S100K2166398', transactionCode: '2354051:1417102', type: 'SETTLEMENT', value: -5.43, transactionTime: '2026-09-26T22:33:42.197Z', orderCode: 'x:af-2-x' },
|
||
{ account: 'default:1S100K2166398', transactionCode: '2354051:1417102', type: 'COMMISSION', value: -79.95, transactionTime: '2026-09-26T22:33:42.197Z', orderCode: 'x:af-2-x' },
|
||
] },
|
||
] };
|
||
const txns = parseTransfers(body);
|
||
assert.equal(txns.length, 3);
|
||
const byKey = new Map(txns.map((t) => [t.key, t]));
|
||
assert.equal(byKey.size, 3, 'every transaction keeps its own key');
|
||
assert.equal(Math.round([...byKey.values()].reduce((s, t) => s + t.value, 0) * 100) / 100, -165.33);
|
||
// The same answer again (the next sync, another page) keys identically.
|
||
assert.deepEqual(parseTransfers(body).map((t) => t.key), txns.map((t) => t.key));
|
||
// Two of one type in one transfer stay apart too.
|
||
const twice = { cashTransfers: [{ transferCode: 't', cashTransactions: [
|
||
{ account: 'default:A', transactionCode: 'c', type: 'COMMISSION', value: -1, transactionTime: '2026-09-26T00:00:00Z' },
|
||
{ account: 'default:A', transactionCode: 'c', type: 'COMMISSION', value: -2, transactionTime: '2026-09-26T00:00:00Z' },
|
||
] }] };
|
||
assert.equal(new Set(parseTransfers(twice).map((t) => t.key)).size, 2);
|
||
});
|
||
|
||
test('margin rates: flat and tiered, the steepest tier, junk ignored', () => {
|
||
assert.equal(marginRateOf({ marginRate: { rateType: 'FLAT', tiers: [{ endVolume: 'Infinity', value: 0.2 }] } }), 0.2);
|
||
assert.equal(marginRateOf({ marginRate: { rateType: 'FLAT', tiers: [{ endVolume: 'Infinity', value: 0.5 }] } }), 0.5);
|
||
assert.equal(marginRateOf({ marginRate: { tiers: [{ endVolume: 1e6, value: 0.1 }, { endVolume: 'Infinity', value: 0.25 }] } }), 0.25);
|
||
assert.equal(marginRateOf({ marginRate: { value: 0.05 } }), 0.05);
|
||
assert.equal(marginRateOf({}), undefined);
|
||
assert.equal(marginRateOf(undefined), undefined);
|
||
assert.equal(marginRateOf({ marginRate: { tiers: [{ value: 'x' }, { value: 0 }, { value: 3 }] } }), undefined);
|
||
});
|
||
|
||
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);
|
||
// 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,
|
||
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.match(candidateRejection({ ...ok, currency: 'EUR' }), /EUR/);
|
||
});
|
||
|
||
// ── Hyperliquid price fallback (lib/hyperliquid-feed.ts) ─────────────────────
|
||
|
||
import { hyperliquidCoinFor, parseL2Book } from '../lib/hyperliquid-feed.ts';
|
||
|
||
test('DXtrade symbols map to Hyperliquid coins', () => {
|
||
const coins = new Set(['BTC', 'ETH', 'xyz:XYZ100', 'xyz:SP500', 'xyz:AAPL']);
|
||
assert.equal(hyperliquidCoinFor('BTC/USD', coins), 'BTC');
|
||
assert.equal(hyperliquidCoinFor('ETH/USD', coins), 'ETH');
|
||
assert.equal(hyperliquidCoinFor('XYZ100', coins), 'xyz:XYZ100');
|
||
assert.equal(hyperliquidCoinFor('SP500', coins), 'xyz:SP500');
|
||
assert.equal(hyperliquidCoinFor('AAPL/USD', coins), 'xyz:AAPL');
|
||
assert.equal(hyperliquidCoinFor('DOGE/USD', coins), null);
|
||
});
|
||
|
||
test('l2Book best bid/ask', () => {
|
||
const q = parseL2Book({ levels: [[{ px: '84310.0' }, { px: '84309' }], [{ px: '84311.0' }]], time: 1790461870878 });
|
||
assert.deepEqual(q, { bid: 84310, ask: 84311, time: 1790461870878 });
|
||
const empty = parseL2Book({ levels: [[], []] });
|
||
assert.equal(empty.bid, undefined);
|
||
assert.equal(empty.ask, undefined);
|
||
});
|