Money forecast: calm optimistic start, rising plateau, thin solid projected lines
Deploy production / deploy (push) Successful in 1m13s

- Optimistic: no crash or correction through 2031 (5 years from today), earning the
  9.5% average; cycle resumes after (correction 2035, crash 2040). Salary plateau
  grows 2%/yr real so pay never stalls.
- Realistic keeps its single -30% crash in 2029; pessimistic unchanged.
- Projected lines are now thin, slightly lighter solid lines in every view; legend updated.
- Explanation text lists the crash/correction years and recovery time.
This commit is contained in:
stan committed 2026-10-26 08:25:52 +00:00
1 parent d3b9bb9ba2
commit 78b69c7d8f
6 files changed
+135 -38

No files matched your search

+32 -7
View File
@@ -39,6 +39,11 @@ export type ScenarioParams = {
firstCrashYear: number;
/** Smaller correction halfway between crashes, percent (0 = none). */
correctionPercent: number;
/**
* Calm start: no crash or correction from this year through this many years ahead; those
* years earn the plain average return (0 = the crash cycle applies from the start).
*/
calmYears: number;
/** Interest on the cash buffer, percent per year. */
cashRatePercent: number;
/** Inflation: prices, the salary cap, pension and benefits grow with it; percent. */
@@ -62,6 +67,8 @@ export type ScenarioParams = {
hopGapMonths: number;
/** Career plateau: base salary cap per year in today's euros (excl. holiday pay). */
salaryCapEuros: number;
/** Real growth of that plateau per year, percent (0 = flat once reached). */
salaryCapGrowthPercent: number;
/** Share of the contract's bonus estimate actually paid each August, 0–100. */
bonusPercentOfTarget: number;
/** Side income, gross today's euros per month, ramping over five years to the end value. */
@@ -97,6 +104,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
crashEveryYears: 8,
firstCrashYear: 2027,
correctionPercent: -8,
calmYears: 0,
cashRatePercent: 0,
inflationPercent: 2.5,
gapMonths: 3,
@@ -110,6 +118,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
hopRaisePercent: 15,
hopGapMonths: 3,
salaryCapEuros: 75000,
salaryCapGrowthPercent: 0,
bonusPercentOfTarget: 0,
sideStartEuros: 100,
sideEndEuros: 100,
@@ -132,6 +141,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
crashEveryYears: 10,
firstCrashYear: 2029,
correctionPercent: -10,
calmYears: 0,
cashRatePercent: 1.5,
inflationPercent: 2.5,
gapMonths: 0,
@@ -145,6 +155,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
hopRaisePercent: 20,
hopGapMonths: 0,
salaryCapEuros: 95000,
salaryCapGrowthPercent: 0,
bonusPercentOfTarget: 100,
sideStartEuros: 250,
sideEndEuros: 500,
@@ -167,6 +178,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
crashEveryYears: 10,
firstCrashYear: 2030,
correctionPercent: -15,
calmYears: 5,
cashRatePercent: 1.5,
inflationPercent: 2.5,
gapMonths: 0,
@@ -180,6 +192,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
hopRaisePercent: 22,
hopGapMonths: 0,
salaryCapEuros: 150000,
salaryCapGrowthPercent: 2,
bonusPercentOfTarget: 100,
sideStartEuros: 300,
sideEndEuros: 2500,
@@ -396,9 +409,11 @@ function monthEnd(first: string) {
/**
* Years of a crash cycle: a crash in the first year, a correction halfway, and normal years whose
* return makes the whole cycle average out at the expected return.
* return makes the whole cycle average out at the expected return. With `calmYears`, the years
* from `startYear` through `startYear + calmYears` have no crash or correction and earn the plain
* average return; the cycle keeps its phase after that.
*/
export function marketReturns(params: ScenarioParams) {
export function marketReturns(params: ScenarioParams, startYear?: number) {
const pct = (value: number) => value / 100;
const every = Math.max(2, Math.round(params.crashEveryYears));
const crashes = params.crashPercent !== 0;
@@ -410,9 +425,16 @@ export function marketReturns(params: ScenarioParams) {
const normal = crashes
? Math.pow(Math.pow(1 + pct(params.returnPercent), every) / shocks, 1 / normalYears) - 1
: pct(params.returnPercent);
const calmUntil =
startYear !== undefined && params.calmYears > 0
? startYear + Math.round(params.calmYears)
: null;
return {
normal,
calmUntil,
yearReturn(year: number) {
if (calmUntil !== null && year <= calmUntil)
return { kind: 'calm' as const, rate: pct(params.returnPercent) };
if (!crashes || year < params.firstCrashYear)
return { kind: 'normal' as const, rate: normal };
const offset = (year - params.firstCrashYear) % every;
@@ -444,7 +466,7 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
const gapStart = addMonths(facts.contractEnd, 1);
const gapEnd = params.gapMonths > 0 ? addMonths(gapStart, params.gapMonths) : gapStart;
const retireStart = `${facts.birthYear + Math.round(params.retireAge)}-01-01`;
const markets = marketReturns(params);
const markets = marketReturns(params, Number(inputs.today.slice(0, 4)));
const monthlyInflation = Math.pow(1 + pct(params.inflationPercent), 1 / 12);
const isHopYear = (year: number) =>
params.hopEveryYears > 0 &&
@@ -589,7 +611,10 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
!(hopStart && month === 10)
)
gross *= 1 + pct(params.raisePercent);
const cap = ((params.salaryCapEuros * 100) / 12) * deflator;
const cap =
((params.salaryCapEuros * 100) / 12) *
deflator *
Math.pow(1 + pct(params.salaryCapGrowthPercent), Math.max(0, index) / 12);
if (hopStart && first === hopStart && params.hopGapMonths > 0 && !retired)
row.events.push(`Between jobs (${params.hopGapMonths} mo)`);
if (hopEnd && first === hopEnd && !retired) {
@@ -727,7 +752,7 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
// compounding gives the same monthly return.
const market = markets.yearReturn(year);
const monthRate =
market.kind === 'normal'
market.kind === 'normal' || market.kind === 'calm'
? Math.pow(1 + market.rate, 1 / 12) - 1
: month >= 3 && month <= 10
? Math.pow(1 + market.rate, 1 / 8) - 1
@@ -848,7 +873,7 @@ export function scenarioProjection(result: ScenarioResult, todaysEuros = false):
];
}
/** One dashed total line per scenario, for comparing them on the wealth chart. */
/** One projected total line per scenario, for comparing them on the wealth chart. */
export function scenarioCompareLines(
results: Record<ScenarioId, ScenarioResult>,
params: Record<ScenarioId, ScenarioParams>,
@@ -858,7 +883,7 @@ export function scenarioCompareLines(
return scenarioIds.map((id) => ({
label: params[id].label,
color: scenarioColors[id],
dashedFrom: today,
projectedFrom: today,
points: results[id].points
.filter((point) => point.date > today)
.map((point) => ({