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.
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 with strong dollar profit. The high-edge band was not opportunity. It was a detector for our own model errors.
The fix was a max_edge_c = 20.0 cap. The scanner refuses to emit signals above the cap. The only edges that survive are the ones in the middle of the distribution, which is where genuine market mispricing lives.
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 the CLV pre-trade gate deployed in shadow mode for several weeks before we trusted it enough to enforce blocks.
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 profitable scanner from one that fires constantly and bleeds.
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 11 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.