简介:这份C++编写的高频交易源码程序,面向量化交易开发者与金融工程学习者,展示了基于盘口价差等实时行情数据的高频交易系统实现路径,涵盖交易算法、低延迟网络通信、实时数据处理与风险管理等核心模块。资源包共16个文件,以5个cpp源文件与4个hpp头文件为主,辅以2个conf配置文件和完整Visual Studio工程文件(sln/vcxproj),压缩包仅20KB,便于快速下载研读。目前已有2574人学习,源码内含主程序、配置解析、日志等模块,可帮助读者理解高频交易中统计套利、流动性捕获等算法思路,以及多线程并行、TCP/socket编程、风控止损等关键技术的落地实现方式。通过分析该项目,开发者能够掌握从行情订阅到交易决策的完整链路,提升在C++高性能编程与金融系统设计方面的实战能力。
1. 高频交易 C++ 源码:这一包东西到底在解决什么
在量化圈子里聊到高频交易 C++ 盘口价差,多数人第一反应是“这得要多快的机器、多贵的行情”才能跑起来。但真实情况是,很多团队最早期的雏形,就是一台普通服务器、一个行情订阅,加上一套用 C++ 写好的订单簿重建和价差信号模块。我拆过几轮类似的东西,这个标题指向的资源,本质上不是帮你“上一套生产级交易系统”,而是给你一条能动手复现的链路:从行情推送进来,到订单簿更新,再到盘口价差计算和信号输出。适合的人有两种:一种是想搞懂高频链路里订单簿到底怎么维护的新手,另一种是手里有策略但总觉得延迟毛刺大的熟手。往下走,我会把这块源码包里的核心模块、关键参数和踩坑点一一拆开。
2. 盘口价差与订单簿:从增量委托到价差信号的核心实现
2.1 订单簿的数据结构:为什么不能直接套 std::map
高频交易里最常见的一个翻车点,是觉得订单簿就是一个价格到数量的映射,直接拿std::map<double, int>维护买一卖一就行。这个思路在低并发低频次行情下当然没问题,可一旦进入高频场景,std::map的树形结构加内存分配就会让延迟变得极不稳定。常见的做法是固定深度数组加哈希索引,买盘和卖盘各维护一个按价格排序的定长数组,再用一个unordered_map记录价格到数组下标的映射。
struct Level { double price_; double qty_; }; class OrderBook { public: static const size_t MAX_LEVELS = 10; Level bids_[MAX_LEVELS]; Level asks_[MAX_LEVELS]; double best_bid_ = 0.0; double best_ask_ = 0.0; // 缓存行对齐,避免伪共享 alignas(64) uint64_t seq_; };这段代码里的MAX_LEVELS一般取 10 或 20,对应行情推送的十档或二十档。seq_是收到的行情序号,每次增量更新后都要记录,用来判断增量是否产生乱序。固定数组意味着更新时直接从哈希索引找到价格槽位改数量,不需要频繁分配内存。对齐到 64 字节是为了防止多线程读写时出现伪共享(false sharing),这个后面还会再提。在盘口价差计算这个场景里,买一卖一直接读bids_[0]和asks_[0],操作复杂度是 O(1),延迟可以稳定在微秒级以内。
2.2 价差计算与盘口不平衡:从快照里提炼信号
有了订单簿结构,价差信号计算就非常直接。价差就是卖一价减买一价,盘口不平衡则是买一挂单量占买卖一总挂单量的比例。别小看这两个指标,很多策略的原始因子就是从它们衍生出来的。比如最常见的均值回归逻辑:当盘口不平衡超过 0.7 且价差收窄到历史低位,短期价格向上反弹的概率会增大。
struct Snapshot { double spread_; double imbalance_; uint64_t seq_; }; Snapshot compute_snapshot(const OrderBook& book) { Snapshot snap; snap.seq_ = book.seq_; snap.spread_ = book.best_ask_ - book.best_bid_; double bid_qty = book.bids_[0].qty_; double ask_qty = book.asks_[0].qty_; double total = bid_qty + ask_qty; snap.imbalance_ = total > 0.0 ? bid_qty / total : 0.0; return snap; }这里的spread_单位是价格最小变动单位(tick size)的倍数,比如股指期货最小变动是 0.2 个点,价差等于 1 就意味着有一个 tick 的跳档。imbalance_的取值范围是 0 到 1,越接近 1 表示买盘越强,越接近 0 表示卖盘越强。实际使用中很多策略不会直接用原始不平衡值,而是和过去 N 笔的平均值做差,变成偏离度指标。这块源码包大概率会自带一个MovingAverage模块,需要确认它的窗口长度是用 tick 数还是用时间跨度。用 tick 数在成交稀疏时会产生窗口时间过短的问题,我一般习惯改成时间窗口。
2.3 增量快照的时序错位:seq gap 必须显式处理
做盘口价差信号,最隐蔽的坑是增量行情和快照行情的拼接。很多交易所先把当前全量快照推给你,再推送后续增量,但增量本身有独立的序号,一旦网络抖动丢了一条,后续所有增量都会在错误的基础上累加,价差和挂单量全部失真。源码包里如果只给了OrderBook和Snapshot的类定义,没有处理 seq 断档,那实盘跑起来就是黑匣子状态:看着信号在出,其实底层订单簿已经跟真实盘面差十万八千里。
std::vector<IncrementalUpdate> pending_; uint64_t last_seq_ = 0; void apply_update(const IncrementalUpdate& upd, OrderBook& book) { if (upd.seq != last_seq_ + 1) { if (upd.seq > last_seq_ + 1) { pending_.push_back(upd); // 缓存乱序增量 return; } // seq 小于期望值,说明是重复推送,直接丢弃 return; } apply_single_update(upd, book); last_seq_ = upd.seq; // 尝试把缓存里的增量按顺序补上 sort(pending_.begin(), pending_.end(), [](const auto& a, const auto& b) { return a.seq < b.seq; }); for (auto& p : pending_) { if (p.seq == last_seq_ + 1) { apply_single_update(p, book); last_seq_ = p.seq; } } }这段代码的思路是先把乱序增量缓存起来,等缺失的序号补上后再统一应用。注意pending_必须按序号排序,否则缓存里的增量仍然可能乱序。我自己实际用的时候还会加一个最大缓存深度,比如 1000 条,超过这个数量直接把订单簿标记为不可用,等待下一次全量快照重建,防止旧数据把新快照污染掉。这个参数源码包里可能没有暴露,但强烈建议无论如何都要加上,否则你看到的价差在一段时间内可能整个反了。
3. 延迟优化:无锁队列与时间线裁剪的三个关键参数
3.1 先从时间线说起:延迟都花在哪了
高频交易里延迟不是靠某一招就能压下来的,要先拆时间线再逐段优化。一段典型路径是:行情数据从网卡到达,经过内核协议栈到用户态程序,程序解析增量包,更新订单簿,计算价差信号,最后提交委托。每一步都有延迟,其中最容易被忽视的是内存拷贝和锁竞争。
常见做法是程序启动后就把线程绑定到独立的 CPU 核心,行情线程、信号线程、风控线程分别绑定不同核心,再用无锁队列做线程间的数据分发。为什么不能直接共享一个订单簿对象加锁?因为互斥锁在竞争激烈时会导致线程被内核挂起,唤醒时延迟可能飙升到几十微秒,这在高频场景里基本等于翻车。无锁队列是解决这个问题的标准方案,但也要注意,无锁不等于无限快,队列的容量和读写频率直接影响延迟表现。
3.2 无锁 SPSC 队列的落地实现
Single Producer Single Consumer(SPSC)队列是高频场景里使用最普遍的组件,因为行情线程和计算线程之间的数据流通常是一对一的。实现上有几个关键点:ring buffer 容量必须是 2 的 N 次幂,读写指针要用std::atomic并配合缓存行填充,避免两个线程频繁失效同一缓存行。
template <typename T> class SPSCQueue { static constexpr size_t CACHE_LINE = 64; alignas(CACHE_LINE) std::atomic<size_t> head_{0}; alignas(CACHE_LINE) std::atomic<size_t> tail_{0}; std::vector<T> buffer_; size_t capacity_; public: explicit SPSCQueue(size_t capacity) : capacity_(capacity), buffer_(capacity) {} bool enqueue(const T& item) { size_t h = head_.load(std::memory_order_relaxed); size_t t = tail_.load(std::memory_order_acquire); if (t - h >= capacity_) return false; // 满了 buffer_[t % capacity_] = item; tail_.store(t + 1, std::memory_order_release); return true; } bool dequeue(T& item) { size_t h = head_.load(std::memory_order_acquire); size_t t = tail_.load(std::memory_order_relaxed); if (h >= t) return false; // 空了 item = buffer_[h % capacity_]; head_.store(h + 1, std::memory_order_release); return true; } };代码里关键点是head_和tail_分别对齐到 64 字节,这两个变量如果落在同一缓存行,写指针更新会导致读指针缓存失效,反而比加锁还慢。capacity_是队列容量,一般设置成 8192 或 16384,太小会频繁丢行情,太大又浪费内存。容量参数看着简单,实际上非常有讲究:如果容量太大而消费线程跟不上,行情线程会一直塞,积压信号,导致信号时间戳严重滞后;容量太小又会直接丢弃行情,造成数据断层。我的习惯是容量设为运行时一秒最大消息数的两倍,然后压测调优。
3.3 线程绑定与预热的隐藏成本
线程绑定到 CPU 核心后,延迟稳定性会明显提升,但很多人忽略了 NUMA 架构下的内存亲和性。绑定了核心,线程访问的内存却是在另一个 NUMA 节点上,跨节点内存访问的延迟比本地节点高很多,结果就是吞吐率反而下降。常见做法是用sched_setaffinity绑定线程,同时确保订单簿对象和行情线程、信号线程分配在同一 NUMA 节点。
#include <sched.h> #include <pthread.h> void bind_thread(int core_id) { cpu_set_t cpuset; CPU_ZERO(&cpuset); CPU_SET(core_id, &cpuset); pthread_setaffinity_np(pthread_self(), sizeof(cpu_set_t), &cpuset); }core_id的选择不能只看 CPU 编号,还要看这个核心对应的 NUMA 节点。在 Intel 平台上,通常 0 号和 1 号核心在一个节点,2 号和 3 号在另一个节点,具体可以通过读取/sys/bus/node/devices/node*/cpu*确认。还有一个容易被忽视的成本是首次访问订单簿数组时会发生缺页中断,延迟可能冲到毫秒级。源码包里如果没做预热,建议在 main 函数创建完订单簿后立刻写一遍所有字段,把物理内存全部映射好,后面运行就不会再触发缺页。
4. 实战复现:把盘口价差模块跑起来并调出可用信号
4.1 源码包结构解构:先看清有哪些模块
拿到源码包后不要急着编译,先把目录结构看一遍。一个可用的高频交易 C++ 源码包至少应该有四块:行情解析模块、订单簿管理模块、信号计算模块和回测辅助模块。行情解析模块负责把二进制协议解码成内部结构体;订单簿管理模块维护买卖盘档位;信号计算模块从订单簿快照里产出价差和不平衡度;回测辅助模块则提供历史行情回放和简单的撮合模拟。
文件的组织方式我见过不少种,最常见的是一堆.cpp和.h直接平铺在src/目录下。源码包的构建系统可能是CMakeLists.txt,也可能是老式Makefile。如果是 CMake,直接cmake -B build && cmake --build build可以一次编过;如果只有 Makefile,注意看一下是不是依赖了某些第三方库的路径,比如libuv或libevent,路径配错会浪费很多时间。
4.2 关键参数调节:价差阈值、深度与采样频率
编译通过之后,下一步是把程序跑起来,但直接跑默认参数通常看不到理想信号。高频交易源码包里可调的一般就三类参数:价差阈值、订单簿深度和采样频率。价差阈值决定信号触发的灵敏度,设得太小会频繁开仓,手续费和滑点吃掉利润;设得太大策略又基本不触发。订单簿深度影响盘口不平衡的计算口径,有的策略看一档,有的看五档,不同合约的盘口深度规律完全不同。采样频率则决定信号计算的时间粒度,这个参数往往最玄学:频率太高信号噪声大,频率太低又捕捉不到瞬时价差收窄。
struct StrategyConfig { double spread_threshold = 1.0; // 单位:tick size_t depth = 5; // 参与计算的档位数 int sample_interval_ms = 10; // 采样间隔,毫秒 double imbalance_ceil = 0.75; // 不平衡上界 double imbalance_floor = 0.25; // 不平衡下界 };depth参数要重点说明一下:参与盘口不平衡计算的档位不是越多越好。部分高频做市商会把整个订单簿前五档都算进加权不平衡,但对多数小资金策略来说,三档以内反而是噪声最小的。因为订单簿深度越深,挂单量越可能包含大量非真实意图的对倒单和止损单,把这些量算进信号里,结果就是信号跟盘口真实意图偏离。我一般会在回测里分别试depth = 1、3、5三组参数,再对比最终的盈亏比,而不是凭感觉拍脑袋定。
4.3 回测与模拟撮合:成交假设比策略本身更决定结果
源码包里如果附带回测模块,务必先确认它的撮合逻辑。很多源码包会犯一个明显的错误:信号触发后直接按当前盘口价格全部成交。但真实订单簿里你的委托能不能成交,取决于对手盘有多少量、你的委托价格排在第几位。模拟撮合至少要做一步“部分成交 + 滑点惩罚”处理:
void simulate_fill(const Snapshot& snap, double signal_price, int64_t order_qty, FillResult& out) { double fill_price = signal_price; int64_t remaining = order_qty; // 价格越激进,成交量越大,但滑点越差 double aggressiveness = 0.0; if (signal_price <= snap.best_bid_) { aggressiveness = 0.5; // 主动吃单 } else { aggressiveness = 0.1; // 挂单等待 } double fill_probability = 0.0; if (aggressiveness > 0.3) { fill_probability = std::min(1.0, snap.imbalance_); } else { fill_probability = std::min(0.5, 1.0 - snap.imbalance_); } if (fill_probability < 0.2) { remaining = 0; // 大概率成交不了 } out.fill_qty = order_qty - remaining; out.avg_price = fill_price * (1.0 + aggressiveness * 0.0002); }这里的aggressiveness是根据信号价格与当前盘口的相对位置估计的主动成交概率参数,fill_probability则把盘口不平衡纳入成交概率。逻辑是:盘口买盘强时,买单成交概率更高,卖单则反之。这个模拟肯定做不到完全准确,但至少比“全成交”假设靠谱得多。回测时如果发现策略在模拟撮合下亏钱,而在全成交假设下赚钱,那基本可以判定策略的真实盈利能力趋近于零。这也是源码包评估里最值得关注的一件事:它给你的是一套会让人产生错觉的成交模型,还是一次清醒的摩擦成本教育。
5. C++ 高频交易常见问题与避坑排查:延迟毛刺、数据错位与死锁
5.1 延迟毛刺从十几微秒涨到几毫秒
现象:程序刚开始运行非常稳,单次更新延迟稳定在 15 微秒以内,跑了半小时后突然出现 2 到 3 毫秒的尖峰,随后又恢复,但尖峰出现频率越来越高。
原因:最常见的两个来源,一个是std::string或容器在运行时触发堆分配,分配器遇到堆碎片或系统调用时延迟飙升;另一个是线程被调度器迁移到了其他 CPU 核心,造成缓存全部冷掉。
解决:第一步先用perf record -e context-switches抓一下线程切换次数,如果切换次数高,核心级绑定没生效,重新确认pthread_setaffinity_np是否在创建线程之后立刻调用。第二步扫描代码里有没有在热路径上使用std::string做拼接或解析,特别是行情解析模块,尽量改成对char*缓冲区的直接操作。我拆这个源码包时就亲眼见过一组代码用std::stringstream去拼行情日志,直接把延迟从微秒级拉到几十微秒。
5.2 卖一价低于买一价,价差出现负值
现象:程序偶尔计算出价差为负数,有时甚至连续好几笔都是负的。按常理,真实的买卖一档价差不可能为负,这个数据明显是错的。
原因:增量委托没有严格按照序号顺序应用。某个价格档位的买单价被更新成新价格后,卖一档还没来得及同步更新,或者增量包本身乱序到达,订单簿暂时处于中间不一致状态。
解决:严格检测增量序号。前面 2.3 节提到的 pending 缓存是最基本的防护,但如果已经有负价差的数据被下游策略消费了,还得在compute_snapshot里加一道防御:
Snapshot compute_snapshot(const OrderBook& book) { Snapshot snap; if (book.best_bid_ > 0.0 && book.best_ask_ > book.best_bid_) { snap.spread_ = book.best_ask_ - book.best_bid_; } else { snap.spread_ = std::numeric_limits<double>::quiet_NaN(); snap.imbalance_ = 0.0; return snap; } // 正常计算 imbalance }这段防御逻辑的意义在于:宁可让信号变成 NaN 也不让错误数据进入策略。用 NaN 的好处是策略端会显式报错,而不是顶着错误价差继续跑。很多策略写得不严谨,价差为负时照样算买卖方向,结果开出方向完全反向的单子。
5.3 多线程共享订单簿导致数据错乱
现象:行情线程负责更新订单簿,信号线程同时读订单簿算价差,程序跑一段时间后价差信号突然彻底失真,比如价格长时间不变或挂单量明显异常。
原因:两个线程同时访问同一个std::vector<Level>,并且代码里用互斥锁保护读操作。互斥锁虽然能防止数据竞态,但在高并发下信号线程会阻塞在锁上,阻塞期间订单簿更新线程继续写入,等信号线程拿到锁时读到的已经是更新后的快照,但读了一半又被打断,造成逻辑上的数据撕裂。
解决:这是典型的架构问题,不能靠加锁解决。正确做法是订单簿更新线程和信号线程之间改用无锁队列传递快照副本,或者只有更新线程能写订单簿,信号线程只读一个用原子指针交换的只读快照。高频场景下双缓冲加原子指针是更常见的方案:
std::atomic<OrderBook*> active_book_; OrderBook book_a_; OrderBook book_b_; void swap_book() { active_book_.store(&book_b_, std::memory_order_release); }active_book_指向当前有效订单簿,更新线程写完book_a_后把指针切到book_a_,信号线程每轮循环先 load 指针再读数据。这个方案需要保证信号线程读的时候,更新线程不会因为重新写同一个对象而产生撕裂。现实中更保险的做法是干脆把订单簿设计成不可变快照,每次更新生成新快照,再用环形缓冲传递。
5.4 回测盈利,实盘连续亏损
现象:回测结果非常好,年化收益漂亮得惊人,一上实盘就连续亏损,手续费和滑点把利润全部吃光。
原因:回测的成交假设过于乐观,典型的有两个错误。一是所有委托都按信号触发时的盘口价成交,忽略了自己下单价可能落在队列尾部,等排到自己时价格早已变迁;二是回测周期只覆盖了单边行情,没有覆盖震荡行情,策略在震荡中反复止损。
解决:在回测里加一个滑点模型,每个信号都按买卖一档价差的一半作为基础滑点惩罚。比如价差是 1 个 tick,那每笔成交价在信号价基础上偏移 0.5 个 tick。如果修改后策略还能盈利,再考虑上实盘。扩回测周期也很重要,至少要覆盖一个完整的震荡区间和一段趋势区间,很多源码包只给了利润曲线不给你行情区间,这种结果参考价值有限。
5.5 CPU 绑定后性能反而下降
现象:给线程绑定了独立 CPU 核心,延迟没降,吞吐率反而掉了 30% 以上,而且 CPU 占用率居高不下。
原因:大概率是绑定的核心和线程频繁访问的内存在不同 NUMA 节点上。线程在 node0 上执行,但订单簿数组分配在 node1 的内存,每次访问都要跨节点走 QPI/UPI 总线,延迟增加,缓存命中率下降,整体性能反而比不绑定时更差。
解决:调整内存分配策略,在程序启动时先用numactl --membind=node0指定内存分配节点,或者直接用mbind系统调用把关键缓冲区分到指定节点。绑定线程前先查看/sys/bus/node/devices/node0/cpulist,搞清楚哪些核心在哪个节点上,再决定线程往哪个核心绑。这也是我在拆源码包时最常提醒的问题,很多写法上没错,硬件上错得离谱。
6. 进阶验证:用确定性回放找出信号的最后一毫秒
前面把订单簿、信号计算和延迟优化的链路都走完了,这套源码包在你手里能跑、能出信号,但这还不够。高频交易里最难抓的问题不是功能错误,而是“看起来正确但慢了几毫秒”的逻辑。每一步都正确,整体却可能因为一次不必要的拷贝或等待多花了几十微秒。验证这个问题的可靠方法,是给这套代码做一次确定性回放。
确定性回放的思路很简单:把真实行情录下来,按固定时间戳逐条重新喂给程序,同时记录每一次信号产生的时间点。因为输入是同样的、顺序是同样的、时间间隔是同样的,理论上每次回放产出的信号时间和信号值必须完全一致。如果两次回放结果不一致,说明代码里有未定义行为或依赖系统时间等不稳定因素。
class ReplayEngine { public: void run() { auto start = std::chrono::high_resolution_clock::now(); while (auto* msg = reader_.next()) { for (auto& handler : handlers_) { handler->on_message(*msg); } } auto end = std::chrono::high_resolution_clock::now(); std::cout << "replay elapsed: " << duration_cast<microseconds>(end - start).count() << " us\n"; } private: MessageReader reader_; std::vector<Handler*> handlers_; };回放结果对比时,先对比信号值序列,再对比每个信号的相对时间戳。如果时间戳误差超过一个很小的范围(比如 100 微秒),就说明某一步存在不受控的等待。常见的隐藏等待包括日志输出、内存分配和线程调度。日志输出是高频链路里最容易混进去的隐性延迟,磁盘 I/O 一发,直接拖垮整条链路;内存分配在关键路径上则表现为偶发性的时间戳偏差。
从那以后,我每次拿到新的 C++ 高频交易源码,都会先做一遍确定性回放,确认信号值和相对时间戳都能稳定复现,再决定要不要往这个方向上继续投入。这套习惯筛掉过不少表面光鲜、实际链路里埋着随机等待的策略包。希望帮到你。
本文还有配套的精品资源,点击获取