简介:《同花顺level-2全推行情Python API操作手册》面向从事高频交易、量化研究与涨停板策略的证券开发者,围绕TDS系统的C/S架构,说明如何用Python接入沪深Level-1/Level-2实时行情。文档从TDS简介、系统特性与运行环境讲起,覆盖Windows/Linux下Python3.5(64位)的安装验证,再进入API使用:初始化TDS实例、定义行情回调与断线通知、设置连接市场与超时、注册证券/指数快照、逐笔成交、逐笔委托、委托队列及极速盘口等订阅接口,并给出五档十档快照与字段含义。版本修订表完整梳理V2.0.0至V2.1.9的多连接、级联、通道号、异步订阅async_sub等变更,便于排查兼容问题。包内为1个PDF文件,约850KB,结构清晰、便于检索;已有2846人学习下载,适合需要快速理解数据接口与版本差异的量化开发者和交易系统搭建者查阅。
1. 全推行情:Level-2 的难点藏在推送模型里
很多人以为 Level-2 行情的门槛在「十档」。其实十档只是比五档多五个价格档位,数据处理逻辑几乎没变。真正跑过 Level-2 全推行情 Python API 的人会发现,难点在别处:一条回调链路要同时接收全市场每秒成百上千个数据包,快照只是其中一路,旁边还并行着逐笔成交、逐笔委托、委托队列和极速盘口四路数据。量化策略里那些最影响延迟的逻辑——涨停板封单的撤单速度、委托队列里排队位置的变化——全都压在这条链路的稳定性上。这套接口的几乎每个细节,都在回答同一个问题:全推场景下,数据怎么做到不丢、不堵、不乱序。
2. 推送链路:从行情源到 Python 回调的三段式拆解
2.1 接入、解码订阅、API 分发各自在干什么
这套系统是典型的 C/S 结构:SDK 作为客户端连到行情服务端,服务端从交易所的行情接入通道取原始流,解码成结构化数据,再按订阅关系过滤后分发给各个客户端。整条链路可以切成三段——接入层负责与交易所对接和首层解码,处理层负责按订阅关系做过滤与路由,API 层负责把二进制结构转成各语言可用的对象。三段之间任何一段出问题,表现都是「数据不来」,但排查手段完全不同。
理解这个分层,最直接的好处是知道延迟该在哪一段量。接入层的延迟由行情源决定,你改不了;处理层和 API 层的延迟取决于服务端负载和你自己的消费速度,是可以优化的。很多人一遇到延迟就怀疑行情源,实际上去看stats里的三个时间戳(后面 2.3 会拆)就能定位到底是哪一段慢。
另一件容易被忽略的事:这类行情是非展示性的。它不是给终端画 K 线用的,而是给程序做决策用的,所以接口设计偏向吞吐和低延迟,而不是完整性和友好性。这就解释了很多看起来「反直觉」的设计——比如回调里没有缓冲队列,比如订阅接口的语义竟然是「取消全部再重订」。
2.2 回调签名 (data_list, count, stats) 为什么是批量的
先看一段能跑起来的最小骨架,注意回调函数里只做入队这一件事:
from datafeed.tds_api import TDS import queue tds = TDS() q = queue.Queue(maxsize=20000) def on_stock(data_list, count, stats): # data_list 是一批同类型数据, count 是本批条数 # 这里只做入队, 任何耗时操作(落库/计算/加锁)都会拖慢整条接收链路 for tick in data_list: try: q.put_nowait(tick) except queue.Full: # 消费不过来时, 主动丢最旧的, 而不是阻塞回调 q.get_nowait() q.put_nowait(tick) def on_trans(data_list, count, stats): q.put_nowait(('trans', data_list)) # set_env 必须先于 login 调用, 回调在这里一次性注册 assert tds.set_env( fnctickstock=on_stock, # 证券快照 fnctransaction=on_trans, # 逐笔成交 m_connectmarket="USH;USZ", # 默认双市场, 只连单市场时填 "USH" 或 "USZ" m_timeout=5000, # 同步请求超时, 单位毫秒 )data_list是列表而不是单条记录,count又与列表长度同源,这个看起来冗余的设计是绑定层的常见取舍:一个网络包解出来的同类型数据往往有好几条,一次跨语言调用传一批,比逐条穿透 Python/C 边界省下大量切换开销;count则来自 C 层结构体字段,绑定层直接透传,省掉在 Python 侧再算一次长度。实际写代码时用count比自己len()更贴近底层的真实语义——虽然两者正常情况下一相等,但一旦不等,说明数据被截断了。
提示:回调函数是 SDK 的接收线程在调用,不是你的主线程。这意味着回调里不能有阻塞调用,也不能假设线程安全。最常见的翻车方式是在回调里直接写数据库或发 HTTP 请求,前期看着没问题,行情一密集就开始堆延迟,最后表现为「数据延迟越来越大」而不是报错。
2.3 stats 三元组:把端到端延迟拆成两段
stats是个字典,装着数据包在链路上到达各节点的时间戳,单位是微秒级 Unix 时间:
def on_stock(data_list, count, stats): produce = stats['produce_time'] # 服务端收到行情的时间 send = stats['send_time'] # 服务端发出该包的时间 recv = stats['recv_time'] # SDK 收到该包的时间 # 服务端内部处理耗时: 解码 + 订阅过滤 + 排队 server_cost = send - produce # 网络传输耗时: 从服务端出口到你机器 net_cost = recv - send # 端到端总延迟 e2e = recv - produce这三个值能拆出两个独立的指标。send - produce反映服务端处理层的负载,如果这个值在工作日开盘前几分钟突然抬高,说明是服务端在排队,你本地再怎么优化也没用;recv - send反映网络和客户端的接收情况,这个值飙高才该怀疑自己的带宽和消费速度。把两个混在一起看,是最容易做无用功的地方。
还有更细的用法:这三个时间戳都来自不同机器(前两个是服务端时钟,第三个是本地时钟),跨机器做差会混入时钟偏移。所以要么只比同一台机器上的差值,要么确保双方都做了时间同步。判断标准很简单——如果算出来的net_cost长期是个稳定的负值或异常大的正值,先怀疑时钟,不要怀疑网络。
2.4 订阅的四条路径与重连后的重发语义
订阅接口按「影响范围」和「是否阻塞」分成四个同步接口加一个异步接口,语义差别比名字看起来大得多:
| 接口 | 语义 | 副作用 |
|---|---|---|
sub | 取消该市场该类型的全部订阅,再按新列表重订 | 之前订的其他代码会被冲掉 |
unsub | 取消该市场该类型的全部订阅 | 全量清空 |
add_sub | 在现有订阅上追加代码 | 幂等性取决于服务端实现 |
del_sub | 从现有订阅中移除指定代码 | 不影响其他代码 |
async_sub | 异步下发订阅请求,结果走回调 | 不阻塞调用线程 |
sub的语义是最容易踩的坑:它不是说「订阅这些代码」,而是说「把这个市场这个类型的订阅整体替换成这些代码」。分两次调用sub('stock', 'USHA', ['600000'])和sub('stock', 'USHA', ['600001']),结果只订到了 600001。要做增量,必须用add_sub。
异步订阅的价值在于把「等结果」从调用线程里拿掉。它的结果通过订阅回调函数通知,回调参数里有一个callback_type字段,用来区分这次回调是主动调用异步接口触发(值为 1)还是断线重连成功后自动重发触发(值为 2)。这个区分很重要:前者你要处理失败重试,后者你只需要记日志确认订阅恢复。
def on_subscribe(data_type, op_type, market, code_list, statuscode, callback_type): # op_type: 1 全新订阅 2 取消订阅 3 增加订阅代码 4 取消订阅代码 # statuscode: 0 成功, 其余失败 # callback_type: 1 异步请求触发, 2 重连/登录成功后自动重发触发 if statuscode != 0: print(f"订阅失败 {data_type} {market} op={op_type} code={statuscode}") tds.set_env(fucsubscribe=on_subscribe) tds.async_sub('stock', 1, 'USHA', ['600000', '600001'])重发订阅这件事本身是个开关:环境参数里的m_connectsubscribe设为 1 时,断线重连成功或手动重新登录成功后,SDK 会把之前的订阅信息重新发一遍。不开这个开关,重连之后连接是通的、心跳是好的,但一包数据都不来——这是排查时最耗时间的一类故障,因为所有「连接是否正常」的检查项都通过。
注意:订阅回调函数的参数个数在接口文档的描述文字和示例代码之间有过不一致,实际以 SDK 签名和示例为准。稳妥做法是先用
inspect.signature打印一次真实签名,再照着写回调,别照着注释写。
3. 连接拓扑:单连接为什么会撞上瓶颈
3.1 从单连接改多连接,改的到底是哪一层
早期版本所有市场共用一条连接,行情源、解码、订阅过滤全压在一个 socket 上。这个设计在数据量小的时候能稳定运行,但有两个结构性隐患:一是故障域不隔离,深市那边网络抖动导致重连,沪市的推送也跟着断;二是无法按市场做独立的限速和优先级,开盘瞬间两个市场的流量叠在一起,谁也别想快。
改成多连接之后,每个市场有自己的域名和端口,连接、心跳、重连、订阅都在各自的连接上独立进行。代价也要看清楚:连接数翻倍意味着心跳包翻倍、客户端内存里的接收缓冲翻倍、文件描述符翻倍,以及重连逻辑从「一份」变成「每市场一份」。第一次上手时最常犯的错,是把原来的单份重连计数当成全局的,结果某一个市场疯狂重连把其他市场挤掉。
后来版本里还有一个务实的改进:把各市场的域名和端口写进默认配置,装完即用,不用手动填。这个改动看着小,实际上消掉了一大类「配置写错一个字,连上了但没数据」的问题。
3.2 市场号不是标签,是寻址的一部分
市场号是这套接口里最容易被当成「随便填填」的参数,但它直接决定请求被路由到哪条连接、哪个数据集合。沪深两市的市场号大致如下:
| 市场号 | 对应市场 | 市场号 | 对应市场 |
|---|---|---|---|
| USHI | 上证指数 | USZI | 深证指数 |
| USHA | 上证 A 股 | USZA | 深证 A 股 |
| USHB | 上证 B 股 | USZB | 深证 B 股 |
| USHD | 上证债券 | USZD | 深证债券 |
| USHJ | 上证基金 | USZJ | 深证基金 |
| USHP | 上证退市整理 | USZP | 深证退市整理 |
| USHT | 上证风险警示板 | — | — |
关键在于最后一行。上证 A 股被拆成了USHA和USHT两个市场号,风险警示板是单独一个。如果你只订了USHA全市场,然后把「全市场都订到了」当成前提去跑选股逻辑,ST 股会静默缺席,不报错、不告警,只是数据里没有。这类问题在回测里几乎不可能发现,实盘时才会以「某只票怎么一直没信号」的形式暴露出来。
正确的做法是先用代码表接口探路,再决定订哪些市场号:
# 拿指数市场的代码表, 返回字符串列表, 失败返回空列表 codes = tds.get_code_list('USHI') # code_list 传 [] 表示该市场全量 snap = tds.get_tick_stock('USHA', []) # 上证 A 股全市场快照 snap_st = tds.get_tick_stock('USHT', []) # 风险警示板单独一份 super_stock = tds.get_super_stock('USHA', ['600000']) # 极速盘口需单独产品权限get_tick_stock这类同步查询函数和订阅是两条独立的路:查询是「我问一次,你答一次」,订阅是「你持续推给我」。很多人混用,比如用查询接口去补订阅漏掉的数据,结果发现查询返回的快照和推送的快照时间戳对不上,拼出来的序列是乱的。两者适合的场景完全不同——查询适合冷启动时补一份初始状态,推送才是常态数据源。
3.3 级联模式:主从分工与两个必填配置
需要在一个局域网里跑多个 SDK 实例时,如果每个实例都去连服务端,一是浪费带宽,二是服务端连接数会有上限。级联模式解决的就是这个问题:主 SDK 连服务端,负责收数据和分发;从 SDK 连主 SDK,从局域网内拿数据。
开启级联靠两步:先改配置文件tool.ini,再从 SDK 调接口注册主节点地址。
# 主 SDK 的 tool.ini, 放在与程序/动态库/jar 同级的目录 master=true socket_port=9999# 从 SDK 的 tool.ini, 同一台机器上端口必须错开 master=false socket_port=9998from datafeed.tds_api import TDS tds = TDS() tds.set_env(fnctickstock=on_stock) # 必须在 login 之前调用, 且主 SDK 已经登录成功 ret = tds.register_addr('master', '192.0.2.10', 9999) if ret != 0: raise RuntimeError(f"连接主节点失败: {ret}") # 从 SDK 的账号密码需要与主 SDK 保持一致 tds.login('demo', 'demo')这里有三处硬约束,每一条都是踩出来的:tool.ini里socket_port的值后面必须带换行符,否则配置读取会失败,表现为「明明写了 9999,但连接不上」;register_addr必须在登录前调用,因为它是建立连接的动作,不是登录后追加的配置;主从必须处于同一局域网,跨网段会直接返回级联专用错误码。另外主从同机部署时,两个 SDK 的监听端口要错开,不然第二个实例起来时会静默失败。
级联这条链路在版本里被反复打磨过:修复过级联模式下回调状态参数为空的问题,做过级联模式性能优化,也调整过订阅回调的参数。如果你的使用场景是多实例分发,尽量用较新的版本,早期版本在级联下的边界情况确实不够稳。
3.4 环境参数与登录返回码:先看返回值,再看日志
环境参数决定了 SDK 的容错行为,几个关键项值得单独说清楚:
| 参数 | 含义 | 取值注意 |
|---|---|---|
m_heartbeat_interval | 心跳间隔(秒) | 小于 1 用系统默认 |
m_reconn_count | 断线重试次数 | 小于 1 表示不重连 |
m_reconn_interval | 重连间隔(秒) | 小于 1 表示立即重连 |
m_timeout | 同步请求超时(毫秒) | 默认 5000 |
m_connectmarket | 连接市场 | 默认USH;USZ,可只填单市场 |
m_connectsubscribe | 重连后是否重发订阅 | 1 为重发 |
登录函数的返回值是个整数,成功返回 0,失败返回负数或特定正数。比较坑的是版本相关的那两个:一个是正数 1 表示「版本过低,请及时更新」,另一个是 -1018 表示「版本太低,请更新程序」。同一个含义用了两个码,原因是它们来自不同的校验环节——一个在客户端本地判断,一个由服务端在下发时判断。写重试逻辑时如果只判断< 0,正数 1 这种情况会被当成成功,然后你就会得到一个「登录成功但一包数据都没有」的诡异状态。
其它值得记住的码:-1002 超时无响应、-1004 用户名密码错误、-1005 无法连接至服务器、-1007 错误市场、-1011 非法订阅操作、-1016 重复登录、-1017 通行证校验失败次数太多、-1014 级联模式下主 IP 连接异常、-1015 级联模式下必须同一局域网。这套码足够细,排查时直接按码查比看日志快得多。
4. 数据语义:字段名背后的口径与陷阱
4.1 status 状态位:为什么是定长字符串而不是枚举
status字段看起来是个简单的状态标签,实际上是整个快照里信息密度最高的字段。上证用 4 位,深证用 2 位,每一位含义不同,无意义的位置填空格。
上证第 1 位是交易阶段:S 启动(开市前)、C 开盘集合竞价、T 连续交易、E 闭市、P 产品停牌、M 可恢复交易的熔断、N 不可恢复交易的熔断、U 收盘集合竞价。第 2 位表示该产品当前是否可正常交易,第 3 位表示是否已上市,第 4 位表示当前时段是否接受订单申报。深证第 1 位是阶段:S、O 开盘集合竞价、T 连续竞价、B 休市、C 收盘集合竞价、E 已闭市、H 临时停牌、A 盘后交易、V 波动性中断;第 2 位是 0 正常、1 全天停牌。
SH_PHASE = {'S': '启动', 'C': '开盘集合竞价', 'T': '连续交易', 'E': '闭市', 'P': '停牌', 'M': '可恢复熔断', 'N': '不可恢复熔断', 'U': '收盘集合竞价'} SZ_PHASE = {'S': '启动', 'O': '开盘集合竞价', 'T': '连续竞价', 'B': '休市', 'C': '收盘集合竞价', 'E': '已闭市', 'H': '临时停牌', 'A': '盘后交易', 'V': '波动性中断'} def parse_status(market: str, status: str) -> dict: # 定长字符串, 空位是空格; 不要 strip 之后再按下标取, 会错位 if market.startswith('USH'): return { 'phase': SH_PHASE.get(status[0], status[0]), 'tradable': status[1:2] == '1', # 用切片, 越界时返回空串而不是抛异常 'listed': status[2:3] == '1', 'accept_order': status[3:4] == '1', } return {'phase': SZ_PHASE.get(status[0], status[0]), 'halted_all_day': status[1:2] == '1'}为什么用定长字符串而不是整型枚举或位图?因为它要跨语言、跨版本、跨市场传。字符串在任何语言的绑定层里都不会有字节序和对齐问题,加一位新状态也不破坏老客户端的解析;代价是每帧多几个字节,并且解析责任丢给了调用方。这是这套接口「把复杂度留给调用方」思路的第一个典型体现。
实际用法上有个关键区分:判断「能不能在这个价位挂单排队」,要看的是accept_order和tradable,不是看最新价涨没涨、是不是停牌状态字符串。有些标的在连续竞价阶段是 T 且可正常交易,但当前时段不接受订单申报,只看第 1 位会误判。
4.2 十档、撤单与内外盘:字段名不能猜
Level-2 快照相比 Level-1 最大的变化是档位从五档变十档,并且每档多了委托笔数:
for tick in data_list: # 十档买卖盘, list 长度 10, 下标 0 是买一/卖一 bid_p = tick['bidorder_price'] # 申买价, 买一到买十 bid_v = tick['bidorder_volume'] # 申买量 bid_n = tick['bidorder_numorders'] # 该档委托笔数, Level-1 没有这个字段 ask_p = tick['askorder_price'] # 汇总口径 total_bid_v = tick['totalbid_volume'] # 总委买量 avg_bid_p = tick['avgbid_price'] # 加权平均委买价totalbid_volume和avgbid_price是服务端算好的汇总值。好处是省掉你在客户端遍历十档的 CPU;坏处是这些值的计算口径由服务端定义,和你在本地按档位加总的结果可能有细微差异——比如是否包含已撤单未确认的部分。做盘口强度类指标时,口径必须统一,要么全用服务端汇总值,要么全用本地加总,混着用会得到不可解释的指标跳动。
撤单类字段只在上证提供,一共 6 个:买入撤单笔数、买入撤单量、买入撤单金额、卖出撤单笔数、卖出撤单量、卖出撤单金额。这几个字段是做封单真实性的核心素材——涨停板上的巨量封单,如果买一量的增长伴随着买入撤单量的同步飙升,说明封单在反复挂撤,不是真买盘。这个判断逻辑在对延迟敏感的涨停板策略里非常常见,也是对全推推送模型要求最高的部分:你必须在毫秒级看到撤单字段的变化,等三秒一次的轮询快照,撤单早就完成了。
值得注意的是买入撤单金额和卖出撤单金额这两个字段的数据类型,在某次版本更新里从 int 改成了 float。这个改动的动机很实际:金额是累加量,用整型存会在长时间累加后溢出,或者丢掉小数部分导致精度偏差,而且在 Python 侧做除法时整型还会带来隐式的截断。同类字段改动在整个版本历史里出现过多次,比如把某些字段的类型对齐、把结构体做优化——这类「不改变语义只改类型」的更新最容易被忽略,因为它不会让程序报错,只会让你的计算结果悄悄偏一点点。
还有一对容易看反的字段:内盘成交量inner_volume和外盘成交量outer_volume。内盘是按买价成交的量(卖方主动),外盘是按卖价成交的量(买方主动),名字和直觉相反。另外盘后成交相关的两个字段,其中一个的字段名拼写有意外的形式(成交量与成交次数),不要按名字猜语义,按文档的类型定义读。
4.3 逐笔流的序号体系:通道号和业务序号在解决什么
快照是「某一时刻的截面」,逐笔是「事件流」。事件流要能用于回放和校验,就必须能判断丢包和乱序,所以逐笔数据里带了一组序号字段。
逐笔成交和逐笔委托都增加了通道号字段。通道号的意义在于:一个市场的行情可能从多个通道并行下发,同一只股票的事件在不同通道上到达你本地的时间顺序,不保证和交易所的产生顺序一致。没有通道号,你只能按到达时间排序,一旦跨通道乱序就会拼出错误的事件序列。
逐笔委托后来还加了委托序号,逐笔数据本身带业务序号。这几个字段的分工大致是:业务序号标识事件在服务端处理流中的位置,用来判断是否丢包;委托序号标识委托单本身,用来把「委托—成交—撤单」串成一条完整的生命周期;通道号标识事件来自哪条通道,用来做跨通道去重和排序。缺了任何一个,事件流就只能是「大概能用」的状态。
常见的实现思路是建一个按通道分开的排序缓冲:每条通道内部按序号严格递增消费,跨通道之间用一个很小的滑动窗口做重排,窗口大小取「最大通道间延迟差」的估计值。窗口开太小会误判乱序,开太大会引入额外延迟。这个取舍在低延迟场景里很关键,而 SDK 本身不做这件事——它只负责把带序号的原始事件推给你。
4.4 深市五种交易方式与 mode_state 的解析
2022 年 5 月中旬之后,深交所发布的数据不再只有竞价交易一种。快照原来的字段变成五种交易方式的汇总值,另外新增了匹配成交量、匹配成交金额、匹配成交价、成交方式、各交易方式状态这几个字段。
这带来一个隐蔽的断层:同一个字段名,在时间点前后代表的口径不一样。跨这个时间点做历史回测,如果不做口径对齐,成交量和成交金额会出现系统性偏差。这不是 bug,是市场规则变化导致的数据语义变化,只能靠回测框架里显式标注数据版本解决。
成交方式deal_mode是一个整型,1 到 5 分别对应匹配成交、协商成交、点击成交、询价成交、竞买成交,其余值无意义。快照里的deal_mode表示的是最近一笔成交的方式,不是整个快照的。这个细节很关键:不能用它来判断「这只票今天走的是哪种交易方式」,只能判断「最后一笔是什么方式」。
各交易方式状态mode_state是个拼接字符串,格式是「交易方式 1 位 + 状态 1 位」循环,比如1T2T3T4T5T表示五种交易方式当前都处于连续竞价状态:
DEAL_MODE = {1: '匹配成交', 2: '协商成交', 3: '点击成交', 4: '询价成交', 5: '竞买成交'} def parse_mode_state(s: str) -> dict: # '1T2T3T4T5T' -> {'1': 'T', '2': 'T', '3': 'T', '4': 'T', '5': 'T'} # 步长 2, 奇数长度时最后一对会被丢弃, 属于预期行为 return {s[i]: s[i + 1] for i in range(0, len(s) - 1, 2)}解析完能得到的结论是「某种交易方式当前是否可交易」,比如只有匹配成交是 T 而协商成交是 B(休市),说明当前只有竞价通道开着。做多交易方式的策略时,这个字段比status更细,因为status描述的是标的状态,mode_state描述的是交易通道状态。
4.5 离线调试:文件名即类型,回放即压测
这套系统由数据驱动。没有实时行情时,可以进入离线调试模式,读本地数据文件来驱动程序运行——这一点在做回归测试时价值极大,因为行情是没法复现的,但回放可以。
机制上分三块。第一块是文件发现:数据文件必须按stock.csv、index.csv、order.csv、queue.csv、trans.csv、superstock.csv、codelist.ini命名,SDK 靠文件名判断数据类型,命名不规范的文件直接不读。第二块是查询缓存:调用快照查询接口时,读取对应文件的前 3000 条(指数是前 300 条),剔除重复代码后生成一份可查询的数据集,再从里面按传入的代码筛选返回。第三块是回放:调用订阅接口一律返回成功,然后按文件顺序、按设定的间隔和单次条数循环推送,触发对应的回调函数。
tds = TDS() tds.set_env(fnctickstock=on_stock) # 必须在 login 之前调用; 已经登录过则不生效 ret = tds.start_offline_debug( path='/data/replay', # 数据文件所在目录 subtype=['stock', 'index', 'order', 'queue', 'trans', 'superstock'], push_space=3000, # 两次推送间隔, 毫秒 push_count=5000, # 单次推送条数 playback_times=1, # 文件遍历次数 ) # 离线模式下 login 不连网关, 直接返回成功 tds.login('demo', 'demo')push_count和push_space这两个参数的组合,本质是在模拟不同的行情密度。把push_space调到 0、push_count调大,就是一次压力测试:看你的回调链路在满速推送下会不会丢数据、队列会不会爆。这比等开盘日抓问题高效得多。
还有两个边界要知道:离线调试必须在实例创建之后、登录之前调用,登录之后再调不生效;级联模式下只有父节点支持离线调试,子节点调用无效,但父节点开了调试后,子节点可以正常取到数据。
5. 避坑与排查:五类真实故障的现象、原因与解法
5.1 连接与登录:报错码比日志更快
现象一:登录返回正数 1 或 -1018,之后程序正常运行,但一包数据都收不到。原因:SDK 版本与服务端网关的协议版本不匹配,服务端拒绝下发数据,但连接层是通的,所以心跳、重连全都正常。这两个码来自不同校验环节,一个本地判断、一个服务端判断,容易只处理了其中一个。 解决:登录返回值不要只判断< 0,把正数 1 和 -1018 都归为「版本问题」,直接终止并提示升级,不要在版本不对的情况下继续跑业务逻辑。同时检查set_env里有没有手动指定过m_version,硬编码的版本号会覆盖 SDK 自己的判断。
现象二:运行几小时后数据突然静止,进程还活着,日志里没有任何异常。原因:断线通知回调没有注册,或者注册了但只打印了一行日志没做处理。断线重连次数耗尽后 SDK 会停止工作,此时它不会自己恢复,必须由调用方重新登录并重订阅。 解决:一定要注册断线通知回调,并在回调里做完整的状态重置——重新调用登录函数,再重新下发订阅。通知内容里带的原因码能区分网络断开、网卡异常、重复登录被踢三种情况,重复登录被踢这种要单独告警,因为它通常意味着账号在别处被使用了。
def on_notify(msg_notify): # code: 1 网络断开, 2 网卡不正常, 3 重复登录被踢下线 print(f"disconnect: code={msg_notify['code']} msg={msg_notify['msg']}") if msg_notify['code'] == 3: alert("账号在别处登录") # 这种情况重连也没意义, 先告警 return relogin_and_resubscribe() # 重新 login + sub, 不要只重连注意:
m_reconn_count小于 1 表示完全不重连,m_reconn_interval小于 1 表示立即重连。两个参数都设成「激进值」时,一次短暂的网络抖动会引发密集重连,反而更容易被服务端判定为异常。
5.2 订阅与回调:静默丢数据最麻烦
现象三:订阅返回成功,大部分票有数据,个别票一直没有。原因:市场号没覆盖全。上证 A 股拆成USHA和USHT两个市场号,只订前者会漏掉风险警示板;另一种情况是code_list传了字符串而不是列表,SDK 把一个字符当成一个代码去解析,失败但不报错。 解决:先用代码表接口确认目标代码落在哪个市场号下,再补订阅;code_list一律用列表,只有一个代码也写成['600000']。全市场订阅记得两个市场号都订。
现象四:刚启动时延迟很低,跑一段时间后回调越来越慢,最后数据严重滞后。原因:回调函数里做了耗时操作——写数据库、加锁、发网络请求、做重计算。回调运行在接收线程里,一次慢调用就会让后面的包排队,排队又会加剧下一次处理的延迟,形成正反馈。这种故障的典型特征是「不报错、不丢包、就是越来越慢」。 解决:回调里只做入队,所有处理搬到独立工作线程。队列要有界并且有明确的丢弃策略——行情数据过期就失去价值,宁可丢最旧的也不要阻塞回调。改造后回看stats里的recv - send,正常情况下应该回到一个稳定的窄区间。
现象五:分两次订阅,第二次调用后第一次订的代码没数据了。原因:用了sub而不是add_sub。sub的语义是「取消该市场该类型的全部订阅再按新列表重订」,它做的是替换不是追加。 解决:增量订阅一律用add_sub,只有在需要整体重置订阅集合时才用sub。改完之后建议在订阅回调里把op_type打出来,1 是全量替换、3 是增量追加,日志里一眼能看出这次到底做了什么操作。
5.3 级联与调试回放:配置的第一个字符就可能出错
现象六(级联):从 SDK 连接主 SDK 失败,或者连上了但登录报错。原因:常见有三种——主 SDK 还没登录成功就先起了从 SDK,级联要求主节点先就绪;tool.ini里socket_port的值后面少了换行符,配置读不出来,端口用了默认值;主从不在同一局域网,跨网段会被直接拒绝。 解决:按顺序检查——主 SDK 的master=true和端口是否生效(看启动日志里打印的监听端口)、主是否已登录、从的register_addr是否在login之前调用、两台机器是否同网段。主从同机部署时,两个socket_port必须不同。
现象七(回放):调了离线调试函数,但回调一次都没被触发。原因:调用时机在登录之后,函数不生效;或者subtype里填的文件名对应文件不存在、命名不规范(比如写成stock.CSV或stocks.csv);再或者是级联模式下的子节点,本身就不支持离线调试。 解决:把start_offline_debug严格放在实例创建之后、login之前;核对文件名与数据类型的对应关系,`
本文还有配套的精品资源,点击获取