Report each account's future payout chain with closed-form reach probabilities

projectPerfectPath prices every projected withdrawal as
min(withdrawalPct × equity profit, cap) × profit split, and tracks the
cumulative probability of reaching it under the fair-walk ruin model —
room/(room + dayTarget) per day (the per-leg product telescopes), trailing
threshold locked at breakeven after the first withdrawal, no obfuscation
branches. The reporter attaches the first 6 payouts per account so the
master's payout calendar renders real amounts and probabilities instead of
one static expectedPayout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-12 17:01:09 -05:00
co-authored by Claude Fable 5
parent 50a5eb4713
commit 52d51afebb
5 changed files with 164 additions and 10 deletions
+77 -6
View File
@@ -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;