3、数据模型设计:订单簿数据结构设计、时间戳精度、价格与数量精度处理

好,咱们进入第三讲。数据模型设计,说白了就是给订单簿搭骨架。骨架搭不好,后面所有逻辑都是空中楼阁。我在量化系统里踩过的坑,有一半都跟数据模型有关。今天咱们就把这块掰开揉碎了讲清楚。

3.1 订单簿的核心数据结构

订单簿本质上就是个价格优先的队列。买盘按价格从高到低排,卖盘按价格从低到高排。我习惯用两个结构来承载:一个是快照结构,一个是增量结构

3.1.1 快照结构

快照就是某一时刻订单簿的全量数据。你想想看,交易所每隔一段时间会推送一次。结构其实很简单:

// 订单簿快照结构
struct OrderBookSnapshot {
    string    exchange;      // 交易所标识
    string    symbol;        // 交易对
    int64_t   timestamp;     // 快照时间戳(纳秒)
    vector<Level> bids;      // 买盘,按价格降序
    vector<Level> asks;      // 卖盘,按价格升序
};

// 单个档位
struct Level {
    double    price;         // 价格
    double    quantity;      // 数量
    int       orderCount;    // 订单数量(可选)
};

嗯,这里要注意。快照结构里我特意把 orderCount 标成了可选。为什么?因为有些交易所不提供这个字段。我在对接某二线交易所时就吃过这个亏——文档里写了,实际推送过来永远是0。

3.1.2 增量结构

增量更新才是高频交易的核心。每次只推送变化的部分,而不是全量数据。结构设计上要能表达「增、删、改」三种操作:

// 增量更新结构
struct OrderBookDelta {
    string    exchange;
    string    symbol;
    int64_t   timestamp;
    int64_t   sequence;      // 序列号,用于校验顺序
    vector<DeltaEntry> bids;
    vector<DeltaEntry> asks;
};

struct DeltaEntry {
    double    price;
    double    quantity;      // 数量为0表示删除该档位
    char      side;         // 'B' 买, 'S' 卖
};

这里有个小技巧:用数量为0来表示删除。这样增量结构里就不需要额外的操作类型字段了。我在早期版本里用过枚举类型(ADD/UPDATE/DELETE),结果发现每次都要多写一堆 switch-case,代码又臭又长。

核心原则:快照用于初始化,增量用于持续更新。两者必须能互相校验。

3.2 时间戳精度——别小看这个细节

时间戳精度问题,我见过太多人翻车了。你以为毫秒就够了?在量化交易里,微秒甚至纳秒级别的差异,可能就决定了你的订单能不能成交。

3.2.1 不同交易所的精度差异

交易所 时间戳精度 备注
Binance 毫秒 部分事件提供微秒
Coinbase 纳秒 严格递增
OKX 毫秒 偶尔出现重复
Bybit 纳秒 推荐使用sequence

你看,光精度就五花八门。我建议统一用纳秒存储。为什么?因为向下兼容。纳秒可以无损转成微秒、毫秒,反过来就不行。我曾经在项目里用毫秒存储,结果对接Coinbase时发现时间戳根本对不上——人家纳秒级别的数据,被我截断成毫秒,订单簿重建时全乱了。

避坑指南:千万不要用 double 存时间戳。浮点数精度有限,纳秒级别的数据存进去会丢失精度。用 int64_t,单位统一为纳秒。

3.2.2 时间戳的校验逻辑

时间戳不只是用来记录时间,它还是校验数据顺序的关键。我常用的校验规则:

  1. 单调递增检查:后一条数据的时间戳必须大于等于前一条
  2. 偏差阈值检查:当前时间戳与本地时间的偏差不能超过5秒
  3. 序列号辅助:当时间戳相同时,用序列号判断先后

你想想看,如果时间戳出现回退,订单簿的增量更新就会乱套。我曾经在回测时发现策略收益异常高,查了半天,原来是某次数据重放时时间戳没处理好,导致订单簿状态错乱,策略「预知」了未来的价格。

3.3 价格与数量精度处理

这个坑最深。价格和数量的精度,直接决定了你的订单能不能被交易所接受。我见过有人因为精度问题,挂单一直挂不上去,白白错过了行情。

3.3.1 价格精度

每个交易对都有自己的价格精度规则。比如BTC/USDT,价格最小变动可能是0.01,也可能是0.1。怎么处理?

// 价格精度处理函数
double roundPrice(double price, double tickSize) {
    // tickSize 是交易所规定的最小价格变动单位
    if (tickSize <= 0) return price;
    
    // 核心逻辑:四舍五入到 tickSize 的整数倍
    double rounded = round(price / tickSize) * tickSize;
    
    // 防止浮点数精度问题
    return std::floor(rounded * 1e8 + 0.5) / 1e8;
}

// 使用示例
double tickSize = 0.01;  // BTC/USDT 最小变动
double rawPrice = 50000.123;
double validPrice = roundPrice(rawPrice, tickSize);
// 结果:50000.12

嗯,这里有个细节。为什么最后要 floor(rounded * 1e8 + 0.5) / 1e8?因为浮点数运算会有误差,比如 0.01 在二进制里是无限循环小数。这个操作相当于把结果截断到8位小数,避免出现 50000.1200000001 这种尴尬情况。

个人习惯:我会把每个交易对的 tickSize 和 lotSize(数量最小单位)缓存起来,启动时从交易所API拉取一次。别写死,因为交易所会调整这些参数。

3.3.2 数量精度

数量精度比价格更复杂。因为除了最小变动单位(lotSize),还有最小交易数量(minQty)和最大交易数量(maxQty)。

// 数量精度处理
struct QuantityConstraints {
    double lotSize;    // 最小变动单位
    double minQty;     // 最小交易数量
    double maxQty;     // 最大交易数量
};

double roundQuantity(double qty, const QuantityConstraints& c) {
    // 1. 先按 lotSize 四舍五入
    double rounded = round(qty / c.lotSize) * c.lotSize;
    
    // 2. 检查范围
    if (rounded < c.minQty) return 0;  // 低于最小量,返回0表示无效
    if (rounded > c.maxQty) return c.maxQty;  // 超过最大量,截断
    
    return rounded;
}

我曾经在项目中遇到过一个奇葩问题:某个山寨币的 lotSize 是 0.0001,但 minQty 是 0.001。也就是说,你按 lotSize 算出来 0.0005 是合法的,但交易所会直接拒绝,因为低于最小交易量。所以处理顺序很重要——先按 lotSize 取整,再检查范围。

3.4 数据模型设计的整体架构

说了这么多,咱们用一张图来总结整个数据模型的设计思路:

订单簿数据模型架构 数据源层 交易所WebSocket流 → 快照 + 增量更新 时间戳精度统一 → 纳秒 (int64_t) 数据校验层 时间戳单调递增检查 | 序列号连续性校验 价格/数量精度合规检查 | 快照与增量一致性校验 数据存储层 内存订单簿 (红黑树/跳表) | 本地缓存 (LevelDB/RocksDB) 历史快照归档 | 增量日志持久化 应用层 策略引擎 | 风控模块 | 回测系统 | 监控告警

这张图展示了数据从交易所到策略引擎的完整流转路径。每一层都有对应的校验和处理逻辑。我个人觉得,数据模型设计最核心的就是把「边界条件」想清楚——时间戳的边界、精度的边界、数据顺序的边界。把这些想透了,后面写代码就是水到渠成的事。

总结一下:数据模型设计不是简单的定义几个结构体。它决定了你的系统能处理多快的行情、能对接多少家交易所、能在异常情况下保持多高的稳定性。花时间把模型设计好,后面能省十倍的时间。

好,这一讲就到这里。记住我说的:快照用于初始化,增量用于持续更新,时间戳统一用纳秒,价格数量精度必须严格合规。下一讲咱们聊聊数据校验的具体实现——怎么用代码把这些规则落地。


公众号:蓝海资料掘金营,微信deep3321