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

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:
- Convert each outcome's closing price to an implied probability:
1 / price. - Add them up. The amount over 1 is the margin (overround).
- Divide each implied probability by that sum, so they add up to 1.
- 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:
| Outcome | Closing price | Implied probability |
|---|---|---|
| Home | 1.95 | 0.51282 |
| Draw | 3.60 | 0.27778 |
| Away | 4.20 | 0.23810 |
| Sum | 1.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:
- Make a full call to
/kit/v1/prematch/fixtures?sport_id=<id>. Store every event and remember the top-levellast. - Poll with
since=<last>. Only changed events come back. Upsert them and timestamp the prices. - Every few minutes, make a full call again. Events missing from it have left the board. For each one whose
startshas 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
totalsladder if it was offered. Don't compare against the new main line. - Asian lines. Quarter handicaps such as
-0.25are separate keys inspreads. Treat each as its own market. - Use the same period.
num_0is the full game andnum_1the first half. Their prices aren't interchangeable. - Check freshness. If
X-Data-Statuswasstalenear kickoff, you have no trustworthy close for that window. Mark it as missing instead of using an older price.X-Data-Age-Mstells 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.
Related
- How to get Pinnacle odds from an API in Python
- Detecting dropping odds with Server-Sent Events: the prematch stream can flag line moves on fixtures you track.
- API docs: prematch endpoints, the Event object and freshness headers.
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.