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>
This commit is contained in:
Adriano
2026-05-29 09:09:22 +00:00
parent eb5f0148ad
commit bc75d3980a
36 changed files with 644 additions and 382 deletions
+183
View File
@@ -0,0 +1,183 @@
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
# ── 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
"strike": None,
"expiry": None,
"option_type": None,
"min_trade_amount": 10,
"open_interest": 123.0,
}]
out = normalize_deribit(rows)
assert len(out) == 1
inst = out[0]
assert inst["exchange"] == "deribit"
assert inst["symbol"] == "BTC-PERPETUAL"
assert inst["asset_class"] == "crypto"
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_not_perpetual():
out = normalize_deribit([{"name": "BTC-27JUN25", "kind": "future"}])
assert out[0]["type"] == "future"
def test_normalize_deribit_option_type():
out = normalize_deribit([{"name": "BTC-27JUN25-100000-C", "kind": "option"}])
assert out[0]["type"] == "option"
def test_normalize_deribit_fees_none_when_absent():
out = normalize_deribit([{"name": "X", "kind": "future"}])
assert out[0]["fees"] is None
assert out[0]["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,
}]
out = normalize_hyperliquid(rows)
assert out[0]["exchange"] == "hyperliquid"
assert out[0]["symbol"] == "BTC"
assert out[0]["type"] == "perpetual"
assert out[0]["fees"] is None
assert out[0]["history_start"] is None
assert out[0]["native"]["max_leverage"] == 50
# ── UnifiedClient ────────────────────────────────────────────────────
class _FakeDeribit:
async def get_instruments(self, **kwargs: Any) -> dict[str, Any]:
self.last_call = kwargs
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]:
self.hist_call = kwargs
return {"candles": [{"timestamp": 1, "open": 1, "high": 1,
"low": 1, "close": 1, "volume": 1}]}
class _FakeHyperliquid:
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]:
return {"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]
@pytest.mark.asyncio
async def test_get_instruments_fans_out_all_venues():
uc = UnifiedClient(
_FakeRegistry({"deribit": _FakeDeribit(), "hyperliquid": _FakeHyperliquid()}),
env="mainnet",
)
out = await uc.get_instruments()
venues = {i["exchange"] for i in out["instruments"]}
assert venues == {"deribit", "hyperliquid"}
assert out["failed_sources"] == []
@pytest.mark.asyncio
async def test_get_instruments_single_exchange_filter():
uc = UnifiedClient(
_FakeRegistry({"deribit": _FakeDeribit(), "hyperliquid": _FakeHyperliquid()}),
env="mainnet",
)
out = await uc.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():
uc = UnifiedClient(_FakeRegistry({}), env="mainnet")
with pytest.raises(HTTPException) as exc:
await uc.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()}), # hyperliquid missing → KeyError
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"]
@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["interval"] == "1h"
assert len(out["candles"]) == 1
assert fake.hist_call["instrument"] == "BTC-PERPETUAL"
@pytest.mark.asyncio
async def test_get_historical_unsupported_exchange_raises_400():
uc = UnifiedClient(_FakeRegistry({}), env="mainnet")
with pytest.raises(HTTPException) as exc:
await uc.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():
uc = UnifiedClient(_FakeRegistry({"deribit": _FakeDeribit()}), env="mainnet")
with pytest.raises(HTTPException) as exc:
await uc.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