# Track line movement

See how each bookmaker's price moved over a window, from recorded history.

> **Pro plan**
> Recorded history (`/v1/odds/history`) and opening and closing snapshots are part of the Pro plan. Other plans get a `403` problem. See [pricing](https://sockodds.com/pricing/).

## What it does

The program reads every recorded change to one game's winner market over an eight hour window, then prints, for each bookmaker and side, the first price seen in the window, the latest one and the difference.

History records changes only. A row has `kind` `opened` (the first quote we recorded), `changed` (a new price or line) or `withdrawn` (the quote was taken down, with a null price). Unchanged quotes add no rows, so a short list means a quiet market, not a gap. How far back history goes depends on the league: see [History and snapshots](https://sockodds.com/docs/history/#how-far-back-it-goes).

## The program

A history window can be at most 24 hours long. The program follows `links.next` until it is null, so it reads every page whatever the window holds.

```javascript
const params = new URLSearchParams({
  event_id: "ev_000ckk0nc1tg",
  from: "2026-10-15T00:00:00Z",
  to: "2026-10-15T08:00:00Z",
  markets: "winner",
  limit: "250",
});
let url = `https://api.sockodds.com/v1/odds/history?${params}`;
const headers = { Authorization: `Bearer ${process.env.SOCKODDS_API_KEY}` };

// First and latest price per bookmaker and outcome. Rows arrive oldest first.
const moves = new Map();
while (url) {
  const body = await (await fetch(url, { headers })).json();
  for (const row of body.data) {
    if (row.decimal_odds === null) continue; // a withdrawal has no price
    const key = `${row.bookmaker}|${row.outcome.side}|${row.market.period}`;
    const move = moves.get(key) ?? { bookmaker: row.bookmaker, name: row.outcome.name, first: row.decimal_odds, changes: 0 };
    move.latest = row.decimal_odds;
    move.at = row.observed_at;
    move.changes += row.kind === "changed" ? 1 : 0;
    moves.set(key, move);
  }
  url = body.links.next;
}

const order = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
for (const key of [...moves.keys()].sort(order)) {
  const m = moves.get(key);
  const diff = m.latest - m.first;
  const sign = diff > 0 ? "+" : "";
  console.log(
    `${m.bookmaker.padEnd(10)} ${m.name.padEnd(12)} ${m.first.toFixed(2)} to ${m.latest.toFixed(2)} ` +
      `(${sign}${diff.toFixed(2)}), changes: ${m.changes}, latest ${m.at}`,
  );
}
```

```python
import json, os, urllib.parse, urllib.request

params = urllib.parse.urlencode({
    "event_id": "ev_000ckk0nc1tg",
    "from": "2026-10-15T00:00:00Z",
    "to": "2026-10-15T08:00:00Z",
    "markets": "winner",
    "limit": "250",
})
url = "https://api.sockodds.com/v1/odds/history?" + params
headers = {"Authorization": "Bearer " + os.environ["SOCKODDS_API_KEY"]}

# First and latest price per bookmaker and outcome. Rows arrive oldest first.
moves = {}
while url:
    with urllib.request.urlopen(urllib.request.Request(url, headers=headers)) as response:
        body = json.load(response)
    for row in body["data"]:
        if row["decimal_odds"] is None:
            continue  # a withdrawal has no price
        key = f"{row['bookmaker']}|{row['outcome']['side']}|{row['market']['period']}"
        move = moves.setdefault(key, {"bookmaker": row["bookmaker"], "name": row["outcome"]["name"], "first": row["decimal_odds"], "changes": 0})
        move["latest"] = row["decimal_odds"]
        move["at"] = row["observed_at"]
        move["changes"] += 1 if row["kind"] == "changed" else 0
    url = body["links"]["next"]

for key in sorted(moves):
    m = moves[key]
    diff = m["latest"] - m["first"]
    sign = "+" if diff > 0 else ""
    print(
        f"{m['bookmaker'].ljust(10)} {m['name'].ljust(12)} {m['first']:.2f} to {m['latest']:.2f} "
        f"({sign}{diff:.2f}), changes: {m['changes']}, latest {m['at']}"
    )
```

Output (from a local copy of the API with invented test prices):

```text
ladbrokes  New Zealand  2.16 to 2.08 (-0.08), changes: 1, latest 2026-10-15T07:58:00.000Z
ladbrokes  Australia    1.75 to 1.69 (-0.06), changes: 1, latest 2026-10-15T07:58:00.000Z
playup     New Zealand  2.32 to 2.23 (-0.09), changes: 1, latest 2026-10-15T07:58:00.000Z
playup     Australia    1.88 to 1.81 (-0.07), changes: 1, latest 2026-10-15T07:58:00.000Z
sportsbet  New Zealand  2.20 to 2.12 (-0.08), changes: 1, latest 2026-10-15T07:58:00.000Z
sportsbet  Australia    1.79 to 1.72 (-0.07), changes: 1, latest 2026-10-15T07:58:00.000Z
tab        New Zealand  2.27 to 2.18 (-0.09), changes: 1, latest 2026-10-15T07:58:00.000Z
tab        Australia    1.84 to 1.77 (-0.07), changes: 1, latest 2026-10-15T07:58:00.000Z
```

In the test data every bookmaker shortened both sides between its first quote in the window and its latest one. `changes` counts the rows of kind `changed`.

## A quicker way: opening and closing

If you only need the first and last quote, not every change, ask the event odds route for snapshots. `snapshots=opening,closing` adds an `opening` and a `closing` object to each bookmaker's price on each outcome, each with a price and `observed_at`. Before kickoff, `closing` is the latest quote so far. Snapshots work on one event at a time and are Pro only.

```javascript
const url = "https://api.sockodds.com/v1/events/ev_000ckk0nc1tg/odds?markets=winner&snapshots=opening,closing";
const response = await fetch(url, {
  headers: { Authorization: `Bearer ${process.env.SOCKODDS_API_KEY}` },
});
const { data: event } = await response.json();
for (const market of event.markets) {
  for (const outcome of market.outcomes) {
    for (const price of outcome.prices) {
      const open = price.opening ? price.opening.decimal_odds.toFixed(2) : "none";
      const close = price.closing ? price.closing.decimal_odds.toFixed(2) : "none";
      console.log(`${price.bookmaker.padEnd(10)} ${outcome.name.padEnd(12)} opening ${open}, closing ${close}`);
    }
  }
}
```

```python
import json, os, urllib.request

url = "https://api.sockodds.com/v1/events/ev_000ckk0nc1tg/odds?markets=winner&snapshots=opening,closing"
request = urllib.request.Request(
    url, headers={"Authorization": "Bearer " + os.environ["SOCKODDS_API_KEY"]}
)
with urllib.request.urlopen(request) as response:
    event = json.load(response)["data"]
for market in event["markets"]:
    for outcome in market["outcomes"]:
        for price in outcome["prices"]:
            opening = f"{price['opening']['decimal_odds']:.2f}" if price.get("opening") else "none"
            closing = f"{price['closing']['decimal_odds']:.2f}" if price.get("closing") else "none"
            print(f"{price['bookmaker'].ljust(10)} {outcome['name'].ljust(12)} opening {opening}, closing {closing}")
```

Output (from a local copy of the API with invented test prices):

```text
playup     Australia    opening 1.88, closing 1.81
tab        Australia    opening 1.84, closing 1.77
sportsbet  Australia    opening 1.79, closing 1.72
ladbrokes  Australia    opening 1.75, closing 1.69
playup     New Zealand  opening 2.32, closing 2.23
tab        New Zealand  opening 2.27, closing 2.18
sportsbet  New Zealand  opening 2.20, closing 2.12
ladbrokes  New Zealand  opening 2.16, closing 2.08
```

With snapshots requested, a quote that is stale or withdrawn can still appear so you can see its opening and closing. Its entry in `prices` has `available` set to false and a null `decimal_odds`, and sorts after the live prices, so check `available` before you treat a price as current.

## Make it yours

- Track a handicap or total: change `markets=` and add the row's `market.id` to the key. Each line is its own market with its own id, so a new line starts a new series.
- Track a whole day: move the window. Each request covers up to 24 hours, so walk a longer period one day at a time.
- Store rows as they come: keep `observed_at`, `kind`, the price and the outcome id, and you have your own copy of the market's movement.

See [price history](https://sockodds.com/docs/history/) for the full rules and the [API reference](https://sockodds.com/docs/#listOddsHistory) for every parameter. The output above comes from a local copy of the API with invented test prices.
