Szenario-Vergleich in der Monte-Carlo-Simulation + Sensitivitaetsanalyse (Tornado)
Deploy App / deploy (push) Successful in 53s
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:
+251
-23
@@ -4,10 +4,10 @@
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
||||||
| **Version** | 0.9 |
|
| **Version** | 1.0 |
|
||||||
| **Datum** | 2026-07-18 |
|
| **Datum** | 2026-07-18 |
|
||||||
| **Status** | Lebendes Dokument |
|
| **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` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet |
|
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet |
|
||||||
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
|
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
| Version | Datum | Autor | Änderung |
|
| 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.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.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. |
|
| 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
|
- **Keine Steuerberechnung** ausser den drei explizit modellierten Sätzen
|
||||||
(Kapitalbezugssteuer, Grundstückgewinnsteuer, PK-Umwandlungssatz). Einkommens- und
|
(Kapitalbezugssteuer, Grundstückgewinnsteuer, PK-Umwandlungssatz). Einkommens- und
|
||||||
Vermögenssteuern sind nicht modelliert und müssen vom Benutzer in den Ausgaben erfasst werden.
|
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
|
(Begründung: [9.14](#914-keine-steuerschätzung)).
|
||||||
Jahresprozentsätze.
|
- **Keine Nebenkosten, kein Eigenmietwert, keine Mieteinnahmen** bei Immobilien
|
||||||
- **Keine Hypothekarzinsen.** Eine Hypothek reduziert nur den Nettowert der Immobilie;
|
(siehe [9.3](#93-immobilien-was-noch-fehlt)).
|
||||||
Zinskosten sind vom Benutzer in den Ausgaben zu erfassen.
|
- **Keine automatische Deckung von Liquiditätslücken.** Negatives Cash wird gemeldet, aber nicht
|
||||||
- **Keine Wertentwicklung von Immobilien.** Der Kaufpreis ist über die Phasendauer konstant
|
korrigiert (siehe [9.1](#91-cash-wird-nicht-automatisch-ausgeglichen)).
|
||||||
(Details siehe [4.6.5](#465-real_estate-immobilie)).
|
|
||||||
- **Keine Mehrbenutzer-Kollaboration.** Pläne gehören genau einem Benutzer.
|
- **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
|
## 1.3 Kernprinzip: Plan als selbsttragende Einheit
|
||||||
|
|
||||||
Seit dem V3-Rework (Migration `20260713150000_profile_to_plan_v3`) trägt **jeder Plan sein
|
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 620–648.
|
Referenz: `src/lib/calculations.ts` Zeilen 620–648.
|
||||||
|
|
||||||
|
### 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 Bedienoberfläche
|
||||||
|
|
||||||
### 3.7.1 Layout
|
### 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):
|
Falls **nicht** fortgeschrieben und **nicht** Phase 1 (= Neukauf in einer späteren Phase):
|
||||||
`investmentsFromCash += max(0, equity)` – das Eigenkapital wird aus dem Cash finanziert.
|
`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
|
### 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
|
leicht zu optimistisch ist. Pflichtfelder: ohne die historischen Ø-Werte startet die Simulation
|
||||||
nicht.
|
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
|
||||||
|
10–90 %-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
|
# 5. Technische Spezifikation
|
||||||
@@ -1624,7 +1792,7 @@ nicht.
|
|||||||
FPT/
|
FPT/
|
||||||
├── prisma/
|
├── prisma/
|
||||||
│ ├── schema.prisma Datenmodell
|
│ ├── schema.prisma Datenmodell
|
||||||
│ └── migrations/ 8 Migrationen (chronologisch)
|
│ └── migrations/ 12 Migrationen (chronologisch, siehe 5.4.6)
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── app/
|
│ ├── app/
|
||||||
│ │ ├── api/ Route Handlers (siehe Kapitel 6)
|
│ │ ├── api/ Route Handlers (siehe Kapitel 6)
|
||||||
@@ -1632,7 +1800,7 @@ FPT/
|
|||||||
│ │ ├── page.tsx Einstiegsseite (lädt /api/auth/me → AppShell)
|
│ │ ├── page.tsx Einstiegsseite (lädt /api/auth/me → AppShell)
|
||||||
│ │ ├── layout.tsx Root-Layout, Theme-Init-Script, Metadata
|
│ │ ├── layout.tsx Root-Layout, Theme-Init-Script, Metadata
|
||||||
│ │ └── globals.css Tailwind + semantische Farb-Tokens (3 Themes)
|
│ │ └── 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)
|
│ ├── generated/prisma/ Generierter Prisma-Client (nicht editieren)
|
||||||
│ ├── lib/ Domänenlogik (siehe 5.3)
|
│ ├── lib/ Domänenlogik (siehe 5.3)
|
||||||
│ └── middleware.ts Zugriffsschutz (Edge-Runtime)
|
│ └── 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()` |
|
| `elements.ts` | Kategorien, Labels, Reihenfolge, JSON-Payload-Typen, Zod-Schemas, `num()` |
|
||||||
| `types.ts` | Domänentypen für API und Berechnung |
|
| `types.ts` | Domänentypen für API und Berechnung |
|
||||||
| `constants.ts` | Schweizer Systemparameter |
|
| `constants.ts` | Schweizer Systemparameter |
|
||||||
| `montecarlo.ts` | Monte-Carlo-Simulation (Sampler + Treiber). Keine I/O, läuft im Browser. |
|
| `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 |
|
| `queries.ts` | Prisma-Includes, `toPlanInput()`, Ownership-Abfragen |
|
||||||
| `db.ts` | Prisma-Singleton (Global-Cache im Dev, verhindert Connection-Leak bei HMR) |
|
| `db.ts` | Prisma-Singleton (Global-Cache im Dev, verhindert Connection-Leak bei HMR) |
|
||||||
| `auth.ts` | JWT erzeugen/prüfen (Edge-kompatibel via `jose`) |
|
| `auth.ts` | JWT erzeugen/prüfen (Edge-kompatibel via `jose`) |
|
||||||
@@ -1916,7 +2086,9 @@ wird der Plan neu geladen; die Berechnung kommt immer vom Server.
|
|||||||
| `PlanProfileFields` | 101 | Wiederverwendete Grundprofil-Felder |
|
| `PlanProfileFields` | 101 | Wiederverwendete Grundprofil-Felder |
|
||||||
| `PhaseDetail` | 95 | Phase bearbeiten/löschen |
|
| `PhaseDetail` | 95 | Phase bearbeiten/löschen |
|
||||||
| `SparquoteChart` | 64 | Einkommen/Ausgaben pro Jahr mit Spar-/Verzehrbändern |
|
| `SparquoteChart` | 64 | Einkommen/Ausgaben pro Jahr mit Spar-/Verzehrbändern |
|
||||||
| `MonteCarloDialog` | ~430 | Monte-Carlo-Dialog: Erklärung, Eingaben, Lauf, Ergebnis + Fächer |
|
| `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 |
|
| `InfoBubble` | 28 | Hilfe-Tooltip |
|
||||||
|
|
||||||
### 5.5.3 Wiederverwendungsmuster
|
### 5.5.3 Wiederverwendungsmuster
|
||||||
@@ -2139,11 +2311,17 @@ Zielumgebung: Hetzner CX23, Traefik als Reverse Proxy, Domain `fpt.aicds.ch`, Gi
|
|||||||
## 8.1 Teststrategie
|
## 8.1 Teststrategie
|
||||||
|
|
||||||
Getestet wird ausschliesslich der Berechnungskern – bewusst, da dort die Fachlogik und das
|
Getestet wird ausschliesslich der Berechnungskern – bewusst, da dort die Fachlogik und das
|
||||||
Regressionsrisiko liegen. `src/lib/calculations.test.ts` (41 Tests: AHV-Rentenformel, Immobilie,
|
Regressionsrisiko liegen. Ausgeführt mit Vitest in der Node-Umgebung (`vitest.config.ts`,
|
||||||
Teilverkauf, Sonderamortisation, AHV einkommensabhängig, „V5 Golden Tests") und
|
Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests.
|
||||||
`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-,
|
| Datei | Tests | Schwerpunkt |
|
||||||
API- oder E2E-Tests.
|
|---|---|---|
|
||||||
|
| `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
|
## 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: 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: 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: 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 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 |
|
| **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 |
|
| 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
|
(`react-hooks/set-state-in-effect`); an vergleichbaren Stellen ist die Regel andernorts
|
||||||
bewusst per `eslint-disable` deaktiviert.
|
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
|
# 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 |
|
| Begriff | Bedeutung im FPT |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Plan** | Selbsttragende Planungseinheit: Grundprofil + Phasenkette + Elemente |
|
| **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 |
|
| **Grundprofil** | Haushaltsform, Personen (Alter, Pensionsalter, Name), Inflationsannahme |
|
||||||
| **Lebensphase** | Zeitabschnitt mit fester Dauer; darf keine Pensionierung überspannen |
|
| **Lebensphase** | Zeitabschnitt mit fester Dauer; darf keine Pensionierung überspannen |
|
||||||
| **Phasentyp** | `ERWERB` / `PENSION` / `MIXED`; abgeleitet, nie gespeichert |
|
| **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*
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
PiggyBank,
|
PiggyBank,
|
||||||
Plus,
|
Plus,
|
||||||
|
Tornado,
|
||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { PlanView } from "@/components/PlanView";
|
import { PlanView } from "@/components/PlanView";
|
||||||
import { Dashboard } from "@/components/Dashboard";
|
import { Dashboard } from "@/components/Dashboard";
|
||||||
import { MonteCarloDialog } from "@/components/MonteCarloDialog";
|
import { MonteCarloDialog } from "@/components/MonteCarloDialog";
|
||||||
|
import { SensitivityDialog } from "@/components/SensitivityDialog";
|
||||||
import { SpecView } from "@/components/SpecView";
|
import { SpecView } from "@/components/SpecView";
|
||||||
import { ProfileMenu } from "@/components/ProfileMenu";
|
import { ProfileMenu } from "@/components/ProfileMenu";
|
||||||
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
|
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
|
||||||
@@ -44,6 +46,7 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
const [showSpec, setShowSpec] = useState(false);
|
const [showSpec, setShowSpec] = useState(false);
|
||||||
const [showCharts, setShowCharts] = useState(false);
|
const [showCharts, setShowCharts] = useState(false);
|
||||||
const [showMonteCarlo, setShowMonteCarlo] = useState(false);
|
const [showMonteCarlo, setShowMonteCarlo] = useState(false);
|
||||||
|
const [showSensitivity, setShowSensitivity] = useState(false);
|
||||||
|
|
||||||
const loadPlans = useCallback(async () => {
|
const loadPlans = useCallback(async () => {
|
||||||
const data = await api.get<{ plans: PlanListItem[] }>("/api/plans");
|
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" />
|
<Dices className="h-4 w-4" />
|
||||||
Monte-Carlo-Simulation
|
Monte-Carlo-Simulation
|
||||||
</button>
|
</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 && (
|
{diff && detail.base && (
|
||||||
@@ -337,10 +348,16 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
<MonteCarloDialog
|
<MonteCarloDialog
|
||||||
plan={detail.plan}
|
plan={detail.plan}
|
||||||
computed={detail.computed}
|
computed={detail.computed}
|
||||||
|
meta={detail.meta}
|
||||||
|
scenarios={activePlan?.scenarios ?? [detail.meta]}
|
||||||
onClose={() => setShowMonteCarlo(false)}
|
onClose={() => setShowMonteCarlo(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showSensitivity && detail && (
|
||||||
|
<SensitivityDialog plan={detail.plan} onClose={() => setShowSensitivity(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
{copyFrom && (
|
{copyFrom && (
|
||||||
<CopyScenarioDialog
|
<CopyScenarioDialog
|
||||||
source={copyFrom}
|
source={copyFrom}
|
||||||
|
|||||||
@@ -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,
|
// Ganzzahliges Betragsfeld. Zeigt den Wert unfokussiert mit 1'000er-Trennzeichen an,
|
||||||
// akzeptiert fokussiert beliebige ganze Zahlen (keine Nachkommastellen) und bietet
|
// akzeptiert fokussiert beliebige ganze Zahlen (keine Nachkommastellen) und bietet
|
||||||
// Pfeil-Buttons mit Klick-und-Halten-BESCHLEUNIGUNG (1 -> 10 -> 100 -> 1'000 -> ...).
|
// Pfeil-Buttons mit Klick-und-Halten-BESCHLEUNIGUNG (1 -> 10 -> 100 -> 1'000 -> ...).
|
||||||
|
|||||||
+398
-187
@@ -1,26 +1,35 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Area, CartesianGrid, ComposedChart, Legend, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
import { Area, CartesianGrid, ComposedChart, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||||
import { Dices, X } from "lucide-react";
|
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 { InfoBubble } from "@/components/InfoBubble";
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
import { formatChf } from "@/lib/format";
|
import { formatChf } from "@/lib/format";
|
||||||
import {
|
import {
|
||||||
runMonteCarlo,
|
buildElementGroups,
|
||||||
defaultVolatilityLevel,
|
defaultVolatilityLevel,
|
||||||
RETURN_VOLATILITY_LEVELS,
|
floorFor,
|
||||||
|
paramsForScenario,
|
||||||
|
runMonteCarloMulti,
|
||||||
INFLATION_VOLATILITY_LEVELS,
|
INFLATION_VOLATILITY_LEVELS,
|
||||||
RETURN_BEARING,
|
RETURN_BEARING,
|
||||||
floorFor,
|
RETURN_VOLATILITY_LEVELS,
|
||||||
type ReturnVolatilityLevel,
|
type ElementGroup,
|
||||||
|
type ElementMcParams,
|
||||||
type InflationVolatilityLevel,
|
type InflationVolatilityLevel,
|
||||||
type MonteCarloResult,
|
type ReturnVolatilityLevel,
|
||||||
|
type ScenarioMcResult,
|
||||||
|
type ScenarioRunInput,
|
||||||
} from "@/lib/montecarlo";
|
} from "@/lib/montecarlo";
|
||||||
import { CATEGORY_LABELS } from "@/lib/elements";
|
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";
|
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 }[] = [
|
const RETURN_LEVEL_OPTIONS: { value: ReturnVolatilityLevel; label: string }[] = [
|
||||||
{ value: "sehr_niedrig", label: "Sehr niedrig" },
|
{ value: "sehr_niedrig", label: "Sehr niedrig" },
|
||||||
{ value: "niedrig", label: "Niedrig" },
|
{ value: "niedrig", label: "Niedrig" },
|
||||||
@@ -40,149 +49,186 @@ const RETURN_HELP =
|
|||||||
const INFLATION_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.";
|
"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).
|
interface ElementDraft {
|
||||||
function MeanField({
|
mean: string;
|
||||||
label,
|
level: ReturnVolatilityLevel;
|
||||||
help,
|
manualSigma: string;
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
help: string;
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
}) {
|
|
||||||
const empty = value.trim() === "";
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 flex items-center text-xs font-medium text-muted">
|
|
||||||
{label}
|
|
||||||
<InfoBubble text={help} />
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step={0.1}
|
|
||||||
value={value}
|
|
||||||
placeholder="Pflicht"
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
className={`w-full rounded-lg border bg-input px-2.5 py-1.5 text-sm text-fg shadow-sm focus:outline-none focus:ring-2 focus:ring-accent/25 ${
|
|
||||||
empty ? "border-danger" : "border-border focus:border-accent"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
{empty && <p className="mt-1 text-[11px] text-danger">Pflichtfeld – bitte ausfüllen.</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ElementInput = { mean: string; level: ReturnVolatilityLevel; manualSigma: string };
|
interface LoadedScenario {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
plan: PlanInput;
|
||||||
|
computed: PlanComputed;
|
||||||
|
}
|
||||||
|
|
||||||
function returnSigma(el: ElementInput): number {
|
function returnSigma(el: ElementDraft): number {
|
||||||
return el.level === "manuell"
|
return el.level === "manuell" ? Number(el.manualSigma) || 0 : RETURN_VOLATILITY_LEVELS[el.level];
|
||||||
? Number(el.manualSigma) || 0
|
|
||||||
: RETURN_VOLATILITY_LEVELS[el.level];
|
|
||||||
}
|
}
|
||||||
function inflationSigma(level: InflationVolatilityLevel, manual: string): number {
|
function inflationSigma(level: InflationVolatilityLevel, manual: string): number {
|
||||||
return level === "manuell" ? Number(manual) || 0 : INFLATION_VOLATILITY_LEVELS[level];
|
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({
|
export function MonteCarloDialog({
|
||||||
plan,
|
plan,
|
||||||
computed,
|
computed,
|
||||||
|
meta,
|
||||||
|
scenarios,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
plan: PlanInput;
|
plan: PlanInput;
|
||||||
computed: PlanComputed;
|
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;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const returnElements = useMemo(
|
const [loaded, setLoaded] = useState<Record<string, LoadedScenario>>(() => ({
|
||||||
() => plan.elements.filter((e) => RETURN_BEARING.includes(e.category)),
|
[meta.id]: { id: meta.id, name: meta.name, plan, computed },
|
||||||
[plan.elements]
|
}));
|
||||||
);
|
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 [runs, setRuns] = useState(1000);
|
||||||
const [inflMean, setInflMean] = useState("");
|
const [inflMean, setInflMean] = useState("");
|
||||||
const [inflLevel, setInflLevel] = useState<InflationVolatilityLevel>("sehr_niedrig");
|
const [inflLevel, setInflLevel] = useState<InflationVolatilityLevel>("sehr_niedrig");
|
||||||
const [inflManual, setInflManual] = useState("1");
|
const [inflManual, setInflManual] = useState("1");
|
||||||
const [target, setTarget] = useState(Math.max(0, computed.nachlass));
|
const [drafts, setDrafts] = useState<Record<string, ElementDraft>>({});
|
||||||
const [els, setEls] = useState<Record<string, ElementInput>>(() =>
|
|
||||||
Object.fromEntries(
|
|
||||||
returnElements.map((e) => [e.id, { mean: "", level: defaultVolatilityLevel(e.category), manualSigma: "10" }])
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState({ index: 0, count: 1, fraction: 0 });
|
||||||
const [result, setResult] = useState<MonteCarloResult | null>(null);
|
const [results, setResults] = useState<ScenarioMcResult[] | null>(null);
|
||||||
|
|
||||||
function setEl(id: string, patch: Partial<ElementInput>) {
|
const scenarioKey = scenarios.map((s) => s.id).join(",");
|
||||||
setEls((prev) => ({ ...prev, [id]: { ...prev[id], ...patch } }));
|
|
||||||
|
// 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 =
|
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 estSeconds = Math.max(1, Math.round((runs * Math.max(1, selected.length)) / 6000));
|
||||||
const detPoints = useMemo(() => {
|
|
||||||
const startAge = plan.persons.find((p) => p.role === "PERSON_A")?.age ?? plan.persons[0]?.age ?? 0;
|
|
||||||
const pts: { age: number; det: number }[] = [];
|
|
||||||
if (computed.phases.length > 0) {
|
|
||||||
pts.push({ age: startAge, det: computed.phases[0].startWealthNominal });
|
|
||||||
let acc = 0;
|
|
||||||
for (const ph of computed.phases) {
|
|
||||||
acc += ph.durationYears;
|
|
||||||
pts.push({ age: startAge + acc, det: ph.endWealthNominal });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return pts;
|
|
||||||
}, [computed.phases, plan.persons]);
|
|
||||||
|
|
||||||
const estSeconds = Math.max(1, Math.round(runs / 6000));
|
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
setRunning(true);
|
setRunning(true);
|
||||||
setResult(null);
|
setResults(null);
|
||||||
setProgress(0);
|
setProgress({ index: 0, count: selected.length, fraction: 0 });
|
||||||
try {
|
try {
|
||||||
const elements = Object.fromEntries(
|
const paramByRoot: Record<string, ElementMcParams> = Object.fromEntries(
|
||||||
returnElements.map((e) => {
|
groups.map((g) => {
|
||||||
const ei = els[e.id];
|
const d = draftFor(g);
|
||||||
return [e.id, { mean: Number(ei.mean) || 0, sigma: returnSigma(ei), floor: floorFor(e.category) }];
|
return [g.rootId, { mean: Number(d.mean) || 0, sigma: returnSigma(d), floor: floorFor(g.category) }];
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
const res = await runMonteCarlo(
|
// EIN Seed fuer alle Szenarien: strukturgleiche Szenarien teilen damit dieselben
|
||||||
plan,
|
// 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,
|
runs,
|
||||||
inflationMean: Number(inflMean) || 0,
|
inflationMean: Number(inflMean) || 0,
|
||||||
inflationSigma: inflationSigma(inflLevel, inflManual),
|
inflationSigma: inflationSigma(inflLevel, inflManual),
|
||||||
elements,
|
seed,
|
||||||
target,
|
elementsFor: (p) => paramsForScenario(p, groups, paramByRoot),
|
||||||
},
|
},
|
||||||
(done, total) => setProgress(done / total)
|
(index, count, fraction) => setProgress({ index, count, fraction })
|
||||||
);
|
);
|
||||||
setResult(res);
|
setResults(res);
|
||||||
} finally {
|
} finally {
|
||||||
setRunning(false);
|
setRunning(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const chartData = useMemo(() => {
|
const overallProgress =
|
||||||
if (!result) return [];
|
progress.count > 0 ? (progress.index + progress.fraction) / progress.count : 0;
|
||||||
return result.bands.map((b) => ({
|
|
||||||
age: b.age,
|
|
||||||
// Range-Flaeche als [unten, oben]-Tupel -- robust auch bei negativem p10 (Ruin-Faelle).
|
|
||||||
band: [b.p10, b.p90] as [number, number],
|
|
||||||
median: b.p50,
|
|
||||||
det: detPoints.find((d) => d.age === b.age)?.det ?? null,
|
|
||||||
}));
|
|
||||||
}, [result, detPoints]);
|
|
||||||
|
|
||||||
return (
|
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 className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
|
||||||
<div
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
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">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
|
<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
|
<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.
|
erreicht wird, plus ein Fächer vom pessimistischen bis zum optimistischen Fall.
|
||||||
</p>
|
</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>
|
<p>
|
||||||
<strong className="text-fg">Verteilung:</strong> Renditen werden mit «fetten Rändern» gezogen (Extremcrashs so
|
<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
|
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>
|
</p>
|
||||||
</div>
|
</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 */}
|
{/* Inflation */}
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">Inflation (Plan-Ebene)</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">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
<MeanField
|
<RequiredNumberField
|
||||||
label="Ø Inflation letzte 20 J. (%)"
|
label="Ø Inflation letzte 20 J. (%)"
|
||||||
help={`Der Mittelpunkt, um den gewürfelt wird. Deine Planung nutzt aktuell ${plan.inflationRateDefault} %. CH langfristig ~2 %.`}
|
help={`Der Mittelpunkt, um den gewürfelt wird. Deine Planung nutzt aktuell ${plan.inflationRateDefault} %. CH langfristig ~2 %.`}
|
||||||
value={inflMean}
|
value={inflMean}
|
||||||
@@ -239,45 +340,61 @@ export function MonteCarloDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Elemente */}
|
{/* Renditetragende Elemente -- ein Satz je logischem Element */}
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">Renditetragende Elemente</div>
|
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
|
||||||
{returnElements.length === 0 && (
|
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">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{returnElements.map((e) => {
|
{groups.map((g) => {
|
||||||
const ei = els[e.id];
|
const d = draftFor(g);
|
||||||
|
const partial = g.scenarioIds.length < selected.length;
|
||||||
return (
|
return (
|
||||||
<div key={e.id} className="rounded-xl border border-border bg-surface-2 p-3">
|
<div key={g.rootId} className="rounded-xl border border-border bg-surface-2 p-3">
|
||||||
<div className="mb-2 flex items-center gap-2 text-sm">
|
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
|
||||||
<span className="font-semibold text-fg">{e.name}</span>
|
<span className="font-semibold text-fg">{g.name}</span>
|
||||||
<span className="text-xs text-faint">{CATEGORY_LABELS[e.category]}</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>
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
<MeanField
|
<RequiredNumberField
|
||||||
label="Ø Rendite letzte 20 J. (%)"
|
label="Ø Rendite letzte 20 J. (%)"
|
||||||
help="Der Mittelpunkt, um den gewürfelt wird – die realistische Rendite dieser Anlage, nicht deine (evtl. optimistische) Planannahme."
|
help="Der Mittelpunkt, um den gewürfelt wird – die realistische Rendite dieser Anlage, nicht deine (evtl. optimistische) Planannahme."
|
||||||
value={ei?.mean ?? ""}
|
value={d.mean}
|
||||||
onChange={(v) => setEl(e.id, { mean: v })}
|
onChange={(v) => setDraft(g.rootId, g, { mean: v })}
|
||||||
/>
|
/>
|
||||||
<SelectField
|
<SelectField
|
||||||
label="Streuung"
|
label="Streuung"
|
||||||
help={RETURN_HELP}
|
help={RETURN_HELP}
|
||||||
value={ei.level}
|
value={d.level}
|
||||||
onChange={(v: ReturnVolatilityLevel) => setEl(e.id, { level: v })}
|
onChange={(v: ReturnVolatilityLevel) => setDraft(g.rootId, g, { level: v })}
|
||||||
options={RETURN_LEVEL_OPTIONS}
|
options={RETURN_LEVEL_OPTIONS}
|
||||||
/>
|
/>
|
||||||
{ei.level === "manuell" ? (
|
{d.level === "manuell" ? (
|
||||||
<NumberField label="Standardabw. (%)" step={0.5} value={Number(ei.manualSigma) || 0} onChange={(v) => setEl(e.id, { manualSigma: String(v) })} />
|
<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>
|
</div>
|
||||||
{(e.category === "PENSION_FUND" || e.category === "PILLAR_3A") && (
|
{(g.category === "PENSION_FUND" || g.category === "PILLAR_3A") && (
|
||||||
<p className="mt-2 text-[11px] text-faint">Boden 0 %: {CATEGORY_LABELS[e.category]} schreibt keine negative Rendite gut.</p>
|
<p className="mt-2 text-[11px] text-faint">Boden 0 %: {CATEGORY_LABELS[g.category]} schreibt keine negative Rendite gut.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -298,14 +415,10 @@ export function MonteCarloDialog({
|
|||||||
{ value: "10000", label: "10'000 (genau)" },
|
{ 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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{missing && <p className="text-xs text-danger">Bitte alle Pflichtfelder (Ø-Werte) ausfüllen, um die Simulation zu starten.</p>}
|
{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">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={missing || running || returnElements.length === 0}
|
disabled={!canRun || running}
|
||||||
onClick={run}
|
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"
|
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>
|
</button>
|
||||||
{running && (
|
{running && (
|
||||||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-2">
|
<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>
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -347,74 +471,161 @@ function ReadOnlySigma({ value }: { value: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MonteCarloResults({
|
function MonteCarloResults({
|
||||||
result,
|
results,
|
||||||
target,
|
detPoints,
|
||||||
chartData,
|
|
||||||
}: {
|
}: {
|
||||||
result: MonteCarloResult;
|
results: ScenarioMcResult[];
|
||||||
target: number;
|
detPoints: { age: number; det: number }[];
|
||||||
chartData: { age: number; band: [number, number]; median: number; det: number | null }[];
|
|
||||||
}) {
|
}) {
|
||||||
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-4 border-t border-border pt-4">
|
<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">
|
{/* Vergleichstabelle -- das eigentliche Vergleichsinstrument */}
|
||||||
<Stat label="Erfolgswahrscheinlichkeit" value={`${Math.round(result.successProbability * 100)} %`} help={`Anteil der Läufe mit Endvermögen ≥ ${formatChf(target)}.`} good />
|
<div>
|
||||||
<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 />
|
<div className="mb-1 flex items-center text-xs font-semibold text-fg">
|
||||||
<Stat label="Läufe" value={result.runs.toLocaleString("de-CH")} />
|
Ergebnis je Szenario
|
||||||
</div>
|
{!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 className="grid grid-cols-1 gap-3 sm:grid-cols-3 text-sm">
|
)}
|
||||||
<Band label="Pessimistisch (10 %)" value={result.finalWealthP10} />
|
</div>
|
||||||
<Band label="Median" value={result.finalWealthMedian} />
|
<div className="overflow-x-auto rounded-xl border border-border">
|
||||||
<Band label="Optimistisch (90 %)" value={result.finalWealthP90} />
|
<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>
|
<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">
|
<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.
|
{single ? (
|
||||||
Die gestrichelte Linie ist deine deterministische Planung – sie liegt meist leicht über dem Median (Schwankung
|
<>
|
||||||
frisst Rendite).
|
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 10–90 %-Bänder werden hier bewusst nicht
|
||||||
|
übereinandergelegt – wähle ein einzelnes Szenario, um den Fächer zu sehen.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<div className="h-72 w-full">
|
<div className="h-72 w-full">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<ComposedChart data={chartData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
{single ? (
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
<ComposedChart data={singleData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||||
<XAxis dataKey="age" tick={{ fontSize: 11 }} tickFormatter={(v) => `${v} J.`} />
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||||
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} />
|
<XAxis dataKey="age" tick={{ fontSize: 11 }} tickFormatter={(v) => `${v} J.`} />
|
||||||
<Tooltip
|
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} />
|
||||||
formatter={(v, name) => [typeof v === "number" ? formatChf(v) : v, name]}
|
<Tooltip
|
||||||
labelFormatter={(v) => `Alter ${v}`}
|
formatter={(v, name) => [typeof v === "number" ? formatChf(v) : v, name]}
|
||||||
/>
|
labelFormatter={(v) => `Alter ${v}`}
|
||||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
/>
|
||||||
<Area dataKey="band" name="10–90 % Band" stroke="none" fill="var(--accent)" fillOpacity={0.16} isAnimationActive={false} />
|
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||||
<Line dataKey="median" name="Median" stroke="var(--accent)" strokeWidth={2} dot={false} isAnimationActive={false} />
|
<Area dataKey="band" name="10–90 % Band" stroke="none" fill="var(--accent)" fillOpacity={0.16} isAnimationActive={false} />
|
||||||
<Line dataKey="det" name="Planung (deterministisch)" stroke="var(--muted)" strokeWidth={1.5} strokeDasharray="5 3" dot={false} connectNulls isAnimationActive={false} />
|
<Line dataKey="median" name="Median" stroke="var(--accent)" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||||
</ComposedChart>
|
<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>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,15 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { computePlan } from "@/lib/calculations";
|
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";
|
import type { PlanInput } from "@/lib/types";
|
||||||
|
|
||||||
// Plan: 40-jaehrig, 1 Phase 10 Jahre, ein Sonstiges Vermoegen 100'000 @ 5 %, 2 % Inflation.
|
// 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);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -79,6 +79,149 @@ export interface MonteCarloResult {
|
|||||||
bands: { age: number; p10: number; p50: number; p90: number }[];
|
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) ---
|
// --- Zufallszahlen (seedbar, damit ein Lauf reproduzierbar ist) ---
|
||||||
|
|
||||||
function mulberry32(seed: number): () => number {
|
function mulberry32(seed: number): () => number {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user