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