diff --git a/src/jarvis_finance/api/schemas/wealth_cockpit.py b/src/jarvis_finance/api/schemas/wealth_cockpit.py index d3ceb7a..3261635 100644 --- a/src/jarvis_finance/api/schemas/wealth_cockpit.py +++ b/src/jarvis_finance/api/schemas/wealth_cockpit.py @@ -1,155 +1,206 @@ from __future__ import annotations from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field from jarvis_finance.api.schemas.portfolio_performance import PerformanceCoverageResponse ReadinessStatus = Literal["ready", "partial", "not_ready", "not_applicable"] class WealthPeriod(BaseModel): model_config = ConfigDict(extra="forbid", populate_by_name=True) preset: Literal[ "since_anchor", "1m", "3m", "1y", "ytd", "previous_year", "12m", "all" ] from_: str = Field(alias="from") to: str class ModelledWealthPeriod(BaseModel): model_config = ConfigDict(extra="forbid", populate_by_name=True) - preset: Literal["since_anchor", "1m", "3m", "1y", "all"] + preset: Literal["since_anchor", "1m", "3m", "ytd", "1y", "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"] + key: Literal["postfinance", "truewealth", "crypto", "bank_cash", "other_assets"] 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 has_confirmed_anchor: bool has_modelled_value: bool components: list[ModelledPointComponent] excluded_account_count: int class ModelledComponentSummary(BaseModel): model_config = ConfigDict(extra="forbid") - key: Literal["postfinance", "truewealth", "crypto", "bank_cash"] + key: Literal["postfinance", "truewealth", "crypto", "bank_cash", "other_assets"] 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"] + source_key: Literal["postfinance", "truewealth", "bank_cash"] 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: ModelledWealthPeriod + last_confirmed_anchor_date: str | None anchor: ModelledValueSummary | None baseline: 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 PortfolioAnalysisAllocation(BaseModel): + model_config = ConfigDict(extra="forbid") + key: str + label: str + current_value_chf: str | None + current_pct: str | None + target_pct: str | None + lower_pct: str | None + upper_pct: str | None + deviation_pp: str | None + deviation_chf: str | None + status: Literal["below_corridor", "within_corridor", "above_corridor", "unavailable"] + + +class PortfolioAnalysisDimensionRow(BaseModel): + model_config = ConfigDict(extra="forbid") + label: str + pct: str | None + + +class PortfolioAnalysisDimension(BaseModel): + model_config = ConfigDict(extra="forbid") + status: Literal["complete", "partial", "unavailable"] + rows: list[PortfolioAnalysisDimensionRow] + + +class PortfolioAnalysisContribution(BaseModel): + model_config = ConfigDict(extra="forbid") + key: str + label: str + value_chf: str | None + status: str | None = None + + +class PortfolioAnalysisHint(BaseModel): + model_config = ConfigDict(extra="forbid") + priority: int + text: str + + +class PortfolioAnalysisV1(BaseModel): + model_config = ConfigDict(extra="forbid") + status: Literal["complete", "partial", "unavailable"] + allocation: list[PortfolioAnalysisAllocation] + concentrations: dict[str, str | None] + dimensions: dict[str, PortfolioAnalysisDimension] + contributions: list[PortfolioAnalysisContribution] + hints: list[PortfolioAnalysisHint] + + class WealthDimensionStatus(BaseModel): model_config = ConfigDict(extra="forbid") status: ReadinessStatus reason_code: str | None class WealthReadinessDimensions(BaseModel): model_config = ConfigDict(extra="forbid") current_value: WealthDimensionStatus freshness: WealthDimensionStatus reconciliation: WealthDimensionStatus performance: WealthDimensionStatus policy: WealthDimensionStatus class WealthReadinessMetric(BaseModel): model_config = ConfigDict(extra="forbid") key: str label: str status: ReadinessStatus included_sources: list[str] missing_sources: list[str] as_of: str | None period: WealthPeriod blocker: str | None action: str | None reason_code: str | None @@ -192,44 +243,45 @@ class WealthSource(BaseModel): as_of: str | None freshness_status: Literal["fresh", "stale", "unavailable", "unknown"] freshness_reason_code: str | None = None expected_as_of: str | None = None reconciliation_status: Literal["reconciled", "difference", "not_assessable"] performance_status: ReadinessStatus value_basis: Literal["confirmed", "modelled", "unavailable"] = "unavailable" last_activity_day: str | None = None last_confirmed_snapshot: str | None = None imported_at: str | None = None coverage_from: str | None = None coverage_to: str | None = None coverage_status: Literal["complete", "partial", "stale", "unavailable"] = "unavailable" new_rows: int = 0 duplicate_rows: int = 0 review_rows: int = 0 next_action: str | None = None performance_blocker: str | None = None class WealthCockpitResponse(BaseModel): """Runtime-validated contract for the read-only wealth cockpit.""" model_config = ConfigDict(extra="forbid") scope_label: str not_net_worth: bool period: WealthPeriod data_cutoff: str modelled_development: ModelledWealthDevelopment + portfolio_analysis: PortfolioAnalysisV1 verified_performance: VerifiedPerformanceSummary kpis: list[dict[str, Any]] totals: dict[str, Any] history: dict[str, Any] distribution: list[dict[str, Any]] sources: list[WealthSource] readiness: WealthReadiness diagnostics: list[WealthDiagnostic] performance_coverage: PerformanceCoverageResponse policy: dict[str, Any] planning: dict[str, Any] data_quality: dict[str, Any] hints: list[str] method: dict[str, str] diff --git a/src/jarvis_finance/services/modelled_wealth.py b/src/jarvis_finance/services/modelled_wealth.py index c3e84b4..acde6ca 100644 --- a/src/jarvis_finance/services/modelled_wealth.py +++ b/src/jarvis_finance/services/modelled_wealth.py @@ -1,58 +1,59 @@ from __future__ import annotations from calendar import monthrange from datetime import date, timedelta from decimal import Decimal import re from sqlite3 import Connection from typing import Any from jarvis_finance.services.cash_service import authoritative_cash_movements from jarvis_finance.services.daily_valuations import SOURCE_KEY as CRYPTO_VALUATION_SOURCE LEGACY_CRYPTO_VALUATION_SOURCE = "daily_crypto_current_valuation_v1" MONEY = Decimal("0.01") PERCENT = Decimal("0.0001") -MODEL_PERIODS = {"since_anchor", "1m", "3m", "1y", "all"} +MODEL_PERIODS = {"since_anchor", "1m", "3m", "ytd", "1y", "all"} SNAPSHOT_PRECEDENCE = { "reconciliation": 4, "manual_balance": 3, "csv_anchor_balance": 2, "calculated_balance": 1, } COMPONENT_LABELS = { "postfinance": "PostFinance", "truewealth": "True Wealth", "crypto": "Krypto", "bank_cash": "Bankguthaben", + "other_assets": "Weitere Anlagen", } def _money(value: Decimal | None) -> str | None: return None if value is None else format(value.quantize(MONEY), "f") def _subtract_months(day: date, months: int) -> date: absolute = day.year * 12 + day.month - 1 - months year, month_index = divmod(absolute, 12) month = month_index + 1 return date(year, month, min(day.day, monthrange(year, month)[1])) def _authoritative_cash_movements( conn: Connection, *, account_id: str, after: str | None, through: str ) -> dict[str, Any]: return authoritative_cash_movements( conn, account_id=account_id, after=after, through=through ) def effective_cash_evidence( conn: Connection, *, account_id: str, as_of: str ) -> dict[str, Any]: """Return one account's effective cash evidence without writing. The newest business date wins. If several valid snapshots exist on that date, the deterministic precedence is reconciliation > manual > CSV anchor. Confirmed movements are then applied forward only. With no anchor, a @@ -340,60 +341,134 @@ def _crypto_events(conn: Connection, *, through: str) -> dict[str, dict[str, Any through, ), ).fetchall() ] rows = [] for source in (CRYPTO_VALUATION_SOURCE, LEGACY_CRYPTO_VALUATION_SOURCE): rows.extend( _latest_model_rows( conn, account_ids=account_ids, through=through, source=source, ) ) latest_by_account_day: dict[tuple[str, str], dict[str, Any]] = {} for row in rows: key = (row["account_id"], row["date"]) if key not in latest_by_account_day or str(row["captured_at"]) > str( latest_by_account_day[key]["captured_at"] ): latest_by_account_day[key] = row values: dict[str, Decimal] = {} for row in latest_by_account_day.values(): values[row["date"]] = values.get(row["date"], Decimal("0")) + row["value"] return { day: {"value": value, "quality": "modelled", "source_date": day} for day, value in values.items() } +def _other_asset_events(conn: Connection, *, through: str) -> dict[str, dict[str, Any]]: + """Aggregate confirmed non-cash account values without inventing daily precision.""" + rows = conn.execute( + """WITH ranked AS ( + SELECT s.account_id,s.valuation_date,s.total_value_chf, + ROW_NUMBER() OVER( + PARTITION BY s.account_id,s.valuation_date + ORDER BY COALESCE(s.valuation_at,s.created_at) DESC,s.snapshot_id DESC + ) rn + FROM account_value_snapshots s + JOIN accounts a ON a.account_id=s.account_id + WHERE a.is_active=1 AND a.account_type IN ('other_asset','membership') + AND s.valuation_date<=? AND COALESCE(s.is_active,1)=1 + AND s.quality_status IN ('confirmed','ok','complete') + ) SELECT account_id,valuation_date,total_value_chf + FROM ranked WHERE rn=1 ORDER BY valuation_date,account_id""", + (through,), + ).fetchall() + latest: dict[str, Decimal] = {} + events: dict[str, dict[str, Any]] = {} + for row in rows: + try: + value = Decimal(str(row["total_value_chf"])) + except Exception: + continue + if not value.is_finite() or value < Decimal("0"): + continue + latest[str(row["account_id"])] = value + day = str(row["valuation_date"]) + events[day] = { + "value": sum(latest.values(), Decimal("0")), + "quality": "confirmed", + "source_date": day, + } + return events + + +def _manual_cash_correction_markers(conn: Connection, *, through: str) -> list[dict[str, str]]: + rows = conn.execute( + """SELECT snapshot_id,account_id,balance_date,amount_chf,created_at + FROM cash_account_snapshots + WHERE source='manual_screenshot_snapshot' AND balance_date<=? + ORDER BY balance_date,created_at,snapshot_id""", + (through,), + ).fetchall() + grouped: dict[str, dict[str, Decimal]] = {} + for row in rows: + previous = conn.execute( + """SELECT amount_chf FROM cash_account_snapshots + WHERE account_id=? AND ( + balance_date list[dict[str, str]]: markers = [] model_days = sorted(models) for row in official: predecessor_days = [ day for day in model_days if day < row["date"] or ( day == row["date"] and str(models[day]["captured_at"]) < str(row["captured_at"]) ) ] if not predecessor_days: continue predecessor_day = predecessor_days[-1] predecessor = models[predecessor_day]["value"] markers.append( { "date": row["date"], "source_key": source_key, "confirmed_value_chf": _money(row["value"]) or "0.00", "predecessor_model_value_chf": _money(predecessor) or "0.00", "difference_chf": _money(row["value"] - predecessor) or "0.00", } @@ -452,147 +527,152 @@ def _safe_bank_label(label: str) -> str: return f"Bankkonto •••• {suffix.group(1)}" if suffix else "Bankkonto" def _earliest_evidence(conn: Connection, *, fallback: date) -> date: rows = conn.execute( """SELECT day FROM ( SELECT valuation_date day FROM account_value_snapshots WHERE COALESCE(is_active,1)=1 AND updated_at IS NULL UNION ALL SELECT substr(valuation_at,1,10) FROM portfolio_valuation_snapshots UNION ALL SELECT balance_date FROM cash_account_snapshots )""" ).fetchall() valid_days: list[date] = [] for row in rows: try: valid_days.append(date.fromisoformat(str(row[0]))) except (TypeError, ValueError): continue return min(valid_days, default=fallback) def _period_start( conn: Connection, *, period: str, as_of: date, latest_anchor: date | None ) -> date: if period == "since_anchor": return latest_anchor or as_of if period == "1m": return _subtract_months(as_of, 1) if period == "3m": return _subtract_months(as_of, 3) + if period == "ytd": + return date(as_of.year, 1, 1) if period == "1y": return _subtract_months(as_of, 12) if period == "all": return _earliest_evidence(conn, fallback=as_of) - raise ValueError("period must be since_anchor, 1m, 3m, 1y or all") + raise ValueError("period must be since_anchor, 1m, 3m, ytd, 1y or all") def build_modelled_wealth_development( conn: Connection, *, period: str = "1m", as_of: str | None = None ) -> dict[str, Any]: """Compose existing immutable valuation/snapshot sources into one read model.""" if period not in MODEL_PERIODS: - raise ValueError("period must be since_anchor, 1m, 3m, 1y or all") + raise ValueError("period must be since_anchor, 1m, 3m, ytd, 1y or all") reference = date.fromisoformat(as_of) if as_of else date.today() through = reference.isoformat() postfinance, pf_markers = _postfinance_events(conn, through=through) truewealth, tw_markers = _truewealth_events(conn, through=through) crypto = _crypto_events(conn, through=through) + other_assets = _other_asset_events(conn, through=through) investment_events = { "postfinance": postfinance, "truewealth": truewealth, "crypto": crypto, + "other_assets": other_assets, } expected_investment = { "postfinance": bool( _role_account_ids(conn, "postfinance_etrading_depot") or _role_account_ids(conn, "postfinance_etrading_cash") ), "truewealth": bool( _role_account_ids(conn, "canonical_truewealth_total_value") ), "crypto": bool(_role_account_ids(conn, "crypto_portfolio")), + "other_assets": bool(other_assets), } - confirmed_days = [ - date.fromisoformat(day) - for events in (postfinance, truewealth) - for day, event in events.items() - if event["quality"] == "confirmed" - ] - cash_snapshot_day = conn.execute( - "SELECT MAX(balance_date) FROM cash_account_snapshots WHERE balance_date<=?", - (through,), - ).fetchone()[0] - if cash_snapshot_day: - confirmed_days.append(date.fromisoformat(str(cash_snapshot_day))) + # Household anchors come from confirmed portfolio-import anchors. Component-only + # cash/membership corrections remain event markers and must not move the solid-line + # boundary or make mixed-date values look fully confirmed. + confirmed_days: list[date] = [] + for events in (postfinance, truewealth): + for day, event in events.items(): + if event["quality"] != "confirmed": + continue + try: + confirmed_days.append(date.fromisoformat(day)) + except ValueError: + continue latest_anchor = max(confirmed_days, default=None) start = _period_start( conn, period=period, as_of=reference, latest_anchor=latest_anchor ) if start > reference: start = reference if (reference - start).days > 5000: start = reference - timedelta(days=5000) bank_accounts = _bank_accounts(conn) points: list[dict[str, Any]] = [] unknown_identity_by_day: dict[str, frozenset[str]] = {} event_dates = { day for events in investment_events.values() for day in events if start.isoformat() <= day <= through } event_dates.update( str(row[0]) for row in conn.execute( "SELECT DISTINCT balance_date FROM cash_account_snapshots WHERE balance_date BETWEEN ? AND ?", (start.isoformat(), through), ).fetchall() ) for account in bank_accounts: movement_evidence = _authoritative_cash_movements( conn, account_id=account["account_id"], after=start.isoformat(), through=through, ) event_dates.update(movement_evidence["days"]) cursor = start while cursor <= reference: day = cursor.isoformat() components: list[dict[str, Any]] = [] qualities: list[str] = [] missing_investment: list[str] = [] known_total = Decimal("0") - for key in ("postfinance", "truewealth", "crypto"): + for key in ("postfinance", "truewealth", "crypto", "other_assets"): selected = _event_at_or_before(investment_events[key], day) value = selected["value"] if selected else None quality = selected["quality"] if selected else "unavailable" if value is not None: known_total += value qualities.append(quality) elif expected_investment[key]: missing_investment.append(key) components.append( { "key": key, "label": COMPONENT_LABELS[key], "value_chf": _money(value), "quality": quality, "source_date": selected["source_date"] if selected else None, } ) bank_total = Decimal("0") bank_qualities: list[str] = [] unknown_on_day: list[str] = [] bank_source_days: list[str] = [] for account in bank_accounts: evidence = effective_cash_evidence( conn, account_id=account["account_id"], as_of=day ) if evidence["value_chf"] is None: unknown_on_day.append(account["account_id"]) continue bank_total += Decimal(evidence["value_chf"]) @@ -602,106 +682,106 @@ def build_modelled_wealth_development( if bank_qualities: bank_quality = ( "modelled" if "modelled" in bank_qualities else "carried" if "carried" in bank_qualities else "confirmed" ) known_total += bank_total qualities.append(bank_quality) bank_value = _money(bank_total) else: bank_quality = "unavailable" bank_value = None components.append( { "key": "bank_cash", "label": COMPONENT_LABELS["bank_cash"], "value_chf": bank_value, "quality": bank_quality, "source_date": min(bank_source_days, default=None), } ) if not qualities: cursor += timedelta(days=1) continue has_confirmed_anchor = any( component["quality"] == "confirmed" and component["source_date"] == day for component in components ) - has_modelled_value = any( + has_modelled_value = (latest_anchor is None or cursor > latest_anchor) and any( component["quality"] == "modelled" and component["source_date"] == day for component in components ) point_quality = ( "incomplete" if unknown_on_day or missing_investment else "modelled" - if "modelled" in qualities + if "modelled" in qualities and (latest_anchor is None or cursor > latest_anchor) else "carried" if "carried" in qualities else "confirmed" ) points.append( { "date": day, "value_chf": _money(known_total) or "0.00", "quality": point_quality, "has_confirmed_anchor": has_confirmed_anchor, "has_modelled_value": has_modelled_value, "components": components, "excluded_account_count": len(unknown_on_day) + len(missing_investment), } ) unknown_identity_by_day[day] = frozenset( [f"bank:{account_id}" for account_id in unknown_on_day] + [f"component:{key}" for key in missing_investment] ) cursor += timedelta(days=1) current_point = points[-1] if points else None current_unknown = [] for index, account in enumerate(bank_accounts, start=1): evidence = effective_cash_evidence( conn, account_id=account["account_id"], as_of=through ) if evidence["value_chf"] is None: current_unknown.append( { "key": f"unknown-bank-{index}", "label": _safe_bank_label(account["label"]), "reason_code": "confirmed_cash_evidence_missing", } ) - for key in ("postfinance", "truewealth", "crypto"): + for key in ("postfinance", "truewealth", "crypto", "other_assets"): if expected_investment[key] and not _event_at_or_before( investment_events[key], through ): current_unknown.append( { "key": f"unknown-component-{key}", "label": COMPONENT_LABELS[key], "reason_code": "stored_valuation_evidence_missing", } ) anchor_point = None if latest_anchor: anchor_point = next( (point for point in points if point["date"] == latest_anchor.isoformat()), None, ) anchor = ( { "date": anchor_point["date"], "value_chf": anchor_point["value_chf"], "quality": anchor_point["quality"], } if anchor_point else None ) comparable_baseline = points[0] if points else None if current_point: current_unknown_identity = unknown_identity_by_day.get( str(current_point["date"]), frozenset() @@ -800,52 +880,53 @@ def build_modelled_wealth_development( quality = "incomplete" component_summaries.append( { "key": key, "label": str(item["label"]), "current_value_chf": _money(current_value), "change_chf": _money(component_change), "change_pct": format(component_change_pct.quantize(PERCENT), "f") if component_change_pct is not None else None, "quality": quality, "as_of": item["source_date"], "unknown_account_count": sum( 1 for unknown in current_unknown if ( str(unknown["key"]).startswith("unknown-bank-") if key == "bank_cash" else unknown["key"] == f"unknown-component-{key}" ) ), } ) return { "status": "available" if points else "unavailable", "period": { "preset": period, "from": start.isoformat(), "to": through, }, + "last_confirmed_anchor_date": latest_anchor.isoformat() if latest_anchor else None, "anchor": anchor, "baseline": baseline, "current": current, "change_chf": _money(change), "change_pct": format(change_pct.quantize(PERCENT), "f") if change_pct is not None else None, "chart_visible": chart_visible, "points": points, "components": component_summaries, "correction_markers": sorted( [ marker - for marker in pf_markers + tw_markers + for marker in pf_markers + tw_markers + _manual_cash_correction_markers(conn, through=through) if start.isoformat() <= marker["date"] <= through ], key=lambda item: (item["date"], item["source_key"]), ), "unknown_accounts": current_unknown, "method": "modelled_wealth_daily_v1", "disclaimer": "Geschätzte Entwicklung aus bestätigten Ankern, gespeicherten Tagesbewertungen und fortgeschriebenen bekannten Salden; keine verifizierte TTWROR oder XIRR.", } diff --git a/src/jarvis_finance/services/wealth_cockpit.py b/src/jarvis_finance/services/wealth_cockpit.py index a30390d..824fdf1 100644 --- a/src/jarvis_finance/services/wealth_cockpit.py +++ b/src/jarvis_finance/services/wealth_cockpit.py @@ -1,52 +1,53 @@ from __future__ import annotations from collections import defaultdict from datetime import UTC, date, datetime, timedelta 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, assess_freshness, combined_freshness, ) 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_analysis_v1 import build_portfolio_analysis_v1 from jarvis_finance.services.portfolio_performance import ( build_performance_coverage, build_portfolio_performance, load_scope_activities, ) from jarvis_finance.services.portfolio_policy import active_policy from jarvis_finance.services.reconciliation_snapshot import ( build_reconciliation_snapshot, safe_account_label, ) MONEY = Decimal("0.01") PERIODS = { "since_anchor", "1m", "3m", "1y", "ytd", "previous_year", "12m", "all", } KNOWN_PERFORMANCE_ROLES = { "postfinance_etrading_depot", "postfinance_etrading_cash", "canonical_truewealth_total_value", "crypto_portfolio", } @@ -1198,60 +1199,63 @@ def _build_readiness( dimensions = { "current_value": {"status": current_status, "reason_code": None if current_status == "ready" else "current_values_incomplete"}, "freshness": {"status": "ready" if freshness_status == "fresh" else "partial" if known_current else "not_ready", "reason_code": None if freshness_status == "fresh" else "source_freshness_mixed"}, "reconciliation": {"status": "ready" if reconciliation_status == "reconciled" else "not_ready" if reconciliation_status == "difference" else "partial", "reason_code": None if reconciliation_status == "reconciled" else "reconciliation_not_fully_assessable"}, "performance": {"status": next(item["status"] for item in metrics if item["key"] == "ttwror"), "reason_code": next(item["reason_code"] for item in metrics if item["key"] == "ttwror")}, "policy": {"status": "ready" if policy.get("configured") else "not_applicable", "reason_code": None if policy.get("configured") else "portfolio_policy_not_configured"}, } return {"dimensions": dimensions, "metrics": metrics} def build_wealth_cockpit( conn: Connection, *, period: str = "ytd", as_of: str | None = None, data_cutoff: str | None = None, ) -> dict[str, Any]: 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() ) + portfolio_analysis = build_portfolio_analysis_v1( + conn, as_of=reference.isoformat(), modelled=modelled_development + ) current = _current_values(conn, as_of=reference) valuation_end = _latest_valuation_date(conn, requested_end) performance: dict[str, Any] | None = None if start < valuation_end: performance = build_portfolio_performance( conn, from_date=start.isoformat(), to_date=valuation_end.isoformat(), 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, account_ids=_account_ids(conn, investment_only=False), from_date=start.isoformat(), to_date=requested_end.isoformat(), data_cutoff=cutoff, ) reconciliation = build_reconciliation_snapshot( conn, now=datetime.combine(reference, datetime.max.time(), tzinfo=UTC) ) history_points, history_reason = _household_history( conn, from_date=start, to_date=requested_end, @@ -1389,52 +1393,53 @@ def build_wealth_cockpit( reconciliation_status=reconciliation_status, ) current_freshness = combined_freshness( [ cast(FreshnessStatus, source["freshness_status"]) 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, + "portfolio_analysis": portfolio_analysis, "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}, {"key": "investment_result", "label": "Anlageergebnis ohne Einzahlungen", "value_chf": investment_result, "status": "available" if investment_result is not None else "not_calculable"}, {"key": "return", "label": "Zeitgewichtete Rendite", "value_pct": summary.get("ttwror_cumulative"), "status": "available" if quality.get("status") == "complete" and summary.get("ttwror_cumulative") is not None else "not_calculable"}, {"key": "net_contributions", "label": "Nettoeinzahlungen ins Anlageportfolio", "value_chf": net_contributions, "status": "available" if net_contributions is not None else "not_calculable"}, {"key": "data_as_of", "label": "Datenstand", "value_date": current["data_as_of"], "status": "available" if current["data_as_of"] else "unknown"}, ], "totals": {"captured_wealth_chf": _money(current["total"]), "investments_chf": _money(current["investments"]), "bank_cash_chf": _money(current["cash"]), "complete": current["complete"]}, "history": {"status": "available" if len(history_points) >= 2 else "not_calculable", "points": history_points, "household_cashflow_events": household_events, "investment_cashflow_events": investment_events, "reason": history_reason}, "distribution": current["distribution"], "sources": current["sources"], "readiness": readiness, "diagnostics": diagnostics, "performance_coverage": coverage, "policy": policy, "planning": {"free_plannable_chf": free_row.get("value_chf") if free_row else None, "available": bool(free_row and free_row.get("value_chf") is not None), "link": "/planning/budget/planning", "included_in_wealth": False}, "data_quality": {"freshness_status": current_freshness, "reconciliation_status": reconciliation_status, "performance_status": quality.get("status", "unavailable"), "performance_reasons": quality.get("reason_codes", ["historical_portfolio_valuations_missing"]), "missing_areas": missing_areas, "unassigned": current["unassigned_items"]}, "hints": hints[:3], "method": {"wealth_change": "Endwert minus Anfangswert innerhalb des gesamten Haushalts; interne Transfers neutral.", "investment_result": "Endwert minus Anfangswert minus Nettoeinzahlungen innerhalb des Anlageportfolios.", "return": "Bestehende TTWROR-Engine; nur bei vollständigen Bewertungen und klassifizierten Kapitalflüssen."}, } __HERMES_CWD_8d46a20096ed__/home/agent/.hermes/worktrees/FinanceManager-sprint23__HERMES_CWD_8d46a20096ed__