diff --git a/src/lib/money-tracker/MoneyChart.svelte b/src/lib/money-tracker/MoneyChart.svelte index 8cec5bd..a1d89c0 100644 --- a/src/lib/money-tracker/MoneyChart.svelte +++ b/src/lib/money-tracker/MoneyChart.svelte @@ -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} {/if} {/each} @@ -306,16 +310,16 @@ {/if} - {#if series.length > 1 || series.some((item) => item.dashedFrom)} + {#if series.length > 1 || series.some((item) => item.projectedFrom)}
{#each series as item (item.label)} {item.label} {/each} - {#if series.some((item) => item.dashedFrom)} - Actual - Projected + {#if series.some((item) => item.projectedFrom)} + Actual + Projected {/if}
{/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; diff --git a/src/lib/money-tracker/ScenarioDetails.svelte b/src/lib/money-tracker/ScenarioDetails.svelte index 8251936..cc935e7 100644 --- a/src/lib/money-tracker/ScenarioDetails.svelte +++ b/src/lib/money-tracker/ScenarioDetails.svelte @@ -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 @@

{active.label} portfolio: {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 (Chase IT 2026, diff --git a/src/lib/money-tracker/charts.test.ts b/src/lib/money-tracker/charts.test.ts index 2395b07..418b4df 100644 --- a/src/lib/money-tracker/charts.test.ts +++ b/src/lib/money-tracker/charts.test.ts @@ -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 }, diff --git a/src/lib/money-tracker/charts.ts b/src/lib/money-tracker/charts.ts index 6ba2a60..8477dc4 100644 --- a/src/lib/money-tracker/charts.ts +++ b/src/lib/money-tracker/charts.ts @@ -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 ?? []) } ]; diff --git a/src/lib/money-tracker/scenarios.test.ts b/src/lib/money-tracker/scenarios.test.ts index 5c63cac..b09746a 100644 --- a/src/lib/money-tracker/scenarios.test.ts +++ b/src/lib/money-tracker/scenarios.test.ts @@ -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, { diff --git a/src/lib/money-tracker/scenarios.ts b/src/lib/money-tracker/scenarios.ts index 6984965..0954e32 100644 --- a/src/lib/money-tracker/scenarios.ts +++ b/src/lib/money-tracker/scenarios.ts @@ -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 = { crashEveryYears: 8, firstCrashYear: 2027, correctionPercent: -8, + calmYears: 0, cashRatePercent: 0, inflationPercent: 2.5, gapMonths: 3, @@ -110,6 +118,7 @@ export const defaultScenarios: Record = { hopRaisePercent: 15, hopGapMonths: 3, salaryCapEuros: 75000, + salaryCapGrowthPercent: 0, bonusPercentOfTarget: 0, sideStartEuros: 100, sideEndEuros: 100, @@ -132,6 +141,7 @@ export const defaultScenarios: Record = { crashEveryYears: 10, firstCrashYear: 2029, correctionPercent: -10, + calmYears: 0, cashRatePercent: 1.5, inflationPercent: 2.5, gapMonths: 0, @@ -145,6 +155,7 @@ export const defaultScenarios: Record = { hopRaisePercent: 20, hopGapMonths: 0, salaryCapEuros: 95000, + salaryCapGrowthPercent: 0, bonusPercentOfTarget: 100, sideStartEuros: 250, sideEndEuros: 500, @@ -167,6 +178,7 @@ export const defaultScenarios: Record = { crashEveryYears: 10, firstCrashYear: 2030, correctionPercent: -15, + calmYears: 5, cashRatePercent: 1.5, inflationPercent: 2.5, gapMonths: 0, @@ -180,6 +192,7 @@ export const defaultScenarios: Record = { 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, params: Record, @@ -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) => ({