复权、停牌与退市:把行情数据洗干净的清单
六个最常见的数据陷阱,以及每一个对应的检查代码。跑完这份清单再开始建模。
六个陷阱
在开始任何建模之前,先花半天把下面六件事确认一遍。它们造成的偏差通常比模型选择带来的差异大得多。
- 用了不复权价格算收益率,分红除权日会凭空出现一根大阴线。
- 用了前复权价格做长周期回测,历史价格会随着新的分红被反复改写,昨天的回测结果今天就复现不出来。
- 停牌日被当成 0 收益参与截面排名,把一批僵尸股排到了信号前列。
- 退市股票从数据库里被删掉,剩下的全是活到今天的赢家,也就是幸存者偏差。
- 财报字段用的是报告期而不是公告日,等于提前一个多月知道了业绩。
- 指数成分股用的是当前名单,回溯到三年前时里面塞满了当时还没被纳入的股票。
# 复权:用后复权做研究,用不复权价算真实成交额 adj = raw['close'] * raw['adj_factor'] # 停牌:先标记,再决定怎么处理,不要直接 ffill 了事 suspended = raw['volume'].eq(0) | raw['close'].isna() print(suspended.groupby(raw['code']).sum().describe()) # 短停牌向前填充,长停牌整段剔除 filled = raw.groupby('code')['close'].ffill(limit=5)
为什么是 limit=5
连续停牌超过一周的标的,复牌当天的价格跳变已经不是「延续」而是「重定价」。向前填充只适合覆盖临时停牌,长停必须整段标记为不可交易。
停牌怎么填才不作弊
关键不是「填什么值」,而是「填完之后这一天能不能交易」。研究里最常见的错误是价格填上了、权重也分配了,回测里却买入了一个当天根本无法成交的标的。
tradable = (~suspended) & raw['amount'].gt(1e6) # 权重先算,再按可交易掩码归零并重新归一 w = w.where(tradable, 0.0) w = w.div(w.sum(axis=1).replace(0, pd.NA), axis=0).fillna(0) # 涨跌停也不可买入:用当日最高价 == 最低价近似识别一字板 limit_up = raw['high'].eq(raw['low']) & raw['close'].gt(raw['pre_close'])
退市股一定要留着
把退市股删掉是最省事也最致命的做法。一个在 2018 年买了 30 只股票的策略,如果其中 3 只后来退市而数据里没有它们,回测收益会凭空多出十几个点。正确做法是保留全部历史标的,用一张 listing 表记录每个代码的上市日和退市日,回测时按日期切片。
下一篇我们把这份清单写成一组 pytest 断言,每次数据更新后自动跑一遍。
数据清洗复权幸存者偏差