DockerSpace / scripts /backtest_c16.py
DennisChan0909's picture
feat: integrate local architecture with HF Space
e610a2f
Raw
History Blame Contribute Delete
9.94 kB
#!/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()