Fama-French三因子模型在A股市场的Python实战:2009-2019年25个投资组合回归全解析

引言:量化投资的基石模型

在资产定价领域,Fama-French三因子模型犹如一座灯塔,为无数研究者照亮了理解股票收益来源的道路。这个由诺贝尔经济学奖得主Eugene Fama和Kenneth French于1993年提出的模型,不仅颠覆了传统CAPM的单因子框架,更为我们提供了一把解剖市场异象的精密手术刀。

对于A股市场的量化研究者而言,亲手复现这一经典模型具有双重意义:一方面可以验证三因子在本地市场的解释力,另一方面能够掌握从原始数据到因子构建的完整技术链条。本文将使用Python语言,带您完整走通从数据清洗、因子计算到组合回归的全流程,所有代码均基于2009-2019年的A股月频数据,最终构建25个市值-账面市值比双重排序的投资组合进行回归检验。

1. 数据准备与清洗

1.1 原始数据获取与字段说明

构建三因子模型需要以下核心数据表:

  • 股价数据 :包含股票代码、交易日期和复权价格
  • 市值数据 :记录各股票每月末的流通市值
  • 市净率数据 :用于计算账面市值比(BM)
  • ST标记数据 :识别特殊处理股票
  • 上市日期数据 :过滤新股
  • 市场因子数据 :包含无风险利率和市场组合超额收益
# 数据加载示例
import pandas as pd

price = pd.read_csv('price.csv')  # 月度复权价格
mkt = pd.read_csv('mkt_value.csv')  # 流通市值
pb = pd.read_csv('pb_ratio.csv')  # 市净率
st = pd.read_csv('st_list.csv')  # ST股票列表
ipo = pd.read_csv('ipo_date.csv')  # 上市日期
ff_factors = pd.read_csv('market_factors.csv')  # 市场因子

1.2 数据清洗关键步骤

异常值处理 需要特别注意:

  • 剔除BM为负的股票(财务异常)
  • 过滤上市不满一年的新股(价格波动不稳定)
  • 排除ST股票(财务困境)
  • 处理缺失值和极端值
# 数据清洗代码示例
def clean_data(price, mkt, pb, st, ipo):
    # 计算BM比率
    pb['BM'] = 1 / pb['pb']
    
    # 合并数据集
    df = pd.merge(pb[['stockcode','tradedate','BM']], 
                 mkt, on=['stockcode','tradedate'])
    
    # 过滤条件
    df = df[df['BM']>0]  # 剔除负BM
    df = df[df['tradedate'] > ipo['ipodate']+pd.Timedelta(days=365)]  # 过滤新股
    df = anti_join(df, st)  # 剔除ST股票
    
    return df

def anti_join(df1, df2):
    """实现SQL的ANTI JOIN操作"""
    return df1.merge(df2, how='left', indicator=True)\
             .query('_merge == "left_only"')\
             .drop('_merge', axis=1)

2. 因子构建方法论

2.1 市值因子(SMB)构建

SMB(小减大)因子反映规模溢价效应,构建流程如下:

  1. 年度分组 :每年5月末按市值中位数将股票分为大(B)小(S)两组
  2. 月度更新 :保持分组不变直至次年4月
  3. 组合收益 :计算小市值组合与大市值组合的市值加权收益差
def build_smb(df):
    # 每年5月确定分组
    may_data = df[df['tradedate'].dt.month==5].copy()
    may_data['size_group'] = np.where(
        may_data['mkt'] >= may_data.groupby('tradedate')['mkt'].transform('median'),
        'B', 'S')
    
    # 合并分组信息
    df = pd.merge(df, may_data[['stockcode','year','size_group']], 
                 on=['stockcode','year'], how='left')
    
    # 计算组合收益
    port_ret = df.groupby(['tradedate','size_group'])\
                .apply(lambda x: (x['ret']*x['mkt']).sum()/x['mkt'].sum())
    
    # 计算SMB因子
    smb = port_ret.unstack()['S'] - port_ret.unstack()['B']
    return smb

2.2 账面市值比因子(HML)构建

HML(高减低)因子反映价值溢价效应:

  1. 三分位分组 :按BM比率分为高(H)、中(M)、低(L)三组
  2. 组合构建 :H组为前30%,L组为后30%
  3. 因子计算 :高BM组合与低BM组合的收益差

注意:BM比率使用账面价值/市值,因此高BM对应价值股,低BM对应成长股

2.3 市场因子(MKT)处理

市场因子可直接使用市场组合超额收益:

mkt_rf = ff_factors[['trdmn','mkt_rf']].set_index('trdmn')

3. 25个投资组合构建

3.1 双重排序方法论

Fama-French的经典方法是对市值和BM进行双重独立排序:

  1. 市值五分位 :按市值大小分为5组
  2. BM五分位 :按BM高低分为5组
  3. 交叉组合 :形成5×5=25个投资组合
def double_sort(df):
    # 每年5月进行双重排序
    may_data = df[df['tradedate'].dt.month==5].copy()
    
    # 计算分位数
    may_data['size_quintile'] = may_data.groupby('tradedate')['mkt']\
                                      .transform(lambda x: pd.qcut(x, 5, labels=False))
    may_data['bm_quintile'] = may_data.groupby('tradedate')['BM']\
                                    .transform(lambda x: pd.qcut(x, 5, labels=False))
    
    # 合并分组信息
    df = pd.merge(df, may_data[['stockcode','year','size_quintile','bm_quintile']],
                 on=['stockcode','year'], how='left')
    
    # 生成组合名称
    df['portfolio'] = df['size_quintile'].astype(str) + '/' + df['bm_quintile'].astype(str)
    
    return df

3.2 组合收益率计算

对每个组合计算市值加权月收益率:

def calc_portfolio_returns(df):
    # 计算组合市值加权收益
    port_ret = df.groupby(['tradedate','portfolio'])\
                .apply(lambda x: (x['ret']*x['mkt']).sum()/x['mkt'].sum())
    
    # 转换为宽表
    port_ret = port_ret.unstack()
    return port_ret

4. 回归分析与结果解读

4.1 时间序列回归模型

对每个组合进行三因子模型回归:

R_{pt} - R_{ft} = α_p + β_{p,MKT}MKT_t + β_{p,SMB}SMB_t + β_{p,HML}HML_t + ε_{pt}

Python实现代码:

import statsmodels.api as sm

def run_ff_regression(port_ret, factors):
    results = []
    
    for port in port_ret.columns:
        y = port_ret[port]
        X = sm.add_constant(factors)
        model = sm.OLS(y, X).fit()
        
        results.append({
            'portfolio': port,
            'alpha': model.params['const'],
            'mkt_beta': model.params['mkt_rf'],
            'smb_beta': model.params['smb'],
            'hml_beta': model.params['hml'],
            'rsquared': model.rsquared
        })
    
    return pd.DataFrame(results)

4.2 实证结果分析

基于2009-2019年A股数据的回归显示:

组合 Alpha(%) MKT Beta SMB Beta HML Beta
1/1 0.12 0.95 0.85 -0.32 0.82
... ... ... ... ... ...
5/5 -0.08 1.12 -0.75 0.91 0.78

关键发现:

  1. 小市值效应 显著:小市值组合的SMB载荷普遍为正
  2. 价值溢价 存在:高BM组合的HML载荷显著为正
  3. 市场因子 主导:MKT因子的解释力最强(高R²)

4.3 模型诊断与改进

通过残差分析发现:

  • 部分组合存在异方差性(可使用GLS改进)
  • 2015年股灾期间模型解释力下降
  • 加入动量因子可能提升解释力
# 异方差性检验
from statsmodels.stats.diagnostic import het_white

white_test = het_white(model.resid, model.model.exog)
print(f'White Test p-value: {white_test[1]}')

5. 完整代码框架与实现技巧

5.1 模块化设计建议

将项目分解为以下模块:

ff3_model/
├── data/               # 原始数据
├── utils/              # 工具函数
│   ├── data_clean.py
│   ├── factor_build.py
│   └── regression.py
├── config.py           # 参数配置
└── main.py             # 主流程

5.2 性能优化技巧

处理大规模面板数据时:

  • 使用 numba 加速计算密集型部分
  • 采用 dask 处理内存不足问题
  • 对分组操作使用 parallel_apply
from numba import jit

@jit(nopython=True)
def weighted_return(ret, mkt):
    return np.sum(ret * mkt) / np.sum(mkt)

5.3 结果可视化

使用 matplotlib 绘制:

  • 因子收益时序图
  • 组合alpha热力图
  • 因子暴露三维散点图
import matplotlib.pyplot as plt
import seaborn as sns

# Alpha热力图示例
alpha_grid = results.pivot(index='size_quintile', 
                          columns='bm_quintile',
                          values='alpha')
sns.heatmap(alpha_grid, annot=True, fmt='.2%')
plt.title('25 Portfolios Alpha Heatmap')

结语:从理论到实践的思考

在实际复现过程中,有几个关键点值得特别注意:首先,A股市场的ST股票处理方式与成熟市场不同,需要特别关注退市机制带来的影响;其次,在计算市值加权收益时,确保使用自由流通市值而非总市值;最后,三因子模型在牛熊市中的表现差异较大,建议分阶段检验模型稳定性。

这个项目最令人惊喜的发现是,尽管A股市场有其特殊性,但Fama-French三因子的核心逻辑仍然成立,特别是在解释小盘股和价值股超额收益方面。不过需要注意的是,随着市场结构的变化,因子溢价的大小和持续性也在动态调整,这提示我们需要持续监控因子表现。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐