Skip to content

Commit 7c797f3

Browse files
committed
[Feature] Implement multi-factor model and aggregators
1 parent b88a8cd commit 7c797f3

8 files changed

Lines changed: 489 additions & 3 deletions

File tree

src/quanteval/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@
3838
ROC,
3939
StochasticOscillator,
4040
)
41+
from quanteval.aggregators import (
42+
FactorAggregator,
43+
EqualWeightAggregator,
44+
ICWeightedAggregator,
45+
)
46+
from quanteval.strategies.multi_factor_model import MultiFactorModel
4147

4248
# Benchmark strategies
4349
from quanteval.strategies.dual_ma import DualMAStrategy
@@ -81,13 +87,22 @@
8187
'VolumeMA',
8288
'ROC',
8389
'StochasticOscillator',
90+
# Factor Aggregators
91+
'FactorAggregator',
92+
'EqualWeightAggregator',
93+
'ICWeightedAggregator',
94+
# Factor Models
95+
'FactorModel',
96+
'CompositeFactorModel',
97+
'MomentumValueModel',
8498
# Strategies
8599
'DualMAStrategy',
86100
'BollingerMeanReversionStrategy',
87101
'BuyAndHoldStrategy',
88102
# Optimization
89103
'GridSearch',
90104
'WalkForwardAnalysis',
105+
'MultiFactorModel',
91106
# Comparison
92107
'StrategyComparator',
93108
'StrategyComparison',
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Aggregators module initialization."""
2+
3+
from quanteval.aggregators.base import FactorAggregator
4+
from quanteval.aggregators.weighted import EqualWeightAggregator, ICWeightedAggregator
5+
6+
__all__ = ['FactorAggregator', 'EqualWeightAggregator', 'ICWeightedAggregator']

src/quanteval/aggregators/base.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from abc import ABC, abstractmethod
2+
3+
import pandas as pd
4+
5+
6+
class FactorAggregator(ABC):
7+
"""
8+
因子聚合器基类
9+
10+
Abstract base class for factor aggregation. All custom aggregators must
11+
inherit from this class.
12+
13+
Args:
14+
name: 聚合器名称 (Aggregator name)
15+
"""
16+
17+
def __init__(self, name: str = 'FactorAggregator'):
18+
self.name = name
19+
20+
@abstractmethod
21+
def aggregate(self, factors: dict[str, pd.Series], data: pd.DataFrame) -> pd.Series:
22+
"""
23+
聚合多个因子为复合信号 (必须实现)
24+
25+
Aggregate multiple factors into a composite score. Must be implemented
26+
by subclasses.
27+
28+
Args:
29+
factors: Dict mapping factor names to their Series values
30+
data: Original OHLCV DataFrame (used for IC calculation in subclasses)
31+
32+
Returns:
33+
Composite score Series with same index as data
34+
"""
35+
pass
36+
37+
def __repr__(self) -> str:
38+
return self.name
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
"""
2+
Factor Aggregator - 因子聚合器
3+
Combine multiple factors into composite signals with various weighting schemes.
4+
"""
5+
6+
import numpy as np
7+
import pandas as pd
8+
9+
from quanteval.aggregators.base import FactorAggregator
10+
11+
12+
def _rolling_zscore(series: pd.Series, window: int, min_periods: int) -> pd.Series:
13+
"""
14+
滚动 Z-Score 标准化
15+
16+
Rolling z-score normalization. Guards against std=0.
17+
18+
Args:
19+
series: Input series to normalize
20+
window: Rolling window size
21+
min_periods: Minimum number of observations required
22+
23+
Returns:
24+
Normalized series with same index
25+
"""
26+
mean = series.rolling(window=window, min_periods=min_periods).mean()
27+
std = series.rolling(window=window, min_periods=min_periods).std()
28+
std = std.replace(0, np.nan) # Prevent division by zero
29+
return (series - mean) / std
30+
31+
32+
def _rolling_rank(series: pd.Series, window: int, min_periods: int) -> pd.Series:
33+
"""
34+
滚动百分位排名
35+
36+
Rolling percentile rank.
37+
38+
Args:
39+
series: Input series to rank
40+
window: Rolling window size
41+
min_periods: Minimum number of observations required
42+
43+
Returns:
44+
Ranked series (0-1 scale) with same index
45+
"""
46+
return series.rolling(window=window, min_periods=min_periods).rank(pct=True)
47+
48+
49+
def _rolling_winsorize(
50+
series: pd.Series, window: int, n_sigma: float, min_periods: int
51+
) -> pd.Series:
52+
"""
53+
滚动缩尾处理
54+
55+
Clip values beyond mean ± n_sigma*std per rolling window.
56+
57+
Args:
58+
series: Input series to winsorize
59+
window: Rolling window size
60+
n_sigma: Number of standard deviations for clipping
61+
min_periods: Minimum number of observations required
62+
63+
Returns:
64+
Winsorized series with same index
65+
"""
66+
mean = series.rolling(window=window, min_periods=min_periods).mean()
67+
std = series.rolling(window=window, min_periods=min_periods).std()
68+
lower = mean - n_sigma * std
69+
upper = mean + n_sigma * std
70+
return series.clip(lower=lower, upper=upper, axis=0)
71+
72+
73+
def _rolling_spearman_corr(
74+
series1: pd.Series, series2: pd.Series, window: int, min_periods: int
75+
) -> pd.Series:
76+
"""
77+
滚动 Spearman 相关系数
78+
79+
Rolling Spearman correlation coefficient between two series.
80+
81+
Args:
82+
series1: First series
83+
series2: Second series
84+
window: Rolling window size
85+
min_periods: Minimum number of observations required
86+
87+
Returns:
88+
Rolling Spearman correlation series with same index as series1
89+
90+
Notes:
91+
Uses rank transformation followed by Pearson correlation on ranks.
92+
"""
93+
94+
def spearman_corr(x, y):
95+
"""Compute Spearman correlation between two arrays."""
96+
if len(x) < 2 or len(y) < 2:
97+
return np.nan
98+
mask = ~(np.isnan(x) | np.isnan(y))
99+
x_clean = x[mask]
100+
y_clean = y[mask]
101+
if len(x_clean) < 2:
102+
return np.nan
103+
rank_x = pd.Series(x_clean).rank()
104+
rank_y = pd.Series(y_clean).rank()
105+
return rank_x.corr(rank_y)
106+
107+
aligned = pd.DataFrame({'s1': series1, 's2': series2})
108+
109+
result = []
110+
for i in range(len(aligned)):
111+
if i < min_periods - 1:
112+
result.append(np.nan)
113+
else:
114+
start_idx = max(0, i - window + 1)
115+
window_data = aligned.iloc[start_idx : i + 1]
116+
corr = spearman_corr(window_data['s1'].values, window_data['s2'].values)
117+
result.append(corr)
118+
119+
return pd.Series(result, index=series1.index)
120+
121+
122+
class EqualWeightAggregator(FactorAggregator):
123+
"""
124+
等权重因子聚合器
125+
126+
Equal-weight factor aggregation with optional rolling z-score normalization.
127+
128+
Args:
129+
normalize: Whether to apply rolling z-score normalization (default: True)
130+
normalize_window: Rolling window for normalization (default: 60)
131+
min_periods: Minimum observations required for normalization (default: 20)
132+
133+
Example:
134+
>>> aggregator = EqualWeightAggregator(normalize=True, normalize_window=60)
135+
>>> composite = aggregator.aggregate(factors_dict, data)
136+
"""
137+
138+
def __init__(
139+
self,
140+
normalize: bool = True,
141+
normalize_window: int = 60,
142+
min_periods: int = 20,
143+
):
144+
super().__init__(name='EqualWeightAggregator')
145+
self.normalize = normalize
146+
self.normalize_window = normalize_window
147+
self.min_periods = min_periods
148+
149+
def aggregate(self, factors: dict[str, pd.Series], data: pd.DataFrame) -> pd.Series:
150+
"""
151+
聚合因子 (等权重)
152+
153+
Aggregate factors using equal weighting. Optionally applies rolling
154+
z-score normalization before averaging.
155+
156+
Args:
157+
factors: Dict mapping factor names to their Series values
158+
data: Original OHLCV DataFrame (not used in equal-weight scheme)
159+
160+
Returns:
161+
Composite score Series with same index as data
162+
"""
163+
if not factors:
164+
return pd.Series(np.nan, index=data.index, name='CompositeScore')
165+
166+
aligned_factors = []
167+
for factor_series in factors.values():
168+
aligned = factor_series.reindex(data.index)
169+
if self.normalize:
170+
aligned = _rolling_zscore(aligned, self.normalize_window, self.min_periods)
171+
aligned_factors.append(aligned)
172+
173+
composite = pd.concat(aligned_factors, axis=1).mean(axis=1)
174+
composite.name = 'CompositeScore'
175+
return composite
176+
177+
178+
class ICWeightedAggregator(FactorAggregator):
179+
"""
180+
IC 加权因子聚合器
181+
182+
Information Coefficient (IC) weighted factor aggregation. Computes rolling
183+
Spearman correlation between each factor and forward returns, then uses
184+
shifted IC as weights to prevent look-ahead bias.
185+
186+
Args:
187+
ic_window: Rolling window for IC calculation (default: 60)
188+
min_periods: Minimum observations required for IC calculation (default: 20)
189+
normalize: Whether to apply rolling z-score normalization (default: True)
190+
normalize_window: Rolling window for normalization (default: 60)
191+
192+
Example:
193+
>>> aggregator = ICWeightedAggregator(ic_window=60, normalize=True)
194+
>>> composite = aggregator.aggregate(factors_dict, data)
195+
196+
Notes:
197+
- Forward returns are computed as Close.pct_change().shift(-1)
198+
- IC weights are shifted by 1 period to prevent look-ahead bias
199+
- Falls back to equal weights when IC is all NaN or sums to zero
200+
"""
201+
202+
def __init__(
203+
self,
204+
ic_window: int = 60,
205+
min_periods: int = 20,
206+
normalize: bool = True,
207+
normalize_window: int = 60,
208+
):
209+
super().__init__(name='ICWeightedAggregator')
210+
self.ic_window = ic_window
211+
self.min_periods = min_periods
212+
self.normalize = normalize
213+
self.normalize_window = normalize_window
214+
215+
def aggregate(self, factors: dict[str, pd.Series], data: pd.DataFrame) -> pd.Series:
216+
"""
217+
聚合因子 (IC 加权)
218+
219+
Aggregate factors using rolling IC weights. Falls back to equal weights
220+
when IC is unavailable.
221+
222+
Args:
223+
factors: Dict mapping factor names to their Series values
224+
data: Original OHLCV DataFrame (must contain 'Close' column)
225+
226+
Returns:
227+
Composite score Series with same index as data
228+
"""
229+
if not factors:
230+
return pd.Series(np.nan, index=data.index, name='CompositeScore')
231+
232+
forward_returns = data['Close'].pct_change().shift(-1)
233+
234+
aligned_factors = []
235+
ic_weights_list = []
236+
237+
for factor_series in factors.values():
238+
aligned = factor_series.reindex(data.index)
239+
240+
rolling_ic = _rolling_spearman_corr(
241+
aligned, forward_returns, self.ic_window, self.min_periods
242+
)
243+
244+
ic_weight = rolling_ic.shift(1)
245+
ic_weights_list.append(ic_weight)
246+
247+
if self.normalize:
248+
aligned = _rolling_zscore(aligned, self.normalize_window, self.min_periods)
249+
aligned_factors.append(aligned)
250+
251+
ic_weights_df = pd.concat(ic_weights_list, axis=1)
252+
ic_sum = ic_weights_df.sum(axis=1)
253+
normalized_weights = ic_weights_df.div(ic_sum, axis=0)
254+
255+
equal_weight = 1.0 / len(factors)
256+
normalized_weights = normalized_weights.fillna(equal_weight)
257+
258+
factors_df = pd.concat(aligned_factors, axis=1)
259+
weighted_factors = factors_df * normalized_weights.values
260+
261+
composite = weighted_factors.sum(axis=1)
262+
composite.name = 'CompositeScore'
263+
return composite

src/quanteval/factors/__init__.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
11
"""Factors module initialization."""
22

33
from quanteval.factors.base import Factor
4-
from quanteval.factors.technical import SMA, EMA, RSI, MACD, BollingerBands, ATR, Momentum, VolumeMA
4+
from quanteval.factors.technical import (
5+
ATR,
6+
BollingerBands,
7+
EMA,
8+
MACD,
9+
Momentum,
10+
ROC,
11+
RSI,
12+
SMA,
13+
StochasticOscillator,
14+
VolumeMA,
15+
)
516

617
__all__ = [
718
'Factor',
@@ -13,4 +24,6 @@
1324
'ATR',
1425
'Momentum',
1526
'VolumeMA',
27+
'ROC',
28+
'StochasticOscillator',
1629
]

src/quanteval/strategies/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
"""Strategies module - Benchmark trading strategies."""
22

3-
from quanteval.strategies.dual_ma import DualMAStrategy
43
from quanteval.strategies.bollinger_mean_reversion import BollingerMeanReversionStrategy
54
from quanteval.strategies.buy_hold import BuyAndHoldStrategy
5+
from quanteval.strategies.donchain_channel import DonchianChannel
6+
from quanteval.strategies.dual_ma import DualMAStrategy
67
from quanteval.strategies.dual_thrust import DualThrustStrategy
78
from quanteval.strategies.rsi_reversion import RSIStrategy
8-
from quanteval.strategies.donchain_channel import DonchianChannel
99

1010
__all__ = [
1111
'DualMAStrategy',

0 commit comments

Comments
 (0)