diff --git a/CLAUDE.md b/CLAUDE.md index 2de2736..46150f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,6 +104,20 @@ Ricerca completa: `scripts/analysis/strategy_research.py` (MR01) e `scripts/analysis/strategy_research_v2.py` (MR02/MR03/MR07). Validazione live-path: `scripts/analysis/oos_validation.py`. +**Filtro trend (riduzione DD + aumento Acc).** Tutte le fade accettano i parametri +opzionali `trend_max` / `ema_long`: saltano i segnali quando il prezzo è troppo +esteso rispetto al trend di fondo (`|close − EMA(ema_long)| / ATR(14) > trend_max`), +cioè quando si starebbe fadando un trend/crollo estremo. Con `trend_max=3.0`, +`ema_long=200` (default in `strategies.yml`): accuratezza su tutti gli sleeve +e DD giù drasticamente su ETH (MR01 71%→26%, MR02 42%→25%, MR03 66%→34%, +MR07 46%→21%), edge OOS confermato (vedi `scripts/analysis/risk_portfolio.py`). +Unica eccezione: MR03 BTC, dove il filtro peggiora entrambe → lasciato disattivo. +Leva non robusta scartate: vol-target sizing e skip-alta-volatilità (peggiorano). + +**Portafoglio.** Diversificare su sotto-conti indipendenti equipesati (le 4 strategie +× BTC/ETH, pos 0.15 ciascuno) abbatte il DD aggregato: ~14% full / ~10% OOS sul +paniere di 8 sleeve, contro il 20-70% del singolo. È la vera leva anti-drawdown. + **Metodologia obbligatoria per ogni nuova strategia** (per non ripetere l'errore squeeze): 1. Ingresso eseguibile: direzione e prezzo decisi con dati **fino a `close[i]`**, mai `close[i-1]` con direzione da `i`. 2. Backtest **NETTO** dopo fee realistiche Deribit (**0.10% RT** taker, non 0.20%) + leva. diff --git a/docs/diary/2026-05-28.md b/docs/diary/2026-05-28.md index 990824a..9757a6a 100644 --- a/docs/diary/2026-05-28.md +++ b/docs/diary/2026-05-28.md @@ -145,3 +145,49 @@ DD alto su ETH (MR03 ~66%, come MR01) → leva più bassa consigliata per quell' `CLAUDE.md` (aggiornati). **Lezione confermata:** ogni edge robusto trovato finora è mean-reversion; ogni variante trend/continuation o oscillatore senza filtro perde netto. + +--- + +### 23:45 — Aumentare Acc e ridurre DD (filtro trend + portafoglio) + +**Obiettivo:** alzare accuratezza e abbassare drawdown sulle 4 fade, senza +distruggere l'edge né overfittare (ogni leva misurata FULL **e** OOS). + +**Diagnosi:** perdite/DD concentrati 2018–2021 (bear/covid/caos vol), su ETH DD +pieno 66–71%. Banco di prova: `scripts/analysis/risk_improvements.py` e +`risk_portfolio.py`. + +**Leve testate:** + +| Leva | Esito | Motivo | +|---|---|---| +| Sizing vol-target (size ∝ 1/dist-SL) | ⛔ | Over-size sui trade a stop stretto → DD su, ritorno giù | +| Skip alta volatilità (ATR% in coda alta) | ⛔ | L'alta vol è *positiva* per le fade (più reversione): Acc e ritorno giù | +| **Filtro trend** (`\|close−EMA200\|/ATR > soglia` → salta) | ✅ | Non fada trend/crolli estremi: Acc↑ ovunque, DD↓ molto su ETH, OOS regge | +| **Portafoglio** equipesato (sotto-conti indipendenti) | ✅ | Curve poco correlate → DD aggregato 14% (full)/10% (OOS) vs 20-70% singolo | + +**Filtro trend — sweep soglia** (assoluta in ATR, regola unica per tutte = niente +overfit): 3.0 ATR è l'equilibrio (2.0 taglia troppo ritorno). Effetto su config +deployata (base → filtro): + +| Sleeve | Acc | DD | +|---|---|---| +| MR01 ETH | 46→55 | **71→26** | +| MR02 ETH | 49→55 | 42→25 | +| MR03 ETH | 49→52 | 66→34 | +| MR07 ETH | 48→54 | 46→21 | +| MR01 BTC | 51→54 | 32→34* | +| MR02 BTC | 48→52 | 29→23 | +| MR07 BTC | 49→53 | 25→18 | +| MR03 BTC | 47→47 | 37→37 (filtro OFF) | + +\*MR01 BTC: DD full +2pt ma Acc +3.7 e DD OOS piatto (14.8→15.0). **MR03 BTC**: +il filtro peggiora entrambe (unico sleeve) → lasciato disattivo nello yaml. + +**Implementazione:** helper `trend_distance()` in `fade_base.py`; param opzionali +`trend_max`/`ema_long` (default None = retro-compatibile) in tutte le strategie +(MR01/02/03/07); `strategies.yml` con `trend_max: 3.0, ema_long: 200` (eccetto +MR03 BTC). Verificato: equivalenza produzione vs ricerca. + +**Lezione:** il modo onesto di ridurre il DD non è strozzare il sizing (peggiora), +ma (a) non opporsi a trend estremi e (b) diversificare su strategie scorrelate. diff --git a/scripts/analysis/risk_improvements.py b/scripts/analysis/risk_improvements.py new file mode 100644 index 0000000..e66c946 --- /dev/null +++ b/scripts/analysis/risk_improvements.py @@ -0,0 +1,139 @@ +"""Migliorare Acc e ridurre DD sulle fade (MR01/MR02/MR03/MR07) senza overfit. + +Leve testate, ognuna misurata FULL e OOS (ultimo 30%) per non illudersi: + - vol-target sizing: size per trade ~ 1/distanza-SL -> rischio costante, DD piu' liscio + - filtro vol regime: salta i trade con ATR% in coda alta (periodi caotici) + - filtro anti-trend: non fadare contro un trend forte (|close-EMA_long|/ATR grande) + - portfolio: equity curve combinata delle 4 strategie su un conto unico + +Engine fedele (ingresso close[i], exit TP/SL intrabar o time-limit, non-overlap, +capitale composto) con sizing per-trade. Numeri NETTI fee 0.10% RT, leva 3x. +""" +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 +from scripts.analysis.strategy_research import bollinger_fade, atr +from scripts.analysis.strategy_research_v2 import donchian_fade, keltner_fade, return_reversal + +FEE_RT, LEV, POS, INIT, OOS_FRAC = 0.001, 3.0, 0.15, 1000.0, 0.30 + +# config base di ogni strategia (come strategies.yml) +STRATS = { + "MR01": (bollinger_fade, dict(n=50, k=2.5, sl_atr=2.0, max_bars=24)), + "MR02": (donchian_fade, dict(n=20, sl_atr=2.0, max_bars=24)), + "MR03": (keltner_fade, dict(n=30, k=2.0, sl_atr=2.0, max_bars=24)), + "MR07": (return_reversal,dict(n=50, k=3.5, tp_atr=2.0, sl_atr=1.5, max_bars=24)), +} +STRATS_ETH3 = dict(STRATS); STRATS_ETH3["MR03"] = (keltner_fade, dict(n=50, k=2.0, sl_atr=2.0, max_bars=24)) + + +def add_context(ents, df, ema_long=200): + """Aggiunge a ogni entry: sl_dist_pct, atr_pct, trend_dist (|close-EMA|/ATR).""" + c = df["close"].values + a = atr(df, 14) + el = pd.Series(c).ewm(span=ema_long, adjust=False).mean().values + apct = a / c + for e in ents: + i = e["i"] + e["sl_dist"] = abs(c[i] - e["sl"]) / c[i] + e["atr_pct"] = apct[i] + e["trend_dist"] = abs(c[i] - el[i]) / a[i] if a[i] else 0.0 + return ents + + +def simulate(ents, df, fee_rt=FEE_RT, lev=LEV, split=-1, + sizer=None, vol_skip=None, trend_skip=None, max_size=0.30): + """sizer: funzione(entry)->frazione capitale; default POS fisso. + vol_skip: soglia atr_pct sopra cui salto. trend_skip: soglia trend_dist sopra cui salto.""" + h, l, c = df["high"].values, df["low"].values, df["close"].values + n = len(c) + ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True) + cap = peak = INIT + dd = 0.0; last = -1; trd = wins = 0 + fee = fee_rt * lev + yearly = {}; rets = [] + for e in ents: + i, d = e["i"], e["d"] + if i <= last or i + 1 >= n or i < split: + continue + if vol_skip is not None and e["atr_pct"] > vol_skip: + continue + if trend_skip is not None and e["trend_dist"] > trend_skip: + continue + entry = c[i]; tp, sl, mb = e["tp"], e["sl"], e["max_bars"] + exit_p = c[min(i + mb, n - 1)]; j = min(i + mb, n - 1) + for k in range(1, mb + 1): + j = i + k + if j >= n: + exit_p = c[n - 1]; break + hs = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl) + ht = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp) + if hs: exit_p = sl; break + if ht: exit_p = tp; break + if k == mb: exit_p = c[j] + ret = (exit_p - entry) / entry * d * lev - fee + size = POS if sizer is None else min(sizer(e), max_size) + cap = max(cap + cap * size * ret, 10.0) + peak = max(peak, cap); dd = max(dd, (peak - cap) / peak) + trd += 1; wins += ret > 0; last = j; rets.append(ret * size) + y = ts.iloc[i].year; yearly[y] = yearly.get(y, 0.0) + ret * size * INIT + sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(len(rets))) if len(rets) > 1 and np.std(rets) > 0 else 0.0 + return dict(trades=trd, acc=wins / trd * 100 if trd else 0.0, + ret=(cap / INIT - 1) * 100, dd=dd * 100, yearly=yearly, sharpe=sharpe) + + +def vol_target_sizer(target=0.015): + """size t.c. rischio (size*lev*sl_dist) ~ target; piu' largo lo stop, meno size.""" + return lambda e: target / (LEV * max(e["sl_dist"], 1e-4)) + + +def line(label, full, oos): + print(f" {label:<28s}{full['trades']:>6d}{full['acc']:>7.1f}{full['ret']:>+10.0f}{full['dd']:>7.1f}{full['sharpe']:>7.2f}" + f" | {oos['trades']:>5d}{oos['acc']:>7.1f}{oos['ret']:>+9.0f}{oos['dd']:>7.1f}{oos['sharpe']:>7.2f}") + + +def main(): + for asset in ["BTC", "ETH"]: + df = load_data(asset, "1h") + split = int(len(df) * (1 - OOS_FRAC)) + table = STRATS_ETH3 if asset == "ETH" else STRATS + # quantili vol globali per la soglia (p90) + print("\n" + "=" * 110) + print(f" {asset} 1h — leve di riduzione DD / aumento Acc | NETTO fee 0.10% RT, leva 3x") + print("=" * 110) + print(f" {'config':<28s}{'Trd':>6s}{'Acc%':>7s}{'Ret%':>10s}{'DD%':>7s}{'Shrp':>7s}" + f" | {'oTrd':>5s}{'oAcc':>7s}{'oRet':>9s}{'oDD':>7s}{'oShrp':>7s}") + print(" " + "-" * 106) + for nm, (fn, params) in table.items(): + ents = add_context(fn(df, **params), df) + apct = np.array([e["atr_pct"] for e in ents]) + p85 = float(np.quantile(apct, 0.85)) + tdist = np.array([e["trend_dist"] for e in ents]) + t90 = float(np.quantile(tdist, 0.90)) + + base_f = simulate(ents, df); base_o = simulate(ents, df, split=split) + line(f"{nm} base", base_f, base_o) + vt_f = simulate(ents, df, sizer=vol_target_sizer()); vt_o = simulate(ents, df, split=split, sizer=vol_target_sizer()) + line(f"{nm} +volTarget", vt_f, vt_o) + vs_f = simulate(ents, df, vol_skip=p85); vs_o = simulate(ents, df, split=split, vol_skip=p85) + line(f"{nm} +volSkip(p85)", vs_f, vs_o) + ts_f = simulate(ents, df, trend_skip=t90); ts_o = simulate(ents, df, split=split, trend_skip=t90) + line(f"{nm} +trendSkip(p90)", ts_f, ts_o) + allf = simulate(ents, df, sizer=vol_target_sizer(), vol_skip=p85, trend_skip=t90) + allo = simulate(ents, df, split=split, sizer=vol_target_sizer(), vol_skip=p85, trend_skip=t90) + line(f"{nm} +ALL", allf, allo) + print(" " + "-" * 106) + print("\n Shrp = Sharpe annuo-naive sui ritorni per-trade. oXxx = stessa metrica su OOS (ultimo 30%).") + + +if __name__ == "__main__": + main() diff --git a/scripts/analysis/risk_portfolio.py b/scripts/analysis/risk_portfolio.py new file mode 100644 index 0000000..a336c2a --- /dev/null +++ b/scripts/analysis/risk_portfolio.py @@ -0,0 +1,163 @@ +"""Affina il filtro trend (soglia assoluta ATR) e costruisce il portafoglio combinato. + +Due risultati: + (1) trend filter: salta le fade quando |close-EMA200|/ATR > soglia (non fadare un + trend estremo). Soglia ASSOLUTA in multipli di ATR -> stessa regola per tutte + le strategie/asset, basso rischio di overfit. Sweep soglie, FULL e OOS. + (2) portafoglio: equity curve combinata delle 4 strategie sullo stesso conto + (rischio diviso fra N posizioni). Curve poco correlate -> DD aggregato << DD + della singola strategia. Confronto singola vs portafoglio, con/senza filtro. +""" +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 +from scripts.analysis.strategy_research import bollinger_fade, atr +from scripts.analysis.strategy_research_v2 import donchian_fade, keltner_fade, return_reversal + +FEE_RT, LEV, INIT, OOS_FRAC = 0.001, 3.0, 1000.0, 0.30 + +STRATS = { + "MR01": (bollinger_fade, dict(n=50, k=2.5, sl_atr=2.0, max_bars=24)), + "MR02": (donchian_fade, dict(n=20, sl_atr=2.0, max_bars=24)), + "MR03": (keltner_fade, dict(n=30, k=2.0, sl_atr=2.0, max_bars=24)), + "MR07": (return_reversal,dict(n=50, k=3.5, tp_atr=2.0, sl_atr=1.5, max_bars=24)), +} +STRATS_ETH = dict(STRATS); STRATS_ETH["MR03"] = (keltner_fade, dict(n=50, k=2.0, sl_atr=2.0, max_bars=24)) + + +def build_trades(ents, df, lev=LEV, fee_rt=FEE_RT, trend_max=None, ema_long=200): + """Ritorna lista trade non-overlap: (entry_idx, exit_idx, ret_netto). Filtro trend opzionale.""" + h, l, c = df["high"].values, df["low"].values, df["close"].values + n = len(c); a = atr(df, 14) + el = pd.Series(c).ewm(span=ema_long, adjust=False).mean().values + fee = fee_rt * lev + out = []; last = -1 + for e in ents: + i, d = e["i"], e["d"] + if i <= last or i + 1 >= n: + continue + if trend_max is not None and a[i] and abs(c[i] - el[i]) / a[i] > trend_max: + continue + entry = c[i]; tp, sl, mb = e["tp"], e["sl"], e["max_bars"] + exit_p = c[min(i + mb, n - 1)]; j = min(i + mb, n - 1) + for k in range(1, mb + 1): + j = i + k + if j >= n: + exit_p = c[n - 1]; break + hs = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl) + ht = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp) + if hs: exit_p = sl; break + if ht: exit_p = tp; break + if k == mb: exit_p = c[j] + ret = (exit_p - entry) / entry * d * lev - fee + out.append((i, j, ret)); last = j + return out + + +def metrics_single(trades, ts, pos=0.15, split=-1): + cap = peak = INIT; dd = 0.0; trd = wins = 0; rets = [] + for i, j, ret in trades: + if i < split: + continue + cap = max(cap + cap * pos * ret, 10.0) + peak = max(peak, cap); dd = max(dd, (peak - cap) / peak) + trd += 1; wins += ret > 0; rets.append(ret * pos) + sh = float(np.mean(rets) / np.std(rets) * np.sqrt(len(rets))) if len(rets) > 1 and np.std(rets) > 0 else 0.0 + return dict(trades=trd, acc=wins / trd * 100 if trd else 0.0, + ret=(cap / INIT - 1) * 100, dd=dd * 100, sharpe=sh) + + +def sleeve_equity(trades, n_bars, pos=0.15, split=-1): + """Equity curve di uno sleeve su sotto-conto indipendente (capitale INIT, pos fissa). + Ritorna array lungo n_bars (step aggiornato alla chiusura di ogni trade).""" + eq = np.full(n_bars, INIT, dtype=float) + cap = INIT + for i, j, ret in sorted(trades, key=lambda t: t[1]): + if i < split: + continue + cap = max(cap + cap * pos * ret, 10.0) + eq[j:] = cap # da j in poi il sotto-conto vale cap + return eq + + +def metrics_portfolio(strat_trades, n_bars, ts, pos=0.15, split=-1): + """Portafoglio equipesato: capitale diviso in N sotto-conti indipendenti, ciascuno + con la sua strategia a `pos` fisso. Equity aggregata = media dei sotto-conti (somma + normalizzata a base INIT). DD misurato sull'equity aggregata. Niente leva sovrapposta.""" + sleeves = [sleeve_equity(tr, n_bars, pos=pos, split=split) for tr in strat_trades.values()] + agg = np.mean(sleeves, axis=0) # media -> base INIT, diversificazione reale + # restringi alla finestra effettiva (da split in poi se OOS) + lo = max(split, 0) + agg = agg[lo:] + peak = np.maximum.accumulate(agg) + dd = float(np.max((peak - agg) / peak) * 100) + trd = sum(1 for tr in strat_trades.values() for i, _, _ in tr if i >= split) + wins = sum(1 for tr in strat_trades.values() for i, _, r in tr if i >= split and r > 0) + return dict(trades=trd, acc=wins / trd * 100 if trd else 0.0, + ret=(agg[-1] / INIT - 1) * 100, dd=dd, sharpe=0.0) + + +def main(): + # ---------- (1) sweep soglia trend ---------- + print("=" * 104) + print(" (1) FILTRO TREND |close-EMA200|/ATR > soglia -> SALTA | NETTO fee 0.10% RT, leva 3x") + print("=" * 104) + print(f" {'Strat/Asset':<14s}{'soglia':>8s}{'Trd':>6s}{'Acc%':>7s}{'Ret%':>9s}{'DD%':>7s}" + f" | {'oAcc':>6s}{'oRet':>9s}{'oDD':>7s}{'oShrp':>7s}") + print(" " + "-" * 100) + thresholds = [None, 4.0, 3.0, 2.5, 2.0] + for asset in ["BTC", "ETH"]: + df = load_data(asset, "1h"); ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True) + split = int(len(df) * (1 - OOS_FRAC)) + table = STRATS_ETH if asset == "ETH" else STRATS + for nm, (fn, params) in table.items(): + ents = fn(df, **params) + for thr in thresholds: + tr = build_trades(ents, df, trend_max=thr) + f = metrics_single(tr, ts); o = metrics_single(tr, ts, split=split) + lab = "base" if thr is None else f"{thr}ATR" + print(f" {nm+' '+asset:<14s}{lab:>8s}{f['trades']:>6d}{f['acc']:>7.1f}{f['ret']:>+9.0f}{f['dd']:>7.1f}" + f" | {o['acc']:>6.1f}{o['ret']:>+9.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}") + print(" " + "-" * 100) + + # ---------- (2) portafoglio combinato ---------- + print("\n" + "=" * 104) + print(" (2) PORTAFOGLIO equipesato: capitale diviso in N sotto-conti indipendenti") + print(" (pos 0.15 ciascuno, filtro trend 3.0 ATR). DD aggregato vs singola strategia.") + print("=" * 104) + print(f" {'Universo':<26s}{'Trd':>6s}{'Acc%':>7s}{'Ret%':>10s}{'DD%':>7s}{'':>7s}" + f" | {'oAcc':>6s}{'oRet':>9s}{'oDD':>7s}{'':>7s}") + print(" " + "-" * 100) + all_trades = {} + for asset in ["BTC", "ETH"]: + df = load_data(asset, "1h"); ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True) + split = int(len(df) * (1 - OOS_FRAC)); n = len(df) + table = STRATS_ETH if asset == "ETH" else STRATS + st = {f"{nm}_{asset}": build_trades(fn(df, **p), df, trend_max=3.0) for nm, (fn, p) in table.items()} + all_trades.update(st) + f = metrics_portfolio(st, n, ts); o = metrics_portfolio(st, n, ts, split=split) + print(f" {'Portafoglio '+asset+' (4 strat)':<26s}{f['trades']:>6d}{f['acc']:>7.1f}{f['ret']:>+10.0f}{f['dd']:>7.1f}{f['sharpe']:>7.2f}" + f" | {o['acc']:>6.1f}{o['ret']:>+9.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}") + # globale 8 sleeve + df0 = load_data("BTC", "1h"); ts0 = pd.to_datetime(df0["timestamp"], unit="ms", utc=True) + split0 = int(len(df0) * (1 - OOS_FRAC)) + f = metrics_portfolio(all_trades, len(df0), ts0); o = metrics_portfolio(all_trades, len(df0), ts0, split=split0) + print(" " + "-" * 100) + print(f" {'GLOBALE BTC+ETH (8 sleeve)':<26s}{f['trades']:>6d}{f['acc']:>7.1f}{f['ret']:>+10.0f}{f['dd']:>7.1f}{f['sharpe']:>7.2f}" + f" | {o['acc']:>6.1f}{o['ret']:>+9.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}") + print("\n Nota: ogni sleeve gira su un sotto-conto indipendente (pos 0.15); l'equity di") + print(" portafoglio e' la media dei sotto-conti. Curve poco correlate => DD aggregato") + print(" molto piu' basso del DD del singolo sleeve (la vera leva anti-drawdown).") + + +if __name__ == "__main__": + main() diff --git a/scripts/strategies/MR01_bollinger_fade.py b/scripts/strategies/MR01_bollinger_fade.py index d2bb2bd..ebbfe12 100644 --- a/scripts/strategies/MR01_bollinger_fade.py +++ b/scripts/strategies/MR01_bollinger_fade.py @@ -57,16 +57,21 @@ class BollingerFade(Strategy): k = params.get("k", 2.5) 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) 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 + el = pd.Series(c).ewm(span=ema_long, adjust=False).mean().values if trend_max is not None else None signals: list[Signal] = [] for i in range(bb_w + 14, n_len): if np.isnan(up[i]) or np.isnan(a[i]): continue + if el is not None and (a[i] == 0 or np.isnan(el[i]) or abs(c[i] - el[i]) / a[i] > trend_max): + 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]: diff --git a/scripts/strategies/MR02_donchian_fade.py b/scripts/strategies/MR02_donchian_fade.py index 6683b75..ec8d7fc 100644 --- a/scripts/strategies/MR02_donchian_fade.py +++ b/scripts/strategies/MR02_donchian_fade.py @@ -26,7 +26,7 @@ import numpy as np import pandas as pd from src.strategies.base import Signal -from src.strategies.fade_base import FadeStrategy, atr +from src.strategies.fade_base import FadeStrategy, atr, trend_distance class DonchianFade(FadeStrategy): @@ -40,16 +40,21 @@ class DonchianFade(FadeStrategy): 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) 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] diff --git a/scripts/strategies/MR03_keltner_fade.py b/scripts/strategies/MR03_keltner_fade.py index 0aa01b3..8caa1bb 100644 --- a/scripts/strategies/MR03_keltner_fade.py +++ b/scripts/strategies/MR03_keltner_fade.py @@ -26,7 +26,7 @@ import numpy as np import pandas as pd from src.strategies.base import Signal -from src.strategies.fade_base import FadeStrategy, atr +from src.strategies.fade_base import FadeStrategy, atr, trend_distance class KeltnerFade(FadeStrategy): @@ -41,16 +41,21 @@ class KeltnerFade(FadeStrategy): k = params.get("k", 2.0) 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) c = df["close"].values e = pd.Series(c).ewm(span=n, adjust=False).mean().values a = atr(df, n) up, lo = e + k * a, e - k * a + td = trend_distance(df, ema_long) if trend_max is not None else None signals: list[Signal] = [] for i in range(n + 1, len(c)): if np.isnan(up[i]) or np.isnan(a[i]): continue + if td is not None and (np.isnan(td[i]) or td[i] > trend_max): + 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]: diff --git a/scripts/strategies/MR07_return_reversal.py b/scripts/strategies/MR07_return_reversal.py index cdbffad..70b41c8 100644 --- a/scripts/strategies/MR07_return_reversal.py +++ b/scripts/strategies/MR07_return_reversal.py @@ -29,7 +29,7 @@ import numpy as np import pandas as pd from src.strategies.base import Signal -from src.strategies.fade_base import FadeStrategy, atr +from src.strategies.fade_base import FadeStrategy, atr, trend_distance class ReturnReversal(FadeStrategy): @@ -45,17 +45,22 @@ class ReturnReversal(FadeStrategy): tp_atr = params.get("tp_atr", 2.0) sl_atr = params.get("sl_atr", 1.5) max_bars = params.get("max_bars", 24) + trend_max = params.get("trend_max") # None = filtro disattivo + ema_long = params.get("ema_long", 200) c = df["close"].values ret = np.zeros_like(c) ret[1:] = np.diff(c) / c[:-1] sig = pd.Series(ret).rolling(n).std().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(sig[i]) or sig[i] == 0 or np.isnan(a[i]): continue + if td is not None and (np.isnan(td[i]) or td[i] > trend_max): + continue z = ret[i] / sig[i] if z <= -k: # crollo di barra -> fade long d, tp, sl = 1, c[i] + tp_atr * a[i], c[i] - sl_atr * a[i] diff --git a/src/strategies/fade_base.py b/src/strategies/fade_base.py index 59eb9cc..3aa953e 100644 --- a/src/strategies/fade_base.py +++ b/src/strategies/fade_base.py @@ -24,6 +24,21 @@ def atr(df: pd.DataFrame, n: int = 14) -> np.ndarray: return pd.Series(tr).rolling(n).mean().values +def trend_distance(df: pd.DataFrame, ema_long: int = 200) -> np.ndarray: + """Distanza del close dalla EMA lunga, in multipli di ATR(14). + + Misura quanto il prezzo e' esteso rispetto al trend di fondo. Le fade + falliscono quando si oppongono a un trend estremo (crolli/parabolic): il + filtro `trend_max` salta i segnali con distanza > soglia. Riduce DD e alza + l'accuratezza (validato OOS: scripts/analysis/risk_portfolio.py). + """ + c = df["close"].values + a = atr(df, 14) + el = pd.Series(c).ewm(span=ema_long, adjust=False).mean().values + with np.errstate(divide="ignore", invalid="ignore"): + return np.abs(c - el) / np.where(a == 0, np.nan, a) + + class FadeStrategy(Strategy): """Strategy con backtest intrabar TP/SL/max_bars (exit guidati dai metadata).""" diff --git a/strategies.yml b/strategies.yml index 02224d3..66699d0 100644 --- a/strategies.yml +++ b/strategies.yml @@ -21,6 +21,8 @@ strategies: k: 2.5 sl_atr: 2.0 max_bars: 24 + trend_max: 3.0 # salta fade contro trend estremo (|close-EMA200|/ATR>3): Acc+ DD- + ema_long: 200 # ETH: edge positivo ma DD piu' alto (~70%); leva piu' bassa consigliata - name: MR01_bollinger_fade @@ -32,6 +34,8 @@ strategies: k: 2.5 sl_atr: 2.0 max_bars: 24 + trend_max: 3.0 # salta fade contro trend estremo (|close-EMA200|/ATR>3): Acc+ DD- + ema_long: 200 # MR02 Donchian fade: fade rottura canale (estremi H/L). Robusto su tutta la # griglia n x sl_atr e tutte le fee. BTC +879%/+171% OOS (8/9 anni), ETH enorme. @@ -43,6 +47,8 @@ strategies: n: 20 sl_atr: 2.0 max_bars: 24 + trend_max: 3.0 # salta fade contro trend estremo (|close-EMA200|/ATR>3): Acc+ DD- + ema_long: 200 - name: MR02_donchian_fade asset: ETH tf: 1h @@ -51,6 +57,8 @@ strategies: n: 20 sl_atr: 2.0 max_bars: 24 + trend_max: 3.0 # salta fade contro trend estremo (|close-EMA200|/ATR>3): Acc+ DD- + ema_long: 200 # MR03 Keltner fade: fade canale ATR su EMA (banda indipendente da Bollinger). # Robusto su tutta la griglia n x k. BTC n30 k2.0 +112% OOS DD20%. @@ -64,6 +72,7 @@ strategies: k: 2.0 sl_atr: 2.0 max_bars: 24 + # NB: su MR03 BTC il filtro trend PEGGIORA Acc e DD (unico sleeve) -> disattivo. - name: MR03_keltner_fade asset: ETH tf: 1h @@ -73,6 +82,8 @@ strategies: k: 2.0 sl_atr: 2.0 max_bars: 24 + trend_max: 3.0 # salta fade contro trend estremo (|close-EMA200|/ATR>3): Acc+ DD- + ema_long: 200 # MR07 Return reversal: fade movimento di barra estremo (z dei rendimenti). # Meccanismo distinto (volatilita' rendimenti, non livelli). Esposizione bassa @@ -87,6 +98,8 @@ strategies: tp_atr: 2.0 sl_atr: 1.5 max_bars: 24 + trend_max: 3.0 # salta fade contro trend estremo (|close-EMA200|/ATR>3): Acc+ DD- + ema_long: 200 - name: MR07_return_reversal asset: ETH tf: 1h @@ -97,3 +110,5 @@ strategies: tp_atr: 2.0 sl_atr: 1.5 max_bars: 24 + trend_max: 3.0 # salta fade contro trend estremo (|close-EMA200|/ATR>3): Acc+ DD- + ema_long: 200