Files
PythagorasGoal/scripts/08_ensemble_multi_tf.py
2026-05-27 00:55:13 +02:00

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}")