☰
CTP量化交易Demo:对接SimNow的行情交易K线策略完整实现
2026/10/20 13:50:21 网站建设 项目流程

简介:面向量化交易初学者的CTP官方接口入门Demo,演示如何用C++搭建行情订阅、交易报单、K线生成与简单策略触发的最小骨架。工程连接simnow模拟环境,可避开实盘资金风险,完整体验从tick行情接收、K线维护、条件判断到委托回报的闭环流程,并理解行情接口与交易接口的分工配合,适合作为程序化交易学习的第一份对照代码。源码按职责拆分:行情回调处理实时行情推送,交易回调处理委托回报与状态更新,K线转换辅助类完成Tick到分钟K线的聚合,策略模块则在K线之上编写条件判断与下单逻辑。配合内置的多份csv历史行情数据,可直接运行观察K线生成结果与策略信号触发过程。压缩包共33个文件,整体约1.94MB,以h/cpp源码、sln/vcxproj工程配置、dll/lib接口库及csv行情数据为主要组成,各类型分工明确,结构清晰、便于检索。已有5759人学习使用,VS2013及以上环境即可编译运行。无论是想弄懂CTP接口的调用顺序与回调机制,还是希望在现成框架上继续扩展自己的策略思路,这份Demo都提供了清晰可循的起点。

1. 先说结论:一个能连 SimNow 跑通的 CTP 量化 Demo

很多做量化的同学一开始都被 CTP 官方接口的复杂度劝退——回调函数十几个、字段结构体上百个、还得自己处理行情和交易两套 API。这个项目是一份难得的简化范本:它把行情、交易、K 线聚合、策略信号全部打通,并且直接对接 SimNow 模拟柜台。拿到手做完配置就能下单,不用先啃完一沓接口文档再动手。适合刚接触 CTP 的开发者快速建立「下单、回报、持仓、撤单」的完整链路认知,也适合想找一个最小可运行骨架来做二次开发的团队。它不是一个生产级系统,但恰好是那个「能跑起来、能看明白、能改」的起点。

2. 工程骨架:目录结构与三个核心模块的分工

2.1 目录结构先看这一层

整个项目是 C++ 写的控制台程序,配合 VS 工程组织,一眼扫过去目录分层很清晰:

ctp_demo/ ├── api/ # CTP 官方头文件与动态库 │ ├── ThostFtdcMdApi.h │ ├── ThostFtdcTraderApi.h │ ├── ThostFtdcUserApiStruct.h │ └── lib/ # Windows 下的 thostmduserapi_se.lib 等 ├── config/ # 配置文件:柜台地址、账号、合约列表 ├── include/ # 项目自定义头文件 ├── src/ │ ├── md/ # 行情模块 │ ├── trader/ # 交易模块 │ ├── kline/ # K 线聚合模块 │ ├── strategy/ # 策略模块 │ └── main.cpp # 入口 └── output/ # 运行日志与成交记录落盘

依赖关系是单向的:main 负责初始化三个模块并串起来,md 只负责收行情,trader 只负责下单撤单查询,kline 监听 md 回调进行 K 线聚合,strategy 消费 K 线算信号再调 trader 下单。

这种分层的直接收获是:你不用为了加一个策略去动行情和交易代码,只需要往 strategy 目录里加文件。后面讲二次开发的时候这点会非常有用。

2.2 配置文件:不把地址写死在代码里

config 目录下有一个配置文件,把 SimNow 的行情地址、交易地址、BrokerID、用户密码全部外置了。这是我最喜欢这个 Demo 的一点:CTP 登录一旦写死地址,换环境就得重新编译,而这里改配置就能切换 SimNow 和实盘柜台的地址。

[simnow] md_address=180.168.146.187:10130 td_address=180.168.146.187:10130 broker_id=9999 user_id=000001 password=888888 appid=simnow_client_test auth_code=0000000000000000 [market] instrument=rb2010 sub_interval=500 [strategy] kline_interval=5 fast_period=5 slow_period=20

这里需要注意appid和auth_code是 SimNow 最新要求的两项认证字段,老教程里通常只填用户名密码就能登录,现在不填会直接报错。行情和交易共用同一端口是 SimNow 的典型配置,实盘柜台这两者通常是分开的。

2.3 日志与成交落盘:最小可用的调试手段

项目在 output 目录下输出两类文件:一类是运行日志,包含登录状态、订阅是否成功、每次下单的回报;另一类是成交记录,以 CSV 格式保存每笔成交的时间、合约、手数、价格、开平标志。这种落盘方式虽然简单,但对于确认「策略到底有没有真的把单子下出去」很关键——量化里最怕的就是界面看起来没动,实际单子已经抛出去了。

3. 核心代码拆解:从 Tick 到下单的完整链路

3.1 初始化 CTP 接口:两套 Api,一个回调入口

CTP 的核心是两套 API:行情接口负责接收 Tick 数据,交易接口负责下单、撤单、查询持仓和账户。这个 Demo 在 main.cpp 里把两个接口的初始化顺序写得比较清晰。

// CThostFtdcMdApi 初始化 CThostFtdcMdApi* pMdApi = CThostFtdcMdApi::CreateFtdcMdApi("./output/md/"); pMdApi->RegisterSpi(new CMdHandler()); pMdApi->RegisterFront((char*)"180.168.146.187:10130"); pMdApi->Init(); // CThostFtdcTraderApi 初始化 CThostFtdcTraderApi* pTraderApi = CThostFtdcTraderApi::CreateFtdcTraderApi("./output/td/"); pTraderApi->RegisterSpi(new CTraderHandler()); pTraderApi->SubscribePrivateTopic(THOST_TERT_RESUME); pTraderApi->SubscribePublicTopic(THOST_TERT_RESUME); pTraderApi->RegisterFront((char*)"180.168.146.187:10130"); pTraderApi->Init();

CreateFtdcMdApi和CreateFtdcTraderApi传入的参数是行情和交易各自的流文件目录,CTP 会在这个目录下生成持久化文件。SubscribePrivateTopic和SubscribePublicTopic用的是THOST_TERT_RESUME,表示断线重连后自动补全错过的回报数据。如果改成THOST_TERT_QUICK则只取当前状态,实盘回调用这个容易漏单,这个参数建议不要动。

初始化之后,框架里专门做了一个同步等待:登录回调里会记录登录成功的标志,主线程用while (!bLogin)空转等待,确保行情和交易都登录成功后才开始订阅行情。这是 CTP 编程里最常见的坑——接口Init是异步的,不等待直接订阅会失败。

3.2 行情回调与 K 线聚合

CTP 行情接口的核心回调是OnRtnDepthMarketData,每个 Tick 都会触发一次。这个 Demo 在收到 Tick 后立即更新分钟 K 线,代码里用了定时器判断而不是简单计数,因为这个接口的 Tick 频率是不均匀的,行情停顿的时候也会继续推。

void CMdHandler::OnRtnDepthMarketData(CThostFtdcDepthMarketDataField* pData) { if (!pData) return; // 用本地时间判断是否新开一根K线 time_t now = time(NULL); if (now - kline_start_time_ >= kline_interval_ * 60) { // 把上一根K线推到策略队列 if (kline_buf_.count > 0) strategy_engine_->OnNewKline(kline_buf_); // 新开一根K线 kline_buf_.open = pData->LastPrice; kline_buf_.high = pData->LastPrice; kline_buf_.low = pData->LastPrice; kline_buf_.close = pData->LastPrice; kline_buf_.count = 1; kline_start_time_ = now; } else { // 更新当前K线的高低点和收盘价 kline_buf_.high = max(kline_buf_.high, pData->LastPrice); kline_buf_.low = min(kline_buf_.low, pData->LastPrice); kline_buf_.close = pData->LastPrice; kline_buf_.count++; kline_buf_.volume += pData->Volume; } }

K 线聚合的粒度在配置里由kline_interval控制,Demo 默认是 5 分钟。聚合逻辑里open取第一笔 Tick 价、close取最后一笔,这是实盘里和行情软件最常见的差异点——某些软件会把最后一笔的买一价当收盘价,导致策略回测和实盘信号对不上。

如果你把这个模块拿去改造,最值得扩展的是加入 Volume 的累计逻辑和成交额字段,目前只存了持仓量变化量,做资金流向分析时不够用。

3.3 交易模块:下单、撤单、查询持仓

交易模块的核心在CTraderHandler,下单入口是OnRspOrderInsert,回报入口是OnRtnOrder和OnRtnTrade。这个 Demo 的代码把三者之间最容易混淆的字段点讲清楚了。

void CTraderHandler::OnRspOrderInsert( CThostFtdcInputOrderField* pInputOrder, CThostFtdcRspInfoField* pRspInfo) { if (pRspInfo && pRspInfo->ErrorID != 0) { // 下单被柜台拒绝,输出错误码和错误信息 LOG("下单拒绝: %s", pRspInfo->ErrorMsg); return; } // pInputOrder 里能看到我们原始的报单参数 LOG("报单成功: OrderRef=%d Price=%.2f Vol=%d", pInputOrder->OrderRef, pInputOrder->LimitPrice, pInputOrder->VolumeTotalOriginal); }

OnRspOrderInsert只代表交易所接受了这笔报单请求,不代表已经成交。真正成交要看OnRtnTrade,这个回调里会携带TradeID(成交编号)和Price两个关键字段。很多入门开发者把OnRspOrderInsert当成成交信号,结果策略在开盘瞬间重复开仓,就是因为没搞懂这个区别。

下单参数里OrderRef是本地编号,CTP 的OrderSysID才是交易所编号,查询回报时要用后者。Demo 里维护了一个从 OrderRef 到 OrderSysID 的映射,撤单时通过映射查找正确的交易所报单引用。

3.4 策略引擎:把信号计算和下单动作拆开

策略模块用了最简单的双均线模型,核心逻辑就几十行,没有引入任何第三方依赖。但代码组织上有意做了分层,让我觉得这个 Demo 的架构可以复用。

void CStrategyEngine::OnNewKline(const KlineData& kline) { // 均线计算:维护一个固定长度的价格窗口 price_window_.push_back(kline.close); if (price_window_.size() > fast_period_) price_window_.erase(price_window_.begin()); if (price_window_.size() < slow_period_) return; // 数据不够,不产生信号 double fast_ma = CalcMA(fast_period_); double slow_ma = CalcMA(slow_period_); if (prev_fast_ma_ <= prev_slow_ma_ && fast_ma > slow_ma) { // 金叉:开多仓 trader_->BuyOpen(kline.close, 1); } else if (prev_fast_ma_ >= prev_slow_ma_ && fast_ma < slow_ma) { // 死叉:平多仓 trader_->SellClose(kline.close, 1); } prev_fast_ma_ = fast_ma; prev_slow_ma_ = slow_ma; }

策略引擎通过接口依赖注入的方式拿到交易模块的指针,没有直接访问 CTP 的类型,这意味着换策略时完全不用动交易模块。金叉死叉的判断用prev_fast_ma_和prev_slow_ma_做差值比较,这是为了避免收盘价在小数点后两位的微小波动造成频繁翻转。

如果数据量只有一根 K 线就产生金叉信号,可能是因为price_window_.size() < slow_period_的判断放在均线计算之前就返回了,但 Demo 里这个判断放在了push_back之后,逻辑上没有问题,唯独循环开始时没有清空历史窗口这一点要注意——重新订阅合约时需要显式重置策略状态。

3.5 参数表速查

参数含义建议值
md_address行情服务器地址SimNow: 180.168.146.187:10130
td_address交易服务器地址SimNow: 180.168.146.187:10130
broker_id经纪商代码SimNow 统一用 9999
user_id / password登录账号密码SimNow 官网注册后分配
appid / auth_code认证信息SimNow 官网申请后分配
kline_intervalK 线周期(分钟)5
fast_period / slow_period双均线周期5 / 20

这些参数在实盘环境里全部要换,尤其注意 appid 和 auth_code,实盘柜台每个用户的认证信息是唯一的,互相不能借用。

4. 运行与避坑:SimNow 登录、模拟交易常见问题排查

4.1 跑通这个 Demo 的前置条件

环境方面需要 Windows 系统加 VS2017 及以上版本,项目里内置了 CTP 的 Windows 动态库,不需要额外安装接口文件。SimNow 账号需要提前在官网注册,注册后会提供一套用户名密码和十位的 auth_code,注意 auth_code 有有效期,过期后需要重新申请。

编译顺序建议先编译整个解决方案,然后打开 config 配置文件,把账号密码替换成自己的,确认配置文件最后的[strategy]段kline_interval=5是 5 分钟,而fast_period=5和slow_period=20是均线周期,初次实验不建议动这个参数,先用默认值跑通链路再调参。

启动后观察日志:先看到「行情登录成功」和「交易登录成功」,然后会输出「订阅合约 rb2010 成功」,大约 10 秒内如果看到「计算 MA5=xxxx MA20=xxxx」,就说明 K 线聚合和策略引擎已经正常工作了。

4.2 常见问题一:登录一直挂在「正在连接行情前置」

现象:程序启动后卡在RegisterFront之后的Init调用上,没有任何登录回报,日志停留在「正在连接行情前置」。

原因:SimNow 的行情和交易前置地址都写在同一个配置文件里,如果只改了交易地址没改行情地址,或者把行情地址写成了实盘的 IP,柜台端口无法连通时 CTP 接口会一直尝试重连,不会主动报错。

解决:确认配置文件的md_address与td_address用的是 SimNow 官网最新公布的地址,不要用网络上的旧文章里的 IP。注意 SimNow 有多个交易组,每个组的行情和交易地址是不同的。

4.3 常见问题二:登录直接报错 3(CTP: 不合法的登录)

现象:日志很快输出「Login failed: 3」,并没有继续往下走。

原因:错误码 3 表示用户登录信息不合法,最常见的是 BrokerID 写错、账号密码错、auth_code 过期。我在调试时最容易翻车的是把 SimNow 中「投资者账号」当成「用户代码」,这两者在 SimNow 控制台是两套不同的字段,报名默认的 user_id 字段填的是投资者账号。

解决:回到 SimNow 官网查到正确的 BrokerID 和投资者账号,确认 auth_code 没有过期。如果还是报错 3,注释掉 appid 和 auth_code 两个字段试一下老版认证模式,有些柜台还在用老认证。

4.4 常见问题三:能登录但订阅行情没反应

现象:交易登录成功,行情也提示登录成功,但是日志里迟迟没有“订阅成功”的输出。

原因:SubscribeMarketData必须在行情接口登录成功的回调之后调用,Demo 里把订阅放在了登录回调之后,这个顺序是对的。但如果你改过代码,把订阅放到了Init()之后立刻执行,就会出现这个问题。还有一个隐蔽原因是合约代码写错了,比如螺纹钢主力在 SimNow 里可能是 rb2601,配置里写死 rb2010 已经退市。

解决:把SubscribeMarketData调用放回OnRspUserLogin回调内部,同时确认合约代码是当前月份的有效主力合约。可以在 SimNow 的行情查询页面先手动订阅一遍,验证合约代码是否存在。

4.5 常见问题四:下单返回错误码 26(CTP: 当前没有可卖持仓)

现象:策略在死叉信号时执行SellClose,但日志报错 26,单子没下出去。

原因:SimNow 对平仓的方向字段要求很严格,CombOffsetFlag要写成THOST_FTDC_OF_CloseToday或者THOST_FTDC_OF_Close。Demo 里的默认写法是Close,但如果前一天有持仓,SimNow 会要求区分平今和平昨,两个标志不能混用。这个报错在实盘里更常见,因为实盘柜台风控更严格。

解决:在执行平仓前先查一遍持仓明细,根据持仓的PositionDate字段判断是今仓还是昨仓,再写入对应的偏移标志。Demo 里没有做这个判断,导致收盘前平仓的指令有一定概率被柜台拒单,这也是为什么实盘前必须先跑一遍 SimNow 验证方向逻辑。

5. 二次开发:把 Demo 改成你自己的策略框架

先聊一个我拿到手就改进的点:把策略信号和下单动作中间加了一个「人工确认」开关。默认情况下策略算出金叉直接买入,这在模拟盘无所谓,但你在实盘前用它做联测时,建议在OnNewKline里输出信号后不直接调trader_->BuyOpen,而是把信号写入一个队列,由交易员人工确认后再下单。最简单的实现是加一个全局 bool 变量bManualConfirm,在策略集成交叉信号时只LOG("金叉,准备买入,等待确认"),然后通过控制台输入字符触发下单。这能帮你熟悉整个策略链路,又避免实盘开盘瞬间策略自动下单带来风险。

第二个值得改的地方是策略参数。现在config.ini里已经外置了fast_period和slow_period,但 K 线周期kline_interval改动后,策略引擎里的时间窗口没有同步适应,比如把 K 线改成 15 分钟,均线的 5 和 20 在语义上就从「5 个 5 分钟」变成了「5 个 15 分钟」,信号频率会完全变样。我一般会在ReadConfig之后做一次参数校验,把每次启动的配置快照写入日志,避免跑了一整天发现参数没生效。

第三个改造方向是加一个简单的回测后处理脚本。Demo 只输出 CSV 成交记录,你可以在策略运行结束后用 Python 读这个 CSV,按买卖配对计算收益率曲线。我习惯把每次模拟交易的OrderRef作为主键,把OnRtnTrade里的成交价、手数、开平标志解析成一个结构化 DataFrame,这样不用写复杂的回测引擎,也能看到策略的真实胜率。

还有一个小技巧:把OnRtnOrder里的Status字段打全。CTP 的报单状态有已报、已撤、部成、全成、拒单等十余种,Demo 里只打了IsOk,遇到部分成交的极端行情时你会误以为单子全成交了。把OrderStatus映射成可读字符串写在日志里,排查问题效率会高很多。

从那以后我每次写 CTP 接入代码,都会强制自己先跑一遍 SimNow 的完整流程——从行情订阅、下单、撤单到平仓全走一遍,确认每一条回报链路都清晰了再上实盘,这个习惯帮我避掉了不少盘中才发现的黑匣子问题。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询