WebSocket Feed
The WebSocket feed streams real-time candle updates, live-match events, OBV deltas, and match lifecycle notifications. It complements the REST candle API — REST gives you history, WS gives you the tape.
| Endpoint (devnet, raw) | ws://178.104.120.151:3456/ws |
| Source | engine/ws-server.ts |
| Protocol | One JSON object per ws.send() (UTF-8). Each message stands alone — no framing beyond the WS frame itself. |
| Heartbeat | Server sweep every 30 s; client connections idle > 90 s are terminate()d |
HTTPS / WSS note
The devnet server exposes ws:// only. The frontend connects via the same proxy approach as the REST API (the hook useRealtimeCandles automatically falls back to HTTP polling when wss:// is unavailable). For Node.js or server-to-server clients, use ws:// directly.
Connection
import WebSocket from "ws";
const ws = new WebSocket("ws://178.104.120.151:3456/ws");
ws.on("open", () => {
console.log("Connected");
ws.send(JSON.stringify({ type: "subscribe", markets: ["team-arsenal", "player-saka"] }));
});
ws.on("message", (data) => {
const msg = JSON.parse(data.toString());
console.log(msg);
});Standard ws library usage. No authentication required — all data is public.
Client → Server messages
All messages are JSON objects with a type field. Unknown types receive an error reply but the connection stays open.
subscribe
Adds market keys to this connection’s subscription set. Subsequent matching candle_update messages (and broadcasts with non-empty subscriptions) will be delivered.
{ "type": "subscribe", "markets": ["team-arsenal", "player-saka"] }Server responds with a subscribed ack listing the full current subscription set (not just the added keys):
{ "type": "subscribed", "markets": ["team-arsenal", "player-saka"] }unsubscribe
Removes market keys from this connection’s subscription set. Server responds with the same subscribed type (there is no separate unsubscribed type), listing what remains:
{ "type": "unsubscribe", "markets": ["team-arsenal"] }{ "type": "subscribed", "markets": ["player-saka"] }subscribe_all / unsubscribe_all
Firehose mode. subscribe_all adds the sentinel "*" to the subscription set, which matches every market for candle_update delivery. unsubscribe_all clears the set entirely.
{ "type": "subscribe_all" }
{ "type": "unsubscribe_all" }The server acks both with subscribed:
{ "type": "subscribed", "markets": ["*"] }
{ "type": "subscribed", "markets": [] }Firehose mode is useful for analytics or archival clients; per-market subscribe scales better for trading UIs.
ping
Keepalive. The server responds with { "type": "pong" }. Any client message — including ping — also updates the server’s lastPing timestamp for that connection, so a regular trickle of subscribe/unsubscribe/ping is enough to avoid the 90-second idle timeout.
{ "type": "ping" }Server → Client messages
candle_update
A 1-minute candle tick for a subscribed market. Broadcast at crank cadence (every 5 minutes outside live windows; more frequently during live matches as event-driven updates arrive). Delivered only to connections whose subscription set contains the market key (or "*").
{
"type": "candle_update",
"market": "team-arsenal",
"price": 602.4,
"time": 1745399700
}price is the post-scaling index value (100–900 range) as a float for display convenience. For settlement math, always read the on-chain mark_price_ema via the SDK — it is in 10⁶ fixed-point.
live_event
A real-time in-match event annotated with its index impact.
{
"type": "live_event",
"matchId": 1371134,
"teamId": 13,
"playerId": 45,
"eventType": "goal",
"indexDelta": 8.0,
"minute": 34,
"liveTeamId": 21,
"livePlayerId": 106232
}liveTeamId and livePlayerId are included only when the Live↔REST ID bridge is attached — teamId/playerId are then the bridge-translated REST IDs (the canonical IDs used in roster.json and the on-chain markets); the live* fields preserve the original live-side IDs for debugging. Without the bridge attached, teamId/playerId are passed through unchanged.
Delivery scope: broadcast to every connection whose subscription set is non-empty — including subscribe_all clients. There is no per-market filter on live events today; clients that only care about a subset must filter locally on teamId / playerId.
eventType reflects whatever the upstream LiveProcessor emits as an EventImpact. Common values include goal, own_goal, red_card, yellow_card, substitution, penalty_scored, penalty_missed, plus the OBV-category strings (pass, shot, defensive_action, dribble_carry, gk_action).
indexDelta is the post-z-score point change in the index contributed by this single event.
live_obv
Aggregated per-match OBV snapshot for a team or player.
{
"type": "live_obv",
"matchId": 1371134,
"entityId": 13,
"entityName": "Crystal Palace",
"entityType": "team",
"liveObv": 0.34
}entityId is a REST ID (team market ID for teams, StatsBomb player ID for players). liveObv is the raw aggregated OBV value, not z-scored, typically in the [−2.0, +2.0] band over a full match. Delivered when the underlying obv-engine sidecar returns a new aggregation. Broadcast scope is the same as live_event (any non-empty subscription set).
obv_teams_breakdown / obv_players_breakdown
Per-(entity × category) breakdown maintained by engine/obv-store.ts. Categories are the five OBV action types: pass, dribble_carry, defensive, shot, gk. Each category exposes a net value (carried as a plain field on the row for backward compatibility), with *_gf and *_ga siblings for the goals-for / goals-against cross-tab.
{
"type": "obv_teams_breakdown",
"matchId": 1371134,
"perCategory": [
{
"teamId": 13,
"pass": 0.42,
"dribble_carry": 0.11,
"defensive": 0.08,
"shot": 0.31,
"gk": -0.03,
"total": 0.89,
"pass_gf": 0.54, "pass_ga": 0.12,
"dribble_carry_gf":0.14, "dribble_carry_ga": 0.03,
"defensive_gf": 0.10, "defensive_ga": 0.02,
"shot_gf": 0.31, "shot_ga": 0.00,
"gk_gf": 0.00, "gk_ga": 0.03,
"total_gf": 1.09, "total_ga": 0.20
}
]
}obv_players_breakdown has the same shape but each row carries playerId and teamId (the player’s team, may be null if unknown) instead of teamId alone.
Note: total values are the sum of the five category values modulo uncategorized events (fouls, subs, stoppages contribute ~0 OBV anyway — observed ~8.8% on fixture match 1371134).
This channel is the source of the per-category bars in the trading UI’s live match feed. The split into gf (offensive contribution) vs ga (defensive cost) lets traders see why a team’s net OBV is moving, not just that it is.
obv_source
A per-match live-OBV source-tier transition. Emitted when the fallback chain changes which scoring source it is consuming for a match (e.g., authoritative → aggregated when the obv-engine sidecar stalls).
{
"type": "obv_source",
"matchId": 1371134,
"team": { "source": "authoritative", "reason": "obv-engine totals received", "since": 1745399700000 },
"player": { "source": "aggregated", "reason": "player totals timed out (30s)", "since": 1745399730000 }
}since is Unix milliseconds (capture time of the last transition). team and player can diverge — one dimension can drop a tier while the other stays stable.
match_start / match_end
Match lifecycle notifications. Broadcast to every connection with a non-empty subscription set.
{ "type": "match_start", "matchId": 1371134, "home": "Crystal Palace", "away": "West Ham" }
{ "type": "match_end", "matchId": 1371134 }After match_end, expect a 30-minute wait before the official post-match OBV arrives from the data partner’s REST feed and the 4-hour EMA blend begins (see Real-Time vs Post-Match).
subscribed
Acknowledgment of subscribe, unsubscribe, subscribe_all, or unsubscribe_all. Always lists the full current subscription set for the connection.
{ "type": "subscribed", "markets": ["team-arsenal", "player-saka"] }pong
Reply to a client ping. Clients can track the round-trip for connection health monitoring.
error
Server reports an error (invalid JSON, unknown message type, etc.). Does not close the connection.
{ "type": "error", "message": "Invalid JSON" }Shadow channels
Whenever the crank runs V2 in shadow mode alongside the authoritative V1 pipeline (SHADOW_REALTIME_OBV=true), it broadcasts the would-be V2 output on shadow_* channels so dashboards can observe the V2 trajectory without existing consumers receiving duplicate ticks:
shadow_candle_update— same payload shape ascandle_updateshadow_obv_source— same shape asobv_sourceshadow_obv_teams_breakdown/shadow_obv_players_breakdown— same shape as the non-shadow variants
A consumer migrates by renaming the channel it listens to; nothing else changes.
Heartbeat and reconnection
- Server sweep interval: every 30 s, the server checks each connection’s
lastPing(updated on any received message). - Idle timeout: > 90 s of silence →
ws.terminate(). - Reconnect: subscriptions do not persist server-side across disconnects. After a reconnect, re-send your subscribe messages. Exponential backoff (2 s → 4 s → 8 s, cap at 30 s) is a reasonable client default.
Message ordering guarantees
Within a single TCP connection:
candle_updatemessages for the same market are delivered in order bytime.live_eventmessages within a single match arrive in the order the upstream processor produced them.- Cross-market / cross-match messages may be interleaved arbitrarily.
No total ordering across all messages is guaranteed. If you need a deterministic replay, use the REST API with timestamp ranges rather than reconstructing from WS.
Rate and throughput
Peak throughput during a typical EPL matchday (5 concurrent matches, ~500 events/match):
- Live events: ~2500 messages/minute during active matches
- Candle updates: ~68 messages per crank cycle (every 5 minutes) outside live windows
- Idle connections: < 1 message/minute on non-matchdays
A single client subscribed to all markets during peak matchday load will see ~50 KB/s of traffic. The server can comfortably handle thousands of concurrent connections on a CPX22.
Example: minimal React hook
import { useEffect, useState } from "react";
export function useMarketTick(marketKey: string) {
const [tick, setTick] = useState<{ price: number; time: number } | null>(null);
useEffect(() => {
const ws = new WebSocket("ws://178.104.120.151:3456/ws");
ws.onopen = () => ws.send(JSON.stringify({ type: "subscribe", markets: [marketKey] }));
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === "candle_update" && msg.market === marketKey) {
setTick({ price: msg.price, time: msg.time });
}
};
const pingInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" }));
}, 30_000);
return () => { clearInterval(pingInterval); ws.close(); };
}, [marketKey]);
return tick;
}See the production version in app/src/hooks/useRealtimeCandles.ts, which adds reconnection, HTTP polling fallback, and candle-buffer management.
Further reading
- Candle REST API — historical data, the WS feed’s companion.
- SDK — on-chain state, the settlement-accurate source.
- Real-Time vs Post-Match — how
live_obvandlive_eventmessages are produced upstream.