chore(reset): v2.0.0 — storico certificato Deribit mainnet, ripartenza pulita

Reset del progetto su fondamenta verificate dopo la scoperta che l'intera
libreria "validata OOS" era artefatto di feed contaminato (print fantasma del
feed Cerbero TESTNET + storico Binance/USDT).

- Storico ricostruito da Deribit MAINNET (ccxt pubblico, tokenless) e
  CERTIFICATO (certify_feed.py): BTC/ETH puliti su TUTTA la storia
  (mediana 2-6 bps vs Coinbase USD), integrita' OHLC + coerenza resample
  (maxΔ 0.00) + cross-venue OK. Alt esclusi (illiquidi/divergenti: LTC/DOGE
  50-82% barre flat; XRP/BNB non certificabili).
- Verdetto sul feed pulito: FADE / PAIRS / XS01 / TSM01 morti (ogni
  portafoglio Sharpe -2.3..-3.0, DD ~40%); solo SH01 e frammenti HONEST
  con segnale residuo, da ri-validare in isolamento.
- Cleanup "restart pulito": strategie, stack live (src/live, src/portfolio,
  runner/executor, yml, docker), ~100 script ricerca/gate, waste/games/
  portfolios, dati non certificati + cache e 60+ diari -> archiviati in Old/
  (preservati, non cancellati). Diario consolidato in un unico documento.
- Skeleton ricerca tenuto: Strategy ABC + indicatori + src/fractal +
  src/backtest/engine + load_data; tool dati certificati (rebuild_history,
  certify_feed, audit_feed, multi_source_check).
- Universo dati ATTIVO: solo BTC/ETH (5m/15m/1h); guardrail fisico
  (load_data su alt -> FileNotFoundError). Esecuzione DISABILITATA, conto flat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-19 15:16:03 +00:00
parent 8401a280b9
commit 14522262e6
383 changed files with 1971 additions and 779 deletions
+205
View File
@@ -0,0 +1,205 @@
"""AD01 — Adaptive Squeeze Threshold.
Problema SQ02: sq_threshold fisso (0.8) non si adatta al regime di volatilità.
Soluzione: threshold adattivo basato su volatilità recente.
Logica:
- Calcola volatilità rolling (std dei rendimenti su finestra 100 barre)
- Confronta con percentile storico (rolling 500 barre)
- Alta vol (>70° percentile) → soglia BASSA (0.65) — squeeze più "lenti"
- Bassa vol (<30° percentile) → soglia ALTA (0.90) — squeeze "stretti"
- Vol media → soglia standard (0.80)
Razionale: in mercati calmi, il BB si stringe molto → sq_threshold alto cattura
segnali migliori. In mercati volatili, bastano squeeze minori per essere significativi.
Anti-overfitting: solo 3 parametri (low_thr, mid_thr, high_thr), logica deterministica.
Eredita antifakeout + volume da SQ02.
"""
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, ema
from src.data.downloader import load_data
def _adaptive_sq_threshold(close: np.ndarray,
vol_window: int = 100,
regime_window: int = 500,
low_thr: float = 0.65,
mid_thr: float = 0.80,
high_thr: float = 0.90) -> np.ndarray:
"""Calcola sq_threshold adattivo per ogni barra."""
n = len(close)
lr = np.diff(np.log(np.where(close <= 0, 1e-10, close)))
vol = np.full(n, np.nan)
for i in range(vol_window, n):
vol[i] = np.std(lr[i - vol_window:i])
# Percentile rolling della volatilità
thresh = np.full(n, mid_thr)
for i in range(regime_window, n):
if np.isnan(vol[i]):
continue
hist = vol[i - regime_window:i]
hist = hist[~np.isnan(hist)]
if len(hist) < 10:
continue
p30 = np.percentile(hist, 30)
p70 = np.percentile(hist, 70)
if vol[i] < p30:
thresh[i] = high_thr # vol bassa → soglia alta
elif vol[i] > p70:
thresh[i] = low_thr # vol alta → soglia bassa
else:
thresh[i] = mid_thr
return thresh
def _detect_adaptive_squeezes(close, high, low, kcr, adaptive_thr,
min_dur: int = 5) -> list[dict]:
"""Squeeze con threshold adattivo per ogni barra."""
events = []
in_sq = False
sq_start = 0
for i in range(1, len(close)):
if np.isnan(kcr[i]) or np.isnan(adaptive_thr[i]):
continue
thr = adaptive_thr[i]
is_sq = kcr[i] < thr
if is_sq and not in_sq:
in_sq = True
sq_start = i
elif not is_sq and in_sq:
in_sq = False
dur = i - sq_start
if dur < min_dur:
continue
events.append({
"idx": i, "dur": dur, "sq_start": sq_start,
"kcr_at_release": kcr[i],
"thr_used": adaptive_thr[i],
})
return events
class AdaptiveSqueeze(Strategy):
name = "AD01_adaptive_squeeze"
description = "Squeeze con threshold adattivo a regime volatilità"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
fee_rt = 0.002
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
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
bb_w = params.get("bb_window", 14)
low_thr = params.get("low_thr", 0.65)
mid_thr = params.get("mid_thr", 0.80)
high_thr = params.get("high_thr", 0.90)
retrace_limit = params.get("retrace_limit", 0.6)
vol_mult = params.get("vol_multiplier", 1.3)
use_vol = params.get("use_vol", True)
vol_window = params.get("vol_window", 100)
regime_window = params.get("regime_window", 500)
kcr = keltner_ratio(c, h, l, bb_w)
adaptive_thr = _adaptive_sq_threshold(
c, vol_window, regime_window, low_thr, mid_thr, high_thr
)
events = _detect_adaptive_squeezes(c, h, l, kcr, adaptive_thr)
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
direction = 1 if first_ret > 0 else -1
# Anti-fakeout
br = h[i] - l[i]
if br > 0:
if direction == 1 and (h[i] - c[i]) / br > retrace_limit:
continue
elif direction == -1 and (c[i] - l[i]) / br > retrace_limit:
continue
# Volume confirm
if use_vol:
sq_start = ev["sq_start"]
avg_sq_v = np.mean(v[sq_start:i])
if avg_sq_v > 0 and v[i] <= avg_sq_v * vol_mult:
continue
signals.append(Signal(
idx=i,
direction=direction,
entry_price=c[i - 1],
metadata={
"dur": ev["dur"],
"thr_used": ev.get("thr_used", mid_thr),
},
))
return signals
if __name__ == "__main__":
strategy = AdaptiveSqueeze()
configs = [
# low_thr, mid_thr, high_thr, use_vol
{"low_thr": 0.65, "mid_thr": 0.80, "high_thr": 0.90, "use_vol": True},
{"low_thr": 0.65, "mid_thr": 0.80, "high_thr": 0.90, "use_vol": False},
{"low_thr": 0.60, "mid_thr": 0.78, "high_thr": 0.92, "use_vol": True},
{"low_thr": 0.70, "mid_thr": 0.82, "high_thr": 0.90, "use_vol": True},
{"low_thr": 0.65, "mid_thr": 0.80, "high_thr": 0.95, "use_vol": True},
{"low_thr": 0.65, "mid_thr": 0.80, "high_thr": 0.90,
"use_vol": True, "vol_multiplier": 1.2},
]
all_results = []
for cfg in configs:
for asset in ["BTC", "ETH"]:
for tf in ["15m", "1h"]:
for hold in [3, 6]:
r = strategy.backtest(asset, tf, hold=hold, **cfg)
if r and r.trades >= 20:
lbl = (f"AD01 lt={cfg['low_thr']} ht={cfg['high_thr']} "
f"v={cfg['use_vol']} h={hold}")
r.strategy_name = lbl
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 130}")
print(" AD01 ADAPTIVE SQUEEZE THRESHOLD — TOP 20")
print(f"{'=' * 130}")
print(f" {'Nome':<50s} {'A/T':>7s} {'Trades':>6s} {'Acc':>6s} "
f"{'PnL€':>10s} {'DD%':>6s} {'€/day':>7s} "
f"{'Mkt%':>5s} {'Dur':>5s} {'Anni':>4s}")
print(f" {'' * 120}")
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%, €5.23/day, 9 anni")
print(f" BENCHMARK MT01: 82.7% acc, 503t, DD 5.9%")
@@ -0,0 +1,183 @@
"""CM01 — Cross-Market Momentum Filter.
Squeeze su asset primario, entra SOLO se l'altro asset (BTC↔ETH)
mostra momentum short-term nella STESSA direzione.
Differenza da MT01: MT01 usa EMA slope su 1h (trend lento).
CM01 usa rendimento grezzo degli ultimi 3-6 bar sull'asset cross
(momentum veloce, stesso timeframe).
Razionale: BTC e ETH sono altamente correlati ma non perfettamente.
Se BTC fa squeeze breakout UP e anche ETH sta salendo (momentum 3-6 bar),
la probabilità di continuazione è maggiore perché c'è consenso di mercato.
Anti-overfitting: 1 parametro chiave (cross_bars 3-6), logica deterministica.
Eredita antifakeout + volume da SQ02.
"""
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
from src.strategies.indicators import keltner_ratio, detect_squeezes
from src.data.downloader import load_data
class CrossMarketMomentum(Strategy):
name = "CM01_cross_momentum"
description = "Squeeze + cross-asset short-term momentum filter"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
fee_rt = 0.002
leverage = 3.0
position_size = 0.15
initial_capital = 1000.0
# Map asset → cross asset
_CROSS = {"BTC": "ETH", "ETH": "BTC"}
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
**params) -> list[Signal]:
"""Genera segnali con cross-market momentum."""
c = df["close"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
ts_ms = df["timestamp"].values
asset = params.get("asset", "BTC")
tf = params.get("tf", "15m")
bb_w = params.get("bb_window", 14)
sq_thr = params.get("sq_threshold", 0.8)
retrace_limit = params.get("retrace_limit", 0.6)
vol_mult = params.get("vol_multiplier", 1.3)
use_vol = params.get("use_vol", True)
cross_bars = params.get("cross_bars", 4) # barre momentum cross
mom_min = params.get("mom_min", 0.0) # momentum minimo (0 = solo direzione)
# Carica cross asset
cross_asset = self._CROSS.get(asset)
if cross_asset is None:
return []
try:
df_cross = load_data(cross_asset, tf)
except Exception:
return []
c_cross = df_cross["close"].values
ts_cross_ms = df_cross["timestamp"].values
n_cross = len(c_cross)
# Momentum cross: rendimento log su cross_bars barre
cross_mom = np.full(n_cross, np.nan)
for i in range(cross_bars, n_cross):
if c_cross[i - cross_bars] > 0:
cross_mom[i] = np.log(c_cross[i] / c_cross[i - cross_bars])
kcr = keltner_ratio(c, h, l, bb_w)
events = detect_squeezes(c, h, l, kcr, sq_thr)
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
direction = 1 if first_ret > 0 else -1
# Anti-fakeout
br = h[i] - l[i]
if br > 0:
if direction == 1 and (h[i] - c[i]) / br > retrace_limit:
continue
elif direction == -1 and (c[i] - l[i]) / br > retrace_limit:
continue
# Volume confirm
if use_vol:
sq_start = ev["sq_start"]
avg_sq_v = np.mean(v[sq_start:i])
if avg_sq_v > 0 and v[i] <= avg_sq_v * vol_mult:
continue
# Cross-market momentum: trova indice cross corrispondente
i_cross = np.searchsorted(ts_cross_ms, ts_ms[i]) - 1
if i_cross < cross_bars or i_cross >= n_cross:
continue
mom = cross_mom[i_cross]
if np.isnan(mom):
continue
# Filtra per direzione concordante
if direction == 1 and mom <= mom_min:
continue
if direction == -1 and mom >= -mom_min:
continue
signals.append(Signal(
idx=i,
direction=direction,
entry_price=c[i - 1],
metadata={
"dur": ev["dur"],
"cross_mom": float(mom),
},
))
return signals
if __name__ == "__main__":
strategy = CrossMarketMomentum()
configs = [
# cross_bars, mom_min, use_vol
{"cross_bars": 3, "mom_min": 0.0, "use_vol": True},
{"cross_bars": 4, "mom_min": 0.0, "use_vol": True},
{"cross_bars": 6, "mom_min": 0.0, "use_vol": True},
{"cross_bars": 4, "mom_min": 0.001, "use_vol": True},
{"cross_bars": 4, "mom_min": 0.002, "use_vol": True},
{"cross_bars": 4, "mom_min": 0.0, "use_vol": False},
{"cross_bars": 3, "mom_min": 0.001, "use_vol": False},
{"cross_bars": 6, "mom_min": 0.001, "use_vol": True},
]
all_results = []
for cfg in configs:
for asset in ["BTC", "ETH"]:
for tf in ["15m", "1h"]:
for hold in [3, 6]:
r = strategy.backtest(asset, tf, hold=hold,
cross_bars=cfg["cross_bars"],
mom_min=cfg["mom_min"],
use_vol=cfg["use_vol"])
if r and r.trades >= 20:
lbl = (f"CM01 cb={cfg['cross_bars']} "
f"mm={cfg['mom_min']} v={cfg['use_vol']} h={hold}")
r.strategy_name = lbl
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 130}")
print(" CM01 CROSS-MARKET MOMENTUM — TOP 20")
print(f"{'=' * 130}")
print(f" {'Nome':<50s} {'A/T':>7s} {'Trades':>6s} {'Acc':>6s} "
f"{'PnL€':>10s} {'DD%':>6s} {'€/day':>7s} "
f"{'Mkt%':>5s} {'Dur':>5s} {'Anni':>4s}")
print(f" {'' * 120}")
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%, €5.23/day, 9 anni")
print(f" BENCHMARK MT01: 82.7% acc, 503t, DD 5.9%")
+266
View File
@@ -0,0 +1,266 @@
"""ML01 — Squeeze + GBM (Gradient Boosting Machine) Walk-Forward.
Strategia ibrida: squeeze breakout come pre-filtro (QUANDO tradare),
GradientBoosting su features strutturali come conferma (QUALE direzione).
Pipeline:
1. Rileva squeeze release (Bollinger esce da Keltner)
2. Estrai 44 features dalla finestra (structural multi-window + squeeze
metadata + price position + ATR + momentum breakout)
3. GBM walk-forward: train su 50% rolling, step 10%, predice direzione
4. Trade solo se ML ha confidenza ≥ ml_threshold
IN:
- OHLCV DataFrame
- Parametri: bb_window (14), sq_threshold (0.8), brk_bars (3),
ml_threshold (0.70), leverage (3), position_pct (0.15)
OUT:
- BacktestResult con metriche walk-forward (no data leakage)
- Solo periodo di test (seconda metà dati)
Risultati tipici:
ETH 15m bb14 ml=0.70: 76.9% acc, 1213 trades, DD 4.2%, €13.78/day
BTC 15m bb14 ml=0.70: 78.8% acc, 1964 trades, DD 7.0%, €5.51/day
BTC 1h bb14 ml=0.70: 77.3% acc, 617 trades, DD 6.7%, €3.85/day
Note:
- GBM = GradientBoostingClassifier di scikit-learn
- Walk-forward: nessun look-ahead, train sempre prima di test
- Il baseline squeeze puro ha accuracy più alta (~79.5%) ma DD peggiore
- Il valore del ML è filtrare breakout deboli → DD ridotto
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.preprocessing import StandardScaler
from src.strategies.base import Strategy, Signal, BacktestResult, YearlyStats, TF_MINUTES
from src.strategies.indicators import keltner_ratio, detect_squeezes
from src.data.downloader import load_data
def _build_features(df: pd.DataFrame, i: int, squeeze_info: dict) -> np.ndarray | None:
"""44 features per il punto di squeeze release."""
if i < 100:
return None
o, h, l, c, v = (df["open"].values, df["high"].values, df["low"].values,
df["close"].values, df["volume"].values)
feats = []
for w in [12, 24, 48]:
wc, wo = c[i-w:i], o[i-w:i]
wh, wl, wv = h[i-w:i], l[i-w:i], v[i-w:i]
mn, mx = wl.min(), max(wh.max(), wc.max())
rng = mx - mn if mx - mn > 0 else 1e-10
total = np.where(wh - wl == 0, 1e-10, wh - wl)
body = np.abs(wc - wo) / total
direction = np.sign(wc - wo)
log_c = np.log(np.where(wc == 0, 1e-10, wc))
rets = np.diff(log_c)
v_mean = np.mean(wv)
feats.extend([
np.mean(rets) if len(rets) > 0 else 0,
np.std(rets) if len(rets) > 0 else 0,
np.sum(rets) if len(rets) > 0 else 0,
float(pd.Series(rets).skew()) if len(rets) > 2 else 0,
float(pd.Series(rets).kurtosis()) if len(rets) > 3 else 0,
np.mean(body), np.std(body),
np.mean(direction), np.mean(direction[-min(3, w):]),
(wc[-1] - mn) / rng,
wv[-1] / v_mean if v_mean > 0 else 1,
np.corrcoef(rets[:-1], rets[1:])[0, 1] if len(rets) > 1 and np.std(rets) > 0 else 0,
])
sq = squeeze_info
feats.extend([
sq["dur"], sq["dur"] / 24, sq["kcr_at_release"],
v[i-1] / sq.get("avg_vol", 1) if sq.get("avg_vol", 0) > 0 else 1,
np.mean(v[i:min(i+3, len(v))]) / sq.get("avg_vol", 1) if sq.get("avg_vol", 0) > 0 else 1,
])
h48, l48 = np.max(h[max(0, i-48):i]), np.min(l[max(0, i-48):i])
r48 = h48 - l48
feats.append((c[i-1] - l48) / r48 if r48 > 0 else 0.5)
tr = np.maximum(h[i-14:i] - l[i-14:i],
np.maximum(np.abs(h[i-14:i] - np.roll(c[i-14:i], 1)),
np.abs(l[i-14:i] - np.roll(c[i-14:i], 1))))
feats.append(np.mean(tr[1:]) / c[i-1] if c[i-1] > 0 else 0)
feats.append((c[i] - c[i-1]) / c[i-1] if c[i-1] > 0 else 0)
return np.nan_to_num(np.array(feats), nan=0, posinf=1e6, neginf=-1e6)
class SqueezeGBM(Strategy):
name = "ML01_squeeze_gbm"
description = "Squeeze + GBM walk-forward — ML filtra breakout deboli"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
fee_ml = 0.001
def generate_signals(self, df, ts, **params):
raise NotImplementedError("ML01 usa backtest custom con walk-forward")
def backtest(self, asset: str, tf: str, hold: int = 3, **params) -> BacktestResult | None:
bb_w = params.get("bb_window", 14)
sq_thr = params.get("sq_threshold", 0.8 if tf == "1h" else 0.9)
brk = params.get("brk_bars", hold)
ml_thr = params.get("ml_threshold", 0.70)
lev = params.get("leverage", self.leverage)
pos = params.get("position_pct", self.position_size)
df = load_data(asset, tf)
close = df["close"].values
high = df["high"].values
low = df["low"].values
volume = df["volume"].values
n = len(df)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
kcr = keltner_ratio(close, high, low, bb_w)
raw_events = detect_squeezes(close, high, low, kcr, sq_thr)
# Aggiungi avg_vol a ogni evento
events = []
for ev in raw_events:
ev["avg_vol"] = float(np.mean(volume[ev["sq_start"]:ev["idx"]]))
events.append(ev)
X_all, y_all, ev_all = [], [], []
for ev in events:
i = ev["idx"]
if i + brk >= n or i < 100:
continue
feats = _build_features(df, i, ev)
if feats is None:
continue
actual_ret = (close[i + brk - 1] - close[i - 1]) / close[i - 1]
X_all.append(feats)
y_all.append(1 if actual_ret > 0 else 0)
ev_all.append(ev)
if len(X_all) < 50:
return None
X, y = np.array(X_all), np.array(y_all)
TRAIN_SIZE = max(int(len(X) * 0.5), 50)
STEP_SIZE = max(int(len(X) * 0.1), 10)
yearly: dict[int, dict] = {}
capital = float(self.initial_capital)
peak = capital
max_dd = 0.0
total_bars = 0
all_t = all_w = 0
start = 0
while start + TRAIN_SIZE + STEP_SIZE <= len(X):
train_end = start + TRAIN_SIZE
test_end = min(train_end + STEP_SIZE, len(X))
X_tr, y_tr = X[start:train_end], y[start:train_end]
X_te = X[train_end:test_end]
if len(np.unique(y_tr)) < 2:
start += STEP_SIZE
continue
scaler = StandardScaler()
X_tr_s = scaler.fit_transform(X_tr)
X_te_s = scaler.transform(X_te)
model = GradientBoostingClassifier(
n_estimators=150, max_depth=4, min_samples_leaf=10,
learning_rate=0.05, subsample=0.8, random_state=42,
)
model.fit(X_tr_s, y_tr)
up_idx = list(model.classes_).index(1) if 1 in model.classes_ else -1
if up_idx < 0:
start += STEP_SIZE
continue
for j in range(len(X_te)):
proba = model.predict_proba(X_te_s[j:j+1])[0]
p_up = proba[up_idx]
ev = ev_all[train_end + j]
i = ev["idx"]
actual_ret = (close[i + brk - 1] - close[i - 1]) / close[i - 1]
if p_up >= ml_thr:
direction = 1
elif p_up <= (1 - ml_thr):
direction = -1
else:
continue
is_correct = (direction == 1 and actual_ret > 0) or (direction == -1 and actual_ret < 0)
trade_ret = actual_ret * direction
net = trade_ret * lev - self.fee_ml * 2 * lev
capital += capital * pos * net
capital = max(capital, 10)
if capital > peak:
peak = capital
dd = (peak - capital) / peak
max_dd = max(max_dd, dd)
total_bars += brk
all_t += 1
if is_correct:
all_w += 1
year = ts.iloc[i].year
if year not in yearly:
yearly[year] = {"w": 0, "t": 0, "pnl": 0.0}
yearly[year]["t"] += 1
if is_correct:
yearly[year]["w"] += 1
yearly[year]["pnl"] += net * self.initial_capital
start += STEP_SIZE
if all_t == 0:
return None
yearly_stats = [
YearlyStats(year=y, trades=d["t"], wins=d["w"], pnl=d["pnl"])
for y, d in sorted(yearly.items())
]
return BacktestResult(
strategy_name=self.name,
asset=asset,
timeframe=tf,
params={"bb_w": bb_w, "sq_thr": sq_thr, "ml_thr": ml_thr,
"brk": brk, "lev": lev, "pos": pos},
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 / n * 100,
avg_trade_duration_h=brk * TF_MINUTES.get(tf, 60) / 60,
years_active=len(yearly),
yearly=yearly_stats,
)
if __name__ == "__main__":
strategy = SqueezeGBM()
print("Training ML models...\n")
results = []
for asset in ["ETH", "BTC"]:
for tf in ["15m", "1h"]:
for ml_thr in [0.65, 0.70]:
r = strategy.backtest(asset, tf, ml_threshold=ml_thr)
if r and r.trades >= 20:
r.strategy_name = f"ML01 {asset} {tf} ml={ml_thr}"
results.append(r)
results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"{'=' * 120}")
print(f" ML01 SQUEEZE+GBM — RISULTATI")
print(f"{'=' * 120}")
for r in results:
r.print_summary()
if results:
results[0].print_yearly()
+82
View File
@@ -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,261 @@
"""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")
@@ -0,0 +1,158 @@
"""PD01 — Price-Volume Divergence Squeeze.
Estende SQ02 con volume TREND come filtro:
- Breakout UP con volume CRESCENTE (ultimi 3 bar vs media squeeze) → ENTRA
- Breakout UP con volume CALANTE → SALTA (divergenza bearish)
- Viceversa per short
Logica anti-fakeout:
1. Squeeze rilascio (come SQ02)
2. Anti-fakeout candela (come SQ02)
3. Volume al breakout > media squeeze (come SQ02)
4. NUOVO: volume trending UP nelle ultime 3 barre prima del breakout
Parametri semplici, nessun overfitting.
"""
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
from src.strategies.indicators import keltner_ratio, detect_squeezes
class PriceVolumeDivergence(Strategy):
name = "PD01_price_vol_div"
description = "Squeeze + antifakeout + volume trend confirmation"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
fee_rt = 0.002
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
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
bb_w = params.get("bb_window", 14)
sq_thr = params.get("sq_threshold", 0.8)
retrace_limit = params.get("retrace_limit", 0.6)
vol_mult = params.get("vol_multiplier", 1.3)
vol_trend_bars = params.get("vol_trend_bars", 3) # barre per trend volume
kcr = keltner_ratio(c, h, l, bb_w)
events = detect_squeezes(c, h, l, kcr, sq_thr)
signals = []
for ev in events:
i = ev["idx"]
if i < vol_trend_bars + 1 or i >= n:
continue
# Direzione breakout
first_ret = (c[i] - c[i - 1]) / c[i - 1] if c[i - 1] > 0 else 0
if abs(first_ret) < 0.001:
continue
direction = 1 if first_ret > 0 else -1
# Anti-fakeout
br = h[i] - l[i]
if br > 0:
if direction == 1 and (h[i] - c[i]) / br > retrace_limit:
continue
elif direction == -1 and (c[i] - l[i]) / br > retrace_limit:
continue
# Volume al breakout > media squeeze
sq_start = ev["sq_start"]
avg_sq_v = np.mean(v[sq_start:i])
if avg_sq_v <= 0 or v[i] <= avg_sq_v * vol_mult:
continue
# Volume TREND: slope delle ultime vol_trend_bars barre
# Usa regressione lineare semplice (rank correlation del volume)
recent_v = v[i - vol_trend_bars:i + 1] # include breakout bar
if len(recent_v) < vol_trend_bars:
continue
# slope: media seconda metà vs prima metà
mid = len(recent_v) // 2
v_early = np.mean(recent_v[:mid])
v_late = np.mean(recent_v[mid:])
vol_trending_up = v_late > v_early
vol_trending_down = v_early > v_late
# Concordanza: long richiede volume trending up, short trending down
if direction == 1 and not vol_trending_up:
continue
if direction == -1 and not vol_trending_down:
continue
signals.append(Signal(
idx=i,
direction=direction,
entry_price=c[i - 1],
metadata={
"dur": ev["dur"],
"vol_ratio": v[i] / avg_sq_v if avg_sq_v > 0 else 0,
"vol_trend": v_late / v_early if v_early > 0 else 1,
},
))
return signals
if __name__ == "__main__":
strategy = PriceVolumeDivergence()
configs = [
{"bb_window": 14, "sq_threshold": 0.8, "retrace_limit": 0.6,
"vol_multiplier": 1.3, "vol_trend_bars": 3},
{"bb_window": 14, "sq_threshold": 0.8, "retrace_limit": 0.6,
"vol_multiplier": 1.2, "vol_trend_bars": 3},
{"bb_window": 14, "sq_threshold": 0.8, "retrace_limit": 0.6,
"vol_multiplier": 1.3, "vol_trend_bars": 5},
{"bb_window": 14, "sq_threshold": 0.8, "retrace_limit": 0.5,
"vol_multiplier": 1.3, "vol_trend_bars": 3},
{"bb_window": 14, "sq_threshold": 0.75, "retrace_limit": 0.6,
"vol_multiplier": 1.3, "vol_trend_bars": 3},
{"bb_window": 20, "sq_threshold": 0.8, "retrace_limit": 0.6,
"vol_multiplier": 1.3, "vol_trend_bars": 3},
]
all_results = []
for cfg in configs:
for asset in ["BTC", "ETH"]:
for tf in ["15m", "1h"]:
for hold in [3, 6]:
r = strategy.backtest(asset, tf, hold=hold, **cfg)
if r and r.trades >= 20:
lbl = (f"PD01 vtb={cfg['vol_trend_bars']} "
f"vm={cfg['vol_multiplier']} "
f"sq={cfg['sq_threshold']} h={hold}")
r.strategy_name = lbl
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 130}")
print(" PD01 PRICE-VOLUME DIVERGENCE — TOP 20")
print(f"{'=' * 130}")
print(f" {'Nome':<50s} {'A/T':>7s} {'Trades':>6s} {'Acc':>6s} "
f"{'PnL€':>10s} {'DD%':>6s} {'€/day':>7s} "
f"{'Mkt%':>5s} {'Dur':>5s} {'Anni':>4s}")
print(f" {'' * 120}")
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%, €5.23/day, 9 anni")
print(f" BENCHMARK MT01: 82.7% acc, 503t, DD 5.9%")
@@ -0,0 +1,223 @@
"""Strategia 11: Volatility compression → breakout.
Approccio diverso: non predire la direzione direttamente.
1. Identifica momenti di COMPRESSIONE (Bollinger squeeze, ATR basso, bassa fractal dim)
2. Quando la volatilità ESPLODE dopo compressione, segui la direzione del breakout
3. Alta precisione perché il breakout DOPO compressione ha forte momentum
Target: pochi trade molto precisi, con leva.
"""
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
from src.fractal.indicators import volatility_ratio
FEE_PCT = 0.001
LEVERAGE = 3
INITIAL_CAPITAL = 1000
def bollinger_bandwidth(close: np.ndarray, window: int = 20) -> np.ndarray:
"""Bandwidth = (upper - lower) / middle."""
result = np.full(len(close), np.nan)
for i in range(window, len(close)):
w = close[i - window : i]
ma = np.mean(w)
std = np.std(w)
if ma > 0:
result[i] = (2 * 2 * std) / ma
return result
def keltner_channel_ratio(close: np.ndarray, high: np.ndarray, low: np.ndarray, window: int = 20) -> np.ndarray:
"""Ratio of Bollinger to Keltner — squeeze when < 1."""
result = np.full(len(close), np.nan)
for i in range(window, len(close)):
w_c = close[i - window : i]
w_h = high[i - window : i]
w_l = low[i - window : i]
ma = np.mean(w_c)
bb_std = np.std(w_c)
bb_upper = ma + 2 * bb_std
bb_lower = ma - 2 * bb_std
tr = np.maximum(w_h - w_l, np.maximum(np.abs(w_h - np.roll(w_c, 1)), np.abs(w_l - np.roll(w_c, 1))))
atr = np.mean(tr[1:])
kc_upper = ma + 1.5 * atr
kc_lower = ma - 1.5 * atr
kc_range = kc_upper - kc_lower
bb_range = bb_upper - bb_lower
if kc_range > 0:
result[i] = bb_range / kc_range
return result
def detect_squeeze_release(
close: np.ndarray,
high: np.ndarray,
low: np.ndarray,
volume: np.ndarray,
bb_window: int = 20,
squeeze_threshold: float = 0.8,
breakout_bars: int = 3,
volume_mult: float = 1.5,
) -> list[dict]:
"""Detect squeeze → breakout events."""
bw = bollinger_bandwidth(close, bb_window)
kcr = keltner_channel_ratio(close, high, low, bb_window)
events = []
in_squeeze = False
squeeze_start = 0
for i in range(bb_window + 1, len(close)):
if np.isnan(kcr[i]):
continue
is_squeeze = kcr[i] < squeeze_threshold
if is_squeeze and not in_squeeze:
in_squeeze = True
squeeze_start = i
elif not is_squeeze and in_squeeze:
in_squeeze = False
squeeze_duration = i - squeeze_start
if squeeze_duration < 5:
continue
# Check breakout direction using next few bars
if i + breakout_bars >= len(close):
continue
breakout_ret = (close[i + breakout_bars - 1] - close[i - 1]) / close[i - 1]
# Volume confirmation
avg_vol = np.mean(volume[squeeze_start:i])
breakout_vol = np.mean(volume[i:i + breakout_bars])
vol_confirmed = breakout_vol > avg_vol * volume_mult if avg_vol > 0 else False
# Momentum confirmation
mom_3 = (close[i + 2] - close[i - 1]) / close[i - 1] if i + 2 < len(close) else 0
events.append({
"idx": i,
"squeeze_duration": squeeze_duration,
"breakout_ret": breakout_ret,
"vol_confirmed": vol_confirmed,
"mom_3": mom_3,
"bb_expansion": bw[i] / bw[squeeze_start] if bw[squeeze_start] > 0 else 1,
})
return events
def run_squeeze_strategy(asset: str, tf: str = "1h"):
print(f"\n{'#'*60}")
print(f" {asset} {tf} — VOLATILITY SQUEEZE BREAKOUT")
print(f"{'#'*60}")
df = load_data(asset, tf)
close = df["close"].values
high = df["high"].values
low = df["low"].values
volume = df["volume"].values
n = len(df)
split_idx = int(n * 0.7)
for bb_w in [14, 20, 30]:
for sq_thr in [0.7, 0.8, 0.9]:
for brk_bars in [3, 6]:
events = detect_squeeze_release(close, high, low, volume,
bb_window=bb_w, squeeze_threshold=sq_thr,
breakout_bars=brk_bars, volume_mult=1.3)
test_events = [e for e in events if e["idx"] >= split_idx]
if len(test_events) < 10:
continue
# Strategy: follow breakout direction, with volume confirmation
capital = float(INITIAL_CAPITAL)
correct = 0
total = 0
for e in test_events:
i = e["idx"]
if i + brk_bars * 2 >= n:
continue
# First 1-bar direction as signal
first_bar_ret = (close[i] - close[i - 1]) / close[i - 1]
if abs(first_bar_ret) < 0.001:
continue
direction = "long" if first_bar_ret > 0 else "short"
# Actual result after holding for brk_bars more
actual_ret = (close[i + brk_bars - 1] - close[i - 1]) / close[i - 1]
is_correct = (direction == "long" and actual_ret > 0) or (direction == "short" and actual_ret < 0)
total += 1
if is_correct:
correct += 1
trade_ret = actual_ret if direction == "long" else -actual_ret
net = trade_ret * LEVERAGE - FEE_PCT * 2 * LEVERAGE
capital += capital * 0.2 * net
capital = max(capital, 0)
# Enhanced: volume-confirmed only
if total > 0:
acc = correct / total * 100
ret = (capital - INITIAL_CAPITAL) / INITIAL_CAPITAL * 100
test_candles = n - split_idx
test_years = test_candles / (24 * 365.25)
ann = ((capital / INITIAL_CAPITAL) ** (1 / test_years) - 1) * 100 if test_years > 0 and capital > 0 else -100
if acc >= 55 and total >= 15:
print(f" BBw={bb_w:2d} sqThr={sq_thr:.1f} brk={brk_bars}: trades={total:4d} acc={acc:.1f}% ret={ret:+.1f}% ann={ann:+.1f}%")
# Volume-confirmed only
cap_vc = float(INITIAL_CAPITAL)
correct_vc = 0
total_vc = 0
for e in test_events:
if not e["vol_confirmed"]:
continue
i = e["idx"]
if i + brk_bars * 2 >= n:
continue
first_bar_ret = (close[i] - close[i - 1]) / close[i - 1]
if abs(first_bar_ret) < 0.001:
continue
direction = "long" if first_bar_ret > 0 else "short"
actual_ret = (close[i + brk_bars - 1] - close[i - 1]) / close[i - 1]
is_correct = (direction == "long" and actual_ret > 0) or (direction == "short" and actual_ret < 0)
total_vc += 1
if is_correct:
correct_vc += 1
trade_ret = actual_ret if direction == "long" else -actual_ret
net = trade_ret * LEVERAGE - FEE_PCT * 2 * LEVERAGE
cap_vc += cap_vc * 0.2 * net
cap_vc = max(cap_vc, 0)
if total_vc >= 10:
acc_vc = correct_vc / total_vc * 100
ret_vc = (cap_vc - INITIAL_CAPITAL) / INITIAL_CAPITAL * 100
ann_vc = ((cap_vc / INITIAL_CAPITAL) ** (1 / (test_candles/(24*365.25))) - 1) * 100 if cap_vc > 0 else -100
if acc_vc >= 55:
print(f" +VOL BBw={bb_w:2d} sqThr={sq_thr:.1f} brk={brk_bars}: trades={total_vc:4d} acc={acc_vc:.1f}% ret={ret_vc:+.1f}% ann={ann_vc:+.1f}%")
for asset in ["BTC", "ETH"]:
for tf in ["1h", "15m"]:
run_squeeze_strategy(asset, tf)
@@ -0,0 +1,408 @@
"""Strategia 13: Squeeze + ML ibrida.
Squeeze breakout come PRE-FILTRO (quando tradare),
ML come CONFERMA DIREZIONALE (quale direzione).
Pipeline:
1. Rileva squeeze release (Bollinger esce da Keltner)
2. Estrai features frattali/strutturali dalla finestra
3. ML predice direzione con confidenza
4. Trade SOLO se squeeze + ML concordano
Obiettivo: accuracy squeeze (>80%) + volume trade ML.
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.preprocessing import StandardScaler
from src.data.downloader import load_data
from src.fractal.patterns import encode_candles
FEE = 0.001
INITIAL = 1000
def keltner_ratio(close, high, low, window=20):
n = len(close)
result = np.full(n, np.nan)
for i in range(window, n):
wc = close[i-window:i]
wh = high[i-window:i]
wl = low[i-window:i]
ma = np.mean(wc)
bb_std = np.std(wc)
tr = np.maximum(wh - wl, np.maximum(np.abs(wh - np.roll(wc, 1)), np.abs(wl - np.roll(wc, 1))))
atr = np.mean(tr[1:])
kc_r = (ma + 1.5*atr) - (ma - 1.5*atr)
bb_r = (ma + 2*bb_std) - (ma - 2*bb_std)
if kc_r > 0:
result[i] = bb_r / kc_r
return result
def detect_squeeze_releases(close, high, low, volume, bb_w, sq_thr, min_duration=5):
kcr = keltner_ratio(close, high, low, bb_w)
events = []
in_sq = False
sq_start = 0
for i in range(bb_w + 1, len(close)):
if np.isnan(kcr[i]):
continue
is_sq = kcr[i] < sq_thr
if is_sq and not in_sq:
in_sq = True
sq_start = i
elif not is_sq and in_sq:
in_sq = False
duration = i - sq_start
if duration < min_duration:
continue
avg_vol = np.mean(volume[sq_start:i])
events.append({
"idx": i,
"squeeze_start": sq_start,
"duration": duration,
"avg_vol_squeeze": avg_vol,
"kcr_at_release": kcr[i],
})
return events
def build_features_at(df, i, squeeze_info):
"""Features per il punto di squeeze release."""
if i < 100:
return None
o = df["open"].values
h = df["high"].values
l = df["low"].values
c = df["close"].values
v = df["volume"].values
feats = []
# Structural features multi-window
for w in [12, 24, 48]:
win_c = c[i-w:i]
win_o = o[i-w:i]
win_h = h[i-w:i]
win_l = l[i-w:i]
win_v = v[i-w:i]
mn, mx = win_l.min(), max(win_h.max(), win_c.max())
rng = mx - mn if mx - mn > 0 else 1e-10
total = win_h - win_l
total = np.where(total == 0, 1e-10, total)
body = np.abs(win_c - win_o) / total
direction = np.sign(win_c - win_o)
log_c = np.log(np.where(win_c == 0, 1e-10, win_c))
rets = np.diff(log_c)
v_mean = np.mean(win_v)
feats.extend([
np.mean(rets) if len(rets) > 0 else 0,
np.std(rets) if len(rets) > 0 else 0,
np.sum(rets) if len(rets) > 0 else 0,
float(pd.Series(rets).skew()) if len(rets) > 2 else 0,
float(pd.Series(rets).kurtosis()) if len(rets) > 3 else 0,
np.mean(body),
np.std(body),
np.mean(direction),
np.mean(direction[-min(3, w):]),
(win_c[-1] - mn) / rng,
win_v[-1] / v_mean if v_mean > 0 else 1,
np.corrcoef(rets[:-1], rets[1:])[0, 1] if len(rets) > 1 and np.std(rets) > 0 else 0,
])
# Squeeze-specific features
sq = squeeze_info
feats.extend([
sq["duration"],
sq["duration"] / 24, # durata in giorni
sq["kcr_at_release"],
v[i-1] / sq["avg_vol_squeeze"] if sq["avg_vol_squeeze"] > 0 else 1,
np.mean(v[i:min(i+3, len(v))]) / sq["avg_vol_squeeze"] if sq["avg_vol_squeeze"] > 0 else 1,
])
# Price position
h48 = np.max(h[max(0, i-48):i])
l48 = np.min(l[max(0, i-48):i])
r48 = h48 - l48
feats.append((c[i-1] - l48) / r48 if r48 > 0 else 0.5)
# ATR normalized
tr = np.maximum(h[i-14:i] - l[i-14:i],
np.maximum(np.abs(h[i-14:i] - np.roll(c[i-14:i], 1)),
np.abs(l[i-14:i] - np.roll(c[i-14:i], 1))))
atr = np.mean(tr[1:])
feats.append(atr / c[i-1] if c[i-1] > 0 else 0)
# First bar momentum (la barra di breakout)
first_ret = (c[i] - c[i-1]) / c[i-1] if c[i-1] > 0 else 0
feats.append(first_ret)
return np.nan_to_num(np.array(feats), nan=0, posinf=1e6, neginf=-1e6)
def run_hybrid(asset, tf, bb_w, sq_thr, brk_bars, leverage, pos_pct):
print(f"\n{'='*65}")
print(f" {asset} {tf} — HYBRID Squeeze+ML (BBw={bb_w}, sq={sq_thr}, brk={brk_bars})")
print(f" Leverage: {leverage}x, Position: {pos_pct*100:.0f}%")
print(f"{'='*65}")
df = load_data(asset, tf)
close = df["close"].values
high = df["high"].values
low = df["low"].values
volume = df["volume"].values
n = len(df)
events = detect_squeeze_releases(close, high, low, volume, bb_w, sq_thr)
print(f" Squeeze releases totali: {len(events)}")
# Build dataset: solo ai punti di squeeze
X_all, y_all, ev_all = [], [], []
for ev in events:
i = ev["idx"]
if i + brk_bars >= n or i < 100:
continue
feats = build_features_at(df, i, ev)
if feats is None:
continue
actual_ret = (close[i + brk_bars - 1] - close[i - 1]) / close[i - 1]
X_all.append(feats)
y_all.append(1 if actual_ret > 0 else 0)
ev_all.append(ev)
if len(X_all) < 50:
print(" Troppi pochi campioni.")
return None
X = np.array(X_all)
y = np.array(y_all)
print(f" Samples: {len(X)}, Up: {np.mean(y)*100:.1f}%")
# Walk-forward
TRAIN_SIZE = max(int(len(X) * 0.5), 50)
STEP_SIZE = max(int(len(X) * 0.1), 10)
results = {}
for ml_thr in [0.50, 0.55, 0.60, 0.65, 0.70]:
capital = float(INITIAL)
equity = [capital]
trades_list = []
correct = 0
total = 0
start = 0
while start + TRAIN_SIZE + STEP_SIZE <= len(X):
train_end = start + TRAIN_SIZE
test_end = min(train_end + STEP_SIZE, len(X))
X_tr = X[start:train_end]
y_tr = y[start:train_end]
X_te = X[train_end:test_end]
y_te = y[train_end:test_end]
if len(np.unique(y_tr)) < 2:
start += STEP_SIZE
continue
scaler = StandardScaler()
X_tr_s = scaler.fit_transform(X_tr)
X_te_s = scaler.transform(X_te)
model = GradientBoostingClassifier(
n_estimators=150, max_depth=4, min_samples_leaf=10,
learning_rate=0.05, subsample=0.8, random_state=42,
)
model.fit(X_tr_s, y_tr)
up_idx = list(model.classes_).index(1) if 1 in model.classes_ else -1
if up_idx < 0:
start += STEP_SIZE
continue
for j in range(len(X_te)):
proba = model.predict_proba(X_te_s[j:j+1])[0]
p_up = proba[up_idx]
ev = ev_all[train_end + j]
i = ev["idx"]
actual_ret = (close[i + brk_bars - 1] - close[i - 1]) / close[i - 1]
# ML decide direction
direction = None
if p_up >= ml_thr:
direction = "long"
elif p_up <= (1 - ml_thr):
direction = "short"
if direction is None:
continue
is_correct = (direction == "long" and actual_ret > 0) or (direction == "short" and actual_ret < 0)
total += 1
if is_correct:
correct += 1
trade_ret = actual_ret if direction == "long" else -actual_ret
net = trade_ret * leverage - FEE * 2 * leverage
pnl = capital * pos_pct * net
capital += pnl
capital = max(capital, 0)
equity.append(capital)
trades_list.append({
"idx": i,
"direction": direction,
"p_up": p_up,
"actual_ret": actual_ret,
"correct": is_correct,
"pnl": pnl,
})
start += STEP_SIZE
if total == 0:
continue
acc = correct / total * 100
# Max drawdown
peak = equity[0]
max_dd = 0
for v in equity:
if v > peak:
peak = v
dd = (peak - v) / peak if peak > 0 else 0
max_dd = max(max_dd, dd)
# Annualized
first_ev = ev_all[TRAIN_SIZE] if TRAIN_SIZE < len(ev_all) else ev_all[0]
last_ev = ev_all[-1]
test_candles = last_ev["idx"] - first_ev["idx"]
if tf == "1h":
test_days = test_candles / 24
elif tf == "15m":
test_days = test_candles / (24 * 4)
else:
test_days = test_candles / 24
test_years = test_days / 365.25 if test_days > 0 else 1
ann = ((capital / INITIAL) ** (1 / test_years) - 1) * 100 if test_years > 0 and capital > 0 else -100
daily_pnl = (capital - INITIAL) / test_days if test_days > 0 else 0
trades_yr = total / test_years if test_years > 0 else 0
tag = ""
if acc >= 80:
tag = " ✅✅"
elif acc >= 70:
tag = ""
print(f" ml_thr={ml_thr:.2f}: trades={total:4d} acc={acc:.1f}%{tag} ret={(capital-INITIAL)/INITIAL*100:+.1f}% ann={ann:+.1f}% dd={max_dd*100:.1f}% sharpe= trades/yr={trades_yr:.0f} €/day={daily_pnl:.2f}")
results[ml_thr] = {
"trades": total, "accuracy": acc, "capital": capital,
"annualized": ann, "max_dd": max_dd * 100, "daily_pnl": daily_pnl,
"trades_yr": trades_yr,
}
# Modalità "squeeze puro" come baseline
capital_sq = float(INITIAL)
correct_sq = 0
total_sq = 0
split = int(len(X) * 0.5)
for k in range(split, len(X)):
ev = ev_all[k]
i = ev["idx"]
if i + brk_bars >= n:
continue
first_ret = (close[i] - close[i-1]) / close[i-1]
if abs(first_ret) < 0.001:
continue
direction = 1 if first_ret > 0 else -1
actual_ret = (close[i + brk_bars - 1] - close[i - 1]) / close[i - 1]
is_correct = (direction == 1 and actual_ret > 0) or (direction == -1 and actual_ret < 0)
total_sq += 1
if is_correct:
correct_sq += 1
trade_ret = actual_ret * direction
net = trade_ret * leverage - FEE * 2 * leverage
capital_sq += capital_sq * pos_pct * net
capital_sq = max(capital_sq, 0)
if total_sq > 0:
acc_sq = correct_sq / total_sq * 100
print(f" BASELINE squeeze puro: trades={total_sq:4d} acc={acc_sq:.1f}% ret={(capital_sq-INITIAL)/INITIAL*100:+.1f}%")
return results
# ===== MAIN: test su multiple configurazioni =====
print("=" * 70)
print(" STRATEGIA 13: SQUEEZE + ML IBRIDA")
print("=" * 70)
configs = [
# (asset, tf, bb_w, sq_thr, brk_bars, leverage, pos_pct)
("ETH", "1h", 20, 0.8, 3, 3, 0.2),
("ETH", "1h", 30, 0.9, 3, 3, 0.2),
("ETH", "1h", 14, 0.8, 3, 3, 0.2),
("ETH", "1h", 20, 0.9, 3, 3, 0.2),
("BTC", "1h", 14, 0.8, 3, 3, 0.2),
("BTC", "1h", 20, 0.8, 3, 3, 0.2),
("BTC", "1h", 14, 0.9, 6, 3, 0.2),
("ETH", "15m", 14, 0.8, 3, 3, 0.15),
("ETH", "15m", 20, 0.9, 3, 3, 0.15),
("BTC", "15m", 14, 0.9, 3, 3, 0.15),
# Aggressive
("ETH", "1h", 20, 0.8, 3, 5, 0.3),
("ETH", "1h", 30, 0.9, 3, 5, 0.3),
]
all_results = []
for cfg in configs:
r = run_hybrid(*cfg)
if r:
for thr, data in r.items():
all_results.append({
"config": f"{cfg[0]} {cfg[1]} BBw={cfg[2]} sq={cfg[3]} brk={cfg[4]} lev={cfg[5]} pos={cfg[6]}",
"ml_thr": thr,
**data,
})
# Sort by accuracy
print("\n\n" + "=" * 70)
print(" CLASSIFICA PER ACCURACY (top 20)")
print("=" * 70)
sorted_acc = sorted(all_results, key=lambda x: x["accuracy"], reverse=True)
for r in sorted_acc[:20]:
tag = "✅✅" if r["accuracy"] >= 80 else "" if r["accuracy"] >= 70 else ""
print(f" {r['config']:55s} ml={r['ml_thr']:.2f} → acc={r['accuracy']:.1f}% {tag:4s} trades={r['trades']:4d} ret={(r['capital']-INITIAL)/INITIAL*100:+.1f}% ann={r['annualized']:+.1f}% dd={r['max_dd']:.1f}% €/day={r['daily_pnl']:.2f}")
print("\n" + "=" * 70)
print(" CLASSIFICA PER ROI ANNUO (top 20, min 20 trades)")
print("=" * 70)
sorted_roi = sorted([r for r in all_results if r["trades"] >= 20], key=lambda x: x["annualized"], reverse=True)
for r in sorted_roi[:20]:
tag = "✅✅" if r["accuracy"] >= 80 else "" if r["accuracy"] >= 70 else ""
print(f" {r['config']:55s} ml={r['ml_thr']:.2f} → acc={r['accuracy']:.1f}% {tag:4s} ann={r['annualized']:+.1f}% trades={r['trades']:4d} dd={r['max_dd']:.1f}% €/day={r['daily_pnl']:.2f}")
print("\n" + "=" * 70)
print(" SWEET SPOT: acc>=75% AND ann>=20% AND trades>=15")
print("=" * 70)
sweet = [r for r in all_results if r["accuracy"] >= 75 and r["annualized"] >= 20 and r["trades"] >= 15]
sweet.sort(key=lambda x: x["accuracy"] * x["annualized"], reverse=True)
for r in sweet:
print(f" {r['config']:55s} ml={r['ml_thr']:.2f} → acc={r['accuracy']:.1f}% ann={r['annualized']:+.1f}% trades={r['trades']:4d} dd={r['max_dd']:.1f}% €/day={r['daily_pnl']:.2f}")
@@ -0,0 +1,317 @@
"""S3-01: Squeeze Migliorato — test per-anno, dati reali.
Miglioramenti rispetto al squeeze base:
1. Cross-asset: squeeze su BTC + ETH contemporaneo = segnale più forte
2. Timing orario: accuracy per fascia oraria
3. Squeeze duration weighted: squeeze lunghi → breakout più forti
4. Dual-timeframe: squeeze su 1h confermato da 15m
5. Anti-fakeout: skip se candela post-breakout ritraccia >50%
6. Dynamic exit: trailing stop basato su ATR
"""
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
INITIAL = 1000
LEVERAGE = 3
def keltner_ratio(close, high, low, window=14):
n = len(close)
r = np.full(n, np.nan)
for i in range(window, n):
wc, wh, wl = close[i-window:i], high[i-window:i], low[i-window:i]
ma = np.mean(wc)
bb_std = np.std(wc)
tr = np.maximum(wh-wl, np.maximum(np.abs(wh-np.roll(wc,1)), np.abs(wl-np.roll(wc,1))))
atr = np.mean(tr[1:])
kc = (ma+1.5*atr)-(ma-1.5*atr)
bb = (ma+2*bb_std)-(ma-2*bb_std)
if kc > 0:
r[i] = bb/kc
return r
def atr_calc(high, low, close, period=14):
tr = np.maximum(high-low, np.maximum(np.abs(high-np.roll(close,1)), np.abs(low-np.roll(close,1))))
tr[0] = high[0]-low[0]
r = np.full(len(close), np.nan)
r[period-1] = np.mean(tr[:period])
k = 2/(period+1)
for i in range(period, len(close)):
r[i] = tr[i]*k + r[i-1]*(1-k)
return r
def detect_squeezes(close, high, low, volume, kcr, sq_thr=0.8, min_dur=5):
"""Ritorna lista di squeeze events con metadata."""
events = []
in_sq = False
sq_start = 0
n = len(close)
for i in range(1, n):
if np.isnan(kcr[i]):
continue
is_sq = kcr[i] < sq_thr
if is_sq and not in_sq:
in_sq = True
sq_start = i
elif not is_sq and in_sq:
in_sq = False
dur = i - sq_start
if dur < min_dur:
continue
avg_vol = np.mean(volume[sq_start:i])
# Range durante squeeze
sq_range = (np.max(high[sq_start:i]) - np.min(low[sq_start:i])) / close[sq_start] if close[sq_start] > 0 else 0
events.append({
"release_idx": i,
"duration": dur,
"avg_vol": avg_vol,
"squeeze_range": sq_range,
"kcr_at_release": kcr[i],
})
return events
def run_improved_squeeze(primary_asset, tf="1h"):
# Carica asset primario
df = load_data(primary_asset, tf)
c, h, l, v = df["close"].values, df["high"].values, df["low"].values, df["volume"].values
n = len(df)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
ts_ms = df["timestamp"].values
kcr = keltner_ratio(c, h, l, 14)
atr_14 = atr_calc(h, l, c, 14)
events = detect_squeezes(c, h, l, v, kcr)
# Carica asset secondario per cross-check
secondary = "BTC" if primary_asset == "ETH" else "ETH"
df2 = load_data(secondary, tf)
c2, h2, l2 = df2["close"].values, df2["high"].values, df2["low"].values
ts2_ms = df2["timestamp"].values
kcr2 = keltner_ratio(c2, h2, l2, 14)
# Mappa ts2 → indici per allineare
def find_idx2(ts_val):
idx = np.searchsorted(ts2_ms, ts_val)
return min(idx, len(c2)-1)
# Carica 15m per dual-TF
if tf == "1h":
df_15m = load_data(primary_asset, "15m")
c15 = df_15m["close"].values
h15 = df_15m["high"].values
l15 = df_15m["low"].values
ts15 = df_15m["timestamp"].values
kcr_15m = keltner_ratio(c15, h15, l15, 14)
else:
kcr_15m = None
ts15 = None
# ================================================================
# CONFIGURAZIONI
# ================================================================
configs = [
# (name, use_cross, use_timing, use_duration, use_dual_tf, use_antifake, use_trailing, hold, stop_atr)
("BASE", False, False, False, False, False, False, 3, 0),
("cross_asset", True, False, False, False, False, False, 3, 0),
("timing_filter", False, True, False, False, False, False, 3, 0),
("long_squeeze", False, False, True, False, False, False, 3, 0),
("dual_tf", False, False, False, True, False, False, 3, 0),
("anti_fakeout", False, False, False, False, True, False, 3, 0),
("trailing_stop", False, False, False, False, False, True, 6, 1.5),
("cross+timing", True, True, False, False, False, False, 3, 0),
("cross+long+timing", True, True, True, False, False, False, 3, 0),
("cross+dual_tf", True, False, False, True, False, False, 3, 0),
("ALL_FILTERS", True, True, True, True, True, False, 3, 0),
("ALL+trailing", True, True, True, True, True, True, 6, 1.5),
("cross+antifake", True, False, False, False, True, False, 3, 0),
("timing+antifake", False, True, False, False, True, False, 3, 0),
("cross+timing+antifk", True, True, False, False, True, False, 3, 0),
("cross+timing+trail", True, True, False, False, False, True, 6, 1.5),
]
print(f"\n{'#'*75}")
print(f" {primary_asset} {tf} — SQUEEZE MIGLIORATO")
print(f"{'#'*75}")
results = []
for name, f_cross, f_timing, f_dur, f_dual, f_antifake, f_trail, hold, stop_atr_m in configs:
yearly = {}
capital = float(INITIAL)
peak = capital
max_dd = 0
for ev in events:
i = ev["release_idx"]
if i + hold + 2 >= n:
continue
# --- FILTRI ---
skip = False
# Cross-asset: secondary deve anche essere in squeeze recente o breakout
if f_cross:
i2 = find_idx2(ts_ms[i])
if i2 >= 5:
sec_in_squeeze = any(not np.isnan(kcr2[j]) and kcr2[j] < 0.85 for j in range(max(0,i2-10), i2+1))
if not sec_in_squeeze:
skip = True
# Timing: solo certe ore (testato: 6-14 UTC migliori)
if f_timing:
hour = ts.iloc[i].hour
if hour < 4 or hour > 16:
skip = True
# Duration: solo squeeze > 10 barre
if f_dur:
if ev["duration"] < 10:
skip = True
# Dual-TF: squeeze anche su 15m
if f_dual and kcr_15m is not None and ts15 is not None:
i15 = np.searchsorted(ts15, ts_ms[i])
if i15 >= 5:
sq_15m = any(not np.isnan(kcr_15m[j]) and kcr_15m[j] < 0.85 for j in range(max(0,i15-20), i15+1))
if not sq_15m:
skip = True
# Anti-fakeout: prima candela post-breakout non deve ritracciare >50%
if f_antifake and i + 1 < n:
breakout_bar_range = h[i] - l[i]
if breakout_bar_range > 0:
if c[i] > c[i-1]: # breakout up
retrace = (h[i] - c[i]) / breakout_bar_range
else: # breakout down
retrace = (c[i] - l[i]) / breakout_bar_range
if retrace > 0.6:
skip = True
if skip:
continue
# --- DIREZIONE ---
first_ret = (c[i] - c[i-1]) / c[i-1]
if abs(first_ret) < 0.001:
continue
direction = 1 if first_ret > 0 else -1
# --- EXIT ---
entry = c[i-1]
if f_trail and not np.isnan(atr_14[i]):
# Trailing stop
trail_dist = atr_14[i] * stop_atr_m
best_price = entry
exit_price = c[min(i+hold, n-1)]
for j in range(i, min(i+hold+1, n)):
if direction == 1:
best_price = max(best_price, h[j])
if l[j] <= best_price - trail_dist:
exit_price = best_price - trail_dist
break
else:
best_price = min(best_price, l[j])
if h[j] >= best_price + trail_dist:
exit_price = best_price + trail_dist
break
exit_price = c[j]
else:
exit_price = c[min(i+hold-1, n-1)]
actual = (exit_price - entry) / entry * direction
net = actual * LEVERAGE - FEE_RT * LEVERAGE
capital += capital * 0.15 * net
capital = max(capital, 10)
if capital > peak: peak = capital
dd = (peak - capital) / peak
max_dd = max(max_dd, dd)
year = ts.iloc[i].year
if year not in yearly:
yearly[year] = {"wins": 0, "total": 0, "pnls": []}
yearly[year]["total"] += 1
if actual > 0:
yearly[year]["wins"] += 1
yearly[year]["pnls"].append(net * INITIAL)
all_t = sum(d["total"] for d in yearly.values())
all_w = sum(d["wins"] for d in yearly.values())
if all_t < 30:
continue
acc = all_w / all_t * 100
all_pnls = [p for d in yearly.values() for p in d["pnls"]]
tot_pnl = sum(all_pnls)
# Worst year
worst_y_acc = 100
worst_y = ""
for y, d in yearly.items():
ya = d["wins"]/d["total"]*100 if d["total"] > 0 else 0
if ya < worst_y_acc:
worst_y_acc = ya
worst_y = str(y)
results.append({
"name": name, "trades": all_t, "acc": acc, "pnl": tot_pnl,
"max_dd": max_dd*100, "capital": capital,
"worst": f"{worst_y}({worst_y_acc:.0f}%)",
"yearly": yearly,
})
# Sort by accuracy
results.sort(key=lambda x: x["acc"], reverse=True)
print(f"\n {'Name':.<26s} {'Trades':>7s} {'Acc':>6s} {'PnL€':>9s} {'DD%':>6s} {'Capital':>10s} {'Worst':>12s}")
print(f" {'-'*80}")
for r in results:
tag = "✅✅" if r["acc"] >= 80 else "" if r["acc"] >= 76 else ""
print(f" {r['name']:.<26s} {r['trades']:>7d} {r['acc']:>5.1f}% €{r['pnl']:>+8.0f} {r['max_dd']:>5.1f}% €{r['capital']:>9,.0f} {r['worst']:>12s} {tag}")
# Dettaglio per anno del migliore
if results:
best = results[0]
print(f"\n MIGLIORE: {best['name']}{best['acc']:.1f}% acc")
print(f" {'Anno':>6s} {'Trades':>7s} {'Acc':>6s} {'PnL€':>9s}")
for y in sorted(best["yearly"]):
d = best["yearly"][y]
ya = d["wins"]/d["total"]*100 if d["total"] > 0 else 0
yp = sum(d["pnls"])
tag = " ← CRASH" if y in [2020,2021,2022] else ""
print(f" {y:>6d} {d['total']:>7d} {ya:>5.1f}% €{yp:>+8.0f}{tag}")
return results
# Run su entrambi gli asset e timeframe
all_results = {}
for asset in ["ETH", "BTC"]:
for tf in ["1h", "15m"]:
key = f"{asset}_{tf}"
all_results[key] = run_improved_squeeze(asset, tf)
# Classifica globale
print(f"\n\n{'='*75}")
print(f" CLASSIFICA GLOBALE — TOP 15")
print(f"{'='*75}")
global_list = []
for key, results in all_results.items():
for r in results:
global_list.append({**r, "asset_tf": key})
global_list.sort(key=lambda x: x["acc"], reverse=True)
print(f"\n {'Asset_TF':.<12s} {'Name':.<26s} {'Trades':>6s} {'Acc':>6s} {'PnL€':>9s} {'DD%':>5s} {'Worst':>12s}")
for r in global_list[:15]:
tag = "✅✅" if r["acc"] >= 80 else "" if r["acc"] >= 76 else ""
print(f" {r['asset_tf']:.<12s} {r['name']:.<26s} {r['trades']:>6d} {r['acc']:>5.1f}% €{r['pnl']:>+8.0f} {r['max_dd']:>4.1f}% {r['worst']:>12s} {tag}")
+290
View File
@@ -0,0 +1,290 @@
"""S3-02: Lead-lag multi-asset squeeze.
Quando BTC fa squeeze breakout, ETH/SOL spesso seguono.
Usa il breakout di BTC per anticipare entrata su ETH (e viceversa).
Testa anche correlazione inter-asset per conferma segnale.
"""
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
INITIAL = 1000
LEVERAGE = 3
def keltner_ratio(close, high, low, window=14):
n = len(close)
r = np.full(n, np.nan)
for i in range(window, n):
wc, wh, wl = close[i-window:i], high[i-window:i], low[i-window:i]
ma = np.mean(wc)
bb_std = np.std(wc)
tr = np.maximum(wh-wl, np.maximum(np.abs(wh-np.roll(wc,1)), np.abs(wl-np.roll(wc,1))))
atr = np.mean(tr[1:])
kc = (ma+1.5*atr)-(ma-1.5*atr)
bb = (ma+2*bb_std)-(ma-2*bb_std)
if kc > 0: r[i] = bb/kc
return r
def load_aligned(assets, tf):
"""Carica e allinea dati multi-asset per timestamp."""
dfs = {}
for asset in assets:
try:
if asset == "SOL":
df = pd.read_parquet(f"data/raw/sol_{tf}.parquet")
df["datetime"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
else:
df = load_data(asset, tf)
dfs[asset] = df
except Exception:
pass
if len(dfs) < 2:
return None
# Allinea per timestamp
common_ts = set(dfs[list(dfs.keys())[0]]["timestamp"].values)
for df in dfs.values():
common_ts &= set(df["timestamp"].values)
common_ts = sorted(common_ts)
aligned = {}
for asset, df in dfs.items():
mask = df["timestamp"].isin(common_ts)
aligned[asset] = df[mask].sort_values("timestamp").reset_index(drop=True)
return aligned
def detect_breakouts(close, high, low, volume, kcr, sq_thr=0.8, min_dur=5):
"""Detect squeeze breakout events."""
events = []
in_sq = False
sq_start = 0
for i in range(1, len(close)):
if np.isnan(kcr[i]):
continue
is_sq = kcr[i] < sq_thr
if is_sq and not in_sq:
in_sq = True
sq_start = i
elif not is_sq and in_sq:
in_sq = False
if i - sq_start < min_dur:
continue
first_ret = (close[i] - close[i-1]) / close[i-1] if close[i-1] > 0 else 0
if abs(first_ret) < 0.001:
continue
events.append({
"idx": i,
"duration": i - sq_start,
"direction": 1 if first_ret > 0 else -1,
"first_ret": first_ret,
})
return events
print("=" * 75)
print(" S3-02: LEAD-LAG MULTI-ASSET SQUEEZE")
print("=" * 75)
for tf in ["1h", "15m"]:
aligned = load_aligned(["BTC", "ETH", "SOL"], tf)
if aligned is None:
continue
n = len(aligned["BTC"])
ts = pd.to_datetime(aligned["BTC"]["timestamp"], unit="ms", utc=True)
print(f"\n Timeframe: {tf}, Candles allineate: {n}")
# Calcola squeeze per ogni asset
asset_data = {}
for asset in aligned:
df = aligned[asset]
c, h, l, v = df["close"].values, df["high"].values, df["low"].values, df["volume"].values
kcr = keltner_ratio(c, h, l, 14)
events = detect_breakouts(c, h, l, v, kcr)
asset_data[asset] = {"close": c, "high": h, "low": l, "vol": v, "kcr": kcr, "events": events}
print(f" {asset}: {len(events)} squeeze breakouts")
# ================================================================
# STRATEGIA A: Leader-follower
# Quando BTC fa breakout, entra su ETH/SOL nella stessa direzione
# ================================================================
print(f"\n --- LEADER-FOLLOWER ({tf}) ---")
for leader, follower in [("BTC", "ETH"), ("BTC", "SOL"), ("ETH", "BTC"), ("ETH", "SOL")]:
if leader not in asset_data or follower not in asset_data:
continue
leader_events = asset_data[leader]["events"]
fc = asset_data[follower]["close"]
for hold in [3, 6]:
for delay in [0, 1, 2]:
yearly = {}
for ev in leader_events:
i = ev["idx"] + delay
if i + hold >= n:
continue
# Anti-fakeout su follower
entry = fc[i]
exit_price = fc[min(i + hold, n - 1)]
direction = ev["direction"]
actual = (exit_price - entry) / entry * direction
net = actual * LEVERAGE - FEE_RT * LEVERAGE
year = ts.iloc[min(i, n-1)].year
if year not in yearly:
yearly[year] = {"w": 0, "t": 0, "pnls": []}
yearly[year]["t"] += 1
if actual > 0:
yearly[year]["w"] += 1
yearly[year]["pnls"].append(net * INITIAL)
all_t = sum(d["t"] for d in yearly.values())
all_w = sum(d["w"] for d in yearly.values())
if all_t < 30:
continue
acc = all_w / all_t * 100
pnl = sum(p for d in yearly.values() for p in d["pnls"])
worst_y = min(yearly.items(), key=lambda x: x[1]["w"]/x[1]["t"] if x[1]["t"]>0 else 0)
worst_acc = worst_y[1]["w"]/worst_y[1]["t"]*100 if worst_y[1]["t"]>0 else 0
tag = "" if acc >= 76 else ""
print(f" {leader}{follower} d={delay} h={hold}: trades={all_t:5d} acc={acc:.1f}% pnl=€{pnl:+.0f} worst={worst_y[0]}({worst_acc:.0f}%) {tag}")
# ================================================================
# STRATEGIA B: Consensus multi-asset
# Trade solo quando 2+ asset hanno squeeze breakout nello stesso momento
# ================================================================
print(f"\n --- CONSENSUS MULTI-ASSET ({tf}) ---")
# Build event map: timestamp → list of (asset, direction)
event_map = {}
for asset, data in asset_data.items():
for ev in data["events"]:
idx = ev["idx"]
if idx not in event_map:
event_map[idx] = []
event_map[idx].append((asset, ev["direction"]))
for target in ["BTC", "ETH", "SOL"]:
if target not in asset_data:
continue
tc = asset_data[target]["close"]
for min_consensus in [2, 3]:
for window_bars in [1, 3, 5]:
yearly = {}
daily_done = set()
for idx in sorted(event_map.keys()):
if idx + 6 >= n:
continue
day = ts.iloc[idx].strftime("%Y-%m-%d")
if day in daily_done:
continue
# Count consensus within window
nearby_events = []
for j in range(max(0, idx - window_bars), idx + window_bars + 1):
if j in event_map:
nearby_events.extend(event_map[j])
# Unique assets
unique_assets = set(a for a, d in nearby_events)
if len(unique_assets) < min_consensus:
continue
# Majority direction
dirs = [d for a, d in nearby_events]
majority = 1 if sum(dirs) > 0 else -1
entry = tc[idx]
exit_price = tc[min(idx + 3, n - 1)]
actual = (exit_price - entry) / entry * majority
net = actual * LEVERAGE - FEE_RT * LEVERAGE
year = ts.iloc[idx].year
if year not in yearly:
yearly[year] = {"w": 0, "t": 0, "pnls": []}
yearly[year]["t"] += 1
if actual > 0:
yearly[year]["w"] += 1
yearly[year]["pnls"].append(net * INITIAL)
daily_done.add(day)
all_t = sum(d["t"] for d in yearly.values())
all_w = sum(d["w"] for d in yearly.values())
if all_t < 20:
continue
acc = all_w / all_t * 100
pnl = sum(p for d in yearly.values() for p in d["pnls"])
tag = "" if acc >= 76 else ""
print(f" {target} consensus>={min_consensus} w={window_bars}: trades={all_t:4d} acc={acc:.1f}% pnl=€{pnl:+.0f} {tag}")
# ================================================================
# STRATEGIA C: Correlation-weighted squeeze
# Peso il segnale squeeze in base alla correlazione rolling con BTC
# ================================================================
print(f"\n --- CORRELATION-WEIGHTED ({tf}) ---")
for target in ["ETH", "SOL"]:
if target not in asset_data:
continue
tc = asset_data[target]["close"]
btc_c = asset_data["BTC"]["close"]
# Rolling correlation
corr_window = 48 # 48 bars
rolling_corr = np.full(n, np.nan)
ret_t = np.diff(np.log(np.where(tc == 0, 1e-10, tc)))
ret_b = np.diff(np.log(np.where(btc_c == 0, 1e-10, btc_c)))
for i in range(corr_window, len(ret_t)):
c_val = np.corrcoef(ret_t[i-corr_window:i], ret_b[i-corr_window:i])[0, 1]
rolling_corr[i + 1] = c_val if np.isfinite(c_val) else 0
events = asset_data[target]["events"]
for corr_thr in [0.5, 0.6, 0.7, 0.8]:
yearly = {}
for ev in events:
i = ev["idx"]
if i + 3 >= n or np.isnan(rolling_corr[i]):
continue
# Solo quando correlazione con BTC è alta
if abs(rolling_corr[i]) < corr_thr:
continue
entry = tc[i - 1]
exit_price = tc[min(i + 2, n - 1)]
actual = (exit_price - entry) / entry * ev["direction"]
net = actual * LEVERAGE - FEE_RT * LEVERAGE
year = ts.iloc[i].year
if year not in yearly:
yearly[year] = {"w": 0, "t": 0, "pnls": []}
yearly[year]["t"] += 1
if actual > 0:
yearly[year]["w"] += 1
yearly[year]["pnls"].append(net * INITIAL)
all_t = sum(d["t"] for d in yearly.values())
all_w = sum(d["w"] for d in yearly.values())
if all_t < 20:
continue
acc = all_w / all_t * 100
pnl = sum(p for d in yearly.values() for p in d["pnls"])
tag = "" if acc >= 76 else ""
print(f" {target} corr>={corr_thr}: trades={all_t:4d} acc={acc:.1f}% pnl=€{pnl:+.0f} {tag}")
+256
View File
@@ -0,0 +1,256 @@
"""S3-03: Ultimate Squeeze — combina TUTTI i filtri migliori.
Filtri che funzionano (testati singolarmente):
- Anti-fakeout (+1% acc)
- Long squeeze duration (+1% acc)
- Cross-asset squeeze simultaneo (+0.5%)
- Timing 4-16 UTC (+0.5%)
- Correlation ETH-BTC alta per ETH trades (+1%)
- Volume confirmation al breakout
Nuovi filtri da testare:
- Volume delta: up_volume - down_volume al breakout
- Momentum confirmation: breakout nella direzione del trend 1h
- Volatility regime: skip in regime estremo (RV > 100%)
"""
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
INITIAL = 1000
LEVERAGE = 3
def keltner_ratio(close, high, low, window=14):
n = len(close)
r = np.full(n, np.nan)
for i in range(window, n):
wc, wh, wl = close[i-window:i], high[i-window:i], low[i-window:i]
ma = np.mean(wc)
bb_std = np.std(wc)
tr = np.maximum(wh-wl, np.maximum(np.abs(wh-np.roll(wc,1)), np.abs(wl-np.roll(wc,1))))
atr = np.mean(tr[1:])
kc = (ma+1.5*atr)-(ma-1.5*atr)
bb = (ma+2*bb_std)-(ma-2*bb_std)
if kc > 0: r[i] = bb/kc
return r
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 rv_ann(close, window):
lr = np.diff(np.log(np.where(close == 0, 1e-10, close)))
r = np.full(len(close), np.nan)
for i in range(window, len(lr)):
r[i+1] = np.std(lr[i-window:i]) * np.sqrt(24*365)
return r
def run_ultimate(primary, tf="15m"):
secondary = "ETH" if primary == "BTC" else "BTC"
df = load_data(primary, tf)
c, h, l, v = df["close"].values, df["high"].values, df["low"].values, df["volume"].values
n = len(df)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
df2 = load_data(secondary, tf)
c2, ts2 = df2["close"].values, df2["timestamp"].values
kcr = keltner_ratio(c, h, l, 14)
kcr2 = keltner_ratio(c2, df2["high"].values, df2["low"].values, 14)
ema_50 = ema(c, 50)
rv_48 = rv_ann(c, 48)
# Rolling correlation
ret1 = np.diff(np.log(np.where(c == 0, 1e-10, c)))
ret2 = np.diff(np.log(np.where(c2[:len(c)] == 0, 1e-10, c2[:len(c)])))
min_len = min(len(ret1), len(ret2))
ret1 = ret1[:min_len]
ret2 = ret2[:min_len]
corr = np.full(n, np.nan)
for i in range(48, min_len):
cv = np.corrcoef(ret1[i-48:i], ret2[i-48:i])[0,1]
corr[i+1] = cv if np.isfinite(cv) else 0
# Detect squeezes
events = []
in_sq = False
sq_start = 0
for i in range(15, n):
if np.isnan(kcr[i]): continue
is_sq = kcr[i] < 0.8
if is_sq and not in_sq:
in_sq = True
sq_start = i
elif not is_sq and in_sq:
in_sq = False
dur = i - sq_start
if dur < 5 or i + 6 >= n:
continue
events.append({"idx": i, "dur": dur, "sq_start": sq_start})
print(f"\n{'#'*70}")
print(f" {primary} {tf} — ULTIMATE SQUEEZE ({len(events)} squeeze events)")
print(f"{'#'*70}")
filters_map = {
"antifake": lambda ev, i: not _antifake(c, h, l, i),
"long_sq": lambda ev, i: ev["dur"] >= 10,
"timing": lambda ev, i: 4 <= ts.iloc[i].hour <= 16,
"cross": lambda ev, i: _cross_squeeze(kcr2, i, ts, ts2),
"corr_high": lambda ev, i: not np.isnan(corr[i]) and abs(corr[i]) >= 0.6,
"vol_confirm": lambda ev, i: _vol_confirm(v, i, ev["sq_start"]),
"trend_align": lambda ev, i: _trend_align(c, ema_50, i),
"low_rv": lambda ev, i: not np.isnan(rv_48[i]) and rv_48[i] < 1.5,
}
def _antifake(c, h, l, i):
if i + 1 >= len(c): return False
br = h[i] - l[i]
if br <= 0: return False
if c[i] > c[i-1]:
return (h[i] - c[i]) / br > 0.6
return (c[i] - l[i]) / br > 0.6
def _cross_squeeze(kcr2, i, ts1, ts2_arr):
i2 = np.searchsorted(ts2_arr, ts.values[i].astype("int64") // 10**6)
i2 = min(i2, len(kcr2)-1)
return any(not np.isnan(kcr2[j]) and kcr2[j] < 0.85 for j in range(max(0,i2-10), i2+1))
def _vol_confirm(v, i, sq_start):
avg = np.mean(v[sq_start:i])
return avg > 0 and v[i] > avg * 1.3
def _trend_align(c, ema_val, i):
if np.isnan(ema_val[i]): return True
first_ret = (c[i] - c[i-1]) / c[i-1] if c[i-1] > 0 else 0
if first_ret > 0:
return c[i] > ema_val[i]
return c[i] < ema_val[i]
# Test combinazioni incrementali
combos = [
("BASE", []),
("antifake", ["antifake"]),
("long_sq", ["long_sq"]),
("antifake+long", ["antifake", "long_sq"]),
("antifake+timing", ["antifake", "timing"]),
("antifake+cross", ["antifake", "cross"]),
("antifake+corr", ["antifake", "corr_high"]),
("antifake+vol", ["antifake", "vol_confirm"]),
("antifake+trend", ["antifake", "trend_align"]),
("af+long+timing", ["antifake", "long_sq", "timing"]),
("af+long+cross", ["antifake", "long_sq", "cross"]),
("af+long+corr", ["antifake", "long_sq", "corr_high"]),
("af+long+trend", ["antifake", "long_sq", "trend_align"]),
("af+long+cross+time", ["antifake", "long_sq", "cross", "timing"]),
("af+long+corr+time", ["antifake", "long_sq", "corr_high", "timing"]),
("af+long+corr+trend", ["antifake", "long_sq", "corr_high", "trend_align"]),
("ALL_NO_VOL", ["antifake", "long_sq", "cross", "timing", "corr_high", "trend_align", "low_rv"]),
("ALL", ["antifake", "long_sq", "cross", "timing", "corr_high", "vol_confirm", "trend_align", "low_rv"]),
("BEST_5", ["antifake", "long_sq", "corr_high", "trend_align", "low_rv"]),
]
results = []
for combo_name, filter_names in combos:
yearly = {}
capital = float(INITIAL)
peak = capital
max_dd = 0
for ev in events:
i = ev["idx"]
first_ret = (c[i] - c[i-1]) / c[i-1] if c[i-1] > 0 else 0
if abs(first_ret) < 0.001:
continue
skip = False
for fn in filter_names:
if fn in filters_map and not filters_map[fn](ev, i):
skip = True
break
if skip:
continue
direction = 1 if first_ret > 0 else -1
entry = c[i-1]
exit_price = c[min(i+2, n-1)]
actual = (exit_price - entry) / entry * direction
net = actual * LEVERAGE - FEE_RT * LEVERAGE
capital += capital * 0.15 * net
capital = max(capital, 10)
if capital > peak: peak = capital
dd = (peak - capital) / peak
max_dd = max(max_dd, dd)
year = ts.iloc[i].year
if year not in yearly:
yearly[year] = {"w": 0, "t": 0, "pnls": []}
yearly[year]["t"] += 1
if actual > 0: yearly[year]["w"] += 1
yearly[year]["pnls"].append(net * INITIAL)
all_t = sum(d["t"] for d in yearly.values())
all_w = sum(d["w"] for d in yearly.values())
if all_t < 20: continue
acc = all_w / all_t * 100
pnl = sum(p for d in yearly.values() for p in d["pnls"])
worst = min(yearly.items(), key=lambda x: x[1]["w"]/x[1]["t"] if x[1]["t"]>0 else 0)
wa = worst[1]["w"]/worst[1]["t"]*100 if worst[1]["t"]>0 else 0
results.append({
"name": combo_name, "trades": all_t, "acc": acc, "pnl": pnl,
"dd": max_dd*100, "capital": capital, "worst": f"{worst[0]}({wa:.0f}%)",
"yearly": yearly,
})
results.sort(key=lambda x: x["acc"], reverse=True)
print(f"\n {'Name':.<28s} {'Trades':>6s} {'Acc':>6s} {'PnL€':>9s} {'DD%':>5s} {'Worst':>12s}")
print(f" {'-'*70}")
for r in results[:20]:
tag = "✅✅" if r["acc"] >= 80 else "" if r["acc"] >= 78 else ""
print(f" {r['name']:.<28s} {r['trades']:>6d} {r['acc']:>5.1f}% €{r['pnl']:>+8.0f} {r['dd']:>4.1f}% {r['worst']:>12s} {tag}")
# Dettaglio migliore
if results:
best = results[0]
print(f"\n MIGLIORE: {best['name']}{best['acc']:.1f}% acc, DD {best['dd']:.1f}%")
for y in sorted(best["yearly"]):
d = best["yearly"][y]
ya = d["w"]/d["t"]*100 if d["t"]>0 else 0
tag = " ← CRASH" if y in [2020,2021,2022] else ""
print(f" {y}: {d['t']:4d}t {ya:5.1f}% €{sum(d['pnls']):+.0f}{tag}")
return results
all_r = []
for asset in ["BTC", "ETH"]:
for tf in ["15m", "1h"]:
r = run_ultimate(asset, tf)
for x in r:
all_r.append({**x, "key": f"{asset}_{tf}"})
all_r.sort(key=lambda x: x["acc"], reverse=True)
print(f"\n\n{'='*70}")
print(f" TOP 10 GLOBALE")
print(f"{'='*70}")
for r in all_r[:10]:
tag = "✅✅" if r["acc"] >= 80 else "" if r["acc"] >= 78 else ""
print(f" {r['key']:.<10s} {r['name']:.<28s} {r['trades']:>5d} {r['acc']:>5.1f}% €{r['pnl']:>+8.0f} DD {r['dd']:.1f}% {r['worst']:>12s} {tag}")
+48
View File
@@ -0,0 +1,48 @@
"""ROT01 — Cross-Sectional Momentum Rotation (multi-crypto, long-only), 1d.
UNA strategia che usa l'INTERO paniere di crypto in un solo book: ogni giorno
ordina gli asset per momentum (rendimento sugli ultimi `lookback` giorni) e alloca
il capitale in parti uguali ai `top_k` con momentum positivo; il resto in cash.
Cattura la dispersione tra crypto (gli alt forti corrono molto piu' di BTC nei bull)
senza shortare nulla. Meccanismo distinto da DIP01/TR01 -> vera diversificazione.
Onesto: i pesi a close[i] usano solo rendimenti passati; il rendimento del giorno
i->i+1 e' realizzato con quei pesi. Fee sul turnover. Allineamento per timestamp.
Validazione (netto, fee 0.10% RT, gross 0.45, OOS = ultimo 30%):
lb=60 top2 -> FULL +679% / OOS +44% / DD 53% / 5-7 anni positivi.
Param-insensitive (tutte le lb/k positive) e regge fee fino 0.20% RT (OOS +41%).
Per-anno: 2020+33 2021+181 2022-29 2023+43 2024+59 2025+6 2026-10 (i negativi = bear).
Dettagli in scripts/analysis/honest_rotation.py / honest_final.py.
"""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.honest_rotation import build_panel, simulate_rotation # noqa: E402
from scripts.analysis.honest_lab import available_assets
LOOKBACK, TOP_K, TF = 60, 2, "1d"
def run():
assets = available_assets()
panel = build_panel(assets, TF)
print("=" * 90)
print(f" ROT01 ROTAZIONE cross-sectional momentum | {TF} lb={LOOKBACK} top{TOP_K} | netto fee 0.10% RT")
print("=" * 90)
print(f" Paniere: {list(panel.columns)}")
print(f" Periodo: {panel.index[0].date()} -> {panel.index[-1].date()} ({panel.shape[0]} barre)")
full = simulate_rotation(panel, lookback=LOOKBACK, top_k=TOP_K, fee_rt=0.001)
oos = simulate_rotation(panel, lookback=LOOKBACK, top_k=TOP_K, fee_rt=0.001, oos_frac=0.30)
print(f"\n FULL: {full['ret']:+.0f}% DD {full['dd']:.0f}% turnover {full['turnover']:.0f}")
print(f" OOS : {oos['ret']:+.0f}% DD {oos['dd']:.0f}% ({full['pos_years']}/{full['n_years']} anni positivi)")
print(" Per-anno: " + " ".join(f"{y}:{v:+.0f}%" for y, v in sorted(full["yearly"].items())))
if __name__ == "__main__":
run()
+68
View File
@@ -0,0 +1,68 @@
"""SQ01 — Squeeze Breakout Base.
Strategia strutturale: rileva compressione di volatilità (Bollinger dentro
Keltner Channel) e segue la direzione del breakout al rilascio.
IN:
- OHLCV DataFrame (da load_data)
- Parametri: bb_window (14), sq_threshold (0.8), min_squeeze_dur (5)
OUT:
- Lista di Signal con direzione breakout (+1/-1)
- BacktestResult con equity, yearly breakdown, metriche
Risultati tipici:
BTC 15m: 76.7% acc, 4062 trades, DD 6.7%, €9.32/day
ETH 15m: 76.4% acc, 2948 trades, DD 6.2%, €10.31/day
"""
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
from src.strategies.indicators import keltner_ratio, detect_squeezes
class SqueezeBase(Strategy):
name = "SQ01_squeeze_base"
description = "Squeeze breakout puro — segui direzione al rilascio"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
**params) -> list[Signal]:
c = df["close"].values
h = df["high"].values
l = df["low"].values
n = len(c)
bb_w = params.get("bb_window", 14)
sq_thr = params.get("sq_threshold", 0.8)
min_dur = params.get("min_dur", 5)
kcr = keltner_ratio(c, h, l, bb_w)
events = detect_squeezes(c, h, l, kcr, sq_thr, min_dur)
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
signals.append(Signal(
idx=i,
direction=1 if first_ret > 0 else -1,
entry_price=c[i - 1],
metadata={"dur": ev["dur"], "kcr": ev["kcr_at_release"]},
))
return signals
if __name__ == "__main__":
strategy = SqueezeBase()
strategy.report()
@@ -0,0 +1,87 @@
"""SQ02 — Squeeze Breakout + Anti-Fakeout + Volume Confirmation.
Migliora SQ01 con due filtri:
1. Anti-fakeout: scarta breakout dove la candela ritraccia >60% del range
2. Volume confirm: volume al breakout deve essere >1.3× la media durante squeeze
IN:
- OHLCV DataFrame
- Parametri: bb_window (14), sq_threshold (0.8), retrace_limit (0.6),
vol_multiplier (1.3)
OUT:
- Lista di Signal filtrati
- BacktestResult
Risultati tipici:
BTC 15m: 79.7% acc, 1250 trades, DD 6.5%, €5.23/day — SOLIDO 9/9 anni
ETH 15m: 78.6% acc, 942 trades, DD 3.4%, €4.33/day
BTC 1h: 78.0% acc, 473 trades, DD 3.5%, Sharpe 6.57
"""
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
from src.strategies.indicators import keltner_ratio, detect_squeezes
class SqueezeAntifakeVol(Strategy):
name = "SQ02_antifake_vol"
description = "Squeeze + antifakeout + volume confirmation"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
**params) -> list[Signal]:
c = df["close"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
bb_w = params.get("bb_window", 14)
sq_thr = params.get("sq_threshold", 0.8)
retrace_limit = params.get("retrace_limit", 0.6)
vol_mult = params.get("vol_multiplier", 1.3)
kcr = keltner_ratio(c, h, l, bb_w)
events = detect_squeezes(c, h, l, kcr, sq_thr)
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
br = h[i] - l[i]
if br > 0:
if c[i] > c[i - 1]:
if (h[i] - c[i]) / br > retrace_limit:
continue
else:
if (c[i] - l[i]) / br > retrace_limit:
continue
avg_v = np.mean(v[ev["sq_start"]:i])
if avg_v > 0 and v[i] <= avg_v * vol_mult:
continue
signals.append(Signal(
idx=i,
direction=1 if first_ret > 0 else -1,
entry_price=c[i - 1],
metadata={"dur": ev["dur"], "vol_ratio": v[i] / avg_v if avg_v > 0 else 0},
))
return signals
if __name__ == "__main__":
strategy = SqueezeAntifakeVol()
strategy.report()
@@ -0,0 +1,175 @@
"""SQ03 — Squeeze con filtri selezionabili.
Ogni filtro è opzionale e attivabile via parametro. Di default attiva solo
antifake + long_squeeze (i due filtri con miglior rapporto accuracy/trade).
Esegue tutte le combinazioni utili e classifica.
Filtri disponibili:
- antifake: scarta breakout con retrace >60% (guadagna ~+1% acc)
- long_sq: solo squeeze durata ≥10 barre (+1% acc, dimezza trade)
- timing: solo ore 4-16 UTC (+0.5% acc)
- cross: asset secondario in squeeze nelle ultime 10 barre (+0.5%)
- vol: volume al breakout >1.3× media squeeze (+1% acc)
IN:
- OHLCV DataFrame (primario + secondario per cross-check)
- Parametri: filters (lista), bb_window, sq_threshold
OUT:
- BacktestResult per ogni preset di filtri
Risultati tipici (BTC 15m):
antifake+long: 77.3% acc, 2179 trades
antifake+vol: 79.7% acc, 1250 trades — SOLIDO
ALL_FILTERS: 79.2% acc, 696 trades (restrittivo)
"""
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
from src.data.downloader import load_data
PRESETS = {
"antifake": ["antifake"],
"long_sq": ["long_sq"],
"antifake+long": ["antifake", "long_sq"],
"antifake+vol": ["antifake", "vol"],
"antifake+timing": ["antifake", "timing"],
"long+timing": ["long_sq", "timing"],
"antifake+long+time": ["antifake", "long_sq", "timing"],
"antifake+cross": ["antifake", "cross"],
"ALL_FILTERS": ["antifake", "long_sq", "timing", "cross"],
}
class SqueezeFiltered(Strategy):
name = "SQ03_filtered"
description = "Squeeze + filtri selezionabili (antifake, long, timing, cross, vol)"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
**params) -> list[Signal]:
c = df["close"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
bb_w = params.get("bb_window", 14)
sq_thr = params.get("sq_threshold", 0.8)
filters = params.get("filters", ["antifake", "long_sq"])
asset = params.get("asset", "BTC")
tf = params.get("tf", "15m")
kcr = keltner_ratio(c, h, l, bb_w)
events = detect_squeezes(c, h, l, kcr, sq_thr)
kcr2 = None
ts2 = None
if "cross" in filters:
secondary = "ETH" if asset == "BTC" else "BTC"
df2 = load_data(secondary, tf)
kcr2 = keltner_ratio(df2["close"].values, df2["high"].values,
df2["low"].values, bb_w)
ts2 = df2["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
skip = False
if "antifake" in filters:
br = h[i] - l[i]
if br > 0:
if c[i] > c[i - 1] and (h[i] - c[i]) / br > 0.6:
skip = True
elif c[i] <= c[i - 1] and (c[i] - l[i]) / br > 0.6:
skip = True
if not skip and "long_sq" in filters:
if ev["dur"] < 10:
skip = True
if not skip and "timing" in filters:
hour = ts.iloc[i].hour
if hour < 4 or hour > 16:
skip = True
if not skip and "vol" in filters:
avg_v = np.mean(v[ev["sq_start"]:i])
if avg_v > 0 and v[i] <= avg_v * 1.3:
skip = True
if not skip and "cross" in filters and kcr2 is not None and ts2 is not None:
i2 = np.searchsorted(ts2, ts.values[i].astype("int64") // 10**6)
i2 = min(i2, len(kcr2) - 1)
cross_ok = any(
not np.isnan(kcr2[j]) and kcr2[j] < 0.85
for j in range(max(0, i2 - 10), i2 + 1)
)
if not cross_ok:
skip = True
if skip:
continue
signals.append(Signal(
idx=i,
direction=1 if first_ret > 0 else -1,
entry_price=c[i - 1],
metadata={"dur": ev["dur"], "filters": filters},
))
return signals
def report_all_presets(self, assets=None, timeframes=None, hold=3):
"""Esegue tutti i preset di filtri × asset × tf."""
assets = assets or self.default_assets
timeframes = timeframes or self.default_timeframes
all_results = []
for preset_name, filter_list in PRESETS.items():
for asset in assets:
for tf in timeframes:
r = self.backtest(asset, tf, hold, filters=filter_list)
if r and r.trades >= 20:
r.strategy_name = f"SQ03 {preset_name}"
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 120}")
print(f" SQ03 SQUEEZE FILTRATO — TUTTI I PRESET ({len(all_results)} config)")
print(f" Fee: {self.fee_rt*100:.1f}% RT | Leva: {self.leverage:.0f}x | Pos: {self.position_size*100:.0f}%")
print(f"{'=' * 120}")
print(f" {'Nome':<30s} {'A/T':>7s} {'Trades':>6s} {'Acc':>6s} "
f"{'PnL€':>10s} {'DD%':>6s} {'€/day':>7s} "
f"{'Mkt%':>5s} {'Dur':>5s} {'Worst':>12s} {'Anni':>4s}")
print(f" {'' * 110}")
for r in all_results:
r.print_summary()
if all_results:
print(f"\n MIGLIORE: ", end="")
best = all_results[0]
best.print_yearly()
return all_results
if __name__ == "__main__":
strategy = SqueezeFiltered()
strategy.report_all_presets()
+204
View File
@@ -0,0 +1,204 @@
"""SQ04 — Ultimate Squeeze — combinazione incrementale di tutti i filtri.
Testa combinazioni di filtri (antifake, long_sq, timing, cross-asset,
correlation, volume, trend alignment, volatility regime) e classifica
per accuracy.
IN:
- OHLCV DataFrame (primario + secondario)
- Parametri: bb_window, sq_threshold, lista filtri da attivare
OUT:
- BacktestResult per ogni combinazione di filtri
- Classifica globale
Risultati tipici:
BTC 15m antifake+corr: 81.6% acc (ma concentrato 2018)
BTC 15m antifake+vol: 79.7% acc, 1250 trades — robusto
ETH 1h antifake+corr: 80.7% acc (solo 2018)
"""
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
from src.strategies.indicators import (
keltner_ratio, detect_squeezes, ema, rv_annualized, rolling_correlation,
)
from src.data.downloader import load_data
class SqueezeUltimate(Strategy):
name = "SQ04_ultimate"
description = "Ultimate squeeze — tutti i filtri combinabili"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
FILTER_PRESETS = {
"antifake+vol": ["antifake", "vol_confirm"],
"antifake+corr": ["antifake", "corr_high"],
"af+long+corr+trend": ["antifake", "long_sq", "corr_high", "trend_align"],
"ALL": ["antifake", "long_sq", "cross", "timing", "corr_high",
"vol_confirm", "trend_align", "low_rv"],
}
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
**params) -> list[Signal]:
c = df["close"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
asset = params.get("asset", "BTC")
tf = params.get("tf", "15m")
filters = params.get("filters", ["antifake", "vol_confirm"])
kcr = keltner_ratio(c, h, l, 14)
events = detect_squeezes(c, h, l, kcr)
secondary = "ETH" if asset == "BTC" else "BTC"
df2 = load_data(secondary, tf)
c2 = df2["close"].values
kcr2 = keltner_ratio(c2, df2["high"].values, df2["low"].values, 14)
ts2 = df2["timestamp"].values
ema_50 = ema(c, 50)
rv_48 = rv_annualized(c, 48)
corr = rolling_correlation(c, c2)
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
skip = False
for f in filters:
if f == "antifake":
br = h[i] - l[i]
if br > 0:
if c[i] > c[i-1] and (h[i] - c[i]) / br > 0.6:
skip = True
elif c[i] <= c[i-1] and (c[i] - l[i]) / br > 0.6:
skip = True
elif f == "long_sq":
if ev["dur"] < 10:
skip = True
elif f == "timing":
if ts.iloc[i].hour < 4 or ts.iloc[i].hour > 16:
skip = True
elif f == "cross":
i2 = np.searchsorted(ts2, ts.values[i].astype("int64") // 10**6)
i2 = min(i2, len(kcr2) - 1)
if not any(not np.isnan(kcr2[j]) and kcr2[j] < 0.85
for j in range(max(0, i2 - 10), i2 + 1)):
skip = True
elif f == "corr_high":
if np.isnan(corr[i]) or abs(corr[i]) < 0.6:
skip = True
elif f == "vol_confirm":
avg_v = np.mean(v[ev["sq_start"]:i])
if avg_v > 0 and v[i] <= avg_v * 1.3:
skip = True
elif f == "trend_align":
if not np.isnan(ema_50[i]):
if first_ret > 0 and c[i] < ema_50[i]:
skip = True
elif first_ret < 0 and c[i] > ema_50[i]:
skip = True
elif f == "low_rv":
if not np.isnan(rv_48[i]) and rv_48[i] >= 1.5:
skip = True
if skip:
break
if skip:
continue
signals.append(Signal(
idx=i,
direction=1 if first_ret > 0 else -1,
entry_price=c[i - 1],
metadata={"dur": ev["dur"], "filters": filters},
))
return signals
def backtest(self, asset: str, tf: str, hold: int = 3, **params):
params.setdefault("asset", asset)
params.setdefault("tf", tf)
df = load_data(asset, tf)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
signals = self.generate_signals(df, ts, **params)
# Usa il backtest della base ma passando i segnali già generati
from src.strategies.base import BacktestResult, YearlyStats, TF_MINUTES
c = df["close"].values
n = len(c)
yearly: dict[int, dict] = {}
capital = float(self.initial_capital)
peak = capital
max_dd = 0.0
total_bars = 0
for sig in signals:
i = sig.idx
if i + hold >= n or i < 1:
continue
entry = sig.entry_price
exit_price = c[min(i + hold - 1, n - 1)]
actual = (exit_price - entry) / entry * sig.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 = ts.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=tf, 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 / n * 100,
avg_trade_duration_h=hold * TF_MINUTES.get(tf, 60) / 60,
years_active=len(yearly), yearly=yearly_stats,
)
def report_all_presets(self):
"""Esegue tutte le combinazioni preset × asset × tf."""
all_results = []
for preset_name, filter_list in self.FILTER_PRESETS.items():
for asset in self.default_assets:
for tf in self.default_timeframes:
r = self.backtest(asset, tf, filters=filter_list)
if r and r.trades >= 20:
r.strategy_name = f"SQ04 {preset_name}"
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 120}")
print(f" SQ04 ULTIMATE — TUTTI I PRESET")
print(f"{'=' * 120}")
for r in all_results:
r.print_summary()
return all_results
if __name__ == "__main__":
strategy = SqueezeUltimate()
strategy.report_all_presets()
+110
View File
@@ -0,0 +1,110 @@
"""Analisi baseline: distribuzione pattern frattali e prima strategia naive."""
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
from src.fractal.patterns import encode_candles, find_patterns, pattern_frequency
from src.backtest.engine import run_backtest, BacktestResult
print("=" * 60)
print(" ANALISI BASELINE — BTC 1H")
print("=" * 60)
df = load_data("BTC", "1h")
print(f"\nDati: {len(df)} candele [{df['datetime'].iloc[0]}{df['datetime'].iloc[-1]}]")
# 1. Distribuzione pattern
print("\n--- DISTRIBUZIONE PATTERN (3-6 candele) ---")
candle_types = encode_candles(df)
unique, counts = np.unique(candle_types, return_counts=True)
type_map = {-1: "DOWN", 0: "DOJI", 1: "UP"}
for t, c in zip(unique, counts):
print(f" {type_map[t]}: {c} ({c/len(df)*100:.1f}%)")
patterns = find_patterns(df, min_len=3, max_len=6)
freq = pattern_frequency(patterns)
print(f"\nPattern unici: {len(freq)}")
print(f"\nTop 20 pattern più frequenti:")
print(freq.head(20).to_string(index=False))
# 2. Analisi predittiva: dopo ogni pattern, il prezzo sale o scende?
print("\n\n--- ANALISI PREDITTIVA PER PATTERN ---")
print("Per ogni pattern: % volte che il prezzo sale nelle N candele successive")
LOOKAHEAD = [1, 3, 6, 12, 24]
top_patterns = freq.head(30)["pattern"].tolist()
results = []
for code in top_patterns:
matching = [p for p in patterns if p.code == code]
if len(matching) < 50:
continue
row = {"pattern": code, "count": len(matching)}
for ahead in LOOKAHEAD:
ups = 0
valid = 0
for p in matching:
future_idx = p.end_idx + ahead
if future_idx >= len(df):
continue
valid += 1
if df["close"].iloc[future_idx] > df["close"].iloc[p.end_idx - 1]:
ups += 1
if valid > 0:
row[f"up_{ahead}h"] = round(ups / valid * 100, 1)
else:
row[f"up_{ahead}h"] = None
results.append(row)
pred_df = pd.DataFrame(results)
print(pred_df.to_string(index=False))
# 3. Strategia naive: compra quando il pattern più bullish si presenta
print("\n\n--- STRATEGIA 1: PATTERN BULLISH NAIVE ---")
# Trova pattern con up_24h > 55%
bullish_patterns = pred_df[pred_df["up_24h"] > 55]["pattern"].tolist()
bearish_patterns = pred_df[pred_df["up_24h"] < 45]["pattern"].tolist()
print(f"Pattern bullish (>55% up in 24h): {len(bullish_patterns)}")
print(f"Pattern bearish (<45% up in 24h): {len(bearish_patterns)}")
# Genera segnali
signals = pd.Series(0, index=df.index)
all_patterns = find_patterns(df, min_len=3, max_len=6)
for p in all_patterns:
if p.code in bullish_patterns:
signals.iloc[p.end_idx - 1] = 1
elif p.code in bearish_patterns:
if signals.iloc[p.end_idx - 1] == 0:
signals.iloc[p.end_idx - 1] = -1
# Train/test split: 70/30
split_idx = int(len(df) * 0.7)
train_df = df.iloc[:split_idx].reset_index(drop=True)
test_df = df.iloc[split_idx:].reset_index(drop=True)
train_signals = signals.iloc[:split_idx].reset_index(drop=True)
test_signals = signals.iloc[split_idx:].reset_index(drop=True)
train_result = run_backtest(train_df, train_signals, initial_capital=1000, fee_pct=0.001)
test_result = run_backtest(test_df, test_signals, initial_capital=1000, fee_pct=0.001)
print("\nRISULTATI TRAIN (70%):")
for k, v in train_result.summary().items():
print(f" {k}: {v}")
print("\nRISULTATI TEST (30%):")
for k, v in test_result.summary().items():
print(f" {k}: {v}")
# 4. Buy & Hold come benchmark
print("\n\n--- BENCHMARK: BUY & HOLD ---")
bh_signals = pd.Series(0, index=test_df.index)
bh_signals.iloc[0] = 1 # Compra al primo candle
bh_result = run_backtest(test_df, bh_signals, initial_capital=1000, fee_pct=0.001, max_hold_candles=len(test_df))
print("Buy & Hold (test period):")
for k, v in bh_result.summary().items():
print(f" {k}: {v}")
+110
View File
@@ -0,0 +1,110 @@
"""Strategia 2: DTW pattern matching.
Idea: per ogni finestra di N candele, cerca le K finestre più simili nel passato
via DTW sui prezzi normalizzati. Se la maggioranza delle match passate è salita
dopo, vai long. Se è scesa, vai short.
"""
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
from src.fractal.similarity import dtw_distance
from src.fractal.patterns import normalize_pattern_window
from src.backtest.engine import run_backtest
print("=" * 60)
print(" STRATEGIA 2: DTW PATTERN MATCHING — BTC 1H")
print("=" * 60)
df = load_data("BTC", "1h")
close = df["close"].values
WINDOW = 12
LOOKAHEAD = 6
K_NEIGHBORS = 20
LOOKBACK = 2000
THRESHOLD = 0.65
split_idx = int(len(df) * 0.7)
def normalize_window(arr: np.ndarray) -> np.ndarray:
mn, mx = arr.min(), arr.max()
if mx - mn == 0:
return np.zeros_like(arr)
return (arr - mn) / (mx - mn)
def compute_returns(close_arr: np.ndarray, idx: int, ahead: int) -> float:
if idx + ahead >= len(close_arr):
return 0.0
return (close_arr[idx + ahead] - close_arr[idx]) / close_arr[idx]
print(f"\nParametri: window={WINDOW}, lookahead={LOOKAHEAD}, K={K_NEIGHBORS}")
print(f"Lookback: {LOOKBACK} candele, threshold: {THRESHOLD}")
print(f"Train: 0→{split_idx}, Test: {split_idx}{len(df)}")
signals = pd.Series(0, index=df.index)
accuracies = []
step = 6
test_range = range(split_idx, len(df) - LOOKAHEAD, step)
total_steps = len(list(test_range))
print(f"\nValutazione: {total_steps} punti (step={step})...")
for count, i in enumerate(test_range):
if count % 500 == 0:
print(f" Progresso: {count}/{total_steps} ({count/total_steps*100:.0f}%)")
current = normalize_window(close[i - WINDOW : i])
search_start = max(WINDOW, i - LOOKBACK)
search_end = i - LOOKAHEAD
if search_end - search_start < K_NEIGHBORS:
continue
distances = []
for j in range(search_start, search_end):
candidate = normalize_window(close[j - WINDOW : j])
if len(candidate) != len(current):
continue
d = dtw_distance(current, candidate)
future_ret = compute_returns(close, j, LOOKAHEAD)
distances.append((d, future_ret))
if len(distances) < K_NEIGHBORS:
continue
distances.sort(key=lambda x: x[0])
top_k = distances[:K_NEIGHBORS]
up_count = sum(1 for _, ret in top_k if ret > 0)
up_ratio = up_count / K_NEIGHBORS
if up_ratio >= THRESHOLD:
signals.iloc[i] = 1
elif up_ratio <= (1 - THRESHOLD):
signals.iloc[i] = -1
actual_ret = compute_returns(close, i, LOOKAHEAD)
predicted_up = up_ratio >= THRESHOLD
predicted_down = up_ratio <= (1 - THRESHOLD)
if predicted_up:
accuracies.append(1 if actual_ret > 0 else 0)
elif predicted_down:
accuracies.append(1 if actual_ret < 0 else 0)
print(f"\nSegnali generati: {(signals != 0).sum()}")
print(f" Long: {(signals == 1).sum()}, Short: {(signals == -1).sum()}")
if accuracies:
print(f"Accuratezza direzione: {np.mean(accuracies)*100:.1f}% su {len(accuracies)} segnali")
test_df = df.iloc[split_idx:].reset_index(drop=True)
test_signals = signals.iloc[split_idx:].reset_index(drop=True)
result = run_backtest(test_df, test_signals, initial_capital=1000, fee_pct=0.001, max_hold_candles=LOOKAHEAD)
print("\nRISULTATI TEST:")
for k, v in result.summary().items():
print(f" {k}: {v}")
+134
View File
@@ -0,0 +1,134 @@
"""Strategia 3: Fourier decomposition e proiezione.
Ispirata al paper Pythagoras Trading Prediction.
Idea: scomponi il prezzo in componenti sinusoidali via FFT,
ricostruisci con le N componenti più forti, proietta nel futuro.
"""
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
from src.backtest.engine import run_backtest
print("=" * 60)
print(" STRATEGIA 3: FOURIER PROJECTION — BTC 1H")
print("=" * 60)
df = load_data("BTC", "1h")
close = df["close"].values
n_total = len(close)
WINDOW = 588 # dal paper: 588 candele per l'indicatore H-C
N_COMPONENTS = 25 # dal paper: 25 linee verticali
LOOKAHEAD = 6
STEP = 6
split_idx = int(n_total * 0.7)
def fourier_project(series: np.ndarray, n_components: int, ahead: int) -> np.ndarray:
"""Ricostruisci serie con top-N componenti Fourier e proietta avanti."""
n = len(series)
detrended = series - np.linspace(series[0], series[-1], n)
fft_vals = np.fft.fft(detrended)
freqs = np.fft.fftfreq(n)
magnitudes = np.abs(fft_vals)
magnitudes[0] = 0
top_indices = np.argsort(magnitudes)[-n_components * 2:]
fft_filtered = np.zeros_like(fft_vals)
fft_filtered[top_indices] = fft_vals[top_indices]
t_extended = np.arange(n + ahead)
reconstruction = np.zeros(n + ahead)
for idx in top_indices:
amp = np.abs(fft_vals[idx]) / n
phase = np.angle(fft_vals[idx])
freq = freqs[idx]
reconstruction += amp * np.cos(2 * np.pi * freq * t_extended / 1 + phase)
trend_slope = (series[-1] - series[0]) / n
trend_extended = series[0] + trend_slope * t_extended
reconstruction += trend_extended
return reconstruction
print(f"\nParametri: window={WINDOW}, components={N_COMPONENTS}, lookahead={LOOKAHEAD}")
print(f"Train: 0→{split_idx}, Test: {split_idx}{n_total}")
signals = pd.Series(0, index=df.index)
accuracies = []
test_range = range(max(split_idx, WINDOW), n_total - LOOKAHEAD, STEP)
total_steps = len(list(test_range))
print(f"Valutazione: {total_steps} punti (step={STEP})...")
for count, i in enumerate(test_range):
if count % 500 == 0:
print(f" Progresso: {count}/{total_steps} ({count/total_steps*100:.0f}%)")
window_data = close[i - WINDOW : i]
projected = fourier_project(window_data, N_COMPONENTS, LOOKAHEAD)
current_price = close[i - 1]
projected_price = projected[-1]
change_pct = (projected_price - current_price) / current_price
if change_pct > 0.005:
signals.iloc[i] = 1
elif change_pct < -0.005:
signals.iloc[i] = -1
actual_ret = (close[i + LOOKAHEAD - 1] - current_price) / current_price
if signals.iloc[i] == 1:
accuracies.append(1 if actual_ret > 0 else 0)
elif signals.iloc[i] == -1:
accuracies.append(1 if actual_ret < 0 else 0)
print(f"\nSegnali generati: {(signals != 0).sum()}")
print(f" Long: {(signals == 1).sum()}, Short: {(signals == -1).sum()}")
if accuracies:
print(f"Accuratezza direzione: {np.mean(accuracies)*100:.1f}% su {len(accuracies)} segnali")
test_df = df.iloc[split_idx:].reset_index(drop=True)
test_signals = signals.iloc[split_idx:].reset_index(drop=True)
result = run_backtest(test_df, test_signals, initial_capital=1000, fee_pct=0.001, max_hold_candles=LOOKAHEAD)
print("\nRISULTATI TEST:")
for k, v in result.summary().items():
print(f" {k}: {v}")
# Varianti con parametri diversi
print("\n\n--- VARIANTI PARAMETRI ---")
for n_comp in [5, 10, 15, 25, 50]:
for window in [144, 288, 588]:
sigs = pd.Series(0, index=df.index)
accs = []
test_r = range(max(split_idx, window), n_total - LOOKAHEAD, STEP)
for i in test_r:
w = close[i - window : i]
proj = fourier_project(w, n_comp, LOOKAHEAD)
cp = close[i - 1]
pp = proj[-1]
ch = (pp - cp) / cp
if ch > 0.005:
sigs.iloc[i] = 1
elif ch < -0.005:
sigs.iloc[i] = -1
ar = (close[i + LOOKAHEAD - 1] - cp) / cp
if sigs.iloc[i] == 1:
accs.append(1 if ar > 0 else 0)
elif sigs.iloc[i] == -1:
accs.append(1 if ar < 0 else 0)
if not accs:
continue
t_sigs = sigs.iloc[split_idx:].reset_index(drop=True)
res = run_backtest(test_df, t_sigs, initial_capital=1000, fee_pct=0.001, max_hold_candles=LOOKAHEAD)
acc = np.mean(accs) * 100
print(f" W={window:3d} N={n_comp:2d} → acc={acc:.1f}% trades={res.total_trades} ret={res.total_return*100:+.1f}% sharpe={res.sharpe_ratio:.2f}")
+231
View File
@@ -0,0 +1,231 @@
"""Strategia 4: Regime-aware fractal ML.
Combina:
1. Hurst exponent per regime detection (trend vs mean-revert vs random)
2. Feature engineering da indicatori frattali
3. RandomForest per predizione direzione
4. Trade filtering aggressivo (solo alta confidenza)
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.metrics import accuracy_score, classification_report
from src.data.downloader import load_data
from src.fractal.indicators import (
hurst_exponent,
fractal_dimension_higuchi,
self_similarity_score,
volatility_ratio,
)
from src.fractal.patterns import encode_candles, extract_body_ratios, extract_shadow_ratios
from src.backtest.engine import run_backtest
print("=" * 60)
print(" STRATEGIA 4: REGIME-AWARE FRACTAL ML — BTC 1H")
print("=" * 60)
df = load_data("BTC", "1h")
close = df["close"].values
n = len(close)
LOOKBACK = 48
LOOKAHEAD = 6
MIN_CONFIDENCE = 0.60
print(f"\nDati: {n} candele")
print(f"Lookback: {LOOKBACK}, Lookahead: {LOOKAHEAD}")
# --- Feature engineering ---
print("\nCalcolo features...")
features_list = []
labels = []
indices = []
returns = np.diff(np.log(np.where(close == 0, 1e-10, close)))
candle_types = encode_candles(df)
body_ratios = extract_body_ratios(df)
shadow_ratios = extract_shadow_ratios(df)
for i in range(LOOKBACK, n - LOOKAHEAD, 3):
if i % 5000 == 0:
print(f" Feature extraction: {i}/{n}")
window = close[i - LOOKBACK : i]
ret_window = returns[i - LOOKBACK : i - 1]
if len(ret_window) < 10:
continue
h = hurst_exponent(ret_window, max_lag=min(len(ret_window) // 4, 20))
fd = fractal_dimension_higuchi(ret_window, k_max=min(8, len(ret_window) // 4))
larger_window = close[max(0, i - LOOKBACK * 6) : i]
ss = self_similarity_score(larger_window, min(LOOKBACK, len(larger_window)))
vr = volatility_ratio(window, fast=12, slow=LOOKBACK)
# Candle pattern features
ct = candle_types[i - 6 : i]
br = body_ratios[i - 6 : i]
sr = shadow_ratios[i - 6 : i]
recent_returns = ret_window[-12:]
momentum_short = np.sum(recent_returns[-3:])
momentum_mid = np.sum(recent_returns[-6:])
momentum_long = np.sum(recent_returns)
vol_short = np.std(recent_returns[-6:]) if len(recent_returns) >= 6 else 0
vol_long = np.std(ret_window) if len(ret_window) > 0 else 0
volume_window = df["volume"].values[i - 12 : i]
vol_avg = np.mean(volume_window) if len(volume_window) > 0 else 0
vol_last = df["volume"].values[i - 1] if i > 0 else 0
vol_ratio = vol_last / vol_avg if vol_avg > 0 else 1.0
up_count_6 = np.sum(ct[-6:] == 1) / 6
down_count_6 = np.sum(ct[-6:] == -1) / 6
features = [
h, # Hurst exponent
fd, # Fractal dimension
ss, # Self-similarity
vr, # Volatility ratio
momentum_short, # 3-candle momentum
momentum_mid, # 6-candle momentum
momentum_long, # Full window momentum
vol_short, # Short-term volatility
vol_long, # Long-term volatility
vol_ratio, # Volume spike ratio
up_count_6, # Bullish ratio (last 6)
down_count_6, # Bearish ratio (last 6)
np.mean(br[-6:]), # Avg body ratio
np.mean(sr[-6:]), # Avg shadow ratio
np.mean(br[-3:]), # Avg body ratio (last 3)
np.std(br[-6:]), # Body ratio std
close[i - 1] / np.mean(window), # Price vs MA
]
# Label: 1 if price goes up in next LOOKAHEAD candles, 0 otherwise
future_ret = (close[i + LOOKAHEAD - 1] - close[i - 1]) / close[i - 1]
label = 1 if future_ret > 0.002 else (0 if future_ret > -0.002 else -1)
features_list.append(features)
labels.append(label)
indices.append(i)
X = np.array(features_list)
y = np.array(labels)
idx_arr = np.array(indices)
print(f"\nDataset: {len(X)} samples")
print(f"Label distribution: up={np.sum(y==1)}, flat={np.sum(y==0)}, down={np.sum(y==-1)}")
# Train/test split cronologico
split_point = int(len(X) * 0.7)
X_train, X_test = X[:split_point], X[split_point:]
y_train, y_test = y[:split_point], y[split_point:]
idx_train, idx_test = idx_arr[:split_point], idx_arr[split_point:]
# Handle NaN/Inf
X_train = np.nan_to_num(X_train, nan=0.0, posinf=1e6, neginf=-1e6)
X_test = np.nan_to_num(X_test, nan=0.0, posinf=1e6, neginf=-1e6)
# --- Modelli ---
print("\n--- TRAINING ---")
models = {
"RandomForest": RandomForestClassifier(
n_estimators=200, max_depth=8, min_samples_leaf=20,
class_weight="balanced", random_state=42, n_jobs=-1,
),
"GradientBoosting": GradientBoostingClassifier(
n_estimators=200, max_depth=5, min_samples_leaf=20,
learning_rate=0.05, random_state=42,
),
}
for name, model in models.items():
print(f"\n{'='*40}")
print(f" {name}")
print(f"{'='*40}")
model.fit(X_train, y_train)
# Feature importance
if hasattr(model, "feature_importances_"):
feat_names = [
"hurst", "fractal_dim", "self_sim", "vol_ratio",
"mom_3", "mom_6", "mom_full", "vol_short", "vol_long",
"vol_spike", "up_ratio", "down_ratio", "body_avg",
"shadow_avg", "body_3", "body_std", "price_vs_ma"
]
imp = model.feature_importances_
sorted_idx = np.argsort(imp)[::-1]
print("\nFeature importance (top 10):")
for j in sorted_idx[:10]:
print(f" {feat_names[j]:15s}: {imp[j]:.4f}")
# Prediction con probabilità
y_pred = model.predict(X_test)
proba = model.predict_proba(X_test)
print(f"\nAccuracy: {accuracy_score(y_test, y_pred)*100:.1f}%")
print(classification_report(y_test, y_pred, target_names=["down", "flat", "up"], zero_division=0))
# Genera segnali filtrati per confidenza
signals = pd.Series(0, index=df.index)
accuracies_filtered = []
classes = model.classes_
up_class_idx = list(classes).index(1) if 1 in classes else -1
down_class_idx = list(classes).index(-1) if -1 in classes else -1
for k, i in enumerate(idx_test):
p = proba[k]
if up_class_idx >= 0 and p[up_class_idx] >= MIN_CONFIDENCE:
signals.iloc[i] = 1
actual = (close[i + LOOKAHEAD - 1] - close[i - 1]) / close[i - 1]
accuracies_filtered.append(1 if actual > 0 else 0)
elif down_class_idx >= 0 and p[down_class_idx] >= MIN_CONFIDENCE:
signals.iloc[i] = -1
actual = (close[i + LOOKAHEAD - 1] - close[i - 1]) / close[i - 1]
accuracies_filtered.append(1 if actual < 0 else 0)
n_signals = (signals != 0).sum()
print(f"\nSegnali filtrati (conf>={MIN_CONFIDENCE}): {n_signals}")
if accuracies_filtered:
print(f"Accuratezza filtrata: {np.mean(accuracies_filtered)*100:.1f}%")
# Backtest
split_idx = int(len(df) * 0.7)
test_df = df.iloc[split_idx:].reset_index(drop=True)
test_signals = signals.iloc[split_idx:].reset_index(drop=True)
result = run_backtest(test_df, test_signals, initial_capital=1000, fee_pct=0.001, max_hold_candles=LOOKAHEAD)
print(f"\nBACKTEST:")
for kk, v in result.summary().items():
print(f" {kk}: {v}")
# Prova con soglie diverse
print(f"\n Varianti soglia:")
for threshold in [0.55, 0.60, 0.65, 0.70, 0.75, 0.80]:
sigs = pd.Series(0, index=df.index)
accs = []
for k, i in enumerate(idx_test):
p = proba[k]
if up_class_idx >= 0 and p[up_class_idx] >= threshold:
sigs.iloc[i] = 1
actual = (close[i + LOOKAHEAD - 1] - close[i - 1]) / close[i - 1]
accs.append(1 if actual > 0 else 0)
elif down_class_idx >= 0 and p[down_class_idx] >= threshold:
sigs.iloc[i] = -1
actual = (close[i + LOOKAHEAD - 1] - close[i - 1]) / close[i - 1]
accs.append(1 if actual < 0 else 0)
t_sigs = sigs.iloc[split_idx:].reset_index(drop=True)
res = run_backtest(test_df, t_sigs, initial_capital=1000, fee_pct=0.001, max_hold_candles=LOOKAHEAD)
acc = np.mean(accs) * 100 if accs else 0
print(f" thr={threshold:.2f}: signals={len(accs):4d} acc={acc:.1f}% ret={res.total_return*100:+.1f}% wr={res.win_rate*100:.0f}% sharpe={res.sharpe_ratio:.2f}")
+202
View File
@@ -0,0 +1,202 @@
"""Strategia 5: Enhanced fractal features + binary classification + position management.
Miglioramenti rispetto a #4:
- Binary classification (up vs down, ignora flat)
- Feature engineering esteso: multi-window fractal indicators
- Migliore filtraggio segnali
- Position sizing basato su confidenza
- Trailing stop
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.metrics import accuracy_score
from src.data.downloader import load_data
from src.fractal.indicators import (
hurst_exponent,
fractal_dimension_higuchi,
self_similarity_score,
volatility_ratio,
)
from src.fractal.patterns import encode_candles, extract_body_ratios, extract_shadow_ratios
print("=" * 60)
print(" STRATEGIA 5: ENHANCED FRACTAL — BTC + ETH 1H")
print("=" * 60)
LOOKAHEADS = [3, 6, 12]
MIN_RETURN = 0.003 # 0.3% threshold for "up" label
for asset in ["BTC", "ETH"]:
for LOOKAHEAD in LOOKAHEADS:
print(f"\n{'#'*60}")
print(f" {asset} 1H — LOOKAHEAD={LOOKAHEAD}")
print(f"{'#'*60}")
df = load_data(asset, "1h")
close = df["close"].values
volume = df["volume"].values
n = len(close)
log_close = np.log(np.where(close == 0, 1e-10, close))
returns = np.diff(log_close)
candle_types = encode_candles(df)
body_ratios = extract_body_ratios(df)
shadow_ratios = extract_shadow_ratios(df)
WINDOWS = [24, 48, 96, 192]
features_list = []
labels = []
indices = []
max_window = max(WINDOWS) + 50
for i in range(max_window, n - LOOKAHEAD, 2):
feats = []
for w in WINDOWS:
ret_w = returns[i - w : i - 1]
close_w = close[i - w : i]
h = hurst_exponent(ret_w, max_lag=min(len(ret_w) // 4, 20))
fd = fractal_dimension_higuchi(ret_w, k_max=min(6, len(ret_w) // 4))
vr = volatility_ratio(close_w, fast=min(12, w // 4), slow=w)
mom = np.sum(ret_w)
vol = np.std(ret_w)
skew = float(pd.Series(ret_w).skew()) if len(ret_w) > 2 else 0
kurt = float(pd.Series(ret_w).kurtosis()) if len(ret_w) > 3 else 0
ma = np.mean(close_w)
price_vs_ma = close[i - 1] / ma if ma > 0 else 1
# Autocorrelation lag-1
if len(ret_w) > 1 and np.std(ret_w) > 0:
ac1 = np.corrcoef(ret_w[:-1], ret_w[1:])[0, 1]
if not np.isfinite(ac1):
ac1 = 0
else:
ac1 = 0
feats.extend([h, fd, vr, mom, vol, skew, kurt, price_vs_ma, ac1])
# Self-similarity multi-scale
large_window = close[max(0, i - 192 * 4) : i]
ss = self_similarity_score(large_window, 48)
feats.append(ss)
# Candle pattern features (last 12 candles)
ct = candle_types[i - 12 : i]
br = body_ratios[i - 12 : i]
sr = shadow_ratios[i - 12 : i]
feats.extend([
np.mean(ct[-3:]),
np.mean(ct[-6:]),
np.mean(ct[-12:]),
np.std(br[-6:]),
np.mean(br[-3:]),
np.mean(sr[-6:]),
np.max(br[-6:]),
np.min(br[-6:]),
])
# Volume features
vol_w = volume[i - 24 : i]
if np.mean(vol_w) > 0:
feats.append(volume[i - 1] / np.mean(vol_w))
feats.append(np.std(vol_w) / np.mean(vol_w))
else:
feats.extend([1.0, 0.0])
# Range/ATR proxy
h_arr = df["high"].values[i - 14 : i]
l_arr = df["low"].values[i - 14 : i]
c_arr = close[i - 14 : i]
tr = np.maximum(h_arr - l_arr, np.maximum(np.abs(h_arr - np.roll(c_arr, 1)), np.abs(l_arr - np.roll(c_arr, 1))))
atr = np.mean(tr[1:])
feats.append(atr / close[i - 1] if close[i - 1] > 0 else 0)
# Label
future_ret = (close[i + LOOKAHEAD - 1] - close[i - 1]) / close[i - 1]
if abs(future_ret) < MIN_RETURN:
continue # skip flat zones
label = 1 if future_ret > 0 else 0
features_list.append(feats)
labels.append(label)
indices.append(i)
X = np.array(features_list)
y = np.array(labels)
idx_arr = np.array(indices)
X = np.nan_to_num(X, nan=0.0, posinf=1e6, neginf=-1e6)
# Split
split = int(len(X) * 0.7)
X_train, X_test = X[:split], X[split:]
y_train, y_test = y[:split], y[split:]
idx_test = idx_arr[split:]
print(f"Samples: {len(X)} (train={split}, test={len(X)-split})")
print(f"Label balance: up={np.mean(y)*100:.1f}%")
# Train
model = GradientBoostingClassifier(
n_estimators=300, max_depth=5, min_samples_leaf=30,
learning_rate=0.03, subsample=0.8, random_state=42,
)
model.fit(X_train, y_train)
y_pred = model.predict(X_test)
proba = model.predict_proba(X_test)
base_acc = accuracy_score(y_test, y_pred)
print(f"Base accuracy: {base_acc*100:.1f}%")
# Threshold sweep
print(f"\n Threshold sweep:")
for thr in [0.50, 0.55, 0.60, 0.65, 0.70, 0.75, 0.80]:
up_idx = model.classes_.tolist().index(1)
sigs = []
accs = []
for k in range(len(X_test)):
p_up = proba[k][up_idx]
i = idx_test[k]
actual = (close[i + LOOKAHEAD - 1] - close[i - 1]) / close[i - 1]
if p_up >= thr:
sigs.append(("long", i))
accs.append(1 if actual > 0 else 0)
elif p_up <= (1 - thr):
sigs.append(("short", i))
accs.append(1 if actual < 0 else 0)
if not accs:
print(f" thr={thr:.2f}: no signals")
continue
acc = np.mean(accs) * 100
# Simple PnL estimate
pnl = 0
capital = 1000
for direction, i in sigs:
entry = close[i - 1]
exit_ = close[i + LOOKAHEAD - 1]
if direction == "long":
ret = (exit_ - entry) / entry
else:
ret = (entry - exit_) / entry
ret -= 0.002 # fees round-trip
pnl += capital * ret * 0.5 # 50% per trade
capital += capital * ret * 0.5
total_ret = (capital - 1000) / 1000 * 100
trades_per_year = len(sigs) / ((n - max_window) / (24 * 365))
print(f" thr={thr:.2f}: signals={len(sigs):5d} acc={acc:.1f}% ret={total_ret:+.1f}% trades/yr={trades_per_year:.0f}")
+201
View File
@@ -0,0 +1,201 @@
"""Strategia 6: Structural Pattern Matching con DTW veloce.
Idea diversa: invece di ML generico, cerca nel passato le finestre OHLC
più simili alla finestra corrente usando una versione veloce (reduced DTW).
Vota sulla direzione basandosi sui K-nearest neighbors nel passato.
Usa features normalizzate (non DTW puro sul prezzo che è lento).
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from sklearn.neighbors import KNeighborsClassifier
from sklearn.preprocessing import StandardScaler
from src.data.downloader import load_data
from src.fractal.patterns import normalize_pattern_window
print("=" * 60)
print(" STRATEGIA 6: STRUCTURAL PATTERN KNN — BTC 1H")
print("=" * 60)
df = load_data("BTC", "1h")
close = df["close"].values
n = len(close)
WINDOW = 24
LOOKAHEAD = 6
MIN_RETURN = 0.003
def extract_structural_features(df: pd.DataFrame, idx: int, window: int) -> np.ndarray | None:
"""Extract normalized structural features from OHLC window."""
if idx < window:
return None
o = df["open"].values[idx - window : idx]
h = df["high"].values[idx - window : idx]
l = df["low"].values[idx - window : idx]
c = df["close"].values[idx - window : idx]
v = df["volume"].values[idx - window : idx]
# Normalize price to [0,1]
all_prices = np.concatenate([o, h, l, c])
mn, mx = all_prices.min(), all_prices.max()
if mx - mn == 0:
return None
o_n = (o - mn) / (mx - mn)
h_n = (h - mn) / (mx - mn)
l_n = (l - mn) / (mx - mn)
c_n = (c - mn) / (mx - mn)
# Body and shadow ratios (already normalized)
total = h - l
total = np.where(total == 0, 1e-10, total)
body = np.abs(c - o) / total
upper_shadow = (h - np.maximum(o, c)) / total
lower_shadow = (np.minimum(o, c) - l) / total
direction = np.sign(c - o)
# Returns
log_c = np.log(np.where(c == 0, 1e-10, c))
returns = np.diff(log_c)
# Volume profile (normalized)
v_mean = np.mean(v)
v_n = v / v_mean if v_mean > 0 else np.ones_like(v)
# Downsample to fixed-size feature vector
# Take every N-th candle if window is large
step = max(1, window // 12)
sampled_idx = np.arange(0, window, step)[:12]
features = np.concatenate([
c_n[sampled_idx], # 12: normalized close
body[sampled_idx], # 12: body ratios
direction[sampled_idx], # 12: direction
upper_shadow[sampled_idx], # 12: upper shadow
lower_shadow[sampled_idx], # 12: lower shadow
v_n[sampled_idx], # 12: volume profile
[np.mean(returns), np.std(returns), np.sum(returns)], # 3: return stats
[np.mean(body), np.std(body)], # 2: body stats
])
return features
print("Extracting features...")
features_all = []
labels_all = []
indices_all = []
for i in range(WINDOW, n - LOOKAHEAD, 1):
feats = extract_structural_features(df, i, WINDOW)
if feats is None:
continue
future_ret = (close[i + LOOKAHEAD - 1] - close[i - 1]) / close[i - 1]
if abs(future_ret) < MIN_RETURN:
continue
features_all.append(feats)
labels_all.append(1 if future_ret > 0 else 0)
indices_all.append(i)
X = np.array(features_all)
y = np.array(labels_all)
idx_arr = np.array(indices_all)
X = np.nan_to_num(X, nan=0.0, posinf=1e6, neginf=-1e6)
split = int(len(X) * 0.7)
X_train, X_test = X[:split], X[split:]
y_train, y_test = y[:split], y[split:]
idx_test = idx_arr[split:]
print(f"Samples: {len(X)} (train={split}, test={len(X)-split})")
print(f"Label balance: up={np.mean(y)*100:.1f}%")
scaler = StandardScaler()
X_train_s = scaler.fit_transform(X_train)
X_test_s = scaler.transform(X_test)
# Test diversi K
print("\n--- KNN SWEEP ---")
for K in [5, 10, 20, 50, 100, 200]:
knn = KNeighborsClassifier(n_neighbors=K, weights="distance", n_jobs=-1)
knn.fit(X_train_s, y_train)
proba = knn.predict_proba(X_test_s)
up_idx = list(knn.classes_).index(1)
for thr in [0.55, 0.60, 0.65, 0.70]:
sigs = []
accs = []
for j in range(len(X_test)):
p_up = proba[j][up_idx]
i = idx_test[j]
actual = (close[i + LOOKAHEAD - 1] - close[i - 1]) / close[i - 1]
if p_up >= thr:
sigs.append(1)
accs.append(1 if actual > 0 else 0)
elif p_up <= (1 - thr):
sigs.append(-1)
accs.append(1 if actual < 0 else 0)
if not accs:
continue
acc = np.mean(accs) * 100
# PnL
capital = 1000
for direction, j in zip(sigs, range(len(accs))):
i_idx = idx_test[[k for k in range(len(X_test)) if proba[k][up_idx] >= thr or proba[k][up_idx] <= (1 - thr)][j]]
entry = close[i_idx - 1]
exit_ = close[i_idx + LOOKAHEAD - 1]
if direction == 1:
ret = (exit_ - entry) / entry
else:
ret = (entry - exit_) / entry
ret -= 0.002
capital *= (1 + ret * 0.5)
total_ret = (capital - 1000) / 1000 * 100
print(f" K={K:3d} thr={thr:.2f}: signals={len(accs):5d} acc={acc:.1f}% ret={total_ret:+.1f}%")
# Best combo: try with Gradient Boosting on same features
print("\n\n--- GRADIENT BOOSTING SU STRUCTURAL FEATURES ---")
from sklearn.ensemble import GradientBoostingClassifier
gb = GradientBoostingClassifier(
n_estimators=300, max_depth=5, min_samples_leaf=30,
learning_rate=0.03, subsample=0.8, random_state=42,
)
gb.fit(X_train_s, y_train)
proba_gb = gb.predict_proba(X_test_s)
up_idx_gb = list(gb.classes_).index(1)
for thr in [0.50, 0.55, 0.60, 0.65, 0.70, 0.75]:
accs = []
capital = 1000
n_trades = 0
for j in range(len(X_test)):
p_up = proba_gb[j][up_idx_gb]
i = idx_test[j]
actual = (close[i + LOOKAHEAD - 1] - close[i - 1]) / close[i - 1]
if p_up >= thr:
accs.append(1 if actual > 0 else 0)
ret = actual - 0.002
capital *= (1 + ret * 0.5)
n_trades += 1
elif p_up <= (1 - thr):
accs.append(1 if actual < 0 else 0)
ret = -actual - 0.002
capital *= (1 + ret * 0.5)
n_trades += 1
if not accs:
continue
acc = np.mean(accs) * 100
total_ret = (capital - 1000) / 1000 * 100
print(f" thr={thr:.2f}: trades={n_trades:5d} acc={acc:.1f}% ret={total_ret:+.1f}%")
+320
View File
@@ -0,0 +1,320 @@
"""Strategia 7: LSTM su features frattali multi-timeframe.
Usa sequenze di features frattali come input a un LSTM
per predire la direzione del prezzo.
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset
from sklearn.preprocessing import StandardScaler
from src.data.downloader import load_data
from src.fractal.indicators import hurst_exponent, fractal_dimension_higuchi, volatility_ratio
from src.fractal.patterns import encode_candles, extract_body_ratios, extract_shadow_ratios
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Device: {DEVICE}")
class FractalLSTM(nn.Module):
def __init__(self, input_size: int, hidden_size: int = 64, num_layers: int = 2, dropout: float = 0.3):
super().__init__()
self.lstm = nn.LSTM(input_size, hidden_size, num_layers, batch_first=True, dropout=dropout)
self.classifier = nn.Sequential(
nn.Linear(hidden_size, 32),
nn.ReLU(),
nn.Dropout(dropout),
nn.Linear(32, 1),
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
_, (h_n, _) = self.lstm(x)
out = self.classifier(h_n[-1])
return out.squeeze(-1)
def extract_candle_features(df: pd.DataFrame, i: int) -> np.ndarray:
"""Extract per-candle features at index i."""
o, h, l, c = df["open"].values[i], df["high"].values[i], df["low"].values[i], df["close"].values[i]
v = df["volume"].values[i]
total = h - l if h - l > 0 else 1e-10
body = abs(c - o) / total
upper_s = (h - max(o, c)) / total
lower_s = (min(o, c) - l) / total
direction = 1 if c > o else (-1 if c < o else 0)
# Log return from previous candle
if i > 0:
prev_c = df["close"].values[i - 1]
log_ret = np.log(c / prev_c) if prev_c > 0 else 0
else:
log_ret = 0
return np.array([body, upper_s, lower_s, direction, log_ret, v])
def build_dataset(df: pd.DataFrame, seq_len: int = 48, lookahead: int = 6, min_ret: float = 0.003):
"""Build sequences of candle features with labels."""
close = df["close"].values
n = len(df)
vol_mean = pd.Series(df["volume"].values).rolling(100, min_periods=1).mean().values
sequences = []
labels = []
indices = []
# Pre-compute additional features
candle_types = encode_candles(df)
body_ratios = extract_body_ratios(df)
shadow_ratios = extract_shadow_ratios(df)
for i in range(seq_len, n - lookahead, 2):
seq = []
for j in range(i - seq_len, i):
feats = extract_candle_features(df, j)
# Normalize volume by rolling mean
feats[5] = feats[5] / vol_mean[j] if vol_mean[j] > 0 else 1.0
seq.append(feats)
future_ret = (close[i + lookahead - 1] - close[i - 1]) / close[i - 1]
if abs(future_ret) < min_ret:
continue
sequences.append(seq)
labels.append(1 if future_ret > 0 else 0)
indices.append(i)
return np.array(sequences), np.array(labels), np.array(indices)
print("=" * 60)
print(" STRATEGIA 7: LSTM FRACTAL — BTC 1H")
print("=" * 60)
df = load_data("BTC", "1h")
close = df["close"].values
SEQ_LEN = 48
LOOKAHEAD = 6
EPOCHS = 30
BATCH_SIZE = 256
LR = 0.001
print(f"\nSeq length: {SEQ_LEN}, Lookahead: {LOOKAHEAD}")
print("Building dataset...")
X, y, idx_arr = build_dataset(df, seq_len=SEQ_LEN, lookahead=LOOKAHEAD)
print(f"Samples: {len(X)}, Features per candle: {X.shape[2]}, Up ratio: {np.mean(y)*100:.1f}%")
# Chronological split
split = int(len(X) * 0.7)
val_split = int(len(X) * 0.85)
X_train, X_val, X_test = X[:split], X[split:val_split], X[val_split:]
y_train, y_val, y_test = y[:split], y[split:val_split], y[val_split:]
idx_test_arr = idx_arr[val_split:]
# Normalize features per-feature across time
n_features = X.shape[2]
for f in range(n_features):
scaler = StandardScaler()
X_train[:, :, f] = scaler.fit_transform(X_train[:, :, f])
X_val[:, :, f] = scaler.transform(X_val[:, :, f])
X_test[:, :, f] = scaler.transform(X_test[:, :, f])
# To tensors
X_train_t = torch.FloatTensor(X_train).to(DEVICE)
y_train_t = torch.FloatTensor(y_train).to(DEVICE)
X_val_t = torch.FloatTensor(X_val).to(DEVICE)
y_val_t = torch.FloatTensor(y_val).to(DEVICE)
X_test_t = torch.FloatTensor(X_test).to(DEVICE)
train_ds = TensorDataset(X_train_t, y_train_t)
train_dl = DataLoader(train_ds, batch_size=BATCH_SIZE, shuffle=True)
# Model
model = FractalLSTM(input_size=n_features, hidden_size=64, num_layers=2, dropout=0.3).to(DEVICE)
optimizer = torch.optim.Adam(model.parameters(), lr=LR, weight_decay=1e-5)
criterion = nn.BCEWithLogitsLoss()
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, patience=5, factor=0.5)
print(f"\nTraining on {DEVICE}...")
best_val_acc = 0
patience_counter = 0
for epoch in range(EPOCHS):
model.train()
total_loss = 0
for xb, yb in train_dl:
optimizer.zero_grad()
pred = model(xb)
loss = criterion(pred, yb)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
total_loss += loss.item()
# Validation
model.eval()
with torch.no_grad():
val_pred = model(X_val_t)
val_loss = criterion(val_pred, y_val_t).item()
val_proba = torch.sigmoid(val_pred).cpu().numpy()
val_acc = np.mean((val_proba > 0.5) == y_val)
scheduler.step(val_loss)
if val_acc > best_val_acc:
best_val_acc = val_acc
torch.save(model.state_dict(), "data/processed/best_lstm.pt")
patience_counter = 0
else:
patience_counter += 1
if epoch % 5 == 0 or patience_counter > 8:
print(f" Epoch {epoch:2d}: train_loss={total_loss/len(train_dl):.4f} val_loss={val_loss:.4f} val_acc={val_acc*100:.1f}% best={best_val_acc*100:.1f}%")
if patience_counter > 10:
print(f" Early stopping at epoch {epoch}")
break
# Load best model and test
model.load_state_dict(torch.load("data/processed/best_lstm.pt", weights_only=True))
model.eval()
with torch.no_grad():
test_pred = model(X_test_t)
test_proba = torch.sigmoid(test_pred).cpu().numpy()
test_acc = np.mean((test_proba > 0.5) == y_test)
print(f"\nTest accuracy (base): {test_acc*100:.1f}%")
# Threshold sweep
print("\n--- THRESHOLD SWEEP ---")
for thr in [0.50, 0.55, 0.60, 0.65, 0.70, 0.75, 0.80]:
accs = []
capital = 1000
n_trades = 0
for j in range(len(X_test)):
p = test_proba[j]
i = idx_test_arr[j]
actual = (close[i + LOOKAHEAD - 1] - close[i - 1]) / close[i - 1]
if p >= thr:
accs.append(1 if actual > 0 else 0)
ret = actual - 0.002
capital *= (1 + ret * 0.3)
n_trades += 1
elif p <= (1 - thr):
accs.append(1 if actual < 0 else 0)
ret = -actual - 0.002
capital *= (1 + ret * 0.3)
n_trades += 1
if not accs:
print(f" thr={thr:.2f}: no signals")
continue
acc = np.mean(accs) * 100
total_ret = (capital - 1000) / 1000 * 100
# Annualized
test_days = (idx_test_arr[-1] - idx_test_arr[0]) / 24
years = test_days / 365.25 if test_days > 0 else 1
ann_ret = ((capital / 1000) ** (1 / years) - 1) * 100 if years > 0 and capital > 0 else -100
trades_yr = n_trades / years if years > 0 else 0
print(f" thr={thr:.2f}: trades={n_trades:5d} acc={acc:.1f}% ret={total_ret:+.1f}% ann={ann_ret:+.1f}% trades/yr={trades_yr:.0f}")
# Also try ETH
print("\n\n" + "=" * 60)
print(" LSTM SU ETH 1H (same model architecture)")
print("=" * 60)
df_eth = load_data("ETH", "1h")
close_eth = df_eth["close"].values
X_eth, y_eth, idx_eth = build_dataset(df_eth, seq_len=SEQ_LEN, lookahead=LOOKAHEAD)
print(f"ETH samples: {len(X_eth)}, Up ratio: {np.mean(y_eth)*100:.1f}%")
split_e = int(len(X_eth) * 0.7)
val_e = int(len(X_eth) * 0.85)
X_train_e, X_val_e, X_test_e = X_eth[:split_e], X_eth[split_e:val_e], X_eth[val_e:]
y_train_e, y_val_e, y_test_e = y_eth[:split_e], y_eth[split_e:val_e], y_eth[val_e:]
idx_test_e = idx_eth[val_e:]
for f in range(n_features):
sc = StandardScaler()
X_train_e[:, :, f] = sc.fit_transform(X_train_e[:, :, f])
X_val_e[:, :, f] = sc.transform(X_val_e[:, :, f])
X_test_e[:, :, f] = sc.transform(X_test_e[:, :, f])
X_tr_e = torch.FloatTensor(X_train_e).to(DEVICE)
y_tr_e = torch.FloatTensor(y_train_e).to(DEVICE)
X_va_e = torch.FloatTensor(X_val_e).to(DEVICE)
y_va_e = torch.FloatTensor(y_val_e).to(DEVICE)
X_te_e = torch.FloatTensor(X_test_e).to(DEVICE)
model_eth = FractalLSTM(input_size=n_features, hidden_size=64, num_layers=2, dropout=0.3).to(DEVICE)
opt_e = torch.optim.Adam(model_eth.parameters(), lr=LR, weight_decay=1e-5)
ds_e = TensorDataset(X_tr_e, y_tr_e)
dl_e = DataLoader(ds_e, batch_size=BATCH_SIZE, shuffle=True)
sch_e = torch.optim.lr_scheduler.ReduceLROnPlateau(opt_e, patience=5, factor=0.5)
best_e = 0
pc = 0
for epoch in range(EPOCHS):
model_eth.train()
tl = 0
for xb, yb in dl_e:
opt_e.zero_grad()
p = model_eth(xb)
loss = criterion(p, yb)
loss.backward()
torch.nn.utils.clip_grad_norm_(model_eth.parameters(), 1.0)
opt_e.step()
tl += loss.item()
model_eth.eval()
with torch.no_grad():
vp = model_eth(X_va_e)
vl = criterion(vp, y_va_e).item()
va = np.mean((torch.sigmoid(vp).cpu().numpy() > 0.5) == y_val_e)
sch_e.step(vl)
if va > best_e:
best_e = va
torch.save(model_eth.state_dict(), "data/processed/best_lstm_eth.pt")
pc = 0
else:
pc += 1
if epoch % 5 == 0:
print(f" Epoch {epoch:2d}: val_acc={va*100:.1f}% best={best_e*100:.1f}%")
if pc > 10:
break
model_eth.load_state_dict(torch.load("data/processed/best_lstm_eth.pt", weights_only=True))
model_eth.eval()
with torch.no_grad():
tp_e = torch.sigmoid(model_eth(X_te_e)).cpu().numpy()
print(f"\nETH Test accuracy: {np.mean((tp_e > 0.5) == y_test_e)*100:.1f}%")
for thr in [0.55, 0.60, 0.65, 0.70]:
accs = []
capital = 1000
for j in range(len(X_test_e)):
p = tp_e[j]
i = idx_test_e[j]
actual = (close_eth[i + LOOKAHEAD - 1] - close_eth[i - 1]) / close_eth[i - 1]
if p >= thr:
accs.append(1 if actual > 0 else 0)
capital *= (1 + (actual - 0.002) * 0.3)
elif p <= (1 - thr):
accs.append(1 if actual < 0 else 0)
capital *= (1 + (-actual - 0.002) * 0.3)
if accs:
print(f" thr={thr:.2f}: trades={len(accs):5d} acc={np.mean(accs)*100:.1f}% ret={(capital-1000)/10:+.1f}%")
+290
View File
@@ -0,0 +1,290 @@
"""Strategia 8: Ensemble multi-timeframe.
Combina i migliori approcci:
1. GBM su structural features (miglior singolo finora: 58.6%/+57.5%)
2. GBM su fractal indicators
3. Multi-timeframe: 1h features + 15m aggregati
Vota con consensus: trade solo quando almeno 2/3 modelli concordano.
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.preprocessing import StandardScaler
from src.data.downloader import load_data
from src.fractal.patterns import encode_candles, extract_body_ratios, extract_shadow_ratios
print("=" * 60)
print(" STRATEGIA 8: ENSEMBLE MULTI-TF — BTC")
print("=" * 60)
# Load both timeframes
df_1h = load_data("BTC", "1h")
df_15m = load_data("BTC", "15m")
close_1h = df_1h["close"].values
ts_1h = df_1h["timestamp"].values
WINDOW_1H = 24
LOOKAHEAD = 6
MIN_RETURN = 0.003
def structural_features_1h(df: pd.DataFrame, i: int, window: int = 24) -> np.ndarray | None:
if i < window:
return None
o = df["open"].values[i - window : i]
h = df["high"].values[i - window : i]
l = df["low"].values[i - window : i]
c = df["close"].values[i - window : i]
v = df["volume"].values[i - window : i]
all_p = np.concatenate([o, h, l, c])
mn, mx = all_p.min(), all_p.max()
if mx - mn == 0:
return None
o_n = (o - mn) / (mx - mn)
h_n = (h - mn) / (mx - mn)
l_n = (l - mn) / (mx - mn)
c_n = (c - mn) / (mx - mn)
total = h - l
total = np.where(total == 0, 1e-10, total)
body = np.abs(c - o) / total
u_shadow = (h - np.maximum(o, c)) / total
l_shadow = (np.minimum(o, c) - l) / total
direction = np.sign(c - o)
log_c = np.log(np.where(c == 0, 1e-10, c))
rets = np.diff(log_c)
v_mean = np.mean(v)
v_n = v / v_mean if v_mean > 0 else np.ones_like(v)
step = max(1, window // 12)
idx = np.arange(0, window, step)[:12]
features = np.concatenate([
c_n[idx], body[idx], direction[idx],
u_shadow[idx], l_shadow[idx], v_n[idx],
[np.mean(rets), np.std(rets), np.sum(rets),
np.mean(body), np.std(body),
np.max(body[-6:]) - np.min(body[-6:])],
])
return features
def multi_tf_features(ts_current: int, df_15m: pd.DataFrame, n_bars: int = 48) -> np.ndarray | None:
"""Extract aggregated features from 15m data aligned to current 1h candle."""
ts_15m = df_15m["timestamp"].values
mask = ts_15m <= ts_current
end_idx = np.sum(mask)
if end_idx < n_bars:
return None
start = end_idx - n_bars
chunk = df_15m.iloc[start:end_idx]
c = chunk["close"].values
h = chunk["high"].values
l = chunk["low"].values
v = chunk["volume"].values
if len(c) < n_bars:
return None
log_c = np.log(np.where(c == 0, 1e-10, c))
rets = np.diff(log_c)
# Micro-structure features
mom_12 = np.sum(rets[-12:])
mom_24 = np.sum(rets[-24:])
vol_12 = np.std(rets[-12:])
vol_48 = np.std(rets)
# Candle pattern stats
ct = encode_candles(chunk)
up_ratio_12 = np.mean(ct[-12:] == 1)
up_ratio_24 = np.mean(ct[-24:] == 1)
# Intra-bar volatility (high-low range)
ranges = (h - l) / np.where(c == 0, 1e-10, c)
avg_range_12 = np.mean(ranges[-12:])
avg_range_48 = np.mean(ranges)
# Volume profile
v_mean = np.mean(v)
v_recent = np.mean(v[-12:])
vol_surge = v_recent / v_mean if v_mean > 0 else 1.0
# Autocorrelation
if np.std(rets) > 0 and len(rets) > 1:
ac1 = np.corrcoef(rets[:-1], rets[1:])[0, 1]
ac1 = 0 if not np.isfinite(ac1) else ac1
else:
ac1 = 0
return np.array([
mom_12, mom_24, vol_12, vol_48,
up_ratio_12, up_ratio_24,
avg_range_12, avg_range_48,
vol_surge, ac1,
vol_12 / vol_48 if vol_48 > 0 else 1.0,
])
print("Extracting features...")
n_1h = len(df_1h)
X_struct = []
X_multi = []
y_all = []
indices = []
for i in range(WINDOW_1H, n_1h - LOOKAHEAD, 1):
if i % 5000 == 0:
print(f" {i}/{n_1h}")
sf = structural_features_1h(df_1h, i, WINDOW_1H)
if sf is None:
continue
mf = multi_tf_features(ts_1h[i - 1], df_15m)
if mf is None:
continue
future_ret = (close_1h[i + LOOKAHEAD - 1] - close_1h[i - 1]) / close_1h[i - 1]
if abs(future_ret) < MIN_RETURN:
continue
X_struct.append(sf)
X_multi.append(mf)
y_all.append(1 if future_ret > 0 else 0)
indices.append(i)
X_s = np.nan_to_num(np.array(X_struct), nan=0, posinf=1e6, neginf=-1e6)
X_m = np.nan_to_num(np.array(X_multi), nan=0, posinf=1e6, neginf=-1e6)
X_combined = np.hstack([X_s, X_m])
y = np.array(y_all)
idx_arr = np.array(indices)
print(f"\nSamples: {len(y)}, struct_feats: {X_s.shape[1]}, multi_feats: {X_m.shape[1]}, combined: {X_combined.shape[1]}")
print(f"Up ratio: {np.mean(y)*100:.1f}%")
split = int(len(y) * 0.7)
# 3 models
configs = {
"M1_structural": X_s,
"M2_multi_tf": X_m,
"M3_combined": X_combined,
}
probas = {}
for name, X_data in configs.items():
X_tr, X_te = X_data[:split], X_data[split:]
y_tr, y_te = y[:split], y[split:]
sc = StandardScaler()
X_tr_s = sc.fit_transform(X_tr)
X_te_s = sc.transform(X_te)
model = GradientBoostingClassifier(
n_estimators=300, max_depth=5, min_samples_leaf=30,
learning_rate=0.03, subsample=0.8, random_state=42,
)
model.fit(X_tr_s, y_tr)
proba = model.predict_proba(X_te_s)
up_idx = list(model.classes_).index(1)
probas[name] = proba[:, up_idx]
# Individual results
for thr in [0.55, 0.60, 0.65, 0.70]:
accs = []
capital = 1000
for j in range(len(X_te)):
p = proba[j][up_idx]
i = idx_arr[split + j]
actual = (close_1h[i + LOOKAHEAD - 1] - close_1h[i - 1]) / close_1h[i - 1]
if p >= thr:
accs.append(1 if actual > 0 else 0)
capital *= (1 + (actual - 0.002) * 0.5)
elif p <= (1 - thr):
accs.append(1 if actual < 0 else 0)
capital *= (1 + (-actual - 0.002) * 0.5)
if accs:
acc = np.mean(accs) * 100
ret = (capital - 1000) / 1000 * 100
test_days = (idx_arr[-1] - idx_arr[split]) / 24
years = test_days / 365.25
ann = ((capital / 1000) ** (1 / years) - 1) * 100 if years > 0 and capital > 0 else -100
print(f" {name:15s} thr={thr:.2f}: trades={len(accs):5d} acc={acc:.1f}% ret={ret:+.1f}% ann={ann:+.1f}%")
# Ensemble voting
print("\n\n--- ENSEMBLE VOTING ---")
y_test = y[split:]
idx_test = idx_arr[split:]
for min_agree in [2, 3]:
for thr in [0.55, 0.60, 0.65, 0.70]:
accs = []
capital = 1000
for j in range(len(y_test)):
votes_up = sum(1 for p in probas.values() if p[j] >= thr)
votes_down = sum(1 for p in probas.values() if p[j] <= (1 - thr))
i = idx_test[j]
actual = (close_1h[i + LOOKAHEAD - 1] - close_1h[i - 1]) / close_1h[i - 1]
if votes_up >= min_agree:
accs.append(1 if actual > 0 else 0)
capital *= (1 + (actual - 0.002) * 0.5)
elif votes_down >= min_agree:
accs.append(1 if actual < 0 else 0)
capital *= (1 + (-actual - 0.002) * 0.5)
if accs:
acc = np.mean(accs) * 100
ret = (capital - 1000) / 1000 * 100
test_days = (idx_test[-1] - idx_test[0]) / 24
years = test_days / 365.25
ann = ((capital / 1000) ** (1 / years) - 1) * 100 if years > 0 and capital > 0 else -100
trades_yr = len(accs) / years if years > 0 else 0
print(f" agree>={min_agree} thr={thr:.2f}: trades={len(accs):5d} acc={acc:.1f}% ret={ret:+.1f}% ann={ann:+.1f}% trades/yr={trades_yr:.0f}")
# Average probability ensemble
print("\n--- ENSEMBLE AVERAGE PROBABILITY ---")
avg_proba = np.mean([p for p in probas.values()], axis=0)
for thr in [0.55, 0.60, 0.65, 0.70, 0.75]:
accs = []
capital = 1000
for j in range(len(y_test)):
p = avg_proba[j]
i = idx_test[j]
actual = (close_1h[i + LOOKAHEAD - 1] - close_1h[i - 1]) / close_1h[i - 1]
if p >= thr:
accs.append(1 if actual > 0 else 0)
capital *= (1 + (actual - 0.002) * 0.5)
elif p <= (1 - thr):
accs.append(1 if actual < 0 else 0)
capital *= (1 + (-actual - 0.002) * 0.5)
if accs:
acc = np.mean(accs) * 100
ret = (capital - 1000) / 1000 * 100
test_days = (idx_test[-1] - idx_test[0]) / 24
years = test_days / 365.25
ann = ((capital / 1000) ** (1 / years) - 1) * 100 if years > 0 and capital > 0 else -100
trades_yr = len(accs) / years if years > 0 else 0
daily_ret = capital ** (1 / (test_days)) - 1 if test_days > 0 and capital > 0 else 0
daily_pnl_on_1k = 1000 * daily_ret
print(f" thr={thr:.2f}: trades={len(accs):5d} acc={acc:.1f}% ret={ret:+.1f}% ann={ann:+.1f}% trades/yr={trades_yr:.0f} daily_pnl=€{daily_pnl_on_1k:.2f}")
+309
View File
@@ -0,0 +1,309 @@
"""Strategia 9: Refined walk-forward with adaptive features.
Combina le lezioni apprese:
- Structural features (migliore singolo)
- Walk-forward validation (no single split bias)
- XGBoost (più potente di GBM per dati tabulari)
- Dynamic exit: trailing stop + take profit
- Multi-asset: BTC + ETH in portafoglio
- Position sizing basato su confidenza
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.preprocessing import StandardScaler
from src.data.downloader import load_data
from src.fractal.patterns import encode_candles, extract_body_ratios, extract_shadow_ratios
from src.fractal.indicators import hurst_exponent, volatility_ratio
print("=" * 60)
print(" STRATEGIA 9: WALK-FORWARD REFINATA")
print("=" * 60)
def build_features(df: pd.DataFrame, i: int) -> np.ndarray | None:
"""All features from structural + fractal, no leakage."""
if i < 200:
return None
o = df["open"].values
h = df["high"].values
l = df["low"].values
c = df["close"].values
v = df["volume"].values
feats = []
# Structural features (3 windows)
for w in [12, 24, 48]:
win_c = c[i - w : i]
win_o = o[i - w : i]
win_h = h[i - w : i]
win_l = l[i - w : i]
win_v = v[i - w : i]
mn, mx = min(win_l.min(), win_o.min()), max(win_h.max(), win_o.max())
if mx - mn == 0:
feats.extend([0] * 15)
continue
c_n = (win_c - mn) / (mx - mn)
total = win_h - win_l
total = np.where(total == 0, 1e-10, total)
body = np.abs(win_c - win_o) / total
direction = np.sign(win_c - win_o)
log_c = np.log(np.where(win_c == 0, 1e-10, win_c))
rets = np.diff(log_c)
v_mean = np.mean(win_v)
v_n = win_v / v_mean if v_mean > 0 else np.ones_like(win_v)
feats.extend([
np.mean(rets),
np.std(rets),
np.sum(rets),
float(pd.Series(rets).skew()) if len(rets) > 2 else 0,
float(pd.Series(rets).kurtosis()) if len(rets) > 3 else 0,
np.mean(body),
np.std(body),
np.mean(direction[-6:]),
np.mean(direction),
c_n[-1],
np.mean(c_n[-6:]),
v_n[-1],
np.mean(v_n[-6:]),
np.max(body[-6:]),
np.corrcoef(rets[:-1], rets[1:])[0, 1] if len(rets) > 1 and np.std(rets) > 0 else 0,
])
# Fractal features
ret_long = np.diff(np.log(np.where(c[i-96:i] == 0, 1e-10, c[i-96:i])))
if len(ret_long) > 20:
h_exp = hurst_exponent(ret_long, max_lag=min(len(ret_long)//4, 20))
else:
h_exp = 0.5
feats.append(h_exp)
feats.append(volatility_ratio(c[i-48:i], fast=12, slow=48))
# ATR
tr_arr = np.maximum(h[i-14:i] - l[i-14:i],
np.maximum(np.abs(h[i-14:i] - np.roll(c[i-14:i], 1)),
np.abs(l[i-14:i] - np.roll(c[i-14:i], 1))))
atr = np.mean(tr_arr[1:])
feats.append(atr / c[i-1] if c[i-1] > 0 else 0)
# Price position relative to recent range
high_48 = np.max(h[i-48:i])
low_48 = np.min(l[i-48:i])
range_48 = high_48 - low_48
feats.append((c[i-1] - low_48) / range_48 if range_48 > 0 else 0.5)
return np.nan_to_num(np.array(feats), nan=0, posinf=1e6, neginf=-1e6)
def walk_forward_backtest(
df: pd.DataFrame,
train_size: int = 10000,
step_size: int = 2000,
lookahead: int = 6,
min_return: float = 0.003,
threshold: float = 0.60,
fee_pct: float = 0.001,
position_pct: float = 0.3,
) -> dict:
"""Walk-forward validation with rolling train window."""
close = df["close"].values
n = len(df)
all_trades = []
capital = 1000.0
equity = [capital]
start = 200
features_cache: dict[int, np.ndarray] = {}
def get_features(idx: int) -> np.ndarray | None:
if idx not in features_cache:
features_cache[idx] = build_features(df, idx)
return features_cache[idx]
# Pre-compute all features
print(" Pre-computing features...")
for i in range(start, n - lookahead, 2):
get_features(i)
fold = 0
train_start = start
total_signals = 0
total_correct = 0
while train_start + train_size + step_size + lookahead < n:
train_end = train_start + train_size
test_end = min(train_end + step_size, n - lookahead)
# Build train set
X_train, y_train = [], []
for i in range(train_start, train_end, 2):
f = get_features(i)
if f is None:
continue
ret = (close[i + lookahead - 1] - close[i - 1]) / close[i - 1]
if abs(ret) < min_return:
continue
X_train.append(f)
y_train.append(1 if ret > 0 else 0)
if len(X_train) < 100:
train_start += step_size
continue
X_tr = np.array(X_train)
y_tr = np.array(y_train)
scaler = StandardScaler()
X_tr_s = scaler.fit_transform(X_tr)
model = GradientBoostingClassifier(
n_estimators=200, max_depth=5, min_samples_leaf=30,
learning_rate=0.05, subsample=0.8, random_state=42,
)
model.fit(X_tr_s, y_tr)
up_idx = list(model.classes_).index(1)
# Test on next step
fold_trades = 0
fold_correct = 0
for i in range(train_end, test_end, 2):
f = get_features(i)
if f is None:
continue
f_s = scaler.transform(f.reshape(1, -1))
proba = model.predict_proba(f_s)[0]
p_up = proba[up_idx]
actual_ret = (close[i + lookahead - 1] - close[i - 1]) / close[i - 1]
if abs(actual_ret) < min_return:
continue
direction = None
if p_up >= threshold:
direction = "long"
elif p_up <= (1 - threshold):
direction = "short"
if direction:
if direction == "long":
trade_ret = actual_ret
else:
trade_ret = -actual_ret
net_ret = trade_ret - fee_pct * 2
pnl = capital * position_pct * net_ret
capital += pnl
equity.append(capital)
is_correct = (direction == "long" and actual_ret > 0) or (direction == "short" and actual_ret < 0)
fold_trades += 1
if is_correct:
fold_correct += 1
all_trades.append({
"fold": fold,
"idx": i,
"direction": direction,
"prob": p_up,
"actual_ret": actual_ret,
"net_ret": net_ret,
"pnl": pnl,
"correct": is_correct,
})
total_signals += fold_trades
total_correct += fold_correct
fold_acc = fold_correct / fold_trades * 100 if fold_trades > 0 else 0
if fold % 3 == 0:
print(f" Fold {fold}: trades={fold_trades} acc={fold_acc:.0f}% capital=€{capital:.0f}")
fold += 1
train_start += step_size
# Results
if not all_trades:
return {"error": "no trades"}
trades_df = pd.DataFrame(all_trades)
total_acc = total_correct / total_signals * 100 if total_signals > 0 else 0
test_candles = n - 200 - train_size
test_days = test_candles / 24
test_years = test_days / 365.25
ann_ret = ((capital / 1000) ** (1 / test_years) - 1) * 100 if test_years > 0 and capital > 0 else -100
# Max drawdown
peak = equity[0]
max_dd = 0
for v in equity:
if v > peak:
peak = v
dd = (peak - v) / peak if peak > 0 else 0
if dd > max_dd:
max_dd = dd
# Sharpe
equity_arr = np.array(equity)
rets = np.diff(equity_arr) / equity_arr[:-1]
rets = rets[np.isfinite(rets)]
sharpe = np.mean(rets) / np.std(rets) * np.sqrt(252 * 24) if np.std(rets) > 0 else 0
return {
"total_trades": total_signals,
"accuracy": total_acc,
"total_return": (capital - 1000) / 1000 * 100,
"annualized_return": ann_ret,
"max_drawdown": max_dd * 100,
"sharpe": sharpe,
"final_capital": capital,
"trades_per_year": total_signals / test_years if test_years > 0 else 0,
"daily_pnl": (capital - 1000) / test_days if test_days > 0 else 0,
"folds": fold,
}
# Run for both assets with parameter sweep
for asset in ["BTC", "ETH"]:
print(f"\n{'#'*60}")
print(f" {asset} 1H — WALK-FORWARD")
print(f"{'#'*60}")
df = load_data(asset, "1h")
for lookahead in [3, 6]:
for threshold in [0.55, 0.60, 0.65, 0.70]:
result = walk_forward_backtest(
df,
train_size=15000,
step_size=3000,
lookahead=lookahead,
threshold=threshold,
position_pct=0.3,
)
if "error" in result:
continue
print(f"\n LA={lookahead} thr={threshold:.2f}: "
f"trades={result['total_trades']:4d} "
f"acc={result['accuracy']:.1f}% "
f"ret={result['total_return']:+.1f}% "
f"ann={result['annualized_return']:+.1f}% "
f"dd={result['max_drawdown']:.1f}% "
f"sharpe={result['sharpe']:.2f} "
f"€/day={result['daily_pnl']:.2f}")
+340
View File
@@ -0,0 +1,340 @@
"""Strategia 10: High Precision (target >80% accuracy).
Approccio: combina TUTTI gli indicatori disponibili, usa ensemble di 5 modelli,
trade SOLO quando tutti concordano. Pochi trade ma molto precisi.
Usa leva 3x per compensare bassa frequenza.
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from sklearn.ensemble import GradientBoostingClassifier, RandomForestClassifier, ExtraTreesClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.svm import SVC
from sklearn.preprocessing import StandardScaler
from src.data.downloader import load_data
from src.fractal.patterns import encode_candles, extract_body_ratios, extract_shadow_ratios
from src.fractal.indicators import hurst_exponent, volatility_ratio
LEVERAGE = 3
FEE_PCT = 0.001
INITIAL_CAPITAL = 1000
def build_rich_features(df: pd.DataFrame, i: int) -> np.ndarray | None:
if i < 200:
return None
o = df["open"].values
h = df["high"].values
l = df["low"].values
c = df["close"].values
v = df["volume"].values
feats = []
for w in [6, 12, 24, 48, 96]:
if i < w:
feats.extend([0] * 18)
continue
win_c = c[i - w : i]
win_o = o[i - w : i]
win_h = h[i - w : i]
win_l = l[i - w : i]
win_v = v[i - w : i]
mn, mx = win_l.min(), max(win_h.max(), win_c.max())
rng = mx - mn if mx - mn > 0 else 1e-10
total = win_h - win_l
total = np.where(total == 0, 1e-10, total)
body = np.abs(win_c - win_o) / total
direction = np.sign(win_c - win_o)
log_c = np.log(np.where(win_c == 0, 1e-10, win_c))
rets = np.diff(log_c)
v_mean = np.mean(win_v)
feats.extend([
np.mean(rets) if len(rets) > 0 else 0,
np.std(rets) if len(rets) > 0 else 0,
np.sum(rets) if len(rets) > 0 else 0,
float(pd.Series(rets).skew()) if len(rets) > 2 else 0,
float(pd.Series(rets).kurtosis()) if len(rets) > 3 else 0,
np.mean(body),
np.std(body),
np.mean(direction),
np.mean(direction[-min(3, w):]),
(win_c[-1] - mn) / rng,
win_v[-1] / v_mean if v_mean > 0 else 1,
np.max(body) - np.min(body),
np.corrcoef(rets[:-1], rets[1:])[0, 1] if len(rets) > 1 and np.std(rets) > 0 else 0,
np.max(rets) if len(rets) > 0 else 0,
np.min(rets) if len(rets) > 0 else 0,
np.mean(np.abs(rets)) if len(rets) > 0 else 0,
np.sum(direction == 1) / w,
np.sum(direction == -1) / w,
])
# Hurst on different windows
for w in [48, 96]:
ret_w = np.diff(np.log(np.where(c[max(0,i-w):i] == 0, 1e-10, c[max(0,i-w):i])))
if len(ret_w) > 20:
feats.append(hurst_exponent(ret_w, max_lag=min(len(ret_w)//4, 15)))
else:
feats.append(0.5)
# Volatility ratios
feats.append(volatility_ratio(c[max(0,i-48):i], fast=6, slow=48))
feats.append(volatility_ratio(c[max(0,i-96):i], fast=12, slow=96))
# ATR normalized
tr = np.maximum(h[i-14:i] - l[i-14:i],
np.maximum(np.abs(h[i-14:i] - np.roll(c[i-14:i], 1)),
np.abs(l[i-14:i] - np.roll(c[i-14:i], 1))))
atr = np.mean(tr[1:])
feats.append(atr / c[i-1] if c[i-1] > 0 else 0)
# Position in range
h48 = np.max(h[i-48:i])
l48 = np.min(l[i-48:i])
r48 = h48 - l48
feats.append((c[i-1] - l48) / r48 if r48 > 0 else 0.5)
h96 = np.max(h[i-96:i])
l96 = np.min(l[i-96:i])
r96 = h96 - l96
feats.append((c[i-1] - l96) / r96 if r96 > 0 else 0.5)
return np.nan_to_num(np.array(feats), nan=0, posinf=1e6, neginf=-1e6)
def run_high_precision(asset: str, lookahead: int = 3):
print(f"\n{'#'*60}")
print(f" {asset} 1H — HIGH PRECISION (LA={lookahead})")
print(f"{'#'*60}")
df = load_data(asset, "1h")
close = df["close"].values
n = len(df)
MIN_RETURN = 0.003
# Build dataset
print(" Building features...")
X_all, y_all, idx_all = [], [], []
for i in range(200, n - lookahead, 1):
f = build_rich_features(df, i)
if f is None:
continue
ret = (close[i + lookahead - 1] - close[i - 1]) / close[i - 1]
if abs(ret) < MIN_RETURN:
continue
X_all.append(f)
y_all.append(1 if ret > 0 else 0)
idx_all.append(i)
X = np.array(X_all)
y = np.array(y_all)
idx_arr = np.array(idx_all)
print(f" Samples: {len(X)}, Features: {X.shape[1]}, Up: {np.mean(y)*100:.1f}%")
# Walk-forward with 5-model ensemble
TRAIN_SIZE = 15000
STEP_SIZE = 3000
models_config = [
("GB1", GradientBoostingClassifier(n_estimators=200, max_depth=4, min_samples_leaf=30, learning_rate=0.05, subsample=0.8, random_state=42)),
("GB2", GradientBoostingClassifier(n_estimators=300, max_depth=5, min_samples_leaf=50, learning_rate=0.03, subsample=0.7, random_state=123)),
("RF", RandomForestClassifier(n_estimators=300, max_depth=8, min_samples_leaf=30, class_weight="balanced", random_state=42, n_jobs=-1)),
("ET", ExtraTreesClassifier(n_estimators=300, max_depth=8, min_samples_leaf=30, class_weight="balanced", random_state=42, n_jobs=-1)),
("LR", LogisticRegression(max_iter=1000, C=0.1, random_state=42)),
]
capital = float(INITIAL_CAPITAL)
all_trades = []
equity = [capital]
fold = 0
start = 0
while start + TRAIN_SIZE + STEP_SIZE < len(X):
train_end = start + TRAIN_SIZE
test_end = min(train_end + STEP_SIZE, len(X))
X_tr, y_tr = X[start:train_end], y[start:train_end]
X_te, y_te = X[train_end:test_end], y[train_end:test_end]
idx_te = idx_arr[train_end:test_end]
scaler = StandardScaler()
X_tr_s = scaler.fit_transform(X_tr)
X_te_s = scaler.transform(X_te)
# Train all models
trained = []
for name, model in models_config:
m = type(model)(**model.get_params())
m.fit(X_tr_s, y_tr)
trained.append((name, m))
# Test with consensus voting
for j in range(len(X_te)):
votes_up = 0
votes_down = 0
max_conf = 0
for name, m in trained:
proba = m.predict_proba(X_te_s[j:j+1])[0]
up_idx = list(m.classes_).index(1)
p_up = proba[up_idx]
if p_up >= 0.60:
votes_up += 1
max_conf = max(max_conf, p_up)
elif p_up <= 0.40:
votes_down += 1
max_conf = max(max_conf, 1 - p_up)
i = idx_te[j]
actual_ret = (close[i + lookahead - 1] - close[i - 1]) / close[i - 1]
# Trade only with strong consensus
min_votes = 4 # at least 4 out of 5 models agree
direction = None
if votes_up >= min_votes:
direction = "long"
elif votes_down >= min_votes:
direction = "short"
if direction:
if direction == "long":
trade_ret = actual_ret
else:
trade_ret = -actual_ret
net_ret = trade_ret * LEVERAGE - FEE_PCT * 2 * LEVERAGE
pos_size = 0.2 # 20% of capital per trade
pnl = capital * pos_size * net_ret
capital += pnl
capital = max(capital, 0)
equity.append(capital)
is_correct = (direction == "long" and actual_ret > 0) or (direction == "short" and actual_ret < 0)
all_trades.append({
"fold": fold,
"idx": i,
"direction": direction,
"votes_up": votes_up,
"votes_down": votes_down,
"actual_ret": actual_ret,
"net_ret": net_ret,
"pnl": pnl,
"correct": is_correct,
})
fold += 1
start += STEP_SIZE
if not all_trades:
print(" No trades generated!")
return
trades_df = pd.DataFrame(all_trades)
n_correct = trades_df["correct"].sum()
n_total = len(trades_df)
accuracy = n_correct / n_total * 100
test_candles = idx_arr[-1] - idx_arr[TRAIN_SIZE]
test_days = test_candles / 24
test_years = test_days / 365.25
ann_ret = ((capital / INITIAL_CAPITAL) ** (1 / test_years) - 1) * 100 if test_years > 0 and capital > 0 else -100
daily_pnl = (capital - INITIAL_CAPITAL) / test_days if test_days > 0 else 0
# Max DD
peak = equity[0]
max_dd = 0
for v in equity:
if v > peak:
peak = v
dd = (peak - v) / peak if peak > 0 else 0
max_dd = max(max_dd, dd)
print(f"\n RISULTATI:")
print(f" Trades: {n_total}")
print(f" Accuracy: {accuracy:.1f}%")
print(f" Return: {(capital-INITIAL_CAPITAL)/INITIAL_CAPITAL*100:+.1f}%")
print(f" Annualized: {ann_ret:+.1f}%")
print(f" Max Drawdown: {max_dd*100:.1f}%")
print(f" Capital: €{capital:.0f}")
print(f" Trades/year: {n_total/test_years:.0f}")
print(f" €/day avg: €{daily_pnl:.2f}")
# Consensus threshold sweep
print(f"\n --- CONSENSUS SWEEP ---")
for min_v in [3, 4, 5]:
for ind_thr in [0.55, 0.60, 0.65]:
cap = float(INITIAL_CAPITAL)
trades_count = 0
correct_count = 0
eq = [cap]
fold_s = 0
start_s = 0
while start_s + TRAIN_SIZE + STEP_SIZE < len(X):
train_end_s = start_s + TRAIN_SIZE
test_end_s = min(train_end_s + STEP_SIZE, len(X))
X_tr_s2 = scaler.fit_transform(X[start_s:train_end_s])
X_te_s2 = scaler.transform(X[train_end_s:test_end_s])
y_tr_s2 = y[start_s:train_end_s]
idx_te_s2 = idx_arr[train_end_s:test_end_s]
trained_s = []
for name, model in models_config:
m2 = type(model)(**model.get_params())
m2.fit(X_tr_s2, y_tr_s2)
trained_s.append(m2)
for j in range(len(X_te_s2)):
vu = sum(1 for m2 in trained_s
if m2.predict_proba(X_te_s2[j:j+1])[0][list(m2.classes_).index(1)] >= ind_thr)
vd = sum(1 for m2 in trained_s
if m2.predict_proba(X_te_s2[j:j+1])[0][list(m2.classes_).index(1)] <= (1-ind_thr))
i_s = idx_te_s2[j]
ar = (close[i_s + lookahead - 1] - close[i_s - 1]) / close[i_s - 1]
d = None
if vu >= min_v:
d = "long"
elif vd >= min_v:
d = "short"
if d:
tr = ar if d == "long" else -ar
nr = tr * LEVERAGE - FEE_PCT * 2 * LEVERAGE
cap += cap * 0.2 * nr
cap = max(cap, 0)
eq.append(cap)
trades_count += 1
if (d == "long" and ar > 0) or (d == "short" and ar < 0):
correct_count += 1
start_s += STEP_SIZE
if trades_count > 0:
acc_s = correct_count / trades_count * 100
ret_s = (cap - INITIAL_CAPITAL) / INITIAL_CAPITAL * 100
ann_s = ((cap / INITIAL_CAPITAL) ** (1 / test_years) - 1) * 100 if test_years > 0 and cap > 0 else -100
dpnl = (cap - INITIAL_CAPITAL) / test_days if test_days > 0 else 0
print(f" min_votes={min_v} ind_thr={ind_thr:.2f}: trades={trades_count:4d} acc={acc_s:.1f}% ret={ret_s:+.1f}% ann={ann_s:+.1f}% €/day={dpnl:.2f}")
for asset in ["BTC", "ETH"]:
for la in [3, 6]:
run_high_precision(asset, la)
+160
View File
@@ -0,0 +1,160 @@
"""S2-01: Mean Reversion oraria con filtro orario.
Idea: crypto ha bias di ritorno alla media nelle ore notturne (00-06 UTC)
e di momentum nelle ore diurne USA (14-20 UTC).
- Compra quando RSI < 30 in ore notturne
- Vendi quando RSI > 70 in ore notturne
- Hold max 4h, stop loss 1.5%
Timeframe: 1h. Ingresso quasi giornaliero.
"""
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 = 0.001
INITIAL = 1000
LEVERAGE = 3
def rsi(close: np.ndarray, period: int = 14) -> np.ndarray:
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)
avg_gain = np.mean(gain[:period])
avg_loss = np.mean(loss[:period])
for i in range(period, len(delta)):
avg_gain = (avg_gain * (period - 1) + gain[i]) / period
avg_loss = (avg_loss * (period - 1) + loss[i]) / period
if avg_loss == 0:
result[i + 1] = 100
else:
rs = avg_gain / avg_loss
result[i + 1] = 100 - 100 / (1 + rs)
return result
def bollinger_pct(close: np.ndarray, window: int = 20) -> np.ndarray:
result = np.full(len(close), 0.5)
for i in range(window, len(close)):
w = close[i - window : i]
ma = np.mean(w)
std = np.std(w)
if std > 0:
result[i] = (close[i] - (ma - 2 * std)) / (4 * std)
return result
def run_mean_reversion(asset, tf="1h"):
df = load_data(asset, tf)
close = df["close"].values
high = df["high"].values
low = df["low"].values
n = len(df)
timestamps = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
hours = timestamps.dt.hour.values
rsi_vals = rsi(close, 14)
bb_pct = bollinger_pct(close, 20)
split = int(n * 0.7)
configs = [
# (rsi_low, rsi_high, allowed_hours, hold_max, stop_pct, name)
(25, 75, list(range(0, 7)), 4, 0.015, "night_0-6_rsi25"),
(30, 70, list(range(0, 7)), 4, 0.015, "night_0-6_rsi30"),
(25, 75, list(range(0, 10)), 6, 0.02, "extended_0-9"),
(30, 70, list(range(20, 24)) + list(range(0, 6)), 4, 0.015, "late_night"),
(20, 80, list(range(0, 24)), 4, 0.015, "all_hours_rsi20"),
# Bollinger band mean reversion
]
print(f"\n{'#'*60}")
print(f" {asset} {tf} — MEAN REVERSION")
print(f"{'#'*60}")
for rsi_low, rsi_high, allowed, hold_max, stop, name in configs:
capital = float(INITIAL)
correct = 0
total = 0
daily_trades = {}
for i in range(max(split, 20), n - hold_max):
hour = hours[i]
if hour not in allowed:
continue
day = timestamps[i].strftime("%Y-%m-%d")
if daily_trades.get(day, 0) >= 2:
continue
direction = None
if rsi_vals[i] < rsi_low and bb_pct[i] < 0.2:
direction = "long"
elif rsi_vals[i] > rsi_high and bb_pct[i] > 0.8:
direction = "short"
if direction is None:
continue
entry = close[i]
best_exit = i + 1
for j in range(i + 1, min(i + hold_max + 1, n)):
price = close[j]
if direction == "long":
pnl_pct = (price - entry) / entry
if pnl_pct <= -stop:
best_exit = j
break
if pnl_pct >= stop * 1.5:
best_exit = j
break
else:
pnl_pct = (entry - price) / entry
if pnl_pct <= -stop:
best_exit = j
break
if pnl_pct >= stop * 1.5:
best_exit = j
break
best_exit = j
exit_price = close[best_exit]
if direction == "long":
trade_ret = (exit_price - entry) / entry
else:
trade_ret = (entry - exit_price) / entry
net = trade_ret * LEVERAGE - FEE * 2 * LEVERAGE
capital += capital * 0.15 * net
capital = max(capital, 0)
is_correct = trade_ret > 0
total += 1
if is_correct:
correct += 1
daily_trades[day] = daily_trades.get(day, 0) + 1
if total < 20:
continue
acc = correct / total * 100
ret = (capital - INITIAL) / INITIAL * 100
test_days = (n - split) / 24
test_years = test_days / 365.25
ann = ((capital / INITIAL) ** (1 / test_years) - 1) * 100 if test_years > 0 and capital > 0 else -100
dpnl = (capital - INITIAL) / test_days if test_days > 0 else 0
days_with_trades = len(daily_trades)
trades_per_day = total / days_with_trades if days_with_trades > 0 else 0
tag = "" if acc >= 60 and ann >= 30 else ""
print(f" {name:25s}: trades={total:5d} acc={acc:.1f}% ret={ret:+.1f}% ann={ann:+.1f}% dd_est ~{abs(min(0, ret/3)):.0f}% €/day={dpnl:.2f} days_active={days_with_trades} {tag}")
for asset in ["ETH", "BTC"]:
run_mean_reversion(asset, "1h")
run_mean_reversion(asset, "15m")
+129
View File
@@ -0,0 +1,129 @@
"""S2-02: Funding Rate Strategy.
Quando il funding rate è molto positivo → troppi long → short il perpetual.
Quando molto negativo → troppi short → long il perpetual.
Si cattura sia il mean reversion del prezzo che il funding rate stesso.
Ingresso: quando funding > 0.03% o < -0.03% (8h rate).
"""
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 = 0.001
INITIAL = 1000
LEVERAGE = 3
def simulate_funding_strategy(asset):
"""Simula funding rate strategy usando il proxy: overnight returns.
Crypto funding settlement ogni 8h → prezzo tende a correggersi dopo settlement.
Proxy: se ultime 8h hanno avuto forte trend, aspettati reversal dopo settlement.
"""
print(f"\n{'#'*60}")
print(f" {asset} — FUNDING RATE PROXY STRATEGY")
print(f"{'#'*60}")
df_1h = load_data(asset, "1h")
close = df_1h["close"].values
volume = df_1h["volume"].values
n = len(close)
split = int(n * 0.7)
timestamps = pd.to_datetime(df_1h["timestamp"], unit="ms", utc=True)
hours = timestamps.dt.hour.values
# Funding settlement su Deribit: 00:00, 08:00, 16:00 UTC
settlement_hours = {0, 8, 16}
configs = [
(0.01, 0.02, 8, 0.02, "mild_1pct"),
(0.015, 0.025, 8, 0.015, "moderate_1.5pct"),
(0.02, 0.03, 8, 0.015, "strong_2pct"),
(0.01, 0.015, 4, 0.01, "fast_1pct_4h"),
(0.02, 0.03, 12, 0.02, "slow_2pct_12h"),
(0.025, 0.04, 6, 0.015, "extreme_2.5pct"),
]
for entry_thr, tp_mult_unused, hold_max, stop, name in configs:
capital = float(INITIAL)
correct = 0
total = 0
daily_trades = {}
for i in range(max(split, 8), n - hold_max):
hour = hours[i]
if hour not in settlement_hours:
continue
day = timestamps[i].strftime("%Y-%m-%d")
if daily_trades.get(day, 0) >= 1:
continue
# 8h return prima del settlement = proxy per funding pressure
ret_8h = (close[i] - close[i - 8]) / close[i - 8]
# Volume spike = conferma
vol_avg = np.mean(volume[max(0, i - 48) : i])
vol_recent = np.mean(volume[i - 8 : i])
vol_spike = vol_recent / vol_avg if vol_avg > 0 else 1
direction = None
if ret_8h > entry_thr and vol_spike > 1.1:
direction = "short" # troppi long, attendi reversal
elif ret_8h < -entry_thr and vol_spike > 1.1:
direction = "long" # troppi short, attendi rimbalzo
if direction is None:
continue
entry_price = close[i]
for j in range(i + 1, min(i + hold_max + 1, n)):
price = close[j]
if direction == "long":
pnl_pct = (price - entry_price) / entry_price
else:
pnl_pct = (entry_price - price) / entry_price
if pnl_pct <= -stop or pnl_pct >= stop * 2 or j == min(i + hold_max, n - 1):
exit_price = price
break
else:
exit_price = close[min(i + hold_max, n - 1)]
if direction == "long":
trade_ret = (exit_price - entry_price) / entry_price
else:
trade_ret = (entry_price - exit_price) / entry_price
# Add funding rate income (approx 0.01% per 8h period if direction correct)
funding_income = 0.0001 * (hold_max / 8) if trade_ret > 0 else 0
net = (trade_ret + funding_income) * LEVERAGE - FEE * 2 * LEVERAGE
capital += capital * 0.2 * net
capital = max(capital, 0)
total += 1
if trade_ret > 0:
correct += 1
daily_trades[day] = daily_trades.get(day, 0) + 1
if total < 10:
continue
acc = correct / total * 100
ret = (capital - INITIAL) / INITIAL * 100
test_days = (n - split) / 24
test_years = test_days / 365.25
ann = ((capital / INITIAL) ** (1 / test_years) - 1) * 100 if test_years > 0 and capital > 0 else -100
dpnl = (capital - INITIAL) / test_days if test_days > 0 else 0
days_active = len(daily_trades)
tag = "" if acc >= 60 and ann >= 30 else ""
print(f" {name:20s}: trades={total:4d} acc={acc:.1f}% ret={ret:+.1f}% ann={ann:+.1f}% €/day={dpnl:.2f} active_days={days_active} {tag}")
for asset in ["ETH", "BTC"]:
simulate_funding_strategy(asset)
+145
View File
@@ -0,0 +1,145 @@
"""S2-03: Volatility Selling — Straddle/Strangle corto simulato.
La IV crypto è cronicamente sopra la realized vol → vendere premium è profittevole.
Simulazione: vendi straddle ATM → profitto = max(0, premium - |move|).
Premium stimato da IV storica. Ingresso giornaliero.
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from scipy.stats import norm
from src.data.downloader import load_data
FEE = 0.001
INITIAL = 1000
def realized_vol(close: np.ndarray, window: int = 24) -> np.ndarray:
"""Annualized realized volatility rolling."""
log_ret = np.diff(np.log(np.where(close == 0, 1e-10, close)))
result = np.full(len(close), 0.5)
for i in range(window, len(log_ret)):
rv = np.std(log_ret[i - window : i]) * np.sqrt(24 * 365)
result[i + 1] = rv
return result
def implied_vol_proxy(close: np.ndarray, window: int = 48) -> np.ndarray:
"""IV proxy: realized vol * premium factor.
Storicamente IV crypto ≈ 1.2-1.5x realized vol (variance risk premium).
"""
rv = realized_vol(close, window)
# Premium factor varia: alto in panic, basso in calma
result = np.full(len(close), 0.5)
for i in range(window, len(close)):
short_rv = realized_vol(close[max(0, i-12):i+1], min(12, i))[-1] if i >= 12 else rv[i]
if rv[i] > 0:
regime = short_rv / rv[i]
premium = 1.15 + 0.3 * max(0, regime - 1) # più alto in regime volatile
else:
premium = 1.2
result[i] = rv[i] * premium
return result
def bs_straddle_price(spot: float, iv: float, dte_hours: float) -> float:
"""Black-Scholes straddle price (call + put ATM)."""
if dte_hours <= 0 or iv <= 0:
return 0
t = dte_hours / (24 * 365)
d1 = (0.5 * iv * iv * t) / (iv * np.sqrt(t))
call = spot * (2 * norm.cdf(d1) - 1)
return call * 2 # straddle = 2 * ATM call (approx for ATM)
def run_vol_selling(asset):
print(f"\n{'#'*60}")
print(f" {asset} — VOLATILITY SELLING (SHORT STRADDLE)")
print(f"{'#'*60}")
df = load_data(asset, "1h")
close = df["close"].values
n = len(close)
split = int(n * 0.7)
timestamps = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
rv = realized_vol(close, 24)
iv_proxy = implied_vol_proxy(close)
configs = [
# (dte_hours, iv_floor, iv_rv_ratio_min, position_pct, name)
(24, 0.3, 1.15, 0.1, "daily_24h"),
(12, 0.3, 1.15, 0.08, "half_day_12h"),
(48, 0.3, 1.10, 0.12, "2day_48h"),
(24, 0.4, 1.20, 0.1, "daily_highIV"),
(8, 0.25, 1.10, 0.06, "ultra_short_8h"),
(24, 0.3, 1.30, 0.15, "daily_bigPremium"),
]
for dte, iv_floor, ratio_min, pos_pct, name in configs:
capital = float(INITIAL)
correct = 0
total = 0
daily_trades = {}
for i in range(max(split, 50), n - dte):
day = timestamps[i].strftime("%Y-%m-%d")
if daily_trades.get(day, 0) >= 1:
continue
hour = timestamps[i].dt.hour if hasattr(timestamps[i], 'dt') else timestamps.iloc[i].hour
if hour != 8: # entrata alle 08 UTC ogni giorno
continue
current_iv = iv_proxy[i]
current_rv = rv[i]
if current_iv < iv_floor:
continue
if current_rv > 0 and current_iv / current_rv < ratio_min:
continue
spot = close[i]
premium = bs_straddle_price(spot, current_iv, dte)
premium_pct = premium / spot
# Actual move during holding period
exit_idx = min(i + dte, n - 1)
actual_move = abs(close[exit_idx] - spot)
actual_move_pct = actual_move / spot
# P&L: premium received - actual move (capped at max loss)
max_loss = spot * 0.05 # cap loss at 5% of spot
pnl = premium - min(actual_move, max_loss + premium)
pnl_on_capital = pnl / spot * pos_pct
fee_cost = FEE * 4 * pos_pct # 4 legs: sell call, sell put, buy back
net_pnl = pnl_on_capital - fee_cost
capital += capital * net_pnl
capital = max(capital, 0)
total += 1
if pnl > 0:
correct += 1
daily_trades[day] = daily_trades.get(day, 0) + 1
if total < 20:
continue
acc = correct / total * 100
ret = (capital - INITIAL) / INITIAL * 100
test_days = (n - split) / 24
test_years = test_days / 365.25
ann = ((capital / INITIAL) ** (1 / test_years) - 1) * 100 if test_years > 0 and capital > 0 else -100
dpnl = (capital - INITIAL) / test_days if test_days > 0 else 0
days_active = len(daily_trades)
tag = "" if acc >= 60 and ann >= 30 else ""
print(f" {name:20s}: trades={total:4d} acc={acc:.1f}% ret={ret:+.1f}% ann={ann:+.1f}% €/day={dpnl:.2f} active={days_active} {tag}")
for asset in ["ETH", "BTC"]:
run_vol_selling(asset)
+159
View File
@@ -0,0 +1,159 @@
"""S2-04: Momentum microstructure su 5m.
Approccio: cattura micro-trend intraday.
- Identifica breakout da consolidamento su 5m
- Conferma con volume e acceleration
- Hold breve (15-30 min), stop stretto
- Target: molti piccoli guadagni, alta frequenza
"""
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 = 0.001
INITIAL = 1000
LEVERAGE = 3
def ema(arr: np.ndarray, period: int) -> np.ndarray:
result = np.full(len(arr), np.nan)
k = 2 / (period + 1)
result[period - 1] = np.mean(arr[:period])
for i in range(period, len(arr)):
result[i] = arr[i] * k + result[i - 1] * (1 - k)
return result
def atr(high: np.ndarray, low: np.ndarray, close: np.ndarray, period: int = 14) -> np.ndarray:
tr = np.maximum(high - low, np.maximum(np.abs(high - np.roll(close, 1)), np.abs(low - np.roll(close, 1))))
tr[0] = high[0] - low[0]
return ema(tr, period)
def run_momentum(asset):
print(f"\n{'#'*60}")
print(f" {asset} 5m — MOMENTUM MICROSTRUCTURE")
print(f"{'#'*60}")
df = load_data(asset, "5m")
close = df["close"].values
high = df["high"].values
low = df["low"].values
volume = df["volume"].values
n = len(close)
split = int(n * 0.7)
timestamps = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
ema_fast = ema(close, 8)
ema_slow = ema(close, 21)
ema_trend = ema(close, 55)
atr_vals = atr(high, low, close, 14)
configs = [
# (consolidation_bars, breakout_atr_mult, hold_bars, stop_atr, tp_atr, min_vol_mult, name)
(12, 1.5, 3, 1.0, 2.0, 1.3, "tight_12bar"),
(12, 1.5, 6, 1.5, 2.5, 1.2, "medium_12bar"),
(24, 2.0, 6, 1.5, 3.0, 1.5, "wide_24bar"),
(6, 1.2, 3, 1.0, 1.5, 1.1, "fast_6bar"),
(12, 1.5, 3, 0.8, 2.0, 1.3, "tight_stop"),
(18, 1.8, 4, 1.2, 2.5, 1.4, "balanced_18bar"),
]
for consol_bars, brk_mult, hold_bars, stop_m, tp_m, vol_mult, name in configs:
capital = float(INITIAL)
correct = 0
total = 0
daily_trades = {}
for i in range(max(split, 60), n - hold_bars):
if np.isnan(ema_fast[i]) or np.isnan(ema_slow[i]) or np.isnan(atr_vals[i]) or atr_vals[i] == 0:
continue
day = timestamps.iloc[i].strftime("%Y-%m-%d")
if daily_trades.get(day, 0) >= 5:
continue
# Consolidation: range delle ultime N barre < 1.5 ATR
consol_range = np.max(high[i - consol_bars : i]) - np.min(low[i - consol_bars : i])
if consol_range > 1.5 * atr_vals[i]:
continue
# Breakout: current bar breaks consolidation range
consol_high = np.max(high[i - consol_bars : i])
consol_low = np.min(low[i - consol_bars : i])
breakout_up = close[i] > consol_high + atr_vals[i] * (brk_mult - 1)
breakout_down = close[i] < consol_low - atr_vals[i] * (brk_mult - 1)
if not (breakout_up or breakout_down):
continue
# Volume confirmation
vol_avg = np.mean(volume[max(0, i - 24) : i])
if vol_avg > 0 and volume[i] < vol_avg * vol_mult:
continue
# Trend filter: only trade in direction of trend
if breakout_up and close[i] < ema_trend[i]:
continue
if breakout_down and close[i] > ema_trend[i]:
continue
direction = "long" if breakout_up else "short"
entry = close[i]
stop_price = entry - atr_vals[i] * stop_m if direction == "long" else entry + atr_vals[i] * stop_m
tp_price = entry + atr_vals[i] * tp_m if direction == "long" else entry - atr_vals[i] * tp_m
exit_price = close[min(i + hold_bars, n - 1)]
for j in range(i + 1, min(i + hold_bars + 1, n)):
if direction == "long":
if low[j] <= stop_price:
exit_price = stop_price
break
if high[j] >= tp_price:
exit_price = tp_price
break
else:
if high[j] >= stop_price:
exit_price = stop_price
break
if low[j] <= tp_price:
exit_price = tp_price
break
exit_price = close[j]
if direction == "long":
trade_ret = (exit_price - entry) / entry
else:
trade_ret = (entry - exit_price) / entry
net = trade_ret * LEVERAGE - FEE * 2 * LEVERAGE
capital += capital * 0.1 * net
capital = max(capital, 0)
total += 1
if trade_ret > 0:
correct += 1
daily_trades[day] = daily_trades.get(day, 0) + 1
if total < 30:
continue
acc = correct / total * 100
ret = (capital - INITIAL) / INITIAL * 100
test_days = (n - split) / (24 * 12)
test_years = test_days / 365.25
ann = ((capital / INITIAL) ** (1 / test_years) - 1) * 100 if test_years > 0 and capital > 0 else -100
dpnl = (capital - INITIAL) / test_days if test_days > 0 else 0
days_active = len(daily_trades)
tag = "" if acc >= 55 and ann >= 30 else ""
print(f" {name:20s}: trades={total:5d} acc={acc:.1f}% ret={ret:+.1f}% ann={ann:+.1f}% €/day={dpnl:.2f} active={days_active} t/day={total/days_active:.1f} {tag}")
for asset in ["ETH", "BTC"]:
run_momentum(asset)
+132
View File
@@ -0,0 +1,132 @@
"""S2-05: Gap fade + overnight reversal.
Crypto non ha gap di apertura classici, ma ha "gap di sessione":
- Asia open (00 UTC): tende a continuare il trend USA precedente
- EU open (07 UTC): spesso corregge eccessi notturni
- USA open (13-14 UTC): alta volatilità, breakout o reversal
Strategia: fai fade dell'overextension al cambio sessione.
Se il prezzo ha fatto >1.5% nella sessione precedente, aspettati reversal.
"""
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 = 0.001
INITIAL = 1000
LEVERAGE = 3
def run_gap_fade(asset, tf="1h"):
print(f"\n{'#'*60}")
print(f" {asset} {tf} — GAP FADE / SESSION REVERSAL")
print(f"{'#'*60}")
df = load_data(asset, tf)
close = df["close"].values
high = df["high"].values
low = df["low"].values
n = len(close)
split = int(n * 0.7)
timestamps = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
hours = timestamps.dt.hour.values
session_opens = {
"asia": 0,
"eu": 7,
"usa": 14,
}
configs = [
# (session_name, lookback_hours, entry_thr, hold_hours, stop_pct, name)
("eu", 7, 0.015, 4, 0.012, "eu_fade_1.5pct"),
("eu", 7, 0.02, 4, 0.015, "eu_fade_2pct"),
("eu", 7, 0.01, 6, 0.01, "eu_fade_1pct_6h"),
("usa", 7, 0.015, 4, 0.012, "usa_fade_1.5pct"),
("usa", 7, 0.02, 4, 0.015, "usa_fade_2pct"),
("asia", 8, 0.02, 6, 0.015, "asia_fade_2pct"),
("eu", 7, 0.025, 3, 0.015, "eu_fade_2.5pct_fast"),
("usa", 6, 0.015, 3, 0.01, "usa_fade_fast"),
]
for session, lookback, entry_thr, hold, stop, name in configs:
capital = float(INITIAL)
correct = 0
total = 0
daily_trades = {}
session_hour = session_opens[session]
for i in range(max(split, lookback + 1), n - hold):
if hours[i] != session_hour:
continue
day = timestamps.iloc[i].strftime("%Y-%m-%d")
if daily_trades.get(day, 0) >= 1:
continue
prev_ret = (close[i] - close[i - lookback]) / close[i - lookback]
direction = None
if prev_ret > entry_thr:
direction = "short" # fade the rally
elif prev_ret < -entry_thr:
direction = "long" # fade the dump
if direction is None:
continue
entry = close[i]
exit_price = close[min(i + hold, n - 1)]
for j in range(i + 1, min(i + hold + 1, n)):
if direction == "long":
if (close[j] - entry) / entry >= stop * 2:
exit_price = close[j]
break
if (entry - close[j]) / entry >= stop:
exit_price = close[j]
break
else:
if (entry - close[j]) / entry >= stop * 2:
exit_price = close[j]
break
if (close[j] - entry) / entry >= stop:
exit_price = close[j]
break
exit_price = close[j]
if direction == "long":
trade_ret = (exit_price - entry) / entry
else:
trade_ret = (entry - exit_price) / entry
net = trade_ret * LEVERAGE - FEE * 2 * LEVERAGE
capital += capital * 0.2 * net
capital = max(capital, 0)
total += 1
if trade_ret > 0:
correct += 1
daily_trades[day] = daily_trades.get(day, 0) + 1
if total < 15:
continue
acc = correct / total * 100
ret = (capital - INITIAL) / INITIAL * 100
test_days = (n - split) / 24
test_years = test_days / 365.25
ann = ((capital / INITIAL) ** (1 / test_years) - 1) * 100 if test_years > 0 and capital > 0 else -100
dpnl = (capital - INITIAL) / test_days if test_days > 0 else 0
days_active = len(daily_trades)
tag = "" if acc >= 58 and ann >= 30 else ""
print(f" {name:25s}: trades={total:4d} acc={acc:.1f}% ret={ret:+.1f}% ann={ann:+.1f}% €/day={dpnl:.2f} active={days_active} {tag}")
for asset in ["ETH", "BTC"]:
run_gap_fade(asset)
+164
View File
@@ -0,0 +1,164 @@
"""S2-06: Iron Condor simulato + Variance Risk Premium harvesting.
Vendi un range: se il prezzo sta dentro il range a scadenza → profitto.
Più sofisticato del vol selling puro:
- Calcolo IV vs RV (variance risk premium)
- Selezione larghezza condor in base a IV/RV ratio
- Dynamic position sizing: più capital quando IV/RV ratio è alto
- Ingresso giornaliero, scadenze 24h e 48h
- Include: tail risk protection (chiudi se move > 2 ATR)
"""
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 = 0.001
INITIAL = 1000
def realized_vol_ann(close: np.ndarray, window: int) -> np.ndarray:
log_ret = np.diff(np.log(np.where(close == 0, 1e-10, close)))
result = np.full(len(close), 0.5)
for i in range(window, len(log_ret)):
result[i + 1] = np.std(log_ret[i - window : i]) * np.sqrt(24 * 365)
return result
def run_iron_condor(asset, tf="1h"):
print(f"\n{'#'*60}")
print(f" {asset} {tf} — IRON CONDOR / VARIANCE PREMIUM")
print(f"{'#'*60}")
df = load_data(asset, tf)
close = df["close"].values
high = df["high"].values
low = df["low"].values
n = len(close)
split = int(n * 0.7)
timestamps = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
rv_24 = realized_vol_ann(close, 24)
rv_48 = realized_vol_ann(close, 48)
rv_168 = realized_vol_ann(close, 168) # 1 week
IV_PREMIUM = 1.25 # IV typically 1.2-1.3x RV in crypto
configs = [
# (dte_hours, condor_width_mult, max_loss_pct, vrp_min, pos_pct, name)
(24, 1.0, 0.03, 1.10, 0.15, "24h_1x_std"),
(24, 1.5, 0.04, 1.10, 0.12, "24h_1.5x_safe"),
(24, 0.8, 0.025, 1.15, 0.18, "24h_0.8x_aggr"),
(48, 1.0, 0.035, 1.10, 0.15, "48h_1x_std"),
(48, 1.5, 0.05, 1.10, 0.12, "48h_1.5x_safe"),
(48, 0.7, 0.025, 1.20, 0.20, "48h_0.7x_highVRP"),
(72, 1.2, 0.04, 1.10, 0.12, "72h_1.2x"),
(24, 1.0, 0.03, 1.30, 0.20, "24h_veryHighVRP"),
(24, 1.2, 0.035, 1.10, 0.15, "24h_1.2x_balanced"),
]
for dte, width_mult, max_loss, vrp_min, pos_pct, name in configs:
capital = float(INITIAL)
correct = 0
total = 0
daily_trades = {}
max_dd = 0
peak = capital
for i in range(max(split, 170), n - dte):
day = timestamps.iloc[i].strftime("%Y-%m-%d")
if daily_trades.get(day, 0) >= 1:
continue
hour = timestamps.iloc[i].hour
if hour != 8:
continue
rv_short = rv_24[i]
rv_long = rv_168[i]
if rv_short <= 0 or rv_long <= 0:
continue
iv_est = rv_long * IV_PREMIUM
vrp_ratio = iv_est / rv_short
if vrp_ratio < vrp_min:
continue
spot = close[i]
t_years = dte / (24 * 365)
# Condor range: spot ± width * daily_std * sqrt(t)
daily_std = rv_short / np.sqrt(365)
range_width = width_mult * daily_std * np.sqrt(dte / 24) * spot
upper_strike = spot + range_width
lower_strike = spot - range_width
# Premium collected (simplified BS for condor)
# Premium ≈ IV * sqrt(t) * (width factor)
premium_pct = iv_est * np.sqrt(t_years) * 0.4 * (1 / width_mult)
# Check if price stays in range
exit_idx = min(i + dte, n - 1)
price_path = close[i : exit_idx + 1]
max_move = max(np.max(price_path) - spot, spot - np.min(price_path))
final_price = close[exit_idx]
in_range = lower_strike <= final_price <= upper_strike
breached_hard = max_move > spot * max_loss
if breached_hard:
pnl_pct = -max_loss * pos_pct
elif in_range:
pnl_pct = premium_pct * pos_pct
else:
# Partial loss: exceeded range but not catastrophic
excess = max(0, final_price - upper_strike, lower_strike - final_price)
loss = min(excess / spot, max_loss)
pnl_pct = (premium_pct - loss) * pos_pct
fee_cost = FEE * 2 * pos_pct
net_pnl = pnl_pct - fee_cost
capital += capital * net_pnl
capital = max(capital, 0)
if capital > peak:
peak = capital
dd = (peak - capital) / peak if peak > 0 else 0
max_dd = max(max_dd, dd)
total += 1
if net_pnl > 0:
correct += 1
daily_trades[day] = daily_trades.get(day, 0) + 1
if total < 20:
continue
acc = correct / total * 100
ret = (capital - INITIAL) / INITIAL * 100
test_days = (n - split) / 24
test_years = test_days / 365.25
ann = ((capital / INITIAL) ** (1 / test_years) - 1) * 100 if test_years > 0 and capital > 0 else -100
dpnl = (capital - INITIAL) / test_days if test_days > 0 else 0
days_active = len(daily_trades)
tag = "✅✅" if acc >= 70 and ann >= 50 else "" if acc >= 65 and ann >= 30 else ""
print(f" {name:22s}: trades={total:4d} acc={acc:.1f}% ret={ret:+.1f}% ann={ann:+.1f}% dd={max_dd*100:.1f}% €/day={dpnl:.2f} active={days_active} {tag}")
for asset in ["ETH", "BTC"]:
run_iron_condor(asset)
# === COMBINAZIONE: Iron Condor + Funding + Gap Fade ===
print(f"\n{'#'*60}")
print(f" COMBINAZIONE: MULTI-STRATEGY PORTFOLIO")
print(f"{'#'*60}")
# Simula portafoglio: 50% iron condor ETH, 25% iron condor BTC, 25% gap fade ETH
print(" (Dettagli nel prossimo script con backtest combinato)")
+252
View File
@@ -0,0 +1,252 @@
"""S2-07: Variance Risk Premium harvesting — versione raffinata.
Ottimizzazione del vol selling con:
1. IV/RV ratio dinamico per entry timing
2. Tail risk cutoff (chiudi se move > N sigma)
3. Position sizing proporzionale al premium
4. Combinazione con directional bias (da gap fade)
5. Multi-asset portfolio (ETH + BTC)
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from scipy.stats import norm
from src.data.downloader import load_data
FEE = 0.001
INITIAL = 1000
def realized_vol(close, window=24):
log_ret = np.diff(np.log(np.where(close == 0, 1e-10, close)))
result = np.full(len(close), 0.5)
for i in range(window, len(log_ret)):
result[i + 1] = np.std(log_ret[i - window : i]) * np.sqrt(24 * 365)
return result
def run_vrp(asset):
print(f"\n{'#'*60}")
print(f" {asset} 1h — VARIANCE RISK PREMIUM REFINED")
print(f"{'#'*60}")
df = load_data(asset, "1h")
close = df["close"].values
high = df["high"].values
low = df["low"].values
n = len(close)
split = int(n * 0.7)
timestamps = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
rv_24 = realized_vol(close, 24)
rv_48 = realized_vol(close, 48)
rv_168 = realized_vol(close, 168)
configs = [
# (dte_h, iv_mult, cutoff_sigma, pos_base, entry_hour, dynamic_sizing, name)
(24, 1.20, 2.5, 0.10, 8, False, "24h_base"),
(24, 1.25, 2.5, 0.12, 8, False, "24h_highPrem"),
(24, 1.20, 2.0, 0.10, 8, False, "24h_tightCut"),
(24, 1.20, 3.0, 0.12, 8, False, "24h_wideCut"),
(48, 1.20, 2.5, 0.12, 8, False, "48h_base"),
(48, 1.25, 2.5, 0.15, 8, False, "48h_highPrem"),
(48, 1.30, 2.5, 0.15, 8, False, "48h_vhighPrem"),
(48, 1.20, 3.0, 0.15, 8, False, "48h_wideCut"),
(24, 1.20, 2.5, 0.10, 8, True, "24h_dynSize"),
(48, 1.20, 2.5, 0.12, 8, True, "48h_dynSize"),
(24, 1.20, 2.5, 0.10, 0, False, "24h_midnight"),
(24, 1.20, 2.5, 0.10, 16, False, "24h_afternoon"),
(36, 1.22, 2.5, 0.12, 8, False, "36h_medium"),
(24, 1.15, 2.5, 0.08, 8, False, "24h_lowPrem_safe"),
(48, 1.20, 2.0, 0.10, 8, True, "48h_tight_dyn"),
]
for dte, iv_mult, cutoff, pos_base, entry_h, dyn_size, name in configs:
capital = float(INITIAL)
correct = 0
total = 0
daily_trades = {}
peak_capital = capital
max_dd = 0
for i in range(max(split, 170), n - dte):
day = timestamps.iloc[i].strftime("%Y-%m-%d")
if daily_trades.get(day, 0) >= 1:
continue
if timestamps.iloc[i].hour != entry_h:
continue
rv_s = rv_24[i]
rv_l = rv_168[i]
if rv_s <= 0.05 or rv_l <= 0.05:
continue
iv_est = rv_l * iv_mult
vrp = iv_est - rv_s
if vrp <= 0:
continue
spot = close[i]
t = dte / (24 * 365)
daily_std = rv_s / np.sqrt(365)
# Premium = IV * sqrt(t) * spot * factor
premium = iv_est * np.sqrt(t) * spot * 0.4
premium_pct = premium / spot
# Expected move based on IV
expected_move = iv_est * np.sqrt(t) * spot
# Cutoff: close if actual move > cutoff * expected_move
max_allowed_move = expected_move * cutoff
# Dynamic sizing: more when VRP is high
if dyn_size:
vrp_ratio = vrp / rv_s
pos_pct = min(pos_base * (1 + vrp_ratio), pos_base * 2)
else:
pos_pct = pos_base
# Check actual path
exit_idx = min(i + dte, n - 1)
actual_move = abs(close[exit_idx] - spot)
# Early exit: check if intra-period move exceeds cutoff
breached = False
for j in range(i + 1, exit_idx + 1):
intra_move = abs(close[j] - spot)
if intra_move > max_allowed_move:
breached = True
exit_idx = j
actual_move = intra_move
break
if breached:
loss = min(actual_move / spot, 0.05) * pos_pct
pnl = -loss
else:
profit = premium_pct * pos_pct
partial_loss = max(0, actual_move / spot - premium_pct) * pos_pct * 0.5
pnl = profit - partial_loss
fee_cost = FEE * 2 * pos_pct
net = pnl - fee_cost
capital += capital * net
capital = max(capital, 0)
if capital > peak_capital:
peak_capital = capital
dd = (peak_capital - capital) / peak_capital if peak_capital > 0 else 0
max_dd = max(max_dd, dd)
total += 1
if pnl > 0:
correct += 1
daily_trades[day] = daily_trades.get(day, 0) + 1
if total < 20:
continue
acc = correct / total * 100
ret = (capital - INITIAL) / INITIAL * 100
test_days = (n - split) / 24
test_years = test_days / 365.25
ann = ((capital / INITIAL) ** (1 / test_years) - 1) * 100 if test_years > 0 and capital > 0 else -100
dpnl = (capital - INITIAL) / test_days if test_days > 0 else 0
days_active = len(daily_trades)
tag = "✅✅" if acc >= 70 and ann >= 50 else "" if acc >= 65 and ann >= 30 else ""
print(f" {name:22s}: trades={total:4d} acc={acc:.1f}% ret={ret:+.1f}% ann={ann:+.1f}% dd={max_dd*100:.1f}% €/day={dpnl:.2f} active={days_active} {tag}")
return daily_trades
# Run both assets
results = {}
for asset in ["ETH", "BTC"]:
results[asset] = run_vrp(asset)
# Multi-asset portfolio simulation
print(f"\n{'#'*60}")
print(f" MULTI-ASSET PORTFOLIO: ETH + BTC")
print(f"{'#'*60}")
df_eth = load_data("ETH", "1h")
df_btc = load_data("BTC", "1h")
close_eth = df_eth["close"].values
close_btc = df_btc["close"].values
n = min(len(close_eth), len(close_btc))
split = int(n * 0.7)
ts = pd.to_datetime(df_eth["timestamp"].values[:n], unit="ms", utc=True)
rv_eth = realized_vol(close_eth[:n], 168)
rv_btc = realized_vol(close_btc[:n], 168)
capital = float(INITIAL)
total = 0
correct = 0
peak = capital
max_dd = 0
daily_trades = {}
for i in range(max(split, 170), n - 48):
day = ts[i].strftime("%Y-%m-%d")
if daily_trades.get(day, 0) >= 1:
continue
if ts[i].hour != 8:
continue
for asset_close, rv_arr, name in [(close_eth[:n], rv_eth, "ETH"), (close_btc[:n], rv_btc, "BTC")]:
rv = rv_arr[i]
if rv <= 0.05:
continue
iv = rv * 1.22
spot = asset_close[i]
t = 48 / (24 * 365)
premium_pct = iv * np.sqrt(t) * 0.4
expected_move = iv * np.sqrt(t) * spot
max_move = expected_move * 2.5
exit_idx = min(i + 48, n - 1)
actual_move = abs(asset_close[exit_idx] - spot)
breached = False
for j in range(i + 1, exit_idx + 1):
if abs(asset_close[j] - spot) > max_move:
breached = True
actual_move = abs(asset_close[j] - spot)
break
pos_pct = 0.07 # 7% per asset = 14% total
if breached:
pnl = -min(actual_move / spot, 0.05) * pos_pct
else:
profit = premium_pct * pos_pct
partial = max(0, actual_move / spot - premium_pct) * pos_pct * 0.5
pnl = profit - partial
capital += capital * (pnl - FEE * 2 * pos_pct)
capital = max(capital, 0)
total += 1
if pnl > 0:
correct += 1
if capital > peak:
peak = capital
dd = (peak - capital) / peak if peak > 0 else 0
max_dd = max(max_dd, dd)
daily_trades[day] = daily_trades.get(day, 0) + 1
if total > 0:
acc = correct / total * 100
ret = (capital - INITIAL) / INITIAL * 100
test_days = (n - split) / 24
test_years = test_days / 365.25
ann = ((capital / INITIAL) ** (1 / test_years) - 1) * 100 if test_years > 0 and capital > 0 else -100
dpnl = (capital - INITIAL) / test_days if test_days > 0 else 0
print(f"\n ETH+BTC 48h portfolio: trades={total:4d} acc={acc:.1f}% ret={ret:+.1f}% ann={ann:+.1f}% dd={max_dd*100:.1f}% €/day={dpnl:.2f} active={len(daily_trades)}")
+245
View File
@@ -0,0 +1,245 @@
"""S2-08: VRP Honest Test.
Problemi del test precedente:
1. IV stimata con moltiplicatore fisso → troppo ottimista
2. Nessun stress test su crash
3. Nessun costo di margin
4. Walk-forward mancante
Fix:
- IV calcolata come rolling ratio IV/RV da dati DVOL reali (90 giorni)
e applicata storicamente con variabilità
- Stress test esplicito su periodi di crisi
- Margin requirement: 5% del notional bloccato
- Walk-forward: retrain IV/RV ratio ogni 30 giorni
- Fee realistiche: 0.05% maker + 0.05% taker per gamba = 0.2% roundtrip straddle
- Slippage: 0.1% per esecuzione
"""
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
# Costi REALISTICI Deribit options
FEE_PER_LEG = 0.0003 # 0.03% per leg (Deribit option fee)
SLIPPAGE = 0.001 # 0.1% bid-ask spread per leg
TOTAL_COST_ROUNDTRIP = (FEE_PER_LEG + SLIPPAGE) * 4 # 4 legs: sell call, sell put, buy back both
MARGIN_REQUIREMENT = 0.05 # 5% del notional bloccato come margine
INITIAL = 1000
def realized_vol_ann(close, window):
log_ret = np.diff(np.log(np.where(close == 0, 1e-10, close)))
result = np.full(len(close), np.nan)
for i in range(window, len(log_ret)):
result[i + 1] = np.std(log_ret[i - window : i]) * np.sqrt(24 * 365)
return result
def iv_estimate_realistic(rv_short, rv_long, regime_vol):
"""Stima IV realistica basata su regime.
In calma: IV ≈ 1.1-1.2x RV
In stress: IV ≈ 0.8-1.0x RV (perché RV è già esplosa ma IV non tiene il passo)
Post-crash: IV ≈ 1.5-2.0x RV (IV elevata, RV sta scendendo)
"""
if rv_short <= 0 or rv_long <= 0:
return rv_long * 1.1 if rv_long > 0 else 0.5
# Regime detection
regime_ratio = rv_short / rv_long
if regime_ratio > 2.0:
# CRASH in corso: RV short term esplosa, IV non scala altrettanto
premium = 0.85 + np.random.normal(0, 0.05)
elif regime_ratio > 1.3:
# Alta volatilità: premium compresso
premium = 1.0 + np.random.normal(0, 0.05)
elif regime_ratio < 0.7:
# Post-crash calma: IV ancora alta, RV scesa
premium = 1.3 + np.random.normal(0, 0.1)
else:
# Normale: premium standard
premium = 1.15 + np.random.normal(0, 0.08)
premium = max(0.7, min(premium, 1.8)) # clamp
return rv_long * premium
def straddle_premium_pct(iv, dte_hours):
"""Premium straddle ATM in % del spot. Approssimazione BS."""
if iv <= 0 or dte_hours <= 0:
return 0
t = dte_hours / (24 * 365)
# ATM straddle ≈ spot * iv * sqrt(t) * 0.8 (approssimazione standard)
return iv * np.sqrt(t) * 0.8
def run_vrp_honest(asset, dte_hours=24, n_simulations=5):
print(f"\n{'='*65}")
print(f" {asset} — VRP HONEST TEST (DTE={dte_hours}h)")
print(f" Fees: {TOTAL_COST_ROUNDTRIP*100:.2f}% roundtrip, Slippage incluso")
print(f" Margin: {MARGIN_REQUIREMENT*100}% del notional")
print(f"{'='*65}")
df = load_data(asset, "1h")
close = df["close"].values
n = len(close)
split = int(n * 0.7)
timestamps = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
rv_24 = realized_vol_ann(close, 24)
rv_72 = realized_vol_ann(close, 72)
rv_168 = realized_vol_ann(close, 168)
# Identifica periodi di crisi per report separato
crisis_periods = {
"COVID crash (Mar 2020)": ("2020-03-01", "2020-04-01"),
"May 2021 crash": ("2021-05-01", "2021-06-01"),
"Luna/3AC (Jun 2022)": ("2022-06-01", "2022-07-15"),
"FTX collapse (Nov 2022)": ("2022-11-01", "2022-12-15"),
}
all_sim_results = []
for sim in range(n_simulations):
np.random.seed(42 + sim)
capital = float(INITIAL)
total = 0
correct = 0
peak = capital
max_dd = 0
daily_trades = {}
crisis_pnl = {k: 0.0 for k in crisis_periods}
for i in range(max(split, 170), n - dte_hours):
day = timestamps.iloc[i].strftime("%Y-%m-%d")
if daily_trades.get(day, 0) >= 1:
continue
if timestamps.iloc[i].hour != 8:
continue
rv_s = rv_24[i]
rv_m = rv_72[i]
rv_l = rv_168[i]
if np.isnan(rv_s) or np.isnan(rv_l) or rv_s <= 0.05 or rv_l <= 0.05:
continue
# IV realistica con variabilità
iv = iv_estimate_realistic(rv_s, rv_l, rv_m)
# Premium straddle
prem_pct = straddle_premium_pct(iv, dte_hours)
if prem_pct <= TOTAL_COST_ROUNDTRIP:
continue # non vale la pena, costi > premium
spot = close[i]
# Position size: limitata dal margine
margin_per_unit = spot * MARGIN_REQUIREMENT
max_notional = capital / margin_per_unit * spot
pos_pct = min(0.15, capital / (spot * MARGIN_REQUIREMENT * 10)) # conservativo
# Actual path
exit_idx = min(i + dte_hours, n - 1)
actual_move_pct = abs(close[exit_idx] - spot) / spot
# Intra-period max move (per stress check)
path = close[i : exit_idx + 1]
max_adverse_pct = max(np.max(path) - spot, spot - np.min(path)) / spot
# P&L straddle short
if actual_move_pct <= prem_pct:
# In profitto: premium - actual move
raw_pnl_pct = (prem_pct - actual_move_pct) * pos_pct
else:
# In perdita: move > premium
loss = actual_move_pct - prem_pct
# Cap loss at 3x premium (risk management)
loss = min(loss, prem_pct * 3)
raw_pnl_pct = -loss * pos_pct
# Costi
cost = TOTAL_COST_ROUNDTRIP * pos_pct
net_pnl_pct = raw_pnl_pct - cost
capital += capital * net_pnl_pct
capital = max(capital, 10) # floor
if capital > peak:
peak = capital
dd = (peak - capital) / peak
max_dd = max(max_dd, dd)
total += 1
if raw_pnl_pct > 0:
correct += 1
daily_trades[day] = daily_trades.get(day, 0) + 1
# Track crisis PnL
for crisis_name, (c_start, c_end) in crisis_periods.items():
if c_start <= day <= c_end:
crisis_pnl[crisis_name] += capital * net_pnl_pct
if total < 20:
continue
acc = correct / total * 100
ret = (capital - INITIAL) / INITIAL * 100
test_days = (n - split) / 24
test_years = test_days / 365.25
ann = ((capital / INITIAL) ** (1 / test_years) - 1) * 100 if test_years > 0 and capital > 0 else -100
dpnl = (capital - INITIAL) / test_days if test_days > 0 else 0
all_sim_results.append({
"sim": sim,
"trades": total,
"accuracy": acc,
"return": ret,
"annualized": ann,
"max_dd": max_dd * 100,
"daily_pnl": dpnl,
"final_capital": capital,
"days_active": len(daily_trades),
"crisis_pnl": crisis_pnl,
})
if not all_sim_results:
print(" No results!")
return
# Aggregate across simulations
accs = [r["accuracy"] for r in all_sim_results]
anns = [r["annualized"] for r in all_sim_results]
dds = [r["max_dd"] for r in all_sim_results]
dpnls = [r["daily_pnl"] for r in all_sim_results]
rets = [r["return"] for r in all_sim_results]
print(f"\n {'Metric':<20s} {'Mean':>10s} {'Min':>10s} {'Max':>10s}")
print(f" {'-'*50}")
print(f" {'Accuracy':.<20s} {np.mean(accs):>9.1f}% {np.min(accs):>9.1f}% {np.max(accs):>9.1f}%")
print(f" {'Annualized':.<20s} {np.mean(anns):>9.1f}% {np.min(anns):>9.1f}% {np.max(anns):>9.1f}%")
print(f" {'Max Drawdown':.<20s} {np.mean(dds):>9.1f}% {np.min(dds):>9.1f}% {np.max(dds):>9.1f}%")
print(f" {'€/day':.<20s} {np.mean(dpnls):>9.2f}{np.min(dpnls):>9.2f}{np.max(dpnls):>9.2f}")
print(f" {'Total return':.<20s} {np.mean(rets):>9.1f}% {np.min(rets):>9.1f}% {np.max(rets):>9.1f}%")
print(f" {'Trades':.<20s} {all_sim_results[0]['trades']:>10d}")
print(f" {'Days active':.<20s} {all_sim_results[0]['days_active']:>10d}")
# Crisis performance
print(f"\n STRESS TEST — Performance durante crisi:")
for crisis_name in crisis_periods:
crisis_vals = [r["crisis_pnl"][crisis_name] for r in all_sim_results]
avg_crisis = np.mean(crisis_vals)
print(f" {crisis_name:30s}: avg PnL = €{avg_crisis:+.2f}")
return all_sim_results
# Run con diversi DTE
for asset in ["ETH", "BTC"]:
for dte in [24, 48]:
run_vrp_honest(asset, dte, n_simulations=10)
+181
View File
@@ -0,0 +1,181 @@
"""S2-09: VRP test per-anno — verità nuda.
Test su OGNI anno separatamente per vedere performance durante crash.
Niente compounding — PnL medio per trade in punti percentuali.
Costi realistici Deribit options.
"""
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_ROUNDTRIP = 0.0052 # 0.52% roundtrip (4 legs × 0.13%)
INITIAL = 1000
def rv_ann(close, window):
lr = np.diff(np.log(np.where(close == 0, 1e-10, close)))
r = np.full(len(close), np.nan)
for i in range(window, len(lr)):
r[i + 1] = np.std(lr[i - window : i]) * np.sqrt(24 * 365)
return r
def straddle_prem(iv, dte_h):
if iv <= 0 or dte_h <= 0:
return 0
return iv * np.sqrt(dte_h / (24 * 365)) * 0.8
def run_per_year(asset, dte=24):
print(f"\n{'='*70}")
print(f" {asset} — VRP PER ANNO (DTE={dte}h, NO compounding)")
print(f" Fee roundtrip: {FEE_ROUNDTRIP*100:.2f}%")
print(f"{'='*70}")
df = load_data(asset, "1h")
close = df["close"].values
n = len(close)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
rv_24 = rv_ann(close, 24)
rv_168 = rv_ann(close, 168)
# IV/RV premium: conservative estimate per regime
# Storicamene crypto VRP ≈ 15-30% (IV/RV ≈ 1.15-1.30)
# Ma durante crash VRP va NEGATIVO (RV > IV)
years = sorted(set(ts.dt.year))
print(f"\n {'Year':>6s} {'Trades':>7s} {'Wins':>5s} {'Acc%':>6s} {'AvgPnL%':>9s} {'TotPnL€':>9s} {'Worst%':>8s} {'MaxMove%':>9s}")
print(f" {'-'*70}")
all_pnls = []
yearly_stats = []
for year in years:
year_mask = ts.dt.year == year
year_indices = np.where(year_mask.values)[0]
if len(year_indices) < 200:
continue
trades_pnl = []
trades_detail = []
for i in year_indices:
if i < 170 or i + dte >= n:
continue
if ts.iloc[i].hour != 8:
continue
rv_s = rv_24[i]
rv_l = rv_168[i]
if np.isnan(rv_s) or np.isnan(rv_l) or rv_s < 0.05 or rv_l < 0.05:
continue
# IV estimate: regime-dependent
regime = rv_s / rv_l if rv_l > 0 else 1.0
if regime > 2.0:
# CRASH: RV esplosa, IV probabilmente = RV o meno
iv_premium_factor = 0.9
elif regime > 1.5:
iv_premium_factor = 1.0
elif regime > 1.0:
iv_premium_factor = 1.1
else:
# Calm: VRP positivo
iv_premium_factor = 1.2
iv = rv_l * iv_premium_factor
prem = straddle_prem(iv, dte)
spot = close[i]
exit_idx = min(i + dte, n - 1)
actual_move = abs(close[exit_idx] - spot) / spot
# P&L (senza compounding — flat € su €1000)
pos_size = INITIAL * 0.10 # 10% fisso, no leverage
if actual_move <= prem:
raw_pnl = (prem - actual_move) * pos_size
else:
raw_pnl = -(actual_move - prem) * pos_size
raw_pnl = max(raw_pnl, -pos_size * 0.05) # cap loss
cost = FEE_ROUNDTRIP * pos_size
net_pnl = raw_pnl - cost
trades_pnl.append(net_pnl)
trades_detail.append({
"prem": prem,
"move": actual_move,
"regime": regime,
"rv_s": rv_s,
"iv": iv,
})
all_pnls.append(net_pnl)
if not trades_pnl:
continue
wins = sum(1 for p in trades_pnl if p > 0)
acc = wins / len(trades_pnl) * 100
avg_pnl = np.mean(trades_pnl)
tot_pnl = np.sum(trades_pnl)
worst = np.min(trades_pnl)
max_move = max(t["move"] for t in trades_detail) * 100
tag = ""
if year in [2020, 2021, 2022]:
tag = " ← CRASH YEAR"
if acc >= 70 and avg_pnl > 0:
tag += ""
print(f" {year:>6d} {len(trades_pnl):>7d} {wins:>5d} {acc:>5.1f}% {avg_pnl:>+8.2f}{tot_pnl:>+8.0f}{worst:>+7.2f}{max_move:>8.1f}% {tag}")
yearly_stats.append({
"year": year, "trades": len(trades_pnl), "acc": acc,
"avg_pnl": avg_pnl, "tot_pnl": tot_pnl, "worst": worst,
})
# Summary
if all_pnls:
total_trades = len(all_pnls)
total_wins = sum(1 for p in all_pnls if p > 0)
print(f"\n {'TOTALE':>6s} {total_trades:>7d} {total_wins:>5d} {total_wins/total_trades*100:>5.1f}% {np.mean(all_pnls):>+8.2f}{np.sum(all_pnls):>+8.0f}{np.min(all_pnls):>+7.2f}")
# Con compounding realistico
capital = float(INITIAL)
peak = capital
max_dd = 0
for pnl in all_pnls:
capital += pnl * (capital / INITIAL) # scala con capitale
capital = max(capital, 10)
if capital > peak:
peak = capital
dd = (peak - capital) / peak
max_dd = max(max_dd, dd)
years_total = (yearly_stats[-1]["year"] - yearly_stats[0]["year"] + 1)
ann = ((capital / INITIAL) ** (1 / years_total) - 1) * 100 if capital > 0 else -100
daily_avg = (capital - INITIAL) / (total_trades) # approx 1 trade/day
print(f"\n CON COMPOUNDING:")
print(f" Capitale finale: €{capital:,.0f}")
print(f" ROI annualizzato: {ann:+.1f}%")
print(f" Max Drawdown: {max_dd*100:.1f}%")
print(f" €/trade medio: €{daily_avg:.2f}")
# Worst year
worst_year = min(yearly_stats, key=lambda x: x["tot_pnl"])
best_year = max(yearly_stats, key=lambda x: x["tot_pnl"])
print(f"\n Anno peggiore: {worst_year['year']}{worst_year['tot_pnl']:+.0f}€ ({worst_year['acc']:.0f}% acc)")
print(f" Anno migliore: {best_year['year']}{best_year['tot_pnl']:+.0f}€ ({best_year['acc']:.0f}% acc)")
for asset in ["ETH", "BTC"]:
for dte in [24, 48]:
run_per_year(asset, dte)
+297
View File
@@ -0,0 +1,297 @@
"""S2-10: VRP + filtri multipli per alzare accuracy.
Filtri testati:
1. NO vol sell se squeeze attivo (compressione → breakout imminente, NON vendere vol)
2. NO vol sell se RV short-term > RV long-term (regime esplosivo)
3. NO vol sell se move delle ultime 4h > 2% (momentum in corso)
4. NO vol sell se volume spike > 2x media (evento in corso)
5. COMBINAZIONI dei filtri sopra
Test per-anno, NO compounding per PnL medio, compounding a fine report.
"""
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_ROUNDTRIP = 0.0052
INITIAL = 1000
def rv_ann(close, window):
lr = np.diff(np.log(np.where(close == 0, 1e-10, close)))
r = np.full(len(close), np.nan)
for i in range(window, len(lr)):
r[i + 1] = np.std(lr[i - window : i]) * np.sqrt(24 * 365)
return r
def keltner_ratio(close, high, low, window=14):
n = len(close)
result = np.full(n, np.nan)
for i in range(window, n):
wc = close[i - window : i]
wh = high[i - window : i]
wl = low[i - window : i]
ma = np.mean(wc)
bb_std = np.std(wc)
tr = np.maximum(wh - wl, np.maximum(np.abs(wh - np.roll(wc, 1)), np.abs(wl - np.roll(wc, 1))))
atr = np.mean(tr[1:])
kc_r = (ma + 1.5 * atr) - (ma - 1.5 * atr)
bb_r = (ma + 2 * bb_std) - (ma - 2 * bb_std)
if kc_r > 0:
result[i] = bb_r / kc_r
return result
def straddle_prem(iv, dte_h):
if iv <= 0 or dte_h <= 0:
return 0
return iv * np.sqrt(dte_h / (24 * 365)) * 0.8
def run_filtered(asset, dte=48):
print(f"\n{'='*75}")
print(f" {asset} — VRP + FILTRI (DTE={dte}h)")
print(f"{'='*75}")
df = load_data(asset, "1h")
close = df["close"].values
high = df["high"].values
low = df["low"].values
volume = df["volume"].values
n = len(close)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
rv_24 = rv_ann(close, 24)
rv_168 = rv_ann(close, 168)
kcr = keltner_ratio(close, high, low, 14)
# Pre-calcolo filtri
vol_avg_48 = np.full(n, np.nan)
for i in range(48, n):
vol_avg_48[i] = np.mean(volume[i - 48 : i])
ret_4h = np.full(n, 0.0)
for i in range(4, n):
ret_4h[i] = abs(close[i] - close[i - 4]) / close[i - 4]
filter_configs = [
# (name, use_squeeze, use_regime, use_momentum, use_volume, squeeze_thr, regime_thr, mom_thr, vol_thr)
("BASELINE (no filter)", False, False, False, False, 0, 0, 0, 0),
("squeeze_only", True, False, False, False, 0.85, 0, 0, 0),
("regime_only", False, True, False, False, 0, 1.3, 0, 0),
("momentum_only", False, False, True, False, 0, 0, 0.02, 0),
("volume_only", False, False, False, True, 0, 0, 0, 2.0),
("squeeze+regime", True, True, False, False, 0.85, 1.3, 0, 0),
("squeeze+momentum", True, False, True, False, 0.85, 0, 0.02, 0),
("squeeze+volume", True, False, False, True, 0.85, 0, 0, 2.0),
("regime+momentum", False, True, True, False, 0, 1.3, 0.02, 0),
("ALL_FILTERS", True, True, True, True, 0.85, 1.3, 0.02, 2.0),
("squeeze+regime+mom", True, True, True, False, 0.85, 1.3, 0.02, 0),
("squeeze_tight", True, False, False, False, 0.80, 0, 0, 0),
("squeeze_tight+regime", True, True, False, False, 0.80, 1.3, 0, 0),
("regime_strict", False, True, False, False, 0, 1.2, 0, 0),
("mom_strict", False, False, True, False, 0, 0, 0.015, 0),
("squeeze_tight+mom_strict", True, False, True, False, 0.80, 0, 0.015, 0),
("ALL_TIGHT", True, True, True, True, 0.80, 1.2, 0.015, 1.8),
]
results_table = []
for name, f_sq, f_reg, f_mom, f_vol, sq_thr, reg_thr, mom_thr, vol_thr in filter_configs:
all_pnls = []
yearly = {}
for i in range(170, n - dte):
if ts.iloc[i].hour != 8:
continue
rv_s = rv_24[i]
rv_l = rv_168[i]
if np.isnan(rv_s) or np.isnan(rv_l) or rv_s < 0.05 or rv_l < 0.05:
continue
# === FILTRI ===
skip = False
if f_sq and not np.isnan(kcr[i]):
in_squeeze = kcr[i] < sq_thr
# Controlla se squeeze nelle ultime 5 barre
recent_squeeze = any(not np.isnan(kcr[j]) and kcr[j] < sq_thr for j in range(max(0, i - 5), i + 1))
if recent_squeeze:
skip = True
if f_reg and rv_l > 0:
if rv_s / rv_l > reg_thr:
skip = True
if f_mom:
if ret_4h[i] > mom_thr:
skip = True
if f_vol and not np.isnan(vol_avg_48[i]) and vol_avg_48[i] > 0:
if volume[i] > vol_avg_48[i] * vol_thr:
skip = True
if skip:
continue
# === TRADE ===
regime = rv_s / rv_l if rv_l > 0 else 1.0
if regime > 2.0:
iv_pf = 0.9
elif regime > 1.5:
iv_pf = 1.0
elif regime > 1.0:
iv_pf = 1.1
else:
iv_pf = 1.2
iv = rv_l * iv_pf
prem = straddle_prem(iv, dte)
spot = close[i]
exit_idx = min(i + dte, n - 1)
actual_move = abs(close[exit_idx] - spot) / spot
pos_size = INITIAL * 0.10
if actual_move <= prem:
raw = (prem - actual_move) * pos_size
else:
raw = -(actual_move - prem) * pos_size
raw = max(raw, -pos_size * 0.05)
net = raw - FEE_ROUNDTRIP * pos_size
all_pnls.append(net)
year = ts.iloc[i].year
if year not in yearly:
yearly[year] = []
yearly[year].append(net)
if len(all_pnls) < 50:
continue
wins = sum(1 for p in all_pnls if p > 0)
acc = wins / len(all_pnls) * 100
avg_pnl = np.mean(all_pnls)
tot_pnl = np.sum(all_pnls)
worst_trade = np.min(all_pnls)
avg_win = np.mean([p for p in all_pnls if p > 0]) if wins > 0 else 0
avg_loss = np.mean([p for p in all_pnls if p <= 0]) if wins < len(all_pnls) else 0
# Worst year
worst_year_acc = 100
worst_year_name = ""
for y, ypnls in sorted(yearly.items()):
yw = sum(1 for p in ypnls if p > 0) / len(ypnls) * 100 if ypnls else 0
if yw < worst_year_acc:
worst_year_acc = yw
worst_year_name = str(y)
# Compounded return
capital = float(INITIAL)
peak = capital
max_dd = 0
for pnl in all_pnls:
capital += pnl * (capital / INITIAL)
capital = max(capital, 10)
if capital > peak:
peak = capital
dd = (peak - capital) / peak
max_dd = max(max_dd, dd)
n_years = len(yearly)
ann = ((capital / INITIAL) ** (1 / n_years) - 1) * 100 if capital > 0 and n_years > 0 else -100
results_table.append({
"name": name,
"trades": len(all_pnls),
"acc": acc,
"avg_pnl": avg_pnl,
"avg_win": avg_win,
"avg_loss": avg_loss,
"ann": ann,
"max_dd": max_dd * 100,
"worst_year": f"{worst_year_name}({worst_year_acc:.0f}%)",
"capital": capital,
})
# Sort by accuracy
results_table.sort(key=lambda x: x["acc"], reverse=True)
print(f"\n {'Name':.<30s} {'Trades':>7s} {'Acc':>6s} {'AvgPnL':>8s} {'AvgWin':>8s} {'AvgLoss':>8s} {'Ann%':>7s} {'DD%':>5s} {'Worst_Yr':>12s} {'Capital':>10s}")
print(f" {'-'*105}")
for r in results_table:
tag = "✅✅" if r["acc"] >= 75 else "" if r["acc"] >= 70 else ""
print(f" {r['name']:.<30s} {r['trades']:>7d} {r['acc']:>5.1f}% {r['avg_pnl']:>+7.2f}{r['avg_win']:>+7.2f}{r['avg_loss']:>+7.2f}{r['ann']:>+6.1f}% {r['max_dd']:>4.1f}% {r['worst_year']:>12s}{r['capital']:>9,.0f} {tag}")
# Dettaglio per anno del migliore
best = results_table[0]
print(f"\n MIGLIORE: {best['name']}{best['acc']:.1f}% acc, {best['ann']:+.1f}% ann, DD {best['max_dd']:.1f}%")
# Rerun best per year
best_name = best["name"]
best_cfg = None
for cfg in filter_configs:
if cfg[0] == best_name:
best_cfg = cfg
break
if best_cfg:
_, f_sq, f_reg, f_mom, f_vol, sq_thr, reg_thr, mom_thr, vol_thr = best_cfg
yearly_detail = {}
for i in range(170, n - dte):
if ts.iloc[i].hour != 8:
continue
rv_s = rv_24[i]
rv_l = rv_168[i]
if np.isnan(rv_s) or np.isnan(rv_l) or rv_s < 0.05 or rv_l < 0.05:
continue
skip = False
if f_sq:
if any(not np.isnan(kcr[j]) and kcr[j] < sq_thr for j in range(max(0, i - 5), i + 1)):
skip = True
if f_reg and rv_l > 0 and rv_s / rv_l > reg_thr:
skip = True
if f_mom and ret_4h[i] > mom_thr:
skip = True
if f_vol and not np.isnan(vol_avg_48[i]) and vol_avg_48[i] > 0 and volume[i] > vol_avg_48[i] * vol_thr:
skip = True
if skip:
continue
regime = rv_s / rv_l if rv_l > 0 else 1.0
iv_pf = {True: 0.9, False: 1.2}[regime > 2.0] if regime > 2.0 else (1.0 if regime > 1.5 else (1.1 if regime > 1.0 else 1.2))
iv = rv_l * iv_pf
prem = straddle_prem(iv, dte)
spot = close[i]
exit_idx = min(i + dte, n - 1)
move = abs(close[exit_idx] - spot) / spot
pos_size = INITIAL * 0.10
if move <= prem:
raw = (prem - move) * pos_size
else:
raw = max(-(move - prem) * pos_size, -pos_size * 0.05)
net = raw - FEE_ROUNDTRIP * pos_size
year = ts.iloc[i].year
if year not in yearly_detail:
yearly_detail[year] = []
yearly_detail[year].append(net)
print(f"\n Dettaglio per anno ({best_name}):")
for y in sorted(yearly_detail):
pnls = yearly_detail[y]
w = sum(1 for p in pnls if p > 0)
a = w / len(pnls) * 100
tag = " ← CRASH" if y in [2020, 2021, 2022] else ""
print(f" {y}: trades={len(pnls):4d} acc={a:.1f}% avg=€{np.mean(pnls):+.2f} tot=€{np.sum(pnls):+.0f}{tag}")
for asset in ["ETH", "BTC"]:
run_filtered(asset, dte=48)
run_filtered(asset, dte=24)
+152
View File
@@ -0,0 +1,152 @@
"""S2-11: VRP con DVOL REALE — unico test valido.
Solo 90 giorni di dati, ma REALI.
Confronta DVOL (IV reale Deribit) vs RV realizzata.
"""
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_ROUNDTRIP = 0.0052
INITIAL = 1000
def rv_ann(close, window):
lr = np.diff(np.log(np.where(close == 0, 1e-10, close)))
r = np.full(len(close), np.nan)
for i in range(window, len(lr)):
r[i + 1] = np.std(lr[i - window : i]) * np.sqrt(24 * 365)
return r
def straddle_prem(iv_pct, dte_h):
"""iv_pct è la IV in decimale (es 0.50 = 50%)."""
if iv_pct <= 0 or dte_h <= 0:
return 0
return iv_pct * np.sqrt(dte_h / (24 * 365)) * 0.8
for asset in ["ETH", "BTC"]:
print(f"\n{'='*70}")
print(f" {asset} — VRP CON DVOL REALE (90 giorni)")
print(f"{'='*70}")
df_price = load_data(asset, "1h")
df_dvol = pd.read_parquet(f"data/raw/{asset.lower()}_dvol.parquet")
close = df_price["close"].values
ts_price = df_price["timestamp"].values
n = len(close)
dvol_ts = df_dvol["timestamp"].values
dvol_vals = df_dvol["dvol"].values / 100 # converti a decimale
rv_24 = rv_ann(close, 24)
rv_48 = rv_ann(close, 48)
# Allinea DVOL ai candles 1h (DVOL è giornaliero)
dvol_aligned = np.full(n, np.nan)
for j in range(len(dvol_ts)):
mask = (ts_price >= dvol_ts[j]) & (ts_price < dvol_ts[j] + 86400000)
dvol_aligned[mask] = dvol_vals[j]
valid_count = np.sum(~np.isnan(dvol_aligned))
print(f" Candele con DVOL reale: {valid_count}")
print(f" DVOL range: {np.nanmin(dvol_aligned)*100:.1f}% — {np.nanmax(dvol_aligned)*100:.1f}%")
# Analisi IV vs RV reale
iv_rv_ratios = []
for i in range(n):
if np.isnan(dvol_aligned[i]) or np.isnan(rv_24[i]) or rv_24[i] <= 0:
continue
iv_rv_ratios.append(dvol_aligned[i] / rv_24[i])
if iv_rv_ratios:
print(f"\n IV/RV ratio REALE:")
print(f" Mean: {np.mean(iv_rv_ratios):.3f}")
print(f" Median: {np.median(iv_rv_ratios):.3f}")
print(f" Min: {np.min(iv_rv_ratios):.3f}")
print(f" Max: {np.max(iv_rv_ratios):.3f}")
print(f" >1 (VRP+): {sum(1 for r in iv_rv_ratios if r > 1)/len(iv_rv_ratios)*100:.0f}% del tempo")
# Backtest VRP reale
for dte in [24, 48]:
print(f"\n --- DTE={dte}h ---")
capital = float(INITIAL)
trades = []
daily_done = set()
for i in range(100, n - dte):
if np.isnan(dvol_aligned[i]) or np.isnan(rv_24[i]):
continue
ts_dt = pd.Timestamp(ts_price[i], unit="ms", tz="UTC")
if ts_dt.hour != 8:
continue
day = ts_dt.strftime("%Y-%m-%d")
if day in daily_done:
continue
iv = dvol_aligned[i]
rv = rv_24[i]
# Filtro regime: skip se RV > IV (no premium)
if rv > iv:
continue
prem = straddle_prem(iv, dte)
spot = close[i]
exit_idx = min(i + dte, n - 1)
actual_move = abs(close[exit_idx] - spot) / spot
pos_pct = 0.10
if actual_move <= prem:
raw = (prem - actual_move) * pos_pct
else:
raw = -(actual_move - prem) * pos_pct
raw = max(raw, -pos_pct * 0.05)
net = raw - FEE_ROUNDTRIP * pos_pct
capital += capital * net
capital = max(capital, 10)
trades.append({
"day": day,
"iv": iv * 100,
"rv": rv * 100,
"premium": prem * 100,
"move": actual_move * 100,
"pnl": net * capital,
"win": raw > 0,
})
daily_done.add(day)
if not trades:
print(" Nessun trade!")
continue
wins = sum(1 for t in trades if t["win"])
acc = wins / len(trades) * 100
ret = (capital - INITIAL) / INITIAL * 100
avg_iv = np.mean([t["iv"] for t in trades])
avg_rv = np.mean([t["rv"] for t in trades])
avg_prem = np.mean([t["premium"] for t in trades])
avg_move = np.mean([t["move"] for t in trades])
print(f" Trades: {len(trades)}")
print(f" Accuracy: {acc:.1f}%")
print(f" Return: {ret:+.1f}%")
print(f" Capital: €{capital:.0f}")
print(f" Avg IV: {avg_iv:.1f}%")
print(f" Avg RV: {avg_rv:.1f}%")
print(f" Avg Prem: {avg_prem:.2f}%")
print(f" Avg Move: {avg_move:.2f}%")
print(f" IV > Move (win condition): {sum(1 for t in trades if t['premium'] > t['move'])/len(trades)*100:.0f}%")
# Worst trade
worst = min(trades, key=lambda t: t["pnl"])
print(f" Worst: day={worst['day']} iv={worst['iv']:.1f}% move={worst['move']:.2f}%")
+320
View File
@@ -0,0 +1,320 @@
"""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)
+131
View File
@@ -0,0 +1,131 @@
"""IB01 — Inside Bar Breakout.
Pattern di compressione a singola candela: quando una barra ha high < prev high
E low > prev low, il prezzo si sta comprimendo. Al breakout del range della
inside bar, segui la direzione.
17% delle candele 15m sono inside bars → frequenza altissima.
IN:
- OHLCV DataFrame
- Parametri: min_consecutive (N inside bars consecutivi),
volume_filter, breakout_confirm
OUT:
- Signal al breakout del range dell'inside bar
- BacktestResult
Logica:
1. Identifica N inside bars consecutivi (compressione)
2. Quando il prezzo rompe il range → entra nella direzione del breakout
3. Filtro: volume al breakout > media
4. Hold fisso
"""
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
class InsideBarBreakout(Strategy):
name = "IB01_inside_bar"
description = "Inside bar breakout — compressione a singola candela"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
fee_rt = 0.002
def generate_signals(self, df, ts, **params):
c = df["close"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
min_consec = params.get("min_consecutive", 2)
use_vol = params.get("vol_filter", False)
min_range_pct = params.get("min_range_pct", 0.002)
# Volume media
vol_ma = np.full(n, np.nan)
for i in range(20, n):
vol_ma[i] = np.mean(v[i - 20:i])
signals = []
consec = 0
mother_high = 0.0
mother_low = 0.0
for i in range(1, n - 1):
is_inside = h[i] <= h[i - 1] and l[i] >= l[i - 1]
if is_inside:
if consec == 0:
mother_high = h[i - 1]
mother_low = l[i - 1]
consec += 1
else:
if consec >= min_consec:
range_pct = (mother_high - mother_low) / mother_low if mother_low > 0 else 0
if range_pct < min_range_pct:
consec = 0
continue
# Breakout detection sulla barra corrente
if c[i] > mother_high:
direction = 1
elif c[i] < mother_low:
direction = -1
else:
consec = 0
continue
# Volume filter
if use_vol and not np.isnan(vol_ma[i]):
if v[i] < vol_ma[i] * 1.2:
consec = 0
continue
signals.append(Signal(
idx=i, direction=direction, entry_price=c[i],
metadata={"consec": consec, "range_pct": round(range_pct * 100, 3)},
))
consec = 0
return signals
if __name__ == "__main__":
strategy = InsideBarBreakout()
configs = [
("2ib", {"min_consecutive": 2}),
("3ib", {"min_consecutive": 3}),
("4ib", {"min_consecutive": 4}),
("2ib+vol", {"min_consecutive": 2, "vol_filter": True}),
("3ib+vol", {"min_consecutive": 3, "vol_filter": True}),
("2ib r>0.3%", {"min_consecutive": 2, "min_range_pct": 0.003}),
("3ib r>0.3%", {"min_consecutive": 3, "min_range_pct": 0.003}),
]
all_results = []
for label, params in configs:
for asset in ["BTC", "ETH"]:
for tf in ["15m", "1h"]:
for hold in [3, 6]:
r = strategy.backtest(asset, tf, hold=hold, **params)
if r and r.trades >= 30:
r.strategy_name = f"IB01 {label} h={hold}"
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 120}")
print(f" IB01 INSIDE BAR BREAKOUT — TOP 20")
print(f"{'=' * 120}")
for r in all_results[:20]:
r.print_summary()
if all_results:
all_results[0].print_yearly()
+133
View File
@@ -0,0 +1,133 @@
"""DC01 — Donchian Channel Breakout con filtri.
Trend-following classico: quando il prezzo rompe il massimo/minimo degli
ultimi N periodi, entra nella direzione del breakout.
Completamente diverso dallo squeeze (che usa Bollinger/Keltner).
Donchian cattura breakout di RANGE, non di VOLATILITÀ.
IN:
- OHLCV DataFrame
- Parametri: channel_period, volume_filter, atr_stop, trend_filter
OUT:
- Signal al breakout del canale Donchian
- BacktestResult
Logica:
1. Donchian upper = max(high, N periodi), lower = min(low, N periodi)
2. Close > upper → LONG (breakout rialzista)
3. Close < lower → SHORT (breakout ribassista)
4. Filtri: volume, trend EMA, ATR minimo
"""
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
class DonchianBreakout(Strategy):
name = "DC01_donchian"
description = "Donchian Channel breakout — trend-following su range"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
fee_rt = 0.002
def generate_signals(self, df, ts, **params):
c = df["close"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
period = params.get("channel_period", 48)
use_vol = params.get("vol_filter", False)
use_trend = params.get("trend_filter", False)
cooldown = params.get("cooldown", 6)
# EMA per trend filter
ema_50 = np.full(n, np.nan)
k = 2 / 51
ema_50[49] = np.mean(c[:50])
for i in range(50, n):
ema_50[i] = c[i] * k + ema_50[i - 1] * (1 - k)
# Volume media
vol_ma = np.full(n, np.nan)
for i in range(20, n):
vol_ma[i] = np.mean(v[i - 20:i])
signals = []
last_signal_idx = -cooldown
for i in range(period + 1, n):
if i - last_signal_idx < cooldown:
continue
upper = np.max(h[i - period:i])
lower = np.min(l[i - period:i])
direction = 0
if c[i] > upper:
direction = 1
elif c[i] < lower:
direction = -1
if direction == 0:
continue
# Trend filter: breakout must align with EMA trend
if use_trend and not np.isnan(ema_50[i]):
if direction == 1 and c[i] < ema_50[i]:
continue
if direction == -1 and c[i] > ema_50[i]:
continue
# Volume filter
if use_vol and not np.isnan(vol_ma[i]):
if v[i] < vol_ma[i] * 1.3:
continue
signals.append(Signal(
idx=i, direction=direction, entry_price=c[i],
metadata={"upper": float(upper), "lower": float(lower)},
))
last_signal_idx = i
return signals
if __name__ == "__main__":
strategy = DonchianBreakout()
configs = [
("p=24", {"channel_period": 24}),
("p=48", {"channel_period": 48}),
("p=96", {"channel_period": 96}),
("p=48+trend", {"channel_period": 48, "trend_filter": True}),
("p=48+vol", {"channel_period": 48, "vol_filter": True}),
("p=48+t+v", {"channel_period": 48, "trend_filter": True, "vol_filter": True}),
("p=96+t+v", {"channel_period": 96, "trend_filter": True, "vol_filter": True}),
]
all_results = []
for label, params in configs:
for asset in ["BTC", "ETH"]:
for tf in ["15m", "1h"]:
for hold in [3, 6, 12]:
r = strategy.backtest(asset, tf, hold=hold, **params)
if r and r.trades >= 30:
r.strategy_name = f"DC01 {label} h={hold}"
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 120}")
print(f" DC01 DONCHIAN BREAKOUT — TOP 20")
print(f"{'=' * 120}")
for r in all_results[:20]:
r.print_summary()
if all_results:
all_results[0].print_yearly()
+163
View File
@@ -0,0 +1,163 @@
"""SB01 — Squeeze Breakout con Retest.
Il problema di SQ01/SQ02: entri al breakout, ma molti breakout sono fakeout.
Soluzione: aspetta il RETEST. Dopo il breakout, il prezzo spesso torna a
testare il livello di breakout prima di continuare.
Più selettivo di SQ02 → meno trade ma più accurati.
Anti-overfitting: meccanismo strutturale (retest è fenomeno di mercato reale).
IN:
- OHLCV DataFrame
- Parametri: bb_window, sq_threshold, retest_window (quante barre aspettare
il retest), retest_tolerance (quanto può tornare indietro)
OUT:
- Signal al retest confermato (non al breakout iniziale)
- BacktestResult
Logica:
1. Rileva squeeze release (come SQ01)
2. NON entrare subito — segna direzione e livello di breakout
3. Nelle N barre successive, aspetta che il prezzo torni verso il livello
4. Se il prezzo torna nel range di tolleranza e poi rimbalza → ENTRA
5. Se il prezzo non torna → skip (momentum troppo forte, entry persa)
6. Se il prezzo sfonda il livello → fakeout confermato, skip
"""
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
from src.strategies.indicators import keltner_ratio, detect_squeezes
class SqueezeBreakoutRetest(Strategy):
name = "SB01_squeeze_retest"
description = "Squeeze breakout con retest — entra solo dopo pullback confermato"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
fee_rt = 0.002
def generate_signals(self, df, ts, **params):
c = df["close"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
bb_w = params.get("bb_window", 14)
sq_thr = params.get("sq_threshold", 0.8)
retest_window = params.get("retest_window", 8)
retest_tol = params.get("retest_tolerance", 0.5)
use_vol = params.get("vol_filter", False)
kcr = keltner_ratio(c, h, l, bb_w)
events = detect_squeezes(c, h, l, kcr, sq_thr)
vol_ma = np.full(n, np.nan)
for i in range(20, n):
vol_ma[i] = np.mean(v[i - 20:i])
signals = []
for ev in events:
brk_idx = ev["idx"]
if brk_idx + retest_window + 3 >= n or brk_idx < 1:
continue
# Direzione breakout
first_ret = (c[brk_idx] - c[brk_idx - 1]) / c[brk_idx - 1]
if abs(first_ret) < 0.001:
continue
direction = 1 if first_ret > 0 else -1
breakout_level = c[brk_idx - 1]
breakout_move = abs(first_ret)
# Aspetta retest nelle prossime N barre
retest_found = False
retest_idx = -1
for j in range(brk_idx + 1, min(brk_idx + retest_window + 1, n)):
if direction == 1:
# Long: il prezzo deve tornare GIÙ verso breakout_level
pullback = (h[brk_idx] - l[j]) / (h[brk_idx] - breakout_level) if h[brk_idx] > breakout_level else 0
if pullback >= retest_tol:
# Tornato abbastanza — ora deve rimbalzare
if c[j] > breakout_level:
retest_found = True
retest_idx = j
break
elif c[j] < breakout_level * 0.998:
# Sfondato sotto → fakeout
break
else:
# Short: il prezzo deve tornare SU verso breakout_level
pullback = (h[j] - l[brk_idx]) / (breakout_level - l[brk_idx]) if breakout_level > l[brk_idx] else 0
if pullback >= retest_tol:
if c[j] < breakout_level:
retest_found = True
retest_idx = j
break
elif c[j] > breakout_level * 1.002:
break
if not retest_found or retest_idx < 0:
continue
# Volume filter al retest
if use_vol and not np.isnan(vol_ma[retest_idx]):
if v[retest_idx] < vol_ma[retest_idx] * 0.8:
continue
signals.append(Signal(
idx=retest_idx, direction=direction,
entry_price=c[retest_idx],
metadata={
"breakout_idx": brk_idx,
"retest_bars": retest_idx - brk_idx,
"breakout_move": round(breakout_move * 100, 3),
},
))
return signals
if __name__ == "__main__":
strategy = SqueezeBreakoutRetest()
configs = [
("rt8 tol50%", {"retest_window": 8, "retest_tolerance": 0.5}),
("rt6 tol50%", {"retest_window": 6, "retest_tolerance": 0.5}),
("rt10 tol50%", {"retest_window": 10, "retest_tolerance": 0.5}),
("rt8 tol30%", {"retest_window": 8, "retest_tolerance": 0.3}),
("rt8 tol70%", {"retest_window": 8, "retest_tolerance": 0.7}),
("rt8 tol50%+vol", {"retest_window": 8, "retest_tolerance": 0.5, "vol_filter": True}),
("rt6 tol30%", {"retest_window": 6, "retest_tolerance": 0.3}),
("rt12 tol50%", {"retest_window": 12, "retest_tolerance": 0.5}),
]
all_results = []
for label, params in configs:
for asset in ["BTC", "ETH"]:
for tf in ["15m", "1h"]:
for hold in [3, 6]:
r = strategy.backtest(asset, tf, hold=hold, **params)
if r and r.trades >= 30:
r.strategy_name = f"SB01 {label} h={hold}"
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 130}")
print(f" SB01 SQUEEZE BREAKOUT RETEST — TOP 25")
print(f"{'=' * 130}")
for r in all_results[:25]:
r.print_summary()
if all_results:
all_results[0].print_yearly()
# Confronto con benchmark
print(f"\n BENCHMARK SQ02: 79.7% acc, 1250 trades, DD 6.5%, 9/9 anni")
+148
View File
@@ -0,0 +1,148 @@
"""MR01 — Mean Reversion da estremi RSI.
Approccio opposto allo squeeze: quando il prezzo va troppo lontano troppo veloce,
scommetti che torni indietro. Autocorrelazione lag-1 negativa (-0.21 BTC, -0.35 ETH)
conferma che il mercato a 15m è mean-reverting.
IN:
- OHLCV DataFrame
- Parametri: rsi_period, rsi_oversold, rsi_overbought, hold_bars,
volume_filter (volume > N× media), atr_filter (move > N×ATR)
OUT:
- Signal: long quando RSI < oversold, short quando RSI > overbought
- BacktestResult con metriche
Logica:
1. RSI scende sotto soglia oversold → LONG (prezzo tornerà su)
2. RSI sale sopra soglia overbought → SHORT (prezzo tornerà giù)
3. Filtro opzionale: volume spike conferma l'eccesso
4. Filtro opzionale: move recente > N×ATR (eccesso di prezzo)
5. Hold fisso, poi chiudi
"""
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
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
result[i + 1] = 100 if al == 0 else 100 - 100 / (1 + ag / al)
return result
class MeanReversionRSI(Strategy):
name = "MR01_mean_reversion_rsi"
description = "Mean reversion da estremi RSI — fade eccessi direzionali"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
fee_rt = 0.002
def generate_signals(self, df, ts, **params):
c = df["close"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
rsi_period = params.get("rsi_period", 14)
oversold = params.get("rsi_oversold", 25)
overbought = params.get("rsi_overbought", 75)
use_vol_filter = params.get("vol_filter", False)
use_atr_filter = params.get("atr_filter", False)
cooldown = params.get("cooldown", 4)
rsi_vals = rsi(c, rsi_period)
# Volume media rolling
vol_ma = np.full(n, np.nan)
for i in range(20, n):
vol_ma[i] = np.mean(v[i - 20:i])
# ATR
tr = np.maximum(h[1:] - l[1:],
np.maximum(np.abs(h[1:] - c[:-1]), np.abs(l[1:] - c[:-1])))
atr_vals = np.full(n, np.nan)
for i in range(15, len(tr)):
atr_vals[i + 1] = np.mean(tr[i - 14:i])
signals = []
last_signal_idx = -cooldown
for i in range(20, n):
if i - last_signal_idx < cooldown:
continue
direction = 0
if rsi_vals[i] < oversold:
direction = 1 # oversold → long
elif rsi_vals[i] > overbought:
direction = -1 # overbought → short
if direction == 0:
continue
# Volume filter
if use_vol_filter and not np.isnan(vol_ma[i]):
if v[i] < vol_ma[i] * 1.5:
continue
# ATR filter: il move recente deve essere > 1.5× ATR
if use_atr_filter and not np.isnan(atr_vals[i]):
recent_move = abs(c[i] - c[max(0, i - 3)]) / c[max(0, i - 3)]
if recent_move < atr_vals[i] / c[i] * 1.5:
continue
signals.append(Signal(
idx=i, direction=direction, entry_price=c[i],
metadata={"rsi": float(rsi_vals[i])},
))
last_signal_idx = i
return signals
if __name__ == "__main__":
strategy = MeanReversionRSI()
configs = [
("RSI25/75", {}),
("RSI20/80", {"rsi_oversold": 20, "rsi_overbought": 80}),
("RSI25/75+vol", {"vol_filter": True}),
("RSI20/80+vol", {"rsi_oversold": 20, "rsi_overbought": 80, "vol_filter": True}),
("RSI25/75+atr", {"atr_filter": True}),
("RSI20/80+vol+atr", {"rsi_oversold": 20, "rsi_overbought": 80, "vol_filter": True, "atr_filter": True}),
]
all_results = []
for label, params in configs:
for asset in ["BTC", "ETH"]:
for tf in ["15m", "1h"]:
for hold in [3, 6]:
r = strategy.backtest(asset, tf, hold=hold, **params)
if r and r.trades >= 30:
r.strategy_name = f"MR01 {label} h={hold}"
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 120}")
print(f" MR01 MEAN REVERSION RSI — TOP 20")
print(f"{'=' * 120}")
for r in all_results[:20]:
r.print_summary()
if all_results:
all_results[0].print_yearly()
+133
View File
@@ -0,0 +1,133 @@
"""VO01 — Volume Spike Reversal.
Quando il volume esplode (>3× media) con un forte move direzionale,
il mercato è in eccesso → fade il move (mean reversion).
Diverso dallo squeeze: non cerca compressione, cerca ECCESSO.
Il volume spike indica panico/euforia → reversal probabile.
IN:
- OHLCV DataFrame
- Parametri: vol_mult (3), move_threshold (0.005), hold
OUT:
- Signal: fade la direzione del volume spike
- BacktestResult
Logica:
1. Volume > vol_mult × media 20 periodi
2. Move nella candela > move_threshold (0.5%)
3. Direzione: opposta al move (mean reversion)
4. Filtro: non entrare se già in trend forte (EMA slope)
"""
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
class VolumeSpikeReversal(Strategy):
name = "VO01_vol_spike_reversal"
description = "Volume spike reversal — fade eccessi di volume/prezzo"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
fee_rt = 0.002
def generate_signals(self, df, ts, **params):
c = df["close"].values
o = df["open"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
vol_mult = params.get("vol_mult", 3.0)
move_thr = params.get("move_threshold", 0.005)
use_trend_filter = params.get("trend_filter", False)
cooldown = params.get("cooldown", 4)
# Volume media rolling
vol_ma = np.full(n, np.nan)
for i in range(20, n):
vol_ma[i] = np.mean(v[i - 20:i])
# EMA per trend filter
ema_20 = np.full(n, np.nan)
k = 2 / 21
ema_20[19] = np.mean(c[:20])
for i in range(20, n):
ema_20[i] = c[i] * k + ema_20[i - 1] * (1 - k)
signals = []
last_idx = -cooldown
for i in range(21, n):
if i - last_idx < cooldown:
continue
if np.isnan(vol_ma[i]):
continue
# Volume spike
if v[i] < vol_ma[i] * vol_mult:
continue
# Price move
move = (c[i] - o[i]) / o[i] if o[i] > 0 else 0
if abs(move) < move_thr:
continue
# Fade: opposto al move
direction = -1 if move > 0 else 1
# Trend filter: non fare mean reversion contro trend forte
if use_trend_filter and not np.isnan(ema_20[i]):
ema_slope = (ema_20[i] - ema_20[max(0, i - 5)]) / ema_20[max(0, i - 5)]
if direction == -1 and ema_slope > 0.005:
continue
if direction == 1 and ema_slope < -0.005:
continue
signals.append(Signal(
idx=i, direction=direction, entry_price=c[i],
metadata={"vol_ratio": float(v[i] / vol_ma[i]), "move_pct": round(move * 100, 3)},
))
last_idx = i
return signals
if __name__ == "__main__":
strategy = VolumeSpikeReversal()
configs = [
("v3x m0.5%", {"vol_mult": 3.0, "move_threshold": 0.005}),
("v3x m1%", {"vol_mult": 3.0, "move_threshold": 0.01}),
("v4x m0.5%", {"vol_mult": 4.0, "move_threshold": 0.005}),
("v4x m1%", {"vol_mult": 4.0, "move_threshold": 0.01}),
("v3x m0.5%+tf", {"vol_mult": 3.0, "move_threshold": 0.005, "trend_filter": True}),
("v3x m1%+tf", {"vol_mult": 3.0, "move_threshold": 0.01, "trend_filter": True}),
("v5x m1%", {"vol_mult": 5.0, "move_threshold": 0.01}),
("v5x m1%+tf", {"vol_mult": 5.0, "move_threshold": 0.01, "trend_filter": True}),
]
all_results = []
for label, params in configs:
for asset in ["BTC", "ETH"]:
for tf in ["15m", "1h"]:
for hold in [3, 6]:
r = strategy.backtest(asset, tf, hold=hold, **params)
if r and r.trades >= 30:
r.strategy_name = f"VO01 {label} h={hold}"
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 120}")
print(f" VO01 VOLUME SPIKE REVERSAL — TOP 20")
print(f"{'=' * 120}")
for r in all_results[:20]:
r.print_summary()
if all_results:
all_results[0].print_yearly()
+169
View File
@@ -0,0 +1,169 @@
"""HY01 — Squeeze + Mean Reversion Ibrida.
Insight: durante lo squeeze (bassa volatilità), il prezzo mean-reverte
DENTRO il range compresso. Autocorrelazione negativa a 15m conferma.
Invece di aspettare il BREAKOUT, tradi la MEAN REVERSION dentro lo squeeze.
Completamente diverso da SQ01-SQ04 che aspettano il RILASCIO.
IN:
- OHLCV DataFrame
- Parametri: bb_window, sq_threshold, rsi_period, rsi_levels,
vol_filter, bb_touch (prezzo tocca banda Bollinger)
OUT:
- Signal: long quando RSI oversold DURANTE squeeze, short quando overbought
- BacktestResult
Logica:
1. Verifica che siamo IN squeeze (BB dentro KC)
2. Prezzo tocca banda inferiore BB → LONG (tornerà alla media)
3. Prezzo tocca banda superiore BB → SHORT (tornerà alla media)
4. Conferma RSI: deve essere estremo nella direzione
5. Hold corto (2-3 barre) — target: ritorno alla media
6. Stop: se prezzo rompe lo squeeze → chiudi subito
"""
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
from src.strategies.indicators import keltner_ratio
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
result[i + 1] = 100 if al == 0 else 100 - 100 / (1 + ag / al)
return result
def bollinger(close, window=14):
n = len(close)
upper = np.full(n, np.nan)
lower = np.full(n, np.nan)
mid = np.full(n, np.nan)
for i in range(window, n):
wc = close[i - window:i]
m = np.mean(wc)
s = np.std(wc)
mid[i] = m
upper[i] = m + 2 * s
lower[i] = m - 2 * s
return upper, mid, lower
class SqueezeMeanReversion(Strategy):
name = "HY01_squeeze_mr"
description = "Mean reversion DENTRO lo squeeze — fade estremi in range compresso"
default_assets = ["BTC", "ETH"]
default_timeframes = ["15m", "1h"]
fee_rt = 0.002
def generate_signals(self, df, ts, **params):
c = df["close"].values
h = df["high"].values
l = df["low"].values
v = df["volume"].values
n = len(c)
bb_w = params.get("bb_window", 14)
sq_thr = params.get("sq_threshold", 0.8)
rsi_period = params.get("rsi_period", 14)
rsi_low = params.get("rsi_oversold", 30)
rsi_high = params.get("rsi_overbought", 70)
use_bb_touch = params.get("bb_touch", True)
cooldown = params.get("cooldown", 3)
kcr = keltner_ratio(c, h, l, bb_w)
rsi_vals = rsi(c, rsi_period)
bb_upper, bb_mid, bb_lower = bollinger(c, bb_w)
signals = []
last_idx = -cooldown
for i in range(bb_w + 1, n):
if i - last_idx < cooldown:
continue
if np.isnan(kcr[i]) or np.isnan(bb_lower[i]):
continue
# Must be IN squeeze
if kcr[i] >= sq_thr:
continue
direction = 0
if use_bb_touch:
# Prezzo tocca/rompe BB lower → long (mean reversion up)
if c[i] <= bb_lower[i] and rsi_vals[i] < rsi_low:
direction = 1
# Prezzo tocca/rompe BB upper → short (mean reversion down)
elif c[i] >= bb_upper[i] and rsi_vals[i] > rsi_high:
direction = -1
else:
# Solo RSI
if rsi_vals[i] < rsi_low:
direction = 1
elif rsi_vals[i] > rsi_high:
direction = -1
if direction == 0:
continue
signals.append(Signal(
idx=i, direction=direction, entry_price=c[i],
metadata={
"rsi": float(rsi_vals[i]),
"kcr": float(kcr[i]),
"bb_pos": "lower" if direction == 1 else "upper",
},
))
last_idx = i
return signals
if __name__ == "__main__":
strategy = SqueezeMeanReversion()
configs = [
("bb+rsi30/70", {"bb_touch": True, "rsi_oversold": 30, "rsi_overbought": 70}),
("bb+rsi25/75", {"bb_touch": True, "rsi_oversold": 25, "rsi_overbought": 75}),
("bb+rsi35/65", {"bb_touch": True, "rsi_oversold": 35, "rsi_overbought": 65}),
("rsi30/70 only", {"bb_touch": False, "rsi_oversold": 30, "rsi_overbought": 70}),
("rsi25/75 only", {"bb_touch": False, "rsi_oversold": 25, "rsi_overbought": 75}),
("sq<0.7 bb+rsi30", {"bb_touch": True, "sq_threshold": 0.7, "rsi_oversold": 30, "rsi_overbought": 70}),
("sq<0.9 bb+rsi30", {"bb_touch": True, "sq_threshold": 0.9, "rsi_oversold": 30, "rsi_overbought": 70}),
("sq<0.9 rsi35/65", {"bb_touch": False, "sq_threshold": 0.9, "rsi_oversold": 35, "rsi_overbought": 65}),
]
all_results = []
for label, params in configs:
for asset in ["BTC", "ETH"]:
for tf in ["15m", "1h"]:
for hold in [2, 3, 4]:
r = strategy.backtest(asset, tf, hold=hold, **params)
if r and r.trades >= 30:
r.strategy_name = f"HY01 {label} h={hold}"
all_results.append(r)
all_results.sort(key=lambda r: r.accuracy, reverse=True)
print(f"\n{'=' * 130}")
print(f" HY01 SQUEEZE MEAN REVERSION — TOP 25")
print(f"{'=' * 130}")
for r in all_results[:25]:
r.print_summary()
if all_results:
all_results[0].print_yearly()