V5-Modell: Einkommen nominal, Ausgaben real->nominal, Inflation plan-weit, Sparquoten-Grafik
Deploy App / deploy (push) Successful in 57s
Deploy App / deploy (push) Successful in 57s
- Einkommen: nominale Basis + nominale Lohnerhoehung (Default 0). Real read-only als Info. - Ausgaben: REALE Basis (heutige Kaufkraft) + reale Mehrausgaben (Default 0); nominal = real x plan-weite Inflation, read-only. Loest die "nominale Ausgaben verwirren"-Problematik. - Inflation nur noch plan-weit (Phasen-Override entfernt). - Engine liefert flowDeflatorEnd (Flow-Realwerte) + yearly-Serie (Jahr/Alter/Einkommen/ Ausgabe nominal/real). Nominal/Real-Umschalter nutzt Flow- vs. Bestands-Deflatoren. - Neue Grafik (Dashboard): Einkommen vs. nominale Ausgabe mit eingefaerbter Sparquoten- Flaeche (gruen/rot) + reale Ausgabe als Referenzlinie (Inflationskeil). - In-Tool-Erklaerungen in den Einkommen/Ausgaben-Popups. - Golden Tests aufs neue Modell hergeleitet (8 gruen, u.a. Ausgaben real->nominal, Ruin 94). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -39,48 +39,48 @@ function plan(opts: {
|
||||
|
||||
const within = (actual: number, expected: number, pct: number) => Math.abs(actual - expected) <= Math.abs(expected) * pct;
|
||||
|
||||
describe("V4 Golden Tests", () => {
|
||||
it("Test 1 – Ansparphase, Flows wachsen (Cash 0%)", () => {
|
||||
// V5-Modell: Einkommen = nominale Basis + nominale Lohnerhoehung; Ausgaben = REALE Basis +
|
||||
// reale Mehrausgaben, nominal = real x (plan-weite Inflation).
|
||||
describe("V5 Golden Tests", () => {
|
||||
it("Test 1 – Ansparen (Einkommen +2% nominal, Ausgaben real flach -> nominal +2%)", () => {
|
||||
const p = plan({
|
||||
age: 40,
|
||||
retirementAge: 60,
|
||||
phases: [{ id: "p1", durationYears: 15 }],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 120000, teuerungsausgleich: 2 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 100000, teuerungsausgleich: 2 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 100000, teuerungsausgleich: 0 } }),
|
||||
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 200000, expectedReturn: 5, annualContribution: 0 } }),
|
||||
],
|
||||
});
|
||||
const r = computePlan(p);
|
||||
const ph = r.phases[0];
|
||||
const ph = computePlan(p).phases[0];
|
||||
expect(within(ph.endWealthNominal, 761654, 0.01)).toBe(true);
|
||||
expect(within(ph.endWealthReal, 565928, 0.01)).toBe(true);
|
||||
expect(ph.cashNegative).toBe(false);
|
||||
});
|
||||
|
||||
it("Test 2 – Verzehr/Ruin: Gesamtvermoegen kippt, Ruin Alter 94", () => {
|
||||
it("Test 2 – Verzehr/Ruin: Ausgaben nominal +2%, Rente fix -> Ruin Alter 94", () => {
|
||||
const p = plan({
|
||||
age: 65,
|
||||
retirementAge: 65,
|
||||
phases: [{ id: "p1", durationYears: 35 }],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 60000, teuerungsausgleich: 0 } }), // Rente nominal fix
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 100000, teuerungsausgleich: 2 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 100000, teuerungsausgleich: 0 } }),
|
||||
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 900000, expectedReturn: 3, annualContribution: 0 } }),
|
||||
],
|
||||
});
|
||||
const r = computePlan(p);
|
||||
expect(r.ruinAge).toBe(94);
|
||||
expect(computePlan(p).ruinAge).toBe(94);
|
||||
});
|
||||
|
||||
it("Test 3 – Cash-Ausgleich, Rate 6'364 (unter Anfangsquote), Cash nie negativ", () => {
|
||||
it("Test 3 – Cash-Ausgleich, Rate 6'364, Cash nie negativ", () => {
|
||||
const p = plan({
|
||||
age: 40,
|
||||
retirementAge: 60,
|
||||
phases: [{ id: "p1", durationYears: 3 }],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 90000, teuerungsausgleich: 2 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 90000, teuerungsausgleich: 0 } }),
|
||||
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 0, expectedReturn: 0, annualContribution: 6364 } }),
|
||||
],
|
||||
});
|
||||
@@ -89,14 +89,14 @@ describe("V4 Golden Tests", () => {
|
||||
expect(ph.cashNegative).toBe(false);
|
||||
});
|
||||
|
||||
it("Test 4 – Cash-Ausgleich, Rate 10'000 (ueber Endquote), Cash wird negativ", () => {
|
||||
it("Test 4 – Cash-Ausgleich, Rate 10'000, Cash wird negativ", () => {
|
||||
const p = plan({
|
||||
age: 40,
|
||||
retirementAge: 60,
|
||||
phases: [{ id: "p1", durationYears: 3 }],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 90000, teuerungsausgleich: 2 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 90000, teuerungsausgleich: 0 } }),
|
||||
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 0, expectedReturn: 0, annualContribution: 10000 } }),
|
||||
],
|
||||
});
|
||||
@@ -105,23 +105,19 @@ describe("V4 Golden Tests", () => {
|
||||
expect(ph.cashNegative).toBe(true);
|
||||
});
|
||||
|
||||
it("Cash-Anfangswert fliesst in die erste Phase ein", () => {
|
||||
it("Ausgaben real -> nominal: reale Basis 100'000, 2% Inflation, 5 J.", () => {
|
||||
const p = plan({
|
||||
age: 40,
|
||||
retirementAge: 60,
|
||||
initialCash: 50000,
|
||||
phases: [{ id: "p1", durationYears: 3 }],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 100000, teuerungsausgleich: 0 } }),
|
||||
],
|
||||
phases: [{ id: "p1", durationYears: 5 }],
|
||||
elements: [el("EXPENSE", "HOUSEHOLD", { p1: { amount: 100000, teuerungsausgleich: 0 } })],
|
||||
});
|
||||
const ph = computePlan(p).phases[0];
|
||||
expect(ph.cashStart).toBe(50000);
|
||||
expect(ph.cashEnd).toBe(50000); // Quote 0, keine Raten -> Cash unveraendert
|
||||
expect(ph.expenseStart).toBe(100000); // Jahr 1 nominal = real
|
||||
expect(ph.expenseEnd).toBe(Math.round(100000 * Math.pow(1.02, 4))); // 108'243
|
||||
});
|
||||
|
||||
it("indexRate 0 -> Einkommen bleibt nominal flach", () => {
|
||||
it("Einkommen nominal flach bei Lohnerhoehung 0%", () => {
|
||||
const p = plan({
|
||||
age: 40,
|
||||
retirementAge: 60,
|
||||
@@ -133,7 +129,24 @@ describe("V4 Golden Tests", () => {
|
||||
expect(ph.incomeEnd).toBe(80000);
|
||||
});
|
||||
|
||||
it("Phasen-Fortschreibung: indexierter Endwert Phase 1 = Startwert Phase 2; Cash laeuft fort", () => {
|
||||
it("Cash-Anfangswert fliesst in die erste Phase ein", () => {
|
||||
const p = plan({
|
||||
age: 40,
|
||||
retirementAge: 60,
|
||||
inflation: 0,
|
||||
initialCash: 50000,
|
||||
phases: [{ id: "p1", durationYears: 3 }],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 100000, teuerungsausgleich: 0 } }),
|
||||
],
|
||||
});
|
||||
const ph = computePlan(p).phases[0];
|
||||
expect(ph.cashStart).toBe(50000);
|
||||
expect(ph.cashEnd).toBe(50000);
|
||||
});
|
||||
|
||||
it("Fortschreibung: nominaler Einkommens-Basiswert Phase 1 -> Startwert Phase 2; Cash laeuft fort", () => {
|
||||
const p = plan({
|
||||
age: 40,
|
||||
retirementAge: 70,
|
||||
@@ -143,7 +156,7 @@ describe("V4 Golden Tests", () => {
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 2 }, p2: {} }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 80000, teuerungsausgleich: 2 }, p2: {} }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 80000, teuerungsausgleich: 0 }, p2: {} }),
|
||||
],
|
||||
});
|
||||
const r = computePlan(p);
|
||||
|
||||
+53
-21
@@ -62,13 +62,24 @@ export interface PhaseComputed {
|
||||
elements: ElementPhaseComputed[];
|
||||
startWealthNominal: number; // inkl. Cash
|
||||
endWealthNominal: number; // inkl. Cash
|
||||
cumulativeInflationStart: number; // Kaufkraft-Deflator zu Phasenbeginn (Startwerte)
|
||||
cumulativeInflationEnd: number; // Kaufkraft-Deflator am Phasenende (Endwerte)
|
||||
cumulativeInflationStart: number; // Kaufkraft-Deflator zu Phasenbeginn (Bestandswerte)
|
||||
cumulativeInflationEnd: number; // Kaufkraft-Deflator am Phasenende (Bestandswerte)
|
||||
flowDeflatorEnd: number; // Deflator fuer den Flow-Endwert (Jahr `duration`)
|
||||
endWealthReal: number;
|
||||
}
|
||||
|
||||
// Ein Datenpunkt pro Jahr (ueber alle Phasen), fuer die Verlaufsgrafik.
|
||||
export interface YearPoint {
|
||||
year: number; // 1-basiert ab Planbeginn
|
||||
age: number; // Alter Person A am Jahresende
|
||||
income: number; // Einkommen inkl. Renten (nominal)
|
||||
expenseNominal: number;
|
||||
expenseReal: number;
|
||||
}
|
||||
|
||||
export interface PlanComputed {
|
||||
phases: PhaseComputed[];
|
||||
yearly: YearPoint[];
|
||||
nachlass: number;
|
||||
ruinAge: number | null; // Alter (Person A), in dem das Gesamtvermoegen (inkl. Cash) erstmals < 0 faellt
|
||||
}
|
||||
@@ -123,6 +134,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
for (const e of plan.elements) carries.set(e.id, emptyCarry());
|
||||
|
||||
const result: PhaseComputed[] = [];
|
||||
const yearly: YearPoint[] = [];
|
||||
let yearsBefore = 0;
|
||||
let cumulativeInflation = 1;
|
||||
let cashCarryIn = Math.round(plan.initialCash || 0);
|
||||
@@ -133,7 +145,9 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
const nextPhase = phases[i + 1];
|
||||
const isFirstPhase = i === 0;
|
||||
const duration = Math.max(1, phase.durationYears);
|
||||
const phaseInflation = phase.inflationRate ?? plan.inflationRateDefault;
|
||||
// Inflation ist plan-weit (V5): keine Phasen-Ueberschreibung mehr.
|
||||
const infl = plan.inflationRateDefault;
|
||||
const cumInflStart = cumulativeInflation; // Kaufkraft-Deflator zu Phasenbeginn
|
||||
|
||||
const personInfos: PersonPhaseInfo[] = persons.map((p) => {
|
||||
const ra = retirementAge.get(p.id)!;
|
||||
@@ -231,9 +245,11 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
switch (e.category) {
|
||||
case "INCOME":
|
||||
case "EXPENSE": {
|
||||
const idx = num(pd.teuerungsausgleich, phaseInflation);
|
||||
// Basiswert: Phase 1 = eingegebener Betrag; ab Phase 2 = fortgeschriebener Endwert
|
||||
// der Vorphase, ausser der Nutzer hat ihn bewusst geaendert (pd.amount gesetzt).
|
||||
// Einkommen: Basis + Rate sind NOMINAL (Lohnerhoehung). Ausgaben: Basis + Rate sind
|
||||
// REAL (heutige Kaufkraft; reale Mehrausgaben) -- die Inflation kommt separat dazu.
|
||||
// Rate-Default = 0 %. Basiswert ab Phase 2 = fortgeschriebener Wert der Vorphase
|
||||
// (nominal fuer Einkommen, real fuer Ausgaben), ausser bewusst geaendert (pd.amount).
|
||||
const idx = num(pd.teuerungsausgleich, 0);
|
||||
ec.baseValue = carry.hasCarry ? Math.round(carry.flowBasis) : Math.round(num(pd.amount));
|
||||
const basis = !carry.hasCarry
|
||||
? Math.round(num(pd.amount))
|
||||
@@ -241,6 +257,8 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
? Math.round(pd.amount)
|
||||
: ec.baseValue;
|
||||
(e.category === "INCOME" ? incomes : expenses).push({ basis, idx, ec });
|
||||
// Basiswert der Folgephase fortschreiben (nominal fuer Einkommen, real fuer Ausgaben).
|
||||
carry.flowBasis = basis * Math.pow(1 + idx / 100, duration);
|
||||
break;
|
||||
}
|
||||
case "AHV": {
|
||||
@@ -347,20 +365,32 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
let quotaEnd = 0;
|
||||
|
||||
for (let t = 1; t <= duration; t++) {
|
||||
// Einkommen: nominal (Basis x (1+Lohnerhoehung)^(t-1)) + Renten (nominal fix).
|
||||
let incomeFlow = renteTotal;
|
||||
for (const inc of incomes) incomeFlow += inc.basis * Math.pow(1 + inc.idx / 100, t - 1);
|
||||
let expenseFlow = 0;
|
||||
for (const exp of expenses) expenseFlow += exp.basis * Math.pow(1 + exp.idx / 100, t - 1);
|
||||
const quote = incomeFlow - expenseFlow;
|
||||
// Ausgaben: real (Basis x (1+reale Mehrausgabe)^(t-1)); nominal = real x kumul. Inflation.
|
||||
const inflFactor = cumInflStart * Math.pow(1 + infl / 100, t - 1);
|
||||
let expenseReal = 0;
|
||||
for (const exp of expenses) expenseReal += exp.basis * Math.pow(1 + exp.idx / 100, t - 1);
|
||||
const expenseNominal = expenseReal * inflFactor;
|
||||
const quote = incomeFlow - expenseNominal;
|
||||
|
||||
yearly.push({
|
||||
year: yearsBefore + t,
|
||||
age: personA.age + yearsBefore + t,
|
||||
income: Math.round(incomeFlow),
|
||||
expenseNominal: Math.round(expenseNominal),
|
||||
expenseReal: Math.round(expenseReal),
|
||||
});
|
||||
|
||||
if (t === 1) {
|
||||
incomeStart = incomeFlow;
|
||||
expenseStart = expenseFlow;
|
||||
expenseStart = expenseNominal;
|
||||
quotaStart = quote;
|
||||
}
|
||||
if (t === duration) {
|
||||
incomeEnd = incomeFlow;
|
||||
expenseEnd = expenseFlow;
|
||||
expenseEnd = expenseNominal;
|
||||
quotaEnd = quote;
|
||||
}
|
||||
|
||||
@@ -376,15 +406,19 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
if (ruinAge === null && total < 0) ruinAge = personA.age + yearsBefore + t;
|
||||
}
|
||||
|
||||
// Endwerte je Element setzen + Endvermoegen bilden.
|
||||
// Flow-Deflator fuer den Endwert (Jahr `duration`): eine Kaufkraft-Stufe weniger als der
|
||||
// Bestands-Deflator am Phasenende.
|
||||
const flowDeflatorEnd = cumInflStart * Math.pow(1 + infl / 100, duration - 1);
|
||||
|
||||
// Endwerte je Element setzen (Einkommen/Ausgaben nominal; Ausgaben-Nominal = real x Infl.).
|
||||
for (const inc of incomes) {
|
||||
inc.ec.startValue = Math.round(inc.basis);
|
||||
inc.ec.endValue = Math.round(inc.basis * Math.pow(1 + inc.idx / 100, duration - 1));
|
||||
inc.ec.summary = fmt(inc.ec.startValue);
|
||||
}
|
||||
for (const exp of expenses) {
|
||||
exp.ec.startValue = Math.round(exp.basis);
|
||||
exp.ec.endValue = Math.round(exp.basis * Math.pow(1 + exp.idx / 100, duration - 1));
|
||||
exp.ec.startValue = Math.round(exp.basis * cumInflStart);
|
||||
exp.ec.endValue = Math.round(exp.basis * Math.pow(1 + exp.idx / 100, duration - 1) * flowDeflatorEnd);
|
||||
exp.ec.summary = fmt(exp.ec.startValue);
|
||||
}
|
||||
for (const a of assets) {
|
||||
@@ -409,8 +443,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
const cashEnd = Math.round(cash);
|
||||
const startWealthNominal = Math.round(wealthStart + cashStart);
|
||||
const endWealthNominal = Math.round(wealthEnd + cashEnd);
|
||||
const cumulativeInflationStart = cumulativeInflation;
|
||||
cumulativeInflation = cumulativeInflation * Math.pow(1 + phaseInflation / 100, duration);
|
||||
cumulativeInflation = cumInflStart * Math.pow(1 + infl / 100, duration);
|
||||
|
||||
result.push({
|
||||
id: phase.id,
|
||||
@@ -435,8 +468,9 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
elements: orderedElements.map((e) => ecById.get(e.id)!),
|
||||
startWealthNominal,
|
||||
endWealthNominal,
|
||||
cumulativeInflationStart,
|
||||
cumulativeInflationStart: cumInflStart,
|
||||
cumulativeInflationEnd: cumulativeInflation,
|
||||
flowDeflatorEnd,
|
||||
endWealthReal: endWealthNominal / cumulativeInflation,
|
||||
});
|
||||
|
||||
@@ -452,10 +486,8 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
!!owner && !!nextPhase && workingByPerson.get(owner.id) === true &&
|
||||
retiresInPhase(owner.id, persons, retirementAge, yearsBefore + duration);
|
||||
|
||||
// Einkommen/Ausgaben: indexierten Basiswert fortschreiben.
|
||||
// Einkommen/Ausgaben: Basiswert wurde bereits im Element-Setup fortgeschrieben.
|
||||
if (e.category === "INCOME" || e.category === "EXPENSE") {
|
||||
const idx = num(pd.teuerungsausgleich, phaseInflation);
|
||||
carry.flowBasis = ec.startValue * Math.pow(1 + idx / 100, duration);
|
||||
carry.hasCarry = true;
|
||||
continue;
|
||||
}
|
||||
@@ -547,7 +579,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
|
||||
}
|
||||
|
||||
const nachlass = result.length > 0 ? result[result.length - 1].endWealthNominal : 0;
|
||||
return { phases: result, nachlass, ruinAge };
|
||||
return { phases: result, yearly, nachlass, ruinAge };
|
||||
}
|
||||
|
||||
function retiresInPhase(
|
||||
|
||||
Reference in New Issue
Block a user