
简介面向个人学习者与二次开发者的实时数据采集器实现方案核心是借助 TdxHqApi 动态库完成通达信实时行情的数据抓取与解析。压缩包共 299 个文件约 110.7MB以 84 个 C# 源码文件和 21 个 Java 源码含编译后的 class为主线另有 23 个 DLL 库、13 个配置文件、23 个文本说明以及少量 PDF/DOC 辅助文档和 zip/rar 备份压缩包兼顾源码、接口、配置、说明与备份目录结构完整清晰。随附 ASP.NET 全局入口、批量运行脚本和封装好的行情 API 类可帮助学习者快速掌握动态库加载、参数配置、请求封装与返回数据解析的完整流程也能对照 C# 与 Java 两种调用路径理解界面层、业务层与行情接口之间的调用关系打开后即可按工程结构定位关键代码适合作为入门参照或二次改造基础。已有 54 人浏览学习提醒资源仅供学习交流不可商用。1. 一个实时数据采集器为什么非要借用 TdxHqApi 的 DLL做行情数据采集的人基本都绕不开一个尴尬上证、深证的实时快照数据官方渠道要么门槛高、要么费用贵个人开发者想拿一份稳定、低延迟的实时行情做策略验证或数据积累能选的路并不多。TdxHqApi 这个 DLL 就是在这种情况下被盯上的——它是通达信行情服务对外暴露的接口库按 TCP 协议直接跟行情服务器通信能拿到沪深两市的实时快照、分笔成交、K 线历史和财务数据。StockRealData 这类采集器本质上就是把这份 C 接口的 DLL 包进自己的进程按约定协议发出请求、解包响应再把数据整理成自己需要的结构。这套做法能被从业者接受核心原因有三点第一DLL 本身就是行情协议的解码器省去了自己抓包逆向协议的工作量第二接口按固定端口和包格式通信只要按文档组织字节流能拿到结构化数据第三采集器可以只依赖一个 DLL 文件运行部署成本低不依赖完整客户端安装。适合谁适合要做本地分钟级或秒级行情落库的量化研究团队、做数据清洗的工程师、给内部系统补行情源的开发人员。不适合指望拿它做生产级高频交易通道的人——这一点后面会反复说。实际落地的时候采集器的工作远不止“加载 DLL 然后调函数”这么简单登录态的维持、心跳超时、订阅字段的取舍、断线重连、数据落盘的时序每一层都有坑。下面就从接口模型开始把整个方案拆开讲。2. TdxHqApi 的调用模型为什么行情采集器都绕不开这个 DLL2.1 接口约定与握手机制TdxHqApi 对外暴露的是一组 C 风格函数典型的几个关键函数是连接服务器、发送请求包、接收响应包、断开连接。它的工作方式不是普通的 HTTP 请求-响应而是维护一条长 TCP 连接客户端发送二进制请求包服务端返回二进制响应包。每个包都有固定包头包含字段包长度、包序号、包类型等。调用方要做的是正确地构造请求体、解析响应体。一个最常见的连接流程是先用 IP 和端口连接行情服务器之后发送登录请求包有的服务器并不强制登录但建议做因为能拿到更完整的授权数据登录成功后进入数据订阅阶段最后按需轮询请求快照或分笔数据。整个过程是同步阻塞式的——你发一个包等着收一个包。对采集器来说这种模型反而简单不需要处理复杂的异步回调只要把收发循环写稳就行。我一般会用 C# 做 P/Invoke 封装来调用这套 DLL。相比 CC# 在结构体布局和字节解析上更省力调试和热更新也方便。对个人开发者来说用 C# 写 StockRealData 这类工具比 C 舒服得多。下面是加载和连接的关键片段[DllImport(TdxHqApi.dll, CallingConvention CallingConvention.StdCall)] private static extern int TdxHq_Connect(string ip, short port); [DllImport(TdxHqApi.dll, CallingConvention CallingConvention.StdCall)] private static extern int TdxHq_Disconnect(); [DllImport(TdxHqApi.dll, CallingConvention CallingConvention.StdCall)] private static extern int TdxHq_Send(int msgType, byte[] buffer, int len); var ret TdxHq_Connect(119.147.212.81, 7709); if (ret 0) { // 0 表示连接成功非 0 要看错误码表 Console.WriteLine(connected); }注意这里的端口是 7709这是通达信行情服务器常用的备用端口。如果你连不上先用 telnet 验证 IP 和端口的连通性再查 DLL 的日志输出。很多“连不上”的问题其实是服务器 IP 已经失效——这类 IP 列表本身变动频繁最好做成配置项启动时自动轮询。连接成功之后是登录。登录包有固定格式包含用户名、密码、机构号等字段按字节序填充计算好包长度后通过TdxHq_Send发出。接收侧用一个循环调用接收函数按包头长度截断一条完整消息后交给解析器。这一步踩坑最多的地方在于接收函数一次可能只返回半包必须用缓冲区累积。这个问题在避坑章节会专门展开。2.2 数据协议与缓存设计DLL 返回的数据不是普通的结构体数组而是打包过的二进制流。以快照数据为例每个字段在协议里按固定偏移排列——最新价、昨收、今开、成交量、买卖五档每一项都有明确的起始字节和长度。用 C# 封装时建议直接用[StructLayout(LayoutKind.Sequential, Pack 1)]定义结构体然后用Marshal.PtrToStructure把字节流转成结构体这种做法的性能比逐字节BitConverter快得多代码也更干净。结构体定义参考这样写[StructLayout(LayoutKind.Sequential, Pack 1)] public struct StockSnapshot { public int MarketType; // 0深圳1上海 public uint Price; // 最新价单位是分需除以100 public int Open; public int High; public int Low; public int LastClose; public long Volume; // 累计成交量股 public long Amount; // 成交额元 [MarshalAs(UnmanagedType.ByValArray, SizeConst 10)] public int[] BidPrices; // 五档买价 [MarshalAs(UnmanagedType.ByValArray, SizeConst 10)] public int[] BidVolumes; }价格字段单位是分这一点新手非常容易忽略解出来 12345 实际上对应 123.45 元。如果直接存库后面算涨跌幅会整体差 100 倍这种问题属于典型的不报错但全错。缓存设计上采集器的结构一般是“收包 - 原始字节缓冲 - 结构体数组 - 批量落库”。DLL 收上来的包频率不低逐条写数据库的 IO 成本太高常见的做法是内存里攒够一定条数或者间隔固定秒数比如 5 秒或 10 秒再批量写。采集器进程内部还要留一个最近几秒的“热缓存”因为有的使用方会在盘中要求“回看两秒前快照”或“算滚动均价”直接从库里查太慢内存环形缓冲更合适。行情采集不是简单的转发而是一个有状态管道。这个认识会贯穿后面所有设计决策。3. 搭建 StockRealData 的最小可运行工程从加载 DLL 到落库3.1 工程目录与轮询调度一个能跑起来的 StockRealData 最小工程至少包含四部分加载和封装 DLL 的适配层、服务器连接管理、轮询调度器、数据落库模块。我用 C# 的 .NET 6 控制台项目做示例DLL 放在项目根目录下的native文件夹里编译后自动拷贝到输出目录。轮询调度是采集器的核心。通常的做法是按固定间隔遍历自选股列表逐只发送快照请求。这个间隔不能太短否则服务器会掐掉你的连接也不能太长否则拿到的时间序列不连贯。我的经验是单线程轮询时每只股票的最小间隔设为 3 秒。不要试图用多线程并发请求同一只股票服务器端对同一连接有请求频率限制触发后直接踢下线这个用血泪经验换来的教训后面细说。调度器可以采用简单的System.Timers.Timer每 tick 遍历一个股票队列。但要注意遍历周期和 tick 间隔是有节奏关系的——如果 100 只股票、每只 3 秒间隔一轮需要 300 秒。分片轮询更合理把股票列表切成 N 组每个 tick比如每 3 秒只请求一组。这样既能控制频率又能保证每只股票都有一个稳定的采样节奏。一个小的调度片段var stockCodes File.ReadAllLines(stock_list.txt); int groupSize 20; int currentIndex 0; Timer timer new Timer(3000); // 每 3 秒 tick 一次 timer.Elapsed (s, e) { var group stockCodes.Skip(currentIndex).Take(groupSize).ToList(); if (group.Count 0) { currentIndex 0; return; } foreach (var code in group) { var req RequestBuilder.BuildSnapshotRequest(code); TdxHq_Send(0x4A, req, req.Length); // 0x4A 是快照请求的类型 // 注意这里只发送接收在另一个线程处理 } currentIndex groupSize; if (currentIndex stockCodes.Count) currentIndex 0; }; timer.Start();发送请求和接收响应不能放在同一个线程里做“发一个、等一个”的同步操作否则网络往返的 RTT 会拖垮整个调度节奏。发送线程只管按节奏发接收线程循环收包、解包、写缓存。这里的0x4A是快照请求的消息类型不同版本的 DLL 可能定义不同最好封装一个请求构造类统一管理不要散落在业务代码里。定时器都是单线程的如果你的组内股票数量太大一个 tick 内发不完所有请求下一次 tick 到来时前面还有积压最终会把连接撑爆。所以分组的核心原则是确保单 tick 内的请求总耗时远小于 tick 间隔。3.2 接收与解析把字节流转成业务对象接收循环单独开一个线程持续调用 DLL 的接收函数把字节累积进内存流按包头长度切出完整包交给解析器。常见的问题是DLL 接收函数返回的数据条数是不定的可能一次拿到多条消息也可能一条消息分多次到达。用MemoryStream缓存最好处理。这里给出接收线程的骨架byte[] buffer new byte[8192]; MemoryStream pending new MemoryStream(); while (true) { int len TdxHq_Recv(buffer, buffer.Length); if (len 0) continue; pending.Write(buffer, 0, len); pending.Position 0; while (pending.Length - pending.Position 16) // 至少包头大小 { BinaryReader reader new BinaryReader(pending); int totalLen reader.ReadInt16(); // 包头前两位是总长度 if (totalLen 0 || totalLen 4096) { // 数据异常丢弃整个缓存重新开始 pending.SetLength(0); break; } if (pending.Length - pending.Position 16 totalLen) { // 半包等下一轮继续积累 pending.Position pending.Position - 16; break; } byte[] packet reader.ReadBytes(totalLen - 16); var snapshot SnapshotParser.Parse(packet); _snapshotCache.AddOrUpdate(snapshot.Code, snapshot); } byte[] remain pending.ToArray(); pending.Clear(); pending.Write(remain, 0, remain.Length); }代码里判断包头长度、半包的逻辑是必须的不然后续解析全部错位。注意这里我留了 16 字节给包头本身具体数值要按实际 DLL 的包头定义调整不同版本可能有差异写死之前先抓包看一眼十六进制头两字节是不是包长。解析模块的目标是把字节流变成干净的 C# 对象。最省力的做法是定义结构体后Marshal.PtrToStructure但前提是结构体布局和协议完全对齐。如果解析出来价格明显不合理用一个 hex dump 工具导出原始响应拿协议对照表手工比对一个字段这是排查结构体错位最快的方式。3.3 落库设计与增量更新落库的推荐方式是 SQLite。采集器单机运行、写入频率不高、查询维度单一SQLite 完全够用还能避免部署 MySQL 或 PostgreSQL 的运维成本。数据库表至少要有两张一张stock_realtime存最新快照主键是股票代码每次更新覆盖一张stock_history存历史采样点按时间代码做复合主键插入不覆盖。写入采用批量事务每攒满 50 条或者 5 秒执行一次避免每条一次 INSERT 的事务开销。下面是批量写入的简化逻辑using (var conn new SQLiteConnection(Data Sourcestock.db)) { conn.Open(); using (var tx conn.BeginTransaction()) { foreach (var snap in batch) { var cmd conn.CreateCommand(); cmd.CommandText INSERT OR REPLACE INTO stock_history (time, code, price, open, high, low, volume, amount) VALUES (time, code, price, open, high, low, volume, amount); cmd.Parameters.AddWithValue(time, snap.Time.ToString(yyyy-MM-dd HH:mm:ss)); cmd.Parameters.AddWithValue(code, snap.Code); cmd.Parameters.AddWithValue(price, snap.Price / 100.0); // 其余字段类似 cmd.ExecuteNonQuery(); } tx.Commit(); } }INSERT OR REPLACE在这里是刻意的如果同一秒内采集到两次同一只股票的数据用最新值覆盖旧值避免重复键冲突导致整个事务回滚。如果你的策略里需要保留同一秒多次采样比如高频校验改成INSERT OR IGNORE或者去掉冲突处理但要注意主键设计。增量更新指的是已经落库的数据不需要重建。启动采集器时先读一次stock_history里每只股票的最新时间后续轮询如果服务器返回的时间戳早于这个值就丢弃避免因为断线重连造成的重复数据覆盖了更新的记录。这是采集器数据可信度的基本保障。另外一个细节是盘中重启采集器。如果不做增量判断重启后第一次全量请求会把当前快照写入历史表和之前最后一条记录形成两个相距几十秒但中间缺失的采样点分析时会被误判为停盘或者跳空。加了增量判断后重启产生的第一条记录会从“新时间大于旧时间”才开始写数据序列是连续的。4. 实时采集的避坑清单DLL 加载失败、粘包与断线重连4.1 DLL 加载失败与初始化例程崩溃现象程序启动时报DllNotFoundException或者加载后第一次调用函数进程直接闪退Windows 事件管理器里能看到0xC0000005访问违规。原因大部分不是代码问题而是 DLL 的依赖链没满足。TdxHqApi.dll 内部依赖了 VC 运行库和若干系统 DLL目标机器上缺这些运行库时加载会失败或初始化崩溃。另一个常见原因是 32 位/64 位不匹配——TdxHqApi 常见的分发版本是 32 位的如果你的 .NET 程序编译成了 x64加载时会直接报错。解决首先确认进程是 x86 还是 x64在项目文件中显式声明PlatformTargetx86/PlatformTarget并在PreBuild事件里把 DLL 拷贝到输出目录。其次安装 VC 2015-2022 Redistributable x86 版本。如果还不行打开 DLL 的依赖检查工具比如 Dependencies.exe看缺失的依赖项分别是什么。不要在这上面浪费太多时间玄学排查依赖库问题基本上是环境问题不是代码问题。4.2 数据包粘包与解析错位现象采集器运行一段时间后解析出来的最新价突然出现几百万的离谱值成交量变成负数或者某只股票的数据永远解不对。原因这是典型的粘包/半包问题。DLL 接收函数返回的是底层 TCP 缓冲区的当前内容不保证每次返回刚好一条或多条完整消息。如果你直接按“一次接收一条消息”来解析就会发生错位。解决接收线程里必须做字节流缓存按包头长度截包。代码参考 3.2 节。重点在于半包时不能把已读的半条数据丢弃要保留在缓存中等待剩余字节到达异常包导致长度字段不可信时要有同步头扫描机制——按字节滑动找到合法的包头特征再继续解析。没有这种兜底程序偶尔的解析错位会像定时炸弹一样潜伏在数据里。我还遇到过一种更难查的变体DLL 的 16 位包头是短整型当包长度超过short.MaxValue时读出来是负数导致解析逻辑认为数据非法直接清空缓存。这种情况要改用ushort来读长度不要看见负数就丢包。4.3 断线重连与订阅失效现象程序连续运行几小时后所有请求没有响应或者连接断开后重连成功但之前能收到的快照全部收不到了。原因行情服务器有连接空闲超时机制。采集器如果长时间只发请求不收响应或反过来收而不发会被服务端判定为死连接并主动断开。另一种情况是重连新建的连接没有重新发送登录包和订阅请求服务器不会主动推数据。解决实现两层心跳。业务层每 30 秒发送一次查询请求可以用查询服务器时间或查一只不重要的股票连接层每 15 秒检查一下连接状态或者发一个保活包。检测到断开后整套流程要按顺序恢复关闭旧连接、重新 connect、重新 login、重新订阅股票。这里最容易漏的是订阅恢复——如果采集器代码把“订阅”和“连接”耦合在初始化流程里重连时很可能不会自动重建订阅。重连逻辑应该独立抽成一个状态机。一个额外的注意点重连后第一次数据请求的时间戳可能会跳变。如果你在落库时不加时间戳校验重连瞬间会把服务器当前快照当作最新数据覆盖掉本地最近记录造成假象。这也是 3.3 节里增量判断能兜住的关键场景。4.4 内存与句柄泄漏现象采集器连续运行 24 小时后内存占用从 80MB 涨到 1.2GB或 TCP 连接数和线程句柄数持续攀升。原因最常见的是接收线程里MemoryStream不断扩容但不释放。3.2 节代码里pending.ToArray()之后如果不清空底层缓冲区每次调用都会把容量扩展成最大数据量最终积累成巨大的内存占用。另一个嫌疑是TdxHq_Recv每次返回的byte[]如果用Marshal.AllocHGlobal分配了就一定要FreeHGlobal否则进程内存只涨不降。解决接收线程里的缓存对象用完后SetLength(0)而不是新建对象所有非托管内存分配成对出现。给采集器加一个简单的指标输出每 5 分钟打印一次进程工作集和线程数连续观察 4 小时以上如果曲线单调不减就按这个思路排查每一项资源。还有一个容易忽视的问题是日志文件。有的采集器会把每次收到的原始包 dump 到磁盘盘子写满后程序开始报错。日志一定要做滚动切割按天留档最多保留 7 天不要养成无限写日志的习惯。5. 把采集器调到经得起长时间跑几个关键技巧与验证方法运行稳定和数据准确是两件不同的事。先把监控项补齐再谈优化。我会在采集器里加一个“数据新鲜度”指标对每只订阅的股票记录当前时间与最后一次有效快照时间之差。这个差值超过 10 秒就报警。比单纯检查 TCP 连接状态有效得多——连接是通的不代表数据还在流动服务器踢了你没踢断也会表现为“连接活着但数据不更新”。用第一人称讲一个习惯我在项目里给 OSD 面板加了这个数以后才第一次在半夜抓到了某个行情源 40 秒无数据的真实故障。那一刻我就确认了所有采集器都应该默认带数据新鲜度监控。第二个技巧是给历史表加一个received_at字段用它和行情时间time做对比。正常情况下两者之差应该在几秒内如果经常出现几十秒甚至几分钟的滞后说明你的轮询节奏太慢或者中继服务器本来就延迟高这个指标能间接暴露链路问题。盘中漂移超过 5 秒的数据在入库时就打上“延迟”标记策略计算的时候可以过滤掉避免脏数据影响指标计算。关于轮询间隔最后提一组我实际试过的边界值作为参考不要照抄要按自己的场景验证场景间隔效果日线级复盘60 秒数据稳定服务器负载低分钟级趋势5-10 秒能看清节奏但要接受 1-2 秒的时序抖动秒级实验3 秒及以下容易被服务端限流长时间跑大概率断线我用下来个人项目的默认值是 5 秒。低于 3 秒后的收益大多被网络抖动吃掉得不偿失。多实例扩展是另一个话题——如果你真的要覆盖几百只股票且间隔很短与其提高采样频率不如横向开多个采集器实例每台连不同的服务器账号或者不同的 IP 段分摊请求量。但这会把数据合并的复杂度推给你自己不是免费的。采集器按代码前缀分区比如 60 开头和 00 开头的分开跑入库时用同一台 SQLite 服务和统一写入接口合并成本会低很多。最后说一句我的筛选习惯任何依赖于 TdxHqApi 的采集器上线前先连续跑 48 小时不重启再盯着数据新鲜度曲线看一段完整交易时段。大多数翻车都不是发生在刚启动时而是在第 6 小时、第 15 小时这种无人值守的深夜时段。把问题留在测试里不要留在交易日的开盘现场。希望帮到你。本文还有配套的精品资源点击获取