Monte-Carlo: zwei Fragestellungen (Planung pruefen / Ziel pruefen / Beides)
Deploy App / deploy (push) Successful in 1m3s
Deploy App / deploy (push) Successful in 1m3s
Roadmap Nr. 46. Umschalter oben trennt die zwei Fragen; kein Eingriff in den Rechenkern (reine Dialog-Logik + kleine montecarlo.ts-Erweiterung). - "Planung pruefen" (Fall 1, wie bisher): wuerfelt um HISTORISCHE Renditen, prueft gegen den Planungs-Endbetrag (read-only) -> "wie realistisch ist meine Planung?" - "Ziel pruefen" (Fall 2, neu): wuerfelt um die GEPLANTEN Werte aus dem Plan, prueft gegen einen manuellen Zielbetrag -> "erreiche ich mein Ziel?"; keine historischen Mittelwerte noetig, nur Streuung - "Beides": beide Durchgaenge gleichzeitig, gemeinsamer Seed - Deutungstexte je Fall, weich formuliert (Volatilitaets-Drag, siehe 9.26) - Zielbetrag in Fall 2 = einer fuer alle Szenarien; Ruin/Faecher aus dem historischen Durchgang (ehrliches Risikobild) montecarlo.ts: runMonteCarloMulti nimmt Inflation je Szenario (inflationMeanFor); neuer Helfer plannedReturnOf (erste Phasenrendite, Wertsteigerung bei Immobilien). Nebenbei die vom Umlaut-Sweep verstuemmelte Hex-Farbe #7c3aed korrigiert. 8 Tests (119 -> 121). SPEZIFIKATION auf 0.16 (Kap. 4.12.7, 9.26 neu). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+60
-5
@@ -4,10 +4,10 @@
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
||||||
| **Version** | 0.15 |
|
| **Version** | 0.16 |
|
||||||
| **Datum** | 2026-07-18 |
|
| **Datum** | 2026-07-18 |
|
||||||
| **Status** | Lebendes Dokument |
|
| **Status** | Lebendes Dokument |
|
||||||
| **Codestand** | Arbeitsstand nach `9252f71` inkl. abschnittsbasiertem Plan-Assistenten (Branch `main`) |
|
| **Codestand** | Arbeitsstand nach `1d046e9` inkl. zwei Monte-Carlo-Fragestellungen (Branch `main`) |
|
||||||
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet |
|
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet |
|
||||||
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
|
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
| Version | Datum | Autor | Änderung |
|
| Version | Datum | Autor | Änderung |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|
| 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). |
|
||||||
| 0.14 | 2026-07-18 | Claude (Opus 4.8) | **Phasenkopf überarbeitet und zwei Verteil-Werkzeuge.** (1) **Zweizeilige Wertdarstellung:** Im Anzeigemodus «Beide» steht der Realwert neu in Klammern in einer **eigenen Zeile** unter dem nominalen Wert statt daneben – im Phasenkopf *und* in den Matrix-Zellen. Der Pfeil wiederholt sich auf der zweiten Zeile, damit der Bezug Start → Ende erhalten bleibt. Nebeneffekt: Die Spalten werden schmaler, wodurch **jede Kennzahl umbruchfrei** (`whitespace-nowrap`) dargestellt werden kann. (2) Die Kennzahl heisst korrekt **«Sparquote»** bzw. **«Verzehrquote»** statt «Quote»/«Verzehr». (3) Neuer Block **«Verfügbares Kapital»** im Phasenkopf (ab Phase 2, nur wenn > 0): der beim Übergang zugeflossene Topf mit «davon verteilt» und «Rest auf Cash». (4) Zwei neue Werkzeuge als eigene Popups: **«Kapital verteilen»** (Zusatzeinlagen in PK/3a/Vermögen, Sonderamortisation, Sofort-Tilgung) und **«Sparquote/Bezug verteilen»** (jährliche Raten), beide mit **Live-Vorschau** über eine erneute `computePlan`-Rechnung im Browser – die angezeigte Wirkung ist dadurch per Konstruktion exakt die spätere, inklusive aller Kappungen. Der Quoten-Dialog weist neben erstem und letztem Jahr die **absolute Quote über die ganze Phase** aus und warnt, wenn die Quote über die Phase sinkt. Neues reines Modul `distribution.ts`. Neue Kapitel 3.6.9, 3.6.10, 9.25; 3.6.1 und 3.6.3 überarbeitet. 8 Tests ergänzt (103 → 111). **Keine Änderung an Berechnung, Datenmodell oder API** – beide Werkzeuge schreiben ausschliesslich bestehende Felder über bestehende Endpunkte. |
|
| 0.14 | 2026-07-18 | Claude (Opus 4.8) | **Phasenkopf überarbeitet und zwei Verteil-Werkzeuge.** (1) **Zweizeilige Wertdarstellung:** Im Anzeigemodus «Beide» steht der Realwert neu in Klammern in einer **eigenen Zeile** unter dem nominalen Wert statt daneben – im Phasenkopf *und* in den Matrix-Zellen. Der Pfeil wiederholt sich auf der zweiten Zeile, damit der Bezug Start → Ende erhalten bleibt. Nebeneffekt: Die Spalten werden schmaler, wodurch **jede Kennzahl umbruchfrei** (`whitespace-nowrap`) dargestellt werden kann. (2) Die Kennzahl heisst korrekt **«Sparquote»** bzw. **«Verzehrquote»** statt «Quote»/«Verzehr». (3) Neuer Block **«Verfügbares Kapital»** im Phasenkopf (ab Phase 2, nur wenn > 0): der beim Übergang zugeflossene Topf mit «davon verteilt» und «Rest auf Cash». (4) Zwei neue Werkzeuge als eigene Popups: **«Kapital verteilen»** (Zusatzeinlagen in PK/3a/Vermögen, Sonderamortisation, Sofort-Tilgung) und **«Sparquote/Bezug verteilen»** (jährliche Raten), beide mit **Live-Vorschau** über eine erneute `computePlan`-Rechnung im Browser – die angezeigte Wirkung ist dadurch per Konstruktion exakt die spätere, inklusive aller Kappungen. Der Quoten-Dialog weist neben erstem und letztem Jahr die **absolute Quote über die ganze Phase** aus und warnt, wenn die Quote über die Phase sinkt. Neues reines Modul `distribution.ts`. Neue Kapitel 3.6.9, 3.6.10, 9.25; 3.6.1 und 3.6.3 überarbeitet. 8 Tests ergänzt (103 → 111). **Keine Änderung an Berechnung, Datenmodell oder API** – beide Werkzeuge schreiben ausschliesslich bestehende Felder über bestehende Endpunkte. |
|
||||||
| 0.13 | 2026-07-18 | Claude (Fable 5) | **UI-Gesamtumbau** – rein an der Oberfläche, Berechnung, Datenmodell und API-Semantik unverändert. **(A) Fundament:** durchgehende **Du-Form** und **echte Umlaute** in allen sichtbaren Texten (inkl. API-Fehlermeldungen); neue UI-Primitiven in `ui.tsx` (Button, Modal mit ESC/Fokus-Falle/Animation, Bestätigungs-Dialog statt `window.confirm`, Toasts statt `alert`, Skeleton-Loader, EmptyState); eigene **Attention-Farbe** (Amber) für offene Entscheide, getrennt vom Akzent; Micro-Interactions mit `prefers-reduced-motion`-Fallback. **(B) Onboarding (Roadmap Nr. 10):** geführter **Plan-Assistent** in fünf Schritten (reine Orchestrierung bestehender Endpunkte, Einkommen bewusst pro Person – räumt die 9.9-Falle aus), **Beispielplan mit einem Klick** (Übergänge absichtlich offen – die Ampel lehrt sich selbst), **interaktive Tour** über die Planansicht, abgeleitete **«Nächste Schritte»**-Karte. **(C) Struktur:** Einzel-Bearbeitungen laufen neu über ein rechtes **Inspector-Panel** statt Modals (Matrix bleibt sichtbar; Klick auf andere Zelle wechselt den Inhalt); **Phasenkopf entschlackt** auf vier Kern-Infos (Rest wohnt in der Detailansicht aus 0.11); Matrix mit eigenem Scrollbereich und **beidachsig fixierten Köpfen**; Sidebar-Gruppen «Meine Pläne»/«Wissen» («So rechnet FPT», Systemparameter); Terminologie-Fix «Szenario-Profil» statt «Plan-Einstellungen»; Aktions-Icons auch ohne Hover sichtbar (Touch). **(D) Extras:** **Sparklines** je Element-Zeile (aus den 0.11-Verlaufswerten, keine Neuberechnung), **Befehls-Palette** (Ctrl/Cmd+K), Ruin-Banner verlinkt auf die Einflussfaktoren. Neue Kapitel 3.2.8, 3.7.6–3.7.9, 9.23, 9.24; 9.17 bereinigt (der `Selection`-Rest und der ProfileMenu-Lint-Fehler sind behoben – `npm run lint` ist erstmals fehlerfrei). Testbestand unverändert 103. |
|
| 0.13 | 2026-07-18 | Claude (Fable 5) | **UI-Gesamtumbau** – rein an der Oberfläche, Berechnung, Datenmodell und API-Semantik unverändert. **(A) Fundament:** durchgehende **Du-Form** und **echte Umlaute** in allen sichtbaren Texten (inkl. API-Fehlermeldungen); neue UI-Primitiven in `ui.tsx` (Button, Modal mit ESC/Fokus-Falle/Animation, Bestätigungs-Dialog statt `window.confirm`, Toasts statt `alert`, Skeleton-Loader, EmptyState); eigene **Attention-Farbe** (Amber) für offene Entscheide, getrennt vom Akzent; Micro-Interactions mit `prefers-reduced-motion`-Fallback. **(B) Onboarding (Roadmap Nr. 10):** geführter **Plan-Assistent** in fünf Schritten (reine Orchestrierung bestehender Endpunkte, Einkommen bewusst pro Person – räumt die 9.9-Falle aus), **Beispielplan mit einem Klick** (Übergänge absichtlich offen – die Ampel lehrt sich selbst), **interaktive Tour** über die Planansicht, abgeleitete **«Nächste Schritte»**-Karte. **(C) Struktur:** Einzel-Bearbeitungen laufen neu über ein rechtes **Inspector-Panel** statt Modals (Matrix bleibt sichtbar; Klick auf andere Zelle wechselt den Inhalt); **Phasenkopf entschlackt** auf vier Kern-Infos (Rest wohnt in der Detailansicht aus 0.11); Matrix mit eigenem Scrollbereich und **beidachsig fixierten Köpfen**; Sidebar-Gruppen «Meine Pläne»/«Wissen» («So rechnet FPT», Systemparameter); Terminologie-Fix «Szenario-Profil» statt «Plan-Einstellungen»; Aktions-Icons auch ohne Hover sichtbar (Touch). **(D) Extras:** **Sparklines** je Element-Zeile (aus den 0.11-Verlaufswerten, keine Neuberechnung), **Befehls-Palette** (Ctrl/Cmd+K), Ruin-Banner verlinkt auf die Einflussfaktoren. Neue Kapitel 3.2.8, 3.7.6–3.7.9, 9.23, 9.24; 9.17 bereinigt (der `Selection`-Rest und der ProfileMenu-Lint-Fehler sind behoben – `npm run lint` ist erstmals fehlerfrei). Testbestand unverändert 103. |
|
||||||
@@ -1973,6 +1974,45 @@ aus. Die Schätzung skaliert mit der Anzahl Szenarien.
|
|||||||
Referenz: `src/lib/montecarlo.ts` (`resolveRootElementId`, `buildElementGroups`,
|
Referenz: `src/lib/montecarlo.ts` (`resolveRootElementId`, `buildElementGroups`,
|
||||||
`paramsForScenario`, `runMonteCarloMulti`), `src/components/MonteCarloDialog.tsx`.
|
`paramsForScenario`, `runMonteCarloMulti`), `src/components/MonteCarloDialog.tsx`.
|
||||||
|
|
||||||
|
### 4.12.7 Zwei Fragestellungen: Planung prüfen vs. Ziel prüfen
|
||||||
|
|
||||||
|
Die Simulation beantwortet **zwei verschiedene Fragen**, die sich sauber trennen lassen (Roadmap
|
||||||
|
Nr. 46). Ein Umschalter oben im Dialog wählt zwischen ihnen – oder rechnet beide gleichzeitig.
|
||||||
|
|
||||||
|
| | **Planung prüfen** (Fall 1) | **Ziel prüfen** (Fall 2) |
|
||||||
|
|---|---|---|
|
||||||
|
| Gewürfelt wird um … | **historische** Mittelwerte (Eingabe) | **geplante** Werte (aus dem Plan) |
|
||||||
|
| Geprüft wird gegen … | **Planungs-Endbetrag** (read-only) | **manueller Zielbetrag** |
|
||||||
|
| Beantwortet | «Wie realistisch ist meine Planung?» | «Erreiche ich mein Ziel?» |
|
||||||
|
| Nötige Eingaben | historische Renditen + Streuung | nur Streuung + Zielbetrag |
|
||||||
|
|
||||||
|
Das baut direkt auf der **Trennung der zwei Renditezahlen** aus [4.12.3](#4123-zwei-renditezahlen--und-warum)
|
||||||
|
auf. Fall 1 ist das bisherige Verhalten – nur wird das Zielfeld in «Planungs-Endbetrag» umbenannt
|
||||||
|
und **read-only** (es ist der geplante Nachlass des jeweiligen Szenarios). Fall 2 dreht es um:
|
||||||
|
Der Mittelpunkt ist nicht mehr ein historischer Wert, sondern die **plan-eigene** Rendite je
|
||||||
|
Element (`plannedReturnOf`: erste Phase, bei Immobilien die Wertsteigerung) und die plan-eigene
|
||||||
|
Inflation; geprüft wird gegen ein frei gewähltes Ziel. Die **Streuung bleibt in beiden Fällen**
|
||||||
|
– ohne sie gäbe es keine Wahrscheinlichkeit.
|
||||||
|
|
||||||
|
**Zielbetrag in Fall 2:** genau **einer für alle** ausgewählten Szenarien – so vergleicht man
|
||||||
|
Szenarien gegen dasselbe Ziel. **Ruin** und **Fächer** stammen immer aus dem historischen
|
||||||
|
Durchgang (Fall 1), weil das das ehrliche Risikobild ist; im reinen Ziel-Modus aus Fall 2.
|
||||||
|
|
||||||
|
**Deutungstexte** (bei einem einzelnen Szenario):
|
||||||
|
- Fall 1, < 50 %: «Deine Planung ist tendenziell zu optimistisch …»; ≥ 50 %: «… realistisch bis
|
||||||
|
vorsichtig …».
|
||||||
|
- Fall 2, > 50 %: «Mit deiner Planung solltest du dein Ziel tendenziell erreichen …»; sonst das
|
||||||
|
Gegenteil.
|
||||||
|
|
||||||
|
Die Formulierung ist bewusst **weich** («tendenziell»): Schon eine faire Planung landet wegen des
|
||||||
|
Volatilitäts-Drags leicht unter 50 % ([4.12.5](#4125-ergebnis), [9.26](#926-die-50--schwelle-ist-kein-scharfer-schnitt)).
|
||||||
|
|
||||||
|
**Technik:** kein Eingriff in den Rechenkern. `runMonteCarloMulti` nimmt die Inflation neu **je
|
||||||
|
Szenario** (`inflationMeanFor`), damit Fall 2 die plan-eigene Inflation verwenden kann. Der
|
||||||
|
Kombi-Modus rechnet zwei Durchgänge mit **demselben Seed**.
|
||||||
|
|
||||||
|
Referenz: `src/lib/montecarlo.ts` (`plannedReturnOf`), `src/components/MonteCarloDialog.tsx`.
|
||||||
|
|
||||||
## 4.13 Sensitivitätsanalyse (Tornado)
|
## 4.13 Sensitivitätsanalyse (Tornado)
|
||||||
|
|
||||||
Die Monte-Carlo-Simulation würfelt alle Unsicherheiten gleichzeitig und beantwortet „wie
|
Die Monte-Carlo-Simulation würfelt alle Unsicherheiten gleichzeitig und beantwortet „wie
|
||||||
@@ -2796,13 +2836,13 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests
|
|||||||
| `calculations.test.ts` | 43 | AHV-Rentenformel, Immobilie, Teilverkauf, Sonderamortisation, AHV einkommensabhängig, „V5 Golden Tests" |
|
| `calculations.test.ts` | 43 | AHV-Rentenformel, Immobilie, Teilverkauf, Sonderamortisation, AHV einkommensabhängig, „V5 Golden Tests" |
|
||||||
| `sensitivity.test.ts` | 15 | Treiber-Transformationen (Reinheit, Einheiten, Kappung), Verfügbarkeit, Tornado-Sortierung und -Richtung, Erklärung wirkungsloser Treiber |
|
| `sensitivity.test.ts` | 15 | Treiber-Transformationen (Reinheit, Einheiten, Kappung), Verfügbarkeit, Tornado-Sortierung und -Richtung, Erklärung wirkungsloser Treiber |
|
||||||
| `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` | 13 | Determinismus, Volatilität/Vol-Drag, Böden, Reproduzierbarkeit; Element-Gruppierung und Szenario-Vergleich |
|
| `montecarlo.test.ts` | 15 | Determinismus, Volatilität/Vol-Drag, Böden, Reproduzierbarkeit; Element-Gruppierung, Szenario-Vergleich, Inflation je Szenario, plannedReturnOf |
|
||||||
| `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 |
|
||||||
| `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** | **119** | |
|
| **Total** | **121** | |
|
||||||
|
|
||||||
## 8.2 Testfälle
|
## 8.2 Testfälle
|
||||||
|
|
||||||
@@ -3204,6 +3244,21 @@ Bewusst **nicht** umgesetzt ist eine automatische Deckelung: Es gibt legitime Gr
|
|||||||
sparen als die laufende Quote hergibt (etwa wenn ein Cash-Polster aus der Vorphase abgebaut
|
sparen als die laufende Quote hergibt (etwa wenn ein Cash-Polster aus der Vorphase abgebaut
|
||||||
werden soll). Das Werkzeug informiert, es bevormundet nicht.
|
werden soll). Das Werkzeug informiert, es bevormundet nicht.
|
||||||
|
|
||||||
|
## 9.26 Die 50-%-Schwelle ist kein scharfer Schnitt
|
||||||
|
|
||||||
|
Die Deutungstexte ([4.12.7](#4127-zwei-fragestellungen-planung-prüfen-vs-ziel-prüfen)) kippen bei
|
||||||
|
50 %. Das ist eine Vereinfachung: Schon eine **völlig faire** Planung (historischer Mittelwert =
|
||||||
|
geplanter Mittelwert) erreicht ihren Endbetrag in etwas **weniger** als 50 % der Fälle, weil
|
||||||
|
Schwankung den Median unter den arithmetischen Mittelwert drückt (Volatilitäts-Drag,
|
||||||
|
[4.12.5](#4125-ergebnis)). Ein Wert knapp unter 50 % bedeutet also nicht zwingend «zu optimistisch»,
|
||||||
|
sondern kann reiner Vol-Drag sein.
|
||||||
|
|
||||||
|
Deshalb ist die Formulierung weich gehalten («tendenziell») und der bestehende Hinweis bleibt,
|
||||||
|
dass die Simulation Risiko **um deine Annahmen** misst, nicht deren Richtigkeit
|
||||||
|
([9.15](#915-monte-carlo-misst-risiko-um-die-annahmen-nicht-deren-richtigkeit)). Eine exakte
|
||||||
|
Schwelle müsste die Vol-Drag-Korrektur einrechnen – bewusst nicht getan, um die Aussage einfach
|
||||||
|
zu halten.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 10. Glossar
|
# 10. Glossar
|
||||||
@@ -3246,4 +3301,4 @@ werden soll). Das Werkzeug informiert, es bevormundet nicht.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Ende der Spezifikation v0.15*
|
*Ende der Spezifikation v0.16*
|
||||||
|
|||||||
+380
-230
@@ -12,6 +12,7 @@ import {
|
|||||||
defaultVolatilityLevel,
|
defaultVolatilityLevel,
|
||||||
floorFor,
|
floorFor,
|
||||||
paramsForScenario,
|
paramsForScenario,
|
||||||
|
plannedReturnOf,
|
||||||
runMonteCarloMulti,
|
runMonteCarloMulti,
|
||||||
INFLATION_VOLATILITY_LEVELS,
|
INFLATION_VOLATILITY_LEVELS,
|
||||||
RETURN_BEARING,
|
RETURN_BEARING,
|
||||||
@@ -28,7 +29,16 @@ import type { PlanInput, ScenarioMeta } from "@/lib/types";
|
|||||||
import type { PlanComputed } from "@/lib/calculations";
|
import type { PlanComputed } from "@/lib/calculations";
|
||||||
|
|
||||||
// Farben der Szenario-Serien -- wie im Vermögensverlauf, damit die Zuordnung vertraut bleibt.
|
// Farben der Szenario-Serien -- wie im Vermögensverlauf, damit die Zuordnung vertraut bleibt.
|
||||||
const PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3äd"];
|
const PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"];
|
||||||
|
|
||||||
|
// Die zwei Fragen, die die Simulation beantworten kann (Roadmap Nr. 46).
|
||||||
|
type McMode = "PLAN" | "TARGET" | "BOTH";
|
||||||
|
|
||||||
|
const MODE_OPTIONS: { value: McMode; label: string }[] = [
|
||||||
|
{ value: "PLAN", label: "Planung prüfen" },
|
||||||
|
{ value: "TARGET", label: "Ziel prüfen" },
|
||||||
|
{ value: "BOTH", label: "Beides" },
|
||||||
|
];
|
||||||
|
|
||||||
const RETURN_LEVEL_OPTIONS: { value: ReturnVolatilityLevel; label: string }[] = [
|
const RETURN_LEVEL_OPTIONS: { value: ReturnVolatilityLevel; label: string }[] = [
|
||||||
{ value: "sehr_niedrig", label: "Sehr niedrig" },
|
{ value: "sehr_niedrig", label: "Sehr niedrig" },
|
||||||
@@ -68,6 +78,9 @@ function returnSigma(el: ElementDraft): number {
|
|||||||
function inflationSigma(level: InflationVolatilityLevel, manual: string): number {
|
function inflationSigma(level: InflationVolatilityLevel, manual: string): number {
|
||||||
return level === "manuell" ? Number(manual) || 0 : INFLATION_VOLATILITY_LEVELS[level];
|
return level === "manuell" ? Number(manual) || 0 : INFLATION_VOLATILITY_LEVELS[level];
|
||||||
}
|
}
|
||||||
|
function orderedPhaseIds(plan: PlanInput): string[] {
|
||||||
|
return [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber).map((p) => p.id);
|
||||||
|
}
|
||||||
|
|
||||||
// Deterministische Planungslinie eines Szenarios (Alterspunkte = Phasengrenzen).
|
// Deterministische Planungslinie eines Szenarios (Alterspunkte = Phasengrenzen).
|
||||||
function detPointsOf(plan: PlanInput, computed: PlanComputed): { age: number; det: number }[] {
|
function detPointsOf(plan: PlanInput, computed: PlanComputed): { age: number; det: number }[] {
|
||||||
@@ -103,9 +116,10 @@ export function MonteCarloDialog({
|
|||||||
const [loading, setLoading] = useState(scenarios.some((s) => s.id !== meta.id));
|
const [loading, setLoading] = useState(scenarios.some((s) => s.id !== meta.id));
|
||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([meta.id]);
|
const [selectedIds, setSelectedIds] = useState<string[]>([meta.id]);
|
||||||
const [targets, setTargets] = useState<Record<string, number>>({
|
|
||||||
[meta.id]: Math.max(0, computed.nachlass),
|
const [mode, setMode] = useState<McMode>("PLAN");
|
||||||
});
|
// Fall 2: EIN Zielbetrag für alle Szenarien.
|
||||||
|
const [manualTarget, setManualTarget] = useState<number>(Math.max(0, computed.nachlass));
|
||||||
|
|
||||||
const [runs, setRuns] = useState(1000);
|
const [runs, setRuns] = useState(1000);
|
||||||
const [inflMean, setInflMean] = useState("");
|
const [inflMean, setInflMean] = useState("");
|
||||||
@@ -115,12 +129,15 @@ export function MonteCarloDialog({
|
|||||||
|
|
||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
const [progress, setProgress] = useState({ index: 0, count: 1, fraction: 0 });
|
const [progress, setProgress] = useState({ index: 0, count: 1, fraction: 0 });
|
||||||
const [results, setResults] = useState<ScenarioMcResult[] | null>(null);
|
// Ergebnisse getrennt nach Frage: planA = "gegen Planungs-Endbetrag", targetB = "gegen Zielbetrag".
|
||||||
|
const [resA, setResA] = useState<ScenarioMcResult[] | null>(null);
|
||||||
|
const [resB, setResB] = useState<ScenarioMcResult[] | null>(null);
|
||||||
|
|
||||||
const scenarioKey = scenarios.map((s) => s.id).join(",");
|
const scenarioKey = scenarios.map((s) => s.id).join(",");
|
||||||
|
|
||||||
// Die übrigen Szenarien einmalig nachladen. Ein Plan hat realistisch eine Handvoll
|
const needsHistorical = mode === "PLAN" || mode === "BOTH"; // Fall 1 braucht historische Mittelwerte
|
||||||
// Szenarien -- gegenüber tausenden Simulationsläufen fällt das nicht ins Gewicht.
|
const needsTarget = mode === "TARGET" || mode === "BOTH"; // Fall 2 braucht einen Zielbetrag
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const missing = scenarios.filter((s) => s.id !== meta.id);
|
const missing = scenarios.filter((s) => s.id !== meta.id);
|
||||||
if (missing.length === 0) return;
|
if (missing.length === 0) return;
|
||||||
@@ -135,10 +152,6 @@ export function MonteCarloDialog({
|
|||||||
);
|
);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setLoaded((prev) => ({ ...prev, ...Object.fromEntries(entries) }));
|
setLoaded((prev) => ({ ...prev, ...Object.fromEntries(entries) }));
|
||||||
setTargets((prev) => ({
|
|
||||||
...prev,
|
|
||||||
...Object.fromEntries(entries.map(([id, v]) => [id, Math.max(0, v.computed.nachlass)])),
|
|
||||||
}));
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!cancelled) setLoadError(e instanceof Error ? e.message : "Szenarien konnten nicht geladen werden.");
|
if (!cancelled) setLoadError(e instanceof Error ? e.message : "Szenarien konnten nicht geladen werden.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -156,73 +169,125 @@ export function MonteCarloDialog({
|
|||||||
[scenarios, loaded]
|
[scenarios, loaded]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ein Parametersatz je LOGISCHEM Element (über die sourceElementId-Kette zusammengefasst).
|
const groups = useMemo(() => buildElementGroups(allLoaded, selectedIds), [allLoaded, selectedIds]);
|
||||||
const groups = useMemo(
|
|
||||||
() => buildElementGroups(allLoaded, selectedIds),
|
|
||||||
[allLoaded, selectedIds]
|
|
||||||
);
|
|
||||||
|
|
||||||
function draftFor(g: ElementGroup): ElementDraft {
|
function draftFor(g: ElementGroup): ElementDraft {
|
||||||
return drafts[g.rootId] ?? { mean: "", level: defaultVolatilityLevel(g.category), manualSigma: "10" };
|
return drafts[g.rootId] ?? { mean: "", level: defaultVolatilityLevel(g.category), manualSigma: "10" };
|
||||||
}
|
}
|
||||||
function setDraft(rootId: string, group: ElementGroup, patch: Partial<ElementDraft>) {
|
function setDraft(rootId: string, group: ElementGroup, patch: Partial<ElementDraft>) {
|
||||||
setDrafts((prev) => ({ ...prev, [rootId]: { ...draftFor(group), ...prev[rootId], ...patch } }));
|
setDrafts((prev) => ({ ...prev, [rootId]: { ...draftFor(group), ...prev[rootId], ...patch } }));
|
||||||
|
clearResults();
|
||||||
|
}
|
||||||
|
function clearResults() {
|
||||||
|
setResA(null);
|
||||||
|
setResB(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleScenario(id: string) {
|
function toggleScenario(id: string) {
|
||||||
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||||
setResults(null);
|
clearResults();
|
||||||
}
|
}
|
||||||
|
|
||||||
const selected = selectedIds.map((id) => loaded[id]).filter((s): s is LoadedScenario => !!s);
|
const selected = selectedIds.map((id) => loaded[id]).filter((s): s is LoadedScenario => !!s);
|
||||||
const anyReturnBearing = selected.some((s) => s.plan.elements.some((e) => RETURN_BEARING.includes(e.category)));
|
const anyReturnBearing = selected.some((s) => s.plan.elements.some((e) => RETURN_BEARING.includes(e.category)));
|
||||||
|
|
||||||
// Pflichtfelder: historische Inflation + je logischem Element die historische Rendite.
|
const missingHist = inflMean.trim() === "" || groups.some((g) => draftFor(g).mean.trim() === "");
|
||||||
const missing =
|
const missing = (needsHistorical && missingHist) || (needsTarget && manualTarget <= 0);
|
||||||
inflMean.trim() === "" || groups.some((g) => draftFor(g).mean.trim() === "");
|
|
||||||
const canRun = !missing && selected.length > 0 && anyReturnBearing && !loading;
|
const canRun = !missing && selected.length > 0 && anyReturnBearing && !loading;
|
||||||
|
|
||||||
const estSeconds = Math.max(1, Math.round((runs * Math.max(1, selected.length)) / 6000));
|
// Laufzeit-Schätzung: im Kombi-Modus laufen zwei Durchgänge.
|
||||||
|
const passes = mode === "BOTH" ? 2 : 1;
|
||||||
|
const estSeconds = Math.max(1, Math.round((runs * Math.max(1, selected.length) * passes) / 6000));
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
setRunning(true);
|
setRunning(true);
|
||||||
setResults(null);
|
clearResults();
|
||||||
setProgress({ index: 0, count: selected.length, fraction: 0 });
|
const totalPasses = passes;
|
||||||
|
setProgress({ index: 0, count: selected.length * totalPasses, fraction: 0 });
|
||||||
try {
|
try {
|
||||||
const paramByRoot: Record<string, ElementMcParams> = Object.fromEntries(
|
// Streuung (Sigma) je logischem Element -- in beiden Fällen dieselbe.
|
||||||
groups.map((g) => {
|
const sigmaByRoot: Record<string, number> = Object.fromEntries(
|
||||||
const d = draftFor(g);
|
groups.map((g) => [g.rootId, returnSigma(draftFor(g))])
|
||||||
return [g.rootId, { mean: Number(d.mean) || 0, sigma: returnSigma(d), floor: floorFor(g.category) }];
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
// EIN Seed für alle Szenarien: strukturgleiche Szenarien teilen damit dieselben
|
const floorByRoot: Record<string, number> = Object.fromEntries(groups.map((g) => [g.rootId, floorFor(g.category)]));
|
||||||
// Marktpfade, und die Unterschiede sind strukturell statt zufällig.
|
const rootByMember = new Map<string, string>();
|
||||||
|
for (const g of groups) for (const id of g.memberIds) rootByMember.set(id, g.rootId);
|
||||||
|
|
||||||
const seed = (Math.random() * 2 ** 32) >>> 0;
|
const seed = (Math.random() * 2 ** 32) >>> 0;
|
||||||
const runInputs: ScenarioRunInput[] = selected.map((s) => ({
|
const inflSig = inflationSigma(inflLevel, inflManual);
|
||||||
scenarioId: s.id,
|
let passIndex = 0;
|
||||||
name: s.name,
|
const onProg = (index: number, count: number, fraction: number) =>
|
||||||
plan: s.plan,
|
setProgress({ index: passIndex * selected.length + index, count: selected.length * totalPasses, fraction });
|
||||||
target: targets[s.id] ?? 0,
|
|
||||||
}));
|
// --- Fall 1: gegen den Planungs-Endbetrag, gewürfelt um HISTORISCHE Mittelwerte ---
|
||||||
const res = await runMonteCarloMulti(
|
if (needsHistorical) {
|
||||||
runInputs,
|
const paramByRoot: Record<string, ElementMcParams> = Object.fromEntries(
|
||||||
{
|
groups.map((g) => [g.rootId, { mean: Number(draftFor(g).mean) || 0, sigma: sigmaByRoot[g.rootId], floor: floorByRoot[g.rootId] }])
|
||||||
runs,
|
);
|
||||||
inflationMean: Number(inflMean) || 0,
|
const inputs: ScenarioRunInput[] = selected.map((s) => ({
|
||||||
inflationSigma: inflationSigma(inflLevel, inflManual),
|
scenarioId: s.id,
|
||||||
seed,
|
name: s.name,
|
||||||
elementsFor: (p) => paramsForScenario(p, groups, paramByRoot),
|
plan: s.plan,
|
||||||
},
|
target: Math.max(0, s.computed.nachlass), // Planungs-Endbetrag (read-only)
|
||||||
(index, count, fraction) => setProgress({ index, count, fraction })
|
}));
|
||||||
);
|
const res = await runMonteCarloMulti(
|
||||||
setResults(res);
|
inputs,
|
||||||
|
{
|
||||||
|
runs,
|
||||||
|
inflationMeanFor: () => Number(inflMean) || 0,
|
||||||
|
inflationSigma: inflSig,
|
||||||
|
seed,
|
||||||
|
elementsFor: (p) => paramsForScenario(p, groups, paramByRoot),
|
||||||
|
},
|
||||||
|
onProg
|
||||||
|
);
|
||||||
|
setResA(res);
|
||||||
|
passIndex += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Fall 2: gegen den Zielbetrag, gewürfelt um die GEPLANTEN Werte ---
|
||||||
|
if (needsTarget) {
|
||||||
|
const inputs: ScenarioRunInput[] = selected.map((s) => ({
|
||||||
|
scenarioId: s.id,
|
||||||
|
name: s.name,
|
||||||
|
plan: s.plan,
|
||||||
|
target: manualTarget,
|
||||||
|
}));
|
||||||
|
const res = await runMonteCarloMulti(
|
||||||
|
inputs,
|
||||||
|
{
|
||||||
|
runs,
|
||||||
|
inflationMeanFor: (p) => p.inflationRateDefault, // plan-eigene Inflation
|
||||||
|
inflationSigma: inflSig,
|
||||||
|
seed,
|
||||||
|
elementsFor: (p) => {
|
||||||
|
const ids = orderedPhaseIds(p);
|
||||||
|
const out: Record<string, ElementMcParams> = {};
|
||||||
|
for (const e of p.elements) {
|
||||||
|
if (!RETURN_BEARING.includes(e.category)) continue;
|
||||||
|
const root = rootByMember.get(e.id);
|
||||||
|
const sigma = root ? sigmaByRoot[root] : returnSigma({ mean: "", level: defaultVolatilityLevel(e.category), manualSigma: "10" });
|
||||||
|
out[e.id] = { mean: plannedReturnOf(e, ids), sigma, floor: floorFor(e.category) };
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onProg
|
||||||
|
);
|
||||||
|
setResB(res);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setRunning(false);
|
setRunning(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const overallProgress =
|
const overallProgress = progress.count > 0 ? (progress.index + progress.fraction) / progress.count : 0;
|
||||||
progress.count > 0 ? (progress.index + progress.fraction) / progress.count : 0;
|
const modeHint =
|
||||||
|
mode === "PLAN"
|
||||||
|
? "Wie wahrscheinlich hält deine Planung? Gewürfelt wird um die historischen Renditen; geprüft wird gegen den geplanten Endbetrag."
|
||||||
|
: mode === "TARGET"
|
||||||
|
? "Wie wahrscheinlich erreichst du dein Ziel? Gewürfelt wird um deine geplanten Werte; geprüft wird gegen deinen Zielbetrag."
|
||||||
|
: "Beide Fragen auf einmal: Ist die Planung realistisch (gegen den Planungs-Endbetrag, historische Renditen) UND erreichst du dein Ziel (gegen den Zielbetrag, geplante Werte)?";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
|
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
|
||||||
@@ -239,38 +304,52 @@ export function MonteCarloDialog({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Modus-Umschalter: die zwei Fragen (Roadmap Nr. 46) */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
|
||||||
|
Was möchtest du wissen?
|
||||||
|
<InfoBubble text="Zwei verschiedene Fragen: «Planung prüfen» misst, wie realistisch deine Planung ist (historische Renditen gegen den geplanten Endbetrag). «Ziel prüfen» misst, wie wahrscheinlich du mit deinen eigenen Planannahmen einen frei gewählten Zielbetrag erreichst. «Beides» rechnet beide gleichzeitig." />
|
||||||
|
</div>
|
||||||
|
<div className="inline-flex rounded-lg border border-border bg-surface-2 p-0.5 text-sm">
|
||||||
|
{MODE_OPTIONS.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setMode(m.value);
|
||||||
|
clearResults();
|
||||||
|
}}
|
||||||
|
className={`rounded-md px-3 py-1 font-medium transition-colors ${
|
||||||
|
mode === m.value ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-muted">{modeHint}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Erklärung */}
|
{/* Erklärung */}
|
||||||
<div className="rounded-xl border border-border bg-surface-2 p-4 text-xs leading-relaxed text-muted">
|
<div className="rounded-xl border border-border bg-surface-2 p-4 text-xs leading-relaxed text-muted">
|
||||||
<p className="mb-2">
|
<p className="mb-2">
|
||||||
<strong className="text-fg">Was ist das?</strong> Dein Plan rechnet mit einer festen Rendite und Inflation
|
<strong className="text-fg">Was ist das?</strong> Dein Plan rechnet mit einer festen Rendite und Inflation
|
||||||
pro Jahr. Real schwanken beide. Die Simulation würfelt <strong className="text-fg">viele tausend mögliche
|
pro Jahr. Real schwanken beide. Die Simulation würfelt <strong className="text-fg">viele tausend mögliche
|
||||||
Verläufe</strong> und zeigt, wie oft dein Plan aufgeht.
|
Verläufe</strong> und zeigt, wie oft ein Betrag erreicht wird.
|
||||||
</p>
|
|
||||||
<p className="mb-2">
|
|
||||||
<strong className="text-fg">Eingabe:</strong> je Anlage die historische Durchschnittsrendite (der Mittelpunkt,
|
|
||||||
um den gewürfelt wird) und wie stark sie schwankt; dazu dieselben Angaben für die Inflation.
|
|
||||||
<strong className="text-fg"> Ergebnis:</strong> die Wahrscheinlichkeit, dass das Geld reicht bzw. dein Zielbetrag
|
|
||||||
erreicht wird, plus ein Fächer vom pessimistischen bis zum optimistischen Fall.
|
|
||||||
</p>
|
|
||||||
<p className="mb-2">
|
|
||||||
<strong className="text-fg">Mehrere Szenarien:</strong> Du kannst unten mehrere Szenarien dieses Plans
|
|
||||||
gleichzeitig rechnen. Die historischen Annahmen werden dabei nur <strong className="text-fg">einmal</strong> erfasst
|
|
||||||
und für alle Szenarien verwendet – sonst würdest du deine Eingaben vergleichen statt der Szenarien. Alle
|
|
||||||
Szenarien laufen zudem mit demselben Zufalls-Seed, damit Unterschiede nicht blosses Rauschen sind.
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong className="text-fg">Verteilung:</strong> Renditen werden mit «fetten Rändern» gezogen (Extremcrashs so
|
<strong className="text-fg">Verteilung:</strong> Renditen werden mit «fetten Rändern» gezogen (Extremcrashs so
|
||||||
häufig wie in der Realität, nicht wie in der Glockenkurve), und alle riskanten Anlagen fallen in einem
|
häufig wie in der Realität, nicht wie in der Glockenkurve), und alle riskanten Anlagen fallen in einem
|
||||||
Marktschock gemeinsam. <em>Die Prozentzahl gilt immer nur relativ zu deinen Annahmen — sie beurteilt nicht,
|
Marktschock gemeinsam. Alle Szenarien laufen mit demselben Zufalls-Seed, damit Unterschiede nicht blosses
|
||||||
ob deine Durchschnittswerte realistisch sind.</em>
|
Rauschen sind. <em>Die Prozentzahl gilt immer nur relativ zu deinen Annahmen.</em>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Szenario-Auswahl inkl. Zielbetrag je Szenario */}
|
{/* Szenario-Auswahl */}
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
|
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
|
||||||
Szenarien
|
Szenarien
|
||||||
<InfoBubble text="Wähle, für welche Szenarien dieses Plans gerechnet werden soll. Der Zielbetrag ist je Szenario eigen und mit dem jeweils geplanten Endvermögen vorbelegt: So misst die Erfolgswahrscheinlichkeit, wie oft jedes Szenario sein eigenes Versprechen hält. Ein pessimistisch geplantes Szenario hat einen tieferen Zielbetrag und damit eine höhere Erfolgswahrscheinlichkeit." />
|
<InfoBubble text="Wähle, für welche Szenarien dieses Plans gerechnet werden soll. Die Streuung und (bei «Planung prüfen») die historischen Renditen werden nur einmal je logischem Element erfasst und für alle Szenarien verwendet." />
|
||||||
</div>
|
</div>
|
||||||
{loading && <p className="text-xs text-muted">Szenarien werden geladen…</p>}
|
{loading && <p className="text-xs text-muted">Szenarien werden geladen…</p>}
|
||||||
{loadError && <p className="text-xs text-danger">{loadError}</p>}
|
{loadError && <p className="text-xs text-danger">{loadError}</p>}
|
||||||
@@ -278,6 +357,7 @@ export function MonteCarloDialog({
|
|||||||
{scenarios.map((s) => {
|
{scenarios.map((s) => {
|
||||||
const isLoaded = !!loaded[s.id];
|
const isLoaded = !!loaded[s.id];
|
||||||
const checked = selectedIds.includes(s.id);
|
const checked = selectedIds.includes(s.id);
|
||||||
|
const nachlass = loaded[s.id]?.computed.nachlass;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={s.id}
|
key={s.id}
|
||||||
@@ -286,66 +366,87 @@ export function MonteCarloDialog({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<label className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
<label className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||||
<input
|
<input type="checkbox" checked={checked} disabled={!isLoaded} onChange={() => toggleScenario(s.id)} />
|
||||||
type="checkbox"
|
|
||||||
checked={checked}
|
|
||||||
disabled={!isLoaded}
|
|
||||||
onChange={() => toggleScenario(s.id)}
|
|
||||||
/>
|
|
||||||
<span className="truncate font-medium text-fg">{s.name}</span>
|
<span className="truncate font-medium text-fg">{s.name}</span>
|
||||||
{s.isBase && <span className="rounded bg-surface px-1.5 text-[10px] text-muted">Basis</span>}
|
{s.isBase && <span className="rounded bg-surface px-1.5 text-[10px] text-muted">Basis</span>}
|
||||||
{!isLoaded && <span className="text-[11px] text-faint">lädt…</span>}
|
{!isLoaded && <span className="text-[11px] text-faint">lädt…</span>}
|
||||||
</label>
|
</label>
|
||||||
{checked && (
|
{checked && needsHistorical && typeof nachlass === "number" && (
|
||||||
<div className="w-56">
|
<span className="text-[11px] text-muted" title="Planungs-Endbetrag dieses Szenarios (read-only)">
|
||||||
<MoneyField
|
Planungs-Endbetrag: <strong className="text-fg">{formatChf(Math.max(0, nachlass))}</strong>
|
||||||
label="Zielbetrag (Endvermögen)"
|
</span>
|
||||||
help="Vorbelegt mit dem geplanten Endvermögen DIESES Szenarios. Die Erfolgswahrscheinlichkeit misst, wie oft dieser Betrag erreicht wird."
|
|
||||||
value={targets[s.id] ?? 0}
|
|
||||||
onChange={(v) => setTargets((prev) => ({ ...prev, [s.id]: v }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{selected.length === 0 && (
|
{selected.length === 0 && <p className="mt-2 text-xs text-danger">Bitte mindestens ein Szenario auswählen.</p>}
|
||||||
<p className="mt-2 text-xs text-danger">Bitte mindestens ein Szenario auswählen.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Zielbetrag (Fall 2) */}
|
||||||
|
{needsTarget && (
|
||||||
|
<div className="sm:max-w-xs">
|
||||||
|
<MoneyField
|
||||||
|
label="Zielbetrag (Endvermögen)"
|
||||||
|
help="Dein frei gewähltes Ziel. Geprüft wird, wie oft die Planung – mit deinen eigenen Annahmen und Schwankung – mindestens diesen Betrag erreicht. Gilt für alle ausgewählten Szenarien."
|
||||||
|
value={manualTarget}
|
||||||
|
onChange={(v) => {
|
||||||
|
setManualTarget(v);
|
||||||
|
clearResults();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Inflation */}
|
{/* Inflation */}
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">Inflation (Plan-Ebene)</div>
|
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">Inflation (Plan-Ebene)</div>
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
<RequiredNumberField
|
{needsHistorical ? (
|
||||||
label="Ø Inflation letzte 20 J. (%)"
|
<RequiredNumberField
|
||||||
help={`Der Mittelpunkt, um den gewürfelt wird. Deine Planung nutzt aktuell ${plan.inflationRateDefault} %. CH langfristig ~2 %.`}
|
label="Ø Inflation letzte 20 J. (%)"
|
||||||
value={inflMean}
|
help={`Der Mittelpunkt, um den bei «Planung prüfen» gewürfelt wird. Deine Planung nutzt aktuell ${plan.inflationRateDefault} %. CH langfristig ~2 %.`}
|
||||||
onChange={setInflMean}
|
value={inflMean}
|
||||||
/>
|
onChange={(v) => {
|
||||||
|
setInflMean(v);
|
||||||
|
clearResults();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 flex items-center text-xs font-medium text-muted">
|
||||||
|
Inflation (aus dem Plan)
|
||||||
|
<InfoBubble text="Bei «Ziel prüfen» wird um die im Plan hinterlegte Inflation gewürfelt – kein historischer Wert nötig." />
|
||||||
|
</label>
|
||||||
|
<div className="w-full rounded-lg border border-dashed border-border bg-surface-2 px-2.5 py-1.5 text-sm text-muted">
|
||||||
|
{plan.inflationRateDefault} %
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<SelectField
|
<SelectField
|
||||||
label="Streuung"
|
label="Streuung"
|
||||||
help={INFLATION_HELP}
|
help={INFLATION_HELP}
|
||||||
value={inflLevel}
|
value={inflLevel}
|
||||||
onChange={(v: InflationVolatilityLevel) => setInflLevel(v)}
|
onChange={(v: InflationVolatilityLevel) => {
|
||||||
|
setInflLevel(v);
|
||||||
|
clearResults();
|
||||||
|
}}
|
||||||
options={INFLATION_LEVEL_OPTIONS}
|
options={INFLATION_LEVEL_OPTIONS}
|
||||||
/>
|
/>
|
||||||
{inflLevel === "manuell" ? (
|
{inflLevel === "manuell" ? (
|
||||||
<NumberField label="Standardabw. (%)" step={0.1} value={Number(inflManual) || 0} onChange={(v) => setInflManual(String(v))} />
|
<NumberField label="Standardabw. (%)" step={0.1} value={Number(inflManual) || 0} onChange={(v) => { setInflManual(String(v)); clearResults(); }} />
|
||||||
) : (
|
) : (
|
||||||
<ReadOnlySigma value={INFLATION_VOLATILITY_LEVELS[inflLevel]} />
|
<ReadOnlySigma value={INFLATION_VOLATILITY_LEVELS[inflLevel]} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Renditetragende Elemente -- ein Satz je logischem Element */}
|
{/* Renditetragende Elemente */}
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
|
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
|
||||||
Renditetragende Elemente
|
Renditetragende Elemente
|
||||||
{selected.length > 1 && (
|
{selected.length > 1 && (
|
||||||
<InfoBubble text="Ein Element, das in mehreren Szenarien vorkommt, erscheint hier nur einmal – erkannt über seine Herkunft beim Kopieren des Szenarios, nicht über den Namen. Elemente, die es nur in einem Szenario gibt, erscheinen mit einem entsprechenden Hinweis." />
|
<InfoBubble text="Ein Element, das in mehreren Szenarien vorkommt, erscheint hier nur einmal – erkannt über seine Herkunft beim Kopieren des Szenarios. Bei «Ziel prüfen» kommt der Mittelpunkt aus dem Plan; du wählst nur noch die Streuung." />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{groups.length === 0 && (
|
{groups.length === 0 && (
|
||||||
@@ -357,6 +458,11 @@ export function MonteCarloDialog({
|
|||||||
{groups.map((g) => {
|
{groups.map((g) => {
|
||||||
const d = draftFor(g);
|
const d = draftFor(g);
|
||||||
const partial = g.scenarioIds.length < selected.length;
|
const partial = g.scenarioIds.length < selected.length;
|
||||||
|
const planned = plannedReturnOf(
|
||||||
|
(loaded[g.scenarioIds[0]]?.plan.elements ?? []).find((e) => g.memberIds.includes(e.id)) ??
|
||||||
|
({ category: g.category, phaseValues: {} } as never),
|
||||||
|
orderedPhaseIds(loaded[g.scenarioIds[0]]?.plan ?? plan)
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div key={g.rootId} className="rounded-xl border border-border bg-surface-2 p-3">
|
<div key={g.rootId} className="rounded-xl border border-border bg-surface-2 p-3">
|
||||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
|
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
|
||||||
@@ -369,12 +475,24 @@ export function MonteCarloDialog({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
<RequiredNumberField
|
{needsHistorical ? (
|
||||||
label="Ø Rendite letzte 20 J. (%)"
|
<RequiredNumberField
|
||||||
help="Der Mittelpunkt, um den gewürfelt wird – die realistische Rendite dieser Anlage, nicht deine (evtl. optimistische) Planannahme."
|
label="Ø Rendite letzte 20 J. (%)"
|
||||||
value={d.mean}
|
help="Der Mittelpunkt für «Planung prüfen» – die realistische Rendite dieser Anlage, nicht deine (evtl. optimistische) Planannahme."
|
||||||
onChange={(v) => setDraft(g.rootId, g, { mean: v })}
|
value={d.mean}
|
||||||
/>
|
onChange={(v) => setDraft(g.rootId, g, { mean: v })}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 flex items-center text-xs font-medium text-muted">
|
||||||
|
Geplante Rendite (aus dem Plan)
|
||||||
|
<InfoBubble text="Bei «Ziel prüfen» ist der Mittelpunkt die im Plan hinterlegte Rendite dieses Elements (erste Phase). Kein historischer Wert nötig." />
|
||||||
|
</label>
|
||||||
|
<div className="w-full rounded-lg border border-dashed border-border bg-surface-2 px-2.5 py-1.5 text-sm text-muted">
|
||||||
|
{planned} %
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<SelectField
|
<SelectField
|
||||||
label="Streuung"
|
label="Streuung"
|
||||||
help={RETURN_HELP}
|
help={RETURN_HELP}
|
||||||
@@ -383,12 +501,7 @@ export function MonteCarloDialog({
|
|||||||
options={RETURN_LEVEL_OPTIONS}
|
options={RETURN_LEVEL_OPTIONS}
|
||||||
/>
|
/>
|
||||||
{d.level === "manuell" ? (
|
{d.level === "manuell" ? (
|
||||||
<NumberField
|
<NumberField label="Standardabw. (%)" step={0.5} value={Number(d.manualSigma) || 0} onChange={(v) => setDraft(g.rootId, g, { manualSigma: String(v) })} />
|
||||||
label="Standardabw. (%)"
|
|
||||||
step={0.5}
|
|
||||||
value={Number(d.manualSigma) || 0}
|
|
||||||
onChange={(v) => setDraft(g.rootId, g, { manualSigma: String(v) })}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<ReadOnlySigma value={RETURN_VOLATILITY_LEVELS[d.level]} />
|
<ReadOnlySigma value={RETURN_VOLATILITY_LEVELS[d.level]} />
|
||||||
)}
|
)}
|
||||||
@@ -408,7 +521,7 @@ export function MonteCarloDialog({
|
|||||||
<SelectField
|
<SelectField
|
||||||
label="Anzahl Simulationen"
|
label="Anzahl Simulationen"
|
||||||
value={String(runs)}
|
value={String(runs)}
|
||||||
onChange={(v: string) => setRuns(Number(v))}
|
onChange={(v: string) => { setRuns(Number(v)); clearResults(); }}
|
||||||
options={[
|
options={[
|
||||||
{ value: "1000", label: "1'000 (schnell)" },
|
{ value: "1000", label: "1'000 (schnell)" },
|
||||||
{ value: "5000", label: "5'000" },
|
{ value: "5000", label: "5'000" },
|
||||||
@@ -416,12 +529,19 @@ export function MonteCarloDialog({
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-[11px] text-faint">
|
<p className="mt-1 text-[11px] text-faint">
|
||||||
{selected.length > 1 ? `${selected.length} Szenarien · ` : ""}geschätzt ~{estSeconds} s
|
{selected.length > 1 ? `${selected.length} Szenarien · ` : ""}
|
||||||
|
{passes > 1 ? "2 Durchgänge · " : ""}geschätzt ~{estSeconds} s
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{missing && <p className="text-xs text-danger">Bitte alle Pflichtfelder (Ø-Werte) ausfüllen, um die Simulation zu starten.</p>}
|
{missing && (
|
||||||
|
<p className="text-xs text-danger">
|
||||||
|
{needsHistorical && missingHist
|
||||||
|
? "Bitte alle Pflichtfelder (historische Ø-Werte) ausfüllen."
|
||||||
|
: "Bitte einen Zielbetrag grösser 0 angeben."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
@@ -430,9 +550,7 @@ export function MonteCarloDialog({
|
|||||||
onClick={run}
|
onClick={run}
|
||||||
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
|
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{running
|
{running ? `Berechne… ${Math.round(overallProgress * 100)} %` : "Berechnung jetzt durchführen"}
|
||||||
? `Berechne… ${progress.count > 1 ? `Szenario ${progress.index + 1}/${progress.count} · ` : ""}${Math.round(overallProgress * 100)} %`
|
|
||||||
: "Berechnung jetzt durchführen"}
|
|
||||||
</button>
|
</button>
|
||||||
{running && (
|
{running && (
|
||||||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-2">
|
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-2">
|
||||||
@@ -441,12 +559,15 @@ export function MonteCarloDialog({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{results && results.length > 0 && (
|
{(resA || resB) && (
|
||||||
<MonteCarloResults
|
<MonteCarloResults
|
||||||
results={results}
|
mode={mode}
|
||||||
|
resA={resA}
|
||||||
|
resB={resB}
|
||||||
|
manualTarget={manualTarget}
|
||||||
detPoints={
|
detPoints={
|
||||||
results.length === 1 && loaded[results[0].scenarioId]
|
resA && resA.length === 1 && loaded[resA[0].scenarioId]
|
||||||
? detPointsOf(loaded[results[0].scenarioId].plan, loaded[results[0].scenarioId].computed)
|
? detPointsOf(loaded[resA[0].scenarioId].plan, loaded[resA[0].scenarioId].computed)
|
||||||
: []
|
: []
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -470,162 +591,191 @@ function ReadOnlySigma({ value }: { value: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deutungstext Fall 1: wie realistisch ist die Planung? Weiche Formulierung, weil schon eine
|
||||||
|
// faire Planung wegen des Volatilitäts-Drags leicht unter 50 % landet.
|
||||||
|
function planVerdict(prob: number): { text: string; tone: "danger" | "success" | "muted" } {
|
||||||
|
const pct = Math.round(prob * 100);
|
||||||
|
if (prob < 0.5)
|
||||||
|
return {
|
||||||
|
text: `Deine Planung ist tendenziell zu optimistisch: Der geplante Endbetrag wird unter Berücksichtigung der historischen Renditen nur in ${pct} % der Fälle erreicht.`,
|
||||||
|
tone: "danger",
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
text: `Deine Planung ist tendenziell realistisch bis vorsichtig: Der geplante Endbetrag wird unter Berücksichtigung der historischen Renditen in ${pct} % der Fälle erreicht.`,
|
||||||
|
tone: "success",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deutungstext Fall 2: erreiche ich mein Ziel?
|
||||||
|
function targetVerdict(prob: number): { text: string; tone: "danger" | "success" | "muted" } {
|
||||||
|
const pct = Math.round(prob * 100);
|
||||||
|
if (prob > 0.5)
|
||||||
|
return {
|
||||||
|
text: `Mit deiner Planung solltest du dein Ziel tendenziell erreichen: Der gewünschte Zielbetrag wird in ${pct} % der Fälle erreicht.`,
|
||||||
|
tone: "success",
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
text: `Mit deiner Planung erreichst du dein Ziel tendenziell nicht: Der gewünschte Zielbetrag wird nur in ${pct} % der Fälle erreicht.`,
|
||||||
|
tone: "danger",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function VerdictCard({ title, prob, verdict }: { title: string; prob: number; verdict: { text: string; tone: "danger" | "success" | "muted" } }) {
|
||||||
|
const color = verdict.tone === "danger" ? "text-danger" : verdict.tone === "success" ? "text-success" : "text-fg";
|
||||||
|
const border = verdict.tone === "danger" ? "border-danger bg-danger-soft" : "border-border bg-surface-2";
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl border p-3 ${border}`}>
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide text-faint">{title}</span>
|
||||||
|
<span className={`text-2xl font-semibold ${color}`}>{Math.round(prob * 100)} %</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-muted">{verdict.text}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function MonteCarloResults({
|
function MonteCarloResults({
|
||||||
results,
|
mode,
|
||||||
|
resA,
|
||||||
|
resB,
|
||||||
|
manualTarget,
|
||||||
detPoints,
|
detPoints,
|
||||||
}: {
|
}: {
|
||||||
results: ScenarioMcResult[];
|
mode: McMode;
|
||||||
|
resA: ScenarioMcResult[] | null; // Fall 1 (gegen Planungs-Endbetrag)
|
||||||
|
resB: ScenarioMcResult[] | null; // Fall 2 (gegen Zielbetrag)
|
||||||
|
manualTarget: number;
|
||||||
detPoints: { age: number; det: number }[];
|
detPoints: { age: number; det: number }[];
|
||||||
}) {
|
}) {
|
||||||
const single = results.length === 1;
|
// Der Fächer stammt aus Fall 1 (historische Renditen = das ehrliche Risikobild). Fehlt Fall 1
|
||||||
|
// (reiner Ziel-Modus), nehmen wir Fall 2.
|
||||||
|
const fanRes = resA ?? resB;
|
||||||
|
const single = !!fanRes && fanRes.length === 1;
|
||||||
|
|
||||||
// Einzelnes Szenario: der gewohnte Fächer (Band + Median + Planungslinie).
|
|
||||||
const singleData = useMemo(() => {
|
const singleData = useMemo(() => {
|
||||||
if (!single) return [];
|
if (!fanRes || !single) return [];
|
||||||
return results[0].bands.map((b) => ({
|
return fanRes[0].bands.map((b) => ({
|
||||||
age: b.age,
|
age: b.age,
|
||||||
band: [b.p10, b.p90] as [number, number],
|
band: [b.p10, b.p90] as [number, number],
|
||||||
median: b.p50,
|
median: b.p50,
|
||||||
det: detPoints.find((d) => d.age === b.age)?.det ?? null,
|
det: detPoints.find((d) => d.age === b.age)?.det ?? null,
|
||||||
}));
|
}));
|
||||||
}, [single, results, detPoints]);
|
}, [fanRes, single, detPoints]);
|
||||||
|
|
||||||
// Mehrere Szenarien: nur die Median-Linien -- übereinandergelegte Bänder wären Farbbrei.
|
|
||||||
const compareData = useMemo(() => {
|
const compareData = useMemo(() => {
|
||||||
if (single) return [];
|
if (!fanRes || single) return [];
|
||||||
const ages = Array.from(new Set(results.flatMap((r) => r.bands.map((b) => b.age)))).sort((a, b) => a - b);
|
const ages = Array.from(new Set(fanRes.flatMap((r) => r.bands.map((b) => b.age)))).sort((a, b) => a - b);
|
||||||
return ages.map((age) => {
|
return ages.map((age) => {
|
||||||
const row: Record<string, number | null> = { age };
|
const row: Record<string, number | null> = { age };
|
||||||
for (const r of results) row[r.scenarioId] = r.bands.find((b) => b.age === age)?.p50 ?? null;
|
for (const r of fanRes) row[r.scenarioId] = r.bands.find((b) => b.age === age)?.p50 ?? null;
|
||||||
return row;
|
return row;
|
||||||
});
|
});
|
||||||
}, [single, results]);
|
}, [fanRes, single]);
|
||||||
|
|
||||||
|
const byId = (arr: ScenarioMcResult[] | null, id: string) => arr?.find((r) => r.scenarioId === id) ?? null;
|
||||||
|
const rows = (resA ?? resB)!;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 border-t border-border pt-4">
|
<div className="flex flex-col gap-4 border-t border-border pt-4">
|
||||||
{/* Vergleichstabelle -- das eigentliche Vergleichsinstrument */}
|
{/* Deutungstexte -- nur sinnvoll bei einem einzelnen Szenario; bei mehreren zählt die Tabelle. */}
|
||||||
|
{single && (
|
||||||
|
<div className={`grid grid-cols-1 gap-3 ${mode === "BOTH" ? "sm:grid-cols-2" : ""}`}>
|
||||||
|
{resA && <VerdictCard title="Realismus der Planung" prob={resA[0].successProbability} verdict={planVerdict(resA[0].successProbability)} />}
|
||||||
|
{resB && <VerdictCard title="Zielerreichung" prob={resB[0].successProbability} verdict={targetVerdict(resB[0].successProbability)} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Vergleichstabelle */}
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-1 flex items-center text-xs font-semibold text-fg">
|
<div className="mb-1 flex items-center text-xs font-semibold text-fg">
|
||||||
Ergebnis je Szenario
|
Ergebnis je Szenario
|
||||||
{!single && (
|
<InfoBubble text="«Planung erreicht» = P(geplanter Endbetrag wird erreicht) unter historischen Renditen. «Ziel erreicht» = P(Zielbetrag wird erreicht) unter den geplanten Werten. Ruin ist zielunabhängig und stammt aus dem realistischen (historischen) Durchgang." />
|
||||||
<InfoBubble text="Die Erfolgswahrscheinlichkeit bezieht sich je Szenario auf dessen EIGENEN Zielbetrag (Spalte Ziel) – sie misst, wie oft ein Szenario sein eigenes Versprechen hält. Die Ruinwahrscheinlichkeit ist zielbetrags-unabhängig und damit direkt vergleichbar." />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto rounded-xl border border-border">
|
<div className="overflow-x-auto rounded-xl border border-border">
|
||||||
<table className="w-full border-collapse text-sm">
|
<table className="w-full border-collapse text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-surface-2 text-xs text-faint">
|
<tr className="bg-surface-2 text-xs text-faint">
|
||||||
<th className="px-3 py-2 text-left font-semibold">Szenario</th>
|
<th className="px-3 py-2 text-left font-semibold">Szenario</th>
|
||||||
<th className="px-3 py-2 text-right font-semibold">Ziel</th>
|
{resA && <th className="px-3 py-2 text-right font-semibold">Planung erreicht</th>}
|
||||||
<th className="px-3 py-2 text-right font-semibold">Erfolg</th>
|
{resB && <th className="px-3 py-2 text-right font-semibold">Ziel erreicht</th>}
|
||||||
<th className="px-3 py-2 text-right font-semibold">Ruin</th>
|
<th className="px-3 py-2 text-right font-semibold">Ruin</th>
|
||||||
<th className="px-3 py-2 text-right font-semibold">Pessimistisch (10 %)</th>
|
<th className="px-3 py-2 text-right font-semibold">Pessim. (10 %)</th>
|
||||||
<th className="px-3 py-2 text-right font-semibold">Median</th>
|
<th className="px-3 py-2 text-right font-semibold">Median</th>
|
||||||
<th className="px-3 py-2 text-right font-semibold">Optimistisch (90 %)</th>
|
<th className="px-3 py-2 text-right font-semibold">Optim. (90 %)</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{results.map((r, i) => (
|
{rows.map((r, i) => {
|
||||||
<tr key={r.scenarioId} className="border-t border-border">
|
const a = byId(resA, r.scenarioId);
|
||||||
<td className="px-3 py-2">
|
const b = byId(resB, r.scenarioId);
|
||||||
<span className="flex items-center gap-2 font-medium text-fg">
|
const fan = a ?? b!;
|
||||||
{!single && (
|
return (
|
||||||
<span
|
<tr key={r.scenarioId} className="border-t border-border">
|
||||||
className="inline-block h-2 w-2 shrink-0 rounded-full"
|
<td className="px-3 py-2">
|
||||||
style={{ backgroundColor: PALETTE[i % PALETTE.length] }}
|
<span className="flex items-center gap-2 font-medium text-fg">
|
||||||
/>
|
{!single && <span className="inline-block h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: PALETTE[i % PALETTE.length] }} />}
|
||||||
)}
|
{r.name}
|
||||||
{r.name}
|
</span>
|
||||||
</span>
|
</td>
|
||||||
</td>
|
{resA && <td className="px-3 py-2 text-right font-semibold text-success">{a ? `${Math.round(a.successProbability * 100)} %` : "–"}</td>}
|
||||||
<td className="px-3 py-2 text-right text-muted">{formatChf(r.target)}</td>
|
{resB && <td className="px-3 py-2 text-right font-semibold text-accent">{b ? `${Math.round(b.successProbability * 100)} %` : "–"}</td>}
|
||||||
<td className="px-3 py-2 text-right font-semibold text-success">
|
<td className="px-3 py-2 text-right font-semibold text-danger">{Math.round(fan.ruinProbability * 100)} %</td>
|
||||||
{Math.round(r.successProbability * 100)} %
|
<td className="px-3 py-2 text-right text-muted">{formatChf(fan.finalWealthP10)}</td>
|
||||||
</td>
|
<td className="px-3 py-2 text-right text-fg">{formatChf(fan.finalWealthMedian)}</td>
|
||||||
<td className="px-3 py-2 text-right font-semibold text-danger">
|
<td className="px-3 py-2 text-right text-muted">{formatChf(fan.finalWealthP90)}</td>
|
||||||
{Math.round(r.ruinProbability * 100)} %
|
</tr>
|
||||||
</td>
|
);
|
||||||
<td className="px-3 py-2 text-right text-muted">{formatChf(r.finalWealthP10)}</td>
|
})}
|
||||||
<td className="px-3 py-2 text-right text-fg">{formatChf(r.finalWealthMedian)}</td>
|
|
||||||
<td className="px-3 py-2 text-right text-muted">{formatChf(r.finalWealthP90)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-[11px] text-faint">
|
<p className="mt-1 text-[11px] text-faint">
|
||||||
{results[0].runs.toLocaleString("de-CH")} Läufe je Szenario
|
{rows[0].runs.toLocaleString("de-CH")} Läufe je Szenario · gemeinsamer Zufalls-Seed
|
||||||
{!single && " · gemeinsamer Zufalls-Seed"}
|
{resB ? ` · Zielbetrag ${formatChf(manualTarget)}` : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* Fächer */}
|
||||||
<div className="mb-1 text-xs font-semibold text-fg">
|
{fanRes && (
|
||||||
{single ? "Vermögensfächer nach Alter (nominal)" : "Vermögensverlauf im Median nach Alter (nominal)"}
|
<div>
|
||||||
|
<div className="mb-1 text-xs font-semibold text-fg">
|
||||||
|
{single ? "Vermögensfächer nach Alter (nominal)" : "Vermögensverlauf im Median nach Alter (nominal)"}
|
||||||
|
{resA ? " · historische Renditen" : " · geplante Werte"}
|
||||||
|
</div>
|
||||||
|
<p className="mb-2 text-[11px] text-muted">
|
||||||
|
{single
|
||||||
|
? "Das Band reicht vom pessimistischen (10 %) bis zum optimistischen (90 %) Fall, die dunkle Linie ist der Median. Die gestrichelte Linie ist deine deterministische Planung – sie liegt meist leicht über dem Median (Schwankung frisst Rendite)."
|
||||||
|
: "Je Szenario die mittlere Entwicklung (Median). Wähle ein einzelnes Szenario, um den vollen Fächer zu sehen."}
|
||||||
|
</p>
|
||||||
|
<div className="h-72 w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
{single ? (
|
||||||
|
<ComposedChart data={singleData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||||
|
<XAxis dataKey="age" tick={{ fontSize: 11 }} tickFormatter={(v) => `${v} J.`} />
|
||||||
|
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} />
|
||||||
|
<Tooltip formatter={(v, name) => [typeof v === "number" ? formatChf(v) : v, name]} labelFormatter={(v) => `Alter ${v}`} />
|
||||||
|
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||||
|
<Area dataKey="band" name="10–90 % Band" stroke="none" fill="var(--accent)" fillOpacity={0.16} isAnimationActive={false} />
|
||||||
|
<Line dataKey="median" name="Median" stroke="var(--accent)" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||||
|
<Line dataKey="det" name="Planung (deterministisch)" stroke="var(--muted)" strokeWidth={1.5} strokeDasharray="5 3" dot={false} connectNulls isAnimationActive={false} />
|
||||||
|
</ComposedChart>
|
||||||
|
) : (
|
||||||
|
<LineChart data={compareData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||||
|
<XAxis dataKey="age" type="number" domain={["dataMin", "dataMax"]} tick={{ fontSize: 11 }} tickFormatter={(v) => `${v} J.`} />
|
||||||
|
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} />
|
||||||
|
<Tooltip formatter={(v, name) => [typeof v === "number" ? formatChf(v) : v, name]} labelFormatter={(v) => `Alter ${v}`} />
|
||||||
|
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||||
|
{fanRes.map((r, i) => (
|
||||||
|
<Line key={r.scenarioId} dataKey={r.scenarioId} name={r.name} stroke={PALETTE[i % PALETTE.length]} strokeWidth={2} dot={false} connectNulls isAnimationActive={false} />
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
)}
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-2 text-[11px] text-muted">
|
)}
|
||||||
{single ? (
|
|
||||||
<>
|
|
||||||
Das Band reicht vom pessimistischen (10 %) bis zum optimistischen (90 %) Fall, die dunkle Linie ist der
|
|
||||||
Median. Die gestrichelte Linie ist deine deterministische Planung – sie liegt meist leicht über dem Median
|
|
||||||
(Schwankung frisst Rendite).
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Je Szenario die mittlere Entwicklung (Median). Die vollständigen 10–90 %-Bänder werden hier bewusst nicht
|
|
||||||
übereinandergelegt – wähle ein einzelnes Szenario, um den Fächer zu sehen.
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<div className="h-72 w-full">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
{single ? (
|
|
||||||
<ComposedChart data={singleData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
|
||||||
<XAxis dataKey="age" tick={{ fontSize: 11 }} tickFormatter={(v) => `${v} J.`} />
|
|
||||||
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} />
|
|
||||||
<Tooltip
|
|
||||||
formatter={(v, name) => [typeof v === "number" ? formatChf(v) : v, name]}
|
|
||||||
labelFormatter={(v) => `Alter ${v}`}
|
|
||||||
/>
|
|
||||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
|
||||||
<Area dataKey="band" name="10–90 % Band" stroke="none" fill="var(--accent)" fillOpacity={0.16} isAnimationActive={false} />
|
|
||||||
<Line dataKey="median" name="Median" stroke="var(--accent)" strokeWidth={2} dot={false} isAnimationActive={false} />
|
|
||||||
<Line dataKey="det" name="Planung (deterministisch)" stroke="var(--muted)" strokeWidth={1.5} strokeDasharray="5 3" dot={false} connectNulls isAnimationActive={false} />
|
|
||||||
</ComposedChart>
|
|
||||||
) : (
|
|
||||||
<LineChart data={compareData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="age"
|
|
||||||
type="number"
|
|
||||||
domain={["dataMin", "dataMax"]}
|
|
||||||
tick={{ fontSize: 11 }}
|
|
||||||
tickFormatter={(v) => `${v} J.`}
|
|
||||||
/>
|
|
||||||
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} />
|
|
||||||
<Tooltip
|
|
||||||
formatter={(v, name) => [typeof v === "number" ? formatChf(v) : v, name]}
|
|
||||||
labelFormatter={(v) => `Alter ${v}`}
|
|
||||||
/>
|
|
||||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
|
||||||
{results.map((r, i) => (
|
|
||||||
<Line
|
|
||||||
key={r.scenarioId}
|
|
||||||
dataKey={r.scenarioId}
|
|
||||||
name={r.name}
|
|
||||||
stroke={PALETTE[i % PALETTE.length]}
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
connectNulls
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</LineChart>
|
|
||||||
)}
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import {
|
|||||||
buildElementGroups,
|
buildElementGroups,
|
||||||
defaultVolatilityLevel,
|
defaultVolatilityLevel,
|
||||||
paramsForScenario,
|
paramsForScenario,
|
||||||
|
plannedReturnOf,
|
||||||
resolveRootElementId,
|
resolveRootElementId,
|
||||||
runMonteCarlo,
|
runMonteCarlo,
|
||||||
runMonteCarloMulti,
|
runMonteCarloMulti,
|
||||||
RETURN_VOLATILITY_LEVELS,
|
RETURN_VOLATILITY_LEVELS,
|
||||||
type MonteCarloParams,
|
type MonteCarloParams,
|
||||||
} from "@/lib/montecarlo";
|
} from "@/lib/montecarlo";
|
||||||
|
import type { ElementInput } from "@/lib/types";
|
||||||
import type { PlanInput } from "@/lib/types";
|
import type { PlanInput } from "@/lib/types";
|
||||||
|
|
||||||
// Plan: 40-jährig, 1 Phase 10 Jahre, ein Sonstiges Vermögen 100'000 @ 5 %, 2 % Inflation.
|
// Plan: 40-jährig, 1 Phase 10 Jahre, ein Sonstiges Vermögen 100'000 @ 5 %, 2 % Inflation.
|
||||||
@@ -226,7 +228,7 @@ describe("Monte Carlo: mehrere Szenarien", () => {
|
|||||||
],
|
],
|
||||||
{
|
{
|
||||||
runs: 2000,
|
runs: 2000,
|
||||||
inflationMean: 2,
|
inflationMeanFor: () => 2,
|
||||||
inflationSigma: 0,
|
inflationSigma: 0,
|
||||||
seed: 4242,
|
seed: 4242,
|
||||||
elementsFor: (p) => paramsForScenario(p, groups, byRoot),
|
elementsFor: (p) => paramsForScenario(p, groups, byRoot),
|
||||||
@@ -242,4 +244,62 @@ describe("Monte Carlo: mehrere Szenarien", () => {
|
|||||||
expect(rBase.scenarioId).toBe("base");
|
expect(rBase.scenarioId).toBe("base");
|
||||||
expect(rOpt.target).toBe(targetOpt);
|
expect(rOpt.target).toBe(targetOpt);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("Inflation je Szenario: Fall 2 nutzt die plan-eigene Inflation", async () => {
|
||||||
|
// Zwei Szenarien mit unterschiedlicher Inflation; bei Streuung 0 muss jedes seine eigene
|
||||||
|
// Inflation verwenden (nicht eine gemeinsame). Reines Vermoegen ohne Rendite -> der
|
||||||
|
// Realwert bleibt gleich, aber der Endwert NOMINAL haengt an der Inflation nicht (Cash 0,
|
||||||
|
// keine nominalen Ausgaben) -- deshalb pruefen wir ueber die Ausgaben: hoehere Inflation
|
||||||
|
// laesst das Vermoegen staerker schmelzen.
|
||||||
|
const low = basePlan();
|
||||||
|
const high: PlanInput = { ...basePlan(), id: "high", inflationRateDefault: 6 };
|
||||||
|
high.elements = high.elements.map((e) => ({ ...e, id: `${e.id}-h` }));
|
||||||
|
// Ausgaben, damit die Inflation ueberhaupt wirkt.
|
||||||
|
const withExpense = (p: PlanInput, sfx: string): PlanInput => ({
|
||||||
|
...p,
|
||||||
|
elements: [
|
||||||
|
...p.elements,
|
||||||
|
{ id: `exp-${sfx}`, category: "EXPENSE", name: "A", ownerRole: "HOUSEHOLD", orderIndex: 9, phaseValues: { p1: { amount: 5000, teuerungsausgleich: 0 } }, transitionValues: {} },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const lo = withExpense(low, "l");
|
||||||
|
const hi = withExpense(high, "h");
|
||||||
|
|
||||||
|
const [rLo, rHi] = await runMonteCarloMulti(
|
||||||
|
[
|
||||||
|
{ scenarioId: "lo", name: "2%", plan: lo, target: 0 },
|
||||||
|
{ scenarioId: "hi", name: "6%", plan: hi, target: 0 },
|
||||||
|
],
|
||||||
|
{
|
||||||
|
runs: 200,
|
||||||
|
inflationMeanFor: (p) => p.inflationRateDefault, // Fall 2: plan-eigene Inflation
|
||||||
|
inflationSigma: 0,
|
||||||
|
seed: 1,
|
||||||
|
elementsFor: () => ({}), // ohne renditetragende Elemente
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// Hoehere Inflation -> hoehere nominale Ausgaben -> tieferes Endvermoegen.
|
||||||
|
expect(rHi.finalWealthMedian).toBeLessThan(rLo.finalWealthMedian);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("plannedReturnOf: erste gesetzte Phasenrendite, Wertsteigerung bei Immobilien", () => {
|
||||||
|
const orderedIds = ["p1", "p2"];
|
||||||
|
const asset: ElementInput = {
|
||||||
|
id: "a", category: "OTHER_ASSET", name: "ETF", ownerRole: "HOUSEHOLD", orderIndex: 1,
|
||||||
|
phaseValues: { p1: { startValue: 100000, expectedReturn: 5 }, p2: { expectedReturn: 7 } },
|
||||||
|
transitionValues: {},
|
||||||
|
};
|
||||||
|
expect(plannedReturnOf(asset, orderedIds)).toBe(5); // erste Phase
|
||||||
|
|
||||||
|
const re: ElementInput = {
|
||||||
|
id: "r", category: "REAL_ESTATE", name: "Haus", ownerRole: "HOUSEHOLD", orderIndex: 2,
|
||||||
|
phaseValues: { p1: { purchasePrice: 800000, valueGrowth: 1.5 } },
|
||||||
|
transitionValues: {},
|
||||||
|
};
|
||||||
|
expect(plannedReturnOf(re, orderedIds)).toBe(1.5); // Wertsteigerung statt expectedReturn
|
||||||
|
|
||||||
|
// Kein Wert gesetzt -> 0.
|
||||||
|
const leer: ElementInput = { id: "x", category: "OTHER_ASSET", name: "x", ownerRole: "HOUSEHOLD", orderIndex: 3, phaseValues: {}, transitionValues: {} };
|
||||||
|
expect(plannedReturnOf(leer, orderedIds)).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+20
-3
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import { computePlan, type PlanSample } from "@/lib/calculations";
|
import { computePlan, type PlanSample } from "@/lib/calculations";
|
||||||
import type { ElementCategory } from "@/lib/elements";
|
import type { ElementCategory } from "@/lib/elements";
|
||||||
import type { PlanInput } from "@/lib/types";
|
import type { ElementInput, PlanInput } from "@/lib/types";
|
||||||
|
|
||||||
// Standardabweichungen (annualisiert, in %) hinter den Streuungsstufen. Recherchiert und
|
// Standardabweichungen (annualisiert, in %) hinter den Streuungsstufen. Recherchiert und
|
||||||
// gerundet: Anleihen ~6 %, globale Aktien ~15-18 %, Schweizer Immobilien(fonds) ~2 %,
|
// gerundet: Anleihen ~6 %, globale Aktien ~15-18 %, Schweizer Immobilien(fonds) ~2 %,
|
||||||
@@ -195,7 +195,9 @@ export async function runMonteCarloMulti(
|
|||||||
scenarios: ScenarioRunInput[],
|
scenarios: ScenarioRunInput[],
|
||||||
common: {
|
common: {
|
||||||
runs: number;
|
runs: number;
|
||||||
inflationMean: number;
|
// Inflations-Mittelwert je Szenario (Fall 1: historischer Wert für alle; Fall 2: die
|
||||||
|
// plan-eigene Inflation, die je Szenario abweichen kann).
|
||||||
|
inflationMeanFor: (plan: PlanInput) => number;
|
||||||
inflationSigma: number;
|
inflationSigma: number;
|
||||||
seed: number;
|
seed: number;
|
||||||
elementsFor: (plan: PlanInput) => Record<string, ElementMcParams>;
|
elementsFor: (plan: PlanInput) => Record<string, ElementMcParams>;
|
||||||
@@ -209,7 +211,7 @@ export async function runMonteCarloMulti(
|
|||||||
s.plan,
|
s.plan,
|
||||||
{
|
{
|
||||||
runs: common.runs,
|
runs: common.runs,
|
||||||
inflationMean: common.inflationMean,
|
inflationMean: common.inflationMeanFor(s.plan),
|
||||||
inflationSigma: common.inflationSigma,
|
inflationSigma: common.inflationSigma,
|
||||||
elements: common.elementsFor(s.plan),
|
elements: common.elementsFor(s.plan),
|
||||||
target: s.target,
|
target: s.target,
|
||||||
@@ -222,6 +224,21 @@ export async function runMonteCarloMulti(
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Repräsentative GEPLANTE Rendite eines Elements für die Simulation "gegen den Zielbetrag"
|
||||||
|
// (Fall 2): der Mittelpunkt ist dann nicht ein historischer Wert, sondern der im Plan
|
||||||
|
// hinterlegte. Genommen wird die erste Phase, in der ein Wert gesetzt ist (bei Immobilien
|
||||||
|
// die Wertsteigerung). Unterschiedliche Renditen über die Phasen werden bewusst auf einen
|
||||||
|
// Wert reduziert -- dieselbe Körnung wie bei der historischen Rendite (ein Wert je Element).
|
||||||
|
export function plannedReturnOf(element: ElementInput, orderedPhaseIds: string[]): number {
|
||||||
|
for (const pid of orderedPhaseIds) {
|
||||||
|
const pd = element.phaseValues[pid];
|
||||||
|
if (!pd) continue;
|
||||||
|
const v = element.category === "REAL_ESTATE" ? pd.valueGrowth : pd.expectedReturn;
|
||||||
|
if (typeof v === "number") return v;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Zufallszahlen (seedbar, damit ein Lauf reproduzierbar ist) ---
|
// --- Zufallszahlen (seedbar, damit ein Lauf reproduzierbar ist) ---
|
||||||
|
|
||||||
function mulberry32(seed: number): () => number {
|
function mulberry32(seed: number): () => number {
|
||||||
|
|||||||
Reference in New Issue
Block a user