"""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")