Szenario-Vergleich in der Monte-Carlo-Simulation + Sensitivitaetsanalyse (Tornado)
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>
@
This commit is contained in:
2026-07-18 14:33:27 +02:00
parent d203e504c0
commit 3fe7a3978d
9 changed files with 1762 additions and 211 deletions
+17
View File
@@ -12,12 +12,14 @@ import {
Menu,
PiggyBank,
Plus,
Tornado,
Trash2,
X,
} from "lucide-react";
import { PlanView } from "@/components/PlanView";
import { Dashboard } from "@/components/Dashboard";
import { MonteCarloDialog } from "@/components/MonteCarloDialog";
import { SensitivityDialog } from "@/components/SensitivityDialog";
import { SpecView } from "@/components/SpecView";
import { ProfileMenu } from "@/components/ProfileMenu";
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
@@ -44,6 +46,7 @@ export function AppShell({ username }: { username: string }) {
const [showSpec, setShowSpec] = useState(false);
const [showCharts, setShowCharts] = useState(false);
const [showMonteCarlo, setShowMonteCarlo] = useState(false);
const [showSensitivity, setShowSensitivity] = useState(false);
const loadPlans = useCallback(async () => {
const data = await api.get<{ plans: PlanListItem[] }>("/api/plans");
@@ -290,6 +293,14 @@ export function AppShell({ username }: { username: string }) {
<Dices className="h-4 w-4" />
Monte-Carlo-Simulation
</button>
<button
type="button"
onClick={() => setShowSensitivity(true)}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
>
<Tornado className="h-4 w-4" />
Einflussfaktoren berechnen
</button>
</>
)}
{diff && detail.base && (
@@ -337,10 +348,16 @@ export function AppShell({ username }: { username: string }) {
<MonteCarloDialog
plan={detail.plan}
computed={detail.computed}
meta={detail.meta}
scenarios={activePlan?.scenarios ?? [detail.meta]}
onClose={() => setShowMonteCarlo(false)}
/>
)}
{showSensitivity && detail && (
<SensitivityDialog plan={detail.plan} onClose={() => setShowSensitivity(false)} />
)}
{copyFrom && (
<CopyScenarioDialog
source={copyFrom}
+47
View File
@@ -51,6 +51,53 @@ export function NumberField({
);
}
// Pflicht-Zahlenfeld, das wirklich LEER sein kann (NumberField erzwingt eine Zahl und haette
// damit immer einen Default -- genau das soll bei Annahmen vermieden werden, siehe
// SPEZIFIKATION 9.15/9.18: ein stiller Default wird nicht hinterfragt). Der Wert wird als
// String gefuehrt; die Validierung liegt beim Aufrufer.
export function RequiredNumberField({
label,
help,
value,
onChange,
step = 0.1,
suffix,
placeholder = "Pflicht",
}: {
label: string;
help?: string;
value: string;
onChange: (value: string) => void;
step?: number;
suffix?: string;
placeholder?: string;
}) {
const empty = value.trim() === "";
return (
<div>
<FieldLabel label={label} help={help} />
<div className="relative">
<input
type="number"
step={step}
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
className={`${baseInputClass} ${suffix ? "pr-8" : ""} ${
empty ? "border-danger focus:border-danger" : ""
}`}
/>
{suffix && (
<span className="pointer-events-none absolute inset-y-0 right-2.5 flex items-center text-xs text-faint">
{suffix}
</span>
)}
</div>
{empty && <p className="mt-1 text-[11px] text-danger">Pflichtfeld bitte ausfüllen.</p>}
</div>
);
}
// Ganzzahliges Betragsfeld. Zeigt den Wert unfokussiert mit 1'000er-Trennzeichen an,
// akzeptiert fokussiert beliebige ganze Zahlen (keine Nachkommastellen) und bietet
// Pfeil-Buttons mit Klick-und-Halten-BESCHLEUNIGUNG (1 -> 10 -> 100 -> 1'000 -> ...).
+398 -187
View File
@@ -1,26 +1,35 @@
"use client";
import { useMemo, useState } from "react";
import { Area, CartesianGrid, ComposedChart, Legend, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
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 } from "@/components/FormField";
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 {
runMonteCarlo,
buildElementGroups,
defaultVolatilityLevel,
RETURN_VOLATILITY_LEVELS,
floorFor,
paramsForScenario,
runMonteCarloMulti,
INFLATION_VOLATILITY_LEVELS,
RETURN_BEARING,
floorFor,
type ReturnVolatilityLevel,
RETURN_VOLATILITY_LEVELS,
type ElementGroup,
type ElementMcParams,
type InflationVolatilityLevel,
type MonteCarloResult,
type ReturnVolatilityLevel,
type ScenarioMcResult,
type ScenarioRunInput,
} from "@/lib/montecarlo";
import { CATEGORY_LABELS } from "@/lib/elements";
import type { PlanInput } from "@/lib/types";
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" },
@@ -40,149 +49,186 @@ const RETURN_HELP =
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.";
// Pflicht-Zahlenfeld, das wirklich leer sein kann (NumberField erzwingt eine Zahl).
function MeanField({
label,
help,
value,
onChange,
}: {
label: string;
help: string;
value: string;
onChange: (v: string) => void;
}) {
const empty = value.trim() === "";
return (
<div>
<label className="mb-1 flex items-center text-xs font-medium text-muted">
{label}
<InfoBubble text={help} />
</label>
<input
type="number"
step={0.1}
value={value}
placeholder="Pflicht"
onChange={(e) => onChange(e.target.value)}
className={`w-full rounded-lg border bg-input px-2.5 py-1.5 text-sm text-fg shadow-sm focus:outline-none focus:ring-2 focus:ring-accent/25 ${
empty ? "border-danger" : "border-border focus:border-accent"
}`}
/>
{empty && <p className="mt-1 text-[11px] text-danger">Pflichtfeld bitte ausfüllen.</p>}
</div>
);
interface ElementDraft {
mean: string;
level: ReturnVolatilityLevel;
manualSigma: string;
}
type ElementInput = { mean: string; level: ReturnVolatilityLevel; manualSigma: string };
interface LoadedScenario {
id: string;
name: string;
plan: PlanInput;
computed: PlanComputed;
}
function returnSigma(el: ElementInput): number {
return el.level === "manuell"
? Number(el.manualSigma) || 0
: RETURN_VOLATILITY_LEVELS[el.level];
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 returnElements = useMemo(
() => plan.elements.filter((e) => RETURN_BEARING.includes(e.category)),
[plan.elements]
);
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 [target, setTarget] = useState(Math.max(0, computed.nachlass));
const [els, setEls] = useState<Record<string, ElementInput>>(() =>
Object.fromEntries(
returnElements.map((e) => [e.id, { mean: "", level: defaultVolatilityLevel(e.category), manualSigma: "10" }])
)
);
const [drafts, setDrafts] = useState<Record<string, ElementDraft>>({});
const [running, setRunning] = useState(false);
const [progress, setProgress] = useState(0);
const [result, setResult] = useState<MonteCarloResult | null>(null);
const [progress, setProgress] = useState({ index: 0, count: 1, fraction: 0 });
const [results, setResults] = useState<ScenarioMcResult[] | null>(null);
function setEl(id: string, patch: Partial<ElementInput>) {
setEls((prev) => ({ ...prev, [id]: { ...prev[id], ...patch } }));
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 } }));
}
// Pflichtfelder: historische Inflation + je Element die historische Rendite muessen gesetzt sein.
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() === "" || returnElements.some((e) => (els[e.id]?.mean ?? "").trim() === "");
inflMean.trim() === "" || groups.some((g) => draftFor(g).mean.trim() === "");
const canRun = !missing && selected.length > 0 && anyReturnBearing && !loading;
// Deterministische Endwert-Linie fuer den Vergleich im Faecher.
const detPoints = useMemo(() => {
const startAge = plan.persons.find((p) => p.role === "PERSON_A")?.age ?? plan.persons[0]?.age ?? 0;
const pts: { age: number; det: number }[] = [];
if (computed.phases.length > 0) {
pts.push({ 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;
}, [computed.phases, plan.persons]);
const estSeconds = Math.max(1, Math.round(runs / 6000));
const estSeconds = Math.max(1, Math.round((runs * Math.max(1, selected.length)) / 6000));
async function run() {
setRunning(true);
setResult(null);
setProgress(0);
setResults(null);
setProgress({ index: 0, count: selected.length, fraction: 0 });
try {
const elements = Object.fromEntries(
returnElements.map((e) => {
const ei = els[e.id];
return [e.id, { mean: Number(ei.mean) || 0, sigma: returnSigma(ei), floor: floorFor(e.category) }];
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) }];
})
);
const res = await runMonteCarlo(
plan,
// 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),
elements,
target,
seed,
elementsFor: (p) => paramsForScenario(p, groups, paramByRoot),
},
(done, total) => setProgress(done / total)
(index, count, fraction) => setProgress({ index, count, fraction })
);
setResult(res);
setResults(res);
} finally {
setRunning(false);
}
}
const chartData = useMemo(() => {
if (!result) return [];
return result.bands.map((b) => ({
age: b.age,
// Range-Flaeche als [unten, oben]-Tupel -- robust auch bei negativem p10 (Ruin-Faelle).
band: [b.p10, b.p90] as [number, number],
median: b.p50,
det: detPoints.find((d) => d.age === b.age)?.det ?? null,
}));
}, [result, detPoints]);
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-3xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
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">
@@ -206,6 +252,12 @@ export function MonteCarloDialog({
<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
@@ -214,11 +266,60 @@ export function MonteCarloDialog({
</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">
<MeanField
<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}
@@ -239,45 +340,61 @@ export function MonteCarloDialog({
</div>
</div>
{/* Elemente */}
{/* Renditetragende Elemente -- ein Satz je logischem Element */}
<div>
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">Renditetragende Elemente</div>
{returnElements.length === 0 && (
<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">
Dieser Plan hat keine renditetragenden Elemente (PK, 3a, Sonstiges Vermögen, Immobilie).
Die ausgewählten Szenarien haben keine renditetragenden Elemente (PK, 3a, Sonstiges Vermögen, Immobilie).
</p>
)}
<div className="flex flex-col gap-3">
{returnElements.map((e) => {
const ei = els[e.id];
{groups.map((g) => {
const d = draftFor(g);
const partial = g.scenarioIds.length < selected.length;
return (
<div key={e.id} className="rounded-xl border border-border bg-surface-2 p-3">
<div className="mb-2 flex items-center gap-2 text-sm">
<span className="font-semibold text-fg">{e.name}</span>
<span className="text-xs text-faint">{CATEGORY_LABELS[e.category]}</span>
<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">
<MeanField
<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={ei?.mean ?? ""}
onChange={(v) => setEl(e.id, { mean: v })}
value={d.mean}
onChange={(v) => setDraft(g.rootId, g, { mean: v })}
/>
<SelectField
label="Streuung"
help={RETURN_HELP}
value={ei.level}
onChange={(v: ReturnVolatilityLevel) => setEl(e.id, { level: v })}
value={d.level}
onChange={(v: ReturnVolatilityLevel) => setDraft(g.rootId, g, { level: v })}
options={RETURN_LEVEL_OPTIONS}
/>
{ei.level === "manuell" ? (
<NumberField label="Standardabw. (%)" step={0.5} value={Number(ei.manualSigma) || 0} onChange={(v) => setEl(e.id, { manualSigma: String(v) })} />
{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[ei.level]} />
<ReadOnlySigma value={RETURN_VOLATILITY_LEVELS[d.level]} />
)}
</div>
{(e.category === "PENSION_FUND" || e.category === "PILLAR_3A") && (
<p className="mt-2 text-[11px] text-faint">Boden 0 %: {CATEGORY_LABELS[e.category]} schreibt keine negative Rendite gut.</p>
{(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>
);
@@ -298,14 +415,10 @@ export function MonteCarloDialog({
{ value: "10000", label: "10'000 (genau)" },
]}
/>
<p className="mt-1 text-[11px] text-faint">geschätzt ~{estSeconds} s</p>
<p className="mt-1 text-[11px] text-faint">
{selected.length > 1 ? `${selected.length} Szenarien · ` : ""}geschätzt ~{estSeconds} s
</p>
</div>
<MoneyField
label="Zielbetrag (Endvermögen)"
help="Die Erfolgswahrscheinlichkeit misst, wie oft das Endvermögen mindestens diesen Betrag erreicht. Vorbelegt mit deinem geplanten Nachlass."
value={target}
onChange={setTarget}
/>
</div>
{missing && <p className="text-xs text-danger">Bitte alle Pflichtfelder (Ø-Werte) ausfüllen, um die Simulation zu starten.</p>}
@@ -313,20 +426,31 @@ export function MonteCarloDialog({
<div className="flex items-center gap-3">
<button
type="button"
disabled={missing || running || returnElements.length === 0}
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… ${Math.round(progress * 100)} %` : "Berechnung jetzt durchführen"}
{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: `${progress * 100}%` }} />
<div className="h-full bg-accent transition-all" style={{ width: `${overallProgress * 100}%` }} />
</div>
)}
</div>
{result && <MonteCarloResults result={result} target={target} chartData={chartData} />}
{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>
);
@@ -347,74 +471,161 @@ function ReadOnlySigma({ value }: { value: number }) {
}
function MonteCarloResults({
result,
target,
chartData,
results,
detPoints,
}: {
result: MonteCarloResult;
target: number;
chartData: { age: number; band: [number, number]; median: number; det: number | null }[];
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">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<Stat label="Erfolgswahrscheinlichkeit" value={`${Math.round(result.successProbability * 100)} %`} help={`Anteil der Läufe mit Endvermögen ≥ ${formatChf(target)}.`} good />
<Stat label="Ruinwahrscheinlichkeit" value={`${Math.round(result.ruinProbability * 100)} %`} help="Anteil der Läufe, in denen das Vermögen vor Planende unter 0 fällt." danger />
<Stat label="Läufe" value={result.runs.toLocaleString("de-CH")} />
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 text-sm">
<Band label="Pessimistisch (10 %)" value={result.finalWealthP10} />
<Band label="Median" value={result.finalWealthMedian} />
<Band label="Optimistisch (90 %)" value={result.finalWealthP90} />
{/* 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">Vermögensfächer nach Alter (nominal)</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">
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).
{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%">
<ComposedChart data={chartData} 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>
{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>
);
}
function Stat({ label, value, help, good, danger }: { label: string; value: string; help?: string; good?: boolean; danger?: boolean }) {
return (
<div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
<div className="flex items-center text-xs text-muted">
{label}
{help && <InfoBubble text={help} />}
</div>
<div className={`mt-1 text-2xl font-semibold ${good ? "text-success" : danger ? "text-danger" : "text-fg"}`}>{value}</div>
</div>
);
}
function Band({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2">
<div className="text-[11px] text-muted">{label}</div>
<div className="font-semibold text-fg">{formatChf(value)}</div>
</div>
);
}
+316
View File
@@ -0,0 +1,316 @@
"use client";
import { useMemo, useState } from "react";
import { Bar, BarChart, CartesianGrid, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { Tornado, X } from "lucide-react";
import { RequiredNumberField, SelectField } from "@/components/FormField";
import { InfoBubble } from "@/components/InfoBubble";
import { formatChf } from "@/lib/format";
import {
computeTornado,
DRIVERS,
UNIT_SUFFIX,
type DriverDef,
type DriverId,
type TornadoMetric,
type TornadoResult,
} from "@/lib/sensitivity";
import type { PlanInput } from "@/lib/types";
interface RangeDraft {
checked: boolean;
low: string;
high: string;
}
// Bewusst OHNE Defaults: Die Balkenlaenge haengt direkt von der eingegebenen Bandbreite ab.
// Ein stiller Default wuerde nicht hinterfragt und das Ranking waere dann eine Aussage ueber
// unsere Vorgabe statt ueber den Plan (gleiche Begruendung wie bei den Monte-Carlo-Mittelwerten).
const emptyDraft: RangeDraft = { checked: false, low: "", high: "" };
function formatRange(low: number, high: number, unit: DriverDef["unit"]): string {
const suffix = UNIT_SUFFIX[unit];
const sign = (v: number) => (unit === "abs_pct" ? `${v}` : v > 0 ? `+${v}` : `${v}`);
return `${sign(low)} ${suffix}${sign(high)} ${suffix}`;
}
export function SensitivityDialog({ plan, onClose }: { plan: PlanInput; onClose: () => void }) {
const available = useMemo(() => DRIVERS.filter((d) => d.applies(plan)), [plan]);
const [metric, setMetric] = useState<TornadoMetric>("real");
const [drafts, setDrafts] = useState<Record<string, RangeDraft>>({});
const [result, setResult] = useState<TornadoResult | null>(null);
function draftFor(id: DriverId): RangeDraft {
return drafts[id] ?? emptyDraft;
}
function setDraft(id: DriverId, patch: Partial<RangeDraft>) {
setDrafts((prev) => ({ ...prev, [id]: { ...draftFor(id), ...patch } }));
setResult(null);
}
const checked = available.filter((d) => draftFor(d.id).checked);
const incomplete = checked.filter((d) => {
const dr = draftFor(d.id);
return dr.low.trim() === "" || dr.high.trim() === "";
});
const degenerate = checked.filter((d) => {
const dr = draftFor(d.id);
return dr.low.trim() !== "" && dr.high.trim() !== "" && Number(dr.low) === Number(dr.high);
});
// Mindestens zwei Treiber: Ein Tornado ist eine RANGLISTE. Mit einem einzigen Balken gibt
// es nichts zu ordnen und die Grafik wuerde eine Aussage suggerieren, die sie nicht hat.
const canRun =
checked.length >= 2 && incomplete.length === 0 && degenerate.length === 0 && plan.phases.length > 0;
function run() {
setResult(
computeTornado(
plan,
metric,
checked.map((d) => {
const dr = draftFor(d.id);
return { id: d.id, low: Number(dr.low), high: Number(dr.high) };
})
)
);
}
const chartData = useMemo(() => {
if (!result) return [];
// Von unten nach oben gezeichnet -> fuer die Trichterform (groesster Balken zuoberst)
// muss die Reihenfolge umgekehrt in die Grafik.
return [...result.bars].reverse().map((b) => ({
label: b.shortLabel,
range: [b.min, b.max] as [number, number],
swing: b.swing,
}));
}, [result]);
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">
<Tornado className="h-5 w-5 text-accent" /> Einflussfaktoren (Sensitivitätsanalyse)
</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> Nicht «wie viel Geld habe ich am Schluss», sondern
<strong className="text-fg"> «welche meiner Annahmen entscheidet überhaupt über das Ergebnis»</strong>. Bei
manchen Annahmen ist es egal, ob du sie exakt triffst bei anderen kippt eine kleine Abweichung den ganzen Plan.
</p>
<p className="mb-2">
<strong className="text-fg">Wie es funktioniert:</strong> Zuerst wird dein Plan wie erfasst gerechnet
(Basisfall). Dann wird <em>ein einziger</em> Parameter auf seinen tiefen und seinen hohen Wert gesetzt, alle
übrigen bleiben unverändert das ergibt zwei Ergebnisse. Das für jeden Parameter wiederholt und nach
<strong className="text-fg"> Spannweite</strong> sortiert ergibt die Trichterform: längster Balken zuoberst.
</p>
<p className="mb-2">
<strong className="text-fg">Was du davon hast:</strong> Die Reihenfolge ist die Botschaft. Bei den obersten
Balken lohnt sich Genauigkeit (dort exakte Zahlen beschaffen) und sie sind meist auch die, die du selbst
steuern kannst. Bei den untersten darfst du grob schätzen.
</p>
<p>
<strong className="text-fg">Zwei ehrliche Grenzen.</strong> Erstens hängt die Balkenlänge von den
Bandbreiten ab, die du unten eingibst wähle sie so, dass sie <em>ähnlich plausibel</em> sind, nicht ähnlich
gross. Zweitens wird immer nur ein Parameter auf einmal variiert; <em>Kombinationen</em> (schlechte Renditen
<em> und</em> hohe Ausgaben) treffen härter als die Summe der Einzelbalken dafür ist die
Monte-Carlo-Simulation zuständig.
</p>
</div>
{/* Zielgroesse */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<SelectField
label="Zielgrösse"
help="Woran die Wirkung gemessen wird: das Endvermögen der letzten Lebensphase. «Real» ist kaufkraftbereinigt auf den Planbeginn und damit die ehrlichere Grösse."
value={metric}
onChange={(v: TornadoMetric) => {
setMetric(v);
setResult(null);
}}
options={[
{ value: "real", label: "Endvermögen real (kaufkraftbereinigt)" },
{ value: "nominal", label: "Endvermögen nominal" },
]}
/>
</div>
{/* Parameter */}
<div>
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
Parameter und Bandbreiten
<InfoBubble text="Hake an, welche Parameter in die Analyse einfliessen sollen, und gib je Parameter eine tiefe und eine hohe Ausprägung an. Es gibt bewusst keine Vorgabewerte: Die Balkenlänge hängt direkt von diesen Bandbreiten ab, deshalb sollst du sie bewusst setzen. Die Hilfe je Parameter nennt plausible Grössenordnungen." />
</div>
<div className="flex flex-col gap-2">
{available.map((d) => {
const dr = draftFor(d.id);
const suffix = UNIT_SUFFIX[d.unit];
const unitHint =
d.unit === "abs_pct"
? "absoluter Wert in %"
: d.unit === "delta_pp"
? "Verschiebung in Prozentpunkten"
: d.unit === "rel_pct"
? "Abweichung vom Planwert in %"
: "Verschiebung in Jahren";
return (
<div
key={d.id}
className={`rounded-xl border p-3 ${dr.checked ? "border-accent bg-accent-soft/30" : "border-border bg-surface-2"}`}
>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={dr.checked} onChange={(e) => setDraft(d.id, { checked: e.target.checked })} />
<span className="font-medium text-fg">{d.label}</span>
<InfoBubble text={d.help} />
<span className="text-[11px] text-faint">({unitHint})</span>
</label>
{dr.checked && (
<div className="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-3">
<RequiredNumberField
label="tief"
value={dr.low}
suffix={suffix}
step={d.unit === "delta_years" ? 1 : 0.1}
onChange={(v) => setDraft(d.id, { low: v })}
/>
<RequiredNumberField
label="hoch"
value={dr.high}
suffix={suffix}
step={d.unit === "delta_years" ? 1 : 0.1}
onChange={(v) => setDraft(d.id, { high: v })}
/>
</div>
)}
</div>
);
})}
</div>
<p className="mt-2 text-[11px] text-faint">
Das <strong>Pensionsalter</strong> ist bewusst nicht enthalten: Es lässt sich nicht sinnvoll variieren, ohne
gleichzeitig die Phasengrenzen mitzuverschieben sonst arbeitet die Person im Modell unverändert weiter
bzw. der Pensions-Übergang entfällt ganz. Der verwandte Treiber «Lebensdauer» ist dagegen sauber abgebildet.
</p>
</div>
{checked.length < 2 && (
<p className="text-xs text-danger">Bitte mindestens zwei Parameter auswählen der Tornado ist eine Rangliste.</p>
)}
{incomplete.length > 0 && (
<p className="text-xs text-danger">Bitte für jeden ausgewählten Parameter eine tiefe und eine hohe Ausprägung angeben.</p>
)}
{degenerate.length > 0 && (
<p className="text-xs text-danger">
Tiefer und hoher Wert sind identisch bei: {degenerate.map((d) => d.label).join(", ")}. Damit gibt es keine Spannweite.
</p>
)}
<div>
<button
type="button"
disabled={!canRun}
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"
>
Einflussfaktoren berechnen
</button>
</div>
{result && <TornadoResults result={result} metric={metric} chartData={chartData} />}
</div>
</div>
);
}
function TornadoResults({
result,
metric,
chartData,
}: {
result: TornadoResult;
metric: TornadoMetric;
chartData: { label: string; range: [number, number]; swing: number }[];
}) {
const metricLabel = metric === "real" ? "Endvermögen real" : "Endvermögen nominal";
return (
<div className="flex flex-col gap-4 border-t border-border pt-4">
<div className="rounded-xl border border-border bg-surface-2 px-4 py-3">
<div className="text-xs text-muted">Basisfall · {metricLabel}</div>
<div className="mt-0.5 text-xl font-semibold text-accent">{formatChf(result.base)} CHF</div>
</div>
<div>
<div className="mb-1 text-xs font-semibold text-fg">Einflussfaktoren nach Spannweite</div>
<p className="mb-2 text-[11px] text-muted">
Jeder Balken zeigt, zwischen welchen Werten das {metricLabel} schwankt, wenn nur dieser eine Parameter
innerhalb seiner Bandbreite variiert. Die senkrechte Linie ist der Basisfall.
</p>
<div className="w-full" style={{ height: Math.max(200, chartData.length * 46 + 60) }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} layout="vertical" margin={{ top: 8, right: 24, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis
type="number"
tick={{ fontSize: 11 }}
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
/>
<YAxis type="category" dataKey="label" tick={{ fontSize: 11 }} width={130} />
<Tooltip
formatter={(v) =>
Array.isArray(v)
? `${formatChf(Number(v[0]))} ${formatChf(Number(v[1]))}`
: typeof v === "number"
? formatChf(v)
: v
}
/>
<ReferenceLine x={result.base} stroke="var(--muted)" strokeDasharray="4 3" />
<Bar dataKey="range" name={metricLabel} fill="var(--accent)" radius={[3, 3, 3, 3]} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</div>
</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">Parameter</th>
<th className="px-3 py-2 text-left font-semibold">Bandbreite</th>
<th className="px-3 py-2 text-right font-semibold">bei «tief»</th>
<th className="px-3 py-2 text-right font-semibold">bei «hoch»</th>
<th className="px-3 py-2 text-right font-semibold">Spannweite</th>
</tr>
</thead>
<tbody>
{result.bars.map((b) => (
<tr key={b.id} className="border-t border-border">
<td className="px-3 py-2 font-medium text-fg">{b.label}</td>
<td className="px-3 py-2 text-muted">{formatRange(b.low, b.high, b.unit)}</td>
<td className="px-3 py-2 text-right text-muted">{formatChf(b.lowResult)}</td>
<td className="px-3 py-2 text-right text-muted">{formatChf(b.highResult)}</td>
<td className="px-3 py-2 text-right font-semibold text-fg">{formatChf(b.swing)}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-[11px] text-faint">
Die Reihenfolge nicht der absolute Betrag ist die Aussage. Sie hängt von den eingegebenen Bandbreiten ab.
</p>
</div>
);
}
+139 -1
View File
@@ -1,6 +1,15 @@
import { describe, it, expect } from "vitest";
import { computePlan } from "@/lib/calculations";
import { runMonteCarlo, defaultVolatilityLevel, RETURN_VOLATILITY_LEVELS, type MonteCarloParams } from "@/lib/montecarlo";
import {
buildElementGroups,
defaultVolatilityLevel,
paramsForScenario,
resolveRootElementId,
runMonteCarlo,
runMonteCarloMulti,
RETURN_VOLATILITY_LEVELS,
type MonteCarloParams,
} from "@/lib/montecarlo";
import type { PlanInput } from "@/lib/types";
// Plan: 40-jaehrig, 1 Phase 10 Jahre, ein Sonstiges Vermoegen 100'000 @ 5 %, 2 % Inflation.
@@ -105,3 +114,132 @@ describe("Monte Carlo", () => {
expect(RETURN_VOLATILITY_LEVELS.sehr_hoch).toBe(55);
});
});
// --- Mehrere Szenarien im selben Lauf ----------------------------------------------------
// Kopie des Basisplans mit neuen Ids; das Element verweist per sourceElementId auf sein
// Gegenstueck -- genau wie es die Kopier-Route beim Anlegen eines Szenarios setzt.
function copyPlan(source: PlanInput, suffix: string, expectedReturn: number): PlanInput {
return {
...source,
id: `${source.id}-${suffix}`,
elements: source.elements.map((e) => ({
...e,
id: `${e.id}-${suffix}`,
sourceElementId: e.id,
phaseValues: { p1: { ...e.phaseValues.p1, expectedReturn } },
})),
};
}
describe("Monte Carlo: mehrere Szenarien", () => {
it("loest die Herkunfts-Kette bis zum Ursprung auf", () => {
const chain = new Map<string, string | null>([
["a", null],
["b", "a"],
["c", "b"],
]);
expect(resolveRootElementId("c", chain)).toBe("a");
expect(resolveRootElementId("a", chain)).toBe("a");
// Verweis ins Leere (Vorlage geloescht): das Element ist selbst die Wurzel.
expect(resolveRootElementId("x", new Map([["x", "weg"]]))).toBe("x");
// Defekte Kette darf nicht zur Endlosschleife fuehren.
expect(resolveRootElementId("p", new Map([["p", "q"], ["q", "p"]]))).toBeDefined();
});
it("fasst dasselbe Element ueber Szenarien zu EINER Gruppe zusammen", () => {
const base = basePlan();
const s2 = copyPlan(base, "s2", 6);
const groups = buildElementGroups(
[{ id: "base", plan: base }, { id: "s2", plan: s2 }],
["base", "s2"]
);
expect(groups).toHaveLength(1); // nicht zwei -- die Annahme wird nur EINMAL erfasst
expect(groups[0].rootId).toBe("asset");
expect(groups[0].memberIds.sort()).toEqual(["asset", "asset-s2"]);
expect(groups[0].scenarioIds.sort()).toEqual(["base", "s2"]);
});
it("loest die Kette auch ueber ein NICHT ausgewaehltes Zwischen-Szenario auf", () => {
const base = basePlan();
const s1 = copyPlan(base, "s1", 6);
const s2 = copyPlan(s1, "s2", 7); // zeigt auf s1, nicht auf base
const groups = buildElementGroups(
[{ id: "base", plan: base }, { id: "s1", plan: s1 }, { id: "s2", plan: s2 }],
["base", "s2"] // s1 ist nur zur Aufloesung geladen
);
expect(groups).toHaveLength(1);
expect(groups[0].rootId).toBe("asset");
expect(groups[0].scenarioIds.sort()).toEqual(["base", "s2"]);
});
it("ein nur in einem Szenario neu angelegtes Element bildet eine eigene Gruppe", () => {
const base = basePlan();
const s2: PlanInput = {
...copyPlan(base, "s2", 5),
elements: [
...copyPlan(base, "s2", 5).elements,
{
id: "neu", category: "OTHER_ASSET", name: "Krypto", ownerRole: "HOUSEHOLD", orderIndex: 2,
phaseValues: { p1: { startValue: 10000, expectedReturn: 10 } }, transitionValues: {},
},
],
};
const groups = buildElementGroups([{ id: "base", plan: base }, { id: "s2", plan: s2 }], ["base", "s2"]);
expect(groups).toHaveLength(2);
const neu = groups.find((g) => g.rootId === "neu")!;
expect(neu.scenarioIds).toEqual(["s2"]); // im Basisszenario ohne Wirkung
});
it("uebersetzt die Gruppen-Parameter auf die Element-Ids des jeweiligen Szenarios", () => {
const base = basePlan();
const s2 = copyPlan(base, "s2", 6);
const groups = buildElementGroups([{ id: "base", plan: base }, { id: "s2", plan: s2 }], ["base", "s2"]);
const byRoot = { asset: { mean: 6, sigma: 15, floor: -100 } };
expect(paramsForScenario(base, groups, byRoot)).toEqual({ asset: byRoot.asset });
expect(paramsForScenario(s2, groups, byRoot)).toEqual({ "asset-s2": byRoot.asset });
});
it("gleicher Seed: nur die GEPLANTE Rendite unterscheidet sich -> identische Verteilung, andere Erfolgsquote", async () => {
// Der Kern-Anwendungsfall: dasselbe Portfolio, einmal pessimistisch (5 %) und einmal
// optimistisch (6 %) geplant. In der Simulation wird die geplante Rendite ersetzt, also
// sind beide Verlaeufe identisch -- der Unterschied liegt allein im Zielbetrag, der aus
// der jeweiligen Planung stammt. Das pessimistische Szenario haelt sein (tieferes)
// Versprechen oefter.
const base = basePlan(); // expectedReturn 5
const optimistisch = copyPlan(base, "opt", 6);
const targetBase = computePlan(base).nachlass;
const targetOpt = computePlan(optimistisch).nachlass;
expect(targetOpt).toBeGreaterThan(targetBase);
const groups = buildElementGroups(
[{ id: "base", plan: base }, { id: "opt", plan: optimistisch }],
["base", "opt"]
);
const byRoot = { asset: { mean: 6, sigma: 20, floor: -100 } };
const [rBase, rOpt] = await runMonteCarloMulti(
[
{ scenarioId: "base", name: "Basis", plan: base, target: targetBase },
{ scenarioId: "opt", name: "Optimistisch", plan: optimistisch, target: targetOpt },
],
{
runs: 2000,
inflationMean: 2,
inflationSigma: 0,
seed: 4242,
elementsFor: (p) => paramsForScenario(p, groups, byRoot),
}
);
// Identische Struktur + gemeinsamer Seed -> exakt dieselben Marktpfade.
expect(rOpt.finalWealthMedian).toBe(rBase.finalWealthMedian);
expect(rOpt.finalWealthP10).toBe(rBase.finalWealthP10);
expect(rOpt.ruinProbability).toBe(rBase.ruinProbability);
// Einziger Unterschied: der Zielbetrag -- und damit die Erfolgswahrscheinlichkeit.
expect(rBase.successProbability).toBeGreaterThan(rOpt.successProbability);
expect(rBase.scenarioId).toBe("base");
expect(rOpt.target).toBe(targetOpt);
});
});
+143
View File
@@ -79,6 +79,149 @@ export interface MonteCarloResult {
bands: { age: number; p10: number; p50: number; p90: number }[];
}
// --- Mehrere Szenarien im selben Lauf vergleichen -------------------------------------
//
// Ein "logisches" Element ueber Szenariogrenzen hinweg: Beim Kopieren eines Szenarios
// erhaelt jedes Element eine NEUE Id plus einen Verweis auf sein Gegenstueck in der Vorlage
// (sourceElementId -- dieselbe Kette, auf der auch der Diff beruht). Ueber diese Kette wird
// dieselbe Anlage in mehreren Szenarien wiedergefunden. Das ist die Voraussetzung dafuer,
// die historischen Annahmen nur EINMAL zu erfassen: Ein Vergleich ist nur dann aussagekraeftig,
// wenn alle Szenarien mit denselben Marktannahmen gewuerfelt werden -- sonst vergleicht man
// die Eingaben statt der Szenarien.
export interface ElementGroup {
rootId: string; // Id des Ursprungs-Elements (Anker der Parametereingabe)
name: string;
category: ElementCategory;
memberIds: string[]; // Element-Ids ueber alle ausgewaehlten Szenarien
scenarioIds: string[]; // Szenarien, in denen dieses Element vorkommt
}
// Folgt sourceElementId bis zum Ursprung. Bricht ab, sobald der Verweis ins Leere zeigt
// (Vorlage geloescht oder Szenario nicht geladen) -- dann ist dieses Element selbst die
// Wurzel und bildet eine eigene Gruppe. Der Zyklusschutz ist reine Vorsicht: die Verweise
// sind lose (kein FK), eine defekte Kette darf nicht zur Endlosschleife fuehren.
export function resolveRootElementId(elementId: string, sourceById: Map<string, string | null>): string {
let current = elementId;
const seen = new Set<string>();
while (!seen.has(current)) {
seen.add(current);
const source = sourceById.get(current);
if (!source || !sourceById.has(source)) return current;
current = source;
}
return current;
}
// `all` enthaelt ALLE Szenarien des Plans (auch nicht ausgewaehlte) -- nur so laesst sich die
// Kette ueber ein uebersprungenes Zwischen-Szenario hinweg aufloesen (Basis -> S1 -> S2, wenn
// nur Basis und S2 ausgewaehlt sind). Gruppen entstehen nur fuer die ausgewaehlten Szenarien.
export function buildElementGroups(
all: { id: string; plan: PlanInput }[],
selectedIds: string[]
): ElementGroup[] {
const sourceById = new Map<string, string | null>();
const nameById = new Map<string, string>();
for (const s of all) {
for (const e of s.plan.elements) {
sourceById.set(e.id, e.sourceElementId ?? null);
nameById.set(e.id, e.name);
}
}
const groups = new Map<string, ElementGroup>();
for (const s of all) {
if (!selectedIds.includes(s.id)) continue;
for (const e of s.plan.elements) {
if (!RETURN_BEARING.includes(e.category)) continue;
const rootId = resolveRootElementId(e.id, sourceById);
let group = groups.get(rootId);
if (!group) {
group = {
rootId,
// Name des Ursprungs-Elements, damit die Gruppe stabil beschriftet ist -- auch
// wenn das Element in einem Szenario umbenannt wurde.
name: nameById.get(rootId) ?? e.name,
category: e.category,
memberIds: [],
scenarioIds: [],
};
groups.set(rootId, group);
}
group.memberIds.push(e.id);
if (!group.scenarioIds.includes(s.id)) group.scenarioIds.push(s.id);
}
}
return [...groups.values()];
}
// Uebersetzt die je Gruppe erfassten Parameter auf die Element-Ids EINES Szenarios.
export function paramsForScenario(
plan: PlanInput,
groups: ElementGroup[],
paramByRoot: Record<string, ElementMcParams>
): Record<string, ElementMcParams> {
const rootByMember = new Map<string, string>();
for (const g of groups) for (const id of g.memberIds) rootByMember.set(id, g.rootId);
const out: Record<string, ElementMcParams> = {};
for (const e of plan.elements) {
if (!RETURN_BEARING.includes(e.category)) continue;
const root = rootByMember.get(e.id);
const params = root ? paramByRoot[root] : undefined;
if (params) out[e.id] = params;
}
return out;
}
export interface ScenarioRunInput {
scenarioId: string;
name: string;
plan: PlanInput;
target: number; // Zielbetrag DIESES Szenarios (i. d. R. sein geplanter Nachlass)
}
export interface ScenarioMcResult extends MonteCarloResult {
scenarioId: string;
name: string;
target: number;
}
// Fuehrt dieselbe Simulation fuer mehrere Szenarien aus -- mit DEMSELBEN Seed. Ohne das
// waeren kleine Unterschiede blosses Rauschen (bei 1'000 Laeufen betraegt der Standardfehler
// der Erfolgswahrscheinlichkeit rund 1.5 Prozentpunkte, zwei identische Szenarien koennten
// also 87 % und 90 % zeigen). Mit gemeinsamem Seed teilen strukturgleiche Szenarien dieselben
// Marktpfade, und die Unterschiede sind rein strukturell (Common Random Numbers).
export async function runMonteCarloMulti(
scenarios: ScenarioRunInput[],
common: {
runs: number;
inflationMean: number;
inflationSigma: number;
seed: number;
elementsFor: (plan: PlanInput) => Record<string, ElementMcParams>;
},
onProgress?: (scenarioIndex: number, scenarioCount: number, fraction: number) => void
): Promise<ScenarioMcResult[]> {
const results: ScenarioMcResult[] = [];
for (let i = 0; i < scenarios.length; i++) {
const s = scenarios[i];
const result = await runMonteCarlo(
s.plan,
{
runs: common.runs,
inflationMean: common.inflationMean,
inflationSigma: common.inflationSigma,
elements: common.elementsFor(s.plan),
target: s.target,
seed: common.seed,
},
(done, total) => onProgress?.(i, scenarios.length, done / total)
);
results.push({ ...result, scenarioId: s.scenarioId, name: s.name, target: s.target });
}
return results;
}
// --- Zufallszahlen (seedbar, damit ein Lauf reproduzierbar ist) ---
function mulberry32(seed: number): () => number {
+173
View File
@@ -0,0 +1,173 @@
import { describe, it, expect } from "vitest";
import { computePlan } from "@/lib/calculations";
import {
applyDriver,
computeTornado,
DRIVERS,
driverById,
planMetric,
} from "@/lib/sensitivity";
import type { PlanInput } from "@/lib/types";
// Plan: 45-jaehrig, zwei Phasen (20 J. Erwerb + 20 J. Pension), Einkommen 100'000 netto,
// Ausgaben 70'000 real, ein Sonstiges Vermoegen 200'000 @ 4 %, Inflation 1.5 %.
function basePlan(): PlanInput {
return {
id: "p",
name: "T",
householdType: "SINGLE",
inflationRateDefault: 1.5,
initialCash: 0,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 45, retirementAge: 65 }],
phases: [
{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 20, cashTransition: {} },
{ id: "p2", sequenceNumber: 2, name: "Pension", durationYears: 20, cashTransition: {} },
],
elements: [
{
id: "inc",
category: "INCOME",
name: "Lohn",
ownerRole: "HOUSEHOLD",
orderIndex: 1,
phaseValues: { p1: { amount: 100000, teuerungsausgleich: 1 } },
transitionValues: {},
},
{
id: "exp",
category: "EXPENSE",
name: "Lebenshaltung",
ownerRole: "HOUSEHOLD",
orderIndex: 2,
phaseValues: { p1: { amount: 70000 }, p2: { amount: 70000 } },
transitionValues: {},
},
{
id: "asset",
category: "OTHER_ASSET",
name: "ETF",
ownerRole: "HOUSEHOLD",
orderIndex: 3,
phaseValues: {
p1: { startValue: 200000, expectedReturn: 4 },
p2: { expectedReturn: 4 },
},
transitionValues: {},
},
],
};
}
describe("Sensitivitaet: applyDriver", () => {
it("laesst den Ausgangsplan unberuehrt (rein)", () => {
const p = basePlan();
const snapshot = JSON.stringify(p);
applyDriver(p, "inflation", 3);
applyDriver(p, "expenses", 20);
applyDriver(p, "returns", 2);
applyDriver(p, "lifespan", 5);
expect(JSON.stringify(p)).toBe(snapshot);
});
it("Inflation wird absolut gesetzt (nicht verschoben)", () => {
expect(applyDriver(basePlan(), "inflation", 3.5).inflationRateDefault).toBe(3.5);
});
it("Rendite wird in Prozentpunkten verschoben, je Element und Phase", () => {
const p = applyDriver(basePlan(), "returns", 2);
const asset = p.elements.find((e) => e.id === "asset")!;
expect(asset.phaseValues.p1.expectedReturn).toBe(6);
expect(asset.phaseValues.p2.expectedReturn).toBe(6);
// Einkommen/Ausgaben bleiben unberuehrt.
expect(p.elements.find((e) => e.id === "inc")!.phaseValues.p1.amount).toBe(100000);
});
it("Ausgaben werden relativ skaliert, Einkommen nicht", () => {
const p = applyDriver(basePlan(), "expenses", 10);
expect(p.elements.find((e) => e.id === "exp")!.phaseValues.p1.amount).toBeCloseTo(77000, 6);
expect(p.elements.find((e) => e.id === "exp")!.phaseValues.p2.amount).toBeCloseTo(77000, 6);
expect(p.elements.find((e) => e.id === "inc")!.phaseValues.p1.amount).toBe(100000);
});
it("Lebensdauer verschiebt nur die LETZTE Phase und bleibt bei mindestens 1 Jahr", () => {
const longer = applyDriver(basePlan(), "lifespan", 5);
expect(longer.phases[0].durationYears).toBe(20);
expect(longer.phases[1].durationYears).toBe(25);
// Kappung nach unten: -100 Jahre darf keine Phase mit 0 oder negativer Dauer erzeugen.
expect(applyDriver(basePlan(), "lifespan", -100).phases[1].durationYears).toBe(1);
});
it("Lohnentwicklung verschiebt den Teuerungsausgleich des Einkommens", () => {
const p = applyDriver(basePlan(), "salaryGrowth", 1);
expect(p.elements.find((e) => e.id === "inc")!.phaseValues.p1.teuerungsausgleich).toBe(2);
});
});
describe("Sensitivitaet: Treiber-Verfuegbarkeit", () => {
it("blendet Treiber aus, fuer die es keine passenden Elemente gibt", () => {
const p = basePlan();
expect(driverById("propertyGrowth").applies(p)).toBe(false); // keine Immobilie
expect(driverById("returns").applies(p)).toBe(true);
expect(driverById("inflation").applies(p)).toBe(true);
const ohneEinkommen: PlanInput = { ...p, elements: p.elements.filter((e) => e.category !== "INCOME") };
expect(driverById("income").applies(ohneEinkommen)).toBe(false);
expect(driverById("salaryGrowth").applies(ohneEinkommen)).toBe(false);
});
it("jeder Treiber ist genau einmal definiert", () => {
expect(new Set(DRIVERS.map((d) => d.id)).size).toBe(DRIVERS.length);
});
});
describe("Sensitivitaet: Tornado", () => {
it("Basiswert entspricht dem unveraenderten Plan", () => {
const p = basePlan();
const result = computeTornado(p, "real", [{ id: "inflation", low: 0.5, high: 3.5 }]);
const last = computePlan(p).phases.at(-1)!;
expect(result.base).toBe(Math.round(last.endWealthReal));
expect(planMetric(p, "nominal")).toBe(last.endWealthNominal);
});
it("sortiert nach Spannweite absteigend (Trichterform)", () => {
const result = computeTornado(basePlan(), "real", [
{ id: "salaryGrowth", low: 0, high: 0 }, // ohne Bandbreite -> keine Wirkung
{ id: "expenses", low: -15, high: 15 },
{ id: "returns", low: -2, high: 2 },
]);
const swings = result.bars.map((b) => b.swing);
expect([...swings].sort((a, b) => b - a)).toEqual(swings);
// Ein Treiber ohne Bandbreite kann nichts bewegen und landet zuunterst.
expect(result.bars.at(-1)!.id).toBe("salaryGrowth");
expect(result.bars.at(-1)!.swing).toBe(0);
// Welcher Treiber oben steht, haengt vom konkreten Plan ab -- genau das ist die Aussage
// des Tornados und deshalb bewusst nicht fix getestet.
});
it("kehrt die Richtung korrekt ab: hoehere Ausgaben -> tieferes Endvermoegen", () => {
const [bar] = computeTornado(basePlan(), "real", [{ id: "expenses", low: -15, high: 15 }]).bars;
expect(bar.lowResult).toBeGreaterThan(bar.highResult); // tiefe Ausgaben = mehr Vermoegen
expect(bar.min).toBe(bar.highResult);
expect(bar.max).toBe(bar.lowResult);
expect(bar.swing).toBe(bar.max - bar.min);
});
it("hoehere Rendite -> hoeheres Endvermoegen", () => {
const [bar] = computeTornado(basePlan(), "real", [{ id: "returns", low: -2, high: 2 }]).bars;
expect(bar.highResult).toBeGreaterThan(bar.lowResult);
});
it("identische Bandbreite ergibt Spannweite 0", () => {
const [bar] = computeTornado(basePlan(), "real", [{ id: "inflation", low: 2, high: 2 }]).bars;
expect(bar.swing).toBe(0);
});
it("real und nominal unterscheiden sich um den Deflator", () => {
const p = basePlan();
const real = planMetric(p, "real");
const nominal = planMetric(p, "nominal");
const last = computePlan(p).phases.at(-1)!;
expect(nominal).toBeGreaterThan(real); // 1.5 % Inflation ueber 40 Jahre
expect(real).toBe(Math.round(nominal / last.cumulativeInflationEnd));
});
});
+278
View File
@@ -0,0 +1,278 @@
// Sensitivitaetsanalyse / Tornado (Roadmap Nr. 20). Beantwortet nicht "wie viel Geld habe
// ich am Schluss", sondern "welche meiner Annahmen entscheidet ueberhaupt ueber das Ergebnis".
//
// Verfahren: One-at-a-time (OAT). Je Treiber wird EIN Parameter auf seinen tiefen und seinen
// hohen Wert gesetzt, alle uebrigen bleiben auf dem Planwert; die Differenz der beiden
// Ergebnisse ist die Spannweite. Nach Spannweite sortiert ergibt sich die Trichterform.
//
// Zwei bewusste Grenzen (im Dialog ausgewiesen, siehe SPEZIFIKATION 9.18):
// 1. Die Balkenlaenge haengt von den eingegebenen Bandbreiten ab -- deshalb gibt es hier
// KEINE Defaults, die Bandbreite ist je Treiber Pflichteingabe.
// 2. OAT sieht keine Wechselwirkungen (tiefe Rendite UND hohe Ausgaben treffen haerter als
// die Summe der Einzelbalken). Dafuer ist die Monte-Carlo-Simulation zustaendig.
//
// Laeuft wie die Monte-Carlo-Simulation vollstaendig im Browser: computePlan ist rein, und
// ein Tornado braucht nur 2 Aufrufe je Treiber (Millisekunden).
import { computePlan } from "@/lib/calculations";
import { num } from "@/lib/elements";
import type { ElementCategory, PhaseData } from "@/lib/elements";
import type { ElementInput, PlanInput } from "@/lib/types";
export type DriverId =
| "inflation"
| "returns"
| "expenses"
| "income"
| "salaryGrowth"
| "lifespan"
| "propertyGrowth";
// Die Einheit bestimmt, WAS der eingegebene Wert bedeutet -- das ist je Treiber verschieden
// und laesst sich nicht vereinheitlichen, ohne fachlich falsch zu werden:
// abs_pct absoluter Prozentsatz (es gibt genau einen plan-weiten Wert)
// delta_pp Verschiebung in Prozentpunkten (die Elemente haben je eigene Saetze -- ein
// absoluter Wert wuerde die PK auf ETF-Rendite plaetten)
// rel_pct relative Abweichung in Prozent (die Elemente haben je eigene Betraege)
// delta_years Verschiebung in Jahren
export type DriverUnit = "abs_pct" | "delta_pp" | "rel_pct" | "delta_years";
export interface DriverDef {
id: DriverId;
label: string;
shortLabel: string; // Achsenbeschriftung im Tornado (kurz genug fuer die y-Achse)
unit: DriverUnit;
help: string;
applies: (plan: PlanInput) => boolean;
}
const RETURN_CATEGORIES: ElementCategory[] = ["PENSION_FUND", "PILLAR_3A", "OTHER_ASSET"];
function hasCategory(plan: PlanInput, categories: ElementCategory[]): boolean {
return plan.elements.some((e) => categories.includes(e.category));
}
export const DRIVERS: DriverDef[] = [
{
id: "expenses",
label: "Ausgaben",
shortLabel: "Ausgaben",
unit: "rel_pct",
help:
"Prozentuale Abweichung aller Ausgaben-Elemente vom Planwert. Sinnvolle Bandbreite: 10 bis +15 %, wenn die Ausgaben aus echten Kontodaten stammen; 20 bis +30 %, wenn sie geschätzt sind. Erfahrungsgemäss der stärkste Hebel und einer, den man selbst steuern kann.",
applies: (p) => hasCategory(p, ["EXPENSE"]),
},
{
id: "returns",
label: "Rendite der Anlagen (PK, 3a, Sonstiges Vermögen)",
shortLabel: "Rendite",
unit: "delta_pp",
help:
"Verschiebung der erwarteten Rendite in Prozentpunkten, auf alle Anlagen gleichzeitig. Sinnvolle Bandbreite: 2 bis +2 Prozentpunkte für ein gemischtes Portfolio, 3 bis +3 bei hohem Aktienanteil. Die Immobilien-Wertsteigerung hat einen eigenen Treiber.",
applies: (p) => hasCategory(p, RETURN_CATEGORIES),
},
{
id: "lifespan",
label: "Lebensdauer (Dauer der letzten Phase)",
shortLabel: "Lebensdauer",
unit: "delta_years",
help:
"Verlängert bzw. verkürzt die letzte Lebensphase. Sinnvolle Bandbreite: 5 bis +10 Jahre die Restlebenserwartung streut stark, und Langlebigkeit ist das eigentliche Planungsrisiko (das Geld muss länger reichen).",
applies: (p) => p.phases.length > 0,
},
{
id: "inflation",
label: "Inflation",
shortLabel: "Inflation",
unit: "abs_pct",
help:
"Absolute Inflationsrate (nicht Abweichung). Sinnvolle Bandbreite für die Schweiz: 0.5 bis 3.5 % der langjährige Schnitt liegt bei rund 1 bis 2 %, einzelne Jahre lagen deutlich darüber.",
applies: () => true,
},
{
id: "income",
label: "Einkommen",
shortLabel: "Einkommen",
unit: "rel_pct",
help:
"Prozentuale Abweichung aller Einkommens-Elemente (netto) vom Planwert. Sinnvolle Bandbreite: 10 bis +10 % bei sicherer Anstellung, 30 bis +20 % bei selbständiger oder variabler Tätigkeit.",
applies: (p) => hasCategory(p, ["INCOME"]),
},
{
id: "salaryGrowth",
label: "Lohnentwicklung",
shortLabel: "Lohnentwicklung",
unit: "delta_pp",
help:
"Verschiebung der jährlichen nominalen Lohnerhöhung in Prozentpunkten. Sinnvolle Bandbreite: 1 bis +1 Prozentpunkt. Wirkt nur über die verbleibenden Erwerbsjahre und ist deshalb meist ein schwacher Hebel.",
applies: (p) => hasCategory(p, ["INCOME"]),
},
{
id: "propertyGrowth",
label: "Wertsteigerung der Immobilie",
shortLabel: "Immo-Wertsteigerung",
unit: "delta_pp",
help:
"Verschiebung der jährlichen Wertsteigerung in Prozentpunkten. Sinnvolle Bandbreite: 2 bis +2 Prozentpunkte. Wirkt auf den Wert der Liegenschaft und damit gehebelt auf das Eigenkapital.",
applies: (p) => hasCategory(p, ["REAL_ESTATE"]),
},
];
// Einheiten-Suffix fuer die Eingabefelder und die Ergebnistabelle.
export const UNIT_SUFFIX: Record<DriverUnit, string> = {
abs_pct: "%",
delta_pp: "pp",
rel_pct: "%",
delta_years: "J.",
};
export function driverById(id: DriverId): DriverDef {
const d = DRIVERS.find((x) => x.id === id);
if (!d) throw new Error(`Unbekannter Treiber: ${id}`);
return d;
}
// --- Anwenden eines Treiber-Wertes auf einen Plan -------------------------------------
// Alle Transformationen sind rein: sie liefern eine Kopie und lassen das Original unberuehrt.
function mapPhaseData(element: ElementInput, f: (pd: PhaseData) => PhaseData): ElementInput {
const phaseValues: Record<string, PhaseData> = {};
for (const [phaseId, pd] of Object.entries(element.phaseValues)) phaseValues[phaseId] = f(pd);
return { ...element, phaseValues };
}
// Bildet die Elemente der gegebenen Kategorien ab; alle uebrigen bleiben unveraendert.
function mapElements(
plan: PlanInput,
categories: ElementCategory[],
f: (pd: PhaseData) => PhaseData
): PlanInput {
return {
...plan,
elements: plan.elements.map((e) => (categories.includes(e.category) ? mapPhaseData(e, f) : e)),
};
}
export function applyDriver(plan: PlanInput, id: DriverId, value: number): PlanInput {
switch (id) {
case "inflation":
return { ...plan, inflationRateDefault: value };
case "returns":
// Verschiebung in Prozentpunkten auf die geplante Rendite. Nur dort, wo ueberhaupt ein
// Werte-Datensatz existiert -- fehlt er, rechnet computePlan ohnehin mit 0 %.
return mapElements(plan, RETURN_CATEGORIES, (pd) => ({
...pd,
expectedReturn: num(pd.expectedReturn) + value,
}));
case "propertyGrowth":
return mapElements(plan, ["REAL_ESTATE"], (pd) => ({
...pd,
valueGrowth: num(pd.valueGrowth) + value,
}));
case "expenses":
case "income": {
// Relative Skalierung des Basisbetrags. Bewusst nur dort, wo `amount` gesetzt ist:
// ab Phase 2 ist der Wert in der Regel live vererbt (kein gespeicherter Betrag), und
// die Fortschreibung leitet ihn aus dem skalierten Basiswert ab -- dadurch wirkt die
// Skalierung automatisch ueber alle Folgephasen.
const factor = 1 + value / 100;
return mapElements(plan, [id === "expenses" ? "EXPENSE" : "INCOME"], (pd) =>
typeof pd.amount === "number" ? { ...pd, amount: Math.max(0, pd.amount * factor) } : pd
);
}
case "salaryGrowth":
return mapElements(plan, ["INCOME"], (pd) => ({
...pd,
teuerungsausgleich: num(pd.teuerungsausgleich, 0) + value,
}));
case "lifespan": {
// Verlaengert/verkuerzt die LETZTE Phase. Bewusst nicht das Pensionsalter: das laesst
// sich ohne Mitverschieben der Phasengrenzen nicht sinnvoll variieren (siehe 9.18).
if (plan.phases.length === 0) return plan;
const lastSeq = Math.max(...plan.phases.map((p) => p.sequenceNumber));
return {
...plan,
phases: plan.phases.map((p) =>
p.sequenceNumber === lastSeq
? { ...p, durationYears: Math.max(1, Math.round(p.durationYears + value)) }
: p
),
};
}
}
}
// --- Tornado ---------------------------------------------------------------------------
export type TornadoMetric = "real" | "nominal";
export interface TornadoInput {
id: DriverId;
low: number;
high: number;
}
export interface TornadoBar {
id: DriverId;
label: string;
shortLabel: string;
unit: DriverUnit;
low: number; // eingegebene Bandbreite
high: number;
lowResult: number; // Zielgroesse beim tiefen Wert
highResult: number; // Zielgroesse beim hohen Wert
min: number; // fuer den Balken: kleinerer der beiden Ergebniswerte
max: number;
swing: number; // max - min
}
export interface TornadoResult {
base: number;
bars: TornadoBar[];
}
// Zielgroesse: Endvermoegen der letzten Phase, real (kaufkraftbereinigt) oder nominal.
export function planMetric(plan: PlanInput, metric: TornadoMetric): number {
const computed = computePlan(plan);
const last = computed.phases[computed.phases.length - 1];
if (!last) return 0;
return Math.round(metric === "real" ? last.endWealthReal : last.endWealthNominal);
}
export function computeTornado(
plan: PlanInput,
metric: TornadoMetric,
inputs: TornadoInput[]
): TornadoResult {
const base = planMetric(plan, metric);
const bars: TornadoBar[] = inputs.map((input) => {
const def = driverById(input.id);
const lowResult = planMetric(applyDriver(plan, input.id, input.low), metric);
const highResult = planMetric(applyDriver(plan, input.id, input.high), metric);
// Die Richtung kann sich umkehren (tiefe Ausgaben -> hohes Vermoegen). Der Balken spannt
// deshalb ueber min..max; welche Eingabe zu welchem Ende gehoert, zeigt die Tabelle.
return {
id: input.id,
label: def.label,
shortLabel: def.shortLabel,
unit: def.unit,
low: input.low,
high: input.high,
lowResult,
highResult,
min: Math.min(lowResult, highResult),
max: Math.max(lowResult, highResult),
swing: Math.abs(highResult - lowResult),
};
});
// Trichterform: groesste Spannweite zuoberst.
bars.sort((a, b) => b.swing - a.swing);
return { base, bars };
}