"""Live MCP fetch for the GUI (saldi exchange, FX rate). The original architecture forbade the GUI from calling MCP services (`docs/11-gui-streamlit.md`). For the "Saldi exchange" panel that constraint is relaxed: the dashboard fetches balances on demand, caches the result with Streamlit's TTL cache, and never holds the async client open between renders. Every fetch is a one-shot: * read endpoints + token from env (same path used by the CLI), * spin up a short-lived ``httpx.AsyncClient``, * query Deribit `get_account_summary` for both ``USDC`` and ``USDT``, * query Hyperliquid `get_account_summary` (returns ``spot_usdc``, ``perps_equity`` etc.), * query Macro `get_asset_price("EURUSD")` for FX, * close the client and return a frozen dataclass to the page. If a single exchange call fails the row is filled with ``error=...`` and the others are still rendered. """ from __future__ import annotations import asyncio from dataclasses import dataclass from datetime import UTC, datetime from decimal import Decimal from typing import Any import httpx from cerbero_bite.clients._base import HttpToolClient from cerbero_bite.clients.deribit import DeribitClient from cerbero_bite.clients.hyperliquid import HyperliquidClient from cerbero_bite.clients.macro import MacroClient from cerbero_bite.config.mcp_endpoints import load_endpoints, load_token __all__ = [ "BalanceRow", "BalancesSnapshot", "fetch_balances_sync", ] _DERIBIT_CURRENCIES = ("USDC", "USDT") @dataclass(frozen=True) class BalanceRow: """One row of the balances table.""" exchange: str currency: str equity: Decimal | None available: Decimal | None unrealized_pnl: Decimal | None error: str | None = None @dataclass(frozen=True) class BalancesSnapshot: """Result of one fetch_balances call (rows + meta).""" rows: list[BalanceRow] eur_usd_rate: Decimal | None fetched_at: datetime fx_error: str | None = None def total_usd(self) -> Decimal: total = Decimal(0) for r in self.rows: if r.equity is not None: total += r.equity return total def total_eur(self) -> Decimal | None: if self.eur_usd_rate is None or self.eur_usd_rate <= 0: return None return self.total_usd() / self.eur_usd_rate def _decimal_or_none(value: Any) -> Decimal | None: if value is None: return None try: return Decimal(str(value)) except (ValueError, ArithmeticError): return None def _resolve_token() -> str: """Read the MCP bearer token from the environment. The token is sourced from ``CERBERO_BITE_MCP_TOKEN``; on Cerbero MCP V2 the same single token decides whether the upstream environment is testnet or mainnet. """ return load_token() async def _fetch_deribit_currency( deribit: DeribitClient, currency: str ) -> BalanceRow: try: summary = await deribit.get_account_summary(currency=currency) except Exception as exc: return BalanceRow( exchange="deribit", currency=currency, equity=None, available=None, unrealized_pnl=None, error=f"{type(exc).__name__}: {exc}", ) # Cerbero MCP V2 returns HTTP 200 with a soft ``error`` field when # the upstream Deribit call failed (e.g. invalid credentials). Treat # that as a row-level failure so the dashboard surfaces the cause # instead of showing a misleading equity=0. soft_error = summary.get("error") if soft_error: return BalanceRow( exchange="deribit", currency=currency, equity=None, available=None, unrealized_pnl=None, error=str(soft_error), ) return BalanceRow( exchange="deribit", currency=currency, equity=_decimal_or_none(summary.get("equity")), available=_decimal_or_none(summary.get("available_funds")), unrealized_pnl=_decimal_or_none(summary.get("unrealized_pnl")), ) async def _fetch_hyperliquid(hl: HyperliquidClient) -> list[BalanceRow]: try: summary = await hl.get_account_summary() except Exception as exc: return [ BalanceRow( exchange="hyperliquid", currency="USDC", equity=None, available=None, unrealized_pnl=None, error=f"{type(exc).__name__}: {exc}", ) ] rows: list[BalanceRow] = [ BalanceRow( exchange="hyperliquid", currency="USDC", equity=_decimal_or_none(summary.get("equity")), available=_decimal_or_none(summary.get("available_balance")), unrealized_pnl=_decimal_or_none(summary.get("unrealized_pnl")), ) ] # Hyperliquid spot may also hold USDT; the MCP server exposes it # under spot_usdt when present. Add a row only if the field is there # so we don't render a confusing "0.00" against an asset the account # never held. spot_usdt = summary.get("spot_usdt") if spot_usdt is not None: rows.append( BalanceRow( exchange="hyperliquid", currency="USDT", equity=_decimal_or_none(spot_usdt), available=_decimal_or_none(spot_usdt), unrealized_pnl=Decimal(0), ) ) return rows async def _fetch_balances_async(*, timeout_s: float = 8.0) -> BalancesSnapshot: endpoints = load_endpoints() token = _resolve_token() async with httpx.AsyncClient(timeout=timeout_s) as http_client: def _client(service: str) -> HttpToolClient: return HttpToolClient( service=service, base_url=endpoints.for_service(service), token=token, timeout_s=timeout_s, retry_max=1, client=http_client, ) deribit = DeribitClient(_client("deribit"), _client("unified")) hl = HyperliquidClient(_client("hyperliquid")) macro = MacroClient(_client("macro")) deribit_results, hl_rows, (fx_value, fx_error) = await asyncio.gather( asyncio.gather( *( _fetch_deribit_currency(deribit, cur) for cur in _DERIBIT_CURRENCIES ) ), _fetch_hyperliquid(hl), _fetch_eur_usd(macro), ) deribit_rows = list(deribit_results) return BalancesSnapshot( rows=[*deribit_rows, *hl_rows], eur_usd_rate=fx_value, fetched_at=datetime.now(UTC), fx_error=fx_error, ) async def _fetch_eur_usd( macro: MacroClient, ) -> tuple[Decimal | None, str | None]: try: rate = await macro.eur_usd_rate() except Exception as exc: return None, f"{type(exc).__name__}: {exc}" return rate, None def fetch_balances_sync(*, timeout_s: float = 8.0) -> BalancesSnapshot: """Sync wrapper for Streamlit pages (which run in a sync context).""" return asyncio.run(_fetch_balances_async(timeout_s=timeout_s))