← Back to Writing
Real-Time Systems Data Pipelines Entity Resolution

From a Hundred Sportsbooks to One Source of Truth: Ingesting, Matching, and Resolving Live Odds

February 28, 2024 · 7 min read

"Show the best odds across all the books, and tell me when my bet wins." That is the entire product in one sentence, and it sounds like fetch-and-display. Underneath it are four genuinely hard problems stacked on top of each other, all running in real time while a game is live and the lines move every few seconds: ingesting odds from many sources, matching them so the same market lines up across books, processing them into something useful, and resolving them into wins and losses when the game ends. Each stage has its own failure modes, and the errors compound, because a mistake early poisons everything downstream.

flowchart LR SRC["Many sportsbooks<br/>(APIs + scraped)"]:::ghost --> ING["Ingest workers"] ING --> NRM["Normalize<br/>event · market · price · timestamp"] NRM --> MAT["Matcher<br/>(canonical id)"] MAT --> PROC["Process<br/>best line · movement · signals"] PROC --> API["Live API"]:::accent MAT --> RES["Resolution<br/>(settle on official result)"]:::accent

Ingestion: many sources, none of them agree

The first job is getting the odds at all. A few books offer real APIs; most you collect the hard way, through the same kind of resilient, proxied, rate-aware collection you would build for any hostile source, because live odds are valuable and the books do not want them scraped. "Live" makes it harder than ordinary scraping, because the data churns constantly and stale is as bad as wrong: an odds value that was right ten seconds ago can cost a user money now. So every source has its own collector, and the very first thing each one does is normalize its output into a single internal shape, an event, a market, a selection, and a price, with a timestamp stamped on arrival, because from this point on freshness is a first-class property of every number in the system.

Matching: the actual hard problem

This is where the real work lives, and it is an entity-resolution problem in disguise. The same real-world market shows up at ten different books that agree on almost nothing. They name teams differently, "NY Giants" against "New York Giants" against an abbreviation. They name markets differently, "Moneyline" against "Match Winner" against "1X2." They quote prices in different formats, American against decimal against fractional. And they post markets at different times, so the same game appears piecemeal across sources. Your job is to map every book's raw market onto one canonical event, market, and selection, so that ten quotes for the same thing line up as ten quotes for the same thing.

flowchart TD RAW["Raw market from a book"]:::ghost --> N["Normalize names + price format"] N --> K["Build candidate key<br/>league · start time · participants"] K --> D{"Deterministic match?"} D -->|yes| ASSIGN["Assign canonical id"]:::accent D -->|no| FZ["Fuzzy score"] FZ -->|high confidence| ASSIGN FZ -->|low confidence| Q["Quarantine for review"]:::gold

You lean on deterministic keys wherever you can, since a normalized combination of league, start time, and participants will confidently match most events, and you fall back to fuzzy or learned matching for the rest, always with a confidence score. The rule that keeps you out of trouble is that low-confidence matches are quarantined, not guessed. Matching two markets that are not actually the same is the worst bug in the system, because it manufactures a "best price" or an "arbitrage" that does not exist, and a user who acts on a phantom opportunity loses real money. In this domain a confident wrong answer is far more dangerous than an honest "not sure yet."

Processing: turn matched odds into signal

Once everything sits under a canonical id, the useful computation is almost easy: the best available price per selection, how a line has moved over time, and the signals people actually want, arbitrage opportunities, middles, and sharp line movement. The discipline is that you compute all of it defensively. Every price carries a freshness window and is dropped the moment it goes stale, so you never headline a number nobody can still bet. You sanity-check each value against sane bounds and recent history, because a single misparsed price, a 6.0 that should have been 1.6, will otherwise present as the opportunity of the century. You dedupe, and you treat a lone outlier from one book as suspicious rather than golden. The output is a clean, current, canonical view that the live API can serve with a straight face.

Resolution: settling when the game ends

The game finishes, and now you have to grade, which is its own problem with its own source of truth. You do not settle off your own odds feed; you settle off an authoritative results source, often a different provider entirely, and you map the official outcome onto each canonical market to mark every selection win, lose, push, or void. The happy path is trivial and the edge cases are the entire job: postponed or abandoned games, dead heats, handicap lines that land exactly on the number and push, partial voids, and the nasty one, results that get corrected hours after the fact when an official score is amended.

So resolution is a state machine, not a one-shot calculation: a market moves from open, to result pending, to graded, and sometimes to corrected before it is finally settled. It is idempotent, because the same result will arrive more than once and a re-grade must never double-settle anything, and it is fully auditable, because money moves on these decisions and disputes are guaranteed. You keep the evidence, the result you graded against and when, so that when someone asks why a bet settled the way it did, you can show them rather than guess.

stateDiagram-v2 [*] --> Open Open --> ResultPending ResultPending --> Graded Graded --> Corrected Corrected --> Graded Graded --> Settled Settled --> [*] style Settled fill:#5bbd86,stroke:#5bbd86,color:#0d0d0b style Corrected fill:#151512,stroke:#e0a72e,color:#ededdf

Confidence and provenance, carried end to end

The mistake that ties all four stages together, when it goes wrong, is throwing away certainty too early. A naive pipeline flattens everything into a single number, the price, the match, the result, and loses the one piece of metadata that lets you reason about trust. The systems that hold up do the opposite: every value carries where it came from and how sure you are of it, from the moment of ingestion to the moment of settlement. An ingested price knows which book and which timestamp produced it. A matched market knows whether it was matched deterministically or fuzzily, and at what confidence. A processed signal knows which underlying quotes it was built from, so a suspicious one can be traced back to its source rather than blindly trusted. And a settled bet knows exactly which official result it was graded against. That thread of provenance is what turns "the system says so" into "here is precisely why," which is the difference between a product you can debug and defend and one you can only apologize for. It costs a little more storage and discipline at every stage, and it is the first thing you are grateful for the moment a number is questioned.

The through-line

Step back and the whole system is one thing: real-time entity resolution over adversarial, inconsistent sources, with money riding on correctness. That framing tells you how to build it. Carry confidence and provenance through every stage rather than flattening everything into a single number. Quarantine instead of guessing whenever you are not sure, because a wrong match or a stale price is worse than a missing one. Treat the freshness clock and the result authority as sacred, because they are the two things that, if you get them wrong, turn a useful product into one that confidently loses people money. Ingest, match, process, resolve: four stages, each easy to do badly and worth the effort to do right.

Enjoyed this? Let me know

0claps