Dynamic Odds: Track Real-Time Price Movement in Python (Free API)
Search “dynamic odds” and you’ll get a dozen definitions that all say the same vague thing: “odds that change.” True, but useless if you’re a developer trying to actually capture that movement. The interesting question isn’t that odds move — it’s how fast, in which direction, why, and how you read that signal in code.
This guide answers that. We’ll define dynamic odds properly, explain the two forces that move them (pre-match money and in-play events), and then walk through tracking real price movement in Python against live data — including a real Roland Garros match we captured while it was being played. Every number below came straight off the OddsPapi API, no fabrication.
What Are Dynamic Odds?
Dynamic odds are bookmaker prices that update continuously in response to new information. A static odd is a single number frozen at one moment. A dynamic odd is that same market re-priced over and over — sometimes every few seconds during a live event — as the bookmaker reacts to money, news, and (in-play) the actual run of play.
Two distinct regimes drive the movement:
| Regime | What moves the price | Typical pace |
|---|---|---|
| Pre-match | Sharp money, team news, weather, lineup confirmation, market balancing (the book shading the line to even out liability) | Minutes to hours |
| In-play (live) | Goals, breaks of serve, red cards, momentum — the scoreboard itself | Seconds |
If you only ever pull a single snapshot, you see a frozen number and none of this. To work with dynamic odds you need two things the data must carry: a timestamp on every price, and access to the full history of prior prices. That’s the whole game.
The Problem: Most APIs Hand You a Frozen Snapshot
The generic odds APIs return a flat blob: bookmaker, market, price. No timestamp telling you when that price was set, and no history endpoint to see where it came from. You’re left polling blindly and diffing payloads yourself — and paying extra for historical data if you want to backfill.
OddsPapi treats odds as the time series they actually are. Every live price ships with a changedAt timestamp, and the full snapshot-by-snapshot price history is on the free tier — the same data competitors gate behind enterprise plans.
| The Old Way | OddsPapi |
|---|---|
| Single price, no timestamp | Every price carries changedAt |
| Diff payloads yourself to detect a move | Read movement straight from the timestamp |
| Historical data paywalled | Full price history free (/historical-odds) |
| ~40 bookmakers | 350+ bookmakers, including Pinnacle & exchanges |
| Poll-only | Real-time WebSocket push on paid tiers |
The Two Timestamps That Make Odds “Dynamic”
Everything in this tutorial hangs on two fields:
changedAt— on the live/oddsendpoint. The moment that exact price was last re-set by the bookmaker. Compare it across polls to know if a price is fresh or stale.createdAt— on the historical/historical-oddsendpoint. Each entry in the price history is a snapshot stamped with when it was recorded. Iterate them to reconstruct the entire movement.
Note the response shapes differ: on /odds the current price lives in players["0"] as a single dict; on /historical-odds that same players["0"] is a list of snapshots. Don’t mix them up.
Step 1 — Authenticate
import requests
API_KEY = "YOUR_API_KEY"
BASE_URL = "https://api.oddspapi.io/v4"
# apiKey is a QUERY PARAMETER, never a header
def get(path, **params):
params["apiKey"] = API_KEY
return requests.get(f"{BASE_URL}/{path}", params=params).json()
print(get("sports")[:1]) # smoke test → 200 + sport list
Step 2 — Find a Live Fixture
Pull fixtures for a sport in a date window, then keep the ones with hasOdds: true. A statusName of "Live" means the market is in-play — where odds move fastest.
# Tennis = sportId 12. Fixtures take from/to (max 10 days apart).
fixtures = get("fixtures", sportId=12, **{"from": "2026-05-29", "to": "2026-06-01"})
live = [f for f in fixtures if f.get("hasOdds") and f.get("statusName") == "Live"]
for f in live[:5]:
print(f["fixtureId"], f["participant1Name"], "vs", f["participant2Name"])
# id1200257971563726 Fonseca, Joao vs Djokovic, Novak
Step 3 — Read the Live Price and Its Timestamp
Now pull live odds for the fixture and read the current price plus its changedAt. The Winner market for tennis is 121 (outcome 121 = player 1, 122 = player 2). Always resolve market and outcome IDs from /markets rather than hardcoding.
FIXTURE = "id1200257971563726" # Fonseca vs Djokovic, French Open
# Build name lookups so IDs become human-readable
cat = get("markets", sportId=12)
market_names = {m["marketId"]: m["marketName"] for m in cat}
odds = get("odds", fixtureId=FIXTURE)
pin = odds["bookmakerOdds"]["pinnacle"]["markets"]["121"]["outcomes"]
for oid, o in pin.items():
cur = o["players"]["0"] # LIVE: players["0"] is a dict
print(oid, cur["price"], "changed", cur["changedAt"])
# 121 4.58 changed 2026-05-29T14:45:44... (Fonseca)
# 122 1.239 changed 2026-05-29T14:45:44... (Djokovic)
Poll this every few seconds and watch changedAt: when it advances, the price moved. When it doesn’t, the price is unchanged — no need to act. That single field is the difference between a dumb poller and a movement-aware one.
Step 4 — Reconstruct the Full Movement (Free Historical)
This is where dynamic odds become visible. /historical-odds returns every recorded snapshot — for free. Here players["0"] is a list; iterate it to trace the line.
# Top-level key is "bookmakers" here (NOT "bookmakerOdds"). Max 3 books/call.
hist = get("historical-odds", fixtureId=FIXTURE, bookmakers="pinnacle")
djok = hist["bookmakers"]["pinnacle"]["markets"]["121"]["outcomes"]["122"]["players"]["0"]
print(f"{len(djok)} snapshots")
for snap in djok[::40]: # every 40th snapshot
flag = "" if snap["active"] else " [SUSPENDED]"
print(snap["createdAt"][:19], snap["price"], flag)
For Djokovic’s price across the day before and during the match, the real history looked like this:
| Time (UTC) | Djokovic price | Phase |
|---|---|---|
| May 28, 08:09 | 1.483 | Market open — a real contest priced in |
| May 29, 11:29 | 1.526 | Pre-match drift (money on Fonseca) |
| May 29, 13:08 | 1.502 | Tip-off |
| May 29, 14:09 | 1.312 | In-play — Djokovic takes control |
| May 29, 14:25 | 1.184 | In-play — pulling away |
| May 29, 14:45 | 1.239 | Fonseca claws back; price drifts out again |
Pinnacle logged 302 snapshots on this single market in about 30 hours. The pre-match price barely twitched (1.48–1.53). The moment the match went live, it swung hard — Djokovic shortened from 1.50 to 1.14 as he took control, then drifted back to 1.24 when Fonseca steadied. Fonseca’s side ranged from 2.49 all the way to 7.12. That is what “dynamic” actually means, rendered as data.
Step 5 — Measure a Move
Detecting and quantifying movement is a few lines. Compare consecutive active snapshots and flag anything past a threshold — the foundation of a steam-move detector.
def biggest_moves(snapshots, pct_threshold=5.0):
moves, prev = [], None
for s in snapshots:
if not s["active"]: # skip suspended ticks
continue
if prev is not None:
change = (s["price"] - prev["price"]) / prev["price"] * 100
if abs(change) >= pct_threshold:
moves.append((s["createdAt"][:19], round(change, 1),
prev["price"], s["price"]))
prev = s
return moves
for t, pct, a, b in biggest_moves(djok):
print(f"{t} {pct:+.1f}% {a} -> {b}")
# In-play ticks show repeated -5% to -8% steps as Djokovic pulls away
Step 6 — Handle Suspension
During live play, books briefly suspend a market (a point in progress, a goal under review). In the data that shows up as active: false — the price is stale and untradeable until it reopens. Pinnacle suspended this market 13 times in our capture. Always filter on active before you trust a live price:
cur = pin["122"]["players"]["0"]
if cur["active"]:
use(cur["price"])
else:
pass # market suspended — do not act on this number
Pre-Match vs In-Play: Two Different Animals
The same fixture gives you two very different signals depending on the phase:
- Pre-match movement is information. A line that drifts from 1.48 to 1.53 over hours is the market digesting money and news. Sharp books like Pinnacle lead; soft books follow. The lag between them is a tradeable edge — see our live betting API guide.
- In-play movement is the scoreboard. A swing from 1.50 to 1.14 in twenty minutes isn’t a balancing act — it’s Djokovic breaking serve. The edge here is latency: catching a soft book that hasn’t re-priced after an event the sharp books already absorbed. That’s why the WebSocket feed exists.
Across this match, 15 bookmakers priced the Winner market simultaneously — Pinnacle, Bet365, DraftKings, FanDuel, BetMGM, Caesars, BetRivers, William Hill, plus Kalshi and Polymarket from the prediction-market side. Each carries its own changedAt, so you can see exactly which book reacted first and which lagged.
Why This Matters for What You’re Building
| If you’re building… | Dynamic-odds field you need |
|---|---|
| Steam / line-movement alerts | changedAt deltas across polls |
| Closing-line value (CLV) tracking | /historical-odds open → close |
| Backtesting a model | Full snapshot history (free) |
| Live arbitrage / middling | Per-book changedAt + active flag |
| In-play trading dashboards | WebSocket push (paid tier) |
You can export the whole history to a spreadsheet too — see exporting historical odds to CSV/Excel — or study a single book’s movement in depth in our Bet365 historical odds guide.
Get Started
Dynamic odds aren’t a feature you bolt on — they’re the native shape of bookmaker data once you have timestamps and history. Stop diffing blind payloads. Get your free OddsPapi API key, pull a live fixture, and watch the price move in real time across 350+ books.
FAQ
What are dynamic odds?
Dynamic odds are bookmaker prices that update continuously as new information arrives — money, team news, and (in-play) the events of the match itself. They’re the opposite of a static, one-time snapshot. With OddsPapi, every live price carries a changedAt timestamp and the full price history is available for free.
How do I track odds movement in Python?
Pull live odds from /odds and read the changedAt field on each price — when it advances between polls, the price moved. For the complete movement, call /historical-odds, which returns every recorded snapshot with a createdAt timestamp so you can reconstruct the entire line.
Is historical odds data free on OddsPapi?
Yes. The full snapshot-by-snapshot price history is available on the free tier via /historical-odds (max 3 bookmakers per call). Most competitors charge for this.
How fast do in-play odds change?
During live events, sharp books re-price within seconds of an event like a goal or break of serve. In our captured tennis match, Pinnacle logged 302 snapshots on one market in about 30 hours, with the steepest moves happening in-play.
What does active: false mean in the odds data?
It means the bookmaker has temporarily suspended that market — common during a live point or while an event is under review. The price is stale and untradeable until the market reopens, so always filter on active before acting on a live number.