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
+5 -28
View File
@@ -20,12 +20,6 @@ class _Fake:
self.calls.append(kwargs)
return {"candles": list(self._candles)}
async def get_bars(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]):
@@ -43,9 +37,8 @@ def _c(ts: int, close: float = 100.0) -> dict[str, Any]:
@pytest.mark.asyncio
async def test_crypto_three_sources_aggregates():
async def test_crypto_two_sources_aggregates():
fakes = {
"bybit": _Fake([_c(1, 100), _c(2, 200)]),
"hyperliquid": _Fake([_c(1, 100), _c(2, 200)]),
"deribit": _Fake([_c(1, 100), _c(2, 200)]),
}
@@ -57,16 +50,15 @@ async def test_crypto_three_sources_aggregates():
assert out["symbol"] == "BTC"
assert out["asset_class"] == "crypto"
assert len(out["candles"]) == 2
assert out["candles"][0]["sources"] == 3
assert out["candles"][0]["sources"] == 2
assert out["candles"][0]["div_pct"] == 0.0
assert set(out["sources_used"]) == {"bybit", "hyperliquid", "deribit"}
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 = {
"bybit": _Fake([_c(1, 100)]),
"hyperliquid": _Fake([_c(1, 100)]),
"deribit": _Fake(raises=RuntimeError("upstream down")),
}
@@ -75,8 +67,8 @@ async def test_crypto_partial_failure_returns_partial_with_warning():
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"] == 2
assert set(out["sources_used"]) == {"bybit", "hyperliquid"}
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"]
@@ -85,7 +77,6 @@ async def test_crypto_partial_failure_returns_partial_with_warning():
@pytest.mark.asyncio
async def test_all_sources_fail_raises_502():
fakes = {
"bybit": _Fake(raises=RuntimeError("a")),
"hyperliquid": _Fake(raises=RuntimeError("b")),
"deribit": _Fake(raises=RuntimeError("c")),
}
@@ -109,20 +100,6 @@ async def test_unsupported_symbol_raises_400():
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_stocks_routes_to_alpaca_only():
fake = _Fake([_c(1, 175.0)])
cc = CrossClient(_FakeRegistry({"alpaca": fake}), env="mainnet")
out = await cc.get_historical(
symbol="AAPL", asset_class="stocks", interval="1d",
start_date="2026-04-09T00:00:00", end_date="2026-04-10T00:00:00",
)
assert out["sources_used"] == ["alpaca"]
assert out["candles"][0]["close"] == 175.0
# Alpaca was called with native symbol
assert fake.calls[0]["symbol"] == "AAPL"
@pytest.mark.asyncio
async def test_unsupported_interval_raises_400():
cc = CrossClient(_FakeRegistry({}), env="mainnet")
@@ -9,23 +9,22 @@ from cerbero_mcp.exchanges.cross.symbol_map import (
def test_btc_crypto_sources():
assert set(get_sources("crypto", "BTC")) == {"bybit", "hyperliquid", "deribit"}
assert set(get_sources("crypto", "BTC")) == {"hyperliquid", "deribit"}
def test_eth_crypto_sources():
assert set(get_sources("crypto", "ETH")) == {"bybit", "hyperliquid", "deribit"}
assert set(get_sources("crypto", "ETH")) == {"hyperliquid", "deribit"}
def test_unknown_crypto_symbol_returns_empty():
assert get_sources("crypto", "DOGEFAKE") == []
def test_stocks_aapl_sources():
assert set(get_sources("stocks", "AAPL")) == {"alpaca"}
def test_unknown_asset_class_returns_empty():
assert get_sources("stocks", "AAPL") == []
def test_native_symbol_btc():
assert to_native_symbol("crypto", "BTC", "bybit") == "BTCUSDT"
assert to_native_symbol("crypto", "BTC", "hyperliquid") == "BTC"
assert to_native_symbol("crypto", "BTC", "deribit") == "BTC-PERPETUAL"
@@ -36,12 +35,10 @@ def test_native_symbol_unsupported_pair_raises():
def test_native_interval_1h():
assert to_native_interval("1h", "bybit") == "60"
assert to_native_interval("1h", "hyperliquid") == "1h"
assert to_native_interval("1h", "deribit") == "1h"
assert to_native_interval("1h", "alpaca") == "1h"
def test_native_interval_unknown_canonical_raises():
with pytest.raises(KeyError):
to_native_interval("3h", "bybit")
to_native_interval("3h", "deribit")
+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