Lesbare Wasserfaelle, Verkaufspreis-Abgleich, Erklaerung wirkungsloser Treiber
Deploy App / deploy (push) Successful in 59s

Die Wasserfall-Zahlen waren korrekt (residual exakt 0), die Darstellung nicht
lesbar. Neu als eigene liegende HTML/CSS-Darstellung statt Recharts:
- Verbindungslinien zwischen den Balken (ohne sie zerfaellt der Wasserfall in
  unverbundene Rechtecke)
- Wertbeschriftung an jedem Schritt
- Zwischenstand und Veraenderung optisch unterschieden
- Abschnitte "Am Uebergang" / "Innerhalb der Phase"
- aufklappbare Tabelle mit laufendem Zwischenstand
- Nullposten werden nicht gezeichnet
- Restposten neu als Fehlermeldung statt beilaeufiger Rundungsnotiz

Verkaufspreis einer Immobilie wird beim Wechsel auf "Verkaufen" mit dem
modellierten Verkehrswert vorbelegt (nur wenn noch keiner erfasst ist);
Verkehrswert und Abweichung werden ausgewiesen, ab 10 % rot abgesetzt. Die
beiden Groessen bleiben bewusst entkoppelt -- ein Verkauf unter Verkehrswert
ist ein realer Fall.

Tornado erklaert Nullbalken statt sie stumm zu zeigen. Wichtigster Fall: Wird
die Immobilie vor Planende verkauft, ist die Wertsteigerung nachweislich
wirkungslos, weil der Erloes am erfassten Verkaufspreis haengt und nicht am
Verkehrswert.

11 Tests ergaenzt (92 -> 103), darunter residual === 0 ueber sieben
Plankonstellationen. SPEZIFIKATION auf 0.12, neue Kapitel 3.5.8, 4.13.5,
4.14.2.1, 9.22. Keine Aenderung an der Berechnung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 21:21:37 +02:00
parent 4791dccf93
commit e1f74fca95
8 changed files with 671 additions and 61 deletions
+108 -9
View File
@@ -4,10 +4,10 @@
| | | | | |
|---|---| |---|---|
| **Dokument** | Funktionale und Technische Spezifikation FPT | | **Dokument** | Funktionale und Technische Spezifikation FPT |
| **Version** | 0.11 | | **Version** | 0.12 |
| **Datum** | 2026-07-18 | | **Datum** | 2026-07-18 |
| **Status** | Lebendes Dokument | | **Status** | Lebendes Dokument |
| **Codestand** | Arbeitsstand nach `1836cad` inkl. Detailansichten, Wasserfall-Zerlegungen und vollständiger Rechenweg-Offenlegung (Branch `main`) | | **Codestand** | Arbeitsstand nach `4791dcc` inkl. lesbarer Wasserfälle und Verkaufspreis-Abgleich (Branch `main`) |
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1v5) im Ordner `Info Dateien` diese sind ab Version 0.1 dieses Dokuments obsolet | | **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` | | **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
@@ -17,6 +17,7 @@
| Version | Datum | Autor | Änderung | | Version | Datum | Autor | Änderung |
|---|---|---|---| |---|---|---|---|
| 0.12 | 2026-07-18 | Claude (Opus 4.8) | **Lesbarkeit der Wasserfälle, Verkaufspreis-Abgleich und Erklärung wirkungsloser Tornado-Treiber.** (1) Die beiden Wasserfälle werden **nicht mehr mit Recharts** gezeichnet, sondern als eigene liegende Darstellung: Verbindungslinien zwischen den Balken, Wertbeschriftung an jedem Schritt, Abschnitts-Überschriften („Am Übergang" / „Innerhalb der Phase") und eine aufklappbare Tabelle mit **laufendem Zwischenstand**. Anlass war, dass die bisherige Darstellung faktisch nicht lesbar war die Zahlen waren korrekt, die Grafik nicht. (2) Der Restposten beider Brücken wird bei Abweichung neu als **Fehlermeldung** ausgewiesen statt als beiläufige „Rundungsdifferenz"; eine nicht aufgehende Zerlegung ist ein Rechenfehler und kein Schönheitsproblem. (3) **Verkaufspreis einer Immobilie** wird beim Wechsel auf „Verkaufen" neu mit dem **modellierten Verkehrswert** vorbelegt; der Dialog weist Verkehrswert und Abweichung aus und warnt ab 10 % Differenz (Kap. 3.5.8, 9.22). Damit fällt auf, wenn angenommene Wertsteigerung und erwarteter Verkaufspreis nicht zusammenpassen. (4) Der Tornado erklärt neu **Nullbalken** statt sie stumm zu zeigen insbesondere den Fall, dass die Immobilien-Wertsteigerung bei einem Verkauf nachweislich wirkungslos ist (`ineffectiveReason`, Kap. 4.13.5). Neue Kapitel 3.5.8, 4.13.5, 9.22; 11 Tests ergänzt (92 → 103), darunter die Invariante `residual === 0` über sieben Plankonstellationen. Keine DB-Änderung, keine Änderung an der Berechnung. |
| 0.11 | 2026-07-18 | Claude (Opus 4.8) | **Detailansichten (Roadmap Nr. 43)** und **vollständige Offenlegung der Berechnungslogiken (Roadmap Nr. 41)**. (1) Neue **Systemparameter-Ansicht** in der Seitenleiste: alle fest hinterlegten Grössen mit Wert, Bedeutung, Herleitung, Quelle und Stand als strukturierte Daten aus `constants.ts`, also aus derselben Quelle, aus der gerechnet wird. (2) **Nur-Lese-Detailansicht** je Element und je Lebensphase über ein Expand-Icon: Element mit Verlaufsgrafik über **alle Planjahre** (dafür führt `computePlan` neu `ElementPhaseComputed.yearly` je Element mit), Phase mit Vermögensaufteilung und **zwei Wasserfällen**. (3) Die **Wasserfälle** sind bewusst getrennt: Der Vermögens-Wasserfall zeigt nur echte Zu- und Abgänge (Quote, Kapitalerträge, Wertsteigerung, PK-Beiträge, Steuern, Verrentung, Einmalposten); Sparraten, Amortisationen und Investitionen sind **Umbuchungen** und erscheinen ausschliesslich im Cash-Wasserfall als Vermögensabgang gezeichnet würden sie einen Verlust vortäuschen, den es nicht gibt. Neue Strukturen `WealthBridge` / `CashBridge` inkl. Restposten als Kontrollgrösse. (4) **Rechenweg-Protokoll**: `computePlan(plan, sample?, { explain })` protokolliert die Schritte, die es ohnehin ausführt Formel, eingesetzte Zahlen, Ergebnis und Hinweis auf geltende Vereinfachungen. Abdeckung über **alle** Ebenen (Element je Phase, Element je Übergang, Phasen-Kennzahlen, Plan-Ebene). Standardmässig aus, damit die Monte-Carlo-Simulation unberührt bleibt. Jeder Rechenweg verlinkt in das passende Kapitel dieser Spezifikation; ein Test prüft, dass alle Verweise eine existierende Überschrift treffen. Neue Kapitel 3.6.7, 3.6.8, 4.14, 9.20, 9.21; 12 Tests ergänzt (80 → 92). Keine DB-Änderung; die 43 Golden Tests laufen unverändert. | | 0.11 | 2026-07-18 | Claude (Opus 4.8) | **Detailansichten (Roadmap Nr. 43)** und **vollständige Offenlegung der Berechnungslogiken (Roadmap Nr. 41)**. (1) Neue **Systemparameter-Ansicht** in der Seitenleiste: alle fest hinterlegten Grössen mit Wert, Bedeutung, Herleitung, Quelle und Stand als strukturierte Daten aus `constants.ts`, also aus derselben Quelle, aus der gerechnet wird. (2) **Nur-Lese-Detailansicht** je Element und je Lebensphase über ein Expand-Icon: Element mit Verlaufsgrafik über **alle Planjahre** (dafür führt `computePlan` neu `ElementPhaseComputed.yearly` je Element mit), Phase mit Vermögensaufteilung und **zwei Wasserfällen**. (3) Die **Wasserfälle** sind bewusst getrennt: Der Vermögens-Wasserfall zeigt nur echte Zu- und Abgänge (Quote, Kapitalerträge, Wertsteigerung, PK-Beiträge, Steuern, Verrentung, Einmalposten); Sparraten, Amortisationen und Investitionen sind **Umbuchungen** und erscheinen ausschliesslich im Cash-Wasserfall als Vermögensabgang gezeichnet würden sie einen Verlust vortäuschen, den es nicht gibt. Neue Strukturen `WealthBridge` / `CashBridge` inkl. Restposten als Kontrollgrösse. (4) **Rechenweg-Protokoll**: `computePlan(plan, sample?, { explain })` protokolliert die Schritte, die es ohnehin ausführt Formel, eingesetzte Zahlen, Ergebnis und Hinweis auf geltende Vereinfachungen. Abdeckung über **alle** Ebenen (Element je Phase, Element je Übergang, Phasen-Kennzahlen, Plan-Ebene). Standardmässig aus, damit die Monte-Carlo-Simulation unberührt bleibt. Jeder Rechenweg verlinkt in das passende Kapitel dieser Spezifikation; ein Test prüft, dass alle Verweise eine existierende Überschrift treffen. Neue Kapitel 3.6.7, 3.6.8, 4.14, 9.20, 9.21; 12 Tests ergänzt (80 → 92). Keine DB-Änderung; die 43 Golden Tests laufen unverändert. |
| 0.10 | 2026-07-18 | Claude (Opus 4.8) | **Szenario-Vergleich in der Monte-Carlo-Simulation** und **Sensitivitätsanalyse / Tornado** (Roadmap Nr. 20). (1) Die MC-Simulation rechnet neu **mehrere Szenarien desselben Plans in einem Lauf**. Die historischen Annahmen werden dabei nur **einmal je logischem Element** erfasst die Zuordnung über die Herkunfts-Kette `sourceElementId`, dieselbe Grundlage wie beim Diff (neue Funktionen `resolveRootElementId`, `buildElementGroups`, `paramsForScenario`, `runMonteCarloMulti`). Alle Szenarien laufen mit **demselben Seed** (Common Random Numbers), damit Unterschiede strukturell und nicht zufällig sind. Der **Zielbetrag bleibt szenario-eigen** (vorbelegt mit dem jeweils geplanten Endvermögen) nur so misst die Erfolgswahrscheinlichkeit, wie oft ein Szenario sein *eigenes* Versprechen hält. Ergebnis als Vergleichstabelle plus Median-Linien je Szenario; bei einem einzelnen Szenario unverändert der bisherige Fächer. (2) Neuer Bereich **Einflussfaktoren** (eigener Button, eigener Dialog) mit einem **Tornado-Chart** nach dem One-at-a-time-Verfahren: neues reines Modul `sensitivity.ts` mit sieben Treibern, je Treiber an-/abwählbar und mit **pflichtiger, frei definierbarer Bandbreite ohne Default**. Das **Pensionsalter ist bewusst nicht enthalten** (Begründung: 9.18). Neue Kapitel 3.6.6, 4.12.6, 4.13, 9.18; 20 Tests ergänzt (60 → 80). Keine DB-Änderung, keine Verhaltensänderung bestehender Pläne. **Ausserdem vier Dokumentationsfehler korrigiert:** Kap. 1.2 nannte noch Monte-Carlo, Hypothekarzinsen und Immobilien-Wertsteigerung als nicht umgesetzt (seit 0.5/0.7 vorhanden); Kap. 4.6.5 endete mit einem widersprüchlichen Restsatz zur fehlenden Wertsteigerung; Kap. 5.2/5.3/5.5.2/8.1 waren bei Migrationszahl, Dateiliste, Komponentenliste und Testzahlen veraltet; der Glossar-Eintrag „Szenario" beschrieb noch das Modell vor V6. | | 0.10 | 2026-07-18 | Claude (Opus 4.8) | **Szenario-Vergleich in der Monte-Carlo-Simulation** und **Sensitivitätsanalyse / Tornado** (Roadmap Nr. 20). (1) Die MC-Simulation rechnet neu **mehrere Szenarien desselben Plans in einem Lauf**. Die historischen Annahmen werden dabei nur **einmal je logischem Element** erfasst die Zuordnung über die Herkunfts-Kette `sourceElementId`, dieselbe Grundlage wie beim Diff (neue Funktionen `resolveRootElementId`, `buildElementGroups`, `paramsForScenario`, `runMonteCarloMulti`). Alle Szenarien laufen mit **demselben Seed** (Common Random Numbers), damit Unterschiede strukturell und nicht zufällig sind. Der **Zielbetrag bleibt szenario-eigen** (vorbelegt mit dem jeweils geplanten Endvermögen) nur so misst die Erfolgswahrscheinlichkeit, wie oft ein Szenario sein *eigenes* Versprechen hält. Ergebnis als Vergleichstabelle plus Median-Linien je Szenario; bei einem einzelnen Szenario unverändert der bisherige Fächer. (2) Neuer Bereich **Einflussfaktoren** (eigener Button, eigener Dialog) mit einem **Tornado-Chart** nach dem One-at-a-time-Verfahren: neues reines Modul `sensitivity.ts` mit sieben Treibern, je Treiber an-/abwählbar und mit **pflichtiger, frei definierbarer Bandbreite ohne Default**. Das **Pensionsalter ist bewusst nicht enthalten** (Begründung: 9.18). Neue Kapitel 3.6.6, 4.12.6, 4.13, 9.18; 20 Tests ergänzt (60 → 80). Keine DB-Änderung, keine Verhaltensänderung bestehender Pläne. **Ausserdem vier Dokumentationsfehler korrigiert:** Kap. 1.2 nannte noch Monte-Carlo, Hypothekarzinsen und Immobilien-Wertsteigerung als nicht umgesetzt (seit 0.5/0.7 vorhanden); Kap. 4.6.5 endete mit einem widersprüchlichen Restsatz zur fehlenden Wertsteigerung; Kap. 5.2/5.3/5.5.2/8.1 waren bei Migrationszahl, Dateiliste, Komponentenliste und Testzahlen veraltet; der Glossar-Eintrag „Szenario" beschrieb noch das Modell vor V6. |
| 0.9 | 2026-07-18 | Claude (Opus 4.8) | **UI-Umbau und Planstart.** (1) Die Grafiken liegen neu im eigenen Bereich **Grafiken** (Dialog, Button oben) statt unter der Matrix. (2) Kennzahl **Geschätzter Nachlass** entfernt sie war identisch mit dem nominalen Endvermögen. (3) **Monte-Carlo-Button** nach oben zu den Szenario-Aktionen verschoben. (4) Neues Profilfeld **Planstart (Jahr)** (`Scenario.startYear`, Migration; reine Anzeige, Berechnung bleibt in relativen Jahren) erscheint auf der Zeitachse. (5) Zeitachse zeigt neu die **Lebensphasen als Segmente** (Breite = Dauer, Einfärbung nach Phasentyp, Jahresspanne). (6) **Lebensphase bearbeiten** neu als Popup statt Panel unter der Tabelle. (7) **Vermögensverlauf** über **alle Jahre** statt nur über die Phasengrenzen dafür führt `computePlan` das Vermögen neu pro Jahr mit (`YearPoint.wealthNominal/wealthReal`). Zwei Tests ergänzt (58 → 60). | | 0.9 | 2026-07-18 | Claude (Opus 4.8) | **UI-Umbau und Planstart.** (1) Die Grafiken liegen neu im eigenen Bereich **Grafiken** (Dialog, Button oben) statt unter der Matrix. (2) Kennzahl **Geschätzter Nachlass** entfernt sie war identisch mit dem nominalen Endvermögen. (3) **Monte-Carlo-Button** nach oben zu den Szenario-Aktionen verschoben. (4) Neues Profilfeld **Planstart (Jahr)** (`Scenario.startYear`, Migration; reine Anzeige, Berechnung bleibt in relativen Jahren) erscheint auf der Zeitachse. (5) Zeitachse zeigt neu die **Lebensphasen als Segmente** (Breite = Dauer, Einfärbung nach Phasentyp, Jahresspanne). (6) **Lebensphase bearbeiten** neu als Popup statt Panel unter der Tabelle. (7) **Vermögensverlauf** über **alle Jahre** statt nur über die Phasengrenzen dafür führt `computePlan` das Vermögen neu pro Jahr mit (`YearPoint.wealthNominal/wealthReal`). Zwei Tests ergänzt (58 → 60). |
@@ -733,6 +734,32 @@ beim Anlegen eines Elements (dort gibt es noch nichts zu überschreiben).
Referenz: `src/components/ElementDetail.tsx` (`CarryWarning`). Referenz: `src/components/ElementDetail.tsx` (`CarryWarning`).
### 3.5.8 Verkaufspreis und modellierter Verkehrswert
Das Modell führt zwei Immobilienwerte getrennt: den **Verkehrswert**, der mit `valueGrowth`
wächst, und den **ursprünglichen Kaufpreis** als Basis der Grundstückgewinnsteuer
([4.6.5](#465-real_estate-immobilie)). Beim Verkauf zählt jedoch ausschliesslich der vom
Benutzer **erfasste Verkaufspreis** ([4.9.4](#494-real_estate)).
Daraus ergab sich eine stille Inkonsistenz: Man konnte 2 % jährliche Wertsteigerung annehmen
und die Immobilie trotzdem zum Kaufpreis verkaufen, ohne dass das Tool widersprach.
Deshalb gilt seit Version 0.12:
- Beim Wechsel auf **Verkaufen** wird der Verkaufspreis mit dem **modellierten Verkehrswert
am Phasenende** vorbelegt aber nur, wenn noch keiner erfasst ist (bestehende Pläne bleiben
unverändert).
- Der Dialog zeigt den Verkehrswert daneben read-only an und beziffert die **Abweichung** in
Franken und Prozent.
- Ab **10 %** Abweichung wird der Hinweis rot abgesetzt, mit der Aufforderung zu prüfen, ob
Wertsteigerungsannahme und erwarteter Verkaufspreis zusammenpassen.
Der erfasste Preis bleibt **massgebend** die Vorbelegung ist eine Hilfe, keine Bevormundung.
Ein bewusst abweichender Preis (Notverkauf, Liebhaberpreis, Verkauf an Nachkommen) bleibt
möglich. Die Berechnung ist unverändert.
Referenz: `src/components/ElementDetail.tsx` (`ElementTransitionFields`, `REAL_ESTATE`).
## 3.6 Auswertung und Visualisierung ## 3.6 Auswertung und Visualisierung
### 3.6.1 Anzeigemodus nominal / beide / real ### 3.6.1 Anzeigemodus nominal / beide / real
@@ -1806,6 +1833,26 @@ spannt deshalb über `min…max`; welche Eingabe zu welchem Ende gehört, zeigt
Referenz: `src/lib/sensitivity.ts`, `src/components/SensitivityDialog.tsx`. Referenz: `src/lib/sensitivity.ts`, `src/components/SensitivityDialog.tsx`.
### 4.13.5 Wirkungslose Treiber werden erklärt
Ein Balken mit Spannweite 0 ohne Erklärung ist die schlechteste Antwort der Benutzer hält
ihn für einen Fehler. `computeTornado` hängt deshalb an jeden Nullbalken eine Begründung
(`ineffectiveReason`).
Der wichtigste Fall ist die **Immobilien-Wertsteigerung bei einem Verkauf**. Der Verkaufserlös
ist `Verkaufspreis Hypothek Grundstückgewinnsteuer` und hängt damit am erfassten
Verkaufspreis, **nicht** am modellierten Verkehrswert. Wird die Immobilie vor Planende
verkauft, wird die aufgelaufene Wertsteigerung an dieser Stelle verworfen der Treiber kann
das Endvermögen dann rechnerisch nicht mehr beeinflussen.
Erkannt wird das daran, dass in der letzten Phase **alle** `REAL_ESTATE`-Elemente den Status
`SOLD` tragen. Andernfalls greift ein allgemeiner Hinweis. Durch Tests abgedeckt: gehalten →
Spannweite > 0 ohne Hinweis; verkauft → Spannweite 0 mit Begründung.
Verwandt: Der Verkaufspreis-Abgleich im Übergangs-Dialog ([3.5.8](#358-verkaufspreis-und-modellierter-verkehrswert))
setzt an derselben Stelle an, nur früher er verhindert, dass die Annahmen überhaupt
auseinanderlaufen.
## 4.14 Verlaufswerte, Brücken und Rechenwege ## 4.14 Verlaufswerte, Brücken und Rechenwege
Dieses Kapitel beschreibt, was `computePlan` über die reinen Ergebniswerte hinaus mitführt Dieses Kapitel beschreibt, was `computePlan` über die reinen Ergebniswerte hinaus mitführt
@@ -1871,10 +1918,38 @@ Cash Ende Vorphase (Phase 1: Cash-Anfangswert)
``` ```
Beide Strukturen führen einen **Restposten** (`residual`) mit: die Differenz zwischen dem Beide Strukturen führen einen **Restposten** (`residual`) mit: die Differenz zwischen dem
gerechneten Endwert und der Summe der Summanden. Er entsteht nur durch die Rundung der einzelnen gerechneten Endwert und der Summe der Summanden. Er ist die eingebaute Selbstkontrolle ist die
Posten auf ganze Franken und liegt im einstelligen Bereich; ein grösserer Wert wäre ein Hinweis Zerlegung vollständig und richtig, muss er **exakt 0** sein.
auf eine unvollständige Zerlegung. Zwei Tests prüfen ihn über einen Plan, der alle Element-Arten
und Übergangs-Entscheide enthält. `src/lib/bridges.test.ts` nagelt das über **sieben Plankonstellationen** fest (Ansparen mit 3a
und Schuldentilgung, Pensionierung mit Verrentung und 3a-Bezug, PK-Kapitalbezug, Immobilie
gehalten, Immobilie verkauft, einmalige Sonderein-/ausgaben, Sofort-Tilgung mit
Sonderamortisation) je Phase für beide Brücken, zusätzlich der Abgleich der Kontrollpunkte
gegen `startWealthNominal` / `endWealthNominal` / `cashStart` / `cashEnd`.
Im UI wird ein Restposten über 2 Franken als **Fehlermeldung** ausgewiesen, nicht als beiläufige
Rundungsnotiz: Eine Brücke, die nicht aufgeht, ist ein Rechenfehler und kein Darstellungsproblem.
### 4.14.2.1 Darstellung der Wasserfälle
Die Wasserfälle werden **nicht mit Recharts** gezeichnet. Ein Wasserfall lebt von drei Dingen,
die dort nicht ohne Weiteres zu bekommen sind:
- **Verbindungslinien** zwischen den Balken ohne sie sieht man nicht, dass jeder Balken dort
ansetzt, wo der vorherige aufhört, und die Grafik zerfällt in unverbundene Rechtecke.
- **Wertbeschriftung** an jedem Schritt, statt Beträge aus der Achse zu schätzen.
- **Unterscheidung von Zwischenstand und Veränderung.** Ein Zwischenstand („Vermögen
Phasenbeginn") ist ein absoluter Wert ab Null, eine Veränderung („Kapitalerträge") setzt auf dem
laufenden Saldo auf. Sehen beide gleich aus, ist die Grafik nicht lesbar.
Die Darstellung ist deshalb eine eigene HTML/CSS-Konstruktion und **liegend** statt stehend die
Beschriftungen sind lang und müssten stehend gedreht werden; liegend ist es ausserdem konsistent
zum Tornado. Abschnitts-Überschriften trennen „Am Übergang in diese Phase" von „Innerhalb der
Phase". Posten mit Wert 0 werden gar nicht erst gezeichnet.
Darunter steht aufklappbar eine **Tabelle mit laufendem Zwischenstand**. Bei sieben bis zwölf
Schritten mit stark unterschiedlichen Grössenordnungen ist sie der Grafik schlicht überlegen
die Grafik zeigt das Verhältnis, die Tabelle die Zahl.
### 4.14.3 Rechenweg-Protokoll ### 4.14.3 Rechenweg-Protokoll
@@ -2487,12 +2562,13 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests
| Datei | Tests | Schwerpunkt | | Datei | Tests | Schwerpunkt |
|---|---|---| |---|---|---|
| `calculations.test.ts` | 43 | AHV-Rentenformel, Immobilie, Teilverkauf, Sonderamortisation, AHV einkommensabhängig, „V5 Golden Tests" | | `calculations.test.ts` | 43 | AHV-Rentenformel, Immobilie, Teilverkauf, Sonderamortisation, AHV einkommensabhängig, „V5 Golden Tests" |
| `sensitivity.test.ts` | 14 | Treiber-Transformationen (Reinheit, Einheiten, Kappung), Verfügbarkeit, Tornado-Sortierung und -Richtung | | `sensitivity.test.ts` | 15 | Treiber-Transformationen (Reinheit, Einheiten, Kappung), Verfügbarkeit, Tornado-Sortierung und -Richtung, Erklärung wirkungsloser Treiber |
| `explain.test.ts` | 12 | Verlaufswerte je Element, Vollständigkeit beider Wasserfall-Zerlegungen, Rechenweg-Protokoll, Gültigkeit der Spezifikations-Verweise | | `explain.test.ts` | 12 | Verlaufswerte je Element, Vollständigkeit beider Wasserfall-Zerlegungen, Rechenweg-Protokoll, Gültigkeit der Spezifikations-Verweise |
| `montecarlo.test.ts` | 13 | Determinismus, Volatilität/Vol-Drag, Böden, Reproduzierbarkeit; Element-Gruppierung und Szenario-Vergleich | | `montecarlo.test.ts` | 13 | Determinismus, Volatilität/Vol-Drag, Böden, Reproduzierbarkeit; Element-Gruppierung und Szenario-Vergleich |
| `bridges.test.ts` | 10 | Vermögens- und Cash-Brücke gehen über sieben Plankonstellationen ohne Restgrösse auf; Umbuchungen bleiben aus der Vermögensbrücke heraus |
| `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario | | `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario |
| `migrations.test.ts` | 1 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein | | `migrations.test.ts` | 1 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein |
| **Total** | **92** | | | **Total** | **103** | |
## 8.2 Testfälle ## 8.2 Testfälle
@@ -2528,6 +2604,11 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests
| **Tornado: Lebensdauer** | verschiebt nur die letzte Phase; Kappung bei mindestens 1 Jahr | | **Tornado: Lebensdauer** | verschiebt nur die letzte Phase; Kappung bei mindestens 1 Jahr |
| **Tornado: Verfügbarkeit** | Treiber ohne passende Elemente werden ausgeblendet (z. B. Immobilien-Wertsteigerung ohne Immobilie) | | **Tornado: Verfügbarkeit** | Treiber ohne passende Elemente werden ausgeblendet (z. B. Immobilien-Wertsteigerung ohne Immobilie) |
| **Tornado: Sortierung/Richtung** | absteigend nach Spannweite; Treiber ohne Bandbreite hat Spannweite 0 und steht zuunterst; höhere Ausgaben → tieferes, höhere Rendite → höheres Endvermögen | | **Tornado: Sortierung/Richtung** | absteigend nach Spannweite; Treiber ohne Bandbreite hat Spannweite 0 und steht zuunterst; höhere Ausgaben → tieferes, höhere Rendite → höheres Endvermögen |
| **Tornado: wirkungslose Treiber** | jeder Nullbalken trägt eine Begründung; verkaufte Immobilie → Wertsteigerung wirkungslos mit konkretem Hinweis, gehaltene Immobilie → Spannweite > 0 ohne Hinweis |
| **Brücken: Restgrösse** | `residual === 0` je Phase für Vermögens- **und** Cash-Brücke über sieben Plankonstellationen (Ansparen, Verrentung, Kapitalbezug, Immobilie gehalten/verkauft, Einmalposten, Sofort-Tilgung mit Sonderamortisation) |
| **Brücken: Kontrollpunkte** | `startWealth`/`endWealth`/`cashStart`/`cashEnd` der Brücken stimmen mit den offiziellen Phasen-Kennzahlen überein |
| **Brücken: Umbuchungen** | Sparraten und Amortisationen erscheinen nur in der Cash-Brücke; die Vermögensänderung erklärt sich exakt aus Quote + Erträgen + Wertsteigerung + PK-Beiträgen |
| **Brücken: Verrentung/Verkauf** | verrentetes PK-Kapital erscheint als Vermögensabgang am Übergang; Verkaufsdifferenz und Grundstückgewinnsteuer nur beim Verkauf, nicht beim Halten |
| **Verlauf: ein Punkt je Jahr** | je aktivem Element genau `durationYears` Punkte pro Phase | | **Verlauf: ein Punkt je Jahr** | je aktivem Element genau `durationYears` Punkte pro Phase |
| **Verlauf: Konvexität** | 200'000 @ 5 % + 10'000 Sparbeitrag: Jahr 1 = 220'000, und die **Jahreszuwächse wachsen** eine Gerade zwischen den Phasengrenzen hätte konstante Zuwächse | | **Verlauf: Konvexität** | 200'000 @ 5 % + 10'000 Sparbeitrag: Jahr 1 = 220'000, und die **Jahreszuwächse wachsen** eine Gerade zwischen den Phasengrenzen hätte konstante Zuwächse |
| **Verlauf: Immobilie** | Verkehrswert, Restschuld und Eigenkapital werden getrennt geführt; Eigenkapital = Verkehrswert Restschuld | | **Verlauf: Immobilie** | Verkehrswert, Restschuld und Eigenkapital werden getrennt geführt; Eigenkapital = Verkehrswert Restschuld |
@@ -2835,6 +2916,24 @@ Konkret uneindeutig sind zwei Fälle:
Der Restposten (`residual`) ist die Kontrollgrösse dafür, dass die gewählte Zerlegung wenigstens Der Restposten (`residual`) ist die Kontrollgrösse dafür, dass die gewählte Zerlegung wenigstens
**vollständig** ist nicht dafür, dass sie die einzig sinnvolle ist. **vollständig** ist nicht dafür, dass sie die einzig sinnvolle ist.
## 9.22 Verkaufspreis und Verkehrswert bleiben unabhängig
Seit Version 0.12 wird der Verkaufspreis mit dem modellierten Verkehrswert vorbelegt und die
Abweichung ausgewiesen ([3.5.8](#358-verkaufspreis-und-modellierter-verkehrswert)). Die beiden
Grössen bleiben aber **entkoppelt** das Tool erzwingt keine Konsistenz.
Das ist bewusst so: Ein Verkauf unter dem Verkehrswert ist ein realer Fall (Notverkauf, Verkauf
an Nachkommen, Liebhaberobjekt ohne Markt). Eine Zwangskopplung würde diese Fälle unmöglich
machen. Der Preis dafür ist, dass eine unplausible Kombination weiterhin eingebbar bleibt neu
aber nicht mehr unbemerkt.
Eine Folge bleibt bestehen und ist nicht offensichtlich: **Wird die Immobilie vor Planende
verkauft, hat die angenommene Wertsteigerung keinen Einfluss mehr auf das Endvermögen.** Der
Erlös folgt allein dem erfassten Verkaufspreis. Im Tornado führt das zu einem Nullbalken, der
seit 0.12 erklärt wird ([4.13.5](#4135-wirkungslose-treiber-werden-erklärt)); in der
Vermögensbrücke erscheint stattdessen die Differenz `Verkaufspreis Verkehrswert` als eigener
Posten.
--- ---
# 10. Glossar # 10. Glossar
@@ -2877,4 +2976,4 @@ Der Restposten (`residual`) ist die Kontrollgrösse dafür, dass die gewählte Z
--- ---
*Ende der Spezifikation v0.11* *Ende der Spezifikation v0.12*
+166 -48
View File
@@ -5,7 +5,6 @@ import {
Bar, Bar,
BarChart, BarChart,
CartesianGrid, CartesianGrid,
Cell,
Legend, Legend,
Line, Line,
LineChart, LineChart,
@@ -29,77 +28,197 @@ import type {
} from "@/lib/calculations"; } from "@/lib/calculations";
// --- Wasserfall --------------------------------------------------------------------------- // --- Wasserfall ---------------------------------------------------------------------------
// Recharts kennt keinen Wasserfall: Er entsteht aus zwei gestapelten Balken -- einem // Bewusst NICHT mit Recharts, sondern als eigene HTML/CSS-Darstellung. Ein Wasserfall lebt
// unsichtbaren Sockel und dem sichtbaren Delta darueber. // von drei Dingen, die Recharts hier nicht hergibt: Verbindungslinien zwischen den Balken
// (ohne sie sieht man nicht, dass jeder Balken dort ansetzt, wo der vorherige aufhoert),
// Wertbeschriftung an jedem Balken, und eine klare optische Trennung von Zwischenstaenden
// und Veraenderungen.
//
// Liegend statt stehend: Die Beschriftungen sind lang ("Wertsteigerung Immobilie"), stehend
// muessten sie gedreht werden. Liegend ist es ausserdem konsistent zum Tornado.
interface WaterfallItem { interface WaterfallItem {
label: string; label: string;
value: number; value: number;
total?: boolean; // Zwischen-/Endsumme: startet bei 0 statt beim laufenden Saldo total?: boolean; // Zwischen-/Endsumme: absoluter Stand statt Veraenderung
section?: string; // optionale Abschnitts-Ueberschrift VOR diesem Eintrag
} }
function waterfallData(items: WaterfallItem[]) { interface WaterfallRow {
label: string;
section?: string;
from: number;
to: number;
value: number;
running: number; // Stand NACH diesem Schritt
kind: "total" | "pos" | "neg";
}
function waterfallRows(items: WaterfallItem[]): WaterfallRow[] {
let running = 0; let running = 0;
return items.map((it) => { return items.map((it) => {
if (it.total) { if (it.total) {
running = it.value; running = it.value;
return { label: it.label, base: 0, delta: Math.abs(it.value), value: it.value, kind: "total" as const }; return { label: it.label, section: it.section, from: 0, to: it.value, value: it.value, running, kind: "total" as const };
} }
const start = running; const from = running;
running += it.value; running += it.value;
return { return {
label: it.label, label: it.label,
base: Math.min(start, running), section: it.section,
delta: Math.abs(it.value), from,
to: running,
value: it.value, value: it.value,
running,
kind: (it.value >= 0 ? "pos" : "neg") as "pos" | "neg", kind: (it.value >= 0 ? "pos" : "neg") as "pos" | "neg",
}; };
}); });
} }
const WF_COLOR = { total: "var(--accent)", pos: "#16a34a", neg: "#dc2626" }; const WF_FILL = { total: "var(--accent)", pos: "#16a34a", neg: "#dc2626" };
const ROW_H = 34;
const BAR_H = 20;
function Waterfall({ items }: { items: WaterfallItem[] }) {
const rows = useMemo(() => waterfallRows(items), [items]);
if (rows.length === 0) return null;
const lo = Math.min(0, ...rows.map((r) => Math.min(r.from, r.to)));
const hi = Math.max(0, ...rows.map((r) => Math.max(r.from, r.to)));
const span = hi - lo || 1;
const pos = (v: number) => ((v - lo) / span) * 100;
function Waterfall({ items, height = 300 }: { items: WaterfallItem[]; height?: number }) {
const data = useMemo(() => waterfallData(items), [items]);
if (data.length === 0) return null;
return ( return (
<div className="w-full" style={{ height }}> <div>
<ResponsiveContainer width="100%" height="100%"> <div className="overflow-hidden rounded-xl border border-border">
<BarChart data={data} margin={{ top: 8, right: 16, left: 8, bottom: 60 }}> {rows.map((r, i) => {
<CartesianGrid strokeDasharray="3 3" className="stroke-border" /> const left = pos(Math.min(r.from, r.to));
<XAxis dataKey="label" tick={{ fontSize: 10 }} interval={0} angle={-32} textAnchor="end" height={70} /> const width = Math.max(0.4, Math.abs(pos(r.to) - pos(r.from)));
<YAxis const isLast = i === rows.length - 1;
tick={{ fontSize: 11 }} return (
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} <div key={`${r.label}-${i}`}>
/> {r.section && (
<Tooltip <div className="border-b border-border bg-surface-2 px-3 py-1 text-[10px] font-semibold uppercase tracking-wide text-faint">
cursor={{ fill: "var(--surface-2)" }} {r.section}
formatter={(_v, _n, p) => [formatChf(p?.payload?.value ?? 0), p?.payload?.label ?? ""]}
labelFormatter={() => ""}
/>
<Bar dataKey="base" stackId="w" fill="transparent" isAnimationActive={false} />
<Bar dataKey="delta" stackId="w" isAnimationActive={false} radius={[2, 2, 0, 0]}>
{data.map((d, i) => (
<Cell key={i} fill={WF_COLOR[d.kind]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div> </div>
)}
<div className={`flex items-stretch ${r.kind === "total" ? "bg-surface-2" : ""}`}>
<div
className={`w-44 shrink-0 border-r border-border px-3 py-2 text-[11px] leading-tight ${
r.kind === "total" ? "font-semibold text-fg" : "text-muted"
}`}
>
{r.label}
</div>
<div className="relative min-w-0 flex-1" style={{ height: ROW_H }}>
{/* Nulllinie */}
<div
className="absolute top-0 h-full border-l border-dashed border-border"
style={{ left: `${pos(0)}%` }}
/>
{/* Balken */}
<div
className="absolute rounded-sm"
style={{
left: `${left}%`,
width: `${width}%`,
top: (ROW_H - BAR_H) / 2,
height: BAR_H,
backgroundColor: WF_FILL[r.kind],
opacity: r.kind === "total" ? 0.85 : 1,
}}
title={`${r.label}: ${formatChf(r.value)}`}
/>
{/* Verbindungslinie zum naechsten Balken: auf dem Stand NACH diesem Schritt */}
{!isLast && (
<div
className="absolute border-l border-dotted border-faint"
style={{
left: `${pos(r.to)}%`,
top: (ROW_H + BAR_H) / 2,
height: (ROW_H - BAR_H) / 2,
}}
/>
)}
</div>
<div
className={`w-28 shrink-0 border-l border-border px-3 py-2 text-right text-[11px] tabular-nums ${
r.kind === "total" ? "font-semibold text-fg" : r.kind === "neg" ? "text-danger" : "text-success"
}`}
>
{r.kind === "total" ? formatChf(r.value) : `${r.value >= 0 ? "+" : ""}${formatChf(Math.abs(r.value))}`}
</div>
</div>
</div>
);
})}
</div>
{/* Zahlen mit laufendem Zwischenstand -- bei stark unterschiedlichen Groessenordnungen
ist die Tabelle der Grafik ueberlegen. */}
<details className="mt-2">
<summary className="cursor-pointer text-[11px] text-muted hover:text-fg">Zahlen mit Zwischenstand anzeigen</summary>
<div className="mt-2 overflow-x-auto rounded-lg border border-border">
<table className="w-full border-collapse text-xs">
<thead>
<tr className="bg-surface-2 text-[10px] uppercase tracking-wide text-faint">
<th className="px-3 py-1.5 text-left font-semibold">Schritt</th>
<th className="px-3 py-1.5 text-right font-semibold">Betrag</th>
<th className="px-3 py-1.5 text-right font-semibold">Zwischenstand</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i} className={`border-t border-border ${r.kind === "total" ? "bg-surface-2 font-semibold" : ""}`}>
<td className="px-3 py-1.5 text-fg">{r.label}</td>
<td
className={`px-3 py-1.5 text-right tabular-nums ${
r.kind === "total" ? "text-faint" : r.kind === "neg" ? "text-danger" : "text-success"
}`}
>
{r.kind === "total" ? "—" : `${r.value >= 0 ? "+" : ""}${formatChf(Math.abs(r.value))}`}
</td>
<td className="px-3 py-1.5 text-right tabular-nums text-fg">{formatChf(r.running)}</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
</div>
);
}
// Kontrollgroesse: Ist die Zerlegung vollstaendig, muss die Differenz zwischen Endwert und
// der Summe der Schritte 0 sein. Sichtbar machen statt verstecken -- ein Wasserfall, der
// nicht aufgeht, ist ein Fehler und kein Schoenheitsproblem.
function ResidualNote({ residual }: { residual: number }) {
if (Math.abs(residual) <= 2) return null;
return (
<p className="mt-2 rounded-lg border border-danger bg-danger-soft px-3 py-2 text-[11px] text-danger">
<strong>Die Zerlegung geht nicht auf.</strong> Nicht zugeordnete Differenz: {formatChf(residual)}. Bitte melden
das ist ein Fehler in der Berechnung, nicht in der Darstellung.
</p>
); );
} }
function bridgeItems(w: WealthBridge, isFirst: boolean): WaterfallItem[] { function bridgeItems(w: WealthBridge, isFirst: boolean): WaterfallItem[] {
const items: WaterfallItem[] = []; const items: WaterfallItem[] = [];
if (!isFirst) { if (!isFirst) {
items.push({ label: "Vermögen Ende Vorphase", value: w.openingWealth, total: true }); items.push({ label: "Vermögen Ende Vorphase", value: w.openingWealth, total: true, section: "Am Übergang in diese Phase" });
if (w.oneOffInflow) items.push({ label: "Einmaliger Zufluss", value: w.oneOffInflow }); if (w.oneOffInflow) items.push({ label: "Einmaliger Zufluss", value: w.oneOffInflow });
if (w.oneOffOutflow) items.push({ label: "Einmalige Kosten", value: -w.oneOffOutflow }); if (w.oneOffOutflow) items.push({ label: "Einmalige Kosten", value: -w.oneOffOutflow });
if (w.transitionTax) items.push({ label: "Steuern am Übergang", value: -w.transitionTax }); if (w.transitionTax) items.push({ label: "Steuern am Übergang", value: -w.transitionTax });
if (w.pensionConversion) items.push({ label: "PK verrentet", value: -w.pensionConversion }); if (w.pensionConversion) items.push({ label: "PK in Rente umgewandelt", value: -w.pensionConversion });
if (w.saleGainLoss) items.push({ label: "Verkaufsdifferenz", value: w.saleGainLoss }); if (w.saleGainLoss) items.push({ label: "Verkaufsdifferenz", value: w.saleGainLoss });
} }
items.push({ label: "Vermögen Phasenbeginn", value: w.startWealth, total: true }); items.push({
label: "Vermögen Phasenbeginn",
value: w.startWealth,
total: true,
section: isFirst ? undefined : "Innerhalb der Phase",
});
if (w.quotaTotal) items.push({ label: "Spar-/Verzehrquote", value: w.quotaTotal }); if (w.quotaTotal) items.push({ label: "Spar-/Verzehrquote", value: w.quotaTotal });
if (w.investmentReturn) items.push({ label: "Kapitalerträge", value: w.investmentReturn }); if (w.investmentReturn) items.push({ label: "Kapitalerträge", value: w.investmentReturn });
if (w.propertyAppreciation) items.push({ label: "Wertsteigerung Immobilie", value: w.propertyAppreciation }); if (w.propertyAppreciation) items.push({ label: "Wertsteigerung Immobilie", value: w.propertyAppreciation });
@@ -110,13 +229,18 @@ function bridgeItems(w: WealthBridge, isFirst: boolean): WaterfallItem[] {
function cashItems(c: CashBridge, isFirst: boolean): WaterfallItem[] { function cashItems(c: CashBridge, isFirst: boolean): WaterfallItem[] {
const items: WaterfallItem[] = []; const items: WaterfallItem[] = [];
items.push({ label: isFirst ? "Cash-Anfangswert" : "Cash Ende Vorphase", value: c.openingCash, total: true }); items.push({
label: isFirst ? "Cash-Anfangswert" : "Cash Ende Vorphase",
value: c.openingCash,
total: true,
section: isFirst ? undefined : "Am Übergang in diese Phase",
});
if (c.capitalInflow) items.push({ label: "Kapitalzufluss", value: c.capitalInflow }); if (c.capitalInflow) items.push({ label: "Kapitalzufluss", value: c.capitalInflow });
if (c.oneOffInflow) items.push({ label: "Einmaliger Zufluss", value: c.oneOffInflow }); if (c.oneOffInflow) items.push({ label: "Einmaliger Zufluss", value: c.oneOffInflow });
if (c.immediateRepay) items.push({ label: "Sofort-Tilgung", value: -c.immediateRepay }); if (c.immediateRepay) items.push({ label: "Sofort-Tilgung", value: -c.immediateRepay });
if (c.oneOffOutflow) items.push({ label: "Einmalige Kosten", value: -c.oneOffOutflow }); if (c.oneOffOutflow) items.push({ label: "Einmalige Kosten", value: -c.oneOffOutflow });
if (c.investments) items.push({ label: "Investitionen", value: -c.investments }); if (c.investments) items.push({ label: "Investitionen", value: -c.investments });
items.push({ label: "Cash Phasenbeginn", value: c.cashStart, total: true }); items.push({ label: "Cash Phasenbeginn", value: c.cashStart, total: true, section: "Innerhalb der Phase" });
if (c.quotaTotal) items.push({ label: "Spar-/Verzehrquote", value: c.quotaTotal }); if (c.quotaTotal) items.push({ label: "Spar-/Verzehrquote", value: c.quotaTotal });
if (c.savingRates) items.push({ label: "Sparraten", value: -c.savingRates }); if (c.savingRates) items.push({ label: "Sparraten", value: -c.savingRates });
if (c.debtRates) items.push({ label: "Amort./Tilgung", value: -c.debtRates }); if (c.debtRates) items.push({ label: "Amort./Tilgung", value: -c.debtRates });
@@ -424,11 +548,7 @@ export function PhaseDetailDialog({
verändern als Balken gezeichnet würden sie einen Verlust vortäuschen, den es nicht gibt. verändern als Balken gezeichnet würden sie einen Verlust vortäuschen, den es nicht gibt.
</p> </p>
<Waterfall items={bridgeItems(phase.wealthBridge, isFirst)} /> <Waterfall items={bridgeItems(phase.wealthBridge, isFirst)} />
{Math.abs(phase.wealthBridge.residual) > 2 && ( <ResidualNote residual={phase.wealthBridge.residual} />
<p className="text-[11px] text-faint">
Rundungsdifferenz: {formatChf(phase.wealthBridge.residual)}
</p>
)}
</section> </section>
<section> <section>
@@ -438,9 +558,7 @@ export function PhaseDetailDialog({
wenn sie das Vermögen nicht mindern. wenn sie das Vermögen nicht mindern.
</p> </p>
<Waterfall items={cashItems(phase.cashBridge, isFirst)} /> <Waterfall items={cashItems(phase.cashBridge, isFirst)} />
{Math.abs(phase.cashBridge.residual) > 2 && ( <ResidualNote residual={phase.cashBridge.residual} />
<p className="text-[11px] text-faint">Rundungsdifferenz: {formatChf(phase.cashBridge.residual)}</p>
)}
</section> </section>
</> </>
); );
+52 -2
View File
@@ -34,6 +34,9 @@ export interface CellContext {
derivedStart: number; // fortgeschriebener Basiswert (read-only Anzeige) derivedStart: number; // fortgeschriebener Basiswert (read-only Anzeige)
derivedMortgage: number; // nur Immobilie: fortgeschriebene Resthypothek zu Phasenbeginn derivedMortgage: number; // nur Immobilie: fortgeschriebene Resthypothek zu Phasenbeginn
mortgageEnd: number; // nur Immobilie: Resthypothek am Phasenende (fuer die Sonderamortisation) mortgageEnd: number; // nur Immobilie: Resthypothek am Phasenende (fuer die Sonderamortisation)
// nur Immobilie: modellierter VERKEHRSWERT am Phasenende (Eigenkapital + Resthypothek),
// inkl. aufgelaufener Wertsteigerung. Vorbelegung und Vergleichswert fuer den Verkaufspreis.
propertyValueEnd: number;
deflatorStart: number; // Kaufkraft-Deflator zu Phasenbeginn (real <-> nominal, erstes Jahr) deflatorStart: number; // Kaufkraft-Deflator zu Phasenbeginn (real <-> nominal, erstes Jahr)
// Warnhinweis: Anzahl Phasen NACH dieser (Aenderungen schreiben sich dorthin fort). // Warnhinweis: Anzahl Phasen NACH dieser (Aenderungen schreiben sich dorthin fort).
laterPhaseCount: number; laterPhaseCount: number;
@@ -769,12 +772,24 @@ export function ElementTransitionFields({
return <WithdrawalDecision td={td} setT={setT} max={context.carriedEndValue} label="3a-Bezug (CHF)" />; return <WithdrawalDecision td={td} setT={setT} max={context.carriedEndValue} label="3a-Bezug (CHF)" />;
case "REAL_ESTATE": { case "REAL_ESTATE": {
const decision = td.decision ?? "HOLD"; const decision = td.decision ?? "HOLD";
const marktwert = Math.round(context.propertyValueEnd);
const preis = num(td.salePrice);
// Abweichung zwischen erfasstem Verkaufspreis und modelliertem Verkehrswert. Beide
// Groessen sind unabhaengig erfassbar -- ohne diesen Vergleich koennte man 2 %
// Wertsteigerung annehmen und trotzdem zum Kaufpreis verkaufen, ohne es zu merken.
const abweichung = preis - marktwert;
const abweichungPct = marktwert > 0 ? (abweichung / marktwert) * 100 : 0;
const deutlich = marktwert > 0 && Math.abs(abweichungPct) >= 10;
return ( return (
<> <>
<SelectField <SelectField
label="Entscheidung" label="Entscheidung"
value={decision === "SELL" ? "SELL" : "HOLD"} value={decision === "SELL" ? "SELL" : "HOLD"}
onChange={(v: "HOLD" | "SELL") => setT({ decision: v })} onChange={(v: "HOLD" | "SELL") =>
// Beim Wechsel auf "Verkaufen" den Verkaufspreis mit dem modellierten
// Verkehrswert vorbelegen -- aber nur, wenn noch keiner erfasst ist.
setT(v === "SELL" && td.salePrice === undefined ? { decision: v, salePrice: marktwert } : { decision: v })
}
options={[ options={[
{ value: "HOLD", label: "Halten" }, { value: "HOLD", label: "Halten" },
{ value: "SELL", label: "Verkaufen" }, { value: "SELL", label: "Verkaufen" },
@@ -782,7 +797,42 @@ export function ElementTransitionFields({
/> />
{decision === "SELL" && ( {decision === "SELL" && (
<> <>
<MoneyField label="Verkaufspreis (CHF)" value={num(td.salePrice)} onChange={(v) => setT({ salePrice: v })} /> <MoneyField
label="Verkaufspreis (CHF)"
help="Vorbelegt mit dem modellierten Verkehrswert am Phasenende. Bewusst änderbar der erfasste Preis ist massgebend, nicht der modellierte Wert."
value={preis}
onChange={(v) => setT({ salePrice: v })}
/>
<DerivedField
label="Modellierter Verkehrswert (Phasenende)"
value={marktwert}
help="Kaufpreis zuzüglich der bis hierhin aufgelaufenen Wertsteigerung. Nur zum Vergleich für den Erlös zählt der oben erfasste Verkaufspreis."
/>
{marktwert > 0 && (
<p
className={`col-span-full rounded-lg px-3 py-2 text-xs ${
deutlich ? "border border-danger bg-danger-soft text-danger" : "bg-surface-2 text-muted"
}`}
>
{Math.abs(abweichung) < 1 ? (
<>Verkaufspreis und modellierter Verkehrswert stimmen überein.</>
) : (
<>
Der Verkaufspreis liegt <strong>{formatChf(Math.abs(abweichung))} CHF</strong> (
{abweichung > 0 ? "+" : ""}
{Math.abs(Math.round(abweichungPct * 10) / 10)} %) {abweichung > 0 ? "über" : "unter"} dem
modellierten Verkehrswert.
{deutlich && (
<>
{" "}
Das ist eine deutliche Abweichung prüfe, ob sie gewollt ist oder ob die angenommene
Wertsteigerung nicht zum erwarteten Verkaufspreis passt.
</>
)}
</>
)}
</p>
)}
<NumberField label="Grundstueckgewinnsteuer (%)" step={1} value={num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE)} onChange={(v) => setT({ saleTaxRate: v })} /> <NumberField label="Grundstueckgewinnsteuer (%)" step={1} value={num(td.saleTaxRate, DEFAULT_PROPERTY_GAINS_TAX_RATE)} onChange={(v) => setT({ saleTaxRate: v })} />
</> </>
)} )}
+5
View File
@@ -226,6 +226,8 @@ export function PlanView({
derivedStart: ce?.baseValue ?? 0, derivedStart: ce?.baseValue ?? 0,
derivedMortgage: ce?.mortgageStart ?? 0, derivedMortgage: ce?.mortgageStart ?? 0,
mortgageEnd: ce?.mortgageEnd ?? 0, mortgageEnd: ce?.mortgageEnd ?? 0,
// Verkehrswert = Eigenkapital + Resthypothek (beides am Phasenende).
propertyValueEnd: (ce?.endValue ?? 0) + (ce?.mortgageEnd ?? 0),
deflatorStart: phase.cumulativeInflationStart, deflatorStart: phase.cumulativeInflationStart,
laterPhaseCount: laterPhaseCount(phase), laterPhaseCount: laterPhaseCount(phase),
ahvCareer: careerFor(element), ahvCareer: careerFor(element),
@@ -246,6 +248,8 @@ export function PlanView({
derivedStart: 0, derivedStart: 0,
derivedMortgage: 0, derivedMortgage: 0,
mortgageEnd: ce?.mortgageEnd ?? 0, mortgageEnd: ce?.mortgageEnd ?? 0,
// Verkehrswert = Eigenkapital + Resthypothek (beides am Phasenende).
propertyValueEnd: (ce?.endValue ?? 0) + (ce?.mortgageEnd ?? 0),
deflatorStart: fromPhase.cumulativeInflationStart, deflatorStart: fromPhase.cumulativeInflationStart,
laterPhaseCount: laterPhaseCount(fromPhase), laterPhaseCount: laterPhaseCount(fromPhase),
ahvCareer: careerFor(element), ahvCareer: careerFor(element),
@@ -1107,6 +1111,7 @@ function AddElementDialog({
derivedStart: 0, derivedStart: 0,
derivedMortgage: 0, derivedMortgage: 0,
mortgageEnd: 0, mortgageEnd: 0,
propertyValueEnd: 0,
deflatorStart: firstPhase.cumulativeInflationStart, deflatorStart: firstPhase.cumulativeInflationStart,
laterPhaseCount: 0, // beim Anlegen bewusst kein Warnhinweis laterPhaseCount: 0, // beim Anlegen bewusst kein Warnhinweis
ahvCareer: null, ahvCareer: null,
+4 -1
View File
@@ -298,7 +298,10 @@ function TornadoResults({
<tbody> <tbody>
{result.bars.map((b) => ( {result.bars.map((b) => (
<tr key={b.id} className="border-t border-border"> <tr key={b.id} className="border-t border-border">
<td className="px-3 py-2 font-medium text-fg">{b.label}</td> <td className="px-3 py-2 font-medium text-fg">
{b.label}
{b.note && <div className="mt-0.5 text-[11px] font-normal text-muted">{b.note}</div>}
</td>
<td className="px-3 py-2 text-muted">{formatRange(b.low, b.high, b.unit)}</td> <td className="px-3 py-2 text-muted">{formatRange(b.low, b.high, b.unit)}</td>
<td className="px-3 py-2 text-right text-muted">{formatChf(b.lowResult)}</td> <td className="px-3 py-2 text-right text-muted">{formatChf(b.lowResult)}</td>
<td className="px-3 py-2 text-right text-muted">{formatChf(b.highResult)}</td> <td className="px-3 py-2 text-right text-muted">{formatChf(b.highResult)}</td>
+274
View File
@@ -0,0 +1,274 @@
import { describe, it, expect } from "vitest";
import { computePlan } from "@/lib/calculations";
import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
import type { PlanInput } from "@/lib/types";
// Die zentrale Invariante beider Wasserfall-Bruecken: `residual` ist die Differenz zwischen
// dem tatsaechlichen Endwert und der Summe der gezeichneten Schritte. Ist die Zerlegung
// vollstaendig und richtig, MUSS sie 0 sein -- ein Wasserfall, der nicht aufgeht, waere ein
// Fehler in der Berechnung und nicht bloss ein Darstellungsproblem. Deshalb ueber moeglichst
// verschiedene Konstellationen festgenagelt statt an einem einzigen Beispiel.
let idc = 0;
const nid = () => `b${idc++}`;
function el(
category: ElementCategory,
ownerRole: string | null,
phaseValues: Record<string, PhaseData>,
transitionValues: Record<string, TransitionData> = {}
) {
return { id: nid(), category, name: category, ownerRole: ownerRole as never, orderIndex: idc, phaseValues, transitionValues };
}
function plan(opts: {
age: number;
retirementAge: number;
inflation?: number;
initialCash?: number;
phases: { id: string; durationYears: number; cashTransition?: CashTransitionData }[];
elements: ReturnType<typeof el>[];
}): PlanInput {
return {
id: "plan",
name: "T",
householdType: "SINGLE",
inflationRateDefault: opts.inflation ?? 1.5,
initialCash: opts.initialCash ?? 0,
persons: [{ id: "A", role: "PERSON_A", name: null, age: opts.age, retirementAge: opts.retirementAge }],
phases: opts.phases.map((p, i) => ({
id: p.id,
sequenceNumber: i + 1,
name: p.id,
durationYears: p.durationYears,
cashTransition: p.cashTransition ?? {},
})),
elements: opts.elements,
};
}
const konstellationen: { name: string; build: () => PlanInput }[] = [
{
name: "Ansparen mit 3a, Sparbeitrag und Schuldentilgung",
build: () =>
plan({
age: 40,
retirementAge: 70,
initialCash: 50000,
phases: [{ id: "p1", durationYears: 10 }],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 120000, teuerungsausgleich: 1 } }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 80000 } }),
el("PILLAR_3A", "PERSON_A", { p1: { currentValue: 50000, annualContribution: 7000, expectedReturn: 3 } }),
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 200000, expectedReturn: 5, annualContribution: 10000 } }),
el("OTHER_DEBT", "HOUSEHOLD", { p1: { startValue: 60000, annualRepayment: 8000 } }),
],
}),
},
{
name: "Pensionierung mit PK-Verrentung und 3a-Bezug",
build: () =>
plan({
age: 60,
retirementAge: 65,
initialCash: 20000,
phases: [
{ id: "p1", durationYears: 5 },
{ id: "p2", durationYears: 20 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 130000 } }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 90000 }, p2: { amount: 75000 } }),
el("AHV", "PERSON_A", { p1: { gapYears: 0 } }, { p1: { reviewed: true, avgIncomeBefore: 95000 } }),
el(
"PENSION_FUND",
"PERSON_A",
{ p1: { currentValue: 600000, annualContribution: 20000, expectedReturn: 2 } },
{ p1: { payoutMode: "PENSION", conversionRate: 6 } }
),
el(
"PILLAR_3A",
"PERSON_A",
{ p1: { currentValue: 120000, annualContribution: 7000, expectedReturn: 3 } },
{ p1: { capitalTaxRate: 8 } }
),
el("OTHER_ASSET", "HOUSEHOLD", {
p1: { startValue: 300000, expectedReturn: 4 },
p2: { expectedReturn: 4, annualWithdrawal: 30000 },
}),
],
}),
},
{
name: "PK-Kapitalbezug statt Rente (Steuer am Uebergang)",
build: () =>
plan({
age: 62,
retirementAge: 65,
phases: [
{ id: "p1", durationYears: 3 },
{ id: "p2", durationYears: 10 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 110000 } }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 80000 }, p2: { amount: 70000 } }),
el(
"PENSION_FUND",
"PERSON_A",
{ p1: { currentValue: 500000, annualContribution: 18000, expectedReturn: 2 } },
{ p1: { payoutMode: "CAPITAL", capitalTaxRate: 8 } }
),
],
}),
},
{
name: "Immobilie gehalten (Wertsteigerung und Amortisation)",
build: () =>
plan({
age: 45,
retirementAge: 70,
phases: [
{ id: "p1", durationYears: 10 },
{ id: "p2", durationYears: 10 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 140000 } }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 90000 }, p2: { amount: 90000 } }),
el("REAL_ESTATE", "HOUSEHOLD", {
p1: { purchasePrice: 1000000, mortgage: 700000, amortization: 12000, interestRate: 1.5, valueGrowth: 1.5 },
p2: { amortization: 12000, interestRate: 1.5, valueGrowth: 1.5 },
}),
],
}),
},
{
name: "Immobilie verkauft (Verkaufsdifferenz und Grundstueckgewinnsteuer)",
build: () =>
plan({
age: 45,
retirementAge: 70,
phases: [
{ id: "p1", durationYears: 10 },
{ id: "p2", durationYears: 10 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 140000 } }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 90000 }, p2: { amount: 90000 } }),
el(
"REAL_ESTATE",
"HOUSEHOLD",
{ p1: { purchasePrice: 1000000, mortgage: 700000, amortization: 12000, interestRate: 1.5, valueGrowth: 1.5 } },
{ p1: { decision: "SELL", salePrice: 1400000, saleTaxRate: 20 } }
),
],
}),
},
{
name: "Einmalige Sonderein-/ausgaben am Uebergang",
build: () =>
plan({
age: 50,
retirementAge: 70,
initialCash: 30000,
phases: [
{
id: "p1",
durationYears: 5,
cashTransition: { mode: "BOTH", inflowAmount: 250000, inflowTaxRate: 10, outflowAmount: 40000 },
},
{ id: "p2", durationYears: 5 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 100000 } }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 85000 }, p2: { amount: 85000 } }),
el("OTHER_ASSET", "HOUSEHOLD", {
p1: { startValue: 100000, expectedReturn: 4 },
p2: { expectedReturn: 4, additionalInvestment: 50000 },
}),
],
}),
},
{
name: "Sofort-Tilgung und Sonderamortisation am Uebergang",
build: () =>
plan({
age: 50,
retirementAge: 70,
initialCash: 200000,
phases: [
{ id: "p1", durationYears: 5 },
{ id: "p2", durationYears: 5 },
],
elements: [
el("INCOME", "PERSON_A", { p1: { amount: 120000 } }),
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 80000 }, p2: { amount: 80000 } }),
el(
"OTHER_DEBT",
"HOUSEHOLD",
{ p1: { startValue: 80000, annualRepayment: 5000 }, p2: { annualRepayment: 5000 } },
{ p1: { immediateRepayment: 30000 } }
),
el(
"REAL_ESTATE",
"HOUSEHOLD",
{
p1: { purchasePrice: 800000, mortgage: 500000, amortization: 10000, valueGrowth: 1 },
p2: { amortization: 10000, valueGrowth: 1 },
},
{ p1: { decision: "HOLD", extraAmortization: 50000 } }
),
],
}),
},
];
describe("Wasserfall-Bruecken", () => {
for (const k of konstellationen) {
it(`${k.name}: beide Bruecken gehen ohne Restgroesse auf`, () => {
const r = computePlan(k.build());
expect(r.phases.length).toBeGreaterThan(0);
for (const ph of r.phases) {
expect(ph.wealthBridge.residual, `Vermoegensbruecke ${ph.name}`).toBe(0);
expect(ph.cashBridge.residual, `Cash-Bruecke ${ph.name}`).toBe(0);
// Die Kontrollpunkte muessen den offiziellen Kennzahlen entsprechen.
expect(ph.wealthBridge.startWealth).toBe(ph.startWealthNominal);
expect(ph.wealthBridge.endWealth).toBe(ph.endWealthNominal);
expect(ph.cashBridge.cashStart).toBe(ph.cashStart);
expect(ph.cashBridge.cashEnd).toBe(ph.cashEnd);
}
});
}
it("Umbuchungen erscheinen NICHT in der Vermoegensbruecke", () => {
// Sparraten und Amortisationen verschieben Geld vom Cash in einen Vermoegenswert, ohne
// das Vermoegen zu aendern. Sie duerfen deshalb nur in der Cash-Bruecke auftauchen; die
// Vermoegensaenderung erklaert sich allein aus Quote, Ertraegen und PK-Beitraegen.
const r = computePlan(konstellationen[0].build());
const ph = r.phases[0];
expect(ph.cashBridge.savingRates).toBeGreaterThan(0);
expect(ph.cashBridge.debtRates).toBeGreaterThan(0);
const w = ph.wealthBridge;
expect(w.endWealth - w.startWealth).toBe(
w.quotaTotal + w.investmentReturn + w.propertyAppreciation + w.pensionFundContribution
);
});
it("PK-Verrentung erscheint als Vermoegensabgang am Uebergang", () => {
// Das verrentete Kapital verlaesst die Bilanz und wird zum Rentenstrom -- in der
// Vermoegensbruecke ein echter Abgang, kein Umbuchungsposten.
const r = computePlan(konstellationen[1].build());
const pension = r.phases[1];
expect(pension.wealthBridge.pensionConversion).toBeGreaterThan(0);
expect(pension.wealthBridge.startWealth).toBeLessThan(pension.wealthBridge.openingWealth);
});
it("Verkaufsdifferenz und Steuer erscheinen nur beim Verkauf", () => {
const gehalten = computePlan(konstellationen[3].build()).phases[1].wealthBridge;
const verkauft = computePlan(konstellationen[4].build()).phases[1].wealthBridge;
expect(gehalten.saleGainLoss).toBe(0);
expect(gehalten.transitionTax).toBe(0);
expect(verkauft.transitionTax).toBeGreaterThan(0);
// Verkaufspreis ueber dem modellierten Verkehrswert -> positive Differenz.
expect(verkauft.saleGainLoss).not.toBe(0);
});
});
+39
View File
@@ -58,6 +58,29 @@ function basePlan(): PlanInput {
}; };
} }
// Plan mit Immobilie -- wahlweise gehalten oder am Uebergang verkauft.
function planMitImmobilie(verkaufen: boolean): PlanInput {
const p = basePlan();
return {
...p,
elements: [
...p.elements,
{
id: "haus",
category: "REAL_ESTATE",
name: "Haus",
ownerRole: "HOUSEHOLD",
orderIndex: 4,
phaseValues: {
p1: { purchasePrice: 900000, mortgage: 600000, amortization: 10000, valueGrowth: 1 },
p2: { amortization: 0, valueGrowth: 1 },
},
transitionValues: verkaufen ? { p1: { decision: "SELL", salePrice: 1100000, saleTaxRate: 20 } } : {},
},
],
};
}
describe("Sensitivitaet: applyDriver", () => { describe("Sensitivitaet: applyDriver", () => {
it("laesst den Ausgangsplan unberuehrt (rein)", () => { it("laesst den Ausgangsplan unberuehrt (rein)", () => {
const p = basePlan(); const p = basePlan();
@@ -160,6 +183,22 @@ describe("Sensitivitaet: Tornado", () => {
it("identische Bandbreite ergibt Spannweite 0", () => { it("identische Bandbreite ergibt Spannweite 0", () => {
const [bar] = computeTornado(basePlan(), "real", [{ id: "inflation", low: 2, high: 2 }]).bars; const [bar] = computeTornado(basePlan(), "real", [{ id: "inflation", low: 2, high: 2 }]).bars;
expect(bar.swing).toBe(0); expect(bar.swing).toBe(0);
expect(bar.note).toBeTruthy(); // Nullbalken bekommt immer eine Erklaerung
});
it("verkaufte Immobilie: Wertsteigerung ist nachweislich wirkungslos und wird erklaert", () => {
// Beim Verkauf ist der Erloes `Verkaufspreis - Hypothek - Steuer` und haengt am ERFASSTEN
// Preis, nicht am modellierten Verkehrswert. Die aufgelaufene Wertsteigerung wird damit
// verworfen -- der Treiber kann das Endvermoegen nicht mehr bewegen.
const gehalten = computeTornado(planMitImmobilie(false), "real", [{ id: "propertyGrowth", low: 0.5, high: 2.5 }]);
const verkauft = computeTornado(planMitImmobilie(true), "real", [{ id: "propertyGrowth", low: 0.5, high: 2.5 }]);
expect(gehalten.bars[0].swing).toBeGreaterThan(0);
expect(gehalten.bars[0].note).toBeUndefined();
expect(verkauft.bars[0].swing).toBe(0);
expect(verkauft.bars[0].note).toContain("verkauft");
expect(verkauft.bars[0].note).toContain("Verkaufspreis");
}); });
it("real und nominal unterscheiden sich um den Deflator", () => { it("real und nominal unterscheiden sich um den Deflator", () => {
+23 -1
View File
@@ -221,6 +221,7 @@ export interface TornadoBar {
id: DriverId; id: DriverId;
label: string; label: string;
shortLabel: string; shortLabel: string;
note?: string; // Erklaerung, wenn der Treiber das Ergebnis nachweislich nicht bewegt
unit: DriverUnit; unit: DriverUnit;
low: number; // eingegebene Bandbreite low: number; // eingegebene Bandbreite
high: number; high: number;
@@ -236,6 +237,25 @@ export interface TornadoResult {
bars: TornadoBar[]; bars: TornadoBar[];
} }
// Warum bewegt ein Treiber gar nichts? Ein stummer Nullbalken ohne Erklaerung ist die
// schlechteste Antwort -- der Nutzer haelt ihn fuer einen Fehler.
//
// Der wichtigste Fall ist die Immobilien-Wertsteigerung bei einem Verkauf: Der Verkaufserloes
// ist `Verkaufspreis - Hypothek - Grundstueckgewinnsteuer` und haengt damit am ERFASSTEN
// Verkaufspreis, nicht am modellierten Verkehrswert. Die aufgelaufene Wertsteigerung wird beim
// Verkauf also verworfen -- der Treiber kann das Endvermoegen nicht mehr beeinflussen.
export function ineffectiveReason(plan: PlanInput, id: DriverId): string {
if (id === "propertyGrowth") {
const computed = computePlan(plan);
const last = computed.phases[computed.phases.length - 1];
const realEstate = (last?.elements ?? []).filter((e) => e.category === "REAL_ESTATE");
if (realEstate.length > 0 && realEstate.every((e) => e.status === "SOLD")) {
return "Wirkungslos, weil die Immobilie vor Planende verkauft wird: Der Verkaufserlös ergibt sich aus dem erfassten Verkaufspreis, nicht aus dem modellierten Verkehrswert. Die aufgelaufene Wertsteigerung wird beim Verkauf verworfen.";
}
}
return "Dieser Parameter bewegt das Endvermögen in diesem Plan nicht.";
}
// Zielgroesse: Endvermoegen der letzten Phase, real (kaufkraftbereinigt) oder nominal. // Zielgroesse: Endvermoegen der letzten Phase, real (kaufkraftbereinigt) oder nominal.
export function planMetric(plan: PlanInput, metric: TornadoMetric): number { export function planMetric(plan: PlanInput, metric: TornadoMetric): number {
const computed = computePlan(plan); const computed = computePlan(plan);
@@ -257,10 +277,12 @@ export function computeTornado(
const highResult = planMetric(applyDriver(plan, input.id, input.high), metric); const highResult = planMetric(applyDriver(plan, input.id, input.high), metric);
// Die Richtung kann sich umkehren (tiefe Ausgaben -> hohes Vermoegen). Der Balken spannt // 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. // deshalb ueber min..max; welche Eingabe zu welchem Ende gehoert, zeigt die Tabelle.
const swing = Math.abs(highResult - lowResult);
return { return {
id: input.id, id: input.id,
label: def.label, label: def.label,
shortLabel: def.shortLabel, shortLabel: def.shortLabel,
note: swing === 0 ? ineffectiveReason(plan, input.id) : undefined,
unit: def.unit, unit: def.unit,
low: input.low, low: input.low,
high: input.high, high: input.high,
@@ -268,7 +290,7 @@ export function computeTornado(
highResult, highResult,
min: Math.min(lowResult, highResult), min: Math.min(lowResult, highResult),
max: Math.max(lowResult, highResult), max: Math.max(lowResult, highResult),
swing: Math.abs(highResult - lowResult), swing,
}; };
}); });