行情模块设计:多品种行情订阅、行情数据归一化、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的幂,取模快)。
整体架构:一张图说清楚
下面这张图展示了行情模块的核心流程。从交易所连接到数据分发,再到归一化和缓存,每一步都有讲究。
从图上你能看到,数据从交易所进来后,先经过分发器按品种拆开,然后归一化成统一格式,最后塞进环形缓存。策略层通过订阅接口获取数据,不需要关心数据从哪来、怎么来的。
避坑指南:我踩过的几个雷
- 连接断开重连:我曾经没做心跳检测,结果交易所连接断了半小时才发现。后来加了定时心跳,断了自动重连,并且重连后要重新订阅所有品种。
- 数据乱序:同一个品种的Tick可能从不同连接过来,时间戳可能乱序。我的做法是:在缓存层做一次排序,或者干脆丢弃旧数据(如果延迟超过阈值)。
- 内存泄漏:环形缓冲区虽然高效,但如果存储的是指针或者复杂对象,记得要正确析构。我见过有人存了
shared_ptr导致循环引用,内存只增不减。 - 性能瓶颈:归一化逻辑里不要做复杂的字符串操作。能用整数就别用字符串,能用位运算就别用除法。我曾经把价格字符串转浮点数再转整数,后来改成直接解析整数,速度提升了3倍。
最后说一句:行情模块是系统的基石,值得花时间打磨。别想着「先跑起来再说」,后面改起来成本更高。我见过太多系统因为行情模块设计不合理,最后整个重构的。