作者:量化专家 | 迪雅数据量化学院 适合读者:正在用免费数据源做研究、或准备把研究代码转成长期运行系统的开发者
前两篇讲的是"怎么发现数据问题"。这一篇讲一个更麻烦的层次:问题发现不了的时候会发生什么。
你可能觉得夸张。但先看一个真实的形态:
[
{"date": "2016-10-12", "open": 9.07, "close": 11.07, "high": 11.07, "low": 9.07, "amount": 355.87},
{"date": "2016-10-13", "open": 12.27, "close": 12.27, "high": 12.27, "low": 12.27, "amount": 73},
{"date": "2016-10-14", "open": 13.6, "close": 13.6, "high": 13.6, "low": 13.6, "amount": 128.89}
]
结构完整,类型正确,字段齐全。看起来完全正常。
但仔细看第二行和第三行:open == close == high == low。一整天的四个价格完全相同。
这在 A 股意味着什么?意味着这一天这只股票只有一个价格。只有两种情况会这样:一字涨跌停,或者这笔数据是假的。
这就是免费数据源最危险的地方——它不报错。
一、错误有三种,只有一种你能立刻发现
| 错误类型 | 表现 | 你能发现吗 |
|---|---|---|
| 接口挂了 | 超时、连接失败、HTTP 500 | 立刻发现,程序直接报错 |
| 返回空 | [] 或 null | 容易发现,但要你写了判断 |
| 返回错的数据 | 结构完整、值也"像那么回事" | 静默通过,进入你的计算链路 |
前两种是"好错误"——它们吵闹、显眼,逼你立刻处理。
第三种是"坏错误"——它安静、体面,穿着正确格式的外衣,出现在你的回测结果里。你可能两周后才从"这个策略收益怎么这么高"的疑虑中发现它。
而且方向往往是让你的结果变好看:缺失的交易日被跳过、错误的复权让收益曲线更平滑、异常值被当成真实波动。你没有动力去怀疑一个表现良好的回测。
二、错误是从哪来的
理解来源,才能设计检查。免费数据源的错误主要来自四个环节:
1. 上游页面结构变化
多数免费源的本质是爬取公开财经页面。上游改一次 HTML 结构、加一次反爬、调整一次分页规则,抓取逻辑就可能失效。
失效不总是"彻底抓不到"。更常见的是部分失效:原来一页 100 条,现在一页 80 条——于是你拿到不完整的数据,但每一行都是真的。
这类错误最隐蔽:字段对、格式对、值也对,只是少了。
2. 字段位置漂移
如果解析逻辑依赖"第 43 个位置是市净率"这种位置约定,上游插入一个新字段,你拿到的就全错位了。
注意:错位后的值仍然是合法的数字。 你不会看到 NaN,你会看到"市净率 = 2.35"——它其实是振幅。
3. 复权口径未声明
免费源常常直接给一个 close,不说是什么口径。你自己按前复权理解,它可能是未复权。
在除权日,这个差异会让你的信号完全反向。
4. 时间基准混乱
有的源用交易日,有的用自然日。非交易日是缺行、补前收、还是补 NaN——不同源处理不同。
一旦不一致,你的"持有 N 天"就变成了不确定的 N 天。
三、四个能自动跑的检查
核心思路是:把"人眼才能发现的错误"变成"程序能自动发现的错误"。
检查 1:OHLC 逻辑自洽
这一条能挡掉大量脏数据,且零成本:
def check_ohlc(bar: dict) -> list[str]:
"""返回问题列表,空列表表示通过"""
errs = []
o, c, h, l = bar['open'], bar['close'], bar['high'], bar['low']
# 最高价必须 >= 所有其他价格
if h < max(o, c, l):
errs.append(f"{bar['date']} 最高价 {h} 低于 open/close/low")
# 最低价必须 <= 所有其他价格
if l > min(o, c, h):
errs.append(f"{bar['date']} 最低价 {l} 高于 open/close/high")
# 价格必须为正
if min(o, c, h, l) <= 0:
errs.append(f"{bar['date']} 存在非正价格")
return errs
def check_flat_days(bars: list[dict], max_ratio: float = 0.05):
"""检测「四价相同」的异常日占比
正常股票偶有一字涨跌停,但占比不应超过 5%
"""
flat = [b for b in bars
if b['open'] == b['close'] == b['high'] == b['low']]
ratio = len(flat) / len(bars) if bars else 0
if ratio > max_ratio:
return {
"ratio": round(ratio, 4),
"count": len(flat),
"sample": [b['date'] for b in flat[:5]],
"verdict": "异常:占比过高,数据源可能返回了填充值",
}
return {"ratio": round(ratio, 4), "count": len(flat), "verdict": "正常"}
为什么 max_ratio 是 5%? 因为正常股票确实会有极少数一字板。这个阈值不是精确科学,是"能筛出批量填充"的量级判断。关键是它自动跑,不用你看图。
检查 2:交易日连续性
from datetime import datetime, timedelta
def check_trading_days(bars: list[dict]):
"""检测缺失的交易日(用粗略的工作日规则,精确版应查交易日历)"""
dates = sorted(datetime.strptime(b['date'], '%Y-%m-%d') for b in bars)
if len(dates) < 2:
return {"verdict": "数据过少"}
gaps = []
for i in range(1, len(dates)):
delta = (dates[i] - dates[i-1]).days
# 超过 5 天(含周末+节假日容差)视为可疑缺口
if delta > 5:
gaps.append({
"from": dates[i-1].strftime('%Y-%m-%d'),
"to": dates[i].strftime('%Y-%m-%d'),
"days": delta,
})
return {
"total_bars": len(bars),
"range": f"{dates[0]:%Y-%m-%d} ~ {dates[-1]:%Y-%m-%d}",
"gaps": gaps,
"verdict": "发现缺口,需核对" if gaps else "连续",
}
注意这个检查只能发现"明显缺口"(比如整月缺失)。精确判断需要真实交易日历——春节休市和普通周一的区别,只有日历知道。但粗略检查已经能抓出大部分"抓取中断"的情况。
检查 3:价格跳变
def check_jumps(bars: list[dict], threshold: float = 0.11):
"""检测相邻交易日超过 ±11% 的跳变
A股涨跌停是 ±10%(ST 为 ±5%),超过 11% 基本可判定为数据问题
排除条件:用户需自行标注的除权日
"""
jumps = []
for i in range(1, len(bars)):
prev_c = bars[i-1]['close']
cur_c = bars[i]['close']
if prev_c <= 0:
continue
chg = (cur_c - prev_c) / prev_c
if abs(chg) > threshold:
jumps.append({
"date": bars[i]['date'],
"prev_close": prev_c,
"close": cur_c,
"chg_pct": round(chg * 100, 2),
})
return jumps
这个检查的产出要人工看一眼——因为除权日确实会出现大幅跳变,那是正常的。所以:
- 跳变集中在少数几天 → 大概率是除权,检查你的数据源是否已复权
- 跳变分散在很多天 → 数据源有问题
「异常检测」的产出永远需要人判断,但程序能帮你把 2400 个交易日缩小到 5 个可疑点。 这就是它的价值。
检查 4:和一个独立源交叉验证
这是最强的一条,也是成本最高的一条:
def cross_validate(bars_a: list[dict], bars_b: list[dict], tol: float = 0.001):
"""两个独立源比对收盘价,容差 0.1%"""
map_b = {b['date']: b['close'] for b in bars_b}
diffs = []
for a in bars_a:
b_close = map_b.get(a['date'])
if b_close is None:
continue
if a['close'] <= 0:
continue
dev = abs(a['close'] - b_close) / a['close']
if dev > tol:
diffs.append({
"date": a['date'],
"a": a['close'],
"b": b_close,
"dev_pct": round(dev * 100, 3),
})
return diffs
判读规则:
| 差异情况 | 含义 |
|---|---|
| 0 个差异 | 两个源一致,可信 |
| 少数几天有差异 | 大概率是除权处理不同,检查复权口径 |
| 差异超过 5% 的交易日 | 至少一个源有问题,不能直接用 |
| 差异系统性偏移(比如 b 总是略高) | 可能是复权基准不同,需要标定 |
交叉验证的前提是两个源真的独立。 如果它们都爬同一个上游页面,那验证的是"两边同时错"——这种验证给你的是虚假的安心。
四、把检查装进流程,而不是装在脑子里
上面四个函数,如果只是"需要的时候跑一下",大概率不会跑。要把它变成流程的一部分。
数据入库
↓
【自动】OHLC 自洽检查 ──→ 失败 → 拒绝入库 + 告警
↓
【自动】交易日连续性 ──→ 异常 → 记录待核 + 继续
↓
【自动】跳变检测 ──→ 输出可疑点清单
↓
【人工】看清单(30 秒)──→ 确认是除权还是错误
↓
【定期】交叉验证 ──→ 每月一次,抽样比对
关键在第二列的分级:硬性错误直接拒绝,可疑情况记录后放行。
如果所有检查都设成"失败就停",你会因为除权日的正常跳变而天天被拦,最后把检查关掉。分级是为了让它能长期跑下去。
五、免费源的隐性成本怎么算
免费源的逻辑是:你不付钱,但要自己承担维护成本。这不是坏事——如果你的场景是学习、验证、一次性的研究,这个成本完全可以接受。
要算清楚的是这笔账:
| 项目 | 免费源 | 付费源 |
|---|---|---|
| 显性成本 | 0 | 有 |
| 接口失效后的排查 | 你 | 服务方 |
| 上游改版的适配 | 你 | 服务方 |
| 数据口径的确认 | 你 | 文档应明确 |
| 出问题时的等待 | 不确定(等上游修复) | 有响应时效 |
把时间折成钱:如果每小时算 200 元,每月花 2 小时修数据源,一年就是 4800 元。这个数很可能已经超过一个付费方案的年费。
但反过来说:如果你一个月只用一次数据、只做一个简单验证,那 4800 元的前提不成立,免费源就是最优选择。
所以问题不是"免费好不好",而是"你的时间值多少钱,以及你的数据会不会进入生产链路"。
六、写在最后
这一篇没有结论,只有三句话:
第一句:错误的形态决定它的危害。吵闹的错误是好事,安静的错误才是问题。
第二句:判断一个源可不可靠,不看它的文档写得多好,看它出问题时的表现。文档可以写得很好,边界处理却很差。
第三句:把"发现错误"从人工变成自动。你不可能每天都检查一遍 2400 个交易日的数据,但程序可以。
前面两篇讲的是"怎么让数据正确",这一篇讲的是"怎么知道数据错了"。后者是前者的前提——你不知道它错了,就永远修不好它。
本文为个人量化研究经验分享,数据接口信息以各平台官网最新公布为准。市场有风险,回测结果不代表实盘收益,不构成任何投资建议。
关于本文的写作立场
本文讨论的是方法,不是产品。文中四个检查函数与任何数据源都无关——它们作用于你已经拿到的数据,用来判断这份数据可不可信。这套检查同样适用于付费数据源(付费买的是"更少的错误"和"出问题有人负责",不是"零错误")。
如果你需要拿一个真实接口跑通这几个检查,迪雅数据(diyadata.cn)提供 50+ 个 A 股数据接口,包含实时行情、历史行情、集合竞价、板块与成分股等门类,可申请接口密钥自行验证。建议按本文的检查清单先跑一遍,再决定用哪个源。
本文是「数据源选型」系列的第三篇。前两篇:《回测十年跑得准,先过数据源这关》《集合竞价那 10 分钟,你的行情接口在"装档"吗?》《同一只股票,两个数据源给出的"成交量"差 100 倍》