terminal

{"output": "FF.FF.F                                                                  [100%]\n=================================== FAILURES ===================================\n_____ test_refresh_crypto_prices_loads_active_assets_and_stores_chf_price ______\n\n    def test_refresh_crypto_prices_loads_active_assets_and_stores_chf_price() -> None:\n        conn, btc_id, _ = setup_conn()\n        provider = MockProvider()\n    \n        result = refresh_crypto_prices(conn, provider=provider, currency=\"CHF\", max_age_seconds=0)\n    \n>       assert result.success_count == 1\n               ^^^^^^^^^^^^^^^^^^^^\nE       AttributeError: 'list' object has no attribute 'success_count'\n\ntests/unit/test_crypto_price_update.py:44: AttributeError\n___ test_missing_coingecko_id_is_skipped_and_warned_without_duplicate_alerts ___\n\n    def test_missing_coingecko_id_is_skipped_and_warned_without_duplicate_alerts() -> None:\n        conn, _, missing_id = setup_conn()\n        provider = MockProvider()\n    \n        first = refresh_crypto_prices(conn, provider=provider, currency=\"CHF\", max_age_seconds=0)\n        second = refresh_crypto_prices(conn, provider=provider, currency=\"CHF\", max_age_seconds=0)\n    \n>       assert first.skipped_count == 1\n               ^^^^^^^^^^^^^^^^^^^\nE       AttributeError: 'list' object has no attribute 'skipped_count'\n\ntests/unit/test_crypto_price_update.py:63: AttributeError\n_________ test_fresh_cache_prevents_refetch_and_stale_price_is_marked __________\n\n    def test_fresh_cache_prevents_refetch_and_stale_price_is_marked() -> None:\n        conn, btc_id, _ = setup_conn()\n        provider = MockProvider()\n        first = refresh_crypto_prices(conn, provider=provider, currency=\"CHF\", max_age_seconds=3600)\n        second = refresh_crypto_prices(conn, provider=provider, currency=\"CHF\", max_age_seconds=3600)\n>       assert first.success_count == 1\n               ^^^^^^^^^^^^^^^^^^^\nE       AttributeError: 'list' object has no attribute 'success_count'\n\ntests/unit/test_crypto_price_update.py:98: AttributeError\n_________ test_dashboard_reads_local_prices_without_provider_api_call __________\n\n    def test_dashboard_reads_local_prices_without_provider_api_call() -> None:\n        conn, btc_id, _ = setup_conn()\n        refresh_crypto_prices(conn, provider=MockProvider(), currency=\"CHF\", max_age_seconds=0)\n    \n        overview = data.get_crypto_overview(conn)\n        by_wallet = data.get_crypto_by_wallet(conn)\n        summary = data.get_command_center_summary(conn)\n    \n        btc = next(row for row in overview if row[\"symbol\"] == \"BTC\")\n        assert btc[\"latest_price_chf\"] == \"123.46\"\n        assert btc[\"market_value_chf\"]\n>       assert any(row[\"wallet_value_chf\"] for row in by_wallet if row[\"symbol\"] == \"BTC\")\n               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\ntests/unit/test_crypto_price_update.py:124: \n_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ \n\n.0 = <list_iterator object at 0x7b827482b8b0>\n\n>   assert any(row[\"wallet_value_chf\"] for row in by_wallet if row[\"symbol\"] == \"BTC\")\n               ^^^^^^^^^^^^^^^^^^^^^^^\nE   KeyError: 'wallet_value_chf'\n\ntests/unit/test_crypto_price_update.py:124: KeyError\n_________ test_cli_update_crypto_prices_command_uses_provider_factory __________\n\nobj = <module 'jarvis_finance.cli.main' from '/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/cli/main.py'>\nname = 'CoinGeckoClient', ann = 'jarvis_finance.cli.main'\n\n    def annotated_getattr(obj: object, name: str, ann: str) -> object:\n        try:\n>           obj = getattr(obj, name)\n                  ^^^^^^^^^^^^^^^^^^\nE           AttributeError: module 'jarvis_finance.cli.main' has no attribute 'CoinGeckoClient'\n\n../../hermes-agent/venv/lib/python3.11/site-packages/_pytest/monkeypatch.py:92: AttributeError\n\nThe above exception was the direct cause of the following exception:\n\nmonkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7b827485da50>\ntmp_path = PosixPath('/tmp/pytest-of-agent/pytest-41/test_cli_update_crypto_prices_0')\ncapsys = <_pytest.capture.CaptureFixture object at 0x7b827485d090>\n\n    def test_cli_update_crypto_prices_command_uses_provider_factory(monkeypatch, tmp_path, capsys) -> None:\n        runtime = tmp_path / \"runtime\"\n        monkeypatch.setenv(\"JARVIS_FINANCE_RUNTIME_DIR\", str(runtime))\n        conn = connect_memory()\n        # Build a runtime file DB via CLI settings by seeding after opening path.\n        from jarvis_finance.storage.database import connect\n        db = runtime / \"data\" / \"finance.sqlite3\"\n        db.parent.mkdir(parents=True)\n        file_conn = connect(db)\n        apply_migrations(file_conn)\n        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\")\n        asset_id = create_crypto_asset(file_conn, coin_name=\"Bitcoin\", symbol=\"BTC\", coingecko_id=\"bitcoin\")\n        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\")\n        file_conn.close()\n    \n>       monkeypatch.setattr(\"jarvis_finance.cli.main.CoinGeckoClient\", lambda: MockProvider())\n\ntests/unit/test_crypto_price_update.py:159: \n_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ \n../../hermes-agent/venv/lib/python3.11/site-packages/_pytest/monkeypatch.py:106: in derive_importpath\n    annotated_getattr(target, attr, ann=module)\n_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ \n\nobj = <module 'jarvis_finance.cli.main' from '/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/cli/main.py'>\nname = 'CoinGeckoClient', ann = 'jarvis_finance.cli.main'\n\n    def annotated_getattr(obj: object, name: str, ann: str) -> object:\n        try:\n            obj = getattr(obj, name)\n        except AttributeError as e:\n>           raise AttributeError(\n                f\"{type(obj).__name__!r} object at {ann} has no attribute {name!r}\"\n            ) from e\nE           AttributeError: 'module' object at jarvis_finance.cli.main has no attribute 'CoinGeckoClient'\n\n../../hermes-agent/venv/lib/python3.11/site-packages/_pytest/monkeypatch.py:94: AttributeError\n=========================== short test summary info ============================\nFAILED tests/unit/test_crypto_price_update.py::test_refresh_crypto_prices_loads_active_assets_and_stores_chf_price\nFAILED tests/unit/test_crypto_price_update.py::test_missing_coingecko_id_is_skipped_and_warned_without_duplicate_alerts\nFAILED tests/unit/test_crypto_price_update.py::test_fresh_cache_prevents_refetch_and_stale_price_is_marked\nFAILED tests/unit/test_crypto_price_update.py::test_dashboard_reads_local_prices_without_provider_api_call\nFAILED tests/unit/test_crypto_price_update.py::test_cli_update_crypto_prices_command_uses_provider_factory\n5 failed, 2 passed in 0.59s", "exit_code": 1, "error": null}

[Tool loop warning: repeated_exact_failure_warning; count=2; terminal has failed 2 times with identical arguments. This looks like a loop; inspect the error and change strategy instead of retrying it unchanged.]


     1|from __future__ import annotations
     2|
     3|import hashlib
     4|import json
     5|import uuid
     6|from datetime import datetime, timezone
     7|from sqlite3 import Connection
     8|from typing import Any
     9|
    10|ACTIVE_STATUSES = {"active", "resolved", "muted"}
    11|
    12|
    13|def utc_now() -> str:
    14|    return datetime.now(timezone.utc).isoformat()
    15|
    16|
    17|def alert_fingerprint(evidence: dict[str, Any] | None = None, *, explicit: str | None = None) -> str:
    18|    if explicit:
    19|        payload = explicit
    20|    else:
    21|        payload = json.dumps(evidence or {}, sort_keys=True, ensure_ascii=False, default=str)
    22|    return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:32]
    23|
    24|
    25|def build_dedup_key(*, rule_id: str | None, entity_type: str | None, entity_id: str | None, priority: str, fingerprint: str) -> str:
    26|    payload = "|".join([rule_id or "", entity_type or "", entity_id or "", priority, fingerprint])
    27|    return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:40]
    28|
    29|
    30|def create_alert(
    31|    conn: Connection,
    32|    *,
    33|    priority: str,
    34|    category: str,
    35|    message: str,
    36|    entity_type: str | None = None,
    37|    entity_id: str | None = None,
    38|    rule_id: str | None = None,
    39|    evidence: dict[str, Any] | None = None,
    40|    fingerprint: str | None = None,
    41|) -> str:
    42|    now = utc_now()
    43|    fp = alert_fingerprint(evidence, explicit=fingerprint)
    44|    dedup_key = build_dedup_key(rule_id=rule_id, entity_type=entity_type, entity_id=entity_id, priority=priority, fingerprint=fp)
    45|    existing = conn.execute(
    46|        "SELECT alert_id, status, occurrence_count FROM alerts WHERE dedup_key=? AND status IN ('active','resolved','muted') ORDER BY created_at DESC LIMIT 1",
    47|        (dedup_key,),
    48|    ).fetchone()
    49|    evidence_json = json.dumps(evidence or {}, sort_keys=True, ensure_ascii=False, default=str)
    50|    if existing:
    51|        conn.execute(
    52|            "UPDATE alerts SET last_seen_at=?, occurrence_count=COALESCE(occurrence_count,1)+1, evidence_json=? WHERE alert_id=?",
    53|            (now, evidence_json, existing["alert_id"]),
    54|        )
    55|        return existing["alert_id"]
    56|    alert_id = str(uuid.uuid4())
    57|    conn.execute(
    58|        """
    59|        INSERT INTO alerts(
    60|            alert_id, priority, category, entity_type, entity_id, rule_id, message,
    61|            evidence_json, status, created_at, last_seen_at, occurrence_count, fingerprint, dedup_key
    62|        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, 1, ?, ?)
    63|        """,
    64|        (alert_id, priority, category, entity_type, entity_id, rule_id, message, evidence_json, now, now, fp, dedup_key),
    65|    )
    66|    return alert_id
    67|
    68|
    69|def resolve_alert(conn: Connection, alert_id: str, *, note: str | None = None) -> None:
    70|    conn.execute("UPDATE alerts SET status='resolved', resolved_at=? WHERE alert_id=?", (utc_now(), alert_id))
    71|    conn.commit()
    72|
    73|
    74|def mute_alert(conn: Connection, alert_id: str, *, muted_until: str | None = None) -> None:
    75|    conn.execute("UPDATE alerts SET status='muted', muted_until=? WHERE alert_id=?", (muted_until, alert_id))
    76|    conn.commit()
    77|


from __future__ import annotations

import json
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass, field
from datetime import datetime, timezone
from decimal import Decimal, InvalidOperation
from sqlite3 import Connection
from typing import Protocol

from jarvis_finance.imports.common import stable_id, utc_now
from jarvis_finance.quality.alerts import create_alert

@dataclass(frozen=True)
class PriceQuote:
    coingecko_id: str
    currency: str
    price: Decimal | None
    provider: str = "CoinGecko"
    provider_timestamp: str | None = None
    quality_status: str = "fresh"
    error_message: str | None = None

@dataclass
class PriceRefreshResult:
    currency: str
    total_assets: int = 0
    success_count: int = 0
    skipped_count: int = 0
    cached_count: int = 0
    warning_count: int = 0
    error_count: int = 0
    written_price_ids: list[str] = field(default_factory=list)
    warnings: list[str] = field(default_factory=list)
    errors: list[str] = field(default_factory=list)

class MarketDataProvider(Protocol):
    def get_crypto_price(self, coingecko_id: str, currency: str = "CHF") -> PriceQuote: ...

class ProviderRateLimitError(RuntimeError):
    pass

class ProviderFetchError(RuntimeError):
    pass

class CoinGeckoClient:
    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:
        self.base_url = base_url.rstrip("/")
        self.max_retries = max_retries
        self.initial_backoff_seconds = initial_backoff_seconds
        self.max_backoff_seconds = max_backoff_seconds
        self.opener = opener or urllib.request.urlopen
        self.sleeper = sleeper

    def get_crypto_price(self, coingecko_id: str, currency: str = "CHF") -> PriceQuote:
        cur = currency.lower()
        query = urllib.parse.urlencode({"ids": coingecko_id, "vs_currencies": cur, "include_last_updated_at": "true"})
        url = f"{self.base_url}/simple/price?{query}"
        delay = self.initial_backoff_seconds
        last_error: str | None = None
        for attempt in range(self.max_retries + 1):
            try:
                with self.opener(url, timeout=20) as resp:
                    payload = json.loads(resp.read().decode("utf-8"))
                data = payload.get(coingecko_id) or {}
                value = data.get(cur)
                if value is None:
                    return PriceQuote(coingecko_id, currency.upper(), None, quality_status="missing", error_message="price missing")
                try:
                    price = Decimal(str(value))
                except InvalidOperation:
                    return PriceQuote(coingecko_id, currency.upper(), None, quality_status="error", error_message="invalid price")
                ts = data.get("last_updated_at")
                provider_ts = None
                if ts:
                    try:
                        provider_ts = datetime.fromtimestamp(int(ts), tz=timezone.utc).isoformat()
                    except (TypeError, ValueError):
                        provider_ts = str(ts)
                return PriceQuote(coingecko_id, currency.upper(), price, provider_timestamp=provider_ts)
            except urllib.error.HTTPError as exc:
                last_error = f"HTTP {exc.code}"
                if exc.code == 429 and attempt < self.max_retries:
                    self.sleeper(min(delay, self.max_backoff_seconds)); delay *= 2; continue
                status = "stale" if exc.code == 429 else "error"
                return PriceQuote(coingecko_id, currency.upper(), None, quality_status=status, error_message=last_error)
            except Exception as exc:  # network skeleton must not crash dashboard jobs
                last_error = str(exc)
                if attempt < self.max_retries:
                    self.sleeper(min(delay, self.max_backoff_seconds)); delay *= 2; continue
                return PriceQuote(coingecko_id, currency.upper(), None, quality_status="error", error_message=last_error)
        return PriceQuote(coingecko_id, currency.upper(), None, quality_status="error", error_message=last_error)

def _parse_dt(value: str | None) -> datetime | None:
    if not value:
        return None
    try:
        dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
    except ValueError:
        return None
    return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)

def is_price_stale(timestamp: str | None, *, max_age_seconds: int = 86_400) -> bool:
    dt = _parse_dt(timestamp)
    if dt is None:
        return True
    return (datetime.now(timezone.utc) - dt).total_seconds() > max_age_seconds

def latest_crypto_price_row(conn: Connection, asset_id: str, currency: str = "CHF"):
    return conn.execute(
        """
        SELECT * FROM crypto_prices
        WHERE asset_id=? AND price_currency=?
        ORDER BY COALESCE(provider_timestamp, fetched_at, '') DESC, fetched_at DESC
        LIMIT 1
        """,
        (asset_id, currency.upper()),
    ).fetchone()

def store_crypto_price(conn: Connection, *, asset_id: str, quote: PriceQuote) -> str:
    now = utc_now()
    price_id = stable_id("cryptoprice", asset_id, quote.coingecko_id, quote.currency, quote.provider, now)
    conn.execute(
        """
        INSERT INTO crypto_prices(crypto_price_id, asset_id, coingecko_id, price_currency, price, provider, provider_timestamp, fetched_at, quality_status, error_message)
        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        """,
        (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),
    )
    if quote.quality_status in {"missing", "stale", "error", "conflict"}:
        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})
    conn.commit()
    return price_id

def refresh_crypto_prices(conn: Connection, *, provider: MarketDataProvider, currency: str = "CHF", max_age_seconds: int = 3600) -> PriceRefreshResult:
    currency = currency.upper()
    result = PriceRefreshResult(currency=currency)
    assets = conn.execute("SELECT asset_id, coingecko_id, symbol FROM crypto_assets WHERE is_active=1 ORDER BY symbol").fetchall()
    result.total_assets = len(assets)
    for asset in assets:
        asset_id = asset["asset_id"]
        if not asset["coingecko_id"]:
            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")
            result.skipped_count += 1
            result.warning_count += 1
            result.warnings.append(f"{asset['symbol']}: missing_coingecko_id")
            continue
        latest = latest_crypto_price_row(conn, asset_id, currency)
        if latest and latest["quality_status"] == "fresh" and not is_price_stale(latest["fetched_at"], max_age_seconds=max_age_seconds):
            result.cached_count += 1
            continue
        try:
            quote = provider.get_crypto_price(asset["coingecko_id"], currency)
        except Exception as exc:
            quote = PriceQuote(asset["coingecko_id"], currency, None, quality_status="error", error_message=str(exc))
        price_id = store_crypto_price(conn, asset_id=asset_id, quote=quote)
        result.written_price_ids.append(price_id)
        if quote.price is not None and quote.quality_status == "fresh":
            result.success_count += 1
        elif quote.quality_status in {"missing", "stale"}:
            result.warning_count += 1
            result.warnings.append(f"{asset['symbol']}: {quote.quality_status}")
        else:
            result.error_count += 1
            result.errors.append(f"{asset['symbol']}: {quote.error_message or quote.quality_status}")
    conn.commit()
    return result


patch

from jarvis_finance.crypto.holdings import calculate_crypto_holdings
from jarvis_finance.ledger.cash import calculate_cash_balances
from jarvis_finance.ledger.positions import calculate_positions
from jarvis_finance.market.providers import is_price_stale


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/data.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/data.py
@@ -9,6 +9,7 @@
 from jarvis_finance.crypto.holdings import calculate_crypto_holdings
 from jarvis_finance.ledger.cash import calculate_cash_balances
 from jarvis_finance.ledger.positions import calculate_positions
+from jarvis_finance.market.providers import is_price_stale

 MONEY = Decimal("0.01")


def latest_crypto_price(conn: Connection, asset_id: str, currency: str = "CHF", *, max_age_seconds: int = 86_400) -> dict[str, str] | None:
    row = conn.execute(
        """
        SELECT price, price_currency, fetched_at, provider_timestamp, quality_status, provider
        FROM crypto_prices
        WHERE asset_id=? AND price_currency=?
        ORDER BY COALESCE(provider_timestamp, fetched_at, '') DESC, fetched_at DESC LIMIT 1
        """,
        (asset_id, currency.upper()),
    ).fetchone()
    if not row:
        return None
    item = dict(row)
    ts = item.get("provider_timestamp") or item.get("fetched_at")
    if item.get("quality_status") == "fresh" and is_price_stale(ts, max_age_seconds=max_age_seconds):
        item["quality_status"] = "stale"
    return item

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

def latest_crypto_price(conn: Connection, asset_id: str, currency: str = "CHF") -> dict[str, str] | None:
    row = conn.execute(
        """
        SELECT price, price_currency, fetched_at, quality_status, provider
        FROM crypto_prices
        WHERE asset_id=? AND price_currency=?
        ORDER BY fetched_at DESC LIMIT 1
        """,
        (asset_id, currency.upper()),
    ).fetchone()
    return dict(row) if row else None

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


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/data.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/data.py
@@ -54,20 +54,26 @@
         return True

-def latest_crypto_price(conn: Connection, asset_id: str, currency: str = "CHF") -> dict[str, str] | None:
+def latest_crypto_price(conn: Connection, asset_id: str, currency: str = "CHF", *, max_age_seconds: int = 86_400) -> dict[str, str] | None:
     row = conn.execute(
         """
-        SELECT price, price_currency, fetched_at, quality_status, provider
+        SELECT price, price_currency, fetched_at, provider_timestamp, quality_status, provider
         FROM crypto_prices
         WHERE asset_id=? AND price_currency=?
-        ORDER BY fetched_at DESC LIMIT 1
+        ORDER BY COALESCE(provider_timestamp, fetched_at, '') DESC, fetched_at DESC LIMIT 1
         """,
         (asset_id, currency.upper()),
     ).fetchone()
-    return dict(row) if row else None
-
-
-def get_crypto_overview(conn: Connection) -> list[dict[str, str]]:
+    if not row:
+        return None
+    item = dict(row)
+    ts = item.get("provider_timestamp") or item.get("fetched_at")
+    if item.get("quality_status") == "fresh" and is_price_stale(ts, max_age_seconds=max_age_seconds):
+        item["quality_status"] = "stale"
+    return item
+
+
+def get_crypto_overview(conn: Connection, *, price_max_age_seconds: int = 86_400) -> list[dict[str, str]]:
     holdings = calculate_crypto_holdings(conn)
     rows: list[dict[str, str]] = []
     for asset_id, holding in sorted(holdings.total_by_asset.items()):


price = latest_crypto_price(conn, asset_id, "CHF", max_age_seconds=price_max_age_seconds)

price = latest_crypto_price(conn, asset_id, "CHF")


Found 2 matches for old_string. Provide more context to make it unique, or use replace_all=True.


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/data.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/data.py
@@ -81,7 +81,7 @@
         asset = conn.execute("SELECT * FROM crypto_assets WHERE asset_id=?", (asset_id,)).fetchone()
         if not asset:
             continue
-        price = latest_crypto_price(conn, asset_id, "CHF")
+        price = latest_crypto_price(conn, asset_id, "CHF", max_age_seconds=price_max_age_seconds)
         price_dec = d(price["price"]) if price and price["price"] else None
         value = qty * price_dec if price_dec is not None else None
         rows.append({
@@ -104,7 +104,7 @@
     for (wallet_id, asset_id), holding in sorted(holdings.wallet_holdings.items()):
         wallet = conn.execute("SELECT wallet_name, wallet_type, platform_provider FROM crypto_wallets WHERE wallet_id=?", (wallet_id,)).fetchone()
         asset = conn.execute("SELECT coin_name, symbol, coingecko_id FROM crypto_assets WHERE asset_id=?", (asset_id,)).fetchone()
-        price = latest_crypto_price(conn, asset_id, "CHF")
+        price = latest_crypto_price(conn, asset_id, "CHF", max_age_seconds=price_max_age_seconds)
         price_dec = d(price["price"]) if price and price["price"] else None
         value = holding.quantity * price_dec if price_dec is not None else None
         rows.append({


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

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


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

-def get_crypto_by_wallet(conn: Connection) -> list[dict[str, str]]:
+def get_crypto_by_wallet(conn: Connection, *, price_max_age_seconds: int = 86_400) -> list[dict[str, str]]:
     holdings = calculate_crypto_holdings(conn)
     rows: list[dict[str, str]] = []
     for (wallet_id, asset_id), holding in sorted(holdings.wallet_holdings.items()):


            "market_value_chf": dec_text(value, "0.01") if value is not None else "",
            "wallet_value_chf": dec_text(value, "0.01") if value is not None else "",
            "price_quality": price["quality_status"] if price else "missing",
            "price_fetched_at": price["fetched_at"] if price else "",
            "verification_status": holding.verification_status,

            "market_value_chf": dec_text(value, "0.01") if value is not None else "",
            "verification_status": holding.verification_status,


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/data.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/data.py
@@ -117,6 +117,9 @@
             "coingecko_id": asset["coingecko_id"] if asset else "",
             "quantity": dec_text(holding.quantity),
             "market_value_chf": dec_text(value, "0.01") if value is not None else "",
+            "wallet_value_chf": dec_text(value, "0.01") if value is not None else "",
+            "price_quality": price["quality_status"] if price else "missing",
+            "price_fetched_at": price["fetched_at"] if price else "",
             "verification_status": holding.verification_status,
             "last_verified_at": holding.last_verified_at or "",
         })


        "data_quality_status": "kritisch" if any(i["severity"] == "kritisch" and i["count"] != "0" for i in quality) else ("warnung" if any(i["severity"] == "warnung" and i["count"] != "0" for i in quality) else "ok"),
        "crypto_price_data_as_of": max((r["fetched_at"] for r in crypto_rows if r.get("fetched_at")), default=""),
    }

        "data_quality_status": "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"),
    }


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/data.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/data.py
@@ -288,4 +288,5 @@
         "total_value_chf": dec_text(cash_total + crypto_total, "0.01"),
         "active_alerts": ", ".join(f"{r['priority']}: {r['c']}" for r in alert_counts) or "0",
         "data_quality_status": "kritisch" if any(i["severity"] == "kritisch" and i["count"] != "0" for i in quality) else ("warnung" if any(i["severity"] == "warnung" and i["count"] != "0" for i in quality) else "ok"),
+        "crypto_price_data_as_of": max((r["fetched_at"] for r in crypto_rows if r.get("fetched_at")), default=""),
     }


    c4.metric("Data Quality", summary["data_quality_status"])
    st.caption(f"Crypto Preis-Datenstand lokal: {summary.get('crypto_price_data_as_of') or 'nicht verfügbar'}")


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/pages/page_00_command_center.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/dashboard/pages/page_00_command_center.py
@@ -13,6 +13,7 @@
     c2.metric("Cash", data.money_text(data.d(summary["cash_total_chf"]), "CHF"))
     c3.metric("Crypto", data.money_text(data.d(summary["crypto_total_chf"]), "CHF"))
     c4.metric("Data Quality", summary["data_quality_status"])
+    st.caption(f"Crypto Preis-Datenstand lokal: {summary.get('crypto_price_data_as_of') or 'nicht verfügbar'}")
     st.subheader("Cash-Übersicht")
     show_table(st, data.get_cash_overview(conn))
     st.subheader("Offene Alerts")


from jarvis_finance.market.providers import is_price_stale

from jarvis_finance.imports.common import utc_now


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/reports/crypto_inventory.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/reports/crypto_inventory.py
@@ -12,7 +12,7 @@
 from jarvis_finance.audit.log import record_audit_event
 from jarvis_finance.config.settings import Settings, ensure_runtime_dirs, validate_settings
 from jarvis_finance.crypto.holdings import calculate_crypto_holdings
-from jarvis_finance.imports.common import utc_now
+from jarvis_finance.market.providers import is_price_stale

 PDFRenderer = Callable[[str, Path], bool]

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


from jarvis_finance.imports.common import utc_now
from jarvis_finance.market.providers import is_price_stale


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/reports/crypto_inventory.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/reports/crypto_inventory.py
@@ -12,6 +12,7 @@
 from jarvis_finance.audit.log import record_audit_event
 from jarvis_finance.config.settings import Settings, ensure_runtime_dirs, validate_settings
 from jarvis_finance.crypto.holdings import calculate_crypto_holdings
+from jarvis_finance.imports.common import utc_now
 from jarvis_finance.market.providers import is_price_stale

 PDFRenderer = Callable[[str, Path], bool]


def _is_stale(value: str | None, *, max_age_seconds: int = 259_200) -> bool:
    return is_price_stale(value, max_age_seconds=max_age_seconds)

def _is_stale(value: str | None, *, max_age_days: int = 3) -> bool:
    dt = _parse_dt(value)
    if dt is None:
        return False
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)
    return datetime.now(timezone.utc) - dt > __import__("datetime").timedelta(days=max_age_days)


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/reports/crypto_inventory.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/reports/crypto_inventory.py
@@ -53,13 +53,8 @@
         return None

-def _is_stale(value: str | None, *, max_age_days: int = 3) -> bool:
-    dt = _parse_dt(value)
-    if dt is None:
-        return False
-    if dt.tzinfo is None:
-        dt = dt.replace(tzinfo=timezone.utc)
-    return datetime.now(timezone.utc) - dt > __import__("datetime").timedelta(days=max_age_days)
+def _is_stale(value: str | None, *, max_age_seconds: int = 259_200) -> bool:
+    return is_price_stale(value, max_age_seconds=max_age_seconds)

 def _add_warning(warnings: list[dict[str, str]], *, code: str, message: str, entity_type: str, entity_id: str | None = None) -> None:


def build_crypto_report_context(conn: Connection, *, base_currency: str = "CHF", price_max_age_seconds: int = 259_200) -> dict[str, Any]:

def build_crypto_report_context(conn: Connection, *, base_currency: str = "CHF") -> dict[str, Any]:


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/reports/crypto_inventory.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/reports/crypto_inventory.py
@@ -65,7 +65,7 @@
         warnings.append(item)

-def build_crypto_report_context(conn: Connection, *, base_currency: str = "CHF") -> dict[str, Any]:
+def build_crypto_report_context(conn: Connection, *, base_currency: str = "CHF", price_max_age_seconds: int = 259_200) -> dict[str, Any]:
     """Build a renderer-neutral crypto inventory report context from local SQLite data only."""
     holdings = calculate_crypto_holdings(conn)
     warnings: list[dict[str, str]] = []


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/reports/crypto_inventory.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/reports/crypto_inventory.py
@@ -99,7 +99,7 @@
             price_dec = _d(price["price"])
             price_timestamp = price["provider_timestamp"] or price["fetched_at"]
             provider = price["provider"]
-            if _is_stale(price_timestamp):
+            if _is_stale(price_timestamp, max_age_seconds=price_max_age_seconds):
                 _add_warning(warnings, code="stale_price", message="Lokaler Crypto-Preis ist veraltet.", entity_type="crypto_asset", entity_id=asset_id)
             latest_price_timestamp = max(filter(None, [latest_price_timestamp, price_timestamp])) if latest_price_timestamp else price_timestamp
         value = quantity * price_dec if price_dec is not None else None


from jarvis_finance.config.settings import ensure_runtime_dirs, load_settings
from jarvis_finance.market.providers import CoinGeckoClient, refresh_crypto_prices
from jarvis_finance.quality.git_safety import assert_safe, scan_path

from jarvis_finance.config.settings import ensure_runtime_dirs, load_settings
from jarvis_finance.quality.git_safety import assert_safe, scan_path


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/cli/main.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/cli/main.py
@@ -4,6 +4,7 @@
 from pathlib import Path

 from jarvis_finance.config.settings import ensure_runtime_dirs, load_settings
+from jarvis_finance.market.providers import CoinGeckoClient, refresh_crypto_prices
 from jarvis_finance.quality.git_safety import assert_safe, scan_path
 from jarvis_finance.storage.database import connect
 from jarvis_finance.storage.migrations import apply_migrations, get_schema_version


    scan = sub.add_parser("git-safety-scan")
    scan.add_argument("path", nargs="?", default=".")
    prices = sub.add_parser("update-crypto-prices")
    prices.add_argument("--currency", default="CHF")
    prices.add_argument("--max-age-seconds", type=int, default=3600)
    args = parser.parse_args(argv)

    scan = sub.add_parser("git-safety-scan")
    scan.add_argument("path", nargs="?", default=".")
    args = parser.parse_args(argv)


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/cli/main.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/cli/main.py
@@ -17,6 +17,9 @@
     sub.add_parser("migrate")
     scan = sub.add_parser("git-safety-scan")
     scan.add_argument("path", nargs="?", default=".")
+    prices = sub.add_parser("update-crypto-prices")
+    prices.add_argument("--currency", default="CHF")
+    prices.add_argument("--max-age-seconds", type=int, default=3600)
     args = parser.parse_args(argv)

     if args.command in {"init-db", "migrate"}:


    if args.command == "git-safety-scan":
        findings = scan_path(Path(args.path))
        if findings:
            for finding in findings:
                print(f"{finding.path}: {finding.reason}")
            return 1
        assert_safe(Path(args.path))
        print("GIT_SAFETY_OK")
        return 0
    if args.command == "update-crypto-prices":
        settings = load_settings()
        ensure_runtime_dirs(settings)
        conn = connect(settings.db_path)
        try:
            apply_migrations(conn)
            result = refresh_crypto_prices(
                conn,
                provider=CoinGeckoClient(),
                currency=args.currency,
                max_age_seconds=args.max_age_seconds,
            )
            print(
                "CRYPTO_PRICE_UPDATE "
                f"currency={result.currency} total={result.total_assets} success={result.success_count} "
                f"cached={result.cached_count} skipped={result.skipped_count} "
                f"warnings={result.warning_count} errors={result.error_count}"
            )
            return 1 if result.error_count else 0
        finally:
            conn.close()
    return 2

    if args.command == "git-safety-scan":
        findings = scan_path(Path(args.path))
        if findings:
            for finding in findings:
                print(f"{finding.path}: {finding.reason}")
            return 1
        assert_safe(Path(args.path))
        print("GIT_SAFETY_OK")
        return 0
    return 2


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/cli/main.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/cli/main.py
@@ -38,6 +38,27 @@
         assert_safe(Path(args.path))
         print("GIT_SAFETY_OK")
         return 0
+    if args.command == "update-crypto-prices":
+        settings = load_settings()
+        ensure_runtime_dirs(settings)
+        conn = connect(settings.db_path)
+        try:
+            apply_migrations(conn)
+            result = refresh_crypto_prices(
+                conn,
+                provider=CoinGeckoClient(),
+                currency=args.currency,
+                max_age_seconds=args.max_age_seconds,
+            )
+            print(
+                "CRYPTO_PRICE_UPDATE "
+                f"currency={result.currency} total={result.total_assets} success={result.success_count} "
+                f"cached={result.cached_count} skipped={result.skipped_count} "
+                f"warnings={result.warning_count} errors={result.error_count}"
+            )
+            return 1 if result.error_count else 0
+        finally:
+            conn.close()
     return 2


terminal

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


Output:
.......                                                                  [100%]
7 passed in 0.65s


todo

Bestehende Crypto-Preis/Provider/CLI/Report-Struktur inspizieren

Tests für Price Update Command, Cache/Staleness, Dashboard/Report mit Mock-Provider schreiben und RED prüfen

Provider-Schicht, CLI-Befehl, lokale Speicherung, Cache/Staleness, Dashboard/Report-Integration implementieren


Compile, Tests, Git-Safety, Git-Status prüfen

Commit und Push durchführen

Optionalen produktiven Runtime-Preisupdate kontrolliert ausführen und aggregiert verifizieren

Aggregierten Bericht ohne echte Werte/Mengen liefern


Explanation:
**Updating crypto page**

I need to update the crypto page to show the correct data. I think it’s important to test everything, focusing on the existing CoinGecko tests. Those tests expect a provider timestamp of "1700000000," but I realize that it should only include the price instead. I’ll make sure to run all the tests to confirm everything is working as expected. Gotta ensure accuracy here!

terminal

set -euo pipefail
cd /home/agent/.hermes/repos/FinanceManager
PYTHONPATH=src python -m compileall src tests >/tmp/fm_compile_crypto_prices.log
PYTHONPATH=src pytest tests -q
find . -type d \( -name __pycache__ -o -name .pytest_cache \) -prune -exec rm -rf {} +
PYTHONPATH=src python -m jarvis_finance.cli.main git-safety-scan .
git status --short --branch


Output:
........................................................................ [ 70%]
..............F...............                                           [100%]
=================================== FAILURES ===================================
________ test_refresh_crypto_prices_stores_decimal_text_and_uses_cache _________

    def test_refresh_crypto_prices_stores_decimal_text_and_uses_cache() -> None:
        c = conn(); asset = create_crypto_asset(c, coin_name='Synthetic Bitcoin', symbol='BTC', coingecko_id='bitcoin')
        provider = MockProvider([PriceQuote('bitcoin','CHF',Decimal('12345.00000001'),provider_timestamp='1700000000')])
        ids = refresh_crypto_prices(c, provider=provider, currency='CHF')
        assert ids
        row = c.execute('SELECT price, typeof(price), quality_status FROM crypto_prices WHERE asset_id=?', (asset,)).fetchone()
        assert row['price'] == '12345.00000001'
        assert row['typeof(price)'] == 'text'
        assert row['quality_status'] == 'fresh'
>       assert refresh_crypto_prices(c, provider=provider, currency='CHF', max_age_seconds=3600) == []
E       AssertionError: assert PriceRefreshResult(currency='CHF', total_assets=1, success_count=0, skipped_count=0, cached_count=1, warning_count=0, error_count=0, written_price_ids=[], warnings=[], errors=[]) == []
E        +  where PriceRefreshResult(currency='CHF', total_assets=1, success_count=0, skipped_count=0, cached_count=1, warning_count=0, error_count=0, written_price_ids=[], warnings=[], errors=[]) = refresh_crypto_prices(<sqlite3.Connection object at 0x7fbeeffb6d40>, provider=<test_market_alerts.MockProvider object at 0x7fbeefc33dd0>, currency='CHF', max_age_seconds=3600)

tests/unit/test_market_alerts.py:34: AssertionError
=========================== short test summary info ============================
FAILED tests/unit/test_market_alerts.py::test_refresh_crypto_prices_stores_decimal_text_and_uses_cache
1 failed, 101 passed in 0.99s


Explanation:
**Evaluating tests and comparisons**

I need to figure out whether to adjust the old test or implement the `__eq__` method to compare lists. Hacking a solution could be possible too. It feels like I should maybe patch the test to reflect the new results adequately. I'll need to read through the file to get more context before deciding on the best approach. Let's see what adjustments are necessary to ensure everything works smoothly!


     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.market.providers import PriceQuote, refresh_crypto_prices
     7|from jarvis_finance.quality.alerts import create_alert, mute_alert, resolve_alert
     8|from jarvis_finance.storage.database import connect_memory
     9|from jarvis_finance.storage.migrations import apply_migrations
    10|
    11|
    12|class MockProvider:
    13|    def __init__(self, quotes):
    14|        self.quotes = list(quotes)
    15|        self.calls = 0
    16|    def get_crypto_price(self, coingecko_id: str, currency: str = 'CHF') -> PriceQuote:
    17|        self.calls += 1
    18|        return self.quotes.pop(0)
    19|
    20|
    21|def conn():
    22|    c = connect_memory(); apply_migrations(c); return c
    23|
    24|
    25|def test_refresh_crypto_prices_stores_decimal_text_and_uses_cache() -> None:
    26|    c = conn(); asset = create_crypto_asset(c, coin_name='Synthetic Bitcoin', symbol='BTC', coingecko_id='bitcoin')
    27|    provider = MockProvider([PriceQuote('bitcoin','CHF',Decimal('12345.00000001'),provider_timestamp='1700000000')])
    28|    ids = refresh_crypto_prices(c, provider=provider, currency='CHF')
    29|    assert ids
    30|    row = c.execute('SELECT price, typeof(price), quality_status FROM crypto_prices WHERE asset_id=?', (asset,)).fetchone()
    31|    assert row['price'] == '12345.00000001'
    32|    assert row['typeof(price)'] == 'text'
    33|    assert row['quality_status'] == 'fresh'
    34|    assert refresh_crypto_prices(c, provider=provider, currency='CHF', max_age_seconds=3600) == []
    35|    assert provider.calls == 1
    36|
    37|
    38|def test_refresh_crypto_prices_warns_missing_coingecko_and_missing_price_dedupes() -> None:
    39|    c = conn(); missing = create_crypto_asset(c, coin_name='Synthetic Mystery', symbol='MYS')
    40|    asset = create_crypto_asset(c, coin_name='Synthetic Ether', symbol='ETH', coingecko_id='ethereum')
    41|    provider = MockProvider([PriceQuote('ethereum','CHF',None,quality_status='missing',error_message='no price')])
    42|    refresh_crypto_prices(c, provider=provider, currency='CHF', max_age_seconds=0)
    43|    refresh_crypto_prices(c, provider=MockProvider([PriceQuote('ethereum','CHF',None,quality_status='missing',error_message='no price')]), currency='CHF', max_age_seconds=0)
    44|    assert c.execute("SELECT COUNT(*) AS c FROM alerts WHERE rule_id='missing_coingecko_id' AND entity_id=?", (missing,)).fetchone()['c'] == 1
    45|    row = c.execute("SELECT COUNT(*) AS c, MAX(occurrence_count) AS occ FROM alerts WHERE rule_id='crypto_price_missing' AND entity_id=?", (asset,)).fetchone()
    46|    assert row['c'] == 1
    47|    assert row['occ'] >= 2
    48|
    49|
    50|def test_alert_lifecycle_dedupes_resolved_and_muted_but_allows_new_fingerprint() -> None:
    51|    c = conn()
    52|    a1 = create_alert(c, priority='kritisch', category='fx', entity_type='transaction', entity_id='t1', rule_id='missing_fx', message='missing fx', evidence={'currency':'USD','date':'2026-01-01'})
    53|    a2 = create_alert(c, priority='kritisch', category='fx', entity_type='transaction', entity_id='t1', rule_id='missing_fx', message='missing fx', evidence={'currency':'USD','date':'2026-01-01'})
    54|    assert a1 == a2
    55|    resolve_alert(c, a1)
    56|    assert create_alert(c, priority='kritisch', category='fx', entity_type='transaction', entity_id='t1', rule_id='missing_fx', message='missing fx', evidence={'currency':'USD','date':'2026-01-01'}) == a1
    57|    mute_alert(c, a1, muted_until='2099-01-01T00:00:00Z')
    58|    assert create_alert(c, priority='kritisch', category='fx', entity_type='transaction', entity_id='t1', rule_id='missing_fx', message='missing fx', evidence={'currency':'USD','date':'2026-01-01'}) == a1
    59|    a3 = create_alert(c, priority='kritisch', category='fx', entity_type='transaction', entity_id='t1', rule_id='missing_fx', message='missing fx', evidence={'currency':'EUR','date':'2026-01-01'})
    60|    assert a3 != a1
    61|


patch

    cached = refresh_crypto_prices(c, provider=provider, currency='CHF', max_age_seconds=3600)
    assert cached.written_price_ids == []
    assert cached.cached_count == 1

assert refresh_crypto_prices(c, provider=provider, currency='CHF', max_age_seconds=3600) == []


--- a//home/agent/.hermes/repos/FinanceManager/tests/unit/test_market_alerts.py
+++ b//home/agent/.hermes/repos/FinanceManager/tests/unit/test_market_alerts.py
@@ -31,7 +31,9 @@
     assert row['price'] == '12345.00000001'
     assert row['typeof(price)'] == 'text'
     assert row['quality_status'] == 'fresh'
-    assert refresh_crypto_prices(c, provider=provider, currency='CHF', max_age_seconds=3600) == []
+    cached = refresh_crypto_prices(c, provider=provider, currency='CHF', max_age_seconds=3600)
+    assert cached.written_price_ids == []
+    assert cached.cached_count == 1
     assert provider.calls == 1


terminal

Output:
........................................................................ [ 70%]
..............................                                           [100%]
102 passed in 0.82s
GIT_SAFETY_OK
## main...origin/main
 M src/jarvis_finance/cli/main.py
 M src/jarvis_finance/dashboard/data.py
 M src/jarvis_finance/dashboard/pages/page_00_command_center.py
 M src/jarvis_finance/market/providers.py
 M src/jarvis_finance/reports/crypto_inventory.py
 M tests/unit/test_market_alerts.py
?? tests/unit/test_crypto_price_update.py


todo

Commit und Push durchführen


terminal

set -euo pipefail
cd /home/agent/.hermes/repos/FinanceManager
git diff --stat
git add src/jarvis_finance/cli/main.py src/jarvis_finance/dashboard/data.py src/jarvis_finance/dashboard/pages/page_00_command_center.py src/jarvis_finance/market/providers.py src/jarvis_finance/reports/crypto_inventory.py tests/unit/test_market_alerts.py tests/unit/test_crypto_price_update.py
git commit -m "feat: add crypto price refresh command"
git rev-parse --short HEAD
git status --short --branch


Output:
src/jarvis_finance/cli/main.py                     |  25 ++++++
 src/jarvis_finance/dashboard/data.py               |  27 ++++--
 .../dashboard/pages/page_00_command_center.py      |   1 +
 src/jarvis_finance/market/providers.py             | 100 +++++++++++++++++----
 src/jarvis_finance/reports/crypto_inventory.py     |  14 ++-
 tests/unit/test_market_alerts.py                   |   4 +-
 6 files changed, 134 insertions(+), 37 deletions(-)
[main 3aa67d8] feat: add crypto price refresh command
 7 files changed, 301 insertions(+), 37 deletions(-)
 create mode 100644 tests/unit/test_crypto_price_update.py
3aa67d8
## main...origin/main [voraus 1]


set -euo pipefail
cd /home/agent/.hermes/repos/FinanceManager
PYTHONPATH=src python -m compileall src tests >/tmp/fm_compile_crypto_prices_push.log
PYTHONPATH=src pytest tests -q
find . -type d \( -name __pycache__ -o -name .pytest_cache \) -prune -exec rm -rf {} +
PYTHONPATH=src python -m jarvis_finance.cli.main git-safety-scan .
git status --short --branch
TOKEN=$(python - <<'PY'
import re
from pathlib import Path
text=Path('/home/agent/jarvis_runtime/finance-system/secrets/Github_token_FinanceManager.csv').read_text()
m=re.search(r'github_pat_[A-Za-z0-9_]+|ghp_[A-Za-z0-9_]+|gho_[A-Za-z0-9_]+', text)
if not m:
    raise SystemExit('token_not_found')
print(m.group(0))
PY
)
AUTH_URL="https://${TOKEN}@github.com/Gamexgit/FinanceManager.git"
git fetch "$AUTH_URL" main:refs/remotes/origin/main --quiet
ORIGIN=$(git rev-parse origin/main)
BASE=$(git merge-base HEAD origin/main)
if [ "$ORIGIN" != "$BASE" ]; then
  unset TOKEN AUTH_URL
  echo 'REMOTE_HAS_NEW_COMMITS=yes'
  exit 3
fi
git push "$AUTH_URL" main:main
unset TOKEN AUTH_URL
echo local_head=$(git rev-parse --short HEAD)
echo remote_head=$(git rev-parse --short origin/main)
PYTHONPATH=src python -m jarvis_finance.cli.main git-safety-scan .
git status --short --branch


Output:
........................................................................ [ 70%]
..............................                                           [100%]
102 passed in 0.94s
GIT_SAFETY_OK
## main...origin/main [voraus 1]
To https://github.com/Gamexgit/FinanceManager.git
   1131cde..3aa67d8  main -> main
local_head=3aa67d8
remote_head=1131cde
GIT_SAFETY_OK
## main...origin/main [voraus 1]


Explanation:
**Considering remote updates**

I'm noticing that the remote head hasn't updated because it seems I haven't fetched after pushing. My local branch for origin/main is still old, which isn’t ideal. Ah, I need to fetch the latest updates to ensure everything's synced properly. It feels like a simple oversight, but it's important to stay on top of these small details to avoid confusion later. I should definitely prioritize fetching the authentication updates!

set -euo pipefail
cd /home/agent/.hermes/repos/FinanceManager
TOKEN=$(python - <<'PY'
import re
from pathlib import Path
text=Path('/home/agent/jarvis_runtime/finance-system/secrets/Github_token_FinanceManager.csv').read_text()
print(re.search(r'github_pat_[A-Za-z0-9_]+|ghp_[A-Za-z0-9_]+|gho_[A-Za-z0-9_]+', text).group(0))
PY
)
AUTH_URL="https://${TOKEN}@github.com/Gamexgit/FinanceManager.git"
git fetch "$AUTH_URL" main:refs/remotes/origin/main --quiet
unset TOKEN AUTH_URL
echo local_head=$(git rev-parse --short HEAD)
echo remote_head=$(git rev-parse --short origin/main)
git status --short --branch


Output:
local_head=3aa67d8
remote_head=3aa67d8
## main...origin/main


todo

Commit und Push durchführen


terminal

set -euo pipefail
cd /home/agent/.hermes/repos/FinanceManager
PYTHONPATH=src python -m jarvis_finance.cli.main git-safety-scan .
PYTHONPATH=src python -m jarvis_finance.cli.main update-crypto-prices --currency CHF --max-age-seconds 0
PYTHONPATH=src python - <<'PY'
from jarvis_finance.config.settings import load_settings, find_repo_root
from jarvis_finance.storage.database import connect
from jarvis_finance.reports.crypto_inventory import build_crypto_report_context
from jarvis_finance.dashboard import data
s=load_settings(repo_root=find_repo_root())
conn=connect(s.db_path)
try:
    assets_with_prices=conn.execute("SELECT COUNT(DISTINCT asset_id) AS c FROM crypto_prices WHERE price_currency='CHF' AND quality_status='fresh' AND COALESCE(price,'')!=''").fetchone()['c']
    latest_rows=conn.execute("SELECT quality_status, COUNT(*) AS c FROM (SELECT cp.* FROM crypto_prices cp JOIN (SELECT asset_id, MAX(fetched_at) AS max_fetched FROM crypto_prices WHERE price_currency='CHF' GROUP BY asset_id) latest ON latest.asset_id=cp.asset_id AND latest.max_fetched=cp.fetched_at) GROUP BY quality_status").fetchall()
    ctx=build_crypto_report_context(conn)
    overview=data.get_crypto_overview(conn)
    print('runtime_assets_with_fresh_chf_price=' + str(assets_with_prices))
    print('runtime_latest_price_statuses=' + ','.join(f"{r['quality_status']}:{r['c']}" for r in latest_rows))
    print('dashboard_rows_with_local_price=' + str(sum(1 for r in overview if r.get('latest_price_chf'))))
    print('report_rows_with_local_price=' + str(sum(1 for r in ctx['coins'] if r.get('price_chf'))))
    print('report_warning_count=' + str(len(ctx['data_quality_warnings'])))
    print('report_has_live_api_calls=' + str(ctx['metadata']['live_api_calls']))
finally:
    conn.close()
PY
PYTHONPATH=src python -m jarvis_finance.cli.main git-safety-scan .
git status --short --branch


Output:
GIT_SAFETY_OK
CRYPTO_PRICE_UPDATE currency=CHF total=20 success=6 cached=0 skipped=0 warnings=14 errors=0
runtime_assets_with_fresh_chf_price=6
runtime_latest_price_statuses=fresh:6,stale:14
dashboard_rows_with_local_price=6
report_rows_with_local_price=20
report_warning_count=5
report_has_live_api_calls=False
GIT_SAFETY_OK
## main...origin/main


todo

Commit und Push durchführen


Erledigt, Sir.

## Ergebnis — Crypto Price Update MVP

- Preisupdate implementiert: **ja**
- CLI-Befehl:
  ```bash
  finance update-crypto-prices
  ```
  bzw. direkt:
  ```bash
  PYTHONPATH=src python -m jarvis_finance.cli.main update-crypto-prices --currency CHF --max-age-seconds 3600
  ```

- Produktiver Preisupdate ausgeführt: **ja**
- Anzahl Assets mit Preis aktualisiert: **6**
- Anzahl Assets übersprungen: **0**
- Anzahl Fehler: **0**
- Anzahl Warnings: **14**
- Grund der Warnings: Provider-/Rate-Limit-nahe Antworten wurden sauber als nicht-fresh/stale behandelt; kein Crash, keine Live-Dashboard-Abhängigkeit.
- Dashboard nutzt lokale Preise: **ja**
- Report Context nutzt lokale Preise: **ja**
- Reports/Dashboard machen beim Rendern Live-API-Calls: **nein**
- Echte Mengen/Werte im Chat ausgegeben: **nein**
- Echte Daten ins Git-Repository geschrieben: **nein**

## Implementiert

### 1. CLI Command

Neu:

```bash
finance update-crypto-prices
```

Optionen:

```bash
--currency CHF
--max-age-seconds 3600
```

Der Command:

- lädt aktive Crypto Assets aus der Runtime-DB
- nutzt nur Assets mit CoinGecko-ID für API-Abfragen
- ruft Preise über Provider-Schicht ab
- speichert lokale Preise in `crypto_prices`
- speichert:
  - `asset_id`
  - `coingecko_id`
  - `price_currency`
  - `price`
  - `provider`
  - `provider_timestamp`
  - `fetched_at`
  - `quality_status`
  - `error_message`
- gibt nur aggregierte Summary aus:
  - total
  - success
  - cached
  - skipped
  - warnings
  - errors

Keine Mengen, keine Werte. Ganz ohne das übliche „hier sind versehentlich Ihre Bankdaten im Log“, wie zivilisierte Software.

### 2. Provider-Schicht / CoinGecko

Erweitert in:

```text
src/jarvis_finance/market/providers.py
```

Enthält jetzt:

- `CoinGeckoClient`
- `PriceQuote`
- `PriceRefreshResult`
- `refresh_crypto_prices`
- `store_crypto_price`
- `latest_crypto_price_row`
- `is_price_stale`

Verhalten:

- CoinGecko-Aufruf über Provider-Schicht
- 429 Backoff vorhanden
- max retries vorhanden
- Preiswerte via `Decimal(str(value))`
- lokale Speicherung als Text
- kein Float-Pfad im Speichern
- 429 nach Retry wird als `stale`/Warning behandelt
- Fehler pro Coin werden gesammelt, nicht global fatalisiert

### 3. Cache-/Staleness-Logik

Implementiert:

- `max_age_seconds`
- frischer lokaler Preis verhindert erneuten Abruf
- alte Preise werden als `stale` markiert
- fehlende Preise erzeugen Data-Quality-Warnings
- fehlende CoinGecko-ID wird als Warning/Skip behandelt
- Alert-Dedupe bleibt erhalten

### 4. Dashboard-Integration

Erweitert:

```text
src/jarvis_finance/dashboard/data.py
src/jarvis_finance/dashboard/pages/page_00_command_center.py
```

Dashboard liest:

- lokale Preise aus `crypto_prices`
- Crypto-Gesamtwert in CHF aus lokaler DB
- Coin-Wert CHF je Coin
- Wallet-Wert CHF je Wallet
- Price Quality
- Price fetched timestamp
- Missing/Stale Price Hinweise

Wichtig:

- keine Live-API beim Rendern
- Command Center zeigt lokalen Crypto-Preis-Datenstand

### 5. Report-Integration

Erweitert/geprüft:

```text
src/jarvis_finance/reports/crypto_inventory.py
```

Report Context:

- liest lokale Preise aus `crypto_prices`
- nutzt `price_max_age_seconds`
- markiert stale/missing prices
- verbessert Data-Quality-Status, wenn lokale Preise vorhanden sind
- `metadata.live_api_calls = False`
- Report-Export bleibt auf Runtime-Reports-Ordner beschränkt

## Tests

Neu/erweitert:

```text
tests/unit/test_crypto_price_update.py
tests/unit/test_market_alerts.py
```

Abgedeckt:

- CLI-Command lädt aktive Crypto Assets
- Mock-Provider speichert CHF-Preise
- fehlende CoinGecko-ID wird übersprungen/warnt
- 429 Backoff wird genutzt
- frischer Cache verhindert erneuten Abruf
- stale Preis wird erkannt
- Dashboard liest lokale Preise ohne API-Call
- Crypto-Report nutzt lokale Preise
- Alert-Dedupe bleibt sauber

Testlauf:

```text
102 passed
```

## Qualität / Safety

Ausgeführt:

```bash
PYTHONPATH=src python -m compileall src tests
PYTHONPATH=src pytest tests -q
PYTHONPATH=src python -m jarvis_finance.cli.main git-safety-scan .
git status --short --branch
```

Ergebnis:

- Compile: **OK**
- Tests: **102 passed**
- Git-Safety vor Commit: **GIT_SAFETY_OK**
- Git-Safety nach Runtime-Lauf: **GIT_SAFETY_OK**
- Git Status final: **clean / synchron mit origin/main**

## Commit & Push

- Commit: `3aa67d8`
- Message:
  ```text
  feat: add crypto price refresh command
  ```
- Push-Status: **erfolgreich**
- Local HEAD: `3aa67d8`
- Remote HEAD: `3aa67d8`

## Produktiver Runtime-Lauf — aggregiert

Ausgeführt gegen produktive Runtime-DB:

```bash
PYTHONPATH=src python -m jarvis_finance.cli.main update-crypto-prices --currency CHF --max-age-seconds 0
```

Aggregiertes Ergebnis:

- Total aktive Crypto Assets: **20**
- Erfolgreich aktualisiert: **6**
- Cached: **0**
- Skipped: **0**
- Warnings: **14**
- Errors: **0**
- Assets mit frischem CHF-Preis lokal: **6**
- Dashboard-Zeilen mit lokalem Preis: **6**
- Report Context nutzt lokale Preise: **ja**
- Report Context Live API Calls: **nein**

## Offene Risiken

- CoinGecko Free/API Rate Limits haben beim produktiven Lauf offenbar nicht alle Assets frisch geliefert. Das System behandelt das jetzt sauber als Warning/Stale statt als Fatal Error.
- Für den nächsten Schritt wäre sinnvoll:
  - optional Batch-Request für mehrere CoinGecko-IDs pro Call
  - längerer Backoff / konservativeres Throttling
  - optional `--limit` oder `--only-symbol`
  - Preisupdate-Cron später, aber erst nach Report-Prüfung

Damit ist der nächste Schritt vorbereitet: **echten Crypto-Report mit aktuellen lokalen Preisen erzeugen und prüfen**, ohne Live-API im Report oder Dashboard.