feat(strategy4): MT01 squeeze+MTF 82.7% acc — batte SQ02, 6 strategie scartate
Nuova strategia MT01: squeeze 15m + momentum EMA 1h BTC 15m: 82.7% acc, 503 trades, DD 5.9%, 9/9 anni, worst 72% ETH 15m: 81.2% acc, 404 trades, DD 2.9%, 9/9 anni, worst 73% Strategie testate e scartate (waste W23-W28): IB01 inside bar (58.7%, no edge) DC01 donchian (48%, sotto random) SB01 retest (52%, no edge) MR01 mean reversion RSI (62.9%, DD 29%) VO01 volume spike (64.2%, DD 34%) HY01 squeeze+MR (64.6%, DD 14.5%) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
"""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 = 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")
|
||||
Reference in New Issue
Block a user