4、交易指令模拟:报单/撤单压力生成、订单簿状态维护、模拟交易所撮合引擎
这一章,我们聊聊压力测试里最核心的部分——怎么模拟交易所的行为。
说白了,就是让你的系统以为自己在跟真的交易所打交道。报单、撤单、成交、订单簿变化,这些都得模拟出来。我刚开始做高频交易系统时,总觉得模拟器嘛,差不多就行。结果有一次,模拟器撮合逻辑写得太粗糙,上线后实盘直接炸了……嗯,从那以后我再也不敢轻视这一步了。
4.1 报单/撤单压力生成
压力测试的第一步,就是往系统里灌指令。你得模拟大量交易员同时报单、撤单的场景。
我个人习惯,把指令生成器设计成可配置的。这样能灵活调整压力大小。
核心参数:
- 指令频率:每秒多少笔报单/撤单。从100笔/秒到10万笔/秒,逐步加压。
- 指令类型比例:限价单、市价单、撤单,各占多少比例。实盘中撤单率可能高达60%以上。
- 价格分布:报单价格落在买一卖一附近,还是随机分布?高频场景下,大部分单子都在盘口附近。
- 数量分布:每笔报单的手数,是固定值还是随机分布?
举个例子,我曾在项目中遇到过一个问题:模拟器只生成限价单,结果系统处理撤单的逻辑从来没被压测过。上线后撤单量一上来,系统直接卡死。所以,指令类型的比例一定要贴近实盘。
// 伪代码:指令生成器配置
struct OrderGeneratorConfig {
int orders_per_second; // 每秒报单数
int cancels_per_second; // 每秒撤单数
double limit_order_ratio; // 限价单比例
double market_order_ratio; // 市价单比例
double cancel_ratio; // 撤单比例
double price_spread; // 价格偏离盘口的范围
int min_volume, max_volume; // 报单手数范围
};
小技巧:压力生成器最好支持「突发模式」。比如前10秒平稳运行,第11秒突然涌入10倍指令量。这种场景最容易暴露系统的瓶颈。
4.2 订单簿状态维护
订单簿,就是交易所用来记录所有未成交限价单的账本。买盘和卖盘,按价格排序。
模拟器里维护订单簿,其实就是在内存里维护两个有序列表。买盘按价格从高到低排,卖盘按价格从低到高排。
数据结构怎么选?
我建议用跳表(Skip List)或者红黑树。为什么?因为订单簿的操作很频繁:插入、删除、查询最优价格。这些操作的时间复杂度得是 O(log n)。
我曾经用 C++ 的 std::map 来实现,效果还不错。但要注意,std::map 的迭代器在插入删除时不会失效,这点很关键。
// 伪代码:订单簿数据结构
struct OrderBook {
// 买盘:价格从高到低
std::map<double, OrderList, std::greater<double>> bids;
// 卖盘:价格从低到高
std::map<double, OrderList, std::less<double>> asks;
// 每个价格档位上的订单列表
struct OrderList {
double price;
int total_volume;
std::list<Order> orders;
};
};
注意:订单簿的深度信息很重要。压力测试时,不仅要看最优买卖价,还要看第5档、第10档的价格和量。很多策略会盯着这些数据做判断。
订单簿状态维护,说白了就是三件事:
- 收到报单:插入到对应的价格档位。如果该价格不存在,新建一个档位。
- 收到撤单:从对应价格档位删除该订单。如果该档位空了,删除这个档位。
- 收到成交:从订单簿里移除已成交的订单。
嗯,这里要注意:撤单和成交的删除操作,必须能快速定位到订单。所以每个订单最好有个全局唯一的ID,并且维护一个 订单ID → 订单位置 的哈希表。
4.3 模拟交易所撮合引擎
撮合引擎,是模拟器的心脏。它的任务很简单:收到报单后,判断能不能成交,能成交就生成成交记录,不能成交就挂到订单簿上。
撮合逻辑其实不复杂,但性能要求极高。高频场景下,每秒可能要撮合几万笔甚至几十万笔订单。
撮合流程:
- 收到一个买单(或卖单)。
- 检查对手盘的最优价格。买单看卖一,卖单看买一。
- 如果价格匹配(买单价格 ≥ 卖一价格),就成交。
- 成交后,更新订单簿。如果买单还有剩余,继续匹配下一档。
- 如果价格不匹配,或者对手盘空了,就把剩余部分挂到订单簿上。
// 伪代码:撮合引擎核心逻辑
void match_order(Order& new_order) {
while (new_order.volume > 0) {
// 找到对手盘最优价格
Order* best_opponent = (new_order.side == BUY) ?
get_best_ask() : get_best_bid();
if (!best_opponent) break; // 对手盘空了
// 检查价格是否匹配
if (new_order.side == BUY && new_order.price < best_opponent->price) break;
if (new_order.side == SELL && new_order.price > best_opponent->price) break;
// 计算成交数量
int trade_volume = std::min(new_order.volume, best_opponent->volume);
double trade_price = best_opponent->price; // 价格优先
// 生成成交记录
Trade trade = {new_order.id, best_opponent->id, trade_price, trade_volume};
trades.push_back(trade);
// 更新订单簿
new_order.volume -= trade_volume;
best_opponent->volume -= trade_volume;
if (best_opponent->volume == 0) {
remove_order(best_opponent); // 完全成交,移除
}
}
// 剩余部分挂单
if (new_order.volume > 0) {
add_to_orderbook(new_order);
}
}
性能优化要点:
- 内存池:订单对象频繁创建销毁,用内存池避免内存碎片。
- 无锁队列:指令接收和撮合处理可以在不同线程,用无锁队列传递数据。
- 批量处理:攒一批指令再撮合,减少上下文切换。但要注意,这会引入延迟。
- 避免拷贝:订单簿里的订单对象尽量用指针或引用,别拷贝来拷贝去。
我曾经在撮合引擎里犯过一个低级错误:每次成交后都重新排序订单簿。结果压力一大,CPU 直接跑满。后来改成只更新受影响的价格档位,性能提升了10倍。你想想看,排序操作是 O(n log n),而局部更新是 O(log n),差距太大了。
4.4 整体架构图
下面这张图,展示了指令模拟、订单簿维护、撮合引擎三者之间的关系。
这张图里,指令生成器产生的指令先进入无锁队列。撮合引擎从队列里取指令,一边撮合,一边更新订单簿。成交记录直接输出,不经过订单簿。这样设计,撮合引擎和订单簿的耦合度很低,方便后续优化。
4.5 避坑指南
我曾经踩过的坑:
- 订单ID冲突:多线程生成指令时,订单ID没加锁,导致重复。后来用原子计数器解决。
- 价格精度问题:期货价格有小数点,用 double 比较时出现精度误差。建议用整数表示,比如把价格乘以10000。
- 撤单找不到订单:撤单指令到达时,订单可能已经成交了。模拟器里要处理这种「撤单失败」的情况。
- 内存泄漏:订单对象创建后忘记释放,跑几个小时内存就爆了。用智能指针或者内存池能解决。
其实,模拟交易所撮合引擎这件事,说难不难,说简单也不简单。关键是要把边界情况都考虑到。比如:
- 市价单遇到对手盘深度不够怎么办?
- 冰山订单(大单拆成小单)怎么模拟?
- 集合竞价阶段的撮合逻辑和连续竞价不一样,怎么处理?
这些细节,在实盘里都会遇到。模拟器做得越真实,压力测试的结果就越有参考价值。
我的建议:先做一个简化版,跑通流程。然后逐步增加功能:冰山订单、FOK(全部成交否则撤销)、IOC(立即成交否则撤销)等。别想着一步到位,迭代开发更靠谱。
好了,这一章的内容就这些。指令模拟、订单簿、撮合引擎,这三块是压力测试的基础设施。把它们搞扎实了,后面的测试才能跑得稳。
公众号:蓝海资料掘金营,微信deep3321