Merge feat/fade-lossguard: loss-guard Hurst sulle fade (attivo live)
Sistema anti-perdite: skip fade in regime persistente (hurst>=0.55). Dimezza il DD del PORT06 (FULL 4.10%->2.39%, Sharpe 6.62->6.76, OOS 8.89->9.15). Solo l'Hurst supera il gate (ADX/vol-expansion falliscono). Attivo live sulle 6 fade. Diagnostici + workflow 11 agenti + diario. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,3 +23,6 @@ data/portfolios/
|
|||||||
# stato locale di tooling (non condiviso)
|
# stato locale di tooling (non condiviso)
|
||||||
.claude/
|
.claude/
|
||||||
.omc/
|
.omc/
|
||||||
|
|
||||||
|
# dati regime (DVOL/funding/feature cache, rigenerabili)
|
||||||
|
data/regime/
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import sys; sys.path.insert(0,".")
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
from scripts.analysis.regime_lab import load_features
|
||||||
|
from scripts.analysis.explore_lab import atr
|
||||||
|
import importlib
|
||||||
|
FEE=0.001; LEV=3
|
||||||
|
|
||||||
|
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__)
|
||||||
|
|
||||||
|
STR={"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))}
|
||||||
|
|
||||||
|
def replay(df, sigs):
|
||||||
|
h=df['high'].values; l=df['low'].values; c=df['close'].values
|
||||||
|
out=[]; last=-1
|
||||||
|
for s in sigs:
|
||||||
|
i=s.idx
|
||||||
|
if i<=last: continue
|
||||||
|
d=s.direction; tp=s.metadata['tp']; sl=s.metadata['sl']; mb=s.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
|
||||||
|
out.append((i,ret)); last=j
|
||||||
|
return out
|
||||||
|
|
||||||
|
# raccogli tutti i trade con il loro dvol_pct e hurst all'ingresso
|
||||||
|
rows=[]
|
||||||
|
for asset in ("BTC","ETH"):
|
||||||
|
df=load_features(asset,"1h"); ts=pd.to_datetime(df['timestamp'],unit='ms',utc=True)
|
||||||
|
for code,(mod,par) in STR.items():
|
||||||
|
s=load_strat(mod); sigs=s.generate_signals(df,ts,**par)
|
||||||
|
for i,ret in replay(df,sigs):
|
||||||
|
rows.append(dict(asset=asset,code=code,year=ts.iloc[i].year,ret=ret,
|
||||||
|
dvol_pct=df['dvol_pct'].iloc[i], hurst=df['hurst'].iloc[i],
|
||||||
|
dvol=df['dvol'].iloc[i]))
|
||||||
|
R=pd.DataFrame(rows).dropna(subset=['dvol_pct'])
|
||||||
|
print(f"trade totali (con DVOL, 2021+): {len(R)}")
|
||||||
|
|
||||||
|
print("\n=== PnL medio per trade per TERZILE DVOL (bassa/media/alta vol) ===")
|
||||||
|
R['dvbin']=pd.cut(R['dvol_pct'],[0,.33,.66,1.0],labels=['LOW-vol','MID','HIGH-vol'])
|
||||||
|
g=R.groupby('dvbin',observed=True)['ret']
|
||||||
|
print(f" {'regime':<10}{'n':>6}{'ret_medio%':>12}{'win%':>8}{'somma%':>10}")
|
||||||
|
for b in ['LOW-vol','MID','HIGH-vol']:
|
||||||
|
x=R[R.dvbin==b]['ret']
|
||||||
|
print(f" {b:<10}{len(x):>6}{x.mean()*100:>12.3f}{(x>0).mean()*100:>8.1f}{x.sum()*100:>10.0f}")
|
||||||
|
|
||||||
|
print("\n=== dentro LOW-vol: split per HURST (anti-persistente vs trending) ===")
|
||||||
|
LV=R[R.dvbin=='LOW-vol'].copy()
|
||||||
|
LV['hbin']=pd.cut(LV['hurst'],[0,.45,.55,1.0],labels=['hurst<.45 (anti-pers)','.45-.55','>.55 (trend)'])
|
||||||
|
for b in ['hurst<.45 (anti-pers)','.45-.55','>.55 (trend)']:
|
||||||
|
x=LV[LV.hbin==b]['ret']
|
||||||
|
if len(x): print(f" {b:<24}{len(x):>6} ret_medio {x.mean()*100:>+7.3f}% win {(x>0).mean()*100:>5.1f}% somma {x.sum()*100:>+6.0f}%")
|
||||||
|
|
||||||
|
print("\n=== per anno: PnL fade in LOW-vol vs resto ===")
|
||||||
|
for y in range(2021,2027):
|
||||||
|
lo=R[(R.year==y)&(R.dvbin=='LOW-vol')]['ret']; hi=R[(R.year==y)&(R.dvbin!='LOW-vol')]['ret']
|
||||||
|
print(f" {y}: LOW-vol somma {lo.sum()*100:>+6.0f}% (n{len(lo)}) | MID/HIGH somma {hi.sum()*100:>+6.0f}% (n{len(hi)})")
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import sys; sys.path.insert(0,".")
|
||||||
|
import numpy as np, pandas as pd
|
||||||
|
from scripts.analysis.regime_lab import load_features
|
||||||
|
import importlib
|
||||||
|
FEE=0.001; LEV=3
|
||||||
|
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__)
|
||||||
|
STR={"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))}
|
||||||
|
def replay(df,sigs):
|
||||||
|
h=df['high'].values;l=df['low'].values;c=df['close'].values;out=[];last=-1
|
||||||
|
for s in sigs:
|
||||||
|
i=s.idx
|
||||||
|
if i<=last: continue
|
||||||
|
d=s.direction;tp=s.metadata['tp'];sl=s.metadata['sl'];mb=s.metadata['max_bars'];j=min(i+mb,len(c)-1);exit_p=c[j];reason='time'
|
||||||
|
for t in range(i+1,j+1):
|
||||||
|
if d==1:
|
||||||
|
if l[t]<=sl: exit_p=sl;j=t;reason='sl';break
|
||||||
|
if h[t]>=tp: exit_p=tp;j=t;reason='tp';break
|
||||||
|
else:
|
||||||
|
if h[t]>=sl: exit_p=sl;j=t;reason='sl';break
|
||||||
|
if l[t]<=tp: exit_p=tp;j=t;reason='tp';break
|
||||||
|
out.append((i,(exit_p-c[i])/c[i]*d*LEV-FEE*LEV,reason));last=j
|
||||||
|
return out
|
||||||
|
rows=[]
|
||||||
|
for asset in ("BTC","ETH"):
|
||||||
|
df=load_features(asset,"1h");ts=pd.to_datetime(df['timestamp'],unit='ms',utc=True)
|
||||||
|
for code,(mod,par) in STR.items():
|
||||||
|
s=load_strat(mod)
|
||||||
|
for i,ret,reason in replay(df,s.generate_signals(df,ts,**par)):
|
||||||
|
rows.append(dict(ret=ret,reason=reason,dvol_pct=df['dvol_pct'].iloc[i],hurst=df['hurst'].iloc[i],
|
||||||
|
vratio=df['vratio'].iloc[i],higuchi=df['higuchi'].iloc[i]))
|
||||||
|
R=pd.DataFrame(rows).dropna(subset=['dvol_pct','hurst'])
|
||||||
|
L=R[R.ret<0] # solo i trade in perdita
|
||||||
|
print(f"trade {len(R)} | in perdita {len(L)} ({len(L)/len(R)*100:.0f}%) | somma perdite {L.ret.sum()*100:.0f}% | media perdita {L.ret.mean()*100:.2f}%")
|
||||||
|
print("\n=== somma PERDITE per regime (dove si concentra il danno) ===")
|
||||||
|
R['dvbin']=pd.cut(R.dvol_pct,[0,.33,.66,1],labels=['LOWvol','MID','HIGHvol'])
|
||||||
|
R['hbin']=pd.cut(R.hurst,[0,.45,.55,1],labels=['anti<.45','.45-.55','trend>.55'])
|
||||||
|
piv=R[R.ret<0].pivot_table(index='dvbin',columns='hbin',values='ret',aggfunc='sum',observed=True)*100
|
||||||
|
print((piv.round(0)).to_string())
|
||||||
|
print("\n (numeri = somma % delle perdite per cella; piu negativo = piu danno)")
|
||||||
|
print("\n=== quota di SL (stop) per regime ===")
|
||||||
|
slr=R.groupby(['dvbin','hbin'],observed=True).apply(lambda x:(x.reason=='sl').mean()*100, include_groups=False)
|
||||||
|
print(slr.round(0).to_string())
|
||||||
|
# worst tail
|
||||||
|
print(f"\n=== peggiori 1% trade: dove? ===")
|
||||||
|
W=R.nsmallest(max(10,len(R)//100),'ret')
|
||||||
|
print(f" worst {len(W)} trade: dvol_pct medio {W.dvol_pct.mean():.2f}, hurst medio {W.hurst.mean():.2f}, quota hurst>.55 {(W.hurst>.55).mean()*100:.0f}%, quota dvol<.33 {(W.dvol_pct<.33).mean()*100:.0f}%")
|
||||||
@@ -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 (skip 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}%")
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
export const meta = {
|
||||||
|
name: 'fade-lossguard',
|
||||||
|
description: 'Sistema anti-perdite per le fade in regime trending/low-vol: test meccanismi su MR01/02/07',
|
||||||
|
phases: [
|
||||||
|
{ title: 'Test', detail: 'agenti: ogni meccanismo di filtro applicato alle fade reali (BTC+ETH)' },
|
||||||
|
{ title: 'Synth', detail: 'classifica + miglior loss-guard, gate: riduce DD senza uccidere edge' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const API = `
|
||||||
|
=== Harness (gia pronto) ===
|
||||||
|
import sys; sys.path.insert(0,'.')
|
||||||
|
import numpy as np, pandas as pd, importlib
|
||||||
|
from scripts.analysis.regime_lab import load_features, report
|
||||||
|
from scripts.analysis.explore_lab import robust, atr
|
||||||
|
|
||||||
|
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))}
|
||||||
|
|
||||||
|
# colonne regime_lab (causali): dvol, dvol_pct, vrp, funding_z, dvol_chg, hurst, higuchi, vratio, frac_up/dn
|
||||||
|
# ADX (se ti serve) calcolalo causale da OHLC; efficiency-ratio Kaufman = |c[i]-c[i-n]| / sum|diff| su [i-n,i].
|
||||||
|
|
||||||
|
# PATTERN: genera i segnali fade, poi APPLICA IL TUO FILTRO scartando le entries in regime sfavorevole,
|
||||||
|
# confronta BASELINE vs FILTRATA su ogni fade x asset:
|
||||||
|
def entries_from(strat, df, par, keep=lambda df,i: True):
|
||||||
|
ts=pd.to_datetime(df['timestamp'],unit='ms',utc=True)
|
||||||
|
out=[]
|
||||||
|
for s in strat.generate_signals(df,ts,**par):
|
||||||
|
if keep(df, s.idx): # keep=False -> filtro scarta (loss-guard)
|
||||||
|
out.append({'i':s.idx,'d':s.direction,'tp':s.metadata['tp'],'sl':s.metadata['sl'],'max_bars':s.metadata['max_bars']})
|
||||||
|
return out
|
||||||
|
# per ogni (fade,asset): res_base=report(.., entries_from(..., keep=tutto)); res_filt=report(.., col tuo keep)
|
||||||
|
# confronta: Sharpe OOS, DD full/oos, ret, #trade (quanti scartati), e robust(). Aggrega sulle 6 combo.
|
||||||
|
`
|
||||||
|
|
||||||
|
const CONTEXT = `
|
||||||
|
PROBLEMA: le fade (MR01 Bollinger, MR02 Donchian, MR07 return-reversal) sono mean-reversion 1h con
|
||||||
|
filtro trend EMA200 (trend_max=3.0). DIAGNOSI EMPIRICA (3022 trade, 2021+): le PERDITE e gli STOP
|
||||||
|
si concentrano nel regime PERSISTENTE/TRENDING, NON nella bassa vol:
|
||||||
|
- somma perdite per cella (Hurst x DVOL): la cella peggiore e' hurst>0.55 (-2695% in low-vol,
|
||||||
|
dominante in ogni terzile vol). I peggiori 1% trade hanno hurst medio 0.61 (77% con hurst>0.55).
|
||||||
|
- tasso STOP-LOSS: 43% quando hurst>0.55 vs 21% quando hurst<0.45 (anti-persistente). 2x.
|
||||||
|
- net: le celle restano positive (i winner battono), quindi filtrare toglie anche winner -> il
|
||||||
|
loss-guard e' utile SOLO se riduce DD/coda SENZA uccidere l'edge netto.
|
||||||
|
RICERCA ESTERNA (confermata): (a) Hurst regime filter: MR solo H<0.45, in 0.45-0.55 ridurre size,
|
||||||
|
evitare H>0.55. (b) ADX: MR profit factor 1.62 con ADX<20 vs -0.74 con ADX>30 (switch di regime piu'
|
||||||
|
importante). (c) ATR/vol-EXPANSION ratio>1.5 disabilita MR -> ha prevenuto il 72% delle perdite
|
||||||
|
maggiori. (d) time-stop: se non rientra in ~15 barre e' un trend, esci.
|
||||||
|
|
||||||
|
OBIETTIVO: trovare il MIGLIOR meccanismo (o combo) che, applicato alle fade reali, RIDUCE DD/coda/
|
||||||
|
stop-rate MANTENENDO l'edge netto OOS. Metodologia: causale no-look-ahead (le colonne regime_lab
|
||||||
|
sono causali; i filtri usano solo dati <= i), netto fee (report() fa OOS+sweep). LEZIONE FR01: un
|
||||||
|
filtro che riduce le perdite ma anche i winner spesso NON migliora -> il gate vero e' DD giu' a
|
||||||
|
parita' (o quasi) di Sharpe/ret, idealmente Sharpe SU e DD GIU'.
|
||||||
|
|
||||||
|
` + API
|
||||||
|
|
||||||
|
const SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
meccanismo: { type: 'string' },
|
||||||
|
descrizione: { type: 'string' },
|
||||||
|
base_oos_sharpe: { type: 'number' }, filt_oos_sharpe: { type: 'number' },
|
||||||
|
base_dd_full: { type: 'number' }, filt_dd_full: { type: 'number' },
|
||||||
|
base_oos_ret: { type: 'number' }, filt_oos_ret: { type: 'number' },
|
||||||
|
trade_scartati_pct: { type: 'number' },
|
||||||
|
riduce_perdite: { type: 'boolean', description: 'riduce DD/coda/stop-rate' },
|
||||||
|
preserva_edge: { type: 'boolean', description: 'edge netto OOS preservato (Sharpe non crolla, robust resta)' },
|
||||||
|
buon_lossguard: { type: 'boolean', description: 'riduce perdite SENZA uccidere edge -> candidato' },
|
||||||
|
verdetto: { type: 'string', description: 'numeri base vs filtrato aggregati sulle 6 combo fade x asset' },
|
||||||
|
},
|
||||||
|
required: ['meccanismo', 'buon_lossguard', 'riduce_perdite', 'preserva_edge', 'verdetto'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const MECHS = [
|
||||||
|
['Hurst-skip H>0.55', 'scarta le fade quando rolling-hurst(window=100) >= 0.55 (regime persistente). Test anche soglia 0.50 e 0.60, riporta la migliore.'],
|
||||||
|
['Hurst-size transition', 'NON scartare ma RIDURRE: tieni tutte le entries ma pesa size 1.0 se hurst<0.45, 0.5 se 0.45-0.55, 0.25 se >0.55. (Per testare la riduzione size col report attuale: approssima scartando il 50%/75% delle entries nelle bin alte in modo deterministico per indice, oppure confronta solo le bin.)'],
|
||||||
|
['ADX-skip', 'calcola ADX(14) causale; scarta le fade quando ADX>25 (trend). Test soglie 20/25/30.'],
|
||||||
|
['vol-expansion vratio', 'scarta le fade quando vratio (vol breve/lunga, colonna regime_lab) > 1.5 (vol in espansione = breakout, non range). Test 1.3/1.5/1.8. (la ricerca dice -72% perdite maggiori)'],
|
||||||
|
['efficiency-ratio Kaufman', 'ER = |c[i]-c[i-n]|/sum(|diff|) su finestra n=20; scarta quando ER>0.5 (moto efficiente/trending). Test 0.4/0.5/0.6.'],
|
||||||
|
['time-stop piu corto', 'riduci max_bars da 24 a 12 o 15 (esci prima se non rientra = probabile trend). Confronta DD/edge.'],
|
||||||
|
['Hurst + vol-expansion combo', 'scarta se hurst>0.55 OPPURE vratio>1.5. Verifica se la combo riduce piu DD del singolo senza perdere piu edge.'],
|
||||||
|
['Hurst + ADX combo', 'scarta se hurst>0.55 E ADX>25 (doppia conferma di trend) -> piu selettivo, scarta meno winner.'],
|
||||||
|
['vol-target sizing', 'scala la size per 1/realized_vol (target vol costante): approssima tenendo solo le entries in vol moderata, riporta effetto su DD/coda.'],
|
||||||
|
['DVOL-rising skip', 'scarta le fade quando dvol_chg>0 forte (DVOL in salita = stress/espansione vol imminente). Test soglie su dvol_chg.'],
|
||||||
|
]
|
||||||
|
const ASSETS_NOTE = 'Applica a tutte e 3 le fade (MR01,MR02,MR07) su BTC E ETH (6 combo), aggrega base vs filtrato.'
|
||||||
|
|
||||||
|
phase('Test')
|
||||||
|
// ogni meccanismo = 1 agente che testa su tutte le 6 combo; piu' 4 agenti che esplorano combo/parametri fini
|
||||||
|
const tasks = MECHS.map(([nm, desc]) => () => agent(
|
||||||
|
CONTEXT + `\n\nMECCANISMO DA TESTARE: ${nm}\n${desc}\n\n${ASSETS_NOTE}\n` +
|
||||||
|
`Scrivi uno script in /tmp (cd /opt/docker/PythagorasGoal && uv run python /tmp/<file>.py), confronta ` +
|
||||||
|
`BASELINE (fade senza filtro) vs FILTRATA su ogni combo, AGGREGA (media o somma equity) e riporta. ` +
|
||||||
|
`Il filtro deve essere CAUSALE. Decidi buon_lossguard=true SOLO se riduce DD/coda/stop-rate MANTENENDO ` +
|
||||||
|
`l'edge netto OOS (Sharpe non crolla, ret OOS resta ampiamente positivo). Cita i numeri base vs filtrato.`,
|
||||||
|
{ label: `mech:${nm.slice(0, 18)}`, phase: 'Test', schema: SCHEMA }))
|
||||||
|
|
||||||
|
const results = (await parallel(tasks)).filter(Boolean)
|
||||||
|
|
||||||
|
phase('Synth')
|
||||||
|
const good = results.filter(r => r.buon_lossguard)
|
||||||
|
const synthesis = await agent(
|
||||||
|
CONTEXT +
|
||||||
|
`\n\nRisultati di ${results.length} meccanismi testati:\n${JSON.stringify(results, null, 1)}\n\n` +
|
||||||
|
`SINTESI FINALE (italiano) per il decisore:
|
||||||
|
1) Esiste un loss-guard che riduce le perdite/DD delle fade in regime trending SENZA uccidere l'edge?
|
||||||
|
2) Tabella: meccanismo | base vs filtrato (OOS Sharpe, DD, ret, %trade scartati) | buon_lossguard?
|
||||||
|
3) Il MIGLIORE (e l'eventuale combo) con i numeri. Quanto DD/coda si risparmia e a che costo di ret.
|
||||||
|
4) Coerenza con la ricerca esterna (Hurst<0.45 / ADX / vol-expansion / time-stop).
|
||||||
|
5) Raccomandazione: quale filtro applicare alle fade live, con che soglia, e il caveat (serve feed
|
||||||
|
DVOL/regime live? il filtro va validato a livello PORT06 = riduce il DD del portafoglio?).
|
||||||
|
Onesta: se nessuno migliora davvero (riduce solo ret), dillo. Cita NUMERI reali.`,
|
||||||
|
{ label: 'synth-lossguard', phase: 'Synth' })
|
||||||
|
|
||||||
|
return { results, good, synthesis }
|
||||||
@@ -19,7 +19,7 @@ from pathlib import Path
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
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/"
|
BASE = "https://www.deribit.com/api/v2/public/"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,10 @@ from src.fractal.indicators import ( # noqa: E402
|
|||||||
rolling_hurst, fractal_dimension_higuchi, self_similarity_score, volatility_ratio,
|
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
|
# --------------------------------------------------------------------------- dati
|
||||||
|
|||||||
@@ -18,8 +18,14 @@ UNIVERSE8 = ["ADA", "BNB", "BTC", "DOGE", "ETH", "LTC", "SOL", "XRP"]
|
|||||||
# MR02/MR07 lo ignorano (**params). Vedi docs/diary/2026-06-01-tp-min-edge.md.
|
# MR02/MR07 lo ignorano (**params). Vedi docs/diary/2026-06-01-tp-min-edge.md.
|
||||||
MIN_TP_FRAC = 0.0015
|
MIN_TP_FRAC = 0.0015
|
||||||
|
|
||||||
|
# Loss-guard Hurst (live): salta le fade in regime PERSISTENTE/trending (rolling-Hurst >= 0.55),
|
||||||
|
# dove si concentrano stop-loss e perdite (stop-rate 43% vs 21% anti-persistente). DIMEZZA il DD
|
||||||
|
# del PORT06 (FULL 4.10%->2.39%) alzando lo Sharpe. Calcolato dalle SOLE close (no feed esterno).
|
||||||
|
# Validato 2026-06-02, vedi docs/diary/2026-06-02-fade-lossguard.md.
|
||||||
|
HURST_MAX = 0.55
|
||||||
|
|
||||||
FADE = [SleeveSpec(kind="single", name=c, sid=f"{c}_{a}", asset=a, cluster=f"{a}-rev",
|
FADE = [SleeveSpec(kind="single", name=c, sid=f"{c}_{a}", asset=a, cluster=f"{a}-rev",
|
||||||
params={"min_tp_frac": MIN_TP_FRAC})
|
params={"min_tp_frac": MIN_TP_FRAC, "hurst_max": HURST_MAX})
|
||||||
for a in ("BTC", "ETH") for c in ("MR01", "MR02", "MR07")]
|
for a in ("BTC", "ETH") for c in ("MR01", "MR02", "MR07")]
|
||||||
HONEST = [
|
HONEST = [
|
||||||
# DIP01: single-asset 1h -> StrategyWorker (Strategy DIP01_dip_buy). TR01/ROT02: multi-asset.
|
# DIP01: single-asset 1h -> StrategyWorker (Strategy DIP01_dip_buy). TR01/ROT02: multi-asset.
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import pandas as pd
|
|||||||
|
|
||||||
from src.strategies.base import Strategy, Signal, BacktestResult, YearlyStats, TF_MINUTES
|
from src.strategies.base import Strategy, Signal, BacktestResult, YearlyStats, TF_MINUTES
|
||||||
from src.data.downloader import load_data
|
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:
|
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
|
# 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.
|
# costo round-trip -> perdenti garantiti anche colpendo il TP. 0 = off.
|
||||||
min_tp_frac = params.get("min_tp_frac", 0.0)
|
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
|
ma = pd.Series(c).rolling(bb_w).mean().values
|
||||||
sd = pd.Series(c).rolling(bb_w).std().values
|
sd = pd.Series(c).rolling(bb_w).std().values
|
||||||
a = _atr(df, 14)
|
a = _atr(df, 14)
|
||||||
up, lo = ma + k * sd, ma - k * sd
|
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
|
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] = []
|
signals: list[Signal] = []
|
||||||
for i in range(bb_w + 14, n_len):
|
for i in range(bb_w + 14, n_len):
|
||||||
if np.isnan(up[i]) or np.isnan(a[i]):
|
if np.isnan(up[i]) or np.isnan(a[i]):
|
||||||
continue
|
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):
|
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
|
continue
|
||||||
if c[i] < lo[i] and c[i - 1] >= lo[i - 1]:
|
if c[i] < lo[i] and c[i - 1] >= lo[i - 1]:
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import numpy as np
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from src.strategies.base import Signal
|
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):
|
class DonchianFade(FadeStrategy):
|
||||||
@@ -44,17 +44,22 @@ class DonchianFade(FadeStrategy):
|
|||||||
ema_long = params.get("ema_long", 200)
|
ema_long = params.get("ema_long", 200)
|
||||||
# Edge minimo: salta i fade il cui TP (midpoint canale) è entro il costo RT. 0 = off.
|
# 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)
|
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
|
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||||
hh = pd.Series(h).rolling(n).max().shift(1).values
|
hh = pd.Series(h).rolling(n).max().shift(1).values
|
||||||
ll = pd.Series(l).rolling(n).min().shift(1).values
|
ll = pd.Series(l).rolling(n).min().shift(1).values
|
||||||
a = atr(df, 14)
|
a = atr(df, 14)
|
||||||
td = trend_distance(df, ema_long) if trend_max is not None else None
|
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] = []
|
signals: list[Signal] = []
|
||||||
for i in range(n + 14, len(c)):
|
for i in range(n + 14, len(c)):
|
||||||
if np.isnan(hh[i]) or np.isnan(a[i]):
|
if np.isnan(hh[i]) or np.isnan(a[i]):
|
||||||
continue
|
continue
|
||||||
|
if skip[i]:
|
||||||
|
continue # loss-guard: regime persistente
|
||||||
if td is not None and (np.isnan(td[i]) or td[i] > trend_max):
|
if td is not None and (np.isnan(td[i]) or td[i] > trend_max):
|
||||||
continue
|
continue
|
||||||
mid = (hh[i] + ll[i]) / 2.0
|
mid = (hh[i] + ll[i]) / 2.0
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import numpy as np
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from src.strategies.base import Signal
|
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):
|
class ReturnReversal(FadeStrategy):
|
||||||
@@ -49,6 +49,8 @@ class ReturnReversal(FadeStrategy):
|
|||||||
ema_long = params.get("ema_long", 200)
|
ema_long = params.get("ema_long", 200)
|
||||||
# Edge minimo: salta i fade il cui TP (ATR-scaled) è entro il costo RT. 0 = off.
|
# 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)
|
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
|
c = df["close"].values
|
||||||
ret = np.zeros_like(c)
|
ret = np.zeros_like(c)
|
||||||
@@ -56,11 +58,14 @@ class ReturnReversal(FadeStrategy):
|
|||||||
sig = pd.Series(ret).rolling(n).std().values
|
sig = pd.Series(ret).rolling(n).std().values
|
||||||
a = atr(df, 14)
|
a = atr(df, 14)
|
||||||
td = trend_distance(df, ema_long) if trend_max is not None else None
|
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] = []
|
signals: list[Signal] = []
|
||||||
for i in range(n + 14, len(c)):
|
for i in range(n + 14, len(c)):
|
||||||
if np.isnan(sig[i]) or sig[i] == 0 or np.isnan(a[i]):
|
if np.isnan(sig[i]) or sig[i] == 0 or np.isnan(a[i]):
|
||||||
continue
|
continue
|
||||||
|
if skip[i]:
|
||||||
|
continue # loss-guard: regime persistente
|
||||||
if td is not None and (np.isnan(td[i]) or td[i] > trend_max):
|
if td is not None and (np.isnan(td[i]) or td[i] > trend_max):
|
||||||
continue
|
continue
|
||||||
z = ret[i] / sig[i]
|
z = ret[i] / sig[i]
|
||||||
|
|||||||
@@ -15,6 +15,25 @@ import pandas as pd
|
|||||||
|
|
||||||
from src.strategies.base import Strategy, BacktestResult, YearlyStats, TF_MINUTES
|
from src.strategies.base import Strategy, BacktestResult, YearlyStats, TF_MINUTES
|
||||||
from src.data.downloader import load_data
|
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,
|
||||||
|
step: int = 6) -> 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).
|
||||||
|
step=6: calcola l'Hurst ogni 6 barre (ffill) -> ~6x piu' veloce per il worker live su finestre
|
||||||
|
lunghe (440g/10560 barre), e coincide con la cache di validazione (frac_step=6). L'Hurst varia
|
||||||
|
lentamente -> differenza trascurabile vs step=1."""
|
||||||
|
n = len(df)
|
||||||
|
if hurst_max is None:
|
||||||
|
return np.zeros(n, dtype=bool)
|
||||||
|
h = rolling_hurst(df["close"].values.astype(float), window=window, step=step)
|
||||||
|
return h >= hurst_max
|
||||||
|
|
||||||
|
|
||||||
def atr(df: pd.DataFrame, n: int = 14) -> np.ndarray:
|
def atr(df: pd.DataFrame, n: int = 14) -> np.ndarray:
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user