Poisson Soccer Model in Python: Reconstruct Correct-Score Odds from the Market
You can see the moneyline. You can see the over/under 2.5. But what’s the market’s implied probability of a 2–1? Or a 0–0? Or the chance of exactly 4 goals? Books quote a handful of headline lines and leave the rest of the score distribution unstated — even though it’s sitting right there, implied by the prices they do publish.
This guide shows you how to recover it. We’ll take a sharp book’s de-vigged 1X2 and over/under prices from the OddsPapi odds API, fit a Poisson model in pure Python (no libraries beyond the standard math module), and reconstruct the entire correct-score matrix — every scoreline, every alternative total, BTTS, all of it. Then we’ll be honest about where the basic Poisson model breaks, and what the pros do about it.
Why Reconstruct Instead of Predict?
There are two ways to get a score distribution. Most tutorials build a predictive model: scrape years of match results, fit attack/defence ratings, project goals. That needs a results-and-stats database you probably don’t have, and it’s only as good as your feature engineering.
The other way — the one sharps actually use as a baseline — is to reconstruct the distribution the market has already priced. Pinnacle moves billions in handle and its closing line is the most accurate public estimate of a match’s outcome. Its 1X2 and over/under prices already encode a full goal-expectancy view. We just invert it. No results database, no training, no overfitting — the sharpest forecast in the world, decoded into every market the book didn’t bother to quote.
| The Old Way | OddsPapi + Poisson |
|---|---|
| Scrape correct-score odds book by book | One /odds call returns the lines you need |
| Build a predictive model from a results DB you don’t have | Calibrate from the market’s own closing line |
| Get only the markets the book chooses to quote | Derive every scoreline and alternative total yourself |
| One bookmaker’s view | De-vig the sharpest of 350+ books, or blend a consensus |
The Model in 60 Seconds
Goals in a football match are well approximated by a Poisson process: each team scores at some average rate (lambda, λ) over 90 minutes, and the number of goals follows a Poisson distribution. If we know the home team’s expected goals λhome and the away team’s λaway, the probability of any exact scoreline i–j is just the product of two Poisson probabilities:
P(score = i-j) = poisson(i, lambda_home) * poisson(j, lambda_away)
So the whole job reduces to two numbers: λhome and λaway. We back them out of the market in two steps:
- Total goals — the de-vigged over/under 2.5 price tells us λhome + λaway.
- The split — the de-vigged 1X2 tells us how to divide that total between the two teams.
Step 1: Authenticate and Pull the Lines
OddsPapi auth is a query parameter, not a header. Grab a free API key and pull the odds for one fixture. We only need a sharp book here — Pinnacle — but the endpoint returns all 350+ books on the fixture, so you can swap in a consensus later.
import requests, time
from math import exp, factorial
API_KEY = "YOUR_API_KEY"
BASE = "https://api.oddspapi.io/v4"
def get(path, **params):
# GET with the ~0.9s same-endpoint cooldown handled
params["apiKey"] = API_KEY
for _ in range(6):
r = requests.get(f"{BASE}/{path}", params=params)
try:
data = r.json()
if data:
return data
except ValueError:
pass
time.sleep(1.0)
return {}
# A World Cup 2026 fixture: Argentina v Austria (snapshot, June 22 2026)
fixture_id = "id1000001666457022"
odds = get("odds", fixtureId=fixture_id)
markets = odds["bookmakerOdds"]["pinnacle"]["markets"]
def price(market_id, outcome_id):
try:
return markets[str(market_id)]["outcomes"][str(outcome_id)]["players"]["0"]["price"]
except (KeyError, TypeError):
return None
# Full Time Result 1X2 (market 101): Home=101, Draw=102, Away=103
h, d, a = price(101, 101), price(101, 102), price(101, 103)
# Over/Under 2.5 goals (market 1010): Over=1010, Under=1011
over25, under25 = price(1010, 1010), price(1010, 1011)
print("1X2 :", h, d, a) # 1.476 4.5 8.0
print("O/U2.5:", over25, under25) # 2.01 1.9
Decimal odds carry the bookmaker’s margin (the “vig”). Before we can read them as probabilities we have to strip it out.
Step 2: De-vig to Fair Probabilities
The simplest de-vig is proportional: take the inverse of each price (the raw implied probability), then normalise so they sum to 1. For a deeper treatment of the power and Shin methods, see our no-vig odds guide — proportional is fine for this walkthrough.
def devig(*odds):
inv = [1 / o for o in odds]
total = sum(inv)
return [i / total for i in inv]
p_home, p_draw, p_away = devig(h, d, a)
p_over, p_under = devig(over25, under25)
print(f"Fair 1X2 : H={p_home:.3f} D={p_draw:.3f} A={p_away:.3f}")
# Fair 1X2 : H=0.661 D=0.217 A=0.122
print(f"Fair O/U : Over={p_over:.3f} Under={p_under:.3f}")
# Fair O/U : Over=0.486 Under=0.514
So the market makes Argentina a 66.1% favourite and sees the game as a near coin-flip on 2.5 goals. Those two facts are enough to pin the entire score distribution.
Step 3: Solve for Total Goal Expectancy
Under a Poisson with mean λtotal, the probability of 3 or more goals (i.e. “over 2.5”) has a closed form. We don’t even need to invert it analytically — a few rounds of bisection nail λtotal to the de-vigged over price.
def poisson(k, lam):
return exp(-lam) * lam ** k / factorial(k)
def prob_over(line, lam):
# P(total goals > line) for a .5 line, e.g. over 2.5 -> P(N >= 3)
threshold = int(line + 0.5)
return 1 - sum(poisson(k, lam) for k in range(threshold))
def solve_lambda_total(p_over, line=2.5):
lo, hi = 0.1, 6.0
for _ in range(60):
mid = (lo + hi) / 2
if prob_over(line, mid) < p_over:
lo = mid
else:
hi = mid
return (lo + hi) / 2
lam_total = solve_lambda_total(p_over)
print(f"lambda_total = {lam_total:.3f}") # 2.617
The market is pricing in about 2.62 total goals. Now we split it.
Step 4: Split the Total Using the 1X2
Given a fixed total, the home/away split is a single degree of freedom: the bigger Argentina's share, the higher its win probability. We bisect on that share until the model's home-win probability matches the de-vigged 1X2.
def outcome_probs(lam_h, lam_a, max_goals=12):
# Home-win / draw / away-win from an independent Poisson grid
ph = pd = pa = 0.0
for i in range(max_goals):
for j in range(max_goals):
p = poisson(i, lam_h) * poisson(j, lam_a)
if i > j: ph += p
elif i == j: pd += p
else: pa += p
return ph, pd, pa
def split_lambda(lam_total, p_home_target):
lo, hi = 0.50, 0.95 # home share of total goals
for _ in range(60):
share = (lo + hi) / 2
lam_h = share * lam_total
ph, _, _ = outcome_probs(lam_h, lam_total - lam_h)
if ph < p_home_target:
lo = share
else:
hi = share
share = (lo + hi) / 2
return share * lam_total, lam_total - share * lam_total
lam_home, lam_away = split_lambda(lam_total, p_home)
print(f"lambda_home = {lam_home:.3f} lambda_away = {lam_away:.3f}")
# lambda_home = 1.914 lambda_away = 0.703
Argentina is priced to score ~1.91, Austria ~0.70. That's our complete model — two numbers that came entirely from the market.
Step 5: Reconstruct Every Market
With λhome and λaway in hand, build the score matrix once and read any market straight off it — including ones Pinnacle never quoted.
MAXG = 12
matrix = [[poisson(i, lam_home) * poisson(j, lam_away)
for j in range(MAXG)] for i in range(MAXG)]
# Top correct scores
scores = [((i, j), matrix[i][j]) for i in range(6) for j in range(6)]
scores.sort(key=lambda x: -x[1])
print("Most likely scorelines:")
for (i, j), p in scores[:6]:
print(f" {i}-{j}: {p*100:4.1f}% fair odds {1/p:5.1f}")
# Derived markets
btts = sum(matrix[i][j] for i in range(1, MAXG) for j in range(1, MAXG))
over_15 = sum(matrix[i][j] for i in range(MAXG) for j in range(MAXG) if i + j >= 2)
over_35 = sum(matrix[i][j] for i in range(MAXG) for j in range(MAXG) if i + j >= 4)
print(f"BTTS yes : {btts*100:.1f}% (fair odds {1/btts:.2f})")
print(f"Over 1.5 : {over_15*100:.1f}%")
print(f"Over 3.5 : {over_35*100:.1f}%")
Output:
Most likely scorelines:
1-0: 14.0% fair odds 7.2
2-0: 13.4% fair odds 7.5
1-1: 9.8% fair odds 10.2
2-1: 9.4% fair odds 10.6
3-0: 8.5% fair odds 11.7
0-0: 7.3% fair odds 13.7
BTTS yes : 43.1% (fair odds 2.32)
Over 1.5 : 80.4%
Over 3.5 : 22.0%
You now have a fair price for every correct score, every alternative total, and BTTS — reconstructed from two market inputs. Cross-check the derived over/under 3.5 and 1.5 against the book's actual prices on the same fixture and they line up almost exactly, which is the point: the model isn't guessing, it's decoding.
Where Basic Poisson Breaks (Read This Before You Bet)
Here's the honest part most tutorials skip. The independent Poisson model has a known, systematic flaw: it assumes the two teams' goals are independent. Real matches don't work that way — game state, red cards, and chasing a result all correlate the two scorelines. Compare our reconstruction to the book's actual both-teams-to-score line:
| Market | Poisson model | Pinnacle (de-vigged) | Gap |
|---|---|---|---|
| Over 2.5 | 48.6% | 48.6% | 0.0 |
| Home win | 66.1% | 66.1% | 0.0 |
| Draw | 21.0% | 21.7% | -0.7 |
| BTTS yes | 43.1% | 47.6% | -4.5 |
The markets we calibrated against match perfectly (they have to). But BTTS is 4.5 points too low, and the draw is slightly under too. That's not noise — independent Poisson always under-prices both-teams-to-score and draws, because in reality low-scoring outcomes cluster. This is exactly why pros reach for the Dixon–Coles adjustment, which adds a correlation term (ρ) to inflate the 0–0, 1–0, 0–1 and 1–1 cells. Our same-game parlay correlation guide digs into why multiplying "independent" leg probabilities fails for the same reason.
Treat the basic model as a fast, transparent baseline — great for filling in unquoted scorelines — not as a value-detection engine. If your reconstructed price beats a soft book, the gap is at least as likely to be the model's independence error as it is a real edge.
From One Book to a Consensus — and Back in Time
Two upgrades make this genuinely useful:
- Blend a consensus. Instead of one book, de-vig several sharp books and average the fair probabilities before fitting — OddsPapi returns 350+ on a single call. Our consensus odds guide has the weighting math.
- Backtest the drift. The
/historical-oddsendpoint is free on OddsPapi, so you can refit λ at every snapshot from opening to close and watch how the implied score distribution moved as money came in — a clean way to study closing-line behaviour without a results database.
Build It on Real Markets
The whole model is ~40 lines of standard-library Python and every number in it came from the market, not a black box. Point it at any upcoming fixture — soccer, or any sport where a Poisson (or its cousins) fits the scoring process — and you've got fair prices for markets the book never published.
Stop scraping correct-score odds. Get your free OddsPapi API key and reconstruct the full distribution from 350+ bookmakers, with free historical odds to backtest it.
Frequently Asked Questions
Why use a Poisson model instead of just reading the correct-score odds?
Bookmakers don't quote every market. They publish the 1X2 and a few totals, but rarely a full correct-score grid, every alternative total, or obscure derived markets. A Poisson model calibrated from the lines they do publish reconstructs all of them from two numbers.
Do I need historical match results to build this?
No. This approach calibrates entirely from current market odds (de-vigged 1X2 and over/under), so it needs no results or stats database. That's the key difference from a predictive model — you're decoding the market's forecast, not building your own from scratch.
Is the basic Poisson model accurate enough to find value bets?
Use caution. Independent Poisson systematically under-prices both-teams-to-score and draws because it ignores goal correlation. If your reconstructed price beats a book, the difference is at least as likely to be the model's known error as a real edge. The Dixon–Coles correction addresses this by adding a correlation parameter.
What data do I need from the OddsPapi API?
Just two markets from one fixture: the Full Time Result (1X2, market 101) and an over/under line (e.g. Over/Under 2.5, market 1010). Both come from a single /v4/odds call. The free tier covers it.
Can I use this for sports other than soccer?
The independent-Poisson framework fits any sport where scoring events are roughly independent and low-frequency — hockey is a common one. High-scoring or possession-based sports (basketball) need different distributions, but the calibrate-from-the-market principle still applies.