Files
Cerbero-mcp/tests/unit/exchanges/cross/test_unified.py
T
Adriano 6faf21369d refactor(V2): get_historical & get_indicators are common-only on /mcp
Consolidate the cross-cutting data tools onto the unified interface and
remove their per-exchange duplicates.

- /mcp/tools/get_historical: single call, `exchange` now optional. Set →
  that venue's candles (native symbol); omitted → cross-exchange consensus
  (canonical symbol, median OHLC + div_pct). Folds in the former
  /mcp-cross consensus, whose router is deleted.
- /mcp/tools/get_indicators: new common tool computing sma/rsi/atr/macd/adx
  over the single-or-consensus series. Computation centralized in
  common/indicators.py::compute_indicators (was duplicated per exchange).
- Remove get_historical from /mcp-deribit and /mcp-hyperliquid; remove
  get_technical_indicators (deribit) and get_indicators (hyperliquid),
  including the now-dead client methods, Req schemas and tool wrappers.
  The client get_historical METHODS stay (used by the unified dispatch).

Tests: rewrite cross tests into test_unified (instruments, historical
single + consensus + failures, indicators single + consensus); drop the
old CrossClient test; tighten app-boot surface assertions (/mcp owns the
data tools; no /mcp-cross, no per-exchange historical/indicators).

Docs: API_REFERENCE / README / CLAUDE updated; smoke run_unified.sh now
covers consensus + indicators.

324 passed, ruff clean. Verified live against Deribit/Hyperliquid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 10:51:21 +00:00

265 lines
10 KiB
Python

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]:
return {"instruments": [{
"name": "BTC-PERPETUAL", "kind": "future", "tick_size": 0.5,
"maker_commission": 0.0, "taker_commission": 0.0005,
"creation_timestamp": 1534377600000,
}]}
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_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"]