"""MR02 — Donchian Fade (mean-reversion sugli estremi del canale). L'opposto esatto del trend-following Donchian (che PERDE netto: vedi scripts/analysis/strategy_research.py). Coerente con la lezione squeeze: i breakout RIENTRANO, quindi si fada la rottura del canale verso il centro. Logica: 1. Canale Donchian: massimo/minimo delle ultime n barre (escludendo la corrente) 2. ENTRY: close rompe SOPRA il massimo del canale -> SHORT (fade); close rompe SOTTO il minimo -> LONG. Ingresso a close[i] (eseguibile). 3. EXIT: take-profit al centro del canale (il rientro atteso), stop-loss a sl_atr*ATR oltre l'estremo, time-limit max_bars. Validazione (netto, fee 0.10% RT reale Deribit, leva 3x, OOS = ultimo 30%): BTC 1h n=20: +879% FULL / +171% OOS, DD 30%, 8/9 anni positivi ETH 1h n=20: enorme FULL / +8452% OOS, DD 42% Robusto su TUTTA la griglia n in {10,20,30,50} x sl_atr in {1.5,2.0,3.0} (BTC+ETH 1h sempre positivo OOS) e su tutte le fee 0.00-0.20% RT. Ricerca completa: scripts/analysis/strategy_research_v2.py. """ from __future__ import annotations import sys sys.path.insert(0, ".") import numpy as np import pandas as pd from src.strategies.base import Signal from src.strategies.fade_base import FadeStrategy, atr, trend_distance class DonchianFade(FadeStrategy): name = "MR02_donchian_fade" description = "Mean-reversion: fada la rottura del canale Donchian, TP al centro" default_assets = ["BTC", "ETH"] default_timeframes = ["1h"] def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex, **params) -> list[Signal]: n = params.get("n", 20) sl_atr = params.get("sl_atr", 2.0) max_bars = params.get("max_bars", 24) trend_max = params.get("trend_max") # None = filtro disattivo ema_long = params.get("ema_long", 200) # Edge minimo: salta i fade il cui TP (midpoint canale) è entro il costo RT. 0 = off. min_tp_frac = params.get("min_tp_frac", 0.0) 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) td = trend_distance(df, ema_long) if trend_max is not None else None signals: list[Signal] = [] for i in range(n + 14, len(c)): if np.isnan(hh[i]) or np.isnan(a[i]): continue if td is not None and (np.isnan(td[i]) or td[i] > trend_max): continue mid = (hh[i] + ll[i]) / 2.0 if c[i] > hh[i] and c[i - 1] <= hh[i - 1]: # rottura rialzista -> fade short d, sl = -1, c[i] + sl_atr * a[i] elif c[i] < ll[i] and c[i - 1] >= ll[i - 1]: # rottura ribassista -> fade long d, sl = 1, c[i] - sl_atr * a[i] else: continue if min_tp_frac > 0 and abs(mid - c[i]) / c[i] <= min_tp_frac: continue # TP entro le fee -> non eseguibile in utile signals.append(Signal( idx=i, direction=d, entry_price=c[i], metadata={"tp": float(mid), "sl": float(sl), "max_bars": max_bars}, )) return signals if __name__ == "__main__": strat = DonchianFade() print("=" * 110) print(f" MR02 DONCHIAN FADE — netto fee {strat.fee_rt*100:.2f}% RT, leva {strat.leverage:.0f}x") print("=" * 110) for asset in ["BTC", "ETH"]: r = strat.backtest(asset, "1h", n=20, sl_atr=2.0, max_bars=24) if r: r.strategy_name = f"MR02 {asset} 1h n20" r.print_summary() r.print_yearly()