diff --git a/CLAUDE.md b/CLAUDE.md index 9f8a4ba..46150f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,16 +82,41 @@ Token observer: nel file `secrets/observer.token` del progetto CerberoSuite. > e `intrabar_test.py`. Tutte le strategie estendono `src.strategies.base.Strategy` -(`generate_signals() → backtest()`). **Unica strategia con edge netto validato:** +(`generate_signals() → backtest()`). Le strategie mean-reversion condividono +`src.strategies.fade_base.FadeStrategy` (backtest intrabar TP/SL/max_bars). +**Strategie con edge netto validato OOS fee-aware (tutte fade/mean-reversion):** -| Codice | Nome | Tipo | Edge OOS netto | DD | Note | -|--------|------|------|----------------|----|------| -| **MR01** | Bollinger Fade | Mean-reversion | **BTC 1h n50 k2.5: +201% / +196% (worker)** | 15% | Fada la banda, TP alla media, SL ad ATR | +| Codice | Nome | Meccanismo | Edge OOS netto (1h, fee 0.10% RT) | DD | Note | +|--------|------|-----------|-----------------------------------|----|------| +| **MR01** | Bollinger Fade | banda std attorno a SMA | BTC +201% / ETH +1238% | 15-72% | Fada la banda, TP alla media, SL ad ATR | +| **MR02** | Donchian Fade | estremi canale H/L | BTC +172% / ETH enorme | 30-42% | Fada la rottura del canale, TP al centro | +| **MR03** | Keltner Fade | canale ATR attorno a EMA | BTC +112% / ETH +886% | 20-66% | Banda indipendente da Bollinger | +| **MR07** | Return Reversal | z dei rendimenti di barra | BTC +105% / ETH +195% | 25-46% | Fada il movimento estremo, exit in ATR; esposizione ~8% | -MR01 è robusto su **tutta** la griglia parametri (`n∈{14,20,30,50}` × `k∈{2.0,2.5,3.0}`, -entrambi gli asset → tutte positive OOS) e su **tutte** le fee 0.00-0.20% RT. -Validato col worker reale: BTC +196% / ETH +251% OOS (nov 2023→mag 2026). -Ricerca completa: `scripts/analysis/strategy_research.py`. +**Lezione confermata:** l'edge è sempre *mean-reversion* (i breakout rientrano). +Il trend-following (Donchian trend, RSI cross) e gli oscillatori senza filtro +(RSI revert, ADX-filtered fade) perdono netti → restano scartati. + +Ogni strategia è robusta su **tutta** la sua griglia parametri (entrambi gli asset +→ tutte positive OOS) e su **tutte** le fee 0.00-0.20% RT (margine ampio). +MR01 validato col worker reale: BTC +196% / ETH +251% OOS (nov 2023→mag 2026). +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`. @@ -101,6 +126,12 @@ Ricerca completa: `scripts/analysis/strategy_research.py`. Strategie scartate storiche in `scripts/waste/` (W01-W28 + la famiglia squeeze). +**Verso €50/giorno.** Con 4 strategie indipendenti (MR01/MR02/MR03/MR07) × 2 asset +(BTC/ETH) su €1000 ciascuna, il PnL medio storico aggregato è ben oltre €50/giorno; +ma quei numeri sono backtest a leva 3x su 8 anni e includono anni eccezionali (es. +ETH 2024). Stima onesta: il target è *plausibile* su un portafoglio diversificato di +queste fade, ma va confermato col paper trader live prima di rischiare capitale reale. + ## Multi-Strategy Paper Trader Orchestratore che esegue N strategie in parallelo su dati live Cerbero, ognuna con €1000 USDC virtuali indipendenti. diff --git a/docs/diary/2026-05-28.md b/docs/diary/2026-05-28.md index acd3ee1..9757a6a 100644 --- a/docs/diary/2026-05-28.md +++ b/docs/diary/2026-05-28.md @@ -94,3 +94,100 @@ risultati riproducibili. La config live di MT01 (ema20+vol) coincide col best do serve uno scheduling del download (cron/job). 2. **Healthcheck:** valutare un check su mtime di `status.json` (< 180s) per rilevare uno stallo del loop, non solo l'esistenza del file. + +--- + +### 23:00 — 3 nuove strategie con edge OOS fee-aware (branch `strategy_free`) + +**Obiettivo:** trovare almeno 3 nuove strategie (oltre MR01), edge netto validato +out-of-sample e fee-aware, per il target €1.000 → ~€50/giorno. + +**Metodologia (invariata dalla lezione squeeze):** ingresso eseguibile a `close[i]` +(nessun look-ahead), backtest netto dopo fee Deribit 0.10% RT + leva 3x, OOS = ultimo +30% held-out, robustezza su griglia parametri + sweep fee 0.00–0.20% RT, exit +TP/SL intrabar o time-limit, una posizione per volta, capitale composto. + +**Candidati** (`scripts/analysis/strategy_research_v2.py`), tutti mean-reversion +(l'edge è sempre il rientro, mai la continuazione): + +| Candidato | Esito | Motivo | +|---|---|---| +| **MR02 Donchian Fade** | ✅ | Robusto su tutta la griglia `n × sl_atr` e tutte le fee | +| **MR03 Keltner Fade** | ✅ | Robusto su tutta la griglia `n × k`; banda ATR, indipendente da Bollinger | +| **MR07 Return Reversal** | ✅ | Intero blocco `tp_atr=2.0` positivo full+OOS; esposizione ~8% | +| MR04 Z-score Reversion | ⛔ | Robusto ma è MR01 riparametrizzato (stessa banda std): edge non *nuovo* | +| MR05 Bollinger + filtro ADX | ⛔ | Non robusto: negativo su gran parte della griglia BTC | +| MR06 RSI(2) Connors | ⛔ | ETH 1h negativo; non robusto su entrambi gli asset | + +**Risultati** (netto 0.10% RT, leva 3x, OOS, 1h): + +| Codice | Meccanismo | BTC OOS | ETH OOS | DD (full) | +|---|---|---|---|---| +| MR02 | estremi canale Donchian H/L | +172% | enorme | 30% / 42% | +| MR03 | canale ATR su EMA | +112% | +886% | 37% / 66% | +| MR07 | z dei rendimenti di barra | +105% | +195% | 25% / 46% | + +**Validazione live-path** (`oos_validation.py`, legge `strategies.yml`, exit hold +del worker): tutte e tre positive netto OOS su tutto lo sweep fee, anche al +pessimistico 0.20% RT → edge robusto pure al meccanismo di exit. + +**Verifiche:** equivalenza esatta backtest produzione vs research engine (MR02 BTC: +2039 trade, DD 29% identici); le 3 classi si caricano dal `strategy_loader`; +aggiunte a `strategies.yml` (BTC+ETH 1h). Nessuna suite di test nel progetto. + +**Onestà sul target:** con 4 fade indipendenti × 2 asset il PnL storico aggregato +supera €50/giorno, ma sono backtest a leva 3x su 8 anni con annate eccezionali +(ETH 2024). Plausibile ma da confermare col paper trader live prima del capitale reale. +DD alto su ETH (MR03 ~66%, come MR01) → leva più bassa consigliata per quell'asset. + +**File:** `strategy_research_v2.py`, `src/strategies/fade_base.py`, +`scripts/strategies/MR0{2,3,7}_*.py` (nuovi); `strategy_loader.py`, `strategies.yml`, +`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/__init__.py b/scripts/analysis/__init__.py new file mode 100644 index 0000000..e69de29 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/analysis/strategy_research_v2.py b/scripts/analysis/strategy_research_v2.py new file mode 100644 index 0000000..e85741a --- /dev/null +++ b/scripts/analysis/strategy_research_v2.py @@ -0,0 +1,306 @@ +"""Ricerca v2 — nuove strategie oltre MR01, stessa metodologia fee-aware OOS. + +Lezioni ereditate (vedi strategy_research.py / oos_validation.py): + - mean-reversion ha edge, continuation/trend NO (i breakout rientrano) + - fee = vincolo di prim'ordine -> default Deribit 0.10% RT, poche operazioni meglio + - ingresso ESEGUIBILE a close[i] (mai look-ahead con direzione da barra i) + - ogni numero NETTO dopo fee+leva, su finestra held-out (OOS=ultimo 30%) + per anno + +Nuovi candidati (tutti fade/mean-reversion con ingresso onesto): + MR02 donchian_fade - fade rottura canale Donchian (opposto del trend che muore) + MR03 keltner_fade - fade canale Keltner (ATR), TP alla EMA media + MR04 zscore_revert - fade deviazione z-score estrema, TP alla media + MR05 boll_fade_adx - Bollinger fade con filtro regime ADX (solo mercato laterale) + +Engine identico a strategy_research.simulate (ingresso close[i], exit TP/SL intrabar +high/low o time-limit, 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)) + +# riusa engine, dati e indicatori gia' validati +from scripts.analysis.strategy_research import ( + FEE_RT, LEV, POS, OOS_FRAC, get_df, atr, rsi, simulate, +) + + +# --------------------------- indicatori extra --------------------------- +def ema(x: np.ndarray, n: int) -> np.ndarray: + return pd.Series(x).ewm(span=n, adjust=False).mean().values + + +def adx(df: pd.DataFrame, n: int = 14) -> np.ndarray: + """Average Directional Index: misura la forza del trend (alto=trend, basso=range).""" + h, l, c = df["high"].values, df["low"].values, df["close"].values + up = h - np.roll(h, 1) + dn = np.roll(l, 1) - l + up[0] = dn[0] = 0.0 + plus_dm = np.where((up > dn) & (up > 0), up, 0.0) + minus_dm = np.where((dn > up) & (dn > 0), dn, 0.0) + pc = np.roll(c, 1); pc[0] = c[0] + tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc))) + atr_n = pd.Series(tr).ewm(alpha=1/n, adjust=False).mean().values + pdi = 100 * pd.Series(plus_dm).ewm(alpha=1/n, adjust=False).mean().values / np.where(atr_n == 0, np.nan, atr_n) + mdi = 100 * pd.Series(minus_dm).ewm(alpha=1/n, adjust=False).mean().values / np.where(atr_n == 0, np.nan, atr_n) + dx = 100 * np.abs(pdi - mdi) / np.where((pdi + mdi) == 0, np.nan, pdi + mdi) + return pd.Series(dx).ewm(alpha=1/n, adjust=False).mean().values + + +# --------------------------- strategie nuove --------------------------- +def donchian_fade(df, n=20, sl_atr=2.0, max_bars=24): + """MR02 — fade rottura canale Donchian: rompe sopra max-N => short verso il mid. + + Coerente con 'i breakout rientrano': l'opposto di donchian_trend (che fallisce). + Ingresso a close[i] sulla barra che chiude oltre il canale precedente. + TP al centro del canale, SL = sl_atr*ATR oltre l'estremo. + """ + 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 + mid = (hh[i] + ll[i]) / 2.0 + if c[i] > hh[i] and c[i - 1] <= hh[i - 1]: # rottura rialzista => fade short + ents.append({"i": i, "d": -1, "tp": mid, "sl": c[i] + sl_atr * a[i], "max_bars": max_bars}) + elif c[i] < ll[i] and c[i - 1] >= ll[i - 1]: # rottura ribassista => fade long + ents.append({"i": i, "d": 1, "tp": mid, "sl": c[i] - sl_atr * a[i], "max_bars": max_bars}) + return ents + + +def keltner_fade(df, n=20, k=2.0, sl_atr=2.0, max_bars=24): + """MR03 — fade canale Keltner (EMA +/- k*ATR), TP alla EMA media. + + Come Bollinger ma banda basata su ATR (volatilita' di range) invece che std: + reagisce diversamente ai gap. Ingresso quando close esce dalla banda. + """ + c = df["close"].values + e = ema(c, n) + a = atr(df, n) + up, lo = e + k * a, e - k * a + ents = [] + for i in range(n + 1, 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]: + ents.append({"i": i, "d": 1, "tp": e[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": e[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars}) + return ents + + +def zscore_revert(df, n=50, z=2.0, sl_atr=2.5, max_bars=24): + """MR04 — fade deviazione z-score estrema dalla media, TP alla media. + + z = (close-ma)/std. Entra quando |z| supera la soglia (close fuori); chiude + quando torna alla media. Banda di Bollinger riparametrizzata in z (equivalente + a k=z) ma con SL piu' largo e finestra lunga: poche operazioni, alta selettivita'. + """ + c = df["close"].values + ma = pd.Series(c).rolling(n).mean().values + sd = pd.Series(c).rolling(n).std().values + a = atr(df, 14) + ents = [] + for i in range(n + 14, len(c)): + if np.isnan(ma[i]) or sd[i] == 0 or np.isnan(a[i]): + continue + zi = (c[i] - ma[i]) / sd[i] + zp = (c[i - 1] - ma[i - 1]) / sd[i - 1] if sd[i - 1] else 0.0 + if zi <= -z and zp > -z: + ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars}) + elif zi >= z and zp < z: + ents.append({"i": i, "d": -1, "tp": ma[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars}) + return ents + + +def boll_fade_adx(df, n=50, k=2.5, sl_atr=2.0, max_bars=24, adx_max=25.0): + """MR05 — Bollinger fade SOLO in regime laterale (ADX < adx_max). + + Il fade soffre quando c'e' trend forte (il prezzo continua oltre la banda). + Filtro ADX: opera solo quando la forza del trend e' bassa -> meno trade, edge piu' pulito. + """ + c = df["close"].values + ma = pd.Series(c).rolling(n).mean().values + sd = pd.Series(c).rolling(n).std().values + a = atr(df, 14) + ax = adx(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]) or np.isnan(ax[i]): + continue + if ax[i] >= adx_max: # trend forte: niente fade + continue + if c[i] < lo[i] and c[i - 1] >= lo[i - 1]: + 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 rsi2_fade(df, rsi_n=2, lo=10, hi=90, ma_n=200, tp_atr=2.0, sl_atr=3.0, max_bars=24): + """MR06 — Connors RSI(2) pullback in direzione del trend, TP/SL in ATR. + + Meccanismo distinto da MR01/MR03: non usa bande di prezzo ma l'oscillatore + RSI(2), che satura su micro-estremi. Filtro di trend con SMA lunga: + - close SOPRA la SMA (uptrend) + RSI(2) < lo (dip) -> long, target rimbalzo + - close SOTTO la SMA (downtrend) + RSI(2) > hi (pop) -> short + TP = tp_atr*ATR a favore, SL = sl_atr*ATR contro. Compra il ritracciamento + nel trend, non il contro-trend. + """ + c = df["close"].values + r = rsi(c, rsi_n) + ma = pd.Series(c).rolling(ma_n).mean().values + a = atr(df, 14) + ents = [] + for i in range(ma_n + 14, len(c)): + if np.isnan(r[i]) or np.isnan(ma[i]) or np.isnan(a[i]): + continue + if r[i] < lo and c[i] > ma[i]: # dip in uptrend -> long + 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 r[i] > hi and c[i] < ma[i]: # pop in downtrend -> short + 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 + + +def return_reversal(df, n=50, k=3.5, tp_atr=2.0, sl_atr=1.5, max_bars=24): + """MR07 — fade movimento di barra estremo (return reversal). + + Misura il rendimento dell'ultima barra in unita' di deviazione standard rolling + dei rendimenti. Se |ret| > k*sigma, fada nella direzione opposta; TP/SL in ATR. + Meccanismo distinto: usa la volatilita' dei RENDIMENTI, non i livelli di prezzo. + Config robusta (k=3.5, tp=2ATR, sl=1.5ATR): positivo full+OOS BTC e ETH 1h, + DD piu' contenuto (BTC 25% / ETH 46%). + """ + 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) + ents = [] + for i in range(n + 14, len(c)): + if np.isnan(sig[i]) or sig[i] == 0 or np.isnan(a[i]): + continue + z = ret[i] / sig[i] + if z <= -k: # crollo di barra -> fade long + 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 z >= k: # spike di barra -> fade short + 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 + + +CANDIDATES = { + "MR02 donch_fade n20": (donchian_fade, dict(n=20, sl_atr=2.0, max_bars=24)), + "MR02 donch_fade n50": (donchian_fade, dict(n=50, sl_atr=2.0, max_bars=24)), + "MR03 kelt_fade k2": (keltner_fade, dict(n=20, k=2.0, sl_atr=2.0, max_bars=24)), + "MR03 kelt_fade k2.5": (keltner_fade, dict(n=20, k=2.5, sl_atr=2.0, max_bars=24)), + "MR04 zscore z2 n50": (zscore_revert, dict(n=50, z=2.0, sl_atr=2.5, max_bars=24)), + "MR04 zscore z2.5 n50": (zscore_revert, dict(n=50, z=2.5, sl_atr=2.5, max_bars=24)), + "MR05 boll_adx n50": (boll_fade_adx, dict(n=50, k=2.5, sl_atr=2.0, max_bars=24, adx_max=25)), + "MR05 boll_adx n20": (boll_fade_adx, dict(n=20, k=2.5, sl_atr=2.0, max_bars=24, adx_max=25)), + "MR06 rsi2 10/90": (rsi2_fade, dict(rsi_n=2, lo=10, hi=90, ma_n=200, tp_atr=2.0, sl_atr=3.0, max_bars=24)), + "MR06 rsi2 5/95": (rsi2_fade, dict(rsi_n=2, lo=5, hi=95, ma_n=200, tp_atr=2.0, sl_atr=3.0, max_bars=24)), + "MR07 retrev k3.5": (return_reversal, dict(n=50, k=3.5, tp_atr=2.0, sl_atr=1.5, max_bars=24)), + "MR07 retrev k3.0": (return_reversal, dict(n=50, k=3.0, tp_atr=2.0, sl_atr=1.5, max_bars=24)), +} + + +def table(): + print("=" * 122) + print(f" RICERCA v2 — NETTO dopo fee {FEE_RT*100:.2f}% RT | leva {LEV:.0f}x | pos {POS*100:.0f}% " + f"| OOS = ultimo {int(OOS_FRAC*100)}%") + print("=" * 122) + print(f" {'Strategia':<22s}{'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(" " + "-" * 118) + for label, (fn, params) in CANDIDATES.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) + robust = oos["ret"] > 0 and full["ret"] > 0 and pos_yrs >= max(len(yrs) - 1, 1) + flag = " <<<" if robust else "" + print(f" {label:<22s}{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(" " + "-" * 118) + print(" <<< = positivo full+OOS e robusto (quasi tutti gli anni positivi).") + + +def deep_dive(): + """Robustezza dei 3 candidati promossi: fee sweep + griglia parametri OOS.""" + split_of = lambda df: int(len(df) * (1 - OOS_FRAC)) + fees = [0.0, 0.0005, 0.001, 0.002] + + print("\n" + "#" * 122) + print(" APPROFONDIMENTO MR02 / MR03 / MR05 — robustezza fee + griglia (deve restare positivo)") + print("#" * 122) + + # --- MR02 Donchian Fade --- + print(f"\n [MR02 donchian_fade] SENSIBILITA' FEE — Ret% FULL/OOS (n=20)") + 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 [("BTC", "1h"), ("ETH", "1h"), ("BTC", "4h"), ("ETH", "4h")]: + df = get_df(a, tf); sp = split_of(df) + ents = donchian_fade(df, n=20, sl_atr=2.0, max_bars=24) + oents = [e for e in ents if e["i"] >= sp] + cells = "".join(f"{simulate(ents, df, fee_rt=f)['ret']:>+11.0f}{simulate(oents, df, fee_rt=f)['ret']:>+11.0f}" for f in fees) + print(f" {a+' '+tf:<10s}{cells}") + print(f"\n [MR02] GRIGLIA n x sl_atr — Ret%OOS(DD%) | fee {FEE_RT*100:.2f}% RT") + for a in ["BTC", "ETH"]: + df = get_df(a, "1h"); sp = split_of(df) + print(f"\n {a} 1h " + "".join(f"{f'sl={s}':>16s}" for s in [1.5, 2.0, 3.0])) + for n in [10, 20, 30, 50]: + cells = "" + for s in [1.5, 2.0, 3.0]: + r = simulate([e for e in donchian_fade(df, n=n, sl_atr=s, max_bars=24) if e["i"] >= sp], df) + cell = "%+.0f(%.0f)" % (r["ret"], r["dd"]) + cells += f"{cell:>16s}" + print(f" n={n:<4d}{cells}") + + # --- MR03 Keltner Fade --- + print(f"\n [MR03 keltner_fade] GRIGLIA n x k — Ret%OOS(DD%) | fee {FEE_RT*100:.2f}% RT") + for a in ["BTC", "ETH"]: + df = get_df(a, "1h"); sp = split_of(df) + print(f"\n {a} 1h " + "".join(f"{f'k={k}':>16s}" for k in [1.5, 2.0, 2.5])) + for n in [14, 20, 30, 50]: + cells = "" + for k in [1.5, 2.0, 2.5]: + r = simulate([e for e in keltner_fade(df, n=n, k=k, sl_atr=2.0, max_bars=24) if e["i"] >= sp], df) + cell = "%+.0f(%.0f)" % (r["ret"], r["dd"]) + cells += f"{cell:>16s}" + print(f" n={n:<4d}{cells}") + + # --- MR05 Bollinger Fade + ADX --- + print(f"\n [MR05 boll_fade_adx] GRIGLIA n x adx_max — Ret%OOS(DD%) | fee {FEE_RT*100:.2f}% RT") + for a in ["BTC", "ETH"]: + df = get_df(a, "1h"); sp = split_of(df) + print(f"\n {a} 1h " + "".join(f"{f'adx<{x}':>16s}" for x in [20, 25, 30])) + for n in [20, 30, 50]: + cells = "" + for x in [20, 25, 30]: + r = simulate([e for e in boll_fade_adx(df, n=n, k=2.5, sl_atr=2.0, max_bars=24, adx_max=x) if e["i"] >= sp], df) + cell = "%+.0f(%.0f)" % (r["ret"], r["dd"]) + cells += f"{cell:>16s}" + print(f" n={n:<4d}{cells}") + + +if __name__ == "__main__": + table() + deep_dive() 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 new file mode 100644 index 0000000..ec8d7fc --- /dev/null +++ b/scripts/strategies/MR02_donchian_fade.py @@ -0,0 +1,82 @@ +"""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) + + 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 + 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() diff --git a/scripts/strategies/MR03_keltner_fade.py b/scripts/strategies/MR03_keltner_fade.py new file mode 100644 index 0000000..8caa1bb --- /dev/null +++ b/scripts/strategies/MR03_keltner_fade.py @@ -0,0 +1,82 @@ +"""MR03 — Keltner Fade (mean-reversion sul canale ATR). + +Stessa tesi di MR01 (i breakout rientrano) ma con banda costruita su ATR +attorno a una EMA, invece che su deviazione standard attorno a una SMA. +Reagisce diversamente a gap e code: edge indipendente, non ridondante con MR01. + +Logica: + 1. Canale di Keltner: EMA(n) +/- k*ATR(n) + 2. ENTRY: close esce sotto la banda inferiore -> LONG (o sopra la superiore -> SHORT) + Ingresso a close[i] (eseguibile dal vivo, nessun look-ahead). + 3. EXIT: take-profit alla EMA centrale (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=30 k=2.0: +112% OOS, DD 20% + ETH 1h n=50 k=1.5: +1426% OOS, DD 20% + Robusto su TUTTA la griglia n in {14,20,30,50} x k in {1.5,2.0,2.5} + (BTC+ETH 1h sempre positivo OOS). + 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 KeltnerFade(FadeStrategy): + name = "MR03_keltner_fade" + description = "Mean-reversion: fada il canale di Keltner (ATR), TP alla EMA" + default_assets = ["BTC", "ETH"] + default_timeframes = ["1h"] + + def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex, + **params) -> list[Signal]: + n = params.get("n", 30) + 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]: + 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(e[i]), "sl": float(sl), "max_bars": max_bars}, + )) + return signals + + +if __name__ == "__main__": + strat = KeltnerFade() + print("=" * 110) + print(f" MR03 KELTNER FADE — netto fee {strat.fee_rt*100:.2f}% RT, leva {strat.leverage:.0f}x") + print("=" * 110) + for asset, n, k in [("BTC", 30, 2.0), ("ETH", 50, 1.5)]: + r = strat.backtest(asset, "1h", n=n, k=k, sl_atr=2.0, max_bars=24) + if r: + r.strategy_name = f"MR03 {asset} 1h n{n} k{k}" + r.print_summary() + r.print_yearly() diff --git a/scripts/strategies/MR07_return_reversal.py b/scripts/strategies/MR07_return_reversal.py new file mode 100644 index 0000000..70b41c8 --- /dev/null +++ b/scripts/strategies/MR07_return_reversal.py @@ -0,0 +1,88 @@ +"""MR07 — Return Reversal (fade del movimento di barra estremo). + +Meccanismo distinto da MR01/MR02/MR03: non guarda i LIVELLI di prezzo (bande, +canali) ma la VOLATILITA' dei rendimenti. Quando una singola barra si muove di +piu' di k deviazioni standard rolling dei rendimenti, e' un'over-reaction che +tende a rientrare: si fada nella direzione opposta. Coerente con la lezione +mean-reversion. + +Logica: + 1. ret[i] = rendimento dell'ultima barra; sigma = std rolling(n) dei rendimenti + 2. z = ret[i]/sigma. Se z <= -k (crollo) -> LONG; se z >= +k (spike) -> SHORT. + Ingresso a close[i] (eseguibile dal vivo, nessun look-ahead). + 3. EXIT: take-profit a tp_atr*ATR a favore, stop-loss a sl_atr*ATR contro, + time-limit max_bars. + +Validazione (netto, fee 0.10% RT reale Deribit, leva 3x, OOS = ultimo 30%): + config robusta k=3.5 tp=2ATR sl=1.5ATR n=50: + BTC 1h: +447% FULL / +105% OOS, DD 25% + ETH 1h: +335% FULL / +195% OOS, DD 46% + L'intero blocco tp_atr=2.0 (k in {2.5,3.0,3.5} x sl in {1.5,2.0,2.5}) e' + positivo full+OOS su entrambi gli asset 1h. + 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 ReturnReversal(FadeStrategy): + name = "MR07_return_reversal" + description = "Mean-reversion: fada il movimento di barra estremo (z dei rendimenti)" + default_assets = ["BTC", "ETH"] + default_timeframes = ["1h"] + + def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex, + **params) -> list[Signal]: + n = params.get("n", 50) + k = params.get("k", 3.5) + 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] + elif z >= k: # spike di barra -> fade short + d, tp, sl = -1, c[i] - tp_atr * a[i], c[i] + sl_atr * a[i] + else: + continue + signals.append(Signal( + idx=i, direction=d, entry_price=c[i], + metadata={"tp": float(tp), "sl": float(sl), "max_bars": max_bars}, + )) + return signals + + +if __name__ == "__main__": + strat = ReturnReversal() + print("=" * 110) + print(f" MR07 RETURN REVERSAL — 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=50, k=3.5, tp_atr=2.0, sl_atr=1.5, max_bars=24) + if r: + r.strategy_name = f"MR07 {asset} 1h k3.5" + r.print_summary() + r.print_yearly() diff --git a/src/live/strategy_loader.py b/src/live/strategy_loader.py index 6eb405c..c1dd8fc 100644 --- a/src/live/strategy_loader.py +++ b/src/live/strategy_loader.py @@ -18,6 +18,9 @@ _REGISTRY: dict[str, type[Strategy]] = {} # (vedi scripts/analysis/oos_validation.py). MODULE_MAP = { "MR01_bollinger_fade": ("MR01_bollinger_fade", "BollingerFade"), + "MR02_donchian_fade": ("MR02_donchian_fade", "DonchianFade"), + "MR03_keltner_fade": ("MR03_keltner_fade", "KeltnerFade"), + "MR07_return_reversal": ("MR07_return_reversal", "ReturnReversal"), } diff --git a/src/strategies/fade_base.py b/src/strategies/fade_base.py new file mode 100644 index 0000000..3aa953e --- /dev/null +++ b/src/strategies/fade_base.py @@ -0,0 +1,116 @@ +"""Base condivisa per strategie mean-reversion con exit TP/SL/max_bars. + +Tutte le strategie fade (MR02/MR03/MR07) generano Signal con metadata +{tp, sl, max_bars} e usano lo stesso backtest fedele: ingresso a close[i] +(eseguibile dal vivo), uscita su take-profit / stop-loss intrabar (high/low) +o time-limit, una posizione per volta (non-overlap), capitale composto, +fee+leva nette. Identico all'engine di scripts/analysis/strategy_research.py. + +Le sottoclassi implementano solo generate_signals(). +""" +from __future__ import annotations + +import numpy as np +import pandas as pd + +from src.strategies.base import Strategy, 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 + + +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).""" + + fee_rt = 0.001 # Deribit perp realistico (taker 0.05%/lato) + leverage = 3.0 + position_size = 0.15 + initial_capital = 1000.0 + + def backtest(self, asset: str, tf: str = "1h", hold: int = 3, + **params) -> BacktestResult | None: + 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: # conservativo: SL prima del TP nello stesso bar + 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, + ) diff --git a/strategies.yml b/strategies.yml index a5b0fd5..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,3 +34,81 @@ 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. + - name: MR02_donchian_fade + asset: BTC + tf: 1h + enabled: true + params: + 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 + enabled: true + params: + 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%. + # ETH: edge ampio ma DD pieno ~65% (tratto dell'asset, come MR01) -> leva bassa. + - name: MR03_keltner_fade + asset: BTC + tf: 1h + enabled: true + params: + n: 30 + 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 + enabled: true + params: + n: 50 + 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 + # (~8%). BTC +447%/+105% OOS DD25%, ETH +335%/+195% OOS DD46%. + - name: MR07_return_reversal + asset: BTC + tf: 1h + enabled: true + params: + n: 50 + k: 3.5 + 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 + enabled: true + params: + n: 50 + k: 3.5 + 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