第三章:行情数据接入——交易所行情协议解析与网关设计
做高频交易的人都知道一句话:行情是命,交易是运。没有靠谱的行情数据,再牛的策略也是白搭。今天我们就来聊聊行情接入这个环节,我把它拆成三个核心问题:怎么解析交易所的UDP组播协议、行情网关该怎么设计、以及数据去重与排序的那些坑。
3.1 交易所行情协议解析(UDP组播)
先说说UDP组播。说白了,交易所把行情数据打包成UDP报文,通过组播地址发出来。你只需要加入这个组播组,就能收到数据。好处是延迟低、带宽省,坏处是——UDP不保证可靠传输。
我记得第一次对接上交所的行情协议时,差点被那个二进制格式搞疯。每个字段的偏移量、字节序、甚至位掩码都得精确匹配。举个例子,上交所的Level-2行情快照,一个包就包含了上千只股票的买卖十档数据。
核心要点:行情协议解析的关键在于零拷贝和内存对齐。千万别用Python的struct.unpack逐字段解析,那延迟会让你哭。
我一般用C++写一个内存映射结构体,直接强转指针读取。像这样:
// 上交所行情快照头部结构(简化版)
#pragma pack(push, 1)
struct SHMarketDataHeader {
uint16_t msg_type; // 消息类型
uint32_t channel_id; // 通道ID
uint64_t timestamp; // 交易所时间戳(纳秒)
uint32_t seq_no; // 序列号
uint32_t body_length; // 消息体长度
};
#pragma pack(pop)
// 解析时直接强转
void onUdpPacket(const char* data, size_t len) {
const SHMarketDataHeader* hdr =
reinterpret_cast<const SHMarketDataHeader*>(data);
// 注意:这里要检查对齐和长度
if (len < sizeof(SHMarketDataHeader)) return;
// 根据msg_type分发到不同的解析器
}
为什么要用#pragma pack(push, 1)?因为交易所的协议是紧凑排列的,没有填充字节。如果你不告诉编译器按1字节对齐,结构体里就会多出空洞,解析出来的数据全是错的。这个坑我踩过,调试了一整天才发现。
3.2 行情网关设计
行情网关,说白了就是交易所和你的交易系统之间的桥梁。它负责收数据、解析、然后推给下游。我设计网关时,最看重三点:低延迟、高吞吐、可扩展。
先看架构图:
网关内部我一般分成三层:
- 网络层:负责UDP组播的接收和初步过滤。用SO_REUSEPORT实现多线程接收,每个线程绑定一个CPU核心。
- 协议层:解析二进制协议,生成内部统一的数据结构。这里要处理不同交易所的差异。
- 分发层:把解析好的数据推送给订阅者。我用的是无锁环形队列(Lock-Free Ring Buffer),延迟在纳秒级。
个人经验:网关的接收缓冲区一定要够大。我曾经因为缓冲区太小,在高行情波动时丢包,导致策略做出了错误判断。建议设置至少64KB的接收缓冲区,并且用mmap映射到大页内存。
3.3 数据去重与排序
为什么要去重?因为UDP组播可能会重复发送数据包。交易所为了保证可靠性,同一个行情快照可能会发两遍。如果你不去重,策略就会收到重复的订单簿快照,导致计算错误。
排序又是怎么回事?UDP包到达顺序可能和发送顺序不一致。比如包1先发但后到,包2后发但先到。如果你不排序,策略看到的行情就是乱序的。
我常用的去重策略是序列号去重。每个行情包都有一个递增的序列号,我维护一个位图(Bitmap)来记录已经收到的序列号。如果某个序列号已经处理过,直接丢弃。
// 简化的去重逻辑
class Deduplicator {
static constexpr uint64_t WINDOW_SIZE = 1024;
std::bitset<WINDOW_SIZE> bitmap_;
uint64_t last_seq_ = 0;
public:
bool isDuplicate(uint64_t seq_no) {
if (seq_no <= last_seq_ - WINDOW_SIZE) {
// 太旧的包,直接丢弃
return true;
}
if (seq_no > last_seq_) {
// 新包,更新窗口
last_seq_ = seq_no;
bitmap_.reset();
return false;
}
// 检查是否重复
uint64_t offset = last_seq_ - seq_no;
if (bitmap_.test(offset)) {
return true; // 重复
}
bitmap_.set(offset);
return false;
}
};
排序的话,我建议用时间戳排序。交易所的行情包里都带了一个高精度时间戳(通常是纳秒级)。我维护一个优先级队列(Priority Queue),按时间戳排序。收到包后先入队,然后从队首取出时间戳最小的包处理。
注意:排序不能引入额外延迟。你不能等所有包都到齐了再排序,那延迟就太大了。我一般设置一个超时阈值(比如100微秒),超时后就把队列里所有包按时间戳排序后批量处理。
这里有个细节:不同交易所的时间戳精度不一样。上交所是纳秒,深交所是微秒,中金所又是另一种格式。你需要在网关层统一转换成内部使用的纳秒时间戳,不然排序会出问题。
3.4 实战中的坑与优化
最后分享几个我实际项目中踩过的坑:
- 网卡调优:一定要开启RSS(Receive Side Scaling),让不同流的UDP包分发到不同CPU核心。不然所有包都挤在一个核上,延迟飙升。
- 内存池:行情数据量巨大,每秒可能几百万个包。频繁的malloc/free会导致内存碎片和延迟抖动。我用的是预分配的内存池,对象复用。
- 日志异步:千万别在行情处理路径上写日志。我见过有人用同步日志,结果行情高峰时日志I/O阻塞了网络接收。日志一定要异步,用单独的线程写。
一句话总结:行情接入的核心是快和准。快靠零拷贝、无锁结构、CPU亲和性;准靠序列号去重、时间戳排序、协议校验。
嗯,行情接入这块就聊这么多。下一章我们会深入订单簿重建,那是另一个有意思的话题。