DockerSpace / scripts /backtest_c2.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
6.09 kB
#!/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()