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 | |
| """C2 validation: BASELINE_FEATURES vs BASELINE + PTT sentiment.""" | |
| 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 | |
| import json | |
| from pathlib import Path | |
| from sklearn.ensemble import RandomForestClassifier | |
| sys.path.insert(0, str(ROOT / "scripts")) | |
| from improvement_harness import ( | |
| BASELINE_FEATURES, fetch_df, build_triple_barrier_labels, compute_metrics, RF_PARAMS, | |
| ) | |
| from models.predictor import _build_features | |
| from data.ptt_sentiment import add_ptt_sentiment, scrape_ptt_sentiment | |
| STOCKS = ["2330", "0050", "2317", "2454", "2881"] | |
| PTT_FEATS = BASELINE_FEATURES + ["ptt_sentiment_1d", "ptt_sentiment_5d_ma"] | |
| MIN_TRAIN = 60 # reduced: PTT window ~180 days | |
| STEP = 10 | |
| LABEL_HORIZON = 5 | |
| def walk_forward_ptt(feat_df, label_arr, cols): | |
| avail = [c for c in cols if c in feat_df.columns] | |
| X_all = feat_df[avail].fillna(0).values | |
| n = len(feat_df) | |
| y_true_all, y_pred_all = [], [] | |
| cutoff = MIN_TRAIN | |
| while cutoff + STEP + LABEL_HORIZON <= n: | |
| y_tr = label_arr[:cutoff] | |
| valid = ~np.isnan(y_tr) | |
| y_v = y_tr[valid] | |
| if len(y_v) < 10 or len(np.unique(y_v)) < 2: | |
| cutoff += STEP; continue | |
| clf = RandomForestClassifier(**RF_PARAMS) | |
| clf.fit(X_all[:cutoff][valid], y_v.astype(int)) | |
| test_end = min(cutoff + STEP, n - LABEL_HORIZON) | |
| y_te = label_arr[cutoff:test_end] | |
| valid_te = ~np.isnan(y_te) | |
| if valid_te.sum() == 0: | |
| cutoff += STEP; continue | |
| y_pred = clf.predict(X_all[cutoff:test_end][valid_te]) | |
| y_true_all.extend(y_te[valid_te].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(): | |
| # Pre-warm PTT cache (scrape once, reuse) | |
| print("Scraping PTT sentiment data (180 days)...", flush=True) | |
| try: | |
| scrape_ptt_sentiment(days_back=180) | |
| except Exception as e: | |
| print(f" PTT scrape failed: {e} — proceeding with zeros") | |
| per_stock = {} | |
| agg = {"baseline": [], "ptt": []} | |
| hdr = f"{'Stock':>6} {'Set':>10} {'Acc%':>5} {'Dir%':>5} {'↑Prec%':>7} {'PTT rows':>8}" | |
| print(f"\n{hdr}\n{'-'*len(hdr)}") | |
| for stock_no in STOCKS: | |
| print(f" computing {stock_no}...", end="\r", flush=True) | |
| df = fetch_df(stock_no) | |
| if df is None or df.empty: | |
| print(f"{stock_no:>6} no data"); continue | |
| # Add PTT sentiment | |
| df = add_ptt_sentiment(df, stock_no) | |
| # Use only last 180 days (PTT window) | |
| if "date" in df.columns: | |
| cutoff_date = pd.Timestamp.now() - pd.Timedelta(days=180) | |
| df_recent = df[pd.to_datetime(df["date"]) >= cutoff_date].copy() | |
| else: | |
| df_recent = df.tail(130).copy() | |
| if len(df_recent) < MIN_TRAIN + STEP + LABEL_HORIZON: | |
| print(f"{stock_no:>6} insufficient recent data ({len(df_recent)} rows)") | |
| continue | |
| # Count non-zero PTT rows | |
| ptt_rows = int((df_recent.get("ptt_sentiment_1d", pd.Series([0])) != 0).sum()) | |
| feat = _build_features(df_recent) | |
| # Copy PTT cols into feat (not built by _build_features) | |
| for col in ["ptt_sentiment_1d", "ptt_sentiment_5d_ma"]: | |
| if col in df_recent.columns: | |
| feat[col] = df_recent[col].values[:len(feat)] | |
| close = (df_recent.set_index("date")["close"] if "date" in df_recent.columns | |
| else df_recent["close"]).values | |
| labels = build_triple_barrier_labels(close) | |
| per_stock[stock_no] = {} | |
| for name, cols in [("baseline", BASELINE_FEATURES), ("ptt", PTT_FEATS)]: | |
| r = walk_forward_ptt(feat, labels, cols) | |
| per_stock[stock_no][name] = r | |
| if r: | |
| agg[name].append(r) | |
| prefix = f"{stock_no:>6}" if name == "baseline" else f"{'':>6}" | |
| print(f"{prefix} {name:>10} {r['accuracy']:>5.1f} {r['dir_accuracy']:>5.1f} " | |
| f"{r['up_precision']:>7.1f} {ptt_rows if name=='ptt' else '':>8}") | |
| if per_stock[stock_no].get("baseline") and per_stock[stock_no].get("ptt"): | |
| rb, rp = per_stock[stock_no]["baseline"], per_stock[stock_no]["ptt"] | |
| print(f"{'':>6} {'Δ':>10} {'':>5} {rp['dir_accuracy']-rb['dir_accuracy']:>+5.1f} " | |
| f"{rp['up_precision']-rb['up_precision']:>+7.1f}") | |
| print() | |
| def mf(rows, f): | |
| vals = [r[f] for r in rows if isinstance(r.get(f),(int,float)) and not np.isnan(r.get(f,float("nan")))] | |
| return round(sum(vals)/len(vals),1) if vals else float("nan") | |
| print("=== AGGREGATE ===") | |
| agg_summary = {} | |
| for name in ("baseline","ptt"): | |
| s = {k: mf(agg[name], k) for k in ("accuracy","dir_accuracy","up_precision","dn_precision")} | |
| agg_summary[name] = s | |
| print(f" {name:>10}: acc={s['accuracy']}% dir={s['dir_accuracy']}% ↑prec={s['up_precision']}%") | |
| b, p = agg_summary.get("baseline",{}), agg_summary.get("ptt",{}) | |
| no_regress = (p.get("dir_accuracy",0) >= b.get("dir_accuracy",0) - 0.5 and | |
| p.get("up_precision",0) >= b.get("up_precision",0) - 0.5) | |
| improvement = (p.get("dir_accuracy",0) - b.get("dir_accuracy",0) >= 1.0 or | |
| p.get("up_precision",0) - b.get("up_precision",0) >= 1.0) | |
| passed = no_regress and improvement | |
| print(f"\n Pass: {'YES' if passed else 'NO'} (no_regress={no_regress}, improvement={improvement})") | |
| Path("docs").mkdir(exist_ok=True) | |
| with open("docs/c2_ptt_result.json","w") as f: | |
| json.dump({"results":per_stock,"aggregate":agg_summary, | |
| "passed":passed,"pass_criterion":"no_regress AND ≥1.0pp on shorter 180d window"}, f, indent=2) | |
| if __name__ == "__main__": | |
| main() | |