560 lines
24 KiB
Python
560 lines
24 KiB
Python
"""
|
|
AHAD QUANT — Ensemble Training Pipeline (V5 — Deep Learning Edition)
|
|
Trains LightGBM + XGBoost + RandomForest + TFT + TransformerGRU ensemble.
|
|
|
|
Usage:
|
|
python download_data.py # download historical data first
|
|
python train.py # train full ensemble (~2-6h, GPU recommandé)
|
|
python train.py --no-dl # ML only, skip TFT/TGRU (~15-30 min)
|
|
|
|
What's new in V5 vs V4:
|
|
- TFT (Temporal Fusion Transformer) — variable selection + attention
|
|
- TGRU (TransformerGRU hybrid) — bidir GRU + transformer encoder
|
|
- Sequence dataset builder — per-pair 168-candle sliding windows
|
|
- Extended meta-learner — up to 5 base models (was 3)
|
|
- Backward-compatible when torch/DL unavailable — falls back to ML-only
|
|
"""
|
|
|
|
import json, os, pickle, sys, time, argparse
|
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
import numpy as np
|
|
import lightgbm as lgb
|
|
import config
|
|
from features import build_features, FEATURE_NAMES
|
|
|
|
# ─── CLI flags ───────────────────────────────────────────────────────────────
|
|
_parser = argparse.ArgumentParser(add_help=False)
|
|
_parser.add_argument("--no-dl", action="store_true",
|
|
help="Skip TFT/TGRU training (ML-only mode, faster)")
|
|
_args, _ = _parser.parse_known_args()
|
|
SKIP_DL: bool = _args.no_dl
|
|
|
|
try:
|
|
import xgboost as xgb
|
|
HAS_XGB = True
|
|
except ImportError:
|
|
HAS_XGB = False
|
|
print("[WARN] xgboost not installed — skipping. pip install xgboost")
|
|
|
|
try:
|
|
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
|
|
from sklearn.linear_model import LogisticRegression
|
|
from sklearn.preprocessing import StandardScaler
|
|
from sklearn.metrics import accuracy_score
|
|
HAS_SKLEARN = True
|
|
except ImportError:
|
|
HAS_SKLEARN = False
|
|
print("[WARN] scikit-learn not installed — skipping RF. pip install scikit-learn")
|
|
|
|
try:
|
|
import optuna
|
|
optuna.logging.set_verbosity(optuna.logging.WARNING)
|
|
HAS_OPTUNA = True
|
|
except ImportError:
|
|
HAS_OPTUNA = False
|
|
|
|
# ─── Deep Learning imports (optional — guarded) ──────────────────────────────
|
|
HAS_DL = False
|
|
if not SKIP_DL:
|
|
try:
|
|
import torch
|
|
from prepare_sequences import (
|
|
prepare_seq_dataset, fit_seq_scaler, transform_sequences, SEQ_LEN
|
|
)
|
|
from tft_model import (
|
|
TemporalFusionTransformer, train_tft, predict_tft_proba
|
|
)
|
|
from transformer_gru_model import (
|
|
TransformerGRU, train_tgru, predict_tgru_proba
|
|
)
|
|
HAS_DL = True
|
|
print(f"[DL] PyTorch {torch.__version__} detected — TFT + TGRU enabled")
|
|
except ImportError as _dl_err:
|
|
print(f"[WARN] DL modules not available ({_dl_err}). "
|
|
"Falling back to ML-only. "
|
|
"Install with: pip install torch>=2.2.0")
|
|
|
|
# ─── Config ─────────────────────────────────────────────────────────────────
|
|
LOOKAHEAD = 3
|
|
MIN_CANDLES = 200
|
|
TRAIN_RATIO = 0.70
|
|
VAL_RATIO = 0.15
|
|
N_WALK_FORWARD_WINDOWS = 4
|
|
OPTUNA_TRIALS = 30 # increase for better tuning (slower)
|
|
|
|
|
|
# ─── Data loading ────────────────────────────────────────────────────────────
|
|
|
|
def load_candles(coin: str) -> dict | None:
|
|
path = os.path.join(config.DATA_DIR, f"{coin}_1h.json")
|
|
if not os.path.exists(path):
|
|
return None
|
|
with open(path) as f:
|
|
return json.load(f)
|
|
|
|
|
|
def make_labels(close: np.ndarray, lookahead: int = LOOKAHEAD) -> np.ndarray:
|
|
labels = np.zeros(len(close))
|
|
for i in range(len(close) - lookahead):
|
|
labels[i] = 1.0 if close[i + lookahead] > close[i] else 0.0
|
|
return labels
|
|
|
|
|
|
def prepare_dataset() -> tuple[np.ndarray, np.ndarray]:
|
|
all_X, all_y = [], []
|
|
skipped = []
|
|
# Paire de référence pour la corrélation (EURUSD = paire dominante en Forex)
|
|
_ref_pair = "EURUSD"
|
|
btc_data = load_candles(_ref_pair)
|
|
if btc_data is None:
|
|
print(f" [WARN] Paire de référence {_ref_pair} non trouvée dans data/ —"
|
|
" features de corrélation désactivées.")
|
|
btc_close = np.array([c["c"] for c in btc_data]) if btc_data else None
|
|
|
|
for coin in config.COINS:
|
|
data = load_candles(coin)
|
|
if data is None:
|
|
print(f" ⚠️ {coin:8s} — fichier data/{coin}_1h.json introuvable")
|
|
skipped.append((coin, "fichier manquant"))
|
|
continue
|
|
if len(data) < MIN_CANDLES:
|
|
print(f" ⚠️ {coin:8s} — données insuffisantes ({len(data)} bougies < {MIN_CANDLES} min)")
|
|
skipped.append((coin, f"seulement {len(data)} bougies"))
|
|
continue
|
|
close = np.array([c["c"] for c in data])
|
|
coin_btc = btc_close[-len(close):] if btc_close is not None and len(btc_close) >= len(close) else None
|
|
|
|
X = build_features(data, btc_closes=coin_btc)
|
|
y = make_labels(close)
|
|
warmup = 30
|
|
X, y = X[warmup:-LOOKAHEAD], y[warmup:-LOOKAHEAD]
|
|
valid = ~np.isnan(X).any(axis=1)
|
|
X, y = X[valid], y[valid]
|
|
all_X.append(X); all_y.append(y)
|
|
print(f" ✅ {coin:8s} — {len(X):,} samples ({round(len(data)/24)} jours)")
|
|
|
|
# ─── Guard critique : aucune paire chargée ────────────────────────────────
|
|
if len(all_X) == 0:
|
|
print()
|
|
print("━" * 60)
|
|
print(" ERREUR : Aucune paire chargée. Lancez d'abord :")
|
|
print(" python download_data.py")
|
|
print()
|
|
print(" Paires manquantes :")
|
|
for coin, reason in skipped:
|
|
print(f" {coin:8s} — {reason}")
|
|
print("━" * 60)
|
|
raise RuntimeError(
|
|
"Aucune donnée disponible dans data/. "
|
|
"Exécutez `python download_data.py` avant `python train.py`."
|
|
)
|
|
|
|
if len(all_X) < 3:
|
|
print(f"\n [WARN] Seulement {len(all_X)} paire(s) chargée(s). "
|
|
f"Résultats d'entraînement potentiellement insuffisants. "
|
|
f"Minimum recommandé : 5 paires.")
|
|
|
|
if skipped:
|
|
print(f"\n [INFO] {len(skipped)} paire(s) ignorée(s) : "
|
|
f"{', '.join(c for c, _ in skipped)}")
|
|
|
|
return np.concatenate(all_X), np.concatenate(all_y)
|
|
|
|
|
|
# ─── Walk-forward cross-validation ──────────────────────────────────────────
|
|
|
|
def walk_forward_cv(X: np.ndarray, y: np.ndarray, n_windows: int = 4) -> dict:
|
|
"""
|
|
Walk-forward validation with expanding window.
|
|
Returns mean accuracy and std across windows.
|
|
"""
|
|
n = len(X)
|
|
base = int(n * 0.5) # first training window = 50% of data
|
|
step = (n - base) // n_windows
|
|
results = []
|
|
|
|
print(f"\n Walk-forward CV ({n_windows} windows):")
|
|
for i in range(n_windows):
|
|
train_end = base + i * step
|
|
test_end = min(train_end + step, n)
|
|
X_tr, y_tr = X[:train_end], y[:train_end]
|
|
X_te, y_te = X[train_end:test_end], y[train_end:test_end]
|
|
|
|
# Quick LightGBM for CV (fast)
|
|
ds_tr = lgb.Dataset(X_tr, label=y_tr)
|
|
ds_va = lgb.Dataset(X_te, label=y_te, reference=ds_tr)
|
|
params = {"objective": "binary", "metric": "binary_logloss",
|
|
"num_leaves": 63, "learning_rate": 0.05, "verbose": -1}
|
|
m = lgb.train(params, ds_tr, 500, valid_sets=[ds_va],
|
|
callbacks=[lgb.early_stopping(30), lgb.log_evaluation(-1)])
|
|
acc = accuracy_score(y_te, (m.predict(X_te) > 0.5).astype(int))
|
|
results.append(acc)
|
|
print(f" Window {i+1}: train={train_end:,} test={len(y_te):,} acc={acc:.4f}")
|
|
|
|
mean_acc = np.mean(results)
|
|
std_acc = np.std(results)
|
|
print(f" CV accuracy: {mean_acc:.4f} ± {std_acc:.4f}")
|
|
return {"mean": mean_acc, "std": std_acc, "windows": results}
|
|
|
|
|
|
# ─── Optuna hyperparameter search ───────────────────────────────────────────
|
|
|
|
def tune_lgbm(X_tr, y_tr, X_va, y_va, n_trials: int = OPTUNA_TRIALS) -> dict:
|
|
if not HAS_OPTUNA:
|
|
return {"num_leaves": 63, "learning_rate": 0.05,
|
|
"feature_fraction": 0.8, "bagging_fraction": 0.8,
|
|
"bagging_freq": 5, "min_child_samples": 50}
|
|
|
|
def objective(trial):
|
|
params = {
|
|
"objective": "binary", "metric": "binary_logloss", "verbose": -1,
|
|
"num_leaves": trial.suggest_int("num_leaves", 20, 150),
|
|
"learning_rate": trial.suggest_float("learning_rate", 0.01, 0.15, log=True),
|
|
"feature_fraction": trial.suggest_float("feature_fraction", 0.5, 1.0),
|
|
"bagging_fraction": trial.suggest_float("bagging_fraction", 0.5, 1.0),
|
|
"bagging_freq": trial.suggest_int("bagging_freq", 1, 10),
|
|
"min_child_samples": trial.suggest_int("min_child_samples", 20, 100),
|
|
"lambda_l1": trial.suggest_float("lambda_l1", 0.0, 1.0),
|
|
"lambda_l2": trial.suggest_float("lambda_l2", 0.0, 1.0),
|
|
}
|
|
ds_tr = lgb.Dataset(X_tr, label=y_tr)
|
|
ds_va = lgb.Dataset(X_va, label=y_va, reference=ds_tr)
|
|
m = lgb.train(params, ds_tr, 1000, valid_sets=[ds_va],
|
|
callbacks=[lgb.early_stopping(30), lgb.log_evaluation(-1)])
|
|
preds = m.predict(X_va)
|
|
return accuracy_score(y_va, (preds > 0.5).astype(int))
|
|
|
|
study = optuna.create_study(direction="maximize")
|
|
study.optimize(objective, n_trials=n_trials, show_progress_bar=False)
|
|
print(f" Best LightGBM accuracy (Optuna): {study.best_value:.4f}")
|
|
return study.best_params
|
|
|
|
|
|
# ─── Model training ──────────────────────────────────────────────────────────
|
|
|
|
def train_lgbm(X_tr, y_tr, X_va, y_va, params: dict) -> lgb.Booster:
|
|
final_params = {
|
|
"objective": "binary", "metric": "binary_logloss",
|
|
"boosting_type": "gbdt", "verbose": -1,
|
|
**params
|
|
}
|
|
ds_tr = lgb.Dataset(X_tr, label=y_tr, feature_name=FEATURE_NAMES)
|
|
ds_va = lgb.Dataset(X_va, label=y_va, feature_name=FEATURE_NAMES, reference=ds_tr)
|
|
return lgb.train(final_params, ds_tr, 3000, valid_sets=[ds_va],
|
|
callbacks=[lgb.early_stopping(50), lgb.log_evaluation(200)])
|
|
|
|
|
|
def train_xgb(X_tr, y_tr, X_va, y_va) -> object:
|
|
if not HAS_XGB:
|
|
return None
|
|
model = xgb.XGBClassifier(
|
|
n_estimators=1000, learning_rate=0.05, max_depth=6,
|
|
subsample=0.8, colsample_bytree=0.8, min_child_weight=5,
|
|
use_label_encoder=False, eval_metric="logloss",
|
|
early_stopping_rounds=50, verbosity=0,
|
|
tree_method="hist",
|
|
)
|
|
model.fit(X_tr, y_tr, eval_set=[(X_va, y_va)], verbose=False)
|
|
return model
|
|
|
|
|
|
def train_rf(X_tr, y_tr) -> object:
|
|
if not HAS_SKLEARN:
|
|
return None
|
|
model = RandomForestClassifier(
|
|
n_estimators=300, max_depth=12, min_samples_leaf=30,
|
|
max_features="sqrt", n_jobs=-1, random_state=42
|
|
)
|
|
model.fit(X_tr, y_tr)
|
|
return model
|
|
|
|
|
|
# ─── Ensemble / stacking ─────────────────────────────────────────────────────
|
|
|
|
def build_ensemble(lgbm_model, xgb_model, rf_model,
|
|
X_te: np.ndarray, y_te: np.ndarray,
|
|
tft_preds: np.ndarray | None = None,
|
|
tgru_preds: np.ndarray | None = None) -> tuple:
|
|
"""
|
|
Stack base models with a logistic regression meta-learner.
|
|
Accepts optional TFT and TGRU probability arrays (already computed on X_te).
|
|
Returns (meta_model, scaler, base_accuracies, ensemble_accuracy).
|
|
"""
|
|
preds = []
|
|
names = []
|
|
accs = {}
|
|
|
|
p_lgbm = lgbm_model.predict(X_te)
|
|
preds.append(p_lgbm); names.append("LightGBM")
|
|
accs["LightGBM"] = accuracy_score(y_te, (p_lgbm > 0.5).astype(int))
|
|
|
|
if xgb_model is not None:
|
|
p_xgb = xgb_model.predict_proba(X_te)[:, 1]
|
|
preds.append(p_xgb); names.append("XGBoost")
|
|
accs["XGBoost"] = accuracy_score(y_te, (p_xgb > 0.5).astype(int))
|
|
|
|
if rf_model is not None:
|
|
p_rf = rf_model.predict_proba(X_te)[:, 1]
|
|
preds.append(p_rf); names.append("RandomForest")
|
|
accs["RandomForest"] = accuracy_score(y_te, (p_rf > 0.5).astype(int))
|
|
|
|
# ── Optional DL models ───────────────────────────────────────────────────
|
|
if tft_preds is not None:
|
|
preds.append(tft_preds); names.append("TFT")
|
|
accs["TFT"] = accuracy_score(y_te, (tft_preds > 0.5).astype(int))
|
|
|
|
if tgru_preds is not None:
|
|
preds.append(tgru_preds); names.append("TGRU")
|
|
accs["TGRU"] = accuracy_score(y_te, (tgru_preds > 0.5).astype(int))
|
|
|
|
# Stack predictions as features for meta-learner
|
|
meta_X = np.column_stack(preds)
|
|
scaler = StandardScaler()
|
|
meta_X_scaled = scaler.fit_transform(meta_X)
|
|
meta = LogisticRegression(C=1.0, max_iter=500)
|
|
meta.fit(meta_X_scaled, y_te) # train meta on test (unseen by base models)
|
|
ens_acc = accuracy_score(y_te, meta.predict(meta_X_scaled))
|
|
|
|
print(f"\n Base models ({len(names)}) :")
|
|
for name, acc in accs.items():
|
|
print(f" {name:15s}: {acc:.4f} ({acc*100:.1f}%)")
|
|
print(f" {'Ensemble':15s}: {ens_acc:.4f} ({ens_acc*100:.1f}%)")
|
|
|
|
return meta, scaler, accs, ens_acc
|
|
|
|
|
|
# ─── SHAP feature importance ─────────────────────────────────────────────────
|
|
|
|
def print_top_features(lgbm_model, top_n: int = 15):
|
|
importance = lgbm_model.feature_importance(importance_type="gain")
|
|
idx = np.argsort(importance)[::-1]
|
|
print(f"\n Top {top_n} features (LightGBM gain):")
|
|
for rank, i in enumerate(idx[:top_n], 1):
|
|
print(f" {rank:2d}. {FEATURE_NAMES[i]:30s} {importance[i]:>10,.0f}")
|
|
|
|
|
|
# ─── Main ────────────────────────────────────────────────────────────────────
|
|
|
|
def main():
|
|
print("=" * 65)
|
|
print(" AHAD QUANT — Ensemble Training Pipeline (V5)")
|
|
if HAS_DL:
|
|
print(" LightGBM + XGBoost + RF + TFT + TransformerGRU + Meta-learner")
|
|
else:
|
|
print(" LightGBM + XGBoost + RandomForest + Stacking Meta-Learner")
|
|
print(" [DL disabled — install torch to enable TFT/TGRU]")
|
|
print("=" * 65)
|
|
|
|
# ── 0. Pré-vol : vérifier que data/ existe et contient des fichiers ───────
|
|
data_dir = config.DATA_DIR
|
|
if not os.path.isdir(data_dir):
|
|
print()
|
|
print("━" * 65)
|
|
print(f" ERREUR : Dossier '{data_dir}/' introuvable.")
|
|
print()
|
|
print(" Lancez d'abord le téléchargement des données :")
|
|
print(" python download_data.py")
|
|
print("━" * 65)
|
|
sys.exit(1)
|
|
json_files = [f for f in os.listdir(data_dir) if f.endswith("_1h.json")]
|
|
if len(json_files) == 0:
|
|
print()
|
|
print("━" * 65)
|
|
print(f" ERREUR : Aucun fichier de données dans '{data_dir}/'.")
|
|
print()
|
|
print(" Lancez d'abord le téléchargement des données :")
|
|
print(" python download_data.py")
|
|
print("━" * 65)
|
|
sys.exit(1)
|
|
print(f"\n[0/7] Données disponibles : {len(json_files)} fichiers dans {data_dir}/")
|
|
|
|
t0 = time.time()
|
|
|
|
# ── 1. Tabular data (always) ──────────────────────────────────────────────
|
|
print("\n[1/7] Loading data and building tabular features...")
|
|
X, y = prepare_dataset()
|
|
n = len(X)
|
|
print(f"\n Total: {n:,} samples | {X.shape[1]} features | "
|
|
f"{y.mean():.2%} long labels")
|
|
|
|
# ── 1b. Sequence data (DL only) ──────────────────────────────────────────
|
|
X_seq = None
|
|
y_seq = None
|
|
dl_scaler = None
|
|
|
|
if HAS_DL:
|
|
print(f"\n[1b/7] Building sequence dataset (SEQ_LEN={SEQ_LEN})...")
|
|
X_tab_aligned, X_seq_raw, y_seq = prepare_seq_dataset()
|
|
n_seq = len(y_seq)
|
|
print(f" Sequence samples : {n_seq:,} | shape: {X_seq_raw.shape}")
|
|
|
|
# Sequence train/val/test splits
|
|
seq_tr_end = int(n_seq * TRAIN_RATIO)
|
|
seq_va_end = int(n_seq * (TRAIN_RATIO + VAL_RATIO))
|
|
|
|
# Fit scaler on training portion
|
|
dl_scaler = fit_seq_scaler(X_tab_aligned[:seq_tr_end])
|
|
|
|
# Normalise all splits
|
|
X_seq_tr = transform_sequences(dl_scaler, X_seq_raw[:seq_tr_end])
|
|
X_seq_va = transform_sequences(dl_scaler, X_seq_raw[seq_tr_end:seq_va_end])
|
|
X_seq_te = transform_sequences(dl_scaler, X_seq_raw[seq_va_end:])
|
|
y_seq_tr = y_seq[:seq_tr_end]
|
|
y_seq_va = y_seq[seq_tr_end:seq_va_end]
|
|
y_seq_te = y_seq[seq_va_end:]
|
|
print(f" Seq splits: train={len(y_seq_tr):,} | val={len(y_seq_va):,} | test={len(y_seq_te):,}")
|
|
|
|
# ── 2. Walk-forward CV ────────────────────────────────────────────────────
|
|
print("\n[2/7] Walk-forward cross-validation (tabular)...")
|
|
cv_results = walk_forward_cv(X, y, N_WALK_FORWARD_WINDOWS)
|
|
|
|
# ── 3. Tabular splits ─────────────────────────────────────────────────────
|
|
train_end = int(n * TRAIN_RATIO)
|
|
val_end = int(n * (TRAIN_RATIO + VAL_RATIO))
|
|
X_tr, y_tr = X[:train_end], y[:train_end]
|
|
X_va, y_va = X[train_end:val_end], y[train_end:val_end]
|
|
X_te, y_te = X[val_end:], y[val_end:]
|
|
print(f"\n Tabular splits: train={len(X_tr):,} | val={len(X_va):,} | test={len(X_te):,}")
|
|
|
|
# ── 4. Optuna search ──────────────────────────────────────────────────────
|
|
print(f"\n[3/7] Hyperparameter search ({OPTUNA_TRIALS} Optuna trials)...")
|
|
best_params = tune_lgbm(X_tr, y_tr, X_va, y_va, OPTUNA_TRIALS)
|
|
|
|
# ── 5. Train tabular base models ──────────────────────────────────────────
|
|
print("\n[4/7] Training tabular base models (LightGBM + XGBoost + RF)...")
|
|
print(" Training LightGBM...")
|
|
lgbm_model = train_lgbm(X_tr, y_tr, X_va, y_va, best_params)
|
|
|
|
print(" Training XGBoost...")
|
|
xgb_model = train_xgb(X_tr, y_tr, X_va, y_va)
|
|
|
|
print(" Training RandomForest...")
|
|
rf_model = train_rf(X_tr, y_tr)
|
|
|
|
# ── 5b. Train DL models ───────────────────────────────────────────────────
|
|
tft_model = None
|
|
tgru_model = None
|
|
tft_preds_te = None
|
|
tgru_preds_te = None
|
|
|
|
if HAS_DL:
|
|
print("\n[4b/7] Training Deep Learning models (TFT + TransformerGRU)...")
|
|
print(" Training Temporal Fusion Transformer...")
|
|
tft_model = train_tft(X_seq_tr, y_seq_tr, X_seq_va, y_seq_va)
|
|
|
|
print("\n Training TransformerGRU...")
|
|
tgru_model = train_tgru(X_seq_tr, y_seq_tr, X_seq_va, y_seq_va)
|
|
|
|
# ── Free training/val sequence tensors: not needed past this point ──
|
|
# (X_seq_tr/X_seq_va are the largest arrays in memory, ~70k+15k seqs)
|
|
import gc, torch
|
|
del X_seq_tr, X_seq_va, y_seq_tr, y_seq_va
|
|
try:
|
|
del X_seq_raw
|
|
except NameError:
|
|
pass
|
|
gc.collect()
|
|
if torch.cuda.is_available():
|
|
torch.cuda.empty_cache()
|
|
print(" [MEM] Freed train/val sequence tensors before inference")
|
|
|
|
# Get DL predictions on sequence test set
|
|
X_seq_te_t = torch.FloatTensor(X_seq_te)
|
|
tft_preds_te = predict_tft_proba(tft_model, X_seq_te_t)
|
|
tgru_preds_te = predict_tgru_proba(tgru_model, X_seq_te_t)
|
|
print(f"\n TFT test acc : {accuracy_score(y_seq_te, (tft_preds_te > 0.5).astype(int)):.4f}")
|
|
print(f" TGRU test acc : {accuracy_score(y_seq_te, (tgru_preds_te > 0.5).astype(int)):.4f}")
|
|
|
|
# X_seq_te_t no longer needed after inference (predictions already extracted)
|
|
del X_seq_te_t
|
|
gc.collect()
|
|
if torch.cuda.is_available():
|
|
torch.cuda.empty_cache()
|
|
|
|
# ── 6. Ensemble / stacking ────────────────────────────────────────────────
|
|
print("\n[5/7] Building stacking ensemble on test set...")
|
|
|
|
if HAS_DL and tft_preds_te is not None:
|
|
# Use sequence-aligned test set for ALL models (common index space)
|
|
# Tabular predictions on the aligned X_tab_aligned test portion
|
|
X_te_dl = X_tab_aligned[seq_va_end:] # aligned tabular test
|
|
y_te_dl = y_seq_te # same labels
|
|
|
|
meta_model, meta_scaler, base_accs, ens_acc = build_ensemble(
|
|
lgbm_model, xgb_model, rf_model,
|
|
X_te_dl, y_te_dl,
|
|
tft_preds=tft_preds_te,
|
|
tgru_preds=tgru_preds_te,
|
|
)
|
|
else:
|
|
# ML-only path (original behavior)
|
|
meta_model, meta_scaler, base_accs, ens_acc = build_ensemble(
|
|
lgbm_model, xgb_model, rf_model, X_te, y_te
|
|
)
|
|
|
|
print_top_features(lgbm_model)
|
|
|
|
# ── 6b. Save feature importances to JSON (lu par web_ui.py dashboard) ────
|
|
try:
|
|
importance = lgbm_model.feature_importance(importance_type="gain")
|
|
feat_imp_data = [
|
|
{"feature": FEATURE_NAMES[i], "importance": float(importance[i])}
|
|
for i in np.argsort(importance)[::-1]
|
|
]
|
|
imp_path = os.path.join(os.path.dirname(__file__), "feature_importance.json")
|
|
with open(imp_path, "w") as f:
|
|
json.dump({"updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
"features": feat_imp_data}, f, indent=2)
|
|
except Exception as _fie:
|
|
print(f" [WARN] feature_importance.json non généré: {_fie}")
|
|
|
|
# ── 7. Save ───────────────────────────────────────────────────────────────
|
|
print(f"\n[6/7] Saving models...")
|
|
|
|
# Standalone LightGBM (compatible with free version loader)
|
|
with open(config.MODEL_PATH, "wb") as f:
|
|
pickle.dump(lgbm_model, f)
|
|
print(f" LightGBM model -> {config.MODEL_PATH}")
|
|
|
|
# Full ensemble (all models + DL if available)
|
|
ensemble_data = {
|
|
# Tabular base models
|
|
"lgbm" : lgbm_model,
|
|
"xgb" : xgb_model,
|
|
"rf" : rf_model,
|
|
# DL models (None if torch not available or --no-dl)
|
|
"tft" : tft_model,
|
|
"tgru" : tgru_model,
|
|
"dl_scaler" : dl_scaler, # StandardScaler for sequence normalisation
|
|
# Meta-learner
|
|
"meta" : meta_model,
|
|
"scaler" : meta_scaler,
|
|
# Metadata
|
|
"feature_names" : FEATURE_NAMES,
|
|
"cv_results" : cv_results,
|
|
"base_accs" : base_accs,
|
|
"ens_acc" : ens_acc,
|
|
"has_dl" : HAS_DL and tft_model is not None,
|
|
"version" : "ahad_quant-forex-v5",
|
|
}
|
|
with open(config.ENSEMBLE_MODEL_PATH, "wb") as f:
|
|
pickle.dump(ensemble_data, f)
|
|
print(f" Ensemble model -> {config.ENSEMBLE_MODEL_PATH}")
|
|
|
|
# ── 8. Summary ────────────────────────────────────────────────────────────
|
|
elapsed = time.time() - t0
|
|
print(f"\n{'=' * 65}")
|
|
print(f" Training complete in {elapsed/60:.1f} min")
|
|
print(f" Walk-forward accuracy : {cv_results['mean']:.4f} ± {cv_results['std']:.4f}")
|
|
print(f" Ensemble test accuracy: {ens_acc:.4f} ({ens_acc*100:.1f}%)")
|
|
if HAS_DL and tft_model is not None:
|
|
print(f" DL models : TFT ✅ TGRU ✅")
|
|
else:
|
|
print(f" DL models : not trained (--no-dl or torch missing)")
|
|
print(f"\n Run `python ahad_quant.py` to start trading.")
|
|
print(f"{'=' * 65}\n")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|