File size: 6,594 Bytes
9c8ca57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
#!/usr/bin/env python3
"""C8: Optuna per-stock RF hyperparameter tuning, validated with walk-forward backtest."""
import sys
import json
import warnings
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

warnings.filterwarnings("ignore")

import numpy as np
import optuna
optuna.logging.set_verbosity(optuna.logging.WARNING)

from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import TimeSeriesSplit

from scripts.improvement_harness import (
    BASELINE_FEATURES, DEFAULT_STOCKS,
    fetch_df, build_triple_barrier_labels, compute_metrics,
    walk_forward, RF_PARAMS, MIN_TRAIN, STEP, LABEL_HORIZON,
)
from models.predictor import _build_features

OPTUNA_ROWS = 400
N_TRIALS    = 30
N_SPLITS    = 3


def tune_stock(feat: np.ndarray, labels: np.ndarray) -> dict:
    n_tune = min(OPTUNA_ROWS, len(feat) // 2)
    X_t = feat[:n_tune]
    y_t = labels[:n_tune]

    tscv = TimeSeriesSplit(n_splits=N_SPLITS)

    def objective(trial):
        params = dict(
            max_depth       = trial.suggest_int("max_depth", 4, 12),
            min_samples_leaf= trial.suggest_int("min_samples_leaf", 5, 30),
            n_estimators    = trial.suggest_int("n_estimators", 100, 400),
            max_features    = trial.suggest_categorical("max_features", ["sqrt", "log2", 0.5]),
            class_weight    = "balanced",
            random_state    = 42,
            n_jobs          = -1,
        )
        scores = []
        for tr_idx, te_idx in tscv.split(X_t):
            y_tr = y_t[tr_idx]
            valid_tr = ~np.isnan(y_tr)
            y_v = y_tr[valid_tr]
            if len(y_v) < 10 or len(np.unique(y_v)) < 2:
                continue
            clf = RandomForestClassifier(**params)
            clf.fit(X_t[tr_idx][valid_tr], y_v.astype(int))

            y_te = y_t[te_idx]
            valid_te = ~np.isnan(y_te)
            if valid_te.sum() == 0:
                continue
            y_pred = clf.predict(X_t[te_idx][valid_te])
            dir_mask = y_pred != 0
            if dir_mask.sum() == 0:
                scores.append(0.0)
                continue
            y_true_fold = y_te[valid_te]
            da = (y_true_fold[dir_mask] == y_pred[dir_mask]).mean()
            scores.append(float(da))
        return float(np.mean(scores)) if scores else 0.0

    study = optuna.create_study(direction="maximize")
    study.optimize(objective, n_trials=N_TRIALS, show_progress_bar=False)
    return study.best_params


def main():
    all_best_params = {}
    results = {}

    for stock in DEFAULT_STOCKS:
        print(f"\n=== {stock} ===")
        df = fetch_df(stock)
        if df is None or df.empty:
            print(f"  fetch_df failed, skipping")
            continue

        feat_df = _build_features(df)
        close = df["close"].values
        labels = build_triple_barrier_labels(close)

        avail = [c for c in BASELINE_FEATURES if c in feat_df.columns]
        X_all = feat_df[avail].fillna(0).values

        print(f"  Optuna tuning ({N_TRIALS} trials)...")
        best_params = tune_stock(X_all, labels)
        all_best_params[stock] = best_params
        print(f"  best_params: {best_params}")

        r_base = walk_forward(feat_df, labels, BASELINE_FEATURES, rf_params=None)
        tuned_rf = {**RF_PARAMS, **best_params, "n_jobs": -1, "class_weight": "balanced", "random_state": 42}
        r_tuned  = walk_forward(feat_df, labels, BASELINE_FEATURES, rf_params=tuned_rf)

        results[stock] = {"baseline": r_base, "tuned": r_tuned}
        print(f"  baseline  dir%={r_base.get('dir_accuracy','N/A')}  ↑prec%={r_base.get('up_precision','N/A')}")
        print(f"  tuned     dir%={r_tuned.get('dir_accuracy','N/A')}  ↑prec%={r_tuned.get('up_precision','N/A')}")

    # Print comparison table
    print("\n" + "="*80)
    print(f"{'Stock':<8} {'base dir%':>10} {'tune dir%':>10} {'Δdir':>6} {'base ↑%':>9} {'tune ↑%':>9} {'Δ↑':>6}")
    print("-"*80)

    base_dirs, tune_dirs, base_ups, tune_ups = [], [], [], []
    for stock in DEFAULT_STOCKS:
        if stock not in results:
            continue
        rb = results[stock]["baseline"]
        rt = results[stock]["tuned"]
        bd = rb.get("dir_accuracy", float("nan"))
        td = rt.get("dir_accuracy", float("nan"))
        bu = rb.get("up_precision", float("nan"))
        tu = rt.get("up_precision", float("nan"))
        dd = round(td - bd, 1) if not (np.isnan(td) or np.isnan(bd)) else float("nan")
        du = round(tu - bu, 1) if not (np.isnan(tu) or np.isnan(bu)) else float("nan")
        print(f"{stock:<8} {bd:>10.1f} {td:>10.1f} {dd:>+6.1f} {bu:>9.1f} {tu:>9.1f} {du:>+6.1f}")
        if not np.isnan(bd): base_dirs.append(bd)
        if not np.isnan(td): tune_dirs.append(td)
        if not np.isnan(bu): base_ups.append(bu)
        if not np.isnan(tu): tune_ups.append(tu)

    agg_base = {
        "dir_accuracy": round(float(np.mean(base_dirs)), 1) if base_dirs else float("nan"),
        "up_precision": round(float(np.mean(base_ups)), 1) if base_ups else float("nan"),
    }
    agg_tuned = {
        "dir_accuracy": round(float(np.mean(tune_dirs)), 1) if tune_dirs else float("nan"),
        "up_precision": round(float(np.mean(tune_ups)), 1) if tune_ups else float("nan"),
    }

    print("-"*80)
    print(f"{'AGG':<8} {agg_base['dir_accuracy']:>10.1f} {agg_tuned['dir_accuracy']:>10.1f} "
          f"{round(agg_tuned['dir_accuracy']-agg_base['dir_accuracy'],1):>+6.1f} "
          f"{agg_base['up_precision']:>9.1f} {agg_tuned['up_precision']:>9.1f} "
          f"{round(agg_tuned['up_precision']-agg_base['up_precision'],1):>+6.1f}")
    print("="*80)

    passed = (
        agg_tuned["dir_accuracy"] >= 43.5 and
        agg_tuned["up_precision"]  >= 53.0
    )
    print(f"\nPass criterion: dir_accuracy >= 43.5 AND up_precision >= 53.0")
    print(f"Result: {'PASSED' if passed else 'FAILED'}")
    print(f"  Tuned aggregate: dir={agg_tuned['dir_accuracy']}, up_prec={agg_tuned['up_precision']}")

    output = {
        "best_params":    all_best_params,
        "results":        results,
        "aggregate":      {"baseline": agg_base, "tuned": agg_tuned},
        "passed":         passed,
        "pass_criterion": "dir_accuracy >= 43.5 AND up_precision >= 53.0",
    }
    out_path = ROOT / "docs" / "c8_optuna_result.json"
    out_path.write_text(json.dumps(output, indent=2))
    print(f"\nWrote {out_path}")


if __name__ == "__main__":
    main()