Files
FPT/src/components/MonteCarloDialog.tsx
T
admGitAICDS 3fe7a3978d
Deploy App / deploy (push) Successful in 53s
Szenario-Vergleich in der Monte-Carlo-Simulation + Sensitivitaetsanalyse (Tornado)
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>
@
2026-07-18 14:33:40 +02:00

632 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 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>
);
}