## Ergebnis Read-only-Audit auf `sprint20/performance-data-readiness-v1` bei `d94a57e8f339dddad844b9cc4c5690f394495948`. Keine produktive DB geöffnet, keine Provider aufgerufen, keine Dateien von mir verändert. ### Aktueller Datenfluss - **Route:** `src/jarvis_finance/api/routers/overview.py` - `wealth_cockpit()` → `build_wealth_cockpit()` - `reconciliation_snapshot()` → `build_reconciliation_snapshot()` - `portfolio_performance_coverage()` → `build_performance_coverage()` - `portfolio_performance()` → `build_portfolio_performance()` - Policy-Routen verwenden `portfolio_policy.py`. - **Aktuelles Vermögen:** `services/wealth_cockpit.py` - `_current_values()` kombiniert: - Cash aus `get_cash_summary()` - Aktien/ETF aus `get_equity_summary()` - Crypto aus `list_crypto_positions()` - True Wealth aus `_truewealth()` - sonstige Kontowerte aus `_unassigned_values()` - `_household_history()` akzeptiert nur gemeinsame, exakt gespeicherte Stichtage. - **Performance:** `services/portfolio_performance.py` - Bestehende Engine bleibt ausreichend: TTWROR, XIRR, PnL/Cost Basis und Attribution besitzen bereits getrennte interne `Quality`-Objekte. - `build_performance_coverage()` reduziert diese aktuell aber auf Status je Metrik plus **eine vermischte** `reason_codes`-Liste. - **Reconciliation:** `services/reconciliation_snapshot.py` - Cash-Abgleich: CSV-Anker/Bewegungen gegen manuellen bzw. Reconciliation-Snapshot. - Snapshotdiagnose umfasst nur `account_value_snapshots` und `fx_rates`. - **Freshness:** `quality/freshness.py` - Ein globaler Grenzwert: zwei Kalendertage für alle Quellen. - **Schemas:** - Performance und Reconciliation sind typisiert. - `/portfolio/wealth-cockpit` liefert einen unvalidierten freien `dict`; es gibt keinen `WealthCockpitResponse`. - **Policy:** `_policy_comparison()` in `wealth_cockpit.py` nutzt die bestehende versionierte Policy ohne neue Persistenz. ## Wichtigste Findings ### 1. `as_of` und `data_cutoff` begrenzen das aktuelle Vermögen nicht `build_wealth_cockpit()` reicht beide Werte nicht an `get_cash_summary()`, `get_equity_summary()`, `list_crypto_positions()`, `_truewealth()` oder `_unassigned_values()` weiter. Diese lesen jeweils den neuesten vorhandenen Datensatz. Folgen: - Ein nach `as_of` liegender Wert kann in „Vermögen heute“ erscheinen. - Ein nach `data_cutoff` eingegangener Datensatz kann eine reproduzierbare historische Abfrage verändern. - Performance ist cutoff-basiert, aktuelles Vermögen aber nicht; dieselbe Response mischt zwei Zeitachsen. Das ist das wichtigste fachliche Risiko. ### 2. Freshness ist fachlich ungeeignet `freshness_status()` verwendet für alles zwei Kalendertage: - Börsenwerte werden am Wochenende oder SIX-Feiertag fälschlich stale. - Crypto wird nicht ausdrücklich als 24/7 behandelt. - Bank-/CSV-Importe und manuelle Salden haben denselben Rhythmus wie Kurse. - True Wealth erhält ebenfalls denselben Rhythmus. - Zukünftige `as_of`-Werte gelten wegen negativer Altersdifferenz als `fresh`. - `received_at` wird korrekt nicht als Ersatz verwendet, aber auch nicht diagnostisch ausgewiesen. ### 3. Freshness, Reconciliation und allgemeine Datenqualität sind vermischt `build_reconciliation_snapshot()` erzeugt `data_quality_status` aus: - Cash-Reconciliation-Freshness und - Account-Value-/FX-Freshness. Der Wealth Cockpit übernimmt diesen Wert direkt als `freshness_status`. Damit kann z. B. ein fehlender FX-Metadatensatz den globalen „Datenstand“ beeinflussen, obwohl aktuelles Cash oder Crypto eine andere Lage haben. Zusätzlich sind in `ReconciliationRecord`: - `freshness_status` - `data_quality_status` aktuell identisch und daher semantisch redundant. ### 4. Source-Diagnostik maskiert Probleme In `_current_values()`: - Cash wird nach **Plattform**, nicht nach Konto gruppiert. - `as_of=max(...)` lässt ein aktuelles Konto ein altes Konto derselben Bank verdecken. - Die Gruppen-Freshness verwendet `available=True`, selbst wenn kein verwendbarer Wert vorhanden ist; daraus wird bei fehlendem Datum `unknown` statt `unavailable`. - Reconciliation wird aus dem lokalisierten String `"Abgleich offen"` abgeleitet. - Keys wie `cash-1` hängen von Sortierung und Plattformbestand ab und sind nicht dauerhaft stabil. - Aktienquelle meldet `current_value_chf="0.00"`, obwohl die Distribution bei vollständig unbewerteten Positionen korrekt `None` liefert. - Der globale `data_as_of=max(source dates)` bezeichnet nur die jüngste Quelle, nicht den gemeinsamen Datenstand. ### 5. Performance Coverage ist nicht wirklich „per metric“ `build_performance_coverage()`: - vereinigt TTWROR-, XIRR- und Attribution-Gründe in eine einzige Liste; - setzt `reliable_from`, sobald TTWROR **oder** XIRR vollständig ist; - bestimmt den Gesamtstatus nur aus TTWROR und XIRR, nicht aus Attribution; - zählt Bewertungsdaten vor der eigentlichen Qualitätsprüfung; - läuft ohne festen `data_cutoff`; - kann bei Drift zwischen `performance_scope_classifications` und `accounts.performance_included` in `build_portfolio_performance()` mit `ValueError` abbrechen und über die Coverage-Route einen 500 erzeugen. Weitere Inkonsistenz: - Coverage zählt Bewertungsdaten nach Kalendertag. - `_aggregate_account_valuations()` gruppiert mehrere Konten nach dem exakten `valuation_at`. - Zwei Konten am selben Tag mit unterschiedlichen Uhrzeiten können daher laut Coverage abgedeckt sein, in der Engine aber keinen gemeinsamen Portfoliopunkt bilden. ### 6. Performance-Enddatum im Cockpit kann falsch gewählt werden `_latest_valuation_date()` nimmt das globale Maximum aus Portfolio-, Account-Value- und Cash-Snapshots. Das Datum muss nicht: - zum Anlageportfolio gehören, - eine vollständige Anlagebewertung besitzen, - oder im angefragten Scope vorhanden sein. Ein aktueller Cash-Snapshot kann deshalb ein Performance-Enddatum erzwingen, für das keine vollständige Anlagebewertung existiert. ### 7. Reconciliation-Snapshot hat Dedupe-/Coverage-Risiken `list_snapshot_metadata()`: - filtert `account_value_snapshots` nicht auf `updated_at IS NULL`; - dedupliziert Account Values nur nach `account_id`, obwohl mehrere Quellverträge existieren können; - dedupliziert FX nach Paar, Provider und Rate Type, wodurch alte Parallelprovider den globalen Status verschlechtern können; - schneidet erst am Ende auf 200 Zeilen ab; bei vielen Account Values können FX-Diagnosen vollständig verschwinden; - enthält keine `market_prices`, `crypto_prices`, Bankimport- oder True-Wealth-spezifische Diagnose. ### 8. Aktuelle Vollständigkeit und Policy haben versteckte Semantik - `_current_values().complete` verlangt immer einen True-Wealth-Wert, auch wenn kein True-Wealth-Konto konfiguriert ist. - Keine Aktienpositionen ergeben in `get_equity_summary()` `coverage_complete=False`; ein legitimer leerer Scope ist damit nicht von fehlender Abdeckung unterscheidbar. - True Wealth wird für Policy-Zwecke vollständig auf `other` abgebildet. Das ist nur dann korrekt, wenn die Policy ausdrücklich Total-Value-Produkte als `other` definiert. - `active_policy()` berücksichtigt `effective_from` beim Laden nicht. - Beitragsorientierung verwendet die Netto-Cashflows des gesamten Anlageportfolios; sie ist nicht automatisch identisch mit planmässigen Sparbeiträgen. ## Kleinstes additives Design ohne DB-Schemaänderung ### A. Eine reine Freshness-Policy ergänzen **Datei:** `src/jarvis_finance/quality/freshness.py` Additiv: - `FreshnessAssessment` als frozen Dataclass: - `status` - `source_kind` - `as_of` - `expected_as_of` - `policy_version` - `reason_code` - `assess_freshness(..., source_kind=...)` - Bestehendes `freshness_status()` als kompatiblen Wrapper behalten. Quellarten und vorgeschlagene explizite Defaults: - `market`: letzter abgeschlossener SIX-Handelstag, maximal eine abgeschlossene Session Rückstand. - `crypto_24_7`: echte Zeitdifferenz, z. B. 48 Stunden; Wochenenden zählen. - `bank_balance`/`bank_import`: eigener dokumentierter Import-/Abgleichrhythmus, z. B. sieben Kalendertage. - `managed_portfolio`: eigener True-Wealth-Rhythmus, z. B. sieben Kalendertage. - `fx`: Handelstagslogik oder explizite FX-Policy, nicht implizit Marktwerte übernehmen. SIX-Kalender lokal, deterministisch und versioniert; mindestens Wochenende, Neujahr, Karfreitag, Ostermontag, Tag der Arbeit, Auffahrt, Pfingstmontag, Weihnachten und Stephanstag. Keine Providerabfrage. Zukünftige Quellzeit muss `unknown`/`future_source_time` ergeben, nie `fresh`. ### B. Source-Diagnostik zentralisieren und korrekt deduplizieren **Datei:** `services/wealth_cockpit.py` Neue reine Helfer: - `_current_value_components(conn, as_of, data_cutoff)` - `_source_diagnostics(components, reference)` - `_performance_readiness(...)` - `_cockpit_readiness(...)` Regeln: - Cashdiagnose je Konto bzw. kanonischem Cash-Account, nicht je Plattform. - Stabile privacy-safe Source-ID, z. B. semantische Rolle plus gehashte Account-ID. - Dedupe nach kanonischem Eigentums-/Bewertungsobjekt: - PostFinance Depot, - PostFinance Settlement Cash, - True-Wealth-Gesamtwert, - Crypto-Portfolio, - sonstiges Account-Total. - Pro logischem Schlüssel genau ein Gewinner nach derselben Präzedenz wie die Wertberechnung; unterlegene Repräsentationen nur als `deduplicated_source` diagnostizieren, nie nochmals summieren. - `available=False` bei fehlendem Wert. - Gruppenstatus aus allen Mitgliedern kombinieren, nie über `max(as_of)` ableiten. - `data_as_of` entweder als frühester gemeinsamer Datenstand ausweisen oder nur noch als rein informativen „latest_source_as_of“ benennen; bestehendes Feld kompatibel lassen. ### C. Fünf Dimensionen additiv trennen Bestehende Response-Felder nicht entfernen. Additiv ein Top-Level-Objekt `readiness`: ```text readiness: current_wealth: status, complete, reason_codes, missing_value_count freshness: status, sources[] reconciliation: status, sources[], reason_codes performance: metrics: wealth_change investment_result ttwror xirr net_contributions attribution cost_basis policy: status, configured, assessable, version, reason_codes ``` Jede Performance-Metrik erhält separat: - `status: available|partial|not_calculable|not_applicable` - `reason_codes` - `coverage_from` - `coverage_to` - optional `value_as_of` Mapping ausschließlich aus vorhandener Engine: - `ttwror` ← `quality.ttwror` - `xirr` ← `quality.xirr` - `cost_basis` ← `quality.cost_basis` - `attribution` und `investment_result` ← `attribution.status/reason_codes` - `net_contributions` ← Cashflow-Coverage/-Klassifikation - `wealth_change` ← exakte Haushalts-Anfangs-/Endstichtage - Keine neue Performanceberechnung. ### D. Coverage-Vertrag additiv schärfen **Dateien:** - `services/portfolio_performance.py` - `api/schemas/portfolio_performance.py` `PerformanceCoverageRow` um metrikspezifische Gründe ergänzen: - `ttwror_reason_codes` - `xirr_reason_codes` - `attribution_reason_codes` - optional `cashflow_status`/`cashflow_reason_codes` Das alte aggregierte `reason_codes` kompatibel behalten. `reliable_from` nicht mehr als gemeinsame Aussage verwenden oder nur setzen, wenn alle ausdrücklich verlangten Metriken bereit sind. Coverage mit explizitem `data_cutoff` reproduzierbar machen und Service-`ValueError` in einen fail-closed Row-Status umwandeln. ### E. Wealth-Cockpit-Vertrag typisieren **Neue Datei:** `src/jarvis_finance/api/schemas/wealth_cockpit.py` **Route:** `api/routers/overview.py::wealth_cockpit` Einen `WealthCockpitResponse` mit `extra="forbid"` und den bestehenden Feldern plus `readiness` einführen. Das verhindert stille Feld-/Statusdrift. Es ist eine API-Schemaänderung, aber keine DB-Schemaänderung. ## Exakte Tests ### `tests/unit/test_freshness_policy_v1.py` - Freitag/Samstag/Sonntag für SIX. - Karfreitag/Ostermontag und Jahreswechsel. - Crypto am Wochenende weiterhin stale. - Bank-/Import- und True-Wealth-Rhythmus separat. - `available=False` vs. fehlendes `as_of`. - zukünftiges `as_of`. - naive und timezone-aware Timestamps. Im Arbeitsbaum erschien während des Audits bereits eine **nicht von mir erzeugte, untracked** Datei dieses Namens. Sie erwartet `assess_freshness()` und schlägt aktuell bei der Collection fehl, weil die Funktion noch nicht existiert. ### `tests/unit/test_wealth_cockpit_v1.py` Ergänzen: - Werte nach `as_of` werden ausgeschlossen. - Werte nach `data_cutoff` werden ausgeschlossen. - Zwei Cashkonten derselben Plattform behalten getrennte Freshness. - Ein frisches Konto maskiert kein stale Konto. - Fehlender Wert ergibt `unavailable`, nicht `unknown` oder Null. - Canonical/Legacy-Doppelrepräsentation wird einmal gezählt. - Unbewertete Aktienquelle liefert keinen fiktiven Nullwert. - TTWROR vollständig bei gleichzeitig fehlender Attribution bleibt metrikspezifisch getrennt. - Reconciliation-Differenz verändert Freshness nicht. - Policy konfiguriert, aber bei unvollständigem Vermögen `not_assessable`. - Response validiert gegen `WealthCockpitResponse`. - `conn.total_changes` bleibt unverändert. ### `tests/unit/test_portfolio_performance_foundation.py` Ergänzen: - gleiche Geschäftstage mit unterschiedlichen Zeitanteilen pro Konto; - Coverage-Gründe pro Metrik bleiben getrennt; - Attribution-unavailable macht TTWROR nicht unavailable und umgekehrt; - Coverage ist mit `data_cutoff` deterministisch; - Klassifikations-/`performance_included`-Drift liefert Diagnose statt 500; - Cash-Snapshot kann kein falsches Anlage-Enddatum erzwingen. ### `tests/unit/test_reconciliation_snapshot_foundation.py` Ergänzen: - `updated_at IS NOT NULL` wird nicht als neuester aktiver Wert gewählt; - Markt-, Crypto-, Bankimport- und True-Wealth-Diagnosen nutzen je eigene Policy; - logisch identische Source-Repräsentationen werden dedupliziert; - ein aktueller Parallelprovider maskiert keinen stale kanonischen Provider und umgekehrt; - 200er-Limit verdrängt keine ganze Diagnoseklasse; - Freshness und Reconciliation bleiben unabhängig. ### API/OpenAPI In den bestehenden API-Tests: - `/api/portfolio/wealth-cockpit` besitzt ein Response-Schema. - `/performance/coverage` dokumentiert metrikspezifische Gründe. - alle betroffenen GET-Routen bleiben read-only. ## Verifikation Bestehende fokussierte Tests: - `test_wealth_cockpit_v1.py`: **11 passed** - `test_reconciliation_snapshot_foundation.py`: **12 passed** - `test_portfolio_performance_foundation.py`: **25 passed** - Gesamt: **48 passed** Die neu erschienene untracked Freshness-Testdatei: Collection-Fehler wegen fehlendem `assess_freshness()` – erwarteter RED-Zustand. **Dateien von mir erstellt/geändert:** keine. **Arbeitsbaumhinweis:** `?? tests/unit/test_freshness_policy_v1.py` erschien parallel während des Audits und stammt nicht von mir.