3fe7a3978d
Deploy App / deploy (push) Successful in 53s
Monte Carlo ueber mehrere Szenarien eines Plans in einem Lauf: - Annahmen nur EINMAL je logischem Element (Zuordnung ueber sourceElementId, dieselbe Kette wie beim Diff) -- sonst vergleicht man die Eingaben statt der Szenarien - gemeinsamer Seed fuer alle Szenarien (Common Random Numbers), damit Unterschiede strukturell und nicht zufaellig sind - Zielbetrag bleibt szenario-eigen: die Erfolgswahrscheinlichkeit misst, wie oft ein Szenario sein EIGENES Versprechen haelt - Vergleichstabelle + Median-Linien; bei einem Szenario unveraenderter Faecher Sensitivitaetsanalyse (Roadmap Nr. 20), eigener Dialog "Einflussfaktoren": - neues reines Modul sensitivity.ts, One-at-a-time ueber 7 Treiber - Bandbreiten je Treiber pflichtig und ohne Default (die Balkenlaenge haengt direkt davon ab) - Pensionsalter bewusst ausgeschlossen: nicht variierbar ohne Mitverschieben der Phasengrenzen (Begruendung in 9.18) SPEZIFIKATION auf 1.0: neue Kapitel 3.6.6, 4.12.6, 4.13, 9.18, 9.19 sowie vier korrigierte Dokumentationsfehler (Kap. 1.2, 4.6.5, 5.2/5.3/5.5.2/8.1, Glossar). 20 Tests ergaenzt (60 -> 80). Keine DB-Aenderung. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> @
632 lines
29 KiB
TypeScript
632 lines
29 KiB
TypeScript
"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 Vermoegensverlauf, damit die Zuordnung vertraut bleibt.
|
||
const PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"];
|
||
|
||
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 ausgewaehlte) -- sie werden geladen, damit sich
|
||
// die Herkunfts-Kette der Elemente auch ueber uebersprungene Zwischen-Szenarien aufloest.
|
||
scenarios: ScenarioMeta[];
|
||
onClose: () => void;
|
||
}) {
|
||
const [loaded, setLoaded] = useState<Record<string, LoadedScenario>>(() => ({
|
||
[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<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 [runs, setRuns] = useState(1000);
|
||
const [inflMean, setInflMean] = useState("");
|
||
const [inflLevel, setInflLevel] = useState<InflationVolatilityLevel>("sehr_niedrig");
|
||
const [inflManual, setInflManual] = useState("1");
|
||
const [drafts, setDrafts] = useState<Record<string, ElementDraft>>({});
|
||
|
||
const [running, setRunning] = useState(false);
|
||
const [progress, setProgress] = useState({ index: 0, count: 1, fraction: 0 });
|
||
const [results, setResults] = useState<ScenarioMcResult[] | null>(null);
|
||
|
||
const scenarioKey = scenarios.map((s) => s.id).join(",");
|
||
|
||
// Die uebrigen Szenarien einmalig nachladen. Ein Plan hat realistisch eine Handvoll
|
||
// Szenarien -- gegenueber tausenden Simulationslaeufen faellt 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 (ueber 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<ElementDraft>) {
|
||
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<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) }];
|
||
})
|
||
);
|
||
// EIN Seed fuer alle Szenarien: strukturgleiche Szenarien teilen damit dieselben
|
||
// Marktpfade, und die Unterschiede sind strukturell statt zufaellig.
|
||
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 (
|
||
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
|
||
<div
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="flex w-full max-w-4xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
|
||
<Dices className="h-5 w-5 text-accent" /> Monte-Carlo-Simulation
|
||
</h2>
|
||
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Erklaerung */}
|
||
<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.
|
||
</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>
|
||
</p>
|
||
</div>
|
||
|
||
{/* Szenario-Auswahl inkl. Zielbetrag je Szenario */}
|
||
<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." />
|
||
</div>
|
||
{loading && <p className="text-xs text-muted">Szenarien werden geladen…</p>}
|
||
{loadError && <p className="text-xs text-danger">{loadError}</p>}
|
||
<div className="flex flex-col gap-2">
|
||
{scenarios.map((s) => {
|
||
const isLoaded = !!loaded[s.id];
|
||
const checked = selectedIds.includes(s.id);
|
||
return (
|
||
<div
|
||
key={s.id}
|
||
className={`flex flex-wrap items-center gap-3 rounded-xl border p-3 ${
|
||
checked ? "border-accent bg-accent-soft/30" : "border-border bg-surface-2"
|
||
}`}
|
||
>
|
||
<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)}
|
||
/>
|
||
<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>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
{selected.length === 0 && (
|
||
<p className="mt-2 text-xs text-danger">Bitte mindestens ein Szenario auswählen.</p>
|
||
)}
|
||
</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}
|
||
/>
|
||
<SelectField
|
||
label="Streuung"
|
||
help={INFLATION_HELP}
|
||
value={inflLevel}
|
||
onChange={(v: InflationVolatilityLevel) => setInflLevel(v)}
|
||
options={INFLATION_LEVEL_OPTIONS}
|
||
/>
|
||
{inflLevel === "manuell" ? (
|
||
<NumberField label="Standardabw. (%)" step={0.1} value={Number(inflManual) || 0} onChange={(v) => setInflManual(String(v))} />
|
||
) : (
|
||
<ReadOnlySigma value={INFLATION_VOLATILITY_LEVELS[inflLevel]} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Renditetragende Elemente -- ein Satz je logischem Element */}
|
||
<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." />
|
||
)}
|
||
</div>
|
||
{groups.length === 0 && (
|
||
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-xs text-muted">
|
||
Die ausgewählten Szenarien haben keine renditetragenden Elemente (PK, 3a, Sonstiges Vermögen, Immobilie).
|
||
</p>
|
||
)}
|
||
<div className="flex flex-col gap-3">
|
||
{groups.map((g) => {
|
||
const d = draftFor(g);
|
||
const partial = g.scenarioIds.length < selected.length;
|
||
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">
|
||
<span className="font-semibold text-fg">{g.name}</span>
|
||
<span className="text-xs text-faint">{CATEGORY_LABELS[g.category]}</span>
|
||
{partial && (
|
||
<span className="rounded bg-surface px-1.5 py-0.5 text-[10px] text-muted">
|
||
nur in: {g.scenarioIds.map((id) => loaded[id]?.name ?? id).join(", ")}
|
||
</span>
|
||
)}
|
||
</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 })}
|
||
/>
|
||
<SelectField
|
||
label="Streuung"
|
||
help={RETURN_HELP}
|
||
value={d.level}
|
||
onChange={(v: ReturnVolatilityLevel) => setDraft(g.rootId, g, { level: v })}
|
||
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) })}
|
||
/>
|
||
) : (
|
||
<ReadOnlySigma value={RETURN_VOLATILITY_LEVELS[d.level]} />
|
||
)}
|
||
</div>
|
||
{(g.category === "PENSION_FUND" || g.category === "PILLAR_3A") && (
|
||
<p className="mt-2 text-[11px] text-faint">Boden 0 %: {CATEGORY_LABELS[g.category]} schreibt keine negative Rendite gut.</p>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Lauf-Parameter */}
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||
<div>
|
||
<SelectField
|
||
label="Anzahl Simulationen"
|
||
value={String(runs)}
|
||
onChange={(v: string) => setRuns(Number(v))}
|
||
options={[
|
||
{ value: "1000", label: "1'000 (schnell)" },
|
||
{ value: "5000", label: "5'000" },
|
||
{ value: "10000", label: "10'000 (genau)" },
|
||
]}
|
||
/>
|
||
<p className="mt-1 text-[11px] text-faint">
|
||
{selected.length > 1 ? `${selected.length} Szenarien · ` : ""}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>}
|
||
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
type="button"
|
||
disabled={!canRun || running}
|
||
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"}
|
||
</button>
|
||
{running && (
|
||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-2">
|
||
<div className="h-full bg-accent transition-all" style={{ width: `${overallProgress * 100}%` }} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{results && results.length > 0 && (
|
||
<MonteCarloResults
|
||
results={results}
|
||
detPoints={
|
||
results.length === 1 && loaded[results[0].scenarioId]
|
||
? detPointsOf(loaded[results[0].scenarioId].plan, loaded[results[0].scenarioId].computed)
|
||
: []
|
||
}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ReadOnlySigma({ value }: { value: number }) {
|
||
return (
|
||
<div>
|
||
<label className="mb-1 flex items-center text-xs font-medium text-muted">
|
||
Standardabweichung
|
||
<InfoBubble text="Die hinter der gewählten Streuungsstufe hinterlegte Standardabweichung. Nur bei Manuell selbst eingebbar." />
|
||
</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">
|
||
{value} %
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MonteCarloResults({
|
||
results,
|
||
detPoints,
|
||
}: {
|
||
results: ScenarioMcResult[];
|
||
detPoints: { age: number; det: number }[];
|
||
}) {
|
||
const single = results.length === 1;
|
||
|
||
// Einzelnes Szenario: der gewohnte Faecher (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 -- uebereinandergelegte Baender waeren 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<string, number | null> = { age };
|
||
for (const r of results) row[r.scenarioId] = r.bands.find((b) => b.age === age)?.p50 ?? null;
|
||
return row;
|
||
});
|
||
}, [single, results]);
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4 border-t border-border pt-4">
|
||
{/* Vergleichstabelle -- das eigentliche Vergleichsinstrument */}
|
||
<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." />
|
||
)}
|
||
</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>
|
||
<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">Median</th>
|
||
<th className="px-3 py-2 text-right font-semibold">Optimistisch (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>
|
||
))}
|
||
</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"}
|
||
</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)"}
|
||
</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 10–90 %-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="10–90 % 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>
|
||
);
|
||
}
|