projectPerfectPath and endedCycleCompletion counted every withdrawal ever made, so a Vest funded account that had claimed on any earlier day was projected at stage 2's $3,000 forever (labelled stage 3, 4, ...), and a day's stage-1 cycle that the auto-claim ended before the reporter saw it was judged against $3,000 and never reported. The projection now takes the stage inputs: a type whose stages reset each day trades today on today's payouts and every projected day from the base stage. The catch-up judges a same-day type's ended cycle on its day's base stage; the stage it reports stays the payout's number among the account's withdrawals, which the master clears the entry by. Other types project exactly as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1969 lines
96 KiB
TypeScript
1969 lines
96 KiB
TypeScript
export interface WithdrawalStage {
|
||
profit: number;
|
||
consistency: number;
|
||
minTradingDays: number;
|
||
/** Spam-withdraw stage: no profit target — book minTradingDays qualifying
|
||
* days of a few dollars (tiny MNQ trades), then wait for the withdrawal.
|
||
* The stage's consistency still applies: the first day is a random draw,
|
||
* later days are sized so the cycle's biggest day fits inside that share
|
||
* of the cycle total (see computeDayPlan). */
|
||
drain?: boolean;
|
||
/** Drain stages only: the per-day booked amount (default $5; randomized
|
||
* the fixed $4–$15 half-dollar grid by drainDayAmount; this field is
|
||
* kept for the wire format but no longer drives the amount). */
|
||
minDayPnL?: number;
|
||
/** Payout overrides for the withdrawal that completes THIS stage; absent
|
||
* fields fall back to the account type's defaults. */
|
||
withdrawalPct?: number;
|
||
payoutCap?: number;
|
||
/** This stage's hard daily loss limit in dollars; absent = the type's. */
|
||
dailyLossLimit?: number;
|
||
}
|
||
|
||
export interface EffectiveConfig {
|
||
profitTarget: number;
|
||
consistency: number;
|
||
minTradingDays: number;
|
||
minDayPnL: number;
|
||
drain: boolean;
|
||
/** The stage's hard daily loss limit (0 = none): its own, else the type's. */
|
||
dailyLossLimit: number;
|
||
}
|
||
|
||
/** What picks a stage beyond the fund rows: the type's daily loss limit (a
|
||
* stage may override it), and for a type whose stages reset each day, how
|
||
* many payouts the account has made since the firm's last daily reset. */
|
||
export interface StageInputs {
|
||
dailyLossLimit?: number;
|
||
stagesSameDay?: boolean;
|
||
sameDayWithdrawals?: number;
|
||
}
|
||
|
||
/**
|
||
* Resolve the effective profit target and consistency for an account based on its withdrawal strategy.
|
||
*
|
||
* Mode A (targetSameEquity=true): the account must reach the same cumulative equity level.
|
||
* remainingProfit = priorProfit + totalWithdrawals (profit still in the account after payouts)
|
||
* effectiveProfitTarget = profitTarget − remainingProfit
|
||
*
|
||
* Mode B (withdrawalStages non-empty): Stage 1 (no withdrawals yet) uses base profitTarget/consistency.
|
||
* After the Nth withdrawal, use withdrawalStages[N-1]; last stage repeats. A drain stage carries no
|
||
* profit target — it resolves to target 0 with drain=true and its own min-day amount.
|
||
* Fallback: returns base values unchanged.
|
||
*/
|
||
export function resolveEffectiveConfig(
|
||
profitTarget: number,
|
||
consistency: number,
|
||
minTradingDays: number,
|
||
targetSameEquity: boolean,
|
||
withdrawalStages: WithdrawalStage[],
|
||
priorProfit: number,
|
||
fundTransactions: { date: string; amount: number }[],
|
||
minDayPnL = 0,
|
||
inputs: StageInputs = {},
|
||
): EffectiveConfig {
|
||
const dailyLossLimit = Math.max(0, inputs.dailyLossLimit ?? 0);
|
||
if (targetSameEquity) {
|
||
// Simple Mode A: effective target = profitTarget - equityProfit, computed in computeDailyTarget.
|
||
// Since computeDailyTarget already compares (amount - accountSize) against profitTarget,
|
||
// no adjustment needed here — just return the base values.
|
||
return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false, dailyLossLimit };
|
||
}
|
||
if (withdrawalStages.length > 0) {
|
||
// Same-day stages (Vest): only today's payouts count, so every new
|
||
// day starts back at stage 1 — the room a payout frees lasts only
|
||
// until the firm's next daily reset.
|
||
const withdrawalCount = inputs.stagesSameDay
|
||
? Math.max(0, Math.floor(inputs.sameDayWithdrawals ?? 0))
|
||
: fundTransactions.filter((f) => f.amount < 0).length;
|
||
if (withdrawalCount === 0) {
|
||
return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false, dailyLossLimit }; // Stage 1 = base values
|
||
}
|
||
const idx = Math.min(withdrawalCount - 1, withdrawalStages.length - 1);
|
||
const stage = withdrawalStages[idx];
|
||
const stageLimit = typeof stage.dailyLossLimit === 'number' && stage.dailyLossLimit >= 0 ? stage.dailyLossLimit : dailyLossLimit;
|
||
if (stage.drain === true) {
|
||
return {
|
||
profitTarget: 0,
|
||
// The firm's consistency rule doesn't pause for the drain
|
||
// (Topstep still holds its ratio at payout), so carry the
|
||
// stage's value instead of zeroing it.
|
||
consistency: stage.consistency,
|
||
minTradingDays: stage.minTradingDays || minTradingDays,
|
||
// Drain amounts come from the fixed $4–$15 grid, so the
|
||
// qualifying bar sits at the grid floor — every draw counts.
|
||
minDayPnL: DRAIN_DAY_MIN,
|
||
drain: true,
|
||
dailyLossLimit: stageLimit,
|
||
};
|
||
}
|
||
// Fall back to base minTradingDays when the stage doesn't specify one (0 or missing)
|
||
return {
|
||
profitTarget: stage.profit, consistency: stage.consistency, minTradingDays: stage.minTradingDays || minTradingDays,
|
||
minDayPnL, drain: false, dailyLossLimit: stageLimit,
|
||
};
|
||
}
|
||
return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false, dailyLossLimit };
|
||
}
|
||
|
||
/** @deprecated Use resolveEffectiveConfig instead */
|
||
export function resolveEffectiveProfitTarget(
|
||
profitTarget: number,
|
||
targetSameEquity: boolean,
|
||
withdrawalStages: WithdrawalStage[],
|
||
priorProfit: number,
|
||
fundTransactions: { date: string; amount: number }[]
|
||
): number {
|
||
return resolveEffectiveConfig(profitTarget, 0, 0, targetSameEquity, withdrawalStages, priorProfit, fundTransactions).profitTarget;
|
||
}
|
||
|
||
// ── randomized targets / split legs ──────────────────────────────────────────
|
||
|
||
/** Split a day's target into multiple legs when it exceeds this. */
|
||
export const BIG_DAY_THRESHOLD = 1700;
|
||
/** Every split leg must be at least this. */
|
||
export const MIN_LEG = 500;
|
||
/** After at least one trade, a residual at or below this counts as "day done". */
|
||
export const LEG_COMPLETION_EPSILON = 25;
|
||
/** firstDayStop: the first cycle day books at this loss and stops. */
|
||
export const FIRST_DAY_STOP = 1000;
|
||
/** minDayStop: a min-day trade's stop sits at this multiple of its target. */
|
||
export const MIN_DAY_STOP_MULT = 2;
|
||
/** Safety valve: never fire more than this many legs per account per trade date. */
|
||
export const MAX_LEGS_PER_DAY = 8;
|
||
|
||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||
|
||
/**
|
||
* Deterministic random bump in {0, 5, 10, 15, 20, 25} for an (account, trade date) pair.
|
||
* Seeded (FNV-1a) rather than Math.random() so the dashboard, reporter, and trade path —
|
||
* which each compute the target independently — always agree on the same number.
|
||
*/
|
||
export function dailyBump(accountId: number, tradeDate: string): number {
|
||
const key = `${accountId}|${tradeDate}`;
|
||
let hash = 0x811c9dc5;
|
||
for (let i = 0; i < key.length; i++) {
|
||
hash ^= key.charCodeAt(i);
|
||
hash = Math.imul(hash, 0x01000193) >>> 0;
|
||
}
|
||
return (hash % 6) * 5;
|
||
}
|
||
|
||
/**
|
||
* Current Tradovate trade date as YYYY-MM-DD: the Chicago calendar date, rolled to the
|
||
* next day once the evening session opens (≥ 5:00 PM CT) — matching when Tradovate
|
||
* resets realizedPnL.
|
||
*/
|
||
export function currentTradeDateCT(now: Date = new Date()): string {
|
||
const parts = new Intl.DateTimeFormat('en-US', {
|
||
timeZone: 'America/Chicago',
|
||
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', hour12: false,
|
||
}).formatToParts(now);
|
||
const get = (t: string) => parts.find((p) => p.type === t)?.value ?? '0';
|
||
let date = Date.UTC(Number(get('year')), Number(get('month')) - 1, Number(get('day')));
|
||
if (Number(get('hour')) % 24 >= 17) date += 24 * 60 * 60 * 1000;
|
||
return new Date(date).toISOString().slice(0, 10);
|
||
}
|
||
|
||
/** Drain-day target grid: $4–$15 in $0.50 steps (23 values, MNQ's
|
||
* per-contract tick), always traded on MNQ. Drain days qualify at ≥
|
||
* DRAIN_DAY_MIN, so every draw counts. Seeded per (account, trade date) like
|
||
* dailyBump so every consumer of the plan computes the same number. Under a
|
||
* consistency rule only the cycle's first drain day draws from it. */
|
||
export const DRAIN_DAY_MIN = 4;
|
||
export const DRAIN_DAY_MAX = 15;
|
||
const DRAIN_DAY_STEPS = Math.round((DRAIN_DAY_MAX - DRAIN_DAY_MIN) / 0.5) + 1;
|
||
|
||
export function drainDayAmount(accountId: number, tradeDate: string): number {
|
||
const key = `${accountId}|drain|${tradeDate}`;
|
||
let hash = 0x811c9dc5;
|
||
for (let i = 0; i < key.length; i++) {
|
||
hash ^= key.charCodeAt(i);
|
||
hash = Math.imul(hash, 0x01000193) >>> 0;
|
||
}
|
||
return round2(DRAIN_DAY_MIN + (hash % DRAIN_DAY_STEPS) * 0.5);
|
||
}
|
||
|
||
/**
|
||
* Deterministic fraction in [0, 1) for a (prefix group, trade date, leg index) triple.
|
||
* Like accounts (same account-type prefix) carving the same leg of the same day draw the
|
||
* same fraction, so pickLegAmount splits their targets identically when their remaining
|
||
* amounts match and proportionally close when they differ (bumps, epsilon leftovers).
|
||
*/
|
||
export function groupLegFraction(prefix: string, tradeDate: string, legIndex: number): number {
|
||
const key = `${prefix}|${tradeDate}|${legIndex}`;
|
||
let hash = 0x811c9dc5;
|
||
for (let i = 0; i < key.length; i++) {
|
||
hash ^= key.charCodeAt(i);
|
||
hash = Math.imul(hash, 0x01000193) >>> 0;
|
||
}
|
||
return hash / 0x100000000;
|
||
}
|
||
|
||
/**
|
||
* Pick the next leg to trade toward today's remaining target.
|
||
* remaining ≤ BIG_DAY_THRESHOLD → trade it all in one final leg; otherwise carve a random
|
||
* $5-rounded piece from [MIN_LEG, remaining − MIN_LEG] so the remainder also stays ≥ MIN_LEG.
|
||
*
|
||
* minLegsLeft (an account's min-legs setting minus the legs already fired
|
||
* today) forces a carve even below the big-day threshold and reserves MIN_LEG
|
||
* for each leg still owed after this one. It is clamped to what MIN_LEG-sized
|
||
* pieces make possible, so a small coast day is never forced into impossible
|
||
* splits.
|
||
*/
|
||
export function pickLegAmount(remainingToday: number, rand: () => number = Math.random, minLegsLeft = 0): number {
|
||
const owedAfter = Math.min(minLegsLeft, Math.floor(remainingToday / MIN_LEG)) - 1;
|
||
if (owedAfter > 0) {
|
||
const lo = MIN_LEG;
|
||
const hi = remainingToday - owedAfter * MIN_LEG;
|
||
if (hi <= lo) return lo;
|
||
const leg = Math.round((lo + rand() * (hi - lo)) / 5) * 5;
|
||
return Math.min(Math.max(leg, lo), hi);
|
||
}
|
||
if (remainingToday <= BIG_DAY_THRESHOLD) return round2(remainingToday);
|
||
const lo = MIN_LEG;
|
||
const hi = remainingToday - MIN_LEG;
|
||
const leg = Math.round((lo + rand() * (hi - lo)) / 5) * 5;
|
||
return Math.min(Math.max(leg, lo), hi);
|
||
}
|
||
|
||
// ── CME holiday session combining ────────────────────────────────────────────
|
||
|
||
const fmtUTC = (d: Date) => d.toISOString().slice(0, 10);
|
||
|
||
/** The nth given weekday of a month, e.g. (2026, 8, 1, 1) = first Monday of September. */
|
||
function nthWeekday(year: number, month: number, weekday: number, n: number): string {
|
||
const first = new Date(Date.UTC(year, month, 1));
|
||
const offset = (weekday - first.getUTCDay() + 7) % 7;
|
||
return fmtUTC(new Date(Date.UTC(year, month, 1 + offset + (n - 1) * 7)));
|
||
}
|
||
|
||
/** The last given weekday of a month, e.g. (2026, 4, 1) = last Monday of May. */
|
||
function lastWeekday(year: number, month: number, weekday: number): string {
|
||
const last = new Date(Date.UTC(year, month + 1, 0));
|
||
const offset = (last.getUTCDay() - weekday + 7) % 7;
|
||
return fmtUTC(new Date(Date.UTC(year, month + 1, 0 - offset)));
|
||
}
|
||
|
||
/** A fixed-date holiday as observed: Saturday moves to Friday, Sunday to Monday. */
|
||
function observed(year: number, month: number, day: number): string {
|
||
const d = new Date(Date.UTC(year, month, day));
|
||
if (d.getUTCDay() === 6) d.setUTCDate(d.getUTCDate() - 1);
|
||
else if (d.getUTCDay() === 0) d.setUTCDate(d.getUTCDate() + 1);
|
||
return fmtUTC(d);
|
||
}
|
||
|
||
const holidayCache = new Map<number, Set<string>>();
|
||
|
||
/**
|
||
* Trade dates in the given year whose CME session does not clear on its own:
|
||
* the exchange runs a shortened holiday session (opened the prior evening) and
|
||
* clearing folds it into the next full trading day. Firms that follow CME
|
||
* reporting count the whole stretch as ONE trading day — e.g. Apex on Labor
|
||
* Day 2026: "Sunday, Monday, and Tuesday trading activity will be combined
|
||
* and recorded as a single trading day on Tuesday, September 8."
|
||
*
|
||
* Computed from the federal-holiday rules rather than a hand-kept table so
|
||
* every year works. Full-closure days (Good Friday, Christmas, New Year's
|
||
* Day) have no session and produce no P&L rows, so they aren't included.
|
||
*/
|
||
function cmeUnsettledDates(year: number): Set<string> {
|
||
let dates = holidayCache.get(year);
|
||
if (!dates) {
|
||
dates = new Set([
|
||
nthWeekday(year, 0, 1, 3), // MLK Day — 3rd Monday of January
|
||
nthWeekday(year, 1, 1, 3), // Presidents' Day — 3rd Monday of February
|
||
lastWeekday(year, 4, 1), // Memorial Day — last Monday of May
|
||
observed(year, 5, 19), // Juneteenth
|
||
observed(year, 6, 4), // Independence Day
|
||
nthWeekday(year, 8, 1, 1), // Labor Day — 1st Monday of September
|
||
nthWeekday(year, 10, 4, 4), // Thanksgiving — 4th Thursday of November
|
||
]);
|
||
holidayCache.set(year, dates);
|
||
}
|
||
return dates;
|
||
}
|
||
|
||
/** Weekends never clear either — a weekend-dated Cash History row (Sunday
|
||
* evening activity attributed to its calendar date) belongs to the next
|
||
* settling day, which is precisely why Apex's holiday notices say "Sunday,
|
||
* Monday, and Tuesday" and not just the holiday itself. */
|
||
function isUnsettledTradeDate(date: string, dayOfWeek: number): boolean {
|
||
if (dayOfWeek === 0 || dayOfWeek === 6) return true;
|
||
return cmeUnsettledDates(Number(date.slice(0, 4))).has(date);
|
||
}
|
||
|
||
/** The trade date a session clears under: weekend- and holiday-dated activity
|
||
* rolls forward, through any consecutive unsettled dates, to the next date
|
||
* that settles. */
|
||
export function combinedTradeDate(date: string): string {
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return date;
|
||
const d = new Date(`${date}T00:00:00Z`);
|
||
if (!isUnsettledTradeDate(date, d.getUTCDay())) return date;
|
||
for (let i = 0; i < 7; i++) {
|
||
d.setUTCDate(d.getUTCDate() + 1);
|
||
const next = fmtUTC(d);
|
||
if (!isUnsettledTradeDate(next, d.getUTCDay())) return next;
|
||
}
|
||
return date; // unreachable: no unsettled stretch spans a week
|
||
}
|
||
|
||
export interface CombinedSessionView {
|
||
/** Daily P&L with holiday-session entries folded into their clearing date. */
|
||
dailyPnL: { date: string; pnl: number }[];
|
||
/** The trade date mapped to its clearing date. */
|
||
tradeDate: string;
|
||
/** Live realizedPnL plus P&L already booked on earlier sessions of the same combined day. */
|
||
realizedPnL: number;
|
||
/** Trading days as the firm counts them (nonzero combined entries). */
|
||
daysTraded: number;
|
||
}
|
||
|
||
/**
|
||
* The account's day-based state as its firm records it. With `enabled` false
|
||
* this is the identity view, so callers can apply it unconditionally.
|
||
*
|
||
* On the later sessions of a combined day (e.g. the Tuesday after a Monday
|
||
* holiday), Tradovate's live realizedPnL covers only the current session; the
|
||
* earlier sessions' booked P&L is added back so day targets, already-traded
|
||
* gates and the day's displayed P&L all treat the stretch as one day. Entries
|
||
* dated `tradeDate` itself are never added — computeDayPlan excludes that row
|
||
* and live realizedPnL already covers it.
|
||
*/
|
||
export function combineCmeSessions(
|
||
enabled: boolean,
|
||
dailyPnL: { date: string; pnl: number }[],
|
||
tradeDate: string,
|
||
realizedPnL: number,
|
||
): CombinedSessionView {
|
||
if (!enabled) {
|
||
return {
|
||
dailyPnL, tradeDate, realizedPnL,
|
||
daysTraded: dailyPnL.filter((d) => d.pnl !== 0).length,
|
||
};
|
||
}
|
||
|
||
const merged = new Map<string, number>();
|
||
for (const entry of dailyPnL) {
|
||
const date = combinedTradeDate(entry.date);
|
||
merged.set(date, round2((merged.get(date) ?? 0) + entry.pnl));
|
||
}
|
||
const combined = [...merged.entries()]
|
||
.map(([date, pnl]) => ({ date, pnl }))
|
||
.sort((a, b) => a.date.localeCompare(b.date));
|
||
|
||
const effectiveTradeDate = combinedTradeDate(tradeDate);
|
||
const carry = dailyPnL
|
||
.filter((d) => d.date !== tradeDate && combinedTradeDate(d.date) === effectiveTradeDate)
|
||
.reduce((sum, d) => sum + d.pnl, 0);
|
||
|
||
return {
|
||
dailyPnL: combined,
|
||
tradeDate: effectiveTradeDate,
|
||
realizedPnL: round2(realizedPnL + carry),
|
||
daysTraded: combined.filter((d) => d.pnl !== 0).length,
|
||
};
|
||
}
|
||
|
||
// ── perfect-path projection ──────────────────────────────────────────────────
|
||
|
||
export interface ProjectedDay {
|
||
date: string;
|
||
/** The day's booked P&L on the perfect path (0 on withdrawal/payout days). */
|
||
pnl: number;
|
||
/** Stage the account is on while trading this day. */
|
||
stage: number;
|
||
drain: boolean;
|
||
/** A simulated withdrawal (negative) lands on this day — cycle resets after it. */
|
||
withdrawal?: number;
|
||
/** Non-staged account completed here; the projection stops. */
|
||
payoutReady?: boolean;
|
||
/** Cumulative probability of the path having survived through this day,
|
||
* under the fair-walk ruin model: each trading day survives with
|
||
* room/(room + dayTarget), where room is the distance to the trailing
|
||
* liquidation threshold (the per-leg product telescopes to exactly
|
||
* this). 1 when no drawdown applies. */
|
||
prob: number;
|
||
/** Withdrawal entries: gross amount leaving the account
|
||
* (min(withdrawalPct × balance, cap)) and the net the person receives
|
||
* (gross × profitSplit). */
|
||
gross?: number;
|
||
net?: number;
|
||
}
|
||
|
||
/** Next trade date after d (YYYY-MM-DD): the next weekday on the CME calendar,
|
||
* where trade dates never fall on Sat/Sun — or simply the next calendar day
|
||
* for a firm that trades every day (24/7 crypto/CFD, e.g. Tradeify247). */
|
||
export function nextTradingDate(d: string, everyDay = false): string {
|
||
const dt = new Date(`${d}T00:00:00Z`);
|
||
do { dt.setUTCDate(dt.getUTCDate() + 1); } while (!everyDay && (dt.getUTCDay() === 0 || dt.getUTCDay() === 6));
|
||
return dt.toISOString().slice(0, 10);
|
||
}
|
||
|
||
/** Whether CME holiday sessions fold into the next day for this account type.
|
||
* Never for an every-day firm: it has no CME calendar to fold by. The catalog
|
||
* sync already clears the flag for those firms; this is the backstop. */
|
||
export function effectiveCombine(cfg: { combineCmeHolidays?: boolean; tradesEveryDay?: boolean }): boolean {
|
||
return cfg.combineCmeHolidays === true && cfg.tradesEveryDay !== true;
|
||
}
|
||
|
||
/**
|
||
* Simulate the account's future assuming every day goes perfectly: each
|
||
* trading day books exactly its planned target (deterministic bumps included,
|
||
* so the numbers match what the trader would actually aim for), stages
|
||
* complete, a 50% withdrawal fires the day a cycle finishes, and drain cycles
|
||
* repeat. Built on the same computeDayPlan/resolveEffectiveConfig the live
|
||
* trade path uses — the projection can't drift from reality's rules.
|
||
*
|
||
* An account without withdrawal stages stops at its first completion, marked
|
||
* payoutReady. Projection starts on the next trading day (today's cell shows
|
||
* live data); today's remaining target is booked silently first.
|
||
*/
|
||
export function projectPerfectPath(opts: {
|
||
accountId: number;
|
||
cfg: {
|
||
profitTarget: number; consistency: number; minDayPnL: number;
|
||
minTradingDays: number; accountSize: number; targetSameEquity: boolean;
|
||
withdrawalStages: WithdrawalStage[]; splitEnabled: boolean;
|
||
/** Firm counts CME holiday sessions with the next full day — the
|
||
* projection then folds P&L rows the same way and never books a
|
||
* projected day on a holiday session. */
|
||
combineCmeHolidays?: boolean;
|
||
/** True = completing a cycle pays out and the account keeps going
|
||
* (withdraw, reset, re-target). False = it just PASSES once (an
|
||
* eval) and the projection stops. */
|
||
payoutable?: boolean;
|
||
/** The firm trades every calendar day (24/7): weekends and CME
|
||
* holidays are ordinary projected days, and nothing combines. */
|
||
tradesEveryDay?: boolean;
|
||
};
|
||
/** Current-cycle daily P&L (account.dailyPnL). */
|
||
cycleDailyPnL: { date: string; pnl: number }[];
|
||
fundTransactions: { date: string; amount: number }[];
|
||
balance: number;
|
||
realizedPnL: number;
|
||
/** What picks the stage beyond the fund rows (stageInputsFor). A type
|
||
* whose stages reset each day trades today on today's payouts and
|
||
* every projected day from the base stage. Absent = every withdrawal
|
||
* counts. */
|
||
stageInputs?: StageInputs;
|
||
/** Trading days to look ahead. */
|
||
horizonDays?: number;
|
||
maxWithdrawals?: number;
|
||
/** Payout + risk model. Absent fields fall back to: withdraw 50%,
|
||
* uncapped, 90% split, no drawdown (prob 1). */
|
||
payout?: {
|
||
withdrawalPct?: number;
|
||
payoutCap?: number;
|
||
profitSplit?: number;
|
||
/** Trailing drawdown in dollars for the ruin probabilities. */
|
||
drawdown?: number;
|
||
/** Current cycle's end-of-day equity-profit high-water mark. */
|
||
startHigh?: number;
|
||
/** The account's REAL current liquidation floor on the equity-profit
|
||
* scale (platform floor − account size), when the platform reports
|
||
* one (Tradovate auto-liq). The modeled trail never sits below it,
|
||
* so today's room is exact and only tightens going forward. */
|
||
startThreshold?: number;
|
||
};
|
||
/** Defaults to the current time. */
|
||
now?: Date;
|
||
}): ProjectedDay[] {
|
||
const { accountId, cfg } = opts;
|
||
const horizon = opts.horizonDays ?? 40;
|
||
const maxWithdrawals = opts.maxWithdrawals ?? 6;
|
||
const basePct = opts.payout?.withdrawalPct ?? 0.5;
|
||
const baseCap = opts.payout?.payoutCap ?? 0;
|
||
const profitSplit = opts.payout?.profitSplit ?? 0.9;
|
||
const dd = Math.max(0, opts.payout?.drawdown ?? 0);
|
||
const everyDay = cfg.tradesEveryDay === true;
|
||
|
||
const rawToday = currentTradeDateCT(opts.now);
|
||
// The firm's own day view: a combining firm folds holiday-session P&L
|
||
// into its clearing date — a Labor-Day trade and the Tuesday after it are
|
||
// ONE trading day, exactly as the firm counts min days. Identity view
|
||
// when combining is off.
|
||
const combine = effectiveCombine(cfg);
|
||
const view = combineCmeSessions(combine, opts.cycleDailyPnL, rawToday, opts.realizedPnL);
|
||
// A weekend "today" (Saturday, or Sunday before the evening open) has no
|
||
// session — the platform's realizedPnL is the LAST session's stale number,
|
||
// already booked in cycleDailyPnL under its own date. Adding it as a
|
||
// today row would fabricate an extra qualifying day. An every-day firm
|
||
// trades weekends, so its weekend today is a live session like any other.
|
||
const todayDow = new Date(`${rawToday}T00:00:00Z`).getUTCDay();
|
||
const todayHasSession = everyDay || (todayDow !== 0 && todayDow !== 6);
|
||
// Today's clearing date when a session exists; on weekends keep the raw
|
||
// date so the projection's first day is Monday, not the day after it
|
||
// (combinedTradeDate rolls weekend dates forward).
|
||
const today = todayHasSession ? view.tradeDate : rawToday;
|
||
let sim = view.dailyPnL.filter((d) => d.date !== today);
|
||
if (todayHasSession && view.realizedPnL !== 0) sim = [...sim, { date: today, pnl: view.realizedPnL }];
|
||
/** Next date the firm settles a session on: weekends skip (except for an
|
||
* every-day firm), and a combining firm's holiday sessions clear into
|
||
* the next full day. */
|
||
const nextDay = (d: string): string => {
|
||
let n = nextTradingDate(d, everyDay);
|
||
while (combine && combinedTradeDate(n) !== n) n = nextTradingDate(n);
|
||
return n;
|
||
};
|
||
const funds = [...opts.fundTransactions];
|
||
let balance = opts.balance;
|
||
let withdrawals = 0;
|
||
const out: ProjectedDay[] = [];
|
||
|
||
// Fair-walk ruin model, mirroring the master's payout sim: the trailing
|
||
// liquidation threshold follows the end-of-day equity-profit high until
|
||
// profit reaches the drawdown, then locks at breakeven — and a past
|
||
// withdrawal means it locked long ago. Within a day the per-leg odds
|
||
// telescope, so a whole day survives with exactly room/(room + target).
|
||
let high = Math.max(0, opts.payout?.startHigh ?? 0, balance - cfg.accountSize);
|
||
if (funds.some((f) => f.amount < 0)) high = Math.max(high, dd);
|
||
// The platform's real floor (when reported) is the baseline the modeled
|
||
// trail can never sit below — a floor that already trailed higher than
|
||
// our history explains stays exact.
|
||
const startThreshold = opts.payout?.startThreshold;
|
||
let prob = 1;
|
||
const dayProb = (dayTarget: number): number => {
|
||
if (dayTarget <= 0) return 1;
|
||
if (dd <= 0 && startThreshold === undefined) return 1;
|
||
const modeled = dd > 0 ? (high >= dd ? 0 : high - dd) : -Infinity;
|
||
const threshold = Math.max(modeled, startThreshold ?? -Infinity);
|
||
const room = (balance - cfg.accountSize) - threshold;
|
||
if (room <= 0) return 0;
|
||
return room / (room + dayTarget);
|
||
};
|
||
|
||
// A type whose stages reset each day (Vest) counts only the payouts made
|
||
// since its day began: today's real ones, and none on a projected day —
|
||
// the path pays out at most once a day, as the day's last entry, so
|
||
// every projected day trades the base stage.
|
||
const sameDay = opts.stageInputs?.stagesSameDay === true;
|
||
/** Payouts before the stage traded on `day`: that day's own for a
|
||
* same-day type, else every withdrawal so far. */
|
||
const paidBefore = (day: string): number => (sameDay
|
||
? (day === today ? Math.max(0, Math.floor(opts.stageInputs?.sameDayWithdrawals ?? 0)) : 0)
|
||
: funds.filter((f) => f.amount < 0).length);
|
||
const effectiveOn = (day: string) => resolveEffectiveConfig(
|
||
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity,
|
||
cfg.withdrawalStages, 0, funds, cfg.minDayPnL,
|
||
sameDay ? { stagesSameDay: true, sameDayWithdrawals: paidBefore(day) } : {},
|
||
);
|
||
/** The payout parameters of the stage being completed: per-stage
|
||
* overrides when set, else the type defaults. */
|
||
const stagePayout = (stage: number): { pct: number; cap: number } => {
|
||
const count = stage - 1;
|
||
if (count === 0 || cfg.withdrawalStages.length === 0) return { pct: basePct, cap: baseCap };
|
||
const s = cfg.withdrawalStages[Math.min(count - 1, cfg.withdrawalStages.length - 1)];
|
||
return { pct: s.withdrawalPct ?? basePct, cap: s.payoutCap ?? baseCap };
|
||
};
|
||
const qualifyingDays = (minDay: number, excludeDate: string) => (
|
||
minDay > 0
|
||
? sim.filter((d) => d.date !== excludeDate && d.pnl >= minDay).length
|
||
: sim.filter((d) => d.date !== excludeDate && d.pnl !== 0).length
|
||
);
|
||
|
||
// Book the rest of today silently — the perfect path finishes today too.
|
||
// Skipped when "today" is a weekend date (Saturday, or Friday evening
|
||
// after the roll): there is no session to finish.
|
||
if (todayHasSession) {
|
||
const eff = effectiveOn(today);
|
||
const plan = computeDayPlan(
|
||
accountId, today, eff.profitTarget, eff.consistency, sim, eff.minDayPnL,
|
||
eff.minTradingDays, balance - cfg.accountSize, opts.realizedPnL, cfg.splitEnabled, eff.drain,
|
||
);
|
||
if (plan && plan.dayTarget > 0 && plan.remainingToday > 0) {
|
||
prob *= dayProb(plan.remainingToday);
|
||
const idx = sim.findIndex((d) => d.date === today);
|
||
const pnl = round2(opts.realizedPnL + plan.remainingToday);
|
||
if (idx >= 0) sim[idx] = { date: today, pnl };
|
||
else sim.push({ date: today, pnl });
|
||
balance = round2(balance + plan.remainingToday);
|
||
high = Math.max(high, balance - cfg.accountSize);
|
||
}
|
||
}
|
||
|
||
// Fires the payout ON the given date — the trading day the cycle's final
|
||
// required day books (the last traded day, when the cycle already stands
|
||
// complete), the payout becomes requestable after that session. The withdrawal takes the completed stage's
|
||
// share of the EQUITY PROFIT (for a $0-start account that is the whole
|
||
// balance), capped. Prop payouts come out of profit, never the funded
|
||
// principal. Returns true when the projection should stop (no further
|
||
// stages).
|
||
// A cycle that completes keeps looping when the account has withdrawal
|
||
// stages OR is simply payoutable — a same-equity payout account (no
|
||
// explicit stages) withdraws its share and re-targets the same equity,
|
||
// over and over. Only a non-payoutable eval stops after one completion.
|
||
const loops = cfg.withdrawalStages.length > 0 || cfg.payoutable === true;
|
||
const finishCycle = (onDate: string, dayPnl: number, stage: number, drain: boolean): boolean => {
|
||
const { pct, cap } = stagePayout(stage);
|
||
const rawGross = round2(Math.max(0, balance - cfg.accountSize) * pct);
|
||
const gross = cap > 0 ? Math.min(rawGross, round2(cap)) : rawGross;
|
||
const net = round2(gross * profitSplit);
|
||
if (loops && withdrawals < maxWithdrawals && gross > 1) {
|
||
funds.push({ date: onDate, amount: -gross });
|
||
balance = round2(balance - gross);
|
||
sim = []; // withdrawal excludes its day — fresh cycle
|
||
withdrawals++;
|
||
high = Math.max(high, dd); // a payout locks the trail at breakeven
|
||
out.push({ date: onDate, pnl: dayPnl, stage, drain, withdrawal: -gross, prob, gross, net });
|
||
return false;
|
||
}
|
||
out.push({ date: onDate, pnl: dayPnl, stage, drain, payoutReady: true, prob, gross, net });
|
||
return true;
|
||
};
|
||
|
||
// Already complete right now — target hit and min days done? The payout
|
||
// anchors to the trading day the cycle finished: the last traded day,
|
||
// requestable after that session's close. Never "today" — a projection
|
||
// computed over the weekend must not slide a Friday finish to Sunday.
|
||
{
|
||
const eff = effectiveOn(today);
|
||
const probe = nextDay(today);
|
||
const plan = computeDayPlan(
|
||
accountId, probe, eff.profitTarget, eff.consistency, sim, eff.minDayPnL,
|
||
eff.minTradingDays, balance - cfg.accountSize, 0, cfg.splitEnabled, eff.drain,
|
||
);
|
||
if (plan && plan.dayTarget <= 0 && eff.minTradingDays <= qualifyingDays(eff.minDayPnL, probe)) {
|
||
const stage = 1 + paidBefore(today);
|
||
const traded = sim.filter((d) => d.pnl !== 0);
|
||
const doneOn = traded.length > 0
|
||
? traded.reduce((a, b) => (a.date > b.date ? a : b)).date
|
||
: today;
|
||
if (finishCycle(doneOn, 0, stage, eff.drain)) return out;
|
||
}
|
||
}
|
||
|
||
let date = today;
|
||
for (let step = 0; step < horizon; step++) {
|
||
date = nextDay(date);
|
||
const eff = effectiveOn(date);
|
||
const stage = 1 + paidBefore(date);
|
||
const plan = computeDayPlan(
|
||
accountId, date, eff.profitTarget, eff.consistency, sim, eff.minDayPnL,
|
||
eff.minTradingDays, balance - cfg.accountSize, 0, cfg.splitEnabled, eff.drain,
|
||
);
|
||
if (!plan) break;
|
||
|
||
const daysNeeded = eff.minTradingDays > qualifyingDays(eff.minDayPnL, date);
|
||
|
||
if (plan.dayTarget <= 0 && !daysNeeded) {
|
||
// The cycle completed mid-projection with nothing left to trade —
|
||
// the pending payout lands on this day.
|
||
if (finishCycle(date, 0, stage, eff.drain)) break;
|
||
continue;
|
||
}
|
||
|
||
// Extra days (min-day 0, days still owed) book a token dollar so the
|
||
// day counts, matching the real in-and-out MNQ trade — riskless.
|
||
const pnl = plan.dayTarget > 0 ? plan.remainingToday : 1;
|
||
if (plan.dayTarget > 0) prob *= dayProb(plan.remainingToday);
|
||
sim.push({ date, pnl });
|
||
balance = round2(balance + pnl);
|
||
high = Math.max(high, balance - cfg.accountSize);
|
||
|
||
// Did this day complete the cycle? Then it IS the payout day.
|
||
const nd = nextDay(date);
|
||
const eff2 = effectiveOn(date);
|
||
const plan2 = computeDayPlan(
|
||
accountId, nd, eff2.profitTarget, eff2.consistency, sim, eff2.minDayPnL,
|
||
eff2.minTradingDays, balance - cfg.accountSize, 0, cfg.splitEnabled, eff2.drain,
|
||
);
|
||
const doneAfter = plan2 !== null
|
||
&& plan2.dayTarget <= 0
|
||
&& eff2.minTradingDays <= qualifyingDays(eff2.minDayPnL, nd);
|
||
if (doneAfter) {
|
||
if (finishCycle(date, pnl, stage, eff.drain)) break;
|
||
} else {
|
||
out.push({ date, pnl, stage, drain: eff.drain, prob });
|
||
}
|
||
}
|
||
|
||
return out;
|
||
}
|
||
|
||
/** Where the current cycle stands against today's P&L: 'today' = today's
|
||
* trading finished it (the final day), 'before' = it already stood complete
|
||
* without today, 'none' = profit or days still owed. */
|
||
export type CycleCompletion = 'none' | 'today' | 'before';
|
||
|
||
/** How long a new trade date waits before it can read 'today': the platforms
|
||
* reset realizedPnL on their own clock (a Tradovate cashBalance event, the
|
||
* TopstepX 60s sync), not at 5 PM sharp, and until then the last session's
|
||
* number — already booked under its own date — would count as a second day. */
|
||
const ROLL_SETTLE_MS = 5 * 60 * 1000;
|
||
|
||
/** How long after a fund transaction starts a new cycle the cycle it ended is
|
||
* still checked for a missed completion. */
|
||
const ENDED_CYCLE_WINDOW_DAYS = 7;
|
||
|
||
/** Nothing left to trade AND the days booked: with no min-day amount the
|
||
* plan coasts at 0 while days are still owed, so count them too. */
|
||
function cycleOwesNothing(
|
||
accountId: number,
|
||
eff: EffectiveConfig,
|
||
rows: { date: string; pnl: number }[],
|
||
date: string,
|
||
equityProfit: number,
|
||
realized: number,
|
||
splitEnabled: boolean,
|
||
): boolean {
|
||
const plan = computeDayPlan(
|
||
accountId, date, eff.profitTarget, eff.consistency, rows, eff.minDayPnL,
|
||
eff.minTradingDays, equityProfit, realized, splitEnabled, eff.drain,
|
||
);
|
||
const qualifying = rows.filter((d) => d.date !== date
|
||
&& (eff.minDayPnL > 0 ? d.pnl >= eff.minDayPnL : d.pnl !== 0)).length;
|
||
return plan !== null && plan.dayTarget <= 0 && qualifying >= eff.minTradingDays;
|
||
}
|
||
|
||
/** Is the live realizedPnL too doubtful to count as today's session? A
|
||
* weekend (it is the last session's stale number, already booked under its
|
||
* own date), a Monday with a weekend-dated row — the live session filed under
|
||
* a weekend date, which would count it twice — and the first minutes after
|
||
* the 5 PM roll. An every-day firm's weekends are real sessions, so only the
|
||
* roll window applies to it. */
|
||
function sessionDoubtful(
|
||
rawTradeDate: string,
|
||
cycleDailyPnL: { date: string; pnl: number }[],
|
||
now: Date,
|
||
everyDay = false,
|
||
): boolean {
|
||
const day = new Date(`${rawTradeDate}T00:00:00Z`);
|
||
const dow = day.getUTCDay();
|
||
if (!everyDay && (dow === 0 || dow === 6)) return true;
|
||
if (!everyDay && dow === 1) {
|
||
const sunday = fmtUTC(new Date(day.getTime() - 24 * 60 * 60 * 1000));
|
||
const saturday = fmtUTC(new Date(day.getTime() - 2 * 24 * 60 * 60 * 1000));
|
||
if (cycleDailyPnL.some((d) => d.date === saturday || d.date === sunday)) return true;
|
||
}
|
||
// Settle window: a few minutes ago it was still an earlier trade date.
|
||
return currentTradeDateCT(new Date(now.getTime() - ROLL_SETTLE_MS)) < rawTradeDate;
|
||
}
|
||
|
||
/**
|
||
* Did today's trading finish the account's current cycle — profit target,
|
||
* consistency and minimum days all met? projectPerfectPath's "already
|
||
* complete" probe, run once without today's P&L and once with it, so only
|
||
* the final day reads 'today'.
|
||
*
|
||
* The master keeps a 'today' permanently, so every doubtful case reads
|
||
* 'none' instead: a weekend (realizedPnL is the last session's stale number,
|
||
* already booked under its own date), a Monday with a weekend-dated row — the
|
||
* live session filed under a weekend date, which would count it twice — and
|
||
* the first minutes after the 5 PM roll. 'before' skips those guards: it only
|
||
* keeps a locked, untraded pass out of the reporter's losses.
|
||
*/
|
||
export function cycleCompletion(opts: {
|
||
accountId: number;
|
||
/** currentTradeDateCT() — NOT the combined date, which rolls a weekend
|
||
* forward to Monday. */
|
||
rawTradeDate: string;
|
||
/** Current-cycle daily P&L as the client holds it (client.dailyPnL). */
|
||
cycleDailyPnL: { date: string; pnl: number }[];
|
||
rawRealizedPnL: number;
|
||
combineCmeHolidays: boolean;
|
||
/** The firm trades every calendar day — weekends are real sessions. */
|
||
tradesEveryDay?: boolean;
|
||
effective: EffectiveConfig;
|
||
/** amount − accountSize — today's P&L included. */
|
||
equityProfit: number;
|
||
splitEnabled: boolean;
|
||
/** Defaults to the current time. */
|
||
now?: Date;
|
||
}): CycleCompletion {
|
||
const { accountId, rawTradeDate, effective: eff, equityProfit, splitEnabled } = opts;
|
||
const everyDay = opts.tradesEveryDay === true;
|
||
const view = combineCmeSessions(
|
||
effectiveCombine({ combineCmeHolidays: opts.combineCmeHolidays, tradesEveryDay: everyDay }),
|
||
opts.cycleDailyPnL, rawTradeDate, opts.rawRealizedPnL,
|
||
);
|
||
const prior = view.dailyPnL.filter((d) => d.date !== view.tradeDate);
|
||
|
||
if (cycleOwesNothing(accountId, eff, prior, view.tradeDate, equityProfit, view.realizedPnL, splitEnabled)) return 'before';
|
||
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay)) return 'none';
|
||
|
||
const withToday = [...prior, { date: view.tradeDate, pnl: view.realizedPnL }];
|
||
const probe = nextTradingDate(view.tradeDate, everyDay);
|
||
if (view.realizedPnL !== 0 && cycleOwesNothing(accountId, eff, withToday, probe, equityProfit, 0, splitEnabled)) return 'today';
|
||
return 'none';
|
||
}
|
||
|
||
/**
|
||
* Catch-up for a missed final day: did the cycle the latest fund transaction
|
||
* just ended stand complete — profit target, consistency and minimum days all
|
||
* met? cycleCompletion reads 'today' on the final day only, so a completion
|
||
* its guards held back (or that the instance was offline for) would vanish
|
||
* once the payout, erasure or reset starts a new cycle and the days revert
|
||
* to 0. Checked for a week after that fund transaction.
|
||
*
|
||
* The ended cycle is judged on its own rows — from the previous fund date
|
||
* (its own row too when the client counted it in, as it does for a deposit),
|
||
* up to the latest one, less any the current cycle holds — against the
|
||
* equity it ended on: live equity less the current cycle's P&L and the fund
|
||
* transaction itself. A failed account's reset (a positive adjustment after
|
||
* a cycle that fell short) correctly reads null: the ended cycle never met
|
||
* the rule. Null on a doubtful session too — on a weekend the live
|
||
* realizedPnL is stale and would come off equity twice — and when this
|
||
* instance never saw the ended cycle start (a new instance or a wipe, whose
|
||
* fetch window cuts it short; TopstepX's first-check lump, which isn't a
|
||
* cycle at all).
|
||
*
|
||
* `cycle` is the fund date that STARTED the ended cycle ('' for the
|
||
* account's first), which is what client.lastFundDates held while it was
|
||
* current — the same key the final-day path reported for this cycle, so the
|
||
* master dedupes the two.
|
||
*/
|
||
export function endedCycleCompletion(opts: {
|
||
accountId: number;
|
||
/** currentTradeDateCT(). */
|
||
rawTradeDate: string;
|
||
/** Every cycle's daily P&L, raw (client.fullDailyPnL). */
|
||
fullDailyPnL: { date: string; pnl: number }[];
|
||
/** Current-cycle daily P&L, raw (client.dailyPnL). */
|
||
cycleDailyPnL: { date: string; pnl: number }[];
|
||
fundTransactions: { date: string; amount: number }[];
|
||
/** client.lastFundDates — the fund transaction that started the current cycle. */
|
||
lastFundDate: string | null;
|
||
/** loadEndedCycleStart() — where the client saw the ended cycle begin,
|
||
* null when it never did. */
|
||
endedCycleStart: { fundDate: string; includesDay: boolean } | null;
|
||
rawRealizedPnL: number;
|
||
/** amount − accountSize — today's P&L included. */
|
||
equityProfit: number;
|
||
cfg: {
|
||
profitTarget: number; consistency: number; minTradingDays: number; minDayPnL: number;
|
||
targetSameEquity: boolean; withdrawalStages: WithdrawalStage[];
|
||
combineCmeHolidays: boolean; splitEnabled: boolean;
|
||
tradesEveryDay?: boolean;
|
||
/** Stages reset each day (StageInputs). */
|
||
stagesSameDay?: boolean;
|
||
};
|
||
/** Defaults to the current time. */
|
||
now?: Date;
|
||
}): { date: string; stage: number; drain: boolean; cycle: string } | null {
|
||
const { accountId, rawTradeDate, lastFundDate, endedCycleStart: start, fundTransactions, cfg } = opts;
|
||
const everyDay = cfg.tradesEveryDay === true;
|
||
const combine = effectiveCombine(cfg);
|
||
if (lastFundDate === null || start === null) return null;
|
||
const ageDays = (Date.parse(`${rawTradeDate}T00:00:00Z`) - Date.parse(`${lastFundDate}T00:00:00Z`)) / (24 * 60 * 60 * 1000);
|
||
if (!(ageDays <= ENDED_CYCLE_WINDOW_DAYS)) return null; // NaN included: an unparseable date
|
||
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay)) return null;
|
||
|
||
const latest = (a: string, b: string) => (b > a ? b : a);
|
||
const earlierFunds = fundTransactions.filter((f) => f.date < lastFundDate);
|
||
const prevFundDate = earlierFunds.length > 0 ? earlierFunds.map((f) => f.date).reduce(latest) : null;
|
||
// Saved for another transition — two fund dates since the client last
|
||
// looked — so the start it saw isn't this cycle's.
|
||
if (start.fundDate !== (prevFundDate ?? '')) return null;
|
||
const current = new Set(opts.cycleDailyPnL.map((d) => d.date));
|
||
const endedRaw = opts.fullDailyPnL.filter((d) => d.date <= lastFundDate
|
||
&& (prevFundDate === null || d.date > prevFundDate || (start.includesDay && d.date === prevFundDate))
|
||
&& !current.has(d.date));
|
||
if (!endedRaw.some((d) => d.pnl !== 0)) return null;
|
||
|
||
// Equity the ended cycle finished on, worked back from live equity: less
|
||
// everything the current cycle booked (today's live P&L included) and
|
||
// less the fund transaction between the two.
|
||
const cur = combineCmeSessions(combine, opts.cycleDailyPnL, rawTradeDate, opts.rawRealizedPnL);
|
||
const curTotal = cur.dailyPnL.filter((d) => d.date !== cur.tradeDate).reduce((s, d) => s + d.pnl, 0)
|
||
+ cur.realizedPnL;
|
||
const fundAmt = fundTransactions.filter((f) => f.date === lastFundDate).reduce((s, f) => s + f.amount, 0);
|
||
const equityBefore = round2(opts.equityProfit - curTotal - fundAmt);
|
||
|
||
// The stage the ended cycle traded on: only the withdrawals before it —
|
||
// for a type whose stages reset each day, only that day's. One fund row
|
||
// a day shows none before it, and the rows here hold that whole day,
|
||
// which began on the base stage: it is judged there.
|
||
const eff = resolveEffectiveConfig(
|
||
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity,
|
||
cfg.withdrawalStages, 0, earlierFunds, cfg.minDayPnL,
|
||
cfg.stagesSameDay === true ? { stagesSameDay: true, sameDayWithdrawals: 0 } : {},
|
||
);
|
||
const rows = combineCmeSessions(combine, endedRaw, lastFundDate, 0).dailyPnL;
|
||
// A combining firm's folded day can net to 0 — nothing traded, as the firm counts it.
|
||
const traded = rows.filter((d) => d.pnl !== 0);
|
||
if (traded.length === 0) return null;
|
||
const probe = nextTradingDate(rows.map((d) => d.date).reduce(latest), everyDay);
|
||
if (!cycleOwesNothing(accountId, eff, rows, probe, equityBefore, 0, cfg.splitEnabled)) return null;
|
||
|
||
return {
|
||
date: traded.map((d) => d.date).reduce(latest),
|
||
// The payout's number among the account's withdrawals, a same-day
|
||
// type's too: the master clears the entry once that many have booked.
|
||
stage: 1 + earlierFunds.filter((f) => f.amount < 0).length,
|
||
drain: eff.drain,
|
||
cycle: prevFundDate ?? '',
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Compute the next trading day's profit target for an account.
|
||
*
|
||
* Path 1 – No positive trading days yet:
|
||
* target = profitTarget × consistency
|
||
*
|
||
* Path 2 – At least one positive day exists:
|
||
* maxDay = highest single-day P&L so far
|
||
* realTarget = maxDay / consistency (the total profit at which maxDay ≤ consistency% of total)
|
||
* needed = realTarget − totalProfit
|
||
*
|
||
* if needed > maxDay → target maxDay (still a long way from the real target; trade a normal day)
|
||
* else → target needed (close to the real target; aim for exactly what's left)
|
||
*
|
||
* Min-day reservation (only when minDayPnL > 0):
|
||
* When there are still mandatory trading days remaining, today's target is capped so that
|
||
* enough profit is reserved for each future mandatory day to meet minDayPnL.
|
||
* Cap = (profitTarget - totalProfit) − (remainingDaysAfterToday × minDayPnL)
|
||
* Floor = minDayPnL (we must make at least this today)
|
||
*/
|
||
export function computeDailyTarget(
|
||
profitTarget: number,
|
||
consistency: number,
|
||
dailyPnL: { date: string; pnl: number }[],
|
||
minDayPnL: number, // 0 = no minimum per day
|
||
minTradingDays: number, // 0 = no minimum trading days
|
||
equityProfit: number | null | undefined, // amount − accountSize. Null/undefined → skip (return null). 0 is valid.
|
||
bump: number = 0 // random extra target. No consistency rule: applied when the min-day floor binds. Strict consistency: first trade ever only.
|
||
): { amount: number; path: 'first_day' | 'normal_day' | 'reduced_day' } | null {
|
||
// Don't run if we don't have a real account balance to compute against — 0 is valid.
|
||
if (typeof equityProfit !== 'number' || !Number.isFinite(equityProfit)) {
|
||
return null;
|
||
}
|
||
|
||
const qualifyingDays = minDayPnL === 0 ? dailyPnL : dailyPnL.filter((d) => d.pnl >= minDayPnL);
|
||
const daysTraded = qualifyingDays.length;
|
||
const effectiveMinDay = Math.max(0, minDayPnL);
|
||
const tradingProfit = dailyPnL.reduce((s, d) => s + d.pnl, 0); // cycle-local trading profit
|
||
|
||
// Shortfall to hit profitTarget (measured in equity terms — accounts for deposits/withdrawals)
|
||
const equityShortfall = Math.max(0, profitTarget - equityProfit);
|
||
|
||
// Shortfall to satisfy consistency rule: total trading profit must be ≥ maxDay / consistency,
|
||
// otherwise the biggest day would exceed the consistency ratio.
|
||
let consistencyShortfall = 0;
|
||
if (consistency > 0 && consistency < 1 && qualifyingDays.length > 0) {
|
||
const maxDay = Math.max(...qualifyingDays.map((d) => d.pnl));
|
||
const realTarget = maxDay / consistency;
|
||
consistencyShortfall = Math.max(0, realTarget - tradingProfit);
|
||
}
|
||
|
||
const remaining = Math.max(equityShortfall, consistencyShortfall);
|
||
|
||
const noConsistencyRule = consistency === 0 || consistency >= 1;
|
||
|
||
// 1. Both target and consistency already satisfied — coast on min-day if mandatory days remain.
|
||
if (remaining <= 0) {
|
||
const needsMoreDays = minTradingDays > daysTraded;
|
||
let coastAmount = needsMoreDays ? effectiveMinDay : 0;
|
||
if (coastAmount > 0 && noConsistencyRule) coastAmount += bump;
|
||
return { amount: coastAmount, path: 'reduced_day' };
|
||
}
|
||
|
||
// 2. Min-day reservation: future mandatory days each reserve minDayPnL.
|
||
// cappedByFuture = remaining profit available after reserving.
|
||
const daysLeft = Math.max(1, minTradingDays - daysTraded); // includes today
|
||
const futureReserve = (daysLeft - 1) * effectiveMinDay;
|
||
const cappedByFuture = remaining - futureReserve;
|
||
|
||
// 3. Consistency cap.
|
||
// cycleStartEquity = equityProfit − tradingProfit (equity at the start of this cycle).
|
||
// cycleNetTarget = profitTarget − cycleStartEquity (cycle's net P&L needed to hit target).
|
||
// maxConsistencyDay = cycleNetTarget × consistency — the largest day the rule allows
|
||
// measured against the cycle's net total (not against profitTarget directly), so prior
|
||
// losses don't artificially expand the cap.
|
||
//
|
||
// Day 1: min(remaining × consistency, maxConsistencyDay).
|
||
// Day 2+: max(maxDay, maxConsistencyDay).
|
||
// - If maxDay ≤ maxConsistencyDay: room to grow days up to that ceiling.
|
||
// - If maxDay > maxConsistencyDay: consistency already broken at target, total must
|
||
// grow to at least maxDay / consistency. Each day can be up to maxDay (going higher
|
||
// creates a new maxDay requiring even more total).
|
||
let consistencyCap: number;
|
||
let path: 'first_day' | 'normal_day' | 'reduced_day';
|
||
|
||
if (noConsistencyRule) {
|
||
consistencyCap = Infinity;
|
||
path = daysTraded === 0 ? 'first_day' : 'reduced_day';
|
||
} else {
|
||
const cycleStartEquity = equityProfit - tradingProfit;
|
||
const cycleNetTarget = Math.max(0, profitTarget - cycleStartEquity);
|
||
const maxConsistencyDay = cycleNetTarget * consistency;
|
||
if (daysTraded === 0) {
|
||
consistencyCap = Math.min(remaining * consistency, maxConsistencyDay);
|
||
path = 'first_day';
|
||
} else {
|
||
const maxDay = Math.max(...qualifyingDays.map((d) => d.pnl));
|
||
consistencyCap = Math.max(maxDay, maxConsistencyDay);
|
||
path = 'normal_day';
|
||
}
|
||
}
|
||
|
||
// 4. Combine caps and apply min-day floor when mandatory days remain.
|
||
const raw = Math.min(consistencyCap, cappedByFuture);
|
||
const mustFloor = minTradingDays > daysTraded;
|
||
let amount = mustFloor ? Math.max(effectiveMinDay, raw) : Math.max(0, raw);
|
||
|
||
// 5. Random bump. Without a consistency rule, bump the days where the min-day floor is
|
||
// what set the target (big days get their randomness from leg splitting instead).
|
||
// With a strict consistency rule, only the first trade ever gets a bump — a slight
|
||
// overshoot there just raises consistencyShortfall, which later days self-correct.
|
||
if (bump > 0) {
|
||
if (noConsistencyRule) {
|
||
if (mustFloor && raw <= effectiveMinDay && effectiveMinDay > 0) amount = effectiveMinDay + bump;
|
||
} else if (daysTraded === 0) {
|
||
amount += bump;
|
||
}
|
||
}
|
||
|
||
return { amount: Math.round(amount * 100) / 100, path };
|
||
}
|
||
|
||
export interface DayPlan {
|
||
/** Full target for this trade date (bump included) — a fixed anchor all day. */
|
||
dayTarget: number;
|
||
/** dayTarget minus what's already realized today; what's left to trade. */
|
||
remainingToday: number;
|
||
path: 'first_day' | 'normal_day' | 'reduced_day';
|
||
/** False when the account type disables splitting, or for strict-consistency
|
||
* accounts past their first trade ever — single unsplit trade. */
|
||
splitAllowed: boolean;
|
||
}
|
||
|
||
/**
|
||
* Compute today's full target and what's still left of it, as of right now.
|
||
*
|
||
* Unlike calling computeDailyTarget directly, this anchors the day: today's PnL row (which
|
||
* the hourly Cash History refresh may write mid-day) is excluded, and day-start equity is
|
||
* reconstructed as equityProfit − realizedPnL. So the same (account, trade date) always
|
||
* yields the same dayTarget no matter when or where it's computed — the dashboard, the
|
||
* reporter, and the trade path agree, and completed legs only move remainingToday.
|
||
*/
|
||
export function computeDayPlan(
|
||
accountId: number,
|
||
tradeDate: string,
|
||
profitTarget: number,
|
||
consistency: number,
|
||
dailyPnL: { date: string; pnl: number }[],
|
||
minDayPnL: number,
|
||
minTradingDays: number,
|
||
equityProfit: number | null | undefined,
|
||
realizedPnL: number,
|
||
splitEnabled: boolean = true,
|
||
drain: boolean = false
|
||
): DayPlan | null {
|
||
if (typeof equityProfit !== 'number' || !Number.isFinite(equityProfit)) return null;
|
||
|
||
const priorDays = dailyPnL.filter((d) => d.date !== tradeDate);
|
||
|
||
// Drain stage: no profit target at all. Book minTradingDays qualifying
|
||
// days, then go quiet until the next withdrawal resets the cycle. One
|
||
// unsplit tiny trade per day. The cycle's first day books a randomized
|
||
// grid amount (every day does when there is no consistency rule).
|
||
//
|
||
// The stage's consistency rule outlives the profit target: the cycle's
|
||
// biggest day must fit inside `consistency` of the cycle total. Past the
|
||
// random first day the drain follows the standard consistency math, so
|
||
// accounts drained together finish together whatever their first draw:
|
||
// a day that can close the cycle aims at the MIDPOINT of the amounts
|
||
// that close it (a fill a little over or under still lands inside), and
|
||
// earlier days size like computeDailyTarget with the target at 0.
|
||
if (drain) {
|
||
const qualifying = minDayPnL > 0
|
||
? priorDays.filter((d) => d.pnl >= minDayPnL)
|
||
: priorDays.filter((d) => d.pnl !== 0);
|
||
const daysDone = qualifying.length >= minTradingDays;
|
||
let dayTarget: number;
|
||
if (consistency <= 0 || consistency >= 1 || qualifying.length === 0) {
|
||
dayTarget = daysDone ? 0 : drainDayAmount(accountId, tradeDate);
|
||
} else {
|
||
const maxDay = Math.max(...qualifying.map((d) => d.pnl));
|
||
const total = round2(priorDays.reduce((s, d) => s + d.pnl, 0));
|
||
const needed = round2(maxDay / consistency - total);
|
||
// A day of x closes the rule when it covers the shortfall without
|
||
// topping maxDay (x ≥ needed), or tops it but still fits as the
|
||
// new biggest day (total + x ≥ x / consistency).
|
||
const lo = Math.max(needed, minDayPnL);
|
||
const hi = round2((total * consistency) / (1 - consistency));
|
||
if (daysDone && needed <= 0) dayTarget = 0;
|
||
else if (qualifying.length + 1 >= minTradingDays && lo <= hi) dayTarget = round2((lo + hi) / 2);
|
||
else dayTarget = computeDailyTarget(0, consistency, priorDays, minDayPnL, minTradingDays, total)?.amount ?? 0;
|
||
}
|
||
return {
|
||
dayTarget,
|
||
remainingToday: round2(dayTarget - realizedPnL),
|
||
path: 'reduced_day',
|
||
splitAllowed: false,
|
||
};
|
||
}
|
||
const dayStartEquity = equityProfit - realizedPnL;
|
||
const target = computeDailyTarget(
|
||
profitTarget, consistency, priorDays, minDayPnL, minTradingDays,
|
||
dayStartEquity, dailyBump(accountId, tradeDate)
|
||
);
|
||
if (!target) return null;
|
||
|
||
const qualifyingDays = minDayPnL === 0 ? priorDays : priorDays.filter((d) => d.pnl >= minDayPnL);
|
||
const splitAllowed = splitEnabled && (consistency === 0 || consistency >= 1 || qualifyingDays.length === 0);
|
||
|
||
return {
|
||
dayTarget: target.amount,
|
||
remainingToday: round2(target.amount - realizedPnL),
|
||
path: target.path,
|
||
splitAllowed,
|
||
};
|
||
}
|
||
|
||
// ── payouts not yet booked ───────────────────────────────────────────────────
|
||
|
||
/** A drop at least this far below a finished cycle's books holds the account. */
|
||
export const PAYOUT_HOLD_MIN = 200;
|
||
|
||
/**
|
||
* Where a finished cycle (day target 0) left the books: its fund rows, and
|
||
* day-start equity less the P&L booked by then. Until a fund row is added,
|
||
* booked P&L is all that may move day-start equity — relative, so history
|
||
* missing from before the cache (or an initial deposit row some platforms
|
||
* book and others don't) cancels out.
|
||
*/
|
||
export interface FinishedCycleMark { funds: string; offset: number }
|
||
|
||
/** Fund rows as one comparable value: a second payout on a date that already
|
||
* has one changes the amount, not the row count. */
|
||
export function fundRowsKey(txns: { date: string; amount: number }[]): string {
|
||
return txns.map((f) => `${f.date}:${f.amount}`).join('|');
|
||
}
|
||
|
||
export function markFinishedCycle(funds: string, dayStartEquity: number, bookedPnL: number): FinishedCycleMark {
|
||
return { funds, offset: round2(dayStartEquity - bookedPnL) };
|
||
}
|
||
|
||
/**
|
||
* How far day-start equity has fallen below a finished cycle's books with no
|
||
* fund row booked since — a payout the platform hasn't booked yet. null when
|
||
* there's no mark, a fund row has moved on from it, or the drop is fee noise.
|
||
* Planned on, that drop reads as lost profit: the finished cycle reopens as
|
||
* the same stage and re-earns the payout at full size, instead of moving to
|
||
* the next stage (a drain day) once the payout books.
|
||
*/
|
||
export function unbookedPayoutDrop(
|
||
mark: FinishedCycleMark | null,
|
||
funds: string,
|
||
dayStartEquity: number,
|
||
bookedPnL: number,
|
||
): number | null {
|
||
if (!mark || mark.funds !== funds) return null;
|
||
const drop = round2(mark.offset - (dayStartEquity - bookedPnL));
|
||
return drop >= PAYOUT_HOLD_MIN ? drop : null;
|
||
}
|
||
|
||
// ── fund events from the balance (TopstepX) ──────────────────────────────────
|
||
|
||
/** Balance the books don't explain past this is a fund event (TopstepX's
|
||
* reconcileFunds books it); under it, fee noise and rounding. The smallest
|
||
* real withdrawal in the drain flow is well above it. */
|
||
export const FUND_EVENT_MIN = 200;
|
||
/** A sighting is booked only once a later pass, at least this much later,
|
||
* sees the same amount... */
|
||
export const FUND_CONFIRM_MS = 30_000;
|
||
/** ...to within this (fee noise between the two passes). */
|
||
export const FUND_CONFIRM_TOLERANCE = 5;
|
||
|
||
/** An unexplained balance seen, not yet booked: the amount and when it was
|
||
* first seen at about that amount. */
|
||
export interface FundSighting { delta: number; since: number }
|
||
|
||
export type FundStep =
|
||
/** Under FUND_EVENT_MIN: nothing to book. */
|
||
| { kind: 'settled' }
|
||
/** Not yet seen twice far enough apart. `sighting` is what to keep for
|
||
* the next pass; `fresh` when it is new (or the amount moved). */
|
||
| { kind: 'unconfirmed'; sighting: FundSighting; fresh: boolean }
|
||
/** Book today's row as `amount` (today's existing row merged in). */
|
||
| { kind: 'book'; amount: number }
|
||
/** Today's bookings net under FUND_EVENT_MIN: a transient and its
|
||
* reversal. Take today's row back and leave `net` unbooked. */
|
||
| { kind: 'drop'; net: number };
|
||
|
||
/**
|
||
* One fresh pass's decision on the balance the books don't explain.
|
||
*
|
||
* The pass reads the balance first and the positions and trades after it, so
|
||
* a fill landing in between shows in the trades but not yet in the balance
|
||
* (or, the other way, in the balance before its trade row). For one pass the
|
||
* books are off by the whole fill. On 2026-10-05 two Express accounts booked a
|
||
* $3,001.16 "deposit" that way, and the next pass booked the $3,002.16
|
||
* "withdrawal" that undid it. The two merged into a −$1.00 row, the
|
||
* commission drift of the day. A negative row is a withdrawal: it started a
|
||
* new cycle, dropped the day's −$3,003.32 out of it and moved both accounts
|
||
* to their drain stage. A real fund event stays put, so an amount is booked
|
||
* only when a pass at least FUND_CONFIRM_MS after the first sighting still
|
||
* sees it.
|
||
*
|
||
* `undoable`: today's row was booked by this reconcile alone, with what it
|
||
* replaced saved (the first booking of a day saves it), so it can be taken
|
||
* back whole. A transient that outlasts the confirmation and is then
|
||
* reversed nets under FUND_EVENT_MIN, and the day is dropped, not left as a
|
||
* tiny row.
|
||
*/
|
||
export function fundReconcileStep(opts: {
|
||
delta: number;
|
||
sighting: FundSighting | null;
|
||
now: number;
|
||
/** Today's fund row, null when there is none. */
|
||
existingToday: number | null;
|
||
undoable: boolean;
|
||
}): FundStep {
|
||
const { delta, sighting, now, existingToday } = opts;
|
||
if (Math.abs(delta) < FUND_EVENT_MIN) return { kind: 'settled' };
|
||
if (sighting === null || Math.abs(sighting.delta - delta) > FUND_CONFIRM_TOLERANCE) {
|
||
return { kind: 'unconfirmed', sighting: { delta, since: now }, fresh: true };
|
||
}
|
||
if (now - sighting.since < FUND_CONFIRM_MS) return { kind: 'unconfirmed', sighting, fresh: false };
|
||
const amount = round2((existingToday ?? 0) + delta);
|
||
if (existingToday !== null && opts.undoable && Math.abs(amount) < FUND_EVENT_MIN) return { kind: 'drop', net: amount };
|
||
return { kind: 'book', amount };
|
||
}
|
||
|
||
// ── fees ─────────────────────────────────────────────────────────────────────
|
||
|
||
/** An entry's fee when the balance update hasn't shown it yet — at least one
|
||
* extra tick on the exit. */
|
||
export const FALLBACK_COMMISSION_PER_CONTRACT = 2.5;
|
||
/** No real per-contract entry fee comes near this. Also what a daily-loss
|
||
* stop reserves per contract and side when the fee couldn't be measured —
|
||
* the $2.50 fallback is fine for a take-profit, but it would under-reserve
|
||
* a hard stop on NQ-class fees. */
|
||
export const MAX_COMMISSION_PER_CONTRACT = 10;
|
||
|
||
/** One side's commission on a percent-of-notional firm (e.g. Tradeify247's
|
||
* 0.04%), rounded UP to the cent so every consumer errs toward paying more.
|
||
* pointValue × contracts is the position's dollars per 1.0 of price, so
|
||
* price × that is its notional. */
|
||
export function percentFee(pct: number, price: number, pointValue: number, contracts: number): number {
|
||
if (!(pct > 0)) return 0;
|
||
const raw = (pct / 100) * Math.abs(price) * pointValue * contracts;
|
||
return Math.ceil(raw * 100 - 1e-9) / 100;
|
||
}
|
||
|
||
/**
|
||
* A price on the tick grid, free of float fuzz. 4217.6 − 87 × 0.1 is
|
||
* 4208.900000000001 in floating point, and TopstepX refuses a price off the
|
||
* grid ("Invalid price. Price is outside allowed range") — a GC short rode
|
||
* with no take-profit on 2026-10-02. An averaged multi-contract fill can sit
|
||
* between ticks too (by 1/contracts of a tick or more), so an off-grid price
|
||
* goes `mode`'s way; fuzz within a millionth of a tick is never pushed a
|
||
* whole tick.
|
||
*/
|
||
export function onTick(price: number, tickSize: number, mode: 'up' | 'down'): number {
|
||
if (!(tickSize > 0)) return price;
|
||
const n = price / tickSize;
|
||
const k = mode === 'up' ? Math.ceil(n - 1e-6) : Math.floor(n + 1e-6);
|
||
return Math.round(k * tickSize * 1e10) / 1e10;
|
||
}
|
||
|
||
/**
|
||
* Where to put the take-profit so the leg nets at least `leg` after fees.
|
||
*
|
||
* Measured-fee firms (commissionPct 0): the exit fee is taken to equal the
|
||
* entry fee — the same formula the fire path always used, reproduced exactly,
|
||
* then put on the tick grid away from the entry (onTick).
|
||
*
|
||
* Percent-fee firms: the exit fee depends on the exit price itself, so solve
|
||
* for it. With r = pct/100 and k = pointValue × contracts, a long nets
|
||
* (X − entry)·k − entryFee − r·X·k at exit price X, which reaches `leg` at
|
||
* X = ((leg + entryFee)/k + entry) / (1 − r); a short mirrors it at
|
||
* X = (entry − (leg + entryFee)/k) / (1 + r). Rounded out to whole ticks and
|
||
* re-checked with the cent-rounded exit fee.
|
||
*/
|
||
export function takeProfitPlan(o: {
|
||
side: 'Buy' | 'Sell';
|
||
entry: number;
|
||
leg: number;
|
||
pointValue: number;
|
||
contracts: number;
|
||
tickSize: number;
|
||
commissionPct: number;
|
||
/** The entry fee: percentFee() on percent firms, the measured one otherwise. */
|
||
entryCommission: number;
|
||
}): { exitPrice: number; ticks: number; entryFee: number; exitFee: number; totalCommission: number; grossTarget: number } {
|
||
const k = o.pointValue * o.contracts;
|
||
const dir = o.side === 'Buy' ? 1 : -1;
|
||
if (!(o.commissionPct > 0)) {
|
||
const totalCommission = o.entryCommission * 2; // entry + exit round-trip
|
||
const grossTarget = o.leg + totalCommission;
|
||
const ticks = Math.ceil(grossTarget / k / o.tickSize);
|
||
return {
|
||
exitPrice: onTick(o.entry + dir * ticks * o.tickSize, o.tickSize, o.side === 'Buy' ? 'up' : 'down'),
|
||
ticks,
|
||
entryFee: o.entryCommission,
|
||
exitFee: o.entryCommission,
|
||
totalCommission,
|
||
grossTarget,
|
||
};
|
||
}
|
||
|
||
const r = o.commissionPct / 100;
|
||
const need = (o.leg + o.entryCommission) / k;
|
||
const target = o.side === 'Buy' ? (need + o.entry) / (1 - r) : (o.entry - need) / (1 + r);
|
||
let ticks = Math.max(1, Math.ceil(Math.abs(target - o.entry) / o.tickSize - 1e-9));
|
||
const priceAt = (t: number) => Math.round((o.entry + dir * t * o.tickSize) * 1e10) / 1e10;
|
||
const netAt = (t: number) => t * o.tickSize * k - o.entryCommission - percentFee(o.commissionPct, priceAt(t), o.pointValue, o.contracts);
|
||
// Cent rounding on the fees can leave the closed form short — by several
|
||
// ticks when a tick is worth a fraction of a cent. Step out by the shortfall.
|
||
for (let i = 0; i < 10; i++) {
|
||
const short = o.leg - netAt(ticks);
|
||
if (short <= 1e-9) break;
|
||
ticks += Math.max(1, Math.ceil(short / (o.tickSize * k * (1 - r)) - 1e-9));
|
||
}
|
||
const exitPrice = priceAt(ticks);
|
||
const exitFee = percentFee(o.commissionPct, exitPrice, o.pointValue, o.contracts);
|
||
const totalCommission = Math.round((o.entryCommission + exitFee) * 100) / 100;
|
||
return { exitPrice, ticks, entryFee: o.entryCommission, exitFee, totalCommission, grossTarget: round2(o.leg + totalCommission) };
|
||
}
|
||
|
||
// ── stops ────────────────────────────────────────────────────────────────────
|
||
|
||
/** Obfuscation stop for the current leg, in dollars. Infinity = no stop.
|
||
* Day 1 of a cycle carries the $1,000 account stop (firstDayStop), min-day
|
||
* (small) trades a 2×-target stop (minDayStop) — and the day after a losing
|
||
* day always rides stopless, the make-back day. A hard daily loss limit is
|
||
* separate (dllStopDollars) and never rides stopless. */
|
||
export function obfuscationStopDollars(
|
||
cfg: { firstDayStop: boolean; minDayStop: boolean },
|
||
dailyPnL: { date: string; pnl: number }[],
|
||
plan: DayPlan,
|
||
tradeDate: string,
|
||
legAmount: number,
|
||
): number {
|
||
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, FIRST_DAY_STOP + realizedToday);
|
||
if (cfg.minDayStop && plan.dayTarget <= BIG_DAY_THRESHOLD) stop = Math.min(stop, MIN_DAY_STOP_MULT * legAmount);
|
||
return stop;
|
||
}
|
||
|
||
/** Share of a hard daily loss limit the bot never spends — room for a stop
|
||
* that fills worse than its price (gaps, thin weekend books). */
|
||
export const DLL_RESERVE_FRACTION = 0.10;
|
||
/** …or this share of the position's notional, when that is more: a stop's
|
||
* slippage grows with the position, a flat reserve doesn't. A platform whose
|
||
* stops are known to fill closer states its own (stopSlippageFraction). */
|
||
export const DLL_SLIPPAGE_FRACTION = 0.001;
|
||
|
||
/** The part of the day's room a stop leaves unspent. */
|
||
export function dllReserve(dailyLossLimit: number, notional = 0, slippageFraction = DLL_SLIPPAGE_FRACTION): number {
|
||
const share = Number.isFinite(slippageFraction) && slippageFraction >= 0 ? slippageFraction : DLL_SLIPPAGE_FRACTION;
|
||
return Math.max(DLL_RESERVE_FRACTION * dailyLossLimit, share * Math.max(0, notional));
|
||
}
|
||
/** Smallest daily-loss stop worth trading. Under a fair walk a leg of L with a
|
||
* stop of S wins S/(S + L) of the time — about 9% for $50 against $500 — so
|
||
* anything tighter just spends the day's room on fees. */
|
||
export const DLL_MIN_STOP = 50;
|
||
|
||
/** The stage inputs for one account: the type's daily loss limit, and for a
|
||
* type whose stages reset each day, the client's count of today's payouts. */
|
||
export function stageInputsFor(
|
||
client: { sameDayClaims?: (accountId: number) => number },
|
||
accountId: number,
|
||
cfg: { dailyLossLimit?: number; stagesSameDay?: boolean },
|
||
): StageInputs {
|
||
const sameDay = cfg.stagesSameDay === true;
|
||
return {
|
||
dailyLossLimit: cfg.dailyLossLimit ?? 0,
|
||
stagesSameDay: sameDay,
|
||
sameDayWithdrawals: sameDay && typeof client.sameDayClaims === 'function' ? client.sameDayClaims(accountId) : 0,
|
||
};
|
||
}
|
||
|
||
/** A leg is given this many of its target holds to be done before a daily
|
||
* reset counts its open loss against the next day: under a fair walk, a leg
|
||
* whose take-profit and stop are within 2:1 is still open after four holds
|
||
* about 1–1.5% of the time, and the reset guard fits those. */
|
||
export const RESET_CLEAR_HOLDS = 4;
|
||
|
||
/** The shortest take-profit the plan's next leg may have — what
|
||
* resetClearStop is held to: a split day can carve a leg as small as
|
||
* MIN_LEG (pickLegAmount), else the leg is all that remains. */
|
||
export function shortestNextLeg(plan: { remainingToday: number; splitAllowed: boolean }): number {
|
||
return plan.splitAllowed ? Math.min(plan.remainingToday, MIN_LEG) : plan.remainingToday;
|
||
}
|
||
|
||
/**
|
||
* The widest stop a leg whose take-profit is `leg` away may carry and still
|
||
* be all but surely closed by a daily reset `resetInMs` away; 0 when the
|
||
* reset is under RESET_CLEAR_HOLDS holds off. The walk's exit time has a
|
||
* tail that decays as e^(−π²·t/(2g·hold)), g = (A+B)²/(A·B): a lopsided leg
|
||
* (a $5,000 stop on a $500 take-profit) lingers far longer than its hold. So
|
||
* the leg may be as lopsided as k = holds-to-reset ÷ RESET_CLEAR_HOLDS times
|
||
* the 2:1 shape (g = 4.5) allows — g ≤ 4.5k — which keeps its chance of
|
||
* still being open at the reset what a 2:1 leg's is at four holds.
|
||
*/
|
||
export function resetClearStop(o: { leg: number; resetInMs: number; holdHours: number }): number {
|
||
if (!(o.leg > 0) || !(o.holdHours > 0) || !Number.isFinite(o.resetInMs)) return 0;
|
||
const k = o.resetInMs / (RESET_CLEAR_HOLDS * o.holdHours * 3_600_000);
|
||
if (k < 1) return 0;
|
||
const c = 4.5 * k - 2;
|
||
return (o.leg * (c + Math.sqrt(c * c - 4))) / 2;
|
||
}
|
||
|
||
/**
|
||
* dllRoom where the platform states its daily-loss floor itself (Vest: the
|
||
* equity the account may not fall below today, already moved by the day's
|
||
* transfers): what's left of the stage's limit after the day's losses, and
|
||
* never more than the floor leaves. The firm's floor alone isn't enough — a
|
||
* limit set below the firm's (Vest's 3% is $1,500; the bot's day stops at
|
||
* $1,229) would let a day that already lost $1,111 trade the $389 Vest still
|
||
* allows. Infinity when there is no limit.
|
||
*
|
||
* `gain` widens it: on a split day the next leg may give back what the
|
||
* earlier legs made, plus the limit — a $1,400 day up $2,604 gets $4,004 —
|
||
* as Vest's own floor does. The client says how much (dayGainForRoom): the
|
||
* gain still in the account, no more than keeps the leg all but sure to be
|
||
* closed by the reset (resetClearStop) — a leg still open there has all of
|
||
* its loss counted against the next day. Never more than the day's realized
|
||
* gain either way.
|
||
*/
|
||
export function floorRoom(o: {
|
||
dailyLossLimit: number;
|
||
closedBalance: number;
|
||
floor: number;
|
||
/** The day's realized P&L since its base (Vest: since this client's
|
||
* latest claim today, else since the reset). */
|
||
realizedToday: number;
|
||
/** What of the day's gain the room may grow by; 0 (default) = none. */
|
||
gain?: number;
|
||
}): number {
|
||
if (!(o.dailyLossLimit > 0)) return Infinity;
|
||
const widen = Math.min(Math.max(0, o.realizedToday), Math.max(0, o.gain ?? 0));
|
||
return round2(Math.min(o.dailyLossLimit + Math.min(0, o.realizedToday) + widen, o.closedBalance - o.floor));
|
||
}
|
||
|
||
/**
|
||
* How much the firm's day may still lose before a hard daily-loss breach.
|
||
* Infinity when the account type has no limit.
|
||
*
|
||
* The day's P&L is the closed balance against the platform's snapshot at the
|
||
* firm's daily reset when the client knows it (DXTrade: 22:00 UTC), else the
|
||
* broker's realized P&L for the session. Only a red day counts: a green day
|
||
* does NOT widen the room past the limit, because a position still open at
|
||
* the next reset has its whole move counted against the NEW day — room
|
||
* borrowed from today's profit would be a breach tomorrow.
|
||
*/
|
||
export function dllRoom(o: {
|
||
dailyLossLimit: number;
|
||
closedBalance: number;
|
||
/** Platform snapshot at the firm's daily reset; undefined/null = unknown. */
|
||
dayStartBalance?: number | null;
|
||
/** Fallback when there's no snapshot: the day's realized P&L (pass the
|
||
* worse of the raw and combined figures). */
|
||
realizedToday: number;
|
||
}): number {
|
||
if (!(o.dailyLossLimit > 0)) return Infinity;
|
||
const dayPnL = typeof o.dayStartBalance === 'number' ? o.closedBalance - o.dayStartBalance : o.realizedToday;
|
||
return round2(o.dailyLossLimit + Math.min(0, dayPnL));
|
||
}
|
||
|
||
/**
|
||
* The widest stop, in dollars of price movement, that keeps a loss inside
|
||
* room − reserve with both fees paid. The exit fee at the stop is at most
|
||
* entryFee + r × (stop distance) — a short's stop sits above its entry — so
|
||
* the worst loss is d(1 + r) + 2·entryFee, plus a cent of fee rounding.
|
||
* Infinity when there is no limit. Can go negative: no room at all.
|
||
*
|
||
* Overnight swap needs no reserve of its own while a day's swap is below the
|
||
* commission rate (Tradeify: 0.033% < 0.04%): a position carried past the
|
||
* reset has its entry fee on the old day's books, and the swap takes no more
|
||
* than that slot on the new day's.
|
||
*/
|
||
export function dllStopDollars(o: { room: number; dailyLossLimit: number; entryFee: number; commissionPct: number; notional?: number; slippageFraction?: number }): number {
|
||
if (!Number.isFinite(o.room)) return Infinity;
|
||
const r = Math.max(0, o.commissionPct) / 100;
|
||
return (o.room - dllReserve(o.dailyLossLimit, o.notional, o.slippageFraction) - 2 * o.entryFee - 0.01) / (1 + r);
|
||
}
|
||
|
||
/**
|
||
* How far the account's balance is above its max-drawdown floor, or null when
|
||
* it isn't known: the broker's liquidation floor where it reports one, else
|
||
* the account type's static max loss below the starting size — whichever is
|
||
* higher.
|
||
*/
|
||
export function maxLossRoom(o: { balance: number; accountSize: number; maxLoss: number; autoLiqThreshold?: number }): number | null {
|
||
const floors: number[] = [];
|
||
if ((o.autoLiqThreshold ?? 0) > 0) floors.push(o.autoLiqThreshold!);
|
||
if (Math.abs(o.maxLoss) > 0 && o.accountSize > 0) floors.push(o.accountSize - Math.abs(o.maxLoss));
|
||
return floors.length > 0 ? round2(o.balance - Math.max(...floors)) : null;
|
||
}
|
||
|
||
/** A daily-loss stop is kept only while the max-drawdown floor lies more than
|
||
* this share of a full daily loss beyond it. */
|
||
export const LAST_STAND_FRACTION = 0.15;
|
||
|
||
/**
|
||
* Whether a leg should ride without its daily-loss stop. The stop is there to
|
||
* keep the account alive for another day; when hitting the day's loss limit
|
||
* would leave it within 15% of that loss of its max-drawdown floor — or the
|
||
* floor is nearer than the limit — there is no useful day after, and the stop
|
||
* only gives up room (the reserve, and whatever the floor allows past it)
|
||
* for nothing. The leg then runs until its take-profit or the firm's own
|
||
* breach. E.g. $3,000 of daily room with $3,294.70 to the floor: $294.70
|
||
* left, 9.8% — a last stand.
|
||
*/
|
||
export function lastStand(o: { dllRoom: number; dailyLossLimit: number; maxLossRoom: number | null }): boolean {
|
||
if (o.maxLossRoom === null || !(o.dailyLossLimit > 0) || !Number.isFinite(o.dllRoom) || !(o.dllRoom > 0)) return false;
|
||
// A full day's loss is the limit, however much a green day widened the
|
||
// room (floorRoom): a stop-out leaving $300 over the floor keeps a day.
|
||
return o.maxLossRoom - o.dllRoom <= LAST_STAND_FRACTION * Math.min(o.dllRoom, o.dailyLossLimit);
|
||
}
|
||
|
||
/** On a last stand: how far price can move against the leg before the firm's
|
||
* nearer floor breaches the account — its equity carries the entry fee, not
|
||
* the exit's. What sizing and the swap estimate take as the walk's far side. */
|
||
export function breachDistance(o: { dllRoom: number; maxLossRoom: number | null; entryFee: number }): number {
|
||
return Math.min(o.dllRoom, o.maxLossRoom ?? Infinity) - o.entryFee;
|
||
}
|
||
|
||
/** Is there enough daily-loss room left to trade at all? */
|
||
export function dllViable(room: number, dailyLossLimit: number, roundTripFeeEstimate: number): boolean {
|
||
if (!Number.isFinite(room)) return true;
|
||
return room - DLL_RESERVE_FRACTION * dailyLossLimit >= DLL_MIN_STOP + roundTripFeeEstimate;
|
||
}
|
||
|
||
/**
|
||
* The stop distance in ticks: the tighter of the obfuscation stop (rounded
|
||
* out, as it always was — it is a soft target) and the daily-loss stop
|
||
* (rounded IN — it is a hard limit). null = no stop at all (lone take-profit);
|
||
* 0 = the daily-loss room doesn't cover a single tick, so the position must
|
||
* not stay open. An obfuscation value of 0 or Infinity means no obfuscation stop.
|
||
*/
|
||
export function stopTicks(o: {
|
||
obfuscationDollars: number;
|
||
dllDollars: number;
|
||
pointValue: number;
|
||
contracts: number;
|
||
tickSize: number;
|
||
}): number | null {
|
||
const perTick = o.pointValue * o.contracts * o.tickSize;
|
||
let ticks: number | null = null;
|
||
if (Number.isFinite(o.obfuscationDollars) && o.obfuscationDollars > 0) {
|
||
ticks = Math.max(1, Math.ceil(o.obfuscationDollars / (o.pointValue * o.contracts) / o.tickSize));
|
||
}
|
||
if (Number.isFinite(o.dllDollars)) {
|
||
const dllTicks = Math.max(0, Math.floor(o.dllDollars / perTick + 1e-9));
|
||
ticks = ticks === null ? dllTicks : Math.min(ticks, dllTicks);
|
||
}
|
||
return ticks;
|
||
}
|
||
|
||
/** Whether the bot places its own stops for this account type — the
|
||
* obfuscation options or a hard daily loss limit: the current stage's
|
||
* (resolveEffectiveConfig) when given, which a stage may set on a type with
|
||
* none. A red day on such an account is the bot's own stop (the personal
|
||
* DLL), not a firm lockout. */
|
||
export function botStopsActive(
|
||
cfg: { firstDayStop: boolean; minDayStop: boolean; dailyLossLimit?: number },
|
||
effective?: { dailyLossLimit: number },
|
||
): boolean {
|
||
return cfg.firstDayStop || cfg.minDayStop || (effective ? effective.dailyLossLimit : cfg.dailyLossLimit ?? 0) > 0;
|
||
}
|
||
|
||
/**
|
||
* The side every new entry on this machine must take, so nothing it opens
|
||
* hedges the person against themselves: the side of every position the
|
||
* machine's accounts hold, in any lane (another firm's, a hand trade's), else
|
||
* the side another lane's batch is placing right now. 'mixed' = it holds both
|
||
* sides already (hand trades, or from before this rule), so no side can be
|
||
* joined without hedging one of them. null = free to choose.
|
||
*
|
||
* The side, not the instrument: NQ futures against an NDX perp is the same
|
||
* bet, and ES or a crypto index close enough to it that one side for
|
||
* everything is the only rule that never hedges.
|
||
*/
|
||
export function machineSide(netPositions: number[], reserved: 'Buy' | 'Sell' | null): 'Buy' | 'Sell' | 'mixed' | null {
|
||
const long = netPositions.some((n) => n > 0) || reserved === 'Buy';
|
||
const short = netPositions.some((n) => n < 0) || reserved === 'Sell';
|
||
if (long && short) return 'mixed';
|
||
return long ? 'Buy' : short ? 'Sell' : null;
|
||
}
|
||
|
||
// ── position size ────────────────────────────────────────────────────────────
|
||
|
||
/** The hold a leg is sized for when the firm doesn't set one. */
|
||
export const DEFAULT_TARGET_HOLD_HOURS = 12;
|
||
|
||
/** How far one person's DXtrade sizes move off the plan, each way. */
|
||
export const PERSON_SIZE_SPREAD = 0.15;
|
||
|
||
/**
|
||
* One person's size factor for a trade date, in [1 − spread, 1 + spread].
|
||
* Seeded (FNV-1a) by the person, so all of one person's accounts size alike
|
||
* while another person's land elsewhere, and drawn afresh each trade date.
|
||
* FNV alone barely moves its top bits when only the key's last character
|
||
* changes (the next day's date), so the hash is mixed (murmur3's finalizer)
|
||
* before it is scaled.
|
||
*/
|
||
export function personSizeFactor(personKey: string, tradeDate: string): number {
|
||
const key = `size|${personKey}|${tradeDate}`;
|
||
let hash = 0x811c9dc5;
|
||
for (let i = 0; i < key.length; i++) {
|
||
hash ^= key.charCodeAt(i);
|
||
hash = Math.imul(hash, 0x01000193) >>> 0;
|
||
}
|
||
hash ^= hash >>> 16;
|
||
hash = Math.imul(hash, 0x85ebca6b);
|
||
hash ^= hash >>> 13;
|
||
hash = Math.imul(hash, 0xc2b2ae35);
|
||
hash = (hash ^ (hash >>> 16)) >>> 0;
|
||
return 1 - PERSON_SIZE_SPREAD + (hash / 0x100000000) * 2 * PERSON_SIZE_SPREAD;
|
||
}
|
||
|
||
/**
|
||
* The hold a person's DXtrade leg is sized for: the firm's target, moved so
|
||
* the size comes out personSizeFactor times the plan. Size goes as 1/√hold
|
||
* (sizeNotional), so hold / factor² does it, and every rule that ranks or
|
||
* checks against the hold sees the same number the size was made for.
|
||
*/
|
||
export function personHoldHours(targetHoldHours: number, personKey: string, tradeDate: string): number {
|
||
const hold = targetHoldHours > 0 ? targetHoldHours : DEFAULT_TARGET_HOLD_HOURS;
|
||
const factor = personSizeFactor(personKey, tradeDate);
|
||
return hold / (factor * factor);
|
||
}
|
||
|
||
/** Share of the broker's margin headroom a new position may use: the rest
|
||
* absorbs the price moving between sizing and the fill, and the entry fee. */
|
||
export const MARGIN_HEADROOM = 0.97;
|
||
|
||
/**
|
||
* The largest position an account may open: what its available funds can
|
||
* margin at the broker's rate for the instrument — the firm's leverage per
|
||
* symbol (5:1 crypto, 2:1 stocks at Tradeify247). 0 = no cap known.
|
||
*/
|
||
export function notionalCap(o: { marginRate?: number; availableFunds?: number }): number {
|
||
if (!((o.marginRate ?? 0) > 0) || !Number.isFinite(o.availableFunds) || !(o.availableFunds! > 0)) return 0;
|
||
return (MARGIN_HEADROOM * o.availableFunds!) / o.marginRate!;
|
||
}
|
||
|
||
/** The swap reserve never grows past this share of the take-profit distance
|
||
* — beyond it the leg is sized too small to hold at all. */
|
||
const MAX_SWAP_RESERVE_FRACTION = 0.25;
|
||
|
||
/**
|
||
* The overnight swap a winning leg is expected to pay before its take-profit
|
||
* fills, so the take-profit can carry it and a win still clears the leg.
|
||
*
|
||
* Same driftless walk as sizeNotional, in dollars: σ$ = notional·σ per √hour,
|
||
* the take-profit A away, the stop B away. Given it wins, the walk takes
|
||
* (A² + 2AB)/(3σ$²) hours on average — longer than the unconditional hold,
|
||
* since the quick endings are mostly stop-outs. The swap is charged at each
|
||
* daily reset the position is held over, about one per 24 h held, on the
|
||
* notional. A includes the reserve itself, so it is solved as a fixed point.
|
||
* A stopless leg is taken as B = A.
|
||
*
|
||
* `distance` = the take-profit distance before swap: leg + round-trip fees.
|
||
*/
|
||
export function expectedSwapToWin(o: {
|
||
distance: number;
|
||
stopDollars: number;
|
||
notional: number;
|
||
volatilityPerHour?: number;
|
||
financingPctPerDay?: number;
|
||
}): number {
|
||
const daily = Math.max(0, o.financingPctPerDay ?? 0) / 100;
|
||
const sigma = o.volatilityPerHour ?? 0;
|
||
if (!(daily > 0) || !(sigma > 0) || !(o.notional > 0) || !(o.distance > 0)) return 0;
|
||
const variance = (o.notional * sigma) ** 2;
|
||
const stopped = Number.isFinite(o.stopDollars) && o.stopDollars > 0;
|
||
const cap = o.distance * MAX_SWAP_RESERVE_FRACTION;
|
||
let swap = 0;
|
||
for (let i = 0; i < 50; i++) {
|
||
const a = o.distance + swap;
|
||
const b = stopped ? o.stopDollars : a;
|
||
const winHours = (a * a + 2 * a * b) / (3 * variance);
|
||
const next = Math.min(cap, daily * o.notional * (winHours / 24));
|
||
const done = Math.abs(next - swap) < 0.005;
|
||
swap = next;
|
||
if (done) break;
|
||
}
|
||
return Math.ceil(swap * 100) / 100;
|
||
}
|
||
|
||
/**
|
||
* How much notional a leg should carry, on platforms that price it (DXTrade).
|
||
*
|
||
* Model price as a driftless random walk with σ per √hour. With the
|
||
* take-profit A dollars away (leg + fees + expected swap) and the stop B
|
||
* dollars away, a position of notional N wins B/(A+B) of the time whatever N
|
||
* is, its expected hold is (A/N)(B/N)/σ² hours, and its expected P&L is minus
|
||
* fees and spread — proportional to N. Smaller is cheaper, bigger is faster,
|
||
* so the knob is the target hold T: N = √(A·B/(σ²·T)). A firm that charges
|
||
* overnight financing adds a floor: below √(f·A·B/(σ²·(2c + s))), a bigger
|
||
* position is both cheaper and faster.
|
||
*
|
||
* A stopless leg (no daily loss limit, no obfuscation stop) is sized as if
|
||
* its stop sat as far away as its target. Returns null without a volatility.
|
||
*/
|
||
export function sizeNotional(o: {
|
||
leg: number;
|
||
/** Stop distance in dollars at a given notional (fees move with it). */
|
||
stopDollarsAt: (notional: number) => number;
|
||
volatilityPerHour?: number;
|
||
/** 0 = DEFAULT_TARGET_HOLD_HOURS. */
|
||
targetHoldHours: number;
|
||
/** Per side, percent. */
|
||
commissionPct: number;
|
||
spreadFraction?: number;
|
||
financingPctPerDay?: number;
|
||
/** Hard cap on the position (see notionalCap); 0 = none. */
|
||
maxNotional: number;
|
||
/** The smallest order the broker takes; 0 = none. It wins over the cap,
|
||
* as the order does. */
|
||
minNotional?: number;
|
||
}): { notional: number; predictedHoldHours: number; winProbability: number | null; swapReserve: number } | null {
|
||
const sigma = o.volatilityPerHour ?? 0;
|
||
if (!(sigma > 0)) return null;
|
||
const hold = o.targetHoldHours > 0 ? o.targetHoldHours : DEFAULT_TARGET_HOLD_HOURS;
|
||
const c = Math.max(0, o.commissionPct) / 100;
|
||
const s = Math.max(0, o.spreadFraction ?? 0);
|
||
const fh = Math.max(0, o.financingPctPerDay ?? 0) / 100 / 24;
|
||
const distances = (n: number) => {
|
||
const stop = o.stopDollarsAt(n);
|
||
const stopped = Number.isFinite(stop) && stop > 0;
|
||
const swap = expectedSwapToWin({
|
||
distance: o.leg + 2 * c * n, stopDollars: stop, notional: n,
|
||
volatilityPerHour: sigma, financingPctPerDay: o.financingPctPerDay,
|
||
});
|
||
const a = o.leg + 2 * c * n + swap;
|
||
return { a, b: stopped ? stop : a, stopped, swap };
|
||
};
|
||
|
||
// Fees and swap depend on N and the daily-loss stop depends on fees: a
|
||
// few fixed-point passes settle it (the correction is second order).
|
||
let n = o.leg * 20;
|
||
for (let pass = 0; pass < 4; pass++) {
|
||
const { a, b } = distances(n);
|
||
const nTime = Math.sqrt((a * b) / (sigma * sigma * hold));
|
||
// No per-trade cost (a zero-commission firm on a zero spread): no
|
||
// floor — there is nothing for a bigger position to save.
|
||
const nCost = fh > 0 && 2 * c + s > 0 ? Math.sqrt((fh * a * b) / (sigma * sigma * (2 * c + s))) : 0;
|
||
n = Math.max(nTime, nCost);
|
||
}
|
||
if (!Number.isFinite(n) || n <= 0) return null;
|
||
const notional = Math.max(o.maxNotional > 0 ? Math.min(n, o.maxNotional) : n, o.minNotional ?? 0);
|
||
const { a, b, stopped, swap } = distances(notional);
|
||
return {
|
||
notional,
|
||
predictedHoldHours: (a / notional) * (b / notional) / (sigma * sigma),
|
||
winProbability: stopped ? b / (a + b) : null,
|
||
swapReserve: swap,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* One leg's size on one instrument for one account — the rule fireLeg
|
||
* trades, the instrument pick ranks by and the dashboard shows.
|
||
*
|
||
* The walk's far side (what sizing, the win chance and the swap estimate
|
||
* use) is the stop the leg carries: the tighter of the obfuscation stop and
|
||
* the hard daily-loss stop. On a last stand there is no daily-loss stop and
|
||
* the far side is the firm's own breach. `cost` is what the leg is expected
|
||
* to spend: spread and both commissions on its notional, plus swap over its
|
||
* expected hold.
|
||
*
|
||
* The size is held to what the order can be — the margin cap and the
|
||
* broker's largest order, then its smallest — so the pick ranks each
|
||
* instrument by the hold it can actually reach. `cap` is the margin cap
|
||
* alone.
|
||
*/
|
||
export function planLegSize(o: {
|
||
leg: number;
|
||
contract: {
|
||
stepNotional?: number; volatilityPerHour?: number; spreadFraction?: number; marginRate?: number;
|
||
/** The broker's largest and smallest order, in steps. */
|
||
maxContracts?: number; minContracts?: number;
|
||
};
|
||
/** Per side, percent. */
|
||
commissionPct: number;
|
||
targetHoldHours: number;
|
||
financingPctPerDay: number;
|
||
dailyLossLimit: number;
|
||
/** Infinity = no hard daily limit. */
|
||
dllRoom: number;
|
||
maxLossRoom: number | null;
|
||
/** Infinity (or ≤ 0) = no obfuscation stop. */
|
||
obfuscationDollars: number;
|
||
availableFunds?: number;
|
||
/** The platform's stopSlippageFraction; default DLL_SLIPPAGE_FRACTION. */
|
||
slippageFraction?: number;
|
||
}): {
|
||
lastStand: boolean;
|
||
cap: number;
|
||
walkAt: (notional: number) => number;
|
||
sizing: ReturnType<typeof sizeNotional>;
|
||
cost: number | null;
|
||
} {
|
||
const dllOn = Number.isFinite(o.dllRoom) && o.dailyLossLimit > 0;
|
||
const stand = dllOn && lastStand({ dllRoom: o.dllRoom, dailyLossLimit: o.dailyLossLimit, maxLossRoom: o.maxLossRoom });
|
||
const obf = Number.isFinite(o.obfuscationDollars) && o.obfuscationDollars > 0 ? o.obfuscationDollars : Infinity;
|
||
const r = Math.max(0, o.commissionPct) / 100;
|
||
const walkAt = (notional: number) => Math.min(
|
||
obf,
|
||
!dllOn ? Infinity
|
||
: stand ? breachDistance({ dllRoom: o.dllRoom, maxLossRoom: o.maxLossRoom, entryFee: r * notional })
|
||
: dllStopDollars({ room: o.dllRoom, dailyLossLimit: o.dailyLossLimit, entryFee: r * notional, commissionPct: o.commissionPct, notional, slippageFraction: o.slippageFraction }),
|
||
);
|
||
const cap = notionalCap({ marginRate: o.contract.marginRate, availableFunds: o.availableFunds });
|
||
const step = o.contract.stepNotional ?? 0;
|
||
const orderMax = (o.contract.maxContracts ?? 0) > 0 ? o.contract.maxContracts! * step : 0;
|
||
const sizing = step > 0
|
||
? sizeNotional({
|
||
leg: o.leg,
|
||
stopDollarsAt: walkAt,
|
||
volatilityPerHour: o.contract.volatilityPerHour,
|
||
targetHoldHours: o.targetHoldHours,
|
||
commissionPct: o.commissionPct,
|
||
spreadFraction: o.contract.spreadFraction,
|
||
financingPctPerDay: o.financingPctPerDay,
|
||
maxNotional: cap > 0 && orderMax > 0 ? Math.min(cap, orderMax) : cap || orderMax,
|
||
minNotional: Math.max(1, o.contract.minContracts ?? 1) * step,
|
||
})
|
||
: null;
|
||
const cost = sizing
|
||
? sizing.notional * (Math.max(0, o.contract.spreadFraction ?? 0) + 2 * r)
|
||
+ (Math.max(0, o.financingPctPerDay) / 100) * sizing.notional * (sizing.predictedHoldHours / 24)
|
||
: null;
|
||
return { lastStand: stand, cap, walkAt, sizing, cost };
|
||
}
|
||
|
||
/**
|
||
* Order candidates for a leg: those that reach the target hold (within a
|
||
* few percent) first, cheapest first; then those whose leverage cap keeps
|
||
* them short of it, quickest first. Returns indices into `options`.
|
||
*/
|
||
export function rankForTarget(options: { holdHours: number; cost: number }[], targetHoldHours: number): number[] {
|
||
const reaches = (h: number) => h <= targetHoldHours * 1.02 + 0.01;
|
||
return options
|
||
.map((opt, i) => ({ ...opt, i, reach: reaches(opt.holdHours) }))
|
||
.sort((a, b) => (a.reach === b.reach
|
||
? (a.reach ? a.cost - b.cost : a.holdHours - b.holdHours) || a.i - b.i
|
||
: a.reach ? -1 : 1))
|
||
.map((x) => x.i);
|
||
}
|
||
|
||
// ── trading hours ────────────────────────────────────────────────────────────
|
||
|
||
/** Tradeify247 (and DXtrade brokers generally) roll the trading day at 22:00
|
||
* UTC — 5 PM Central in summer, 4 PM in winter. */
|
||
export const FIRM_RESET_UTC_HOUR = 22;
|
||
/** Every-day firms stop opening new trades this long before the earlier of
|
||
* their reset and the 5 PM CT roll. */
|
||
const ROLL_GUARD_MIN = 5;
|
||
|
||
function centralClock(now: Date): { day: string; minutes: number; resetMinutes: number } {
|
||
const parts = new Intl.DateTimeFormat('en-US', {
|
||
weekday: 'short', hour: 'numeric', minute: 'numeric', hour12: false, timeZone: 'America/Chicago',
|
||
}).formatToParts(now);
|
||
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '';
|
||
const minutes = (parseInt(get('hour'), 10) % 24) * 60 + parseInt(get('minute'), 10);
|
||
// Central's offset from UTC right now (−300 in CDT, −360 in CST) places
|
||
// the firm's 22:00 UTC reset on the Central clock.
|
||
const utcMinutes = now.getUTCHours() * 60 + now.getUTCMinutes();
|
||
const offset = ((minutes - utcMinutes + 720 + 1440) % 1440) - 720;
|
||
const resetMinutes = (FIRM_RESET_UTC_HOUR * 60 + offset + 1440) % 1440;
|
||
return { day: get('weekday'), minutes, resetMinutes };
|
||
}
|
||
|
||
/**
|
||
* The CME evening session's first entry, in Central minutes: 5:45 PM, 45
|
||
* minutes after the 5 PM roll. Tradovate moves each account's cash record to
|
||
* the new trade date on its own schedule — an idle one perhaps not until it
|
||
* trades — and the bot holds any account still on the old day until 5:45
|
||
* (tradovate-class ROLL_WINDOW_END_MIN). Opening then puts every account in
|
||
* the first batch, instead of the ready ones firing alone and the rest
|
||
* waiting for them to go flat, and skips the open's widest spreads.
|
||
*/
|
||
export const CME_EVENING_OPEN_MIN = 17 * 60 + 45;
|
||
/** An account's reason for sitting a tick out, with its dollar amounts and
|
||
* "=number" values folded, so like reasons count together on the master:
|
||
* "day done ($12 left of $300)" and "day done ($40 left of $500)" are one. */
|
||
export function foldReason(reason: string): string {
|
||
return reason.replace(/\$-?[\d,]+(?:\.\d+)?/g, '$…').replace(/=-?\d+(?:\.\d+)?/g, '=…');
|
||
}
|
||
|
||
/** Every-day firms' roll guard ends here (plus the day's delay): they have
|
||
* no Tradovate roll to wait for. */
|
||
const EVERY_DAY_OPEN_MIN = 17 * 60 + 5;
|
||
|
||
/**
|
||
* Whether no new trade may open right now.
|
||
*
|
||
* CME firms follow the trading_hours setting:
|
||
* "full_cme" — Sun 5:45 PM – Fri 2:55 PM Central (see CME_EVENING_OPEN_MIN)
|
||
* "equity_hours" — 8:35 AM – 2:55 PM Central, Mon–Fri (5 min buffer on each side)
|
||
* with the open pushed back by the day's 0–180 minute delay.
|
||
*
|
||
* Every-day firms ignore trading_hours and close only around the day's roll:
|
||
* from 5 minutes before the earlier of their 22:00 UTC reset and 5 PM CT, to
|
||
* 5:05 PM CT + jitter. In CST that is 3:55 → 5:05 PM, which keeps new entries
|
||
* out of the hour where the firm's day (reset at 4 PM CT) and our trade date
|
||
* (5 PM CT) disagree.
|
||
*/
|
||
export function isNoTradeTime(now: Date, o: { everyDay: boolean; mode: string; jitterMin: number }): boolean {
|
||
const { day, minutes, resetMinutes } = centralClock(now);
|
||
const cmeOpen = CME_EVENING_OPEN_MIN + o.jitterMin;
|
||
|
||
if (o.everyDay) {
|
||
const guardStart = Math.min(resetMinutes, 17 * 60) - ROLL_GUARD_MIN;
|
||
return minutes >= guardStart && minutes < EVERY_DAY_OPEN_MIN + o.jitterMin;
|
||
}
|
||
|
||
if (o.mode === 'equity_hours') {
|
||
if (day === 'Sat' || day === 'Sun') return true;
|
||
const open = 8 * 60 + 35 + o.jitterMin;
|
||
const close = 14 * 60 + 55;
|
||
return minutes < open || minutes >= close;
|
||
}
|
||
|
||
// Full CME: Sun 5:45 PM (plus the delay) – Fri 2:55 PM Central.
|
||
if (day === 'Sat') return true;
|
||
if (day === 'Sun') return minutes < cmeOpen;
|
||
const cmeClose = 14 * 60 + 55;
|
||
if (day === 'Fri' && minutes >= cmeClose) return true;
|
||
// Mon–Thu: block 2:55 PM – 5:45 PM Central (early stop, daily halt, Tradovate's roll).
|
||
return minutes >= cmeClose && minutes < cmeOpen;
|
||
}
|
||
|
||
/** The trade date's session has closed and the 5 PM roll hasn't opened the
|
||
* next one: weekdays 2:55–5:00 PM CT on the CME calendar, the pre-roll guard
|
||
* for an every-day firm. */
|
||
export function isPastSessionClose(now: Date, everyDay: boolean): boolean {
|
||
const { day, minutes, resetMinutes } = centralClock(now);
|
||
if (everyDay) return minutes >= Math.min(resetMinutes, 17 * 60) - ROLL_GUARD_MIN && minutes < 17 * 60;
|
||
if (day === 'Sat' || day === 'Sun') return false;
|
||
return minutes >= 14 * 60 + 55 && minutes < 17 * 60;
|
||
}
|
||
|
||
/**
|
||
* A firm's cap on how many of its accounts trade at once, on top of the
|
||
* machine's own max concurrent accounts: keeps `items` in order, at most
|
||
* `firm.maxConcurrentAccounts` per firm less what that firm already holds
|
||
* open (`held`, by firm id). A cap of 0 (or none) means no firm cap.
|
||
*/
|
||
export function withinFirmCaps<T extends { firm: { id: string; maxConcurrentAccounts?: number } }>(
|
||
items: readonly T[],
|
||
held: ReadonlyMap<string, number> = new Map(),
|
||
): T[] {
|
||
const taken = new Map(held);
|
||
return items.filter(({ firm }) => {
|
||
const cap = firm.maxConcurrentAccounts ?? 0;
|
||
if (cap <= 0) return true;
|
||
const count = taken.get(firm.id) ?? 0;
|
||
if (count >= cap) return false;
|
||
taken.set(firm.id, count + 1);
|
||
return true;
|
||
});
|
||
}
|