强者恒强,弱者恒弱——至少在一段时间内。
为什么"追涨"在量化世界里是合理的
1980年代,芝加哥大学有一个叫Richard Driehaus的人,他做了一件让华尔街那帮价值投资者血压飙升的事——他公开说:"我买的就是涨得最猛的股票。"
他的逻辑很简单:一只股票涨了,说明有人在买。有人买,说明有信息在驱动。只要趋势还在,我就跟着。等趋势反转了,我走人。
Driehaus用这套"动量投资"策略赚了超过20亿美元。他被叫做"动量投资之父"。
但动量策略的学术根基,是1993年Jegadeesh和Titman发表的一篇论文。他们发现了一个所有人都觉得不可思议的现象:过去3到12个月表现最好的股票,在未来3到12个月里,依然会跑赢表现最差的股票。
这在当时是炸裂级的发现。因为有效市场假说(EMH)认为,价格已经反映了所有信息,过去的涨跌对未来没有预测力。但Jegadeesh和Titman说:不对,过去涨的,未来还会涨——至少在3-12个月的窗口内是这样。
这个现象,就是动量效应(Momentum Effect)。
动量效应之所以存在,行为金融学给出了几个解释:
•反应不足:市场对新信息的消化是渐进的,不是一步到位的。好消息出来后,价格不会立刻涨到位,而是慢慢涨——这个过程就形成了动量
•处置效应:散户倾向于过早卖出赚钱的股票(落袋为安),过晚卖出亏钱的股票(死扛不割)。这种非理性行为拉长了趋势的持续时间
•羊群效应:涨了→更多人关注→更多人买入→继续涨。正反馈循环。
不管背后的原因是什么,动量效应在全球各个市场——美股、A股、港股、欧股、新兴市场——都被反复验证过。它是学术界公认的"异象"之一,也是量化交易中最被广泛使用的因子之一。
今天这一篇,把动量特征拆开来看。历史收益率这一个数据,有多少种"打开方式"?每一种打开方式,分别捕捉了什么信息?在实战中怎么构造?
是什么:动量特征的本质
动量特征的本质是什么?用过去的价格变化,预测未来的价格方向。
但"过去的价格变化"不只是一个数字。你可以用不同的时间窗口、不同的计算方式、不同的变换方法,从同一组价格数据中提取出完全不同的动量信号。
打个比方:你想知道一个人跑步的速度。你可以看他最近10秒的速度(短期动量),也可以看他最近10分钟的平均速度(中期动量),还可以看他10分钟内的速度变化趋势(加速度)。三种指标,描述的是同一个人的同一段跑步过程,但捕捉的信息完全不同。
动量特征也一样。我们可以从以下几个维度来"打开"历史收益率:
维度 | 代表什么 | 例子 |
不同周期的收益率 | 不同时间尺度的趋势 | 5日收益率、20日收益率、120日收益率 |
收益率的滚动统计 | 趋势的稳定性 | 滚动均值、滚动标准差、滚动偏度 |
收益率的排名 | 相对强弱 | 横截面排名、百分位排名 |
收益率的组合特征 | 多个维度信息的综合 | 动量加速度、动量反转比 |
下面逐个拆解。
怎么做:动量特征的完整构造方法
1. 不同周期的收益率:最基础的动量
这是动量特征最直接的形态。用不同时间窗口的收益率,捕捉不同尺度的趋势。
// python
import pandas as pd
import numpy as np
import tushare as ts
# 获取数据
pro = ts.pro_api('你的token')
df = pro.daily(ts_code='000001.SZ', start_date='20200101', end_date='20260830')
df = df.sort_values('trade_date').reset_index(drop=True)
# ===== 不同周期的简单收益率 =====
# 核心逻辑:过去N天的累计涨跌幅
# 公式:(今天收盘价 - N天前收盘价) / N天前收盘价
df['ret_5d'] = df['close'] / df['close'].shift(5) - 1
# 5日收益率:捕捉最近一周的短期趋势
df['ret_20d'] = df['close'] / df['close'].shift(20) - 1
# 20日收益率:捕捉最近一个月的中期趋势
df['ret_60d'] = df['close'] / df['close'].shift(60) - 1
# 60日收益率:捕捉最近一个季度的中长期趋势
df['ret_120d'] = df['close'] / df['close'].shift(120) - 1
# 120日收益率:捕捉最近半年的长期趋势
df['ret_250d'] = df['close'] / df['close'].shift(250) - 1
# 250日收益率:捕捉最近一年的超长期趋势
# ===== 注意:经典动量因子的"跳过最近1个月"技巧 =====
# Jegadeesh & Titman的经典做法是:
# 用过去12个月的收益率,但跳过最近1个月
# 因为最近1个月存在"短期反转效应"——涨太多的股票短期内可能回调
# 跳过之后,动量信号的预测力更强
df['ret_12m_skip1m'] = df['close'].shift(20) / df['close'].shift(250) - 1
# 分子shift(20):用20天前(约1个月前)的收盘价作为终点
# 分母shift(250):用250天前(约1年前)的收盘价作为起点
# 这样算出来的收益率,覆盖了12个月但跳过了最近1个月
print("不同周期收益率:")
print(df[['trade_date', 'close', 'ret_5d', 'ret_20d', 'ret_60d', 'ret_120d', 'ret_12m_skip1m']].tail(10))
不同周期的含义差异:
•短期(5-20日):捕捉短期趋势和动量,但噪声也大。在A股,5日动量往往带有反转效应——涨太多的短期可能回调
•中期(20-120日):动量效应最强的区间。Jegadeesh和Titman发现的最佳窗口就是3-12个月
•长期(120-250日以上):长期趋势,但也包含了宏观经济周期的影响。信号更稳定但更慢
实战建议:不要只用一个周期。同时构造多个周期的动量特征,让模型自己学习哪些周期的信号更有预测力。
2. 收益率的滚动统计:捕捉趋势的"质量"
单纯的收益率只告诉你"涨了多少",但不告诉你"涨得稳不稳"。同样是过去20天涨了10%,一种是一路上涨,一种是先暴跌再暴涨——两种情况的"动量质量"完全不同。
滚动统计特征就是用来捕捉这些"质量"信息的。
// python
# ===== 滚动均值(平均每日收益率)=====
df['rolling_mean_20'] = df['close'].pct_change().rolling(20).mean()
# 过去20天日收益率的平均值
# 和ret_20d的关系:ret_20d是累计涨跌,rolling_mean是平均每日涨跌
# 后者消除了"起点/终点选择"的影响
# ===== 滚动标准差(波动率)=====
df['rolling_std_20'] = df['close'].pct_change().rolling(20).std()
# 过去20天日收益率的标准差
# 标准差大 → 涨得剧烈、波动大
# 标准差小 → 涨得平稳、波动小
# 同样是涨10%,标准差小的那个"动量质量"更高
# ===== 滚动偏度(不对称性)=====
df['rolling_skew_20'] = df['close'].pct_change().rolling(20).skew()
# 偏度 > 0:大涨的日子比大跌的日子多(右偏)→ 多方占优
# 偏度 < 0:大跌的日子比大涨的日子多(左偏)→ 空方占优
# 偏度告诉你:收益率的分布是对称的吗?
# ===== 滚动最大回撤 =====
def rolling_max_drawdown(series, window):
"""计算滚动最大回撤"""
result = []
for i in range(len(series)):
if i < window - 1:
result.append(np.nan)
continue
window_data = series.iloc[i - window + 1:i + 1]
# 窗口内的最高点
peak = window_data.cummax()
# 每个点相对于最高点的回撤
drawdown = (window_data - peak) / peak
# 最大回撤
result.append(drawdown.min())
return result
df['rolling_mdd_20'] = rolling_max_drawdown(df['close'], 20)
# 过去20天内的最大回撤
# 回撤小 → 一路上涨,趋势健康
# 回撤大 → 中间有大跌,趋势不稳定
# ===== 滚动胜率(上涨天数占比)=====
df['rolling_win_rate_20'] = (
df['close'].pct_change().rolling(20)
.apply(lambda x: (x > 0).sum() / len(x))
)
# 过去20天里,上涨的天数占比
# > 0.6 → 大多数日子都在涨,趋势很强
# < 0.4 → 大多数日子都在跌,趋势很弱
print("滚动统计特征:")
print(df[['trade_date', 'ret_20d', 'rolling_mean_20', 'rolling_std_20',
'rolling_skew_20', 'rolling_mdd_20', 'rolling_win_rate_20']].tail(10))
这些特征回答的是比"涨了多少"更深的问题:
•涨得稳不稳?(滚动标准差)
•涨得对称不对称?(滚动偏度)
•中间有没有大坑?(滚动最大回撤)
•大多数日子是涨还是跌?(滚动胜率)
3. 收益率的横截面排名:相对强弱
前面的特征都是"时间序列"维度的——和股票自己的历史比。但量化交易中,还有一个极其重要的维度——横截面。
横截面特征回答的问题不是"这只股票自己涨了多少",而是"在所有股票里,这只股票排第几?"
// python
# ===== 横截面排名 =====
# 假设我们有多只股票的数据
# 这里用模拟数据演示
# 获取多只股票数据(实际中你需要用pro.daily获取多只股票)
# 为了演示,假设df_multi是一个包含多只股票的面板数据
# columns: ts_code, trade_date, close, return_20d
# 按日期分组,计算每只股票20日收益率的排名
# df_multi['rank_20d'] = df_multi.groupby('trade_date')['ret_20d'].rank(pct=True)
# rank(pct=True):百分位排名,0-1之间
# 0.95 = 排名前5%(最强势的股票)
# 0.05 = 排名后5%(最弱势的股票)
# ===== 单只股票的时序排名替代方案 =====
# 如果你只分析一只股票,可以用"当前收益率在过去N天中的排名"
df['ret_rank_60'] = df['close'].pct_change().rolling(60).rank(pct=True)
# 今天的日收益率,在过去60天里排第几
# 0.9 → 今天的涨幅比过去60天里90%的日子都大
# 0.1 → 今天的跌幅比过去60天里90%的日子都大
# ===== 相对强弱指标(RS)=====
# 用个股收益率 / 指数收益率,衡量个股相对于大盘的表现
# 假设你有沪深300的数据(index_df)
# index_ret_20 = index_df.set_index('trade_date')['close'].pct_change(20)
# 这里用该股票自身的长期vs短期来模拟
df['relative_strength'] = df['ret_20d'] / df['ret_250d']
# 20日收益率 / 250日收益率
# > 1 → 近期表现强于长期趋势 → 加速上涨
# < 1 → 近期表现弱于长期趋势 → 涨势放缓
# < 0 → 长期涨但近期跌 → 趋势可能反转
print("相对强弱特征:")
print(df[['trade_date', 'ret_20d', 'ret_250d', 'relative_strength', 'ret_rank_60']].tail(10))
横截面排名的好处是消除了市场整体涨跌的干扰。大盘暴跌时,所有股票都在跌——横截面排名能找出"跌得最少"的股票,它们可能是最先反弹的。
4. 动量组合特征:更高阶的信号
把上面的基础特征组合起来,可以构造出更有信息量的复合动量特征。
// python
# ===== 动量加速度 =====
# 近期动量 vs 远期动量的差
# 如果近期动量 > 远期动量 → 趋势在加速
# 如果近期动量 < 远期动量 → 趋势在减速
df['momentum_acceleration'] = df['ret_20d'] - df['ret_60d'].shift(20) / 3
# 简化理解:最近20天的动量 减去 之前40天(第21-60天)的平均月动量
# 正值 → 最近涨得比以前快 → 趋势加速
# 负值 → 最近涨得比以前慢 → 趋势减速
# ===== 动量反转比 =====
# 最近5天的动量 和 最近20天的动量方向是否一致
# 一致 → 趋势延续
# 不一致 → 短期反转
df['momentum_reversal'] = np.sign(df['ret_5d']) * np.sign(df['ret_20d'])
# +1:5日和20日方向一致(都在涨或都在跌)→ 趋势延续
# -1:5日和20日方向相反(一个涨一个跌)→ 短期反转信号
# ===== 动量路径一致性 =====
# 用月度收益率的标准差来衡量动量路径的"平滑度"
# 路径越平滑,动量越可靠
# 简化版:把过去60天分成3个20天窗口,比较三个窗口的收益率
df['ret_20d_m1'] = df['close'].shift(0) / df['close'].shift(20) - 1 # 最近20天
df['ret_20d_m2'] = df['close'].shift(20) / df['close'].shift(40) - 1 # 第21-40天
df['ret_20d_m3'] = df['close'].shift(40) / df['close'].shift(60) - 1 # 第41-60天
# 三个月收益率的标准差越小 → 路径越一致 → 动量越可靠
df['momentum_consistency'] = pd.concat(
[df['ret_20d_m1'], df['ret_20d_m2'], df['ret_20d_m3']], axis=1
).std(axis=1)
# 标准差小 → 三个月涨得差不多 → 趋势稳定
# 标准差大 → 一个月暴涨一个月暴跌 → 趋势不稳定
# ===== 成交量加权动量 =====
# 涨的时候成交量大,跌的时候成交量小 → 动量信号更强
# 涨的时候成交量小,跌的时候成交量大 → 动量信号更弱
df['vol_weighted_momentum'] = df['ret_20d'] * (
df['vol'].rolling(20).apply(
lambda x: x[df['close'].pct_change().loc[x.index] > 0].sum() / x.sum()
if x.sum() > 0 else 0.5
)
)
# 收益率 × 上涨日成交量占比
# 上涨日成交量占比高 → 多方力量强 → 动量信号被放大
# 上涨日成交量占比低 → 多方力量弱 → 动量信号被削弱
print("动量组合特征:")
print(df[['trade_date', 'momentum_acceleration', 'momentum_reversal',
'momentum_consistency']].tail(10))
这些组合特征的价值在于:它们不仅看"涨了多少",还看"怎么涨的"——加速涨还是减速涨?一路上涨还是暴涨暴跌?放量涨还是缩量涨?不同的"涨法",预示着不同的未来走势。
5. 完整的动量特征集
把上面的内容汇总,一个完整的动量特征集包含这些特征:
// python
def build_momentum_features(df):
"""
构建完整的动量特征集
输入:包含trade_date, close, vol的日线数据
输出:动量特征DataFrame
"""
features = pd.DataFrame(index=df.index)
features['trade_date'] = df['trade_date']
# ===== 1. 多周期收益率 =====
for period in [5, 10, 20, 60, 120, 250]:
features[f'ret_{period}d'] = df['close'] / df['close'].shift(period) - 1
# 经典动量:12个月跳过最近1个月
features['ret_12m_skip1m'] = df['close'].shift(20) / df['close'].shift(250) - 1
# ===== 2. 滚动统计特征(20日窗口)=====
daily_ret = df['close'].pct_change()
features['rolling_mean_20'] = daily_ret.rolling(20).mean()
features['rolling_std_20'] = daily_ret.rolling(20).std()
features['rolling_skew_20'] = daily_ret.rolling(20).skew()
features['rolling_win_rate_20'] = (
daily_ret.rolling(20).apply(lambda x: (x > 0).sum() / len(x))
)
# ===== 3. 相对强弱 =====
features['relative_strength_20_250'] = features['ret_20d'] / features['ret_250d']
features['ret_rank_60'] = daily_ret.rolling(60).rank(pct=True)
# ===== 4. 组合特征 =====
# 动量加速度
features['momentum_acceleration'] = features['ret_20d'] - features['ret_60d'].shift(20) / 3
# 动量反转比
features['momentum_reversal'] = np.sign(features['ret_5d']) * np.sign(features['ret_20d'])
# 动量路径一致性(三个月收益率标准差)
m1 = df['close'] / df['close'].shift(20) - 1
m2 = df['close'].shift(20) / df['close'].shift(40) - 1
m3 = df['close'].shift(40) / df['close'].shift(60) - 1
features['momentum_consistency'] = pd.concat([m1, m2, m3], axis=1).std(axis=1)
# ===== 5. 特征缩放(滚动标准化,避免未来数据泄露)=====
feature_cols = [c for c in features.columns if c not in ['trade_date']]
for col in feature_cols:
rolling_mean = features[col].rolling(60).mean()
rolling_std = features[col].rolling(60).std()
features[col] = (features[col] - rolling_mean) / rolling_std.replace(0, np.nan)
return features
# 使用
momentum_df = build_momentum_features(df)
print(f"动量特征数量: {len([c for c in momentum_df.columns if c != 'trade_date'])}")
print(f"特征列表:")
for col in momentum_df.columns:
if col != 'trade_date':
print(f" - {col}")
print(f"\n有效样本数: {momentum_df.dropna().shape[0]}")
这个特征集包含了19个动量特征,覆盖了不同时间窗口、不同维度、不同信息层次。实际使用时,你可以根据需要增减。
三个关键注意事项
第一,不同周期的动量特征,预测的是不同的东西。短期动量(5-10日)预测的是短期趋势延续或反转;中期动量(20-120日)预测的是中期趋势延续;长期动量(120-250日)更多反映的是基本面变化。把它们混在一起扔给模型,模型需要足够聪明才能分辨每个特征的用途。
第二,"跳过最近1个月"不是可选的,在A股是必须的。学术研究发现,A股的短期(1个月以内)存在显著的反转效应——最近1个月涨得最多的股票,下个月大概率跑输。所以经典的动量因子要跳过最近1个月,只保留2-12个月的信号。不跳过的话,你构建的"动量因子"实际上混杂了"反转因子",预测力会大幅下降。
第三,动量特征最大的敌人是"极端行情"。市场暴跌的时候,所有股票都在跌,动量排名基本失效。这时候你需要加入市场状态判断——比如用波动率或大盘收益率做一个"市场环境特征",让模型知道当前是不是极端行情。极端行情下,动量信号需要打折。
所以呢?收益率不是一个数字,是一座矿
回到Jegadeesh和Titman的发现:过去涨的股票,未来还会涨。
这个发现看起来简单,但"过去涨了多少"这个信息,有无数种方式可以提取和加工。简单收益率、滚动统计、横截面排名、组合特征——每一种打开方式,都在从历史收益率中提取不同的信息维度。
好的特征工程师,就像一个矿工——他知道矿石里有金子,但他也知道金子不是裸露在地表的。他需要筛选、需要提炼、需要知道哪些矿石含金量高、哪些是废石。
动量特征的构造,本质上就是这种"提炼"工作。
记住三点:
1.多周期、多维度构造:不要只用一个周期的收益率,同时构造短期/中期/长期、时序/横截面、基础/组合
2.跳过最近1个月:在A股做动量因子,经典做法是12个月收益率跳过最近1个月
3.关注动量的"质量"而不只是"幅度":涨得稳不稳、加速还是减速、放量还是缩量——这些"质量"信息往往比单纯的涨跌幅更有预测力
下一篇,讲波动率特征——从历史波动率到已实现波动率,波动率的N种度量方式。