Live-Simulation: Was-waere-wenn-Regler (Roadmap 22)
Deploy App / deploy (push) Successful in 54s

Zweispalter: links Regler, rechts waehlbare Grafik, oben Kennzahlen mit
Differenz zum unveraenderten Plan. Keine eigene Rechenlogik -- die Regler
nutzen dieselben Transformationen wie der Tornado.

Neu: applyElementDriver / tunableElements fuer einzeln regelbare
Element-Renditen; livesim.ts; AllocationChart aus dem Dashboard geloest.

computePlan misst 0.2 ms auf 60 Jahren -> synchron, ohne Debounce.
Spezifikation 0.18 (4.15 und 9.27 neu), 15 Tests (124 -> 139).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 21:22:49 +02:00
parent 50e98122cf
commit d04e07fdfb
9 changed files with 1009 additions and 62 deletions
+118 -3
View File
@@ -4,7 +4,7 @@
| | | | | |
|---|---| |---|---|
| **Dokument** | Funktionale und Technische Spezifikation FPT | | **Dokument** | Funktionale und Technische Spezifikation FPT |
| **Version** | 0.17 | | **Version** | 0.18 |
| **Datum** | 2026-07-18 | | **Datum** | 2026-07-18 |
| **Status** | Lebendes Dokument | | **Status** | Lebendes Dokument |
| **Codestand** | Arbeitsstand nach `1d046e9` inkl. zwei Monte-Carlo-Fragestellungen (Branch `main`) | | **Codestand** | Arbeitsstand nach `1d046e9` inkl. zwei Monte-Carlo-Fragestellungen (Branch `main`) |
@@ -17,6 +17,7 @@
| Version | Datum | Autor | Änderung | | Version | Datum | Autor | Änderung |
|---|---|---|---| |---|---|---|---|
| 0.18 | 2026-07-19 | Claude (Opus 4.8) | **Live-Simulation** (Roadmap Nr. 22). Neuer Button und Dialog als Zweispalter: links Schieberegler, rechts eine wählbare Grafik, darüber eine Kennzahlenleiste. Dreht man an einem Regler, wird der Plan **sofort** neu gerechnet ohne für jede Variante eine Szenario-Kopie anzulegen. **Keine eigene Rechenlogik:** Die Regler benutzen dieselben Transformationen wie der Tornado (`applyDriver`), können also gar nicht etwas anderes zeigen als die Einflussfaktoren-Analyse. Neu ist nur `applyElementDriver` dieselbe Verschiebung auf ein **einzelnes** Element statt auf eine ganze Kategorie: Standardmässig gibt es einen Sammelregler «Rendite», ein Klick auf «Renditen einzeln aufschlüsseln» ersetzt ihn durch je einen Regler pro Anlage (der Sammelregler wird dabei **entfernt**, nicht ergänzt, sonst zählte eine Bewegung doppelt; ein Test sichert ab, dass beide Ansichten denselben Plan beschreiben). Der unveränderte Plan wird als **Referenzlinie** mitgezeichnet, und die Kennzahlenleiste weist Endvermögen nominal/real **mit Differenz zum Plan** aus sowie als eigene Karte ob das Kapital reicht; ein gekippter Plan ist einer Verlaufslinie sonst nicht anzusehen. Gemessene Laufzeit von `computePlan`: **0.2 ms** auf einem 60-Jahres-Plan mit 10 Elementen, also rund 1 % des 16-ms-Frame-Budgets deshalb wird synchron gerechnet, **ohne Debounce und ohne Worker**. Anders als der Tornado haben die Regler **Standardbereiche** (Begründung des scheinbaren Widerspruchs zu 9.18: neues Kapitel 9.27), beide Enden editierbar. **Das Pensionsalter fehlt weiterhin** (9.18, eigener Roadmap-Punkt); «Als Szenario speichern» ist bewusst zurückgestellt, ersatzweise zeigt der Dialog die aktive Einstellung als lesbare Zeile. Die Vermögensaufteilung wurde als `AllocationChart` aus dem Dashboard herausgelöst, damit beide sie nutzen. Neue Kapitel 4.15 und 9.27; 15 Tests ergänzt (124 → 139). Keine API-, DB- oder Schreib-Änderung das Feature liest ausschliesslich. Nebenbei dieselbe vom Umlaut-Sweep verstümmelte Hex-Farbe (`#7c3aed`) wie in 0.17, diesmal im Dashboard. |
| 0.17 | 2026-07-19 | Claude (Opus 4.8) | **Monte-Carlo: zwei Welten, vier Fälle.** Behebt einen Darstellungs-Widerspruch: Zuvor konnte «Planung 69 % erreicht» neben «Ziel 3 Mio nur 41 %» stehen, obwohl 3 Mio unter dem Plan-Endbetrag von 3.7 Mio lag die beiden Zahlen stammten aus **verschiedenen simulierten Welten**. Neu läuft die Simulation **immer zweimal** (historische Renditen / geplante Werte, gemeinsamer Seed) und liest aus **jeder** Verteilung **beide** Schwellen ab: Plan-Endbetrag und Zielbetrag. Fall 1 und Fall 3 stammen damit aus derselben Verteilung, wodurch ein tieferes Ziel **nie** unwahrscheinlicher sein kann als ein höheres der Widerspruch ist strukturell ausgeschlossen (Test). Zweite Korrektur: Der Nullpunkt für das Urteil ist **nicht 50 %**, sondern **Fall 2** (derselbe Schwellwert in der eigenen geplanten Welt); durch den Volatilitäts-Drag liegt der je nach Streuung bei 2748 %. Verglichen wird Fall 1 gegen Fall 2 mit ± 5 pp Toleranzband → «zurückhaltend / realistisch / zu optimistisch». Darstellung: Fall 1 prominent mit Urteil, Fall 3+4 als Satzpaar untergeordnet, Fall 2 und die Mediane klein als Referenz. Der Drei-Wege-Umschalter aus 0.16 entfällt; historische Mittelwerte **und** Zielbetrag sind jetzt beide Pflicht. Technisch: `MonteCarloResult.finalWealthSorted` (alle Endvermögen sortiert) plus neuer Helfer `probabilityAtLeast` (Binärsuche) vier Zahlen aus zwei Läufen statt vier Läufen. Kapitel 4.12.7 und 9.26 neu gefasst; 3 Tests ergänzt (121 → 124). Keine Änderung am Rechenkern. | | 0.17 | 2026-07-19 | Claude (Opus 4.8) | **Monte-Carlo: zwei Welten, vier Fälle.** Behebt einen Darstellungs-Widerspruch: Zuvor konnte «Planung 69 % erreicht» neben «Ziel 3 Mio nur 41 %» stehen, obwohl 3 Mio unter dem Plan-Endbetrag von 3.7 Mio lag die beiden Zahlen stammten aus **verschiedenen simulierten Welten**. Neu läuft die Simulation **immer zweimal** (historische Renditen / geplante Werte, gemeinsamer Seed) und liest aus **jeder** Verteilung **beide** Schwellen ab: Plan-Endbetrag und Zielbetrag. Fall 1 und Fall 3 stammen damit aus derselben Verteilung, wodurch ein tieferes Ziel **nie** unwahrscheinlicher sein kann als ein höheres der Widerspruch ist strukturell ausgeschlossen (Test). Zweite Korrektur: Der Nullpunkt für das Urteil ist **nicht 50 %**, sondern **Fall 2** (derselbe Schwellwert in der eigenen geplanten Welt); durch den Volatilitäts-Drag liegt der je nach Streuung bei 2748 %. Verglichen wird Fall 1 gegen Fall 2 mit ± 5 pp Toleranzband → «zurückhaltend / realistisch / zu optimistisch». Darstellung: Fall 1 prominent mit Urteil, Fall 3+4 als Satzpaar untergeordnet, Fall 2 und die Mediane klein als Referenz. Der Drei-Wege-Umschalter aus 0.16 entfällt; historische Mittelwerte **und** Zielbetrag sind jetzt beide Pflicht. Technisch: `MonteCarloResult.finalWealthSorted` (alle Endvermögen sortiert) plus neuer Helfer `probabilityAtLeast` (Binärsuche) vier Zahlen aus zwei Läufen statt vier Läufen. Kapitel 4.12.7 und 9.26 neu gefasst; 3 Tests ergänzt (121 → 124). Keine Änderung am Rechenkern. |
| 0.16 | 2026-07-19 | Claude (Opus 4.8) | **Monte-Carlo mit zwei Fragestellungen** (Roadmap Nr. 46). Ein Umschalter oben trennt: **«Planung prüfen»** (Fall 1, wie bisher) würfelt um die **historischen** Renditen und prüft gegen den **Planungs-Endbetrag** (read-only) «wie realistisch ist meine Planung?». **«Ziel prüfen»** (Fall 2, neu) würfelt um die **geplanten** Werte aus dem Plan und prüft gegen einen **manuellen Zielbetrag** «erreiche ich mein Ziel?»; hier sind keine historischen Mittelwerte nötig, nur die Streuung. **«Beides»** rechnet beide Durchgänge gleichzeitig (gemeinsamer Seed). Neue Ergebnis-**Deutungstexte** je Fall (weich formuliert wegen des Volatilitäts-Drags). Der Fächer stammt aus dem historischen Durchgang; der Zielbetrag ist in Fall 2 **einer für alle** Szenarien. `runMonteCarloMulti` nimmt neu die Inflation **je Szenario** (`inflationMeanFor`); neuer Helfer `plannedReturnOf`. Kapitel 4.12 überarbeitet, 4.12.7 und 9.26 neu; 8 Tests ergänzt (119 → 121). Nebenbei eine vom Umlaut-Sweep verstümmelte Hex-Farbe (`#7c3aed`) korrigiert. Keine Änderung am Rechenkern. | | 0.16 | 2026-07-19 | Claude (Opus 4.8) | **Monte-Carlo mit zwei Fragestellungen** (Roadmap Nr. 46). Ein Umschalter oben trennt: **«Planung prüfen»** (Fall 1, wie bisher) würfelt um die **historischen** Renditen und prüft gegen den **Planungs-Endbetrag** (read-only) «wie realistisch ist meine Planung?». **«Ziel prüfen»** (Fall 2, neu) würfelt um die **geplanten** Werte aus dem Plan und prüft gegen einen **manuellen Zielbetrag** «erreiche ich mein Ziel?»; hier sind keine historischen Mittelwerte nötig, nur die Streuung. **«Beides»** rechnet beide Durchgänge gleichzeitig (gemeinsamer Seed). Neue Ergebnis-**Deutungstexte** je Fall (weich formuliert wegen des Volatilitäts-Drags). Der Fächer stammt aus dem historischen Durchgang; der Zielbetrag ist in Fall 2 **einer für alle** Szenarien. `runMonteCarloMulti` nimmt neu die Inflation **je Szenario** (`inflationMeanFor`); neuer Helfer `plannedReturnOf`. Kapitel 4.12 überarbeitet, 4.12.7 und 9.26 neu; 8 Tests ergänzt (119 → 121). Nebenbei eine vom Umlaut-Sweep verstümmelte Hex-Farbe (`#7c3aed`) korrigiert. Keine Änderung am Rechenkern. |
| 0.15 | 2026-07-19 | Claude (Opus 4.8) | **Plan-Assistent überarbeitet** (Schritt 2 und 4). Rein an der Oberfläche, keine Änderung an Berechnung, Datenmodell oder API. **(Schritt 2 Lebensphasen):** Die Lebenslinie zerfällt neu an den **fixen Pensionierungszeitpunkten** in Abschnitte (neues reines Modul `phaseplan.ts`, `planSegments`): Erwerb (alle arbeiten), Misch (eine pensioniert, eine arbeitet), Pension (alle pensioniert) jeweils mit **kurzer Definition**. In den durch eine Pensionierung **fest begrenzten** Abschnitten verteilt der Nutzer beliebig viele Phasen mit **+/Papierkorb** und **eigenem Namen je Phase**; eine Live-Summe erzwingt, dass die Phasendauern exakt aufgehen («Weiter» ist bis dahin gesperrt). Der letzte Pensions-Abschnitt ist **offen** (Lebensdauer frei). Die Anzahl Abschnitte wird **abgeleitet** Einzelplan: 2 (Erwerb, Pension); Paar mit unterschiedlichem Pensionsalter: 3. Neue **Zeitachse** mit Pensionierungs-Flaggen und nummerierter Beschriftung **unter** dem Balken (auch kurze Phasen bleiben lesbar). Behebt den Fehler, dass die Erwerbsphase zuvor beliebig über die Pensionierung hinaus gesetzt werden konnte. **(Schritt 4 Vorsorge & Vermögen):** bei Paaren aufgeteilt in **Gemeinsam / Person A / Person B**; PK und 3a sind je Person, Wertschriften/Wohneigentum/Schulden je Bereich (gemeinsam oder pro Person). Neue Kapitel 3.2.8 überarbeitet; 8 Tests ergänzt (111 → 119). | | 0.15 | 2026-07-19 | Claude (Opus 4.8) | **Plan-Assistent überarbeitet** (Schritt 2 und 4). Rein an der Oberfläche, keine Änderung an Berechnung, Datenmodell oder API. **(Schritt 2 Lebensphasen):** Die Lebenslinie zerfällt neu an den **fixen Pensionierungszeitpunkten** in Abschnitte (neues reines Modul `phaseplan.ts`, `planSegments`): Erwerb (alle arbeiten), Misch (eine pensioniert, eine arbeitet), Pension (alle pensioniert) jeweils mit **kurzer Definition**. In den durch eine Pensionierung **fest begrenzten** Abschnitten verteilt der Nutzer beliebig viele Phasen mit **+/Papierkorb** und **eigenem Namen je Phase**; eine Live-Summe erzwingt, dass die Phasendauern exakt aufgehen («Weiter» ist bis dahin gesperrt). Der letzte Pensions-Abschnitt ist **offen** (Lebensdauer frei). Die Anzahl Abschnitte wird **abgeleitet** Einzelplan: 2 (Erwerb, Pension); Paar mit unterschiedlichem Pensionsalter: 3. Neue **Zeitachse** mit Pensionierungs-Flaggen und nummerierter Beschriftung **unter** dem Balken (auch kurze Phasen bleiben lesbar). Behebt den Fehler, dass die Erwerbsphase zuvor beliebig über die Pensionierung hinaus gesetzt werden konnte. **(Schritt 4 Vorsorge & Vermögen):** bei Paaren aufgeteilt in **Gemeinsam / Person A / Person B**; PK und 3a sind je Person, Wertschriften/Wohneigentum/Schulden je Bereich (gemeinsam oder pro Person). Neue Kapitel 3.2.8 überarbeitet; 8 Tests ergänzt (111 → 119). |
@@ -2289,6 +2290,90 @@ Serverergebnis.
Referenz: `src/lib/calculations.ts`, `src/components/DetailView.tsx`. Referenz: `src/lib/calculations.ts`, `src/components/DetailView.tsx`.
## 4.15 Live-Simulation (Was-wäre-wenn-Regler)
Roadmap Nr. 22. Beantwortet weder «welche Annahme entscheidet» (das ist der Tornado,
[4.13](#413-sensitivitätsanalyse-tornado)) noch «wie wahrscheinlich ist das» (das ist
Monte-Carlo, [4.12](#412-monte-carlo-simulation)), sondern schlicht: **«Wie sieht mein Plan aus,
wenn ich hier drehe?»** sofort, und ohne für jede Variante eine Szenario-Kopie anzulegen.
Eigener Button **«Live-Simulation»** in der Szenario-Leiste, Dialog als Zweispalter: links die
Regler, rechts die Grafik, darüber eine Kennzahlenleiste.
### 4.15.1 Keine eigene Rechenlogik
Die Regler benutzen **dieselben Transformationen wie der Tornado** (`applyDriver`). Damit kann
die Live-Simulation gar nicht etwas anderes zeigen als die Einflussfaktoren-Analyse beide
bewegen den Plan identisch. Es entsteht kein zweiter, potenziell abweichender Rechenweg.
Neu hinzu kommt nur `applyElementDriver(plan, elementId, deltaPp)`: dieselbe Verschiebung, aber
auf **ein einzelnes** Element statt auf eine ganze Kategorie (bei Immobilien auf `valueGrowth`
statt `expectedReturn`). Element-IDs sind innerhalb eines Szenarios eindeutig; die
Herkunfts-Verkettung `sourceElementId` aus der Monte-Carlo-Simulation braucht es hier **nicht**,
weil die Live-Simulation immer nur auf **einem** Szenario läuft.
### 4.15.2 Sammelregler und Aufschlüsselung
Standardmässig gibt es **einen** Rendite-Regler für alle Anlagen das hält das Panel ruhig und
entspricht dem Tornado. Ein Klick auf **«Renditen einzeln aufschlüsseln»** ersetzt ihn durch je
einen Regler pro renditetragendem Element (PK, 3a, Sonstiges Vermögen, Immobilie). Erst dann
lässt sich die eigentliche Spielfrage stellen: *Was, wenn mein ETF schlechter läuft, die PK aber
wie geplant?*
Der Sammelregler wird beim Aufklappen **entfernt**, nicht bloss ergänzt sonst würde eine
Bewegung doppelt zählen. Aus demselben Grund werden die Rendite-Regler beim Umschalten
zurückgesetzt. Ein Test sichert ab, dass beide Ansichten denselben Plan beschreiben: Alle
Elemente einzeln um +1 pp zu heben ergibt exakt dasselbe Endvermögen wie der Sammelregler auf
+1 pp.
**Bewusst nicht aufschlüsselbar sind Ausgaben und Einkommen.** «Alle Ausgaben ±20 %» ist die
Frage, die man tatsächlich stellt; Ausgaben-Elemente sind typischerweise viele kleine Posten,
deren Einzelregler das Panel fluten würden, ohne eine bessere Frage zu ermöglichen.
**Das Pensionsalter fehlt weiterhin** aus demselben Grund wie beim Tornado
([9.18](#918-tornado-was-der-chart-nicht-leistet)): Es liesse sich nicht verschieben,
ohne die Phasengrenzen mitzuziehen. Ersatzweise gibt es **Lebensdauer** (letzte Phase
verlängern/verkürzen), was das Langlebigkeitsrisiko abdeckt, nicht aber die Frühpensionierung.
### 4.15.3 Referenz und Kennzahlen
Eine wandernde Linie ohne Anker ist wertlos «ist 2.9 Mio jetzt viel oder wenig?». Deshalb:
- Der **unveränderte Plan** wird im Vermögensverlauf als blasse Referenzlinie mitgezeichnet.
- Darüber steht eine **Kennzahlenleiste** mit Endvermögen nominal und real, jeweils mit der
Differenz zum Plan (`3'660'683 → 2'880'100, 780'583`).
- Eine dritte Karte meldet, ob das **Kapital reicht** oder in welchem Alter es aufgebraucht ist.
Das ist die wichtigste Einzelinformation und einer Verlaufslinie nicht zuverlässig anzusehen:
Ein Plan kann optisch plausibel aussehen und trotzdem zwischendurch unter null fallen.
Die Grafik zeigt **wann** sich etwas ändert, die Leiste **wie viel**.
Rechts stehen drei Grafiken zur Wahl: **Vermögensverlauf** (mit Referenzlinie),
**Vermögensaufteilung** je Phase und **Einkommen vs. Ausgaben**. Die beiden letzteren zeigen nur
den simulierten Stand ein zweiter gestapelter Balkensatz wäre nicht mehr lesbar; darauf weist
der Dialog hin.
### 4.15.4 Laufzeit: synchron, ohne Debounce
Gemessen an einem Plan über 60 Jahre mit 10 Elementen braucht `computePlan` rund **0.2 ms**.
Bei 60 fps stehen 16 ms je Bild zur Verfügung die Rechnung kostet also etwa **1 %** des
Budgets. Deshalb wird bei **jeder** Reglerbewegung synchron neu gerechnet: kein Debounce, kein
Web Worker, keine Ladeanzeige. Der Engpass ist das Neuzeichnen der Grafik, nicht die Mathematik.
### 4.15.5 Nichts wird gespeichert
Die Live-Simulation **schreibt nicht** keine API, keine Datenbank, kein Schreibpfad. Genau das
ist der Punkt der Roadmap-Anforderung («ohne für jede Variante eine Szenario-Kopie anzulegen»).
Ein **«Als neues Szenario speichern»** ist bewusst **noch nicht** umgesetzt: Reglerwerte in echte
Element- und Phasenwerte zurückzuschreiben hiesse viele einzelne Schreibvorgänge und einen neuen
Schreibpfad eine eigene Ausbaustufe. Als Behelf zeigt der Dialog die **aktive Einstellung** als
lesbare Zeile («Rendite 1.5 pp · Ausgaben +10 % · Lebensdauer +5 J.»), die sich von Hand in ein
echtes Szenario übertragen lässt.
Referenz: `src/lib/livesim.ts`, `src/lib/sensitivity.ts` (`applyElementDriver`,
`tunableElements`), `src/components/LiveSimDialog.tsx`, `src/components/AllocationChart.tsx`.
--- ---
# 5. Technische Spezifikation # 5. Technische Spezifikation
@@ -2361,7 +2446,8 @@ PlanComputed ← an den Client geliefert
| `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), Szenario-Vergleich und Element-Gruppierung über die Herkunfts-Kette. 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. | | `sensitivity.ts` | Sensitivitätsanalyse / Tornado: Treiber-Katalog, Parameter-Transformationen, `computeTornado`; zusätzlich `applyElementDriver` / `tunableElements` für die einzeln regelbaren Element-Renditen. Rein, läuft im Browser. |
| `livesim.ts` | Live-Simulation: Reglerkatalog mit Standardbereichen, Anwenden mehrerer Regler, Kennzahlen (Kap. 4.15). Rein, läuft im Browser. |
| `distribution.ts` | Kapitaltopf und Quoten-Zerlegung, Anwenden von Entwurfswerten für die Verteil-Werkzeuge (Kap. 3.6.9/3.6.10). Rein. | | `distribution.ts` | Kapitaltopf und Quoten-Zerlegung, Anwenden von Entwurfswerten für die Verteil-Werkzeuge (Kap. 3.6.9/3.6.10). Rein. |
| `phaseplan.ts` | Ableitung der Lebensabschnitte (Erwerb/Misch/Pension) aus den fixen Pensionierungszeitpunkten für den Assistenten (Kap. 3.2.8). Rein. | | `phaseplan.ts` | Ableitung der Lebensabschnitte (Erwerb/Misch/Pension) aus den fixen Pensionierungszeitpunkten für den Assistenten (Kap. 3.2.8). Rein. |
| `constants.ts` (erweitert) | zusätzlich `SYSTEM_PARAMETERS`: dieselben Werte maschinenlesbar mit Bedeutung, Herleitung, Quelle und Stand Grundlage der Systemparameter-Ansicht | | `constants.ts` (erweitert) | zusätzlich `SYSTEM_PARAMETERS`: dieselben Werte maschinenlesbar mit Bedeutung, Herleitung, Quelle und Stand Grundlage der Systemparameter-Ansicht |
@@ -2619,6 +2705,8 @@ wird der Plan neu geladen; die Berechnung kommt immer vom Server.
| `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` | ~600 | Monte-Carlo-Dialog: Erklärung, Szenario-Auswahl, Eingaben, Lauf, Vergleichstabelle + Fächer | | `MonteCarloDialog` | ~600 | Monte-Carlo-Dialog: Erklärung, Szenario-Auswahl, Eingaben, Lauf, Vergleichstabelle + Fächer |
| `LiveSimDialog` | ~330 | Live-Simulation: Regler links, Grafikwahl rechts, Kennzahlenleiste mit Differenz zum Plan ([4.15](#415-live-simulation-was-wäre-wenn-regler)) |
| `AllocationChart` | ~80 | Gestapelte Vermögensaufteilung je Phase; aus dem Dashboard herausgelöst, damit die Live-Simulation sie mitbenutzen kann |
| `SensitivityDialog` | ~330 | Einflussfaktoren: Erklärung, Treiber-Auswahl mit Bandbreiten, Tornado + Tabelle | | `SensitivityDialog` | ~330 | Einflussfaktoren: Erklärung, Treiber-Auswahl mit Bandbreiten, Tornado + Tabelle |
| `DetailView` | ~470 | Detailansichten Element/Phase, Wasserfall-Darstellung, Rechenweg-Blöcke, plan-weite Rechenwege | | `DetailView` | ~470 | Detailansichten Element/Phase, Wasserfall-Darstellung, Rechenweg-Blöcke, plan-weite Rechenwege |
| `SystemParametersView` | ~90 | Systemparameter mit Wert, Bedeutung, Herleitung, Quelle und Stand | | `SystemParametersView` | ~90 | Systemparameter mit Wert, Bedeutung, Herleitung, Quelle und Stand |
@@ -2861,11 +2949,12 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests
| `explain.test.ts` | 12 | Verlaufswerte je Element, Vollständigkeit beider Wasserfall-Zerlegungen, Rechenweg-Protokoll, Gültigkeit der Spezifikations-Verweise | | `explain.test.ts` | 12 | Verlaufswerte je Element, Vollständigkeit beider Wasserfall-Zerlegungen, Rechenweg-Protokoll, Gültigkeit der Spezifikations-Verweise |
| `montecarlo.test.ts` | 18 | Determinismus, Volatilität/Vol-Drag, Böden, Reproduzierbarkeit; Element-Gruppierung, Szenario-Vergleich, Inflation je Szenario, plannedReturnOf; Schwellen-Ablesung (probabilityAtLeast), Monotonie über Schwellen, Fall 2 systematisch unter 50 % | | `montecarlo.test.ts` | 18 | Determinismus, Volatilität/Vol-Drag, Böden, Reproduzierbarkeit; Element-Gruppierung, Szenario-Vergleich, Inflation je Szenario, plannedReturnOf; Schwellen-Ablesung (probabilityAtLeast), Monotonie über Schwellen, Fall 2 systematisch unter 50 % |
| `distribution.test.ts` | 8 | Kapitaltopf und Quoten-Zerlegung gegen die Cash-Brücke; Entwurfswerte anwenden ohne Verlust bestehender Felder | | `distribution.test.ts` | 8 | Kapitaltopf und Quoten-Zerlegung gegen die Cash-Brücke; Entwurfswerte anwenden ohne Verlust bestehender Felder |
| `livesim.test.ts` | 15 | Element-Regler bewegt genau ein Element; Aufschlüsselung deckt sich mit dem Sammelregler; neutrale Stellung verändert den Plan nicht; Ruinmeldung |
| `phaseplan.test.ts` | 8 | Ableitung der Lebensabschnitte aus den Pensionierungszeitpunkten (Einzel/Paar/bereits pensioniert); letzter Teil immer offen | | `phaseplan.test.ts` | 8 | Ableitung der Lebensabschnitte aus den Pensionierungszeitpunkten (Einzel/Paar/bereits pensioniert); letzter Teil immer offen |
| `bridges.test.ts` | 10 | Vermögens- und Cash-Brücke gehen über sieben Plankonstellationen ohne Restgrösse auf; Umbuchungen bleiben aus der Vermögensbrücke heraus | | `bridges.test.ts` | 10 | Vermögens- und Cash-Brücke gehen über sieben Plankonstellationen ohne Restgrösse auf; Umbuchungen bleiben aus der Vermögensbrücke heraus |
| `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario | | `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario |
| `migrations.test.ts` | 1 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein | | `migrations.test.ts` | 1 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein |
| **Total** | **124** | | | **Total** | **139** | |
## 8.2 Testfälle ## 8.2 Testfälle
@@ -3301,6 +3390,32 @@ dass die Simulation Risiko **um deine Annahmen** misst und nicht deren Richtigke
([9.15](#915-monte-carlo-misst-risiko-um-die-annahmen-nicht-deren-richtigkeit)), bleibt bestehen. ([9.15](#915-monte-carlo-misst-risiko-um-die-annahmen-nicht-deren-richtigkeit)), bleibt bestehen.
## 9.27 Warum die Regler Standardbereiche haben und der Tornado nicht
Zwei Kapitel dieser Spezifikation scheinen sich zu widersprechen:
[9.18](#918-tornado-was-der-chart-nicht-leistet) begründet, warum die
Sensitivitätsanalyse **bewusst keine** Default-Bandbreiten anbietet, während die Live-Simulation
([4.15](#415-live-simulation-was-wäre-wenn-regler)) für jeden Regler einen vorbelegten Bereich
mitbringt. Das ist kein Versehen.
**Beim Tornado bestimmt die Bandbreite das Ergebnis.** Die Balkenlänge ist die Spannweite
zwischen dem tiefen und dem hohen Wert wer «Rendite ±3 pp» gegen «Ausgaben ±5 %» stellt,
erzeugt eine Rangfolge, die er selbst vorgegeben hat. Ein Default wäre dort eine **frei erfundene
Aussage**: Das Werkzeug würde behaupten, ein Treiber sei wichtiger als ein anderer, obwohl der
Unterschied nur aus den voreingestellten Bereichen stammt. Deshalb ist die Bandbreite dort
Pflichteingabe ohne Vorschlag.
**Ein Regler vergleicht nichts.** Er zeigt genau einen Zustand: «bei dieser Rendite kommt dieses
Endvermögen heraus». Der Bereich bestimmt nur, wie weit sich der Schieber bewegen lässt er
verändert das angezeigte Ergebnis an keiner Stelle. Ein Standardbereich erfindet hier also keine
Aussage; er macht den Regler überhaupt erst bedienbar, denn ohne Ober- und Untergrenze gibt es
keinen Schieber.
Die Bereiche sind trotzdem **an beiden Enden editierbar** (Häkchen «Bereiche anpassen»), und
neben jedem Regler steht sein **Neutralpunkt** der Wert, bei dem der Plan unverändert bleibt.
Bei allen Verschiebungs-Treibern ist das 0, bei der Inflation der Planwert selbst, weil sie als
einzige absolut und nicht als Differenz eingegeben wird.
--- ---
# 10. Glossar # 10. Glossar
+77
View File
@@ -0,0 +1,77 @@
"use client";
import { useMemo } from "react";
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { formatChf } from "@/lib/format";
import type { PlanComputed } from "@/lib/calculations";
export const CHART_PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"];
const ASSET_CATS = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"];
// Gestapelte Vermögensaufteilung je Phase (Beginn und Ende). Eigene Komponente, weil sie
// sowohl im Grafiken-Dialog als auch in der Live-Simulation gebraucht wird.
export function AllocationChart({ computed, height = 288 }: { computed: PlanComputed; height?: number }) {
// Asset-Elemente (nach id, damit gleiche Namen nicht kollidieren), die irgendwann einen
// positiven Wert haben -- in Reihenfolge ihres ersten Auftretens.
const assetEls = useMemo(() => {
const info = new Map<string, { name: string; any: boolean }>();
for (const phase of computed.phases) {
for (const el of phase.elements) {
if (!ASSET_CATS.includes(el.category)) continue;
const cur = info.get(el.elementId) ?? { name: el.name, any: false };
cur.name = el.name;
if (el.startValue > 0 || el.endValue > 0) cur.any = true;
info.set(el.elementId, cur);
}
}
return [...info.entries()].filter(([, v]) => v.any).map(([id, v]) => ({ id, name: v.name }));
}, [computed]);
// Je Phase zwei Kategorien auf der x-Achse: Beginn und Ende.
const barData = useMemo(
() =>
computed.phases.flatMap((phase) => {
const beginn: Record<string, number | string> = { label: `${phase.name} · Beginn` };
const ende: Record<string, number | string> = { label: `${phase.name} · Ende` };
for (const el of phase.elements) {
if (!ASSET_CATS.includes(el.category)) continue;
beginn[el.elementId] = Math.max(0, Math.round(el.startValue));
ende[el.elementId] = Math.max(0, Math.round(el.endValue));
}
return [beginn, ende];
}),
[computed]
);
if (assetEls.length === 0) {
return <p className="text-sm text-muted">Dieser Plan enthält keine Vermögenselemente.</p>;
}
return (
<div className="w-full" style={{ height }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={barData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="label" tick={{ fontSize: 10 }} interval={0} angle={-30} textAnchor="end" height={70} />
<YAxis
tick={{ fontSize: 11 }}
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
/>
<Tooltip formatter={(v) => (typeof v === "number" ? formatChf(v) : v)} />
<Legend wrapperStyle={{ fontSize: 12 }} />
{assetEls.map((el, i) => (
<Bar
key={el.id}
dataKey={el.id}
name={el.name}
stackId="a"
fill={CHART_PALETTE[i % CHART_PALETTE.length]}
isAnimationActive={false}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
);
}
+10
View File
@@ -24,6 +24,7 @@ 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 { SensitivityDialog } from "@/components/SensitivityDialog";
import { LiveSimDialog } from "@/components/LiveSimDialog";
import { SpecView } from "@/components/SpecView"; import { SpecView } from "@/components/SpecView";
import { SystemParametersView } from "@/components/SystemParametersView"; import { SystemParametersView } from "@/components/SystemParametersView";
import { PlanTraceDialog } from "@/components/DetailView"; import { PlanTraceDialog } from "@/components/DetailView";
@@ -85,6 +86,7 @@ function AppShellInner({ username }: { username: string }) {
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 [showSensitivity, setShowSensitivity] = useState(false);
const [showLiveSim, setShowLiveSim] = useState(false);
const [showSystemParams, setShowSystemParams] = useState(false); const [showSystemParams, setShowSystemParams] = useState(false);
const [showPlanTraces, setShowPlanTraces] = useState(false); const [showPlanTraces, setShowPlanTraces] = useState(false);
const [showPalette, setShowPalette] = useState(false); const [showPalette, setShowPalette] = useState(false);
@@ -421,6 +423,10 @@ function AppShellInner({ username }: { username: string }) {
<BarChart3 className="h-4 w-4" /> <BarChart3 className="h-4 w-4" />
Grafiken Grafiken
</Button> </Button>
<Button variant="secondary" onClick={() => setShowLiveSim(true)}>
<SlidersHorizontal className="h-4 w-4" />
Live-Simulation
</Button>
<Button variant="secondary" onClick={() => setShowMonteCarlo(true)}> <Button variant="secondary" onClick={() => setShowMonteCarlo(true)}>
<Dices className="h-4 w-4" /> <Dices className="h-4 w-4" />
Monte-Carlo-Simulation Monte-Carlo-Simulation
@@ -530,6 +536,10 @@ function AppShellInner({ username }: { username: string }) {
<SensitivityDialog plan={detail.plan} onClose={() => setShowSensitivity(false)} /> <SensitivityDialog plan={detail.plan} onClose={() => setShowSensitivity(false)} />
)} )}
{showLiveSim && detail && (
<LiveSimDialog plan={detail.plan} onClose={() => setShowLiveSim(false)} />
)}
{showPlanTraces && detail && ( {showPlanTraces && detail && (
<PlanTraceDialog <PlanTraceDialog
computed={computePlan(detail.plan, undefined, { explain: true })} computed={computePlan(detail.plan, undefined, { explain: true })}
+4 -58
View File
@@ -1,8 +1,8 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { BarChart3, Download, LineChart as LineChartIcon } from "lucide-react"; import { BarChart3, Download, LineChart as LineChartIcon } from "lucide-react";
import { AllocationChart, CHART_PALETTE } from "@/components/AllocationChart";
import { WealthChart, type TimelineSeries } from "@/components/WealthChart"; import { WealthChart, type TimelineSeries } from "@/components/WealthChart";
import { SparquoteChart } from "@/components/SparquoteChart"; import { SparquoteChart } from "@/components/SparquoteChart";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
@@ -10,8 +10,6 @@ import { formatChf } from "@/lib/format";
import type { PlanComputed } from "@/lib/calculations"; import type { PlanComputed } from "@/lib/calculations";
import type { PlanInput } from "@/lib/types"; import type { PlanInput } from "@/lib/types";
const PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3äd"];
interface PlanListItem { interface PlanListItem {
id: string; id: string;
name: string; name: string;
@@ -43,51 +41,15 @@ export function Dashboard({
} }
const series: TimelineSeries[] = useMemo(() => { const series: TimelineSeries[] = useMemo(() => {
const result: TimelineSeries[] = [{ label: plan.name, color: PALETTE[0], computed }]; const result: TimelineSeries[] = [{ label: plan.name, color: CHART_PALETTE[0], computed }];
compareIds.forEach((id, i) => { compareIds.forEach((id, i) => {
const c = compareData[id]; const c = compareData[id];
const name = siblings.find((p) => p.id === id)?.name ?? id; const name = siblings.find((p) => p.id === id)?.name ?? id;
if (c) result.push({ label: name, color: PALETTE[(i + 1) % PALETTE.length], computed: c }); if (c) result.push({ label: name, color: CHART_PALETTE[(i + 1) % CHART_PALETTE.length], computed: c });
}); });
return result; return result;
}, [plan.name, computed, compareIds, compareData, siblings]); }, [plan.name, computed, compareIds, compareData, siblings]);
const ASSET_CATS = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"];
// Asset-Elemente (nach id, damit gleiche Namen nicht kollidieren), die irgendwann einen
// positiven Wert haben -- in Reihenfolge ihres ersten Auftretens.
const assetEls = useMemo(() => {
const info = new Map<string, { name: string; any: boolean }>();
for (const phase of computed.phases) {
for (const el of phase.elements) {
if (!ASSET_CATS.includes(el.category)) continue;
const cur = info.get(el.elementId) ?? { name: el.name, any: false };
cur.name = el.name;
if (el.startValue > 0 || el.endValue > 0) cur.any = true;
info.set(el.elementId, cur);
}
}
return [...info.entries()].filter(([, v]) => v.any).map(([id, v]) => ({ id, name: v.name }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [computed]);
// Je Phase zwei Kategorien auf der x-Achse: Beginn und Ende. Werte je Asset-Element.
const barData = useMemo(
() =>
computed.phases.flatMap((phase) => {
const beginn: Record<string, number | string> = { label: `${phase.name} · Beginn` };
const ende: Record<string, number | string> = { label: `${phase.name} · Ende` };
for (const el of phase.elements) {
if (!ASSET_CATS.includes(el.category)) continue;
beginn[el.elementId] = Math.max(0, Math.round(el.startValue));
ende[el.elementId] = Math.max(0, Math.round(el.endValue));
}
return [beginn, ende];
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[computed]
);
const otherPlans = siblings; const otherPlans = siblings;
const lastPhase = computed.phases[computed.phases.length - 1]; const lastPhase = computed.phases[computed.phases.length - 1];
@@ -151,23 +113,7 @@ export function Dashboard({
Je Phase links die Aufteilung zu Beginn, rechts am Ende. Das Ende einer Phase entspricht im Je Phase links die Aufteilung zu Beginn, rechts am Ende. Das Ende einer Phase entspricht im
Gesamtvolumen dem Beginn der nächsten &ndash; die Aufteilung kann durch Umschichtung abweichen. Gesamtvolumen dem Beginn der nächsten &ndash; die Aufteilung kann durch Umschichtung abweichen.
</p> </p>
<div className="h-72 w-full"> <AllocationChart computed={computed} />
<ResponsiveContainer width="100%" height="100%">
<BarChart data={barData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="label" tick={{ fontSize: 10 }} interval={0} angle={-30} textAnchor="end" height={70} />
<YAxis
tick={{ fontSize: 11 }}
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
/>
<Tooltip formatter={(v) => (typeof v === "number" ? formatChf(v) : v)} />
<Legend wrapperStyle={{ fontSize: 12 }} />
{assetEls.map((el, i) => (
<Bar key={el.id} dataKey={el.id} name={el.name} stackId="a" fill={PALETTE[i % PALETTE.length]} />
))}
</BarChart>
</ResponsiveContainer>
</div>
</section> </section>
</div> </div>
); );
+337
View File
@@ -0,0 +1,337 @@
"use client";
import { useMemo, useState } from "react";
import { ChevronDown, ChevronRight, RotateCcw, SlidersHorizontal, X } from "lucide-react";
import { AllocationChart } from "@/components/AllocationChart";
import { SparquoteChart } from "@/components/SparquoteChart";
import { WealthChart } from "@/components/WealthChart";
import { InfoBubble } from "@/components/InfoBubble";
import { computePlan } from "@/lib/calculations";
import { formatChf } from "@/lib/format";
import {
buildSliders,
describeSettings,
isNeutral,
kpisOf,
runLive,
type SliderDef,
type SliderValues,
} from "@/lib/livesim";
import { UNIT_SUFFIX } from "@/lib/sensitivity";
import type { PlanInput } from "@/lib/types";
type ChartId = "wealth" | "allocation" | "cashflow";
const CHARTS: { id: ChartId; label: string; hint: string }[] = [
{
id: "wealth",
label: "Vermögensverlauf",
hint: "Gesamtvermögen über das Alter. Die blasse Linie ist dein unveränderter Plan.",
},
{
id: "allocation",
label: "Vermögensaufteilung",
hint: "Wie sich das Vermögen zu Beginn und am Ende jeder Phase auf die Anlagen verteilt.",
},
{
id: "cashflow",
label: "Einkommen vs. Ausgaben",
hint: "Die Fläche dazwischen ist die Spar- bzw. Verzehrquote.",
},
];
const BASE_COLOR = "#9ca3af";
const LIVE_COLOR = "#4f46e5";
export function LiveSimDialog({ plan, onClose }: { plan: PlanInput; onClose: () => void }) {
const [expandReturns, setExpandReturns] = useState(false);
const [values, setValues] = useState<SliderValues>({});
// Vom Nutzer überschriebene Reglerbereiche (Schlüssel -> [min, max]).
const [bounds, setBounds] = useState<Record<string, [number, number]>>({});
const [chart, setChart] = useState<ChartId>("wealth");
const [showRanges, setShowRanges] = useState(false);
const sliders = useMemo(() => {
const base = buildSliders(plan, expandReturns);
return base.map((s) => {
const b = bounds[s.key];
return b ? { ...s, min: b[0], max: b[1] } : s;
});
}, [plan, expandReturns, bounds]);
// Referenz: der unveränderte Plan. Wird nur neu gerechnet, wenn sich der Plan ändert.
const base = useMemo(() => {
const computed = computePlan(plan);
return { computed, kpis: kpisOf(computed) };
}, [plan]);
// Der Kern: bei JEDER Reglerbewegung synchron neu rechnen (~0.2 ms, siehe livesim.ts).
const live = useMemo(() => runLive(plan, sliders, values), [plan, sliders, values]);
const neutral = isNeutral(sliders, values);
const settings = describeSettings(sliders, values);
function setValue(key: string, v: number) {
setValues((prev) => ({ ...prev, [key]: v }));
}
function reset() {
setValues({});
}
// Beim Auf-/Zuklappen die Rendite-Regler zurücksetzen: Sammel- und Element-Regler bewegen
// dieselben Werte, ein Übertrag würde doppelt zählen.
function toggleExpand() {
setValues((prev) => {
const next: SliderValues = {};
for (const [k, v] of Object.entries(prev)) {
if (k === "d:returns" || k === "d:propertyGrowth" || k.startsWith("e:")) continue;
next[k] = v;
}
return next;
});
setExpandReturns((v) => !v);
}
return (
<div className="ui-fade 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="ui-pop flex w-full max-w-6xl 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">
<SlidersHorizontal className="h-5 w-5 text-accent" /> Live-Simulation
</h2>
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
<X className="h-4 w-4" />
</button>
</div>
<p className="rounded-xl border border-border bg-surface-2 p-3 text-xs leading-relaxed text-muted">
Dreh an den Reglern und sieh sofort, was passiert. <strong className="text-fg">Nichts davon wird
gespeichert</strong> dein Plan bleibt unverändert, du brauchst für kein Durchspielen eine
Szenario-Kopie. Die Regler benutzen dieselben Umrechnungen wie die Einflussfaktoren-Analyse.
</p>
{/* Kennzahlenleiste: die eigentliche Antwort. Die Grafik zeigt WANN, das hier WIE VIEL. */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<DeltaCard label="Endvermögen (nominal)" base={base.kpis.endNominal} live={live.kpis.endNominal} />
<DeltaCard label="Endvermögen (real)" base={base.kpis.endReal} live={live.kpis.endReal} />
<RuinCard baseAge={base.kpis.ruinAge} liveAge={live.kpis.ruinAge} />
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[320px_1fr]">
{/* --- Links: Regler --- */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
Parameter
<InfoBubble text="Es erscheinen nur Regler, die in diesem Plan überhaupt etwas bewegen. Die Standardbereiche sind Vorschläge du kannst beide Enden anpassen." />
</span>
<button
type="button"
onClick={reset}
disabled={neutral}
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[11px] font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg disabled:opacity-40"
>
<RotateCcw className="h-3 w-3" /> Zurücksetzen
</button>
</div>
<button
type="button"
onClick={toggleExpand}
className="flex items-center gap-1.5 self-start rounded-lg border border-dashed border-border px-2.5 py-1 text-[11px] font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg"
>
{expandReturns ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
{expandReturns ? "Renditen wieder zusammenfassen" : "Renditen einzeln aufschlüsseln"}
</button>
<label className="flex items-center gap-1.5 text-[11px] text-muted">
<input type="checkbox" checked={showRanges} onChange={() => setShowRanges((v) => !v)} />
Bereiche anpassen
</label>
<div className="flex max-h-[46vh] flex-col gap-3 overflow-y-auto pr-1">
{sliders.map((s) => (
<Slider
key={s.key}
def={s}
value={values[s.key] ?? s.neutral}
showRange={showRanges}
onChange={(v) => setValue(s.key, v)}
onBounds={(min, max) => setBounds((prev) => ({ ...prev, [s.key]: [min, max] }))}
/>
))}
{sliders.length === 0 && (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-xs text-muted">
Dieser Plan enthält noch keine Elemente, an denen sich etwas regeln liesse.
</p>
)}
</div>
{settings.length > 0 && (
<div className="rounded-lg border border-border bg-surface-2 p-2.5">
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-faint">
Aktive Einstellung
</div>
<p className="text-[11px] leading-relaxed text-muted">{settings.join(" · ")}</p>
</div>
)}
</div>
{/* --- Rechts: Grafik --- */}
<div className="flex min-w-0 flex-col gap-2">
<div className="inline-flex flex-wrap gap-0.5 self-start rounded-lg border border-border bg-surface-2 p-0.5 text-xs">
{CHARTS.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setChart(c.id)}
className={`rounded-md px-2.5 py-1 font-medium transition-colors ${
chart === c.id ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
}`}
>
{c.label}
</button>
))}
</div>
<p className="text-[11px] text-muted">{CHARTS.find((c) => c.id === chart)!.hint}</p>
<div className="min-w-0 rounded-xl border border-border bg-surface p-3">
{chart === "wealth" && (
<WealthChart
series={[
// Referenz zuerst, damit die Live-Linie darüber liegt.
{ label: "Dein Plan", color: BASE_COLOR, computed: base.computed },
{ label: "Simuliert", color: LIVE_COLOR, computed: live.computed },
]}
/>
)}
{chart === "allocation" && <AllocationChart computed={live.computed} />}
{chart === "cashflow" && <SparquoteChart computed={live.computed} />}
</div>
{chart !== "wealth" && (
<p className="text-[11px] text-faint">
Diese Grafik zeigt nur den simulierten Stand. Die Gegenüberstellung mit deinem
unveränderten Plan liefert der Vermögensverlauf und die Zahlen oben.
</p>
)}
</div>
</div>
</div>
</div>
);
}
// Ein Regler. Die Zahl steht bewusst neben dem Schieber und ist auch direkt eingebbar --
// mit der Maus trifft man 5.2 % nicht zuverlässig.
function Slider({
def,
value,
showRange,
onChange,
onBounds,
}: {
def: SliderDef;
value: number;
showRange: boolean;
onChange: (v: number) => void;
onBounds: (min: number, max: number) => void;
}) {
const suffix = UNIT_SUFFIX[def.unit];
const touched = value !== def.neutral;
return (
<div className={`rounded-lg border p-2.5 ${touched ? "border-accent bg-accent-soft/20" : "border-border bg-surface-2"}`}>
<div className="mb-1.5 flex items-center justify-between gap-2">
<span className="flex min-w-0 items-center text-xs font-medium text-fg">
<span className="truncate">{def.label}</span>
<InfoBubble text={def.help} />
</span>
<span className="flex shrink-0 items-center gap-1">
<input
type="number"
value={value}
step={def.step}
onChange={(e) => onChange(Number(e.target.value))}
className="w-16 rounded border border-border bg-surface px-1 py-0.5 text-right text-xs text-fg"
/>
<span className="text-[10px] text-faint">{suffix}</span>
</span>
</div>
<input
type="range"
min={def.min}
max={def.max}
step={def.step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full accent-[var(--accent)]"
/>
{showRange ? (
<div className="mt-1 flex items-center gap-1.5 text-[10px] text-faint">
<input
type="number"
value={def.min}
step={def.step}
onChange={(e) => onBounds(Number(e.target.value), def.max)}
className="w-14 rounded border border-border bg-surface px-1 py-0.5 text-right text-fg"
/>
<span>bis</span>
<input
type="number"
value={def.max}
step={def.step}
onChange={(e) => onBounds(def.min, Number(e.target.value))}
className="w-14 rounded border border-border bg-surface px-1 py-0.5 text-right text-fg"
/>
<span className="ml-auto">neutral: {def.neutral} {suffix}</span>
</div>
) : (
<div className="mt-0.5 flex justify-between text-[10px] text-faint">
<span>{def.min}</span>
<span>{def.max}</span>
</div>
)}
</div>
);
}
function DeltaCard({ label, base, live }: { label: string; base: number; live: number }) {
const diff = live - base;
const tone = diff === 0 ? "text-fg" : diff > 0 ? "text-success" : "text-danger";
return (
<div className="rounded-xl border border-border bg-surface-2 p-3">
<div className="text-[10px] font-semibold uppercase tracking-wide text-faint">{label}</div>
<div className="mt-0.5 text-lg font-semibold text-fg">{formatChf(live)}</div>
<div className="text-[11px] text-muted">
Plan: {formatChf(base)}
{diff !== 0 && (
<span className={`ml-1.5 font-semibold ${tone}`}>
{diff > 0 ? "+" : ""}
{formatChf(Math.abs(diff))}
</span>
)}
</div>
</div>
);
}
// Ein gekippter Plan ist die wichtigste Einzelinformation -- einer Verlaufslinie sieht man
// nicht zuverlässig an, dass das Kapital zwischendurch unter null gefallen ist.
function RuinCard({ baseAge, liveAge }: { baseAge: number | null; liveAge: number | null }) {
const broken = liveAge !== null;
return (
<div className={`rounded-xl border p-3 ${broken ? "border-danger bg-danger-soft" : "border-border bg-surface-2"}`}>
<div className="text-[10px] font-semibold uppercase tracking-wide text-faint">Kapital reicht</div>
<div className={`mt-0.5 text-lg font-semibold ${broken ? "text-danger" : "text-success"}`}>
{broken ? `aufgebraucht mit ${liveAge}` : "bis Planende"}
</div>
<div className="text-[11px] text-muted">
{baseAge === null ? "Plan: reicht bis Planende" : `Plan: aufgebraucht mit ${baseAge}`}
</div>
</div>
);
}
+1 -1
View File
@@ -47,7 +47,7 @@ const STEPS: TourStep[] = [
{ {
target: "analysen", target: "analysen",
title: "Analysen", title: "Analysen",
text: "Grafiken, Monte-Carlo-Simulation und Einflussfaktoren: Wie sicher ist dein Plan, und welche Annahme entscheidet wirklich?", text: "Grafiken, Live-Simulation, Monte-Carlo und Einflussfaktoren: Was passiert, wenn ich hier drehe wie sicher ist mein Plan und welche Annahme entscheidet wirklich?",
}, },
]; ];
+256
View File
@@ -0,0 +1,256 @@
import { describe, it, expect } from "vitest";
import { computePlan } from "@/lib/calculations";
import { applyElementDriver, tunableElements } from "@/lib/sensitivity";
import {
applySliders,
buildSliders,
describeSettings,
isNeutral,
kpisOf,
runLive,
type SliderValues,
} from "@/lib/livesim";
import type { PlanInput } from "@/lib/types";
// Plan: 40-jährig, zwei Phasen (25 J. Erwerb + 20 J. Pension), Einkommen 120'000 netto,
// Ausgaben 80'000, ZWEI Sonstige Vermögen mit unterschiedlicher Rendite plus eine PK --
// damit lässt sich prüfen, dass der Sammelregler alle bewegt, der Element-Regler nur einen.
function basePlan(): PlanInput {
return {
id: "p",
name: "T",
householdType: "SINGLE",
inflationRateDefault: 1.5,
initialCash: 0,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
phases: [
{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 25, cashTransition: {} },
{ id: "p2", sequenceNumber: 2, name: "Pension", durationYears: 20, cashTransition: {} },
],
elements: [
{
id: "inc",
category: "INCOME",
name: "Lohn",
ownerRole: "HOUSEHOLD",
orderIndex: 1,
phaseValues: { p1: { amount: 120000, teuerungsausgleich: 1 } },
transitionValues: {},
},
{
id: "exp",
category: "EXPENSE",
name: "Lebenshaltung",
ownerRole: "HOUSEHOLD",
orderIndex: 2,
phaseValues: { p1: { amount: 80000 }, p2: { amount: 80000 } },
transitionValues: {},
},
{
id: "etf",
category: "OTHER_ASSET",
name: "ETF",
ownerRole: "HOUSEHOLD",
orderIndex: 3,
phaseValues: { p1: { startValue: 200000, expectedReturn: 5 }, p2: { expectedReturn: 4 } },
transitionValues: {},
},
{
id: "fest",
category: "OTHER_ASSET",
name: "Festgeld",
ownerRole: "HOUSEHOLD",
orderIndex: 4,
phaseValues: { p1: { startValue: 100000, expectedReturn: 1 }, p2: { expectedReturn: 1 } },
transitionValues: {},
},
{
id: "pk",
category: "PENSION_FUND",
name: "Pensionskasse",
ownerRole: "PERSON_A",
orderIndex: 5,
phaseValues: { p1: { startValue: 300000, expectedReturn: 2, contributionRate: 10 } },
transitionValues: {},
},
],
} as unknown as PlanInput;
}
const endOf = (p: PlanInput) => kpisOf(computePlan(p)).endNominal;
describe("applyElementDriver", () => {
it("bewegt genau EIN Element und lässt die übrigen unberührt", () => {
const plan = basePlan();
const out = applyElementDriver(plan, "etf", 2);
const get = (p: PlanInput, id: string, phase: string) =>
p.elements.find((e) => e.id === id)!.phaseValues[phase]?.expectedReturn;
// ETF: in ALLEN Phasen verschoben (5 -> 7, 4 -> 6).
expect(get(out, "etf", "p1")).toBe(7);
expect(get(out, "etf", "p2")).toBe(6);
// Alle anderen unverändert.
expect(get(out, "fest", "p1")).toBe(1);
expect(get(out, "pk", "p1")).toBe(2);
// Und das Original ist unangetastet (Reinheit).
expect(get(plan, "etf", "p1")).toBe(5);
});
it("greift bei Immobilien auf die Wertsteigerung statt auf die Rendite", () => {
const plan = basePlan();
plan.elements.push({
id: "haus",
category: "REAL_ESTATE",
name: "Haus",
ownerRole: "HOUSEHOLD",
orderIndex: 6,
phaseValues: { p1: { startValue: 800000, valueGrowth: 1, purchasePrice: 800000 } },
transitionValues: {},
} as never);
const out = applyElementDriver(plan, "haus", 1.5);
const pd = out.elements.find((e) => e.id === "haus")!.phaseValues.p1;
expect(pd.valueGrowth).toBe(2.5);
expect(pd.expectedReturn).toBeUndefined();
});
it("ignoriert eine unbekannte Element-ID, statt zu werfen", () => {
const plan = basePlan();
expect(applyElementDriver(plan, "gibtsnicht", 5)).toBe(plan);
});
it("listet nur renditetragende Elemente als regelbar auf", () => {
const ids = tunableElements(basePlan()).map((e) => e.id);
expect(ids).toEqual(["etf", "fest", "pk"]);
// Einkommen und Ausgaben tragen keine Rendite -- sie haben ihre eigenen Sammelregler.
expect(ids).not.toContain("inc");
expect(ids).not.toContain("exp");
});
});
describe("buildSliders", () => {
it("zeigt nur Regler, die im Plan überhaupt greifen", () => {
const plan = basePlan(); // keine Immobilie
const keys = buildSliders(plan, false).map((s) => s.key);
expect(keys).toContain("d:returns");
expect(keys).toContain("d:expenses");
expect(keys).not.toContain("d:propertyGrowth");
});
it("ersetzt beim Aufklappen den Sammelregler durch je einen Regler pro Element", () => {
const plan = basePlan();
const keys = buildSliders(plan, true).map((s) => s.key);
// Sammelregler weg ...
expect(keys).not.toContain("d:returns");
// ... dafür einer je Element. Sonst würde eine Bewegung doppelt zählen.
expect(keys).toContain("e:etf");
expect(keys).toContain("e:fest");
expect(keys).toContain("e:pk");
});
it("setzt den Neutralpunkt der Inflation auf den Planwert, sonst auf 0", () => {
const plan = basePlan();
const sliders = buildSliders(plan, false);
expect(sliders.find((s) => s.key === "d:inflation")!.neutral).toBe(1.5);
expect(sliders.find((s) => s.key === "d:returns")!.neutral).toBe(0);
});
});
describe("applySliders", () => {
it("lässt den Plan bei neutraler Stellung exakt unverändert", () => {
const plan = basePlan();
const sliders = buildSliders(plan, false);
const neutral: SliderValues = Object.fromEntries(sliders.map((s) => [s.key, s.neutral]));
expect(isNeutral(sliders, neutral)).toBe(true);
expect(isNeutral(sliders, {})).toBe(true); // fehlender Schlüssel = neutral
// Der entscheidende Punkt: Die Kennzahl muss dem unveränderten Plan EXAKT entsprechen,
// sonst würde der Referenzwert gegen sich selbst abweichen.
expect(endOf(applySliders(plan, sliders, neutral))).toBe(endOf(plan));
});
it("kombiniert mehrere Regler unabhängig von der Reihenfolge", () => {
const plan = basePlan();
const sliders = buildSliders(plan, false);
const values: SliderValues = { "d:returns": 1, "d:expenses": -10, "d:lifespan": 3 };
const a = applySliders(plan, sliders, values);
const b = applySliders(plan, [...sliders].reverse(), values);
expect(endOf(a)).toBe(endOf(b));
});
it("wirkt aufgeschlüsselt wie der Sammelregler, wenn alle Elemente gleich bewegt werden", () => {
const plan = basePlan();
// Sammelregler: alle Anlagen +1 pp.
const collapsed = buildSliders(plan, false);
const viaDriver = applySliders(plan, collapsed, { "d:returns": 1 });
// Aufgeschlüsselt: jedes Element einzeln +1 pp. Muss auf dasselbe hinauslaufen -- sonst
// würden die beiden Ansichten desselben Reglers verschiedene Pläne beschreiben.
const expanded = buildSliders(plan, true);
const viaElements = applySliders(
plan,
expanded,
Object.fromEntries(expanded.filter((s) => s.ref.kind === "element").map((s) => [s.key, 1]))
);
expect(endOf(viaElements)).toBe(endOf(viaDriver));
});
it("trennt die Elemente sauber: nur den ETF anzuheben wirkt schwächer als alle", () => {
const plan = basePlan();
const expanded = buildSliders(plan, true);
const base = endOf(plan);
const onlyEtf = endOf(applySliders(plan, expanded, { "e:etf": 2 }));
const all = endOf(
applySliders(plan, expanded, { "e:etf": 2, "e:fest": 2, "e:pk": 2 })
);
expect(onlyEtf).toBeGreaterThan(base);
expect(all).toBeGreaterThan(onlyEtf);
});
});
describe("runLive", () => {
it("liefert den veränderten Plan mit passender Kennzahl", () => {
const plan = basePlan();
const sliders = buildSliders(plan, false);
const res = runLive(plan, sliders, { "d:expenses": 25 });
// Höhere Ausgaben -> weniger Endvermögen.
expect(res.kpis.endNominal).toBeLessThan(endOf(plan));
// Und die Kennzahl gehört wirklich zum zurückgegebenen Plan.
expect(res.kpis.endNominal).toBe(endOf(res.plan));
});
it("meldet ein Ruinalter, sobald die Regler den Plan kippen", () => {
const plan = basePlan();
const sliders = buildSliders(plan, false);
expect(runLive(plan, sliders, {}).kpis.ruinAge).toBeNull();
// Ausgaben ans obere Ende und Rendite ans untere: der Plan muss brechen. Ohne diese
// Anzeige sähe die Verlaufslinie weiterhin plausibel aus.
const broken = runLive(plan, sliders, { "d:expenses": 30, "d:returns": -3, "d:income": -30 });
expect(broken.kpis.ruinAge).not.toBeNull();
});
});
describe("describeSettings", () => {
it("beschreibt nur die abweichenden Regler, mit Vorzeichen und Einheit", () => {
const plan = basePlan();
const sliders = buildSliders(plan, false);
const text = describeSettings(sliders, { "d:returns": -1.5, "d:lifespan": 5, "d:expenses": 0 });
expect(text).toContain("Rendite -1.5 pp");
expect(text).toContain("Lebensdauer +5 J.");
// Ein neutral stehender Regler taucht nicht auf.
expect(text.some((t) => t.startsWith("Ausgaben"))).toBe(false);
});
it("schreibt die Inflation ohne Vorzeichen -- sie ist ein absoluter Wert", () => {
const plan = basePlan();
const sliders = buildSliders(plan, false);
expect(describeSettings(sliders, { "d:inflation": 3 })).toContain("Inflation 3 %");
});
});
+171
View File
@@ -0,0 +1,171 @@
// Live-Simulation / Was-wäre-wenn-Regler (Roadmap Nr. 22). Beantwortet nicht «welche Annahme
// entscheidet» (das ist der Tornado) und nicht «wie wahrscheinlich» (das ist Monte-Carlo),
// sondern schlicht: «wie sieht mein Plan aus, wenn ich HIER drehe?» -- sofort, ohne für jede
// Variante eine Szenario-Kopie anzulegen.
//
// Bewusst KEINE eigene Rechenlogik: Die Regler benutzen dieselben Transformationen wie der
// Tornado (`applyDriver`, `applyElementDriver`). Dadurch kann die Live-Simulation gar nicht
// etwas anderes zeigen als der Tornado -- beide bewegen den Plan identisch.
//
// Zur Laufzeit: `computePlan` braucht auf einem 60-Jahres-Plan mit 10 Elementen rund 0.2 ms.
// Bei 60 fps stehen 16 ms zur Verfügung -- die Rechnung kostet also etwa 1 % des Budgets.
// Deshalb wird bei JEDER Reglerbewegung synchron neu gerechnet: kein Debounce, kein Worker,
// keine Ladeanzeige. Der Engpass ist das Neuzeichnen der Grafik, nicht die Mathematik.
import { computePlan } from "@/lib/calculations";
import { applyDriver, applyElementDriver, driverById, DRIVERS, tunableElements } from "@/lib/sensitivity";
import type { DriverId, DriverUnit } from "@/lib/sensitivity";
import type { PlanComputed } from "@/lib/calculations";
import type { PlanInput } from "@/lib/types";
// Ein Regler: entweder ein plan-weiter Treiber oder die Rendite eines einzelnen Elements.
export type SliderRef = { kind: "driver"; id: DriverId } | { kind: "element"; id: string };
export interface SliderDef {
key: string; // eindeutig über beide Arten hinweg
ref: SliderRef;
label: string;
unit: DriverUnit;
help: string;
min: number; // Standardbereich, im Dialog überschreibbar
max: number;
step: number;
neutral: number; // Wert, bei dem der Regler den Plan NICHT verändert
}
// Standardbereiche je Treiber.
//
// Der Tornado hat bewusst KEINE Default-Bandbreiten (SPEZIFIKATION 9.18): Dort bestimmt die
// Bandbreite die Balkenlänge, und ein Default würde eine Rangfolge frei erfinden. Hier ist das
// anders -- ein Regler vergleicht keine Treiber gegeneinander, er zeigt ein einzelnes
// absolutes Ergebnis. Ein Standardbereich erfindet also keine Aussage, er macht den Regler
// überhaupt erst bedienbar. Beide Enden bleiben editierbar. Siehe 9.27.
const RANGES: Record<DriverId, { min: number; max: number; step: number; neutral: number }> = {
// Absolute Werte: der neutrale Punkt ist der Planwert selbst und wird zur Laufzeit gesetzt.
inflation: { min: 0, max: 4, step: 0.1, neutral: 0 },
// Verschiebungen: neutral ist immer 0.
returns: { min: -3, max: 3, step: 0.1, neutral: 0 },
propertyGrowth: { min: -2, max: 2, step: 0.1, neutral: 0 },
salaryGrowth: { min: -1, max: 2, step: 0.1, neutral: 0 },
expenses: { min: -20, max: 30, step: 1, neutral: 0 },
income: { min: -30, max: 20, step: 1, neutral: 0 },
lifespan: { min: -5, max: 10, step: 1, neutral: 0 },
};
// Bereich für die einzeln aufgeschlüsselten Element-Renditen: derselbe wie der Sammelregler,
// damit ein aufgeklappter Regler nicht plötzlich anders skaliert.
const ELEMENT_RANGE = RANGES.returns;
export const ELEMENT_HELP =
"Verschiebung der erwarteten Rendite dieses einen Elements in Prozentpunkten. Der Sammelregler «Rendite» bewegt stattdessen alle Anlagen gleichzeitig.";
// Baut die Reglerliste. `expandReturns` ersetzt den Sammelregler «Rendite» durch je einen
// Regler pro renditetragendem Element.
export function buildSliders(plan: PlanInput, expandReturns: boolean): SliderDef[] {
const out: SliderDef[] = [];
for (const def of DRIVERS) {
if (!def.applies(plan)) continue;
// Aufgeschlüsselt übernehmen die Element-Regler die Rolle der Sammelregler.
if (expandReturns && (def.id === "returns" || def.id === "propertyGrowth")) continue;
const r = RANGES[def.id];
out.push({
key: `d:${def.id}`,
ref: { kind: "driver", id: def.id },
label: def.shortLabel,
unit: def.unit,
help: def.help,
min: r.min,
max: r.max,
step: r.step,
// Die Inflation ist der einzige absolute Treiber: neutral ist der Wert aus dem Plan.
neutral: def.id === "inflation" ? plan.inflationRateDefault : 0,
});
}
if (expandReturns) {
for (const el of tunableElements(plan)) {
out.push({
key: `e:${el.id}`,
ref: { kind: "element", id: el.id },
label: el.name,
unit: "delta_pp",
help: ELEMENT_HELP,
min: ELEMENT_RANGE.min,
max: ELEMENT_RANGE.max,
step: ELEMENT_RANGE.step,
neutral: 0,
});
}
}
return out;
}
// Reglerstellungen: Schlüssel -> Wert. Fehlt ein Schlüssel, steht der Regler neutral.
export type SliderValues = Record<string, number>;
// Wendet alle vom Neutralpunkt abweichenden Regler nacheinander an.
//
// Die Reihenfolge ist bei den heutigen Treibern gleichgültig -- sie greifen auf verschiedene
// Felder zu, und die einzige Überschneidung (Sammel- vs. Element-Rendite) kann nicht
// gleichzeitig auftreten, weil `buildSliders` beim Aufklappen den Sammelregler entfernt.
export function applySliders(plan: PlanInput, sliders: SliderDef[], values: SliderValues): PlanInput {
let out = plan;
for (const s of sliders) {
const v = values[s.key];
if (v === undefined || v === s.neutral) continue;
out = s.ref.kind === "driver" ? applyDriver(out, s.ref.id, v) : applyElementDriver(out, s.ref.id, v);
}
return out;
}
export function isNeutral(sliders: SliderDef[], values: SliderValues): boolean {
return sliders.every((s) => values[s.key] === undefined || values[s.key] === s.neutral);
}
// --- Kennzahlen ------------------------------------------------------------------------
// Die eigentliche Antwort der Live-Simulation. Eine Grafik zeigt WANN sich etwas ändert,
// diese Leiste zeigt WIE VIEL -- und dass ein Plan gekippt ist (Ruin), was man einer
// Verlaufslinie allein nicht zuverlässig ansieht.
export interface LiveKpis {
endNominal: number;
endReal: number;
ruinAge: number | null;
}
export function kpisOf(computed: PlanComputed): LiveKpis {
const last = computed.phases[computed.phases.length - 1];
return {
endNominal: Math.round(last ? last.endWealthNominal : 0),
endReal: Math.round(last ? last.endWealthReal : 0),
ruinAge: computed.ruinAge,
};
}
export interface LiveResult {
plan: PlanInput; // der veränderte Plan
computed: PlanComputed;
kpis: LiveKpis;
}
export function runLive(plan: PlanInput, sliders: SliderDef[], values: SliderValues): LiveResult {
const tuned = applySliders(plan, sliders, values);
const computed = computePlan(tuned);
return { plan: tuned, computed, kpis: kpisOf(computed) };
}
// Lesbare Zusammenfassung der aktiven Regler. Ersatz für ein «als Szenario speichern», das
// bewusst noch nicht existiert (Roadmap): Man kann die gefundene Einstellung wenigstens
// ablesen und von Hand in ein echtes Szenario übertragen.
export function describeSettings(sliders: SliderDef[], values: SliderValues): string[] {
const out: string[] = [];
for (const s of sliders) {
const v = values[s.key];
if (v === undefined || v === s.neutral) continue;
const sign = s.ref.kind === "driver" && s.ref.id === "inflation" ? "" : v > 0 ? "+" : "";
const unit = s.unit === "delta_pp" ? " pp" : s.unit === "delta_years" ? " J." : " %";
out.push(`${s.label} ${sign}${v}${unit}`);
}
return out;
}
export { driverById };
+35
View File
@@ -207,6 +207,41 @@ export function applyDriver(plan: PlanInput, id: DriverId, value: number): PlanI
} }
} }
// Verschiebt die Rendite EINES Elements um eine Anzahl Prozentpunkte. Gegenstück zu
// `applyDriver("returns" | "propertyGrowth", …)`, das immer alle Elemente einer Kategorie
// gleichzeitig bewegt.
//
// Für den Tornado wäre das falsch (dreizehn Balken sind keine Rangfolge mehr), für die
// Live-Simulation ist es die entscheidende Frage: «Was, wenn mein ETF schlechter läuft, die
// PK aber wie geplant?». Siehe SPEZIFIKATION 4.15.
//
// Element-IDs sind innerhalb eines Szenarios eindeutig -- die Herkunfts-Verkettung
// (`sourceElementId`) aus der Monte-Carlo-Simulation braucht es hier nicht, weil die
// Live-Simulation immer nur auf EINEM Szenario läuft.
export function applyElementDriver(plan: PlanInput, elementId: string, deltaPp: number): PlanInput {
const target = plan.elements.find((e) => e.id === elementId);
if (!target) return plan;
// Immobilien tragen ihre Rendite als Wertsteigerung der Liegenschaft, alle übrigen als
// erwartete Rendite auf den Anlagewert.
const field = target.category === "REAL_ESTATE" ? "valueGrowth" : "expectedReturn";
return {
...plan,
elements: plan.elements.map((e) =>
e.id === elementId
? mapPhaseData(e, (pd) => ({ ...pd, [field]: num(pd[field as keyof PhaseData] as never) + deltaPp }))
: e
),
};
}
// Elemente, deren Rendite sich einzeln regeln lässt -- dieselbe Menge, die auch der
// Sammelregler bewegt (Anlagen + Immobilien).
export function tunableElements(plan: PlanInput): { id: string; name: string; category: ElementCategory }[] {
return plan.elements
.filter((e) => RETURN_CATEGORIES.includes(e.category) || e.category === "REAL_ESTATE")
.map((e) => ({ id: e.id, name: e.name, category: e.category }));
}
// --- Tornado --------------------------------------------------------------------------- // --- Tornado ---------------------------------------------------------------------------
export type TornadoMetric = "real" | "nominal"; export type TornadoMetric = "real" | "nominal";