2. 数据源与采集:常见交易所API(Binance、Coinbase),WebSocket实时流与REST快照

做订单簿数据清洗,第一步不是写代码,而是搞清楚数据从哪来。

我见过不少新手,一上来就对着K线数据猛写清洗逻辑,结果发现订单簿的原始数据结构和K线完全两码事。嗯,今天我们就聊聊数据源这件事。

2.1 为什么选Binance和Coinbase?

市面上交易所很多,但真正适合做订单簿数据研究的,其实没几家。我个人习惯用Binance和Coinbase,原因很简单:

  • 文档清晰:API文档写得像教科书,新手也能看懂
  • 数据量大:深度好,流动性足,订单簿变化频繁
  • WebSocket稳定:断线重连机制成熟,我在项目中几乎没遇到过数据丢失
  • REST快照准确:快照数据是订单簿的基准,Binance的快照延迟通常在50ms以内
小提示:如果你刚开始做订单簿研究,建议先用Binance的BTC/USDT交易对。流动性最好,数据特征最典型,踩坑了也容易排查。

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的增量数据。

注意:Binance的REST快照最多返回5000档深度。但说实话,我建议你只用前100档。为什么?因为深度越深,数据噪音越大,而且大部分交易都发生在靠近盘口的位置。取1000档,清洗起来反而麻烦。

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"]
  ]
}

这里有个关键点:Uu。它们表示这次更新覆盖的更新ID范围。你拿到增量数据后,需要根据这些ID来更新本地的订单簿副本。

我曾经踩过一个坑:WebSocket推送的增量数据,和REST快照的lastUpdateId之间,可能存在间隙。如果你直接用增量去更新快照,订单簿就会错位。解决办法是:

  1. 先拉取REST快照,记录lastUpdateId
  2. 同时开始接收WebSocket增量数据
  3. 丢弃所有u <= lastUpdateId的增量
  4. 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,表示删除该价格档位。

经验之谈:我在做跨交易所订单簿对比时,发现Binance和Coinbase的深度数据在盘口附近差异很小,但远离盘口时,Coinbase的深度往往更薄。这跟两个交易所的做市商策略有关。

2.5 数据采集的整体流程

把上面这些串起来,数据采集的流程其实就三步:

  1. 建立WebSocket连接:订阅订单簿增量数据
  2. 拉取REST快照:获取当前订单簿基准
  3. 对齐数据:用快照的lastUpdateId过滤增量,然后逐条更新

下面这张图展示了整个流程:

订单簿数据采集流程 WebSocket实时流 增量数据 (depthUpdate) REST快照 全量数据 (depth) 数据对齐 过滤+合并 清洗后的订单簿 持续接收增量 一次性拉取 丢弃过期数据 关键:快照的 lastUpdateId 必须小于等于增量的 U

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"]
注意:上面的代码只是演示核心逻辑。生产环境中,你还需要处理WebSocket断线重连、数据持久化、异常重试等。我建议用asyncioensure_future来并行处理快照和增量,避免阻塞。

2.7 常见问题与避坑

  • 数据延迟:WebSocket的延迟通常在10-50ms,但REST快照可能延迟100ms以上。如果你需要毫秒级精度,建议只用WebSocket增量,定期用REST快照做校准。
  • 数据丢失:网络波动可能导致WebSocket断开。我建议在客户端维护一个心跳检测,如果5秒内没有收到数据,就重新连接并重新拉取快照。
  • 数据对齐:这是最容易出错的地方。我曾经因为忘记处理Uu的边界情况,导致订单簿数据错乱了整整一天。后来我加了一个校验:每次更新后,检查本地订单簿的买卖价差是否合理。
小技巧:如果你只是想快速验证某个策略,可以用Binance的depth接口直接拉取快照,不用WebSocket。虽然数据不是实时的,但做回测足够了。

好了,数据源和采集这部分就聊到这。记住一句话:快照是基准,增量是更新,对齐是关键。把这个流程跑通了,后面的清洗工作就顺了。

总结:Binance和Coinbase的API设计思路类似,但细节上有差异。建议先吃透一个交易所,再迁移到另一个。我个人推荐从Binance开始,因为它的文档更友好,社区资源也更丰富。