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:
+139
-1
@@ -1,6 +1,15 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { runMonteCarlo, defaultVolatilityLevel, RETURN_VOLATILITY_LEVELS, type MonteCarloParams } from "@/lib/montecarlo";
|
||||
import {
|
||||
buildElementGroups,
|
||||
defaultVolatilityLevel,
|
||||
paramsForScenario,
|
||||
resolveRootElementId,
|
||||
runMonteCarlo,
|
||||
runMonteCarloMulti,
|
||||
RETURN_VOLATILITY_LEVELS,
|
||||
type MonteCarloParams,
|
||||
} from "@/lib/montecarlo";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
// Plan: 40-jaehrig, 1 Phase 10 Jahre, ein Sonstiges Vermoegen 100'000 @ 5 %, 2 % Inflation.
|
||||
@@ -105,3 +114,132 @@ describe("Monte Carlo", () => {
|
||||
expect(RETURN_VOLATILITY_LEVELS.sehr_hoch).toBe(55);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Mehrere Szenarien im selben Lauf ----------------------------------------------------
|
||||
|
||||
// Kopie des Basisplans mit neuen Ids; das Element verweist per sourceElementId auf sein
|
||||
// Gegenstueck -- genau wie es die Kopier-Route beim Anlegen eines Szenarios setzt.
|
||||
function copyPlan(source: PlanInput, suffix: string, expectedReturn: number): PlanInput {
|
||||
return {
|
||||
...source,
|
||||
id: `${source.id}-${suffix}`,
|
||||
elements: source.elements.map((e) => ({
|
||||
...e,
|
||||
id: `${e.id}-${suffix}`,
|
||||
sourceElementId: e.id,
|
||||
phaseValues: { p1: { ...e.phaseValues.p1, expectedReturn } },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Monte Carlo: mehrere Szenarien", () => {
|
||||
it("loest die Herkunfts-Kette bis zum Ursprung auf", () => {
|
||||
const chain = new Map<string, string | null>([
|
||||
["a", null],
|
||||
["b", "a"],
|
||||
["c", "b"],
|
||||
]);
|
||||
expect(resolveRootElementId("c", chain)).toBe("a");
|
||||
expect(resolveRootElementId("a", chain)).toBe("a");
|
||||
// Verweis ins Leere (Vorlage geloescht): das Element ist selbst die Wurzel.
|
||||
expect(resolveRootElementId("x", new Map([["x", "weg"]]))).toBe("x");
|
||||
// Defekte Kette darf nicht zur Endlosschleife fuehren.
|
||||
expect(resolveRootElementId("p", new Map([["p", "q"], ["q", "p"]]))).toBeDefined();
|
||||
});
|
||||
|
||||
it("fasst dasselbe Element ueber Szenarien zu EINER Gruppe zusammen", () => {
|
||||
const base = basePlan();
|
||||
const s2 = copyPlan(base, "s2", 6);
|
||||
const groups = buildElementGroups(
|
||||
[{ id: "base", plan: base }, { id: "s2", plan: s2 }],
|
||||
["base", "s2"]
|
||||
);
|
||||
expect(groups).toHaveLength(1); // nicht zwei -- die Annahme wird nur EINMAL erfasst
|
||||
expect(groups[0].rootId).toBe("asset");
|
||||
expect(groups[0].memberIds.sort()).toEqual(["asset", "asset-s2"]);
|
||||
expect(groups[0].scenarioIds.sort()).toEqual(["base", "s2"]);
|
||||
});
|
||||
|
||||
it("loest die Kette auch ueber ein NICHT ausgewaehltes Zwischen-Szenario auf", () => {
|
||||
const base = basePlan();
|
||||
const s1 = copyPlan(base, "s1", 6);
|
||||
const s2 = copyPlan(s1, "s2", 7); // zeigt auf s1, nicht auf base
|
||||
const groups = buildElementGroups(
|
||||
[{ id: "base", plan: base }, { id: "s1", plan: s1 }, { id: "s2", plan: s2 }],
|
||||
["base", "s2"] // s1 ist nur zur Aufloesung geladen
|
||||
);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].rootId).toBe("asset");
|
||||
expect(groups[0].scenarioIds.sort()).toEqual(["base", "s2"]);
|
||||
});
|
||||
|
||||
it("ein nur in einem Szenario neu angelegtes Element bildet eine eigene Gruppe", () => {
|
||||
const base = basePlan();
|
||||
const s2: PlanInput = {
|
||||
...copyPlan(base, "s2", 5),
|
||||
elements: [
|
||||
...copyPlan(base, "s2", 5).elements,
|
||||
{
|
||||
id: "neu", category: "OTHER_ASSET", name: "Krypto", ownerRole: "HOUSEHOLD", orderIndex: 2,
|
||||
phaseValues: { p1: { startValue: 10000, expectedReturn: 10 } }, transitionValues: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
const groups = buildElementGroups([{ id: "base", plan: base }, { id: "s2", plan: s2 }], ["base", "s2"]);
|
||||
expect(groups).toHaveLength(2);
|
||||
const neu = groups.find((g) => g.rootId === "neu")!;
|
||||
expect(neu.scenarioIds).toEqual(["s2"]); // im Basisszenario ohne Wirkung
|
||||
});
|
||||
|
||||
it("uebersetzt die Gruppen-Parameter auf die Element-Ids des jeweiligen Szenarios", () => {
|
||||
const base = basePlan();
|
||||
const s2 = copyPlan(base, "s2", 6);
|
||||
const groups = buildElementGroups([{ id: "base", plan: base }, { id: "s2", plan: s2 }], ["base", "s2"]);
|
||||
const byRoot = { asset: { mean: 6, sigma: 15, floor: -100 } };
|
||||
expect(paramsForScenario(base, groups, byRoot)).toEqual({ asset: byRoot.asset });
|
||||
expect(paramsForScenario(s2, groups, byRoot)).toEqual({ "asset-s2": byRoot.asset });
|
||||
});
|
||||
|
||||
it("gleicher Seed: nur die GEPLANTE Rendite unterscheidet sich -> identische Verteilung, andere Erfolgsquote", async () => {
|
||||
// Der Kern-Anwendungsfall: dasselbe Portfolio, einmal pessimistisch (5 %) und einmal
|
||||
// optimistisch (6 %) geplant. In der Simulation wird die geplante Rendite ersetzt, also
|
||||
// sind beide Verlaeufe identisch -- der Unterschied liegt allein im Zielbetrag, der aus
|
||||
// der jeweiligen Planung stammt. Das pessimistische Szenario haelt sein (tieferes)
|
||||
// Versprechen oefter.
|
||||
const base = basePlan(); // expectedReturn 5
|
||||
const optimistisch = copyPlan(base, "opt", 6);
|
||||
|
||||
const targetBase = computePlan(base).nachlass;
|
||||
const targetOpt = computePlan(optimistisch).nachlass;
|
||||
expect(targetOpt).toBeGreaterThan(targetBase);
|
||||
|
||||
const groups = buildElementGroups(
|
||||
[{ id: "base", plan: base }, { id: "opt", plan: optimistisch }],
|
||||
["base", "opt"]
|
||||
);
|
||||
const byRoot = { asset: { mean: 6, sigma: 20, floor: -100 } };
|
||||
|
||||
const [rBase, rOpt] = await runMonteCarloMulti(
|
||||
[
|
||||
{ scenarioId: "base", name: "Basis", plan: base, target: targetBase },
|
||||
{ scenarioId: "opt", name: "Optimistisch", plan: optimistisch, target: targetOpt },
|
||||
],
|
||||
{
|
||||
runs: 2000,
|
||||
inflationMean: 2,
|
||||
inflationSigma: 0,
|
||||
seed: 4242,
|
||||
elementsFor: (p) => paramsForScenario(p, groups, byRoot),
|
||||
}
|
||||
);
|
||||
|
||||
// Identische Struktur + gemeinsamer Seed -> exakt dieselben Marktpfade.
|
||||
expect(rOpt.finalWealthMedian).toBe(rBase.finalWealthMedian);
|
||||
expect(rOpt.finalWealthP10).toBe(rBase.finalWealthP10);
|
||||
expect(rOpt.ruinProbability).toBe(rBase.ruinProbability);
|
||||
// Einziger Unterschied: der Zielbetrag -- und damit die Erfolgswahrscheinlichkeit.
|
||||
expect(rBase.successProbability).toBeGreaterThan(rOpt.successProbability);
|
||||
expect(rBase.scenarioId).toBe("base");
|
||||
expect(rOpt.target).toBe(targetOpt);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,6 +79,149 @@ export interface MonteCarloResult {
|
||||
bands: { age: number; p10: number; p50: number; p90: number }[];
|
||||
}
|
||||
|
||||
// --- Mehrere Szenarien im selben Lauf vergleichen -------------------------------------
|
||||
//
|
||||
// Ein "logisches" Element ueber Szenariogrenzen hinweg: Beim Kopieren eines Szenarios
|
||||
// erhaelt jedes Element eine NEUE Id plus einen Verweis auf sein Gegenstueck in der Vorlage
|
||||
// (sourceElementId -- dieselbe Kette, auf der auch der Diff beruht). Ueber diese Kette wird
|
||||
// dieselbe Anlage in mehreren Szenarien wiedergefunden. Das ist die Voraussetzung dafuer,
|
||||
// die historischen Annahmen nur EINMAL zu erfassen: Ein Vergleich ist nur dann aussagekraeftig,
|
||||
// wenn alle Szenarien mit denselben Marktannahmen gewuerfelt werden -- sonst vergleicht man
|
||||
// die Eingaben statt der Szenarien.
|
||||
export interface ElementGroup {
|
||||
rootId: string; // Id des Ursprungs-Elements (Anker der Parametereingabe)
|
||||
name: string;
|
||||
category: ElementCategory;
|
||||
memberIds: string[]; // Element-Ids ueber alle ausgewaehlten Szenarien
|
||||
scenarioIds: string[]; // Szenarien, in denen dieses Element vorkommt
|
||||
}
|
||||
|
||||
// Folgt sourceElementId bis zum Ursprung. Bricht ab, sobald der Verweis ins Leere zeigt
|
||||
// (Vorlage geloescht oder Szenario nicht geladen) -- dann ist dieses Element selbst die
|
||||
// Wurzel und bildet eine eigene Gruppe. Der Zyklusschutz ist reine Vorsicht: die Verweise
|
||||
// sind lose (kein FK), eine defekte Kette darf nicht zur Endlosschleife fuehren.
|
||||
export function resolveRootElementId(elementId: string, sourceById: Map<string, string | null>): string {
|
||||
let current = elementId;
|
||||
const seen = new Set<string>();
|
||||
while (!seen.has(current)) {
|
||||
seen.add(current);
|
||||
const source = sourceById.get(current);
|
||||
if (!source || !sourceById.has(source)) return current;
|
||||
current = source;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
// `all` enthaelt ALLE Szenarien des Plans (auch nicht ausgewaehlte) -- nur so laesst sich die
|
||||
// Kette ueber ein uebersprungenes Zwischen-Szenario hinweg aufloesen (Basis -> S1 -> S2, wenn
|
||||
// nur Basis und S2 ausgewaehlt sind). Gruppen entstehen nur fuer die ausgewaehlten Szenarien.
|
||||
export function buildElementGroups(
|
||||
all: { id: string; plan: PlanInput }[],
|
||||
selectedIds: string[]
|
||||
): ElementGroup[] {
|
||||
const sourceById = new Map<string, string | null>();
|
||||
const nameById = new Map<string, string>();
|
||||
for (const s of all) {
|
||||
for (const e of s.plan.elements) {
|
||||
sourceById.set(e.id, e.sourceElementId ?? null);
|
||||
nameById.set(e.id, e.name);
|
||||
}
|
||||
}
|
||||
|
||||
const groups = new Map<string, ElementGroup>();
|
||||
for (const s of all) {
|
||||
if (!selectedIds.includes(s.id)) continue;
|
||||
for (const e of s.plan.elements) {
|
||||
if (!RETURN_BEARING.includes(e.category)) continue;
|
||||
const rootId = resolveRootElementId(e.id, sourceById);
|
||||
let group = groups.get(rootId);
|
||||
if (!group) {
|
||||
group = {
|
||||
rootId,
|
||||
// Name des Ursprungs-Elements, damit die Gruppe stabil beschriftet ist -- auch
|
||||
// wenn das Element in einem Szenario umbenannt wurde.
|
||||
name: nameById.get(rootId) ?? e.name,
|
||||
category: e.category,
|
||||
memberIds: [],
|
||||
scenarioIds: [],
|
||||
};
|
||||
groups.set(rootId, group);
|
||||
}
|
||||
group.memberIds.push(e.id);
|
||||
if (!group.scenarioIds.includes(s.id)) group.scenarioIds.push(s.id);
|
||||
}
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
// Uebersetzt die je Gruppe erfassten Parameter auf die Element-Ids EINES Szenarios.
|
||||
export function paramsForScenario(
|
||||
plan: PlanInput,
|
||||
groups: ElementGroup[],
|
||||
paramByRoot: Record<string, ElementMcParams>
|
||||
): Record<string, ElementMcParams> {
|
||||
const rootByMember = new Map<string, string>();
|
||||
for (const g of groups) for (const id of g.memberIds) rootByMember.set(id, g.rootId);
|
||||
|
||||
const out: Record<string, ElementMcParams> = {};
|
||||
for (const e of plan.elements) {
|
||||
if (!RETURN_BEARING.includes(e.category)) continue;
|
||||
const root = rootByMember.get(e.id);
|
||||
const params = root ? paramByRoot[root] : undefined;
|
||||
if (params) out[e.id] = params;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface ScenarioRunInput {
|
||||
scenarioId: string;
|
||||
name: string;
|
||||
plan: PlanInput;
|
||||
target: number; // Zielbetrag DIESES Szenarios (i. d. R. sein geplanter Nachlass)
|
||||
}
|
||||
|
||||
export interface ScenarioMcResult extends MonteCarloResult {
|
||||
scenarioId: string;
|
||||
name: string;
|
||||
target: number;
|
||||
}
|
||||
|
||||
// Fuehrt dieselbe Simulation fuer mehrere Szenarien aus -- mit DEMSELBEN Seed. Ohne das
|
||||
// waeren kleine Unterschiede blosses Rauschen (bei 1'000 Laeufen betraegt der Standardfehler
|
||||
// der Erfolgswahrscheinlichkeit rund 1.5 Prozentpunkte, zwei identische Szenarien koennten
|
||||
// also 87 % und 90 % zeigen). Mit gemeinsamem Seed teilen strukturgleiche Szenarien dieselben
|
||||
// Marktpfade, und die Unterschiede sind rein strukturell (Common Random Numbers).
|
||||
export async function runMonteCarloMulti(
|
||||
scenarios: ScenarioRunInput[],
|
||||
common: {
|
||||
runs: number;
|
||||
inflationMean: number;
|
||||
inflationSigma: number;
|
||||
seed: number;
|
||||
elementsFor: (plan: PlanInput) => Record<string, ElementMcParams>;
|
||||
},
|
||||
onProgress?: (scenarioIndex: number, scenarioCount: number, fraction: number) => void
|
||||
): Promise<ScenarioMcResult[]> {
|
||||
const results: ScenarioMcResult[] = [];
|
||||
for (let i = 0; i < scenarios.length; i++) {
|
||||
const s = scenarios[i];
|
||||
const result = await runMonteCarlo(
|
||||
s.plan,
|
||||
{
|
||||
runs: common.runs,
|
||||
inflationMean: common.inflationMean,
|
||||
inflationSigma: common.inflationSigma,
|
||||
elements: common.elementsFor(s.plan),
|
||||
target: s.target,
|
||||
seed: common.seed,
|
||||
},
|
||||
(done, total) => onProgress?.(i, scenarios.length, done / total)
|
||||
);
|
||||
results.push({ ...result, scenarioId: s.scenarioId, name: s.name, target: s.target });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// --- Zufallszahlen (seedbar, damit ein Lauf reproduzierbar ist) ---
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import {
|
||||
applyDriver,
|
||||
computeTornado,
|
||||
DRIVERS,
|
||||
driverById,
|
||||
planMetric,
|
||||
} from "@/lib/sensitivity";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
// Plan: 45-jaehrig, zwei Phasen (20 J. Erwerb + 20 J. Pension), Einkommen 100'000 netto,
|
||||
// Ausgaben 70'000 real, ein Sonstiges Vermoegen 200'000 @ 4 %, Inflation 1.5 %.
|
||||
function basePlan(): PlanInput {
|
||||
return {
|
||||
id: "p",
|
||||
name: "T",
|
||||
householdType: "SINGLE",
|
||||
inflationRateDefault: 1.5,
|
||||
initialCash: 0,
|
||||
persons: [{ id: "A", role: "PERSON_A", name: null, age: 45, retirementAge: 65 }],
|
||||
phases: [
|
||||
{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 20, cashTransition: {} },
|
||||
{ id: "p2", sequenceNumber: 2, name: "Pension", durationYears: 20, cashTransition: {} },
|
||||
],
|
||||
elements: [
|
||||
{
|
||||
id: "inc",
|
||||
category: "INCOME",
|
||||
name: "Lohn",
|
||||
ownerRole: "HOUSEHOLD",
|
||||
orderIndex: 1,
|
||||
phaseValues: { p1: { amount: 100000, teuerungsausgleich: 1 } },
|
||||
transitionValues: {},
|
||||
},
|
||||
{
|
||||
id: "exp",
|
||||
category: "EXPENSE",
|
||||
name: "Lebenshaltung",
|
||||
ownerRole: "HOUSEHOLD",
|
||||
orderIndex: 2,
|
||||
phaseValues: { p1: { amount: 70000 }, p2: { amount: 70000 } },
|
||||
transitionValues: {},
|
||||
},
|
||||
{
|
||||
id: "asset",
|
||||
category: "OTHER_ASSET",
|
||||
name: "ETF",
|
||||
ownerRole: "HOUSEHOLD",
|
||||
orderIndex: 3,
|
||||
phaseValues: {
|
||||
p1: { startValue: 200000, expectedReturn: 4 },
|
||||
p2: { expectedReturn: 4 },
|
||||
},
|
||||
transitionValues: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("Sensitivitaet: applyDriver", () => {
|
||||
it("laesst den Ausgangsplan unberuehrt (rein)", () => {
|
||||
const p = basePlan();
|
||||
const snapshot = JSON.stringify(p);
|
||||
applyDriver(p, "inflation", 3);
|
||||
applyDriver(p, "expenses", 20);
|
||||
applyDriver(p, "returns", 2);
|
||||
applyDriver(p, "lifespan", 5);
|
||||
expect(JSON.stringify(p)).toBe(snapshot);
|
||||
});
|
||||
|
||||
it("Inflation wird absolut gesetzt (nicht verschoben)", () => {
|
||||
expect(applyDriver(basePlan(), "inflation", 3.5).inflationRateDefault).toBe(3.5);
|
||||
});
|
||||
|
||||
it("Rendite wird in Prozentpunkten verschoben, je Element und Phase", () => {
|
||||
const p = applyDriver(basePlan(), "returns", 2);
|
||||
const asset = p.elements.find((e) => e.id === "asset")!;
|
||||
expect(asset.phaseValues.p1.expectedReturn).toBe(6);
|
||||
expect(asset.phaseValues.p2.expectedReturn).toBe(6);
|
||||
// Einkommen/Ausgaben bleiben unberuehrt.
|
||||
expect(p.elements.find((e) => e.id === "inc")!.phaseValues.p1.amount).toBe(100000);
|
||||
});
|
||||
|
||||
it("Ausgaben werden relativ skaliert, Einkommen nicht", () => {
|
||||
const p = applyDriver(basePlan(), "expenses", 10);
|
||||
expect(p.elements.find((e) => e.id === "exp")!.phaseValues.p1.amount).toBeCloseTo(77000, 6);
|
||||
expect(p.elements.find((e) => e.id === "exp")!.phaseValues.p2.amount).toBeCloseTo(77000, 6);
|
||||
expect(p.elements.find((e) => e.id === "inc")!.phaseValues.p1.amount).toBe(100000);
|
||||
});
|
||||
|
||||
it("Lebensdauer verschiebt nur die LETZTE Phase und bleibt bei mindestens 1 Jahr", () => {
|
||||
const longer = applyDriver(basePlan(), "lifespan", 5);
|
||||
expect(longer.phases[0].durationYears).toBe(20);
|
||||
expect(longer.phases[1].durationYears).toBe(25);
|
||||
// Kappung nach unten: -100 Jahre darf keine Phase mit 0 oder negativer Dauer erzeugen.
|
||||
expect(applyDriver(basePlan(), "lifespan", -100).phases[1].durationYears).toBe(1);
|
||||
});
|
||||
|
||||
it("Lohnentwicklung verschiebt den Teuerungsausgleich des Einkommens", () => {
|
||||
const p = applyDriver(basePlan(), "salaryGrowth", 1);
|
||||
expect(p.elements.find((e) => e.id === "inc")!.phaseValues.p1.teuerungsausgleich).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sensitivitaet: Treiber-Verfuegbarkeit", () => {
|
||||
it("blendet Treiber aus, fuer die es keine passenden Elemente gibt", () => {
|
||||
const p = basePlan();
|
||||
expect(driverById("propertyGrowth").applies(p)).toBe(false); // keine Immobilie
|
||||
expect(driverById("returns").applies(p)).toBe(true);
|
||||
expect(driverById("inflation").applies(p)).toBe(true);
|
||||
|
||||
const ohneEinkommen: PlanInput = { ...p, elements: p.elements.filter((e) => e.category !== "INCOME") };
|
||||
expect(driverById("income").applies(ohneEinkommen)).toBe(false);
|
||||
expect(driverById("salaryGrowth").applies(ohneEinkommen)).toBe(false);
|
||||
});
|
||||
|
||||
it("jeder Treiber ist genau einmal definiert", () => {
|
||||
expect(new Set(DRIVERS.map((d) => d.id)).size).toBe(DRIVERS.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sensitivitaet: Tornado", () => {
|
||||
it("Basiswert entspricht dem unveraenderten Plan", () => {
|
||||
const p = basePlan();
|
||||
const result = computeTornado(p, "real", [{ id: "inflation", low: 0.5, high: 3.5 }]);
|
||||
const last = computePlan(p).phases.at(-1)!;
|
||||
expect(result.base).toBe(Math.round(last.endWealthReal));
|
||||
expect(planMetric(p, "nominal")).toBe(last.endWealthNominal);
|
||||
});
|
||||
|
||||
it("sortiert nach Spannweite absteigend (Trichterform)", () => {
|
||||
const result = computeTornado(basePlan(), "real", [
|
||||
{ id: "salaryGrowth", low: 0, high: 0 }, // ohne Bandbreite -> keine Wirkung
|
||||
{ id: "expenses", low: -15, high: 15 },
|
||||
{ id: "returns", low: -2, high: 2 },
|
||||
]);
|
||||
const swings = result.bars.map((b) => b.swing);
|
||||
expect([...swings].sort((a, b) => b - a)).toEqual(swings);
|
||||
// Ein Treiber ohne Bandbreite kann nichts bewegen und landet zuunterst.
|
||||
expect(result.bars.at(-1)!.id).toBe("salaryGrowth");
|
||||
expect(result.bars.at(-1)!.swing).toBe(0);
|
||||
// Welcher Treiber oben steht, haengt vom konkreten Plan ab -- genau das ist die Aussage
|
||||
// des Tornados und deshalb bewusst nicht fix getestet.
|
||||
});
|
||||
|
||||
it("kehrt die Richtung korrekt ab: hoehere Ausgaben -> tieferes Endvermoegen", () => {
|
||||
const [bar] = computeTornado(basePlan(), "real", [{ id: "expenses", low: -15, high: 15 }]).bars;
|
||||
expect(bar.lowResult).toBeGreaterThan(bar.highResult); // tiefe Ausgaben = mehr Vermoegen
|
||||
expect(bar.min).toBe(bar.highResult);
|
||||
expect(bar.max).toBe(bar.lowResult);
|
||||
expect(bar.swing).toBe(bar.max - bar.min);
|
||||
});
|
||||
|
||||
it("hoehere Rendite -> hoeheres Endvermoegen", () => {
|
||||
const [bar] = computeTornado(basePlan(), "real", [{ id: "returns", low: -2, high: 2 }]).bars;
|
||||
expect(bar.highResult).toBeGreaterThan(bar.lowResult);
|
||||
});
|
||||
|
||||
it("identische Bandbreite ergibt Spannweite 0", () => {
|
||||
const [bar] = computeTornado(basePlan(), "real", [{ id: "inflation", low: 2, high: 2 }]).bars;
|
||||
expect(bar.swing).toBe(0);
|
||||
});
|
||||
|
||||
it("real und nominal unterscheiden sich um den Deflator", () => {
|
||||
const p = basePlan();
|
||||
const real = planMetric(p, "real");
|
||||
const nominal = planMetric(p, "nominal");
|
||||
const last = computePlan(p).phases.at(-1)!;
|
||||
expect(nominal).toBeGreaterThan(real); // 1.5 % Inflation ueber 40 Jahre
|
||||
expect(real).toBe(Math.round(nominal / last.cumulativeInflationEnd));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
// Sensitivitaetsanalyse / Tornado (Roadmap Nr. 20). Beantwortet nicht "wie viel Geld habe
|
||||
// ich am Schluss", sondern "welche meiner Annahmen entscheidet ueberhaupt ueber das Ergebnis".
|
||||
//
|
||||
// Verfahren: One-at-a-time (OAT). Je Treiber wird EIN Parameter auf seinen tiefen und seinen
|
||||
// hohen Wert gesetzt, alle uebrigen bleiben auf dem Planwert; die Differenz der beiden
|
||||
// Ergebnisse ist die Spannweite. Nach Spannweite sortiert ergibt sich die Trichterform.
|
||||
//
|
||||
// Zwei bewusste Grenzen (im Dialog ausgewiesen, siehe SPEZIFIKATION 9.18):
|
||||
// 1. Die Balkenlaenge haengt von den eingegebenen Bandbreiten ab -- deshalb gibt es hier
|
||||
// KEINE Defaults, die Bandbreite ist je Treiber Pflichteingabe.
|
||||
// 2. OAT sieht keine Wechselwirkungen (tiefe Rendite UND hohe Ausgaben treffen haerter als
|
||||
// die Summe der Einzelbalken). Dafuer ist die Monte-Carlo-Simulation zustaendig.
|
||||
//
|
||||
// Laeuft wie die Monte-Carlo-Simulation vollstaendig im Browser: computePlan ist rein, und
|
||||
// ein Tornado braucht nur 2 Aufrufe je Treiber (Millisekunden).
|
||||
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { num } from "@/lib/elements";
|
||||
import type { ElementCategory, PhaseData } from "@/lib/elements";
|
||||
import type { ElementInput, PlanInput } from "@/lib/types";
|
||||
|
||||
export type DriverId =
|
||||
| "inflation"
|
||||
| "returns"
|
||||
| "expenses"
|
||||
| "income"
|
||||
| "salaryGrowth"
|
||||
| "lifespan"
|
||||
| "propertyGrowth";
|
||||
|
||||
// Die Einheit bestimmt, WAS der eingegebene Wert bedeutet -- das ist je Treiber verschieden
|
||||
// und laesst sich nicht vereinheitlichen, ohne fachlich falsch zu werden:
|
||||
// abs_pct absoluter Prozentsatz (es gibt genau einen plan-weiten Wert)
|
||||
// delta_pp Verschiebung in Prozentpunkten (die Elemente haben je eigene Saetze -- ein
|
||||
// absoluter Wert wuerde die PK auf ETF-Rendite plaetten)
|
||||
// rel_pct relative Abweichung in Prozent (die Elemente haben je eigene Betraege)
|
||||
// delta_years Verschiebung in Jahren
|
||||
export type DriverUnit = "abs_pct" | "delta_pp" | "rel_pct" | "delta_years";
|
||||
|
||||
export interface DriverDef {
|
||||
id: DriverId;
|
||||
label: string;
|
||||
shortLabel: string; // Achsenbeschriftung im Tornado (kurz genug fuer die y-Achse)
|
||||
unit: DriverUnit;
|
||||
help: string;
|
||||
applies: (plan: PlanInput) => boolean;
|
||||
}
|
||||
|
||||
const RETURN_CATEGORIES: ElementCategory[] = ["PENSION_FUND", "PILLAR_3A", "OTHER_ASSET"];
|
||||
|
||||
function hasCategory(plan: PlanInput, categories: ElementCategory[]): boolean {
|
||||
return plan.elements.some((e) => categories.includes(e.category));
|
||||
}
|
||||
|
||||
export const DRIVERS: DriverDef[] = [
|
||||
{
|
||||
id: "expenses",
|
||||
label: "Ausgaben",
|
||||
shortLabel: "Ausgaben",
|
||||
unit: "rel_pct",
|
||||
help:
|
||||
"Prozentuale Abweichung aller Ausgaben-Elemente vom Planwert. Sinnvolle Bandbreite: −10 bis +15 %, wenn die Ausgaben aus echten Kontodaten stammen; −20 bis +30 %, wenn sie geschätzt sind. Erfahrungsgemäss der stärkste Hebel – und einer, den man selbst steuern kann.",
|
||||
applies: (p) => hasCategory(p, ["EXPENSE"]),
|
||||
},
|
||||
{
|
||||
id: "returns",
|
||||
label: "Rendite der Anlagen (PK, 3a, Sonstiges Vermögen)",
|
||||
shortLabel: "Rendite",
|
||||
unit: "delta_pp",
|
||||
help:
|
||||
"Verschiebung der erwarteten Rendite in Prozentpunkten, auf alle Anlagen gleichzeitig. Sinnvolle Bandbreite: −2 bis +2 Prozentpunkte für ein gemischtes Portfolio, −3 bis +3 bei hohem Aktienanteil. Die Immobilien-Wertsteigerung hat einen eigenen Treiber.",
|
||||
applies: (p) => hasCategory(p, RETURN_CATEGORIES),
|
||||
},
|
||||
{
|
||||
id: "lifespan",
|
||||
label: "Lebensdauer (Dauer der letzten Phase)",
|
||||
shortLabel: "Lebensdauer",
|
||||
unit: "delta_years",
|
||||
help:
|
||||
"Verlängert bzw. verkürzt die letzte Lebensphase. Sinnvolle Bandbreite: −5 bis +10 Jahre – die Restlebenserwartung streut stark, und Langlebigkeit ist das eigentliche Planungsrisiko (das Geld muss länger reichen).",
|
||||
applies: (p) => p.phases.length > 0,
|
||||
},
|
||||
{
|
||||
id: "inflation",
|
||||
label: "Inflation",
|
||||
shortLabel: "Inflation",
|
||||
unit: "abs_pct",
|
||||
help:
|
||||
"Absolute Inflationsrate (nicht Abweichung). Sinnvolle Bandbreite für die Schweiz: 0.5 bis 3.5 % – der langjährige Schnitt liegt bei rund 1 bis 2 %, einzelne Jahre lagen deutlich darüber.",
|
||||
applies: () => true,
|
||||
},
|
||||
{
|
||||
id: "income",
|
||||
label: "Einkommen",
|
||||
shortLabel: "Einkommen",
|
||||
unit: "rel_pct",
|
||||
help:
|
||||
"Prozentuale Abweichung aller Einkommens-Elemente (netto) vom Planwert. Sinnvolle Bandbreite: −10 bis +10 % bei sicherer Anstellung, −30 bis +20 % bei selbständiger oder variabler Tätigkeit.",
|
||||
applies: (p) => hasCategory(p, ["INCOME"]),
|
||||
},
|
||||
{
|
||||
id: "salaryGrowth",
|
||||
label: "Lohnentwicklung",
|
||||
shortLabel: "Lohnentwicklung",
|
||||
unit: "delta_pp",
|
||||
help:
|
||||
"Verschiebung der jährlichen nominalen Lohnerhöhung in Prozentpunkten. Sinnvolle Bandbreite: −1 bis +1 Prozentpunkt. Wirkt nur über die verbleibenden Erwerbsjahre und ist deshalb meist ein schwacher Hebel.",
|
||||
applies: (p) => hasCategory(p, ["INCOME"]),
|
||||
},
|
||||
{
|
||||
id: "propertyGrowth",
|
||||
label: "Wertsteigerung der Immobilie",
|
||||
shortLabel: "Immo-Wertsteigerung",
|
||||
unit: "delta_pp",
|
||||
help:
|
||||
"Verschiebung der jährlichen Wertsteigerung in Prozentpunkten. Sinnvolle Bandbreite: −2 bis +2 Prozentpunkte. Wirkt auf den Wert der Liegenschaft und damit gehebelt auf das Eigenkapital.",
|
||||
applies: (p) => hasCategory(p, ["REAL_ESTATE"]),
|
||||
},
|
||||
];
|
||||
|
||||
// Einheiten-Suffix fuer die Eingabefelder und die Ergebnistabelle.
|
||||
export const UNIT_SUFFIX: Record<DriverUnit, string> = {
|
||||
abs_pct: "%",
|
||||
delta_pp: "pp",
|
||||
rel_pct: "%",
|
||||
delta_years: "J.",
|
||||
};
|
||||
|
||||
export function driverById(id: DriverId): DriverDef {
|
||||
const d = DRIVERS.find((x) => x.id === id);
|
||||
if (!d) throw new Error(`Unbekannter Treiber: ${id}`);
|
||||
return d;
|
||||
}
|
||||
|
||||
// --- Anwenden eines Treiber-Wertes auf einen Plan -------------------------------------
|
||||
// Alle Transformationen sind rein: sie liefern eine Kopie und lassen das Original unberuehrt.
|
||||
|
||||
function mapPhaseData(element: ElementInput, f: (pd: PhaseData) => PhaseData): ElementInput {
|
||||
const phaseValues: Record<string, PhaseData> = {};
|
||||
for (const [phaseId, pd] of Object.entries(element.phaseValues)) phaseValues[phaseId] = f(pd);
|
||||
return { ...element, phaseValues };
|
||||
}
|
||||
|
||||
// Bildet die Elemente der gegebenen Kategorien ab; alle uebrigen bleiben unveraendert.
|
||||
function mapElements(
|
||||
plan: PlanInput,
|
||||
categories: ElementCategory[],
|
||||
f: (pd: PhaseData) => PhaseData
|
||||
): PlanInput {
|
||||
return {
|
||||
...plan,
|
||||
elements: plan.elements.map((e) => (categories.includes(e.category) ? mapPhaseData(e, f) : e)),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyDriver(plan: PlanInput, id: DriverId, value: number): PlanInput {
|
||||
switch (id) {
|
||||
case "inflation":
|
||||
return { ...plan, inflationRateDefault: value };
|
||||
|
||||
case "returns":
|
||||
// Verschiebung in Prozentpunkten auf die geplante Rendite. Nur dort, wo ueberhaupt ein
|
||||
// Werte-Datensatz existiert -- fehlt er, rechnet computePlan ohnehin mit 0 %.
|
||||
return mapElements(plan, RETURN_CATEGORIES, (pd) => ({
|
||||
...pd,
|
||||
expectedReturn: num(pd.expectedReturn) + value,
|
||||
}));
|
||||
|
||||
case "propertyGrowth":
|
||||
return mapElements(plan, ["REAL_ESTATE"], (pd) => ({
|
||||
...pd,
|
||||
valueGrowth: num(pd.valueGrowth) + value,
|
||||
}));
|
||||
|
||||
case "expenses":
|
||||
case "income": {
|
||||
// Relative Skalierung des Basisbetrags. Bewusst nur dort, wo `amount` gesetzt ist:
|
||||
// ab Phase 2 ist der Wert in der Regel live vererbt (kein gespeicherter Betrag), und
|
||||
// die Fortschreibung leitet ihn aus dem skalierten Basiswert ab -- dadurch wirkt die
|
||||
// Skalierung automatisch ueber alle Folgephasen.
|
||||
const factor = 1 + value / 100;
|
||||
return mapElements(plan, [id === "expenses" ? "EXPENSE" : "INCOME"], (pd) =>
|
||||
typeof pd.amount === "number" ? { ...pd, amount: Math.max(0, pd.amount * factor) } : pd
|
||||
);
|
||||
}
|
||||
|
||||
case "salaryGrowth":
|
||||
return mapElements(plan, ["INCOME"], (pd) => ({
|
||||
...pd,
|
||||
teuerungsausgleich: num(pd.teuerungsausgleich, 0) + value,
|
||||
}));
|
||||
|
||||
case "lifespan": {
|
||||
// Verlaengert/verkuerzt die LETZTE Phase. Bewusst nicht das Pensionsalter: das laesst
|
||||
// sich ohne Mitverschieben der Phasengrenzen nicht sinnvoll variieren (siehe 9.18).
|
||||
if (plan.phases.length === 0) return plan;
|
||||
const lastSeq = Math.max(...plan.phases.map((p) => p.sequenceNumber));
|
||||
return {
|
||||
...plan,
|
||||
phases: plan.phases.map((p) =>
|
||||
p.sequenceNumber === lastSeq
|
||||
? { ...p, durationYears: Math.max(1, Math.round(p.durationYears + value)) }
|
||||
: p
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tornado ---------------------------------------------------------------------------
|
||||
|
||||
export type TornadoMetric = "real" | "nominal";
|
||||
|
||||
export interface TornadoInput {
|
||||
id: DriverId;
|
||||
low: number;
|
||||
high: number;
|
||||
}
|
||||
|
||||
export interface TornadoBar {
|
||||
id: DriverId;
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
unit: DriverUnit;
|
||||
low: number; // eingegebene Bandbreite
|
||||
high: number;
|
||||
lowResult: number; // Zielgroesse beim tiefen Wert
|
||||
highResult: number; // Zielgroesse beim hohen Wert
|
||||
min: number; // fuer den Balken: kleinerer der beiden Ergebniswerte
|
||||
max: number;
|
||||
swing: number; // max - min
|
||||
}
|
||||
|
||||
export interface TornadoResult {
|
||||
base: number;
|
||||
bars: TornadoBar[];
|
||||
}
|
||||
|
||||
// Zielgroesse: Endvermoegen der letzten Phase, real (kaufkraftbereinigt) oder nominal.
|
||||
export function planMetric(plan: PlanInput, metric: TornadoMetric): number {
|
||||
const computed = computePlan(plan);
|
||||
const last = computed.phases[computed.phases.length - 1];
|
||||
if (!last) return 0;
|
||||
return Math.round(metric === "real" ? last.endWealthReal : last.endWealthNominal);
|
||||
}
|
||||
|
||||
export function computeTornado(
|
||||
plan: PlanInput,
|
||||
metric: TornadoMetric,
|
||||
inputs: TornadoInput[]
|
||||
): TornadoResult {
|
||||
const base = planMetric(plan, metric);
|
||||
|
||||
const bars: TornadoBar[] = inputs.map((input) => {
|
||||
const def = driverById(input.id);
|
||||
const lowResult = planMetric(applyDriver(plan, input.id, input.low), metric);
|
||||
const highResult = planMetric(applyDriver(plan, input.id, input.high), metric);
|
||||
// Die Richtung kann sich umkehren (tiefe Ausgaben -> hohes Vermoegen). Der Balken spannt
|
||||
// deshalb ueber min..max; welche Eingabe zu welchem Ende gehoert, zeigt die Tabelle.
|
||||
return {
|
||||
id: input.id,
|
||||
label: def.label,
|
||||
shortLabel: def.shortLabel,
|
||||
unit: def.unit,
|
||||
low: input.low,
|
||||
high: input.high,
|
||||
lowResult,
|
||||
highResult,
|
||||
min: Math.min(lowResult, highResult),
|
||||
max: Math.max(lowResult, highResult),
|
||||
swing: Math.abs(highResult - lowResult),
|
||||
};
|
||||
});
|
||||
|
||||
// Trichterform: groesste Spannweite zuoberst.
|
||||
bars.sort((a, b) => b.swing - a.swing);
|
||||
return { base, bars };
|
||||
}
|
||||
Reference in New Issue
Block a user