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:
+118
-3
@@ -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 27–48 %. 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 27–48 %. 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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 })}
|
||||||
|
|||||||
@@ -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 – die Aufteilung kann durch Umschichtung abweichen.
|
Gesamtvolumen dem Beginn der nächsten – 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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?",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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 %");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user