14522262e6
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>
291 lines
9.0 KiB
Python
291 lines
9.0 KiB
Python
"""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}")
|