
说实话刚开始接触量化的时候我觉得搞懂策略逻辑、会写点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(symbol000001, periodmonthly, start_date20150101, end_date20231231, adjustqfq) print(df_month.head())这里有个细节adjustqfq是前复权。计算因子模型的月度收益率时如果你直接用不复权价格遇到分红除权缺口当月的收益率可能会被严重低估。前复权能保证收益率序列的连续性。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(indexmonthly_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实现能给你提供一套顺手的分析工具让你在量化这条路上少踩几个坑。