merge: strategy_free -> main (fade MR02/MR03/MR07 + filtro trend Acc/DD)
Integra il lavoro su branch strategy_free, indipendente dalle strategie oneste (DIP/TR/ROT/PORT) gia' su main: nessun file in comune, merge pulito. - 3 nuove fade mean-reversion validate OOS fee-aware: MR02 Donchian fade, MR03 Keltner fade, MR07 Return reversal (+ base condivisa fade_base). - Filtro trend (trend_max/ema_long) su tutte le fade: alza Acc e riduce DD (drastico su ETH), edge OOS confermato; modello portafoglio equipesato.
This commit is contained in:
@@ -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]:
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user