WebSocket 스트림
실시간 푸시입니다. 티켓을 발급받고 연결하면, 서버가 플랜에 맞는 기회를 끊김 없이 스트리밍하며, 하트비트와 재연결 복구까지 제공합니다.
WebSocket 스트림은 API의 실시간 절반을 담당합니다. 폴링이나 지연 없이 기회가 열리거나 변경되는 순간 즉시 전송합니다. Basic과 Pro 플랜에서 사용할 수 있습니다. Free 키는 REST 스냅샷을 읽을 수 있지만 스트림 티켓은 발급받을 수 없습니다.
스트림은 Centrifugo 프로토콜을 사용하므로, 원시 소켓 대신 사용하는 언어의 centrifuge 클라이언트로 연결합니다. 연결은 2단계 핸드셰이크입니다: REST로 단기 티켓을 발급받은 다음, 그 티켓으로 소켓을 엽니다.
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로 소켓을 열고 connect data의 t 아래에 티켓을 전달하세요. 채널을 직접 구독할 필요는 없습니다. 서버가 티켓에서 플랜을 읽어 권한이 있는 채널로 정확히 구독시켜주므로, 연결하는 즉시 게시물(publication)이 도착합니다.
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();티켓이 단기로 발급되는 것은 의도된 설계입니다. 새로운 연결마다 새 티켓을 발급받으세요. 일단 연결이 수립되면 클라이언트가 연결을 유지하므로, 처음부터 다시 연결할 때만 티켓을 다시 발급받으면 됩니다.
채널
서버는 티어에 따라 채널 세트를 결정합니다. 아래 중 일부만 받게 됩니다.
| Channel | Plan | Carries |
|---|---|---|
markets:all | Basic, Pro | 매칭 피드 스트림: 활성화된 모든 스포츠에 걸쳐 공동 상장된 모든 마켓을, 상태 프레임과 가격 프레임으로 전달합니다. 이는 v2 표준 채널입니다. 범위가 전체 스포츠일 때 이 채널을 받습니다. |
markets:{sport} | Basic, Pro | markets:baseball처럼 하나의 넓은 스포츠에 대한 매칭 피드 스트림입니다. 범위가 일부 스포츠로 제한될 때 markets:all 대신 이 채널들을 받습니다. |
status | Basic, Pro | 하트비트입니다. 하트비트를 참고하세요. |
quote:{sport} | Pro | 스포츠별 원시 최우선호가(top-of-book) 변경. |
candidates:{sport} | Pro | 스포츠별 검토 대상 후보, 즉 확정되기 전의 잠정 매치입니다. 매칭 및 신뢰도를 참고하세요. |
org:{id} | Basic, Pro | 조직의 presence 채널입니다. 모든 연결이 이 채널에 참여해 동시 연결 수를 상한과 대조해 셀 수 있게 합니다. 이 채널로 데이터를 게시하거나 읽지는 않습니다. |
스포츠는 넓은 범주의 키를 사용합니다: baseball, basketball, football, hockey, soccer, tennis, lol, dota2, valorant, cs2, mma. 하나의 스포츠가 여러 리그를 아우를 수 있지만(baseball은 MLB, KBO, NPB를 포함), 채널은 리그 단위가 아니라 스포츠 단위입니다. 현재 시즌 중인 스포츠와 리그는 /v2/leagues로 확인하세요.
메시지
무엇이 도착하는지는 채널 계열에 따라 다릅니다. markets:* 채널은 평평한(flat) market과 price 프레임을 전달합니다. status 채널은 하트비트를 전달합니다. 모든 메시지는 type 필드를 가진 JSON입니다.
markets 채널에서
markets:* 채널은 v2 표준 스트림으로, market 객체를 전달합니다. 프레임은 평평한 구조입니다: type 필드가 프레임을 구분하며, data 래퍼도 channel 키도 schema_version 키도 없습니다. 두 가지 프레임 유형이 도착합니다.
market 프레임은 페어 상태 변경 시 전송됩니다: 새 페어가 활성화되거나, 페어가 취소되거나, signal이 바뀔 때입니다. 이는 type 구분자가 추가된, 전체 표준 market 객체입니다. 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도 포함됩니다: 소비되는(더 저렴한) ask 기준으로 결과들 중 가장 작은 뎁스이며, 이를 통해 스트리밍되는 arb가 사이즈를 알 수 없는 상태가 되지 않습니다. 소비되는 레그 중 하나라도 뎁스 정보가 없으면 max_stake는 null입니다.
프레임은 markets:{sport}와 markets:all에 이중으로 게시되며, 전달은 최소 한 번(at-least-once) 방식입니다. 부분 게시 이후에는 다음 스윕에서 다시 전송하므로, 드물게 중복 프레임이 발생할 수 있습니다. 프레임은 자기 설명적이고 멱등적이므로 중복을 적용해도 문제가 없습니다.
status 메시지는 하트비트이며, 다음에 다룹니다.
하트비트
arb는 드문 이벤트이므로 피드는 대부분의 시간 동안 조용합니다. 조용한 소켓은 애매합니다: 열려 있는 arb가 없다는 뜻일 수도 있고, 피드가 다운되었다는 뜻일 수도 있습니다. status 채널이 이 모호함을 없애줍니다. 브레인은 arb가 열려 있는지 여부와 무관하게 타이머에 맞춰 하트비트를 게시합니다.
{
"type": "status",
"schema_version": 1,
"channel": "status",
"ts": "2026-06-25T21:00:00+00:00",
"data": {
"state": "ok",
"active_pairs": 312,
"open_opportunities": 0
}
}
| Field | Meaning |
|---|---|
state | 피드가 살아 있고 정상 작동 중이면 ok, 콜드스타트 예열 중이거나 arb가 보류되는 복구 구간이면 degraded. |
active_pairs | 브레인이 현재 감시 중인 매칭된 경기 수. |
open_opportunities | 지금 열려 있는 arb 수. ok와 0이 함께 오는 하트비트는 피드가 정상이며 단지 실행할 것이 없다는 뜻입니다. |
무언가 잘못되었다는 신호는 조용한 arb 피드가 아니라 하트비트의 공백으로 판단하세요.
연결 제한
각 플랜에는 동시 연결 상한이 있습니다: Basic은 3, Pro는 10, Free는 없습니다. 상한은 조직의 presence 채널을 기준으로 서버 측에서 카운트되므로, 여러 프로세스와 머신에 걸쳐 유지됩니다. 상한을 초과하는 연결은 거부됩니다. 이 상한이 바로 가치 지표(value metric)이므로, 병렬 전략을 운용하거나 프로덕션과 나란히 스테이징 라인을 두고 싶다면, 상위 티어로 올릴 때 지불하는 것이 바로 이 부분입니다.
스냅샷으로 부트스트래핑하기
완전한 그림으로 시작한 다음 실시간을 유지하려면, 먼저 GET /v2/markets?signal=arb를 읽어 현재 열려 있는 모든 arb를 로드한 다음 스트림에 연결하세요. REST 스냅샷은 stream-offset 필드를 가지고 있지 않으므로 스트림에 넘길 정확한 재개 지점이 없습니다. REST 읽기와 스트림 연결을 두 개의 독립적인 뷰로 취급하세요. 실무적으로는: 스냅샷을 로드하고, 곧바로 연결한 다음, 이미 스냅샷에 있던 메시지가 스트림에서도 잠시 함께 도착하는 작은 구간을 예상하면 됩니다. 마켓 id로 중복을 제거하세요.
연결 끊김 이후 복구
스트림 채널은 약 5분간 100개 메시지 정도의 짧은 히스토리를 보관합니다. centrifuge 클라이언트는 마지막으로 본 메시지의 offset을 기록해두었다가, 재연결할 때 서버에 그 지점 이후 놓친 것을 다시 보내달라고 요청합니다. 짧은 끊김이라면 이는 자동이며 끊김 없이 처리됩니다.
히스토리 윈도우보다 오래 연결이 끊겨 있었다면 복구로는 그 공백을 채울 수 없습니다. 클라이언트는 복구가 실패했음을 알려주며, 이때는 부분적인 스트림을 신뢰하기보다 새로운 REST 스냅샷으로 다시 부트스트래핑하는 것이 올바른 방법입니다.