2. 行情接入层设计:多交易所行情协议适配

行情接入层,说白了就是系统的「眼睛」。眼睛要是瞎了,后面策略写得再漂亮也是白搭。我做了这么多年量化,见过太多团队在行情接入上栽跟头——有的数据延迟高,有的断连了没人知道,最惨的是接了脏数据导致策略反向开仓。

今天咱们就聊聊,怎么把这层设计得既稳又准。

2.1 多交易所协议适配:FIX / WebSocket / REST

不同交易所用的协议不一样,你得有个统一的接入框架。我个人习惯用「协议适配器模式」——每个交易所一个适配器,对外暴露统一接口。

FIX 协议接入

FIX 协议在传统金融圈用得最多,期货、外汇、股票都认它。但说实话,FIX 的报文格式挺啰嗦的,一个行情消息能拆成几十个字段。

我在项目中遇到过一个问题:某交易所的 FIX 网关每隔 30 秒会发一个 TestRequest,如果你没在规定时间内回 Heartbeat,它就直接断开连接。嗯,这个坑我踩过,后来在适配器里加了个定时器,专门处理心跳。

// FIX 适配器核心逻辑(伪代码)
class FixAdapter : public MarketAdapter {
    void onMessage(const string& rawMsg) {
        FixMessage msg = FixParser::parse(rawMsg);
        if (msg.type == MARKET_DATA_SNAPSHOT) {
            // 转成统一行情结构
            MarketData md = convert(msg);
            dispatch(md);
        } else if (msg.type == HEARTBEAT) {
            sendHeartbeat();  // 必须及时回复
        }
    }
};

WebSocket 接入

WebSocket 现在是数字货币交易所的主流选择。它比 FIX 轻量,但有个问题——断连重连你得自己处理。

你想想看,行情数据每秒几十上百条,一旦断连,中间的数据就丢了。我建议的做法是:

  • 维护一个心跳计数器,超过 N 秒没收到数据就主动重连
  • 重连后请求一次快照,把缺失的增量数据补回来
  • 用指数退避策略,别一断就连,连不上又断,把服务器搞崩了
小技巧: WebSocket 的 ping/pong 帧不要依赖底层库,自己实现一个应用层心跳。有些云服务商的负载均衡器会掐掉长时间空闲的连接。

REST 接口接入

REST 一般用来拿历史数据或者做备用通道。实时行情用 REST 轮询?说实话,延迟太高了,不推荐。

但 REST 有个好处——它是最简单的兜底方案。如果 WebSocket 挂了,你可以临时切到 REST 顶一阵子。我曾经在某个交易所的 WebSocket 连续断连 3 次后,自动切到 REST 模式,虽然延迟多了 200ms,但至少没断数据。

2.2 行情数据标准化

不同交易所的行情字段名千奇百怪。有的叫 lastPrice,有的叫 close,还有的叫 tradePrice。你总不能每个策略都去适配一遍吧?

所以,标准化是必须的。我一般定义一套统一的数据结构:

struct MarketData {
    string symbol;        // 统一品种代码,如 "BTCUSDT"
    double  bidPrice;     // 买一价
    double  askPrice;     // 卖一价
    double  lastPrice;    // 最新成交价
    double  volume;       // 成交量
    int64_t timestamp;    // 统一用毫秒时间戳
    // ... 其他字段
};

每个适配器在收到原始数据后,先转成这个结构再往上抛。这样上层策略、风控、回测模块都不用关心底层是哪个交易所。

核心原则: 行情数据在接入层完成标准化,后续所有模块只认统一格式。谁要是把原始数据往上抛,谁就是给自己挖坑。

2.3 行情去重与缓存策略

行情数据有个特点——同一笔成交可能被推送多次。比如交易所的撮合引擎先发了个增量更新,紧接着又发了个快照,里面包含了同样的数据。如果你不做去重,策略可能会重复计算,导致信号错乱。

去重怎么做?

我常用的方法是「序列号 + 时间戳」双重校验:

  • 每个行情消息带一个递增的序列号,重复的序列号直接丢弃
  • 如果序列号不连续,说明中间有丢包,需要触发补数据流程
  • 时间戳作为辅助校验,防止序列号回绕
bool isDuplicate(const MarketData& md) {
    static int64_t lastSeq = 0;
    if (md.seqNum <= lastSeq) {
        return true;  // 重复数据
    }
    if (md.seqNum > lastSeq + 1) {
        // 丢包了!触发补数据
        requestSnapshot(md.symbol);
    }
    lastSeq = md.seqNum;
    return false;
}
注意: 有些交易所的序列号是每个品种独立的,有些是全局递增的。你得看清楚文档,别把不同品种的序列号混在一起比较。

缓存策略

行情缓存的目的有两个:一是给策略提供「当前最新行情」的快速查询,二是给断连重连后补数据用。

我一般用两级缓存:

缓存层级 存储内容 更新频率 用途
L1 内存缓存 最新一笔行情 每次推送更新 策略实时查询
L2 环形缓冲区 最近 N 笔行情 滚动覆盖 断连补数据、回放

L1 缓存用 std::unordered_map<string, MarketData> 就行,key 是品种代码。L2 缓存我建议用环形缓冲区,每个品种维护一个固定大小的数组,比如存最近 1000 笔。为什么用环形?因为行情数据量太大,用链表或者 vector 频繁分配内存,GC 会让你哭的。

我曾经在一个高频项目里,L2 缓存用了 std::deque,结果跑了一天内存涨了 2 个 G。后来换成环形缓冲区,内存稳稳的。

2.4 整体架构图

下面这张图展示了行情接入层的核心流程。从左边多个交易所进来,经过协议适配、去重、标准化,最后统一输出给上层使用。

行情接入层架构图 交易所 A (FIX) 交易所 B (WebSocket) 交易所 C (REST) 协议适配层 FIX 解析器 | WebSocket 管理器 | REST 轮询器 去重 & 标准化 序列号去重 | 统一 MarketData 结构 缓存层 L1 最新行情缓存 | L2 环形缓冲区 → 策略引擎 / 风控 / 回测

从图上你能看到,数据从左到右流经三个核心模块。每个模块各司其职,互不干扰。这也是我设计系统的一贯原则——高内聚、低耦合。

总结一下: 行情接入层设计得好不好,直接决定了整个交易系统的数据质量。协议适配要灵活,标准化要彻底,去重要严谨,缓存要高效。这四个点做好了,后面的策略开发就轻松多了。
避坑指南: 我曾经在接入一个新交易所时,发现它的行情时间戳用的是本地时间,不是 UTC。结果策略在夏令时切换那天直接崩了。所以,拿到行情数据后第一件事——检查时间戳的时区!

专注资料整理