Effektive Werte: Plan-Ist-Vergleich (Roadmap 5)
Deploy App / deploy (push) Successful in 1m57s

Neuer Knopf auf Plan-Ebene: Liste plus Wizard in zwei Schritten. Ein
Ist-Satz haengt am PLAN, nicht am Szenario -- die Zuordnung laeuft ueber
die Herkunfts-Kette sourceElementId.

computePlan nimmt neu { actuals }: Die Werte schnappen in jedem erfassten
Jahr auf die Realitaet und laufen von dort planmaessig weiter. Luecken
fallen auf die Plandaten zurueck. Ohne die Option unveraendert -- die 43
Golden Tests laufen durch.

Der Sprung ist keine Rendite: eigene Brueckenposition actualsCorrection
in Vermoegens- und Cash-Bruecke, sonst ginge die Zerlegung nicht auf.

Matrix: Umschalter Plan/Effektiv, im Ist-Modus mit farbiger Abweichung
statt acht Zahlen je Zelle. Zeitachse: Marker je Jahr, juengster farbig.

Vier Analysewerkzeuge mit einheitlicher Leiste (nominal/real als
Einfachauswahl, Plan/Effektiv). MC: Zielbetrag dreht mit, Startjahr
abgeleitet statt eingebbar.

Neue Tabelle ActualsSet (gegen echtes Postgres verifiziert), Module
actuals.ts und dataview.ts. Spezifikation 0.21, 27 Tests (181 -> 208).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 20:35:07 +02:00
parent 4343d50aa4
commit e117e5a4e5
25 changed files with 1991 additions and 74 deletions
+73 -2
View File
@@ -11,6 +11,7 @@ import {
DEFAULT_PROPERTY_GAINS_TAX_RATE,
} from "@/lib/constants";
import { num } from "@/lib/elements";
import { actualsForYear, rebaseFlow, type ResolvedActuals } from "@/lib/actuals";
import type { ElementCategory } from "@/lib/elements";
import type { PersonRole, PlanInput } from "@/lib/types";
@@ -50,6 +51,9 @@ export interface ComputeOptions {
// Standardmässig aus: die Monte-Carlo-Simulation ruft computePlan zehntausendfach auf
// und darf von der Protokollierung nichts merken.
explain?: boolean;
// Effektive (Ist-)Werte, auf DIESES Szenario aufgelöst (siehe `actuals.ts`). Ohne sie
// rechnet die Funktion exakt wie bisher -- die Plan-Sicht bleibt unangetastet.
actuals?: ResolvedActuals[];
}
// Ein Datenpunkt pro Jahr JE ELEMENT -- Grundlage der Detailansicht (Roadmap Nr. 43).
@@ -111,6 +115,10 @@ export interface WealthBridge {
investmentReturn: number; // Rendite auf PK/3a/Sonstigem Vermögen
propertyAppreciation: number; // Wertsteigerung der Liegenschaft
pensionFundContribution: number; // PK-Beiträge: erhöhen das Vermögen, ohne Cash zu kosten
// Sprung auf die erfassten Ist-Werte (nur in der Ist-Sicht, sonst 0). Bewusst als eigene
// Position: Die Differenz zwischen Plan und Wirklichkeit ist KEINE Rendite und darf nicht
// als solche erscheinen -- ohne diese Zeile ginge die Brücke im Ist-Jahr nicht auf.
actualsCorrection: number;
endWealth: number; // = endWealthNominal
residual: number; // Rundungsdifferenz (Kontrollgrösse, sollte nahe 0 sein)
}
@@ -127,6 +135,7 @@ export interface CashBridge {
savingRates: number; // 3a + Sparbeiträge (Abgang)
debtRates: number; // Amortisationen + Tilgungen (Abgang)
withdrawals: number; // Bezugsraten aus Sonstigem Vermögen (Zugang)
actualsCorrection: number; // Sprung auf den erfassten Ist-Cashbestand (sonst 0)
cashEnd: number;
residual: number;
}
@@ -332,6 +341,8 @@ function pct(v: number): string {
export function computePlan(plan: PlanInput, sample?: PlanSample, options?: ComputeOptions): PlanComputed {
const explain = options?.explain === true;
// Ohne Ist-Werte verhält sich die Funktion exakt wie bisher (die Golden Tests belegen es).
const actuals = options?.actuals;
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];
@@ -703,6 +714,10 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
let savingRatesTotal = 0;
let debtRatesTotal = 0;
let withdrawalsTotal = 0;
// Sprung auf die Ist-Werte. Ohne Ist-Daten bleiben beide 0 und die Brücken rechnen
// exakt wie bisher.
let actualsCorrectionTotal = 0;
let actualsCashCorrectionTotal = 0;
for (let t = 1; t <= duration; t++) {
// Einkommen: nominal (Basis x (1+Lohnerhöhung)^(t-1)) + Renten (nominal fix).
@@ -787,6 +802,58 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
if (t === 1) plannedSaveRate = fixedRatesTotal + debtRates;
cash += quote - fixedRatesTotal - debtRates + cashFromWithdraw;
// --- Effektive Werte einspielen (Roadmap Nr. 5) --------------------------------------
// Bewusst NACH Verzinsung, Tilgung und Cash-Fortschreibung: Der erfasste Wert ist der
// Stand AM ENDE des Ist-Jahres. Rechnet man 2022 mit 120 und wieder 2024 mit 140, so
// liegen dazwischen genau zwei Wachstumsjahre -- das entspricht der Erwartung.
//
// Die Differenz wird als eigene Grösse geführt und NICHT den Renditen zugeschlagen:
// Ein Rückstand gegenüber dem Plan ist keine negative Rendite, sondern eine Korrektur.
const act = actuals ? actualsForYear(actuals, yearsBefore + t) : null;
if (act) {
for (const a of assets) {
const v = act.byElementId[a.ec.elementId]?.value;
if (typeof v !== "number") continue; // Lücke -> Planlinie läuft weiter
actualsCorrectionTotal += v - a.value;
a.value = v;
}
for (const re of realEstates) {
const av = act.byElementId[re.ec.elementId];
if (typeof av?.value === "number") {
actualsCorrectionTotal += av.value - re.value;
re.value = av.value;
}
if (typeof av?.mortgage === "number") {
// Eine höhere Restschuld mindert das Vermögen -- Vorzeichen umgekehrt.
actualsCorrectionTotal -= av.mortgage - re.mortgage;
re.mortgage = av.mortgage;
}
}
for (const d of debts) {
const v = act.byElementId[d.ec.elementId]?.value;
if (typeof v !== "number") continue;
actualsCorrectionTotal -= v - d.owed;
d.owed = v;
}
// Flüsse: Der erfasste Betrag gilt für DIESES Jahr; die Basis wird so zurückgerechnet,
// dass die Reihe hier den Ist-Wert trifft und danach planmässig weiterwächst.
for (const inc of incomes) {
const v = act.byElementId[inc.ec.elementId]?.value;
if (typeof v === "number") inc.basis = rebaseFlow(v, inc.idx, t);
}
for (const exp of expenses) {
const v = act.byElementId[exp.ec.elementId]?.value;
// Ausgaben werden real geführt, erfasst wird der nominale Ist-Betrag.
if (typeof v === "number") exp.basis = rebaseFlow(v, exp.idx, t, inflFactor);
}
if (typeof act.cash === "number") {
actualsCashCorrectionTotal += act.cash - cash;
actualsCorrectionTotal += act.cash - cash;
cash = act.cash;
}
}
if (cash < 0) cashNegative = true;
quotaTotal += quote;
@@ -1203,6 +1270,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
investmentReturn: Math.round(investmentReturnTotal),
propertyAppreciation: Math.round(propertyAppreciationTotal),
pensionFundContribution: Math.round(pensionFundContributionTotal),
actualsCorrection: Math.round(actualsCorrectionTotal),
endWealth: endWealthNominal,
residual:
endWealthNominal -
@@ -1215,7 +1283,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
Math.round(quotaTotal) +
Math.round(investmentReturnTotal) +
Math.round(propertyAppreciationTotal) +
Math.round(pensionFundContributionTotal)),
Math.round(pensionFundContributionTotal) +
Math.round(actualsCorrectionTotal)),
},
cashBridge: {
openingCash: isFirstPhase ? Math.round(plan.initialCash || 0) : previousCashEnd,
@@ -1229,6 +1298,7 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
savingRates: Math.round(savingRatesTotal),
debtRates: Math.round(debtRatesTotal),
withdrawals: Math.round(withdrawalsTotal),
actualsCorrection: Math.round(actualsCashCorrectionTotal),
cashEnd,
residual:
cashEnd -
@@ -1236,7 +1306,8 @@ export function computePlan(plan: PlanInput, sample?: PlanSample, options?: Comp
Math.round(quotaTotal) -
Math.round(savingRatesTotal) -
Math.round(debtRatesTotal) +
Math.round(withdrawalsTotal)),
Math.round(withdrawalsTotal) +
Math.round(actualsCashCorrectionTotal)),
},
traces: explain ? phaseTraces : undefined,
});