A sync reads the balance first and each account's positions and trades after it, so a fill landing in between shows in the trades but not yet in the balance, and for that one pass the books are off by the whole fill. reconcileFunds booked that as a fund event, and the next pass booked the reversal. Same-day rows merge, so the two netted to the commission drift of the day and left a tiny row: - 2026-10-05, Express 43788281 and 23275136: +3,001.16 "deposit" (balance -2.16 against the -3,001.16 stop-out already in the trades), then -3,002.16, merged to -1.00. - 2026-10-08, combine 99745612: +2,474.01, then -2,492.01, left -18.00. - 2026-09-24, five 150K combines on another instance: +3,049.45, then -3,059.45, left -10.00 each. A negative row is a withdrawal. It started a new cycle, dropped its day out of the cycle (the -3,003.32 on 10-05) and counted toward the stage: both Express accounts traded drain days from 10-06 on a $1 "payout". - fundReconcileStep (trading-logic): an unexplained balance is booked only when a check at least 30s after the first sighting still sees it, to within $5. A transient is gone by the next pass and books nothing. - A transient that outlasts that, and so books, is taken back whole once its reversal nets the day under FUND_EVENT_MIN. The day's first booking saves what it replaced (fund_day_before), and dropFundTransaction deletes the row and puts last_fund_date and ended_cycle_start back in one transaction. A row reconcileFunds didn't book (an import) merges as before. - funds_reconciled is set only once a check settles, so a first-check lump that waits on a second pass is still booked as the first check. Existing tiny rows are left as they are; cleaning them up is a separate, one-time step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
885 lines
52 KiB
JavaScript
885 lines
52 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, onTick, 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,
|
||
currentTradeDateCT, foldReason,
|
||
} 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;
|
||
};
|
||
}
|
||
|
||
// ── prices on the tick grid ─────────────────────────────────────────────────
|
||
|
||
test('onTick: 2026-10-02 GC short — 87 ticks below 4217.6 is 4208.9, not 4208.900000000001', () => {
|
||
assert.notEqual(4217.6 - 87 * 0.1, 4208.9); // the fuzz TopstepX refused
|
||
const tp = takeProfitPlan({ side: 'Sell', entry: 4217.6, leg: 861.4, pointValue: 100, contracts: 1, tickSize: 0.1, commissionPct: 0, entryCommission: 2.8 });
|
||
assert.equal(tp.ticks, 87);
|
||
assert.equal(tp.exitPrice, 4208.9);
|
||
assert.equal(JSON.stringify(tp.exitPrice), '4208.9');
|
||
});
|
||
|
||
test('onTick: measured-fee take-profits always land on the grid, as the shortest decimal', () => {
|
||
const r = rng(11);
|
||
for (let i = 0; i < 5000; i++) {
|
||
const side = r() < 0.5 ? 'Buy' : 'Sell';
|
||
const [pv, tick] = [[100, 0.1], [1000, 0.01], [10, 0.1], [12.5, 0.0001], [1000, 0.03125], [50, 0.25]][Math.floor(r() * 6)];
|
||
const decimals = String(tick).split('.')[1].length;
|
||
const entry = Number((Math.round((50 + r() * 20000) / tick) * tick).toFixed(decimals));
|
||
const tp = takeProfitPlan({ side, entry, leg: 5 + r() * 2500, pointValue: pv, contracts: 1 + Math.floor(r() * 4), tickSize: tick, commissionPct: 0, entryCommission: Math.round(r() * 1000) / 100 });
|
||
assert.equal(tp.exitPrice, Number(tp.exitPrice.toFixed(decimals)), `${tp.exitPrice} has more decimals than tick ${tick}`);
|
||
assert.equal(Math.round(Math.abs(tp.exitPrice - entry) / tick), tp.ticks);
|
||
}
|
||
});
|
||
|
||
test('onTick: an averaged fill between ticks goes the given way; fuzz never moves a tick', () => {
|
||
assert.equal(onTick(7749.125, 0.25, 'up'), 7749.25); // 2 ES filled at .00 and .25
|
||
assert.equal(onTick(7749.125, 0.25, 'down'), 7749);
|
||
assert.equal(onTick(4208.900000000001, 0.1, 'up'), 4208.9);
|
||
assert.equal(onTick(4208.899999999999, 0.1, 'down'), 4208.9);
|
||
assert.equal(onTick(62.07, 0.01, 'up'), 62.07);
|
||
assert.equal(onTick(123.5, 0, 'up'), 123.5); // no tick size known: untouched
|
||
});
|
||
|
||
// ── 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);
|
||
// The same price, minus the old formula's float fuzz.
|
||
assert.equal(got.exitPrice, Math.round(want.exitPrice * 1e10) / 1e10);
|
||
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 + 45 + 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: 5:45 PM plus the delay to 2:55 PM, 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 [0, 1, 13, 20, 95, 180]) {
|
||
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('the evening session opens at 5:45 PM CT, and a 180-minute delay makes it 8:45', () => {
|
||
const o = { everyDay: false, mode: 'full_cme', jitterMin: 180 };
|
||
assert.equal(isNoTradeTime(cdt('2026-09-28T17:45:00'), o), true); // Monday
|
||
assert.equal(isNoTradeTime(cdt('2026-09-28T20:44:00'), o), true);
|
||
assert.equal(isNoTradeTime(cdt('2026-09-28T20:45:00'), o), false);
|
||
assert.equal(isNoTradeTime(cdt('2026-09-27T20:44:00'), o), true); // Sunday too
|
||
assert.equal(isNoTradeTime(cdt('2026-09-27T20:45:00'), o), false);
|
||
assert.equal(isNoTradeTime(cdt('2026-09-29T10:00:00'), o), false); // the day session is untouched
|
||
// No delay: 5:45 PM — not 5:05, while Tradovate rolls the accounts' days.
|
||
const now = { ...o, jitterMin: 0 };
|
||
assert.equal(isNoTradeTime(cdt('2026-09-28T17:05:00'), now), true);
|
||
assert.equal(isNoTradeTime(cdt('2026-09-28T17:44:00'), now), true);
|
||
assert.equal(isNoTradeTime(cdt('2026-09-28T17:45:00'), now), false);
|
||
// Every-day firms keep their own 5:05 PM end of the roll guard.
|
||
assert.equal(isNoTradeTime(cdt('2026-09-28T17:05:00'), { ...now, everyDay: true }), false);
|
||
});
|
||
|
||
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 hitting the daily limit would leave 15% of it or less', () => {
|
||
// A full day's $3,000 of room.
|
||
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(3000 * 1.15 + 1), false, 'just over 15% left: keep the stop');
|
||
assert.equal(at(3000 * 1.15), true, '15% left: last stand');
|
||
assert.equal(at(3294.70), true, 'one stopped day in: $294.70 left, 9.8% — last stand');
|
||
assert.equal(at(800), true, 'floor nearer than the limit: last stand');
|
||
assert.equal(at(null), false, 'no floor known');
|
||
// Measured against what's left of the day: $1,000 of room, $1,200 to the floor.
|
||
assert.equal(lastStand({ dllRoom: 1000, dailyLossLimit: 3000, maxLossRoom: 1200 }), false);
|
||
assert.equal(lastStand({ dllRoom: 1000, dailyLossLimit: 3000, maxLossRoom: 1150 }), true);
|
||
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('planLegSize: the broker\'s order size limits bind the sizing, so the pick sees the real hold', () => {
|
||
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, maxLossRoom: 5757.39, obfuscationDollars: Infinity, availableFunds: 99757.39,
|
||
};
|
||
const free = planLegSize(base);
|
||
// A largest order well under the ideal size: the notional stops there and
|
||
// the hold grows past the target (the margin cap stays what it was).
|
||
const capped = planLegSize({ ...base, contract: { ...eth, maxContracts: 5000 } });
|
||
assert.ok(Math.abs(capped.sizing.notional - 5000 * 26.86) < 1e-6, `notional ${capped.sizing.notional}`);
|
||
assert.ok(capped.sizing.notional < free.sizing.notional);
|
||
assert.ok(capped.sizing.predictedHoldHours > 12, `hold ${capped.sizing.predictedHoldHours}`);
|
||
assert.equal(capped.cap, free.cap);
|
||
// One that doesn't bind changes nothing.
|
||
assert.equal(planLegSize({ ...base, contract: { ...eth, maxContracts: 1e9 } }).sizing.notional, free.sizing.notional);
|
||
// A smallest order above the ideal size: the order is that big.
|
||
const floored = planLegSize({ ...base, contract: { ...eth, minContracts: 20000 } });
|
||
assert.ok(Math.abs(floored.sizing.notional - 20000 * 26.86) < 1e-6);
|
||
assert.ok(floored.sizing.predictedHoldHours < 12);
|
||
// Ranked on the capped hold, a capped instrument no longer wins on cost.
|
||
const order = rankForTarget([
|
||
{ holdHours: capped.sizing.predictedHoldHours, cost: capped.cost },
|
||
{ holdHours: free.sizing.predictedHoldHours, cost: free.cost + 50 },
|
||
], 12);
|
||
assert.deepEqual(order, [1, 0]);
|
||
});
|
||
|
||
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, firmDayOf, 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('the firm\'s day: the same label as the CT trade date, rolling at its reset', () => {
|
||
// Summer: the reset is 5 PM CDT, so the two agree on both sides of it.
|
||
assert.equal(firmDayOf(new Date('2026-09-26T21:59:00Z')), '2026-09-26');
|
||
assert.equal(firmDayOf(new Date('2026-09-26T22:00:00Z')), '2026-09-27');
|
||
assert.equal(firmDayOf(new Date('2026-09-26T22:00:00Z')), currentTradeDateCT(new Date('2026-09-26T22:00:00Z')));
|
||
// Winter: a close at 4:30 PM CST is the firm's next day, still ours.
|
||
const close = cst('2027-01-12T16:30:00');
|
||
assert.equal(firmDayOf(close), '2027-01-13');
|
||
assert.equal(currentTradeDateCT(close), '2027-01-12');
|
||
assert.equal(firmDayOf(cst('2027-01-12T15:59:00')), '2027-01-12');
|
||
assert.equal(firmDayOf(cst('2027-01-12T17:30:00')), currentTradeDateCT(cst('2027-01-12T17:30:00')));
|
||
});
|
||
|
||
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);
|
||
});
|
||
|
||
test('tick reasons fold their amounts, so like reasons count together', () => {
|
||
assert.equal(foldReason('day done ($12.5 left of $300)'), 'day done ($… left of $…)');
|
||
assert.equal(foldReason('day done ($40 left of $1,500)'), 'day done ($… left of $…)');
|
||
assert.equal(foldReason('already traded today (realizedPnL=-123.45)'), 'already traded today (realizedPnL=…)');
|
||
assert.equal(foldReason('daily loss limit: $294.7 of $3000 left — day booked'), 'daily loss limit: $… of $… left — day booked');
|
||
assert.equal(foldReason("waiting for the broker to roll the account's day"), "waiting for the broker to roll the account's day");
|
||
assert.equal(foldReason('leg cap reached (5 today)'), 'leg cap reached (5 today)');
|
||
});
|
||
|
||
// ── fund events from the balance (TopstepX reconcileFunds) ──────────────────
|
||
|
||
import { fundReconcileStep, FUND_CONFIRM_MS } from '../lib/trading-logic.ts';
|
||
|
||
/** reconcileFunds' bookkeeping for one trade date, pass by pass: `balance`
|
||
* and `trades` (all trade P&L, today's included) as each pass read them.
|
||
* `existing` is a row already on the day that reconcileFunds didn't book. */
|
||
function reconcileDay(passes, existing = null) {
|
||
let row = existing;
|
||
let undoable = false; // the day's first booking saves what it replaces
|
||
let sighting = null;
|
||
const steps = [];
|
||
for (const { at, balance, trades } of passes) {
|
||
const delta = Math.round((balance - trades - (row ?? 0)) * 100) / 100;
|
||
const step = fundReconcileStep({ delta, sighting, now: at * 1000, existingToday: row, undoable });
|
||
steps.push(step);
|
||
if (step.kind === 'unconfirmed') { sighting = step.sighting; continue; }
|
||
sighting = null;
|
||
if (step.kind === 'book') { if (row === null) undoable = true; row = step.amount; }
|
||
if (step.kind === 'drop') { row = null; undoable = false; }
|
||
}
|
||
return { row, kinds: steps.map((s) => s.kind), steps };
|
||
}
|
||
|
||
test('fund reconcile: a balance read before a fill the trades already show books nothing', () => {
|
||
// 2026-10-05, EXPRESS-V2-CT-DLL-39029-23275136: the pass read the balance
|
||
// (−2.16), then the trades with the −3,001.16 stop-out in them. One pass
|
||
// later the balance had it (−3,004.32, $1.00 of commission off-book).
|
||
// Booked as it came, that was +3,001.16, then −3,002.16, merged to −1.00.
|
||
const express = reconcileDay([
|
||
{ at: 0, balance: -2.16, trades: -3003.32 },
|
||
{ at: 60, balance: -3004.32, trades: -3003.32 },
|
||
]);
|
||
assert.deepEqual(express.kinds, ['unconfirmed', 'settled']);
|
||
assert.equal(express.steps[0].sighting.delta, 3001.16);
|
||
assert.equal(express.row, null);
|
||
|
||
// 2026-10-08, 150KTC-SKU-V2-DLL-39029-99745612 (balance less the $150K
|
||
// start): +2,474.01 then −2,492.01, which left a −18.00 row.
|
||
const combine = reconcileDay([
|
||
{ at: 0, balance: 2413.43, trades: -60.58 },
|
||
{ at: 60, balance: -78.58, trades: -60.58 },
|
||
]);
|
||
assert.deepEqual(combine.kinds, ['unconfirmed', 'settled']);
|
||
assert.equal(combine.row, null);
|
||
});
|
||
|
||
test('fund reconcile: a real withdrawal books once a later pass still sees it', () => {
|
||
// 2026-10-14, EXPRESS-V2-CT-DLL-39029-43788281: a $4,620 payout.
|
||
const payout = reconcileDay([
|
||
{ at: 0, balance: 4601.42, trades: 9221.42 },
|
||
{ at: 10, balance: 4601.42, trades: 9221.42 }, // too soon to confirm
|
||
{ at: 60, balance: 4601.42, trades: 9221.42 },
|
||
]);
|
||
assert.deepEqual(payout.kinds, ['unconfirmed', 'unconfirmed', 'book']);
|
||
assert.equal(payout.steps[1].fresh, false);
|
||
assert.equal(payout.steps[1].sighting.since, 0); // the clock runs from the first sighting
|
||
assert.equal(payout.row, -4620);
|
||
|
||
// Fee noise between the passes still confirms; the later amount books.
|
||
assert.equal(reconcileDay([
|
||
{ at: 0, balance: 4601.42, trades: 9221.42 },
|
||
{ at: FUND_CONFIRM_MS / 1000, balance: 4598.42, trades: 9221.42 },
|
||
]).row, -4623);
|
||
|
||
// An amount that moves starts over: the second sighting is the new one.
|
||
const moved = reconcileDay([
|
||
{ at: 0, balance: 4601.42, trades: 9221.42 },
|
||
{ at: 60, balance: 4901.42, trades: 9221.42 },
|
||
{ at: 120, balance: 4901.42, trades: 9221.42 },
|
||
]);
|
||
assert.deepEqual(moved.kinds, ['unconfirmed', 'unconfirmed', 'book']);
|
||
assert.equal(moved.steps[1].fresh, true);
|
||
assert.equal(moved.row, -4320);
|
||
});
|
||
|
||
test('fund reconcile: a transient that outlasts the confirmation is taken back whole', () => {
|
||
// The balance lags for two passes, so +3,001.16 books; when it catches up,
|
||
// the reversal nets the day to −1.00 and the day is dropped, not left as
|
||
// a −1.00 "withdrawal" that splits the cycle.
|
||
const lagging = reconcileDay([
|
||
{ at: 0, balance: -2.16, trades: -3003.32 },
|
||
{ at: 60, balance: -2.16, trades: -3003.32 },
|
||
{ at: 120, balance: -3004.32, trades: -3003.32 },
|
||
{ at: 180, balance: -3004.32, trades: -3003.32 },
|
||
]);
|
||
assert.deepEqual(lagging.kinds, ['unconfirmed', 'book', 'unconfirmed', 'drop']);
|
||
assert.equal(lagging.steps[1].amount, 3001.16);
|
||
assert.equal(lagging.steps[3].net, -1);
|
||
assert.equal(lagging.row, null);
|
||
|
||
// A payout landing with the reversal is real: the day keeps its row.
|
||
const withPayout = reconcileDay([
|
||
{ at: 0, balance: -2.16, trades: -3003.32 },
|
||
{ at: 60, balance: -2.16, trades: -3003.32 },
|
||
{ at: 120, balance: -7624.32, trades: -3003.32 },
|
||
{ at: 180, balance: -7624.32, trades: -3003.32 },
|
||
]);
|
||
assert.deepEqual(withPayout.kinds, ['unconfirmed', 'book', 'unconfirmed', 'book']);
|
||
assert.equal(withPayout.row, -4621);
|
||
|
||
// Only a day reconcileFunds booked itself is taken back: a row it found
|
||
// there (an import) merges as before.
|
||
const imported = reconcileDay([
|
||
{ at: 0, balance: -1, trades: 0 },
|
||
{ at: 60, balance: -1, trades: 0 },
|
||
], -4620);
|
||
assert.deepEqual(imported.kinds, ['unconfirmed', 'book']);
|
||
assert.equal(imported.row, -1);
|
||
});
|