Szenario-Vergleich in der Monte-Carlo-Simulation + Sensitivitaetsanalyse (Tornado)
Deploy App / deploy (push) Successful in 53s

Monte Carlo ueber mehrere Szenarien eines Plans in einem Lauf:
- Annahmen nur EINMAL je logischem Element (Zuordnung ueber sourceElementId,
  dieselbe Kette wie beim Diff) -- sonst vergleicht man die Eingaben statt
  der Szenarien
- gemeinsamer Seed fuer alle Szenarien (Common Random Numbers), damit
  Unterschiede strukturell und nicht zufaellig sind
- Zielbetrag bleibt szenario-eigen: die Erfolgswahrscheinlichkeit misst,
  wie oft ein Szenario sein EIGENES Versprechen haelt
- Vergleichstabelle + Median-Linien; bei einem Szenario unveraenderter Faecher

Sensitivitaetsanalyse (Roadmap Nr. 20), eigener Dialog "Einflussfaktoren":
- neues reines Modul sensitivity.ts, One-at-a-time ueber 7 Treiber
- Bandbreiten je Treiber pflichtig und ohne Default (die Balkenlaenge haengt
  direkt davon ab)
- Pensionsalter bewusst ausgeschlossen: nicht variierbar ohne Mitverschieben
  der Phasengrenzen (Begruendung in 9.18)

SPEZIFIKATION auf 1.0: neue Kapitel 3.6.6, 4.12.6, 4.13, 9.18, 9.19 sowie
vier korrigierte Dokumentationsfehler (Kap. 1.2, 4.6.5, 5.2/5.3/5.5.2/8.1,
Glossar). 20 Tests ergaenzt (60 -> 80). Keine DB-Aenderung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
This commit is contained in:
2026-07-18 14:33:27 +02:00
parent d203e504c0
commit 3fe7a3978d
9 changed files with 1762 additions and 211 deletions
+251 -23
View File
@@ -4,10 +4,10 @@
| | |
|---|---|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
| **Version** | 0.9 |
| **Version** | 1.0 |
| **Datum** | 2026-07-18 |
| **Status** | Lebendes Dokument |
| **Codestand** | Arbeitsstand nach `a5d4868` inkl. UI-Umbau und Planstart (Branch `main`) |
| **Codestand** | Arbeitsstand nach `d203e50` inkl. Szenario-Vergleich in der Monte-Carlo-Simulation und Sensitivitätsanalyse (Branch `main`) |
| **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` |
@@ -17,6 +17,7 @@
| Version | Datum | Autor | Änderung |
|---|---|---|---|
| 1.0 | 2026-07-18 | Claude (Opus 4.8) | **Szenario-Vergleich in der Monte-Carlo-Simulation** und **Sensitivitätsanalyse / Tornado** (Roadmap Nr. 20). (1) Die MC-Simulation rechnet neu **mehrere Szenarien desselben Plans in einem Lauf**. Die historischen Annahmen werden dabei nur **einmal je logischem Element** erfasst die Zuordnung über die Herkunfts-Kette `sourceElementId`, dieselbe Grundlage wie beim Diff (neue Funktionen `resolveRootElementId`, `buildElementGroups`, `paramsForScenario`, `runMonteCarloMulti`). Alle Szenarien laufen mit **demselben Seed** (Common Random Numbers), damit Unterschiede strukturell und nicht zufällig sind. Der **Zielbetrag bleibt szenario-eigen** (vorbelegt mit dem jeweils geplanten Endvermögen) nur so misst die Erfolgswahrscheinlichkeit, wie oft ein Szenario sein *eigenes* Versprechen hält. Ergebnis als Vergleichstabelle plus Median-Linien je Szenario; bei einem einzelnen Szenario unverändert der bisherige Fächer. (2) Neuer Bereich **Einflussfaktoren** (eigener Button, eigener Dialog) mit einem **Tornado-Chart** nach dem One-at-a-time-Verfahren: neues reines Modul `sensitivity.ts` mit sieben Treibern, je Treiber an-/abwählbar und mit **pflichtiger, frei definierbarer Bandbreite ohne Default**. Das **Pensionsalter ist bewusst nicht enthalten** (Begründung: 9.18). Neue Kapitel 3.6.6, 4.12.6, 4.13, 9.18; 20 Tests ergänzt (60 → 80). Keine DB-Änderung, keine Verhaltensänderung bestehender Pläne. **Ausserdem vier Dokumentationsfehler korrigiert:** Kap. 1.2 nannte noch Monte-Carlo, Hypothekarzinsen und Immobilien-Wertsteigerung als nicht umgesetzt (seit 0.5/0.7 vorhanden); Kap. 4.6.5 endete mit einem widersprüchlichen Restsatz zur fehlenden Wertsteigerung; Kap. 5.2/5.3/5.5.2/8.1 waren bei Migrationszahl, Dateiliste, Komponentenliste und Testzahlen veraltet; der Glossar-Eintrag „Szenario" beschrieb noch das Modell vor V6. |
| 0.9 | 2026-07-18 | Claude (Opus 4.8) | **UI-Umbau und Planstart.** (1) Die Grafiken liegen neu im eigenen Bereich **Grafiken** (Dialog, Button oben) statt unter der Matrix. (2) Kennzahl **Geschätzter Nachlass** entfernt sie war identisch mit dem nominalen Endvermögen. (3) **Monte-Carlo-Button** nach oben zu den Szenario-Aktionen verschoben. (4) Neues Profilfeld **Planstart (Jahr)** (`Scenario.startYear`, Migration; reine Anzeige, Berechnung bleibt in relativen Jahren) erscheint auf der Zeitachse. (5) Zeitachse zeigt neu die **Lebensphasen als Segmente** (Breite = Dauer, Einfärbung nach Phasentyp, Jahresspanne). (6) **Lebensphase bearbeiten** neu als Popup statt Panel unter der Tabelle. (7) **Vermögensverlauf** über **alle Jahre** statt nur über die Phasengrenzen dafür führt `computePlan` das Vermögen neu pro Jahr mit (`YearPoint.wealthNominal/wealthReal`). Zwei Tests ergänzt (58 → 60). |
| 0.8 | 2026-07-18 | Claude (Opus 4.8) | **Szenario-Hierarchie (V6)** grösste Umstrukturierung bisher. Der **Plan** ist neu ein schlanker Behälter ohne Finanzdaten; die berechenbare Einheit ist das **Szenario**, das Grundprofil (inkl. **Pensionsalter** → Frühpensionierungs-Szenarien), Phasen und Elemente trägt. Jeder Plan erhält beim Anlegen automatisch ein **Basisszenario**; weitere Szenarien entstehen als vollständige Kopie eines beliebigen Szenarios und hängen als **Baum** darunter (Sidebar zeigt die Verschachtelung). Kopierte Phasen/Elemente tragen Herkunfts-Verweise (`sourcePhaseId`, `sourceElementId`) darauf beruht die **Abweichungs-Markierung**: geändert = gelb, neu = grün, entfernt = graue Geisterzeile (eigene Theme-Tokens für alle drei Farbschemata). Charts vergleichen neu die Geschwister-Szenarien. Datenmodell: neue Tabelle `Plan`, bisheriger `Plan``Scenario` (IDs erhalten), `planId``scenarioId` in Person/Phase/FinancialElement. API neu unter `/api/scenarios/*`. Neue Kapitel 2.1, 3.2, 4.13; 9 Diff-Tests + Migrations-Test (48 → 58). **Migration mit echtem Postgres (PGlite) verifiziert**, inkl. verschachtelter Szenarien und Cascade. |
| 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. |
@@ -66,15 +67,25 @@ Aus dem Code direkt ableitbare Abgrenzungen:
- **Keine Steuerberechnung** ausser den drei explizit modellierten Sätzen
(Kapitalbezugssteuer, Grundstückgewinnsteuer, PK-Umwandlungssatz). Einkommens- und
Vermögenssteuern sind nicht modelliert und müssen vom Benutzer in den Ausgaben erfasst werden.
- **Keine Monte-Carlo-Simulation / keine Stochastik.** Alle Renditen sind deterministische
Jahresprozentsätze.
- **Keine Hypothekarzinsen.** Eine Hypothek reduziert nur den Nettowert der Immobilie;
Zinskosten sind vom Benutzer in den Ausgaben zu erfassen.
- **Keine Wertentwicklung von Immobilien.** Der Kaufpreis ist über die Phasendauer konstant
(Details siehe [4.6.5](#465-real_estate-immobilie)).
Vermögenssteuern sind nicht modelliert und müssen vom Benutzer in den Ausgaben erfasst werden
(Begründung: [9.14](#914-keine-steuerschätzung)).
- **Keine Nebenkosten, kein Eigenmietwert, keine Mieteinnahmen** bei Immobilien
(siehe [9.3](#93-immobilien-was-noch-fehlt)).
- **Keine automatische Deckung von Liquiditätslücken.** Negatives Cash wird gemeldet, aber nicht
korrigiert (siehe [9.1](#91-cash-wird-nicht-automatisch-ausgeglichen)).
- **Keine Mehrbenutzer-Kollaboration.** Pläne gehören genau einem Benutzer.
> **Hinweis zur Dokumenthistorie:** Bis Version 0.9 stand hier zusätzlich „keine
> Monte-Carlo-Simulation", „keine Hypothekarzinsen" und „keine Wertentwicklung von Immobilien".
> Alle drei sind seit Version 0.5 bzw. 0.7 umgesetzt ([4.4.5](#445-netto-brutto-umrechnung-für-die-ahv),
> [4.6.5](#465-real_estate-immobilie), [4.12](#412-monte-carlo-simulation)); die Abgrenzung war
> versehentlich stehen geblieben.
Die deterministische Rechnung bleibt der Kern des Tools; Stochastik kommt ausschliesslich in der
**Monte-Carlo-Simulation** ([4.12](#412-monte-carlo-simulation)) und als reine Was-wäre-wenn-
Rechnung in der **Sensitivitätsanalyse** ([4.13](#413-sensitivitätsanalyse-tornado)) dazu.
Beide verändern die gespeicherten Plandaten nicht.
## 1.3 Kernprinzip: Plan als selbsttragende Einheit
Seit dem V3-Rework (Migration `20260713150000_profile_to_plan_v3`) trägt **jeder Plan sein
@@ -823,6 +834,26 @@ Dateiname = Planname, nicht-alphanumerische Zeichen durch `_` ersetzt.
Referenz: `src/lib/calculations.ts` Zeilen 620648.
### 3.6.6 Analyse-Bereich „Einflussfaktoren"
Der Button **Einflussfaktoren berechnen** in der oberen Aktionsleiste (neben „Grafiken" und
„Monte-Carlo-Simulation") öffnet die Sensitivitätsanalyse als eigenen Dialog. Aufbau bewusst
analog zur Monte-Carlo-Simulation:
1. **Erklärung** was die Analyse beantwortet, wie sie rechnet, was man davon hat, und die zwei
ehrlichen Grenzen (Bandbreiten-Abhängigkeit, keine Wechselwirkungen).
2. **Zielgrösse** Endvermögen real (Default) oder nominal.
3. **Parameter** je Treiber eine Checkbox; erst angehakt erscheinen die beiden Pflichtfelder
„tief" und „hoch" in der Einheit des Treibers, mit Hilfe-Bubble zu plausiblen Bandbreiten.
Nicht anwendbare Treiber werden gar nicht erst angezeigt.
4. **Ergebnis** Basisfall, Tornado-Chart und Tabelle.
Der Dialog ist bewusst **nicht** Teil des Grafiken-Bereichs: Dieser ist reine Ausgabe, während der
Tornado Pflichteingaben braucht. Ein Hinweis am Ende der Parameterliste benennt, warum das
**Pensionsalter** nicht enthalten ist (siehe [9.18](#918-tornado-was-der-chart-nicht-leistet)).
Referenz: `src/components/SensitivityDialog.tsx`, `src/components/AppShell.tsx`.
## 3.7 Bedienoberfläche
### 3.7.1 Layout
@@ -1195,9 +1226,6 @@ Basis der Verzinsung ist die Liegenschaft.
Falls **nicht** fortgeschrieben und **nicht** Phase 1 (= Neukauf in einer späteren Phase):
`investmentsFromCash += max(0, equity)` das Eigenkapital wird aus dem Cash finanziert.
Der Wert der Immobilie ist über die Phasendauer **konstant der Kaufpreis**; nur die Hypothek
sinkt. Es gibt keine Wertsteigerung der Verkaufspreis wird erst am Übergang erfasst.
### 4.6.6 OTHER_ASSET
```
@@ -1592,6 +1620,146 @@ Der Median liegt typischerweise **unter** der deterministischen Linie der
leicht zu optimistisch ist. Pflichtfelder: ohne die historischen Ø-Werte startet die Simulation
nicht.
### 4.12.6 Mehrere Szenarien im Vergleich
Der Dialog rechnet auf Wunsch **mehrere Szenarien desselben Plans in einem Lauf**. Drei
Entscheide machen den Vergleich überhaupt aussagekräftig.
**(1) Eine Parametereingabe je logischem Element.** Die MC-Parameter hängen an der `elementId`,
und Element-IDs sind szenario-spezifisch eine Kopie bekommt neue IDs. Ohne Zuordnung müsste
dieselbe Anlage pro Szenario erneut erfasst werden. Das wäre nicht nur mühsam, es würde den
Vergleich **zerstören**: Mit 5 % im einen und 6 % im anderen Szenario vergleicht man die
Eingaben statt der Szenarien.
Die Zuordnung läuft über die Herkunfts-Kette `sourceElementId` dieselbe Grundlage wie beim
Diff ([3.2.6](#326-abweichungs-markierung-diff)). `resolveRootElementId` folgt ihr bis zum
Ursprung; alle Elemente mit derselben Wurzel bilden eine **Gruppe** und teilen einen
Parametersatz. Deshalb lädt der Dialog beim Öffnen **alle** Szenarien des Plans, nicht nur die
ausgewählten: Nur so löst sich die Kette auch über ein übersprungenes Zwischen-Szenario auf
(Basis → S1 → S2 bei Auswahl von Basis und S2). Ein Element, das es nur in einem Szenario gibt,
bildet eine eigene Gruppe und wird im Dialog entsprechend gekennzeichnet.
**(2) Gemeinsamer Seed.** Alle Szenarien eines Laufs verwenden denselben Zufalls-Seed
(*Common Random Numbers*). Ohne das wären kleine Unterschiede blosses Rauschen: Bei 1'000 Läufen
beträgt der Standardfehler der Erfolgswahrscheinlichkeit rund 1.5 Prozentpunkte zwei identische
Szenarien könnten 87 % und 90 % zeigen. Mit gemeinsamem Seed teilen **strukturgleiche** Szenarien
exakt dieselben Marktpfade, und die Unterschiede sind rein strukturell. Einschränkung: Die Pfade
sind nur dort identisch, wo die Struktur es ist abweichende Laufzeit oder Elementzahl verschiebt
die Ziehungsreihenfolge.
**(3) Zielbetrag je Szenario.** Der Zielbetrag ist bewusst **nicht** gemeinsam, sondern je
Szenario mit dessen geplantem Endvermögen vorbelegt (einzeln editierbar). Damit misst die
Erfolgswahrscheinlichkeit, wie oft ein Szenario **sein eigenes Versprechen** hält.
> **Warum das der entscheidende Punkt ist:** In der Simulation wird die *geplante* Rendite
> vollständig durch die gewürfelte ersetzt. Unterscheiden sich zwei Szenarien **nur** in der
> geplanten Rendite (5 % vs. 6 %), sind ihre simulierten Verteilungen **identisch** gleicher
> Median, gleicher Fächer, gleiche Ruinwahrscheinlichkeit. Der einzige Unterschied ist der
> Zielbetrag. Mit einem gemeinsamen Zielbetrag zeigte der Vergleich zwei identische Zeilen; mit
> szenario-eigenem Zielbetrag zeigt er die eigentliche Aussage: Das pessimistisch geplante
> Szenario erreicht sein tieferes Ziel häufiger und ist damit das belastbarere. Durch einen Test
> abgedeckt (Kap. 8.2).
Folge für die Darstellung: Die **Ruinwahrscheinlichkeit** ist zielbetrags-unabhängig und damit
die direkt vergleichbare Kennzahl; die Erfolgswahrscheinlichkeit bezieht sich je Zeile auf eine
andere Messlatte. Deshalb steht der Zielbetrag als **eigene Spalte** in der Vergleichstabelle.
**Darstellung:** eine Vergleichstabelle (Szenario, Ziel, Erfolg, Ruin, P10/Median/P90) als
Hauptinstrument, dazu ein Chart mit der **Median-Linie je Szenario**. Übereinandergelegte
1090 %-Bänder wären unlesbar; der vollständige Fächer inklusive deterministischer Linie erscheint
deshalb nur, wenn **genau ein** Szenario ausgewählt ist dann verhält sich der Dialog exakt wie
zuvor.
**Laufzeit:** Die Szenarien laufen sequenziell, der Fortschritt weist Szenario und Gesamtanteil
aus. Die Schätzung skaliert mit der Anzahl Szenarien.
Referenz: `src/lib/montecarlo.ts` (`resolveRootElementId`, `buildElementGroups`,
`paramsForScenario`, `runMonteCarloMulti`), `src/components/MonteCarloDialog.tsx`.
## 4.13 Sensitivitätsanalyse (Tornado)
Die Monte-Carlo-Simulation würfelt alle Unsicherheiten gleichzeitig und beantwortet „wie
wahrscheinlich geht mein Plan auf?". Die Sensitivitätsanalyse (Roadmap Nr. 20, `sensitivity.ts`)
beantwortet die komplementäre Frage: **„Welche meiner Annahmen entscheidet überhaupt über das
Ergebnis?"**
### 4.13.1 Verfahren
**One-at-a-time (OAT):**
```
base = Zielgrösse(Plan)
für jeden ausgewählten Treiber d:
lowResult = Zielgrösse(applyDriver(Plan, d, d.low))
highResult = Zielgrösse(applyDriver(Plan, d, d.high))
swing = |highResult lowResult|
sortiere absteigend nach swing → Trichterform, längster Balken zuoberst
```
Alle übrigen Parameter bleiben dabei auf dem Planwert. Das sind 2 Aufrufe je Treiber bei
sieben Treibern 14 `computePlan`-Aufrufe, also Millisekunden. Wie die Monte-Carlo-Simulation
läuft alles **im Browser**; `applyDriver` ist rein und lässt den Ausgangsplan unberührt.
**Zielgrösse** ist das Endvermögen der letzten Phase, wahlweise **real** (Default,
kaufkraftbereinigt) oder nominal. Das Ruinalter wäre als Balkengrösse untauglich, weil es in
vielen Plänen `null` ist.
### 4.13.2 Die Treiber und ihre Einheiten
Die Einheit ist je Treiber verschieden und lässt sich nicht vereinheitlichen, ohne fachlich
falsch zu werden:
| Treiber | Einheit | Wirkung |
|---|---|---|
| Ausgaben | **relativ %** | skaliert `amount` aller `EXPENSE`-Elemente |
| Rendite (PK, 3a, Sonstiges Vermögen) | **Δ Prozentpunkte** | verschiebt `expectedReturn` |
| Lebensdauer | **Δ Jahre** | verlängert/verkürzt die **letzte** Phase (min. 1 Jahr) |
| Inflation | **absolut %** | setzt `inflationRateDefault` |
| Einkommen | **relativ %** | skaliert `amount` aller `INCOME`-Elemente |
| Lohnentwicklung | **Δ Prozentpunkte** | verschiebt `teuerungsausgleich` der `INCOME`-Elemente |
| Wertsteigerung der Immobilie | **Δ Prozentpunkte** | verschiebt `valueGrowth` |
Die Begründungen im Einzelnen:
- **Absolut** nur bei der Inflation es gibt genau einen plan-weiten Wert.
- **Δ Prozentpunkte** bei den Renditen, weil die Elemente je eigene Sätze tragen. Ein absolutes
„3 % bis 7 %" würde die PK auf ETF-Rendite plätten.
- **Relativ %** bei Einkommen und Ausgaben, weil die Elemente je eigene Beträge tragen.
- Immobilien-Wertsteigerung ist ein **eigener** Treiber und nicht Teil von „Rendite", damit sie
nicht doppelt zählt.
Die Skalierung von Einkommen/Ausgaben greift nur dort, wo `amount` gesetzt ist. Das ist korrekt
und beabsichtigt: Ab Phase 2 ist der Wert in der Regel live vererbt ([4.6.1](#461-income--expense)),
und die Fortschreibung leitet ihn aus dem skalierten Basiswert ab die Skalierung wirkt damit
automatisch über alle Folgephasen.
Ein Treiber erscheint nur, wenn der Plan passende Elemente enthält (`applies`).
### 4.13.3 Bandbreiten sind Pflicht ohne Default
Je Treiber gibt der Benutzer eine tiefe und eine hohe Ausprägung an; **Vorgabewerte gibt es
bewusst nicht**. Grund: Die Balkenlänge hängt direkt von diesen Bandbreiten ab. Ein stiller
Default würde nicht hinterfragt, und das Ranking wäre dann eine Aussage über unsere Vorgabe
statt über den Plan dieselbe Begründung wie bei den Monte-Carlo-Mittelwerten
([9.15](#915-monte-carlo-misst-risiko-um-die-annahmen-nicht-deren-richtigkeit)).
Die Hilfe-Bubble je Treiber nennt stattdessen plausible Grössenordnungen. Entscheidend ist, die
Bandbreiten **ähnlich plausibel** zu wählen, nicht ähnlich gross: „±10 % Inflation" (1.5 → 1.65 %)
und „±10 % Ausgaben" sind völlig ungleich wahrscheinlich.
Zwei weitere Regeln: mindestens **zwei** Treiber (ein Tornado ist eine Rangliste ein einzelner
Balken ordnet nichts), und tiefer und hoher Wert dürfen nicht identisch sein (Spannweite 0).
### 4.13.4 Darstellung
Waagrechtes Balkendiagramm, je Balken die Spanne `min…max` der Zielgrösse, senkrechte
Referenzlinie beim Basisfall, sortiert nach Spannweite. Darunter eine Tabelle mit der
eingegebenen Bandbreite, den beiden Ergebniswerten und der Spannweite.
Die **Richtung kann sich umkehren** tiefe Ausgaben ergeben ein hohes Endvermögen. Der Balken
spannt deshalb über `min…max`; welche Eingabe zu welchem Ende gehört, zeigt die Tabelle.
Referenz: `src/lib/sensitivity.ts`, `src/components/SensitivityDialog.tsx`.
---
# 5. Technische Spezifikation
@@ -1624,7 +1792,7 @@ nicht.
FPT/
├── prisma/
│ ├── schema.prisma Datenmodell
│ └── migrations/ 8 Migrationen (chronologisch)
│ └── migrations/ 12 Migrationen (chronologisch, siehe 5.4.6)
├── src/
│ ├── app/
│ │ ├── api/ Route Handlers (siehe Kapitel 6)
@@ -1632,7 +1800,7 @@ FPT/
│ │ ├── page.tsx Einstiegsseite (lädt /api/auth/me → AppShell)
│ │ ├── layout.tsx Root-Layout, Theme-Init-Script, Metadata
│ │ └── globals.css Tailwind + semantische Farb-Tokens (3 Themes)
│ ├── components/ 12 React-Komponenten (alle "use client")
│ ├── components/ 15 React-Komponenten (alle "use client")
│ ├── generated/prisma/ Generierter Prisma-Client (nicht editieren)
│ ├── lib/ Domänenlogik (siehe 5.3)
│ └── middleware.ts Zugriffsschutz (Edge-Runtime)
@@ -1663,7 +1831,9 @@ 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. |
| `montecarlo.ts` | Monte-Carlo-Simulation (Sampler + Treiber), Szenario-Vergleich und Element-Gruppierung über die Herkunfts-Kette. Keine I/O, läuft im Browser. |
| `sensitivity.ts` | Sensitivitätsanalyse / Tornado: Treiber-Katalog, Parameter-Transformationen, `computeTornado`. Rein, läuft im Browser. |
| `diff.ts` | Abweichungs-Erkennung eines Szenarios gegen sein Eltern-Szenario (Kap. 3.2.6) |
| `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`) |
@@ -1916,7 +2086,9 @@ 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 |
| `MonteCarloDialog` | ~600 | Monte-Carlo-Dialog: Erklärung, Szenario-Auswahl, Eingaben, Lauf, Vergleichstabelle + Fächer |
| `SensitivityDialog` | ~330 | Einflussfaktoren: Erklärung, Treiber-Auswahl mit Bandbreiten, Tornado + Tabelle |
| `SpecView` | 50 | Rendert `SPEZIFIKATION.md` (via `/api/spec`) als lesbares Dokument |
| `InfoBubble` | 28 | Hilfe-Tooltip |
### 5.5.3 Wiederverwendungsmuster
@@ -2139,11 +2311,17 @@ 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` (41 Tests: AHV-Rentenformel, Immobilie,
Teilverkauf, Sonderamortisation, AHV einkommensabhängig, „V5 Golden Tests") und
`src/lib/montecarlo.test.ts` (7 Tests), `src/lib/diff.test.ts` (9 Tests) und `src/lib/migrations.test.ts` (1 Test, spielt alle Migrationen gegen echtes PostgreSQL ein) ergeben zusammen **60 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. Ausgeführt mit Vitest in der Node-Umgebung (`vitest.config.ts`,
Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests.
| Datei | Tests | Schwerpunkt |
|---|---|---|
| `calculations.test.ts` | 43 | AHV-Rentenformel, Immobilie, Teilverkauf, Sonderamortisation, AHV einkommensabhängig, „V5 Golden Tests" |
| `sensitivity.test.ts` | 14 | Treiber-Transformationen (Reinheit, Einheiten, Kappung), Verfügbarkeit, Tornado-Sortierung und -Richtung |
| `montecarlo.test.ts` | 13 | Determinismus, Volatilität/Vol-Drag, Böden, Reproduzierbarkeit; Element-Gruppierung und Szenario-Vergleich |
| `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario |
| `migrations.test.ts` | 1 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein |
| **Total** | **80** | |
## 8.2 Testfälle
@@ -2170,6 +2348,15 @@ API- oder E2E-Tests.
| **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 % |
| **MC: Herkunfts-Kette** | `resolveRootElementId` folgt der Kette bis zum Ursprung; Verweis ins Leere → eigenes Element ist Wurzel; defekte Kette terminiert |
| **MC: Element-Gruppen** | Kopie und Original ergeben **eine** Gruppe (nicht zwei); Auflösung auch über ein nicht ausgewähltes Zwischen-Szenario; ein nur in einem Szenario neues Element bildet eine eigene Gruppe |
| **MC: Parameter-Übersetzung** | `paramsForScenario` bildet die Gruppen-Parameter auf die Element-Ids des jeweiligen Szenarios ab |
| **MC: Szenario-Vergleich** | Zwei Szenarien, die sich nur in der **geplanten** Rendite unterscheiden: identischer Median/P10/Ruin (gleicher Seed, gleiche Struktur), aber **höhere Erfolgswahrscheinlichkeit** beim pessimistisch geplanten der einzige Unterschied ist der Zielbetrag |
| **Tornado: Reinheit** | `applyDriver` lässt den Ausgangsplan unverändert |
| **Tornado: Einheiten** | Inflation absolut gesetzt; Rendite/Lohnentwicklung in pp verschoben; Ausgaben relativ skaliert (Einkommen unberührt) |
| **Tornado: Lebensdauer** | verschiebt nur die letzte Phase; Kappung bei mindestens 1 Jahr |
| **Tornado: Verfügbarkeit** | Treiber ohne passende Elemente werden ausgeblendet (z. B. Immobilien-Wertsteigerung ohne Immobilie) |
| **Tornado: Sortierung/Richtung** | absteigend nach Spannweite; Treiber ohne Bandbreite hat Spannweite 0 und steht zuunterst; höhere Ausgaben → tieferes, höhere Rendite → höheres Endvermögen |
| **Vermögen je Jahr** | 100k @ 10 % über 3 J. → 110k/121k/133.1k je Jahrespunkt; Endjahr = Phasen-Endvermögen |
| **Vermögen real** | 100k bei 10 % Inflation → real 90'909 |
| Test 1 Ansparen | Einkommen +2 % nominal, Ausgaben real flach: Endvermögen 761'654 nominal / 565'928 real (±1 %), kein negatives Cash |
@@ -2393,6 +2580,45 @@ Verwandt: Wird ein Element in der Vorlage gelöscht, gilt das Gegenstück im Kin
(`react-hooks/set-state-in-effect`); an vergleichbaren Stellen ist die Regel andernorts
bewusst per `eslint-disable` deaktiviert.
## 9.18 Tornado: was der Chart nicht leistet
**Die Balkenlänge hängt von den eingegebenen Bandbreiten ab.** Wer Renditen mit ±2 Prozentpunkten
und Ausgaben mit ±20 % variiert, misst zu einem Teil die eigene Wahl dieser Bandbreiten. Deshalb
sind sie Pflichteingabe ohne Default und im Ergebnis sichtbar ([4.13.3](#4133-bandbreiten-sind-pflicht--ohne-default)).
Aussagekräftig ist die **Reihenfolge**, nicht der absolute Betrag.
**One-at-a-time sieht keine Wechselwirkungen.** Schlechte Renditen *und* hohe Ausgaben treffen
härter als die Summe der Einzelbalken weil in der Folge Kapital verzehrt wird, das später zur
Verzinsung fehlt. Für Kombinationen ist die Monte-Carlo-Simulation zuständig.
**Das Pensionsalter ist bewusst nicht enthalten.** Die Roadmap nennt es als Top-Hebel, aber
`retirementAge` lässt sich im aktuellen Datenmodell nicht isoliert variieren, ohne die
Phasengrenzen mitzuverschieben und das Ergebnis wäre nicht ungenau, sondern **irreführend**.
Beispiel: Person 45, Pension mit 65, Phase 1 = 20 Jahre (45→65), Phase 2 = 20 Jahre.
- **Pensionsalter auf 62:** Phase 1 beginnt mit 45, also `45 < 62` → die Phase bleibt vollständig
Erwerbsphase. Die Person arbeitet im Modell weiterhin bis 65, der Pensions-Übergang liegt an
derselben Grenze. Wirkung auf das Ergebnis: **praktisch null.**
- **Pensionsalter auf 68:** Phase 2 beginnt mit 65, also `65 < 68` → Phase 2 wird zur
**Erwerbsphase**, das Einkommen läuft weiter. Zugleich wird `ownerRetiresNext` an der Grenze
nach Phase 1 falsch (65 ≥ 68 trifft nicht zu), womit der **Pensions-Übergang komplett entfällt**:
keine PK-Verrentung, kein 3a-Bezug, keine AHV-Rente. Der Balken wäre riesig er misst aber den
Ausfall der Vorsorgelogik, nicht „drei Jahre länger arbeiten".
Fachlich korrekt wäre nur, `retirementAge` **und** die Phasengrenze gemeinsam zu verschieben
(Erwerbsphase kürzer, Pensionsphase länger). Das hat eigene Sonderfälle Paare mit
unterschiedlichem Pensionsalter, Grenzen abseits des Pensionsereignisses, Verschiebung grösser als
die Phasendauer und ist als eigener Arbeitsschritt offen. Der verwandte Treiber **Lebensdauer**
(Dauer der letzten Phase) ist dagegen sauber abgebildet und deckt einen Teil des Bedürfnisses ab.
## 9.19 Simulationsparameter werden nicht gespeichert
Weder die Monte-Carlo-Annahmen noch die Tornado-Bandbreiten werden persistiert; beide leben nur
im geöffneten Dialog. Das ist ein bewusster Entscheid (kein Datenmodell für Annahmen, keine
Migration), hat aber den Preis, dass eine Analyse bei jedem Öffnen neu parametrisiert werden muss.
Bei der Monte-Carlo-Simulation über mehrere Szenarien fällt das stärker ins Gewicht als zuvor,
weil dort mehr Eingaben zusammenkommen.
---
# 10. Glossar
@@ -2400,7 +2626,9 @@ Verwandt: Wird ein Element in der Vorlage gelöscht, gilt das Gegenstück im Kin
| Begriff | Bedeutung im FPT |
|---|---|
| **Plan** | Selbsttragende Planungseinheit: Grundprofil + Phasenkette + Elemente |
| **Szenario** | Deep-Copy eines Plans bis zu einer Verzweigungsphase; danach unabhängig |
| **Szenario** | Die berechenbare Einheit (seit V6): trägt Grundprofil, Phasenkette und Elemente. Jeder Plan hat genau ein Basisszenario; weitere entstehen als vollständige Kopie eines beliebigen Szenarios und hängen als Baum darunter |
| **Logisches Element** | Dasselbe finanzielle Element über Szenariogrenzen hinweg, erkannt über die Herkunfts-Kette `sourceElementId` Grundlage der einmaligen Parametereingabe im Szenario-Vergleich ([4.12.6](#4126-mehrere-szenarien-im-vergleich)) |
| **Spannweite (Tornado)** | Differenz zwischen dem Ergebnis beim tiefen und beim hohen Wert eines Treibers; bestimmt Balkenlänge und Rangfolge |
| **Grundprofil** | Haushaltsform, Personen (Alter, Pensionsalter, Name), Inflationsannahme |
| **Lebensphase** | Zeitabschnitt mit fester Dauer; darf keine Pensionierung überspannen |
| **Phasentyp** | `ERWERB` / `PENSION` / `MIXED`; abgeleitet, nie gespeichert |
@@ -2429,4 +2657,4 @@ Verwandt: Wird ein Element in der Vorlage gelöscht, gilt das Gegenstück im Kin
---
*Ende der Spezifikation v0.1*
*Ende der Spezifikation v1.0*
+17
View File
@@ -12,12 +12,14 @@ import {
Menu,
PiggyBank,
Plus,
Tornado,
Trash2,
X,
} from "lucide-react";
import { PlanView } from "@/components/PlanView";
import { Dashboard } from "@/components/Dashboard";
import { MonteCarloDialog } from "@/components/MonteCarloDialog";
import { SensitivityDialog } from "@/components/SensitivityDialog";
import { SpecView } from "@/components/SpecView";
import { ProfileMenu } from "@/components/ProfileMenu";
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
@@ -44,6 +46,7 @@ export function AppShell({ username }: { username: string }) {
const [showSpec, setShowSpec] = useState(false);
const [showCharts, setShowCharts] = useState(false);
const [showMonteCarlo, setShowMonteCarlo] = useState(false);
const [showSensitivity, setShowSensitivity] = useState(false);
const loadPlans = useCallback(async () => {
const data = await api.get<{ plans: PlanListItem[] }>("/api/plans");
@@ -290,6 +293,14 @@ export function AppShell({ username }: { username: string }) {
<Dices className="h-4 w-4" />
Monte-Carlo-Simulation
</button>
<button
type="button"
onClick={() => setShowSensitivity(true)}
className="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"
>
<Tornado className="h-4 w-4" />
Einflussfaktoren berechnen
</button>
</>
)}
{diff && detail.base && (
@@ -337,10 +348,16 @@ export function AppShell({ username }: { username: string }) {
<MonteCarloDialog
plan={detail.plan}
computed={detail.computed}
meta={detail.meta}
scenarios={activePlan?.scenarios ?? [detail.meta]}
onClose={() => setShowMonteCarlo(false)}
/>
)}
{showSensitivity && detail && (
<SensitivityDialog plan={detail.plan} onClose={() => setShowSensitivity(false)} />
)}
{copyFrom && (
<CopyScenarioDialog
source={copyFrom}
+47
View File
@@ -51,6 +51,53 @@ export function NumberField({
);
}
// Pflicht-Zahlenfeld, das wirklich LEER sein kann (NumberField erzwingt eine Zahl und haette
// damit immer einen Default -- genau das soll bei Annahmen vermieden werden, siehe
// SPEZIFIKATION 9.15/9.18: ein stiller Default wird nicht hinterfragt). Der Wert wird als
// String gefuehrt; die Validierung liegt beim Aufrufer.
export function RequiredNumberField({
label,
help,
value,
onChange,
step = 0.1,
suffix,
placeholder = "Pflicht",
}: {
label: string;
help?: string;
value: string;
onChange: (value: string) => void;
step?: number;
suffix?: string;
placeholder?: string;
}) {
const empty = value.trim() === "";
return (
<div>
<FieldLabel label={label} help={help} />
<div className="relative">
<input
type="number"
step={step}
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
className={`${baseInputClass} ${suffix ? "pr-8" : ""} ${
empty ? "border-danger focus:border-danger" : ""
}`}
/>
{suffix && (
<span className="pointer-events-none absolute inset-y-0 right-2.5 flex items-center text-xs text-faint">
{suffix}
</span>
)}
</div>
{empty && <p className="mt-1 text-[11px] text-danger">Pflichtfeld bitte ausfüllen.</p>}
</div>
);
}
// Ganzzahliges Betragsfeld. Zeigt den Wert unfokussiert mit 1'000er-Trennzeichen an,
// akzeptiert fokussiert beliebige ganze Zahlen (keine Nachkommastellen) und bietet
// Pfeil-Buttons mit Klick-und-Halten-BESCHLEUNIGUNG (1 -> 10 -> 100 -> 1'000 -> ...).
+398 -187
View File
@@ -1,26 +1,35 @@
"use client";
import { useMemo, useState } from "react";
import { Area, CartesianGrid, ComposedChart, Legend, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { useEffect, useMemo, useState } from "react";
import { Area, CartesianGrid, ComposedChart, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { Dices, X } from "lucide-react";
import { NumberField, SelectField, MoneyField } from "@/components/FormField";
import { NumberField, SelectField, MoneyField, RequiredNumberField } from "@/components/FormField";
import { InfoBubble } from "@/components/InfoBubble";
import { api } from "@/lib/api-client";
import { formatChf } from "@/lib/format";
import {
runMonteCarlo,
buildElementGroups,
defaultVolatilityLevel,
RETURN_VOLATILITY_LEVELS,
floorFor,
paramsForScenario,
runMonteCarloMulti,
INFLATION_VOLATILITY_LEVELS,
RETURN_BEARING,
floorFor,
type ReturnVolatilityLevel,
RETURN_VOLATILITY_LEVELS,
type ElementGroup,
type ElementMcParams,
type InflationVolatilityLevel,
type MonteCarloResult,
type ReturnVolatilityLevel,
type ScenarioMcResult,
type ScenarioRunInput,
} from "@/lib/montecarlo";
import { CATEGORY_LABELS } from "@/lib/elements";
import type { PlanInput } from "@/lib/types";
import type { PlanInput, ScenarioMeta } from "@/lib/types";
import type { PlanComputed } from "@/lib/calculations";
// Farben der Szenario-Serien -- wie im Vermoegensverlauf, damit die Zuordnung vertraut bleibt.
const PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"];
const RETURN_LEVEL_OPTIONS: { value: ReturnVolatilityLevel; label: string }[] = [
{ value: "sehr_niedrig", label: "Sehr niedrig" },
{ value: "niedrig", label: "Niedrig" },
@@ -40,149 +49,186 @@ const RETURN_HELP =
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>
);
interface ElementDraft {
mean: string;
level: ReturnVolatilityLevel;
manualSigma: string;
}
type ElementInput = { mean: string; level: ReturnVolatilityLevel; manualSigma: string };
interface LoadedScenario {
id: string;
name: string;
plan: PlanInput;
computed: PlanComputed;
}
function returnSigma(el: ElementInput): number {
return el.level === "manuell"
? Number(el.manualSigma) || 0
: RETURN_VOLATILITY_LEVELS[el.level];
function returnSigma(el: ElementDraft): 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];
}
// Deterministische Planungslinie eines Szenarios (Alterspunkte = Phasengrenzen).
function detPointsOf(plan: PlanInput, computed: PlanComputed): { age: number; det: number }[] {
const startAge = plan.persons.find((p) => p.role === "PERSON_A")?.age ?? plan.persons[0]?.age ?? 0;
if (computed.phases.length === 0) return [];
const pts = [{ 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;
}
export function MonteCarloDialog({
plan,
computed,
meta,
scenarios,
onClose,
}: {
plan: PlanInput;
computed: PlanComputed;
meta: ScenarioMeta;
// Alle Szenarien dieses Plans (auch nicht ausgewaehlte) -- sie werden geladen, damit sich
// die Herkunfts-Kette der Elemente auch ueber uebersprungene Zwischen-Szenarien aufloest.
scenarios: ScenarioMeta[];
onClose: () => void;
}) {
const returnElements = useMemo(
() => plan.elements.filter((e) => RETURN_BEARING.includes(e.category)),
[plan.elements]
);
const [loaded, setLoaded] = useState<Record<string, LoadedScenario>>(() => ({
[meta.id]: { id: meta.id, name: meta.name, plan, computed },
}));
const [loading, setLoading] = useState(scenarios.some((s) => s.id !== meta.id));
const [loadError, setLoadError] = useState<string | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([meta.id]);
const [targets, setTargets] = useState<Record<string, number>>({
[meta.id]: Math.max(0, computed.nachlass),
});
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 [drafts, setDrafts] = useState<Record<string, ElementDraft>>({});
const [running, setRunning] = useState(false);
const [progress, setProgress] = useState(0);
const [result, setResult] = useState<MonteCarloResult | null>(null);
const [progress, setProgress] = useState({ index: 0, count: 1, fraction: 0 });
const [results, setResults] = useState<ScenarioMcResult[] | null>(null);
function setEl(id: string, patch: Partial<ElementInput>) {
setEls((prev) => ({ ...prev, [id]: { ...prev[id], ...patch } }));
const scenarioKey = scenarios.map((s) => s.id).join(",");
// Die uebrigen Szenarien einmalig nachladen. Ein Plan hat realistisch eine Handvoll
// Szenarien -- gegenueber tausenden Simulationslaeufen faellt das nicht ins Gewicht.
useEffect(() => {
const missing = scenarios.filter((s) => s.id !== meta.id);
if (missing.length === 0) return;
let cancelled = false;
(async () => {
try {
const entries = await Promise.all(
missing.map(async (s) => {
const data = await api.get<{ plan: PlanInput; computed: PlanComputed }>(`/api/scenarios/${s.id}`);
return [s.id, { id: s.id, name: s.name, plan: data.plan, computed: data.computed }] as const;
})
);
if (cancelled) return;
setLoaded((prev) => ({ ...prev, ...Object.fromEntries(entries) }));
setTargets((prev) => ({
...prev,
...Object.fromEntries(entries.map(([id, v]) => [id, Math.max(0, v.computed.nachlass)])),
}));
} catch (e) {
if (!cancelled) setLoadError(e instanceof Error ? e.message : "Szenarien konnten nicht geladen werden.");
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scenarioKey, meta.id]);
const allLoaded = useMemo(
() => scenarios.map((s) => loaded[s.id]).filter((s): s is LoadedScenario => !!s),
[scenarios, loaded]
);
// Ein Parametersatz je LOGISCHEM Element (ueber die sourceElementId-Kette zusammengefasst).
const groups = useMemo(
() => buildElementGroups(allLoaded, selectedIds),
[allLoaded, selectedIds]
);
function draftFor(g: ElementGroup): ElementDraft {
return drafts[g.rootId] ?? { mean: "", level: defaultVolatilityLevel(g.category), manualSigma: "10" };
}
function setDraft(rootId: string, group: ElementGroup, patch: Partial<ElementDraft>) {
setDrafts((prev) => ({ ...prev, [rootId]: { ...draftFor(group), ...prev[rootId], ...patch } }));
}
// Pflichtfelder: historische Inflation + je Element die historische Rendite muessen gesetzt sein.
function toggleScenario(id: string) {
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
setResults(null);
}
const selected = selectedIds.map((id) => loaded[id]).filter((s): s is LoadedScenario => !!s);
const anyReturnBearing = selected.some((s) => s.plan.elements.some((e) => RETURN_BEARING.includes(e.category)));
// Pflichtfelder: historische Inflation + je logischem Element die historische Rendite.
const missing =
inflMean.trim() === "" || returnElements.some((e) => (els[e.id]?.mean ?? "").trim() === "");
inflMean.trim() === "" || groups.some((g) => draftFor(g).mean.trim() === "");
const canRun = !missing && selected.length > 0 && anyReturnBearing && !loading;
// 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));
const estSeconds = Math.max(1, Math.round((runs * Math.max(1, selected.length)) / 6000));
async function run() {
setRunning(true);
setResult(null);
setProgress(0);
setResults(null);
setProgress({ index: 0, count: selected.length, fraction: 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 paramByRoot: Record<string, ElementMcParams> = Object.fromEntries(
groups.map((g) => {
const d = draftFor(g);
return [g.rootId, { mean: Number(d.mean) || 0, sigma: returnSigma(d), floor: floorFor(g.category) }];
})
);
const res = await runMonteCarlo(
plan,
// EIN Seed fuer alle Szenarien: strukturgleiche Szenarien teilen damit dieselben
// Marktpfade, und die Unterschiede sind strukturell statt zufaellig.
const seed = (Math.random() * 2 ** 32) >>> 0;
const runInputs: ScenarioRunInput[] = selected.map((s) => ({
scenarioId: s.id,
name: s.name,
plan: s.plan,
target: targets[s.id] ?? 0,
}));
const res = await runMonteCarloMulti(
runInputs,
{
runs,
inflationMean: Number(inflMean) || 0,
inflationSigma: inflationSigma(inflLevel, inflManual),
elements,
target,
seed,
elementsFor: (p) => paramsForScenario(p, groups, paramByRoot),
},
(done, total) => setProgress(done / total)
(index, count, fraction) => setProgress({ index, count, fraction })
);
setResult(res);
setResults(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]);
const overallProgress =
progress.count > 0 ? (progress.index + progress.fraction) / progress.count : 0;
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"
className="flex w-full max-w-4xl 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">
@@ -206,6 +252,12 @@ export function MonteCarloDialog({
<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 className="mb-2">
<strong className="text-fg">Mehrere Szenarien:</strong> Du kannst unten mehrere Szenarien dieses Plans
gleichzeitig rechnen. Die historischen Annahmen werden dabei nur <strong className="text-fg">einmal</strong> erfasst
und für alle Szenarien verwendet sonst würdest du deine Eingaben vergleichen statt der Szenarien. Alle
Szenarien laufen zudem mit demselben Zufalls-Seed, damit Unterschiede nicht blosses Rauschen sind.
</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
@@ -214,11 +266,60 @@ export function MonteCarloDialog({
</p>
</div>
{/* Szenario-Auswahl inkl. Zielbetrag je Szenario */}
<div>
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
Szenarien
<InfoBubble text="Wähle, für welche Szenarien dieses Plans gerechnet werden soll. Der Zielbetrag ist je Szenario eigen und mit dem jeweils geplanten Endvermögen vorbelegt: So misst die Erfolgswahrscheinlichkeit, wie oft jedes Szenario sein eigenes Versprechen hält. Ein pessimistisch geplantes Szenario hat einen tieferen Zielbetrag und damit eine höhere Erfolgswahrscheinlichkeit." />
</div>
{loading && <p className="text-xs text-muted">Szenarien werden geladen</p>}
{loadError && <p className="text-xs text-danger">{loadError}</p>}
<div className="flex flex-col gap-2">
{scenarios.map((s) => {
const isLoaded = !!loaded[s.id];
const checked = selectedIds.includes(s.id);
return (
<div
key={s.id}
className={`flex flex-wrap items-center gap-3 rounded-xl border p-3 ${
checked ? "border-accent bg-accent-soft/30" : "border-border bg-surface-2"
}`}
>
<label className="flex min-w-0 flex-1 items-center gap-2 text-sm">
<input
type="checkbox"
checked={checked}
disabled={!isLoaded}
onChange={() => toggleScenario(s.id)}
/>
<span className="truncate font-medium text-fg">{s.name}</span>
{s.isBase && <span className="rounded bg-surface px-1.5 text-[10px] text-muted">Basis</span>}
{!isLoaded && <span className="text-[11px] text-faint">lädt</span>}
</label>
{checked && (
<div className="w-56">
<MoneyField
label="Zielbetrag (Endvermögen)"
help="Vorbelegt mit dem geplanten Endvermögen DIESES Szenarios. Die Erfolgswahrscheinlichkeit misst, wie oft dieser Betrag erreicht wird."
value={targets[s.id] ?? 0}
onChange={(v) => setTargets((prev) => ({ ...prev, [s.id]: v }))}
/>
</div>
)}
</div>
);
})}
</div>
{selected.length === 0 && (
<p className="mt-2 text-xs text-danger">Bitte mindestens ein Szenario auswählen.</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
<RequiredNumberField
label="Ø Inflation letzte 20 J. (%)"
help={`Der Mittelpunkt, um den gewürfelt wird. Deine Planung nutzt aktuell ${plan.inflationRateDefault} %. CH langfristig ~2 %.`}
value={inflMean}
@@ -239,45 +340,61 @@ export function MonteCarloDialog({
</div>
</div>
{/* Elemente */}
{/* Renditetragende Elemente -- ein Satz je logischem Element */}
<div>
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">Renditetragende Elemente</div>
{returnElements.length === 0 && (
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
Renditetragende Elemente
{selected.length > 1 && (
<InfoBubble text="Ein Element, das in mehreren Szenarien vorkommt, erscheint hier nur einmal erkannt über seine Herkunft beim Kopieren des Szenarios, nicht über den Namen. Elemente, die es nur in einem Szenario gibt, erscheinen mit einem entsprechenden Hinweis." />
)}
</div>
{groups.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).
Die ausgewählten Szenarien haben 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];
{groups.map((g) => {
const d = draftFor(g);
const partial = g.scenarioIds.length < selected.length;
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 key={g.rootId} className="rounded-xl border border-border bg-surface-2 p-3">
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
<span className="font-semibold text-fg">{g.name}</span>
<span className="text-xs text-faint">{CATEGORY_LABELS[g.category]}</span>
{partial && (
<span className="rounded bg-surface px-1.5 py-0.5 text-[10px] text-muted">
nur in: {g.scenarioIds.map((id) => loaded[id]?.name ?? id).join(", ")}
</span>
)}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<MeanField
<RequiredNumberField
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 })}
value={d.mean}
onChange={(v) => setDraft(g.rootId, g, { mean: v })}
/>
<SelectField
label="Streuung"
help={RETURN_HELP}
value={ei.level}
onChange={(v: ReturnVolatilityLevel) => setEl(e.id, { level: v })}
value={d.level}
onChange={(v: ReturnVolatilityLevel) => setDraft(g.rootId, g, { 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) })} />
{d.level === "manuell" ? (
<NumberField
label="Standardabw. (%)"
step={0.5}
value={Number(d.manualSigma) || 0}
onChange={(v) => setDraft(g.rootId, g, { manualSigma: String(v) })}
/>
) : (
<ReadOnlySigma value={RETURN_VOLATILITY_LEVELS[ei.level]} />
<ReadOnlySigma value={RETURN_VOLATILITY_LEVELS[d.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>
{(g.category === "PENSION_FUND" || g.category === "PILLAR_3A") && (
<p className="mt-2 text-[11px] text-faint">Boden 0 %: {CATEGORY_LABELS[g.category]} schreibt keine negative Rendite gut.</p>
)}
</div>
);
@@ -298,14 +415,10 @@ export function MonteCarloDialog({
{ value: "10000", label: "10'000 (genau)" },
]}
/>
<p className="mt-1 text-[11px] text-faint">geschätzt ~{estSeconds} s</p>
<p className="mt-1 text-[11px] text-faint">
{selected.length > 1 ? `${selected.length} Szenarien · ` : ""}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>}
@@ -313,20 +426,31 @@ export function MonteCarloDialog({
<div className="flex items-center gap-3">
<button
type="button"
disabled={missing || running || returnElements.length === 0}
disabled={!canRun || running}
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"}
{running
? `Berechne… ${progress.count > 1 ? `Szenario ${progress.index + 1}/${progress.count} · ` : ""}${Math.round(overallProgress * 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 className="h-full bg-accent transition-all" style={{ width: `${overallProgress * 100}%` }} />
</div>
)}
</div>
{result && <MonteCarloResults result={result} target={target} chartData={chartData} />}
{results && results.length > 0 && (
<MonteCarloResults
results={results}
detPoints={
results.length === 1 && loaded[results[0].scenarioId]
? detPointsOf(loaded[results[0].scenarioId].plan, loaded[results[0].scenarioId].computed)
: []
}
/>
)}
</div>
</div>
);
@@ -347,74 +471,161 @@ function ReadOnlySigma({ value }: { value: number }) {
}
function MonteCarloResults({
result,
target,
chartData,
results,
detPoints,
}: {
result: MonteCarloResult;
target: number;
chartData: { age: number; band: [number, number]; median: number; det: number | null }[];
results: ScenarioMcResult[];
detPoints: { age: number; det: number }[];
}) {
const single = results.length === 1;
// Einzelnes Szenario: der gewohnte Faecher (Band + Median + Planungslinie).
const singleData = useMemo(() => {
if (!single) return [];
return results[0].bands.map((b) => ({
age: b.age,
band: [b.p10, b.p90] as [number, number],
median: b.p50,
det: detPoints.find((d) => d.age === b.age)?.det ?? null,
}));
}, [single, results, detPoints]);
// Mehrere Szenarien: nur die Median-Linien -- uebereinandergelegte Baender waeren Farbbrei.
const compareData = useMemo(() => {
if (single) return [];
const ages = Array.from(new Set(results.flatMap((r) => r.bands.map((b) => b.age)))).sort((a, b) => a - b);
return ages.map((age) => {
const row: Record<string, number | null> = { age };
for (const r of results) row[r.scenarioId] = r.bands.find((b) => b.age === age)?.p50 ?? null;
return row;
});
}, [single, results]);
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} />
{/* Vergleichstabelle -- das eigentliche Vergleichsinstrument */}
<div>
<div className="mb-1 flex items-center text-xs font-semibold text-fg">
Ergebnis je Szenario
{!single && (
<InfoBubble text="Die Erfolgswahrscheinlichkeit bezieht sich je Szenario auf dessen EIGENEN Zielbetrag (Spalte Ziel) sie misst, wie oft ein Szenario sein eigenes Versprechen hält. Die Ruinwahrscheinlichkeit ist zielbetrags-unabhängig und damit direkt vergleichbar." />
)}
</div>
<div className="overflow-x-auto rounded-xl border border-border">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-surface-2 text-xs text-faint">
<th className="px-3 py-2 text-left font-semibold">Szenario</th>
<th className="px-3 py-2 text-right font-semibold">Ziel</th>
<th className="px-3 py-2 text-right font-semibold">Erfolg</th>
<th className="px-3 py-2 text-right font-semibold">Ruin</th>
<th className="px-3 py-2 text-right font-semibold">Pessimistisch (10 %)</th>
<th className="px-3 py-2 text-right font-semibold">Median</th>
<th className="px-3 py-2 text-right font-semibold">Optimistisch (90 %)</th>
</tr>
</thead>
<tbody>
{results.map((r, i) => (
<tr key={r.scenarioId} className="border-t border-border">
<td className="px-3 py-2">
<span className="flex items-center gap-2 font-medium text-fg">
{!single && (
<span
className="inline-block h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: PALETTE[i % PALETTE.length] }}
/>
)}
{r.name}
</span>
</td>
<td className="px-3 py-2 text-right text-muted">{formatChf(r.target)}</td>
<td className="px-3 py-2 text-right font-semibold text-success">
{Math.round(r.successProbability * 100)} %
</td>
<td className="px-3 py-2 text-right font-semibold text-danger">
{Math.round(r.ruinProbability * 100)} %
</td>
<td className="px-3 py-2 text-right text-muted">{formatChf(r.finalWealthP10)}</td>
<td className="px-3 py-2 text-right text-fg">{formatChf(r.finalWealthMedian)}</td>
<td className="px-3 py-2 text-right text-muted">{formatChf(r.finalWealthP90)}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-1 text-[11px] text-faint">
{results[0].runs.toLocaleString("de-CH")} Läufe je Szenario
{!single && " · gemeinsamer Zufalls-Seed"}
</p>
</div>
<div>
<div className="mb-1 text-xs font-semibold text-fg">Vermögensfächer nach Alter (nominal)</div>
<div className="mb-1 text-xs font-semibold text-fg">
{single ? "Vermögensfächer nach Alter (nominal)" : "Vermögensverlauf im Median 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).
{single ? (
<>
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).
</>
) : (
<>
Je Szenario die mittlere Entwicklung (Median). Die vollständigen 1090 %-Bänder werden hier bewusst nicht
übereinandergelegt wähle ein einzelnes Szenario, um den Fächer zu sehen.
</>
)}
</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>
{single ? (
<ComposedChart data={singleData} 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>
) : (
<LineChart data={compareData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis
dataKey="age"
type="number"
domain={["dataMin", "dataMax"]}
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 }} />
{results.map((r, i) => (
<Line
key={r.scenarioId}
dataKey={r.scenarioId}
name={r.name}
stroke={PALETTE[i % PALETTE.length]}
strokeWidth={2}
dot={false}
connectNulls
isAnimationActive={false}
/>
))}
</LineChart>
)}
</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>
);
}
+316
View File
@@ -0,0 +1,316 @@
"use client";
import { useMemo, useState } from "react";
import { Bar, BarChart, CartesianGrid, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { Tornado, X } from "lucide-react";
import { RequiredNumberField, SelectField } from "@/components/FormField";
import { InfoBubble } from "@/components/InfoBubble";
import { formatChf } from "@/lib/format";
import {
computeTornado,
DRIVERS,
UNIT_SUFFIX,
type DriverDef,
type DriverId,
type TornadoMetric,
type TornadoResult,
} from "@/lib/sensitivity";
import type { PlanInput } from "@/lib/types";
interface RangeDraft {
checked: boolean;
low: string;
high: string;
}
// Bewusst OHNE Defaults: Die Balkenlaenge haengt direkt von der eingegebenen Bandbreite ab.
// Ein stiller Default wuerde nicht hinterfragt und das Ranking waere dann eine Aussage ueber
// unsere Vorgabe statt ueber den Plan (gleiche Begruendung wie bei den Monte-Carlo-Mittelwerten).
const emptyDraft: RangeDraft = { checked: false, low: "", high: "" };
function formatRange(low: number, high: number, unit: DriverDef["unit"]): string {
const suffix = UNIT_SUFFIX[unit];
const sign = (v: number) => (unit === "abs_pct" ? `${v}` : v > 0 ? `+${v}` : `${v}`);
return `${sign(low)} ${suffix}${sign(high)} ${suffix}`;
}
export function SensitivityDialog({ plan, onClose }: { plan: PlanInput; onClose: () => void }) {
const available = useMemo(() => DRIVERS.filter((d) => d.applies(plan)), [plan]);
const [metric, setMetric] = useState<TornadoMetric>("real");
const [drafts, setDrafts] = useState<Record<string, RangeDraft>>({});
const [result, setResult] = useState<TornadoResult | null>(null);
function draftFor(id: DriverId): RangeDraft {
return drafts[id] ?? emptyDraft;
}
function setDraft(id: DriverId, patch: Partial<RangeDraft>) {
setDrafts((prev) => ({ ...prev, [id]: { ...draftFor(id), ...patch } }));
setResult(null);
}
const checked = available.filter((d) => draftFor(d.id).checked);
const incomplete = checked.filter((d) => {
const dr = draftFor(d.id);
return dr.low.trim() === "" || dr.high.trim() === "";
});
const degenerate = checked.filter((d) => {
const dr = draftFor(d.id);
return dr.low.trim() !== "" && dr.high.trim() !== "" && Number(dr.low) === Number(dr.high);
});
// Mindestens zwei Treiber: Ein Tornado ist eine RANGLISTE. Mit einem einzigen Balken gibt
// es nichts zu ordnen und die Grafik wuerde eine Aussage suggerieren, die sie nicht hat.
const canRun =
checked.length >= 2 && incomplete.length === 0 && degenerate.length === 0 && plan.phases.length > 0;
function run() {
setResult(
computeTornado(
plan,
metric,
checked.map((d) => {
const dr = draftFor(d.id);
return { id: d.id, low: Number(dr.low), high: Number(dr.high) };
})
)
);
}
const chartData = useMemo(() => {
if (!result) return [];
// Von unten nach oben gezeichnet -> fuer die Trichterform (groesster Balken zuoberst)
// muss die Reihenfolge umgekehrt in die Grafik.
return [...result.bars].reverse().map((b) => ({
label: b.shortLabel,
range: [b.min, b.max] as [number, number],
swing: b.swing,
}));
}, [result]);
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-4xl 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">
<Tornado className="h-5 w-5 text-accent" /> Einflussfaktoren (Sensitivitätsanalyse)
</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> Nicht «wie viel Geld habe ich am Schluss», sondern
<strong className="text-fg"> «welche meiner Annahmen entscheidet überhaupt über das Ergebnis»</strong>. Bei
manchen Annahmen ist es egal, ob du sie exakt triffst bei anderen kippt eine kleine Abweichung den ganzen Plan.
</p>
<p className="mb-2">
<strong className="text-fg">Wie es funktioniert:</strong> Zuerst wird dein Plan wie erfasst gerechnet
(Basisfall). Dann wird <em>ein einziger</em> Parameter auf seinen tiefen und seinen hohen Wert gesetzt, alle
übrigen bleiben unverändert das ergibt zwei Ergebnisse. Das für jeden Parameter wiederholt und nach
<strong className="text-fg"> Spannweite</strong> sortiert ergibt die Trichterform: längster Balken zuoberst.
</p>
<p className="mb-2">
<strong className="text-fg">Was du davon hast:</strong> Die Reihenfolge ist die Botschaft. Bei den obersten
Balken lohnt sich Genauigkeit (dort exakte Zahlen beschaffen) und sie sind meist auch die, die du selbst
steuern kannst. Bei den untersten darfst du grob schätzen.
</p>
<p>
<strong className="text-fg">Zwei ehrliche Grenzen.</strong> Erstens hängt die Balkenlänge von den
Bandbreiten ab, die du unten eingibst wähle sie so, dass sie <em>ähnlich plausibel</em> sind, nicht ähnlich
gross. Zweitens wird immer nur ein Parameter auf einmal variiert; <em>Kombinationen</em> (schlechte Renditen
<em> und</em> hohe Ausgaben) treffen härter als die Summe der Einzelbalken dafür ist die
Monte-Carlo-Simulation zuständig.
</p>
</div>
{/* Zielgroesse */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<SelectField
label="Zielgrösse"
help="Woran die Wirkung gemessen wird: das Endvermögen der letzten Lebensphase. «Real» ist kaufkraftbereinigt auf den Planbeginn und damit die ehrlichere Grösse."
value={metric}
onChange={(v: TornadoMetric) => {
setMetric(v);
setResult(null);
}}
options={[
{ value: "real", label: "Endvermögen real (kaufkraftbereinigt)" },
{ value: "nominal", label: "Endvermögen nominal" },
]}
/>
</div>
{/* Parameter */}
<div>
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
Parameter und Bandbreiten
<InfoBubble text="Hake an, welche Parameter in die Analyse einfliessen sollen, und gib je Parameter eine tiefe und eine hohe Ausprägung an. Es gibt bewusst keine Vorgabewerte: Die Balkenlänge hängt direkt von diesen Bandbreiten ab, deshalb sollst du sie bewusst setzen. Die Hilfe je Parameter nennt plausible Grössenordnungen." />
</div>
<div className="flex flex-col gap-2">
{available.map((d) => {
const dr = draftFor(d.id);
const suffix = UNIT_SUFFIX[d.unit];
const unitHint =
d.unit === "abs_pct"
? "absoluter Wert in %"
: d.unit === "delta_pp"
? "Verschiebung in Prozentpunkten"
: d.unit === "rel_pct"
? "Abweichung vom Planwert in %"
: "Verschiebung in Jahren";
return (
<div
key={d.id}
className={`rounded-xl border p-3 ${dr.checked ? "border-accent bg-accent-soft/30" : "border-border bg-surface-2"}`}
>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={dr.checked} onChange={(e) => setDraft(d.id, { checked: e.target.checked })} />
<span className="font-medium text-fg">{d.label}</span>
<InfoBubble text={d.help} />
<span className="text-[11px] text-faint">({unitHint})</span>
</label>
{dr.checked && (
<div className="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-3">
<RequiredNumberField
label="tief"
value={dr.low}
suffix={suffix}
step={d.unit === "delta_years" ? 1 : 0.1}
onChange={(v) => setDraft(d.id, { low: v })}
/>
<RequiredNumberField
label="hoch"
value={dr.high}
suffix={suffix}
step={d.unit === "delta_years" ? 1 : 0.1}
onChange={(v) => setDraft(d.id, { high: v })}
/>
</div>
)}
</div>
);
})}
</div>
<p className="mt-2 text-[11px] text-faint">
Das <strong>Pensionsalter</strong> ist bewusst nicht enthalten: Es lässt sich nicht sinnvoll variieren, ohne
gleichzeitig die Phasengrenzen mitzuverschieben sonst arbeitet die Person im Modell unverändert weiter
bzw. der Pensions-Übergang entfällt ganz. Der verwandte Treiber «Lebensdauer» ist dagegen sauber abgebildet.
</p>
</div>
{checked.length < 2 && (
<p className="text-xs text-danger">Bitte mindestens zwei Parameter auswählen der Tornado ist eine Rangliste.</p>
)}
{incomplete.length > 0 && (
<p className="text-xs text-danger">Bitte für jeden ausgewählten Parameter eine tiefe und eine hohe Ausprägung angeben.</p>
)}
{degenerate.length > 0 && (
<p className="text-xs text-danger">
Tiefer und hoher Wert sind identisch bei: {degenerate.map((d) => d.label).join(", ")}. Damit gibt es keine Spannweite.
</p>
)}
<div>
<button
type="button"
disabled={!canRun}
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"
>
Einflussfaktoren berechnen
</button>
</div>
{result && <TornadoResults result={result} metric={metric} chartData={chartData} />}
</div>
</div>
);
}
function TornadoResults({
result,
metric,
chartData,
}: {
result: TornadoResult;
metric: TornadoMetric;
chartData: { label: string; range: [number, number]; swing: number }[];
}) {
const metricLabel = metric === "real" ? "Endvermögen real" : "Endvermögen nominal";
return (
<div className="flex flex-col gap-4 border-t border-border pt-4">
<div className="rounded-xl border border-border bg-surface-2 px-4 py-3">
<div className="text-xs text-muted">Basisfall · {metricLabel}</div>
<div className="mt-0.5 text-xl font-semibold text-accent">{formatChf(result.base)} CHF</div>
</div>
<div>
<div className="mb-1 text-xs font-semibold text-fg">Einflussfaktoren nach Spannweite</div>
<p className="mb-2 text-[11px] text-muted">
Jeder Balken zeigt, zwischen welchen Werten das {metricLabel} schwankt, wenn nur dieser eine Parameter
innerhalb seiner Bandbreite variiert. Die senkrechte Linie ist der Basisfall.
</p>
<div className="w-full" style={{ height: Math.max(200, chartData.length * 46 + 60) }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} layout="vertical" margin={{ top: 8, right: 24, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis
type="number"
tick={{ fontSize: 11 }}
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
/>
<YAxis type="category" dataKey="label" tick={{ fontSize: 11 }} width={130} />
<Tooltip
formatter={(v) =>
Array.isArray(v)
? `${formatChf(Number(v[0]))} ${formatChf(Number(v[1]))}`
: typeof v === "number"
? formatChf(v)
: v
}
/>
<ReferenceLine x={result.base} stroke="var(--muted)" strokeDasharray="4 3" />
<Bar dataKey="range" name={metricLabel} fill="var(--accent)" radius={[3, 3, 3, 3]} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
<div className="overflow-x-auto rounded-xl border border-border">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-surface-2 text-xs text-faint">
<th className="px-3 py-2 text-left font-semibold">Parameter</th>
<th className="px-3 py-2 text-left font-semibold">Bandbreite</th>
<th className="px-3 py-2 text-right font-semibold">bei «tief»</th>
<th className="px-3 py-2 text-right font-semibold">bei «hoch»</th>
<th className="px-3 py-2 text-right font-semibold">Spannweite</th>
</tr>
</thead>
<tbody>
{result.bars.map((b) => (
<tr key={b.id} className="border-t border-border">
<td className="px-3 py-2 font-medium text-fg">{b.label}</td>
<td className="px-3 py-2 text-muted">{formatRange(b.low, b.high, b.unit)}</td>
<td className="px-3 py-2 text-right text-muted">{formatChf(b.lowResult)}</td>
<td className="px-3 py-2 text-right text-muted">{formatChf(b.highResult)}</td>
<td className="px-3 py-2 text-right font-semibold text-fg">{formatChf(b.swing)}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-[11px] text-faint">
Die Reihenfolge nicht der absolute Betrag ist die Aussage. Sie hängt von den eingegebenen Bandbreiten ab.
</p>
</div>
);
}
+139 -1
View File
@@ -1,6 +1,15 @@
import { describe, it, expect } from "vitest";
import { computePlan } from "@/lib/calculations";
import { runMonteCarlo, defaultVolatilityLevel, RETURN_VOLATILITY_LEVELS, type MonteCarloParams } from "@/lib/montecarlo";
import {
buildElementGroups,
defaultVolatilityLevel,
paramsForScenario,
resolveRootElementId,
runMonteCarlo,
runMonteCarloMulti,
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.
@@ -105,3 +114,132 @@ describe("Monte Carlo", () => {
expect(RETURN_VOLATILITY_LEVELS.sehr_hoch).toBe(55);
});
});
// --- Mehrere Szenarien im selben Lauf ----------------------------------------------------
// Kopie des Basisplans mit neuen Ids; das Element verweist per sourceElementId auf sein
// Gegenstueck -- genau wie es die Kopier-Route beim Anlegen eines Szenarios setzt.
function copyPlan(source: PlanInput, suffix: string, expectedReturn: number): PlanInput {
return {
...source,
id: `${source.id}-${suffix}`,
elements: source.elements.map((e) => ({
...e,
id: `${e.id}-${suffix}`,
sourceElementId: e.id,
phaseValues: { p1: { ...e.phaseValues.p1, expectedReturn } },
})),
};
}
describe("Monte Carlo: mehrere Szenarien", () => {
it("loest die Herkunfts-Kette bis zum Ursprung auf", () => {
const chain = new Map<string, string | null>([
["a", null],
["b", "a"],
["c", "b"],
]);
expect(resolveRootElementId("c", chain)).toBe("a");
expect(resolveRootElementId("a", chain)).toBe("a");
// Verweis ins Leere (Vorlage geloescht): das Element ist selbst die Wurzel.
expect(resolveRootElementId("x", new Map([["x", "weg"]]))).toBe("x");
// Defekte Kette darf nicht zur Endlosschleife fuehren.
expect(resolveRootElementId("p", new Map([["p", "q"], ["q", "p"]]))).toBeDefined();
});
it("fasst dasselbe Element ueber Szenarien zu EINER Gruppe zusammen", () => {
const base = basePlan();
const s2 = copyPlan(base, "s2", 6);
const groups = buildElementGroups(
[{ id: "base", plan: base }, { id: "s2", plan: s2 }],
["base", "s2"]
);
expect(groups).toHaveLength(1); // nicht zwei -- die Annahme wird nur EINMAL erfasst
expect(groups[0].rootId).toBe("asset");
expect(groups[0].memberIds.sort()).toEqual(["asset", "asset-s2"]);
expect(groups[0].scenarioIds.sort()).toEqual(["base", "s2"]);
});
it("loest die Kette auch ueber ein NICHT ausgewaehltes Zwischen-Szenario auf", () => {
const base = basePlan();
const s1 = copyPlan(base, "s1", 6);
const s2 = copyPlan(s1, "s2", 7); // zeigt auf s1, nicht auf base
const groups = buildElementGroups(
[{ id: "base", plan: base }, { id: "s1", plan: s1 }, { id: "s2", plan: s2 }],
["base", "s2"] // s1 ist nur zur Aufloesung geladen
);
expect(groups).toHaveLength(1);
expect(groups[0].rootId).toBe("asset");
expect(groups[0].scenarioIds.sort()).toEqual(["base", "s2"]);
});
it("ein nur in einem Szenario neu angelegtes Element bildet eine eigene Gruppe", () => {
const base = basePlan();
const s2: PlanInput = {
...copyPlan(base, "s2", 5),
elements: [
...copyPlan(base, "s2", 5).elements,
{
id: "neu", category: "OTHER_ASSET", name: "Krypto", ownerRole: "HOUSEHOLD", orderIndex: 2,
phaseValues: { p1: { startValue: 10000, expectedReturn: 10 } }, transitionValues: {},
},
],
};
const groups = buildElementGroups([{ id: "base", plan: base }, { id: "s2", plan: s2 }], ["base", "s2"]);
expect(groups).toHaveLength(2);
const neu = groups.find((g) => g.rootId === "neu")!;
expect(neu.scenarioIds).toEqual(["s2"]); // im Basisszenario ohne Wirkung
});
it("uebersetzt die Gruppen-Parameter auf die Element-Ids des jeweiligen Szenarios", () => {
const base = basePlan();
const s2 = copyPlan(base, "s2", 6);
const groups = buildElementGroups([{ id: "base", plan: base }, { id: "s2", plan: s2 }], ["base", "s2"]);
const byRoot = { asset: { mean: 6, sigma: 15, floor: -100 } };
expect(paramsForScenario(base, groups, byRoot)).toEqual({ asset: byRoot.asset });
expect(paramsForScenario(s2, groups, byRoot)).toEqual({ "asset-s2": byRoot.asset });
});
it("gleicher Seed: nur die GEPLANTE Rendite unterscheidet sich -> identische Verteilung, andere Erfolgsquote", async () => {
// Der Kern-Anwendungsfall: dasselbe Portfolio, einmal pessimistisch (5 %) und einmal
// optimistisch (6 %) geplant. In der Simulation wird die geplante Rendite ersetzt, also
// sind beide Verlaeufe identisch -- der Unterschied liegt allein im Zielbetrag, der aus
// der jeweiligen Planung stammt. Das pessimistische Szenario haelt sein (tieferes)
// Versprechen oefter.
const base = basePlan(); // expectedReturn 5
const optimistisch = copyPlan(base, "opt", 6);
const targetBase = computePlan(base).nachlass;
const targetOpt = computePlan(optimistisch).nachlass;
expect(targetOpt).toBeGreaterThan(targetBase);
const groups = buildElementGroups(
[{ id: "base", plan: base }, { id: "opt", plan: optimistisch }],
["base", "opt"]
);
const byRoot = { asset: { mean: 6, sigma: 20, floor: -100 } };
const [rBase, rOpt] = await runMonteCarloMulti(
[
{ scenarioId: "base", name: "Basis", plan: base, target: targetBase },
{ scenarioId: "opt", name: "Optimistisch", plan: optimistisch, target: targetOpt },
],
{
runs: 2000,
inflationMean: 2,
inflationSigma: 0,
seed: 4242,
elementsFor: (p) => paramsForScenario(p, groups, byRoot),
}
);
// Identische Struktur + gemeinsamer Seed -> exakt dieselben Marktpfade.
expect(rOpt.finalWealthMedian).toBe(rBase.finalWealthMedian);
expect(rOpt.finalWealthP10).toBe(rBase.finalWealthP10);
expect(rOpt.ruinProbability).toBe(rBase.ruinProbability);
// Einziger Unterschied: der Zielbetrag -- und damit die Erfolgswahrscheinlichkeit.
expect(rBase.successProbability).toBeGreaterThan(rOpt.successProbability);
expect(rBase.scenarioId).toBe("base");
expect(rOpt.target).toBe(targetOpt);
});
});
+143
View File
@@ -79,6 +79,149 @@ export interface MonteCarloResult {
bands: { age: number; p10: number; p50: number; p90: number }[];
}
// --- Mehrere Szenarien im selben Lauf vergleichen -------------------------------------
//
// Ein "logisches" Element ueber Szenariogrenzen hinweg: Beim Kopieren eines Szenarios
// erhaelt jedes Element eine NEUE Id plus einen Verweis auf sein Gegenstueck in der Vorlage
// (sourceElementId -- dieselbe Kette, auf der auch der Diff beruht). Ueber diese Kette wird
// dieselbe Anlage in mehreren Szenarien wiedergefunden. Das ist die Voraussetzung dafuer,
// die historischen Annahmen nur EINMAL zu erfassen: Ein Vergleich ist nur dann aussagekraeftig,
// wenn alle Szenarien mit denselben Marktannahmen gewuerfelt werden -- sonst vergleicht man
// die Eingaben statt der Szenarien.
export interface ElementGroup {
rootId: string; // Id des Ursprungs-Elements (Anker der Parametereingabe)
name: string;
category: ElementCategory;
memberIds: string[]; // Element-Ids ueber alle ausgewaehlten Szenarien
scenarioIds: string[]; // Szenarien, in denen dieses Element vorkommt
}
// Folgt sourceElementId bis zum Ursprung. Bricht ab, sobald der Verweis ins Leere zeigt
// (Vorlage geloescht oder Szenario nicht geladen) -- dann ist dieses Element selbst die
// Wurzel und bildet eine eigene Gruppe. Der Zyklusschutz ist reine Vorsicht: die Verweise
// sind lose (kein FK), eine defekte Kette darf nicht zur Endlosschleife fuehren.
export function resolveRootElementId(elementId: string, sourceById: Map<string, string | null>): string {
let current = elementId;
const seen = new Set<string>();
while (!seen.has(current)) {
seen.add(current);
const source = sourceById.get(current);
if (!source || !sourceById.has(source)) return current;
current = source;
}
return current;
}
// `all` enthaelt ALLE Szenarien des Plans (auch nicht ausgewaehlte) -- nur so laesst sich die
// Kette ueber ein uebersprungenes Zwischen-Szenario hinweg aufloesen (Basis -> S1 -> S2, wenn
// nur Basis und S2 ausgewaehlt sind). Gruppen entstehen nur fuer die ausgewaehlten Szenarien.
export function buildElementGroups(
all: { id: string; plan: PlanInput }[],
selectedIds: string[]
): ElementGroup[] {
const sourceById = new Map<string, string | null>();
const nameById = new Map<string, string>();
for (const s of all) {
for (const e of s.plan.elements) {
sourceById.set(e.id, e.sourceElementId ?? null);
nameById.set(e.id, e.name);
}
}
const groups = new Map<string, ElementGroup>();
for (const s of all) {
if (!selectedIds.includes(s.id)) continue;
for (const e of s.plan.elements) {
if (!RETURN_BEARING.includes(e.category)) continue;
const rootId = resolveRootElementId(e.id, sourceById);
let group = groups.get(rootId);
if (!group) {
group = {
rootId,
// Name des Ursprungs-Elements, damit die Gruppe stabil beschriftet ist -- auch
// wenn das Element in einem Szenario umbenannt wurde.
name: nameById.get(rootId) ?? e.name,
category: e.category,
memberIds: [],
scenarioIds: [],
};
groups.set(rootId, group);
}
group.memberIds.push(e.id);
if (!group.scenarioIds.includes(s.id)) group.scenarioIds.push(s.id);
}
}
return [...groups.values()];
}
// Uebersetzt die je Gruppe erfassten Parameter auf die Element-Ids EINES Szenarios.
export function paramsForScenario(
plan: PlanInput,
groups: ElementGroup[],
paramByRoot: Record<string, ElementMcParams>
): Record<string, ElementMcParams> {
const rootByMember = new Map<string, string>();
for (const g of groups) for (const id of g.memberIds) rootByMember.set(id, g.rootId);
const out: Record<string, ElementMcParams> = {};
for (const e of plan.elements) {
if (!RETURN_BEARING.includes(e.category)) continue;
const root = rootByMember.get(e.id);
const params = root ? paramByRoot[root] : undefined;
if (params) out[e.id] = params;
}
return out;
}
export interface ScenarioRunInput {
scenarioId: string;
name: string;
plan: PlanInput;
target: number; // Zielbetrag DIESES Szenarios (i. d. R. sein geplanter Nachlass)
}
export interface ScenarioMcResult extends MonteCarloResult {
scenarioId: string;
name: string;
target: number;
}
// Fuehrt dieselbe Simulation fuer mehrere Szenarien aus -- mit DEMSELBEN Seed. Ohne das
// waeren kleine Unterschiede blosses Rauschen (bei 1'000 Laeufen betraegt der Standardfehler
// der Erfolgswahrscheinlichkeit rund 1.5 Prozentpunkte, zwei identische Szenarien koennten
// also 87 % und 90 % zeigen). Mit gemeinsamem Seed teilen strukturgleiche Szenarien dieselben
// Marktpfade, und die Unterschiede sind rein strukturell (Common Random Numbers).
export async function runMonteCarloMulti(
scenarios: ScenarioRunInput[],
common: {
runs: number;
inflationMean: number;
inflationSigma: number;
seed: number;
elementsFor: (plan: PlanInput) => Record<string, ElementMcParams>;
},
onProgress?: (scenarioIndex: number, scenarioCount: number, fraction: number) => void
): Promise<ScenarioMcResult[]> {
const results: ScenarioMcResult[] = [];
for (let i = 0; i < scenarios.length; i++) {
const s = scenarios[i];
const result = await runMonteCarlo(
s.plan,
{
runs: common.runs,
inflationMean: common.inflationMean,
inflationSigma: common.inflationSigma,
elements: common.elementsFor(s.plan),
target: s.target,
seed: common.seed,
},
(done, total) => onProgress?.(i, scenarios.length, done / total)
);
results.push({ ...result, scenarioId: s.scenarioId, name: s.name, target: s.target });
}
return results;
}
// --- Zufallszahlen (seedbar, damit ein Lauf reproduzierbar ist) ---
function mulberry32(seed: number): () => number {
+173
View File
@@ -0,0 +1,173 @@
import { describe, it, expect } from "vitest";
import { computePlan } from "@/lib/calculations";
import {
applyDriver,
computeTornado,
DRIVERS,
driverById,
planMetric,
} from "@/lib/sensitivity";
import type { PlanInput } from "@/lib/types";
// Plan: 45-jaehrig, zwei Phasen (20 J. Erwerb + 20 J. Pension), Einkommen 100'000 netto,
// Ausgaben 70'000 real, ein Sonstiges Vermoegen 200'000 @ 4 %, Inflation 1.5 %.
function basePlan(): PlanInput {
return {
id: "p",
name: "T",
householdType: "SINGLE",
inflationRateDefault: 1.5,
initialCash: 0,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 45, retirementAge: 65 }],
phases: [
{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 20, cashTransition: {} },
{ id: "p2", sequenceNumber: 2, name: "Pension", durationYears: 20, cashTransition: {} },
],
elements: [
{
id: "inc",
category: "INCOME",
name: "Lohn",
ownerRole: "HOUSEHOLD",
orderIndex: 1,
phaseValues: { p1: { amount: 100000, teuerungsausgleich: 1 } },
transitionValues: {},
},
{
id: "exp",
category: "EXPENSE",
name: "Lebenshaltung",
ownerRole: "HOUSEHOLD",
orderIndex: 2,
phaseValues: { p1: { amount: 70000 }, p2: { amount: 70000 } },
transitionValues: {},
},
{
id: "asset",
category: "OTHER_ASSET",
name: "ETF",
ownerRole: "HOUSEHOLD",
orderIndex: 3,
phaseValues: {
p1: { startValue: 200000, expectedReturn: 4 },
p2: { expectedReturn: 4 },
},
transitionValues: {},
},
],
};
}
describe("Sensitivitaet: applyDriver", () => {
it("laesst den Ausgangsplan unberuehrt (rein)", () => {
const p = basePlan();
const snapshot = JSON.stringify(p);
applyDriver(p, "inflation", 3);
applyDriver(p, "expenses", 20);
applyDriver(p, "returns", 2);
applyDriver(p, "lifespan", 5);
expect(JSON.stringify(p)).toBe(snapshot);
});
it("Inflation wird absolut gesetzt (nicht verschoben)", () => {
expect(applyDriver(basePlan(), "inflation", 3.5).inflationRateDefault).toBe(3.5);
});
it("Rendite wird in Prozentpunkten verschoben, je Element und Phase", () => {
const p = applyDriver(basePlan(), "returns", 2);
const asset = p.elements.find((e) => e.id === "asset")!;
expect(asset.phaseValues.p1.expectedReturn).toBe(6);
expect(asset.phaseValues.p2.expectedReturn).toBe(6);
// Einkommen/Ausgaben bleiben unberuehrt.
expect(p.elements.find((e) => e.id === "inc")!.phaseValues.p1.amount).toBe(100000);
});
it("Ausgaben werden relativ skaliert, Einkommen nicht", () => {
const p = applyDriver(basePlan(), "expenses", 10);
expect(p.elements.find((e) => e.id === "exp")!.phaseValues.p1.amount).toBeCloseTo(77000, 6);
expect(p.elements.find((e) => e.id === "exp")!.phaseValues.p2.amount).toBeCloseTo(77000, 6);
expect(p.elements.find((e) => e.id === "inc")!.phaseValues.p1.amount).toBe(100000);
});
it("Lebensdauer verschiebt nur die LETZTE Phase und bleibt bei mindestens 1 Jahr", () => {
const longer = applyDriver(basePlan(), "lifespan", 5);
expect(longer.phases[0].durationYears).toBe(20);
expect(longer.phases[1].durationYears).toBe(25);
// Kappung nach unten: -100 Jahre darf keine Phase mit 0 oder negativer Dauer erzeugen.
expect(applyDriver(basePlan(), "lifespan", -100).phases[1].durationYears).toBe(1);
});
it("Lohnentwicklung verschiebt den Teuerungsausgleich des Einkommens", () => {
const p = applyDriver(basePlan(), "salaryGrowth", 1);
expect(p.elements.find((e) => e.id === "inc")!.phaseValues.p1.teuerungsausgleich).toBe(2);
});
});
describe("Sensitivitaet: Treiber-Verfuegbarkeit", () => {
it("blendet Treiber aus, fuer die es keine passenden Elemente gibt", () => {
const p = basePlan();
expect(driverById("propertyGrowth").applies(p)).toBe(false); // keine Immobilie
expect(driverById("returns").applies(p)).toBe(true);
expect(driverById("inflation").applies(p)).toBe(true);
const ohneEinkommen: PlanInput = { ...p, elements: p.elements.filter((e) => e.category !== "INCOME") };
expect(driverById("income").applies(ohneEinkommen)).toBe(false);
expect(driverById("salaryGrowth").applies(ohneEinkommen)).toBe(false);
});
it("jeder Treiber ist genau einmal definiert", () => {
expect(new Set(DRIVERS.map((d) => d.id)).size).toBe(DRIVERS.length);
});
});
describe("Sensitivitaet: Tornado", () => {
it("Basiswert entspricht dem unveraenderten Plan", () => {
const p = basePlan();
const result = computeTornado(p, "real", [{ id: "inflation", low: 0.5, high: 3.5 }]);
const last = computePlan(p).phases.at(-1)!;
expect(result.base).toBe(Math.round(last.endWealthReal));
expect(planMetric(p, "nominal")).toBe(last.endWealthNominal);
});
it("sortiert nach Spannweite absteigend (Trichterform)", () => {
const result = computeTornado(basePlan(), "real", [
{ id: "salaryGrowth", low: 0, high: 0 }, // ohne Bandbreite -> keine Wirkung
{ id: "expenses", low: -15, high: 15 },
{ id: "returns", low: -2, high: 2 },
]);
const swings = result.bars.map((b) => b.swing);
expect([...swings].sort((a, b) => b - a)).toEqual(swings);
// Ein Treiber ohne Bandbreite kann nichts bewegen und landet zuunterst.
expect(result.bars.at(-1)!.id).toBe("salaryGrowth");
expect(result.bars.at(-1)!.swing).toBe(0);
// Welcher Treiber oben steht, haengt vom konkreten Plan ab -- genau das ist die Aussage
// des Tornados und deshalb bewusst nicht fix getestet.
});
it("kehrt die Richtung korrekt ab: hoehere Ausgaben -> tieferes Endvermoegen", () => {
const [bar] = computeTornado(basePlan(), "real", [{ id: "expenses", low: -15, high: 15 }]).bars;
expect(bar.lowResult).toBeGreaterThan(bar.highResult); // tiefe Ausgaben = mehr Vermoegen
expect(bar.min).toBe(bar.highResult);
expect(bar.max).toBe(bar.lowResult);
expect(bar.swing).toBe(bar.max - bar.min);
});
it("hoehere Rendite -> hoeheres Endvermoegen", () => {
const [bar] = computeTornado(basePlan(), "real", [{ id: "returns", low: -2, high: 2 }]).bars;
expect(bar.highResult).toBeGreaterThan(bar.lowResult);
});
it("identische Bandbreite ergibt Spannweite 0", () => {
const [bar] = computeTornado(basePlan(), "real", [{ id: "inflation", low: 2, high: 2 }]).bars;
expect(bar.swing).toBe(0);
});
it("real und nominal unterscheiden sich um den Deflator", () => {
const p = basePlan();
const real = planMetric(p, "real");
const nominal = planMetric(p, "nominal");
const last = computePlan(p).phases.at(-1)!;
expect(nominal).toBeGreaterThan(real); // 1.5 % Inflation ueber 40 Jahre
expect(real).toBe(Math.round(nominal / last.cumulativeInflationEnd));
});
});
+278
View File
@@ -0,0 +1,278 @@
// Sensitivitaetsanalyse / Tornado (Roadmap Nr. 20). Beantwortet nicht "wie viel Geld habe
// ich am Schluss", sondern "welche meiner Annahmen entscheidet ueberhaupt ueber das Ergebnis".
//
// Verfahren: One-at-a-time (OAT). Je Treiber wird EIN Parameter auf seinen tiefen und seinen
// hohen Wert gesetzt, alle uebrigen bleiben auf dem Planwert; die Differenz der beiden
// Ergebnisse ist die Spannweite. Nach Spannweite sortiert ergibt sich die Trichterform.
//
// Zwei bewusste Grenzen (im Dialog ausgewiesen, siehe SPEZIFIKATION 9.18):
// 1. Die Balkenlaenge haengt von den eingegebenen Bandbreiten ab -- deshalb gibt es hier
// KEINE Defaults, die Bandbreite ist je Treiber Pflichteingabe.
// 2. OAT sieht keine Wechselwirkungen (tiefe Rendite UND hohe Ausgaben treffen haerter als
// die Summe der Einzelbalken). Dafuer ist die Monte-Carlo-Simulation zustaendig.
//
// Laeuft wie die Monte-Carlo-Simulation vollstaendig im Browser: computePlan ist rein, und
// ein Tornado braucht nur 2 Aufrufe je Treiber (Millisekunden).
import { computePlan } from "@/lib/calculations";
import { num } from "@/lib/elements";
import type { ElementCategory, PhaseData } from "@/lib/elements";
import type { ElementInput, PlanInput } from "@/lib/types";
export type DriverId =
| "inflation"
| "returns"
| "expenses"
| "income"
| "salaryGrowth"
| "lifespan"
| "propertyGrowth";
// Die Einheit bestimmt, WAS der eingegebene Wert bedeutet -- das ist je Treiber verschieden
// und laesst sich nicht vereinheitlichen, ohne fachlich falsch zu werden:
// abs_pct absoluter Prozentsatz (es gibt genau einen plan-weiten Wert)
// delta_pp Verschiebung in Prozentpunkten (die Elemente haben je eigene Saetze -- ein
// absoluter Wert wuerde die PK auf ETF-Rendite plaetten)
// rel_pct relative Abweichung in Prozent (die Elemente haben je eigene Betraege)
// delta_years Verschiebung in Jahren
export type DriverUnit = "abs_pct" | "delta_pp" | "rel_pct" | "delta_years";
export interface DriverDef {
id: DriverId;
label: string;
shortLabel: string; // Achsenbeschriftung im Tornado (kurz genug fuer die y-Achse)
unit: DriverUnit;
help: string;
applies: (plan: PlanInput) => boolean;
}
const RETURN_CATEGORIES: ElementCategory[] = ["PENSION_FUND", "PILLAR_3A", "OTHER_ASSET"];
function hasCategory(plan: PlanInput, categories: ElementCategory[]): boolean {
return plan.elements.some((e) => categories.includes(e.category));
}
export const DRIVERS: DriverDef[] = [
{
id: "expenses",
label: "Ausgaben",
shortLabel: "Ausgaben",
unit: "rel_pct",
help:
"Prozentuale Abweichung aller Ausgaben-Elemente vom Planwert. Sinnvolle Bandbreite: 10 bis +15 %, wenn die Ausgaben aus echten Kontodaten stammen; 20 bis +30 %, wenn sie geschätzt sind. Erfahrungsgemäss der stärkste Hebel und einer, den man selbst steuern kann.",
applies: (p) => hasCategory(p, ["EXPENSE"]),
},
{
id: "returns",
label: "Rendite der Anlagen (PK, 3a, Sonstiges Vermögen)",
shortLabel: "Rendite",
unit: "delta_pp",
help:
"Verschiebung der erwarteten Rendite in Prozentpunkten, auf alle Anlagen gleichzeitig. Sinnvolle Bandbreite: 2 bis +2 Prozentpunkte für ein gemischtes Portfolio, 3 bis +3 bei hohem Aktienanteil. Die Immobilien-Wertsteigerung hat einen eigenen Treiber.",
applies: (p) => hasCategory(p, RETURN_CATEGORIES),
},
{
id: "lifespan",
label: "Lebensdauer (Dauer der letzten Phase)",
shortLabel: "Lebensdauer",
unit: "delta_years",
help:
"Verlängert bzw. verkürzt die letzte Lebensphase. Sinnvolle Bandbreite: 5 bis +10 Jahre die Restlebenserwartung streut stark, und Langlebigkeit ist das eigentliche Planungsrisiko (das Geld muss länger reichen).",
applies: (p) => p.phases.length > 0,
},
{
id: "inflation",
label: "Inflation",
shortLabel: "Inflation",
unit: "abs_pct",
help:
"Absolute Inflationsrate (nicht Abweichung). Sinnvolle Bandbreite für die Schweiz: 0.5 bis 3.5 % der langjährige Schnitt liegt bei rund 1 bis 2 %, einzelne Jahre lagen deutlich darüber.",
applies: () => true,
},
{
id: "income",
label: "Einkommen",
shortLabel: "Einkommen",
unit: "rel_pct",
help:
"Prozentuale Abweichung aller Einkommens-Elemente (netto) vom Planwert. Sinnvolle Bandbreite: 10 bis +10 % bei sicherer Anstellung, 30 bis +20 % bei selbständiger oder variabler Tätigkeit.",
applies: (p) => hasCategory(p, ["INCOME"]),
},
{
id: "salaryGrowth",
label: "Lohnentwicklung",
shortLabel: "Lohnentwicklung",
unit: "delta_pp",
help:
"Verschiebung der jährlichen nominalen Lohnerhöhung in Prozentpunkten. Sinnvolle Bandbreite: 1 bis +1 Prozentpunkt. Wirkt nur über die verbleibenden Erwerbsjahre und ist deshalb meist ein schwacher Hebel.",
applies: (p) => hasCategory(p, ["INCOME"]),
},
{
id: "propertyGrowth",
label: "Wertsteigerung der Immobilie",
shortLabel: "Immo-Wertsteigerung",
unit: "delta_pp",
help:
"Verschiebung der jährlichen Wertsteigerung in Prozentpunkten. Sinnvolle Bandbreite: 2 bis +2 Prozentpunkte. Wirkt auf den Wert der Liegenschaft und damit gehebelt auf das Eigenkapital.",
applies: (p) => hasCategory(p, ["REAL_ESTATE"]),
},
];
// Einheiten-Suffix fuer die Eingabefelder und die Ergebnistabelle.
export const UNIT_SUFFIX: Record<DriverUnit, string> = {
abs_pct: "%",
delta_pp: "pp",
rel_pct: "%",
delta_years: "J.",
};
export function driverById(id: DriverId): DriverDef {
const d = DRIVERS.find((x) => x.id === id);
if (!d) throw new Error(`Unbekannter Treiber: ${id}`);
return d;
}
// --- Anwenden eines Treiber-Wertes auf einen Plan -------------------------------------
// Alle Transformationen sind rein: sie liefern eine Kopie und lassen das Original unberuehrt.
function mapPhaseData(element: ElementInput, f: (pd: PhaseData) => PhaseData): ElementInput {
const phaseValues: Record<string, PhaseData> = {};
for (const [phaseId, pd] of Object.entries(element.phaseValues)) phaseValues[phaseId] = f(pd);
return { ...element, phaseValues };
}
// Bildet die Elemente der gegebenen Kategorien ab; alle uebrigen bleiben unveraendert.
function mapElements(
plan: PlanInput,
categories: ElementCategory[],
f: (pd: PhaseData) => PhaseData
): PlanInput {
return {
...plan,
elements: plan.elements.map((e) => (categories.includes(e.category) ? mapPhaseData(e, f) : e)),
};
}
export function applyDriver(plan: PlanInput, id: DriverId, value: number): PlanInput {
switch (id) {
case "inflation":
return { ...plan, inflationRateDefault: value };
case "returns":
// Verschiebung in Prozentpunkten auf die geplante Rendite. Nur dort, wo ueberhaupt ein
// Werte-Datensatz existiert -- fehlt er, rechnet computePlan ohnehin mit 0 %.
return mapElements(plan, RETURN_CATEGORIES, (pd) => ({
...pd,
expectedReturn: num(pd.expectedReturn) + value,
}));
case "propertyGrowth":
return mapElements(plan, ["REAL_ESTATE"], (pd) => ({
...pd,
valueGrowth: num(pd.valueGrowth) + value,
}));
case "expenses":
case "income": {
// Relative Skalierung des Basisbetrags. Bewusst nur dort, wo `amount` gesetzt ist:
// ab Phase 2 ist der Wert in der Regel live vererbt (kein gespeicherter Betrag), und
// die Fortschreibung leitet ihn aus dem skalierten Basiswert ab -- dadurch wirkt die
// Skalierung automatisch ueber alle Folgephasen.
const factor = 1 + value / 100;
return mapElements(plan, [id === "expenses" ? "EXPENSE" : "INCOME"], (pd) =>
typeof pd.amount === "number" ? { ...pd, amount: Math.max(0, pd.amount * factor) } : pd
);
}
case "salaryGrowth":
return mapElements(plan, ["INCOME"], (pd) => ({
...pd,
teuerungsausgleich: num(pd.teuerungsausgleich, 0) + value,
}));
case "lifespan": {
// Verlaengert/verkuerzt die LETZTE Phase. Bewusst nicht das Pensionsalter: das laesst
// sich ohne Mitverschieben der Phasengrenzen nicht sinnvoll variieren (siehe 9.18).
if (plan.phases.length === 0) return plan;
const lastSeq = Math.max(...plan.phases.map((p) => p.sequenceNumber));
return {
...plan,
phases: plan.phases.map((p) =>
p.sequenceNumber === lastSeq
? { ...p, durationYears: Math.max(1, Math.round(p.durationYears + value)) }
: p
),
};
}
}
}
// --- Tornado ---------------------------------------------------------------------------
export type TornadoMetric = "real" | "nominal";
export interface TornadoInput {
id: DriverId;
low: number;
high: number;
}
export interface TornadoBar {
id: DriverId;
label: string;
shortLabel: string;
unit: DriverUnit;
low: number; // eingegebene Bandbreite
high: number;
lowResult: number; // Zielgroesse beim tiefen Wert
highResult: number; // Zielgroesse beim hohen Wert
min: number; // fuer den Balken: kleinerer der beiden Ergebniswerte
max: number;
swing: number; // max - min
}
export interface TornadoResult {
base: number;
bars: TornadoBar[];
}
// Zielgroesse: Endvermoegen der letzten Phase, real (kaufkraftbereinigt) oder nominal.
export function planMetric(plan: PlanInput, metric: TornadoMetric): number {
const computed = computePlan(plan);
const last = computed.phases[computed.phases.length - 1];
if (!last) return 0;
return Math.round(metric === "real" ? last.endWealthReal : last.endWealthNominal);
}
export function computeTornado(
plan: PlanInput,
metric: TornadoMetric,
inputs: TornadoInput[]
): TornadoResult {
const base = planMetric(plan, metric);
const bars: TornadoBar[] = inputs.map((input) => {
const def = driverById(input.id);
const lowResult = planMetric(applyDriver(plan, input.id, input.low), metric);
const highResult = planMetric(applyDriver(plan, input.id, input.high), metric);
// Die Richtung kann sich umkehren (tiefe Ausgaben -> hohes Vermoegen). Der Balken spannt
// deshalb ueber min..max; welche Eingabe zu welchem Ende gehoert, zeigt die Tabelle.
return {
id: input.id,
label: def.label,
shortLabel: def.shortLabel,
unit: def.unit,
low: input.low,
high: input.high,
lowResult,
highResult,
min: Math.min(lowResult, highResult),
max: Math.max(lowResult, highResult),
swing: Math.abs(highResult - lowResult),
};
});
// Trichterform: groesste Spannweite zuoberst.
bars.sort((a, b) => b.swing - a.swing);
return { base, bars };
}