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