DockerSpace / scripts /backtest_compare.py
DennisChan0909's picture
feat(C2): add PTT sentiment scraper and backtest; FAILED β€” PTT rows=0, features not added to model
cec37e3
Raw
History Blame Contribute Delete
12.2 kB
#!/usr/bin/env python3
"""
Walk-forward backtest: compare old (40-feat) vs new (current model) feature set.
Strategy:
- Expanding window starting at MIN_TRAIN_ROWS rows
- Step forward STEP_DAYS at a time
- For each step: train both models on all data up to cutoff,
predict on next STEP_DAYS rows, record accuracy
- Label: 5-day forward return > +2% β†’ 1 (UP), < -2% β†’ -1 (DOWN), else 0 (HOLD)
- Metrics: accuracy, directional precision (UP+DOWN only, ignore HOLD), long-only P&L
Usage:
./venv/bin/python3 scripts/backtest_compare.py
./venv/bin/python3 scripts/backtest_compare.py --stocks 2330 0050 2317 2454 2881
"""
import argparse
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 numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score
# ── Feature sets ────────────────────────────────────────────────────────────
OLD_FEATURES = [
"return_1d", "return_5d", "return_10d", "return_20d",
"close_ma5_ratio", "close_ma20_ratio", "ma5_ma20_ratio", "ma20_ma60_ratio",
"rsi",
"macd_hist", "macd_signal_ratio", "macd_hist_norm", "macd_hist_delta_1d",
"macd_hist_slope_3d", "macd_cross_up", "macd_cross_down", "macd_above_zero",
"bb_pct_b", "k", "d", "volume_ratio",
"atr_ratio", "high_low_ratio", "obv_trend",
"close_ma60_ratio", "log_volume_ratio",
"volume_zscore", "price_volume_div", "volatility_20d",
"taiex_return_5d", "taiex_ma20_ratio", "usdtwd_return_5d",
"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",
]
# Always use the live FEATURE_COLUMNS from predictor so this script stays in sync
from models.predictor import FEATURE_COLUMNS as NEW_FEATURES
# ── Backtest parameters ──────────────────────────────────────────────────────
LABEL_HORIZON = 5 # predict 5-day forward return
LABEL_THRESH = 0.02 # Β±2% to label UP/DOWN
MIN_TRAIN_ROWS = 240 # ~1 year of trading days before first prediction
STEP_DAYS = 21 # step forward ~1 month at a time
RF_PARAMS = dict(n_estimators=200, max_depth=6, min_samples_leaf=10,
class_weight="balanced", random_state=42, n_jobs=-1)
# ── Data fetching ────────────────────────────────────────────────────────────
def fetch_df(stock_no: str) -> pd.DataFrame:
from services.predictor_service import _fetch_with_cache
from indicators.technical import add_all_indicators, add_cross_asset_tw
from data.institutional_flow import add_institutional_flow
from data.margin_flow import add_margin_flow
from data.fetcher import is_us_ticker, fetch_cross_asset_tw
df = _fetch_with_cache(stock_no, months=24)
if df.empty:
return df
df = add_all_indicators(df)
if not is_us_ticker(stock_no):
df = add_institutional_flow(df, stock_no)
df = add_margin_flow(df, stock_no)
start, end = str(df["date"].min()), str(df["date"].max())
result = fetch_cross_asset_tw(start, end)
taiex, usdtwd = result[0], result[1]
sox = result[2] if len(result) > 2 else None
tnx = result[3] if len(result) > 3 else None
df = add_cross_asset_tw(df, taiex, usdtwd, sox_close=sox, tnx_close=tnx)
return df
# ── Backtest engine ──────────────────────────────────────────────────────────
def make_labels(close: pd.Series) -> pd.Series:
fwd = close.shift(-LABEL_HORIZON)
ret = (fwd - close) / close
return pd.Series(
np.where(ret > LABEL_THRESH, 1, np.where(ret < -LABEL_THRESH, -1, 0)),
index=close.index,
)
def walk_forward(feat: pd.DataFrame, feature_cols: list[str]) -> dict:
"""
Expanding-window walk-forward.
Returns dict of aggregated metrics.
"""
avail = [c for c in feature_cols if c in feat.columns]
labels = feat["_label"].values
X_all = feat[avail].fillna(0).values
close = feat["_close"].values
n = len(feat)
y_true_all, y_pred_all = [], []
# For P&L: +1 if predicted UP and actually went up, -1 if predicted DOWN and went down
pnl_model, pnl_buy_hold = [], []
cutoff = MIN_TRAIN_ROWS
while cutoff + STEP_DAYS + LABEL_HORIZON <= n:
X_train = X_all[:cutoff]
y_train = labels[:cutoff]
# Test window: next STEP_DAYS rows (skip last LABEL_HORIZON β€” no label yet)
test_end = min(cutoff + STEP_DAYS, n - LABEL_HORIZON)
X_test = X_all[cutoff:test_end]
y_test = labels[cutoff:test_end]
c_test = close[cutoff:test_end]
if len(np.unique(y_train)) < 2 or len(X_test) == 0:
cutoff += STEP_DAYS
continue
clf = RandomForestClassifier(**RF_PARAMS)
clf.fit(X_train, y_train)
y_pred = clf.predict(X_test)
y_true_all.extend(y_test.tolist())
y_pred_all.extend(y_pred.tolist())
# Simple directional P&L: 5-day fwd return when signal is UP or DOWN
classes = list(clf.classes_)
proba = clf.predict_proba(X_test)
prob_map = {cls: proba[:, i] for i, cls in enumerate(classes)}
for i in range(len(X_test)):
p_up = prob_map.get(1, np.zeros(len(X_test)))[i]
p_dn = prob_map.get(-1, np.zeros(len(X_test)))[i]
fwd_ret = (close[cutoff + i + LABEL_HORIZON] - c_test[i]) / c_test[i] if (cutoff + i + LABEL_HORIZON) < n else 0.0
if y_pred[i] == 1:
pnl_model.append(fwd_ret)
elif y_pred[i] == -1:
pnl_model.append(-fwd_ret) # short position
# Buy-and-hold baseline: always long
pnl_buy_hold.append(fwd_ret)
cutoff += STEP_DAYS
if not y_true_all:
return {}
y_true = np.array(y_true_all)
y_pred = np.array(y_pred_all)
# Directional subset (exclude HOLD predictions for precision/recall)
dir_mask = y_pred != 0
y_true_dir = y_true[dir_mask]
y_pred_dir = y_pred[dir_mask]
acc = accuracy_score(y_true, y_pred)
n_signals = dir_mask.sum()
dir_acc = accuracy_score(y_true_dir, y_pred_dir) if n_signals > 0 else float("nan")
# UP-only precision: when model says UP, how often correct?
up_mask = y_pred == 1
up_prec = (y_true[up_mask] == 1).mean() if up_mask.sum() > 0 else float("nan")
dn_mask = y_pred == -1
dn_prec = (y_true[dn_mask] == -1).mean() if dn_mask.sum() > 0 else float("nan")
total_pnl = sum(pnl_model)
bh_pnl = sum(pnl_buy_hold)
n_trades = len(pnl_model)
win_rate = (np.array(pnl_model) > 0).mean() if pnl_model else float("nan")
return {
"n_predictions": len(y_true),
"n_signals": int(n_signals),
"accuracy": round(acc * 100, 1),
"dir_accuracy": round(dir_acc * 100, 1) if not np.isnan(dir_acc) else float("nan"),
"up_precision": round(up_prec * 100, 1) if not np.isnan(up_prec) else float("nan"),
"dn_precision": round(dn_prec * 100, 1) if not np.isnan(dn_prec) else float("nan"),
"pnl_pct": round(total_pnl * 100, 1),
"bh_pnl_pct": round(bh_pnl * 100, 1),
"win_rate": round(win_rate * 100, 1) if not np.isnan(win_rate) else float("nan"),
"n_trades": n_trades,
}
def fmt(val, suffix=""):
if isinstance(val, float) and np.isnan(val):
return " N/A"
return f"{val:>5.1f}{suffix}"
def run_stock(stock_no: str) -> dict | None:
from models.predictor import _build_features
df = fetch_df(stock_no)
if df is None or df.empty or len(df) < MIN_TRAIN_ROWS + STEP_DAYS + LABEL_HORIZON:
return None
feat = _build_features(df)
close_series = df.set_index("date")["close"] if "date" in df.columns else df["close"]
labels = make_labels(close_series)
feat["_label"] = labels.values
feat["_close"] = close_series.values
return {
"old": walk_forward(feat, OLD_FEATURES),
"new": walk_forward(feat, NEW_FEATURES),
}
# ── Main ─────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--stocks", nargs="+", default=["2330", "0050", "2317", "2454", "2881"])
args = parser.parse_args()
print("\nWalk-forward backtest β€” expanding window, step=21d, label=5d Β±2%")
print(f"Min train: {MIN_TRAIN_ROWS} rows | Features: old={len(OLD_FEATURES)}, new={len(NEW_FEATURES)}\n")
col_w = 54
hdr = (f"{'Stock':<6} {'Model':<5} {'Acc%':>5} {'Dir%':>5} "
f"{'↑Prec':>6} {'↓Prec':>6} {'WinR%':>6} {'P&L%':>6} {'B&H%':>6} "
f"{'Trades':>6} {'Preds':>5}")
print(hdr)
print("-" * len(hdr))
agg = {"old": [], "new": []}
for stock_no in args.stocks:
print(f" computing {stock_no}...", end="\r", flush=True)
try:
results = run_stock(stock_no)
except Exception as e:
print(f"{stock_no:<6} ERROR: {e}")
continue
if not results:
print(f"{stock_no:<6} insufficient data")
continue
for key in ("old", "new"):
r = results[key]
if not r:
continue
label = f"old{len(OLD_FEATURES)}" if key == "old" else f"new{len(NEW_FEATURES)}"
print(
f"{stock_no:<6} {label:<5} "
f"{fmt(r['accuracy'])} {fmt(r['dir_accuracy'])} "
f"{fmt(r['up_precision'])}% {fmt(r['dn_precision'])}% "
f"{fmt(r['win_rate'])}% {fmt(r['pnl_pct'])}% "
f"{fmt(r['bh_pnl_pct'])}% {r['n_trades']:>6} {r['n_predictions']:>5}"
)
agg[key].append(r)
# Delta row
ro, rn = results["old"], results["new"]
if ro and rn:
da = rn["accuracy"] - ro["accuracy"]
dd = rn["dir_accuracy"] - ro["dir_accuracy"]
du = rn["up_precision"] - ro["up_precision"]
dw = rn["win_rate"] - ro["win_rate"]
dp = rn["pnl_pct"] - ro["pnl_pct"]
print(
f"{'':6} {'Ξ”':<5} "
f"{da:>+5.1f} {dd:>+5.1f} "
f"{du:>+5.1f}% {'':>6} "
f"{dw:>+5.1f}% {dp:>+5.1f}%"
)
print()
# ── Aggregate summary ────────────────────────────────────────────────────
if agg["old"] and agg["new"]:
print("=" * len(hdr))
print("AGGREGATE (mean across stocks):")
for key, label in [("old", f"old{len(OLD_FEATURES)}"), ("new", f"new{len(NEW_FEATURES)}")]:
rows = agg[key]
def m(field):
vals = [r[field] for r in rows if not np.isnan(r.get(field, float("nan")))]
return round(sum(vals) / len(vals), 1) if vals else float("nan")
print(
f" {label:<7} acc={m('accuracy')}% dir={m('dir_accuracy')}% "
f"↑prec={m('up_precision')}% ↓prec={m('dn_precision')}% "
f"win={m('win_rate')}% P&L={m('pnl_pct')}% B&H={m('bh_pnl_pct')}%"
)
print()
if __name__ == "__main__":
main()