     1	from __future__ import annotations
     2	
     3	from calendar import monthrange
     4	from datetime import date, timedelta
     5	from decimal import Decimal
     6	import re
     7	from sqlite3 import Connection
     8	from typing import Any
     9	
    10	from jarvis_finance.services.cash_service import authoritative_cash_movements
    11	from jarvis_finance.services.daily_valuations import SOURCE_KEY as CRYPTO_VALUATION_SOURCE
    12	
    13	LEGACY_CRYPTO_VALUATION_SOURCE = "daily_crypto_current_valuation_v1"
    14	
    15	MONEY = Decimal("0.01")
    16	PERCENT = Decimal("0.0001")
    17	MODEL_PERIODS = {"since_anchor", "1m", "3m", "ytd", "1y", "all"}
    18	SNAPSHOT_PRECEDENCE = {
    19	    "reconciliation": 4,
    20	    "manual_balance": 3,
    21	    "csv_anchor_balance": 2,
    22	    "calculated_balance": 1,
    23	}
    24	COMPONENT_LABELS = {
    25	    "postfinance": "PostFinance",
    26	    "truewealth": "True Wealth",
    27	    "crypto": "Krypto",
    28	    "bank_cash": "Bankguthaben",
    29	    "other_assets": "Weitere Anlagen",
    30	}
    31	
    32	
    33	def _money(value: Decimal | None) -> str | None:
    34	    return None if value is None else format(value.quantize(MONEY), "f")
    35	
    36	
    37	def _subtract_months(day: date, months: int) -> date:
    38	    absolute = day.year * 12 + day.month - 1 - months
    39	    year, month_index = divmod(absolute, 12)
    40	    month = month_index + 1
    41	    return date(year, month, min(day.day, monthrange(year, month)[1]))
    42	
    43	
    44	def _authoritative_cash_movements(
    45	    conn: Connection, *, account_id: str, after: str | None, through: str
    46	) -> dict[str, Any]:
    47	    return authoritative_cash_movements(
    48	        conn, account_id=account_id, after=after, through=through
    49	    )
    50	
    51	
    52	def effective_cash_evidence(
    53	    conn: Connection, *, account_id: str, as_of: str
    54	) -> dict[str, Any]:
    55	    """Return one account's effective cash evidence without writing.
    56	
    57	    The newest business date wins.  If several valid snapshots exist on that
    58	    date, the deterministic precedence is reconciliation > manual > CSV anchor.
    59	    Confirmed movements are then applied forward only.  With no anchor, a
    60	    non-empty confirmed ledger may provide a calculated balance; empty evidence
    61	    is unavailable rather than a false CHF 0.
    62	    """
    63	
    64	    snapshot = conn.execute(
    65	        """SELECT * FROM cash_account_snapshots
    66	             WHERE account_id=? AND balance_date<=? AND amount_chf IS NOT NULL
    67	             ORDER BY balance_date DESC,
    68	               CASE snapshot_type
    69	                 WHEN 'reconciliation' THEN 4
    70	                 WHEN 'manual_balance' THEN 3
    71	                 WHEN 'csv_anchor_balance' THEN 2
    72	                 WHEN 'calculated_balance' THEN 1
    73	                 ELSE 0 END DESC,
    74	               created_at DESC,snapshot_id DESC LIMIT 1""",
    75	        (account_id, as_of),
    76	    ).fetchone()
    77	    if snapshot:
    78	        anchor_day = str(snapshot["balance_date"])
    79	        anchor = Decimal(str(snapshot["amount_chf"]))
    80	        movement_evidence = _authoritative_cash_movements(
    81	            conn, account_id=account_id, after=anchor_day, through=as_of
    82	        )
    83	        movement = movement_evidence["amount"]
    84	        return {
    85	            "account_id": account_id,
    86	            "anchor_type": str(snapshot["snapshot_type"]),
    87	            "anchor_date": anchor_day,
    88	            "anchor_value_chf": _money(anchor),
    89	            "movement_chf": _money(movement),
    90	            "value_chf": _money(anchor + movement),
    91	            "quality": "confirmed" if anchor_day == as_of and not movement else "carried",
    92	            "source_date": movement_evidence["last_date"] or anchor_day,
    93	            "movement_source": movement_evidence["source"],
    94	        }
    95	
    96	    movement_evidence = _authoritative_cash_movements(
    97	        conn, account_id=account_id, after=None, through=as_of
    98	    )
    99	    if movement_evidence["count"]:
   100	        value = movement_evidence["amount"]
   101	        return {
   102	            "account_id": account_id,
   103	            "anchor_type": "calculated_balance",
   104	            "anchor_date": None,
   105	            "anchor_value_chf": None,
   106	            "movement_chf": _money(value),
   107	            "value_chf": _money(value),
   108	            "quality": "modelled",
   109	            "source_date": movement_evidence["last_date"],
   110	            "movement_source": movement_evidence["source"],
   111	        }
   112	    return {
   113	        "account_id": account_id,
   114	        "anchor_type": None,
   115	        "anchor_date": None,
   116	        "anchor_value_chf": None,
   117	        "movement_chf": None,
   118	        "value_chf": None,
   119	        "quality": "unavailable",
   120	        "source_date": None,
   121	        "movement_source": None,
   122	    }
   123	
   124	
   125	def _role_account_ids(conn: Connection, role: str) -> list[str]:
   126	    return [
   127	        str(row[0])
   128	        for row in conn.execute(
   129	            """SELECT a.account_id FROM accounts a
   130	                 JOIN performance_scope_classifications psc ON psc.account_id=a.account_id
   131	                WHERE a.is_active=1 AND psc.classification_role=?
   132	                  AND psc.decision_version='investment_performance_scope_v1'
   133	                ORDER BY a.account_id""",
   134	            (role,),
   135	        ).fetchall()
   136	    ]
   137	
   138	
   139	def _latest_model_rows(
   140	    conn: Connection,
   141	    *,
   142	    account_ids: list[str],
   143	    through: str,
   144	    source: str | None = None,
   145	    source_prefix: str | None = None,
   146	) -> list[dict[str, Any]]:
   147	    if not account_ids:
   148	        return []
   149	    placeholders = ",".join("?" for _ in account_ids)
   150	    if source and source_prefix:
   151	        raise ValueError("source and source_prefix are mutually exclusive")
   152	    source_sql = " AND source=?" if source else " AND source LIKE ?" if source_prefix else ""
   153	    params: list[Any] = [*account_ids, through]
   154	    if source:
   155	        params.append(source)
   156	    elif source_prefix:
   157	        params.append(f"{source_prefix}%")
   158	    rows = conn.execute(
   159	        f"""WITH ranked AS (
   160	              SELECT scope_id,substr(valuation_at,1,10) day,value_original,currency,
   161	                     fx_rate_to_base,source,snapshot_version,captured_at,snapshot_id,
   162	                     ROW_NUMBER() OVER(
   163	                       PARTITION BY scope_id,substr(valuation_at,1,10)
   164	                       ORDER BY snapshot_version DESC,captured_at DESC,snapshot_id DESC
   165	                     ) rn
   166	                FROM portfolio_valuation_snapshots
   167	               WHERE scope_kind='account' AND scope_id IN ({placeholders})
   168	                 AND substr(valuation_at,1,10)<=?
   169	                 AND quality_status IN ('complete','ok','partial')
   170	                 {source_sql}
   171	             ) SELECT * FROM ranked WHERE rn=1 ORDER BY day,scope_id""",
   172	        tuple(params),
   173	    ).fetchall()
   174	    result = []
   175	    for row in rows:
   176	        try:
   177	            observation_day = date.fromisoformat(str(row["day"]))
   178	            if str(row["currency"]) == "CHF":
   179	                value = Decimal(str(row["value_original"]))
   180	            elif row["fx_rate_to_base"] is not None:
   181	                value = Decimal(str(row["value_original"])) * Decimal(
   182	                    str(row["fx_rate_to_base"])
   183	                )
   184	            else:
   185	                continue
   186	        except Exception:
   187	            continue
   188	        result.append(
   189	            {
   190	                "account_id": str(row["scope_id"]),
   191	                "date": observation_day.isoformat(),
   192	                "value": value,
   193	                "source": str(row["source"]),
   194	                "captured_at": str(row["captured_at"]),
   195	            }
   196	        )
   197	    return result
   198	
   199	
   200	def _official_rows(
   201	    conn: Connection,
   202	    *,
   203	    account_ids: list[str],
   204	    through: str,
   205	    allowed_sources: tuple[str, ...],
   206	) -> list[dict[str, Any]]:
   207	    if not account_ids:
   208	        return []
   209	    placeholders = ",".join("?" for _ in account_ids)
   210	    source_placeholders = ",".join("?" for _ in allowed_sources)
   211	    rows = conn.execute(
   212	        f"""WITH ranked AS (
   213	              SELECT account_id,valuation_date,total_value_chf,source_type,
   214	                     COALESCE(valuation_at,created_at) captured_at,
   215	                     ROW_NUMBER() OVER(PARTITION BY account_id,valuation_date
   216	                       ORDER BY COALESCE(valuation_at,created_at) DESC,snapshot_id DESC) rn
   217	                FROM account_value_snapshots
   218	               WHERE account_id IN ({placeholders}) AND valuation_date<=?
   219	                 AND COALESCE(is_active,1)=1 AND updated_at IS NULL
   220	                 AND source_type IN ({source_placeholders})
   221	                 AND quality_status IN ('ok','complete')
   222	             ) SELECT * FROM ranked WHERE rn=1 ORDER BY valuation_date,account_id""",
   223	        (*account_ids, through, *allowed_sources),
   224	    ).fetchall()
   225	    result = []
   226	    for row in rows:
   227	        try:
   228	            observation_day = date.fromisoformat(str(row["valuation_date"]))
   229	            value = Decimal(str(row["total_value_chf"]))
   230	        except Exception:
   231	            continue
   232	        if not value.is_finite() or value < Decimal("0"):
   233	            continue
   234	        result.append({
   235	            "account_id": str(row["account_id"]),
   236	            "date": observation_day.isoformat(),
   237	            "value": value,
   238	            "source": str(row["source_type"]),
   239	            "captured_at": str(row["captured_at"]),
   240	        })
   241	    return result
   242	
   243	
   244	def _postfinance_events(conn: Connection, *, through: str) -> tuple[dict[str, dict[str, Any]], list[dict[str, Any]]]:
   245	    depot_ids = _role_account_ids(conn, "postfinance_etrading_depot")
   246	    cash_ids = _role_account_ids(conn, "postfinance_etrading_cash")
   247	    official = _official_rows(
   248	        conn,
   249	        account_ids=depot_ids,
   250	        through=through,
   251	        allowed_sources=("postfinance_official_import",),
   252	    )
   253	    model_rows = _latest_model_rows(
   254	        conn,
   255	        account_ids=depot_ids + cash_ids,
   256	        through=through,
   257	        source_prefix="daily_market_fx_v",
   258	    )
   259	    model_by_day: dict[str, dict[str, Decimal]] = {}
   260	    for row in model_rows:
   261	        model_by_day.setdefault(row["date"], {})[row["account_id"]] = row["value"]
   262	    models: dict[str, Decimal] = {}
   263	    model_observations: dict[str, dict[str, Any]] = {}
   264	    for day, values in model_by_day.items():
   265	        if depot_ids and cash_ids and all(account in values for account in depot_ids + cash_ids):
   266	            models[day] = sum(values.values(), Decimal("0"))
   267	            model_observations[day] = {
   268	                "value": models[day],
   269	                "captured_at": max(
   270	                    row["captured_at"] for row in model_rows if row["date"] == day
   271	                ),
   272	            }
   273	    events = {
   274	        day: {"value": value, "quality": "modelled", "source_date": day}
   275	        for day, value in models.items()
   276	    }
   277	    for row in official:
   278	        events[row["date"]] = {
   279	            "value": row["value"],
   280	            "quality": "confirmed",
   281	            "source_date": row["date"],
   282	        }
   283	    markers = _correction_markers(
   284	        source_key="postfinance", official=official, models=model_observations
   285	    )
   286	    return events, markers
   287	
   288	
   289	def _truewealth_events(conn: Connection, *, through: str) -> tuple[dict[str, dict[str, Any]], list[dict[str, Any]]]:
   290	    account_ids = _role_account_ids(conn, "canonical_truewealth_total_value")
   291	    official = _official_rows(
   292	        conn,
   293	        account_ids=account_ids,
   294	        through=through,
   295	        allowed_sources=("truewealth_official_import", "manual_total_value"),
   296	    )
   297	    model_rows = _latest_model_rows(
   298	        conn,
   299	        account_ids=account_ids,
   300	        through=through,
   301	        source="truewealth_modelled_daily",
   302	    )
   303	    models: dict[str, Decimal] = {}
   304	    model_observations: dict[str, dict[str, Any]] = {}
   305	    for row in model_rows:
   306	        models[row["date"]] = models.get(row["date"], Decimal("0")) + row["value"]
   307	        model_observations[row["date"]] = {
   308	            "value": models[row["date"]],
   309	            "captured_at": max(
   310	                str(row["captured_at"]),
   311	                str(model_observations.get(row["date"], {}).get("captured_at", "")),
   312	            ),
   313	        }
   314	    events = {
   315	        day: {"value": value, "quality": "modelled", "source_date": day}
   316	        for day, value in models.items()
   317	    }
   318	    for row in official:
   319	        events[row["date"]] = {
   320	            "value": row["value"],
   321	            "quality": "confirmed",
   322	            "source_date": row["date"],
   323	        }
   324	    return events, _correction_markers(
   325	        source_key="truewealth", official=official, models=model_observations
   326	    )
   327	
   328	
   329	def _crypto_events(conn: Connection, *, through: str) -> dict[str, dict[str, Any]]:
   330	    account_ids = _role_account_ids(conn, "crypto_portfolio")
   331	    if not account_ids:
   332	        account_ids = [
   333	            str(row[0])
   334	            for row in conn.execute(
   335	                """SELECT DISTINCT scope_id FROM portfolio_valuation_snapshots
   336	                    WHERE scope_kind='account' AND source IN (?,?)
   337	                      AND substr(valuation_at,1,10)<=?""",
   338	                (
   339	                    CRYPTO_VALUATION_SOURCE,
   340	                    LEGACY_CRYPTO_VALUATION_SOURCE,
   341	                    through,
   342	                ),
   343	            ).fetchall()
   344	        ]
   345	    rows = []
   346	    for source in (CRYPTO_VALUATION_SOURCE, LEGACY_CRYPTO_VALUATION_SOURCE):
   347	        rows.extend(
   348	            _latest_model_rows(
   349	                conn,
   350	                account_ids=account_ids,
   351	                through=through,
   352	                source=source,
   353	            )
   354	        )
   355	    latest_by_account_day: dict[tuple[str, str], dict[str, Any]] = {}
   356	    for row in rows:
   357	        key = (row["account_id"], row["date"])
   358	        if key not in latest_by_account_day or str(row["captured_at"]) > str(
   359	            latest_by_account_day[key]["captured_at"]
   360	        ):
   361	            latest_by_account_day[key] = row
   362	    values: dict[str, Decimal] = {}
   363	    for row in latest_by_account_day.values():
   364	        values[row["date"]] = values.get(row["date"], Decimal("0")) + row["value"]
   365	    return {
   366	        day: {"value": value, "quality": "modelled", "source_date": day}
   367	        for day, value in values.items()
   368	    }
   369	
   370	
   371	def _other_asset_events(conn: Connection, *, through: str) -> dict[str, dict[str, Any]]:
   372	    """Aggregate confirmed non-cash account values without inventing daily precision."""
   373	    rows = conn.execute(
   374	        """WITH ranked AS (
   375	             SELECT s.account_id,s.valuation_date,s.total_value_chf,
   376	                    ROW_NUMBER() OVER(
   377	                      PARTITION BY s.account_id,s.valuation_date
   378	                      ORDER BY COALESCE(s.valuation_at,s.created_at) DESC,s.snapshot_id DESC
   379	                    ) rn
   380	               FROM account_value_snapshots s
   381	               JOIN accounts a ON a.account_id=s.account_id
   382	              WHERE a.is_active=1 AND a.account_type IN ('other_asset','membership')
   383	                AND s.valuation_date<=? AND COALESCE(s.is_active,1)=1
   384	                AND s.quality_status IN ('confirmed','ok','complete')
   385	           ) SELECT account_id,valuation_date,total_value_chf
   386	               FROM ranked WHERE rn=1 ORDER BY valuation_date,account_id""",
   387	        (through,),
   388	    ).fetchall()
   389	    latest: dict[str, Decimal] = {}
   390	    events: dict[str, dict[str, Any]] = {}
   391	    for row in rows:
   392	        try:
   393	            value = Decimal(str(row["total_value_chf"]))
   394	        except Exception:
   395	            continue
   396	        if not value.is_finite() or value < Decimal("0"):
   397	            continue
   398	        latest[str(row["account_id"])] = value
   399	        day = str(row["valuation_date"])
   400	        events[day] = {
   401	            "value": sum(latest.values(), Decimal("0")),
   402	            "quality": "confirmed",
   403	            "source_date": day,
   404	        }
   405	    return events
   406	
   407	
   408	def _manual_cash_correction_markers(conn: Connection, *, through: str) -> list[dict[str, str]]:
   409	    rows = conn.execute(
   410	        """SELECT snapshot_id,account_id,balance_date,amount_chf,created_at
   411	             FROM cash_account_snapshots
   412	            WHERE source='manual_screenshot_snapshot' AND balance_date<=?
   413	            ORDER BY balance_date,created_at,snapshot_id""",
   414	        (through,),
   415	    ).fetchall()
   416	    grouped: dict[str, dict[str, Decimal]] = {}
   417	    for row in rows:
   418	        previous = conn.execute(
   419	            """SELECT amount_chf FROM cash_account_snapshots
   420	                 WHERE account_id=? AND (
   421	                   balance_date<? OR (balance_date=? AND (created_at<? OR (created_at=? AND snapshot_id<?)))
   422	                 )
   423	                 ORDER BY balance_date DESC,created_at DESC,snapshot_id DESC LIMIT 1""",
   424	            (
   425	                row["account_id"], row["balance_date"], row["balance_date"],
   426	                row["created_at"], row["created_at"], row["snapshot_id"],
   427	            ),
   428	        ).fetchone()
   429	        day = str(row["balance_date"])
   430	        values = grouped.setdefault(day, {"confirmed": Decimal("0"), "previous": Decimal("0")})
   431	        values["confirmed"] += Decimal(str(row["amount_chf"]))
   432	        values["previous"] += Decimal(str(previous["amount_chf"])) if previous else Decimal("0")
   433	    return [
   434	        {
   435	            "date": day,
   436	            "source_key": "bank_cash",
   437	            "confirmed_value_chf": _money(values["confirmed"]) or "0.00",
   438	            "predecessor_model_value_chf": _money(values["previous"]) or "0.00",
   439	            "difference_chf": _money(values["confirmed"] - values["previous"]) or "0.00",
   440	        }
   441	        for day, values in sorted(grouped.items())
   442	    ]
   443	
   444	
   445	def _correction_markers(
   446	    *,
   447	    source_key: str,
   448	    official: list[dict[str, Any]],
   449	    models: dict[str, dict[str, Any]],
   450	) -> list[dict[str, str]]:
   451	    markers = []
   452	    model_days = sorted(models)
   453	    for row in official:
   454	        predecessor_days = [
   455	            day
   456	            for day in model_days
   457	            if day < row["date"]
   458	            or (
   459	                day == row["date"]
   460	                and str(models[day]["captured_at"]) < str(row["captured_at"])
   461	            )
   462	        ]
   463	        if not predecessor_days:
   464	            continue
   465	        predecessor_day = predecessor_days[-1]
   466	        predecessor = models[predecessor_day]["value"]
   467	        markers.append(
   468	            {
   469	                "date": row["date"],
   470	                "source_key": source_key,
   471	                "confirmed_value_chf": _money(row["value"]) or "0.00",
   472	                "predecessor_model_value_chf": _money(predecessor) or "0.00",
   473	                "difference_chf": _money(row["value"] - predecessor) or "0.00",
   474	            }
   475	        )
   476	    return markers
   477	
   478	
   479	def _event_at_or_before(
   480	    events: dict[str, dict[str, Any]], day: str
   481	) -> dict[str, Any] | None:
   482	    eligible = [event_day for event_day in events if event_day <= day]
   483	    if not eligible:
   484	        return None
   485	    source_day = max(eligible)
   486	    event = events[source_day]
   487	    return {
   488	        "value": event["value"],
   489	        "quality": event["quality"] if source_day == day else "carried",
   490	        "source_date": source_day,
   491	    }
   492	
   493	
   494	def _bank_accounts(conn: Connection) -> list[dict[str, str]]:
   495	    rows = conn.execute(
   496	        """SELECT a.account_id,a.account_name,COALESCE(psc.classification_role,'') role
   497	             FROM accounts a
   498	             LEFT JOIN performance_scope_classifications psc ON psc.account_id=a.account_id
   499	            WHERE a.is_active=1 AND a.account_type='cash'
   500	              AND (
   501	                  EXISTS(SELECT 1 FROM cash_account_snapshots s WHERE s.account_id=a.account_id)
   502	                  OR EXISTS(SELECT 1 FROM transactions t WHERE t.account_id=a.account_id)
   503	                  OR EXISTS(SELECT 1 FROM cash_balances b WHERE b.account_id=a.account_id)
   504	                  OR EXISTS(
   505	                      SELECT 1 FROM budget_accounts ba
   506	                      WHERE ba.linked_account_id=a.account_id AND ba.is_active=1
   507	                  )
   508	                  OR EXISTS(
   509	                      SELECT 1 FROM household_account_source_mappings m
   510	                      WHERE m.canonical_account_id=a.account_id AND m.is_active=1
   511	                  )
   512	              )
   513	            ORDER BY a.account_name,a.account_id"""
   514	    ).fetchall()
   515	    excluded_roles = {"postfinance_etrading_cash", "postfinance_efinance_control"}
   516	    return [
   517	        {"account_id": str(row["account_id"]), "label": str(row["account_name"])}
   518	        for row in rows
   519	        if str(row["role"] or "") not in excluded_roles
   520	    ]
   521	
   522	
   523	def _safe_bank_label(label: str) -> str:
   524	    """Expose only a generic label and an already-masked four-digit suffix."""
   525	
   526	    suffix = re.search(r"(?:•{4}|\*{4}|x{4})\s*(\d{4})\b", label, re.IGNORECASE)
   527	    return f"Bankkonto •••• {suffix.group(1)}" if suffix else "Bankkonto"
   528	
   529	
   530	def _earliest_evidence(conn: Connection, *, fallback: date) -> date:
   531	    rows = conn.execute(
   532	        """SELECT day FROM (
   533	             SELECT valuation_date day FROM account_value_snapshots
   534	              WHERE COALESCE(is_active,1)=1 AND updated_at IS NULL
   535	             UNION ALL SELECT substr(valuation_at,1,10) FROM portfolio_valuation_snapshots
   536	             UNION ALL SELECT balance_date FROM cash_account_snapshots
   537	           )"""
   538	    ).fetchall()
   539	    valid_days: list[date] = []
   540	    for row in rows:
   541	        try:
   542	            valid_days.append(date.fromisoformat(str(row[0])))
   543	        except (TypeError, ValueError):
   544	            continue
   545	    return min(valid_days, default=fallback)
   546	
   547	
   548	def _period_start(
   549	    conn: Connection, *, period: str, as_of: date, latest_anchor: date | None
   550	) -> date:
   551	    if period == "since_anchor":
   552	        return latest_anchor or as_of
   553	    if period == "1m":
   554	        return _subtract_months(as_of, 1)
   555	    if period == "3m":
   556	        return _subtract_months(as_of, 3)
   557	    if period == "ytd":
   558	        return date(as_of.year, 1, 1)
   559	    if period == "1y":
   560	        return _subtract_months(as_of, 12)
   561	    if period == "all":
   562	        return _earliest_evidence(conn, fallback=as_of)
   563	    raise ValueError("period must be since_anchor, 1m, 3m, ytd, 1y or all")
   564	
   565	
   566	def build_modelled_wealth_development(
   567	    conn: Connection, *, period: str = "1m", as_of: str | None = None
   568	) -> dict[str, Any]:
   569	    """Compose existing immutable valuation/snapshot sources into one read model."""
   570	
   571	    if period not in MODEL_PERIODS:
   572	        raise ValueError("period must be since_anchor, 1m, 3m, ytd, 1y or all")
   573	    reference = date.fromisoformat(as_of) if as_of else date.today()
   574	    through = reference.isoformat()
   575	    postfinance, pf_markers = _postfinance_events(conn, through=through)
   576	    truewealth, tw_markers = _truewealth_events(conn, through=through)
   577	    crypto = _crypto_events(conn, through=through)
   578	    other_assets = _other_asset_events(conn, through=through)
   579	    investment_events = {
   580	        "postfinance": postfinance,
   581	        "truewealth": truewealth,
   582	        "crypto": crypto,
   583	        "other_assets": other_assets,
   584	    }
   585	    expected_investment = {
   586	        "postfinance": bool(
   587	            _role_account_ids(conn, "postfinance_etrading_depot")
   588	            or _role_account_ids(conn, "postfinance_etrading_cash")
   589	        ),
   590	        "truewealth": bool(
   591	            _role_account_ids(conn, "canonical_truewealth_total_value")
   592	        ),
   593	        "crypto": bool(_role_account_ids(conn, "crypto_portfolio")),
   594	        "other_assets": bool(other_assets),
   595	    }
   596	    # Household anchors come from confirmed portfolio-import anchors. Component-only
   597	    # cash/membership corrections remain event markers and must not move the solid-line
   598	    # boundary or make mixed-date values look fully confirmed.
   599	    confirmed_days: list[date] = []
   600	    for events in (postfinance, truewealth):
   601	        for day, event in events.items():
   602	            if event["quality"] != "confirmed":
   603	                continue
   604	            try:
   605	                confirmed_days.append(date.fromisoformat(day))
   606	            except ValueError:
   607	                continue
   608	    latest_anchor = max(confirmed_days, default=None)
   609	    start = _period_start(
   610	        conn, period=period, as_of=reference, latest_anchor=latest_anchor
   611	    )
   612	    if start > reference:
   613	        start = reference
   614	    if (reference - start).days > 5000:
   615	        start = reference - timedelta(days=5000)
   616	
   617	    bank_accounts = _bank_accounts(conn)
   618	    points: list[dict[str, Any]] = []
   619	    unknown_identity_by_day: dict[str, frozenset[str]] = {}
   620	    event_dates = {
   621	        day
   622	        for events in investment_events.values()
   623	        for day in events
   624	        if start.isoformat() <= day <= through
   625	    }
   626	    event_dates.update(
   627	        str(row[0])
   628	        for row in conn.execute(
   629	            "SELECT DISTINCT balance_date FROM cash_account_snapshots WHERE balance_date BETWEEN ? AND ?",
   630	            (start.isoformat(), through),
   631	        ).fetchall()
   632	    )
   633	    for account in bank_accounts:
   634	        movement_evidence = _authoritative_cash_movements(
   635	            conn,
   636	            account_id=account["account_id"],
   637	            after=start.isoformat(),
   638	            through=through,
   639	        )
   640	        event_dates.update(movement_evidence["days"])
   641	    cursor = start
   642	    while cursor <= reference:
   643	        day = cursor.isoformat()
   644	        components: list[dict[str, Any]] = []
   645	        qualities: list[str] = []
   646	        missing_investment: list[str] = []
   647	        known_total = Decimal("0")
   648	        for key in ("postfinance", "truewealth", "crypto", "other_assets"):
   649	            selected = _event_at_or_before(investment_events[key], day)
   650	            value = selected["value"] if selected else None
   651	            quality = selected["quality"] if selected else "unavailable"
   652	            if value is not None:
   653	                known_total += value
   654	                qualities.append(quality)
   655	            elif expected_investment[key]:
   656	                missing_investment.append(key)
   657	            components.append(
   658	                {
   659	                    "key": key,
   660	                    "label": COMPONENT_LABELS[key],
   661	                    "value_chf": _money(value),
   662	                    "quality": quality,
   663	                    "source_date": selected["source_date"] if selected else None,
   664	                }
   665	            )
   666	
   667	        bank_total = Decimal("0")
   668	        bank_qualities: list[str] = []
   669	        unknown_on_day: list[str] = []
   670	        bank_source_days: list[str] = []
   671	        for account in bank_accounts:
   672	            evidence = effective_cash_evidence(
   673	                conn, account_id=account["account_id"], as_of=day
   674	            )
   675	            if evidence["value_chf"] is None:
   676	                unknown_on_day.append(account["account_id"])
   677	                continue
   678	            bank_total += Decimal(evidence["value_chf"])
   679	            bank_qualities.append(str(evidence["quality"]))
   680	            if evidence["source_date"]:
   681	                bank_source_days.append(str(evidence["source_date"]))
   682	        if bank_qualities:
   683	            bank_quality = (
   684	                "modelled"
   685	                if "modelled" in bank_qualities
   686	                else "carried"
   687	                if "carried" in bank_qualities
   688	                else "confirmed"
   689	            )
   690	            known_total += bank_total
   691	            qualities.append(bank_quality)
   692	            bank_value = _money(bank_total)
   693	        else:
   694	            bank_quality = "unavailable"
   695	            bank_value = None
   696	        components.append(
   697	            {
   698	                "key": "bank_cash",
   699	                "label": COMPONENT_LABELS["bank_cash"],
   700	                "value_chf": bank_value,
   701	                "quality": bank_quality,
   702	                "source_date": min(bank_source_days, default=None),
   703	            }
   704	        )
   705	        if not qualities:
   706	            cursor += timedelta(days=1)
   707	            continue
   708	        has_confirmed_anchor = any(
   709	            component["quality"] == "confirmed" and component["source_date"] == day
   710	            for component in components
   711	        )
   712	        has_modelled_value = (latest_anchor is None or cursor > latest_anchor) and any(
   713	            component["quality"] == "modelled" and component["source_date"] == day
   714	            for component in components
   715	        )
   716	        point_quality = (
   717	            "incomplete"
   718	            if unknown_on_day or missing_investment
   719	            else "modelled"
   720	            if "modelled" in qualities and (latest_anchor is None or cursor > latest_anchor)
   721	            else "carried"
   722	            if "carried" in qualities
   723	            else "confirmed"
   724	        )
   725	        points.append(
   726	            {
   727	                "date": day,
   728	                "value_chf": _money(known_total) or "0.00",
   729	                "quality": point_quality,
   730	                "has_confirmed_anchor": has_confirmed_anchor,
   731	                "has_modelled_value": has_modelled_value,
   732	                "components": components,
   733	                "excluded_account_count": len(unknown_on_day)
   734	                + len(missing_investment),
   735	            }
   736	        )
   737	        unknown_identity_by_day[day] = frozenset(
   738	            [f"bank:{account_id}" for account_id in unknown_on_day]
   739	            + [f"component:{key}" for key in missing_investment]
   740	        )
   741	        cursor += timedelta(days=1)
   742	
   743	    current_point = points[-1] if points else None
   744	    current_unknown = []
   745	    for index, account in enumerate(bank_accounts, start=1):
   746	        evidence = effective_cash_evidence(
   747	            conn, account_id=account["account_id"], as_of=through
   748	        )
   749	        if evidence["value_chf"] is None:
   750	            current_unknown.append(
   751	                {
   752	                    "key": f"unknown-bank-{index}",
   753	                    "label": _safe_bank_label(account["label"]),
   754	                    "reason_code": "confirmed_cash_evidence_missing",
   755	                }
   756	            )
   757	    for key in ("postfinance", "truewealth", "crypto", "other_assets"):
   758	        if expected_investment[key] and not _event_at_or_before(
   759	            investment_events[key], through
   760	        ):
   761	            current_unknown.append(
   762	                {
   763	                    "key": f"unknown-component-{key}",
   764	                    "label": COMPONENT_LABELS[key],
   765	                    "reason_code": "stored_valuation_evidence_missing",
   766	                }
   767	            )
   768	
   769	    anchor_point = None
   770	    if latest_anchor:
   771	        anchor_point = next(
   772	            (point for point in points if point["date"] == latest_anchor.isoformat()),
   773	            None,
   774	        )
   775	    anchor = (
   776	        {
   777	            "date": anchor_point["date"],
   778	            "value_chf": anchor_point["value_chf"],
   779	            "quality": anchor_point["quality"],
   780	        }
   781	        if anchor_point
   782	        else None
   783	    )
   784	    comparable_baseline = points[0] if points else None
   785	    if current_point:
   786	        current_unknown_identity = unknown_identity_by_day.get(
   787	            str(current_point["date"]), frozenset()
   788	        )
   789	        current_known_keys = {
   790	            str(item["key"])
   791	            for item in current_point["components"]
   792	            if item["value_chf"] is not None
   793	        }
   794	        comparable_baseline = next(
   795	            (
   796	                point
   797	                for point in points
   798	                if {
   799	                    str(item["key"])
   800	                    for item in point["components"]
   801	                    if item["value_chf"] is not None
   802	                }
   803	                >= current_known_keys
   804	                and unknown_identity_by_day.get(
   805	                    str(point["date"]), frozenset()
   806	                )
   807	                == current_unknown_identity
   808	            ),
   809	            comparable_baseline,
   810	        )
   811	    baseline_point = (
   812	        anchor_point
   813	        if period == "since_anchor" and anchor_point is not None
   814	        else comparable_baseline
   815	    )
   816	    baseline = (
   817	        {
   818	            "date": baseline_point["date"],
   819	            "value_chf": baseline_point["value_chf"],
   820	            "quality": baseline_point["quality"],
   821	        }
   822	        if baseline_point
   823	        else None
   824	    )
   825	    current = (
   826	        {
   827	            "date": current_point["date"],
   828	            "value_chf": current_point["value_chf"],
   829	            "quality": current_point["quality"],
   830	        }
   831	        if current_point
   832	        else None
   833	    )
   834	    baseline_value = Decimal(baseline["value_chf"]) if baseline else None
   835	    change = (
   836	        Decimal(current["value_chf"]) - baseline_value
   837	        if current and baseline_value is not None
   838	        else None
   839	    )
   840	    change_pct = None
   841	    if (
   842	        change is not None
   843	        and baseline_value is not None
   844	        and baseline_value != Decimal("0")
   845	    ):
   846	        change_pct = change / baseline_value * Decimal("100")
   847	    chart_visible = len(event_dates) >= 2 and len(points) >= 2
   848	    component_summaries: list[dict[str, Any]] = []
   849	    if current_point:
   850	        baseline_components = {
   851	            str(item["key"]): item for item in (baseline_point or {}).get("components", [])
   852	        }
   853	        for item in current_point["components"]:
   854	            key = str(item["key"])
   855	            opening = baseline_components.get(key)
   856	            current_value = (
   857	                Decimal(str(item["value_chf"]))
   858	                if item["value_chf"] is not None
   859	                else None
   860	            )
   861	            opening_value = (
   862	                Decimal(str(opening["value_chf"]))
   863	                if opening and opening["value_chf"] is not None
   864	                else None
   865	            )
   866	            component_change = (
   867	                current_value - opening_value
   868	                if current_value is not None and opening_value is not None
   869	                else None
   870	            )
   871	            component_change_pct = (
   872	                component_change / opening_value * Decimal("100")
   873	                if component_change is not None
   874	                and opening_value is not None
   875	                and opening_value != Decimal("0")
   876	                else None
   877	            )
   878	            quality = str(item["quality"])
   879	            if key == "bank_cash" and current_unknown:
   880	                quality = "incomplete"
   881	            component_summaries.append(
   882	                {
   883	                    "key": key,
   884	                    "label": str(item["label"]),
   885	                    "current_value_chf": _money(current_value),
   886	                    "change_chf": _money(component_change),
   887	                    "change_pct": format(component_change_pct.quantize(PERCENT), "f")
   888	                    if component_change_pct is not None
   889	                    else None,
   890	                    "quality": quality,
   891	                    "as_of": item["source_date"],
   892	                    "unknown_account_count": sum(
   893	                        1
   894	                        for unknown in current_unknown
   895	                        if (
   896	                            str(unknown["key"]).startswith("unknown-bank-")
   897	                            if key == "bank_cash"
   898	                            else unknown["key"] == f"unknown-component-{key}"
   899	                        )
   900	                    ),
   901	                }
   902	            )
   903	    return {
   904	        "status": "available" if points else "unavailable",
   905	        "period": {
   906	            "preset": period,
   907	            "from": start.isoformat(),
   908	            "to": through,
   909	        },
   910	        "last_confirmed_anchor_date": latest_anchor.isoformat() if latest_anchor else None,
   911	        "anchor": anchor,
   912	        "baseline": baseline,
   913	        "current": current,
   914	        "change_chf": _money(change),
   915	        "change_pct": format(change_pct.quantize(PERCENT), "f")
   916	        if change_pct is not None
   917	        else None,
   918	        "chart_visible": chart_visible,
   919	        "points": points,
   920	        "components": component_summaries,
   921	        "correction_markers": sorted(
   922	            [
   923	                marker
   924	                for marker in pf_markers + tw_markers + _manual_cash_correction_markers(conn, through=through)
   925	                if start.isoformat() <= marker["date"] <= through
   926	            ],
   927	            key=lambda item: (item["date"], item["source_key"]),
   928	        ),
   929	        "unknown_accounts": current_unknown,
   930	        "method": "modelled_wealth_daily_v1",
   931	        "disclaimer": "Geschätzte Entwicklung aus bestätigten Ankern, gespeicherten Tagesbewertungen und fortgeschriebenen bekannten Salden; keine verifizierte TTWROR oder XIRR.",
   932	    }

__HERMES_CWD_8d46a20096ed__/home/agent/.hermes/worktrees/FinanceManager-sprint23__HERMES_CWD_8d46a20096ed__
