diff --git a/SPEZIFIKATION.md b/SPEZIFIKATION.md index 27eaf99..ed1d35b 100644 --- a/SPEZIFIKATION.md +++ b/SPEZIFIKATION.md @@ -4,10 +4,10 @@ | | | |---|---| | **Dokument** | Funktionale und Technische Spezifikation FPT | -| **Version** | 0.6 | +| **Version** | 0.7 | | **Datum** | 2026-07-17 | | **Status** | Lebendes Dokument | -| **Codestand** | Arbeitsstand nach `a97de5b` inkl. Teilverkauf (Sonstiges Vermögen) und Sonderamortisation (Immobilie) (Branch `main`) | +| **Codestand** | Arbeitsstand nach `5bf35bd` inkl. Monte-Carlo-Simulation (Stufe A) (Branch `main`) | | **Ersetzt** | `FDD_TDD_FPT.docx` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet | | **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` | @@ -17,6 +17,7 @@ | Version | Datum | Autor | Änderung | |---|---|---|---| +| 0.7 | 2026-07-17 | Claude (Opus 4.8) | **Monte-Carlo-Simulation** (Roadmap Nr. 19, Stufe A). Button in der Planansicht → Dialog mit Erklärung, Eingaben und Ergebnis. Statt einer festen Rendite/Inflation werden tausende Zufallspfade gerechnet; ausgewiesen werden **Ruinwahrscheinlichkeit**, **Erfolgswahrscheinlichkeit** (P(Endvermögen ≥ Zielbetrag)) und ein **Fächer** (10 %/Median/90 %) plus die deterministische Linie. Läuft komplett im Browser (`computePlan` ist rein). Modell: fettschwänzige Verteilung (Student-t, ν=5), gemeinsamer Marktschock (ρ=0.7), Böden 0 % für PK/3a und −100 % sonst. Pro renditetragendem Element und für die Inflation je: historischer Ø (Pflicht), Streuungsstufe, σ. Neue Datei `montecarlo.ts` + optionaler `sample`-Parameter in `computePlan` (Inflation neu als kumulatives Array; deterministisch identisch). Neues Kapitel 4.12; 7 MC-Tests + Refactor-Absicherung (41 → 48). Keine Verhaltensänderung, keine DB-Änderung. | | 0.6 | 2026-07-17 | Claude (Opus 4.8) | **Teilverkauf** von Sonstigem Vermögen (Roadmap Nr. 42) und **Sonderamortisation** der Hypothek (Roadmap Nr. 15). `OTHER_ASSET` am Übergang neu: Halten / Verkaufen / **Teilverkauf** – ein Betrag fliesst ins Cash (erscheint als „Kapitalzufluss" im Phasenkopf), der Rest bleibt investiert. `REAL_ESTATE` im Halten-Fall neu mit **Einmaltilgung** aus dem Cash (analog zur Sofort-Tilgung bei Schulden; erscheint als „Kapitalinvestition"). Damit lässt sich die indirekte Amortisation via 3a mechanisch nachbilden. Die eigentliche Roadmap Nr. 15 (Steuerwirkung der indirekten Amortisation) bleibt mit dem Steuer-Bündel 13/14/23 zurückgestellt – siehe 9.14. Kapitel 4.9.3/4.9.4 ergänzt. Fünf Regressionstests (36 → 41). Keine Verhaltensänderung für bestehende Pläne. | | 0.5 | 2026-07-17 | Claude (Opus 4.8) | **Netto/Brutto geklärt** (Roadmap Nr. 9, reduziert) und **Immobilien-Modul erweitert** (Roadmap Nr. 8). Einkommen ist neu explizit als **Nettolohn** definiert (Label und Hilfetext); für die AHV rechnet das Tool intern mit `AHV_GROSS_FROM_NET_FACTOR = 1.12` auf den Bruttolohn hoch – die AHV bemisst sich am Brutto, die bisherige Netto-Basis unterschätzte die Rente um bis zu ~1'900/Jahr. Immobilie neu mit **Hypothekarzins** (% der Restschuld, sinkt mit der Amortisation, mit Doppelzählungs-Schalter) und **Wertsteigerung** (auf die **Liegenschaft**, nicht auf das Eigenkapital – Hebeleffekt). Grundstückgewinnsteuer bemisst sich neu explizit am ursprünglichen Kaufpreis. Keine Aufschlüsselung bei Einkommen oder Ausgaben, keine Steuerschätzung (Begründung: 9.14). Neues Kapitel 4.4.5; 4.6.5 und 4.9.4 überarbeitet; Abschnitt 9 um zwei Punkte ergänzt. Sechs Regressionstests (30 → 36). **Verhaltensänderung:** siehe 9.13. | | 0.4 | 2026-07-17 | Claude (Opus 4.8) | **AHV-Rente einkommensabhängig** (Roadmap Nr. 3) und **Fortschreibungs-Warnhinweis** (Roadmap Nr. 4). Die AHV-Rente folgt neu der amtlichen Rentenformel (Skala 44) über das massgebende durchschnittliche Jahreseinkommen statt pauschal der Maximalrente; verifiziert gegen die amtliche Tabelle 318.117.1 (51/51 Zeilen). Prüfung der Beitragskarriere am Pensions-Übergang, mit Zusatzfeldern für die Jahre vor Planbeginn (ab Alter 21). Alles **real** gerechnet. Neue Konstanten `AHV_MIN_MONTHLY_FULL`, `AHV_PENSION_MONTHS`, `AHV_CONTRIBUTION_START_AGE`; `AHV_MAX_ANNUAL_SINGLE` neu abgeleitet. Warnhinweis in Phasenzellen und Phasen-Detail, wenn Folgephasen existieren. Neue Kapitel 3.5.6, 4.4; Abschnitt 9 um zwei Punkte ergänzt. Zwölf Regressionstests (18 → 30). **Verhaltensänderung:** siehe 9.10. | @@ -1418,6 +1419,106 @@ Die drei Default-Sätze werden **sowohl als UI-Vorschlag als auch in der Berechn verwendet (`num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE)`). Grund laut Code-Kommentar: Damit ein nicht angetippter Wert nicht fälschlich als 0 gerechnet wird. +## 4.12 Monte-Carlo-Simulation + +Die deterministische Berechnung nimmt pro Anlage *eine* feste Rendite und *eine* feste Inflation +an. Real schwanken beide. Die Monte-Carlo-Simulation (Roadmap Nr. 19, Stufe A, `montecarlo.ts`) +würfelt viele tausend mögliche Verläufe und weist die **Erfolgswahrscheinlichkeit** des Plans aus. + +### 4.12.1 Die Nahtstelle in `computePlan` + +`computePlan(plan, sample?)` nimmt optional ein `PlanSample`: + +```ts +interface PlanSample { + inflation: number[]; // Inflation %/Jahr (Index 0 = Jahr 1) + assetReturn: (elementId: string, year: number) => number; // Rendite %/Jahr (1-basiert) +} +``` + +Ohne `sample` rechnet die Funktion **exakt wie bisher** (die geplanten Annahmen). Mit `sample` +liefert sie einen einzelnen simulierten Pfad. Voraussetzung war eine Umstellung der Inflation +auf ein **kumulatives Deflator-Array** `cumInfl[]` (statt der geschlossenen `(1+i)^t`-Formel), +damit die Inflation pro Jahr variieren kann – deterministisch bitgenau identisch, durch die +Golden Tests abgesichert. Die AHV-Karriere bleibt bewusst auf der festen Plan-Inflation (sie +ist eine Real-Grösse auf Planungsbasis, sie wird nicht mitgewürfelt). + +**Architektur-Vorteil:** `computePlan` ist eine reine Funktion ohne Server-Abhängigkeiten und +läuft damit **im Browser**. Die gesamte Simulation rechnet client-seitig – null Serverlast. +~10'000 Läufe in rund 1 Sekunde; die Ausführung gibt alle 500 Läufe die Kontrolle ab +(Fortschrittsbalken, keine eingefrorene Oberfläche). + +### 4.12.2 Das statistische Modell + +Pro Jahr ein **gemeinsamer Marktschock** `z_markt`; je Element und Jahr: + +``` +rendite = mittelwert + σ × (ρ × z_markt + √(1−ρ²) × z_eigen) +``` + +- `z_markt`, `z_eigen`: **standardisierte Student-t** (ν = 5) – „fette Ränder", damit + Extremcrashs realistisch häufig auftreten. Eine Normalverteilung macht ein −40%-Jahr zu einem + 1-in-250-Ereignis; real ist es ~1-in-15. Standardisiert auf Einheitsvarianz → die eingegebene + Standardabweichung σ bleibt die tatsächliche. +- `ρ = 0.7` → Korrelation zweier riskanter Anlagen ≈ 0.5: **alle riskanten Anlagen fallen im + Crash gemeinsam.** Unabhängiges Würfeln würde das Absturzrisiko systematisch unterschätzen. +- **Böden:** `max(0, rendite)` für PK/3a (schreiben keine negative Rendite gut), + `max(−100, rendite)` sonst. + +Die Inflation wird analog gezogen (eigener Student-t-Schock, Mittelwert + σ), unabhängig vom +Marktschock. Der Zufallsgenerator ist **seedbar** (reproduzierbare Läufe). + +### 4.12.3 Zwei Renditezahlen — und warum + +Pro renditetragendem Element (PK, 3a, Sonstiges Vermögen, Immobilie) gibt es im Simulations-Dialog +**zwei** Renditen mit verschiedenen Rollen: + +| Zahl | Rolle | +|---|---| +| **Geplante Rendite** (im Plan) | zeichnet die deterministische Linie = der *Zielbalken* | +| **Historische Ø-Rendite** (im Dialog, Pflicht) | der Mittelpunkt, um den die Simulation streut | + +Ohne diese Trennung wäre die Kennzahl „P(erreiche mein geplantes Endvermögen)" **immer ~50 %**, +egal welche Rendite man annimmt (der Zielbetrag wüchse ja mit). Erst weil die Simulation um die +*historische* Rendite streut, während der Zielbalken auf der *geplanten* steht, wird ein +konservativer Plan (tiefe Planannahme) korrekt mit einer höheren Erfolgsquote belohnt als ein +optimistischer. Analog auf Plan-Ebene für die Inflation. + +**Ehrliche Grenze (im Dialog ausgewiesen):** Die Simulation misst das Risiko *um deine Annahmen +herum* – sie beurteilt **nicht**, ob deine Mittelwerte realistisch sind. Ein zu optimistischer +Mittelwert liefert eine beruhigende, aber unrealistische Erfolgsquote (siehe 9.15). + +### 4.12.4 Streuungsstufen + +| Stufe (Rendite) | σ | Beispiele | | Stufe (Inflation) | σ | +|---|---|---|---|---|---| +| Sehr niedrig | 3 % | Staatsanleihen, Geldmarkt | | Sehr niedrig | 1 % | +| Niedrig | 6 % | Immobilien, defensive Mischportfolios | | Niedrig | 2 % | +| Moderat | 15 % | breit diversifizierte Aktien-ETFs/Fonds | | Manuell | frei | +| Hoch | 25 % | Einzelaktien, Branchen-/Schwellenländerfonds | | | | +| Sehr hoch | 55 % | Kryptowährungen, hochspekulative Anlagen | | | | +| Manuell | frei | eigene Eingabe | | | | + +Default-Stufe je Typ: PK → sehr niedrig · 3a/Immobilie → niedrig · Sonstiges Vermögen → moderat. +Bei Inflation gibt es bewusst nur zwei Stufen (höhere wären Hyperinflations-Annahmen). + +Quellen der σ-Werte: Anleihen ~6 %, globale Aktien ~15–18 %, Schweizer Immobilien(fonds) ~2 %, +Bitcoin ~54 %, Schweizer Inflation SD der letzten 20 J. ~1 %. Belege: BSV/Weltbank sowie +Markt-/Volatilitätsstatistiken (recherchiert 2026-07-17). + +### 4.12.5 Ergebnis + +| Kennzahl | Bedeutung | +|---|---| +| **Ruinwahrscheinlichkeit** | Anteil der Läufe mit `ruinAge !== null` (Vermögen fällt vor Planende unter 0) | +| **Erfolgswahrscheinlichkeit** | Anteil der Läufe mit Endvermögen ≥ Zielbetrag (nominal, vorbelegt mit dem geplanten Nachlass) | +| **Fächer** | je Alterspunkt (Phasengrenzen) das 10-/50-/90-Perzentil des Vermögens; dazu die deterministische Planungslinie | + +Der Median liegt typischerweise **unter** der deterministischen Linie – der „Volatilitäts-Drag" +(`geometrisch ≈ arithmetisch − σ²/2`) macht sichtbar, dass die glatte Ein-Zahl-Planung schon +leicht zu optimistisch ist. Pflichtfelder: ohne die historischen Ø-Werte startet die Simulation +nicht. + --- # 5. Technische Spezifikation @@ -1489,6 +1590,7 @@ PlanComputed ← an den Client geliefert | `elements.ts` | Kategorien, Labels, Reihenfolge, JSON-Payload-Typen, Zod-Schemas, `num()` | | `types.ts` | Domänentypen für API und Berechnung | | `constants.ts` | Schweizer Systemparameter | +| `montecarlo.ts` | Monte-Carlo-Simulation (Sampler + Treiber). Keine I/O, läuft im Browser. | | `queries.ts` | Prisma-Includes, `toPlanInput()`, Ownership-Abfragen | | `db.ts` | Prisma-Singleton (Global-Cache im Dev, verhindert Connection-Leak bei HMR) | | `auth.ts` | JWT erzeugen/prüfen (Edge-kompatibel via `jose`) | @@ -1712,6 +1814,7 @@ wird der Plan neu geladen; die Berechnung kommt immer vom Server. | `PlanProfileFields` | 101 | Wiederverwendete Grundprofil-Felder | | `PhaseDetail` | 95 | Phase bearbeiten/löschen | | `SparquoteChart` | 64 | Einkommen/Ausgaben pro Jahr mit Spar-/Verzehrbändern | +| `MonteCarloDialog` | ~430 | Monte-Carlo-Dialog: Erklärung, Eingaben, Lauf, Ergebnis + Fächer | | `InfoBubble` | 28 | Hilfe-Tooltip | ### 5.5.3 Wiederverwendungsmuster @@ -1919,10 +2022,11 @@ Zielumgebung: Hetzner CX23, Traefik als Reverse Proxy, Domain `fpt.aicds.ch`, Gi ## 8.1 Teststrategie Getestet wird ausschliesslich der Berechnungskern – bewusst, da dort die Fachlogik und das -Regressionsrisiko liegen. `src/lib/calculations.test.ts` enthält 41 Tests (AHV-Rentenformel, -Immobilie, Teilverkauf, Sonderamortisation, AHV einkommensabhängig, „V5 Golden Tests"), -ausgeführt mit Vitest in der Node-Umgebung (`vitest.config.ts`, Include `src/**/*.test.ts`). -Es gibt **keine** Komponenten-, API- oder E2E-Tests. +Regressionsrisiko liegen. `src/lib/calculations.test.ts` (41 Tests: AHV-Rentenformel, Immobilie, +Teilverkauf, Sonderamortisation, AHV einkommensabhängig, „V5 Golden Tests") und +`src/lib/montecarlo.test.ts` (7 Tests) ergeben zusammen **48 Tests**, ausgeführt mit Vitest in +der Node-Umgebung (`vitest.config.ts`, Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, +API- oder E2E-Tests. ## 8.2 Testfälle @@ -1945,6 +2049,10 @@ Es gibt **keine** Komponenten-, API- oder E2E-Tests. | **Immobilie: Verkauf** | Grundstückgewinnsteuer auf `Verkaufspreis − Kaufpreis`, nicht auf den Verkehrswert | | **Teilverkauf (42)** | Betrag ins Cash (→ `capitalInflow`), Rest bleibt aktiv; am Endwert gekappt; Halten/Vollverkauf unverändert | | **Sonderamortisation (15)** | Einmaltilgung senkt Restschuld, belastet Cash (→ `capitalInvest`), am Restsaldo gekappt | +| **MC: Determinismus** | Streuung 0 reproduziert exakt das deterministische Ergebnis (Bänder kollabieren) | +| **MC: Volatilität / Vol-Drag** | σ > 0 spreizt p10 void; +}) { + const empty = value.trim() === ""; + return ( +
+ + onChange(e.target.value)} + className={`w-full rounded-lg border bg-input px-2.5 py-1.5 text-sm text-fg shadow-sm focus:outline-none focus:ring-2 focus:ring-accent/25 ${ + empty ? "border-danger" : "border-border focus:border-accent" + }`} + /> + {empty &&

Pflichtfeld – bitte ausfüllen.

} +
+ ); +} + +type ElementInput = { mean: string; level: ReturnVolatilityLevel; manualSigma: string }; + +function returnSigma(el: ElementInput): number { + return el.level === "manuell" + ? Number(el.manualSigma) || 0 + : RETURN_VOLATILITY_LEVELS[el.level]; +} +function inflationSigma(level: InflationVolatilityLevel, manual: string): number { + return level === "manuell" ? Number(manual) || 0 : INFLATION_VOLATILITY_LEVELS[level]; +} + +export function MonteCarloDialog({ + plan, + computed, + onClose, +}: { + plan: PlanInput; + computed: PlanComputed; + onClose: () => void; +}) { + const returnElements = useMemo( + () => plan.elements.filter((e) => RETURN_BEARING.includes(e.category)), + [plan.elements] + ); + + const [runs, setRuns] = useState(1000); + const [inflMean, setInflMean] = useState(""); + const [inflLevel, setInflLevel] = useState("sehr_niedrig"); + const [inflManual, setInflManual] = useState("1"); + const [target, setTarget] = useState(Math.max(0, computed.nachlass)); + const [els, setEls] = useState>(() => + Object.fromEntries( + returnElements.map((e) => [e.id, { mean: "", level: defaultVolatilityLevel(e.category), manualSigma: "10" }]) + ) + ); + + const [running, setRunning] = useState(false); + const [progress, setProgress] = useState(0); + const [result, setResult] = useState(null); + + function setEl(id: string, patch: Partial) { + setEls((prev) => ({ ...prev, [id]: { ...prev[id], ...patch } })); + } + + // Pflichtfelder: historische Inflation + je Element die historische Rendite muessen gesetzt sein. + const missing = + inflMean.trim() === "" || returnElements.some((e) => (els[e.id]?.mean ?? "").trim() === ""); + + // Deterministische Endwert-Linie fuer den Vergleich im Faecher. + const detPoints = useMemo(() => { + const startAge = plan.persons.find((p) => p.role === "PERSON_A")?.age ?? plan.persons[0]?.age ?? 0; + const pts: { age: number; det: number }[] = []; + if (computed.phases.length > 0) { + pts.push({ age: startAge, det: computed.phases[0].startWealthNominal }); + let acc = 0; + for (const ph of computed.phases) { + acc += ph.durationYears; + pts.push({ age: startAge + acc, det: ph.endWealthNominal }); + } + } + return pts; + }, [computed.phases, plan.persons]); + + const estSeconds = Math.max(1, Math.round(runs / 6000)); + + async function run() { + setRunning(true); + setResult(null); + setProgress(0); + try { + const elements = Object.fromEntries( + returnElements.map((e) => { + const ei = els[e.id]; + return [e.id, { mean: Number(ei.mean) || 0, sigma: returnSigma(ei), floor: floorFor(e.category) }]; + }) + ); + const res = await runMonteCarlo( + plan, + { + runs, + inflationMean: Number(inflMean) || 0, + inflationSigma: inflationSigma(inflLevel, inflManual), + elements, + target, + }, + (done, total) => setProgress(done / total) + ); + setResult(res); + } finally { + setRunning(false); + } + } + + const chartData = useMemo(() => { + if (!result) return []; + return result.bands.map((b) => ({ + age: b.age, + // Range-Flaeche als [unten, oben]-Tupel -- robust auch bei negativem p10 (Ruin-Faelle). + band: [b.p10, b.p90] as [number, number], + median: b.p50, + det: detPoints.find((d) => d.age === b.age)?.det ?? null, + })); + }, [result, detPoints]); + + return ( +
+
e.stopPropagation()} + className="flex w-full max-w-3xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl" + > +
+

+ Monte-Carlo-Simulation +

+ +
+ + {/* Erklaerung */} +
+

+ Was ist das? Dein Plan rechnet mit einer festen Rendite und Inflation + pro Jahr. Real schwanken beide. Die Simulation würfelt viele tausend mögliche + Verläufe und zeigt, wie oft dein Plan aufgeht. +

+

+ Eingabe: je Anlage die historische Durchschnittsrendite (der Mittelpunkt, + um den gewürfelt wird) und wie stark sie schwankt; dazu dieselben Angaben für die Inflation. + Ergebnis: die Wahrscheinlichkeit, dass das Geld reicht bzw. dein Zielbetrag + erreicht wird, plus ein Fächer vom pessimistischen bis zum optimistischen Fall. +

+

+ Verteilung: Renditen werden mit «fetten Rändern» gezogen (Extremcrashs so + häufig wie in der Realität, nicht wie in der Glockenkurve), und alle riskanten Anlagen fallen in einem + Marktschock gemeinsam. Die Prozentzahl gilt immer nur relativ zu deinen Annahmen — sie beurteilt nicht, + ob deine Durchschnittswerte realistisch sind. +

+
+ + {/* Inflation */} +
+
Inflation (Plan-Ebene)
+
+ + setInflLevel(v)} + options={INFLATION_LEVEL_OPTIONS} + /> + {inflLevel === "manuell" ? ( + setInflManual(String(v))} /> + ) : ( + + )} +
+
+ + {/* Elemente */} +
+
Renditetragende Elemente
+ {returnElements.length === 0 && ( +

+ Dieser Plan hat keine renditetragenden Elemente (PK, 3a, Sonstiges Vermögen, Immobilie). +

+ )} +
+ {returnElements.map((e) => { + const ei = els[e.id]; + return ( +
+
+ {e.name} + {CATEGORY_LABELS[e.category]} +
+
+ setEl(e.id, { mean: v })} + /> + setEl(e.id, { level: v })} + options={RETURN_LEVEL_OPTIONS} + /> + {ei.level === "manuell" ? ( + setEl(e.id, { manualSigma: String(v) })} /> + ) : ( + + )} +
+ {(e.category === "PENSION_FUND" || e.category === "PILLAR_3A") && ( +

Boden 0 %: {CATEGORY_LABELS[e.category]} schreibt keine negative Rendite gut.

+ )} +
+ ); + })} +
+
+ + {/* Lauf-Parameter */} +
+
+ setRuns(Number(v))} + options={[ + { value: "1000", label: "1'000 (schnell)" }, + { value: "5000", label: "5'000" }, + { value: "10000", label: "10'000 (genau)" }, + ]} + /> +

geschätzt ~{estSeconds} s

+
+ +
+ + {missing &&

Bitte alle Pflichtfelder (Ø-Werte) ausfüllen, um die Simulation zu starten.

} + +
+ + {running && ( +
+
+
+ )} +
+ + {result && } +
+
+ ); +} + +function ReadOnlySigma({ value }: { value: number }) { + return ( +
+ +
+ {value} % +
+
+ ); +} + +function MonteCarloResults({ + result, + target, + chartData, +}: { + result: MonteCarloResult; + target: number; + chartData: { age: number; band: [number, number]; median: number; det: number | null }[]; +}) { + return ( +
+
+ + + +
+ +
+ + + +
+ +
+
Vermögensfächer nach Alter (nominal)
+

+ Das Band reicht vom pessimistischen (10 %) bis zum optimistischen (90 %) Fall, die dunkle Linie ist der Median. + Die gestrichelte Linie ist deine deterministische Planung – sie liegt meist leicht über dem Median (Schwankung + frisst Rendite). +

+
+ + + + `${v} J.`} /> + Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} /> + [typeof v === "number" ? formatChf(v) : v, name]} + labelFormatter={(v) => `Alter ${v}`} + /> + + + + + + +
+
+
+ ); +} + +function Stat({ label, value, help, good, danger }: { label: string; value: string; help?: string; good?: boolean; danger?: boolean }) { + return ( +
+
+ {label} + {help && } +
+
{value}
+
+ ); +} + +function Band({ label, value }: { label: string; value: number }) { + return ( +
+
{label}
+
{formatChf(value)}
+
+ ); +} diff --git a/src/components/PlanView.tsx b/src/components/PlanView.tsx index a56c96f..01598cd 100644 --- a/src/components/PlanView.tsx +++ b/src/components/PlanView.tsx @@ -8,6 +8,7 @@ import { ChevronDown, ChevronRight, CreditCard, + Dices, Home, Landmark, PiggyBank, @@ -32,6 +33,7 @@ import { type CellContext, } from "@/components/ElementDetail"; import { PhaseDetail } from "@/components/PhaseDetail"; +import { MonteCarloDialog } from "@/components/MonteCarloDialog"; import { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFields"; import { MoneyField } from "@/components/FormField"; import { api } from "@/lib/api-client"; @@ -104,6 +106,7 @@ export function PlanView({ const [editTransition, setEditTransition] = useState<{ elementId: string; fromPhaseId: string } | null>(null); const [editPhaseCell, setEditPhaseCell] = useState<{ elementId: string; phaseId: string } | null>(null); const [showCashInit, setShowCashInit] = useState(false); + const [showMonteCarlo, setShowMonteCarlo] = useState(false); // fromPhaseId des Cash-Uebergangs, der gerade bearbeitet wird. const [editCashTransition, setEditCashTransition] = useState(null); const [valueMode, setValueMode] = useState("nominal"); @@ -350,6 +353,13 @@ export function PlanView({ > Lebensphase +
)} @@ -570,6 +580,10 @@ export function PlanView({ /> )} + {showMonteCarlo && ( + setShowMonteCarlo(false)} /> + )} + {editCashTransition && (() => { const fromPhase = computed.phases.find((p) => p.id === editCashTransition); if (!fromPhase) return null; diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts index b6fa557..3f7db1a 100644 --- a/src/lib/calculations.ts +++ b/src/lib/calculations.ts @@ -210,7 +210,16 @@ function personByRole(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(); 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(); // AHV-Beitragskarriere je Person: reales Einkommen x Beitragsjahre, und Beitragsjahre. const ahvIncomeAccum = new Map(); @@ -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, diff --git a/src/lib/montecarlo.test.ts b/src/lib/montecarlo.test.ts new file mode 100644 index 0000000..75a1fe4 --- /dev/null +++ b/src/lib/montecarlo.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from "vitest"; +import { computePlan } from "@/lib/calculations"; +import { runMonteCarlo, defaultVolatilityLevel, RETURN_VOLATILITY_LEVELS, type MonteCarloParams } from "@/lib/montecarlo"; +import type { PlanInput } from "@/lib/types"; + +// Plan: 40-jaehrig, 1 Phase 10 Jahre, ein Sonstiges Vermoegen 100'000 @ 5 %, 2 % Inflation. +// Deterministisch: 100'000 x 1.05^10 = 162'889 Endvermoegen, kein Ruin. +function basePlan(): PlanInput { + return { + id: "p", name: "T", householdType: "SINGLE", inflationRateDefault: 2, initialCash: 0, + persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 70 }], + phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 10, cashTransition: {} }], + elements: [ + { + id: "asset", category: "OTHER_ASSET", name: "ETF", ownerRole: "HOUSEHOLD", orderIndex: 1, + phaseValues: { p1: { startValue: 100000, expectedReturn: 5, annualContribution: 0 } }, + transitionValues: {}, + }, + ], + }; +} + +function params(over: Partial = {}): MonteCarloParams { + return { + runs: 2000, + inflationMean: 2, + inflationSigma: 0, + elements: { asset: { mean: 5, sigma: 0, floor: -100 } }, + target: 0, + seed: 12345, + ...over, + }; +} + +const detEnd = () => computePlan(basePlan()).phases[0].endWealthNominal; + +describe("Monte Carlo", () => { + it("Streuung 0 reproduziert exakt das deterministische Ergebnis", async () => { + const r = await runMonteCarlo(basePlan(), params()); + const det = detEnd(); + expect(r.finalWealthMedian).toBe(det); + expect(r.finalWealthP10).toBe(det); + expect(r.finalWealthP90).toBe(det); // Baender kollabieren auf die deterministische Linie + expect(r.ruinProbability).toBe(0); + expect(r.bands[r.bands.length - 1].p50).toBe(det); + }); + + it("Volatilitaet spreizt den Faecher (p90 > p10)", async () => { + const r = await runMonteCarlo(basePlan(), params({ elements: { asset: { mean: 5, sigma: 20, floor: -100 } } })); + expect(r.finalWealthP90).toBeGreaterThan(r.finalWealthP10); + // Median bleibt in der Naehe des deterministischen Werts (leicht darunter wegen Vol-Drag). + expect(r.finalWealthMedian).toBeLessThan(r.finalWealthP90); + expect(r.finalWealthMedian).toBeGreaterThan(r.finalWealthP10); + }); + + it("Erfolgswahrscheinlichkeit sinkt mit steigendem Zielbetrag", async () => { + const opts = { elements: { asset: { mean: 5, sigma: 20, floor: -100 } } }; + const low = await runMonteCarlo(basePlan(), params({ ...opts, target: 50000 })); + const high = await runMonteCarlo(basePlan(), params({ ...opts, target: 500000 })); + expect(low.successProbability).toBeGreaterThan(high.successProbability); + expect(low.successProbability).toBeGreaterThan(0.9); // 50k ist fast sicher erreicht + expect(high.successProbability).toBeLessThan(0.1); // 500k praktisch unerreichbar + }); + + it("gleicher Seed -> identisches Ergebnis (reproduzierbar)", async () => { + const opts = params({ elements: { asset: { mean: 5, sigma: 20, floor: -100 } }, seed: 777 }); + const a = await runMonteCarlo(basePlan(), opts); + const b = await runMonteCarlo(basePlan(), opts); + expect(a.finalWealthMedian).toBe(b.finalWealthMedian); + expect(a.ruinProbability).toBe(b.ruinProbability); + }); + + it("Boden 0 % (PK/3a): Rendite nie negativ -> Endvermoegen nie unter dem Startwert", async () => { + // Ohne Beitraege kann ein bei 0 % gebodetes Asset nur wachsen oder gleich bleiben. + const r = await runMonteCarlo( + basePlan(), + params({ elements: { asset: { mean: 0, sigma: 80, floor: 0 } } }) + ); + expect(r.finalWealthP10).toBeGreaterThanOrEqual(100000); + }); + + it("Ruinwahrscheinlichkeit: sicherer Verzehr fuehrt immer in den Ruin", async () => { + // Rente 20k, Ausgaben 60k, kleines Vermoegen -> deterministisch Ruin, auch ohne Streuung. + const plan: PlanInput = { + id: "p", name: "T", householdType: "SINGLE", inflationRateDefault: 0, initialCash: 0, + persons: [{ id: "A", role: "PERSON_A", name: null, age: 65, retirementAge: 65 }], + phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 20, cashTransition: {} }], + elements: [ + { id: "inc", category: "INCOME", name: "Rente", ownerRole: "PERSON_A", orderIndex: 1, phaseValues: { p1: { amount: 20000, teuerungsausgleich: 0 } }, transitionValues: {} }, + { id: "exp", category: "EXPENSE", name: "Ausgaben", ownerRole: "HOUSEHOLD", orderIndex: 2, phaseValues: { p1: { amount: 60000, teuerungsausgleich: 0 } }, transitionValues: {} }, + { id: "asset", category: "OTHER_ASSET", name: "V", ownerRole: "HOUSEHOLD", orderIndex: 3, phaseValues: { p1: { startValue: 100000, expectedReturn: 0, annualContribution: 0 } }, transitionValues: {} }, + ], + }; + const r = await runMonteCarlo(plan, { + runs: 1000, inflationMean: 0, inflationSigma: 0, + elements: { asset: { mean: 0, sigma: 10, floor: -100 } }, target: 0, seed: 1, + }); + expect(r.ruinProbability).toBe(1); // Verzehr uebersteigt Rente + Vermoegen in jedem Pfad + }); + + it("Hilfsfunktionen: Default-Stufen und σ-Tabelle", () => { + expect(defaultVolatilityLevel("PENSION_FUND")).toBe("sehr_niedrig"); + expect(defaultVolatilityLevel("OTHER_ASSET")).toBe("moderat"); + expect(RETURN_VOLATILITY_LEVELS.moderat).toBe(15); + expect(RETURN_VOLATILITY_LEVELS.sehr_hoch).toBe(55); + }); +}); diff --git a/src/lib/montecarlo.ts b/src/lib/montecarlo.ts new file mode 100644 index 0000000..022817c --- /dev/null +++ b/src/lib/montecarlo.ts @@ -0,0 +1,221 @@ +// Monte-Carlo-Simulation (Roadmap Nr. 19, Stufe A). Laeuft vollstaendig im Browser, weil +// computePlan eine reine Funktion ohne Server-Abhaengigkeiten ist. +// +// Modell: Pro Jahr wird EIN gemeinsamer Marktschock gezogen (damit riskante Anlagen zusammen +// fallen, nicht gegeneinander). Jede Rendite = historischer Mittelwert + Standardabweichung x +// (Marktanteil x Marktschock + Eigenanteil x Eigenrauschen). Beide Schocks sind fettschwaenzig +// (standardisierte Student-t), damit Extremcrashs realistisch haeufig auftreten -- eine +// Normalverteilung wuerde sie stark unterschaetzen. Boeden: 0 % fuer PK/3a, -100 % sonst. + +import { computePlan, type PlanSample } from "@/lib/calculations"; +import type { ElementCategory } from "@/lib/elements"; +import type { PlanInput } from "@/lib/types"; + +// Standardabweichungen (annualisiert, in %) hinter den Streuungsstufen. Recherchiert und +// gerundet: Anleihen ~6 %, globale Aktien ~15-18 %, Schweizer Immobilien(fonds) ~2 %, +// Bitcoin ~54 %. Quellen: siehe SPEZIFIKATION Kap. 4. +export const RETURN_VOLATILITY_LEVELS = { + sehr_niedrig: 3, + niedrig: 6, + moderat: 15, + hoch: 25, + sehr_hoch: 55, +} as const; + +// Inflation ist in der Schweiz historisch stabil (Standardabweichung der letzten 20 Jahre +// ~1 %). Hoehere Stufen ergaeben Hyperinflations-Annahmen -- deshalb nur zwei Stufen. +export const INFLATION_VOLATILITY_LEVELS = { + sehr_niedrig: 1, + niedrig: 2, +} as const; + +export type ReturnVolatilityLevel = keyof typeof RETURN_VOLATILITY_LEVELS | "manuell"; +export type InflationVolatilityLevel = keyof typeof INFLATION_VOLATILITY_LEVELS | "manuell"; + +// Default-Stufe je Element-Typ (damit die Simulation ohne Eingabe eine plausible Streuung hat). +export function defaultVolatilityLevel(category: ElementCategory): ReturnVolatilityLevel { + switch (category) { + case "PENSION_FUND": + return "sehr_niedrig"; + case "REAL_ESTATE": + case "PILLAR_3A": + return "niedrig"; + default: + return "moderat"; + } +} + +// Kategorien mit Marktrendite -- nur diese brauchen Monte-Carlo-Parameter. +export const RETURN_BEARING: ElementCategory[] = ["PENSION_FUND", "PILLAR_3A", "OTHER_ASSET", "REAL_ESTATE"]; + +// PK/3a schreiben dem Versicherten keine negative Rendite gut -> Boden bei 0 %. +export function floorFor(category: ElementCategory): number { + return category === "PENSION_FUND" || category === "PILLAR_3A" ? 0 : -100; +} + +export interface ElementMcParams { + mean: number; // historische Durchschnittsrendite (%/Jahr) + sigma: number; // Standardabweichung (%/Jahr) + floor: number; // 0 fuer PK/3a, -100 sonst +} + +export interface MonteCarloParams { + runs: number; + inflationMean: number; + inflationSigma: number; + elements: Record; // key = elementId + target: number; // Zielbetrag fuer die Erfolgswahrscheinlichkeit (nominal) + seed?: number; +} + +export interface MonteCarloResult { + runs: number; + ruinProbability: number; // P(Vermoegen faellt vor Planende unter 0) + successProbability: number; // P(Endvermoegen >= Zielbetrag) + finalWealthP10: number; + finalWealthMedian: number; + finalWealthP90: number; + // Faecher ueber das Alter: je Alterspunkt der pessimistische/mittlere/optimistische Wert. + bands: { age: number; p10: number; p50: number; p90: number }[]; +} + +// --- Zufallszahlen (seedbar, damit ein Lauf reproduzierbar ist) --- + +function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return () => { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function normal(rng: () => number): number { + // Box-Muller. Kleiner Schutz gegen log(0). + const u = Math.max(rng(), 1e-12); + return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * rng()); +} + +// Standardisierte Student-t mit nu Freiheitsgraden (Einheitsvarianz -> die eingegebene +// Standardabweichung bleibt die tatsaechliche). Fette Raender: nu = 5. +const NU = 5; +function studentT(rng: () => number): number { + let chi2 = 0; + for (let i = 0; i < NU; i++) { + const z = normal(rng); + chi2 += z * z; + } + const t = normal(rng) / Math.sqrt(chi2 / NU); + return t * Math.sqrt((NU - 2) / NU); // auf Einheitsvarianz standardisieren +} + +// Ladefaktor auf den gemeinsamen Marktschock: rho^2 ist die Korrelation zweier riskanter +// Anlagen. rho = 0.7 -> ~0.5. Diversifikation hilft etwas, rettet aber nicht im Crash. +const RHO = 0.7; +const RHO_IDIO = Math.sqrt(1 - RHO * RHO); + +function percentile(sortedAsc: number[], p: number): number { + if (sortedAsc.length === 0) return 0; + const idx = Math.min(sortedAsc.length - 1, Math.max(0, Math.round(p * (sortedAsc.length - 1)))); + return sortedAsc[idx]; +} + +// Ein Alterspunkt je Phasenanfang plus das Planende (wie im Vermoegensverlauf-Chart). +function agePointsOf(plan: PlanInput): number[] { + const phases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber); + const startAge = plan.persons.find((p) => p.role === "PERSON_A")?.age ?? plan.persons[0]?.age ?? 0; + const points: number[] = [startAge]; + let acc = 0; + for (const p of phases) { + acc += Math.max(1, p.durationYears); + points.push(startAge + acc); + } + return points; +} + +// Wert je Alterspunkt fuer EINEN Durchlauf (Start-/Endvermoegen der Phasen, nominal). +function wealthTrajectory(computed: ReturnType): number[] { + const phases = computed.phases; + if (phases.length === 0) return []; + const pts = [phases[0].startWealthNominal]; + for (const p of phases) pts.push(p.endWealthNominal); + return pts; +} + +export async function runMonteCarlo( + plan: PlanInput, + params: MonteCarloParams, + onProgress?: (done: number, total: number) => void +): Promise { + const phases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber); + const totalYears = phases.reduce((s, p) => s + Math.max(1, p.durationYears), 0); + const rng = mulberry32(params.seed ?? (Math.random() * 2 ** 32) >>> 0); + + const agePoints = agePointsOf(plan); + const nPoints = agePoints.length; + const wealthByPoint: number[][] = Array.from({ length: nPoints }, () => []); + const finalWealth: number[] = []; + let ruinCount = 0; + let successCount = 0; + + const elementIds = Object.keys(params.elements); + + for (let run = 0; run < params.runs; run++) { + // Pro Jahr ein Marktschock; pro Element/Jahr eine Rendite (fette Raender, gebodet). + const inflation: number[] = new Array(totalYears); + for (let y = 0; y < totalYears; y++) { + inflation[y] = params.inflationMean + params.inflationSigma * studentT(rng); + } + + const returns = new Map(); + for (const id of elementIds) returns.set(id, new Float64Array(totalYears + 1)); + for (let y = 1; y <= totalYears; y++) { + const market = studentT(rng); + for (const id of elementIds) { + const ep = params.elements[id]; + const shock = RHO * market + RHO_IDIO * studentT(rng); + const r = ep.mean + ep.sigma * shock; + returns.get(id)![y] = Math.max(ep.floor, r); + } + } + + const sample: PlanSample = { + inflation, + assetReturn: (elementId, year) => returns.get(elementId)?.[year] ?? 0, + }; + + const computed = computePlan(plan, sample); + if (computed.ruinAge !== null) ruinCount++; + + const traj = wealthTrajectory(computed); + for (let i = 0; i < nPoints; i++) wealthByPoint[i].push(traj[i] ?? 0); + const fw = traj[traj.length - 1] ?? 0; + finalWealth.push(fw); + if (fw >= params.target) successCount++; + + // Alle ~500 Laeufe die Kontrolle abgeben, damit die Oberflaeche nicht einfriert. + if (run % 500 === 499) { + onProgress?.(run + 1, params.runs); + await new Promise((r) => setTimeout(r, 0)); + } + } + onProgress?.(params.runs, params.runs); + + finalWealth.sort((a, b) => a - b); + const bands = agePoints.map((age, i) => { + const col = wealthByPoint[i].sort((a, b) => a - b); + return { age, p10: percentile(col, 0.1), p50: percentile(col, 0.5), p90: percentile(col, 0.9) }; + }); + + return { + runs: params.runs, + ruinProbability: ruinCount / params.runs, + successProbability: successCount / params.runs, + finalWealthP10: percentile(finalWealth, 0.1), + finalWealthMedian: percentile(finalWealth, 0.5), + finalWealthP90: percentile(finalWealth, 0.9), + bands, + }; +}