money scenarios: forecast horizon 1–100y (?horizon=), today’s-euro view, scaling gridlines, career paths with job hops and plateaus, crash cycles per portfolio, retirement at 68 with withdrawals, salary path table
All checks were successful
Deploy production / deploy (push) Successful in 2m49s

This commit is contained in:
2026-10-24 15:13:20 +00:00
parent c4689adda4
commit 61ae72e50b
8 changed files with 642 additions and 129 deletions

View File

@@ -1,5 +1,12 @@
import { describe, expect, it } from 'vitest';
import { defaultScenarios, runScenario, scenarioInputs, type ScenarioParams } from './scenarios';
import {
defaultScenarios,
marketReturns,
roleFor,
runScenario,
scenarioInputs,
type ScenarioParams
} from './scenarios';
const accounts = [
{ id: 1, name: 'Revolut', kind: 'checking' },
@@ -55,8 +62,11 @@ describe('runScenario', () => {
const flat: ScenarioParams = {
...defaultScenarios.realistic,
returnPercent: 0,
recoveryPercent: 0,
crashPercent: 0,
correctionPercent: 0,
cashRatePercent: 0,
inflationPercent: 0,
hopEveryYears: 0,
sideStartEuros: 0,
sideEndEuros: 0,
spendGrowthPercent: 0,
@@ -97,17 +107,60 @@ describe('runScenario', () => {
expect(pessimistic).toBeLessThan(realistic);
expect(realistic).toBeLessThan(optimistic);
});
it('applies a crash in its year and a job gap after the contract', () => {
const calm = runScenario(inputs, { ...flat, returnPercent: 5, recoveryPercent: 5 });
it('applies crashes and job gaps', () => {
const calm = runScenario(inputs, { ...flat, returnPercent: 5 });
const crash = runScenario(inputs, {
...flat,
returnPercent: 5,
recoveryPercent: 5,
crashPercent: -30,
crashYear: 2027
crashEveryYears: 10,
firstCrashYear: 2027
});
expect(crash.end.total).toBeLessThan(calm.end.total);
// The crash year itself ends lower; later years catch up to the same long-run average.
const at2027 = (result: typeof calm) => result.years.find((row) => row.year === 2027)!.total;
expect(at2027(crash)).toBeLessThan(at2027(calm));
const gap = runScenario(inputs, { ...flat, gapMonths: 3, gapNetEuros: 1000 });
expect(gap.end.total).toBeLessThan(runScenario(inputs, flat).end.total);
});
it('averages the crash cycle out at the expected return', () => {
const markets = marketReturns({
...flat,
returnPercent: 7,
crashPercent: -30,
crashEveryYears: 10,
firstCrashYear: 2029,
correctionPercent: -10
});
let growth = 1;
for (let year = 2029; year < 2039; year++) growth *= 1 + markets.yearReturn(year).rate;
expect(growth).toBeCloseTo(Math.pow(1.07, 10), 6);
expect(markets.yearReturn(2029).kind).toBe('crash');
expect(markets.yearReturn(2034).kind).toBe('correction');
expect(markets.yearReturn(2028).kind).toBe('normal');
});
it('hops jobs, caps the salary and retires at the chosen age', () => {
const long = { ...inputs, endDate: '2126-10-24' };
const result = runScenario(long, {
...flat,
firstHopYear: 2029,
hopEveryYears: 3,
hopRaisePercent: 20,
hopGapMonths: 4,
salaryCapEuros: 60000
});
expect(result.points).toHaveLength(1201);
const byYear = new Map(result.years.map((row) => [row.year, row]));
expect(byYear.get(2029)!.events).toEqual(['Between jobs (4 mo)']);
expect(byYear.get(2030)!.events).toEqual(['New job']);
// The gap runs into 2030, when the raise lands, then the plateau caps it.
expect(byYear.get(2030)!.grossMonthly).toBeCloseTo(500000, -1);
expect(byYear.get(2071)!.role).toBe('Retired');
expect(byYear.get(2071)!.events).toContain('Retire at 68');
expect(byYear.get(2070)!.age).toBe(67);
});
it('labels roles by base salary in today’s euros', () => {
expect(roleFor(44700)).toBe('Junior');
expect(roleFor(70000)).toBe('Senior');
expect(roleFor(150000)).toBe('Principal / big-tech staff');
});
});