
简介这份《深交所Level2行情数据接口规范V1.11》面向高频交易与量化策略开发者、券商行情接入人员及关注涨停板博弈的进阶交易者系统给出深市Level2与STEP格式行情的数据获取与处理规则解决行情消息解析、字段取值与系统兼容性等实际问题。压缩包共1个PDF文件约682KB为深圳证券交易所2021年8月发布的技术标准正式文档包含修订历史、名词释义、会话机制及各业务消息定义方便按章节查阅。该资源已有1995人学习下载说明其在行情接入与量化研究群体中具有一定参考价值。文档完整记录从2013年0.10版到1.11版的历次修订脉络逐项说明快照行情频道统计、证券实时状态开关类别、逐笔委托与成交、盘后定价交易、债券现券交易及港股开市前时段等消息的增补与调整并明确新增行情条目与开关类别应被自动忽略的兼容性要求可作为接口开发、联调测试与版本升级对照的规范依据。1. 先看清这份规范能解决什么再谈接入Level2 行情接进策略之前最先卡住人的往往不是模型而是那份接口规范。某交易所发布的 STEP 行情数据接口规范 V1.11 就是这类文档它不讲策略只规定证券交易行情怎么编码、怎么分频道、丢了怎么补。对做量化的团队来说这份文档是把行情落成可用 tick 的唯一依据对刚接手接入的新人来说它更像一份需要逐条对照的验收清单。从 2013 年的 0.10 版一路改到 2021 年 8 月文档版本已经到 1.11通信版本却始终停在 1.02中间塞进了债券现券、盘后定价、港股开市前时段、逐笔两种发送模式等一堆增量。它能不能用能而且必须按它来但它不是开箱即用的 SDK很多关键约定藏在字段级的注释里读完文档和跑通链路之间还隔着一层工程活。2. 会话与端口9129 / 9130 两个连接撑起整条链路接入的第一步不是解包是把连接建对。规范里对会话的描述很克制只有几行字但每一行都对应一个上线后才会暴露的故障点。用户行情系统VSS和行情网关MDGW之间是标准 TCP/IP 点对点连接实时数据端口默认 9129重传服务端口默认 9130每个端口只允许建立一条连接。这句话的含义是你不可能靠多开几条 TCP 来提高吞吐网关侧对连接数是有硬约束的想扩容只能在单连接内做消费能力的优化。还有一个容易忽略的前提只有网络版行情网关提供重传服务端口。现场版走单向卫星没有数据重传功能。如果接入方案里设计了一套依赖重传的补数逻辑却部署在现场版网关上那套逻辑永远不会被触发丢包就是永久丢失。选型阶段先把网关版本确认清楚比后面写多少补偿代码都管用。2.1 Logon 里的 DefaultApplVerID填 1.02不是 1.11文档封面写着 V1.11很多人第一反应是把 1.11 填进登录消息结果登录直接被拒。规范 2.1.1 节说得很明确本版接口规范对应的通信版本号是 1.02在 Logon 消息的 DefaultApplVerID 字段填写该通信版本号。文档版本和通信版本是两条独立的版本线文档改到 1.11 是因为业务字段在增加通信层协议并没有跟着跳版本。# 建立实时数据会话时的登录消息构造字段编号参照规范会话层定义 logon { MsgType: A, # Logon DefaultApplVerID: 1.02, # 通信版本号注意不是文档版本 1.11 HeartBtInt: 30, # 会话层心跳间隔秒与频道心跳不是一回事 ResetSeqNumFlag: Y, # 首次登录建议重置序列号 } def build_logon(host: str, port: int 9129) - dict: 实时会话与重传会话分别建连端口不同但登录参数一致 return { endpoint: (host, port), logon: logon, session_type: realtime if port 9129 else resend, }这段代码里有两个参数值得展开。HeartBtInt 是会话层心跳和后面 3 秒一次的频道心跳完全是两个层级的东西会话层心跳用来保活 TCP 会话频道心跳用来判断某个频道是否还在推数据。ResetSeqNumFlag 在首次登录时置 Y重连时是否重置要看你的恢复策略——如果依赖应用层重传补数会话层序列号重置不会影响补数正确性因为补数走的是记录号而不是会话序列号。提示会话层自带的恢复机制只是为了和标准会话协议兼容规范明确说它不能当作真正的消息恢复机制使用。丢数据的补全必须走应用层的重传消息这一点在 design review 阶段就要定死。2.2 频道代码不是随便订阅的101x 与 201x 是两套东西规范第三章给了一张频道表这张表是整个接入工作的索引。快照行情和逐笔行情走的是完全不同的频道段业务类别靠频道代码的第 4 位区分。表中 x 表示 0 到 9 的数字所以 101x 实际展开是 1010 到 1019 十个频道对应股票快照201x 展开是 2010 到 2019对应股票逐笔。频道代码频道内容消息类型可否重传1市场实时状态、证券实时状态f / h否2公告B是按公告 ID10 / 11交易所指数、国证指数快照W否101x~105x股票/基金/可转债/权证/期权集中竞价快照W否201x~205x对应品种的逐笔委托与逐笔成交UA201 / UA202是106x / 206x债券通用质押式回购快照 / 匹配成交逐笔W / UA201、UA202快照否、逐笔是107x / 207x债券现券交易快照 / 匹配成交逐笔W / UA201、UA202同上300x / 301x盘后定价大宗交易快照 / 盘后定价交易快照W否3021债券分销快照W否400x / 401x综合金融服务逐笔 / 债券报价及大额逐笔UA201、UA202是5000用户信息报告UA003否5001港股实时行情快照W否这张表落到代码里就是一个频道过滤器。网关可能推送你并不关心的频道规范 4.1 节的兼容性要求第一条就说得很清楚新增行情频道时VSS 如果不使用这些数据应当能不作变更而自动忽略。所以过滤逻辑要按「白名单匹配 未知频道丢弃」来写而不是「遇到不认识的频道就抛异常」。# 频道白名单按业务需要订阅未命中的频道直接丢弃不报错 SNAPSHOT_PREFIX (10, 11, 101, 102, 103, 104, 105, 106, 107, 300, 301) TICK_PREFIX (201, 202, 203, 204, 205, 206, 207, 400, 401) def classify_channel(channel_no: int) - str: ch str(channel_no) if ch 1: return status # 市场/证券实时状态 if ch 2: return news # 公告 if ch 5000: return user_report # 仅信息商需要发送 if ch 5001 or ch.startswith(SNAPSHOT_PREFIX): return snapshot # 定时发布不可重传 if ch.startswith(TICK_PREFIX): return tick # 带记录号可重传 return unknown # 未知频道静默丢弃参数上唯一需要小心的是前缀匹配的歧义频道 10 和 101x 都以「10」开头如果用 startswith(10) 会把股票快照和指数快照混在一起。实际工程里我一般把频道号先转成整数再按区间判断或者像上面这样把精确值放在前缀判断之前用顺序保证优先级。这种小坑在单元测试里加两条用例就能锁死。2.3 快照靠定时、逐笔靠记录号两个接收循环不能写成一套快照行情和逐笔行情在接收语义上是两种东西硬套同一套循环逻辑必然出问题。快照行情定时发布规范明确写了不能重传每个快照频道可能包含多个类别的行情每类有自己的发布频率。这意味着快照侧的正确性判断标准是「有没有收到最新一版」而不是「有没有漏掉某一条」。消费端用覆盖写就行不需要维护序号。逐笔行情不一样。每条逐笔消息都带频道代码和消息记录号记录号在频道内从 1 开始顺序递增。判断逻辑只有三条收到的记录号小于等于已收到的最大记录号说明是重复消息忽略等于最大值加一正常大于最大值加一说明中间丢了触发重传。每个频道的逐笔消息发完之后网关会发一笔频道结束消息。频道空闲时有独立心跳心跳消息没有记录号。class TickChannelState: def __init__(self, channel_no: int): self.channel_no channel_no self.max_seq 0 # 本频道已收到的最大记录号 self.gap_buffer {} # 记录号 - 消息等待补数期间先缓存 def on_message(self, seq_num: int, payload: dict): if seq_num self.max_seq: return duplicate # 重复消息直接丢 if seq_num self.max_seq 1: self.max_seq seq_num self._drain_gap_buffer() return ok # 出现跳变先缓存再发起重传避免乱序到达时二次触发 self.gap_buffer[seq_num] payload return gap def _drain_gap_buffer(self): while self.max_seq 1 in self.gap_buffer: self.max_seq 1 self.gap_buffer.pop(self.max_seq)这段状态机的关键在于跳变时先缓存再请求补数。如果收到 12 就直接把 max_seq 推到 12那么 11 补回来之后会被判成重复消息丢掉数据就永久缺了一条。血泪经验是补数期间到达的新消息不能直接推进水位线必须等缺口填平。心跳超时的判断也有具体数字。规范写的是频道心跳间隔 3 秒如果超过两倍心跳间隔没有收到数据包就可能是网关故障应当断开重连。也就是 6 秒。这个阈值不要自己拍脑袋改成 30 秒改大了故障发现慢改小了网络抖动就误判断线。3. FAST 解码链路RawData 里装的不只是一条消息消息结构的定义在 4.2 节看起来只有五行表格但它是整个解码器的地基。所有应用层消息都由 STEP 消息层和 FAST 消息层构成Standard Header、频道代码 ChannelNo标记 10201、RawDataLength标记 95、RawData标记 96、Standard Trailer。RawData 是 FAST 编码的消息体而消息体里可以包含本频道的多条 FAST 编码消息。这句话是整个解码器设计的核心约束——你不能假设一个 TCP 包对应一条业务消息。3.1 四个必备域的读取顺序决定了解码器能不能复用正确顺序是先读 Standard Header 拿到消息类型再读 ChannelNo 决定这条消息交给哪个业务处理器然后按 RawDataLength 从字节流里切出定长的 RawData 块最后把这块交给 FAST 解码器。RawDataLength 是字节长度不是字符长度如果实现语言里字符串和字节数组混用中文公告内容一定会切错位置。import struct def parse_step_frame(buf: bytes, offset: int): 从字节流中解析一个 STEP 消息帧返回 (channel_no, raw_data, next_offset) # Standard Header 长度依会话层规范而定此处以占位长度示意 header_len read_header_length(buf, offset) pos offset header_len # 标记 10201 ChannelNo按会话层域定义的长度读取 channel_no struct.unpack_from(I, buf, pos)[0] pos 4 # 标记 95 RawDataLength raw_len struct.unpack_from(I, buf, pos)[0] pos 4 # 标记 96 RawData raw_data buf[pos:pos raw_len] pos raw_len # Standard Trailer 略过 return channel_no, raw_data, pos trailer_len这里的参数说明比代码本身重要。header_len 和 trailer_len 必须从会话层规范里取实际值不能照抄示例。raw_len 读出来之后要做一个上限校验防止脏数据导致超大内存分配常见做法是设一个比如 1 MB 的硬上限超过就断开重连并记录日志。这个校验不是防御性编程的洁癖是真实存在的翻车场景字节流错位之后读出来的长度字段可能是任意值。3.2 模板 ID 的两个区间以及每次解码前必须重置字典FAST 模板 ID 的编码区间是硬约定3000 到 3999 是公共消息4000 到 15999 是实时行情数据。公共消息里心跳是 3001重传是 3002用户信息报告是 3003对应的 STEP 层消息类型分别是 UA001、UA002、UA003。比模板 ID 更容易出错的是 FAST 字典前值的处理。规范在消息结构表下面专门加了一句注释解码 FAST 消息体前应该重置解码器的 FAST 字典前值。FAST 协议靠前值复制和增量编码压缩数据解码器内部维护了一份「上一次的值」如果跨 RawData 块复用这份状态上一块里的价格会被带到下一块解出来的行情价格就全错了。这种错误不会报异常只会让价格看起来「有点像但又不对」排查起来极其痛苦。def decode_raw_data(channel_no: int, raw_data: bytes, decoder): 每个 RawData 块独立解码块内多条 FAST 消息共享同一份字典 decoder.reset_previous_values() # 关键跨块必须重置 messages [] pos 0 while pos len(raw_data): msg, consumed decoder.decode_one(raw_data, pos) messages.append(msg) pos consumed # 块内多条消息之间不重置保持前值连续性 return messages块内不重置、块间重置这个边界要写进注释否则下一个人重构时很容易把 reset 提到循环外面性能看起来更好了数据却悄悄错了。上线前的验证方法也简单拿一段真实行情把解码结果和快照里的最新价做交叉比对价格对不上就是字典状态串了。3.3 重传请求怎么构造ApplEndSeqNum 填 0 是个开关重传走的是 9130 端口那条独立会话。请求消息的 STEP 层类型是 UA002FAST 层模板 ID 3002关键字段有四个ResendType标记 10077取 1 表示逐笔行情数据、取 2 表示公告信息ChannelNo 指定频道ApplBegSeqNum1182和 ApplEndSeqNum1183指定记录号范围。ApplEndSeqNum 有一个特殊语义填 0 时网关会把它设置成收到重传请求时该频道数据在内存中的最大值。也就是说填 0 等价于「把从起始序号到当前最新的全部补给我」。这个用法在首次建连或者断线时间较长时非常实用但要注意它只补到请求那一刻为止补完之后如果实时流还在推得靠实时会话继续追。def build_resend_request(channel_no: int, begin_seq: int, end_seq: int 0, resend_type: int 1, news_id: str ): req { MsgType: UA002, TemplateID: 3002, ResendType: resend_type, # 1逐笔行情 2公告 ChannelNo: channel_no, ApplBegSeqNum: begin_seq, ApplEndSeqNum: end_seq, # 0 表示补到网关内存中的最大值 } if resend_type 2: req[NewsID] news_id # 为空表示申请公告概要 return req应答侧的处理同样要看字段。网关返回的重传消息里ResendStatus10076有四种取值1 完成、2 部分完成、3 无权限、4 数据不可用。只有 1 才能认为补数成功2 意味着还有缺口得再发一次请求3 和 4 属于配置或权限问题重试没有意义应该直接告警。Text58字段在请求被拒时带回错误代码日志里一定要打出来否则线上排查只能靠猜。公告的处理路径不太一样。每个公告文件有唯一 ID登录完成后建议立即向网关申请重传公告文件。申请时 NewsID 留空表示要公告概要概要里包含所有已发布公告文件的 ID拿到之后逐个比对本地已有文件缺哪个补哪个。规范给的建议是「完成登录动作之后立即申请」这条别省否则连接建立之前的公告就永久缺失了。4. 数据字典落地开关类别和行情条目是两套扩展位数据字典那一章看起来最枯燥实际是兼容性工作的主战场。规范第五章把域分成会话层和业务层两组业务层里有两类字段会随着业务演进而不断增加取值证券实时状态的开关类别SecuritySwitchType和快照行情的行情条目类别MDEntryType。这两类字段的处理方式直接决定了你每次交易所升级规范时要不要改代码。4.1 开关类别34 回售撤销、35 转融通出借、36 债券回售转售开关类别的取值在历史上被反复增删。2020 年 1 月的 1.05 版增加了「34-回售撤销」同时删掉了「18-转股撤单」「19-回售撤单」「24-实时分拆」「25-实时合并」2020 年 4 月的 1.06 版增加了「35-转融通出借」2021 年 7 月的 1.10 版增加了「36-债券回售转售」。这种「加新的、删旧的」的演进节奏说明一件事开关类别不能硬编码成枚举校验。# 错误示范遇到未知开关类别直接抛异常 def apply_switch_bad(switch_type: int, value: str): if switch_type not in (1, 2, 3, 4, 5, 34, 35, 36): raise ValueError(funknown switch type {switch_type}) # 正确做法已知类别落库未知类别记一条统计后忽略 KNOWN_SWITCHES {1: 申购, 2: 赎回, 34: 回售撤销, 35: 转融通出借, 36: 债券回售转售} def apply_switch(switch_type: int, value: str, stats: dict): name KNOWN_SWITCHES.get(switch_type) if name is None: stats[unknown_switch] stats.get(unknown_switch, 0) 1 return None # 静默忽略不中断消息处理 return {switch: name, value: value}参数说明上stats 这个计数器不是可选项。未知开关类别虽然要忽略但必须能观测到——如果某天计数突然从 0 涨到几百说明交易所在推新业务了该去查规范更新了。这类「静默忽略但埋点上报」的模式是应对规范里所有可扩展枚举的通用解法。4.2 MDEntryType 的增量从加权平均价到港股买卖盘上下限行情条目类别的扩充更频繁。2017 年 4 月的 1.02 版增加了「9 加权平均价」「xj 加权平均价涨跌 BP」「xk 昨收盘加权平均价」2016 年增加了「xh 按盘价」「xi 参考价」2020 年 8 月的 1.07 版为支持港股开市前时段增加了 xr 买盘上限价、xs 买盘下限价、xt 卖盘上限价、xu 卖盘下限价四个条目。注意这些取值是字符串而不是数字解析时别用整型转换。快照消息的解析要按「条目类别 → 字段」的映射来写遇到不认识的类别跳过该条目继续解析后面的条目而不是整条消息丢弃。这个区别很关键一条快照里同时有最新价、成交量、买卖五档如果因为一个不认识的条目把整条消息扔了损失的是整条行情。条目类别含义引入版本解析注意9加权平均价1.02数值型注意精度xh按盘价1.01字符串枚举xi参考价1.01 / 1.08集中竞价快照也使用xj加权平均价涨跌 BP1.02单位是基点不是百分比xk昨收盘加权平均价1.02与昨收价不是同一字段xr/xs/xt/xu港股买卖盘上下限价1.07仅开市前时段有效4.3 逐笔的两种发送模式与占位消息2020 年 8 月的 1.07 版引入了一个结构级变化逐笔行情支持两种不同的发送模式。一种是把逐笔委托UA201和逐笔成交UA202分成两条独立消息流另一种是逐笔委托及成交合并成一条消息。这两种模式对下游的撮合还原逻辑影响很大——分开模式下你可以先建委托簿再匹配成交合并模式下必须按消息内自带的类型标记分流。选型上我一般倾向分开模式原因是委托和成交的字段差异大合并消息里有一半字段永远是空的解析时的分支判断反而更重。但如果是带宽敏感的场景合并模式的消息条数少一半压缩收益明显。这个取舍要在接入方案阶段和网关配置对齐不能单方面决定。2021 年 4 月的 1.09 版还增加了逐笔占位消息。债券现券交易业务的逐笔行情在某些时段没有实际成交但记录号必须连续占位消息就是用来占住记录号位置的。解析器遇到占位消息要正常推进 max_seq但不要往委托簿里写任何东西。这一点如果漏了记录号水位线不前进后面每一条真实消息都会被判成跳变重传请求会像雪崩一样发出去。注意兼容性要求一共五条分别针对新增频道、新增应用层消息类型、新增行情类别、新增行情条目类别、新增开关类别。五条的共同指向是「不使用就自动忽略不作变更」。把这条原则写进解析器的默认分支比事后补丁可靠得多。5. 避坑排查上线后最容易翻车的五个点规范读得再细链路跑起来还是会遇到问题。下面这几条是按「现象 → 原因 → 解决」整理的基本覆盖了接入初期八成以上的故障。5.1 连接与重传类故障现象一跑着跑着连接就断了没有任何错误日志。原因是流量控制。规范 2.1.5 节写得很直接如果用户行情系统不能及时处理网关发送的数据导致网关累积的待发送消息超过设定阈值网关会立即断开连接。这不是网络问题是消费速度跟不上。解决办法是把解码和业务处理解耦收包线程只做拆帧和入队业务逻辑放到独立线程池队列长度要监控堆积超过阈值就告警而不是等断线。另外要确认重连逻辑是带退避的别写成断线后立即狂重连那样只会被反复踢。现象二重传请求发出去了返回 ResendStatus3。原因是权限。3 表示无权限通常是重传会话没建在 9130 端口或者接入账号没有开通重传服务。现场版网关根本不提供重传端口如果部署在现场版环境里发重传请求只能拿到失败应答。先确认网关版本和端口再去查账号权限顺序别反。现象三补数补完之后数据还是有缺口而且缺口位置飘忽。原因是补数期间水位线被错误推进。前面代码里提过跳变时如果直接把 max_seq 更新到新收到的记录号中间缺失的那几条补回来就会被判成重复消息丢弃。解决方式是缺口期间用缓冲区暂存等补数应答把缺口填平再统一推进水位线。还有一种可能是 ApplEndSeqNum 填了具体值而不是 0导致补数范围不够这种情况把结束序号改成 0 让它补到最新即可。5.2 解码与字段类故障现象四价格字段偶发出现明显偏离重放同一段数据又复现不了。十有八九是 FAST 字典前值没重置。跨 RawData 块复用了上一块的解码状态增量编码的基准值错了解出来的价格就会带上前一条消息的残留。排查方法是把解码器改成每块强制 reset 之后重放如果问题消失就确认了。这类问题最坑的地方在于它不报错只出错数而且错得「看起来合理」肉眼看 K 线不一定能发现。现象五新增业务上线当天程序开始大量抛异常。原因通常是解析器对未知枚举做了严格校验。规范每次版本升级都会加新的开关类别或行情条目类别比如 34 回售撤销、35 转融通出借、36 债券回售转售或者 xr 到 xu 这四个港股买卖盘限价条目。如果代码里写的是「不在白名单就抛异常」新业务一来整条链路就挂了。改法是所有可扩展枚举统一走「已知处理、未知忽略并计数」的路径同时在监控里给未知类别单独建一个指标涨了就去查规范。6. 联调自检用频道统计和心跳间隔做最后一公里验证规范读通了、代码写完了还差一步才能真正上线拿什么证明这条链路是对的。我一般会在联调阶段做三件事顺序不能乱。先用频道心跳做链路存活验证。心跳模板 ID 3001三个关键字段ChannelNo、ApplLastSeqNum标记 1350最后一条行情消息的记录号、EndOfChannel标记 10205频道结束标志。心跳间隔 3 秒。写一个守夜脚本把每个已订阅频道的心跳时间戳打出来超过 6 秒没有心跳就标红。这一步能把「订阅了但网关没推」和「推了但你没收到」区分开省掉大量扯皮。再用快照行情频道统计消息做数据完整性校验。每个快照频道都会发统计消息里面带行情类别和该频道的消息计数。拿统计值和本地入库条数做对账差值稳定为 0 才算通。这一步的价值在于快照不可重传丢了就是丢了只能靠统计对账发现问题。对账周期我一般设成每分钟一次差值超过阈值就告警。最后用重传链路做一次人为破坏测试。手动丢掉若干条逐笔消息看补数请求能不能在预期时间内把缺口填平填平之后委托簿的最终状态和没丢消息的那一路是否一致。这个测试一定要在仿真环境做别在生产上试。检查项判定标准失败时的第一动作频道心跳任意频道间隔不超过 6 秒查该频道订阅配置与网关推送日志快照对账统计消息计数与本地入库差值稳定为 0查解析分支是否丢弃了未知条目逐笔水位线记录号连续无长期挂起的缺口查重传应答的 ResendStatus公告完整性登录后公告概要中的 ID 全部落地查 NewsID 为空时是否正确申请概要未知枚举计数长期为 0 或稳定不变查规范是否有新版发布有一件事我印象很深早期接入时觉得快照不能重传是件小事反正定时发丢了等下一版就行。直到某次盘后复盘发现某个时点的盘口状态对不上才知道快照丢一条那一秒的盘口就是错的而策略恰好在那秒做了判断。从那以后我每次接入新频道都强制把「统计对账」和「心跳守夜」两个脚本先跑通再谈业务逻辑宁可多花半天也不事后翻日志找那一秒。希望帮到你。本文还有配套的精品资源点击获取