Spaces:
Running
Running
| """ml_predictor/features.py β shared point-in-time feature builder. | |
| ONE source of truth for the ML model's feature vector, used identically by: | |
| β’ dataset.py (training-CSV construction over historical dates) | |
| β’ infer.py (live inference on the latest bar) | |
| so training features exactly equal production features (no train/serve skew). | |
| Every feature is computed point-in-time from data up to and including `date` | |
| (no lookahead), mirroring research/backtest.py::_compute_indicators and the | |
| 11-weight ML sub-feature math in predictor_core.get_ml_feature_score (L667-753). | |
| News sentiment is deliberately EXCLUDED β it is live-only and cannot be | |
| backfilled to historical dates (see CLAUDE.md). It is applied as a live | |
| inference-time confidence adjustment inside infer.py, not as a trained feature. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import numpy as np | |
| import pandas as pd | |
| # Lightweight indicator primitives (no heavy imports). | |
| from trial_run import rsi, atr, obv, adx_s, macd_h | |
| from ml_combiner import bollinger_position, ema_stack_score, shadow_flag | |
| TIMEFRAMES = ["INTRADAY", "1D", "3D"] | |
| # Experiment flag: when ML_STRATEGY_FEATURES=1, the best NSE-backtested S-signals are | |
| # appended as binary features (computed in dataset.py per ticker). Off by default. | |
| STRATEGY_FEATURE_COLS = ["sig_s8", "sig_ctrio", "sig_s4v2", "sig_s6", "sig_s16", "sig_s1"] | |
| _USE_STRATEGY_FEATS = os.environ.get("ML_STRATEGY_FEATURES") == "1" | |
| # Extra indicator + Monte-Carlo features. PRODUCTION DEFAULT (validated on the 5-month OOS | |
| # holdout, research/ml_backtest.py): vs the 37-feature baseline they lift INTRADAY direction | |
| # 71%β82% and price-target hit 64%β84%, cut the estimated-high MAE 1.54β1.07, and raise 1D/3D | |
| # per-trade P&L (+0.86β+1.02, +1.29β+1.51, PF 1.74β1.93 / 1.79β1.96) with NO regression. The | |
| # channel positions, extra oscillators, realized vol, and MC hit-probability/excursion estimates | |
| # are computed identically in compute_features (live) and research/augment_features.py (training | |
| # CSV) via the shared _extra_feature_series / _mc_features helpers β no train/serve skew. | |
| # Set ML_EXTRA_FEATURES=0 to revert to the 37-feature model (must retrain to match). | |
| EXTRA_FEATURE_COLS = [ | |
| "bb_bandwidth", "bb_squeeze", # Bollinger volatility regime / squeeze | |
| "keltner_pct", "donchian_pct", # channel positions | |
| "stoch_k", "cci20", "mfi14", "williams_r", # extra oscillators | |
| "hist_vol_20", # realized volatility | |
| "mc_up_prob_3d", "mc_exp_maxup_3d", "mc_exp_maxdn_3d", # Monte-Carlo path features | |
| ] | |
| _USE_EXTRA_FEATS = os.environ.get("ML_EXTRA_FEATURES", "1") != "0" | |
| # Ordered feature list β the manifest stores this so infer.py builds columns in | |
| # the exact order the models were trained on. Keep additions APPEND-ONLY. | |
| FEATURE_COLUMNS: list[str] = [ | |
| # Momentum / oscillators | |
| "rsi14", "rsi5", "rsi2", "macd_hist", "adx14", | |
| # Trend / EMA | |
| "price_vs_ema20", "price_vs_ema50", "price_vs_ema200", "ema_stack", "supertrend", | |
| # Bollinger | |
| "bb_pct", "bb_lower_dist", "bb_upper_dist", | |
| # Relative strength vs Nifty | |
| "rs_3m", | |
| # Volume | |
| "vol_ratio", "obv_z", "vol_trend_5d", | |
| # Volatility / range | |
| "atr_pct", "shadow", | |
| # Returns / streak | |
| "return_10d", "return_20d", "return_90d", "dist_52w_high", "consec_days", | |
| # Domain trigger flags | |
| "trigger_T1", "trigger_T2", "trigger_T3", "trigger_T4", "trigger_T5", | |
| "trigger_T6", "trigger_T7", "trigger_B1", "trigger_B2", "trigger_B3", | |
| # Macro regime | |
| "vix_level", "nifty_ok", "vix_decl", | |
| ] | |
| if _USE_STRATEGY_FEATS: | |
| FEATURE_COLUMNS = FEATURE_COLUMNS + STRATEGY_FEATURE_COLS | |
| if _USE_EXTRA_FEATS: | |
| FEATURE_COLUMNS = FEATURE_COLUMNS + EXTRA_FEATURE_COLS | |
| _NAN = float("nan") | |
| def _supertrend_dir(c: pd.Series, h: pd.Series, l: pd.Series, period: int = 10, mult: float = 3.0) -> int: | |
| """Supertrend(10,3) final direction: +1 bullish, -1 bearish. Mirrors | |
| predictor_core._st_dir_last / backtest._compute_indicators supertrend block.""" | |
| try: | |
| if len(c) < period + 1: | |
| return 0 | |
| tr = pd.concat([h - l, (h - c.shift(1)).abs(), (l - c.shift(1)).abs()], axis=1).max(axis=1) | |
| _atr = tr.rolling(period).mean() | |
| hl2 = (h + l) / 2 | |
| up_raw = (hl2 + mult * _atr).values | |
| dn_raw = (hl2 - mult * _atr).values | |
| cv = c.values | |
| n = len(cv) | |
| upper, lower = up_raw.copy(), dn_raw.copy() | |
| dirn = 1 | |
| for i in range(1, n): | |
| if not (pd.isna(up_raw[i]) or pd.isna(dn_raw[i])): | |
| upper[i] = min(up_raw[i], upper[i - 1]) if cv[i - 1] <= upper[i - 1] else up_raw[i] | |
| lower[i] = max(dn_raw[i], lower[i - 1]) if cv[i - 1] >= lower[i - 1] else dn_raw[i] | |
| if cv[i] > upper[i - 1]: | |
| dirn = 1 | |
| elif cv[i] < lower[i - 1]: | |
| dirn = -1 | |
| return dirn | |
| except Exception: | |
| return 0 | |
| def _consec_days(c: pd.Series) -> float: | |
| """Signed consecutive-day streak: +n up, -n down, 0 otherwise.""" | |
| try: | |
| if len(c) < 6: | |
| return 0.0 | |
| diffs = c.iloc[-6:].diff().dropna() | |
| up = dn = 0 | |
| for d in reversed(diffs.values): | |
| if d > 0 and dn == 0: | |
| up += 1 | |
| elif d < 0 and up == 0: | |
| dn += 1 | |
| else: | |
| break | |
| if up >= 1: | |
| return float(up) | |
| if dn >= 1: | |
| return float(-dn) | |
| return 0.0 | |
| except Exception: | |
| return 0.0 | |
| def _trigger_flags(rsi14, bb_pct, r10, r20, macd, above_ema50, above_ema200, consec) -> dict: | |
| """Replicates research/backtest.py::_compute_trigger_flags (1D canonical triggers).""" | |
| consec_up = int(consec) if consec and consec > 0 else 0 | |
| crash_exhausted = bool(r10 < -6.0 or r20 < -8.0) | |
| overbought_extreme = bool(rsi14 > 70) | |
| T1 = bool(above_ema50 and macd > 0 and not overbought_extreme) | |
| T2 = bool(above_ema50 and r10 > 3.0 and bb_pct < 85.0) | |
| T3 = bool(above_ema50 and consec_up >= 3 and r20 > 0.0) | |
| T4 = bool(rsi14 < 50 and bb_pct < 45.0 and r10 > -2.0 and not crash_exhausted) | |
| T5 = bool(r10 > 7.0 and bb_pct < 80.0) | |
| T6 = bool(rsi14 < 44 and bb_pct < 35.0 and not crash_exhausted) | |
| T7 = bool(above_ema50 and 1.0 <= r20 <= 5.0 and rsi14 < 62.0) | |
| B1 = False # removed from production; kept for schema stability | |
| B2 = bool((not above_ema50) and macd < 0 and r10 < -4.0 and rsi14 > 42 and bb_pct > 40.0) | |
| B3 = bool(crash_exhausted and macd < 0) | |
| return { | |
| "trigger_T1": int(T1), "trigger_T2": int(T2), "trigger_T3": int(T3), | |
| "trigger_T4": int(T4), "trigger_T5": int(T5), "trigger_T6": int(T6), | |
| "trigger_T7": int(T7), "trigger_B1": int(B1), "trigger_B2": int(B2), | |
| "trigger_B3": int(B3), | |
| } | |
| def _last(series: pd.Series, default=_NAN) -> float: | |
| try: | |
| v = float(series.iloc[-1]) | |
| return v if np.isfinite(v) else default | |
| except Exception: | |
| return default | |
| # ββ Extra indicator features (ML_EXTRA_FEATURES=1) β vectorized, point-in-time ββ | |
| # These return full backward-looking Series so the SAME math serves both live inference | |
| # (compute_features β last value at `date`) and the training-CSV augmenter | |
| # (research/augment_features.py β value indexed at each sampled date). All windows are | |
| # trailing (rolling/ewm), so the last value of the full series == the value at that date | |
| # with no lookahead. Keep in ONE place to avoid train/serve skew. | |
| def _extra_feature_series(c: pd.Series, h: pd.Series, l: pd.Series, v: pd.Series) -> dict: | |
| out: dict[str, pd.Series] = {} | |
| tp = (h + l + c) / 3.0 # typical price | |
| # Bollinger bandwidth + squeeze regime. | |
| sma20 = c.rolling(20).mean() | |
| std20 = c.rolling(20).std() | |
| bb_up = sma20 + 2 * std20 | |
| bb_lo = sma20 - 2 * std20 | |
| bandwidth = (bb_up - bb_lo) / sma20.replace(0, np.nan) * 100.0 | |
| out["bb_bandwidth"] = bandwidth | |
| # squeeze = bandwidth in the bottom 20% of its trailing 126-bar range (breakout setup). | |
| bw_q20 = bandwidth.rolling(126, min_periods=30).quantile(0.20) | |
| out["bb_squeeze"] = (bandwidth <= bw_q20).astype(float) | |
| # Keltner channel position (EMA20 Β± 2Β·ATR20). | |
| _atr20 = atr(h, l, c, 20) | |
| ema20 = c.ewm(span=20).mean() | |
| kc_up = ema20 + 2 * _atr20 | |
| kc_lo = ema20 - 2 * _atr20 | |
| out["keltner_pct"] = (c - kc_lo) / (kc_up - kc_lo).replace(0, np.nan) * 100.0 | |
| # Donchian channel position (20). | |
| dc_hi = h.rolling(20).max() | |
| dc_lo = l.rolling(20).min() | |
| out["donchian_pct"] = (c - dc_lo) / (dc_hi - dc_lo).replace(0, np.nan) * 100.0 | |
| # Stochastic %K (14). | |
| ll14 = l.rolling(14).min() | |
| hh14 = h.rolling(14).max() | |
| out["stoch_k"] = (c - ll14) / (hh14 - ll14).replace(0, np.nan) * 100.0 | |
| # CCI (20). | |
| tp_sma = tp.rolling(20).mean() | |
| tp_mad = tp.rolling(20).apply(lambda x: np.mean(np.abs(x - x.mean())), raw=True) | |
| out["cci20"] = (tp - tp_sma) / (0.015 * tp_mad.replace(0, np.nan)) | |
| # Money Flow Index (14). | |
| rmf = tp * v | |
| pos_mf = rmf.where(tp.diff() > 0, 0.0).rolling(14).sum() | |
| neg_mf = rmf.where(tp.diff() < 0, 0.0).rolling(14).sum() | |
| mfr = pos_mf / neg_mf.replace(0, np.nan) | |
| out["mfi14"] = 100.0 - 100.0 / (1.0 + mfr) | |
| # Williams %R (14) β mapped to 0..100 (0 = at 14-bar low, 100 = at 14-bar high). | |
| out["williams_r"] = (c - ll14) / (hh14 - ll14).replace(0, np.nan) * 100.0 | |
| # Realized (historical) volatility, 20-bar, annualized %. | |
| rets = c.pct_change() | |
| out["hist_vol_20"] = rets.rolling(20).std() * np.sqrt(252) * 100.0 | |
| return out | |
| def _mc_features(rets: np.ndarray, horizon: int = 3, n_sims: int = 400) -> tuple: | |
| """Monte-Carlo bootstrap of forward price paths from the trailing daily returns. | |
| Draws `n_sims` paths of length `horizon` by sampling WITH replacement from the recent | |
| return distribution (non-parametric, captures fat tails / skew unlike a Gaussian), then | |
| measures the forward-excursion distribution the way the labels do: | |
| β’ mc_up_prob = P(best up-excursion over the path > 0) β chance the up-target is reachable | |
| β’ mc_exp_maxup = mean best up-excursion (%) β expected reachable high | |
| β’ mc_exp_maxdn = mean worst down-excursion (%) β expected drawdown | |
| Deterministic (fixed seed) so live inference and the training augmenter agree exactly. | |
| """ | |
| rets = rets[np.isfinite(rets)] | |
| if rets.size < 10: | |
| return (_NAN, _NAN, _NAN) | |
| rng = np.random.default_rng(12345) | |
| draws = rng.choice(rets, size=(n_sims, horizon), replace=True) | |
| paths = np.cumprod(1.0 + draws, axis=1) # cumulative price factor along each path | |
| max_up = (paths.max(axis=1) - 1.0) * 100.0 # best up-excursion per path (%) | |
| min_dn = (paths.min(axis=1) - 1.0) * 100.0 # worst down-excursion per path (%) | |
| return (float(np.mean(max_up > 0.0)) * 100.0, float(np.mean(max_up)), float(np.mean(min_dn))) | |
| def compute_extra_features(c: pd.Series, h: pd.Series, l: pd.Series, v: pd.Series) -> dict: | |
| """Point-in-time extra-feature dict for the LAST bar of the given series (live path).""" | |
| ser = _extra_feature_series(c, h, l, v) | |
| feat = {k: _last(s, _NAN) for k, s in ser.items()} | |
| rets = c.pct_change().to_numpy()[-63:] | |
| up_prob, exp_up, exp_dn = _mc_features(rets) | |
| feat["mc_up_prob_3d"] = up_prob | |
| feat["mc_exp_maxup_3d"] = exp_up | |
| feat["mc_exp_maxdn_3d"] = exp_dn | |
| return {k: float(feat.get(k, _NAN)) for k in EXTRA_FEATURE_COLS} | |
| def compute_features( | |
| c: pd.Series, h: pd.Series, l: pd.Series, v: pd.Series, | |
| nifty_c: pd.Series | None = None, vix_c: pd.Series | None = None, | |
| date=None, | |
| ) -> dict | None: | |
| """Return the ordered numeric feature dict for one (ticker, date). | |
| c/h/l/v are full daily Close/High/Low/Volume Series (DatetimeIndex). | |
| nifty_c/vix_c are ^NSEI / ^INDIAVIX Close Series (for RS + macro features). | |
| If `date` is given, all series are sliced to `.loc[:date]` (point-in-time). | |
| Returns None if there is too little history (< 26 bars) to compute indicators. | |
| """ | |
| if date is not None: | |
| c = c.loc[:date] | |
| h = h.loc[:date] | |
| l = l.loc[:date] | |
| v = v.loc[:date] | |
| c = c.dropna(); h = h.dropna(); l = l.dropna(); v = v.dropna() | |
| if len(c) < 26: | |
| return None | |
| price = float(c.iloc[-1]) | |
| f: dict[str, float] = {} | |
| # ββ Momentum / oscillators ββββββββββββββββββββββββββββββββββββββββββββββ | |
| f["rsi14"] = _last(rsi(c, 14), 50.0) | |
| f["rsi5"] = _last(rsi(c, 5), 50.0) | |
| f["rsi2"] = _last(rsi(c, 2), 50.0) | |
| f["macd_hist"] = _last(macd_h(c), 0.0) if len(c) >= 27 else 0.0 | |
| f["adx14"] = _last(adx_s(h, l, c), 20.0) if (len(h) >= 15 and len(l) >= 15) else 20.0 | |
| # ββ Trend / EMA (distance in %) βββββββββββββββββββββββββββββββββββββββββ | |
| e20 = float(c.ewm(span=20).mean().iloc[-1]) | |
| e50 = float(c.ewm(span=50).mean().iloc[-1]) if len(c) >= 50 else _NAN | |
| e200 = float(c.ewm(span=200).mean().iloc[-1]) if len(c) >= 200 else _NAN | |
| f["price_vs_ema20"] = (price / e20 - 1.0) * 100.0 | |
| f["price_vs_ema50"] = (price / e50 - 1.0) * 100.0 if np.isfinite(e50) else _NAN | |
| f["price_vs_ema200"] = (price / e200 - 1.0) * 100.0 if np.isfinite(e200) else _NAN | |
| f["ema_stack"] = _last(ema_stack_score(c), 0.5) if len(c) >= 20 else 0.5 | |
| f["supertrend"] = float(1 if _supertrend_dir(c, h, l) > 0 else 0) | |
| # ββ Bollinger position + explicit band-distance (room to band, %) βββββββ | |
| bb_pct = 50.0 | |
| bb_lower_dist = _NAN | |
| bb_upper_dist = _NAN | |
| if len(c) >= 20: | |
| sma20 = float(c.rolling(20).mean().iloc[-1]) | |
| std20 = float(c.rolling(20).std().iloc[-1]) | |
| bb_upper = sma20 + 2 * std20 | |
| bb_lower = sma20 - 2 * std20 | |
| if bb_upper > bb_lower: | |
| bb_pct = (price - bb_lower) / (bb_upper - bb_lower) * 100.0 | |
| bb_lower_dist = (price - bb_lower) / price * 100.0 | |
| bb_upper_dist = (bb_upper - price) / price * 100.0 | |
| f["bb_pct"] = bb_pct | |
| f["bb_lower_dist"] = bb_lower_dist | |
| f["bb_upper_dist"] = bb_upper_dist | |
| # ββ Relative strength vs Nifty (3M = 63 bars) βββββββββββββββββββββββββββ | |
| rs_3m = 0.0 | |
| if nifty_c is not None and len(nifty_c) > 0 and len(c) >= 63: | |
| ni = nifty_c | |
| if date is not None: | |
| ni = ni.loc[:date] | |
| ni = ni.reindex(c.index).ffill() | |
| try: | |
| rs_3m = float((c.iloc[-1] / c.iloc[-63] - 1) - (ni.iloc[-1] / ni.iloc[-63] - 1)) * 100.0 | |
| except Exception: | |
| rs_3m = 0.0 | |
| f["rs_3m"] = rs_3m | |
| # ββ Volume ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| vol_ratio = 1.0 | |
| if len(v) >= 20: | |
| v20 = float(v.rolling(20).mean().iloc[-1]) | |
| vol_ratio = float(v.iloc[-1]) / v20 if v20 > 0 else 1.0 | |
| f["vol_ratio"] = vol_ratio | |
| obv_z = 0.0 | |
| if len(c) >= 20: | |
| ob = obv(c, v) | |
| ob_mu = ob.rolling(20).mean().iloc[-1] | |
| ob_std = ob.rolling(20).std().iloc[-1] | |
| obv_z = float((ob.iloc[-1] - ob_mu) / ob_std) if ob_std and ob_std > 0 else 0.0 | |
| f["obv_z"] = float(np.clip(obv_z, -5, 5)) | |
| vt = 1.0 | |
| if len(v) >= 25: | |
| v20s = v / (v.rolling(20).mean() + 1e-9) | |
| vt = float(v20s.rolling(5).mean().iloc[-1]) | |
| f["vol_trend_5d"] = float(np.clip(vt, 0, 5)) | |
| # ββ Volatility / range ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| atr14 = _last(atr(h, l, c, 14), _NAN) if (len(h) >= 15 and len(l) >= 15) else _NAN | |
| f["atr_pct"] = (atr14 / price * 100.0) if (np.isfinite(atr14) and price > 0) else _NAN | |
| f["shadow"] = _last(shadow_flag(c, l), 0.0) if len(c) >= 20 else 0.0 | |
| # ββ Returns / streak ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| f["return_10d"] = (price / float(c.iloc[-10]) - 1) * 100.0 if len(c) >= 10 else _NAN | |
| f["return_20d"] = (price / float(c.iloc[-20]) - 1) * 100.0 if len(c) >= 20 else _NAN | |
| f["return_90d"] = (price / float(c.iloc[-63]) - 1) * 100.0 if len(c) >= 63 else _NAN | |
| f["dist_52w_high"] = (price / float(c.iloc[-252:].max()) - 1) * 100.0 if len(c) >= 252 else _NAN | |
| consec = _consec_days(c) | |
| f["consec_days"] = consec | |
| # ββ Domain trigger flags ββββββββββββββββββββββββββββββββββββββββββββββ | |
| above_ema50 = np.isfinite(e50) and price > e50 | |
| above_ema200 = np.isfinite(e200) and price > e200 | |
| r10 = f["return_10d"] if np.isfinite(f["return_10d"]) else 0.0 | |
| r20 = f["return_20d"] if np.isfinite(f["return_20d"]) else 0.0 | |
| f.update(_trigger_flags(f["rsi14"], bb_pct, r10, r20, f["macd_hist"], | |
| above_ema50, above_ema200, consec)) | |
| # ββ Macro regime ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| vix_level = _NAN | |
| vix_decl = 0.0 | |
| if vix_c is not None and len(vix_c) > 0: | |
| vc = vix_c | |
| if date is not None: | |
| vc = vc.loc[:date] | |
| vc = vc.dropna() | |
| if len(vc) > 0: | |
| vix_level = float(vc.iloc[-1]) | |
| if len(vc) >= 10: | |
| vix_ema5 = vc.ewm(span=5).mean() | |
| vix_decl = float(1 if vix_ema5.iloc[-1] < vix_ema5.iloc[-2] else 0) | |
| f["vix_level"] = vix_level | |
| f["vix_decl"] = vix_decl | |
| nifty_ok = 0.0 | |
| if nifty_c is not None and len(nifty_c) > 0: | |
| ni = nifty_c | |
| if date is not None: | |
| ni = ni.loc[:date] | |
| ni = ni.dropna() | |
| if len(ni) >= 3: | |
| nema = ni.ewm(span=200).mean().iloc[-1] | |
| nifty_ok = float(1 if float(ni.iloc[-1]) > float(nema) else 0) | |
| f["nifty_ok"] = nifty_ok | |
| # ββ Extra indicator + Monte-Carlo features (ML_EXTRA_FEATURES=1) ββββββββββ | |
| if _USE_EXTRA_FEATS: | |
| try: | |
| f.update(compute_extra_features(c, h, l, v)) | |
| except Exception: | |
| f.update({k: _NAN for k in EXTRA_FEATURE_COLS}) | |
| # Return in canonical order (any missing key β NaN, tolerated by HistGBM). | |
| return {k: float(f.get(k, _NAN)) for k in FEATURE_COLUMNS} | |
| def features_to_row(feat: dict) -> list[float]: | |
| """Feature dict β ordered list matching FEATURE_COLUMNS (for model input).""" | |
| return [feat.get(k, _NAN) for k in FEATURE_COLUMNS] | |