Skip to content

Guides

Closing line value (CLV): what it is and how to track it with live odds data

7 min readPinnRadar team

What closing line value means, how to remove the margin from the closing price, and how to record your own closing lines from prematch odds data.

Closing line value (CLV) compares the price you recorded for an outcome with that outcome's price just before the event started. Analysts and modellers use it as a process metric. Results from a handful of events are mostly noise, but whether your prices tend to beat the final pre-event market is a steadier signal. This post explains the calculation, including removing the bookmaker margin, and shows how to capture closing prices yourself from live odds data with the PinnRadar REST API.

It is a measurement technique, not a strategy. A positive CLV history says nothing certain about future outcomes, and nothing here is betting advice.

The definition

The closing line is the last available price for an outcome before the event starts. For a pre-game market, that is the price just before kickoff.

With decimal odds, the simplest version is:

CLV (raw) = your_price / closing_price - 1

If you recorded 2.10 and the market closed at 1.95, the raw CLV is 2.10 / 1.95 - 1 = +7.69%. Your price was longer than where the market finished.

Removing the margin

Closing prices include the bookmaker's margin, so comparing against them flatters every result a little. A cleaner reference is the no-vig (fair) closing price:

  1. Convert each outcome's closing price to an implied probability: 1 / price.
  2. Add them up. The amount over 1 is the margin (overround).
  3. Divide each implied probability by that sum, so they add up to 1.
  4. The fair price is 1 / normalised_probability.

This proportional method is the simplest of several ways to remove margin. Others weight the favourite and the longshot differently. Whichever you choose, use it consistently.

CLV (no-vig) = your_price / fair_closing_price - 1

A worked example (illustrative numbers)

These numbers are made up to show the arithmetic. They are not real market data.

Suppose you recorded the home side of a soccer match at 2.10. The closing 1X2 prices were:

OutcomeClosing priceImplied probability
Home1.950.51282
Draw3.600.27778
Away4.200.23810
Sum1.02870

The overround is 2.87%. The normalised home probability is 0.51282 / 1.02870 = 0.49852, so the fair closing price is 1 / 0.49852 = 2.006.

  • Raw CLV: 2.10 / 1.95 - 1 = +7.69%
  • No-vig CLV: 2.10 / 2.006 - 1 = +4.69%

A second illustrative case: you recorded Over 2.5 at 1.88. That line closed at Over 1.97 / Under 1.93. The two-way sum is 1.02575, which gives a fair Over price of 2.021. Your raw CLV is -4.57% and your no-vig CLV is -6.96%. The market finished at a longer price than the one you took.

In code:

def fair_prices(prices):
    """Proportional margin removal. prices: decimal odds of every outcome in one market."""
    implied = [1.0 / p for p in prices]
    total = sum(implied)
    return [total / q for q in implied]


def clv(your_price, closing_price, closing_market=None):
    raw = your_price / closing_price - 1
    if not closing_market:
        return raw, None
    fair = fair_prices(closing_market)[closing_market.index(closing_price)]
    return raw, your_price / fair - 1


# illustrative numbers from the example above
print(clv(2.10, 1.95, [1.95, 3.60, 4.20]))   # (0.0769..., 0.0468...)
print(clv(1.88, 1.97, [1.97, 1.93]))         # (-0.0456..., -0.0696...)

You have to capture the close yourself

PinnRadar's REST endpoints return current prices. The docs don't describe a historical endpoint, so nobody stores closing lines for you. You record them as they happen. (For historical data needs, the docs say to contact support.) Fixtures behave in a way that makes this workable:

  • Upcoming fixtures sit on the prematch board: GET /kit/v1/prematch/fixtures?sport_id=<id>.
  • When a fixture starts and its in-play listing exists, it leaves the prematch board. From then on it is served as event_type=live.
  • Each event has starts (ISO 8601, UTC).

So the last prematch snapshot you took before starts is your closing line. How close it is to the true close depends on how often you sampled in the final minutes. Treat it as an approximation and record its timestamp.

An event can also disappear from the prematch board because it was withdrawn, and tennis, golf and fights are often rescheduled. Only freeze a closing record when the event has gone and the current time is at or past its latest known starts.

Approach A: a few events, close to kickoff (fits the Free plan)

The Free plan allows 100 requests a day (and 20 a minute), which is too few to watch a whole sport continuously. It is enough to sample a handful of chosen fixtures in their last minutes. For each fixture, call GET /kit/v1/prematch/markets?event_id=<id> at, say, 30, 10, 3 and 1 minute before starts, and keep the last good reply. Four events at four samples each is 16 requests.

import os
import sqlite3
import time
from datetime import datetime, timezone

import requests

BASE = "https://pinnradar.com"
session = requests.Session()
session.headers["x-api-key"] = os.environ["PINNRADAR_KEY"]

db = sqlite3.connect("closing_lines.db")
db.execute("""CREATE TABLE IF NOT EXISTS snapshots (
    event_id INTEGER, taken_at TEXT, starts TEXT, period TEXT,
    market TEXT, line TEXT, side TEXT, price REAL)""")


def snapshot(event_id):
    resp = session.get(BASE + "/kit/v1/prematch/markets",
                       params={"event_id": event_id}, timeout=30)
    if resp.status_code == 429:
        time.sleep(float(resp.headers.get("Retry-After", 5)))
        return None
    resp.raise_for_status()
    if resp.headers.get("X-Data-Status") != "fresh":
        return None                    # held back or cold start: not a usable close
    events = resp.json().get("events") or []
    if not events:
        return None                    # already off the prematch board
    ev = events[0]
    now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")  # same format as `starts`
    rows = []
    for period_key, period in (ev.get("periods") or {}).items():
        for side, price in (period.get("money_line") or {}).items():
            if price:
                rows.append((ev["event_id"], now, ev["starts"], period_key, "money_line", "", side, price))
        for line, s in (period.get("spreads") or {}).items():
            rows += [(ev["event_id"], now, ev["starts"], period_key, "spreads", line, "home", s["home"]),
                     (ev["event_id"], now, ev["starts"], period_key, "spreads", line, "away", s["away"])]
        for line, t in (period.get("totals") or {}).items():
            rows += [(ev["event_id"], now, ev["starts"], period_key, "totals", line, "over", t["over"]),
                     (ev["event_id"], now, ev["starts"], period_key, "totals", line, "under", t["under"])]
    db.executemany("INSERT INTO snapshots VALUES (?,?,?,?,?,?,?,?)", rows)
    db.commit()
    return len(rows)

Schedule snapshot(event_id) at your chosen offsets before starts. The closing price for a selection is the row with the latest taken_at that is still before starts:

SELECT price, taken_at FROM snapshots
WHERE event_id = ? AND period = 'num_0' AND market = 'totals'
  AND line = '2.5' AND side = 'over' AND taken_at < starts
ORDER BY taken_at DESC LIMIT 1;

The taken_at < starts comparison works because both are written in the same YYYY-MM-DDTHH:MM:SSZ UTC format, so text order matches time order. If you sample with the full spread or total ladder, keep the whole market: the no-vig calculation needs every side of the same line.

Approach B: a whole sport (REST, Edge or Scale plan)

To record a close for every fixture in a sport, poll the board with the incremental cursor:

  1. Make a full call to /kit/v1/prematch/fixtures?sport_id=<id>. Store every event and remember the top-level last.
  2. Poll with since=<last>. Only changed events come back. Upsert them and timestamp the prices.
  3. Every few minutes, make a full call again. Events missing from it have left the board. For each one whose starts has passed, freeze its latest stored prices as the close.

The 10 requests per second on REST and Edge (30 on Scale) leave plenty of room to poll one sport every few seconds. Some sports' boards are much larger than others. The Python guide has a complete delta-polling loop with the merge rules.

On a plan with REST access, the WebSocket add-on is another option. Its prematch frames push price changes as upstream publishes them, and a prematch_membership frame each cycle lists fixtures added and removed. That gives you a clean departure signal without re-reading the full board. See the WebSocket API page and our transport comparison.

Matching your records to the close

Most CLV errors come from comparing the wrong things:

  • Same market, same line, same period. If you recorded the Over at 2.5 and the main total later moved to 2.75, compare against the 2.5 line's close, which is still in the totals ladder if it was offered. Don't compare against the new main line.
  • Asian lines. Quarter handicaps such as -0.25 are separate keys in spreads. Treat each as its own market.
  • Use the same period. num_0 is the full game and num_1 the first half. Their prices aren't interchangeable.
  • Check freshness. If X-Data-Status was stale near kickoff, you have no trustworthy close for that window. Mark it as missing instead of using an older price. X-Data-Age-Ms tells you how old the newest board data was.
  • Withdrawn and rescheduled events. Drop the record or wait for the new starts.

Reading the numbers

Report the average no-vig CLV together with the sample size. Raw CLV is easier to explain, but it mixes in the margin. A small sample says very little, and CLV measures prices, not outcomes. Market prices can move for reasons unrelated to how good your model is.

Questions readers ask

What is closing line value?

It compares the price you recorded for an outcome with that outcome's last price before the event started. With decimal odds, raw CLV is your_price / closing_price - 1.

Why remove the margin from the closing price?

Closing prices include the bookmaker's margin, which makes every comparison look slightly better than it is. Normalising the implied probabilities of all outcomes so they add up to 1 gives a fair (no-vig) closing price to compare against.

Does PinnRadar store closing lines for me?

The REST endpoints return current prices, and no historical endpoint is documented, so you record closing lines yourself. The last prematch snapshot you take before an event's starts time is your closing price. Contact support if you need historical data.

Can I track CLV on the Free plan?

For a few events, yes. Sample each fixture several times in its last minutes with /kit/v1/prematch/markets. Watching a whole sport continuously needs more than 100 requests a day, which means a paid REST plan.

Does positive CLV guarantee results?

No. CLV measures the prices you got, not outcomes, and small samples are noisy. Treat it as one diagnostic among several.

Pull Pinnacle prices into your own code

The PinnRadar odds APIcarries Pinnacle’s live and prematch markets, drop alerts included, over REST, SSE and WebSocket. Compare the plans, or start on a free key: just an email, and no Pinnacle account needed.

Get a free key →