9879b46688
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>
262 lines
9.3 KiB
Python
262 lines
9.3 KiB
Python
"""MT01 — Squeeze + Multi-Timeframe Momentum.
|
|
|
|
Problema SQ02: entra al breakout 15m ma non sa se il trend 1h è allineato.
|
|
Soluzione: squeeze su 15m + conferma momentum su 1h.
|
|
|
|
Anti-overfitting: usa solo 2 indicatori (squeeze + EMA slope),
|
|
nessun parametro complesso.
|
|
|
|
IN:
|
|
- OHLCV 15m + 1h per lo stesso asset
|
|
- Parametri: sq_threshold, ema_period_1h, min_slope
|
|
|
|
OUT:
|
|
- Signal al breakout 15m confermato da trend 1h
|
|
- BacktestResult
|
|
|
|
Logica:
|
|
1. Squeeze release su 15m (come SQ01)
|
|
2. Antifakeout filter (come SQ02)
|
|
3. Check 1h: EMA slope positiva per long, negativa per short
|
|
4. Check 1h: prezzo sopra/sotto EMA per conferma trend
|
|
5. Entra solo se 15m e 1h concordano
|
|
"""
|
|
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.strategies.indicators import keltner_ratio, detect_squeezes, ema
|
|
from src.data.downloader import load_data
|
|
|
|
|
|
class SqueezeMTFMomentum(Strategy):
|
|
name = "MT01_squeeze_mtf"
|
|
description = "Squeeze 15m + momentum trend 1h — multi-timeframe"
|
|
default_assets = ["BTC", "ETH"]
|
|
default_timeframes = ["15m"]
|
|
fee_rt = 0.002
|
|
|
|
def generate_signals(self, df, ts, **params):
|
|
"""Genera segnali squeeze 15m confermati da trend 1h."""
|
|
c = df["close"].values
|
|
h = df["high"].values
|
|
l = df["low"].values
|
|
v = df["volume"].values
|
|
n = len(c)
|
|
|
|
asset = params.get("asset", "BTC")
|
|
sq_thr = params.get("sq_threshold", 0.8)
|
|
ema_period = params.get("ema_period", 50)
|
|
min_slope_val = params.get("min_slope", 0.001)
|
|
use_antifake = params.get("antifake", True)
|
|
use_vol = params.get("vol_filter", False)
|
|
|
|
kcr = keltner_ratio(c, h, l, 14)
|
|
events = detect_squeezes(c, h, l, kcr, sq_thr)
|
|
|
|
df_1h = params.get("df_1h")
|
|
if df_1h is None:
|
|
df_1h = load_data(asset, "1h")
|
|
c1h = df_1h["close"].values
|
|
ts1h_ms = df_1h["timestamp"].values
|
|
n1h = len(c1h)
|
|
ema_1h = ema(c1h, ema_period)
|
|
ema_slope_arr = np.full(n1h, np.nan)
|
|
for i in range(5, n1h):
|
|
if not np.isnan(ema_1h[i]) and not np.isnan(ema_1h[i-5]) and ema_1h[i-5] > 0:
|
|
ema_slope_arr[i] = (ema_1h[i] - ema_1h[i-5]) / ema_1h[i-5]
|
|
|
|
ts_ms = df["timestamp"].values
|
|
signals = []
|
|
|
|
for ev in events:
|
|
i = ev["idx"]
|
|
if i < 1 or i >= n:
|
|
continue
|
|
first_ret = (c[i] - c[i-1]) / c[i-1] if c[i-1] > 0 else 0
|
|
if abs(first_ret) < 0.001:
|
|
continue
|
|
if use_antifake:
|
|
br = h[i] - l[i]
|
|
if br > 0:
|
|
if c[i] > c[i-1] and (h[i] - c[i]) / br > 0.6:
|
|
continue
|
|
elif c[i] <= c[i-1] and (c[i] - l[i]) / br > 0.6:
|
|
continue
|
|
if use_vol:
|
|
avg_v = np.mean(v[ev["sq_start"]:i])
|
|
if avg_v > 0 and v[i] <= avg_v * 1.3:
|
|
continue
|
|
|
|
direction = 1 if first_ret > 0 else -1
|
|
i1h = np.searchsorted(ts1h_ms, ts_ms[i]) - 1
|
|
if i1h < ema_period or i1h >= n1h:
|
|
continue
|
|
if np.isnan(ema_1h[i1h]) or np.isnan(ema_slope_arr[i1h]):
|
|
continue
|
|
if direction == 1:
|
|
if c1h[i1h] < ema_1h[i1h] or ema_slope_arr[i1h] < min_slope_val:
|
|
continue
|
|
else:
|
|
if c1h[i1h] > ema_1h[i1h] or ema_slope_arr[i1h] > -min_slope_val:
|
|
continue
|
|
|
|
signals.append(Signal(idx=i, direction=direction, entry_price=c[i-1]))
|
|
|
|
return signals
|
|
|
|
def backtest(self, asset, tf="15m", hold=3, **params):
|
|
sq_thr = params.get("sq_threshold", 0.8)
|
|
ema_period = params.get("ema_period", 50)
|
|
min_slope = params.get("min_slope", 0.001)
|
|
use_antifake = params.get("antifake", True)
|
|
use_vol = params.get("vol_filter", False)
|
|
|
|
# Carica 15m e 1h
|
|
df_15m = load_data(asset, "15m")
|
|
df_1h = load_data(asset, "1h")
|
|
|
|
c15 = df_15m["close"].values
|
|
h15 = df_15m["high"].values
|
|
l15 = df_15m["low"].values
|
|
v15 = df_15m["volume"].values
|
|
n15 = len(c15)
|
|
ts15 = pd.to_datetime(df_15m["timestamp"], unit="ms", utc=True)
|
|
ts15_ms = df_15m["timestamp"].values
|
|
|
|
c1h = df_1h["close"].values
|
|
ts1h_ms = df_1h["timestamp"].values
|
|
n1h = len(c1h)
|
|
|
|
kcr = keltner_ratio(c15, h15, l15, 14)
|
|
events = detect_squeezes(c15, h15, l15, kcr, sq_thr)
|
|
|
|
# EMA su 1h
|
|
ema_1h = ema(c1h, ema_period)
|
|
|
|
# EMA slope (variazione percentuale su 5 barre)
|
|
ema_slope = np.full(n1h, np.nan)
|
|
for i in range(5, n1h):
|
|
if not np.isnan(ema_1h[i]) and not np.isnan(ema_1h[i - 5]) and ema_1h[i - 5] > 0:
|
|
ema_slope[i] = (ema_1h[i] - ema_1h[i - 5]) / ema_1h[i - 5]
|
|
|
|
yearly = {}
|
|
capital = float(self.initial_capital)
|
|
peak = capital
|
|
max_dd = 0.0
|
|
total_bars = 0
|
|
|
|
for ev in events:
|
|
i = ev["idx"]
|
|
if i + hold + 1 >= n15 or i < 1:
|
|
continue
|
|
|
|
first_ret = (c15[i] - c15[i - 1]) / c15[i - 1] if c15[i - 1] > 0 else 0
|
|
if abs(first_ret) < 0.001:
|
|
continue
|
|
|
|
# Antifake
|
|
if use_antifake:
|
|
br = h15[i] - l15[i]
|
|
if br > 0:
|
|
if c15[i] > c15[i - 1] and (h15[i] - c15[i]) / br > 0.6:
|
|
continue
|
|
elif c15[i] <= c15[i - 1] and (c15[i] - l15[i]) / br > 0.6:
|
|
continue
|
|
|
|
# Volume filter
|
|
if use_vol:
|
|
avg_v = np.mean(v15[ev["sq_start"]:i])
|
|
if avg_v > 0 and v15[i] <= avg_v * 1.3:
|
|
continue
|
|
|
|
direction = 1 if first_ret > 0 else -1
|
|
|
|
# Trova indice 1h corrispondente
|
|
i1h = np.searchsorted(ts1h_ms, ts15_ms[i]) - 1
|
|
if i1h < ema_period or i1h >= n1h or np.isnan(ema_1h[i1h]) or np.isnan(ema_slope[i1h]):
|
|
continue
|
|
|
|
# Conferma trend 1h
|
|
if direction == 1:
|
|
if c1h[i1h] < ema_1h[i1h]:
|
|
continue
|
|
if ema_slope[i1h] < min_slope:
|
|
continue
|
|
else:
|
|
if c1h[i1h] > ema_1h[i1h]:
|
|
continue
|
|
if ema_slope[i1h] > -min_slope:
|
|
continue
|
|
|
|
entry = c15[i - 1]
|
|
exit_price = c15[min(i + hold - 1, n15 - 1)]
|
|
actual = (exit_price - entry) / entry * direction
|
|
net = actual * self.leverage - self.fee_rt * self.leverage
|
|
|
|
capital += capital * self.position_size * net
|
|
capital = max(capital, 10)
|
|
if capital > peak: peak = capital
|
|
dd = (peak - capital) / peak
|
|
max_dd = max(max_dd, dd)
|
|
total_bars += hold
|
|
|
|
year = ts15.iloc[i].year
|
|
if year not in yearly:
|
|
yearly[year] = {"w": 0, "t": 0, "pnl": 0.0}
|
|
yearly[year]["t"] += 1
|
|
if actual > 0: yearly[year]["w"] += 1
|
|
yearly[year]["pnl"] += net * self.initial_capital
|
|
|
|
all_t = sum(d["t"] for d in yearly.values())
|
|
all_w = sum(d["w"] for d in yearly.values())
|
|
if all_t == 0:
|
|
return None
|
|
|
|
yearly_stats = [YearlyStats(y, d["t"], d["w"], d["pnl"]) for y, d in sorted(yearly.items())]
|
|
return BacktestResult(
|
|
strategy_name=self.name, asset=asset, timeframe="15m", params=params,
|
|
trades=all_t, wins=all_w, pnl=sum(d["pnl"] for d in yearly.values()),
|
|
capital=capital, initial_capital=self.initial_capital,
|
|
max_dd=max_dd * 100, time_in_market_pct=total_bars / n15 * 100,
|
|
avg_trade_duration_h=hold * 15 / 60, years_active=len(yearly), yearly=yearly_stats,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
strategy = SqueezeMTFMomentum()
|
|
|
|
configs = [
|
|
("ema50 sl0.1%", {"ema_period": 50, "min_slope": 0.001}),
|
|
("ema50 sl0.05%", {"ema_period": 50, "min_slope": 0.0005}),
|
|
("ema50 sl0.2%", {"ema_period": 50, "min_slope": 0.002}),
|
|
("ema20 sl0.1%", {"ema_period": 20, "min_slope": 0.001}),
|
|
("ema50 sl0.1%+vol", {"ema_period": 50, "min_slope": 0.001, "vol_filter": True}),
|
|
("ema20 sl0.1%+vol", {"ema_period": 20, "min_slope": 0.001, "vol_filter": True}),
|
|
("ema50 noAF", {"ema_period": 50, "min_slope": 0.001, "antifake": False}),
|
|
("ema100 sl0.05%", {"ema_period": 100, "min_slope": 0.0005}),
|
|
]
|
|
|
|
all_results = []
|
|
for label, params in configs:
|
|
for asset in ["BTC", "ETH"]:
|
|
for hold in [3, 6]:
|
|
r = strategy.backtest(asset, "15m", hold=hold, **params)
|
|
if r and r.trades >= 30:
|
|
r.strategy_name = f"MT01 {label} h={hold}"
|
|
all_results.append(r)
|
|
|
|
all_results.sort(key=lambda r: r.accuracy, reverse=True)
|
|
print(f"\n{'=' * 130}")
|
|
print(f" MT01 SQUEEZE + MTF MOMENTUM — TOP 20")
|
|
print(f"{'=' * 130}")
|
|
for r in all_results[:20]:
|
|
r.print_summary()
|
|
if all_results:
|
|
all_results[0].print_yearly()
|
|
|
|
print(f"\n BENCHMARK SQ02: 79.7% acc, 1250t, DD 6.5%, 9 anni, €5.23/day")
|