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; } 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 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>(); /** * 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 { 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(); 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; } /** 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. */ function sessionDoubtful(rawTradeDate: string, cycleDailyPnL: { date: string; pnl: number }[], now: Date): boolean { const day = new Date(`${rawTradeDate}T00:00:00Z`); const dow = day.getUTCDay(); if (dow === 0 || dow === 6) return true; if (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; 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 view = combineCmeSessions(opts.combineCmeHolidays, 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())) return 'none'; const withToday = [...prior, { date: view.tradeDate, pnl: view.realizedPnL }]; const probe = nextTradingDate(view.tradeDate); 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; }; /** 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; 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())) 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(cfg.combineCmeHolidays, 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. const eff = resolveEffectiveConfig( cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, 0, earlierFunds, cfg.minDayPnL, ); const rows = combineCmeSessions(cfg.combineCmeHolidays, 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)); if (!cycleOwesNothing(accountId, eff, rows, probe, equityBefore, 0, cfg.splitEnabled)) return null; return { date: traded.map((d) => d.date).reduce(latest), stage: 1 + earlierFunds.filter((f) => f.amount < 0).length, drain: eff.drain, cycle: prevFundDate ?? '', }; } /** 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, 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, }; }