ac6f3766b0
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>
132 lines
5.5 KiB
Python
132 lines
5.5 KiB
Python
"""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
|
|
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:
|
|
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,
|
|
)
|