From 9879b46688a47d9de9441e6ebe462794fac7326f Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Thu, 28 May 2026 20:22:11 +0000 Subject: [PATCH] refactor(strategie): tieni solo MR01 mean-reversion, squeeze -> waste L'analisi out-of-sample fee-aware ha dimostrato che l'intera famiglia squeeze-breakout (SQ01-04, MT01, ML01, AD01, CM01, PD01) non ha edge: le accuratezze storiche 76-82% erano un artefatto di look-ahead (ingresso a close[i-1] con direzione decisa da close[i]). Sotto ingresso onesto a close[i] e fee reali tutte perdono, anche a fee zero. - nuova MR01_bollinger_fade (mean-reversion): edge netto validato OOS, robusto su griglia parametri e fino a 0.20% fee RT. BTC 1h n50 k2.5: +201% OOS, DD 15% - 9 strategie squeeze spostate in scripts/waste/ - strategy_loader + strategies.yml: solo MR01 (BTC/ETH 1h) - signal_engine.train: validazione OOS (accuratezza test + signal precision) - scripts/analysis/strategy_research.py: harness di ricerca fee-aware NOTA: lo StrategyWorker va aggiornato per usare gli exit TP/SL passati in metadata prima di tradare MR01 dal vivo (ora esce solo a hold_bars/stop fisso). Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/analysis/strategy_research.py | 258 ++++++++++++++++++ scripts/strategies/MR01_bollinger_fade.py | 167 ++++++++++++ .../AD01_adaptive_squeeze.py | 0 .../CM01_cross_market_momentum.py | 0 .../{strategies => waste}/ML01_squeeze_gbm.py | 0 .../MT01_squeeze_mtf_momentum.py | 0 .../PD01_price_volume_divergence.py | 0 .../SQ01_squeeze_base.py | 0 .../SQ02_squeeze_antifake_vol.py | 0 .../SQ03_squeeze_all_filters.py | 0 .../SQ04_squeeze_ultimate.py | 0 src/live/signal_engine.py | 64 ++++- src/live/strategy_loader.py | 11 +- strategies.yml | 53 ++-- 14 files changed, 506 insertions(+), 47 deletions(-) create mode 100644 scripts/analysis/strategy_research.py create mode 100644 scripts/strategies/MR01_bollinger_fade.py rename scripts/{strategies => waste}/AD01_adaptive_squeeze.py (100%) rename scripts/{strategies => waste}/CM01_cross_market_momentum.py (100%) rename scripts/{strategies => waste}/ML01_squeeze_gbm.py (100%) rename scripts/{strategies => waste}/MT01_squeeze_mtf_momentum.py (100%) rename scripts/{strategies => waste}/PD01_price_volume_divergence.py (100%) rename scripts/{strategies => waste}/SQ01_squeeze_base.py (100%) rename scripts/{strategies => waste}/SQ02_squeeze_antifake_vol.py (100%) rename scripts/{strategies => waste}/SQ03_squeeze_all_filters.py (100%) rename scripts/{strategies => waste}/SQ04_squeeze_ultimate.py (100%) diff --git a/scripts/analysis/strategy_research.py b/scripts/analysis/strategy_research.py new file mode 100644 index 0000000..1847d32 --- /dev/null +++ b/scripts/analysis/strategy_research.py @@ -0,0 +1,258 @@ +"""Ricerca strategie fee-aware, OOS, oltre la famiglia squeeze. + +Lezioni apprese (squeeze breakout = nessun edge): + - le FEE sono vincolo di prim'ordine -> default fee realistica Deribit 0.10% RT + (taker 0.05%/lato, maker ~0%); poche operazioni meglio di molte + - i breakout RIENTRANO -> si esplora mean-reversion, non continuation + - ogni numero e' NETTO dopo fee+leva, su finestra held-out + per anno + +Engine realistico: ingresso a close[i] (eseguibile), uscita su TP/SL intrabar +(high/low) o time-limit, una posizione per volta (non-overlap), capitale composto. +""" +from __future__ import annotations + +import sys +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 + +FEE_RT = 0.001 # Deribit perp realistico: taker 0.05%/lato +LEV = 3.0 +POS = 0.15 +OOS_FRAC = 0.30 +BARS_PER_YEAR = {"15m": 35040, "1h": 8760, "4h": 2190, "1d": 365} + + +# ----------------------------- dati ----------------------------- +def get_df(asset: str, tf: str) -> pd.DataFrame: + """tf nativo (15m,1h) o resample da 1h (4h,1d).""" + if tf in ("15m", "1h"): + return load_data(asset, tf).reset_index(drop=True) + base = load_data(asset, "1h").copy() + base["dt"] = pd.to_datetime(base["timestamp"], unit="ms", utc=True) + base = base.set_index("dt") + rule = {"4h": "4h", "1d": "1D"}[tf] + agg = base.resample(rule).agg( + {"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"} + ).dropna() + agg["timestamp"] = agg.index.asi8 // 10**6 + return agg.reset_index(drop=True) + + +# --------------------------- 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 + + +# --------------------------- engine --------------------------- +def simulate(entries: list[dict], df: pd.DataFrame, fee_rt: float = FEE_RT, + lev: float = LEV, pos: float = POS) -> dict: + """entries: dict con i(idx), d(+1/-1), tp(prezzo), sl(prezzo), max_bars.""" + h, l, c = 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"] + if i <= last_exit or i + 1 >= n: + continue + entry = c[i] + tp, sl, mb = e["tp"], e["sl"], e["max_bars"] + exit_p = c[min(i + mb, n - 1)] + for k in range(1, mb + 1): + j = i + k + if j >= n: + exit_p = c[n - 1]; 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 + cb = cap + cap = max(cb + cb * pos * ret, 10.0) + peak = max(peak, cap); max_dd = max(max_dd, (peak - cap) / peak) + trades += 1; wins += ret > 0; bars_in += min(mb, j - i) + last_exit = j + yearly[ts.iloc[i].year] = yearly.get(ts.iloc[i].year, 0.0) + ret * 100 + return { + "trades": trades, + "win": wins / trades * 100 if trades else 0.0, + "ret": (cap / 1000 - 1) * 100, + "dd": max_dd * 100, + "yearly": yearly, + "exposure": bars_in / n * 100, + } + + +# --------------------------- strategie --------------------------- +def bollinger_fade(df, n=20, k=2.0, sl_atr=2.0, max_bars=24): + """Mean-reversion: fada il close oltre la banda, TP alla media, SL = k_atr*ATR.""" + c = df["close"].values + ma = pd.Series(c).rolling(n).mean().values + sd = pd.Series(c).rolling(n).std().values + a = atr(df, 14) + up, lo = ma + k * sd, ma - k * sd + ents = [] + for i in range(n + 14, len(c)): + if np.isnan(up[i]) or np.isnan(a[i]): + continue + if c[i] < lo[i] and c[i - 1] >= lo[i - 1]: # appena sotto la banda + ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars}) + elif c[i] > up[i] and c[i - 1] <= up[i - 1]: + ents.append({"i": i, "d": -1, "tp": ma[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars}) + return ents + + +def rsi_revert(df, n=14, lo=30, hi=70, sl_atr=2.0, max_bars=24, ma_n=20): + """RSI mean-reversion: long su RSI hi >= r[i]: + ents.append({"i": i, "d": -1, "tp": ma[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars}) + return ents + + +def donchian_trend(df, n=20, sl_atr=2.0, tp_atr=6.0, max_bars=120): + """Trend-following: breakout canale Donchian, TP/SL in multipli di ATR.""" + h, l, c = df["high"].values, df["low"].values, df["close"].values + hh = pd.Series(h).rolling(n).max().shift(1).values + ll = pd.Series(l).rolling(n).min().shift(1).values + a = atr(df, 14) + ents = [] + for i in range(n + 14, len(c)): + if np.isnan(hh[i]) or np.isnan(a[i]): + continue + if c[i] > hh[i]: + ents.append({"i": i, "d": 1, "tp": c[i] + tp_atr * a[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars}) + elif c[i] < ll[i]: + ents.append({"i": i, "d": -1, "tp": c[i] - tp_atr * a[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars}) + return ents + + +STRATS = { + "BOLL_fade k2 m24": (bollinger_fade, dict(n=20, k=2.0, sl_atr=2.0, max_bars=24)), + "BOLL_fade k2.5 m24": (bollinger_fade, dict(n=20, k=2.5, sl_atr=2.0, max_bars=24)), + "RSI_revert 30/70": (rsi_revert, dict(n=14, lo=30, hi=70, sl_atr=2.0, max_bars=24)), + "RSI_revert 25/75": (rsi_revert, dict(n=14, lo=25, hi=75, sl_atr=2.0, max_bars=24)), + "DONCH_trend n20": (donchian_trend, dict(n=20, sl_atr=2.0, tp_atr=6.0, max_bars=120)), + "DONCH_trend n50": (donchian_trend, dict(n=50, sl_atr=2.0, tp_atr=8.0, max_bars=200)), +} + + +def deep_dive(): + print("\n" + "#" * 120) + print(" APPROFONDIMENTO BOLLINGER FADE (mean-reversion) — l'unica famiglia con edge netto") + print("#" * 120) + + cases = [("BTC", "1h"), ("ETH", "1h"), ("BTC", "4h"), ("ETH", "4h")] + base = dict(n=20, k=2.5, sl_atr=2.0, max_bars=24) + + # --- per anno (config base k2.5/n20) --- + print(f"\n [1] PnL NETTO per anno — n=20 k=2.5 sl=2ATR | fee {FEE_RT*100:.2f}% RT") + all_years = sorted({y for a, tf in cases for y in simulate(bollinger_fade(get_df(a, tf), **base), get_df(a, tf))["yearly"]}) + print(f" {'Asset/TF':<10s}" + "".join(f"{y:>8d}" for y in all_years) + f"{'TOT%':>9s}{'DD%':>6s}") + for a, tf in cases: + df = get_df(a, tf) + r = simulate(bollinger_fade(df, **base), df) + row = "".join(f"{r['yearly'].get(y, 0):>+8.0f}" for y in all_years) + print(f" {a+' '+tf:<10s}{row}{r['ret']:>+9.0f}{r['dd']:>6.0f}") + + # --- sensibilita' fee --- + print(f"\n [2] SENSIBILITA' FEE — Ret% FULL / OOS (n=20 k=2.5)") + fees = [0.0, 0.0005, 0.001, 0.002] + print(f" {'Asset/TF':<10s}" + "".join(f"{f'{f*100:.2f}%RT':>22s}" for f in fees)) + print(f" {'':<10s}" + "".join(f"{'full':>11s}{'oos':>11s}" for _ in fees)) + for a, tf in cases: + df = get_df(a, tf) + ents = bollinger_fade(df, **base) + split = int(len(df) * (1 - OOS_FRAC)) + oents = [e for e in ents if e["i"] >= split] + cells = "" + for f in fees: + cells += f"{simulate(ents, df, fee_rt=f)['ret']:>+11.0f}{simulate(oents, df, fee_rt=f)['ret']:>+11.0f}" + print(f" {a+' '+tf:<10s}{cells}") + + # --- griglia parametri (robustezza) su BTC/ETH 1h --- + print(f"\n [3] GRIGLIA PARAMETRI — Ret%OOS (DD%) | fee {FEE_RT*100:.2f}% RT, deve essere stabile") + for a in ["BTC", "ETH"]: + df = get_df(a, "1h") + split = int(len(df) * (1 - OOS_FRAC)) + print(f"\n {a} 1h " + "".join(f"{f'k={k}':>16s}" for k in [2.0, 2.5, 3.0])) + for n in [14, 20, 30, 50]: + cells = "" + for k in [2.0, 2.5, 3.0]: + ents = [e for e in bollinger_fade(df, n=n, k=k, sl_atr=2.0, max_bars=24) if e["i"] >= split] + r = simulate(ents, df) + cell = f"{r['ret']:+.0f}({r['dd']:.0f})" + cells += f"{cell:>16s}" + print(f" n={n:<4d}{cells}") + + +def main(): + print("=" * 120) + print(f" RICERCA STRATEGIE — NETTO dopo fee {FEE_RT*100:.2f}% RT | leva {LEV:.0f}x | pos {POS*100:.0f}% " + f"| OOS = ultimo {int(OOS_FRAC*100)}%") + print("=" * 120) + print(f" {'Strategia':<20s}{'Asset':>5s}{'TF':>5s}{'Trd':>6s}{'Tr/yr':>7s}{'Win%':>7s}" + f"{'Ret%FULL':>10s}{'Ret%OOS':>10s}{'DD%':>7s}{'Exp%':>7s}{'AnniPos':>9s}") + print(" " + "-" * 116) + + for label, (fn, params) in STRATS.items(): + for asset in ["BTC", "ETH"]: + for tf in ["1h", "4h"]: + df = get_df(asset, tf) + ents = fn(df, **params) + full = simulate(ents, df) + split = int(len(df) * (1 - OOS_FRAC)) + oos = simulate([e for e in ents if e["i"] >= split], df) + yrs = full["yearly"] + pos_yrs = sum(1 for v in yrs.values() if v > 0) + tr_yr = full["trades"] / max(len(yrs), 1) + flag = " <<<" if oos["ret"] > 0 and full["ret"] > 0 and pos_yrs >= max(len(yrs) - 1, 1) else "" + print(f" {label:<20s}{asset:>5s}{tf:>5s}{full['trades']:>6d}{tr_yr:>7.0f}{full['win']:>7.1f}" + f"{full['ret']:>+10.1f}{oos['ret']:>+10.1f}{full['dd']:>7.1f}{full['exposure']:>7.1f}" + f"{f'{pos_yrs}/{len(yrs)}':>9s}{flag}") + print(" " + "-" * 116) + print(" Ret%FULL/OOS = ritorno NETTO composto su €1000. AnniPos = anni con PnL netto>0.") + print(" <<< = positivo full+OOS e robusto (quasi tutti gli anni positivi).") + deep_dive() + + +if __name__ == "__main__": + main() diff --git a/scripts/strategies/MR01_bollinger_fade.py b/scripts/strategies/MR01_bollinger_fade.py new file mode 100644 index 0000000..d2bb2bd --- /dev/null +++ b/scripts/strategies/MR01_bollinger_fade.py @@ -0,0 +1,167 @@ +"""MR01 — Bollinger Fade (mean-reversion). + +L'UNICA famiglia con edge netto reale dopo l'analisi out-of-sample fee-aware +(vedi scripts/analysis/strategy_research.py). Contrario della tesi squeeze: +i breakout RIENTRANO, quindi si fada l'estremo verso la media. + +Logica: + 1. Bollinger Band (window n, k deviazioni) sul close + 2. ENTRY: close esce sotto la banda inferiore -> long (o sopra la superiore -> short) + 3. EXIT: take-profit alla media mobile (il rientro atteso), + stop-loss a sl_atr*ATR oltre l'estremo, oppure time-limit max_bars + 4. ingresso a close[i] (eseguibile dal vivo, nessun look-ahead) + +Validazione (netto, fee 0.10% RT reale Deribit, leva 3x, OOS = ultimo 30%): + BTC 1h n=50 k=2.5: +201% OOS, DD 15%, ~tutti gli anni positivi + ETH 1h n=50 k=2.0: +1238% OOS, DD 23% + Robusto su TUTTA la griglia n in {14,20,30,50} x k in {2.0,2.5,3.0} + e su tutte le fee 0.00-0.20% RT (margine di sicurezza ampio). + +NOTA LIVE: usa TP alla media + SL ad ATR + max_bars. Lo StrategyWorker attuale +esce solo a hold_bars/stop -2% fisso: per tradarla come validata il worker deve +supportare gli exit TP/SL passati in metadata (vedi metadata di ogni Signal). +""" +from __future__ import annotations +import sys +sys.path.insert(0, ".") + +import numpy as np +import pandas as pd + +from src.strategies.base import Strategy, Signal, BacktestResult, YearlyStats, TF_MINUTES +from src.data.downloader import load_data + + +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 + + +class BollingerFade(Strategy): + name = "MR01_bollinger_fade" + description = "Mean-reversion: fada la banda di Bollinger, TP alla media" + default_assets = ["BTC", "ETH"] + default_timeframes = ["1h"] + fee_rt = 0.001 # Deribit perp realistico (taker 0.05%/lato) + leverage = 3.0 + position_size = 0.15 + initial_capital = 1000.0 + + def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex, + **params) -> list[Signal]: + c = df["close"].values + n_len = len(c) + bb_w = params.get("bb_window", 50) + k = params.get("k", 2.5) + sl_atr = params.get("sl_atr", 2.0) + max_bars = params.get("max_bars", 24) + + ma = pd.Series(c).rolling(bb_w).mean().values + sd = pd.Series(c).rolling(bb_w).std().values + a = _atr(df, 14) + up, lo = ma + k * sd, ma - k * sd + + signals: list[Signal] = [] + for i in range(bb_w + 14, n_len): + if np.isnan(up[i]) or np.isnan(a[i]): + continue + if c[i] < lo[i] and c[i - 1] >= lo[i - 1]: + d, sl = 1, c[i] - sl_atr * a[i] + elif c[i] > up[i] and c[i - 1] <= up[i - 1]: + d, sl = -1, c[i] + sl_atr * a[i] + else: + continue + signals.append(Signal( + idx=i, direction=d, entry_price=c[i], + metadata={"tp": float(ma[i]), "sl": float(sl), "max_bars": max_bars}, + )) + return signals + + def backtest(self, asset: str, tf: str = "1h", hold: int = 3, + **params) -> BacktestResult | None: + """Backtest fedele: TP alla media / SL ad ATR / time-limit, fee+leva nette.""" + df = load_data(asset, tf) + ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True) + signals = self.generate_signals(df, ts, **params) + if not signals: + return None + + h, l, c = df["high"].values, df["low"].values, df["close"].values + n = len(c) + fee = self.fee_rt * self.leverage + capital = peak = float(self.initial_capital) + max_dd = 0.0 + total_bars = 0 + last_exit = -1 + yearly: dict[int, dict] = {} + + for sig in signals: + i, d = sig.idx, sig.direction + if i <= last_exit or i + 1 >= n: + continue + entry = c[i] + tp, sl, mb = sig.metadata["tp"], sig.metadata["sl"], sig.metadata["max_bars"] + exit_p = c[min(i + mb, n - 1)] + j = min(i + mb, n - 1) + for step in range(1, mb + 1): + j = i + step + 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: + exit_p = sl; break + if hit_tp: + exit_p = tp; break + if step == mb: + exit_p = c[j] + + ret = (exit_p - entry) / entry * d * self.leverage - fee + capital = max(capital + capital * self.position_size * ret, 10.0) + if capital > peak: + peak = capital + max_dd = max(max_dd, (peak - capital) / peak) + total_bars += (j - i) + last_exit = j + + year = ts.iloc[i].year + yr = yearly.setdefault(year, {"w": 0, "t": 0, "pnl": 0.0}) + yr["t"] += 1 + if ret > 0: + yr["w"] += 1 + yr["pnl"] += ret * self.initial_capital + + all_t = sum(v["t"] for v in yearly.values()) + all_w = sum(v["w"] for v in yearly.values()) + if all_t == 0: + return None + + yearly_stats = [YearlyStats(y, v["t"], v["w"], v["pnl"]) for y, v in sorted(yearly.items())] + return BacktestResult( + strategy_name=self.name, asset=asset, timeframe=tf, params=params, + trades=all_t, wins=all_w, pnl=sum(v["pnl"] for v in yearly.values()), + capital=capital, initial_capital=self.initial_capital, + max_dd=max_dd * 100, time_in_market_pct=total_bars / n * 100, + avg_trade_duration_h=total_bars / all_t * TF_MINUTES.get(tf, 60) / 60, + years_active=len(yearly), yearly=yearly_stats, + ) + + +if __name__ == "__main__": + strat = BollingerFade() + print(f"{'=' * 110}") + print(f" MR01 BOLLINGER FADE — netto fee {strat.fee_rt*100:.2f}% RT, leva {strat.leverage:.0f}x") + print(f"{'=' * 110}") + results = [] + for asset in ["BTC", "ETH"]: + for k in [2.0, 2.5]: + r = strat.backtest(asset, "1h", bb_window=50, k=k, sl_atr=2.0, max_bars=24) + if r: + r.strategy_name = f"MR01 {asset} 1h n50 k{k}" + results.append(r) + for r in results: + r.print_summary() + if results: + results[0].print_yearly() diff --git a/scripts/strategies/AD01_adaptive_squeeze.py b/scripts/waste/AD01_adaptive_squeeze.py similarity index 100% rename from scripts/strategies/AD01_adaptive_squeeze.py rename to scripts/waste/AD01_adaptive_squeeze.py diff --git a/scripts/strategies/CM01_cross_market_momentum.py b/scripts/waste/CM01_cross_market_momentum.py similarity index 100% rename from scripts/strategies/CM01_cross_market_momentum.py rename to scripts/waste/CM01_cross_market_momentum.py diff --git a/scripts/strategies/ML01_squeeze_gbm.py b/scripts/waste/ML01_squeeze_gbm.py similarity index 100% rename from scripts/strategies/ML01_squeeze_gbm.py rename to scripts/waste/ML01_squeeze_gbm.py diff --git a/scripts/strategies/MT01_squeeze_mtf_momentum.py b/scripts/waste/MT01_squeeze_mtf_momentum.py similarity index 100% rename from scripts/strategies/MT01_squeeze_mtf_momentum.py rename to scripts/waste/MT01_squeeze_mtf_momentum.py diff --git a/scripts/strategies/PD01_price_volume_divergence.py b/scripts/waste/PD01_price_volume_divergence.py similarity index 100% rename from scripts/strategies/PD01_price_volume_divergence.py rename to scripts/waste/PD01_price_volume_divergence.py diff --git a/scripts/strategies/SQ01_squeeze_base.py b/scripts/waste/SQ01_squeeze_base.py similarity index 100% rename from scripts/strategies/SQ01_squeeze_base.py rename to scripts/waste/SQ01_squeeze_base.py diff --git a/scripts/strategies/SQ02_squeeze_antifake_vol.py b/scripts/waste/SQ02_squeeze_antifake_vol.py similarity index 100% rename from scripts/strategies/SQ02_squeeze_antifake_vol.py rename to scripts/waste/SQ02_squeeze_antifake_vol.py diff --git a/scripts/strategies/SQ03_squeeze_all_filters.py b/scripts/waste/SQ03_squeeze_all_filters.py similarity index 100% rename from scripts/strategies/SQ03_squeeze_all_filters.py rename to scripts/waste/SQ03_squeeze_all_filters.py diff --git a/scripts/strategies/SQ04_squeeze_ultimate.py b/scripts/waste/SQ04_squeeze_ultimate.py similarity index 100% rename from scripts/strategies/SQ04_squeeze_ultimate.py rename to scripts/waste/SQ04_squeeze_ultimate.py diff --git a/src/live/signal_engine.py b/src/live/signal_engine.py index 0b8b6d6..0a7a39c 100644 --- a/src/live/signal_engine.py +++ b/src/live/signal_engine.py @@ -112,6 +112,54 @@ class SignalEngine: self.squeeze_start_idx = 0 self.trained = False + def _new_model(self) -> GradientBoostingClassifier: + return GradientBoostingClassifier( + n_estimators=150, max_depth=4, min_samples_leaf=10, + learning_rate=0.05, subsample=0.8, random_state=42, + ) + + def _validate_oos(self, X: np.ndarray, y: np.ndarray, test_frac: float = 0.2) -> dict: + """Split temporale (no shuffle) per stimare la performance out-of-sample. + + Allena su training iniziale e valuta sull'ultimo `test_frac` dei campioni. + Oltre all'accuratezza OOS, riporta la precisione sui soli segnali con + confidenza >= ml_thr — cioè i trade che la strategia aprirebbe davvero. + """ + n_test = int(len(X) * test_frac) + n_train = len(X) - n_test + if n_train < 30 or n_test < 5: + return {"oos_warning": "test set troppo piccolo", "oos_test_samples": n_test} + + scaler = StandardScaler() + X_tr = scaler.fit_transform(X[:n_train]) + X_te = scaler.transform(X[n_train:]) + y_tr, y_te = y[:n_train], y[n_train:] + + model = self._new_model() + model.fit(X_tr, y_tr) + + up_idx = list(model.classes_).index(1) + p_up = model.predict_proba(X_te)[:, up_idx] + test_acc = float(np.mean((p_up >= 0.5).astype(int) == y_te) * 100) + oos_train_acc = float(np.mean(model.predict(X_tr) == y_tr) * 100) + + long_sig = p_up >= self.ml_thr + short_sig = p_up <= (1 - self.ml_thr) + n_sig = int((long_sig | short_sig).sum()) + if n_sig > 0: + correct = int(((long_sig & (y_te == 1)) | (short_sig & (y_te == 0))).sum()) + sig_prec = round(correct / n_sig * 100, 1) + else: + sig_prec = None + + return { + "oos_train_accuracy": round(oos_train_acc, 1), + "oos_test_accuracy": round(test_acc, 1), + "oos_test_samples": n_test, + "oos_signals": n_sig, + "oos_signal_precision": sig_prec, + } + def train(self, df: pd.DataFrame, lookahead: int = 3) -> dict: """Addestra il modello su dati storici.""" close = df["close"].values @@ -154,20 +202,24 @@ class SignalEngine: X = np.array(X_all) y = np.array(y_all) + oos = self._validate_oos(X, y) + self.scaler = StandardScaler() X_s = self.scaler.fit_transform(X) - self.model = GradientBoostingClassifier( - n_estimators=150, max_depth=4, min_samples_leaf=10, - learning_rate=0.05, subsample=0.8, random_state=42, - ) + self.model = self._new_model() self.model.fit(X_s, y) self.trained = True preds = self.model.predict(X_s) - train_acc = np.mean(preds == y) * 100 + train_acc = float(np.mean(preds == y) * 100) - return {"samples": len(X), "up_ratio": np.mean(y) * 100, "train_accuracy": train_acc} + return { + "samples": len(X), + "up_ratio": round(float(np.mean(y) * 100), 1), + "train_accuracy": round(train_acc, 1), + **oos, + } def check_signal(self, df: pd.DataFrame) -> dict | None: """Controlla se c'è un segnale sulle ultime candele. diff --git a/src/live/strategy_loader.py b/src/live/strategy_loader.py index 3c1b053..6eb405c 100644 --- a/src/live/strategy_loader.py +++ b/src/live/strategy_loader.py @@ -12,13 +12,12 @@ STRATEGIES_DIR = PROJECT_ROOT / "scripts" / "strategies" _REGISTRY: dict[str, type[Strategy]] = {} +# Solo strategie con edge netto validato out-of-sample (fee-aware). +# La famiglia squeeze-breakout (SQ/MT/ML/AD/CM/PD) e' stata spostata in +# scripts/waste/: l'edge storico era un artefatto di look-ahead +# (vedi scripts/analysis/oos_validation.py). MODULE_MAP = { - "SQ01_squeeze_base": ("SQ01_squeeze_base", "SqueezeBase"), - "SQ02_antifake_vol": ("SQ02_squeeze_antifake_vol", "SqueezeAntifakeVol"), - "SQ03_filtered": ("SQ03_squeeze_all_filters", "SqueezeFiltered"), - "SQ04_ultimate": ("SQ04_squeeze_ultimate", "SqueezeUltimate"), - "ML01_squeeze_gbm": ("ML01_squeeze_gbm", "SqueezeGBM"), - "MT01_squeeze_mtf": ("MT01_squeeze_mtf_momentum", "SqueezeMTFMomentum"), + "MR01_bollinger_fade": ("MR01_bollinger_fade", "BollingerFade"), } diff --git a/strategies.yml b/strategies.yml index 538d4fd..a5b0fd5 100644 --- a/strategies.yml +++ b/strategies.yml @@ -6,46 +6,29 @@ defaults: poll_seconds: 60 retrain_hours: 24 +# Solo MR01 Bollinger fade (mean-reversion): unica con edge netto validato +# out-of-sample e fee-aware. La famiglia squeeze e' in scripts/waste/. +# ATTENZIONE: MR01 esce su TP-alla-media / SL-ad-ATR / max_bars (vedi metadata +# dei Signal). Lo StrategyWorker attuale esce solo a hold_bars/stop -2% fisso: +# va aggiornato per usare gli exit in metadata PRIMA di tradare MR01 dal vivo. strategies: - - name: SQ02_antifake_vol + - name: MR01_bollinger_fade asset: BTC - tf: 15m - enabled: true - - - name: SQ02_antifake_vol - asset: ETH - tf: 15m - enabled: true - - - name: SQ01_squeeze_base - asset: BTC - tf: 15m - enabled: true - - - name: ML01_squeeze_gbm - asset: ETH - tf: 15m - enabled: true - position_size: 0.15 - params: - ml_threshold: 0.70 - bb_window: 14 - sq_threshold: 0.8 - - - name: MT01_squeeze_mtf - asset: BTC - tf: 15m + tf: 1h enabled: true params: - ema_period: 20 - min_slope: 0.001 - vol_filter: true + bb_window: 50 + k: 2.5 + sl_atr: 2.0 + max_bars: 24 - - name: MT01_squeeze_mtf + # ETH: edge positivo ma DD piu' alto (~70%); leva piu' bassa consigliata + - name: MR01_bollinger_fade asset: ETH - tf: 15m + tf: 1h enabled: true params: - ema_period: 20 - min_slope: 0.001 - vol_filter: true + bb_window: 50 + k: 2.5 + sl_atr: 2.0 + max_bars: 24