CTP 的 API 是全异步的:你发起一个 ReqOrderInsert,函数立刻返回,真正的结果以回调形式异步回来。很多对接事故的根源都在这条异步链路上——以为发出去就等于报进去了。本篇把一张报单从客户端到成交的完整消息流拆开看。
四条异步消息
一笔正常报单,客户端依次会收到:
- OnRspOrderInsert:柜台受理回执,只说明报单通过了本地校验(字段合法性、资金、限仓),不代表交易所收到;
- OnRtnOrder(已报):核心把报单送出报盘进程、交易所确认接收后的状态推送;
- OnRtnTrade:成交回报,一张报单可以有多条(分批成交);
- OnRtnOrder(全部成交/部成):最终状态收尾。
中间任何一步失败都有对应的错误回调:柜台校验拒绝走 OnRspOrderInsert 带 ErrorID;已报之后被交易所废单(涨跌停价外、平仓量不足等)走 OnErrRtnOrderInsert,这是新手最容易漏接的一个回调——漏了它,程序眼里这张单永远停在"已报"。
订单状态机
CTP 订单状态字段(OrderStatus)+ 报单提交状态(OrderSubmitStatus)组合成一个二维状态机,核心迁移大概是:
已录入(InsertSubmitted)
├─ 柜台拒绝 → 已拒绝(Rejected)
└─ 已提交(Accepted)→ 已报(NoTradeQueueing)
├─ 部成(PartTradedQueueing)→ 全部成交(AllTraded)
├─ 撤单中 → 已撤(Canceled)
└─ 交易所废单 → 已拒绝(ExchangeReject)
业务代码的正确姿势是把回报按"当前状态 + 目标状态"做合法迁移校验,非法迁移记日志告警,而不是无脑覆盖。原因很简单:回报可能重复(重放)、可能乱序(网络抖动),状态机是最后一道防线。
幂等三元组:FrontID + SessionID + OrderRef
CTP 没有全局唯一的订单号,幂等靠客户端自己保证:OrderRef 是客户端填的递增序号,加上本次连接的 FrontID 和 SessionID,三者拼起来才是唯一键 FrontID:SessionID:OrderRef。
这个设计把幂等责任推给了客户端,但换来了无状态前置——前置不需要登记任何订单映射。代价是客户端进程重启后如果 OrderRef 又从 1 开始,可能撞上历史单号,稳妥做法是把最后用过的 OrderRef 持久化到本地,重启后从这个值继续递增。CTP 的 OrderRef 是字符串数字,长度上限 13 位,量大长跑的程序要留意进位。
流量控制:三条线
CTP 的流控分三层:
- 会话级报单流控:单会话每秒报单笔数有限额(默认值很保守,常见为每秒 1 笔,期货公司可调),超限直接拒单并罚时——这是量化程序必须做客户端主动限速的原因,裸 for 循环打满会把自己封掉;
- 查询流控:Qry 类请求有单独的间隔限制,高频轮询持仓是典型违规姿势,正确做法是用回报驱动增量维护本地状态,查询只做对账;
- 交易所层流控:报盘到交易所还有一层,且各所有差异,CTP 把交易所的拒单原样透传。
超限时 API 返回 -1(网络错误)或带着"每秒请求数超限"的 ErrorID,程序要区分这两种失败:前者可以重试,后者必须退避,否则越重试越堵。
实务建议
写 CTP 对接程序,我们的习惯是把所有回调只干一件事——塞进无锁队列,由单消费者线程按序处理。回调线程里直接做业务(发新单、查库)是高危操作:回报频率上来后回调线程被拖住,会导致心跳超时断连。幂等键用 FrontID:SessionID:OrderRef,本地状态机做迁移校验,查询类请求按分钟级预算使用。这套规矩不复杂,但每一条背后都有生产事故垫底。