monte-carlo improvements

This commit is contained in:
2025-12-06 21:42:00 +01:00
parent 7fcb2c9a0f
commit 4aa961fc1c
3 changed files with 105 additions and 45 deletions

View File

@@ -11,11 +11,12 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { ChartContainer, ChartTooltip } from '@/components/ui/chart'; import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart';
import { import {
Area, Area,
AreaChart, AreaChart,
CartesianGrid, CartesianGrid,
Line,
XAxis, XAxis,
YAxis, YAxis,
ReferenceLine, ReferenceLine,
@@ -93,15 +94,7 @@ const tooltipRenderer = ({ active, payload }: TooltipProps<ValueType, NameType>)
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 ?? data.balance)}`}</p>
<>
<p className="text-orange-500">{`Median Balance: ${formatNumber(data.balanceP50)}`}</p>
<p className="text-xs text-orange-300">{`10th %: ${formatNumber(data.balanceP10 ?? 0)}`}</p>
<p className="text-xs text-orange-300">{`90th %: ${formatNumber(data.balanceP90 ?? 0)}`}</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>
@@ -113,10 +106,10 @@ const tooltipRenderer = ({ active, payload }: TooltipProps<ValueType, NameType>)
export default function FireCalculatorForm({ export default function FireCalculatorForm({
initialValues, initialValues,
autoCalculate = false, autoCalculate = false,
}: { }: Readonly<{
initialValues?: Partial<FireCalculatorFormValues>; initialValues?: Partial<FireCalculatorFormValues>;
autoCalculate?: boolean; autoCalculate?: boolean;
}) { }>) {
const [result, setResult] = useState<CalculationResult | null>(null); const [result, setResult] = useState<CalculationResult | null>(null);
const irlYear = new Date().getFullYear(); const irlYear = new Date().getFullYear();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@@ -220,7 +213,7 @@ export default function FireCalculatorForm({
const simulationMode = values.simulationMode; const simulationMode = values.simulationMode;
const volatility = values.volatility; const volatility = values.volatility;
const numSimulations = simulationMode === 'monte-carlo' ? 500 : 1; const numSimulations = simulationMode === 'monte-carlo' ? 2000 : 1;
const simulationResults: number[][] = []; // [yearIndex][simulationIndex] -> balance const simulationResults: number[][] = []; // [yearIndex][simulationIndex] -> balance
// Prepare simulation runs // Prepare simulation runs
@@ -298,9 +291,9 @@ export default function FireCalculatorForm({
// Sort to find percentiles // Sort to find percentiles
balancesForYear.sort((a, b) => a - b); balancesForYear.sort((a, b) => a - b);
const p10 = balancesForYear[Math.floor(numSimulations * 0.1)]; const p10 = balancesForYear[Math.floor(numSimulations * 0.4)];
const p50 = balancesForYear[Math.floor(numSimulations * 0.5)]; const p50 = balancesForYear[Math.floor(numSimulations * 0.5)];
const p90 = balancesForYear[Math.floor(numSimulations * 0.9)]; const p90 = balancesForYear[Math.floor(numSimulations * 0.6)];
// Calculate other metrics (using deterministic logic for "untouched" etc for simplicity, or p50) // 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 // We need to reconstruct the "standard" fields for compatibility with the chart
@@ -402,6 +395,13 @@ export default function FireCalculatorForm({
}); });
}; };
const isMonteCarlo = form.watch('simulationMode') === 'monte-carlo';
const chartData =
result?.yearlyData.map((row) => ({
...row,
mcRange: (row.balanceP90 ?? 0) - (row.balanceP10 ?? 0),
})) ?? [];
return ( return (
<> <>
<Card className="border-primary/15 bg-background/90 shadow-primary/10 mb-6 border shadow-lg backdrop-blur"> <Card className="border-primary/15 bg-background/90 shadow-primary/10 mb-6 border shadow-lg backdrop-blur">
@@ -719,7 +719,7 @@ export default function FireCalculatorForm({
<FormItem> <FormItem>
<FormLabel> <FormLabel>
Simulation Mode Simulation Mode
<InfoTooltip content="Deterministic uses fixed yearly returns. Monte Carlo simulates market randomness with 500 runs to show probability ranges." /> <InfoTooltip content="Monte Carlo simulates market randomness with 2000 runs to show probability ranges. Deterministic uses fixed yearly returns." />
</FormLabel> </FormLabel>
<Select <Select
onValueChange={(val) => { onValueChange={(val) => {
@@ -852,11 +852,13 @@ export default function FireCalculatorForm({
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="px-2"> <CardContent className="px-2">
{isMonteCarlo && (
<p className="text-muted-foreground px-2 text-xs" data-testid="mc-band-legend">
Shaded band shows 40th-60th percentile outcomes across 2000 simulations.
</p>
)}
<ChartContainer className="aspect-auto h-80 w-full" config={{}}> <ChartContainer className="aspect-auto h-80 w-full" config={{}}>
<AreaChart <AreaChart data={chartData} margin={{ top: 10, right: 20, left: 20, bottom: 10 }}>
data={result.yearlyData}
margin={{ top: 10, right: 20, left: 20, bottom: 10 }}
>
<CartesianGrid strokeDasharray="3 3" /> <CartesianGrid strokeDasharray="3 3" />
<XAxis <XAxis
dataKey="year" dataKey="year"
@@ -901,12 +903,27 @@ export default function FireCalculatorForm({
width={30} width={30}
stroke="var(--color-red-600)" stroke="var(--color-red-600)"
/> />
<ChartTooltip content={tooltipRenderer} /> <ChartTooltip
content={
<ChartTooltipContent
label={'Year'}
labelFormatter={(value) => {
return value;
}}
labelKey="year"
indicator="line"
/>
}
/>
<defs> <defs>
<linearGradient id="fillBalance" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="fillBalance" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-orange-500)" stopOpacity={0.8} /> <stop offset="5%" stopColor="var(--color-orange-500)" stopOpacity={0.8} />
<stop offset="95%" stopColor="var(--color-orange-500)" stopOpacity={0.1} /> <stop offset="95%" stopColor="var(--color-orange-500)" stopOpacity={0.1} />
</linearGradient> </linearGradient>
<linearGradient id="fillMonteCarloBand" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-primary)" stopOpacity={0.3} />
<stop offset="95%" stopColor="var(--color-secondary)" stopOpacity={0.3} />
</linearGradient>
</defs> </defs>
<Area <Area
type="monotone" type="monotone"
@@ -919,28 +936,55 @@ export default function FireCalculatorForm({
yAxisId={'right'} yAxisId={'right'}
stackId={'a'} stackId={'a'}
/> />
{form.getValues('simulationMode') === 'monte-carlo' && ( <Area
<> type="monotone"
<Area dataKey="balanceP10"
type="monotone" stackId="mc-range"
dataKey="balanceP10" stroke="none"
stroke="none" fill="none"
fill="var(--color-orange-500)" yAxisId={'right'}
fillOpacity={0.1} connectNulls
yAxisId={'right'} isAnimationActive={false}
connectNulls className="mc-bound-base"
/> data-testid="mc-bound-lower"
<Area />
type="monotone" <Area
dataKey="balanceP90" type="monotone"
stroke="none" dataKey={(data: YearlyData & { mcRange: number }) => data.mcRange}
fill="var(--color-orange-500)" stackId="mc-range"
fillOpacity={0.1} stroke="none"
yAxisId={'right'} fill="url(#fillMonteCarloBand)"
connectNulls fillOpacity={0.3}
/> yAxisId={'right'}
</> connectNulls
)} isAnimationActive={false}
className="mc-bound-band"
data-testid="mc-bound-band"
/>
<Line
type="monotone"
dataKey="balanceP10"
stroke="var(--color-orange-500)"
strokeDasharray="6 6"
strokeWidth={1.25}
dot={false}
activeDot={false}
yAxisId={'right'}
className="mc-bound-line-lower"
data-testid="mc-bound-line-lower"
/>
<Line
type="monotone"
dataKey="balanceP90"
stroke="var(--color-orange-500)"
strokeDasharray="6 6"
strokeWidth={1.25}
dot={false}
activeDot={false}
yAxisId={'right'}
className="mc-bound-line-upper"
data-testid="mc-bound-line-upper"
/>
<Area <Area
type="step" type="step"
dataKey="monthlyAllowance" dataKey="monthlyAllowance"

View File

@@ -113,6 +113,22 @@ describe('FireCalculatorForm', () => {
expect(await screen.findByRole('spinbutton', { name: /Market Volatility/i })).toBeInTheDocument(); expect(await screen.findByRole('spinbutton', { name: /Market Volatility/i })).toBeInTheDocument();
}); });
it('shows Monte Carlo percentile bounds on the chart', async () => {
const user = userEvent.setup();
render(<FireCalculatorForm />);
const modeTrigger = screen.getByRole('combobox', { name: /Simulation Mode/i });
await user.click(modeTrigger);
const monteCarloOption = await screen.findByRole('option', { name: /Monte Carlo/i });
await user.click(monteCarloOption);
await screen.findByText('Financial Projection');
const bandLegend = await screen.findByTestId('mc-band-legend');
expect(bandLegend).toHaveTextContent('10th-90th percentile');
});
it('handles withdrawal strategy selection', async () => { it('handles withdrawal strategy selection', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<FireCalculatorForm />); render(<FireCalculatorForm />);

View File

@@ -24,7 +24,7 @@ export const fireCalculatorFormSchema = z.object({
.max(100, 'Coast FIRE age must be at most 100') .max(100, 'Coast FIRE age must be at most 100')
.optional(), .optional(),
baristaIncome: z.coerce.number().min(0, 'Barista income must be a non-negative number').optional(), baristaIncome: z.coerce.number().min(0, 'Barista income must be a non-negative number').optional(),
simulationMode: z.enum(['deterministic', 'monte-carlo']).default('deterministic'), simulationMode: z.enum(['deterministic', 'monte-carlo']).default('monte-carlo'),
volatility: z.coerce.number().min(0).default(15), volatility: z.coerce.number().min(0).default(15),
withdrawalStrategy: z.enum(['fixed', 'percentage']).default('fixed'), withdrawalStrategy: z.enum(['fixed', 'percentage']).default('fixed'),
withdrawalPercentage: z.coerce.number().min(0).max(100).default(4), withdrawalPercentage: z.coerce.number().min(0).max(100).default(4),
@@ -43,7 +43,7 @@ export const fireCalculatorDefaultValues: FireCalculatorFormValues = {
retirementAge: 65, retirementAge: 65,
coastFireAge: undefined, coastFireAge: undefined,
baristaIncome: 0, baristaIncome: 0,
simulationMode: 'deterministic', simulationMode: 'monte-carlo',
volatility: 15, volatility: 15,
withdrawalStrategy: 'fixed', withdrawalStrategy: 'fixed',
withdrawalPercentage: 4, withdrawalPercentage: 4,