0e47956f7a
- src/strategies/base.py: Strategy ABC con Signal, BacktestResult, YearlyStats - src/strategies/indicators.py: keltner_ratio, detect_squeezes, ema, atr, rv, corr - scripts/strategies/: SQ01-SQ04 (squeeze puro/filtri), ML01 (squeeze+GBM) - scripts/waste/: W01-W22 script scartati + REF originali - scripts/analysis/: compare, best_yearly, final_report, paper_status - CLAUDE.md aggiornato con nuova struttura e tabella strategie Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
321 lines
12 KiB
Python
321 lines
12 KiB
Python
"""S2-12: Strategie SOLO su perpetual — dati 100% reali.
|
||
Niente opzioni, niente IV stimata. Solo prezzo OHLCV.
|
||
Mix di approcci diversi da quelli già testati su main.
|
||
|
||
1. Intraday range breakout con filtro volatilità
|
||
2. Daily open range breakout (prima ora di trading)
|
||
3. RSI divergence (prezzo fa nuovo min/max, RSI no)
|
||
4. Close-to-close momentum filtrato da volatilità regime
|
||
5. Multi-timeframe confirmation (15m signal + 1h trend)
|
||
|
||
Test per-anno, onesto, con fee reali perpetual (0.05% taker × 2 = 0.1% roundtrip).
|
||
"""
|
||
from __future__ import annotations
|
||
import sys
|
||
sys.path.insert(0, ".")
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
from src.data.downloader import load_data
|
||
|
||
FEE_RT = 0.002 # 0.1% taker roundtrip
|
||
INITIAL = 1000
|
||
LEVERAGE = 3
|
||
|
||
|
||
def rsi(close, period=14):
|
||
delta = np.diff(close)
|
||
gain = np.where(delta > 0, delta, 0)
|
||
loss = np.where(delta < 0, -delta, 0)
|
||
result = np.full(len(close), 50.0)
|
||
if len(gain) < period:
|
||
return result
|
||
ag = np.mean(gain[:period])
|
||
al = np.mean(loss[:period])
|
||
for i in range(period, len(delta)):
|
||
ag = (ag * (period - 1) + gain[i]) / period
|
||
al = (al * (period - 1) + loss[i]) / period
|
||
if al == 0:
|
||
result[i + 1] = 100
|
||
else:
|
||
result[i + 1] = 100 - 100 / (1 + ag / al)
|
||
return result
|
||
|
||
|
||
def ema(arr, period):
|
||
r = np.full(len(arr), np.nan)
|
||
k = 2 / (period + 1)
|
||
r[period - 1] = np.mean(arr[:period])
|
||
for i in range(period, len(arr)):
|
||
r[i] = arr[i] * k + r[i - 1] * (1 - k)
|
||
return r
|
||
|
||
|
||
def run_all_perpetual(asset):
|
||
print(f"\n{'#'*70}")
|
||
print(f" {asset} — STRATEGIE PERPETUAL (dati reali)")
|
||
print(f" Fee: {FEE_RT*100}% roundtrip, Leva: {LEVERAGE}x")
|
||
print(f"{'#'*70}")
|
||
|
||
df_1h = load_data(asset, "1h")
|
||
df_15m = load_data(asset, "15m")
|
||
c1h = df_1h["close"].values
|
||
h1h = df_1h["high"].values
|
||
l1h = df_1h["low"].values
|
||
v1h = df_1h["volume"].values
|
||
n1h = len(c1h)
|
||
ts1h = pd.to_datetime(df_1h["timestamp"], unit="ms", utc=True)
|
||
|
||
rsi_14 = rsi(c1h, 14)
|
||
ema_20 = ema(c1h, 20)
|
||
ema_50 = ema(c1h, 50)
|
||
|
||
results = {}
|
||
|
||
# ======================================================
|
||
# STRAT 1: Daily Open Range Breakout
|
||
# Prima ora (08-09 UTC) definisce il range. Breakout = entrata.
|
||
# ======================================================
|
||
for hold, stop_m in [(6, 1.0), (12, 1.5), (4, 0.8)]:
|
||
name = f"ORB_h{hold}_s{stop_m}"
|
||
capital = float(INITIAL)
|
||
yearly = {}
|
||
|
||
for i in range(50, n1h - hold):
|
||
if ts1h.iloc[i].hour != 9: # fine della prima ora
|
||
continue
|
||
day = ts1h.iloc[i].strftime("%Y-%m-%d")
|
||
if day in yearly and len(yearly[day]) >= 1:
|
||
continue
|
||
|
||
range_high = h1h[i - 1]
|
||
range_low = l1h[i - 1]
|
||
range_size = range_high - range_low
|
||
if range_size <= 0:
|
||
continue
|
||
|
||
# ATR per stop
|
||
atr_14 = np.mean(h1h[max(0,i-14):i] - l1h[max(0,i-14):i])
|
||
if atr_14 <= 0:
|
||
continue
|
||
|
||
# Breakout detection: la candela attuale rompe il range
|
||
if c1h[i] > range_high:
|
||
direction = "long"
|
||
elif c1h[i] < range_low:
|
||
direction = "short"
|
||
else:
|
||
continue
|
||
|
||
entry = c1h[i]
|
||
stop_dist = atr_14 * stop_m
|
||
exit_price = c1h[min(i + hold, n1h - 1)]
|
||
|
||
for j in range(i + 1, min(i + hold + 1, n1h)):
|
||
if direction == "long":
|
||
if l1h[j] <= entry - stop_dist:
|
||
exit_price = entry - stop_dist
|
||
break
|
||
if h1h[j] >= entry + stop_dist * 2:
|
||
exit_price = entry + stop_dist * 2
|
||
break
|
||
else:
|
||
if h1h[j] >= entry + stop_dist:
|
||
exit_price = entry + stop_dist
|
||
break
|
||
if l1h[j] <= entry - stop_dist * 2:
|
||
exit_price = entry - stop_dist * 2
|
||
break
|
||
exit_price = c1h[j]
|
||
|
||
trade_ret = ((exit_price - entry) / entry if direction == "long" else (entry - exit_price) / entry)
|
||
net = trade_ret * LEVERAGE - FEE_RT * LEVERAGE
|
||
capital += capital * 0.15 * net
|
||
capital = max(capital, 10)
|
||
|
||
year = ts1h.iloc[i].year
|
||
if year not in yearly:
|
||
yearly[year] = []
|
||
yearly[year].append(net > 0)
|
||
if day not in yearly:
|
||
yearly[day] = []
|
||
|
||
if sum(len(v) for v in yearly.values() if isinstance(v, list) and all(isinstance(x, bool) for x in v)) > 30:
|
||
all_wins = [w for v in yearly.values() if isinstance(v, list) for w in v if isinstance(w, bool)]
|
||
acc = sum(all_wins) / len(all_wins) * 100
|
||
ret = (capital - INITIAL) / INITIAL * 100
|
||
results[name] = {"acc": acc, "ret": ret, "trades": len(all_wins), "capital": capital}
|
||
|
||
# ======================================================
|
||
# STRAT 2: RSI Divergence
|
||
# Prezzo fa nuovo low, RSI no = bullish divergence → long
|
||
# ======================================================
|
||
for lookback, rsi_thr_low, rsi_thr_high, hold in [(20, 30, 70, 6), (14, 25, 75, 8), (10, 35, 65, 4)]:
|
||
name = f"RSIdiv_lb{lookback}_h{hold}"
|
||
capital = float(INITIAL)
|
||
trades_list = []
|
||
|
||
for i in range(max(50, lookback + 1), n1h - hold):
|
||
day = ts1h.iloc[i].strftime("%Y-%m-%d")
|
||
|
||
# Bullish divergence: price new low, RSI higher low
|
||
price_new_low = c1h[i] < np.min(c1h[i - lookback : i])
|
||
rsi_higher = rsi_14[i] > np.min(rsi_14[i - lookback : i]) and rsi_14[i] < rsi_thr_low
|
||
|
||
# Bearish divergence: price new high, RSI lower high
|
||
price_new_high = c1h[i] > np.max(c1h[i - lookback : i])
|
||
rsi_lower = rsi_14[i] < np.max(rsi_14[i - lookback : i]) and rsi_14[i] > rsi_thr_high
|
||
|
||
direction = None
|
||
if price_new_low and rsi_higher:
|
||
direction = "long"
|
||
elif price_new_high and rsi_lower:
|
||
direction = "short"
|
||
|
||
if direction is None:
|
||
continue
|
||
|
||
entry = c1h[i]
|
||
exit_price = c1h[min(i + hold, n1h - 1)]
|
||
trade_ret = ((exit_price - entry) / entry if direction == "long" else (entry - exit_price) / entry)
|
||
net = trade_ret * LEVERAGE - FEE_RT * LEVERAGE
|
||
capital += capital * 0.12 * net
|
||
capital = max(capital, 10)
|
||
trades_list.append({"year": ts1h.iloc[i].year, "win": trade_ret > 0})
|
||
|
||
if len(trades_list) > 30:
|
||
acc = sum(1 for t in trades_list if t["win"]) / len(trades_list) * 100
|
||
ret = (capital - INITIAL) / INITIAL * 100
|
||
results[name] = {"acc": acc, "ret": ret, "trades": len(trades_list), "capital": capital}
|
||
|
||
# ======================================================
|
||
# STRAT 3: Momentum regime — trend following solo in low-vol regime
|
||
# ======================================================
|
||
for fast, slow, vol_w, vol_thr, hold in [
|
||
(8, 21, 48, 0.8, 12),
|
||
(5, 13, 24, 0.8, 6),
|
||
(13, 34, 72, 0.7, 24),
|
||
(8, 21, 48, 0.9, 8),
|
||
]:
|
||
name = f"MomReg_f{fast}s{slow}_h{hold}"
|
||
ema_f = ema(c1h, fast)
|
||
ema_s = ema(c1h, slow)
|
||
|
||
rv_short = np.full(n1h, np.nan)
|
||
rv_long = np.full(n1h, np.nan)
|
||
lr = np.diff(np.log(np.where(c1h == 0, 1e-10, c1h)))
|
||
for idx in range(vol_w, len(lr)):
|
||
rv_short[idx + 1] = np.std(lr[idx - min(12, vol_w) : idx])
|
||
rv_long[idx + 1] = np.std(lr[idx - vol_w : idx])
|
||
|
||
capital = float(INITIAL)
|
||
trades_list = []
|
||
daily_done = set()
|
||
|
||
for i in range(max(60, slow + 1), n1h - hold):
|
||
if np.isnan(ema_f[i]) or np.isnan(ema_s[i]) or np.isnan(rv_short[i]) or np.isnan(rv_long[i]):
|
||
continue
|
||
if rv_long[i] <= 0:
|
||
continue
|
||
|
||
day = ts1h.iloc[i].strftime("%Y-%m-%d")
|
||
if day in daily_done:
|
||
continue
|
||
|
||
# Only trade in low-vol regime
|
||
vol_ratio = rv_short[i] / rv_long[i]
|
||
if vol_ratio > vol_thr:
|
||
continue
|
||
|
||
# EMA crossover signal
|
||
cross_up = ema_f[i] > ema_s[i] and ema_f[i - 1] <= ema_s[i - 1]
|
||
cross_down = ema_f[i] < ema_s[i] and ema_f[i - 1] >= ema_s[i - 1]
|
||
|
||
if not (cross_up or cross_down):
|
||
continue
|
||
|
||
direction = "long" if cross_up else "short"
|
||
entry = c1h[i]
|
||
exit_price = c1h[min(i + hold, n1h - 1)]
|
||
trade_ret = ((exit_price - entry) / entry if direction == "long" else (entry - exit_price) / entry)
|
||
net = trade_ret * LEVERAGE - FEE_RT * LEVERAGE
|
||
capital += capital * 0.15 * net
|
||
capital = max(capital, 10)
|
||
trades_list.append({"year": ts1h.iloc[i].year, "win": trade_ret > 0})
|
||
daily_done.add(day)
|
||
|
||
if len(trades_list) > 30:
|
||
acc = sum(1 for t in trades_list if t["win"]) / len(trades_list) * 100
|
||
ret = (capital - INITIAL) / INITIAL * 100
|
||
results[name] = {"acc": acc, "ret": ret, "trades": len(trades_list), "capital": capital}
|
||
|
||
# ======================================================
|
||
# STRAT 4: Multi-TF confirmation (15m entry, 1h trend)
|
||
# ======================================================
|
||
c15 = df_15m["close"].values
|
||
h15 = df_15m["high"].values
|
||
l15 = df_15m["low"].values
|
||
ts15 = df_15m["timestamp"].values
|
||
n15 = len(c15)
|
||
|
||
ema_1h_50 = ema(c1h, 50)
|
||
rsi_15m = rsi(c15, 14)
|
||
|
||
capital = float(INITIAL)
|
||
trades_list = []
|
||
daily_done = set()
|
||
|
||
for i in range(100, n15 - 12):
|
||
ts_dt = pd.Timestamp(ts15[i], unit="ms", tz="UTC")
|
||
day = ts_dt.strftime("%Y-%m-%d")
|
||
if day in daily_done:
|
||
continue
|
||
|
||
# 15m signal: RSI extreme
|
||
if rsi_15m[i] > 35 and rsi_15m[i] < 65:
|
||
continue
|
||
|
||
# Find matching 1h candle
|
||
h_idx = np.searchsorted(ts1h.values.astype("int64"), ts15[i]) - 1
|
||
if h_idx < 50 or h_idx >= n1h or np.isnan(ema_1h_50[h_idx]):
|
||
continue
|
||
|
||
# 1h trend confirmation
|
||
trend_up = c1h[h_idx] > ema_1h_50[h_idx]
|
||
trend_down = c1h[h_idx] < ema_1h_50[h_idx]
|
||
|
||
direction = None
|
||
if rsi_15m[i] < 30 and trend_up:
|
||
direction = "long" # oversold in uptrend
|
||
elif rsi_15m[i] > 70 and trend_down:
|
||
direction = "short" # overbought in downtrend
|
||
|
||
if direction is None:
|
||
continue
|
||
|
||
entry = c15[i]
|
||
hold_bars = 12 # 12 × 15m = 3h
|
||
exit_price = c15[min(i + hold_bars, n15 - 1)]
|
||
trade_ret = ((exit_price - entry) / entry if direction == "long" else (entry - exit_price) / entry)
|
||
net = trade_ret * LEVERAGE - FEE_RT * LEVERAGE
|
||
capital += capital * 0.12 * net
|
||
capital = max(capital, 10)
|
||
trades_list.append({"year": ts_dt.year, "win": trade_ret > 0})
|
||
daily_done.add(day)
|
||
|
||
if len(trades_list) > 30:
|
||
acc = sum(1 for t in trades_list if t["win"]) / len(trades_list) * 100
|
||
ret = (capital - INITIAL) / INITIAL * 100
|
||
results["MultiTF_15m1h"] = {"acc": acc, "ret": ret, "trades": len(trades_list), "capital": capital}
|
||
|
||
# === PRINT RESULTS ===
|
||
print(f"\n {'Strategy':<25s} {'Trades':>7s} {'Acc':>6s} {'Return':>10s} {'Capital':>10s}")
|
||
print(f" {'-'*60}")
|
||
for name, r in sorted(results.items(), key=lambda x: x[1]["acc"], reverse=True):
|
||
tag = "✅" if r["acc"] >= 60 and r["ret"] > 30 else ""
|
||
print(f" {name:<25s} {r['trades']:>7d} {r['acc']:>5.1f}% {r['ret']:>+9.1f}% €{r['capital']:>9,.0f} {tag}")
|
||
|
||
|
||
for asset in ["ETH", "BTC"]:
|
||
run_all_perpetual(asset)
|