"use client"; import { useEffect, useMemo, useState } from "react"; import { Area, CartesianGrid, ComposedChart, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; import { Dices, X } from "lucide-react"; import { NumberField, SelectField, MoneyField, RequiredNumberField } from "@/components/FormField"; import { InfoBubble } from "@/components/InfoBubble"; import { api } from "@/lib/api-client"; import { formatChf } from "@/lib/format"; import { buildElementGroups, defaultVolatilityLevel, floorFor, paramsForScenario, runMonteCarloMulti, INFLATION_VOLATILITY_LEVELS, RETURN_BEARING, RETURN_VOLATILITY_LEVELS, type ElementGroup, type ElementMcParams, type InflationVolatilityLevel, type ReturnVolatilityLevel, type ScenarioMcResult, type ScenarioRunInput, } from "@/lib/montecarlo"; import { CATEGORY_LABELS } from "@/lib/elements"; 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 RETURN_LEVEL_OPTIONS: { value: ReturnVolatilityLevel; label: string }[] = [ { value: "sehr_niedrig", label: "Sehr niedrig" }, { value: "niedrig", label: "Niedrig" }, { value: "moderat", label: "Moderat" }, { value: "hoch", label: "Hoch" }, { value: "sehr_hoch", label: "Sehr hoch" }, { value: "manuell", label: "Manuell" }, ]; const INFLATION_LEVEL_OPTIONS: { value: InflationVolatilityLevel; label: string }[] = [ { value: "sehr_niedrig", label: "Sehr niedrig" }, { value: "niedrig", label: "Niedrig" }, { value: "manuell", label: "Manuell" }, ]; const RETURN_HELP = "Wie stark die Jahresrendite schwankt. Sehr niedrig ≈ Staatsanleihen/Geldmarkt · Niedrig ≈ Immobilien, defensive Mischportfolios · Moderat ≈ breit diversifizierte Aktien-ETFs/Fonds · Hoch ≈ Einzelaktien, Branchen-/Schwellenländerfonds · Sehr hoch ≈ Kryptowährungen, hochspekulative Anlagen."; const INFLATION_HELP = "Wie stark die Inflation schwankt. Für die Schweiz ist sie historisch sehr stabil (Sehr niedrig ≈ 1 %). Höhere Stufen wären Hyperinflations-Annahmen."; interface ElementDraft { mean: string; level: ReturnVolatilityLevel; manualSigma: string; } interface LoadedScenario { id: string; name: string; plan: PlanInput; computed: PlanComputed; } function returnSigma(el: ElementDraft): number { return el.level === "manuell" ? Number(el.manualSigma) || 0 : RETURN_VOLATILITY_LEVELS[el.level]; } function inflationSigma(level: InflationVolatilityLevel, manual: string): number { return level === "manuell" ? Number(manual) || 0 : INFLATION_VOLATILITY_LEVELS[level]; } // Deterministische Planungslinie eines Szenarios (Alterspunkte = Phasengrenzen). function detPointsOf(plan: PlanInput, computed: PlanComputed): { age: number; det: number }[] { const startAge = plan.persons.find((p) => p.role === "PERSON_A")?.age ?? plan.persons[0]?.age ?? 0; if (computed.phases.length === 0) return []; const pts = [{ age: startAge, det: computed.phases[0].startWealthNominal }]; let acc = 0; for (const ph of computed.phases) { acc += ph.durationYears; pts.push({ age: startAge + acc, det: ph.endWealthNominal }); } return pts; } export function MonteCarloDialog({ plan, computed, meta, scenarios, onClose, }: { plan: PlanInput; computed: PlanComputed; meta: ScenarioMeta; // Alle Szenarien dieses Plans (auch nicht ausgewählte) -- sie werden geladen, damit sich // die Herkunfts-Kette der Elemente auch über übersprungene Zwischen-Szenarien auflöst. scenarios: ScenarioMeta[]; onClose: () => void; }) { const [loaded, setLoaded] = useState>(() => ({ [meta.id]: { id: meta.id, name: meta.name, plan, computed }, })); 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 [runs, setRuns] = useState(1000); const [inflMean, setInflMean] = useState(""); const [inflLevel, setInflLevel] = useState("sehr_niedrig"); const [inflManual, setInflManual] = useState("1"); const [drafts, setDrafts] = useState>({}); const [running, setRunning] = useState(false); const [progress, setProgress] = useState({ index: 0, count: 1, fraction: 0 }); const [results, setResults] = 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. useEffect(() => { const missing = scenarios.filter((s) => s.id !== meta.id); if (missing.length === 0) return; let cancelled = false; (async () => { try { const entries = await Promise.all( missing.map(async (s) => { const data = await api.get<{ plan: PlanInput; computed: PlanComputed }>(`/api/scenarios/${s.id}`); return [s.id, { id: s.id, name: s.name, plan: data.plan, computed: data.computed }] as const; }) ); 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 { if (!cancelled) setLoading(false); } })(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [scenarioKey, meta.id]); const allLoaded = useMemo( () => scenarios.map((s) => loaded[s.id]).filter((s): s is LoadedScenario => !!s), [scenarios, loaded] ); // Ein Parametersatz je LOGISCHEM Element (über die sourceElementId-Kette zusammengefasst). 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 } })); } function toggleScenario(id: string) { setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])); setResults(null); } 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 canRun = !missing && selected.length > 0 && anyReturnBearing && !loading; const estSeconds = Math.max(1, Math.round((runs * Math.max(1, selected.length)) / 6000)); async function run() { setRunning(true); setResults(null); setProgress({ index: 0, count: selected.length, 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) }]; }) ); // EIN Seed für alle Szenarien: strukturgleiche Szenarien teilen damit dieselben // Marktpfade, und die Unterschiede sind strukturell statt zufällig. 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); } finally { setRunning(false); } } const overallProgress = progress.count > 0 ? (progress.index + progress.fraction) / progress.count : 0; return (
e.stopPropagation()} className="flex w-full max-w-4xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl" >

Monte-Carlo-Simulation

{/* 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.

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.

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

Szenarien werden geladen…

} {loadError &&

{loadError}

}
{scenarios.map((s) => { const isLoaded = !!loaded[s.id]; const checked = selectedIds.includes(s.id); return (
{checked && (
setTargets((prev) => ({ ...prev, [s.id]: v }))} />
)}
); })}
{selected.length === 0 && (

Bitte mindestens ein Szenario auswählen.

)}
{/* Inflation */}
Inflation (Plan-Ebene)
setInflLevel(v)} options={INFLATION_LEVEL_OPTIONS} /> {inflLevel === "manuell" ? ( setInflManual(String(v))} /> ) : ( )}
{/* Renditetragende Elemente -- ein Satz je logischem Element */}
Renditetragende Elemente {selected.length > 1 && ( )}
{groups.length === 0 && (

Die ausgewählten Szenarien haben keine renditetragenden Elemente (PK, 3a, Sonstiges Vermögen, Immobilie).

)}
{groups.map((g) => { const d = draftFor(g); const partial = g.scenarioIds.length < selected.length; return (
{g.name} {CATEGORY_LABELS[g.category]} {partial && ( nur in: {g.scenarioIds.map((id) => loaded[id]?.name ?? id).join(", ")} )}
setDraft(g.rootId, g, { mean: v })} /> setDraft(g.rootId, g, { level: v })} options={RETURN_LEVEL_OPTIONS} /> {d.level === "manuell" ? ( setDraft(g.rootId, g, { manualSigma: String(v) })} /> ) : ( )}
{(g.category === "PENSION_FUND" || g.category === "PILLAR_3A") && (

Boden 0 %: {CATEGORY_LABELS[g.category]} schreibt keine negative Rendite gut.

)}
); })}
{/* Lauf-Parameter */}
setRuns(Number(v))} options={[ { value: "1000", label: "1'000 (schnell)" }, { value: "5000", label: "5'000" }, { value: "10000", label: "10'000 (genau)" }, ]} />

{selected.length > 1 ? `${selected.length} Szenarien · ` : ""}geschätzt ~{estSeconds} s

{missing &&

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

}
{running && (
)}
{results && results.length > 0 && ( )}
); } function ReadOnlySigma({ value }: { value: number }) { return (
{value} %
); } function MonteCarloResults({ results, detPoints, }: { results: ScenarioMcResult[]; detPoints: { age: number; det: number }[]; }) { const single = results.length === 1; // Einzelnes Szenario: der gewohnte Fächer (Band + Median + Planungslinie). const singleData = useMemo(() => { if (!single) return []; return results[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]); // 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); 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; return row; }); }, [single, results]); return (
{/* Vergleichstabelle -- das eigentliche Vergleichsinstrument */}
Ergebnis je Szenario {!single && ( )}
{results.map((r, i) => ( ))}
Szenario Ziel Erfolg Ruin Pessimistisch (10 %) Median Optimistisch (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)}

{results[0].runs.toLocaleString("de-CH")} Läufe je Szenario {!single && " · gemeinsamer Zufalls-Seed"}

{single ? "Vermögensfächer nach Alter (nominal)" : "Vermögensverlauf im Median nach Alter (nominal)"}

{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) => ( ))} )}
); }