Monte-Carlo: zwei Fragestellungen (Planung pruefen / Ziel pruefen / Beides)
Deploy App / deploy (push) Successful in 1m3s

Roadmap Nr. 46. Umschalter oben trennt die zwei Fragen; kein Eingriff in den
Rechenkern (reine Dialog-Logik + kleine montecarlo.ts-Erweiterung).

- "Planung pruefen" (Fall 1, wie bisher): wuerfelt um HISTORISCHE Renditen,
  prueft gegen den Planungs-Endbetrag (read-only) -> "wie realistisch ist
  meine Planung?"
- "Ziel pruefen" (Fall 2, neu): wuerfelt um die GEPLANTEN Werte aus dem Plan,
  prueft gegen einen manuellen Zielbetrag -> "erreiche ich mein Ziel?"; keine
  historischen Mittelwerte noetig, nur Streuung
- "Beides": beide Durchgaenge gleichzeitig, gemeinsamer Seed
- Deutungstexte je Fall, weich formuliert (Volatilitaets-Drag, siehe 9.26)
- Zielbetrag in Fall 2 = einer fuer alle Szenarien; Ruin/Faecher aus dem
  historischen Durchgang (ehrliches Risikobild)

montecarlo.ts: runMonteCarloMulti nimmt Inflation je Szenario
(inflationMeanFor); neuer Helfer plannedReturnOf (erste Phasenrendite,
Wertsteigerung bei Immobilien). Nebenbei die vom Umlaut-Sweep verstuemmelte
Hex-Farbe #7c3aed korrigiert.

8 Tests (119 -> 121). SPEZIFIKATION auf 0.16 (Kap. 4.12.7, 9.26 neu).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 13:54:45 +02:00
parent 1d046e9f7e
commit 683d240518
4 changed files with 521 additions and 239 deletions
+380 -230
View File
@@ -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<string | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([meta.id]);
const [targets, setTargets] = useState<Record<string, number>>({
[meta.id]: Math.max(0, computed.nachlass),
});
const [mode, setMode] = useState<McMode>("PLAN");
// Fall 2: EIN Zielbetrag für alle Szenarien.
const [manualTarget, setManualTarget] = useState<number>(Math.max(0, computed.nachlass));
const [runs, setRuns] = useState(1000);
const [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<ScenarioMcResult[] | null>(null);
// Ergebnisse getrennt nach Frage: planA = "gegen Planungs-Endbetrag", targetB = "gegen Zielbetrag".
const [resA, setResA] = useState<ScenarioMcResult[] | null>(null);
const [resB, setResB] = useState<ScenarioMcResult[] | null>(null);
const scenarioKey = scenarios.map((s) => s.id).join(",");
// 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<ElementDraft>) {
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<string, ElementMcParams> = 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<string, number> = 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<string, number> = Object.fromEntries(groups.map((g) => [g.rootId, floorFor(g.category)]));
const rootByMember = new Map<string, string>();
for (const g of groups) for (const id of g.memberIds) rootByMember.set(id, g.rootId);
const seed = (Math.random() * 2 ** 32) >>> 0;
const 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<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),
},
onProg
);
setResA(res);
passIndex += 1;
}
// --- Fall 2: gegen den Zielbetrag, gewürfelt um die GEPLANTEN Werte ---
if (needsTarget) {
const inputs: ScenarioRunInput[] = selected.map((s) => ({
scenarioId: s.id,
name: s.name,
plan: s.plan,
target: manualTarget,
}));
const res = await runMonteCarloMulti(
inputs,
{
runs,
inflationMeanFor: (p) => p.inflationRateDefault, // plan-eigene Inflation
inflationSigma: inflSig,
seed,
elementsFor: (p) => {
const ids = orderedPhaseIds(p);
const out: Record<string, ElementMcParams> = {};
for (const e of p.elements) {
if (!RETURN_BEARING.includes(e.category)) continue;
const root = rootByMember.get(e.id);
const sigma = root ? sigmaByRoot[root] : returnSigma({ mean: "", level: defaultVolatilityLevel(e.category), manualSigma: "10" });
out[e.id] = { mean: plannedReturnOf(e, ids), sigma, floor: floorFor(e.category) };
}
return out;
},
},
onProg
);
setResB(res);
}
} finally {
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 (
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
@@ -239,38 +304,52 @@ export function MonteCarloDialog({
</button>
</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">
<strong className="text-fg">Was ist das?</strong> Dein Plan rechnet mit einer festen Rendite und Inflation
pro Jahr. Real schwanken beide. Die Simulation würfelt <strong className="text-fg">viele tausend mögliche
Verläufe</strong> und zeigt, wie oft dein Plan aufgeht.
</p>
<p className="mb-2">
<strong className="text-fg">Eingabe:</strong> je Anlage die historische Durchschnittsrendite (der Mittelpunkt,
um den gewürfelt wird) und wie stark sie schwankt; dazu dieselben Angaben für die Inflation.
<strong className="text-fg"> Ergebnis:</strong> die Wahrscheinlichkeit, dass das Geld reicht bzw. dein Zielbetrag
erreicht wird, plus ein Fächer vom pessimistischen bis zum optimistischen Fall.
</p>
<p className="mb-2">
<strong className="text-fg">Mehrere Szenarien:</strong> Du kannst unten mehrere Szenarien dieses Plans
gleichzeitig rechnen. Die historischen Annahmen werden dabei nur <strong className="text-fg">einmal</strong> erfasst
und für alle Szenarien verwendet sonst würdest du deine Eingaben vergleichen statt der Szenarien. Alle
Szenarien laufen zudem mit demselben Zufalls-Seed, damit Unterschiede nicht blosses Rauschen sind.
Verläufe</strong> und zeigt, wie oft ein Betrag erreicht wird.
</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
Marktschock gemeinsam. <em>Die Prozentzahl gilt immer nur relativ zu deinen Annahmen sie beurteilt nicht,
ob deine Durchschnittswerte realistisch sind.</em>
Marktschock gemeinsam. Alle Szenarien laufen mit demselben Zufalls-Seed, damit Unterschiede nicht blosses
Rauschen sind. <em>Die Prozentzahl gilt immer nur relativ zu deinen Annahmen.</em>
</p>
</div>
{/* Szenario-Auswahl inkl. Zielbetrag je Szenario */}
{/* Szenario-Auswahl */}
<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. Der Zielbetrag ist je Szenario eigen und mit dem jeweils geplanten Endvermögen vorbelegt: So misst die Erfolgswahrscheinlichkeit, wie oft jedes Szenario sein eigenes Versprechen hält. Ein pessimistisch geplantes Szenario hat einen tieferen Zielbetrag und damit eine höhere Erfolgswahrscheinlichkeit." />
<InfoBubble text="Wähle, für welche Szenarien dieses Plans gerechnet werden soll. Die Streuung und (bei «Planung prüfen») die historischen Renditen werden nur einmal je logischem Element erfasst und für alle Szenarien verwendet." />
</div>
{loading && <p className="text-xs text-muted">Szenarien werden geladen</p>}
{loadError && <p className="text-xs text-danger">{loadError}</p>}
@@ -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 (
<div
key={s.id}
@@ -286,66 +366,87 @@ export function MonteCarloDialog({
}`}
>
<label className="flex min-w-0 flex-1 items-center gap-2 text-sm">
<input
type="checkbox"
checked={checked}
disabled={!isLoaded}
onChange={() => toggleScenario(s.id)}
/>
<input type="checkbox" checked={checked} disabled={!isLoaded} onChange={() => toggleScenario(s.id)} />
<span className="truncate font-medium text-fg">{s.name}</span>
{s.isBase && <span className="rounded bg-surface px-1.5 text-[10px] text-muted">Basis</span>}
{!isLoaded && <span className="text-[11px] text-faint">lädt</span>}
</label>
{checked && (
<div className="w-56">
<MoneyField
label="Zielbetrag (Endvermögen)"
help="Vorbelegt mit dem geplanten Endvermögen DIESES Szenarios. Die Erfolgswahrscheinlichkeit misst, wie oft dieser Betrag erreicht wird."
value={targets[s.id] ?? 0}
onChange={(v) => setTargets((prev) => ({ ...prev, [s.id]: v }))}
/>
</div>
{checked && needsHistorical && 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>
)}
</div>
);
})}
</div>
{selected.length === 0 && (
<p className="mt-2 text-xs text-danger">Bitte mindestens ein Szenario auswählen.</p>
)}
{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>
)}
{/* 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">
<RequiredNumberField
label="Ø Inflation letzte 20 J. (%)"
help={`Der Mittelpunkt, um den gewürfelt wird. Deine Planung nutzt aktuell ${plan.inflationRateDefault} %. CH langfristig ~2 %.`}
value={inflMean}
onChange={setInflMean}
/>
{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>
)}
<SelectField
label="Streuung"
help={INFLATION_HELP}
value={inflLevel}
onChange={(v: InflationVolatilityLevel) => setInflLevel(v)}
onChange={(v: InflationVolatilityLevel) => {
setInflLevel(v);
clearResults();
}}
options={INFLATION_LEVEL_OPTIONS}
/>
{inflLevel === "manuell" ? (
<NumberField label="Standardabw. (%)" step={0.1} value={Number(inflManual) || 0} onChange={(v) => setInflManual(String(v))} />
<NumberField label="Standardabw. (%)" step={0.1} value={Number(inflManual) || 0} onChange={(v) => { setInflManual(String(v)); clearResults(); }} />
) : (
<ReadOnlySigma value={INFLATION_VOLATILITY_LEVELS[inflLevel]} />
)}
</div>
</div>
{/* Renditetragende Elemente -- ein Satz je logischem Element */}
{/* Renditetragende Elemente */}
<div>
<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, nicht über den Namen. Elemente, die es nur in einem Szenario gibt, erscheinen mit einem entsprechenden Hinweis." />
<InfoBubble text="Ein Element, das in mehreren Szenarien vorkommt, erscheint hier nur einmal erkannt über seine Herkunft beim Kopieren des Szenarios. Bei «Ziel prüfen» kommt der Mittelpunkt aus dem Plan; du wählst nur noch die Streuung." />
)}
</div>
{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 (
<div key={g.rootId} className="rounded-xl border border-border bg-surface-2 p-3">
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
@@ -369,12 +475,24 @@ export function MonteCarloDialog({
)}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<RequiredNumberField
label="Ø Rendite letzte 20 J. (%)"
help="Der Mittelpunkt, um den gewürfelt wird die realistische Rendite dieser Anlage, nicht deine (evtl. optimistische) Planannahme."
value={d.mean}
onChange={(v) => setDraft(g.rootId, g, { mean: v })}
/>
{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>
)}
<SelectField
label="Streuung"
help={RETURN_HELP}
@@ -383,12 +501,7 @@ export function MonteCarloDialog({
options={RETURN_LEVEL_OPTIONS}
/>
{d.level === "manuell" ? (
<NumberField
label="Standardabw. (%)"
step={0.5}
value={Number(d.manualSigma) || 0}
onChange={(v) => setDraft(g.rootId, g, { manualSigma: String(v) })}
/>
<NumberField label="Standardabw. (%)" step={0.5} value={Number(d.manualSigma) || 0} onChange={(v) => setDraft(g.rootId, g, { manualSigma: String(v) })} />
) : (
<ReadOnlySigma value={RETURN_VOLATILITY_LEVELS[d.level]} />
)}
@@ -408,7 +521,7 @@ export function MonteCarloDialog({
<SelectField
label="Anzahl Simulationen"
value={String(runs)}
onChange={(v: string) => 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({
]}
/>
<p className="mt-1 text-[11px] text-faint">
{selected.length > 1 ? `${selected.length} Szenarien · ` : ""}geschätzt ~{estSeconds} s
{selected.length > 1 ? `${selected.length} Szenarien · ` : ""}
{passes > 1 ? "2 Durchgänge · " : ""}geschätzt ~{estSeconds} s
</p>
</div>
</div>
{missing && <p className="text-xs text-danger">Bitte alle Pflichtfelder (Ø-Werte) ausfüllen, um die Simulation zu starten.</p>}
{missing && (
<p className="text-xs text-danger">
{needsHistorical && missingHist
? "Bitte alle Pflichtfelder (historische Ø-Werte) ausfüllen."
: "Bitte einen Zielbetrag grösser 0 angeben."}
</p>
)}
<div className="flex items-center gap-3">
<button
@@ -430,9 +550,7 @@ export function MonteCarloDialog({
onClick={run}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
>
{running
? `Berechne… ${progress.count > 1 ? `Szenario ${progress.index + 1}/${progress.count} · ` : ""}${Math.round(overallProgress * 100)} %`
: "Berechnung jetzt durchführen"}
{running ? `Berechne… ${Math.round(overallProgress * 100)} %` : "Berechnung jetzt durchführen"}
</button>
{running && (
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-2">
@@ -441,12 +559,15 @@ export function MonteCarloDialog({
)}
</div>
{results && results.length > 0 && (
{(resA || resB) && (
<MonteCarloResults
results={results}
mode={mode}
resA={resA}
resB={resB}
manualTarget={manualTarget}
detPoints={
results.length === 1 && loaded[results[0].scenarioId]
? detPointsOf(loaded[results[0].scenarioId].plan, loaded[results[0].scenarioId].computed)
resA && resA.length === 1 && loaded[resA[0].scenarioId]
? detPointsOf(loaded[resA[0].scenarioId].plan, loaded[resA[0].scenarioId].computed)
: []
}
/>
@@ -470,162 +591,191 @@ function ReadOnlySigma({ value }: { value: number }) {
);
}
// Deutungstext Fall 1: wie realistisch ist die Planung? Weiche Formulierung, weil schon eine
// faire Planung wegen des Volatilitäts-Drags leicht unter 50 % landet.
function planVerdict(prob: number): { text: string; tone: "danger" | "success" | "muted" } {
const pct = Math.round(prob * 100);
if (prob < 0.5)
return {
text: `Deine Planung ist tendenziell zu optimistisch: Der geplante Endbetrag wird unter Berücksichtigung der historischen Renditen nur in ${pct} % der Fälle erreicht.`,
tone: "danger",
};
return {
text: `Deine Planung ist tendenziell realistisch bis vorsichtig: Der geplante Endbetrag wird unter Berücksichtigung der historischen Renditen in ${pct} % der Fälle erreicht.`,
tone: "success",
};
}
// Deutungstext Fall 2: erreiche ich mein Ziel?
function targetVerdict(prob: number): { text: string; tone: "danger" | "success" | "muted" } {
const pct = Math.round(prob * 100);
if (prob > 0.5)
return {
text: `Mit deiner Planung solltest du dein Ziel tendenziell erreichen: Der gewünschte Zielbetrag wird in ${pct} % der Fälle erreicht.`,
tone: "success",
};
return {
text: `Mit deiner Planung erreichst du dein Ziel tendenziell nicht: Der gewünschte Zielbetrag wird nur in ${pct} % der Fälle erreicht.`,
tone: "danger",
};
}
function VerdictCard({ title, prob, verdict }: { title: string; prob: number; verdict: { text: string; tone: "danger" | "success" | "muted" } }) {
const color = verdict.tone === "danger" ? "text-danger" : verdict.tone === "success" ? "text-success" : "text-fg";
const border = verdict.tone === "danger" ? "border-danger bg-danger-soft" : "border-border bg-surface-2";
return (
<div className={`rounded-xl border p-3 ${border}`}>
<div className="flex items-baseline justify-between gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-faint">{title}</span>
<span className={`text-2xl font-semibold ${color}`}>{Math.round(prob * 100)} %</span>
</div>
<p className="mt-1 text-xs text-muted">{verdict.text}</p>
</div>
);
}
function MonteCarloResults({
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<string, number | null> = { age };
for (const r of results) row[r.scenarioId] = r.bands.find((b) => b.age === age)?.p50 ?? null;
for (const r of fanRes) row[r.scenarioId] = r.bands.find((b) => b.age === age)?.p50 ?? null;
return row;
});
}, [single, results]);
}, [fanRes, single]);
const byId = (arr: ScenarioMcResult[] | null, id: string) => arr?.find((r) => r.scenarioId === id) ?? null;
const rows = (resA ?? resB)!;
return (
<div className="flex flex-col gap-4 border-t border-border pt-4">
{/* Vergleichstabelle -- das eigentliche Vergleichsinstrument */}
{/* Deutungstexte -- nur sinnvoll bei einem einzelnen Szenario; bei mehreren zählt die Tabelle. */}
{single && (
<div className={`grid grid-cols-1 gap-3 ${mode === "BOTH" ? "sm:grid-cols-2" : ""}`}>
{resA && <VerdictCard title="Realismus der Planung" prob={resA[0].successProbability} verdict={planVerdict(resA[0].successProbability)} />}
{resB && <VerdictCard title="Zielerreichung" prob={resB[0].successProbability} verdict={targetVerdict(resB[0].successProbability)} />}
</div>
)}
{/* Vergleichstabelle */}
<div>
<div className="mb-1 flex items-center text-xs font-semibold text-fg">
Ergebnis je Szenario
{!single && (
<InfoBubble text="Die Erfolgswahrscheinlichkeit bezieht sich je Szenario auf dessen EIGENEN Zielbetrag (Spalte Ziel) sie misst, wie oft ein Szenario sein eigenes Versprechen hält. Die Ruinwahrscheinlichkeit ist zielbetrags-unabhängig und damit direkt vergleichbar." />
)}
<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." />
</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>
<th className="px-3 py-2 text-right font-semibold">Ziel</th>
<th className="px-3 py-2 text-right font-semibold">Erfolg</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">Ruin</th>
<th className="px-3 py-2 text-right font-semibold">Pessimistisch (10 %)</th>
<th className="px-3 py-2 text-right font-semibold">Pessim. (10 %)</th>
<th className="px-3 py-2 text-right font-semibold">Median</th>
<th className="px-3 py-2 text-right font-semibold">Optimistisch (90 %)</th>
<th className="px-3 py-2 text-right font-semibold">Optim. (90 %)</th>
</tr>
</thead>
<tbody>
{results.map((r, i) => (
<tr key={r.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}
</span>
</td>
<td className="px-3 py-2 text-right text-muted">{formatChf(r.target)}</td>
<td className="px-3 py-2 text-right font-semibold text-success">
{Math.round(r.successProbability * 100)} %
</td>
<td className="px-3 py-2 text-right font-semibold text-danger">
{Math.round(r.ruinProbability * 100)} %
</td>
<td className="px-3 py-2 text-right text-muted">{formatChf(r.finalWealthP10)}</td>
<td className="px-3 py-2 text-right text-fg">{formatChf(r.finalWealthMedian)}</td>
<td className="px-3 py-2 text-right text-muted">{formatChf(r.finalWealthP90)}</td>
</tr>
))}
{rows.map((r, i) => {
const a = byId(resA, r.scenarioId);
const b = byId(resB, r.scenarioId);
const fan = a ?? b!;
return (
<tr key={r.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}
</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 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>
</tr>
);
})}
</tbody>
</table>
</div>
<p className="mt-1 text-[11px] text-faint">
{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)}` : ""}
</p>
</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)"}
{/* Fächer */}
{fanRes && (
<div>
<div className="mb-1 text-xs font-semibold text-fg">
{single ? "Vermögensfächer nach Alter (nominal)" : "Vermögensverlauf im Median nach Alter (nominal)"}
{resA ? " · historische Renditen" : " · geplante Werte"}
</div>
<p className="mb-2 text-[11px] text-muted">
{single
? "Das Band reicht vom pessimistischen (10 %) bis zum optimistischen (90 %) Fall, die dunkle Linie ist der Median. Die gestrichelte Linie ist deine deterministische Planung sie liegt meist leicht über dem Median (Schwankung frisst Rendite)."
: "Je Szenario die mittlere Entwicklung (Median). Wähle ein einzelnes Szenario, um den vollen Fächer zu sehen."}
</p>
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
{single ? (
<ComposedChart data={singleData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="age" tick={{ fontSize: 11 }} tickFormatter={(v) => `${v} J.`} />
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} />
<Tooltip formatter={(v, name) => [typeof v === "number" ? formatChf(v) : v, name]} labelFormatter={(v) => `Alter ${v}`} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Area dataKey="band" name="1090 % Band" stroke="none" fill="var(--accent)" fillOpacity={0.16} isAnimationActive={false} />
<Line dataKey="median" name="Median" stroke="var(--accent)" strokeWidth={2} dot={false} isAnimationActive={false} />
<Line dataKey="det" name="Planung (deterministisch)" stroke="var(--muted)" strokeWidth={1.5} strokeDasharray="5 3" dot={false} connectNulls isAnimationActive={false} />
</ComposedChart>
) : (
<LineChart data={compareData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="age" type="number" domain={["dataMin", "dataMax"]} tick={{ fontSize: 11 }} tickFormatter={(v) => `${v} J.`} />
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} />
<Tooltip formatter={(v, name) => [typeof v === "number" ? formatChf(v) : v, name]} labelFormatter={(v) => `Alter ${v}`} />
<Legend wrapperStyle={{ fontSize: 12 }} />
{fanRes.map((r, i) => (
<Line key={r.scenarioId} dataKey={r.scenarioId} name={r.name} stroke={PALETTE[i % PALETTE.length]} strokeWidth={2} dot={false} connectNulls isAnimationActive={false} />
))}
</LineChart>
)}
</ResponsiveContainer>
</div>
</div>
<p className="mb-2 text-[11px] text-muted">
{single ? (
<>
Das Band reicht vom pessimistischen (10 %) bis zum optimistischen (90 %) Fall, die dunkle Linie ist der
Median. Die gestrichelte Linie ist deine deterministische Planung sie liegt meist leicht über dem Median
(Schwankung frisst Rendite).
</>
) : (
<>
Je Szenario die mittlere Entwicklung (Median). Die vollständigen 1090 %-Bänder werden hier bewusst nicht
übereinandergelegt wähle ein einzelnes Szenario, um den Fächer zu sehen.
</>
)}
</p>
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
{single ? (
<ComposedChart data={singleData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="age" tick={{ fontSize: 11 }} tickFormatter={(v) => `${v} J.`} />
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} />
<Tooltip
formatter={(v, name) => [typeof v === "number" ? formatChf(v) : v, name]}
labelFormatter={(v) => `Alter ${v}`}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
<Area dataKey="band" name="1090 % Band" stroke="none" fill="var(--accent)" fillOpacity={0.16} isAnimationActive={false} />
<Line dataKey="median" name="Median" stroke="var(--accent)" strokeWidth={2} dot={false} isAnimationActive={false} />
<Line dataKey="det" name="Planung (deterministisch)" stroke="var(--muted)" strokeWidth={1.5} strokeDasharray="5 3" dot={false} connectNulls isAnimationActive={false} />
</ComposedChart>
) : (
<LineChart data={compareData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis
dataKey="age"
type="number"
domain={["dataMin", "dataMax"]}
tick={{ fontSize: 11 }}
tickFormatter={(v) => `${v} J.`}
/>
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} />
<Tooltip
formatter={(v, name) => [typeof v === "number" ? formatChf(v) : v, name]}
labelFormatter={(v) => `Alter ${v}`}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
{results.map((r, i) => (
<Line
key={r.scenarioId}
dataKey={r.scenarioId}
name={r.name}
stroke={PALETTE[i % PALETTE.length]}
strokeWidth={2}
dot={false}
connectNulls
isAnimationActive={false}
/>
))}
</LineChart>
)}
</ResponsiveContainer>
</div>
</div>
)}
</div>
);
}