WebSocket 流
实时推送。生成一张票据、发起连接,服务器就会将你的套餐有权获取的机会推送给你——无遗漏,附带心跳和断线重连恢复机制。
WebSocket 流是 API 的实时一侧。每当有机会开放或发生变化,它都会在那一瞬间推送给你,无需轮询,也不会有数据过时的问题。它适用于 Basic 和 Pro 套餐。Free 密钥可以读取 REST 快照,但无法生成流票据。
该流使用 Centrifugo 协议,因此你需要用对应语言的 centrifuge 客户端连接,而不是原始 Socket。连接是一次两步握手:先通过 REST 生成一张短期有效的票据,然后用它打开 Socket 连接。
1. 生成票据
使用你的 API 密钥调用 POST /v1/stream/token。票据的有效期约为 120 秒,且仅限一次连接使用。
curl -s -X POST "https://api.dino.markets/v1/stream/token" \
-H "Authorization: Bearer sk_live_..."
{
"ticket": "eyJhbGciOi...",
"ws_url": "wss://stream.dino.markets/connection/websocket",
"expires_in": 120,
"allowed": {
"channels": ["markets:all", "status"],
"max_conns": 3
}
}
allowed 如实反映你的套餐所授予的权限:服务器会让你订阅的频道,以及你的并发连接数上限。Free 密钥或已过期的密钥在这一步会收到附带升级链接的 402,而不是静默返回一张空票据。
2. 连接
在 ws_url 上打开 Socket,并在连接数据的 t 字段中传入票据。你不需要手动订阅频道。服务器会根据票据读取你的套餐,并让你订阅恰好有权访问的频道,因此一旦连接建立,消息推送就会随之开始。
import { Centrifuge } from "centrifuge";
const { ticket, ws_url } = await mintTicket();
const centrifuge = new Centrifuge(ws_url, {
data: { t: ticket },
});
centrifuge.on("publication", (ctx) => {
console.log(ctx.channel, ctx.data);
});
centrifuge.connect();票据被设计成短期有效是有意为之。每次新建连接都请生成一张新票据。连接一旦建立,客户端会负责保持连接,因此只有在从头重新连接时才需要重新生成票据。
频道
服务器会根据你的套餐挑选一组频道。你会收到以下频道中的一部分:
| 频道 | 套餐 | 携带内容 |
|---|---|---|
markets:all | Basic、Pro | 匹配数据流:覆盖所有活跃运动项目、跨平台上架的每一个市场,以状态帧和价格帧的形式推送。这是 v2 的规范频道。当你的范围是全部运动项目时,你会收到该频道。 |
markets:{sport} | Basic、Pro | 单个运动大类(例如 markets:baseball)的匹配数据流。当你的范围限定为某个子集时,你会收到这些频道,而不是 markets:all。 |
status | Basic、Pro | 心跳。参见心跳。 |
quote:{sport} | Pro | 每个运动项目原始的最优报价变化。 |
candidates:{sport} | Pro | 每个运动项目的待审核候选项,即尚未确认前的暂定匹配。参见匹配与置信度。 |
org:{id} | Basic、Pro | 你所属组织的在线状态频道。每个连接都会加入该频道,以便将你的并发连接数计入上限统计。你不会在该频道上发送或读取数据。 |
运动项目使用大类键:baseball、basketball、football、hockey、soccer、tennis、lol、dota2、valorant、cs2、mma。一个运动项目可以跨越多个联赛(baseball 覆盖 MLB、KBO 和 NPB),但频道是按运动项目划分的,而不是按联赛划分。查询 /v2/leagues 可获取当前处于赛季中的组合。
消息
收到的内容取决于频道系列。markets:* 频道携带扁平的 market 帧和 price 帧。status 频道携带心跳。每条消息都是带有 type 字段的 JSON。
markets 频道
markets:* 频道是 v2 的规范数据流,携带市场对象。这些帧是扁平的:由 type 字段区分类型,没有 data 包裹层,没有 channel 键,也没有 schema_version 键。共有两种帧类型。
market 帧会在配对状态发生变化时发送:新配对上线、配对被撤回,或 signal 发生翻转。它是携带 type 判别字段的完整规范市场对象;当 signal 为 "arb" 时,它还会携带按结果划分的深度订单簿。在 market 帧上,priced_at 是该帧被构建出来的那一刻。REST 响应中的同一字段读取的是该配对的 updated_at(目前还没有单独的实时价格列),因此数据流携带的时间戳更新。
{
"type": "market",
"id": "dino_8f3a1c92-47e8-4c3f-b9d2-f1a8e6c4d5f2",
"...": "the rest of the market object"
}
price 帧会在仅价格发生变化时发送。它是一个以稳定的结果 key 为键的精简增量,携带每一个由价格派生出的顶层标量字段,因此缓存的 market 对象不会因此过时:
{
"type": "price",
"id": "dino_8f3a1c92-47e8-4c3f-b9d2-f1a8e6c4d5f2",
"priced_at": "2026-07-01T22:55:02+00:00",
"spread_pts": 2.0,
"potential_arb_pct": 1.83,
"outcomes": [
{ "key": "NYY", "kalshi": 0.52, "polymarket": 0.54 },
{ "key": "BOS", "kalshi": 0.50, "polymarket": 0.46 }
]
}
当该配对的 signal 为 arb 时,price 帧中的每个结果还会额外携带其 kalshi_book 和 polymarket_book 深度对象,并且该帧会携带一个顶层的 max_stake:即在各结果中,被消费的(更便宜的)那一侧卖价上的最小深度,因此流式推送的套利永远不会出现规模不明的情况。当被消费的任意一腿没有深度读数时,max_stake 为 null。
帧会同时发布到 markets:{sport} 和 markets:all,投递语义为至少一次:在一次部分发布之后,下一轮扫描会重新发送,因此偶尔可能收到重复帧。帧是自描述且幂等的,因此重复应用并无害处。
status 消息就是心跳,接下来会介绍。
心跳
由于套利是一种罕见事件,数据流大多数时间都很安静。一个无声的连接是模糊的:它既可能意味着没有套利开放,也可能意味着数据流本身出了问题。status 频道消除了这种模糊性。无论当前是否有套利开放,系统核心都会按固定周期发布心跳。
{
"type": "status",
"schema_version": 1,
"channel": "status",
"ts": "2026-06-25T21:00:00+00:00",
"data": {
"state": "ok",
"active_pairs": 312,
"open_opportunities": 0
}
}
| 字段 | 说明 |
|---|---|
state | 当数据流实时运行、正常触发时为 ok;在冷启动预热期间,或在暂缓套利的恢复窗口期间为 degraded。 |
active_pairs | 系统核心当前正在追踪的已匹配比赛数量。 |
open_opportunities | 当前开放的套利数量。ok 与 0 组合出现的心跳,意味着数据流健康,只是暂时没有可执行的机会。 |
请把心跳的中断视为出问题的信号,而不是安静的套利数据流。
连接数上限
每个套餐都有并发连接数上限:Basic 允许 3 个,Pro 允许 10 个,Free 则没有可用连接数。该上限由服务器端根据你所属组织的在线状态频道计数,因此会跨进程、跨设备保持一致。超出上限的连接会被拒绝。这个上限正是你所付费获得的价值所在:如果你要并行运行多套策略,或者希望在生产环境之外再保留一条预发布连接,升级套餐买的就是这个。
从快照初始化
如果要从完整的状态起步,然后持续保持实时更新,请先读取 GET /v2/markets?signal=arb 加载当前所有开放的套利,再连接数据流。REST 快照不携带流偏移量字段,因此没有精确的续传点可以交给数据流。请把 REST 读取和数据流连接当作两个相互独立的视图来对待。实际操作中:先加载快照,然后立即连接,并预期会有一个很短的窗口期,其中快照里已经出现过的消息也会在数据流中再次到达。请根据市场 id 去重。
断线后恢复
数据流的各个频道会保留一段简短的历史记录,大约是 5 分钟内的 100 条消息。centrifuge 客户端会记录它看到的最后一条消息的 offset,因此重新连接时,它会请求服务器从该位置起重放遗漏的内容。对于短暂的断线,这个过程是自动且不会有消息缺失的。
如果断线时间超过历史记录窗口,恢复机制就无法补全这段缺口。客户端会告知你恢复失败,此时正确的做法是从一份全新的 REST 快照重新初始化,而不是信任一段不完整的数据流。