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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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 – 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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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?",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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 %");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user