fpl-solver / odds_source.py
AnayShukla's picture
Claude Opus 5
Blend a decaying market-odds prior into the team xG rating
540e846
Raw History Blame Contribute Delete
9.99 kB
"""Market odds -> implied per-team expected goals, from two free feeds.
Neither feed alone is enough:
football-data.co.uk Pinnacle CLOSING odds (1X2, over/under 2.5, Asian
handicap) for every completed match, ~20 seasons back,
plain CSV with no key. Sharp and free, but published
after each round - it never covers upcoming fixtures.
the-odds-api.com Live prices for UPCOMING fixtures. Free tier is 500
credits/month and cost is markets x regions, so one
`h2h,totals` call for `soccer_epl` in one region costs
2 credits and returns every upcoming match. Refreshing
twice a week runs at roughly 3% of the free quota.
Needs ODDS_API_KEY in .env; absent, we degrade to
football-data only.
Both are reduced to the same shape - (round, home, away, implied home goals,
implied away goals) - so market_ratings.py can pool played and upcoming
matches into a single fit. Pooling matters most at the start of a season,
when played matches alone cannot identify twenty teams' ratings.
Prices become expected goals by removing the bookmaker's margin and inverting
an independent-Poisson scoreline model: find the goal total and home/away
split whose implied P(home win) and P(over 2.5) match the de-vigged market.
Usage: python odds_source.py [--season 2026-2027] [--no-live]
"""
import argparse
import os
from pathlib import Path
import numpy as np
import pandas as pd
import requests
from dotenv import load_dotenv
from scipy.optimize import brentq
from scipy.stats import poisson
from build_team_totals import canonical
load_dotenv()
FOOTBALL_DATA_URL = "https://www.football-data.co.uk/mmz4281/{code}/E0.csv"
ODDS_API_URL = "https://api.the-odds-api.com/v4/sports/soccer_epl/odds"
ODDS_API_KEY = os.environ.get("ODDS_API_KEY", "").strip()
# Pinnacle closing columns. The "C" is closing - the last price before kickoff,
# which is the sharpest number the market produces. Opening odds are also in
# the file (PSH/PSD/PSA) and are materially worse; do not substitute them.
PIN_1X2 = ["PSCH", "PSCD", "PSCA"]
PIN_OU = ["PC>2.5", "PC<2.5"]
# Bet365 closing, used only when Pinnacle is missing for a match.
B365_1X2 = ["B365CH", "B365CD", "B365CA"]
B365_OU = ["B365C>2.5", "B365C<2.5"]
MATCHES_PER_ROUND = 10
# football-data.co.uk uses short names; fotmob (via canonical()) uses long ones.
FD_NAMES = {
"Man City": "Manchester City",
"Man United": "Manchester United",
"Newcastle": "Newcastle United",
"Tottenham": "Tottenham Hotspur",
"Wolves": "Wolverhampton Wanderers",
"West Ham": "West Ham United",
"Brighton": "Brighton and Hove Albion",
"Bournemouth": "AFC Bournemouth",
"Nott'm Forest": "Nottingham Forest",
"Leicester": "Leicester City",
"Leeds": "Leeds United",
"Norwich": "Norwich City",
"Luton": "Luton Town",
"Ipswich": "Ipswich Town",
"Sheffield United": "Sheffield United",
"Coventry": "Coventry City",
"Hull": "Hull City",
"Sunderland": "Sunderland",
}
# Poisson support. 14 is far beyond any realistic scoreline; the tail is ~0.
_K = 14
_IDX = np.arange(_K)
def team_name(raw):
return canonical(FD_NAMES.get(str(raw).strip(), str(raw).strip()))
def season_code(season):
"""'2026-2027' -> '2627', football-data's directory code."""
start, end = season.split("-")
return start[2:] + end[2:]
def devig(odds):
"""Decimal odds -> probabilities with the bookmaker margin removed.
Proportional (multiplicative) normalisation. Shin and power methods are
marginally better on longshots, but Pinnacle's margin is thin enough that
the difference is far below the noise in what we do downstream.
"""
p = 1.0 / np.asarray(odds, dtype=float)
return p / p.sum()
def _scoreline_probs(lh, la):
"""(P(home win), P(over 2.5)) under independent Poisson."""
grid = poisson.pmf(_IDX, lh)[:, None] * poisson.pmf(_IDX, la)[None, :]
totals = _IDX[:, None] + _IDX[None, :]
return grid[np.tril_indices(_K, -1)].sum(), grid[totals > 2.5].sum()
def implied_goals(p_home, p_away, p_over, iters=6):
"""Expected goals per side matching the de-vigged home-win and over prices.
Two equations, two unknowns, solved by alternating: given a split, solve
the total against the over/under; given a total, solve the split against
the home-win price. Converges within a few passes.
Returns (home_goals, away_goals), or None if the prices are not solvable
(which happens on badly mispriced or stale rows).
"""
share = p_home / (p_home + p_away)
total = 2.6
for _ in range(iters):
try:
total = brentq(
lambda t: _scoreline_probs(t * share, t * (1 - share))[1] - p_over,
0.3, 9.0)
share = brentq(
lambda s: _scoreline_probs(total * s, total * (1 - s))[0] - p_home,
0.02, 0.98)
except ValueError:
return None
return total * share, total * (1 - share)
def _row_to_goals(row):
"""One football-data row -> implied goals, preferring Pinnacle."""
for cols_1x2, cols_ou in ((PIN_1X2, PIN_OU), (B365_1X2, B365_OU)):
if not all(c in row.index for c in cols_1x2 + cols_ou):
continue
vals = [row[c] for c in cols_1x2 + cols_ou]
if any(pd.isna(v) or float(v) <= 1.0 for v in vals):
continue
ph, _, pa = devig([row[c] for c in cols_1x2])
p_over = devig([row[c] for c in cols_ou])[0]
return implied_goals(ph, pa, p_over)
return None
def fetch_football_data(season, timeout=40):
"""Completed matches with closing odds. [(round, home, away, gh, ga), ...]"""
url = FOOTBALL_DATA_URL.format(code=season_code(season))
r = requests.get(url, timeout=timeout)
r.raise_for_status()
tmp = Path(os.environ.get("TEMP", ".")) / f"_fd_{season_code(season)}.csv"
tmp.write_bytes(r.content)
df = pd.read_csv(tmp, encoding="latin-1").dropna(how="all")
tmp.unlink(missing_ok=True)
out, skipped = [], 0
for i, row in df.reset_index(drop=True).iterrows():
goals = _row_to_goals(row)
if goals is None:
skipped += 1
continue
# football-data lists chronologically; rounds are not a column, so
# infer them. Only used to order matches, never as a real GW label.
out.append((i // MATCHES_PER_ROUND + 1,
team_name(row["HomeTeam"]), team_name(row["AwayTeam"]),
goals[0], goals[1]))
print(f"[football-data] {len(out)} played matches priced"
+ (f", {skipped} skipped (no usable closing odds)" if skipped else ""))
return out
def fetch_odds_api(regions="uk", timeout=30):
"""Upcoming fixtures from the-odds-api. [] when no key or nothing priced."""
if not ODDS_API_KEY:
print("[odds-api] ODDS_API_KEY not set - upcoming fixtures skipped")
return []
params = {"apiKey": ODDS_API_KEY, "regions": regions,
"markets": "h2h,totals", "oddsFormat": "decimal"}
r = requests.get(ODDS_API_URL, params=params, timeout=timeout)
if r.status_code != 200:
print(f"[odds-api] HTTP {r.status_code}: {r.text[:200]}")
return []
print(f"[odds-api] quota used {r.headers.get('x-requests-used','?')}"
f", remaining {r.headers.get('x-requests-remaining','?')}")
out = []
for ev in r.json():
home, away = ev.get("home_team"), ev.get("away_team")
best = _best_book(ev, home, away)
if not best:
continue
ph, pa, p_over = best
goals = implied_goals(ph, pa, p_over)
if goals:
out.append((None, team_name(home), team_name(away), goals[0], goals[1]))
print(f"[odds-api] {len(out)} upcoming matches priced")
return out
def _best_book(event, home, away):
"""First bookmaker in the event quoting both h2h and a 2.5 total."""
for bk in event.get("bookmakers", []):
h2h = tot = None
for m in bk.get("markets", []):
if m["key"] == "h2h":
h2h = m["outcomes"]
elif m["key"] == "totals":
tot = [o for o in m["outcomes"] if abs(o.get("point", 0) - 2.5) < 1e-6]
if not h2h or not tot or len(tot) < 2:
continue
try:
price = {o["name"]: float(o["price"]) for o in h2h}
ph, _, pa = devig([price[home], price["Draw"], price[away]])
over = next(o for o in tot if o["name"].lower() == "over")
under = next(o for o in tot if o["name"].lower() == "under")
p_over = devig([float(over["price"]), float(under["price"])])[0]
return ph, pa, p_over
except (KeyError, StopIteration, ValueError):
continue
return None
def collect(season, live=True):
"""Played + upcoming matches, as one list, for the rating fit."""
played = fetch_football_data(season)
upcoming = fetch_odds_api() if live else []
return played, upcoming
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--season", default="2026-2027")
ap.add_argument("--no-live", action="store_true",
help="skip the-odds-api call (saves credits)")
args = ap.parse_args()
played, upcoming = collect(args.season, live=not args.no_live)
allm = played + upcoming
if not allm:
print("no priced matches found")
return
gf = [g for _, _, _, g, _ in allm] + [g for _, _, _, _, g in allm]
print(f"\ntotal {len(allm)} matches | implied goals/team "
f"mean {np.mean(gf):.3f} min {min(gf):.2f} max {max(gf):.2f}")
for rnd, h, a, gh, ga in allm[:5]:
tag = f"R{rnd}" if rnd else "next"
print(f" {tag:>5} {h:<24} {gh:.2f} - {ga:.2f} {a}")
if __name__ == "__main__":
main()