Two of the cycles a claim ends never got a completion entry. A Vest day's second cycle is one: the auto-claim at the next tick beats the 30 s report, and the payout books into that day's fund row, so the latest fund date never moves for the catch-up. A cycle claimed right after the 20:00 ET reset is the other: the reset puts it back on stage 1, and all of its P&L sits in the previous claim's day row, so endedCycleCompletion reads none of it. Before a claim goes out, claimedCycle (trading-logic) checks whether the cycle stands complete: cycleCompletion 'today' or 'before' on the client's numbers. It keys the cycle the way the final day would (completionPlace on the payout list), so the master dedupes the two. afterClaim keeps the result in account_meta. The reporter names each kept cycle for the catch-up's week. It lists them last in the slice, newest first, under the master's per-slice cap, and adds no counter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1477 lines
92 KiB
JavaScript
1477 lines
92 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, stagePayouts, 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('projectPerfectPath: today is the raw trade date it is given — Vest\'s, still the day before from 5 PM CT to 20:00 ET', () => {
|
||
const firstOf = (path, n) => path.slice(0, n).map((d) => [d.date, d.stage, d.withdrawal]);
|
||
// Claimed once on Vest day 10-27 and $1,000 made since.
|
||
const claimed = {
|
||
accountId: 7, cfg: vestFunded, cycleDailyPnL: [], fundTransactions: [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-27', amount: -1500 }],
|
||
balance: 51000, realizedPnL: 1000, payout: { withdrawalPct: 1 }, stageInputs: { stagesSameDay: true, sameDayWithdrawals: 1 },
|
||
};
|
||
const before = projectPerfectPath({ ...claimed, now: cdt('2026-10-27T16:30:00') });
|
||
assert.deepEqual(firstOf(before, 3), [['2026-10-27', 2, -3000], ['2026-10-28', 1, -1500], ['2026-10-28', 2, -3000]]);
|
||
// 5:30 PM CT: the Central date has rolled, Vest's hasn't — the day's
|
||
// claim stays on it, and 10-28 keeps its stage-1 payout.
|
||
assert.deepEqual(projectPerfectPath({ ...claimed, now: cdt('2026-10-27T17:30:00'), rawTradeDate: '2026-10-27' }), before);
|
||
// No date given: the Central one, as before.
|
||
assert.equal(projectPerfectPath({ ...claimed, now: cdt('2026-10-27T17:30:00') })[0].date, '2026-10-28');
|
||
|
||
// No claim yet, Vest day 10-27's row already booked: counted once, as today's.
|
||
const open = {
|
||
...claimed, fundTransactions: claimedBefore, cycleDailyPnL: [{ date: '2026-10-27', pnl: 1000 }],
|
||
stageInputs: { stagesSameDay: true, sameDayWithdrawals: 0 },
|
||
};
|
||
const earlier = projectPerfectPath({ ...open, now: cdt('2026-10-27T16:30:00') });
|
||
assert.deepEqual(firstOf(earlier, 2), [['2026-10-27', 1, -1500], ['2026-10-27', 2, -3000]]);
|
||
assert.deepEqual(projectPerfectPath({ ...open, now: cdt('2026-10-27T17:30:00'), rawTradeDate: '2026-10-27' }), earlier);
|
||
});
|
||
|
||
test('stages without the same-day reset count each payout, where one fund row holds a day\'s two (Vest)', () => {
|
||
// Two listed stages; claims of $1,500 and $3,000 on Vest day 10-27 share its row.
|
||
const stages = [{ profit: 3000, consistency: 0, minTradingDays: 0 }, { profit: 2000, consistency: 0, minTradingDays: 0 }];
|
||
const rows = [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-27', amount: -4500 }];
|
||
const resolve = (inputs) => resolveEffectiveConfig(1500, 0, 0, false, stages, 0, rows, 0, inputs);
|
||
assert.equal(resolve({ payouts: 2 }).profitTarget, 2000);
|
||
// No payout list: one per negative fund row, as always.
|
||
assert.equal(resolve({}).profitTarget, 3000);
|
||
// A same-day type still counts only today's.
|
||
assert.equal(resolve({ stagesSameDay: true, sameDayWithdrawals: 0, payouts: 2 }).profitTarget, 1500);
|
||
|
||
// stageInputsFor takes the count from the client's payout list, when it has read one.
|
||
const client = { sameDayClaims: () => 2, payoutDays: () => ['2026-10-27', '2026-10-27'] };
|
||
assert.deepEqual(stageInputsFor(client, 7, {}), { dailyLossLimit: 0, stagesSameDay: false, sameDayWithdrawals: 0, payouts: 2 });
|
||
assert.deepEqual(stageInputsFor({ payoutDays: () => undefined }, 7, {}), { dailyLossLimit: 0, stagesSameDay: false, sameDayWithdrawals: 0 });
|
||
assert.deepEqual(stageInputsFor(client, 7, { stagesSameDay: true }), { dailyLossLimit: 0, stagesSameDay: true, sameDayWithdrawals: 2 });
|
||
// The state route's stage label counts the same way.
|
||
assert.equal(stagePayouts(rows, stageInputsFor(client, 7, {})), 2);
|
||
assert.equal(stagePayouts(rows, stageInputsFor({}, 7, {})), 1);
|
||
|
||
// The projection starts from the same count: stage 3's $2,000 next, then it repeats.
|
||
const opts = {
|
||
accountId: 7, cfg: { ...vestFunded, withdrawalStages: stages }, cycleDailyPnL: [], fundTransactions: rows,
|
||
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]);
|
||
assert.deepEqual(firstOf(projectPerfectPath({ ...opts, stageInputs: stageInputsFor(client, 7, {}) }), 2), [['2026-10-27', 3, -2000], ['2026-10-28', 4, -2000]]);
|
||
assert.deepEqual(firstOf(projectPerfectPath(opts), 2), [['2026-10-27', 2, -3000], ['2026-10-28', 3, -2000]]);
|
||
});
|
||
|
||
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);
|
||
});
|
||
|
||
import { claimedCycle, withinEndedCycleWindow } from '../lib/trading-logic.ts';
|
||
|
||
test('claimedCycle: a claim names the complete cycle it ends, where the final day and the catch-up can\'t', () => {
|
||
const cfg = { ...vestFunded, stagesSameDay: true, dailyLossLimit: 1500 };
|
||
const activated = { date: '2026-10-20', amount: 50000 };
|
||
/** A Vest client's numbers for account 7, just before a claim. */
|
||
const client = (o) => ({
|
||
accountCashBalances: { 7: o.cash }, dailyPnL: { 7: o.cycle ?? [] }, fundTransactions: { 7: o.rows },
|
||
lastFundDates: { 7: o.last }, priorProfit: { 7: 0 },
|
||
tradeDate: () => o.day, sameDayClaims: () => o.today, payoutDays: o.payouts && (() => o.payouts),
|
||
});
|
||
|
||
// Vest day 10-27, 6 PM ET: claim 1 took stage 1's $1,500 at 3 PM, and
|
||
// stage 2 has made its $3,000 since. The auto-claim at the next tick
|
||
// beats the 30 s report, and its payout books into 10-27's row: the
|
||
// latest fund date never moves for the catch-up.
|
||
const claimedOnce = [activated, { date: '2026-10-27', amount: -1500 }];
|
||
const second = { cash: { amount: 53000, realizedPnL: 3000 }, rows: claimedOnce, last: '2026-10-27', day: '2026-10-27', today: 1, payouts: ['2026-10-27'] };
|
||
assert.deepEqual(claimedCycle(client(second), 7, cfg, cdt('2026-10-27T17:00:00')),
|
||
{ date: '2026-10-27', stage: 2, drain: false, cycle: '2026-10-27', cycleSeq: 0 });
|
||
// A Claim now short of stage 2's $3,000 names nothing.
|
||
assert.equal(claimedCycle(client({ ...second, cash: { amount: 51000, realizedPnL: 1000 } }), 7, cfg, cdt('2026-10-27T17:00:00')), null);
|
||
// The day's third cycle, after that claim, has a key of its own.
|
||
const twice = [activated, { date: '2026-10-27', amount: -4500 }];
|
||
assert.deepEqual(claimedCycle(client({ ...second, rows: twice, today: 2, payouts: ['2026-10-27', '2026-10-27'] }), 7, cfg, cdt('2026-10-27T17:00:00')),
|
||
{ date: '2026-10-27', stage: 3, drain: false, cycle: '2026-10-27', cycleSeq: 1 });
|
||
|
||
// Stage 2 made only $1,500 of its $3,000 by the 20:00 ET reset, which
|
||
// puts the account back on stage 1's $1,500: claimed at the first tick,
|
||
// 8:00:30 PM ET.
|
||
const afterReset = { cash: { amount: 51500, realizedPnL: 0 }, rows: claimedOnce, last: '2026-10-27', day: '2026-10-28', today: 0, payouts: ['2026-10-27'] };
|
||
assert.deepEqual(claimedCycle(client(afterReset), 7, cfg, cdt('2026-10-27T19:00:30')),
|
||
{ date: '2026-10-28', stage: 2, drain: false, cycle: '2026-10-27', cycleSeq: 0 });
|
||
// The catch-up after it reads none of that cycle's P&L, all in 10-27's
|
||
// row, which the cycle before owns.
|
||
assert.equal(endedCycleCompletion({
|
||
accountId: 7, rawTradeDate: '2026-10-28', fullDailyPnL: [{ date: '2026-10-27', pnl: 3000 }], cycleDailyPnL: [],
|
||
fundTransactions: [...claimedOnce, { date: '2026-10-28', amount: -1500 }], lastFundDate: '2026-10-28',
|
||
endedCycleStart: { fundDate: '2026-10-27', includesDay: false }, rawRealizedPnL: 0, equityProfit: 0, cfg, now: cdt('2026-10-27T19:10:00'),
|
||
}), null);
|
||
|
||
// Where the catch-up does name the cycle — its P&L on a day of its own —
|
||
// the claim names it by the same key, so the master keeps one.
|
||
const crossDay = { cash: { amount: 51500, realizedPnL: 1500 }, rows: claimedBefore, last: '2026-10-25', day: '2026-10-26', today: 0, payouts: ['2026-10-25'] };
|
||
assert.deepEqual(claimedCycle(client(crossDay), 7, cfg, cdt('2026-10-26T12:00:00')),
|
||
{ date: '2026-10-26', stage: 2, drain: false, cycle: '2026-10-25', cycleSeq: 0 });
|
||
// Without a payout list, one payout per negative fund row.
|
||
assert.equal(claimedCycle(client({ ...crossDay, payouts: undefined }), 7, cfg, cdt('2026-10-26T12:00:00')).stage, 2);
|
||
// Numbers not read yet: nothing.
|
||
assert.equal(claimedCycle({ ...client(crossDay), accountCashBalances: {} }, 7, cfg, cdt('2026-10-26T12:00:00')), null);
|
||
assert.equal(claimedCycle({ ...client(crossDay), fundTransactions: {} }, 7, cfg, cdt('2026-10-26T12:00:00')), null);
|
||
});
|
||
|
||
test('withinEndedCycleWindow: a week after the date, and never for one that doesn\'t parse', () => {
|
||
assert.equal(withinEndedCycleWindow('2026-10-21', '2026-10-28'), true);
|
||
assert.equal(withinEndedCycleWindow('2026-10-28', '2026-10-28'), true);
|
||
assert.equal(withinEndedCycleWindow('2026-10-20', '2026-10-28'), false);
|
||
assert.equal(withinEndedCycleWindow('', '2026-10-28'), false);
|
||
assert.equal(withinEndedCycleWindow('10/27', '2026-10-28'), false);
|
||
});
|