frontend/src/api/portfolio.ts | 36 +++- .../DataIngestionReconciliationPanel.test.ts | 4 +- .../components/wealth/WealthCockpitPanel.test.ts | 94 +++++--- .../src/components/wealth/WealthCockpitPanel.vue | 240 ++++++++------------- src/jarvis_finance/api/routers/overview.py | 5 +- src/jarvis_finance/api/schemas/wealth_cockpit.py | 99 ++++++++- src/jarvis_finance/services/cash_service.py | 60 ++++-- src/jarvis_finance/services/household_import.py | 6 + src/jarvis_finance/services/wealth_cockpit.py | 218 +++++++++++++++++-- tests/unit/test_cash_truewealth_management.py | 10 +- tests/unit/test_wealth_cockpit_v1.py | 4 +- 11 files changed, 556 insertions(+), 220 deletions(-) diff --git a/src/jarvis_finance/api/routers/overview.py b/src/jarvis_finance/api/routers/overview.py index b8258be..6a96190 100644 --- a/src/jarvis_finance/api/routers/overview.py +++ b/src/jarvis_finance/api/routers/overview.py @@ -129,7 +129,10 @@ def overview(conn: Connection = Depends(get_db)) -> PortfolioSummary: @router.get("/portfolio/wealth-cockpit", response_model=WealthCockpitResponse) def wealth_cockpit( - period: str = Query(default="ytd", pattern="^(ytd|previous_year|12m|all)$"), + period: str = Query( + default="1m", + pattern="^(since_anchor|1m|3m|1y|ytd|previous_year|12m|all)$", + ), as_of: str | None = None, data_cutoff: str | None = None, conn: Connection = Depends(get_db), diff --git a/src/jarvis_finance/api/schemas/wealth_cockpit.py b/src/jarvis_finance/api/schemas/wealth_cockpit.py index bd0c6e9..2f95272 100644 --- a/src/jarvis_finance/api/schemas/wealth_cockpit.py +++ b/src/jarvis_finance/api/schemas/wealth_cockpit.py @@ -12,11 +12,106 @@ ReadinessStatus = Literal["ready", "partial", "not_ready", "not_applicable"] class WealthPeriod(BaseModel): model_config = ConfigDict(extra="forbid", populate_by_name=True) - preset: Literal["ytd", "previous_year", "12m", "all"] + preset: Literal[ + "since_anchor", "1m", "3m", "1y", "ytd", "previous_year", "12m", "all" + ] from_: str = Field(alias="from") to: str +ModelledValueQuality = Literal[ + "confirmed", "modelled", "carried", "incomplete", "unavailable" +] + + +class ModelledValueSummary(BaseModel): + model_config = ConfigDict(extra="forbid") + + date: str + value_chf: str + quality: ModelledValueQuality + + +class ModelledPointComponent(BaseModel): + model_config = ConfigDict(extra="forbid") + + key: Literal["postfinance", "truewealth", "crypto", "bank_cash"] + label: str + value_chf: str | None + quality: ModelledValueQuality + source_date: str | None + + +class ModelledDailyPoint(BaseModel): + model_config = ConfigDict(extra="forbid") + + date: str + value_chf: str + quality: ModelledValueQuality + components: list[ModelledPointComponent] + excluded_account_count: int + + +class ModelledComponentSummary(BaseModel): + model_config = ConfigDict(extra="forbid") + + key: Literal["postfinance", "truewealth", "crypto", "bank_cash"] + label: str + current_value_chf: str | None + change_chf: str | None + change_pct: str | None + quality: ModelledValueQuality + as_of: str | None + unknown_account_count: int + + +class ModelledCorrectionMarker(BaseModel): + model_config = ConfigDict(extra="forbid") + + date: str + source_key: Literal["postfinance", "truewealth"] + confirmed_value_chf: str + predecessor_model_value_chf: str + difference_chf: str + + +class ModelledUnknownAccount(BaseModel): + model_config = ConfigDict(extra="forbid") + + key: str + label: str + reason_code: str + + +class ModelledWealthDevelopment(BaseModel): + model_config = ConfigDict(extra="forbid") + + status: Literal["available", "unavailable"] + period: WealthPeriod + anchor: ModelledValueSummary | None + current: ModelledValueSummary | None + change_chf: str | None + change_pct: str | None + chart_visible: bool + points: list[ModelledDailyPoint] + components: list[ModelledComponentSummary] + correction_markers: list[ModelledCorrectionMarker] + unknown_accounts: list[ModelledUnknownAccount] + method: Literal["modelled_wealth_daily_v1"] + disclaimer: str + + +class VerifiedPerformanceSummary(BaseModel): + model_config = ConfigDict(extra="forbid") + + status: Literal["verified", "not_verified"] + label: str + ttwror_status: ReadinessStatus + xirr_status: ReadinessStatus + ttwror_pct: str | None + xirr_pct: str | None + + class WealthDimensionStatus(BaseModel): model_config = ConfigDict(extra="forbid") @@ -112,6 +207,8 @@ class WealthCockpitResponse(BaseModel): not_net_worth: bool period: WealthPeriod data_cutoff: str + modelled_development: ModelledWealthDevelopment + verified_performance: VerifiedPerformanceSummary kpis: list[dict[str, Any]] totals: dict[str, Any] history: dict[str, Any] diff --git a/src/jarvis_finance/services/cash_service.py b/src/jarvis_finance/services/cash_service.py index 5612842..be028af 100644 --- a/src/jarvis_finance/services/cash_service.py +++ b/src/jarvis_finance/services/cash_service.py @@ -134,13 +134,31 @@ def _latest_snapshot(conn: Connection, account_id: str, snapshot_type: str): """ SELECT * FROM cash_account_snapshots WHERE account_id=? AND snapshot_type=? - ORDER BY balance_date DESC, created_at DESC + ORDER BY balance_date DESC, created_at DESC, snapshot_id DESC LIMIT 1 """, (account_id, snapshot_type), ).fetchone() +def _latest_effective_snapshot(conn: Connection, account_id: str): + """Newest confirmed cash day; type precedence breaks same-day ties.""" + + return conn.execute( + """SELECT * FROM cash_account_snapshots + WHERE account_id=? AND amount_chf IS NOT NULL + ORDER BY balance_date DESC, + CASE snapshot_type + WHEN 'reconciliation' THEN 4 + WHEN 'manual_balance' THEN 3 + WHEN 'csv_anchor_balance' THEN 2 + WHEN 'calculated_balance' THEN 1 + ELSE 0 END DESC, + created_at DESC,snapshot_id DESC LIMIT 1""", + (account_id,), + ).fetchone() + + def _cash_movements_after_anchor(conn: Connection, account_id: str, anchor_date: str | None) -> Decimal: if not anchor_date: return Decimal("0") @@ -154,7 +172,17 @@ def _cash_movements_after_anchor(conn: Connection, account_id: str, anchor_date: """, (account_id, anchor_date), ).fetchone() - return Decimal(str(row["movement"] or "0")) + canonical = Decimal(str(row["movement"] or "0")) + budget_row = conn.execute( + """SELECT COALESCE(SUM(CAST(bt.amount_chf AS NUMERIC)),0) movement + FROM budget_transactions bt + JOIN budget_accounts ba ON ba.budget_account_id=bt.account_id + WHERE ba.linked_account_id=? AND ba.is_active=1 + AND bt.status='confirmed' AND bt.amount_chf IS NOT NULL + AND bt.transaction_date>?""", + (account_id, anchor_date), + ).fetchone() + return canonical + Decimal(str(budget_row["movement"] or "0")) def _calculated_balance(conn: Connection, account_id: str) -> Decimal: @@ -353,6 +381,7 @@ def list_cash_positions(conn: Connection) -> list[CashPosition]: continue manual = _latest_snapshot(conn, account_id, "manual_balance") anchor = _latest_snapshot(conn, account_id, "csv_anchor_balance") + effective_snapshot = _latest_effective_snapshot(conn, account_id) mode = row["balance_mode"] or "manual" default_currency = str(row["currency"]) materialized_rows = conn.execute( @@ -375,7 +404,7 @@ def list_cash_positions(conn: Connection) -> list[CashPosition]: # not be added to per-currency ledger rows from the same account. currencies = ( {default_currency} - if manual or anchor + if effective_snapshot else set(ledger_currencies) | set(materialized_by_currency) or {default_currency} ) @@ -396,7 +425,7 @@ def list_cash_positions(conn: Connection) -> list[CashPosition]: if ledger_balance is not None else currency_rows ) - if manual or anchor: + if effective_snapshot: calculated = _calculated_balance(conn, account_id) amount_original = ( calculated if currency.upper() == "CHF" else Decimal("0") @@ -407,7 +436,7 @@ def list_cash_positions(conn: Connection) -> list[CashPosition]: else: calculated = Decimal("0") amount_original = Decimal("0") - if not (manual or anchor): + if not effective_snapshot: amount_original += sum( ( Decimal(str(balance_row["amount_original"])) @@ -428,11 +457,18 @@ def list_cash_positions(conn: Connection) -> list[CashPosition]: if manual and currency == default_currency else None ) - used = ( - manual_amount - if mode == "manual" and manual_amount is not None - else calculated - ) + if effective_snapshot: + effective_value = Decimal(str(effective_snapshot["amount_chf"])) + effective_value += _cash_movements_after_anchor( + conn, account_id, str(effective_snapshot["balance_date"]) + ) + used = effective_value + else: + used = ( + manual_amount + if mode == "manual" and manual_amount is not None + else calculated + ) difference = ( manual_amount - calculated if manual_amount is not None @@ -473,8 +509,8 @@ def list_cash_positions(conn: Connection) -> list[CashPosition]: used_value_chf=decimal_text(used, 2), difference_chf=decimal_text(difference, 2), last_manual_reconciliation=( - manual["balance_date"] - if manual and currency == default_currency + effective_snapshot["balance_date"] + if effective_snapshot and currency == default_currency else None ), last_imported_booking=_last_imported_booking(conn, account_id), diff --git a/src/jarvis_finance/services/household_import.py b/src/jarvis_finance/services/household_import.py index a371adb..0cba76e 100644 --- a/src/jarvis_finance/services/household_import.py +++ b/src/jarvis_finance/services/household_import.py @@ -171,6 +171,12 @@ def _norm(value: Any) -> str: return " ".join(str(value or "").strip().casefold().split()) +def source_reference_hash(value: Any) -> str: + """Return the contract-bound private source-reference fingerprint.""" + + return _sha(_norm(value)) + + _UNMATCHED_TRANSFER_MARKERS = ( "uebertrag eigenes konto", "ubertrag eigenes konto", diff --git a/src/jarvis_finance/services/wealth_cockpit.py b/src/jarvis_finance/services/wealth_cockpit.py index d6217fd..a30390d 100644 --- a/src/jarvis_finance/services/wealth_cockpit.py +++ b/src/jarvis_finance/services/wealth_cockpit.py @@ -6,6 +6,8 @@ from decimal import Decimal from sqlite3 import Connection from typing import Any, cast +from fastapi import HTTPException + from jarvis_finance.ledger.performance import effective_activities, external_cashflow from jarvis_finance.quality.freshness import ( FreshnessStatus, @@ -16,6 +18,8 @@ from jarvis_finance.services.budget_planning import get_annual_budget_assistant from jarvis_finance.services.cash_service import get_cash_summary from jarvis_finance.services.crypto_service import list_crypto_positions from jarvis_finance.services.equity_service import get_equity_summary +from jarvis_finance.services.household_import import source_reference_hash +from jarvis_finance.services.modelled_wealth import build_modelled_wealth_development from jarvis_finance.services.portfolio_performance import ( build_performance_coverage, build_portfolio_performance, @@ -28,7 +32,16 @@ from jarvis_finance.services.reconciliation_snapshot import ( ) MONEY = Decimal("0.01") -PERIODS = {"ytd", "previous_year", "12m", "all"} +PERIODS = { + "since_anchor", + "1m", + "3m", + "1y", + "ytd", + "previous_year", + "12m", + "all", +} KNOWN_PERFORMANCE_ROLES = { "postfinance_etrading_depot", "postfinance_etrading_cash", @@ -90,17 +103,36 @@ def _earliest_evidence_date(conn: Connection, fallback: date) -> date: def period_bounds(conn: Connection, *, period: str, as_of: date) -> tuple[date, date]: if period not in PERIODS: - raise ValueError("Zeitraum muss ytd, previous_year, 12m oder all sein") - if period == "ytd": - return date(as_of.year, 1, 1), as_of - if period == "previous_year": - return date(as_of.year - 1, 1, 1), date(as_of.year - 1, 12, 31) - if period == "12m": + raise ValueError( + "Zeitraum muss since_anchor, 1m, 3m, 1y, ytd, previous_year, 12m oder all sein" + ) + if period == "since_anchor": + row = conn.execute( + """SELECT MAX(day) FROM ( + SELECT valuation_date day FROM account_value_snapshots + WHERE COALESCE(is_active,1)=1 AND updated_at IS NULL + UNION ALL SELECT balance_date FROM cash_account_snapshots + ) WHERE day<=?""", + (as_of.isoformat(),), + ).fetchone() + return ( + date.fromisoformat(str(row[0])) if row and row[0] else as_of, + as_of, + ) + if period == "1m": + return as_of - timedelta(days=31), as_of + if period == "3m": + return as_of - timedelta(days=93), as_of + if period in {"1y", "12m"}: try: start = as_of.replace(year=as_of.year - 1) except ValueError: start = as_of.replace(year=as_of.year - 1, day=28) return start, as_of + if period == "ytd": + return date(as_of.year, 1, 1), as_of + if period == "previous_year": + return date(as_of.year - 1, 1, 1), date(as_of.year - 1, 12, 31) return _earliest_evidence_date(conn, as_of - timedelta(days=1)), as_of @@ -194,13 +226,108 @@ def _unassigned_values(conn: Connection) -> tuple[Decimal, list[dict[str, str]]] return sum((Decimal(item["value_chf"]) for item in items), Decimal("0")), items -def _household_import_meta(conn: Connection, profile: str) -> dict[str, Any]: +def _household_import_meta( + conn: Connection, + profile: str, + *, + canonical_account_id: str | None = None, +) -> dict[str, Any]: source_type = { "akb": "akb_bank", "raiffeisen": "raiffeisen_bank", "viseca_one": "visa_credit_card", "migros_receipts": "migros_receipts", }.get(profile, profile) + unavailable = { + "imported_at": None, + "coverage_from": None, + "coverage_to": None, + "coverage_status": "unavailable", + "new_rows": 0, + "duplicate_rows": 0, + "review_rows": 0, + } + if canonical_account_id: + budget_ids = { + str(row[0]) + for row in conn.execute( + """SELECT budget_account_id FROM budget_accounts + WHERE linked_account_id=? AND is_active=1""", + (canonical_account_id,), + ).fetchall() + } + mapping_hashes = { + str(row[0]) + for row in conn.execute( + """SELECT source_reference_hash + FROM household_account_source_mappings + WHERE canonical_account_id=? AND source_type=? AND is_active=1""", + (canonical_account_id, source_type), + ).fetchall() + } + candidates = conn.execute( + """SELECT c.transaction_candidate_id,c.transaction_date,c.status, + c.household_batch_id,c.account_source,c.confirmed_transaction_id, + bt.account_id confirmed_budget_account_id,b.confirmed_at + FROM budget_transaction_candidates c + LEFT JOIN budget_transactions bt + ON bt.budget_transaction_id=c.confirmed_transaction_id + LEFT JOIN household_import_batches b + ON b.batch_id=c.household_batch_id + WHERE c.source_type=? AND c.household_batch_id IS NOT NULL + ORDER BY COALESCE(b.confirmed_at,c.created_at),c.transaction_date, + c.transaction_candidate_id""", + (source_type,), + ).fetchall() + attributable = [] + for candidate in candidates: + bound_by_transaction = str( + candidate["confirmed_budget_account_id"] or "" + ) in budget_ids + bound_by_mapping = False + source_reference = str(candidate["account_source"] or "").strip() + if source_reference and mapping_hashes: + try: + bound_by_mapping = ( + source_reference_hash(source_reference) in mapping_hashes + ) + except HTTPException: + # API tests and offline fixtures may intentionally omit the + # private fingerprint key. Confirmed canonical lineage still + # remains usable; weak source text never becomes identity. + bound_by_mapping = False + if bound_by_transaction or bound_by_mapping: + attributable.append(candidate) + if not attributable: + return unavailable + latest_batch = max( + attributable, + key=lambda row: ( + str(row["confirmed_at"] or ""), str(row["household_batch_id"]) + ), + )["household_batch_id"] + selected = [ + row for row in attributable if row["household_batch_id"] == latest_batch + ] + batch = conn.execute( + """SELECT confirmed_at FROM household_import_batches WHERE batch_id=?""", + (latest_batch,), + ).fetchone() + days = [str(row["transaction_date"]) for row in selected] + review_rows = sum(str(row["status"]) == "needs_review" for row in selected) + return { + "imported_at": batch["confirmed_at"] if batch else None, + "coverage_from": min(days, default=None), + "coverage_to": max(days, default=None), + "coverage_status": "partial", + "new_rows": len(selected), + # Duplicates cannot be assigned to an account without durable + # source-row lineage. Report zero rather than inheriting a provider + # count from a sibling account. + "duplicate_rows": 0, + "review_rows": review_rows, + } + row = conn.execute( """SELECT f.batch_id,f.row_count,f.period_start,f.period_end,f.created_at,b.confirmed_at FROM household_import_files f @@ -209,21 +336,27 @@ def _household_import_meta(conn: Connection, profile: str) -> dict[str, Any]: (source_type,), ).fetchone() if not row: - return {"imported_at": None, "coverage_from": None, "coverage_to": None, "coverage_status": "unavailable", "new_rows": 0, "duplicate_rows": 0, "review_rows": 0} - imported_rows = int(conn.execute( - "SELECT COUNT(*) FROM household_import_items WHERE batch_id=? AND source_type=?", - (row["batch_id"], source_type), - ).fetchone()[0]) - review_rows = int(conn.execute( - "SELECT COUNT(*) FROM budget_transaction_candidates WHERE household_batch_id=? AND source_type=? AND status='needs_review'", - (row["batch_id"], source_type), - ).fetchone()[0]) + return unavailable + imported_rows = int( + conn.execute( + "SELECT COUNT(*) FROM household_import_items WHERE batch_id=? AND source_type=?", + (row["batch_id"], source_type), + ).fetchone()[0] + ) + review_rows = int( + conn.execute( + "SELECT COUNT(*) FROM budget_transaction_candidates WHERE household_batch_id=? AND source_type=? AND status='needs_review'", + (row["batch_id"], source_type), + ).fetchone()[0] + ) row_count = int(row["row_count"]) return { "imported_at": row["confirmed_at"] or row["created_at"], "coverage_from": row["period_start"], "coverage_to": row["period_end"], - "coverage_status": "complete" if row["period_start"] and row["period_end"] else "partial", + "coverage_status": "complete" + if row["period_start"] and row["period_end"] + else "partial", "new_rows": imported_rows, "duplicate_rows": max(row_count - imported_rows, 0), "review_rows": review_rows, @@ -448,11 +581,13 @@ def _current_values(conn: Connection, *, as_of: date) -> dict[str, Any]: "dates": [], "statuses": [], "balance_modes": [], + "account_ids": set(), } ) for item in cash.positions: account_label = getattr(item, "account_label", item.platform) group = grouped_cash[(item.platform, account_label)] + group["account_ids"].add(str(getattr(item, "account_id", ""))) group["total"] += 1 has_value = bool( item.amount_chf is not None @@ -481,6 +616,9 @@ def _current_values(conn: Connection, *, as_of: date) -> dict[str, Any]: sources.append( { "key": f"cash-{index}", + "_canonical_account_id": next(iter(group["account_ids"])) + if len(group["account_ids"]) == 1 and "" not in group["account_ids"] + else None, "label": safe_account_label(account_label), "provider_label": safe_account_label(platform), "kind": "Bankguthaben", @@ -1077,6 +1215,16 @@ def build_wealth_cockpit( reference = date.fromisoformat(as_of) if as_of else date.today() start, requested_end = period_bounds(conn, period=period, as_of=reference) cutoff = data_cutoff or _latest_data_cutoff(conn) + model_period = ( + period + if period in {"since_anchor", "1m", "3m", "1y", "all"} + else "1y" + if period in {"ytd", "previous_year", "12m"} + else "all" + ) + modelled_development = build_modelled_wealth_development( + conn, period=model_period, as_of=reference.isoformat() + ) current = _current_values(conn, as_of=reference) valuation_end = _latest_valuation_date(conn, requested_end) performance: dict[str, Any] | None = None @@ -1085,12 +1233,13 @@ def build_wealth_cockpit( conn, from_date=start.isoformat(), to_date=valuation_end.isoformat(), - method="twr", + method="both", base_currency="CHF", data_cutoff=cutoff, ) summary = performance.get("summary", {}) if performance else {} quality = performance.get("quality", {}).get("ttwror", {}) if performance else {} + xirr_quality = performance.get("quality", {}).get("xirr", {}) if performance else {} investment_events = performance.get("external_cashflows", []) if performance else [] household_events = scope_cashflows( conn, @@ -1136,11 +1285,20 @@ def build_wealth_cockpit( elif key == "visa-liability": meta = _household_import_meta(conn, "viseca_one") elif "akb" in provider: - meta = _household_import_meta(conn, "akb") + meta = _household_import_meta( + conn, + "akb", + canonical_account_id=source.get("_canonical_account_id"), + ) elif "raiffeisen" in provider: - meta = _household_import_meta(conn, "raiffeisen") + meta = _household_import_meta( + conn, + "raiffeisen", + canonical_account_id=source.get("_canonical_account_id"), + ) else: meta = {"imported_at": None, "coverage_from": None, "coverage_to": None, "coverage_status": "unavailable", "new_rows": 0, "duplicate_rows": 0, "review_rows": 0} + source.pop("_canonical_account_id", None) source.update({name: value for name, value in meta.items() if name != "last_snapshot"}) source["last_activity_day"] = meta.get("coverage_to") or source.get("as_of") source["last_confirmed_snapshot"] = meta.get("last_snapshot") or source.get("as_of") @@ -1236,11 +1394,29 @@ def build_wealth_cockpit( for source in current["sources"] ] ) + ttwror_verified = ( + quality.get("status") == "complete" + and summary.get("ttwror_cumulative") is not None + ) + xirr_verified = ( + xirr_quality.get("status") == "complete" + and summary.get("xirr_annualized") is not None + ) + verified_performance = { + "status": "verified" if ttwror_verified and xirr_verified else "not_verified", + "label": "Verifiziert" if ttwror_verified and xirr_verified else "Noch nicht verifiziert", + "ttwror_status": "ready" if ttwror_verified else "not_ready", + "xirr_status": "ready" if xirr_verified else "not_ready", + "ttwror_pct": summary.get("ttwror_cumulative") if ttwror_verified else None, + "xirr_pct": summary.get("xirr_annualized") if xirr_verified else None, + } return { "scope_label": "Erfasstes Vermögen", "not_net_worth": True, "period": period_payload, "data_cutoff": cutoff, + "modelled_development": modelled_development, + "verified_performance": verified_performance, "kpis": [ {"key": "captured_wealth", "label": "Erfasstes Vermögen heute", "value_chf": _money(current["total"]), "status": "complete" if current["complete"] else "approximate"}, {"key": "wealth_change", "label": "Veränderung im Zeitraum", "value_chf": household_change, "status": household_change_status}, diff --git a/tests/unit/test_cash_truewealth_management.py b/tests/unit/test_cash_truewealth_management.py index bda4493..1c6594f 100644 --- a/tests/unit/test_cash_truewealth_management.py +++ b/tests/unit/test_cash_truewealth_management.py @@ -18,7 +18,11 @@ from jarvis_finance.services.cash_service import ( get_cash_summary, preview_cash_snapshot, ) -from jarvis_finance.services.manual_entry_service import confirm_account_value, preview_account_value, preview_position +from jarvis_finance.services.manual_entry_service import ( + confirm_account_value, + preview_account_value, + preview_position, +) from jarvis_finance.services.portfolio_service import get_overview from jarvis_finance.storage.database import connect_memory from jarvis_finance.storage.migrations import apply_migrations @@ -99,7 +103,7 @@ def test_manual_cash_snapshot_preview_confirm_audit_and_dashboard_value_for_manu assert conn.execute("SELECT COUNT(*) FROM cash_account_snapshots WHERE account_id=?", (account_id,)).fetchone()[0] == 1 -def test_csv_calculated_account_uses_calculated_value_and_manual_as_control_difference(): +def test_csv_calculated_account_keeps_calculation_as_control_but_newer_manual_snapshot_wins(): conn = db() ensure_canonical_cash_accounts(conn) account_id = conn.execute("SELECT account_id FROM accounts WHERE account_name='Raiffeisen'").fetchone()[0] @@ -116,7 +120,7 @@ def test_csv_calculated_account_uses_calculated_value_and_manual_as_control_diff assert row.csv_anchor_balance_chf == "1000.00" assert row.calculated_balance_chf == "1250.00" assert row.manual_balance_chf == "1300.00" - assert row.used_value_chf == "1250.00" + assert row.used_value_chf == "1300.00" assert row.difference_chf == "50.00" assert row.status == "Abgleich offen" diff --git a/tests/unit/test_wealth_cockpit_v1.py b/tests/unit/test_wealth_cockpit_v1.py index b73704e..d36f36c 100644 --- a/tests/unit/test_wealth_cockpit_v1.py +++ b/tests/unit/test_wealth_cockpit_v1.py @@ -508,10 +508,12 @@ def test_normal_cockpit_get_is_read_only_and_uses_only_stored_data(monkeypatch): conn = base_db() before = conn.total_changes payload = wealth_cockpit_endpoint( - period="ytd", as_of="2026-08-01", conn=conn + period="1m", as_of="2026-08-01", conn=conn ) assert len(payload["kpis"]) == 6 + assert payload["modelled_development"]["method"] == "modelled_wealth_daily_v1" + assert payload["verified_performance"]["status"] in {"verified", "not_verified"} assert payload["not_net_worth"] is True assert payload["planning"]["included_in_wealth"] is False validated = WealthCockpitResponse.model_validate(payload) __HERMES_CWD_8d46a20096ed__/home/agent/.hermes/worktrees/FinanceManager-current-import-performance__HERMES_CWD_8d46a20096ed__