← Back to blog

How to Build a Real-Time Edge Signals Scanner for Sports Markets

By ZenHodl. Dataset documentation, research and model evaluations are linked in the article. A separate filtered ledger of bot-attributed trades, including losses and its admission rules, is public at /results.

A real-time edge signals scanner is the heart of any in-play sports trading system. It listens to live game state and live market prices, computes the gap between fair probability and market price for every active contract, and surfaces the gaps that are large enough to act on.

This post walks through how we built ours — the data sources, the latency budget, the edge math, the calibration filter, and the half-dozen production lessons that took the scanner from "looks good in a notebook" to "actually trustworthy in live markets." If you are building one yourself, work through it in order.

What Counts as Edge in Real Time

Edge in real time means the same thing it means anywhere else: your fair probability minus the market's ask price minus your costs. The thing that changes is staleness. In real time, the market price you see in your terminal is already a few hundred milliseconds out of date by the time it arrives, and your fair probability is a few seconds out of date because the live game state feed lags the actual game.

Both lags compound. If your fair probability is computed from game state that is five seconds old and the market price you fire against is one second old, your decision is made on data with a six-second age. In a fast sport like NBA, the score can swing 4-6 points in those six seconds. The "edge" you see may have already evaporated.

The scanner's job is to manage this staleness explicitly — flag the stale data, refuse to act on it, and only fire signals when the data freshness clears a defined threshold.

The Data Sources You Need

Three live feeds, in priority order:

Live market price. Either an orderbook WebSocket (Polymarket CLOB, Kalshi) or a polled REST endpoint. WebSocket is preferable because the latency is much lower — milliseconds versus seconds. We use Polymarket's WebSocket for orderbook updates and fall back to REST polling only when the socket disconnects.

Live game state. Score, time remaining, period, possession, and whatever sport-specific signals matter (power play in NHL, server in tennis, round in CS2). ESPN's undocumented JSON endpoints handle most US sports for free. League-specific official APIs (MLB Stats API, NHL API, lolesports) are the next-best alternative when ESPN lags.

Reference fair probability. Either your own model running locally, or a calibrated prediction API like ours. The choice affects latency: a local model can refresh on every game-state tick (sub-second), an API call typically adds 100-500ms.

If you are starting from scratch, pull from ESPN for game state, ZenHodl or your own model for fair probability, and Polymarket's WebSocket for prices — our prediction market API guide covers the Polymarket, Kalshi, and ESPN endpoints in detail.

The Latency Budget

A useful scanner has a budget of around two seconds end to end. Anything longer and the price you act on is meaningfully different from the price the scanner saw. Here is roughly how that budget breaks down for an in-play system:

Step Budget
Game-state poll → cache 200-500 ms
Fair probability compute or API call 50-300 ms
Market price WebSocket update → cache 50-200 ms
Edge math + filter 1-5 ms
Order placement (if firing) 200-500 ms
Total 500-1500 ms

Anything longer than 2 seconds and you are competing against bots that are faster than you. Anything under 500 ms is usually wasted on networks slower than your code.

The scanner's main loop should poll game state on a strict cadence (every 5-10 seconds for most sports), refresh the fair probability whenever game state changes, and react to price changes asynchronously via the WebSocket callback. Do not poll prices — let the WebSocket push them.

A Minimum Scanner in Python

The skeleton:

import asyncio, time
from dataclasses import dataclass

@dataclass
class Quote:
    token_id: str
    ask_price_c: float
    bid_price_c: float
    ts: float

@dataclass
class GameState:
    game_id: str
    score_diff: int
    time_remaining: int  # seconds
    period: int
    ts: float

# Caches updated by separate tasks
quotes: dict[str, Quote] = {}
games: dict[str, GameState] = {}

MIN_EDGE_C = 6.0
MAX_EDGE_C = 20.0
MAX_QUOTE_AGE_S = 5.0
MAX_GAME_AGE_S = 8.0

def fair_prob(state: GameState) -> float:
    """Your model. Replace with API call or local pickle."""
    ...

async def scan():
    while True:
        now = time.time()
        for token_id, q in list(quotes.items()):
            if now - q.ts > MAX_QUOTE_AGE_S:
                continue
            game = lookup_game_for_token(token_id)
            if game is None or now - game.ts > MAX_GAME_AGE_S:
                continue
            fp = fair_prob(game)
            edge_c = (fp * 100) - q.ask_price_c - 2.0  # 2c fee
            if MIN_EDGE_C <= edge_c <= MAX_EDGE_C:
                emit_signal(token_id, fp, q.ask_price_c, edge_c)
        await asyncio.sleep(0.5)

That is the entire core. The hard part is not the loop — it is the freshness checks (MAX_QUOTE_AGE_S and MAX_GAME_AGE_S) and the MIN_EDGE_C/MAX_EDGE_C band. Skip those and the scanner will fire constantly on stale data and model errors.

Why You Need a Maximum Edge Cap

The hardest counterintuitive lesson of running a scanner: the largest detected edges are usually wrong.

A 30-cent edge feels like a gift. In practice, it is almost always one of three failure modes: the game-state feed has lagged behind a major scoring play (the model thinks the team is ahead when they have just gone behind), the orderbook is showing a stale quote that has already been swept, or the model is in a region of the feature space where it is poorly calibrated.

Our tennis bot taught us this expensively. ATP edges between 20 and 25 cents were running 31% win rate over 13 trades — meaningfully worse than coin flip — while edges between 15 and 20 cents were running 59% win rate over a similarly small sample. The high-edge band was not opportunity. It was a detector for our own model errors.

Correction (2026-09-25): the 15-20c band above should not be read as a "discovered profitable edge." Both figures are small-sample (n<30) and predate a later, much larger study: a September 2026 review of 2,584 live fills found no edge threshold in the 0-30c range clears zero expected value for any sport, including tennis, and the bought-side calibration gap is negative across the board (ATP −20pp; internal fill study, September 2026). The lesson that survives is the risk-control one below — cap the extreme end because it is disproportionately model error — not a claim that the surviving band is profitable.

The fix was a max_edge_c = 20.0 cap. The scanner refuses to emit signals above the cap. This is a risk control, not a discovery: it removes the band most contaminated by model error, not a band proven to have positive expected value.

Calibration Confidence as a Filter

Even within the safe edge band, not every signal is equal. Some signals come from regions of the feature space where the model has lots of training data and well-known calibration. Others come from rare states (overtime, blowout, weird score patterns) where the model has thin data and could be miscalibrated.

A scanner that treats all signals equally over-trades the rare states. The fix is to filter on calibration confidence per bucket — for example, only act on signals where the local ECE is below 0.05 and the historical CLV for that bucket is positive.

We pre-compute these buckets nightly and ship them to the scanner as a JSON file (clv_buckets.json in our case). The scanner looks up the bucket the candidate signal falls into, refuses to fire if the bucket has consistently lost CLV, and otherwise lets it through. This is how we built our CLV pre-trade gate, and it carries a warning. The gate failed out-of-sample (walk-forward) validation: buckets that looked strong in-sample reversed on held-out trades, so we switched it off in May 2026. Walk-forward test any bucket filter like this before you let it block trades.

Production Lessons

A few things that took live trading to learn:

Idempotent signal emission. If you re-evaluate the same token every loop iteration, you will emit the same signal hundreds of times before the position fills. Add a TTL cache keyed by token + bucket so the same signal only fires once per minute.

Watch your WebSocket reconnects. Polymarket's WebSocket disconnects every few hours. If you do not handle reconnect cleanly, your scanner will silently stop receiving prices. Heartbeat the connection; restart on stale data.

Log every blocked signal, not just the ones you fired. When the scanner refuses a trade, log the reason. Six weeks of "blocked: edge too high" logs are the input to the next round of filter tuning.

Separate compute from action. The scanner should compute and emit. A separate trader process should consume the signals and place orders. This separation lets you run the scanner in shadow mode (compute but do not act) while you build confidence.

Monitor the scanner itself. What is its decision latency? How often does it fire per minute? How often does it block? When the rate suddenly changes — usually because a feed went down or a sport entered a quiet period — you want to know within minutes, not days.

The Bottom Line

A real-time edge signals scanner is a six-piece machine: a price source, a game-state source, a fair-probability source, a freshness filter, an edge band, and a calibration filter. The data flow is simple. The discipline of refusing to act on stale or out-of-distribution data is what separates a well-engineered scanner from one that fires constantly and bleeds — that discipline is necessary, but (see the correction above) it is not by itself evidence of a profitable edge.

If you build one, build the freshness checks first, the edge band cap second, and the calibration filter third. Skip any of those and you will spend the next month explaining where the money went.


Live edge signals across 8 sports power zenhodl.net's CLV dashboard. The bot course at zenhodl.net/products walks through building this scanner end to end. Free seven-day API trial at zenhodl.net/pricing.

Related reading

Get ZenHodl Weekly

Dataset releases, research notes, and public results, including corrections.

Research and dataset updates from ZenHodl.

Want the data behind this post?

Historical sports prediction-market datasets with measured coverage, documented schemas, and disclosed gaps.