第13讲 合并连接

📎 配套代码第13讲_合并连接.py
📊 配套数据data/quote/ data/industry/ data/fundamental/ —— 交易数据、行业数据、财务数据(沪深300 × 2024 年以来,仓库自带,开箱即跑)

🎬 开场:合并之后,行数变多了

取某个交易日的截面,298 只股票,给每只贴上某期年报的 ROE:

q = fi[fi["end_date"] == "20231231"]"ts_code", "roe"
merged = cross.merge(q, on="ts_code", how="left")
合并前  298 行
合并后  330 行        ← 多了 32 行

how="left" 的语义是”保留左表所有行”,直觉上行数不该变。但它多了 32 行。

原因在右表:

该报告期的记录里,同一只股票平均有 1.11 条

同一只股票、同一个报告期,出现了不止一条记录——财报会修正,会有更正公告,数据源把每个版本都保留了。左表的一行遇到右表的两行,就变成了两行。

merge 没有报错。膨胀倍数只有 1.11,describe() 看不出异常,画图也正常。但你的截面里有大约 11% 的股票被重复计算了——算等权组合收益时它们的权重翻了倍。

这一讲讲清合并的几件事:连接方式怎么选、行数为什么会变、以及量化里最容易搞错的那次合并——把财报数据对齐到交易日


🎯 这一讲结束时,你能

  • merge 的四种 how 和多种键指定方式拼接两张表
  • 在合并之前判断两表是什么关系(一对一、多对一、多对多),并用 validate= 让 pandas 替你把关
  • indicator= 检查匹配率,知道合并后第一件事该做什么
  • 分清 mergeconcat 各自的适用场景
  • merge_asof 按公告日对齐财报数据,避免把未来信息带进回测

一、🧰 merge 的基本用法

a.merge(b, on="ts_code")                              # 同名键
a.merge(b, left_on="code", right_on="ts_code")        # 键名不同
a.merge(b, left_index=True, right_on="ts_code")       # 一边用索引
a.merge(b, on=["ts_code", "trade_date"])              # 多键
a.merge(b, on="ts_code", suffixes=("_行情", "_财务"))   # 重名列加后缀

四种 how

how 保留哪些行 量化里什么时候用
left 左表全部 最常用。以行情表为主,贴上行业、财务等附加信息
inner 两边都有的 要求信息完整,缺任何一边就不要这只股票
right 右表全部 少用,把两表位置对调写成 left 更好读
outer 两边全部 检查数据完整性时用,配合 indicator=

同一份数据实测(左表 10 万行,右表 5519 只股票的基础信息):

how=inner  →  100,000 行
how=left   →  100,000 行
how=right  →  105,344 行
how=outer  →  105,344 行

rightouter 多出来的行,是右表里有、但左表这 10 万行没覆盖到的股票——它们的行情列全是 NaN。

TIP: 🚀 默认写 how="left"
merge 的默认值是 how="inner",会静默丢掉左表中没匹配上的行
量化里以行情表为主体贴信息时,你几乎总是希望左表一行不少——匹配不上就留 NaN,让你能看见有多少没匹配上,而不是让它们悄悄消失。


二、🐛 合并后第一件事:检查行数

合并的行为完全取决于两表的键是什么关系

关系 含义 合并后行数
一对一 (1:1) 两边键都唯一 不变
多对一 (m:1) 左表键可重复,右表键唯一 不变
多对多 (m:n) 两边键都可重复 相乘,会膨胀

量化里绝大多数合并都该是多对一:行情表(一只股票几千行)贴基础信息(一只股票一行)。

merged = px.merge(sb, on="ts_code", how="left")
171,347 → 171,347 行     行数不变 ✓
未匹配上的 0 行 (0.00%)

行数不变,说明右表的键确实唯一。这份样本里一行都没漏——沪深 300 成分股在基础信息表里都查得到。换成全市场,退市股之类会有少量匹配不上。

让 pandas 替你把关:validate=

与其合并完再检查,不如声明你期望的关系,让不符合的直接报错:

cross.merge(q, on="ts_code", how="left", validate="m:1")
MergeError: Merge keys are not unique in right dataset; not a many-to-one merge

开场那个悄悄膨胀的合并,加一个参数就变成了明确的报错。

validate 的四个取值:"1:1""1:m""m:1""m:m"

IMPORTANT: 🔑 只要你觉得”这次合并行数不该变”,就写上 validate="m:1"
这是本讲最值得养成的习惯。它把一个静默的数据错误变成了立刻的报错
代价是零——键真的唯一时它什么都不做。

检查匹配率:indicator=

m = px.merge(sb, on="ts_code", how="outer", indicator=True)
m["_merge"].value_counts()
both          171347
left_only          0
right_only         0

_merge 这一列标记每行来自哪边。left_only 不为零说明左表有没匹配上的,right_only 不为零说明右表有多余的。

配合 how="outer" 用,能一次看清两边的覆盖情况。

🎮 随堂快练

QUESTION: 你写 px.merge(sb, on="ts_code"),合并后行数比 px 少了。可能是什么原因?
TIP: 👉 答案
忘了写 how,用了默认的 "inner"——左表中在右表找不到对应键的行被丢掉了。
改成 how="left" 就会保留,未匹配的字段留 NaN。然后用 merged["industry"].isna().sum() 看有多少没匹配上,再去查这些代码是什么情况(退市?B股?代码格式不一致?)。
最后一种可能性别忘了——第 10 讲那个 "000001.SZ" vs "000001" 的坑,格式不一致时一条都匹配不上


三、🐛 多对多为什么会膨胀

机制很简单:左表的每一行,会和右表所有匹配的行各配一次。

左表 code=A 有 3 行
右表 code=A 有 2 行
合并后 code=A 有 3 × 2 = 6 行

开场那个例子就是这样:某些股票在右表有 2 条 2023 年报记录,左表那一行就变成了两行。

现实中的来源

财务表按 (ts_code, end_date) 判重 → 3,469 行重复

1.9 万行的财务指标表里,有 3,469 行(18.1%)是”同一只股票、同一个报告期”的重复记录。原因包括财报修正、更正公告、以及数据源同时保留了合并报表和母公司报表。

这不是数据质量问题,是业务上本来就有多个版本。你要做的是决定用哪个版本。

怎么处理

q = q.sort_values("ann_date").drop_duplicates("ts_code", keep="last")   # 取最新公告的版本
去重后再 merge → 298 行 ✓

keep 选哪个取决于你的用途:

  • 回测:要用当时能看到的那个版本,不是最新修正版——否则就是未来函数。这需要按公告日做 as-of 对齐(第五节)。
  • 横截面研究(只看当前状态):用最新版本,keep="last"

四、🧰 concat:另一种拼接

merge按键横向对齐concat沿某个轴摞起来

pd.concat([jan, feb, mar])                      # 纵向摞(axis=0,默认)
pd.concat([jan, feb, mar], ignore_index=True)   # 重排行号
pd.concat([df1, df2], axis=1)                   # 横向摞,按索引对齐
pd.concat({"1月": jan, "2月": feb})              # 用字典,键成为外层索引
merge concat
按什么对齐 指定的键列 索引
典型场景 行情表贴行业分类 十二个月的数据拼成全年
两表的列合并 通常列结构相同
行数 取决于键的关系 各表行数相加

读多个文件拼起来是 concat,给表贴附加信息是 merge

WARNING: ⚠️ concat 之后有两件事要做
① 排序(第 12 讲):各分片内部有序不代表拼完整体有序。层次索引上要 sort_index()
② 检查类别一致性(第 11 讲):各分片各自 astype("category") 的话,类别表不同会导致拼完退化成 object,内存优化白做。

concat(axis=1) 按索引对齐,索引不一致时会产生并集加 NaN——这一点容易出意外,横向拼接前先确认两表索引确实对应。


五、🔬 量化里最容易错的一次合并:财报对齐

这一节是本讲的重点。它不是关于 merge 的语法,而是关于用错了会让回测结果完全失真

问题:财报有两个日期

ts_code    ann_date   end_date    roe
000001.SZ  20251025   20250930   7.5711
000001.SZ  20250823   20250630   4.9497
  • end_date报告期——这份财报描述的是哪个季度
  • ann_date公告日——这份财报什么时候公开

两者相差多少?真实数据:

公告日比报告期晚:中位数 50 天,90 分位 117 天,最大 2019 天

中位数 50 天。 三季报在 9 月 30 日结束,但要到 10 月下旬才公布。

end_date 对齐就是未来函数

如果你这样合并:

px.merge(fi, left_on=["ts_code", "quarter_end"], right_on=["ts_code", "end_date"])

那么在 9 月 30 日那天,你的策略就”知道”了三季报的 ROE——而真实世界里这个数字要 50 天后才公布。

回测会跑出漂亮的结果,实盘一分钱赚不到。这是量化里最经典、也最隐蔽的错误之一,因为代码完全正确,数据也完全真实,错的只是时间对齐。

正解:merge_asof 按公告日 as-of 对齐

r = pd.merge_asof(
    px_sorted,                    # 左表:行情,按日期排序
    fi_sorted,                    # 右表:财务,按公告日排序
    left_on="dt",                 # 左表的时间列
    right_on="ann",               # 右表的时间列(公告日)
    by="ts_code",                 # 在每只股票内部分别对齐
    direction="backward",         # 取"不晚于左表时间"的最后一条
)
merge_asof → 574 行(和左表一样),roe 缺失 0

挑一次公告的前后几天看:

trade_date    close         ann   end_date      roe
  20260422  1409.50  2026-04-17   20251231  34.4620
  20260423  1419.00  2026-04-17   20251231  34.4620
  20260424  1458.49  2026-04-17   20251231  34.4620
  20260427  1403.20  2026-04-25   20260331  10.5687     ← 换期了
  20260428  1405.00  2026-04-25   20260331  10.5687
  20260429  1401.17  2026-04-25   20260331  10.5687

4 月 24 日用的还是 2025 年报(ROE 34.46),4 月 27 日才换成 4 月 25 日公告的一季报(ROE 10.57)。

换期发生在公告日,不是报告期结束日。 这正是那几天你真实能看到的信息。如果按 end_date 对齐,3 月 31 日那天就该用上 10.57 了——而它要到 4 月 25 日才公开。

merge_asof 的三个关键参数

参数 作用
direction="backward" 取不晚于左表时间的最后一条(默认,PIT 用这个
direction="forward" 取不早于左表时间的第一条(会用到未来,慎用)
by="ts_code" 在每只股票内部各自对齐,不跨股票匹配
tolerance=pd.Timedelta("365D") 超过这个时间差就不匹配,留 NaN

IMPORTANT: 🔑 merge_asof 的两个硬性前提
两张表都必须按时间列排好序,否则直接报错。这是第 09 讲”排序决定结果”的又一次体现。
时间列必须是真正的时间类型,字符串日期不行——pd.to_datetime 先转好。
另外 by= 不能省。不写的话它会跨股票匹配,把 A 股票的财报贴到 B 股票上。

TIP: 🚀 tolerance 值得加上
不加的话,一只股票如果三年没发财报,merge_asof 会一路把三年前的数据贴到今天。
tolerance=pd.Timedelta("200D") 让超期的留 NaN,比用一个过期数据安全。这和第 09 讲 ffill(limit=) 是同一个道理。


六、🧰 其它几个拼接方法

a.join(b)                        # 按索引合并,是 merge(left_index=True, right_index=True) 的简写
a.combine_first(b)               # 用 b 填补 a 的缺失,两边按索引对齐

combine_first多数据源互补时有用:主数据源缺的字段,用备用数据源补上。

main.combine_first(backup)       # main 优先,缺的地方用 backup

🏋️ 训练营

QUESTION: 🟢 训练 1:行情表 px(17 万行)要贴上行业分类 sb(5519 行,每只股票一行)。写出合并语句,并写出合并后应该做的两项检查。
TIP: 👉 参考

m = px.merge(sb"ts_code", "industry", on="ts_code",
             how="left", validate="m:1")
assert len(m) == len(px)                      # 检查一:行数不变
print(m["industry"].isna().mean())            # 检查二:未匹配比例

how="left" 保住左表所有行,validate="m:1" 声明右表键唯一——不唯一就报错而不是静默膨胀。
两项检查缺一不可:行数不变说明没膨胀,未匹配比例说明覆盖率。实测这份数据未匹配 0.02%,是退市股之类。

QUESTION: 🟡 训练 2:下面这段代码合并后行数从 5243 变成了 5766,但没有报错。找出问题,给出两种修法。

q = fi[fi["end_date"] == "20231231"]"ts_code", "roe"
merged = cross.merge(q, on="ts_code", how="left")

TIP: 👉 参考
问题:右表 qts_code 不唯一——同一报告期部分股票有多个版本(财报修正、完全重复的冗余记录)。多对多合并导致行数膨胀。
修法一,去重后再合并:

q = q.sort_values("ann_date").drop_duplicates("ts_code", keep="last")
merged = cross.merge(q, on="ts_code", how="left", validate="m:1")

修法二,先加 validate 让它报错,再决定怎么处理:

merged = cross.merge(q, on="ts_code", how="left", validate="m:1")   # MergeError

修法二更好——它让你在写代码时就发现右表有重复,而不是事后对着行数猜。发现之后再决定去重规则。
危险之处在于膨胀只有 1.11 倍,不看行数根本发现不了,而这 11% 的重复会让等权组合的权重失真。

QUESTION: 🔴 训练 3:你要构造一个 PIT 正确的”ROE 因子”:每个交易日,每只股票对应它当时最新已公告的 ROE。数据是行情表和财务指标表(含 ann_dateend_dateroe)。写出实现,并说明三处如果做错就会引入未来函数。
TIP: 👉 参考

f = fi.dropna(subset=["ann_date"]).copy()
f["ann"] = pd.to_datetime(f["ann_date"], format="%Y%m%d")
f = f.sort_values("ann")                                    # ①

p = px.copy()
p["dt"] = pd.to_datetime(p["trade_date"], format="%Y%m%d")
p = p.sort_values("dt")

r = pd.merge_asof(p, f"ann", "ts_code", "roe",
                  left_on="dt", right_on="ann",
                  by="ts_code",                             # ②
                  direction="backward",                     # ③
                  tolerance=pd.Timedelta("200D"))

三处会引入未来函数的地方
① 用 end_date 而不是 ann_date 对齐——报告期结束当天就用上财报,平均提前 51 天知道结果。这是最常见的错误。
② 忘了 by="ts_code"——会跨股票匹配,把别的公司的财报贴过来。这不只是未来函数,是完全错误的数据。
③ 写成 direction="forward"——取”不早于当前时间的第一条”,即用下一次公告的数据,是标准的未来函数。
加分项:tolerance 防止长期不发财报的公司一直沿用过期数据。以及去重——如果同一天有多条公告,merge_asof 取的是排序后的最后一条,要确认这符合你的预期。


🐛 常见坑

  • ⚠️ merge 默认 how="inner":静默丢掉左表没匹配上的行。以主表为准时显式写 how="left"
  • ⚠️ 不检查行数:多对多会让行数悄悄膨胀,膨胀 1.1 倍这种幅度看不出来。养成 assert len(m) == len(左表) 的习惯。
  • ⚠️ 不用 validate:一个参数就能把静默的数据错误变成明确报错,几乎没有理由不写。
  • ⚠️ 键的格式不一致"000001.SZ""000001" 一条都匹配不上,结果是空表(第 10 讲)。
  • ⚠️ end_date 对齐财报:平均提前 50 天用上财报,是最经典的未来函数。用 ann_date + merge_asof
  • ⚠️ merge_asof 忘了排序:两张表都必须按时间列排序,否则报错。
  • ⚠️ merge_asof 忘了 by=:会跨股票匹配,把别的公司的数据贴过来。
  • ⚠️ merge_asof 不加 tolerance:长期不更新的数据会被一路沿用下去。
  • ⚠️ concat 后忘了排序和检查类别:层次索引要 sort_index(),分类列的类别表不一致会退化成 object。
  • ⚠️ concat(axis=1) 索引不对齐:按索引对齐产生并集加 NaN,横向拼接前确认索引确实对应。

✍️ 作业

  1. 把本地行情表和基础信息表合并,分别用 how 的四个取值各做一次,记录四种结果的行数,解释为什么 rightouter 会多出行。
  2. 给同一次合并加 indicator=Truehow="outer",打印 _merge 列的 value_counts(),说明 left_onlyright_only 各代表什么。
  3. 从财务表里取某一个报告期的数据,检查 ts_code 是否唯一(duplicated().sum())。如果不唯一,找出一只重复的股票,打印它的所有记录,判断这些记录的差异来自什么。
  4. 复现开场那个膨胀:用未去重的财务数据 merge 一个截面,记录合并前后行数。再加 validate="m:1",把报错信息抄下来。
  5. merge_asof 给一只股票的行情贴上 PIT 的 ROE。打印其中某个财报公告日前后各三天的记录,确认公告日之前用的是上一期数据、之后才换成新一期。
  6. 思考题:merge_asof 要求两表都按时间排序。如果你的左表是按 ["ts_code", "trade_date"] 排的(第 12 讲推荐的面板顺序),直接传给 merge_asof 会怎样?该怎么办?(提示:merge_asof 的排序要求是针对时间列的全局有序,而 by= 分组是另一回事。)

🔮 下讲预告:第 14 讲——重塑与透视。第 12 讲末尾你已经见过 unstack 把长表变成”行是日期、列是股票”的行情矩阵。下一讲把 stack/unstack/pivot/melt 这一组操作讲清楚:长表和宽表各适合什么、互转时缺失是怎么产生的,以及为什么因子数据的存储形态会直接影响你后面每一步的写法。


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