CTP Java API源码解析:从JNI桥接到程序化交易实战
简介CTP JAVA API 源码及demo 面向期货量化与程序化交易开发者解决Java语言对接上期技术CTP柜台接口时常见的JNI桥接、常量调用与回调实现难题。压缩包共634个Java文件整体约726KB以thosttradeapiJNI、thostmduserapiJNI等JNI封装类为核心辅以CThostFtdcTraderApi、CThostFtdcTraderSpi等交易/行情API类以及覆盖订单、成交、错误条件单等业务场景的字段定义文件目录结构清晰、可直接对照学习。目前已有766人学习下载。通过源码与Demo使用者可快速掌握接口初始化、登录认证、行情订阅、报单与查询等关键流程理解CTP事件驱动模型与数据字段含义减少自行踩坑和反复调试的成本适合有一定Java基础、准备接入CTP柜台进行交易系统开发的读者。1. CTP Java API 源码项目这套包到底解决什么问题如果你做过期货程序化交易一定对 CTP 这三个字母不陌生。它是国内期货柜台最主流的交易接口但原生是 C 动态库Java 开发者想用它通常要拿着源码包自己踩一轮 JNI 的坑。这套 CTP Java API 源码及 demo就是把行情、交易、账户查询这些接入能力封装成 Java 能直接调用的接口并附了可运行的示例工程。适合谁用呢一类是刚入行想做期货策略实盘的学生另一类是已经在用 Java 写交易系统、想绕开 C 编译环节直接对接柜台的团队。我会从源码结构讲起带着你把 demo 跑起来再把核心回调机制拆开最后给你一套二次开发的骨架。2. 源码结构拆解JNI 封装层与行情/交易双通道的调用链路CTP 接口天生是两个通道行情通道负责订阅和接收实时盘口交易通道负责登录、报单、撤单和查询。Java API 的源码通常也沿着这条线组织。这一章先看目录再搞懂 JNI 桥接最后把两个通道的核心类认全这样你打开源码包时不会迷路。2.1 解压之后目录布局与构建方式一个典型的 CTP Java API 源码包解压出来结构大致是下面这样ctp-java-api/ ├── lib/ │ ├── win64/ # Windows 64位动态库和接口头文件 │ ├── linux64/ # Linux 64位动态库 │ └── mac64/ # macOS 动态库部分封装版本才有 ├── src/ │ └── main/ │ ├── java/ │ │ ├── api/ # JNI 封装核心类 │ │ ├── demo/ # 可运行的示例程序 │ │ └── util/ # 资金分析、持仓计算等工具 │ └── resources/ # 配置文件与日志配置 ├── pom.xml # Maven 工程描述 └── README.md # 编译和运行说明lib 目录里放的是 C 动态库也就是柜台协议真正干活的部分。Maven 工程的话pom.xml 里一般会做两件事一是把 lib 下的动态库目录加入系统库路径二是用 maven-shade-plugin 把依赖打包成一个可执行 jar。很多版本为了方便干脆在 src/main/resources 里放一个 lib 目录运行前用 System.load 显式加载。你在自己的工程里引入时选择哪种方式都可以但注意动态库不能被 IDE 或构建工具“吞掉”否则运行时才会报库找不到。2.2 为什么需要 JNI 桥接层而不是纯 Java 实现CTP 柜台服务端与客户端之间的协议基于 C 二进制结构体客户端前置机只认这个协议。Java 想直接连要么用 Netty 自己实现协议栈要么通过 JNI 把官方动态库包一层。Netty 方案听起来很优雅但柜台协议里有大量可变长字段和位域复刻一个协议栈要维护很久而且柜台版本升级时你要跟着调协议格式血泪教训不少。我见过的大多数“CTP Java API 源码及 demo”都选第二种C 侧动态库不动用 JNI 建一座桥。public class CtpTraderApi { static { String libDir System.getProperty(ctp.lib.dir); if (libDir null) { libDir new File(lib/win64).getAbsolutePath(); } String osName System.getProperty(os.name).toLowerCase(); String libName; if (osName.contains(win)) { libName thosttraderapi_se.dll; } else if (osName.contains(linux)) { libName libthosttraderapi_se.so; } else { throw new RuntimeException(Unsupported OS: osName); } System.load(new File(libDir, libName).getAbsolutePath()); } // 对应C侧 createFtdcTraderApi private native long createTraderApi(String flowPath); // 对应C侧 RegisterFront private native void registerFront(long apiPtr, String frontAddr); // 对应C侧 ReqUserLogin private native int reqUserLogin(long apiPtr, String brokerId, String userId, String password); }代码逻辑说明static 块负责找库通过 ctp.lib.dir 这个 JVM 参数可以覆盖默认目录native 方法声明和 C 侧导出函数一一对应最终还是拿一个 long 类型当作 C 对象的指针使用。参数说明flowPath 是 CTP 存放本地流文件的目录注意它不是日志目录每个独立连接最好用不同子目录否则多个进程同时跑时会互相抢文件。踩坑提前讲一句JNI 方法签名只要和 C 侧对不上运行时直接抛 UnsatisfiedLinkError而且编译期完全看不出来。2.3 四个核心类的协作方式把源码包里的类按职责分成四类你读代码时先盯住这四个类名所属通道职责CtpMdApi行情注册前置、登录行情账户、订阅合约CtpTdApi交易登录交易账户、报单撤单、各类查询CtpMdSpi行情回调OnFrontConnected、OnRtnDepthMarketDataCtpTdSpi交易回调OnRtnOrder、OnRtnTrade、OnRspQryInvestorPosition这四个类之间的协作流程是MdApi.init() 触发异步连接连接成功后 Spi 收到 OnFrontConnected 回调业务代码在回调里再调 api 方法发起登录构成事件驱动的闭环。交易通道同理TdApi.init() 之后等 OnFrontConnected再调 ReqUserLogin。这种“回调里发起下一步动作”的模式贯穿整个 CTP 调用链理解了它后面所有 demo 代码都顺了。2.4 回调方法命名映射从 C 的 OnXXX 到 Java 的 ListenerC 里所有 SPI 方法都是 OnRtnDepthMarketData 这种驼峰命名Java 封装一般会去掉 On 前缀变成接口方法或者在回调类里保留 onRtnXxx 命名。命名对应关系能帮你在官方 C 文档和 Java 源码之间快速跳转C 的 OnRspUserLogin 对应 Java 的 onRspUserLoginOnRtnOrder 对应 onRtnOrder。查字段时也方便官方文档里定义的 DepthMarketData 结构体字段在 Java 类里几乎同名顶多把下划线去掉。建议你把官方结构体文档和这份源码并列放在一起遇到不懂的字段直接按名字搜比看 demo 硬猜高效得多。3. Demo 实战从登录柜台到订阅行情再到一笔委托单这一章我会带你完整跑一遍 demo。先准备环境再分别跑通行情通道和交易通道最后用日志验证整条链路是否正常。3.1 准备三样东西JDK、柜台账号、动态库路径跑起来之前确认三件事。第一JDK 必须用 64 位版本用 32 位 JDK 去加载 64 位动态库会直接失败第二需要一个柜台账号一般用模拟柜台环境去柜台服务商官网申请模拟账号会拿到经纪商代码、用户名、密码以及行情和交易前置地址第三动态库路径要能在运行时被找到最省事的方式是启动参数里指定。java -Dctp.lib.dir./lib/win64 -Djava.library.path./lib/win64 -jar ctp-demo.jar参数说明ctp.lib.dir 是前面 static 块里用到的自定义参数java.library.path 是 JVM 加载 native 库的默认搜索路径两个都指向动态库所在目录即可。申请模拟账号时要注意有些模拟柜台会额外要求 AppID 和 AuthCodedemo 源码里如果带了这两个字段说明这套封装对应的是柜台较新版本不要漏填。3.2 行情通道跑通连接、登录、订阅合约行情通道的目标是拿到实时 tick 数据。把配置集中放在一个 Config 类里示例如下public class Config { public static final String MD_FRONT tcp://127.0.0.1:10130; // 替换成你申请到的行情前置 public static final String TD_FRONT tcp://127.0.0.1:10131; // 替换成你申请到的交易前置 public static final String BROKER_ID 9999; public static final String USER_NAME yourUserId; public static final String PASSWORD yourPassword; }参数说明MD_FRONT 是行情前置TD_FRONT 是交易前置两个地址通常不同BROKER_ID 是经纪商代码模拟环境一般固定实盘环境每家期货公司都不同。然后是行情 api 的启动流程public class MarketDataDemo { private CtpMdApi mdApi; public void start() { mdApi new CtpMdApi(md-flow); // 行情流文件目录 mdApi.registerSpi(new MdSpiImpl()); mdApi.registerFront(Config.MD_FRONT); mdApi.init(); // 触发异步连接 } class MdSpiImpl extends CtpMdSpi { Override public void onFrontConnected() { System.out.println(行情前置已连接开始登录); mdApi.login(Config.BROKER_ID, Config.USER_NAME, Config.PASSWORD); } Override public void onRspUserLogin(int result, String errorMsg) { if (result 0) { System.out.println(行情登录成功订阅合约); mdApi.subscribeMarketData(new String[]{rb2601, rb2602}); } else { System.err.println(行情登录失败: errorMsg); } } Override public void onRtnDepthMarketData(DepthMarketData data) { System.out.printf(%s 最新价:%.2f 成交量:%d%n, data.getInstrumentId(), data.getLastPrice(), data.getVolume()); } } }为什么登录要放在 onFrontConnected 里面因为 init() 是异步连接连接刚发出时服务器还没握手完立即发登录包会被丢弃。所有 CTP 封装都有这个时序要求这是最容易翻车的一步。参数说明result 0 代表登录成功非 0 时去看 errorMsg 定位问题订阅合约可以一次性传多个合约代码比如同时订阅螺纹钢的不同月份。onRtnDepthMarketData 就是行情回调每笔 tick 变化都会触发一次频率高的时候每秒几十次这里不要做耗时操作。3.3 交易通道跑通登录、查询账户、下一笔限价单交易通道的 demo 和行情通道结构类似只是把 CtpMdApi 换成 CtpTdApi回调换成交易回调public class TradeDemo { private CtpTdApi tdApi; public void start() { tdApi new CtpTdApi(td-flow); tdApi.registerSpi(new TdSpiImpl()); tdApi.registerFront(Config.TD_FRONT); tdApi.init(); } class TdSpiImpl extends CtpTdSpi { Override public void onFrontConnected() { tdApi.login(Config.BROKER_ID, Config.USER_NAME, Config.PASSWORD); } Override public void onRspUserLogin(int result, String errorMsg) { if (result 0) { System.out.println(交易登录成功); tdApi.queryBalance(); OrderRequest req new OrderRequest(); req.setInstrumentId(rb2601); req.setPrice(3500.0); req.setVolume(1); req.setDirection(OrderDirection.BUY); req.setOffsetFlag(OffsetFlag.OPEN); tdApi.insertOrder(req); } else { System.err.println(交易登录失败: errorMsg); } } Override public void onRtnOrder(OrderInfo order) { System.out.println(委托状态: order.getOrderStatus()); } Override public void onRtnTrade(TradeInfo trade) { System.out.println(成交价格: trade.getPrice()); } } }报单字段说明Direction 是买卖方向买用 BUY卖用 SELLOffsetFlag 是开平标志开仓用 OPEN平仓用 CLOSEPriceType 不设置时默认限价。注意不同交易品种的最小变动价位不同螺纹钢是 1 元一跳价格必须是合法档位否则会被柜台拒单。onRtnOrder 是被动回报一笔委托从提交到成交会经历多个状态onRtnTrade 只有真正成交时才触发。3.4 从日志里验证整条链路demo 跑起来之后按下面顺序核对日志行情前置 connected 日志出现行情登录成功日志出现订阅成功日志出现且后续能看到 tick 行情输出交易前置 connected 日志出现交易登录成功日志出现委托回报里出现“已报”状态接着可能是“全部成交”或“已撤”如果有成交回报能对应上委托的订单号任何一个环节缺失按上一章的核心类协作顺序往前查。这个验证动作看起来简单但它能区分问题出在连接层、登录层还是业务层我每次接一个新柜台都会先做一遍。4. 核心实现讲解JNI 回调线程模型与事件分发机制上一章把 demo 跑通了这一章钻到源码里看封装层做了什么为什么回调必须立即返回以及一套可复用的订单状态机和查询请求编号方案。这些代码你二次开发时可以直接抄。4.1 C 回调如何变成 Java 事件以及线程模型CTP C 侧在收到柜台消息时会在它自己的网络线程里回调 SPI。JNI 封装层感知到这个调用后直接在 JNI 层调用 Java 方法那么这个 Java 回调也跑在 CTP 内部线程上。这带来三个结论回调执行时间过长会卡住底层网络线程不要在回调里做阻塞或重计算多个回调线程之间访问共享数据要考虑线程安全。public class CtpEventDispatcher { private final ExecutorService pool new ThreadPoolExecutor( 4, 8, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue(1024) ); public void post(String eventName, Runnable task) { try { pool.submit(task); } catch (RejectedExecutionException e) { System.err.println(事件队列已满丢弃事件: eventName); } } }参数说明核心线程数 4最大 8线程空闲 60 秒后回收队列容量 1024满了之后拒绝新任务并打印告警。常见做法是回调里只打包事件对象并 post 到线程池业务逻辑放到任务里异步执行。这样即使某个业务处理崩了也不会拖垮底层网络线程导致掉线。4.2 报单状态机OnRtnOrder 与 OnRtnTrade 的配合你下一笔委托后柜台会回报多次先是“已报”接着可能是“部成”或“全部成交”撤单则会有“已撤”状态。用订单引用号做 key 维护一张状态表MapString, OrderState orderMap new ConcurrentHashMap(); Override public void onRtnOrder(OrderInfo order) { OrderState state orderMap.computeIfAbsent( order.getOrderRef(), k - new OrderState(order)); state.transition(order); System.out.printf(订单%s状态更新为%s%n, order.getOrderRef(), state.status()); } Override public void onRtnTrade(TradeInfo trade) { OrderState state orderMap.get(trade.getOrderRef()); if (state ! null) { state.addTrade(trade); } }关键点OrderRef 是本地报单引用号同一笔委托在后续所有回报里都带这个号用它可以串联委托回报和成交回报。常见误用是把它们当成两个独立事件流处理最后对不上账。OrderState 是一个简单状态机类内部记录当前状态、累计成交数量、剩余数量transition() 方法根据回报里的订单状态字段推演状态流转。注意不同封装版本里字段名可能是 OrderRef、OrderSysID 或 OrderID要看你拿到的源码实际定义。4.3 查询的异步模型请求编号与回调去重CTP 的查询查持仓、查资金、查合约也是异步的发出查询后结果不会立即返回而是过一段时间在对应的回调里返回。当你在回调里再发新查询时就要区分“这次结果对应哪一次请求”。AtomicInteger requestIdGen new AtomicInteger(0); public void queryPosition() { int reqId requestIdGen.incrementAndGet(); tdApi.queryInvestorPosition(reqId, rb2601); } Override public void onRspQryInvestorPosition(InvestorPosition position, int reqId, int result) { if (reqId ! myExpectedReqId) { return; // 忽略过期请求 } System.out.println(持仓: position.getInstrumentId() 仓位: position.getPosition()); }参数说明queryInvestorPosition 的第一个参数是请求编号回调里第三个参数会带回来如果同一个条件查询发了很多次用 reqId 去重能避免把旧结果错当新结果。这个模式在 CTP 二次开发里很常用做定时轮询持仓时我会维护一个 MapreqId, 业务回调查询结果回来直接分发到对应业务模块。5. CTP 接入避坑指南动态库、流控、回调线程的五个常见问题这一章把我在接入过程中遇到过的坑以及在网上看到同行踩得最多的坑按“现象、原因、解决”整理出来。每一条都值得你在上线前自查一遍。5.1 动态库加载失败UnsatisfiedLinkError 翻车实录现象程序启动几秒钟后抛 java.lang.UnsatisfiedLinkError提示 no thosttraderapi_se in java.library.path或者找不到 native 方法签名。原因最常见的是 JDK 位数不对32 位 JDK 加载不了 64 位动态库其次是用相对路径指定动态库目录但进程工作目录和预期不一致还有一种是 JNI 方法签名和 C 侧导出函数对不上比如 flowPath 参数类型写成了 String 而不是 String这类错误只在运行时才暴露。解决启动时打印 System.getProperty(java.library.path) 和 System.getProperty(os.arch)确认库路径和 JDK 位数尽量用绝对路径给 ctp.lib.dir如果报的是 native 方法签名错误打开源码里的 native 方法声明和头文件逐个比对返回值类型和参数列表。5.2 回调线程里做重活导致行情延迟和掉线现象订阅行情后数据有时延迟几秒有时直接连接断开日志里看到前置超时。原因onRtnDepthMarketData 回调跑在 CTP 底层网络线程你在里面写数据库、发 HTTP 请求、做复杂计算都会阻塞网络线程后续数据包堆积最终触发柜台断线。解决回调里只做两件事——把数据拷出来、把事件扔进线程池。类似上一章的 CtpEventDispatcher 模式线程池满时宁可丢事件也不要阻塞。这个坑我见过不止一次有人把行情入库逻辑直接写在回调里跑模拟盘没事一上实盘就断线。5.3 登录失败前置地址、账号信息、认证信息各就各位现象onRspUserLogin 回调返回非 0错误信息提示账号错误、密码错误或前置不匹配。原因前置地址串成了行情和交易互换经纪商代码填错密码带空格或大小写不对部分模拟柜台要求 AppID 和 AuthCode 同时注册漏填其中一个也会登录失败。解决前置地址按 tcp://host:port 格式核对行情和交易分开账号配置统一从 Config 读取不要 hard code对照柜台申请邮件里的每一项信息逐条过一遍。登录这种连接后第一步的操作失败多半是配置问题而配置问题最好用最笨的方法逐项检查才能定位。5.4 流控触发查询太频繁被柜台拒绝现象查询持仓、查询资金或报单时回调返回错误提示“流控”或“请求过于频繁”更严重的是短时间内所有请求都被拒。原因CTP 柜台对流控有硬限制比如查询类请求每秒限制一到两次报单类限制每秒五笔左右。程序里循环查询或者定时器间隔太短就会触发。解决查询加上固定的间隔我一般用 1 秒以上的间隔并且对所有查询请求做统一排队报单保持克制遇到拒单先打印废弃原因不要循环重发多个线程同时下单时在业务层加一把锁限制每秒请求数。流控触发的恢复时间不一定与其赌运气不如从源头控制频率。5.5 订阅后收不到 tick合约代码不活跃或订阅数量超限现象订阅回调查询成功但 onRtnDepthMarketData 一直不触发或者只收到部分合约的行情。原因订阅的合约代码过期了不在交易列表里合约不活跃一天成交不了几笔有些行情柜台的订阅数量有上限超过后静默丢弃。解决订阅前先调合约查询接口核对合约代码比如螺纹钢主力合约每个月会换约上一月代码到期后不再产生行情订阅后打印订阅回报确认每一笔都成功批量订阅时控制在柜台限制之内分批订阅也可以。这一条对做回测的尤其重要代码过期导致数据缺口策略回测结果失真属于比较隐蔽的玄学问题。6. 二次开发思路把 Demo 改造成事件驱动的交易框架demo 能跑通只是起点真正做程序化交易还需要把它改造成一个可复用、可监控的小框架。这一章给三个实战改造方向。6.1 用事件驱动取代硬编码回调把 CtpMdSpi 和 CtpTdSpi 里的每一个回调都转成事件对象业务模块按事件类型注册监听器public enum CtpEventType { FRONT_CONNECTED, USER_LOGIN, DEPTH_MARKET_DATA, ORDER_UPDATE, TRADE_UPDATE, POSITION_UPDATE } public interface CtpEventListener { void onEvent(CtpEventType type, Object data); } // 内部一个 EventBus 负责发布与订阅改造后策略模块不再依赖具体的 api 类型只需要关注 CtpEventType 里的事件。这样做的好处是测试时可以直接构造业务事件喂给监听器不依赖柜台在线。6.2 加一层行情快照与订单状态缓存在原来的 tick 回调之上加一个 CurrentPriceCache用 ConcurrentHashMap 维护每个合约的最新快照策略读取时直接拿内存数据避免把频繁的 tick 回调直接透传给策略层。订单状态机则沿用第 4 章的 OrderState 结构并把状态变化同步发到 EventBus让风控模块实时掌握所有委托的进度。这两个缓存是后续做策略、做风控的地基。6.3 验证方法冒烟脚本与历史回放改造完后不要直接上实盘。我一般做三步验证第一步拿模拟柜台跑启动→登录→订阅→报单→撤单→查询持仓这条冒烟链路第二步把一天的历史 tick 数据按时间戳回放进 EventBus确认状态机能正确消化每个事件第三步用模拟盘下一整天的小单成交回报和资金计算对账无误后再接入真实账户。从那以后我每次接一个新柜台或改一次封装层都强制走一遍这套冒烟链路确认基础功能没被改坏再进入业务开发这个习惯帮我避掉了至少三次“上线才发现登录取数失败”的尴尬。希望帮到你。本文还有配套的精品资源点击获取