📎 配套代码:
第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=检查匹配率,知道合并后第一件事该做什么 - 分清
merge和concat各自的适用场景 - 用
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 行
right 和 outer 多出来的行,是右表里有、但左表这 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: 👉 参考
问题:右表q的ts_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_date、end_date、roe)。写出实现,并说明三处如果做错就会引入未来函数。
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,横向拼接前确认索引确实对应。
✍️ 作业
- 把本地行情表和基础信息表合并,分别用
how的四个取值各做一次,记录四种结果的行数,解释为什么right和outer会多出行。 - 给同一次合并加
indicator=True和how="outer",打印_merge列的value_counts(),说明left_only和right_only各代表什么。 - 从财务表里取某一个报告期的数据,检查
ts_code是否唯一(duplicated().sum())。如果不唯一,找出一只重复的股票,打印它的所有记录,判断这些记录的差异来自什么。 - 复现开场那个膨胀:用未去重的财务数据
merge一个截面,记录合并前后行数。再加validate="m:1",把报错信息抄下来。 - 用
merge_asof给一只股票的行情贴上 PIT 的 ROE。打印其中某个财报公告日前后各三天的记录,确认公告日之前用的是上一期数据、之后才换成新一期。 - 思考题:
merge_asof要求两表都按时间排序。如果你的左表是按["ts_code", "trade_date"]排的(第 12 讲推荐的面板顺序),直接传给merge_asof会怎样?该怎么办?(提示:merge_asof的排序要求是针对时间列的全局有序,而by=分组是另一回事。)
🔮 下讲预告:第 14 讲——重塑与透视。第 12 讲末尾你已经见过
unstack把长表变成”行是日期、列是股票”的行情矩阵。下一讲把stack/unstack/pivot/melt这一组操作讲清楚:长表和宽表各适合什么、互转时缺失是怎么产生的,以及为什么因子数据的存储形态会直接影响你后面每一步的写法。