     1|from __future__ import annotations
     2|
     3|from dataclasses import dataclass, field
     4|from decimal import Decimal, InvalidOperation
     5|from sqlite3 import Connection
     6|
     7|from jarvis_finance.audit.log import record_audit_event
     8|from jarvis_finance.crypto.assets import create_crypto_asset
     9|from jarvis_finance.crypto.holdings import calculate_crypto_holdings, create_initial_holding_snapshot
    10|from jarvis_finance.imports.common import stable_id, utc_now
    11|from jarvis_finance.quality.alerts import create_alert
    12|
    13|CRYPTO_MANUAL_REVIEW_COIN_LABELS = [
    14|    "ADA Cardano",
    15|    "Amp",
    16|    "Avalanche AVAX",
    17|    "Bitcoin BTC",
    18|    "Chainlink",
    19|    "DOGE",
    20|    "Decentraland MANA",
    21|    "Ethereum ETH",
    22|    "HIGH Highstreet",
    23|    "IOTA IOT",
    24|    "LUNA Terra",
    25|    "LUNC Terra Classic",
    26|    "NETVR",
    27|    "NuCypher NU",
    28|    "Ox ZRX",
    29|    "Polygon MATIC",
    30|    "Ripple XRP",
    31|    "SAND The Sandbox",
    32|    "SAROS",
    33|    "SKY",
    34|    "SOL Solana",
    35|    "Sundae",
    36|    "USTC Terra Classic",
    37|]
    38|
    39|VALID_ADD_TYPES = {"initial_snapshot_addition", "manual_adjustment", "crypto_buy"}
    40|
    41|
    42|@dataclass(frozen=True)
    43|class CryptoManageResult:
    44|    asset_id: str
    45|    wallet_id: str
    46|    holding_id: str | None = None
    47|    transaction_id: str | None = None
    48|    warnings: list[str] = field(default_factory=list)
    49|
    50|
    51|def parse_decimal_text(value: str, *, field_name: str = "quantity") -> Decimal:
    52|    text = str(value).strip()
    53|    if not text:
    54|        raise ValueError(f"{field_name} is required")
    55|    try:
    56|        dec = Decimal(text)
    57|    except (InvalidOperation, ValueError) as exc:
    58|        raise ValueError(f"{field_name} must be a Decimal string") from exc
    59|    if not dec.is_finite():
    60|        raise ValueError(f"{field_name} must be finite")
    61|    return dec
    62|
    63|
    64|def decimal_text(value: Decimal) -> str:
    65|    return format(value, "f")
    66|
    67|
    68|def _require_confirm(confirm: bool) -> None:
    69|    if not confirm:
    70|        raise ValueError("explicit confirm is required before saving")
    71|
    72|
    73|def _require_date(effective_date: str) -> str:
    74|    text = str(effective_date).strip()
    75|    if not text:
    76|        raise ValueError("date is required")
    77|    # Keep validation intentionally simple and deterministic; Streamlit date_input supplies ISO dates.
    78|    parts = text.split("-")
    79|    if len(parts) != 3 or not all(part.isdigit() for part in parts):
    80|        raise ValueError("date must be ISO YYYY-MM-DD")
    81|    return text
    82|
    83|
    84|def _require_wallet(conn: Connection, wallet_id: str) -> str:
    85|    wallet_id = str(wallet_id).strip()
    86|    if not wallet_id:
    87|        raise ValueError("wallet is required")
    88|    row = conn.execute("SELECT wallet_id FROM crypto_wallets WHERE wallet_id=?", (wallet_id,)).fetchone()
    89|    if row is None:
    90|        raise ValueError("wallet not found")
    91|    return wallet_id
    92|
    93|
    94|def _require_coin_fields(coin_name: str, symbol: str) -> tuple[str, str]:
    95|    coin_name = str(coin_name).strip()
    96|    symbol = str(symbol).strip().upper()
    97|    if not coin_name:
    98|        raise ValueError("coin name is required")
    99|    if not symbol:
   100|        raise ValueError("symbol is required")
   101|    return coin_name, symbol
   102|
   103|
   104|def _require_note(note: str, *, reason: str) -> str:
   105|    note = str(note or "").strip()
   106|    if not note:
   107|        raise ValueError(f"note is required for {reason}")
   108|    return note
   109|
   110|
   111|def _find_asset(conn: Connection, *, coin_name: str, symbol: str, coingecko_id: str | None) -> str | None:
   112|    if coingecko_id:
   113|        row = conn.execute("SELECT asset_id FROM crypto_assets WHERE coingecko_id=?", (coingecko_id.strip(),)).fetchone()
   114|        if row:
   115|            return row["asset_id"]
   116|    row = conn.execute(
   117|        "SELECT asset_id FROM crypto_assets WHERE lower(coin_name)=lower(?) AND upper(symbol)=upper(?)",
   118|        (coin_name, symbol),
   119|    ).fetchone()
   120|    return row["asset_id"] if row else None
   121|
   122|
   123|def _ensure_asset(
   124|    conn: Connection,
   125|    *,
   126|    coin_name: str,
   127|    symbol: str,
   128|    coingecko_id: str | None,
   129|    coingecko_status: str,
   130|    note: str | None,
   131|) -> tuple[str, list[str]]:
   132|    warnings: list[str] = []
   133|    cg = coingecko_id.strip() if coingecko_id and coingecko_id.strip() else None
   134|    existing = _find_asset(conn, coin_name=coin_name, symbol=symbol, coingecko_id=cg)
   135|    if existing:
   136|        asset_id = existing
   137|    else:
   138|        asset_id = create_crypto_asset(conn, coin_name=coin_name, symbol=symbol, coingecko_id=cg, notes=note)
   139|    if not cg:
   140|        warnings.append("missing_coingecko_id")
   141|    if coingecko_status in {"missing_confirmed", "uncertain"}:
   142|        rule = "crypto_coingecko_mapping_uncertain" if coingecko_status == "uncertain" else "missing_coingecko_id"
   143|        create_alert(
   144|            conn,
   145|            priority="warnung",
   146|            category="crypto",
   147|            entity_type="crypto_asset",
   148|            entity_id=asset_id,
   149|            rule_id=rule,
   150|            message="Crypto asset CoinGecko mapping needs manual confirmation.",
   151|            evidence={"symbol": symbol, "coin_name": coin_name, "coingecko_status": coingecko_status},
   152|            fingerprint=coingecko_status,
   153|        )
   154|        conn.commit()
   155|        if rule not in warnings:
   156|            warnings.append(rule)
   157|    return asset_id, warnings
   158|
   159|
   160|def current_wallet_asset_quantity(conn: Connection, *, wallet_id: str, asset_id: str) -> Decimal:
   161|    result = calculate_crypto_holdings(conn)
   162|    holding = result.wallet_holdings.get((wallet_id, asset_id))
   163|    return holding.quantity if holding else Decimal("0")
   164|
   165|
   166|def _insert_manual_adjustment_transaction(
   167|    conn: Connection,
   168|    *,
   169|    asset_id: str,
   170|    wallet_id: str,
   171|    delta: Decimal,
   172|    effective_date: str,
   173|    note: str,
   174|) -> str:
   175|    if delta == 0:
   176|        raise ValueError("manual adjustment delta must not be zero")
   177|    now = utc_now()
   178|    qty = abs(delta)
   179|    tx_id = stable_id("cryptotx", "manual_adjustment", asset_id, wallet_id, decimal_text(delta), effective_date, now)
   180|    from_wallet_id = wallet_id if delta < 0 else None
   181|    to_wallet_id = wallet_id if delta > 0 else None
   182|    conn.execute(
   183|        """
   184|        INSERT INTO crypto_transactions(
   185|            crypto_transaction_id, transaction_type, asset_id, quantity,
   186|            from_wallet_id, to_wallet_id, transaction_datetime, source,
   187|            confirmation_status, parse_confidence, notes, created_at
   188|        ) VALUES (?, 'manual_adjustment', ?, ?, ?, ?, ?, 'manual_dashboard', 'confirmed', 1, ?, ?)
   189|        """,
   190|        (tx_id, asset_id, decimal_text(qty), from_wallet_id, to_wallet_id, effective_date, note, now),
   191|    )
   192|    return tx_id
   193|
   194|
   195|def add_crypto_position(
   196|    conn: Connection,
   197|    *,
   198|    coin_name: str,
   199|    symbol: str,
   200|    wallet_id: str,
   201|    quantity_text: str,
   202|    effective_date: str,
   203|    operation_type: str,
   204|    note: str,
   205|    confirm: bool,
   206|    coingecko_id: str | None = None,
   207|    coingecko_status: str = "selected",
   208|) -> CryptoManageResult:
   209|    _require_confirm(confirm)
   210|    if operation_type not in VALID_ADD_TYPES:
   211|        raise ValueError("unsupported crypto management operation type")
   212|    if operation_type == "crypto_buy":
   213|        raise ValueError("crypto_buy requires the dedicated ledger buy workflow; use manual_adjustment or initial_snapshot_addition in this MVP")
   214|    effective_date = _require_date(effective_date)
   215|    wallet_id = _require_wallet(conn, wallet_id)
   216|    coin_name, symbol = _require_coin_fields(coin_name, symbol)
   217|    quantity = parse_decimal_text(quantity_text)
   218|    if quantity <= 0:
   219|        raise ValueError("quantity must be > 0")
   220|    if operation_type == "manual_adjustment":
   221|        note = _require_note(note, reason="manual_adjustment")
   222|    else:
   223|        note = str(note or "").strip() or "manual initial snapshot addition"
   224|    asset_id, warnings = _ensure_asset(conn, coin_name=coin_name, symbol=symbol, coingecko_id=coingecko_id, coingecko_status=coingecko_status, note=note)
   225|    existing_holding = conn.execute(
   226|        "SELECT crypto_holding_id FROM crypto_holdings WHERE asset_id=? AND wallet_id=?",
   227|        (asset_id, wallet_id),
   228|    ).fetchone()
   229|    if operation_type == "initial_snapshot_addition":
   230|        if existing_holding:
   231|            raise ValueError("initial snapshot addition would overwrite an existing holding; use manual_adjustment")
   232|        holding_id = create_initial_holding_snapshot(
   233|            conn,
   234|            asset_id=asset_id,
   235|            wallet_id=wallet_id,
   236|            quantity=quantity,
   237|            verification_status="verified",
   238|            last_verified_at=effective_date,
   239|            legacy_snapshot_date=effective_date,
   240|            note=note,
   241|        )
   242|        return CryptoManageResult(asset_id=asset_id, wallet_id=wallet_id, holding_id=holding_id, warnings=warnings)
   243|    before = current_wallet_asset_quantity(conn, wallet_id=wallet_id, asset_id=asset_id)
   244|    tx_id = _insert_manual_adjustment_transaction(conn, asset_id=asset_id, wallet_id=wallet_id, delta=quantity, effective_date=effective_date, note=note)
   245|    after = before + quantity
   246|    record_audit_event(
   247|        conn,
   248|        source="crypto_manage",
   249|        action="crypto_manual_adjustment",
   250|        entity_type="crypto_transaction",
   251|        entity_id=tx_id,
   252|        old_values={"asset_id": asset_id, "wallet_id": wallet_id, "quantity": decimal_text(before)},
   253|        new_values={"asset_id": asset_id, "wallet_id": wallet_id, "quantity": decimal_text(after), "delta": decimal_text(quantity)},
   254|        user_text_note=note,
   255|        confirmed=True,
   256|        created_by="dashboard",
   257|    )
   258|    conn.commit()
   259|    return CryptoManageResult(asset_id=asset_id, wallet_id=wallet_id, transaction_id=tx_id, warnings=warnings)
   260|
   261|
   262|def adjust_crypto_position(
   263|    conn: Connection,
   264|    *,
   265|    asset_id: str,
   266|    wallet_id: str,
   267|    new_quantity_text: str,
   268|    effective_date: str,
   269|    note: str,
   270|    confirm: bool,
   271|) -> CryptoManageResult:
   272|    _require_confirm(confirm)
   273|    effective_date = _require_date(effective_date)
   274|    wallet_id = _require_wallet(conn, wallet_id)
   275|    note = _require_note(note, reason="manual_adjustment")
   276|    new_quantity = parse_decimal_text(new_quantity_text, field_name="new quantity")
   277|    if new_quantity < 0:
   278|        raise ValueError("new quantity must be >= 0")
   279|    asset = conn.execute("SELECT asset_id FROM crypto_assets WHERE asset_id=?", (asset_id,)).fetchone()
   280|    if asset is None:
   281|        raise ValueError("asset not found")
   282|    before = current_wallet_asset_quantity(conn, wallet_id=wallet_id, asset_id=asset_id)
   283|    delta = new_quantity - before
   284|    if before + delta < 0:
   285|        raise ValueError("negative holding blocked")
   286|    tx_id = _insert_manual_adjustment_transaction(conn, asset_id=asset_id, wallet_id=wallet_id, delta=delta, effective_date=effective_date, note=note)
   287|    record_audit_event(
   288|        conn,
   289|        source="crypto_manage",
   290|        action="crypto_manual_adjustment",
   291|        entity_type="crypto_transaction",
   292|        entity_id=tx_id,
   293|        old_values={"asset_id": asset_id, "wallet_id": wallet_id, "quantity": decimal_text(before)},
   294|        new_values={"asset_id": asset_id, "wallet_id": wallet_id, "quantity": decimal_text(new_quantity), "delta": decimal_text(delta)},
   295|        user_text_note=note,
   296|        confirmed=True,
   297|        created_by="dashboard",
   298|    )
   299|    if before != 0 and (new_quantity / before >= Decimal("10") or new_quantity == 0):
   300|        create_alert(
   301|            conn,
   302|            priority="hinweis",
   303|            category="crypto",
   304|            entity_type="crypto_transaction",
   305|            entity_id=tx_id,
   306|            rule_id="crypto_manual_adjustment_review",
   307|            message="Manual crypto adjustment should be reviewed because it materially changes the holding.",
   308|            evidence={"asset_id": asset_id, "wallet_id": wallet_id},
   309|            fingerprint="manual_adjustment_review",
   310|        )
   311|    conn.commit()
   312|    return CryptoManageResult(asset_id=asset_id, wallet_id=wallet_id, transaction_id=tx_id)
   313|
   314|
   315|def reduce_crypto_position(
   316|    conn: Connection,
   317|    *,
   318|    asset_id: str,
   319|    wallet_id: str,
   320|    reduction_quantity_text: str,
   321|    effective_date: str,
   322|    note: str,
   323|    confirm: bool,
   324|) -> CryptoManageResult:
   325|    _require_confirm(confirm)
   326|    reduction = parse_decimal_text(reduction_quantity_text, field_name="reduction quantity")
   327|    if reduction <= 0:
   328|        raise ValueError("reduction quantity must be > 0")
   329|    current = current_wallet_asset_quantity(conn, wallet_id=wallet_id, asset_id=asset_id)
   330|    new_quantity = current - reduction
   331|    if new_quantity < 0:
   332|        raise ValueError("negative holding blocked")
   333|    return adjust_crypto_position(
   334|        conn,
   335|        asset_id=asset_id,
   336|        wallet_id=wallet_id,
   337|        new_quantity_text=decimal_text(new_quantity),
   338|        effective_date=effective_date,
   339|        note=note,
   340|        confirm=confirm,
   341|    )
   342|
   343|
   344|def get_crypto_management_options(conn: Connection) -> dict[str, list[dict[str, str]] | list[str]]:
   345|    wallets = [
   346|        {"wallet_id": row["wallet_id"], "wallet_name": row["wallet_name"], "wallet_type": row["wallet_type"]}
   347|        for row in conn.execute("SELECT wallet_id, wallet_name, wallet_type FROM crypto_wallets WHERE is_active=1 ORDER BY wallet_name").fetchall()
   348|    ]
   349|    assets = [
   350|        {"asset_id": row["asset_id"], "coin_name": row["coin_name"], "symbol": row["symbol"], "coingecko_id": row["coingecko_id"] or ""}
   351|        for row in conn.execute("SELECT asset_id, coin_name, symbol, coingecko_id FROM crypto_assets WHERE is_active=1 ORDER BY coin_name, symbol").fetchall()
   352|    ]
   353|    holdings = [
   354|        {
   355|            "asset_id": row["asset_id"],
   356|            "wallet_id": row["wallet_id"],
   357|            "coin_name": row["coin_name"],
   358|            "symbol": row["symbol"],
   359|            "wallet_name": row["wallet_name"],
   360|            "quantity": row["quantity"],
   361|        }
   362|        for row in conn.execute(
   363|            """
   364|            SELECT h.asset_id, h.wallet_id, a.coin_name, a.symbol, w.wallet_name, h.quantity
   365|            FROM crypto_holdings h
   366|            JOIN crypto_assets a ON a.asset_id=h.asset_id
   367|            JOIN crypto_wallets w ON w.wallet_id=h.wallet_id
   368|            ORDER BY a.coin_name, w.wallet_name
   369|            """
   370|        ).fetchall()
   371|    ]
   372|    return {
   373|        "wallets": wallets,
   374|        "assets": assets,
   375|        "holdings": holdings,
   376|        "manual_review_coin_labels": list(CRYPTO_MANUAL_REVIEW_COIN_LABELS),
   377|    }
   378|


     1|from __future__ import annotations
     2|
     3|import json
     4|from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
     5|from pathlib import Path
     6|from sqlite3 import Connection
     7|
     8|from jarvis_finance.config.settings import Settings
     9|from jarvis_finance.crypto.holdings import calculate_crypto_holdings
    10|from jarvis_finance.ledger.cash import calculate_cash_balances
    11|from jarvis_finance.ledger.positions import calculate_positions
    12|from jarvis_finance.market.providers import is_price_stale
    13|
    14|MONEY = Decimal("0.01")
    15|
    16|
    17|def d(value: object, default: str = "0") -> Decimal:
    18|    if value is None or str(value).strip() == "":
    19|        return Decimal(default)
    20|    try:
    21|        return Decimal(str(value))
    22|    except InvalidOperation:
    23|        return Decimal(default)
    24|
    25|
    26|def dec_text(value: Decimal | int | str | None, places: str | None = None) -> str:
    27|    if value is None:
    28|        return ""
    29|    value = d(value)
    30|    if places:
    31|        value = value.quantize(Decimal(places), rounding=ROUND_HALF_UP)
    32|    return format(value, "f")
    33|
    34|
    35|def money_text(value: Decimal | None, currency: str = "CHF") -> str:
    36|    if value is None:
    37|        return f"n/a {currency}"
    38|    return f"{dec_text(value, '0.01')} {currency}"
    39|
    40|
    41|def rowdicts(rows) -> list[dict[str, str]]:
    42|    return [{k: ("" if row[k] is None else str(row[k])) for k in row.keys()} for row in rows]
    43|
    44|
    45|def db_exists(path: str | Path) -> bool:
    46|    return Path(path).expanduser().exists()
    47|
    48|
    49|def runtime_outside_repo(settings: Settings, repo_root: Path) -> bool:
    50|    try:
    51|        settings.runtime_paths.base_dir.resolve().relative_to(repo_root.resolve())
    52|        return False
    53|    except ValueError:
    54|        return True
    55|
    56|
    57|def latest_crypto_price(conn: Connection, asset_id: str, currency: str = "CHF", *, max_age_seconds: int = 86_400) -> dict[str, str] | None:
    58|    row = conn.execute(
    59|        """
    60|        SELECT price, price_currency, fetched_at, provider_timestamp, quality_status, provider
    61|        FROM crypto_prices
    62|        WHERE asset_id=? AND price_currency=?
    63|        ORDER BY COALESCE(provider_timestamp, fetched_at, '') DESC, fetched_at DESC LIMIT 1
    64|        """,
    65|        (asset_id, currency.upper()),
    66|    ).fetchone()
    67|    if not row:
    68|        return None
    69|    item = dict(row)
    70|    ts = item.get("provider_timestamp") or item.get("fetched_at")
    71|    if item.get("quality_status") == "fresh" and is_price_stale(ts, max_age_seconds=max_age_seconds):
    72|        item["quality_status"] = "stale"
    73|    return item
    74|
    75|
    76|def get_crypto_overview(conn: Connection, *, price_max_age_seconds: int = 86_400) -> list[dict[str, str]]:
    77|    holdings = calculate_crypto_holdings(conn)
    78|    rows: list[dict[str, str]] = []
    79|    for asset_id, holding in sorted(holdings.total_by_asset.items()):
    80|        qty = holding.quantity
    81|        asset = conn.execute("SELECT * FROM crypto_assets WHERE asset_id=?", (asset_id,)).fetchone()
    82|        if not asset:
    83|            continue
    84|        price = latest_crypto_price(conn, asset_id, "CHF", max_age_seconds=price_max_age_seconds)
    85|        price_dec = d(price["price"]) if price and price["price"] else None
    86|        value = qty * price_dec if price_dec is not None else None
    87|        rows.append({
    88|            "asset_id": asset_id,
    89|            "coin_name": asset["coin_name"],
    90|            "symbol": asset["symbol"],
    91|            "coingecko_id": asset["coingecko_id"] or "",
    92|            "quantity": dec_text(qty),
    93|            "latest_price_chf": dec_text(price_dec, "0.01") if price_dec is not None else "",
    94|            "market_value_chf": dec_text(value, "0.01") if value is not None else "",
    95|            "price_quality": price["quality_status"] if price else "missing",
    96|            "fetched_at": price["fetched_at"] if price else "",
    97|        })
    98|    return rows
    99|
   100|
   101|def get_crypto_by_wallet(conn: Connection, *, price_max_age_seconds: int = 86_400) -> list[dict[str, str]]:
   102|    holdings = calculate_crypto_holdings(conn)
   103|    rows: list[dict[str, str]] = []
   104|    for (wallet_id, asset_id), holding in sorted(holdings.wallet_holdings.items()):
   105|        wallet = conn.execute("SELECT wallet_name, wallet_type, platform_provider FROM crypto_wallets WHERE wallet_id=?", (wallet_id,)).fetchone()
   106|        asset = conn.execute("SELECT coin_name, symbol, coingecko_id FROM crypto_assets WHERE asset_id=?", (asset_id,)).fetchone()
   107|        price = latest_crypto_price(conn, asset_id, "CHF", max_age_seconds=price_max_age_seconds)
   108|        price_dec = d(price["price"]) if price and price["price"] else None
   109|        value = holding.quantity * price_dec if price_dec is not None else None
   110|        rows.append({
   111|            "wallet_id": wallet_id,
   112|            "wallet_name": wallet["wallet_name"] if wallet else wallet_id,
   113|            "wallet_type": wallet["wallet_type"] if wallet else "",
   114|            "provider": wallet["platform_provider"] if wallet else "",
   115|            "coin_name": asset["coin_name"] if asset else asset_id,
   116|            "symbol": asset["symbol"] if asset else "",
   117|            "coingecko_id": asset["coingecko_id"] if asset else "",
   118|            "quantity": dec_text(holding.quantity),
   119|            "market_value_chf": dec_text(value, "0.01") if value is not None else "",
   120|            "wallet_value_chf": dec_text(value, "0.01") if value is not None else "",
   121|            "price_quality": price["quality_status"] if price else "missing",
   122|            "price_fetched_at": price["fetched_at"] if price else "",
   123|            "verification_status": holding.verification_status,
   124|            "last_verified_at": holding.last_verified_at or "",
   125|        })
   126|    return rows
   127|
   128|
   129|def get_wallets(conn: Connection) -> list[dict[str, str]]:
   130|    return rowdicts(conn.execute(
   131|        """
   132|        SELECT wallet_id, wallet_name, wallet_type, platform_provider AS provider,
   133|               network_chain, last_verified_at, is_active, notes
   134|        FROM crypto_wallets ORDER BY wallet_name
   135|        """
   136|    ).fetchall())
   137|
   138|
   139|def get_latest_crypto_transactions(conn: Connection, limit: int = 20) -> list[dict[str, str]]:
   140|    return rowdicts(conn.execute(
   141|        """
   142|        SELECT ct.transaction_datetime, ct.transaction_type, ca.symbol, ct.quantity, ct.fee_quantity,
   143|               fw.wallet_name AS from_wallet, tw.wallet_name AS to_wallet, ct.currency_original,
   144|               ct.amount_chf, ct.confirmation_status, ct.source
   145|        FROM crypto_transactions ct
   146|        LEFT JOIN crypto_assets ca ON ca.asset_id=ct.asset_id
   147|        LEFT JOIN crypto_wallets fw ON fw.wallet_id=ct.from_wallet_id
   148|        LEFT JOIN crypto_wallets tw ON tw.wallet_id=ct.to_wallet_id
   149|        ORDER BY ct.transaction_datetime DESC, ct.created_at DESC LIMIT ?
   150|        """, (limit,)
   151|    ).fetchall())
   152|
   153|
   154|def get_platform_overview(conn: Connection) -> list[dict[str, str]]:
   155|    return rowdicts(conn.execute(
   156|        """
   157|        SELECT p.name AS platform, p.platform_type, a.account_name, a.account_type, a.currency,
   158|               a.performance_included, a.is_active
   159|        FROM accounts a JOIN platforms p ON p.platform_id=a.platform_id
   160|        ORDER BY p.name, a.account_name
   161|        """
   162|    ).fetchall())
   163|
   164|
   165|def get_cash_overview(conn: Connection) -> list[dict[str, str]]:
   166|    result = calculate_cash_balances(conn)
   167|    rows: list[dict[str, str]] = []
   168|    for (account_id, currency), bal in sorted(result.balances.items()):
   169|        row = conn.execute(
   170|            "SELECT a.account_name, p.name AS platform FROM accounts a JOIN platforms p ON p.platform_id=a.platform_id WHERE a.account_id=?",
   171|            (account_id,),
   172|        ).fetchone()
   173|        rows.append({
   174|            "platform": row["platform"] if row else "",
   175|            "account_name": row["account_name"] if row else account_id,
   176|            "currency": currency,
   177|            "amount_original": dec_text(bal.amount_original, "0.01"),
   178|            "amount_chf": dec_text(bal.amount_chf, "0.01") if bal.amount_chf is not None else "",
   179|            "quality_status": bal.quality_status,
   180|        })
   181|    return rows
   182|
   183|
   184|def get_positions(conn: Connection) -> list[dict[str, str]]:
   185|    result = calculate_positions(conn)
   186|    rows: list[dict[str, str]] = []
   187|    for (account_id, instrument_id), pos in sorted(result.positions.items()):
   188|        row = conn.execute(
   189|            """
   190|            SELECT a.account_name, p.name AS platform, i.name, i.ticker, i.isin, i.asset_class, i.currency,
   191|                   i.hedge_status, i.instrument_status, i.valuation_policy, i.corporate_action_status
   192|            FROM accounts a JOIN platforms p ON p.platform_id=a.platform_id
   193|            JOIN instruments i ON i.instrument_id=? WHERE a.account_id=?
   194|            """,
   195|            (instrument_id, account_id),
   196|        ).fetchone()
   197|        rows.append({
   198|            "platform": row["platform"] if row else "",
   199|            "account_name": row["account_name"] if row else account_id,
   200|            "asset_class": row["asset_class"] if row else "",
   201|            "name": row["name"] if row else instrument_id,
   202|            "ticker": row["ticker"] if row else "",
   203|            "isin": row["isin"] if row else "",
   204|            "quantity": dec_text(pos.quantity),
   205|            "cost_basis_chf": dec_text(pos.cost_basis_chf, "0.01"),
   206|            "market_value_chf": dec_text(pos.market_value_chf, "0.01") if pos.market_value_chf is not None else "",
   207|            "price_status": "ok" if pos.market_price_original is not None else "missing_market_price",
   208|            "provider_symbol_status": "mapped" if conn.execute("SELECT 1 FROM instrument_price_mappings WHERE instrument_id=? AND mapping_status='mapped' AND provider_symbol IS NOT NULL LIMIT 1", (instrument_id,)).fetchone() else "missing_provider_symbol",
   209|            "hedge_status": row["hedge_status"] if row else pos.hedge_status,
   210|            "instrument_status": row["instrument_status"] if row else pos.instrument_status,
   211|            "valuation_policy": row["valuation_policy"] if row else pos.valuation_policy,
   212|            "fx_status": "ok" if "missing_fx" not in pos.quality_warnings else "missing_fx",
   213|            "market_price_status": "ok" if pos.market_price_original is not None else "missing_market_price",
   214|            "valuation_timestamp_status": pos.valuation_timestamp_status,
   215|            "corporate_action_status": row["corporate_action_status"] if row else pos.corporate_action_status,
   216|            "valuation_status": pos.valuation_status,
   217|            "total_return_chf": dec_text(pos.total_return_chf, "0.01") if pos.total_return_chf is not None else "",
   218|            "total_return_status": "complete" if pos.total_return_chf is not None else "incomplete",
   219|            "quality_warnings": ",".join(pos.quality_warnings),
   220|            "data_quality_status": pos.data_quality_status,
   221|        })
   222|    return rows
   223|
   224|
   225|def get_ledger_transactions(conn: Connection, limit: int = 200) -> list[dict[str, str]]:
   226|    return rowdicts(conn.execute(
   227|        """
   228|        SELECT t.trade_date, p.name AS platform, a.account_name, t.transaction_type,
   229|               i.name AS instrument, i.ticker, t.quantity, t.gross_amount_original,
   230|               t.net_amount_original, t.currency_original, t.fx_status, t.quality_status,
   231|               t.source_type, t.source_id
   232|        FROM transactions t
   233|        LEFT JOIN accounts a ON a.account_id=t.account_id
   234|        LEFT JOIN platforms p ON p.platform_id=a.platform_id
   235|        LEFT JOIN instruments i ON i.instrument_id=t.instrument_id
   236|        ORDER BY t.trade_date DESC, t.created_at DESC LIMIT ?
   237|        """, (limit,)
   238|    ).fetchall())
   239|
   240|
   241|def get_watchlist(conn: Connection) -> list[dict[str, str]]:
   242|    return rowdicts(conn.execute("SELECT name, asset_class, reason, status, target_entry_price, target_entry_currency, investment_case, bear_case, next_review_date FROM watchlist ORDER BY name").fetchall())
   243|
   244|
   245|def get_alerts(conn: Connection) -> list[dict[str, str]]:
   246|    return rowdicts(conn.execute(
   247|        """
   248|        SELECT priority, status, category, entity_type, entity_id, rule_id, message,
   249|               occurrence_count, last_seen_at, resolved_at, muted_until, fingerprint, dedup_key, created_at
   250|        FROM alerts
   251|        ORDER BY CASE priority WHEN 'kritisch' THEN 0 WHEN 'warnung' THEN 1 ELSE 2 END, status, created_at DESC
   252|        """
   253|    ).fetchall())
   254|
   255|
   256|def get_audit_events(conn: Connection, limit: int = 200) -> list[dict[str, str]]:
   257|    rows = conn.execute(
   258|        """
   259|        SELECT timestamp, source, action, entity_type, entity_id, old_values_json, new_values_json,
   260|               confirmed, created_by, quality_status
   261|        FROM audit_log ORDER BY timestamp DESC LIMIT ?
   262|        """, (limit,)
   263|    ).fetchall()
   264|    out: list[dict[str, str]] = []
   265|    for row in rows:
   266|        item = {k: ("" if row[k] is None else str(row[k])) for k in row.keys()}
   267|        for key in ("old_values_json", "new_values_json"):
   268|            if item[key]:
   269|                try:
   270|                    item[key] = json.dumps(json.loads(item[key]), ensure_ascii=False, indent=2, sort_keys=True)
   271|                except json.JSONDecodeError:
   272|                    pass
   273|        out.append(item)
   274|    return out
   275|
   276|
   277|def get_data_quality(conn: Connection) -> list[dict[str, str]]:
   278|    items: list[dict[str, str]] = []
   279|    checks = [
   280|        ("fehlende FX", "SELECT COUNT(*) AS c FROM transactions WHERE fx_status='missing'", "kritisch"),
   281|        ("fehlende/stale FX Alerts", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id IN ('missing_fx','stale_fx','historical_fx_unavailable','manual_override_required')", "kritisch"),
   282|        ("historical FX unavailable", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='historical_fx_unavailable'", "kritisch"),
   283|        ("manual override required", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='manual_override_required'", "kritisch"),
   284|        ("fehlende Provider-Symbole", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='missing_provider_symbol'", "warnung"),
   285|        ("provider lookup unavailable", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='provider_lookup_unavailable'", "warnung"),
   286|        ("ambiguous_provider_mapping", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='ambiguous_provider_mapping'", "warnung"),
   287|        ("multiple_listings_same_isin", "SELECT COUNT(*) AS c FROM (SELECT isin FROM instrument_price_mapping_candidates WHERE COALESCE(isin,'')<>'' AND review_status IN ('proposed','needs_manual_review') GROUP BY isin HAVING COUNT(DISTINCT COALESCE(candidate_exchange,'') || ':' || COALESCE(candidate_currency,''))>1)", "warnung"),
   288|        ("currency_mismatch", "SELECT COUNT(*) AS c FROM instrument_price_mapping_candidates WHERE review_status IN ('proposed','needs_manual_review') AND risk_flags LIKE '%currency_mismatch%'", "warnung"),
   289|        ("hedge_status_unknown", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='hedge_status_unknown'", "warnung"),
   290|        ("instrument_status_unknown", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='instrument_status_unknown'", "warnung"),
   291|        ("provider_symbol_missing", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='missing_provider_symbol'", "warnung"),
   292|        ("candidate_review_required", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='candidate_review_required'", "warnung"),
   293|        ("valuation_not_ready", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id IN ('missing_provider_symbol','missing_market_price','missing_fx','hedge_status_unknown','instrument_status_unknown','candidate_review_required')", "warnung"),
   294|        ("instrument status unknown", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='instrument_status_unknown'", "warnung"),
   295|        ("manual FX override required", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id IN ('manual_override_required','manual_fx_override_required')", "kritisch"),
   296|        ("fehlende Market Prices", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='missing_market_price'", "warnung"),
   297|        ("stale Market Prices", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='stale_market_price'", "warnung"),
   298|        ("ambiguous Instrument Mapping", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='ambiguous_instrument_mapping'", "warnung"),
   299|        ("price currency mismatch", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='price_currency_mismatch'", "warnung"),
   300|        ("hedge status unknown", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='hedge_status_unknown'", "warnung"),
   301|        ("stale valuation", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='stale_valuation'", "warnung"),
   302|        ("corporate action suspected", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='corporate_action_suspected'", "warnung"),
   303|        ("split/corporate action review", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='split_or_corporate_action_review_required'", "warnung"),
   304|        ("delisted/suspended", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='delisted_or_suspended'", "warnung"),
   305|        ("snapshot_only Alerts", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='snapshot_only'", "info"),
   306|        ("cost_basis_uncertain Alerts", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='cost_basis_uncertain'", "warnung"),
   307|        ("fehlende CoinGecko-ID", "SELECT COUNT(*) AS c FROM crypto_assets WHERE coingecko_id IS NULL OR coingecko_id='' ", "warnung"),
   308|        ("fehlende Crypto-Preise", "SELECT COUNT(*) AS c FROM crypto_assets ca LEFT JOIN crypto_prices cp ON cp.asset_id=ca.asset_id WHERE cp.crypto_price_id IS NULL", "warnung"),
   309|        ("fehlende ISIN", "SELECT COUNT(*) AS c FROM instrument_mappings WHERE asset_class IN ('equity','etf') AND (isin IS NULL OR isin='') AND mapping_status!='ignored'", "warnung"),
   310|        ("Catalog Instrumente ohne ISIN", "SELECT COUNT(*) AS c FROM instrument_catalog_entries WHERE asset_class IN ('equity','stock','etf') AND (isin IS NULL OR isin='')", "warnung"),
   311|        ("Catalog Ticker ohne Exchange", "SELECT COUNT(*) AS c FROM instrument_catalog_entries WHERE COALESCE(ticker,'')<>'' AND COALESCE(exchange,'')=''", "warnung"),
   312|        ("Catalog mehrere Listings gleicher ISIN", "SELECT COUNT(*) AS c FROM (SELECT isin FROM instrument_catalog_entries WHERE COALESCE(isin,'')<>'' GROUP BY isin HAVING COUNT(DISTINCT COALESCE(exchange,'') || ':' || COALESCE(trading_currency,''))>1)", "info"),
   313|        ("Manual Review needed", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id='manual_review_needed'", "warnung"),
   314|        ("Mapping-Kandidaten unbestätigt", "SELECT COUNT(*) AS c FROM instrument_price_mapping_candidates WHERE review_status IN ('proposed','needs_manual_review')", "warnung"),
   315|        ("Instrumente mit mehreren Mapping-Kandidaten", "SELECT COUNT(*) AS c FROM (SELECT instrument_id FROM instrument_price_mapping_candidates WHERE review_status IN ('proposed','needs_manual_review') GROUP BY instrument_id HAVING COUNT(*) > 1)", "warnung"),
   316|        ("Valuation not ready", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id IN ('missing_provider_symbol','missing_market_price','missing_fx','hedge_status_unknown','instrument_status_unknown')", "warnung"),
   317|        ("fehlende FX in Import-Reviews", "SELECT COUNT(*) AS c FROM instrument_mappings WHERE quality_flags_json LIKE '%missing_fx%' AND mapping_status!='ignored'", "warnung"),
   318|        ("snapshot_only Positionen", "SELECT COUNT(*) AS c FROM instrument_mappings WHERE quality_flags_json LIKE '%snapshot_only%' AND mapping_status!='ignored'", "info"),
   319|        ("cost_basis_uncertain", "SELECT COUNT(*) AS c FROM instrument_mappings WHERE quality_flags_json LIKE '%cost_basis_uncertain%' AND mapping_status!='ignored'", "warnung"),
   320|        ("aktive Alerts", "SELECT COUNT(*) AS c FROM alerts WHERE status='active'", "warnung"),
   321|        ("resolved Alerts", "SELECT COUNT(*) AS c FROM alerts WHERE status='resolved'", "ok"),
   322|        ("alte aktive Alerts", "SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND last_seen_at < datetime('now', '-7 days')", "info"),
   323|    ]
   324|    for label, sql, severity in checks:
   325|        row = conn.execute(sql).fetchone()
   326|        count = int(row["c"] or 0) if row else 0
   327|        sev = severity if count else "ok"
   328|        items.append({"check": label, "count": str(count), "severity": sev})
   329|    for flag_row in get_review_quality_flag_counts(conn):
   330|        count = int(flag_row["open_items"] or 0)
   331|        items.append({"check": f"Review Flag: {flag_row['quality_flag']}", "count": str(count), "severity": "warnung" if count else "ok"})
   332|    for ready in get_review_readiness_counts(conn):
   333|        count = int(ready["count"] or 0)
   334|        sev = "ok" if ready["readiness_status"] in {"ready_for_import", "ignored"} else ("warnung" if count else "ok")
   335|        items.append({"check": f"Review Readiness: {ready['readiness_status']}", "count": str(count), "severity": sev})
   336|    for source in get_ready_for_import_by_source(conn):
   337|        if int(source["total"] or 0) and int(source["ready_for_import"] or 0) == 0:
   338|            items.append({"check": f"Quelle ohne ready_for_import: {source['source_platform']}", "count": source["total"], "severity": "warnung"})
   339|    missing_snapshot = conn.execute("SELECT COUNT(*) AS c FROM broker_import_dry_runs WHERE snapshot_date_status='missing'").fetchone()
   340|    count = int(missing_snapshot["c"] or 0) if missing_snapshot else 0
   341|    items.append({"check": "Dry Runs ohne Snapshot Date", "count": str(count), "severity": "warnung" if count else "ok"})
   342|    return items
   343|
   344|
   345|def get_instrument_catalog_entries(conn: Connection, query: str | None = None, asset_class: str | None = None) -> list[dict[str, str]]:
   346|    if query:
   347|        from jarvis_finance.market_data.catalog import search_local_catalog
   348|        return [r.__dict__ for r in search_local_catalog(conn, query, asset_class)]
   349|    sql = """
   350|        SELECT catalog_entry_id, asset_class, name, isin, ticker, exchange, trading_currency,
   351|               instrument_currency, provider, provider_symbol, provider_market, hedge_status,
   352|               instrument_status, valuation_policy, source, source_confidence, last_verified_at
   353|        FROM instrument_catalog_entries
   354|    """
   355|    params: list[object] = []
   356|    if asset_class:
   357|        sql += " WHERE asset_class=?"
   358|        params.append(asset_class.lower())
   359|    sql += " ORDER BY name, exchange, trading_currency LIMIT 100"
   360|    return rowdicts(conn.execute(sql, tuple(params)).fetchall())
   361|
   362|
   363|def get_instrument_catalog_summary(conn: Connection) -> dict[str, str]:
   364|    total = conn.execute("SELECT COUNT(*) AS c FROM instrument_catalog_entries").fetchone()["c"]
   365|    multi = conn.execute("SELECT COUNT(*) AS c FROM (SELECT isin FROM instrument_catalog_entries WHERE COALESCE(isin,'')<>'' GROUP BY isin HAVING COUNT(DISTINCT COALESCE(exchange,'') || ':' || COALESCE(trading_currency,''))>1)").fetchone()["c"]
   366|    no_provider = conn.execute("SELECT COUNT(*) AS c FROM instrument_catalog_entries WHERE COALESCE(provider_symbol,'')='' ").fetchone()["c"]
   367|    manual = conn.execute("SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND rule_id IN ('manual_review_needed','missing_provider_symbol','ambiguous_provider_mapping')").fetchone()["c"]
   368|    return {"catalog_entries": str(total or 0), "multiple_listings": str(multi or 0), "without_provider_symbol": str(no_provider or 0), "manual_review_required": str(manual or 0)}
   369|
   370|
   371|def get_instrument_mapping_review(conn: Connection) -> list[dict[str, str]]:
   372|    rows = conn.execute(
   373|        """
   374|        SELECT i.instrument_id, i.isin, i.name, i.ticker, i.exchange, i.currency,
   375|               i.hedge_status, i.instrument_status, i.valuation_policy,
   376|               COALESCE(m.mapping_status, 'missing_provider_symbol') AS mapping_status,
   377|               COALESCE(m.provider, '') AS provider,
   378|               COALESCE(m.provider_symbol, '') AS provider_symbol,
   379|               COUNT(c.candidate_id) AS candidate_count
   380|        FROM instruments i
   381|        JOIN transactions t ON t.instrument_id=i.instrument_id
   382|        LEFT JOIN instrument_price_mappings m ON m.instrument_id=i.instrument_id AND m.mapping_status='mapped'
   383|        LEFT JOIN instrument_price_mapping_candidates c ON c.instrument_id=i.instrument_id AND c.review_status IN ('proposed','needs_manual_review')
   384|        WHERE t.transaction_type='initial_position_snapshot'
   385|          AND t.source_type='broker_import_reviewed_snapshot'
   386|          AND COALESCE(t.is_voided,0)=0
   387|        GROUP BY i.instrument_id
   388|        ORDER BY i.isin, i.name
   389|        """
   390|    ).fetchall()
   391|    return rowdicts(rows)
   392|
   393|
   394|def get_instrument_mapping_candidates(conn: Connection, instrument_id: str | None = None) -> list[dict[str, str]]:
   395|    if instrument_id:
   396|        rows = conn.execute(
   397|            """
   398|            SELECT candidate_id, instrument_id, isin, candidate_provider, candidate_provider_symbol,
   399|                   candidate_exchange, candidate_currency, candidate_name, candidate_asset_class,
   400|                   candidate_is_hedged, candidate_hedged_to_currency, candidate_hedge_status,
   401|                   candidate_instrument_status, candidate_valuation_policy, confidence,
   402|                   ranking_score, ranking_reason, risk_flags, recommended_action,
   403|                   CASE
   404|                       WHEN recommended_action='select_candidate' OR ranking_score >= 75 THEN 'wahrscheinlich passend'
   405|                       WHEN recommended_action='reject_candidate' OR ranking_score < 40 THEN 'wahrscheinlich falsch'
   406|                       ELSE 'unsicher'
   407|                   END AS ranking_mark,
   408|                   candidate_currency AS closing_currency,
   409|                   evidence_source, evidence_note, review_status, created_at
   410|            FROM instrument_price_mapping_candidates WHERE instrument_id=?
   411|            ORDER BY ranking_score DESC, CASE confidence WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, candidate_provider
   412|            """,
   413|            (instrument_id,),
   414|        ).fetchall()
   415|    else:
   416|        rows = conn.execute(
   417|            """
   418|            SELECT candidate_id, instrument_id, isin, candidate_provider, candidate_provider_symbol,
   419|                   candidate_exchange, candidate_currency, candidate_name, candidate_asset_class,
   420|                   candidate_is_hedged, candidate_hedged_to_currency, candidate_hedge_status,
   421|                   candidate_instrument_status, candidate_valuation_policy, confidence,
   422|                   ranking_score, ranking_reason, risk_flags, recommended_action,
   423|                   CASE
   424|                       WHEN recommended_action='select_candidate' OR ranking_score >= 75 THEN 'wahrscheinlich passend'
   425|                       WHEN recommended_action='reject_candidate' OR ranking_score < 40 THEN 'wahrscheinlich falsch'
   426|                       ELSE 'unsicher'
   427|                   END AS ranking_mark,
   428|                   candidate_currency AS closing_currency,
   429|                   evidence_source, evidence_note, review_status, created_at
   430|            FROM instrument_price_mapping_candidates
   431|            ORDER BY instrument_id, ranking_score DESC, CASE confidence WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, candidate_provider
   432|            """
   433|        ).fetchall()
   434|    return rowdicts(rows)
   435|
   436|
   437|def get_fx_override_requirements(conn: Connection) -> list[dict[str, str]]:
   438|    rows = conn.execute(
   439|        """
   440|        SELECT t.currency_original AS base_currency, 'CHF' AS quote_currency, t.trade_date AS rate_date,
   441|               COUNT(DISTINCT t.instrument_id) AS instrument_count,
   442|               CASE WHEN fr.fx_rate_id IS NULL THEN 'missing_fx' ELSE fr.quality_status END AS fx_status
   443|        FROM transactions t
   444|        LEFT JOIN fx_rates fr ON fr.base_currency=t.currency_original AND fr.quote_currency='CHF' AND fr.rate_date=t.trade_date
   445|        WHERE t.transaction_type='initial_position_snapshot'
   446|          AND t.source_type='broker_import_reviewed_snapshot'
   447|          AND COALESCE(t.is_voided,0)=0
   448|          AND t.currency_original IS NOT NULL
   449|          AND t.currency_original <> 'CHF'
   450|        GROUP BY t.currency_original, t.trade_date, fx_status
   451|        ORDER BY t.currency_original, t.trade_date
   452|        """
   453|    ).fetchall()
   454|    return rowdicts(rows)
   455|
   456|
   457|def get_import_wizard_summary(conn: Connection) -> dict[str, str]:
   458|    from jarvis_finance.imports.broker_mapping import build_import_wizard_summary
   459|
   460|    return build_import_wizard_summary(conn)
   461|
   462|
   463|def get_manual_review_items(conn: Connection) -> list[dict[str, str]]:
   464|    from jarvis_finance.imports.broker_mapping import get_manual_review_queue
   465|
   466|    return get_manual_review_queue(conn)
   467|
   468|
   469|def get_broker_dry_runs(conn: Connection, limit: int = 20) -> list[dict[str, str]]:
   470|    from jarvis_finance.imports.broker_mapping import get_dry_run_sessions
   471|
   472|    return get_dry_run_sessions(conn, limit=limit)
   473|
   474|
   475|def get_safe_mode_status(conn: Connection) -> dict[str, str]:
   476|    backup = conn.execute("SELECT created_at FROM audit_log WHERE action LIKE '%backup%' ORDER BY created_at DESC LIMIT 1").fetchone()
   477|    return {
   478|        "db_mode": "Runtime",
   479|        "write_mode": "Read-only",
   480|        "last_backup_at": backup["created_at"] if backup else "nicht verfügbar",
   481|        "git_safety_status": "zuletzt OK / CLI prüfen",
   482|        "data_location": "Echte Daten liegen außerhalb Git",
   483|    }
   484|
   485|
   486|def get_broker_review_items(conn: Connection, limit: int = 100, **filters) -> list[dict[str, str]]:
   487|    from jarvis_finance.imports.broker_mapping import get_broker_review_items as _items
   488|
   489|    return _items(conn, limit=limit, **filters)
   490|
   491|
   492|def get_review_readiness_counts(conn: Connection) -> list[dict[str, str]]:
   493|    from jarvis_finance.imports.broker_mapping import get_review_readiness_counts as _counts
   494|
   495|    return _counts(conn)
   496|
   497|
   498|def get_ready_for_import_by_source(conn: Connection) -> list[dict[str, str]]:
   499|    from jarvis_finance.imports.broker_mapping import get_ready_for_import_by_source as _counts
   500|
   501|    return _counts(conn)
   502|
   503|
   504|def get_review_quality_flag_counts(conn: Connection) -> list[dict[str, str]]:
   505|    from jarvis_finance.imports.broker_mapping import aggregate_review_quality_flags
   506|
   507|    return [{"quality_flag": k, "open_items": str(v)} for k, v in sorted(aggregate_review_quality_flags(conn).items())]
   508|
   509|
   510|def get_command_center_summary(conn: Connection) -> dict[str, str]:
   511|    cash_rows = get_cash_overview(conn)
   512|    crypto_rows = get_crypto_overview(conn)
   513|    cash_total = sum(d(r["amount_chf"]) for r in cash_rows if r["amount_chf"])
   514|    crypto_total = sum(d(r["market_value_chf"]) for r in crypto_rows if r["market_value_chf"])
   515|    alert_counts = conn.execute("SELECT priority, COUNT(*) AS c FROM alerts WHERE status='active' GROUP BY priority").fetchall()
   516|    quality = get_data_quality(conn)
   517|    return {
   518|        "base_currency": "CHF",
   519|        "cash_total_chf": dec_text(cash_total, "0.01"),
   520|        "crypto_total_chf": dec_text(crypto_total, "0.01"),
   521|        "total_value_chf": dec_text(cash_total + crypto_total, "0.01"),
   522|        "active_alerts": ", ".join(f"{r['priority']}: {r['c']}" for r in alert_counts) or "0",
   523|        "data_quality_status": "kritisch" if any(i["severity"] == "kritisch" and i["count"] != "0" for i in quality) else ("warnung" if any(i["severity"] == "warnung" and i["count"] != "0" for i in quality) else "ok"),
   524|        "crypto_price_data_as_of": max((r["fetched_at"] for r in crypto_rows if r.get("fetched_at")), default=""),
   525|    }
   526|


     1|from __future__ import annotations
     2|
     3|from . import page_00_command_center
     4|from . import page_01_portfolio
     5|from . import page_02_platforms
     6|from . import page_03_equities_etfs
     7|from . import page_04_crypto
     8|from . import page_05_wallets
     9|from . import page_06_ledger
    10|from . import page_07_watchlist
    11|from . import page_08_reports
    12|from . import page_09_alerts
    13|from . import page_10_audit
    14|from . import page_11_settings_data_quality
    15|from . import page_12_crypto_manage
    16|from . import page_13_equity_manage
    17|from . import page_14_import_wizard
    18|from . import page_15_manual_review_queue
    19|from . import page_16_data_quality_center
    20|
    21|PAGES = [
    22|    page_00_command_center,
    23|    page_01_portfolio,
    24|    page_02_platforms,
    25|    page_03_equities_etfs,
    26|    page_04_crypto,
    27|    page_05_wallets,
    28|    page_06_ledger,
    29|    page_07_watchlist,
    30|    page_08_reports,
    31|    page_09_alerts,
    32|    page_10_audit,
    33|    page_11_settings_data_quality,
    34|    page_12_crypto_manage,
    35|    page_13_equity_manage,
    36|    page_14_import_wizard,
    37|    page_15_manual_review_queue,
    38|    page_16_data_quality_center,
    39|]
    40|


Need implement. Also main navigation.

Need implement. Also main navigation.


     1|from __future__ import annotations
     2|
     3|from pathlib import Path
     4|from sqlite3 import Connection
     5|
     6|from jarvis_finance.config.settings import ensure_runtime_dirs, find_repo_root, load_settings
     7|from jarvis_finance.dashboard.demo_data import load_synthetic_demo_data
     8|from jarvis_finance.dashboard.pages import PAGES
     9|from jarvis_finance.dashboard import data as dashboard_data
    10|from jarvis_finance.storage.database import connect
    11|from jarvis_finance.storage.migrations import apply_migrations
    12|
    13|
    14|def safe_connect_existing_db(db_path: Path) -> Connection | None:
    15|    if not db_path.exists():
    16|        return None
    17|    conn = connect(db_path)
    18|    apply_migrations(conn)
    19|    return conn
    20|
    21|
    22|def create_demo_db(db_path: Path) -> None:
    23|    db_path.parent.mkdir(parents=True, exist_ok=True)
    24|    conn = connect(db_path)
    25|    try:
    26|        load_synthetic_demo_data(conn)
    27|    finally:
    28|        conn.close()
    29|
    30|
    31|def run_app() -> None:
    32|    import streamlit as st
    33|
    34|    st.set_page_config(page_title="JARVIS Finance MVP", layout="wide")
    35|    st.sidebar.title("JARVIS Finance")
    36|    st.sidebar.caption("MVP: lokal, synthetisch, read-first")
    37|
    38|    try:
    39|        repo_root = find_repo_root()
    40|        settings = load_settings(repo_root=repo_root)
    41|    except Exception as exc:
    42|        st.error(f"Settings-/Runtime-Ladeprüfung fehlgeschlagen: {exc}")
    43|        st.info("Prüfe JARVIS_FINANCE_RUNTIME_DIR. Runtime muss ausserhalb des Repos liegen.")
    44|        return
    45|
    46|    ensure_runtime_dirs(settings)
    47|    db_path = settings.db_path
    48|    conn = safe_connect_existing_db(db_path)
    49|
    50|    if conn is None:
    51|        st.title("JARVIS Finance Dashboard")
    52|        st.error(f"Keine lokale SQLite-DB gefunden: {db_path}")
    53|        st.info("Das Dashboard erstellt oder verändert keine echte Datenbank automatisch.")
    54|        st.caption("Für lokale Entwicklung kann explizit eine synthetische Dummy-DB aus tests/fixtures geladen werden.")
    55|        if st.button("Synthetische Dummy-DB jetzt erstellen", type="primary"):
    56|            create_demo_db(db_path)
    57|            st.success("Synthetische Dummy-DB erstellt. Bitte Seite neu laden.")
    58|        return
    59|
    60|    try:
    61|        page_names = [page.PAGE_TITLE for page in PAGES]
    62|        selected = st.sidebar.radio("Navigation", page_names, index=0)
    63|        st.sidebar.write(f"DB: `{db_path}`")
    64|        safe = dashboard_data.get_safe_mode_status(conn)
    65|        st.sidebar.info(f"DB-Modus: {safe['db_mode']} | Write Mode: {safe['write_mode']}")
    66|        st.sidebar.caption(f"Backup: {safe['last_backup_at']}")
    67|        st.sidebar.warning("Keine Live-API-Abfragen beim Seitenreload. Preise nur aus lokaler DB/Cache. Echte Daten liegen außerhalb Git.")
    68|        page = PAGES[page_names.index(selected)]
    69|        page.render(st, conn)
    70|    finally:
    71|        conn.close()
    72|
    73|
    74|if __name__ == "__main__":
    75|    run_app()
    76|


     1|from __future__ import annotations
     2|
     3|from jarvis_finance.dashboard.components.tables import show_table
     4|from jarvis_finance.dashboard import data
     5|
     6|PAGE_TITLE = "Command Center"
     7|
     8|def render(st, conn) -> None:
     9|    st.title("Command Center")
    10|    summary = data.get_command_center_summary(conn)
    11|    c1, c2, c3, c4 = st.columns(4)
    12|    c1.metric("Gesamtwert", data.money_text(data.d(summary["total_value_chf"]), "CHF"))
    13|    c2.metric("Cash", data.money_text(data.d(summary["cash_total_chf"]), "CHF"))
    14|    c3.metric("Crypto", data.money_text(data.d(summary["crypto_total_chf"]), "CHF"))
    15|    c4.metric("Data Quality", summary["data_quality_status"])
    16|    st.caption(f"Crypto Preis-Datenstand lokal: {summary.get('crypto_price_data_as_of') or 'nicht verfügbar'}")
    17|    st.subheader("Safe Mode")
    18|    st.write(data.get_safe_mode_status(conn))
    19|    st.subheader("Cash-Übersicht")
    20|    show_table(st, data.get_cash_overview(conn))
    21|    st.subheader("Offene Alerts")
    22|    show_table(st, [r for r in data.get_alerts(conn) if r["status"] == "active"][:10])
    23|    st.subheader("Letzte Audit-Ereignisse")
    24|    show_table(st, data.get_audit_events(conn, limit=10))
    25|


{"content": "     1|from __future__ import annotations\n     2|\n     3|from jarvis_finance.dashboard.components.tables import show_table\n     4|from jarvis_finance.dashboard import data\n     5|\n     6|PAGE_TITLE = \"Crypto\"\n     7|\n     8|def render(st, conn) -> None:\n     9|    st.title(\"Crypto\")\n    10|    st.caption(\"Preise werden nur aus der lokalen DB/Cache gelesen; kein CoinGecko-Aufruf beim Rendern.\")\n    11|    st.subheader(\"Coins aggregiert\")\n    12|    show_table(st, data.get_crypto_overview(conn))\n    13|    st.subheader(\"Coins nach Wallet\")\n    14|    show_table(st, data.get_crypto_by_wallet(conn))\n    15|    st.subheader(\"Wallet-Gesamtübersicht\")\n    16|    show_table(st, data.get_wallets(conn))\n    17|    st.subheader(\"Fehlende/veraltete Preis- und CoinGecko-Hinweise\")\n    18|    quality_rows = [r for r in data.get_crypto_overview(conn) if not r[\"coingecko_id\"] or r[\"price_quality\"] != \"fresh\"]\n    19|    show_table(st, quality_rows)\n    20|    st.subheader(\"Letzte Crypto-Transaktionen\")\n    21|    show_table(st, data.get_latest_crypto_transactions(conn))\n    22|", "total_lines": 21, "file_size": 925, "truncated": false, "is_binary": false, "is_image": false}

[Tool loop warning: idempotent_no_progress_warning; count=2; read_file returned the same result 2 times. Use the result already provided or change the query instead of repeating it unchanged.]


{"content": "     1|from __future__ import annotations\n     2|\n     3|from jarvis_finance.dashboard.components.tables import show_table\n     4|from jarvis_finance.dashboard import data\n     5|\n     6|PAGE_TITLE = \"Wallets\"\n     7|\n     8|def render(st, conn) -> None:\n     9|    st.title(\"Wallets\")\n    10|    st.subheader(\"Wallet-Tabelle\")\n    11|    show_table(st, data.get_wallets(conn))\n    12|    st.subheader(\"Synthetische Holdings je Wallet\")\n    13|    show_table(st, data.get_crypto_by_wallet(conn))\n    14|", "total_lines": 13, "file_size": 405, "truncated": false, "is_binary": false, "is_image": false}

[Tool loop warning: idempotent_no_progress_warning; count=2; read_file returned the same result 2 times. Use the result already provided or change the query instead of repeating it unchanged.]


Explanation:
**Planning implementation tests**

I need to make sure to implement enough tests for this project. I’ll focus on using write_file/patch and ensure that I import record_crypto_transfer from transfer_crypto_position. It looks like I’ll also need various data functions and pages. I’ll confirm the ETFs runtime via a script later. I should know about the auditing process as well, and manage how to create an active/inactive wallet. I wonder if the UI can create and then update an inactive wallet.

patch

from jarvis_finance.crypto.holdings import calculate_crypto_holdings, create_initial_holding_snapshot
from jarvis_finance.imports.common import stable_id, utc_now

from jarvis_finance.crypto.holdings import calculate_crypto_holdings, create_initial_holding_snapshot
from jarvis_finance.crypto.transactions import record_crypto_transfer
from jarvis_finance.imports.common import stable_id, utc_now


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/crypto/manage.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/crypto/manage.py
@@ -7,6 +7,7 @@
 from jarvis_finance.audit.log import record_audit_event
 from jarvis_finance.crypto.assets import create_crypto_asset
 from jarvis_finance.crypto.holdings import calculate_crypto_holdings, create_initial_holding_snapshot
+from jarvis_finance.crypto.transactions import record_crypto_transfer
 from jarvis_finance.imports.common import stable_id, utc_now
 from jarvis_finance.quality.alerts import create_alert


def transfer_crypto_position(
    conn: Connection,
    *,
    asset_id: str,
    from_wallet_id: str,
    to_wallet_id: str,
    quantity_text: str,
    effective_date: str,
    note: str,
    confirm: bool,
    fee_quantity_text: str | None = None,
) -> CryptoManageResult:
    _require_confirm(confirm)
    _require_date(effective_date)
    from_wallet_id = _require_wallet(conn, from_wallet_id)
    to_wallet_id = _require_wallet(conn, to_wallet_id)
    note = str(note or "").strip() or "manual wallet transfer"
    asset = conn.execute("SELECT asset_id FROM crypto_assets WHERE asset_id=?", (asset_id,)).fetchone()
    if asset is None:
        raise ValueError("asset not found")
    quantity = parse_decimal_text(quantity_text)
    if quantity <= 0:
        raise ValueError("quantity must be > 0")
    fee_quantity = parse_decimal_text(fee_quantity_text or "0", field_name="fee quantity")
    if fee_quantity < 0:
        raise ValueError("fee quantity must be >= 0")
    tx_id = record_crypto_transfer(
        conn,
        asset_id=asset_id,
        from_wallet_id=from_wallet_id,
        to_wallet_id=to_wallet_id,
        quantity=quantity,
        fee_quantity=fee_quantity,
        note=note,
    )
    return CryptoManageResult(asset_id=asset_id, wallet_id=to_wallet_id, transaction_id=tx_id)

def get_crypto_management_options(conn: Connection) -> dict[str, list[dict[str, str]] | list[str]]:


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/crypto/manage.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/crypto/manage.py
@@ -342,6 +342,45 @@
     )

+def transfer_crypto_position(
+    conn: Connection,
+    *,
+    asset_id: str,
+    from_wallet_id: str,
+    to_wallet_id: str,
+    quantity_text: str,
+    effective_date: str,
+    note: str,
+    confirm: bool,
+    fee_quantity_text: str | None = None,
+) -> CryptoManageResult:
+    _require_confirm(confirm)
+    _require_date(effective_date)
+    from_wallet_id = _require_wallet(conn, from_wallet_id)
+    to_wallet_id = _require_wallet(conn, to_wallet_id)
+    note = str(note or "").strip() or "manual wallet transfer"
+    asset = conn.execute("SELECT asset_id FROM crypto_assets WHERE asset_id=?", (asset_id,)).fetchone()
+    if asset is None:
+        raise ValueError("asset not found")
+    quantity = parse_decimal_text(quantity_text)
+    if quantity <= 0:
+        raise ValueError("quantity must be > 0")
+    fee_quantity = parse_decimal_text(fee_quantity_text or "0", field_name="fee quantity")
+    if fee_quantity < 0:
+        raise ValueError("fee quantity must be >= 0")
+    tx_id = record_crypto_transfer(
+        conn,
+        asset_id=asset_id,
+        from_wallet_id=from_wallet_id,
+        to_wallet_id=to_wallet_id,
+        quantity=quantity,
+        fee_quantity=fee_quantity,
+        note=note,
+    )
+    return CryptoManageResult(asset_id=asset_id, wallet_id=to_wallet_id, transaction_id=tx_id)
+
+
+
 def get_crypto_management_options(conn: Connection) -> dict[str, list[dict[str, str]] | list[str]]:
     wallets = [
         {"wallet_id": row["wallet_id"], "wallet_name": row["wallet_name"], "wallet_type": row["wallet_type"]}


def percent_text(value: Decimal | None) -> str:
    if value is None:
        return ""
    return f"{dec_text(value, '0.01')}%"

def price_status(price: dict[str, str] | None) -> str:
    if not price:
        return "missing"
    return price.get("quality_status") or "missing"

def _latest_price_time(conn: Connection, *, currency: str = "CHF") -> str:
    row = conn.execute(
        """
        SELECT COALESCE(MAX(provider_timestamp), MAX(fetched_at)) AS ts
        FROM crypto_prices WHERE price_currency=?
        """,
        (currency.upper(),),
    ).fetchone()
    return row["ts"] if row and row["ts"] else ""

def _crypto_value_rows(conn: Connection, *, price_max_age_seconds: int = 86_400) -> list[dict[str, object]]:
    holdings = calculate_crypto_holdings(conn)
    rows: list[dict[str, object]] = []
    for asset_id, holding in sorted(holdings.total_by_asset.items()):
        asset = conn.execute("SELECT * FROM crypto_assets WHERE asset_id=?", (asset_id,)).fetchone()
        if not asset:
            continue
        price = latest_crypto_price(conn, asset_id, "CHF", max_age_seconds=price_max_age_seconds)
        price_dec = d(price["price"]) if price and price["price"] else None
        value = holding.quantity * price_dec if price_dec is not None else None
        wallets = {wid for (wid, aid), wh in holdings.wallet_holdings.items() if aid == asset_id and wh.quantity != 0}
        rows.append({
            "asset_id": asset_id,
            "coin": asset["coin_name"],
            "symbol": asset["symbol"],
            "coingecko_id": asset["coingecko_id"] or "",
            "quantity": holding.quantity,
            "price": price_dec,
            "value": value,
            "wallet_count": len(wallets),
            "price_status": price_status(price),
            "last_price_update": (price["provider_timestamp"] or price["fetched_at"]) if price else "",
        })
    return rows

def rowdicts(rows) -> list[dict[str, str]]:
    return [{k: ("" if row[k] is None else str(row[k])) for k in row.keys()} for row in rows]


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/data.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/data.py
@@ -38,8 +38,53 @@
     return f"{dec_text(value, '0.01')} {currency}"

-def rowdicts(rows) -> list[dict[str, str]]:
-    return [{k: ("" if row[k] is None else str(row[k])) for k in row.keys()} for row in rows]
+def percent_text(value: Decimal | None) -> str:
+    if value is None:
+        return ""
+    return f"{dec_text(value, '0.01')}%"
+
+
+def price_status(price: dict[str, str] | None) -> str:
+    if not price:
+        return "missing"
+    return price.get("quality_status") or "missing"
+
+
+def _latest_price_time(conn: Connection, *, currency: str = "CHF") -> str:
+    row = conn.execute(
+        """
+        SELECT COALESCE(MAX(provider_timestamp), MAX(fetched_at)) AS ts
+        FROM crypto_prices WHERE price_currency=?
+        """,
+        (currency.upper(),),
+    ).fetchone()
+    return row["ts"] if row and row["ts"] else ""
+
+
+def _crypto_value_rows(conn: Connection, *, price_max_age_seconds: int = 86_400) -> list[dict[str, object]]:
+    holdings = calculate_crypto_holdings(conn)
+    rows: list[dict[str, object]] = []
+    for asset_id, holding in sorted(holdings.total_by_asset.items()):
+        asset = conn.execute("SELECT * FROM crypto_assets WHERE asset_id=?", (asset_id,)).fetchone()
+        if not asset:
+            continue
+        price = latest_crypto_price(conn, asset_id, "CHF", max_age_seconds=price_max_age_seconds)
+        price_dec = d(price["price"]) if price and price["price"] else None
+        value = holding.quantity * price_dec if price_dec is not None else None
+        wallets = {wid for (wid, aid), wh in holdings.wallet_holdings.items() if aid == asset_id and wh.quantity != 0}
+        rows.append({
+            "asset_id": asset_id,
+            "coin": asset["coin_name"],
+            "symbol": asset["symbol"],
+            "coingecko_id": asset["coingecko_id"] or "",
+            "quantity": holding.quantity,
+            "price": price_dec,
+            "value": value,
+            "wallet_count": len(wallets),
+            "price_status": price_status(price),
+            "last_price_update": (price["provider_timestamp"] or price["fetched_at"]) if price else "",
+        })
+    return rows

 def db_exists(path: str | Path) -> bool:


def rowdicts(rows) -> list[dict[str, str]]:
    return [{k: ("" if row[k] is None else str(row[k])) for k in row.keys()} for row in rows]

def db_exists(path: str | Path) -> bool:


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/data.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/data.py
@@ -85,6 +85,10 @@
             "last_price_update": (price["provider_timestamp"] or price["fetched_at"]) if price else "",
         })
     return rows
+
+
+def rowdicts(rows) -> list[dict[str, str]]:
+    return [{k: ("" if row[k] is None else str(row[k])) for k in row.keys()} for row in rows]

 def db_exists(path: str | Path) -> bool:


def get_crypto_summary_cards(conn: Connection, *, price_max_age_seconds: int = 86_400) -> dict[str, str]:
    rows = _crypto_value_rows(conn, price_max_age_seconds=price_max_age_seconds)
    holdings = calculate_crypto_holdings(conn)
    total_value = sum((r["value"] for r in rows if r["value"] is not None), Decimal("0"))
    missing = sum(1 for r in rows if r["price_status"] == "missing")
    stale = sum(1 for r in rows if r["price_status"] == "stale")
    wallets_with_holdings = {wid for (wid, _aid), holding in holdings.wallet_holdings.items() if holding.quantity != 0}
    status = "ok" if missing == 0 and stale == 0 else ("warning" if missing or stale else "ok")
    return {
        "total_value_chf": dec_text(total_value),
        "coin_count": str(len(rows)),
        "wallets_with_holdings": str(len(wallets_with_holdings)),
        "holding_count": str(sum(1 for h in holdings.wallet_holdings.values() if h.quantity != 0)),
        "coins_without_price": str(missing),
        "stale_price_count": str(stale),
        "last_price_update_at": _latest_price_time(conn),
        "data_quality_status": status,
    }

def get_crypto_coin_summary(conn: Connection, *, admin_mode: bool = False, price_max_age_seconds: int = 86_400) -> list[dict[str, str]]:
    raw = _crypto_value_rows(conn, price_max_age_seconds=price_max_age_seconds)
    total = sum((r["value"] for r in raw if r["value"] is not None), Decimal("0"))
    out: list[dict[str, str]] = []
    for r in sorted(raw, key=lambda item: item["value"] or Decimal("-1"), reverse=True):
        value = r["value"]
        share = ((value / total) * Decimal("100")) if value is not None and total else None
        item = {
            "coin": str(r["coin"]),
            "symbol": str(r["symbol"]),
            "total_quantity": dec_text(r["quantity"]),
            "current_price_chf": dec_text(r["price"], "0.01") if r["price"] is not None else "nicht bewertet",
            "total_value_chf": dec_text(value, "0.01") if value is not None else "nicht bewertet",
            "portfolio_share": percent_text(share),
            "wallet_count": str(r["wallet_count"]),
            "price_status": str(r["price_status"]),
            "last_price_update": str(r["last_price_update"]),
            "actions": "Details · Bestand hinzufügen · Korrigieren · Transfer · Verlauf",
            "_asset_id": str(r["asset_id"]),
        }
        if admin_mode:
            item.update({"asset_id": str(r["asset_id"]), "coingecko_id": str(r["coingecko_id"])})
        out.append(item)
    return out

def get_crypto_coin_wallet_details(conn: Connection, asset_id: str, *, price_max_age_seconds: int = 86_400, admin_mode: bool = False) -> list[dict[str, str]]:
    holdings = calculate_crypto_holdings(conn)
    price = latest_crypto_price(conn, asset_id, "CHF", max_age_seconds=price_max_age_seconds)
    price_dec = d(price["price"]) if price and price["price"] else None
    total_qty = sum((h.quantity for (wid, aid), h in holdings.wallet_holdings.items() if aid == asset_id), Decimal("0"))
    rows: list[dict[str, str]] = []
    for (wallet_id, aid), holding in sorted(holdings.wallet_holdings.items()):
        if aid != asset_id or holding.quantity == 0:
            continue
        wallet = conn.execute("SELECT wallet_name, wallet_type, platform_provider, notes FROM crypto_wallets WHERE wallet_id=?", (wallet_id,)).fetchone()
        value = holding.quantity * price_dec if price_dec is not None else None
        share = (holding.quantity / total_qty * Decimal("100")) if total_qty else None
        item = {
            "wallet": wallet["wallet_name"] if wallet else wallet_id,
            "wallet_type": wallet["wallet_type"] if wallet else "",
            "quantity": dec_text(holding.quantity),
            "value_chf": dec_text(value, "0.01") if value is not None else "nicht bewertet",
            "share_of_coin": percent_text(share),
            "last_verified_at": holding.last_verified_at or "",
            "note": wallet["notes"] if wallet and wallet["notes"] else "",
            "actions": "Korrigieren · Transfer · Entfernen/0 setzen · Audit anzeigen",
            "_wallet_id": wallet_id,
            "_asset_id": asset_id,
        }
        if admin_mode:
            item.update({"wallet_id": wallet_id, "asset_id": asset_id})
        rows.append(item)
    return rows

def get_wallet_overview_cards(conn: Connection, *, price_max_age_seconds: int = 86_400) -> dict[str, str]:
    rows = get_wallet_user_overview(conn, price_max_age_seconds=price_max_age_seconds)
    total_value = sum((d(r["total_value_chf"]) for r in rows if r["total_value_chf"] not in {"", "nicht bewertet"}), Decimal("0"))
    missing_verification = sum(1 for r in rows if not r.get("last_verified_at"))
    return {"wallet_count": str(len(rows)), "total_value_chf": dec_text(total_value), "wallets_missing_verification": str(missing_verification)}

def get_wallet_user_overview(conn: Connection, *, price_max_age_seconds: int = 86_400, admin_mode: bool = False) -> list[dict[str, str]]:
    holdings = calculate_crypto_holdings(conn)
    rows: list[dict[str, str]] = []
    for wallet in conn.execute("SELECT * FROM crypto_wallets ORDER BY wallet_name").fetchall():
        value = Decimal("0")
        has_missing_price = False
        coins: set[str] = set()
        for (wallet_id, asset_id), holding in holdings.wallet_holdings.items():
            if wallet_id != wallet["wallet_id"] or holding.quantity == 0:
                continue
            coins.add(asset_id)
            price = latest_crypto_price(conn, asset_id, "CHF", max_age_seconds=price_max_age_seconds)
            if price and price.get("price"):
                value += holding.quantity * d(price["price"])
            else:
                has_missing_price = True
        item = {
            "wallet_name": wallet["wallet_name"],
            "wallet_type": wallet["wallet_type"],
            "provider": wallet["platform_provider"] or "",
            "total_value_chf": dec_text(value, "0.01") if not has_missing_price else f"{dec_text(value, '0.01')} + unbewertet",
            "coin_count": str(len(coins)),
            "last_verified_at": wallet["last_verified_at"] or "",
            "status": "active" if int(wallet["is_active"] or 0) else "inactive",
            "actions": "Coin hinzufügen · Bestand korrigieren · Transfer · Verifizieren · Deaktivieren",
            "_wallet_id": wallet["wallet_id"],
        }
        if admin_mode:
            item["wallet_id"] = wallet["wallet_id"]
        rows.append(item)
    return rows

def get_wallet_coin_details(conn: Connection, wallet_id: str, *, price_max_age_seconds: int = 86_400, admin_mode: bool = False) -> list[dict[str, str]]:
    holdings = calculate_crypto_holdings(conn)
    rows: list[dict[str, str]] = []
    for (wid, asset_id), holding in sorted(holdings.wallet_holdings.items()):
        if wid != wallet_id or holding.quantity == 0:
            continue
        asset = conn.execute("SELECT coin_name, symbol FROM crypto_assets WHERE asset_id=?", (asset_id,)).fetchone()
        price = latest_crypto_price(conn, asset_id, "CHF", max_age_seconds=price_max_age_seconds)
        price_dec = d(price["price"]) if price and price["price"] else None
        value = holding.quantity * price_dec if price_dec is not None else None
        item = {
            "coin": asset["coin_name"] if asset else asset_id,
            "symbol": asset["symbol"] if asset else "",
            "quantity": dec_text(holding.quantity),
            "value_chf": dec_text(value, "0.01") if value is not None else "nicht bewertet",
            "price_status": price_status(price),
            "actions": "Bestand korrigieren · Transfer aus Wallet · Audit anzeigen",
            "_asset_id": asset_id,
            "_wallet_id": wallet_id,
        }
        if admin_mode:
            item.update({"asset_id": asset_id, "wallet_id": wallet_id})
        rows.append(item)
    return rows

def get_crypto_overview(conn: Connection, *, price_max_age_seconds: int = 86_400) -> list[dict[str, str]]:


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/data.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/data.py
@@ -122,7 +122,148 @@
     return item

-def get_crypto_overview(conn: Connection, *, price_max_age_seconds: int = 86_400) -> list[dict[str, str]]:
+def get_crypto_summary_cards(conn: Connection, *, price_max_age_seconds: int = 86_400) -> dict[str, str]:
+    rows = _crypto_value_rows(conn, price_max_age_seconds=price_max_age_seconds)
+    holdings = calculate_crypto_holdings(conn)
+    total_value = sum((r["value"] for r in rows if r["value"] is not None), Decimal("0"))
+    missing = sum(1 for r in rows if r["price_status"] == "missing")
+    stale = sum(1 for r in rows if r["price_status"] == "stale")
+    wallets_with_holdings = {wid for (wid, _aid), holding in holdings.wallet_holdings.items() if holding.quantity != 0}
+    status = "ok" if missing == 0 and stale == 0 else ("warning" if missing or stale else "ok")
+    return {
+        "total_value_chf": dec_text(total_value),
+        "coin_count": str(len(rows)),
+        "wallets_with_holdings": str(len(wallets_with_holdings)),
+        "holding_count": str(sum(1 for h in holdings.wallet_holdings.values() if h.quantity != 0)),
+        "coins_without_price": str(missing),
+        "stale_price_count": str(stale),
+        "last_price_update_at": _latest_price_time(conn),
+        "data_quality_status": status,
+    }
+
+
+def get_crypto_coin_summary(conn: Connection, *, admin_mode: bool = False, price_max_age_seconds: int = 86_400) -> list[dict[str, str]]:
+    raw = _crypto_value_rows(conn, price_max_age_seconds=price_max_age_seconds)
+    total = sum((r["value"] for r in raw if r["value"] is not None), Decimal("0"))
+    out: list[dict[str, str]] = []
+    for r in sorted(raw, key=lambda item: item["value"] or Decimal("-1"), reverse=True):
+        value = r["value"]
+        share = ((value / total) * Decimal("100")) if value is not None and total else None
+        item = {
+            "coin": str(r["coin"]),
+            "symbol": str(r["symbol"]),
+            "total_quantity": dec_text(r["quantity"]),
+            "current_price_chf": dec_text(r["price"], "0.01") if r["price"] is not None else "nicht bewertet",
+            "total_value_chf": dec_text(value, "0.01") if value is not None else "nicht bewertet",
+            "portfolio_share": percent_text(share),
+            "wallet_count": str(r["wallet_count"]),
+            "price_status": str(r["price_status"]),
+            "last_price_update": str(r["last_price_update"]),
+            "actions": "Details · Bestand hinzufügen · Korrigieren · Transfer · Verlauf",
+            "_asset_id": str(r["asset_id"]),
+        }
+        if admin_mode:
+            item.update({"asset_id": str(r["asset_id"]), "coingecko_id": str(r["coingecko_id"])})
+        out.append(item)
+    return out
+
+
+def get_crypto_coin_wallet_details(conn: Connection, asset_id: str, *, price_max_age_seconds: int = 86_400, admin_mode: bool = False) -> list[dict[str, str]]:
+    holdings = calculate_crypto_holdings(conn)
+    price = latest_crypto_price(conn, asset_id, "CHF", max_age_seconds=price_max_age_seconds)
+    price_dec = d(price["price"]) if price and price["price"] else None
+    total_qty = sum((h.quantity for (wid, aid), h in holdings.wallet_holdings.items() if aid == asset_id), Decimal("0"))
+    rows: list[dict[str, str]] = []
+    for (wallet_id, aid), holding in sorted(holdings.wallet_holdings.items()):
+        if aid != asset_id or holding.quantity == 0:
+            continue
+        wallet = conn.execute("SELECT wallet_name, wallet_type, platform_provider, notes FROM crypto_wallets WHERE wallet_id=?", (wallet_id,)).fetchone()
+        value = holding.quantity * price_dec if price_dec is not None else None
+        share = (holding.quantity / total_qty * Decimal("100")) if total_qty else None
+        item = {
+            "wallet": wallet["wallet_name"] if wallet else wallet_id,
+            "wallet_type": wallet["wallet_type"] if wallet else "",
+            "quantity": dec_text(holding.quantity),
+            "value_chf": dec_text(value, "0.01") if value is not None else "nicht bewertet",
+            "share_of_coin": percent_text(share),
+            "last_verified_at": holding.last_verified_at or "",
+            "note": wallet["notes"] if wallet and wallet["notes"] else "",
+            "actions": "Korrigieren · Transfer · Entfernen/0 setzen · Audit anzeigen",
+            "_wallet_id": wallet_id,
+            "_asset_id": asset_id,
+        }
+        if admin_mode:
+            item.update({"wallet_id": wallet_id, "asset_id": asset_id})
+        rows.append(item)
+    return rows
+
+
+def get_wallet_overview_cards(conn: Connection, *, price_max_age_seconds: int = 86_400) -> dict[str, str]:
+    rows = get_wallet_user_overview(conn, price_max_age_seconds=price_max_age_seconds)
+    total_value = sum((d(r["total_value_chf"]) for r in rows if r["total_value_chf"] not in {"", "nicht bewertet"}), Decimal("0"))
+    missing_verification = sum(1 for r in rows if not r.get("last_verified_at"))
+    return {"wallet_count": str(len(rows)), "total_value_chf": dec_text(total_value), "wallets_missing_verification": str(missing_verification)}
+
+
+def get_wallet_user_overview(conn: Connection, *, price_max_age_seconds: int = 86_400, admin_mode: bool = False) -> list[dict[str, str]]:
+    holdings = calculate_crypto_holdings(conn)
+    rows: list[dict[str, str]] = []
+    for wallet in conn.execute("SELECT * FROM crypto_wallets ORDER BY wallet_name").fetchall():
+        value = Decimal("0")
+        has_missing_price = False
+        coins: set[str] = set()
+        for (wallet_id, asset_id), holding in holdings.wallet_holdings.items():
+            if wallet_id != wallet["wallet_id"] or holding.quantity == 0:
+                continue
+            coins.add(asset_id)
+            price = latest_crypto_price(conn, asset_id, "CHF", max_age_seconds=price_max_age_seconds)
+            if price and price.get("price"):
+                value += holding.quantity * d(price["price"])
+            else:
+                has_missing_price = True
+        item = {
+            "wallet_name": wallet["wallet_name"],
+            "wallet_type": wallet["wallet_type"],
+            "provider": wallet["platform_provider"] or "",
+            "total_value_chf": dec_text(value, "0.01") if not has_missing_price else f"{dec_text(value, '0.01')} + unbewertet",
+            "coin_count": str(len(coins)),
+            "last_verified_at": wallet["last_verified_at"] or "",
+            "status": "active" if int(wallet["is_active"] or 0) else "inactive",
+            "actions": "Coin hinzufügen · Bestand korrigieren · Transfer · Verifizieren · Deaktivieren",
+            "_wallet_id": wallet["wallet_id"],
+        }
+        if admin_mode:
+            item["wallet_id"] = wallet["wallet_id"]
+        rows.append(item)
+    return rows
+
+
+def get_wallet_coin_details(conn: Connection, wallet_id: str, *, price_max_age_seconds: int = 86_400, admin_mode: bool = False) -> list[dict[str, str]]:
+    holdings = calculate_crypto_holdings(conn)
+    rows: list[dict[str, str]] = []
+    for (wid, asset_id), holding in sorted(holdings.wallet_holdings.items()):
+        if wid != wallet_id or holding.quantity == 0:
+            continue
+        asset = conn.execute("SELECT coin_name, symbol FROM crypto_assets WHERE asset_id=?", (asset_id,)).fetchone()
+        price = latest_crypto_price(conn, asset_id, "CHF", max_age_seconds=price_max_age_seconds)
+        price_dec = d(price["price"]) if price and price["price"] else None
+        value = holding.quantity * price_dec if price_dec is not None else None
+        item = {
+            "coin": asset["coin_name"] if asset else asset_id,
+            "symbol": asset["symbol"] if asset else "",
+            "quantity": dec_text(holding.quantity),
+            "value_chf": dec_text(value, "0.01") if value is not None else "nicht bewertet",
+            "price_status": price_status(price),
+            "actions": "Bestand korrigieren · Transfer aus Wallet · Audit anzeigen",
+            "_asset_id": asset_id,
+            "_wallet_id": wallet_id,
+        }
+        if admin_mode:
+            item.update({"asset_id": asset_id, "wallet_id": wallet_id})
+        rows.append(item)
+    return rows
+
+
     holdings = calculate_crypto_holdings(conn)
     rows: list[dict[str, str]] = []
     for asset_id, holding in sorted(holdings.total_by_asset.items()):


   255|            "value_chf": dec_text(value, "0.01") if value is not None else "nicht bewertet",
   256|            "price_status": price_status(price),
   257|            "actions": "Bestand korrigieren · Transfer aus Wallet · Audit anzeigen",
   258|            "_asset_id": asset_id,
   259|            "_wallet_id": wallet_id,
   260|        }
   261|        if admin_mode:
   262|            item.update({"asset_id": asset_id, "wallet_id": wallet_id})
   263|        rows.append(item)
   264|    return rows
   265|
   266|
   267|    holdings = calculate_crypto_holdings(conn)
   268|    rows: list[dict[str, str]] = []
   269|    for asset_id, holding in sorted(holdings.total_by_asset.items()):
   270|        qty = holding.quantity
   271|        asset = conn.execute("SELECT * FROM crypto_assets WHERE asset_id=?", (asset_id,)).fetchone()
   272|        if not asset:
   273|            continue
   274|        price = latest_crypto_price(conn, asset_id, "CHF", max_age_seconds=price_max_age_seconds)
   275|        price_dec = d(price["price"]) if price and price["price"] else None
   276|        value = qty * price_dec if price_dec is not None else None
   277|        rows.append({
   278|            "asset_id": asset_id,
   279|            "coin_name": asset["coin_name"],
   280|            "symbol": asset["symbol"],
   281|            "coingecko_id": asset["coingecko_id"] or "",
   282|            "quantity": dec_text(qty),
   283|            "latest_price_chf": dec_text(price_dec, "0.01") if price_dec is not None else "",
   284|            "market_value_chf": dec_text(value, "0.01") if value is not None else "",
   285|            "price_quality": price["quality_status"] if price else "missing",
   286|            "fetched_at": price["fetched_at"] if price else "",
   287|        })
   288|    return rows
   289|
   290|
   291|def get_crypto_by_wallet(conn: Connection, *, price_max_age_seconds: int = 86_400) -> list[dict[str, str]]:
   292|    holdings = calculate_crypto_holdings(conn)
   293|    rows: list[dict[str, str]] = []
   294|    for (wallet_id, asset_id), holding in sorted(holdings.wallet_holdings.items()):
   295|        wallet = conn.execute("SELECT wallet_name, wallet_type, platform_provider FROM crypto_wallets WHERE wallet_id=?", (wallet_id,)).fetchone()
   296|        asset = conn.execute("SELECT coin_name, symbol, coingecko_id FROM crypto_assets WHERE asset_id=?", (asset_id,)).fetchone()
   297|        price = latest_crypto_price(conn, asset_id, "CHF", max_age_seconds=price_max_age_seconds)
   298|        price_dec = d(price["price"]) if price and price["price"] else None
   299|        value = holding.quantity * price_dec if price_dec is not None else None
   300|        rows.append({
   301|            "wallet_id": wallet_id,
   302|            "wallet_name": wallet["wallet_name"] if wallet else wallet_id,
   303|            "wallet_type": wallet["wallet_type"] if wallet else "",
   304|            "provider": wallet["platform_provider"] if wallet else "",
   305|            "coin_name": asset["coin_name"] if asset else asset_id,
   306|            "symbol": asset["symbol"] if asset else "",
   307|            "coingecko_id": asset["coingecko_id"] if asset else "",
   308|            "quantity": dec_text(holding.quantity),
   309|            "market_value_chf": dec_text(value, "0.01") if value is not None else "",
   310|            "wallet_value_chf": dec_text(value, "0.01") if value is not None else "",
   311|            "price_quality": price["quality_status"] if price else "missing",
   312|            "price_fetched_at": price["fetched_at"] if price else "",
   313|            "verification_status": holding.verification_status,
   314|            "last_verified_at": holding.last_verified_at or "",
   315|

Use offset=315 to continue reading (showing 255-314 of 715 lines)


def get_crypto_overview(conn: Connection, *, price_max_age_seconds: int = 86_400) -> list[dict[str, str]]:
    holdings = calculate_crypto_holdings(conn)


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/data.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/data.py
@@ -262,8 +262,7 @@
             item.update({"asset_id": asset_id, "wallet_id": wallet_id})
         rows.append(item)
     return rows
-
-
+def get_crypto_overview(conn: Connection, *, price_max_age_seconds: int = 86_400) -> list[dict[str, str]]:
     holdings = calculate_crypto_holdings(conn)
     rows: list[dict[str, str]] = []
     for asset_id, holding in sorted(holdings.total_by_asset.items()):

/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/data.py was last read with offset/limit pagination (partial view). Re-read the whole file before overwriting it.


def get_command_center_summary(conn: Connection) -> dict[str, str]:
    cash_rows = get_cash_overview(conn)
    crypto_summary = get_crypto_summary_cards(conn)
    positions = get_positions(conn)
    cash_total = sum(d(r["amount_chf"]) for r in cash_rows if r["amount_chf"])
    crypto_total = d(crypto_summary["total_value_chf"])
    equity_total = sum(d(r["market_value_chf"]) for r in positions if r.get("market_value_chf"))
    alert_counts = conn.execute("SELECT priority, COUNT(*) AS c FROM alerts WHERE status='active' GROUP BY priority").fetchall()
    critical = sum(int(r["c"] or 0) for r in alert_counts if r["priority"] == "kritisch")
    quality = get_data_quality(conn)
    dq = "kritisch" if any(i["severity"] == "kritisch" and i["count"] != "0" for i in quality) else ("warnung" if any(i["severity"] == "warnung" and i["count"] != "0" for i in quality) else "ok")
    return {
        "base_currency": "CHF",
        "cash_total_chf": dec_text(cash_total, "0.01"),
        "crypto_total_chf": dec_text(crypto_total, "0.01"),
        "equity_etf_total_chf": dec_text(equity_total, "0.01"),
        "total_portfolio_chf": dec_text(cash_total + crypto_total + equity_total, "0.01"),
        "total_value_chf": dec_text(cash_total + crypto_total + equity_total, "0.01"),
        "critical_alert_count": str(critical),
        "active_alerts": ", ".join(f"{r['priority']}: {r['c']}" for r in alert_counts) or "0",
        "data_quality_status": dq,
        "last_price_update_at": crypto_summary.get("last_price_update_at", ""),
        "crypto_price_data_as_of": crypto_summary.get("last_price_update_at", ""),
    }

def get_command_center_summary(conn: Connection) -> dict[str, str]:
    cash_rows = get_cash_overview(conn)
    crypto_rows = get_crypto_overview(conn)
    cash_total = sum(d(r["amount_chf"]) for r in cash_rows if r["amount_chf"])
    crypto_total = sum(d(r["market_value_chf"]) for r in crypto_rows if r["market_value_chf"])
    alert_counts = conn.execute("SELECT priority, COUNT(*) AS c FROM alerts WHERE status='active' GROUP BY priority").fetchall()
    quality = get_data_quality(conn)
    return {
        "base_currency": "CHF",
        "cash_total_chf": dec_text(cash_total, "0.01"),
        "crypto_total_chf": dec_text(crypto_total, "0.01"),
        "total_value_chf": dec_text(cash_total + crypto_total, "0.01"),
        "active_alerts": ", ".join(f"{r['priority']}: {r['c']}" for r in alert_counts) or "0",
        "data_quality_status": "kritisch" if any(i["severity"] == "kritisch" and i["count"] != "0" for i in quality) else ("warnung" if any(i["severity"] == "warnung" and i["count"] != "0" for i in quality) else "ok"),
        "crypto_price_data_as_of": max((r["fetched_at"] for r in crypto_rows if r.get("fetched_at")), default=""),
    }


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/data.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/data.py
@@ -698,17 +698,25 @@

 def get_command_center_summary(conn: Connection) -> dict[str, str]:
     cash_rows = get_cash_overview(conn)
-    crypto_rows = get_crypto_overview(conn)
+    crypto_summary = get_crypto_summary_cards(conn)
+    positions = get_positions(conn)
     cash_total = sum(d(r["amount_chf"]) for r in cash_rows if r["amount_chf"])
-    crypto_total = sum(d(r["market_value_chf"]) for r in crypto_rows if r["market_value_chf"])
+    crypto_total = d(crypto_summary["total_value_chf"])
+    equity_total = sum(d(r["market_value_chf"]) for r in positions if r.get("market_value_chf"))
     alert_counts = conn.execute("SELECT priority, COUNT(*) AS c FROM alerts WHERE status='active' GROUP BY priority").fetchall()
+    critical = sum(int(r["c"] or 0) for r in alert_counts if r["priority"] == "kritisch")
     quality = get_data_quality(conn)
+    dq = "kritisch" if any(i["severity"] == "kritisch" and i["count"] != "0" for i in quality) else ("warnung" if any(i["severity"] == "warnung" and i["count"] != "0" for i in quality) else "ok")
     return {
         "base_currency": "CHF",
         "cash_total_chf": dec_text(cash_total, "0.01"),
         "crypto_total_chf": dec_text(crypto_total, "0.01"),
-        "total_value_chf": dec_text(cash_total + crypto_total, "0.01"),
+        "equity_etf_total_chf": dec_text(equity_total, "0.01"),
+        "total_portfolio_chf": dec_text(cash_total + crypto_total + equity_total, "0.01"),
+        "total_value_chf": dec_text(cash_total + crypto_total + equity_total, "0.01"),
+        "critical_alert_count": str(critical),
         "active_alerts": ", ".join(f"{r['priority']}: {r['c']}" for r in alert_counts) or "0",
-        "data_quality_status": "kritisch" if any(i["severity"] == "kritisch" and i["count"] != "0" for i in quality) else ("warnung" if any(i["severity"] == "warnung" and i["count"] != "0" for i in quality) else "ok"),
-        "crypto_price_data_as_of": max((r["fetched_at"] for r in crypto_rows if r.get("fetched_at")), default=""),
+        "data_quality_status": dq,
+        "last_price_update_at": crypto_summary.get("last_price_update_at", ""),
+        "crypto_price_data_as_of": crypto_summary.get("last_price_update_at", ""),
     }


            "price_status": "ok" if pos.market_price_original is not None else "missing_market_price",
            "provider_symbol_status": "mapped" if conn.execute("SELECT 1 FROM instrument_price_mappings WHERE instrument_id=? AND mapping_status IN ('mapped','confirmed','user_confirmed') AND provider_symbol IS NOT NULL LIMIT 1", (instrument_id,)).fetchone() else "missing_provider_symbol",

            "price_status": "ok" if pos.market_price_original is not None else "missing_market_price",
            "provider_symbol_status": "mapped" if conn.execute("SELECT 1 FROM instrument_price_mappings WHERE instrument_id=? AND mapping_status='mapped' AND provider_symbol IS NOT NULL LIMIT 1", (instrument_id,)).fetchone() else "missing_provider_symbol",


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/data.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/data.py
@@ -394,7 +394,7 @@
             "cost_basis_chf": dec_text(pos.cost_basis_chf, "0.01"),
             "market_value_chf": dec_text(pos.market_value_chf, "0.01") if pos.market_value_chf is not None else "",
             "price_status": "ok" if pos.market_price_original is not None else "missing_market_price",
-            "provider_symbol_status": "mapped" if conn.execute("SELECT 1 FROM instrument_price_mappings WHERE instrument_id=? AND mapping_status='mapped' AND provider_symbol IS NOT NULL LIMIT 1", (instrument_id,)).fetchone() else "missing_provider_symbol",
+            "provider_symbol_status": "mapped" if conn.execute("SELECT 1 FROM instrument_price_mappings WHERE instrument_id=? AND mapping_status IN ('mapped','confirmed','user_confirmed') AND provider_symbol IS NOT NULL LIMIT 1", (instrument_id,)).fetchone() else "missing_provider_symbol",
             "hedge_status": row["hedge_status"] if row else pos.hedge_status,
             "instrument_status": row["instrument_status"] if row else pos.instrument_status,
             "valuation_policy": row["valuation_policy"] if row else pos.valuation_policy,