3、数据模型设计:统一行情数据模型(Tick、K线、OrderBook)、时间戳规范化处理、数据版本控制策略
好,咱们进入第三章。说实话,数据模型设计这块,是整个多源聚合方案的基石。你想想看,如果地基没打好,上层建筑再花哨也是白搭。我在早期做行情系统时,就吃过这个亏——不同交易所的数据格式五花八门,硬生生把聚合逻辑写成了意大利面条,维护起来简直想哭。
所以这一章,咱们重点聊聊怎么设计一套统一、健壮、可扩展的行情数据模型。我会把我在实战中踩过的坑、总结的经验,都揉碎了讲给你听。
3.1 统一行情数据模型
说白了,我们要做的是把不同交易所的“方言”翻译成“普通话”。常见的行情数据无非三种:Tick、K线、OrderBook。咱们一个一个来。
3.1.1 Tick 数据模型
Tick 是最细粒度的数据,记录每一笔成交。不同交易所的 Tick 字段差异很大,有的带买方ID,有的带卖方ID,有的甚至没有成交方向。我的做法是:抽象出最小公共集,再通过扩展字段兜底。
// 统一 Tick 模型(Go 语言示例)
type UnifiedTick struct {
Symbol string `json:"symbol"` // 交易对,如 BTCUSDT
Exchange string `json:"exchange"` // 交易所标识,如 binance
Price float64 `json:"price"` // 成交价
Volume float64 `json:"volume"` // 成交量
Side int32 `json:"side"` // 成交方向:0-未知,1-买,2-卖
Timestamp int64 `json:"timestamp"` // 统一时间戳(纳秒)
TradeID string `json:"trade_id"` // 全局唯一成交ID
Extra map[string]interface{} `json:"extra"` // 扩展字段,存放交易所特有信息
}
嗯,这里要注意 Side 字段。有些交易所的 Tick 不提供方向,我就默认填 0。另外 TradeID 我建议用字符串,因为不同交易所的 ID 格式差异太大,有的是纯数字,有的是带前缀的字符串。
exchange_xxx 作为 key 前缀,比如 exchange_binance_taker_id。这样既保留了原始信息,又不会污染公共字段。
3.1.2 K线数据模型
K线相对简单,但坑也不少。最大的坑是时间对齐。不同交易所的 K线开盘时间可能差几毫秒,聚合时如果不处理,就会出现“锯齿”。
// 统一 K线模型
type UnifiedKline struct {
Symbol string `json:"symbol"`
Exchange string `json:"exchange"`
Interval string `json:"interval"` // 周期,如 1m, 5m, 1h
Open float64 `json:"open"`
High float64 `json:"high"`
Low float64 `json:"low"`
Close float64 `json:"close"`
Volume float64 `json:"volume"`
Turnover float64 `json:"turnover"` // 成交额
OpenTime int64 `json:"open_time"` // 统一开盘时间(纳秒)
CloseTime int64 `json:"close_time"` // 统一收盘时间(纳秒)
Count int64 `json:"count"` // 成交笔数
}
我建议把 OpenTime 作为主键的一部分。为什么?因为同一个交易对、同一个周期、同一个开盘时间,理论上只有一条 K线。如果发现重复,说明数据源有问题,需要告警。
3.1.3 OrderBook 数据模型
OrderBook 是最复杂的。因为它有快照和增量两种模式。快照是全量数据,增量是变化数据。设计模型时,必须同时支持这两种模式。
// 统一 OrderBook 模型
type UnifiedOrderBook struct {
Symbol string `json:"symbol"`
Exchange string `json:"exchange"`
Bids []PriceLevel `json:"bids"` // 买盘,按价格降序
Asks []PriceLevel `json:"asks"` // 卖盘,按价格升序
Timestamp int64 `json:"timestamp"` // 快照时间戳
Sequence int64 `json:"sequence"` // 序列号,用于增量更新
IsSnapshot bool `json:"is_snapshot"` // 是否为快照
}
type PriceLevel struct {
Price float64 `json:"price"`
Volume float64 `json:"volume"`
OrderNum int32 `json:"order_num"` // 订单数量(部分交易所提供)
}
这里有个关键点:Sequence 字段。它是增量更新的“锚点”。我曾经遇到过一个交易所,它的序列号会回滚,导致增量数据错乱。后来我加了一个校验逻辑:如果新序列号小于旧序列号,就丢弃增量,重新请求快照。
3.2 时间戳规范化处理
时间戳是行情数据的灵魂。但不同交易所的时间戳格式五花八门:秒级、毫秒级、微秒级,甚至还有字符串格式。如果不统一处理,聚合时就会乱套。
我的策略很简单:统一使用纳秒级时间戳。为什么是纳秒?因为高频交易场景下,微秒级的精度都不够用。而且纳秒可以向下兼容所有其他精度。
// 时间戳规范化函数
func NormalizeTimestamp(raw interface{}) (int64, error) {
switch v := raw.(type) {
case int64:
// 假设是毫秒级,乘以 1e6 转为纳秒
return v * 1e6, nil
case float64:
// 可能是秒级,乘以 1e9
return int64(v * 1e9), nil
case string:
// 解析 ISO 8601 格式
t, err := time.Parse(time.RFC3339Nano, v)
if err != nil {
return 0, err
}
return t.UnixNano(), nil
default:
return 0, fmt.Errorf("unsupported timestamp type: %T", raw)
}
}
另外,我建议在数据库里也存储纳秒级时间戳。虽然会多占一点存储空间,但查询和聚合时不需要再做转换,性能更好。我在项目中实测过,纳秒级时间戳做范围查询,比字符串格式快 3-5 倍。
3.3 数据版本控制策略
行情数据是不断变化的。同一个交易对、同一个时间点,可能因为数据源不同、处理逻辑不同,产生多个版本的数据。如果没有版本控制,数据回溯时就会一团糟。
我的做法是引入数据版本号。每个数据记录都带一个版本号,版本号递增。当数据发生变更时,不是覆盖旧数据,而是写入新版本。
| 版本号 | 数据来源 | 时间戳 | 状态 |
|---|---|---|---|
| 1 | 交易所A | 2024-01-01 10:00:00.000 | 已废弃 |
| 2 | 交易所B | 2024-01-01 10:00:00.001 | 已废弃 |
| 3 | 聚合引擎 | 2024-01-01 10:00:00.002 | 当前有效 |
你看,同一个时间点的数据,有三个版本。版本1和版本2来自不同交易所,版本3是聚合后的结果。如果后续发现聚合逻辑有 bug,我们可以回退到版本2,重新聚合。
version 字段,再建一个 data_versions 表记录版本元信息。这样回溯时,只需要切换版本号,不需要动数据本身。
版本控制还有一个好处:支持数据回放。在策略回测时,我们可以指定使用哪个版本的数据。比如,我想测试某个策略在“未聚合”的原始数据上的表现,就可以选择版本1或版本2的数据。
3.4 知识体系总览
说了这么多,咱们用一张图来总结一下整个数据模型设计的核心逻辑。这张图是我自己画的,涵盖了 Tick、K线、OrderBook 的模型设计,以及时间戳规范化和版本控制的流程。
这张图清晰地展示了数据从多源输入,到时间戳规范化,再到统一模型,最后经过版本控制存入存储的完整流程。你可以把它当作设计时的参考框架。
好了,这一章的内容就到这里。数据模型是系统的骨架,骨架正了,后面的路就好走了。下一章咱们聊聊数据采集层的设计,那又是另一番天地了。