A drain stage zeroed consistency, so the payout was planned (and the account stopped trading) as soon as the min days were booked — even when the cycle's biggest day still exceeded the firm's ratio (Topstep holds its 40% through the drain). Carry stage.consistency into the effective config and keep booking drain days until the cycle total covers maxDay / consistency. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
841 lines
39 KiB
TypeScript
841 lines
39 KiB
TypeScript
export interface WithdrawalStage {
|
||
profit: number;
|
||
consistency: number;
|
||
minTradingDays: number;
|
||
/** Spam-withdraw stage: no profit target — book minTradingDays qualifying
|
||
* days of ~minDayPnL (tiny MNQ trades), then wait for the withdrawal.
|
||
* The stage's consistency still applies: days keep booking until the
|
||
* cycle's biggest day fits inside that share of the cycle total. */
|
||
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;
|
||
}
|
||
|
||
export interface EffectiveConfig {
|
||
profitTarget: number;
|
||
consistency: number;
|
||
minTradingDays: number;
|
||
minDayPnL: number;
|
||
drain: boolean;
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
): EffectiveConfig {
|
||
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 };
|
||
}
|
||
if (withdrawalStages.length > 0) {
|
||
const withdrawalCount = fundTransactions.filter((f) => f.amount < 0).length;
|
||
if (withdrawalCount === 0) {
|
||
return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false }; // Stage 1 = base values
|
||
}
|
||
const idx = Math.min(withdrawalCount - 1, withdrawalStages.length - 1);
|
||
const stage = withdrawalStages[idx];
|
||
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,
|
||
};
|
||
}
|
||
// 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 };
|
||
}
|
||
return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false };
|
||
}
|
||
|
||
/** @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: the stage's min-day amount plus a deterministic bump of
|
||
* $0–$10 in $0.50 steps — MNQ's per-contract tick value, so every possible
|
||
* target lands on a realizable exit ($5 min day → $5.00, $5.50, … $15.00
|
||
* depending on the (account, trade date) pair). Seeded like dailyBump so
|
||
* every consumer of the plan computes the same number.
|
||
*/
|
||
/** Drain-day target grid: $4–$15 in $0.50 steps (23 values), always traded
|
||
* on MNQ. Drain days qualify at ≥ DRAIN_DAY_MIN, so every draw counts. */
|
||
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 weekday after d (YYYY-MM-DD). Trade dates never fall on Sat/Sun. */
|
||
function nextTradingDate(d: string): string {
|
||
const dt = new Date(`${d}T00:00:00Z`);
|
||
do { dt.setUTCDate(dt.getUTCDate() + 1); } while (dt.getUTCDay() === 0 || dt.getUTCDay() === 6);
|
||
return dt.toISOString().slice(0, 10);
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
};
|
||
/** Current-cycle daily P&L (account.dailyPnL). */
|
||
cycleDailyPnL: { date: string; pnl: number }[];
|
||
fundTransactions: { date: string; amount: number }[];
|
||
balance: number;
|
||
realizedPnL: number;
|
||
/** 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;
|
||
};
|
||
}): 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 rawToday = currentTradeDateCT();
|
||
// 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 = cfg.combineCmeHolidays === true;
|
||
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.
|
||
const todayDow = new Date(`${rawToday}T00:00:00Z`).getUTCDay();
|
||
const todayHasSession = 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 always skip, and a
|
||
* combining firm's holiday sessions clear into the next full day. */
|
||
const nextDay = (d: string): string => {
|
||
let n = nextTradingDate(d);
|
||
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);
|
||
};
|
||
|
||
const effectiveNow = () => resolveEffectiveConfig(
|
||
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity,
|
||
cfg.withdrawalStages, 0, funds, cfg.minDayPnL,
|
||
);
|
||
/** The payout parameters of the stage currently being traded: per-stage
|
||
* overrides when set, else the type defaults. */
|
||
const stagePayout = (): { pct: number; cap: number } => {
|
||
const count = funds.filter((f) => f.amount < 0).length;
|
||
if (count === 0 || cfg.withdrawalStages.length === 0) return { pct: basePct, cap: baseCap };
|
||
const stage = cfg.withdrawalStages[Math.min(count - 1, cfg.withdrawalStages.length - 1)];
|
||
return { pct: stage.withdrawalPct ?? basePct, cap: stage.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 = effectiveNow();
|
||
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();
|
||
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 = effectiveNow();
|
||
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 + funds.filter((f) => f.amount < 0).length;
|
||
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 = effectiveNow();
|
||
const stage = 1 + funds.filter((f) => f.amount < 0).length;
|
||
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 = effectiveNow();
|
||
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;
|
||
}
|
||
|
||
/** Dollar-per-point value for common futures products. */
|
||
export const POINT_VALUES: { [symbol: string]: number } = {
|
||
NQ: 20, MNQ: 2, ES: 50, MES: 5,
|
||
YM: 5, MYM: 0.5, RTY: 50, M2K: 10,
|
||
GC: 100, MGC: 10, SI: 50, CL: 1000,
|
||
MCL: 100, NG: 10000, ZB: 1000, ZN: 1000,
|
||
ZF: 1000, '6E': 125000, '6J': 12500000, '6B': 62500,
|
||
};
|
||
|
||
/**
|
||
* 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 at the randomized min-day amount, then go quiet until the next
|
||
// withdrawal resets the cycle. One unsplit tiny trade per day.
|
||
if (drain) {
|
||
const qualifying = minDayPnL > 0
|
||
? priorDays.filter((d) => d.pnl >= minDayPnL)
|
||
: priorDays.filter((d) => d.pnl !== 0);
|
||
// The stage's consistency rule outlives the profit target: the cycle's
|
||
// biggest day must fit inside `consistency` of the cycle total, so the
|
||
// drain keeps booking days past the min-day count until the total
|
||
// covers maxDay / consistency.
|
||
let consistencyShort = false;
|
||
if (consistency > 0 && consistency < 1 && priorDays.length > 0) {
|
||
const maxDay = Math.max(0, ...priorDays.map((d) => d.pnl));
|
||
const total = priorDays.reduce((s, d) => s + d.pnl, 0);
|
||
consistencyShort = maxDay > 0 && total < round2(maxDay / consistency) - 0.005;
|
||
}
|
||
const dayTarget = qualifying.length >= minTradingDays && !consistencyShort
|
||
? 0
|
||
: drainDayAmount(accountId, tradeDate);
|
||
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,
|
||
};
|
||
}
|