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

Use offset=261 to continue reading (showing 1-260 of 358 lines)


terminal

set -euo pipefail
cd /home/agent/.hermes/repos/FinanceManager
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.storage.migrations import apply_migrations
from jarvis_finance.dashboard.pages import page_12_crypto_manage

class DummyContext:
    def __enter__(self): return self
    def __exit__(self, *args): return False

class FakeStreamlit:
    def __init__(self): self.calls=[]
    def __getattr__(self, name):
        def f(*args, **kwargs):
            self.calls.append(name)
            if name == 'tabs': return [DummyContext() for _ in args[0]]
            if name in {'selectbox','radio'}:
                opts=args[1] if len(args)>1 else kwargs.get('options', [])
                return opts[0] if opts else None
            if name == 'date_input': return kwargs.get('value')
            if name in {'button','checkbox'}: return False
            if name in {'text_input','text_area'}: return ''
            return None
        return f

settings=load_settings(repo_root=find_repo_root())
conn=connect(settings.db_path)
try:
    apply_migrations(conn)
    st=FakeStreamlit()
    page_12_crypto_manage.render(st, conn)
    print('crypto_manage_render_no_button_write=OK')
    print('fake_st_calls=' + str(len(st.calls)))
finally:
    conn.close()
PY
find . -type d \( -name __pycache__ -o -name .pytest_cache \) -prune -exec rm -rf {} +
git status --short --branch


Output:
crypto_manage_render_no_button_write=OK
fake_st_calls=41
## main...origin/main


Verstanden, Sir. Ich starte **keinen neuen Feature-Block**. Ich habe nur den kontrollierten Praxistest vorbereitet und **nichts in die produktive DB geschrieben**.

## 1. Dashboard-Start vorbereitet

Geprüft:

- Produktive Runtime-DB vorhanden: **ja**
- Runtime außerhalb Repo: **ja**
- Crypto Manage Seite verfügbar: **ja**
- Dashboard-Smoke-Render ohne Speichern: **OK**
- Keine Live-API beim Render-Smoke-Test: **OK**
- Git-Safety aktuell: **GIT_SAFETY_OK**
- Git Status: **clean / synchron mit origin/main**
- Aktive Crypto-Wallets: **8**
- BTC in aktueller Runtime-DB: **noch nicht als Asset vorhanden**
- Doppelte Holding-Paare `(asset_id + wallet_id)`: **0**

Streamlit ist lokal aktuell **nicht installiert**.

Installations-/Startbefehl:

```bash
cd /home/agent/.hermes/repos/FinanceManager
python -m pip install -e '.[dashboard]'
PYTHONPATH=src python -m streamlit run src/jarvis_finance/app.py --server.address 127.0.0.1 --server.port 8501
```

Das Dashboard nutzt standardmäßig:

```bash
~/jarvis_runtime/finance-system/data/finance.sqlite3
```

Es erstellt oder verändert die echte DB **nicht automatisch**. Schreibvorgang erfolgt erst über die explizite UI-Bestätigung.

## 2. Test-Coin

Für den Einzeltest ist **Bitcoin** fachlich eindeutig und in der Runtime-DB aktuell noch nicht vorhanden.

Bitte verwenden:

- Coin-Name: `Bitcoin`
- Symbol: `BTC`
- CoinGecko-ID: `bitcoin`
- CoinGecko-Mapping-Status: `selected`

## 3. UI-Testablauf

Im Dashboard:

1. Seite öffnen:
   - Navigation: **Crypto Manage**

2. Tab öffnen:
   - **Hinzufügen**

3. Felder ausfüllen:

   - Coin-Name: `Bitcoin`
   - Symbol: `BTC`
   - CoinGecko-ID: `bitcoin`
   - CoinGecko-Mapping-Status: `selected`
   - Wallet: **das Ziel-Wallet auswählen, das zur BTC-Zeile in deiner Excel-Datei gehört**
   - Menge als Decimal-Text: **exakt aus deiner Excel-Datei übernehmen**
     - keine Rundung
     - kein Tausendertrennzeichen
     - Dezimalpunkt/Decimal-Text so eingeben, dass es als Decimal lesbar ist
   - Typ: `initial_snapshot_addition`
   - Datum: `31.12.2025`
     - intern wird `2025-12-31` gespeichert
   - Notiz:
     ```text
     Nachtrag aus initialer Crypto-Bestandsdatei, manuell geprüft
     ```

4. Review prüfen:
   - Aktion: `initial_snapshot_addition`
   - Coin: Bitcoin / BTC
   - CoinGecko-ID: bitcoin
   - Wallet stimmt
   - Datum stimmt
   - Es ist nur **eine einzelne Änderung**

5. Checkbox setzen:
   - **Ich bestätige diese einzelne Änderung**

6. Button drücken:
   - **Bestätigt speichern**

Wichtig: Falls das Dashboard meldet, dass bereits eine Holding für exakt dieselbe Kombination aus `BTC + Wallet` existiert, **nicht erzwingen**. Dann wäre `initial_snapshot_addition` korrekt blockiert und wir würden stattdessen gezielt prüfen, ob `manual_adjustment` fachlich passend ist.

## 4. Was ich nach deiner Durchführung prüfe

Sobald du im Dashboard gespeichert hast und mir kurz „gespeichert“ meldest, prüfe ich aggregiert und ohne Mengen-/Werte-Ausgabe:

- BTC-Asset vorhanden: ja/nein
- CoinGecko-ID bewusst gesetzt: ja/nein
- Holding wurde angelegt: ja/nein
- keine doppelte Holding für dieselbe `asset_id + wallet_id`: ja/nein
- Quantity-Spalte als Text/Decimal gespeichert: ja/nein
- Audit-Log für Änderung vorhanden: ja/nein
- Initial-Snapshot-/History-Spur vorhanden: ja/nein
- Crypto-Seite / Read Model enthält BTC: ja/nein
- Report Context enthält BTC: ja/nein
- Git-Safety bleibt sauber
- Git Status bleibt clean

Ich werde dabei **keine echten Mengen oder Werte im Chat ausgeben**.

## 5. Stop-Punkt

Ich schreibe jetzt **nicht automatisch** in die Runtime-DB.

Bitte führe den Dashboard-Schritt aus, sobald Streamlit installiert/gestartet ist. Danach antworte hier einfach mit:

```text
BTC-Test gespeichert
```

Dann führe ich die Nachprüfung aus und berichte nur aggregiert.

Explanation:
**Considering installation steps**

I’m thinking about whether I need to run some compile tests. Since there aren’t any changes, it might not be necessary, but it could reassure me. I already did a pre-check, yet I want to mention git safety. I might need to create a post-check script or run a command depending on the situation. Streamlit isn't available, which means I can’t start the server until it’s installed. If the user wants to test after the UI, I could ask them to perform that test and then let me know!
**Asking for confirmation**

I might want to confirm with the user about whether we can proceed with the dashboard context tests. It’s important to ask for explicit confirmation before moving forward since the final response can provide necessary instructions. I could suggest that the user runs the test, perhaps saying something like, "Please confirm..."

I also need to note that the BTC asset rows are currently absent, with eight active wallets but no quantities. It would be good to mention they should choose a wallet from Excel, and if the dashboard displays names, that’s fine. The date input expects an ISO format, so 31.12.2025 should be recorded as 2025-12-31, and I'll perhaps suggest verification after saving with commands or a script.
**Considering installation commands**

I might need to include a command that allows printing with no quantities. Once the user completes their tasks, I can run checks through the terminal, especially since they might run the dashboard on the same machine that the database modifies. If the user can’t run it, Streamlit is missing, so I’d want to provide an install command. It looks like I’ll suggest:

```bash
cd /home/agent/.hermes/repos/FinanceManager
python -m pip install -e '.[dashboard]'
PYTHONPATH=src python -m streamlit run src/jarvis_finance/app.py --server.address 127.0.0.1 --server.port 8501
```

Also, we need to ensure the production database is set to default.