Track odds movement
Store a price series correctly, given lines are only written when they change.
Two modes, pick deliberately
# Snapshot: latest line per outcome across every open market. NOT paginated.
curl -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/odds?limit=1000"
# History: every captured line for one match, newest first, cursor-paginated.
curl -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/odds?match=019f3d18-c15f-7319-81a7-343e8a80a578"For a live board, poll the snapshot. For analysis of one match, walk its history.
Append-on-change
A row is written only when the price moves. Two consequences:
Age is not staleness
An eleven-hour-old line means the price hasn't moved in eleven hours — that's information, not rot. A recency filter that drops "old" lines will drop valid prices for quiet markets. Judge whether the feed is healthy by whether any market is updating, not by one line's age.
And your storage should be idempotent: re-polling the snapshot returns the same row until something
changes, so key on the line's id and ignore duplicates.
for line in get("/v1/cs2/odds?limit=1000")["data"]:
db.insert_ignore(line) # primary key: line["id"]Group correctly
A match can have more than one market. Group by (market_id, outcome_key, line) before treating
anything as a series — otherwise you interleave a moneyline with a handicap and see movement that
isn't there.
from collections import defaultdict
series = defaultdict(list)
for l in history:
series[(l["market_id"], l["outcome_key"], l["line"])].append(l)
for key, rows in series.items():
rows.sort(key=lambda r: r["captured_at"]) # history is newest-firstMovement without the history
Every line already carries its own movement, computed per response:
open_price— the first price in that series.delta_since_open—price − open_price. Positive drifted out, negative shortened.
So a single snapshot call tells you how far every market has moved since opening, without fetching one historical row.
Closing lines
is_closing marks the last price before scheduled_at. Settled rows also carry is_winner, so
collecting closing lines for completed matches gives you prediction and outcome together — the
standard basis for scoring a forecast.
closing = [l for l in history if l["is_closing"]]
for l in closing:
implied = 1 / l["price"]
print(f"{l['label']:20} closed {l['price']:6.2f} p={implied:.3f} won={l['is_winner']}")Sanity check your parsing
Because the line is de-vigged, implied probabilities across a market's outcomes sum to ~1.0:
p = sum(1 / l["price"] for l in one_market_latest)
assert 0.98 < p < 1.02, f"expected ~1.0, got {p}"If you get ~1.05, you've probably mixed two markets into one group. See the market line.
Cost
The snapshot is one request regardless of how many markets are open. At limit=1000 every 5
minutes that's 8,640 requests a month — 43% of the plan, and the main line item in most
integrations. A WebSocket removes it entirely.