4、数据清洗与标准化:不同交易所的数据格式差异,统一成标准OrderBook和Trade结构
做量化交易,最头疼的事是什么?
我个人觉得,不是策略亏钱,而是数据打架。
你连上币安、OKX、Bybit三家交易所,拿到的OrderBook结构完全不一样。有的用bids,有的用b,有的价格和数量顺序还反着来。这要是直接喂给策略,不出错才怪。
所以这一章,咱们就聊聊怎么把这些乱七八糟的数据,统一成一套标准结构。
4.1 为什么需要数据清洗?
说白了,交易所不是为你家策略设计的。
每家都有自己的历史包袱。有的早期为了省带宽,字段名用单字母;有的为了兼容老系统,数据结构改了好几版。你想想看,一个聚合引擎要同时对接10家交易所,每家格式都不一样,代码里全是if-else,维护起来简直要命。
我在项目中遇到过最离谱的一次——某交易所的OrderBook里,price字段有时候是字符串,有时候是数字。同一个字段,类型不固定。嗯,这种坑踩过一次就记住了。
4.2 常见的数据格式差异
我整理了一下,不同交易所的数据差异主要集中在以下几个方面:
| 差异类型 | 举例 | 影响 |
|---|---|---|
| 字段命名 | 币安用bids,某小交易所用buy_orders |
代码无法通用 |
| 数据类型 | 价格有时是字符串"123.45",有时是浮点123.45 |
计算时报错 |
| 数据顺序 | 有的bids按价格降序,有的升序 | 撮合逻辑出错 |
| 时间格式 | Unix毫秒、ISO8601、字符串"2024-01-01T00:00:00Z" |
时间对齐困难 |
| 精度位数 | 有的价格保留8位小数,有的保留2位 | 精度丢失或计算偏差 |
你看,光一个OrderBook,就有这么多坑。更别提Trade数据了,有的带trade_id,有的带tid,有的干脆没有唯一ID。
4.3 标准OrderBook结构
我个人习惯,先把目标结构定死。不管上游怎么变,下游只认这一套。
// 标准OrderBook结构
{
"exchange": "binance", // 交易所名称
"symbol": "BTCUSDT", // 交易对
"timestamp": 1704067200000, // 更新时间,Unix毫秒
"bids": [ // 买单,按价格降序
["45000.12", "1.2345"], // [价格, 数量],字符串类型
["45000.11", "0.5678"]
],
"asks": [ // 卖单,按价格升序
["45000.13", "2.3456"],
["45000.14", "1.7890"]
]
}
为什么价格和数量用字符串?
因为浮点数在计算机里是不精确的。0.1 + 0.2 不等于 0.3,这个大家都知道。用字符串传递,到计算层再转Decimal,能避免精度问题。我曾经因为这个吃过亏,算出来的价差差了0.0001,导致套利策略频繁误触发。
Decimal或C++的boost::multiprecision。别用float/double直接算。
4.4 标准Trade结构
Trade数据比OrderBook简单一些,但同样需要统一。
// 标准Trade结构
{
"exchange": "okx",
"symbol": "ETHUSDT",
"trade_id": "123456789", // 唯一成交ID
"timestamp": 1704067200123, // 成交时间,Unix毫秒
"price": "2200.50", // 成交价格
"quantity": "0.1234", // 成交数量
"side": "buy", // 买卖方向:buy/sell
"is_maker": true // 是否maker成交
}
这里有个细节——trade_id。有些交易所的trade_id是数字,有些是字符串,还有些是UUID。统一转成字符串最省事。另外,is_maker字段不是所有交易所都提供,没有的话就默认false。
4.5 清洗流程实战
光说理论不行,咱们看看实际代码怎么写。下面是一个Python示例,把币安的OrderBook转成标准格式:
def clean_binance_orderbook(raw, symbol):
"""
清洗币安OrderBook数据
raw: 从WebSocket收到的原始数据
symbol: 交易对
"""
# 检查数据完整性
if 'bids' not in raw or 'asks' not in raw:
raise ValueError("缺少bids或asks字段")
# 统一时间戳
timestamp = raw.get('E', raw.get('lastUpdateId', 0))
# 币安的时间戳是毫秒,直接使用
# 构建标准结构
standard = {
"exchange": "binance",
"symbol": symbol,
"timestamp": timestamp,
"bids": [],
"asks": []
}
# 清洗bids:确保价格降序,数量转字符串
for price, qty in raw['bids']:
# 有些交易所返回的是数字,转成字符串
price_str = str(price) if not isinstance(price, str) else price
qty_str = str(qty) if not isinstance(qty, str) else qty
standard['bids'].append([price_str, qty_str])
# 清洗asks:确保价格升序
for price, qty in raw['asks']:
price_str = str(price) if not isinstance(price, str) else price
qty_str = str(qty) if not isinstance(qty, str) else qty
standard['asks'].append([price_str, qty_str])
# 排序校验
standard['bids'].sort(key=lambda x: float(x[0]), reverse=True)
standard['asks'].sort(key=lambda x: float(x[0]))
return standard
4.6 数据校验与异常处理
数据清洗完了,不代表就万事大吉。你还得校验一下。
我一般会做这几项检查:
- 空值检查: bids或asks为空?那这数据大概率有问题,直接丢弃。
- 价格合理性: 价格突然变成0或者负数?肯定是脏数据。
- 时间单调性: 新数据的时间戳比上一条还早?说明数据乱序了,需要重排或丢弃。
- 买卖价差: 如果bids的最高价大于asks的最低价,说明买卖交叉了。这在正常市场里不应该出现,可能是数据延迟或错误。
我曾经在生产环境遇到过一个问题——某交易所的WebSocket偶尔会推送重复数据。同样的trade_id出现两次,导致策略重复计算成交。后来我在清洗层加了个去重逻辑,用LRU缓存最近1000个trade_id,重复的直接跳过。
4.7 整体流程一览
说了这么多,咱们用一张图把整个流程串起来:
你看,整个流程其实不复杂。上游不管来什么格式,经过清洗层统一处理后,下游只认标准结构。这样每个模块各司其职,出了问题也好排查。
4.8 避坑指南
最后,分享几个我踩过的坑:
- 别信文档: 交易所的API文档经常和实际返回不一致。我建议你每次对接新交易所,先打印原始数据看10分钟,再写清洗代码。
- 注意空数组: 有些交易所行情清淡时,bids或asks可能为空数组。你的清洗逻辑要能处理这种情况,别直接抛异常。
- 时间戳陷阱: 有的交易所返回秒级时间戳,有的是毫秒级。差1000倍,策略直接废掉。统一转成毫秒,加个判断:如果时间戳小于1e12,说明是秒级,乘以1000。
- 增量更新要小心: 如果做OrderBook增量更新,一定要校验sequence number的连续性。丢了一条增量,整个OrderBook就废了。我建议你加个监控,如果连续丢3条增量,主动重新订阅全量快照。
cleaner_binance.py、cleaner_okx.py。这样新增交易所时,不影响已有逻辑。代码结构清晰,维护起来也省心。
数据清洗这事儿,看起来琐碎,但做不好后面全是坑。你想想看,如果连数据都是错的,再牛逼的策略也是白搭。所以,花时间把清洗层做扎实,绝对是值得的。
公众号:蓝海资料掘金营,微信deep3321