简介:这份压缩包面向需要接入期货公司 CTP 行情与交易接口的开发者,提供一套可完整编译、可直接联调的数据接收示例,重点解决网上同类代码缺失依赖、编译报错、难以真正取到行情的问题。包内共 76 个文件,以 C++ 源码、头文件、DLL 动态库、工程与解决方案配置为主,并包含可执行程序、编译中间产物和说明文档,压缩包约 19.74MB,在 VS2010 或 VS2012 下可较顺利通过编译。示例围绕行情与交易两套接口展开,涵盖登录、订阅、回调处理及基础查询等环节,读者自行申请期货账号后,可据此快速跑通 CTP 接入流程,并对照排查环境配置、库依赖和接口初始化问题,并保留清晰的工程目录结构。目前已有 1534 人学习,适合具备一定 C++ 基础、希望获得可运行实战样例的开发者参考,也可作为课程设计、接口联调或二次开发的起点。
1. 从一份能跑的行情接收例子说起:CTP 到底卡在哪
行情接收这件事,写第一行代码只花十分钟,把它稳定跑满一个交易周才是真门槛。CTP 这套接口在期货行情接入里几乎是默认选项,一个头文件、一个动态库、一个继承下来的回调类,就能把交易所的逐笔快照收进自己的进程。但绝大多数人第一次跑通之后都会撞上同一批问题:连接显示成功却一条数据都没有、买一价是个 1.79e308 的天文数字、夜盘的时间戳跨日错乱、跑到后半夜连接无声无息地断了。
这份例子把最小可运行骨架、订阅与回调的字段处理、以及上面这几类现场排错串成一条线。适合刚接手行情落地的开发者,也适合已经能收数据、但对边界情况心里没底、想补一套自检口径的老手。
2. 把编译链路先跑通:头文件、动态库和最小骨架
拿到压缩包先别急着写业务,先把「能编出来、能跑起来、能连上」这三步分开验证。很多人把编译错误和网络错误混在一起看,一出问题就怀疑前置地址写错了,实际是 so 库版本对不上。
2.1 一份行情包拆开是什么
行情包里真正要用的东西不多,但少哪一个都编不过:
| 文件 | 作用 | 缺失后果 |
|---|---|---|
| ThostFtdcMdApi.h | 行情接口类CThostFtdcMdApi与回调基类CThostFtdcMdSpi的声明 | 编不过 |
| ThostFtdcUserApiStruct.h | 所有结构体,含CThostFtdcDepthMarketDataField | 编不过,字段名无从查起 |
| ThostFtdcUserApiDataType.h | 字段长度宏、枚举、类型别名 | 编不过 |
| thostmduserapi_se.so / .dll / .lib | 运行时本体 | 能编过,运行时动态加载失败 |
| error.xml / error.dtd | 错误码描述表 | 能跑,但错误信息只有数字 |
这里有个新手常见误判:行情包里没有交易接口。CThostFtdcTraderApi在交易包里,两个包是分开发布的。有人拿到行情包后想调ReqUserLogin之外的东西,翻遍头文件也找不到,就是拿错了包。
头文件里的结构体字段会随发布批次增补,但接口签名这些年基本稳定。落地的稳妥做法是把头文件和库放进项目third_party/ctp/下随代码一起提交,别去依赖某个系统目录里的版本——换台机器编出来行为不一致,排查成本极高。
2.2 Linux 与 Windows 两套链接方式
Linux 下编译命令不复杂,但库名要去掉lib前缀和扩展名:
# 目录约定:include/ 放头文件,lib/ 放 so g++ -std=c++11 -O2 -o md_recv md_recv.cpp \ -I./include \ -L./lib -lthostmduserapi_se \ -lpthread -ldl # 运行时找不到 so 的临时解法 export LD_LIBRARY_PATH=./lib:$LD_LIBRARY_PATH ./md_recv参数含义逐个说清:-I./include指定头文件搜索路径,只有加了它才找得到ThostFtdcMdApi.h;-L./lib指定库搜索路径;-lthostmduserapi_se对应磁盘上的libthostmduserapi_se.so,链接器会自动补前缀和后缀;-lpthread是必需的,接口内部会起工作线程;-ldl是为了动态符号解析,缺了在某些发行版上会报undefined reference to dlopen。
如果报skipping incompatible,几乎一定是位数不匹配:库是 32 位、编译器默认出 64 位,或者反过来。用file libthostmduserapi_se.so看一眼就清楚,别在这上面耗时间。
Windows 下走 MSVC,把.lib加进附加依赖项,.dll放到 exe 同目录。运行库选项要统一:你选/MD,依赖的库也得是/MD编的,混用/MT会在对象析构时崩,而且崩的位置看起来和 CTP 毫无关系。
提示:先单独编一个只调用
CreateFtdcMdApi和Release的空程序,确认链接跑通,再往里加业务逻辑。这一步能省掉后面一半的排错时间。
2.3 最小可运行骨架
骨架只做三件事:建实例、注册回调、连前置。业务先不写。
#include "ThostFtdcMdApi.h" #include <cstdio> #include <cstring> class MdSpi : public CThostFtdcMdSpi { public: explicit MdSpi(CThostFtdcMdApi* api) : api_(api) {} // TCP 建连成功后由 API 线程回调,登录只能在这里发起 void OnFrontConnected() override { std::printf("[connected]\n"); CThostFtdcReqUserLoginField req; std::memset(&req, 0, sizeof(req)); // BrokerID 由开户方给,通常是四位数字;UserID 与 Password 同理 std::strncpy(req.BrokerID, "9999", sizeof(req.BrokerID) - 1); std::strncpy(req.UserID, "your_id", sizeof(req.UserID) - 1); std::strncpy(req.Password, "your_pwd", sizeof(req.Password) - 1); int ret = api_->ReqUserLogin(&req, ++reqid_); std::printf("[ReqUserLogin] ret=%d\n", ret); } void OnRspUserLogin(CThostFtdcRspUserLoginField* pRsp, CThostFtdcRspInfoField* pInfo, int reqid, bool last) override { // pInfo 非空且 ErrorID 非 0 才是业务失败,别只看指针 if (pInfo && pInfo->ErrorID != 0) { std::printf("[login failed] id=%d msg=%s\n", pInfo->ErrorID, pInfo->ErrorMsg); return; } std::printf("[login ok] tradingday=%s\n", pRsp ? pRsp->TradingDay : ""); // 订阅必须放在这里,放 OnFrontConnected 里会静默失败 } private: CThostFtdcMdApi* api_; int reqid_ = 0; }; int main() { // 第一个参数是流文件目录,必须以分隔符结尾,且目录要事先存在 CThostFtdcMdApi* api = CThostFtdcMdApi::CreateFtdcMdApi("./flow/"); MdSpi spi(api); api->RegisterSpi(&spi); // 前置地址固定 tcp://ip:port 形式 char front[] = "tcp://127.0.0.1:41213"; api->RegisterFront(front); api->Init(); // 异步:立即返回,真正的连接在后台线程里做 api->Join(); // 阻塞主线程,直到 Release 被调用 api->Release(); return 0; }几个参数必须解释。CreateFtdcMdApi的第一个参数是流文件落地目录,接口会在这个目录下写续传用的私有流和公有流文件,传空字符串等于用当前工作目录;后两个参数是「是否用 UDP」和「是否用组播」,除非经纪商明确告知开了组播,一律保持默认false。RegisterFront接受char*而非const char*,直接把字符串字面量传进去在部分编译器上会报错,用数组接一下最稳。
Init()是异步的,返回 0 不代表连上了,只代表请求发出去了。真正的连接结果在OnFrontConnected里。Join()会一直阻塞,所以它必须放在主线程末尾;如果程序自己有事件循环,可以不用Join,改成自己sleep保活。
3. 连接与登录:前置地址、流文件目录和回调时序
骨架能跑起来之后,下一步是把「连上」和「登录成功」当成两件事分别验证。这两件事的失败表现完全不同,但报错信息都很含糊,不看时序基本靠猜。
3.1 前置地址从哪来、格式怎么核对
地址形如tcp://1.2.3.4:41213,协议头、IP、端口三部分缺一不可,写成1.2.3.4:41213会因为解析失败直接连不上。行情前置和交易前置是两个不同的端口,行情端口常见的是 41213 这类,交易端另有其值,具体以开户方给的清单为准——不要拿网上抄来的地址硬试,环境不匹配永远连不上。
同一家经纪商通常有三套环境:生产、仿真、以及用于周末调试的 7x24 环境。三套地址互不相通,账号也互不相通。血泪经验是:把地址和账号写进配置文件,并且在启动日志里打印「当前连的是哪套环境」,不然某天误连仿真环境收不到行情,会怀疑人生。
RegisterFront可以调多次,注册多个前置做冗余。但要注意它必须在Init()之前调用,Init()之后再注册不会生效,且不会报错——这是个典型的静默失败。
3.2 流文件目录:一个字符决定能不能连上
流文件目录有三个硬性要求,缺一个就是连不上或者连上后随机断:
第一,目录必须事先存在。接口不会帮你递归创建,路径不存在时初始化阶段就可能异常退出,而且退出得很难看。
第二,路径要以分隔符结尾。Linux 下写成./flow/,Windows 下写成.\\flow\\。少写这个斜杠,文件会被拼成一个奇怪的路径名,表现是「有时候能连、有时候不能」。
第三,进程必须有写权限,且同一目录不能被多个进程同时使用。两个进程写同一批流文件,会让续传位置互相覆盖,出现典型的玄学现象:单跑没问题,双跑就随机丢数据。
生产环境的常见做法是按「进程名 + 启动日期」建目录,比如./flow/md_recv_20260101/,每天一个新目录,旧目录定期清理。这样出问题时能直接从流文件里还原当时的续传状态。
3.3 Init 到 OnRspUserLogin 的完整时序
正确的调用顺序是固定的,插队一定失败:
CreateFtdcMdApi → RegisterSpi → RegisterFront → Init ↓(异步,秒级) OnFrontConnected ↓ ReqUserLogin ↓ OnRspUserLogin(ErrorID == 0) ↓ SubscribeMarketData ↓ OnRspSubMarketData → OnRtnDepthMarketData(持续推送)最容易错的一步是把ReqUserLogin写在Init()后面。此时 TCP 还没建好,返回值通常是 -1,请求被直接丢弃。另一个错法是把订阅写在OnFrontConnected里,登录还没完成,订阅请求会被忽略,并且OnRspSubMarketData里返回的错误信息很容易被忽略掉。
返回值分两层看。接口函数的返回值是「请求是否成功发出」:0 成功,-1 网络连接失败,-2 未处理请求超过许可数,-3 每秒发送请求数超限。业务结果在回调的pInfo->ErrorID里,非 0 才是真的失败。
登录失败里最常见的是ErrorID = 3,含义是账号、密码或经纪商代码三者有一项对不上。次常见的是「用户禁止登录」,一般是账号被占用或该账号不支持行情登录。还有一类是登录成功但TradingDay返回空,多半是连到了非交易时段的环境,账号本身没问题。
注意:
OnRspUserLogin里拿到的TradingDay是交易所交易日,不是自然日。夜盘 21 点之后,它显示的是下一个工作日。这个字段决定了后面所有数据该落到哪个文件里。
4. 订阅与行情回调:从 SubscribeMarketData 到 DepthMarketData 字段落地
登录通了之后,真正决定数据质量的环节在订阅和回调之间。订阅写错,报错还算明显;回调字段处理错,数据能一路流到数据库里,等你发现的时候已经脏了好几天。
4.1 订阅接口的参数形态
订阅的签名是数组加数量,不是逗号分隔的字符串:
// 合约代码按各交易所习惯写:沪铜 cu2601、螺纹 rb2601、豆粕 m2601 const char* codes[] = {"cu2601", "rb2601", "m2601"}; char* arr[3]; for (int i = 0; i < 3; ++i) arr[i] = const_cast<char*>(codes[i]); // nCount 是数组元素个数,不是字节数 int ret = api_->SubscribeMarketData(arr, 3); if (ret != 0) { std::printf("[subscribe send failed] ret=%d\n", ret); }nCount传错是很常见的翻车点:传成sizeof(arr)会变成 24(64 位下指针数组),接口会去读越界内存,行为不可预期。
单次订阅的合约数量要控制。一次性丢几百个合约进去,接口会被流控拦下,返回 -3;就算发出去了,回调也会在短时间内堆积。稳妥的批次是每批 50 到 100 个,批与批之间sleep一二百毫秒。订阅是幂等的,重复订阅同一个合约不会报错,也不会重复推送,所以断线重连之后无脑全量重订是最省事的做法。
合约代码本身还有两个细节:字母部分的大小写要跟交易所习惯一致,部分品种的月份是三位而非四位;另外同一个代码在不同交易所可能都存在,判断归属要看回调里的ExchangeID,不能靠代码前缀猜。
4.2 DepthMarketData 字段逐项拆
回调里给你的是一个快照结构体,字段多且有几个是陷阱:
| 字段 | 含义 | 落地时的注意事项 |
|---|---|---|
InstrumentID | 合约代码 | 直接作为主键一部分 |
ExchangeID | 交易所标识 | 同名合约靠它区分 |
LastPrice | 最新价 | 集合竞价、无成交时可能为 0 |
PreSettlementPrice | 昨结算 | 算涨跌幅用这个,不是昨收 |
PreClosePrice | 昨收盘 | 商品期货里参考价值有限 |
Volume | 当日累计成交量 | 必须差分才是本 tick 量 |
Turnover | 当日累计成交额 | 同上,且单位随品种不同 |
OpenInterest | 持仓量 | 也是当日值,可直接用 |
BidPrice1/AskPrice1 | 买一 / 卖一 | 无报价时是DBL_MAX |
BidVolume1/AskVolume1 | 买一量 / 卖一量 | 与价格同步过滤 |
UpperLimitPrice/LowerLimitPrice | 涨停 / 跌停 | 校验异常值的第一道闸 |
UpdateTime | HH:MM:SS | 没有日期部分 |
UpdateMillisec | 毫秒 | 与上者拼起来才是完整时刻 |
TradingDay | 交易日 | 分文件的依据 |
ActionDay | 自然日 | 拼时间戳用这个 |
AveragePrice | 均价 | 部分品种不推,值为 0 |
最容易漏的是「累计量」这件事。Volume是当日累计值,不是这一笔的成交量。直接把它当 tick 量入库,画出来的量柱是一条单调上升的斜线,而且没人会立刻发现,因为数值看起来一直很合理。
4.3 时间戳、累计量、异常价:把快照变成可用 Tick
把上面三个陷阱一次性处理掉,回调体大致长这样:
void OnRtnDepthMarketData(CThostFtdcDepthMarketDataField* d) override { if (!d) return; // 1) 时间戳:接口不给完整日期,用自然日 + 时间 + 毫秒拼 char ts[40]; std::snprintf(ts, sizeof(ts), "%s %s.%03d", d->ActionDay, d->UpdateTime, d->UpdateMillisec); // 2) 无报价时买卖价被填成 DBL_MAX,先过滤再参与计算 const double kInvalid = 1e300; double bid = (d->BidPrice1 > kInvalid) ? NAN : d->BidPrice1; double ask = (d->AskPrice1 > kInvalid) ? NAN : d->AskPrice1; // 3) Volume 是当日累计,差分出本 tick 成交量 auto it = last_vol_.find(d->InstrumentID); long long tick_vol = (it == last_vol_.end()) ? d->Volume : d->Volume - it->second; last_vol_[d->InstrumentID] = d->Volume; if (tick_vol < 0) tick_vol = 0; // 跨交易日累计量归零会短暂为负 // 4) 落盘/入队放在这里只做「投递」,不做同步 IO queue_.push(Tick{ts, d->InstrumentID, d->LastPrice, bid, ask, tick_vol, d->OpenInterest}); }第 1 步的拼接顺序不能换。ActionDay是自然日,TradingDay是交易日,两者在夜盘期间差一天。要不要用ActionDay拼时间戳,取决于你想让数据按「真实发生时刻」排序还是按「交易日」归档——绝大多数回测场景要的是前者,归档目录用后者。
第 2 步的阈值判据不要写成== DBL_MAX。有些品种在涨跌停时价格被填成一个极大但非标准的数,用> 1e300这种量级判断更稳。
第 3 步里tick_vol < 0的兜底不是洁癖。跨交易日的那一刻,累计量从当日总量跳回 0,差分结果就是负的。没有这行兜底,后面的成交量统计会出现一个巨大的负值尖峰。
第 4 步是性能关键。OnRtnDepthMarketData跑在接口内部线程上,同步写文件、同步插库、甚至过多的printf都会拖慢回调,堆积到一定程度就是丢包。正确做法是丢进无锁队列,由独立的消费者线程落盘。
5. 排查:行情收不到、价格异常、半夜断线的五种现场
下面五条都是接手行情链路时高概率遇到的现场,按「现象 → 原因 → 解决」记,遇到同类问题直接对号入座。
5.1 现象:连接成功、订阅返回 0,但一条行情都没有
现象是OnFrontConnected打印了,OnRspUserLogin返回成功,SubscribeMarketData返回 0,然后日志安静得像没人。原因通常是三个:一是当前不是交易时段,非交易时段的行情前置照样能登录,但不会推送任何数据;二是合约代码写错,订阅请求发出去了、OnRspSubMarketData里其实带着错误但被忽略了;三是订阅写在了OnFrontConnected而非OnRspUserLogin里。
解决办法是先把OnRspSubMarketData的pInfo打印出来,看ErrorID是否非 0;再确认当前时间是否落在交易时段内(商品期货有夜盘,金融期货没有);最后核对合约代码的大小写和月份位数。三步走完,绝大多数「订阅了没数据」能定位。
5.2 现象:买一价是 1.7976931348623157e+308
现象是入库之后发现有大量行情的BidPrice1是这个数字,或者是一个接近1e308的量级。原因是接口用DBL_MAX填充「当前无报价」的档位,常见的触发场景是集合竞价阶段、涨跌停封板、以及刚开盘还没撮合出买卖盘的那一刻。
解决办法是在回调入口做量级过滤:凡是大于1e300的价格字段一律置为无效值(NaN或数据库NULL),买卖量字段同步处理。不要在 SQL 里用where bid > 1e300这种事后补救,因为下游算价差、算中间价的时候已经把这个数带进去了。
5.3 现象:夜盘 tick 的时间戳比系统时间差一天
现象是白天一切正常,夜盘时入库的时间戳落在昨天,按天聚合时数据被劈成两半。原因是用TradingDay去拼时间戳了——夜盘 21 点之后TradingDay是下一个工作日,而ActionDay才是当下的自然日。
解决办法是拆开两个用途:拼时间戳用ActionDay + UpdateTime + UpdateMillisec,归档目录用TradingDay。这两个字段在结构体里挨着,最容易顺手拿错。
5.4 现象:跑几个小时后回调变慢,然后连接断开
现象是程序启动后前几小时正常,之后 tick 间隔越拉越大,日志出现心跳超时警告,最终OnFrontDisconnected触发。原因基本都是在回调里做了重活:同步写 CSV、同步insert数据库、每条都printf刷屏、或者在回调里做跨合约的聚合计算。
解决办法是把回调体压缩到只做「组装结构体 + 入队」两件事,落盘交给独立线程批量写。如果确实需要在回调里加日志,改成按秒采样而不是逐条打印。改完之后同样的行情量,CPU 占用能降一个量级。
5.5 现象:换个工作目录就起不来,或者第二个进程永远连不上
现象是同一份二进制,在 A 机器能跑,在 B 机器初始化阶段就崩;或者起了两个进程,第二个一直连不上。原因是流文件目录的问题:一是路径不存在或没有写权限;二是路径结尾少了分隔符,文件被写到奇怪的位置;三是两个进程共用同一个流文件目录,互相覆盖续传位置。
解决办法是启动时先mkdir -p建目录,路径用绝对路径加结尾分隔符拼接,每个进程用独立目录。另外Release()一定要调用,异常退出时用信号处理兜一下,否则残留的流文件和连接状态会让下一次启动表现异常。
注意:
OnFrontDisconnected的nReason值得打印出来。读失败、写失败、心跳超时、登录超时对应不同的原因码,看码比看现象快得多。
6. 进阶:断线自愈、Tick 落盘和三个对账口径
能收到数据只是及格线,能证明数据是对的才算落地。断线自愈的第一原则是:绝不在回调线程里重建实例。OnFrontDisconnected里只做一件事——置一个connected_ = false的标志位,然后把重连交给独立的守护线程:
void OnFrontDisconnected(int nReason) override { std::printf("[disconnected] reason=0x%x\n", nReason); connected_ = false; // 只改标志,不动 API 对象 } // 守护线程主循环,指数退避,上限 30 秒 void GuardLoop() { int backoff = 1; while (running_) { if (!connected_) { api_->Release(); // 先释放旧实例 api_ = CThostFtdcMdApi::CreateFtdcMdApi(flow_dir_.c_str()); api_->RegisterSpi(&spi_); for (auto& f : fronts_) api_->RegisterFront(f.data()); api_->Init(); std::this_thread::sleep_for(std::chrono::seconds(backoff)); backoff = std::min(backoff * 2, 30); } else { backoff = 1; std::this_thread::sleep_for(std::chrono::seconds(1)); } } }这里有两个必踩过的点。一是订阅关系不跨连接保留,重连成功、登录成功之后必须重新全量订阅一遍,指望接口记住上次订阅的合约是不行的。二是Release()之后旧指针立即失效,所有持有它的地方都要换成新指针,别在别处缓存了裸指针还在用。
落盘这边,我一般按TradingDay/InstrumentID两级目录分文件,格式用 CSV 或长度固定的二进制,字段顺序固定,写完立刻fflush到页缓存但不fsync,靠进程正常退出时刷盘,追求的是吞吐而不是断电不丢。
验证数据对不对,靠三个口径对账,比任何日志都可靠。第一,收盘后把当天所有 tick 的tick_vol累加,和交易所公布的日成交量比,误差应该在千分之几以内;误差大说明回调丢了包。第二,检查每分钟的 tick 条数分布,正常品种在活跃时段应该基本平稳,出现整分钟为零就是断线没被发现。第三,抽样比对LastPrice是否始终落在LowerLimitPrice和UpperLimitPrice之间,越界就是脏数据没过滤干净。
从那以后我每次新接一路行情,都强制先把这三个对账脚本跑一遍再交付,哪怕当天数据看起来完美——因为丢包和脏数据从来不会自己举手。希望帮到你。
本文还有配套的精品资源,点击获取