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
+203 -58
View File
@@ -1,95 +1,240 @@
"""Tests for PortfolioClient."""
"""Tests for in-process PortfolioClient (composes deribit + hyperliquid + macro)."""
from __future__ import annotations
from decimal import Decimal
from typing import Any
import pytest
from pytest_httpx import HTTPXMock
from cerbero_bite.clients._base import HttpToolClient
from cerbero_bite.clients._exceptions import McpDataAnomalyError
from cerbero_bite.clients.portfolio import PortfolioClient
# ---------------------------------------------------------------------------
# Test doubles
# ---------------------------------------------------------------------------
def _client() -> PortfolioClient:
http = HttpToolClient(
service="portfolio",
base_url="http://mcp-portfolio:9018",
token="t",
retry_max=1,
class _FakeDeribit:
SERVICE = "deribit"
def __init__(
self,
*,
equity_usd: Decimal | float = Decimal("0"),
positions: list[dict[str, Any]] | None = None,
) -> None:
self._equity = Decimal(str(equity_usd))
self._positions = positions or []
async def get_account_summary(self, currency: str = "USDC") -> dict[str, Any]:
assert currency == "USDC"
return {"equity": float(self._equity), "currency": "USDC"}
async def get_positions(self, currency: str = "USDC") -> list[dict[str, Any]]:
assert currency == "USDC"
return list(self._positions)
class _FakeHyperliquid:
SERVICE = "hyperliquid"
def __init__(
self,
*,
equity_usd: Decimal | float = Decimal("0"),
positions: list[dict[str, Any]] | None = None,
) -> None:
self._equity = Decimal(str(equity_usd))
self._positions = positions or []
async def get_account_summary(self) -> dict[str, Any]:
return {"equity": float(self._equity)}
async def get_positions(self) -> list[dict[str, Any]]:
return list(self._positions)
class _FakeMacro:
SERVICE = "macro"
def __init__(self, *, eur_usd: Decimal | float | None = Decimal("1.10")) -> None:
self._eur_usd = eur_usd
async def eur_usd_rate(self) -> Decimal:
if self._eur_usd is None:
raise McpDataAnomalyError(
"missing", service="macro", tool="get_asset_price"
)
return Decimal(str(self._eur_usd))
def _make(
*,
deribit_eq: Decimal | float = 0,
hl_eq: Decimal | float = 0,
deribit_pos: list[dict[str, Any]] | None = None,
hl_pos: list[dict[str, Any]] | None = None,
eur_usd: Decimal | float | None = Decimal("1.10"),
) -> PortfolioClient:
return PortfolioClient(
deribit=_FakeDeribit(equity_usd=deribit_eq, positions=deribit_pos),
hyperliquid=_FakeHyperliquid(equity_usd=hl_eq, positions=hl_pos),
macro=_FakeMacro(eur_usd=eur_usd),
)
return PortfolioClient(http)
# ---------------------------------------------------------------------------
# total_equity_usd / total_equity_eur
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_total_equity_eur(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(
url="http://mcp-portfolio:9018/tools/get_total_portfolio_value",
json={"total_value_eur": 12345.67},
)
out = await _client().total_equity_eur()
assert out == Decimal("12345.67")
async def test_total_equity_usd_sums_both_exchanges() -> None:
p = _make(deribit_eq="1500.50", hl_eq="982.50")
assert await p.total_equity_usd() == Decimal("2483.00")
@pytest.mark.asyncio
async def test_total_equity_anomaly_when_missing(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(json={})
with pytest.raises(McpDataAnomalyError, match="total_value_eur"):
await _client().total_equity_eur()
async def test_total_equity_eur_converts_with_fx() -> None:
p = _make(deribit_eq="1100", hl_eq="0", eur_usd="1.10")
# 1100 USD / 1.10 = 1000 EUR
assert await p.total_equity_eur() == Decimal("1000")
@pytest.mark.asyncio
async def test_total_equity_anomaly_on_unexpected_shape(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(json=[1, 2, 3])
with pytest.raises(McpDataAnomalyError, match="unexpected shape"):
await _client().total_equity_eur()
async def test_total_equity_eur_zero_when_no_balance() -> None:
p = _make(deribit_eq=0, hl_eq=0, eur_usd="1.20")
assert await p.total_equity_eur() == Decimal("0")
@pytest.mark.asyncio
async def test_asset_pct_aggregates_matching_tickers(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(
url="http://mcp-portfolio:9018/tools/get_holdings",
json=[
{"ticker": "ETH-USD", "current_value_eur": 3000.0},
{"ticker": "ETHE", "current_value_eur": 1000.0}, # ETH ticker variant
{"ticker": "AAPL", "current_value_eur": 6000.0},
async def test_total_equity_eur_raises_on_non_positive_fx() -> None:
p = _make(deribit_eq="100", hl_eq="0", eur_usd="0")
with pytest.raises(McpDataAnomalyError, match="non-positive EURUSD"):
await p.total_equity_eur()
@pytest.mark.asyncio
async def test_total_equity_eur_propagates_macro_anomaly() -> None:
p = _make(deribit_eq="100", hl_eq="0", eur_usd=None)
with pytest.raises(McpDataAnomalyError):
await p.total_equity_eur()
# ---------------------------------------------------------------------------
# asset_pct_of_portfolio
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_asset_pct_aggregates_eth_across_both_exchanges() -> None:
p = _make(
deribit_eq="5000",
hl_eq="5000",
deribit_pos=[
{
"instrument_name": "ETH-15MAY26-2475-P",
"size": 10,
"mark_price": 100,
},
# BTC position should be ignored when asking for ETH
{
"instrument_name": "BTC-PERPETUAL",
"size": 1,
"mark_price": 75000,
},
],
hl_pos=[
{"coin": "ETH", "notional_usd": 1000},
],
)
pct = await _client().asset_pct_of_portfolio("ETH")
# 4000 / 10000 = 0.4
assert pct == Decimal("0.4")
# ETH exposure: 10×100 (deribit) + 1000 (hl) = 2000
# total equity: 10000
pct = await p.asset_pct_of_portfolio("ETH")
assert pct == Decimal("0.2")
@pytest.mark.asyncio
async def test_asset_pct_returns_zero_for_empty_portfolio(
httpx_mock: HTTPXMock,
) -> None:
httpx_mock.add_response(json=[])
assert await _client().asset_pct_of_portfolio("ETH") == Decimal("0")
async def test_asset_pct_returns_zero_when_no_positions() -> None:
p = _make(deribit_eq="1000", hl_eq="0")
assert await p.asset_pct_of_portfolio("ETH") == Decimal("0")
@pytest.mark.asyncio
async def test_asset_pct_skips_entries_without_value(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(
json=[
{"ticker": "ETH", "current_value_eur": None},
{"ticker": "AAPL", "current_value_eur": 1000.0},
]
async def test_asset_pct_returns_zero_when_no_equity() -> None:
p = _make(
deribit_eq=0,
hl_eq=0,
deribit_pos=[
{"instrument_name": "ETH-PERP", "notional_usd": 100},
],
)
assert await _client().asset_pct_of_portfolio("ETH") == Decimal("0")
assert await p.asset_pct_of_portfolio("ETH") == Decimal("0")
@pytest.mark.asyncio
async def test_asset_pct_anomaly_when_response_not_list(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(json={"holdings": []})
with pytest.raises(McpDataAnomalyError, match="unexpected shape"):
await _client().asset_pct_of_portfolio("ETH")
def test_portfolio_client_rejects_wrong_service() -> None:
bad = HttpToolClient(
service="macro", base_url="http://x:1", token="t", retry_max=1
async def test_asset_pct_uses_explicit_notional_when_present() -> None:
p = _make(
deribit_eq="1000",
hl_eq=0,
deribit_pos=[
# explicit notional_usd takes precedence over size×mark
{
"instrument_name": "ETH-XYZ",
"notional_usd": 250,
"size": 999,
"mark_price": 999,
},
],
)
with pytest.raises(ValueError, match="requires service 'portfolio'"):
PortfolioClient(bad)
assert await p.asset_pct_of_portfolio("ETH") == Decimal("0.25")
@pytest.mark.asyncio
async def test_asset_pct_falls_back_to_size_times_mark() -> None:
p = _make(
deribit_eq="1000",
hl_eq=0,
deribit_pos=[
{"instrument_name": "ETH-XYZ", "size": 5, "mark_price": 40},
],
)
# 5×40 / 1000 = 0.2
assert await p.asset_pct_of_portfolio("ETH") == Decimal("0.2")
@pytest.mark.asyncio
async def test_asset_pct_takes_absolute_value_for_short_positions() -> None:
p = _make(
deribit_eq="1000",
hl_eq=0,
hl_pos=[{"coin": "ETH", "size": -10, "mark_price": 50}],
)
# |-10×50| / 1000 = 0.5
assert await p.asset_pct_of_portfolio("ETH") == Decimal("0.5")
@pytest.mark.asyncio
async def test_asset_pct_case_insensitive_match() -> None:
p = _make(
deribit_eq="1000",
hl_eq=0,
deribit_pos=[
{"instrument_name": "eth-perpetual", "notional_usd": 300},
],
)
assert await p.asset_pct_of_portfolio("eth") == Decimal("0.3")
@pytest.mark.asyncio
async def test_asset_pct_skips_non_dict_entries() -> None:
p = _make(
deribit_eq="1000",
hl_eq=0,
deribit_pos=[
"not a dict", # type: ignore[list-item]
{"instrument_name": "ETH", "notional_usd": 100},
],
)
assert await p.asset_pct_of_portfolio("ETH") == Decimal("0.1")