← 返回教程库

数据到手别急着回测,先跑这六项自查

最后更新 2026-08-19
📚 量化与 AI 投研 📖 E 线 · 起步 ⏱ 约 24 分钟 R2 · 注意风险
你将学到
  • 拿到任意一份行情数据,能按缺失值、时间戳、极端值、零成交量、复权因子、交叉比对这六项跑一遍自查,每项都说得出判据
  • 每项查出问题后知道该走哪条处置分支——修补、剔除、换源还是只记账不动手,以及为什么「默认修补」经常是最坏的一条
  • 看得懂开源框架里的数据检查代码在检查什么,并能把同样的判据套到自己手上那份数据上

数据下完了,几百个文件躺在硬盘上,加起来两个 G。你打开其中一个瞄了一眼,列名对得上,日期看着连续,价格也不像乱码。

于是你关掉文件,去写策略了。

这一步就是绝大多数人踩坑的地方——不是因为他们不知道数据可能有问题,而是因为数据有问题的时候,它长得跟没问题一模一样。 它不会抛异常,不会打红字,不会在你 read_csv 的时候提醒你「本文件缺 37 个交易日」。它只会安安静静地进入你的回测(回测就是拿历史行情把一套交易规则重放一遍,算出这套规则在过去能赚多少),然后给你一条曲线。

而且——这是最要命的一点——坏数据给出的曲线,通常比好数据更好看。

这一篇就干一件事:给你一份到手就能跑的自查清单,六项。每一项我都会写清楚三件事:这项在查什么、用什么判据算不合格、查出来之后具体该干什么。 第三件事是重点,因为「发现问题」这一步大多数人做得到,「发现之后不知道该修还是该扔」才是真正卡住的地方。

先划边界:这些坑各自是怎么形成的、为什么某个数据源特别容易出某类问题,那是 行情数据里那些会让你回测结果全错的坑 的地盘。这一篇不讲成因,只讲动作——你现在坐在电脑前,该敲哪几行、看哪几个数、然后做哪个决定。

为什么这套自查必须排在回测之前

先说人话

你开了家小餐馆,每天早上收菜。

有一种老板是这样的:菜到了直接进后厨,中午客人吃出问题了再回头查。另一种老板会在收货的时候拆开箱子,翻到底下看看,称一下重量,闻一下味道,然后才签字。

第二种老板不是更谨慎,他是算过账的。第一种做法出问题的时候,损失的不只是那箱菜,是那一整天的客人、那些人的口碑,还有他自己一整天的白干。而验货只要十分钟。

数据自查就是这个验货动作。它的成本是十几分钟,它防的是你接下来两个月的白干。

换成投资的话来说

为什么说坏数据会让结果更好看,而不是更难看?这件事值得单独说明白,因为它是整套自查存在的全部理由。

想象数据里有一天的收盘价出错了,多打了一个零。在你的回测里会发生什么?程序看到这个价格暴涨,如果你的规则是追涨的,它会买;然后第二天价格「跌回来」了,它可能又被止损。这一笔大概率是亏的——这种错,回测会亏钱,所以你反而容易发现。

但更常见的是另一类错,它们全都朝一个方向偏:

缺的那些天,恰好是最难交易的那些天。 数据源在极端行情下更容易掉数据,而那几天正是你的策略最可能亏大钱的时候。数据里没有这几天,等于回测帮你自动躲过了最难的日子。

停牌期被填成了平线。 一只股票停牌三个月,如果这三个月被前一天的收盘价填满,你的回测会看到一条完美的水平线——波动率算出来更低,最大回撤(账户从历史最高点往下掉了多少)算出来更小,夏普比率(每承担一份波动换来多少超额收益)算出来更高。而真实情况是这三个月你的钱根本动不了,甚至可能复牌当天直接跌一大截。

退市和被并购的公司整个不见了。 剩下的全是活到今天的,这叫幸存者偏差,幸存者偏差:你的股票池里,输掉的那些早就不见了 讲得很细。它的效果是把你的历史收益整体抬高,而且抬得毫无痕迹。

复权(把送股、拆股、分红造成的价格跳空还原掉,让前后价格可比)没做对。 一只股票十送十之后价格腰斩,如果数据没做复权处理,你的程序会看到一根「跌 50%」的 K 线(K 线就是把一段时间的开盘、最高、最低、收盘四个价格压成的一根柱子)。这种错在不同策略里方向不一,但共同点是:它制造出大量真实世界里根本不存在的机会。

四条里有三条半是往「更好看」的方向偏。所以你不做自查的代价,不是拿到一个错误的结论,而是拿到一个让你更愿意相信的错误结论。

这套清单的组织方式

六项检查,我按「查出来的东西有多致命」排序,不是按「跑起来有多快」排序:

  1. 列齐不齐 + 缺失值分布 — 最基础,但重点在「分布」不在「有没有」
  2. 时间戳连续性 — 分三个子项:有重复的、少了交易日、多出了非交易日
  3. 极端值 — 判据必须用倍数,不能用绝对值
  4. 成交量为零或为负 — 一格数字,能一次性揪出停牌、涨跌停、假数据三类问题
  5. 复权因子在不在、是不是全空 — 单独一项,因为它是整份数据可不可用的开关
  6. 换个数据源交叉比对 — 前五项都过了才做,因为它最贵

每项后面我都会给一条处置分支。处置只有四种,你每次只需要在这四个里选一个:

  • 修补 — 按明确规则把缺的补上,并且留下补过的记录
  • 剔除 — 把这个品种、这段时间整个拿掉,不参与回测
  • 换源 — 这份数据不能用,去找另一份
  • 只记账 — 不动数据,但在回测报告里写明「本次结果建立在什么样的数据上」

默认选项应该是「只记账」,不是「修补」。 这一条反直觉,我在后面会反复说明为什么。

第一项:列齐不齐,以及缺失值缺在哪儿

先说人话

你收到一份员工考勤表,一年的。你的第一个动作不该是算出勤率,而是翻到最后看看:这表是不是每个月都有?还是从三月直接跳到了七月?

出勤率是一个数,它算得出来。但如果四五六三个月压根没记录,这个数就是从九个月的数据里算出来的,它跟你想知道的那件事已经不是一回事了。

这一项在查什么

分成两层,第二层才是重点。

第一层:该有的列在不在。 行情数据的标准五列叫 OHLCV——开盘价、最高价、最低价、收盘价、成交量。缺任何一列,后面的检查都做不了。这一层几乎不用你判断,缺了就是缺了。

第二层:缺失值缺在哪儿。 这一层才是绝大多数人做错的地方。多数人的做法是 df.isnull().sum(),看到一个总数,比如「缺 240 个」,然后心想「两千多行里缺两百多,不到一成,还行」。

这个判断是错的,因为「缺多少」和「缺在哪」完全不是一回事:

  • 240 个缺失均匀散在五年里 → 大概率是数据源偶发的采集失败,影响有限
  • 240 个缺失全部集中在连续的一年里 → 这个品种在那一年里根本没有数据,你的回测在那一年是空跑
  • 240 个缺失全部落在同一个月,而那个月市场剧烈波动 → 这是最危险的一种,你的回测系统性地避开了最难的时段

同一个数字,三种完全不同的结论。 所以判据不能是「缺失率低于多少」,必须是「缺失有没有扎堆」。

判据怎么定

给你三条可以直接用的:

判据 A:任何一个品种,最长的一段连续缺失有多长。 这个数比缺失总数有用得多。日线数据里连续缺失超过一周,就该单独看一眼;连续缺失超过一个月,基本可以判定这段时间该品种要么停牌要么数据源没覆盖。

判据 B:把所有品种的缺失日期叠在一起,看有没有共同的空洞。 如果某一天所有品种都缺,那不是品种的问题,是你的数据源那天挂了。这种「全体缺失日」必须整天剔除,不能只剔一部分品种——只剔一部分会让当天的横截面(同一天所有品种放在一起看的那一横排)变形。

判据 C:缺失日期和市场波动大的日子有没有重合。 这条最难自动化,但可以粗暴做:把缺失最多的那几天列出来,人工看一眼那几天发生了什么。

查出来之后怎么办

列缺了 → 换源。 没有商量余地。缺成交量的数据没法做任何流动性判断(流动性就是「你想买卖的时候能不能顺利成交」),缺最高最低价的数据没法算任何区间类指标。

连续缺失一大段 → 剔除,剔整个品种或整段时间。 不要修补。理由后面讲。

零散缺失 → 只记账。 在你的回测报告里写一行:「本次数据共 N 个品种,缺失率 X%,最长连续缺失 Y 天」。就这一行,将来别人质疑你结果的时候,你拿得出来。

全体缺失日 → 整天剔除。

工具里长什么样

qlib 这个开源量化框架的 scripts 目录下有一个专门的数据体检脚本 check_data_health.py,里面是一个叫 DataHealthChecker 的类。它做的第一件事就是这两层。

check_required_columns 检查 OHLCV 五列齐不齐,缺了就把品种名和缺的列名列出来。check_missing_data 则逐个文件统计每一列各缺多少,只要超过一个可配置的门槛(这个门槛在我读到的快照里默认是 0,也就是「一个都不许缺」,具体默认值以你手上那个版本为准)就把这个品种收进结果表。

我想让你注意的是它的输出形态:它不是打印一个总的缺失率,而是返回一张以品种为索引、每一列各缺多少的表。这个形态就是在逼你看分布,而不是看总数。

它还有一个很有意思的检查叫 check_features_dir_lowercase——检查数据目录下的子目录名是不是全小写。这跟金融毫无关系,纯粹是因为它的数据加载逻辑用小写的品种名去拼路径,在区分大小写的文件系统上,大写的目录名会导致加载失败。源码的注释里明确写了这是一个已知问题并附了对应的 issue 链接。

这一条对你的启发是:你的自查清单里应该有几项跟金融无关、纯粹跟你自己的环境有关的检查。 路径大小写、文件编码、时区设置——这些东西不会让你算错,只会让你加载不出来或者静默加载出空数据,而静默加载出空数据是最难查的那种故障。

第二项:时间戳连续性

先说人话

你在整理一沓打卡记录。

三件事你必须分开检查:有没有同一天打了两遍(重复)、有没有该上班的日子没记录(漏)、有没有周末居然有记录(多)。

这三件事的原因不一样,处理方式也完全不一样。同一天打两遍多半是机器抽风,直接去重;该上班没打卡可能是请假,得问;周末有记录那就是打卡机时间设错了,整个记录都要怀疑。

行情数据的时间戳检查,就是这三件事。

这一项在查什么

子项 1:重复的时间戳。 同一个时间点出现两行。原因通常是增量下载的时候重叠区间没处理好,或者数据源本身返回了重复的记录。

子项 2:缺交易日。 交易日历上有这一天、市场那天开着门、但你的数据里没有这一行。

子项 3:多出非交易日。 你的数据里有一行,日期是周末或者法定假期。这一条最容易被忽略,也最能说明问题——数据里出现了市场根本没开门的日子,说明这份数据经过了某种「补全」处理,而你不知道是谁补的、按什么规则补的。

第三子项还有一个变体特别值得警惕:数据里有交易日历上没有的日子,而那些日子的价格全都跟前一天一模一样。 这就是有人替你做了前向填充(用前一个有效值填补空缺)。

判据怎么定

这三项的判据都是可以精确判定的,不需要你拍脑袋定阈值:

重复的判据: 时间戳列去重前后的行数一不一样。差一行都算问题。

缺交易日的判据: 你需要一份交易日历——市场哪些天开门的清单。拿到日历之后,判据就是集合运算:日历上有、你的数据里没有的日期集合,大小是不是零。

如果你手上没有现成的交易日历,有个很实用的替代做法:把所有品种的日期取并集,用这个并集当作近似的交易日历。 只要你的品种池够大够杂,某一天全部品种同时缺数据的概率很低,所以这个并集会非常接近真实日历。然后每个品种再拿自己的日期跟这个并集比,缺的就露出来了。

多出非交易日的判据: 你的数据里有、日历上没有的日期集合,大小是不是零。有的话,先看是不是周末——如果多出来的全是周末,那是数据源在做补全;如果多出来的是零散的工作日,那多半是假期日历没对上。

还有一条判据是跨品种的:所有品种的起止日期是不是一致。 不一致本身不是错(新上市的品种当然起点晚),但它决定了你的回测实际上是在哪一段时间上跑的。

查出来之后怎么办

重复 → 修补,这是唯一一个「修补」该当默认选项的地方。 去重规则要写死:同一时间戳的多行怎么合成一行,开盘价取第一个、最高价取最大、最低价取最小、收盘价取最后一个、成交量按你的场景取最大或求和。规则一旦定下就不要改。

缺交易日 → 分情况。 缺一两天且不扎堆,只记账。缺一大段,剔除这个品种或这段时间。特别提醒:不要用前向填充来补缺失的交易日。 前向填充会造出一根「开高低收全等于前一天收盘价、成交量为零」的假 K 线,而这根假 K 线在你的指标计算里是完全真实的——它会拉低波动率、拉平均线、让所有「N 日内」的统计口径全部走样。

多出非交易日 → 先查清楚是谁加的,再决定。 如果确认是数据源自动补的,最干净的做法是拿真实交易日历重新对齐一遍,把日历外的行整个删掉。如果查不清来源,换源

工具里长什么样

这一项在两个框架里都有现成的对照。

freqtrade 的 clean_ohlcv_dataframe 是一个清洗入口函数,做三件事,顺序写死:先按日期分组去重(合成规则就是我上面说的开取第一、高取最大、低取最小、收取最后、量取最大),然后可选地丢掉最后一根 K 线(因为最后一根很可能是还没走完的、不完整的),最后可选地补齐缺失。

我想请你注意后两件事是可选的,而且是靠参数控制的。这不是随便设计的——去重永远安全,丢最后一根和补缺失都是有代价的操作,所以框架把决定权交出来了。 你自己写自查脚本的时候,也该照这个原则分:安全的操作可以默认做,有代价的操作必须显式开启。

补缺失那个函数 ohlcv_fill_up_missing_data 里的行为,值得你逐条看一遍,因为它就是我上面警告的那种前向填充:它按时间频率重采样造出空行,然后用前一个收盘价前向填充 close,再拿填好的 close 去填 openhighlow,成交量置零。

这个函数最有价值的地方不在于它怎么填,而在于它填完之后做了什么: 它算出补之前多少行、补之后多少行、补出来的行占了百分之多少,并且把这三个数打进日志。而且它对日志级别做了区分——补的比例超过一个小门槛才用较高的级别打出来,比例很小就压到调试级别,免得刷屏(具体的门槛数值以你手上那个版本为准)。

这就是我说的「只记账」的标准形态:动手改了数据,就必须留下改了多少的痕迹,并且让这个痕迹的显眼程度跟改动的规模挂钩。

另一个函数 validate_backtest_data 更直接,它就是缺交易日检查本身。逻辑非常朴素:拿数据的起止时间差除以 K 线周期,算出「理论上应该有多少根」,跟实际行数一比,少了就打警告,把品种名、期望根数、实际根数、差了多少全部写进日志。

请你注意它的返回值是一个布尔量——它不修数据,它只是回答「有没有缺」这个问题。 这个设计正是「检查」和「处置」分离的样板:检查函数只负责说出事实,要不要修、怎么修,是调用方的决定。你写自己的自查脚本时值得照抄这个结构。

配套的 get_timerange 则是跨品种起止范围的检查:它把所有品种各自的起止时间收集起来,取最早的开始和最晚的结束。这个数你在回测报告里必须写出来,因为它才是你这次回测真正覆盖的时间窗——你以为自己测了五年,实际可能只有三年半的品种是全程在场的。

命令行这一侧也有对应的东西:freqtrade 的 list-data 子命令可以列出本地有哪些品种、哪些周期的数据,加上 --show-timerange 选项还能显示每份数据的可用时间范围。文档里给这个选项标注了一句「可能需要一会儿才能算完」——这句提示本身就说明了它是要去逐个文件读起止时间的。你自己的自查脚本第一步也该是这个:先把「我手上到底有什么、各自覆盖到什么时候」打印出来。

第三项:极端值

先说人话

你在核对一份报销单,一整年的餐费。

大部分条目在几十到几百之间。突然有一条是四万八。

你不需要任何专业知识就知道该去问一句。而你判断它可疑的方式,不是「四万八这个数太大了」,是「四万八跟旁边那些数不在一个量级上」。如果这是一份年度部门预算表,四万八再正常不过。

同一个数字,在不同的上下文里,可疑程度完全不同。所以极端值检查的判据必须是相对的

这一项在查什么

相邻两根 K 线之间,变化幅度大到不像真的。

为什么这一项这么重要?因为在行情数据里,一个极端跳变的最常见成因不是市场真的暴涨暴跌,而是复权没做对

具体说:一家公司做了十送十,除权当天的价格会直接腰斩——这不是股票跌了 50%,是每股拆成了两股,你手上的总市值没变。如果数据做了复权处理,历史价格会被统一调整过,这个跳空就消失了;如果没做,你的数据里就凭空多出一根跌 50% 的 K 线。

拆股方向也一样:一比一百的合股,会造出一根「涨 100 倍」的 K 线。

所以极端值检查在实践中主要是复权检查的代理指标。 你查出来的那些跳变,绝大多数会指向同一个根因。至于除权除息本身怎么换算,站内有个 复权价格计算器 可以让你手算一遍找找感觉。

判据怎么定

判据必须用倍数,不能用绝对值。 一块钱的股票涨一块是翻倍,一千块的股票涨一块什么都不是。所以你要算的是相邻两行的变化率。

价格和成交量的判据必须分开定,因为它们的正常波动范围差着一个数量级:

价格: 日线数据里,单日涨跌超过某个倍数就该报出来。这个倍数怎么定,取决于你的市场——有涨跌停限制的市场(一天最多涨跌某个百分比,到了就不让继续成交了),正常情况下单日变化根本不可能超过那个限制,所以任何超出涨跌停幅度的跳变都必然是数据问题或者除权。没有涨跌停的市场,判据就得放宽。

成交量: 阈值要比价格宽得多。成交量本来就是暴涨暴跌的——某只票平时一天成交几百万,出个消息当天成交几个亿,完全正常。所以拿价格的阈值去卡成交量,你会收到一堆误报。

具体的阈值定成多少,我不给你数字,因为它依赖你的市场、你的周期、你的品种池。 我给你定阈值的方法:先不设阈值,把所有品种的相邻变化率排个序,看看分布长什么样,然后把阈值定在分布明显断开的那个位置。 真实的极端值和数据错误之间,通常存在一道肉眼可见的裂缝。

还有一条判据能大幅降低误报:看这个跳变是不是「跳过去又跳回来」。 真实的暴涨暴跌是单向的——涨上去之后价格就在新水平附近了。而数据错误经常表现为「今天跳到十倍,明天跳回来」,那是单个数据点错了,不是价格真的动了。

查出来之后怎么办

如果跳变对应的是真实的除权除息事件 → 换源或者补复权因子。 你需要的是一份做过复权的数据,或者一份带复权因子的数据让你自己算。不要手工去改那几根 K 线——一次除权影响的是它之前的全部历史价格,不是那一根。

如果是单点错误(跳过去又跳回来) → 剔除那一行,或者剔除那个品种。 我倾向剔整个品种,因为一份数据里出现单点错误,说明它的采集流程有问题,你查出来的那一处大概率不是唯一的一处。

如果确实是真实的极端行情 → 只记账,并且单独标记出来。 这些日子在你后续分析里有特殊价值——它们是你的策略压力测试样本。

绝对不要做的一件事:把极端值直接截断到阈值。 这个做法在别的数据科学场景里很常见,在行情数据里是灾难——你把真实的极端行情磨平了,而极端行情正是决定你策略生死的那几天。

工具里长什么样

qlib 那个体检脚本里的 check_large_step_changes 就是这一项。它逐个品种、逐列算相邻变化率的绝对值,超过阈值就把品种名、是哪一列、第一次超标是哪一天、以及最大的那个变化率记进结果表。

它对价格列和成交量列用了两套不同的阈值,成交量那套明显宽松得多——这正好印证了我上面说的分开定阈值。两个阈值都是构造函数的参数,可以在命令行覆盖(默认值以你手上那个版本为准)。

还有一个更值得看的标本,在 qlib 的数据采集脚本里。它对某个公开数据源做归一化处理时,源码里有这么一段逻辑:算出相邻两天的变化倍数之后,如果落在某个特定的区间内,就把这几列价格统一除以一百,然后重新算一遍变化率、再判断一次,循环下去;循环超过一定次数还没收敛,就打一条警告说这个品种连续多天变化异常、请人工去看具体的数据文件。

源码在这段逻辑上方写了一条全大写的 WARNING 注释,大意是:如果对某个交易所来说,连续交易日之间出现这个量级的差异属于正常现象,那么下面这段逻辑就需要改。

我想让你从这段代码里带走三件事:

第一,这是一个针对已知数据源、已知故障模式的定点修复。 那个特定的倍数区间不是通用阈值,它对应的是这个数据源已知会犯的一类错。你抄的应该是这个思路——针对你自己数据源的已知毛病写定点检查——而不是抄那几个数字。

第二,作者知道自己的修复可能误伤,所以把误伤条件写在了注释里。 这是一份负责任的代码该有的样子,也是你自己写自查脚本时该有的习惯:每一条自动修复的旁边,写清楚它在什么情况下会做错事。

第三,修不好就不硬修,转人工。 循环若干次仍不收敛,它不是接着修,也不是静默放弃,而是打警告点名让人去看。自动化检查的终点不是自动化修复,是自动化地把该人看的东西挑出来。

第四项:成交量为零或为负

先说人话

一家店的流水账上,某一天的营业额是零。

这一格能说明的事情特别多:可能那天没开门,可能开了门但一个客人没来,也可能是记账的人漏了。这三种情况在账本上长得一模一样,但它们对「这家店生意怎么样」这个问题的答案完全不同。

零成交量就是这一格。它是一个信息密度极高的信号,成本极低,几乎不该跳过。

这一项在查什么

成交量为零,通常对应这几种情况:

停牌。 这只股票那天不让交易,所以没有成交。停牌可能几天,也可能好几个月。

涨跌停封死。 价格顶在涨跌停板上,挂单全在一边,几乎没有成交量。这种情况下数据里可能有价格但成交量极小或为零。

流动性枯竭。 这个品种本来就没什么人交易,某些天完全没有成交。

前向填充的痕迹。 前面说过的那种假 K 线——某人补数据时造出来的,成交量就是零。

采集失败。 数据源那天没抓到成交量,填了个零。

成交量为则简单得多:这在任何真实场景下都不可能,见到就是数据错误。

这一项和你的策略之间的关系非常直接:零成交量的日子,你根本没法交易。 如果你的回测在这些日子上产生了成交,那个成交是凭空捏造的。这一层跟 流动性与策略容量 讲的是同一件事的两端——那篇讲的是「成交量不够时你的钱进不去」,这里讲的是「成交量为零时你的单子压根不存在」。

判据怎么定

判据本身没有难度,就是筛出成交量小于等于零的行。难的是分类——把筛出来的行归到上面五种情况里去。给你三条可用的区分线索:

线索 1:这一天的开高低收是不是四个数完全相等。 相等且成交量为零,几乎可以确定是填充出来的假 K 线或者停牌。

线索 2:零成交量是连续的还是零散的。 连续一大段指向停牌;零散单点更可能是采集失败或流动性枯竭。

线索 3:同一天别的品种有没有成交量。 如果全市场所有品种当天成交量都是零,那是采集问题,不是市场问题。

查出来之后怎么办

成交量为负 → 剔除,并且怀疑整份数据。 出现物理上不可能的数值,说明这份数据的清洗流程有问题。

零成交量对应停牌 → 剔除这些行,而且必须同时保证你的回测在这些日子上不产生交易。 只删数据不够——如果你的回测框架会自动前向填充,删掉之后它可能又给你填回来。你需要的是显式地把这些日期标记成「不可交易」。

零成交量对应涨跌停 → 保留数据,标记不可买入或不可卖出。 这是最需要精细处理的一种:涨停板上你买不进但卖得出,跌停板上你卖不掉但买得进。一刀切成「不可交易」会让你的回测偏离真实情况,只是偏离的方向跟不处理时相反。

零成交量对应流动性枯竭 → 这是个策略层面的决定,不是数据层面的。 你可以选择把这类品种整个排除出品种池,也可以保留但在回测里给它更悲观的成交假设。无论选哪个,都要写进你的报告。

零成交量对应采集失败 → 换源,或者至少对这个品种换源。

工具里长什么样

qlib 那个数据归一化流程里有一行处理特别值得看:成交量小于等于零、或者成交量是空值的那些行,除了品种名之外的所有列全部被置成空值。

这个动作很激进——它不是删掉这些行,而是把整行的价格数据全部作废,只留下日期这个骨架。

为什么这么做?因为「那天没有成交」和「那天的价格是多少」是两个绑在一起的事实。 没有成交量,那个价格就没有成交支撑,它可能是上一天的残留、可能是挂单价、可能是补出来的。留着它比删掉它更危险——留着的话,下游算指标的时候会把它当成一个真实的成交价用。

而保留行、只作废内容这个做法,好处是时间轴的完整性没被破坏。后续任何按日期对齐的操作都还能正常工作,而任何试图读取价格的操作都会拿到一个明确的空值,而不是一个看起来很正常的假数字。

这是一个我建议你直接抄的模式:作废可疑数据的时候,优先「置空」而不是「删行」。 空值会在下游明确地暴露自己,删掉的行只会让你的统计口径悄悄变小。

同一段代码在做完这个处理之后,还把 volume <= 0 的判断又执行了一次——在算完变化率、加上新列之后再执行一遍,保证新算出来的那些列也一起被作废。这个细节的意思是:作废操作要放在派生列算完之后,或者算完之后再补一次,否则派生列会带着已被作废的数据活下来。

第五项:复权因子在不在,是不是全空

先说人话

你在看一份三年的工资流水,中间公司做过一次币种改制,一块钱换成了十块。

如果流水上只有金额、没有说明改制发生在哪一天、换算比例是多少,那么这份流水你就没法用——你看到某个月工资从五千「涨到」五万,你既不能说他涨薪了,也不能说他没涨。你缺的不是数据,是把前后两段接起来的那个换算系数。

复权因子就是这个系数。

这一项在查什么

三层,一层比一层深:

第一层:有没有复权因子这一列。 没有的话,你手上这份数据要么是已经复权过的成品(那么它的价格不等于当年的真实成交价),要么是完全没处理的原始价格(那么它在除权点上是断的)。这两种情况你必须知道自己拿的是哪一种。

第二层:这一列是不是全空。 列在但全是空值,比没有这一列更糟——因为你的代码会以为自己拿到了因子。

第三层:因子的取值是不是合理。 因子应该是分段常数:在没有除权事件的日子里保持不变,在除权日跳一下。如果你看到因子每天都在小幅波动,那多半不是复权因子,是别的什么东西。如果你看到因子中间有空洞,那些空洞对应的日期你的复权就是错的。

判据怎么定

判据 1:因子列存在,且非空比例接近全部。

判据 2:因子的变化点数量是不是合理。 一只正常的股票,几年里除权除息的次数是有限的,所以因子的变化点应该是屈指可数的几个。变化点特别多,说明这不是复权因子。

判据 3:指数类品种要单独放行。 指数(把一篮子股票按规则合成的一个数,比如沪深 300)不存在除权除息,所以它没有复权因子是正常的。如果你的检查脚本对指数也要求因子,你会收到一堆误报。

查出来之后怎么办

没有因子列,且数据方说不清楚有没有复权 → 换源。 这一条我建议你守得很硬。一份说不清自己有没有复权的价格数据,不能用来做任何跨越除权点的回测。

有因子但全空 → 换源。

因子有零散空洞 → 剔除有空洞的那些品种。

指数没有因子 → 正常,在检查里显式放行。

工具里长什么样

qlib 体检脚本里的 check_missing_factor 就是这一项,而且它连第三层的坑都替你踩了。

它做两个判断:因子列在不在,以及因子列是不是全部为空。注意是「全部为空」而不是「有空值」——这个区分很讲究,因为部分为空和全部为空是两个严重程度完全不同的问题。

更值得注意的是这个函数开头的一段跳过逻辑:它把几个指数品种从检查里排除掉了,方式是判断文件名里包不包含那几个特定的代码。跳过的理由正是我上面说的——指数没有复权因子,检查它等于制造误报。

这一行代码看起来很不起眼,但它体现了一个非常重要的工程习惯:当你的检查项对某一类对象天然不适用时,要在检查里显式地把它们排除出去,而不是靠事后人工忽略误报。 误报一旦多起来,整套检查就废了——因为人会开始习惯性地忽略输出,然后连真的告警也一起忽略掉。

至于因子本身是怎么算出来的,同一个仓库的数据采集脚本里有一段可以看:它拿复权后的收盘价除以原始收盘价得到因子,然后前向填充;如果数据里压根没有复权后的收盘价这一列,因子就直接置成 1。接着用这个因子去还原各列——价格类的列乘上因子,成交量那一列除以因子。

成交量除以因子这一点值得单独记住。 价格和数量的调整方向是相反的:股票拆成两份,每股价格减半,股数翻倍,两者相乘的总额才不变。你自己处理复权时如果只调价格不调成交量,那么所有涉及成交额的计算都会错,而且是在除权点前后错得不一样。

顺带说一句,「一个数字什么时候才被人知道」这件事跟复权是同一类问题的两个方向。复权是把历史价格按今天的口径重算,而 Point-In-Time 数据 讲的是反过来——你在历史上的某一天,究竟能知道哪些数字。做回测的时候这两件事都得盯着,方向刚好相反:价格要按今天的口径统一,信息要按当时的口径限制。

第六项:换个数据源交叉比对

先说人话

一份体检报告上的某个指标偏高,医生的第一反应通常是让你换一台机器再测一次。

不是因为不信第一次的结果,而是因为一台机器自己是发现不了自己的系统性偏差的。它每次都测出同一个数,从内部看它非常稳定、非常自洽。只有把它跟另一台机器一比,那个偏差才会露出来。

前面五项检查有一个共同的盲区:它们全都是拿数据跟自己比。 缺失、连续性、极端值、零成交量、因子——都是从这份数据内部就能算出来的。而一份数据可以在内部完全自洽,同时整体偏离真实市场。

要发现这类问题,只有一个办法:找第二份数据。

这一项在查什么

不是「哪一份对」,而是「两份在哪里不一样」。

这个区别很关键。 交叉比对的产出不是一个判决,是一张差异清单。有了这张清单你才能去逐条判断:这处差异是复权口径不同,那处差异是一方漏了一天,还有一处是两边的时区处理不一致。

常见的差异来源有这么几类:

复权口径不同。 一份做了前复权、一份做了后复权,或者一份从某个基准日复权、另一份从另一个基准日复权。这类差异表现为整段价格差一个固定比例。

时区或时间戳含义不同。 同一根 K 线,一份标的是这段时间的开始时刻、另一份标的是结束时刻。这类差异表现为整体错位一根。

成交量口径不同。 有的算成交股数、有的算成交金额、有的把大宗交易算进去、有的不算。

某一方补过数据。 一方有、一方没有的那些日期,通常就是补出来的。

判据怎么定

交叉比对的成本比前五项高得多——你得下第二份数据、对齐两边的格式、处理命名差异。所以它的判据要设计得务实:

判据 1:不要全量比。 挑一个有代表性的子集:几个流动性好的大品种、几个流动性差的小品种、几段特殊时期。这个子集能暴露绝大多数系统性问题。

判据 2:分层比对,从粗到细。 先比品种清单——两边各有哪些品种,谁多谁少。再比时间范围。再比日期集合。最后才比具体数值。先粗后细能省掉大量无谓的数值比对,因为粗的层次上一旦对不上,细的层次比了也没有意义。

判据 3:数值比对要允许容差。 两份数据的浮点精度、小数位数、复权基准都可能不同,要求逐位相等是不现实的。设一个相对容差,超出才算差异。

判据 4:把差异分类计数,而不是列出全部。 比对的输出应该是「一共比了多少个品种,其中完全一致多少个、有差异多少个、一方没有的多少个、比对本身出错的多少个」。有了这个分类计数你才知道问题的规模。

查出来之后怎么办

差异集中在少数品种 → 剔除那些品种。

差异是整体性的、有规律的(比如整段差一个固定比例) → 先别急着换源,先搞清楚是不是口径差异。 口径差异不是错误,你只要知道自己用的是哪个口径,并且在报告里写清楚,就能继续用。

差异是零散的、无规律的 → 你有两份都不太可靠的数据。 这时候要么找第三份来做多数表决,要么承认这个品种池不适合做精细的回测。

比对时发现一方整个缺了某些品种 → 这一条最有价值。 一方有、另一方没有的品种,很可能就是退市或改名的品种,也就是幸存者偏差的直接证据。

工具里长什么样

qlib 的 scripts 目录下有另一个脚本 check_dump_bin.py,做的正是交叉比对——它把转换成二进制格式之后的数据,跟原始的 CSV 文件逐字段比回去,验证转换过程有没有出错。

它的结构里有几处值得抄:

结果被分成四类:不在目标数据里、比对不一致、比对一致、比对过程本身报错。注意最后一类的存在。 很多人写比对脚本只有「一致」和「不一致」两个出口,遇到异常就崩了或者静默跳过。而把「比对本身出错」单列一类,意味着你事后能看到「有多少个品种我压根没比成」——这个数如果很大,你那份「一致率 99%」的报告就是假的。

品种名统一转成小写再匹配。 这就是前面那个大小写检查对应的同一个坑——跨数据源比对时,命名差异是最常见的对不上的原因,而它长得像「数据缺失」。

要比对哪些字段是可以指定的,不指定就自动从目标数据里推断出全部字段。 这个默认行为很友好:你不需要事先知道有哪些字段就能跑第一遍。

用了成熟的数据框比对库来做实际的逐行比对,而不是自己写循环。 这一点很实在——比对逻辑里的边界情况(空值算不算相等、浮点容差、索引对齐)多到你自己写一定会漏。

六项自查速查表

判据 最该走的处置分支 千万别做
列与缺失值 五列齐不齐;最长连续缺失多长,不看总缺失率 缺列换源;连续缺失剔除;零散缺失只记账 看到一个缺失率就下结论
时间戳 去重前后行数差;日历减数据(缺);数据减日历(多) 重复去重;缺一大段剔除;多出非交易日查清来源 用前向填充补缺失的交易日
极端值 相邻变化倍数,价格与成交量分开定阈值 除权导致的换源或补因子;单点错误剔除;真实极端只记账 把极端值截断到阈值
零成交量 成交量 ≤ 0;配合「开高低收是否全等」分类 停牌剔除并标记不可交易;涨跌停标记单向不可交易 一刀切当成正常交易日
复权因子 列在不在;是不是全空;变化点数量合不合理 说不清有没有复权就换源;指数显式放行 只调价格不调成交量
交叉比对 分层从粗到细;数值比对带容差;差异分类计数 差异集中就剔品种;口径差异记账即可 只设「一致/不一致」两个出口

这张表可以直接贴在你写自查脚本的那个文件顶上。

三条贯穿所有检查的纪律

跑完这六项之后,还有三件事决定你这套自查是真有用还是走个过场。

第一,检查和处置必须分开。 检查函数只回答「有没有问题、有多少、在哪」,返回事实,不改数据。要不要改、怎么改,是另一层的决定,而且这个决定要能被记录、被复查、被推翻。前面提到的那个缺失校验函数返回一个布尔量而不是修好的数据,就是这个原则的样板。把检查和处置写在一个函数里,最直接的后果是你半年后完全说不清自己那份数据到底被改过什么。

第二,「只记账」应该是默认选项。 我在每一项里都在强调这件事,因为它是最反直觉的一条。人的本能是「发现问题就修好它」,但在数据这件事上,每一次修补都是你在替真实世界做假设,而这个假设会以你看不见的方式传导到最终结果里。缺三天你填三天,填出来的那三天在你的指标里是完全真实的,没有任何东西提醒你它们是造出来的。

真正安全的做法是:能剔就剔,不能剔就记账,实在要修就把修的规模打进日志、写进报告。 判断标准很简单——如果你没法用一句话说清「我改了什么、改了多少、按什么规则改的」,那就别改。

第三,自查结果要跟着回测报告一起交。 一份回测报告的第一页,除了起止日期,还应该有这么几行:多少个品种、缺失率多少、最长连续缺失多少天、剔除了多少个品种及原因、有没有补过数据补了多少。

这几行的作用不是给别人看的,是给三个月后的你自己看的。三个月后你会拿到一个奇怪的结果,你会想「是不是数据的问题」,那时候这几行能让你在五分钟内回答这个问题,而不是从头再跑一遍。拿到一份策略业绩报告,先看哪几个地方 那篇讲了读报告的顺序,而这几行数据体检结果,应该排在那个顺序的最前面——连数据都说不清的报告,后面几站都不用看了。

最后再说一句边界。这六项自查全部做完、全部干净,也只能说明这份数据没有明显的结构性缺陷。它不能说明这份数据准确,更不能说明用它跑出来的回测靠得住。回测和实盘之间还隔着一整层机制差异,那些在 回测好看实盘亏,差的到底是哪三件事 里;而从一句规则到一段能跑的代码,中间那些「你没说、程序替你定了」的空档,在 一句话规则,怎么变成程序能执行的东西 里。自查只是把最低那道门槛跨过去,跨过去之后前面还有很多道。

⚠️ 风险提示

本文讲的是行情数据的质量自查方法与工程实践,不构成任何投资建议、买卖信号或收益承诺,也不推荐任何数据源、平台、交易品种或工具。文中对开源框架的所有描述,均来自对指定快照版本源码与文档的阅读,不是运行结果,我没有实际执行过这些脚本;函数行为、默认阈值与参数名在不同版本间都可能变化,一切以你手上那个版本的源码为准。文中提到的阈值、判据均为方法示例,不存在通用的正确取值,必须结合你自己的市场、周期与品种池确定。数据自查通过不等于回测结果可信,回测结果也不等于实盘结果,任何历史数据与历史业绩都不预示未来收益。完整风险提示见 免责声明

小结

  • 自查必须排在回测之前,理由不是「数据可能有问题」,而是坏数据给出的结果通常比好数据更好看——缺失扎堆在极端行情、停牌被填成平线、退市品种整个消失,这几种偏差全都朝抬高历史收益的方向偏。
  • 第一项查缺失,重点在分布不在总数。 判据是「最长的一段连续缺失有多长」和「有没有全体品种共同缺失的日子」,不是缺失率百分比。
  • 第二项查时间戳,分重复、缺交易日、多出非交易日三个子项。 去重是唯一该默认执行的修补;缺交易日绝对不要用前向填充去补,那会造出一根拉低波动率的假 K 线。
  • 第三项查极端值,判据必须用相邻变化倍数,价格和成交量分开定阈值。 行情数据里的极端跳变,大多数指向同一个根因:复权没做对。别把极端值截断到阈值,那会磨平真实的极端行情。
  • 第四项查零成交量,一格数字能同时揪出停牌、涨跌停封死、流动性枯竭、填充痕迹和采集失败五类情况;作废可疑数据时优先「置空」而不是「删行」,空值会在下游暴露自己,删掉的行只会让统计口径悄悄变小。
  • 第五项查复权因子,三层递进:列在不在、是不是全空、变化点合不合理;指数要显式放行,否则误报会淹没真告警。自己处理复权时记得价格乘因子、成交量除因子。
  • 第六项换源交叉比对,它是唯一能发现「数据内部自洽但整体偏离」的检查。分层从粗到细比,数值比对带容差,输出要有「比对本身出错」这第四类出口。
  • 贯穿全部六项的三条纪律:检查和处置分开写;「只记账」当默认选项而不是「修补」;自查结果跟着回测报告一起交。

一句话说完这篇:数据到手先别急着算收益,花二十分钟看看它缺在哪儿、有没有断过、有没有哪天的数字大得不像话、有没有哪天根本没人交易——查出来的问题能扔就扔、不能扔就老老实实记在本子上,千万别顺手替它把空缺填上,因为你填的那几笔,将来会以「这个方法真不错」的样子回来找你。

内容有错、看不懂、或想看下一篇?告诉我们 →

本文为投资教育整理,不构成任何投资建议;不荐个股/基金、不预测点位、不承诺收益。关键数据与结论请结合权威来源自行验证,并见风险免责声明