refactor(data): replace ccxt OHLCV loader with CerberoOHLCVLoader (deribit default)
Cerbero MCP diventa unica fonte di verità per dati di mercato Phase 1.
Il nuovo CerberoOHLCVLoader chiama mcp-{exchange}/tools/get_historical
con shape per-exchange (deribit/bybit/hyperliquid) e parser difensivo
sulla risposta (object-of-records, array-of-arrays, raw list).
- src/multi_swarm/data/cerbero_ohlcv.py (nuovo) con OHLCVRequest +
CerberoOHLCVLoader, cache parquet via SHA1 della request
- tests/unit/test_cerbero_ohlcv.py (nuovo, 5 test, CerberoClient mockato)
- src/multi_swarm/data/ohlcv_loader.py + test ccxt rimossi
- scripts/run_phase1.py: costruisce CerberoClient, --exchange CLI arg,
default --symbol BTC-PERPETUAL (formato Deribit)
- pyproject.toml: rimosso ccxt>=4.4 (uv sync ha rimosso 16 transitivi)
- .env.example: CERBERO_BASE_URL=https://cerbero-mcp.tielogic.xyz +
nota su MAINNET vs TESTNET token
Schema confermato via OpenAPI di Cerbero (instrument/start_date/end_date
+ resolution opzionale). Forma della risposta non garantita dallo schema:
parser difensivo prova candles/data/result/ohlcv/klines/bars e segnala
errore chiaro se nessuna shape combacia. Live verification skippata
(nessun token in .env).
Paginazione non ancora implementata: si assume che get_historical paginI
internamente. Da rivedere se una live call mostra cap (~1000 candele).
Test: 122 passed (era 122 con 2 ccxt + 0 cerbero, ora 0 ccxt + 5 cerbero,
delta netto +3, ma 2 test ga_loop preesistenti rimossi in altro commit
mantenevano il totale a 122).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
"""Tests for CerberoOHLCVLoader (mocked CerberoClient)."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from multi_swarm.data.cerbero_ohlcv import CerberoOHLCVLoader, OHLCVRequest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_records_object() -> list[dict[str, float | int]]:
|
||||
base = int(datetime(2024, 1, 1, tzinfo=UTC).timestamp() * 1000)
|
||||
return [
|
||||
{
|
||||
"ts": base + i * 3600 * 1000,
|
||||
"open": 40000 + i,
|
||||
"high": 40100 + i,
|
||||
"low": 39900 + i,
|
||||
"close": 40050 + i,
|
||||
"volume": 100.0 + i,
|
||||
}
|
||||
for i in range(48)
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_records_array() -> list[list[float | int]]:
|
||||
base = int(datetime(2024, 1, 1, tzinfo=UTC).timestamp() * 1000)
|
||||
return [
|
||||
[base + i * 3600 * 1000, 40000 + i, 40100 + i, 39900 + i, 40050 + i, 100.0 + i]
|
||||
for i in range(48)
|
||||
]
|
||||
|
||||
|
||||
def test_loader_parses_object_records(
|
||||
tmp_path: Path, mocker, sample_records_object
|
||||
) -> None:
|
||||
fake_client = mocker.MagicMock()
|
||||
fake_client.call_tool.return_value = {"candles": sample_records_object}
|
||||
|
||||
loader = CerberoOHLCVLoader(client=fake_client, cache_dir=tmp_path)
|
||||
df = loader.load(
|
||||
OHLCVRequest(
|
||||
symbol="BTC-PERPETUAL",
|
||||
timeframe="1h",
|
||||
start=datetime(2024, 1, 1, tzinfo=UTC),
|
||||
end=datetime(2024, 1, 3, tzinfo=UTC),
|
||||
exchange="deribit",
|
||||
)
|
||||
)
|
||||
|
||||
assert list(df.columns) == ["open", "high", "low", "close", "volume"]
|
||||
assert len(df) == 48
|
||||
assert df.index.tz is not None
|
||||
fake_client.call_tool.assert_called_once_with(
|
||||
"deribit",
|
||||
"get_historical",
|
||||
{
|
||||
"instrument": "BTC-PERPETUAL",
|
||||
"start_date": "2024-01-01T00:00:00+00:00",
|
||||
"end_date": "2024-01-03T00:00:00+00:00",
|
||||
"resolution": "1h",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_loader_parses_array_records(
|
||||
tmp_path: Path, mocker, sample_records_array
|
||||
) -> None:
|
||||
fake_client = mocker.MagicMock()
|
||||
fake_client.call_tool.return_value = {"candles": sample_records_array}
|
||||
|
||||
loader = CerberoOHLCVLoader(client=fake_client, cache_dir=tmp_path)
|
||||
df = loader.load(
|
||||
OHLCVRequest(
|
||||
symbol="BTC-PERPETUAL",
|
||||
timeframe="1h",
|
||||
start=datetime(2024, 1, 1, tzinfo=UTC),
|
||||
end=datetime(2024, 1, 3, tzinfo=UTC),
|
||||
)
|
||||
)
|
||||
assert len(df) == 48
|
||||
|
||||
|
||||
def test_loader_uses_cache_on_second_call(
|
||||
tmp_path: Path, mocker, sample_records_object
|
||||
) -> None:
|
||||
fake_client = mocker.MagicMock()
|
||||
fake_client.call_tool.return_value = {"candles": sample_records_object}
|
||||
|
||||
loader = CerberoOHLCVLoader(client=fake_client, cache_dir=tmp_path)
|
||||
req = OHLCVRequest(
|
||||
symbol="BTC-PERPETUAL",
|
||||
timeframe="1h",
|
||||
start=datetime(2024, 1, 1, tzinfo=UTC),
|
||||
end=datetime(2024, 1, 3, tzinfo=UTC),
|
||||
)
|
||||
df1 = loader.load(req)
|
||||
fake_client.call_tool.reset_mock()
|
||||
df2 = loader.load(req)
|
||||
assert fake_client.call_tool.call_count == 0
|
||||
pd.testing.assert_frame_equal(df1, df2)
|
||||
|
||||
|
||||
def test_loader_unsupported_exchange_raises(tmp_path: Path, mocker) -> None:
|
||||
fake_client = mocker.MagicMock()
|
||||
loader = CerberoOHLCVLoader(client=fake_client, cache_dir=tmp_path)
|
||||
req = OHLCVRequest(
|
||||
symbol="X",
|
||||
timeframe="1h",
|
||||
start=datetime(2024, 1, 1, tzinfo=UTC),
|
||||
end=datetime(2024, 1, 2, tzinfo=UTC),
|
||||
exchange="kraken",
|
||||
)
|
||||
with pytest.raises(ValueError, match="unsupported exchange"):
|
||||
loader.load(req)
|
||||
|
||||
|
||||
def test_loader_bybit_args(tmp_path: Path, mocker, sample_records_object) -> None:
|
||||
fake_client = mocker.MagicMock()
|
||||
fake_client.call_tool.return_value = {"candles": sample_records_object}
|
||||
|
||||
loader = CerberoOHLCVLoader(client=fake_client, cache_dir=tmp_path)
|
||||
loader.load(
|
||||
OHLCVRequest(
|
||||
symbol="BTCUSDT",
|
||||
timeframe="1h",
|
||||
start=datetime(2024, 1, 1, tzinfo=UTC),
|
||||
end=datetime(2024, 1, 3, tzinfo=UTC),
|
||||
exchange="bybit",
|
||||
)
|
||||
)
|
||||
args = fake_client.call_tool.call_args.args
|
||||
assert args[0] == "bybit"
|
||||
assert args[1] == "get_historical"
|
||||
payload = args[2]
|
||||
assert payload["symbol"] == "BTCUSDT"
|
||||
assert payload["interval"] == 60
|
||||
@@ -1,64 +0,0 @@
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from multi_swarm.data.ohlcv_loader import OHLCVLoader, OHLCVRequest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_ohlcv_rows():
|
||||
base_ts = int(datetime(2024, 1, 1, tzinfo=UTC).timestamp() * 1000)
|
||||
rows = []
|
||||
for i in range(48):
|
||||
rows.append(
|
||||
[base_ts + i * 3600 * 1000, 40000 + i, 40100 + i, 39900 + i, 40050 + i, 100.0 + i]
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def test_loader_fetches_and_caches(tmp_path: Path, mocker, sample_ohlcv_rows):
|
||||
fake_exchange = mocker.MagicMock()
|
||||
fake_exchange.fetch_ohlcv.return_value = sample_ohlcv_rows
|
||||
mocker.patch("multi_swarm.data.ohlcv_loader.ccxt.binance", return_value=fake_exchange)
|
||||
|
||||
loader = OHLCVLoader(cache_dir=tmp_path)
|
||||
req = OHLCVRequest(
|
||||
symbol="BTC/USDT",
|
||||
timeframe="1h",
|
||||
start=datetime(2024, 1, 1, tzinfo=UTC),
|
||||
end=datetime(2024, 1, 3, tzinfo=UTC),
|
||||
)
|
||||
df = loader.load(req)
|
||||
|
||||
assert isinstance(df, pd.DataFrame)
|
||||
assert list(df.columns) == ["open", "high", "low", "close", "volume"]
|
||||
assert len(df) == 48
|
||||
assert df.index.is_monotonic_increasing
|
||||
cache_files = list(tmp_path.glob("*.parquet"))
|
||||
assert len(cache_files) == 1
|
||||
|
||||
|
||||
def test_loader_uses_cache_on_second_call(tmp_path: Path, mocker, sample_ohlcv_rows):
|
||||
fake_exchange = mocker.MagicMock()
|
||||
fake_exchange.fetch_ohlcv.return_value = sample_ohlcv_rows
|
||||
mocker.patch("multi_swarm.data.ohlcv_loader.ccxt.binance", return_value=fake_exchange)
|
||||
|
||||
loader = OHLCVLoader(cache_dir=tmp_path)
|
||||
req = OHLCVRequest(
|
||||
symbol="BTC/USDT",
|
||||
timeframe="1h",
|
||||
start=datetime(2024, 1, 1, tzinfo=UTC),
|
||||
end=datetime(2024, 1, 3, tzinfo=UTC),
|
||||
)
|
||||
df1 = loader.load(req)
|
||||
df2 = loader.load(req)
|
||||
|
||||
assert fake_exchange.fetch_ohlcv.call_count == 1 # 48 < limit, single batch
|
||||
pd.testing.assert_frame_equal(df1, df2)
|
||||
# Seconda chiamata legge da cache, non chiama exchange
|
||||
fake_exchange.fetch_ohlcv.reset_mock()
|
||||
df3 = loader.load(req)
|
||||
assert fake_exchange.fetch_ohlcv.call_count == 0
|
||||
pd.testing.assert_frame_equal(df1, df3)
|
||||
Reference in New Issue
Block a user