Files
Cerbero-mcp/tests/unit/exchanges/cross/test_client.py
T
Adriano bc75d3980a feat(V2): unified /mcp interface; retire Bybit/Alpaca to old/
Add a common cross-exchange interface (/mcp) over the integrated venues
(deribit, hyperliquid):

- get_instruments: uniform schema where each row carries its own
  `exchange`, `fees` (maker/taker, live from Deribit, null where the
  venue has no per-instrument schedule) and `history_start` (listing
  date, live from Deribit creation_timestamp), plus type/tick_size and a
  lossless `native` blob. Optional `exchange` filter; fan-out otherwise.
- get_historical: generalized to {exchange, instrument, interval,
  start_date, end_date}, returning a single chosen venue's candles.
  Consensus merge stays available on /mcp-cross.

New: routers/unified.py, exchanges/cross/instruments.py (normalizers),
UnifiedClient in cross/client.py, schemas in cross/tools.py. Deribit
get_instruments now also surfaces maker/taker_commission and
creation_timestamp (additive).

Retire Bybit and Alpaca from the API surface: move clients, routers,
settings classes and their tests under old/ (history preserved via
git mv); drop them from the builder, /mcp-cross dispatch and symbol_map.
Bybit remains a public funding/OI data source in sentiment (not the
trading client). IBKR is intentionally excluded from /mcp for now.

Docs: rewrite API_REFERENCE.md (remove Bybit/Alpaca, document /mcp,
clarify that data_timestamp is injected globally by middleware).

Tests: add unified-interface coverage; update cross/settings/builder/boot
tests for the reduced venue set. Fix a pre-existing flaky assertion in
the Hyperliquid signing test (r/s use eth_utils.to_hex like the official
SDK, so a leading zero byte yields <66 chars ~1/256 of the time).

323 passed, ruff clean.

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

112 lines
3.8 KiB
Python

from __future__ import annotations
from typing import Any
import pytest
from cerbero_mcp.exchanges.cross.client import CrossClient
from fastapi import HTTPException
class _Fake:
def __init__(self, candles: list[dict[str, Any]] | None = None,
*, raises: Exception | None = None):
self._candles = candles or []
self._raises = raises
self.calls: list[dict[str, Any]] = []
async def get_historical(self, **kwargs: Any) -> dict[str, Any]:
if self._raises:
raise self._raises
self.calls.append(kwargs)
return {"candles": list(self._candles)}
class _FakeRegistry:
def __init__(self, clients: dict[str, _Fake]):
self._clients = clients
async def get(self, exchange: str, env: str) -> _Fake:
if exchange not in self._clients:
raise KeyError(exchange)
return self._clients[exchange]
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}
@pytest.mark.asyncio
async def test_crypto_two_sources_aggregates():
fakes = {
"hyperliquid": _Fake([_c(1, 100), _c(2, 200)]),
"deribit": _Fake([_c(1, 100), _c(2, 200)]),
}
cc = CrossClient(_FakeRegistry(fakes), env="mainnet")
out = await cc.get_historical(
symbol="BTC", asset_class="crypto", interval="1h",
start_date="2026-05-09T00:00:00", end_date="2026-05-10T00:00:00",
)
assert out["symbol"] == "BTC"
assert out["asset_class"] == "crypto"
assert len(out["candles"]) == 2
assert out["candles"][0]["sources"] == 2
assert out["candles"][0]["div_pct"] == 0.0
assert set(out["sources_used"]) == {"hyperliquid", "deribit"}
assert out["failed_sources"] == []
@pytest.mark.asyncio
async def test_crypto_partial_failure_returns_partial_with_warning():
fakes = {
"hyperliquid": _Fake([_c(1, 100)]),
"deribit": _Fake(raises=RuntimeError("upstream down")),
}
cc = CrossClient(_FakeRegistry(fakes), env="mainnet")
out = await cc.get_historical(
symbol="BTC", asset_class="crypto", interval="1h",
start_date="2026-05-09T00:00:00", end_date="2026-05-10T00:00:00",
)
assert out["candles"][0]["sources"] == 1
assert set(out["sources_used"]) == {"hyperliquid"}
assert len(out["failed_sources"]) == 1
assert out["failed_sources"][0]["exchange"] == "deribit"
assert "upstream down" in out["failed_sources"][0]["error"]
@pytest.mark.asyncio
async def test_all_sources_fail_raises_502():
fakes = {
"hyperliquid": _Fake(raises=RuntimeError("b")),
"deribit": _Fake(raises=RuntimeError("c")),
}
cc = CrossClient(_FakeRegistry(fakes), env="mainnet")
with pytest.raises(HTTPException) as exc_info:
await cc.get_historical(
symbol="BTC", asset_class="crypto", interval="1h",
start_date="2026-05-09T00:00:00", end_date="2026-05-10T00:00:00",
)
assert exc_info.value.status_code == 502
@pytest.mark.asyncio
async def test_unsupported_symbol_raises_400():
cc = CrossClient(_FakeRegistry({}), env="mainnet")
with pytest.raises(HTTPException) as exc_info:
await cc.get_historical(
symbol="NONEXISTENT", asset_class="crypto", interval="1h",
start_date="2026-05-09T00:00:00", end_date="2026-05-10T00:00:00",
)
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_unsupported_interval_raises_400():
cc = CrossClient(_FakeRegistry({}), env="mainnet")
with pytest.raises(HTTPException) as exc_info:
await cc.get_historical(
symbol="BTC", asset_class="crypto", interval="3h",
start_date="2026-05-09T00:00:00", end_date="2026-05-10T00:00:00",
)
assert exc_info.value.status_code == 400