Core concepts

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

FieldMeaning
open_priceThe earliest captured price in this (market, outcome, source) series.
delta_since_openprice − open_price. Positive means the price drifted out (less likely); negative means it shortened.
is_closingTrue on the last line captured before the match began — the closing price.
is_winnerWhich outcome came in. null until the market settles.
in_playWhether this price was captured after the match began. null means unknown.
market_statusThe 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.

On this page