实时

WebSocket 数据流

一个中继。所覆盖交易所的事件经过标准化并合并为单一输出,每 100 毫秒推送一次。这是唯一的处理:一笔成交进来,一笔成交出去。

它不是指标来源(没有滚动窗口、计数器或平均值),也没有历史:你收到的是从连接那一刻起发生的事件。过去的数据由 REST API 提供。

#连接

WSSwss://api.bytnode.com/ws?api_key=cb_live_…WebSocket 传输

密钥以 api_key 参数的形式放在 URL 中,并在握手时校验,早于连接建立。它与 REST API 使用同一把密钥。握手时可能出现三种拒绝,均为带 X-Deny-Reason 响应头的 403:密钥缺失、无效或已吊销(key);方案不含数据流,Free 即是如此(websocket);连接建立后,超出方案上限的那个连接会以代码 4003 关闭。

因此会话建立时即已认证:第一条消息就可以是订阅。旧的 op: auth 帧仍被接受并返回 ok,但它是可选的。

{"op": "auth", "api_key": "your_key"}

#订阅

{
  "op": "subscribe",
  "channels": ["trades.futures.BTCUSDT", "book.BTCUSDT"],
  "depth": 5
}

取消订阅是对称的:op: unsubscribe,响应 op: unsubscribed。

  • 响应会列出该连接的所有活跃频道,并排好序,而不仅是本次请求中的频道。它表示状态,而不是确认回执。
  • depth 作用于连接,而不是某个频道。每次 subscribe 都会重置会话中所有订单簿的深度;省略时恢复为 5。可接受值:1 到 5。
  • 订阅是全有或全无的。只要列表中有一个频道无效,就不会应用任何频道,错误会说明原因。部分订阅会让你误以为收到了所请求的内容。
  • 订单簿订阅一旦被接受,会立即发送当前状态,无需等待下一个 tick。

#四个频道

频道性质与频率
trades.futures.<SYMBOL>事件。每 100 毫秒一个数据包,没有内容时不发送。
trades.spot.<SYMBOL>事件,频率相同。
liquidations.<SYMBOL>事件,频率相同。
book.<SYMBOL>状态。每个 tick 都会推送,即使没有变化。

四个频道都提供二十个永续合约。两种稳定币只存在于现货:订阅它们的期货数据流、订单簿或爆仓会返回明确的错误,绝不会悄悄返回空频道。

频道与方案

每个频道都属于一个数据族,与 REST API 相同:trades.futures、trades.spot 和 book 属于微观结构,liquidations 属于衍生品。方案中缺少的数据族在所有通道上都不可用,包括数据流。具体来说:Free 完全没有数据流(握手时返回 403),自 Traders 起开放全部数据流。实时数据不等于原始逐笔数据族——后者仅限 Startup,指的是 REST API 的逐笔历史。

订阅方案外的频道会收到 {"op": "error", "message": "canal 'book.BTCUSDT' hors offre : la famille 'microstructure' n'est pas incluse dans votre abonnement."},并且与任何订阅一样,该帧中的频道都不会生效。每个账户的同时连接数也取决于方案:Traders:3, Financial:10, Startup:20。见价格。

#消息格式

每条推送的消息都带有四个通用键。

字段类型说明
channelstring来源频道。
tsinteger该 tick 的服务器时间戳,单位为毫秒。
seqinteger序列号,在每个频道和每个连接内单调递增。出现跳跃表示有消息丢失。
venues_ininteger为该消息提供数据的交易所数量。是一个数字,而不是列表。

成交

trades
{
  "channel": "trades.futures.BTCUSDT",
  "ts": 1787000000100,
  "seq": 42,
  "venues_in": 6,
  "data": [
    { "ts": 1787000000037, "price": 71420.4, "qty": 0.125, "side": "buy" },
    { "ts": 1787000000091, "price": 71420.2, "qty": 1.400, "side": "sell" }
  ]
}

side 表示吃单方的方向:buy 表示买方吃掉了卖单。成交按 100 毫秒分组,但不会丢失任何一笔:每笔都在 data[].ts 中保留原始时间戳,与 tick 的 ts 不同。

爆仓

liquidations
{
  "channel": "liquidations.BTCUSDT",
  "ts": 1787000000100,
  "seq": 7,
  "venues_in": 2,
  "data": [
    { "ts": 1787000000064, "price": 71180.0, "qty": 2.5, "side": "long", "usd": 177950.0 }
  ]
}

这里的 side 表示被强平仓位的方向:long 表示一个多头仓位被强平。并非所有交易所都采用这一约定;我们已为你做了标准化。

订单簿

book
{
  "channel": "book.BTCUSDT",
  "ts": 1787000000100,
  "seq": 128,
  "venues_in": 6,
  "data": {
    "bids": [[71420.0, 12.41], [71419.0, 8.07], [71418.0, 15.33]],
    "asks": [[71421.0, 9.85], [71422.0, 4.12], [71423.0, 22.60]]
  }
}

每一行是 [价格, 数量],数量为各交易所在该价位的总和。bids 递减,asks 递增。价格会被归并到每种资产统一的价格网格上,否则每个交易所会落在各自的档位里,合并后的订单簿会有更多但更薄的行。

订单簿的覆盖面比成交窄:有一个交易所没有提供可用的有界窗口。和其他地方一样,venues_in 表示该消息的实际覆盖情况。

#需要了解的四件事

合并订单簿可能出现交叉

在单一交易所中,最高买价总是低于最低卖价:撮合引擎会让它们相互成交。在多个交易所之间,这种保障并不存在。因此一个交易所的最佳 bid 可能高于另一个交易所的最佳 ask。

这不是数据流损坏:这是真实的套利窗口,之所以存在,是因为在交易所之间转移资金需要时间并产生费用。在比特币上测得的量级:三到八个基点。

成交笔数不能衡量活跃度

有些交易所把成交拆得极细,细到一聪、不到一美分的订单。因此在合并数据流中,成交笔数主要由极小的成交构成。要衡量活跃度,请累加 qty 或 qty × price,而不要数行数。

seq 跳跃说明你丢失了消息

每个客户端的发送队列上限为 256 帧,约八个 tick(可吸收 800 毫秒的抖动)。消费不够快的客户端会丢弃最旧的消息;它只影响自己,不会波及其他客户端。

在事件频道上,被丢弃的消息永久丢失;seq 的跳跃会告诉你这一点。在订单簿上只保留最新状态:跳跃没有任何影响,下一个状态会完全替换前一个。

密钥放在 URL 中,而不是帧中

密钥在握手时校验,早于连接建立:不含数据流的方案会收到 403,不会建立任何连接。这就是它以 URL 参数传递的原因:浏览器无法为 WebSocket 设置请求头,而校验必须在连接建立之前完成。

{"op": "auth"} 帧仍被接受并返回 ok,但已不再必要:会话建立时即已认证。?api_key= 参数只在 WebSocket 握手时被接受,REST 接口从不接受,且网关不会记录 /ws 的请求行。

同时连接数取决于方案

每个方案都限制同时打开的连接数,按整个账户计算,涵盖所有密钥。超出的连接会立即以代码 4003 关闭,并在原因中给出上限。

断开连接会立即释放名额:无需等待,也无需重置。

venues_in 是一个数字,而不是列表

数据流从不标明来源交易所。venues_in 给出覆盖情况但不透露来源,而且不可或缺:没有它,就无法区分市场平静与一半交易所宕机。沉默超过十秒的交易所会自动被排除,订单簿为空的交易所也从不计入。

#限制与关闭

限制超出时
URL 中的密钥缺失或无效握手时返回 403,X-Deny-Reason: key
方案不含数据流(Free)握手时返回 403,X-Deny-Reason: websocket
Simultaneous connections per account: 3 on Traders, 10 on Financial, 20 on Startup以 4003 关闭,原因中给出上限
订阅方案外数据族的频道op: error,该帧中的频道均不生效
建立连接:每秒 1 次,突发 10 次握手时返回 429
发送队列 256 帧丢弃最旧的帧,seq 出现跳跃
允许溢出 10 次以 4003 关闭,原因为 “client too slow”
depth 在 1 到 5 之间错误,订阅不生效
关闭码原因
4003(上限)已达到方案的同时连接数上限,按整个账户计算。断开连接会立即释放名额。
4003(客户端过慢)队列长时间饱和。连续十次丢失八百毫秒数据流的客户端将无法追上。
4001显式认证帧被拒绝:其中携带的密钥无效。
4002会话在 5 秒内始终未认证。由于密钥已在握手时校验,这种情况很少见。

每个无法满足的请求都会收到带名称的错误,绝不会默不作声:未知交易对、未知频道、在稳定币上请求期货市场、depth 越界、认证前订阅、无法解析的消息。错误会说明期望的内容。

#完整示例

认证、订阅、丢包检测以及读取交叉的订单簿。
import asyncio
import json
import os

import websockets


async def main() -> None:
    # The key travels in the URL, checked at the handshake: the session opens
    # already authenticated. A refused key, or a plan without the stream, gets
    # 403 before it opens (InvalidStatus on the websockets side).
    url = f"wss://api.bytnode.com/ws?api_key={os.environ['BYTNODE_KEY']}"
    async with websockets.connect(url) as ws:
        await ws.send(json.dumps({
            "op": "subscribe",
            "channels": ["trades.futures.BTCUSDT", "book.BTCUSDT"],
            "depth": 5,
        }))
        print(await ws.recv())

        expected: dict[str, int] = {}

        async for raw in ws:
            msg = json.loads(raw)
            channel, seq = msg["channel"], msg["seq"]

            # A sequence jump signals lost messages: the client is not
            # consuming fast enough.
            if channel in expected and seq != expected[channel]:
                print(f"!! {seq - expected[channel]} message(s) lost on {channel}")
            expected[channel] = seq + 1

            if channel.startswith("book."):
                bids, asks = msg["data"]["bids"], msg["data"]["asks"]
                if bids and asks:
                    # Can be negative: the consolidated book crosses.
                    spread = asks[0][0] - bids[0][0]
                    print(f"{channel} spread={spread:+.2f} venues={msg['venues_in']}")
            else:
                print(f"{channel} {len(msg['data'])} event(s)")


asyncio.run(main())