Skip to content

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:allBasic、Pro匹配数据流:覆盖所有活跃运动项目、跨平台上架的每一个市场,以状态帧和价格帧的形式推送。这是 v2 的规范频道。当你的范围是全部运动项目时,你会收到该频道。
markets:{sport}Basic、Pro单个运动大类(例如 markets:baseball)的匹配数据流。当你的范围限定为某个子集时,你会收到这些频道,而不是 markets:all
statusBasic、Pro心跳。参见心跳
quote:{sport}Pro每个运动项目原始的最优报价变化。
candidates:{sport}Pro每个运动项目的待审核候选项,即尚未确认前的暂定匹配。参见匹配与置信度
org:{id}Basic、Pro你所属组织的在线状态频道。每个连接都会加入该频道,以便将你的并发连接数计入上限统计。你不会在该频道上发送或读取数据。

运动项目使用大类键:baseballbasketballfootballhockeysoccertennisloldota2valorantcs2mma。一个运动项目可以跨越多个联赛(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_bookpolymarket_book 深度对象,并且该帧会携带一个顶层的 max_stake:即在各结果中,被消费的(更便宜的)那一侧卖价上的最小深度,因此流式推送的套利永远不会出现规模不明的情况。当被消费的任意一腿没有深度读数时,max_stakenull

帧会同时发布到 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当前开放的套利数量。ok0 组合出现的心跳,意味着数据流健康,只是暂时没有可执行的机会。

请把心跳的中断视为出问题的信号,而不是安静的套利数据流。

连接数上限

每个套餐都有并发连接数上限:Basic 允许 3 个,Pro 允许 10 个,Free 则没有可用连接数。该上限由服务器端根据你所属组织的在线状态频道计数,因此会跨进程、跨设备保持一致。超出上限的连接会被拒绝。这个上限正是你所付费获得的价值所在:如果你要并行运行多套策略,或者希望在生产环境之外再保留一条预发布连接,升级套餐买的就是这个。

从快照初始化

如果要从完整的状态起步,然后持续保持实时更新,请先读取 GET /v2/markets?signal=arb 加载当前所有开放的套利,再连接数据流。REST 快照不携带流偏移量字段,因此没有精确的续传点可以交给数据流。请把 REST 读取和数据流连接当作两个相互独立的视图来对待。实际操作中:先加载快照,然后立即连接,并预期会有一个很短的窗口期,其中快照里已经出现过的消息也会在数据流中再次到达。请根据市场 id 去重。

断线后恢复

数据流的各个频道会保留一段简短的历史记录,大约是 5 分钟内的 100 条消息。centrifuge 客户端会记录它看到的最后一条消息的 offset,因此重新连接时,它会请求服务器从该位置起重放遗漏的内容。对于短暂的断线,这个过程是自动且不会有消息缺失的。

如果断线时间超过历史记录窗口,恢复机制就无法补全这段缺口。客户端会告知你恢复失败,此时正确的做法是从一份全新的 REST 快照重新初始化,而不是信任一段不完整的数据流。