V5-Modell: Einkommen nominal, Ausgaben real->nominal, Inflation plan-weit, Sparquoten-Grafik
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:
2026-07-15 22:20:06 +02:00
parent 310ebc66fb
commit 6402583e0c
7 changed files with 212 additions and 80 deletions
+53 -21
View File
@@ -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(