基于Wind Python接口实现Fama-French三因子与五因子模型构建

发布时间:2026/10/25 12:23:38
基于Wind Python接口实现Fama-French三因子与五因子模型构建

简介:资源包含Fama-French三因子与五因子模型的Python实现,面向金融量化研究者、投资分析人员及Python开发者,帮助理解并应用经典资产定价模型解释股票收益的差异。压缩包共121个文件,以Python脚本为主(101个py),涵盖数据获取、因子计算、数据清洗、多因子回归与结果输出等环节;同时包含CSV因子数据表、TXT说明文档、README及项目配置文件,整体约11.98MB,目录结构清晰,便于按需查阅。已有1383人学习,适合具备一定Python和金融基础的学习者直接参考。通过阅读源码与示例数据,可掌握从Wind金融终端提取因子数据、用statsmodels进行多因子回归、分析因子系数和统计显著性的完整流程;代码注释与说明文档能帮助快速复现三因子与五因子模型结果,为后续投资组合构建、策略回测和风险分析提供可直接调用的工具。

1. 这个压缩包到底在干什么:先搞懂 Wind_Python 和 Fama-French 是两件事

拿到Wind_Python-master.zip_FamaFench 三因子、五因子模型这个名字,很多人的第一反应是“这应该是个直接能跑的源码包”。实际上你得先分清两件事:Wind_Python是万得(Wind)金融终端对外的 Python 接口,负责把行情、财务、指数成分等数据拉到内存里;而 Fama-French 三因子、五因子是学术界的资产定价模型,描述的是“股票收益能被哪几个风险因子解释”。压缩包本质上是用 Wind 的 Python 接口去获取数据,再把数据构造成 Fama-French 因子,最后做回归检验的一套工程方案。它解决的是量化研究里最干的活:把手工去 Excel 里复制数据的流程,变成可复现、可更新的 Python 代码。适合谁用?写毕业论文需要做因子检验的经济金融硕士、刚进量化私募需要搭研究框架的初级研究员,以及想把手里的 Wind 终端物尽其用的个人投资者。这套东西的难点不在统计理论,而在数据对齐、分组逻辑和 Wind 接口的细节上,一不留神就会产出符号反了、收益率对不齐的废数据。

2. 搭好环境只是开始:Wind Python 接口的安装与取数约束

2.1 先确认你的 Wind 终端和 Python 版本匹配

Wind 的 Python 接口在 2019 年之后已经不需要单独安装客户端插件,只要你的电脑装了 Wind 金融终端,并且能正常登录,就可以在 Python 里通过WindPy包直接调数据。常见做法是打开终端后在命令栏输入Python回车,如果返回WindPy is ready就说明接口可用。但这里有个前置条件:WindPy 插件是绑定 Python 版本的,官方早年支持 Python 2.7 和 3.5/3.6,现在的windshare模块对 Python 3.8 以上支持更好。如果你装了 Python 3.11 或 3.12 并且调接口失败,大概率不是代码问题,而是 Wind 客户端还没适配这个新版本,换回 3.8 或 3.9 能省掉大量排查时间。

from WindPy import w import pandas as pd # 启动 Wind 接口,只有终端在线且登录成功时才会返回 0 err_code = w.start() print("Wind 接口连接状态:", err_code) # 如果返回 0,执行一条最简单的取数测试 if err_code == 0: data = w.wsd("000300.SH", "close", "2024-01-01", "2024-01-10", "") print(data)

这段代码里的w.start()是 WindPy 的启动入口,返回值 0 代表成功,-1基本可以断定是终端没登录或版本不兼容。w.wsd是取日频序列的核心函数,第一个参数是证券代码,第二个是指标名,后面依次是开始日期、结束日期和参数。运行成功后会返回一个结构体,里面包含错误码、时间戳和数值列表,我用data.Data这个字段取最终的数据数组。第一次跑通这段代码,你的环境就搭完了,后面的因子构造都是围绕w.wsd的不同参数组合来展开的。

2.2 取因子数据时的四个硬性参数:复权、日期对齐、空值、频率

Fama-French 三因子模型需要三个基本输入:市场收益率、市值(用于分组)、账面市值比(用于分组)。五因子在基础上还要盈利能力和投资水平。从 Wind 取这些数据时,四个参数最容易出问题。第一是复权方式,计算收益率必须用后复权价格,也就是priceAdj=forward或者直接取pct_chg指标,否则除权除息日会出现假跌;第二是日期对齐,Wind 返回的交易日序列和你本地拼接的日期索引可能不同,需要用pd.merge的inner方式对齐;第三是空值处理,新股上市初期没有市值数据,停牌股没有收益率,这些缺失值如果不填充,分组时会被qcut直接报错;第四是频率选择,因子模型的标准频率是月度,但 Wind 的财务报表字段默认是季频,需要你手动做ffill持久化处理。

# 取沪深300指数日收益率,用于计算市场超额收益 market_data = w.wsd("000300.SH", "pct_chg", "2024-01-01", "2024-12-31", "") market_df = pd.DataFrame({ "trade_date": market_data.Times, "mkt_ret": [x / 100 for x in market_data.Data[0]] }) # 取所有A股的日收益率,注意指标名用 "pct_chg",返回的是百分比 stock_data = w.wsd("000001.SZ,000002.SZ", "pct_chg", "2024-01-01", "2024-12-31", "returnType=1")

这里我把指数收益率除以 100 转成小数,方便后续和因子收益率处于同一量纲。returnType=1表示不进行复权处理的原始涨跌幅,但实际上做因子研究时更推荐用后复权价格自己算对数收益率,因为 Wind 的pct_chg在停牌复牌日的表现容易让数据出现断裂。常见做法是取close后自己pct_change(),这样你能完全掌控复权逻辑。注意w.wsd的代码参数支持逗号分隔的证券列表,但你还需要拿到包含全部 A 股的代码列表,这时用w.wset或者直接w.wss按板块取指数成分股。不要尝试用w.wsd一次请求超过几百只股票,Wind 接口对单次请求的股票数量和日期范围有限制,容易被切流。

2.3 文件的操作:把取数结果落盘成中间层,别每次都打 Wind 接口

在实际项目中,我一般不会每次运行因子模型都去调 Wind 接口,一是慢,二是接口有每日调用次数上限。更稳妥的做法是第一次把取到的日频数据存成 CSV 或 Parquet 文件,之后所有调试都基于本地文件。Python 的文件的操作在这一步就派上用场了,to_csv和read_csv加上pickle都可以,建议用 Parquet 或 pickle 保留datetime64类型,CSV 会在读回时把日期解析成字符串,你还得手动pd.to_datetime。

import os import pandas as pd data_dir = "./factor_data" os.makedirs(data_dir, exist_ok=True) # 保存原始数据到本地,避免每次重复请求 Wind 接口 market_df.to_pickle(os.path.join(data_dir, "market_ret.pkl")) stock_data.to_pickle(os.path.join(data_dir, "stock_ret.pkl")) # 从本地读回,作为后续所有因子计算的起点 market_df = pd.read_pickle(os.path.join(data_dir, "market_ret.pkl")) print(market_df.head())

参数说明其实很简单:to_pickle会把整个 DataFrame 序列化到磁盘,读回来时数据类型完全保留。这样做的好处不只是快,更重要的是你的因子计算结果变得可审计——任何人拿到你的 pkl 文件都能复现你的分组逻辑,而不是依赖 Wind 终端里的某个历史快照。如果你担心文件损坏,可以在文件名里带上日期后缀,比如market_ret_20241231.pkl,这样回溯数据版本时不会搞混。给一个实际经验:Wind 终端的财务数据更新有滞后,今天取的账面市值比可能和三天后取的同一日期数据不同,所以落盘时务必注明“数据下载日期”,这会成为你日后调试时最重要的元信息。

3. 构造三因子:从市值和账面市值比到 SMB 与 HML 的完整管线

3.1 分组的底层逻辑:2x3 分割法到底在切什么

Fama-French 三因子模型里的 SMB(Small Minus Big)和 HML(High Minus Low),构造逻辑并不复杂。每年 6 月末,把所有股票按市值中位数分成小盘(Small)和大盘(Big)两组;再按账面市值比(BM)的 30% 和 70% 分位数分成低(Low)、中(Middle)、高(High)三组。两组市值和三组 BM 交叉,得到 S/L、S/M、S/H、B/L、B/M、B/H 六个组合。SMB 就是三组小盘股组合的等权平均收益率减去三组大盘股组合的等权平均收益率,HML 则是两个高 BM 组合的平均减去两个低 BM 组合的平均。这个逻辑的直觉是:小市值公司相对大市值公司有超额收益,高账面市值比(价值股)相对低账面市值比(成长股)有超额收益,这两个超额就是因子。

import pandas as pd import numpy as np def construct_fama_french(market_cap: pd.Series, bm: pd.Series, ret: pd.Series): """ 按 2x3 分组构造三因子所需的六个组合收益 market_cap: 个股总市值 bm: 账面市值比 ret: 个股月度收益率 """ # 市值中位数分组 cap_median = market_cap.median() size_group = pd.Series(np.where(market_cap >= cap_median, "B", "S"), index=market_cap.index) # BM 按 30% 和 70% 分位数切三组 bm_low = bm.quantile(0.3) bm_high = bm.quantile(0.7) bm_group = pd.Series( np.where(bm < bm_low, "L", np.where(bm <= bm_high, "M", "H")), index=bm.index ) # 交叉分组后计算等权组合收益 group = size_group + "/" + bm_group combo_ret = pd.DataFrame({"group": group, "ret": ret}).groupby("group")["ret"].mean() return combo_ret

这段代码的核心是用np.where做两次嵌套分组:先按市值切大小,再按 BM 切高中低。你在自己项目里要注意的是分位数计算时要不要剔除 ST 股和上市不足半年的次新股,常见做法是把总市值小于 0 或 BM 为负的样本直接丢掉,否则quantile会被极端值带偏。六个组合的收益率我用groupby求等权平均,这也是学界标准的处理方式,市值加权也不是不行,但在 A 股里市值加权会让小盘组合被几只大票绑架,等权更贴近小盘因子的原始定义。这里选 30% 和 70% 分位数而不是 50% 中位数,是沿用了 Fama 和 French 1993 年论文的原始设定,你要是做稳健性检验,可以改成 3x3 或 2x2 分组,代码逻辑不变。

3.2 月度频率重采样:日频数据到月末截面

Wind 接口返回的是日频数据,但 Fama-French 因子的标准频率是月度。把日频转月频的常见做法是取每月最后一个交易日的市值和 BM 作为当月截面数据,月收益率用当月累计收益。这里踩坑的重灾区是“每月最后一个交易日”在 pandas 里的实现,用resample('M')的话会把自然月的最后一天当作截面日,但 A 股最后交易日往往不是自然月最后一天,会产生一两天错位。

# 假设 daily_data 是包含 trade_date、mkt_cap、bm、ret 的日频 DataFrame daily_data["trade_date"] = pd.to_datetime(daily_data["trade_date"]) daily_data = daily_data.sort_values("trade_date") # 取每月最后一个交易日的截面数据 monthly_cap_bm = daily_data.groupby(daily_data["trade_date"].dt.to_period("M")).tail(1) # 月收益用日收益率的复利计算 daily_data["month"] = daily_data["trade_date"].dt.to_period("M") monthly_ret = daily_data.groupby(["month", "stock_code"])["ret"].apply( lambda x: (1 + x).prod() - 1 ).reset_index()

先按月份分组取最后一行的市值和 BM,再把日收益累乘成月度收益。这里有个细节:(1+x).prod() - 1得到的复利月收益,比直接加总日收益更准确,尤其在震荡市里二者差异能到 1% 以上。取最后一行的tail(1)是在已排序的 DataFrame 上做的,你一定要注意先sort_values("trade_date"),否则每个月取到的可能是随机的一天。股票代码作为分组键不能被groupby吞掉,上面代码里我专门把它保留下来,后面与市值、BM 合并时才能对齐。

3.3 完整的三因子计算函数:SMB 和 HML 的最终合成

把上面的分组逻辑和月度截面合并起来,就是一个完整的三因子构造流程。实际代码里我会再加一个步骤:剔除每个截面日上市不满 6 个月的股票,这些股票没有足够的 BM 数据,会干扰分位数计算。剔除方式是拿到每只股票的首个交易日,然后和当前截面日期比较。

def calc_smb_hml(monthly_snapshot): """ monthly_snapshot: 每个月的截面数据,包含 stock_code, mkt_cap, bm, ret 返回 SMB 和 HML 因子序列 """ results = [] for period, snapshot in monthly_snapshot.groupby("month"): # 剔除市值或 BM 缺失的样本 snapshot = snapshot.dropna(subset=["mkt_cap", "bm", "ret"]) combos = construct_fama_french(snapshot["mkt_cap"], snapshot["bm"], snapshot["ret"]) # SMB = 小盘三组平均 - 大盘三组平均 smb = (combos["S/L"] + combos["S/M"] + combos["S/H"]) / 3 - \ (combos["B/L"] + combos["B/M"] + combos["B/H"]) / 3 # HML = 高账面市值比两组平均 - 低账面市值比两组平均 hml = (combos["S/H"] + combos["B/H"]) / 2 - \ (combos["S/L"] + combos["B/L"]) / 2 results.append({"month": period, "SMB": smb, "HML": hml}) return pd.DataFrame(results)

这段代码每月的截面做一次分组和因子合成,最后拼成一个月度序列。SMB 的分子是三组小盘组合收益的平均,分母是三组大盘组合收益的平均;HML 则横向跨市值、纵向跨 BM 组做差。这就是 2x3 分组法的完全实现。需要提醒的是combos["S/L"]这种取值方式依赖groupby结果里存在这个键,如果某些月份没有股票落在 S/L 组,combos里直接缺了一个 key 会触发KeyError。我在真实项目里会在取数后打印每个组合的股票数量,发现某组少于 5 只就跳过该月并且打日志,这种做法比让程序崩溃好得多。

4. 五因子模型扩展:在 Wind 数据里补上 RMW 和 CMA 两个维度

4.1 五因子的分组变量:盈利能力和投资风格怎么度量

五因子模型在三因子基础上增加了两个因子:RMW(Robust Minus Weak,盈利因子)和 CMA(Conservative Minus Aggressive,投资因子)。RMW 的分组变量是盈利能力,Fama-French 用“营业利润 / 账面权益”来衡量;CMA 的分组变量是投资水平,用“总资产增长率”来衡量。构造方法和三因子类似,但不再用 2x3 而是独立地做 2x2 或 2x3 分组。以标准做法为例:市值按中位数分大小两组,盈利按中位数分强弱两组,交叉后得到 S/R、S/W、B/R、B/W 四个组合,RMW 就是(S/R + B/R)/2 - (S/W + B/W)/2;投资因子同理按投资水平高低分组做差。

# 从 Wind 取盈利能力指标:营业利润 / 净资产 wm_profit = w.wss("000001.SZ,000002.SZ", "or_ttm,equity_ttm", "tradeDate=20240628") # 从 Wind 取总资产,用于计算投资水平(总资产同比增速) wm_asset = w.wss("000001.SZ,000002.SZ", "total_assets", "tradeDate=20240628;year=2024") def construct_rmw_cma(mkt_cap, profit_ratio, invest_ratio, ret): # 市值、盈利、投资三个维度都按中位数分成两组 size = pd.Series(np.where(mkt_cap >= mkt_cap.median(), "B", "S"), index=mkt_cap.index) profit = pd.Series(np.where(profit_ratio >= profit_ratio.median(), "R", "W"), index=profit_ratio.index) invest = pd.Series(np.where(invest_ratio >= invest_ratio.median(), "A", "C"), index=invest_ratio.index) # RMW: 盈利强组 - 盈利弱组 rmw = ret[profit == "R"].mean() - ret[profit == "W"].mean() # CMA: 投资保守组 - 投资激进组 cma = ret[invest == "C"].mean() - ret[invest == "A"].mean() return rmw, cma

这里用w.wss取了截面财务指标,or_ttm是滚动十二个月营业利润,equity_ttm是最近财报的净资产。一个必须注意的坑是财务数据的时点对齐:我们在 2024 年 6 月做分组时,不能提前知道 2024 年全年的 EBITDA,只能用最新披露的 2024 年一季报或 2023 年年报数据。Wind 的tradeDate参数会自动帮你选这个时间点上最新的财报披露值,但你要确认返回的数据确实没有用到未来数据——简单办法是打印取数日期对应的报告期,如果出现reportDate晚于当前日期,说明你穿透到了未来数据,这在回测里属于典型的“前视偏差”,会让你的因子表现虚高。

4.2 五因子组合的完整构建:2x3 分组和 2x2 分组的结果差异

多因子组合构造有个学术上的细节:Fama-French 的官方五因子并不要求六个组合都参与所有因子计算,他们用的是市值和每个因子独立交叉的 2x3 分组法,和最终每个因子做加权平均权重。常见的可执行做法是在三因子的基础之上,把市值和盈利能力、投资水平分别做交叉,组成两个独立的 2x3 分组矩阵,然后分别把高盈利组平均收益减去低盈利组平均收益得到 RMW,把低投资组平均收益减去高投资组平均收益得到 CMA。这样做的好处是每个因子都覆盖了大中小市值全部样本,不容易被某一端市值股票绑架。

def calc_rmw_cma(monthly_snapshot): results = [] for period, snapshot in monthly_snapshot.groupby("month"): snapshot = snapshot.dropna(subset=["mkt_cap", "profit_ratio", "invest_ratio", "ret"]) rmw, cma = construct_rmw_cma( snapshot["mkt_cap"], snapshot["profit_ratio"], snapshot["invest_ratio"], snapshot["ret"] ) results.append({"month": period, "RMW": rmw, "CMA": cma}) return pd.DataFrame(results)

construct_rmw_cma函数内部直接对每个月的横截面做中位数分组,然后求组间均值之差。这里要注意profit_ratio和invest_ratio可能有极端值,比如亏损企业营业利润为负,净资产为负的企业or_ttm/equity_ttm可能失真。我建议在分组之前先用np.clip或者直接剔除上下 1% 分位极端值,尤其是invest_ratio总资产增速可能因为并购出现几倍甚至几十倍的天文数字,不剔除的话中位数会飘。真实的 A 股数据处理里,五因子的构建经常会发现 RMW 因子不显著,一个常见原因是 A 股壳价值和小市值炒作让盈利因子失效,另一个原因就是财务数据没对齐导致噪声过大。

4.3 矩阵切片的高效写法:用数组切片命令替代逐行循环

如果你处理的是几千只股票、十几年历史的数据,逐月for循环做分组会明显卡顿。一个常见做法是用 numpy 的数组切片命令把整个时序数据矩阵化:把每个月的市值、盈利、投资三个指标堆叠成一个三维数组,然后一次性算出所有月份的分组索引。虽然概念上有点绕,但性能能提升几十倍,尤其是做滚动窗口测试时,每组每年都要重新分组、重新计算因子,用循环会跑十几分钟。

import numpy as np # 假设已经构造好了面板数据:shape = (月数, 股票数) # cap_matrix: 每个月的市值矩阵 # profit_matrix: 每个月的盈利指标矩阵 # ret_matrix: 每个月的收益率矩阵 # 每个月的市值中位数,用于分组:True 表示大盘股 big_mask = cap_matrix > np.median(cap_matrix, axis=1, keepdims=True) # 每个月的盈利中位数,用于分盈利强组 robust_mask = profit_matrix > np.nanmedian(profit_matrix, axis=1, keepdims=True) # 组合收益:净利润强且小盘的组合 small_robust = np.where(big_mask & robust_mask, ret_matrix, np.nan) rmw_numerator = np.nanmean(small_robust, axis=1)

这段代码的精髓在于np.median的axis=1加keepdims=True,这样得到的每月中位数可以广播到整个矩阵做比较。np.where把不满足条件的元素置为np.nan,再用np.nanmean忽略缺失值求组合平均,一气呵成拿掉显式循环。这种做法就是你在做 python 量化交易策略代码时最常用的矩阵化思路——把要循环的逻辑全部转成数组切片的布尔运算,然后再用nanmean聚合。对于新手来说,可以先写循环版本保证正确性,再慢慢迁移到numpy版本,别一上来就挑战矩阵化,否则逻辑错了很难排查。

5. 避坑手册:Wind 取数和因子模型里最常翻车的五个细节

5.1 收益率和市值日期错位:用 merge 而不是 append

现象:构造出来的因子序列和组合净值走势图对不上,回归分析里系数符号完全反了。原因:Wind 的w.wsd返回的日频序列本身对齐,但你在手动拼接市值和收益率时用了pd.concat按索引硬拼,不同证券的停牌日期会导致索引长度不一致,错位就在所难免。解决:任何两个 DataFrame 合并都用pd.merge显式指定on="trade_date",并且用how="inner"只保留两边都有的日期。

ret = pd.merge(stock_ret, market_ret, on="trade_date", how="inner")

这个细节能挡住大量玄学错误。我在做因子复现时反复提醒自己,任何时候不要用+或concat去对齐金融时间序列,merge才是唯一安全的路径。

5.2 Wind 接口偶发断连:不能用 try 就完事

现象:脚本跑到一半,w.wsd返回错误码-3后面全是空数据,程序不报错但结果全是NaN。原因:Wind 接口依赖终端进程,如果终端弹出更新提醒、行情重连、或者用户手动刷新了自选股,接口就会短暂不可用。解决:不要只检查返回值,必须把每次请求的错误信息打印出来并重试。

def wsd_retry(func, *args, retries=3, **kwargs): for i in range(retries): data = func(*args, **kwargs) if data.ErrorCode == 0: return data print(f"Wind 接口错误: {data.ErrorCode}, 第 {i+1} 次重试") time.sleep(2) raise RuntimeError("Wind 接口连续失败,请检查终端登录状态")

wsd_retry把晚几个月的回测数据全都取到了,但实际你用未来数据在回测里钻空子。常见做法是在取财务数据时,把 Wind 返回的报告期字段打印出来人工检查。

5.4 ST 股和次新股对分位数的影响:不剔除导致因子失真

现象:SMB 因子在 A 股的月度收益比学术文献里高一大截,正得离谱。原因:A 股里 ST 股和上市不足半年的股票会被游资爆炒,小市值因子天然自带壳价值溢价。如果分组时把这些股票都纳进来,SMB 就是“垃圾股因子”,完全脱离 Fama-French 里“规模风险溢价”的本意。解决:在进入分组逻辑之前,统一剔除 ST、*ST、上市不满 6 个月、以及当日停牌的股票,用stock_code过滤即可。

# 剔除 ST 股:Wind 返回的证券简称里含 "ST" 的标记出来 if "ST" in sec_name: snapshot.drop(index=stock_code, inplace=True)

这个步骤不能省,否则你算出来的因子不是拿去解释收益的,是拿去炒壳的,论文评审和实盘都不会认可。

5.5 因子符号习惯:A 股市场三因子的符号跟美股不一定一致

现象:回归出来的三因子系数符号和 Fama-French 原文不一致,HML 在 A 股经常是负的。原因:A 股长期存在小市值效应和成长股溢价,价值因子(高 BM)在多数年份是负贡献,这是正常现象而不是程序出错。解决:不要为了追求符号一致去改代码,你要做的是把原始因子序列画出来,观察长期趋势和波动区间,再判断是你的数据错还是市场本身如此。我在自己的回测里经常看到 HML 系数为负,这反而说明数据取对了。

import matplotlib.pyplot as plt # 画 SMB 和 HML 的累计净值曲线 factor_df[["SMB", "HML"]].cumsum().plot(figsize=(10, 6)) plt.title("Fama-French Factor Cumulative Returns") plt.savefig("factor_curve.png", dpi=150)

5.6 用本地缓存代替每次全量拉数

现象:模型参数需要调,每次跑因子都要等 Wind 接口返回几百 MB 的数据,调试一次浪费二十分钟。原因:没有把中间数据落盘做缓存层。解决:把取数模块和分组模块分开,取数模块的输出永远写到一个固定目录的 pkl/parquet 文件里,分组模块直接读 pkl。我一般会在每个数据文件的文件名里加上日期范围和数据来源,比如ff_factors_2000_2024_wind.pkl,这样既能追溯,也能在数据更新后快速替换。

import pickle # 因子计算完成后,立即把结果缓存到本地 with open(os.path.join(data_dir, "ff_factors.pkl"), "wb") as f: pickle.dump(factor_df, f) # 下次启动时直接读缓存,不必重新调 Wind 接口 if os.path.exists(os.path.join(data_dir, "ff_factors.pkl")): with open(os.path.join(data_dir, "ff_factors.pkl"), "rb") as f: factor_df = pickle.load(f)

实际上,你在项目初期就把缓存和调试的逻辑搭好,后面调参数和改分组会顺滑很多。我的经验是:数据工程占整个因子复现项目的 60% 时间,统计回归反而只要半小时。先把缓存做扎实,才有时间真正去看因子到底灵不灵。

6. 让因子动起来:滚动窗口重构、手动验证与回归输出

学完因子构造,真正进阶的一步是把它做成滚动窗口的版本。做滚动窗口的意义在于,Fama-French 分组用的是年末截面,但你实盘的时候每天都要判断当前股票属于哪个组合——先看市值是否超过中位数,再看 BM 是否超过 30% 分位,然后才能算出当天的 SMB 和 HML。你需要把第 3 章的静态分组函数改造成“每天用过去 12 个月的截面数据更新分位数,然后对今天的股票分组”的模式。

def rolling_factor(market_cap_series, bm_series, ret_series, window=252): """ 滚动窗口因子构造:每天用过去 252 天的中位数分位点重新分组 """ factor_list = [] for i in range(window, len(ret_series)): hist_cap = market_cap_series.iloc[i - window:i] hist_bm = bm_series.iloc[i - window:i] # 用历史窗口内的分位数作为今天的分组边界 cap_med = hist_cap.median() bm_low, bm_high = hist_bm.quantile(0.3), hist_bm.quantile(0.7) today_cap = market_cap_series.iloc[i] today_bm = bm_series.iloc[i] today_ret = ret_series.iloc[i] # 分组 size = "B" if today_cap >= cap_med else "S" bm_group = "L" if today_bm < bm_low else ("M" if today_bm <= bm_high else "H") factor_list.append({"date": ret_series.index[i], "size": size, "bm_group": bm_group, "ret": today_ret}) return pd.DataFrame(factor_list)

这个滚动版本的关键参数是window=252,一年交易日数量,代表用过去一年的数据确定分位数边界。窗口越大,分组边界越稳定,但对市场风格切换的反应也越慢;窗口越小,边界越灵敏,但噪声也越大。我通常会用 126(半年)和 504(两年)做对比测试,观察 SMB 和 HML 序列的敏感度差异。还有一点:如果做回归研究,建议把样本期切成两段,比如 2000-2012 和 2013-2024,分别跑三因子和五因子回归,看因子系数在不同时期的稳定性——这是学术论文里最常见的稳健性检验。

import statsmodels.api as sm # 组合超额收益为 y,三因子收益为 X y = portfolio_ret - risk_free_rate X = factor_df[["MKT_RF", "SMB", "HML"]].copy() X = sm.add_constant(X) model = sm.OLS(y, X).fit(cov_type="HAC", cov_kwds={"maxlags": 12}) print(model.summary())

回归代码的最后一点提醒:一定要用cov_type="HAC"做 Newey-West 标准误修正,因为金融收益率序列普遍存在异方差和自相关,普通 OLS 标准误会低估 t 值。maxlags=12是按月度数据设置的滞后阶数,年度频率可以改成 4。输出里重点看P>|t|那一列,如果 SMB 或 HML 的 p 值大于 0.05,说明这个因子在样本期内对组合收益没有解释力,这不是代码的问题,是真实的市场现象。

我在最初复现 Fama-French 模型时走过一段弯路:花了两天调试 Wind 接口的数据对齐,自以为搞定了,第三天才发现用的市值数据没有做季度更新,每月的分组边界一直在用年初的旧值,导致 SMB 序列严重失真。调完后因子曲线和学术论文里的形状终于对上了。希望帮到你。

本文还有配套的精品资源,点击获取