e18adba4a6
Il refresh notturno e' rimasto 10h appeso e ha TRONCATO i parquet BTC al 2018: cerbero-mcp ha rimosso l'endpoint legacy /mcp-deribit/tools/get_historical (404), il downloader skippava in silenzio OGNI chunk (3 retry + sleep esponenziale x migliaia di chunk) e a fine giro scriveva comunque il file con la sola Fase 1 storica. Aggravante: TIMEFRAMES includeva '1m' (1 giorno/richiesta = ~3000 richieste/asset). ETH salvato in tempo (kill prima della sovrascrittura); BTC ripristinato via v2 (1h/15m/5m completi 2018->oggi in ~30 min). - _fetch_deribit -> endpoint v2 /mcp/tools/get_historical (lo stesso del runner) - guard chunk: >50% skippati = endpoint rotto -> RuntimeError, niente parziali - guard anti-regressione in download_asset: mai sovrascrivere un parquet con dati che finiscono PRIMA dell'esistente - '1m' fuori da TIMEFRAMES (refresh torna 5m/15m/1h; il 1m ad-hoc se serve) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
301 lines
10 KiB
Python
301 lines
10 KiB
Python
"""Download historical OHLCV data. Primary: Cerbero MCP. Fallback: Binance/ccxt."""
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
import pandas as pd
|
|
import requests
|
|
from tqdm import tqdm
|
|
|
|
DATA_DIR = Path(__file__).resolve().parents[2] / "data" / "raw"
|
|
|
|
CERBERO_URL = "https://cerbero-mcp.tielogic.xyz"
|
|
CERBERO_TOKEN = "_hm0FkyC67P9OXJTy7R9SE2lfhGz_Wa6i89KqH_uXrk"
|
|
CERBERO_HEADERS = {
|
|
"Authorization": f"Bearer {CERBERO_TOKEN}",
|
|
"X-Bot-Tag": "pythagoras-downloader",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
ASSETS = {
|
|
"BTC": {
|
|
"deribit": {"instrument": "BTC-PERPETUAL", "start": "2018-09-01"},
|
|
"binance_symbol": "BTC/USDT",
|
|
"binance_start": "2018-01-01",
|
|
},
|
|
"ETH": {
|
|
"deribit": {"instrument": "ETH-PERPETUAL", "start": "2019-06-01"},
|
|
"binance_symbol": "ETH/USDT",
|
|
"binance_start": "2018-01-01",
|
|
},
|
|
}
|
|
|
|
# NB: "1m" RIMOSSO da download_all (2026-06-12): con MAX_DAYS_PER_REQUEST=1 sono
|
|
# ~3000 richieste/asset — il refresh notturno e' rimasto 10h su BTC. Il 1m si
|
|
# scarica ad-hoc con download_asset("BTC", "1m") quando serve davvero.
|
|
TIMEFRAMES = ["5m", "15m", "1h"]
|
|
|
|
DERIBIT_RESOLUTION = {"1m": "1", "5m": "5", "15m": "15", "1h": "60"}
|
|
|
|
TF_SECONDS = {"1m": 60, "5m": 300, "15m": 900, "1h": 3600}
|
|
|
|
MAX_DAYS_PER_REQUEST = {"1m": 1, "5m": 5, "15m": 15, "1h": 30}
|
|
|
|
|
|
def _parquet_path(asset: str, tf: str) -> Path:
|
|
return DATA_DIR / f"{asset.lower()}_{tf}.parquet"
|
|
|
|
|
|
def _fetch_deribit(instrument: str, tf: str, start: str, end: str) -> list[dict]:
|
|
"""Endpoint v2 unificato (2026-06-12): il legacy /mcp-deribit/tools/get_historical
|
|
e' stato RIMOSSO da cerbero-mcp (404) — il refresh notturno ha skippato in
|
|
silenzio ogni chunk e scritto parquet troncati al 2018. Il v2 e' lo stesso
|
|
endpoint usato dal runner live (interval '1h'/'15m'/...)."""
|
|
resp = requests.post(
|
|
f"{CERBERO_URL}/mcp/tools/get_historical",
|
|
headers=CERBERO_HEADERS,
|
|
json={
|
|
"exchange": "deribit",
|
|
"instrument": instrument,
|
|
"interval": tf,
|
|
"start_date": start,
|
|
"end_date": end,
|
|
},
|
|
timeout=30,
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
return data.get("candles", [])
|
|
|
|
|
|
def _fetch_binance(symbol: str, tf: str, since_ms: int, limit: int = 1000) -> list[list]:
|
|
import ccxt
|
|
exchange = ccxt.binance({"enableRateLimit": True, "options": {"defaultType": "spot"}})
|
|
return exchange.fetch_ohlcv(symbol, tf, since=since_ms, limit=limit)
|
|
|
|
|
|
def _download_cerbero_range(
|
|
instrument: str, resolution: str, tf: str, start_date: str, end_date: str,
|
|
allow_unvalidated: bool = False,
|
|
) -> pd.DataFrame:
|
|
# Gate: si raccolgono dati SOLO per strumenti validati nel registry.
|
|
# Esegui `python -m src.data.instruments` per (ri)costruirlo.
|
|
if not allow_unvalidated:
|
|
from src.data.instruments import is_validated
|
|
if not is_validated(instrument, tf, "deribit"):
|
|
raise ValueError(
|
|
f"Strumento non validato: {instrument} @ {tf}. "
|
|
f"Costruisci il registry (python -m src.data.instruments) o passa "
|
|
f"allow_unvalidated=True per forzare."
|
|
)
|
|
all_candles: list[dict] = []
|
|
max_days = MAX_DAYS_PER_REQUEST[tf]
|
|
chunks = skipped = 0
|
|
current = datetime.fromisoformat(start_date)
|
|
end = datetime.fromisoformat(end_date)
|
|
|
|
pbar = tqdm(
|
|
total=(end - current).days,
|
|
desc=f" Cerbero {instrument} {tf}",
|
|
unit="days",
|
|
)
|
|
|
|
while current < end:
|
|
chunk_end = min(current + timedelta(days=max_days), end)
|
|
start_str = current.strftime("%Y-%m-%d")
|
|
end_str = chunk_end.strftime("%Y-%m-%d")
|
|
|
|
for attempt in range(3):
|
|
try:
|
|
candles = _fetch_deribit(instrument, tf, start_str, end_str)
|
|
all_candles.extend(candles)
|
|
break
|
|
except Exception as e:
|
|
if attempt == 2:
|
|
print(f" SKIP {start_str}→{end_str}: {e}")
|
|
skipped += 1
|
|
time.sleep(2 ** attempt)
|
|
|
|
chunks += 1
|
|
pbar.update(max_days)
|
|
current = chunk_end
|
|
|
|
pbar.close()
|
|
|
|
# GUARD (2026-06-12): se la maggioranza dei chunk e' stata skippata l'endpoint
|
|
# e' rotto, non il singolo range — ritornare il parziale farebbe scrivere un
|
|
# parquet troncato in silenzio (successo: 10h di SKIP e file fermi al 2018)
|
|
if chunks and skipped > chunks // 2:
|
|
raise RuntimeError(
|
|
f"{instrument} {tf}: {skipped}/{chunks} chunk falliti — endpoint rotto? "
|
|
"NON scrivo dati parziali.")
|
|
|
|
if not all_candles:
|
|
return pd.DataFrame()
|
|
|
|
df = pd.DataFrame(all_candles)
|
|
df = df.rename(columns={"timestamp": "timestamp"})
|
|
df["timestamp"] = df["timestamp"].astype("int64")
|
|
return df.drop_duplicates(subset="timestamp").sort_values("timestamp").reset_index(drop=True)
|
|
|
|
|
|
def _download_binance_range(
|
|
symbol: str, tf: str, start_date: str, end_date: str
|
|
) -> pd.DataFrame:
|
|
import ccxt
|
|
|
|
exchange = ccxt.binance({
|
|
"enableRateLimit": True,
|
|
"timeout": 30000,
|
|
"options": {"defaultType": "spot", "adjustForTimeDifference": True},
|
|
})
|
|
exchange.load_markets()
|
|
|
|
start_ms = int(datetime.fromisoformat(start_date).replace(tzinfo=timezone.utc).timestamp() * 1000)
|
|
end_ms = int(datetime.fromisoformat(end_date).replace(tzinfo=timezone.utc).timestamp() * 1000)
|
|
tf_ms = TF_SECONDS[tf] * 1000
|
|
all_rows: list[list] = []
|
|
|
|
pbar = tqdm(desc=f" Binance {symbol} {tf}", unit=" candles")
|
|
|
|
since = start_ms
|
|
while since < end_ms:
|
|
for attempt in range(3):
|
|
try:
|
|
ohlcv = exchange.fetch_ohlcv(symbol, tf, since=since, limit=1000)
|
|
break
|
|
except ccxt.RateLimitExceeded:
|
|
time.sleep(10)
|
|
ohlcv = []
|
|
except Exception as e:
|
|
if attempt == 2:
|
|
print(f" ERR: {e}")
|
|
time.sleep(2 ** attempt)
|
|
ohlcv = []
|
|
|
|
if not ohlcv:
|
|
break
|
|
|
|
filtered = [c for c in ohlcv if c[0] < end_ms]
|
|
all_rows.extend(filtered)
|
|
pbar.update(len(filtered))
|
|
since = ohlcv[-1][0] + tf_ms
|
|
|
|
if len(ohlcv) < 1000 or ohlcv[-1][0] >= end_ms:
|
|
break
|
|
|
|
pbar.close()
|
|
|
|
if not all_rows:
|
|
return pd.DataFrame()
|
|
|
|
cols = ["timestamp", "open", "high", "low", "close", "volume"]
|
|
df = pd.DataFrame(all_rows, columns=cols)
|
|
df["timestamp"] = df["timestamp"].astype("int64")
|
|
return df.drop_duplicates(subset="timestamp").sort_values("timestamp").reset_index(drop=True)
|
|
|
|
|
|
def download_asset(asset: str, tf: str) -> pd.DataFrame:
|
|
path = _parquet_path(asset, tf)
|
|
info = ASSETS[asset]
|
|
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
resolution = DERIBIT_RESOLUTION[tf]
|
|
|
|
parts: list[pd.DataFrame] = []
|
|
|
|
binance_start = info["binance_start"]
|
|
deribit_start = info["deribit"]["start"]
|
|
|
|
if binance_start < deribit_start:
|
|
print(f"\n Fase 1: Binance {binance_start} → {deribit_start}")
|
|
df_binance = _download_binance_range(
|
|
info["binance_symbol"], tf, binance_start, deribit_start
|
|
)
|
|
if not df_binance.empty:
|
|
parts.append(df_binance)
|
|
|
|
print(f"\n Fase 2: Cerbero/Deribit {deribit_start} → {today}")
|
|
df_deribit = _download_cerbero_range(
|
|
info["deribit"]["instrument"], resolution, tf, deribit_start, today
|
|
)
|
|
if not df_deribit.empty:
|
|
parts.append(df_deribit)
|
|
|
|
if not parts:
|
|
print(f" VUOTO: {asset} {tf}")
|
|
return pd.DataFrame()
|
|
|
|
df = pd.concat(parts).drop_duplicates(subset="timestamp").sort_values("timestamp").reset_index(drop=True)
|
|
|
|
# GUARD anti-regressione (2026-06-12): MAI sovrascrivere un parquet buono con
|
|
# dati che finiscono PRIMA dell'esistente (fase Cerbero vuota per endpoint
|
|
# rotto = file troncato al 2018 scritto in silenzio; il bootstrap SH01 e i
|
|
# backtest leggono questi file)
|
|
if path.exists():
|
|
try:
|
|
old_last = int(pd.read_parquet(path, columns=["timestamp"])["timestamp"].max())
|
|
except Exception:
|
|
old_last = 0
|
|
if int(df["timestamp"].max()) < old_last:
|
|
raise RuntimeError(
|
|
f"{path.name}: i nuovi dati finiscono PRIMA dell'esistente — "
|
|
"NON sovrascrivo (endpoint rotto?)")
|
|
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
df.to_parquet(path, index=False)
|
|
|
|
first = datetime.fromtimestamp(df["timestamp"].iloc[0] / 1000, tz=timezone.utc)
|
|
last = datetime.fromtimestamp(df["timestamp"].iloc[-1] / 1000, tz=timezone.utc)
|
|
print(f" ✓ {path.name}: {len(df)} candele [{first.date()} → {last.date()}]")
|
|
|
|
return df
|
|
|
|
|
|
def download_all() -> dict[str, dict[str, pd.DataFrame]]:
|
|
result: dict[str, dict[str, pd.DataFrame]] = {}
|
|
for asset in ASSETS:
|
|
print(f"\n{'='*60}")
|
|
print(f" {asset}")
|
|
print(f"{'='*60}")
|
|
result[asset] = {}
|
|
for tf in TIMEFRAMES:
|
|
result[asset][tf] = download_asset(asset, tf)
|
|
return result
|
|
|
|
|
|
def load_data(asset: str = "BTC", tf: str = "1h") -> pd.DataFrame:
|
|
path = _parquet_path(asset, tf)
|
|
if not path.exists():
|
|
raise FileNotFoundError(f"Dati non trovati: {path}. Esegui download_all().")
|
|
df = pd.read_parquet(path)
|
|
df["datetime"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
|
return df
|
|
|
|
|
|
def data_summary() -> pd.DataFrame:
|
|
rows = []
|
|
for asset in ASSETS:
|
|
for tf in TIMEFRAMES:
|
|
path = _parquet_path(asset, tf)
|
|
if path.exists():
|
|
df = pd.read_parquet(path)
|
|
first = datetime.fromtimestamp(df["timestamp"].iloc[0] / 1000, tz=timezone.utc)
|
|
last = datetime.fromtimestamp(df["timestamp"].iloc[-1] / 1000, tz=timezone.utc)
|
|
rows.append({
|
|
"asset": asset,
|
|
"tf": tf,
|
|
"candles": len(df),
|
|
"from": first.date(),
|
|
"to": last.date(),
|
|
"size_mb": round(path.stat().st_size / 1024 / 1024, 1),
|
|
})
|
|
return pd.DataFrame(rows)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
download_all()
|
|
print("\n" + "=" * 60)
|
|
print(data_summary().to_string(index=False))
|