📎 配套代码:
第21讲_案例二_财务因子与时间对齐.py
📊 配套数据:data/quote/data/fundamental/data/industry/—— 交易数据、财务数据、行业数据(沪深300 × 2024 年以来,仓库自带,开箱即跑)
上一讲的因子来自价格。价格每天都有,每天都是当天就能看到的——数据的时间属性最简单。
财报不一样:一个季度才更新一次,而且要等公告日之后才能用。数据的时间属性一变,整条流水线的接法就得跟着变。
🎯 这一讲结束时,你能
- 说清财务数据的三个数据质量问题,以及各自该怎么处理
- 用
merge_asof把季频财报按公告日贴到日频交易日上 - 量化”按报告期对齐”和”按公告日对齐”的差异有多大
- 识别数据本身的局限,并在结论里如实说明
一、🎯 要算什么
问题
用 ROE 做因子。ROE 来自财报,而财报有个价格数据没有的麻烦:它描述的时间段,和它能被看到的时间,不是同一个时间。
所以问题不是”怎么算 ROE”,而是:2024 年 4 月 15 日那天,我能看到的最新 ROE 是多少?
产出
一张和行情表等长的表,每个交易日每只股票配一个当天真实可见的 ROE:
ts_code trade_date close roe end_date
600519.SH 20240415 1685.01 36.1755 20231231 ← 用的是 2023 年报
600519.SH 20240429 1403.20 10.5688 20240331 ← 换成一季报了
...
(171347, 7)
关键在 end_date 那一列什么时候切换——必须切换在公告日,而不是报告期结束日。
为什么这件事值得单独一讲
因为做错了不报错,而且回测结果会变好。第 20 讲那条流水线可以原样复用,只要把因子从动量换成 ROE。唯一的区别就是这一步时间对齐——但它足以让整个回测失真。
二、📥 要用什么数据
| 字段 | 来源 | 为什么需要 |
|---|---|---|
ts_code、trade_date、close
|
pro_bar |
日频主表,要往上贴 ROE |
roe |
fina_indicator |
因子本身 |
ann_date |
fina_indicator |
公告日——决定这条记录什么时候可用 |
end_date |
fina_indicator |
报告期——这份财报描述哪个季度 |
POOL = rqdata.hs300_pool() px = rqdata.pro_bar(codes=POOL, since="20240101")"ts_code", "trade_date", "close" fi = rqdata.fina_indicator(codes=POOL) # 全历史,(19197, 6)
注意财务表取的是全历史,不按日期截断。因为 2024 年 1 月要用的是 2023 年三季报,那条记录的 end_date 在 2023 年。财务数据的时间范围必须比行情宽。
三、🧭 设计思路
核心矛盾
行情是日频、无延迟的:t 日的收盘价,t 日收盘就知道。
财报是季频、有延迟的:t 日结束的季度,要等 t+50 天左右才公布。
把后者贴到前者上,就必须回答”每个交易日,最新已公布的是哪一期”。
三种做法,两种是错的
| 做法 | 怎么做 | 对不对 |
|---|---|---|
| 按报告期 | t 日用它所属季度的财报 | ❌ 用了还没公布的数据 |
| 按公告日等值匹配 | 只在公告日当天有值,其余为空 | ❌ 大部分交易日没有因子值 |
| 按公告日 as-of | t 日用不晚于 t 日的最后一次公告 | ✅ |
第三种正是 merge_asof 做的事(第 13 讲)。它不要求键相等,只要求”右表的时间不晚于左表”,取最近的一条。
数据本身还有三个问题要先解决
在对齐之前,财务表必须先清洗:
- 同一报告期有多条记录——财报修正、更正公告、以及纯粹的重复行
- 公告日缺失——没有公告日就无法判断什么时候可用
- 公告延迟差异巨大——中位数 50 天,但有的能到几年
第 1、2 条要在对齐前处理掉。第 3 条不用处理,但要知道它的存在——它决定了 merge_asof 的 tolerance 该设多大。
四、📝 伪代码
输入:行情表(股票、日期、收盘价)、财务表(股票、公告日、报告期、ROE)
1. 检查财务表的三个数据质量问题
- 同一 (股票, 报告期) 有几条记录?
- 公告日缺失多少?
- 公告日比报告期晚多少天?(看分位数)
2. 清洗财务表
丢掉公告日或 ROE 缺失的行
按 (股票, 报告期, 公告日) 排序
同一 (股票, 报告期) 只留最后一次公告 # 即最新修正版
3. 两张表各自按时间列排序 # merge_asof 的硬性要求
4. as-of 对齐:
对每只股票,对每个交易日:
找出该股票所有公告日 ≤ 当前交易日的记录
取其中公告日最晚的那条
如果最近的一条已经超过 400 天,视为无效 # tolerance
要求:结果行数 = 行情表行数
5. (对照实验)按报告期对齐:
对每个交易日:
找出它所属的上一个季度末
用该报告期的财报
6. 比较两种对齐的差异:
有多少行的 ROE 不同?
第 4 步和第 5 步的输入完全相同、输出形状也完全相同,差别只在”取哪一条”。这正是它容易出错的原因——从代码形状上看不出对错。
五、🔧 逐段实现
第 1 步:检查财务数据的三个问题
拿到财务指标表(沪深 300 成分股全历史,19197 行),先看三件事。
问题一:同一个报告期有多条记录
fi.duplicated(["ts_code", "end_date"]).sum()
3469 行 / 19197 占 18.1%
近两成是重复的。看一个例子:
ts_code ann_date end_date roe or_yoy 000001.SZ 20120816 20120630 8.8308 61.6965 000001.SZ 20120816 20120630 8.8308 61.6965 ← 完全一样的两行 000001.SZ 20130308 20121231 16.9537 34.0909 000001.SZ 20130308 20121231 16.9537 34.0909
这里是完全重复的记录——连公告日和数值都一样,属于数据源的冗余。另一类重复是财报修正:同一报告期、不同公告日、数值不同。
两类要分开处理:
f = f.sort_values(["ts_code", "end_date", "ann_date"]) f = f.drop_duplicates(["ts_code", "end_date"], keep="last") # 留最后一次公告
19197 → 15633 行
keep="last" 配合先按 ann_date 排序,取的是最新公布的版本。
WARNING: ⚠️ “取最新版本”在回测里是有问题的
财报修正后的数据,在修正公告之前是看不到的。严格的 PIT 应该用当时那个版本。
但多数数据源不保留历史版本,只给你最终值。这是一个你知道存在、但用现有数据修不了的偏差——正确的做法是在结论里说明它,而不是假装不存在。
问题二:公告日缺失
fi["ann_date"].isna().sum() # 4
只有 4 行。但它们必须处理——没有公告日就无法判断什么时候可用:
f = f.dropna(subset=["ann_date", "roe"])
第 09 讲说过:删之前先知道删了多少。4 行占比极小,直接删是合理的;如果是几千行,就得先搞清楚为什么缺。
问题三:公告日比报告期晚多少
lag = (pd.to_datetime(fi["ann_date"]) - pd.to_datetime(fi["end_date"])).dt.days
中位数 50 天 90 分位 117 天 最大 2019 天
中位数 50 天,90 分位接近 4 个月。最大值 2019 天是异常记录(补公告历史数据),实务里要单独看。
这 50 天就是这一讲的全部问题所在。
第 2 步:两种对齐方式
方式 A:按公告日 as-of(正确)
f["ann"] = pd.to_datetime(f["ann_date"], format="%Y%m%d") f = f.sort_values("ann") p = px.sort_values("dt") A = pd.merge_asof(p, f"ann", "ts_code", "roe", "end_date", left_on="dt", right_on="ann", by="ts_code", direction="backward", tolerance=pd.Timedelta("400D"))
→ (171347, 7) 行数和行情表一样
每个交易日贴的是那天之前最后一次公告的财报(第 13 讲)。
四个参数一个都不能少:两表按时间列排序、by="ts_code" 防止跨股票匹配、direction="backward" 只看过去、tolerance 防止长期不发财报的公司一路沿用过期数据。
方式 B:按报告期对齐(常见错误)
p["qtr"] = (p["dt"] - pd.offsets.QuarterEnd(1)).dt.strftime("%Y%m%d") B = p.merge(f, left_on=["ts_code", "qtr"], right_on=["ts_code", "end_date"], how="left")
意思是”每个交易日用它所属的上一个季度的财报”。看起来很合理,也不报错。
差异有多大
可比行数 171,314 因子值不同的行数 102,778 (60.0%) 不同的行上 ROE 平均差 4.582
60% 的行,两种方式给出不同的 ROE。
六、📊 一个具体的例子
看茅台的财报公告时间表:
end_date ann_date roe 20231231 20240403 36.1755 ← 2023 年报,4 月 3 日公布 20240331 20240427 10.5688 ← 2024 一季报,4 月 27 日公布 20240630 20240809 19.2038 20240930 20241026 26.8330
现在问:2024 年 4 月 15 日那天,你能看到的最新 ROE 是多少?
方式 A(按公告日):2023 年报的 36.1755
——4 月 3 日已公布,4 月 15 日确实能看到 ✅
方式 B(按报告期):2024 一季报的 10.5688
——但它 4 月 27 日才公布,提前了 12 天 ❌
ROE 从 36.18 变成 10.57,差了三倍多。而这只是一只股票、一天的差异。
IMPORTANT: 🔑 为什么年报 ROE 和一季报 ROE 差这么多
因为它们的口径不同——年报是全年累计,一季报是一个季度。这不是数据错误。
但正因如此,用错了时间对齐,因子值不只是”稍微偏一点”,而是换了一个完全不同的量纲。整个截面排序都会乱掉。
七、🐛 数据的局限:说明白,别假装
这个案例里有两个用现有数据修不了的问题。
局限一:股票池是当前成分
POOL = 沪深300 最新一期的 300 只成分股
用今天的成分股去回测历史,是典型的幸存者偏差——那些期间被调出指数的股票(往往是表现差的)不在样本里。
严格做法是用每期的历史成分。本地 index_weight 表确实有历史数据(2025-04 起),但覆盖期比行情短得多。
局限二:行业分类只有当前快照
sb = rqdata.stock_basic() len(sb), sb["ts_code"].nunique() # (5519, 5519) → 每只股票只有一行
每只股票只有一行,没有任何时间维度——说明这张表是当前的行业归属快照,不是历史。一家公司 2015 年从制造业转型到软件业,这张表只记得它现在是软件业。
用它做历史的行业中性化,会把”当时的行业”记错。
判断一张分类表有没有历史,看两件事:有没有生效日期字段(in_date/out_date),以及同一个主体是否出现多行。两者都没有,就是快照。
IMPORTANT: 🔑 数据局限的正确处理方式
不是忽略,也不是因此放弃分析,而是三步:
- 发现它——用「有没有生效日期字段」「每只股票出现几次」这类检查
- 估计影响方向——幸存者偏差通常让回测结果偏好
- 在结论里写出来——”本结果使用当前成分股和当前行业分类,存在幸存者偏差和行业归属的前视偏差”
分析报告里最有价值的往往不是结论,是这段说明。它决定了别人该多大程度上相信你的数字。
八、🔧 把财务因子和价格因子合成
有了 PIT 正确的 ROE,就能和上一讲的动量因子放在一起:
# 两个因子各自做截面标准化(第 16 讲) for col in ["roe", "mom"]: g = df.groupby("trade_date")[col] df[col + "_z"] = (df[col] - g.transform("mean")) / g.transform("std") df["combo"] = 0.5 * df["roe_z"] + 0.5 * df["mom_z"]
标准化是合成的前提。两个因子的量纲差了两个数量级:
roe 区间 [ -76.29, 188.61] 标准差 10.359 mom 区间 [-0.6875, 3.0150] 标准差 0.120
不标准化直接相加会怎样:
合成因子与 roe 的相关系数 0.9999 ← 动量白加了
0.9999——合成因子几乎就是 ROE 本身。动量的方差只有 ROE 的百分之一,加进去等于没加。
标准化之后:
与 roe_z 相关 0.7225 与 mom_z 相关 0.7225 ← 两个因子各占一半
合成之后的检查点:
每日截面均值 |max| 6.0e-08 ≈ 0 ✓ 每日截面标准差均值 0.7213 ← 不是 1 两因子相关系数 +0.0440
均值仍然是 0,但标准差不再是 1。因为两个因子基本不相关(0.044),等权合成后方差约为 √(0.5² + 0.5²) ≈ 0.707,实测 0.7213 正好在这个量级。
这个数本身就是个检查点:如果合成后的标准差接近 1,说明两个因子高度相关,合成没有带来新信息。
九、📚 复盘:这个案例用到了哪些 pandas 功能
按讲次回顾
| 讲次 | 用在哪一步 | 具体写法 |
|---|---|---|
| 第 08 讲 数据加载 | 读财务表 | 财务表取全历史,不按日期截断 |
| 第 09 讲 缺失与排序 | 清洗、merge_asof 前 |
dropna(subset=)、sort_values
|
| 第 10 讲 转换与字符串 | 去重 |
drop_duplicates(keep="last") + 先排序 |
| 第 13 讲 合并连接 | 核心一步 | merge_asof(left_on=, right_on=, by=, direction=, tolerance=) |
| 第 16 讲 transform | 因子合成 |
transform("mean") / transform("std") 做截面标准化 |
| 第 18 讲 时间类型 | 日期转换 |
to_datetime(format="%Y%m%d")、Timedelta
|
比上一讲少了几讲,因为这一讲的难点不在 API,在时间语义。真正的核心只有一个函数:merge_asof。
一个观察:同样的数据,两种对齐
第 4 步和第 5 步用的是同一份输入,产出同样的形状:
pd.merge_asof(p, f, left_on="dt", right_on="ann", by="ts_code", direction="backward") p.merge(f, left_on=["ts_code","qtr"], right_on=["ts_code","end_date"], how="left")
两行都不报错,结果都是 (171347, 7)。但 60% 的行 ROE 不同。
这是整门课里最纯粹的一个例子:代码的正确性无法从代码本身判断,只能从数据的语义判断。你必须知道”财报有公告日”这件事,才能看出第二行是错的。
merge_asof 的四个参数,每个都对应一个业务约束
| 参数 | 业务含义 | 不写会怎样 |
|---|---|---|
| 两表按时间排序 | as-of 查找的前提 | 直接报错 |
by="ts_code" |
每只股票各自对齐 | 把 A 公司的财报贴到 B 公司 |
direction="backward" |
只能看过去 |
forward 就是用下一次公告 = 未来函数 |
tolerance= |
数据有保质期 | 三年前的财报一路沿用到今天 |
这四个参数不是 API 细节,是把业务规则翻译成代码。 少写一个,代码照样跑,结果照样有,只是不再对应现实。
最值得带走的三件事
① 数据有两种时间:描述的时间,和可见的时间。 价格这两者重合,所以感觉不到。财报、分析师预测、宏观数据、指数成分调整——凡是”公布”出来的数据,都有这个区别。看到一张表有两个日期列,先搞清楚哪个是可见时间。
② 清洗要在对齐之前。 财务表 18.1% 的重复记录,如果不先去重,merge_asof 会取到不确定的那一条。顺序错了,后面每一步都在错误的基础上。
③ 数据局限要写进结论。 当前成分股、当前行业快照、只有最终版财报——这三条都是知道存在但修不了的偏差。正确做法不是忽略,是三步:发现它、估计影响方向、在结论里说明。分析报告里最有价值的往往不是结论,是这段说明。
🏋️ 训练营
QUESTION: 🟢 训练 1:财务表里同一个
(ts_code, end_date)有多条记录。写出去重代码,并说明keep="last"之前为什么必须排序。
TIP: 👉 参考f = f.sort_values(["ts_code", "end_date", "ann_date"]) f = f.drop_duplicates(["ts_code", "end_date"], keep="last")
keep="last"保留的是当前行顺序里的最后一行,不是”公告日最晚的那一行”(第 10 讲)。不排序的话,留下哪一条取决于数据源给的顺序,完全不可控。
排序键要写全三列:先按股票和报告期分组定位,再按公告日决定组内顺序。
QUESTION: 🟡 训练 2:
merge_asof里去掉tolerance参数会怎样?举一个具体的场景。
TIP: 👉 参考
direction="backward"会一直往前找,找到多久以前都算。
场景:一家公司 2021 年之后停止披露(退市、长期停牌、或数据源断更)。不加tolerance,2024 年的每个交易日都会贴上 2021 年那份财报,因子值是三年前的。
这和第 09 讲ffill不加limit是完全相同的问题——过期数据比缺失数据更危险,因为它看起来是有效的。
tolerance=pd.Timedelta("400D")让超过约一年的匹配返回 NaN。400 天而不是 365,是给年报公告留出余量(年报通常在次年 4 月才出,间隔可能接近 480 天,所以实务里tolerance要按最长的正常间隔来定)。
QUESTION: 🔴 训练 3:你要把这个 PIT 流水线改造成”每月最后一个交易日调仓”的月频版本。哪些步骤要改?哪些可以原样复用?
TIP: 👉 参考
原样复用:财报清洗(去重、缺失处理)、merge_asof对齐——这两步和调仓频率无关。
需要改的:# 先取出每月最后一个交易日(第 18、19 讲) month_ends = (px.set_index("dt").groupby("ts_code") .resample("ME")["close"].last().index) # 或更直接:在日频结果上筛 monthly = A[A["dt"].isin(A.groupby(A["dt"].dt.to_period("M"))["dt"].max())]一个容易错的地方:月末调仓意味着你在月末收盘后才知道因子值,实际执行在下个月第一个交易日。所以收益要用
shift(-1)取下个月的,而不是当月的。
这和第 19 讲”信号必须比收益早至少一期”是同一条规则,只是频率从日变成了月。
另一个:月频下tolerance的含义没变(还是按自然日算),但样本量会少 20 倍,qcut分档时更容易撞上duplicates问题(第 10 讲)。
✍️ 作业
- 统计你手上财务表的三个数据质量指标:重复率、
ann_date缺失数、公告滞后的分位数。和课件里的对照。 - 挑一只股票,打印它连续四期的
end_date/ann_date/roe,然后手工判断某个具体日期上”当天可见的最新 ROE”是哪一期。再用merge_asof跑一遍,确认结果一致。 - 实现方式 A 和方式 B,统计两者因子值不同的行数占比。再挑几行差异最大的,打印出对应的
end_date和ann_date,解释差异从何而来。 - 检查
stock_basic里每只股票出现几次、有没有生效日期字段,确认它是当前快照。写一段两三句话的数据局限说明,就像你要把它放进研究报告一样。 - 把 ROE 和上一讲的动量因子合成。分别在标准化前后各合成一次,比较两个合成因子和原始 ROE 的相关系数,用它说明为什么标准化是必需的。
- 思考题:本讲的
tolerance=400D是拍出来的。怎么用数据本身算出一个合理的值?(提示:统计同一只股票相邻两次财报公告的间隔分布,看它的高分位在哪里。)
🔮 下讲预告:第 22 讲——案例三:行业轮动策略完整回测。前两个案例都停在”因子值”这一步,最后一讲要走到底:从信号到持仓、从持仓到净值、从净值到绩效指标。你会看到一条完整回测链上有多少个地方要对齐时间,以及”差一天”到底差多少。