📎 配套代码:
第20讲_案例一_单因子测试.py
📊 配套数据:data/quote/data/industry/—— 交易数据、行业数据(沪深300 × 2024 年以来,仓库自带,开箱即跑)
前十九讲每一讲解决一个局部问题。这一讲把它们串成一条完整的流水线。
练的是数据处理,不是投资判断。 因子有没有效是别的课的事,这里只关心:给定一个问题,怎么一步步把数据变成答案。
一、🎯 要算什么
问题
手上有一个因子(这里用 20 日动量),想知道它对未来收益有没有预测能力。
产出
两样东西,都是可以直接下结论的形式:
① 每日 IC 序列——因子值和次日收益的截面相关系数,每天一个数
trade_date 20240201 0.0821 20240202 -0.0344 ... (549,)
IC 为正说明因子值高的股票次日涨得多。看它的均值和稳定性,就知道因子有没有方向。
② 分档收益矩阵——每天把股票按因子值分五档,记录每档的次日收益
q 0 1 2 3 4 trade_date 20240201 0.0031 -0.0012 0.0008 0.0021 0.0044 ... (549, 5)
看各档收益是不是单调,就知道因子是不是”越大越好”。
为什么要这两个,而不是只要一个
它们回答的是两个不同的问题:
- IC 问”因子和收益的相关性有多强”——这是个连续的度量
- 分档 问”照着因子选股能不能赚钱”——这是个可执行的策略
一个因子可能 IC 很小但分档单调(弱而稳定),也可能 IC 不小但分档乱七八糟(被少数极端值撑起来)。两个一起看才完整。
二、📥 要用什么数据
需要哪些字段
| 字段 | 来源 | 为什么需要 |
|---|---|---|
ts_code、trade_date
|
pro_bar |
面板的两个维度,所有分组和排序都靠它们 |
close |
pro_bar |
算因子(20 日动量)和标签(次日收益) |
pct_chg |
pro_bar |
只用来验证 close 是不是复权价,不参与计算 |
name |
stock_basic |
识别 ST 股,剔除 |
industry |
stock_basic |
行业中性化 |
total_mv |
daily_basic |
市值中性化 |
POOL = rqdata.hs300_pool() # 沪深300 最新成分,300 只 px = rqdata.pro_bar(codes=POOL, since="20240101") # (171347, 9) sb = rqdata.stock_basic() # (5519, 6) db = rqdata.daily_basic(codes=POOL, since="20240101") # (170148, 8)
用之前先确认数据能用
拿到价格数据,第一件事是确认它是不是复权价。
不复权的价格在除权日会出现巨大跳空——10 送 10 之后价格直接腰斩。用它算收益率,那天就是 −50%,而实际持有人一分钱没亏。
怎么验证?拿 close 自己算的收益率,和数据源给的 pct_chg 对照:
t = px[px["ts_code"] == "600519.SH"].sort_values("trade_date") diff = (t["close"].pct_change(fill_method=None) - t["pct_chg"] / 100).abs() (diff > 0.02).sum()
0 ← 一天都对不上不了,说明 close 已经是复权价
如果这个数不为 0,说明 close 是未复权价,需要乘上复权因子再用。
IMPORTANT: 🔑 这类”数据本身对不对”的检查,成本极低,收益极高
一行代码,避免整条流水线基于错误的输入。
类似的还有:pct_chg的绝对值有没有超过 11%(涨跌停约束)、成交量有没有负数、市值有没有 0。
拿到任何数据,先用你知道的业务规则验证它一次。
三、🧭 设计思路
从”原始行情”到”IC 和分档收益”,中间要跨过四道坎。
坎一:因子和标签必须错开时间
因子是 t 日算出来的,收益必须是 t+1 日的。同期相乘就是未来函数(第 19 讲)。
→ 因子用 pct_change(20),标签用 pct_change().shift(-1)。
坎二:不同股票的因子值不可比
一只股票 20 日涨 5%,另一只涨 50%——但如果第二只本来就波动大,5% 可能更难得。直接比大小是在比波动率,不是在比动量。
→ 每天在截面上标准化:减去当日均值、除以当日标准差。这样每天的因子值都是”相对同期其他股票的位置”。
坎三:因子里混着别的东西
高动量的股票可能集中在某几个行业,或者都是小市值。这时候你测到的”动量有效”,可能只是”那个行业涨了”或”小市值涨了”。
→ 中性化:减去所属行业当日均值、减去所属市值档当日均值。剥掉这两层,剩下的才是动量自己的贡献。
坎四:极端值会主导统计量
一只股票 20 日涨了 10 倍,标准化时它会把整个分布拉歪,均值和标准差都失真。
→ 去极值:每天把因子值截断到 1%~99% 分位之间。注意是每天各自算分位数,用全样本分位数就等于让后面的日子影响前面。
顺序为什么是这样
去极值 → 标准化 → 中性化
- 去极值必须在标准化之前:极端值不剔掉,算出来的均值和标准差就是被污染的
- 中性化在标准化之后:中性化是减去组内均值,前提是各组的数值已经在同一量纲上
四道坎跨过去,剩下的就是评价:算 IC、分档、汇总。
四、📝 伪代码
先把流程写成不带 pandas API 的形式,确认逻辑本身对:
输入:行情表(股票、日期、收盘价)、基础信息(行业、名称)、估值(市值)
1. 读数据
验证 close 是复权价 # 不通过就停
2. 把行业和市值贴到行情表上
要求:行数不变 # 贴附加信息不该改变主表行数
3. 剔除不能用的样本
- 名称含 ST 的
- 行业或市值缺失的
4. 按 (股票, 日期) 排序 # 后面所有涉及顺序的操作都依赖它
对每只股票:
因子 = 收盘价相对 20 个交易日前的涨幅
标签 = 下一个交易日的涨幅
丢掉因子或标签算不出来的行
5. 对每个交易日:
把该日所有股票的因子值截断到 1%~99% 分位
6. 对每个交易日:
因子 = (因子 − 该日均值) / 该日标准差
检查:每日均值应为 0,标准差应为 1
7. 对每个 (交易日, 行业): 因子 −= 该组均值
对每个 (交易日, 市值档):因子 −= 该组均值
8. 对每个交易日:
IC = 该日因子值与标签的秩相关系数
9. 对每个交易日:
把股票按因子值分成 5 档
对每个 (交易日, 档位):
收益 = 该组标签的均值
整理成"行是日期、列是档位"的矩阵
九步。注意第 4、5、6、7、9 步都以”对每个……”开头——它们全是分组操作,只是分组键不同(股票 / 交易日 / 交易日+行业)。这就是后面代码里 groupby 反复出现的原因。
写伪代码的价值在于:先确认逻辑对,再考虑用什么 API。上面这九步不涉及任何 pandas 知识,但如果哪一步的逻辑错了(比如因子和标签没错开),写成什么代码都救不回来。
五、🔧 逐段实现
第 1 步:读数据
px = rqdata.pro_bar(codes=POOL, since=SINCE)"ts_code", "trade_date", "close", "pct_chg"
→ (171347, 4)
为什么只取四列:原表 9 列,用不上的 5 列不读进来(第 08 讲)。这个习惯在几千万行的表上是几秒和几分钟的差别。
第 2 步:合并行业和市值
df = (px.merge(sb, on="ts_code", how="left", validate="m:1") .merge(db, on=["ts_code", "trade_date"], how="left", validate="1:1"))
→ (171347, 7) 行数不变 ✓ 行业缺失 0 市值缺失 1199
为什么写 validate:伪代码第 2 步写着”要求行数不变”,validate 就是把这个要求翻译成代码(第 13 讲)。
-
m:1——行情表一只股票几百行,基础信息表一只股票一行 -
1:1——行情和估值都是一只股票一天一条
不符合就报错,而不是让行数悄悄膨胀。把假设写进代码,比写在注释里可靠。
为什么 how="left":默认的 inner 会静默丢掉没匹配上的行。用 left 保住主表,缺的留 NaN,让你能看见有多少没匹配上。
第 3 步:剔除不可用的样本
df = df[~df["name"].str.contains("ST", na=False) & df["industry"].notna() & df["total_mv"].notna()] df["industry"] = df["industry"].astype("category")
→ (170148, 7) 少了 1199 行
na=False 不能省(第 10 讲):name 有缺失时 contains 返回 NaN 而不是 False,拿去做布尔索引会直接报错。
为什么转 category:后面第 7 步要按行业反复分组,category 的分组比 object 快,内存也省(第 11 讲)。
第 4 步:算因子和标签
df = df.sort_values(["ts_code", "trade_date"]) # ← 不能省 df["mom"] = df.groupby("ts_code")["close"].pct_change(20, fill_method=None) df["fwd"] = df.groupby("ts_code")["close"].pct_change(fill_method=None).shift(-1) df = df.dropna(subset=["mom", "fwd"])
→ (163848, 9) 掉了 6300 行 = 300 只 × 21
这两行代码里藏着三个前置条件,少一个结果就是错的,而且不报错:
| 条件 | 不写会怎样 | 出处 |
|---|---|---|
sort_values |
groupby 保持组内原序,pct_change 拿的”上一行”是随机某天 |
第 09 讲 |
groupby("ts_code") |
窗口跨越股票边界,每只开头 20 行混进上一只的价格 | 第 19 讲 |
fill_method=None |
默认先填充缺失再算,在停牌处造出假收益 | 第 14 讲 |
掉的 6300 行怎么验证:每只股票开头 20 行算不出 20 日动量、最后 1 行算不出次日收益,所以每只掉 21 行。300 × 21 = 6300,精确相等。
shift(-1) 是不是未来函数:不是。它取的是标签(要预测的东西),出现在评价环节是正常的。如果它出现在因子计算里,那才是未来函数。
第 5 步:去极值
g = df.groupby("trade_date")["mom"] lo, hi = g.transform(lambda x: x.quantile(.01)), g.transform(lambda x: x.quantile(.99)) df["f"] = df["mom"].clip(lo, hi)
截断 3294 行(2.0%) ← 上下各 1%,合计正好 2%,说明分位数算对了
为什么用 transform 而不是 agg:要的是”每一行配上它那天的分位数”,长度必须和原表一样(第 16 讲)。agg 每天只给一个数,形状对不上。
为什么分组键是 trade_date:每天各自算分位数。用全样本分位数,等于让 12 月的极端值影响 1 月的截断——这是隐蔽的未来函数。
第 6 步:截面标准化
gz = df.groupby("trade_date")["f"] df["f"] = (df["f"] - gz.transform("mean")) / gz.transform("std")
每日截面均值 |max| 1.8e-16 ≈ 0 ✓ 每日截面标准差均值 1.0000 ✓
为什么拆成两次 transform 而不是一个 lambda:第 16 讲实测快 47 倍。transform("mean") 走编译好的快路径,lambda 每组都要往返 Python。
这两个数是检查点:标准化之后每天的截面均值必然是 0、标准差必然是 1。对不上就说明分组键写错了——比如漏了 trade_date,变成了全样本标准化。
第 7 步:中性化
df["f"] -= df.groupby(["trade_date", "industry"], observed=True)["f"].transform("mean") df["mv_q"] = df.groupby("trade_date")["total_mv"].transform( lambda s: pd.qcut(s, 5, labels=False, duplicates="drop")) df["f"] -= df.groupby(["trade_date", "mv_q"], observed=True)["f"].transform("mean")
→ (163848, 11)
分组键为什么都含 trade_date:中性化问的是”相对同一天同行业的其他股票”。少了日期就变成跨期比较,把不同时期的行业水平混在一起。
observed=True 为什么不能省:industry 是分类列,不写会生成所有行业 × 所有日期的全组合,其中大部分是空组(第 11 讲)。
为什么市值要先分档再中性化:市值是连续值,没法直接”减去同组均值”。先 qcut 分成 5 档,把连续变量变成分组键。这一步是少数不得不用 lambda 的地方——qcut 没有内置的 transform 字符串形式。
第 8 步:算 IC
ic = df.groupby("trade_date").apply( lambda x: x["f"].corr(x["fwd"], method="spearman"), include_groups=False)
→ (549,) 长表压成了每天一行
为什么这里可以用 apply:它要同时看两列做相关。agg 一次只能作用在一列上,transform 要求返回等长结果——都表达不了(第 07 讲)。这是”确实需要看整个子表”的情况。
为什么用 spearman 而不是 pearson:秩相关只看排序,不受剩余极端值影响。因子研究里的 IC 默认是秩相关。
第 9 步:分档收益矩阵
df["q"] = df.groupby("trade_date")["f"].transform( lambda s: pd.qcut(s, 5, labels=False, duplicates="drop")) tbl = df.pivot_table(index="trade_date", columns="q", values="fwd", aggfunc="mean", observed=True) tbl["LS"] = tbl[4] - tbl[0] nav = (1 + tbl["LS"].fillna(0)).cumprod()
→ (549, 5) 分档 × 日期 的矩阵 → (549,) 多空净值曲线
为什么用 pivot_table:伪代码第 9 步要的是”行是日期、列是档位”的矩阵,这正是 pivot_table 的形状(第 17 讲)。用 groupby([日期, 档位]).mean() 得到的是长表,还要再 unstack 一次。
duplicates="drop" 不能省:某天如果因子值大量重复(比如中性化后很多股票落在同一个值),分位数边界会重复,qcut 直接报错中断整条流水线(第 10 讲)。
六、📊 跑出来是什么样
IC 均值 -0.0129 IC 标准差 0.0978 IR -0.132 IC > 0 占比 44.4% 各档平均日收益(bp) q0 10.92 q1 6.49 q2 5.02 q3 5.44 q4 13.71 多空 2.79 多空净值末值 1.1479 交易日 549 全流程耗时 1.3 秒
NOTE: 💡 这些数字说明流水线跑通了,不说明因子有效
各档收益不单调(q0 和 q4 都比中间高),IC 均值接近 0,IR 也很小。
这一讲的目标是走通流程,不是找到一个好因子。 因子怎么筛选、怎么检验显著性、怎么组合,是因子投资课的内容。
值得关注的是:每一步的检查点都对上了——行数按预期变化、截面均值是 0、去极值正好截断 2%。流水线本身是可靠的。
七、🐛 检查点放在哪
好的流水线不是”能跑通”,而是错了会立刻停下来。这九步里埋了六个检查点:
| 位置 | 检查什么 | 怎么写 |
|---|---|---|
| 第 1 步前 | 价格是不是复权的 |
close.pct_change() 对照 pct_chg
|
| 第 2 步 | 合并有没有让行数膨胀 |
validate="m:1" / "1:1"
|
| 第 2 步 | 有多少没匹配上 | df["industry"].isna().sum() |
| 第 4 步 | 掉的行数是否合理 | 应 ≈ 股票数 × (回看期+1) |
| 第 5 步 | 去极值截断比例 | 应 ≈ 2% |
| 第 6 步 | 标准化后截面均值/标准差 | 应 ≈ 0 和 1 |
assert len(df2) == len(df1), "merge 让行数变了" assert abs(df.groupby("trade_date")["f"].mean()).max() < 1e-10, "标准化没对" assert 0.015 < n_clip / len(df) < 0.025, "去极值比例异常"
IMPORTANT: 🔑 检查点要放在”形状发生变化”的地方
流水线里最容易出错的不是计算,是接口——合并、筛选、分组、重塑这些改变形状的操作。
每次形状变了,就问一句”变成这样对吗”,并把答案写成assert。
这些断言的成本是几毫秒,省下的是”跑完发现结果不对、回头逐步排查”的几小时。
八、📚 复盘:这个案例用到了哪些 pandas 功能
按讲次回顾
| 讲次 | 用在哪一步 | 具体写法 |
|---|---|---|
| 第 04 讲 按条件取数 | 第 3 步剔除样本、第 5 步去极值 | 布尔索引 &、clip(lo, hi)
|
| 第 07 讲 apply | 第 8 步算 IC |
groupby().apply() + include_groups=False
|
| 第 08 讲 数据加载 | 第 1 步读数据 | read_parquet(columns=, filters=) |
| 第 09 讲 缺失与排序 | 第 3、4 步 |
sort_values(["ts_code","trade_date"])、dropna(subset=)
|
| 第 10 讲 转换与字符串 | 第 3 步识别 ST、第 7/9 步分档 |
str.contains(na=False)、qcut(duplicates="drop")
|
| 第 11 讲 Categorical | 第 3 步转类型、第 7 步分组 |
astype("category")、observed=True
|
| 第 13 讲 合并连接 | 第 2 步贴行业市值 | merge(how="left", validate="m:1") |
| 第 14 讲 重塑与透视 | 第 4 步算收益 | pct_change(fill_method=None) |
| 第 15 讲 分组与聚合 | 第 8 步 |
groupby() 的分组键选择 |
| 第 16 讲 transform | 第 5、6、7、9 步 |
transform("mean")、transform("std")、多键分组 |
| 第 17 讲 透视表 | 第 9 步 | pivot_table(index=, columns=, values=, aggfunc=) |
| 第 19 讲 移动窗口 | 第 4 步 |
shift(-1)、groupby 后再做时序运算 |
十二讲里的十一讲都用上了,而且不是零散地用——它们按顺序串成了一条链。
一个观察:transform 出现了四次
第 5、6、7、9 步全是 transform:
g.transform(lambda x: x.quantile(.01)) # 去极值的边界 gz.transform("mean") / gz.transform("std") # 标准化 groupby([日期, 行业]).transform("mean") # 中性化 groupby(日期).transform(qcut) # 分档
它们的共同形式是:按某个键分组算一个统计量,然后把结果贴回每一行。
这正是因子处理的基本动作——因子值永远是”相对于同一天其他股票”的,而”同一天其他股票的统计量”必须广播回每一行才能相减。理解了 transform 的形状语义,因子预处理的大半就通了。
一个对比:什么时候不用 transform
第 8 步算 IC 用的是 apply,因为它要同时看两列(因子和收益)做相关。第 9 步的汇总用的是 pivot_table,因为要的是二维矩阵不是等长的列。
三者的分工是形状决定的:
| 想要什么形状 | 用什么 |
|---|---|
| 和原表等长,贴回每一行 | transform |
| 每组一个值,做汇总表 |
agg / pivot_table
|
| 需要同时看多列、或返回任意形状 | apply |
最值得带走的三件事
① 检查点放在形状变化的地方。 合并、筛选、分组、重塑——这四类操作改变形状,也是最容易出错的地方。每次形状变了就问一句”变成这样对吗”,写成 assert。
② 分组键决定了含义。 同一个 transform("mean"),分组键是 trade_date 就是截面标准化,是 ts_code 就是时序标准化,两者形状一样、都不报错、含义完全不同(训练 2)。写 groupby 时先问自己:我要和谁比?
③ 先写伪代码。 这条流水线的九步,逻辑上和 pandas 无关。先把逻辑写对,再考虑用什么 API——顺序反了,容易被 API 牵着走,写出”能跑但逻辑错”的代码。
🏋️ 训练营
QUESTION: 🟢 训练 1:第 4 步掉了 6300 行。这个数字合理吗?自己推算一遍再对照。
TIP: 👉 参考
每只股票开头 20 行算不出 20 日动量、最后 1 行算不出次日收益,所以每只掉 21 行。
300 只 × 21 = 6300,和实际掉的行数精确相等。
能精确相等,说明这 300 只股票在样本期内每只都有完整的连续数据——没有中途上市的、没有长期停牌到样本不足 21 行的。
反过来,如果这个数小于 300×21,就说明有股票的样本不足 21 行。那不是错误,但值得去看一眼是哪几只、为什么。
这类”预期行数”的推算是流水线自检的常用手段——算得出来的数就该算一遍。
QUESTION: 🟡 训练 2:设计思路里说”去极值必须在标准化之前”。把这两步对调,结果会怎样?
TIP: 👉 参考
对调之后:先用含极端值的数据算均值和标准差,标准化的分母被极端值撑大了;再去极值,截断的是已经被压缩过的值。
后果是所有股票的因子值都偏小(因为除了一个过大的标准差),而且截断比例不再是 2%——极端值在标准化后可能已经落在 1%~99% 分位内,根本截不到。
验证方法很直接:对调后再看第 5 步的截断比例,如果明显偏离 2%,说明顺序错了。
这就是为什么检查点有用——它不只验证代码跑没跑通,还验证步骤的顺序对不对。
QUESTION: 🔴 训练 3:这条流水线里有三处如果去掉就会得到”看起来正常但完全错误”的结果。找出来,说明各自的错误长什么样。
TIP: 👉 参考
① 第 4 步的sort_values(["ts_code","trade_date"])
groupby保持组内原序(第 09 讲)。数据源顺序不保证按时间时,pct_change用的”上一行”是随机的某一天。第 09 讲实测:不排序算出来 58% 的交易日涨幅超过 10%,而 A 股有涨跌停。② 第 4 步的
groupby("ts_code")
不分组,pct_change(20)的窗口会跨越股票边界(第 19 讲)。每只股票开头 20 行用的是上一只股票的价格。而 NaN 数量看起来反而”更完整”——第 19 讲实测不分组只有 19 个 NaN,分组后有 5700 个。③ 第 6、7 步分组键里的
trade_date
去掉它,标准化和中性化就变成用整个样本期算均值——等于用后面的日子影响前面。这是一种隐蔽的未来函数,而且结果的形状、量级都正常。三处的共同点:都不报错,结果都长得像对的。这也是整门课反复出现的主题。
✍️ 作业
- 把这条流水线完整跑一遍,记录每一步的形状,和课件里的对照。任何一步对不上,回去找原因。
- 把股票池换成另一批股票(比如
stock_basic里按行业筛一批),重跑一遍。重点是确认流水线换了池子仍然跑通、检查点仍然通过。 - 把 20 日动量换成另一个因子(换手率、波动率、或 5 日反转),其余步骤不动。确认流水线的可复用性——好的流水线换因子应该只改一行。
- 在第 2、4、6 步各加一个
assert,然后故意破坏一处(比如去掉sort_values),确认断言能抓住它。 - 第 8 步用了
apply算 IC。试着用别的写法实现同样的结果(提示:把因子和收益各自转成宽表,然后按行算相关),比较两种写法的耗时和可读性。 - 思考题:这条流水线的输出是”每日 IC”和”分档收益矩阵”。如果你要把它改造成多个因子一起测,哪些步骤需要改?哪些可以原样复用?(提示:想想第 14 讲的长表和宽表——多因子的自然形态是哪一种。)
🔮 下讲预告:第 21 讲——案例二:财务因子与时间对齐。这一讲的因子来自价格,价格每天都有;下一讲的因子来自财报,而财报一个季度才更新一次,还要等公告日之后才能用。数据的时间属性一变,整条流水线的接法就得跟着变。