☰
基于TdxHqApi.dll的A股实时行情采集器:从TCP协议到落库实践
2026/10/7 9:27:57 网站建设 项目流程

简介:这是一套借助TdxHqApi.dll实现的实时股票数据采集器,面向量化交易、行情分析及通达信接口二次开发者。压缩包内含C#与Java两套示例源码,以及对应DLL、配置文件、数据格式说明和文档资料,可帮助读者快速掌握通达信行情接口的调用方式、实时数据解析与采集逻辑。资源共248个文件,以cs、java源码为主,辅以dll动态库、txt说明、config配置、class编译文件,以及少量pdf、xlsx、csv等文档表格,整体打包约105.88MB,目录结构清晰便于按语言和模块查阅。目前已有832人学习下载,适合需要对接通达信行情、构建实时数据管道的初中级开发者参考,既可直接借鉴C#或Java调用示例,也可深入研究TdxHqApi.dll的封装与数据格式细节。

1. 借用TdxHqApi.dll做实时数据采集器,为什么比HTTP轮询和云行情都划算

先立一个场景:做A股实时行情采集的人,多半都经历过新浪、腾讯的HTTP行情接口延迟不稳、请求频繁被限流的憋屈。拉个分时数据要轮询,轮询一勤快就被封IP,数据一断就是几分钟空洞。想上Level-2或者云厂商的实时行情服务,报价又让人肉疼。这时候通达信客户端自带的TdxHqApi.dll就成了一个很务实的选择——它走的是通达信自有的TCP行情协议,跟行情主站建立长连接后,数据是主站主动推送过来的,延迟低了一个量级,而且没有HTTP那种请求次数限制,接口又是免费开放的。标题里的StockRealData.zip,就是一个把TdxHqApi.dll包进采集进程的典型工程:负责连接主站、订阅股票、解析行情、落库,最终产出一份随时保鲜的本地实时行情库。适合做量化回测、自建盯盘工具、维护数据仓库的开发者,不适合只想拿现成数据、不想碰底层协议的纯业务方。

2. 先读懂TdxHqApi.dll的行情通道:自定义TCP协议、导出函数和数据流向

2.1 通达信行情DLL的底层协议:为什么大家都绕着它做

通达信行情接口不是开放的RESTful API。它走的是自己定义的一套二进制TCP协议,封包格式、字段偏移、加解密规则都是内部实现。早期想做实时采集的人,得靠抓包去逆这套协议,工作量非常大,且协议一旦改版就要跟着改。它的价值在于,这层协议已经被DLL封装好了。你不需要理解封包里第几个字节是价格,只需要把DLL加载进进程、传入服务器地址和端口、注册回调函数,它就能把解好密、拆好包的数据从回调里吐给你。

这个DLL的本质是C接口的动态库,不是.NET组件,所以C#要P/Invoke导入。它的数据流向有两条线:一条是主动请求,比如客户端发起"查询某只股票当前快照",DLL收包后在回调里返回结果;另一条是订阅推送,你注册了某只股票的关注,主站一有行情变动就推给你,对应的是回调里的某个消息类型。实时采集器能叫"实时",靠的就是第二条线——不是你去轮询,是服务端主动找上门。

2.2 把TdxHqApi.dll接进C#工程:最小P/Invoke调用代码与导出表确认

拿到DLL的第一件事,不是写代码,而是看清导出函数表。这DLL的导出函数命名和序号在不同版本里可能不同,有些为了减小体积甚至只用序号导出,函数名都不给。我通常先用Visual Studio自带的dumpbin把导出表打出来:

dumpbin /exports TdxHqApi.dll

输出大致长这样(不同版本差异很大,仅示意格式):

ordinal hint RVA name 1 0 00001000 TdxHq_Init 2 1 00001200 TdxHq_Connect 3 2 00001400 TdxHq_RegisterNotify

看到真实函数名或序号之后,再在C#里声明。如果只有序号没有名字,就通过EntryPoint="#序号"来绑定。一个最小可用的P/Invoke声明大概是这样的:

// 注意:函数名与序号仅示意,实际以dumpbin导出的为准 [DllImport("TdxHqApi.dll", EntryPoint = "#1", CallingConvention = CallingConvention.StdCall)] private static extern int TdxHq_Init(string configDir); [DllImport("TdxHqApi.dll", EntryPoint = "#2", CallingConvention = CallingConvention.StdCall)] private static extern int TdxHq_Connect(string host, int port); [DllImport("TdxHqApi.dll", EntryPoint = "#3", CallingConvention = CallingConvention.StdCall)] private static extern int TdxHq_RegisterNotify(NotifyCallback cb, IntPtr userData);

参数里最关键的是CallingConvention。TdxHqApi.dll这类老牌Windows动态库,清栈方式基本是StdCall,也就是调用方在被调函数返回后不需要自己调整栈。写错成Cdecl,轻则参数错乱,重则进程在调试器里看起来毫无征兆地崩溃,属于典型的"玄学故障"。另一个关键参数是configDir,它指向一个配置文件目录,DLL初始化时要读这里面的服务器地址列表、通讯超时等参数,路径给错的话后续连接会返回一个让人摸不着头脑的错误码。

2.3 主动查询与推送回调:采集器的实时性从哪来

接下来要把调用逻辑按两条路径设计。主动查询是"拉",适合启动时拉快照、补漏掉的历史Tick;推送回调是"推",行情变动时DLL在内部接收线程上触发你注册的委托。实时数据采集器的"实时"两字,完全靠推送回调撑着。

// 行情推送回调的典型签名,具体消息类型和参数以导出表为准 private delegate void NotifyCallback(int msgType, int market, byte[] rawData, int length); private static void OnNotify(int msgType, int market, byte[] rawData, int length) { // msgType 区分是快照、成交明细还是指数行情,这边只关心快照 if (msgType != 0x0101) return; // market 区分沪市和深市,注意不同版本的约定可能相反 // 先把数据塞进队列,绝不在回调里做解析落库 _incomingQueue.Enqueue(new RawPacket(market, rawData, length)); }

这段代码揭示了一个核心原则:回调是在DLL的接收线程上执行的,你在回调里做的任何耗时操作都会阻塞它的接收循环。如果回调阻塞超过一定时间,TCP接收缓冲堆积,主站会判定这个客户端处理不过来,直接断开连接。所以回调函数里只做浅拷贝和入队,解析、落库、打日志全部丢给其它线程。

3. 组装StockRealData采集器:连接、登录、心跳与断线重连

3.1 建立连接和登录主站:服务器列表、端口和配置解析

生产环境的采集器不能只连一台行情主站。主站是分地域、分运营商部署的,单台能接纳的连接数有限,而且偶尔有维护。常见做法是准备一个服务器列表配置文件,里面维护多台主站。通达信行情主站的默认端口一般是7709,这个数字在大量开源项目里都能对上。

// ServerList.txt 示例 // 格式:ip,端口,市场 // 119.147.212.13,7709,1 // 60.28.23.33,7709,0 private static void ConnectAllServers() { var lines = File.ReadAllLines("ServerList.txt"); foreach (var line in lines) { var parts = line.Split(','); if (parts.Length != 3) continue; int ret = TdxHq_Connect(parts[0], int.Parse(parts[1])); if (ret == 0) { // 连接成功后需要登录,登录接口一般在connect之后调用 // 很多版本不校验账户密码,只要求传客户端标识 int loginRet = TdxHq_Login("stockDataCollector"); if (loginRet == 0) break; // 第一台连上就够,其他备用 } } }

我在这里做了"连上就停"的取舍。为什么不多连几台做负载均衡?因为一台采集器实例维护多条主连接,意味着多套心跳、多套消息序号管理,复杂度成倍上升,而且主站的异常检测会更敏感。单主连接单实例,备用服务器只参与重连,这是最稳的形态。

3.2 心跳包和断线重连:让采集器常年挂在服务器上的两个前提

实时采集器的生命周期不是几分钟,是几个月。它的网络连接必须过"心跳保活"和"断线重连"两道关。主站对空闲连接是有超时保护的,我观察到的阈值一般在60到90秒,超过这个时间没有数据往来,连接会被主动断开。所以采集器必须自己维持心跳。

// 心跳保活线程:每30秒发一次最轻量的查询,保持连接不空闲 private static async Task HeartbeatLoopAsync(CancellationToken token) { while (!token.IsCancellationRequested) { try { // 发送一个无副作用的查询,比如查询服务器时间或行情状态 TdxHq_SendHeartbeat(); await Task.Delay(TimeSpan.FromSeconds(30), token); } catch (Exception ex) { // 心跳一旦失败,立即触发重连流程,而不是等下一次心跳 Log($"Heartbeat failed: {ex.Message}"); await ReconnectAsync(token); await Task.Delay(TimeSpan.FromSeconds(5), token); } } }

心跳间隔为什么是30秒?因为要在主站60秒的空闲阈值内至少产生两个数据包,即便网络抖动丢掉一个,也不会触发踢线。重连的姿势比心跳更重要。我建议把重连做成状态机,连接失败按指数退避:1秒、2秒、4秒、8秒……上限30秒。收到断开通知就立刻重连,但连带失败的次数多了就要拉长间隔,避免高频重连把自己的IP送进对端风控名单。

3.3 采集范围与启动流程:自选股列表、全量快照加增量推送

连接稳定后,要决定采什么。实时采集器不会去订阅沪深两市全部股票,主站对单连接的订阅数量是有限制的。所以工程上要有股票池清单,然后启动时先拉一次全量快照,再进入推送订阅。

private static async Task InitSnapshotAsync(string[] stockCodes, CancellationToken token) { // 1. 对股票池分批查询当前快照,写入内存库 var snapshot = await RequestSnapshotAsync(stockCodes, token); _store.BulkUpsert(snapshot); // 2. 快照拉完后,再向DLL注册订阅,进入实时推送模式 int subRet = TdxHq_Subscribe(stockCodes); if (subRet != 0) { Log($"Subscribe failed, code={subRet}"); } // 3. 订阅后3秒内如果没有推送,判定订阅失败,触发告警 await HealthCheckAsync(TimeSpan.FromSeconds(3), token); }

顺序不能反:先订阅再拉快照,推送的最新价会覆盖快照里的开盘价、昨收、成交量等基础字段,最终数据表里只会残留被更新的那几列,其它字段全是空。先快照打底,再增量推送,才能保证任何时刻同一只股票的数据都是一行完整的记录。

4. 解析与落库:字段对齐、字节序、快照和成交明细分开存

4.1 原始包怎么拆字段:字节序和定点数的坑

回调里拿到的rawData,是DLL解过密的,但还不是结构体,它按照协议排列成字节流。拆包时最大的坑是字节序和数值表示。通达信协议里不同消息类型的字节序可能不一致,有些版本索性全用大端。价格和成交量很少是浮点数,而是整数定点数,比如价格乘以1000存储,收到后要除回去。

// 从字节流里读取一个32位整数,按定点数比例转换为价格 private static decimal ToPrice(byte[] buf, int offset, bool bigEndian) { if (bigEndian) { int val = (buf[offset] << 24) | (buf[offset + 1] << 16) | (buf[offset + 2] << 8) | buf[offset + 3]; return val / 1000m; // 实际比例系数按品种类型配,不一定都是1000 } int little = BitConverter.ToInt32(buf, offset); return little / 1000m; }

这个函数只处理了一个价格字段,实际包里还有昨收、今开、最高、最低、买一卖一价等十几个字段,每个都要走一遍类似的转换。经验是不要相信"这个DLL一定是小端"或者"所有价格都乘1000"这种经验之谈。我一般会在解析器里做一个校准模式:先拿一只价格明确且带小数的股票做对比,价格字段能对上再批量信任,对不上说明定点系数配错了,趁早查配置。

4.2 表结构设计:快照表、成交明细表和逐笔委托表三分

落库结构上,见过最稳的是拆成三张表,别想着把推送的所有字段塞进一张宽表。快照表存的是某一刻的状态快照,更新频率极高;成交明细表是逐笔成交,插入频率更高而且只增不改;逐笔委托表讲究更细,一般采集器都放在后面考虑。三张表的主键设计也不一样。

表名主键典型字段
snapshot_stock(ts, code)时间、代码、开盘、最高、最低、现价、成交量、买一卖一五档
trade_detail(ts, code, seq)时间、代码、成交价、成交量、买卖方向
order_detail(ts, code, seq)时间、代码、委托价、委托量、委托类型

快照表主键必须加时间维度。同一毫秒内行情可能多次变动,每股每秒都可能推送多帧,你要是只拿代码当主键做覆盖写,历史数据就被抹掉了,后面想回放等于没数据。成交明细表要用序列号做辅助去重,防止主站重复推送或者TCP重传导致同一笔成交写两次。

4.3 写库的压力控制:回调只入队,写库线程批量提交

数据库写入是实时采集器最容易翻车的地方。每次推送回调都直接执行Insert,行情一活跃,数据库连接池被打满,回调阻塞,很快就被主站断开。解决思路是生产者-消费者模式:回调入队,后台线程定时批量提交。

// 批量落库线程:每500毫秒或攒够512条触发一次写入 private static async Task FlushToDatabaseAsync(CancellationToken token) { var batch = new List<SnapshotStock>(512); while (!token.IsCancellationRequested) { while (_queue.TryDequeue(out var item) && batch.Count < 512) { batch.Add(item); } if (batch.Count > 0) { try { // 用Dapper的Execute或EF的批量扩展,不要单条Insert await BulkInsertSnapshotsAsync(batch, token); } catch (Exception ex) { Log($"Bulk insert failed: {ex.Message}"); // 写入失败要保留数据,不能直接Clear,重新放回队列或写本地缓冲 } batch.Clear(); } await Task.Delay(TimeSpan.FromMilliseconds(500), token); } }

这里有两个容易被忽略的细节。一是批量写失败时不能直接把batch丢了,否则数据莫名消失且没有任何痕迹。我会把失败的batch写进本地文件缓冲,等连接恢复后再重放。二是积压有上限,不能无限制堆积,否则进程内存会被撑爆。队列长度超过1万条时,选择丢弃最旧的数据并计数告警,宁可少一小段行情也不能让进程OOM。

5. 实时数据采集器常见故障排查:5个让我翻车的地方

5.1 现象:一加载TdxHqApi.dll就抛BadImageFormatException

原因是DLL是32位的,而C#工程编译成了AnyCPU或x64。在64位系统上加载32位DLL,CLR直接抛格式不正确异常。这个报错内容很长很吓人,新手很容易往"缺少VC运行库"、"依赖缺失"方向排查。解决是进入项目属性把平台目标设为x86,且确保宿主进程也是32位。我把采集器注册成Windows服务后还踩过一次,服务管理器里启动的进程不一定继承主机的平台配置,所以入口处要主动打印Environment.Is64BitProcess,日志里能一眼确认当前进程位数。

5.2 现象:连上主站后几十秒就断,或者订阅后完全没有推送

这个现象要分两类排查。连上就断,大概率是心跳保活没做或间隔太长,主站识别的空闲连接超时主动断开。完全没推送,几乎可以锁定在订阅代码格式上,比如把600000传成了没有交易所后缀的裸代码,主站匹配不到股票,就不推行情,而DLL在订阅环节通常不返回错误,看起来就像"连接正常,但数据为空"。解决方法是抓包看TCP层:有包且被断开,去修心跳;完全没数据包,把代码格式统一成600000.SH、000001.SZ这类带后缀的写法,并在订阅后立刻打印返回码。我第一次做这个方向时,就在裸代码上浪费了一晚上,属于最容易忽略的静默故障。

5.3 现象:解析出的价格和行情软件里对不上,差一个数量级或固定小数位

原因基本是定点数比例系数不对。通达信协议里价格字段的隐式小数位因消息类型甚至品种类型而异,股票、指数、债券用的系数可能不一样。照抄网上某份解析代码按统一系数除,必然对一部分品种翻车。解决方式是把系数做成配置化表,按品种类型分别配。落地时写一个对拍校验工具:解析结果里随机抽20只股票,和行情软件里显示的现价、均价逐行对比,系数错一眼就能看出规律。

5.4 现象:更新脚本时发现TdxHqApi.dll被占用,无法覆盖

原因是采集器进程没退出,或者退出后还有线程没完全释放DLL模块句柄。Windows对正在被进程加载的DLL文件会加锁,直到所有引用它的进程退出。很多运维习惯直接杀掉进程就复制文件,但进程刚死那几秒句柄可能还没释放。解决方法是不要原地覆盖:先停服务,再复制,再启动,分三步走。更省心的是给DLL文件名带版本号,配置里指定加载哪个版本,更新时把配置指向新版本,旧文件等下次维护窗口再清理。这样还能随时回滚,相当于给采集器留了后悔药。

5.5 现象:多线程环境下行情数据错乱、内存持续上涨

数据错乱是因为DLL回调线程和主线程共享了非线程安全的集合,或者虽然是线程安全集合但用了错误的使用方式。内存上涨是因为行情数据被某种方式持有,迟迟没有释放。这类问题通常要在采集器运行几小时甚至几天后才会暴露,属于慢性病。解决方法是所有共享中转集合一律用ConcurrentQueue、ConcurrentDictionary,并且明确这些集合只做中转不做存储,落库后立即移除引用。同时给队列设置硬上限,超过就丢弃最旧数据并计数告警,宁可让监控发现丢了几个Tick,也不能让进程被OOM杀掉。

6. 把StockRealData做成常驻服务:进程守护、数据保鲜自检和升级技巧

如果采集器只是在自己电脑上偶尔跑一跑,上面那些坑最多浪费点时间。但当它要部署到无人值守的服务器上,连续运行几个月时,还需要一套服务化的保障层。

第一个技巧是进程守护。不要把EXE裸奔在桌面上,要注册成Windows服务。SCM能帮你做崩溃重启。用sc create StockRealData binPath= ...创建服务,在服务属性里把"失败后重启"设为"重新启动服务",进程崩溃后最多断几分钟就能拉起来。服务账户如果用LocalSystem,网络权限没问题,但工作目录容易变到系统目录,DLL的相对路径就会找不到配置文件。最笨但最有效的做法,是在代码里用AppDomain.CurrentDomain.BaseDirectory拼接所有文件路径,强制与EXE所在目录绑定。

第二个技巧是数据保鲜自检。实时行情最怕的不是数据错,而是"看起来一切正常,实际上已经停更半天"。写一个独立的监控逻辑,定期查询数据表里最新行情时间戳和当前时间的差值,超过90秒就判定采集器失联,触发告警和自动重连。

// 每60秒检查一次最新行情新鲜度,超阈值自杀重启,让系统接管 private static async Task FreshnessWatchdogAsync(CancellationToken token) { while (!token.IsCancellationRequested) { var latestTs = await GetLatestQuoteTimestampAsync(token); if (DateTime.Now - latestTs > TimeSpan.FromSeconds(90)) { await ReconnectAsync(token); // 重连两次无效就退出进程,让Windows服务管理器拉起 } await Task.Delay(TimeSpan.FromSeconds(60), token); } }

第三个技巧是日志轮转和升级回滚。采集器长时间运行,日志增长很快,我按天分文件,只保留最近7天,老日志自动清理。DLL升级时采用带版本号的文件名加配置切换,避免文件占用导致的更新失败。这套组合做下来,采集器基本达到"只坏网络不坏程序"的稳定状态。

做了几年行情采集,我最大的教训是:实时采集器的崩溃往往不在技术难点,而在对静默故障的容忍度太低。你可以优化心跳、优化批量落库、优化线程模型,但想让它长期稳定运行,一定要有自检机制和进程守护,时刻知道自己采集的数据到底还新鲜不新鲜。希望这些经验能帮你少走几条弯路,把StockRealData稳稳当当地跑起来,做成一条可靠的实时数据管道。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询