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:
@@ -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%")
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
@@ -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}")
|
||||
@@ -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}")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
@@ -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}")
|
||||
@@ -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}")
|
||||
@@ -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}")
|
||||
@@ -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}")
|
||||
@@ -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}%")
|
||||
@@ -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}%")
|
||||
@@ -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}")
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)")
|
||||
@@ -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)}")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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}%")
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user