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
+72
View File
@@ -0,0 +1,72 @@
from __future__ import annotations
import pytest
from cerbero_mcp.common.candles import Candle, validate_candles
from fastapi import HTTPException
def test_valid_candle():
c = Candle(timestamp=1_700_000_000_000, open=100.0, high=110.0,
low=95.0, close=105.0, volume=12.5)
assert c.high == 110.0
def test_high_below_close_rejected():
with pytest.raises(ValueError):
Candle(timestamp=1, open=100, high=90, low=80, close=95, volume=1)
def test_high_below_open_rejected():
with pytest.raises(ValueError):
Candle(timestamp=1, open=100, high=90, low=80, close=85, volume=1)
def test_low_above_close_rejected():
with pytest.raises(ValueError):
Candle(timestamp=1, open=100, high=110, low=105, close=102, volume=1)
def test_low_above_open_rejected():
with pytest.raises(ValueError):
Candle(timestamp=1, open=95, high=110, low=100, close=105, volume=1)
def test_negative_volume_rejected():
with pytest.raises(ValueError):
Candle(timestamp=1, open=100, high=110, low=90, close=105, volume=-1)
def test_non_positive_timestamp_rejected():
with pytest.raises(ValueError):
Candle(timestamp=0, open=100, high=110, low=90, close=105, volume=1)
def test_validate_candles_sorts_by_timestamp():
raw = [
{"timestamp": 3, "open": 1, "high": 2, "low": 1, "close": 1, "volume": 0},
{"timestamp": 1, "open": 1, "high": 2, "low": 1, "close": 1, "volume": 0},
{"timestamp": 2, "open": 1, "high": 2, "low": 1, "close": 1, "volume": 0},
]
out = validate_candles(raw)
assert [c["timestamp"] for c in out] == [1, 2, 3]
def test_validate_candles_coerces_string_numerics():
raw = [{"timestamp": "1", "open": "100", "high": "110",
"low": "90", "close": "105", "volume": "10"}]
out = validate_candles(raw)
assert out[0]["open"] == 100.0
assert isinstance(out[0]["volume"], float)
def test_validate_candles_malformed_raises_http_502():
raw = [{"timestamp": 1, "open": 100, "high": 50, "low": 90,
"close": 105, "volume": 1}]
with pytest.raises(HTTPException) as exc_info:
validate_candles(raw)
assert exc_info.value.status_code == 502
assert "candle" in str(exc_info.value.detail).lower()
def test_validate_candles_empty_list():
assert validate_candles([]) == []