839285d75b
Cerbero MCP V2 ha spostato get_instruments/get_historical/get_indicators sul router unificato /mcp (rimossi dagli endpoint per-exchange) e ha rinominato get_technical_indicators in get_indicators. Il client chiamava ancora questi tool su /mcp-deribit -> 404 -> kill-switch armato, raccolta option-chain ferma e bias direzionale rotto. - mcp_endpoints: nuovo endpoint `unified` (CERBERO_BITE_MCP_UNIFIED_URL, default http://cerbero-mcp:9000/mcp) - deribit: i 3 tool dati passano dal client unificato con exchange="deribit", interval minuscolo (1D->1d) e parsing del nuovo shape (symbol + native.*); adx_14 usa get_indicators (indicators.adx.adx) - dependencies/gui: iniettano il client unificato in DeribitClient - docker-compose: default in-cluster per l'endpoint /mcp Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
231 lines
7.1 KiB
Python
231 lines
7.1 KiB
Python
"""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))
|