refactor: telegram + portfolio in-process (drop shared MCP)

Each bot now manages its own notification + portfolio aggregation:

* TelegramClient calls the public Bot API directly via httpx, reading
  CERBERO_BITE_TELEGRAM_BOT_TOKEN / CERBERO_BITE_TELEGRAM_CHAT_ID from
  env. No credentials → silent disabled mode.
* PortfolioClient composes DeribitClient + HyperliquidClient + the new
  MacroClient.get_asset_price/eur_usd_rate to expose equity (EUR) and
  per-asset exposure as the bot's own slice (no cross-bot view).
* mcp-telegram and mcp-portfolio removed from MCP_SERVICES / McpEndpoints
  and the cerbero-bite ping CLI; health_check no longer probes portfolio.

Docs (02/04/06/07) and docker-compose updated to reflect the new
architecture.

353/353 tests pass; ruff clean; mypy src clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-30 00:31:20 +02:00
parent 067f74bc89
commit abf5a140e2
26 changed files with 836 additions and 423 deletions
+131 -66
View File
@@ -1,92 +1,157 @@
"""Wrapper around ``mcp-portfolio``.
"""In-process portfolio aggregator.
Cerbero Bite uses two pieces of information from this service:
Each Cerbero Suite bot now manages its own portfolio view: instead of
calling a shared ``mcp-portfolio`` service, this client composes the
account summaries and open positions from the exchanges the bot
actually uses (Deribit options + Hyperliquid perps) and converts them
to EUR via the macro service.
* total portfolio value (EUR) — fed to the sizing engine after FX
conversion to USD;
* exposure of a specific asset as percentage of the total portfolio —
used by entry filter §2.7 (``eth_holdings_pct_max``).
Two values are exposed:
The portfolio service stores everything in EUR. The orchestrator is
responsible for the EUR→USD conversion using a live FX rate.
* :py:meth:`total_equity_eur` — sum of USDC equity on Deribit and USD
equity on Hyperliquid, converted to EUR using the live ``EURUSD``
rate from ``mcp-macro``.
* :py:meth:`asset_pct_of_portfolio` — fraction (0..1) of total USD
equity exposed to a specific ticker via open positions on the two
exchanges. Used by entry filter §2.7 (``eth_holdings_pct_max``).
**Scope note**: this is the bot's own slice. Holdings on other
exchanges, in cold storage, or held by other bots in the suite are
*not* counted. The §2.7 limit is therefore a per-bot cap, not a
suite-wide one.
"""
from __future__ import annotations
import asyncio
from collections.abc import Iterable
from decimal import Decimal
from typing import Any
from typing import Any, cast
from cerbero_bite.clients._base import HttpToolClient
from cerbero_bite.clients._exceptions import McpDataAnomalyError
from cerbero_bite.clients.deribit import DeribitClient
from cerbero_bite.clients.hyperliquid import HyperliquidClient
from cerbero_bite.clients.macro import MacroClient
__all__ = ["PortfolioClient"]
class PortfolioClient:
SERVICE = "portfolio"
def _decimal_or_zero(value: Any) -> Decimal:
if value is None:
return Decimal(0)
try:
return Decimal(str(value))
except (ValueError, ArithmeticError):
return Decimal(0)
def __init__(self, http: HttpToolClient) -> None:
if http.service != self.SERVICE:
raise ValueError(
f"PortfolioClient requires service '{self.SERVICE}', got '{http.service}'"
)
self._http = http
def _position_notional_usd(pos: dict[str, Any]) -> Decimal:
"""Best-effort USD notional of an open position.
Prefers an explicit ``notional_usd`` / ``size_usd`` / ``value_usd``
field. Falls back to ``|size × mark_price|`` (or ``index_price`` if
mark is missing). Returns 0 on malformed entries.
"""
for key in ("notional_usd", "size_usd", "value_usd", "position_value"):
v = pos.get(key)
if v is not None:
return abs(_decimal_or_zero(v))
size = _decimal_or_zero(pos.get("size") or pos.get("szi"))
mark = _decimal_or_zero(
pos.get("mark_price")
or pos.get("entry_price")
or pos.get("index_price")
)
return abs(size * mark)
def _instrument_label(pos: dict[str, Any]) -> str:
for key in ("instrument_name", "instrument", "symbol", "coin", "asset"):
v = pos.get(key)
if v is not None:
return str(v).upper()
return ""
class PortfolioClient:
"""Aggregates equity + asset exposure across the bot's exchange accounts."""
def __init__(
self,
*,
deribit: DeribitClient,
hyperliquid: HyperliquidClient,
macro: MacroClient,
) -> None:
self._deribit = deribit
self._hyperliquid = hyperliquid
self._macro = macro
async def _equity_usd_components(self) -> tuple[Decimal, Decimal]:
"""Concurrent fetch of (deribit_equity_usd, hyperliquid_equity_usd)."""
deribit_summary, hl_summary = await asyncio.gather(
self._deribit.get_account_summary(currency="USDC"),
self._hyperliquid.get_account_summary(),
)
deribit_eq = _decimal_or_zero(deribit_summary.get("equity"))
hl_eq = _decimal_or_zero(hl_summary.get("equity"))
return deribit_eq, hl_eq
async def total_equity_usd(self) -> Decimal:
"""Sum equity USD across the bot's exchange accounts."""
deribit_eq, hl_eq = await self._equity_usd_components()
return deribit_eq + hl_eq
async def total_equity_eur(self) -> Decimal:
"""Return the aggregate portfolio value in EUR."""
raw = await self._http.call(
"get_total_portfolio_value", {"currency": "EUR"}
)
if not isinstance(raw, dict):
"""Return aggregate bot equity in EUR.
Concurrent: account summaries × FX. Raises
:class:`McpDataAnomalyError` if the FX rate is non-positive.
"""
components_t = asyncio.create_task(self._equity_usd_components())
fx_t = asyncio.create_task(self._macro.eur_usd_rate())
await asyncio.gather(components_t, fx_t)
deribit_eq, hl_eq = components_t.result()
fx = fx_t.result()
if fx <= 0:
raise McpDataAnomalyError(
f"portfolio total_value_eur unexpected shape: {type(raw).__name__}",
service=self.SERVICE,
tool="get_total_portfolio_value",
f"non-positive EURUSD rate: {fx}",
service="macro",
tool="get_asset_price",
)
value = raw.get("total_value_eur")
if value is None:
raise McpDataAnomalyError(
"portfolio response missing 'total_value_eur'",
service=self.SERVICE,
tool="get_total_portfolio_value",
)
return Decimal(str(value))
usd_total = deribit_eq + hl_eq
return usd_total / fx
async def asset_pct_of_portfolio(self, ticker: str) -> Decimal:
"""Return the fraction (0..1) of the portfolio held in ``ticker``.
"""Fraction of bot equity (USD) exposed to ``ticker``.
Iterates the holdings list and aggregates ``current_value_eur``
for any holding whose ticker contains ``ticker`` (case-insensitive).
Empty portfolio → 0.
Sums absolute USD notional of open positions whose instrument
label contains ``ticker`` (case-insensitive) on Deribit and
Hyperliquid, divided by the bot's total USD equity. Returns 0
when there is no equity or no exposure.
"""
holdings = await self._http.call("get_holdings", {"min_value_eur": 0})
if not isinstance(holdings, list):
raise McpDataAnomalyError(
f"portfolio get_holdings unexpected shape: {type(holdings).__name__}",
service=self.SERVICE,
tool="get_holdings",
)
target = ticker.upper()
matching_value = Decimal("0")
total_value = Decimal("0")
for entry in holdings:
if not isinstance(entry, dict):
continue
value = entry.get("current_value_eur")
if value is None:
continue
value_dec = Decimal(str(value))
total_value += value_dec
entry_ticker = str(entry.get("ticker") or "").upper()
if target in entry_ticker:
matching_value += value_dec
deribit_pos_t = asyncio.create_task(
self._deribit.get_positions(currency="USDC")
)
hl_pos_t = asyncio.create_task(self._hyperliquid.get_positions())
equity_t = asyncio.create_task(self._equity_usd_components())
await asyncio.gather(deribit_pos_t, hl_pos_t, equity_t)
if total_value == 0:
return Decimal("0")
return matching_value / total_value
exposure_usd = Decimal(0)
for raw_pos in cast(Iterable[Any], deribit_pos_t.result()):
if not isinstance(raw_pos, dict):
continue
if target in _instrument_label(raw_pos):
exposure_usd += _position_notional_usd(raw_pos)
for raw_pos in cast(Iterable[Any], hl_pos_t.result()):
if not isinstance(raw_pos, dict):
continue
if target in _instrument_label(raw_pos):
exposure_usd += _position_notional_usd(raw_pos)
async def health(self) -> dict[str, Any]:
"""Lightweight call used by ``cerbero-bite ping``."""
result: Any = await self._http.call("get_last_update_info", {})
return result if isinstance(result, dict) else {}
deribit_eq, hl_eq = equity_t.result()
total_eq = deribit_eq + hl_eq
if total_eq <= 0:
return Decimal(0)
return exposure_usd / total_eq