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
+ 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. */}
+
+ 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 (
+
+ );
+}
+
+// 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";