价格序列的四个陷阱
复权、除权、停牌、退市——它们不在你的代码里,在数据里
前两节我们把代码里的坑堵住了:未来函数有两把尺子守着,数据源有降级链兜着。
这一节的四个坑不一样——它们不在你的代码里,在数据里。
先给你一个数字:同一只股票、同一段 21 年,用不复权价算,累计涨 3524%;用后复权价算,累计涨 16993%。
同一只标的,同一段时间,两个都是"真实的历史价格"。差了将近 5 倍。
代码写得再干净,口径拿错,结论一样是错的。
量身高,有人光脚量,有人穿鞋量,有人踮着脚量。三个数字都是"真实测出来的"。
问题不在于哪个尺子准,而在于:你有没有搞清楚自己用的是哪一种,以及它和你要回答的问题匹不匹配。
陷阱一:复权口径
同一只样本股(2005-01-04 ~ 2026-07-29,5238 个交易日),三种口径:
| 口径 | 首日收盘 | 末日收盘 | 期间累计涨幅 |
|---|---|---|---|
| 不复权 | 36.45 | 1321.00 | 3524.1% |
| 后复权 | 59.27 | 10131.09 | 16993.0% |
| 前复权 | 7.73 | 1321.00 | 16993.0% |
用不复权价算收益,比用后复权少算 371.6%——少掉的全是被除权"吃掉"的分红。
注意前复权和后复权的累计涨幅完全相同(都是 16993.0%),因为它们只是同一条乘数序列选了不同的锚点。那为什么回测要用后复权?
因为前复权序列会变。
前复权把今天的价格当锚点,往前推算历史。所以每发生一次新分红,整条历史序列都要重算一遍——你昨天存下来的 CSV,今天就对不上了。
这会直接击穿判据七(可复现):同一段代码、同一个标的,今天跑和下个月跑,结果不一样,而你查不出原因。
后复权把历史当锚点,历史一旦发生就不再改变。回测用后复权,展示给人看用前复权(因为前复权的价格和行情软件上看到的一致)。这是行业惯例,也是本课的默认。
陷阱二:除权日的假跌幅
除权日当天,不复权价格会凭空跳空下跌——那不是跌,是分红从股价里划出去了。
实测:两种口径日收益差超过 0.5% 的交易日共 26 天,几乎全是除权日。最大的几例:
| 日期 | 不复权当日收益 | 后复权当日收益 | 差 |
|---|---|---|---|
| 2006-05-25 | -56.69% | -11.96% | -44.73pp |
| 2005-08-05 | -15.96% | +1.77% | -17.73pp |
| 2014-06-25 | -13.05% | -1.72% | -11.33pp |
| 2015-07-17 | -9.26% | +1.58% | -10.84pp |
| 2011-07-01 | -9.88% | +0.22% | -10.10pp |
看 2005-08-05 和 2015-07-17 这两行:真实是涨的,不复权口径下却是大跌。
26 天合计,不复权序列平白多记了 125.3 个百分点的"跌幅"。
后果不只是收益算错:
- 止损规则会被这些假跌幅误触发
- 动量因子会把除权日误判成暴跌
- 风控指标(回撤、下行波动)全部被污染
🔧 动手做:用不复权价算一次收益,见识满屏"假崩盘"(5 分钟)
跑 python price_traps.py,对比同一只股票的不复权价和后复权价。
你会看到(实测):同一只股 21 年,不复权累计 3524%、后复权 16993%——不复权少算了近 13000 个百分点。更要命的是除权日的假跌幅:2006-05-25 那天,不复权显示 -56.69%,而后复权只是 -11.96%——那 44 个百分点的"暴跌"根本不存在,是分红除权造成的账面假象。全样本这样的假跌幅有 26 天、合计 125pp。
想明白:复权/除权/停牌/退市这些坑不在你的代码里,在数据里——代码写得再干净,拿错口径的价格,结论一样是错的。算收益必须用复权价;看到某天"暴跌 50%",先查是不是除权日。
陷阱三:停牌——数据里有这一行,但那天不能交易
这个坑最阴。我原本以为停牌日会在数据里"缺席",实测结果是:
指数交易日 5238 天,该股有数据 5238 天,缺 0 天
⚠️ 但 tradestatus=0(停牌)的行有 68 行 —— 数据在,交易不在
其中 68/68 天的日收益恰好为 0(价格=前收盘,是占位不是行情)
数据源一天都没少给你,但其中 68 天是占位符。价格是前一天的收盘价,收益恰好为 0。
如果你不看 tradestatus 这个字段,这 68 天会被当成 68 个"平盘日":
含停牌日的年化波动率 31.90% vs 剔除后 32.11% (相差 0.21pp)
波动率被稀释 0.21 个百分点——每一个假的 0 收益,都在悄悄把你的风险指标往下压。
而更严重的是回测撮合:停牌日你的引擎会照常"成交",现实里你根本下不了单。P4 章的撮合层必须显式处理这件事。
三种处理方式,三种错误
| 做法 | 错在哪 |
|---|---|
| ① 直接丢弃停牌日 | 停牌期间的风险凭空消失,回撤被低估 |
| ② 填成 0 收益 | 等于假设停牌期间"什么都没发生"——而停牌往往正因为出了事 |
| ③ 用复牌首日补记 | 最接近真实,但会造成单日巨幅收益,须在风控里单独处理 |
本课选 ③,并在引擎里给复牌首日打标记。没有完美选项,只有"你知道自己选了哪个"。
陷阱四:退市与幸存者偏差
baostock 全量股票 5540 只:在市 5203 只,已退市/停止上市 337 只 (6.1%)
2019 年以来各年退市数量:
2019: 11 2020: 16 2021: 20
2022: 43 2023: 45 2024: 52 2025: 30 2026: 19
退市数量在系统性上升:2022 年起每年 40+ 只,是 2015-2018 年均值(约 4.75 只/年)的 7 倍以上。
如果你的股票池是"今天还在交易的股票",你就自动删掉了 337 只死掉的样本。这些样本恰好是表现最差的那一批——它们不是随机消失的。
后果:任何全市场回测都会系统性高估收益。而且这个偏差不会体现在任何指标上,它只是让你的整个样本变得比历史更好。
P5 章做选股时,必须用当时的成分股名单,而不是今天的。这是幸存者偏差在数据层的解法。
📌 免责:本课为技术教学。本节需要一只长期分红的个股来演示除权机制(指数不除权),代码中的样本股仅因分红次数多、跳空明显而被选中,与其投资价值无关,不构成任何投资建议。
四个陷阱,四个实测数字:
- 复权:不复权 3524% vs 后复权 16993%,差近 5 倍;回测一律用后复权(前复权会变,击穿可复现)
- 除权:26 天假跌幅合计 125.3pp,其中最大一天 -56.69%(真实是 -11.96%)
- 停牌:68 行数据在、交易不在,68/68 收益恰为 0,波动率被稀释 0.21pp
- 退市:337 只已退市(6.1%),2022 年起每年 40+,用"今天还在的股票"回测必然高估
它们的共同点:代码是干净的,错在数据口径。所以 p0.3 那两把尺子抓不到它们——这类错误只能靠知道它们存在,以及下一节要做的数据体检来防。
下一节讲交易日历与多频率对齐:不同市场、不同频率的数据怎么对齐才不出错,以及为什么"用 pandas 的 date_range 生成交易日"是一个会让你付出代价的偷懒。
🔎 来源与核验· 5 条,点开核对
