feat(gui): traduzione italiana, logo Cerbero, saldi live e Forza ciclo

* Localizzazione italiana di tutte le pagine (Stato, Audit, Equity,
  Storico, Posizione) e della home; date relative ("5s fa", "12m fa").
* Logo Cerbero (cane a tre teste) in src/cerbero_bite/gui/assets/
  cerbero_logo.png — sostituisce l'emoji 🐺 (lupo, semanticamente
  errata) sia come favicon (`page_icon`) sia in sidebar e header.
* Caricamento automatico di `.env` dal CWD all'avvio della CLI (skip
  sotto pytest tramite PYTEST_CURRENT_TEST), evitando di doversi
  esportare manualmente le 4 URL MCP. Aggiunto python-dotenv come
  dipendenza, `.env.example` committato come template, `.env` resta
  ignorato da git.
* Pagina Stato: nuovo pannello "Saldi exchange" che fa fetch live
  via gateway MCP (Deribit USDC + USDT, Hyperliquid USDC + opzionale
  USDT spot) con cache TTL 60s e bottone refresh; tile riassuntivi
  totale USD / EUR / cambio.
* Pagina Stato: nuovo pannello "Forza ciclo" con tre bottoni
  (entry/monitor/health) che accodano azioni `run_cycle` nella tabella
  manual_actions; il consumer dell'engine — quando in esecuzione —
  dispatcha al `Orchestrator.run_*` corrispondente.
* manual_actions: nuovo `kind="run_cycle"` nello schema
  ManualAction; consumer accetta dict di cycle_runners che
  l'orchestrator popola in install_scheduler. 3 nuovi test (dispatch
  entry, ciclo sconosciuto, fallback senza runner).
* gui/live_data.py — modulo dedicato al fetch MCP dalla GUI
  (relax controllato della regola "no MCP from GUI" solo per i saldi,
  non per i dati di trading).

363/363 tests pass; ruff clean; mypy strict src clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-30 14:11:40 +02:00
parent da88e7f746
commit 63d1aa4262
17 changed files with 730 additions and 222 deletions
+220
View File
@@ -0,0 +1,220 @@
"""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 / file (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
import os
from dataclasses import dataclass
from datetime import UTC, datetime
from decimal import Decimal
from pathlib import Path
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 bearer token from disk, mirroring the CLI default chain."""
explicit = os.environ.get("CERBERO_BITE_CORE_TOKEN_FILE")
if explicit:
return load_token(path=Path(explicit))
# Fallback: project-relative `secrets/core.token` (typical local dev).
local = Path("secrets") / "core.token"
if local.is_file():
return load_token(path=local)
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}",
)
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"))
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))