Spaces:
Running
Running
feat(C2): add PTT sentiment scraper and backtest; FAILED β PTT rows=0, features not added to model
cec37e3 | #!/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() | |