Money forecast: calm optimistic start, rising plateau, thin solid projected lines
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
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:
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
>,
|
>,
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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 ?? [])
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user