Odds movement
open_price, delta_since_open and is_closing — reading a price series correctly.
Odds lines are a time series. A handful of fields on every row let you read movement, and separate pre-match from live, without fetching the whole history.
The movement fields
| Field | Meaning |
|---|---|
open_price | The earliest captured price in this (market, outcome, source) series. |
delta_since_open | price − open_price. Positive means the price drifted out (less likely); negative means it shortened. |
is_closing | True on the last line captured before the match began — the closing price. |
is_winner | Which outcome came in. null until the market settles. |
in_play | Whether this price was captured after the match began. null means unknown. |
market_status | The parent market's state: open, suspended or settled. |
open_price and delta_since_open are computed per response, not stored — so they're correct
on every row without you fetching the series first.
Pre-match and live are different series
in_play is the first split to make. A price captured before the match began and one captured
after it began are not comparable observations of the same thing: the later number carries the
current round state, and mixing the two produces movement that looks dramatic and means nothing.
Filter or group on it before you do anything else.
pre = [l for l in series if l["in_play"] is False]
live = [l for l in series if l["in_play"] is True]null is not pre-match
in_play: null means we hold no start time for that match, so nothing can be said — a fixture we
never observed go live, or an outright market with no match at all. Treating null as false puts
unknown rows in your pre-match series. Test for is False, not for falsiness.
market_status answers the other half: a served line stays readable after its market closes,
because the history is the product. open is still trading; suspended has paused (typically
mid-round, or around a technical pause); settled means the outcome is decided. A snapshot request
returns open markets only, so market_status is mostly interesting when you walk a match's history.
Append-on-change
A new row is written only when the price changes. This is the single most important thing to know about the series, and it has a consequence people get wrong:
Age is not staleness
A line captured eleven hours ago is not stale — it means the price hasn't moved in eleven hours, which is itself information. A recency check that discards "old" lines will discard perfectly valid prices for quiet markets.
Judge whether data is flowing by whether any market is updating, not by the age of one line.
Snapshot vs history
The same endpoint serves two different things:
# 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"
# 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"In snapshot mode limit truncates silently and meta.next_cursor is always null — if meta.count
equals your limit, assume there were more.
The closing line
is_closing marks the last price before the match started. It's the standard benchmark for
evaluating a forecast: comparing a prediction against the closing line asks "did this beat where the
market ended up", which is a much harder and more meaningful test than comparing against an opening
price.
The cutoff is the match's actual started_at when we hold one, and its scheduled_at only when we
don't. That distinction is load-bearing for delayed matches, which keep taking real pre-match prices
long after their scheduled time — an earlier version of this page said the cutoff was always
scheduled_at, which would have flagged the wrong row on every one of them.
To collect closing lines for settled matches, walk history mode per match and take the flagged rows;
they also carry is_winner, so you have prediction and outcome in one place.
Reading a series
lines = get(f"/v1/cs2/odds?match={match_id}&source=eo_market&limit=1000")["data"]
# History comes back newest-first; reverse for chronological order.
series = sorted(lines, key=lambda l: l["captured_at"])
for l in series:
implied = 1 / l["price"]
phase = "LIVE" if l["in_play"] else ("PRE" if l["in_play"] is False else "?")
print(f"{l['captured_at']} {l['label']:20} {l['price']:6.2f} p={implied:.3f}"
f" {phase}{' CLOSE' if l['is_closing'] else ''}")Group by (market_id, outcome_key, line) if a match has more than one market — otherwise you'll
interleave a moneyline with a handicap and see movement that isn't there. market_type and
map_number are on every row for exactly this: they tell you which market an opaque market_id
is, so you can label a series without a second request.
Freshness: use input_oldest_at, not captured_at
On an eo_market row, captured_at is the instant the aggregate was computed — effectively always
"now". The contributing quotes behind it can be materially older. input_oldest_at is the oldest of
them, and it is the honest age of the line. Use captured_at to order a series and
input_oldest_at to decide whether a price is worth acting on. It is null on eo_model rows,
which are computed from features rather than quotes.