第21讲 案例二 财务因子与时间对齐

📎 配套代码第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_codetrade_dateclose 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 讲)。它不要求键相等,只要求”右表的时间不晚于左表”,取最近的一条。

数据本身还有三个问题要先解决

在对齐之前,财务表必须先清洗:

  1. 同一报告期有多条记录——财报修正、更正公告、以及纯粹的重复行
  2. 公告日缺失——没有公告日就无法判断什么时候可用
  3. 公告延迟差异巨大——中位数 50 天,但有的能到几年

第 1、2 条要在对齐前处理掉。第 3 条不用处理,但要知道它的存在——它决定了 merge_asoftolerance 该设多大。


四、📝 伪代码

输入:行情表(股票、日期、收盘价)、财务表(股票、公告日、报告期、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: 🔑 数据局限的正确处理方式
不是忽略,也不是因此放弃分析,而是三步

  1. 发现它——用「有没有生效日期字段」「每只股票出现几次」这类检查
  2. 估计影响方向——幸存者偏差通常让回测结果偏好
  3. 在结论里写出来——”本结果使用当前成分股和当前行业分类,存在幸存者偏差和行业归属的前视偏差”

分析报告里最有价值的往往不是结论,是这段说明。它决定了别人该多大程度上相信你的数字。


八、🔧 把财务因子和价格因子合成

有了 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 讲)。


✍️ 作业

  1. 统计你手上财务表的三个数据质量指标:重复率、ann_date 缺失数、公告滞后的分位数。和课件里的对照。
  2. 挑一只股票,打印它连续四期的 end_date / ann_date / roe,然后手工判断某个具体日期上”当天可见的最新 ROE”是哪一期。再用 merge_asof 跑一遍,确认结果一致。
  3. 实现方式 A 和方式 B,统计两者因子值不同的行数占比。再挑几行差异最大的,打印出对应的 end_dateann_date,解释差异从何而来。
  4. 检查 stock_basic 里每只股票出现几次、有没有生效日期字段,确认它是当前快照。写一段两三句话的数据局限说明,就像你要把它放进研究报告一样。
  5. 把 ROE 和上一讲的动量因子合成。分别在标准化前后各合成一次,比较两个合成因子和原始 ROE 的相关系数,用它说明为什么标准化是必需的。
  6. 思考题:本讲的 tolerance=400D 是拍出来的。怎么用数据本身算出一个合理的值?(提示:统计同一只股票相邻两次财报公告的间隔分布,看它的高分位在哪里。)

🔮 下讲预告:第 22 讲——案例三:行业轮动策略完整回测。前两个案例都停在”因子值”这一步,最后一讲要走到底:从信号到持仓、从持仓到净值、从净值到绩效指标。你会看到一条完整回测链上有多少个地方要对齐时间,以及”差一天”到底差多少。


← 上一讲  ·  返回课程  ·  下一讲 →