WebSocket 数据流
一个中继。所覆盖交易所的事件经过标准化并合并为单一输出,每 100 毫秒推送一次。这是唯一的处理:一笔成交进来,一笔成交出去。
它不是指标来源(没有滚动窗口、计数器或平均值),也没有历史:你收到的是从连接那一刻起发生的事件。过去的数据由 REST API 提供。
#连接
密钥以 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。见价格。
#消息格式
每条推送的消息都带有四个通用键。
| 字段 | 类型 | 说明 |
|---|---|---|
channel | string | 来源频道。 |
ts | integer | 该 tick 的服务器时间戳,单位为毫秒。 |
seq | integer | 序列号,在每个频道和每个连接内单调递增。出现跳跃表示有消息丢失。 |
venues_in | integer | 为该消息提供数据的交易所数量。是一个数字,而不是列表。 |
成交
{
"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 不同。
爆仓
{
"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 表示一个多头仓位被强平。并非所有交易所都采用这一约定;我们已为你做了标准化。
订单簿
{
"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())