基于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 取这些数据时四个参数最容易出问题。第一是复权方式计算收益率必须用后复权价格也就是priceAdjforward或者直接取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, returnType1)这里我把指数收益率除以 100 转成小数方便后续和因子收益率处于同一量纲。returnType1表示不进行复权处理的原始涨跌幅但实际上做因子研究时更推荐用后复权价格自己算对数收益率因为 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_okTrue) # 保存原始数据到本地避免每次重复请求 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 三因子模型里的 SMBSmall Minus Big和 HMLHigh 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), indexmarket_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)), indexbm.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再把日收益累乘成月度收益。这里有个细节(1x).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 五因子的分组变量盈利能力和投资风格怎么度量五因子模型在三因子基础上增加了两个因子RMWRobust Minus Weak盈利因子和 CMAConservative 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, tradeDate20240628) # 从 Wind 取总资产用于计算投资水平总资产同比增速 wm_asset w.wss(000001.SZ,000002.SZ, total_assets, tradeDate20240628;year2024) def construct_rmw_cma(mkt_cap, profit_ratio, invest_ratio, ret): # 市值、盈利、投资三个维度都按中位数分成两组 size pd.Series(np.where(mkt_cap mkt_cap.median(), B, S), indexmkt_cap.index) profit pd.Series(np.where(profit_ratio profit_ratio.median(), R, W), indexprofit_ratio.index) invest pd.Series(np.where(invest_ratio invest_ratio.median(), A, C), indexinvest_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, axis1, keepdimsTrue) # 每个月的盈利中位数用于分盈利强组 robust_mask profit_matrix np.nanmedian(profit_matrix, axis1, keepdimsTrue) # 组合收益净利润强且小盘的组合 small_robust np.where(big_mask robust_mask, ret_matrix, np.nan) rmw_numerator np.nanmean(small_robust, axis1)这段代码的精髓在于np.median的axis1加keepdimsTrue这样得到的每月中位数可以广播到整个矩阵做比较。np.where把不满足条件的元素置为np.nan再用np.nanmean忽略缺失值求组合平均一气呵成拿掉显式循环。这种做法就是你在做 python 量化交易策略代码时最常用的矩阵化思路——把要循环的逻辑全部转成数组切片的布尔运算然后再用nanmean聚合。对于新手来说可以先写循环版本保证正确性再慢慢迁移到numpy版本别一上来就挑战矩阵化否则逻辑错了很难排查。5. 避坑手册Wind 取数和因子模型里最常翻车的五个细节5.1 收益率和市值日期错位用 merge 而不是 append现象构造出来的因子序列和组合净值走势图对不上回归分析里系数符号完全反了。原因Wind 的w.wsd返回的日频序列本身对齐但你在手动拼接市值和收益率时用了pd.concat按索引硬拼不同证券的停牌日期会导致索引长度不一致错位就在所难免。解决任何两个 DataFrame 合并都用pd.merge显式指定ontrade_date并且用howinner只保留两边都有的日期。ret pd.merge(stock_ret, market_ret, ontrade_date, howinner)这个细节能挡住大量玄学错误。我在做因子复现时反复提醒自己任何时候不要用或concat去对齐金融时间序列merge才是唯一安全的路径。5.2 Wind 接口偶发断连不能用 try 就完事现象脚本跑到一半w.wsd返回错误码-3后面全是空数据程序不报错但结果全是NaN。原因Wind 接口依赖终端进程如果终端弹出更新提醒、行情重连、或者用户手动刷新了自选股接口就会短暂不可用。解决不要只检查返回值必须把每次请求的错误信息打印出来并重试。def wsd_retry(func, *args, retries3, **kwargs): for i in range(retries): data func(*args, **kwargs) if data.ErrorCode 0: return data print(fWind 接口错误: {data.ErrorCode}, 第 {i1} 次重试) 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(indexstock_code, inplaceTrue)这个步骤不能省否则你算出来的因子不是拿去解释收益的是拿去炒壳的论文评审和实盘都不会认可。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, dpi150)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, window252): 滚动窗口因子构造每天用过去 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)这个滚动版本的关键参数是window252一年交易日数量代表用过去一年的数据确定分位数边界。窗口越大分组边界越稳定但对市场风格切换的反应也越慢窗口越小边界越灵敏但噪声也越大。我通常会用 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_typeHAC, cov_kwds{maxlags: 12}) print(model.summary())回归代码的最后一点提醒一定要用cov_typeHAC做 Newey-West 标准误修正因为金融收益率序列普遍存在异方差和自相关普通 OLS 标准误会低估 t 值。maxlags12是按月度数据设置的滞后阶数年度频率可以改成 4。输出里重点看P|t|那一列如果 SMB 或 HML 的 p 值大于 0.05说明这个因子在样本期内对组合收益没有解释力这不是代码的问题是真实的市场现象。我在最初复现 Fama-French 模型时走过一段弯路花了两天调试 Wind 接口的数据对齐自以为搞定了第三天才发现用的市值数据没有做季度更新每月的分组边界一直在用年初的旧值导致 SMB 序列严重失真。调完后因子曲线和学术论文里的形状终于对上了。希望帮到你。本文还有配套的精品资源点击获取