Monte-Carlo: zwei Welten, vier Faelle statt widerspruechlicher Modi
Deploy App / deploy (push) Successful in 1m1s

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 20:46:50 +02:00
parent 683d240518
commit 50e98122cf
4 changed files with 373 additions and 282 deletions
+86 -44
View File
@@ -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 2748 %. 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 **2737 %**, 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.
---
+220 -238
View File
@@ -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<string | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([meta.id]);
const [mode, setMode] = useState<McMode>("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<number>(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<ScenarioMcResult[] | null>(null);
const [resB, setResB] = useState<ScenarioMcResult[] | null>(null);
const [outcomes, setOutcomes] = useState<Outcome[] | null>(null);
// Fächer/Bänder stammen aus der historischen Welt.
const [fanRes, setFanRes] = useState<ScenarioMcResult[] | null>(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<string, number> = Object.fromEntries(
groups.map((g) => [g.rootId, returnSigma(draftFor(g))])
);
@@ -213,81 +217,88 @@ export function MonteCarloDialog({
const rootByMember = new Map<string, string>();
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<string, ElementMcParams> = 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<string, ElementMcParams> = 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<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
);
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<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);
}
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 (
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
@@ -304,32 +315,6 @@ export function MonteCarloDialog({
</button>
</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 */}
<div className="rounded-xl border border-border bg-surface-2 p-4 text-xs leading-relaxed text-muted">
<p className="mb-2">
@@ -337,6 +322,12 @@ export function MonteCarloDialog({
pro Jahr. Real schwanken beide. Die Simulation würfelt <strong className="text-fg">viele tausend mögliche
Verläufe</strong> und zeigt, wie oft ein Betrag erreicht wird.
</p>
<p className="mb-2">
<strong className="text-fg">Zwei Welten, ein Lauf:</strong> Gerechnet wird einmal um die{" "}
<strong className="text-fg">historischen</strong> Renditen (wie der Markt bisher lief) und einmal um deine{" "}
<strong className="text-fg">geplanten</strong> Werte (wie du rechnest). Aus beiden Verteilungen werden
derselbe Plan-Endbetrag und dein Zielbetrag abgelesen deshalb können sich die Zahlen nicht widersprechen.
</p>
<p>
<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
@@ -349,7 +340,7 @@ export function MonteCarloDialog({
<div>
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
Szenarien
<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." />
<InfoBubble text="Wähle, für welche Szenarien dieses Plans gerechnet werden soll. Streuung und historische Renditen werden nur einmal je logischem Element erfasst und für alle Szenarien verwendet." />
</div>
{loading && <p className="text-xs text-muted">Szenarien werden geladen</p>}
{loadError && <p className="text-xs text-danger">{loadError}</p>}
@@ -371,7 +362,7 @@ export function MonteCarloDialog({
{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>}
</label>
{checked && needsHistorical && typeof nachlass === "number" && (
{checked && typeof nachlass === "number" && (
<span className="text-[11px] text-muted" title="Planungs-Endbetrag dieses Szenarios (read-only)">
Planungs-Endbetrag: <strong className="text-fg">{formatChf(Math.max(0, nachlass))}</strong>
</span>
@@ -383,46 +374,32 @@ export function MonteCarloDialog({
{selected.length === 0 && <p className="mt-2 text-xs text-danger">Bitte mindestens ein Szenario auswählen.</p>}
</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>
)}
{/* Zielbetrag */}
<div className="sm:max-w-xs">
<MoneyField
label="Zielbetrag (Endvermögen)"
help="Dein frei gewähltes Ziel, nominal. Geprüft wird, wie oft es in beiden Welten mindestens erreicht wird. Gilt für alle ausgewählten Szenarien."
value={manualTarget}
onChange={(v) => {
setManualTarget(v);
clearResults();
}}
/>
</div>
{/* Inflation */}
<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">
{needsHistorical ? (
<RequiredNumberField
label="Ø Inflation letzte 20 J. (%)"
help={`Der Mittelpunkt, um den bei «Planung prüfen» gewürfelt wird. Deine Planung nutzt aktuell ${plan.inflationRateDefault} %. CH langfristig ~2 %.`}
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>
)}
<RequiredNumberField
label="Ø Inflation letzte 20 J. (%)"
help={`Der Mittelpunkt der historischen Welt. Die geplante Welt würfelt um deine Planannahme von ${plan.inflationRateDefault} %. CH langfristig ~2 %.`}
value={inflMean}
onChange={(v) => {
setInflMean(v);
clearResults();
}}
/>
<SelectField
label="Streuung"
help={INFLATION_HELP}
@@ -446,7 +423,7 @@ export function MonteCarloDialog({
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
Renditetragende Elemente
{selected.length > 1 && (
<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." />
<InfoBubble text="Ein Element, das in mehreren Szenarien vorkommt, erscheint hier nur einmal erkannt über seine Herkunft beim Kopieren des Szenarios." />
)}
</div>
{groups.length === 0 && (
@@ -475,24 +452,12 @@ export function MonteCarloDialog({
)}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{needsHistorical ? (
<RequiredNumberField
label="Ø Rendite letzte 20 J. (%)"
help="Der Mittelpunkt für «Planung prüfen» die realistische Rendite dieser Anlage, nicht deine (evtl. optimistische) Planannahme."
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>
)}
<RequiredNumberField
label="Ø Rendite letzte 20 J. (%)"
help={`Der Mittelpunkt der historischen Welt die realistische Rendite dieser Anlage. Deine Planung rechnet mit ${planned} %; damit läuft die geplante Welt.`}
value={d.mean}
onChange={(v) => setDraft(g.rootId, g, { mean: v })}
/>
<SelectField
label="Streuung"
help={RETURN_HELP}
@@ -530,14 +495,14 @@ export function MonteCarloDialog({
/>
<p className="mt-1 text-[11px] text-faint">
{selected.length > 1 ? `${selected.length} Szenarien · ` : ""}
{passes > 1 ? "2 Durchgänge · " : ""}geschätzt ~{estSeconds} s
2 Welten · geschätzt ~{estSeconds} s
</p>
</div>
</div>
{missing && (
<p className="text-xs text-danger">
{needsHistorical && missingHist
{missingHist
? "Bitte alle Pflichtfelder (historische Ø-Werte) ausfüllen."
: "Bitte einen Zielbetrag grösser 0 angeben."}
</p>
@@ -559,15 +524,13 @@ export function MonteCarloDialog({
)}
</div>
{(resA || resB) && (
{outcomes && fanRes && (
<MonteCarloResults
mode={mode}
resA={resA}
resB={resB}
manualTarget={manualTarget}
outcomes={outcomes}
fanRes={fanRes}
detPoints={
resA && resA.length === 1 && loaded[resA[0].scenarioId]
? detPointsOf(loaded[resA[0].scenarioId].plan, loaded[resA[0].scenarioId].computed)
fanRes.length === 1 && loaded[fanRes[0].scenarioId]
? detPointsOf(loaded[fanRes[0].scenarioId].plan, loaded[fanRes[0].scenarioId].computed)
: []
}
/>
@@ -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 (
<div className={`rounded-xl border p-3 ${border}`}>
<div className={`rounded-xl border p-4 ${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>
<span className={`text-3xl font-semibold ${color}`}>{Math.round(prob * 100)} %</span>
</div>
<p className="mt-1 text-xs text-muted">{verdict.text}</p>
</div>
@@ -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<string, number | null> = { 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 (
<div className="flex flex-col gap-4 border-t border-border pt-4">
{/* 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 && (
<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 className="flex flex-col gap-3">
{/* Prominent: Fall 1 */}
<VerdictCard title="Realismus der Planung" prob={one.f1} verdict={planVerdict(one.f1, one.f2)} />
{/* Untergeordnet: Fall 3 und 4 als Paar */}
<div className="rounded-xl border border-border bg-surface-2 p-3">
<div className="mb-1 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
Dein Zielbetrag {formatChf(one.target)}
<InfoBubble text="Dieselbe Zielschwelle in beiden Welten. Fall 3 stammt aus derselben Verteilung wie das Urteil oben liegt dein Ziel unter dem Plan-Endbetrag, kann seine Wahrscheinlichkeit deshalb nie tiefer sein." />
</div>
<p className="text-xs leading-relaxed text-muted">{targetSentence(one.f3, one.f4)}</p>
</div>
{/* Kleingedruckt: Fall 2 als Referenz plus typischer Ausgang je Welt */}
<p className="text-[11px] leading-relaxed text-faint">
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.
</p>
</div>
)}
@@ -689,15 +671,15 @@ function MonteCarloResults({
<div>
<div className="mb-1 flex items-center text-xs font-semibold text-fg">
Ergebnis je Szenario
<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="«Planung» = P(Plan-Endbetrag erreicht) in der historischen Welt (Fall 1), in Klammern derselbe Schwellwert in der geplanten Welt (Fall 2). «Ziel» = P(Zielbetrag erreicht), historisch (Fall 3) und nach Plan (Fall 4). Ruin, Bandbreite und Median stammen aus der historischen Welt." />
</div>
<div className="overflow-x-auto rounded-xl border border-border">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-surface-2 text-xs text-faint">
<th className="px-3 py-2 text-left font-semibold">Szenario</th>
{resA && <th className="px-3 py-2 text-right font-semibold">Planung erreicht</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">Planung hist. (Plan)</th>
<th className="px-3 py-2 text-right font-semibold">Ziel hist. (Plan)</th>
<th className="px-3 py-2 text-right font-semibold">Ruin</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>
@@ -705,21 +687,23 @@ function MonteCarloResults({
</tr>
</thead>
<tbody>
{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 (
<tr key={r.scenarioId} className="border-t border-border">
<tr key={o.scenarioId} className="border-t border-border">
<td className="px-3 py-2">
<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}
{o.name}
</span>
</td>
{resA && <td className="px-3 py-2 text-right font-semibold text-success">{a ? `${Math.round(a.successProbability * 100)} %` : ""}</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-danger">{Math.round(fan.ruinProbability * 100)} %</td>
<td className="px-3 py-2 text-right font-semibold text-success">
{Math.round(o.f1 * 100)} % <span className="font-normal text-faint">({Math.round(o.f2 * 100)} %)</span>
</td>
<td className="px-3 py-2 text-right font-semibold text-accent">
{Math.round(o.f3 * 100)} % <span className="font-normal text-faint">({Math.round(o.f4 * 100)} %)</span>
</td>
<td className="px-3 py-2 text-right font-semibold text-danger">{Math.round(o.ruin * 100)} %</td>
<td className="px-3 py-2 text-right text-muted">{formatChf(fan.finalWealthP10)}</td>
<td className="px-3 py-2 text-right text-fg">{formatChf(fan.finalWealthMedian)}</td>
<td className="px-3 py-2 text-right text-muted">{formatChf(fan.finalWealthP90)}</td>
@@ -730,17 +714,16 @@ function MonteCarloResults({
</table>
</div>
<p className="mt-1 text-[11px] text-faint">
{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)}
</p>
</div>
{/* Fächer */}
{fanRes && (
<div>
<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"}
{" · historische Renditen"}
</div>
<p className="mb-2 text-[11px] text-muted">
{single
@@ -774,8 +757,7 @@ function MonteCarloResults({
)}
</ResponsiveContainer>
</div>
</div>
)}
</div>
</div>
);
}
+48
View File
@@ -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 = {
+19
View File
@@ -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,
};
}