14522262e6
Reset del progetto su fondamenta verificate dopo la scoperta che l'intera libreria "validata OOS" era artefatto di feed contaminato (print fantasma del feed Cerbero TESTNET + storico Binance/USDT). - Storico ricostruito da Deribit MAINNET (ccxt pubblico, tokenless) e CERTIFICATO (certify_feed.py): BTC/ETH puliti su TUTTA la storia (mediana 2-6 bps vs Coinbase USD), integrita' OHLC + coerenza resample (maxΔ 0.00) + cross-venue OK. Alt esclusi (illiquidi/divergenti: LTC/DOGE 50-82% barre flat; XRP/BNB non certificabili). - Verdetto sul feed pulito: FADE / PAIRS / XS01 / TSM01 morti (ogni portafoglio Sharpe -2.3..-3.0, DD ~40%); solo SH01 e frammenti HONEST con segnale residuo, da ri-validare in isolamento. - Cleanup "restart pulito": strategie, stack live (src/live, src/portfolio, runner/executor, yml, docker), ~100 script ricerca/gate, waste/games/ portfolios, dati non certificati + cache e 60+ diari -> archiviati in Old/ (preservati, non cancellati). Diario consolidato in un unico documento. - Skeleton ricerca tenuto: Strategy ABC + indicatori + src/fractal + src/backtest/engine + load_data; tool dati certificati (rebuild_history, certify_feed, audit_feed, multi_source_check). - Universo dati ATTIVO: solo BTC/ETH (5m/15m/1h); guardrail fisico (load_data su alt -> FileNotFoundError). Esecuzione DISABILITATA, conto flat. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
275 lines
12 KiB
Python
275 lines
12 KiB
Python
"""CLEAN FEED — ripara gli spike-print del feed Deribit/Cerbero coi dati reali di Binance.
|
|
|
|
Motivo (2026-06-18): la ricerca Price Ladder ha rivelato che data/raw/btc_1h.parquet (e gli
|
|
altri TF/asset) contengono barre con WICK FASULLI (es. BTC 2024-02-13: low 38.580 con
|
|
close ~49.968, BTC reale ~50k) — lo stesso spike-print testnet documentato in CLAUDE.md
|
|
(TP_PHANTOM / feed congelato). Sono pochi (decine per file) ma avvelenano i backtest
|
|
(stop/entry su prezzi mai avvenuti) e gonfiano le code (la "FULL DD BTC ~54%" del ladder era
|
|
in gran parte questo).
|
|
|
|
Metodo (conservativo, fonte di verita' = Binance spot via ccxt, gia' cablato nel progetto):
|
|
1. DETECT: barra sospetta = high/low che sfora >15% il cluster di close locale [i-1,i,i+1]
|
|
(close sano + wick fasullo). Soglia larga: tanto e' Binance ad arbitrare.
|
|
2. ARBITRA: per ogni sospetta, scarica la barra Binance reale (BTC/USDT, ETH/USDT) allo
|
|
stesso tf/timestamp. Sostituisce O/H/L/C SOLO se Binance dissente materialmente (>2% su
|
|
high o low) -> un wick VERO confermato da Binance resta intatto. Volume/timestamp invariati.
|
|
3. BACKUP (data/_feed_backup/) + scrittura atomica + VALIDAZIONE (re-scan = 0 sospette,
|
|
n righe invariato). Log dettagliato di ogni barra riparata (old OHLC -> new).
|
|
|
|
uv run python scripts/analysis/clean_feed.py [ASSET_TF ...] # default: tutti BTC/ETH x TF
|
|
uv run python scripts/analysis/clean_feed.py BTC_1h # un solo file
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import shutil
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0, str(PROJECT_ROOT))
|
|
|
|
from src.data.downloader import _parquet_path, DATA_DIR
|
|
|
|
BACKUP = PROJECT_ROOT / "data" / "_feed_backup"
|
|
SYMBOL = {"BTC": "BTC/USDT", "ETH": "ETH/USDT"}
|
|
WICK_THR = 0.15 # detect: wick oltre 15% il cluster di close locale
|
|
REPLACE_THR = 0.02 # arbitra: sostituisci solo se Binance dissente >2% su high/low
|
|
CLOSE_THR = 0.01 # close-aware: sostituisci la barra se il CLOSE Deribit dista >1% da Binance
|
|
TF_MS = {"5m": 5, "8m": 8, "13m": 13, "15m": 15, "19m": 19, "30m": 30, "1h": 60}
|
|
_EX = None
|
|
|
|
|
|
def _binance():
|
|
global _EX
|
|
if _EX is None:
|
|
import ccxt
|
|
_EX = ccxt.binance({"enableRateLimit": True})
|
|
return _EX
|
|
|
|
|
|
def suspect_mask(df: pd.DataFrame) -> np.ndarray:
|
|
c = df["close"].to_numpy(float); h = df["high"].to_numpy(float); l = df["low"].to_numpy(float)
|
|
cp = np.roll(c, 1); cp[0] = c[0]; cn = np.roll(c, -1); cn[-1] = c[-1]
|
|
locmax = np.maximum.reduce([c, cp, cn]); locmin = np.minimum.reduce([c, cp, cn])
|
|
return (h > locmax * (1 + WICK_THR)) | (l < locmin * (1 - WICK_THR))
|
|
|
|
|
|
def _binance_bar(symbol: str, tf: str, ts_ms: int):
|
|
"""OHLC reale Binance alla barra ts_ms (None se assente)."""
|
|
try:
|
|
rows = _binance().fetch_ohlcv(symbol, tf, since=ts_ms - 1, limit=3)
|
|
except Exception as e:
|
|
print(f" ! binance err: {type(e).__name__}: {str(e)[:80]}")
|
|
return None
|
|
for r in rows:
|
|
if int(r[0]) == ts_ms:
|
|
return float(r[1]), float(r[2]), float(r[3]), float(r[4])
|
|
return None
|
|
|
|
|
|
def clean_file(asset: str, tf: str) -> dict:
|
|
path = _parquet_path(asset, tf)
|
|
if not path.exists():
|
|
return {"file": f"{asset}_{tf}", "skip": "no-file"}
|
|
df = pd.read_parquet(path)
|
|
mask = suspect_mask(df)
|
|
idx = np.where(mask)[0]
|
|
n0 = len(df)
|
|
if len(idx) == 0:
|
|
return {"file": f"{asset}_{tf}", "suspect": 0, "repaired": 0, "kept_real": 0,
|
|
"missing_binance": 0, "rows_before": n0, "rows_after": n0,
|
|
"still_suspect": 0, "log": []}
|
|
repaired, kept, missing = 0, 0, 0
|
|
log = []
|
|
for i in idx:
|
|
ts = int(df.iloc[i]["timestamp"])
|
|
b = _binance_bar(SYMBOL[asset], tf, ts)
|
|
oh, ol = float(df.iloc[i]["high"]), float(df.iloc[i]["low"])
|
|
if b is None:
|
|
missing += 1
|
|
continue
|
|
bo, bh, bl, bc = b
|
|
if abs(oh - bh) / bh > REPLACE_THR or abs(ol - bl) / max(bl, 1e-9) > REPLACE_THR:
|
|
df.iat[i, df.columns.get_loc("open")] = bo
|
|
df.iat[i, df.columns.get_loc("high")] = bh
|
|
df.iat[i, df.columns.get_loc("low")] = bl
|
|
df.iat[i, df.columns.get_loc("close")] = bc
|
|
repaired += 1
|
|
ts_s = pd.to_datetime(ts, unit="ms", utc=True).strftime("%Y-%m-%d %H:%M")
|
|
log.append(f" {ts_s} H {oh:,.0f}->{bh:,.0f} L {ol:,.0f}->{bl:,.0f}")
|
|
else:
|
|
kept += 1 # Binance conferma il wick: barra reale, intatta
|
|
if repaired:
|
|
BACKUP.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(path, BACKUP / f"{asset.lower()}_{tf}.parquet.bak")
|
|
tmp = path.with_suffix(".parquet.tmp")
|
|
df.to_parquet(tmp, index=False)
|
|
tmp.replace(path)
|
|
# validazione
|
|
df2 = pd.read_parquet(path)
|
|
still = int(suspect_mask(df2).sum())
|
|
return {"file": f"{asset}_{tf}", "suspect": len(idx), "repaired": repaired,
|
|
"kept_real": kept, "missing_binance": missing, "rows_before": n0,
|
|
"rows_after": len(df2), "still_suspect": still, "log": log}
|
|
|
|
|
|
def _binance_series(asset: str, tf: str, start_ms: int, end_ms: int) -> dict:
|
|
"""OHLC reale Binance per l'intero range -> dict ts_ms -> (o,h,l,c). Bulk paginato."""
|
|
ex = _binance()
|
|
tf_ms = TF_MS[tf] * 60 * 1000
|
|
out: dict[int, tuple] = {}
|
|
since = start_ms
|
|
while since <= end_ms:
|
|
try:
|
|
rows = ex.fetch_ohlcv(SYMBOL[asset], tf, since=since, limit=1000)
|
|
except Exception as e:
|
|
print(f" ! binance err: {type(e).__name__}: {str(e)[:80]}")
|
|
break
|
|
if not rows:
|
|
break
|
|
for r in rows:
|
|
out[int(r[0])] = (float(r[1]), float(r[2]), float(r[3]), float(r[4]))
|
|
nxt = int(rows[-1][0]) + tf_ms
|
|
if nxt <= since:
|
|
break
|
|
since = nxt
|
|
if len(rows) < 1000 and since > end_ms:
|
|
break
|
|
return out
|
|
|
|
|
|
def clean_file_close(asset: str, tf: str, thr: float = CLOSE_THR, backup_dir: Path | None = None) -> dict:
|
|
"""CLOSE-AWARE: sostituisce O/H/L/C con Binance per ogni barra il cui CLOSE Deribit
|
|
dista > thr da Binance (1% default). Cattura i print 'silenziosi' che il wick-check
|
|
>15% non vede (close fantasma su barra di range piccolo). Fonte di verita' = Binance
|
|
spot (il feed storico e' perp testnet -> inaffidabile; lo spot ~ mainnet via arbitraggio)."""
|
|
if tf not in TF_MS:
|
|
return {"file": f"{asset}_{tf}", "skip": "tf-non-binance"}
|
|
path = _parquet_path(asset, tf)
|
|
if not path.exists():
|
|
return {"file": f"{asset}_{tf}", "skip": "no-file"}
|
|
df = pd.read_parquet(path)
|
|
n0 = len(df)
|
|
tms = df["timestamp"].to_numpy("int64")
|
|
c = df["close"].to_numpy(float)
|
|
bz = _binance_series(asset, tf, int(tms[0]), int(tms[-1]))
|
|
col = {k: df.columns.get_loc(k) for k in ("open", "high", "low", "close")}
|
|
fixed, by_year, missing = 0, {}, 0
|
|
log = []
|
|
for i in range(n0):
|
|
b = bz.get(int(tms[i]))
|
|
if b is None:
|
|
missing += 1
|
|
continue
|
|
bo, bh, bl, bc = b
|
|
if bc <= 0:
|
|
continue
|
|
orig = float(c[i]) # cattura PRIMA della scrittura (to_numpy puo' essere una view)
|
|
if abs(orig - bc) / bc > thr:
|
|
df.iat[i, col["open"]] = bo
|
|
df.iat[i, col["high"]] = bh
|
|
df.iat[i, col["low"]] = bl
|
|
df.iat[i, col["close"]] = bc
|
|
fixed += 1
|
|
y = pd.to_datetime(int(tms[i]), unit="ms", utc=True).year
|
|
by_year[y] = by_year.get(y, 0) + 1
|
|
if len(log) < 10:
|
|
ts_s = pd.to_datetime(int(tms[i]), unit="ms", utc=True).strftime("%Y-%m-%d %H:%M")
|
|
log.append(f" {ts_s} C {orig:,.2f}->{bc:,.2f} ({abs(orig-bc)/bc*100:.1f}%)")
|
|
if fixed:
|
|
bdir = backup_dir or BACKUP
|
|
bdir.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(path, bdir / f"{asset.lower()}_{tf}.parquet.bak")
|
|
tmp = path.with_suffix(".parquet.tmp")
|
|
df.to_parquet(tmp, index=False)
|
|
tmp.replace(path)
|
|
# validazione: ri-scan, 0 barre residue oltre soglia (fra quelle coperte da Binance)
|
|
df2 = pd.read_parquet(path)
|
|
c2 = df2["close"].to_numpy(float)
|
|
still = sum(1 for i in range(len(df2))
|
|
if (b := bz.get(int(tms[i]))) and b[3] > 0 and abs(c2[i] - b[3]) / b[3] > thr)
|
|
return {"file": f"{asset}_{tf}", "covered": n0 - missing, "fixed": fixed,
|
|
"missing_binance": missing, "rows_before": n0, "rows_after": len(df2),
|
|
"still_over_thr": still, "by_year": by_year, "log": log}
|
|
|
|
|
|
def main():
|
|
args = [a for a in sys.argv[1:] if not a.startswith("--")]
|
|
close_mode = "--close" in sys.argv
|
|
dry = "--dry" in sys.argv
|
|
if close_mode:
|
|
targets = args or [f"{a}_{tf}" for a in ("BTC", "ETH") for tf in ("5m", "15m", "1h")]
|
|
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
bdir = BACKUP / f"pre_close_clean_{stamp}"
|
|
print(f"CLEAN FEED (close-aware vs Binance, thr={CLOSE_THR*100:.0f}%) — "
|
|
f"{'DRY-RUN (nessuna scrittura)' if dry else f'backup in {bdir}'}\n")
|
|
grand = 0
|
|
for t in targets:
|
|
asset, tf = t.split("_", 1)
|
|
if dry:
|
|
# dry: conta soltanto, niente scrittura
|
|
r = _dry_close(asset, tf)
|
|
else:
|
|
r = clean_file_close(asset, tf, backup_dir=bdir)
|
|
if r.get("skip"):
|
|
print(f" {t:<9} SKIP ({r['skip']})"); continue
|
|
grand += r.get("fixed", 0)
|
|
yr = " ".join(f"{y}:{n}" for y, n in sorted(r.get("by_year", {}).items()))
|
|
print(f" {r['file']:<9} coperte={r.get('covered',0):>7} riparate={r.get('fixed',0):>4} "
|
|
f"no-binance={r.get('missing_binance',0):>5} | righe {r['rows_before']}=={r.get('rows_after',r['rows_before'])} "
|
|
f"residue>thr={r.get('still_over_thr','-')}")
|
|
if yr:
|
|
print(f" per anno: {yr}")
|
|
for line in r.get("log", []):
|
|
print(line)
|
|
print(f"\n TOTALE barre riparate (close-aware): {grand}")
|
|
return
|
|
targets = args or [f"{a}_{tf}" for a in ("BTC", "ETH") for tf in ("5m", "15m", "30m", "1h")]
|
|
print(f"CLEAN FEED — backup in {BACKUP}\n")
|
|
grand = 0
|
|
for t in targets:
|
|
asset, tf = t.split("_", 1)
|
|
r = clean_file(asset, tf)
|
|
if r.get("skip"):
|
|
print(f" {t:<9} SKIP ({r['skip']})"); continue
|
|
grand += r.get("repaired", 0)
|
|
print(f" {r['file']:<9} sospette={r['suspect']:>3} riparate={r['repaired']:>3} "
|
|
f"reali-tenute={r.get('kept_real',0):>3} no-binance={r.get('missing_binance',0):>2} "
|
|
f"| righe {r['rows_before']}=={r['rows_after']} residue-sospette={r['still_suspect']}")
|
|
for line in r.get("log", [])[:8]:
|
|
print(line)
|
|
if len(r.get("log", [])) > 8:
|
|
print(f" ... (+{len(r['log'])-8} altre)")
|
|
print(f"\n TOTALE barre riparate: {grand}")
|
|
|
|
|
|
def _dry_close(asset: str, tf: str, thr: float = CLOSE_THR) -> dict:
|
|
"""Conta soltanto quante barre verrebbero riparate (nessuna scrittura)."""
|
|
if tf not in TF_MS:
|
|
return {"file": f"{asset}_{tf}", "skip": "tf-non-binance"}
|
|
path = _parquet_path(asset, tf)
|
|
if not path.exists():
|
|
return {"file": f"{asset}_{tf}", "skip": "no-file"}
|
|
df = pd.read_parquet(path)
|
|
tms = df["timestamp"].to_numpy("int64"); c = df["close"].to_numpy(float)
|
|
bz = _binance_series(asset, tf, int(tms[0]), int(tms[-1]))
|
|
fixed, by_year, missing = 0, {}, 0
|
|
for i in range(len(df)):
|
|
b = bz.get(int(tms[i]))
|
|
if b is None:
|
|
missing += 1; continue
|
|
if b[3] > 0 and abs(c[i] - b[3]) / b[3] > thr:
|
|
fixed += 1
|
|
y = pd.to_datetime(int(tms[i]), unit="ms", utc=True).year
|
|
by_year[y] = by_year.get(y, 0) + 1
|
|
return {"file": f"{asset}_{tf}", "covered": len(df) - missing, "fixed": fixed,
|
|
"missing_binance": missing, "rows_before": len(df), "by_year": by_year, "log": []}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|