跳到主要内容

现货 WebSocket

现货订单簿(CLOB)的实时推送通道。共 6 条:4 条公共行情通道 + 2 条按用户鉴权的私有通道。

现货推送和永续推送共用同一个 WebSocket 端点——只需建立一个连接,按需 subscribe 任意永续 + 现货组合。鉴权、ping/pong、subscribe / unsubscribe 语义与永续 WebSocket 通用说明一致。

连接地址

环境URL
测试网wss://api-sepolia.p99.world/ws
主网(暂未部署)

所有帧均为 JSON 文本。建议每 30 秒发一次 {"type":"ping"} 维持连接。

通道速览

通道鉴权订阅快照实时
spot:depth:{symbol}是(前 1000 档)每次盘口变动推送差分
spot:trade:{symbol}每笔公共成交一推
spot:ticker:{symbol}是(当前 24h 行)每次成交一推(外加 60 秒周期重算)
spot:kline:{symbol}:{interval}是(最新一根)每次落入该周期的成交一推
spot:user:orders必填每次 place / fill / cancel / reject 一推
spot:user:balances必填每次 (token) 行变动一推

{interval}1m / 5m / 15m / 1h / 4h / 1d。MVP 仅上线 DFUSDT,故 {symbol} 固定为 DFUSDT

未识别的现货通道会返回 INVALID_CHANNEL 错误,不会被静默接受。

鉴权

私有现货通道(spot:user:ordersspot:user:balances)的鉴权流程与私有永续通道完全一致。最简便方式是 JWT:

{"type": "auth_token", "token": "<JWT>"}

或者直接在 subscribe 帧里附带 token

{"type": "subscribe", "channel": "spot:user:orders", "token": "<JWT>"}

EIP-712 签名和 Binance 风格的 listenKey 也可以用——完整 payload 见永续鉴权章节。鉴权通过后,服务端会在每条现货用户推送转发前按你的钱包地址过滤,因此即使同一连接被多用户共享(当前不支持)也只能看到自己的数据。

公共通道

spot:depth:{symbol}

订阅时立刻发送一次快照(type: "spot_depth_snapshot")。之后每次盘口变动(place / cancel / fill)只推变化的价位

快照

{
"type": "spot_depth_snapshot",
"channel": "spot:depth:DFUSDT",
"data": {
"symbol": "DFUSDT",
"last_update_id": 12345,
"bids": [["0.5000", "100"], ["0.4999", "200"]],
"asks": [["0.5001", "150"], ["0.5002", "180"]]
}
}

差分

{
"type": "spot_depth_diff",
"channel": "spot:depth:DFUSDT",
"data": {
"symbol": "DFUSDT",
"update_id_first": 12346,
"update_id_last": 12346,
"bids": [["0.5000", "70"]],
"asks": []
}
}
字段说明
last_update_id / update_id_*引擎单调递增计数。每次 place / cancel / fill 自增。可用于判断是否丢失差分。
bids / asks[价格, 该档总量]总量 == "0" 表示该档已空,本地应移除。

差分包含变化的价位——一笔吃单消耗了 1 档买盘并部分吃掉另 1 档时只推 2 条记录,不会推整本。

spot:trade:{symbol}

每笔公共成交一推,无快照。如需历史成交请先 GET /spot/trades

{
"type": "spot_trade",
"channel": "spot:trade:DFUSDT",
"data": {
"trade_id": "9f2a-…",
"symbol": "DFUSDT",
"side": "buy",
"price": "0.5000",
"quantity": "30",
"ts": 1778400000123
}
}

side 是吃单方(taker)。ts 单位为 unix 毫秒

spot:ticker:{symbol}

订阅时发送当前 24h 行的快照(type: "spot_ticker"),之后每次成交以及周期 60 秒重算各推送一次 spot_ticker

{
"type": "spot_ticker",
"channel": "spot:ticker:DFUSDT",
"data": {
"symbol": "DFUSDT",
"last_price": "0.5",
"open_price": "0.48",
"high": "0.51",
"low": "0.47",
"volume": "10000",
"quote_volume": "5000",
"trade_count": 234,
"open_time": 1778313600,
"close_time": 1778400000,
"ts": 1778400000
}
}

open_time / close_time / ts 均为 unix 。窗口 = [now-86400, now]

spot:kline:{symbol}:{interval}

订阅时推送最新一根 K 线快照(type: "spot_kline_snapshot")。之后每笔落入该周期的成交都会推送 spot_kline_update,含最新 OHLCV。

{
"type": "spot_kline_update",
"channel": "spot:kline:DFUSDT:1m",
"data": {
"symbol": "DFUSDT",
"interval": "1m",
"open_time": 1778313600,
"close_time": 1778313659,
"open": "0.48",
"high": "0.51",
"low": "0.47",
"close": "0.50",
"volume": "10000",
"quote_volume": "4900",
"trade_count": 234,
"is_closed": false
}
}
字段说明
open_time / close_timeunix close_time = open_time + interval - 1
is_closed周期窗口结束时变为 true。监听该字段以推进到下一根 K 线。

聚合器对每个 (周期, 成交) 组合都会更新一根 K 线,因此同时订阅 1m + 5m + 1h 时每笔成交会收到 3 条推送(一周期一条)。

私有通道

spot:user:orders

在订单 place、fill(每笔成交一推;maker 与 taker 各看自己)、cancel、reject 时推送。

{
"type": "spot_user_order",
"channel": "spot:user:orders",
"data": {
"id": "8b3d-…",
"symbol": "DFUSDT",
"side": "buy",
"type": "limit",
"tif": "GTC",
"price": "0.5",
"quantity": "100",
"quote_quantity": null,
"filled_qty": "30",
"avg_fill_price": "0.5",
"status": "partially_filled",
"reject_reason": null,
"updated_at": 1778400000,
"last_fill": {
"trade_id": "9f2a-…",
"price": "0.5",
"quantity": "30",
"fee": "0.015",
"fee_token": "DF"
}
}
}
字段说明
statusnew / open / partially_filled / filled / canceled / rejected。与 GET /spot/orders 同义。
last_fill仅在该推送由成交触发时存在。 普通 place / cancel / reject 不带该字段。
last_fill.fee_token手续费扣费币种——收到 DF 时为 DF(你是 buy 的 taker 或 sell 的 maker),收到 USDT 时为 USDT
quote_quantity仅当原下单为按 quote 计量的市价 BUY(REST 中的 quoteQuantity)时填值,其它情况为 null

updated_at 为 unix

spot:user:balances

当你的现货余额 (token) 行有变动时推送——成交、admin 加余额、永续 ↔ 现货内部划转都会触发。每条推送对应一个 (token),因此一笔同时影响 DFUSDT 的成交会发 2 条消息。

{
"type": "spot_user_balance",
"channel": "spot:user:balances",
"data": {
"token": "DF",
"available": "10000",
"frozen": "100",
"ts": 1778400000
}
}
字段说明
available可用余额(与 GET /spot/balances 一致)。
frozen已为在途提现 / 挂单冻结的部分。
tsunix

错误

{ "type": "error", "code": "INVALID_CHANNEL", "message": "Unknown channel: spot:wat" }
代码触发条件
INVALID_CHANNEL未知通道名(任何现货前缀拼写错误,例如 spot:depths:…)。
AUTH_REQUIRED未先鉴权就订阅 spot:user:*
INVALID_MESSAGE客户端帧解析失败。

快照 ↔ 实时同步

spot:depth:* 推荐流程:

  1. 订阅 → 服务端排队一条 spot_depth_snapshot,并立即开始为该 symbol 缓存实时 spot_depth_diff
  2. 应用快照。
  3. 按到达顺序应用后续差分。快照的 last_update_id 与差分的 update_id_last - 1 衔接(如果订阅时正赶上一波连续推送,则丢弃 update_id_last <= last_update_id 的差分)。

spot:ticker:*spot:kline:* 的快照与后续推送是幂等的——丢失的某次推送会被几秒后的下一次覆盖,无需重同步。

spot:user:* 通道不发送快照。鉴权后请先调用 GET /spot/ordersGET /spot/balances 各一次播种本地状态,之后通过实时推送维护即可。

背压

每个连接有一个 256 帧的发送队列。如果客户端消费不过来,超出部分会被静默丢弃。服务端日志中 lagged 警告反映了这一情况;客户端如发现 update_id_* 出现大跨度跳跃,应视为"需要重新拉快照"并重新订阅 depth。

代码示例

const ws = new WebSocket("wss://api-sepolia.p99.world/ws");

ws.onopen = () => {
// 仅订阅 spot:user:* 时需要鉴权
ws.send(JSON.stringify({ type: "auth_token", token: "<JWT>" }));
};

ws.onmessage = (event) => {
const msg = JSON.parse(event.data);

if (msg.type === "auth_result" && msg.success) {
ws.send(JSON.stringify({ type: "subscribe", channel: "spot:depth:DFUSDT" }));
ws.send(JSON.stringify({ type: "subscribe", channel: "spot:trade:DFUSDT" }));
ws.send(JSON.stringify({ type: "subscribe", channel: "spot:ticker:DFUSDT" }));
ws.send(JSON.stringify({ type: "subscribe", channel: "spot:kline:DFUSDT:1m" }));
ws.send(JSON.stringify({ type: "subscribe", channel: "spot:user:orders" }));
ws.send(JSON.stringify({ type: "subscribe", channel: "spot:user:balances" }));
}

if (msg.type === "spot_depth_snapshot") { /* 播种本地盘口 */ }
if (msg.type === "spot_depth_diff") { /* 应用差分 */ }
if (msg.type === "spot_trade") { /* 打印成交带 */ }
if (msg.type === "spot_ticker") { /* 更新顶部行情 */ }
if (msg.type === "spot_kline_update") { /* 重绘当前 K 线 */ }
if (msg.type === "spot_user_order") { /* 同步自己的订单状态 */ }
if (msg.type === "spot_user_balance") { /* 更新钱包显示 */ }
};

setInterval(() => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" }));
}, 30000);