ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Trade.dll与TradeX.dll交易接口实战:从回调线程到收盘对账

Trade.dll与TradeX.dll交易接口实战:从回调线程到收盘对账

简介:面向量化开发、程序化交易及通达信接口集成人员的接口资料包,围绕 Trade.dll 与 TradeX.dll 展开:既包含老版交易接口,也提供行情交易二合一接口,核心思路是通过 TdxTradeServer 将动态库调用封装为 HTTP 接口,便于在自建服务中完成自动交易与行情获取;适合具备一定原生开发基础、需要把客户端接口接入自有交易系统的开发者参考。压缩包共 78 个文件,约 61.61MB,以动态库、授权文件、源码、工程文件、演示程序和说明文档为主,另含运行库、服务器列表及多语言示例,覆盖多种语言接入与运行环境配置。已有 6623 人学习,适合用作接口联调、动态库调用验证、服务端封装与交易流程自动化的参考资料;也可据此梳理新旧接口的目录组织、授权文件放置、依赖加载顺序及示例工程结构,减少从零搭建和排错成本。

1. 开盘前 9:15 那三分钟:Trade.dll 与 TradeX.dll 到底解决什么问题

集合竞价前的三分钟,行情在跳、委托队列在刷,很多人的第一版程序就卡在这里:界面还能动,单子发不出去,回调一条不来。这类问题八成不是策略写错了,而是没有把底层接口的职责边界理清。Trade.dll 是一个纯交易的 TDX 交易接口封装,负责登录、下单、撤单、查资金持仓和回报推送;TradeX.dll 是行情交易二合一接口,把行情订阅和交易委托放在同一个连接上下文里,省掉两个进程之间的数据搬运。适合做自动化委托、盘中监控、批量调仓、算法下单的从业者,也适合想把行情触发和委托动作收进一个进程的开发者——前提是你清楚它为什么这么设计,以及哪里会翻车。

2. 两个 DLL 的分工:导出表、调用约定与结构体对齐

在动手写第一行代码之前,先把这两个文件当成对象看,不要当成一个“能下单的黑盒”。它们抽象层次不同,适用的场景也不同。搞错这一层,后面写的所有代码都要返工。

2.1 Trade.dll 只干交易这一件事

纯交易接口的设计哲学是单一职责:连接柜台、维持会话、发委托、收回报。它不订阅行情,不做行情分发,也不关心行情快照怎么合并。这种拆分不是洁癖,是运维需求——行情端可以随时重启,交易端的会话不能断;行情端处理几万条推送时,交易的委托链路不应该被拖累。

从接口形态看,Trade.dll 常见导出函数分四族:初始化与登录(Init、Login、Logout)、查询类(QueryBalance、QueryPosition、QueryOrder)、委托类(Order、Cancel)、回调注册类(RegisterCallback、SetNotify)。这四族函数基本按“先建连、再认证、再订阅、再操作”的顺序使用,跳跃调用一般直接返回未登录错误码。

选型判断很简单:如果你的策略是定时调仓或人工确认后下单,交易和行情分开部署更稳;如果是行情满足条件瞬发委托,行情到交易之间的进程间跳转是明确的开销,要么走共享内存,要么就用二合一。

提示:纯交易接口的会话通常有保活机制,长时间不调用查询类函数会被柜台判定空闲而断链,生产上一般用 15~30 秒一次的轻量心跳查询来维持。

2.2 TradeX.dll 把行情和交易合到一层的原因

二合一的好处集中在三点。第一,上下文统一:一个登录态同时能拿行情、发委托,省掉账户凭证和会话 ID 的跨模块传递。第二,时序确定:行情回调里拿到的最新价,和随后发出的委托,落在同一条内部消息链上,不需要靠时间戳猜。第三,部署简单:一个进程、一份配置、一套日志,个人开发和小团队维护成本明显低。

代价也要讲清楚。行情洪峰和委托回报共用一套回调线程池时,如果回调处理慢,委托回报会被行情推送压住,出现“单子已经成交了但回报延迟几秒”的假象。另一个问题是崩溃域耦合:行情解析模块一旦异常,交易进程一起退出。所以二合一适合中等频率、单账户或少量账户的场景;高频多账户一般还是拆开,用独立进程加可靠的进程间通道。

实际选型里我会看两个指标:每秒行情推送条数,以及日内委托笔数。推送在千条每秒以内、委托几百笔的,二合一完全够用;推送上万条还要求委托低延迟的,老老实实拆进程。

2.3 先把导出表拉出来再看代码

不要相信任何文档上的函数名,直接看二进制。Windows SDK 自带的 dumpbin 就够用,重点看三样:导出函数名、函数修饰方式(决定调用约定)、依赖库(决定运行时环境)。

# 1) 导出表:函数名、序号、修饰形式 dumpbin /EXPORTS Trade.dll > exports_trade.txt dumpbin /EXPORTS TradeX.dll > exports_tradex.txt # 2) 依赖库:看是否缺 VC 运行时或三方库 dumpbin /DEPENDENTS Trade.dll # 3) 位数:x86 还是 x64,决定宿主进程必须同位数 dumpbin /HEADERS Trade.dll | findstr /C:"machine"

三条命令的逻辑说明:/EXPORTS输出的是动态库对外暴露的符号表,这是唯一可信的调用清单;/DEPENDENTS列出的是加载期依赖,缺一个就会在LoadLibrary时直接失败,错误码通常是 126;/HEADERS里的 machine 一行决定位数,x86 的 DLL 塞进 64 位进程里加载必然失败,而且报错信息往往很含糊。

判断调用约定有个土办法:导出名形如_Init@8是 stdcall(32 位下带 @ 加参数字节数),形如Init且没有修饰的多为 cdecl + extern "C",形如?Init@@YGXXZ的是 C++ 修饰名,需要用 undecorated 名或序号取地址。ctypes 里 stdcall 用WinDLL,cdecl 用CDLL,选错的表现是进程直接崩,没有异常堆栈,这是典型的血泪经验。

常见的导出函数命名族大致如下,实际名字以导出表为准:

功能族典型命名参数特征备注
初始化Init / Open配置路径或结构体指针每进程调用一次
登录Login / Logon账号、密码、超时部分实现需要二次确认
查询Query*输出缓冲区指针同步返回fill,或异步回调
委托Order / Insert委托结构体指针注意数量单位
撤单Cancel委托引用号需要原委托号
订阅Subscribe / Reg合约代码数组有数量上限
回调注册Register / SetNotify函数指针 + 用户上下文生命周期要管住

注意:导出表里如果同时存在InitA和InitW,说明是宽窄字符两套,Python 侧调用InitW要传c_wchar_p,混用会读到乱码路径然后初始化失败。

3. 跑通第一个连接:位数匹配、初始化时序与最小骨架

这一章的目标很朴素:让程序能登录成功,并且能干净地退出。别小看“干净退出”,后面第五章有一条坑就是退出顺序导致的崩溃。

3.1 三件套:位数、运行时、工作目录

位数问题前面提过,这里说具体。宿主进程和 DLL 必须同为 32 位或同为 64 位。Python 用户尤其容易中招:机器上装的是 64 位 Python,DLL 是 32 位的,WinDLL直接抛OSError: [WinError 193] %1 不是有效的 Win32 应用程序。解决办法不是改 DLL,而是换一个同位数解释器,或者用 32 位宿主做一个独立的小服务,主程序通过本地通道调用它。

运行时依赖看/DEPENDENTS的输出。如果看到MSVCP140.dll、VCRUNTIME140.dll而目标机没有,就要装对应的运行库;如果看到某个三方行情或加密库,那必须把这个库放在 DLL 同目录或系统能找到的路径。DLL 搜索顺序里有“应用程序目录”这一项,所以最省事的做法是把所有依赖和 exe 放同一目录。

工作目录也要定死。很多接口的配置、证书、日志路径是相对路径,从 IDE 里启动和从计划任务里启动,工作目录不同,表现就是“我这儿好好的,服务器上一启动就登录失败”。我一般会在程序入口处显式SetCurrentDirectory到 exe 所在目录。

3.2 C++ 最小骨架:从 LoadLibrary 到 Login

下面这段是 32 位控制台程序的骨架,函数名用占位名,实际要按你的导出表替换。编译时用/MT静态链接运行时,能省掉目标机装 VC 运行库的麻烦。

// trade_min.cpp —— 32 位控制台程序,DLL 与 exe 同目录 #include <windows.h> #include <cstdio> // 函数指针类型:参数与返回值必须和导出函数严格一致 using InitFn = int(*)(const char* cfgPath); using LoginFn = int(*)(const char* account, const char* pwd, int timeoutMs); using LogoutFn = int(*)(void); // 回调:事件类型、载荷、用户上下文 using CbFn = void(*)(int evt, const char* payload, void* user); using RegCbFn = int(*)(void* user, CbFn cb); static void OnEvent(int evt, const char* payload, void* user) { // 回调里只做拷贝,绝不做耗时操作 printf("[evt=%d] %s\n", evt, payload ? payload : ""); } int main() { // 1) 加载:找不到依赖时返回 NULL,用 GetLastError 区分 126/193 HMODULE h = LoadLibraryA("Trade.dll"); if (!h) { printf("LoadLibrary failed: %lu\n", GetLastError()); return 1; } // 2) 取地址:名字必须与 dumpbin 输出完全一致 auto pInit = (InitFn) GetProcAddress(h, "Init"); auto pLogin = (LoginFn) GetProcAddress(h, "Login"); auto pLogout = (LogoutFn)GetProcAddress(h, "Logout"); auto pReg = (RegCbFn) GetProcAddress(h, "RegisterCallback"); if (!pInit || !pLogin || !pLogout || !pReg) { printf("GetProcAddress failed\n"); FreeLibrary(h); return 2; } // 3) 时序:注册回调 -> 初始化 -> 登录,顺序反了部分实现会丢早期事件 pReg(nullptr, &OnEvent); if (pInit("./trade.ini") != 0) { printf("init failed\n"); FreeLibrary(h); return 3; } if (pLogin("ACC001", "******", 5000) != 0) { printf("login failed\n"); pLogout(); FreeLibrary(h); return 4; } // 4) 业务逻辑占位,生产环境这里是消息循环 Sleep(3000); // 5) 退出顺序:先登出,再反注册,最后释放 pLogout(); pReg(nullptr, nullptr); FreeLibrary(h); return 0; }

逻辑说明:整段的核心是四步时序——注册回调、初始化、登录、登出。回调要先注册,是因为部分实现在 Init 阶段就会抛出配置加载事件,注册晚了会丢。退出顺序反过来,先 Logout 再 FreeLibrary,中间留一点时间让内部线程收尾。

参数说明:Init的参数是配置文件路径,相对路径基于当前工作目录;Login的超时单位是毫秒,设太短在网络抖动时会误判失败,我一般给 5000;RegisterCallback的user指针会原样回传给回调,用它挂接自己的上下文对象,避免全局变量。

3.3 Python 侧用 ctypes 十分钟验证

不是所有验证都要写 C++。用 ctypes 做连通性验证快得多,前提是类型声明写对。

import ctypes as C import os # 32 位 DLL 必须由 32 位解释器加载,先自检 assert C.sizeof(C.c_void_p) == 4, "请换用 32 位 Python 解释器" os.chdir(os.path.dirname(os.path.abspath(__file__))) api = C.WinDLL(r".\Trade.dll") # stdcall;若导出为 cdecl 改 CDLL # 逐个函数声明签名,argtypes 不写会按 int 传参,指针会被截断 api.Init.argtypes = [C.c_char_p] api.Init.restype = C.c_int api.Login.argtypes = [C.c_char_p, C.c_char_p, C.c_int] api.Login.restype = C.c_int api.Logout.argtypes = [] api.Logout.restype = C.c_int # 回调类型:WINFUNCTYPE 对应 stdcall 回调 CB = C.WINFUNCTYPE(None, C.c_int, C.c_char_p, C.c_void_p) @CB def on_event(evt, payload, user): # 回调运行在 DLL 内部线程,这里只入队,不碰业务 print("event", evt, payload.decode("gbk", "ignore") if payload else "") api.RegisterCallback.argtypes = [C.c_void_p, CB] api.RegisterCallback.restype = C.c_int api.RegisterCallback(None, on_event) # 保持 on_event 的全局引用,防止被 GC rc = api.Init(b"./trade.ini") print("init rc =", rc) if rc == 0: rc = api.Login(b"ACC001", b"******", 5000) print("login rc =", rc) api.Logout()

逻辑说明:先做位数自检,再改工作目录,然后逐函数声明签名。argtypes这一步不能省——ctypes 默认把 Python 的 bytes 当char*传没问题,但结构体指针和回调指针不声明就会被当成 32 位 int 截断,表现为访问违例。

参数说明:C.c_char_p对应const char*,传 bytes 不传 str;C.c_void_p传 None 表示没有用户上下文;回调对象必须保持模块级引用,局部变量被回收后 DLL 再回调就是野指针,这是最常见的一类崩溃。字符串编码用 GBK 兼容老接口的默认代码页,UTF-8 传进去中文路径会乱。

4. 行情订阅与下单:回调线程模型、参数与回报匹配

能登录只是起点。真正决定这套接口能不能上生产的,是回调线程模型和委托回报的匹配逻辑。这一块写歪了,压测时问题会成倍放大。

4.1 回调注册:调用你的函数是谁的线程

DLL 内部一般有一到两个后台线程:一个收行情推送,一个收交易回报。你的回调函数就在这些线程上被执行,不是主线程。这意味着两件事:第一,回调里不能做阻塞操作(写大文件、同步网络请求、Sleep),否则会堵住整个推送通道;第二,回调里不能直接调用下单撤单接口,部分实现内部有锁,回调线程持有读锁时再申请写锁就是死锁,表现是程序卡住、CPU 归零、没有任何日志。

标准做法是在回调里只做一件事:把数据拷贝进无锁队列或线程安全队列,交给业务线程消费。行情快照这种高频数据,拷贝时只取需要的字段,别整结构体 memcpy。

import queue, threading q = queue.Queue(maxsize=200000) # 有界队列,满了就丢,保交易不保行情 def on_market(evt, payload, user): try: q.put_nowait((evt, bytes(payload))) # 只拷贝,不解析 except queue.Full: dropped[0] += 1 # 计数用于事后判断是否要扩容 def worker(): while running: evt, raw = q.get() # 在这里解析、算指标、触发下单 handle(evt, raw) q.task_done() threading.Thread(target=worker, daemon=True).start()

逻辑说明:生产端(DLL 线程)只做入队,消费端(业务线程)做解析和决策,两边彻底解耦。队列必须有界,无界队列在行情洪峰时会吃光内存然后被系统杀掉,比丢包更糟。

参数说明:maxsize按峰值推送速率乘上可容忍延迟来估,千条每秒、容忍 10 秒就是一万量级,留十倍余量;dropped计数器要暴露到监控里,丢包率超过千分之一说明消费端处理不过来了,要么加线程要么减订阅。

4.2 下单参数:价格、数量与方向的单位陷阱

委托结构体看着简单,翻车点全在单位上。数量字段有的是“股”,有的是“手”,差一百倍;价格字段有的是浮点元,有的是整数分,差一百倍;买卖方向有的用0/1,有的用1/2,填反了就是反向开仓,这个错误在实盘里代价极高。

字段常见含义常见取值典型坑
证券代码合约唯一标识"600000"带市场后缀的写法各家不同
市场交易所标识0/1 或枚举与代码前缀不一致会被拒
买卖方向买/卖0=买 1=卖 或 1=买 2=卖两套约定混用会反向
价格类型限价/市价/对手方最优枚举整型市价单在部分柜台不支持
委托价格限价单价格浮点元 或 整数分精度截断导致废单
委托数量股数或手数整型单位错误差 100 倍
委托引用号本地唯一号自增整数重复会导致回报串号

下单函数调用本身很短,真正的复杂度在参数组装。我一般会写一层适配:内部统一用“整数分 + 股数 + 枚举方向”,只在调用 DLL 前做一次转换,把单位换算集中到一个函数里,出错时只改一处。

def build_order(symbol, side, price_yuan, qty_shares, price_type=0): return OrderReq( code=symbol.encode("gbk"), market=0 if symbol.startswith(("6", "5")) else 1, # 简单前缀判断,实盘要查表 side=0 if side == "buy" else 1, price_type=price_type, price=int(round(price_yuan * 100)), # 元 -> 分,四舍五入避免截断 qty=int(qty_shares), # 统一按股,接口要手时在这里除 100 ref=next_ref(), # 本地自增号,回报匹配靠它 )

逻辑说明:所有外部输入在进入适配层前都归一化为内部单位,适配层负责转成接口要的格式。四舍五入用round而不是int,int(12.34*100)在浮点误差下可能得到 1233。

参数说明:price_type要按你的接口枚举来填,限价一般是最稳的;ref必须全局唯一且单调,进程重启后要从持久化里恢复,否则当天的回报匹配会串。

4.3 撤单与回报匹配:别拿时间戳当索引

委托回报是异步的,成交回报更晚。用“发出时间 + 代码”去匹配本地委托,在并发下单时几乎必然串号,因为两个委托可能落在同一毫秒。正确做法是本地委托引用号,发单时自己生成并记入字典,回报里带回同一个号,用它做唯一索引。

pending: dict[int, dict] = {} def send(order): pending[order.ref] = {"state": "sent", "filled": 0, "order": order} rc = api.Order(byref(order)) if rc != 0: pending.pop(order.ref, None) # 发送失败立刻清理,别留脏数据 raise RuntimeError(f"order rejected rc={rc}") def on_trade_report(ref, filled_qty, status): rec = pending.get(ref) if rec is None: log.warning("unknown ref %s, maybe restored session", ref) # 断线重连场景 return rec["filled"] += filled_qty if status in (FILLED, CANCELLED, REJECTED): pending.pop(ref, None) # 终态清理

逻辑说明:pending是内存里的委托状态机,发单登记、回报更新、终态清除三步闭环。发单返回非零必须立刻回滚登记,否则这条脏记录会一直占着内存并让后续对账出现“幽灵委托”。

参数说明:status的枚举值要对着接口文档或实测抓出来,别猜;断线重连后本地pending会丢,所以启动时要先QueryOrder把当日委托拉一遍重建字典,这一步不做,重连后所有回报都会变成 unknown ref。

5. 避坑与排查:五个血泪翻车现场

接口跑通容易,跑稳难。下面五条都是现象明确、原因清晰、解法可落地的,按发生阶段从连接到退出排列。

5.1 连接与登录阶段:返回成功不等于可用

现象一:Login 返回 0,紧接着 QueryPosition 返回未登录错误。

原因通常是会话尚未就绪。部分实现里 Login 只是把认证请求发出去,真正的柜台确认是异步回调,返回 0 表示“请求已受理”而不是“已登录”。此时立刻发查询,会被判定为无会话。

解决方式是按状态机走:Login 返回后不立刻查询,等登录成功回调(或轮询一个GetStatus直到返回已登录),再执行后续动作,并且设一个总超时,超时就重连而不是无限等。

现象二:本地跑得好好的,部署到服务器上初始化失败,错误码看不出原因。

八成是工作目录和路径问题。配置里的证书、日志、缓存路径是相对路径,从计划任务启动时当前目录是system32,文件自然找不到。解决方式是程序入口处显式切换工作目录到 exe 所在路径,配置里所有路径改成绝对路径或基于工作目录的相对路径,并且在失败时把完整路径打进日志。

5.2 回调与线程阶段:卡死往往没有堆栈

现象三:行情推送几秒后程序无响应,CPU 占用掉到 0。

这是死锁的典型特征。回调线程里调用了下单接口,而接口内部的下单路径需要等待行情回调线程释放的锁,两边互等。也可能是在回调里做了同步网络请求,把推送通道堵死,进而让内部缓冲区满、写线程阻塞。

解决方式只有一条铁律:回调只入队,业务在别的线程做,回调里不调用任何 DLL 的其他导出函数。如果确实需要在行情触发时下单,把触发信号投递给业务线程,由业务线程调用Order。

现象四:回调偶发崩溃,地址访问违例,重启后又能跑一段。

ctypes 场景下最常见的原因是回调对象被垃圾回收。局部定义的@CB装饰函数如果没有赋值给模块级变量,函数返回后引用计数归零,DLL 再回调就是野指针。C++ 场景下则是用户上下文指针指向了已经析构的对象,比如把this传给RegisterCallback,对象先于函数释放。

解决方式是:回调对象挂模块级全局,生命周期覆盖整个接口使用期;用户上下文用裸指针时,确保对象析构前先反注册;退出时先RegisterCallback(nullptr, nullptr)再释放对象。

5.3 数据与退出阶段:精度和顺序

现象五:收盘后进程偶发崩溃,或者第二天启动提示会话残留。

退出顺序错了。直接FreeLibrary而不 Logout,DLL 内部线程还在跑,代码段已经被卸载,下一跳就是崩溃。更隐蔽的是崩溃发生在退出路径上,日志来不及刷盘,看起来像“正常退出后无日志”。

解决方式是固定退出序列:停止接收新业务、等待在途委托终态、Logout、反注册回调、短暂等待内部线程退出(100~500 毫秒)、最后FreeLibrary。这一套封装成shutdown()函数,在所有退出路径(包括异常和信号)里都走同一个函数,别在main末尾手写一遍。

注意:进程被强杀时上述顺序无法执行,所以第二天启动时的“会话残留”清理要单独做——登录前先调用一次登出或会话查询,把服务端的旧会话顶掉。

6. 跑满一个交易日:日志字段、压测口径与收盘对账

接口稳不稳,跑一个完整交易日才知道。我一般不看“能不能下单”,看的是收盘后三组数据对不对:委托笔数、成交笔数、持仓变动。对不上的那天,日志就是唯一的后悔药。

日志要记的字段不多,但不能缺。每条委托至少记:本地引用号、发出时间(毫秒)、代码、方向、价格(分)、数量、接口返回码;每条回报记:引用号、回报时间、成交数量、成交价、状态枚举原文。关键是引用号贯穿全链路和时间用毫秒时间戳,出问题时能按号追一条委托的完整生命周期。

压测别用真实委托去打,用模拟环境或者最小数量反复撤单。我通常跑两种负载:一是行情满订阅加空转,看内存和队列水位会不会持续上涨,涨就是泄漏;二是每秒固定笔数的委托撤单循环,看回报延迟的 P99,超过 500 毫秒就说明回调通道被堵。第二种跑两小时,队列dropped计数还是 0,才算过关。

收盘对账用十行脚本就够:把当日日志里的委托按引用号去重,汇总数量和方向,和QueryPosition拉回来的持仓做差值比对,差值不为零就打印出问题的那几条引用号。

# close_check.py:日志与接口持仓对账 from collections import defaultdict delta = defaultdict(int) for rec in read_log("orders.log"): # 只取终态记录 if rec["status"] in ("FILLED", "PART_FILLED"): sign = 1 if rec["side"] == "buy" else -1 delta[rec["code"]] += sign * rec["filled"] # 净买入股数 pos = {p.code: p.qty for p in api.QueryPositions()} for code, d in delta.items(): if pos.get(code, 0) - d != expected[code]: # expected 为昨日持仓快照 print("MISMATCH", code, "log_delta=", d, "broker_qty=", pos.get(code, 0))

这段的重点不在代码本身,而在口径:日志侧只统计终态,避免部分成交被重复计算;持仓侧以柜台返回为准;expected用昨日收盘快照,不要用当日开盘快照,否则会把隔夜变动算进去。对不上的第一个反应不是怀疑接口,而是先查自己有没有漏记撤单和废单。

从那以后我每次接一个新的交易接口,都会先用模拟环境跑满一整天的空转加撤单循环,把日志字段和退出顺序固定下来,再谈策略。这套习惯救过我很多次——真正让人安心的不是接口文档写得多好,而是收盘后账能对上。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进