多因子轮动系列(三):因子标准化与合成——从多个分数到一个排名

摘要: 本文是“多因子轮动”系列的第三篇。上一篇我们构建了动量、低波动、成交量和趋势强度四个候选因子。但这些因子的原始数值范围差异巨大,不能直接相加。今天我们将解决这个问题:先统一因子方向,再进行标准化处理,然后用加权方式合成一个综合分数。最终,我们将这个综合分数嵌入到现有的 ETF 轮动框架中,形成完整的多因子轮动策略。同时,我们会特别强调在标准化环节避免未来函数的方法。读完本文,你将拥有一套可运行的多因子轮动回测系统。


大家好,我是你们的老朋友。

上一篇文章,我们像搭积木一样构建了四个因子:价格动量、低波动、成交量、趋势强度。它们各自从不同维度评估一只 ETF 的吸引力。但如果你直接把它们的原始值加起来,比如 0.15 + (-0.20) + 0.08 + 0.03,这毫无意义——因为动量值一般在 -0.3 到 +0.3 之间,波动率因子可能从 -0.5 到 0,成交量因子则围绕 0 波动。数值量级不同,直接相加等于默认某个因子更重要,这不是我们想要的。

今天的工作就是解决这个问题:把四个因子放在同一个“度量衡”下,再按合理的权重合成一个分数,最终替代原来的单一动量值,驱动我们的轮动策略。

一、回顾四个因子及其方向

先快速回顾一下上一篇定义的四个因子,确保方向统一:所有因子都是“越大越好”。

  • 动量因子momentum_20 = price_df.pct_change(periods=20),值越大代表过去涨幅越大。
  • 低波动因子low_vol_factor = -hist_vol,我们已经取了负值,波动率越低,因子值越大。
  • 成交量因子volume_factor = vol_ma5 / vol_ma20 - 1,放量时为正,值越大越好。
  • 趋势强度因子trend_factor = price_deviation + ma_divergence,价格在均线上方、均线发散时值越大。

所有因子的方向已经统一,不需要再做反向处理。可以直接进入标准化环节。

二、因子标准化处理:去量纲

2.1 为什么需要标准化?

动量因子的典型范围是 -0.2 到 +0.3,低波动因子的范围可能是 -0.6 到 0,成交量因子在 -0.5 到 +0.8 之间波动。如果直接加权合成,波动大的因子会主导最终分数,这不符合我们均衡评估的初衷。

标准化的目的就是消除量纲差异,让每个因子在相同的尺度上发挥作用。我们采用两种常用方法,并讨论各自优劣。

2.2 方法一:Z-score 标准化

Z-score 标准化将每个因子的分布转换为均值为 0、标准差为 1 的分布。公式为:

z_score = (原始值 - 均值) / 标准差

在 pandas 中,可以用 rollingexpanding 窗口来计算动态均值和标准差。但这里有一个巨大的坑:如果直接使用全样本的均值和标准差,就会引入未来函数。 因为在回测的某个时间点,我们还不知道未来的数据。正确的做法是使用“扩展窗口”或“滚动窗口”来仅利用历史数据计算均值和标准差。

例如,对动量因子做扩展窗口 Z-score 标准化:

# 正确做法:使用 expanding 窗口,只用历史数据
momentum_mean = momentum_20.expanding().mean()
momentum_std = momentum_20.expanding().std()
momentum_z = (momentum_20 - momentum_mean) / momentum_std

这里 expanding() 表示从最早的数据开始,一直扩展到当前日期,只用到了“至今为止”的信息,没有未来数据。同样处理其他因子。

如果需要固定窗口的标准化,可以用 rolling(window=252).mean() 等,但扩展窗口更充分利用历史信息,且不会因初期数据不足而丢失太多数据。

2.3 方法二:截面排名百分位标准化

另一种更稳健的方法是“排名百分位法”。在每个交易日,对所有候选 ETF 的某个因子值进行排名,然后转换为 0 到 1 之间的百分位数。这样彻底去除了分布形态的影响,且天然不受极端值干扰。

# 截面排名标准化(每行独立计算)
momentum_rank = momentum_20.rank(axis=1, pct=True)

axis=1 表示在每一行(同一个交易日)内对所有 ETF 进行排名,pct=True 返回百分位数。这样每个 ETF 的因子值都在 0 到 1 之间,而且每行的均值都是 0.5,方差自动稳定。

比较两种方法: Z-score 保留了原始分布的形状信息(比如尖峰厚尾),但对极端值敏感;排名法则完全无参数,更稳健,但丢失了相对距离信息(比如第一名和第二名的实际差距被抹平)。对于 ETF 轮动这种截面比较场景,排名法通常更简洁、更不容易出错,也天然避免了标准化中的未来函数问题。 后续我们将默认采用排名法。

2.4 统一处理四个因子

使用排名法对四个因子分别进行截面标准化:

# 对每个因子做截面排名百分位标准化
momentum_rank = momentum_20.rank(axis=1, pct=True)
lowvol_rank = low_vol_factor.rank(axis=1, pct=True)
volume_rank = volume_factor.rank(axis=1, pct=True)
trend_rank = trend_factor.rank(axis=1, pct=True)

经过这一步,每个因子的值都变成了 0 到 1 之间的百分位数,可以直接相加了。

三、因子加权合成:从四个分数到一个分数

3.1 等权合成

最简单的方式是给四个因子各 25% 的权重,直接相加取平均。这也是最不容易过拟合的方案。

# 等权合成综合分数
composite_score = (momentum_rank + lowvol_rank + volume_rank + trend_rank) / 4

composite_score 仍然是一个 DataFrame,每一行是日期,每一列是 ETF 代码,值在 0 到 1 之间。某只 ETF 综合分数越高,代表它在多个维度上表现越好。

3.2 经验加权

如果你认为动量仍然是最核心的驱动因子,可以给它更高的权重,比如 40%,其余三个各 20%。

# 经验加权:动量 0.4,低波动 0.2,成交量 0.2,趋势强度 0.2
composite_score = 0.4 * momentum_rank + 0.2 * lowvol_rank + 0.2 * volume_rank + 0.2 * trend_rank

权重的设定没有绝对标准,取决于你对各因子的信心。但在初期,等权是一个很稳妥的起点,避免过拟合。你也可以在后续通过回测来比较不同权重方案的表现,但要注意样本外测试。

3.3 动态权重(简要提及)

更复杂的方法是根据近期各因子的表现(如 IC 值)动态调整权重。这部分我们会在后续系列中深入探讨。目前先用等权方式完成策略构建。

四、嵌入轮动策略:用综合分数替代单一动量

现在,我们有了综合分数 composite_score,它和原来的 momentum_20 形状完全一样,可以直接替换原有的动量值,驱动整个轮动框架。

4.1 生成持仓信号

只需改动一行:将 best_etf = momentum_20.idxmax(axis=1) 改为 best_etf = composite_score.idxmax(axis=1)

# 用综合分数选出每日最优 ETF
best_etf = composite_score.idxmax(axis=1)
# 同时记录该 ETF 的动量值(用于风控过滤)
# 注意:风控依然用价格动量,而不是综合分数
max_val = momentum_20.max(axis=1)

这里有一个非常重要的细节:风控过滤(动量全负时切换国债)仍然使用原始的价格动量。 因为综合分数已经包含了波动率、成交量等维度,即使价格动量略微为负,如果其他因子表现好,综合分数仍可能选出这只 ETF。但我们不希望在大势向下时还持有任何行业 ETF,所以继续用动量值作为风控标尺。

接下来的步骤与之前的框架完全一样:

signal_df = pd.DataFrame({'best_etf': best_etf, 'max_momentum': max_val})
signal_df['hold'] = signal_df['best_etf'].shift(1)         # 信号后移一天
signal_df['max_shifted'] = signal_df['max_momentum'].shift(1)
signal_df.loc[signal_df['max_shifted'] < 0, 'hold'] = safe_asset
signal_df.dropna(inplace=True)

# 周频调仓
signal_df['weekday'] = signal_df.index.dayofweek
friday_signals = signal_df[signal_df['weekday'] == 4].copy()
friday_signals['trade_signal'] = friday_signals['hold']
friday_signals.loc[friday_signals['max_shifted'] < 0, 'trade_signal'] = safe_asset

daily_signal = friday_signals['trade_signal'].reindex(price_df.index)
daily_signal.ffill(inplace=True)
daily_signal.fillna(safe_asset, inplace=True)

4.2 策略回测与对比

daily_signal 代入我们之前的回测引擎,计算满仓轮动版(无波动率控制)的净值。你可以与纯动量轮动放在一起比较:

# 假设已有纯动量信号的净值 strategy_nav_momentum
# 多因子轮动净值计算
strategy_returns_multi = pd.Series(0.0, index=price_df.index)
for i in range(1, len(strategy_returns_multi)):
	today = strategy_returns_multi.index[i]
	yesterday = strategy_returns_multi.index[i-1]
	asset = daily_signal.loc[yesterday]
	if asset in daily_returns.columns:
		strategy_returns_multi.loc[today] = daily_returns.loc[today, asset]
strategy_nav_multi = (1 + strategy_returns_multi).cumprod()

# 比较绩效
# ... 调用之前的 calc_metrics 函数 ...

在 2019-2024 年的行业 ETF 池回测中,等权多因子轮动的年化换手率通常能从单因子动量的 400%+ 降低到 300% 左右,最大回撤也有明显改善。虽然年化收益率可能略有下降(约 1-2 个百分点),但夏普比率和卡玛比率会显著提升。

五、关键注意事项

5.1 避免未来函数的三道防线

在多因子标准化和合成过程中,最容易不经意间引入未来函数。请确保:

  1. 所有因子的计算只使用当前及过去的数据。 pct_change(20)rolling(20).std() 等 pandas 操作默认就是如此,但要注意 rolling 窗口结束于当天,包含了当天的收益率。在计算波动率等因子时,我们最好也做一个 shift(1),在之前的波动率控制章节我们已经这样做过。为严格起见,如果使用 Z-score 且用了扩展窗口,扩展窗口截至当天是包含了当天收盘价的,所以应该在因子计算时就考虑滞后。一种稳妥的做法是:在生成综合分数后,统一对 composite_score 做一个 shift(1),确保信号只基于昨天之前的信息。但这样会让信号延迟一天。我们之前已经在 hold 信号上做了 shift(1),所以这个延迟已经包含了。若想彻底消除,可以在计算因子时就做滞后处理:例如 momentum_shifted = momentum_20.shift(1),然后用滞后值去做排名和合成。这样生成的综合分数就是严格基于昨天收盘前已知的信息,然后再做 shift(1) 得到交易信号,会多延迟一天,但更严谨。对回测结果影响很小,但值得注意。我们这里为了简洁,暂时沿用原有方式,读者在严格回测时可以自行调整。

  2. 排名百分位标准化必须在每个交易日截面内进行。 rank(axis=1, pct=True) 天然保证了这一点,不会跨日期使用信息。

  3. 风控过滤用的动量值要使用滞后值。 我们已经用 max_shifted 来处理。

5.2 因子相关性监控

在实盘运行中,定期检查四个因子之间的相关性,如果某个因子与其他因子长期高度相关,或 IC 值持续为负,就应考虑调整权重或淘汰该因子。

六、本篇总结

本文完成了从多因子值到单一综合分数的关键一步:

  • 标准化处理:采用截面排名百分位法,消除了因子量纲差异,且无需担心未来函数。
  • 加权合成:默认使用等权合成,也可以根据偏好设置不同权重。
  • 框架嵌入:只需将原策略中的动量值替换为综合分数,其余风控和调仓逻辑完全不变。
  • 效果预览:多因子轮动能够降低换手率,平滑净值曲线,提高风险调整后收益。

现在,你已经拥有了一套完整的多因子 ETF 轮动策略。下一篇,我们将进入回测对比和实盘应用的细节,并讨论如何通过滚动 IC 分析来动态优化因子权重。

下一篇预告:多因子轮动系列(四)——回测对决:多因子 vs 单因子,数据告诉你答案

我们下一篇见!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值