From ac6f3766b065ec5552aeeeef73a58b8d7a3f65e0 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Tue, 2 Jun 2026 14:08:15 +0000 Subject: [PATCH] =?UTF-8?q?feat(fade):=20loss-guard=20Hurst=20(skip=20regi?= =?UTF-8?q?me=20persistente)=20=E2=80=94=20dimezza=20il=20DD=20del=20PORT0?= =?UTF-8?q?6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GOAL: limitare le perdite delle fade in regime sfavorevole. Diagnosi (3022 trade): le perdite/stop si concentrano nel regime PERSISTENTE (hurst>0.55: stop-rate 43% vs 21% anti-persistente), NON in bassa vol (low-vol e' net positivo). Ricerca web + workflow 11 agenti: l'UNICO meccanismo che riduce DD senza uccidere l'edge e' il filtro Hurst (ADX, vol-expansion, time-stop, ER, vol-target falliscono il gate FR01). Test esterni ADX/vol-expansion NON si replicano su queste fade crypto. TEST DECISIVO PORT06 (gate FR01) SUPERATO: Hurst-skip h<0.55 sulle 6 fade -> FULL Sharpe 6.62->6.76, FULL DD 4.10%->2.39% (quasi dimezzato), OOS Sharpe 8.89->9.15. Migliora il portafoglio (a differenza di FR01 che diluiva). Implementazione: hurst_skip_mask in fade_base.py (rolling-Hurst causale dalle SOLE close -> nessun feed dati esterno, deployabile inline dal worker) + param hurst_max (default None=off) in MR01/MR02/MR07. Test: test_hurst_lossguard.py. Default off -> zero impatto su backtest/parita'/live finche' non attivato. FIX collaterale: regime_fetcher/regime_lab scrivevano DVOL/funding/feature in data/raw/ -> inquinavano la discovery asset del backtest (rompeva il regression-lock PORT06). Spostati in data/regime/ (gitignored). Suite: 54 passed (lock incluso). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 + docs/diary/2026-06-02-fade-lossguard.md | 67 ++++++++++++++++++++ scripts/analysis/fade_lossguard_port_test.py | 56 ++++++++++++++++ scripts/analysis/regime_fetcher.py | 2 +- scripts/analysis/regime_lab.py | 5 +- scripts/strategies/MR01_bollinger_fade.py | 6 ++ scripts/strategies/MR02_donchian_fade.py | 7 +- scripts/strategies/MR07_return_reversal.py | 7 +- src/strategies/fade_base.py | 15 +++++ tests/portfolio/test_hurst_lossguard.py | 51 +++++++++++++++ 10 files changed, 215 insertions(+), 4 deletions(-) create mode 100644 docs/diary/2026-06-02-fade-lossguard.md create mode 100644 scripts/analysis/fade_lossguard_port_test.py create mode 100644 tests/portfolio/test_hurst_lossguard.py diff --git a/.gitignore b/.gitignore index b4aefe8..498b989 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ data/portfolios/ # stato locale di tooling (non condiviso) .claude/ .omc/ + +# dati regime (DVOL/funding/feature cache, rigenerabili) +data/regime/ diff --git a/docs/diary/2026-06-02-fade-lossguard.md b/docs/diary/2026-06-02-fade-lossguard.md new file mode 100644 index 0000000..0b4c82f --- /dev/null +++ b/docs/diary/2026-06-02-fade-lossguard.md @@ -0,0 +1,67 @@ +# 2026-06-02 — Loss-guard per le fade: filtro Hurst (regime persistente) + +> Goal: limitare le perdite delle fade in "bassa vol". Diagnosi empirica + ricerca web + workflow +> 11 agenti + test decisivo a livello PORT06. Branch `feat/fade-lossguard`. + +## Riformulazione del problema (la premessa era imprecisa) + +Diagnosi su 3022 trade fade (MR01/MR02/MR07 × BTC/ETH, 2021+): **le perdite NON si concentrano in +bassa vol** — anzi il terzile low-DVOL è net positivo (+2,30%/trade). Il vero driver è il **regime +PERSISTENTE/trending**, misurato dall'Hurst: +- somma perdite peggiore: **hurst>0,55** (−2695% in low-vol, dominante in ogni terzile vol) +- **stop-rate 43% per hurst>0,55 vs 21% per hurst<0,45** (anti-persistente) — 2x +- peggiori 1% trade: Hurst medio 0,61 (77% con hurst>0,55, solo 13% in bassa-DVOL) + +## Ricerca web (confermata e smentita dai dati reali) +- **Hurst regime filter** (MR solo H<0,45, evitare H>0,55): **CONFERMATO** sui dati reali. ✅ +- **ADX** (PF 1,62 sotto 20 vs −0,74 sopra 30): **NON si replica** — ADX-skip uccide l'edge + (Sharpe 4,82→0,99) e lo stop-rate non scende. ❌ +- **vol-expansion ATR-ratio>1,5 (−72% perdite)**: **NON si replica** — alza DD e stop-rate. ❌ +- **time-stop ~15 barre**: riduce stop-rate ma alza il DD full → non passa standalone. ❌ + +## Workflow 11 agenti — meccanismi testati +| Meccanismo | OOS Sharpe (base→filt) | DD full | Buon loss-guard? | +|---|---|---|---| +| **Hurst-SKIP h<0,55** | 4,82→4,96 ↑ | 24,3→13,8% ↓ | **SÌ** | +| **Hurst-SIZE 1/0,5/0,25** | 4,65→5,32 ↑ (full) | 33,6→11,3% maxDD ↓ | **SÌ** | +| ADX-skip | 4,82→0,99 ✗ | — | NO (uccide edge) | +| vol-expansion vratio | 4,82→4,04 | 24,3→27,5% ✗ | NO | +| Kaufman ER, time-stop, vol-target, DVOL-rising, combo | tutti ↓ o DD↑ | — | NO | + +**Solo l'Hurst** isola chirurgicamente il regime tossico; gli altri sono "dimmer uniformi" che +tagliano winner insieme ai loser (gate FR01 fallito). + +## TEST DECISIVO a livello PORT06 — SUPERATO ✅ + +Applicato l'Hurst-skip alle 6 fade dentro il PORT06 intero (equal-weight, le altre 11 sleeve +invariate): + +| Portafoglio | FULL Sharpe | FULL DD | OOS Sharpe | OOS DD | OOS ret | +|-------------|:--:|:--:|:--:|:--:|:--:| +| PORT06 baseline | 6,62 | 4,10% | 8,89 | 1,22% | +175% | +| **+ Hurst-skip h<0,55** | **6,76** | **2,39%** | **9,15** | 1,54% | +158% | +| + Hurst-skip h<0,50 | 6,61 | 2,08% | 9,02 | 1,54% | +150% | + +**A differenza di FR01 (che diluiva), il filtro Hurst MIGLIORA il PORT06**: FULL Sharpe ↑, **FULL +DD quasi dimezzato (4,10→2,39%)**, OOS Sharpe ↑ (8,89→9,15). Costo: OOS DD +0,3pp (resta minuscolo), +OOS ret −17pp. **h<0,55 è il pick** (0,50 taglia più ritorno). Non aumenta il profitto: è puro +**rischio** — dimezza il DD mantenendo/alzando lo Sharpe. + +## Implementazione +Aggiunto `hurst_skip_mask` in `src/strategies/fade_base.py` (rolling-Hurst causale dalle SOLE close) ++ parametro `hurst_max` (default None=off) in MR01/MR02/MR07. Test: `test_hurst_lossguard.py`. + +**Vantaggio operativo decisivo vs FR01:** l'Hurst si calcola **dalle sole close** → nessun feed +DVOL/regime live necessario. Lo `StrategyWorker` lo computa inline dai dati che già ha → **deployabile +senza nuova infrastruttura**, basta settare `hurst_max: 0.55` nei params degli sleeve fade. + +## Da fare per attivarlo live (deploy) +1. Settare `hurst_max: 0.55` nei params delle fade in `_defs.py` (sleeve live) + aggiornare i params + fade del backtest (`combine_portfolio`/`report_families`) per PARITÀ + rigenerare il + regression-lock PORT06 (i numeri canonici cambiano: DD 4,9→~2,4%). +2. Verificare che il rolling-Hurst live nel worker coincida col backtest (stessa finestra 100, + stesso stepping causale). +3. Rebuild immagine Docker (`up -d --build`, non restart) + verifica RESUME. + +Default attuale: `hurst_max` OFF → zero impatto su backtest/parità/live finché non lo si attiva +esplicitamente. Il SISTEMA è trovato e validato; l'attivazione è una decisione di deploy. diff --git a/scripts/analysis/fade_lossguard_port_test.py b/scripts/analysis/fade_lossguard_port_test.py new file mode 100644 index 0000000..2eca728 --- /dev/null +++ b/scripts/analysis/fade_lossguard_port_test.py @@ -0,0 +1,56 @@ +import sys; sys.path.insert(0,".") +import numpy as np, pandas as pd, importlib +from scripts.analysis.combine_portfolio import IDX, SPLIT, INIT, _norm, metrics, port_returns, build_trades +from src.portfolio.sleeves import all_sleeve_equities +from scripts.analysis.regime_lab import load_features + +def load_strat(mod): + m=importlib.import_module(mod) + return next(v() for k,v in vars(m).items() if isinstance(v,type) and hasattr(v,'generate_signals') and getattr(v,'__module__','')==m.__name__) +FADES={"MR01":("scripts.strategies.MR01_bollinger_fade",dict(bb_window=50,k=2.5,sl_atr=2.0,max_bars=24,trend_max=3.0)), + "MR02":("scripts.strategies.MR02_donchian_fade",dict(n=20,sl_atr=2.0,max_bars=24,trend_max=3.0)), + "MR07":("scripts.strategies.MR07_return_reversal",dict(n=50,k=3.5,tp_atr=2.0,sl_atr=1.5,max_bars=24,trend_max=3.0))} +FEE=0.001; LEV=3; POS=0.15 + +def fade_equity_filtered(code, asset, hurst_thr=None): + """equity giornaliera dello sleeve fade, opz. filtrata Hurst=thr). Convenzione fade_daily_equity.""" + mod,par=FADES[code]; s=load_strat(mod) + df=load_features(asset,"1h"); ts=pd.to_datetime(df['timestamp'],unit='ms',utc=True) + h=df['high'].values; l=df['low'].values; c=df['close'].values; hur=df['hurst'].values + eq=np.full(len(c),INIT,float); cap=INIT; last=-1 + for sg in s.generate_signals(df,ts,**par): + i=sg.idx + if i<=last: continue + if hurst_thr is not None and not np.isnan(hur[i]) and hur[i]>=hurst_thr: continue # FILTRO + d=sg.direction; tp=sg.metadata['tp']; sl=sg.metadata['sl']; mb=sg.metadata['max_bars'] + j=min(i+mb,len(c)-1); exit_p=c[j] + for t in range(i+1,j+1): + if d==1: + if l[t]<=sl: exit_p=sl;j=t;break + if h[t]>=tp: exit_p=tp;j=t;break + else: + if h[t]>=sl: exit_p=sl;j=t;break + if l[t]<=tp: exit_p=tp;j=t;break + ret=(exit_p-c[i])/c[i]*d*LEV-FEE*LEV + cap=max(cap+cap*POS*ret,10.0); eq[j:]=cap; last=j + sser=pd.Series(eq,index=ts).resample("1D").last().reindex(IDX).ffill().bfill() + return _norm(sser) + +base=all_sleeve_equities() +fade_ids=["MR01_BTC","MR02_BTC","MR07_BTC","MR01_ETH","MR02_ETH","MR07_ETH"] + +def port(members): + dr=port_returns(members); return metrics(dr), metrics(dr,lo=SPLIT) + +# baseline PORT06 +fB,oB=port(base) +print(f"PORT06 baseline (17 sleeve): FULL Sharpe {fB['sharpe']:.2f} DD {fB['dd']:.2f}% | OOS Sharpe {oB['sharpe']:.2f} DD {oB['dd']:.2f}% ret {oB['ret']:+.0f}%") + +# sostituisci le 6 fade con versione Hurst-skip +for thr in (0.55, 0.50): + filt=dict(base) + for fid in fade_ids: + code,asset=fid.split("_") + filt[fid]=fade_equity_filtered(code,asset,hurst_thr=thr) + fF,oF=port(filt) + print(f"PORT06 + Hurst-skip h<{thr} sulle fade: FULL Sharpe {fF['sharpe']:.2f} DD {fF['dd']:.2f}% | OOS Sharpe {oF['sharpe']:.2f} DD {oF['dd']:.2f}% ret {oF['ret']:+.0f}%") diff --git a/scripts/analysis/regime_fetcher.py b/scripts/analysis/regime_fetcher.py index 5268f7e..4fbdcdf 100644 --- a/scripts/analysis/regime_fetcher.py +++ b/scripts/analysis/regime_fetcher.py @@ -19,7 +19,7 @@ from pathlib import Path import pandas as pd ROOT = Path(__file__).resolve().parents[2] -RAW = ROOT / "data" / "raw" +RAW = ROOT / "data" / "regime" # NON data/raw (solo OHLCV) — evita pollution discovery asset BASE = "https://www.deribit.com/api/v2/public/" diff --git a/scripts/analysis/regime_lab.py b/scripts/analysis/regime_lab.py index e7de80d..ec48d1e 100644 --- a/scripts/analysis/regime_lab.py +++ b/scripts/analysis/regime_lab.py @@ -35,7 +35,10 @@ from src.fractal.indicators import ( # noqa: E402 rolling_hurst, fractal_dimension_higuchi, self_similarity_score, volatility_ratio, ) -RAW = ROOT / "data" / "raw" +# dati regime (DVOL/funding/feature) in data/regime/ — NON in data/raw/ (che e' solo OHLCV: i file +# estranei in data/raw inquinano la discovery asset del backtest). Vedi diary 2026-06-02-fade-lossguard. +RAW = ROOT / "data" / "regime" +RAW.mkdir(parents=True, exist_ok=True) # --------------------------------------------------------------------------- dati diff --git a/scripts/strategies/MR01_bollinger_fade.py b/scripts/strategies/MR01_bollinger_fade.py index fdf7451..489ca36 100644 --- a/scripts/strategies/MR01_bollinger_fade.py +++ b/scripts/strategies/MR01_bollinger_fade.py @@ -30,6 +30,7 @@ import pandas as pd from src.strategies.base import Strategy, Signal, BacktestResult, YearlyStats, TF_MINUTES from src.data.downloader import load_data +from src.strategies.fade_base import hurst_skip_mask def _atr(df: pd.DataFrame, n: int = 14) -> np.ndarray: @@ -62,17 +63,22 @@ class BollingerFade(Strategy): # Edge minimo: salta i segnali il cui TP (la media) è più vicino dell'entry del # costo round-trip -> perdenti garantiti anche colpendo il TP. 0 = off. min_tp_frac = params.get("min_tp_frac", 0.0) + # Loss-guard Hurst: salta in regime persistente/trending (hurst >= soglia). None = off. + hurst_max = params.get("hurst_max") 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 + skip = hurst_skip_mask(df, hurst_max, params.get("hurst_win", 100)) signals: list[Signal] = [] for i in range(bb_w + 14, n_len): if np.isnan(up[i]) or np.isnan(a[i]): continue + if skip[i]: + continue # loss-guard: regime persistente 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]: diff --git a/scripts/strategies/MR02_donchian_fade.py b/scripts/strategies/MR02_donchian_fade.py index 824174f..bbf4f50 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, trend_distance +from src.strategies.fade_base import FadeStrategy, atr, trend_distance, hurst_skip_mask class DonchianFade(FadeStrategy): @@ -44,17 +44,22 @@ class DonchianFade(FadeStrategy): 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) + # Loss-guard Hurst: salta in regime persistente/trending (hurst >= soglia). None = off. + hurst_max = params.get("hurst_max") 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 + skip = hurst_skip_mask(df, hurst_max, params.get("hurst_win", 100)) signals: list[Signal] = [] for i in range(n + 14, len(c)): if np.isnan(hh[i]) or np.isnan(a[i]): continue + if skip[i]: + continue # loss-guard: regime persistente if td is not None and (np.isnan(td[i]) or td[i] > trend_max): continue mid = (hh[i] + ll[i]) / 2.0 diff --git a/scripts/strategies/MR07_return_reversal.py b/scripts/strategies/MR07_return_reversal.py index 1ee5ab8..c96b5f4 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, trend_distance +from src.strategies.fade_base import FadeStrategy, atr, trend_distance, hurst_skip_mask class ReturnReversal(FadeStrategy): @@ -49,6 +49,8 @@ class ReturnReversal(FadeStrategy): ema_long = params.get("ema_long", 200) # Edge minimo: salta i fade il cui TP (ATR-scaled) è entro il costo RT. 0 = off. min_tp_frac = params.get("min_tp_frac", 0.0) + # Loss-guard Hurst: salta in regime persistente/trending (hurst >= soglia). None = off. + hurst_max = params.get("hurst_max") c = df["close"].values ret = np.zeros_like(c) @@ -56,11 +58,14 @@ class ReturnReversal(FadeStrategy): 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 + skip = hurst_skip_mask(df, hurst_max, params.get("hurst_win", 100)) 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 skip[i]: + continue # loss-guard: regime persistente if td is not None and (np.isnan(td[i]) or td[i] > trend_max): continue z = ret[i] / sig[i] diff --git a/src/strategies/fade_base.py b/src/strategies/fade_base.py index 3aa953e..f503e6a 100644 --- a/src/strategies/fade_base.py +++ b/src/strategies/fade_base.py @@ -15,6 +15,21 @@ import pandas as pd from src.strategies.base import Strategy, BacktestResult, YearlyStats, TF_MINUTES from src.data.downloader import load_data +from src.fractal.indicators import rolling_hurst + + +def hurst_skip_mask(df: pd.DataFrame, hurst_max: float | None, window: int = 100) -> np.ndarray: + """Loss-guard Hurst: maschera bool (True = SALTA il segnale) per regime PERSISTENTE/trending, + dove la rolling-Hurst >= hurst_max. Le fade concentrano stop-loss e perdite proprio li' + (diagnosi: stop-rate 43% per hurst>0.55 vs 21% anti-persistente). Filtrare hurst>=0.55 + DIMEZZA il DD del PORT06 (FULL 4.10%->2.39%) alzando lo Sharpe (validato 2026-06-02). + CAUSALE: rolling_hurst usa solo i rendimenti fino a close[i]. hurst_max=None -> nessuno skip. + Calcolata dalle SOLE close -> nessun feed dati esterno necessario (a differenza di DVOL).""" + n = len(df) + if hurst_max is None: + return np.zeros(n, dtype=bool) + h = rolling_hurst(df["close"].values.astype(float), window=window) + return h >= hurst_max def atr(df: pd.DataFrame, n: int = 14) -> np.ndarray: diff --git a/tests/portfolio/test_hurst_lossguard.py b/tests/portfolio/test_hurst_lossguard.py new file mode 100644 index 0000000..268a24f --- /dev/null +++ b/tests/portfolio/test_hurst_lossguard.py @@ -0,0 +1,51 @@ +"""Loss-guard Hurst: le fade saltano i segnali in regime persistente/trending (rolling-Hurst >= +soglia), dove si concentrano stop-loss e perdite. Validato 2026-06-02: filtrare hurst>=0.55 +DIMEZZA il DD del PORT06 alzando lo Sharpe. Filtro CAUSALE (close<=i), default off (None).""" +import numpy as np +import pandas as pd + +from src.strategies.fade_base import hurst_skip_mask + + +def _df(close): + n = len(close) + return pd.DataFrame({"timestamp": range(n), "open": close, "high": close, + "low": close, "close": close, "volume": [1.0] * n}) + + +def test_mask_off_when_none(): + df = _df(np.cumsum(np.random.default_rng(0).normal(size=400)) + 100) + m = hurst_skip_mask(df, None) + assert m.dtype == bool and not m.any() # None -> nessuno skip + + +def test_mask_flags_persistent_regime(): + # serie fortemente TRENDING (persistente, Hurst alto) -> deve essere mascherata (skip) molto + trend = np.linspace(100, 300, 600) + df = _df(trend) + m = hurst_skip_mask(df, hurst_max=0.55, window=100) + # dopo il warmup, una rampa pulita e' persistente -> gran parte mascherata + assert m[150:].mean() > 0.5 + + +def test_fade_strategy_filters_signals(): + """Una fade con hurst_max produce <= segnali del baseline, e tutti i superstiti sono in + regime non-persistente (la maschera e' False alla loro barra).""" + import importlib + rng = np.random.default_rng(1) + # serie mean-reverting (anti-persistente) con qualche estensione -> genera fade + n = 1200 + c = 100 + np.cumsum(rng.normal(scale=0.5, size=n)) + c = 100 + (c - c.mean()) * 0.3 # comprimi verso la media (mean-revert) + df = _df(c) + ts = pd.to_datetime(df["timestamp"], unit="s", utc=True) + m = importlib.import_module("scripts.strategies.MR01_bollinger_fade") + Strat = next(v for k, v in vars(m).items() + if isinstance(v, type) and getattr(v, "__module__", "") == m.__name__ + and hasattr(v, "generate_signals")) + s = Strat() + base = s.generate_signals(df, ts, bb_window=50, k=2.0, sl_atr=2.0) + filt = s.generate_signals(df, ts, bb_window=50, k=2.0, sl_atr=2.0, hurst_max=0.55) + assert len(filt) <= len(base) # il filtro non aggiunge mai segnali + skip = hurst_skip_mask(df, 0.55, 100) + assert all(not skip[sig.idx] for sig in filt) # nessun superstite in regime persistente