/** * 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 = 800; /** 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); } /** * 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. */ export function pickLegAmount(remainingToday: number, rand: () => number = Math.random): number { 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); } /** 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 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 ): 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 = consistency === 0 || consistency >= 1 || qualifyingDays.length === 0; return { dayTarget: target.amount, remainingToday: round2(target.amount - realizedPnL), path: target.path, splitAllowed, }; }