diff --git a/SPEZIFIKATION.md b/SPEZIFIKATION.md index bfdb97f..d9e3976 100644 --- a/SPEZIFIKATION.md +++ b/SPEZIFIKATION.md @@ -4,10 +4,10 @@ | | | |---|---| | **Dokument** | Funktionale und Technische Spezifikation FPT | -| **Version** | 0.9 | +| **Version** | 1.0 | | **Datum** | 2026-07-18 | | **Status** | Lebendes Dokument | -| **Codestand** | Arbeitsstand nach `a5d4868` inkl. UI-Umbau und Planstart (Branch `main`) | +| **Codestand** | Arbeitsstand nach `d203e50` inkl. Szenario-Vergleich in der Monte-Carlo-Simulation und Sensitivitätsanalyse (Branch `main`) | | **Ersetzt** | `FDD_TDD_FPT.docx` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet | | **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` | @@ -17,6 +17,7 @@ | Version | Datum | Autor | Änderung | |---|---|---|---| +| 1.0 | 2026-07-18 | Claude (Opus 4.8) | **Szenario-Vergleich in der Monte-Carlo-Simulation** und **Sensitivitätsanalyse / Tornado** (Roadmap Nr. 20). (1) Die MC-Simulation rechnet neu **mehrere Szenarien desselben Plans in einem Lauf**. Die historischen Annahmen werden dabei nur **einmal je logischem Element** erfasst – die Zuordnung über die Herkunfts-Kette `sourceElementId`, dieselbe Grundlage wie beim Diff (neue Funktionen `resolveRootElementId`, `buildElementGroups`, `paramsForScenario`, `runMonteCarloMulti`). Alle Szenarien laufen mit **demselben Seed** (Common Random Numbers), damit Unterschiede strukturell und nicht zufällig sind. Der **Zielbetrag bleibt szenario-eigen** (vorbelegt mit dem jeweils geplanten Endvermögen) – nur so misst die Erfolgswahrscheinlichkeit, wie oft ein Szenario sein *eigenes* Versprechen hält. Ergebnis als Vergleichstabelle plus Median-Linien je Szenario; bei einem einzelnen Szenario unverändert der bisherige Fächer. (2) Neuer Bereich **Einflussfaktoren** (eigener Button, eigener Dialog) mit einem **Tornado-Chart** nach dem One-at-a-time-Verfahren: neues reines Modul `sensitivity.ts` mit sieben Treibern, je Treiber an-/abwählbar und mit **pflichtiger, frei definierbarer Bandbreite ohne Default**. Das **Pensionsalter ist bewusst nicht enthalten** (Begründung: 9.18). Neue Kapitel 3.6.6, 4.12.6, 4.13, 9.18; 20 Tests ergänzt (60 → 80). Keine DB-Änderung, keine Verhaltensänderung bestehender Pläne. **Ausserdem vier Dokumentationsfehler korrigiert:** Kap. 1.2 nannte noch Monte-Carlo, Hypothekarzinsen und Immobilien-Wertsteigerung als nicht umgesetzt (seit 0.5/0.7 vorhanden); Kap. 4.6.5 endete mit einem widersprüchlichen Restsatz zur fehlenden Wertsteigerung; Kap. 5.2/5.3/5.5.2/8.1 waren bei Migrationszahl, Dateiliste, Komponentenliste und Testzahlen veraltet; der Glossar-Eintrag „Szenario" beschrieb noch das Modell vor V6. | | 0.9 | 2026-07-18 | Claude (Opus 4.8) | **UI-Umbau und Planstart.** (1) Die Grafiken liegen neu im eigenen Bereich **Grafiken** (Dialog, Button oben) statt unter der Matrix. (2) Kennzahl **Geschätzter Nachlass** entfernt – sie war identisch mit dem nominalen Endvermögen. (3) **Monte-Carlo-Button** nach oben zu den Szenario-Aktionen verschoben. (4) Neues Profilfeld **Planstart (Jahr)** (`Scenario.startYear`, Migration; reine Anzeige, Berechnung bleibt in relativen Jahren) – erscheint auf der Zeitachse. (5) Zeitachse zeigt neu die **Lebensphasen als Segmente** (Breite = Dauer, Einfärbung nach Phasentyp, Jahresspanne). (6) **Lebensphase bearbeiten** neu als Popup statt Panel unter der Tabelle. (7) **Vermögensverlauf** über **alle Jahre** statt nur über die Phasengrenzen – dafür führt `computePlan` das Vermögen neu pro Jahr mit (`YearPoint.wealthNominal/wealthReal`). Zwei Tests ergänzt (58 → 60). | | 0.8 | 2026-07-18 | Claude (Opus 4.8) | **Szenario-Hierarchie (V6)** – grösste Umstrukturierung bisher. Der **Plan** ist neu ein schlanker Behälter ohne Finanzdaten; die berechenbare Einheit ist das **Szenario**, das Grundprofil (inkl. **Pensionsalter** → Frühpensionierungs-Szenarien), Phasen und Elemente trägt. Jeder Plan erhält beim Anlegen automatisch ein **Basisszenario**; weitere Szenarien entstehen als vollständige Kopie eines beliebigen Szenarios und hängen als **Baum** darunter (Sidebar zeigt die Verschachtelung). Kopierte Phasen/Elemente tragen Herkunfts-Verweise (`sourcePhaseId`, `sourceElementId`) – darauf beruht die **Abweichungs-Markierung**: geändert = gelb, neu = grün, entfernt = graue Geisterzeile (eigene Theme-Tokens für alle drei Farbschemata). Charts vergleichen neu die Geschwister-Szenarien. Datenmodell: neue Tabelle `Plan`, bisheriger `Plan` → `Scenario` (IDs erhalten), `planId` → `scenarioId` in Person/Phase/FinancialElement. API neu unter `/api/scenarios/*`. Neue Kapitel 2.1, 3.2, 4.13; 9 Diff-Tests + Migrations-Test (48 → 58). **Migration mit echtem Postgres (PGlite) verifiziert**, inkl. verschachtelter Szenarien und Cascade. | | 0.7 | 2026-07-17 | Claude (Opus 4.8) | **Monte-Carlo-Simulation** (Roadmap Nr. 19, Stufe A). Button in der Planansicht → Dialog mit Erklärung, Eingaben und Ergebnis. Statt einer festen Rendite/Inflation werden tausende Zufallspfade gerechnet; ausgewiesen werden **Ruinwahrscheinlichkeit**, **Erfolgswahrscheinlichkeit** (P(Endvermögen ≥ Zielbetrag)) und ein **Fächer** (10 %/Median/90 %) plus die deterministische Linie. Läuft komplett im Browser (`computePlan` ist rein). Modell: fettschwänzige Verteilung (Student-t, ν=5), gemeinsamer Marktschock (ρ=0.7), Böden 0 % für PK/3a und −100 % sonst. Pro renditetragendem Element und für die Inflation je: historischer Ø (Pflicht), Streuungsstufe, σ. Neue Datei `montecarlo.ts` + optionaler `sample`-Parameter in `computePlan` (Inflation neu als kumulatives Array; deterministisch identisch). Neues Kapitel 4.12; 7 MC-Tests + Refactor-Absicherung (41 → 48). Keine Verhaltensänderung, keine DB-Änderung. | @@ -66,15 +67,25 @@ Aus dem Code direkt ableitbare Abgrenzungen: - **Keine Steuerberechnung** ausser den drei explizit modellierten Sätzen (Kapitalbezugssteuer, Grundstückgewinnsteuer, PK-Umwandlungssatz). Einkommens- und - Vermögenssteuern sind nicht modelliert und müssen vom Benutzer in den Ausgaben erfasst werden. -- **Keine Monte-Carlo-Simulation / keine Stochastik.** Alle Renditen sind deterministische - Jahresprozentsätze. -- **Keine Hypothekarzinsen.** Eine Hypothek reduziert nur den Nettowert der Immobilie; - Zinskosten sind vom Benutzer in den Ausgaben zu erfassen. -- **Keine Wertentwicklung von Immobilien.** Der Kaufpreis ist über die Phasendauer konstant - (Details siehe [4.6.5](#465-real_estate-immobilie)). + Vermögenssteuern sind nicht modelliert und müssen vom Benutzer in den Ausgaben erfasst werden + (Begründung: [9.14](#914-keine-steuerschätzung)). +- **Keine Nebenkosten, kein Eigenmietwert, keine Mieteinnahmen** bei Immobilien + (siehe [9.3](#93-immobilien-was-noch-fehlt)). +- **Keine automatische Deckung von Liquiditätslücken.** Negatives Cash wird gemeldet, aber nicht + korrigiert (siehe [9.1](#91-cash-wird-nicht-automatisch-ausgeglichen)). - **Keine Mehrbenutzer-Kollaboration.** Pläne gehören genau einem Benutzer. +> **Hinweis zur Dokumenthistorie:** Bis Version 0.9 stand hier zusätzlich „keine +> Monte-Carlo-Simulation", „keine Hypothekarzinsen" und „keine Wertentwicklung von Immobilien". +> Alle drei sind seit Version 0.5 bzw. 0.7 umgesetzt ([4.4.5](#445-netto-brutto-umrechnung-für-die-ahv), +> [4.6.5](#465-real_estate-immobilie), [4.12](#412-monte-carlo-simulation)); die Abgrenzung war +> versehentlich stehen geblieben. + +Die deterministische Rechnung bleibt der Kern des Tools; Stochastik kommt ausschliesslich in der +**Monte-Carlo-Simulation** ([4.12](#412-monte-carlo-simulation)) und – als reine Was-wäre-wenn- +Rechnung – in der **Sensitivitätsanalyse** ([4.13](#413-sensitivitätsanalyse-tornado)) dazu. +Beide verändern die gespeicherten Plandaten nicht. + ## 1.3 Kernprinzip: Plan als selbsttragende Einheit Seit dem V3-Rework (Migration `20260713150000_profile_to_plan_v3`) trägt **jeder Plan sein @@ -823,6 +834,26 @@ Dateiname = Planname, nicht-alphanumerische Zeichen durch `_` ersetzt. Referenz: `src/lib/calculations.ts` Zeilen 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.1 Layout @@ -1195,9 +1226,6 @@ Basis der Verzinsung ist die Liegenschaft. Falls **nicht** fortgeschrieben und **nicht** Phase 1 (= Neukauf in einer späteren Phase): `investmentsFromCash += max(0, equity)` – das Eigenkapital wird aus dem Cash finanziert. -Der Wert der Immobilie ist über die Phasendauer **konstant der Kaufpreis**; nur die Hypothek -sinkt. Es gibt keine Wertsteigerung – der Verkaufspreis wird erst am Übergang erfasst. - ### 4.6.6 OTHER_ASSET ``` @@ -1592,6 +1620,146 @@ Der Median liegt typischerweise **unter** der deterministischen Linie – der leicht zu optimistisch ist. Pflichtfelder: ohne die historischen Ø-Werte startet die Simulation nicht. +### 4.12.6 Mehrere Szenarien im Vergleich + +Der Dialog rechnet auf Wunsch **mehrere Szenarien desselben Plans in einem Lauf**. Drei +Entscheide machen den Vergleich überhaupt aussagekräftig. + +**(1) Eine Parametereingabe je logischem Element.** Die MC-Parameter hängen an der `elementId`, +und Element-IDs sind szenario-spezifisch – eine Kopie bekommt neue IDs. Ohne Zuordnung müsste +dieselbe Anlage pro Szenario erneut erfasst werden. Das wäre nicht nur mühsam, es würde den +Vergleich **zerstören**: Mit 5 % im einen und 6 % im anderen Szenario vergleicht man die +Eingaben statt der Szenarien. + +Die Zuordnung läuft über die Herkunfts-Kette `sourceElementId` – dieselbe Grundlage wie beim +Diff ([3.2.6](#326-abweichungs-markierung-diff)). `resolveRootElementId` folgt ihr bis zum +Ursprung; alle Elemente mit derselben Wurzel bilden eine **Gruppe** und teilen einen +Parametersatz. Deshalb lädt der Dialog beim Öffnen **alle** Szenarien des Plans, nicht nur die +ausgewählten: Nur so löst sich die Kette auch über ein übersprungenes Zwischen-Szenario auf +(Basis → S1 → S2 bei Auswahl von Basis und S2). Ein Element, das es nur in einem Szenario gibt, +bildet eine eigene Gruppe und wird im Dialog entsprechend gekennzeichnet. + +**(2) Gemeinsamer Seed.** Alle Szenarien eines Laufs verwenden denselben Zufalls-Seed +(*Common Random Numbers*). Ohne das wären kleine Unterschiede blosses Rauschen: Bei 1'000 Läufen +beträgt der Standardfehler der Erfolgswahrscheinlichkeit rund 1.5 Prozentpunkte – zwei identische +Szenarien könnten 87 % und 90 % zeigen. Mit gemeinsamem Seed teilen **strukturgleiche** Szenarien +exakt dieselben Marktpfade, und die Unterschiede sind rein strukturell. Einschränkung: Die Pfade +sind nur dort identisch, wo die Struktur es ist – abweichende Laufzeit oder Elementzahl verschiebt +die Ziehungsreihenfolge. + +**(3) Zielbetrag je Szenario.** Der Zielbetrag ist bewusst **nicht** gemeinsam, sondern je +Szenario mit dessen geplantem Endvermögen vorbelegt (einzeln editierbar). Damit misst die +Erfolgswahrscheinlichkeit, wie oft ein Szenario **sein eigenes Versprechen** hält. + +> **Warum das der entscheidende Punkt ist:** In der Simulation wird die *geplante* Rendite +> vollständig durch die gewürfelte ersetzt. Unterscheiden sich zwei Szenarien **nur** in der +> geplanten Rendite (5 % vs. 6 %), sind ihre simulierten Verteilungen **identisch** – gleicher +> Median, gleicher Fächer, gleiche Ruinwahrscheinlichkeit. Der einzige Unterschied ist der +> Zielbetrag. Mit einem gemeinsamen Zielbetrag zeigte der Vergleich zwei identische Zeilen; mit +> szenario-eigenem Zielbetrag zeigt er die eigentliche Aussage: Das pessimistisch geplante +> Szenario erreicht sein tieferes Ziel häufiger und ist damit das belastbarere. Durch einen Test +> abgedeckt (Kap. 8.2). + +Folge für die Darstellung: Die **Ruinwahrscheinlichkeit** ist zielbetrags-unabhängig und damit +die direkt vergleichbare Kennzahl; die Erfolgswahrscheinlichkeit bezieht sich je Zeile auf eine +andere Messlatte. Deshalb steht der Zielbetrag als **eigene Spalte** in der Vergleichstabelle. + +**Darstellung:** eine Vergleichstabelle (Szenario, Ziel, Erfolg, Ruin, P10/Median/P90) als +Hauptinstrument, dazu ein Chart mit der **Median-Linie je Szenario**. Übereinandergelegte +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 @@ -1624,7 +1792,7 @@ nicht. FPT/ ├── prisma/ │ ├── schema.prisma Datenmodell -│ └── migrations/ 8 Migrationen (chronologisch) +│ └── migrations/ 12 Migrationen (chronologisch, siehe 5.4.6) ├── src/ │ ├── app/ │ │ ├── api/ Route Handlers (siehe Kapitel 6) @@ -1632,7 +1800,7 @@ FPT/ │ │ ├── page.tsx Einstiegsseite (lädt /api/auth/me → AppShell) │ │ ├── layout.tsx Root-Layout, Theme-Init-Script, Metadata │ │ └── globals.css Tailwind + semantische Farb-Tokens (3 Themes) -│ ├── components/ 12 React-Komponenten (alle "use client") +│ ├── components/ 15 React-Komponenten (alle "use client") │ ├── generated/prisma/ Generierter Prisma-Client (nicht editieren) │ ├── lib/ Domänenlogik (siehe 5.3) │ └── middleware.ts Zugriffsschutz (Edge-Runtime) @@ -1663,7 +1831,9 @@ PlanComputed ← an den Client geliefert | `elements.ts` | Kategorien, Labels, Reihenfolge, JSON-Payload-Typen, Zod-Schemas, `num()` | | `types.ts` | Domänentypen für API und Berechnung | | `constants.ts` | Schweizer Systemparameter | -| `montecarlo.ts` | Monte-Carlo-Simulation (Sampler + Treiber). Keine I/O, läuft im Browser. | +| `montecarlo.ts` | Monte-Carlo-Simulation (Sampler + Treiber), Szenario-Vergleich und Element-Gruppierung über die Herkunfts-Kette. Keine I/O, läuft im Browser. | +| `sensitivity.ts` | Sensitivitätsanalyse / Tornado: Treiber-Katalog, Parameter-Transformationen, `computeTornado`. Rein, läuft im Browser. | +| `diff.ts` | Abweichungs-Erkennung eines Szenarios gegen sein Eltern-Szenario (Kap. 3.2.6) | | `queries.ts` | Prisma-Includes, `toPlanInput()`, Ownership-Abfragen | | `db.ts` | Prisma-Singleton (Global-Cache im Dev, verhindert Connection-Leak bei HMR) | | `auth.ts` | JWT erzeugen/prüfen (Edge-kompatibel via `jose`) | @@ -1916,7 +2086,9 @@ wird der Plan neu geladen; die Berechnung kommt immer vom Server. | `PlanProfileFields` | 101 | Wiederverwendete Grundprofil-Felder | | `PhaseDetail` | 95 | Phase bearbeiten/löschen | | `SparquoteChart` | 64 | Einkommen/Ausgaben pro Jahr mit Spar-/Verzehrbändern | -| `MonteCarloDialog` | ~430 | Monte-Carlo-Dialog: Erklärung, Eingaben, Lauf, Ergebnis + Fächer | +| `MonteCarloDialog` | ~600 | Monte-Carlo-Dialog: Erklärung, Szenario-Auswahl, Eingaben, Lauf, Vergleichstabelle + Fächer | +| `SensitivityDialog` | ~330 | Einflussfaktoren: Erklärung, Treiber-Auswahl mit Bandbreiten, Tornado + Tabelle | +| `SpecView` | 50 | Rendert `SPEZIFIKATION.md` (via `/api/spec`) als lesbares Dokument | | `InfoBubble` | 28 | Hilfe-Tooltip | ### 5.5.3 Wiederverwendungsmuster @@ -2139,11 +2311,17 @@ Zielumgebung: Hetzner CX23, Traefik als Reverse Proxy, Domain `fpt.aicds.ch`, Gi ## 8.1 Teststrategie Getestet wird ausschliesslich der Berechnungskern – bewusst, da dort die Fachlogik und das -Regressionsrisiko liegen. `src/lib/calculations.test.ts` (41 Tests: AHV-Rentenformel, Immobilie, -Teilverkauf, Sonderamortisation, AHV einkommensabhängig, „V5 Golden Tests") und -`src/lib/montecarlo.test.ts` (7 Tests), `src/lib/diff.test.ts` (9 Tests) und `src/lib/migrations.test.ts` (1 Test, spielt alle Migrationen gegen echtes PostgreSQL ein) ergeben zusammen **60 Tests**, ausgeführt mit Vitest in -der Node-Umgebung (`vitest.config.ts`, Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, -API- oder E2E-Tests. +Regressionsrisiko liegen. Ausgeführt mit Vitest in der Node-Umgebung (`vitest.config.ts`, +Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests. + +| Datei | Tests | Schwerpunkt | +|---|---|---| +| `calculations.test.ts` | 43 | AHV-Rentenformel, Immobilie, Teilverkauf, Sonderamortisation, AHV einkommensabhängig, „V5 Golden Tests" | +| `sensitivity.test.ts` | 14 | Treiber-Transformationen (Reinheit, Einheiten, Kappung), Verfügbarkeit, Tornado-Sortierung und -Richtung | +| `montecarlo.test.ts` | 13 | Determinismus, Volatilität/Vol-Drag, Böden, Reproduzierbarkeit; Element-Gruppierung und Szenario-Vergleich | +| `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario | +| `migrations.test.ts` | 1 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein | +| **Total** | **80** | | ## 8.2 Testfälle @@ -2170,6 +2348,15 @@ API- oder E2E-Tests. | **MC: Volatilität / Vol-Drag** | σ > 0 spreizt p10 { const data = await api.get<{ plans: PlanListItem[] }>("/api/plans"); @@ -290,6 +293,14 @@ export function AppShell({ username }: { username: string }) { Monte-Carlo-Simulation + )} {diff && detail.base && ( @@ -337,10 +348,16 @@ export function AppShell({ username }: { username: string }) { setShowMonteCarlo(false)} /> )} + {showSensitivity && detail && ( + setShowSensitivity(false)} /> + )} + {copyFrom && ( void; + step?: number; + suffix?: string; + placeholder?: string; +}) { + const empty = value.trim() === ""; + return ( +
+ +
+ onChange(e.target.value)} + className={`${baseInputClass} ${suffix ? "pr-8" : ""} ${ + empty ? "border-danger focus:border-danger" : "" + }`} + /> + {suffix && ( + + {suffix} + + )} +
+ {empty &&

Pflichtfeld – bitte ausfüllen.

} +
+ ); +} + // Ganzzahliges Betragsfeld. Zeigt den Wert unfokussiert mit 1'000er-Trennzeichen an, // akzeptiert fokussiert beliebige ganze Zahlen (keine Nachkommastellen) und bietet // Pfeil-Buttons mit Klick-und-Halten-BESCHLEUNIGUNG (1 -> 10 -> 100 -> 1'000 -> ...). diff --git a/src/components/MonteCarloDialog.tsx b/src/components/MonteCarloDialog.tsx index b8a2b9a..d2890e9 100644 --- a/src/components/MonteCarloDialog.tsx +++ b/src/components/MonteCarloDialog.tsx @@ -1,26 +1,35 @@ "use client"; -import { useMemo, useState } from "react"; -import { Area, CartesianGrid, ComposedChart, Legend, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; +import { useEffect, useMemo, useState } from "react"; +import { Area, CartesianGrid, ComposedChart, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; import { Dices, X } from "lucide-react"; -import { NumberField, SelectField, MoneyField } from "@/components/FormField"; +import { NumberField, SelectField, MoneyField, RequiredNumberField } from "@/components/FormField"; import { InfoBubble } from "@/components/InfoBubble"; +import { api } from "@/lib/api-client"; import { formatChf } from "@/lib/format"; import { - runMonteCarlo, + buildElementGroups, defaultVolatilityLevel, - RETURN_VOLATILITY_LEVELS, + floorFor, + paramsForScenario, + runMonteCarloMulti, INFLATION_VOLATILITY_LEVELS, RETURN_BEARING, - floorFor, - type ReturnVolatilityLevel, + RETURN_VOLATILITY_LEVELS, + type ElementGroup, + type ElementMcParams, type InflationVolatilityLevel, - type MonteCarloResult, + type ReturnVolatilityLevel, + type ScenarioMcResult, + type ScenarioRunInput, } from "@/lib/montecarlo"; import { CATEGORY_LABELS } from "@/lib/elements"; -import type { PlanInput } from "@/lib/types"; +import type { PlanInput, ScenarioMeta } from "@/lib/types"; import type { PlanComputed } from "@/lib/calculations"; +// Farben der Szenario-Serien -- wie im Vermoegensverlauf, damit die Zuordnung vertraut bleibt. +const PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"]; + const RETURN_LEVEL_OPTIONS: { value: ReturnVolatilityLevel; label: string }[] = [ { value: "sehr_niedrig", label: "Sehr niedrig" }, { value: "niedrig", label: "Niedrig" }, @@ -40,149 +49,186 @@ const RETURN_HELP = const INFLATION_HELP = "Wie stark die Inflation schwankt. Für die Schweiz ist sie historisch sehr stabil (Sehr niedrig ≈ 1 %). Höhere Stufen wären Hyperinflations-Annahmen."; -// Pflicht-Zahlenfeld, das wirklich leer sein kann (NumberField erzwingt eine Zahl). -function MeanField({ - label, - help, - value, - onChange, -}: { - label: string; - help: string; - value: string; - onChange: (v: string) => void; -}) { - const empty = value.trim() === ""; - return ( -
- - onChange(e.target.value)} - className={`w-full rounded-lg border bg-input px-2.5 py-1.5 text-sm text-fg shadow-sm focus:outline-none focus:ring-2 focus:ring-accent/25 ${ - empty ? "border-danger" : "border-border focus:border-accent" - }`} - /> - {empty &&

Pflichtfeld – bitte ausfüllen.

} -
- ); +interface ElementDraft { + mean: string; + level: ReturnVolatilityLevel; + manualSigma: string; } -type ElementInput = { mean: string; level: ReturnVolatilityLevel; manualSigma: string }; +interface LoadedScenario { + id: string; + name: string; + plan: PlanInput; + computed: PlanComputed; +} -function returnSigma(el: ElementInput): number { - return el.level === "manuell" - ? Number(el.manualSigma) || 0 - : RETURN_VOLATILITY_LEVELS[el.level]; +function returnSigma(el: ElementDraft): number { + return el.level === "manuell" ? Number(el.manualSigma) || 0 : RETURN_VOLATILITY_LEVELS[el.level]; } function inflationSigma(level: InflationVolatilityLevel, manual: string): number { return level === "manuell" ? Number(manual) || 0 : INFLATION_VOLATILITY_LEVELS[level]; } +// Deterministische Planungslinie eines Szenarios (Alterspunkte = Phasengrenzen). +function detPointsOf(plan: PlanInput, computed: PlanComputed): { age: number; det: number }[] { + const startAge = plan.persons.find((p) => p.role === "PERSON_A")?.age ?? plan.persons[0]?.age ?? 0; + if (computed.phases.length === 0) return []; + const pts = [{ age: startAge, det: computed.phases[0].startWealthNominal }]; + let acc = 0; + for (const ph of computed.phases) { + acc += ph.durationYears; + pts.push({ age: startAge + acc, det: ph.endWealthNominal }); + } + return pts; +} + export function MonteCarloDialog({ plan, computed, + meta, + scenarios, onClose, }: { plan: PlanInput; computed: PlanComputed; + meta: ScenarioMeta; + // Alle Szenarien dieses Plans (auch nicht ausgewaehlte) -- sie werden geladen, damit sich + // die Herkunfts-Kette der Elemente auch ueber uebersprungene Zwischen-Szenarien aufloest. + scenarios: ScenarioMeta[]; onClose: () => void; }) { - const returnElements = useMemo( - () => plan.elements.filter((e) => RETURN_BEARING.includes(e.category)), - [plan.elements] - ); + const [loaded, setLoaded] = useState>(() => ({ + [meta.id]: { id: meta.id, name: meta.name, plan, computed }, + })); + const [loading, setLoading] = useState(scenarios.some((s) => s.id !== meta.id)); + const [loadError, setLoadError] = useState(null); + const [selectedIds, setSelectedIds] = useState([meta.id]); + const [targets, setTargets] = useState>({ + [meta.id]: Math.max(0, computed.nachlass), + }); const [runs, setRuns] = useState(1000); const [inflMean, setInflMean] = useState(""); const [inflLevel, setInflLevel] = useState("sehr_niedrig"); const [inflManual, setInflManual] = useState("1"); - const [target, setTarget] = useState(Math.max(0, computed.nachlass)); - const [els, setEls] = useState>(() => - Object.fromEntries( - returnElements.map((e) => [e.id, { mean: "", level: defaultVolatilityLevel(e.category), manualSigma: "10" }]) - ) - ); + const [drafts, setDrafts] = useState>({}); const [running, setRunning] = useState(false); - const [progress, setProgress] = useState(0); - const [result, setResult] = useState(null); + const [progress, setProgress] = useState({ index: 0, count: 1, fraction: 0 }); + const [results, setResults] = useState(null); - function setEl(id: string, patch: Partial) { - setEls((prev) => ({ ...prev, [id]: { ...prev[id], ...patch } })); + const scenarioKey = scenarios.map((s) => s.id).join(","); + + // Die uebrigen Szenarien einmalig nachladen. Ein Plan hat realistisch eine Handvoll + // Szenarien -- gegenueber tausenden Simulationslaeufen faellt das nicht ins Gewicht. + useEffect(() => { + const missing = scenarios.filter((s) => s.id !== meta.id); + if (missing.length === 0) return; + let cancelled = false; + (async () => { + try { + const entries = await Promise.all( + missing.map(async (s) => { + const data = await api.get<{ plan: PlanInput; computed: PlanComputed }>(`/api/scenarios/${s.id}`); + return [s.id, { id: s.id, name: s.name, plan: data.plan, computed: data.computed }] as const; + }) + ); + if (cancelled) return; + setLoaded((prev) => ({ ...prev, ...Object.fromEntries(entries) })); + setTargets((prev) => ({ + ...prev, + ...Object.fromEntries(entries.map(([id, v]) => [id, Math.max(0, v.computed.nachlass)])), + })); + } catch (e) { + if (!cancelled) setLoadError(e instanceof Error ? e.message : "Szenarien konnten nicht geladen werden."); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scenarioKey, meta.id]); + + const allLoaded = useMemo( + () => scenarios.map((s) => loaded[s.id]).filter((s): s is LoadedScenario => !!s), + [scenarios, loaded] + ); + + // Ein Parametersatz je LOGISCHEM Element (ueber die sourceElementId-Kette zusammengefasst). + const groups = useMemo( + () => buildElementGroups(allLoaded, selectedIds), + [allLoaded, selectedIds] + ); + + function draftFor(g: ElementGroup): ElementDraft { + return drafts[g.rootId] ?? { mean: "", level: defaultVolatilityLevel(g.category), manualSigma: "10" }; + } + function setDraft(rootId: string, group: ElementGroup, patch: Partial) { + setDrafts((prev) => ({ ...prev, [rootId]: { ...draftFor(group), ...prev[rootId], ...patch } })); } - // Pflichtfelder: historische Inflation + je Element die historische Rendite muessen gesetzt sein. + function toggleScenario(id: string) { + setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])); + setResults(null); + } + + const selected = selectedIds.map((id) => loaded[id]).filter((s): s is LoadedScenario => !!s); + const anyReturnBearing = selected.some((s) => s.plan.elements.some((e) => RETURN_BEARING.includes(e.category))); + + // Pflichtfelder: historische Inflation + je logischem Element die historische Rendite. const missing = - inflMean.trim() === "" || returnElements.some((e) => (els[e.id]?.mean ?? "").trim() === ""); + inflMean.trim() === "" || groups.some((g) => draftFor(g).mean.trim() === ""); + const canRun = !missing && selected.length > 0 && anyReturnBearing && !loading; - // Deterministische Endwert-Linie fuer den Vergleich im Faecher. - const detPoints = useMemo(() => { - const startAge = plan.persons.find((p) => p.role === "PERSON_A")?.age ?? plan.persons[0]?.age ?? 0; - const pts: { age: number; det: number }[] = []; - if (computed.phases.length > 0) { - pts.push({ age: startAge, det: computed.phases[0].startWealthNominal }); - let acc = 0; - for (const ph of computed.phases) { - acc += ph.durationYears; - pts.push({ age: startAge + acc, det: ph.endWealthNominal }); - } - } - return pts; - }, [computed.phases, plan.persons]); - - const estSeconds = Math.max(1, Math.round(runs / 6000)); + const estSeconds = Math.max(1, Math.round((runs * Math.max(1, selected.length)) / 6000)); async function run() { setRunning(true); - setResult(null); - setProgress(0); + setResults(null); + setProgress({ index: 0, count: selected.length, fraction: 0 }); try { - const elements = Object.fromEntries( - returnElements.map((e) => { - const ei = els[e.id]; - return [e.id, { mean: Number(ei.mean) || 0, sigma: returnSigma(ei), floor: floorFor(e.category) }]; + const paramByRoot: Record = Object.fromEntries( + groups.map((g) => { + const d = draftFor(g); + return [g.rootId, { mean: Number(d.mean) || 0, sigma: returnSigma(d), floor: floorFor(g.category) }]; }) ); - const res = await runMonteCarlo( - plan, + // EIN Seed fuer alle Szenarien: strukturgleiche Szenarien teilen damit dieselben + // Marktpfade, und die Unterschiede sind strukturell statt zufaellig. + const seed = (Math.random() * 2 ** 32) >>> 0; + const runInputs: ScenarioRunInput[] = selected.map((s) => ({ + scenarioId: s.id, + name: s.name, + plan: s.plan, + target: targets[s.id] ?? 0, + })); + const res = await runMonteCarloMulti( + runInputs, { runs, inflationMean: Number(inflMean) || 0, inflationSigma: inflationSigma(inflLevel, inflManual), - elements, - target, + seed, + elementsFor: (p) => paramsForScenario(p, groups, paramByRoot), }, - (done, total) => setProgress(done / total) + (index, count, fraction) => setProgress({ index, count, fraction }) ); - setResult(res); + setResults(res); } finally { setRunning(false); } } - const chartData = useMemo(() => { - if (!result) return []; - return result.bands.map((b) => ({ - age: b.age, - // Range-Flaeche als [unten, oben]-Tupel -- robust auch bei negativem p10 (Ruin-Faelle). - band: [b.p10, b.p90] as [number, number], - median: b.p50, - det: detPoints.find((d) => d.age === b.age)?.det ?? null, - })); - }, [result, detPoints]); + const overallProgress = + progress.count > 0 ? (progress.index + progress.fraction) / progress.count : 0; return (
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" >

@@ -206,6 +252,12 @@ export function MonteCarloDialog({ Ergebnis: die Wahrscheinlichkeit, dass das Geld reicht bzw. dein Zielbetrag erreicht wird, plus ein Fächer vom pessimistischen bis zum optimistischen Fall.

+

+ Mehrere Szenarien: Du kannst unten mehrere Szenarien dieses Plans + gleichzeitig rechnen. Die historischen Annahmen werden dabei nur einmal 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. +

Verteilung: Renditen werden mit «fetten Rändern» gezogen (Extremcrashs so häufig wie in der Realität, nicht wie in der Glockenkurve), und alle riskanten Anlagen fallen in einem @@ -214,11 +266,60 @@ export function MonteCarloDialog({

+ {/* Szenario-Auswahl inkl. Zielbetrag je Szenario */} +
+
+ Szenarien + +
+ {loading &&

Szenarien werden geladen…

} + {loadError &&

{loadError}

} +
+ {scenarios.map((s) => { + const isLoaded = !!loaded[s.id]; + const checked = selectedIds.includes(s.id); + return ( +
+ + {checked && ( +
+ setTargets((prev) => ({ ...prev, [s.id]: v }))} + /> +
+ )} +
+ ); + })} +
+ {selected.length === 0 && ( +

Bitte mindestens ein Szenario auswählen.

+ )} +
+ {/* Inflation */}
Inflation (Plan-Ebene)
-
- {/* Elemente */} + {/* Renditetragende Elemente -- ein Satz je logischem Element */}
-
Renditetragende Elemente
- {returnElements.length === 0 && ( +
+ Renditetragende Elemente + {selected.length > 1 && ( + + )} +
+ {groups.length === 0 && (

- 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).

)}
- {returnElements.map((e) => { - const ei = els[e.id]; + {groups.map((g) => { + const d = draftFor(g); + const partial = g.scenarioIds.length < selected.length; return ( -
-
- {e.name} - {CATEGORY_LABELS[e.category]} +
+
+ {g.name} + {CATEGORY_LABELS[g.category]} + {partial && ( + + nur in: {g.scenarioIds.map((id) => loaded[id]?.name ?? id).join(", ")} + + )}
- setEl(e.id, { mean: v })} + value={d.mean} + onChange={(v) => setDraft(g.rootId, g, { mean: v })} /> setEl(e.id, { level: v })} + value={d.level} + onChange={(v: ReturnVolatilityLevel) => setDraft(g.rootId, g, { level: v })} options={RETURN_LEVEL_OPTIONS} /> - {ei.level === "manuell" ? ( - setEl(e.id, { manualSigma: String(v) })} /> + {d.level === "manuell" ? ( + setDraft(g.rootId, g, { manualSigma: String(v) })} + /> ) : ( - + )}
- {(e.category === "PENSION_FUND" || e.category === "PILLAR_3A") && ( -

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

+ {(g.category === "PENSION_FUND" || g.category === "PILLAR_3A") && ( +

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

)}
); @@ -298,14 +415,10 @@ export function MonteCarloDialog({ { value: "10000", label: "10'000 (genau)" }, ]} /> -

geschätzt ~{estSeconds} s

+

+ {selected.length > 1 ? `${selected.length} Szenarien · ` : ""}geschätzt ~{estSeconds} s +

-
{missing &&

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

} @@ -313,20 +426,31 @@ export function MonteCarloDialog({
{running && (
-
+
)}
- {result && } + {results && results.length > 0 && ( + + )}
); @@ -347,74 +471,161 @@ function ReadOnlySigma({ value }: { value: number }) { } function MonteCarloResults({ - result, - target, - chartData, + results, + detPoints, }: { - result: MonteCarloResult; - target: number; - chartData: { age: number; band: [number, number]; median: number; det: number | null }[]; + results: ScenarioMcResult[]; + detPoints: { age: number; det: number }[]; }) { + const single = results.length === 1; + + // Einzelnes Szenario: der gewohnte Faecher (Band + Median + Planungslinie). + const singleData = useMemo(() => { + if (!single) return []; + return results[0].bands.map((b) => ({ + age: b.age, + band: [b.p10, b.p90] as [number, number], + median: b.p50, + det: detPoints.find((d) => d.age === b.age)?.det ?? null, + })); + }, [single, results, detPoints]); + + // Mehrere Szenarien: nur die Median-Linien -- uebereinandergelegte Baender waeren Farbbrei. + const compareData = useMemo(() => { + if (single) return []; + const ages = Array.from(new Set(results.flatMap((r) => r.bands.map((b) => b.age)))).sort((a, b) => a - b); + return ages.map((age) => { + const row: Record = { age }; + for (const r of results) row[r.scenarioId] = r.bands.find((b) => b.age === age)?.p50 ?? null; + return row; + }); + }, [single, results]); + return (
-
- - - -
- -
- - - + {/* Vergleichstabelle -- das eigentliche Vergleichsinstrument */} +
+
+ Ergebnis je Szenario + {!single && ( + + )} +
+
+ + + + + + + + + + + + + + {results.map((r, i) => ( + + + + + + + + + + ))} + +
SzenarioZielErfolgRuinPessimistisch (10 %)MedianOptimistisch (90 %)
+ + {!single && ( + + )} + {r.name} + + {formatChf(r.target)} + {Math.round(r.successProbability * 100)} % + + {Math.round(r.ruinProbability * 100)} % + {formatChf(r.finalWealthP10)}{formatChf(r.finalWealthMedian)}{formatChf(r.finalWealthP90)}
+
+

+ {results[0].runs.toLocaleString("de-CH")} Läufe je Szenario + {!single && " · gemeinsamer Zufalls-Seed"} +

-
Vermögensfächer nach Alter (nominal)
+
+ {single ? "Vermögensfächer nach Alter (nominal)" : "Vermögensverlauf im Median nach Alter (nominal)"} +

- Das Band reicht vom pessimistischen (10 %) bis zum optimistischen (90 %) Fall, die dunkle Linie ist der Median. - Die gestrichelte Linie ist deine deterministische Planung – sie liegt meist leicht über dem Median (Schwankung - frisst Rendite). + {single ? ( + <> + Das Band reicht vom pessimistischen (10 %) bis zum optimistischen (90 %) Fall, die dunkle Linie ist der + Median. Die gestrichelte Linie ist deine deterministische Planung – sie liegt meist leicht über dem Median + (Schwankung frisst Rendite). + + ) : ( + <> + Je Szenario die mittlere Entwicklung (Median). Die vollständigen 10–90 %-Bänder werden hier bewusst nicht + übereinandergelegt – wähle ein einzelnes Szenario, um den Fächer zu sehen. + + )}

- - - `${v} J.`} /> - Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} /> - [typeof v === "number" ? formatChf(v) : v, name]} - labelFormatter={(v) => `Alter ${v}`} - /> - - - - - + {single ? ( + + + `${v} J.`} /> + Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} /> + [typeof v === "number" ? formatChf(v) : v, name]} + labelFormatter={(v) => `Alter ${v}`} + /> + + + + + + ) : ( + + + `${v} J.`} + /> + Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} /> + [typeof v === "number" ? formatChf(v) : v, name]} + labelFormatter={(v) => `Alter ${v}`} + /> + + {results.map((r, i) => ( + + ))} + + )}
); } - -function Stat({ label, value, help, good, danger }: { label: string; value: string; help?: string; good?: boolean; danger?: boolean }) { - return ( -
-
- {label} - {help && } -
-
{value}
-
- ); -} - -function Band({ label, value }: { label: string; value: number }) { - return ( -
-
{label}
-
{formatChf(value)}
-
- ); -} diff --git a/src/components/SensitivityDialog.tsx b/src/components/SensitivityDialog.tsx new file mode 100644 index 0000000..6bb0d17 --- /dev/null +++ b/src/components/SensitivityDialog.tsx @@ -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("real"); + const [drafts, setDrafts] = useState>({}); + const [result, setResult] = useState(null); + + function draftFor(id: DriverId): RangeDraft { + return drafts[id] ?? emptyDraft; + } + function setDraft(id: DriverId, patch: Partial) { + 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 ( +
+
e.stopPropagation()} + className="flex w-full max-w-4xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl" + > +
+

+ Einflussfaktoren (Sensitivitätsanalyse) +

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

+ Was ist das? Nicht «wie viel Geld habe ich am Schluss», sondern + «welche meiner Annahmen entscheidet überhaupt über das Ergebnis». Bei + manchen Annahmen ist es egal, ob du sie exakt triffst – bei anderen kippt eine kleine Abweichung den ganzen Plan. +

+

+ Wie es funktioniert: Zuerst wird dein Plan wie erfasst gerechnet + (Basisfall). Dann wird ein einziger 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 + Spannweite sortiert ergibt die Trichterform: längster Balken zuoberst. +

+

+ Was du davon hast: 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. +

+

+ Zwei ehrliche Grenzen. Erstens hängt die Balkenlänge von den + Bandbreiten ab, die du unten eingibst – wähle sie so, dass sie ähnlich plausibel sind, nicht ähnlich + gross. Zweitens wird immer nur ein Parameter auf einmal variiert; Kombinationen (schlechte Renditen + und hohe Ausgaben) treffen härter als die Summe der Einzelbalken – dafür ist die + Monte-Carlo-Simulation zuständig. +

+
+ + {/* Zielgroesse */} +
+ { + setMetric(v); + setResult(null); + }} + options={[ + { value: "real", label: "Endvermögen real (kaufkraftbereinigt)" }, + { value: "nominal", label: "Endvermögen nominal" }, + ]} + /> +
+ + {/* Parameter */} +
+
+ Parameter und Bandbreiten + +
+
+ {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 ( +
+ + {dr.checked && ( +
+ setDraft(d.id, { low: v })} + /> + setDraft(d.id, { high: v })} + /> +
+ )} +
+ ); + })} +
+ +

+ Das Pensionsalter 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. +

+
+ + {checked.length < 2 && ( +

Bitte mindestens zwei Parameter auswählen – der Tornado ist eine Rangliste.

+ )} + {incomplete.length > 0 && ( +

Bitte für jeden ausgewählten Parameter eine tiefe und eine hohe Ausprägung angeben.

+ )} + {degenerate.length > 0 && ( +

+ Tiefer und hoher Wert sind identisch bei: {degenerate.map((d) => d.label).join(", ")}. Damit gibt es keine Spannweite. +

+ )} + +
+ +
+ + {result && } +
+
+ ); +} + +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 ( +
+
+
Basisfall · {metricLabel}
+
{formatChf(result.base)} CHF
+
+ +
+
Einflussfaktoren nach Spannweite
+

+ Jeder Balken zeigt, zwischen welchen Werten das {metricLabel} schwankt, wenn nur dieser eine Parameter + innerhalb seiner Bandbreite variiert. Die senkrechte Linie ist der Basisfall. +

+
+ + + + Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} + /> + + + Array.isArray(v) + ? `${formatChf(Number(v[0]))} – ${formatChf(Number(v[1]))}` + : typeof v === "number" + ? formatChf(v) + : v + } + /> + + + + +
+
+ +
+ + + + + + + + + + + + {result.bars.map((b) => ( + + + + + + + + ))} + +
ParameterBandbreitebei «tief»bei «hoch»Spannweite
{b.label}{formatRange(b.low, b.high, b.unit)}{formatChf(b.lowResult)}{formatChf(b.highResult)}{formatChf(b.swing)}
+
+

+ Die Reihenfolge – nicht der absolute Betrag – ist die Aussage. Sie hängt von den eingegebenen Bandbreiten ab. +

+
+ ); +} diff --git a/src/lib/montecarlo.test.ts b/src/lib/montecarlo.test.ts index 75a1fe4..aa69056 100644 --- a/src/lib/montecarlo.test.ts +++ b/src/lib/montecarlo.test.ts @@ -1,6 +1,15 @@ import { describe, it, expect } from "vitest"; import { computePlan } from "@/lib/calculations"; -import { runMonteCarlo, defaultVolatilityLevel, RETURN_VOLATILITY_LEVELS, type MonteCarloParams } from "@/lib/montecarlo"; +import { + buildElementGroups, + defaultVolatilityLevel, + paramsForScenario, + resolveRootElementId, + runMonteCarlo, + runMonteCarloMulti, + RETURN_VOLATILITY_LEVELS, + type MonteCarloParams, +} from "@/lib/montecarlo"; import type { PlanInput } from "@/lib/types"; // Plan: 40-jaehrig, 1 Phase 10 Jahre, ein Sonstiges Vermoegen 100'000 @ 5 %, 2 % Inflation. @@ -105,3 +114,132 @@ describe("Monte Carlo", () => { expect(RETURN_VOLATILITY_LEVELS.sehr_hoch).toBe(55); }); }); + +// --- Mehrere Szenarien im selben Lauf ---------------------------------------------------- + +// Kopie des Basisplans mit neuen Ids; das Element verweist per sourceElementId auf sein +// Gegenstueck -- genau wie es die Kopier-Route beim Anlegen eines Szenarios setzt. +function copyPlan(source: PlanInput, suffix: string, expectedReturn: number): PlanInput { + return { + ...source, + id: `${source.id}-${suffix}`, + elements: source.elements.map((e) => ({ + ...e, + id: `${e.id}-${suffix}`, + sourceElementId: e.id, + phaseValues: { p1: { ...e.phaseValues.p1, expectedReturn } }, + })), + }; +} + +describe("Monte Carlo: mehrere Szenarien", () => { + it("loest die Herkunfts-Kette bis zum Ursprung auf", () => { + const chain = new Map([ + ["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); + }); +}); diff --git a/src/lib/montecarlo.ts b/src/lib/montecarlo.ts index 022817c..7b99ee8 100644 --- a/src/lib/montecarlo.ts +++ b/src/lib/montecarlo.ts @@ -79,6 +79,149 @@ export interface MonteCarloResult { bands: { age: number; p10: number; p50: number; p90: number }[]; } +// --- Mehrere Szenarien im selben Lauf vergleichen ------------------------------------- +// +// Ein "logisches" Element ueber Szenariogrenzen hinweg: Beim Kopieren eines Szenarios +// erhaelt jedes Element eine NEUE Id plus einen Verweis auf sein Gegenstueck in der Vorlage +// (sourceElementId -- dieselbe Kette, auf der auch der Diff beruht). Ueber diese Kette wird +// dieselbe Anlage in mehreren Szenarien wiedergefunden. Das ist die Voraussetzung dafuer, +// die historischen Annahmen nur EINMAL zu erfassen: Ein Vergleich ist nur dann aussagekraeftig, +// wenn alle Szenarien mit denselben Marktannahmen gewuerfelt werden -- sonst vergleicht man +// die Eingaben statt der Szenarien. +export interface ElementGroup { + rootId: string; // Id des Ursprungs-Elements (Anker der Parametereingabe) + name: string; + category: ElementCategory; + memberIds: string[]; // Element-Ids ueber alle ausgewaehlten Szenarien + scenarioIds: string[]; // Szenarien, in denen dieses Element vorkommt +} + +// Folgt sourceElementId bis zum Ursprung. Bricht ab, sobald der Verweis ins Leere zeigt +// (Vorlage geloescht oder Szenario nicht geladen) -- dann ist dieses Element selbst die +// Wurzel und bildet eine eigene Gruppe. Der Zyklusschutz ist reine Vorsicht: die Verweise +// sind lose (kein FK), eine defekte Kette darf nicht zur Endlosschleife fuehren. +export function resolveRootElementId(elementId: string, sourceById: Map): string { + let current = elementId; + const seen = new Set(); + 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(); + const nameById = new Map(); + 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(); + 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 +): Record { + const rootByMember = new Map(); + for (const g of groups) for (const id of g.memberIds) rootByMember.set(id, g.rootId); + + const out: Record = {}; + 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; + }, + onProgress?: (scenarioIndex: number, scenarioCount: number, fraction: number) => void +): Promise { + const results: ScenarioMcResult[] = []; + for (let i = 0; i < scenarios.length; i++) { + const s = scenarios[i]; + const result = await runMonteCarlo( + s.plan, + { + runs: common.runs, + inflationMean: common.inflationMean, + inflationSigma: common.inflationSigma, + elements: common.elementsFor(s.plan), + target: s.target, + seed: common.seed, + }, + (done, total) => onProgress?.(i, scenarios.length, done / total) + ); + results.push({ ...result, scenarioId: s.scenarioId, name: s.name, target: s.target }); + } + return results; +} + // --- Zufallszahlen (seedbar, damit ein Lauf reproduzierbar ist) --- function mulberry32(seed: number): () => number { diff --git a/src/lib/sensitivity.test.ts b/src/lib/sensitivity.test.ts new file mode 100644 index 0000000..adc45f1 --- /dev/null +++ b/src/lib/sensitivity.test.ts @@ -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)); + }); +}); diff --git a/src/lib/sensitivity.ts b/src/lib/sensitivity.ts new file mode 100644 index 0000000..2f866f2 --- /dev/null +++ b/src/lib/sensitivity.ts @@ -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 = { + 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 = {}; + 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 }; +}