File size: 12,003 Bytes
b0d9ebf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8af7f2e
 
 
 
b0d9ebf
 
 
 
 
 
 
 
 
 
8af7f2e
 
 
 
 
 
 
 
 
 
b0d9ebf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dffb9e9
 
 
 
b0d9ebf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dffb9e9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b0d9ebf
dffb9e9
 
 
 
 
 
 
b0d9ebf
 
 
 
 
 
dffb9e9
 
 
 
b0d9ebf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
#!/usr/bin/env python3
"""ml_predictor/train.py β€” fit the quantile-regression price model.

For each timeframe TF ∈ {INTRADAY, 1D, 3D} we train 5 sklearn HistGradientBoosting
estimators (15 total artifacts), all native to scikit-learn (already in
requirements.txt β€” no lightgbm/xgboost/torch, so HF Spaces builds cleanly):

  up_q50   HistGradientBoostingRegressor(loss="quantile", quantile=0.50)  β†’ median best-up excursion
  up_q90   HistGradientBoostingRegressor(loss="quantile", quantile=0.90)  β†’ optimistic high
  down_q50 quantile=0.50 on the worst-down excursion                       β†’ median dip depth
  down_q10 quantile=0.10 on the worst-down excursion                       β†’ downside/stop floor
  direction HistGradientBoostingClassifier(class_weight="balanced")        β†’ BULLISH/BEARISH/NEUTRAL

Time-based split (no leakage): train on rows on/before `cutoff = max_date - HOLDOUT_MONTHS`,
with a TRADING-DAY embargo dropping rows whose 3-day label window crosses the cutoff.
Writes joblib artifacts + manifest.json to ml_predictor/models/.

Usage (from project root, after dataset.py):
    python ml_predictor/train.py
    python ml_predictor/train.py --csv ml_predictor/training_data.csv --holdout-months 5
"""
from __future__ import annotations

import argparse
import json
import os
import sys
import warnings
from datetime import timedelta

import numpy as np
import pandas as pd

warnings.filterwarnings("ignore")

_PROJ_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _PROJ_ROOT not in sys.path:
    sys.path.insert(0, _PROJ_ROOT)

import joblib  # noqa: E402
import sklearn  # noqa: E402
from sklearn.ensemble import HistGradientBoostingRegressor, HistGradientBoostingClassifier  # noqa: E402
from sklearn.calibration import CalibratedClassifierCV  # noqa: E402

from ml_predictor.features import FEATURE_COLUMNS, TIMEFRAMES  # noqa: E402

# MODEL_DIR / CSV overridable via env for safe A/B experiments (train a variant to a temp
# dir + point infer/backtest at it via ML_MODEL_DIR without clobbering the production model).
MODEL_DIR = os.environ.get("ML_MODEL_DIR", os.path.join(os.path.dirname(os.path.abspath(__file__)), "models"))
DEFAULT_CSV = os.environ.get("ML_CSV", os.path.join(os.path.dirname(os.path.abspath(__file__)), "training_data.csv"))

HOLDOUT_MONTHS = 5      # last N months held out as the test window
EMBARGO_DAYS = 5        # calendar-day gap between train-end and holdout (label window is ≀3 trading days)
MIN_TRAIN_ROWS = 500

# Label column per (target, TF).
_UP_LABEL = {"INTRADAY": "up_INTRADAY", "1D": "up_1D", "3D": "up_3D"}
_DN_LABEL = {"INTRADAY": "dn_INTRADAY", "1D": "dn_1D", "3D": "dn_3D"}
_DIR_LABEL = {"INTRADAY": "dir_INTRADAY", "1D": "dir_1D", "3D": "dir_3D"}

# Hyperparameters (env-overridable for tuning experiments).
_MAX_ITER = int(os.environ.get("ML_MAX_ITER", "300"))
_MAX_LEAVES = int(os.environ.get("ML_MAX_LEAVES", "31"))
_LR = float(os.environ.get("ML_LR", "0.06"))
_MIN_LEAF = int(os.environ.get("ML_MIN_LEAF", "60"))
_L2 = float(os.environ.get("ML_L2", "1.0"))
_REG_PARAMS = dict(max_iter=_MAX_ITER, max_leaf_nodes=_MAX_LEAVES, learning_rate=_LR,
                   min_samples_leaf=_MIN_LEAF, l2_regularization=_L2, random_state=42)
_CLF_PARAMS = dict(max_iter=_MAX_ITER, max_leaf_nodes=_MAX_LEAVES, learning_rate=_LR,
                   min_samples_leaf=_MIN_LEAF, l2_regularization=_L2, random_state=42,
                   class_weight="balanced")


def _fit_quantile(X, y, q):
    m = HistGradientBoostingRegressor(loss="quantile", quantile=q, **_REG_PARAMS)
    m.fit(X, y)
    return m


def train_all(csv_path: str = DEFAULT_CSV, out_dir: str = MODEL_DIR,
              holdout_months: int = HOLDOUT_MONTHS) -> dict:
    df = pd.read_csv(csv_path)
    df["date"] = pd.to_datetime(df["date"])
    max_date = df["date"].max()
    cutoff = max_date - pd.DateOffset(months=holdout_months)
    train_end = cutoff - timedelta(days=EMBARGO_DAYS)
    print(f"  Rows: {len(df):,} Β· dates {df['date'].min().date()} β†’ {max_date.date()}")
    print(f"  Train ≀ {train_end.date()} (embargo {EMBARGO_DAYS}d) Β· holdout {cutoff.date()} β†’ {max_date.date()}")

    train_df = df[df["date"] <= train_end]
    test_df = df[df["date"] >= cutoff]
    print(f"  Train rows: {len(train_df):,} Β· Holdout rows: {len(test_df):,}")
    if len(train_df) < MIN_TRAIN_ROWS:
        raise SystemExit(f"Not enough training rows ({len(train_df)} < {MIN_TRAIN_ROWS}). "
                         f"Build a denser CSV (dataset.py --step 1) or reduce --holdout-months.")

    os.makedirs(out_dir, exist_ok=True)
    X_tr_full = train_df[FEATURE_COLUMNS].to_numpy(dtype=float)

    manifest = {
        "sklearn_version": sklearn.__version__,
        "feature_columns": FEATURE_COLUMNS,
        "timeframes": TIMEFRAMES,
        "train_cutoff": str(train_end.date()),
        "holdout_start": str(cutoff.date()),
        "max_date": str(max_date.date()),
        "n_train_rows": int(len(train_df)),
        "quantiles": {"up": [0.10, 0.50, 0.90], "down": [0.10, 0.50, 0.90]},
        # Whether 1D/3D direction labels are EXCESS-of-Nifty (alpha). Mirrors dataset.py's
        # ML_EXCESS_LABELS default; consumed by infer.py to set each TF's dir_basis so the UI
        # can say "outperform/underperform vs Nifty" instead of a misleading absolute call.
        "excess_labels": os.environ.get("ML_EXCESS_LABELS", "1") != "0",
        "tf": {},
    }

    for tf in TIMEFRAMES:
        print(f"\n  ── {tf} ──")
        up_y = train_df[_UP_LABEL[tf]].to_numpy(dtype=float)
        dn_y = train_df[_DN_LABEL[tf]].to_numpy(dtype=float)
        dir_y = train_df[_DIR_LABEL[tf]].astype(str).to_numpy()

        # Drop rows with NaN labels (features may contain NaN β€” HistGBM handles them).
        up_ok = np.isfinite(up_y)
        dn_ok = np.isfinite(dn_y)

        # up-excursion quantiles: q10 = easily-reached floor, q50 = expected high, q90 = optimistic.
        up_q10 = _fit_quantile(X_tr_full[up_ok], up_y[up_ok], 0.10)
        up_q50 = _fit_quantile(X_tr_full[up_ok], up_y[up_ok], 0.50)
        up_q90 = _fit_quantile(X_tr_full[up_ok], up_y[up_ok], 0.90)
        # down-excursion quantiles: q10 = deep worst-case (stop floor), q50 = median dip (buy level),
        # q90 = shallow dip closest to 0 (easily-reached bearish range bound).
        down_q10 = _fit_quantile(X_tr_full[dn_ok], dn_y[dn_ok], 0.10)
        down_q50 = _fit_quantile(X_tr_full[dn_ok], dn_y[dn_ok], 0.50)
        down_q90 = _fit_quantile(X_tr_full[dn_ok], dn_y[dn_ok], 0.90)

        # Direction classifier with ISOTONIC PROBABILITY CALIBRATION (improvement "c").
        # The raw HistGBM proba was over-confident; CalibratedClassifierCV(cv=3) maps it to
        # empirical frequencies so max-proba is a trustworthy P(correct) β€” the basis for the
        # HIGH/MEDIUM/LOW label (previously derived from band-width, which anti-correlated
        # with returns per the diagnostics).
        clf = CalibratedClassifierCV(
            HistGradientBoostingClassifier(**_CLF_PARAMS), method="isotonic", cv=3)
        clf.fit(X_tr_full, dir_y)

        for name, mdl in [("up_q10", up_q10), ("up_q50", up_q50), ("up_q90", up_q90),
                          ("down_q10", down_q10), ("down_q50", down_q50), ("down_q90", down_q90),
                          ("direction", clf)]:
            joblib.dump(mdl, os.path.join(out_dir, f"{tf}_{name}.joblib"))

        # ── Per-TF metadata: band width + calibrated-confidence thresholds ──
        p_up10 = up_q10.predict(X_tr_full)
        p_up90 = np.maximum(up_q90.predict(X_tr_full), p_up10)  # monotonic
        median_band = float(np.median(p_up90 - p_up10))
        dir_classes = list(clf.classes_)
        # Confidence thresholds = ABSOLUTE, tied to the calibrated max-class probability's
        # reliability relative to the random baseline (1/n_classes). Because the classifier is
        # isotonic-calibrated, max_proba β‰ˆ P(direction correct), so these thresholds mean the
        # same thing across timeframes and stocks. This replaced the old per-TF TERTILES, which
        # forced exactly 1/3 of EVERY TF's predictions to LOW regardless of real reliability β€”
        # so an easy call could read "LOW" just for sitting in the bottom third. Now a TF whose
        # direction is genuinely more separable (INTRADAY) earns more HIGHs, and a noisy TF (3D)
        # earns more LOWs β€” honest and comparable.
        #
        # conf_hi is PER-TF and calibrated so a "HIGH" label means β‰₯~85% direction accuracy
        # (research/ml_confidence_sweep.py, OOS 25k rows): INTRADAY max-proba reaches 0.99 and
        # conf_hi=0.72 yields ~87% 3-class / ~85% directional-only accuracy at ~46% coverage
        # (0.70β†’86%/84% cov 51%, 0.75β†’89%/87% cov 40%). 1D/3D max-proba tops out at ~0.63 and
        # even the most-confident calls only hit ~62-66% β€” an 85% (or even 75%) HIGH is
        # UNREACHABLE (signal ceiling), so HIGH is disabled for them (conf_hi>1) and they cap
        # at MEDIUM. Every threshold is env-overridable per TF (ML_CONF_HI_INTRADAY, …) for A/B.
        maxp_tr = clf.predict_proba(X_tr_full).max(axis=1)
        baseline = 1.0 / max(1, len(dir_classes))
        _DEFAULT_CONF_HI = {"INTRADAY": 0.72, "1D": 1.01, "3D": 1.01}
        conf_hi = float(os.environ.get(f"ML_CONF_HI_{tf}",
                        os.environ.get("ML_CONF_HI_MARGIN_ABS",
                        _DEFAULT_CONF_HI.get(tf, baseline + 0.20))))
        conf_mid = baseline + float(os.environ.get(f"ML_CONF_MID_{tf}",
                        os.environ.get("ML_CONF_MID_MARGIN", "0.08")))

        manifest["tf"][tf] = {
            "median_train_width": median_band,
            "direction_classes": dir_classes,
            "conf_hi": round(conf_hi, 4),
            "conf_mid": round(conf_mid, 4),
            "conf_scheme": "absolute_vs_baseline_perTF",
            "high_disabled": conf_hi > 1.0,
            "train_maxproba_p33": round(float(np.quantile(maxp_tr, 0.33)), 4),
            "train_maxproba_p66": round(float(np.quantile(maxp_tr, 0.66)), 4),
        }

        # ── Quick holdout calibration (coverage) sanity print ──
        if len(test_df) > 20:
            Xte = test_df[FEATURE_COLUMNS].to_numpy(dtype=float)
            up_true = test_df[_UP_LABEL[tf]].to_numpy(dtype=float)
            dn_true = test_df[_DN_LABEL[tf]].to_numpy(dtype=float)
            pred_up90 = np.maximum(up_q90.predict(Xte), up_q50.predict(Xte))
            pred_dn10 = np.minimum(down_q10.predict(Xte), down_q50.predict(Xte))
            cov_up = float(np.mean(up_true <= pred_up90))     # target β‰ˆ 0.90
            cov_dn = float(np.mean(dn_true >= pred_dn10))     # target β‰ˆ 0.90 (10% below)
            dir_acc = float(np.mean(clf.predict(Xte) == test_df[_DIR_LABEL[tf]].astype(str).to_numpy()))
            print(f"    up_q90 coverage={cov_up:.0%} (~90%) Β· dn_q10 coverage={cov_dn:.0%} (~90%) "
                  f"Β· dir_acc={dir_acc:.0%} Β· median band={median_band:.2f}%")
            manifest["tf"][tf].update({
                "holdout_up_q90_coverage": round(cov_up, 3),
                "holdout_dn_q10_coverage": round(cov_dn, 3),
                "holdout_direction_accuracy": round(dir_acc, 3),
            })

    with open(os.path.join(out_dir, "manifest.json"), "w") as f:
        json.dump(manifest, f, indent=2)
    n_est = len(TIMEFRAMES) * 7  # 6 quantile regressors + 1 direction classifier per TF
    print(f"\n  βœ“ Wrote {n_est} estimators + manifest.json β†’ {out_dir}")
    print(f"  sklearn={sklearn.__version__} Β· train_cutoff={manifest['train_cutoff']}")
    return manifest


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--csv", default=DEFAULT_CSV)
    ap.add_argument("--out", default=MODEL_DIR)
    ap.add_argument("--holdout-months", type=int, default=HOLDOUT_MONTHS)
    args = ap.parse_args()
    train_all(args.csv, args.out, args.holdout_months)


if __name__ == "__main__":
    main()