2. 数据源与采集:常见交易所API(Binance、Coinbase),WebSocket实时流与REST快照
做订单簿数据清洗,第一步不是写代码,而是搞清楚数据从哪来。
我见过不少新手,一上来就对着K线数据猛写清洗逻辑,结果发现订单簿的原始数据结构和K线完全两码事。嗯,今天我们就聊聊数据源这件事。
2.1 为什么选Binance和Coinbase?
市面上交易所很多,但真正适合做订单簿数据研究的,其实没几家。我个人习惯用Binance和Coinbase,原因很简单:
- 文档清晰:API文档写得像教科书,新手也能看懂
- 数据量大:深度好,流动性足,订单簿变化频繁
- WebSocket稳定:断线重连机制成熟,我在项目中几乎没遇到过数据丢失
- REST快照准确:快照数据是订单簿的基准,Binance的快照延迟通常在50ms以内
2.2 REST快照:订单簿的“基准线”
REST快照,说白了就是你在某个时间点,向交易所要一份完整的订单簿数据。它包含当前所有的买单和卖单。
Binance的REST快照接口长这样:
GET https://api.binance.com/api/v3/depth?symbol=BTCUSDT&limit=1000
返回的数据结构:
{
"lastUpdateId": 1027024,
"bids": [
["0.0024", "10.0"], // [价格, 数量]
["0.0023", "5.0"]
],
"asks": [
["0.0026", "8.0"],
["0.0027", "3.0"]
]
}
注意这里的lastUpdateId,它是订单簿的版本号。你每次拉取快照,这个ID都会递增。为什么重要?因为后面我们要用它来对齐WebSocket的增量数据。
2.3 WebSocket实时流:订单簿的“心跳”
REST快照只能拿到一个时间点的数据。但订单簿是实时变化的,每秒钟可能有几十次更新。这时候就需要WebSocket了。
Binance的WebSocket地址:
wss://stream.binance.com:9443/ws/btcusdt@depth
推送的数据格式:
{
"e": "depthUpdate", // 事件类型
"E": 123456789, // 事件时间
"s": "BTCUSDT", // 交易对
"U": 157, // 第一更新ID
"u": 160, // 最后更新ID
"b": [ // 买单更新
["0.0024", "10.0"]
],
"a": [ // 卖单更新
["0.0026", "8.0"]
]
}
这里有个关键点:U和u。它们表示这次更新覆盖的更新ID范围。你拿到增量数据后,需要根据这些ID来更新本地的订单簿副本。
我曾经踩过一个坑:WebSocket推送的增量数据,和REST快照的lastUpdateId之间,可能存在间隙。如果你直接用增量去更新快照,订单簿就会错位。解决办法是:
- 先拉取REST快照,记录
lastUpdateId - 同时开始接收WebSocket增量数据
- 丢弃所有
u <= lastUpdateId的增量 - 从
U <= lastUpdateId + 1 && u >= lastUpdateId + 1开始处理
2.4 Coinbase的差异点
Coinbase的API和Binance有些不同。它的REST快照接口:
GET https://api.pro.coinbase.com/products/BTC-USD/book?level=2
返回数据:
{
"sequence": 123456,
"bids": [
["0.0024", "10.0", 1]
],
"asks": [
["0.0026", "8.0", 1]
]
}
注意这里的sequence,相当于Binance的lastUpdateId。但Coinbase的WebSocket推送中,每条消息都带一个sequence字段,你需要用它来保证数据顺序。
Coinbase的WebSocket订阅:
wss://ws-feed.pro.coinbase.com
// 订阅消息
{
"type": "subscribe",
"product_ids": ["BTC-USD"],
"channels": ["level2"]
}
推送的增量数据:
{
"type": "l2update",
"product_id": "BTC-USD",
"time": "2024-01-01T00:00:00.000Z",
"changes": [
["buy", "0.0024", "10.0"]
]
}
Coinbase的增量处理逻辑和Binance类似,但有个细节:它的changes数组里,每个元素是[side, price, size]。如果size为0,表示删除该价格档位。
2.5 数据采集的整体流程
把上面这些串起来,数据采集的流程其实就三步:
- 建立WebSocket连接:订阅订单簿增量数据
- 拉取REST快照:获取当前订单簿基准
- 对齐数据:用快照的
lastUpdateId过滤增量,然后逐条更新
下面这张图展示了整个流程:
2.6 代码示例:Python实现
下面是一个简单的Python示例,演示如何同时拉取REST快照和接收WebSocket增量:
import asyncio
import json
import aiohttp
class OrderBookCollector:
def __init__(self, symbol="btcusdt"):
self.symbol = symbol
self.bids = {}
self.asks = {}
self.last_update_id = 0
async def fetch_snapshot(self):
"""拉取REST快照"""
url = f"https://api.binance.com/api/v3/depth?symbol={self.symbol}&limit=100"
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
data = await resp.json()
self.last_update_id = data["lastUpdateId"]
# 初始化订单簿
for price, qty in data["bids"]:
self.bids[float(price)] = float(qty)
for price, qty in data["asks"]:
self.asks[float(price)] = float(qty)
async def handle_depth_update(self, msg):
"""处理增量更新"""
data = json.loads(msg)
if data["u"] <= self.last_update_id:
return # 丢弃过期数据
# 更新买单
for price, qty in data["b"]:
price_f = float(price)
qty_f = float(qty)
if qty_f == 0:
self.bids.pop(price_f, None)
else:
self.bids[price_f] = qty_f
# 更新卖单
for price, qty in data["a"]:
price_f = float(price)
qty_f = float(qty)
if qty_f == 0:
self.asks.pop(price_f, None)
else:
self.asks[price_f] = qty_f
self.last_update_id = data["u"]
asyncio的ensure_future来并行处理快照和增量,避免阻塞。
2.7 常见问题与避坑
- 数据延迟:WebSocket的延迟通常在10-50ms,但REST快照可能延迟100ms以上。如果你需要毫秒级精度,建议只用WebSocket增量,定期用REST快照做校准。
- 数据丢失:网络波动可能导致WebSocket断开。我建议在客户端维护一个心跳检测,如果5秒内没有收到数据,就重新连接并重新拉取快照。
- 数据对齐:这是最容易出错的地方。我曾经因为忘记处理
U和u的边界情况,导致订单簿数据错乱了整整一天。后来我加了一个校验:每次更新后,检查本地订单簿的买卖价差是否合理。
depth接口直接拉取快照,不用WebSocket。虽然数据不是实时的,但做回测足够了。
好了,数据源和采集这部分就聊到这。记住一句话:快照是基准,增量是更新,对齐是关键。把这个流程跑通了,后面的清洗工作就顺了。