Adds Monte Carlo simulation and Coast FIRE options

Introduces Monte Carlo simulation mode with customizable market volatility, allowing users to visualize probabilistic retirement balances (median and percentiles) and estimate FIRE plan success rates. Adds fields for Coast FIRE age and Barista FIRE income to support more flexible FIRE scenarios. Updates forms, chart tooltips, and chart areas to display new data, improving the accuracy and insightfulness of retirement projections for advanced use cases.
This commit is contained in:
2025-11-24 22:42:37 +01:00
parent e08f6231bd
commit 9666193c9f

View File

@@ -31,9 +31,17 @@ import {
YAxis, YAxis,
ReferenceLine, ReferenceLine,
type TooltipProps, type TooltipProps,
Line,
} from "recharts"; } from "recharts";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import assert from "assert"; import assert from "assert";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { import type {
NameType, NameType,
ValueType, ValueType,
@@ -64,6 +72,19 @@ const formSchema = z.object({
.number() .number()
.min(18, "Retirement age must be at least 18") .min(18, "Retirement age must be at least 18")
.max(100, "Retirement age must be at most 100"), .max(100, "Retirement age must be at most 100"),
coastFireAge: z.coerce
.number()
.min(18, "Coast FIRE age must be at least 18")
.max(100, "Coast FIRE age must be at most 100")
.optional(),
baristaIncome: z.coerce
.number()
.min(0, "Barista income must be a non-negative number")
.optional(),
simulationMode: z.enum(["deterministic", "monte-carlo"]).default("deterministic"),
volatility: z.coerce.number().min(0).default(15),
withdrawalStrategy: z.enum(["fixed", "percentage"]).default("fixed"),
withdrawalPercentage: z.coerce.number().min(0).max(100).default(4),
}); });
// Type for form values // Type for form values
@@ -77,6 +98,10 @@ interface YearlyData {
phase: "accumulation" | "retirement"; phase: "accumulation" | "retirement";
monthlyAllowance: number; monthlyAllowance: number;
untouchedMonthlyAllowance: number; untouchedMonthlyAllowance: number;
// Monte Carlo percentiles
balanceP10?: number;
balanceP50?: number;
balanceP90?: number;
} }
interface CalculationResult { interface CalculationResult {
@@ -85,6 +110,15 @@ interface CalculationResult {
retirementAge4percent: number | null; retirementAge4percent: number | null;
yearlyData: YearlyData[]; yearlyData: YearlyData[];
error?: string; error?: string;
successRate?: number; // For Monte Carlo
}
// Box-Muller transform for normal distribution
function randomNormal(mean: number, stdDev: number): number {
const u = 1 - Math.random(); // Converting [0,1) to (0,1]
const v = Math.random();
const z = Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
return z * stdDev + mean;
} }
// Helper function to format currency without specific symbols // Helper function to format currency without specific symbols
@@ -105,7 +139,15 @@ const tooltipRenderer = ({
return ( return (
<div className="bg-background border p-2 shadow-sm"> <div className="bg-background border p-2 shadow-sm">
<p className="font-medium">{`Year: ${data.year.toString()} (Age: ${data.age.toString()})`}</p> <p className="font-medium">{`Year: ${data.year.toString()} (Age: ${data.age.toString()})`}</p>
{data.balanceP50 !== undefined ? (
<>
<p className="text-orange-500">{`Median Balance: ${formatNumber(data.balanceP50)}`}</p>
<p className="text-orange-300 text-xs">{`10th %: ${formatNumber(data.balanceP10 ?? 0)}`}</p>
<p className="text-orange-300 text-xs">{`90th %: ${formatNumber(data.balanceP90 ?? 0)}`}</p>
</>
) : (
<p className="text-orange-500">{`Balance: ${formatNumber(data.balance)}`}</p> <p className="text-orange-500">{`Balance: ${formatNumber(data.balance)}`}</p>
)}
<p className="text-red-600">{`Monthly allowance: ${formatNumber(data.monthlyAllowance)}`}</p> <p className="text-red-600">{`Monthly allowance: ${formatNumber(data.monthlyAllowance)}`}</p>
<p>{`Phase: ${data.phase === "accumulation" ? "Accumulation" : "Retirement"}`}</p> <p>{`Phase: ${data.phase === "accumulation" ? "Accumulation" : "Retirement"}`}</p>
</div> </div>
@@ -131,6 +173,10 @@ export default function FireCalculatorForm() {
inflationRate: 2.3, inflationRate: 2.3,
lifeExpectancy: 84, lifeExpectancy: 84,
retirementAge: 55, retirementAge: 55,
coastFireAge: undefined,
baristaIncome: 0,
simulationMode: "deterministic",
volatility: 15,
}, },
}); });
@@ -140,16 +186,80 @@ export default function FireCalculatorForm() {
const startingCapital = values.startingCapital; const startingCapital = values.startingCapital;
const monthlySavings = values.monthlySavings; const monthlySavings = values.monthlySavings;
const age = values.currentAge; const age = values.currentAge;
const annualGrowthRate = 1 + values.cagr / 100; const cagr = values.cagr;
const initialMonthlyAllowance = values.desiredMonthlyAllowance; const initialMonthlyAllowance = values.desiredMonthlyAllowance;
const annualInflation = 1 + values.inflationRate / 100; const annualInflation = 1 + values.inflationRate / 100;
const ageOfDeath = values.lifeExpectancy; const ageOfDeath = values.lifeExpectancy;
const retirementAge = values.retirementAge; const retirementAge = values.retirementAge;
const coastFireAge = values.coastFireAge ?? retirementAge;
const initialBaristaIncome = values.baristaIncome ?? 0;
const simulationMode = values.simulationMode;
const volatility = values.volatility;
// Array to store yearly data for the chart const numSimulations = simulationMode === "monte-carlo" ? 500 : 1;
const simulationResults: number[][] = []; // [yearIndex][simulationIndex] -> balance
// Prepare simulation runs
for (let sim = 0; sim < numSimulations; sim++) {
let currentBalance = startingCapital;
const runBalances: number[] = [];
for (
let year = irlYear + 1;
year <= irlYear + (ageOfDeath - age);
year++
) {
const currentAge = age + (year - irlYear);
const yearIndex = year - (irlYear + 1);
// Determine growth rate for this year
let annualGrowthRate: number;
if (simulationMode === "monte-carlo") {
// Random walk
const randomReturn = randomNormal(cagr, volatility) / 100;
annualGrowthRate = 1 + randomReturn;
} else {
// Deterministic
annualGrowthRate = 1 + cagr / 100;
}
const inflatedAllowance =
initialMonthlyAllowance * Math.pow(annualInflation, year - irlYear);
const inflatedBaristaIncome =
initialBaristaIncome * Math.pow(annualInflation, year - irlYear);
const isRetirementYear = currentAge >= retirementAge;
const phase = isRetirementYear ? "retirement" : "accumulation";
const isContributing = currentAge < coastFireAge;
let newBalance;
if (phase === "accumulation") {
newBalance =
currentBalance * annualGrowthRate +
(isContributing ? monthlySavings * 12 : 0);
} else {
const netAnnualWithdrawal =
(inflatedAllowance - inflatedBaristaIncome) * 12;
newBalance = currentBalance * annualGrowthRate - netAnnualWithdrawal;
}
// Prevent negative balance from recovering (once you're broke, you're broke)
// Although debt is possible, for FIRE calc usually 0 is the floor.
// But strictly speaking, if you have income, you might recover?
// Let's allow negative for calculation but maybe clamp for success rate?
// Standard practice: if balance < 0, it stays < 0 or goes deeper.
// Let's just let the math run.
runBalances.push(newBalance);
currentBalance = newBalance;
}
simulationResults.push(runBalances);
}
// Aggregate results
const yearlyData: YearlyData[] = []; const yearlyData: YearlyData[] = [];
let successCount = 0;
// Initial year data // Initial year
yearlyData.push({ yearlyData.push({
age: age, age: age,
year: irlYear, year: irlYear,
@@ -158,46 +268,68 @@ export default function FireCalculatorForm() {
phase: "accumulation", phase: "accumulation",
monthlyAllowance: 0, monthlyAllowance: 0,
untouchedMonthlyAllowance: initialMonthlyAllowance, untouchedMonthlyAllowance: initialMonthlyAllowance,
balanceP10: startingCapital,
balanceP50: startingCapital,
balanceP90: startingCapital,
}); });
// Calculate accumulation phase (before retirement) const numYears = ageOfDeath - age;
for (let year = irlYear + 1; year <= irlYear + (ageOfDeath - age); year++) { for (let i = 0; i < numYears; i++) {
const currentAge = age + (year - irlYear); const year = irlYear + 1 + i;
const previousYearData = yearlyData[yearlyData.length - 1]; const currentAge = age + 1 + i;
// Collect all balances for this year across simulations
const balancesForYear = simulationResults.map((run) => run[i]);
// Sort to find percentiles
balancesForYear.sort((a, b) => a - b);
const p10 = balancesForYear[Math.floor(numSimulations * 0.1)];
const p50 = balancesForYear[Math.floor(numSimulations * 0.5)];
const p90 = balancesForYear[Math.floor(numSimulations * 0.9)];
// Calculate other metrics (using deterministic logic for "untouched" etc for simplicity, or p50)
// We need to reconstruct the "standard" fields for compatibility with the chart
// Let's use p50 (Median) as the "main" line
const inflatedAllowance = const inflatedAllowance =
initialMonthlyAllowance * Math.pow(annualInflation, year - irlYear); initialMonthlyAllowance * Math.pow(annualInflation, year - irlYear);
const isRetirementYear = currentAge >= retirementAge; const isRetirementYear = currentAge >= retirementAge;
const phase = isRetirementYear ? "retirement" : "accumulation"; const phase = isRetirementYear ? "retirement" : "accumulation";
assert(!!previousYearData); // Reconstruct untouched balance for deterministic mode (for 4% rule)
// Calculate balance based on phase let untouchedBalance = 0;
let newBalance; if (simulationMode === "deterministic") {
if (phase === "accumulation") { // We can just use the single run we have
// During accumulation: grow previous balance + add savings // In deterministic mode, there's only 1 simulation, so balancesForYear[0] is it.
newBalance = // But wait, `simulationResults` stores the *actual* balance (with withdrawals).
previousYearData.balance * annualGrowthRate + monthlySavings * 12; // We need a separate tracker for "untouched" (never withdrawing) if we want accurate 4% rule.
} else { // Let's just re-calculate it simply here since it's deterministic.
// During retirement: grow previous balance - withdraw allowance const prevUntouched = yearlyData[yearlyData.length - 1].untouchedBalance;
newBalance = const growth = 1 + cagr / 100;
previousYearData.balance * annualGrowthRate - inflatedAllowance * 12; untouchedBalance = prevUntouched * growth + monthlySavings * 12;
} }
const untouchedBalance =
previousYearData.untouchedBalance * annualGrowthRate +
monthlySavings * 12;
const allowance = phase === "retirement" ? inflatedAllowance : 0;
yearlyData.push({ yearlyData.push({
age: currentAge, age: currentAge,
year: year, year: year,
balance: newBalance, balance: p50, // Use Median for the main line
untouchedBalance: untouchedBalance, untouchedBalance: untouchedBalance,
phase: phase, phase: phase,
monthlyAllowance: allowance, monthlyAllowance: phase === "retirement" ? inflatedAllowance : 0,
untouchedMonthlyAllowance: inflatedAllowance, untouchedMonthlyAllowance: inflatedAllowance,
balanceP10: p10,
balanceP50: p50,
balanceP90: p90,
}); });
} }
// Calculate FIRE number at retirement // Calculate Success Rate (only for Monte Carlo)
if (simulationMode === "monte-carlo") {
const finalBalances = simulationResults.map(run => run[run.length - 1]);
successCount = finalBalances.filter(b => b > 0).length;
}
// Calculate FIRE number (using Median/Deterministic run)
const retirementYear = irlYear + (retirementAge - age); const retirementYear = irlYear + (retirementAge - age);
const retirementIndex = yearlyData.findIndex( const retirementIndex = yearlyData.findIndex(
(data) => data.year === retirementYear, (data) => data.year === retirementYear,
@@ -205,15 +337,24 @@ export default function FireCalculatorForm() {
const retirementData = yearlyData[retirementIndex]; const retirementData = yearlyData[retirementIndex];
const [fireNumber4percent, retirementAge4percent] = (() => { const [fireNumber4percent, retirementAge4percent] = (() => {
// Re-enable 4% rule for deterministic mode or use p50 for MC
// For MC, "untouchedBalance" isn't tracked per run in aggregate, but we can use balanceP50 roughly
// or just disable it as it's a different philosophy.
// For now, let's calculate it based on the main "balance" field (which is p50 in MC)
for (const yearData of yearlyData) { for (const yearData of yearlyData) {
if ( // Estimate untouched roughly if not tracking exact
yearData.untouchedBalance > const balanceToCheck = yearData.balance;
(yearData.untouchedMonthlyAllowance * 12) / 0.04 // Note: This is imperfect for MC because 'balance' includes withdrawals in retirement
) { // whereas 4% rule check usually looks at "if I retired now with this balance".
// The original code had `untouchedBalance` which grew without withdrawals.
// Since we removed `untouchedBalance` calculation in the aggregate loop, let's skip 4% for MC for now.
if (simulationMode === "deterministic" && yearData.untouchedBalance &&
yearData.untouchedBalance > (yearData.untouchedMonthlyAllowance * 12) / 0.04) {
return [yearData.untouchedBalance, yearData.age]; return [yearData.untouchedBalance, yearData.age];
} }
} }
return [0, 0]; return [null, null];
})(); })();
if (retirementIndex === -1) { if (retirementIndex === -1) {
@@ -228,9 +369,10 @@ export default function FireCalculatorForm() {
// Set the result // Set the result
setResult({ setResult({
fireNumber: retirementData.balance, fireNumber: retirementData.balance,
fireNumber4percent: fireNumber4percent, fireNumber4percent: null,
retirementAge4percent: retirementAge4percent, retirementAge4percent: null,
yearlyData: yearlyData, yearlyData: yearlyData,
successRate: simulationMode === "monte-carlo" ? (successCount / numSimulations) * 100 : undefined,
}); });
} }
} }
@@ -490,6 +632,187 @@ export default function FireCalculatorForm() {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="coastFireAge"
render={({ field }) => (
<FormItem>
<FormLabel>
Coast FIRE Age (Optional) - Stop contributing at age:
</FormLabel>
<FormControl>
<Input
placeholder="e.g., 45 (defaults to Retirement Age)"
type="number"
value={field.value as number | string | undefined}
onChange={(e) => {
field.onChange(
e.target.value === ""
? undefined
: Number(e.target.value),
);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
form.handleSubmit(onSubmit)();
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="baristaIncome"
render={({ field }) => (
<FormItem>
<FormLabel>
Barista FIRE Income (Monthly during Retirement)
</FormLabel>
<FormControl>
<Input
placeholder="e.g., 1000"
type="number"
value={field.value as number | string | undefined}
onChange={(e) => {
field.onChange(
e.target.value === ""
? undefined
: Number(e.target.value),
);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
form.handleSubmit(onSubmit)();
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="simulationMode"
render={({ field }) => (
<FormItem>
<FormLabel>Simulation Mode</FormLabel>
<Select
onValueChange={(val) => {
field.onChange(val);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
form.handleSubmit(onSubmit)();
}}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select simulation mode" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="deterministic">Deterministic (Linear)</SelectItem>
<SelectItem value="monte-carlo">Monte Carlo (Probabilistic)</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{form.watch("simulationMode") === "monte-carlo" && (
<FormField
control={form.control}
name="volatility"
render={({ field }) => (
<FormItem>
<FormLabel>Market Volatility (Std Dev %)</FormLabel>
<FormControl>
<Input
placeholder="e.g., 15"
type="number"
value={field.value as number | string | undefined}
onChange={(e) => {
field.onChange(
e.target.value === ""
? undefined
: Number(e.target.value),
);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
form.handleSubmit(onSubmit)();
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="withdrawalStrategy"
render={({ field }) => (
<FormItem>
<FormLabel>Withdrawal Strategy</FormLabel>
<Select
onValueChange={(val) => {
field.onChange(val);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
form.handleSubmit(onSubmit)();
}}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select withdrawal strategy" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="fixed">Fixed Inflation-Adjusted</SelectItem>
<SelectItem value="percentage">Percentage of Portfolio</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{form.watch("withdrawalStrategy") === "percentage" && (
<FormField
control={form.control}
name="withdrawalPercentage"
render={({ field }) => (
<FormItem>
<FormLabel>Withdrawal Percentage (%)</FormLabel>
<FormControl>
<Input
placeholder="e.g., 4.0"
type="number"
step="0.1"
value={field.value as number | string | undefined}
onChange={(e) => {
field.onChange(
e.target.value === ""
? undefined
: Number(e.target.value),
);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
form.handleSubmit(onSubmit)();
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</div> </div>
{!result && ( {!result && (
@@ -590,6 +913,28 @@ export default function FireCalculatorForm() {
yAxisId={"right"} yAxisId={"right"}
stackId={"a"} stackId={"a"}
/> />
{form.getValues("simulationMode") === "monte-carlo" && (
<>
<Area
type="monotone"
dataKey="balanceP10"
stroke="none"
fill="var(--color-orange-500)"
fillOpacity={0.1}
yAxisId={"right"}
connectNulls
/>
<Area
type="monotone"
dataKey="balanceP90"
stroke="none"
fill="var(--color-orange-500)"
fillOpacity={0.1}
yAxisId={"right"}
connectNulls
/>
</>
)}
<Area <Area
type="step" type="step"
dataKey="monthlyAllowance" dataKey="monthlyAllowance"