1. 为什么非要做“仿CTP”这一层:一个尴尬的兼容困局
半年前我们团队接到一个有点尴尬的需求:量化组的同事手里攒了一大堆基于标准CTP接口开发的交易客户端、算法脚本和风控工具,而公司内部实际跑交易的却是另一套自研系统,团队内部代号叫homes平台。两边数据结构对不上、接口风格完全两样,量化同事想用现成工具直接连进homes,发现根本连不上。要么把策略全部重写一遍,要么在homes平台外面加一层“仿CTP接口”做兼容。重写的成本大家都知道——策略代码动辄上万行,还牵扯到实盘验证、历史回测对比,不是说改就能改的。于是我们选了第二条路:在homes交易平台之上,照着行业通用的CTP接口规范做一套兼容接口,把外部CTP客户端的请求翻译成homes能懂的命令,再把homes的成交回报翻译回CTP格式。
先说清楚一个概念:所谓仿CTP,不是说我们要去复刻一个一模一样的官方交易柜台,而是做一层协议翻译层。CTP是期货行业里非常普遍的交易接口规范,大量行情软件、量化平台、套利工具、跟单系统都是照着这个规范写的。只要你对外暴露的接口在函数名、数据结构、回调时序上和CTP一致,这些现成的客户端就能不做修改地连上来。这是一条非常务实的路:把“行业生态”接进自己的系统。
这件事适合谁参考?如果你所在的团队恰好维护着一套自有交易系统,又希望让团队外的量化、行情、风控工具能低成本接入;或者你在做交易系统的中间件、网关开发,需要理解CTP接口底层是怎么组织的;再或者你只是好奇一个“假CTP”要怎么设计和验证,这篇的内容就是我从立项到跑通整个过程的完整记录。文中涉及的具体代码细节我按常见实践做了补充说明,实际落地时以你们内部系统的接口文档为准。
2. 仿CTP接口的总体架构设计:在homes上再造一个交易网关
2.1 整体模块划分与数据流
仿CTP接口不是一个独立的大程序,而是一组协作模块。我们最终拆成了四块:会话管理模块、请求处理模块、回报推送模块、行情转发模块。会话管理负责客户端的连接、登录、登出和心跳保活;请求处理模块接收CTP格式的请求,解析后转换为homes平台内部服务调用;回报推送模块订阅homes平台产生的委托、成交、资金变动等事件,重新组装成CTP的回调数据结构推给客户端;行情转发模块则从homes的行情源拉取行情快照,按CTP的行情结构体推送出去。
这四块之间的数据流大致是这样的:客户端发起TCP连接,会话模块完成身份认证后,为这个连接分配一个会话标识;后续所有请求都带着这个会话标识进入请求处理模块,请求处理模块调用homes平台的内部服务,同时把这次调用登记在“待回报”列表里;homes平台的业务结果会通过事件通知回来,回报模块根据会话标识找到对应的客户端连接,再把回报推送出去。整个过程一进一出,很像把两个不同方言的人拉在一起做翻译,只不过翻译的不是单词,而是二进制结构体。
这里有一个经常被忽略的点:CTP接口是“请求-响应”和“主动回报”两种模式混在一起的。比如查询资金,客户端发一个ReqQryTradingAccount,服务端在某个不确定的时刻通过OnRspQryTradingAccount回调把结果推回来;而下单之后,委托回报、成交回报则是另一个通道OnRtnOrder、OnRtnTrade主动推送。这两个通道在时序上是有先后关系的,后面我们专门花了很大力气调这个时序,稍后细说。
2.2 会话层设计:从TCP到内部服务总线
CTP客户端的连接行为是有一套固定流程的:先CreateApi,再RegisterFront连接前置地址,连接成功触发OnFrontConnected,然后客户端发ReqUserLogin,服务端校验后返回OnRspUserLogin,登录成功后才能做查询和交易。仿CTP接口的第一个门槛就是把这套流程完整模拟出来。
具体实现上,我们对外监听一个TCP端口,采用CTP客户端常见的通信模式:客户端发过来的第一个包是登录请求,里面包含投资者账号、密码等字段。请求处理模块拿到后,调用homes平台的认证服务校验账密,认证通过则创建一个会话对象,记录客户端的地址、连接句柄、投资者账号映射关系,同时维护一个自增的SessionID。这个SessionID很关键,CTP协议里所有交易请求都依赖登录时返回的SessionID,服务端要校验每个请求的会话是否有效。
会话层还肩负心跳保活。CTP客户端登录后会周期性发送心跳探测,服务端如果长时间收不到心跳,会判定连接已断开并主动释放会话。这个超时时间我们设为90秒,实测下来客户端默认30秒探一次,不会误杀。这类参数我建议做成配置文件而不是写死在代码里,调试期可以调短一点方便测重连逻辑,实盘再调回正常值。
2.3 请求线程模型与回报推送的隔离设计
这一块是整个网关最容易写烂的地方,很多仿CTP实现都挂在回报乱序上。我们的做法是:请求处理用一套工作线程池,回报推送用独立的发送队列。所有进入的请求先按请求ID做幂等去重,然后投递给内部服务总线等待结果,超时时间统一设成3秒。回报推送则单独开一组发送线程,从homes平台事件中拿到回报数据后,按客户端会话加锁写入该连接对应的发送缓冲,再由TCP发送线程按序flush出去。
为什么要隔离而不是请求线程直接发回报?因为homes平台的回报事件可能在任何线程上触发,如果直接在事件回调里往TCP写数据,一旦客户端消费慢,TCP发送缓冲区被打满,就会阻塞homes平台的事件处理线程,严重的会把整个交易链路都卡死。隔离之后,最坏情况只是该客户端的发送缓冲积压,其他客户端的交易不受影响。
这个设计思路和官方CTP的架构是相似的,官方也是前置进程和核心进程分离,前置负责连接管理和行情分发。仿CTP接口本质上就是一个简化版的前置网关,只不过背后对接的不是交易所核心,而是homes平台。
3. 核心交易流程的字段级设计与状态机还原
3.1 登录与认证:身份映射怎么做
登录流程的映射要处理好两个层面:协议层的会话认证和业务层的权限校验。CTP登录请求里的字段包括投资者账号、密码、软件版本号等,homes平台内部的认证接口字段却可能完全不一样。我们做了一个配置化的字段映射表,把CTP字段转成homes内部字段,同时把homes返回的登录结果转回CTP的返回码和错误信息。
这里有一个细节值得单独拿出来说:CTP登录成功后,客户端会拿到一串包含FrontID和SessionID的会话信息,之后下单时会把这两个值原样带回,服务端用来判断这张委托是不是来自合法会话。仿CTP层必须完整保存这个对应关系,否则客户端重连之后,旧的会话还在homes里挂着,新会话又来登录,就会产生一头多会话的混乱状态。我们的处理方式是:新登录成功后,强制注销该投资者账号下的旧会话,并给旧连接推送一个会话失效的回报。这样客户端即使没来得及处理断线,也会在下次请求时收到明确错误。
3.2 查询链路:资金、持仓、合约的正确翻译
查询是仿CTP里最“繁琐”但最不“烧脑”的部分,难在字段多、含义微妙。拿查询资金来说,CTP返回的账户资金结构体里有几十个字段,包括可用资金、冻结保证金、平仓盈亏、持仓盈亏、手续费、风险度等。homes平台内部的资金模型不叫这些名字,粒度也可能不同,需要一条条对。我们当时的做法是写了一张字段映射Excel,列了CTP字段名、homes字段名、转换逻辑、备注,光是资金查询就列了四十多条映射。
持仓查询更要注意“今仓”和“昨仓”的区分。CTP的持仓结构体里通过PositionDate字段区分今仓和昨仓,homes平台内部可能用持仓编码区分。如果映射错了,客户端平仓时就会把开平标志搞错,该平今的平成了昨仓,直接被交易所拒单。我们在这上面栽过一次,后面在验证清单里加了一条专项用例外才彻底解决。
合约查询相对简单,把homes的品种、合约、最小变动价位、合约乘数等字段映射到CTP的合约结构体即可。需要注意的是保证金率和手续费率,homes平台一般是按交易所标准算的,仿CTP层不需要重新计算,直接透传就行。但有些客户端会拿这些数据做本地风控预检,所以字段必须准确,不能拍脑袋填。
3.3 下单与撤单:OrderRef、报单状态机与回报时序
下单是仿CTP接口的核心中的核心。CTP的下单请求核心字段包括:投资者账号、合约代码、买卖方向、开平标志、投机套保标志、价格类型(限价/市价)、委托价格、手数、OrderRef(客户端自定义的委托引用编号)。客户端用OrderRef来关联自己发出的请求和服务端推回来的回报,所以OrderRef必须完整透传,不能篡改。
我们内部把一个下单请求的完整生命周期梳理成五步:第一步客户端发ReqOrderInsert,仿CTP层做最基本的参数校验,比如手数大于零、合约在交易时段内、价格非负;第二步把请求翻译成homes下单指令并发送;第三步homes返回“受理”事件,仿CTP层生成一条OrderRecord记录到内存表,状态置为“已报”;第四步homes产生成交回报事件,仿CTP层更新OrderRecord的成交手数,同时生成一条TradeRecord;第五步如果全部成交,状态置为“全成”,如果部分成交则保持“部成”,如果被拒则状态置为“拒绝”,并推送错误回报。
回报的推送给客户端时,我们严格保持了两个顺序:委托回报一定在成交回报之前,拒绝回报一定在委托回报之后。这个顺序官方CTP有约定,客户端代码里有不少是假设这个顺序的。比如先收到OnRtnTrade成交回报,再收到OnRtnOrder委托回报,某些客户端就会找不到对应的OrderRecord而直接丢弃成交,导致持仓不一致。
撤单流程类似,但多了一个竞态:客户端发撤单指令的同时,这张单可能已经全部成交了。CTP给出的处理是:如果撤单时单子已经全成,服务端返回“不能撤”的错误;如果还没成交,返回撤单成功。仿CTP层必须准确判断委托的当前状态,不能想当然地先撤再说。我们是先查内存OrderRecord的当前状态,再决定是发撤单指令还是直接返回错误码。
3.4 订单状态机的关键在于“边角状态”
CTP的报单状态词很多:全部成交、部分成交还在排队、已撤单、未成交还在排队、已接受、已报、已提交等。仿CTP层不能只映射“核心几个”,那些边角状态也必须处理到位。比如“部成撤单”这个状态,指部分成交之后剩余手数被撤掉,这是真实交易里非常常见的结果。如果仿CTP层只处理“全成”和“全撤”而漏了“部成之后撤单”,客户端剩余持仓就会算错,这是会直接造成资金损失的bug级别问题。
我们最终的实现里维护了一张完整的委托状态表,把homes平台的状态枚举和CTP状态枚举做了双向映射,并在每个状态转换点打日志。这个表后来成了测试的核心依据,压测时我们就是靠它对照客户端日志来验证状态流转是否一致。关于枚举映射,下一篇踩坑部分我再展开细讲。
4. 实盘踩坑记录:心跳、开平枚举、行情拥堵和日志
4.1 会话超时与心跳:最隐蔽的踢下线
第一个坑来自心跳。仿CTP层上线后的某个下午,量化同事反馈:客户端挂在那边半小时不动,再发单的时候就报“会话不存在”。排查了半天,最后发现是homes平台内部有一个空闲会话回收机制,超过一定时间没有活跃请求的会话会被自动清理。而我们的仿CTP层只在收到客户端请求时才去更新活跃时间,客户端安静挂机时CTP的心跳包虽然到了,但我们没有把这个心跳包转成homes平台侧的“会话保活”动作,于是homes认为这个会话死了,把它清掉了。
修复方案很简单:收到CTP心跳包时,除了回复心跳应答,还要主动调用homes平台的会话保活接口,把会话的活跃时间刷新一下。这里要特别注意刷新频率,不能每个心跳包都刷新一次,我们最后是按30秒一次节流刷新,避免给homes平台造成没必要的压力。
这类坑最烦人的地方在于它不是第一时间暴露的,往往是客户端挂了十几二十分钟之后才出现,而且报错信息看起来像是“账号被登出”,很容易让人去查认证逻辑而不是会话保活逻辑。我把这条经验写进团队的排障手册里,后来几次类似问题排查速度明显快了很多。
4.2 开平今昨逻辑:枚举映射错一个,仓位就乱
CTP的开平标志是一个组合字段,取值范围包括:开仓、平仓、平今、平昨。这四个值对应交易所里的几种仓位处理逻辑,尤其“平今”和“平昨”在商品期货和股指期货里的规则不一样,映射错了就是灾难。
homes平台内部用的开平标志是另一套编码,我们在第一版映射的时候想当然地按字典序对应,结果实盘仿真一跑,平今仓的指令被homes识别成了平昨仓,在无昨仓持仓的情况下直接被拒单。这个问题的隐蔽之处在于:如果你只做普通的卖出平仓,不区分今昨,看上去也能成交,但如果你做的是日内交易,平今和平昨的手续费差好几倍,盈亏计算完全对不上。
最后我们处理的方式很笨但很有效:找交易所的持仓查询接口,先拉一份当前持仓明细,把每一笔持仓的今昨属性、开仓日期都打印出来,再拿CTP的报单指令去对。一条条对完之后,把映射规则固化进配置表,并加了一条单元测试:枚举全组合回归,覆盖开仓、平昨、平今、平今失败转平昨等场景。这种测试是必须的,因为客户端那边接到拒单错误后,有的会改flag重试,有的直接就停止策略了,完全取决于客户端的实现,我们不能替客户端做决定,但必须保证自己给出的flag语义是准确的。
4.3 行情推送拥堵导致回报迟延:并发模型的教训
第三个坑和行情有关。CTP的行情接口和交易接口是分开的两套API,行情通过OnRtnDepthMarketData回调推送,交易通过OnRtnOrder/OnRtnTrade回调推送。我们在仿CTP实现时,刚开始把行情转发线程和交易回报推送线程放在同一个线程池里,结果实盘行情一波动,行情数据量大,把线程池占满,交易回报排在了后面,客户端那边直观感受就是“成交回报延迟了好几秒”。
这在实盘里是非常危险的。对高频策略来说,几千毫秒的回报延迟可能意味着错失一个价位。对风控系统来说,回报迟迟不到意味着持仓状态不确定,可能触发重复报单的连锁反应。我们把行情和交易彻底分流,行情走单线程广播通道,交易回报走独立线程池,并且交易线程池的优先级通过队列配置提高。修改之后实测,行情高峰期的成交回报延迟稳定在200毫秒以内,再没出现阻塞。
这个教训告诉我们,仿CTP接口的性能瓶颈往往不在单条消息的处理速度,而在不同优先级消息之间的资源争抢。做网关类服务的同学,一开始就要把消息分优先级排队这个事想清楚,别等出了故障再回头改。
4.4 日志先行,调试才活得下去
最后这条算不上坑,但比任何一条坑都重要:日志。仿CTP层本质上就是一个协议转换器,出问题时第一件事就是确认某个字段在入口是多少、出口是多少、转换规则有没有执行。如果没有完整日志,排查一个字段错位的问题可能要一天;有了日志,十分钟就能定位。
我们最终的日志格式是一条请求一条日志、一条回报一条日志,每个关键节点都打上会话ID、请求ID、OrderRef、合约代码、方向、手数、价格、转换前后的字段值。日志按天滚动,保留三十天。团队后来养成了习惯:任何客户端报数据不对,先让对方发客户端日志,我们再同时拉服务端日志,用OrderRef做关联键把两头一拼,问题基本当场现形。如果你也在做类似网关,我强烈建议先把日志设计好再写业务代码,否则后面调试期流的泪都是前面日志埋的雷。
5. 仿真平台验证的完整清单:怎么证明它“够像”CTP
5.1 用官方CTP通用工具当测试用例
验证仿CTP接口做得好不好,最直接的办法就是把官方的CTP客户端工具拿来连你的接口。官方工具对接口行为的校验是最严格的,它对回报顺序、字段完整性、错误码语义都有隐含假设,任何一个环节不对劲都会在界面上直接表现出异常。我们当时在测试环境配好地址后,先用官方工具跑通了完整的“登录-查询资金-查询持仓-下单-撤单-查成交”流程,才算迈过了第一道门槛。
除了交易流程,行情也要用官方工具订阅验证。打开行情订阅,确认每个合约的快照数据、买卖五档、最新价、涨跌停价都在更新,而且刷新频率符合预期。官方工具的行情窗口刷新比较直观,数据是否连续一眼就能看出来,比看日志效率高很多。
5.2 压测数据:下单吞吐、回报延迟与断线重连
功能通了之后,接下来是压测。我们搭了一套基于负载工具的脚本,模拟一百个客户端并发连接,每个客户端同时维护五笔在途委托,按平均每秒二十笔的速度不断下单、撤单,跑两个小时。同时用探针程序记录每一笔委托从发出到收到回报的端到端延迟。压测指标有两个硬性要求:一是整个过程不能丢任何一笔回报,二是P99延迟不超过500毫秒。我们把压测结果打成趋势图,每次版本迭代都重新跑一遍,防止回归劣化。
断线重连也是必测项。我们的做法是:让客户端正常交易中,在homes平台的会话层手动踢掉该会话,观察客户端是否能感知断线并自动发起重连,重连后能否继续下单,旧的未成交委托是否还能正常收到回报。CTP客户端有一个机制:重连后如果旧会话还有在途委托,服务端会在新会话里补推这些委托的最新状态。仿CTP层也要实现这个“补回报”逻辑,否则客户端重连后就会对已在交易所成交的单子毫无感知,这是实盘大忌。
5.3 异常场景演练:连续流、反单、重复撤单
最后是异常场景演练。连续流指客户端在极短时间内连续下多笔单,仿CTP层必须保证这些请求按顺序进入homes平台,不能乱序,也不能因为并发处理而出现后发先至。反单指客户端在持仓不足的情况下强行平仓,仿CTP层要准确透传被拒错误,并确保该笔委托的内存状态被标记为“废单”,不能影响后续报单。
重复撤单指客户端对同一笔委托发出两次撤单请求,第二次撤单时委托已经撤掉了,仿CTP层必须返回“该委托已完成”的明确错误,而不是再往homes平台发一次撤单指令。这些场景在正常测试里都遇不到,但实盘里全都可能发生。我们的测试用例清单里专门建了一个“极端操作”文件夹,把这些场景全部自动化跑起来,每轮回归必过。
仿真验证走到这一步,心里基本就有底了。官方工具测过、压测数据达标、异常场景全覆盖,这套仿CTP接口就具备了接入实盘仿真的条件。再往后就是把交易量从模拟切换到真实,但前期的验证工作做到位,切换的时候就不会手忙脚乱。
最后分享一点个人体会:做仿CTP接口这件事,技术难度不在一行行代码,而在于你愿不愿意把一个协议从头到尾琢磨透。CTP接口文档有几百页,数据结构几十个,回调和状态枚举更是多得让人头大,但只要你愿意沉下心把每一项映射都弄清楚、把每一种异常时序都测到位,这个工程就没有想象中那么可怕。做完之后你会发现,团队对交易链路的理解水平整个上了一个台阶,后续再对接任何其他接口都轻松很多。