diff --git a/app/accounts/[id]/page.tsx b/app/accounts/[id]/page.tsx index b71eb86..1d049bb 100644 --- a/app/accounts/[id]/page.tsx +++ b/app/accounts/[id]/page.tsx @@ -484,6 +484,12 @@ export default function AccountPage() { fundTransactions, balance: account.amount, realizedPnL: account.realizedPnL, + payout: { + withdrawalPct: cfg.withdrawalPct, + payoutCap: cfg.payoutCap, + profitSplit: cfg.profitSplit, + drawdown: cfg.trailingDrawdown ?? 0, + }, }) : []; const projPnlMap = new Map(projection.filter((p) => p.withdrawal === undefined && !p.payoutReady).map((p) => [p.date, p.pnl])); const projFundMap = new Map(projection.filter((p) => p.withdrawal !== undefined).map((p) => [p.date, p.withdrawal!])); diff --git a/lib/master-protocol.ts b/lib/master-protocol.ts index 7729ab9..875f034 100644 --- a/lib/master-protocol.ts +++ b/lib/master-protocol.ts @@ -47,6 +47,16 @@ export interface AccountTypeStats { * after the 5 PM CT roll a day booked on tomorrow's trade date is * already counted in `days`. */ lastDay?: string; + /** This account's projected payout chain on the perfect path: one + * entry per future withdrawal, with the gross amount + * (min(withdrawalPct × balance, cap)), the net received + * (gross × profitSplit), and prob = the CUMULATIVE probability of + * reaching that payout under the fair-walk ruin model. Optional so + * an older master simply ignores it. */ + futurePayouts?: { + date: string; gross: number; net: number; prob: number; + stage: number; drain?: boolean; + }[]; }[]; } @@ -131,8 +141,23 @@ export interface CatalogAccountType { /** Per-stage config after the Nth withdrawal; the last stage repeats. * drain=true marks a spam-withdraw stage: no profit target — book * minTradingDays qualifying days of ~minDayPnL (default $5, randomized - * $5–$15, traded as tiny MNQ legs), then wait for the next withdrawal. */ - withdrawalStages: { profit: number; consistency: number; minTradingDays: number; drain?: boolean; minDayPnL?: number }[]; + * $5–$15, traded as tiny MNQ legs), then wait for the next withdrawal. + * withdrawalPct/payoutCap override the type's payout defaults per stage. */ + withdrawalStages: { profit: number; consistency: number; minTradingDays: number; drain?: boolean; minDayPnL?: number; withdrawalPct?: number; payoutCap?: number }[]; + /** Payout-projection settings, owned by the master's payout calendar but + * consumed here to compute each account's future payout chain. Optional: + * an older master doesn't send them. */ + payoutable?: boolean; + /** Trailing drawdown in dollars for ruin-model probabilities. 0 = unset. */ + trailingDrawdown?: number; + /** Legacy static payout estimate — superseded by the computed chain. */ + expectedPayout?: number; + /** Share of the balance withdrawn when a cycle completes (default 0.5). */ + withdrawalPct?: number; + /** Cap on the gross withdrawal in dollars. 0 = no cap. */ + payoutCap?: number; + /** Share of the gross the person actually receives (default 0.9). */ + profitSplit?: number; /** ISO. Ties between two matching account types are broken by this. */ updatedAt: string; } diff --git a/lib/reporter.ts b/lib/reporter.ts index 4626cf4..6ce9f5d 100644 --- a/lib/reporter.ts +++ b/lib/reporter.ts @@ -17,7 +17,7 @@ import { getClients } from './clients'; import { getTradableFirms } from './firm-catalog'; import { getAccountConfig } from './account-type-resolver'; import { getSchedulerStatus } from './auto-trade'; -import { combineCmeSessions, computeDayPlan, currentTradeDateCT, LEG_COMPLETION_EPSILON, resolveEffectiveConfig } from './trading-logic'; +import { combineCmeSessions, computeDayPlan, currentTradeDateCT, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig } from './trading-logic'; import type { AccountTypeStats } from './master-protocol'; function collectFirmStats() { @@ -126,6 +126,49 @@ function collectFirmStats() { if (run > highWater) highWater = run; } const traded = combined.dailyPnL.filter((d) => d.pnl !== 0); + + // The account's future payout chain on the perfect path — + // dates, dynamic amounts (withdrawal % of balance, capped, + // × profit split) and cumulative fair-walk reach probability. + // The master renders these directly instead of running its + // own Monte-Carlo for this account. + let futurePayouts: NonNullable[number]['futurePayouts']; + try { + futurePayouts = projectPerfectPath({ + accountId: acc.id, + cfg, + cycleDailyPnL: client.dailyPnL[acc.id] ?? [], + fundTransactions: fundTxns, + balance: cash.amount, + realizedPnL: cash.realizedPnL, + payout: { + withdrawalPct: cfg.withdrawalPct, + payoutCap: cfg.payoutCap, + profitSplit: cfg.profitSplit, + // Same default chain the master's calendar always + // used: configured trailing drawdown, else the + // max-loss size, else $2,000. + drawdown: (cfg.trailingDrawdown ?? 0) > 0 + ? cfg.trailingDrawdown! + : Math.abs(cfg.maxLoss) > 0 ? Math.abs(cfg.maxLoss) : 2000, + startHigh: highWater, + }, + }) + .filter((p) => p.withdrawal !== undefined || p.payoutReady) + .slice(0, 6) + .map((p) => ({ + date: p.date, + gross: p.gross ?? 0, + net: p.net ?? 0, + prob: Math.round(p.prob * 10000) / 10000, + stage: p.stage, + ...(p.drain ? { drain: true } : {}), + })); + if (futurePayouts.length === 0) futurePayouts = undefined; + } catch (err) { + console.error(`[reporter] payout projection failed for account ${acc.id}`, err); + } + (slice.payoutStates ??= []).push({ days: qualifying.length, profit: equityProfit, @@ -137,6 +180,7 @@ function collectFirmStats() { consistency: effective.consistency, minDays: effective.minTradingDays, lastDay: traded.length > 0 ? traded.reduce((a, b) => (a.date > b.date ? a : b)).date : undefined, + ...(futurePayouts ? { futurePayouts } : {}), }); } diff --git a/lib/trading-logic.ts b/lib/trading-logic.ts index b847af3..84643ab 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -8,6 +8,10 @@ export interface WithdrawalStage { /** Drain stages only: the per-day booked amount (default $5; randomized * $5–$15 by drainDayAmount). */ minDayPnL?: number; + /** Payout overrides for the withdrawal that completes THIS stage; absent + * fields fall back to the account type's defaults. */ + withdrawalPct?: number; + payoutCap?: number; } export interface EffectiveConfig { @@ -337,6 +341,17 @@ export interface ProjectedDay { withdrawal?: number; /** Non-staged account completed here; the projection stops. */ payoutReady?: boolean; + /** Cumulative probability of the path having survived through this day, + * under the fair-walk ruin model: each trading day survives with + * room/(room + dayTarget), where room is the distance to the trailing + * liquidation threshold (the per-leg product telescopes to exactly + * this). 1 when no drawdown applies. */ + prob: number; + /** Withdrawal entries: gross amount leaving the account + * (min(withdrawalPct × balance, cap)) and the net the person receives + * (gross × profitSplit). */ + gross?: number; + net?: number; } /** Next weekday after d (YYYY-MM-DD). Trade dates never fall on Sat/Sun. */ @@ -373,10 +388,25 @@ export function projectPerfectPath(opts: { /** 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; + }; }): 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 today = currentTradeDateCT(); let sim = opts.cycleDailyPnL.filter((d) => d.date !== today); @@ -387,10 +417,34 @@ export function projectPerfectPath(opts: { 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); + let prob = 1; + const dayProb = (dayTarget: number): number => { + if (dd <= 0 || dayTarget <= 0) return 1; + const threshold = high >= dd ? 0 : high - dd; + const room = (balance - cfg.accountSize) - threshold; + if (room <= 0) return 0; + return room / (room + dayTarget); + }; + const effectiveNow = () => resolveEffectiveConfig( cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, 0, funds, cfg.minDayPnL, ); + /** The payout parameters of the stage currently being traded: per-stage + * overrides when set, else the type defaults. */ + const stagePayout = (): { pct: number; cap: number } => { + const count = funds.filter((f) => f.amount < 0).length; + if (count === 0 || cfg.withdrawalStages.length === 0) return { pct: basePct, cap: baseCap }; + const stage = cfg.withdrawalStages[Math.min(count - 1, cfg.withdrawalStages.length - 1)]; + return { pct: stage.withdrawalPct ?? basePct, cap: stage.payoutCap ?? baseCap }; + }; const qualifyingDays = (minDay: number, excludeDate: string) => ( minDay > 0 ? sim.filter((d) => d.date !== excludeDate && d.pnl >= minDay).length @@ -408,11 +462,13 @@ export function projectPerfectPath(opts: { 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); } } @@ -430,26 +486,41 @@ export function projectPerfectPath(opts: { const daysNeeded = eff.minTradingDays > qualifyingDays(eff.minDayPnL, date); if (plan.dayTarget <= 0 && !daysNeeded) { - // Cycle complete — nothing left to trade. - const withdrawal = -round2(balance / 2); + // Cycle complete — nothing left to trade. 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. + const { pct, cap } = stagePayout(); + const rawGross = round2(Math.max(0, balance - cfg.accountSize) * pct); + const gross = cap > 0 ? Math.min(rawGross, round2(cap)) : rawGross; + const withdrawal = -gross; if (cfg.withdrawalStages.length > 0 && withdrawals < maxWithdrawals && withdrawal < -1) { funds.push({ date, amount: withdrawal }); balance = round2(balance + withdrawal); sim = []; // withdrawal excludes its day — fresh cycle withdrawals++; - out.push({ date, pnl: 0, stage, drain: eff.drain, withdrawal }); + high = Math.max(high, dd); // a payout locks the trail at breakeven + out.push({ + date, pnl: 0, stage, drain: eff.drain, withdrawal, prob, + gross, net: round2(gross * profitSplit), + }); continue; } - out.push({ date, pnl: 0, stage, drain: eff.drain, payoutReady: true }); + out.push({ + date, pnl: 0, stage, drain: eff.drain, payoutReady: true, prob, + gross, net: round2(gross * profitSplit), + }); break; } // Extra days (min-day 0, days still owed) book a token dollar so the - // day counts, matching the real in-and-out MNQ trade. + // 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); - out.push({ date, pnl, stage, drain: eff.drain }); + high = Math.max(high, balance - cfg.accountSize); + out.push({ date, pnl, stage, drain: eff.drain, prob }); } return out; diff --git a/types.ts b/types.ts index 59de823..2dbac5b 100644 --- a/types.ts +++ b/types.ts @@ -16,7 +16,15 @@ export interface AccountConfig { firstDayStop: boolean; // first cycle day carries a $1,000 account stop - books red and ends minDayStop: boolean; // min-day trades carry a 2x-target stop; next day after a loss rides stopless combineCmeHolidays: boolean; // true = CME holiday sessions count with the next full trading day (Apex-style) - withdrawalStages: { profit: number; consistency: number; minTradingDays: number; drain?: boolean; minDayPnL?: number }[]; // per-stage fresh profit + consistency (empty = use base values); drain = spam-withdraw stage + withdrawalStages: { profit: number; consistency: number; minTradingDays: number; drain?: boolean; minDayPnL?: number; withdrawalPct?: number; payoutCap?: number }[]; // per-stage fresh profit + consistency (empty = use base values); drain = spam-withdraw stage + /** Payout model, from the master's payout settings (absent on old masters): + * withdraw withdrawalPct of balance (capped at payoutCap) per cycle, + * receive profitSplit of the gross; trailingDrawdown feeds the ruin + * probabilities. */ + withdrawalPct?: number; + payoutCap?: number; + profitSplit?: number; + trailingDrawdown?: number; } export interface FirmConfig {