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

View File

@@ -43,6 +43,7 @@
{ key: 'crashEveryYears', label: 'Crash every', unit: 'yr' }, { key: 'crashEveryYears', label: 'Crash every', unit: 'yr' },
{ key: 'firstCrashYear', label: 'First crash', unit: '' }, { key: 'firstCrashYear', label: 'First crash', unit: '' },
{ key: 'correctionPercent', label: 'Mid-cycle correction', 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: 'cashRatePercent', label: 'Interest on cash', unit: '%' },
{ key: 'inflationPercent', label: 'Inflation', unit: '%' } { key: 'inflationPercent', label: 'Inflation', unit: '%' }
] ]
@@ -61,6 +62,7 @@
{ key: 'hopRaisePercent', label: 'Raise per hop', unit: '%' }, { key: 'hopRaisePercent', label: 'Raise per hop', unit: '%' },
{ key: 'hopGapMonths', label: 'Gap before each hop', unit: 'mo' }, { key: 'hopGapMonths', label: 'Gap before each hop', unit: 'mo' },
{ key: 'salaryCapEuros', label: 'Plateau base salary', unit: '€/yr' }, { 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: 'bonusPercentOfTarget', label: 'Bonus paid of estimate', unit: '%' },
{ key: 'sideStartEuros', label: 'Side income now (gross)', unit: '€/mo' }, { key: 'sideStartEuros', label: 'Side income now (gross)', unit: '€/mo' },
{ key: 'sideEndEuros', label: 'Side income from year 5', unit: '€/mo' } { key: 'sideEndEuros', label: 'Side income from year 5', unit: '€/mo' }
@@ -101,7 +103,14 @@
: 'Not by the end'; : 'Not by the end';
let active = $derived(params[selected]); let active = $derived(params[selected]);
let result = $derived(results[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. */ /** Salary path rows through the retirement year. */
let career = $derived( let career = $derived(
result.years.filter( result.years.filter(
@@ -163,11 +172,24 @@
<p class="muted scenario-portfolio"> <p class="muted scenario-portfolio">
<strong>{active.label} portfolio:</strong> <strong>{active.label} portfolio:</strong>
{active.portfolio}. Average {active.returnPercent}%/yr nominal, volatility about {active.volatilityPercent}%. {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 {#if active.crashPercent !== 0}A {active.crashPercent}% crash every {active.crashEveryYears} years
from {active.firstCrashYear}{active.correctionPercent !== 0 from {active.firstCrashYear}{active.correctionPercent !== 0
? `, a ${active.correctionPercent}% correction halfway between` ? `, 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} {: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 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 side income stop, {cents(active.pensionNetEuros * 100)}/mo net pension (today’s €) starts, and
the rest of the spending is sold from investments. 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 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} 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 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. grow with inflation (the plateau can grow faster, see above); spending grows at the spending
Box 3 keeps taxing gains (losses carried forward). Salary bands (base per year, excl. 8% rate for ten years, then with inflation. Box 3 keeps taxing gains (losses carried forward).
holiday pay): medior €52–68k, senior €65–91k, lead €79–110k (<a 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/" href="https://chaseitrecruitment.nl/tools/salaris/software-engineer/"
rel="external noreferrer">Chase IT 2026</a 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'); 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([ expect(total.points).toEqual([
{ date: '2026-09-01', value: 50000 }, { date: '2026-09-01', value: 50000 },
{ date: '2026-09-10', value: 70000 }, { date: '2026-09-10', value: 70000 },

View File

@@ -15,8 +15,8 @@ export type ChartSeries = {
color: string; color: string;
points: ChartPoint[]; points: ChartPoint[];
emphasis?: boolean; emphasis?: boolean;
/** Points after this date are drawn dashed (projected). */ /** Points after this date are projected: drawn as a thinner, lighter solid line. */
dashedFrom?: string; projectedFrom?: string;
}; };
const palette = ['#0070f3', '#e5484d', '#46a758', '#ad7f00', '#8e4ec6', '#0091ad', '#e56910']; 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 * 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. * 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[] { export function decimateSeries(series: ChartSeries[], buckets: number): ChartSeries[] {
const limit = Math.max(2, Math.floor(buckets)) * 2; 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); const keep = new Uint8Array(line.points.length);
keep[0] = 1; keep[0] = 1;
keep[line.points.length - 1] = 1; keep[line.points.length - 1] = 1;
if (line.dashedFrom) { if (line.projectedFrom) {
const join = line.points.findLastIndex((point) => point.date <= line.dashedFrom!); const join = line.points.findLastIndex((point) => point.date <= line.projectedFrom!);
if (join >= 0) keep[join] = 1; if (join >= 0) keep[join] = 1;
} }
let bucket = -1; let bucket = -1;
@@ -334,7 +334,7 @@ export function decimateSeries(series: ChartSeries[], buckets: number): ChartSer
export type TimelineView = 'total' | 'split' | 'accounts'; 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. * today) so both read as one continuous line per measure.
*/ */
export function wealthTimeline( export function wealthTimeline(
@@ -379,7 +379,7 @@ export function wealthTimeline(
label: 'Total', label: 'Total',
color: 'var(--site-text)', color: 'var(--site-text)',
emphasis: true, emphasis: true,
dashedFrom: today, projectedFrom: today,
points: join(history.at(-1)!.points, totalProjection?.points ?? []) points: join(history.at(-1)!.points, totalProjection?.points ?? [])
}; };
if (view === 'accounts') if (view === 'accounts')
@@ -395,13 +395,13 @@ export function wealthTimeline(
{ {
label: 'Spendable', label: 'Spendable',
color: '#46a758', color: '#46a758',
dashedFrom: today, projectedFrom: today,
points: join(sumKind(false), spendableProjection?.points ?? []) points: join(sumKind(false), spendableProjection?.points ?? [])
}, },
{ {
label: 'Invested', label: 'Invested',
color: '#8e4ec6', color: '#8e4ec6',
dashedFrom: today, projectedFrom: today,
points: join(sumKind(true), investedProjection?.points ?? []) points: join(sumKind(true), investedProjection?.points ?? [])
} }
]; ];

View File

@@ -189,6 +189,49 @@ describe('runScenario', () => {
expect(markets.yearReturn(2034).kind).toBe('correction'); expect(markets.yearReturn(2034).kind).toBe('correction');
expect(markets.yearReturn(2028).kind).toBe('normal'); 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', () => { it('hops jobs, caps the salary and retires at the chosen age', () => {
const long = { ...inputs, endDate: '2126-10-24' }; const long = { ...inputs, endDate: '2126-10-24' };
const result = runScenario(long, { const result = runScenario(long, {

View File

@@ -39,6 +39,11 @@ export type ScenarioParams = {
firstCrashYear: number; firstCrashYear: number;
/** Smaller correction halfway between crashes, percent (0 = none). */ /** Smaller correction halfway between crashes, percent (0 = none). */
correctionPercent: number; 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. */ /** Interest on the cash buffer, percent per year. */
cashRatePercent: number; cashRatePercent: number;
/** Inflation: prices, the salary cap, pension and benefits grow with it; percent. */ /** Inflation: prices, the salary cap, pension and benefits grow with it; percent. */
@@ -62,6 +67,8 @@ export type ScenarioParams = {
hopGapMonths: number; hopGapMonths: number;
/** Career plateau: base salary cap per year in today's euros (excl. holiday pay). */ /** Career plateau: base salary cap per year in today's euros (excl. holiday pay). */
salaryCapEuros: number; 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. */ /** Share of the contract's bonus estimate actually paid each August, 0–100. */
bonusPercentOfTarget: number; bonusPercentOfTarget: number;
/** Side income, gross today's euros per month, ramping over five years to the end value. */ /** 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, crashEveryYears: 8,
firstCrashYear: 2027, firstCrashYear: 2027,
correctionPercent: -8, correctionPercent: -8,
calmYears: 0,
cashRatePercent: 0, cashRatePercent: 0,
inflationPercent: 2.5, inflationPercent: 2.5,
gapMonths: 3, gapMonths: 3,
@@ -110,6 +118,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
hopRaisePercent: 15, hopRaisePercent: 15,
hopGapMonths: 3, hopGapMonths: 3,
salaryCapEuros: 75000, salaryCapEuros: 75000,
salaryCapGrowthPercent: 0,
bonusPercentOfTarget: 0, bonusPercentOfTarget: 0,
sideStartEuros: 100, sideStartEuros: 100,
sideEndEuros: 100, sideEndEuros: 100,
@@ -132,6 +141,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
crashEveryYears: 10, crashEveryYears: 10,
firstCrashYear: 2029, firstCrashYear: 2029,
correctionPercent: -10, correctionPercent: -10,
calmYears: 0,
cashRatePercent: 1.5, cashRatePercent: 1.5,
inflationPercent: 2.5, inflationPercent: 2.5,
gapMonths: 0, gapMonths: 0,
@@ -145,6 +155,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
hopRaisePercent: 20, hopRaisePercent: 20,
hopGapMonths: 0, hopGapMonths: 0,
salaryCapEuros: 95000, salaryCapEuros: 95000,
salaryCapGrowthPercent: 0,
bonusPercentOfTarget: 100, bonusPercentOfTarget: 100,
sideStartEuros: 250, sideStartEuros: 250,
sideEndEuros: 500, sideEndEuros: 500,
@@ -167,6 +178,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
crashEveryYears: 10, crashEveryYears: 10,
firstCrashYear: 2030, firstCrashYear: 2030,
correctionPercent: -15, correctionPercent: -15,
calmYears: 5,
cashRatePercent: 1.5, cashRatePercent: 1.5,
inflationPercent: 2.5, inflationPercent: 2.5,
gapMonths: 0, gapMonths: 0,
@@ -180,6 +192,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
hopRaisePercent: 22, hopRaisePercent: 22,
hopGapMonths: 0, hopGapMonths: 0,
salaryCapEuros: 150000, salaryCapEuros: 150000,
salaryCapGrowthPercent: 2,
bonusPercentOfTarget: 100, bonusPercentOfTarget: 100,
sideStartEuros: 300, sideStartEuros: 300,
sideEndEuros: 2500, 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 * 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 pct = (value: number) => value / 100;
const every = Math.max(2, Math.round(params.crashEveryYears)); const every = Math.max(2, Math.round(params.crashEveryYears));
const crashes = params.crashPercent !== 0; const crashes = params.crashPercent !== 0;
@@ -410,9 +425,16 @@ export function marketReturns(params: ScenarioParams) {
const normal = crashes const normal = crashes
? Math.pow(Math.pow(1 + pct(params.returnPercent), every) / shocks, 1 / normalYears) - 1 ? Math.pow(Math.pow(1 + pct(params.returnPercent), every) / shocks, 1 / normalYears) - 1
: pct(params.returnPercent); : pct(params.returnPercent);
const calmUntil =
startYear !== undefined && params.calmYears > 0
? startYear + Math.round(params.calmYears)
: null;
return { return {
normal, normal,
calmUntil,
yearReturn(year: number) { yearReturn(year: number) {
if (calmUntil !== null && year <= calmUntil)
return { kind: 'calm' as const, rate: pct(params.returnPercent) };
if (!crashes || year < params.firstCrashYear) if (!crashes || year < params.firstCrashYear)
return { kind: 'normal' as const, rate: normal }; return { kind: 'normal' as const, rate: normal };
const offset = (year - params.firstCrashYear) % every; 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 gapStart = addMonths(facts.contractEnd, 1);
const gapEnd = params.gapMonths > 0 ? addMonths(gapStart, params.gapMonths) : gapStart; const gapEnd = params.gapMonths > 0 ? addMonths(gapStart, params.gapMonths) : gapStart;
const retireStart = `${facts.birthYear + Math.round(params.retireAge)}-01-01`; 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 monthlyInflation = Math.pow(1 + pct(params.inflationPercent), 1 / 12);
const isHopYear = (year: number) => const isHopYear = (year: number) =>
params.hopEveryYears > 0 && params.hopEveryYears > 0 &&
@@ -589,7 +611,10 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
!(hopStart && month === 10) !(hopStart && month === 10)
) )
gross *= 1 + pct(params.raisePercent); 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) if (hopStart && first === hopStart && params.hopGapMonths > 0 && !retired)
row.events.push(`Between jobs (${params.hopGapMonths} mo)`); row.events.push(`Between jobs (${params.hopGapMonths} mo)`);
if (hopEnd && first === hopEnd && !retired) { if (hopEnd && first === hopEnd && !retired) {
@@ -727,7 +752,7 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
// compounding gives the same monthly return. // compounding gives the same monthly return.
const market = markets.yearReturn(year); const market = markets.yearReturn(year);
const monthRate = const monthRate =
market.kind === 'normal' market.kind === 'normal' || market.kind === 'calm'
? Math.pow(1 + market.rate, 1 / 12) - 1 ? Math.pow(1 + market.rate, 1 / 12) - 1
: month >= 3 && month <= 10 : month >= 3 && month <= 10
? Math.pow(1 + market.rate, 1 / 8) - 1 ? 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( export function scenarioCompareLines(
results: Record<ScenarioId, ScenarioResult>, results: Record<ScenarioId, ScenarioResult>,
params: Record<ScenarioId, ScenarioParams>, params: Record<ScenarioId, ScenarioParams>,
@@ -858,7 +883,7 @@ export function scenarioCompareLines(
return scenarioIds.map((id) => ({ return scenarioIds.map((id) => ({
label: params[id].label, label: params[id].label,
color: scenarioColors[id], color: scenarioColors[id],
dashedFrom: today, projectedFrom: today,
points: results[id].points points: results[id].points
.filter((point) => point.date > today) .filter((point) => point.date > today)
.map((point) => ({ .map((point) => ({