From 683d24051869a8b3d92cd598ce0f67047a236722 Mon Sep 17 00:00:00 2001 From: kelle Date: Sun, 19 Jul 2026 13:54:45 +0200 Subject: [PATCH] Monte-Carlo: zwei Fragestellungen (Planung pruefen / Ziel pruefen / Beides) Roadmap Nr. 46. Umschalter oben trennt die zwei Fragen; kein Eingriff in den Rechenkern (reine Dialog-Logik + kleine montecarlo.ts-Erweiterung). - "Planung pruefen" (Fall 1, wie bisher): wuerfelt um HISTORISCHE Renditen, prueft gegen den Planungs-Endbetrag (read-only) -> "wie realistisch ist meine Planung?" - "Ziel pruefen" (Fall 2, neu): wuerfelt um die GEPLANTEN Werte aus dem Plan, prueft gegen einen manuellen Zielbetrag -> "erreiche ich mein Ziel?"; keine historischen Mittelwerte noetig, nur Streuung - "Beides": beide Durchgaenge gleichzeitig, gemeinsamer Seed - Deutungstexte je Fall, weich formuliert (Volatilitaets-Drag, siehe 9.26) - Zielbetrag in Fall 2 = einer fuer alle Szenarien; Ruin/Faecher aus dem historischen Durchgang (ehrliches Risikobild) montecarlo.ts: runMonteCarloMulti nimmt Inflation je Szenario (inflationMeanFor); neuer Helfer plannedReturnOf (erste Phasenrendite, Wertsteigerung bei Immobilien). Nebenbei die vom Umlaut-Sweep verstuemmelte Hex-Farbe #7c3aed korrigiert. 8 Tests (119 -> 121). SPEZIFIKATION auf 0.16 (Kap. 4.12.7, 9.26 neu). Co-Authored-By: Claude Opus 4.8 --- SPEZIFIKATION.md | 65 ++- src/components/MonteCarloDialog.tsx | 610 +++++++++++++++++----------- src/lib/montecarlo.test.ts | 62 ++- src/lib/montecarlo.ts | 23 +- 4 files changed, 521 insertions(+), 239 deletions(-) diff --git a/SPEZIFIKATION.md b/SPEZIFIKATION.md index 003f22a..a04c3cb 100644 --- a/SPEZIFIKATION.md +++ b/SPEZIFIKATION.md @@ -4,10 +4,10 @@ | | | |---|---| | **Dokument** | Funktionale und Technische Spezifikation FPT | -| **Version** | 0.15 | +| **Version** | 0.16 | | **Datum** | 2026-07-18 | | **Status** | Lebendes Dokument | -| **Codestand** | Arbeitsstand nach `9252f71` inkl. abschnittsbasiertem Plan-Assistenten (Branch `main`) | +| **Codestand** | Arbeitsstand nach `1d046e9` inkl. zwei Monte-Carlo-Fragestellungen (Branch `main`) | | **Ersetzt** | `FDD_TDD_FPT.docx` (v1–v5) im Ordner `Info Dateien` – diese sind ab Version 0.1 dieses Dokuments obsolet | | **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` | @@ -17,6 +17,7 @@ | Version | Datum | Autor | Änderung | |---|---|---|---| +| 0.16 | 2026-07-19 | Claude (Opus 4.8) | **Monte-Carlo mit zwei Fragestellungen** (Roadmap Nr. 46). Ein Umschalter oben trennt: **«Planung prüfen»** (Fall 1, wie bisher) würfelt um die **historischen** Renditen und prüft gegen den **Planungs-Endbetrag** (read-only) – «wie realistisch ist meine Planung?». **«Ziel prüfen»** (Fall 2, neu) würfelt um die **geplanten** Werte aus dem Plan und prüft gegen einen **manuellen Zielbetrag** – «erreiche ich mein Ziel?»; hier sind keine historischen Mittelwerte nötig, nur die Streuung. **«Beides»** rechnet beide Durchgänge gleichzeitig (gemeinsamer Seed). Neue Ergebnis-**Deutungstexte** je Fall (weich formuliert wegen des Volatilitäts-Drags). Der Fächer stammt aus dem historischen Durchgang; der Zielbetrag ist in Fall 2 **einer für alle** Szenarien. `runMonteCarloMulti` nimmt neu die Inflation **je Szenario** (`inflationMeanFor`); neuer Helfer `plannedReturnOf`. Kapitel 4.12 überarbeitet, 4.12.7 und 9.26 neu; 8 Tests ergänzt (119 → 121). Nebenbei eine vom Umlaut-Sweep verstümmelte Hex-Farbe (`#7c3aed`) korrigiert. Keine Änderung am Rechenkern. | | 0.15 | 2026-07-19 | Claude (Opus 4.8) | **Plan-Assistent überarbeitet** (Schritt 2 und 4). Rein an der Oberfläche, keine Änderung an Berechnung, Datenmodell oder API. **(Schritt 2 – Lebensphasen):** Die Lebenslinie zerfällt neu an den **fixen Pensionierungszeitpunkten** in Abschnitte (neues reines Modul `phaseplan.ts`, `planSegments`): Erwerb (alle arbeiten), Misch (eine pensioniert, eine arbeitet), Pension (alle pensioniert) – jeweils mit **kurzer Definition**. In den durch eine Pensionierung **fest begrenzten** Abschnitten verteilt der Nutzer beliebig viele Phasen mit **+/Papierkorb** und **eigenem Namen je Phase**; eine Live-Summe erzwingt, dass die Phasendauern exakt aufgehen («Weiter» ist bis dahin gesperrt). Der letzte Pensions-Abschnitt ist **offen** (Lebensdauer frei). Die Anzahl Abschnitte wird **abgeleitet** – Einzelplan: 2 (Erwerb, Pension); Paar mit unterschiedlichem Pensionsalter: 3. Neue **Zeitachse** mit Pensionierungs-Flaggen und nummerierter Beschriftung **unter** dem Balken (auch kurze Phasen bleiben lesbar). Behebt den Fehler, dass die Erwerbsphase zuvor beliebig über die Pensionierung hinaus gesetzt werden konnte. **(Schritt 4 – Vorsorge & Vermögen):** bei Paaren aufgeteilt in **Gemeinsam / Person A / Person B**; PK und 3a sind je Person, Wertschriften/Wohneigentum/Schulden je Bereich (gemeinsam oder pro Person). Neue Kapitel 3.2.8 überarbeitet; 8 Tests ergänzt (111 → 119). | | 0.14 | 2026-07-18 | Claude (Opus 4.8) | **Phasenkopf überarbeitet und zwei Verteil-Werkzeuge.** (1) **Zweizeilige Wertdarstellung:** Im Anzeigemodus «Beide» steht der Realwert neu in Klammern in einer **eigenen Zeile** unter dem nominalen Wert statt daneben – im Phasenkopf *und* in den Matrix-Zellen. Der Pfeil wiederholt sich auf der zweiten Zeile, damit der Bezug Start → Ende erhalten bleibt. Nebeneffekt: Die Spalten werden schmaler, wodurch **jede Kennzahl umbruchfrei** (`whitespace-nowrap`) dargestellt werden kann. (2) Die Kennzahl heisst korrekt **«Sparquote»** bzw. **«Verzehrquote»** statt «Quote»/«Verzehr». (3) Neuer Block **«Verfügbares Kapital»** im Phasenkopf (ab Phase 2, nur wenn > 0): der beim Übergang zugeflossene Topf mit «davon verteilt» und «Rest auf Cash». (4) Zwei neue Werkzeuge als eigene Popups: **«Kapital verteilen»** (Zusatzeinlagen in PK/3a/Vermögen, Sonderamortisation, Sofort-Tilgung) und **«Sparquote/Bezug verteilen»** (jährliche Raten), beide mit **Live-Vorschau** über eine erneute `computePlan`-Rechnung im Browser – die angezeigte Wirkung ist dadurch per Konstruktion exakt die spätere, inklusive aller Kappungen. Der Quoten-Dialog weist neben erstem und letztem Jahr die **absolute Quote über die ganze Phase** aus und warnt, wenn die Quote über die Phase sinkt. Neues reines Modul `distribution.ts`. Neue Kapitel 3.6.9, 3.6.10, 9.25; 3.6.1 und 3.6.3 überarbeitet. 8 Tests ergänzt (103 → 111). **Keine Änderung an Berechnung, Datenmodell oder API** – beide Werkzeuge schreiben ausschliesslich bestehende Felder über bestehende Endpunkte. | | 0.13 | 2026-07-18 | Claude (Fable 5) | **UI-Gesamtumbau** – rein an der Oberfläche, Berechnung, Datenmodell und API-Semantik unverändert. **(A) Fundament:** durchgehende **Du-Form** und **echte Umlaute** in allen sichtbaren Texten (inkl. API-Fehlermeldungen); neue UI-Primitiven in `ui.tsx` (Button, Modal mit ESC/Fokus-Falle/Animation, Bestätigungs-Dialog statt `window.confirm`, Toasts statt `alert`, Skeleton-Loader, EmptyState); eigene **Attention-Farbe** (Amber) für offene Entscheide, getrennt vom Akzent; Micro-Interactions mit `prefers-reduced-motion`-Fallback. **(B) Onboarding (Roadmap Nr. 10):** geführter **Plan-Assistent** in fünf Schritten (reine Orchestrierung bestehender Endpunkte, Einkommen bewusst pro Person – räumt die 9.9-Falle aus), **Beispielplan mit einem Klick** (Übergänge absichtlich offen – die Ampel lehrt sich selbst), **interaktive Tour** über die Planansicht, abgeleitete **«Nächste Schritte»**-Karte. **(C) Struktur:** Einzel-Bearbeitungen laufen neu über ein rechtes **Inspector-Panel** statt Modals (Matrix bleibt sichtbar; Klick auf andere Zelle wechselt den Inhalt); **Phasenkopf entschlackt** auf vier Kern-Infos (Rest wohnt in der Detailansicht aus 0.11); Matrix mit eigenem Scrollbereich und **beidachsig fixierten Köpfen**; Sidebar-Gruppen «Meine Pläne»/«Wissen» («So rechnet FPT», Systemparameter); Terminologie-Fix «Szenario-Profil» statt «Plan-Einstellungen»; Aktions-Icons auch ohne Hover sichtbar (Touch). **(D) Extras:** **Sparklines** je Element-Zeile (aus den 0.11-Verlaufswerten, keine Neuberechnung), **Befehls-Palette** (Ctrl/Cmd+K), Ruin-Banner verlinkt auf die Einflussfaktoren. Neue Kapitel 3.2.8, 3.7.6–3.7.9, 9.23, 9.24; 9.17 bereinigt (der `Selection`-Rest und der ProfileMenu-Lint-Fehler sind behoben – `npm run lint` ist erstmals fehlerfrei). Testbestand unverändert 103. | @@ -1973,6 +1974,45 @@ 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 + +Die Simulation beantwortet **zwei verschiedene Fragen**, die sich sauber trennen lassen (Roadmap +Nr. 46). Ein Umschalter oben im Dialog wählt zwischen ihnen – oder rechnet beide gleichzeitig. + +| | **Planung prüfen** (Fall 1) | **Ziel prüfen** (Fall 2) | +|---|---|---| +| Gewürfelt wird um … | **historische** Mittelwerte (Eingabe) | **geplante** Werte (aus dem Plan) | +| Geprüft wird gegen … | **Planungs-Endbetrag** (read-only) | **manueller Zielbetrag** | +| Beantwortet | «Wie realistisch ist meine Planung?» | «Erreiche ich mein Ziel?» | +| Nötige Eingaben | historische Renditen + Streuung | nur Streuung + Zielbetrag | + +Das baut direkt auf der **Trennung der zwei Renditezahlen** aus [4.12.3](#4123-zwei-renditezahlen--und-warum) +auf. Fall 1 ist das bisherige Verhalten – nur wird das Zielfeld in «Planungs-Endbetrag» umbenannt +und **read-only** (es ist der geplante Nachlass des jeweiligen Szenarios). Fall 2 dreht es um: +Der Mittelpunkt ist nicht mehr ein historischer Wert, sondern die **plan-eigene** Rendite je +Element (`plannedReturnOf`: erste Phase, bei Immobilien die Wertsteigerung) und die plan-eigene +Inflation; geprüft wird gegen ein frei gewähltes Ziel. Die **Streuung bleibt in beiden Fällen** +– ohne sie gäbe es keine Wahrscheinlichkeit. + +**Zielbetrag in Fall 2:** genau **einer für alle** ausgewählten Szenarien – so vergleicht man +Szenarien gegen dasselbe Ziel. **Ruin** und **Fächer** stammen immer aus dem historischen +Durchgang (Fall 1), weil das das ehrliche Risikobild ist; im reinen Ziel-Modus aus Fall 2. + +**Deutungstexte** (bei einem einzelnen Szenario): +- Fall 1, < 50 %: «Deine Planung ist tendenziell zu optimistisch …»; ≥ 50 %: «… realistisch bis + vorsichtig …». +- Fall 2, > 50 %: «Mit deiner Planung solltest du dein Ziel tendenziell erreichen …»; sonst das + Gegenteil. + +Die Formulierung ist bewusst **weich** («tendenziell»): Schon eine faire Planung landet wegen des +Volatilitäts-Drags leicht unter 50 % ([4.12.5](#4125-ergebnis), [9.26](#926-die-50--schwelle-ist-kein-scharfer-schnitt)). + +**Technik:** kein Eingriff in den Rechenkern. `runMonteCarloMulti` nimmt die Inflation neu **je +Szenario** (`inflationMeanFor`), damit Fall 2 die plan-eigene Inflation verwenden kann. Der +Kombi-Modus rechnet zwei Durchgänge mit **demselben Seed**. + +Referenz: `src/lib/montecarlo.ts` (`plannedReturnOf`), `src/components/MonteCarloDialog.tsx`. + ## 4.13 Sensitivitätsanalyse (Tornado) Die Monte-Carlo-Simulation würfelt alle Unsicherheiten gleichzeitig und beantwortet „wie @@ -2796,13 +2836,13 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests | `calculations.test.ts` | 43 | AHV-Rentenformel, Immobilie, Teilverkauf, Sonderamortisation, AHV einkommensabhängig, „V5 Golden Tests" | | `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` | 13 | Determinismus, Volatilität/Vol-Drag, Böden, Reproduzierbarkeit; Element-Gruppierung und Szenario-Vergleich | +| `montecarlo.test.ts` | 15 | Determinismus, Volatilität/Vol-Drag, Böden, Reproduzierbarkeit; Element-Gruppierung, Szenario-Vergleich, Inflation je Szenario, plannedReturnOf | | `distribution.test.ts` | 8 | Kapitaltopf und Quoten-Zerlegung gegen die Cash-Brücke; Entwurfswerte anwenden ohne Verlust bestehender Felder | | `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** | **119** | | +| **Total** | **121** | | ## 8.2 Testfälle @@ -3204,6 +3244,21 @@ Bewusst **nicht** umgesetzt ist eine automatische Deckelung: Es gibt legitime Gr sparen als die laufende Quote hergibt (etwa wenn ein Cash-Polster aus der Vorphase abgebaut werden soll). Das Werkzeug informiert, es bevormundet nicht. +## 9.26 Die 50-%-Schwelle ist kein scharfer Schnitt + +Die Deutungstexte ([4.12.7](#4127-zwei-fragestellungen-planung-prüfen-vs-ziel-prüfen)) kippen bei +50 %. Das ist eine Vereinfachung: Schon eine **völlig faire** Planung (historischer Mittelwert = +geplanter Mittelwert) erreicht ihren Endbetrag in etwas **weniger** als 50 % der Fälle, weil +Schwankung den Median unter den arithmetischen Mittelwert drückt (Volatilitäts-Drag, +[4.12.5](#4125-ergebnis)). Ein Wert knapp unter 50 % bedeutet also nicht zwingend «zu optimistisch», +sondern kann reiner Vol-Drag sein. + +Deshalb ist die Formulierung weich gehalten («tendenziell») und der bestehende Hinweis bleibt, +dass die Simulation Risiko **um deine Annahmen** misst, nicht deren Richtigkeit +([9.15](#915-monte-carlo-misst-risiko-um-die-annahmen-nicht-deren-richtigkeit)). Eine exakte +Schwelle müsste die Vol-Drag-Korrektur einrechnen – bewusst nicht getan, um die Aussage einfach +zu halten. + --- # 10. Glossar @@ -3246,4 +3301,4 @@ werden soll). Das Werkzeug informiert, es bevormundet nicht. --- -*Ende der Spezifikation v0.15* +*Ende der Spezifikation v0.16* diff --git a/src/components/MonteCarloDialog.tsx b/src/components/MonteCarloDialog.tsx index 4aca972..573f601 100644 --- a/src/components/MonteCarloDialog.tsx +++ b/src/components/MonteCarloDialog.tsx @@ -12,6 +12,7 @@ import { defaultVolatilityLevel, floorFor, paramsForScenario, + plannedReturnOf, runMonteCarloMulti, INFLATION_VOLATILITY_LEVELS, RETURN_BEARING, @@ -28,7 +29,16 @@ import type { PlanInput, ScenarioMeta } from "@/lib/types"; 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", "#7c3äd"]; +const PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"]; + +// Die zwei Fragen, die die Simulation beantworten kann (Roadmap Nr. 46). +type McMode = "PLAN" | "TARGET" | "BOTH"; + +const MODE_OPTIONS: { value: McMode; label: string }[] = [ + { value: "PLAN", label: "Planung prüfen" }, + { value: "TARGET", label: "Ziel prüfen" }, + { value: "BOTH", label: "Beides" }, +]; const RETURN_LEVEL_OPTIONS: { value: ReturnVolatilityLevel; label: string }[] = [ { value: "sehr_niedrig", label: "Sehr niedrig" }, @@ -68,6 +78,9 @@ function returnSigma(el: ElementDraft): number { function inflationSigma(level: InflationVolatilityLevel, manual: string): number { return level === "manuell" ? Number(manual) || 0 : INFLATION_VOLATILITY_LEVELS[level]; } +function orderedPhaseIds(plan: PlanInput): string[] { + return [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber).map((p) => p.id); +} // Deterministische Planungslinie eines Szenarios (Alterspunkte = Phasengrenzen). function detPointsOf(plan: PlanInput, computed: PlanComputed): { age: number; det: number }[] { @@ -103,9 +116,10 @@ export function MonteCarloDialog({ const [loading, setLoading] = useState(scenarios.some((s) => s.id !== meta.id)); const [loadError, setLoadError] = useState(null); const [selectedIds, setSelectedIds] = useState([meta.id]); - const [targets, setTargets] = useState>({ - [meta.id]: Math.max(0, computed.nachlass), - }); + + const [mode, setMode] = useState("PLAN"); + // Fall 2: EIN Zielbetrag für alle Szenarien. + const [manualTarget, setManualTarget] = useState(Math.max(0, computed.nachlass)); const [runs, setRuns] = useState(1000); const [inflMean, setInflMean] = useState(""); @@ -115,12 +129,15 @@ export function MonteCarloDialog({ const [running, setRunning] = useState(false); const [progress, setProgress] = useState({ index: 0, count: 1, fraction: 0 }); - const [results, setResults] = useState(null); + // Ergebnisse getrennt nach Frage: planA = "gegen Planungs-Endbetrag", targetB = "gegen Zielbetrag". + const [resA, setResA] = useState(null); + const [resB, setResB] = useState(null); const scenarioKey = scenarios.map((s) => s.id).join(","); - // Die übrigen Szenarien einmalig nachladen. Ein Plan hat realistisch eine Handvoll - // Szenarien -- gegenüber tausenden Simulationsläufen fällt das nicht ins Gewicht. + 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; @@ -135,10 +152,6 @@ export function MonteCarloDialog({ ); if (cancelled) return; setLoaded((prev) => ({ ...prev, ...Object.fromEntries(entries) })); - setTargets((prev) => ({ - ...prev, - ...Object.fromEntries(entries.map(([id, v]) => [id, Math.max(0, v.computed.nachlass)])), - })); } catch (e) { if (!cancelled) setLoadError(e instanceof Error ? e.message : "Szenarien konnten nicht geladen werden."); } finally { @@ -156,73 +169,125 @@ export function MonteCarloDialog({ [scenarios, loaded] ); - // Ein Parametersatz je LOGISCHEM Element (über die sourceElementId-Kette zusammengefasst). - const groups = useMemo( - () => buildElementGroups(allLoaded, selectedIds), - [allLoaded, selectedIds] - ); + const groups = useMemo(() => buildElementGroups(allLoaded, selectedIds), [allLoaded, selectedIds]); function draftFor(g: ElementGroup): ElementDraft { return drafts[g.rootId] ?? { mean: "", level: defaultVolatilityLevel(g.category), manualSigma: "10" }; } function setDraft(rootId: string, group: ElementGroup, patch: Partial) { setDrafts((prev) => ({ ...prev, [rootId]: { ...draftFor(group), ...prev[rootId], ...patch } })); + clearResults(); + } + function clearResults() { + setResA(null); + setResB(null); } function toggleScenario(id: string) { setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])); - setResults(null); + clearResults(); } const selected = selectedIds.map((id) => loaded[id]).filter((s): s is LoadedScenario => !!s); const anyReturnBearing = selected.some((s) => s.plan.elements.some((e) => RETURN_BEARING.includes(e.category))); - // Pflichtfelder: historische Inflation + je logischem Element die historische Rendite. - const missing = - inflMean.trim() === "" || groups.some((g) => draftFor(g).mean.trim() === ""); + const missingHist = inflMean.trim() === "" || groups.some((g) => draftFor(g).mean.trim() === ""); + const missing = (needsHistorical && missingHist) || (needsTarget && manualTarget <= 0); const canRun = !missing && selected.length > 0 && anyReturnBearing && !loading; - const estSeconds = Math.max(1, Math.round((runs * Math.max(1, selected.length)) / 6000)); + // Laufzeit-Schätzung: im Kombi-Modus laufen zwei Durchgänge. + const passes = mode === "BOTH" ? 2 : 1; + const estSeconds = Math.max(1, Math.round((runs * Math.max(1, selected.length) * passes) / 6000)); async function run() { setRunning(true); - setResults(null); - setProgress({ index: 0, count: selected.length, fraction: 0 }); + clearResults(); + const totalPasses = passes; + setProgress({ index: 0, count: selected.length * totalPasses, fraction: 0 }); try { - const paramByRoot: Record = Object.fromEntries( - groups.map((g) => { - const d = draftFor(g); - return [g.rootId, { mean: Number(d.mean) || 0, sigma: returnSigma(d), floor: floorFor(g.category) }]; - }) + // Streuung (Sigma) je logischem Element -- in beiden Fällen dieselbe. + const sigmaByRoot: Record = Object.fromEntries( + groups.map((g) => [g.rootId, returnSigma(draftFor(g))]) ); - // EIN Seed für alle Szenarien: strukturgleiche Szenarien teilen damit dieselben - // Marktpfade, und die Unterschiede sind strukturell statt zufällig. + const floorByRoot: Record = Object.fromEntries(groups.map((g) => [g.rootId, floorFor(g.category)])); + const rootByMember = new Map(); + for (const g of groups) for (const id of g.memberIds) rootByMember.set(id, g.rootId); + const seed = (Math.random() * 2 ** 32) >>> 0; - const runInputs: ScenarioRunInput[] = selected.map((s) => ({ - scenarioId: s.id, - name: s.name, - plan: s.plan, - target: targets[s.id] ?? 0, - })); - const res = await runMonteCarloMulti( - runInputs, - { - runs, - inflationMean: Number(inflMean) || 0, - inflationSigma: inflationSigma(inflLevel, inflManual), - seed, - elementsFor: (p) => paramsForScenario(p, groups, paramByRoot), - }, - (index, count, fraction) => setProgress({ index, count, fraction }) - ); - setResults(res); + 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 }); + + // --- 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), + }, + onProg + ); + setResA(res); + passIndex += 1; + } + + // --- Fall 2: gegen den Zielbetrag, gewürfelt um die GEPLANTEN Werte --- + if (needsTarget) { + const inputs: ScenarioRunInput[] = selected.map((s) => ({ + scenarioId: s.id, + name: s.name, + plan: s.plan, + target: manualTarget, + })); + const res = await runMonteCarloMulti( + inputs, + { + runs, + inflationMeanFor: (p) => p.inflationRateDefault, // plan-eigene Inflation + inflationSigma: inflSig, + seed, + elementsFor: (p) => { + const ids = orderedPhaseIds(p); + const out: Record = {}; + for (const e of p.elements) { + if (!RETURN_BEARING.includes(e.category)) continue; + const root = rootByMember.get(e.id); + const sigma = root ? sigmaByRoot[root] : returnSigma({ mean: "", level: defaultVolatilityLevel(e.category), manualSigma: "10" }); + out[e.id] = { mean: plannedReturnOf(e, ids), sigma, floor: floorFor(e.category) }; + } + return out; + }, + }, + onProg + ); + setResB(res); + } } finally { setRunning(false); } } - const overallProgress = - progress.count > 0 ? (progress.index + progress.fraction) / progress.count : 0; + 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 (
@@ -239,38 +304,52 @@ export function MonteCarloDialog({
+ {/* Modus-Umschalter: die zwei Fragen (Roadmap Nr. 46) */} +
+
+ Was möchtest du wissen? + +
+
+ {MODE_OPTIONS.map((m) => ( + + ))} +
+

{modeHint}

+
+ {/* Erklärung */}

Was ist das? Dein Plan rechnet mit einer festen Rendite und Inflation pro Jahr. Real schwanken beide. Die Simulation würfelt viele tausend mögliche - Verläufe und zeigt, wie oft dein Plan aufgeht. -

-

- Eingabe: je Anlage die historische Durchschnittsrendite (der Mittelpunkt, - um den gewürfelt wird) und wie stark sie schwankt; dazu dieselben Angaben für die Inflation. - Ergebnis: die Wahrscheinlichkeit, dass das Geld reicht bzw. dein Zielbetrag - erreicht wird, plus ein Fächer vom pessimistischen bis zum optimistischen Fall. -

-

- Mehrere Szenarien: Du kannst unten mehrere Szenarien dieses Plans - gleichzeitig rechnen. Die historischen Annahmen werden dabei nur einmal erfasst - und für alle Szenarien verwendet – sonst würdest du deine Eingaben vergleichen statt der Szenarien. Alle - Szenarien laufen zudem mit demselben Zufalls-Seed, damit Unterschiede nicht blosses Rauschen sind. + Verläufe und zeigt, wie oft ein Betrag erreicht wird.

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 - Marktschock gemeinsam. Die Prozentzahl gilt immer nur relativ zu deinen Annahmen — sie beurteilt nicht, - ob deine Durchschnittswerte realistisch sind. + Marktschock gemeinsam. Alle Szenarien laufen mit demselben Zufalls-Seed, damit Unterschiede nicht blosses + Rauschen sind. Die Prozentzahl gilt immer nur relativ zu deinen Annahmen.

- {/* Szenario-Auswahl inkl. Zielbetrag je Szenario */} + {/* Szenario-Auswahl */}
Szenarien - +
{loading &&

Szenarien werden geladen…

} {loadError &&

{loadError}

} @@ -278,6 +357,7 @@ export function MonteCarloDialog({ {scenarios.map((s) => { const isLoaded = !!loaded[s.id]; const checked = selectedIds.includes(s.id); + const nachlass = loaded[s.id]?.computed.nachlass; return (
- {checked && ( -
- setTargets((prev) => ({ ...prev, [s.id]: v }))} - /> -
+ {checked && needsHistorical && typeof nachlass === "number" && ( + + Planungs-Endbetrag: {formatChf(Math.max(0, nachlass))} + )}
); })}
- {selected.length === 0 && ( -

Bitte mindestens ein Szenario auswählen.

- )} + {selected.length === 0 &&

Bitte mindestens ein Szenario auswählen.

} + {/* Zielbetrag (Fall 2) */} + {needsTarget && ( +
+ { + setManualTarget(v); + clearResults(); + }} + /> +
+ )} + {/* Inflation */}
Inflation (Plan-Ebene)
- + {needsHistorical ? ( + { + setInflMean(v); + clearResults(); + }} + /> + ) : ( +
+ +
+ {plan.inflationRateDefault} % +
+
+ )} setInflLevel(v)} + onChange={(v: InflationVolatilityLevel) => { + setInflLevel(v); + clearResults(); + }} options={INFLATION_LEVEL_OPTIONS} /> {inflLevel === "manuell" ? ( - setInflManual(String(v))} /> + { setInflManual(String(v)); clearResults(); }} /> ) : ( )}
- {/* Renditetragende Elemente -- ein Satz je logischem Element */} + {/* Renditetragende Elemente */}
Renditetragende Elemente {selected.length > 1 && ( - + )}
{groups.length === 0 && ( @@ -357,6 +458,11 @@ export function MonteCarloDialog({ {groups.map((g) => { const d = draftFor(g); const partial = g.scenarioIds.length < selected.length; + const planned = plannedReturnOf( + (loaded[g.scenarioIds[0]]?.plan.elements ?? []).find((e) => g.memberIds.includes(e.id)) ?? + ({ category: g.category, phaseValues: {} } as never), + orderedPhaseIds(loaded[g.scenarioIds[0]]?.plan ?? plan) + ); return (
@@ -369,12 +475,24 @@ export function MonteCarloDialog({ )}
- setDraft(g.rootId, g, { mean: v })} - /> + {needsHistorical ? ( + setDraft(g.rootId, g, { mean: v })} + /> + ) : ( +
+ +
+ {planned} % +
+
+ )} {d.level === "manuell" ? ( - setDraft(g.rootId, g, { manualSigma: String(v) })} - /> + setDraft(g.rootId, g, { manualSigma: String(v) })} /> ) : ( )} @@ -408,7 +521,7 @@ export function MonteCarloDialog({ setRuns(Number(v))} + onChange={(v: string) => { setRuns(Number(v)); clearResults(); }} options={[ { value: "1000", label: "1'000 (schnell)" }, { value: "5000", label: "5'000" }, @@ -416,12 +529,19 @@ export function MonteCarloDialog({ ]} />

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

- {missing &&

Bitte alle Pflichtfelder (Ø-Werte) ausfüllen, um die Simulation zu starten.

} + {missing && ( +

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

+ )}
{running && (
@@ -441,12 +559,15 @@ export function MonteCarloDialog({ )}
- {results && results.length > 0 && ( + {(resA || resB) && ( @@ -470,162 +591,191 @@ function ReadOnlySigma({ value }: { value: number }) { ); } +// Deutungstext Fall 1: wie realistisch ist die Planung? Weiche Formulierung, weil schon eine +// faire Planung wegen des Volatilitäts-Drags leicht unter 50 % landet. +function planVerdict(prob: number): { text: string; tone: "danger" | "success" | "muted" } { + const pct = Math.round(prob * 100); + if (prob < 0.5) + return { + text: `Deine Planung ist tendenziell zu optimistisch: Der geplante Endbetrag wird unter Berücksichtigung der historischen Renditen nur in ${pct} % der Fälle erreicht.`, + tone: "danger", + }; + return { + text: `Deine Planung ist tendenziell realistisch bis vorsichtig: Der geplante Endbetrag wird unter Berücksichtigung der historischen Renditen in ${pct} % der Fälle erreicht.`, + tone: "success", + }; +} + +// Deutungstext Fall 2: erreiche ich mein Ziel? +function targetVerdict(prob: number): { text: string; tone: "danger" | "success" | "muted" } { + const pct = Math.round(prob * 100); + if (prob > 0.5) + return { + text: `Mit deiner Planung solltest du dein Ziel tendenziell erreichen: Der gewünschte Zielbetrag wird in ${pct} % der Fälle erreicht.`, + tone: "success", + }; + return { + text: `Mit deiner Planung erreichst du dein Ziel tendenziell nicht: Der gewünschte Zielbetrag wird nur in ${pct} % der Fälle erreicht.`, + tone: "danger", + }; +} + +function VerdictCard({ title, prob, verdict }: { title: string; prob: number; verdict: { text: string; tone: "danger" | "success" | "muted" } }) { + const color = verdict.tone === "danger" ? "text-danger" : verdict.tone === "success" ? "text-success" : "text-fg"; + const border = verdict.tone === "danger" ? "border-danger bg-danger-soft" : "border-border bg-surface-2"; + return ( +
+
+ {title} + {Math.round(prob * 100)} % +
+

{verdict.text}

+
+ ); +} + function MonteCarloResults({ - results, + mode, + resA, + resB, + manualTarget, detPoints, }: { - results: ScenarioMcResult[]; + mode: McMode; + resA: ScenarioMcResult[] | null; // Fall 1 (gegen Planungs-Endbetrag) + resB: ScenarioMcResult[] | null; // Fall 2 (gegen Zielbetrag) + manualTarget: number; detPoints: { age: number; det: number }[]; }) { - const single = results.length === 1; + // Der Fächer stammt aus Fall 1 (historische Renditen = das ehrliche Risikobild). Fehlt Fall 1 + // (reiner Ziel-Modus), nehmen wir Fall 2. + const fanRes = resA ?? resB; + const single = !!fanRes && fanRes.length === 1; - // Einzelnes Szenario: der gewohnte Fächer (Band + Median + Planungslinie). const singleData = useMemo(() => { - if (!single) return []; - return results[0].bands.map((b) => ({ + if (!fanRes || !single) return []; + return fanRes[0].bands.map((b) => ({ age: b.age, band: [b.p10, b.p90] as [number, number], median: b.p50, det: detPoints.find((d) => d.age === b.age)?.det ?? null, })); - }, [single, results, detPoints]); + }, [fanRes, single, detPoints]); - // Mehrere Szenarien: nur die Median-Linien -- übereinandergelegte Bänder wären Farbbrei. const compareData = useMemo(() => { - if (single) return []; - const ages = Array.from(new Set(results.flatMap((r) => r.bands.map((b) => b.age)))).sort((a, b) => a - b); + if (!fanRes || 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 }; - for (const r of results) row[r.scenarioId] = r.bands.find((b) => b.age === age)?.p50 ?? null; + for (const r of fanRes) row[r.scenarioId] = r.bands.find((b) => b.age === age)?.p50 ?? null; return row; }); - }, [single, results]); + }, [fanRes, single]); + + const byId = (arr: ScenarioMcResult[] | null, id: string) => arr?.find((r) => r.scenarioId === id) ?? null; + const rows = (resA ?? resB)!; return (
- {/* Vergleichstabelle -- das eigentliche Vergleichsinstrument */} + {/* Deutungstexte -- nur sinnvoll bei einem einzelnen Szenario; bei mehreren zählt die Tabelle. */} + {single && ( +
+ {resA && } + {resB && } +
+ )} + + {/* Vergleichstabelle */}
Ergebnis je Szenario - {!single && ( - - )} +
- - + {resA && } + {resB && } - + - + - {results.map((r, i) => ( - - - - - - - - - - ))} + {rows.map((r, i) => { + const a = byId(resA, r.scenarioId); + const b = byId(resB, r.scenarioId); + const fan = a ?? b!; + return ( + + + {resA && } + {resB && } + + + + + + ); + })}
SzenarioZielErfolgPlanung erreichtZiel erreichtRuinPessimistisch (10 %)Pessim. (10 %) MedianOptimistisch (90 %)Optim. (90 %)
- - {!single && ( - - )} - {r.name} - - {formatChf(r.target)} - {Math.round(r.successProbability * 100)} % - - {Math.round(r.ruinProbability * 100)} % - {formatChf(r.finalWealthP10)}{formatChf(r.finalWealthMedian)}{formatChf(r.finalWealthP90)}
+ + {!single && } + {r.name} + + {a ? `${Math.round(a.successProbability * 100)} %` : "–"}{b ? `${Math.round(b.successProbability * 100)} %` : "–"}{Math.round(fan.ruinProbability * 100)} %{formatChf(fan.finalWealthP10)}{formatChf(fan.finalWealthMedian)}{formatChf(fan.finalWealthP90)}

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

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

+ {single + ? "Das Band reicht vom pessimistischen (10 %) bis zum optimistischen (90 %) Fall, die dunkle Linie ist der Median. Die gestrichelte Linie ist deine deterministische Planung – sie liegt meist leicht über dem Median (Schwankung frisst Rendite)." + : "Je Szenario die mittlere Entwicklung (Median). Wähle ein einzelnes Szenario, um den vollen Fächer zu sehen."} +

+
+ + {single ? ( + + + `${v} J.`} /> + Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} /> + [typeof v === "number" ? formatChf(v) : v, name]} labelFormatter={(v) => `Alter ${v}`} /> + + + + + + ) : ( + + + `${v} J.`} /> + Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} /> + [typeof v === "number" ? formatChf(v) : v, name]} labelFormatter={(v) => `Alter ${v}`} /> + + {fanRes.map((r, i) => ( + + ))} + + )} + +
-

- {single ? ( - <> - Das Band reicht vom pessimistischen (10 %) bis zum optimistischen (90 %) Fall, die dunkle Linie ist der - Median. Die gestrichelte Linie ist deine deterministische Planung – sie liegt meist leicht über dem Median - (Schwankung frisst Rendite). - - ) : ( - <> - Je Szenario die mittlere Entwicklung (Median). Die vollständigen 10–90 %-Bänder werden hier bewusst nicht - übereinandergelegt – wähle ein einzelnes Szenario, um den Fächer zu sehen. - - )} -

-
- - {single ? ( - - - `${v} J.`} /> - Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} /> - [typeof v === "number" ? formatChf(v) : v, name]} - labelFormatter={(v) => `Alter ${v}`} - /> - - - - - - ) : ( - - - `${v} J.`} - /> - Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} /> - [typeof v === "number" ? formatChf(v) : v, name]} - labelFormatter={(v) => `Alter ${v}`} - /> - - {results.map((r, i) => ( - - ))} - - )} - -
-
+ )}
); } diff --git a/src/lib/montecarlo.test.ts b/src/lib/montecarlo.test.ts index 88309a4..390cec5 100644 --- a/src/lib/montecarlo.test.ts +++ b/src/lib/montecarlo.test.ts @@ -4,12 +4,14 @@ import { buildElementGroups, defaultVolatilityLevel, paramsForScenario, + plannedReturnOf, resolveRootElementId, runMonteCarlo, runMonteCarloMulti, RETURN_VOLATILITY_LEVELS, type MonteCarloParams, } from "@/lib/montecarlo"; +import type { ElementInput } from "@/lib/types"; import type { PlanInput } from "@/lib/types"; // Plan: 40-jährig, 1 Phase 10 Jahre, ein Sonstiges Vermögen 100'000 @ 5 %, 2 % Inflation. @@ -226,7 +228,7 @@ describe("Monte Carlo: mehrere Szenarien", () => { ], { runs: 2000, - inflationMean: 2, + inflationMeanFor: () => 2, inflationSigma: 0, seed: 4242, elementsFor: (p) => paramsForScenario(p, groups, byRoot), @@ -242,4 +244,62 @@ describe("Monte Carlo: mehrere Szenarien", () => { expect(rBase.scenarioId).toBe("base"); expect(rOpt.target).toBe(targetOpt); }); + + it("Inflation je Szenario: Fall 2 nutzt die plan-eigene Inflation", async () => { + // Zwei Szenarien mit unterschiedlicher Inflation; bei Streuung 0 muss jedes seine eigene + // Inflation verwenden (nicht eine gemeinsame). Reines Vermoegen ohne Rendite -> der + // Realwert bleibt gleich, aber der Endwert NOMINAL haengt an der Inflation nicht (Cash 0, + // keine nominalen Ausgaben) -- deshalb pruefen wir ueber die Ausgaben: hoehere Inflation + // laesst das Vermoegen staerker schmelzen. + const low = basePlan(); + const high: PlanInput = { ...basePlan(), id: "high", inflationRateDefault: 6 }; + high.elements = high.elements.map((e) => ({ ...e, id: `${e.id}-h` })); + // Ausgaben, damit die Inflation ueberhaupt wirkt. + const withExpense = (p: PlanInput, sfx: string): PlanInput => ({ + ...p, + elements: [ + ...p.elements, + { id: `exp-${sfx}`, category: "EXPENSE", name: "A", ownerRole: "HOUSEHOLD", orderIndex: 9, phaseValues: { p1: { amount: 5000, teuerungsausgleich: 0 } }, transitionValues: {} }, + ], + }); + const lo = withExpense(low, "l"); + const hi = withExpense(high, "h"); + + const [rLo, rHi] = await runMonteCarloMulti( + [ + { scenarioId: "lo", name: "2%", plan: lo, target: 0 }, + { scenarioId: "hi", name: "6%", plan: hi, target: 0 }, + ], + { + runs: 200, + inflationMeanFor: (p) => p.inflationRateDefault, // Fall 2: plan-eigene Inflation + inflationSigma: 0, + seed: 1, + elementsFor: () => ({}), // ohne renditetragende Elemente + } + ); + // Hoehere Inflation -> hoehere nominale Ausgaben -> tieferes Endvermoegen. + expect(rHi.finalWealthMedian).toBeLessThan(rLo.finalWealthMedian); + }); + + it("plannedReturnOf: erste gesetzte Phasenrendite, Wertsteigerung bei Immobilien", () => { + const orderedIds = ["p1", "p2"]; + const asset: ElementInput = { + id: "a", category: "OTHER_ASSET", name: "ETF", ownerRole: "HOUSEHOLD", orderIndex: 1, + phaseValues: { p1: { startValue: 100000, expectedReturn: 5 }, p2: { expectedReturn: 7 } }, + transitionValues: {}, + }; + expect(plannedReturnOf(asset, orderedIds)).toBe(5); // erste Phase + + const re: ElementInput = { + id: "r", category: "REAL_ESTATE", name: "Haus", ownerRole: "HOUSEHOLD", orderIndex: 2, + phaseValues: { p1: { purchasePrice: 800000, valueGrowth: 1.5 } }, + transitionValues: {}, + }; + expect(plannedReturnOf(re, orderedIds)).toBe(1.5); // Wertsteigerung statt expectedReturn + + // Kein Wert gesetzt -> 0. + const leer: ElementInput = { id: "x", category: "OTHER_ASSET", name: "x", ownerRole: "HOUSEHOLD", orderIndex: 3, phaseValues: {}, transitionValues: {} }; + expect(plannedReturnOf(leer, orderedIds)).toBe(0); + }); }); diff --git a/src/lib/montecarlo.ts b/src/lib/montecarlo.ts index c156cbf..f486a01 100644 --- a/src/lib/montecarlo.ts +++ b/src/lib/montecarlo.ts @@ -9,7 +9,7 @@ import { computePlan, type PlanSample } from "@/lib/calculations"; import type { ElementCategory } from "@/lib/elements"; -import type { PlanInput } from "@/lib/types"; +import type { ElementInput, PlanInput } from "@/lib/types"; // Standardabweichungen (annualisiert, in %) hinter den Streuungsstufen. Recherchiert und // gerundet: Anleihen ~6 %, globale Aktien ~15-18 %, Schweizer Immobilien(fonds) ~2 %, @@ -195,7 +195,9 @@ export async function runMonteCarloMulti( scenarios: ScenarioRunInput[], common: { runs: number; - inflationMean: number; + // Inflations-Mittelwert je Szenario (Fall 1: historischer Wert für alle; Fall 2: die + // plan-eigene Inflation, die je Szenario abweichen kann). + inflationMeanFor: (plan: PlanInput) => number; inflationSigma: number; seed: number; elementsFor: (plan: PlanInput) => Record; @@ -209,7 +211,7 @@ export async function runMonteCarloMulti( s.plan, { runs: common.runs, - inflationMean: common.inflationMean, + inflationMean: common.inflationMeanFor(s.plan), inflationSigma: common.inflationSigma, elements: common.elementsFor(s.plan), target: s.target, @@ -222,6 +224,21 @@ export async function runMonteCarloMulti( return results; } +// Repräsentative GEPLANTE Rendite eines Elements für die Simulation "gegen den Zielbetrag" +// (Fall 2): der Mittelpunkt ist dann nicht ein historischer Wert, sondern der im Plan +// hinterlegte. Genommen wird die erste Phase, in der ein Wert gesetzt ist (bei Immobilien +// die Wertsteigerung). Unterschiedliche Renditen über die Phasen werden bewusst auf einen +// Wert reduziert -- dieselbe Körnung wie bei der historischen Rendite (ein Wert je Element). +export function plannedReturnOf(element: ElementInput, orderedPhaseIds: string[]): number { + for (const pid of orderedPhaseIds) { + const pd = element.phaseValues[pid]; + if (!pd) continue; + const v = element.category === "REAL_ESTATE" ? pd.valueGrowth : pd.expectedReturn; + if (typeof v === "number") return v; + } + return 0; +} + // --- Zufallszahlen (seedbar, damit ein Lauf reproduzierbar ist) --- function mulberry32(seed: number): () => number {