行情数据质量校验的难点不在“数据有没有到”,而在“到了的数据对不对”。链路中断会报错,错误的值不会:一次价格尖峰、一段时间戳回退、一组交叉盘口,都能安静穿过采集、落库与消费三层,直到在下游的假告警或回测结果里显形。本文按 TIQEX 已文档化的字段口径给出六类静默失效的判据,并附一个可直接运行的 Python 校验器。
一个不会报警的故障
设想一条已在生产运行的管道:WebSocket 连上了,type=subscribed 回执正常,tick 帧以稳定速率到达,连接数、帧率、HTTP 状态码全绿。但日志显示,某个标的的 tick.at 已停在十二分钟前——上游推送早已停止,客户端收到的只是同一个旧值的重复帧。
这类故障的共性是传输层健康、内容层已错,而多数团队只监控前者。更隐蔽的是,即使状态码为 2xx,业务层仍可能失败:TIQEX 的错误文档明确指出,响应信封的 succ 字段在 2xx 下仍可能为 false,因此两者必须同时检查。只看 HTTP 200 就放行,等于把内容层的判据全部让给下游。
结论:生产级管道需要两条独立的健康度信号——传输健康度与内容健康度(值域、时间、自洽性)。本文只讨论后者。
静默失效为什么比中断更贵
中断是显式失败:触发重试与告警,代价可见且有界——几秒到几分钟的缺口,加一次人工确认。静默失效不触发任何机制,代价沿链路放大:
- 假信号成本。一条尖峰进入告警系统,触发一次人工核查。被侵蚀的是告警通道的信噪比——假告警占比上升时,真实信号被忽略的概率同步上升。
- 重算成本。错误值一旦进入回测数据集或报表,修复不是改一行记录,而是重跑整条管道并重新分发结论。
- 信任成本。最贵的一段。团队开始对全部数据打折扣,决策退回人工判断。
作为量级参照,假设一次假告警核查为十五分钟、一次回测重跑为四小时——这是示例参数,不是实测数字。真正值得注意的是成本的不对称:检测本身几乎免费。六条断言都在本地对已到达的数据执行,不发出 API 调用,不消耗配额。用零边际成本换取下游可信度,正是把校验前移到采集端的理由。
六类静默失效与最小检测判据
以下六类覆盖了“值不为空但已经错了”的绝大多数情况,共同点是都能用一条廉价断言检出,无需统计模型或人工标注。
| # | 失效类型 | 典型表现 | 最小检测判据 | 主要误报来源 |
|---|---|---|---|---|
| 1 | 陈旧值 stale | 帧持续到达,at 不前进 | now − at > k × 典型间隔 | 休市时段 |
| 2 | 时间戳回退 | at 小于上一条 | 按标的维护水位线 | 乱序到达 |
| 3 | 价格尖峰 | last 偏离中位数一个数量级 | |last − median| > k × 1.4826 × MAD | 真实跳空 |
| 4 | K 线不自洽 | high 小于 max(open, close) | 落库前对每根 bar 断言 | 复权口径混用 |
| 5 | 有价无量伪更新 | volume 未变而 last 变化 | 对 (last, volume) 联合判断 | 集合竞价 |
| 6 | 盘口交叉 | bid 大于 ask 或报价非正 | 中间价计算前断言 | 瞬时单边 |
三点补充。第 1 类必须结合交易时段:加密市场 7×24 运行(见加密市场页),股票与期货有休市窗口,同一阈值在非交易时段会大量误报。第 3 类用中位数绝对偏差(MAD)而非标准差,因为标准差会被尖峰污染——一个极端值抬高阈值,让下一次尖峰逃逸。第 4 类依赖 OHLCV 字段口径,复权口径混用是最常见的误报来源。
一个可以直接运行的校验器
下面的实现把六条规则收在一个类里,只依赖标准库。输入是 TIQEX WebSocket 的 tick 帧(at 为 RFC3339 时间),输出为违规原因列表,空列表代表通过。订阅报文与帧格式见WebSocket 实时行情接入指南。
# quality.py — 行情数据质量校验:六条廉价断言,仅依赖标准库
from collections import deque
from datetime import datetime, timezone
class QualityGate:
"""median_window: 尖峰检测窗口长度;spike_k: MAD 倍数阈值;stale_after: 陈旧阈值(秒)。"""
def __init__(self, median_window=200, spike_k=8.0, stale_after=60.0):
self.prices = deque(maxlen=median_window)
self.spike_k = spike_k
self.stale_after = stale_after
self.last_at = {}
self.last_vol = {}
def check(self, tick, now=None):
now = now or datetime.now(timezone.utc)
sym = tick["symbol"]
out = []
at = datetime.fromisoformat(tick["at"].replace("Z", "+00:00"))
# 1 陈旧值:帧仍在到达,时间戳不再前进
lag = (now - at).total_seconds()
if lag > self.stale_after:
out.append("stale %s lag=%.0fs" % (sym, lag))
# 2 时间戳回退:同一标的后一条早于前一条
prev = self.last_at.get(sym)
if prev is not None and at < prev:
out.append("time_regression %s" % sym)
self.last_at[sym] = at if prev is None else max(at, prev)
last = tick.get("last")
# 3 价格尖峰:相对滚动中位数的 MAD 偏离
if isinstance(last, (int, float)):
if len(self.prices) >= 30:
med = sorted(self.prices)[len(self.prices) // 2]
mad = sorted(abs(x - med) for x in self.prices)[len(self.prices) // 2]
if mad and abs(last - med) > self.spike_k * 1.4826 * mad:
out.append("spike %s %s" % (sym, last))
self.prices.append(last)
# 4 有价无量的伪更新:volume 未变而 last 变化
vol = tick.get("volume")
if isinstance(vol, (int, float)) and isinstance(last, (int, float)):
pv = self.last_vol.get(sym)
if pv is not None and pv[0] == vol and pv[1] != last:
out.append("phantom_update %s" % sym)
self.last_vol[sym] = (vol, last)
# 5 盘口交叉 / 非正报价:不得进入中间价计算
bid, ask = tick.get("bid"), tick.get("ask")
if isinstance(bid, (int, float)) and isinstance(ask, (int, float)):
if bid <= 0 or ask <= 0 or bid > ask:
out.append("crossed_book %s bid=%s ask=%s" % (sym, bid, ask))
return out
def check_bar(bar):
"""第 6 类:K 线内部自洽性,落库前逐根断言。"""
o, h, l, c = (bar[k] for k in ("open", "high", "low", "close"))
if h < l or h < max(o, c) or l > min(o, c):
return ["inconsistent_bar %s" % bar.get("openTime")]
return []用法是把 check() 接在收帧回调的第一行:空列表就落库,非空则连同违规原因一并写入,而不是丢弃。
阈值不该写死
把 stale_after=60 当作全局常量是下一个常见错误:同一套阈值用在 1 分钟 K 线和日线上,误报率会差一个数量级。可行做法是从标的自身的历史分布反推,并按标的缓存:
# thresholds.py — 从历史分布反推阈值,不要写死全局常量
import statistics as st
def suggest_thresholds(bars):
"""bars 取自 /api/v1/market/equity/kline 的 bars[] 数组。"""
gaps = [bars[i]["closeTime"] - bars[i - 1]["closeTime"] for i in range(1, len(bars))]
steps = [abs(bars[i]["close"] - bars[i - 1]["close"]) for i in range(1, len(bars))]
med = st.median(steps)
mad = st.median([abs(s - med) for s in steps]) or med * 0.01
return {
"stale_after_ms": int(st.median(gaps) * 5), # 连续 5 个周期无更新即判陈旧
"spike_abs": round(med + 8 * 1.4826 * mad, 6), # 8 倍 MAD 以上判为尖峰
}输入是 /api/v1/market/equity/kline 的响应体,每根 bar 携带 openTime / closeTime(毫秒);interval 默认 1D,可取 1T 至 1M 共九档,limit 默认 100、范围 1 至 1000。跑一遍历史数据得到的阈值,比经验值更贴近该标的的真实波动结构。
三道防线与放置位置
校验放错位置,成本会翻倍。三条防线各管一类:
- 采集端(最优先)。WebSocket 帧到达即校验。此处上下文最完整,且推流不按条计费,校验的边际成本为零。
- 落库前。对 K 线做第 4 类自洽断言与时间戳单调性检查。写入前拒绝,避免污染历史数据集——它被污染后修复成本最高。
- 消费前。告警与回测读取时再校验一次。理由不是冗余:历史数据可能被上游事后修正。
一条原则:校验不通过的数据应当被标记,而不是被删除。删除会掩盖失效率。
校验的成本与套餐配额
把校验放在采集端的关键收益在成本结构:TIQEX 对 WebSocket 只在握手时计入日配额与 RPM,之后的 tick 推送不再按条计费。对推流做逐条校验几乎不增加调用成本。
真正消耗配额的是异常后的补数。补数走 REST,两个上限需要注意:quote 的 codes 单次最多 50 个标的,kline 的 limit 上限 1000。补数应按批组织,遇到 429 时优先使用响应中的 Retry-After,配合带抖动的指数退避。
速率限制按套餐分档:免费版与试用 30 RPM,标准版 120,专业版 600,企业版 3000;WebSocket 连接数与标的上限依次为 1 / 10、3 / 25、10 / 50、50 / 200。配额与覆盖方面,免费版 300 次/日(加密 / 美股 / 金属延时),标准版 $99/月 50 万次,专业版 $299/月 500 万次且全市场实时,完整对照见定价页。
最后一条容易混淆:SLA 保证的是接口可用性,不是内容正确性。标准版 99.9% 与专业版 99.99% 描述的是服务可用性与延迟档位;内容层的正确性由本文的校验负责,两者需要分开监控。
常见问题(FAQ)
校验会不会拖慢推流?
不会成为瓶颈。六条断言都是常数级或对数级的本地计算,滚动窗口长度可配。需要控制的是每标的状态字典增长,超过数千标的时改用环形数组。单条 tick 的校验开销通常在微秒量级,远低于网络抖动。
阈值应该用固定值还是自适应?
自适应,但要分层:先按市场时段分层,再按标的用历史分布反推,最后按 K 线周期分开配置。全局常量一旦跨市场就会失效。
发现坏 tick 应该丢弃还是保留?
保留并标记。丢弃会让失效率无法统计,也无法判断上游是否在持续劣化。落库时增加一个 quality_flags 字段,由消费端按需过滤——先保住状态,再决定用途。
免费版能做这套校验吗?
可以,而且这是推荐起点。免费版提供加密 / 美股 / 金属的延时数据与 WebSocket 流式推送,300 次/日足够完成校验器开发与阈值标定。需要全市场实时数据时再升级,免费起步无需信用卡。
下一步
落地路径很短:把两个脚本拷进项目,用免费密钥对着一个标的跑 24 小时,统计六类违规的触发次数;再据此调整阈值,最后把校验接进收帧回调。不需要新依赖,也不需要改动数据模型——只是给每条记录补上一个质量标记。
行情管道的可靠性不取决于上游 SLA 有多高,而取决于数据变坏时你多快能知道。把这条判据前移到采集端,是最省成本的一步。
本文引用的接口字段、错误码、速率限制与套餐覆盖均来自 tiqex.io 官方文档(更新于 2026-09-12)与定价页,访问时间 2026-09-22。示例参数仅用于说明工程实践,不构成投资建议或收益预测。