Money forecast: calm optimistic start, rising plateau, thin solid projected lines
All checks were successful
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:
2026-10-26 08:25:52 +00:00
parent d3b9bb9ba2
commit 78b69c7d8f
6 changed files with 135 additions and 38 deletions

View File

@@ -105,14 +105,17 @@
)
.join(' ');
}
/** Solid part through `dashedFrom` and dashed part from the last solid point onward. */
/** Actual part through `projectedFrom` and projected part from the last actual point onward. */
function segments(item: ChartSeries) {
if (!item.dashedFrom) return { solid: item.points, dashed: [] };
const split = item.points.findLastIndex((point) => point.date <= item.dashedFrom!);
if (!item.projectedFrom) return { actual: item.points, projected: [] };
const split = item.points.findLastIndex((point) => point.date <= item.projectedFrom!);
return split < 0
? { solid: [], dashed: item.points }
: { solid: item.points.slice(0, split + 1), dashed: item.points.slice(split) };
? { actual: [], projected: item.points }
: { actual: item.points.slice(0, split + 1), projected: item.points.slice(split) };
}
/** Actual lines are thick and opaque; projected ones a thin, slightly lighter solid line. */
const lineWidth = (item: ChartSeries, projected: boolean) =>
projected ? (item.emphasis ? 1.5 : 1.1) : item.emphasis ? 3 : 1.75;
function pointAt(points: ChartPoint[], date: string) {
if (!points.length || date < points[0].date || date > points.at(-1)!.date) return null;
return points.findLast((point) => point.date <= date) ?? null;
@@ -236,16 +239,17 @@
{/if}
{#each series as item (item.label)}
{@const parts = segments(item)}
{#each [parts.solid, parts.dashed] as points, index (index)}
{#each [parts.actual, parts.projected] as points, index (index)}
{#if points.length > 1}
<path
d={path(points)}
fill="none"
stroke={item.color}
stroke-width={item.emphasis ? 3 : 1.75}
stroke-dasharray={index ? '6 5' : undefined}
stroke-linecap={index ? 'butt' : 'round'}
stroke-width={lineWidth(item, index === 1)}
stroke-opacity={index ? 0.8 : 1}
stroke-linecap="round"
stroke-linejoin="round"
data-line={index ? 'projected' : 'actual'}
/>
{/if}
{/each}
@@ -306,16 +310,16 @@
</div>
{/if}
</div>
{#if series.length > 1 || series.some((item) => item.dashedFrom)}
{#if series.length > 1 || series.some((item) => item.projectedFrom)}
<figcaption class="legend">
{#each series as item (item.label)}
<span class:emphasis={item.emphasis}
><span class="swatch" style={`background:${item.color}`}></span>{item.label}</span
>
{/each}
{#if series.some((item) => item.dashedFrom)}
<span class="line-key first"><span class="key solid"></span>Actual</span>
<span class="line-key"><span class="key dashed"></span>Projected</span>
{#if series.some((item) => item.projectedFrom)}
<span class="line-key first"><span class="key actual"></span>Actual</span>
<span class="line-key"><span class="key projected"></span>Projected</span>
{/if}
</figcaption>
{/if}
@@ -419,10 +423,12 @@
.key {
display: inline-block;
width: 18px;
border-top: 2px solid var(--site-muted);
border-top: 3px solid var(--site-muted);
border-radius: 2px;
}
.key.dashed {
border-top-style: dashed;
.key.projected {
border-top-width: 1.5px;
opacity: 0.8;
}
.swatch {
display: inline-block;

View File

@@ -43,6 +43,7 @@
{ key: 'crashEveryYears', label: 'Crash every', unit: 'yr' },
{ key: 'firstCrashYear', label: 'First crash', unit: '' },
{ key: 'correctionPercent', label: 'Mid-cycle correction', unit: '%' },
{ key: 'calmYears', label: 'Calm start (no crashes)', unit: 'yr' },
{ key: 'cashRatePercent', label: 'Interest on cash', unit: '%' },
{ key: 'inflationPercent', label: 'Inflation', unit: '%' }
]
@@ -61,6 +62,7 @@
{ key: 'hopRaisePercent', label: 'Raise per hop', unit: '%' },
{ key: 'hopGapMonths', label: 'Gap before each hop', unit: 'mo' },
{ key: 'salaryCapEuros', label: 'Plateau base salary', unit: '€/yr' },
{ key: 'salaryCapGrowthPercent', label: 'Plateau growth (real)', unit: '%/yr' },
{ key: 'bonusPercentOfTarget', label: 'Bonus paid of estimate', unit: '%' },
{ key: 'sideStartEuros', label: 'Side income now (gross)', unit: '€/mo' },
{ key: 'sideEndEuros', label: 'Side income from year 5', unit: '€/mo' }
@@ -101,7 +103,14 @@
: 'Not by the end';
let active = $derived(params[selected]);
let result = $derived(results[selected]);
let normalReturn = $derived(marketReturns(active).normal);
let markets = $derived(marketReturns(active, result.years[0]?.year));
let normalReturn = $derived(markets.normal);
/** Crash and correction years in the first ten years, for the explanation. */
let shocks = $derived(
Array.from({ length: 11 }, (_, offset) => (result.years[0]?.year ?? 0) + offset)
.map((year) => ({ year, ...markets.yearReturn(year) }))
.filter((row) => row.kind === 'crash' || row.kind === 'correction')
);
/** Salary path rows through the retirement year. */
let career = $derived(
result.years.filter(
@@ -163,11 +172,24 @@
<p class="muted scenario-portfolio">
<strong>{active.label} portfolio:</strong>
{active.portfolio}. Average {active.returnPercent}%/yr nominal, volatility about {active.volatilityPercent}%.
{#if markets.calmUntil !== null}No crash or down year through {markets.calmUntil}: those years
earn the {active.returnPercent}% average.
{/if}
{#if active.crashPercent !== 0}A {active.crashPercent}% crash every {active.crashEveryYears} years
from {active.firstCrashYear}{active.correctionPercent !== 0
? `, a ${active.correctionPercent}% correction halfway between`
: ''}, and {percent(normalReturn)} in the other years so it averages out.
: ''}, and {percent(normalReturn)} in the other years so it averages out{markets.calmUntil !==
null
? ` (from ${markets.calmUntil + 1})`
: ''}. A crash drops over March–October and takes about {Math.ceil(
Math.log(1 / (1 + active.crashPercent / 100)) / Math.log(1 + normalReturn)
)} years of normal returns to recover.
{#if shocks.length}Next ten years: {shocks
.map((row) => `${row.year} ${row.kind} ${Math.round(row.rate * 100)}%`)
.join(', ')}.{:else}No crash or correction in the next ten years.{/if}
{:else}No crashes modelled.{/if}
{#if active.salaryCapGrowthPercent > 0}The salary plateau keeps rising {active.salaryCapGrowthPercent}%/yr
above inflation, so pay never stalls.{/if}
Retirement in {scenarioFacts.birthYear + active.retireAge} at {active.retireAge}: salary and
side income stop, {cents(active.pensionNetEuros * 100)}/mo net pension (today’s €) starts, and
the rest of the spending is sold from investments.
@@ -180,9 +202,10 @@
Joint items from the recurring list in full until the rent starts, then your share, while your
partner’s income pays hers, DUO {cents(scenarioFacts.duoCents)}/mo from {scenarioFacts.duoFrom}
to {scenarioFacts.duoUntil}. Prices, benefits, pension, the buffer and the salary plateau
grow with inflation; spending grows at the spending rate for ten years, then with inflation.
Box 3 keeps taxing gains (losses carried forward). Salary bands (base per year, excl. 8%
holiday pay): medior €52–68k, senior €65–91k, lead €79–110k (<a
grow with inflation (the plateau can grow faster, see above); spending grows at the spending
rate for ten years, then with inflation. Box 3 keeps taxing gains (losses carried forward).
Salary bands (base per year, excl. 8% holiday pay): medior €52–68k, senior €65–91k, lead
€79–110k (<a
href="https://chaseitrecruitment.nl/tools/salaris/software-engineer/"
rel="external noreferrer">Chase IT 2026</a
>,

View File

@@ -207,7 +207,7 @@ describe('money tracker calculations', () => {
}
];
const [total] = wealthTimeline(accounts, actual, projection, '2026-09-23', 'total');
expect(total.dashedFrom).toBe('2026-09-23');
expect(total.projectedFrom).toBe('2026-09-23');
expect(total.points).toEqual([
{ date: '2026-09-01', value: 50000 },
{ date: '2026-09-10', value: 70000 },

View File

@@ -15,8 +15,8 @@ export type ChartSeries = {
color: string;
points: ChartPoint[];
emphasis?: boolean;
/** Points after this date are drawn dashed (projected). */
dashedFrom?: string;
/** Points after this date are projected: drawn as a thinner, lighter solid line. */
projectedFrom?: string;
};
const palette = ['#0070f3', '#e5484d', '#46a758', '#ad7f00', '#8e4ec6', '#0091ad', '#e56910'];
@@ -283,7 +283,7 @@ export function wealthProjection(
/**
* Thins long series for drawing: per time bucket only the lowest and highest point stay (in
* date order), so a daily projection over decades keeps its spikes without plotting every day.
* The first and last points and the solid/dashed join are always kept.
* The first and last points and the actual/projected join are always kept.
*/
export function decimateSeries(series: ChartSeries[], buckets: number): ChartSeries[] {
const limit = Math.max(2, Math.floor(buckets)) * 2;
@@ -301,8 +301,8 @@ export function decimateSeries(series: ChartSeries[], buckets: number): ChartSer
const keep = new Uint8Array(line.points.length);
keep[0] = 1;
keep[line.points.length - 1] = 1;
if (line.dashedFrom) {
const join = line.points.findLastIndex((point) => point.date <= line.dashedFrom!);
if (line.projectedFrom) {
const join = line.points.findLastIndex((point) => point.date <= line.projectedFrom!);
if (join >= 0) keep[join] = 1;
}
let bucket = -1;
@@ -334,7 +334,7 @@ export function decimateSeries(series: ChartSeries[], buckets: number): ChartSer
export type TimelineView = 'total' | 'split' | 'accounts';
/**
* Joins actual history (solid, through today) with the wealth projection (dashed, after
* Joins actual history (solid, through today) with the wealth projection (thin solid line, after
* today) so both read as one continuous line per measure.
*/
export function wealthTimeline(
@@ -379,7 +379,7 @@ export function wealthTimeline(
label: 'Total',
color: 'var(--site-text)',
emphasis: true,
dashedFrom: today,
projectedFrom: today,
points: join(history.at(-1)!.points, totalProjection?.points ?? [])
};
if (view === 'accounts')
@@ -395,13 +395,13 @@ export function wealthTimeline(
{
label: 'Spendable',
color: '#46a758',
dashedFrom: today,
projectedFrom: today,
points: join(sumKind(false), spendableProjection?.points ?? [])
},
{
label: 'Invested',
color: '#8e4ec6',
dashedFrom: today,
projectedFrom: today,
points: join(sumKind(true), investedProjection?.points ?? [])
}
];

View File

@@ -189,6 +189,49 @@ describe('runScenario', () => {
expect(markets.yearReturn(2034).kind).toBe('correction');
expect(markets.yearReturn(2028).kind).toBe('normal');
});
it('keeps the optimistic start calm and its cycle after that', () => {
const markets = marketReturns(defaultScenarios.optimistic, 2026);
for (let year = 2026; year <= 2031; year++)
expect(markets.yearReturn(year)).toEqual({ kind: 'calm', rate: 0.095 });
expect(markets.yearReturn(2032).kind).toBe('normal');
expect(markets.yearReturn(2035)).toMatchObject({ kind: 'correction', rate: -0.15 });
expect(markets.yearReturn(2040)).toMatchObject({ kind: 'crash', rate: -0.38 });
// No down year at all in the first five years of the run.
const run = runScenario(inputs, defaultScenarios.optimistic);
const growth = run.years.filter((row) => row.year <= 2031).map((row) => row.invested);
growth.slice(1).forEach((value, index) => expect(value).toBeGreaterThan(growth[index]));
});
it('gives realistic one crash in its first ten years and keeps pessimistic as it was', () => {
const shocks = (id: 'realistic' | 'pessimistic') => {
const markets = marketReturns(defaultScenarios[id], 2026);
return Array.from({ length: 10 }, (_, offset) => 2026 + offset)
.map((year) => ({ year, ...markets.yearReturn(year) }))
.filter((row) => row.kind === 'crash');
};
expect(shocks('realistic')).toEqual([{ year: 2029, kind: 'crash', rate: -0.3 }]);
expect(shocks('pessimistic').map((row) => row.year)).toEqual([2027, 2035]);
expect(defaultScenarios.realistic.calmYears).toBe(0);
expect(defaultScenarios.pessimistic.calmYears).toBe(0);
// Without a start year the calm start never applies.
expect(marketReturns(defaultScenarios.optimistic).yearReturn(2030).kind).toBe('crash');
});
it('keeps the optimistic salary rising every working year', () => {
const long = { ...inputs, endDate: '2076-10-24' };
const years = runScenario(long, defaultScenarios.optimistic).years.filter(
(row) => row.year >= 2027 && row.role !== 'Retired'
);
expect(years.length).toBeGreaterThan(40);
years
.slice(1)
.forEach((row, index) => expect(row.baseReal).toBeGreaterThan(years[index].baseReal));
// A flat plateau stalls once it is reached.
const flatCap = runScenario(long, {
...defaultScenarios.optimistic,
salaryCapGrowthPercent: 0
});
const late = flatCap.years.filter((row) => row.year >= 2050 && row.role !== 'Retired');
expect(late.at(-1)!.baseReal).toBeCloseTo(late[0].baseReal, -2);
});
it('hops jobs, caps the salary and retires at the chosen age', () => {
const long = { ...inputs, endDate: '2126-10-24' };
const result = runScenario(long, {

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) => ({