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,159 +320,40 @@ 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}
/>
</FormControl>
<FormMessage />
</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
control={form.control}
name="retirementStrategy"
render={({ field }) => (
<FormItem>
<FormLabel>Retirement Strategy</FormLabel>
<Select
onValueChange={field.onChange} onValueChange={field.onChange}
defaultValue={field.value} className="py-4"
> />
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a retirement strategy" />
</SelectTrigger>
</FormControl> </FormControl>
<SelectContent>
<SelectItem value="Depletion">Depletion</SelectItem>
<SelectItem value="Maintenance">
Maintenance
</SelectItem>
<SelectItem value="4% Rule">4% Rule</SelectItem>
</SelectContent>
</Select>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormItem className="col-span-full"></FormItem>
</div> </div>
<Button type="submit" className="w-full"> <Button type="submit" className="w-full">
Calculate Calculate
</Button> </Button>
</form>
</Form>
</CardContent>
</Card>
{result && (
<div className="mb-4 grid grid-cols-1 gap-2 md:grid-cols-2">
{result.error ? (
<Card className="col-span-full">
<CardContent className="pt-6">
<p className="text-destructive">{result.error}</p>
</CardContent>
</Card>
) : (
<>
<Card>
<CardHeader>
<CardTitle>FIRE Number</CardTitle>
<CardDescription className="text-xs">
Required capital at retirement using{" "}
{form.getValues().retirementStrategy}
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">
{formatNumber(result.fireNumber)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Retirement Age</CardTitle>
<CardDescription className="text-xs">
Estimated age when you can retire
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">
{result.retirementAge ?? "N/A"}
</p>
</CardContent>
</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>
)}
{result?.yearlyData && ( {result?.yearlyData && (
<Card> <Card className="rounded-md shadow-none">
<CardHeader> <CardHeader>
<CardTitle>Financial Projection</CardTitle> <CardTitle>Financial Projection</CardTitle>
<CardDescription> <CardDescription>
Projected balance growth and FIRE number threshold Projected balance growth with your selected retirement age
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -474,10 +364,6 @@ export default function FireCalculatorForm() {
label: "Balance", label: "Balance",
color: "var(--chart-1)", color: "var(--chart-1)",
}, },
fireNumber: {
label: "FIRE Number",
color: "var(--chart-2)",
},
realBalance: { realBalance: {
label: "Real Balance", label: "Real Balance",
color: "var(--chart-3)", color: "var(--chart-3)",
@ -503,6 +389,10 @@ export default function FireCalculatorForm() {
return `${(value / 1000000).toPrecision(3)}M`; return `${(value / 1000000).toPrecision(3)}M`;
} else if (value >= 1000) { } else if (value >= 1000) {
return `${(value / 1000).toPrecision(3)}K`; 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(); return value.toString();
}} }}
@ -517,11 +407,7 @@ export default function FireCalculatorForm() {
<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>
<p className="text-chart-1">{`Balance: ${formatNumber(data.balance)}`}</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-2">{`Monthly allowance: ${formatNumber(data.monthlyAllowance)}`}</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> <p>{`Phase: ${data.phase === "accumulation" ? "Accumulation" : "Retirement"}`}</p>
</div> </div>
); );
@ -530,20 +416,8 @@ export default function FireCalculatorForm() {
}} }}
/> />
<defs> <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 <linearGradient
id="fillFireNumber" id="fillBalance"
x1="0" x1="0"
y1="0" y1="0"
x2="0" x2="0"
@ -551,12 +425,12 @@ export default function FireCalculatorForm() {
> >
<stop <stop
offset="5%" offset="5%"
stopColor="var(--chart-2)" stopColor="var(--chart-1)"
stopOpacity={0.8} stopOpacity={0.8}
/> />
<stop <stop
offset="95%" offset="95%"
stopColor="var(--chart-2)" stopColor="var(--chart-1)"
stopOpacity={0.1} stopOpacity={0.1}
/> />
</linearGradient> </linearGradient>
@ -569,12 +443,12 @@ export default function FireCalculatorForm() {
> >
<stop <stop
offset="5%" offset="5%"
stopColor="var(--chart-4)" stopColor="var(--chart-2)"
stopOpacity={0.8} stopOpacity={0.8}
/> />
<stop <stop
offset="95%" offset="95%"
stopColor="var(--chart-4)" stopColor="var(--chart-2)"
stopOpacity={0.1} stopOpacity={0.1}
/> />
</linearGradient> </linearGradient>
@ -588,20 +462,11 @@ export default function FireCalculatorForm() {
fillOpacity={0.4} fillOpacity={0.4}
activeDot={{ r: 6 }} activeDot={{ r: 6 }}
/> />
<Area
type="monotone"
dataKey="fireNumber"
name="fireNumber"
stroke="var(--chart-2)"
fill="url(#fillFireNumber)"
fillOpacity={0.4}
activeDot={{ r: 6 }}
/>
<Area <Area
type="monotone" type="monotone"
dataKey="monthlyAllowance" dataKey="monthlyAllowance"
name="allowance" name="allowance"
stroke="var(--chart-4)" stroke="var(--chart-2)"
fill="url(#fillAllowance)" fill="url(#fillAllowance)"
fillOpacity={0.4} fillOpacity={0.4}
activeDot={{ r: 6 }} activeDot={{ r: 6 }}
@ -610,19 +475,19 @@ export default function FireCalculatorForm() {
<ReferenceLine <ReferenceLine
y={result.fireNumber} y={result.fireNumber}
stroke="var(--chart-3)" stroke="var(--chart-3)"
strokeWidth={2} strokeWidth={1}
strokeDasharray="5 5" strokeDasharray="2 2"
label={{ label={{
value: "FIRE Number", value: "FIRE Number",
position: "insideBottomRight", position: "insideBottomRight",
}} }}
/> />
)} )}
{result.retirementAge && (
<ReferenceLine <ReferenceLine
x={ x={
irlYear + irlYear +
(result.retirementAge - form.getValues().currentAge) (form.getValues().retirementAge -
form.getValues().currentAge)
} }
stroke="var(--chart-2)" stroke="var(--chart-2)"
strokeWidth={2} strokeWidth={2}
@ -631,12 +496,58 @@ export default function FireCalculatorForm() {
position: "insideTopRight", position: "insideTopRight",
}} }}
/> />
)}
</AreaChart> </AreaChart>
</ChartContainer> </ChartContainer>
</CardContent> </CardContent>
</Card> </Card>
)} )}
</form>
</Form>
</CardContent>
</Card>
{result && (
<div className="mb-4 grid grid-cols-1 gap-2 md:grid-cols-2">
{result.error ? (
<Card className="col-span-full">
<CardContent className="pt-6">
<p className="text-destructive">{result.error}</p>
</CardContent>
</Card>
) : (
<>
<Card>
<CardHeader>
<CardTitle>FIRE Number</CardTitle>
<CardDescription className="text-xs">
Capital at retirement
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">
{formatNumber(result.fireNumber)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Retirement Duration</CardTitle>
<CardDescription className="text-xs">
Years to enjoy your financial independence
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">
{form.getValues().lifeExpectancy -
form.getValues().retirementAge}
</p>
</CardContent>
</Card>
</>
)}
</div>
)}
</> </>
); );
} }