/** * 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. * Fallback: returns base profitTarget and consistency unchanged. */ export function resolveEffectiveConfig( profitTarget: number, consistency: number, minTradingDays: number, targetSameEquity: boolean, withdrawalStages: { profit: number; consistency: number; minTradingDays: number }[], priorProfit: number, fundTransactions: { date: string; amount: number }[] ): { profitTarget: number; consistency: number; minTradingDays: number } { 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 }; } if (withdrawalStages.length > 0) { const withdrawalCount = fundTransactions.filter((f) => f.amount < 0).length; if (withdrawalCount === 0) { return { profitTarget, consistency, minTradingDays }; // Stage 1 = base values } const idx = Math.min(withdrawalCount - 1, withdrawalStages.length - 1); const stage = withdrawalStages[idx]; // 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 }; } return { profitTarget, consistency, minTradingDays }; } /** @deprecated Use resolveEffectiveConfig instead */ export function resolveEffectiveProfitTarget( profitTarget: number, targetSameEquity: boolean, withdrawalStages: { profit: number; consistency: number; minTradingDays: number }[], 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; /** 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); } /** * 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, }; } /** 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 ): DayPlan | null { if (typeof equityProfit !== 'number' || !Number.isFinite(equityProfit)) return null; const priorDays = dailyPnL.filter((d) => d.date !== tradeDate); 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, }; }