File size: 14,084 Bytes
3be03dd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
"""
ML training pipeline: feature selection, cross-validation, model building,
and model serialisation for both stock and ETF pipelines.
"""
import os
import warnings
import numpy as np
import pandas as pd
import yfinance as yf
import joblib
import json
from datetime import datetime

from sklearn.ensemble          import (GradientBoostingClassifier,
                                       RandomForestClassifier,
                                       StackingClassifier)
from sklearn.linear_model      import LogisticRegression
from sklearn.preprocessing     import StandardScaler
from sklearn.model_selection   import TimeSeriesSplit
from sklearn.calibration       import CalibratedClassifierCV
from sklearn.feature_selection import SelectFromModel
from sklearn.metrics           import classification_report, balanced_accuracy_score

from tools.tool_ml_features import (
    DATA_DIR,
    ETF_MODEL_PATH, ETF_SCALER_PATH, ETF_SELECTOR_PATH, ETF_META_PATH,
    STOCK_MODEL_PATH, STOCK_SCALER_PATH, STOCK_SELECTOR_PATH, STOCK_META_PATH,
    _build_features, _build_labels,
)


# ── Feature selection ─────────────────────────────────────────────────────────

def _select_features(X: np.ndarray, y: np.ndarray,
                     feature_names: list) -> tuple:
    """
    Use a quick Random Forest to rank features by importance.
    Drop the bottom third β€” they add noise, not signal.

    This is called ONCE during training and saved to disk.
    At prediction time we just apply the saved selector.

    Why this helps:
      Financial data is noisy. With 17 features and only 1250 rows,
      weak features can overfit. Selecting the top 10-12 improves
      out-of-sample performance consistently.
    """
    selector_model = RandomForestClassifier(
        n_estimators=100, max_depth=4, random_state=42, n_jobs=-1
    )
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        selector_model.fit(X, y)

    selector = SelectFromModel(
        selector_model,
        threshold="0.5*mean",    # keep features above half average importance
        prefit=True,
    )

    selected_mask  = selector.get_support()
    selected_names = [n for n, s in zip(feature_names, selected_mask) if s]
    dropped_names  = [n for n, s in zip(feature_names, selected_mask) if not s]

    print(f"  Features kept:    {selected_names}")
    print(f"  Features dropped: {dropped_names}")

    return selector, selector.transform(X)


# ── Walk-forward validation ───────────────────────────────────────────────────

def _walk_forward_cv(X: np.ndarray, y: np.ndarray,
                     model, n_splits: int = 5,
                     gap: int = 10) -> list[float]:
    """
    Walk-forward cross-validation with a GAP between train and test.

    The gap is critical. Without it:
      Train ends on day 240, test starts on day 241.
      But our labels look 10 days forward β€” so day 240's label uses
      data from day 250, which overlaps with the test window.
      That's leakage. The gap prevents it.

    With gap=10:
      Train ends on day 240, test starts on day 251.
      Clean separation. Honest accuracy.

    Returns balanced accuracy per fold (not raw accuracy β€” balanced
    accuracy accounts for class imbalance so HOLD-heavy datasets
    don't inflate the score).
    """
    tscv   = TimeSeriesSplit(n_splits=n_splits, gap=gap)
    scores = []

    for fold, (tr_idx, te_idx) in enumerate(tscv.split(X)):
        X_tr, X_te = X[tr_idx], X[te_idx]
        y_tr, y_te = y[tr_idx], y[te_idx]

        # Need at least 2 classes in train to fit
        if len(np.unique(y_tr)) < 2:
            continue

        with warnings.catch_warnings():
            warnings.simplefilter("ignore")
            model.fit(X_tr, y_tr)

        preds = model.predict(X_te)
        score = balanced_accuracy_score(y_te, preds)
        scores.append(score)
        print(f"  Fold {fold+1}: balanced accuracy = {score:.3f}")

    return scores


# ── Stacked model builder ─────────────────────────────────────────────────────

def _build_stacked_model(cv: int = 3):
    """
    Stack 3 diverse base models under a Logistic Regression meta-learner.

    Why these three?
      GradientBoosting β€” sequential tree boosting, best on tabular data
      RandomForest     β€” parallel bagging, decorrelated from GBM
      LogisticRegression β€” linear model, provides a different "view"

    Diversity matters. If all base models are GBM variants they'll
    make the same mistakes. GBM + RF + LR fail on different examples,
    so the meta-learner can learn from their disagreements.

    The meta-learner (also LogisticRegression) takes the probability
    outputs of all three and learns how to weight them optimally.
    cv=3 in StackingClassifier means it uses 3-fold CV internally
    to generate the meta-features without leakage.
    """
    base_estimators = [
        ("gbm", GradientBoostingClassifier(
            n_estimators=200,
            max_depth=3,           # shallow = resistant to overfit
            learning_rate=0.05,
            subsample=0.8,
            min_samples_leaf=20,
            random_state=42,
        )),
        ("rf", RandomForestClassifier(
            n_estimators=200,
            max_depth=5,
            min_samples_leaf=15,
            class_weight="balanced",
            random_state=42,
            n_jobs=-1,
        )),
        ("lr", LogisticRegression(
            C=0.1,                   # strong regularisation for small dataset
            class_weight="balanced",  # prevents collapsing to HOLD
            max_iter=1000,
            random_state=42,
        )),
    ]

    meta_learner = LogisticRegression(C=1.0, class_weight="balanced", max_iter=500, random_state=42)

    stacked = StackingClassifier(
        estimators      = base_estimators,
        final_estimator = meta_learner,
        cv              = cv,
        stack_method    = "predict_proba",
        n_jobs          = -1,
        passthrough     = False,
    )

    # Wrap in probability calibration
    # Isotonic regression corrects overconfident probabilities.
    calibrated = CalibratedClassifierCV(
        stacked,
        method = "isotonic",
        cv     = cv,
    )

    return calibrated


# ── Training ──────────────────────────────────────────────────────────────────

def train_model(training_ticker: str = "SPY",
                period: str           = "5y",
                forward_days: int     = 10,
                threshold: float      = 0.03,
                extra_tickers: list   = None,
                model_path: str       = None,
                scaler_path: str      = None,
                selector_path: str    = None,
                meta_path: str        = None) -> dict:
    """
    Full training pipeline with feature selection, walk-forward CV,
    stacked model, and calibration.
    """
    _model_path    = model_path    or STOCK_MODEL_PATH
    _scaler_path   = scaler_path   or STOCK_SCALER_PATH
    _selector_path = selector_path or STOCK_SELECTOR_PATH
    _meta_path     = meta_path     or STOCK_META_PATH

    if extra_tickers is None:
        extra_tickers = [
            "NVDA", "AAPL", "MSFT", "TSLA", "AMZN", "META", "GOOGL", "AMD",
            "QQQ", "VOO", "VTI",
            "SMH", "XLK", "XLF", "XLE", "ITA", "GLD",
            "VWRP.L", "WENS.L", "SGLN.L", "FTAL.L", "SEMI.L",
        ]

    os.makedirs(DATA_DIR, exist_ok=True)

    # ── Fetch VIX once for market-regime features ─────────────────────────────
    print("\nFetching VIX (market regime)...")
    try:
        vix_hist = yf.Ticker("^VIX").history(period=period)
        vix = vix_hist["Close"].copy() if not vix_hist.empty else None
        if vix is not None:
            vix.index = pd.to_datetime(vix.index).tz_localize(None)
            full_idx = pd.date_range(vix.index.min(), vix.index.max(), freq="D")
            vix = vix.reindex(full_idx).ffill()
    except Exception:
        vix = None

    # ── Pool multiple tickers for more training data ──────────────────────────
    all_features, all_labels = [], []
    for tkr in [training_ticker] + extra_tickers:
        print(f"\nFetching {tkr} ({period})...")
        hist = yf.Ticker(tkr).history(period=period)
        if hist.empty:
            print(f"  skipped (no data)")
            continue
        hist.index = pd.to_datetime(hist.index).tz_localize(None)

        feat = _build_features(hist, vix=vix)
        lbl  = _build_labels(hist, forward_days, threshold).reindex(feat.index).dropna()
        feat = feat.reindex(lbl.index)
        if len(lbl) < 50:
            print(f"  skipped (only {len(lbl)} rows)")
            continue

        b = (lbl == 1).sum(); s = (lbl == -1).sum(); h = (lbl == 0).sum()
        print(f"  {len(lbl)} rows | BUY:{b} HOLD:{h} SELL:{s}")
        all_features.append(feat)
        all_labels.append(lbl)

    features      = pd.concat(all_features)
    labels_series = pd.concat(all_labels)

    feature_names = features.columns.tolist()
    X_raw = features.values
    y     = labels_series.values

    n_buy  = (y ==  1).sum()
    n_sell = (y == -1).sum()
    n_hold = (y ==  0).sum()
    print(f"\nCombined dataset: {len(y)} rows | BUY:{n_buy} HOLD:{n_hold} SELL:{n_sell}")

    # Scale first (needed for LR in the stack)
    scaler   = StandardScaler()
    X_scaled = scaler.fit_transform(X_raw)

    # Feature selection
    print("\nSelecting features...")
    selector, X_selected = _select_features(X_scaled, y, feature_names)

    # Walk-forward CV on a single GBM (fast proxy for the stack)
    print("\nWalk-forward cross-validation...")
    proxy = GradientBoostingClassifier(
        n_estimators=100, max_depth=3, learning_rate=0.05,
        subsample=0.8, random_state=42
    )
    cv_scores = _walk_forward_cv(X_selected, y, proxy, n_splits=5, gap=forward_days)
    mean_cv   = float(np.mean(cv_scores))
    print(f"\nCV balanced accuracy: {mean_cv:.3f} ({mean_cv*100:.1f}%)")

    min_class = int(min(n_buy, n_sell, n_hold))
    if min_class >= 3:
        safe_cv = min(3, min_class)
        print(f"\nTraining stacked model (GBM + RF + LR, cv={safe_cv})...")
        model = _build_stacked_model(cv=safe_cv)
    else:
        print(f"\nDataset too small for stacking (min class={min_class}) β€” using simple GBM...")
        model = GradientBoostingClassifier(
            n_estimators=100, max_depth=3, learning_rate=0.05,
            subsample=0.8, random_state=42,
        )
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        model.fit(X_selected, y)

    # Per-class performance on full training set
    preds  = model.predict(X_selected)
    report = classification_report(y, preds, target_names=["SELL","HOLD","BUY"],
                                   output_dict=True, zero_division=0)

    # Save everything
    joblib.dump(model,    _model_path)
    joblib.dump(scaler,   _scaler_path)
    joblib.dump(selector, _selector_path)

    meta = {
        "trained_on":        training_ticker,
        "trained_at":        datetime.now().isoformat(),
        "n_samples":         len(y),
        "n_features_raw":    len(feature_names),
        "n_features_kept":   int(selector.get_support().sum()),
        "forward_days":      forward_days,
        "threshold":         threshold,
        "cv_balanced_acc":   round(mean_cv, 4),
        "cv_fold_scores":    [round(s, 4) for s in cv_scores],
        "class_distribution":{"BUY": int(n_buy), "HOLD": int(n_hold), "SELL": int(n_sell)},
        "per_class": {
            "BUY":  {"precision": round(report["BUY"]["precision"],  3),
                     "recall":    round(report["BUY"]["recall"],     3)},
            "SELL": {"precision": round(report["SELL"]["precision"], 3),
                     "recall":    round(report["SELL"]["recall"],    3)},
            "HOLD": {"precision": round(report["HOLD"]["precision"], 3),
                     "recall":    round(report["HOLD"]["recall"],    3)},
        }
    }
    with open(_meta_path, "w") as f:
        json.dump(meta, f, indent=2)

    print(f"\nDone. Model saved.")
    print(f"BUY  β€” precision: {meta['per_class']['BUY']['precision']:.0%}  recall: {meta['per_class']['BUY']['recall']:.0%}")
    print(f"SELL β€” precision: {meta['per_class']['SELL']['precision']:.0%}  recall: {meta['per_class']['SELL']['recall']:.0%}")
    print(f"HOLD β€” precision: {meta['per_class']['HOLD']['precision']:.0%}  recall: {meta['per_class']['HOLD']['recall']:.0%}")

    return meta


def train_etf_model() -> dict:
    """
    Dedicated pipeline for ETF/ETC instruments.

    Why different from stocks?
    - ETFs trend more smoothly (less idiosyncratic noise)
    - Smaller moves are still tradeable (2% threshold vs 3%)
    - Longer horizon (20 days) suits buy-and-hold ETF owners
    - Training data: only broad-market and sector ETFs
    """
    etf_tickers = [
        "SPY", "QQQ", "VOO", "VTI", "IVV",
        "XLK", "XLE", "XLF", "XLV", "XLI", "XLU", "XLP",
        "SMH", "ITA", "GLD", "SLV",
        "EFA", "EEM", "VEA",
        "VWRP.L", "FTAL.L", "SEMI.L", "WENS.L", "DFND.L", "SGLN.L",
    ]
    return train_model(
        training_ticker = "SPY",
        period          = "5y",
        forward_days    = 20,
        threshold       = 0.02,
        extra_tickers   = etf_tickers,
        model_path      = ETF_MODEL_PATH,
        scaler_path     = ETF_SCALER_PATH,
        selector_path   = ETF_SELECTOR_PATH,
        meta_path       = ETF_META_PATH,
    )