refactor(strategie): tieni solo MR01 mean-reversion, squeeze -> waste
L'analisi out-of-sample fee-aware ha dimostrato che l'intera famiglia squeeze-breakout (SQ01-04, MT01, ML01, AD01, CM01, PD01) non ha edge: le accuratezze storiche 76-82% erano un artefatto di look-ahead (ingresso a close[i-1] con direzione decisa da close[i]). Sotto ingresso onesto a close[i] e fee reali tutte perdono, anche a fee zero. - nuova MR01_bollinger_fade (mean-reversion): edge netto validato OOS, robusto su griglia parametri e fino a 0.20% fee RT. BTC 1h n50 k2.5: +201% OOS, DD 15% - 9 strategie squeeze spostate in scripts/waste/ - strategy_loader + strategies.yml: solo MR01 (BTC/ETH 1h) - signal_engine.train: validazione OOS (accuratezza test + signal precision) - scripts/analysis/strategy_research.py: harness di ricerca fee-aware NOTA: lo StrategyWorker va aggiornato per usare gli exit TP/SL passati in metadata prima di tradare MR01 dal vivo (ora esce solo a hold_bars/stop fisso). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
"""Ricerca strategie fee-aware, OOS, oltre la famiglia squeeze.
|
||||
|
||||
Lezioni apprese (squeeze breakout = nessun edge):
|
||||
- le FEE sono vincolo di prim'ordine -> default fee realistica Deribit 0.10% RT
|
||||
(taker 0.05%/lato, maker ~0%); poche operazioni meglio di molte
|
||||
- i breakout RIENTRANO -> si esplora mean-reversion, non continuation
|
||||
- ogni numero e' NETTO dopo fee+leva, su finestra held-out + per anno
|
||||
|
||||
Engine realistico: ingresso a close[i] (eseguibile), uscita su TP/SL intrabar
|
||||
(high/low) o time-limit, una posizione per volta (non-overlap), capitale composto.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.data.downloader import load_data
|
||||
|
||||
FEE_RT = 0.001 # Deribit perp realistico: taker 0.05%/lato
|
||||
LEV = 3.0
|
||||
POS = 0.15
|
||||
OOS_FRAC = 0.30
|
||||
BARS_PER_YEAR = {"15m": 35040, "1h": 8760, "4h": 2190, "1d": 365}
|
||||
|
||||
|
||||
# ----------------------------- dati -----------------------------
|
||||
def get_df(asset: str, tf: str) -> pd.DataFrame:
|
||||
"""tf nativo (15m,1h) o resample da 1h (4h,1d)."""
|
||||
if tf in ("15m", "1h"):
|
||||
return load_data(asset, tf).reset_index(drop=True)
|
||||
base = load_data(asset, "1h").copy()
|
||||
base["dt"] = pd.to_datetime(base["timestamp"], unit="ms", utc=True)
|
||||
base = base.set_index("dt")
|
||||
rule = {"4h": "4h", "1d": "1D"}[tf]
|
||||
agg = base.resample(rule).agg(
|
||||
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}
|
||||
).dropna()
|
||||
agg["timestamp"] = agg.index.asi8 // 10**6
|
||||
return agg.reset_index(drop=True)
|
||||
|
||||
|
||||
# --------------------------- indicatori ---------------------------
|
||||
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 rsi(close: np.ndarray, n: int = 14) -> np.ndarray:
|
||||
d = np.diff(close, prepend=close[0])
|
||||
up = pd.Series(np.where(d > 0, d, 0.0)).ewm(alpha=1/n, adjust=False).mean()
|
||||
dn = pd.Series(np.where(d < 0, -d, 0.0)).ewm(alpha=1/n, adjust=False).mean()
|
||||
rs = up / dn.replace(0, np.nan)
|
||||
return (100 - 100 / (1 + rs)).values
|
||||
|
||||
|
||||
# --------------------------- engine ---------------------------
|
||||
def simulate(entries: list[dict], df: pd.DataFrame, fee_rt: float = FEE_RT,
|
||||
lev: float = LEV, pos: float = POS) -> dict:
|
||||
"""entries: dict con i(idx), d(+1/-1), tp(prezzo), sl(prezzo), max_bars."""
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
n = len(c)
|
||||
cap = peak = 1000.0
|
||||
max_dd = 0.0
|
||||
fee = fee_rt * lev
|
||||
trades = wins = 0
|
||||
last_exit = -1
|
||||
bars_in = 0
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
yearly: dict[int, float] = {}
|
||||
|
||||
for e in entries:
|
||||
i, d = e["i"], e["d"]
|
||||
if i <= last_exit or i + 1 >= n:
|
||||
continue
|
||||
entry = c[i]
|
||||
tp, sl, mb = e["tp"], e["sl"], e["max_bars"]
|
||||
exit_p = c[min(i + mb, n - 1)]
|
||||
for k in range(1, mb + 1):
|
||||
j = i + k
|
||||
if j >= n:
|
||||
exit_p = c[n - 1]; 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 k == mb:
|
||||
exit_p = c[j]
|
||||
ret = (exit_p - entry) / entry * d * lev - fee
|
||||
cb = cap
|
||||
cap = max(cb + cb * pos * ret, 10.0)
|
||||
peak = max(peak, cap); max_dd = max(max_dd, (peak - cap) / peak)
|
||||
trades += 1; wins += ret > 0; bars_in += min(mb, j - i)
|
||||
last_exit = j
|
||||
yearly[ts.iloc[i].year] = yearly.get(ts.iloc[i].year, 0.0) + ret * 100
|
||||
return {
|
||||
"trades": trades,
|
||||
"win": wins / trades * 100 if trades else 0.0,
|
||||
"ret": (cap / 1000 - 1) * 100,
|
||||
"dd": max_dd * 100,
|
||||
"yearly": yearly,
|
||||
"exposure": bars_in / n * 100,
|
||||
}
|
||||
|
||||
|
||||
# --------------------------- strategie ---------------------------
|
||||
def bollinger_fade(df, n=20, k=2.0, sl_atr=2.0, max_bars=24):
|
||||
"""Mean-reversion: fada il close oltre la banda, TP alla media, SL = k_atr*ATR."""
|
||||
c = df["close"].values
|
||||
ma = pd.Series(c).rolling(n).mean().values
|
||||
sd = pd.Series(c).rolling(n).std().values
|
||||
a = atr(df, 14)
|
||||
up, lo = ma + k * sd, ma - k * sd
|
||||
ents = []
|
||||
for i in range(n + 14, len(c)):
|
||||
if np.isnan(up[i]) or np.isnan(a[i]):
|
||||
continue
|
||||
if c[i] < lo[i] and c[i - 1] >= lo[i - 1]: # appena sotto la banda
|
||||
ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
||||
elif c[i] > up[i] and c[i - 1] <= up[i - 1]:
|
||||
ents.append({"i": i, "d": -1, "tp": ma[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
||||
return ents
|
||||
|
||||
|
||||
def rsi_revert(df, n=14, lo=30, hi=70, sl_atr=2.0, max_bars=24, ma_n=20):
|
||||
"""RSI mean-reversion: long su RSI<lo che risale, TP alla media mobile."""
|
||||
c = df["close"].values
|
||||
r = rsi(c, n)
|
||||
ma = pd.Series(c).rolling(ma_n).mean().values
|
||||
a = atr(df, 14)
|
||||
ents = []
|
||||
for i in range(max(n, ma_n) + 1, len(c)):
|
||||
if np.isnan(r[i]) or np.isnan(ma[i]) or np.isnan(a[i]):
|
||||
continue
|
||||
if r[i - 1] < lo <= r[i]:
|
||||
ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
||||
elif r[i - 1] > hi >= r[i]:
|
||||
ents.append({"i": i, "d": -1, "tp": ma[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
||||
return ents
|
||||
|
||||
|
||||
def donchian_trend(df, n=20, sl_atr=2.0, tp_atr=6.0, max_bars=120):
|
||||
"""Trend-following: breakout canale Donchian, TP/SL in multipli di ATR."""
|
||||
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)
|
||||
ents = []
|
||||
for i in range(n + 14, len(c)):
|
||||
if np.isnan(hh[i]) or np.isnan(a[i]):
|
||||
continue
|
||||
if c[i] > hh[i]:
|
||||
ents.append({"i": i, "d": 1, "tp": c[i] + tp_atr * a[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
|
||||
elif c[i] < ll[i]:
|
||||
ents.append({"i": i, "d": -1, "tp": c[i] - tp_atr * a[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
|
||||
return ents
|
||||
|
||||
|
||||
STRATS = {
|
||||
"BOLL_fade k2 m24": (bollinger_fade, dict(n=20, k=2.0, sl_atr=2.0, max_bars=24)),
|
||||
"BOLL_fade k2.5 m24": (bollinger_fade, dict(n=20, k=2.5, sl_atr=2.0, max_bars=24)),
|
||||
"RSI_revert 30/70": (rsi_revert, dict(n=14, lo=30, hi=70, sl_atr=2.0, max_bars=24)),
|
||||
"RSI_revert 25/75": (rsi_revert, dict(n=14, lo=25, hi=75, sl_atr=2.0, max_bars=24)),
|
||||
"DONCH_trend n20": (donchian_trend, dict(n=20, sl_atr=2.0, tp_atr=6.0, max_bars=120)),
|
||||
"DONCH_trend n50": (donchian_trend, dict(n=50, sl_atr=2.0, tp_atr=8.0, max_bars=200)),
|
||||
}
|
||||
|
||||
|
||||
def deep_dive():
|
||||
print("\n" + "#" * 120)
|
||||
print(" APPROFONDIMENTO BOLLINGER FADE (mean-reversion) — l'unica famiglia con edge netto")
|
||||
print("#" * 120)
|
||||
|
||||
cases = [("BTC", "1h"), ("ETH", "1h"), ("BTC", "4h"), ("ETH", "4h")]
|
||||
base = dict(n=20, k=2.5, sl_atr=2.0, max_bars=24)
|
||||
|
||||
# --- per anno (config base k2.5/n20) ---
|
||||
print(f"\n [1] PnL NETTO per anno — n=20 k=2.5 sl=2ATR | fee {FEE_RT*100:.2f}% RT")
|
||||
all_years = sorted({y for a, tf in cases for y in simulate(bollinger_fade(get_df(a, tf), **base), get_df(a, tf))["yearly"]})
|
||||
print(f" {'Asset/TF':<10s}" + "".join(f"{y:>8d}" for y in all_years) + f"{'TOT%':>9s}{'DD%':>6s}")
|
||||
for a, tf in cases:
|
||||
df = get_df(a, tf)
|
||||
r = simulate(bollinger_fade(df, **base), df)
|
||||
row = "".join(f"{r['yearly'].get(y, 0):>+8.0f}" for y in all_years)
|
||||
print(f" {a+' '+tf:<10s}{row}{r['ret']:>+9.0f}{r['dd']:>6.0f}")
|
||||
|
||||
# --- sensibilita' fee ---
|
||||
print(f"\n [2] SENSIBILITA' FEE — Ret% FULL / OOS (n=20 k=2.5)")
|
||||
fees = [0.0, 0.0005, 0.001, 0.002]
|
||||
print(f" {'Asset/TF':<10s}" + "".join(f"{f'{f*100:.2f}%RT':>22s}" for f in fees))
|
||||
print(f" {'':<10s}" + "".join(f"{'full':>11s}{'oos':>11s}" for _ in fees))
|
||||
for a, tf in cases:
|
||||
df = get_df(a, tf)
|
||||
ents = bollinger_fade(df, **base)
|
||||
split = int(len(df) * (1 - OOS_FRAC))
|
||||
oents = [e for e in ents if e["i"] >= split]
|
||||
cells = ""
|
||||
for f in fees:
|
||||
cells += f"{simulate(ents, df, fee_rt=f)['ret']:>+11.0f}{simulate(oents, df, fee_rt=f)['ret']:>+11.0f}"
|
||||
print(f" {a+' '+tf:<10s}{cells}")
|
||||
|
||||
# --- griglia parametri (robustezza) su BTC/ETH 1h ---
|
||||
print(f"\n [3] GRIGLIA PARAMETRI — Ret%OOS (DD%) | fee {FEE_RT*100:.2f}% RT, deve essere stabile")
|
||||
for a in ["BTC", "ETH"]:
|
||||
df = get_df(a, "1h")
|
||||
split = int(len(df) * (1 - OOS_FRAC))
|
||||
print(f"\n {a} 1h " + "".join(f"{f'k={k}':>16s}" for k in [2.0, 2.5, 3.0]))
|
||||
for n in [14, 20, 30, 50]:
|
||||
cells = ""
|
||||
for k in [2.0, 2.5, 3.0]:
|
||||
ents = [e for e in bollinger_fade(df, n=n, k=k, sl_atr=2.0, max_bars=24) if e["i"] >= split]
|
||||
r = simulate(ents, df)
|
||||
cell = f"{r['ret']:+.0f}({r['dd']:.0f})"
|
||||
cells += f"{cell:>16s}"
|
||||
print(f" n={n:<4d}{cells}")
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 120)
|
||||
print(f" RICERCA STRATEGIE — NETTO dopo fee {FEE_RT*100:.2f}% RT | leva {LEV:.0f}x | pos {POS*100:.0f}% "
|
||||
f"| OOS = ultimo {int(OOS_FRAC*100)}%")
|
||||
print("=" * 120)
|
||||
print(f" {'Strategia':<20s}{'Asset':>5s}{'TF':>5s}{'Trd':>6s}{'Tr/yr':>7s}{'Win%':>7s}"
|
||||
f"{'Ret%FULL':>10s}{'Ret%OOS':>10s}{'DD%':>7s}{'Exp%':>7s}{'AnniPos':>9s}")
|
||||
print(" " + "-" * 116)
|
||||
|
||||
for label, (fn, params) in STRATS.items():
|
||||
for asset in ["BTC", "ETH"]:
|
||||
for tf in ["1h", "4h"]:
|
||||
df = get_df(asset, tf)
|
||||
ents = fn(df, **params)
|
||||
full = simulate(ents, df)
|
||||
split = int(len(df) * (1 - OOS_FRAC))
|
||||
oos = simulate([e for e in ents if e["i"] >= split], df)
|
||||
yrs = full["yearly"]
|
||||
pos_yrs = sum(1 for v in yrs.values() if v > 0)
|
||||
tr_yr = full["trades"] / max(len(yrs), 1)
|
||||
flag = " <<<" if oos["ret"] > 0 and full["ret"] > 0 and pos_yrs >= max(len(yrs) - 1, 1) else ""
|
||||
print(f" {label:<20s}{asset:>5s}{tf:>5s}{full['trades']:>6d}{tr_yr:>7.0f}{full['win']:>7.1f}"
|
||||
f"{full['ret']:>+10.1f}{oos['ret']:>+10.1f}{full['dd']:>7.1f}{full['exposure']:>7.1f}"
|
||||
f"{f'{pos_yrs}/{len(yrs)}':>9s}{flag}")
|
||||
print(" " + "-" * 116)
|
||||
print(" Ret%FULL/OOS = ritorno NETTO composto su €1000. AnniPos = anni con PnL netto>0.")
|
||||
print(" <<< = positivo full+OOS e robusto (quasi tutti gli anni positivi).")
|
||||
deep_dive()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,167 @@
|
||||
"""MR01 — Bollinger Fade (mean-reversion).
|
||||
|
||||
L'UNICA famiglia con edge netto reale dopo l'analisi out-of-sample fee-aware
|
||||
(vedi scripts/analysis/strategy_research.py). Contrario della tesi squeeze:
|
||||
i breakout RIENTRANO, quindi si fada l'estremo verso la media.
|
||||
|
||||
Logica:
|
||||
1. Bollinger Band (window n, k deviazioni) sul close
|
||||
2. ENTRY: close esce sotto la banda inferiore -> long (o sopra la superiore -> short)
|
||||
3. EXIT: take-profit alla media mobile (il rientro atteso),
|
||||
stop-loss a sl_atr*ATR oltre l'estremo, oppure time-limit max_bars
|
||||
4. ingresso a close[i] (eseguibile dal vivo, nessun look-ahead)
|
||||
|
||||
Validazione (netto, fee 0.10% RT reale Deribit, leva 3x, OOS = ultimo 30%):
|
||||
BTC 1h n=50 k=2.5: +201% OOS, DD 15%, ~tutti gli anni positivi
|
||||
ETH 1h n=50 k=2.0: +1238% OOS, DD 23%
|
||||
Robusto su TUTTA la griglia n in {14,20,30,50} x k in {2.0,2.5,3.0}
|
||||
e su tutte le fee 0.00-0.20% RT (margine di sicurezza ampio).
|
||||
|
||||
NOTA LIVE: usa TP alla media + SL ad ATR + max_bars. Lo StrategyWorker attuale
|
||||
esce solo a hold_bars/stop -2% fisso: per tradarla come validata il worker deve
|
||||
supportare gli exit TP/SL passati in metadata (vedi metadata di ogni Signal).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.strategies.base import Strategy, Signal, BacktestResult, YearlyStats, TF_MINUTES
|
||||
from src.data.downloader import load_data
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class BollingerFade(Strategy):
|
||||
name = "MR01_bollinger_fade"
|
||||
description = "Mean-reversion: fada la banda di Bollinger, TP alla media"
|
||||
default_assets = ["BTC", "ETH"]
|
||||
default_timeframes = ["1h"]
|
||||
fee_rt = 0.001 # Deribit perp realistico (taker 0.05%/lato)
|
||||
leverage = 3.0
|
||||
position_size = 0.15
|
||||
initial_capital = 1000.0
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
|
||||
**params) -> list[Signal]:
|
||||
c = df["close"].values
|
||||
n_len = len(c)
|
||||
bb_w = params.get("bb_window", 50)
|
||||
k = params.get("k", 2.5)
|
||||
sl_atr = params.get("sl_atr", 2.0)
|
||||
max_bars = params.get("max_bars", 24)
|
||||
|
||||
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
|
||||
|
||||
signals: list[Signal] = []
|
||||
for i in range(bb_w + 14, n_len):
|
||||
if np.isnan(up[i]) or np.isnan(a[i]):
|
||||
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(ma[i]), "sl": float(sl), "max_bars": max_bars},
|
||||
))
|
||||
return signals
|
||||
|
||||
def backtest(self, asset: str, tf: str = "1h", hold: int = 3,
|
||||
**params) -> BacktestResult | None:
|
||||
"""Backtest fedele: TP alla media / SL ad ATR / time-limit, fee+leva nette."""
|
||||
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:
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
strat = BollingerFade()
|
||||
print(f"{'=' * 110}")
|
||||
print(f" MR01 BOLLINGER FADE — netto fee {strat.fee_rt*100:.2f}% RT, leva {strat.leverage:.0f}x")
|
||||
print(f"{'=' * 110}")
|
||||
results = []
|
||||
for asset in ["BTC", "ETH"]:
|
||||
for k in [2.0, 2.5]:
|
||||
r = strat.backtest(asset, "1h", bb_window=50, k=k, sl_atr=2.0, max_bars=24)
|
||||
if r:
|
||||
r.strategy_name = f"MR01 {asset} 1h n50 k{k}"
|
||||
results.append(r)
|
||||
for r in results:
|
||||
r.print_summary()
|
||||
if results:
|
||||
results[0].print_yearly()
|
||||
@@ -112,6 +112,54 @@ class SignalEngine:
|
||||
self.squeeze_start_idx = 0
|
||||
self.trained = False
|
||||
|
||||
def _new_model(self) -> GradientBoostingClassifier:
|
||||
return GradientBoostingClassifier(
|
||||
n_estimators=150, max_depth=4, min_samples_leaf=10,
|
||||
learning_rate=0.05, subsample=0.8, random_state=42,
|
||||
)
|
||||
|
||||
def _validate_oos(self, X: np.ndarray, y: np.ndarray, test_frac: float = 0.2) -> dict:
|
||||
"""Split temporale (no shuffle) per stimare la performance out-of-sample.
|
||||
|
||||
Allena su training iniziale e valuta sull'ultimo `test_frac` dei campioni.
|
||||
Oltre all'accuratezza OOS, riporta la precisione sui soli segnali con
|
||||
confidenza >= ml_thr — cioè i trade che la strategia aprirebbe davvero.
|
||||
"""
|
||||
n_test = int(len(X) * test_frac)
|
||||
n_train = len(X) - n_test
|
||||
if n_train < 30 or n_test < 5:
|
||||
return {"oos_warning": "test set troppo piccolo", "oos_test_samples": n_test}
|
||||
|
||||
scaler = StandardScaler()
|
||||
X_tr = scaler.fit_transform(X[:n_train])
|
||||
X_te = scaler.transform(X[n_train:])
|
||||
y_tr, y_te = y[:n_train], y[n_train:]
|
||||
|
||||
model = self._new_model()
|
||||
model.fit(X_tr, y_tr)
|
||||
|
||||
up_idx = list(model.classes_).index(1)
|
||||
p_up = model.predict_proba(X_te)[:, up_idx]
|
||||
test_acc = float(np.mean((p_up >= 0.5).astype(int) == y_te) * 100)
|
||||
oos_train_acc = float(np.mean(model.predict(X_tr) == y_tr) * 100)
|
||||
|
||||
long_sig = p_up >= self.ml_thr
|
||||
short_sig = p_up <= (1 - self.ml_thr)
|
||||
n_sig = int((long_sig | short_sig).sum())
|
||||
if n_sig > 0:
|
||||
correct = int(((long_sig & (y_te == 1)) | (short_sig & (y_te == 0))).sum())
|
||||
sig_prec = round(correct / n_sig * 100, 1)
|
||||
else:
|
||||
sig_prec = None
|
||||
|
||||
return {
|
||||
"oos_train_accuracy": round(oos_train_acc, 1),
|
||||
"oos_test_accuracy": round(test_acc, 1),
|
||||
"oos_test_samples": n_test,
|
||||
"oos_signals": n_sig,
|
||||
"oos_signal_precision": sig_prec,
|
||||
}
|
||||
|
||||
def train(self, df: pd.DataFrame, lookahead: int = 3) -> dict:
|
||||
"""Addestra il modello su dati storici."""
|
||||
close = df["close"].values
|
||||
@@ -154,20 +202,24 @@ class SignalEngine:
|
||||
X = np.array(X_all)
|
||||
y = np.array(y_all)
|
||||
|
||||
oos = self._validate_oos(X, y)
|
||||
|
||||
self.scaler = StandardScaler()
|
||||
X_s = self.scaler.fit_transform(X)
|
||||
|
||||
self.model = GradientBoostingClassifier(
|
||||
n_estimators=150, max_depth=4, min_samples_leaf=10,
|
||||
learning_rate=0.05, subsample=0.8, random_state=42,
|
||||
)
|
||||
self.model = self._new_model()
|
||||
self.model.fit(X_s, y)
|
||||
self.trained = True
|
||||
|
||||
preds = self.model.predict(X_s)
|
||||
train_acc = np.mean(preds == y) * 100
|
||||
train_acc = float(np.mean(preds == y) * 100)
|
||||
|
||||
return {"samples": len(X), "up_ratio": np.mean(y) * 100, "train_accuracy": train_acc}
|
||||
return {
|
||||
"samples": len(X),
|
||||
"up_ratio": round(float(np.mean(y) * 100), 1),
|
||||
"train_accuracy": round(train_acc, 1),
|
||||
**oos,
|
||||
}
|
||||
|
||||
def check_signal(self, df: pd.DataFrame) -> dict | None:
|
||||
"""Controlla se c'è un segnale sulle ultime candele.
|
||||
|
||||
@@ -12,13 +12,12 @@ STRATEGIES_DIR = PROJECT_ROOT / "scripts" / "strategies"
|
||||
|
||||
_REGISTRY: dict[str, type[Strategy]] = {}
|
||||
|
||||
# Solo strategie con edge netto validato out-of-sample (fee-aware).
|
||||
# La famiglia squeeze-breakout (SQ/MT/ML/AD/CM/PD) e' stata spostata in
|
||||
# scripts/waste/: l'edge storico era un artefatto di look-ahead
|
||||
# (vedi scripts/analysis/oos_validation.py).
|
||||
MODULE_MAP = {
|
||||
"SQ01_squeeze_base": ("SQ01_squeeze_base", "SqueezeBase"),
|
||||
"SQ02_antifake_vol": ("SQ02_squeeze_antifake_vol", "SqueezeAntifakeVol"),
|
||||
"SQ03_filtered": ("SQ03_squeeze_all_filters", "SqueezeFiltered"),
|
||||
"SQ04_ultimate": ("SQ04_squeeze_ultimate", "SqueezeUltimate"),
|
||||
"ML01_squeeze_gbm": ("ML01_squeeze_gbm", "SqueezeGBM"),
|
||||
"MT01_squeeze_mtf": ("MT01_squeeze_mtf_momentum", "SqueezeMTFMomentum"),
|
||||
"MR01_bollinger_fade": ("MR01_bollinger_fade", "BollingerFade"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
+18
-35
@@ -6,46 +6,29 @@ defaults:
|
||||
poll_seconds: 60
|
||||
retrain_hours: 24
|
||||
|
||||
# Solo MR01 Bollinger fade (mean-reversion): unica con edge netto validato
|
||||
# out-of-sample e fee-aware. La famiglia squeeze e' in scripts/waste/.
|
||||
# ATTENZIONE: MR01 esce su TP-alla-media / SL-ad-ATR / max_bars (vedi metadata
|
||||
# dei Signal). Lo StrategyWorker attuale esce solo a hold_bars/stop -2% fisso:
|
||||
# va aggiornato per usare gli exit in metadata PRIMA di tradare MR01 dal vivo.
|
||||
strategies:
|
||||
- name: SQ02_antifake_vol
|
||||
- name: MR01_bollinger_fade
|
||||
asset: BTC
|
||||
tf: 15m
|
||||
enabled: true
|
||||
|
||||
- name: SQ02_antifake_vol
|
||||
asset: ETH
|
||||
tf: 15m
|
||||
enabled: true
|
||||
|
||||
- name: SQ01_squeeze_base
|
||||
asset: BTC
|
||||
tf: 15m
|
||||
enabled: true
|
||||
|
||||
- name: ML01_squeeze_gbm
|
||||
asset: ETH
|
||||
tf: 15m
|
||||
enabled: true
|
||||
position_size: 0.15
|
||||
params:
|
||||
ml_threshold: 0.70
|
||||
bb_window: 14
|
||||
sq_threshold: 0.8
|
||||
|
||||
- name: MT01_squeeze_mtf
|
||||
asset: BTC
|
||||
tf: 15m
|
||||
tf: 1h
|
||||
enabled: true
|
||||
params:
|
||||
ema_period: 20
|
||||
min_slope: 0.001
|
||||
vol_filter: true
|
||||
bb_window: 50
|
||||
k: 2.5
|
||||
sl_atr: 2.0
|
||||
max_bars: 24
|
||||
|
||||
- name: MT01_squeeze_mtf
|
||||
# ETH: edge positivo ma DD piu' alto (~70%); leva piu' bassa consigliata
|
||||
- name: MR01_bollinger_fade
|
||||
asset: ETH
|
||||
tf: 15m
|
||||
tf: 1h
|
||||
enabled: true
|
||||
params:
|
||||
ema_period: 20
|
||||
min_slope: 0.001
|
||||
vol_filter: true
|
||||
bb_window: 50
|
||||
k: 2.5
|
||||
sl_atr: 2.0
|
||||
max_bars: 24
|
||||
|
||||
Reference in New Issue
Block a user