Files
PythagorasGoal/src/data/downloader.py
T
Adriano Dal Pastro e18adba4a6 fix(data): downloader su endpoint v2 + guard anti-troncamento (post-mortem refresh notturno)
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>
2026-06-12 06:08:42 +00:00

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))