Spaces:
Sleeping
Sleeping
Download odds_source.py from AnayShukla/fpl-solver: direct link, hf CLI and curl.
- Browser
- Download file 9.99 kB
-
https://huggingface.co/spaces/AnayShukla/fpl-solver/resolve/main/odds_source.py
- Command line
-
hf download hf://spaces/AnayShukla/fpl-solver/odds_source.py
-
curl -L -o odds_source.py https://huggingface.co/spaces/AnayShukla/fpl-solver/resolve/main/odds_source.py
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() | |