From 50e98122cf216fa26f20b2a5bd8b3296cf47a385 Mon Sep 17 00:00:00 2001 From: kelle Date: Sun, 19 Jul 2026 20:46:50 +0200 Subject: [PATCH] Monte-Carlo: zwei Welten, vier Faelle statt widerspruechlicher Modi Zwei Laeufe (historische / geplante Renditen, gemeinsamer Seed), aus jeder Verteilung beide Schwellen abgelesen. Fall 1 und Fall 3 stammen damit aus derselben Verteilung -- ein tieferes Ziel kann nie unwahrscheinlicher sein. Nullpunkt fuer das Urteil ist Fall 2 (nicht 50 %), Toleranzband +/- 5 pp. Neu: MonteCarloResult.finalWealthSorted, probabilityAtLeast. Spezifikation 0.17 (4.12.7 und 9.26 neu gefasst), 3 Tests (121 -> 124). Co-Authored-By: Claude Opus 4.8 --- SPEZIFIKATION.md | 130 +++++--- src/components/MonteCarloDialog.tsx | 458 +++++++++++++--------------- src/lib/montecarlo.test.ts | 48 +++ src/lib/montecarlo.ts | 19 ++ 4 files changed, 373 insertions(+), 282 deletions(-) diff --git a/SPEZIFIKATION.md b/SPEZIFIKATION.md index a04c3cb..4a6c63e 100644 --- a/SPEZIFIKATION.md +++ b/SPEZIFIKATION.md @@ -4,7 +4,7 @@ | | | |---|---| | **Dokument** | Funktionale und Technische Spezifikation FPT | -| **Version** | 0.16 | +| **Version** | 0.17 | | **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.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). | | 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. | @@ -1974,44 +1975,66 @@ aus. Die Schätzung skaliert mit der Anzahl Szenarien. Referenz: `src/lib/montecarlo.ts` (`resolveRootElementId`, `buildElementGroups`, `paramsForScenario`, `runMonteCarloMulti`), `src/components/MonteCarloDialog.tsx`. -### 4.12.7 Zwei Fragestellungen: Planung prüfen vs. Ziel prüfen +### 4.12.7 Zwei Welten, vier Fälle -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. +Die Simulation läuft **immer zweimal** – einmal um die **historischen** Renditen, einmal um die +**geplanten** Werte des Plans. Aus jeder der beiden Verteilungen werden **zwei Schwellen** +abgelesen: der **Plan-Endbetrag** (read-only, der geplante Nachlass des Szenarios) und der +**Zielbetrag** (manuell, einer für alle Szenarien). Das ergibt vier Zahlen: -| | **Planung prüfen** (Fall 1) | **Ziel prüfen** (Fall 2) | +| | Schwelle **Plan-Endbetrag** | Schwelle **Zielbetrag** | |---|---|---| -| 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 | +| Welt **historisch** (Eingabe) | **Fall 1** – Realismus der Planung | **Fall 3** – Ziel, wenn der Markt wie früher läuft | +| Welt **geplant** (aus dem Plan) | **Fall 2** – Nullpunkt für das Urteil | **Fall 4** – Ziel, wenn der Markt wie geplant läuft | -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. +Das baut auf der **Trennung der zwei Renditezahlen** aus [4.12.3](#4123-zwei-renditezahlen--und-warum) +auf. Die **Streuung ist in beiden Welten dieselbe**; nur der Mittelpunkt unterscheidet sie. In der +geplanten Welt kommt der Mittelpunkt je Element aus `plannedReturnOf` (erste Phase, bei Immobilien +die Wertsteigerung) und die Inflation aus `plan.inflationRateDefault`. -**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. +**Warum zwei Läufe und nicht vier.** Eine Wahrscheinlichkeit P(Endvermögen ≥ X) hängt nur von der +Verteilung und von X ab. Wird das gesamte Endvermögen eines Laufs sortiert aufbewahrt +(`finalWealthSorted`), lässt sich jede Schwelle nachträglich per Binärsuche ablesen +(`probabilityAtLeast`). Zwei Läufe genügen also für vier Zahlen – halbe Rechenzeit, und vor allem: -**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. +**Fall 1 und Fall 3 stammen aus derselben Verteilung.** Liegt der Zielbetrag unter dem +Plan-Endbetrag, ist Fall 3 damit **zwingend ≥ Fall 1**. Der Widerspruch, der die alte Darstellung +unlesbar machte («Planung 69 %, Ziel 3 Mio nur 41 %» – zwei verschiedene Welten gegeneinander +gestellt), ist strukturell nicht mehr möglich. Ein Test sichert das ab. -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)). +**Der Nullpunkt ist Fall 2, nicht 50 %.** Naiv erwartet man, dass der eigene Plan-Endbetrag in der +eigenen geplanten Welt in ~50 % der Fälle erreicht wird. Das ist falsch: Der Volatilitäts-Drag +drückt den Median unter den deterministischen Pfad, und zwar umso stärker, je höher die Streuung +(gemessen: 37 % bei σ = 15 %, 27 % bei σ = 25 %; siehe [9.26](#926-fall-2-ist-der-nullpunkt--nicht-50-)). +Fall 2 misst genau diesen Nullpunkt und wird deshalb als **Referenz** ausgewiesen, gegen die Fall 1 +verglichen wird – beides derselbe Schwellwert, nur andere Renditeannahme. -**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**. +**Urteil** (Toleranzband ± 5 Prozentpunkte): -Referenz: `src/lib/montecarlo.ts` (`plannedReturnOf`), `src/components/MonteCarloDialog.tsx`. +| Fall 1 vs. Fall 2 | Urteil | +|---|---| +| mehr als 5 pp höher | Planung **zurückhaltend** gerechnet | +| innerhalb ± 5 pp | Planung **realistisch** gerechnet | +| mehr als 5 pp tiefer | Planung tendenziell **zu optimistisch** | + +**Darstellung** (bei einem einzelnen Szenario): +- **Prominent:** Fall 1 als grosse Prozentzahl mit dem Urteil. +- **Untergeordnet:** Fall 3 und 4 als Satzpaar, dessen Wortwahl automatisch dreht, je nachdem + welche Welt besser dasteht («Wenn der Markt so ‹schlecht› läuft, wie du es in deiner Planung + vorgibst, … bei X %. Läuft er so ‹gut› wie in der Vergangenheit, … bei Y %.»). +- **Kleingedruckt:** Fall 2 als Referenz plus der Median je Welt. + +Die Tabelle zeigt je Szenario `Fall 1 (Fall 2)` und `Fall 3 (Fall 4)`. **Ruin**, **Bandbreite** und +**Fächer** stammen immer aus der historischen Welt, weil das das ehrliche Risikobild ist. + +**Eingaben:** Da beide Welten immer laufen, sind die historischen Mittelwerte **und** der +Zielbetrag stets Pflichtfelder. Der frühere Drei-Wege-Umschalter entfällt. + +**Technik:** kein Eingriff in den Rechenkern. Beide Durchgänge laufen mit **demselben Seed** +(Common Random Numbers), damit der Unterschied zwischen den Welten nicht Rauschen ist. + +Referenz: `src/lib/montecarlo.ts` (`finalWealthSorted`, `probabilityAtLeast`, `plannedReturnOf`), +`src/components/MonteCarloDialog.tsx`. ## 4.13 Sensitivitätsanalyse (Tornado) @@ -2836,13 +2859,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" | | `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 | -| `montecarlo.test.ts` | 15 | Determinismus, Volatilität/Vol-Drag, Böden, Reproduzierbarkeit; Element-Gruppierung, Szenario-Vergleich, Inflation je Szenario, plannedReturnOf | +| `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 | | `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** | **121** | | +| **Total** | **124** | | ## 8.2 Testfälle @@ -3244,20 +3267,39 @@ 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 werden soll). Das Werkzeug informiert, es bevormundet nicht. -## 9.26 Die 50-%-Schwelle ist kein scharfer Schnitt +## 9.26 Fall 2 ist der Nullpunkt – nicht 50 % -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. +Naiv erwartet man, dass eine Planung ihren eigenen Endbetrag in **50 %** der Fälle erreicht, sobald +man um genau die geplanten Werte würfelt. Das ist falsch, und der Fehler ist gross genug, um ein +Urteil zu verderben. + +Der Grund ist der **Volatilitäts-Drag** ([4.12.5](#4125-ergebnis)): Der deterministische Pfad +kapitalisiert das arithmetische Mittel, der Median einer Simulation folgt aber näher dem +geometrischen. Gemessen an einem Testplan (800k Startkapital, 30 Jahre, geplante Rendite 5.2 %, +deterministisches Endvermögen 3'457'554): + +| Streuung σ | Median-Endvermögen | P(Plan-Endbetrag erreicht) | +|---|---|---| +| 0 % | 3'457'554 (= deterministisch) | – | +| 3 % | 3'432'399 | ~48 % | +| 6 % | 3'322'529 | ~45 % | +| 15 % | 2'612'222 | **37 %** | +| 25 % | 1'451'659 | **27 %** | + +Eine Planung mit hoher Aktienquote landet also bei **27–37 %**, obwohl an ihr nichts falsch ist. +Ein fixer 50-%-Schnitt würde sie als «zu optimistisch» brandmarken. + +Deshalb ist der Nullpunkt für das Urteil **Fall 2** – dieselbe Schwelle, dieselbe Streuung, nur der +Mittelpunkt aus den eigenen Planannahmen statt aus der Historie +([4.12.7](#4127-zwei-welten-vier-fälle)). Der Vol-Drag steckt dann in **beiden** Zahlen und kürzt +sich weg; übrig bleibt genau die Frage, die interessiert: *Ist meine Renditeannahme optimistischer +als die Vergangenheit?* Das Toleranzband von ± 5 Prozentpunkten fängt Simulationsrauschen ab. + +Fall 2 selbst wird nur klein als Referenz gezeigt: als Zahl ist er wenig aussagekräftig (er misst +im Wesentlichen die eigene Streuung), als **Vergleichsanker** dagegen unverzichtbar. Der Hinweis, +dass die Simulation Risiko **um deine Annahmen** misst und nicht deren Richtigkeit +([9.15](#915-monte-carlo-misst-risiko-um-die-annahmen-nicht-deren-richtigkeit)), bleibt bestehen. -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. --- diff --git a/src/components/MonteCarloDialog.tsx b/src/components/MonteCarloDialog.tsx index 573f601..4701706 100644 --- a/src/components/MonteCarloDialog.tsx +++ b/src/components/MonteCarloDialog.tsx @@ -13,6 +13,7 @@ import { floorFor, paramsForScenario, plannedReturnOf, + probabilityAtLeast, runMonteCarloMulti, INFLATION_VOLATILITY_LEVELS, RETURN_BEARING, @@ -31,14 +32,22 @@ import type { PlanComputed } from "@/lib/calculations"; // Farben der Szenario-Serien -- wie im Vermögensverlauf, damit die Zuordnung vertraut bleibt. 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" }, -]; +// Ergebnis je Szenario: ZWEI Welten (historische vs. geplante Renditen), aus jeder werden +// ZWEI Schwellen abgelesen (Plan-Endbetrag und Zielbetrag) -- die vier Fälle aus 4.12.7. +interface Outcome { + scenarioId: string; + name: string; + planEnd: number; + target: number; + f1: number; // historische Welt, Schwelle Plan-Endbetrag -> Realismus der Planung + f2: number; // geplante Welt, Schwelle Plan-Endbetrag -> Nullpunkt für das Urteil + f3: number; // historische Welt, Schwelle Zielbetrag -> Ziel, wenn der Markt wie früher läuft + f4: number; // geplante Welt, Schwelle Zielbetrag -> Ziel, wenn der Markt wie geplant läuft + medianHist: number; + medianPlan: number; + ruin: number; // aus der historischen Welt (das ehrliche Risikobild) + runs: number; +} const RETURN_LEVEL_OPTIONS: { value: ReturnVolatilityLevel; label: string }[] = [ { value: "sehr_niedrig", label: "Sehr niedrig" }, @@ -117,8 +126,7 @@ export function MonteCarloDialog({ const [loadError, setLoadError] = useState(null); const [selectedIds, setSelectedIds] = useState([meta.id]); - const [mode, setMode] = useState("PLAN"); - // Fall 2: EIN Zielbetrag für alle Szenarien. + // EIN Zielbetrag für alle Szenarien, vorbelegt mit dem Plan-Endbetrag des offenen Szenarios. const [manualTarget, setManualTarget] = useState(Math.max(0, computed.nachlass)); const [runs, setRuns] = useState(1000); @@ -129,15 +137,12 @@ export function MonteCarloDialog({ const [running, setRunning] = useState(false); const [progress, setProgress] = useState({ index: 0, count: 1, fraction: 0 }); - // Ergebnisse getrennt nach Frage: planA = "gegen Planungs-Endbetrag", targetB = "gegen Zielbetrag". - const [resA, setResA] = useState(null); - const [resB, setResB] = useState(null); + const [outcomes, setOutcomes] = useState(null); + // Fächer/Bänder stammen aus der historischen Welt. + const [fanRes, setFanRes] = useState(null); const scenarioKey = scenarios.map((s) => s.id).join(","); - const needsHistorical = mode === "PLAN" || mode === "BOTH"; // Fall 1 braucht historische Mittelwerte - const needsTarget = mode === "TARGET" || mode === "BOTH"; // Fall 2 braucht einen Zielbetrag - useEffect(() => { const missing = scenarios.filter((s) => s.id !== meta.id); if (missing.length === 0) return; @@ -179,8 +184,8 @@ export function MonteCarloDialog({ clearResults(); } function clearResults() { - setResA(null); - setResB(null); + setOutcomes(null); + setFanRes(null); } function toggleScenario(id: string) { @@ -192,20 +197,19 @@ export function MonteCarloDialog({ const anyReturnBearing = selected.some((s) => s.plan.elements.some((e) => RETURN_BEARING.includes(e.category))); const missingHist = inflMean.trim() === "" || groups.some((g) => draftFor(g).mean.trim() === ""); - const missing = (needsHistorical && missingHist) || (needsTarget && manualTarget <= 0); + const missing = missingHist || manualTarget <= 0; const canRun = !missing && selected.length > 0 && anyReturnBearing && !loading; - // 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)); + // Immer zwei Durchgänge: historische Welt und geplante Welt. + const estSeconds = Math.max(1, Math.round((runs * Math.max(1, selected.length) * 2) / 6000)); async function run() { setRunning(true); clearResults(); - const totalPasses = passes; - setProgress({ index: 0, count: selected.length * totalPasses, fraction: 0 }); + setProgress({ index: 0, count: selected.length * 2, fraction: 0 }); try { - // Streuung (Sigma) je logischem Element -- in beiden Fällen dieselbe. + // Streuung (Sigma) je logischem Element -- in BEIDEN Welten dieselbe. Nur der + // Mittelpunkt unterscheidet sie. const sigmaByRoot: Record = Object.fromEntries( groups.map((g) => [g.rootId, returnSigma(draftFor(g))]) ); @@ -213,81 +217,88 @@ export function MonteCarloDialog({ const rootByMember = new Map(); for (const g of groups) for (const id of g.memberIds) rootByMember.set(id, g.rootId); + // Gemeinsamer Seed für beide Welten und alle Szenarien. const seed = (Math.random() * 2 ** 32) >>> 0; const inflSig = inflationSigma(inflLevel, inflManual); - let passIndex = 0; - const onProg = (index: number, count: number, fraction: number) => - setProgress({ index: passIndex * selected.length + index, count: selected.length * totalPasses, fraction }); + const inputs: ScenarioRunInput[] = selected.map((s) => ({ + scenarioId: s.id, + name: s.name, + plan: s.plan, + // Der mitgegebene Zielbetrag ist nur ein Vorgabewert; die vier Fälle werden + // anschliessend direkt aus der Verteilung abgelesen. + target: manualTarget, + })); + const onProg = (pass: number) => (index: number, _count: number, fraction: number) => + setProgress({ index: pass * selected.length + index, count: selected.length * 2, fraction }); - // --- Fall 1: gegen den Planungs-Endbetrag, gewürfelt um HISTORISCHE Mittelwerte --- - if (needsHistorical) { - const paramByRoot: Record = Object.fromEntries( - groups.map((g) => [g.rootId, { mean: Number(draftFor(g).mean) || 0, sigma: sigmaByRoot[g.rootId], floor: floorByRoot[g.rootId] }]) - ); - const inputs: ScenarioRunInput[] = selected.map((s) => ({ - scenarioId: s.id, - name: s.name, - plan: s.plan, - target: Math.max(0, s.computed.nachlass), // Planungs-Endbetrag (read-only) - })); - const res = await runMonteCarloMulti( - inputs, - { - runs, - inflationMeanFor: () => Number(inflMean) || 0, - inflationSigma: inflSig, - seed, - elementsFor: (p) => paramsForScenario(p, groups, paramByRoot), + // --- Welt A: HISTORISCHE Renditen (das realistische Bild) --- + const paramByRoot: Record = Object.fromEntries( + groups.map((g) => [g.rootId, { mean: Number(draftFor(g).mean) || 0, sigma: sigmaByRoot[g.rootId], floor: floorByRoot[g.rootId] }]) + ); + const hist = await runMonteCarloMulti( + inputs, + { + runs, + inflationMeanFor: () => Number(inflMean) || 0, + inflationSigma: inflSig, + seed, + elementsFor: (p) => paramsForScenario(p, groups, paramByRoot), + }, + onProg(0) + ); + + // --- Welt B: GEPLANTE Renditen (die Welt der eigenen Annahmen) --- + const planned = await runMonteCarloMulti( + inputs, + { + runs, + inflationMeanFor: (p) => p.inflationRateDefault, + inflationSigma: inflSig, + seed, + elementsFor: (p) => { + const ids = orderedPhaseIds(p); + const out: Record = {}; + 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 - ); - setResA(res); - passIndex += 1; - } + }, + onProg(1) + ); - // --- Fall 2: gegen den Zielbetrag, gewürfelt um die GEPLANTEN Werte --- - if (needsTarget) { - const inputs: ScenarioRunInput[] = selected.map((s) => ({ + // Vier Fälle aus zwei Verteilungen ablesen. Weil Fall 1 und 3 aus DERSELBEN Verteilung + // stammen, gilt zwingend: tieferes Ziel -> nie kleinere Wahrscheinlichkeit. + const out: Outcome[] = selected.map((s) => { + const h = hist.find((r) => r.scenarioId === s.id)!; + const b = planned.find((r) => r.scenarioId === s.id)!; + const planEnd = Math.max(0, s.computed.nachlass); + return { scenarioId: s.id, name: s.name, - plan: s.plan, + planEnd, 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 = {}; - 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); - } + f1: probabilityAtLeast(h.finalWealthSorted, planEnd), + f2: probabilityAtLeast(b.finalWealthSorted, planEnd), + f3: probabilityAtLeast(h.finalWealthSorted, manualTarget), + f4: probabilityAtLeast(b.finalWealthSorted, manualTarget), + medianHist: h.finalWealthMedian, + medianPlan: b.finalWealthMedian, + ruin: h.ruinProbability, + runs: h.runs, + }; + }); + setOutcomes(out); + setFanRes(hist); } finally { setRunning(false); } } const overallProgress = 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 (
@@ -304,32 +315,6 @@ export function MonteCarloDialog({
- {/* Modus-Umschalter: die zwei Fragen (Roadmap Nr. 46) */} -
-
- Was möchtest du wissen? - -
-
- {MODE_OPTIONS.map((m) => ( - - ))} -
-

{modeHint}

-
- {/* Erklärung */}

@@ -337,6 +322,12 @@ export function MonteCarloDialog({ pro Jahr. Real schwanken beide. Die Simulation würfelt viele tausend mögliche Verläufe und zeigt, wie oft ein Betrag erreicht wird.

+

+ Zwei Welten, ein Lauf: Gerechnet wird einmal um die{" "} + historischen Renditen (wie der Markt bisher lief) und einmal um deine{" "} + geplanten Werte (wie du rechnest). Aus beiden Verteilungen werden + derselbe Plan-Endbetrag und dein Zielbetrag abgelesen – deshalb können sich die Zahlen nicht widersprechen. +

Verteilung: 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 @@ -349,7 +340,7 @@ export function MonteCarloDialog({

Szenarien - +
{loading &&

Szenarien werden geladen…

} {loadError &&

{loadError}

} @@ -371,7 +362,7 @@ export function MonteCarloDialog({ {s.isBase && Basis} {!isLoaded && lädt…} - {checked && needsHistorical && typeof nachlass === "number" && ( + {checked && typeof nachlass === "number" && ( Planungs-Endbetrag: {formatChf(Math.max(0, nachlass))} @@ -383,46 +374,32 @@ export function MonteCarloDialog({ {selected.length === 0 &&

Bitte mindestens ein Szenario auswählen.

}
- {/* Zielbetrag (Fall 2) */} - {needsTarget && ( -
- { - setManualTarget(v); - clearResults(); - }} - /> -
- )} + {/* Zielbetrag */} +
+ { + setManualTarget(v); + clearResults(); + }} + /> +
{/* Inflation */}
Inflation (Plan-Ebene)
- {needsHistorical ? ( - { - setInflMean(v); - clearResults(); - }} - /> - ) : ( -
- -
- {plan.inflationRateDefault} % -
-
- )} + { + setInflMean(v); + clearResults(); + }} + /> Renditetragende Elemente {selected.length > 1 && ( - + )}
{groups.length === 0 && ( @@ -475,24 +452,12 @@ export function MonteCarloDialog({ )}
- {needsHistorical ? ( - setDraft(g.rootId, g, { mean: v })} - /> - ) : ( -
- -
- {planned} % -
-
- )} + setDraft(g.rootId, g, { mean: v })} + />

{selected.length > 1 ? `${selected.length} Szenarien · ` : ""} - {passes > 1 ? "2 Durchgänge · " : ""}geschätzt ~{estSeconds} s +2 Welten · geschätzt ~{estSeconds} s

{missing && (

- {needsHistorical && missingHist + {missingHist ? "Bitte alle Pflichtfelder (historische Ø-Werte) ausfüllen." : "Bitte einen Zielbetrag grösser 0 angeben."}

@@ -559,15 +524,13 @@ export function MonteCarloDialog({ )} - {(resA || resB) && ( + {outcomes && fanRes && ( @@ -591,43 +554,53 @@ 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) +// Toleranzband für das Urteil, in Prozentpunkten. +const VERDICT_BAND = 5; + +// Urteil über die Planung. Der Nullpunkt ist NICHT 50 %, sondern Fall 2: die Wahrscheinlichkeit, +// den Plan-Endbetrag in der eigenen geplanten Welt zu erreichen. Die liegt wegen des +// Volatilitäts-Drags systematisch unter 50 % (bei hoher Streuung deutlich). Verglichen wird +// also Fall 1 gegen Fall 2 -- beides derselbe Schwellwert, nur andere Renditeannahme. +function planVerdict(f1: number, f2: number): { text: string; tone: "danger" | "success" | "muted" } { + const p1 = Math.round(f1 * 100); + const diff = (f1 - f2) * 100; + if (diff > VERDICT_BAND) 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.`, + text: `Deine Planung ist zurückhaltend gerechnet: Mit den historischen Renditen wird dein geplanter Endbetrag in ${p1} % der Fälle erreicht – häufiger als mit deinen eigenen Annahmen.`, + tone: "success", + }; + if (diff < -VERDICT_BAND) + return { + text: `Deine Planung ist tendenziell zu optimistisch: Mit den historischen Renditen wird dein geplanter Endbetrag nur in ${p1} % der Fälle erreicht – seltener als mit deinen eigenen Annahmen.`, 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", + text: `Deine Planung ist realistisch gerechnet: Mit den historischen Renditen wird dein geplanter Endbetrag in ${p1} % der Fälle erreicht – praktisch gleich häufig wie mit deinen eigenen Annahmen.`, + tone: "muted", }; } -// 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", - }; +// Untergeordnetes Fall-3/4-Paar: dieselbe Zielschwelle, einmal in der geplanten und einmal in der +// historischen Welt. Die Formulierung dreht automatisch, je nachdem welche Welt besser dasteht. +function targetSentence(f3: number, f4: number): string { + const p3 = Math.round(f3 * 100); + const p4 = Math.round(f4 * 100); + const planWord = f4 < f3 ? "schlecht" : "gut"; + const histWord = f4 < f3 ? "gut" : "zurückhaltend"; + return ( + `Wenn der Markt so «${planWord}» läuft, wie du es in deiner Planung vorgibst, liegt die Wahrscheinlichkeit, ` + + `deinen Zielbetrag zu erreichen, bei ${p4} %. Läuft er so «${histWord}» wie in der Vergangenheit, liegt sie bei ${p3} %.` + ); } 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 ( -
+
{title} - {Math.round(prob * 100)} % + {Math.round(prob * 100)} %

{verdict.text}

@@ -635,25 +608,18 @@ function VerdictCard({ title, prob, verdict }: { title: string; prob: number; ve } function MonteCarloResults({ - mode, - resA, - resB, - manualTarget, + outcomes, + fanRes, detPoints, }: { - mode: McMode; - resA: ScenarioMcResult[] | null; // Fall 1 (gegen Planungs-Endbetrag) - resB: ScenarioMcResult[] | null; // Fall 2 (gegen Zielbetrag) - manualTarget: number; + outcomes: Outcome[]; + fanRes: ScenarioMcResult[]; // Fächer/Bänder aus der historischen Welt detPoints: { age: number; det: number }[]; }) { - // 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; + const single = fanRes.length === 1; const singleData = useMemo(() => { - if (!fanRes || !single) return []; + if (!single) return []; return fanRes[0].bands.map((b) => ({ age: b.age, band: [b.p10, b.p90] as [number, number], @@ -663,7 +629,7 @@ function MonteCarloResults({ }, [fanRes, single, detPoints]); const compareData = useMemo(() => { - if (!fanRes || single) return []; + if (single) return []; const ages = Array.from(new Set(fanRes.flatMap((r) => r.bands.map((b) => b.age)))).sort((a, b) => a - b); return ages.map((age) => { const row: Record = { age }; @@ -672,16 +638,32 @@ function MonteCarloResults({ }); }, [fanRes, single]); - const byId = (arr: ScenarioMcResult[] | null, id: string) => arr?.find((r) => r.scenarioId === id) ?? null; - const rows = (resA ?? resB)!; + const one = outcomes[0]; return (
- {/* Deutungstexte -- nur sinnvoll bei einem einzelnen Szenario; bei mehreren zählt die Tabelle. */} + {/* Deutung -- nur bei einem einzelnen Szenario; bei mehreren zählt die Tabelle. */} {single && ( -
- {resA && } - {resB && } +
+ {/* Prominent: Fall 1 */} + + + {/* Untergeordnet: Fall 3 und 4 als Paar */} +
+
+ Dein Zielbetrag {formatChf(one.target)} + +
+

{targetSentence(one.f3, one.f4)}

+
+ + {/* Kleingedruckt: Fall 2 als Referenz plus typischer Ausgang je Welt */} +

+ Referenz: Mit deinen eigenen Planannahmen und Schwankung wird der Plan-Endbetrag von{" "} + {formatChf(one.planEnd)} in {Math.round(one.f2 * 100)} % der Fälle erreicht – das ist der Nullpunkt für + das Urteil oben, nicht 50 % (Schwankung frisst Rendite). Typischer Ausgang (Median):{" "} + {formatChf(one.medianHist)} historisch, {formatChf(one.medianPlan)} nach Plan. +

)} @@ -689,15 +671,15 @@ function MonteCarloResults({
Ergebnis je Szenario - +
- {resA && } - {resB && } + + @@ -705,21 +687,23 @@ function MonteCarloResults({ - {rows.map((r, i) => { - const a = byId(resA, r.scenarioId); - const b = byId(resB, r.scenarioId); - const fan = a ?? b!; + {outcomes.map((o, i) => { + const fan = fanRes.find((r) => r.scenarioId === o.scenarioId)!; return ( - + - {resA && } - {resB && } - + + + @@ -730,17 +714,16 @@ function MonteCarloResults({
SzenarioPlanung erreichtZiel erreichtPlanung hist. (Plan)Ziel hist. (Plan) Ruin Pessim. (10 %) Median
{!single && } - {r.name} + {o.name} {a ? `${Math.round(a.successProbability * 100)} %` : "–"}{b ? `${Math.round(b.successProbability * 100)} %` : "–"}{Math.round(fan.ruinProbability * 100)} % + {Math.round(o.f1 * 100)} % ({Math.round(o.f2 * 100)} %) + + {Math.round(o.f3 * 100)} % ({Math.round(o.f4 * 100)} %) + {Math.round(o.ruin * 100)} % {formatChf(fan.finalWealthP10)} {formatChf(fan.finalWealthMedian)} {formatChf(fan.finalWealthP90)}

- {rows[0].runs.toLocaleString("de-CH")} Läufe je Szenario · gemeinsamer Zufalls-Seed - {resB ? ` · Zielbetrag ${formatChf(manualTarget)}` : ""} + {one.runs.toLocaleString("de-CH")} Läufe je Szenario und Welt · gemeinsamer Zufalls-Seed · Zielbetrag{" "} + {formatChf(one.target)}

{/* Fächer */} - {fanRes && ( -
+
{single ? "Vermögensfächer nach Alter (nominal)" : "Vermögensverlauf im Median nach Alter (nominal)"} - {resA ? " · historische Renditen" : " · geplante Werte"} + {" · historische Renditen"}

{single @@ -774,8 +757,7 @@ function MonteCarloResults({ )}

-
- )} +
); } diff --git a/src/lib/montecarlo.test.ts b/src/lib/montecarlo.test.ts index 390cec5..01a9f49 100644 --- a/src/lib/montecarlo.test.ts +++ b/src/lib/montecarlo.test.ts @@ -5,6 +5,7 @@ import { defaultVolatilityLevel, paramsForScenario, plannedReturnOf, + probabilityAtLeast, resolveRootElementId, runMonteCarlo, runMonteCarloMulti, @@ -282,6 +283,53 @@ describe("Monte Carlo: mehrere Szenarien", () => { expect(rHi.finalWealthMedian).toBeLessThan(rLo.finalWealthMedian); }); + it("probabilityAtLeast entspricht exakt der Erfolgszählung des Laufs", async () => { + const p = basePlan(); + const det = computePlan(p).nachlass; + const r = await runMonteCarlo(p, params({ elements: { asset: { mean: 5, sigma: 20, floor: -100 } }, target: det })); + // Aus derselben Verteilung abgelesen muss dieselbe Zahl herauskommen. + expect(probabilityAtLeast(r.finalWealthSorted, det)).toBe(r.successProbability); + // Randfälle. + expect(probabilityAtLeast(r.finalWealthSorted, -Infinity)).toBe(1); + expect(probabilityAtLeast(r.finalWealthSorted, Infinity)).toBe(0); + expect(probabilityAtLeast([], 0)).toBe(0); + }); + + it("EINE Welt, mehrere Schwellen: tieferes Ziel ist NIE unwahrscheinlicher", async () => { + // Das ist die strukturelle Garantie gegen den scheinbaren Widerspruch: Werden Plan-Endbetrag + // und Zielbetrag aus DERSELBEN Verteilung abgelesen, kann ein tieferes Ziel nie seltener + // erreicht werden als ein höheres. + const p = basePlan(); + const det = computePlan(p).nachlass; + const r = await runMonteCarlo(p, params({ elements: { asset: { mean: 5, sigma: 20, floor: -100 } }, target: det })); + + const fallPlanEnd = probabilityAtLeast(r.finalWealthSorted, det); + const fallZielTiefer = probabilityAtLeast(r.finalWealthSorted, det * 0.8); + const fallZielHoeher = probabilityAtLeast(r.finalWealthSorted, det * 1.2); + + expect(fallZielTiefer).toBeGreaterThanOrEqual(fallPlanEnd); + expect(fallZielHoeher).toBeLessThanOrEqual(fallPlanEnd); + // Zielbetrag == Plan-Endbetrag -> exakt identisch (nicht nur ungefähr). + expect(probabilityAtLeast(r.finalWealthSorted, det)).toBe(fallPlanEnd); + }); + + it("Fall 2 liegt systematisch UNTER 50 % -- der Volatilitäts-Drag", async () => { + // Auch wenn die Annahmen exakt zutreffen (Mittelwert = Planrendite), wird der + // deterministische Plan-Endbetrag in weniger als der Hälfte der Fälle erreicht. Deshalb ist + // Fall 2 der richtige Nullpunkt für das Urteil "optimistisch/konservativ" -- nicht 50 %. + const p = basePlan(); // Planrendite 5 % + const det = computePlan(p).nachlass; + + const wenigStreuung = await runMonteCarlo(p, params({ elements: { asset: { mean: 5, sigma: 6, floor: -100 } }, target: det })); + const vielStreuung = await runMonteCarlo(p, params({ elements: { asset: { mean: 5, sigma: 25, floor: -100 } }, target: det })); + + expect(wenigStreuung.successProbability).toBeLessThan(0.5); + expect(vielStreuung.successProbability).toBeLessThan(0.5); + // Je mehr Streuung, desto stärker der Drag. + expect(vielStreuung.successProbability).toBeLessThan(wenigStreuung.successProbability); + expect(vielStreuung.finalWealthMedian).toBeLessThan(wenigStreuung.finalWealthMedian); + }); + it("plannedReturnOf: erste gesetzte Phasenrendite, Wertsteigerung bei Immobilien", () => { const orderedIds = ["p1", "p2"]; const asset: ElementInput = { diff --git a/src/lib/montecarlo.ts b/src/lib/montecarlo.ts index f486a01..189948f 100644 --- a/src/lib/montecarlo.ts +++ b/src/lib/montecarlo.ts @@ -77,6 +77,24 @@ export interface MonteCarloResult { finalWealthP90: number; // Fächer über das Alter: je Alterspunkt der pessimistische/mittlere/optimistische Wert. bands: { age: number; p10: number; p50: number; p90: number }[]; + // Alle Endvermögen des Laufs, aufsteigend sortiert. Damit lässt sich P(>= X) für JEDE + // Schwelle nachträglich ablesen, ohne erneut zu simulieren -- Grundlage dafür, aus EINEM + // Lauf mehrere Fragen zu beantworten (Plan-Endbetrag UND Zielbetrag, siehe 4.12.7). + finalWealthSorted: number[]; +} + +// Anteil der Läufe mit Endvermögen >= threshold. Binäre Suche auf der sortierten Liste; +// identische Zählweise wie in runMonteCarlo (>=, nicht >). +export function probabilityAtLeast(sortedAsc: number[], threshold: number): number { + if (sortedAsc.length === 0) return 0; + let lo = 0; + let hi = sortedAsc.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (sortedAsc[mid] >= threshold) hi = mid; + else lo = mid + 1; + } + return (sortedAsc.length - lo) / sortedAsc.length; } // --- Mehrere Szenarien im selben Lauf vergleichen ------------------------------------- @@ -377,5 +395,6 @@ export async function runMonteCarlo( finalWealthMedian: percentile(finalWealth, 0.5), finalWealthP90: percentile(finalWealth, 0.9), bands, + finalWealthSorted: finalWealth, }; }