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:
@@ -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([]) == []
|
||||
Reference in New Issue
Block a user