1|from __future__ import annotations
     2|
     3|import html
     4|import json
     5|import uuid
     6|from datetime import datetime, timezone
     7|from decimal import Decimal, ROUND_HALF_UP
     8|from pathlib import Path
     9|from sqlite3 import Connection
    10|from typing import Any, Callable
    11|
    12|from jarvis_finance.audit.log import record_audit_event
    13|from jarvis_finance.config.settings import Settings, ensure_runtime_dirs, validate_settings
    14|from jarvis_finance.crypto.holdings import calculate_crypto_holdings
    15|from jarvis_finance.imports.common import utc_now
    16|
    17|PDFRenderer = Callable[[str, Path], bool]
    18|
    19|
    20|def _d(value: object, default: str = "0") -> Decimal:
    21|    if value is None or str(value).strip() == "":
    22|        return Decimal(default)
    23|    return Decimal(str(value))
    24|
    25|
    26|def _dec_text(value: Decimal | None, places: str | None = None) -> str | None:
    27|    if value is None:
    28|        return None
    29|    if places is not None:
    30|        value = value.quantize(Decimal(places), rounding=ROUND_HALF_UP)
    31|    return format(value, "f")
    32|
    33|
    34|def _latest_crypto_price(conn: Connection, asset_id: str, currency: str) -> Any | None:
    35|    return conn.execute(
    36|        """
    37|        SELECT * FROM crypto_prices
    38|        WHERE asset_id=? AND price_currency=?
    39|        ORDER BY COALESCE(provider_timestamp, fetched_at, '') DESC, fetched_at DESC
    40|        LIMIT 1
    41|        """,
    42|        (asset_id, currency),
    43|    ).fetchone()
    44|
    45|
    46|def _parse_dt(value: str | None) -> datetime | None:
    47|    if not value:
    48|        return None
    49|    try:
    50|        return datetime.fromisoformat(value.replace("Z", "+00:00"))
    51|    except ValueError:
    52|        return None
    53|
    54|
    55|def _is_stale(value: str | None, *, max_age_days: int = 3) -> bool:
    56|    dt = _parse_dt(value)
    57|    if dt is None:
    58|        return False
    59|    if dt.tzinfo is None:
    60|        dt = dt.replace(tzinfo=timezone.utc)
    61|    return datetime.now(timezone.utc) - dt > __import__("datetime").timedelta(days=max_age_days)
    62|
    63|
    64|def _add_warning(warnings: list[dict[str, str]], *, code: str, message: str, entity_type: str, entity_id: str | None = None) -> None:
    65|    item = {"code": code, "message": message, "entity_type": entity_type}
    66|    if entity_id:
    67|        item["entity_id"] = entity_id
    68|    if item not in warnings:
    69|        warnings.append(item)
    70|
    71|
    72|def build_crypto_report_context(conn: Connection, *, base_currency: str = "CHF") -> dict[str, Any]:
    73|    """Build a renderer-neutral crypto inventory report context from local SQLite data only."""
    74|    holdings = calculate_crypto_holdings(conn)
    75|    warnings: list[dict[str, str]] = []
    76|    generated_at = utc_now()
    77|
    78|    wallet_rows = {row["wallet_id"]: row for row in conn.execute("SELECT * FROM crypto_wallets").fetchall()}
    79|    asset_rows = {row["asset_id"]: row for row in conn.execute("SELECT * FROM crypto_assets").fetchall()}
    80|
    81|    coin_rows: list[dict[str, Any]] = []
    82|    total_value = Decimal("0")
    83|    valued_asset_ids: set[str] = set()
    84|    latest_price_timestamp: str | None = None
    85|    latest_wallet_verification: str | None = None
    86|
    87|    for asset_id, total_holding in sorted(holdings.total_by_asset.items()):
    88|        asset = asset_rows.get(asset_id)
    89|        if asset is None:
    90|            continue
    91|        quantity = total_holding.quantity
    92|        if quantity < 0:
    93|            _add_warning(warnings, code="negative_holding", message="Negativer Crypto-Bestand erkannt.", entity_type="crypto_asset", entity_id=asset_id)
    94|        if not asset["coingecko_id"]:
    95|            _add_warning(warnings, code="missing_coingecko_id", message="CoinGecko-ID fehlt; Bewertung ist eingeschränkt.", entity_type="crypto_asset", entity_id=asset_id)
    96|        price = _latest_crypto_price(conn, asset_id, base_currency)
    97|        price_dec: Decimal | None = None
    98|        price_timestamp: str | None = None
    99|        provider: str | None = None
   100|        if price is None or price["price"] is None:
   101|            _add_warning(warnings, code="missing_price", message="Lokaler Crypto-Preis fehlt.", entity_type="crypto_asset", entity_id=asset_id)
   102|        else:
   103|            price_dec = _d(price["price"])
   104|            price_timestamp = price["provider_timestamp"] or price["fetched_at"]
   105|            provider = price["provider"]
   106|            if _is_stale(price_timestamp):
   107|                _add_warning(warnings, code="stale_price", message="Lokaler Crypto-Preis ist veraltet.", entity_type="crypto_asset", entity_id=asset_id)
   108|            latest_price_timestamp = max(filter(None, [latest_price_timestamp, price_timestamp])) if latest_price_timestamp else price_timestamp
   109|        value = quantity * price_dec if price_dec is not None else None
   110|        if value is not None:
   111|            total_value += value
   112|            valued_asset_ids.add(asset_id)
   113|
   114|        has_legacy = any(
   115|            h.asset_id == asset_id and (h.legacy_snapshot_value_chf is not None or h.legacy_snapshot_value_original is not None)
   116|            for h in holdings.wallet_holdings.values()
   117|        )
   118|        if has_legacy:
   119|            _add_warning(warnings, code="legacy_snapshot_value_ignored", message="Legacy Snapshot Value vorhanden, aber für aktuelle Bewertung ignoriert.", entity_type="crypto_asset", entity_id=asset_id)
   120|
   121|        coin_rows.append(
   122|            {
   123|                "asset_id": asset_id,
   124|                "coin_name": asset["coin_name"],
   125|                "symbol": asset["symbol"],
   126|                "coingecko_id": asset["coingecko_id"],
   127|                "total_quantity": _dec_text(quantity),
   128|                "price_chf": _dec_text(price_dec),
   129|                "value_chf": _dec_text(value, "0.0001"),
   130|                "portfolio_share_pct": None,
   131|                "price_source": provider,
   132|                "price_timestamp": price_timestamp,
   133|                "warning": ", ".join(w["code"] for w in warnings if w.get("entity_id") == asset_id) or None,
   134|            }
   135|        )
   136|
   137|    for row in coin_rows:
   138|        if row["asset_id"] in valued_asset_ids and total_value != 0:
   139|            share = (_d(row["value_chf"]) / total_value * Decimal("100"))
   140|            row["portfolio_share_pct"] = _dec_text(share, "0.01")
   141|
   142|    wallet_agg: dict[str, dict[str, Any]] = {}
   143|    detail_rows: list[dict[str, Any]] = []
   144|    for (wallet_id, asset_id), holding in sorted(holdings.wallet_holdings.items()):
   145|        wallet = wallet_rows.get(wallet_id)
   146|        asset = asset_rows.get(asset_id)
   147|        if wallet is None or asset is None:
   148|            continue
   149|        if wallet["last_verified_at"]:
   150|            latest_wallet_verification = max(filter(None, [latest_wallet_verification, wallet["last_verified_at"]])) if latest_wallet_verification else wallet["last_verified_at"]
   151|        if wallet["last_verified_at"] is None or holding.verification_status != "verified":
   152|            _add_warning(warnings, code="unverified_wallet", message="Wallet ist nicht verifiziert oder Holding-Status ist nicht verified.", entity_type="crypto_wallet", entity_id=wallet_id)
   153|        if holding.quantity < 0:
   154|            _add_warning(warnings, code="negative_holding", message="Negativer Wallet-Bestand erkannt.", entity_type="crypto_wallet", entity_id=wallet_id)
   155|        price = _latest_crypto_price(conn, asset_id, base_currency)
   156|        price_dec = _d(price["price"]) if price and price["price"] is not None else None
   157|        value = holding.quantity * price_dec if price_dec is not None else None
   158|        agg = wallet_agg.setdefault(
   159|            wallet_id,
   160|            {
   161|                "wallet_id": wallet_id,
   162|                "wallet_name": wallet["wallet_name"],
   163|                "wallet_type": wallet["wallet_type"],
   164|                "provider": wallet["platform_provider"],
   165|                "coin_ids": set(),
   166|                "total_value_chf_dec": Decimal("0"),
   167|                "last_verified_at": wallet["last_verified_at"],
   168|                "is_active": bool(wallet["is_active"]),
   169|            },
   170|        )
   171|        agg["coin_ids"].add(asset_id)
   172|        if value is not None:
   173|            agg["total_value_chf_dec"] += value
   174|        detail_rows.append(
   175|            {
   176|                "wallet_id": wallet_id,
   177|                "wallet_name": wallet["wallet_name"],
   178|                "asset_id": asset_id,
   179|                "coin": asset["coin_name"],
   180|                "symbol": asset["symbol"],
   181|                "quantity": _dec_text(holding.quantity),
   182|                "price_chf": _dec_text(price_dec),
   183|                "value_chf": _dec_text(value, "0.0001"),
   184|                "last_verified_at": wallet["last_verified_at"] or holding.last_verified_at,
   185|                "verification_status": holding.verification_status,
   186|                "notes": None,
   187|            }
   188|        )
   189|
   190|    wallet_summary = []
   191|    for row in wallet_agg.values():
   192|        wallet_summary.append(
   193|            {
   194|                "wallet_id": row["wallet_id"],
   195|                "wallet_name": row["wallet_name"],
   196|                "wallet_type": row["wallet_type"],
   197|                "provider": row["provider"],
   198|                "coin_count": len(row["coin_ids"]),
   199|                "total_value_chf": _dec_text(row["total_value_chf_dec"]),
   200|                "last_verified_at": row["last_verified_at"],
   201|                "is_active": row["is_active"],
   202|            }
   203|        )
   204|
   205|    status = "ok"
   206|    if any(w["code"] in {"negative_holding", "missing_price", "missing_coingecko_id"} for w in warnings):
   207|        status = "warning"
   208|    if any(w["code"] == "negative_holding" for w in warnings):
   209|        status = "critical"
   210|
   211|    return {
   212|        "title": "JARVIS Finance System – Crypto-Bestandsübersicht",
   213|        "generated_at": generated_at,
   214|        "base_currency": base_currency,
   215|        "price_data_as_of": latest_price_timestamp,
   216|        "wallet_verification_as_of": latest_wallet_verification,
   217|        "disclaimer": "Bestandsübersicht, keine vollständige Steuererklärung.",
   218|        "summary": {
   219|            "total_value_chf": _dec_text(total_value, "0.0001"),
   220|            "coin_count": len(coin_rows),
   221|            "wallet_count": len(wallet_summary),
   222|            "data_quality_status": status,
   223|        },
   224|        "coins": coin_rows,
   225|        "wallets": sorted(wallet_summary, key=lambda r: r["wallet_name"]),
   226|        "coin_wallet_details": detail_rows,
   227|        "data_quality_warnings": warnings,
   228|        "metadata": {
   229|            "report_type": "crypto_inventory",
   230|            "source": "local_sqlite",
   231|            "live_api_calls": False,
   232|            "currencies_prepared": ["CHF", "USD", "EUR"],
   233|        },
   234|    }
   235|
   236|
   237|def render_crypto_report_markdown(context: dict[str, Any]) -> str:
   238|    lines = [
   239|        f"# {context['title']}",
   240|        "",
   241|        f"Erstellt: {context['generated_at']}",
   242|        f"Basiswährung: {context['base_currency']}",
   243|        f"Datenstand Preise: {context.get('price_data_as_of') or 'nicht verfügbar'}",
   244|        f"Datenstand Wallet-Verifikation: {context.get('wallet_verification_as_of') or 'nicht verfügbar'}",
   245|        "",
   246|        f"Disclaimer: {context['disclaimer']}",
   247|        "",
   248|        "## Executive Summary",
   249|        f"- Gesamtwert Crypto CHF: {context['summary']['total_value_chf']} CHF",
   250|        f"- Anzahl Coins: {context['summary']['coin_count']}",
   251|        f"- Anzahl Wallets/Plattformen: {context['summary']['wallet_count']}",
   252|        f"- Datenqualitätsstatus: {context['summary']['data_quality_status']}",
   253|        "",
   254|        "## Coin-Übersicht",
   255|    ]
   256|    for row in context["coins"]:
   257|        lines.append(f"- {row['coin_name']} ({row['symbol']}): {row['total_quantity']} @ {row['price_chf'] or 'n/a'} CHF = {row['value_chf'] or 'n/a'} CHF; Anteil {row['portfolio_share_pct'] or 'n/a'}%; Quelle {row['price_source'] or 'n/a'}; Preiszeit {row['price_timestamp'] or 'n/a'}; Warnung {row['warning'] or '-'}")
   258|    lines.extend(["", "## Wallet-Übersicht"])
   259|    for row in context["wallets"]:
   260|        lines.append(f"- {row['wallet_name']} ({row['wallet_type']}, {row['provider'] or 'n/a'}): {row['coin_count']} Coins, {row['total_value_chf']} CHF, letzte Verifikation {row['last_verified_at'] or 'n/a'}")
   261|    lines.extend(["", "## Detail pro Coin nach Wallet"])
   262|    for row in context["coin_wallet_details"]:
   263|        lines.append(f"- {row['wallet_name']} / {row['symbol']}: Menge {row['quantity']}, Kurs {row['price_chf'] or 'n/a'}, Wert {row['value_chf'] or 'n/a'} CHF, Verifikation {row['last_verified_at'] or 'n/a'}, Notizen {row['notes'] or '-'}")
   264|    lines.extend(["", "## Datenqualitätswarnungen"])
   265|    if context["data_quality_warnings"]:
   266|        for warning in context["data_quality_warnings"]:
   267|            lines.append(f"- {warning['code']}: {warning['message']}")
   268|    else:
   269|        lines.append("- Keine Warnungen.")
   270|    lines.append("")
   271|    return "\n".join(lines)
   272|
   273|
   274|def render_crypto_report_html(context: dict[str, Any]) -> str:
   275|    markdown = render_crypto_report_markdown(context)
   276|    body = "\n".join(f"
{html.escape(line)}
" if line else "" for line in markdown.splitlines())
   277|    return "<!doctype html><meta charset='utf-8'><title>Crypto-Bestandsübersicht</title>" + body + ""
   278|
   279|
   280|def _default_pdf_renderer(html_text: str, output_path: Path) -> bool:
   281|    try:
   282|        from weasyprint import HTML  # type: ignore
   283|    except Exception:
   284|        return False
   285|    HTML(string=html_text).write_pdf(str(output_path))
   286|    return True
   287|
   288|
   289|def export_crypto_inventory_report(
   290|    conn: Connection,
   291|    *,
   292|    settings: Settings,
   293|    preferred_format: str = "pdf",
   294|    renderer: PDFRenderer | None = _default_pdf_renderer,
   295|) -> dict[str, Any]:
   296|    validate_settings(settings)
   297|    ensure_runtime_dirs(settings)
   298|    reports_dir = settings.runtime_paths.reports_dir.resolve()
   299|    report_id = str(uuid.uuid4())
   300|    context = build_crypto_report_context(conn, base_currency=settings.base_currency)
   301|    stem = f"crypto_inventory_{context['generated_at'].replace(':', '').replace('+', '_')}_{report_id[:8]}"
   302|    warnings: list[str] = []
   303|    html_text = render_crypto_report_html(context)
   304|    markdown_text = render_crypto_report_markdown(context)
   305|
   306|    fmt = preferred_format.lower()
   307|    if fmt == "pdf" and renderer is not None:
   308|        pdf_path = reports_dir / f"{stem}.pdf"
   309|        if renderer(html_text, pdf_path):
   310|            output_path = pdf_path
   311|            final_format = "pdf"
   312|        else:
   313|            warnings.append("PDF renderer unavailable; wrote HTML fallback.")
   314|            output_path = reports_dir / f"{stem}.html"
   315|            output_path.write_text(html_text, encoding="utf-8")
   316|            final_format = "html"
   317|    elif fmt == "markdown":
   318|        output_path = reports_dir / f"{stem}.md"
   319|        output_path.write_text(markdown_text, encoding="utf-8")
   320|        final_format = "markdown"
   321|

Use offset=321 to continue reading (showing 1-320 of 358 lines)


     1|from __future__ import annotations
     2|
     3|import argparse
     4|from pathlib import Path
     5|
     6|from jarvis_finance.config.settings import ensure_runtime_dirs, load_settings
     7|from jarvis_finance.quality.git_safety import assert_safe, scan_path
     8|from jarvis_finance.storage.database import connect
     9|from jarvis_finance.storage.migrations import apply_migrations, get_schema_version
    10|
    11|
    12|def main(argv: list[str] | None = None) -> int:
    13|    parser = argparse.ArgumentParser(prog="finance")
    14|    sub = parser.add_subparsers(dest="command", required=True)
    15|    sub.add_parser("init-db")
    16|    sub.add_parser("migrate")
    17|    scan = sub.add_parser("git-safety-scan")
    18|    scan.add_argument("path", nargs="?", default=".")
    19|    args = parser.parse_args(argv)
    20|
    21|    if args.command in {"init-db", "migrate"}:
    22|        settings = load_settings()
    23|        ensure_runtime_dirs(settings)
    24|        conn = connect(settings.db_path)
    25|        apply_migrations(conn)
    26|        print(f"SCHEMA_VERSION={get_schema_version(conn)}")
    27|        return 0
    28|    if args.command == "git-safety-scan":
    29|        findings = scan_path(Path(args.path))
    30|        if findings:
    31|            for finding in findings:
    32|                print(f"{finding.path}: {finding.reason}")
    33|            return 1
    34|        assert_safe(Path(args.path))
    35|        print("GIT_SAFETY_OK")
    36|        return 0
    37|    return 2
    38|
    39|
    40|if __name__ == "__main__":
    41|    raise SystemExit(main())
    42|


     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 = "Crypto"
     7|
     8|def render(st, conn) -> None:
     9|    st.title("Crypto")
    10|    st.caption("Preise werden nur aus der lokalen DB/Cache gelesen; kein CoinGecko-Aufruf beim Rendern.")
    11|    st.subheader("Coins aggregiert")
    12|    show_table(st, data.get_crypto_overview(conn))
    13|    st.subheader("Coins nach Wallet")
    14|    show_table(st, data.get_crypto_by_wallet(conn))
    15|    st.subheader("Wallet-Gesamtübersicht")
    16|    show_table(st, data.get_wallets(conn))
    17|    st.subheader("Fehlende/veraltete Preis- und CoinGecko-Hinweise")
    18|    quality_rows = [r for r in data.get_crypto_overview(conn) if not r["coingecko_id"] or r["price_quality"] != "fresh"]
    19|    show_table(st, quality_rows)
    20|    st.subheader("Letzte Crypto-Transaktionen")
    21|    show_table(st, data.get_latest_crypto_transactions(conn))
    22|


     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.subheader("Cash-Übersicht")
    17|    show_table(st, data.get_cash_overview(conn))
    18|    st.subheader("Offene Alerts")
    19|    show_table(st, [r for r in data.get_alerts(conn) if r["status"] == "active"][:10])
    20|    st.subheader("Letzte Audit-Ereignisse")
    21|    show_table(st, data.get_audit_events(conn, limit=10))
    22|


     1|from __future__ import annotations
     2|
     3|from dataclasses import dataclass, field
     4|from decimal import Decimal
     5|from sqlite3 import Connection
     6|
     7|from jarvis_finance.audit.log import record_audit_event
     8|from jarvis_finance.imports.common import stable_id, utc_now
     9|from jarvis_finance.quality.alerts import create_alert
    10|
    11|VALID_VERIFICATION_STATUS = {"verified", "stale", "unverified", "estimated"}
    12|
    13|
    14|@dataclass
    15|class CryptoHolding:
    16|    wallet_id: str | None
    17|    asset_id: str
    18|    quantity: Decimal = Decimal("0")
    19|    verification_status: str = "unverified"
    20|    last_verified_at: str | None = None
    21|    legacy_snapshot_value_original: Decimal | None = None
    22|    legacy_snapshot_value_chf: Decimal | None = None
    23|    current_value_chf: Decimal | None = None
    24|    quality_warnings: list[str] = field(default_factory=list)
    25|
    26|
    27|@dataclass
    28|class CryptoHoldingsResult:
    29|    wallet_holdings: dict[tuple[str, str], CryptoHolding] = field(default_factory=dict)
    30|    total_by_asset: dict[str, CryptoHolding] = field(default_factory=dict)
    31|
    32|
    33|def _d(value: object, default: str = "0") -> Decimal:
    34|    if value is None or str(value).strip() == "":
    35|        return Decimal(default)
    36|    return Decimal(str(value))
    37|
    38|
    39|def _ds(value: Decimal | None) -> str | None:
    40|    return format(value, "f") if value is not None else None
    41|
    42|
    43|def _ensure_holding(result: CryptoHoldingsResult, wallet_id: str, asset_id: str) -> CryptoHolding:
    44|    return result.wallet_holdings.setdefault((wallet_id, asset_id), CryptoHolding(wallet_id=wallet_id, asset_id=asset_id))
    45|
    46|
    47|def _ensure_total(result: CryptoHoldingsResult, asset_id: str) -> CryptoHolding:
    48|    return result.total_by_asset.setdefault(asset_id, CryptoHolding(wallet_id=None, asset_id=asset_id))
    49|
    50|
    51|def create_initial_holding_snapshot(
    52|    conn: Connection,
    53|    *,
    54|    asset_id: str,
    55|    wallet_id: str,
    56|    quantity: Decimal,
    57|    verification_status: str,
    58|    note: str,
    59|    last_verified_at: str | None = None,
    60|    legacy_snapshot_value_original: Decimal | None = None,
    61|    legacy_snapshot_value_chf: Decimal | None = None,
    62|    legacy_snapshot_currency: str | None = None,
    63|    legacy_snapshot_date: str | None = None,
    64|) -> str:
    65|    if quantity < 0:
    66|        raise ValueError("initial holding quantity must be >= 0")
    67|    if verification_status not in VALID_VERIFICATION_STATUS:
    68|        raise ValueError("invalid verification_status")
    69|    if not note.strip():
    70|        raise ValueError("initial holding snapshot requires a note")
    71|    now = utc_now()
    72|    holding_id = stable_id("cryptoholding", asset_id, wallet_id)
    73|    conn.execute(
    74|        """
    75|        INSERT OR REPLACE INTO crypto_holdings(
    76|            crypto_holding_id, asset_id, wallet_id, quantity, acquisition_source,
    77|            last_verified_at, verification_status, legacy_snapshot_value_original,
    78|            legacy_snapshot_value_chf, legacy_snapshot_currency, legacy_snapshot_date,
    79|            notes, created_at, updated_at
    80|        ) VALUES (?, ?, ?, ?, 'initial_snapshot', ?, ?, ?, ?, ?, ?, ?,
    81|                  COALESCE((SELECT created_at FROM crypto_holdings WHERE crypto_holding_id=?), ?), ?)
    82|        """,
    83|        (
    84|            holding_id, asset_id, wallet_id, _ds(quantity), last_verified_at, verification_status,
    85|            _ds(legacy_snapshot_value_original),
    86|            _ds(legacy_snapshot_value_chf),
    87|            legacy_snapshot_currency, legacy_snapshot_date, note, holding_id, now, now,
    88|        ),
    89|    )
    90|    record_audit_event(
    91|        conn,
    92|        source="crypto_holdings",
    93|        action="initial_crypto_holding_snapshot",
    94|        entity_type="crypto_holding",
    95|        entity_id=holding_id,
    96|        new_values={"asset_id": asset_id, "wallet_id": wallet_id, "quantity": _ds(quantity), "verification_status": verification_status},
    97|        user_text_note=note,
    98|        confirmed=True,
    99|        created_by="system",
   100|    )
   101|    conn.commit()
   102|    return holding_id
   103|
   104|
   105|def _apply_delta(result: CryptoHoldingsResult, wallet_id: str, asset_id: str, delta: Decimal) -> None:
   106|    holding = _ensure_holding(result, wallet_id, asset_id)
   107|    holding.quantity += delta
   108|    total = _ensure_total(result, asset_id)
   109|    total.quantity += delta
   110|
   111|
   112|def calculate_crypto_holdings(conn: Connection) -> CryptoHoldingsResult:
   113|    result = CryptoHoldingsResult()
   114|    for row in conn.execute("SELECT * FROM crypto_holdings ORDER BY wallet_id, asset_id").fetchall():
   115|        holding = _ensure_holding(result, row["wallet_id"], row["asset_id"])
   116|        holding.quantity += _d(row["quantity"])
   117|        holding.verification_status = row["verification_status"]
   118|        holding.last_verified_at = row["last_verified_at"]
   119|        holding.legacy_snapshot_value_original = _d(row["legacy_snapshot_value_original"]) if row["legacy_snapshot_value_original"] is not None else None
   120|        holding.legacy_snapshot_value_chf = _d(row["legacy_snapshot_value_chf"]) if row["legacy_snapshot_value_chf"] is not None else None
   121|        total = _ensure_total(result, row["asset_id"])
   122|        total.quantity += _d(row["quantity"])
   123|    rows = conn.execute("SELECT * FROM crypto_transactions ORDER BY transaction_datetime, created_at, crypto_transaction_id").fetchall()
   124|    for row in rows:
   125|        asset_id = row["asset_id"]
   126|        qty = _d(row["quantity"])
   127|        fee_qty = _d(row["fee_quantity"])
   128|        t = row["transaction_type"]
   129|        if t == "transfer":
   130|            _apply_delta(result, row["from_wallet_id"], asset_id, -(qty + fee_qty))
   131|            _apply_delta(result, row["to_wallet_id"], asset_id, qty)
   132|        elif t == "buy":
   133|            _apply_delta(result, row["to_wallet_id"], asset_id, qty - fee_qty)
   134|        elif t == "sell":
   135|            _apply_delta(result, row["from_wallet_id"], asset_id, -(qty + fee_qty))
   136|        elif t == "fee":
   137|            _apply_delta(result, row["from_wallet_id"], asset_id, -fee_qty)
   138|        elif t == "manual_adjustment":
   139|            if row["to_wallet_id"]:
   140|                _apply_delta(result, row["to_wallet_id"], asset_id, qty)
   141|            elif row["from_wallet_id"]:
   142|                _apply_delta(result, row["from_wallet_id"], asset_id, -qty)
   143|    for asset in conn.execute("SELECT asset_id, symbol, coingecko_id FROM crypto_assets").fetchall():
   144|        if not asset["coingecko_id"]:
   145|            msg = "Crypto asset has no CoinGecko ID; current valuation is unavailable."
   146|            for holding in [h for h in result.wallet_holdings.values() if h.asset_id == asset["asset_id"]]:
   147|                holding.quality_warnings.append("missing_coingecko_id")
   148|            create_alert(
   149|                conn,
   150|                priority="warnung",
   151|                category="crypto",
   152|                entity_type="crypto_asset",
   153|                entity_id=asset["asset_id"],
   154|                rule_id="missing_coingecko_id",
   155|                message=msg,
   156|                evidence={"symbol": asset["symbol"]},
   157|                fingerprint="missing_coingecko_id",
   158|            )
   159|    conn.commit()
   160|    return result
   161|


     1|from __future__ import annotations
     2|
     3|from decimal import Decimal
     4|from pathlib import Path
     5|
     6|from jarvis_finance.config.paths import RuntimePaths
     7|from jarvis_finance.config.settings import Settings
     8|from jarvis_finance.crypto.assets import create_crypto_asset
     9|from jarvis_finance.crypto.holdings import create_initial_holding_snapshot
    10|from jarvis_finance.crypto.wallets import create_wallet
    11|from jarvis_finance.dashboard.demo_data import load_synthetic_demo_data
    12|from jarvis_finance.reports.crypto_inventory import (
    13|    build_crypto_report_context,
    14|    export_crypto_inventory_report,
    15|)
    16|from jarvis_finance.storage.database import connect_memory
    17|from jarvis_finance.storage.migrations import apply_migrations
    18|
    19|
    20|def setup_demo_conn():
    21|    conn = connect_memory()
    22|    apply_migrations(conn)
    23|    load_synthetic_demo_data(conn)
    24|    return conn
    25|
    26|
    27|def test_crypto_report_context_from_synthetic_db_aggregates_coins_and_wallets() -> None:
    28|    conn = setup_demo_conn()
    29|
    30|    context = build_crypto_report_context(conn)
    31|
    32|    assert context["title"] == "JARVIS Finance System – Crypto-Bestandsübersicht"
    33|    assert context["base_currency"] == "CHF"
    34|    assert context["summary"]["coin_count"] >= 2
    35|    assert context["summary"]["wallet_count"] >= 2
    36|    btc = next(row for row in context["coins"] if row["symbol"] == "DBTC")
    37|    assert btc["total_quantity"] == "0.0999"
    38|    assert btc["price_chf"] == "40000.00"
    39|    assert btc["value_chf"] == "3996.0000"
    40|    assert context["summary"]["total_value_chf"] == "7746.0000"
    41|    kraken = next(row for row in context["wallets"] if row["wallet_name"] == "Kraken Demo")
    42|    assert kraken["coin_count"] == 2
    43|
    44|
    45|def test_crypto_report_context_preserves_decimal_text_and_ignores_legacy_snapshot_values() -> None:
    46|    conn = setup_demo_conn()
    47|    conn.execute(
    48|        "UPDATE crypto_holdings SET legacy_snapshot_value_chf=?, legacy_snapshot_currency=? WHERE quantity=?",
    49|        ("999999999.99", "CHF", "0.10"),
    50|    )
    51|    conn.commit()
    52|
    53|    context = build_crypto_report_context(conn)
    54|
    55|    btc = next(row for row in context["coins"] if row["symbol"] == "DBTC")
    56|    assert btc["total_quantity"] == "0.0999"
    57|    assert btc["value_chf"] == "3996.0000"
    58|    assert context["summary"]["total_value_chf"] != "999999999.99"
    59|    assert any(w["code"] == "legacy_snapshot_value_ignored" for w in context["data_quality_warnings"])
    60|
    61|
    62|def test_crypto_report_context_warns_for_missing_price_and_missing_coingecko_id() -> None:
    63|    conn = setup_demo_conn()
    64|    wallet_id = conn.execute("SELECT wallet_id FROM crypto_wallets LIMIT 1").fetchone()["wallet_id"]
    65|    asset_id = create_crypto_asset(conn, coin_name="Demo Missing", symbol="DMX", coingecko_id=None)
    66|    create_initial_holding_snapshot(
    67|        conn,
    68|        asset_id=asset_id,
    69|        wallet_id=wallet_id,
    70|        quantity=Decimal("12.34567890123456789"),
    71|        verification_status="verified",
    72|        last_verified_at="2026-01-31T12:00:00Z",
    73|        note="synthetic missing price",
    74|    )
    75|
    76|    context = build_crypto_report_context(conn)
    77|
    78|    dmx = next(row for row in context["coins"] if row["symbol"] == "DMX")
    79|    assert dmx["total_quantity"] == "12.34567890123456789"
    80|    codes = {warning["code"] for warning in context["data_quality_warnings"]}
    81|    assert "missing_coingecko_id" in codes
    82|    assert "missing_price" in codes
    83|
    84|
    85|def test_crypto_report_context_warns_for_unverified_wallet_and_negative_holding() -> None:
    86|    conn = setup_demo_conn()
    87|    asset_id = conn.execute("SELECT asset_id FROM crypto_assets WHERE symbol='DETH'").fetchone()["asset_id"]
    88|    wallet_id = create_wallet(
    89|        conn,
    90|        wallet_name="Unverified Demo Wallet",
    91|        wallet_type="Software Wallet",
    92|        platform_provider="Synthetic",
    93|        last_verified_at=None,
    94|    )
    95|    conn.execute(
    96|        """
    97|        INSERT INTO crypto_holdings(
    98|            crypto_holding_id, asset_id, wallet_id, quantity, acquisition_source,
    99|            verification_status, notes, created_at
   100|        ) VALUES ('negative-demo', ?, ?, '-0.25', 'initial_snapshot', 'unverified', 'synthetic negative', '2026-01-31T00:00:00Z')
   101|        """,
   102|        (asset_id, wallet_id),
   103|    )
   104|    conn.commit()
   105|
   106|    context = build_crypto_report_context(conn)
   107|
   108|    codes = {warning["code"] for warning in context["data_quality_warnings"]}
   109|    assert "unverified_wallet" in codes
   110|    assert "negative_holding" in codes
   111|
   112|
   113|def test_crypto_report_export_writes_runtime_report_metadata_and_audit(tmp_path: Path) -> None:
   114|    conn = setup_demo_conn()
   115|    runtime = RuntimePaths.from_base(tmp_path / "runtime")
   116|    settings = Settings(runtime_paths=runtime)
   117|
   118|    result = export_crypto_inventory_report(conn, settings=settings, preferred_format="pdf", renderer=lambda html, path: False)
   119|
   120|    output_path = Path(result["file_path"])
   121|    assert output_path.exists()
   122|    assert output_path.suffix == ".html"
   123|    assert runtime.reports_dir in output_path.parents
   124|    assert not Path(result["file_path"]).resolve().is_relative_to(Path.cwd().resolve())
   125|    report = conn.execute("SELECT * FROM reports WHERE report_type='crypto_inventory'").fetchone()
   126|    assert report is not None
   127|    assert report["format"] == "html"
   128|    assert report["file_path"] == str(output_path)
   129|    audit = conn.execute("SELECT * FROM audit_log WHERE action='report_generated'").fetchone()
   130|    assert audit is not None
   131|
   132|
   133|def test_crypto_report_export_markdown_fallback_when_pdf_renderer_unavailable(tmp_path: Path) -> None:
   134|    conn = setup_demo_conn()
   135|    runtime = RuntimePaths.from_base(tmp_path / "runtime")
   136|    settings = Settings(runtime_paths=runtime)
   137|
   138|    result = export_crypto_inventory_report(conn, settings=settings, preferred_format="pdf", renderer=None)
   139|
   140|    output_path = Path(result["file_path"])
   141|    assert output_path.exists()
   142|    assert output_path.suffix in {".html", ".md"}
   143|    assert result["format"] in {"html", "markdown"}
   144|    assert result["warnings"]
   145|    assert all(not p.is_relative_to(Path.cwd().resolve()) for p in runtime.reports_dir.glob("*"))
   146|


     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|
    13|MONEY = Decimal("0.01")
    14|
    15|
    16|def d(value: object, default: str = "0") -> Decimal:
    17|    if value is None or str(value).strip() == "":
    18|        return Decimal(default)
    19|    try:
    20|        return Decimal(str(value))
    21|    except InvalidOperation:
    22|        return Decimal(default)
    23|
    24|
    25|def dec_text(value: Decimal | int | str | None, places: str | None = None) -> str:
    26|    if value is None:
    27|        return ""
    28|    value = d(value)
    29|    if places:
    30|        value = value.quantize(Decimal(places), rounding=ROUND_HALF_UP)
    31|    return format(value, "f")
    32|
    33|
    34|def money_text(value: Decimal | None, currency: str = "CHF") -> str:
    35|    if value is None:
    36|        return f"n/a {currency}"
    37|    return f"{dec_text(value, '0.01')} {currency}"
    38|
    39|
    40|def rowdicts(rows) -> list[dict[str, str]]:
    41|    return [{k: ("" if row[k] is None else str(row[k])) for k in row.keys()} for row in rows]
    42|
    43|
    44|def db_exists(path: str | Path) -> bool:
    45|    return Path(path).expanduser().exists()
    46|
    47|
    48|def runtime_outside_repo(settings: Settings, repo_root: Path) -> bool:
    49|    try:
    50|        settings.runtime_paths.base_dir.resolve().relative_to(repo_root.resolve())
    51|        return False
    52|    except ValueError:
    53|        return True
    54|
    55|
    56|def latest_crypto_price(conn: Connection, asset_id: str, currency: str = "CHF") -> dict[str, str] | None:
    57|    row = conn.execute(
    58|        """
    59|        SELECT price, price_currency, fetched_at, quality_status, provider
    60|        FROM crypto_prices
    61|        WHERE asset_id=? AND price_currency=?
    62|        ORDER BY fetched_at DESC LIMIT 1
    63|        """,
    64|        (asset_id, currency.upper()),
    65|    ).fetchone()
    66|    return dict(row) if row else None
    67|
    68|
    69|def get_crypto_overview(conn: Connection) -> list[dict[str, str]]:
    70|    holdings = calculate_crypto_holdings(conn)
    71|    rows: list[dict[str, str]] = []
    72|    for asset_id, holding in sorted(holdings.total_by_asset.items()):
    73|        qty = holding.quantity
    74|        asset = conn.execute("SELECT * FROM crypto_assets WHERE asset_id=?", (asset_id,)).fetchone()
    75|        if not asset:
    76|            continue
    77|        price = latest_crypto_price(conn, asset_id, "CHF")
    78|        price_dec = d(price["price"]) if price and price["price"] else None
    79|        value = qty * price_dec if price_dec is not None else None
    80|        rows.append({
    81|            "asset_id": asset_id,
    82|            "coin_name": asset["coin_name"],
    83|            "symbol": asset["symbol"],
    84|            "coingecko_id": asset["coingecko_id"] or "",
    85|            "quantity": dec_text(qty),
    86|            "latest_price_chf": dec_text(price_dec, "0.01") if price_dec is not None else "",
    87|            "market_value_chf": dec_text(value, "0.01") if value is not None else "",
    88|            "price_quality": price["quality_status"] if price else "missing",
    89|            "fetched_at": price["fetched_at"] if price else "",
    90|        })
    91|    return rows
    92|
    93|
    94|def get_crypto_by_wallet(conn: Connection) -> list[dict[str, str]]:
    95|    holdings = calculate_crypto_holdings(conn)
    96|    rows: list[dict[str, str]] = []
    97|    for (wallet_id, asset_id), holding in sorted(holdings.wallet_holdings.items()):
    98|        wallet = conn.execute("SELECT wallet_name, wallet_type, platform_provider FROM crypto_wallets WHERE wallet_id=?", (wallet_id,)).fetchone()
    99|        asset = conn.execute("SELECT coin_name, symbol, coingecko_id FROM crypto_assets WHERE asset_id=?", (asset_id,)).fetchone()
   100|        price = latest_crypto_price(conn, asset_id, "CHF")
   101|        price_dec = d(price["price"]) if price and price["price"] else None
   102|        value = holding.quantity * price_dec if price_dec is not None else None
   103|        rows.append({
   104|            "wallet_id": wallet_id,
   105|            "wallet_name": wallet["wallet_name"] if wallet else wallet_id,
   106|            "wallet_type": wallet["wallet_type"] if wallet else "",
   107|            "provider": wallet["platform_provider"] if wallet else "",
   108|            "coin_name": asset["coin_name"] if asset else asset_id,
   109|            "symbol": asset["symbol"] if asset else "",
   110|            "coingecko_id": asset["coingecko_id"] if asset else "",
   111|            "quantity": dec_text(holding.quantity),
   112|            "market_value_chf": dec_text(value, "0.01") if value is not None else "",
   113|            "verification_status": holding.verification_status,
   114|            "last_verified_at": holding.last_verified_at or "",
   115|        })
   116|    return rows
   117|
   118|
   119|def get_wallets(conn: Connection) -> list[dict[str, str]]:
   120|    return rowdicts(conn.execute(
   121|        """
   122|        SELECT wallet_id, wallet_name, wallet_type, platform_provider AS provider,
   123|               network_chain, last_verified_at, is_active, notes
   124|        FROM crypto_wallets ORDER BY wallet_name
   125|        """
   126|    ).fetchall())
   127|
   128|
   129|def get_latest_crypto_transactions(conn: Connection, limit: int = 20) -> list[dict[str, str]]:
   130|    return rowdicts(conn.execute(
   131|        """
   132|        SELECT ct.transaction_datetime, ct.transaction_type, ca.symbol, ct.quantity, ct.fee_quantity,
   133|               fw.wallet_name AS from_wallet, tw.wallet_name AS to_wallet, ct.currency_original,
   134|               ct.amount_chf, ct.confirmation_status, ct.source
   135|        FROM crypto_transactions ct
   136|        LEFT JOIN crypto_assets ca ON ca.asset_id=ct.asset_id
   137|        LEFT JOIN crypto_wallets fw ON fw.wallet_id=ct.from_wallet_id
   138|        LEFT JOIN crypto_wallets tw ON tw.wallet_id=ct.to_wallet_id
   139|        ORDER BY ct.transaction_datetime DESC, ct.created_at DESC LIMIT ?
   140|        """, (limit,)
   141|    ).fetchall())
   142|
   143|
   144|def get_platform_overview(conn: Connection) -> list[dict[str, str]]:
   145|    return rowdicts(conn.execute(
   146|        """
   147|        SELECT p.name AS platform, p.platform_type, a.account_name, a.account_type, a.currency,
   148|               a.performance_included, a.is_active
   149|        FROM accounts a JOIN platforms p ON p.platform_id=a.platform_id
   150|        ORDER BY p.name, a.account_name
   151|        """
   152|    ).fetchall())
   153|
   154|
   155|def get_cash_overview(conn: Connection) -> list[dict[str, str]]:
   156|    result = calculate_cash_balances(conn)
   157|    rows: list[dict[str, str]] = []
   158|    for (account_id, currency), bal in sorted(result.balances.items()):
   159|        row = conn.execute(
   160|            "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=?",
   161|            (account_id,),
   162|        ).fetchone()
   163|        rows.append({
   164|            "platform": row["platform"] if row else "",
   165|            "account_name": row["account_name"] if row else account_id,
   166|            "currency": currency,
   167|            "amount_original": dec_text(bal.amount_original, "0.01"),
   168|            "amount_chf": dec_text(bal.amount_chf, "0.01") if bal.amount_chf is not None else "",
   169|            "quality_status": bal.quality_status,
   170|        })
   171|    return rows
   172|
   173|
   174|def get_positions(conn: Connection) -> list[dict[str, str]]:
   175|    result = calculate_positions(conn)
   176|    rows: list[dict[str, str]] = []
   177|    for (account_id, instrument_id), pos in sorted(result.positions.items()):
   178|        row = conn.execute(
   179|            """
   180|            SELECT a.account_name, p.name AS platform, i.name, i.ticker, i.isin, i.asset_class, i.currency
   181|            FROM accounts a JOIN platforms p ON p.platform_id=a.platform_id
   182|            JOIN instruments i ON i.instrument_id=? WHERE a.account_id=?
   183|            """,
   184|            (instrument_id, account_id),
   185|        ).fetchone()
   186|        rows.append({
   187|            "platform": row["platform"] if row else "",
   188|            "account_name": row["account_name"] if row else account_id,
   189|            "asset_class": row["asset_class"] if row else "",
   190|            "name": row["name"] if row else instrument_id,
   191|            "ticker": row["ticker"] if row else "",
   192|            "isin": row["isin"] if row else "",
   193|            "quantity": dec_text(pos.quantity),
   194|            "cost_basis_chf": dec_text(pos.cost_basis_chf, "0.01"),
   195|            "market_value_chf": dec_text(pos.market_value_chf, "0.01") if pos.market_value_chf is not None else "",
   196|            "total_return_chf": dec_text(pos.total_return_chf, "0.01") if pos.total_return_chf is not None else "",
   197|            "data_quality_status": pos.data_quality_status,
   198|        })
   199|    return rows
   200|
   201|
   202|def get_ledger_transactions(conn: Connection, limit: int = 200) -> list[dict[str, str]]:
   203|    return rowdicts(conn.execute(
   204|        """
   205|        SELECT t.trade_date, p.name AS platform, a.account_name, t.transaction_type,
   206|               i.name AS instrument, i.ticker, t.quantity, t.gross_amount_original,
   207|               t.net_amount_original, t.currency_original, t.fx_status, t.quality_status,
   208|               t.source_type, t.source_id
   209|        FROM transactions t
   210|        LEFT JOIN accounts a ON a.account_id=t.account_id
   211|        LEFT JOIN platforms p ON p.platform_id=a.platform_id
   212|        LEFT JOIN instruments i ON i.instrument_id=t.instrument_id
   213|        ORDER BY t.trade_date DESC, t.created_at DESC LIMIT ?
   214|        """, (limit,)
   215|    ).fetchall())
   216|
   217|
   218|def get_watchlist(conn: Connection) -> list[dict[str, str]]:
   219|    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())
   220|
   221|
   222|def get_alerts(conn: Connection) -> list[dict[str, str]]:
   223|    return rowdicts(conn.execute(
   224|        """
   225|        SELECT priority, status, category, entity_type, entity_id, rule_id, message,
   226|               occurrence_count, last_seen_at, resolved_at, muted_until, fingerprint, dedup_key, created_at
   227|        FROM alerts
   228|        ORDER BY CASE priority WHEN 'kritisch' THEN 0 WHEN 'warnung' THEN 1 ELSE 2 END, status, created_at DESC
   229|        """
   230|    ).fetchall())
   231|
   232|
   233|def get_audit_events(conn: Connection, limit: int = 200) -> list[dict[str, str]]:
   234|    rows = conn.execute(
   235|        """
   236|        SELECT timestamp, source, action, entity_type, entity_id, old_values_json, new_values_json,
   237|               confirmed, created_by, quality_status
   238|        FROM audit_log ORDER BY timestamp DESC LIMIT ?
   239|        """, (limit,)
   240|    ).fetchall()
   241|    out: list[dict[str, str]] = []
   242|    for row in rows:
   243|        item = {k: ("" if row[k] is None else str(row[k])) for k in row.keys()}
   244|        for key in ("old_values_json", "new_values_json"):
   245|            if item[key]:
   246|                try:
   247|                    item[key] = json.dumps(json.loads(item[key]), ensure_ascii=False, indent=2, sort_keys=True)
   248|                except json.JSONDecodeError:
   249|                    pass
   250|        out.append(item)
   251|    return out
   252|
   253|
   254|def get_data_quality(conn: Connection) -> list[dict[str, str]]:
   255|    items: list[dict[str, str]] = []
   256|    for row in conn.execute("SELECT COUNT(*) AS c FROM transactions WHERE fx_status='missing'"):
   257|        items.append({"check": "fehlende FX", "count": str(row["c"]), "severity": "kritisch" if row["c"] else "ok"})
   258|    for row in conn.execute("SELECT COUNT(*) AS c FROM crypto_assets WHERE coingecko_id IS NULL OR coingecko_id='' "):
   259|        items.append({"check": "fehlende CoinGecko-ID", "count": str(row["c"]), "severity": "warnung" if row["c"] else "ok"})
   260|    for row in conn.execute("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"):
   261|        items.append({"check": "fehlende Crypto-Preise", "count": str(row["c"]), "severity": "warnung" if row["c"] else "ok"})
   262|    for row in conn.execute("SELECT COUNT(*) AS c FROM alerts WHERE status='active' AND priority='kritisch'"):
   263|        items.append({"check": "aktive kritische Alerts", "count": str(row["c"]), "severity": "kritisch" if row["c"] else "ok"})
   264|    return items
   265|
   266|
   267|def get_command_center_summary(conn: Connection) -> dict[str, str]:
   268|    cash_rows = get_cash_overview(conn)
   269|    crypto_rows = get_crypto_overview(conn)
   270|    cash_total = sum(d(r["amount_chf"]) for r in cash_rows if r["amount_chf"])
   271|    crypto_total = sum(d(r["market_value_chf"]) for r in crypto_rows if r["market_value_chf"])
   272|    alert_counts = conn.execute("SELECT priority, COUNT(*) AS c FROM alerts WHERE status='active' GROUP BY priority").fetchall()
   273|    quality = get_data_quality(conn)
   274|    return {
   275|        "base_currency": "CHF",
   276|        "cash_total_chf": dec_text(cash_total, "0.01"),
   277|        "crypto_total_chf": dec_text(crypto_total, "0.01"),
   278|        "total_value_chf": dec_text(cash_total + crypto_total, "0.01"),
   279|        "active_alerts": ", ".join(f"{r['priority']}: {r['c']}" for r in alert_counts) or "0",
   280|        "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"),
   281|    }
   282|


Explanation:
**Implementing schema for crypto prices**

I need to focus on implementing a schema for crypto prices. This will likely involve defining the structures and fields necessary to accurately represent the data related to various cryptocurrencies, such as their current prices, historical data, and possibly market trends. I might want to consider which attributes are essential, like the cryptocurrency name, symbol, and price fluctuations. It’s important to organize this clearly for optimal usage, making sure the structure is well-defined.

crypto_prices|CREATE TABLE IF NOT EXISTS crypto_assets|quality_status


terminal

python - <<'PY'
from pathlib import Path
s=Path('/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/storage/schema.py').read_text()
for term in ['crypto_assets','crypto_prices','quality_status']:
 print(term, s.find(term))
 start=max(0,s.find(term)-500); end=s.find(term)+1200
 print(s[start:end].replace('\\n','\n')[:1800])
PY


Output:
crypto_assets 5642
reated_at TEXT NOT NULL,
    UNIQUE(instrument_id, price_date, provider)
);

CREATE TABLE IF NOT EXISTS crypto_wallets (
    wallet_id TEXT PRIMARY KEY,
    wallet_name TEXT NOT NULL UNIQUE,
    wallet_type TEXT NOT NULL,
    platform_provider TEXT,
    network_chain TEXT,
    wallet_address TEXT,
    owner TEXT,
    is_active INTEGER NOT NULL DEFAULT 1,
    last_verified_at TEXT,
    notes TEXT,
    created_at TEXT NOT NULL,
    updated_at TEXT
);

CREATE TABLE IF NOT EXISTS crypto_assets (
    asset_id TEXT PRIMARY KEY,
    coin_name TEXT NOT NULL,
    symbol TEXT NOT NULL,
    coingecko_id TEXT UNIQUE,
    network_chain_default TEXT,
    is_stablecoin INTEGER NOT NULL DEFAULT 0,
    price_provider_primary TEXT NOT NULL DEFAULT 'CoinGecko',
    price_provider_fallback TEXT,
    is_active INTEGER NOT NULL DEFAULT 1,
    notes TEXT,
    created_at TEXT NOT NULL,
    updated_at TEXT
);

CREATE TABLE IF NOT EXISTS crypto_holdings (
    crypto_holding_id TEXT PRIMARY KEY,
    asset_id TEXT NOT NULL REFERENCES crypto_assets(asset_id),
    wallet_id TEXT NOT NULL REFERENCES crypto_wallets(wallet_id),
    quantity TEXT NOT NULL,
    acquisition_source TEXT,
    last_verified_at TEXT,
    verification_status TEXT NOT NULL DEFAULT 'unverified',
    legacy_snapshot_value_original TEXT,
    legacy_snapshot_value_chf TEXT,
    legacy_snapshot_currency TEXT,
    legacy_snapshot_date TEXT,
    notes TEXT,
    created_at TEXT NOT NULL,
    updated_at TEXT,
    UNIQUE(asset_id, wallet_id)
);

CREATE TABLE IF NOT EXISTS crypto_transactions (
    crypto_transaction_id TEXT PRIMARY KEY,
    transaction_id TEXT REFERENCES transactions(tr
crypto_prices 7652
fx_rate_to_chf TEXT,
    fx_source TEXT,
    amount_chf TEXT,
    from_wallet_id TEXT REFERENCES crypto_wallets(wallet_id),
    to_wallet_id TEXT REFERENCES crypto_wallets(wallet_id),
    transaction_datetime TEXT NOT NULL,
    tx_hash TEXT,
    source TEXT NOT NULL,
    confirmation_status TEXT NOT NULL DEFAULT 'confirmed',
    parse_confidence NUMERIC,
    original_input_text TEXT,
    notes TEXT,
    created_at TEXT NOT NULL,
    updated_at TEXT
);

CREATE TABLE IF NOT EXISTS crypto_prices (
    crypto_price_id TEXT PRIMARY KEY,
    asset_id TEXT NOT NULL REFERENCES crypto_assets(asset_id),
    coingecko_id TEXT,
    price_currency TEXT NOT NULL,
    price TEXT NOT NULL,
    provider TEXT NOT NULL DEFAULT 'CoinGecko',
    provider_timestamp TEXT,
    fetched_at TEXT NOT NULL,
    quality_status TEXT NOT NULL DEFAULT 'fresh',
    error_message TEXT
);

CREATE TABLE IF NOT EXISTS watchlist (
    watchlist_id TEXT PRIMARY KEY,
    instrument_id TEXT REFERENCES instruments(instrument_id),
    crypto_asset_id TEXT REFERENCES crypto_assets(asset_id),
    name TEXT NOT NULL,
    asset_class TEXT NOT NULL,
    reason TEXT NOT NULL,
    target_entry_price NUMERIC,
    target_entry_currency TEXT,
    desired_position_size_chf NUMERIC,
    desired_weight_pct NUMERIC,
    trigger_rules TEXT,
    risk_notes TEXT,
    investment_case TEXT,
    bear_case TEXT,
    sources TEXT,
    status TEXT NOT NULL DEFAULT 'active',
    next_review_date TEXT,
    created_at TEXT NOT NULL,
    updated_at TEXT
);

CREATE TABLE IF NOT EXISTS reports (
    report_id TEXT PRIMARY KEY,
    report_type TEXT NOT NULL,
    title TEXT NOT NULL,
    per
quality_status 2597
nt_original TEXT,
    fee_original TEXT DEFAULT '0',
    tax_original TEXT DEFAULT '0',
    net_amount_original TEXT,
    currency_original TEXT NOT NULL,
    fx_rate_to_chf TEXT,
    fx_source TEXT,
    fx_status TEXT NOT NULL DEFAULT 'ok',
    gross_amount_chf TEXT,
    fee_chf TEXT,
    tax_chf TEXT,
    net_amount_chf TEXT,
    source_type TEXT NOT NULL,
    source_id TEXT,
    external_transaction_id TEXT,
    row_hash TEXT,
    is_confirmed INTEGER NOT NULL DEFAULT 1,
    quality_status TEXT NOT NULL DEFAULT 'ok',
    notes TEXT,
    created_at TEXT NOT NULL,
    updated_at TEXT
);

CREATE TABLE IF NOT EXISTS positions_snapshot (
    position_snapshot_id TEXT PRIMARY KEY,
    snapshot_date TEXT NOT NULL,
    account_id TEXT NOT NULL REFERENCES accounts(account_id),
    platform_id TEXT NOT NULL REFERENCES platforms(platform_id),
    instrument_id TEXT NOT NULL REFERENCES instruments(instrument_id),
    quantity NUMERIC NOT NULL,
    average_cost_original NUMERIC,
    cost_basis_original NUMERIC,
    cost_basis_chf NUMERIC,
    market_price_original NUMERIC,
    market_value_original NUMERIC,
    market_fx_rate_to_chf NUMERIC,
    market_value_chf NUMERIC,
    unrealized_price_pnl_chf NUMERIC,
    unrealized_fx_pnl_chf NUMERIC,
    realized_pnl_chf NUMERIC,
    income_chf NUMERIC,
    fees_chf NUMERIC,
    taxes_chf NUMERIC,
    total_return_chf NUMERIC,
    portfolio_weight_pct NUMERIC,
    category TEXT,
    data_quality_status TEXT NOT NULL DEFAULT 'ok',
    created_at TEXT NOT NULL,
    UNIQUE(snapshot_date, account_id, instrument_id)
);

CREATE TABLE IF NOT EXISTS cash_balances (
    cash_balance_id TEXT PRIMAR


Explanation:
**Considering tests for Coingecko**

I think I need to look into tests related to Coingecko. Maybe there are existing test cases or frameworks I could use. It’s important to ensure everything works smoothly, so I want to explore available tests thoroughly. I'll check out their documentation and search for test resources to help make this process easier for me. Understanding what's out there will give me a strong foundation to work from.


     1|from __future__ import annotations
     2|
     3|import urllib.error
     4|from decimal import Decimal
     5|
     6|from jarvis_finance.market.providers import CoinGeckoClient
     7|
     8|
     9|class Resp:
    10|    def __init__(self, payload: bytes): self.payload = payload
    11|    def __enter__(self): return self
    12|    def __exit__(self, *args): return False
    13|    def read(self): return self.payload
    14|
    15|
    16|def test_coingecko_client_retries_429_and_returns_quote_without_float() -> None:
    17|    calls = {'n': 0}
    18|    sleeps: list[float] = []
    19|    def opener(url, timeout=20):
    20|        calls['n'] += 1
    21|        if calls['n'] < 3:
    22|            raise urllib.error.HTTPError(url, 429, 'rate limited', None, None)
    23|        return Resp(b'{"bitcoin":{"chf":0.00000001,"last_updated_at":1700000000}}')
    24|    client = CoinGeckoClient(max_retries=3, initial_backoff_seconds=0.5, max_backoff_seconds=1.0, opener=opener, sleeper=sleeps.append)
    25|    quote = client.get_crypto_price('bitcoin', 'CHF')
    26|    assert calls['n'] == 3
    27|    assert sleeps == [0.5, 1.0]
    28|    assert quote.price == Decimal('1E-8')
    29|    assert quote.quality_status == 'fresh'
    30|
    31|
    32|def test_coingecko_client_429_after_retries_returns_stale_not_crash() -> None:
    33|    def opener(url, timeout=20):
    34|        raise urllib.error.HTTPError(url, 429, 'rate limited', None, None)
    35|    client = CoinGeckoClient(max_retries=1, initial_backoff_seconds=0, opener=opener, sleeper=lambda s: None)
    36|    quote = client.get_crypto_price('bitcoin', 'CHF')
    37|    assert quote.price is None
    38|    assert quote.quality_status == 'stale'
    39|    assert '429' in (quote.error_message or '')
    40|


     1|from __future__ import annotations
     2|
     3|import json
     4|import time
     5|import urllib.error
     6|import urllib.parse
     7|import urllib.request
     8|from dataclasses import dataclass
     9|from decimal import Decimal, InvalidOperation
    10|from sqlite3 import Connection
    11|from typing import Protocol
    12|
    13|from jarvis_finance.imports.common import stable_id, utc_now
    14|from jarvis_finance.quality.alerts import create_alert
    15|
    16|
    17|@dataclass(frozen=True)
    18|class PriceQuote:
    19|    coingecko_id: str
    20|    currency: str
    21|    price: Decimal | None
    22|    provider: str = "CoinGecko"
    23|    provider_timestamp: str | None = None
    24|    quality_status: str = "fresh"
    25|    error_message: str | None = None
    26|
    27|
    28|class MarketDataProvider(Protocol):
    29|    def get_crypto_price(self, coingecko_id: str, currency: str = "CHF") -> PriceQuote: ...
    30|
    31|
    32|class ProviderRateLimitError(RuntimeError):
    33|    pass
    34|
    35|
    36|class ProviderFetchError(RuntimeError):
    37|    pass
    38|
    39|
    40|class CoinGeckoClient:
    41|    def __init__(self, *, base_url: str = "https://api.coingecko.com/api/v3", max_retries: int = 2, initial_backoff_seconds: float = 1.0, max_backoff_seconds: float = 8.0, opener=None, sleeper=time.sleep) -> None:
    42|        self.base_url = base_url.rstrip("/")
    43|        self.max_retries = max_retries
    44|        self.initial_backoff_seconds = initial_backoff_seconds
    45|        self.max_backoff_seconds = max_backoff_seconds
    46|        self.opener = opener or urllib.request.urlopen
    47|        self.sleeper = sleeper
    48|
    49|    def get_crypto_price(self, coingecko_id: str, currency: str = "CHF") -> PriceQuote:
    50|        cur = currency.lower()
    51|        query = urllib.parse.urlencode({"ids": coingecko_id, "vs_currencies": cur, "include_last_updated_at": "true"})
    52|        url = f"{self.base_url}/simple/price?{query}"
    53|        delay = self.initial_backoff_seconds
    54|        last_error: str | None = None
    55|        for attempt in range(self.max_retries + 1):
    56|            try:
    57|                with self.opener(url, timeout=20) as resp:
    58|                    payload = json.loads(resp.read().decode("utf-8"))
    59|                data = payload.get(coingecko_id) or {}
    60|                value = data.get(cur)
    61|                if value is None:
    62|                    return PriceQuote(coingecko_id, currency.upper(), None, quality_status="missing", error_message="price missing")
    63|                try:
    64|                    price = Decimal(str(value))
    65|                except InvalidOperation:
    66|                    return PriceQuote(coingecko_id, currency.upper(), None, quality_status="error", error_message="invalid price")
    67|                ts = data.get("last_updated_at")
    68|                return PriceQuote(coingecko_id, currency.upper(), price, provider_timestamp=str(ts) if ts else None)
    69|            except urllib.error.HTTPError as exc:
    70|                last_error = f"HTTP {exc.code}"
    71|                if exc.code == 429 and attempt < self.max_retries:
    72|                    self.sleeper(min(delay, self.max_backoff_seconds)); delay *= 2; continue
    73|                status = "stale" if exc.code == 429 else "error"
    74|                return PriceQuote(coingecko_id, currency.upper(), None, quality_status=status, error_message=last_error)
    75|            except Exception as exc:  # network skeleton must not crash dashboard jobs
    76|                last_error = str(exc)
    77|                if attempt < self.max_retries:
    78|                    self.sleeper(min(delay, self.max_backoff_seconds)); delay *= 2; continue
    79|                return PriceQuote(coingecko_id, currency.upper(), None, quality_status="error", error_message=last_error)
    80|        return PriceQuote(coingecko_id, currency.upper(), None, quality_status="error", error_message=last_error)
    81|
    82|
    83|def store_crypto_price(conn: Connection, *, asset_id: str, quote: PriceQuote) -> str:
    84|    now = utc_now()
    85|    price_id = stable_id("cryptoprice", asset_id, quote.coingecko_id, quote.currency, quote.provider, now)
    86|    conn.execute(
    87|        """
    88|        INSERT INTO crypto_prices(crypto_price_id, asset_id, coingecko_id, price_currency, price, provider, provider_timestamp, fetched_at, quality_status, error_message)
    89|        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    90|        """,
    91|        (price_id, asset_id, quote.coingecko_id, quote.currency.upper(), format(quote.price, "f") if quote.price is not None else "", quote.provider, quote.provider_timestamp, now, quote.quality_status, quote.error_message),
    92|    )
    93|    if quote.quality_status in {"missing", "stale", "error", "conflict"}:
    94|        create_alert(conn, priority="warnung", category="market_data", entity_type="crypto_asset", entity_id=asset_id, rule_id=f"crypto_price_{quote.quality_status}", message=f"Crypto price quality is {quote.quality_status}.", evidence={"coingecko_id": quote.coingecko_id, "currency": quote.currency, "error": quote.error_message})
    95|    conn.commit()
    96|    return price_id
    97|
    98|
    99|def refresh_crypto_prices(conn: Connection, *, provider: MarketDataProvider, currency: str = "CHF", max_age_seconds: int = 3600) -> list[str]:
   100|    written: list[str] = []
   101|    assets = conn.execute("SELECT asset_id, coingecko_id, symbol FROM crypto_assets WHERE is_active=1").fetchall()
   102|    for asset in assets:
   103|        if not asset["coingecko_id"]:
   104|            create_alert(conn, priority="warnung", category="crypto", entity_type="crypto_asset", entity_id=asset["asset_id"], rule_id="missing_coingecko_id", message="Crypto asset has no CoinGecko ID; price refresh skipped.", evidence={"symbol": asset["symbol"]}, fingerprint="missing_coingecko_id")
   105|            continue
   106|        latest = conn.execute("SELECT fetched_at FROM crypto_prices WHERE asset_id=? AND price_currency=? ORDER BY fetched_at DESC LIMIT 1", (asset["asset_id"], currency.upper())).fetchone()
   107|        if latest:
   108|            # ISO timestamps sort lexicographically; avoid dashboard-triggered hammering.
   109|            from datetime import datetime, timezone
   110|            try:
   111|                age = (datetime.now(timezone.utc) - datetime.fromisoformat(latest["fetched_at"])).total_seconds()
   112|                if age < max_age_seconds:
   113|                    continue
   114|            except ValueError:
   115|                pass
   116|        quote = provider.get_crypto_price(asset["coingecko_id"], currency)
   117|        written.append(store_crypto_price(conn, asset_id=asset["asset_id"], quote=quote))
   118|    conn.commit()
   119|    return written
   120|


     1|from __future__ import annotations
     2|
     3|import os
     4|from dataclasses import dataclass
     5|from pathlib import Path
     6|
     7|from .paths import DEFAULT_RUNTIME_DIR, RuntimePaths, is_relative_to
     8|
     9|
    10|@dataclass(frozen=True)
    11|class Settings:
    12|    env: str = "local"
    13|    base_currency: str = "CHF"
    14|    timezone: str = "Europe/Zurich"
    15|    runtime_paths: RuntimePaths = RuntimePaths.from_base(DEFAULT_RUNTIME_DIR)
    16|    log_level: str = "INFO"
    17|    block_runtime_inside_repo: bool = True
    18|
    19|    @property
    20|    def db_path(self) -> Path:
    21|        return self.runtime_paths.db_path
    22|
    23|
    24|def find_repo_root(start: Path | None = None) -> Path:
    25|    current = (start or Path.cwd()).resolve()
    26|    for candidate in [current, *current.parents]:
    27|        if (candidate / ".git").exists() or (candidate / "pyproject.toml").exists():
    28|            return candidate
    29|    return current
    30|
    31|
    32|def load_settings(repo_root: Path | None = None, environ: dict[str, str] | None = None) -> Settings:
    33|    env = environ or os.environ
    34|    runtime_dir = Path(env.get("JARVIS_FINANCE_RUNTIME_DIR", str(DEFAULT_RUNTIME_DIR))).expanduser()
    35|    paths = RuntimePaths.from_base(runtime_dir)
    36|    db_override = env.get("JARVIS_FINANCE_DB_PATH")
    37|    if db_override:
    38|        object.__setattr__(paths, "db_path", Path(db_override).expanduser().resolve())
    39|    settings = Settings(
    40|        env=env.get("JARVIS_FINANCE_ENV", "local"),
    41|        base_currency=env.get("JARVIS_FINANCE_BASE_CURRENCY", "CHF"),
    42|        timezone=env.get("JARVIS_FINANCE_TIMEZONE", "Europe/Zurich"),
    43|        runtime_paths=paths,
    44|        log_level=env.get("JARVIS_FINANCE_LOG_LEVEL", "INFO"),
    45|        block_runtime_inside_repo=env.get("JARVIS_FINANCE_BLOCK_RUNTIME_INSIDE_REPO", "1") not in {"0", "false", "False"},
    46|    )
    47|    validate_settings(settings, repo_root=repo_root)
    48|    return settings
    49|
    50|
    51|def validate_settings(settings: Settings, repo_root: Path | None = None) -> None:
    52|    root = (repo_root or find_repo_root()).resolve()
    53|    if settings.block_runtime_inside_repo and is_relative_to(settings.runtime_paths.base_dir, root):
    54|        raise ValueError(f"Runtime directory must be outside the Git repository: {settings.runtime_paths.base_dir}")
    55|    if settings.base_currency != "CHF":
    56|        raise ValueError("MVP 1 base currency must be CHF")
    57|
    58|
    59|def ensure_runtime_dirs(settings: Settings) -> None:
    60|    settings.runtime_paths.ensure_dirs()
    61|


Explanation:
**Considering implementation details**

I need to think about implementing environment variables and writing tests. Maybe I can use an apply function for that. I'll likely have to do some CLI tests where I call the main function with a monkeypatch provider. It could be simpler to test the update function and CLI parsing by monkeypatching main.CoinGeckoClient. I'll need to create test files and apply migrations as well. Additionally, I'll inspect how to create the crypto asset in a stable way.


     1|from __future__ import annotations
     2|
     3|from sqlite3 import Connection, IntegrityError
     4|
     5|from jarvis_finance.audit.log import record_audit_event
     6|from jarvis_finance.imports.common import stable_id, utc_now
     7|from jarvis_finance.quality.alerts import create_alert
     8|
     9|
    10|def create_crypto_asset(
    11|    conn: Connection,
    12|    *,
    13|    coin_name: str,
    14|    symbol: str,
    15|    coingecko_id: str | None = None,
    16|    network_chain_default: str | None = None,
    17|    is_stablecoin: bool = False,
    18|    notes: str | None = None,
    19|) -> str:
    20|    symbol = symbol.upper().strip()
    21|    existing_symbol = conn.execute("SELECT asset_id FROM crypto_assets WHERE symbol=?", (symbol,)).fetchone()
    22|    asset_id = stable_id("cryptoasset", coingecko_id or coin_name, symbol, network_chain_default or "")
    23|    now = utc_now()
    24|    try:
    25|        conn.execute(
    26|            """
    27|            INSERT INTO crypto_assets(
    28|                asset_id, coin_name, symbol, coingecko_id, network_chain_default,
    29|                is_stablecoin, price_provider_primary, is_active, notes, created_at
    30|            ) VALUES (?, ?, ?, ?, ?, ?, 'CoinGecko', 1, ?, ?)
    31|            """,
    32|            (asset_id, coin_name, symbol, coingecko_id, network_chain_default, int(is_stablecoin), notes, now),
    33|        )
    34|    except IntegrityError as exc:
    35|        raise ValueError("crypto asset uniqueness conflict; coingecko_id must be unique") from exc
    36|    if not coingecko_id:
    37|        create_alert(
    38|            conn,
    39|            priority="warnung",
    40|            category="crypto",
    41|            entity_type="crypto_asset",
    42|            entity_id=asset_id,
    43|            rule_id="missing_coingecko_id",
    44|            message="Crypto asset has no CoinGecko ID; valuation quality is incomplete.",
    45|            evidence={"symbol": symbol, "coin_name": coin_name},
    46|            fingerprint="missing_coingecko_id",
    47|        )
    48|    if existing_symbol:
    49|        create_alert(
    50|            conn,
    51|            priority="warnung",
    52|            category="crypto",
    53|            entity_type="crypto_asset",
    54|            entity_id=asset_id,
    55|            rule_id="crypto_symbol_conflict",
    56|            message="Crypto symbol is not unique; manual asset selection is required.",
    57|            evidence={"symbol": symbol, "existing_asset_id": existing_symbol["asset_id"]},
    58|        )
    59|    record_audit_event(
    60|        conn,
    61|        source="crypto_assets",
    62|        action="create_crypto_asset",
    63|        entity_type="crypto_asset",
    64|        entity_id=asset_id,
    65|        new_values={"coin_name": coin_name, "symbol": symbol, "coingecko_id": coingecko_id},
    66|        confirmed=True,
    67|        created_by="system",
    68|    )
    69|    conn.commit()
    70|    return asset_id
    71|
    72|
    73|def get_crypto_asset(conn: Connection, asset_id: str):
    74|    row = conn.execute("SELECT * FROM crypto_assets WHERE asset_id=?", (asset_id,)).fetchone()
    75|    if row is None:
    76|        raise KeyError(asset_id)
    77|    return row
    78|


     1|from __future__ import annotations
     2|
     3|import hashlib
     4|import json
     5|import uuid
     6|from dataclasses import dataclass, field
     7|from datetime import datetime, timezone
     8|from pathlib import Path
     9|from sqlite3 import Connection
    10|from typing import Any
    11|
    12|
    13|def utc_now() -> str:
    14|    return datetime.now(timezone.utc).isoformat()
    15|
    16|
    17|def stable_id(prefix: str, *parts: object) -> str:
    18|    payload = "|".join(str(p or "").strip().lower() for p in parts)
    19|    digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()[:24]
    20|    return f"{prefix}_{digest}"
    21|
    22|
    23|def parse_bool(value: object, default: bool = False) -> int:
    24|    if value is None or str(value).strip() == "":
    25|        return 1 if default else 0
    26|    return 1 if str(value).strip().lower() in {"1", "true", "yes", "ja", "y"} else 0
    27|
    28|
    29|def clean(value: object) -> str:
    30|    return str(value or "").strip()
    31|
    32|
    33|def require_text(row: dict[str, Any], field: str, line: int) -> str:
    34|    value = clean(row.get(field))
    35|    if not value:
    36|        raise ValueError(f"line {line}: {field} is required")
    37|    return value
    38|
    39|
    40|def source_hash(path: str | Path) -> str:
    41|    data = Path(path).read_bytes()
    42|    return hashlib.sha256(data).hexdigest()
    43|
    44|
    45|@dataclass(frozen=True)
    46|class ImportResult:
    47|    import_session_id: str
    48|    import_type: str
    49|    status: str
    50|    rows_total: int
    51|    rows_new: int
    52|    rows_existing: int
    53|    rows_failed: int
    54|    errors: list[str] = field(default_factory=list)
    55|
    56|
    57|def create_import_session(
    58|    conn: Connection,
    59|    *,
    60|    import_type: str,
    61|    source_filename: str,
    62|    file_hash: str | None,
    63|    status: str,
    64|    rows_total: int,
    65|    rows_imported: int,
    66|    rows_failed: int,
    67|    errors: list[str],
    68|    notes: str | None = None,
    69|) -> str:
    70|    session_id = str(uuid.uuid4())
    71|    now = utc_now()
    72|    conn.execute(
    73|        """
    74|        INSERT INTO import_sessions(
    75|            import_session_id, import_type, source_filename, source_hash, started_at,
    76|            finished_at, status, rows_total, rows_imported, rows_failed, errors_json, notes
    77|        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    78|        """,
    79|        (
    80|            session_id,
    81|            import_type,
    82|            source_filename,
    83|            file_hash,
    84|            now,
    85|            now,
    86|            status,
    87|            rows_total,
    88|            rows_imported,
    89|            rows_failed,
    90|            json.dumps(errors, ensure_ascii=False),
    91|            notes,
    92|        ),
    93|    )
    94|    conn.commit()
    95|    return session_id
    96|


Explanation:
**Considering test implementation**

I need to write tests, potentially in `test_crypto_prices.py`. I might start by importing necessary components, but I think I may be missing something for the update summary. Some tests already pass, but there could be specific failures. I’ll focus on writing tests for behaviors that don’t currently exist, which means I’ll need a provider summary class. Okay, let's get started on this!

from __future__ import annotations

import urllib.error
from datetime import datetime, timedelta, timezone
from decimal import Decimal

from jarvis_finance.cli.main import main
from jarvis_finance.crypto.assets import create_crypto_asset
from jarvis_finance.crypto.holdings import create_initial_holding_snapshot
from jarvis_finance.crypto.wallets import create_wallet
from jarvis_finance.dashboard import data
from jarvis_finance.market.providers import CoinGeckoClient, PriceQuote, refresh_crypto_prices
from jarvis_finance.reports.crypto_inventory import build_crypto_report_context
from jarvis_finance.storage.database import connect_memory
from jarvis_finance.storage.migrations import apply_migrations

class MockProvider:
    def __init__(self) -> None:
        self.calls: list[tuple[str, str]] = []

    def get_crypto_price(self, coingecko_id: str, currency: str = "CHF") -> PriceQuote:
        self.calls.append((coingecko_id, currency))
        return PriceQuote(coingecko_id=coingecko_id, currency=currency, price=Decimal("123.456789"), provider="MockGecko", provider_timestamp="2026-01-31T00:00:00+00:00")

def setup_conn():
    conn = connect_memory()
    apply_migrations(conn)
    wallet_id = create_wallet(conn, wallet_name="Synthetic Wallet", wallet_type="hardware", platform_provider="Synthetic", last_verified_at="2026-01-31T00:00:00+00:00")
    btc_id = create_crypto_asset(conn, coin_name="Bitcoin", symbol="BTC", coingecko_id="bitcoin")
    create_initial_holding_snapshot(conn, asset_id=btc_id, wallet_id=wallet_id, quantity=Decimal("0.01"), verification_status="verified", note="synthetic fixture", last_verified_at="2026-01-31T00:00:00+00:00")
    missing_id = create_crypto_asset(conn, coin_name="Missing Coin", symbol="MISS", coingecko_id=None)
    create_initial_holding_snapshot(conn, asset_id=missing_id, wallet_id=wallet_id, quantity=Decimal("1"), verification_status="verified", note="synthetic fixture", last_verified_at="2026-01-31T00:00:00+00:00")
    return conn, btc_id, missing_id

def test_refresh_crypto_prices_loads_active_assets_and_stores_chf_price() -> None:
    conn, btc_id, _ = setup_conn()
    provider = MockProvider()

    result = refresh_crypto_prices(conn, provider=provider, currency="CHF", max_age_seconds=0)

    assert result.success_count == 1
    assert result.skipped_count == 1
    assert result.warning_count == 1
    assert result.error_count == 0
    assert provider.calls == [("bitcoin", "CHF")]
    row = conn.execute("SELECT * FROM crypto_prices WHERE asset_id=?", (btc_id,)).fetchone()
    assert row is not None
    assert row["price"] == "123.456789"
    assert row["price_currency"] == "CHF"
    assert row["provider"] == "MockGecko"

def test_missing_coingecko_id_is_skipped_and_warned_without_duplicate_alerts() -> None:
    conn, _, missing_id = setup_conn()
    provider = MockProvider()

    first = refresh_crypto_prices(conn, provider=provider, currency="CHF", max_age_seconds=0)
    second = refresh_crypto_prices(conn, provider=provider, currency="CHF", max_age_seconds=0)

    assert first.skipped_count == 1
    assert second.skipped_count == 1
    alerts = conn.execute("SELECT * FROM alerts WHERE entity_id=? AND rule_id='missing_coingecko_id'", (missing_id,)).fetchall()
    assert len(alerts) == 1
    assert alerts[0]["occurrence_count"] >= 2

def test_429_backoff_is_used_by_provider() -> None:
    calls = {"n": 0}
    sleeps: list[float] = []

    def opener(url, timeout=20):
        calls["n"] += 1
        if calls["n"] == 1:
            raise urllib.error.HTTPError(url, 429, "rate limited", None, None)
        class Resp:
            def __enter__(self): return self
            def __exit__(self, *args): return False
            def read(self): return b'{"bitcoin":{"chf":"12.34","last_updated_at":1700000000}}'
        return Resp()

    client = CoinGeckoClient(max_retries=2, initial_backoff_seconds=0.25, max_backoff_seconds=1, opener=opener, sleeper=sleeps.append)

    quote = client.get_crypto_price("bitcoin", "CHF")

    assert quote.price == Decimal("12.34")
    assert sleeps == [0.25]
    assert calls["n"] == 2

def test_fresh_cache_prevents_refetch_and_stale_price_is_marked() -> None:
    conn, btc_id, _ = setup_conn()
    provider = MockProvider()
    first = refresh_crypto_prices(conn, provider=provider, currency="CHF", max_age_seconds=3600)
    second = refresh_crypto_prices(conn, provider=provider, currency="CHF", max_age_seconds=3600)
    assert first.success_count == 1
    assert second.cached_count == 1
    assert provider.calls == [("bitcoin", "CHF")]

    old = (datetime.now(timezone.utc) - timedelta(days=10)).isoformat()
    conn.execute("UPDATE crypto_prices SET fetched_at=?, provider_timestamp=? WHERE asset_id=?", (old, old, btc_id))
    conn.commit()

    overview = data.get_crypto_overview(conn, price_max_age_seconds=3600)
    btc = next(row for row in overview if row["symbol"] == "BTC")
    assert btc["price_quality"] == "stale"
    context = build_crypto_report_context(conn, price_max_age_seconds=3600)
    assert any(w["code"] == "stale_price" and w["entity_id"] == btc_id for w in context["data_quality_warnings"])

def test_dashboard_reads_local_prices_without_provider_api_call() -> None:
    conn, btc_id, _ = setup_conn()
    refresh_crypto_prices(conn, provider=MockProvider(), currency="CHF", max_age_seconds=0)

    overview = data.get_crypto_overview(conn)
    by_wallet = data.get_crypto_by_wallet(conn)
    summary = data.get_command_center_summary(conn)

    btc = next(row for row in overview if row["symbol"] == "BTC")
    assert btc["latest_price_chf"] == "123.46"
    assert btc["market_value_chf"]
    assert any(row["wallet_value_chf"] for row in by_wallet if row["symbol"] == "BTC")
    assert summary["crypto_total_chf"] != "0.00"
    assert summary["crypto_price_data_as_of"]

def test_crypto_report_uses_local_prices_and_improves_missing_price_status() -> None:
    conn, btc_id, _ = setup_conn()
    before = build_crypto_report_context(conn)
    assert any(w["code"] == "missing_price" and w["entity_id"] == btc_id for w in before["data_quality_warnings"])

    refresh_crypto_prices(conn, provider=MockProvider(), currency="CHF", max_age_seconds=0)
    after = build_crypto_report_context(conn)

    btc = next(row for row in after["coins"] if row["symbol"] == "BTC")
    assert btc["price_chf"] == "123.456789"
    assert btc["value_chf"]
    assert not any(w["code"] == "missing_price" and w["entity_id"] == btc_id for w in after["data_quality_warnings"])
    assert after["metadata"]["live_api_calls"] is False

def test_cli_update_crypto_prices_command_uses_provider_factory(monkeypatch, tmp_path, capsys) -> None:
    runtime = tmp_path / "runtime"
    monkeypatch.setenv("JARVIS_FINANCE_RUNTIME_DIR", str(runtime))
    conn = connect_memory()
    # Build a runtime file DB via CLI settings by seeding after opening path.
    from jarvis_finance.storage.database import connect
    db = runtime / "data" / "finance.sqlite3"
    db.parent.mkdir(parents=True)
    file_conn = connect(db)
    apply_migrations(file_conn)
    wallet_id = create_wallet(file_conn, wallet_name="CLI Wallet", wallet_type="hardware", platform_provider="Synthetic", last_verified_at="2026-01-31T00:00:00+00:00")
    asset_id = create_crypto_asset(file_conn, coin_name="Bitcoin", symbol="BTC", coingecko_id="bitcoin")
    create_initial_holding_snapshot(file_conn, asset_id=asset_id, wallet_id=wallet_id, quantity=Decimal("0.01"), verification_status="verified", note="synthetic fixture", last_verified_at="2026-01-31T00:00:00+00:00")
    file_conn.close()

    monkeypatch.setattr("jarvis_finance.cli.main.CoinGeckoClient", lambda: MockProvider())

    rc = main(["update-crypto-prices", "--max-age-seconds", "0"])

    out = capsys.readouterr().out
    assert rc == 0
    assert "CRYPTO_PRICE_UPDATE" in out
    assert "success=1" in out
    conn.close()


PYTHONPATH=src pytest tests/unit/test_crypto_price_update.py -q


Output:
FF.FFFF                                                                  [100%]
=================================== FAILURES ===================================
_____ test_refresh_crypto_prices_loads_active_assets_and_stores_chf_price ______

    def test_refresh_crypto_prices_loads_active_assets_and_stores_chf_price() -> None:
>       conn, btc_id, _ = setup_conn()
                          ^^^^^^^^^^^^

tests/unit/test_crypto_price_update.py:39:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
tests/unit/test_crypto_price_update.py:30: in setup_conn
    wallet_id = create_wallet(conn, wallet_name="Synthetic Wallet", wallet_type="hardware", platform_provider="Synthetic", last_verified_at="2026-01-31T00:00:00+00:00")
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
src/jarvis_finance/crypto/wallets.py:35: in create_wallet
    _validate_wallet_type(wallet_type)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

wallet_type = 'hardware'

    def _validate_wallet_type(wallet_type: str) -> None:
        if wallet_type not in VALID_WALLET_TYPES:
>           raise ValueError(f"wallet_type must be one of {sorted(VALID_WALLET_TYPES)}")
E           ValueError: wallet_type must be one of ['Bank/Broker', 'DeFi', 'Exchange', 'Hardware Wallet', 'Software Wallet', 'Sonstiges']

src/jarvis_finance/crypto/wallets.py:20: ValueError
___ test_missing_coingecko_id_is_skipped_and_warned_without_duplicate_alerts ___

    def test_missing_coingecko_id_is_skipped_and_warned_without_duplicate_alerts() -> None:
>       conn, _, missing_id = setup_conn()
                              ^^^^^^^^^^^^

tests/unit/test_crypto_price_update.py:57:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
tests/unit/test_crypto_price_update.py:30: in setup_conn
    wallet_id = create_wallet(conn, wallet_name="Synthetic Wallet", wallet_type="hardware", platform_provider="Synthetic", last_verified_at="2026-01-31T00:00:00+00:00")
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
src/jarvis_finance/crypto/wallets.py:35: in create_wallet
    _validate_wallet_type(wallet_type)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

wallet_type = 'hardware'

    def _validate_wallet_type(wallet_type: str) -> None:
        if wallet_type not in VALID_WALLET_TYPES:
>           raise ValueError(f"wallet_type must be one of {sorted(VALID_WALLET_TYPES)}")
E           ValueError: wallet_type must be one of ['Bank/Broker', 'DeFi', 'Exchange', 'Hardware Wallet', 'Software Wallet', 'Sonstiges']

src/jarvis_finance/crypto/wallets.py:20: ValueError
_________ test_fresh_cache_prevents_refetch_and_stale_price_is_marked __________

    def test_fresh_cache_prevents_refetch_and_stale_price_is_marked() -> None:
>       conn, btc_id, _ = setup_conn()
                          ^^^^^^^^^^^^

tests/unit/test_crypto_price_update.py:94:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
tests/unit/test_crypto_price_update.py:30: in setup_conn
    wallet_id = create_wallet(conn, wallet_name="Synthetic Wallet", wallet_type="hardware", platform_provider="Synthetic", last_verified_at="2026-01-31T00:00:00+00:00")
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
src/jarvis_finance/crypto/wallets.py:35: in create_wallet
    _validate_wallet_type(wallet_type)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

wallet_type = 'hardware'

    def _validate_wallet_type(wallet_type: str) -> None:
        if wallet_type not in VALID_WALLET_TYPES:
>           raise ValueError(f"wallet_type must be one of {sorted(VALID_WALLET_TYPES)}")
E           ValueError: wallet_type must be one of ['Bank/Broker', 'DeFi', 'Exchange', 'Hardware Wallet', 'Software Wallet', 'Sonstiges']

src/jarvis_finance/crypto/wallets.py:20: ValueError
_________ test_dashboard_reads_local_prices_without_provider_api_call __________

    def test_dashboard_reads_local_prices_without_provider_api_call() -> None:
>       conn, btc_id, _ = setup_conn()
                          ^^^^^^^^^^^^

tests/unit/test_crypto_price_update.py:114:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
tests/unit/test_crypto_price_update.py:30: in setup_conn
    wallet_id = create_wallet(conn, wallet_name="Synthetic Wallet", wallet_type="hardware", platform_provider="Synthetic", last_verified_at="2026-01-31T00:00:00+00:00")
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
src/jarvis_finance/crypto/wallets.py:35: in create_wallet
    _validate_wallet_type(wallet_type)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

wallet_type = 'hardware'

    def _validate_wallet_type(wallet_type: str) -> None:
        if wallet_type not in VALID_WALLET_TYPES:
>           raise ValueError(f"wallet_type must be one of {sorted(VALID_WALLET_TYPES)}")
E           ValueError: wallet_type must be one of ['Bank/Broker', 'DeFi', 'Exchange', 'Hardware Wallet', 'Software Wallet', 'Sonstiges']

src/jarvis_finance/crypto/wallets.py:20: ValueError
____ test_crypto_report_uses_local_prices_and_improves_missing_price_status ____

    def test_crypto_report_uses_local_prices_and_improves_missing_price_status() -> None:
>       conn, btc_id, _ = setup_conn()
                          ^^^^^^^^^^^^

tests/unit/test_crypto_price_update.py:130:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
tests/unit/test_crypto_price_update.py:30: in setup_conn
    wallet_id = create_wallet(conn, wallet_name="Synthetic Wallet", wallet_type="hardware", platform_provider="Synthetic", last_verified_at="2026-01-31T00:00:00+00:00")
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
src/jarvis_finance/crypto/wallets.py:35: in create_wallet
    _validate_wallet_type(wallet_type)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

wallet_type = 'hardware'

    def _validate_wallet_type(wallet_type: str) -> None:
        if wallet_type not in VALID_WALLET_TYPES:
>           raise ValueError(f"wallet_type must be one of {sorted(VALID_WALLET_TYPES)}")
E           ValueError: wallet_type must be one of ['Bank/Broker', 'DeFi', 'Exchange', 'Hardware Wallet', 'Software Wallet', 'Sonstiges']

src/jarvis_finance/crypto/wallets.py:20: ValueError
_________ test_cli_update_crypto_prices_command_uses_provider_factory __________

monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x736b2adbd490>
tmp_path = PosixPath('/tmp/pytest-of-agent/pytest-40/test_cli_update_crypto_prices_0')
capsys = <_pytest.capture.CaptureFixture object at 0x736b2adbd650>

    def test_cli_update_crypto_prices_command_uses_provider_factory(monkeypatch, tmp_path, capsys) -> None:
        runtime = tmp_path / "runtime"
        monkeypatch.setenv("JARVIS_FINANCE_RUNTIME_DIR", str(runtime))
        conn = connect_memory()
        # Build a runtime file DB via CLI settings by seeding after opening path.
        from jarvis_finance.storage.database import connect
        db = runtime / "data" / "finance.sqlite3"
        db.parent.mkdir(parents=True)
        file_conn = connect(db)
        apply_migrations(file_conn)
>       wallet_id = create_wallet(file_conn, wallet_name="CLI Wallet", wallet_type="hardware", platform_provider="Synthetic", last_verified_at="2026-01-31T00:00:00+00:00")
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

tests/unit/test_crypto_price_update.py:154:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
src/jarvis_finance/crypto/wallets.py:35: in create_wallet
    _validate_wallet_type(wallet_type)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

wallet_type = 'hardware'

    def _validate_wallet_type(wallet_type: str) -> None:
        if wallet_type not in VALID_WALLET_TYPES:
>           raise ValueError(f"wallet_type must be one of {sorted(VALID_WALLET_TYPES)}")
E           ValueError: wallet_type must be one of ['Bank/Broker', 'DeFi', 'Exchange', 'Hardware Wallet', 'Software Wallet', 'Sonstiges']

src/jarvis_finance/crypto/wallets.py:20: ValueError
=========================== short test summary info ============================
FAILED tests/unit/test_crypto_price_update.py::test_refresh_crypto_prices_loads_active_assets_and_stores_chf_price
FAILED tests/unit/test_crypto_price_update.py::test_missing_coingecko_id_is_skipped_and_warned_without_duplicate_alerts
FAILED tests/unit/test_crypto_price_update.py::test_fresh_cache_prevents_refetch_and_stale_price_is_marked
FAILED tests/unit/test_crypto_price_update.py::test_dashboard_reads_local_prices_without_provider_api_call
FAILED tests/unit/test_crypto_price_update.py::test_crypto_report_uses_local_prices_and_improves_missing_price_status
FAILED tests/unit/test_crypto_price_update.py::test_cli_update_crypto_prices_command_uses_provider_factory
6 failed, 1 passed in 0.56s


patch

--- a//home/agent/.hermes/repos/FinanceManager/tests/unit/test_crypto_price_update.py
+++ b//home/agent/.hermes/repos/FinanceManager/tests/unit/test_crypto_price_update.py
@@ -27,7 +27,7 @@
 def setup_conn():
     conn = connect_memory()
     apply_migrations(conn)
-    wallet_id = create_wallet(conn, wallet_name="Synthetic Wallet", wallet_type="hardware", platform_provider="Synthetic", last_verified_at="2026-01-31T00:00:00+00:00")
+    wallet_id = create_wallet(conn, wallet_name="Synthetic Wallet", wallet_type="Hardware Wallet", platform_provider="Synthetic", last_verified_at="2026-01-31T00:00:00+00:00")
     btc_id = create_crypto_asset(conn, coin_name="Bitcoin", symbol="BTC", coingecko_id="bitcoin")
     create_initial_holding_snapshot(conn, asset_id=btc_id, wallet_id=wallet_id, quantity=Decimal("0.01"), verification_status="verified", note="synthetic fixture", last_verified_at="2026-01-31T00:00:00+00:00")
     missing_id = create_crypto_asset(conn, coin_name="Missing Coin", symbol="MISS", coingecko_id=None)
@@ -151,7 +151,7 @@
     db.parent.mkdir(parents=True)
     file_conn = connect(db)
     apply_migrations(file_conn)
-    wallet_id = create_wallet(file_conn, wallet_name="CLI Wallet", wallet_type="hardware", platform_provider="Synthetic", last_verified_at="2026-01-31T00:00:00+00:00")
+    wallet_id = create_wallet(file_conn, wallet_name="CLI Wallet", wallet_type="Hardware Wallet", platform_provider="Synthetic", last_verified_at="2026-01-31T00:00:00+00:00")
     asset_id = create_crypto_asset(file_conn, coin_name="Bitcoin", symbol="BTC", coingecko_id="bitcoin")
     create_initial_holding_snapshot(file_conn, asset_id=asset_id, wallet_id=wallet_id, quantity=Decimal("0.01"), verification_status="verified", note="synthetic fixture", last_verified_at="2026-01-31T00:00:00+00:00")
     file_conn.close()