redesigned algorith, use user specified retirement age

This commit is contained in:
Felix Schulze 2025-05-01 15:25:22 +02:00
parent 383625aede
commit 09e9485f2f

View File

@ -32,23 +32,19 @@ import {
YAxis, YAxis,
ReferenceLine, ReferenceLine,
} from "recharts"; } from "recharts";
import { import { Slider } from "@/components/ui/slider";
Select, import assert from "assert";
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
// Schema for form validation // Schema for form validation
const formSchema = z.object({ const formSchema = z.object({
startingCapital: z.coerce startingCapital: z.coerce.number(),
.number()
.min(0, "Starting capital must be a non-negative number"),
monthlySavings: z.coerce monthlySavings: z.coerce
.number() .number()
.min(0, "Monthly savings must be a non-negative number"), .min(0, "Monthly savings must be a non-negative number"),
currentAge: z.coerce.number().min(18, "Age must be at least 18"), currentAge: z.coerce
.number()
.min(1, "Age must be at least 1")
.max(100, "No point in starting this late"),
cagr: z.coerce.number().min(0, "Growth rate must be a non-negative number"), cagr: z.coerce.number().min(0, "Growth rate must be a non-negative number"),
desiredMonthlyAllowance: z.coerce desiredMonthlyAllowance: z.coerce
.number() .number()
@ -58,8 +54,12 @@ const formSchema = z.object({
.min(0, "Inflation rate must be a non-negative number"), .min(0, "Inflation rate must be a non-negative number"),
lifeExpectancy: z.coerce lifeExpectancy: z.coerce
.number() .number()
.min(50, "Life expectancy must be at least 50"), .min(40, "Be a bit more optimistic buddy :(")
retirementStrategy: z.enum(["Depletion", "Maintenance", "4% Rule"]), .max(100, "You should be more realistic..."),
retirementAge: z.coerce
.number()
.min(18, "Retirement age must be at least 18")
.max(100, "Retirement age must be at most 100"),
}); });
// Type for form values // Type for form values
@ -71,16 +71,12 @@ interface YearlyData {
balance: number; balance: number;
phase: "accumulation" | "retirement"; phase: "accumulation" | "retirement";
monthlyAllowance: number; monthlyAllowance: number;
fireNumber: number | null;
} }
interface CalculationResult { interface CalculationResult {
fireNumber: number | null; fireNumber: number | null;
retirementAge: number | null; yearlyData: YearlyData[];
inflationAdjustedAllowance: number | null;
retirementYears: number | null;
error?: string; error?: string;
yearlyData?: Record<string, YearlyData>;
} }
export default function FireCalculatorForm() { export default function FireCalculatorForm() {
@ -95,23 +91,14 @@ export default function FireCalculatorForm() {
monthlySavings: 1500, monthlySavings: 1500,
currentAge: 25, currentAge: 25,
cagr: 7, cagr: 7,
desiredMonthlyAllowance: 2000, desiredMonthlyAllowance: 3000,
inflationRate: 2, inflationRate: 2,
lifeExpectancy: 84, lifeExpectancy: 84,
retirementStrategy: "Depletion", retirementAge: 55,
}, },
}); });
function onSubmit(values: FormValues) { function onSubmit(values: FormValues) {
/*
PSEUDOCODE
1. calculate all balances if no retirement.
2. calculate all required FIRE numbers for each possible year of retirement for the selected strategy.
2.1 calculate the monthly allowance for each year of retirement for all years, fire number is these but cumulative.
3. binary search the crossover
4. calculate new balance for each year of retirement
5. graph balance, balance if no retirement, fire numbers, allowances
*/
setResult(null); // Reset previous results setResult(null); // Reset previous results
const startingCapital = values.startingCapital; const startingCapital = values.startingCapital;
@ -121,85 +108,72 @@ export default function FireCalculatorForm() {
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 retirementStrategy = values.retirementStrategy; const retirementAge = values.retirementAge;
let requiredCapital: number | null = null; // Array to store yearly data for the chart
let retirementAge: number | null = null; const yearlyData: YearlyData[] = [];
let finalInflationAdjustedAllowance: number | null = null;
// Array to store yearly data for the chart with initial value // Initial year data
const yearlyData: Record<number, YearlyData> = { yearlyData.push({
[irlYear]: { age: age,
age: age, year: irlYear,
year: irlYear, balance: startingCapital,
balance: startingCapital, phase: "accumulation",
phase: "accumulation", monthlyAllowance: initialMonthlyAllowance,
monthlyAllowance: initialMonthlyAllowance,
fireNumber: null,
},
};
// calculate all balances if no retirement
for (let year = irlYear + 1; year <= irlYear + ageOfDeath - age; year++) {
const previousYearData = yearlyData[year - 1];
if (!previousYearData) {
continue;
}
yearlyData[year] = {
age: age + year - irlYear,
year: year,
balance:
previousYearData.balance * annualGrowthRate + monthlySavings * 12,
phase: "accumulation",
monthlyAllowance: previousYearData.monthlyAllowance * annualInflation,
fireNumber: null,
};
}
// calculate FIRE numbers based on allowances
for (let year = irlYear + ageOfDeath - age; year >= irlYear; year--) {
const yearData = yearlyData[year];
if (!yearData) {
continue;
}
yearData.fireNumber =
(yearlyData[year + 1]?.fireNumber ?? 0) +
12 * yearData.monthlyAllowance;
}
// calculate new balance and retirement age
for (let year = irlYear; year <= irlYear + ageOfDeath - age; year++) {
const yearData = yearlyData[year];
const previousYearData = yearlyData[year - 1];
if (!yearData?.fireNumber) {
continue;
}
if (!previousYearData) {
yearData.monthlyAllowance = 0;
continue;
}
if (yearData.balance > yearData.fireNumber) {
retirementAge ??= yearData.age;
requiredCapital ??= yearData.balance;
finalInflationAdjustedAllowance ??= yearData.monthlyAllowance;
yearData.phase = "retirement";
yearData.balance =
previousYearData.balance * annualGrowthRate -
yearData.monthlyAllowance * 12;
} else {
yearData.monthlyAllowance = 0;
}
}
// --- Calculation Logic based on Strategy ---
// --- Set Final Result ---
setResult({
fireNumber: requiredCapital,
retirementAge: retirementAge,
inflationAdjustedAllowance: finalInflationAdjustedAllowance,
retirementYears: ageOfDeath - retirementAge,
yearlyData: Object.values(yearlyData),
error: undefined,
}); });
// Calculate accumulation phase (before retirement)
for (let year = irlYear + 1; year <= irlYear + (ageOfDeath - age); year++) {
const currentAge = age + (year - irlYear);
const previousYearData = yearlyData[yearlyData.length - 1];
const inflatedAllowance =
initialMonthlyAllowance * Math.pow(annualInflation, year - irlYear);
const isRetirementYear = currentAge >= retirementAge;
const phase = isRetirementYear ? "retirement" : "accumulation";
assert(!!previousYearData);
// Calculate balance based on phase
let newBalance;
if (phase === "accumulation") {
// During accumulation: grow previous balance + add savings
newBalance =
previousYearData.balance * annualGrowthRate + monthlySavings * 12;
} else {
// During retirement: grow previous balance - withdraw allowance
newBalance =
previousYearData.balance * annualGrowthRate - inflatedAllowance * 12;
}
yearlyData.push({
age: currentAge,
year: year,
balance: newBalance,
phase: phase,
monthlyAllowance: inflatedAllowance,
});
}
// Calculate FIRE number at retirement
const retirementYear = irlYear + (retirementAge - age);
const retirementIndex = yearlyData.findIndex(
(data) => data.year === retirementYear,
);
const retirementData = yearlyData[retirementIndex];
if (retirementIndex === -1 || !retirementData) {
setResult({
fireNumber: null,
error: "Could not calculate retirement data",
yearlyData: yearlyData,
});
} else {
// Set the result
setResult({
fireNumber: retirementData.balance,
yearlyData: yearlyData,
});
}
} }
// Helper function to format currency without specific symbols // Helper function to format currency without specific symbols
@ -274,6 +248,23 @@ export default function FireCalculatorForm() {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="lifeExpectancy"
render={({ field }) => (
<FormItem>
<FormLabel>Life Expectancy (Age)</FormLabel>
<FormControl>
<Input
placeholder="e.g., 90"
type="number"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="cagr" name="cagr"
@ -292,6 +283,24 @@ export default function FireCalculatorForm() {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="inflationRate"
render={({ field }) => (
<FormItem>
<FormLabel>Annual Inflation Rate (%)</FormLabel>
<FormControl>
<Input
placeholder="e.g., 2"
type="number"
step="0.1"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="desiredMonthlyAllowance" name="desiredMonthlyAllowance"
@ -311,73 +320,187 @@ export default function FireCalculatorForm() {
</FormItem> </FormItem>
)} )}
/> />
{/* Retirement Age Slider */}
<FormField <FormField
control={form.control} control={form.control}
name="inflationRate" name="retirementAge"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Annual Inflation Rate (%)</FormLabel> <FormLabel>Retirement Age: {field.value}</FormLabel>
<FormControl> <FormControl>
<Input <Slider
placeholder="e.g., 2" value={[field.value]}
type="number" min={form.getValues().currentAge + 1}
step="0.1" max={form.getValues().lifeExpectancy - 1}
{...field} step={1}
onValueChange={field.onChange}
className="py-4"
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormItem className="col-span-full"></FormItem>
control={form.control}
name="lifeExpectancy"
render={({ field }) => (
<FormItem>
<FormLabel>Life Expectancy (Age)</FormLabel>
<FormControl>
<Input
placeholder="e.g., 90"
type="number"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="retirementStrategy"
render={({ field }) => (
<FormItem>
<FormLabel>Retirement Strategy</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a retirement strategy" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Depletion">Depletion</SelectItem>
<SelectItem value="Maintenance">
Maintenance
</SelectItem>
<SelectItem value="4% Rule">4% Rule</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div> </div>
<Button type="submit" className="w-full"> <Button type="submit" className="w-full">
Calculate Calculate
</Button> </Button>
{result?.yearlyData && (
<Card className="rounded-md shadow-none">
<CardHeader>
<CardTitle>Financial Projection</CardTitle>
<CardDescription>
Projected balance growth with your selected retirement age
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer
className="aspect-auto h-80 w-full"
config={{
balance: {
label: "Balance",
color: "var(--chart-1)",
},
realBalance: {
label: "Real Balance",
color: "var(--chart-3)",
},
}}
>
<AreaChart
data={result.yearlyData}
margin={{ top: 20, right: 30, left: 20, bottom: 20 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="year"
label={{
value: "Year",
position: "insideBottom",
offset: -10,
}}
/>
<YAxis
tickFormatter={(value: number) => {
if (value >= 1000000) {
return `${(value / 1000000).toPrecision(3)}M`;
} else if (value >= 1000) {
return `${(value / 1000).toPrecision(3)}K`;
} else if (value <= -1000000) {
return `${(value / 1000000).toPrecision(3)}M`;
} else if (value <= -1000) {
return `${(value / 1000).toPrecision(3)}K`;
}
return value.toString();
}}
width={25}
/>
<ChartTooltip
content={({ active, payload }) => {
if (active && payload?.[0]?.payload) {
const data = payload[0]
.payload as (typeof result.yearlyData)[0];
return (
<div className="bg-background border p-2 shadow-sm">
<p className="font-medium">{`Year: ${data.year.toString()} (Age: ${data.age.toString()})`}</p>
<p className="text-chart-1">{`Balance: ${formatNumber(data.balance)}`}</p>
<p className="text-chart-2">{`Monthly allowance: ${formatNumber(data.monthlyAllowance)}`}</p>
<p>{`Phase: ${data.phase === "accumulation" ? "Accumulation" : "Retirement"}`}</p>
</div>
);
}
return null;
}}
/>
<defs>
<linearGradient
id="fillBalance"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor="var(--chart-1)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--chart-1)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient
id="fillAllowance"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor="var(--chart-2)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--chart-2)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="balance"
name="balance"
stroke="var(--chart-1)"
fill="url(#fillBalance)"
fillOpacity={0.4}
activeDot={{ r: 6 }}
/>
<Area
type="monotone"
dataKey="monthlyAllowance"
name="allowance"
stroke="var(--chart-2)"
fill="url(#fillAllowance)"
fillOpacity={0.4}
activeDot={{ r: 6 }}
/>
{result.fireNumber && (
<ReferenceLine
y={result.fireNumber}
stroke="var(--chart-3)"
strokeWidth={1}
strokeDasharray="2 2"
label={{
value: "FIRE Number",
position: "insideBottomRight",
}}
/>
)}
<ReferenceLine
x={
irlYear +
(form.getValues().retirementAge -
form.getValues().currentAge)
}
stroke="var(--chart-2)"
strokeWidth={2}
label={{
value: "Retirement",
position: "insideTopRight",
}}
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
)}
</form> </form>
</Form> </Form>
</CardContent> </CardContent>
@ -397,8 +520,7 @@ export default function FireCalculatorForm() {
<CardHeader> <CardHeader>
<CardTitle>FIRE Number</CardTitle> <CardTitle>FIRE Number</CardTitle>
<CardDescription className="text-xs"> <CardDescription className="text-xs">
Required capital at retirement using{" "} Capital at retirement
{form.getValues().retirementStrategy}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -410,233 +532,22 @@ export default function FireCalculatorForm() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Retirement Age</CardTitle> <CardTitle>Retirement Duration</CardTitle>
<CardDescription className="text-xs"> <CardDescription className="text-xs">
Estimated age when you can retire Years to enjoy your financial independence
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-3xl font-bold"> <p className="text-3xl font-bold">
{result.retirementAge ?? "N/A"} {form.getValues().lifeExpectancy -
form.getValues().retirementAge}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
{result.inflationAdjustedAllowance && (
<Card>
<CardHeader>
<CardTitle>Monthly Allowance</CardTitle>
<CardDescription className="text-xs">
At retirement (inflation adjusted)
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">
{formatNumber(result.inflationAdjustedAllowance)}
</p>
</CardContent>
</Card>
)}
{result.retirementYears && (
<Card>
<CardHeader>
<CardTitle>Retirement Duration</CardTitle>
<CardDescription className="text-xs">
Years in retirement
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">
{result.retirementYears}
</p>
</CardContent>
</Card>
)}
</> </>
)} )}
</div> </div>
)} )}
{result?.yearlyData && (
<Card>
<CardHeader>
<CardTitle>Financial Projection</CardTitle>
<CardDescription>
Projected balance growth and FIRE number threshold
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer
className="aspect-auto h-80 w-full"
config={{
balance: {
label: "Balance",
color: "var(--chart-1)",
},
fireNumber: {
label: "FIRE Number",
color: "var(--chart-2)",
},
realBalance: {
label: "Real Balance",
color: "var(--chart-3)",
},
}}
>
<AreaChart
data={result.yearlyData}
margin={{ top: 20, right: 30, left: 20, bottom: 20 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="year"
label={{
value: "Year",
position: "insideBottom",
offset: -10,
}}
/>
<YAxis
tickFormatter={(value: number) => {
if (value >= 1000000) {
return `${(value / 1000000).toPrecision(3)}M`;
} else if (value >= 1000) {
return `${(value / 1000).toPrecision(3)}K`;
}
return value.toString();
}}
width={25}
/>
<ChartTooltip
content={({ active, payload }) => {
if (active && payload?.[0]?.payload) {
const data = payload[0]
.payload as (typeof result.yearlyData)[0];
return (
<div className="bg-background border p-2 shadow-sm">
<p className="font-medium">{`Year: ${data.year.toString()} (Age: ${data.age.toString()})`}</p>
<p className="text-chart-1">{`Balance: ${formatNumber(data.balance)}`}</p>
<p className="text-chart-2">{`FIRE number: ${formatNumber(data.fireNumber)}`}</p>
<p className="text-chart-4">{`Monthly allowance: ${formatNumber(data.monthlyAllowance)}`}</p>
{result.fireNumber !== null && (
<p className="text-destructive">{`Target FIRE Number: ${formatNumber(result.fireNumber)}`}</p>
)}
<p>{`Phase: ${data.phase === "accumulation" ? "Accumulation" : "Retirement"}`}</p>
</div>
);
}
return null;
}}
/>
<defs>
<linearGradient id="fillBalance" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--chart-1)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--chart-1)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient
id="fillFireNumber"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor="var(--chart-2)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--chart-2)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient
id="fillAllowance"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor="var(--chart-4)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--chart-4)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="balance"
name="balance"
stroke="var(--chart-1)"
fill="url(#fillBalance)"
fillOpacity={0.4}
activeDot={{ r: 6 }}
/>
<Area
type="monotone"
dataKey="fireNumber"
name="fireNumber"
stroke="var(--chart-2)"
fill="url(#fillFireNumber)"
fillOpacity={0.4}
activeDot={{ r: 6 }}
/>
<Area
type="monotone"
dataKey="monthlyAllowance"
name="allowance"
stroke="var(--chart-4)"
fill="url(#fillAllowance)"
fillOpacity={0.4}
activeDot={{ r: 6 }}
/>
{result.fireNumber && (
<ReferenceLine
y={result.fireNumber}
stroke="var(--chart-3)"
strokeWidth={2}
strokeDasharray="5 5"
label={{
value: "FIRE Number",
position: "insideBottomRight",
}}
/>
)}
{result.retirementAge && (
<ReferenceLine
x={
irlYear +
(result.retirementAge - form.getValues().currentAge)
}
stroke="var(--chart-2)"
strokeWidth={2}
label={{
value: "Retirement",
position: "insideTopRight",
}}
/>
)}
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
)}
</> </>
); );
} }