feat(V2): shared Candle validator + uniform 'candles' response key

Introduce common/candles.py with a Pydantic Candle model enforcing OHLC
consistency (high≥max, low≤min), non-negative volume and positive
timestamp. validate_candles() coerces upstream rows, sorts by timestamp
and raises HTTPException(502) on malformed data — surfacing upstream
data corruption as a retryable envelope instead of silently returning
nonsense.

Wired into all five exchange historical endpoints (Bybit, Hyperliquid,
Deribit, Alpaca, IBKR). BREAKING: Alpaca get_bars and IBKR get_bars now
return 'candles' (was 'bars') to align with the other exchanges.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-05-10 19:19:20 +00:00
parent 110ca7f5cf
commit c94312d79f
8 changed files with 192 additions and 54 deletions
+18 -16
View File
@@ -11,10 +11,11 @@ from fastapi import HTTPException
from cerbero_mcp.common import indicators as ind
from cerbero_mcp.common import microstructure as micro
from cerbero_mcp.common import options as opt
from cerbero_mcp.common.candles import validate_candles
from cerbero_mcp.common.http import async_client
def _parse_deribit_response(resp) -> dict:
def _parse_deribit_response(resp: Any) -> dict[str, Any]:
"""Map Deribit upstream errors to a clean HTTP 502 (retryable) instead of
leaking JSONDecodeError when the body is HTML (e.g. Cloudflare 5xx page)."""
if resp.status_code >= 500:
@@ -23,7 +24,8 @@ def _parse_deribit_response(resp) -> dict:
detail=f"Deribit upstream HTTP {resp.status_code}",
)
try:
return resp.json()
data: dict[str, Any] = resp.json()
return data
except json.JSONDecodeError as e:
raise HTTPException(
status_code=502,
@@ -121,10 +123,10 @@ class DeribitClient:
resp = await http.get(url, params=request_params, headers=headers)
data = _parse_deribit_response(resp)
if "result" in data:
return data # type: ignore[no-any-return]
return data
return {"result": None, "error": error_msg}
return data # type: ignore[no-any-return]
return data
# ── Read tools ───────────────────────────────────────────────
@@ -418,24 +420,24 @@ class DeribitClient:
},
)
r = raw.get("result") or {}
candles = []
ticks = r.get("ticks", []) or []
opens = r.get("open", []) or []
highs = r.get("high", []) or []
lows = r.get("low", []) or []
closes = r.get("close", []) or []
volumes = r.get("volume", []) or []
for idx, ts in enumerate(ticks):
if idx >= min(len(opens), len(highs), len(lows), len(closes), len(volumes)):
break
candles.append({
"timestamp": ts,
"open": opens[idx],
"high": highs[idx],
"low": lows[idx],
"close": closes[idx],
"volume": volumes[idx],
})
n = min(len(ticks), len(opens), len(highs), len(lows), len(closes), len(volumes))
candles = validate_candles([
{
"timestamp": ticks[i],
"open": opens[i],
"high": highs[i],
"low": lows[i],
"close": closes[i],
"volume": volumes[i],
}
for i in range(n)
])
return {"candles": candles}
async def get_dvol(