monte-carlo improvements
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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 />);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user