new strategy human algo

This commit is contained in:
Felix Schulze 2025-04-30 23:17:48 +02:00
parent 5544c2f69f
commit 6a6557c3bf

View File

@ -8,7 +8,6 @@ import * as z from "zod";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { import {
Form, Form,
FormControl, FormControl,
@ -66,23 +65,27 @@ const formSchema = z.object({
// Type for form values // Type for form values
type FormValues = z.infer<typeof formSchema>; type FormValues = z.infer<typeof formSchema>;
interface YearlyData {
age: number;
year: number;
balance: number;
phase: "accumulation" | "retirement";
monthlyAllowance: number;
fireNumber: number | null;
}
interface CalculationResult { interface CalculationResult {
fireNumber: number | null; fireNumber: number | null;
retirementAge: number | null; retirementAge: number | null;
inflationAdjustedAllowance: number | null; inflationAdjustedAllowance: number | null;
retirementYears: number | null; retirementYears: number | null;
error?: string; error?: string;
yearlyData?: Array<{ yearlyData?: Record<string, YearlyData>;
age: number;
year: number;
balance: number;
phase: "accumulation" | "retirement";
}>;
} }
export default function FireCalculatorForm() { export default function FireCalculatorForm() {
const [result, setResult] = useState<CalculationResult | null>(null); const [result, setResult] = useState<CalculationResult | null>(null);
const currentYear = new Date().getFullYear(); const irlYear = new Date().getFullYear();
// Initialize form with default values // Initialize form with default values
const form = useForm<FormValues>({ const form = useForm<FormValues>({
@ -100,263 +103,103 @@ export default function FireCalculatorForm() {
}); });
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;
const monthlySavings = values.monthlySavings; const monthlySavings = values.monthlySavings;
const currentAge = values.currentAge; const age = values.currentAge;
const annualGrowthRate = values.cagr / 100; const annualGrowthRate = 1 + values.cagr / 100;
const initialMonthlyAllowance = values.desiredMonthlyAllowance; const initialMonthlyAllowance = values.desiredMonthlyAllowance;
const annualInflation = values.inflationRate / 100; const annualInflation = 1 + values.inflationRate / 100;
const lifeExpectancy = values.lifeExpectancy; const ageOfDeath = values.lifeExpectancy;
const retirementStrategy = values.retirementStrategy; const retirementStrategy = values.retirementStrategy;
const monthlyGrowthRate = Math.pow(1 + annualGrowthRate, 1 / 12) - 1;
const monthlyInflationRate = Math.pow(1 + annualInflation, 1 / 12) - 1;
const maxIterations = 100; // Adjusted max iterations for age limit
let requiredCapital: number | null = null; let requiredCapital: number | null = null;
let retirementAge: number | null = null; let retirementAge: number | null = null;
let finalInflationAdjustedAllowance: number | null = null; let finalInflationAdjustedAllowance: number | null = null;
let canRetire = false;
let errorMessage: string | undefined = undefined;
// Array to store yearly data for the chart // Array to store yearly data for the chart with initial value
const yearlyData: CalculationResult["yearlyData"] = []; const yearlyData: Record<number, YearlyData> = {
yearlyData.push({ [irlYear]: {
age: currentAge, age: age,
year: currentYear, year: irlYear,
balance: startingCapital, balance: startingCapital,
phase: "accumulation", phase: "accumulation",
}); 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;
}
let currentCapital = startingCapital; // calculate new balance and retirement age
let age = currentAge; for (let year = irlYear; year <= irlYear + ageOfDeath - age; year++) {
let monthlyAllowance = initialMonthlyAllowance; 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 --- // --- Calculation Logic based on Strategy ---
if (retirementStrategy === "4% Rule") {
// --- 4% Rule Calculation ---
requiredCapital = (initialMonthlyAllowance * 12) / 0.04;
// Simulate accumulation until the 4% rule target is met
while (age < lifeExpectancy) {
if (currentCapital >= requiredCapital) {
canRetire = true;
retirementAge = age;
finalInflationAdjustedAllowance = monthlyAllowance;
break; // Found retirement age
}
// Simulate one year of saving and growth
for (let month = 0; month < 12; month++) {
currentCapital += monthlySavings;
currentCapital *= 1 + monthlyGrowthRate;
monthlyAllowance *= 1 + monthlyInflationRate; // Keep track of inflation-adjusted allowance
}
age++;
yearlyData.push({
age: age,
year: currentYear + (age - currentAge),
balance: Math.round(currentCapital),
phase: "accumulation",
});
if (age >= lifeExpectancy) break; // Stop if life expectancy is reached
}
if (!canRetire) {
errorMessage =
"Cannot reach FIRE goal (4% Rule) before life expectancy.";
requiredCapital = null; // Cannot retire, so no specific FIRE number applies this way
} else if (retirementAge !== null) {
// Simulate retirement phase for chart data (using 4% withdrawal adjusted for inflation)
let simulationCapital = currentCapital;
let simulationAge = retirementAge;
// Mark retirement phase in existing data
yearlyData.forEach((data) => {
if (data.age >= retirementAge!) {
data.phase = "retirement";
}
});
while (simulationAge < lifeExpectancy) {
let yearlyWithdrawal = requiredCapital * 0.04; // Initial 4%
// Adjust for inflation annually from retirement start
yearlyWithdrawal *= Math.pow(
1 + annualInflation,
simulationAge - retirementAge,
);
const monthlyWithdrawal = yearlyWithdrawal / 12;
for (let month = 0; month < 12; month++) {
simulationCapital -=
monthlyWithdrawal * Math.pow(1 + monthlyInflationRate, month); // Approximate intra-year inflation on withdrawal
simulationCapital *= 1 + monthlyGrowthRate;
}
simulationAge++;
yearlyData.push({
age: simulationAge,
year: currentYear + (simulationAge - currentAge),
balance: Math.round(simulationCapital),
phase: "retirement",
});
}
}
} else {
// --- Depletion and Maintenance Calculation (Simulation-based) ---
let iterations = 0;
while (age < lifeExpectancy && iterations < maxIterations) {
// Simulate one year of saving and growth
for (let month = 0; month < 12; month++) {
currentCapital += monthlySavings;
currentCapital *= 1 + monthlyGrowthRate;
monthlyAllowance *= 1 + monthlyInflationRate;
}
age++;
iterations++;
yearlyData.push({
age: age,
year: currentYear + (age - currentAge),
balance: Math.round(currentCapital),
phase: "accumulation",
});
// --- Check if retirement is possible at this age ---
let testCapital = currentCapital;
let testAge = age;
let testAllowance = monthlyAllowance;
let isSufficient = true;
// Simulate retirement phase to check sufficiency
while (testAge < lifeExpectancy) {
const yearlyStartCapital = testCapital;
for (let month = 0; month < 12; month++) {
const withdrawal = testAllowance;
testCapital -= withdrawal;
const growth = testCapital * monthlyGrowthRate;
testCapital += growth; // Apply growth *after* withdrawal for the month
testAllowance *= 1 + monthlyInflationRate; // Inflate allowance for next month
}
testAge++;
if (testCapital <= 0) {
// Depleted capital before life expectancy
isSufficient = false;
break;
}
if (retirementStrategy === "Maintenance") {
// Maintenance check: Withdrawal should not exceed growth for the year
// Use average capital for a slightly more stable check? Or end-of-year growth vs start-of-year withdrawal?
// Let's check if end-of-year capital is less than start-of-year capital
if (testCapital < yearlyStartCapital) {
isSufficient = false;
break; // Capital decreased, maintenance failed
}
// Alternative check: yearlyWithdrawal > yearlyGrowth
// if (yearlyWithdrawal > yearlyGrowth) {
// isSufficient = false;
// break; // Withdrawals exceed growth, maintenance failed
// }
}
} // End retirement simulation check
if (isSufficient) {
canRetire = true;
retirementAge = age;
requiredCapital = currentCapital; // The capital needed at this point
finalInflationAdjustedAllowance = monthlyAllowance; // Allowance level at retirement
break; // Found retirement age
}
} // End accumulation simulation loop
if (!canRetire) {
errorMessage = `Cannot reach FIRE goal (${retirementStrategy}) before life expectancy or within ${maxIterations} years.`;
requiredCapital = null;
} else if (retirementAge !== null) {
// Simulate the actual retirement phase for chart data if retirement is possible
let simulationCapital = requiredCapital!;
let simulationAge = retirementAge;
let simulationAllowance = finalInflationAdjustedAllowance!;
// Mark retirement phase in existing data
yearlyData.forEach((data) => {
if (data.age >= retirementAge!) {
data.phase = "retirement";
}
});
// Simulate remaining years until life expectancy
while (simulationAge < lifeExpectancy) {
for (let month = 0; month < 12; month++) {
simulationCapital -= simulationAllowance;
simulationCapital *= 1 + monthlyGrowthRate;
simulationAllowance *= 1 + monthlyInflationRate;
}
simulationAge++;
// Ensure capital doesn't go below zero for chart visibility in Depletion
const displayBalance =
retirementStrategy === "Depletion"
? Math.max(0, simulationCapital)
: simulationCapital;
yearlyData.push({
age: simulationAge,
year: currentYear + (simulationAge - currentAge),
balance: Math.round(displayBalance),
phase: "retirement",
});
}
}
} // End Depletion/Maintenance logic
// --- Set Final Result --- // --- Set Final Result ---
if ( setResult({
canRetire && fireNumber: requiredCapital,
retirementAge !== null && retirementAge: retirementAge,
requiredCapital !== null && inflationAdjustedAllowance: finalInflationAdjustedAllowance,
finalInflationAdjustedAllowance !== null retirementYears: ageOfDeath - retirementAge,
) { yearlyData: Object.values(yearlyData),
// Ensure yearlyData covers up to lifeExpectancy if retirement happens early error: undefined,
const lastDataYear = });
yearlyData[yearlyData.length - 1]?.year ?? currentYear;
const expectedEndYear = currentYear + (lifeExpectancy - currentAge);
if (lastDataYear < expectedEndYear) {
// Need to continue simulation purely for charting if the main calc stopped early
// (This might already be covered by the post-retirement simulation loops added above)
console.warn(
"Chart data might not extend fully to life expectancy in some scenarios.",
);
}
setResult({
fireNumber: requiredCapital,
retirementAge: retirementAge,
inflationAdjustedAllowance: finalInflationAdjustedAllowance,
retirementYears: lifeExpectancy - retirementAge,
yearlyData: yearlyData,
error: undefined,
});
} else {
setResult({
fireNumber: null,
retirementAge: null,
inflationAdjustedAllowance: null,
retirementYears: null,
yearlyData: yearlyData, // Show accumulation data even if goal not reached
error:
errorMessage ?? "Calculation failed to find a retirement scenario.",
});
}
} }
// Helper function to format currency without specific symbols // Helper function to format currency without specific symbols
@ -615,7 +458,7 @@ export default function FireCalculatorForm() {
</div> </div>
)} )}
{result?.yearlyData && result.yearlyData.length > 0 && ( {result?.yearlyData && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Financial Projection</CardTitle> <CardTitle>Financial Projection</CardTitle>
@ -633,6 +476,10 @@ export default function FireCalculatorForm() {
}, },
fireNumber: { fireNumber: {
label: "FIRE Number", label: "FIRE Number",
color: "var(--chart-2)",
},
realBalance: {
label: "Real Balance",
color: "var(--chart-3)", color: "var(--chart-3)",
}, },
}} }}
@ -669,7 +516,9 @@ export default function FireCalculatorForm() {
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>
<p className="text-primary">{`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-4">{`Monthly allowance: ${formatNumber(data.monthlyAllowance)}`}</p>
{result.fireNumber !== null && ( {result.fireNumber !== null && (
<p className="text-destructive">{`Target FIRE Number: ${formatNumber(result.fireNumber)}`}</p> <p className="text-destructive">{`Target FIRE Number: ${formatNumber(result.fireNumber)}`}</p>
)} )}
@ -693,6 +542,42 @@ export default function FireCalculatorForm() {
stopOpacity={0.1} stopOpacity={0.1}
/> />
</linearGradient> </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> </defs>
<Area <Area
type="monotone" type="monotone"
@ -703,6 +588,24 @@ 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
type="monotone"
dataKey="monthlyAllowance"
name="allowance"
stroke="var(--chart-4)"
fill="url(#fillAllowance)"
fillOpacity={0.4}
activeDot={{ r: 6 }}
/>
{result.fireNumber && ( {result.fireNumber && (
<ReferenceLine <ReferenceLine
y={result.fireNumber} y={result.fireNumber}
@ -718,7 +621,7 @@ export default function FireCalculatorForm() {
{result.retirementAge && ( {result.retirementAge && (
<ReferenceLine <ReferenceLine
x={ x={
currentYear + irlYear +
(result.retirementAge - form.getValues().currentAge) (result.retirementAge - form.getValues().currentAge)
} }
stroke="var(--chart-2)" stroke="var(--chart-2)"