Spaces:
Sleeping
Sleeping
| """ | |
| Feature engineering and constants for the ML signal pipeline. | |
| Exports path constants and feature-building functions used by both | |
| the training pipeline (tool_ml_train.py) and prediction pipeline | |
| (tool_ml_signal.py). | |
| """ | |
| import os | |
| import warnings | |
| import numpy as np | |
| import pandas as pd | |
| DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "data") | |
| # Separate model files for ETF vs individual stock pipelines | |
| ETF_MODEL_PATH = os.path.join(DATA_DIR, "signal_model_etf.pkl") | |
| ETF_SCALER_PATH = os.path.join(DATA_DIR, "signal_scaler_etf.pkl") | |
| ETF_SELECTOR_PATH = os.path.join(DATA_DIR, "signal_selector_etf.pkl") | |
| ETF_META_PATH = os.path.join(DATA_DIR, "signal_meta_etf.json") | |
| STOCK_MODEL_PATH = os.path.join(DATA_DIR, "signal_model_stock.pkl") | |
| STOCK_SCALER_PATH = os.path.join(DATA_DIR, "signal_scaler_stock.pkl") | |
| STOCK_SELECTOR_PATH = os.path.join(DATA_DIR, "signal_selector_stock.pkl") | |
| STOCK_META_PATH = os.path.join(DATA_DIR, "signal_meta_stock.json") | |
| # Legacy aliases β used by old code, now point to stock model | |
| MODEL_PATH = STOCK_MODEL_PATH | |
| SCALER_PATH = STOCK_SCALER_PATH | |
| SELECTOR_PATH = STOCK_SELECTOR_PATH | |
| META_PATH = STOCK_META_PATH | |
| # ββ Feature engineering βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _build_features(hist: pd.DataFrame, vix: pd.Series = None) -> pd.DataFrame: | |
| """ | |
| Build feature matrix from OHLCV data. | |
| Every feature is normalised (ratios, percentages, z-scores) so the | |
| model generalises across different price levels and tickers. | |
| """ | |
| close = hist["Close"] | |
| high = hist["High"] | |
| low = hist["Low"] | |
| vol = hist["Volume"] | |
| df = pd.DataFrame(index=hist.index) | |
| # ββ Momentum features βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| df["ret_1d"] = close.pct_change(1) | |
| df["ret_5d"] = close.pct_change(5) | |
| df["ret_10d"] = close.pct_change(10) | |
| df["ret_20d"] = close.pct_change(20) | |
| # RSI (14) | |
| delta = close.diff() | |
| gain = delta.clip(lower=0).rolling(14).mean() | |
| loss = (-delta.clip(upper=0)).rolling(14).mean() | |
| df["rsi"] = 100 - (100 / (1 + gain / loss.replace(0, np.nan))) | |
| # Rate of change | |
| df["roc_10"] = (close / close.shift(10)) - 1 | |
| df["roc_20"] = (close / close.shift(20)) - 1 | |
| # ββ Trend features ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| df["dist_ma20"] = (close / close.rolling(20).mean()) - 1 | |
| df["dist_ma50"] = (close / close.rolling(50).mean()) - 1 | |
| df["dist_ma200"] = (close / close.rolling(200).mean()) - 1 | |
| ema12 = close.ewm(span=12).mean() | |
| ema26 = close.ewm(span=26).mean() | |
| macd = ema12 - ema26 | |
| sig = macd.ewm(span=9).mean() | |
| df["macd_hist_norm"] = (macd - sig) / close # normalised by price | |
| # ββ Volatility features βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| ma20 = close.rolling(20).mean() | |
| std20 = close.rolling(20).std() | |
| df["bb_position"] = (close - (ma20 - 2*std20)) / (4 * std20.replace(0, np.nan)) | |
| df["volatility"] = close.pct_change().rolling(20).std() | |
| # ATR ratio | |
| tr = pd.concat([high - low, | |
| (high - close.shift()).abs(), | |
| (low - close.shift()).abs()], axis=1).max(axis=1) | |
| df["atr_ratio"] = tr.rolling(14).mean() / close | |
| # ββ Volume features βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| vol_ma = vol.rolling(20).mean().replace(0, np.nan) | |
| df["vol_ratio"] = vol / vol_ma | |
| df["vol_trend"] = vol.rolling(5).mean() / vol_ma | |
| # ββ Interaction features (new in v2) ββββββββββββββββββββββββββββββββββββββ | |
| # RSI Γ BB position: oversold AND near lower band = stronger signal | |
| df["rsi_x_bb"] = df["rsi"] * (1 - df["bb_position"]) | |
| # Volume Γ momentum: high volume + positive return = conviction | |
| df["vol_x_ret5"] = df["vol_ratio"] * df["ret_5d"] | |
| # ββ 52-week position features βββββββββββββββββββββββββββββββββββββββββββββ | |
| df["dist_52w_high"] = (close / close.rolling(252).max()) - 1 # 0 = at high | |
| df["dist_52w_low"] = (close / close.rolling(252).min()) - 1 # 0 = at low | |
| # Momentum vs market (using recent vs 6-month return) | |
| df["momentum_6m"] = (close / close.shift(126)) - 1 | |
| # ββ Market regime (VIX) βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if vix is not None: | |
| aligned = vix.reindex(df.index, method="ffill") | |
| df["vix_level"] = aligned / 20.0 # normalised: >1 = fear | |
| df["vix_trend"] = aligned.pct_change(5) # rising/falling fear | |
| return df.dropna() | |
| def _build_labels(hist: pd.DataFrame, | |
| forward_days: int = 10, | |
| threshold: float = 0.03) -> pd.Series: | |
| """ | |
| Label each day based on what actually happened next. | |
| +1 = price rose > threshold in next forward_days β BUY was right | |
| -1 = price fell > threshold β SELL was right | |
| 0 = moved less than threshold β HOLD | |
| threshold=3% filters out noise. A signal that predicts 0.5% moves | |
| isn't tradeable after fees and spread. | |
| """ | |
| close = hist["Close"] | |
| fwd = close.shift(-forward_days) / close - 1 | |
| labels = pd.Series(0, index=fwd.index) | |
| labels[fwd > threshold] = 1 | |
| labels[fwd < -threshold] = -1 | |
| return labels | |