Monte-Carlo-Simulation (Roadmap Nr. 19, Stufe A)
Deploy App / deploy (push) Successful in 57s

Button in der Planansicht -> Dialog: Erklaerung, Eingaben, Lauf, Ergebnis + Faecher.
Statt einer festen Rendite/Inflation werden tausende Zufallspfade gerechnet und die
Erfolgs-/Ruinwahrscheinlichkeit des Plans ausgewiesen.

Kern (computePlan-Nahtstelle):
- computePlan(plan, sample?) nimmt optional pro Jahr Inflation und pro Element/Jahr
  eine Rendite. Ohne sample bitgenau wie bisher (durch Golden Tests abgesichert).
- Dazu Inflation auf ein kumulatives Deflator-Array umgestellt (statt (1+i)^t), damit
  sie pro Jahr variieren kann. Deterministisch identisch.
- Reine Funktion ohne Server-Deps -> Simulation laeuft komplett im Browser, null
  Serverlast. ~10'000 Laeufe in ~1 s, Fortschritt alle 500 Laeufe (kein Freeze).

Statistik (montecarlo.ts):
- Fettschwaenzig (standardisierte Student-t, nu=5): Extremcrashs realistisch haeufig,
  eine Normalverteilung wuerde sie stark unterschaetzen.
- Gemeinsamer Marktschock (rho=0.7): riskante Anlagen fallen im Crash zusammen,
  nicht gegeneinander.
- Boeden: 0 % fuer PK/3a, -100 % sonst. Seedbar (reproduzierbar).
- Zwei Renditezahlen: geplante (Zielbalken) vs. historische (Streu-Mittelpunkt) --
  sonst waere P(>= geplantes Endvermoegen) immer ~50 %.

UI: Erklaerung, pro Element/Inflation historischer Oe (Pflicht, kein Default) +
Streuungsstufe (recherchierte sigma-Werte) + Anzahl Laeufe. Ergebnis: Ruin-/
Erfolgswahrscheinlichkeit, Faecher (10/50/90) + deterministische Linie.

7 MC-Tests inkl. deterministischer Aequivalenz, Vol-Drag, Reproduzierbarkeit, Boden,
Ruin (41 -> 48). Keine DB-Aenderung. Spezifikation auf v0.7 (Kap. 4.12, 9.15).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 22:47:18 +02:00
parent 5bf35bd6da
commit 71137f7cea
6 changed files with 922 additions and 17 deletions
+129 -7
View File
@@ -4,10 +4,10 @@
| | |
|---|---|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
| **Version** | 0.6 |
| **Version** | 0.7 |
| **Datum** | 2026-07-17 |
| **Status** | Lebendes Dokument |
| **Codestand** | Arbeitsstand nach `a97de5b` inkl. Teilverkauf (Sonstiges Vermögen) und Sonderamortisation (Immobilie) (Branch `main`) |
| **Codestand** | Arbeitsstand nach `5bf35bd` inkl. Monte-Carlo-Simulation (Stufe A) (Branch `main`) |
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1v5) im Ordner `Info Dateien` diese sind ab Version 0.1 dieses Dokuments obsolet |
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
@@ -17,6 +17,7 @@
| Version | Datum | Autor | Änderung |
|---|---|---|---|
| 0.7 | 2026-07-17 | Claude (Opus 4.8) | **Monte-Carlo-Simulation** (Roadmap Nr. 19, Stufe A). Button in der Planansicht → Dialog mit Erklärung, Eingaben und Ergebnis. Statt einer festen Rendite/Inflation werden tausende Zufallspfade gerechnet; ausgewiesen werden **Ruinwahrscheinlichkeit**, **Erfolgswahrscheinlichkeit** (P(Endvermögen ≥ Zielbetrag)) und ein **Fächer** (10 %/Median/90 %) plus die deterministische Linie. Läuft komplett im Browser (`computePlan` ist rein). Modell: fettschwänzige Verteilung (Student-t, ν=5), gemeinsamer Marktschock (ρ=0.7), Böden 0 % für PK/3a und 100 % sonst. Pro renditetragendem Element und für die Inflation je: historischer Ø (Pflicht), Streuungsstufe, σ. Neue Datei `montecarlo.ts` + optionaler `sample`-Parameter in `computePlan` (Inflation neu als kumulatives Array; deterministisch identisch). Neues Kapitel 4.12; 7 MC-Tests + Refactor-Absicherung (41 → 48). Keine Verhaltensänderung, keine DB-Änderung. |
| 0.6 | 2026-07-17 | Claude (Opus 4.8) | **Teilverkauf** von Sonstigem Vermögen (Roadmap Nr. 42) und **Sonderamortisation** der Hypothek (Roadmap Nr. 15). `OTHER_ASSET` am Übergang neu: Halten / Verkaufen / **Teilverkauf** ein Betrag fliesst ins Cash (erscheint als „Kapitalzufluss" im Phasenkopf), der Rest bleibt investiert. `REAL_ESTATE` im Halten-Fall neu mit **Einmaltilgung** aus dem Cash (analog zur Sofort-Tilgung bei Schulden; erscheint als „Kapitalinvestition"). Damit lässt sich die indirekte Amortisation via 3a mechanisch nachbilden. Die eigentliche Roadmap Nr. 15 (Steuerwirkung der indirekten Amortisation) bleibt mit dem Steuer-Bündel 13/14/23 zurückgestellt siehe 9.14. Kapitel 4.9.3/4.9.4 ergänzt. Fünf Regressionstests (36 → 41). Keine Verhaltensänderung für bestehende Pläne. |
| 0.5 | 2026-07-17 | Claude (Opus 4.8) | **Netto/Brutto geklärt** (Roadmap Nr. 9, reduziert) und **Immobilien-Modul erweitert** (Roadmap Nr. 8). Einkommen ist neu explizit als **Nettolohn** definiert (Label und Hilfetext); für die AHV rechnet das Tool intern mit `AHV_GROSS_FROM_NET_FACTOR = 1.12` auf den Bruttolohn hoch die AHV bemisst sich am Brutto, die bisherige Netto-Basis unterschätzte die Rente um bis zu ~1'900/Jahr. Immobilie neu mit **Hypothekarzins** (% der Restschuld, sinkt mit der Amortisation, mit Doppelzählungs-Schalter) und **Wertsteigerung** (auf die **Liegenschaft**, nicht auf das Eigenkapital Hebeleffekt). Grundstückgewinnsteuer bemisst sich neu explizit am ursprünglichen Kaufpreis. Keine Aufschlüsselung bei Einkommen oder Ausgaben, keine Steuerschätzung (Begründung: 9.14). Neues Kapitel 4.4.5; 4.6.5 und 4.9.4 überarbeitet; Abschnitt 9 um zwei Punkte ergänzt. Sechs Regressionstests (30 → 36). **Verhaltensänderung:** siehe 9.13. |
| 0.4 | 2026-07-17 | Claude (Opus 4.8) | **AHV-Rente einkommensabhängig** (Roadmap Nr. 3) und **Fortschreibungs-Warnhinweis** (Roadmap Nr. 4). Die AHV-Rente folgt neu der amtlichen Rentenformel (Skala 44) über das massgebende durchschnittliche Jahreseinkommen statt pauschal der Maximalrente; verifiziert gegen die amtliche Tabelle 318.117.1 (51/51 Zeilen). Prüfung der Beitragskarriere am Pensions-Übergang, mit Zusatzfeldern für die Jahre vor Planbeginn (ab Alter 21). Alles **real** gerechnet. Neue Konstanten `AHV_MIN_MONTHLY_FULL`, `AHV_PENSION_MONTHS`, `AHV_CONTRIBUTION_START_AGE`; `AHV_MAX_ANNUAL_SINGLE` neu abgeleitet. Warnhinweis in Phasenzellen und Phasen-Detail, wenn Folgephasen existieren. Neue Kapitel 3.5.6, 4.4; Abschnitt 9 um zwei Punkte ergänzt. Zwölf Regressionstests (18 → 30). **Verhaltensänderung:** siehe 9.10. |
@@ -1418,6 +1419,106 @@ Die drei Default-Sätze werden **sowohl als UI-Vorschlag als auch in der Berechn
verwendet (`num(td.capitalTaxRate, DEFAULT_CAPITAL_TAX_RATE)`). Grund laut Code-Kommentar: Damit
ein nicht angetippter Wert nicht fälschlich als 0 gerechnet wird.
## 4.12 Monte-Carlo-Simulation
Die deterministische Berechnung nimmt pro Anlage *eine* feste Rendite und *eine* feste Inflation
an. Real schwanken beide. Die Monte-Carlo-Simulation (Roadmap Nr. 19, Stufe A, `montecarlo.ts`)
würfelt viele tausend mögliche Verläufe und weist die **Erfolgswahrscheinlichkeit** des Plans aus.
### 4.12.1 Die Nahtstelle in `computePlan`
`computePlan(plan, sample?)` nimmt optional ein `PlanSample`:
```ts
interface PlanSample {
inflation: number[]; // Inflation %/Jahr (Index 0 = Jahr 1)
assetReturn: (elementId: string, year: number) => number; // Rendite %/Jahr (1-basiert)
}
```
Ohne `sample` rechnet die Funktion **exakt wie bisher** (die geplanten Annahmen). Mit `sample`
liefert sie einen einzelnen simulierten Pfad. Voraussetzung war eine Umstellung der Inflation
auf ein **kumulatives Deflator-Array** `cumInfl[]` (statt der geschlossenen `(1+i)^t`-Formel),
damit die Inflation pro Jahr variieren kann deterministisch bitgenau identisch, durch die
Golden Tests abgesichert. Die AHV-Karriere bleibt bewusst auf der festen Plan-Inflation (sie
ist eine Real-Grösse auf Planungsbasis, sie wird nicht mitgewürfelt).
**Architektur-Vorteil:** `computePlan` ist eine reine Funktion ohne Server-Abhängigkeiten und
läuft damit **im Browser**. Die gesamte Simulation rechnet client-seitig null Serverlast.
~10'000 Läufe in rund 1 Sekunde; die Ausführung gibt alle 500 Läufe die Kontrolle ab
(Fortschrittsbalken, keine eingefrorene Oberfläche).
### 4.12.2 Das statistische Modell
Pro Jahr ein **gemeinsamer Marktschock** `z_markt`; je Element und Jahr:
```
rendite = mittelwert + σ × (ρ × z_markt + √(1−ρ²) × z_eigen)
```
- `z_markt`, `z_eigen`: **standardisierte Student-t** (ν = 5) „fette Ränder", damit
Extremcrashs realistisch häufig auftreten. Eine Normalverteilung macht ein 40%-Jahr zu einem
1-in-250-Ereignis; real ist es ~1-in-15. Standardisiert auf Einheitsvarianz → die eingegebene
Standardabweichung σ bleibt die tatsächliche.
- `ρ = 0.7` → Korrelation zweier riskanter Anlagen ≈ 0.5: **alle riskanten Anlagen fallen im
Crash gemeinsam.** Unabhängiges Würfeln würde das Absturzrisiko systematisch unterschätzen.
- **Böden:** `max(0, rendite)` für PK/3a (schreiben keine negative Rendite gut),
`max(100, rendite)` sonst.
Die Inflation wird analog gezogen (eigener Student-t-Schock, Mittelwert + σ), unabhängig vom
Marktschock. Der Zufallsgenerator ist **seedbar** (reproduzierbare Läufe).
### 4.12.3 Zwei Renditezahlen — und warum
Pro renditetragendem Element (PK, 3a, Sonstiges Vermögen, Immobilie) gibt es im Simulations-Dialog
**zwei** Renditen mit verschiedenen Rollen:
| Zahl | Rolle |
|---|---|
| **Geplante Rendite** (im Plan) | zeichnet die deterministische Linie = der *Zielbalken* |
| **Historische Ø-Rendite** (im Dialog, Pflicht) | der Mittelpunkt, um den die Simulation streut |
Ohne diese Trennung wäre die Kennzahl „P(erreiche mein geplantes Endvermögen)" **immer ~50 %**,
egal welche Rendite man annimmt (der Zielbetrag wüchse ja mit). Erst weil die Simulation um die
*historische* Rendite streut, während der Zielbalken auf der *geplanten* steht, wird ein
konservativer Plan (tiefe Planannahme) korrekt mit einer höheren Erfolgsquote belohnt als ein
optimistischer. Analog auf Plan-Ebene für die Inflation.
**Ehrliche Grenze (im Dialog ausgewiesen):** Die Simulation misst das Risiko *um deine Annahmen
herum* sie beurteilt **nicht**, ob deine Mittelwerte realistisch sind. Ein zu optimistischer
Mittelwert liefert eine beruhigende, aber unrealistische Erfolgsquote (siehe 9.15).
### 4.12.4 Streuungsstufen
| Stufe (Rendite) | σ | Beispiele | | Stufe (Inflation) | σ |
|---|---|---|---|---|---|
| Sehr niedrig | 3 % | Staatsanleihen, Geldmarkt | | Sehr niedrig | 1 % |
| Niedrig | 6 % | Immobilien, defensive Mischportfolios | | Niedrig | 2 % |
| Moderat | 15 % | breit diversifizierte Aktien-ETFs/Fonds | | Manuell | frei |
| Hoch | 25 % | Einzelaktien, Branchen-/Schwellenländerfonds | | | |
| Sehr hoch | 55 % | Kryptowährungen, hochspekulative Anlagen | | | |
| Manuell | frei | eigene Eingabe | | | |
Default-Stufe je Typ: PK → sehr niedrig · 3a/Immobilie → niedrig · Sonstiges Vermögen → moderat.
Bei Inflation gibt es bewusst nur zwei Stufen (höhere wären Hyperinflations-Annahmen).
Quellen der σ-Werte: Anleihen ~6 %, globale Aktien ~1518 %, Schweizer Immobilien(fonds) ~2 %,
Bitcoin ~54 %, Schweizer Inflation SD der letzten 20 J. ~1 %. Belege: BSV/Weltbank sowie
Markt-/Volatilitätsstatistiken (recherchiert 2026-07-17).
### 4.12.5 Ergebnis
| Kennzahl | Bedeutung |
|---|---|
| **Ruinwahrscheinlichkeit** | Anteil der Läufe mit `ruinAge !== null` (Vermögen fällt vor Planende unter 0) |
| **Erfolgswahrscheinlichkeit** | Anteil der Läufe mit Endvermögen ≥ Zielbetrag (nominal, vorbelegt mit dem geplanten Nachlass) |
| **Fächer** | je Alterspunkt (Phasengrenzen) das 10-/50-/90-Perzentil des Vermögens; dazu die deterministische Planungslinie |
Der Median liegt typischerweise **unter** der deterministischen Linie der „Volatilitäts-Drag"
(`geometrisch ≈ arithmetisch σ²/2`) macht sichtbar, dass die glatte Ein-Zahl-Planung schon
leicht zu optimistisch ist. Pflichtfelder: ohne die historischen Ø-Werte startet die Simulation
nicht.
---
# 5. Technische Spezifikation
@@ -1489,6 +1590,7 @@ PlanComputed ← an den Client geliefert
| `elements.ts` | Kategorien, Labels, Reihenfolge, JSON-Payload-Typen, Zod-Schemas, `num()` |
| `types.ts` | Domänentypen für API und Berechnung |
| `constants.ts` | Schweizer Systemparameter |
| `montecarlo.ts` | Monte-Carlo-Simulation (Sampler + Treiber). Keine I/O, läuft im Browser. |
| `queries.ts` | Prisma-Includes, `toPlanInput()`, Ownership-Abfragen |
| `db.ts` | Prisma-Singleton (Global-Cache im Dev, verhindert Connection-Leak bei HMR) |
| `auth.ts` | JWT erzeugen/prüfen (Edge-kompatibel via `jose`) |
@@ -1712,6 +1814,7 @@ wird der Plan neu geladen; die Berechnung kommt immer vom Server.
| `PlanProfileFields` | 101 | Wiederverwendete Grundprofil-Felder |
| `PhaseDetail` | 95 | Phase bearbeiten/löschen |
| `SparquoteChart` | 64 | Einkommen/Ausgaben pro Jahr mit Spar-/Verzehrbändern |
| `MonteCarloDialog` | ~430 | Monte-Carlo-Dialog: Erklärung, Eingaben, Lauf, Ergebnis + Fächer |
| `InfoBubble` | 28 | Hilfe-Tooltip |
### 5.5.3 Wiederverwendungsmuster
@@ -1919,10 +2022,11 @@ Zielumgebung: Hetzner CX23, Traefik als Reverse Proxy, Domain `fpt.aicds.ch`, Gi
## 8.1 Teststrategie
Getestet wird ausschliesslich der Berechnungskern bewusst, da dort die Fachlogik und das
Regressionsrisiko liegen. `src/lib/calculations.test.ts` enthält 41 Tests (AHV-Rentenformel,
Immobilie, Teilverkauf, Sonderamortisation, AHV einkommensabhängig, „V5 Golden Tests"),
ausgeführt mit Vitest in der Node-Umgebung (`vitest.config.ts`, Include `src/**/*.test.ts`).
Es gibt **keine** Komponenten-, API- oder E2E-Tests.
Regressionsrisiko liegen. `src/lib/calculations.test.ts` (41 Tests: AHV-Rentenformel, Immobilie,
Teilverkauf, Sonderamortisation, AHV einkommensabhängig, „V5 Golden Tests") und
`src/lib/montecarlo.test.ts` (7 Tests) ergeben zusammen **48 Tests**, ausgeführt mit Vitest in
der Node-Umgebung (`vitest.config.ts`, Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-,
API- oder E2E-Tests.
## 8.2 Testfälle
@@ -1945,6 +2049,10 @@ Es gibt **keine** Komponenten-, API- oder E2E-Tests.
| **Immobilie: Verkauf** | Grundstückgewinnsteuer auf `Verkaufspreis Kaufpreis`, nicht auf den Verkehrswert |
| **Teilverkauf (42)** | Betrag ins Cash (→ `capitalInflow`), Rest bleibt aktiv; am Endwert gekappt; Halten/Vollverkauf unverändert |
| **Sonderamortisation (15)** | Einmaltilgung senkt Restschuld, belastet Cash (→ `capitalInvest`), am Restsaldo gekappt |
| **MC: Determinismus** | Streuung 0 reproduziert exakt das deterministische Ergebnis (Bänder kollabieren) |
| **MC: Volatilität / Vol-Drag** | σ > 0 spreizt p10<median<p90; Median unter dem deterministischen Wert |
| **MC: Erfolg / Reproduzierbarkeit** | P(≥ Ziel) fällt mit steigendem Ziel; gleicher Seed → identisches Ergebnis |
| **MC: Boden / Ruin** | 0%-Boden hält PK/3a ≥ Startwert; sicherer Verzehr → Ruinwahrscheinlichkeit 100 % |
| Test 1 Ansparen | Einkommen +2 % nominal, Ausgaben real flach: Endvermögen 761'654 nominal / 565'928 real (±1 %), kein negatives Cash |
| Test 2 Verzehr/Ruin | Rente nominal fix 60k, Ausgaben real 100k, Vermögen 900k @3 %: `ruinAge === 94` |
| Test 3 Cash-Ausgleich | Sparrate 6'364: `cashEnd === 5472`, nie negativ |
@@ -2125,7 +2233,21 @@ hinterfragt. Der Benutzer kennt seine Steuerrechnung dagegen exakt aus der letzt
**Heutiger Weg:** ein normales Ausgaben-Element „Steuern" dafür braucht es kein neues Feld.
Echte Steuerlogik ist Roadmap Nr. 23, mit Kanton und Gemeinde als Eingabe.
## 9.15 Kleinere Beobachtungen
## 9.15 Monte-Carlo misst Risiko um die Annahmen, nicht deren Richtigkeit
Die Simulation streut um die eingegebenen Mittelwerte (historische Rendite/Inflation). Sie
beurteilt **nicht**, ob diese Mittelwerte realistisch sind. Ein zu optimistischer Mittelwert
liefert eine beruhigende, aber unrealistische Erfolgsquote die präzise Prozentzahl täuscht
dann Sicherheit vor. Der Dialog weist das explizit aus; die Mittelwerte sind Pflichtfelder ohne
Default, damit sie bewusst gesetzt werden.
Weitere bewusste Vereinfachungen der Stufe A: Inflation und Renditen werden **unabhängig**
gezogen (real sind sie negativ korreliert); die Normalverteilungs-Alternative wird gar nicht
angeboten (fette Ränder fest eingebaut); die Simulationsparameter werden **nicht persistiert**
(ephemer im Dialog). Ein historischer Backtest (Stufe B) und korrelierte/vollständigere Modelle
(Stufe C) sind offen.
## 9.16 Kleinere Beobachtungen
- `planToCsv(plan, computed)` erhält den `plan`-Parameter, verwendet ihn aber nicht.
- Der Typ `Selection` in `PlanView` hat nur eine Variante (`{ type: "phase" }`) ein Rest der
+420
View File
@@ -0,0 +1,420 @@
"use client";
import { useMemo, useState } from "react";
import { Area, CartesianGrid, ComposedChart, Legend, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { Dices, X } from "lucide-react";
import { NumberField, SelectField, MoneyField } from "@/components/FormField";
import { InfoBubble } from "@/components/InfoBubble";
import { formatChf } from "@/lib/format";
import {
runMonteCarlo,
defaultVolatilityLevel,
RETURN_VOLATILITY_LEVELS,
INFLATION_VOLATILITY_LEVELS,
RETURN_BEARING,
floorFor,
type ReturnVolatilityLevel,
type InflationVolatilityLevel,
type MonteCarloResult,
} from "@/lib/montecarlo";
import { CATEGORY_LABELS } from "@/lib/elements";
import type { PlanInput } from "@/lib/types";
import type { PlanComputed } from "@/lib/calculations";
const RETURN_LEVEL_OPTIONS: { value: ReturnVolatilityLevel; label: string }[] = [
{ value: "sehr_niedrig", label: "Sehr niedrig" },
{ value: "niedrig", label: "Niedrig" },
{ value: "moderat", label: "Moderat" },
{ value: "hoch", label: "Hoch" },
{ value: "sehr_hoch", label: "Sehr hoch" },
{ value: "manuell", label: "Manuell" },
];
const INFLATION_LEVEL_OPTIONS: { value: InflationVolatilityLevel; label: string }[] = [
{ value: "sehr_niedrig", label: "Sehr niedrig" },
{ value: "niedrig", label: "Niedrig" },
{ value: "manuell", label: "Manuell" },
];
const RETURN_HELP =
"Wie stark die Jahresrendite schwankt. Sehr niedrig ≈ Staatsanleihen/Geldmarkt · Niedrig ≈ Immobilien, defensive Mischportfolios · Moderat ≈ breit diversifizierte Aktien-ETFs/Fonds · Hoch ≈ Einzelaktien, Branchen-/Schwellenländerfonds · Sehr hoch ≈ Kryptowährungen, hochspekulative Anlagen.";
const INFLATION_HELP =
"Wie stark die Inflation schwankt. Für die Schweiz ist sie historisch sehr stabil (Sehr niedrig ≈ 1 %). Höhere Stufen wären Hyperinflations-Annahmen.";
// Pflicht-Zahlenfeld, das wirklich leer sein kann (NumberField erzwingt eine Zahl).
function MeanField({
label,
help,
value,
onChange,
}: {
label: string;
help: string;
value: string;
onChange: (v: string) => void;
}) {
const empty = value.trim() === "";
return (
<div>
<label className="mb-1 flex items-center text-xs font-medium text-muted">
{label}
<InfoBubble text={help} />
</label>
<input
type="number"
step={0.1}
value={value}
placeholder="Pflicht"
onChange={(e) => onChange(e.target.value)}
className={`w-full rounded-lg border bg-input px-2.5 py-1.5 text-sm text-fg shadow-sm focus:outline-none focus:ring-2 focus:ring-accent/25 ${
empty ? "border-danger" : "border-border focus:border-accent"
}`}
/>
{empty && <p className="mt-1 text-[11px] text-danger">Pflichtfeld bitte ausfüllen.</p>}
</div>
);
}
type ElementInput = { mean: string; level: ReturnVolatilityLevel; manualSigma: string };
function returnSigma(el: ElementInput): number {
return el.level === "manuell"
? Number(el.manualSigma) || 0
: RETURN_VOLATILITY_LEVELS[el.level];
}
function inflationSigma(level: InflationVolatilityLevel, manual: string): number {
return level === "manuell" ? Number(manual) || 0 : INFLATION_VOLATILITY_LEVELS[level];
}
export function MonteCarloDialog({
plan,
computed,
onClose,
}: {
plan: PlanInput;
computed: PlanComputed;
onClose: () => void;
}) {
const returnElements = useMemo(
() => plan.elements.filter((e) => RETURN_BEARING.includes(e.category)),
[plan.elements]
);
const [runs, setRuns] = useState(1000);
const [inflMean, setInflMean] = useState("");
const [inflLevel, setInflLevel] = useState<InflationVolatilityLevel>("sehr_niedrig");
const [inflManual, setInflManual] = useState("1");
const [target, setTarget] = useState(Math.max(0, computed.nachlass));
const [els, setEls] = useState<Record<string, ElementInput>>(() =>
Object.fromEntries(
returnElements.map((e) => [e.id, { mean: "", level: defaultVolatilityLevel(e.category), manualSigma: "10" }])
)
);
const [running, setRunning] = useState(false);
const [progress, setProgress] = useState(0);
const [result, setResult] = useState<MonteCarloResult | null>(null);
function setEl(id: string, patch: Partial<ElementInput>) {
setEls((prev) => ({ ...prev, [id]: { ...prev[id], ...patch } }));
}
// Pflichtfelder: historische Inflation + je Element die historische Rendite muessen gesetzt sein.
const missing =
inflMean.trim() === "" || returnElements.some((e) => (els[e.id]?.mean ?? "").trim() === "");
// Deterministische Endwert-Linie fuer den Vergleich im Faecher.
const detPoints = useMemo(() => {
const startAge = plan.persons.find((p) => p.role === "PERSON_A")?.age ?? plan.persons[0]?.age ?? 0;
const pts: { age: number; det: number }[] = [];
if (computed.phases.length > 0) {
pts.push({ age: startAge, det: computed.phases[0].startWealthNominal });
let acc = 0;
for (const ph of computed.phases) {
acc += ph.durationYears;
pts.push({ age: startAge + acc, det: ph.endWealthNominal });
}
}
return pts;
}, [computed.phases, plan.persons]);
const estSeconds = Math.max(1, Math.round(runs / 6000));
async function run() {
setRunning(true);
setResult(null);
setProgress(0);
try {
const elements = Object.fromEntries(
returnElements.map((e) => {
const ei = els[e.id];
return [e.id, { mean: Number(ei.mean) || 0, sigma: returnSigma(ei), floor: floorFor(e.category) }];
})
);
const res = await runMonteCarlo(
plan,
{
runs,
inflationMean: Number(inflMean) || 0,
inflationSigma: inflationSigma(inflLevel, inflManual),
elements,
target,
},
(done, total) => setProgress(done / total)
);
setResult(res);
} finally {
setRunning(false);
}
}
const chartData = useMemo(() => {
if (!result) return [];
return result.bands.map((b) => ({
age: b.age,
// Range-Flaeche als [unten, oben]-Tupel -- robust auch bei negativem p10 (Ruin-Faelle).
band: [b.p10, b.p90] as [number, number],
median: b.p50,
det: detPoints.find((d) => d.age === b.age)?.det ?? null,
}));
}, [result, detPoints]);
return (
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
<div
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-3xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
>
<div className="flex items-center justify-between">
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
<Dices className="h-5 w-5 text-accent" /> Monte-Carlo-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>
{/* Erklaerung */}
<div className="rounded-xl border border-border bg-surface-2 p-4 text-xs leading-relaxed text-muted">
<p className="mb-2">
<strong className="text-fg">Was ist das?</strong> Dein Plan rechnet mit einer festen Rendite und Inflation
pro Jahr. Real schwanken beide. Die Simulation würfelt <strong className="text-fg">viele tausend mögliche
Verläufe</strong> und zeigt, wie oft dein Plan aufgeht.
</p>
<p className="mb-2">
<strong className="text-fg">Eingabe:</strong> je Anlage die historische Durchschnittsrendite (der Mittelpunkt,
um den gewürfelt wird) und wie stark sie schwankt; dazu dieselben Angaben für die Inflation.
<strong className="text-fg"> Ergebnis:</strong> die Wahrscheinlichkeit, dass das Geld reicht bzw. dein Zielbetrag
erreicht wird, plus ein Fächer vom pessimistischen bis zum optimistischen Fall.
</p>
<p>
<strong className="text-fg">Verteilung:</strong> Renditen werden mit «fetten Rändern» gezogen (Extremcrashs so
häufig wie in der Realität, nicht wie in der Glockenkurve), und alle riskanten Anlagen fallen in einem
Marktschock gemeinsam. <em>Die Prozentzahl gilt immer nur relativ zu deinen Annahmen sie beurteilt nicht,
ob deine Durchschnittswerte realistisch sind.</em>
</p>
</div>
{/* Inflation */}
<div>
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">Inflation (Plan-Ebene)</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<MeanField
label="Ø Inflation letzte 20 J. (%)"
help={`Der Mittelpunkt, um den gewürfelt wird. Deine Planung nutzt aktuell ${plan.inflationRateDefault} %. CH langfristig ~2 %.`}
value={inflMean}
onChange={setInflMean}
/>
<SelectField
label="Streuung"
help={INFLATION_HELP}
value={inflLevel}
onChange={(v: InflationVolatilityLevel) => setInflLevel(v)}
options={INFLATION_LEVEL_OPTIONS}
/>
{inflLevel === "manuell" ? (
<NumberField label="Standardabw. (%)" step={0.1} value={Number(inflManual) || 0} onChange={(v) => setInflManual(String(v))} />
) : (
<ReadOnlySigma value={INFLATION_VOLATILITY_LEVELS[inflLevel]} />
)}
</div>
</div>
{/* Elemente */}
<div>
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">Renditetragende Elemente</div>
{returnElements.length === 0 && (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-xs text-muted">
Dieser Plan hat keine renditetragenden Elemente (PK, 3a, Sonstiges Vermögen, Immobilie).
</p>
)}
<div className="flex flex-col gap-3">
{returnElements.map((e) => {
const ei = els[e.id];
return (
<div key={e.id} className="rounded-xl border border-border bg-surface-2 p-3">
<div className="mb-2 flex items-center gap-2 text-sm">
<span className="font-semibold text-fg">{e.name}</span>
<span className="text-xs text-faint">{CATEGORY_LABELS[e.category]}</span>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<MeanField
label="Ø Rendite letzte 20 J. (%)"
help="Der Mittelpunkt, um den gewürfelt wird die realistische Rendite dieser Anlage, nicht deine (evtl. optimistische) Planannahme."
value={ei?.mean ?? ""}
onChange={(v) => setEl(e.id, { mean: v })}
/>
<SelectField
label="Streuung"
help={RETURN_HELP}
value={ei.level}
onChange={(v: ReturnVolatilityLevel) => setEl(e.id, { level: v })}
options={RETURN_LEVEL_OPTIONS}
/>
{ei.level === "manuell" ? (
<NumberField label="Standardabw. (%)" step={0.5} value={Number(ei.manualSigma) || 0} onChange={(v) => setEl(e.id, { manualSigma: String(v) })} />
) : (
<ReadOnlySigma value={RETURN_VOLATILITY_LEVELS[ei.level]} />
)}
</div>
{(e.category === "PENSION_FUND" || e.category === "PILLAR_3A") && (
<p className="mt-2 text-[11px] text-faint">Boden 0 %: {CATEGORY_LABELS[e.category]} schreibt keine negative Rendite gut.</p>
)}
</div>
);
})}
</div>
</div>
{/* Lauf-Parameter */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div>
<SelectField
label="Anzahl Simulationen"
value={String(runs)}
onChange={(v: string) => setRuns(Number(v))}
options={[
{ value: "1000", label: "1'000 (schnell)" },
{ value: "5000", label: "5'000" },
{ value: "10000", label: "10'000 (genau)" },
]}
/>
<p className="mt-1 text-[11px] text-faint">geschätzt ~{estSeconds} s</p>
</div>
<MoneyField
label="Zielbetrag (Endvermögen)"
help="Die Erfolgswahrscheinlichkeit misst, wie oft das Endvermögen mindestens diesen Betrag erreicht. Vorbelegt mit deinem geplanten Nachlass."
value={target}
onChange={setTarget}
/>
</div>
{missing && <p className="text-xs text-danger">Bitte alle Pflichtfelder (Ø-Werte) ausfüllen, um die Simulation zu starten.</p>}
<div className="flex items-center gap-3">
<button
type="button"
disabled={missing || running || returnElements.length === 0}
onClick={run}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
>
{running ? `Berechne… ${Math.round(progress * 100)} %` : "Berechnung jetzt durchführen"}
</button>
{running && (
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-2">
<div className="h-full bg-accent transition-all" style={{ width: `${progress * 100}%` }} />
</div>
)}
</div>
{result && <MonteCarloResults result={result} target={target} chartData={chartData} />}
</div>
</div>
);
}
function ReadOnlySigma({ value }: { value: number }) {
return (
<div>
<label className="mb-1 flex items-center text-xs font-medium text-muted">
Standardabweichung
<InfoBubble text="Die hinter der gewählten Streuungsstufe hinterlegte Standardabweichung. Nur bei Manuell selbst eingebbar." />
</label>
<div className="w-full rounded-lg border border-dashed border-border bg-surface-2 px-2.5 py-1.5 text-sm text-muted">
{value} %
</div>
</div>
);
}
function MonteCarloResults({
result,
target,
chartData,
}: {
result: MonteCarloResult;
target: number;
chartData: { age: number; band: [number, number]; median: number; det: number | null }[];
}) {
return (
<div className="flex flex-col gap-4 border-t border-border pt-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<Stat label="Erfolgswahrscheinlichkeit" value={`${Math.round(result.successProbability * 100)} %`} help={`Anteil der Läufe mit Endvermögen ≥ ${formatChf(target)}.`} good />
<Stat label="Ruinwahrscheinlichkeit" value={`${Math.round(result.ruinProbability * 100)} %`} help="Anteil der Läufe, in denen das Vermögen vor Planende unter 0 fällt." danger />
<Stat label="Läufe" value={result.runs.toLocaleString("de-CH")} />
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 text-sm">
<Band label="Pessimistisch (10 %)" value={result.finalWealthP10} />
<Band label="Median" value={result.finalWealthMedian} />
<Band label="Optimistisch (90 %)" value={result.finalWealthP90} />
</div>
<div>
<div className="mb-1 text-xs font-semibold text-fg">Vermögensfächer nach Alter (nominal)</div>
<p className="mb-2 text-[11px] text-muted">
Das Band reicht vom pessimistischen (10 %) bis zum optimistischen (90 %) Fall, die dunkle Linie ist der Median.
Die gestrichelte Linie ist deine deterministische Planung sie liegt meist leicht über dem Median (Schwankung
frisst Rendite).
</p>
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="age" tick={{ fontSize: 11 }} tickFormatter={(v) => `${v} J.`} />
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} />
<Tooltip
formatter={(v, name) => [typeof v === "number" ? formatChf(v) : v, name]}
labelFormatter={(v) => `Alter ${v}`}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
<Area dataKey="band" name="1090 % Band" stroke="none" fill="var(--accent)" fillOpacity={0.16} isAnimationActive={false} />
<Line dataKey="median" name="Median" stroke="var(--accent)" strokeWidth={2} dot={false} isAnimationActive={false} />
<Line dataKey="det" name="Planung (deterministisch)" stroke="var(--muted)" strokeWidth={1.5} strokeDasharray="5 3" dot={false} connectNulls isAnimationActive={false} />
</ComposedChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
}
function Stat({ label, value, help, good, danger }: { label: string; value: string; help?: string; good?: boolean; danger?: boolean }) {
return (
<div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
<div className="flex items-center text-xs text-muted">
{label}
{help && <InfoBubble text={help} />}
</div>
<div className={`mt-1 text-2xl font-semibold ${good ? "text-success" : danger ? "text-danger" : "text-fg"}`}>{value}</div>
</div>
);
}
function Band({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2">
<div className="text-[11px] text-muted">{label}</div>
<div className="font-semibold text-fg">{formatChf(value)}</div>
</div>
);
}
+14
View File
@@ -8,6 +8,7 @@ import {
ChevronDown,
ChevronRight,
CreditCard,
Dices,
Home,
Landmark,
PiggyBank,
@@ -32,6 +33,7 @@ import {
type CellContext,
} from "@/components/ElementDetail";
import { PhaseDetail } from "@/components/PhaseDetail";
import { MonteCarloDialog } from "@/components/MonteCarloDialog";
import { PlanProfileFields, type ProfileDraft } from "@/components/PlanProfileFields";
import { MoneyField } from "@/components/FormField";
import { api } from "@/lib/api-client";
@@ -104,6 +106,7 @@ export function PlanView({
const [editTransition, setEditTransition] = useState<{ elementId: string; fromPhaseId: string } | null>(null);
const [editPhaseCell, setEditPhaseCell] = useState<{ elementId: string; phaseId: string } | null>(null);
const [showCashInit, setShowCashInit] = useState(false);
const [showMonteCarlo, setShowMonteCarlo] = useState(false);
// fromPhaseId des Cash-Uebergangs, der gerade bearbeitet wird.
const [editCashTransition, setEditCashTransition] = useState<string | null>(null);
const [valueMode, setValueMode] = useState<ValueMode>("nominal");
@@ -350,6 +353,13 @@ export function PlanView({
>
<Plus className="h-4 w-4" /> Lebensphase
</button>
<button
type="button"
onClick={() => setShowMonteCarlo(true)}
className="ml-auto flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-muted hover:bg-surface-2"
>
<Dices className="h-4 w-4" /> Monte-Carlo-Simulation durchführen
</button>
</div>
)}
@@ -570,6 +580,10 @@ export function PlanView({
/>
)}
{showMonteCarlo && (
<MonteCarloDialog plan={plan} computed={computed} onClose={() => setShowMonteCarlo(false)} />
)}
{editCashTransition && (() => {
const fromPhase = computed.phases.find((p) => p.id === editCashTransition);
if (!fromPhase) return null;
+31 -10
View File
@@ -210,7 +210,16 @@ function personByRole<T extends { role: PersonRole }>(persons: T[], role: string
return persons.find((p) => p.role === role) ?? null;
}
export function computePlan(plan: PlanInput): PlanComputed {
// Ein Zufalls-Szenario fuer die Monte-Carlo-Simulation: liefert je Jahr eine Inflation und
// je Element/Jahr eine Rendite. Ohne Sample rechnet computePlan rein deterministisch (die
// geplanten Annahmen), mit Sample einen einzelnen simulierten Pfad. Jahr ist 1-basiert
// (ab Planbeginn); der Inflations-Index ist 0-basiert (inflation[0] = Jahr 1).
export interface PlanSample {
inflation: number[];
assetReturn: (elementId: string, year: number) => number;
}
export function computePlan(plan: PlanInput, sample?: PlanSample): PlanComputed {
const phases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
const persons = plan.persons;
const personA = persons.find((p) => p.role === "PERSON_A") ?? persons[0];
@@ -218,6 +227,15 @@ export function computePlan(plan: PlanInput): PlanComputed {
const retirementAge = new Map<string, number>();
for (const p of persons) retirementAge.set(p.id, p.retirementAge);
// Kumulierter Inflations-Deflator je Jahr (cumInfl[0] = 1, cumInfl[k] = Kaufkraftfaktor nach
// k Jahren). Deterministisch identisch zur bisherigen (1+infl)^k-Formel; mit Sample variiert
// die Inflation pro Jahr. Ersetzt die frueheren geschlossenen Potenz-Ausdruecke.
const totalYears = phases.reduce((s, p) => s + Math.max(1, p.durationYears), 0);
const inflationOfYear = (year: number) =>
sample ? sample.inflation[year - 1] ?? plan.inflationRateDefault : plan.inflationRateDefault;
const cumInfl: number[] = [1];
for (let y = 1; y <= totalYears; y++) cumInfl[y] = cumInfl[y - 1] * (1 + inflationOfYear(y) / 100);
const gapYearsByPerson = new Map<string, number>();
// AHV-Beitragskarriere je Person: reales Einkommen x Beitragsjahre, und Beitragsjahre.
const ahvIncomeAccum = new Map<string, number>();
@@ -247,9 +265,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
const nextPhase = phases[i + 1];
const isFirstPhase = i === 0;
const duration = Math.max(1, phase.durationYears);
// Inflation ist plan-weit (V5): keine Phasen-Ueberschreibung mehr.
const infl = plan.inflationRateDefault;
const cumInflStart = cumulativeInflation; // Kaufkraft-Deflator zu Phasenbeginn
const cumInflStart = cumInfl[yearsBefore]; // Kaufkraft-Deflator zu Phasenbeginn
const personInfos: PersonPhaseInfo[] = persons.map((p) => {
const ra = retirementAge.get(p.id)!;
@@ -404,7 +420,10 @@ export function computePlan(plan: PlanInput): PlanComputed {
const attributed =
owner ?? (plan.householdType === "SINGLE" && e.ownerRole === "HOUSEHOLD" ? personA : null);
if (attributed) {
const avgRealGross = avgRealFlow(basis, idx, infl, duration, cumInflStart) * AHV_GROSS_FROM_NET_FACTOR;
// Die AHV-Karriere ist eine Real-Groesse auf Planungsbasis -- bewusst mit der
// festen Plan-Inflation, nicht der (evtl. gewuerfelten) Sample-Inflation.
const avgRealGross =
avgRealFlow(basis, idx, plan.inflationRateDefault, duration, cumInflStart) * AHV_GROSS_FROM_NET_FACTOR;
phaseRealIncomeByPerson.set(
attributed.id,
(phaseRealIncomeByPerson.get(attributed.id) ?? 0) + avgRealGross
@@ -553,7 +572,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
let incomeFlow = renteTotal;
for (const inc of incomes) incomeFlow += inc.basis * Math.pow(1 + inc.idx / 100, t - 1);
// Ausgaben: real (Basis x (1+reale Mehrausgabe)^(t-1)); nominal = real x kumul. Inflation.
const inflFactor = cumInflStart * Math.pow(1 + infl / 100, t - 1);
const inflFactor = cumInfl[yearsBefore + t - 1];
let expenseRealBase = 0;
for (const exp of expenses) expenseRealBase += exp.basis * Math.pow(1 + exp.idx / 100, t - 1);
@@ -592,7 +611,8 @@ export function computePlan(plan: PlanInput): PlanComputed {
// Vermoegen verzinsen + Sparbeitrag; Bezugsrate entnehmen (gekappt am Bestand) und ins Cash.
let cashFromWithdraw = 0;
for (const a of assets) {
const grown = a.value * (1 + a.r / 100) + a.rate;
const r = sample ? sample.assetReturn(a.ec.elementId, yearsBefore + t) : a.r;
const grown = a.value * (1 + r / 100) + a.rate;
const w = Math.min(a.withdrawal, Math.max(0, grown));
a.value = grown - w;
cashFromWithdraw += w;
@@ -607,7 +627,8 @@ export function computePlan(plan: PlanInput): PlanComputed {
debtRates += pay;
// Wertsteigerung wirkt auf die LIEGENSCHAFT, nicht auf das Eigenkapital -- das ist der
// Hebel: 1 % von 1 Mio sind 10'000, also 10 % eines Eigenkapitals von 100'000.
re.value *= 1 + re.growth / 100;
const g = sample ? sample.assetReturn(re.ec.elementId, yearsBefore + t) : re.growth;
re.value *= 1 + g / 100;
}
for (const d of debts) {
const pay = Math.min(d.repay, d.owed);
@@ -629,7 +650,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
// Flow-Deflator fuer den Endwert (Jahr `duration`): eine Kaufkraft-Stufe weniger als der
// Bestands-Deflator am Phasenende.
const flowDeflatorEnd = cumInflStart * Math.pow(1 + infl / 100, duration - 1);
const flowDeflatorEnd = cumInfl[yearsBefore + duration - 1];
// Endwerte je Element setzen (Einkommen/Ausgaben nominal; Ausgaben-Nominal = real x Infl.).
for (const inc of incomes) {
@@ -663,7 +684,7 @@ export function computePlan(plan: PlanInput): PlanComputed {
const cashEnd = Math.round(cash);
const startWealthNominal = Math.round(wealthStart + cashStart);
const endWealthNominal = Math.round(wealthEnd + cashEnd);
cumulativeInflation = cumInflStart * Math.pow(1 + infl / 100, duration);
cumulativeInflation = cumInfl[yearsBefore + duration];
result.push({
id: phase.id,
+107
View File
@@ -0,0 +1,107 @@
import { describe, it, expect } from "vitest";
import { computePlan } from "@/lib/calculations";
import { runMonteCarlo, defaultVolatilityLevel, 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.
// Deterministisch: 100'000 x 1.05^10 = 162'889 Endvermoegen, kein Ruin.
function basePlan(): PlanInput {
return {
id: "p", name: "T", householdType: "SINGLE", inflationRateDefault: 2, initialCash: 0,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 70 }],
phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 10, cashTransition: {} }],
elements: [
{
id: "asset", category: "OTHER_ASSET", name: "ETF", ownerRole: "HOUSEHOLD", orderIndex: 1,
phaseValues: { p1: { startValue: 100000, expectedReturn: 5, annualContribution: 0 } },
transitionValues: {},
},
],
};
}
function params(over: Partial<MonteCarloParams> = {}): MonteCarloParams {
return {
runs: 2000,
inflationMean: 2,
inflationSigma: 0,
elements: { asset: { mean: 5, sigma: 0, floor: -100 } },
target: 0,
seed: 12345,
...over,
};
}
const detEnd = () => computePlan(basePlan()).phases[0].endWealthNominal;
describe("Monte Carlo", () => {
it("Streuung 0 reproduziert exakt das deterministische Ergebnis", async () => {
const r = await runMonteCarlo(basePlan(), params());
const det = detEnd();
expect(r.finalWealthMedian).toBe(det);
expect(r.finalWealthP10).toBe(det);
expect(r.finalWealthP90).toBe(det); // Baender kollabieren auf die deterministische Linie
expect(r.ruinProbability).toBe(0);
expect(r.bands[r.bands.length - 1].p50).toBe(det);
});
it("Volatilitaet spreizt den Faecher (p90 > p10)", async () => {
const r = await runMonteCarlo(basePlan(), params({ elements: { asset: { mean: 5, sigma: 20, floor: -100 } } }));
expect(r.finalWealthP90).toBeGreaterThan(r.finalWealthP10);
// Median bleibt in der Naehe des deterministischen Werts (leicht darunter wegen Vol-Drag).
expect(r.finalWealthMedian).toBeLessThan(r.finalWealthP90);
expect(r.finalWealthMedian).toBeGreaterThan(r.finalWealthP10);
});
it("Erfolgswahrscheinlichkeit sinkt mit steigendem Zielbetrag", async () => {
const opts = { elements: { asset: { mean: 5, sigma: 20, floor: -100 } } };
const low = await runMonteCarlo(basePlan(), params({ ...opts, target: 50000 }));
const high = await runMonteCarlo(basePlan(), params({ ...opts, target: 500000 }));
expect(low.successProbability).toBeGreaterThan(high.successProbability);
expect(low.successProbability).toBeGreaterThan(0.9); // 50k ist fast sicher erreicht
expect(high.successProbability).toBeLessThan(0.1); // 500k praktisch unerreichbar
});
it("gleicher Seed -> identisches Ergebnis (reproduzierbar)", async () => {
const opts = params({ elements: { asset: { mean: 5, sigma: 20, floor: -100 } }, seed: 777 });
const a = await runMonteCarlo(basePlan(), opts);
const b = await runMonteCarlo(basePlan(), opts);
expect(a.finalWealthMedian).toBe(b.finalWealthMedian);
expect(a.ruinProbability).toBe(b.ruinProbability);
});
it("Boden 0 % (PK/3a): Rendite nie negativ -> Endvermoegen nie unter dem Startwert", async () => {
// Ohne Beitraege kann ein bei 0 % gebodetes Asset nur wachsen oder gleich bleiben.
const r = await runMonteCarlo(
basePlan(),
params({ elements: { asset: { mean: 0, sigma: 80, floor: 0 } } })
);
expect(r.finalWealthP10).toBeGreaterThanOrEqual(100000);
});
it("Ruinwahrscheinlichkeit: sicherer Verzehr fuehrt immer in den Ruin", async () => {
// Rente 20k, Ausgaben 60k, kleines Vermoegen -> deterministisch Ruin, auch ohne Streuung.
const plan: PlanInput = {
id: "p", name: "T", householdType: "SINGLE", inflationRateDefault: 0, initialCash: 0,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 65, retirementAge: 65 }],
phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 20, cashTransition: {} }],
elements: [
{ id: "inc", category: "INCOME", name: "Rente", ownerRole: "PERSON_A", orderIndex: 1, phaseValues: { p1: { amount: 20000, teuerungsausgleich: 0 } }, transitionValues: {} },
{ id: "exp", category: "EXPENSE", name: "Ausgaben", ownerRole: "HOUSEHOLD", orderIndex: 2, phaseValues: { p1: { amount: 60000, teuerungsausgleich: 0 } }, transitionValues: {} },
{ id: "asset", category: "OTHER_ASSET", name: "V", ownerRole: "HOUSEHOLD", orderIndex: 3, phaseValues: { p1: { startValue: 100000, expectedReturn: 0, annualContribution: 0 } }, transitionValues: {} },
],
};
const r = await runMonteCarlo(plan, {
runs: 1000, inflationMean: 0, inflationSigma: 0,
elements: { asset: { mean: 0, sigma: 10, floor: -100 } }, target: 0, seed: 1,
});
expect(r.ruinProbability).toBe(1); // Verzehr uebersteigt Rente + Vermoegen in jedem Pfad
});
it("Hilfsfunktionen: Default-Stufen und σ-Tabelle", () => {
expect(defaultVolatilityLevel("PENSION_FUND")).toBe("sehr_niedrig");
expect(defaultVolatilityLevel("OTHER_ASSET")).toBe("moderat");
expect(RETURN_VOLATILITY_LEVELS.moderat).toBe(15);
expect(RETURN_VOLATILITY_LEVELS.sehr_hoch).toBe(55);
});
});
+221
View File
@@ -0,0 +1,221 @@
// Monte-Carlo-Simulation (Roadmap Nr. 19, Stufe A). Laeuft vollstaendig im Browser, weil
// computePlan eine reine Funktion ohne Server-Abhaengigkeiten ist.
//
// Modell: Pro Jahr wird EIN gemeinsamer Marktschock gezogen (damit riskante Anlagen zusammen
// fallen, nicht gegeneinander). Jede Rendite = historischer Mittelwert + Standardabweichung x
// (Marktanteil x Marktschock + Eigenanteil x Eigenrauschen). Beide Schocks sind fettschwaenzig
// (standardisierte Student-t), damit Extremcrashs realistisch haeufig auftreten -- eine
// Normalverteilung wuerde sie stark unterschaetzen. Boeden: 0 % fuer PK/3a, -100 % sonst.
import { computePlan, type PlanSample } from "@/lib/calculations";
import type { ElementCategory } from "@/lib/elements";
import type { PlanInput } from "@/lib/types";
// Standardabweichungen (annualisiert, in %) hinter den Streuungsstufen. Recherchiert und
// gerundet: Anleihen ~6 %, globale Aktien ~15-18 %, Schweizer Immobilien(fonds) ~2 %,
// Bitcoin ~54 %. Quellen: siehe SPEZIFIKATION Kap. 4.
export const RETURN_VOLATILITY_LEVELS = {
sehr_niedrig: 3,
niedrig: 6,
moderat: 15,
hoch: 25,
sehr_hoch: 55,
} as const;
// Inflation ist in der Schweiz historisch stabil (Standardabweichung der letzten 20 Jahre
// ~1 %). Hoehere Stufen ergaeben Hyperinflations-Annahmen -- deshalb nur zwei Stufen.
export const INFLATION_VOLATILITY_LEVELS = {
sehr_niedrig: 1,
niedrig: 2,
} as const;
export type ReturnVolatilityLevel = keyof typeof RETURN_VOLATILITY_LEVELS | "manuell";
export type InflationVolatilityLevel = keyof typeof INFLATION_VOLATILITY_LEVELS | "manuell";
// Default-Stufe je Element-Typ (damit die Simulation ohne Eingabe eine plausible Streuung hat).
export function defaultVolatilityLevel(category: ElementCategory): ReturnVolatilityLevel {
switch (category) {
case "PENSION_FUND":
return "sehr_niedrig";
case "REAL_ESTATE":
case "PILLAR_3A":
return "niedrig";
default:
return "moderat";
}
}
// Kategorien mit Marktrendite -- nur diese brauchen Monte-Carlo-Parameter.
export const RETURN_BEARING: ElementCategory[] = ["PENSION_FUND", "PILLAR_3A", "OTHER_ASSET", "REAL_ESTATE"];
// PK/3a schreiben dem Versicherten keine negative Rendite gut -> Boden bei 0 %.
export function floorFor(category: ElementCategory): number {
return category === "PENSION_FUND" || category === "PILLAR_3A" ? 0 : -100;
}
export interface ElementMcParams {
mean: number; // historische Durchschnittsrendite (%/Jahr)
sigma: number; // Standardabweichung (%/Jahr)
floor: number; // 0 fuer PK/3a, -100 sonst
}
export interface MonteCarloParams {
runs: number;
inflationMean: number;
inflationSigma: number;
elements: Record<string, ElementMcParams>; // key = elementId
target: number; // Zielbetrag fuer die Erfolgswahrscheinlichkeit (nominal)
seed?: number;
}
export interface MonteCarloResult {
runs: number;
ruinProbability: number; // P(Vermoegen faellt vor Planende unter 0)
successProbability: number; // P(Endvermoegen >= Zielbetrag)
finalWealthP10: number;
finalWealthMedian: number;
finalWealthP90: number;
// Faecher ueber das Alter: je Alterspunkt der pessimistische/mittlere/optimistische Wert.
bands: { age: number; p10: number; p50: number; p90: number }[];
}
// --- Zufallszahlen (seedbar, damit ein Lauf reproduzierbar ist) ---
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function normal(rng: () => number): number {
// Box-Muller. Kleiner Schutz gegen log(0).
const u = Math.max(rng(), 1e-12);
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * rng());
}
// Standardisierte Student-t mit nu Freiheitsgraden (Einheitsvarianz -> die eingegebene
// Standardabweichung bleibt die tatsaechliche). Fette Raender: nu = 5.
const NU = 5;
function studentT(rng: () => number): number {
let chi2 = 0;
for (let i = 0; i < NU; i++) {
const z = normal(rng);
chi2 += z * z;
}
const t = normal(rng) / Math.sqrt(chi2 / NU);
return t * Math.sqrt((NU - 2) / NU); // auf Einheitsvarianz standardisieren
}
// Ladefaktor auf den gemeinsamen Marktschock: rho^2 ist die Korrelation zweier riskanter
// Anlagen. rho = 0.7 -> ~0.5. Diversifikation hilft etwas, rettet aber nicht im Crash.
const RHO = 0.7;
const RHO_IDIO = Math.sqrt(1 - RHO * RHO);
function percentile(sortedAsc: number[], p: number): number {
if (sortedAsc.length === 0) return 0;
const idx = Math.min(sortedAsc.length - 1, Math.max(0, Math.round(p * (sortedAsc.length - 1))));
return sortedAsc[idx];
}
// Ein Alterspunkt je Phasenanfang plus das Planende (wie im Vermoegensverlauf-Chart).
function agePointsOf(plan: PlanInput): number[] {
const phases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
const startAge = plan.persons.find((p) => p.role === "PERSON_A")?.age ?? plan.persons[0]?.age ?? 0;
const points: number[] = [startAge];
let acc = 0;
for (const p of phases) {
acc += Math.max(1, p.durationYears);
points.push(startAge + acc);
}
return points;
}
// Wert je Alterspunkt fuer EINEN Durchlauf (Start-/Endvermoegen der Phasen, nominal).
function wealthTrajectory(computed: ReturnType<typeof computePlan>): number[] {
const phases = computed.phases;
if (phases.length === 0) return [];
const pts = [phases[0].startWealthNominal];
for (const p of phases) pts.push(p.endWealthNominal);
return pts;
}
export async function runMonteCarlo(
plan: PlanInput,
params: MonteCarloParams,
onProgress?: (done: number, total: number) => void
): Promise<MonteCarloResult> {
const phases = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber);
const totalYears = phases.reduce((s, p) => s + Math.max(1, p.durationYears), 0);
const rng = mulberry32(params.seed ?? (Math.random() * 2 ** 32) >>> 0);
const agePoints = agePointsOf(plan);
const nPoints = agePoints.length;
const wealthByPoint: number[][] = Array.from({ length: nPoints }, () => []);
const finalWealth: number[] = [];
let ruinCount = 0;
let successCount = 0;
const elementIds = Object.keys(params.elements);
for (let run = 0; run < params.runs; run++) {
// Pro Jahr ein Marktschock; pro Element/Jahr eine Rendite (fette Raender, gebodet).
const inflation: number[] = new Array(totalYears);
for (let y = 0; y < totalYears; y++) {
inflation[y] = params.inflationMean + params.inflationSigma * studentT(rng);
}
const returns = new Map<string, Float64Array>();
for (const id of elementIds) returns.set(id, new Float64Array(totalYears + 1));
for (let y = 1; y <= totalYears; y++) {
const market = studentT(rng);
for (const id of elementIds) {
const ep = params.elements[id];
const shock = RHO * market + RHO_IDIO * studentT(rng);
const r = ep.mean + ep.sigma * shock;
returns.get(id)![y] = Math.max(ep.floor, r);
}
}
const sample: PlanSample = {
inflation,
assetReturn: (elementId, year) => returns.get(elementId)?.[year] ?? 0,
};
const computed = computePlan(plan, sample);
if (computed.ruinAge !== null) ruinCount++;
const traj = wealthTrajectory(computed);
for (let i = 0; i < nPoints; i++) wealthByPoint[i].push(traj[i] ?? 0);
const fw = traj[traj.length - 1] ?? 0;
finalWealth.push(fw);
if (fw >= params.target) successCount++;
// Alle ~500 Laeufe die Kontrolle abgeben, damit die Oberflaeche nicht einfriert.
if (run % 500 === 499) {
onProgress?.(run + 1, params.runs);
await new Promise((r) => setTimeout(r, 0));
}
}
onProgress?.(params.runs, params.runs);
finalWealth.sort((a, b) => a - b);
const bands = agePoints.map((age, i) => {
const col = wealthByPoint[i].sort((a, b) => a - b);
return { age, p10: percentile(col, 0.1), p50: percentile(col, 0.5), p90: percentile(col, 0.9) };
});
return {
runs: params.runs,
ruinProbability: ruinCount / params.runs,
successProbability: successCount / params.runs,
finalWealthP10: percentile(finalWealth, 0.1),
finalWealthMedian: percentile(finalWealth, 0.5),
finalWealthP90: percentile(finalWealth, 0.9),
bands,
};
}