{"id":2938,"date":"2026-06-01T10:00:00","date_gmt":"2026-06-01T10:00:00","guid":{"rendered":"https:\/\/oddspapi.io\/blog\/?p=2938"},"modified":"2026-05-29T14:29:23","modified_gmt":"2026-05-29T14:29:23","slug":"tennis-elo-model-python","status":"publish","type":"post","link":"https:\/\/oddspapi.io\/blog\/tennis-elo-model-python\/","title":{"rendered":"Tennis ELO Model in Python: Beat the Closing Line with Free Historical Odds"},"content":{"rendered":"\n\n<h2>You Don&#8217;t Need a Black-Box Tennis Model. You Need a Sharper Benchmark.<\/h2>\n\n<p>Search &#8220;tennis prediction model&#8221; and you get two kinds of results: academic ELO papers with no code, and paid &#8220;AI tipster&#8221; services that won&#8217;t show you their math. Neither is useful if you actually want to <em>build and validate<\/em> your own model.<\/p>\n\n<p>Here&#8217;s the thing nobody tells you: the model is the easy part. ELO ratings for tennis are about 40 lines of Python. The hard part is answering one question \u2014 <strong>is my model actually better than the market?<\/strong> And to answer that you need the one number that&#8217;s almost impossible to get cheaply: the sharp closing line, plus its full price history.<\/p>\n\n<p>This guide builds a surface-weighted ELO model from scratch, then benchmarks it against Pinnacle&#8217;s de-vigged probability and 23 other bookmakers \u2014 all from a single free API call. We&#8217;ll use a real match: <strong>Lorenzo Musetti vs Holger Rune, French Open Round 4<\/strong>, which had <strong>24 books pricing it live<\/strong>, including Pinnacle, Kalshi, Polymarket and Betfair Exchange.<\/p>\n\n<h2>The Old Way vs OddsPapi<\/h2>\n\n<figure class=\"wp-block-table\">\n<table>\n  <thead><tr><th>Step<\/th><th>The Old Way<\/th><th>OddsPapi<\/th><\/tr><\/thead>\n  <tbody>\n    <tr><td>Match data &amp; results<\/td><td>Scrape Tennis Abstract \/ Flashscore, parse HTML<\/td><td>One <code>\/fixtures<\/code> call (sportId 12)<\/td><\/tr>\n    <tr><td>The sharp benchmark<\/td><td>Pinnacle API is closed to the public<\/td><td><code>\/odds<\/code> returns Pinnacle + 23 books<\/td><\/tr>\n    <tr><td>Closing-line history (for CLV)<\/td><td>Paid add-on, or scrape every 5 min yourself<\/td><td><code>\/historical-odds<\/code> \u2014 free tier<\/td><\/tr>\n    <tr><td>De-vig to &#8220;true&#8221; probability<\/td><td>Manual, per book<\/td><td>Same nested JSON across every book<\/td><\/tr>\n    <tr><td>Cost<\/td><td>$50\u2013300\/mo + scraper maintenance<\/td><td>Free tier<\/td><\/tr>\n  <\/tbody>\n<\/table>\n<\/figure>\n\n<p>That third row is the killer. A predictive model is only as good as the yardstick you measure it against, and the only honest yardstick in betting is the <strong>closing line<\/strong> \u2014 specifically a sharp book&#8217;s closing line, de-vigged. Pinnacle moves their price as sharp money comes in; by the time the match starts, their no-vig probability is the most accurate public forecast on the planet. OddsPapi gives you that line <em>and<\/em> its full history for free, so you can compute Closing Line Value (CLV) and actually prove whether your edge is real.<\/p>\n\n<h2>Step 1: Authenticate<\/h2>\n\n<p>OddsPapi uses an <code>apiKey<\/code> <strong>query parameter<\/strong> \u2014 not a header. Every call needs it.<\/p>\n\n<pre class=\"wp-block-code\"><code>import requests\n\nAPI_KEY = \"YOUR_API_KEY\"\nBASE_URL = \"https:\/\/api.oddspapi.io\/v4\"\n\n# Smoke test\nr = requests.get(f\"{BASE_URL}\/sports\", params={\"apiKey\": API_KEY})\nprint(r.status_code)  # 200\n<\/code><\/pre>\n\n<h2>Step 2: Find Tennis Fixtures<\/h2>\n\n<p>Tennis is <code>sportId=12<\/code>. Pull a date range (max 10 days apart) and keep the ones that actually have odds.<\/p>\n\n<pre class=\"wp-block-code\"><code>import time\n\ndef tennis_fixtures(date_from, date_to):\n    r = requests.get(f\"{BASE_URL}\/fixtures\", params={\n        \"apiKey\": API_KEY,\n        \"sportId\": 12,\n        \"from\": date_from,\n        \"to\": date_to,\n    })\n    return [f for f in r.json() if f.get(\"hasOdds\")]\n\nfixtures = tennis_fixtures(\"2026-06-01\", \"2026-06-07\")\nfor f in fixtures[:5]:\n    print(f[\"fixtureId\"], f[\"participant1Name\"], \"v\", f[\"participant2Name\"],\n          \"|\", f[\"tournamentName\"])\n<\/code><\/pre>\n\n<p>During Roland Garros this returned 100+ singles fixtures. Our target \u2014 Musetti v Rune \u2014 carried <strong>24 bookmakers<\/strong> on the match-winner market.<\/p>\n\n<h2>Step 3: Fetch the Winner Odds<\/h2>\n\n<p>The tennis match-winner market is <strong>marketId <code>171<\/code><\/strong>. Outcomes are <code>171<\/code> (player 1) and <code>172<\/code> (player 2). The live <code>\/odds<\/code> response is deeply nested \u2014 <code>players[\"0\"]<\/code> is a <strong>dict<\/strong> holding the current price (the historical endpoint uses a list; don&#8217;t mix them up).<\/p>\n\n<pre class=\"wp-block-code\"><code>WINNER = \"171\"\n\ndef winner_prices(fixture_id):\n    r = requests.get(f\"{BASE_URL}\/odds\", params={\n        \"apiKey\": API_KEY,\n        \"fixtureId\": fixture_id,\n    })\n    books = r.json().get(\"bookmakerOdds\", {})\n    out = {}\n    for slug, data in books.items():\n        market = (data.get(\"markets\") or {}).get(WINNER)\n        if not market:\n            continue\n        oc = market[\"outcomes\"]\n        try:\n            p1 = oc[\"171\"][\"players\"][\"0\"][\"price\"]\n            p2 = oc[\"172\"][\"players\"][\"0\"][\"price\"]\n        except (KeyError, TypeError):\n            continue\n        if p1 and p2:\n            out[slug] = (p1, p2)\n    return out\n\nprices = winner_prices(\"id1003846272365390\")  # Musetti v Rune\nprint(len(prices), \"books\")\nprint(\"Pinnacle:\", prices[\"pinnacle\"])  # (1.473, 2.71)\n<\/code><\/pre>\n\n<p>Real output for this match (sorted by margin, tightest first):<\/p>\n\n<figure class=\"wp-block-table\">\n<table>\n  <thead><tr><th>Book<\/th><th>Musetti<\/th><th>Rune<\/th><th>Margin (vig)<\/th><\/tr><\/thead>\n  <tbody>\n    <tr><td>Kalshi<\/td><td>1.587<\/td><td>2.632<\/td><td>1.01%<\/td><\/tr>\n    <tr><td>Polymarket<\/td><td>1.55<\/td><td>2.70<\/td><td>1.49%<\/td><\/tr>\n    <tr><td>Betfair Exchange<\/td><td>1.55<\/td><td>2.66<\/td><td>2.14%<\/td><\/tr>\n    <tr><td>Parimatch \/ Betsson \/ GGBet<\/td><td>1.50<\/td><td>2.66<\/td><td>3.95%<\/td><\/tr>\n    <tr><td><strong>Pinnacle<\/strong><\/td><td><strong>1.473<\/strong><\/td><td><strong>2.71<\/strong><\/td><td><strong>4.79%<\/strong><\/td><\/tr>\n    <tr><td>Bet365 \/ Unibet<\/td><td>1.47<\/td><td>2.70<\/td><td>5.05%<\/td><\/tr>\n    <tr><td>DraftKings \/ BoyleSports<\/td><td>1.45<\/td><td>2.70<\/td><td>5.94%<\/td><\/tr>\n    <tr><td>Coral \/ Ladbrokes<\/td><td>1.47<\/td><td>2.625<\/td><td>6.18%<\/td><\/tr>\n  <\/tbody>\n<\/table>\n<\/figure>\n\n<p>Note the pattern OddsPapi exposes that single-book APIs can&#8217;t: the <strong>prediction markets (Kalshi, Polymarket) and the exchange (Betfair) price tighter than Pinnacle itself<\/strong> \u2014 1\u20132% margin vs Pinnacle&#8217;s 4.8%. That matters for your benchmark, and we&#8217;ll come back to it.<\/p>\n\n<h2>Step 4: Build the ELO Engine<\/h2>\n\n<p>ELO is dead simple. Every player has a rating. Before a match, the expected win probability of player A is a logistic function of the rating gap. After the match, both ratings move toward the actual result \u2014 winner gains, loser loses, scaled by <code>K<\/code> and by how surprising the result was.<\/p>\n\n<pre class=\"wp-block-code\"><code>def expected_score(rating_a, rating_b):\n    \"\"\"Win probability of A given the rating gap.\"\"\"\n    return 1.0 \/ (1.0 + 10 ** ((rating_b - rating_a) \/ 400.0))\n\ndef update(rating_a, rating_b, a_won, k=32):\n    \"\"\"Return updated (rating_a, rating_b) after a single match.\"\"\"\n    exp_a = expected_score(rating_a, rating_b)\n    score_a = 1.0 if a_won else 0.0\n    rating_a += k * (score_a - exp_a)\n    rating_b += k * ((1 - score_a) - (1 - exp_a))\n    return rating_a, rating_b\n<\/code><\/pre>\n\n<p>Tennis has one wrinkle that makes a generic ELO useless: <strong>surface<\/strong>. A clay specialist like Musetti is a different player on clay than on hard court. The fix the research community settled on (Kovalchik, 2016) is to keep a <strong>separate rating per surface<\/strong> and blend it with the overall rating. Here&#8217;s a compact surface-aware engine that ingests a match history and spits out ratings:<\/p>\n\n<pre class=\"wp-block-code\"><code>from collections import defaultdict\n\nclass TennisElo:\n    def __init__(self, k=32, base=1500, surface_weight=0.6):\n        self.overall = defaultdict(lambda: base)\n        self.surface = defaultdict(lambda: defaultdict(lambda: base))\n        self.k = k\n        self.sw = surface_weight  # how much surface form counts\n\n    def rating(self, player, surface):\n        # Blend surface-specific and overall rating\n        return self.sw * self.surface[surface][player] + (1 - self.sw) * self.overall[player]\n\n    def predict(self, p1, p2, surface):\n        return expected_score(self.rating(p1, surface), self.rating(p2, surface))\n\n    def add_match(self, winner, loser, surface):\n        # Update the overall ratings\n        ro_w, ro_l = self.overall[winner], self.overall[loser]\n        self.overall[winner], self.overall[loser] = update(ro_w, ro_l, True, self.k)\n        # Update the surface-specific ratings\n        rs_w, rs_l = self.surface[surface][winner], self.surface[surface][loser]\n        self.surface[surface][winner], self.surface[surface][loser] = update(rs_w, rs_l, True, self.k)\n\n# Feed it your match history (winner, loser, surface), oldest first:\nmodel = TennisElo()\nfor m in match_history:          # list of (winner, loser, surface) tuples\n    model.add_match(*m)\n\np_musetti = model.predict(\"Lorenzo Musetti\", \"Holger Rune\", \"clay\")\nprint(f\"ELO win prob Musetti: {p_musetti:.1%}\")\n<\/code><\/pre>\n\n<p>Where does <code>match_history<\/code> come from? Process a season of completed results \u2014 ATP\/WTA match logs are widely available as CSV, and you backfill ratings by replaying every match chronologically. The <code>surface_weight<\/code> and <code>k<\/code> are the knobs you tune in the backtest (Step 6).<\/p>\n\n<h2>Step 5: De-vig the Market \u2014 Your Ground Truth<\/h2>\n\n<p>You can&#8217;t compare your ELO probability to a raw bookmaker price, because that price includes the bookmaker&#8217;s margin (the &#8220;vig&#8221;). You have to strip it out first. For a two-way market the simplest method is multiplicative normalisation:<\/p>\n\n<pre class=\"wp-block-code\"><code>def devig_two_way(odds_a, odds_b):\n    \"\"\"Return (fair_prob_a, fair_prob_b) with the margin removed.\"\"\"\n    imp_a, imp_b = 1 \/ odds_a, 1 \/ odds_b\n    overround = imp_a + imp_b\n    return imp_a \/ overround, imp_b \/ overround\n\n# Pinnacle: Musetti 1.473, Rune 2.71\nfair_m, fair_r = devig_two_way(1.473, 2.71)\nprint(f\"Pinnacle fair: Musetti {fair_m:.1%}, Rune {fair_r:.1%}\")\n# -> Pinnacle fair: Musetti 64.8%, Rune 35.2%\n<\/code><\/pre>\n\n<p>So the sharpest book on earth, with the margin removed, makes Musetti a <strong>64.8%<\/strong> favourite. That&#8217;s your benchmark. (For heavy favourites you may prefer the &#8220;power&#8221; method, which corrects the favourite\u2013longshot bias \u2014 but for a balanced match like this, multiplicative is fine and within a fraction of a percent.)<\/p>\n\n<h2>Step 6: Compare, and Find the Edge<\/h2>\n\n<p>Now put the two numbers side by side. Suppose your clay-weighted ELO \u2014 after replaying the season \u2014 outputs ratings of roughly <strong>2003 (Musetti)<\/strong> vs <strong>1897 (Rune)<\/strong>. That&#8217;s a 106-point gap, which the logistic turns into:<\/p>\n\n<pre class=\"wp-block-code\"><code>p = expected_score(2003, 1897)\nprint(f\"ELO: {p:.1%}\")   # 64.8%\n<\/code><\/pre>\n\n<p>Your model and the sharp market land in the <em>same<\/em> place: ~64.8%. That&#8217;s actually the most common outcome when you benchmark against Pinnacle \u2014 and it&#8217;s a feature, not a disappointment. It tells you the favourite is fairly priced and your model isn&#8217;t fooling itself. <strong>The edge isn&#8217;t in disagreeing with the market on probability. It&#8217;s in the price.<\/strong><\/p>\n\n<p>Your fair probability says Musetti should be <code>1 \/ 0.648 = 1.543<\/code>. Now scan all 24 books for the best available price on Musetti:<\/p>\n\n<pre class=\"wp-block-code\"><code>def best_price(prices, side):\n    # side: 0 for player1, 1 for player2\n    return max(prices.items(), key=lambda kv: kv[1][side])\n\nbook, (m, r) = best_price(prices, 0)\nprint(f\"Best Musetti: {book} @ {m}\")   # Best Musetti: kalshi @ 1.587\n\nfair_odds = 1 \/ fair_m            # 1.543\nev = fair_m * 1.587 - 1           # expected value at the best price\nprint(f\"Fair odds {fair_odds:.3f} | EV at 1.587: {ev:+.2%}\")\n# -> Fair odds 1.543 | EV at 1.587: +2.82%\n<\/code><\/pre>\n\n<p><strong>Kalshi was offering Musetti at 1.587 while the sharp fair price was 1.543.<\/strong> Backing the favourite there is <strong>+2.8% EV<\/strong> versus Pinnacle&#8217;s de-vigged line \u2014 a clean, model-confirmed value bet that exists purely because a prediction market priced the favourite more generously than the sharp book. Check the other side too: the best Rune price was 1xBet at 2.74, but fair Rune odds are <code>1 \/ 0.352 = 2.84<\/code>, so even the best dog price is <strong>\u22123.5% EV<\/strong>. Pass.<\/p>\n\n<p>This is the whole workflow in one screen: <strong>ELO gives you an independent prior, the de-vigged sharp line tells you the true probability, and line-shopping across 350+ books turns &#8220;I think Musetti wins&#8221; into a quantified +EV bet.<\/strong> The model&#8217;s real job is to stop you taking the \u2212EV side when the price <em>looks<\/em> tempting.<\/p>\n\n<h2>Step 7: Prove It With Closing Line Value<\/h2>\n\n<p>One worked example proves nothing \u2014 a model is only validated over hundreds of matches. The professional standard is <strong>Closing Line Value<\/strong>: did you consistently bet at prices better than the closing (sharpest) line? Beat the close over a big sample and you are, by definition, +EV. OddsPapi&#8217;s free <code>\/historical-odds<\/code> endpoint makes this measurable. The shape differs from live odds \u2014 here <code>players[\"0\"]<\/code> is a <strong>list<\/strong> of snapshots:<\/p>\n\n<pre class=\"wp-block-code\"><code>def closing_line(fixture_id, book=\"pinnacle\"):\n    r = requests.get(f\"{BASE_URL}\/historical-odds\", params={\n        \"apiKey\": API_KEY,\n        \"fixtureId\": fixture_id,\n        \"bookmakers\": book,          # max 3 per call\n    })\n    oc = r.json()[\"bookmakers\"][book][\"markets\"][\"171\"][\"outcomes\"]\n    history = oc[\"171\"][\"players\"][\"0\"]   # list of snapshots\n    return [(s[\"createdAt\"], s[\"price\"]) for s in history]\n\nsnaps = closing_line(\"id1003846272365390\")\nprint(\"Open:\", snaps[0], \"| Close:\", snaps[-1])\n# Open: ('2026-05-31T08:58...', 1.485) | Close: (..., 1.473)\n<\/code><\/pre>\n\n<p>Pinnacle <strong>shortened Musetti from 1.485 to 1.473<\/strong> in the hours before the match \u2014 sharp money landed on the Italian. If you&#8217;d taken Kalshi&#8217;s 1.587 the morning of the match, you beat the close by a wide margin: textbook positive CLV. A real backtest loops this over your whole bet log:<\/p>\n\n<pre class=\"wp-block-code\"><code>def clv(bet_odds, closing_odds):\n    \"\"\"Positive = you beat the closing line.\"\"\"\n    return bet_odds \/ closing_odds - 1\n\n# Your 1.587 vs Pinnacle close 1.473\nprint(f\"CLV: {clv(1.587, 1.473):+.1%}\")   # CLV: +7.7%\n<\/code><\/pre>\n\n<p>Run your ELO picks through this over a season. If your average CLV is positive, your model has a real edge and you can size up with confidence. If it&#8217;s negative, no staking plan will save you \u2014 and you&#8217;ve learned that for the price of zero API calls. To turn a validated edge into bet sizes, pipe the de-vigged probability into a <a href=\"https:\/\/oddspapi.io\/blog\/kelly-criterion-staking-calculator-python\/\">Kelly criterion staking calculator<\/a>.<\/p>\n\n<h2>Why This Matters: The Three Kill Shots<\/h2>\n\n<ul>\n  <li><strong>Free historical odds.<\/strong> CLV is the only honest way to validate a model. Competitors charge for closing-line history; OddsPapi gives it away on the free tier.<\/li>\n  <li><strong>350+ bookmakers, sharps included.<\/strong> One match returned 24 books \u2014 Pinnacle for the benchmark, Kalshi\/Polymarket\/Betfair for the tightest prices, and soft books where the value usually hides.<\/li>\n  <li><strong>The sharp line, no enterprise contract.<\/strong> Pinnacle&#8217;s API is closed to the public. OddsPapi hands you their price (and history) through one query parameter.<\/li>\n<\/ul>\n\n<h2>Frequently Asked Questions<\/h2>\n\n<h3>What&#8217;s the best K-factor and surface weight for tennis ELO?<\/h3>\n<p>There&#8217;s no universal answer \u2014 that&#8217;s what the CLV backtest is for. Common starting points are <code>K=32<\/code> and a surface weight around 0.5\u20130.7. Tune them to maximise your average CLV against Pinnacle&#8217;s closing line over a full season, not to maximise in-sample accuracy.<\/p>\n\n<h3>Where do I get the historical match results to train ELO?<\/h3>\n<p>ATP\/WTA match logs (winner, loser, surface, date) are published as free CSVs by several community datasets. You replay them oldest-first through the engine in Step 4 to backfill ratings. OddsPapi supplies the <em>odds<\/em> side \u2014 the closing lines you validate against.<\/p>\n\n<h3>Why de-vig Pinnacle instead of just using its raw price?<\/h3>\n<p>Raw odds bake in the bookmaker&#8217;s margin, so they overstate both players&#8217; probabilities. De-vigging normalises them back to 100% so you can compare apples to apples with your model. Pinnacle is the book to de-vig because its low margin and sharp customer base make its closing number the most accurate public forecast.<\/p>\n\n<h3>Can I do this for other sports?<\/h3>\n<p>Yes. ELO works for any head-to-head sport \u2014 swap <code>sportId<\/code> and the match-winner market ID. The de-vig and CLV logic is identical. See the <a href=\"https:\/\/oddspapi.io\/blog\/tennis-odds-api-live-atp-wta\/\">Tennis Odds API guide<\/a> for the full market catalogue.<\/p>\n\n<h3>Is the OddsPapi free tier really enough for this?<\/h3>\n<p>Yes. Fixtures, live odds across 350+ books, and historical odds are all on the free tier. The only limits are a short cooldown between calls (use <code>time.sleep(0.2)<\/code> in loops) and a max of 3 bookmakers per historical-odds call.<\/p>\n\n<h2>Stop Guessing. Benchmark Against the Sharps.<\/h2>\n\n<p>A tennis model you can&#8217;t measure is just a hunch with extra steps. Build the ELO engine, de-vig the sharp line, and prove your edge with closing line value \u2014 all on free data. <a href=\"https:\/\/oddspapi.io\/\">Grab your free OddsPapi API key<\/a> and start validating.<\/p>\n\n<p>Next steps: turn your fair probabilities into a full <a href=\"https:\/\/oddspapi.io\/blog\/expected-value-betting-python-ev-clv\/\">expected value &amp; CLV scanner<\/a>, calculate <a href=\"https:\/\/oddspapi.io\/blog\/consensus-odds-fair-odds-calculator-python\/\">consensus fair odds from 350+ books<\/a>, and read the <a href=\"https:\/\/oddspapi.io\/blog\/backtest-betting-model-free-historical-odds\/\">guide to backtesting a betting model with free historical odds<\/a>.<\/p>\n\n<!--\nFocus Keyphrase: tennis elo model\nSEO Title: Tennis ELO Model in Python: Beat the Closing Line (Free API)\nMeta Description: Build a surface-weighted tennis ELO model in Python and benchmark it against Pinnacle's de-vigged closing line using OddsPapi's free historical odds.\nSlug: tennis-elo-model-python\n-->\n\n\n","protected":false},"excerpt":{"rendered":"<p>Build a surface-weighted tennis ELO model in Python and benchmark it against Pinnacle&#8217;s de-vigged closing line using OddsPapi&#8217;s free historical odds.<\/p>\n","protected":false},"author":2,"featured_media":2942,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[7],"tags":[30,8,4,9,11],"class_list":["post-2938","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-how-to-guides","tag-backtesting","tag-free-api","tag-historical-odds","tag-odds-api","tag-python"],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v26.4 - https:\/\/yoast.com\/wordpress\/plugins\/seo\/ -->\n<title>Tennis ELO Model in Python: Beat the Closing Line with Free Historical Odds | Odds API Development Blog<\/title>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/oddspapi.io\/blog\/tennis-elo-model-python\/\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"Tennis ELO Model in Python: Beat the Closing Line with Free Historical Odds | Odds API Development Blog\" \/>\n<meta property=\"og:description\" content=\"Build a surface-weighted tennis ELO model in Python and benchmark it against Pinnacle&#039;s de-vigged closing line using OddsPapi&#039;s free historical odds.\" \/>\n<meta property=\"og:url\" content=\"https:\/\/oddspapi.io\/blog\/tennis-elo-model-python\/\" \/>\n<meta property=\"og:site_name\" content=\"Odds API Development Blog\" \/>\n<meta property=\"article:published_time\" content=\"2026-06-01T10:00:00+00:00\" \/>\n<meta property=\"og:image\" content=\"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/05\/tennis-elo-model-python-2-scaled.webp\" \/>\n\t<meta property=\"og:image:width\" content=\"2560\" \/>\n\t<meta property=\"og:image:height\" content=\"1429\" \/>\n\t<meta property=\"og:image:type\" content=\"image\/webp\" \/>\n<meta name=\"author\" content=\"Odds API Writer\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:image\" content=\"https:\/\/oddspapi.io\/logo-v2.webp\" \/>\n<meta name=\"twitter:creator\" content=\"@oddspapiapi\" \/>\n<meta name=\"twitter:site\" content=\"@oddspapiapi\" \/>\n<meta name=\"twitter:label1\" content=\"Written by\" \/>\n\t<meta name=\"twitter:data1\" content=\"Odds API Writer\" \/>\n\t<meta name=\"twitter:label2\" content=\"Est. reading time\" \/>\n\t<meta name=\"twitter:data2\" content=\"11 minutes\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\/\/schema.org\",\"@graph\":[{\"@type\":\"Article\",\"@id\":\"https:\/\/oddspapi.io\/blog\/tennis-elo-model-python\/#article\",\"isPartOf\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/tennis-elo-model-python\/\"},\"author\":{\"name\":\"Odds API Writer\",\"@id\":\"https:\/\/oddspapi.io\/blog\/#\/schema\/person\/b6f21e649c4f556f0a95c23a0f1efa13\"},\"headline\":\"Tennis ELO Model in Python: Beat the Closing Line with Free Historical Odds\",\"datePublished\":\"2026-06-01T10:00:00+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/tennis-elo-model-python\/\"},\"wordCount\":1612,\"commentCount\":0,\"publisher\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/#organization\"},\"image\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/tennis-elo-model-python\/#primaryimage\"},\"thumbnailUrl\":\"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/05\/tennis-elo-model-python-2-scaled.webp\",\"keywords\":[\"Backtesting\",\"Free API\",\"historical odds\",\"Odds API\",\"Python\"],\"articleSection\":[\"How To Guides\"],\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"CommentAction\",\"name\":\"Comment\",\"target\":[\"https:\/\/oddspapi.io\/blog\/tennis-elo-model-python\/#respond\"]}]},{\"@type\":\"WebPage\",\"@id\":\"https:\/\/oddspapi.io\/blog\/tennis-elo-model-python\/\",\"url\":\"https:\/\/oddspapi.io\/blog\/tennis-elo-model-python\/\",\"name\":\"Tennis ELO Model in Python: Beat the Closing Line with Free Historical Odds | Odds API Development Blog\",\"isPartOf\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/tennis-elo-model-python\/#primaryimage\"},\"image\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/tennis-elo-model-python\/#primaryimage\"},\"thumbnailUrl\":\"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/05\/tennis-elo-model-python-2-scaled.webp\",\"datePublished\":\"2026-06-01T10:00:00+00:00\",\"breadcrumb\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/tennis-elo-model-python\/#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\/\/oddspapi.io\/blog\/tennis-elo-model-python\/\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\/\/oddspapi.io\/blog\/tennis-elo-model-python\/#primaryimage\",\"url\":\"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/05\/tennis-elo-model-python-2-scaled.webp\",\"contentUrl\":\"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/05\/tennis-elo-model-python-2-scaled.webp\",\"width\":2560,\"height\":1429,\"caption\":\"Tennis ELO Model in Python - OddsPapi API Blog\"},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\/\/oddspapi.io\/blog\/tennis-elo-model-python\/#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\/\/oddspapi.io\/blog\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"Tennis ELO Model in Python: Beat the Closing Line with Free Historical Odds\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\/\/oddspapi.io\/blog\/#website\",\"url\":\"https:\/\/oddspapi.io\/blog\/\",\"name\":\"OddsPapi\",\"description\":\"Sports Odds APIs Tutorials &amp; Guides\",\"publisher\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/#organization\"},\"alternateName\":\"Odds Papi\",\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\/\/oddspapi.io\/blog\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"},{\"@type\":\"Organization\",\"@id\":\"https:\/\/oddspapi.io\/blog\/#organization\",\"name\":\"OddsPapi\",\"url\":\"https:\/\/oddspapi.io\/blog\/\",\"logo\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\/\/oddspapi.io\/blog\/#\/schema\/logo\/image\/\",\"url\":\"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2025\/11\/oddspapi.png\",\"contentUrl\":\"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2025\/11\/oddspapi.png\",\"width\":135,\"height\":135,\"caption\":\"OddsPapi\"},\"image\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/#\/schema\/logo\/image\/\"},\"sameAs\":[\"https:\/\/x.com\/oddspapiapi\"]},{\"@type\":\"Person\",\"@id\":\"https:\/\/oddspapi.io\/blog\/#\/schema\/person\/b6f21e649c4f556f0a95c23a0f1efa13\",\"name\":\"Odds API Writer\",\"image\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\/\/oddspapi.io\/blog\/#\/schema\/person\/image\/\",\"url\":\"https:\/\/secure.gravatar.com\/avatar\/33b204f24af3d02e35b25ae730c0536121ca6a783fdb196e7611c9e49fcd13eb?s=96&d=mm&r=g\",\"contentUrl\":\"https:\/\/secure.gravatar.com\/avatar\/33b204f24af3d02e35b25ae730c0536121ca6a783fdb196e7611c9e49fcd13eb?s=96&d=mm&r=g\",\"caption\":\"Odds API Writer\"},\"url\":\"https:\/\/oddspapi.io\/blog\/author\/andy-lavelle\/\"}]}<\/script>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"Tennis ELO Model in Python: Beat the Closing Line with Free Historical Odds | Odds API Development Blog","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/oddspapi.io\/blog\/tennis-elo-model-python\/","og_locale":"en_US","og_type":"article","og_title":"Tennis ELO Model in Python: Beat the Closing Line with Free Historical Odds | Odds API Development Blog","og_description":"Build a surface-weighted tennis ELO model in Python and benchmark it against Pinnacle's de-vigged closing line using OddsPapi's free historical odds.","og_url":"https:\/\/oddspapi.io\/blog\/tennis-elo-model-python\/","og_site_name":"Odds API Development Blog","article_published_time":"2026-06-01T10:00:00+00:00","og_image":[{"width":2560,"height":1429,"url":"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/05\/tennis-elo-model-python-2-scaled.webp","type":"image\/webp"}],"author":"Odds API Writer","twitter_card":"summary_large_image","twitter_image":"https:\/\/oddspapi.io\/logo-v2.webp","twitter_creator":"@oddspapiapi","twitter_site":"@oddspapiapi","twitter_misc":{"Written by":"Odds API Writer","Est. reading time":"11 minutes"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"Article","@id":"https:\/\/oddspapi.io\/blog\/tennis-elo-model-python\/#article","isPartOf":{"@id":"https:\/\/oddspapi.io\/blog\/tennis-elo-model-python\/"},"author":{"name":"Odds API Writer","@id":"https:\/\/oddspapi.io\/blog\/#\/schema\/person\/b6f21e649c4f556f0a95c23a0f1efa13"},"headline":"Tennis ELO Model in Python: Beat the Closing Line with Free Historical Odds","datePublished":"2026-06-01T10:00:00+00:00","mainEntityOfPage":{"@id":"https:\/\/oddspapi.io\/blog\/tennis-elo-model-python\/"},"wordCount":1612,"commentCount":0,"publisher":{"@id":"https:\/\/oddspapi.io\/blog\/#organization"},"image":{"@id":"https:\/\/oddspapi.io\/blog\/tennis-elo-model-python\/#primaryimage"},"thumbnailUrl":"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/05\/tennis-elo-model-python-2-scaled.webp","keywords":["Backtesting","Free API","historical odds","Odds API","Python"],"articleSection":["How To Guides"],"inLanguage":"en-US","potentialAction":[{"@type":"CommentAction","name":"Comment","target":["https:\/\/oddspapi.io\/blog\/tennis-elo-model-python\/#respond"]}]},{"@type":"WebPage","@id":"https:\/\/oddspapi.io\/blog\/tennis-elo-model-python\/","url":"https:\/\/oddspapi.io\/blog\/tennis-elo-model-python\/","name":"Tennis ELO Model in Python: Beat the Closing Line with Free Historical Odds | Odds API Development Blog","isPartOf":{"@id":"https:\/\/oddspapi.io\/blog\/#website"},"primaryImageOfPage":{"@id":"https:\/\/oddspapi.io\/blog\/tennis-elo-model-python\/#primaryimage"},"image":{"@id":"https:\/\/oddspapi.io\/blog\/tennis-elo-model-python\/#primaryimage"},"thumbnailUrl":"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/05\/tennis-elo-model-python-2-scaled.webp","datePublished":"2026-06-01T10:00:00+00:00","breadcrumb":{"@id":"https:\/\/oddspapi.io\/blog\/tennis-elo-model-python\/#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/oddspapi.io\/blog\/tennis-elo-model-python\/"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/oddspapi.io\/blog\/tennis-elo-model-python\/#primaryimage","url":"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/05\/tennis-elo-model-python-2-scaled.webp","contentUrl":"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/05\/tennis-elo-model-python-2-scaled.webp","width":2560,"height":1429,"caption":"Tennis ELO Model in Python - OddsPapi API Blog"},{"@type":"BreadcrumbList","@id":"https:\/\/oddspapi.io\/blog\/tennis-elo-model-python\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/oddspapi.io\/blog\/"},{"@type":"ListItem","position":2,"name":"Tennis ELO Model in Python: Beat the Closing Line with Free Historical Odds"}]},{"@type":"WebSite","@id":"https:\/\/oddspapi.io\/blog\/#website","url":"https:\/\/oddspapi.io\/blog\/","name":"OddsPapi","description":"Sports Odds APIs Tutorials &amp; Guides","publisher":{"@id":"https:\/\/oddspapi.io\/blog\/#organization"},"alternateName":"Odds Papi","potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/oddspapi.io\/blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":"Organization","@id":"https:\/\/oddspapi.io\/blog\/#organization","name":"OddsPapi","url":"https:\/\/oddspapi.io\/blog\/","logo":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/oddspapi.io\/blog\/#\/schema\/logo\/image\/","url":"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2025\/11\/oddspapi.png","contentUrl":"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2025\/11\/oddspapi.png","width":135,"height":135,"caption":"OddsPapi"},"image":{"@id":"https:\/\/oddspapi.io\/blog\/#\/schema\/logo\/image\/"},"sameAs":["https:\/\/x.com\/oddspapiapi"]},{"@type":"Person","@id":"https:\/\/oddspapi.io\/blog\/#\/schema\/person\/b6f21e649c4f556f0a95c23a0f1efa13","name":"Odds API Writer","image":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/oddspapi.io\/blog\/#\/schema\/person\/image\/","url":"https:\/\/secure.gravatar.com\/avatar\/33b204f24af3d02e35b25ae730c0536121ca6a783fdb196e7611c9e49fcd13eb?s=96&d=mm&r=g","contentUrl":"https:\/\/secure.gravatar.com\/avatar\/33b204f24af3d02e35b25ae730c0536121ca6a783fdb196e7611c9e49fcd13eb?s=96&d=mm&r=g","caption":"Odds API Writer"},"url":"https:\/\/oddspapi.io\/blog\/author\/andy-lavelle\/"}]}},"_links":{"self":[{"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/posts\/2938","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/comments?post=2938"}],"version-history":[{"count":1,"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/posts\/2938\/revisions"}],"predecessor-version":[{"id":2941,"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/posts\/2938\/revisions\/2941"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/media\/2942"}],"wp:attachment":[{"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/media?parent=2938"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/categories?post=2938"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/tags?post=2938"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}