简介:深交所Level2行情数据接口规范V1.11是官方发布的STEP行情数据接口标准,面向高频交易、量化策略及涨停板交易等专业场景,提供Level2五档行情、逐笔委托、快照消息等完整数据规则。资源共1个PDF文件,约682KB,内容涵盖会话机制、消息字段定义、行情类别与开关取值,并梳理了2013年至2021年的历次修订,包括盘后定价交易、港股通、期权备兑、债券现券等扩展功能。接口兼容性要求已明确说明,用户系统可自动忽略新增条目,便于现有系统平滑升级。文档目录结构清晰,便于按章节直接查阅具体字段定义与示例。已有1990人学习下载,适合券商、信息商、量化开发者及交易所接入用户作为接口开发与运维的权威参考。
1. 深交所Level2行情V1.11:快照秒级刷新,盘口只能靠逐笔拼
做量化的人第一次看深交所Level2行情接口规范V1.11,多半被两个数字劝退:快照每秒只有一两张,逐笔委托和逐笔成交却是实时流。光靠快照,你根本还原不出真实盘口,中间那些撤单和成交撮合,全部要用逐笔消息去补。这份规范解决的就是这件事——把登录鉴权、消息帧、快照、逐笔委托(ORD)、逐笔成交(TRD)的字段和时序约束定义清楚,让行情开发商、私募和券商自研系统有同一份“翻译稿”。
它的目标读者有两类:负责接入深交所行情网关的工程师,和做回测落地的量化研究员。前者关心协议骨架和鉴权流程,后者关心字段含义和Tick复权。我的建议是先跑通二进制链路,再研究STEP链路;先解析消息头,再去抠字段。下面按这个顺序带你走一遍,最后把最容易翻车的几个坑单独拉出来讲。
2. 先读消息头再谈业务:V1.11的协议骨架与连接生命周期
把V1.11当成一个黑匣子去调,是最容易浪费一周时间的做法。协议栈的顺序是固定的:TCP连接、登录鉴权、按帧收消息、按消息头分发、按消息体解析。任何一步错位,后面都是乱码。所以这一章先把骨架立住,你后面填业务字段时才不会迷路。
2.1 两套应用层协议:二进制Binary与STEP
V1.11规范里同时存在两套应用层协议:Binary,定长加变长的紧凑二进制;STEP,基于文本标签的类FIX协议。生产环境几乎都用Binary,带宽省、解析快;STEP更多出现在跨部门联调和错误排查现场,因为它每个字段都有英文标签,抓包工具里一眼能看到“Price=10.50”这种内容。
常见做法是行情网关同时支持这两种编码。你选哪种,取决于接入配置:登录报文里通常有一个字段指定语言或编码类型,或者你在网关的客户端配置里声明。我的习惯是先用STEP把业务跑通,确认字段语义全对,再切换Binary压性能和时延。反过来容易心态崩,二进制解析错一位,后面全部错位,你很难判断是网关问题还是自己问题。
| 对比项 | Binary | STEP |
|---|---|---|
| 编码风格 | 紧凑二进制 | 文本标签,类FIX |
| 调试成本 | 需要自写解析器 | 可直接看报文文本 |
| 带宽占用 | 低 | 高 |
| 典型场景 | 生产接收、压测 | 联调排错、跨部门协作 |
2.2 消息头24字节:“解析错一位后面全错”的根因
不管是快照还是逐笔,每条业务消息前面都顶着一个定长消息头。公开资料里深交所Binary协议的消息头为24字节定长,大端序。我用C结构体把它固定下来:
#pragma pack(push, 1) typedef struct { uint16_t msg_type; /* 业务消息类型:快照、ORD、TRD、登录等 */ uint32_t body_len; /* 消息体长度,不含消息头 */ uint16_t version; /* 协议版本,V1.11 阶段固定版本号 */ uint32_t seq_no; /* 消息序号,断线重连时用来对齐 */ uint64_t timestamp; /* 发送时间,精度以规范定义为准 */ uint32_t source_id; /* 源标识,多路网关时用来去重 */ uint16_t reserved; /* 保留字段 */ uint16_t checksum; /* 部分链路启用,未启用时全 0 */ } szse_l2_header; #pragma pack(pop)这个结构体不建议手敲进生产代码,而是用代码生成器从规范的机器可读描述里生成。手敲容易漏字节对齐,尤其在Windows和Linux下默认对齐不一样,一旦漏了或多了关键字,解析出来的body_len就是错的,整条消息全部偏移。
消息头里的msg_type是分发键,body_len告诉你这条消息的边界在哪;seq_no做序列对齐;timestamp是带了,但注意它是网关发送时间,不是业务发生时间。逐笔成交消息体里还有一个自己的时间戳,那才是撮合时点。这两个时间戳混用,是后面避坑章要讲的常见错误。
2.3 登录、心跳与重连:连接生命周期的三个动作
V1.11的连接生命周期分三步:TCP连上后先发登录报文,网关回登录响应;然后进入正常行情推送;断线后重连,再按最新序号补拉行情。
登录报文里有两个关键点。一是用户名、密码的加密,常见做法是MD5加盐后再做一层编码,盐值拼接顺序在规范附录里;二是登录响应里会带一个会话标识,后续所有业务报文建议都带上它,方便网关做会话关联。密码这里最容易踩的坑是字符编码,中文用户名用GBK还是UTF-8转出来的字节不同,MD5结果完全不同,测试环境里八成鉴权失败都出在这个字符集上。
心跳一般由客户端主动发,间隔3秒或5秒,网关连续几次没收到就断开连接。这是TCP层和应用层要同时做的:TCP层靠系统keepalive兜底,应用层靠你定时发心跳报文。重连之后,先别急着收行情,拿本地最大的seq_no和网关对一次,差多少补多少。补拉回来的消息先落盘再回放,避免一边收实时流一边补历史流,把处理顺序搞乱。这个习惯能省掉后面无数笔对账的麻烦。
3. 用Python把快照和逐笔成交拆成Tick:字段映射与解析要点
协议骨架立住之后,就该碰业务字段了。生产环境用C++或Java,但原型验证阶段我强烈建议用Python——struct.unpack一行就是一个字段,调试迭代速度快一个量级。这一章给你能直接跑的最小解析代码,字段偏移以你手上的V1.11文档为准,不同版本会有微调。
3.1 快照消息(MKT):先看十档,再看统计量
深交所Level2快照消息里最值钱的字段不是十档盘口,而是成交笔数和总买总卖。十档盘口每个行情源都能给,但成交笔数是逐笔成交累加的结果,你本地累出来的数和快照里的数对得上,就说明你的逐笔流没漏。快照常见布局是:证券代码、时间戳、最新价、十档买卖价、十档买卖量、总买量、总卖量、成交笔数,后面再跟统计扩展字段。
import struct def parse_mkt(body: bytes) -> dict: # 快照是定长块:证券6 + 时间8 + 最新价4 + 十档买/卖 + 统计量 base = ">6s I I 10i 10I 10i 10I I I I" base_size = struct.calcsize(base) if len(body) < base_size: raise ValueError("MKT body too short: %d" % len(body)) vals = struct.unpack_from(base, body) return { "symbol": vals[0].decode("ascii"), "time": vals[1], "last_px": vals[2], "bid_px": vals[3:13], "bid_vol": vals[13:23], "ask_px": vals[23:33], "ask_vol": vals[33:43], "total_bid_vol": vals[43], "total_ask_vol": vals[44], "trade_count": vals[45], }这段代码的核心是定长字段全部走一个fmt字符串,变长扩展字段单独处理。十档价格我直接用int数组读进来,到位数转换放到解析后面统一做,不在这一层处理。原因很简单:有的源用定点数表示价格,有的直接用浮点,混在解析层会越改越乱,价格精度问题应该由统一的转换函数负责。
trade_count是对账锚点。你本地解析完逐笔成交后维护一个计数器,定时和快照里的trade_count比对,不一致就说明漏包或重复处理。把这条检查写成一个独立函数,每个快照消息都跑一遍,开销很低,但能救你于联调末期——等你发现的时候,通常已经漏了几千笔。
3.2 逐笔委托(ORD)与逐笔成交(TRD):最小可用的拆包代码
ORD消息体是委托维度,TRD是成交维度。两者都带证券代码、方向、价格、数量,差异在标识:ORD有委托序号,TRD有成交序号,以及匹配的买卖订单号。深交所逐笔消息一个帧里可以包含多只股票的记录,所以解析要按条目循环:
TRD_ITEM = ">6s I I I I I I I I B B I" def parse_trd(body: bytes) -> list: item_size = struct.calcsize(TRD_ITEM) items = [] for off in range(0, len(body) - item_size + 1, item_size): v = struct.unpack_from(TRD_ITEM, body, off) items.append({ "symbol": v[0].decode("ascii"), "seq": v[1], "trade_px": v[2], "trade_qty": v[3], "bid_order": v[4], "ask_order": v[5], "exec_type": v[6], "side": v[7], # 主动方向:买或卖 "ts": v[8], }) return items注意每个条目开头都带证券代码,这是深交所逐笔消息的特点,你不必按股票分组才能解析,直接一条条append到全局队列,后续用symbol字段分桶即可。方向字段的枚举值,V1.11用数字表示买卖和撤单,我建议直接在解析层保留数字,到因子计算时再用映射表翻译,这样格式调整时只改映射表,不用回头动解析层。
3.3 用msgType做分发:一个能撑住V1.11全部业务消息的骨架
一个健壮的消息分发循环,本质上只做三件事:读头、按msg_type查表、把body交给对应的解析函数。我用字典做路由,msg_type的具体数值以你手上的规范附录为准:
def dispatch(data: bytes) -> dict: header, body = split_header(data) handler = HANDLERS.get(header["msg_type"]) if handler is None: # 未知消息类型:记录日志,不要中断主循环 return {"type": "unknown", "seq": header["seq_no"]} return handler(body) HANDLERS = { "LOGIN_RESP": parse_login_resp, "MKT": parse_mkt, "ORD": parse_ord, "TRD": parse_trd, }这里有个容易被忽略的点:分发字典里必须包含对未知类型的处理。测试环境偶尔会出现规范之外的消息类型,最常见的是网关升级期间临时推送的测试消息。你要是直接抛异常,整个接收线程就挂了。正确做法是记一条warning日志,记下seq_no和body_len,然后继续读下一条。
分发骨架写完后,再逐条加字段校验:价格不能为负、数量不能为零、时间戳不能倒退。校验放在解析之后而不是之前,因为解析失败时你已经知道是哪条消息,直接跳过比中断强。我还会给这个模块单独定一条代码规范:所有解析函数只做字节到字段的转换,不做任何业务判断,业务判断全部放上层。这条规矩比检查代码规范里的缩进规则更能救你——解析层保持无状态,出问题时才能快速隔离是网络层、解析层还是策略层的问题。
4. Tick复权算法:成交价量怎么才能不受除权日影响
解析完Tick,你以为数据能直接用了,但除权除息日会给你上一课:某只股票10转10的第二天,前一天的收盘价和后一天的开盘价差出一倍,直接在原始价格上算收益率,回测信号全是假的。日线复权是复权OHLC四个价,Tick复权要复权每一笔成交的价和量,算法逻辑不一样,工序也更细。
4.1 复权为什么是Tick数据独有的坎
除权除息日,交易所会对股价做除权处理:送股、转增、派息都会让股价跳空。日线数据一天只有一根K线,复权只需调整几个关键价;Tick数据一天有几十万笔成交,前后两笔价格可能从10块跳到5块,中间没有任何过渡。如果你不做复权,订单簿阈值、收益率序列、流动性指标全部会被除权日污染,而且污染是永久性的——你没法在策略层通过简单过滤把它去掉。
常见做法是因子法:把除权除息公告转换成每日价格因子,再按时间累计,对历史Tick逐笔调整。这里有个关键认知:成交量必须跟着价格一起调整。价格打折到原来的一半,同样成交笔数对应的成交额不变,所以成交量要除以价格因子。只调价不调量,回测里的流动性判断会失真,这是Tick复权最容易漏的一步。
4.2 用累计因子做前复权、后复权:一段可跑的Python
因子法的核心是构建“价格调整因子序列”,然后把因子乘到对应日期之前的每一笔成交上。下面这个简化版本用的是Pandas:
import pandas as pd def build_adj_factor(dividend_events: pd.DataFrame) -> pd.Series: # dividend_events 列:date、factor # factor 表示除权后的价格折扣,0.5 表示价格打五折 s = dividend_events.set_index("date")["factor"] # 后复权:因子按时间累计,早于除权日的价格乘上累积值 s = s.sort_index().cumprod() return s def adjust_ticks(tick_df: pd.DataFrame, adj: pd.Series) -> pd.DataFrame: df = tick_df.copy() df = df.merge(adj.reset_index(), left_on="date", right_on="date", how="left") df["factor"] = df["factor"].fillna(1.0).cumprod() df["px_adj"] = df["px"] * df["factor"] df["qty_adj"] = df["qty"] / df["factor"] return df这段代码的逻辑是:先按日期把因子排序,除权日越早,因子累积越大;然后把每个交易日对应的因子merge到Tick数据上,向前填充再累乘,得到每一笔成交当天的复权因子。价格乘以因子得到复权价,数量除以因子保持成交额守恒。参数上,factor的来源建议直接用行情源发布的除权除息公告,不要自己从价格里反推,反推出来的因子和交易所口径对不上,回测和实盘会系统性偏离。
4.3 参数与边界:复权基准日、精度、新股和ST
复权算法调通容易,调准难。参数边界上我踩过几个值得说明的位置。复权基准日:前复权和后复权的基准不同,前复权以最新交易日为基准,后复权以最早上市日为基准,同一个Tick数据用两种口径跑出来的收益率序列一致,但绝对价格不同,做订单簿回测时一定要明确你用的是哪一种,混用了你会看到同一策略在不同时段表现完全不一样。
价格精度:复权后价格是浮点乘法,累计下来会出现1e-6级别的误差。做Tick回测建议把价格整数化,用最小变动价位做单位,或者统一用Decimal,不要在float上直接比较两个价格。
新股上市首日没有除权除息记录,因子为1,不参与累计;ST股票的除权频率更高,因子变化更密,要单独建一张表维护,避免漏掉因子导致后续所有日期全部错位。检查方法也简单:把复权后的序列里单日价格跳变超过阈值(比如30%)的记录全部列出来,人工核对当天是否有除权事件。没有事件的跳变,就是数据质量问题。
5. 接入联调的避坑清单:从鉴权失败到序号断层
接入阶段最容易出问题的地方不在协议正文,而在测试联调规范没定清楚。下面五条是我在多个项目里反复踩过的坑,现象、原因、解决方式一次讲完,你照着排查能省掉大半联调时间。
5.1 登录鉴权失败:密文长度永远对不上
现象:登录报文发出去,网关回“鉴权失败”,日志里看不到任何具体原因,你反复核对用户名密码却找不出问题。
原因:密码加密后字节长度与规范不符,最常见两类——盐值拼接顺序写反了,规范是先拼盐再拼密码,你写成了密码在前;或用户名里的中文字符用了平台默认编码,GBK和UTF-8转出来的字节不同,MD5结果完全不同。
解决:把加密前和加密后的字节各做一份十六进制dump,与规范附录里的样例输入逐字节比对,先拿纯英文用户名跑通链路,再切换中文用户名。这一步能立刻定位是字符集问题还是盐序问题。
5.2 快照的成交笔数与本地累计不一致
现象:解析一切正常,但快照里的trade_count比本地逐笔累计的成交笔数多几十笔,每天偏差还不固定。
原因:漏了集合竞价前后的特殊成交记录,或者断线重连后补拉回来的消息没有走同一个计数入口,直接插进了实时队列。
解决:把补拉消息和实时消息统一路由到同一个处理函数,补拉数据先落盘再回放,不允许绕过计数器直接入队列。每天收盘后跑一次对账脚本,差值超过阈值就告警,把问题暴露在交易结束后而不是第二天开盘前。
5.3 序号断层:重连之后SeqNo倒退了
现象:断线重连后收到的第一条消息seq_no比本地记录的最大值小几百,你以为网关把历史重发了一遍,其实没有。
原因:不同网关源之间的序号空间是独立的,source_id不同,seq_no不能混着比。你本地把多个源的序号存进了同一个变量,重连后拿A源的seq_no去对比B源,当然看起来倒退。
解决:按source_id分桶维护seq_no,重连只对比同一source_id的最新值。跨源的序号只做消息去重,不做连续性判断,连续性判断必须限定在单源内。
5.4 集合竞价那段快照:开盘价会跳变
现象:9:15到9:25之间快照里的最新价来回跳,拿这个区间算波动率、算开盘动量,策略结果乱得没法看。
原因:集合竞价阶段撮合还没完成,快照里的最新价是参考价而不是成交价,字段含义和连续竞价阶段不一样。规范里这个阶段的消息会带有阶段标志位,你没读它。
解决:在解析层就把阶段标志位提取出来,集合竞价阶段的快照单独打标,不进入策略计算管道。实在要用,只取集合竞价的最终结果,不要取中间过程的价量数据。
5.5 STEP和Binary混用:联调和生产两套口径
现象:STEP链路调通了,切到Binary后部分字段对不上,价格能对上、量对不上,或者反过来。
原因:STEP和Binary的字段顺序不一样,报文头的字段顺序也不一样,你拿STEP的字段顺序套Binary,解析结果必错。
解决:按规范分别维护两套解析头描述,写一个自动对比脚本:同一组输入分别走两种协议解析,对每个字段做diff。联调时用STEP定位业务问题,上线前用Binary做回归测试,两套口径都过了再切生产。
6. 用快照加逐笔组装订单簿:验证解析正确性的一个具体技巧
全部解析调通之后,怎么证明你是对的?我的做法是:别急着全量组装订单簿,先拿快照和逐笔成交做三个字段的互验:快照买一价、逐笔主动方向、成交价。这三个字段如果自洽,说明快照和逐笔数据在你手里是同一份真实盘的切片,而不是两套各说各话的乱码。
验证逻辑如下:快照里买一价是当前最优买价,如果接下来一笔逐笔成交的成交价低于或等于买一价,这笔大概率是主动卖出,会消耗买一档的量。于是你维护一个临时变量,初始为快照买一量,每来一笔主动卖出的成交,就把它扣掉;扣到0时,下一张快照的买一价和买一量必须发生变化,否则说明中间有成交没推给你,或者方向判断错了。
def check_snapshot_vs_tick(mkt: dict, tick_q: list) -> bool: bid_px, bid_vol = mkt["bid_px"][0], mkt["bid_vol"][0] while tick_q and tick_q[0]["px"] <= bid_px: t = tick_q.pop(0) bid_vol -= t["qty"] if bid_vol < 0: # 快照量被消耗完还没等到下一张快照,大概率漏了成交或方向错 return False return True这段代码的关键参数是价格比较方向:成交价小于等于买一价才算主动卖出,大于买一价的部分属于主动买入,不消耗买一量。实际操作时,建议用连续竞价阶段的数据跑,避开集合竞价;从9:30开始取前1000笔做循环校验,每遇到“buy一量被扣穿”就打印快照序号和成交序号,人工去看这两条消息之间发生了什么。这个技巧不是全流程自动化,但它能在你刚开始接深交所Level2行情时,用最少的排查成本建立起“快照和逐笔是一致的”这个信心。
我最早做这套系统时,只信快照里的买一量,结果某天下午盘中发现量对不上账,最后一查,是上午有一段逐笔成交因为序号断点被当成重复消息过滤掉了。从那以后,每个交易日开盘前跑一遍这个对账脚本就成了固定动作,算是我个人接入Level2最值得保留的一个习惯。希望帮到你。
本文还有配套的精品资源,点击获取