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:
@@ -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
|
||||
Reference in New Issue
Block a user