Monte-Carlo-Simulation (Roadmap Nr. 19, Stufe A)
Deploy App / deploy (push) Successful in 57s

Button in der Planansicht -> Dialog: Erklaerung, Eingaben, Lauf, Ergebnis + Faecher.
Statt einer festen Rendite/Inflation werden tausende Zufallspfade gerechnet und die
Erfolgs-/Ruinwahrscheinlichkeit des Plans ausgewiesen.

Kern (computePlan-Nahtstelle):
- computePlan(plan, sample?) nimmt optional pro Jahr Inflation und pro Element/Jahr
  eine Rendite. Ohne sample bitgenau wie bisher (durch Golden Tests abgesichert).
- Dazu Inflation auf ein kumulatives Deflator-Array umgestellt (statt (1+i)^t), damit
  sie pro Jahr variieren kann. Deterministisch identisch.
- Reine Funktion ohne Server-Deps -> Simulation laeuft komplett im Browser, null
  Serverlast. ~10'000 Laeufe in ~1 s, Fortschritt alle 500 Laeufe (kein Freeze).

Statistik (montecarlo.ts):
- Fettschwaenzig (standardisierte Student-t, nu=5): Extremcrashs realistisch haeufig,
  eine Normalverteilung wuerde sie stark unterschaetzen.
- Gemeinsamer Marktschock (rho=0.7): riskante Anlagen fallen im Crash zusammen,
  nicht gegeneinander.
- Boeden: 0 % fuer PK/3a, -100 % sonst. Seedbar (reproduzierbar).
- Zwei Renditezahlen: geplante (Zielbalken) vs. historische (Streu-Mittelpunkt) --
  sonst waere P(>= geplantes Endvermoegen) immer ~50 %.

UI: Erklaerung, pro Element/Inflation historischer Oe (Pflicht, kein Default) +
Streuungsstufe (recherchierte sigma-Werte) + Anzahl Laeufe. Ergebnis: Ruin-/
Erfolgswahrscheinlichkeit, Faecher (10/50/90) + deterministische Linie.

7 MC-Tests inkl. deterministischer Aequivalenz, Vol-Drag, Reproduzierbarkeit, Boden,
Ruin (41 -> 48). Keine DB-Aenderung. Spezifikation auf v0.7 (Kap. 4.12, 9.15).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 22:47:18 +02:00
parent 5bf35bd6da
commit 71137f7cea
6 changed files with 922 additions and 17 deletions
+31 -10
View File
@@ -210,7 +210,16 @@ function personByRole<T extends { role: PersonRole }>(persons: T[], role: string
return persons.find((p) => p.role === role) ?? null;
}
export function computePlan(plan: PlanInput): PlanComputed {
// Ein Zufalls-Szenario fuer die Monte-Carlo-Simulation: liefert je Jahr eine Inflation und
// je Element/Jahr eine Rendite. Ohne Sample rechnet computePlan rein deterministisch (die
// geplanten Annahmen), mit Sample einen einzelnen simulierten Pfad. Jahr ist 1-basiert
// (ab Planbeginn); der Inflations-Index ist 0-basiert (inflation[0] = Jahr 1).
export interface PlanSample {
inflation: number[];
assetReturn: (elementId: string, year: number) => number;
}
export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed {
const phases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
const persons = plan.persons;
const personA = persons.find((p) => p.role === "PERSON_A") ?? persons[0];
@@ -218,6 +227,15 @@ export function computePlan(plan: PlanInput): PlanComputed {
const retirementAge = new Map<string, number>();
for (const p of persons) retirementAge.set(p.id, p.retirementAge);
// Kumulierter Inflations-Deflator je Jahr (cumInfl[0] = 1, cumInfl[k] = Kaufkraftfaktor nach
// k Jahren). Deterministisch identisch zur bisherigen (1+infl)^k-Formel; mit Sample variiert
// die Inflation pro Jahr. Ersetzt die frueheren geschlossenen Potenz-Ausdruecke.
const totalYears = phases.reduce((s, p) => s + Math.max(1, p.durationYears), 0);
const inflationOfYear = (year: number) =>
sample ? sample.inflation[year - 1] ?? plan.inflationRateDefault : plan.inflationRateDefault;
const cumInfl: number[] = [1];
for (let y = 1; y <= totalYears; y++) cumInfl[y] = cumInfl[y - 1] * (1 + inflationOfYear(y) / 100);
const gapYearsByPerson = new Map<string, number>();
// AHV-Beitragskarriere je Person: reales Einkommen x Beitragsjahre, und Beitragsjahre.
const ahvIncomeAccum = new Map<string, number>();
@@ -247,9 +265,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
const nextPhase = phases[i + 1];
const isFirstPhase = i === 0;
const duration = Math.max(1, phase.durationYears);
// Inflation ist plan-weit (V5): keine Phasen-Ueberschreibung mehr.
const infl = plan.inflationRateDefault;
const cumInflStart = cumulativeInflation; // Kaufkraft-Deflator zu Phasenbeginn
const cumInflStart = cumInfl[yearsBefore]; // Kaufkraft-Deflator zu Phasenbeginn
const personInfos: PersonPhaseInfo[] = persons.map((p) => {
const ra = retirementAge.get(p.id)!;
@@ -404,7 +420,10 @@ export function computePlan(plan: PlanInput): PlanComputed {
const attributed =
owner ?? (plan.householdType === "SINGLE" && e.ownerRole === "HOUSEHOLD" ? personA : null);
if (attributed) {
const avgRealGross = avgRealFlow(basis, idx, infl, duration, cumInflStart) * AHV_GROSS_FROM_NET_FACTOR;
// Die AHV-Karriere ist eine Real-Groesse auf Planungsbasis -- bewusst mit der
// festen Plan-Inflation, nicht der (evtl. gewuerfelten) Sample-Inflation.
const avgRealGross =
avgRealFlow(basis, idx, plan.inflationRateDefault, duration, cumInflStart) * AHV_GROSS_FROM_NET_FACTOR;
phaseRealIncomeByPerson.set(
attributed.id,
(phaseRealIncomeByPerson.get(attributed.id) ?? 0) + avgRealGross
@@ -553,7 +572,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
let incomeFlow = renteTotal;
for (const inc of incomes) incomeFlow += inc.basis * Math.pow(1 + inc.idx / 100, t - 1);
// Ausgaben: real (Basis x (1+reale Mehrausgabe)^(t-1)); nominal = real x kumul. Inflation.
const inflFactor = cumInflStart * Math.pow(1 + infl / 100, t - 1);
const inflFactor = cumInfl[yearsBefore + t - 1];
let expenseRealBase = 0;
for (const exp of expenses) expenseRealBase += exp.basis * Math.pow(1 + exp.idx / 100, t - 1);
@@ -592,7 +611,8 @@ export function computePlan(plan: PlanInput): PlanComputed {
// Vermoegen verzinsen + Sparbeitrag; Bezugsrate entnehmen (gekappt am Bestand) und ins Cash.
let cashFromWithdraw = 0;
for (const a of assets) {
const grown = a.value * (1 + a.r / 100) + a.rate;
const r = sample ? sample.assetReturn(a.ec.elementId, yearsBefore + t) : a.r;
const grown = a.value * (1 + r / 100) + a.rate;
const w = Math.min(a.withdrawal, Math.max(0, grown));
a.value = grown - w;
cashFromWithdraw += w;
@@ -607,7 +627,8 @@ export function computePlan(plan: PlanInput): PlanComputed {
debtRates += pay;
// Wertsteigerung wirkt auf die LIEGENSCHAFT, nicht auf das Eigenkapital -- das ist der
// Hebel: 1 % von 1 Mio sind 10'000, also 10 % eines Eigenkapitals von 100'000.
re.value *= 1 + re.growth / 100;
const g = sample ? sample.assetReturn(re.ec.elementId, yearsBefore + t) : re.growth;
re.value *= 1 + g / 100;
}
for (const d of debts) {
const pay = Math.min(d.repay, d.owed);
@@ -629,7 +650,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
// 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);
const flowDeflatorEnd = cumInfl[yearsBefore + duration - 1];
// Endwerte je Element setzen (Einkommen/Ausgaben nominal; Ausgaben-Nominal = real x Infl.).
for (const inc of incomes) {
@@ -663,7 +684,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
const cashEnd = Math.round(cash);
const startWealthNominal = Math.round(wealthStart + cashStart);
const endWealthNominal = Math.round(wealthEnd + cashEnd);
cumulativeInflation = cumInflStart * Math.pow(1 + infl / 100, duration);
cumulativeInflation = cumInfl[yearsBefore + duration];
result.push({
id: phase.id,