DockerSpace / scripts /backtest_c25.py
DennisChan0909's picture
Add news sentiment pipeline (C28) and macro factor experiments C24-C27
f786dce
Raw
History Blame Contribute Delete
10.6 kB
#!/usr/bin/env python3
"""C25: C20 sector-conditional model + C24 DJIA/VIX macro features (combined).
C20 showed sector pooling raises 12-stock up_prec to 54.2% (gate pass).
C24 showed DJIA+VIX raises TSMC up_prec from 54% → 61% but pulls MediaTek/Fubon
without sector pooling. Combining both should keep TSMC's DJIA boost while C20's
sector training floors MediaTek and Fubon precision.
New features over CURRENT_FEATURES:
djia_ret_1d — prior-day DJIA return (non-leaking)
djia_ret_5d — DJIA 5-day return
djia_ma20_ratio — DJIA / 20d MA
vix_level — VIX spot level
vix_change_5d — 5-day VIX change
"""
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
import warnings; warnings.filterwarnings("ignore")
import json
import argparse
import numpy as np
import pandas as pd
import yfinance as yf
from sklearn.ensemble import RandomForestClassifier
from sklearn.preprocessing import StandardScaler
from scripts.improvement_harness import (
fetch_df, build_triple_barrier_labels, compute_metrics,
CURRENT_FEATURES, DEFAULT_STOCKS, EXTENDED_STOCKS,
PASS_DIR_ACC, PASS_UP_PREC,
MIN_TRAIN, STEP, LABEL_HORIZON, RF_PARAMS,
)
from models.predictor import _build_features
# ── Sector config (same as C20) ──────────────────────────────────────────────
SECTOR_MAP = {
"semis": ["2330", "2454", "2303"],
"electronics": ["2317", "2382", "2308"],
"financials": ["2881", "2882", "2886"],
"etfs": ["0050", "0056"],
"telecom": ["2412"],
}
NO_POOL_SECTORS = {"electronics", "telecom"}
STOCK_SECTOR = {s: sec for sec, stocks in SECTOR_MAP.items() for s in stocks}
ALL_STOCKS = list(dict.fromkeys(DEFAULT_STOCKS + EXTENDED_STOCKS))
# ── C24 feature extensions ───────────────────────────────────────────────────
C24_FEATURES = ["djia_ret_1d", "djia_ret_5d", "djia_ma20_ratio", "vix_level", "vix_change_5d"]
NEW_FEATURES = CURRENT_FEATURES + C24_FEATURES
_macro_cache: dict = {}
def _fetch_macro(start: str, end: str):
key = f"{start}:{end}"
if key in _macro_cache:
return _macro_cache[key]
djia = vix = None
try:
raw = yf.download(["^DJI", "^VIX"], start=start, end=end,
auto_adjust=True, progress=False)
if not raw.empty:
close = raw["Close"] if "Close" in raw.columns else raw.get("close")
if close is not None and not close.empty:
close.index = pd.to_datetime(close.index).strftime("%Y-%m-%d")
if "^DJI" in close.columns:
djia = close["^DJI"].dropna()
if "^VIX" in close.columns:
vix = close["^VIX"].dropna()
except Exception as exc:
print(f" [warn] macro fetch: {exc}")
result = (djia, vix)
_macro_cache[key] = result
return result
def _append_macro(feat: pd.DataFrame, df: pd.DataFrame) -> pd.DataFrame:
date_col = df["date"].astype(str) if "date" in df.columns else None
start = str(df["date"].min()) if "date" in df.columns else "2020-01-01"
end = str(df["date"].max()) if "date" in df.columns else "2025-01-01"
djia, vix = _fetch_macro(start, end)
nan_col = pd.Series(np.nan, index=feat.index)
if djia is not None and date_col is not None:
d = djia.to_dict()
aligned = date_col.map(d).astype(float)
feat["djia_ret_1d"] = aligned.pct_change(1).shift(1).values
feat["djia_ret_5d"] = aligned.pct_change(5).values
ma20 = aligned.rolling(20, min_periods=20).mean()
feat["djia_ma20_ratio"] = (aligned / ma20.replace(0, np.nan)).values
else:
feat["djia_ret_1d"] = feat["djia_ret_5d"] = feat["djia_ma20_ratio"] = 0.0
if vix is not None and date_col is not None:
v = vix.to_dict()
aligned_v = date_col.map(v).astype(float)
feat["vix_level"] = aligned_v.values
feat["vix_change_5d"] = aligned_v.diff(5).values
else:
feat["vix_level"] = feat["vix_change_5d"] = 0.0
for col in C24_FEATURES:
feat[col] = pd.Series(feat[col], index=feat.index).ffill().bfill().fillna(0.0)
return feat
def load_stock(stock_no: str):
df = fetch_df(stock_no)
if df is None or df.empty:
return None, None, None
feat = _build_features(df)
feat = _append_macro(feat, df)
close = (df.set_index("date")["close"] if "date" in df.columns else df["close"]).values
labels = build_triple_barrier_labels(close)
dates = df["date"].values if "date" in df.columns else None
return feat, labels, dates
def walk_forward_sector(target_feat, target_labels, target_dates, peers, cols):
avail = [c for c in cols if c in target_feat.columns]
X_tgt = target_feat[avail].fillna(0).values
n = len(target_feat)
y_true_all, y_pred_all = [], []
cutoff = MIN_TRAIN
while cutoff + STEP + LABEL_HORIZON <= n:
train_end = cutoff - LABEL_HORIZON
if train_end < MIN_TRAIN - LABEL_HORIZON:
cutoff += STEP; continue
y_tr = target_labels[:train_end]
valid = ~np.isnan(y_tr)
y_v = y_tr[valid].astype(int)
if len(y_v) < 10 or len(np.unique(y_v)) < 2:
cutoff += STEP; continue
X_train_list = [X_tgt[:train_end][valid]]
y_train_list = [y_v]
cutoff_date = target_dates[train_end - 1] if (target_dates is not None and train_end > 0) else None
for (peer_feat, peer_labels, peer_dates) in peers:
peer_avail = [c for c in cols if c in peer_feat.columns]
if cutoff_date is not None and peer_dates is not None:
peer_end = int((peer_dates <= cutoff_date).sum())
else:
peer_end = int(train_end * len(peer_feat) / n)
peer_end = min(peer_end, len(peer_feat) - LABEL_HORIZON)
if peer_end < 15:
continue
y_p = peer_labels[:peer_end]
valid_p = ~np.isnan(y_p)
y_vp = y_p[valid_p].astype(int)
if len(y_vp) < 5 or len(np.unique(y_vp)) < 2:
continue
X_p = peer_feat[peer_avail].fillna(0).values[:peer_end][valid_p]
X_train_list.append(X_p)
y_train_list.append(y_vp)
X_train = np.vstack(X_train_list)
y_train = np.concatenate(y_train_list)
if len(np.unique(y_train)) < 2:
cutoff += STEP; continue
scaler = StandardScaler()
X_train_s = scaler.fit_transform(X_train)
rf = RandomForestClassifier(**RF_PARAMS)
rf.fit(X_train_s, y_train)
test_end = min(cutoff + STEP, n - LABEL_HORIZON)
y_te = target_labels[cutoff:test_end]
valid_te = ~np.isnan(y_te)
if valid_te.sum() == 0:
cutoff += STEP; continue
X_te_s = scaler.transform(X_tgt[cutoff:test_end][valid_te])
y_pred = rf.predict(X_te_s)
y_true_all.extend(y_te[valid_te].astype(int).tolist())
y_pred_all.extend(y_pred.tolist())
cutoff += STEP
if not y_true_all:
return {}
return compute_metrics(np.array(y_true_all), np.array(y_pred_all))
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--extended", action="store_true")
args = parser.parse_args()
eval_stocks = EXTENDED_STOCKS if args.extended else DEFAULT_STOCKS
tag = "12-stock" if args.extended else "5-stock"
suffix = "_12stock" if args.extended else ""
print(f"\n=== C25: Sector model + DJIA/VIX features [{tag}] ===")
print(f" Features: {len(NEW_FEATURES)} ({len(CURRENT_FEATURES)} base + {len(C24_FEATURES)} macro)\n")
print(" Loading all stock data...", flush=True)
stock_data: dict = {}
for s in ALL_STOCKS:
feat, labels, dates = load_stock(s)
if feat is None:
print(f" {s}: no data")
continue
stock_data[s] = {"feat": feat, "labels": labels, "dates": dates}
print(f" {s}: {len(feat)} rows, sector={STOCK_SECTOR.get(s, '?')}")
print()
per_stock, metrics_list = {}, []
for target_no in eval_stocks:
if target_no not in stock_data:
print(f" {target_no}: missing, skip")
continue
sector = STOCK_SECTOR.get(target_no, "unknown")
if sector in NO_POOL_SECTORS:
peer_stocks = []
else:
peer_stocks = [s for s in SECTOR_MAP.get(sector, []) if s != target_no and s in stock_data]
peers = [
(stock_data[p]["feat"], stock_data[p]["labels"], stock_data[p]["dates"])
for p in peer_stocks
]
td = stock_data[target_no]
print(f" {target_no} [{sector}, peers={peer_stocks}]...", end=" ", flush=True)
m = walk_forward_sector(td["feat"], td["labels"], td["dates"], peers, NEW_FEATURES)
per_stock[target_no] = {**m, "sector": sector, "peers": peer_stocks}
metrics_list.append(m)
if m:
print(f"dir={m['dir_accuracy']}% ↑prec={m['up_precision']}%")
else:
print("no output")
def _avg(key):
vals = [m[key] for m in metrics_list if m and not np.isnan(m.get(key, float("nan")))]
return round(float(np.mean(vals)), 1) if vals else float("nan")
avg_dir = _avg("dir_accuracy")
avg_up = _avg("up_precision")
passed = avg_dir >= PASS_DIR_ACC and avg_up >= PASS_UP_PREC
print(f"\n Avg: dir={avg_dir}% ↑prec={avg_up}%")
print(f" Gate (dir≥{PASS_DIR_ACC}% AND ↑prec≥{PASS_UP_PREC}%): {'PASS ✓' if passed else 'FAIL ✗'}")
result = {
"experiment": "C25",
"description": "C20 sector-conditional model + C24 DJIA/VIX macro features",
"new_features": C24_FEATURES,
"stocks": eval_stocks,
"aggregate": {"dir_accuracy": avg_dir, "up_precision": avg_up},
"per_stock": per_stock,
"passed": passed,
"pass_gate": {"dir_accuracy": PASS_DIR_ACC, "up_precision": PASS_UP_PREC},
}
out = ROOT / f"docs/c25_result{suffix}.json"
out.parent.mkdir(exist_ok=True)
out.write_text(json.dumps(result, indent=2))
print(f"\n Saved: {out}")
return 0 if passed else 1
if __name__ == "__main__":
sys.exit(main())