用Wind Python接口从零构建Fama-French三因子五因子模型

发布时间:2026/10/25 21:54:14
用Wind Python接口从零构建Fama-French三因子五因子模型

简介:这是一份围绕Fama-French三因子与五因子模型的Python实现资料包,面向金融量化初学者、金融专业学生以及需要做因子分析的从业者。压缩包共121个文件、约11.98MB,以101个py脚本为主,辅以csv因子数据表、txt说明、pyc编译文件、xml工程配置和md文档,覆盖数据获取、清洗、回归建模与结果输出的完整流程。已有1383人学习,适合边阅读代码边理解市场因子、规模因子、价值因子,以及盈利因子和投资因子的构造与检验逻辑。资料基于Wind金融终端的数据接口,结合pandas、statsmodels等工具完成多因子线性回归,可帮助读者快速搭起可运行的因子分析框架,并继续扩展到组合构造、收益归因和风险检验等场景。

1. Wind_Python-master.zip 在量化里通常指什么:先厘清三因子、五因子模型的落地路径

如果你在本地磁盘见过 Wind_Python-master.zip 这个压缩包,大概率是从某个 Git 仓库或开发者社区拖下来的 Wind Python API 示例工程;而 FamaFench 其实是 Fama-French 的常见手误。这个标题真正要解决的事很具体:用 Wind 的 Python 接口把行情、市值、财务数据取下来,再按 Fama-French(1993 三因子、2015 五因子)的方法手动构建市场溢价、SMB、HML、RMW、CMA 这五个因子序列,最后跑回归看某只股票或某个组合的 alpha 到底是不是运气。这套流程适合量化研究员、金融工程学习者,以及手里有一批自选股想验证超额收益是否真实存在的人。读完这一篇,你应该能自己从 zip 解压一路跑到回归输出。

2. 从 zip 到可运行环境:项目结构、依赖安装与 WindPy 初始化

2.1 解开 zip 先看什么:bin、examples 与 WindPy 的路径约定

Wind 官方和第三方维护者发的 Python SDK 包,通常不是 PyPI 上那种标准安装包,而是一个压缩包。解开之后常见的结构是:一个放 WindPy 主体文件的目录(里面至少能看到 WindPy 的 pyd 或 py 文件,以及配套的数据接口动态库),一个 examples 目录放取数和计算的示例脚本,可能还有一份 doc 目录放接口说明。不同版本结构略有差异,但核心文件基本就那几个。

这里有一个容易让新手卡住的点:Wind 的 Python 接口不像 requests 那样 pip install 完就结束,你需要让 Python 解释器能找到 WindPy 这个模块。常见做法是两种:一是把解压出来的主目录放进 PYTHONPATH;二是在 site-packages 里写一个 .pth 文件指向这个目录。我一般用第一种,简单直接,换版本也方便。WindPy 本身没有依赖什么冷门第三方库,核心依赖就是 pandas、numpy、scipy、statsmodels,其中 statsmodels 是跑因子回归时用的,pandas 负责数据对齐。

:: 在 Windows 命令行或 PowerShell 里查看 Python 版本,确认是 3.7 以上 64 位 python -c "import sys; print(sys.version)" :: 安装因子模型全套依赖,一条命令装完 numpy、pandas、scipy、statsmodels pip install numpy pandas scipy statsmodels :: 把 Wind Python SDK 所在目录写进环境变量,注意替换成你的实际路径 set PYTHONPATH=D:\wind\Wind_Python-master;%PYTHONPATH% :: 验证 WindPy 能被找到,能打印出路径说明环境变量没配错 python -c "from WindPy import w; print(w.__file__)"

这段命令的逻辑是先确认 Python 版本,再装依赖,然后配环境变量。很多人在 pip 安装 numpy 库的方法上没问题,卡在最后一步:from WindPy import w直接报 ModuleNotFoundError。这里要特别提醒,Wind 官方终端对 Python 位数有要求,Python 是 64 位就配 64 位 SDK,别混用;另外 python 环境变量配置如果没生效,新开的命令行窗口也可能不认set PYTHONPATH,要重新打开终端再试。

2.2 初始化 WindPy 与错误码排查:第一次连接要过三道关

配好路径之后,下一步是启动 Wind 终端并让 Python 进程连上它。Wind 的 Python 接口底层是通过本地协议连到你正在运行的 Wind 金融终端,所以终端没打开、没登录,Python 这边一定连不上。这个过程在代码里只有两行,但报错信息很唬人,第一次遇到的几乎都懵。

from WindPy import w import time # 启动 Wind 连接,返回值 errors 是错误码,0 表示成功 errors = w.start() print("ErrorCode:", errors) # 如果 ErrorCode 不是 0,大概率是终端没开或没登录 if errors != 0: print("检查 Wind 终端是否已打开并登录") else: # 连续 5 秒每秒检查一次连接状态,避免刚启动还没握手完成 for i in range(5): if w.isconnected(): print("连接成功") break time.sleep(1)

这里的逻辑是:w.start()只负责发起连接,返回错误码0代表成功,-405这类负数一般是终端未启动或未登录。w.isconnected()是二次确认,因为终端刚启动时协议握手可能要一两秒,直接取数容易拿到空数据。连接成功后,可以先跑一个最小的取数验证,比如取上证指数的收盘价,确认返回的 ErrorCode 是 0 再往下做。这一道关过了,后面的事都是数据处理层面的问题。

2.3 第一次取数验证:用五只股票确认接口通畅

连接验证通过后,我习惯用五只不同行业的股票拉一周日线,确认w.wsd的返回结构能正确解析。这一步看着简单,但能把数据结构问题提前暴露出来,比等因子构建到一半再发现日期格式错了要省事得多。

from WindPy import w import pandas as pd codes = ["000001.SZ", "600000.SH", "600519.SH", "000858.SZ", "601318.SH"] # wsd 取日频序列,pct_chg 是涨跌幅(百分比),mkt_cap_ard 是总市值(元) err, data, times = w.wsd(codes, "pct_chg,mkt_cap_ard", "2024-01-02", "2024-01-08", "PriceAdj=F") if err == 0: # data 是三维数组:股票 × 字段 × 日期,需要转置成 DataFrame df_list = [] for i, code in enumerate(codes): temp = pd.DataFrame({ "code": code, "pct_chg": data[i][0], "mkt_cap_ard": data[i][1], }, index=pd.to_datetime(times)) df_list.append(temp) df = pd.concat(df_list) print(df.head())

这段代码的关键点是:w.wsd返回的data是三维结构,第一维是股票,第二维是字段,第三维是时间序列。很多人直接拿data去构造 DataFrame,结果行列全是乱的。times是 Wind 返回的时间列表,需要转成pd.to_datetime再当索引。参数PriceAdj=F表示不复权,因为这里要的是涨跌幅而不是价格本身,涨跌幅不受复权影响。如果真的用价格算收益率,复权参数就要改成PriceAdj=B(后复权),这个细节第 5 章会专门讲。

3. 用 Wind Python 取数:行情、市值和财务数据怎么对齐

3.1 日频行情用 w.wsd:复权参数与返回格式的取舍

因子模型的输入分三块:日频收益率、截面上的市值和账面价值、以及财务指标。先讲日频行情。Wind 取日频序列的标准接口是w.wsd,它支持单股票多字段、多股票单字段、多股票多字段三种组合。多股票多字段时返回三维数组,处理起来最绕,建议新手拆成单股票循环取,速度慢一点但逻辑清晰。

from WindPy import w import pandas as pd def fetch_daily(codes, start, end): """批量拉取日频涨跌幅和总市值,返回长表 DataFrame""" frames = [] for code in codes: # 单股票取两个字段,返回的 data 是 2 × N 的二维结构 err, data, times = w.wsd(code, "pct_chg,mkt_cap_ard", start, end, "PriceAdj=F") if err != 0: print(f"{code} 取数失败,ErrorCode={err}") continue df = pd.DataFrame({ "code": code, "ret": [x / 100.0 for x in data[0]], # Wind 返回百分比,转成小数 "mktcap": data[1], }, index=pd.to_datetime(times)) frames.append(df) return pd.concat(frames) # 用法示例:拉取 2023 年全年日频数据 df_daily = fetch_daily(["000001.SZ", "600519.SH"], "2023-01-01", "2023-12-31") print(df_daily.head())

这里有一个必须养成的习惯:Wind 的pct_chg返回的是百分比数值,比如涨 2% 返回的是2.0,不是0.02。直接拿这个数当收益率跑回归,截距和因子系数会整体偏大 100 倍,结果完全不可用。我在代码里用x / 100.0做了转换。mkt_cap_ard是总市值,单位是元,在分组时直接用这个字段排序就行;如果你要用流通市值,字段名换成mkt_cap_float。

3.2 截面数据用 w.wss:市值、PB 与账面价值的取法

日频数据解决收益率和市值的时间序列,但 Fama-French 分组还需要每个股票在特定截面日期的账面价值。账面价值通常用股东权益近似,Wind 里可以从w.wss的财务字段取,比如tot_equity(所有者权益合计)。w.wss是截面接口,传入一组股票代码和字段,返回每个股票在指定日期或最新报告期的值。

from WindPy import w import pandas as pd codes = ["000001.SZ", "600519.SH", "000858.SZ"] # wss 取截面数据:总市值、市净率、最新一期 ROE err, data, _ = w.wss(codes, "mkt_cap_ard,pb_lf,roe_ttm", "tradeDate=20231229") if err == 0: df_cross = pd.DataFrame({ "code": codes, "mktcap": data[0], "pb": data[1], "roe": data[2], }) print(df_cross)

tradeDate=20231229是截面日期参数,表示取这一天收盘后的数据。这里要注意:pb_lf是 Wind 的"最新"市净率,roe_ttm是滚动十二个月 ROE。在正式分组时,账面价值不能用"最新"值,因为最新值可能包含了未来季报的信息,会导致前视偏差。正确做法是固定取上年年报的所有者权益,这个逻辑放到 3.3 单独说。如果你在代码里看到别人直接用pb_lf反推账面市值比,那套因子序列大概率经不住推敲。

3.3 三张表对齐:交易日历、停牌与财务披露时点

三张表对齐是整个因子模型里最耗时间的环节。第一张是日频行情表,第二张是截面市值表,第三张是财务数据表。对齐的核心是交易日历:Wind 有w.tdays接口可以直接返回两个日期之间的交易日序列,比自己去剔除周末和节假日靠谱得多。

from WindPy import w import pandas as pd # 取 2023 年全年交易日 err, days, _ = w.tdays("2023-01-01", "2023-12-31") trade_cal = pd.to_datetime(days) print("交易日数量:", len(trade_cal)) # 行情表按交易日 reindex,停牌日会变成 NaN df_daily["trade_date"] = df_daily.index df_aligned = df_daily.set_index("trade_date").reindex(trade_cal) # 停牌日不填充,后面算收益率时直接 dropna,避免用停牌价算出假收益 df_aligned = df_aligned.dropna(subset=["ret"])

停牌是 A 股对齐时最脏的数据。停牌日的价格是前一天的收盘价,如果用close的pct_change算收益率,复牌那天会算出一个暴涨暴跌的假收益率。正确做法是直接用 Wind 的pct_chg字段,它本身已经处理了停牌复牌的涨跌幅口径。如果非要用价格自己算,就一定要用后复权价,并且把停牌日的收益率置为 0 而不是 NaN。财务数据的对齐则是另一套逻辑:每年 6 月底构建组合时,只能使用当年 4 月 30 日之前已披露的年报数据,这个"披露时点"的约束比数据本身更重要。

4. 手写 Fama-French 因子:2x3 分组与 SMB/HML/RMW/CMA 构建全程

4.1 分组前准备:股票池筛选与因子暴露的原始值

分组是 Fama-French 的核心工序。在动手分组前,先把股票池洗干净。我的日常做法是:股票池用全 A 股,剔除 ST、*ST、退市整理、上市不满 12 个月的新股,以及分组时点停牌的股票。剔除 ST 和次新股是因为它们的收益特征和正常经营的公司差异太大,会把小盘因子带偏;剔除停牌股是因为没有可靠价格。

import pandas as pd def prepare_universe(df_cross, df_daily, min_list_days=252): """筛选股票池:剔除 ST、次新、停牌股""" # 假设 df_cross 里有 st_flag(是否 ST)和 list_days(上市天数) df = df_cross.copy() df = df[df["st_flag"] == 0] # 剔除 ST df = df[df["list_days"] >= min_list_days] # 剔除次新股 # 剔除分组日停牌股:当日收益率为 NaN 且无成交量的股票 valid_codes = df_daily[df_daily["ret"].notna()]["code"].unique() df = df[df["code"].isin(valid_codes)] return df

这段代码的筛选逻辑是三层:ST 一条、上市天数一条、停牌一条。list_days字段需要从 Wind 的wss接口取,字段名是list_date,拿到后自己算上市天数。实际操作里,ST 标记也可以用股票名称里是否含 "ST" 来粗筛,但 Wind 有专门的字段is_st,直接取来用更可靠。筛选完的股票池,在 6 月末时点大概还有 4000 只左右,这个量级足够支撑分组计算。

4.2 2x3 分组与市值加权组合收益:分组边界和权重口径

Fama-French 的经典分组是做两次独立的 2x3 切分:规模按中位数分成小(S)大(B)两组,账面市值比按 30%/70% 分位数切成低(L)、中(M)、高(H)三组,交叉得到 6 个组合。把这 6 个组合的市值加权收益率算出来,后续因子就是这些组合收益的线性组合。

import pandas as pd def ff_group(df, size_col="mktcap", bm_col="bm"): """按规模和账面市值比做 2x3 分组,返回带组合标签的 DataFrame""" df = df.copy() # 规模分两组:中位数以下为 S,以上为 B size_med = df[size_col].median() df["size_group"] = df[size_col].apply(lambda x: "S" if x <= size_med else "B") # 账面市值比分三组:30% 分位以下 L,70% 以上 H,中间 M bm_low, bm_high = df[bm_col].quantile([0.3, 0.7]) def bm_group(x): if x <= bm_low: return "L" elif x >= bm_high: return "H" return "M" df["bm_group"] = df[bm_col].apply(bm_group) return df # 组合收益:按 size_group + bm_group 分组,市值加权 def weighted_ret(df, ret_col="ret", weight_col="mktcap"): df = df.copy() df["weight"] = df.groupby(["size_group", "bm_group"])[weight_col].transform("sum") df["weighted_ret"] = df[ret_col] * df[weight_col] / df["weight"] port_ret = df.groupby(["size_group", "bm_group"])["weighted_ret"].sum() return port_ret

分组代码里有两个边界坑。第一个坑是分位数口径:这里用的是全 A 股中位数,而 Fama-French 原论文用的是 NYSE 上市股票的分位数做锚。A 股没有交易所分层,常见做法是直接用全 A 分位,或者用中证 800 成分股的分位做锚,但无论用哪种,全流程必须统一,否则 SMB 的含义会变。第二个坑是加权方式:市值加权是用分组时点的总市值做权重,不是等权。等权算出来的 SMB 对小盘股更敏感,和学术定义不一致。分组完成后,每个组合都是一个市值加权组合,组合代码用S_H、B_L这样的标签标识。

4.3 计算 SMB/HML/RMW/CMA:从六个组合到四组因子序列

有了 2x3 组合收益,因子就是组合收益的差值。这里要区分三因子和五因子的构建差异:三因子只需要规模和账面市值比的分组,SMB 是三个小盘组合平均收益减三个大盘组合平均收益,HML 是高账面市值比组合平均减低账面市值比组合平均。五因子在此基础上多出盈利分组和投资分组,RMW 是高盈利组合平均减低盈利组合平均,CMA 是低投资(保守)组合平均减高投资(激进)组合平均。

# 假设 port_ret 是 DataFrame,行索引是组合标签,列是月份 # SMB 三因子版:S 组三个组合均值 - B 组三个组合均值 smb = (port_ret.loc[["S_H", "S_M", "S_L"]].mean() - port_ret.loc[["B_H", "B_M", "B_L"]].mean()) # HML:高账面市值比组合 - 低账面市值比组合 hml = (port_ret.loc[["S_H", "B_H"]].mean() - port_ret.loc[["S_L", "B_L"]].mean()) # RMW 和 CMA 需要额外做盈利/投资分组,字段换成 roe 和 invest # RMW = 高盈利组合均值 - 低盈利组合均值 rmw = (port_ret.loc[["S_R", "B_R"]].mean() - port_ret.loc[["S_W", "B_W"]].mean()) # CMA = 低投资组合均值 - 高投资组合均值 cma = (port_ret.loc[["S_C", "B_C"]].mean() - port_ret.loc[["S_A", "B_A"]].mean())

这里的因子命名约定是:R 表示高盈利(Robust),W 表示低盈利(Weak),C 表示保守投资(Conservative),A 表示激进投资(Aggressive)。注意 RMW 和 CMA 的分组也需要先按规模切成 S/B 两组,再分别按盈利或投资指标切三组,不能用现成的规模组直接套。很多简化代码会把盈利因子直接算成"高盈利股票收益减低盈利股票收益",不做规模分层,这样算出来的因子混入了规模效应,和五因子模型里的 RMW 并不是同一种东西。做完因子计算,把四组序列存成 CSV,这就是后续回归的输入。

4.4 做回归:三因子与五因子的 alpha 对比

因子序列构建好之后,回归就是标准的 OLS。这里以单只股票或一个组合的日频收益率为因变量,因子收益率为自变量。三因子模型的 X 是市场超额收益、SMB、HML;五因子模型在 X 里追加 RMW、CMA。对比两个模型的回归结果,重点看两个东西:新增因子是否显著(t 值绝对值大于 2),以及加入后 alpha 是否明显变小。

import statsmodels.api as sm def reg_factor(stock_ret, factors, mkt_ret, rf=0.0): """ stock_ret: 个股或组合的超额收益率序列 factors: DataFrame,列包含 SMB, HML, RMW, CMA mkt_ret: 市场超额收益率序列 rf: 无风险利率,日频一般取 0 或国债收益率/252 """ y = stock_ret - rf X = factors.copy() X["MKT_RF"] = mkt_ret - rf X = sm.add_constant(X) # 加截距项,截距就是 alpha model = sm.OLS(y, X).fit(cov_type="HAC", cov_kwds={"maxlags": 5}) return model # 三因子模型 factors_3 = df_factors[["SMB", "HML"]] res_3 = reg_factor(stock_ret, factors_3, mkt_ret) # 五因子模型 factors_5 = df_factors[["SMB", "HML", "RMW", "CMA"]] res_5 = reg_factor(stock_ret, factors_5, mkt_ret) # 输出对比:alpha、t 值、R2 print("三因子 alpha:", res_3.params["const"], "t=", res_3.tvalues["const"]) print("五因子 alpha:", res_5.params["const"], "t=", res_5.tvalues["const"]) print("R2 对比:", res_3.rsquared, res_5.rsquared)

为什么用cov_type="HAC"是关键。日频收益率的回归残差几乎必然存在自相关,普通 OLS 的标准误会低估,导致 t 值虚高。maxlags=5是日频数据常见的滞后阶数选择,如果你把数据换成月频,可以去掉 HAC 或者把 maxlags 设为 1。截距项const就是 alpha,注意这里用的是超额收益做因变量,所以 alpha 已经扣掉了无风险利率,不要再去减一遍。如果三因子模型里 alpha 显著但五因子模型里不显著,说明这个组合的超额收益大概率来自盈利或投资风格暴露,而不是真正的选股能力。

5. 避坑:让三因子五因子复现翻车的 5 个数据细节

5.1 现象:因子序列和研报对不上

复现出来的 SMB 月度均值和研报里的数字差了不止一个量级,甚至符号都对不上。原因多半是收益率单位没转换,Wind 的pct_chg返回 2.0 代表 2%,直接参与计算会被当成 200% 处理,所有组合收益和因子收益整体放大 100 倍。解决方法是取数后立刻统一成小数,在w.wsd返回处做x / 100.0,不要拖到因子构建那一步才处理。另外还要检查复权参数,如果自己用价格算收益率,前复权和后复权算出来的因子序列在除权除息日附近会明显偏离。

5.2 现象:SMB 与 HML 高度共线,回归系数不稳定

回归输出里 SMB 和 HML 的系数在样本区间切换时剧烈抖动,VIF 明显偏高。原因基本是分组边界不干净:规模分位点用的是全 A 股,但账面市值比分位点用的是样本股票自己,两套分组锚在部分区间重叠严重。Fama-French 原论文里所有分位点统一用 NYSE 股票计算,A 股没有这个条件,但至少要把规模和账面市值比的分组锚统一到同一个股票池。解决方法是在 4.2 的分组函数里写死一个anchor_universe参数,全流程只用一个股票池算分位点。

5.3 现象:回归 R2 很高但 alpha 的 t 值忽高忽低

日频回归跑出来 R2 有 0.9,但把样本区间从一年改成半年,alpha 的 t 值就从 2.5 掉到 0.8。原因有两个:一是日频残差自相关导致 OLS 标准误不可信,二是因子和个股收益率本身都有明显的波动聚集效应。解决方法是回归时用 Newey-West 调整标准误,也就是代码里的cov_type="HAC";更稳妥的做法是先用日频因子构建月度收益率,再做月频回归,月频数据的残差自相关问题会轻很多,结果也更容易和研报对比。

5.4 现象:新股和 ST 股把组合收益拉偏

某个小盘组合的月收益突然出现极端值,查下去发现是刚上市两个月的次新股或者一只摘帽 ST 股贡献的。原因就是股票池筛选没做干净,次新股上市初期波动巨大,ST 股的交易结构和正常股票完全不同,它们混进组合后会把市值加权收益带偏。解决方法是在入池条件里加三条硬性过滤:上市天数不少于 252 个交易日、剔除is_st=1的股票、剔除分组日成交额为 0 的股票。这三条过滤要放在取数之后、分组之前,且每次调仓都重新执行。

5.5 现象:换月时因子序列跳变,前一个月和后一个月不连续

6 月底构建的组合,7 月初的因子值和 6 月底的因子值之间出现明显断层。原因多半是账面价值的使用时点错了:如果用了当年一季报或半年报的最新数据,不同股票因为披露时间不同,账面价值的新鲜度不一致,组合切换时因子就会跳。解决方法是固定使用上年年报的所有者权益,并且等年报披露窗口完全关闭(4 月 30 日)后再换仓。实际操作中我会额外建一个disclosure_map,记录每个股票年报实际披露日期,确保取到的账面价值在组合构建日之前是公开可得的。

6. 把它做成每周可复跑的因子工厂:脚本化、缓存与结果校验

走到这一步,流程已经能跑通,但手工执行一遍要花不少时间。更实际的做法是把整个流程写成一个配置驱动的脚本:一个config.json维护股票池、起止日期、分组分位数、调仓月份,一个主程序按"取数 → 清洗 → 分组 → 因子 → 回归"的顺序执行。取数结果缓存成 parquet 文件,Wind 接口下次就不用重新拉,既省时间又避免重复消费接口次数。

import json import pandas as pd def run_fama_french(config_path): with open(config_path, "r", encoding="utf-8") as f: cfg = json.load(f) # 从缓存读行情,没有缓存则调用 wsd 拉取并存档 df_daily = load_or_fetch("daily.parquet", fetch_daily, cfg) # 从缓存读截面数据 df_cross = load_or_fetch("cross.parquet", fetch_cross, cfg) # 清洗、分组、计算因子 df_pool = prepare_universe(df_cross, df_daily) port_ret = ff_group_and_weight(df_pool, df_daily) factors = compute_factors(port_ret) # 回归并写日志 res = reg_all_stocks(factors, cfg["stock_list"]) save_report(res, "factor_report.csv") return res

脚本化的价值在于每次换股票池、换时间区间只需要改配置,不用动代码。校验环节我一般做三件事:第一,手工抽查三只股票的收益率序列,和 Wind 终端界面肉眼核对;第二,检查 SMB 和 HML 的相关系数,经验上绝对值超过 0.4 就要回头查分组逻辑;第三,对比五因子和三因子的回归结果,如果五因子里 RMW、CMA 都不显著且 alpha 变化很小,说明这组数据大概率没有问题。我自己在这套流程上踩过最深的坑,几乎都是数据对齐和时点问题,模型本身的代码从来没有出过错。写模型前先花半天把数据口径理清楚,比调参重要得多,希望帮到你。

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