     1|from __future__ import annotations
     2|
     3|from jarvis_finance.dashboard.components.tables import show_table
     4|from jarvis_finance.dashboard import data
     5|from jarvis_finance.dashboard.actions import action_button, edit_mode_enabled, open_crypto_manage, read_only_hint
     6|
     7|PAGE_TITLE = "Crypto"
     8|
     9|
    10|def _metric_row(st, metrics: list[tuple[str, str]]) -> None:
    11|    cols = st.columns(len(metrics))
    12|    for col, (label, value) in zip(cols, metrics):
    13|        col.metric(label, value)
    14|
    15|
    16|def _chart(st, title: str, rows: list[dict[str, str]]) -> None:
    17|    st.markdown(f"**{title}**")
    18|    if rows:
    19|        st.bar_chart({r["label"]: float(data.d(r["value_chf"])) for r in rows})
    20|    else:
    21|        st.info("Noch keine bewertbaren Daten.")
    22|
    23|
    24|def render(st, conn) -> None:
    25|    st.title("Crypto Portfolio")
    26|    st.caption("Aggregiert nach Coin. Bewertung liest ausschließlich lokale CHF-Preise aus der Runtime-DB.")
    27|    summary = data.get_crypto_summary_cards(conn)
    28|    coins = data.get_crypto_coin_summary(conn)
    29|    largest = coins[0]["coin"] if coins else "—"
    30|    _metric_row(st, [
    31|        ("Crypto-Gesamtwert", data.chf_text(summary["total_value_chf"])),
    32|        ("Coins", summary["coin_count"]),
    33|        ("Wallets", summary["wallets_with_holdings"]),
    34|        ("Coins ohne Preis", summary["coins_without_price"]),
    35|        ("Grösste Position", largest),
    36|        ("Letztes Preisupdate", data.date_text(summary["last_price_update_at"])),
    37|    ])
    38|
    39|    c1, c2 = st.columns(2)
    40|    with c1:
    41|        _chart(st, "Allokation nach Coin", data.get_crypto_coin_allocation_chart(conn))
    42|    with c2:
    43|        _chart(st, "Allokation nach Wallet", data.get_crypto_wallet_allocation_chart(conn))
    44|    _chart(st, "Top 10 Coins nach CHF-Wert", data.get_crypto_coin_allocation_chart(conn)[:10])
    45|
    46|    st.subheader("Positionen")
    47|    if not coins:
    48|        st.info("Keine Crypto-Positionen vorhanden.")
    49|        return
    50|    search = st.text_input("Coin suchen", value="")
    51|    sort_by = st.selectbox("Sortierung", ["Wert absteigend", "Wert aufsteigend", "Name A–Z", "Anteil", "Wallet-Anzahl", "Status"])
    52|    status_filter = st.selectbox("Status filtern", ["Alle", "Aktuell", "Veraltet", "Preis fehlt"])
    53|    wallet_filter = st.selectbox("Wallet filtern", ["Alle", *sorted({name for row in data.get_crypto_coin_summary(conn) for name in row.get("_wallet_names", "").split(" | ") if name})])
    54|    coins = data.get_crypto_coin_summary(conn, search=search, sort_by=sort_by, status_filter=status_filter, wallet_filter=wallet_filter)
    55|    labels = [f"{r['coin']} ({r['symbol']}) — {r['gesamtwert_chf']}" for r in coins]
    56|    selected = st.selectbox("Coin auswählen / öffnen", labels)
    57|    selected_row = coins[labels.index(selected)] if selected in labels else coins[0]
    58|    st.session_state["selected_crypto_asset_id"] = selected_row["_asset_id"]
    59|    show_table(st, [{k: r[k] for k in ["coin", "symbol", "gesamtmenge", "kurs_chf", "gesamtwert_chf", "anteil", "wallets", "status"]} for r in coins])
    60|
    61|    st.subheader("Coin Detail")
    62|    detail = data.get_crypto_coin_detail(conn, selected_row["_asset_id"])
    63|    d1, d2, d3, d4 = st.columns(4)
    64|    d1.metric("Coin", f"{detail['coin']} ({detail['symbol']})")
    65|    d2.metric("Gesamtwert", detail["gesamtwert_chf"])
    66|    d3.metric("Kurs", detail["kurs_chf"])
    67|    d4.metric("Anteil", detail["anteil"])
    68|    st.caption(f"Status: {detail['preisstatus']} · letzte Preisaktualisierung: {detail['letzte_preisaktualisierung']}")
    69|    st.markdown("**Wallet-Aufteilung**")
    70|    show_table(st, detail["wallets"])
    71|    st.markdown("**Aktionen**")
    72|    cols = st.columns(len(detail["actions"]))
    73|    for col, action in zip(cols, detail["actions"]):
    74|        col.button(action)
    75|    with st.expander("Admin: technische Coin-Details"):
    76|        show_table(st, data.get_crypto_coin_summary(conn, admin_mode=True))
    77|


Explanation:
**Finishing tasks**

I see I need to wrap things up. There's also the matter of renaming some page stubs, which I'll need to make sure I get right. Additionally, I need to inspect the wallet reports to manage everything effectively. It seems like a few steps still need to be tackled to get everything in order. Let's make a plan to address these tasks systematically!


     1|from __future__ import annotations
     2|
     3|from jarvis_finance.crypto.wallets import create_wallet, update_wallet, VALID_WALLET_TYPES
     4|from jarvis_finance.dashboard.components.tables import show_table
     5|from jarvis_finance.dashboard import data
     6|
     7|PAGE_TITLE = "Wallets"
     8|
     9|
    10|def render(st, conn) -> None:
    11|    st.title("Crypto Wallets")
    12|    st.caption("Wallets als verwaltbare Container: öffnen, prüfen, verifizieren, bearbeiten.")
    13|    cards = data.get_wallet_overview_cards(conn)
    14|    wallet_rows = data.get_wallet_user_overview(conn)
    15|    largest = max(wallet_rows, key=lambda r: data.d(r["_numeric_value_chf"]), default={"wallet_name": "—"})
    16|    c1, c2, c3, c4, c5 = st.columns(5)
    17|    c1.metric("Gesamtwert", data.chf_text(cards["total_value_chf"]))
    18|    c2.metric("Wallets", cards["wallet_count"])
    19|    c3.metric("fehlende Verifikation", cards["wallets_missing_verification"])
    20|    c4.metric("Grösstes Wallet", largest["wallet_name"])
    21|    c5.button("Neues Wallet")
    22|
    23|    st.subheader("Wallets")
    24|    if not wallet_rows:
    25|        st.info("Keine Wallets vorhanden.")
    26|        return
    27|    labels = [f"{r['wallet_name']} — {r['total_value_chf']}" for r in wallet_rows]
    28|    selected = st.selectbox("Wallet auswählen / öffnen", labels)
    29|    selected_row = wallet_rows[labels.index(selected)] if selected in labels else wallet_rows[0]
    30|    show_table(st, [{k: r[k] for k in ["wallet_name", "wallet_type", "provider", "total_value_chf", "coin_count", "status", "actions"]} for r in wallet_rows])
    31|
    32|    st.subheader("Wallet Detail")
    33|    detail = data.get_wallet_detail(conn, selected_row["_wallet_id"])
    34|    d1, d2, d3, d4 = st.columns(4)
    35|    d1.metric("Wallet", detail["wallet_name"])
    36|    d2.metric("Typ", detail["typ"])
    37|    d3.metric("Gesamtwert", detail["gesamtwert_chf"])
    38|    d4.metric("Status", detail["status"])
    39|    st.caption(f"Provider: {detail['provider']} · letzte Verifikation: {detail['letzte_verifikation']} · Notizen: {detail['notizen']}")
    40|    st.markdown("**Coins in diesem Wallet**")
    41|    show_table(st, detail["coins"])
    42|    st.markdown("**Aktionen**")
    43|    cols = st.columns(len(detail["actions"]))
    44|    for col, action in zip(cols, detail["actions"]):
    45|        col.button(action)
    46|
    47|    with st.expander("Neues Wallet / Wallet bearbeiten"):
    48|        name = st.text_input("Wallet-Name")
    49|        wallet_type = st.selectbox("Wallet-Typ", sorted(VALID_WALLET_TYPES))
    50|        provider = st.text_input("Provider")
    51|        chain = st.text_input("Chain/Network optional")
    52|        note = st.text_area("Notiz optional")
    53|        active = st.checkbox("active", value=True)
    54|        confirm = st.checkbox("Review bestätigt")
    55|        if st.button("Wallet speichern"):
    56|            try:
    57|                if not confirm:
    58|                    raise ValueError("Review-Bestätigung fehlt")
    59|                wallet_id = create_wallet(conn, wallet_name=name, wallet_type=wallet_type, platform_provider=provider or None, network_chain=chain or None, notes=note or None)
    60|                if not active:
    61|                    update_wallet(conn, wallet_id, is_active=0)
    62|                st.success("Wallet auditiert gespeichert.")
    63|            except Exception as exc:
    64|                st.error(str(exc))
    65|
    66|    with st.expander("Admin: technische Wallet-Details"):
    67|        show_table(st, data.get_wallets(conn))
    68|


     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 = "Reports"
     7|
     8|def render(st, conn) -> None:
     9|    st.title("Reports")
    10|    st.info("Report-Download und PDF-Erzeugung folgen später. Diese MVP-Seite ist bewusst read-only.")
    11|    show_table(st, data.rowdicts(conn.execute("SELECT report_type, title, generated_at, data_quality_status FROM reports ORDER BY generated_at DESC").fetchall()))
    12|


File not found: /home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/pages/page_11_crypto_manage.py


     1|from __future__ import annotations
     2|
     3|from datetime import date
     4|
     5|from jarvis_finance.crypto.manage import (
     6|    add_crypto_position,
     7|    adjust_crypto_position,
     8|    current_wallet_asset_quantity,
     9|    transfer_crypto_position,
    10|    get_crypto_management_options,
    11|)
    12|from jarvis_finance.crypto.wallets import create_wallet, update_wallet, VALID_WALLET_TYPES
    13|from jarvis_finance.dashboard.components.tables import show_table
    14|from jarvis_finance.dashboard import data
    15|
    16|PAGE_TITLE = "Crypto Manage"
    17|ACTIONS = [
    18|    "Coin hinzufügen",
    19|    "Bestand korrigieren",
    20|    "Transfer zwischen Wallets",
    21|    "Bestand entfernen / auf 0 setzen",
    22|    "Neues Wallet",
    23|    "Verlauf ansehen",
    24|]
    25|
    26|
    27|def _wallet_options(options):
    28|    return {f"{w['wallet_name']} ({w['wallet_type']})": w["wallet_id"] for w in options["wallets"]}
    29|
    30|
    31|def _asset_options(options):
    32|    return {f"{a['coin_name']} / {a['symbol']}": a["asset_id"] for a in options["assets"]}
    33|
    34|
    35|def _holding_options(options):
    36|    return {f"{h['coin_name']} / {h['symbol']} @ {h['wallet_name']} — {h['quantity']}": (h["asset_id"], h["wallet_id"]) for h in options["holdings"]}
    37|
    38|
    39|def _set_action(st, action: str) -> None:
    40|    try:
    41|        st.session_state["crypto_manage_action"] = action
    42|    except Exception:
    43|        pass
    44|
    45|
    46|def _get_action(st) -> str | None:
    47|    try:
    48|        return st.session_state.get("crypto_manage_action")
    49|    except Exception:
    50|        return None
    51|
    52|
    53|def _render_action_cards(st) -> None:
    54|    st.subheader("Was möchten Sie tun?")
    55|    cols = st.columns(3)
    56|    descriptions = {
    57|        "Coin hinzufügen": "Neue oder bestehende Coin-Position auditierbar erfassen.",
    58|        "Bestand korrigieren": "Aktuellen Wallet-Bestand per manual_adjustment korrigieren.",
    59|        "Transfer zwischen Wallets": "Coin zwischen Wallets verschieben; Quelle und Ziel bleiben nachvollziehbar.",
    60|        "Bestand entfernen / auf 0 setzen": "Nichts löschen: Bestand auditierbar auf 0 setzen.",
    61|        "Neues Wallet": "Wallet-Container ohne Pflicht-Adresse anlegen.",
    62|        "Verlauf ansehen": "Transaktionen und Audit-Trail prüfen.",
    63|    }
    64|    for idx, action in enumerate(ACTIONS):
    65|        with cols[idx % 3]:
    66|            st.markdown(f"### {action}")
    67|            st.caption(descriptions[action])
    68|            if st.button(action, key=f"crypto_manage_action_{idx}"):
    69|                _set_action(st, action)
    70|
    71|
    72|def _render_wizard_header(st, action: str) -> None:
    73|    st.markdown(f"## {action}")
    74|    st.caption("Schrittfolge: 1 Was? → 2 Wo? → 3 Menge/Datum → 4 Review → 5 Bestätigen")
    75|
    76|
    77|def render(st, conn) -> None:
    78|    st.title("Crypto Manage")
    79|    st.caption("Action Cards statt Formular-Wüste. Formulare erscheinen erst nach Aktionsauswahl; alle Änderungen bleiben auditierbar.")
    80|    options = get_crypto_management_options(conn)
    81|    wallet_labels = _wallet_options(options)
    82|    asset_labels = _asset_options(options)
    83|    holding_labels = _holding_options(options)
    84|
    85|    _render_action_cards(st)
    86|    action = _get_action(st)
    87|    if not action:
    88|        st.info("Wählen Sie eine Aktion aus. Bis dahin wird nichts geändert und kein Formular geöffnet.")
    89|        return
    90|
    91|    _render_wizard_header(st, action)
    92|
    93|    if action == "Coin hinzufügen":
    94|        coin_name = st.text_input("Schritt 1 — Coin-Name")
    95|        symbol = st.text_input("Schritt 1 — Symbol")
    96|        coingecko_id = st.text_input("Schritt 1 — CoinGecko-ID optional")
    97|        wallet_label = st.selectbox("Schritt 2 — Wallet", list(wallet_labels)) if wallet_labels else None
    98|        qty = st.text_input("Schritt 3 — Menge als Decimal/Text")
    99|        effective = st.date_input("Schritt 3 — Datum", value=date.today())
   100|        op_type = st.selectbox("Schritt 3 — Typ", ["initial_snapshot_addition", "manual_adjustment"])
   101|        note = st.text_area("Schritt 4 — Review-Notiz")
   102|        st.write({"Coin": coin_name, "Symbol": symbol, "Wallet": wallet_label, "Menge": qty, "Typ": op_type, "Datum": str(effective)})
   103|        confirm = st.checkbox("Schritt 5 — Review bestätigt: Coin hinzufügen")
   104|        if st.button("Bestätigen und speichern"):
   105|            try:
   106|                result = add_crypto_position(conn, coin_name=coin_name, symbol=symbol, coingecko_id=coingecko_id or None, wallet_id=wallet_labels[wallet_label], quantity_text=qty, effective_date=str(effective), operation_type=op_type, note=note, confirm=confirm)
   107|                st.success(f"Gespeichert und auditiert: {result.transaction_id or result.holding_id}")
   108|            except Exception as exc:
   109|                st.error(str(exc))
   110|
   111|    elif action == "Bestand korrigieren":
   112|        holding_label = st.selectbox("Schritt 1/2 — Coin + Wallet", list(holding_labels)) if holding_labels else None
   113|        if holding_label:
   114|            asset_id, wallet_id = holding_labels[holding_label]
   115|            current = current_wallet_asset_quantity(conn, wallet_id=wallet_id, asset_id=asset_id)
   116|            st.metric("Aktuelle Menge", data.dec_text(current))
   117|            new_qty = st.text_input("Schritt 3 — Neue Menge als Decimal/Text")
   118|            note = st.text_area("Schritt 4 — Notiz Pflicht")
   119|            confirm = st.checkbox("Schritt 5 — Review bestätigt: Korrektur speichern")
   120|            if st.button("Korrektur speichern"):
   121|                try:
   122|                    result = adjust_crypto_position(conn, asset_id=asset_id, wallet_id=wallet_id, new_quantity_text=new_qty, effective_date=str(date.today()), note=note, confirm=confirm)
   123|                    st.success(f"Korrektur auditiert: {result.transaction_id}")
   124|                except Exception as exc:
   125|                    st.error(str(exc))
   126|
   127|    elif action == "Transfer zwischen Wallets":
   128|        asset_label = st.selectbox("Schritt 1 — Coin", list(asset_labels)) if asset_labels else None
   129|        from_label = st.selectbox("Schritt 2 — von Wallet", list(wallet_labels)) if wallet_labels else None
   130|        to_label = st.selectbox("Schritt 2 — zu Wallet", list(wallet_labels)) if wallet_labels else None
   131|        qty = st.text_input("Schritt 3 — Menge")
   132|        fee = st.text_input("Schritt 3 — Fee optional", value="0")
   133|        note = st.text_area("Schritt 4 — Notiz optional")
   134|        confirm = st.checkbox("Schritt 5 — Review bestätigt: Transfer speichern")
   135|        if st.button("Transfer speichern"):
   136|            try:
   137|                result = transfer_crypto_position(conn, asset_id=asset_labels[asset_label], from_wallet_id=wallet_labels[from_label], to_wallet_id=wallet_labels[to_label], quantity_text=qty, fee_quantity_text=fee, effective_date=str(date.today()), note=note, confirm=confirm)
   138|                st.success(f"Transfer auditiert: {result.transaction_id}")
   139|            except Exception as exc:
   140|                st.error(str(exc))
   141|
   142|    elif action == "Bestand entfernen / auf 0 setzen":
   143|        st.warning("Es wird nichts gelöscht. Der Bestand wird via manual_adjustment auditierbar auf 0 gesetzt.")
   144|        holding_label = st.selectbox("Schritt 1/2 — Coin + Wallet", list(holding_labels)) if holding_labels else None
   145|        note = st.text_area("Schritt 4 — Notiz Pflicht")
   146|        confirm = st.checkbox("Schritt 5 — Review bestätigt: auf 0 setzen")
   147|        if st.button("Bestand auf 0 setzen") and holding_label:
   148|            try:
   149|                asset_id, wallet_id = holding_labels[holding_label]
   150|                result = adjust_crypto_position(conn, asset_id=asset_id, wallet_id=wallet_id, new_quantity_text="0", effective_date=str(date.today()), note=note, confirm=confirm)
   151|                st.success(f"Bestand auf 0 gesetzt und auditiert: {result.transaction_id}")
   152|            except Exception as exc:
   153|                st.error(str(exc))
   154|
   155|    elif action == "Neues Wallet":
   156|        name = st.text_input("Schritt 1 — Wallet-Name")
   157|        wallet_type = st.selectbox("Schritt 1 — Wallet-Typ", sorted(VALID_WALLET_TYPES))
   158|        provider = st.text_input("Schritt 2 — Provider")
   159|        chain = st.text_input("Schritt 2 — Chain/Network optional")
   160|        note = st.text_area("Schritt 4 — Notiz optional")
   161|        active = st.checkbox("Schritt 4 — active", value=True)
   162|        confirm = st.checkbox("Schritt 5 — Review bestätigt: Wallet erstellen")
   163|        if st.button("Wallet speichern"):
   164|            try:
   165|                if not confirm:
   166|                    raise ValueError("explicit confirm is required before saving")
   167|                wallet_id = create_wallet(conn, wallet_name=name, wallet_type=wallet_type, platform_provider=provider or None, network_chain=chain or None, notes=note or None)
   168|                if not active:
   169|                    update_wallet(conn, wallet_id, is_active=0)
   170|                st.success("Wallet auditiert erstellt.")
   171|            except Exception as exc:
   172|                st.error(str(exc))
   173|
   174|    elif action == "Verlauf ansehen":
   175|        show_table(st, data.get_latest_crypto_transactions(conn))
   176|        with st.expander("Technische Audit-Details anzeigen"):
   177|            show_table(st, data.get_audit_events(conn, limit=50))
   178|


Explanation:
**Considering report export options**

I need to implement a robust report export feature. Maybe having a price button is a good idea, and I could even think about a CLI fallback or service as an alternative. I'm wondering if I should also search for cryptocurrency prices through the CLI, but that might not be necessary. I want to ensure everything works smoothly and meets user needs while exploring all possible options!


     1|from __future__ import annotations
     2|
     3|from dataclasses import dataclass, field
     4|from decimal import Decimal
     5|from sqlite3 import Connection
     6|from typing import Protocol
     7|
     8|from jarvis_finance.imports.common import stable_id, utc_now
     9|from jarvis_finance.market_data.instruments import resolve_instrument_alerts, update_instrument_metadata
    10|from jarvis_finance.quality.alerts import create_alert
    11|
    12|
    13|@dataclass(frozen=True)
    14|class EquityPriceQuote:
    15|    provider_symbol: str
    16|    currency: str
    17|    close: Decimal | None
    18|    provider: str = "mock"
    19|    provider_market: str | None = None
    20|    price_timestamp: str | None = None
    21|    adjusted_close: Decimal | None = None
    22|    quality_status: str = "fresh"
    23|    error_message: str | None = None
    24|
    25|
    26|@dataclass
    27|class MarketPriceRefreshResult:
    28|    asset_class: str
    29|    total_mappings: int = 0
    30|    updated_count: int = 0
    31|    skipped_count: int = 0
    32|    excluded_count: int = 0
    33|    cached_count: int = 0
    34|    stale_count: int = 0
    35|    warning_count: int = 0
    36|    error_count: int = 0
    37|    dry_run: bool = False
    38|    warnings: list[str] = field(default_factory=list)
    39|    errors: list[str] = field(default_factory=list)
    40|
    41|
    42|class EquityPriceProvider(Protocol):
    43|    name: str
    44|    def get_price(self, provider_symbol: str, *, price_date: str | None = None) -> EquityPriceQuote: ...
    45|
    46|
    47|class MockEquityPriceProvider:
    48|    name = "mock"
    49|
    50|    def __init__(self, prices: dict[str, Decimal | str | None], *, currency: str = "USD", timestamps: dict[str, str] | None = None) -> None:
    51|        self.prices = {k: (Decimal(str(v)) if v is not None else None) for k, v in prices.items()}
    52|        self.currency = currency.upper()
    53|        self.timestamps = timestamps or {}
    54|
    55|    def get_price(self, provider_symbol: str, *, price_date: str | None = None) -> EquityPriceQuote:
    56|        if provider_symbol not in self.prices or self.prices[provider_symbol] is None:
    57|            return EquityPriceQuote(provider_symbol=provider_symbol, currency=self.currency, close=None, quality_status="missing", error_message="price missing", provider=self.name)
    58|        return EquityPriceQuote(provider_symbol=provider_symbol, currency=self.currency, close=self.prices[provider_symbol], price_timestamp=self.timestamps.get(provider_symbol), provider=self.name)
    59|
    60|
    61|def _resolve_market_alerts(conn: Connection, *, instrument_id: str) -> int:
    62|    return resolve_instrument_alerts(conn, instrument_id=instrument_id, rule_ids=["missing_market_price", "stale_market_price"])
    63|
    64|
    65|def _previous_price(conn: Connection, *, instrument_id: str, price_date: str, provider: str):
    66|    return conn.execute(
    67|        """
    68|        SELECT * FROM market_prices
    69|        WHERE instrument_id=? AND provider=? AND price_date<? AND close IS NOT NULL AND close!=''
    70|        ORDER BY price_date DESC, created_at DESC LIMIT 1
    71|        """,
    72|        (instrument_id, provider, price_date),
    73|    ).fetchone()
    74|
    75|
    76|def _corporate_action_status(conn: Connection, *, instrument_id: str, price_date: str, provider: str, close: Decimal | None) -> str:
    77|    if close is None or close <= 0:
    78|        return "not_checked"
    79|    prev = _previous_price(conn, instrument_id=instrument_id, price_date=price_date, provider=provider)
    80|    if not prev or not prev["close"]:
    81|        return "not_checked"
    82|    old = Decimal(str(prev["close"]))
    83|    if old <= 0:
    84|        return "not_checked"
    85|    change = abs((close - old) / old)
    86|    if change > Decimal("0.25"):
    87|        create_alert(conn, priority="warnung", category="market_data", entity_type="instrument", entity_id=instrument_id, rule_id="corporate_action_suspected", message="Large local price move detected; corporate action review required before high-confidence valuation.", evidence={"previous_price_date": prev["price_date"], "price_date": price_date}, fingerprint="corporate_action_suspected")
    88|        create_alert(conn, priority="warnung", category="market_data", entity_type="instrument", entity_id=instrument_id, rule_id="split_or_corporate_action_review_required", message="Split/corporate-action review required; no automatic split correction is applied.", evidence={"previous_price_date": prev["price_date"], "price_date": price_date}, fingerprint="split_or_corporate_action_review_required")
    89|        update_instrument_metadata(conn, instrument_id=instrument_id, corporate_action_status="suspected", split_or_corporate_action_review_required=True, note="Automatic C2 heuristic detected >25% local price move; review required.")
    90|        return "suspected"
    91|    return "none_known"
    92|
    93|
    94|def store_market_price(
    95|    conn: Connection,
    96|    *,
    97|    instrument_id: str,
    98|    price_date: str,
    99|    close: Decimal | None,
   100|    currency: str,
   101|    provider: str,
   102|    provider_symbol: str | None,
   103|    provider_market: str | None = None,
   104|    price_timestamp: str | None = None,
   105|    adjusted_close: Decimal | None = None,
   106|    quality_status: str = "fresh",
   107|    error_message: str | None = None,
   108|) -> str:
   109|    now = utc_now()
   110|    corp_status = _corporate_action_status(conn, instrument_id=instrument_id, price_date=price_date, provider=provider, close=close) if quality_status == "fresh" else "not_checked"
   111|    market_price_id = stable_id("marketprice", instrument_id, price_date, provider, provider_symbol or "", now)
   112|    conn.execute(
   113|        """
   114|        INSERT INTO market_prices(
   115|            market_price_id, instrument_id, price_date, price_timestamp, close, adjusted_close,
   116|            currency, provider, provider_symbol, quality_status, created_at, provider_market, error_message,
   117|            corporate_action_status
   118|        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
   119|        ON CONFLICT(instrument_id, price_date, provider) DO UPDATE SET
   120|            price_timestamp=excluded.price_timestamp,
   121|            close=excluded.close,
   122|            adjusted_close=excluded.adjusted_close,
   123|            currency=excluded.currency,
   124|            provider_symbol=excluded.provider_symbol,
   125|            quality_status=excluded.quality_status,
   126|            created_at=excluded.created_at,
   127|            provider_market=excluded.provider_market,
   128|            error_message=excluded.error_message,
   129|            corporate_action_status=excluded.corporate_action_status
   130|        """,
   131|        (market_price_id, instrument_id, price_date, price_timestamp, format(close, "f") if close is not None else "", format(adjusted_close, "f") if adjusted_close is not None else None, currency.upper(), provider, provider_symbol, quality_status, now, provider_market, error_message, corp_status),
   132|    )
   133|    if quality_status in {"missing", "stale", "error", "conflict"} or close is None:
   134|        rule = "stale_market_price" if quality_status == "stale" else "missing_market_price"
   135|        create_alert(conn, priority="warnung", category="market_data", entity_type="instrument", entity_id=instrument_id, rule_id=rule, message="Instrument local market price is not fresh.", evidence={"provider": provider, "provider_symbol": provider_symbol, "price_date": price_date, "quality_status": quality_status}, fingerprint=f"{rule}:{provider}:{provider_symbol}")
   136|    elif quality_status == "fresh":
   137|        _resolve_market_alerts(conn, instrument_id=instrument_id)
   138|    conn.commit()
   139|    return market_price_id
   140|
   141|
   142|def refresh_market_prices(
   143|    conn: Connection,
   144|    *,
   145|    provider: EquityPriceProvider,
   146|    asset_class: str,
   147|    price_date: str | None = None,
   148|    only_missing: bool = False,
   149|    only_stale: bool = False,
   150|    only_isin: str | None = None,
   151|    limit: int | None = None,
   152|    dry_run: bool = False,
   153|) -> MarketPriceRefreshResult:
   154|    asset = asset_class.lower()
   155|    result = MarketPriceRefreshResult(asset_class=asset, dry_run=dry_run)
   156|    query = """
   157|        SELECT m.*, i.asset_class, i.isin, i.instrument_status AS current_instrument_status, i.valuation_policy AS current_valuation_policy FROM instrument_price_mappings m
   158|        JOIN instruments i ON i.instrument_id=m.instrument_id
   159|        WHERE LOWER(i.asset_class)=? AND m.mapping_status='mapped' AND m.provider_symbol IS NOT NULL
   160|        ORDER BY i.isin, m.provider_symbol
   161|    """
   162|    mappings = conn.execute(query, (asset,)).fetchall()
   163|    if only_isin:
   164|        mappings = [m for m in mappings if (m["isin"] or "").upper() == only_isin.upper()]
   165|    if limit is not None:
   166|        mappings = mappings[:limit]
   167|    result.total_mappings = len(mappings)
   168|    effective_date = price_date or utc_now()[:10]
   169|    for mapping in mappings:
   170|        if mapping["current_instrument_status"] in {"delisted", "suspended", "merged", "inactive"} or mapping["current_valuation_policy"] == "exclude_from_auto_price_update":
   171|            result.excluded_count += 1
   172|            result.skipped_count += 1
   173|            if not dry_run:
   174|                create_alert(conn, priority="warnung", category="market_data", entity_type="instrument", entity_id=mapping["instrument_id"], rule_id="delisted_or_suspended", message="Instrument excluded from automatic market-price update.", evidence={"instrument_status": mapping["current_instrument_status"], "valuation_policy": mapping["current_valuation_policy"]}, fingerprint=f"excluded:{mapping['current_instrument_status']}:{mapping['current_valuation_policy']}")
   175|            continue
   176|        latest = conn.execute("SELECT * FROM market_prices WHERE instrument_id=? AND quality_status='fresh' ORDER BY price_date DESC, created_at DESC LIMIT 1", (mapping["instrument_id"],)).fetchone()
   177|        if only_missing and latest:
   178|            result.cached_count += 1
   179|            continue
   180|        if only_stale and latest and latest["price_date"] >= effective_date:
   181|            result.cached_count += 1
   182|            continue
   183|        quote = provider.get_price(mapping["provider_symbol"], price_date=effective_date)
   184|        status = quote.quality_status
   185|        if not dry_run:
   186|            store_market_price(conn, instrument_id=mapping["instrument_id"], price_date=effective_date, close=quote.close, currency=quote.currency or mapping["currency"], provider=quote.provider or provider.name, provider_symbol=mapping["provider_symbol"], provider_market=mapping["provider_market"], price_timestamp=quote.price_timestamp, adjusted_close=quote.adjusted_close, quality_status=status, error_message=quote.error_message)
   187|        if quote.close is not None and status == "fresh":
   188|            result.updated_count += 1
   189|        elif status in {"missing", "stale"}:
   190|            result.warning_count += 1
   191|            result.warnings.append(mapping["instrument_id"])
   192|            if status == "stale":
   193|                result.stale_count += 1
   194|            if status == "missing" and not dry_run:
   195|                update_instrument_metadata(conn, instrument_id=mapping["instrument_id"], instrument_status="suspected_inactive", note="Provider returned missing price; suspected inactive requires manual review.")
   196|        else:
   197|            result.error_count += 1
   198|            result.errors.append(mapping["instrument_id"])
   199|    if not dry_run:
   200|        conn.commit()
   201|    return result
   202|


Explanation:
**Searching for crypto price service**

I think I need a crypto price service, but it seems it might not be in the src folder since I couldn't find it on the previous commit. When searching for "coingecko," I'm realizing it might have failed due to case sensitivity or being excluded by the path settings. I'll need to figure out how to search through all the files efficiently to see if I can find it.


     1|from __future__ import annotations
     2|
     3|import urllib.error
     4|from datetime import datetime, timedelta, timezone
     5|from decimal import Decimal
     6|
     7|from jarvis_finance.cli.main import main
     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 import data
    12|from jarvis_finance.market.providers import CoinGeckoClient, PriceQuote, refresh_crypto_prices
    13|from jarvis_finance.reports.crypto_inventory import build_crypto_report_context
    14|from jarvis_finance.storage.database import connect_memory
    15|from jarvis_finance.storage.migrations import apply_migrations
    16|
    17|
    18|class MockProvider:
    19|    def __init__(self) -> None:
    20|        self.calls: list[tuple[str, str]] = []
    21|        self.batch_calls: list[tuple[tuple[str, ...], str]] = []
    22|
    23|    def get_crypto_price(self, coingecko_id: str, currency: str = "CHF") -> PriceQuote:
    24|        self.calls.append((coingecko_id, currency))
    25|        return PriceQuote(coingecko_id=coingecko_id, currency=currency, price=Decimal("123.456789"), provider="MockGecko", provider_timestamp="2026-01-31T00:00:00+00:00")
    26|
    27|    def get_crypto_prices(self, coingecko_ids, currency: str = "CHF"):
    28|        self.batch_calls.append((tuple(coingecko_ids), currency))
    29|        return {coingecko_id: self.get_crypto_price(coingecko_id, currency) for coingecko_id in coingecko_ids}
    30|
    31|
    32|def setup_conn():
    33|    conn = connect_memory()
    34|    apply_migrations(conn)
    35|    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")
    36|    btc_id = create_crypto_asset(conn, coin_name="Bitcoin", symbol="BTC", coingecko_id="bitcoin")
    37|    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")
    38|    missing_id = create_crypto_asset(conn, coin_name="Missing Coin", symbol="MISS", coingecko_id=None)
    39|    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")
    40|    return conn, btc_id, missing_id
    41|
    42|
    43|def test_refresh_crypto_prices_loads_active_assets_and_stores_chf_price() -> None:
    44|    conn, btc_id, _ = setup_conn()
    45|    provider = MockProvider()
    46|
    47|    result = refresh_crypto_prices(conn, provider=provider, currency="CHF", max_age_seconds=0)
    48|
    49|    assert result.success_count == 1
    50|    assert result.skipped_count == 1
    51|    assert result.warning_count == 1
    52|    assert result.error_count == 0
    53|    assert provider.batch_calls == [(("bitcoin",), "CHF")]
    54|    assert provider.calls == [("bitcoin", "CHF")]
    55|    row = conn.execute("SELECT * FROM crypto_prices WHERE asset_id=?", (btc_id,)).fetchone()
    56|    assert row is not None
    57|    assert row["price"] == "123.456789"
    58|    assert row["price_currency"] == "CHF"
    59|    assert row["provider"] == "MockGecko"
    60|
    61|
    62|def test_missing_coingecko_id_is_skipped_and_warned_without_duplicate_alerts() -> None:
    63|    conn, _, missing_id = setup_conn()
    64|    provider = MockProvider()
    65|
    66|    first = refresh_crypto_prices(conn, provider=provider, currency="CHF", max_age_seconds=0)
    67|    second = refresh_crypto_prices(conn, provider=provider, currency="CHF", max_age_seconds=0)
    68|
    69|    assert first.skipped_count == 1
    70|    assert second.skipped_count == 1
    71|    alerts = conn.execute("SELECT * FROM alerts WHERE entity_id=? AND rule_id='missing_coingecko_id'", (missing_id,)).fetchall()
    72|    assert len(alerts) == 1
    73|    assert alerts[0]["occurrence_count"] >= 2
    74|
    75|
    76|def test_429_backoff_is_used_by_provider() -> None:
    77|    calls = {"n": 0}
    78|    sleeps: list[float] = []
    79|
    80|    def opener(url, timeout=20):
    81|        calls["n"] += 1
    82|        if calls["n"] == 1:
    83|            raise urllib.error.HTTPError(url, 429, "rate limited", None, None)
    84|        class Resp:
    85|            def __enter__(self): return self
    86|            def __exit__(self, *args): return False
    87|            def read(self): return b'{"bitcoin":{"chf":"12.34","last_updated_at":1700000000}}'
    88|        return Resp()
    89|
    90|    client = CoinGeckoClient(max_retries=2, initial_backoff_seconds=0.25, max_backoff_seconds=1, opener=opener, sleeper=sleeps.append)
    91|
    92|    quote = client.get_crypto_price("bitcoin", "CHF")
    93|
    94|    assert quote.price == Decimal("12.34")
    95|    assert sleeps == [0.25]
    96|    assert calls["n"] == 2
    97|
    98|
    99|def test_fresh_cache_prevents_refetch_and_stale_price_is_marked() -> None:
   100|    conn, btc_id, _ = setup_conn()
   101|    provider = MockProvider()
   102|    first = refresh_crypto_prices(conn, provider=provider, currency="CHF", max_age_seconds=3600)
   103|    second = refresh_crypto_prices(conn, provider=provider, currency="CHF", max_age_seconds=3600)
   104|    assert first.success_count == 1
   105|    assert second.cached_count == 1
   106|    assert provider.calls == [("bitcoin", "CHF")]
   107|
   108|    old = (datetime.now(timezone.utc) - timedelta(days=10)).isoformat()
   109|    conn.execute("UPDATE crypto_prices SET fetched_at=?, provider_timestamp=? WHERE asset_id=?", (old, old, btc_id))
   110|    conn.commit()
   111|
   112|    overview = data.get_crypto_overview(conn, price_max_age_seconds=3600)
   113|    btc = next(row for row in overview if row["symbol"] == "BTC")
   114|    assert btc["price_quality"] == "stale"
   115|    context = build_crypto_report_context(conn, price_max_age_seconds=3600)
   116|    assert any(w["code"] == "stale_price" and w["entity_id"] == btc_id for w in context["data_quality_warnings"])
   117|
   118|
   119|def test_dashboard_reads_local_prices_without_provider_api_call() -> None:
   120|    conn, btc_id, _ = setup_conn()
   121|    refresh_crypto_prices(conn, provider=MockProvider(), currency="CHF", max_age_seconds=0)
   122|
   123|    overview = data.get_crypto_overview(conn)
   124|    by_wallet = data.get_crypto_by_wallet(conn)
   125|    summary = data.get_command_center_summary(conn)
   126|
   127|    btc = next(row for row in overview if row["symbol"] == "BTC")
   128|    assert btc["latest_price_chf"] == "123.46"
   129|    assert btc["market_value_chf"]
   130|    assert any(row["wallet_value_chf"] for row in by_wallet if row["symbol"] == "BTC")
   131|    assert summary["crypto_total_chf"] != "0.00"
   132|    assert summary["crypto_price_data_as_of"]
   133|
   134|
   135|def test_crypto_report_uses_local_prices_and_improves_missing_price_status() -> None:
   136|    conn, btc_id, _ = setup_conn()
   137|    before = build_crypto_report_context(conn)
   138|    assert any(w["code"] == "missing_price" and w["entity_id"] == btc_id for w in before["data_quality_warnings"])
   139|
   140|    refresh_crypto_prices(conn, provider=MockProvider(), currency="CHF", max_age_seconds=0)
   141|    after = build_crypto_report_context(conn)
   142|
   143|    btc = next(row for row in after["coins"] if row["symbol"] == "BTC")
   144|    assert btc["price_chf"] == "123.456789"
   145|    assert btc["value_chf"]
   146|    assert not any(w["code"] == "missing_price" and w["entity_id"] == btc_id for w in after["data_quality_warnings"])
   147|    assert after["metadata"]["live_api_calls"] is False
   148|
   149|
   150|def test_cli_update_crypto_prices_command_uses_provider_factory(monkeypatch, tmp_path, capsys) -> None:
   151|    runtime = tmp_path / "runtime"
   152|    monkeypatch.setenv("JARVIS_FINANCE_RUNTIME_DIR", str(runtime))
   153|    conn = connect_memory()
   154|    # Build a runtime file DB via CLI settings by seeding after opening path.
   155|    from jarvis_finance.storage.database import connect
   156|    db = runtime / "data" / "finance.sqlite3"
   157|    db.parent.mkdir(parents=True)
   158|    file_conn = connect(db)
   159|    apply_migrations(file_conn)
   160|    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")
   161|    asset_id = create_crypto_asset(file_conn, coin_name="Bitcoin", symbol="BTC", coingecko_id="bitcoin")
   162|    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")
   163|    file_conn.close()
   164|
   165|    monkeypatch.setattr("jarvis_finance.cli.main.CoinGeckoClient", lambda **kwargs: MockProvider())
   166|
   167|    rc = main(["update-crypto-prices", "--max-age-seconds", "0"])
   168|
   169|    out = capsys.readouterr().out
   170|    assert rc == 0
   171|    assert "CRYPTO_PRICE_UPDATE" in out
   172|    assert "updated=1" in out
   173|    assert "assets_still_missing_local_price=0" in out
   174|    conn.close()
   175|
   176|
   177|def _add_asset_with_holding(conn, wallet_id: str, *, coin_name: str, symbol: str, coingecko_id: str):
   178|    asset_id = create_crypto_asset(conn, coin_name=coin_name, symbol=symbol, coingecko_id=coingecko_id)
   179|    create_initial_holding_snapshot(conn, asset_id=asset_id, wallet_id=wallet_id, quantity=Decimal("2"), verification_status="verified", note="synthetic fixture", last_verified_at="2026-01-31T00:00:00+00:00")
   180|    return asset_id
   181|
   182|
   183|def test_batch_request_updates_multiple_assets_in_one_provider_call() -> None:
   184|    conn, btc_id, _ = setup_conn()
   185|    wallet_id = conn.execute("SELECT wallet_id FROM crypto_wallets LIMIT 1").fetchone()["wallet_id"]
   186|    eth_id = _add_asset_with_holding(conn, wallet_id, coin_name="Ethereum", symbol="ETH", coingecko_id="ethereum")
   187|    provider = MockProvider()
   188|
   189|    result = refresh_crypto_prices(conn, provider=provider, currency="CHF", max_age_seconds=0)
   190|
   191|    assert result.updated_count == 2
   192|    assert provider.batch_calls == [(("bitcoin", "ethereum"), "CHF")]
   193|    assert conn.execute("SELECT COUNT(*) AS n FROM crypto_prices WHERE asset_id IN (?, ?)", (btc_id, eth_id)).fetchone()["n"] == 2
   194|
   195|
   196|def test_only_missing_refreshes_assets_without_any_fresh_price() -> None:
   197|    conn, btc_id, _ = setup_conn()
   198|    wallet_id = conn.execute("SELECT wallet_id FROM crypto_wallets LIMIT 1").fetchone()["wallet_id"]
   199|    _add_asset_with_holding(conn, wallet_id, coin_name="Ethereum", symbol="ETH", coingecko_id="ethereum")
   200|    refresh_crypto_prices(conn, provider=MockProvider(), currency="CHF", max_age_seconds=3600, only_symbol="BTC")
   201|    provider = MockProvider()
   202|
   203|    result = refresh_crypto_prices(conn, provider=provider, currency="CHF", max_age_seconds=3600, only_missing=True)
   204|
   205|    assert result.updated_count == 1
   206|    assert provider.batch_calls == [(("ethereum",), "CHF")]
   207|    assert conn.execute("SELECT COUNT(*) AS n FROM crypto_prices WHERE asset_id=?", (btc_id,)).fetchone()["n"] == 1
   208|
   209|
   210|def test_only_stale_refreshes_only_stale_local_prices() -> None:
   211|    conn, btc_id, _ = setup_conn()
   212|    wallet_id = conn.execute("SELECT wallet_id FROM crypto_wallets LIMIT 1").fetchone()["wallet_id"]
   213|    _add_asset_with_holding(conn, wallet_id, coin_name="Ethereum", symbol="ETH", coingecko_id="ethereum")
   214|    refresh_crypto_prices(conn, provider=MockProvider(), currency="CHF", max_age_seconds=3600)
   215|    old = (datetime.now(timezone.utc) - timedelta(days=10)).isoformat()
   216|    conn.execute("UPDATE crypto_prices SET fetched_at=?, provider_timestamp=? WHERE asset_id=?", (old, old, btc_id))
   217|    conn.commit()
   218|    provider = MockProvider()
   219|
   220|    result = refresh_crypto_prices(conn, provider=provider, currency="CHF", max_age_seconds=3600, only_stale=True)
   221|
   222|    assert result.updated_count == 1
   223|    assert provider.batch_calls == [(("bitcoin",), "CHF")]
   224|
   225|
   226|def test_only_symbol_and_limit_constrain_refresh_scope() -> None:
   227|    conn, _, _ = setup_conn()
   228|    wallet_id = conn.execute("SELECT wallet_id FROM crypto_wallets LIMIT 1").fetchone()["wallet_id"]
   229|    _add_asset_with_holding(conn, wallet_id, coin_name="Ethereum", symbol="ETH", coingecko_id="ethereum")
   230|    _add_asset_with_holding(conn, wallet_id, coin_name="Solana", symbol="SOL", coingecko_id="solana")
   231|    provider = MockProvider()
   232|
   233|    symbol_result = refresh_crypto_prices(conn, provider=provider, currency="CHF", max_age_seconds=0, only_symbol="ETH")
   234|    assert symbol_result.updated_count == 1
   235|    assert provider.batch_calls == [(("ethereum",), "CHF")]
   236|
   237|    provider2 = MockProvider()
   238|    limit_result = refresh_crypto_prices(conn, provider=provider2, currency="CHF", max_age_seconds=0, limit=1)
   239|    assert limit_result.updated_count == 1
   240|    assert len(provider2.batch_calls[0][0]) == 1
   241|
   242|
   243|def test_dry_run_does_not_write_prices_or_alerts() -> None:
   244|    conn, btc_id, missing_id = setup_conn()
   245|    alerts_before = conn.execute("SELECT COUNT(*) AS n FROM alerts WHERE entity_id IN (?, ?)", (btc_id, missing_id)).fetchone()["n"]
   246|
   247|    result = refresh_crypto_prices(conn, provider=MockProvider(), currency="CHF", max_age_seconds=0, dry_run=True)
   248|
   249|    assert result.dry_run is True
   250|    assert result.updated_count == 1
   251|    assert result.missing_local_price_count == 2
   252|    assert conn.execute("SELECT COUNT(*) AS n FROM crypto_prices").fetchone()["n"] == 0
   253|    assert conn.execute("SELECT COUNT(*) AS n FROM alerts WHERE entity_id IN (?, ?)", (btc_id, missing_id)).fetchone()["n"] == alerts_before
   254|
   255|
   256|def test_rate_limit_after_retries_marks_batch_stale_and_backoff_is_capped() -> None:
   257|    sleeps: list[float] = []
   258|    calls = {"n": 0}
   259|
   260|    def opener(url, timeout=20):
   261|        calls["n"] += 1
   262|        raise urllib.error.HTTPError(url, 429, "rate limited", None, None)
   263|
   264|    client = CoinGeckoClient(max_retries=2, initial_backoff_seconds=0.5, max_backoff_seconds=0.75, opener=opener, sleeper=sleeps.append)
   265|
   266|    quotes = client.get_crypto_prices(["bitcoin", "ethereum"], "CHF")
   267|
   268|    assert calls["n"] == 3
   269|    assert sleeps == [0.5, 0.75]
   270|    assert {quote.quality_status for quote in quotes.values()} == {"stale"}
   271|
   272|
   273|def test_missing_local_price_alert_is_deduped_for_failed_refreshes() -> None:
   274|    class MissingProvider:
   275|        def get_crypto_prices(self, coingecko_ids, currency="CHF"):
   276|            return {cid: PriceQuote(cid, currency, None, quality_status="missing", error_message="not listed") for cid in coingecko_ids}
   277|
   278|    conn, btc_id, _ = setup_conn()
   279|    refresh_crypto_prices(conn, provider=MissingProvider(), currency="CHF", max_age_seconds=0, only_symbol="BTC")
   280|    refresh_crypto_prices(conn, provider=MissingProvider(), currency="CHF", max_age_seconds=0, only_symbol="BTC")
   281|
   282|    alerts = conn.execute("SELECT * FROM alerts WHERE entity_id=? AND rule_id='crypto_price_missing_local'", (btc_id,)).fetchall()
   283|    assert len(alerts) == 1
   284|    assert alerts[0]["occurrence_count"] >= 2
   285|


Explanation:
**Executing price update**

I need to get the PriceUpdate done. I want to create a page price button using the command line interface or service. My focus is on utilizing market.providers for this task. It should help with fetching or updating the price information effectively. I’m considering the steps involved to ensure I do it the right way and everything functions smoothly! Let’s keep this neat and efficient while I work through it.


     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, field
     9|from datetime import datetime, timezone
    10|from decimal import Decimal, InvalidOperation
    11|from sqlite3 import Connection
    12|from typing import Protocol, Sequence
    13|
    14|from jarvis_finance.imports.common import stable_id, utc_now
    15|from jarvis_finance.quality.alerts import create_alert
    16|from jarvis_finance.quality.data_quality import resolve_fixed_crypto_price_alerts
    17|
    18|
    19|@dataclass(frozen=True)
    20|class PriceQuote:
    21|    coingecko_id: str
    22|    currency: str
    23|    price: Decimal | None
    24|    provider: str = "CoinGecko"
    25|    provider_timestamp: str | None = None
    26|    quality_status: str = "fresh"
    27|    error_message: str | None = None
    28|
    29|
    30|@dataclass
    31|class PriceRefreshResult:
    32|    currency: str
    33|    total_assets: int = 0
    34|    updated_count: int = 0
    35|    skipped_count: int = 0
    36|    cached_count: int = 0
    37|    stale_count: int = 0
    38|    warning_count: int = 0
    39|    error_count: int = 0
    40|    missing_local_price_count: int = 0
    41|    dry_run: bool = False
    42|    written_price_ids: list[str] = field(default_factory=list)
    43|    warnings: list[str] = field(default_factory=list)
    44|    errors: list[str] = field(default_factory=list)
    45|
    46|    @property
    47|    def success_count(self) -> int:  # backward-compatible name used by older tests/callers
    48|        return self.updated_count
    49|
    50|
    51|class MarketDataProvider(Protocol):
    52|    def get_crypto_price(self, coingecko_id: str, currency: str = "CHF") -> PriceQuote: ...
    53|
    54|
    55|class BatchMarketDataProvider(MarketDataProvider, Protocol):
    56|    def get_crypto_prices(self, coingecko_ids: Sequence[str], currency: str = "CHF") -> dict[str, PriceQuote]: ...
    57|
    58|
    59|class ProviderRateLimitError(RuntimeError):
    60|    pass
    61|
    62|
    63|class ProviderFetchError(RuntimeError):
    64|    pass
    65|
    66|
    67|class CoinGeckoClient:
    68|    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:
    69|        self.base_url = base_url.rstrip("/")
    70|        self.max_retries = max_retries
    71|        self.initial_backoff_seconds = initial_backoff_seconds
    72|        self.max_backoff_seconds = max_backoff_seconds
    73|        self.opener = opener or urllib.request.urlopen
    74|        self.sleeper = sleeper
    75|
    76|    def get_crypto_price(self, coingecko_id: str, currency: str = "CHF") -> PriceQuote:
    77|        return self.get_crypto_prices([coingecko_id], currency).get(
    78|            coingecko_id,
    79|            PriceQuote(coingecko_id, currency.upper(), None, quality_status="missing", error_message="price missing"),
    80|        )
    81|
    82|    def get_crypto_prices(self, coingecko_ids: Sequence[str], currency: str = "CHF") -> dict[str, PriceQuote]:
    83|        ids = [cid for cid in dict.fromkeys(coingecko_ids) if cid]
    84|        cur = currency.lower()
    85|        if not ids:
    86|            return {}
    87|        query = urllib.parse.urlencode({"ids": ",".join(ids), "vs_currencies": cur, "include_last_updated_at": "true"})
    88|        url = f"{self.base_url}/simple/price?{query}"
    89|        delay = self.initial_backoff_seconds
    90|        last_error: str | None = None
    91|        for attempt in range(self.max_retries + 1):
    92|            try:
    93|                with self.opener(url, timeout=20) as resp:
    94|                    payload = json.loads(resp.read().decode("utf-8"))
    95|                return {coingecko_id: self._quote_from_payload(coingecko_id, currency, payload.get(coingecko_id) or {}) for coingecko_id in ids}
    96|            except urllib.error.HTTPError as exc:
    97|                last_error = f"HTTP {exc.code}"
    98|                if exc.code == 429 and attempt < self.max_retries:
    99|                    self.sleeper(min(delay, self.max_backoff_seconds))
   100|                    delay *= 2
   101|                    continue
   102|                status = "stale" if exc.code == 429 else "error"
   103|                return {cid: PriceQuote(cid, currency.upper(), None, quality_status=status, error_message=last_error) for cid in ids}
   104|            except Exception as exc:  # network skeleton must not crash dashboard jobs
   105|                last_error = str(exc)
   106|                if attempt < self.max_retries:
   107|                    self.sleeper(min(delay, self.max_backoff_seconds))
   108|                    delay *= 2
   109|                    continue
   110|                return {cid: PriceQuote(cid, currency.upper(), None, quality_status="error", error_message=last_error) for cid in ids}
   111|        return {cid: PriceQuote(cid, currency.upper(), None, quality_status="error", error_message=last_error) for cid in ids}
   112|
   113|    @staticmethod
   114|    def _quote_from_payload(coingecko_id: str, currency: str, data: dict) -> PriceQuote:
   115|        cur = currency.lower()
   116|        value = data.get(cur)
   117|        if value is None:
   118|            return PriceQuote(coingecko_id, currency.upper(), None, quality_status="missing", error_message="price missing")
   119|        try:
   120|            price = Decimal(str(value))
   121|        except InvalidOperation:
   122|            return PriceQuote(coingecko_id, currency.upper(), None, quality_status="error", error_message="invalid price")
   123|        ts = data.get("last_updated_at")
   124|        provider_ts = None
   125|        if ts:
   126|            try:
   127|                provider_ts = datetime.fromtimestamp(int(ts), tz=timezone.utc).isoformat()
   128|            except (TypeError, ValueError):
   129|                provider_ts = str(ts)
   130|        return PriceQuote(coingecko_id, currency.upper(), price, provider_timestamp=provider_ts)
   131|
   132|
   133|def _parse_dt(value: str | None) -> datetime | None:
   134|    if not value:
   135|        return None
   136|    try:
   137|        dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
   138|    except ValueError:
   139|        return None
   140|    return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
   141|
   142|
   143|def is_price_stale(timestamp: str | None, *, max_age_seconds: int = 86_400) -> bool:
   144|    dt = _parse_dt(timestamp)
   145|    if dt is None:
   146|        return True
   147|    return (datetime.now(timezone.utc) - dt).total_seconds() > max_age_seconds
   148|
   149|
   150|def latest_crypto_price_row(conn: Connection, asset_id: str, currency: str = "CHF"):
   151|    return conn.execute(
   152|        """
   153|        SELECT * FROM crypto_prices
   154|        WHERE asset_id=? AND price_currency=?
   155|        ORDER BY COALESCE(provider_timestamp, fetched_at, '') DESC, fetched_at DESC
   156|        LIMIT 1
   157|        """,
   158|        (asset_id, currency.upper()),
   159|    ).fetchone()
   160|
   161|
   162|def store_crypto_price(conn: Connection, *, asset_id: str, quote: PriceQuote) -> str:
   163|    now = utc_now()
   164|    price_id = stable_id("cryptoprice", asset_id, quote.coingecko_id, quote.currency, quote.provider, now)
   165|    conn.execute(
   166|        """
   167|        INSERT INTO crypto_prices(crypto_price_id, asset_id, coingecko_id, price_currency, price, provider, provider_timestamp, fetched_at, quality_status, error_message)
   168|        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
   169|        """,
   170|        (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),
   171|    )
   172|    if quote.quality_status in {"missing", "stale", "error", "conflict"}:
   173|        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})
   174|    conn.commit()
   175|    return price_id
   176|
   177|
   178|def _create_missing_local_price_alert(conn: Connection, *, asset_id: str, symbol: str | None, coingecko_id: str | None, currency: str) -> None:
   179|    create_alert(
   180|        conn,
   181|        priority="warnung",
   182|        category="market_data",
   183|        entity_type="crypto_asset",
   184|        entity_id=asset_id,
   185|        rule_id="crypto_price_missing_local",
   186|        message="Crypto asset has no fresh local price after refresh.",
   187|        evidence={"symbol": symbol, "coingecko_id": coingecko_id, "currency": currency},
   188|        fingerprint=f"crypto_price_missing_local:{currency}",
   189|    )
   190|
   191|
   192|def _has_fresh_local_price(conn: Connection, asset_id: str, currency: str, max_age_seconds: int) -> bool:
   193|    latest = latest_crypto_price_row(conn, asset_id, currency)
   194|    return bool(latest and latest["quality_status"] == "fresh" and not is_price_stale(latest["fetched_at"], max_age_seconds=max_age_seconds))
   195|
   196|
   197|def _filter_assets(conn: Connection, *, currency: str, max_age_seconds: int, only_missing: bool, only_stale: bool, only_symbol: str | None, limit: int | None):
   198|    assets = conn.execute("SELECT asset_id, coingecko_id, symbol FROM crypto_assets WHERE is_active=1 ORDER BY symbol").fetchall()
   199|    filtered = []
   200|    wanted_symbol = only_symbol.upper() if only_symbol else None
   201|    for asset in assets:
   202|        if wanted_symbol and (asset["symbol"] or "").upper() != wanted_symbol:
   203|            continue
   204|        latest = latest_crypto_price_row(conn, asset["asset_id"], currency)
   205|        has_any = latest is not None and latest["price"] not in (None, "") and latest["quality_status"] == "fresh"
   206|        is_fresh = bool(latest and latest["quality_status"] == "fresh" and not is_price_stale(latest["fetched_at"], max_age_seconds=max_age_seconds))
   207|        is_stale = bool(latest and latest["quality_status"] == "fresh" and is_price_stale(latest["fetched_at"], max_age_seconds=max_age_seconds))
   208|        if only_missing and has_any:
   209|            continue
   210|        if only_stale and not is_stale:
   211|            continue
   212|        filtered.append(asset)
   213|        if limit is not None and len(filtered) >= limit:
   214|            break
   215|    return assets, filtered
   216|
   217|
   218|def _provider_get_batch(provider: MarketDataProvider, coingecko_ids: Sequence[str], currency: str) -> dict[str, PriceQuote]:
   219|    if hasattr(provider, "get_crypto_prices"):
   220|        return provider.get_crypto_prices(coingecko_ids, currency)  # type: ignore[attr-defined]
   221|    return {cid: provider.get_crypto_price(cid, currency) for cid in coingecko_ids}
   222|
   223|
   224|def refresh_crypto_prices(
   225|    conn: Connection,
   226|    *,
   227|    provider: MarketDataProvider,
   228|    currency: str = "CHF",
   229|    max_age_seconds: int = 3600,
   230|    only_missing: bool = False,
   231|    only_stale: bool = False,
   232|    only_symbol: str | None = None,
   233|    limit: int | None = None,
   234|    dry_run: bool = False,
   235|    sleep_seconds: float = 0.0,
   236|    batch_size: int = 100,
   237|) -> PriceRefreshResult:
   238|    currency = currency.upper()
   239|    result = PriceRefreshResult(currency=currency, dry_run=dry_run)
   240|    all_assets, assets = _filter_assets(conn, currency=currency, max_age_seconds=max_age_seconds, only_missing=only_missing, only_stale=only_stale, only_symbol=only_symbol, limit=limit)
   241|    result.total_assets = len(all_assets)
   242|    if only_missing or only_stale or only_symbol or limit is not None:
   243|        result.skipped_count += max(0, len(all_assets) - len(assets))
   244|
   245|    request_assets = []
   246|    for asset in assets:
   247|        asset_id = asset["asset_id"]
   248|        if not asset["coingecko_id"]:
   249|            if not dry_run:
   250|                create_alert(conn, priority="warnung", category="crypto", entity_type="crypto_asset", entity_id=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")
   251|                _create_missing_local_price_alert(conn, asset_id=asset_id, symbol=asset["symbol"], coingecko_id=None, currency=currency)
   252|            result.skipped_count += 1
   253|            result.warning_count += 1
   254|            result.warnings.append(f"{asset['symbol']}: missing_coingecko_id")
   255|            continue
   256|        latest = latest_crypto_price_row(conn, asset_id, currency)
   257|        if latest and latest["quality_status"] == "fresh" and not is_price_stale(latest["fetched_at"], max_age_seconds=max_age_seconds):
   258|            result.cached_count += 1
   259|            continue
   260|        request_assets.append(asset)
   261|
   262|    refreshed_fresh_asset_ids: set[str] = set()
   263|    for start in range(0, len(request_assets), max(1, batch_size)):
   264|        if start and sleep_seconds > 0:
   265|            time.sleep(sleep_seconds)
   266|        chunk = request_assets[start : start + max(1, batch_size)]
   267|        ids = [asset["coingecko_id"] for asset in chunk]
   268|        try:
   269|            quotes = _provider_get_batch(provider, ids, currency)
   270|        except Exception as exc:
   271|            quotes = {cid: PriceQuote(cid, currency, None, quality_status="error", error_message=str(exc)) for cid in ids}
   272|        for asset in chunk:
   273|            quote = quotes.get(asset["coingecko_id"]) or PriceQuote(asset["coingecko_id"], currency, None, quality_status="missing", error_message="price missing")
   274|            if not dry_run:
   275|                price_id = store_crypto_price(conn, asset_id=asset["asset_id"], quote=quote)
   276|                result.written_price_ids.append(price_id)
   277|            if quote.price is not None and quote.quality_status == "fresh":
   278|                result.updated_count += 1
   279|                refreshed_fresh_asset_ids.add(asset["asset_id"])
   280|                if not dry_run:
   281|                    resolve_fixed_crypto_price_alerts(conn, currency=currency, max_age_seconds=max_age_seconds)
   282|            elif quote.quality_status in {"missing", "stale"}:
   283|                result.warning_count += 1
   284|                result.warnings.append(f"{asset['symbol']}: {quote.quality_status}")
   285|            else:
   286|                result.error_count += 1
   287|                result.errors.append(f"{asset['symbol']}: {quote.error_message or quote.quality_status}")
   288|            if quote.quality_status == "stale":
   289|                result.stale_count += 1
   290|            if (quote.price is None or quote.quality_status != "fresh") and not dry_run:
   291|                _create_missing_local_price_alert(conn, asset_id=asset["asset_id"], symbol=asset["symbol"], coingecko_id=asset["coingecko_id"], currency=currency)
   292|
   293|    if not dry_run:
   294|        for asset in assets:
   295|            if asset["asset_id"] in refreshed_fresh_asset_ids:
   296|                continue
   297|            if not _has_fresh_local_price(conn, asset["asset_id"], currency, max_age_seconds):
   298|                result.missing_local_price_count += 1
   299|                _create_missing_local_price_alert(conn, asset_id=asset["asset_id"], symbol=asset["symbol"], coingecko_id=asset["coingecko_id"], currency=currency)
   300|        conn.commit()
   301|    else:
   302|        # Dry-run reports current known gaps plus simulated failed quotes without mutating alerts/prices.
   303|        for asset in assets:
   304|            if not _has_fresh_local_price(conn, asset["asset_id"], currency, max_age_seconds):
   305|                result.missing_local_price_count += 1
   306|    return result
   307|


Explanation:
**Troubleshooting implementation issues**

I'm thinking that I might need to run some tests because things might fail. I need to implement page buttons and patch the crypto action columns. Also, I wonder if there's an issue with the column button records label—does the fake column have the same structure as the real columns? I might need to check if a fake `__getattr__` for the columns could return `[self]*n` to simplify things. It’ll be worthwhile to investigate.


     1|from __future__ import annotations
     2|
     3|from decimal import Decimal
     4|
     5|from jarvis_finance.crypto.assets import create_crypto_asset
     6|from jarvis_finance.crypto.holdings import create_initial_holding_snapshot
     7|from jarvis_finance.crypto.wallets import create_wallet
     8|from jarvis_finance.dashboard import data as dashboard_data
     9|from jarvis_finance.dashboard.demo_data import load_synthetic_demo_data
    10|from jarvis_finance.dashboard.pages import page_12_crypto_manage
    11|from jarvis_finance.market.providers import PriceQuote, store_crypto_price
    12|from jarvis_finance.storage.database import connect_memory
    13|from jarvis_finance.storage.migrations import apply_migrations
    14|
    15|
    16|class FakeStreamlit:
    17|    def __init__(self, button_presses: set[str] | None = None) -> None:
    18|        self.calls: list[tuple[str, tuple[object, ...], dict[str, object]]] = []
    19|        self.sidebar = self
    20|        self.session_state = {}
    21|        self.button_presses = button_presses or set()
    22|
    23|    def __getattr__(self, name: str):
    24|        def recorder(*args, **kwargs):
    25|            self.calls.append((name, args, kwargs))
    26|            if name == "button":
    27|                label = str(args[0]) if args else str(kwargs.get("label", ""))
    28|                return label in self.button_presses and not bool(kwargs.get("disabled", False))
    29|            if name == "checkbox":
    30|                return False
    31|            if name == "toggle":
    32|                return bool(kwargs.get("value", False))
    33|            if name in {"text_input", "text_area", "date_input"}:
    34|                return ""
    35|            if name in {"selectbox", "radio", "pills"}:
    36|                options = args[1] if len(args) > 1 else kwargs.get("options", [])
    37|                return list(options)[0] if options else None
    38|            if name in {"tabs", "columns"}:
    39|                values = args[0] if args else []
    40|                count = values if isinstance(values, int) else len(values)
    41|                return [self for _ in range(count)]
    42|            if name == "expander":
    43|                return self
    44|            return None
    45|        return recorder
    46|
    47|    def __enter__(self):
    48|        return self
    49|
    50|    def __exit__(self, *args):
    51|        return False
    52|
    53|
    54|def setup_demo_conn():
    55|    conn = connect_memory()
    56|    apply_migrations(conn)
    57|    load_synthetic_demo_data(conn)
    58|    return conn
    59|
    60|
    61|def setup_multi_wallet_crypto_conn():
    62|    conn = connect_memory()
    63|    apply_migrations(conn)
    64|    w1 = create_wallet(conn, wallet_name="MetaMask Demo", wallet_type="Software Wallet", platform_provider="MetaMask", last_verified_at="2026-01-31T10:00:00Z")
    65|    w2 = create_wallet(conn, wallet_name="SwissBorg Demo", wallet_type="Exchange", platform_provider="SwissBorg", last_verified_at="2026-01-31T11:00:00Z")
    66|    eth = create_crypto_asset(conn, coin_name="Ethereum", symbol="ETH", coingecko_id="ethereum")
    67|    btc = create_crypto_asset(conn, coin_name="Bitcoin", symbol="BTC", coingecko_id="bitcoin")
    68|    create_initial_holding_snapshot(conn, asset_id=eth, wallet_id=w1, quantity=Decimal("2"), verification_status="verified", last_verified_at="2026-01-31T10:00:00Z", note="synthetic eth split")
    69|    create_initial_holding_snapshot(conn, asset_id=eth, wallet_id=w2, quantity=Decimal("2"), verification_status="verified", last_verified_at="2026-01-31T11:00:00Z", note="synthetic eth split")
    70|    create_initial_holding_snapshot(conn, asset_id=btc, wallet_id=w1, quantity=Decimal("0.00000001"), verification_status="stale", last_verified_at="2026-01-01T00:00:00Z", note="synthetic btc tiny")
    71|    store_crypto_price(conn, asset_id=eth, quote=PriceQuote("ethereum", "CHF", Decimal("2500.00"), provider="synthetic-cache", provider_timestamp="2026-02-01T00:00:00Z"))
    72|    store_crypto_price(conn, asset_id=btc, quote=PriceQuote("bitcoin", "CHF", Decimal("40000.00"), provider="synthetic-cache", provider_timestamp="2026-02-01T00:00:00Z"))
    73|    return conn
    74|
    75|TECHNICAL_FIELDS = {
    76|    "asset_id",
    77|    "wallet_id",
    78|    "instrument_id",
    79|    "entity_id",
    80|    "rule_id",
    81|

Use offset=81 to continue reading (showing 1-80 of 160 lines)


Explanation:
**Identifying necessary features**

I’m thinking about the need for patch action buttons. These buttons seem essential for improving user interaction and functionality. I want to ensure they’re clearly labeled and easy to access so users can effectively manage their tasks. It might be helpful to brainstorm what actions the buttons should perform and how to present them visually. Maybe considering user feedback could guide this process!

patch

    st.markdown("**Aktionen**")
    cols = st.columns(len(detail["actions"]))
    for col, action in zip(cols, detail["actions"]):
        col.button(action)

    st.markdown("**Aktionen**")
    if not edit_mode_enabled(st):
        read_only_hint(st)
    cols = st.columns(len(detail["actions"]))
    for col, action in zip(cols, detail["actions"]):
        if action_button(col, action, key=f"crypto_coin_action_{action}"):
            mapped = "Transfer zwischen Wallets" if action == "Transfer" else ("Bestand entfernen / auf 0 setzen" if action == "Bestand auf 0 setzen" else action)
            open_crypto_manage(st, mapped, asset_id=selected_row["_asset_id"])
            st.success(f"Aktion vorbereitet: {action}. Öffnen Sie Admin/Debug → Crypto Manage zur Review.")


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/pages/page_04_crypto.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/pages/page_04_crypto.py
@@ -69,8 +69,13 @@
     st.markdown("**Wallet-Aufteilung**")
     show_table(st, detail["wallets"])
     st.markdown("**Aktionen**")
+    if not edit_mode_enabled(st):
+        read_only_hint(st)
     cols = st.columns(len(detail["actions"]))
     for col, action in zip(cols, detail["actions"]):
-        col.button(action)
+        if action_button(col, action, key=f"crypto_coin_action_{action}"):
+            mapped = "Transfer zwischen Wallets" if action == "Transfer" else ("Bestand entfernen / auf 0 setzen" if action == "Bestand auf 0 setzen" else action)
+            open_crypto_manage(st, mapped, asset_id=selected_row["_asset_id"])
+            st.success(f"Aktion vorbereitet: {action}. Öffnen Sie Admin/Debug → Crypto Manage zur Review.")
     with st.expander("Admin: technische Coin-Details"):
         show_table(st, data.get_crypto_coin_summary(conn, admin_mode=True))


Explanation:
**Adding price update functionality**

I need to create a button for the price update. It seems like I should import the provider for this. Maybe I can place it before the charts to keep it organized? I’ll use the CoinGeckoClient for refreshing, which makes sense. The good news is that I won't need any APIs other than just the click for this functionality. Just keeping it straightforward should help streamline the process!

from jarvis_finance.dashboard.actions import action_button, edit_mode_enabled, open_crypto_manage, read_only_hint
from jarvis_finance.market.providers import CoinGeckoClient, refresh_crypto_prices


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/pages/page_04_crypto.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/pages/page_04_crypto.py
@@ -3,6 +3,7 @@
 from jarvis_finance.dashboard.components.tables import show_table
 from jarvis_finance.dashboard import data
 from jarvis_finance.dashboard.actions import action_button, edit_mode_enabled, open_crypto_manage, read_only_hint
+from jarvis_finance.market.providers import CoinGeckoClient, refresh_crypto_prices

 PAGE_TITLE = "Crypto"


    ])

    if st.button("Preise aktualisieren", key="crypto_price_refresh"):
        with st.spinner("Lokale CHF-Preise werden aktualisiert …"):
            result = refresh_crypto_prices(conn, provider=CoinGeckoClient(max_retries=2, initial_backoff_seconds=1.0, max_backoff_seconds=8.0), currency="CHF", only_missing=True, sleep_seconds=1.0)
        st.success(
            f"Preisupdate abgeschlossen: aktualisiert={result.updated_count}, übersprungen={result.skipped_count}, "
            f"cached={result.cached_count}, warnings={result.warning_count}, errors={result.error_count}, "
            f"weiterhin ohne frischen Preis={result.missing_local_price_count}"
        )

    c1, c2 = st.columns(2)


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/pages/page_04_crypto.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/pages/page_04_crypto.py
@@ -36,6 +36,15 @@
         ("Grösste Position", largest),
         ("Letztes Preisupdate", data.date_text(summary["last_price_update_at"])),
     ])
+
+    if st.button("Preise aktualisieren", key="crypto_price_refresh"):
+        with st.spinner("Lokale CHF-Preise werden aktualisiert …"):
+            result = refresh_crypto_prices(conn, provider=CoinGeckoClient(max_retries=2, initial_backoff_seconds=1.0, max_backoff_seconds=8.0), currency="CHF", only_missing=True, sleep_seconds=1.0)
+        st.success(
+            f"Preisupdate abgeschlossen: aktualisiert={result.updated_count}, übersprungen={result.skipped_count}, "
+            f"cached={result.cached_count}, warnings={result.warning_count}, errors={result.error_count}, "
+            f"weiterhin ohne frischen Preis={result.missing_local_price_count}"
+        )

     c1, c2 = st.columns(2)
     with c1:


     1|from __future__ import annotations
     2|
     3|
     4|def show_table(st, rows: list[dict[str, str]], *, empty_message: str = "Keine Daten vorhanden.") -> None:
     5|    if rows:
     6|        st.dataframe(rows, use_container_width=True, hide_index=True)
     7|    else:
     8|        st.info(empty_message)
     9|


show_table(st, [{k: row[k] for k in data.visible_user_columns(row)} for row in detail["wallets"]])


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/pages/page_04_crypto.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/pages/page_04_crypto.py
@@ -77,7 +77,7 @@
     d4.metric("Anteil", detail["anteil"])
     st.caption(f"Status: {detail['preisstatus']} · letzte Preisaktualisierung: {detail['letzte_preisaktualisierung']}")
     st.markdown("**Wallet-Aufteilung**")
-    show_table(st, detail["wallets"])
+    show_table(st, [{k: row[k] for k in data.visible_user_columns(row)} for row in detail["wallets"]])
     st.markdown("**Aktionen**")
     if not edit_mode_enabled(st):
         read_only_hint(st)


Explanation:
**Updating wallet features**

I’m thinking about updating the wallet functionality by patching imports and actions. It seems like I should remove the actions column and also disable the buttons related to the session that's selected. Plus, I want to ensure that the wallet form is set to be writable, but only for editing. It feels a bit technical, but I'm focused on streamlining this process effectively! Alright, let's get to work on this!

from jarvis_finance.dashboard.components.tables import show_table
from jarvis_finance.dashboard import data
from jarvis_finance.dashboard.actions import action_button, edit_mode_enabled, open_crypto_manage, read_only_hint


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/pages/page_05_wallets.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/pages/page_05_wallets.py
@@ -3,6 +3,7 @@
 from jarvis_finance.crypto.wallets import create_wallet, update_wallet, VALID_WALLET_TYPES
 from jarvis_finance.dashboard.components.tables import show_table
 from jarvis_finance.dashboard import data
+from jarvis_finance.dashboard.actions import action_button, edit_mode_enabled, open_crypto_manage, read_only_hint

 PAGE_TITLE = "Wallets"


    if action_button(c5, "Neues Wallet", key="new_wallet_top"):
        open_crypto_manage(st, "Neues Wallet")


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/pages/page_05_wallets.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/pages/page_05_wallets.py
@@ -19,7 +19,8 @@
     c2.metric("Wallets", cards["wallet_count"])
     c3.metric("fehlende Verifikation", cards["wallets_missing_verification"])
     c4.metric("Grösstes Wallet", largest["wallet_name"])
-    c5.button("Neues Wallet")
+    if action_button(c5, "Neues Wallet", key="new_wallet_top"):
+        open_crypto_manage(st, "Neues Wallet")

     st.subheader("Wallets")
     if not wallet_rows:


    selected_row = wallet_rows[labels.index(selected)] if selected in labels else wallet_rows[0]
    st.session_state["selected_crypto_wallet_id"] = selected_row["_wallet_id"]
    show_table(st, [{k: r[k] for k in ["wallet_name", "wallet_type", "provider", "total_value_chf", "coin_count", "status"]} for r in wallet_rows])

    selected_row = wallet_rows[labels.index(selected)] if selected in labels else wallet_rows[0]
    show_table(st, [{k: r[k] for k in ["wallet_name", "wallet_type", "provider", "total_value_chf", "coin_count", "status", "actions"]} for r in wallet_rows])


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/pages/page_05_wallets.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/pages/page_05_wallets.py
@@ -29,7 +29,8 @@
     labels = [f"{r['wallet_name']} — {r['total_value_chf']}" for r in wallet_rows]
     selected = st.selectbox("Wallet auswählen / öffnen", labels)
     selected_row = wallet_rows[labels.index(selected)] if selected in labels else wallet_rows[0]
-    show_table(st, [{k: r[k] for k in ["wallet_name", "wallet_type", "provider", "total_value_chf", "coin_count", "status", "actions"]} for r in wallet_rows])
+    st.session_state["selected_crypto_wallet_id"] = selected_row["_wallet_id"]
+    show_table(st, [{k: r[k] for k in ["wallet_name", "wallet_type", "provider", "total_value_chf", "coin_count", "status"]} for r in wallet_rows])

     st.subheader("Wallet Detail")
     detail = data.get_wallet_detail(conn, selected_row["_wallet_id"])


    show_table(st, [{k: row[k] for k in data.visible_user_columns(row)} for row in detail["coins"]])
    st.markdown("**Aktionen**")
    if not edit_mode_enabled(st):
        read_only_hint(st)
    cols = st.columns(len(detail["actions"]))
    for col, action in zip(cols, detail["actions"]):
        if action_button(col, action, key=f"wallet_action_{action}"):
            mapped = "Transfer zwischen Wallets" if action == "Transfer aus Wallet" else ("Coin hinzufügen" if action == "Coin in Wallet hinzufügen" else action)
            open_crypto_manage(st, mapped, wallet_id=selected_row["_wallet_id"])
            st.success(f"Aktion vorbereitet: {action}. Öffnen Sie Admin/Debug → Crypto Manage zur Review.")

    show_table(st, detail["coins"])
    st.markdown("**Aktionen**")
    cols = st.columns(len(detail["actions"]))
    for col, action in zip(cols, detail["actions"]):
        col.button(action)


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/pages/page_05_wallets.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/pages/page_05_wallets.py
@@ -41,11 +41,16 @@
     d4.metric("Status", detail["status"])
     st.caption(f"Provider: {detail['provider']} · letzte Verifikation: {detail['letzte_verifikation']} · Notizen: {detail['notizen']}")
     st.markdown("**Coins in diesem Wallet**")
-    show_table(st, detail["coins"])
+    show_table(st, [{k: row[k] for k in data.visible_user_columns(row)} for row in detail["coins"]])
     st.markdown("**Aktionen**")
+    if not edit_mode_enabled(st):
+        read_only_hint(st)
     cols = st.columns(len(detail["actions"]))
     for col, action in zip(cols, detail["actions"]):
-        col.button(action)
+        if action_button(col, action, key=f"wallet_action_{action}"):
+            mapped = "Transfer zwischen Wallets" if action == "Transfer aus Wallet" else ("Coin hinzufügen" if action == "Coin in Wallet hinzufügen" else action)
+            open_crypto_manage(st, mapped, wallet_id=selected_row["_wallet_id"])
+            st.success(f"Aktion vorbereitet: {action}. Öffnen Sie Admin/Debug → Crypto Manage zur Review.")

     with st.expander("Neues Wallet / Wallet bearbeiten"):
         name = st.text_input("Wallet-Name")


if st.button("Wallet speichern", disabled=not edit_mode_enabled(st)):


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/pages/page_05_wallets.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/pages/page_05_wallets.py
@@ -60,7 +60,7 @@
         note = st.text_area("Notiz optional")
         active = st.checkbox("active", value=True)
         confirm = st.checkbox("Review bestätigt")
-        if st.button("Wallet speichern"):
+        if st.button("Wallet speichern", disabled=not edit_mode_enabled(st)):
             try:
                 if not confirm:
                     raise ValueError("Review-Bestätigung fehlt")