Money forecast: calm optimistic start, rising plateau, thin solid projected lines
Deploy production / deploy (push) Successful in 1m13s
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:
1 parent
d3b9bb9ba2
commit
78b69c7d8f
6 files changed
+135
-38
No files matched your search
@@ -39,6 +39,11 @@ export type ScenarioParams = {
|
||||
firstCrashYear: number;
|
||||
/** Smaller correction halfway between crashes, percent (0 = none). */
|
||||
correctionPercent: number;
|
||||
/**
|
||||
* Calm start: no crash or correction from this year through this many years ahead; those
|
||||
* years earn the plain average return (0 = the crash cycle applies from the start).
|
||||
*/
|
||||
calmYears: number;
|
||||
/** Interest on the cash buffer, percent per year. */
|
||||
cashRatePercent: number;
|
||||
/** Inflation: prices, the salary cap, pension and benefits grow with it; percent. */
|
||||
@@ -62,6 +67,8 @@ export type ScenarioParams = {
|
||||
hopGapMonths: number;
|
||||
/** Career plateau: base salary cap per year in today's euros (excl. holiday pay). */
|
||||
salaryCapEuros: number;
|
||||
/** Real growth of that plateau per year, percent (0 = flat once reached). */
|
||||
salaryCapGrowthPercent: number;
|
||||
/** Share of the contract's bonus estimate actually paid each August, 0–100. */
|
||||
bonusPercentOfTarget: number;
|
||||
/** Side income, gross today's euros per month, ramping over five years to the end value. */
|
||||
@@ -97,6 +104,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
|
||||
crashEveryYears: 8,
|
||||
firstCrashYear: 2027,
|
||||
correctionPercent: -8,
|
||||
calmYears: 0,
|
||||
cashRatePercent: 0,
|
||||
inflationPercent: 2.5,
|
||||
gapMonths: 3,
|
||||
@@ -110,6 +118,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
|
||||
hopRaisePercent: 15,
|
||||
hopGapMonths: 3,
|
||||
salaryCapEuros: 75000,
|
||||
salaryCapGrowthPercent: 0,
|
||||
bonusPercentOfTarget: 0,
|
||||
sideStartEuros: 100,
|
||||
sideEndEuros: 100,
|
||||
@@ -132,6 +141,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
|
||||
crashEveryYears: 10,
|
||||
firstCrashYear: 2029,
|
||||
correctionPercent: -10,
|
||||
calmYears: 0,
|
||||
cashRatePercent: 1.5,
|
||||
inflationPercent: 2.5,
|
||||
gapMonths: 0,
|
||||
@@ -145,6 +155,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
|
||||
hopRaisePercent: 20,
|
||||
hopGapMonths: 0,
|
||||
salaryCapEuros: 95000,
|
||||
salaryCapGrowthPercent: 0,
|
||||
bonusPercentOfTarget: 100,
|
||||
sideStartEuros: 250,
|
||||
sideEndEuros: 500,
|
||||
@@ -167,6 +178,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
|
||||
crashEveryYears: 10,
|
||||
firstCrashYear: 2030,
|
||||
correctionPercent: -15,
|
||||
calmYears: 5,
|
||||
cashRatePercent: 1.5,
|
||||
inflationPercent: 2.5,
|
||||
gapMonths: 0,
|
||||
@@ -180,6 +192,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
|
||||
hopRaisePercent: 22,
|
||||
hopGapMonths: 0,
|
||||
salaryCapEuros: 150000,
|
||||
salaryCapGrowthPercent: 2,
|
||||
bonusPercentOfTarget: 100,
|
||||
sideStartEuros: 300,
|
||||
sideEndEuros: 2500,
|
||||
@@ -396,9 +409,11 @@ function monthEnd(first: string) {
|
||||
|
||||
/**
|
||||
* Years of a crash cycle: a crash in the first year, a correction halfway, and normal years whose
|
||||
* return makes the whole cycle average out at the expected return.
|
||||
* return makes the whole cycle average out at the expected return. With `calmYears`, the years
|
||||
* from `startYear` through `startYear + calmYears` have no crash or correction and earn the plain
|
||||
* average return; the cycle keeps its phase after that.
|
||||
*/
|
||||
export function marketReturns(params: ScenarioParams) {
|
||||
export function marketReturns(params: ScenarioParams, startYear?: number) {
|
||||
const pct = (value: number) => value / 100;
|
||||
const every = Math.max(2, Math.round(params.crashEveryYears));
|
||||
const crashes = params.crashPercent !== 0;
|
||||
@@ -410,9 +425,16 @@ export function marketReturns(params: ScenarioParams) {
|
||||
const normal = crashes
|
||||
? Math.pow(Math.pow(1 + pct(params.returnPercent), every) / shocks, 1 / normalYears) - 1
|
||||
: pct(params.returnPercent);
|
||||
const calmUntil =
|
||||
startYear !== undefined && params.calmYears > 0
|
||||
? startYear + Math.round(params.calmYears)
|
||||
: null;
|
||||
return {
|
||||
normal,
|
||||
calmUntil,
|
||||
yearReturn(year: number) {
|
||||
if (calmUntil !== null && year <= calmUntil)
|
||||
return { kind: 'calm' as const, rate: pct(params.returnPercent) };
|
||||
if (!crashes || year < params.firstCrashYear)
|
||||
return { kind: 'normal' as const, rate: normal };
|
||||
const offset = (year - params.firstCrashYear) % every;
|
||||
@@ -444,7 +466,7 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
|
||||
const gapStart = addMonths(facts.contractEnd, 1);
|
||||
const gapEnd = params.gapMonths > 0 ? addMonths(gapStart, params.gapMonths) : gapStart;
|
||||
const retireStart = `${facts.birthYear + Math.round(params.retireAge)}-01-01`;
|
||||
const markets = marketReturns(params);
|
||||
const markets = marketReturns(params, Number(inputs.today.slice(0, 4)));
|
||||
const monthlyInflation = Math.pow(1 + pct(params.inflationPercent), 1 / 12);
|
||||
const isHopYear = (year: number) =>
|
||||
params.hopEveryYears > 0 &&
|
||||
@@ -589,7 +611,10 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
|
||||
!(hopStart && month === 10)
|
||||
)
|
||||
gross *= 1 + pct(params.raisePercent);
|
||||
const cap = ((params.salaryCapEuros * 100) / 12) * deflator;
|
||||
const cap =
|
||||
((params.salaryCapEuros * 100) / 12) *
|
||||
deflator *
|
||||
Math.pow(1 + pct(params.salaryCapGrowthPercent), Math.max(0, index) / 12);
|
||||
if (hopStart && first === hopStart && params.hopGapMonths > 0 && !retired)
|
||||
row.events.push(`Between jobs (${params.hopGapMonths} mo)`);
|
||||
if (hopEnd && first === hopEnd && !retired) {
|
||||
@@ -727,7 +752,7 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
|
||||
// compounding gives the same monthly return.
|
||||
const market = markets.yearReturn(year);
|
||||
const monthRate =
|
||||
market.kind === 'normal'
|
||||
market.kind === 'normal' || market.kind === 'calm'
|
||||
? Math.pow(1 + market.rate, 1 / 12) - 1
|
||||
: month >= 3 && month <= 10
|
||||
? Math.pow(1 + market.rate, 1 / 8) - 1
|
||||
@@ -848,7 +873,7 @@ export function scenarioProjection(result: ScenarioResult, todaysEuros = false):
|
||||
];
|
||||
}
|
||||
|
||||
/** One dashed total line per scenario, for comparing them on the wealth chart. */
|
||||
/** One projected total line per scenario, for comparing them on the wealth chart. */
|
||||
export function scenarioCompareLines(
|
||||
results: Record<ScenarioId, ScenarioResult>,
|
||||
params: Record<ScenarioId, ScenarioParams>,
|
||||
@@ -858,7 +883,7 @@ export function scenarioCompareLines(
|
||||
return scenarioIds.map((id) => ({
|
||||
label: params[id].label,
|
||||
color: scenarioColors[id],
|
||||
dashedFrom: today,
|
||||
projectedFrom: today,
|
||||
points: results[id].points
|
||||
.filter((point) => point.date > today)
|
||||
.map((point) => ({
|
||||
|
||||
Reference in new issue
Block a user