money scenarios: forecast horizon 1–100y (?horizon=), today’s-euro view, scaling gridlines, career paths with job hops and plateaus, crash cycles per portfolio, retirement at 68 with withdrawals, salary path table
All checks were successful
Deploy production / deploy (push) Successful in 2m49s
All checks were successful
Deploy production / deploy (push) Successful in 2m49s
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Pessimistic / realistic / optimistic wealth scenarios to the projection end date.
|
||||
* Pessimistic / realistic / optimistic wealth scenarios to the chosen horizon (up to 100 years).
|
||||
*
|
||||
* Money in the tracker drives the start balances, recurring costs and one-off estimates; each
|
||||
* scenario adds labelled assumptions (returns, salary path, spending growth, Box 3, inflation)
|
||||
* scenario adds labelled assumptions (portfolio and crashes, career path, retirement, spending
|
||||
* growth, Box 3, inflation)
|
||||
* that the page lists and lets you tweak. Amounts are in cents, except the editable assumptions,
|
||||
* which are whole euros or percentages so they read like the inputs on the page.
|
||||
*/
|
||||
@@ -14,34 +15,53 @@ export const scenarioIds: ScenarioId[] = ['pessimistic', 'realistic', 'optimisti
|
||||
|
||||
export type ScenarioParams = {
|
||||
label: string;
|
||||
/** Yearly investment return before any crash, in percent. */
|
||||
/** Portfolio mix, shown with the assumptions. */
|
||||
portfolio: string;
|
||||
/** Long-run average yearly return of the portfolio including crashes, in percent. */
|
||||
returnPercent: number;
|
||||
/** Market drop in the crash year (percent, e.g. -28); 0 means no crash. */
|
||||
/** Typical yearly swing (standard deviation), percent; shown for context. */
|
||||
volatilityPercent: number;
|
||||
/** Market drop in a crash year (percent, e.g. -30); 0 means no crashes. */
|
||||
crashPercent: number;
|
||||
crashYear: number;
|
||||
/** Yearly return in the years after the crash, in percent. */
|
||||
recoveryPercent: number;
|
||||
/** Years between crashes, counted from the first crash year. */
|
||||
crashEveryYears: number;
|
||||
firstCrashYear: number;
|
||||
/** Smaller correction halfway between crashes, percent (0 = none). */
|
||||
correctionPercent: number;
|
||||
/** Interest on the cash buffer, percent per year. */
|
||||
cashRatePercent: number;
|
||||
/** Months without a job after the contract ends (unemployment benefit instead). */
|
||||
/** Inflation: prices, the salary cap, pension and benefits grow with it; percent. */
|
||||
inflationPercent: number;
|
||||
/** Months without a job after the contract ends on 30 Sep 2027 (benefit instead). */
|
||||
gapMonths: number;
|
||||
/** Net unemployment benefit per month during the gap, euros. */
|
||||
/** Net unemployment benefit per month during a gap, today's euros. */
|
||||
gapNetEuros: number;
|
||||
/** Gross salary change at the extension or next job, percent. */
|
||||
/** Gross salary change at the extension or next job in October 2027, percent. */
|
||||
nextJobPercent: number;
|
||||
/** Yearly raise in percent, applied each raiseMonth from raiseFromYear. */
|
||||
raisePercent: number;
|
||||
raiseMonth: number;
|
||||
raiseFromYear: number;
|
||||
/** Job hops: in October of the first year, then every N years (0 = never). */
|
||||
firstHopYear: number;
|
||||
hopEveryYears: number;
|
||||
/** Gross raise at each hop, percent. */
|
||||
hopRaisePercent: number;
|
||||
/** Months without a job before each hop starts (unemployment spells). */
|
||||
hopGapMonths: number;
|
||||
/** Career plateau: base salary cap per year in today's euros (excl. holiday pay). */
|
||||
salaryCapEuros: number;
|
||||
/** Share of the contract's bonus estimate actually paid each August, 0–100. */
|
||||
bonusPercentOfTarget: number;
|
||||
/** Side income, gross euros per month, ramping linearly to the end value. */
|
||||
/** Side income, gross today's euros per month, ramping over five years to the end value. */
|
||||
sideStartEuros: number;
|
||||
sideEndEuros: number;
|
||||
/** Spending growth per year (lifestyle creep plus prices), percent. */
|
||||
/** Retirement age: salary and side income stop, pension starts, spending comes out of savings. */
|
||||
retireAge: number;
|
||||
/** Net pension per month (AOW plus workplace pension), today's euros. */
|
||||
pensionNetEuros: number;
|
||||
/** Spending growth per year for the first ten years (prices plus lifestyle), then inflation. */
|
||||
spendGrowthPercent: number;
|
||||
/** Inflation used to show the end value in today's euros, percent. */
|
||||
inflationPercent: number;
|
||||
/** Your share of Joint household costs after the move, percent. */
|
||||
householdSharePercent: number;
|
||||
/** Personal spending not in the recurring list (going out, transport, shopping), euros/month. */
|
||||
@@ -59,22 +79,32 @@ export type ScenarioParams = {
|
||||
export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
|
||||
pessimistic: {
|
||||
label: 'Pessimistic',
|
||||
returnPercent: 0,
|
||||
crashPercent: -28,
|
||||
crashYear: 2027,
|
||||
recoveryPercent: 15,
|
||||
portfolio: '60% global equity ETF, 40% euro bonds',
|
||||
returnPercent: 4,
|
||||
volatilityPercent: 10,
|
||||
crashPercent: -25,
|
||||
crashEveryYears: 8,
|
||||
firstCrashYear: 2027,
|
||||
correctionPercent: -8,
|
||||
cashRatePercent: 0,
|
||||
inflationPercent: 2.5,
|
||||
gapMonths: 3,
|
||||
gapNetEuros: 2300,
|
||||
nextJobPercent: 0,
|
||||
raisePercent: 2,
|
||||
raiseMonth: 1,
|
||||
raiseFromYear: 2029,
|
||||
firstHopYear: 2031,
|
||||
hopEveryYears: 5,
|
||||
hopRaisePercent: 15,
|
||||
hopGapMonths: 3,
|
||||
salaryCapEuros: 75000,
|
||||
bonusPercentOfTarget: 0,
|
||||
sideStartEuros: 100,
|
||||
sideEndEuros: 100,
|
||||
retireAge: 68,
|
||||
pensionNetEuros: 1700,
|
||||
spendGrowthPercent: 3.5,
|
||||
inflationPercent: 2.5,
|
||||
householdSharePercent: 57,
|
||||
otherSpendEuros: 400,
|
||||
householdExtraEuros: 200,
|
||||
@@ -84,22 +114,32 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
|
||||
},
|
||||
realistic: {
|
||||
label: 'Realistic',
|
||||
returnPercent: 6.5,
|
||||
crashPercent: 0,
|
||||
crashYear: 2027,
|
||||
recoveryPercent: 6.5,
|
||||
portfolio: '100% global equity ETF (all-world index)',
|
||||
returnPercent: 7,
|
||||
volatilityPercent: 15,
|
||||
crashPercent: -30,
|
||||
crashEveryYears: 10,
|
||||
firstCrashYear: 2029,
|
||||
correctionPercent: -10,
|
||||
cashRatePercent: 1.5,
|
||||
inflationPercent: 2.5,
|
||||
gapMonths: 0,
|
||||
gapNetEuros: 2300,
|
||||
nextJobPercent: 3,
|
||||
raisePercent: 3.5,
|
||||
nextJobPercent: 6,
|
||||
raisePercent: 4,
|
||||
raiseMonth: 10,
|
||||
raiseFromYear: 2028,
|
||||
firstHopYear: 2029,
|
||||
hopEveryYears: 3,
|
||||
hopRaisePercent: 20,
|
||||
hopGapMonths: 0,
|
||||
salaryCapEuros: 95000,
|
||||
bonusPercentOfTarget: 100,
|
||||
sideStartEuros: 250,
|
||||
sideEndEuros: 500,
|
||||
spendGrowthPercent: 2.5,
|
||||
inflationPercent: 2.5,
|
||||
retireAge: 68,
|
||||
pensionNetEuros: 2400,
|
||||
spendGrowthPercent: 3,
|
||||
householdSharePercent: 50,
|
||||
otherSpendEuros: 330,
|
||||
householdExtraEuros: 65,
|
||||
@@ -109,22 +149,32 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
|
||||
},
|
||||
optimistic: {
|
||||
label: 'Optimistic',
|
||||
returnPercent: 9,
|
||||
crashPercent: 0,
|
||||
crashYear: 2027,
|
||||
recoveryPercent: 9,
|
||||
portfolio: '80% global equity ETF, 20% tech, small-cap and factor tilts',
|
||||
returnPercent: 9.5,
|
||||
volatilityPercent: 19,
|
||||
crashPercent: -38,
|
||||
crashEveryYears: 10,
|
||||
firstCrashYear: 2030,
|
||||
correctionPercent: -15,
|
||||
cashRatePercent: 1.5,
|
||||
inflationPercent: 2.5,
|
||||
gapMonths: 0,
|
||||
gapNetEuros: 2300,
|
||||
nextJobPercent: 6,
|
||||
raisePercent: 5,
|
||||
nextJobPercent: 8,
|
||||
raisePercent: 6,
|
||||
raiseMonth: 10,
|
||||
raiseFromYear: 2028,
|
||||
firstHopYear: 2029,
|
||||
hopEveryYears: 3,
|
||||
hopRaisePercent: 22,
|
||||
hopGapMonths: 0,
|
||||
salaryCapEuros: 150000,
|
||||
bonusPercentOfTarget: 100,
|
||||
sideStartEuros: 300,
|
||||
sideEndEuros: 2500,
|
||||
spendGrowthPercent: 2,
|
||||
inflationPercent: 2.5,
|
||||
retireAge: 68,
|
||||
pensionNetEuros: 3200,
|
||||
spendGrowthPercent: 2.5,
|
||||
householdSharePercent: 45,
|
||||
otherSpendEuros: 280,
|
||||
householdExtraEuros: 0,
|
||||
@@ -139,6 +189,8 @@ export const scenarioFacts = {
|
||||
/** Contract gross per month and the date the current contract ends. */
|
||||
grossCents: 372493,
|
||||
contractEnd: '2027-09-30',
|
||||
/** Born 2003: 23 now, so 68 is 2071 (the expected AOW age for that year is about 68–70). */
|
||||
birthYear: 2003,
|
||||
/** Extra net per extra gross euro: about 50% marginal tax after 2.65% pension. */
|
||||
marginalNet: (1 - 0.0265) * 0.5,
|
||||
/**
|
||||
@@ -155,14 +207,36 @@ export const scenarioFacts = {
|
||||
firstBonusYear: 2027,
|
||||
bonusNetYearCents: 130000,
|
||||
sideTaxRate: 0.4,
|
||||
/** Cash buffer in today's euros (grows with inflation); everything above it is invested. */
|
||||
bufferCents: 500000,
|
||||
/** Joint household contribution before the move, per month (Apr–Oct 2026 average). */
|
||||
preMoveHouseholdCents: 90000,
|
||||
/** DUO study-debt repayment from 2029, per month. */
|
||||
/** DUO study-debt repayment, per month, for at most 35 years from 2029. */
|
||||
duoFrom: 2029,
|
||||
duoCents: 10000
|
||||
duoUntil: 2063,
|
||||
duoCents: 10000,
|
||||
/** Box 3 from 2028: 36% on gains above €1,000 a year, losses carried forward. */
|
||||
box3Rate: 0.36,
|
||||
box3FreeCents: 100000,
|
||||
/** Withdrawal rate for the "passive income" milestone. */
|
||||
withdrawalRate: 0.035,
|
||||
/** Monthly amount for the income milestones, today's euros. */
|
||||
targetMonthlyCents: 1_000_000
|
||||
};
|
||||
|
||||
/** Base salary bands per year in today's euros (lower ends), for the role column. */
|
||||
export const salaryBands: { fromEuros: number; role: string }[] = [
|
||||
{ fromEuros: 0, role: 'Junior' },
|
||||
{ fromEuros: 52000, role: 'Medior' },
|
||||
{ fromEuros: 65000, role: 'Senior' },
|
||||
{ fromEuros: 85000, role: 'Lead / senior II' },
|
||||
{ fromEuros: 105000, role: 'Staff / eng. manager' },
|
||||
{ fromEuros: 135000, role: 'Principal / big-tech staff' }
|
||||
];
|
||||
export function roleFor(baseRealEuros: number) {
|
||||
return salaryBands.findLast((band) => baseRealEuros >= band.fromEuros)?.role ?? 'Junior';
|
||||
}
|
||||
|
||||
export type ScenarioRecurring = {
|
||||
name: string;
|
||||
amountCents: number;
|
||||
@@ -206,6 +280,13 @@ export type ScenarioYear = {
|
||||
invested: number;
|
||||
real: number;
|
||||
box3: number;
|
||||
age: number;
|
||||
/** Gross per month at year end (nominal, 0 when retired). */
|
||||
grossMonthly: number;
|
||||
/** Base salary per year in today's euros, for the role. */
|
||||
baseReal: number;
|
||||
role: string;
|
||||
events: string[];
|
||||
};
|
||||
export type ScenarioResult = {
|
||||
points: ScenarioPoint[];
|
||||
@@ -299,33 +380,93 @@ function monthEnd(first: string) {
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function marketReturns(params: ScenarioParams) {
|
||||
const pct = (value: number) => value / 100;
|
||||
const every = Math.max(2, Math.round(params.crashEveryYears));
|
||||
const crashes = params.crashPercent !== 0;
|
||||
const corrections = crashes && params.correctionPercent !== 0 && every >= 3;
|
||||
const shocks =
|
||||
(1 + (crashes ? pct(params.crashPercent) : 0)) *
|
||||
(1 + (corrections ? pct(params.correctionPercent) : 0));
|
||||
const normalYears = every - (crashes ? 1 : 0) - (corrections ? 1 : 0);
|
||||
const normal = crashes
|
||||
? Math.pow(Math.pow(1 + pct(params.returnPercent), every) / shocks, 1 / normalYears) - 1
|
||||
: pct(params.returnPercent);
|
||||
return {
|
||||
normal,
|
||||
yearReturn(year: number) {
|
||||
if (!crashes || year < params.firstCrashYear)
|
||||
return { kind: 'normal' as const, rate: normal };
|
||||
const offset = (year - params.firstCrashYear) % every;
|
||||
if (offset === 0) return { kind: 'crash' as const, rate: pct(params.crashPercent) };
|
||||
if (corrections && offset === Math.floor(every / 2))
|
||||
return { kind: 'correction' as const, rate: pct(params.correctionPercent) };
|
||||
return { kind: 'normal' as const, rate: normal };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): ScenarioResult {
|
||||
const facts = scenarioFacts;
|
||||
const baseGross = facts.grossCents;
|
||||
const pct = (value: number) => value / 100;
|
||||
const netFromGross = (gross: number) =>
|
||||
inputs.netSalaryCents + (gross - baseGross) * facts.marginalNet;
|
||||
const gapStart = addMonths(facts.contractEnd, 1);
|
||||
const gapEnd = params.gapMonths > 0 ? addMonths(gapStart, params.gapMonths) : gapStart;
|
||||
const pct = (value: number) => value / 100;
|
||||
const retireStart = `${facts.birthYear + Math.round(params.retireAge)}-01-01`;
|
||||
const markets = marketReturns(params);
|
||||
const monthlyInflation = Math.pow(1 + pct(params.inflationPercent), 1 / 12);
|
||||
const isHopYear = (year: number) =>
|
||||
params.hopEveryYears > 0 &&
|
||||
year >= params.firstHopYear &&
|
||||
(year - params.firstHopYear) % Math.max(1, Math.round(params.hopEveryYears)) === 0;
|
||||
let cash = inputs.spendableCents;
|
||||
let invested = inputs.investedCents;
|
||||
let basis = inputs.investedCents;
|
||||
let gross = baseGross;
|
||||
let accruedHoliday = (facts.holidayNetYearCents / 12) * facts.holidayMonthsAccrued;
|
||||
let accrued13 = (facts.thirteenthNetYearCents / 12) * facts.thirteenthMonthsAccrued;
|
||||
let investedAtYearStart = invested;
|
||||
let contributedThisYear = 0;
|
||||
let realisedThisYear = 0;
|
||||
let lossCarried = 0;
|
||||
let box3Due = 0;
|
||||
let deflator = 1;
|
||||
let spendGrowth = 1;
|
||||
const recentRealIncome: number[] = [];
|
||||
const points: ScenarioPoint[] = [
|
||||
{ date: inputs.today, cash, invested, total: cash + invested, real: cash + invested }
|
||||
];
|
||||
const years = new Map<number, ScenarioYear>();
|
||||
const target = facts.targetMonthlyCents;
|
||||
const milestones: { label: string; date: string | null; test: () => boolean }[] = [
|
||||
{ label: 'Cash buffer full', date: null, test: () => cash >= facts.bufferCents - 100 },
|
||||
...[10_000, 25_000, 50_000, 100_000].map((euros) => ({
|
||||
label: `€${euros / 1000}k invested`,
|
||||
{
|
||||
label: 'Cash buffer full',
|
||||
date: null,
|
||||
test: () => cash >= facts.bufferCents * deflator - 100
|
||||
},
|
||||
...[10_000, 100_000, 250_000, 1_000_000].map((euros) => ({
|
||||
label: `€${euros >= 1_000_000 ? `${euros / 1_000_000}M` : `${euros / 1000}k`} invested`,
|
||||
date: null,
|
||||
test: () => invested >= euros * 100
|
||||
}))
|
||||
})),
|
||||
{
|
||||
label: '€10k/mo net income (today’s €)',
|
||||
date: null,
|
||||
test: () =>
|
||||
recentRealIncome.length === 12 &&
|
||||
recentRealIncome.reduce((sum, value) => sum + value, 0) / 12 >= target
|
||||
},
|
||||
{
|
||||
label: '€10k/mo passive at 3.5% (today’s €)',
|
||||
date: null,
|
||||
test: () => ((invested / deflator) * facts.withdrawalRate) / 12 >= target
|
||||
}
|
||||
];
|
||||
const firstMonth = addMonths(inputs.today, 1);
|
||||
const totalMonths =
|
||||
@@ -334,12 +475,13 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
|
||||
Number(firstMonth.slice(5, 7)) +
|
||||
1;
|
||||
const moveDate = inputs.moveDate ?? '9999-12-31';
|
||||
for (let index = 0; index < Math.min(totalMonths, 600); index++) {
|
||||
for (let index = 0; index < Math.min(totalMonths, 1212); index++) {
|
||||
const first = addMonths(firstMonth, index);
|
||||
const last = monthEnd(first);
|
||||
const date = last < inputs.endDate ? last : inputs.endDate;
|
||||
const year = Number(first.slice(0, 4));
|
||||
const month = Number(first.slice(5, 7));
|
||||
deflator *= monthlyInflation;
|
||||
const row =
|
||||
years.get(year) ??
|
||||
years
|
||||
@@ -352,66 +494,114 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
|
||||
total: 0,
|
||||
invested: 0,
|
||||
real: 0,
|
||||
box3: 0
|
||||
box3: 0,
|
||||
age: year - facts.birthYear,
|
||||
grossMonthly: 0,
|
||||
baseReal: 0,
|
||||
role: '',
|
||||
events: []
|
||||
})
|
||||
.get(year)!;
|
||||
if (month === 1) {
|
||||
investedAtYearStart = invested;
|
||||
contributedThisYear = 0;
|
||||
realisedThisYear = 0;
|
||||
}
|
||||
// Salary path: extension or next job after the contract, with an optional gap.
|
||||
const employed = !(params.gapMonths > 0 && first >= gapStart && first < gapEnd);
|
||||
if (first === gapStart) gross = baseGross * (1 + pct(params.nextJobPercent));
|
||||
if (first > gapStart && year >= params.raiseFromYear && month === params.raiseMonth)
|
||||
const retired = first >= retireStart;
|
||||
// Salary path: extension or next job after the contract (optional gap), raises, job hops
|
||||
// in October (optional gap before each), capped at the career plateau in today's euros.
|
||||
const hopStart = isHopYear(year) ? `${year}-10-01` : null;
|
||||
// A gap that starts in October can run into the next year.
|
||||
const hops = [year - 1, year].filter(isHopYear).map((hopYear) => {
|
||||
const start = `${hopYear}-10-01`;
|
||||
return { start, end: addMonths(start, Math.max(0, params.hopGapMonths)) };
|
||||
});
|
||||
const inHopGap = hops.some((hop) => first >= hop.start && first < hop.end);
|
||||
const hopEnd = hops.find((hop) => hop.end === first)?.end ?? null;
|
||||
const inContractGap = params.gapMonths > 0 && first >= gapStart && first < gapEnd;
|
||||
const employed = !retired && !inHopGap && !inContractGap;
|
||||
if (first === gapStart) {
|
||||
gross = baseGross * (1 + pct(params.nextJobPercent));
|
||||
row.events.push(
|
||||
params.gapMonths > 0 ? `Contract ends, ${params.gapMonths} mo gap` : 'Contract renewed'
|
||||
);
|
||||
}
|
||||
if (
|
||||
first > gapStart &&
|
||||
year >= params.raiseFromYear &&
|
||||
month === params.raiseMonth &&
|
||||
!(hopStart && month === 10)
|
||||
)
|
||||
gross *= 1 + pct(params.raisePercent);
|
||||
let income = employed ? netFromGross(gross) : params.gapNetEuros * 100;
|
||||
const cap = ((params.salaryCapEuros * 100) / 12) * deflator;
|
||||
if (hopStart && first === hopStart && params.hopGapMonths > 0 && !retired)
|
||||
row.events.push(`Between jobs (${params.hopGapMonths} mo)`);
|
||||
if (hopEnd && first === hopEnd && !retired) {
|
||||
const plateau = gross >= cap * 0.99;
|
||||
gross *= 1 + pct(params.hopRaisePercent);
|
||||
row.events.push(
|
||||
(params.hopGapMonths > 0 ? 'New job' : 'Job hop') + (plateau ? ' (at plateau)' : '')
|
||||
);
|
||||
}
|
||||
if (gross > cap) gross = cap;
|
||||
const jobChange =
|
||||
first === retireStart ||
|
||||
(hopStart !== null && first === hopStart) ||
|
||||
(params.gapMonths > 0 && first === gapStart);
|
||||
let income = 0;
|
||||
if (employed) income += netFromGross(gross);
|
||||
else if (!retired) income += params.gapNetEuros * 100 * deflator;
|
||||
if (employed) {
|
||||
accruedHoliday += (facts.holidayNetYearCents / 12) * (gross / baseGross);
|
||||
accrued13 += (facts.thirteenthNetYearCents / 12) * (gross / baseGross);
|
||||
}
|
||||
if (month === 5) {
|
||||
if (month === 5 || jobChange) {
|
||||
income += accruedHoliday;
|
||||
accruedHoliday = 0;
|
||||
}
|
||||
if (month === 11) {
|
||||
if (month === 11 || jobChange) {
|
||||
income += accrued13;
|
||||
accrued13 = 0;
|
||||
}
|
||||
// A job change pays out what is still accrued when the contract ends.
|
||||
if (params.gapMonths > 0 && first === addMonths(facts.contractEnd, 0)) {
|
||||
income += accruedHoliday + accrued13;
|
||||
accruedHoliday = 0;
|
||||
accrued13 = 0;
|
||||
}
|
||||
if (month === 8 && year >= facts.firstBonusYear && employed)
|
||||
income +=
|
||||
pct(params.bonusPercentOfTarget) *
|
||||
(year === facts.firstBonusYear
|
||||
? facts.firstBonusCents
|
||||
: facts.bonusNetYearCents * (gross / baseGross));
|
||||
const ramp = Math.min(1, Math.max(0, (index - 3) / Math.max(1, totalMonths - 4)));
|
||||
const sideGross = params.sideStartEuros + (params.sideEndEuros - params.sideStartEuros) * ramp;
|
||||
income += sideGross * 100 * (1 - facts.sideTaxRate);
|
||||
if (retired) {
|
||||
income += params.pensionNetEuros * 100 * deflator;
|
||||
if (first === retireStart) row.events.push(`Retire at ${params.retireAge}`);
|
||||
} else {
|
||||
const ramp = Math.min(1, Math.max(0, (index - 3) / 57));
|
||||
const sideGross =
|
||||
params.sideStartEuros + (params.sideEndEuros - params.sideStartEuros) * ramp;
|
||||
income += sideGross * 100 * deflator * (1 - facts.sideTaxRate);
|
||||
}
|
||||
// Recurring items from the tracker (positive ones such as benefits count as income).
|
||||
const growth = Math.pow(1 + pct(params.spendGrowthPercent), Math.max(0, (index - 3) / 12));
|
||||
if (index >= 3)
|
||||
spendGrowth *= Math.pow(
|
||||
1 + pct(index < 123 ? params.spendGrowthPercent : params.inflationPercent),
|
||||
1 / 12
|
||||
);
|
||||
let spending = 0;
|
||||
for (const item of inputs.personal) {
|
||||
if (!activeIn(item, first, last)) continue;
|
||||
const value = monthly(item);
|
||||
if (value >= 0) income += value;
|
||||
else spending -= value * growth;
|
||||
if (value >= 0) income += retired ? 0 : value;
|
||||
else spending -= value * spendGrowth;
|
||||
}
|
||||
spending += params.otherSpendEuros * 100 * growth;
|
||||
if (first >= '2027-01-01') spending += params.healthInsuranceEuros * 100 * growth;
|
||||
spending += params.otherSpendEuros * 100 * spendGrowth;
|
||||
if (first >= '2027-01-01') spending += params.healthInsuranceEuros * 100 * spendGrowth;
|
||||
if (first < moveDate.slice(0, 7) + '-01') spending += facts.preMoveHouseholdCents;
|
||||
else {
|
||||
let household = params.householdExtraEuros * 100;
|
||||
for (const item of inputs.household)
|
||||
if (activeIn(item, first, last)) household -= monthly(item);
|
||||
spending += household * pct(params.householdSharePercent) * growth;
|
||||
spending += household * pct(params.householdSharePercent) * spendGrowth;
|
||||
}
|
||||
if (first === moveDate.slice(0, 7) + '-01') spending += params.moveCostEuros * 100;
|
||||
if (year >= facts.duoFrom) spending += facts.duoCents;
|
||||
if (year >= facts.duoFrom && year <= facts.duoUntil) spending += facts.duoCents;
|
||||
for (const item of inputs.oneOffs)
|
||||
if ((index === 0 ? item.date > inputs.today : item.date >= first) && item.date <= last) {
|
||||
if (item.amountCents >= 0) income += item.amountCents;
|
||||
@@ -423,42 +613,55 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
|
||||
box3Due = 0;
|
||||
}
|
||||
cash += income - spending - tax;
|
||||
// Returns: a crash spreads over March–October of its year.
|
||||
const crash = params.crashPercent !== 0 && year === params.crashYear;
|
||||
const yearly =
|
||||
params.crashPercent !== 0 && year > params.crashYear
|
||||
? pct(params.recoveryPercent)
|
||||
: pct(params.returnPercent);
|
||||
const rate = crash
|
||||
? month >= 3 && month <= 10
|
||||
? Math.pow(1 + pct(params.crashPercent), 1 / 8) - 1
|
||||
: 0
|
||||
: Math.pow(1 + yearly, 1 / 12) - 1;
|
||||
// Returns: crash and correction years spread their drop over March–October.
|
||||
const market = markets.yearReturn(year);
|
||||
const rate =
|
||||
market.kind === 'normal'
|
||||
? Math.pow(1 + market.rate, 1 / 12) - 1
|
||||
: month >= 3 && month <= 10
|
||||
? Math.pow(1 + market.rate, 1 / 8) - 1
|
||||
: 0;
|
||||
invested += invested * rate;
|
||||
cash *= Math.pow(1 + pct(params.cashRatePercent), 1 / 12);
|
||||
if (cash > facts.bufferCents) {
|
||||
const extra = cash - facts.bufferCents;
|
||||
const buffer = facts.bufferCents * deflator;
|
||||
if (cash > buffer) {
|
||||
const extra = cash - buffer;
|
||||
invested += extra;
|
||||
basis += extra;
|
||||
contributedThisYear += extra;
|
||||
cash = facts.bufferCents;
|
||||
cash = buffer;
|
||||
} else if (cash < 0 && invested > 0) {
|
||||
// Withdrawals: sell investments to cover the shortfall.
|
||||
const sold = Math.min(invested, -cash);
|
||||
const soldBasis = invested > 0 ? basis * (sold / invested) : 0;
|
||||
realisedThisYear += sold - soldBasis;
|
||||
basis -= soldBasis;
|
||||
invested -= sold;
|
||||
contributedThisYear -= sold;
|
||||
cash += sold;
|
||||
}
|
||||
if (month === 12) {
|
||||
const gain = invested - investedAtYearStart - contributedThisYear;
|
||||
if (year >= 2028) box3Due = params.box3 === 'accrual' ? Math.max(0, gain - 100000) * 0.36 : 0;
|
||||
else {
|
||||
if (year >= 2028) {
|
||||
const taxable = params.box3 === 'accrual' ? gain : realisedThisYear;
|
||||
const net = taxable - lossCarried;
|
||||
if (net < 0) {
|
||||
lossCarried = -net;
|
||||
box3Due = 0;
|
||||
} else {
|
||||
lossCarried = 0;
|
||||
box3Due = Math.max(0, net - facts.box3FreeCents) * facts.box3Rate;
|
||||
}
|
||||
} else {
|
||||
const exempt = year === 2026 ? 5_935_700 : 3_084_600;
|
||||
const deemed = year === 2026 ? 0.06 : 0.0787;
|
||||
box3Due = Math.max(0, invested - Math.max(0, exempt - cash)) * deemed * 0.36;
|
||||
box3Due = Math.max(0, invested - Math.max(0, exempt - cash)) * deemed * facts.box3Rate;
|
||||
}
|
||||
}
|
||||
const total = cash + invested;
|
||||
const elapsed = (index + 1) / 12;
|
||||
const real = total / Math.pow(1 + pct(params.inflationPercent), elapsed);
|
||||
const real = total / deflator;
|
||||
recentRealIncome.push(income / deflator);
|
||||
if (recentRealIncome.length > 12) recentRealIncome.shift();
|
||||
points.push({ date, cash, invested, total, real });
|
||||
row.income += income;
|
||||
row.spending += spending + tax;
|
||||
@@ -466,6 +669,9 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
|
||||
row.total = total;
|
||||
row.invested = invested;
|
||||
row.real = real;
|
||||
row.grossMonthly = employed ? gross : 0;
|
||||
row.baseReal = retired ? 0 : (gross * 12) / deflator / 100;
|
||||
row.role = retired ? 'Retired' : roleFor(row.baseReal);
|
||||
for (const milestone of milestones)
|
||||
if (milestone.date === null && milestone.test()) milestone.date = first.slice(0, 7);
|
||||
}
|
||||
@@ -492,9 +698,14 @@ export function scenarioColor(id: ScenarioId) {
|
||||
}
|
||||
|
||||
/** Total, spendable and invested lines in the shape `wealthTimeline` expects for a projection. */
|
||||
export function scenarioProjection(result: ScenarioResult): ChartSeries[] {
|
||||
export function scenarioProjection(result: ScenarioResult, todaysEuros = false): ChartSeries[] {
|
||||
const line = (key: 'total' | 'cash' | 'invested') =>
|
||||
result.points.map((point) => ({ date: point.date, value: Math.round(point[key]) }));
|
||||
result.points.map((point) => ({
|
||||
date: point.date,
|
||||
value: Math.round(
|
||||
todaysEuros && point.total ? point[key] * (point.real / point.total) : point[key]
|
||||
)
|
||||
}));
|
||||
return [
|
||||
{ label: 'Total wealth', color: '#0070f3', points: line('total'), emphasis: true },
|
||||
{ label: 'Spendable', color: '#46a758', points: line('cash') },
|
||||
@@ -506,7 +717,8 @@ export function scenarioProjection(result: ScenarioResult): ChartSeries[] {
|
||||
export function scenarioCompareLines(
|
||||
results: Record<ScenarioId, ScenarioResult>,
|
||||
params: Record<ScenarioId, ScenarioParams>,
|
||||
today: string
|
||||
today: string,
|
||||
todaysEuros = false
|
||||
): ChartSeries[] {
|
||||
return scenarioIds.map((id) => ({
|
||||
label: params[id].label,
|
||||
@@ -514,6 +726,9 @@ export function scenarioCompareLines(
|
||||
dashedFrom: today,
|
||||
points: results[id].points
|
||||
.filter((point) => point.date > today)
|
||||
.map((point) => ({ date: point.date, value: Math.round(point.total) }))
|
||||
.map((point) => ({
|
||||
date: point.date,
|
||||
value: Math.round(todaysEuros ? point.real : point.total)
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user