|
| 1 | +"""Feature pipeline for ML-based stock selection. |
| 2 | +
|
| 3 | +Computes technical indicator features from OHLCV data using |
| 4 | +attribute_history, ensuring no look-ahead bias. |
| 5 | +""" |
| 6 | + |
| 7 | +import logging |
| 8 | +from typing import Callable, Optional |
| 9 | + |
| 10 | +import numpy as np |
| 11 | +import pandas as pd |
| 12 | + |
| 13 | +from eqlib.data import attribute_history |
| 14 | + |
| 15 | +log = logging.getLogger(__name__) |
| 16 | + |
| 17 | + |
| 18 | +class FeaturePipeline: |
| 19 | + """Builds ML-ready feature matrices from OHLCV data. |
| 20 | +
|
| 21 | + All features are computed point-in-time using ``attribute_history`` |
| 22 | + to guarantee no look-ahead bias. |
| 23 | +
|
| 24 | + Parameters |
| 25 | + ---------- |
| 26 | + features : list[str] or None |
| 27 | + List of built-in feature names to compute. If None, uses a default |
| 28 | + set of commonly useful features. |
| 29 | + custom_features : dict[str, Callable] or None |
| 30 | + Optional dict mapping feature name to a callable that accepts |
| 31 | + (close, high, low, volume) Series and returns a scalar feature value. |
| 32 | +
|
| 33 | + Examples |
| 34 | + -------- |
| 35 | + >>> pipeline = FeaturePipeline(features=['rsi', 'macd_hist', 'atr']) |
| 36 | + >>> df = pipeline.compute(['601390', '600519'], context, lookback=60) |
| 37 | + """ |
| 38 | + |
| 39 | + # Supported built-in features |
| 40 | + BUILT_IN_FEATURES = { |
| 41 | + 'rsi', |
| 42 | + 'macd_dif', |
| 43 | + 'macd_dea', |
| 44 | + 'macd_hist', |
| 45 | + 'atr', |
| 46 | + 'boll_upper', |
| 47 | + 'boll_mid', |
| 48 | + 'boll_lower', |
| 49 | + 'donchian_upper', |
| 50 | + 'donchian_mid', |
| 51 | + 'donchian_lower', |
| 52 | + 'cci', |
| 53 | + 'obv', |
| 54 | + 'volume_ratio', |
| 55 | + 'momentum', |
| 56 | + 'volatility', |
| 57 | + 'roc', |
| 58 | + } |
| 59 | + |
| 60 | + DEFAULT_FEATURES = [ |
| 61 | + 'rsi', |
| 62 | + 'macd_hist', |
| 63 | + 'atr', |
| 64 | + 'boll_upper', |
| 65 | + 'boll_lower', |
| 66 | + 'volume_ratio', |
| 67 | + 'momentum', |
| 68 | + 'volatility', |
| 69 | + ] |
| 70 | + |
| 71 | + def __init__( |
| 72 | + self, |
| 73 | + features: Optional[list[str]] = None, |
| 74 | + custom_features: Optional[dict[str, Callable]] = None, |
| 75 | + ): |
| 76 | + if features is None: |
| 77 | + self.features = list(self.DEFAULT_FEATURES) |
| 78 | + else: |
| 79 | + unknown = set(features) - self.BUILT_IN_FEATURES |
| 80 | + if unknown and not custom_features: |
| 81 | + raise ValueError(f"Unknown features: {unknown}") |
| 82 | + self.features = list(features) |
| 83 | + |
| 84 | + self.custom_features = custom_features or {} |
| 85 | + self._feature_cache: dict = {} |
| 86 | + |
| 87 | + def compute( |
| 88 | + self, |
| 89 | + securities: list[str], |
| 90 | + context, |
| 91 | + lookback: int = 60, |
| 92 | + ) -> pd.DataFrame: |
| 93 | + """Compute features for the given securities at ``context.current_dt``. |
| 94 | +
|
| 95 | + Parameters |
| 96 | + ---------- |
| 97 | + securities : list[str] |
| 98 | + List of bare security codes (e.g. ``['601390', '600519']``). |
| 99 | + context : Context |
| 100 | + The current backtest context (provides ``current_dt``). |
| 101 | + lookback : int |
| 102 | + Number of historical bars to fetch for computing indicators. |
| 103 | +
|
| 104 | + Returns |
| 105 | + ------- |
| 106 | + pd.DataFrame |
| 107 | + DataFrame indexed by security code, columns = feature names. |
| 108 | + Missing values (e.g. insufficient history) are filled with NaN. |
| 109 | + """ |
| 110 | + rows = [] |
| 111 | + for sec in securities: |
| 112 | + try: |
| 113 | + row = self._compute_single(sec, context, lookback) |
| 114 | + except Exception as exc: |
| 115 | + log.debug("Feature compute failed for %s: %s", sec, exc) |
| 116 | + row = {} |
| 117 | + rows.append(row) |
| 118 | + |
| 119 | + df = pd.DataFrame(rows, index=securities) |
| 120 | + # Ensure all requested columns exist even if empty |
| 121 | + for feat in self.features: |
| 122 | + if feat not in df.columns: |
| 123 | + df[feat] = np.nan |
| 124 | + return df |
| 125 | + |
| 126 | + def _compute_single(self, sec: str, context, lookback: int) -> dict: |
| 127 | + """Compute features for a single security.""" |
| 128 | + # Need extra bars for indicators that need history |
| 129 | + # RSI(14) needs 14 bars, MACD needs ~33, Bollinger needs 20 |
| 130 | + # Add a safety buffer |
| 131 | + min_history = max(lookback, 60) |
| 132 | + |
| 133 | + hist = attribute_history( |
| 134 | + sec, min_history, "1d", |
| 135 | + fields=["close", "high", "low", "volume"], |
| 136 | + ) |
| 137 | + if hist is None or hist.empty or len(hist) < 20: |
| 138 | + return {} |
| 139 | + |
| 140 | + close = hist["close"] |
| 141 | + high = hist["high"] |
| 142 | + low = hist["low"] |
| 143 | + volume = hist["volume"] |
| 144 | + |
| 145 | + result = {} |
| 146 | + |
| 147 | + # Only compute features that were requested |
| 148 | + if any(f in self.features for f in ('rsi',)): |
| 149 | + result.update(self._compute_rsi(close)) |
| 150 | + |
| 151 | + if any(f in self.features for f in ('macd_dif', 'macd_dea', 'macd_hist')): |
| 152 | + result.update(self._compute_macd(close)) |
| 153 | + |
| 154 | + if any(f in self.features for f in ('atr',)): |
| 155 | + result.update(self._compute_atr(high, low, close)) |
| 156 | + |
| 157 | + if any(f in self.features for f in ('boll_upper', 'boll_mid', 'boll_lower')): |
| 158 | + result.update(self._compute_bollinger(close)) |
| 159 | + |
| 160 | + if any(f in self.features for f in ('donchian_upper', 'donchian_mid', 'donchian_lower')): |
| 161 | + result.update(self._compute_donchian(high, low, close)) |
| 162 | + |
| 163 | + if any(f in self.features for f in ('cci',)): |
| 164 | + result.update(self._compute_cci(high, low, close)) |
| 165 | + |
| 166 | + if any(f in self.features for f in ('obv',)): |
| 167 | + result.update(self._compute_obv(close, volume)) |
| 168 | + |
| 169 | + if any(f in self.features for f in ('volume_ratio',)): |
| 170 | + result.update(self._compute_volume_ratio(volume)) |
| 171 | + |
| 172 | + if any(f in self.features for f in ('momentum',)): |
| 173 | + result.update(self._compute_momentum(close)) |
| 174 | + |
| 175 | + if any(f in self.features for f in ('volatility',)): |
| 176 | + result.update(self._compute_volatility(close)) |
| 177 | + |
| 178 | + if any(f in self.features for f in ('roc',)): |
| 179 | + result.update(self._compute_roc(close)) |
| 180 | + |
| 181 | + # Custom features |
| 182 | + for name, func in self.custom_features.items(): |
| 183 | + try: |
| 184 | + result[name] = func(close, high, low, volume) |
| 185 | + except Exception as exc: |
| 186 | + log.debug("Custom feature %s failed for %s: %s", name, sec, exc) |
| 187 | + result[name] = np.nan |
| 188 | + |
| 189 | + # Filter to only requested features |
| 190 | + result = {k: v for k, v in result.items() if k in self.features} |
| 191 | + return result |
| 192 | + |
| 193 | + # -- Built-in feature calculators --------------------------------------- |
| 194 | + |
| 195 | + @staticmethod |
| 196 | + def _compute_rsi(close: pd.Series) -> dict: |
| 197 | + from eqlib.utils.indicators import rsi |
| 198 | + try: |
| 199 | + val = rsi(close, 14) |
| 200 | + return {'rsi': float(val.iloc[-1]) if not val.empty and not pd.isna(val.iloc[-1]) else np.nan} |
| 201 | + except Exception: |
| 202 | + return {'rsi': np.nan} |
| 203 | + |
| 204 | + @staticmethod |
| 205 | + def _compute_macd(close: pd.Series) -> dict: |
| 206 | + from eqlib.utils.indicators import macd |
| 207 | + try: |
| 208 | + dif, dea, hist = macd(close, fast=12, slow=26, signal=9) |
| 209 | + return { |
| 210 | + 'macd_dif': float(dif.iloc[-1]) if not dif.empty and not pd.isna(dif.iloc[-1]) else np.nan, |
| 211 | + 'macd_dea': float(dea.iloc[-1]) if not dea.empty and not pd.isna(dea.iloc[-1]) else np.nan, |
| 212 | + 'macd_hist': float(hist.iloc[-1]) if not hist.empty and not pd.isna(hist.iloc[-1]) else np.nan, |
| 213 | + } |
| 214 | + except Exception: |
| 215 | + return {'macd_dif': np.nan, 'macd_dea': np.nan, 'macd_hist': np.nan} |
| 216 | + |
| 217 | + @staticmethod |
| 218 | + def _compute_atr(high: pd.Series, low: pd.Series, close: pd.Series) -> dict: |
| 219 | + from eqlib.utils.indicators import atr |
| 220 | + try: |
| 221 | + val = atr(high, low, close, 14) |
| 222 | + return {'atr': float(val.iloc[-1]) if not val.empty and not pd.isna(val.iloc[-1]) else np.nan} |
| 223 | + except Exception: |
| 224 | + return {'atr': np.nan} |
| 225 | + |
| 226 | + @staticmethod |
| 227 | + def _compute_bollinger(close: pd.Series) -> dict: |
| 228 | + from eqlib.utils.indicators import boll |
| 229 | + try: |
| 230 | + upper, mid, lower = boll(close, period=20, num_std=2.0) |
| 231 | + return { |
| 232 | + 'boll_upper': float(upper.iloc[-1]) if not upper.empty and not pd.isna(upper.iloc[-1]) else np.nan, |
| 233 | + 'boll_mid': float(mid.iloc[-1]) if not mid.empty and not pd.isna(mid.iloc[-1]) else np.nan, |
| 234 | + 'boll_lower': float(lower.iloc[-1]) if not lower.empty and not pd.isna(lower.iloc[-1]) else np.nan, |
| 235 | + } |
| 236 | + except Exception: |
| 237 | + return {'boll_upper': np.nan, 'boll_mid': np.nan, 'boll_lower': np.nan} |
| 238 | + |
| 239 | + @staticmethod |
| 240 | + def _compute_donchian(high: pd.Series, low: pd.Series, close: pd.Series) -> dict: |
| 241 | + from eqlib.utils.indicators import donchian |
| 242 | + try: |
| 243 | + upper, mid, lower = donchian(high, low, close, period=20) |
| 244 | + return { |
| 245 | + 'donchian_upper': float(upper.iloc[-1]) if not upper.empty and not pd.isna(upper.iloc[-1]) else np.nan, |
| 246 | + 'donchian_mid': float(mid.iloc[-1]) if not mid.empty and not pd.isna(mid.iloc[-1]) else np.nan, |
| 247 | + 'donchian_lower': float(lower.iloc[-1]) if not lower.empty and not pd.isna(lower.iloc[-1]) else np.nan, |
| 248 | + } |
| 249 | + except Exception: |
| 250 | + return {'donchian_upper': np.nan, 'donchian_mid': np.nan, 'donchian_lower': np.nan} |
| 251 | + |
| 252 | + @staticmethod |
| 253 | + def _compute_cci(high: pd.Series, low: pd.Series, close: pd.Series) -> dict: |
| 254 | + from eqlib.utils.indicators import cci |
| 255 | + try: |
| 256 | + val = cci(high, low, close, 14) |
| 257 | + return {'cci': float(val.iloc[-1]) if not val.empty and not pd.isna(val.iloc[-1]) else np.nan} |
| 258 | + except Exception: |
| 259 | + return {'cci': np.nan} |
| 260 | + |
| 261 | + @staticmethod |
| 262 | + def _compute_obv(close: pd.Series, volume: pd.Series) -> dict: |
| 263 | + from eqlib.utils.indicators import obv |
| 264 | + try: |
| 265 | + val = obv(close, volume) |
| 266 | + return {'obv': float(val.iloc[-1]) if not val.empty and not pd.isna(val.iloc[-1]) else np.nan} |
| 267 | + except Exception: |
| 268 | + return {'obv': np.nan} |
| 269 | + |
| 270 | + @staticmethod |
| 271 | + def _compute_volume_ratio(volume: pd.Series) -> dict: |
| 272 | + """5-day average volume / 20-day average volume.""" |
| 273 | + if len(volume) < 20: |
| 274 | + return {'volume_ratio': np.nan} |
| 275 | + vol_5 = volume.iloc[-5:].mean() |
| 276 | + vol_20 = volume.iloc[-20:].mean() |
| 277 | + if vol_20 == 0: |
| 278 | + return {'volume_ratio': np.nan} |
| 279 | + return {'volume_ratio': float(vol_5 / vol_20)} |
| 280 | + |
| 281 | + @staticmethod |
| 282 | + def _compute_momentum(close: pd.Series) -> dict: |
| 283 | + """Price / price 20 days ago - 1.""" |
| 284 | + if len(close) < 21: |
| 285 | + return {'momentum': np.nan} |
| 286 | + return {'momentum': float(close.iloc[-1] / close.iloc[-21] - 1.0)} |
| 287 | + |
| 288 | + @staticmethod |
| 289 | + def _compute_volatility(close: pd.Series) -> dict: |
| 290 | + """20-day standard deviation of daily returns.""" |
| 291 | + if len(close) < 21: |
| 292 | + return {'volatility': np.nan} |
| 293 | + returns = close.pct_change().dropna() |
| 294 | + if len(returns) < 20: |
| 295 | + return {'volatility': np.nan} |
| 296 | + return {'volatility': float(returns.iloc[-20:].std())} |
| 297 | + |
| 298 | + @staticmethod |
| 299 | + def _compute_roc(close: pd.Series) -> dict: |
| 300 | + """Rate of change (12-period).""" |
| 301 | + from eqlib.utils.indicators import roc |
| 302 | + try: |
| 303 | + val = roc(close, 12) |
| 304 | + return {'roc': float(val.iloc[-1]) if not val.empty and not pd.isna(val.iloc[-1]) else np.nan} |
| 305 | + except Exception: |
| 306 | + return {'roc': np.nan} |
0 commit comments