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:
root
2026-05-10 21:41:18 +00:00
parent c94312d79f
commit 0ba5a05219
11 changed files with 580 additions and 0 deletions
+146
View File
@@ -0,0 +1,146 @@
"""Cross-exchange historical aggregator.
Fan-out a canonical (symbol, asset_class, interval, start, end) request to
every active exchange that supports the pair, then merge the results into
a single consensus candle series with per-bar divergence metrics.
"""
from __future__ import annotations
import asyncio
import datetime as _dt
from typing import Any, Literal, Protocol
from fastapi import HTTPException
from cerbero_mcp.exchanges.cross.consensus import merge_candles
from cerbero_mcp.exchanges.cross.symbol_map import (
get_sources,
supported_intervals,
to_native_interval,
to_native_symbol,
)
Environment = Literal["testnet", "mainnet"]
class _Registry(Protocol):
async def get(self, exchange: str, env: Environment) -> Any: ...
def _iso_to_ms(s: str) -> int:
return int(_dt.datetime.fromisoformat(
s.replace("Z", "+00:00")
).timestamp() * 1000)
async def _call_bybit(client: Any, sym: str, interval: str,
start: str, end: str) -> dict[str, Any]:
resp: dict[str, Any] = await client.get_historical(
symbol=sym, category="linear", interval=interval,
start=_iso_to_ms(start), end=_iso_to_ms(end),
)
return resp
async def _call_hyperliquid(client: Any, sym: str, interval: str,
start: str, end: str) -> dict[str, Any]:
resp: dict[str, Any] = await client.get_historical(
instrument=sym, start_date=start, end_date=end, resolution=interval,
)
return resp
async def _call_deribit(client: Any, sym: str, interval: str,
start: str, end: str) -> dict[str, Any]:
resp: dict[str, Any] = await client.get_historical(
instrument=sym, start_date=start, end_date=end, resolution=interval,
)
return resp
async def _call_alpaca(client: Any, sym: str, interval: str,
start: str, end: str) -> dict[str, Any]:
resp: dict[str, Any] = await client.get_bars(
symbol=sym, asset_class="stocks", interval=interval,
start=start, end=end,
)
return resp
_DISPATCH = {
"bybit": _call_bybit,
"hyperliquid": _call_hyperliquid,
"deribit": _call_deribit,
"alpaca": _call_alpaca,
}
class CrossClient:
def __init__(self, registry: _Registry, *, env: Environment):
self._registry = registry
self._env = env
async def _fetch_one(
self, exchange: str, native_sym: str, native_interval: str,
start: str, end: str,
) -> tuple[str, list[dict[str, Any]] | Exception]:
try:
client = await self._registry.get(exchange, self._env)
resp = await _DISPATCH[exchange](
client, native_sym, native_interval, start, end,
)
return exchange, resp.get("candles", [])
except Exception as e: # noqa: BLE001
return exchange, e
async def get_historical(
self, *, symbol: str, asset_class: str, interval: str,
start_date: str, end_date: str,
) -> dict[str, Any]:
sources = get_sources(asset_class, symbol)
if not sources:
raise HTTPException(
status_code=400,
detail=f"unsupported symbol/asset_class: {symbol} ({asset_class})",
)
if interval not in supported_intervals():
raise HTTPException(
status_code=400,
detail=f"unsupported interval: {interval}; "
f"supported: {supported_intervals()}",
)
tasks = [
self._fetch_one(
ex,
to_native_symbol(asset_class, symbol, ex),
to_native_interval(interval, ex),
start_date, end_date,
)
for ex in sources
]
results = await asyncio.gather(*tasks)
by_source: dict[str, list[dict[str, Any]]] = {}
failed: list[dict[str, str]] = []
for ex, payload in results:
if isinstance(payload, Exception):
failed.append({"exchange": ex, "error": f"{type(payload).__name__}: {payload}"})
else:
by_source[ex] = payload
if not by_source:
raise HTTPException(
status_code=502,
detail={"error": "all sources failed", "failed_sources": failed},
)
return {
"symbol": symbol.upper(),
"asset_class": asset_class,
"interval": interval,
"candles": merge_candles(by_source),
"sources_used": sorted(by_source.keys()),
"failed_sources": failed,
}
@@ -0,0 +1,37 @@
"""Pure consensus aggregation: merge per-source OHLCV candles by timestamp.
The output is a single time-series with the median OHLC across sources,
mean volume, the contributing source count, and a divergence % computed
on the close range. div_pct gives a quick quality signal: 0 means full
agreement, > X% means at least one source is suspect.
"""
from __future__ import annotations
from collections import defaultdict
from statistics import median
from typing import Any
def merge_candles(by_source: dict[str, list[dict[str, Any]]]) -> list[dict[str, Any]]:
grouped: dict[int, list[dict[str, Any]]] = defaultdict(list)
for candles in by_source.values():
for c in candles:
grouped[int(c["timestamp"])].append(c)
out: list[dict[str, Any]] = []
for ts in sorted(grouped):
rows = grouped[ts]
closes = [float(r["close"]) for r in rows]
med_close = float(median(closes))
div_pct = (max(closes) - min(closes)) / med_close if med_close else 0.0
out.append({
"timestamp": ts,
"open": float(median(float(r["open"]) for r in rows)),
"high": float(median(float(r["high"]) for r in rows)),
"low": float(median(float(r["low"]) for r in rows)),
"close": med_close,
"volume": sum(float(r["volume"]) for r in rows) / len(rows),
"sources": len(rows),
"div_pct": div_pct,
})
return out
@@ -0,0 +1,60 @@
"""Routing table: canonical (asset_class, symbol, interval) → per-exchange native.
Crypto canonical symbols default to USD/USDT-quoted perpetuals on the most
liquid pair available. Equities currently route to Alpaca only — IBKR is
omitted from the cross MVP because its bars endpoint takes a relative
period instead of (start, end).
"""
from __future__ import annotations
AssetClass = str
_CRYPTO_SYMBOLS: dict[str, dict[str, str]] = {
"BTC": {"bybit": "BTCUSDT", "hyperliquid": "BTC", "deribit": "BTC-PERPETUAL"},
"ETH": {"bybit": "ETHUSDT", "hyperliquid": "ETH", "deribit": "ETH-PERPETUAL"},
"SOL": {"bybit": "SOLUSDT", "hyperliquid": "SOL"},
}
_STOCK_SYMBOLS: dict[str, dict[str, str]] = {
"AAPL": {"alpaca": "AAPL"},
"SPY": {"alpaca": "SPY"},
"QQQ": {"alpaca": "QQQ"},
"TSLA": {"alpaca": "TSLA"},
"NVDA": {"alpaca": "NVDA"},
}
_SYMBOLS: dict[AssetClass, dict[str, dict[str, str]]] = {
"crypto": _CRYPTO_SYMBOLS,
"stocks": _STOCK_SYMBOLS,
}
_INTERVALS: dict[str, dict[str, str]] = {
"1m": {"bybit": "1", "hyperliquid": "1m", "deribit": "1m", "alpaca": "1m"},
"5m": {"bybit": "5", "hyperliquid": "5m", "deribit": "5m", "alpaca": "5m"},
"15m": {"bybit": "15", "hyperliquid": "15m", "deribit": "15m", "alpaca": "15m"},
"1h": {"bybit": "60", "hyperliquid": "1h", "deribit": "1h", "alpaca": "1h"},
"4h": {"bybit": "240", "hyperliquid": "4h", "deribit": "4h", "alpaca": "4h"},
"1d": {"bybit": "D", "hyperliquid": "1d", "deribit": "1d", "alpaca": "1d"},
}
def get_sources(asset_class: AssetClass, symbol: str) -> list[str]:
table = _SYMBOLS.get(asset_class, {})
mapping = table.get(symbol.upper())
if mapping is None:
return []
return list(mapping.keys())
def to_native_symbol(
asset_class: AssetClass, symbol: str, exchange: str
) -> str:
return _SYMBOLS[asset_class][symbol.upper()][exchange]
def to_native_interval(interval: str, exchange: str) -> str:
return _INTERVALS[interval][exchange]
def supported_intervals() -> list[str]:
return list(_INTERVALS.keys())
+28
View File
@@ -0,0 +1,28 @@
"""Pydantic schemas + thin tool wrappers for the /mcp-cross router."""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel
from cerbero_mcp.exchanges.cross.client import CrossClient
AssetClass = Literal["crypto", "stocks"]
class GetHistoricalReq(BaseModel):
symbol: str
asset_class: AssetClass = "crypto"
interval: str = "1h"
start_date: str
end_date: str
async def get_historical(client: CrossClient, params: GetHistoricalReq) -> dict:
return await client.get_historical(
symbol=params.symbol,
asset_class=params.asset_class,
interval=params.interval,
start_date=params.start_date,
end_date=params.end_date,
)