第三章:行情数据接入——交易所行情协议解析与网关设计

做高频交易的人都知道一句话:行情是命,交易是运。没有靠谱的行情数据,再牛的策略也是白搭。今天我们就来聊聊行情接入这个环节,我把它拆成三个核心问题:怎么解析交易所的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组播 行情网关 数据接收 → 解析 → 去重 → 排序 → 推送 内部总线 策略引擎 风控模块 日志存储 图3-1 行情网关架构示意图

网关内部我一般分成三层:

  1. 网络层:负责UDP组播的接收和初步过滤。用SO_REUSEPORT实现多线程接收,每个线程绑定一个CPU核心。
  2. 协议层:解析二进制协议,生成内部统一的数据结构。这里要处理不同交易所的差异。
  3. 分发层:把解析好的数据推送给订阅者。我用的是无锁环形队列(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亲和性;准靠序列号去重、时间戳排序、协议校验。

嗯,行情接入这块就聊这么多。下一章我们会深入订单簿重建,那是另一个有意思的话题。


专注资料整理