现货 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:orders、spot: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_time | unix 秒。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"
}
}
}
| 字段 | 说明 |
|---|---|
status | new / 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),因此一笔同时影响 DF 和 USDT 的成交会发 2 条消息。
{
"type": "spot_user_balance",
"channel": "spot:user:balances",
"data": {
"token": "DF",
"available": "10000",
"frozen": "100",
"ts": 1778400000
}
}
| 字段 | 说明 |
|---|---|
available | 可用余额(与 GET /spot/balances 一致)。 |
frozen | 已为在途提现 / 挂单冻结的部分。 |
ts | unix 秒。 |
错误
{ "type": "error", "code": "INVALID_CHANNEL", "message": "Unknown channel: spot:wat" }
| 代码 | 触发条件 |
|---|---|
INVALID_CHANNEL | 未知通道名(任何现货前缀拼写错误,例如 spot:depths:…)。 |
AUTH_REQUIRED | 未先鉴权就订阅 spot:user:*。 |
INVALID_MESSAGE | 客户端帧解析失败。 |
快照 ↔ 实时同步
spot:depth:* 推荐流程:
- 订阅 → 服务端排队一条
spot_depth_snapshot,并立即开始为该 symbol 缓存实时spot_depth_diff。 - 应用快照。
- 按到达顺序应用后续差分。快照的
last_update_id与差分的update_id_last - 1衔接(如果订阅时正赶上一波连续推送,则丢弃update_id_last <= last_update_id的差分)。
spot:ticker:* 与 spot:kline:* 的快照与后续推送是幂等的——丢失的某次推送会被几秒后的下一次覆盖,无需重同步。
spot:user:* 通道不发送快照。鉴权后请先调用 GET /spot/orders 与 GET /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);