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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user