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 | |
| """ | |
| Compare prediction confidence between old (dev-main, 40 features) and | |
| new (feat/chip-score, 63 features) model on the same live data. | |
| Usage: | |
| python scripts/compare_versions.py | |
| python scripts/compare_versions.py --stocks 2330 0050 2317 2454 | |
| """ | |
| import argparse | |
| import os | |
| 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 | |
| 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 | |
| LABEL_HORIZON = 5 | |
| LABEL_THRESHOLD = 0.02 | |
| RF_PARAMS = dict(n_estimators=200, max_depth=6, min_samples_leaf=10, | |
| class_weight="balanced", random_state=42, n_jobs=-1) | |
| def fetch_df(stock_no: str) -> pd.DataFrame: | |
| from services.predictor_service import _fetch_with_cache | |
| from indicators.technical import add_all_indicators | |
| 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 | |
| from indicators.technical import add_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 | |
| def predict_with_features(feat: pd.DataFrame, cols: list[str]) -> dict: | |
| avail = [c for c in cols if c in feat.columns] | |
| labels = feat["_label"].dropna() | |
| common = feat.index.intersection(labels.index) | |
| X = feat.loc[common, avail].fillna(0).values | |
| y = labels.loc[common].values | |
| # Drop last LABEL_HORIZON rows (no forward return yet) | |
| X_train, y_train = X[:-5], y[:-5] | |
| X_last = X[[-1]] | |
| if len(np.unique(y_train)) < 2: | |
| return {"signal": "N/A", "prob_up": float("nan"), | |
| "prob_dn": float("nan"), "prob_nt": float("nan"), | |
| "n_features": len(avail), "train_rows": len(X_train)} | |
| clf = RandomForestClassifier(**RF_PARAMS) | |
| clf.fit(X_train, y_train) | |
| classes = list(clf.classes_) | |
| proba = clf.predict_proba(X_last)[0] | |
| prob_map = dict(zip(classes, proba)) | |
| prob_up = prob_map.get(1, 0.0) | |
| prob_dn = prob_map.get(-1, 0.0) | |
| prob_nt = prob_map.get(0, 0.0) | |
| best = max(prob_up, prob_dn, prob_nt) | |
| signal = "UP" if prob_up == best else ("DOWN" if prob_dn == best else "HOLD") | |
| return { | |
| "signal": signal, | |
| "prob_up": round(prob_up * 100, 1), | |
| "prob_dn": round(prob_dn * 100, 1), | |
| "prob_nt": round(prob_nt * 100, 1), | |
| "n_features": len(avail), | |
| "train_rows": len(X_train), | |
| } | |
| def predict_stock(stock_no: str): | |
| from models.predictor import _build_features | |
| df = fetch_df(stock_no) | |
| if df is None or df.empty: | |
| return None | |
| feat = _build_features(df) | |
| close = df.set_index("date")["close"] if "date" in df.columns else df["close"] | |
| fwd = close.shift(-LABEL_HORIZON) | |
| ret = (fwd - close) / close | |
| labels = pd.Series( | |
| np.where(ret > LABEL_THRESHOLD, 1, np.where(ret < -LABEL_THRESHOLD, -1, 0)), | |
| index=feat.index, | |
| ).astype(float) | |
| labels.iloc[-LABEL_HORIZON:] = np.nan | |
| feat["_label"] = labels | |
| return { | |
| f"old ({len(OLD_FEATURES)} feat)": predict_with_features(feat, OLD_FEATURES), | |
| f"new ({len(NEW_FEATURES)} feat)": predict_with_features(feat, NEW_FEATURES), | |
| } | |
| def arrow(old_sig: str, new_sig: str) -> str: | |
| if old_sig == new_sig: | |
| return "=" | |
| return f"{old_sig}→{new_sig}" | |
| def main(): | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument("--stocks", nargs="+", default=["2330", "0050", "2317", "2454", "2881"]) | |
| args = parser.parse_args() | |
| hdr = f"{'Stock':>6} {'Version':<18} {'Signal':>5} {'↑%':>6} {'↓%':>6} {'⬄%':>6} {'Feat':>4} {'Rows':>5}" | |
| sep = "-" * len(hdr) | |
| print(f"\n{hdr}\n{sep}") | |
| for stock_no in args.stocks: | |
| print(f" fetching {stock_no}...", end="\r", flush=True) | |
| try: | |
| results = predict_stock(stock_no) | |
| except Exception as e: | |
| print(f"{stock_no:>6} ERROR: {e}") | |
| continue | |
| if results is None: | |
| print(f"{stock_no:>6} No data") | |
| continue | |
| versions = list(results.items()) | |
| first = True | |
| for version, r in versions: | |
| prefix = f"{stock_no:>6}" if first else f"{'':>6}" | |
| first = False | |
| print( | |
| f"{prefix} {version:<18} {r['signal']:>5} " | |
| f"{r['prob_up']:>5.1f}% {r['prob_dn']:>5.1f}% {r['prob_nt']:>5.1f}% " | |
| f"{r['n_features']:>4} {r.get('train_rows', 0):>5}" | |
| ) | |
| # Summary delta line | |
| if len(versions) == 2: | |
| _, r_old = versions[0] | |
| _, r_new = versions[1] | |
| if r_old["signal"] != "N/A" and r_new["signal"] != "N/A": | |
| delta_up = r_new["prob_up"] - r_old["prob_up"] | |
| delta_dn = r_new["prob_dn"] - r_old["prob_dn"] | |
| sig_change = arrow(r_old["signal"], r_new["signal"]) | |
| print( | |
| f"{'':>6} {' delta':<18} {sig_change:>5} " | |
| f"{delta_up:>+5.1f}% {delta_dn:>+5.1f}%" | |
| ) | |
| print() | |
| if __name__ == "__main__": | |
| main() | |