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
+23 -3
View File
@@ -1,13 +1,17 @@
"""Wrapper around ``mcp-hyperliquid``.
Cerbero Bite consumes a single tool: ``get_funding_rate`` for ETH-PERP,
used by entry filter §2.6 of ``docs/01-strategy-rules.md`` (cap on the
absolute annualised funding rate).
Cerbero Bite consumes:
* ``get_funding_rate`` — entry filter §2.6 cap on absolute annualised
funding rate (``docs/01-strategy-rules.md``).
* ``get_account_summary`` and ``get_positions`` — feed the in-process
portfolio aggregator (equity + ETH/BTC exposure on the perp side).
"""
from __future__ import annotations
from decimal import Decimal
from typing import Any
from cerbero_bite.clients._base import HttpToolClient
from cerbero_bite.clients._exceptions import McpDataAnomalyError
@@ -47,3 +51,19 @@ class HyperliquidClient:
tool="get_funding_rate",
)
return Decimal(str(rate)) * Decimal(HOURLY_FUNDING_PERIODS_PER_YEAR)
async def get_account_summary(self) -> dict[str, Any]:
"""Account equity and balances (USD)."""
raw: Any = await self._http.call("get_account_summary", {})
return raw if isinstance(raw, dict) else {}
async def get_positions(self) -> list[dict[str, Any]]:
"""Open perp positions (list of dicts)."""
raw: Any = await self._http.call("get_positions", {})
if isinstance(raw, list):
return raw
if isinstance(raw, dict):
inner = raw.get("positions")
if isinstance(inner, list):
return inner
return []