File size: 5,484 Bytes
f786dce
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
#!/usr/bin/env python3
"""C28: News sentiment features (news_score_1d, news_score_5d_ma).

Adds daily news sentiment from the pre-built history cache
(data/news_sentiment_history.json) as ML training features.

Must run scripts/build_news_history.py first to populate the cache.

Features added over CURRENT_FEATURES:
  news_score_1d    — prior-day sentiment score [-1, 1] (non-leaking)
  news_score_5d_ma — 5-day rolling mean of sentiment

Hypothesis: News sentiment provides leading signal beyond price/volume —
positive news precedes institutional buying, negative news precedes selling.
"""
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))

import warnings; warnings.filterwarnings("ignore")
import json
import argparse
import numpy as np

from scripts.improvement_harness import (
    fetch_df, walk_forward, build_triple_barrier_labels,
    CURRENT_FEATURES, DEFAULT_STOCKS, EXTENDED_STOCKS,
    PASS_DIR_ACC, PASS_UP_PREC,
)
from models.predictor import _build_features
from data.news_history import add_news_sentiment, _load_cache

# Stock name map — needed for add_news_sentiment cache lookup
STOCK_NAMES = {
    "2330": "台積電", "0050": "元大台灣50", "2317": "鴻海",
    "2454": "聯發科", "2881": "富邦金",   "2303": "聯電",
    "2382": "廣達",   "2308": "台達電",   "2882": "國泰金",
    "2886": "兆豐金", "0056": "元大高股息", "2412": "中華電",
}

C28_FEATURES = ["news_score_1d", "news_score_5d_ma"]
NEW_FEATURES  = CURRENT_FEATURES + C28_FEATURES


def load_stock(stock_no: str, news_cache: dict):
    df = fetch_df(stock_no)
    if df is None or df.empty:
        return None, None

    df   = add_news_sentiment(df, stock_no, cache=news_cache)
    feat = _build_features(df)

    # Append news features (not in _build_features output)
    for col in C28_FEATURES:
        feat[col] = df.get(col, 0.0)

    close  = (df.set_index("date")["close"] if "date" in df.columns else df["close"]).values
    labels = build_triple_barrier_labels(close)
    return feat, labels


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--extended", action="store_true")
    args = parser.parse_args()

    stocks = EXTENDED_STOCKS if args.extended else DEFAULT_STOCKS
    tag    = "12-stock" if args.extended else "5-stock"
    suffix = "_12stock" if args.extended else ""

    print(f"\n=== C28: News sentiment features [{tag}] ===")

    news_cache = _load_cache()
    # Summarise cache coverage
    covered = {s for s in stocks if s in news_cache and len(news_cache[s]) > 4}
    print(f"  Cache: {len(news_cache)} stocks total, {len(covered)}/{len(stocks)} eval stocks covered")
    if not covered:
        print("\n  ERROR: news_sentiment_history.json is empty.")
        print("  Run:  venv/bin/python scripts/build_news_history.py --months 24")
        return 1

    print(f"  C28 features: {C28_FEATURES}\n")

    per_stock = {}
    base_results, c28_results = [], []

    for stock_no in stocks:
        print(f"  {stock_no}...", end=" ", flush=True)
        feat, labels = load_stock(stock_no, news_cache)
        if feat is None:
            print("no data")
            continue

        n_news = (feat.get("news_score_1d", 0) != 0).sum() if "news_score_1d" in feat.columns else 0
        m_base = walk_forward(feat, labels, CURRENT_FEATURES)
        m_c28  = walk_forward(feat, labels, NEW_FEATURES)

        per_stock[stock_no] = {"baseline": m_base, "c28": m_c28, "news_days": int(n_news)}
        base_results.append(m_base)
        c28_results.append(m_c28)

        b_dir = m_base.get("dir_accuracy", float("nan"))
        b_up  = m_base.get("up_precision", float("nan"))
        c_dir = m_c28.get("dir_accuracy",  float("nan"))
        c_up  = m_c28.get("up_precision",  float("nan"))
        print(f"news_days={n_news}  base dir={b_dir}% ↑prec={b_up}%  →  c28 dir={c_dir}% ↑prec={c_up}%")

    def _avg(results, key):
        vals = [m[key] for m in results if m and not np.isnan(m.get(key, float("nan")))]
        return round(float(np.mean(vals)), 1) if vals else float("nan")

    base_agg = {"dir_accuracy": _avg(base_results, "dir_accuracy"),
                "up_precision": _avg(base_results, "up_precision")}
    c28_agg  = {"dir_accuracy": _avg(c28_results,  "dir_accuracy"),
                "up_precision": _avg(c28_results,  "up_precision")}

    passed = c28_agg["dir_accuracy"] >= PASS_DIR_ACC and c28_agg["up_precision"] >= PASS_UP_PREC

    print(f"\n  Baseline avg: dir={base_agg['dir_accuracy']}%  ↑prec={base_agg['up_precision']}%")
    print(f"  C28 avg:      dir={c28_agg['dir_accuracy']}%   ↑prec={c28_agg['up_precision']}%")
    print(f"  Gate (dir≥{PASS_DIR_ACC}% AND ↑prec≥{PASS_UP_PREC}%): {'PASS ✓' if passed else 'FAIL ✗'}")

    result = {
        "experiment":   "C28",
        "description":  "News sentiment features from local LLM scoring",
        "new_features": C28_FEATURES,
        "stocks":       stocks,
        "aggregate":    {"baseline": base_agg, "c28": c28_agg},
        "passed":       passed,
        "pass_gate":    {"dir_accuracy": PASS_DIR_ACC, "up_precision": PASS_UP_PREC},
        "per_stock":    per_stock,
    }

    out = ROOT / f"docs/c28_result{suffix}.json"
    out.parent.mkdir(exist_ok=True)
    out.write_text(json.dumps(result, indent=2))
    print(f"\n  Saved: {out}")
    return 0 if passed else 1


if __name__ == "__main__":
    sys.exit(main())