作者:量化专家 | 迪雅数据量化学院 适合读者:用行情接口做监控、告警或日内策略的个人开发者
上一篇《回测十年跑得准,先过数据源这关》聊的是历史数据。这一篇说一个只在盘中 10 分钟内出现、但足够让你的监控脚本误报一整年的坑。
一、一个真实的场景
你在 9:25 盯着自己写的监控脚本,它每分钟拉一次自选股的实时行情。9:25 那一刻,屏幕跳出一行告警:
【异动】XX股份 现价 0.00,跌幅 -100.00%
你手忙脚乱打开交易软件——股票明明好好的,昨天收盘 18.60,现在集合竞价挂的是 18.75。
问题不在股票上。问题在这个数据源在集合竞价时段给你返回了 0。
这不是个例。A 股每个交易日 9:15–9:25 是集合竞价窗口,9:20 之前还能撤单、9:20 之后不能撤,9:25 撮合出开盘价。这段时间里,"最新价"这个字段在不同的数据源、甚至同一个数据源的不同接口上,含义完全不同。
二、"装档"是指什么
这是行情圈的一个说法,指数据源在非连续交易时段,用一个占位值填进本该有真实价格的字段。
常见的占位手法有四种,危害程度完全不同:
| 装档方式 | 你看到的值 | 危害 |
|---|---|---|
| 填 0 | 0.00 | 极高。任何按百分比算涨跌幅的代码都会算出 -100%,直接触发告警 |
| 填昨收 | 18.60(昨收) | 最高。看起来"正常",但涨跌幅永远是 0%,你的异动监控彻底失效且不报错 |
| 填昨结/前收但字段含义被改 | 数值正常 | 高。你以为拿到的是竞价撮合价,其实是昨收,逻辑判断全歪 |
| 返回空 + 明确标注时段 | null / 无此条 | 干净。你的代码能判断出"这是空档期",可正确处理 |
注意第二行那格——它比填 0 更危险。填 0 至少会炸出告警让你发现,填昨收则是静默失效:你的监控在 9:15–9:25 这 10 分钟里变成了一个"永远显示涨跌幅 0%"的摆设,而且它不报错、日志干净、看起来一切正常。
一个可以立刻验证的真实样本
随手拉一份实时行情快照,你会看到这种形态:
[
{
"代码": "sz000001",
"名称": "平安银行",
"最新价": "10.93",
"涨跌幅": "-0.09",
"成交量": "0",
"成交额": "0",
"今开": "10.94",
"昨收": "10.94",
"最高": "10.93",
"最低": "10.93"
}
]
价格字段全有值,但成交量和成交额是 0。这不是错误——它反映的是"这个时刻还没有成交"。问题是:
- 如果你的告警逻辑里有"成交量放大 N 倍"这一条,
0 倍会不会误判? - 如果你的策略用
成交额 / 成交量去算均价,这个除法会不会炸?
同一份数据,"有值"和"有意义"是两件事。 数据源没错,是你的代码需要知道这个边界。
三、为什么会装档
三个层面的原因,都是真实的工程约束:
1. 集合竞价本身没有"连续成交价" 9:15–9:25 沪深两市是撮合阶段,只公布虚拟开盘参考价。严格说,这 10 分钟里不存在"最新成交价"这个量。数据源要么给你虚拟参考价,要么给你一个占位符——它可以选,但很多源的实现是"如果字段空就填 0"。
2. 上游推送的字段不齐
部分行情源在竞价期间只推成交量、不推最新价。中间层做字段拼装时,缺失字段用默认值兜底,默认值恰好是 0.0(因为很多语言的数值类型默认就是 0)。
3. 缓存穿透后的兜底值 如果你的请求打到了缓存未命中的路径,有些实现会返回一个结构完整但内容为空的骨架,价格字段是 0。
这三个原因都不是 bug,是设计选择。 但对你的代码来说,结果是一样的:拿到了一个假数据,而且不知道它是假的。
四、怎么验证你的数据源
不用等第二天开盘。下面这套检查你现在就能跑,全部只看字段行为,不依赖你选哪家源。
检查 1:非交易时段调用,看返回什么
选一个周末或交易日 15:30 之后,调一次实时行情接口。看三个点:
- HTTP 状态码:干净的做法是
200+ 空数据,或者明确的状态标识。返回500或超时的是粗糙做法,意味着你必须在客户端加"非交易时段判断",否则半夜监控必炸。 - 价格字段的值:是
null、不返回该字段、返回 0、还是返回昨收? - 有没有时段标识:理想的响应里会带一个字段告诉你"这是收盘后快照"或"非交易时段",让你的代码有依据可判断。
检查 2:看字段的"语义声明"
看文档里 last_price(或对应字段)的定义。如果文档只写"最新价"三个字,没有说明非交易时段的行为,这本身就是个信号——说明实现方没有把这个边界当回事。
检查 3:写一个时段的探针(关键)
这才是最该做的一步。写个脚本,在交易日 9:14 到 9:31 之间,每 30 秒调一次,把时间戳和价格字段一起打到日志里:
import time, requests, json
from datetime import datetime
API = "https://api.cxdy.vip/api/hq"
TOKEN = "你的Token"
SYMBOL = "sh600519"
log = []
while True:
now = datetime.now()
if now.hour == 9 and 14 <= now.minute <= 31:
try:
r = requests.get(API, params={
"apiToken": TOKEN,
"symbol": SYMBOL,
}, timeout=10).json()
log.append({
"t": now.strftime("%H:%M:%S"),
"raw": r,
})
except Exception as e:
log.append({"t": now.strftime("%H:%M:%S"), "error": str(e)})
elif now.hour == 9 and now.minute > 31:
break
time.sleep(30)
with open("auction_probe.json", "w", encoding="utf-8") as f:
json.dump(log, f, ensure_ascii=False, indent=2)
print(f"采集 {len(log)} 条,看 9:25 前后价格字段怎么变")
跑完之后,重点看这三行日志:
| 观察点 | 干净的表现 | 危险的表现 |
|---|---|---|
| 9:15–9:20 | 价格有值(虚拟参考价)或明确为空标记 | 返回 0 |
| 9:20–9:25 | 价格稳定、不跳变(不可撤单期) | 字段闪烁、值忽有忽无 |
| 9:25 瞬间 | 价格从参考价切到开盘价,且能看出这是切换点 | 无法区分,或切换时出现异常值 |
这张表的价值在于:它不评判哪家源好,它只告诉你"你的代码需要为哪种情况写防御"。
五、防御写法:三层保护
知道了坑在哪,代码里加三层就够。
第一层:时段时间窗判断
from datetime import time as t
def is_auction_period(now):
"""A股集合竞价:9:15-9:25;连续竞价:9:30-11:30, 13:00-15:00"""
hm = now.time()
return t(9, 15) <= hm < t(9, 25)
def is_continuous_trading(now):
hm = now.time()
return (t(9, 30) <= hm < t(11, 30)) or (t(13, 0) <= hm < t(15, 0))
第二层:值合法性校验(这一层最关键)
不要相信任何单个价格字段。用下面的规则过滤:
def sanitize_quote(q, pre_close):
"""过滤装档值。返回 (是否可信, 价格)"""
if q is None:
return False, None
# 规则1:0 或负数一律不可信
if q <= 0:
return False, None
# 规则2:偏离昨收超过 ±20% 的,A股除新股外基本不可能,判定为异常
if pre_close and pre_close > 0:
dev = abs(q - pre_close) / pre_close
if dev > 0.20:
return False, None
# 规则3:完全不偏离(正好等于昨收)在竞价期高度可疑,标记为存疑
if dev == 0:
return "suspect", q
return True, q
第三层:告警去抖
监控脚本不要在单次异常值上直接告警,要求"连续 2 次采样都异常"才推。这一层能挡掉绝大部分由装档、缓存穿透、网络抖动引起的误报。
class Debouncer:
def __init__(self, need=2):
self.need = need
self.hits = {}
def check(self, key, is_bad):
n = self.hits.get(key, 0)
n = n + 1 if is_bad else 0
self.hits[key] = n
return n >= self.need # 连续 N 次才真告警
六、另一种思路:把竞价数据单独拿出来
上面讲的是"防御"——用一个通用实时接口,然后自己过滤掉不可信的值。
还有另一种设计思路:既然竞价这 10 分钟的数据结构和连续竞价完全不同,就给它一个独立的接口。
这样做的直接好处是:字段名里可以带上单位,从源头消除歧义。看一个真实的竞价数据返回:
[
{
"股票代码": "000001.SZ",
"交易日期": "20260526",
"集合竞价成交量(股)": "867700",
"集合竞价成交均价(元)": 10.71,
"集合竞价成交金额(元)": 9293067,
"昨收价(元)": 10.68,
"集合竞价换手率(%)": 0,
"集合竞价量比": 1.02,
"流通股本(万股)": 1940560
}
]
注意三个细节:
1. 单位写在字段名里(成交量(股)、成交均价(元)、流通股本(万股))。你不用去翻文档确认"这个 volume 是手还是股"——歧义在设计阶段就被消掉了。这比事后写注释可靠得多。
2. 没有"最新价"字段,只有"成交均价"。这个命名是诚实的:集合竞价输出的是一个撮合均价,不是某个瞬时成交价。字段名如实描述了它的语义,你也就不会误用。
3. 换手率 是 0,但成交量不是 0。867700 股对应的换手率太小,被抹成了 0。注意——这是小数精度问题,不是装档。区分这两者很重要:
| 现象 | 性质 | 你要做什么 |
|---|---|---|
| 成交量 867700,换手率显示 0 | 精度抹零 | 用成交量自己算,别依赖这个字段 |
| 成交量 0,价格有值(收盘后) | 非交易时段 | 加时段判断 |
| 价格 0,成交量有值 | 真装档 | 加值合法性校验 |
同一个"0",可能是三种完全不同的东西。 这就是为什么"看到 0 就当异常处理"是偷懒做法——它会让你在正常场景下也误报。
另一种是历史竞价数据,字段设计又不一样:
[
{
"股票代码": "sh688595",
"股票名称": "芯海科技",
"交易日期": "2026-09-09",
"集合竞价价格": 29.4001,
"集合竞价成交量": 4864,
"竞价涨幅": 1.17,
"开盘价": 29.4001,
"竞价换手率": 0.34,
"竞价量比": 0.23,
"竞价情绪": "平开"
}
]
这里有个有意思的字段——竞价情绪(值 平开)。它把"竞价价格相对昨收的偏离程度"直接算成了一个可读标签。分类这件事在服务端做完,你不用在自己的代码里再维护一套阈值表。
但请注意两个接口的 symbol 格式不一样:zpjj 返回 000001.SZ(后缀式),lsjj 返回 sh688595(前缀式)。同一个站内的两个接口都不统一。
这不是迪雅独有的问题——几乎每一家数据源都存在接口间格式不一致,因为不同接口往往由不同的数据管线维护。这引出了下一篇的主题:字段校准。你的代码必须能吸收这种差异,而不是假设"既然是同一家,格式肯定一样"。
七、把这件事写进你的选型清单
上一篇的结论是"数据源要口径稳定"。这一篇补上一条更具体的:
在选行情接口时,除了看"能不能拿到实时数据",还要专门确认两件事:
- 非交易时段 / 集合竞价时段,价格字段返回什么? 文档里有没有明确写?
- 有没有办法判断"这个值是不是有效值"? 是靠你猜,还是响应里有字段可以判断?
这两条都能明确回答的源,说明它的实现方认真想过边界情况。回答不了的,你就得在客户端自己补防御层——补得上,但那是你替它承担的维护成本。
第三条判断标准,是看它有没有为特殊时段做专门的数据结构。做这件事的成本不低(要单独维护一条数据管线),愿意做的源,通常意味着它对数据质量有明确要求。
八、写在最后
集合竞价那 10 分钟,占一个交易日的 1/24。但因为它是每天都会准时发生的边界情况,一旦没处理,你的监控脚本就会在每一个交易日都经历一次数据失真。
比"拿到 0"更麻烦的是"拿到昨收"——它不报错,让你以为一切正常。同样麻烦的是把"精度抹零"当成"装档"来防——它会让你在正常场景下也误报。
判断方法很简单:找个周末,或者今天收盘后,调一次你的接口。 看它返回什么。这五分钟的测试,能省掉你未来无数个被误报告警吵醒的早上。
本文为个人量化研究经验分享,数据接口信息以各平台官网最新公布为准。市场有风险,回测与监控结果不代表实盘收益,不构成任何投资建议。
关于本文用到的数据
文中的竞价数据结构示例,取自迪雅数据(diyadata.cn)的集合竞价接口。它提供两个互补的接口:
| 接口 | 用途 | 数据范围 |
|---|---|---|
早盘竞价 zpjj | 当日竞价数据,字段带单位后缀 | 当日 |
历史集合竞价 lsjj | 历史竞价回溯,含「竞价情绪」分类字段 | 历史区间 |
两者的 symbol 格式不同(000001.SZ 与 sh688595),建议按本文的归一化思路统一处理。迪雅数据目前提供 50+ 个 A 股数据接口,具体字段与调用方式见官网接口文档。
下一篇预告:《同一只股票,两个数据源给出的"成交量"差 100 倍——字段校准 6 个校准点》