Live-Simulation: Was-waere-wenn-Regler (Roadmap 22)
Deploy App / deploy (push) Successful in 54s

Zweispalter: links Regler, rechts waehlbare Grafik, oben Kennzahlen mit
Differenz zum unveraenderten Plan. Keine eigene Rechenlogik -- die Regler
nutzen dieselben Transformationen wie der Tornado.

Neu: applyElementDriver / tunableElements fuer einzeln regelbare
Element-Renditen; livesim.ts; AllocationChart aus dem Dashboard geloest.

computePlan misst 0.2 ms auf 60 Jahren -> synchron, ohne Debounce.
Spezifikation 0.18 (4.15 und 9.27 neu), 15 Tests (124 -> 139).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 21:22:49 +02:00
parent 50e98122cf
commit d04e07fdfb
9 changed files with 1009 additions and 62 deletions
+77
View File
@@ -0,0 +1,77 @@
"use client";
import { useMemo } from "react";
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { formatChf } from "@/lib/format";
import type { PlanComputed } from "@/lib/calculations";
export const CHART_PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"];
const ASSET_CATS = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"];
// Gestapelte Vermögensaufteilung je Phase (Beginn und Ende). Eigene Komponente, weil sie
// sowohl im Grafiken-Dialog als auch in der Live-Simulation gebraucht wird.
export function AllocationChart({ computed, height = 288 }: { computed: PlanComputed; height?: number }) {
// Asset-Elemente (nach id, damit gleiche Namen nicht kollidieren), die irgendwann einen
// positiven Wert haben -- in Reihenfolge ihres ersten Auftretens.
const assetEls = useMemo(() => {
const info = new Map<string, { name: string; any: boolean }>();
for (const phase of computed.phases) {
for (const el of phase.elements) {
if (!ASSET_CATS.includes(el.category)) continue;
const cur = info.get(el.elementId) ?? { name: el.name, any: false };
cur.name = el.name;
if (el.startValue > 0 || el.endValue > 0) cur.any = true;
info.set(el.elementId, cur);
}
}
return [...info.entries()].filter(([, v]) => v.any).map(([id, v]) => ({ id, name: v.name }));
}, [computed]);
// Je Phase zwei Kategorien auf der x-Achse: Beginn und Ende.
const barData = useMemo(
() =>
computed.phases.flatMap((phase) => {
const beginn: Record<string, number | string> = { label: `${phase.name} · Beginn` };
const ende: Record<string, number | string> = { label: `${phase.name} · Ende` };
for (const el of phase.elements) {
if (!ASSET_CATS.includes(el.category)) continue;
beginn[el.elementId] = Math.max(0, Math.round(el.startValue));
ende[el.elementId] = Math.max(0, Math.round(el.endValue));
}
return [beginn, ende];
}),
[computed]
);
if (assetEls.length === 0) {
return <p className="text-sm text-muted">Dieser Plan enthält keine Vermögenselemente.</p>;
}
return (
<div className="w-full" style={{ height }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={barData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="label" tick={{ fontSize: 10 }} interval={0} angle={-30} textAnchor="end" height={70} />
<YAxis
tick={{ fontSize: 11 }}
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
/>
<Tooltip formatter={(v) => (typeof v === "number" ? formatChf(v) : v)} />
<Legend wrapperStyle={{ fontSize: 12 }} />
{assetEls.map((el, i) => (
<Bar
key={el.id}
dataKey={el.id}
name={el.name}
stackId="a"
fill={CHART_PALETTE[i % CHART_PALETTE.length]}
isAnimationActive={false}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
);
}
+10
View File
@@ -24,6 +24,7 @@ import { PlanView } from "@/components/PlanView";
import { Dashboard } from "@/components/Dashboard";
import { MonteCarloDialog } from "@/components/MonteCarloDialog";
import { SensitivityDialog } from "@/components/SensitivityDialog";
import { LiveSimDialog } from "@/components/LiveSimDialog";
import { SpecView } from "@/components/SpecView";
import { SystemParametersView } from "@/components/SystemParametersView";
import { PlanTraceDialog } from "@/components/DetailView";
@@ -85,6 +86,7 @@ function AppShellInner({ username }: { username: string }) {
const [showCharts, setShowCharts] = useState(false);
const [showMonteCarlo, setShowMonteCarlo] = useState(false);
const [showSensitivity, setShowSensitivity] = useState(false);
const [showLiveSim, setShowLiveSim] = useState(false);
const [showSystemParams, setShowSystemParams] = useState(false);
const [showPlanTraces, setShowPlanTraces] = useState(false);
const [showPalette, setShowPalette] = useState(false);
@@ -421,6 +423,10 @@ function AppShellInner({ username }: { username: string }) {
<BarChart3 className="h-4 w-4" />
Grafiken
</Button>
<Button variant="secondary" onClick={() => setShowLiveSim(true)}>
<SlidersHorizontal className="h-4 w-4" />
Live-Simulation
</Button>
<Button variant="secondary" onClick={() => setShowMonteCarlo(true)}>
<Dices className="h-4 w-4" />
Monte-Carlo-Simulation
@@ -530,6 +536,10 @@ function AppShellInner({ username }: { username: string }) {
<SensitivityDialog plan={detail.plan} onClose={() => setShowSensitivity(false)} />
)}
{showLiveSim && detail && (
<LiveSimDialog plan={detail.plan} onClose={() => setShowLiveSim(false)} />
)}
{showPlanTraces && detail && (
<PlanTraceDialog
computed={computePlan(detail.plan, undefined, { explain: true })}
+4 -58
View File
@@ -1,8 +1,8 @@
"use client";
import { useMemo, useState } from "react";
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { BarChart3, Download, LineChart as LineChartIcon } from "lucide-react";
import { AllocationChart, CHART_PALETTE } from "@/components/AllocationChart";
import { WealthChart, type TimelineSeries } from "@/components/WealthChart";
import { SparquoteChart } from "@/components/SparquoteChart";
import { api } from "@/lib/api-client";
@@ -10,8 +10,6 @@ import { formatChf } from "@/lib/format";
import type { PlanComputed } from "@/lib/calculations";
import type { PlanInput } from "@/lib/types";
const PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3äd"];
interface PlanListItem {
id: string;
name: string;
@@ -43,51 +41,15 @@ export function Dashboard({
}
const series: TimelineSeries[] = useMemo(() => {
const result: TimelineSeries[] = [{ label: plan.name, color: PALETTE[0], computed }];
const result: TimelineSeries[] = [{ label: plan.name, color: CHART_PALETTE[0], computed }];
compareIds.forEach((id, i) => {
const c = compareData[id];
const name = siblings.find((p) => p.id === id)?.name ?? id;
if (c) result.push({ label: name, color: PALETTE[(i + 1) % PALETTE.length], computed: c });
if (c) result.push({ label: name, color: CHART_PALETTE[(i + 1) % CHART_PALETTE.length], computed: c });
});
return result;
}, [plan.name, computed, compareIds, compareData, siblings]);
const ASSET_CATS = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"];
// Asset-Elemente (nach id, damit gleiche Namen nicht kollidieren), die irgendwann einen
// positiven Wert haben -- in Reihenfolge ihres ersten Auftretens.
const assetEls = useMemo(() => {
const info = new Map<string, { name: string; any: boolean }>();
for (const phase of computed.phases) {
for (const el of phase.elements) {
if (!ASSET_CATS.includes(el.category)) continue;
const cur = info.get(el.elementId) ?? { name: el.name, any: false };
cur.name = el.name;
if (el.startValue > 0 || el.endValue > 0) cur.any = true;
info.set(el.elementId, cur);
}
}
return [...info.entries()].filter(([, v]) => v.any).map(([id, v]) => ({ id, name: v.name }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [computed]);
// Je Phase zwei Kategorien auf der x-Achse: Beginn und Ende. Werte je Asset-Element.
const barData = useMemo(
() =>
computed.phases.flatMap((phase) => {
const beginn: Record<string, number | string> = { label: `${phase.name} · Beginn` };
const ende: Record<string, number | string> = { label: `${phase.name} · Ende` };
for (const el of phase.elements) {
if (!ASSET_CATS.includes(el.category)) continue;
beginn[el.elementId] = Math.max(0, Math.round(el.startValue));
ende[el.elementId] = Math.max(0, Math.round(el.endValue));
}
return [beginn, ende];
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[computed]
);
const otherPlans = siblings;
const lastPhase = computed.phases[computed.phases.length - 1];
@@ -151,23 +113,7 @@ export function Dashboard({
Je Phase links die Aufteilung zu Beginn, rechts am Ende. Das Ende einer Phase entspricht im
Gesamtvolumen dem Beginn der nächsten &ndash; die Aufteilung kann durch Umschichtung abweichen.
</p>
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={barData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="label" tick={{ fontSize: 10 }} interval={0} angle={-30} textAnchor="end" height={70} />
<YAxis
tick={{ fontSize: 11 }}
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
/>
<Tooltip formatter={(v) => (typeof v === "number" ? formatChf(v) : v)} />
<Legend wrapperStyle={{ fontSize: 12 }} />
{assetEls.map((el, i) => (
<Bar key={el.id} dataKey={el.id} name={el.name} stackId="a" fill={PALETTE[i % PALETTE.length]} />
))}
</BarChart>
</ResponsiveContainer>
</div>
<AllocationChart computed={computed} />
</section>
</div>
);
+337
View File
@@ -0,0 +1,337 @@
"use client";
import { useMemo, useState } from "react";
import { ChevronDown, ChevronRight, RotateCcw, SlidersHorizontal, X } from "lucide-react";
import { AllocationChart } from "@/components/AllocationChart";
import { SparquoteChart } from "@/components/SparquoteChart";
import { WealthChart } from "@/components/WealthChart";
import { InfoBubble } from "@/components/InfoBubble";
import { computePlan } from "@/lib/calculations";
import { formatChf } from "@/lib/format";
import {
buildSliders,
describeSettings,
isNeutral,
kpisOf,
runLive,
type SliderDef,
type SliderValues,
} from "@/lib/livesim";
import { UNIT_SUFFIX } from "@/lib/sensitivity";
import type { PlanInput } from "@/lib/types";
type ChartId = "wealth" | "allocation" | "cashflow";
const CHARTS: { id: ChartId; label: string; hint: string }[] = [
{
id: "wealth",
label: "Vermögensverlauf",
hint: "Gesamtvermögen über das Alter. Die blasse Linie ist dein unveränderter Plan.",
},
{
id: "allocation",
label: "Vermögensaufteilung",
hint: "Wie sich das Vermögen zu Beginn und am Ende jeder Phase auf die Anlagen verteilt.",
},
{
id: "cashflow",
label: "Einkommen vs. Ausgaben",
hint: "Die Fläche dazwischen ist die Spar- bzw. Verzehrquote.",
},
];
const BASE_COLOR = "#9ca3af";
const LIVE_COLOR = "#4f46e5";
export function LiveSimDialog({ plan, onClose }: { plan: PlanInput; onClose: () => void }) {
const [expandReturns, setExpandReturns] = useState(false);
const [values, setValues] = useState<SliderValues>({});
// Vom Nutzer überschriebene Reglerbereiche (Schlüssel -> [min, max]).
const [bounds, setBounds] = useState<Record<string, [number, number]>>({});
const [chart, setChart] = useState<ChartId>("wealth");
const [showRanges, setShowRanges] = useState(false);
const sliders = useMemo(() => {
const base = buildSliders(plan, expandReturns);
return base.map((s) => {
const b = bounds[s.key];
return b ? { ...s, min: b[0], max: b[1] } : s;
});
}, [plan, expandReturns, bounds]);
// Referenz: der unveränderte Plan. Wird nur neu gerechnet, wenn sich der Plan ändert.
const base = useMemo(() => {
const computed = computePlan(plan);
return { computed, kpis: kpisOf(computed) };
}, [plan]);
// Der Kern: bei JEDER Reglerbewegung synchron neu rechnen (~0.2 ms, siehe livesim.ts).
const live = useMemo(() => runLive(plan, sliders, values), [plan, sliders, values]);
const neutral = isNeutral(sliders, values);
const settings = describeSettings(sliders, values);
function setValue(key: string, v: number) {
setValues((prev) => ({ ...prev, [key]: v }));
}
function reset() {
setValues({});
}
// Beim Auf-/Zuklappen die Rendite-Regler zurücksetzen: Sammel- und Element-Regler bewegen
// dieselben Werte, ein Übertrag würde doppelt zählen.
function toggleExpand() {
setValues((prev) => {
const next: SliderValues = {};
for (const [k, v] of Object.entries(prev)) {
if (k === "d:returns" || k === "d:propertyGrowth" || k.startsWith("e:")) continue;
next[k] = v;
}
return next;
});
setExpandReturns((v) => !v);
}
return (
<div className="ui-fade 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="ui-pop flex w-full max-w-6xl 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">
<SlidersHorizontal className="h-5 w-5 text-accent" /> Live-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>
<p className="rounded-xl border border-border bg-surface-2 p-3 text-xs leading-relaxed text-muted">
Dreh an den Reglern und sieh sofort, was passiert. <strong className="text-fg">Nichts davon wird
gespeichert</strong> dein Plan bleibt unverändert, du brauchst für kein Durchspielen eine
Szenario-Kopie. Die Regler benutzen dieselben Umrechnungen wie die Einflussfaktoren-Analyse.
</p>
{/* Kennzahlenleiste: die eigentliche Antwort. Die Grafik zeigt WANN, das hier WIE VIEL. */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<DeltaCard label="Endvermögen (nominal)" base={base.kpis.endNominal} live={live.kpis.endNominal} />
<DeltaCard label="Endvermögen (real)" base={base.kpis.endReal} live={live.kpis.endReal} />
<RuinCard baseAge={base.kpis.ruinAge} liveAge={live.kpis.ruinAge} />
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[320px_1fr]">
{/* --- Links: Regler --- */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
Parameter
<InfoBubble text="Es erscheinen nur Regler, die in diesem Plan überhaupt etwas bewegen. Die Standardbereiche sind Vorschläge du kannst beide Enden anpassen." />
</span>
<button
type="button"
onClick={reset}
disabled={neutral}
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[11px] font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg disabled:opacity-40"
>
<RotateCcw className="h-3 w-3" /> Zurücksetzen
</button>
</div>
<button
type="button"
onClick={toggleExpand}
className="flex items-center gap-1.5 self-start rounded-lg border border-dashed border-border px-2.5 py-1 text-[11px] font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg"
>
{expandReturns ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
{expandReturns ? "Renditen wieder zusammenfassen" : "Renditen einzeln aufschlüsseln"}
</button>
<label className="flex items-center gap-1.5 text-[11px] text-muted">
<input type="checkbox" checked={showRanges} onChange={() => setShowRanges((v) => !v)} />
Bereiche anpassen
</label>
<div className="flex max-h-[46vh] flex-col gap-3 overflow-y-auto pr-1">
{sliders.map((s) => (
<Slider
key={s.key}
def={s}
value={values[s.key] ?? s.neutral}
showRange={showRanges}
onChange={(v) => setValue(s.key, v)}
onBounds={(min, max) => setBounds((prev) => ({ ...prev, [s.key]: [min, max] }))}
/>
))}
{sliders.length === 0 && (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-xs text-muted">
Dieser Plan enthält noch keine Elemente, an denen sich etwas regeln liesse.
</p>
)}
</div>
{settings.length > 0 && (
<div className="rounded-lg border border-border bg-surface-2 p-2.5">
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-faint">
Aktive Einstellung
</div>
<p className="text-[11px] leading-relaxed text-muted">{settings.join(" · ")}</p>
</div>
)}
</div>
{/* --- Rechts: Grafik --- */}
<div className="flex min-w-0 flex-col gap-2">
<div className="inline-flex flex-wrap gap-0.5 self-start rounded-lg border border-border bg-surface-2 p-0.5 text-xs">
{CHARTS.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setChart(c.id)}
className={`rounded-md px-2.5 py-1 font-medium transition-colors ${
chart === c.id ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
}`}
>
{c.label}
</button>
))}
</div>
<p className="text-[11px] text-muted">{CHARTS.find((c) => c.id === chart)!.hint}</p>
<div className="min-w-0 rounded-xl border border-border bg-surface p-3">
{chart === "wealth" && (
<WealthChart
series={[
// Referenz zuerst, damit die Live-Linie darüber liegt.
{ label: "Dein Plan", color: BASE_COLOR, computed: base.computed },
{ label: "Simuliert", color: LIVE_COLOR, computed: live.computed },
]}
/>
)}
{chart === "allocation" && <AllocationChart computed={live.computed} />}
{chart === "cashflow" && <SparquoteChart computed={live.computed} />}
</div>
{chart !== "wealth" && (
<p className="text-[11px] text-faint">
Diese Grafik zeigt nur den simulierten Stand. Die Gegenüberstellung mit deinem
unveränderten Plan liefert der Vermögensverlauf und die Zahlen oben.
</p>
)}
</div>
</div>
</div>
</div>
);
}
// Ein Regler. Die Zahl steht bewusst neben dem Schieber und ist auch direkt eingebbar --
// mit der Maus trifft man 5.2 % nicht zuverlässig.
function Slider({
def,
value,
showRange,
onChange,
onBounds,
}: {
def: SliderDef;
value: number;
showRange: boolean;
onChange: (v: number) => void;
onBounds: (min: number, max: number) => void;
}) {
const suffix = UNIT_SUFFIX[def.unit];
const touched = value !== def.neutral;
return (
<div className={`rounded-lg border p-2.5 ${touched ? "border-accent bg-accent-soft/20" : "border-border bg-surface-2"}`}>
<div className="mb-1.5 flex items-center justify-between gap-2">
<span className="flex min-w-0 items-center text-xs font-medium text-fg">
<span className="truncate">{def.label}</span>
<InfoBubble text={def.help} />
</span>
<span className="flex shrink-0 items-center gap-1">
<input
type="number"
value={value}
step={def.step}
onChange={(e) => onChange(Number(e.target.value))}
className="w-16 rounded border border-border bg-surface px-1 py-0.5 text-right text-xs text-fg"
/>
<span className="text-[10px] text-faint">{suffix}</span>
</span>
</div>
<input
type="range"
min={def.min}
max={def.max}
step={def.step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full accent-[var(--accent)]"
/>
{showRange ? (
<div className="mt-1 flex items-center gap-1.5 text-[10px] text-faint">
<input
type="number"
value={def.min}
step={def.step}
onChange={(e) => onBounds(Number(e.target.value), def.max)}
className="w-14 rounded border border-border bg-surface px-1 py-0.5 text-right text-fg"
/>
<span>bis</span>
<input
type="number"
value={def.max}
step={def.step}
onChange={(e) => onBounds(def.min, Number(e.target.value))}
className="w-14 rounded border border-border bg-surface px-1 py-0.5 text-right text-fg"
/>
<span className="ml-auto">neutral: {def.neutral} {suffix}</span>
</div>
) : (
<div className="mt-0.5 flex justify-between text-[10px] text-faint">
<span>{def.min}</span>
<span>{def.max}</span>
</div>
)}
</div>
);
}
function DeltaCard({ label, base, live }: { label: string; base: number; live: number }) {
const diff = live - base;
const tone = diff === 0 ? "text-fg" : diff > 0 ? "text-success" : "text-danger";
return (
<div className="rounded-xl border border-border bg-surface-2 p-3">
<div className="text-[10px] font-semibold uppercase tracking-wide text-faint">{label}</div>
<div className="mt-0.5 text-lg font-semibold text-fg">{formatChf(live)}</div>
<div className="text-[11px] text-muted">
Plan: {formatChf(base)}
{diff !== 0 && (
<span className={`ml-1.5 font-semibold ${tone}`}>
{diff > 0 ? "+" : ""}
{formatChf(Math.abs(diff))}
</span>
)}
</div>
</div>
);
}
// Ein gekippter Plan ist die wichtigste Einzelinformation -- einer Verlaufslinie sieht man
// nicht zuverlässig an, dass das Kapital zwischendurch unter null gefallen ist.
function RuinCard({ baseAge, liveAge }: { baseAge: number | null; liveAge: number | null }) {
const broken = liveAge !== null;
return (
<div className={`rounded-xl border p-3 ${broken ? "border-danger bg-danger-soft" : "border-border bg-surface-2"}`}>
<div className="text-[10px] font-semibold uppercase tracking-wide text-faint">Kapital reicht</div>
<div className={`mt-0.5 text-lg font-semibold ${broken ? "text-danger" : "text-success"}`}>
{broken ? `aufgebraucht mit ${liveAge}` : "bis Planende"}
</div>
<div className="text-[11px] text-muted">
{baseAge === null ? "Plan: reicht bis Planende" : `Plan: aufgebraucht mit ${baseAge}`}
</div>
</div>
);
}
+1 -1
View File
@@ -47,7 +47,7 @@ const STEPS: TourStep[] = [
{
target: "analysen",
title: "Analysen",
text: "Grafiken, Monte-Carlo-Simulation und Einflussfaktoren: Wie sicher ist dein Plan, und welche Annahme entscheidet wirklich?",
text: "Grafiken, Live-Simulation, Monte-Carlo und Einflussfaktoren: Was passiert, wenn ich hier drehe wie sicher ist mein Plan und welche Annahme entscheidet wirklich?",
},
];
+256
View File
@@ -0,0 +1,256 @@
import { describe, it, expect } from "vitest";
import { computePlan } from "@/lib/calculations";
import { applyElementDriver, tunableElements } from "@/lib/sensitivity";
import {
applySliders,
buildSliders,
describeSettings,
isNeutral,
kpisOf,
runLive,
type SliderValues,
} from "@/lib/livesim";
import type { PlanInput } from "@/lib/types";
// Plan: 40-jährig, zwei Phasen (25 J. Erwerb + 20 J. Pension), Einkommen 120'000 netto,
// Ausgaben 80'000, ZWEI Sonstige Vermögen mit unterschiedlicher Rendite plus eine PK --
// damit lässt sich prüfen, dass der Sammelregler alle bewegt, der Element-Regler nur einen.
function basePlan(): PlanInput {
return {
id: "p",
name: "T",
householdType: "SINGLE",
inflationRateDefault: 1.5,
initialCash: 0,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
phases: [
{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 25, cashTransition: {} },
{ id: "p2", sequenceNumber: 2, name: "Pension", durationYears: 20, cashTransition: {} },
],
elements: [
{
id: "inc",
category: "INCOME",
name: "Lohn",
ownerRole: "HOUSEHOLD",
orderIndex: 1,
phaseValues: { p1: { amount: 120000, teuerungsausgleich: 1 } },
transitionValues: {},
},
{
id: "exp",
category: "EXPENSE",
name: "Lebenshaltung",
ownerRole: "HOUSEHOLD",
orderIndex: 2,
phaseValues: { p1: { amount: 80000 }, p2: { amount: 80000 } },
transitionValues: {},
},
{
id: "etf",
category: "OTHER_ASSET",
name: "ETF",
ownerRole: "HOUSEHOLD",
orderIndex: 3,
phaseValues: { p1: { startValue: 200000, expectedReturn: 5 }, p2: { expectedReturn: 4 } },
transitionValues: {},
},
{
id: "fest",
category: "OTHER_ASSET",
name: "Festgeld",
ownerRole: "HOUSEHOLD",
orderIndex: 4,
phaseValues: { p1: { startValue: 100000, expectedReturn: 1 }, p2: { expectedReturn: 1 } },
transitionValues: {},
},
{
id: "pk",
category: "PENSION_FUND",
name: "Pensionskasse",
ownerRole: "PERSON_A",
orderIndex: 5,
phaseValues: { p1: { startValue: 300000, expectedReturn: 2, contributionRate: 10 } },
transitionValues: {},
},
],
} as unknown as PlanInput;
}
const endOf = (p: PlanInput) => kpisOf(computePlan(p)).endNominal;
describe("applyElementDriver", () => {
it("bewegt genau EIN Element und lässt die übrigen unberührt", () => {
const plan = basePlan();
const out = applyElementDriver(plan, "etf", 2);
const get = (p: PlanInput, id: string, phase: string) =>
p.elements.find((e) => e.id === id)!.phaseValues[phase]?.expectedReturn;
// ETF: in ALLEN Phasen verschoben (5 -> 7, 4 -> 6).
expect(get(out, "etf", "p1")).toBe(7);
expect(get(out, "etf", "p2")).toBe(6);
// Alle anderen unverändert.
expect(get(out, "fest", "p1")).toBe(1);
expect(get(out, "pk", "p1")).toBe(2);
// Und das Original ist unangetastet (Reinheit).
expect(get(plan, "etf", "p1")).toBe(5);
});
it("greift bei Immobilien auf die Wertsteigerung statt auf die Rendite", () => {
const plan = basePlan();
plan.elements.push({
id: "haus",
category: "REAL_ESTATE",
name: "Haus",
ownerRole: "HOUSEHOLD",
orderIndex: 6,
phaseValues: { p1: { startValue: 800000, valueGrowth: 1, purchasePrice: 800000 } },
transitionValues: {},
} as never);
const out = applyElementDriver(plan, "haus", 1.5);
const pd = out.elements.find((e) => e.id === "haus")!.phaseValues.p1;
expect(pd.valueGrowth).toBe(2.5);
expect(pd.expectedReturn).toBeUndefined();
});
it("ignoriert eine unbekannte Element-ID, statt zu werfen", () => {
const plan = basePlan();
expect(applyElementDriver(plan, "gibtsnicht", 5)).toBe(plan);
});
it("listet nur renditetragende Elemente als regelbar auf", () => {
const ids = tunableElements(basePlan()).map((e) => e.id);
expect(ids).toEqual(["etf", "fest", "pk"]);
// Einkommen und Ausgaben tragen keine Rendite -- sie haben ihre eigenen Sammelregler.
expect(ids).not.toContain("inc");
expect(ids).not.toContain("exp");
});
});
describe("buildSliders", () => {
it("zeigt nur Regler, die im Plan überhaupt greifen", () => {
const plan = basePlan(); // keine Immobilie
const keys = buildSliders(plan, false).map((s) => s.key);
expect(keys).toContain("d:returns");
expect(keys).toContain("d:expenses");
expect(keys).not.toContain("d:propertyGrowth");
});
it("ersetzt beim Aufklappen den Sammelregler durch je einen Regler pro Element", () => {
const plan = basePlan();
const keys = buildSliders(plan, true).map((s) => s.key);
// Sammelregler weg ...
expect(keys).not.toContain("d:returns");
// ... dafür einer je Element. Sonst würde eine Bewegung doppelt zählen.
expect(keys).toContain("e:etf");
expect(keys).toContain("e:fest");
expect(keys).toContain("e:pk");
});
it("setzt den Neutralpunkt der Inflation auf den Planwert, sonst auf 0", () => {
const plan = basePlan();
const sliders = buildSliders(plan, false);
expect(sliders.find((s) => s.key === "d:inflation")!.neutral).toBe(1.5);
expect(sliders.find((s) => s.key === "d:returns")!.neutral).toBe(0);
});
});
describe("applySliders", () => {
it("lässt den Plan bei neutraler Stellung exakt unverändert", () => {
const plan = basePlan();
const sliders = buildSliders(plan, false);
const neutral: SliderValues = Object.fromEntries(sliders.map((s) => [s.key, s.neutral]));
expect(isNeutral(sliders, neutral)).toBe(true);
expect(isNeutral(sliders, {})).toBe(true); // fehlender Schlüssel = neutral
// Der entscheidende Punkt: Die Kennzahl muss dem unveränderten Plan EXAKT entsprechen,
// sonst würde der Referenzwert gegen sich selbst abweichen.
expect(endOf(applySliders(plan, sliders, neutral))).toBe(endOf(plan));
});
it("kombiniert mehrere Regler unabhängig von der Reihenfolge", () => {
const plan = basePlan();
const sliders = buildSliders(plan, false);
const values: SliderValues = { "d:returns": 1, "d:expenses": -10, "d:lifespan": 3 };
const a = applySliders(plan, sliders, values);
const b = applySliders(plan, [...sliders].reverse(), values);
expect(endOf(a)).toBe(endOf(b));
});
it("wirkt aufgeschlüsselt wie der Sammelregler, wenn alle Elemente gleich bewegt werden", () => {
const plan = basePlan();
// Sammelregler: alle Anlagen +1 pp.
const collapsed = buildSliders(plan, false);
const viaDriver = applySliders(plan, collapsed, { "d:returns": 1 });
// Aufgeschlüsselt: jedes Element einzeln +1 pp. Muss auf dasselbe hinauslaufen -- sonst
// würden die beiden Ansichten desselben Reglers verschiedene Pläne beschreiben.
const expanded = buildSliders(plan, true);
const viaElements = applySliders(
plan,
expanded,
Object.fromEntries(expanded.filter((s) => s.ref.kind === "element").map((s) => [s.key, 1]))
);
expect(endOf(viaElements)).toBe(endOf(viaDriver));
});
it("trennt die Elemente sauber: nur den ETF anzuheben wirkt schwächer als alle", () => {
const plan = basePlan();
const expanded = buildSliders(plan, true);
const base = endOf(plan);
const onlyEtf = endOf(applySliders(plan, expanded, { "e:etf": 2 }));
const all = endOf(
applySliders(plan, expanded, { "e:etf": 2, "e:fest": 2, "e:pk": 2 })
);
expect(onlyEtf).toBeGreaterThan(base);
expect(all).toBeGreaterThan(onlyEtf);
});
});
describe("runLive", () => {
it("liefert den veränderten Plan mit passender Kennzahl", () => {
const plan = basePlan();
const sliders = buildSliders(plan, false);
const res = runLive(plan, sliders, { "d:expenses": 25 });
// Höhere Ausgaben -> weniger Endvermögen.
expect(res.kpis.endNominal).toBeLessThan(endOf(plan));
// Und die Kennzahl gehört wirklich zum zurückgegebenen Plan.
expect(res.kpis.endNominal).toBe(endOf(res.plan));
});
it("meldet ein Ruinalter, sobald die Regler den Plan kippen", () => {
const plan = basePlan();
const sliders = buildSliders(plan, false);
expect(runLive(plan, sliders, {}).kpis.ruinAge).toBeNull();
// Ausgaben ans obere Ende und Rendite ans untere: der Plan muss brechen. Ohne diese
// Anzeige sähe die Verlaufslinie weiterhin plausibel aus.
const broken = runLive(plan, sliders, { "d:expenses": 30, "d:returns": -3, "d:income": -30 });
expect(broken.kpis.ruinAge).not.toBeNull();
});
});
describe("describeSettings", () => {
it("beschreibt nur die abweichenden Regler, mit Vorzeichen und Einheit", () => {
const plan = basePlan();
const sliders = buildSliders(plan, false);
const text = describeSettings(sliders, { "d:returns": -1.5, "d:lifespan": 5, "d:expenses": 0 });
expect(text).toContain("Rendite -1.5 pp");
expect(text).toContain("Lebensdauer +5 J.");
// Ein neutral stehender Regler taucht nicht auf.
expect(text.some((t) => t.startsWith("Ausgaben"))).toBe(false);
});
it("schreibt die Inflation ohne Vorzeichen -- sie ist ein absoluter Wert", () => {
const plan = basePlan();
const sliders = buildSliders(plan, false);
expect(describeSettings(sliders, { "d:inflation": 3 })).toContain("Inflation 3 %");
});
});
+171
View File
@@ -0,0 +1,171 @@
// Live-Simulation / Was-wäre-wenn-Regler (Roadmap Nr. 22). Beantwortet nicht «welche Annahme
// entscheidet» (das ist der Tornado) und nicht «wie wahrscheinlich» (das ist Monte-Carlo),
// sondern schlicht: «wie sieht mein Plan aus, wenn ich HIER drehe?» -- sofort, ohne für jede
// Variante eine Szenario-Kopie anzulegen.
//
// Bewusst KEINE eigene Rechenlogik: Die Regler benutzen dieselben Transformationen wie der
// Tornado (`applyDriver`, `applyElementDriver`). Dadurch kann die Live-Simulation gar nicht
// etwas anderes zeigen als der Tornado -- beide bewegen den Plan identisch.
//
// Zur Laufzeit: `computePlan` braucht auf einem 60-Jahres-Plan mit 10 Elementen rund 0.2 ms.
// Bei 60 fps stehen 16 ms zur Verfügung -- die Rechnung kostet also etwa 1 % des Budgets.
// Deshalb wird bei JEDER Reglerbewegung synchron neu gerechnet: kein Debounce, kein Worker,
// keine Ladeanzeige. Der Engpass ist das Neuzeichnen der Grafik, nicht die Mathematik.
import { computePlan } from "@/lib/calculations";
import { applyDriver, applyElementDriver, driverById, DRIVERS, tunableElements } from "@/lib/sensitivity";
import type { DriverId, DriverUnit } from "@/lib/sensitivity";
import type { PlanComputed } from "@/lib/calculations";
import type { PlanInput } from "@/lib/types";
// Ein Regler: entweder ein plan-weiter Treiber oder die Rendite eines einzelnen Elements.
export type SliderRef = { kind: "driver"; id: DriverId } | { kind: "element"; id: string };
export interface SliderDef {
key: string; // eindeutig über beide Arten hinweg
ref: SliderRef;
label: string;
unit: DriverUnit;
help: string;
min: number; // Standardbereich, im Dialog überschreibbar
max: number;
step: number;
neutral: number; // Wert, bei dem der Regler den Plan NICHT verändert
}
// Standardbereiche je Treiber.
//
// Der Tornado hat bewusst KEINE Default-Bandbreiten (SPEZIFIKATION 9.18): Dort bestimmt die
// Bandbreite die Balkenlänge, und ein Default würde eine Rangfolge frei erfinden. Hier ist das
// anders -- ein Regler vergleicht keine Treiber gegeneinander, er zeigt ein einzelnes
// absolutes Ergebnis. Ein Standardbereich erfindet also keine Aussage, er macht den Regler
// überhaupt erst bedienbar. Beide Enden bleiben editierbar. Siehe 9.27.
const RANGES: Record<DriverId, { min: number; max: number; step: number; neutral: number }> = {
// Absolute Werte: der neutrale Punkt ist der Planwert selbst und wird zur Laufzeit gesetzt.
inflation: { min: 0, max: 4, step: 0.1, neutral: 0 },
// Verschiebungen: neutral ist immer 0.
returns: { min: -3, max: 3, step: 0.1, neutral: 0 },
propertyGrowth: { min: -2, max: 2, step: 0.1, neutral: 0 },
salaryGrowth: { min: -1, max: 2, step: 0.1, neutral: 0 },
expenses: { min: -20, max: 30, step: 1, neutral: 0 },
income: { min: -30, max: 20, step: 1, neutral: 0 },
lifespan: { min: -5, max: 10, step: 1, neutral: 0 },
};
// Bereich für die einzeln aufgeschlüsselten Element-Renditen: derselbe wie der Sammelregler,
// damit ein aufgeklappter Regler nicht plötzlich anders skaliert.
const ELEMENT_RANGE = RANGES.returns;
export const ELEMENT_HELP =
"Verschiebung der erwarteten Rendite dieses einen Elements in Prozentpunkten. Der Sammelregler «Rendite» bewegt stattdessen alle Anlagen gleichzeitig.";
// Baut die Reglerliste. `expandReturns` ersetzt den Sammelregler «Rendite» durch je einen
// Regler pro renditetragendem Element.
export function buildSliders(plan: PlanInput, expandReturns: boolean): SliderDef[] {
const out: SliderDef[] = [];
for (const def of DRIVERS) {
if (!def.applies(plan)) continue;
// Aufgeschlüsselt übernehmen die Element-Regler die Rolle der Sammelregler.
if (expandReturns && (def.id === "returns" || def.id === "propertyGrowth")) continue;
const r = RANGES[def.id];
out.push({
key: `d:${def.id}`,
ref: { kind: "driver", id: def.id },
label: def.shortLabel,
unit: def.unit,
help: def.help,
min: r.min,
max: r.max,
step: r.step,
// Die Inflation ist der einzige absolute Treiber: neutral ist der Wert aus dem Plan.
neutral: def.id === "inflation" ? plan.inflationRateDefault : 0,
});
}
if (expandReturns) {
for (const el of tunableElements(plan)) {
out.push({
key: `e:${el.id}`,
ref: { kind: "element", id: el.id },
label: el.name,
unit: "delta_pp",
help: ELEMENT_HELP,
min: ELEMENT_RANGE.min,
max: ELEMENT_RANGE.max,
step: ELEMENT_RANGE.step,
neutral: 0,
});
}
}
return out;
}
// Reglerstellungen: Schlüssel -> Wert. Fehlt ein Schlüssel, steht der Regler neutral.
export type SliderValues = Record<string, number>;
// Wendet alle vom Neutralpunkt abweichenden Regler nacheinander an.
//
// Die Reihenfolge ist bei den heutigen Treibern gleichgültig -- sie greifen auf verschiedene
// Felder zu, und die einzige Überschneidung (Sammel- vs. Element-Rendite) kann nicht
// gleichzeitig auftreten, weil `buildSliders` beim Aufklappen den Sammelregler entfernt.
export function applySliders(plan: PlanInput, sliders: SliderDef[], values: SliderValues): PlanInput {
let out = plan;
for (const s of sliders) {
const v = values[s.key];
if (v === undefined || v === s.neutral) continue;
out = s.ref.kind === "driver" ? applyDriver(out, s.ref.id, v) : applyElementDriver(out, s.ref.id, v);
}
return out;
}
export function isNeutral(sliders: SliderDef[], values: SliderValues): boolean {
return sliders.every((s) => values[s.key] === undefined || values[s.key] === s.neutral);
}
// --- Kennzahlen ------------------------------------------------------------------------
// Die eigentliche Antwort der Live-Simulation. Eine Grafik zeigt WANN sich etwas ändert,
// diese Leiste zeigt WIE VIEL -- und dass ein Plan gekippt ist (Ruin), was man einer
// Verlaufslinie allein nicht zuverlässig ansieht.
export interface LiveKpis {
endNominal: number;
endReal: number;
ruinAge: number | null;
}
export function kpisOf(computed: PlanComputed): LiveKpis {
const last = computed.phases[computed.phases.length - 1];
return {
endNominal: Math.round(last ? last.endWealthNominal : 0),
endReal: Math.round(last ? last.endWealthReal : 0),
ruinAge: computed.ruinAge,
};
}
export interface LiveResult {
plan: PlanInput; // der veränderte Plan
computed: PlanComputed;
kpis: LiveKpis;
}
export function runLive(plan: PlanInput, sliders: SliderDef[], values: SliderValues): LiveResult {
const tuned = applySliders(plan, sliders, values);
const computed = computePlan(tuned);
return { plan: tuned, computed, kpis: kpisOf(computed) };
}
// Lesbare Zusammenfassung der aktiven Regler. Ersatz für ein «als Szenario speichern», das
// bewusst noch nicht existiert (Roadmap): Man kann die gefundene Einstellung wenigstens
// ablesen und von Hand in ein echtes Szenario übertragen.
export function describeSettings(sliders: SliderDef[], values: SliderValues): string[] {
const out: string[] = [];
for (const s of sliders) {
const v = values[s.key];
if (v === undefined || v === s.neutral) continue;
const sign = s.ref.kind === "driver" && s.ref.id === "inflation" ? "" : v > 0 ? "+" : "";
const unit = s.unit === "delta_pp" ? " pp" : s.unit === "delta_years" ? " J." : " %";
out.push(`${s.label} ${sign}${v}${unit}`);
}
return out;
}
export { driverById };
+35
View File
@@ -207,6 +207,41 @@ export function applyDriver(plan: PlanInput, id: DriverId, value: number): PlanI
}
}
// Verschiebt die Rendite EINES Elements um eine Anzahl Prozentpunkte. Gegenstück zu
// `applyDriver("returns" | "propertyGrowth", …)`, das immer alle Elemente einer Kategorie
// gleichzeitig bewegt.
//
// Für den Tornado wäre das falsch (dreizehn Balken sind keine Rangfolge mehr), für die
// Live-Simulation ist es die entscheidende Frage: «Was, wenn mein ETF schlechter läuft, die
// PK aber wie geplant?». Siehe SPEZIFIKATION 4.15.
//
// Element-IDs sind innerhalb eines Szenarios eindeutig -- die Herkunfts-Verkettung
// (`sourceElementId`) aus der Monte-Carlo-Simulation braucht es hier nicht, weil die
// Live-Simulation immer nur auf EINEM Szenario läuft.
export function applyElementDriver(plan: PlanInput, elementId: string, deltaPp: number): PlanInput {
const target = plan.elements.find((e) => e.id === elementId);
if (!target) return plan;
// Immobilien tragen ihre Rendite als Wertsteigerung der Liegenschaft, alle übrigen als
// erwartete Rendite auf den Anlagewert.
const field = target.category === "REAL_ESTATE" ? "valueGrowth" : "expectedReturn";
return {
...plan,
elements: plan.elements.map((e) =>
e.id === elementId
? mapPhaseData(e, (pd) => ({ ...pd, [field]: num(pd[field as keyof PhaseData] as never) + deltaPp }))
: e
),
};
}
// Elemente, deren Rendite sich einzeln regeln lässt -- dieselbe Menge, die auch der
// Sammelregler bewegt (Anlagen + Immobilien).
export function tunableElements(plan: PlanInput): { id: string; name: string; category: ElementCategory }[] {
return plan.elements
.filter((e) => RETURN_CATEGORIES.includes(e.category) || e.category === "REAL_ESTATE")
.map((e) => ({ id: e.id, name: e.name, category: e.category }));
}
// --- Tornado ---------------------------------------------------------------------------
export type TornadoMetric = "real" | "nominal";