行情模块设计:多品种行情订阅、行情数据归一化、Tick级数据缓存

行情模块,说白了就是做市系统的「眼睛」。眼睛要是瞎了,策略再牛也白搭。我见过不少团队,策略逻辑写得花里胡哨,结果行情模块一崩,整个系统直接瘫痪。今天咱们就聊聊,怎么把这双「眼睛」练得又亮又稳。

多品种行情订阅:别让连接成为瓶颈

做市系统通常要同时盯着几十个甚至上百个品种。每个交易所的行情接口都不一样,有的走WebSocket,有的走FIX协议,还有的用UDP组播。你想想看,如果每个品种都单独开一个线程去连,那系统光维护连接就得累死。

我个人习惯的做法是:连接池 + 事件驱动。每个交易所只维护一个或少数几个连接,通过订阅主题(Topic)的方式来区分品种。比如币安的WebSocket,一条连接可以订阅多个交易对,数据过来后根据stream名称做分发。

核心原则:连接数越少越好,但吞吐量要够。别为了省连接把数据堵死。

我在项目中遇到过一个问题:某个交易所的WebSocket连接数有限制,每个IP只能开20条。我们当时要订阅50个品种,怎么办?后来用了多路复用,一条连接上挂50个订阅,数据过来后按品种拆开。嗯,这里要注意,拆包逻辑一定要快,否则容易积压。

// 伪代码:多品种订阅管理器
class SubscriptionManager {
    unordered_map<string, WebSocketSession*> exchange_sessions;
    unordered_map<string, vector<string>> symbol_to_session;
    
    void subscribe(const string& exchange, const string& symbol) {
        auto session = get_or_create_session(exchange);
        session->subscribe(symbol);
        symbol_to_session[symbol].push_back(exchange);
    }
    
    void onMessage(const string& raw) {
        // 解析出品种ID,然后分发
        auto parsed = parse(raw);
        dispatch(parsed.symbol, parsed);
    }
};

行情数据归一化:把「方言」翻译成「普通话」

不同交易所的数据格式千奇百怪。有的用Unix毫秒时间戳,有的用字符串时间;有的价格是字符串,有的是整数(带精度)。如果不做归一化,策略层写起来会疯掉——每个品种都得单独处理。

归一化说白了就是:定义一套内部标准格式,所有外部数据进来都转成这个格式。我建议用结构体或者Protocol Buffers来定义,这样序列化反序列化都方便。

字段 原始数据(币安) 原始数据(OKX) 归一化后
时间戳 1623456789123 (毫秒) "2023-06-12T10:20:30.123Z" 1623456789123000 (纳秒)
价格 "45000.12" 4500012 (精度6) 45000120000 (精度9)
数量 "0.5" 500000 (精度6) 500000000 (精度9)

你可能会问:为什么时间戳要用纳秒?因为做市系统对时间精度要求极高,尤其是高频场景下,毫秒级的时间戳根本不够用。我曾经因为时间精度不够,导致两个Tick的顺序判断错误,直接亏了一笔。从那以后,我统一用纳秒。

小技巧:归一化逻辑最好放在数据接入层,不要放到策略层。策略层只认内部格式,这样换交易所时只需要改接入层代码。

Tick级数据缓存:别让CPU空转

行情数据来了,策略不一定马上能用。比如有些策略需要攒够一定数量的Tick才做计算,或者需要回溯最近N个Tick。这时候就需要一个高效的缓存机制。

Tick级缓存,说白了就是一个环形缓冲区(Ring Buffer)。每个品种维护一个固定大小的数组,新数据来了就覆盖最旧的数据。为什么用环形缓冲区?因为内存分配是O(1)的,没有GC压力,适合高频场景。

template<typename T, size_t N>
class TickRingBuffer {
    T data[N];
    atomic<size_t> head{0};
    
public:
    void push(const T& tick) {
        size_t idx = head.fetch_add(1, memory_order_relaxed) % N;
        data[idx] = tick;
    }
    
    // 获取最近的第n个Tick(0表示最新)
    T get(size_t n) const {
        size_t idx = (head.load(memory_order_acquire) - 1 - n) % N;
        return data[idx];
    }
};

这里有个坑:多线程环境下,head的原子操作一定要用对内存序。我见过有人用memory_order_relaxed导致读到了脏数据。嗯,建议用acquire-release语义,保证写操作对其他线程可见。

注意:缓存大小要合理设置。太小了容易丢数据,太大了浪费内存。一般根据策略需要的回溯深度来定,比如高频策略可能需要最近1000个Tick,那就设成1024(2的幂,取模快)。

整体架构:一张图说清楚

下面这张图展示了行情模块的核心流程。从交易所连接到数据分发,再到归一化和缓存,每一步都有讲究。

行情模块核心架构 交易所连接池 WebSocket / FIX / UDP 数据分发器 按品种ID分发 数据归一化 统一格式 Tick级环形缓存 品种A: 1024个Tick 品种B: 1024个Tick 策略订阅接口 按品种订阅 按事件回调 做市策略引擎 数据流方向:交易所 → 分发 → 归一化 → 缓存 → 策略 每个品种独立维护缓存,互不干扰

从图上你能看到,数据从交易所进来后,先经过分发器按品种拆开,然后归一化成统一格式,最后塞进环形缓存。策略层通过订阅接口获取数据,不需要关心数据从哪来、怎么来的。

避坑指南:我踩过的几个雷

  • 连接断开重连:我曾经没做心跳检测,结果交易所连接断了半小时才发现。后来加了定时心跳,断了自动重连,并且重连后要重新订阅所有品种。
  • 数据乱序:同一个品种的Tick可能从不同连接过来,时间戳可能乱序。我的做法是:在缓存层做一次排序,或者干脆丢弃旧数据(如果延迟超过阈值)。
  • 内存泄漏:环形缓冲区虽然高效,但如果存储的是指针或者复杂对象,记得要正确析构。我见过有人存了shared_ptr导致循环引用,内存只增不减。
  • 性能瓶颈:归一化逻辑里不要做复杂的字符串操作。能用整数就别用字符串,能用位运算就别用除法。我曾经把价格字符串转浮点数再转整数,后来改成直接解析整数,速度提升了3倍。

最后说一句:行情模块是系统的基石,值得花时间打磨。别想着「先跑起来再说」,后面改起来成本更高。我见过太多系统因为行情模块设计不合理,最后整个重构的。

专注资料整理