Files
Cerbero-Bite/tests/unit/test_clients_portfolio.py
T
Adriano abf5a140e2 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>
2026-04-30 00:31:20 +02:00

241 lines
7.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for in-process PortfolioClient (composes deribit + hyperliquid + macro)."""
from __future__ import annotations
from decimal import Decimal
from typing import Any
import pytest
from cerbero_bite.clients._exceptions import McpDataAnomalyError
from cerbero_bite.clients.portfolio import PortfolioClient
# ---------------------------------------------------------------------------
# Test doubles
# ---------------------------------------------------------------------------
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),
)
# ---------------------------------------------------------------------------
# total_equity_usd / total_equity_eur
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
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_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_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_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},
],
)
# 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_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_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 p.asset_pct_of_portfolio("ETH") == Decimal("0")
@pytest.mark.asyncio
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,
},
],
)
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")