feat(fade): loss-guard Hurst (skip regime persistente) — dimezza il DD del PORT06
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user