简介: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、OnRtnDepthMarketData |
| CtpTdSpi | 交易回调 | OnRtnOrder、OnRtnTrade、OnRspQryInvestorPosition |
这四个类之间的协作流程是:MdApi.init() 触发异步连接,连接成功后 Spi 收到 OnFrontConnected 回调,业务代码在回调里再调 api 方法发起登录,构成事件驱动的闭环。交易通道同理,TdApi.init() 之后等 OnFrontConnected,再调 ReqUserLogin。这种“回调里发起下一步动作”的模式贯穿整个 CTP 调用链,理解了它,后面所有 demo 代码都顺了。
2.4 回调方法命名映射:从 C++ 的 OnXXX 到 Java 的 Listener
C++ 里所有 SPI 方法都是 OnRtnDepthMarketData 这种驼峰命名,Java 封装一般会去掉 On 前缀变成接口方法,或者在回调类里保留 onRtnXxx 命名。命名对应关系能帮你在官方 C++ 文档和 Java 源码之间快速跳转:C++ 的 OnRspUserLogin 对应 Java 的 onRspUserLogin,OnRtnOrder 对应 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 和 AuthCode,demo 源码里如果带了这两个字段,说明这套封装对应的是柜台较新版本,不要漏填。
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,卖用 SELL;OffsetFlag 是开平标志,开仓用 OPEN,平仓用 CLOSE;PriceType 不设置时默认限价。注意不同交易品种的最小变动价位不同,螺纹钢是 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 维护一张状态表:
Map<String, 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 二次开发里很常用,做定时轮询持仓时我会维护一个 Map<reqId, 业务回调>,查询结果回来直接分发到对应业务模块。
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,确认状态机能正确消化每个事件;第三步,用模拟盘下一整天的小单,成交回报和资金计算对账无误后再接入真实账户。从那以后,我每次接一个新柜台或改一次封装层,都强制走一遍这套冒烟链路,确认基础功能没被改坏再进入业务开发,这个习惯帮我避掉了至少三次“上线才发现登录取数失败”的尴尬。希望帮到你。
本文还有配套的精品资源,点击获取