feat(V2): /mcp-cross/tools/get_historical with cross-exchange consensus
Add a unified historical endpoint that fans out to every exchange supporting the requested (asset_class, symbol) pair, then merges the results into a single consensus candle series with per-bar divergence metrics: - candles[i].close = median across sources - candles[i].sources = count of contributing exchanges - candles[i].div_pct = (max-min)/median for that bar's close Crypto routes BTC/ETH/SOL across Bybit + Hyperliquid + Deribit; equities route to Alpaca for now (IBKR omitted from MVP because its bars endpoint takes a relative period instead of start/end). Partial failures return a warning envelope (failed_sources) instead of failing the whole request; all sources failing → HTTP 502. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
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)}
|
||||
|
||||
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]):
|
||||
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_three_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)]),
|
||||
}
|
||||
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"] == 3
|
||||
assert out["candles"][0]["div_pct"] == 0.0
|
||||
assert set(out["sources_used"]) == {"bybit", "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")),
|
||||
}
|
||||
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"] == 2
|
||||
assert set(out["sources_used"]) == {"bybit", "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 = {
|
||||
"bybit": _Fake(raises=RuntimeError("a")),
|
||||
"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_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")
|
||||
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
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from cerbero_mcp.exchanges.cross.consensus import merge_candles
|
||||
|
||||
|
||||
def _c(ts, o, h, l, c, v):
|
||||
return {"timestamp": ts, "open": o, "high": h, "low": l, "close": c, "volume": v}
|
||||
|
||||
|
||||
def test_empty_input():
|
||||
assert merge_candles({}) == []
|
||||
|
||||
|
||||
def test_single_source_passthrough():
|
||||
out = merge_candles({"bybit": [_c(1, 100, 110, 90, 105, 5)]})
|
||||
assert len(out) == 1
|
||||
assert out[0]["timestamp"] == 1
|
||||
assert out[0]["close"] == 105
|
||||
assert out[0]["sources"] == 1
|
||||
assert out[0]["div_pct"] == 0.0
|
||||
|
||||
|
||||
def test_three_sources_identical_no_divergence():
|
||||
src = {
|
||||
"bybit": [_c(1, 100, 110, 90, 105, 5)],
|
||||
"hyperliquid": [_c(1, 100, 110, 90, 105, 3)],
|
||||
"deribit": [_c(1, 100, 110, 90, 105, 7)],
|
||||
}
|
||||
out = merge_candles(src)
|
||||
assert len(out) == 1
|
||||
assert out[0]["close"] == 105.0
|
||||
assert out[0]["sources"] == 3
|
||||
assert out[0]["div_pct"] == 0.0
|
||||
# volume is mean across sources
|
||||
assert abs(out[0]["volume"] - 5.0) < 1e-9
|
||||
|
||||
|
||||
def test_three_sources_divergent_close():
|
||||
src = {
|
||||
"bybit": [_c(1, 100, 110, 90, 100, 1)],
|
||||
"hyperliquid": [_c(1, 100, 110, 90, 110, 1)],
|
||||
"deribit": [_c(1, 100, 110, 90, 105, 1)],
|
||||
}
|
||||
out = merge_candles(src)
|
||||
# median of [100, 110, 105] = 105
|
||||
assert out[0]["close"] == 105.0
|
||||
# div_pct = (110 - 100) / 105 ≈ 0.0952
|
||||
assert abs(out[0]["div_pct"] - 10 / 105) < 1e-6
|
||||
assert out[0]["sources"] == 3
|
||||
|
||||
|
||||
def test_misaligned_timestamps():
|
||||
src = {
|
||||
"bybit": [_c(1, 100, 110, 90, 105, 1), _c(2, 100, 110, 90, 105, 1)],
|
||||
"hyperliquid": [_c(2, 100, 110, 90, 105, 1), _c(3, 100, 110, 90, 105, 1)],
|
||||
}
|
||||
out = merge_candles(src)
|
||||
timestamps = [c["timestamp"] for c in out]
|
||||
sources_by_ts = {c["timestamp"]: c["sources"] for c in out}
|
||||
assert timestamps == [1, 2, 3]
|
||||
assert sources_by_ts == {1: 1, 2: 2, 3: 1}
|
||||
|
||||
|
||||
def test_two_sources_even_median():
|
||||
src = {
|
||||
"bybit": [_c(1, 100, 110, 90, 100, 1)],
|
||||
"hyperliquid": [_c(1, 100, 110, 90, 110, 1)],
|
||||
}
|
||||
out = merge_candles(src)
|
||||
# even median = mean of two = 105
|
||||
assert out[0]["close"] == 105.0
|
||||
|
||||
|
||||
def test_empty_source_ignored():
|
||||
src = {
|
||||
"bybit": [_c(1, 100, 110, 90, 105, 1)],
|
||||
"hyperliquid": [],
|
||||
}
|
||||
out = merge_candles(src)
|
||||
assert len(out) == 1
|
||||
assert out[0]["sources"] == 1
|
||||
|
||||
|
||||
def test_output_sorted_by_timestamp():
|
||||
src = {
|
||||
"bybit": [_c(3, 100, 110, 90, 105, 1), _c(1, 100, 110, 90, 105, 1),
|
||||
_c(2, 100, 110, 90, 105, 1)],
|
||||
}
|
||||
out = merge_candles(src)
|
||||
assert [c["timestamp"] for c in out] == [1, 2, 3]
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from cerbero_mcp.exchanges.cross.symbol_map import (
|
||||
get_sources,
|
||||
to_native_interval,
|
||||
to_native_symbol,
|
||||
)
|
||||
|
||||
|
||||
def test_btc_crypto_sources():
|
||||
assert set(get_sources("crypto", "BTC")) == {"bybit", "hyperliquid", "deribit"}
|
||||
|
||||
|
||||
def test_eth_crypto_sources():
|
||||
assert set(get_sources("crypto", "ETH")) == {"bybit", "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_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"
|
||||
|
||||
|
||||
def test_native_symbol_unsupported_pair_raises():
|
||||
with pytest.raises(KeyError):
|
||||
to_native_symbol("crypto", "BTC", "alpaca")
|
||||
|
||||
|
||||
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")
|
||||
Reference in New Issue
Block a user