from __future__ import annotations from typing import Any import pytest from cerbero_mcp.exchanges.cross.client import UnifiedClient from cerbero_mcp.exchanges.cross.instruments import ( normalize_deribit, normalize_hyperliquid, ) from fastapi import HTTPException def _c(ts: int, close: float = 100.0) -> dict[str, Any]: return {"timestamp": ts, "open": close, "high": close, "low": close, "close": close, "volume": 1.0} # ── Normalizers ────────────────────────────────────────────────────── def test_normalize_deribit_populates_fees_and_history_start(): rows = [{ "name": "BTC-PERPETUAL", "kind": "future", "tick_size": 0.5, "maker_commission": 0.0, "taker_commission": 0.0005, "creation_timestamp": 1534377600000, # 2018-08-16 "open_interest": 123.0, }] inst = normalize_deribit(rows)[0] assert inst["exchange"] == "deribit" assert inst["symbol"] == "BTC-PERPETUAL" assert inst["type"] == "perpetual" assert inst["fees"] == {"maker": 0.0, "taker": 0.0005} assert inst["history_start"] == "2018-08-16" assert inst["tick_size"] == 0.5 assert inst["native"]["open_interest"] == 123.0 def test_normalize_deribit_dated_future_is_future(): assert normalize_deribit([{"name": "BTC-27JUN25", "kind": "future"}])[0]["type"] == "future" def test_normalize_deribit_option(): assert normalize_deribit([{"name": "BTC-27JUN25-100000-C", "kind": "option"}])[0]["type"] == "option" def test_normalize_deribit_fees_none_when_absent(): inst = normalize_deribit([{"name": "X", "kind": "future"}])[0] assert inst["fees"] is None and inst["history_start"] is None def test_normalize_hyperliquid_leaves_fees_and_history_none(): rows = [{"asset": "BTC", "mark_price": 100.0, "funding_rate": 0.0001, "open_interest": 5.0, "volume_24h": 9.0, "max_leverage": 50}] inst = normalize_hyperliquid(rows)[0] assert inst["exchange"] == "hyperliquid" assert inst["symbol"] == "BTC" assert inst["type"] == "perpetual" assert inst["fees"] is None and inst["history_start"] is None assert inst["native"]["max_leverage"] == 50 # ── Fakes ──────────────────────────────────────────────────────────── class _FakeDeribit: def __init__(self, candles: list[dict[str, Any]] | None = None, *, raises: Exception | None = None): self._candles = candles or [_c(1, 100), _c(2, 200)] self._raises = raises async def get_instruments(self, **kwargs: Any) -> dict[str, Any]: self.inst_call = kwargs return { "instruments": [{ "name": "BTC-PERPETUAL", "kind": "future", "tick_size": 0.5, "maker_commission": 0.0, "taker_commission": 0.0005, "creation_timestamp": 1534377600000, }], "total": 1, "offset": kwargs.get("offset", 0), "limit": kwargs.get("limit", 100), "has_more": False, } async def get_historical(self, **kwargs: Any) -> dict[str, Any]: if self._raises: raise self._raises self.hist_call = kwargs return {"candles": list(self._candles)} class _FakeHyperliquid: def __init__(self, candles: list[dict[str, Any]] | None = None, *, raises: Exception | None = None): self._candles = candles or [_c(1, 100), _c(2, 200)] self._raises = raises async def get_markets(self) -> list[dict[str, Any]]: return [{"asset": "BTC", "mark_price": 1.0, "funding_rate": 0.0, "open_interest": 1.0, "volume_24h": 1.0, "max_leverage": 50}] async def get_historical(self, **kwargs: Any) -> dict[str, Any]: if self._raises: raise self._raises return {"candles": list(self._candles)} class _FakeRegistry: def __init__(self, clients: dict[str, Any]): self._clients = clients async def get(self, exchange: str, env: str) -> Any: if exchange not in self._clients: raise KeyError(exchange) return self._clients[exchange] def _both() -> UnifiedClient: return UnifiedClient( _FakeRegistry({"deribit": _FakeDeribit(), "hyperliquid": _FakeHyperliquid()}), env="mainnet", ) # ── get_instruments ────────────────────────────────────────────────── @pytest.mark.asyncio async def test_get_instruments_fans_out_all_venues(): out = await _both().get_instruments() assert {i["exchange"] for i in out["instruments"]} == {"deribit", "hyperliquid"} assert out["failed_sources"] == [] @pytest.mark.asyncio async def test_get_instruments_single_exchange_filter(): out = await _both().get_instruments(exchange="deribit") assert {i["exchange"] for i in out["instruments"]} == {"deribit"} @pytest.mark.asyncio async def test_get_instruments_unsupported_exchange_raises_400(): with pytest.raises(HTTPException) as exc: await UnifiedClient(_FakeRegistry({}), env="mainnet").get_instruments(exchange="bybit") assert exc.value.status_code == 400 @pytest.mark.asyncio async def test_get_instruments_deribit_filters_and_meta(): fake = _FakeDeribit() uc = UnifiedClient(_FakeRegistry({"deribit": fake}), env="mainnet") out = await uc.get_instruments( exchange="deribit", currency="ETH", kind="option", strike_min=1000.0, expiry_to="2026-12-31", min_open_interest=5.0, offset=5, limit=10, ) # advanced Deribit filters reach the client assert fake.inst_call["currency"] == "ETH" assert fake.inst_call["kind"] == "option" assert fake.inst_call["strike_min"] == 1000.0 assert fake.inst_call["expiry_to"] == "2026-12-31" assert fake.inst_call["min_open_interest"] == 5.0 assert fake.inst_call["offset"] == 5 and fake.inst_call["limit"] == 10 # pagination metadata surfaced per source assert out["meta"]["deribit"]["limit"] == 10 assert out["meta"]["deribit"]["has_more"] is False @pytest.mark.asyncio async def test_get_instruments_partial_failure_reports_failed(): uc = UnifiedClient(_FakeRegistry({"deribit": _FakeDeribit()}), env="mainnet") out = await uc.get_instruments() assert {i["exchange"] for i in out["instruments"]} == {"deribit"} assert [f["exchange"] for f in out["failed_sources"]] == ["hyperliquid"] # ── get_historical: single exchange ────────────────────────────────── @pytest.mark.asyncio async def test_get_historical_single_exchange(): fake = _FakeDeribit() uc = UnifiedClient(_FakeRegistry({"deribit": fake}), env="mainnet") out = await uc.get_historical( exchange="deribit", instrument="BTC-PERPETUAL", interval="1h", start_date="2026-05-09", end_date="2026-05-10", ) assert out["exchange"] == "deribit" assert out["instrument"] == "BTC-PERPETUAL" assert out["sources_used"] == ["deribit"] assert len(out["candles"]) == 2 assert fake.hist_call["instrument"] == "BTC-PERPETUAL" @pytest.mark.asyncio async def test_get_historical_unsupported_exchange_raises_400(): with pytest.raises(HTTPException) as exc: await UnifiedClient(_FakeRegistry({}), env="mainnet").get_historical( exchange="bybit", instrument="BTCUSDT", interval="1h", start_date="2026-05-09", end_date="2026-05-10", ) assert exc.value.status_code == 400 @pytest.mark.asyncio async def test_get_historical_unsupported_interval_raises_400(): with pytest.raises(HTTPException) as exc: await _both().get_historical( exchange="deribit", instrument="BTC-PERPETUAL", interval="3h", start_date="2026-05-09", end_date="2026-05-10", ) assert exc.value.status_code == 400 # ── get_historical: consensus (exchange omitted) ───────────────────── @pytest.mark.asyncio async def test_get_historical_consensus_merges_sources(): out = await _both().get_historical( instrument="BTC", interval="1h", start_date="2026-05-09", end_date="2026-05-10", ) assert out["exchange"] is None assert out["instrument"] == "BTC" assert set(out["sources_used"]) == {"deribit", "hyperliquid"} assert out["candles"][0]["sources"] == 2 assert out["failed_sources"] == [] @pytest.mark.asyncio async def test_get_historical_consensus_partial_failure(): uc = UnifiedClient( _FakeRegistry({"deribit": _FakeDeribit(raises=RuntimeError("down")), "hyperliquid": _FakeHyperliquid()}), env="mainnet", ) out = await uc.get_historical(instrument="BTC", interval="1h", start_date="2026-05-09", end_date="2026-05-10") assert out["sources_used"] == ["hyperliquid"] assert [f["exchange"] for f in out["failed_sources"]] == ["deribit"] @pytest.mark.asyncio async def test_get_historical_consensus_all_fail_raises_502(): uc = UnifiedClient( _FakeRegistry({"deribit": _FakeDeribit(raises=RuntimeError("a")), "hyperliquid": _FakeHyperliquid(raises=RuntimeError("b"))}), env="mainnet", ) with pytest.raises(HTTPException) as exc: await uc.get_historical(instrument="BTC", interval="1h", start_date="2026-05-09", end_date="2026-05-10") assert exc.value.status_code == 502 @pytest.mark.asyncio async def test_get_historical_consensus_unsupported_symbol_raises_400(): with pytest.raises(HTTPException) as exc: await _both().get_historical(instrument="NOPE", interval="1h", start_date="2026-05-09", end_date="2026-05-10") assert exc.value.status_code == 400 # ── get_indicators ─────────────────────────────────────────────────── @pytest.mark.asyncio async def test_get_indicators_single_exchange(): fake = _FakeDeribit(candles=[_c(i, 100 + i) for i in range(1, 40)]) uc = UnifiedClient(_FakeRegistry({"deribit": fake}), env="mainnet") out = await uc.get_indicators( indicators=["rsi", "sma", "unknownX"], exchange="deribit", instrument="BTC-PERPETUAL", interval="1h", start_date="2026-05-01", end_date="2026-05-10", ) assert out["exchange"] == "deribit" assert out["candles_used"] == 39 assert set(out["indicators"].keys()) == {"rsi", "sma", "unknownx"} assert out["indicators"]["unknownx"] is None @pytest.mark.asyncio async def test_get_indicators_consensus(): candles = [_c(i, 100 + i) for i in range(1, 40)] uc = UnifiedClient( _FakeRegistry({"deribit": _FakeDeribit(candles=candles), "hyperliquid": _FakeHyperliquid(candles=candles)}), env="mainnet", ) out = await uc.get_indicators( indicators=["rsi"], instrument="BTC", interval="1h", start_date="2026-05-01", end_date="2026-05-10", ) assert out["exchange"] is None assert set(out["sources_used"]) == {"deribit", "hyperliquid"} assert "rsi" in out["indicators"]