diff --git a/SPEZIFIKATION.md b/SPEZIFIKATION.md index 4a6c63e..5763189 100644 --- a/SPEZIFIKATION.md +++ b/SPEZIFIKATION.md @@ -4,7 +4,7 @@ | | | |---|---| | **Dokument** | Funktionale und Technische Spezifikation FPT | -| **Version** | 0.17 | +| **Version** | 0.18 | | **Datum** | 2026-07-18 | | **Status** | Lebendes Dokument | | **Codestand** | Arbeitsstand nach `1d046e9` inkl. zwei Monte-Carlo-Fragestellungen (Branch `main`) | @@ -17,6 +17,7 @@ | 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.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). | @@ -2289,6 +2290,90 @@ Serverergebnis. 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 @@ -2361,7 +2446,8 @@ PlanComputed ← an den Client geliefert | `types.ts` | Domänentypen für API und Berechnung | | `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. | -| `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. | | `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 | @@ -2619,6 +2705,8 @@ wird der Plan neu geladen; die Berechnung kommt immer vom Server. | `PhaseDetail` | 95 | Phase bearbeiten/löschen | | `SparquoteChart` | 64 | Einkommen/Ausgaben pro Jahr mit Spar-/Verzehrbändern | | `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 | | `DetailView` | ~470 | Detailansichten Element/Phase, Wasserfall-Darstellung, Rechenweg-Blöcke, plan-weite Rechenwege | | `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 | | `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 | +| `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 | | `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 | | `migrations.test.ts` | 1 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein | -| **Total** | **124** | | +| **Total** | **139** | | ## 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.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 diff --git a/src/components/AllocationChart.tsx b/src/components/AllocationChart.tsx new file mode 100644 index 0000000..635fad0 --- /dev/null +++ b/src/components/AllocationChart.tsx @@ -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(); + 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 = { label: `${phase.name} · Beginn` }; + const ende: Record = { 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

Dieser Plan enthält keine Vermögenselemente.

; + } + + return ( +
+ + + + + Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} + /> + (typeof v === "number" ? formatChf(v) : v)} /> + + {assetEls.map((el, i) => ( + + ))} + + +
+ ); +} diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 077df32..dda06ab 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -24,6 +24,7 @@ import { PlanView } from "@/components/PlanView"; import { Dashboard } from "@/components/Dashboard"; import { MonteCarloDialog } from "@/components/MonteCarloDialog"; import { SensitivityDialog } from "@/components/SensitivityDialog"; +import { LiveSimDialog } from "@/components/LiveSimDialog"; import { SpecView } from "@/components/SpecView"; import { SystemParametersView } from "@/components/SystemParametersView"; import { PlanTraceDialog } from "@/components/DetailView"; @@ -85,6 +86,7 @@ function AppShellInner({ username }: { username: string }) { const [showCharts, setShowCharts] = useState(false); const [showMonteCarlo, setShowMonteCarlo] = useState(false); const [showSensitivity, setShowSensitivity] = useState(false); + const [showLiveSim, setShowLiveSim] = useState(false); const [showSystemParams, setShowSystemParams] = useState(false); const [showPlanTraces, setShowPlanTraces] = useState(false); const [showPalette, setShowPalette] = useState(false); @@ -421,6 +423,10 @@ function AppShellInner({ username }: { username: string }) { Grafiken + + + +

+ Dreh an den Reglern und sieh sofort, was passiert. Nichts davon wird + gespeichert – dein Plan bleibt unverändert, du brauchst für kein Durchspielen eine + Szenario-Kopie. Die Regler benutzen dieselben Umrechnungen wie die Einflussfaktoren-Analyse. +

+ + {/* Kennzahlenleiste: die eigentliche Antwort. Die Grafik zeigt WANN, das hier WIE VIEL. */} +
+ + + +
+ +
+ {/* --- Links: Regler --- */} +
+
+ + Parameter + + + +
+ + + + + +
+ {sliders.map((s) => ( + setValue(s.key, v)} + onBounds={(min, max) => setBounds((prev) => ({ ...prev, [s.key]: [min, max] }))} + /> + ))} + {sliders.length === 0 && ( +

+ Dieser Plan enthält noch keine Elemente, an denen sich etwas regeln liesse. +

+ )} +
+ + {settings.length > 0 && ( +
+
+ Aktive Einstellung +
+

{settings.join(" · ")}

+
+ )} +
+ + {/* --- Rechts: Grafik --- */} +
+
+ {CHARTS.map((c) => ( + + ))} +
+

{CHARTS.find((c) => c.id === chart)!.hint}

+ +
+ {chart === "wealth" && ( + + )} + {chart === "allocation" && } + {chart === "cashflow" && } +
+ + {chart !== "wealth" && ( +

+ Diese Grafik zeigt nur den simulierten Stand. Die Gegenüberstellung mit deinem + unveränderten Plan liefert der Vermögensverlauf – und die Zahlen oben. +

+ )} +
+
+ + + ); +} + +// 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 ( +
+
+ + {def.label} + + + + 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" + /> + {suffix} + +
+ onChange(Number(e.target.value))} + className="w-full accent-[var(--accent)]" + /> + {showRange ? ( +
+ 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" + /> + bis + 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" + /> + neutral: {def.neutral} {suffix} +
+ ) : ( +
+ {def.min} + {def.max} +
+ )} +
+ ); +} + +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 ( +
+
{label}
+
{formatChf(live)}
+
+ Plan: {formatChf(base)} + {diff !== 0 && ( + + {diff > 0 ? "+" : "−"} + {formatChf(Math.abs(diff))} + + )} +
+
+ ); +} + +// 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 ( +
+
Kapital reicht
+
+ {broken ? `aufgebraucht mit ${liveAge}` : "bis Planende"} +
+
+ {baseAge === null ? "Plan: reicht bis Planende" : `Plan: aufgebraucht mit ${baseAge}`} +
+
+ ); +} diff --git a/src/components/Tour.tsx b/src/components/Tour.tsx index 0a1005f..eb6dd79 100644 --- a/src/components/Tour.tsx +++ b/src/components/Tour.tsx @@ -47,7 +47,7 @@ const STEPS: TourStep[] = [ { target: "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?", }, ]; diff --git a/src/lib/livesim.test.ts b/src/lib/livesim.test.ts new file mode 100644 index 0000000..224514e --- /dev/null +++ b/src/lib/livesim.test.ts @@ -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 %"); + }); +}); diff --git a/src/lib/livesim.ts b/src/lib/livesim.ts new file mode 100644 index 0000000..f647247 --- /dev/null +++ b/src/lib/livesim.ts @@ -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 = { + // 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; + +// 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 }; diff --git a/src/lib/sensitivity.ts b/src/lib/sensitivity.ts index a957b4f..6f2563d 100644 --- a/src/lib/sensitivity.ts +++ b/src/lib/sensitivity.ts @@ -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 --------------------------------------------------------------------------- export type TornadoMetric = "real" | "nominal";