I've been playing around with prediction markets for a while now. They're useful for gauging general sentiment, and possibly as a supplemental data source for traditional financial analysis. Watching these markets resolve and reading a couple articles about the algorithmic trading happening on Kalshi's platform got me interested in building a real-time dashboard. I was curious how correlated these crypto prediction markets are across currencies. Just from casual observation, it seemed that BTC and ETH track each other closely; BTC and DOGE, not consistently. I wanted to see if Kalshi's API could help me understand this in a visual way, so I built a live dashboard, Kalshi Binary Crypto Explorer.
Kalshi has 15-minute binary markets for seven major crypto assets: BTC, ETH, SOL, XRP, DOGE, BNB, and HYPE. Each market asks a simple yes/no question: will this asset be above or below a given price at close? You can participate in these markets 24/7, every 15 minutes (so wild). Because the structure is identical across all markets, the data is perfectly consistent.
Learning Kalshi's API
The Kalshi docs are very transparent and detailed, though a bit sprawling. Rate limits are generous enough that you don't have to think carefully about them for a project like this.
There are two relevant APIs:
- REST — for fetching market metadata (tickers, strike prices, open/close times, historical data)
- WebSocket — for live-streaming tick data and market lifecycle events
With the REST API, you fetch all markets in a series given a ticker like KXBTC15M, filter to those that haven't closed yet, sort by close time, and take the nearest one.
// kalshi/rest.js
export async function fetchActiveMarketForSeries(seriesTicker) {
const allMarkets = await fetchMarkets(seriesTicker)
const now = Date.now()
const markets = allMarkets.filter(m => new Date(m.close_time).getTime() > now)
markets.sort((a, b) => new Date(a.close_time) - new Date(b.close_time))
return markets[0]
}
For the WebSocket API, you authenticate on the initial request, subscribe to the ticker and market_lifecycle channels for
your list of tickers, and then receive a stream of price updates and settlement events.
// kalshi/ws.js
function connect() {
const headers = getAuthHeaders('GET', WS_PATH)
ws = new WebSocket(WS_URL, { headers })
ws.on('open', () => {
ws.send(JSON.stringify({
id: nextId(),
cmd: 'subscribe',
params: {
channels: ['ticker', 'market_lifecycle'],
market_tickers: tickers,
send_initial_snapshot: true,
},
}))
})
ws.on('message', raw => {
const msg = JSON.parse(raw.toString())
if (msg.type === 'ticker') onTick(msg.msg.market_ticker, series, msg.msg)
if (msg.type === 'market_lifecycle_v2' && msg.msg.event_type === 'determined') {
onSettled(msg.msg.market_ticker, series, msg.msg)
}
})
ws.on('close', () => {
retryDelay = Math.min(retryDelay * 2, 60_000)
setTimeout(connect, retryDelay)
})
}
After the initial subscribe, you can dynamically add or remove tickers via update_subscription commands (more on this later).
This is essential for rolling over to the next contract when a market settles, without dropping and re-establishing the connection.
Reacting to New Data
The frontend receives a WebSocket stream from the Node server, which proxies Kalshi data to browser clients.
All the market state lives in a single React context backed by useReducer, not the component-level useState.
Sharing state across components is reason enough for context, but the real argument for useReducer is coordinated updates.
When a market settles, you need to simultaneously freeze the chart, mark the result, and show a rollover prompt.
That's one action producing one consistent new state, which is much cleaner than a cascade of setState calls.
// src/context/KalshiContext.jsx
case 'MSG_SETTLED': {
const newData = { ...state.data }
if (msg.yes_prob != null) {
newData[msg.series] = [...arr, { t: Date.now(), prob: msg.yes_prob }]
}
return {
...state,
data: newData,
settledResult: { ...state.settledResult, [msg.series]: settledValue },
pendingRollover: { ...state.pendingRollover, [msg.series]: { settled: true } },
}
}
System Design
The Node.js server holds one WebSocket connection to Kalshi for all seven markets and fans that out to browser clients. Browser clients never talk to Kalshi directly, for a few reasons.
The API key stays on the server where it can't be read from the browser. One connection to Kalshi is also simpler than having every browser tab open its own. I capped concurrent browser clients at 50 to keep resource usage predictable.
┌─────────────────────────────────────────────┐
│ Node Server │
│ │
│ Kalshi REST API Kalshi WebSocket │
│ (tickers, metadata) (ticks, lifecycles) │
│ │ │ │
│ └──────────┬─────────────┘ │
│ ▼ │
│ seriesMeta │
│ │ │
│ ▼ │
│ Browser WebSocket │
│ max 50 clients, 1 per IP │
└───────────────────┬─────────────────────────┘
│
▼
┌───────────────────────┐
│ React Frontend │
│ │
│ useKalshiWS hook │
│ │ │
│ ▼ │
│ KalshiContext │
│ (useReducer) │
│ │ │
│ ▼ │
│ UI, D3 chart, │
│ dropdowns, results... │
└───────────────────────┘The tradeoff is that the server is a single point of failure. If the dyno goes down, every connected browser client loses the stream at the same time. For a personal project that's fine for now.
Market Closures and Rollovers
Every 15 minutes, a market settles and a new one opens. Handling this cleanly turned out to be the most challenging part of the project, both technically and in terms of UX.
When Kalshi fires a market_lifecycle_v2 settlement event, the server retries fetching the next contract until three
conditions are true: the old ticker is gone, the new ticker is present, and its floor_strike field is populated.
Any of these can lag independently. Kalshi creates a new market about 5 seconds before the existing one closes.
I retry fetching the next market in a window around the closing event, just in case there is a blip in service.
Once the next contract is ready, it gets added to the WebSocket subscription via update_subscription (a Kalshi-specific command) and the old ticker is removed.
A rollover pending message is broadcast to browser clients.
Right now, rollovers are manually triggered. After a market settles, a "Go to Next Live Market" button appears. The user clicks it to advance the data forward to the next contract. This is a little awkward, but it works.
The reason I haven't automated the rollover is UX uncertainty. Should the chart clear immediately, or should the settled data stay visible and blend into the new data? Should the strike price and title text snap to the new contract text abruptly? Would an automatic transition feel natural, or disorienting if it happens mid-glance? I'm still working that out.
Heroku Deployment
The app runs on Heroku Eco dynos. There's no database. All state is in memory on the server. If the dyno restarts, the state rebuilds from Kalshi's REST API on next startup. Eco dynos sleep after 30 minutes of inactivity, so the first visitor wakes it up with a cold start.
It was a fun exercise in WebSockets more than anything else. Watching the markets tick in real time is satisfying, even if I'm not sure the correlations mean much.