Spaces:
Running
Running
File size: 10,877 Bytes
87f5964 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 | #!/usr/bin/env python3
"""research/selection_rolling_oos.py — is the ML top-N SELECTION edge robust across MANY
out-of-sample windows, or a one-window fluke?
research/ml_selection_backtest.py --six-filter showed the top-N BULLISH basket beats the market
on ONE 6-month holdout. This rolls the cutoff across several consecutive OOS windows and reports
the basket's edge / Sharpe / win-rate per window, so we can see if the edge is consistent.
For each window: train a self-contained base model (HGB direction classifier + up_q50 quantile
regressor) on all rows BEFORE the window, then on each decision day in the window buy the top-N
BULLISH stocks ranked by up_q50 and measure the equal-weight basket's 3-day net return vs the
equal-weight market.
Research only; reads training_data_extra.csv; never touches the production model.
Usage:
python research/selection_rolling_oos.py # top-5, 6×2-month windows
python research/selection_rolling_oos.py --top 5 --windows 6 --test-months 2
"""
from __future__ import annotations
import argparse
import math
import os
import sys
import warnings
import numpy as np
import pandas as pd
warnings.filterwarnings("ignore")
_PROJ_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _PROJ_ROOT not in sys.path:
sys.path.insert(0, _PROJ_ROOT)
from ml_predictor.features import FEATURE_COLUMNS # noqa: E402
# Delivery feature columns (research-only; the prod feature pipeline does NOT include these —
# the delivery %% experiment was validated NOT robust across rolling windows and not promoted).
DELIVERY_FEATURE_COLS = ["deliv_per", "deliv_z20", "deliv_chg5"]
_CSV = os.path.join(_PROJ_ROOT, "ml_predictor", "training_data_extra.csv")
_CSV_DELIV = os.path.join(_PROJ_ROOT, "ml_predictor", "training_data_delivery.csv")
COST = 0.30
TRADING_DAYS = 252
HOLD = 3
def _sharpe(rets):
a = np.asarray([x for x in rets if not np.isnan(x)], dtype=float)
if a.size < 2 or a.std(ddof=1) <= 1e-9:
return 0.0
return float(a.mean() / a.std(ddof=1) * np.sqrt(TRADING_DAYS / HOLD))
def _eval_window(base_dir, up50, feats, te, top_n):
"""Per-day top-N basket net return vs market over one OOS window."""
Xte = te[feats].to_numpy(float)
te = te.assign(_dir=base_dir.predict(Xte), _up50=up50.predict(Xte),
_net=te["ret_3D"].to_numpy(float) - COST)
basket, market = [], []
for _, day in te.groupby("date"):
market.append(float(day["_net"].mean()))
bull = day[day["_dir"] == "BULLISH"]
if len(bull) < top_n:
basket.append(np.nan); continue
pick = bull.sort_values("_up50", ascending=False).head(top_n)
basket.append(float(pick["_net"].mean()))
b = np.array([x for x in basket if not np.isnan(x)])
m = np.array(market)
edge = np.array([bb - mk for bb, mk in zip(basket, market) if not np.isnan(bb)])
return {
"days": len(b),
"basket": b.mean() if len(b) else float("nan"),
"market": m.mean() if len(m) else float("nan"),
"edge": edge.mean() if len(edge) else float("nan"),
"sharpe": _sharpe(basket),
"win_days": float((b > 0).mean()) if len(b) else float("nan"),
}, basket, market
def run(top_n: int, windows: int, test_months: int, min_train_months: int):
from sklearn.ensemble import HistGradientBoostingClassifier, HistGradientBoostingRegressor
df = pd.read_csv(_CSV)
df["date"] = pd.to_datetime(df["date"])
feats = [c for c in FEATURE_COLUMNS if c in df.columns]
df = df.dropna(subset=feats + ["ret_3D", "up_3D", "dir_3D", "date"]).reset_index(drop=True)
dmax = df["date"].max()
dmin = df["date"].min()
# Build `windows` consecutive test slices ending at dmax, each test_months long.
edges = [dmax - pd.DateOffset(months=test_months * k) for k in range(windows + 1)][::-1]
slices = list(zip(edges[:-1], edges[1:])) # (test_start, test_end)
print(f" Data {dmin.date()} → {dmax.date()} · top-{top_n} · {windows}×{test_months}-mo OOS windows · cost {COST}%")
all_basket, all_market = [], []
rows = []
for wi, (ts, tend) in enumerate(slices, 1):
tr = df[df["date"] < ts]
te = df[(df["date"] >= ts) & (df["date"] < tend)]
if (ts - dmin).days < min_train_months * 30 or len(te) < 200:
print(f" window {wi} {ts.date()}→{tend.date()}: skipped (train too short / OOS too small)")
continue
Xtr = tr[feats].to_numpy(float)
bd = HistGradientBoostingClassifier(max_iter=250, learning_rate=0.06,
class_weight="balanced", random_state=0)
bd.fit(Xtr, tr["dir_3D"].to_numpy())
u5 = HistGradientBoostingRegressor(loss="quantile", quantile=0.5, max_iter=250,
learning_rate=0.06, random_state=0)
u5.fit(Xtr, tr["up_3D"].to_numpy(float))
s, basket, market = _eval_window(bd, u5, feats, te, top_n)
all_basket += basket; all_market += market
rows.append({"win": wi, "start": ts.date(), "end": tend.date(), **s})
print(f" window {wi} {ts.date()}→{tend.date()}: trained on {len(tr):,} rows, {s['days']} decision days")
print("\n" + "=" * 88)
print(f" ROLLING OOS SELECTION EDGE — top-{top_n} BULLISH basket, 3-day hold, net of {COST}%")
print("=" * 88)
print(f" {'Window':<7}{'Period':<24}{'Days':>6}{'Basket%':>9}{'Market%':>9}{'Edge':>8}{'Sharpe':>8}{'WinDay':>8}")
print(" " + "-" * 78)
pos = 0
for r in rows:
flag = " ⟵" if r["edge"] > 0 and r["sharpe"] > 0.5 else ""
if r["edge"] > 0:
pos += 1
print(f" {r['win']:<7}{str(r['start'])+'→'+str(r['end']):<24}{r['days']:>6}"
f"{r['basket']:>+9.2f}{r['market']:>+9.2f}{r['edge']:>+8.2f}{r['sharpe']:>8.2f}{r['win_days']:>7.0%}{flag}")
# Pooled across all windows
ps = _sharpe(all_basket)
bb = np.array([x for x in all_basket if not np.isnan(x)])
edge_all = np.array([b - m for b, m in zip(all_basket, all_market) if not np.isnan(b)])
print(" " + "-" * 78)
print(f" {'POOLED':<7}{'all windows':<24}{len(bb):>6}{bb.mean():>+9.2f}"
f"{'':<9}{edge_all.mean():>+8.2f}{ps:>8.2f}{(bb>0).mean():>7.0%}")
print(f"\n Windows with positive edge: {pos}/{len(rows)} · pooled Sharpe {ps:.2f} · "
f"pooled edge {edge_all.mean():+.2f}%/trade")
if pos >= math.ceil(0.7 * len(rows)) and ps > 0.5:
print(" → ROBUST: the selection edge holds across most windows (not a one-window fluke).")
else:
print(" → FRAGILE: the edge is inconsistent across windows — treat with caution.")
def run_dual(top_n: int, windows: int, test_months: int, min_train_months: int):
"""Per rolling window, train BASE (current features) AND BASE+DELIVERY, compare the edge/Sharpe
delta — so we can see if delivery %% helps CONSISTENTLY, not just on one window."""
from sklearn.ensemble import HistGradientBoostingClassifier, HistGradientBoostingRegressor
df = pd.read_csv(_CSV_DELIV)
df["date"] = pd.to_datetime(df["date"])
base_feats = [c for c in FEATURE_COLUMNS if c in df.columns and not c.startswith("deliv")]
deliv_feats = base_feats + [c for c in DELIVERY_FEATURE_COLS if c in df.columns]
df = df.dropna(subset=base_feats + ["ret_3D", "up_3D", "dir_3D", "date"]).reset_index(drop=True)
dmax, dmin = df["date"].max(), df["date"].min()
edges = [dmax - pd.DateOffset(months=test_months * k) for k in range(windows + 1)][::-1]
slices = list(zip(edges[:-1], edges[1:]))
print(f" BASE vs BASE+DELIVERY · top-{top_n} · {windows}×{test_months}-mo OOS windows · "
f"{len(deliv_feats)-len(base_feats)} delivery feats")
def _fit(feats, tr):
bd = HistGradientBoostingClassifier(max_iter=250, learning_rate=0.06,
class_weight="balanced", random_state=0)
bd.fit(tr[feats].to_numpy(float), tr["dir_3D"].to_numpy())
u5 = HistGradientBoostingRegressor(loss="quantile", quantile=0.5, max_iter=250,
learning_rate=0.06, random_state=0)
u5.fit(tr[feats].to_numpy(float), tr["up_3D"].to_numpy(float))
return bd, u5
rows = []
for wi, (ts, tend) in enumerate(slices, 1):
tr = df[df["date"] < ts]
te = df[(df["date"] >= ts) & (df["date"] < tend)]
if (ts - dmin).days < min_train_months * 30 or len(te) < 200:
continue
bd0, u0 = _fit(base_feats, tr)
s0, _, _ = _eval_window(bd0, u0, base_feats, te, top_n)
bd1, u1 = _fit(deliv_feats, tr)
s1, _, _ = _eval_window(bd1, u1, deliv_feats, te, top_n)
rows.append({"win": wi, "start": ts.date(), "end": tend.date(),
"e0": s0["edge"], "sh0": s0["sharpe"], "e1": s1["edge"], "sh1": s1["sharpe"]})
print(f" window {wi} {ts.date()}→{tend.date()}: trained on {len(tr):,} rows")
print("\n" + "=" * 90)
print(f" ROLLING BASE vs +DELIVERY — top-{top_n} selection edge & Sharpe per OOS window")
print("=" * 90)
print(f" {'Window':<24}{'Base edge':>10}{'+Deliv edge':>12}{'Δedge':>8}"
f"{'Base Shp':>10}{'+Deliv Shp':>12}{'ΔShp':>8}")
print(" " + "-" * 84)
de_pos = sh_pos = 0
for r in rows:
de = r["e1"] - r["e0"]; dsh = r["sh1"] - r["sh0"]
if de > 0: de_pos += 1
if dsh > 0: sh_pos += 1
flag = " ⟵" if (de > 0 and dsh > 0) else ""
print(f" {str(r['start'])+'→'+str(r['end']):<24}{r['e0']:>+10.2f}{r['e1']:>+12.2f}{de:>+8.2f}"
f"{r['sh0']:>10.2f}{r['sh1']:>12.2f}{dsh:>+8.2f}{flag}")
n = len(rows)
print(" " + "-" * 84)
print(f" Delivery improves EDGE in {de_pos}/{n} windows · improves SHARPE in {sh_pos}/{n} windows")
if de_pos >= math.ceil(0.7 * n) and sh_pos >= math.ceil(0.7 * n):
print(" → CONSISTENT: delivery %% helps across most windows — worth promoting to prod.")
else:
print(" → INCONSISTENT: delivery %% helps only sometimes — the single-window gain was likely noise.")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--top", type=int, default=5)
ap.add_argument("--windows", type=int, default=6)
ap.add_argument("--test-months", type=int, default=2)
ap.add_argument("--min-train-months", type=int, default=12)
ap.add_argument("--delivery", action="store_true",
help="compare BASE vs BASE+DELIVERY per window (needs training_data_delivery.csv)")
args = ap.parse_args()
if args.delivery:
run_dual(args.top, args.windows, args.test_months, args.min_train_months)
else:
run(args.top, args.windows, args.test_months, args.min_train_months)
if __name__ == "__main__":
main()
|