File size: 12,178 Bytes
8dd75fe
 
cec37e3
8dd75fe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cec37e3
 
8dd75fe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cec37e3
 
 
 
 
8dd75fe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
#!/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()