面向开发者的实时 WebSocket 数据流
一个中继。所覆盖交易所的事件经过标准化并合并为单一输出,按 100 毫秒的 tick 推送。这是唯一的处理:一笔成交进来,一笔成交出去。
它不是指标来源(没有滚动窗口、计数器或平均值),也没有历史:你收到的是从连接那一刻起发生的事件。过去的数据通过 REST 提供,面向 AI 智能体则通过 MCP 提供。
对 AI 智能体而言,通道是 MCP 服务器,而不是数据流:AI 智能体不会保持连接,而是在需要时发问。get_pair_data MCP 工具按需读取一个交易对的订单簿和最新成交;数据流则面向持续监听的代码。
#连接
同一数据流也在其独立服务器 wss://ws.bytnode.com/ws 上提供:相同的密钥、相同的频道、相同的帧。两个地址任选其一。
在服务器端,密钥放在握手的 X-API-KEY 请求头中;在网页中,短期令牌放在 URL 中(?token=,由 POST /v1/ws/token 签发:60 秒,一个连接)。两者都在握手时校验,早于连接建立。它与 REST 和 MCP 服务器使用同一把密钥(URL 中的 ?api_key= 仍被接受)。握手时可能出现两种拒绝:密钥或令牌缺失、无效、过期或已被使用时返回 401(X-Deny-Reason: missing_api_key / invalid_api_key,并与 REST 一样带 WWW-Authenticate);方案不含数据流(Free 即是如此)时返回 403(X-Deny-Reason: plan_websocket)。握手时返回 503 则是另一回事:数据流服务已达到容量上限(所有账户合计),请稍后重试。连接建立后,超出方案上限的那个连接会以代码 4003 关闭。
因此会话建立时即已认证:第一条消息就可以是订阅。旧的 op: auth 帧仍为握手校验之前编写的客户端保留:在已认证的连接上,它会被忽略并返回 {"op": "authenticated", "authenticated_by": "handshake"},无论其中携带什么密钥。
{"op": "auth", "api_key": "your_key"}#订阅
{
"op": "subscribe",
"channels": ["trades.futures.BTCUSDT", "orderbook.futures.BTCUSDT"],
"depth": 5
}取消订阅是对称的:op: unsubscribe,响应 op: unsubscribed。
- 响应会列出该连接的所有活跃频道,并排好序,而不仅是本次请求中的频道。它表示状态,而不是确认回执。
depth作用于连接,而不是某个频道。每次subscribe都会重置会话中所有订单簿的深度;省略时恢复为 5。它是每一侧(买方和卖方)的价格档位数;可接受值:1 到 5。- 订阅是全有或全无的。只要列表中有一个频道无效,就不会应用任何频道,错误会说明原因。部分订阅会让你误以为收到了所请求的内容。频道名称不区分大小写:
trades.futures.btcusdt会以trades.futures.BTCUSDT的形式返回。 - 订单簿订阅一旦被接受,会立即发送当前状态,无需等待下一个 tick。
subscribe 上可选的 mode:frame(默认)每帧发送一条消息,如下文所述;batch 在每个 tick 把所有待发送的帧放进一条消息 {"op": "batch", "frames": [...]}——消息更少,帧内容相同。batch 模式的客户端必须读取 frames,而不是跳过所有带 op 的消息。与 depth 一样,它作用于整个连接:每次 subscribe 都会重置它,省略时恢复为 frame。数据流的机器可读描述(频道、帧、控制消息、关闭代码)见 AsyncAPI 契约。
#五个频道
| 频道 | 性质与频率 |
|---|---|
| trades.futures.<SYMBOL> | 事件。每个 100 毫秒 tick 最多一个数据包,没有内容时不发送。负载较高时数据包间隔会变长,有时超过一秒:几秒钟的静默不代表故障。 |
| trades.spot.<SYMBOL> | 事件,频率相同。 |
| liquidations.<SYMBOL> | 事件,频率相同。 |
| orderbook.spot.<SYMBOL> | 状态:现货订单簿,仅合并以 USDT 计价的现货订单簿(只以美元计价该资产的交易所不在其中)。每个 tick 都会推送,即使没有变化。 |
| orderbook.futures.<SYMBOL> | 状态:永续合约订单簿,合并各交易所的永续市场,包括以 USD 或 USDC 计价的永续合约。推送节奏相同。 |
GET /v1/symbols 中的所有永续交易对都在五个频道上提供。两种稳定币只存在于现货:它们通过 trades.spot 和 orderbook.spot 提供,订阅它们的期货数据流、永续订单簿或爆仓会返回明确的错误,绝不会悄悄返回空频道。
频道与方案
每个频道都属于一个市场数据族,与 REST 和 MCP 相同:trades.futures、trades.spot、orderbook.spot 和 orderbook.futures 属于微观结构,liquidations 属于衍生品。方案中缺少的市场数据族在所有通道上都不可用,包括数据流。具体来说:Free 完全没有数据流(握手时返回 403),自 Traders 起开放全部数据流。实时市场数据不等于原始逐笔市场数据族——后者自 Financial 方案起开放,指的是通过 REST 提供的逐笔历史。
订阅方案外的频道会收到 {"op": "error", "server_time": "…", "error": {"code": "plan_family", "message": "Channel 'orderbook.futures.BTCUSDT' is not in your plan: the 'microstructure' data family is not included in your subscription.", "param": "channels"}},并且与任何订阅一样,该帧中的频道都不会生效。每个账户的同时连接数也取决于方案:Traders:3, Financial:10, Startup:20。见价格。
#消息格式
每条推送的消息都带有三个通用键。
| 字段 | 类型 | 说明 |
|---|---|---|
channel | string | 来源频道。 |
server_time | string | 构建该消息的服务器 tick 时刻,采用带毫秒的 ISO 8601 UTC 格式——与 REST 外层使用相同的键和格式。订单簿的初始状态带有其快照所在的 tick,因此可能早于其前面的 subscribed。每笔成交和每次强平在 data 中各自带有自己的 time。 |
seq | integer | 序列号,在每个频道内单调递增。起始值是任意的:不要假定从 0 或 1 开始。只有相邻两条消息之间的跳跃才表示有消息丢失。 |
成交
{
"channel": "trades.futures.BTCUSDT",
"server_time": "2026-10-14T09:25:26.408Z",
"data": [
{ "time": "2026-10-14T09:25:26.176Z", "id": "3cf0988ca613980c", "side": "buy", "price": 83283.9, "quantity": 0.24, "value_usd": 19988.136 },
{ "time": "2026-10-14T09:25:26.184Z", "id": "4b408a64af719aa6", "side": "sell", "price": 83268.6, "quantity": 0.002, "value_usd": 166.5372 }
],
"seq": 443379
}side 表示吃单方的方向:buy 表示买方吃掉了卖单。一行即一次成交:一笔吃单若在同一价格扫掉多笔相同数量的挂单,就会产生多行完全相同的记录,这是正常的。成交按 tick 分组,但不会丢失任何一笔:每笔都在 data[].time 中保留原始时间戳,与消息的 server_time 不同;同一条消息中的成交按该时间戳从早到晚排序。该顺序仅在单条消息内成立:后续消息可能带来比已收到的最新成交更早的成交(某交易所延迟上报,通常不到一秒,偶尔可达约 1.5 秒)。构建 K 线时,请按 data[].time 归入时间段,并在时间段结束约两秒后再收盘。
每笔成交都带有 id,与该成交在 /v1/trades/futures 或 /v1/trades/spot 中 tick 的标识相同(一个不透明的指纹:既不透露交易所,也不透露其原生 ID),以及 value_usd = price × quantity。可用 id 将实时流与 REST 历史去重。
爆仓
{
"channel": "liquidations.BTCUSDT",
"server_time": "2026-10-10T09:11:51.652Z",
"data": [
{ "time": "2026-10-10T09:11:51.576Z", "price": 83168.0, "quantity": 0.081, "side": "short", "value_usd": 6736.608 }
],
"seq": 7
}这里的 side 表示被强平仓位的方向:long 表示一个多头仓位被强平。并非所有交易所都采用这一约定;我们已为你做了标准化。与成交一样,同一条消息中的爆仓按 time 排序。某些交易所可能延迟几秒上报强平:请按 time 过滤和划分窗口,而不是按接收时刻。交易所在其 time 之后超过 60 秒重新发布的旧强平不会被转发(/v1/liquidations/events 保留所有事件)。
订单簿
{
"channel": "orderbook.futures.BTCUSDT",
"server_time": "2026-10-14T09:25:26.304Z",
"data": {
"bids": [[83271.0, 67.65684], [83270.0, 4.85424], [83269.0, 15.31266]],
"asks": [[83272.0, 17.0135], [83273.0, 11.33315], [83274.0, 5.34624]],
"avg_mid_price": 83271.75,
"avg_spread_bps": 0.036
},
"seq": 627302
}每一行是 [价格, 数量],数量为各交易所在该价位的总和。bids 递减,asks 递增。价格会被归并到每种资产统一的价格网格上,否则每个交易所会落在各自的档位里,合并后的订单簿会有更多但更薄的行。
该网格的步长(以计价货币计):BTC 1; ETH, BNB, TAO, ZEC, BCH 0.1; SOL, HYPE, XMR, LTC, QNT, AAVE, ETC, VVV, DASH, BSV 0.01; LINK, UNI, GRAM, AVAX, NEAR, DOT, MORPHO, ICP, RENDER, ATOM, FIL, INJ, LIT, ZRO, CAKE, TRUMP, PENDLE, LDO 0.001; XRP, TRX, SUI, XLM, ADA, USDCUSDT, USDTUSDC, ENA, ONDO, WLD, ASTER, WLFI, ARB, ALGO, POL, JUP, AERO, APT, STX, TIA, CRV, FET, VIRTUAL, GRASS, XPL, SPX, XTZ 0.0001; DOGE, HBAR, CC, SKY, JST, KAS, STABLE, AKE, NIGHT, PYTH, STRK, SEI, FF 0.00001; PUMP, VET, PENGU 0.000001。
每个市场各有一个订单簿:orderbook.spot 只合并现货订单簿,orderbook.futures 只合并永续订单簿。两者从不混合,因为它们的价格相差一个基差。在同一市场内,若某交易所的中间价偏离其他交易所中位数超过 5 个基点(至少有三个交易所时),它会被排除在合并之外,直到恢复正常:单个异常交易所无法扭曲合并后的订单簿。
#需要了解的事项
合并订单簿是深度分布,而不是某个交易所的订单簿
同一市场的多个交易所之间没有统一的撮合引擎:它们的中间价相差几个基点,而单个交易所在 BTC 上的买卖价差约为 0.1 个基点。若按原始价格相加,它们的档位几乎在每条消息中都会让合并订单簿出现交叉。
自 2026-10-08 起,每个交易所在合并前都会以 avg_mid_price 为中心重新对齐:其所有档位平移 avg_mid_price 减去其自身中间价的差值。数量保持不变,每个档位到其交易所中间价的距离也保持不变,合并订单簿从不交叉:bids[0][0] 始终低于 asks[0][0]。此刻只有单边订单簿的交易所不参与合并。
成交笔数不能衡量活跃度
有些交易所把成交拆得极细,细到一聪、不到一美分的订单。因此在合并数据流中,成交笔数主要由极小的成交构成。要衡量活跃度,请累加 quantity 或 quantity × price,而不要数行数。
seq 跳跃说明你丢失了消息
每个客户端的发送队列上限为 512 帧,满负载时约八个 tick(可吸收 800 毫秒的抖动)。消费不够快的客户端会丢弃最旧的消息;它只影响自己,不会波及其他客户端。
在事件频道上,被丢弃的消息永久丢失;seq 的跳跃会告诉你这一点。在订单簿上只保留最新状态:跳跃没有任何影响,下一个状态会完全替换前一个。
凭据在握手时校验,而不是在帧中
密钥(或令牌)在握手时校验,早于连接建立:不含数据流的方案会收到 403,不会建立任何连接。在服务器端,它放在 X-API-KEY 请求头中;浏览器无法为 WebSocket 设置请求头,因此在 URL 中使用短期令牌(?token=),这样密钥本身永远不会离开你的服务器。
{"op": "auth"} 帧仍被接受并返回 authenticated,但已不再必要:会话建立时即已认证。?token= 和 ?api_key= 只在 WebSocket 握手时被接受,REST 接口从不接受,且网关不会记录 /ws 的请求行。
同时连接数取决于方案
每个方案都限制同时打开的连接数,按整个账户计算,涵盖所有密钥。超出的连接会立即以代码 4003 关闭,并在原因中给出上限。不要把它与握手时的 503 混淆:后者表示服务本身已满,与方案无关。
断开连接会立即释放名额:无需等待,也无需重置。
数据流从不标明交易所
任何消息都不会说明一笔成交、一次爆仓或订单簿的某一档来自哪个交易所,也不会说明有多少交易所参与:数据流提供的是合并后的市场。沉默超过十秒的交易所会自动退出订单簿,订单簿为空的交易所也从不参与合并。
#限制与关闭
| 限制 | 超出时 |
|---|---|
| 密钥或令牌缺失、无效或已被使用 | 握手时返回 401,X-Deny-Reason: missing_api_key / invalid_api_key |
| 方案不含数据流(Free) | 握手时返回 403,X-Deny-Reason: plan_websocket |
| Simultaneous connections per account: 3 on Traders, 10 on Financial, 20 on Startup | 以 4003 关闭,原因中给出上限 |
| 数据流服务的总容量(所有账户合计) | 握手时返回 503,稍后重试 |
| 订阅方案外市场数据族的频道 | op: error,该帧中的频道均不生效 |
| 建立连接:每秒 1 次,突发 10 次 | 握手时返回 429 |
| 发送队列 512 帧 | 丢弃最旧的帧,seq 出现跳跃 |
| 允许溢出 10 次 | 以 4005 关闭,原因为 “client too slow” |
| depth 在 1 到 5 之间 | 错误,订阅不生效 |
| 关闭码 | 原因 |
|---|---|
| 4003(上限) | 已达到方案的同时连接数上限,按整个账户计算。断开连接会立即释放名额。 |
| 4005(客户端过慢) | 队列长时间饱和。连续十次丢失八百毫秒数据流的客户端将无法追上。 |
| 4008 | 客户端每秒发送超过 20 条消息:控制消息按连接限速。 |
| 4010 | 会话过期:连接在 6 小时后关闭;请重新连接并再次订阅(密钥和方案会重新校验)。 |
| 1011 | 保活 ping 超时:服务器每 20 秒发送一次 ping,若 20 秒内未收到 pong 则关闭连接。常用客户端库会自动应答;请重新连接。 |
| 4001 | 实际上已不再发送:密钥在握手时校验(401),在已打开的会话上发送 auth 帧会返回 authenticated。 |
| 4002 | 会话在 5 秒内始终未认证。由于密钥已在握手时校验,这种情况很少见。 |
每个无法满足的请求都会收到带名称的错误,绝不会默不作声:未知交易对、未知频道、在稳定币上请求期货市场、depth 越界、认证前订阅、无法解析的消息。错误会说明期望的内容。
错误帧包含与 REST 错误相同的部分:{"op": "error", "server_time": …, "error": {"code": …, "message": …, "param": …, "details": …}'},其中 param 是你的消息中出错的字段。所有控制帧(subscribed、unsubscribed、authenticated)都带有 server_time,发送 {"op": "ping"} 会收到 {"op": "pong", "server_time": …}。
#重新连接
连接可能会中断(部署、网络)。请以递增的等待时间重连(0.5 秒起,每次加倍,最多 30 秒,并加入随机部分),然后重新发送 subscribe:订阅不会在连接之间保留。握手时的 401 或 403,以及因上限而关闭的 4003,不要重试:会得到同样的回应。服务运行期间 seq 在重连之间连续,但服务重启后会从任意值重新开始:请把重连后的第一条消息当作新的起点。若您的套餐包含逐笔成交,期间错过的成交可以通过 /v1/trades/spot 或 /v1/trades/futures 加 since_ms 补读:每条成交的 id 与流中相同,按它去重。SDK 会自动为您重连并重新订阅。
#完整示例
import asyncio
import json
import os
import websockets # pip install websockets
async def main() -> None:
# From a server, the key goes in the X-API-KEY header, checked at the
# handshake: the session opens already authenticated. A refused key gets 401
# before it opens, a plan without the stream 403, a service at capacity 503
# (InvalidStatus on the websockets side). In a web page: ?token= instead.
headers = {"X-API-KEY": os.environ["BYTNODE_API_KEY"]}
async with websockets.connect("wss://api.bytnode.com/ws", additional_headers=headers) as ws:
await ws.send(json.dumps({
"op": "subscribe",
"channels": ["trades.futures.BTCUSDT", "orderbook.futures.BTCUSDT"],
"depth": 5,
}))
print(await ws.recv())
expected: dict[str, int] = {}
async for raw in ws:
msg = json.loads(raw)
if "op" in msg: # a control message (error, pong...), not data
print(msg)
continue
channel, seq = msg["channel"], msg["seq"]
# The first seq of a channel is arbitrary: only a jump between two
# messages counts. It 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("orderbook."):
# The book is a consolidated depth centred on avg_mid_price, not one
# venue's book: use the per-venue average spread to trade.
spread = msg["data"]["avg_spread_bps"]
if spread is not None:
print(f"{channel} mid={msg['data']['avg_mid_price']} spread={spread:.3f} bps")
else:
print(f"{channel} {len(msg['data'])} event(s)")
asyncio.run(main())若用 AI 编程智能体编写此客户端,Agent Skill bytnode-realtime-stream 会教它本页内容:浏览器令牌、由成交生成实时 K 线、丢失消息与重连。