1361 lines
83 KiB
JavaScript
1361 lines
83 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, endedCycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, notionalCap, expectedSwapToWin, MARGIN_HEADROOM,
|
||
dllReserve, DLL_SLIPPAGE_FRACTION, maxLossRoom, lastStand, breachDistance, LAST_STAND_FRACTION, planLegSize, rankForTarget,
|
||
currentTradeDateCT, foldReason, withinFirmCaps, personSizeFactor, personHoldHours, PERSON_SIZE_SPREAD, machineSide,
|
||
resolveEffectiveConfig, stageInputsFor, floorRoom, resetClearStop, shortestNextLeg, computeDayPlan, clearsClose, RESET_CLEAR_HOLDS,
|
||
legCloseNote,
|
||
} 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);
|
||
});
|
||
|
||
test('botStopsActive: the current stage\'s daily loss limit, which a stage may set on a type with none', () => {
|
||
const cfg = { firstDayStop: false, minDayStop: false, dailyLossLimit: 0 };
|
||
const stages = [{ profit: 3000, consistency: 0, minTradingDays: 0, dailyLossLimit: 3000 }];
|
||
const paidOnce = [{ date: '2026-10-25', amount: -1500 }];
|
||
// Stage 2 places its $3,000 stop: a red day there is the personal DLL.
|
||
const stage2 = resolveEffectiveConfig(1500, 0, 0, false, stages, 0, paidOnce, 0, { dailyLossLimit: 0 });
|
||
assert.equal(botStopsActive(cfg, stage2), true);
|
||
// Stage 1 of the same type places none.
|
||
assert.equal(botStopsActive(cfg, resolveEffectiveConfig(1500, 0, 0, false, stages, 0, [], 0, { dailyLossLimit: 0 })), false);
|
||
// A stage that sets 0 lifts the type's limit.
|
||
const lifted = resolveEffectiveConfig(1500, 0, 0, false, [{ ...stages[0], dailyLossLimit: 0 }], 0, paidOnce, 0, { dailyLossLimit: 1229 });
|
||
assert.equal(botStopsActive({ ...cfg, dailyLossLimit: 1229 }, lifted), false);
|
||
// The obfuscation stops count whatever the stage.
|
||
assert.equal(botStopsActive({ ...cfg, minDayStop: true }, lifted), 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('a platform whose stops fill closer reserves its own share of the position', () => {
|
||
// Me's Vest eval on 2026-10-27: $1,400 of room on $495,439 of NDX. At the
|
||
// 0.1% default the reserve is $495.44; at 0.02% the flat $140 binds.
|
||
const n = 495439;
|
||
assert.ok(Math.abs(dllReserve(1400, n) - 495.439) < 1e-9);
|
||
assert.equal(dllReserve(1400, n, 0.0002), 140);
|
||
assert.ok(Math.abs(dllReserve(1400, 900000, 0.0002) - 180) < 1e-9, 'past $700k the share binds again');
|
||
// Left out, or not a share at all: the default.
|
||
for (const bad of [undefined, NaN, -0.0002, Infinity]) assert.equal(dllReserve(1400, n, bad), dllReserve(1400, n), String(bad));
|
||
assert.equal(dllReserve(1400, n, 0), 140, 'no slippage at all still keeps the flat 10%');
|
||
const stop = (slippageFraction) => dllStopDollars({ room: 1400, dailyLossLimit: 1400, entryFee: 12.45, commissionPct: 0, notional: n, slippageFraction });
|
||
assert.ok(Math.abs(stop(undefined) - (1400 - 495.439 - 24.9 - 0.01)) < 1e-9);
|
||
assert.ok(Math.abs(stop(0.0002) - (1400 - 140 - 24.9 - 0.01)) < 1e-9);
|
||
});
|
||
|
||
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');
|
||
// A green day's widened room is measured against a full day's limit: a
|
||
// $1,400 eval starting $1,700 over its floor, up $1,000 — leg 2's $2,400
|
||
// stop-out leaves $300, over 15% of $1,400, so it keeps its stop.
|
||
assert.equal(lastStand({ dllRoom: 2400, dailyLossLimit: 1400, maxLossRoom: 2700 }), false);
|
||
assert.equal(lastStand({ dllRoom: 2400, dailyLossLimit: 1400, maxLossRoom: 2610 }), true);
|
||
// The room past the floor is a last stand however it was widened.
|
||
assert.equal(lastStand({ dllRoom: 2400, dailyLossLimit: 1400, maxLossRoom: 2300 }), true);
|
||
// 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('resetClearStop: the widest stop a leg may carry and still close before the reset', () => {
|
||
const h = 3_600_000;
|
||
const near = (a, b) => Math.abs(a - b) < 1e-6;
|
||
// Exactly four holds off: a 2:1 leg, no more.
|
||
assert.ok(near(resetClearStop({ leg: 2395.76, resetInMs: 6 * h, holdHours: 1.5 }), 2 * 2395.76));
|
||
// Under four holds (the 5:30 PM CT open batch, 1.5 h before Vest's
|
||
// reset): no widening at all.
|
||
assert.equal(resetClearStop({ leg: 2395.76, resetInMs: 1.5 * h, holdHours: 1.5 }), 0);
|
||
assert.equal(resetClearStop({ leg: 2395.76, resetInMs: 6 * h - 1, holdHours: 1.5 }), 0);
|
||
// Dingo's second leg at ~4 AM ET, 16 h off: far past the $4,004 the day allows.
|
||
assert.ok(resetClearStop({ leg: 2395.76, resetInMs: 16 * h, holdHours: 1.5 }) > 20000);
|
||
// A $500 final leg 10 h off may carry about $2,656, not the $5,900 a
|
||
// $4,500 gain would give: g = (A+B)²/(AB) stays at 4.5 × 10/6.
|
||
const lopsided = resetClearStop({ leg: 500, resetInMs: 10 * h, holdHours: 1.5 });
|
||
assert.ok(Math.abs(lopsided - 2656) < 1, `${lopsided}`);
|
||
assert.ok(near((500 + lopsided) ** 2 / (500 * lopsided), 4.5 * 10 / 6));
|
||
assert.equal(resetClearStop({ leg: 0, resetInMs: 16 * h, holdHours: 1.5 }), 0);
|
||
assert.equal(resetClearStop({ leg: 500, resetInMs: 16 * h, holdHours: 0 }), 0);
|
||
assert.equal(resetClearStop({ leg: 500, resetInMs: NaN, holdHours: 1.5 }), 0);
|
||
// The cap is held to the shortest leg the day may carve: a split day's
|
||
// $2,395.76 can come out a $500 leg.
|
||
assert.equal(shortestNextLeg({ remainingToday: 2395.76, splitAllowed: true }), 500);
|
||
assert.equal(shortestNextLeg({ remainingToday: 320, splitAllowed: true }), 320);
|
||
assert.equal(shortestNextLeg({ remainingToday: 2395.76, splitAllowed: false }), 2395.76);
|
||
// Dingo at ~4 AM ET still gets the whole $2,604.24: a $500 leg 16 h off
|
||
// may carry ~$4,950.
|
||
assert.ok(resetClearStop({ leg: 500, resetInMs: 16 * h, holdHours: 1.5 }) - 1400 > 2604.24);
|
||
});
|
||
|
||
test('clearsClose: no leg starts under four holds of its market\'s close', () => {
|
||
const h = 3_600_000;
|
||
assert.equal(RESET_CLEAR_HOLDS, 4);
|
||
// The weekly close 7.5 h off, legs sized for 2 h: no entry.
|
||
assert.equal(clearsClose({ closeInMs: 7.5 * h, holdHours: 2 }), false);
|
||
// Exactly four holds off, or more: the clearance the daily reset gets.
|
||
assert.equal(clearsClose({ closeInMs: 8 * h, holdHours: 2 }), true);
|
||
assert.equal(clearsClose({ closeInMs: 8 * h - 1, holdHours: 2 }), false);
|
||
assert.equal(clearsClose({ closeInMs: 20 * h, holdHours: 2 }), true);
|
||
// A leg its leverage cap holds to 3.5 h needs 14 h, where a 2 h one is clear.
|
||
assert.equal(clearsClose({ closeInMs: 10 * h, holdHours: 3.5 }), false);
|
||
// It agrees with resetClearStop, which gives no stop at all under four holds.
|
||
for (const off of [5.9, 6, 6.1, 12]) {
|
||
assert.equal(clearsClose({ closeInMs: off * h, holdHours: 1.5 }), resetClearStop({ leg: 500, resetInMs: off * h, holdHours: 1.5 }) > 0);
|
||
}
|
||
// An unknown hold never clears.
|
||
assert.equal(clearsClose({ closeInMs: 20 * h, holdHours: NaN }), false);
|
||
});
|
||
|
||
test('legCloseNote: each account\'s leg is held to its market\'s close at its own size', () => {
|
||
const h = 3_600_000;
|
||
const now = Date.parse('2026-10-30T12:30:00Z');
|
||
const close = now + 9 * h;
|
||
// The batch's lead leg, 2 h: clear. Another account's, sized to 3.5 h: not.
|
||
assert.equal(legCloseNote(close, 2, now), null);
|
||
assert.equal(legCloseNote(close, 3.5, now), 'closes in 9.0h, under 4 holds of 3.5h');
|
||
// A contract that names no close (futures, crypto) never holds a leg back.
|
||
assert.equal(legCloseNote(undefined, 3.5, now), null);
|
||
assert.equal(legCloseNote(null, 3.5, now), null);
|
||
// An unknown hold doesn't open into a known close.
|
||
assert.notEqual(legCloseNote(close, NaN, now), null);
|
||
});
|
||
|
||
test('planLegSize: a split day\'s next leg, with the day\'s gain on its stop, sizes up for the same hold', () => {
|
||
// Dingo's 2026-10-27 evals: up $2,604.24 on a $1,400 day, $2,395.76 to
|
||
// pass, NDX at ~$29.5k on Vest (1.31%/day), sized for 1.5 h.
|
||
const ndx = { stepNotional: 2.95, volatilityPerHour: 0.0131 / Math.sqrt(24), spreadFraction: 0.0000085, marginRate: 0.02 };
|
||
const leg = {
|
||
leg: 2395.76, contract: ndx, commissionPct: 0.0025, targetHoldHours: 1.5, financingPctPerDay: 0,
|
||
dailyLossLimit: 1400, obfuscationDollars: Infinity, availableFunds: 52604.24, maxLossRoom: 5604.24,
|
||
};
|
||
const capped = planLegSize({ ...leg, dllRoom: 1400 });
|
||
const room = floorRoom({ dailyLossLimit: 1400, closedBalance: 52604.24, floor: 48500, realizedToday: 2604.24, gain: 2604.24 });
|
||
const widened = planLegSize({ ...leg, dllRoom: room });
|
||
assert.equal(widened.lastStand, false);
|
||
for (const p of [capped, widened]) assert.ok(Math.abs(p.sizing.predictedHoldHours - 1.5) < 0.05, `hold ${p.sizing.predictedHoldHours}`);
|
||
assert.ok(widened.sizing.notional > 1.5 * capped.sizing.notional, `${widened.sizing.notional} vs ${capped.sizing.notional}`);
|
||
// The stop gives back the day's gain plus the limit, less the reserve.
|
||
const n = widened.sizing.notional;
|
||
assert.ok(widened.walkAt(n) > 2604.24 && widened.walkAt(n) < 4004.24, `stop ${widened.walkAt(n)}`);
|
||
});
|
||
|
||
test('planLegSize: a platform\'s own slippage share widens the stop and sizes the leg up for the same hold', () => {
|
||
// Me's first-day Vest eval leg on 2026-10-27: $5,000 to pass, $1,400 of
|
||
// room, NDX at ~$29.8k, sized for 2.6 h.
|
||
const ndx = { stepNotional: 2.98, volatilityPerHour: 0.0131 / Math.sqrt(24), spreadFraction: 0.0000085, marginRate: 0.02 };
|
||
const leg = {
|
||
leg: 5000, contract: ndx, commissionPct: 0.0025, targetHoldHours: 2.6, financingPctPerDay: 0,
|
||
dailyLossLimit: 1400, dllRoom: 1400, obfuscationDollars: Infinity, availableFunds: 50000, maxLossRoom: 3000,
|
||
};
|
||
const deflt = planLegSize(leg);
|
||
const vest = planLegSize({ ...leg, slippageFraction: 0.0002 });
|
||
assert.equal(planLegSize({ ...leg, slippageFraction: undefined }).sizing.notional, deflt.sizing.notional);
|
||
for (const p of [deflt, vest]) {
|
||
assert.equal(p.lastStand, false);
|
||
assert.ok(Math.abs(p.sizing.predictedHoldHours - 2.6) < 0.05, `hold ${p.sizing.predictedHoldHours}`);
|
||
}
|
||
const r = 0.0025 / 100;
|
||
const nd = deflt.sizing.notional, nv = vest.sizing.notional;
|
||
assert.ok(Math.abs(deflt.walkAt(nd) - (1400 - 0.001 * nd - 2 * r * nd - 0.01) / (1 + r)) < 1e-9);
|
||
assert.ok(Math.abs(vest.walkAt(nv) - (1400 - 140 - 2 * r * nv - 0.01) / (1 + r)) < 1e-9, 'the flat $140 binds');
|
||
assert.ok(vest.walkAt(nv) > 1200 && deflt.walkAt(nd) < 950, `stops ${vest.walkAt(nv)} vs ${deflt.walkAt(nd)}`);
|
||
assert.ok(nv > 1.1 * nd, `${nv} vs ${nd}`);
|
||
assert.ok(vest.sizing.winProbability > deflt.sizing.winProbability);
|
||
});
|
||
|
||
test('personSizeFactor: one draw per person and trade date, within the spread', () => {
|
||
const people = Array.from({ length: 300 }, (_, i) => `0b6f${i.toString(16).padStart(4, '0')}-5c1e-4a7e-9d2b-${i}`);
|
||
const draws = people.map((p) => personSizeFactor(p, '2026-10-20'));
|
||
for (const f of draws) assert.ok(f >= 1 - PERSON_SIZE_SPREAD && f <= 1 + PERSON_SIZE_SPREAD, `factor ${f}`);
|
||
// Spread over the whole range, not bunched at 1.
|
||
assert.ok(Math.min(...draws) < 0.88 && Math.max(...draws) > 1.12, `${Math.min(...draws)}–${Math.max(...draws)}`);
|
||
// Repeatable, so every sizing of the day agrees.
|
||
assert.equal(personSizeFactor(people[0], '2026-10-20'), draws[0]);
|
||
// Two people land within half a percent of each other about as often as
|
||
// uniform draws do (2 × 0.005 / 0.3 ≈ 3%), not bunched together.
|
||
const close = draws.filter((f, i) => i > 0 && Math.abs(f - draws[i - 1]) < 0.005).length;
|
||
assert.ok(close < draws.length * 0.08, `${close} near-identical neighbours`);
|
||
// A new trade date, a new draw: a month of one person's days covers the
|
||
// range too, not a slow drift (the next date differs in its last digit).
|
||
const days = Array.from({ length: 30 }, (_, i) => personSizeFactor(people[0], `2026-11-${String(i + 1).padStart(2, '0')}`));
|
||
assert.ok(Math.max(...days) - Math.min(...days) > PERSON_SIZE_SPREAD, `days span ${Math.min(...days)}–${Math.max(...days)}`);
|
||
const steps = days.slice(1).map((f, i) => Math.abs(f - days[i]));
|
||
assert.ok(steps.reduce((a, b) => a + b, 0) / steps.length > 0.05, 'day to day the draw should jump, not creep');
|
||
});
|
||
|
||
test('personHoldHours: the size comes out the person\'s factor times the plan, at the hold it was sized for', () => {
|
||
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 plan = planLegSize(base).sizing;
|
||
for (const person of ['me-uuid', 'nik-uuid', 'ahmad-uuid', 'ziad-uuid']) {
|
||
const factor = personSizeFactor(person, '2026-10-20');
|
||
const hold = personHoldHours(12, person, '2026-10-20');
|
||
const sized = planLegSize({ ...base, targetHoldHours: hold }).sizing;
|
||
// Fees and the daily-loss stop move a little with the size, so the
|
||
// ratio sits a touch nearer 1 than the factor.
|
||
const ratio = sized.notional / plan.notional;
|
||
assert.ok(Math.abs(ratio - factor) < 0.02, `${person}: ×${ratio.toFixed(4)} vs factor ${factor.toFixed(4)}`);
|
||
assert.ok((ratio - 1) * (factor - 1) > 0, `${person}: moved the wrong way`);
|
||
// The pick ranks against this hold, and the size reaches it.
|
||
assert.ok(Math.abs(sized.predictedHoldHours - hold) < 0.1, `${person}: ${sized.predictedHoldHours}h vs ${hold}h`);
|
||
}
|
||
// No firm hold: the default, moved the same way.
|
||
assert.equal(personHoldHours(0, 'me-uuid', '2026-10-20'), personHoldHours(DEFAULT_TARGET_HOLD_HOURS, 'me-uuid', '2026-10-20'));
|
||
});
|
||
|
||
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');
|
||
});
|
||
|
||
import { completionPlace } from '../lib/trading-logic.ts';
|
||
|
||
test('completionPlace: each payout counts, and a day\'s second cycle has a key of its own', () => {
|
||
// Vest: activated on the 20th; claims c1 and c2 on the 27th share one
|
||
// fund row; c3 on the 28th.
|
||
const activated = { date: '2026-10-20', amount: 50000 };
|
||
// Cycle A, the first, completes on the 27th ahead of any claim.
|
||
assert.deepEqual(completionPlace({ payoutDays: [], fundTransactions: [activated], cycle: '2026-10-20' }), { stage: 1, cycleSeq: 0 });
|
||
// c1 starts cycle B, which completes the same day.
|
||
const b = completionPlace({ payoutDays: ['2026-10-27'], fundTransactions: [activated, { date: '2026-10-27', amount: -2250 }], cycle: '2026-10-27' });
|
||
assert.deepEqual(b, { stage: 2, cycleSeq: 0 });
|
||
// c2 starts cycle C on the same fund row, which completes on the 28th:
|
||
// payout 3, and not B's key — the master would fold C into B.
|
||
const both = [activated, { date: '2026-10-27', amount: -3750 }];
|
||
const c = completionPlace({ payoutDays: ['2026-10-27', '2026-10-27'], fundTransactions: both, cycle: '2026-10-27' });
|
||
assert.deepEqual(c, { stage: 3, cycleSeq: 1 });
|
||
// C caught up after c3 ended it: the same stage and key as its final day.
|
||
assert.deepEqual(completionPlace({
|
||
payoutDays: ['2026-10-27', '2026-10-27', '2026-10-28'], fundTransactions: [...both, { date: '2026-10-28', amount: -1500 }],
|
||
cycle: '2026-10-27', end: '2026-10-28',
|
||
}), c);
|
||
// One fund row per payout (every other platform): 1 + the rows before, no
|
||
// cycleSeq — as the stage always read.
|
||
const rows = [{ date: '2026-09-01', amount: -500 }, { date: '2026-09-15', amount: -800 }];
|
||
const days = rows.map((f) => f.date);
|
||
assert.deepEqual(completionPlace({ payoutDays: days, fundTransactions: rows, cycle: '2026-09-15' }), { stage: 3, cycleSeq: 0 });
|
||
assert.deepEqual(completionPlace({ payoutDays: days, fundTransactions: rows, cycle: '2026-09-01', end: '2026-09-15' }), { stage: 2, cycleSeq: 0 });
|
||
// The account's first cycle; and a claim on the day it was activated,
|
||
// whose deposit row names the cycle it started.
|
||
assert.deepEqual(completionPlace({ payoutDays: [], fundTransactions: [], cycle: '' }), { stage: 1, cycleSeq: 0 });
|
||
assert.deepEqual(completionPlace({ payoutDays: ['2026-10-20'], fundTransactions: [{ date: '2026-10-20', amount: 47750 }], cycle: '2026-10-20' }), { stage: 2, cycleSeq: 1 });
|
||
});
|
||
|
||
// ── 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)');
|
||
});
|
||
|
||
test('withinFirmCaps: a firm\'s cap on accounts at once, in order, less what it holds', () => {
|
||
const topstep = { id: 'ts', maxConcurrentAccounts: 3 };
|
||
const apex = { id: 'apex', maxConcurrentAccounts: 0 };
|
||
const lucid = { id: 'lucid' };
|
||
const items = [
|
||
...[1, 2, 3, 4, 5, 6].map((n) => ({ firm: topstep, n: `ts${n}` })),
|
||
...[1, 2, 3, 4].map((n) => ({ firm: apex, n: `apex${n}` })),
|
||
{ firm: lucid, n: 'lucid1' },
|
||
];
|
||
const names = (xs) => xs.map((x) => x.n);
|
||
// 6 Topstep capped at 3 keep the first 3; uncapped firms keep everything.
|
||
assert.deepEqual(names(withinFirmCaps(items)), ['ts1', 'ts2', 'ts3', 'apex1', 'apex2', 'apex3', 'apex4', 'lucid1']);
|
||
// Two already open: one more slot.
|
||
assert.deepEqual(names(withinFirmCaps(items, new Map([['ts', 2]]))), ['ts1', 'apex1', 'apex2', 'apex3', 'apex4', 'lucid1']);
|
||
// At (or past) the cap: none.
|
||
assert.deepEqual(names(withinFirmCaps(items, new Map([['ts', 4]]))), ['apex1', 'apex2', 'apex3', 'apex4', 'lucid1']);
|
||
// Held counts don't touch an uncapped firm, and the input isn't mutated.
|
||
const held = new Map([['apex', 9]]);
|
||
assert.equal(withinFirmCaps(items, held).filter((x) => x.firm === apex).length, 4);
|
||
assert.equal(held.get('apex'), 9);
|
||
assert.equal(held.has('ts'), false);
|
||
// Interleaved order is kept.
|
||
const mixed = [{ firm: topstep, n: 'a' }, { firm: apex, n: 'b' }, { firm: topstep, n: 'c' }, { firm: topstep, n: 'd' }, { firm: topstep, n: 'e' }];
|
||
assert.deepEqual(names(withinFirmCaps(mixed)), ['a', 'b', 'c', 'd']);
|
||
assert.deepEqual(withinFirmCaps([]), []);
|
||
});
|
||
|
||
// ── 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);
|
||
});
|
||
|
||
test('machineSide: one side per machine, across every lane', () => {
|
||
// Nothing held, nothing being placed: free.
|
||
assert.equal(machineSide([], null), null);
|
||
// Vest shorts held (hand trades or the bot's): every lane must short.
|
||
assert.equal(machineSide([-42.6545, -12.1], null), 'Sell');
|
||
// A long NQ in the futures lane: the Vest lane must go long.
|
||
assert.equal(machineSide([3], null), 'Buy');
|
||
// Another lane's batch is placing longs right now, nothing filled yet.
|
||
assert.equal(machineSide([], 'Buy'), 'Buy');
|
||
assert.equal(machineSide([2], 'Buy'), 'Buy');
|
||
// Both sides already: nothing may be added.
|
||
assert.equal(machineSide([1, -1], null), 'mixed');
|
||
assert.equal(machineSide([-5], 'Buy'), 'mixed');
|
||
});
|
||
|
||
test('same-day stages: today\'s payouts pick the stage, and a stage may carry its own daily limit', () => {
|
||
// The Vest 50k Funded type: $1,500 then claim, then $3,000 on the room the claim freed.
|
||
const stages = [{ profit: 3000, consistency: 0, minTradingDays: 0, dailyLossLimit: 3000 }];
|
||
const fundRows = [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-25', amount: -1500 }];
|
||
const resolve = (inputs) => resolveEffectiveConfig(1500, 0, 0, false, stages, 0, fundRows, 0, inputs);
|
||
|
||
// Base stage, base limit: yesterday's payout doesn't count for a same-day type.
|
||
const fresh = resolve({ dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 0 });
|
||
assert.equal(fresh.profitTarget, 1500);
|
||
assert.equal(fresh.dailyLossLimit, 1229);
|
||
// After a claim today: stage 2, its own limit.
|
||
const after = resolve({ dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 1 });
|
||
assert.equal(after.profitTarget, 3000);
|
||
assert.equal(after.dailyLossLimit, 3000);
|
||
// A second claim today: the last stage repeats.
|
||
assert.equal(resolve({ dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 2 }).profitTarget, 3000);
|
||
// Without same-day stages the lifetime count picks it, as it always has.
|
||
assert.equal(resolve({ dailyLossLimit: 1229 }).profitTarget, 3000);
|
||
// A stage without its own limit keeps the type's; no inputs = no limit.
|
||
const plain = resolveEffectiveConfig(1500, 0, 0, false, [{ profit: 2000, consistency: 0, minTradingDays: 0 }], 0, fundRows, 0, { dailyLossLimit: 1229 });
|
||
assert.equal(plain.dailyLossLimit, 1229);
|
||
assert.equal(resolveEffectiveConfig(1500, 0, 0, false, [], 0, [], 0).dailyLossLimit, 0);
|
||
|
||
// stageInputsFor asks the client only for a same-day type.
|
||
const client = { sameDayClaims: () => 1 };
|
||
assert.deepEqual(stageInputsFor(client, 7, { dailyLossLimit: 1229, stagesSameDay: true }), { dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 1 });
|
||
assert.deepEqual(stageInputsFor(client, 7, { dailyLossLimit: 1229 }), { dailyLossLimit: 1229, stagesSameDay: false, sameDayWithdrawals: 0 });
|
||
assert.deepEqual(stageInputsFor({}, 7, { stagesSameDay: true }), { dailyLossLimit: 0, stagesSameDay: true, sameDayWithdrawals: 0 });
|
||
});
|
||
|
||
test('floorRoom: what\'s left of the stage\'s limit, widened by the day\'s gain the client allows, never past the platform\'s floor', () => {
|
||
// Fresh day: $50k, Vest floor $48,500, the type's $1,229.
|
||
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 50000, floor: 48500, realizedToday: 0 }), 1229);
|
||
// Lost $1,111.65 today: Vest still allows $388.35, but the bot's own
|
||
// $1,229 day has only $117.35 left.
|
||
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 48888.35, floor: 48500, realizedToday: -1111.65 }), 117.35);
|
||
// A green day alone doesn't widen the limit...
|
||
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 50600, floor: 48500, realizedToday: 600 }), 1229);
|
||
// ...the gain the client allows does: the next leg may give it back plus
|
||
// the limit, as on Dingo's 2026-10-27 evals ($1,400 a day, up $2,604.24)...
|
||
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 50600, floor: 48500, realizedToday: 600, gain: 600 }), 1829);
|
||
assert.equal(floorRoom({ dailyLossLimit: 1400, closedBalance: 52604.24, floor: 48500, realizedToday: 2604.24, gain: 2604.24 }), 4004.24);
|
||
// ...never past Vest's own floor...
|
||
assert.equal(floorRoom({ dailyLossLimit: 1400, closedBalance: 52604.24, floor: 49000, realizedToday: 2604.24, gain: 2604.24 }), 3604.24);
|
||
// ...and never by more than the day realized: $2,250 claimed by hand
|
||
// leaves realized at $2,250 (added back) but none of it in the account.
|
||
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 50000, floor: 46250, realizedToday: 2250, gain: 0 }), 1229);
|
||
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 50600, floor: 48500, realizedToday: 600, gain: 5000 }), 1829);
|
||
// A red day still counts in full, whatever gain is offered.
|
||
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 49500, floor: 48500, realizedToday: -500, gain: 300 }), 729);
|
||
// Won $1,500 and claimed it: balance $50k, Vest's floor lowered to $47k,
|
||
// the day's P&L counted from the claim. Stage 2's $3,000 gets all of it.
|
||
assert.equal(floorRoom({ dailyLossLimit: 3000, closedBalance: 50000, floor: 47000, realizedToday: 0 }), 3000);
|
||
// ...and after a $500 stage-2 loss, $2,500 either way.
|
||
assert.equal(floorRoom({ dailyLossLimit: 3000, closedBalance: 49500, floor: 47000, realizedToday: -500 }), 2500);
|
||
// If Vest hadn't lowered the floor, only what it really leaves.
|
||
assert.equal(floorRoom({ dailyLossLimit: 3000, closedBalance: 50000, floor: 48500, realizedToday: 0 }), 1500);
|
||
// Below the floor already: negative room (no trade).
|
||
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 48400, floor: 48500, realizedToday: -1600 }), -371);
|
||
assert.equal(floorRoom({ dailyLossLimit: 0, closedBalance: 50000, floor: 48500, realizedToday: -900 }), Infinity);
|
||
});
|
||
|
||
test('the day after a claim: a fresh cycle that aims at the next stage\'s target', () => {
|
||
// Claimed everything above $50k; P&L since the claim is 0, the cycle has no days yet.
|
||
const plan = computeDayPlan(7, '2026-10-27', 3000, 0, [], 0, 0, 0, 0, false, false);
|
||
assert.ok(plan.dayTarget >= 3000 && plan.dayTarget <= 3025, `dayTarget ${plan.dayTarget}`);
|
||
assert.equal(plan.remainingToday, plan.dayTarget);
|
||
});
|
||
|
||
// ── what an order is for (lib/platform-client.ts orderEffect) ────────────────
|
||
|
||
import { readFileSync } from 'node:fs';
|
||
import { orderEffect, OrderNotPlacedError, NothingToCloseError, isOrderNotPlaced, isNothingToClose } from '../lib/platform-client.ts';
|
||
|
||
test('an entry never closes a position, a flatten never opens one (Vest, DXtrade)', () => {
|
||
/** The route, or the refusal thrown. `recorded`: the client's own record
|
||
* of the symbol, by default what the fresh read shows. */
|
||
const effect = (held, side, intent, recorded = held) => {
|
||
try {
|
||
return orderEffect({ symbol: 'NDX-USD-PERP', held, recorded, side, intent });
|
||
} catch (err) {
|
||
return err;
|
||
}
|
||
};
|
||
// No intent: the position decides, as before (the manual order route).
|
||
assert.equal(effect(null, 'long'), 'open');
|
||
assert.equal(effect('short', 'long'), 'close');
|
||
assert.match(effect('long', 'long').message, /adding to an open NDX-USD-PERP position/);
|
||
// An entry on an account that already holds the symbol — a hand short the
|
||
// engine's synced positions didn't show yet — is refused (the leg is
|
||
// un-counted), not turned into a close of that short.
|
||
assert.equal(effect(null, 'long', 'open'), 'open');
|
||
for (const recorded of ['short', null]) {
|
||
const entry = effect('short', 'long', 'open', recorded);
|
||
assert.equal(isOrderNotPlaced(entry), true);
|
||
assert.equal(isNothingToClose(entry), false);
|
||
assert.match(entry.message, /short NDX-USD-PERP position is already open/);
|
||
}
|
||
assert.match(effect('long', 'long', 'open').message, /adding to an open/);
|
||
// A flatten closes the leg it was sent for; on a flat account it opens
|
||
// nothing, and says there is nothing to close when the record agrees.
|
||
assert.equal(effect('long', 'short', 'close'), 'close');
|
||
const flat = effect(null, 'short', 'close');
|
||
assert.ok(flat instanceof NothingToCloseError);
|
||
assert.equal(isOrderNotPlaced(flat), true);
|
||
assert.equal(isNothingToClose(flat), true);
|
||
assert.match(flat.message, /no NDX-USD-PERP position to close/);
|
||
// A flat read just after a fill the client saw (the leg still on record)
|
||
// may be the gateway lagging: nothing sent, but not "flat" — the flatten
|
||
// keeps its alarm.
|
||
const lagging = effect(null, 'short', 'close', 'long');
|
||
assert.ok(lagging instanceof OrderNotPlacedError);
|
||
assert.equal(isNothingToClose(lagging), false);
|
||
assert.match(lagging.message, /fresh read shows no NDX-USD-PERP position, but this client last saw a long one open/);
|
||
// Holding the flatten's own side is not the leg: refused, and not "flat".
|
||
const wrong = effect('short', 'short', 'close');
|
||
assert.ok(wrong instanceof OrderNotPlacedError);
|
||
assert.equal(isNothingToClose(wrong), false);
|
||
assert.match(wrong.message, /holds a short NDX-USD-PERP position, not one to close/);
|
||
|
||
assert.equal(isNothingToClose(new OrderNotPlacedError('Vest refused the order: 400')), false);
|
||
assert.equal(isNothingToClose(new Error('outcome unknown')), false);
|
||
assert.equal(isNothingToClose(undefined), false);
|
||
});
|
||
|
||
test('the engine says what each order is for, and both position-based clients go by it', () => {
|
||
const source = (file) => readFileSync(new URL(`../lib/${file}`, import.meta.url), 'utf8');
|
||
// An order sent without its intent lets the position decide again: an
|
||
// entry closing a hand trade, a flatten opening a position with no exits.
|
||
const calls = [...source('auto-trade.ts').matchAll(/client\.sendOrder\(([^;]+?)\);/g)].map((m) => m[1].trim());
|
||
assert.ok(calls.length >= 4, `found ${calls.length} sendOrder calls`);
|
||
for (const args of calls) assert.match(args, /, '(open|close)'$/, `sendOrder(${args}) names no intent`);
|
||
for (const file of ['vest-class.ts', 'dxtrade-class.ts']) {
|
||
const src = source(file);
|
||
assert.match(src, /return await this\.sendOrderAndWait\([^)]*, intent\);/, `${file}: sendOrder drops the intent`);
|
||
assert.match(src, /= orderEffect\(\{[^}]*\bintent\b[^}]*\}\) === 'close' \? held : undefined;/, `${file}: orderEffect doesn't pick the route`);
|
||
}
|
||
});
|
||
|
||
// The Vest 50k Funded type: $1,500 and claim it all, then $3,000 on the room
|
||
// the claim freed — and every new day back at $1,500.
|
||
const vestFunded = {
|
||
profitTarget: 1500, consistency: 0, minDayPnL: 0, minTradingDays: 0, accountSize: 50000,
|
||
targetSameEquity: false, splitEnabled: true, payoutable: true, tradesEveryDay: true, combineCmeHolidays: false,
|
||
withdrawalStages: [{ profit: 3000, consistency: 0, minTradingDays: 0, dailyLossLimit: 3000 }],
|
||
};
|
||
const claimedBefore = [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-25', amount: -1500 }];
|
||
|
||
test('projectPerfectPath: a same-day type trades each projected day from the base stage, then its next stages that day', () => {
|
||
const opts = {
|
||
accountId: 7, cfg: vestFunded, cycleDailyPnL: [], fundTransactions: claimedBefore,
|
||
balance: 50000, realizedPnL: 0, payout: { withdrawalPct: 1 }, now: cdt('2026-10-27T12:00:00'),
|
||
};
|
||
const firstOf = (path, n) => path.slice(0, n).map((d) => [d.date, d.stage, d.withdrawal]);
|
||
|
||
// An earlier day's claim doesn't count: today and every day after it
|
||
// make $1,500 on stage 1 and claim it, then $3,000 on stage 2 and claim
|
||
// that — once through the stages a day.
|
||
const fresh = projectPerfectPath({ ...opts, stageInputs: { dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 0 } });
|
||
assert.deepEqual(firstOf(fresh, 5), [
|
||
['2026-10-27', 1, -1500], ['2026-10-27', 2, -3000],
|
||
['2026-10-28', 1, -1500], ['2026-10-28', 2, -3000],
|
||
['2026-10-29', 1, -1500],
|
||
]);
|
||
|
||
// Claimed once already today: today's cycle is stage 2's $3,000, tomorrow $1,500 and $3,000 again.
|
||
const claimedToday = projectPerfectPath({ ...opts, stageInputs: { dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 1 } });
|
||
assert.deepEqual(firstOf(claimedToday, 3), [['2026-10-27', 2, -3000], ['2026-10-28', 1, -1500], ['2026-10-28', 2, -3000]]);
|
||
|
||
// A cycle that stood complete yesterday is claimed today, so today trades stage 2.
|
||
const unclaimed = projectPerfectPath({
|
||
...opts, cycleDailyPnL: [{ date: '2026-10-26', pnl: 1500 }], balance: 51500,
|
||
stageInputs: { dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 0 },
|
||
});
|
||
assert.deepEqual(firstOf(unclaimed, 3), [['2026-10-26', 1, -1500], ['2026-10-27', 2, -3000], ['2026-10-28', 1, -1500]]);
|
||
|
||
// Two listed stages: three payouts a day, and the day after starts over.
|
||
const twoStages = projectPerfectPath({
|
||
...opts, cfg: { ...vestFunded, withdrawalStages: [...vestFunded.withdrawalStages, { profit: 2000, consistency: 0, minTradingDays: 0 }] },
|
||
maxWithdrawals: 10, stageInputs: { stagesSameDay: true, sameDayWithdrawals: 0 },
|
||
});
|
||
assert.deepEqual(firstOf(twoStages, 4).map(([date, stage]) => [date, stage]), [
|
||
['2026-10-27', 1], ['2026-10-27', 2], ['2026-10-27', 3], ['2026-10-28', 1],
|
||
]);
|
||
|
||
// Any other type counts every withdrawal, as it always has.
|
||
const lifetime = projectPerfectPath(opts);
|
||
assert.deepEqual(firstOf(lifetime, 3), [['2026-10-27', 2, -3000], ['2026-10-28', 3, -3000], ['2026-10-29', 4, -3000]]);
|
||
assert.deepEqual(projectPerfectPath({ ...opts, stageInputs: { dailyLossLimit: 1229, stagesSameDay: false, sameDayWithdrawals: 0 } }), lifetime);
|
||
});
|
||
|
||
test('endedCycleCompletion: a same-day type\'s ended cycle is judged on its day\'s base stage', () => {
|
||
// 2026-10-26 made $1,500 and the auto-claim took it before the reporter
|
||
// saw the final day; the account had claimed the day before too. The
|
||
// claim ended that day's stage 1, not a $3,000 stage 2.
|
||
const opts = {
|
||
accountId: 7, rawTradeDate: '2026-10-26',
|
||
fullDailyPnL: [{ date: '2026-10-25', pnl: 1500 }, { date: '2026-10-26', pnl: 1500 }],
|
||
cycleDailyPnL: [],
|
||
fundTransactions: [...claimedBefore, { date: '2026-10-26', amount: -1500 }],
|
||
lastFundDate: '2026-10-26',
|
||
endedCycleStart: { fundDate: '2026-10-25', includesDay: false },
|
||
rawRealizedPnL: 0, equityProfit: 0,
|
||
cfg: { ...vestFunded, stagesSameDay: true },
|
||
now: cdt('2026-10-26T12:00:00'),
|
||
};
|
||
// Its stage stays the payout's number among the account's withdrawals,
|
||
// which the master clears the entry by.
|
||
assert.deepEqual(endedCycleCompletion(opts), { date: '2026-10-26', stage: 2, drain: false, cycle: '2026-10-25' });
|
||
// Any other type: judged on stage 2's $3,000, as before.
|
||
assert.equal(endedCycleCompletion({ ...opts, cfg: vestFunded }), null);
|
||
});
|