数据质量检查:看不见的坑,最要命
做量化交易,说白了,策略逻辑再漂亮,输入的数据不对,结果就是废纸。我见过太多团队,花几个月调参、回测、优化,一上实盘就崩。为什么?数据质量不过关。
这一章,咱们聊聊四种最要命的数据问题:缺失值、异常值、生存偏差、前视偏差。每个我都吃过亏,有些亏现在还疼。
核心观点:数据质量问题不解决,回测结果再好看,也只是数字游戏。你赚的钱,可能是数据漏洞给你的幻觉。
一、缺失值:数据里的窟窿
举个简单的例子。你有一张日线行情表,某天某只股票的价格是 NaN。为什么会出现这种情况?可能是交易所数据断流,可能是行情商接口挂了,也可能是交易日历没对齐。
我个人习惯,收到任何数据源的第一件事就是跑一下缺失值检查。几行代码就能搞定的事,很多人偏偏跳过。
import pandas as pd
import numpy as np
# 加载日线数据
df = pd.read_csv('daily_data.csv', parse_dates=['date'], index_col='date')
# 检查每列的缺失比例
missing_ratio = df.isnull().sum() / len(df)
print(missing_ratio)
# 定位缺失的具体日期和股票
missing_dates = df[df['close'].isnull()].index
print(f"缺失收盘价的交易日: {len(missing_dates)} 天")
处理方式,我一般分三种情况:
- 随机缺失(比如个别交易日没数据):用前值填充或用插值法补上。简单场景用
ffill,精致一点用线性插值。 - 连续大面积缺失(比如某股票停牌一个月):直接删除这一段,不要造数据。你填进去的也不真实。
- 交易日历不匹配(比如 A 股和港股假期不同):做对齐时慎重。我在项目中遇到过,用某个数据商的港股数据,竟然漏掉了圣诞节的交易日,导致因子计算全乱了。
提示:填充缺失值时,注意不要引入未来信息。比如用 ffill 是安全的,因为它只用过去的数据。用 bfill ?你要小心——那个未来值可能还没有发生。
二、异常值:谁在捣乱?
异常值,就是数据里那些离谱的数。比如某股票今天涨了 1000%,或者成交量突然多了几个量级。这种数据一看就不正常。
你想想看,如果策略是基于统计规律的,一个异常值可能把整个回归模型拉偏。我记得有一次回测年化收益做到了 45%,查了半天发现是因为某天的成交量数据多打了一个零。
识别方法,我常用两种:
- Z-score 法:把数据标准化,看哪些点离均值太远。一般 Z-score 值超过 3 的,就算异常。
- IQR 法:四分位距法。Q1 - 1.5*IQR 以下,Q3 + 1.5*IQR 以上,都标记为异常。
# Z-score 检测
from scipy import stats
z_scores = np.abs(stats.zscore(df['volume']))
outliers_z = df[z_scores > 3]
print(f"Z-score 检测到 {len(outliers_z)} 个异常成交量")
# IQR 检测
Q1 = df['volume'].quantile(0.25)
Q3 = df['volume'].quantile(0.75)
IQR = Q3 - Q1
outliers_iqr = df[(df['volume'] < Q1 - 1.5*IQR) | (df['volume'] > Q3 + 1.5*IQR)]
print(f"IQR 检测到 {len(outliers_iqr)} 个异常成交量")
处理方式上,我的建议是:
- 金融数据的异常值,很多时候是真实事件(比如分红、送股、拆分)。不要去改它,而是要做复权处理。
- 真正因为数据录入错误导致的异常值,直接删除或者用前后均值替换。
- 遇到极端行情(比如 2015 年股灾),异常值反而是信号来源。要不要处理,看策略目的。
警告:不要对所有异常值一刀切。我曾经因为误删了分红日的价格数据,导致回测结果偏差了 3% 的年化收益。一定要先搞清楚异常产生的原因。
三、生存偏差:你以为的样本,其实不完整
这是个大坑。很多回测只用了「还活着」的股票——也就是到今天还在交易的股票。那些退市的、被收购的、跌没了的股票,都不在数据集里。
结果是什么?回测收益被高估了。因为你只看了活下来的幸存者,那些亏到退市的都不计数。
我记得有个公开的量化数据集,就是因为没处理生存偏差,被很多新手拿来跑回测,个个都觉得自己发现了圣杯。
怎么处理?
- 必须使用包含退市股票的历史数据。很多数据商提供「退市数据包」,虽然贵,但值得买。
- 回测时,把退市股票的最后一个交易日价格算入最终组合收益,然后股票池里把它删掉。
- 特别提醒:退市股票的最后价格往往是零或很低,这对多空策略会带来真实的亏损模拟。
| 数据来源 | 是否含退市数据 | 生存偏差风险 | 我的建议 |
|---|---|---|---|
| 免费 Yahoo Finance | 不全 | 高 | 慎用,需补退市数据 |
| Wind/CSMAR | 含有退市板块 | 低 | 可以用,但需确认覆盖完整 |
| QuantConnect/Backtrader | 部分 | 中等 | 仔细阅读其数据说明文档 |
提示:如果你用的是免费数据源,一个变通的方法是只回测“最近三年的活跃股票”。时间窗口短,生存偏差影响相对小。但长期回测,不能绕过这个问题。
四、前视偏差:用未来信息做决策
这可能是最隐蔽的坑。说白了,就是在回测的时候,你无意中用了「当时还看不到」的数据。
举个例子:你在计算某个因子时,用到了整个月的数据来做标准化。但事实上,在月初你根本不可能知道月底的数据。这就是前视偏差。
我遇到过最离谱的一次,是一个团队的策略年化收益 80%,夏普比 3.5。我一查代码,发现他们在「次日开盘价」计算中误用了「当日收盘价」。换句话说,他们的信号依赖于当天收盘后才可能知道的信息——这等于作弊。
如何避免?
- 严格对齐时间轴:所有用于计算的输入数据,在时间上必须早于决策时刻。
- 使用滞后算子:比如用 T-1 日的收盘价来计算 T 日的信号,而不是 T 日的收盘价。
- 做一次批处理回测:把信号生成和交易执行拆分为两个阶段,中间引入至少一个 tick 的延迟。
# 错误示例:前视偏差
df['signal'] = np.where(df['close'].rolling(5).mean() > df['close'], 1, 0)
# 这里的 rolling(5) 用了当前及之前共5天的数据,但信号产生时刻,最后一天数据还没固定
# 正确做法:使用严格滞后的数据
df['signal'] = df['close'].shift(1).rolling(5).mean() > df['close'].shift(1)
# shift(1) 确保了信号是在下一个交易日才能用的
警告:前视偏差通常是最隐蔽,也是破坏力最大的数据问题。我的习惯是,写完回测代码后,再手动做一次「时间穿越检查」:假设今天是 2018 年 1 月 1 日,我能不能拿到这些数据?拿不到,就是前视偏差。
小结一下
数据质量检查,是策略失效排查的第一步,也是最重要的一步。缺失值、异常值、生存偏差、前视偏差,这四个问题,你至少要在数据清洗阶段全部过一遍。
我个人习惯,每次拿到新数据,先跑一个完整的数据质量报告,打印出下面这张表:
| 检查项目 | 检测方法 | 补救措施 | 优先级 |
|---|---|---|---|
| 缺失值 | isnull() 逐列统计 | 向前填充 / 删除 / 插值 | 高 |
| 异常值 | Z-score / IQR / 人工审查 | 判断原因后处理 | 高 |
| 生存偏差 | 检查退市股票是否在数据集内 | 补充退市数据 | 中 |
| 前视偏差 | 逐行检查 shift() 对齐 | 调整时间轴,加滞后 | 极高 |
数据干净了,策略检验才有意义。不然你就跟我当年一样,白高兴一场,然后熬夜查 bug。