Spaces:
Running
Running
| #!/usr/bin/env python3 | |
| """C16: Stacking ensemble β RF + XGB + LGBM base models, XGBoost meta-learner. | |
| Instead of averaging base model probabilities, a meta-learner learns the optimal | |
| combination from held-out base predictions. | |
| Meta-train: last 60 rows of each training window (before test period). | |
| Meta-features: [rf_p(-1), rf_p(0), rf_p(1), xgb_p(-1..1), lgbm_p(-1..1)] = 9 dims. | |
| Meta-learner: XGBoostClassifier (light params, fast). | |
| Pass gate: dir_accuracy > 43.5% AND up_precision > 54%. | |
| """ | |
| import sys | |
| from pathlib import Path | |
| ROOT = Path(__file__).resolve().parent.parent | |
| sys.path.insert(0, str(ROOT)) | |
| try: | |
| from dotenv import load_dotenv; load_dotenv(ROOT / ".env") | |
| except ImportError: | |
| pass | |
| import warnings; warnings.filterwarnings("ignore") | |
| import json | |
| import numpy as np | |
| from sklearn.ensemble import RandomForestClassifier | |
| from xgboost import XGBClassifier | |
| from lightgbm import LGBMClassifier | |
| sys.path.insert(0, str(ROOT / "scripts")) | |
| from improvement_harness import ( | |
| BASELINE_FEATURES, DEFAULT_STOCKS, RF_PARAMS, | |
| fetch_df, build_triple_barrier_labels, compute_metrics, | |
| walk_forward, | |
| MIN_TRAIN, STEP, LABEL_HORIZON, | |
| ) | |
| from models.predictor import _build_features | |
| STOCKS = DEFAULT_STOCKS | |
| CLASSES = np.array([-1, 0, 1]) | |
| META_WINDOW = 60 # rows used to train meta-learner per step | |
| def _fit_base(X_tr, y_v): | |
| """Train RF + XGB + LGBM, return fitted list.""" | |
| rf = RandomForestClassifier( | |
| n_estimators=200, max_depth=6, min_samples_leaf=10, | |
| class_weight="balanced", random_state=42, n_jobs=-1 | |
| ) | |
| xgb = XGBClassifier( | |
| n_estimators=150, max_depth=4, learning_rate=0.05, | |
| subsample=0.8, colsample_bytree=0.8, | |
| use_label_encoder=False, eval_metric="mlogloss", | |
| random_state=42, verbosity=0, n_jobs=-1 | |
| ) | |
| lgbm = LGBMClassifier( | |
| n_estimators=150, max_depth=4, learning_rate=0.05, | |
| subsample=0.8, colsample_bytree=0.8, | |
| class_weight="balanced", random_state=42, verbose=-1, n_jobs=-1 | |
| ) | |
| rf.fit(X_tr, y_v) | |
| xgb.fit(X_tr, y_v + 1) # XGB needs 0-indexed labels | |
| lgbm.fit(X_tr, y_v) | |
| return rf, xgb, lgbm | |
| def _base_proba(rf, xgb, lgbm, X) -> np.ndarray: | |
| """Return (n, 9) meta-features: 3 probs Γ 3 models.""" | |
| def _align(clf, X_in, shift=0): | |
| p = clf.predict_proba(X_in) | |
| out = np.zeros((len(X_in), 3)) | |
| cls = list(clf.classes_) | |
| for j, c in enumerate(CLASSES): | |
| idx = cls.index(c + shift) if (c + shift) in cls else -1 | |
| if idx >= 0: | |
| out[:, j] = p[:, idx] | |
| return out | |
| rf_p = _align(rf, X, shift=0) | |
| xgb_p = _align(xgb, X, shift=1) # XGB labels were shifted +1 | |
| lgbm_p = _align(lgbm, X, shift=0) | |
| return np.hstack([rf_p, xgb_p, lgbm_p]) # (n, 9) | |
| def walk_forward_stacked(feat_df, label_arr, cols): | |
| avail = [c for c in cols if c in feat_df.columns] | |
| X_all = feat_df[avail].fillna(0).values | |
| n = len(feat_df) | |
| y_true_all, y_pred_avg_all, y_pred_stack_all = [], [], [] | |
| cutoff = MIN_TRAIN | |
| while cutoff + STEP + LABEL_HORIZON <= n: | |
| y_tr = label_arr[:cutoff] | |
| valid = ~np.isnan(y_tr) | |
| y_v = y_tr[valid].astype(int) | |
| if len(y_v) < 40 or len(np.unique(y_v)) < 2: | |
| cutoff += STEP; continue | |
| # ββ Base train window (everything before meta window) ββββββββββββββββββ | |
| meta_start = max(0, valid.sum() - META_WINDOW) | |
| # Indices in the original (unfiltered) space | |
| valid_idx = np.where(valid)[0] | |
| if len(valid_idx) <= META_WINDOW + 10: | |
| cutoff += STEP; continue | |
| base_idx = valid_idx[:meta_start] | |
| meta_idx = valid_idx[meta_start:] | |
| X_base, y_base = X_all[base_idx], y_v[:meta_start] | |
| X_meta, y_meta = X_all[meta_idx], y_v[meta_start:] | |
| if len(np.unique(y_base)) < 2: | |
| cutoff += STEP; continue | |
| try: | |
| rf, xgb, lgbm = _fit_base(X_base, y_base) | |
| except Exception: | |
| cutoff += STEP; continue | |
| # ββ Meta-learner ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| meta_feats = _base_proba(rf, xgb, lgbm, X_meta) | |
| meta_clf = XGBClassifier( | |
| n_estimators=50, max_depth=3, learning_rate=0.1, | |
| use_label_encoder=False, eval_metric="mlogloss", | |
| random_state=42, verbosity=0, n_jobs=1 | |
| ) | |
| try: | |
| meta_clf.fit(meta_feats, y_meta + 1) # shift for XGB | |
| except Exception: | |
| cutoff += STEP; continue | |
| # ββ Test window βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| test_end = min(cutoff + STEP, n - LABEL_HORIZON) | |
| X_te = X_all[cutoff:test_end] | |
| y_te = label_arr[cutoff:test_end] | |
| valid_te = ~np.isnan(y_te) | |
| if valid_te.sum() == 0: | |
| cutoff += STEP; continue | |
| X_te_v = X_te[valid_te] | |
| # Average ensemble prediction | |
| avg_p = ( | |
| _align_proba(rf, X_te_v, shift=0) + | |
| _align_proba(xgb, X_te_v, shift=1) + | |
| _align_proba(lgbm, X_te_v, shift=0) | |
| ) / 3 | |
| y_avg = CLASSES[np.argmax(avg_p, axis=1)] | |
| # Stacked prediction | |
| te_meta_feats = _base_proba(rf, xgb, lgbm, X_te_v) | |
| stack_proba = meta_clf.predict_proba(te_meta_feats) | |
| # meta_clf classes are 0,1,2 β map to -1,0,1 | |
| y_stack = CLASSES[np.argmax(stack_proba, axis=1)] | |
| y_true_all.extend(y_te[valid_te].tolist()) | |
| y_pred_avg_all.extend(y_avg.tolist()) | |
| y_pred_stack_all.extend(y_stack.tolist()) | |
| cutoff += STEP | |
| if not y_true_all: | |
| return {}, {} | |
| yt = np.array(y_true_all) | |
| return ( | |
| compute_metrics(yt, np.array(y_pred_avg_all)), | |
| compute_metrics(yt, np.array(y_pred_stack_all)), | |
| ) | |
| def _align_proba(clf, X, shift=0): | |
| p = clf.predict_proba(X) | |
| out = np.zeros((len(X), 3)) | |
| cls = list(clf.classes_) | |
| for j, c in enumerate(CLASSES): | |
| idx = cls.index(c + shift) if (c + shift) in cls else -1 | |
| if idx >= 0: | |
| out[:, j] = p[:, idx] | |
| return out | |
| CURRENT_FEATURES = [f for f in BASELINE_FEATURES if f not in { | |
| "macd_cross_up", "macd_cross_down", "price_volume_div", | |
| "foreign_net_vol_ratio", "trust_net_vol_ratio", "dealer_net_vol_ratio", | |
| "institutional_net_vol_ratio", "institutional_5d_net_vol_ratio", | |
| "institutional_20d_zscore", "foreign_trust_alignment", "institutional_streak", | |
| }] | |
| PASS_DIR_ACC = 43.5 | |
| PASS_UP_PREC = 54.0 | |
| def _mean(rows, field): | |
| vals = [r[field] for r in rows if isinstance(r.get(field), (int, float)) | |
| and not np.isnan(r.get(field, float("nan")))] | |
| return round(sum(vals) / len(vals), 1) if vals else float("nan") | |
| def main(): | |
| hdr = f"{'Stock':>6} {'Model':>10} {'Acc%':>5} {'Dir%':>5} {'βPrec%':>7} {'Signals':>7}" | |
| print(f"\n{hdr}\n{'-'*len(hdr)}") | |
| agg_avg, agg_stack = [], [] | |
| results = {} | |
| for stock_no in STOCKS: | |
| print(f" computing {stock_no}...", end="\r", flush=True) | |
| df = fetch_df(stock_no) | |
| if df is None or df.empty: | |
| print(f"{stock_no:>6} no data"); continue | |
| feat = _build_features(df) | |
| close = df["close"].values if "date" not in df.columns else df.set_index("date")["close"].values | |
| labels = build_triple_barrier_labels(close) | |
| r_avg, r_stack = walk_forward_stacked(feat, labels, CURRENT_FEATURES) | |
| if not r_avg: | |
| continue | |
| agg_avg.append(r_avg) | |
| agg_stack.append(r_stack) | |
| results[stock_no] = {"avg": r_avg, "stack": r_stack} | |
| for name, r in [("avg_ens", r_avg), ("stacked", r_stack)]: | |
| prefix = f"{stock_no:>6}" if name == "avg_ens" else f"{'':>6}" | |
| print(f"{prefix} {name:>10} {r.get('accuracy',0):>5.1f} " | |
| f"{r.get('dir_accuracy',0):>5.1f} {r.get('up_precision',0):>7.1f} " | |
| f"{r.get('n_signals',0):>7}") | |
| da = r_stack.get("dir_accuracy", 0) - r_avg.get("dir_accuracy", 0) | |
| dp = r_stack.get("up_precision", 0) - r_avg.get("up_precision", 0) | |
| print(f"{'':>6} {'':>10} {'Ξ':>5} {da:>+5.1f} {dp:>+7.1f}") | |
| agg_a = {"dir_accuracy": _mean(agg_avg, "dir_accuracy"), | |
| "up_precision": _mean(agg_avg, "up_precision")} | |
| agg_s = {"dir_accuracy": _mean(agg_stack, "dir_accuracy"), | |
| "up_precision": _mean(agg_stack, "up_precision")} | |
| print(f"\n=== AGGREGATE ===") | |
| print(f" avg_ensemble: dir={agg_a['dir_accuracy']}% βprec={agg_a['up_precision']}%") | |
| print(f" stacked: dir={agg_s['dir_accuracy']}% βprec={agg_s['up_precision']}%") | |
| dir_delta = (agg_s["dir_accuracy"] or 0) - (agg_a["dir_accuracy"] or 0) | |
| prec_delta = (agg_s["up_precision"] or 0) - (agg_a["up_precision"] or 0) | |
| passed = ( | |
| (agg_s["dir_accuracy"] or 0) >= PASS_DIR_ACC and | |
| (agg_s["up_precision"] or 0) >= PASS_UP_PREC and | |
| dir_delta >= -0.5 and prec_delta >= -0.5 | |
| ) | |
| print(f"\nββ Results ββββββββββββββββββββββββββββββββββββββ") | |
| print(f" Ξ dir_acc={dir_delta:+.1f}pp Ξ up_prec={prec_delta:+.1f}pp") | |
| print(f" Pass gate: dir>{PASS_DIR_ACC}% AND up_prec>{PASS_UP_PREC}%") | |
| print(f" Result: {'PASSED' if passed else 'FAILED'}") | |
| out = {"experiment": "C16_stacking", "aggregate": {"avg": agg_a, "stacked": agg_s}, | |
| "results": results, "passed": passed} | |
| Path("docs").mkdir(exist_ok=True) | |
| with open("docs/c16_result.json", "w") as f: | |
| json.dump(out, f, indent=2, default=str) | |
| print(" Saved β docs/c16_result.json") | |
| if __name__ == "__main__": | |
| main() | |