说实话,刚开始接触量化的时候,我觉得搞懂策略逻辑、会写点Python跑通回测,就已经算是入门了。直到真的开始拿着真金白银去检验一个选股逻辑,才发现自己缺的不是那几行买卖代码,而是对风险来源的理解。你看一只股票涨了,你很难说清楚它到底是涨对了,还是只是恰好处在某一个风格的风口上。后来接触到Fama-French三因子模型,慢慢地才开始明白,怎么从收益里面拆出市场和风格的影响,怎么判断一个策略到底是真的有alpha,还是单纯暴露在了小市值或者价值风格里。
这篇博文就把我自己从理论到代码,再到实际用Python做A股三因子实证的全过程整理出来了。里面会有模型的数学表达,也有我自己踩过的坑,比如对齐财务数据时日期怎么处理、为什么小市值股票的数据总是少一截、回归跑出来R²虚高是怎么回事。内容不绕弯子,尽量把每一步为什么这么做讲清楚,你能跟着思路完整地把三因子模型从零搭起来,并且能看懂回归结果想告诉你什么。
1. 为什么是三因子:从CAPM到多因子模型的演进逻辑
先聊个直白的问题:为什么大家都说CAPM不够用了,才催生出三因子模型?你如果只用市场因子来解释股票收益,就会遇到一个很尴尬的情况——A股的很多股票组合,算出来的超额收益alpha显著不为零,而且这种alpha的来源往往说不清道不明。比如你纯粹买一批小市值股票,在很长一段时间里它CAPM的alpha是显著为正的,但你心里清楚,这根本不是你基金经理有多牛,而是小市值风格本身就是一种系统性风险暴露。
Fama和French在1993年那篇经典论文里干的事,就是把这种说不清道不明的风格暴露给量化出来。他们发现,除了市场因子之外,**规模(Size)和账面市值比(Book-to-Market)**这两个维度,能够解释大量CAPM无法解释的横截面收益差异。小公司股票长期平均收益更高,高账面市值比(也就是价值股)股票长期平均收益也更高,这两种现象在全球主要市场里都反复出现。于是他们把这两个维度拆成了SMB和HML两个因子,加上市场超额收益MKT,就构成了三因子模型的核心框架。
1.1 三个因子的经济学含义
你去看Fama-French三因子模型的时候,很容易被那一堆SMB、HML的缩写搞晕。其实拆开来看并不复杂:
- MKT(市场因子):这个最好理解,就是大盘整体涨跌带来的收益,实操中用市场组合收益率减去无风险利率。它衡量的是你在市场中承担系统性风险获得的补偿。
- SMB(规模因子,Small Minus Big):代表做多小市值股票、做空大市值股票的组合收益。如果你买入市值较小的公司,长期来看确实承担了额外的风险,比如流动性差、抗风险能力弱,市场对这部分风险是有补偿的。SMB为正,说明小市值风格正在跑赢大盘股。
- HML(价值因子,High Minus Low):代表做多高账面市值比股票、做空低账面市值比股票的收益。高账面市值比的公司通常市盈率低、市净率低,也就是大家常说的价值股;低账面市值比是成长股。HML为正,说明价值风格占优。
这三者的关系可以用一句话概括:你不光是因为买了股票赚钱,还因为买的是小公司、买的是价值风格而赚钱。三因子模型把这两类风格收益从总收益里剥离出来,剩下的才是真正的“本事”。
1.2 为什么这套模型在A股一样管用
很多人会问,Fama-French是用美股市场几十年的数据验证出来的,拿到A股还能不能打?我可以直接说结论:能打,但细节上有出入。A股市场的散户占比高,市场情绪的定价噪音大,小市值效应在相当长的历史区间里表现得比美股还要强,早期做小市值轮动的策略,收益来源基本就是大量暴露在SMB因子上。但价值因子(HML)在A股的稳定性不如美股,有些年份成长风格会长期压制价值风格,这时候三因子模型的解释力会阶段性下降。
所以在A股做实证的时候,你要明白模型是工具、不是教条。它最核心的贡献不是给你一个“稳赚”的公式,而是提供一套分解收益的框架,让你搞清楚自己赚的是市场钱、风格钱还是真本事的钱。
2. 数据准备:实证前最容易翻车的一环
我见过不少人,代码写得很炫,但最后结果跑出来一团乱麻,一查全是数据预处理出了问题。三因子模型的实证所需的输入数据不算复杂,但每一类都有坑。你需要四类数据:个股月度收益率、个股总市值、个股账面市值比(需要财务报表数据)、市场指数收益以及无风险利率。
2.1 数据库选型:别自己硬爬数据
做学术或业余研究,我不建议自己从网页到处抓数据,效率低,而且财务数据对齐很容易出错。我自己常用两个方案:
- Tushare Pro:积分门槛有等级,但基础数据基本够用,接口返回结构化数据,文档还算友好。
- AkShare:完全免费,接口非常丰富,A股数据覆盖很全。缺点是部分接口的稳定性偶尔抽风,你在抓数据的时候要加个重试机制。
我个人偏好在A股实证中用AkShare,主要因为它对财务数据、每日行情、股票列表的覆盖足够完整,而且没有积分门槛限制,新手跑通全流程会比较爽。
2.2 关键预处理:ST、停牌、次新股一个都不能漏
处理A股数据的时候,有几类股票必须先做清洗,不然结果会严重失真:
剔除ST和*ST股票:这些股票有退市风险,涨跌幅限制跟正常股票不一样(5%),而且经常会连续跌停,流动性极差。你如果不剔除,组合里混入ST股,算出来的收益率方差会被拉得非常大,回归结果抖到没法看。
剔除上市不满6个月的次新股:次新股上市初期价格波动极其剧烈,且往往没有足够的月频数据纳入排序,容易成为异常值。一般至少要求股票上市满6个月或者12个月才纳入样本池。
剔除停牌股票:月频数据里如果股票某个月停牌了,当月收益率为0或者NaN,需要统一处理。我的做法是当月交易天数少于5天就直接剔除,不回补。
处理涨跌停无法交易的现实问题:这是A股特有的坑。如果你用日频数据构造月度组合,遇到批量涨停的股票,第二天你按收盘价去买入,实际上根本买不进。处理方式有两种,一种是在计算收益率时用“下一日开盘价”买入,另一种是直接剔除当日涨停的股票。在月频三因子模型里,为了简化我一般会在排序时直接剔除最近一字涨停的股票,保证组合在实操层面是相对可实现的。
举个例子,用AkShare抓月线数据和财务数据,核心代码大概这样:
import akshare as ak import pandas as pd import numpy as np # 拉取A股股票列表,保留必要字段 stock_info = ak.stock_info_a_code_name() # 拉取个股月线数据(这里以平安银行为例) df_month = ak.stock_zh_a_hist(symbol="000001", period="monthly", start_date="20150101", end_date="20231231", adjust="qfq") print(df_month.head())这里有个细节,adjust="qfq"是前复权。计算因子模型的月度收益率时,如果你直接用不复权价格,遇到分红除权缺口,当月的收益率可能会被严重低估。前复权能保证收益率序列的连续性。
2.3 无风险利率的选择
通常用一年期定期存款利率或者国债收益率折算成月度数据。在A股实证中,很多人会直接用10年期国债收益率年化除以12作为月度无风险利率。你也可以不纠结,因为对回归结果的影响极其有限,但参数别不填。
3. 核心细节:SMB和HML的计算方法
重头戏来了。三因子模型里最难理解、也最容易算错的就是SMB和HML的构造。Fama-French原始论文里的做法是2x3分组,也就是把股票池按中位数分为大小两个规模组,再按账面市值比的30%和70%分位数分成三组(低、中、高),实际交叉形成6个组合。
3.1 分组构造的完整流程
我按自己的实操流程一步步拆解:
第一步:确定时间节点。每年6月底进行一次分组。为什么是6月底?因为此时上一年的年报基本披露完毕,但你还没拿到当年的半年报数据,这样可以避免前视偏差。这个细节极其重要,也是新手最容易忽略的——如果你用了未来才公布的财务数据来分组,那模型就是“作弊”,回测结果毫无意义。
第二步:计算市值。用6月底(实际是当年6月最后一个交易日)的总市值作为规模指标。
第三步:计算账面市值比(BM)。这里有个严格的时间对齐要求。账面价值要使用上一年度的年报数据,然后用上一年12月底的总市值来算BM。也就是说,在t年6月分组时,市值用t年6月底的,账面市值比用t-1年末的市值和t-1年末的净资产。
同样一句话解释:市值是“新”的,账目是“旧”的。两者有一个会计年度的时间差,这是为了保证不是用未来信息做决策。
第四步:正式分组。
- 先按市值大小,以中位数为界,把股票分成小盘(S)和大盘(B)两组。
- 再按账面市值比的分位数(30%和70%),把股票分成低(L)、中(M)、高(H)三组。
交叉组合形成6个组合:S/L、S/M、S/H、B/L、B/M、B/H。每个组合按市值加权计算月度收益率。
第五步:计算因子收益率。
SMB的计算核心是“剥离规模影响”:
[ SMB = \frac{(S/L + S/M + S/H)}{3} - \frac{(B/L + B/M + B/H)}{3} ]
这个公式的含义可以这样理解:SMB是三个小盘组合的平均收益率减去三个大盘组合的平均收益率,这样在比较时,账面市值比的高低已经被平均掉了,剩下的纯粹是大小盘风格的差异。
同理,HML的计算核心是“剥离价值影响”:
[ HML = \frac{(S/H + B/H)}{2} - \frac{(S/L + B/L)}{2} ]
用高账面市值比组合的平均收益率减去低账面市值比组合的平均收益率,规模的影响被抵消,剩下的就是价值风格溢价。
矩阵分组计算的方式在实操中有个好处:交叉分组能更好地控制因子之间的相关性,让SMB和HML的解释力相对独立。这也是Fama和French后来一直坚持用交叉分组的原因。
3.2 加权方式:等权还是市值加权
选市值加权还是等权,会影响因子收益率的数值。我自己的建议是做实证研究时优先用市值加权,因为市值加权更贴近真实可投资的组合——你按市值加权构造组合,实际上是可复制的,大市值股票占更多仓位,交易成本也更可控。等权组合在小市值股票上的暴露偏高,回测收益看起来更好看,真金白银操作时冲击成本会让你想哭。
但反过来说,如果你是在检验因子是否存在横截面定价关系,等权组合有时能捕捉到更纯粹的因子收益,因为市值加权会让少数超大盘股主导组合。如果你条件允许,可以两个加权方式都算一遍,看结论是否稳健。
4. Python实战:完整落地三因子模型
前面讲的都是思路和原理,这一节放完整可跑的代码。我默认你会用Pandas处理DataFrame,不会也没关系,跟着注释也能看明白思路。
4.1 整体流程设计
整个实证流程可以分成六个模块,按顺序执行,每一步的输出都是下一步的输入:
- 获取全部A股月度收益率、市值、财务数据
- 数据清洗(剔除ST、停牌、上市未满N月等)
- 每年6月底分组,构造6个组合
- 计算组合月度收益率,合成SMB和HML因子收益序列
- 用回归检验个股或组合收益能否被三因子解释
- 输出结果并可视化
4.2 因子计算核心代码
这是最关键的一段,代码我做过精简,但逻辑完整,数据结构需要你根据自己的数据源做调整。
import pandas as pd import numpy as np def calc_fama_french_factors(monthly_ret, market_cap, bm_ratio, ret_market, rf): """ monthly_ret: DataFrame, index为日期(月末), columns为股票代码, 值为月度收益率 market_cap: DataFrame, 每年6月末市值 bm_ratio: DataFrame, 上一年度BM值(与monthly_ret对应时间对齐) ret_market: Series, 市场指数月度收益率 rf: Series, 月度无风险利率 """ factors = pd.DataFrame(index=monthly_ret.index) factors['MKT'] = ret_market - rf smb_list = [] hml_list = [] for dt in monthly_ret.index: # 判断是否为6月末,如果是则重新分组 if dt.month == 6: # 取该时点的市值、BM值 mcap = market_cap.loc[dt] bm = bm_ratio.loc[dt] # 剔除空值 valid = mcap.notna() & bm.notna() & monthly_ret.loc[dt].notna() mcap = mcap[valid] bm = bm[valid] ret = monthly_ret.loc[dt][valid] # 大小规模分组 median_size = mcap.median() size_small = mcap <= median_size size_big = mcap > median_size # 账面市值比三分组 bm_30 = bm.quantile(0.3) bm_70 = bm.quantile(0.7) bm_low = bm <= bm_30 bm_mid = (bm > bm_30) & (bm < bm_70) bm_high = bm >= bm_70 # 构造6个组合的股票集 combos = { 'SL': size_small & bm_low, 'SM': size_small & bm_mid, 'SH': size_small & bm_high, 'BL': size_big & bm_low, 'BM': size_big & bm_mid, 'BH': size_big & bm_high, } # 计算组合月度收益(市值加权) combo_ret = {} for name, mask in combos.items(): selected_ret = ret[mask] selected_mcap = mcap[mask] if selected_mcap.sum() > 0: combo_ret[name] = (selected_ret * selected_mcap).sum() / selected_mcap.sum() else: combo_ret[name] = np.nan current_smb = np.nanmean([combo_ret['SL'], combo_ret['SM'], combo_ret['SH']]) - \ np.nanmean([combo_ret['BL'], combo_ret['BM'], combo_ret['BH']]) current_hml = np.nanmean([combo_ret['SH'], combo_ret['BH']]) - \ np.nanmean([combo_ret['SL'], combo_ret['BL']]) else: # 非6月沿用上次分组,重新计算组合收益 # (此处省略重复组合构造代码,实际需把前一步分组结果保存下来) pass smb_list.append(current_smb) hml_list.append(current_hml) factors['SMB'] = smb_list factors['HML'] = hml_list return factors这段代码重点在于6月底重新分组的动态逻辑,另外附一句:np.nanmean可以自动忽略NaN,避免某个组合没有股票时报错。
4.3 组合收益的时序回归
有了因子序列之后,就可以做最核心的验证了。假设你有一个“被解释对象”——可以是某只股票的超额收益,也可以是一个策略组合的超额收益。回归方程是:
[ R_{it} - R_{ft} = \alpha_i + \beta_{i,MKT} \cdot MKT_t + \beta_{i,SMB} \cdot SMB_t + \beta_{i,HML} \cdot HML_t + \varepsilon_{it} ]
alpha就是三因子无法解释的超额收益。如果因子模型是完备的,alpha应该不显著异于零。用statsmodels跑一下:
import statsmodels.api as sm # 假设y是某组合的超额收益序列 X = factors[['MKT', 'SMB', 'HML']] X = sm.add_constant(X) y = portfolio_excess_return # 需要你自己构造 model = sm.OLS(y, X).fit() print(model.summary())回归结果里你要重点看的几个数字:
- R²:三因子模型的整体解释力,一般组合层面做到0.85以上就很不错了。组合R²不高,说明你的组合收益来源可能不在这个模型框架内,要么有真正的alpha,要么有其他风格暴露。
- t值:系数的显著性。SMB和HML的t值绝对值大于2,可以认为该因子对收益有显著解释力。
- alpha和它的t值:alpha的t值极低(比如在±2之间),说明三因子能完全解释这个组合的收益,不存在显著异常收益。
4.4 分年度滚动回归
有一个新手很容易犯的错误:拿全样本一次性回归,发现R²挺高,就以为模型成立。但金融数据是有时变性的,因子的解释力在不同年份差别很大。比如A股2017年之后,大盘价值风格持续跑赢,HML因子的解释力会明显增强;但在2013到2015年的成长股大牛市里,SMB才是绝对的主宰。
我会习惯做滚动回归:每36个月一窗口,每次滚动12个月,看因子载荷怎么变化。这样做能直观看到组合的风格漂移,判断你的策略到底是靠什么吃饭的。
rolling_result = {} for end in range(36, len(X)+1, 12): X_window = X.iloc[end-36:end] y_window = y.iloc[end-36:end] model = sm.OLS(y_window, X_window).fit() rolling_result[X.index[end-1]] = model.params跑出来之后你可以画一张滚动系数图,瞬间就能看出你的策略在哪个时段开始集中暴露在某一类风格上。这一招在策略诊断中特别实用。
5. 结果解读与因子画像
回归跑完了,不能只看几个数字就完事,要学会“读”因子。
5.1 怎么理解因子载荷
因子载荷(系数)反映的是组合对特定风格的敏感程度。比如:
- 如果你的组合对SMB的载荷是0.7,说明组合参照小市值风格运行,小市值因子每涨1%,你的组合预期涨0.7%。这不是alpha,而是风格贡献。
- 对HML的载荷是负值,说明组合偏成长风格。买了成长型股票的朋友要心里有数,你赚的钱里有一部分是“高风险成长风格”的承担补偿,不是因为你眼光独到。
理解了这一点,再看自己组合的表现时,思路会清晰很多。回测收益高,先把因子贡献剥掉,剩下来的才是策略本身的决策贡献。我见过不少人的“好策略”,拆完之后发现其实就是满仓干小市值,去掉SMB的贡献后跟银行理财比也没什么优势。
5.2 A股因子收益的时序特征
我把自己跑过A股数据的经验列一下(基于过去十几年的日频数据按月构造),方便你有个预期参考:
| 时段特征 | SMB表现 | HML表现 | 主导风格 |
|---|---|---|---|
| 2013-2015 | 极强 | 明显为负 | 小盘+成长 |
| 2016-2017 | 持续为负 | 转正走强 | 大盘蓝筹+价值 |
| 2019-2020 | 略偏负 | 偏负 | 成长核心资产 |
| 2021-2023 | 明显为正 | 波动加大 | 小盘量化+价值回归 |
这不是教科书里会写的东西,而是我自己用三因子模型跑A股时序时直观感受到的表象。你会发现A股的风格切换非常剧烈,三因子模型的解释力也存在“阶段性失效”——风格切换的时候,模型误差会大幅扩大。所以用这个模型做归因分析的时候,务必结合市场的风格环境一起看,不要机械套用。
6. 常见问题与排查技巧实录
以下是实操中最常踩的几个坑,我按出现频率排序。
6.1 财务数据的前视偏差
这是模型结果“好得假”的最常见原因。如果你在t年6月底分组时用了当年一季报甚至当年的预测数据,实际上就是用未来信息选股票。回测结果再漂亮,实盘也复制不了。一个简单粗暴的校准规则:6月底分组,账面数据只能用上一年年报。宁愿少一点信息,也要保证没有前视污染。
6.2 幸存者偏差
很多公开数据源默认会把已退市的股票从历史列表中剔除,这样你回测出来的股票池只有活到今天的股票,等于“站在上帝视角”筛掉了一堆后来死掉的股票。处理方式是在数据获取阶段,把所有出现过但已退市的股票也纳入历史样本。具体在AkShare中,可以用退市股列表接口补上这些代码,再跟正常股票列表拼接起来做全样本计算。
6.3 计算效率太慢怎么优化
如果你股票池有4000只股票,月频回测20年,用纯Pandas循环计算,每次分组都要重新切片、算市值加权,循环下来会让电脑卡得想砸键盘。我的做法是:
- 把每月数据预聚合成长格式(长表),一条记录是“股票-月份-市值-BM-收益率”,分组时用
groupby('month')+布尔索引批量处理,效率提升不止一个量级。 - 如果数据量更大,可以用
numba加速市值加权的计算部分,但大部分场景其实用不到。
6.4 回归结果R²很高,但因子载荷不显著
有可能是共线性问题。虽然SMB和HML的设计初衷是降低相关性,但在某些时间窗口内,小市值和价值风格可能同时占优,导致两个因子相关性升高。检查方法很直接:看方差膨胀因子(VIF),如果超过10就说明有共线性。
from statsmodels.stats.outliers_influence import variance_inflation_factor X_with_const = sm.add_constant(factors[['MKT', 'SMB', 'HML']]) vif_data = pd.DataFrame() vif_data["feature"] = X_with_const.columns vif_data["VIF"] = [variance_inflation_factor(X_with_const.values, i) for i in range(X_with_const.shape[1])] print(vif_data)实际遇到共线性也不用慌,可以看相关系数矩阵,如果SMB和HML的相关系数绝对值超过0.5,最好还是在解读结果时特别留意,或者改用正交化的因子构造方式。
6.5 停牌股和涨跌停如何处理
前面数据部分提过,这里再强调一遍排序逻辑:构造组合时如果某只股票当月流动性极差,比如停牌超过半个月,应该从当期组合中剔除。不要用那种“账面上很好但实际买不进”的股票去填充组合权重,这样回测才是真金白银能做出来的。
7. 三条延伸到实盘的经验
代码能跑通、结果能解释,只是第一步。把三因子模型真正用到决策辅助,我还有几条个人体会。
第一,因子的方向会变,不要死扛。SMB在A股长期可能是有效因子,但“长期有效”不意味着“时时刻刻有效”,中间可能夹着好几年连续跑输。如果你的策略风格和当前市场环境明显相悖,要考虑降低暴露,而不是硬扛。
第二,因子模型是归因工具,不是预测模型。它可以告诉你钱是从哪里来的,但没办法精确预测下周哪个因子跑赢。有些人拿它做选股排序,试图买SMB因子暴露最高的股票,这其实是用错了工具。因子模型更多是用于控制风险和风格诊断。
第三,数据质量永远比模型复杂程度重要。我见过有人用极其复杂的机器学习模型做量化选股,结果数据里满是幸存者偏差和前视偏差,模型再高级也是垃圾进垃圾出。三因子模型的逻辑足够简单清晰,反而是验证数据质量的一把标尺——如果你拿到一份数据,跑出来因子收益率和公开研究差异巨大,那多半不是模型错了,而是数据有问题。
现在这套代码我还在持续维护,每次跑新的策略组合归因,三因子模型都是我的第一个落点。先把风格收益剥干净,再谈真正的决策能力。希望这篇完整的解析和Python实现能给你提供一套顺手的分析工具,让你在量化这条路上少踩几个坑。