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:
+31
-10
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user