Skip to content

Commit 11d06bb

Browse files
AlanFokCoclaude
andcommitted
feat: add ML module (P0 + P1) for machine-learning based stock selection
Implements the eqlib.ml package providing: - FeaturePipeline: computes technical indicator features (RSI, MACD, ATR, Bollinger, momentum, volatility, etc.) from OHLCV data with strict point-in-time safety (no look-ahead bias) - BaseMLModel: unified wrapper for sklearn models (RandomForest, LogisticRegression, GradientBoosting) with optional XGBoost support, including save/load via pickle - MLSelector: StockSelector subclass that replaces hand-tuned factor weights with a learned model. Supports training on historical data and ranking stocks by predicted forward returns - Hyperparameter tuning (tuning.py): time-series-aware GridSearchCV - ML validation (validation.py): feature importance checks and distribution drift detection Updates: - pyproject.toml: adds scikit-learn>=1.2.0 dependency - eqlib/__init__.py: exports FeaturePipeline, BaseMLModel, MLSelector Tests: 23 new tests covering features, models, selection, and tuning. All 643 tests pass with zero regressions. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 3d7daf8 commit 11d06bb

13 files changed

Lines changed: 1440 additions & 0 deletions

eqlib/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,9 @@ def __repr__(self):
235235
query, valuation, get_fundamentals, get_current_data_object,
236236
)
237237

238+
# Machine Learning [EXPERIMENTAL]
239+
from eqlib.ml import FeaturePipeline, BaseMLModel, MLSelector
240+
238241
# Utilities: indicators, statistics, money management [STABLE]
239242
from eqlib import utils
240243

@@ -330,6 +333,10 @@ def __repr__(self):
330333
"set_local_data_dir", "save_stock_local", "load_stock_local",
331334
"has_local_data", "list_local_stocks", "remove_local_data",
332335
"clear_all_local_data",
336+
# Machine Learning
337+
"FeaturePipeline",
338+
"BaseMLModel",
339+
"MLSelector",
333340
# Utilities
334341
"utils",
335342
# Portfolio backtest

eqlib/ml/__init__.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""Machine Learning module for eqlib.
2+
3+
Provides ML-based stock selection, feature engineering, and model training
4+
integrated with the existing backtest framework.
5+
6+
Usage:
7+
from eqlib.ml import MLSelector, FeaturePipeline
8+
9+
selector = MLSelector(
10+
model='random_forest',
11+
features=['rsi', 'macd_hist', 'atr', 'momentum'],
12+
target='forward_return_5d',
13+
top_n=5
14+
)
15+
selected = selector.rank(context.universe, context)
16+
"""
17+
18+
from eqlib.ml.features import FeaturePipeline
19+
from eqlib.ml.models import BaseMLModel
20+
from eqlib.ml.selection import MLSelector
21+
22+
__all__ = [
23+
"FeaturePipeline",
24+
"BaseMLModel",
25+
"MLSelector",
26+
]

eqlib/ml/features.py

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
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

Comments
 (0)