摘要: 本文是“多因子轮动”系列的第二篇。上一篇我们剖析了单因子动量的三个核心局限,今天我们将正式动手,为策略引入更多维度的信息。我们将逐一构建四个候选因子:价格动量(保留)、低波动、成交量相对强弱、以及趋势强度。对每个因子,我们都会解释其逻辑、给出计算公式,并用Python代码实现。最后,我们会讨论因子间的相关性,避免选取冗余因子。读完本文,你将拥有一个基础因子库,为下一篇文章的因子合成与策略构建做好准备。
大家好,我是你们的老朋友。
上一篇我们坦诚地聊了单因子动量的“天花板”:它分不清稳健上涨和颠簸上涨,在动量值接近时缺乏分辨力,还完全忽视成交量的验证。这些局限的根源就是信息来源太单一。
从这一篇开始,我们要给策略装上“复眼”——用多个维度的信息综合判断。今天的目标很明确:选定几个候选因子,把它们的计算逻辑和代码都搞扎实。 我们不会急着构建策略,而是先把每一个因子研究透彻,确保它确实能提供动量之外的有效信息。
一、候选因子概览
我们选择因子的原则有三个:逻辑清晰、计算简单、与动量因子相关性低。基于这些原则,首期候选因子池包括四个因子:
这四个因子从不同角度刻画ETF的状态:动量看“涨了多少”,低波动看“涨得稳不稳”,成交量看“有没有资金支持”,趋势强度看“趋势健不健康”。组合起来,就能描绘出一幅更立体的图像。
二、因子一:价格动量因子
这个因子我们已经用了很久,这里简单回顾一下,并统一参数和计算口径。
逻辑:过去一段时间涨幅越大的ETF,未来继续上涨的概率越高。这是动量效应的核心假设。
计算方式:过去N个交易日的累计涨跌幅。我们沿用经典的20日周期。
# 计算20日动量
momentum_20 = price_df.pct_change(periods=20)
# 删除前20日无法计算的行
momentum_20.dropna(inplace=True)
pct_change(periods=20) 一步到位,它等价于 (今日收盘价 / 20个交易日前收盘价 – 1)。返回的DataFrame中,每一行是日期,每一列是ETF代码,值就是该ETF当天的20日涨跌幅。
因子方向:数值越大越好。这是我们的基准因子,后续所有新增因子都要与它配合。
三、因子二:低波动因子
这是针对“颠簸上涨”问题引入的因子。
逻辑:在同等收益水平下,波动率更低的ETF通常趋势更稳健,回撤风险更小,持有体验更好。我们给低波动ETF更高的分数。
计算方式:先计算过去20个交易日的年化波动率,然后取负值(因为波动率越低我们越喜欢,所以用负值来统一“越大越好”的方向)。
# 计算每日收益率
daily_returns = price_df.pct_change()
# 计算20日滚动年化波动率
hist_vol = daily_returns.rolling(20).std() * np.sqrt(252)
# 低波动因子:取负值,即波动率越小,因子值越大
low_vol_factor = –hist_vol
这里有两个细节需要注意。第一,rolling(20).std() 计算的是滚动标准差,乘以 np.sqrt(252) 转换为年化值,方便理解和比较。第二,取负值是一个简单但关键的技巧——它让低波动因子与动量因子的方向一致,后续合成时不会出现逻辑混淆。
因子方向:数值越大越好(即波动率越低越好)。
四、因子三:成交量因子
成交量因子用来验证价格动量的可靠性。
逻辑:“量在价先”,放量上涨通常意味着资金在积极介入,趋势更可靠;缩量上涨可能只是被动拉升或技术性反弹。我们给近期成交量相对放大的ETF更高的分数。
计算方式:用近5日的平均成交量除以近20日的平均成交量,再减1。这样得到的是一个相对变化率,正值表示近期放量,负值表示缩量。
# 假设 volume_df 是每日成交量DataFrame,列是ETF代码
# 计算近5日和近20日的均量
vol_ma5 = volume_df.rolling(5).mean()
vol_ma20 = volume_df.rolling(20).mean()
# 成交量相对变化率
volume_factor = vol_ma5 / vol_ma20 – 1
选择5日和20日作为短期和长期窗口,是为了与我们20日动量周期相匹配。volume_factor 是一个围绕0波动的值,正数表示近期成交量高于近期平均水平,负数则相反。
因子方向:数值越大越好(近期放量越明显越好)。
数据获取补充:要计算成交量因子,我们需要在数据获取阶段同时拉取成交量数据。如果使用 pytdx,get_security_bars 返回的数据中已经包含 volume 字段;如果用其他数据源,确保能拿到 volume 列即可。
# 数据获取阶段,保留volume列
def get_etf_daily_data(hq, code, market, start_date='20180101', end_date='20251231'):
# … 前面的代码不变 …
df = df[['date', 'close', 'volume']].set_index('date').sort_index()
return df
# 分别构建价格DataFrame和成交量DataFrame
price_df = pd.concat(...) # 收盘价
volume_df = pd.concat(...) # 成交量
五、因子四:趋势强度因子
这个因子刻画价格相对于均线的位置,以及短期均线与长期均线的发散程度。
逻辑:处于上升趋势的ETF,其价格应该在均线之上运行,且短期均线高于长期均线。价格在均线上方越多、均线发散越明显,趋势越强。
计算方式:用两个子指标合成。第一个是价格相对于20日均线的偏离度;第二个是5日均线与20日均线的相对差值。我们将两者相加,得到一个综合的趋势强度度量。
# 计算均线
ma5 = price_df.rolling(5).mean()
ma20 = price_df.rolling(20).mean()
# 价格偏离度:收盘价相对于20日均线的位置
price_deviation = (price_df – ma20) / ma20
# 均线发散度:5日均线相对于20日均线的位置
ma_divergence = (ma5 – ma20) / ma20
# 趋势强度因子:两者之和
trend_factor = price_deviation + ma_divergence
price_deviation 捕捉的是价格本身的趋势位置,ma_divergence 捕捉的是短期趋势相对于中期趋势的强度。当价格在20日均线上方运行,且5日均线也在20日均线上方时,trend_factor 为正值;两者都在下方时,为负值;一上一下时,数值在零附近。
因子方向:数值越大越好(趋势越强越好)。
六、因子相关性分析:避免冗余
选好了四个因子,我们还需要确认它们之间没有太高的相关性。如果两个因子高度相关(比如相关系数超过0.8),意味着它们提供的信息高度重叠,保留一个就够了。冗余因子不仅不会提升策略,还会增加过拟合风险。
我们可以计算四个因子在历史上的平均截面相关系数。以下是一个简化的分析框架:
# 将四个因子在某一天的值取出,计算相关系数矩阵
import pandas as pd
import numpy as np
# 假设我们有一个日期列表,每个日期计算因子间的相关系数
corr_list = []
for date in common_dates:
# 取出当天所有ETF的四个因子值
data = pd.DataFrame({
'momentum': momentum_20.loc[date],
'lowvol': low_vol_factor.loc[date],
'volume': volume_factor.loc[date],
'trend': trend_factor.loc[date]
})
corr_matrix = data.corr()
corr_list.append(corr_matrix)
# 计算平均相关系数矩阵
avg_corr = sum(corr_list) / len(corr_list)
print(avg_corr)
根据历史经验,动量因子和趋势强度因子之间可能存在中等偏高的相关性(因为两者都基于价格趋势),但通常不会超过0.7。如果确实超过0.8,可以考虑只保留其中一个,或者将两者合并为一个“复合趋势因子”。在首期策略中,我们暂时保留全部四个因子,实际回测时再根据表现调整。
七、本篇总结
今天我们完成了多因子轮动的基础工作——构建了一个包含四个因子的候选池:
| 价格动量 | pct_change(20) | 越大越好 | 捕捉趋势延续 |
| 低波动 | -rolling(20).std() * sqrt(252) | 越大越好 | 过滤颠簸上涨 |
| 成交量 | MA5 / MA20 – 1 | 越大越好 | 验证资金支持 |
| 趋势强度 | (close-MA20)/MA20 + (MA5-MA20)/MA20 | 越大越好 | 评估趋势健康度 |
这四个因子各有侧重,组合起来能够从“涨了多少、涨得稳不稳、有没有量、趋势强不强”四个维度综合评判一只ETF。在下一篇文章中,我们将对这些因子进行标准化处理,并合成一个综合分数,嵌入到现有的轮动策略框架中。
下一篇预告:多因子轮动系列(三)——因子标准化与合成:从多个分数到一个排名
我们离完整的多因子轮动策略只差最后一步了。准备好将理论变成代码了吗?咱们下篇见!


