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
+129 -7
View File
@@ -4,10 +4,10 @@
| | | | | |
|---|---| |---|---|
| **Dokument** | Funktionale und Technische Spezifikation FPT | | **Dokument** | Funktionale und Technische Spezifikation FPT |
| **Version** | 0.6 | | **Version** | 0.7 |
| **Datum** | 2026-07-17 | | **Datum** | 2026-07-17 |
| **Status** | Lebendes Dokument | | **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` (v1v5) im Ordner `Info Dateien` diese sind ab Version 0.1 dieses Dokuments obsolet | | **Ersetzt** | `FDD_TDD_FPT.docx` (v1v5) im Ordner `Info Dateien` diese sind ab Version 0.1 dieses Dokuments obsolet |
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` | | **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
@@ -17,6 +17,7 @@
| Version | Datum | Autor | Änderung | | 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.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.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. | | 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 verwendet (`num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE)`). Grund laut Code-Kommentar: Damit
ein nicht angetippter Wert nicht fälschlich als 0 gerechnet wird. 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 ~1518 %, 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 # 5. Technische Spezifikation
@@ -1489,6 +1590,7 @@ PlanComputed ← an den Client geliefert
| `elements.ts` | Kategorien, Labels, Reihenfolge, JSON-Payload-Typen, Zod-Schemas, `num()` | | `elements.ts` | Kategorien, Labels, Reihenfolge, JSON-Payload-Typen, Zod-Schemas, `num()` |
| `types.ts` | Domänentypen für API und Berechnung | | `types.ts` | Domänentypen für API und Berechnung |
| `constants.ts` | Schweizer Systemparameter | | `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 | | `queries.ts` | Prisma-Includes, `toPlanInput()`, Ownership-Abfragen |
| `db.ts` | Prisma-Singleton (Global-Cache im Dev, verhindert Connection-Leak bei HMR) | | `db.ts` | Prisma-Singleton (Global-Cache im Dev, verhindert Connection-Leak bei HMR) |
| `auth.ts` | JWT erzeugen/prüfen (Edge-kompatibel via `jose`) | | `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 | | `PlanProfileFields` | 101 | Wiederverwendete Grundprofil-Felder |
| `PhaseDetail` | 95 | Phase bearbeiten/löschen | | `PhaseDetail` | 95 | Phase bearbeiten/löschen |
| `SparquoteChart` | 64 | Einkommen/Ausgaben pro Jahr mit Spar-/Verzehrbändern | | `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 | | `InfoBubble` | 28 | Hilfe-Tooltip |
### 5.5.3 Wiederverwendungsmuster ### 5.5.3 Wiederverwendungsmuster
@@ -1919,10 +2022,11 @@ Zielumgebung: Hetzner CX23, Traefik als Reverse Proxy, Domain `fpt.aicds.ch`, Gi
## 8.1 Teststrategie ## 8.1 Teststrategie
Getestet wird ausschliesslich der Berechnungskern bewusst, da dort die Fachlogik und das 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, Regressionsrisiko liegen. `src/lib/calculations.test.ts` (41 Tests: AHV-Rentenformel, Immobilie,
Immobilie, Teilverkauf, Sonderamortisation, AHV einkommensabhängig, „V5 Golden Tests"), Teilverkauf, Sonderamortisation, AHV einkommensabhängig, „V5 Golden Tests") und
ausgeführt mit Vitest in der Node-Umgebung (`vitest.config.ts`, Include `src/**/*.test.ts`). `src/lib/montecarlo.test.ts` (7 Tests) ergeben zusammen **48 Tests**, ausgeführt mit Vitest in
Es gibt **keine** Komponenten-, API- oder E2E-Tests. der Node-Umgebung (`vitest.config.ts`, Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-,
API- oder E2E-Tests.
## 8.2 Testfälle ## 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 | | **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 | | **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 | | **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<median<p90; Median unter dem deterministischen Wert |
| **MC: Erfolg / Reproduzierbarkeit** | P(≥ Ziel) fällt mit steigendem Ziel; gleicher Seed → identisches Ergebnis |
| **MC: Boden / Ruin** | 0%-Boden hält PK/3a ≥ Startwert; sicherer Verzehr → Ruinwahrscheinlichkeit 100 % |
| Test 1 Ansparen | Einkommen +2 % nominal, Ausgaben real flach: Endvermögen 761'654 nominal / 565'928 real (±1 %), kein negatives Cash | | Test 1 Ansparen | Einkommen +2 % nominal, Ausgaben real flach: Endvermögen 761'654 nominal / 565'928 real (±1 %), kein negatives Cash |
| Test 2 Verzehr/Ruin | Rente nominal fix 60k, Ausgaben real 100k, Vermögen 900k @3 %: `ruinAge === 94` | | Test 2 Verzehr/Ruin | Rente nominal fix 60k, Ausgaben real 100k, Vermögen 900k @3 %: `ruinAge === 94` |
| Test 3 Cash-Ausgleich | Sparrate 6'364: `cashEnd === 5472`, nie negativ | | Test 3 Cash-Ausgleich | Sparrate 6'364: `cashEnd === 5472`, nie negativ |
@@ -2125,7 +2233,21 @@ hinterfragt. Der Benutzer kennt seine Steuerrechnung dagegen exakt aus der letzt
**Heutiger Weg:** ein normales Ausgaben-Element „Steuern" dafür braucht es kein neues Feld. **Heutiger Weg:** ein normales Ausgaben-Element „Steuern" dafür braucht es kein neues Feld.
Echte Steuerlogik ist Roadmap Nr. 23, mit Kanton und Gemeinde als Eingabe. Echte Steuerlogik ist Roadmap Nr. 23, mit Kanton und Gemeinde als Eingabe.
## 9.15 Kleinere Beobachtungen ## 9.15 Monte-Carlo misst Risiko um die Annahmen, nicht deren Richtigkeit
Die Simulation streut um die eingegebenen Mittelwerte (historische Rendite/Inflation). Sie
beurteilt **nicht**, ob diese Mittelwerte realistisch sind. Ein zu optimistischer Mittelwert
liefert eine beruhigende, aber unrealistische Erfolgsquote die präzise Prozentzahl täuscht
dann Sicherheit vor. Der Dialog weist das explizit aus; die Mittelwerte sind Pflichtfelder ohne
Default, damit sie bewusst gesetzt werden.
Weitere bewusste Vereinfachungen der Stufe A: Inflation und Renditen werden **unabhängig**
gezogen (real sind sie negativ korreliert); die Normalverteilungs-Alternative wird gar nicht
angeboten (fette Ränder fest eingebaut); die Simulationsparameter werden **nicht persistiert**
(ephemer im Dialog). Ein historischer Backtest (Stufe B) und korrelierte/vollständigere Modelle
(Stufe C) sind offen.
## 9.16 Kleinere Beobachtungen
- `planToCsv(plan, computed)` erhält den `plan`-Parameter, verwendet ihn aber nicht. - `planToCsv(plan, computed)` erhält den `plan`-Parameter, verwendet ihn aber nicht.
- Der Typ `Selection` in `PlanView` hat nur eine Variante (`{ type: "phase" }`) ein Rest der - Der Typ `Selection` in `PlanView` hat nur eine Variante (`{ type: "phase" }`) ein Rest der
+420
View File
@@ -0,0 +1,420 @@
"use client";
import { useMemo, useState } from "react";
import { Area, CartesianGrid, ComposedChart, Legend, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { Dices, X } from "lucide-react";
import { NumberField, SelectField, MoneyField } from "@/components/FormField";
import { InfoBubble } from "@/components/InfoBubble";
import { formatChf } from "@/lib/format";
import {
runMonteCarlo,
defaultVolatilityLevel,
RETURN_VOLATILITY_LEVELS,
INFLATION_VOLATILITY_LEVELS,
RETURN_BEARING,
floorFor,
type ReturnVolatilityLevel,
type InflationVolatilityLevel,
type MonteCarloResult,
} from "@/lib/montecarlo";
import { CATEGORY_LABELS } from "@/lib/elements";
import type { PlanInput } from "@/lib/types";
import type { PlanComputed } from "@/lib/calculations";
const RETURN_LEVEL_OPTIONS: { value: ReturnVolatilityLevel; label: string }[] = [
{ value: "sehr_niedrig", label: "Sehr niedrig" },
{ value: "niedrig", label: "Niedrig" },
{ value: "moderat", label: "Moderat" },
{ value: "hoch", label: "Hoch" },
{ value: "sehr_hoch", label: "Sehr hoch" },
{ value: "manuell", label: "Manuell" },
];
const INFLATION_LEVEL_OPTIONS: { value: InflationVolatilityLevel; label: string }[] = [
{ value: "sehr_niedrig", label: "Sehr niedrig" },
{ value: "niedrig", label: "Niedrig" },
{ value: "manuell", label: "Manuell" },
];
const RETURN_HELP =
"Wie stark die Jahresrendite schwankt. Sehr niedrig ≈ Staatsanleihen/Geldmarkt · Niedrig ≈ Immobilien, defensive Mischportfolios · Moderat ≈ breit diversifizierte Aktien-ETFs/Fonds · Hoch ≈ Einzelaktien, Branchen-/Schwellenländerfonds · Sehr hoch ≈ Kryptowährungen, hochspekulative Anlagen.";
const INFLATION_HELP =
"Wie stark die Inflation schwankt. Für die Schweiz ist sie historisch sehr stabil (Sehr niedrig ≈ 1 %). Höhere Stufen wären Hyperinflations-Annahmen.";
// Pflicht-Zahlenfeld, das wirklich leer sein kann (NumberField erzwingt eine Zahl).
function MeanField({
label,
help,
value,
onChange,
}: {
label: string;
help: string;
value: string;
onChange: (v: string) => void;
}) {
const empty = value.trim() === "";
return (
<div>
<label className="mb-1 flex items-center text-xs font-medium text-muted">
{label}
<InfoBubble text={help} />
</label>
<input
type="number"
step={0.1}
value={value}
placeholder="Pflicht"
onChange={(e) => 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 && <p className="mt-1 text-[11px] text-danger">Pflichtfeld bitte ausfüllen.</p>}
</div>
);
}
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<InflationVolatilityLevel>("sehr_niedrig");
const [inflManual, setInflManual] = useState("1");
const [target, setTarget] = useState(Math.max(0, computed.nachlass));
const [els, setEls] = useState<Record<string, ElementInput>>(() =>
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<MonteCarloResult | null>(null);
function setEl(id: string, patch: Partial<ElementInput>) {
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 (
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
<div
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-3xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
>
<div className="flex items-center justify-between">
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
<Dices className="h-5 w-5 text-accent" /> Monte-Carlo-Simulation
</h2>
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
<X className="h-4 w-4" />
</button>
</div>
{/* Erklaerung */}
<div className="rounded-xl border border-border bg-surface-2 p-4 text-xs leading-relaxed text-muted">
<p className="mb-2">
<strong className="text-fg">Was ist das?</strong> Dein Plan rechnet mit einer festen Rendite und Inflation
pro Jahr. Real schwanken beide. Die Simulation würfelt <strong className="text-fg">viele tausend mögliche
Verläufe</strong> und zeigt, wie oft dein Plan aufgeht.
</p>
<p className="mb-2">
<strong className="text-fg">Eingabe:</strong> je Anlage die historische Durchschnittsrendite (der Mittelpunkt,
um den gewürfelt wird) und wie stark sie schwankt; dazu dieselben Angaben für die Inflation.
<strong className="text-fg"> Ergebnis:</strong> die Wahrscheinlichkeit, dass das Geld reicht bzw. dein Zielbetrag
erreicht wird, plus ein Fächer vom pessimistischen bis zum optimistischen Fall.
</p>
<p>
<strong className="text-fg">Verteilung:</strong> 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. <em>Die Prozentzahl gilt immer nur relativ zu deinen Annahmen sie beurteilt nicht,
ob deine Durchschnittswerte realistisch sind.</em>
</p>
</div>
{/* Inflation */}
<div>
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">Inflation (Plan-Ebene)</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<MeanField
label="Ø Inflation letzte 20 J. (%)"
help={`Der Mittelpunkt, um den gewürfelt wird. Deine Planung nutzt aktuell ${plan.inflationRateDefault} %. CH langfristig ~2 %.`}
value={inflMean}
onChange={setInflMean}
/>
<SelectField
label="Streuung"
help={INFLATION_HELP}
value={inflLevel}
onChange={(v: InflationVolatilityLevel) => setInflLevel(v)}
options={INFLATION_LEVEL_OPTIONS}
/>
{inflLevel === "manuell" ? (
<NumberField label="Standardabw. (%)" step={0.1} value={Number(inflManual) || 0} onChange={(v) => setInflManual(String(v))} />
) : (
<ReadOnlySigma value={INFLATION_VOLATILITY_LEVELS[inflLevel]} />
)}
</div>
</div>
{/* Elemente */}
<div>
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">Renditetragende Elemente</div>
{returnElements.length === 0 && (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-xs text-muted">
Dieser Plan hat keine renditetragenden Elemente (PK, 3a, Sonstiges Vermögen, Immobilie).
</p>
)}
<div className="flex flex-col gap-3">
{returnElements.map((e) => {
const ei = els[e.id];
return (
<div key={e.id} className="rounded-xl border border-border bg-surface-2 p-3">
<div className="mb-2 flex items-center gap-2 text-sm">
<span className="font-semibold text-fg">{e.name}</span>
<span className="text-xs text-faint">{CATEGORY_LABELS[e.category]}</span>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<MeanField
label="Ø Rendite letzte 20 J. (%)"
help="Der Mittelpunkt, um den gewürfelt wird die realistische Rendite dieser Anlage, nicht deine (evtl. optimistische) Planannahme."
value={ei?.mean ?? ""}
onChange={(v) => setEl(e.id, { mean: v })}
/>
<SelectField
label="Streuung"
help={RETURN_HELP}
value={ei.level}
onChange={(v: ReturnVolatilityLevel) => setEl(e.id, { level: v })}
options={RETURN_LEVEL_OPTIONS}
/>
{ei.level === "manuell" ? (
<NumberField label="Standardabw. (%)" step={0.5} value={Number(ei.manualSigma) || 0} onChange={(v) => setEl(e.id, { manualSigma: String(v) })} />
) : (
<ReadOnlySigma value={RETURN_VOLATILITY_LEVELS[ei.level]} />
)}
</div>
{(e.category === "PENSION_FUND" || e.category === "PILLAR_3A") && (
<p className="mt-2 text-[11px] text-faint">Boden 0 %: {CATEGORY_LABELS[e.category]} schreibt keine negative Rendite gut.</p>
)}
</div>
);
})}
</div>
</div>
{/* Lauf-Parameter */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div>
<SelectField
label="Anzahl Simulationen"
value={String(runs)}
onChange={(v: string) => setRuns(Number(v))}
options={[
{ value: "1000", label: "1'000 (schnell)" },
{ value: "5000", label: "5'000" },
{ value: "10000", label: "10'000 (genau)" },
]}
/>
<p className="mt-1 text-[11px] text-faint">geschätzt ~{estSeconds} s</p>
</div>
<MoneyField
label="Zielbetrag (Endvermögen)"
help="Die Erfolgswahrscheinlichkeit misst, wie oft das Endvermögen mindestens diesen Betrag erreicht. Vorbelegt mit deinem geplanten Nachlass."
value={target}
onChange={setTarget}
/>
</div>
{missing && <p className="text-xs text-danger">Bitte alle Pflichtfelder (Ø-Werte) ausfüllen, um die Simulation zu starten.</p>}
<div className="flex items-center gap-3">
<button
type="button"
disabled={missing || running || returnElements.length === 0}
onClick={run}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
>
{running ? `Berechne… ${Math.round(progress * 100)} %` : "Berechnung jetzt durchführen"}
</button>
{running && (
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-2">
<div className="h-full bg-accent transition-all" style={{ width: `${progress * 100}%` }} />
</div>
)}
</div>
{result && <MonteCarloResults result={result} target={target} chartData={chartData} />}
</div>
</div>
);
}
function ReadOnlySigma({ value }: { value: number }) {
return (
<div>
<label className="mb-1 flex items-center text-xs font-medium text-muted">
Standardabweichung
<InfoBubble text="Die hinter der gewählten Streuungsstufe hinterlegte Standardabweichung. Nur bei Manuell selbst eingebbar." />
</label>
<div className="w-full rounded-lg border border-dashed border-border bg-surface-2 px-2.5 py-1.5 text-sm text-muted">
{value} %
</div>
</div>
);
}
function MonteCarloResults({
result,
target,
chartData,
}: {
result: MonteCarloResult;
target: number;
chartData: { age: number; band: [number, number]; median: number; det: number | null }[];
}) {
return (
<div className="flex flex-col gap-4 border-t border-border pt-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<Stat label="Erfolgswahrscheinlichkeit" value={`${Math.round(result.successProbability * 100)} %`} help={`Anteil der Läufe mit Endvermögen ≥ ${formatChf(target)}.`} good />
<Stat label="Ruinwahrscheinlichkeit" value={`${Math.round(result.ruinProbability * 100)} %`} help="Anteil der Läufe, in denen das Vermögen vor Planende unter 0 fällt." danger />
<Stat label="Läufe" value={result.runs.toLocaleString("de-CH")} />
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 text-sm">
<Band label="Pessimistisch (10 %)" value={result.finalWealthP10} />
<Band label="Median" value={result.finalWealthMedian} />
<Band label="Optimistisch (90 %)" value={result.finalWealthP90} />
</div>
<div>
<div className="mb-1 text-xs font-semibold text-fg">Vermögensfächer nach Alter (nominal)</div>
<p className="mb-2 text-[11px] text-muted">
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).
</p>
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="age" tick={{ fontSize: 11 }} tickFormatter={(v) => `${v} J.`} />
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} />
<Tooltip
formatter={(v, name) => [typeof v === "number" ? formatChf(v) : v, name]}
labelFormatter={(v) => `Alter ${v}`}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
<Area dataKey="band" name="1090 % Band" stroke="none" fill="var(--accent)" fillOpacity={0.16} isAnimationActive={false} />
<Line dataKey="median" name="Median" stroke="var(--accent)" strokeWidth={2} dot={false} isAnimationActive={false} />
<Line dataKey="det" name="Planung (deterministisch)" stroke="var(--muted)" strokeWidth={1.5} strokeDasharray="5 3" dot={false} connectNulls isAnimationActive={false} />
</ComposedChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
}
function Stat({ label, value, help, good, danger }: { label: string; value: string; help?: string; good?: boolean; danger?: boolean }) {
return (
<div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
<div className="flex items-center text-xs text-muted">
{label}
{help && <InfoBubble text={help} />}
</div>
<div className={`mt-1 text-2xl font-semibold ${good ? "text-success" : danger ? "text-danger" : "text-fg"}`}>{value}</div>
</div>
);
}
function Band({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2">
<div className="text-[11px] text-muted">{label}</div>
<div className="font-semibold text-fg">{formatChf(value)}</div>
</div>
);
}
+14
View File
@@ -8,6 +8,7 @@ import {
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
CreditCard, CreditCard,
Dices,
Home, Home,
Landmark, Landmark,
PiggyBank, PiggyBank,
@@ -32,6 +33,7 @@ import {
type CellContext, type CellContext,
} from "@/components/ElementDetail"; } from "@/components/ElementDetail";
import { PhaseDetail } from "@/components/PhaseDetail"; import { PhaseDetail } from "@/components/PhaseDetail";
import { MonteCarloDialog } from "@/components/MonteCarloDialog";
import { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFields"; import { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFields";
import { MoneyField } from "@/components/FormField"; import { MoneyField } from "@/components/FormField";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
@@ -104,6 +106,7 @@ export function PlanView({
const [editTransition, setEditTransition] = useState<{ elementId: string; fromPhaseId: string } | null>(null); const [editTransition, setEditTransition] = useState<{ elementId: string; fromPhaseId: string } | null>(null);
const [editPhaseCell, setEditPhaseCell] = useState<{ elementId: string; phaseId: string } | null>(null); const [editPhaseCell, setEditPhaseCell] = useState<{ elementId: string; phaseId: string } | null>(null);
const [showCashInit, setShowCashInit] = useState(false); const [showCashInit, setShowCashInit] = useState(false);
const [showMonteCarlo, setShowMonteCarlo] = useState(false);
// fromPhaseId des Cash-Uebergangs, der gerade bearbeitet wird. // fromPhaseId des Cash-Uebergangs, der gerade bearbeitet wird.
const [editCashTransition, setEditCashTransition] = useState<string | null>(null); const [editCashTransition, setEditCashTransition] = useState<string | null>(null);
const [valueMode, setValueMode] = useState<ValueMode>("nominal"); const [valueMode, setValueMode] = useState<ValueMode>("nominal");
@@ -350,6 +353,13 @@ export function PlanView({
> >
<Plus className="h-4 w-4" /> Lebensphase <Plus className="h-4 w-4" /> Lebensphase
</button> </button>
<button
type="button"
onClick={() => setShowMonteCarlo(true)}
className="ml-auto flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
>
<Dices className="h-4 w-4" /> Monte-Carlo-Simulation durchführen
</button>
</div> </div>
)} )}
@@ -570,6 +580,10 @@ export function PlanView({
/> />
)} )}
{showMonteCarlo && (
<MonteCarloDialog plan={plan} computed={computed} onClose={() => setShowMonteCarlo(false)} />
)}
{editCashTransition && (() => { {editCashTransition && (() => {
const fromPhase = computed.phases.find((p) => p.id === editCashTransition); const fromPhase = computed.phases.find((p) => p.id === editCashTransition);
if (!fromPhase) return null; if (!fromPhase) return null;
+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; 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 phases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
const persons = plan.persons; const persons = plan.persons;
const personA = persons.find((p) => p.role === "PERSON_A") ?? persons[0]; 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>(); const retirementAge = new Map<string, number>();
for (const p of persons) retirementAge.set(p.id, p.retirementAge); 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>(); const gapYearsByPerson = new Map<string, number>();
// AHV-Beitragskarriere je Person: reales Einkommen x Beitragsjahre, und Beitragsjahre. // AHV-Beitragskarriere je Person: reales Einkommen x Beitragsjahre, und Beitragsjahre.
const ahvIncomeAccum = new Map<string, number>(); const ahvIncomeAccum = new Map<string, number>();
@@ -247,9 +265,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
const nextPhase = phases[i + 1]; const nextPhase = phases[i + 1];
const isFirstPhase = i === 0; const isFirstPhase = i === 0;
const duration = Math.max(1, phase.durationYears); const duration = Math.max(1, phase.durationYears);
// Inflation ist plan-weit (V5): keine Phasen-Ueberschreibung mehr. const cumInflStart = cumInfl[yearsBefore]; // Kaufkraft-Deflator zu Phasenbeginn
const infl = plan.inflationRateDefault;
const cumInflStart = cumulativeInflation; // Kaufkraft-Deflator zu Phasenbeginn
const personInfos: PersonPhaseInfo[] = persons.map((p) => { const personInfos: PersonPhaseInfo[] = persons.map((p) => {
const ra = retirementAge.get(p.id)!; const ra = retirementAge.get(p.id)!;
@@ -404,7 +420,10 @@ export function computePlan(plan: PlanInput): PlanComputed {
const attributed = const attributed =
owner ?? (plan.householdType === "SINGLE" && e.ownerRole === "HOUSEHOLD" ? personA : null); owner ?? (plan.householdType === "SINGLE" && e.ownerRole === "HOUSEHOLD" ? personA : null);
if (attributed) { 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( phaseRealIncomeByPerson.set(
attributed.id, attributed.id,
(phaseRealIncomeByPerson.get(attributed.id) ?? 0) + avgRealGross (phaseRealIncomeByPerson.get(attributed.id) ?? 0) + avgRealGross
@@ -553,7 +572,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
let incomeFlow = renteTotal; let incomeFlow = renteTotal;
for (const inc of incomes) incomeFlow += inc.basis * Math.pow(1 + inc.idx / 100, t - 1); 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. // 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; let expenseRealBase = 0;
for (const exp of expenses) expenseRealBase += exp.basis * Math.pow(1 + exp.idx / 100, t - 1); 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. // Vermoegen verzinsen + Sparbeitrag; Bezugsrate entnehmen (gekappt am Bestand) und ins Cash.
let cashFromWithdraw = 0; let cashFromWithdraw = 0;
for (const a of assets) { 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)); const w = Math.min(a.withdrawal, Math.max(0, grown));
a.value = grown - w; a.value = grown - w;
cashFromWithdraw += w; cashFromWithdraw += w;
@@ -607,7 +627,8 @@ export function computePlan(plan: PlanInput): PlanComputed {
debtRates += pay; debtRates += pay;
// Wertsteigerung wirkt auf die LIEGENSCHAFT, nicht auf das Eigenkapital -- das ist der // 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. // 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) { for (const d of debts) {
const pay = Math.min(d.repay, d.owed); 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 // Flow-Deflator fuer den Endwert (Jahr `duration`): eine Kaufkraft-Stufe weniger als der
// Bestands-Deflator am Phasenende. // 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.). // Endwerte je Element setzen (Einkommen/Ausgaben nominal; Ausgaben-Nominal = real x Infl.).
for (const inc of incomes) { for (const inc of incomes) {
@@ -663,7 +684,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
const cashEnd = Math.round(cash); const cashEnd = Math.round(cash);
const startWealthNominal = Math.round(wealthStart + cashStart); const startWealthNominal = Math.round(wealthStart + cashStart);
const endWealthNominal = Math.round(wealthEnd + cashEnd); const endWealthNominal = Math.round(wealthEnd + cashEnd);
cumulativeInflation = cumInflStart * Math.pow(1 + infl / 100, duration); cumulativeInflation = cumInfl[yearsBefore + duration];
result.push({ result.push({
id: phase.id, id: phase.id,
+107
View File
@@ -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> = {}): 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);
});
});
+221
View File
@@ -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<string, ElementMcParams>; // 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<typeof computePlan>): 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<MonteCarloResult> {
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<string, Float64Array>();
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,
};
}