PaperTrade / research /qlib_train.py
Khanna, Videh Rakesh Rakesh
Add bear-direction fixes, research scripts, market_calendar, and gitignore cleanup
74d4035
Raw
History Blame Contribute Delete
12.2 kB
#!/usr/bin/env python3
"""
research/qlib_train.py β€” Train LightGBM alpha model on NSE data.
Replaces ml_combiner.py's LogisticRegression/XGBoost with LightGBM, using the
same build_feature_matrix() feature pipeline and a 13-fold walk-forward split
that mirrors the existing ml_combiner.py structure.
Note: pyqlib is not available for Python 3.13; this script uses LightGBM
directly on the pandas feature matrix produced by ml_combiner.build_feature_matrix().
The saved model (research/models/lgbm_model.pkl) is loaded at inference time by
qlib_predictor.py, which slots into predictor_core.get_ml_feature_score().
Usage (one-time, run from project root):
python research/qlib_train.py
Output:
research/models/lgbm_model.pkl ← trained model + metadata
"""
from __future__ import annotations
import sys
import os
import pickle
import warnings
import logging
from datetime import datetime
from pathlib import Path
import numpy as np
import pandas as pd
import lightgbm as lgb
from sklearn.metrics import accuracy_score
warnings.filterwarnings("ignore")
logging.basicConfig(level=logging.WARNING)
# ── Paths ─────────────────────────────────────────────────────────────────────
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
MODEL_DIR = PROJECT_ROOT / "research" / "models"
MODEL_PATH = MODEL_DIR / "lgbm_model.pkl"
# ── Training config ───────────────────────────────────────────────────────────
TRAIN_START = "2019-01-01"
TRAIN_END = "2024-01-01"
HORIZON_DAYS = 3 # 3D forward return is best-performing per backtest findings
N_FOLDS = 13
FOLD_MONTHS = 6 # each fold covers 6 months of OOS test data
PROB_THRESHOLD = 0.60 # mirrors ml_combiner.PROB_THRESHOLD
# LightGBM hyperparameters
LGB_PARAMS = {
"objective": "binary",
"metric": "binary_logloss",
"n_estimators": 500,
"learning_rate": 0.05,
"max_depth": 6,
"num_leaves": 63,
"subsample": 0.8,
"colsample_bytree": 0.8,
"min_child_samples": 100,
"reg_alpha": 0.1,
"reg_lambda": 0.1,
"class_weight": "balanced",
"random_state": 42,
"verbose": -1,
"n_jobs": -1,
}
FEAT_COLS_EXCLUDE = {"date", "ticker", "label", "fwd_ret", "nifty_fwd"}
def generate_wf_splits(n_folds: int = N_FOLDS, fold_months: int = FOLD_MONTHS) -> list[dict]:
"""Expanding-window walk-forward splits (mirrors ml_combiner.py logic)."""
splits = []
test_end = pd.Timestamp(TRAIN_END)
for fold in range(n_folds, 0, -1):
t_end = test_end - pd.DateOffset(months=fold_months * (fold - 1))
t_start = t_end - pd.DateOffset(months=fold_months)
if t_start <= pd.Timestamp(TRAIN_START):
continue
splits.append({
"fold": n_folds - fold + 1,
"train_start": pd.Timestamp(TRAIN_START),
"train_end": t_start,
"test_start": t_start,
"test_end": t_end,
})
return splits
def train_lgbm_fold(X_tr, y_tr, X_te, y_te) -> tuple[lgb.LGBMClassifier, np.ndarray]:
"""Train one LightGBM fold, return (model, test_probabilities)."""
model = lgb.LGBMClassifier(**LGB_PARAMS)
model.fit(
X_tr, y_tr,
eval_set=[(X_te, y_te)],
callbacks=[lgb.early_stopping(50, verbose=False), lgb.log_evaluation(period=-1)],
)
probs = model.predict_proba(X_te)[:, 1]
return model, probs
def evaluate_fold(probs: np.ndarray, labels: np.ndarray, fold: int,
test_start: pd.Timestamp, test_end: pd.Timestamp) -> dict:
oos_acc = accuracy_score(labels, (probs >= 0.5).astype(int)) * 100
sig_mask = probs >= PROB_THRESHOLD
n_sigs = int(sig_mask.sum())
sig_acc = float((labels[sig_mask] == 1).mean() * 100) if n_sigs > 0 else float("nan")
return {
"fold": fold,
"test_start": test_start.strftime("%Y-%m"),
"test_end": test_end.strftime("%Y-%m"),
"oos_acc": round(oos_acc, 1),
"n_signals": n_sigs,
"signal_acc": round(sig_acc, 1) if not np.isnan(sig_acc) else None,
"n_test": len(labels),
}
def print_fold_table(fold_stats: list[dict]) -> None:
header = f"{'Fold':>4} {'Period':>16} {'OOS Acc':>8} {'N Signals':>10} {'Signal Acc':>11} {'N Test':>7}"
print(f"\n{'─'*68}")
print(f" {header}")
print(f" {'─'*64}")
for fs in fold_stats:
oos_s = f"{fs['oos_acc']:.1f}%" if fs["oos_acc"] is not None else " N/A"
sig_s = f"{fs['signal_acc']:.1f}%" if fs["signal_acc"] is not None else " N/A"
print(f" {fs['fold']:>4} "
f"{fs['test_start']:>7}–{fs['test_end']:<7} "
f"{oos_s:>8} {fs['n_signals']:>10} {sig_s:>11} {fs['n_test']:>7}")
def main() -> None:
print("=" * 68)
print(" QLIB TRAIN β€” LightGBM Alpha Model for NSE")
print(f" Period: {TRAIN_START} β†’ {TRAIN_END} | Horizon: {HORIZON_DAYS}D")
print(f" Run: {datetime.now().strftime('%d %b %Y %H:%M')}")
print("=" * 68)
# ── Step 1: Load data ────────────────────────────────────────────────────
print("\n Loading NSE market data (yfinance, ~150 tickers Γ— 5 years)...")
from trial_run import load_data
sc, sh, sl, sv, nifty_c, vix_c = load_data()
print(f" Tickers: {sc.shape[1]} | Dates: {len(sc)}")
# ── Step 2: Build macro features ─────────────────────────────────────────
mc_obj = None
try:
from macro_context import MacroContext
mc_obj = MacroContext()
mc_obj.load(TRAIN_START, TRAIN_END)
print(f" Macro context: {mc_obj.summary()}")
except Exception as e:
print(f" Macro context skipped ({e}); macro features will be zero-filled")
from ml_combiner import build_feature_matrix, _build_macro_feat
macro_feat = _build_macro_feat(vix_c, mc_obj)
# ── Step 3: Build feature matrix ─────────────────────────────────────────
print(f"\n Building feature matrix ({HORIZON_DAYS}D horizon)...")
feat_matrix = build_feature_matrix(
sc, sh, sl, sv, nifty_c, vix_c, macro_feat, HORIZON_DAYS
)
feat_cols = [c for c in feat_matrix.columns if c not in FEAT_COLS_EXCLUDE]
print(f" Rows: {len(feat_matrix):,} | Features: {len(feat_cols)}")
print(f" Up-day base rate: {feat_matrix['label'].mean()*100:.1f}%")
print(f" Feature columns: {feat_cols}")
# ── Step 4: Walk-forward evaluation ──────────────────────────────────────
splits = generate_wf_splits()
print(f"\n Walk-forward splits: {len(splits)}")
fold_stats = []
all_probs = []
all_labels = []
fold_models = []
for sp in splits:
train_mask = (feat_matrix["date"] >= sp["train_start"]) & \
(feat_matrix["date"] < sp["train_end"])
test_mask = (feat_matrix["date"] >= sp["test_start"]) & \
(feat_matrix["date"] < sp["test_end"])
tr = feat_matrix[train_mask].dropna(subset=feat_cols)
te = feat_matrix[test_mask].dropna(subset=feat_cols)
if len(tr) < 500 or len(te) < 10:
fold_stats.append({
"fold": sp["fold"], "test_start": sp["test_start"].strftime("%Y-%m"),
"test_end": sp["test_end"].strftime("%Y-%m"),
"oos_acc": None, "n_signals": 0, "signal_acc": None, "n_test": len(te),
})
continue
X_tr, y_tr = tr[feat_cols].values, tr["label"].values
X_te, y_te = te[feat_cols].values, te["label"].values
model, probs = train_lgbm_fold(X_tr, y_tr, X_te, y_te)
fold_stats.append(evaluate_fold(probs, y_te, sp["fold"], sp["test_start"], sp["test_end"]))
all_probs.extend(probs.tolist())
all_labels.extend(y_te.tolist())
fold_models.append(model)
print(f" Fold {sp['fold']:>2} {sp['test_start'].strftime('%Y-%m')}–{sp['test_end'].strftime('%Y-%m')} "
f"OOS={fold_stats[-1]['oos_acc']}% signals={fold_stats[-1]['n_signals']} "
f"sig_acc={fold_stats[-1]['signal_acc']}%", flush=True)
print_fold_table(fold_stats)
# ── Step 5: Overall OOS stats ─────────────────────────────────────────────
if all_probs:
p = np.array(all_probs)
y = np.array(all_labels)
overall_oos = accuracy_score(y, (p >= 0.5).astype(int)) * 100
sig_mask = p >= PROB_THRESHOLD
n_sigs = sig_mask.sum()
sig_acc = (y[sig_mask] == 1).mean() * 100 if n_sigs > 0 else float("nan")
print(f"\n{'═'*68}")
print(f" OVERALL OOS | LightGBM | Threshold={PROB_THRESHOLD}")
print(f"{'─'*68}")
print(f" OOS accuracy (all): {overall_oos:.1f}%")
print(f" High-conf signals: {n_sigs}")
print(f" Signal accuracy: {sig_acc:.1f}%")
print(f"{'═'*68}")
# ── Step 6: Train final model on full 2019-2022, validate 2023 ────────────
print("\n Training final model (2019-2022 train / 2023 validate)...")
final_train_mask = (feat_matrix["date"] >= TRAIN_START) & \
(feat_matrix["date"] < "2023-01-01")
final_val_mask = (feat_matrix["date"] >= "2023-01-01") & \
(feat_matrix["date"] < TRAIN_END)
tr_f = feat_matrix[final_train_mask].dropna(subset=feat_cols)
te_f = feat_matrix[final_val_mask].dropna(subset=feat_cols)
X_tr_f, y_tr_f = tr_f[feat_cols].values, tr_f["label"].values
X_te_f, y_te_f = te_f[feat_cols].values, te_f["label"].values
final_model = lgb.LGBMClassifier(**LGB_PARAMS)
final_model.fit(
X_tr_f, y_tr_f,
eval_set=[(X_te_f, y_te_f)],
callbacks=[lgb.early_stopping(50, verbose=False), lgb.log_evaluation(period=-1)],
)
val_probs = final_model.predict_proba(X_te_f)[:, 1]
val_acc = accuracy_score(y_te_f, (val_probs >= 0.5).astype(int)) * 100
val_sigs = (val_probs >= PROB_THRESHOLD).sum()
val_sig_acc = (y_te_f[val_probs >= PROB_THRESHOLD] == 1).mean() * 100 if val_sigs > 0 else float("nan")
print(f" Final model β€” 2023 validation: OOS={val_acc:.1f}% signals={val_sigs} sig_acc={val_sig_acc:.1f}%")
# Feature importance (top 10)
importances = pd.Series(
final_model.feature_importances_, index=feat_cols
).sort_values(ascending=False)
print("\n Top-10 feature importances:")
for feat, imp in importances.head(10).items():
print(f" {feat:<20} {imp:>6.0f}")
# ── Step 7: Save model ────────────────────────────────────────────────────
MODEL_DIR.mkdir(parents=True, exist_ok=True)
payload = {
"model": final_model,
"feature_names": feat_cols,
"horizon_days": HORIZON_DAYS,
"trained_at": datetime.now().isoformat(),
"train_period": f"{TRAIN_START} β†’ 2022-12-31",
"val_acc": round(val_acc, 1),
"val_signal_acc": round(val_sig_acc, 1) if not np.isnan(val_sig_acc) else None,
"prob_threshold": PROB_THRESHOLD,
}
with open(MODEL_PATH, "wb") as f:
pickle.dump(payload, f, protocol=pickle.HIGHEST_PROTOCOL)
print(f"\n Model saved β†’ {MODEL_PATH}")
print(f" Size: {MODEL_PATH.stat().st_size / 1024:.0f} KB")
print("=" * 68)
if __name__ == "__main__":
main()