"""honest_lab — laboratorio di ricerca strategie ONESTO e fee-aware. Principi (per non ripetere l'errore look-ahead della famiglia squeeze): 1. Ogni segnale a barra i usa SOLO dati fino a close[i]. Ingresso a close[i] (eseguibile dal vivo: il worker vede la candela chiusa ed entra). Opzione di robustezza: ingresso a open[i+1] (ancora piu' conservativo). 2. Uscita TP/SL valutata intrabar su high/low, conservativa: SL prima del TP nello stesso bar. Time-limit max_bars. Una posizione per volta (non-overlap). 3. Tutto NETTO dopo fee round-trip realistiche (0.10% Deribit) * leva. 4. Validazione: FULL + OOS (held-out ultimo 30%) + per-anno + sweep fee + griglia parametri + su PIU' asset. Niente di tutto cio' -> scartata. Engine condiviso riusabile da tutte le strategie candidate. """ from __future__ import annotations import sys from dataclasses import dataclass 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 load_data # noqa: E402 FEE_RT = 0.001 # Deribit perp realistico: taker ~0.05%/lato = 0.10% RT LEV = 3.0 POS = 0.15 OOS_FRAC = 0.30 DATA_DIR = PROJECT_ROOT / "data" / "raw" # ---------------------------------------------------------------------------- # dati # ---------------------------------------------------------------------------- _CACHE: dict[tuple[str, str], pd.DataFrame] = {} def available_assets() -> list[str]: out = [] for p in sorted(DATA_DIR.glob("*_1h.parquet")): name = p.stem.replace("_1h", "").upper() if name not in ("BTC_DVOL", "ETH_DVOL"): out.append(name) return out def get_df(asset: str, tf: str) -> pd.DataFrame: """tf nativo (15m,1h) o resample da 1h (2h,4h,6h,12h,1d).""" key = (asset, tf) if key in _CACHE: return _CACHE[key] if tf in ("15m", "1h"): df = load_data(asset, tf).reset_index(drop=True) else: base = load_data(asset, "1h").copy() base["dt"] = pd.to_datetime(base["timestamp"], unit="ms", utc=True) base = base.set_index("dt") rule = {"2h": "2h", "4h": "4h", "6h": "6h", "12h": "12h", "1d": "1D"}[tf] agg = base.resample(rule).agg( {"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"} ).dropna() # l'indice puo' essere datetime64[ms] o [ns]: forza ms in modo robusto agg["timestamp"] = agg.index.values.astype("datetime64[ms]").astype("int64") df = agg.reset_index(drop=True) df = df[["timestamp", "open", "high", "low", "close", "volume"]].copy() _CACHE[key] = df return df # ---------------------------------------------------------------------------- # indicatori # ---------------------------------------------------------------------------- def atr(df: pd.DataFrame, n: int = 14) -> np.ndarray: h, l, c = df["high"].values, df["low"].values, df["close"].values pc = np.roll(c, 1); pc[0] = c[0] tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc))) return pd.Series(tr).rolling(n).mean().values def rsi(close: np.ndarray, n: int = 14) -> np.ndarray: d = np.diff(close, prepend=close[0]) up = pd.Series(np.where(d > 0, d, 0.0)).ewm(alpha=1 / n, adjust=False).mean() dn = pd.Series(np.where(d < 0, -d, 0.0)).ewm(alpha=1 / n, adjust=False).mean() rs = up / dn.replace(0, np.nan) return (100 - 100 / (1 + rs)).values def ema(close: np.ndarray, n: int) -> np.ndarray: return pd.Series(close).ewm(span=n, adjust=False).mean().values # ---------------------------------------------------------------------------- # engine # ---------------------------------------------------------------------------- @dataclass class SimResult: trades: int win: float ret: float # ritorno % netto composto su 1000 dd: float exposure: float yearly: dict[int, float] @property def pos_years(self) -> int: return sum(1 for v in self.yearly.values() if v > 0) @property def n_years(self) -> int: return len(self.yearly) def simulate(entries: list[dict], df: pd.DataFrame, fee_rt: float = FEE_RT, lev: float = LEV, pos: float = POS, entry_on_open: bool = False) -> SimResult: """entries: dict {i, d(+1/-1), tp, sl, max_bars}. entry_on_open=True -> ingresso a open[i+1] invece di close[i] (robustezza). """ o, h, l, c = (df["open"].values, df["high"].values, df["low"].values, df["close"].values) n = len(c) cap = peak = 1000.0 max_dd = 0.0 fee = fee_rt * lev trades = wins = 0 last_exit = -1 bars_in = 0 ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True) yearly: dict[int, float] = {} for e in entries: i, d = e["i"], e["d"] ei = i + 1 if entry_on_open else i # barra di ingresso if ei <= last_exit or ei + 1 >= n: continue entry = o[ei] if entry_on_open else c[i] tp, sl, mb = e["tp"], e["sl"], e["max_bars"] exit_p = c[min(ei + mb, n - 1)] j = min(ei + mb, n - 1) for k in range(1, mb + 1): j = ei + k if j >= n: j = n - 1; exit_p = c[j]; break hit_sl = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl) hit_tp = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp) if hit_sl: # conservativo: SL prima del TP nello stesso bar exit_p = sl; break if hit_tp: exit_p = tp; break if k == mb: exit_p = c[j] ret = (exit_p - entry) / entry * d * lev - fee cap = max(cap + cap * pos * ret, 10.0) peak = max(peak, cap); max_dd = max(max_dd, (peak - cap) / peak) trades += 1; wins += ret > 0; bars_in += (j - ei) last_exit = j yr = ts.iloc[i].year yearly[yr] = yearly.get(yr, 0.0) + ret * 100 return SimResult( trades=trades, win=wins / trades * 100 if trades else 0.0, ret=(cap / 1000 - 1) * 100, dd=max_dd * 100, exposure=bars_in / n * 100, yearly=yearly, ) def oos_split(entries: list[dict], df: pd.DataFrame, frac: float = OOS_FRAC): split = int(len(df) * (1 - frac)) ins = [e for e in entries if e["i"] < split] oos = [e for e in entries if e["i"] >= split] return ins, oos # ---------------------------------------------------------------------------- # criterio di accettazione # ---------------------------------------------------------------------------- def verdict(full: SimResult, oos: SimResult) -> bool: """Strategia attendibile su un singolo asset/tf.""" if full.trades < 30: return False if full.ret <= 0 or oos.ret <= 0: return False if full.pos_years < max(full.n_years - 1, 1): return False if full.dd > 45: return False return True