Szenario-Vergleich in der Monte-Carlo-Simulation + Sensitivitaetsanalyse (Tornado)
Deploy App / deploy (push) Successful in 53s
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user