Power Ratings From Closing Odds: Build a Market-Derived Team Model in Python
You want a model. You have no training data. The OddsPapi feed carries schedules and prices but no scores, and assembling five seasons of results before you learn anything is a month you probably do not have.
There is a shortcut, and two of the most successful operations in football betting run on it. Matthew Benham founded Smartodds and owns Brentford. Tony Bloom founded Starlizard and has chaired Brighton since 2009. The public story is always xG and scouting networks. The part a developer can copy this afternoon is duller: a closing price is the best free estimate of a football match anybody publishes, and a season of them turns into team ratings without a single score line.
This post fits a full MLS power rating to nothing but Pinnacle’s closing 1X2 prices on 222 matches, pulled free from /historical-odds, then tests it on 56 matches it never saw. It beat a league-average baseline by 40% on mean absolute error and 67% on KL divergence.
Why prices make better training data than results
A finished match hands you one label: home win, draw, or away win. Every Manchester City 1-0 counts the same as a 5-0, and a team that hit the post four times gets filed as a loser. You need hundreds of those labels before the noise averages out.
A closing price hands you a probability triple. Pinnacle spent the week before kick-off absorbing money from people who knew about the injury, the flight, the rotation, and the weather, and the number it settles on is a summary of all of it. Fitting to that gives you a much richer target per match, and it works on a sample size that would be useless for results.
There is a second reason on this feed specifically. OddsPapi carries no scores or player stats, only schedules, status, and odds, so results-based fitting is not available here anyway. Our free sports data API guide covers what the fixture object does and does not include, and how to join to a stats provider if you need the results as well.
| Ratings from results | Ratings from closing prices | |
|---|---|---|
| Input | Final scores | De-vigged closing 1X2 |
| Where you get it | A separate stats provider | /historical-odds, free tier |
| Signal per match | One outcome label | A full probability triple |
| Knows about a late injury | After the match | Before kick-off |
| Sample needed | Multiple seasons | Half a season |
| Ceiling | Can beat the market | Converges toward the market |
| Best used for | Hunting edge | Benchmarking, pricing fixtures nobody has posted |
That last row is the honest limit and it is worth stating before you write any code. A rating fit to Pinnacle’s closers is a compression of Pinnacle. It will not find value against Pinnacle. What it gives you is a number for every fixture in the league, including the ones no book has priced yet, plus a stable yardstick to grade your own model against.
Step 1: Pull a season of fixtures
/fixtures takes a date range capped at 10 days, so a season means walking the calendar in windows. Filter on tournamentName, because sport 10 returns every soccer competition on the planet in that range.
import math
import time
from datetime import datetime, date, timedelta
import requests
API_KEY = "YOUR_API_KEY"
BASE_URL = "https://api.oddspapi.io/v4"
def api(path, **params):
for _ in range(6):
r = requests.get(f"{BASE_URL}/{path}", params={"apiKey": API_KEY, **params})
body = r.json()
if r.status_code == 200 and not (isinstance(body, dict) and body.get("error")):
return body
time.sleep((body.get("error") or {}).get("retryMs", 3000) / 1000 + 0.4)
raise RuntimeError(f"{path} kept rate-limiting")
def season_fixtures(sport_id, tournament, start, end):
"""Every fixture in one competition. /fixtures caps the window at 10 days."""
found = {}
day = start
while day < end:
stop = min(day + timedelta(days=9), end)
for f in api("fixtures", sportId=sport_id,
**{"from": day.isoformat(), "to": stop.isoformat()}):
if f["tournamentName"] == tournament:
found[f["fixtureId"]] = f
day = stop + timedelta(days=1)
time.sleep(1.0)
return sorted(found.values(), key=lambda f: f["startTime"])
season = season_fixtures(10, "MLS", date(2026, 3, 1), date(2026, 7, 27))
print(len(season), "fixtures")
That returned 225 MLS fixtures across 30 clubs from March 1 to July 26, with a gap through June while the World Cup ran. Finished fixtures come back with hasOdds: false because the live feed drops prices once a match ends. The history is still there, which is the whole point of the next step.
Step 2: Take the closing price out of the history
/historical-odds returns every snapshot the feed recorded for a fixture, all the way back to when the book first posted a number. The closing price is the last snapshot stamped before kick-off. Snapshots taken after kick-off are in-play and will wreck a rating if you let them in: on one of our fixtures the home side drifted from 2.14 to 7.07 during the match.
Note the shape difference from the live endpoint. /odds gives you bookmakerOdds and a single price dict per outcome. /historical-odds gives you bookmakers and a list of snapshots. Mixing them up is the most common way this code fails.
def moment(stamp):
return datetime.fromisoformat(stamp.replace("Z", "+00:00"))
def closing_1x2(fixture, book="pinnacle"):
"""The last price the book posted before kick-off, for each of 1 / X / 2."""
hist = api("historical-odds", fixtureId=fixture["fixtureId"], bookmakers=book)
markets = (hist.get("bookmakers", {}).get(book) or {}).get("markets", {})
outcomes = markets.get("101", {}).get("outcomes", {})
kickoff = moment(fixture["startTime"])
prices = []
for outcome_id in ("101", "102", "103"): # 1, X, 2
snaps = [s for s in outcomes.get(outcome_id, {}).get("players", {}).get("0", [])
if s.get("price")
and s.get("active") is not False
and moment(s["createdAt"]) < kickoff]
if not snaps:
return None
prices.append(max(snaps, key=lambda s: s["createdAt"])["price"])
return prices
closers = []
for fixture in season:
prices = closing_1x2(fixture)
if prices:
closers.append((fixture["participant1Name"], fixture["participant2Name"], prices))
time.sleep(4.6)
Three things to know before you run that loop.
Market 101 is Full Time Result, with outcomes 101, 102 and 103 for 1, X and 2. Resolve it from /markets?sportId=10 rather than trusting this paragraph in a year’s time, because soccer carries 32,815 market IDs and the catalogue moves.
Filter on active is not False, not on truthy active. The feed ships active: null alongside perfectly good prices, and a truthy check throws them away without telling you.
Pace it. /historical-odds is the slowest endpoint on the free tier: each response here ran to roughly 3.3 MB, and the cooldown between successful calls measured around 4.5 seconds. Sleeping 4.6 seconds got 222 of 225 fixtures cleanly in about 22 minutes. The three failures were fixtures Pinnacle never priced. Anything faster and you spend the time on 429s instead.
Those 222 fixtures carried a median of 298 recorded snapshots per fixture on the home price alone, close to 64,000 snapshots in total, all free. Competitors charge for this. If you want the whole archive on disk rather than in memory, our SQLite odds database guide shows the change-only storage pattern.
Step 3: Strip the margin
Raw prices sum to more than 100% because the book takes a cut. Pinnacle’s MLS closers averaged 3.57% vig (median 3.47%), which is thin for soccer and one of the reasons to fit to Pinnacle rather than a retail book. Proportional de-vig divides that out and leaves a clean probability triple.
def devig(prices):
"""Proportional de-vig: strip the margin, keep the shape."""
inverse = [1 / p for p in prices]
total = sum(inverse)
return [i / total for i in inverse]
matches = [(home, away, devig(prices)) for home, away, prices in closers]
Proportional de-vig assumes the margin sits evenly across all three outcomes, which is close enough for a 3.5% book and wrong enough to matter on a 7% one. Our no-vig odds guide compares proportional against power and Shin if you want to swap the method here.
Across the 222 closers the league averaged 46.9% home, 24.3% draw, 28.9% away. Hold on to those three numbers, because they are the baseline the ratings have to beat.
Step 4: Fit the ratings
The model is Bradley-Terry with a draw term. Give every club one number, add a league-wide home bonus, add one parameter controlling how often draws happen, and push all three through a softmax to get probabilities:
- home score = rating(home) + home_edge
- away score = rating(away)
- draw score = draw_param + (home score + away score) / 2
The draw sits at the midpoint of the two sides by construction, which is what makes mismatches produce few draws and even games produce many. Fitting means moving the ratings until the softmax output matches the de-vigged closers. Cross-entropy is the loss, and its gradient through a softmax is just the difference between what you predicted and what the market said, so the whole optimiser is thirty lines of standard library.
def softmax3(scores):
top = max(scores)
weights = [math.exp(s - top) for s in scores]
total = sum(weights)
return [w / total for w in weights]
def fit_ratings(matches, iters=6000, lr=0.6, ridge=0.01):
"""matches: [(home, away, [p_home, p_draw, p_away]), ...] from de-vigged closers."""
teams = sorted({t for m in matches for t in m[:2]})
slot = {t: i for i, t in enumerate(teams)}
rating = [0.0] * len(teams)
home_edge, draw = 0.2, math.log(0.6)
n = len(matches)
for _ in range(iters):
grad = [0.0] * len(teams)
g_home = g_draw = 0.0
for home, away, target in matches:
i, j = slot[home], slot[away]
strong_h = rating[i] + home_edge
strong_a = rating[j]
q = softmax3([strong_h,
draw + (strong_h + strong_a) / 2,
strong_a])
d_home, d_draw, d_away = (q[0] - target[0], q[1] - target[1], q[2] - target[2])
grad[i] += d_home + 0.5 * d_draw
grad[j] += d_away + 0.5 * d_draw
g_home += d_home + 0.5 * d_draw
g_draw += d_draw
for i in range(len(teams)):
rating[i] -= lr * (grad[i] / n + ridge * rating[i])
home_edge -= lr * g_home / n
draw -= lr * g_draw / n
centre = sum(rating) / len(rating)
rating = [r - centre for r in rating]
return dict(zip(teams, rating)), home_edge, math.exp(draw)
ratings, home_edge, draw = fit_ratings(matches)
Two implementation details carry weight. Re-centring the ratings on zero every iteration pins down a model that is otherwise only identified up to a constant. The ridge term pulls thinly-connected clubs toward the league average, which matters in MLS because the two conferences only play each other a handful of times.
On 222 matches the loss flattened by iteration 1,000 and did not move again. Six thousand iterations takes a few seconds and leaves no doubt.
What came out
The fitted home advantage was 0.499 in log-strength. Put two identical clubs on the pitch and the model gives the host 46.9% / 24.6% / 28.5%, against 37.4% / 25.1% / 37.4% on neutral ground. Playing at home in MLS is worth 9.5 percentage points of win probability.
The ratings themselves, top and bottom of a 30-club league, with the fair home price the model would quote against an average opponent:
| # | Club | Rating | Fair home price vs average |
|---|---|---|---|
| 1 | Vancouver Whitecaps FC | +0.578 | 1.73 |
| 2 | Los Angeles FC | +0.401 | 1.83 |
| 3 | Inter Miami CF | +0.387 | 1.84 |
| 4 | Columbus Crew | +0.279 | 1.91 |
| 5 | Seattle Sounders | +0.272 | 1.92 |
| … | … | … | … |
| 27 | CF Montreal | -0.249 | 2.37 |
| 28 | Portland Timbers | -0.262 | 2.39 |
| 29 | Orlando City SC | -0.305 | 2.43 |
| 30 | Sporting Kansas City | -0.605 | 2.82 |
Top to bottom the league spans 1.183 log units. Run the two ends against each other and the model prices Vancouver at home to Sporting Kansas City at 1.48 / 5.09 / 7.93, and the same fixture in Kansas City at 3.93 / 4.15 / 1.98. Nobody has posted that game yet. You have a number for it anyway.
Step 5: Price any fixture
def price_fixture(ratings, home_edge, draw, home, away):
strong_h = ratings.get(home, 0.0) + home_edge
strong_a = ratings.get(away, 0.0)
p_home, p_draw, p_away = softmax3([strong_h,
math.log(draw) + (strong_h + strong_a) / 2,
strong_a])
return {"home": p_home, "draw": p_draw, "away": p_away,
"fair_odds": [round(1 / p, 3) for p in (p_home, p_draw, p_away)]}
print(price_fixture(ratings, home_edge, draw, "Inter Miami CF", "Columbus Crew"))
An unknown club falls back to a rating of zero, so a promoted or expansion side gets priced as league-average until it has matches behind it. That is the right default and it is worth knowing it is happening.
Does it actually work?
Split the season chronologically, fit on the first 166 matches, and predict the 56 that came later. The comparison is against a baseline that quotes the league’s average 46.9 / 24.3 / 28.9 on every fixture, a hard baseline in a league this flat.
| Metric on 56 unseen matches | League average | Fitted ratings | Improvement |
|---|---|---|---|
| Mean absolute error | 0.0614 | 0.0367 | 40.3% |
| KL divergence | 0.0312 | 0.0105 | 66.5% |
An average miss of 3.7 percentage points against Pinnacle’s closing number, on matches the model never saw, from a fit whose only input was other closing numbers. That is close enough to use as a benchmark and nowhere near close enough to bet into.
The negative result worth keeping
The obvious improvement is recency weighting: decay old matches so the rating tracks current form. We tested half-lives of 120, 90, 60, 45 and 30 days against no decay at all. Holdout error moved from 0.0367 to 0.0365 and back to 0.0367, which is noise. Squad strength over half an MLS season is stable enough that a flat average is as good as anything fancier, and the residual error is not coming from staleness.
It is coming from somewhere more interesting. All four of the model’s worst holdout misses involved one club. Inter Miami’s market price swung between 0.720 and 0.374 for a home win in the same three-month window, and a single season-average rating cannot straddle that. When one player’s availability moves the number that far, no team-level rating will follow it. Handle those fixtures by reading the live price, not the rating.
Where the model and the market disagree
Fit on all 222 matches, then compare against live Pinnacle prices for the round of July 31 to August 2. Sixteen or seventeen books quoted each fixture; Pinnacle priced 12 of the 15.
| Fixture | Ratings (H/D/A) | Pinnacle (H/D/A) | Largest gap |
|---|---|---|---|
| Portland Timbers v Seattle Sounders | 0.368 / 0.251 / 0.381 | 0.445 / 0.238 / 0.317 | Home -7.7 pp |
| Sporting Kansas City v Houston Dynamo | 0.352 / 0.251 / 0.396 | 0.281 / 0.282 / 0.436 | Home +7.1 pp |
| DC United v Nashville SC | 0.393 / 0.251 / 0.355 | 0.325 / 0.274 / 0.402 | Home +6.9 pp |
| New York Red Bulls v Orlando City SC | 0.512 / 0.240 / 0.249 | 0.451 / 0.231 / 0.318 | Away -6.9 pp |
| Inter Miami CF v Columbus Crew | 0.490 / 0.243 / 0.267 | 0.552 / 0.238 / 0.210 | Home -6.2 pp |
| Saint Louis City SC v Real Salt Lake | 0.440 / 0.249 / 0.312 | 0.443 / 0.253 / 0.304 | Away +0.7 pp |
Mean absolute gap across the 12 priced fixtures was 3.0 percentage points. Read those rows the right way round. A 7.7-point gap on a Cascadia derby raises a question rather than a bet: what does Pinnacle know about this fixture that a season average does not? Lineup news, a suspension, a cup game three days later. The gaps are a worklist, and the three fixtures with no Pinnacle price at all are where a rating earns its keep, because the model quotes them and the sharpest book does not.
If you want to turn the disagreements into positions rather than questions, the right next step is grading against the closing line rather than against your own fit. Our EV and CLV guide covers that loop.
What this is good for
Price fixtures nobody has posted. Books put MLS lines up a few days out; a rating covers the whole remaining season the moment you fit it.
Benchmark a real model. If you build something from xG or lineups, the question is whether it beats the closing line, and this gives you the closing line in a form you can query.
Fill the gaps in thin markets. Twelve of fifteen fixtures had a Pinnacle number in our round. The rating covers the other three.
Sanity-check a book. A soft book sitting eight points off a rating built from sharp closers is worth a second look before the number moves.
Four things it will not do. It will not beat the book it was fit to. It will not know about an injury announced this morning. It will not transfer between leagues, because ratings are only comparable inside the competition they were fitted on. And with 13 to 16 matches per club it is a half-season estimate, so treat the middle of the table as a cluster rather than a ranking.
Run it on your league
Everything above is four functions and two loops, no dependencies beyond requests. Swap sportId and tournamentName and it runs on any competition in the catalogue, across 69 sports and 381 bookmakers. Two-outcome sports are simpler still: drop the draw term and the softmax collapses to a logistic.
The expensive part of this exercise, a season of closing lines from a sharp book, costs nothing on our free tier. Get your free API key and fit your own league. If MLS is the league, our MLS odds API guide covers the fixture and market plumbing in more depth, and the Poisson model guide shows the complementary trick of reconstructing a correct-score grid from a single fixture’s prices.
FAQ
Can you build team ratings without match results?
Yes. Fit the ratings to de-vigged closing prices instead of outcomes. On 222 MLS matches this produced ratings that predicted unseen closing prices with a mean absolute error of 0.0367, 40% better than a league-average baseline, without using a single score line.
Why fit to Pinnacle rather than an average of books?
Margin. Pinnacle’s MLS closers averaged 3.57% vig, so there is less distortion to divide out before you get a usable probability. Averaging books is defensible, but dedupe first, because several slugs on the feed quote byte-identical prices and naive averaging over-weights one opinion.
How far back does free historical odds data go?
Far enough for a season fit. On this pull, fixtures from March 2026 still carried their full snapshot history in July, with a median of 298 recorded snapshots per fixture on the home outcome alone. The endpoint accepts a maximum of three bookmakers per call.
Will these ratings find value bets?
Not against the book they were fitted to. A rating trained on Pinnacle’s closers is a compression of Pinnacle’s opinion, so a disagreement usually means the model is missing information rather than the book being wrong. Use it to price fixtures no book has posted, and to benchmark a model built on independent data.
Does recency weighting improve a market-derived rating?
Not measurably here. Half-lives from 120 days down to 30 moved holdout error between 0.0365 and 0.0367, which is noise. The residual error concentrated on one club whose price swung 35 percentage points across the season, which is a lineup problem rather than a decay problem.