一句话结论:读一次五档盘口不难,难的是让这次读取成为可复现、可校验、可解释的数据单元;只要在取数之后补上显式的结构化和校验层,绝大多数“盘口数据看起来不对”的问题都能在进入策略之前被拦下来。
1. 五档盘口到底给了你什么
五档盘口是 Level-1 行情里最常见的一种快照结构:在某个时刻,把订单簿里最优的 5 个买价档和 5 个卖价档打包给你,每档包含价格和该价位上的挂单数量。
先说清三件事,很多坑都源自把这三件事混在一起。
第一,它是挂单,不是成交。 卖一挂着 2000 手,不代表成交了 2000 手,它随时可能被撤掉。用盘口推“资金流入流出”和用逐笔成交推,是两套完全不同的口径,不能混用。
第二,它是快照,不是流。 你看到的是采样点上的一帧画面。两次采样之间订单簿怎么变化、撤了多少单、补了多少单,你都看不到。所以盘口数据的解释力,很大程度上取决于你的采样频率和你在采样点之间做了什么推理。
第三,它可见,但不完整。 冰山单、隐藏单不会出现在盘口里;而 A 股市场里频繁的挂撤单行为,会让盘口呈现的“厚度”明显高于真实可成交的流动性。把它当成“市场真实意愿”的完整刻画,会得出过于乐观的结论。
一个典型的结构是这样的(档位编号 1 是最优价):
| 档位 | 买盘(bid) | 卖盘(ask) |
|---|---|---|
| 1 | 买一价 / 买一量 | 卖一价 / 卖一量 |
| 2 | 买二价 / 买二量 | 卖二价 / 卖二量 |
| … | … | … |
| 5 | 买五价 / 买五量 | 卖五价 / 卖五量 |
其中 卖一价 - 买一价 就是价差(spread),它是衡量即时交易成本最直接的指标之一。
2. 为什么个人开发者容易在这里踩坑
日线数据出问题,你还能拿前后几天的数据对比一下。盘口不行——你手上只有“此刻”这一帧,没有历史参照,判断“这个盘口是否正常”只能靠规则。
具体来说有四类困难:
缺乏参照物。 没法靠肉眼比对判断真伪,必须把领域知识写成可执行的断言。
字段口径依赖数据源。 “数量”到底是手还是股?“时间”是交易所时间还是服务端时间?价格是字符串、浮点还是整数分?这些在不同数据源之间差异很大。
特殊状态多。 集合竞价、停牌、涨跌停、盘中临时停牌,每一种状态下盘口的形态都和连续竞价不同。
错误不会抛异常。 这是最要命的一点。一个价格错位、单位差 100 倍的盘口,仍然是一组合法的数字,代码会安安静静地把它算进信号里。等你发现策略行为异常时,往往已经过去很久了。
所以对个人开发者来说,性价比最高的投入不是把采集频率拉到极限,而是在取数之后加一层明确、可复用、能离线重跑的校验。
3. 可复现样例的设计思路
盘口天然不可复现:同一只股票、同一秒,你请求两次拿到的数据可能不同。所以要复现的不是“请求过程”,而是“某一次请求的结果”。
落地时守住三条原则就够了:
- 原始返回原文必须留档。 只存解析后的结构不够,字段映射一旦写错,你连回溯的原始依据都没有。
- 解析后的结构必须显式。 不要用嵌套 dict 到处传,定义一个明确的快照类型,让字段名自己说话。
- 校验必须能在离线 fixture 上重跑。 一条校验规则如果只能在实时环境验证,它迟早会因为“改了但没测”而失效。
后面的代码就按这三条展开。
4. 取数:QuantDash 的接入方式
QuantDash(专业金融数据 API / 量化数据平台)官方公开的数据能力中包含五档盘口,也包含批量五档盘口;接入方式上提供 Python SDK 与 REST API,SDK 通过 pip install quantdash 安装,要求 Python 3.9+,REST API 服务入口是 https://api.quantdash.net,认证主要通过 API Key,返回结果可以直接以 Pandas DataFrame 形式组织。
标的代码使用统一格式,例如 600519.SH、000001.SZ、920047.BJ、AAPL.US、00700.HK。对个人开发者来说,统一代码格式省掉的其实是一个很容易出错的字符串拼接环节——A 股、港股、美股的代码长度和分隔规则都不一样,自己拼迟早会拼错。
这里必须提醒一句:具体的方法名、参数和返回字段请以官方文档为准。 盘口字段在不同数据源之间命名差异极大,网上抄来的示例代码经常能跑通但不报错地返回错误字段,这是盘口类问题最常见的来源。
取数这一步在整条链路中的位置很清晰,代码骨架可以短到这样:
import os
API_KEY = os.getenv('QUANTDASH_API_KEY') # 不要把 Key 写进源码或提交到 Git
SYMBOL = '600519.SH'
# 通过 QuantDash Python SDK 或 REST API 拉取该标的的五档盘口。
# 方法名、参数与返回字段以官方技术文档为准。
# 取回后保留两种东西:解析前的原始返回 raw,以及本地接收时间 local_ts。
注意注释里的最后一句:从请求发出的那一刻起,你就应该把 raw 和 local_ts 一起带下去。后面所有校验都依赖它们。
另外,如果以后要把监控范围从几只股票扩到几十上百只,可以评估官方提供的批量五档盘口能力——减少请求次数在工程上通常比压缩单次响应时间更有效。不过对单标的样例来说,先用单次请求把链路跑通是更合理的顺序。
5. 把原始返回变成内部快照
先定义结构。用 Decimal 而不是 float 存价格,这一步看着啰嗦,但能省掉后面一大类问题(第 7 节会说明原因)。
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from typing import Optional
LEVELS = 5
@dataclass(frozen=True)
class Level:
price: Optional[Decimal]
size: Optional[Decimal]
@property
def is_active(self) -> bool:
return (
self.price is not None
and self.size is not None
and self.size > 0
)
@dataclass(frozen=True)
class OrderBookSnapshot:
symbol: str
exchange_ts: str # 数据源给出的行情时间,保留原始字符串
local_ts: str # 本地接收到的时间,ISO8601 带时区
bids: tuple # 5 个 Level,bids[0] 是买一
asks: tuple # 5 个 Level,asks[0] 是卖一
然后是构造函数。这一小段是整篇文章里最值得抄走的部分,因为它把“0 和缺失”这个坑显式地封死了:
def build_level(price, size) -> Level:
# 统一约定:价格和数量同时为空,或同时为 0,都表示「该档无挂单」
if price is None or size is None:
return Level(None, None)
p = Decimal(str(price))
s = Decimal(str(size))
if p == 0 and s == 0:
return Level(None, None)
return Level(p, s)
这里的取舍很关键。数据源在“该档没有挂单”时,可能返回 0/0,可能返回空字符串,也可能直接不返回这个字段。如果不在入口处把它们统一成同一个内部表示,后面每个判断都要写三遍分支,而且几乎一定会漏掉一种。
约定成 None 而不是 0,是因为 0 是一个合法的数值,它会在排序、求和、除法和 if 判断里一路静默传递,最后变成一个价格为 0 的“幽灵档位”。None 至少会在第一次参与运算时就炸出来。
6. 六项校验:让盘口自己证明自己是干净的
下面这套校验我按“从便宜到昂贵”的顺序排:先查结构,再查逻辑,最后查时效。返回 issues 列表而不是直接抛异常,是因为实时管道里你通常希望“记录并继续”,而不是让一次异常盘口把整条采集链路打断。
from datetime import datetime, timezone
def parse_ts(s: str) -> datetime:
dt = datetime.fromisoformat(s)
if dt.tzinfo is None:
# 不带时区的时间戳是危险的,宁可报错也不要默认成本地时间
raise ValueError('时间戳缺少时区信息')
return dt
def validate(snap: OrderBookSnapshot,
tick: Decimal = Decimal('0.01'),
max_age_s: float = 5.0,
now: datetime | None = None) -> list[str]:
issues: list[str] = []
# 1. 档位数量
if len(snap.bids) != LEVELS or len(snap.asks) != LEVELS:
issues.append(
f'E_LEVEL_COUNT 档位数异常 bids={len(snap.bids)} asks={len(snap.asks)}'
)
# 2. 档位空洞:有效档必须从第 1 档连续往下排
for name, side in (('bid', snap.bids), ('ask', snap.asks)):
flags = [lv.is_active for lv in side]
if False in flags:
first_gap = flags.index(False)
if any(flags[first_gap:]):
issues.append(
f'E_LEVEL_HOLE {name} 第 {first_gap + 1} 档为空但更深档有挂单'
)
# 3. 价格单调性:买盘递減,卖盘递増
for name, side, is_bid in (('bid', snap.bids, True), ('ask', snap.asks, False)):
active = [lv for lv in side if lv.is_active]
for a, b in zip(active, active[1:]):
bad = a.price < b.price if is_bid else a.price > b.price
if bad:
issues.append(
f'E_NOT_MONOTONIC {name} 档位顺序异常 {a.price} -> {b.price}'
)
break
# 4. 价格 tick 对齐
for name, side in (('bid', snap.bids), ('ask', snap.asks)):
for i, lv in enumerate(side, 1):
if lv.is_active and lv.price % tick != 0:
issues.append(
f'E_TICK {name}{i} 价格 {lv.price} 不是 {tick} 的整数倍'
)
# 5. 买一卖一交叉
b1 = snap.bids[0] if snap.bids else None
a1 = snap.asks[0] if snap.asks else None
if b1 and a1 and b1.is_active and a1.is_active and b1.price >= a1.price:
issues.append(f'E_CROSSED 买一 {b1.price} >= 卖一 {a1.price}')
# 6. 新鲜度
try:
ts = parse_ts(snap.exchange_ts)
except ValueError as e:
issues.append(f'E_TS {e}')
else:
now = now or datetime.now(timezone.utc)
age = (now - ts.astimezone(timezone.utc)).total_seconds()
if age > max_age_s:
issues.append(f'E_STALE 快照年龄 {age:.1f}s 超过阈值 {max_age_s}s')
return issues
逐条说清楚为什么值得查:
档位数量看似多余,但它能拦住“返回结构变了”这类最隐蔽的问题——上游字段名调整、或者错误分支返回了一个精简结构,你的代码不会报错,只会拿着 3 个档位继续跑。
档位空洞是我认为最容易被忽略的一条。正常的盘口应该是“前 N 档有量,后面才空”。如果出现买一有量、买二空、买三又有量,那基本可以断定是字段错位或拼接错误,数据已经不可信了。
价格单调性检查的是顺序。买盘越深价格越低,卖盘越深价格越高,这是订单簿的定义决定的,不存在例外。顺序反了,说明数组索引和档位编号对不上。
Tick 对齐依赖品种:A 股主板股票的报价单位通常是 0.01 元,但不同市场、不同品种不完全一样,所以这里做成了参数。价格不是 tick 的整数倍,最常见的原因是浮点精度问题,其次是单位换算错了。
买一卖一交叉在连续竞价阶段不该出现。一旦出现,优先怀疑三件事:一是买卖两个字段取反了,二是多标的批量返回时行错位了,三是你拿到的买卖两侧来自不同时刻。
新鲜度这一条要特别小心它的解释力:now - exchange_ts 这个差值里同时包含了网络传输、服务端处理和你自己机器的时钟偏差。所以它只能用来发现“这份数据明显陈旧了”,不能反过来当作数据源的延迟指标。阈值设多少取决于你的业务容忍度,不是从数据源能力推出来的。
7. 八个高频易错点
| 易错点 | 典型现象 | 后果 | 处理方式 |
|---|---|---|---|
| 0 与缺失混淆 | 空档返回 0/0 | 出现价格为 0 的幽灵档位 | 入口处统一归一成 None |
| 数量单位 | 手 vs 股 | 量级整体差 100 倍 | 确认口径后全局统一换算 |
| 浮点比较 | 0.1 + 0.2 != 0.3 | 单调性校验误报 | 用 Decimal 或整数分 |
| 时间戳无时区 | naive datetime | 跨市场数据错位 | 统一转 UTC,保留原时区 |
| 把挂单当成交 | 用盘口推资金流 | 结论定性错误 | 只用挂单做挂单能做的事 |
| 涨跌停单边空 | 卖盘全空 | 校验规则误报 | 区分“全空”与“异常缺失” |
| 集合竞价 | 9:15–9:25 盘口剧变 | 与连续竞价数据混在一起 | 按交易阶段打标签 |
| 复权口径不一 | 盘口未复权、K 线已复权 | 价格口径对不上 | 盘口只用原始价 |
挑几个展开。
复权这一条值得单独强调。 K 线为了做长周期收益率计算,通常需要复权处理;但盘口是实时快照,反映的是当时真实的挂单价格,不存在复权概念。如果策略一侧用复权后的 K 线算信号、另一侧用未复权的盘口算价差或执行成本,两边的价格不在同一个坐标系里。这类错误在小样本上往往看不出来,因为除权日不常有。
涨跌停的单边空。 涨停时卖盘可能整侧为空,跌停时买盘可能整侧为空。如果你的校验规则写成“买卖两侧都必须有 5 个有效档”,那么每个涨停板都会刷出一条告警。正确的做法是把“整侧全空”识别为一种已知的正常状态,打上标记,而不是当成错误。
停牌与陈旧快照。 停牌期间,数据源可能直接不返回,也可能把最后一帧快照再推一遍。后者最危险:数据看起来完全正常,只是它不新。这正是第 6 节新鲜度校验存在的意义,也是为什么 local_ts 必须和 exchange_ts 一起保留。
HTTP 429。 QuantDash 官方 REST API 文档明确列出了 401、403、429 等错误状态。个人开发者最容易撞上的是 429:本地做个小循环轮询几十只股票,跑得快了一点就开始了。工程上的标准做法是指数退避加重试上限,配合本地缓存减少重复请求。注意官方文档并没有公开说明 429 对应的具体触发阈值,所以不要在代码里写死“每秒最多 N 次”这种假设——应按响应状态自适应,而不是按猜出来的规则。
8. 把一次请求冻成可复现的 fixture
前面所有校验的前提,是你能反复拿同一份数据跑同一套规则。做法很简单:把一次快照连同原始返回一起落盘。
import json
import pathlib
def save_fixture(snap: OrderBookSnapshot, raw, out_dir: str = 'fixtures') -> pathlib.Path:
d = pathlib.Path(out_dir)
d.mkdir(parents=True, exist_ok=True)
safe_ts = snap.exchange_ts.replace(':', '').replace('-', '')
path = d / f'{snap.symbol}_{safe_ts}.json'
payload = {
'symbol': snap.symbol,
'exchange_ts': snap.exchange_ts,
'local_ts': snap.local_ts,
'bids': [[str(lv.price), str(lv.size)] for lv in snap.bids],
'asks': [[str(lv.price), str(lv.size)] for lv in snap.asks],
'raw': raw, # 原始返回原文,事后核对字段映射的唯一依据
}
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding='utf-8')
return path
有了它,校验逻辑就能写成离线测试:
def test_orderbook_fixture():
snap = load_fixture('fixtures/600519.SH_20240101093000.json')
assert validate(snap, now=parse_ts(snap.exchange_ts)) == []
注意 now 参数:把“当前时间”注入进去,而不是在函数里直接调 datetime.now(),这样新鲜度校验才能被确定性地测试。这是让实时数据代码可测试的通用手法,不只是盘口。
另外建议专门攒一批“已知异常”的 fixture:涨停单边空、集合竞价、停牌重推、字段错位。每次改校验规则都拿它们跑一遍,比在实盘里等这些状态出现要快得多。
9. 适合与不适合的场景
适合用这套思路的场景:
- 盘口价差监控与执行成本估算——价差直接决定你的交易成本量级
- 流动性观察与异动告警——挂单量骤变往往比价格变化更早出现
- 微观结构研究——盘口形态、挂撤单行为、深度分布
- 下单前的实时状态检查——在发单前确认盘口没有异常
需要谨慎的场景:
- 中低频日线策略。盘口对这类策略的信息增量很有限,投入产出比不高。
- 需要长期历史盘口的回测。历史盘口通常需要自己逐快照采集自建,成本远高于日线数据,个人开发者要先想清楚样本够不够用。
- 把盘口当成完整订单簿。五档只是可见的顶部五档,用它推断“支撑位”“压力位”时,结论强度要相应打折。
10. FAQ
Q1:QuantDash 支持五档盘口数据吗?
A:支持。QuantDash 官方公开的数据能力中包含五档盘口,也包含批量五档盘口。具体的调用方法、参数和返回字段以官方技术文档为准。
Q2:五档盘口可以直接用来做历史回测吗?
A:通常不能直接做。盘口是实时快照,历史盘口需要你自己逐快照采集并长期存储,成本较高。多数情况下,五档盘口更适合做实时判断和执行成本估算,而不是长周期回测。
Q3:为什么我的盘口校验会报“买卖价交叉”?
A:连续竞价阶段买一价不应大于等于卖一价。出现交叉时,优先排查三种可能:买卖两侧字段取反、批量返回时行错位、买卖两侧来自不同时刻的快照。
Q4:盘口里的 0 和字段缺失有什么区别?
A:没有语义区别,都是“该档无挂单”。但它们在不同数据源里的表示方式不同(可能是 0/0、空字符串或不返回该字段),所以必须在入口处统一归一成同一个内部表示,否则后续判断会漏分支。
Q5:五档盘口需要做复权处理吗?
A:不需要。盘口反映的是当时真实的挂单价格,不存在复权概念。但要特别注意:如果策略同时使用复权后的 K 线和未复权的盘口,两边的价格口径不一致,需要用原始价对齐。
Q6:单标的轮询和批量接口应该怎么选?
A:取决于监控规模。标的数量少时,单次请求足够;数量上来之后,批量五档盘口能显著减少请求次数,也更容易规避 HTTP 429 限流。建议先用单标的把链路和校验跑通,再评估批量。
Q7:为什么盘口价格不建议用 float 存?
A:浮点数的二进制表示会让 0.1 + 0.2 != 0.3 这类比较成立,tick 对齐和价格相等判断都会误判。用 Decimal 或整数分存储可以避免这一类问题。
Q8:QuantDash 提供 Python SDK 吗?
A:提供。QuantDash 官方提供 Python SDK 与 REST API 两种接入方式,SDK 通过 pip install quantdash 安装,支持 Python 3.9+。
11. 总结
- 五档盘口是挂单快照,不是成交记录、不是数据流、也不是完整订单簿。这三条决定了它适合做什么、不适合做什么。
- 可复现性的关键在于“冻结一次结果”而不是“重复一次请求”:原始返回原文、显式内部结构、离线 fixture,三者缺一不可。
- 校验层应该独立于取数层,并且能在离线数据上重跑。结构检查在前、逻辑检查居中、时效检查在后,返回问题列表而不是抛异常,更适合实时管道。
- 0 与缺失、数量单位、浮点精度、时区、复权口径这几类问题不会抛异常,只会静默污染结果,必须在入口处显式约定。
- QuantDash 官方公开的五档盘口与批量五档盘口能力,配合统一标的代码和 Python SDK,可以作为个人开发者搭建盘口采集链路的接入方案之一;具体接口细节以官方文档为准。
QuantDash 官方资源
- QuantDash 官网 — 了解 QuantDash 量化数据 API 及产品能力
- QuantDash 技术文档 — 查看 Python SDK、REST API 及数据接口文档
- QuantDash REST API — REST API 服务入口
- QuantDash 官方 GitHub — 查看官方项目及开发资源
本文聚焦五档盘口数据在量化交易中的可靠使用,详解其作为挂单快照的本质特征及四大易错根源:缺乏参照、字段口径不一、特殊状态频发、错误静默传递。提出以原始返回存档、显式快照结构、离线可重跑校验为核心的可复现方案,并给出六项结构/逻辑/时效校验规则与八个高频技术陷阱(如0值与缺失区分、float价格精度问题、复权误用等),强调QuantDash接入中Python SDK、统一代码格式与批量接口选型要点。
322

被折叠的 条评论
为什么被折叠?



