做量化这两年,我最大的感悟是:策略跑不出来,多数时候不是代码的问题,而是你对“超额收益到底从哪里来”的假设太简陋。CAPM用市场收益解释一切,但在A股你会发现,小市值风格强的时候,随便一篮子小盘股都能跑出漂亮的净值,beta却弱得可怜;风格一切换,回撤又全回来了。直到我把Fama-French三因子模型真正用Python在本地数据上完整跑了一遍,才看清收益结构长什么样。这篇文章不绕弯子,直接从因子构造讲到回归实现,再把回测方法和实战中踩过的坑一起倒给你。无论你是刚开始学量化,还是已经在跑多因子组合,这份代码和思路都可以直接拿来当基础框架。
1. 从CAPM到三因子:市场贝塔解释不了的那部分收益
1.1 CAPM的底层假设与软肋
资本资产定价模型(CAPM)的核心就一句话:股票的预期超额收益只跟它对市场组合的敏感程度有关,也就是:
[ E(R_i) - R_f = \beta_i \times (E(R_m) - R_f) ]
它假设所有投资者同质预期、可以无风险借贷、市场没有摩擦,并且所有超额收益都能用一个beta解释。但这些假设在真实市场里几乎都不成立。最明显的问题是,单用beta解释横截面收益时,残差很大。你拿A股的数据去做回归,经常发现beta不显著,alpha却非常显著——这不是说选股能力厉害,而是CAPM漏掉了风格因子。
我最早跑日频回归时,拿某只长期强势的股票做检验,日收益对大盘指数收益回归,beta只有0.7,但alpha高达0.001且检验显著。当时还以为找到了“穿越牛熊”的神器,后来发现它就是一只高账面市值比的价值型小盘股,纯粹是风格收益。没有因子模型做基准,很容易把风格暴露当成能力。
1.2 规模效应和价值效应:三因子模型的经验基础
Fama和French在1992年、1993年的论文里做了大量横截面检验,发现两个规律非常稳定:
- 市值较小的公司,长期平均收益普遍高于市值较大的公司。
- 账面市值比(Book-to-Market,BM)较高的公司,也就是传统意义上的价值股,长期平均收益普遍高于BM较低的成长股。
于是他们把这两个效应量化成两个因子:SMB(Small Minus Big,小市值减大市值组合的收益差)和HML(High Minus Low,高BM减低BM组合的收益差)。加上市场因子,就构成了三因子模型。
为什么会有规模溢价和价值溢价?学界一直有争论。风险补偿派认为小公司盈利波动大、融资约束强,价值股面临财务困境风险,所以高收益是承担风险的补偿;行为金融派则认为投资者过度外推成长股的业绩,导致成长股被高估、价值股被低估。对做量化的人来说,经济解释可以慢慢体会,更重要的是把它当成一种可复现、可回测的风险暴露。
1.3 三因子模型公式与回归方程
三因子模型的完整表达式是:
[ E(R_i) - R_f = \alpha_i + \beta_{mkt} \times (R_m - R_f) + \beta_{smb} \times SMB + \beta_{hml} \times HML + \varepsilon_i ]
注意这里的alpha是扣掉市场、规模、价值三个风格后的剩余收益。如果原来的组合收益能被SMB和HML解释掉,alpha就会变得不显著,说明策略真正赚的是风格钱,而不是模型无法解释的alpha钱。
在实战里,我们一般把再平衡周期设定为每月或每季度一次。但做研究时,为了充分利用日频数据,也可以用日频因子序列做回归。下面第二部分就讲因子序列怎么构造,这是整个流程里最容易出错,也是最核心的一环。
2. 手把手构造SMB和HML:分组方法、数据口径与Python实现
2.1 数据准备:行情、市值和账面市值比从哪里来
要构造因子,最少需要四个字段:日期、股票代码、当日收益率、当日总市值。另外还需要一个用于计算账面市值比的股东权益数据。A股便利的做法是用akshare或tushare这类开源库拉行情和财务数据,也可以直接用本地Wind或聚宽的导出数据。我的建议是无论用什么数据源,先把最底层的数据处理成统一格式:
| 字段 | 含义 | 说明 |
|---|---|---|
| date | 交易日期 | 统一成datetime类型 |
| stock_id | 股票代码 | 例如600000.SH |
| ret | 日收益率 | 必须是用复权价格计算的收益率 |
| mktcap | 总市值 | 单位为元 |
| bm | 账面市值比 | 股东权益 / 总市值 |
股票列表记得剔除上市不足一年、长期停牌、以及退市股票的缺失区间。这里最容易忽略的是退市股票——如果只保留当前正常上市的公司,后面的回测一定会带幸存者偏差,历史收益被系统性高估。
2.2 月度调仓与2x3分组逻辑
Fama-French原文的构造方法可以简化总结为“2x3分组”。先按市值大小分成两组:小市值(S)和大市值(B),分界点用中位数。再按账面市值比分成三组:低BM(L,成长股)、中BM(M)、高BM(H,价值股),分界点用30%和70%分位数。
这样就得到六个组合:SL、SM、SH、BL、BM、BH,前一位是规模,后一位是价值分组。所有组合收益都采用市值加权,也就是组合内每只股票按市值占比加权平均。
然后按如下公式计算SMB和HML:
[ SMB = \frac{SL + SM + SH}{3} - \frac{BL + BM + BH}{3} ]
[ HML = \frac{SH + BH}{2} - \frac{SL + BL}{2} ]
SMB的含义是:在三个BM组内部,小市值组合相对大市值组合的收益差,然后取平均,剥离掉价值风格的影响。HML的含义同理:在大小市值两个组内部,高BM组合相对低BM组合的收益差,取平均,剥离掉规模风格的影响。
2.3 计算市场因子、SMB和HML的Python代码
下面这段代码是核心构造逻辑。假设你已经把数据清洗成前面表格里的格式,放在DataFramedf里:
import pandas as pd import numpy as np def compute_factors(df, rf_daily=0.02 / 252): df = df.dropna(subset=['ret', 'mktcap', 'bm']).copy() # 1. 用日期分组计算市值排名和BM排名 df['size_rank'] = df.groupby('date')['mktcap'].rank(pct=True) df['bm_rank'] = df.groupby('date')['bm'].rank(pct=True) # 2. 市值分组:<=50% 为小市值 S,>50% 为大市值 B df['size_group'] = np.where(df['size_rank'] <= 0.5, 'S', 'B') # 3. BM分组:<=30% 为 L,30%-70% 为 M,>70% 为 H df['bm_group'] = np.where( df['bm_rank'] <= 0.3, 'L', np.where(df['bm_rank'] <= 0.7, 'M', 'H') ) # 4. 组合标签 df['port'] = df['size_group'] + df['bm_group'] # 5. 市值加权组合收益 def vw_return(g): w = g['mktcap'] / g['mktcap'].sum() return float(np.dot(g['ret'], w)) port_ret = df.groupby(['date', 'port']).apply(vw_return).unstack() # 6. 市场收益:全市场市值加权(实际研究建议用t-1市值做权重) df['mkt_w'] = df['mktcap'] / df.groupby('date')['mktcap'].transform('sum') mkt_ret = df.groupby('date').apply( lambda g: float(np.dot(g['ret'], g['mkt_w'])) ) mkt_ret = pd.Series(mkt_ret, index=port_ret.index, name='MKT') # 7. 构造因子 factors = pd.DataFrame(index=port_ret.index) factors['MKT'] = mkt_ret - rf_daily factors['SMB'] = ( port_ret[['SL', 'SM', 'SH']].mean(axis=1) - port_ret[['BL', 'BM', 'BH']].mean(axis=1) ) factors['HML'] = ( port_ret[['SH', 'BH']].mean(axis=1) - port_ret[['SL', 'BL']].mean(axis=1) ) return factors, port_ret factors, port_ret = compute_factors(df)有几个点要强调。第一,vw_return用的是当日市值做权重,严格来说会有未来函数,因为当日收益率是用当日收盘价算的,而当日市值也是收盘市值。更严谨的做法是把上一期市值作为权重。对日频数据,我一般会先按股票和日期排序,用groupby('stock_id')['mktcap'].shift(1)生成前一日市值,再算加权收益。第二,如果某个组合当天没有股票,port_ret会出现NaN,最好用全市场等权收益补上,否则因子序列会断。
2.4 几个必须处理的口径细节
第一个是财务数据的时点匹配。账面市值比里的股东权益,必须使用已经公开披露的财报数,不能用未来数据。A股上市公司年报一般在次年前四个月发布,所以每年5月1日之后再使用上年年报数据,是相对保守且安全的做法。如果非要提高时效性,可以用一季报或三季报,但一定要在财报发布日期之后才允许进入样本。
第二个是复权。股票价格在除权除息后会有跳空,如果直接用不复权价算收益率,日收益率会出现巨大负值。研究用后复权价计算日收益比较稳妥,因为后复权价能保证历史价格连续。前复权价虽然看盘方便,但新股上市时可能出现负价格,不太适合批量计算。
第三个是ST、停牌和极端值。ST股票经常连续涨跌停,流动性差且交易规则受限,一般建议剔除。停牌股当天没有成交,收益率应该填0而不是NaN,或者直接删掉当天记录。极端收益率通常用缩尾(winsorize)处理,比如把每天收益率在1%和99%分位数之外的样本拉回到边界值。
3. 回归建模与结果解读:为什么我的alpha突然不显著了
3.1 选好被解释对象
因子构造好之后,接下来要回答的核心问题是:某个策略组合的收益,到底有多少来自三因子暴露,多少来自真正的alpha。
被解释对象可以是单个股票,也可以是某个组合。我更建议先用组合而不是个股做回归,因为个股的噪声太大,回归R方经常不到0.1,看不出规律。比如你有一个十只股票的小组合,每天计算组合的等权或市值加权收益率,然后统一减去无风险利率,得到excess_ret,再和前面构造的因子序列对齐。
对齐时最常出现的问题是日期错位。因子序列用的是交易日,组合收益可能有几天因为停牌或新加入股票导致缺失。用pd.concat([excess_ret, factors], axis=1).dropna()是最简单的做法,但要小心别把连续几天的数据都删光,尤其是加密货币那种非交易日和数据源不一致的场景。
3.2 用statsmodels跑OLS回归,并做Newey-West调整
金融时间序列的回归残差通常有自相关和异方差,直接使用普通最小二乘的标准误会低估真实波动。statsmodels提供了HAC(异方差自相关一致)标准误选项,实操中我用Newey-West调整,滞后阶数通常取5或10。
import statsmodels.api as sm # factors 是前面构造的三因子日频序列 # excess_ret 是要检验的组合超额收益 data = pd.concat([excess_ret, factors], axis=1).dropna() y = data['excess_ret'] X = sm.add_constant(data[['MKT', 'SMB', 'HML']]) model = sm.OLS(y, X).fit( cov_type='HAC', cov_kwds={'maxlags': 5} ) print(model.summary())看到输出后,第一件事看alpha的t值。t值的绝对值小于2,通常说明alpha在统计上不显著;t值显著且系数为正,才说明策略有模型无法解释的正超额收益。第二件事看系数符号。beta_mkt一般应在0.8到1.2之间,如果偏离太多,说明组合本身风格很极端。beta_smb为正,表示组合偏向小市值;beta_hml为正,表示组合偏向价值股。第三件事看整体R方。如果R方在0.8以上,说明组合收益绝大部分能被三因子解释,这时候还想追求超额alpha,难度就很大。
3.3 一张回归结果表怎么读
我拿一段A股数据做过一个虚拟组合的回归,结果大概是下面这样:
| 变量 | 系数 | 标准误 | t值 | P值 |
|---|---|---|---|---|
| const | 0.0004 | 0.0003 | 1.33 | 0.185 |
| MKT | 0.95 | 0.02 | 47.50 | 0.000 |
| SMB | 0.68 | 0.05 | 13.60 | 0.000 |
| HML | 0.21 | 0.04 | 5.25 | 0.000 |
R方0.84,F统计量显著。这个结果的意思是:组合基准收益对市场很敏感,同时大幅暴露在小市值上,中等程度暴露在价值风格上。alpha不显著,说明组合所谓的超额收益很可能来自小市值和价值暴露,而不是选股能力。真正做策略评审时,我会同时输出CAPM单因子回归和三因子回归的对比,这样能很直观地看到加入SMB和HML之后,alpha从显著变成不显著的过程。
3.4 因子共线性问题:VIF与正交化
三因子内部也可能存在相关性,尤其是市场因子和SMB。A股历史上小市值和整体行情经常同涨同跌,导致MKT和SMB的相关性忽高忽低。回归前先看一下相关矩阵,然后算方差膨胀因子:
from statsmodels.stats.outliers_influence import variance_inflation_factor factor_cols = ['MKT', 'SMB', 'HML'] Xv = data[factor_cols].copy() Xv['const'] = 1 vif = pd.Series( [variance_inflation_factor(Xv.values, i) for i in range(len(factor_cols))], index=factor_cols ) print(vif)VIF一般小于5都在可接受范围内。如果某个因子VIF超过10,就要考虑是不是数据里有极端值或者样本期太短。更彻底的解法是对因子做正交化处理,用SMB和HML分别对市场因子回归取残差,但我个人不推荐一上来就正交化,因为正交化会改变因子的经济含义,而且不同顺序的正交化结果完全不同。
4. 滚动窗口与多空组合回测:三因子在A股的稳定性检验
4.1 为什么单次回归不够
金融市场不是稳态。一个组合的因子暴露和因子收益都会随着宏观环境、投资者结构、监管制度变化。单次拿全样本回归得到的平均暴露,很可能会掩盖2017年之前小市值持续占优、2017年之后核心资产占优的风格切换。所以我会把样本切成滚动窗口,比如每次用过去252个交易日(约一年)做回归,然后往前滚动21天,得到一条alpha和因子系数的时序曲线。
这个做法的目的不是为了拟合更好看,而是为了观察三点:
- 因子系数的符号有没有发生过翻转。
- alpha在哪个时间段突然变显著,背后对应什么市场状态。
- 组合的“打法”是否始终如一。
4.2 滚动窗口回归的Python代码
代码逻辑不复杂,核心就是循环切片:
rolling_results = [] for end in range(252, len(data) + 1, 21): window = data.iloc[end - 252:end] y = window['excess_ret'] X = sm.add_constant(window[['MKT', 'SMB', 'HML']]) model = sm.OLS(y, X).fit() rolling_results.append({ 'end_date': window.index[-1], 'const': model.params['const'], 'mkt': model.params['MKT'], 'smb': model.params['SMB'], 'hml': model.params['HML'], 'r2': model.rsquared, }) df_rolling = pd.DataFrame(rolling_results).set_index('end_date')跑完以后,我会先画三条系数曲线,再画alpha系数的t值曲线。如果t值长时间超过2,可能是市场结构变化,也可能是被解释组合自身漂移了。比如一个持仓中小盘成长的策略,在行情转向价值风格后,hml系数会从负转正,同时alpha可能一落千丈。
4.3 用SMB/HML构造多空组合
三因子里的SMB和HML本身就是多空组合的收益序列,可以直接作为策略回测。比如SMB就是做多小市值组合、做空大市值组合的每日收益。做多空组合的累计收益曲线,能直观看出某种风格在历史上的赚钱效应。
cum_smb = (1 + factors['SMB']).cumprod() cum_hml = (1 + factors['HML']).cumprod() # 你可以继续计算年化、夏普、最大回撤 annual_return = cum_smb.iloc[-1] ** (252 / len(cum_smb)) - 1 sharpe = factors['SMB'].mean() / factors['SMB'].std() * np.sqrt(252) max_drawdown = (cum_smb / cum_smb.cummax() - 1).min()回测结果出现大幅波动时,不要急着下结论“因子失效”。先拆成时间段看,尤其在A股,小市值因子历史上长期有效,但在2017年和2021年之后出现过明显的回撤期。用三因子模型做基准和做风格轮动是两回事,前者只需要识别暴露,后者还要预测风格切换,难度完全不在一个量级。
4.4 从回测结果反推模型问题
如果回测里SMB多空收益年化很高,但只在特定年份集中贡献,大概率是因为A股散户占比高、壳价值长期存在,小市值因子里有大量“垃圾股溢价”。这类因子在注册制推进、退市常态化之后,有效性会下降。更合理的做法是检查多空组合每个月的胜率和相对大市值组合的超额收益分布,而不是只看累计净值。
5. 最容易翻车的五个细节:财务滞后、复权、共线性和幸存者偏差
5.1 财务数据时间滞后——前视偏差
这是新手最容易踩的坑。账面市值比里的“账面价值”取哪个季度的财报,必须严格滞后。我见过有人直接把最新财报的股东权益和当日股价合并,结果模型历史回测极好,实盘却崩了,原因就是用了未来数据。正确做法有两种:一是每年5月1日之后统一使用上年年报数据,中间不更新;二是用merge_asof按财报发布日期做向后匹配,保证当前交易日在财报公布之后才使用新数据。
5.2 复权问题对因子收益的影响
如果你用不复权价算日收益率,遇到除权除息日会出现虚假的-10%或-30%收益,市值加权组合可能会因此产生巨大回撤。用前复权价算历史收益率,则存在未来复权因子的问题——每次除权后,整体历史价格都会重新调整,导致同一段历史收益在不同时间算出来不一样。所以我更推荐用后复权价计算收益率序列,因为它不改变历史价格相对关系,而且每次除权只是向后延续,研究一致性更好。
5.3 极端值和NaN怎么处理
市值、BM和收益率都会有极端值。市值和BM最好先取对数,再按MAD(中位数绝对偏差)缩尾,避免几家超大盘公司或财务指标异常的公司主导分组。收益率极端值通常是因为数据源出现了除权价格未修正、停牌后复牌补跌等情况,缩尾阈值为1%到99%分位数已经够用。处理NaN时,组合日收益最好别用0填充,而是用全市场等权收益临时顶替,否则会低估真实波动。
5.4 幸存者偏差:隐形收益放大器
做多因子研究时,如果用“当前还在交易的股票”回溯到五年前,那么已经退市的股票就完全消失了。而退市的往往是亏损、财务造假或经营恶化的股票,它们的负收益没被统计进去,历史策略收益自然会被高估。解决方法是:在每一个历史时点,使用当时还存续的全部股票,包括后来退市的股票,直到它的最后一个交易日。很多公开数据源不方便拿退市股历史数据,但量化研究里这一步不能省。
5.5 因子收益和策略收益不能循环论证
最后说一个逻辑问题。如果你用三因子模型去检验一个“小市值价值策略”,而这个小市值价值策略本身就是用构成SMB和HML的同一批股票选出来的,那回归结果当然显显著。这不是模型有问题,而是你的检验对象和因子之间有天然重叠。更严谨的做法是用样本外股票或剔除了重合股票后的组合做检验,或者先设定好规则再跑因子,不要反复调参直到回归显著。
我在实际项目中已经把三因子回归作为所有股票多头策略的标配基准。每次调完仓位,先输出三因子回归摘要,再决定是否继续优化alpha。如果你也想复现,建议先用一个小样本跑通流程,比如取50只股票、一年日频数据,构造因子加回归,然后再扩大到全市场。分组代码、回归代码都可以直接复用,最容易出现问题的永远不是算法,而是数据口径。祝顺利。