A tennis model or a betting system is only as good as the backtest behind it, and a backtest needs the market's closing prices for the matches you test on. Daily feeds give you that from now on; for last year's Wimbledon or the 2023 Paris Masters you need the archive. BetExplorer keeps one public results page per tournament season with the closing odds of every match, and each match page lists the closing price of every bookmaker it tracked.
This post turns those pages into rows in Python, checks how often the closing favourite won, and notes what the archive does and doesn't contain.
Disclosure: I'm Aurelix, and I built the Tennis Odds Scraper, which reads these pages in its History mode. It's a paid tool (prices below).
Finding a season
On betexplorer.com, go to Tennis, pick the tour (ATP Singles, WTA Singles, Challenger...), the tournament and Results, then choose the year. Past seasons have their own path:
/tennis/atp-singles/paris-2023/results/
/tennis/wta-singles/wimbledon-2023/results/
Use the path, not a ?year= link: BetExplorer's robots.txt asks bots not to fetch those query URLs, so the tool reads seasons by path only (a ?year=2023 link you paste is rewritten to the -2023 path).
Every match of a season with closing odds
pip install apify-client
import os
from apify_client import ApifyClient
client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor("aurelix/tennis-odds-scraper").call(
run_input={
"mode": "history",
"seasons": [
"/tennis/atp-singles/paris-2023/results/",
"/tennis/wta-singles/wimbledon-2023/results/",
],
"bookmakerOdds": True,
"maxResults": 500,
}
)
rows = list(client.dataset(run["defaultDatasetId"]).iterate_items())
print(len(rows), "matches")
Each row is one match:
-
tournament("Paris 2023"),round("Final", "Semi-finals", "1/8-finals"...),date,category,match_type. -
player1/player2as BetExplorer lists them ("Djokovic N."), and with match pages onplayer1_full_name,player1_url,score(6-4 6-3) andset_scores. -
status: finished, retired, walkover or canceled (keep onlyfinishedfor win/loss stats, since books settle retirements differently);winner,winner_name,sets_p1/sets_p2. -
odds_p1/odds_p2: BetExplorer's average closing odds;implied_prob_p1/p2with the margin removed and theoverround. -
bookmaker_odds: each listed bookmaker's closing price and the time it was set;closing_avg_*andclosing_best_*across them.
In the 2023 Paris final, for example, BetExplorer's average closing odds were 1.33 for Djokovic and 3.34 for Dimitrov, with 12 bookmakers listed; Djokovic won 6-4 6-3.
How often did the closing favourite win?
done = [r for r in rows if r["status"] == "finished"
and r["odds_p1"] and r["odds_p2"] and r["odds_p1"] != r["odds_p2"]]
fav = sum((r["odds_p1"] < r["odds_p2"]) == (r["winner"] == "player1") for r in done)
print(f"{fav} of {len(done)} matches ({fav / len(done):.0%}) went to the closing favourite")
On those two seasons it was 69% in Paris 2023 (36 of 52 priced matches) and 72% at Wimbledon 2023 (women's singles, 77 of 107). Only fully finished matches count: retirements are left out because bookmakers settle them differently (some void the bet if a set isn't completed, others pay whoever advances), so their closing price doesn't map to a clean win or loss, and walkovers never had a match. Matches where both closing prices were equal have no favourite and are skipped too. Paris had no retirements; Wimbledon had 4, and the favourite advanced in 2 of them. That is the base rate any model has to beat, and the implied probabilities tell you how much the market already knew.
For a calibration check, bucket implied_prob_p1 and compare it with how often player 1 actually won in each bucket; with a few seasons of Grand Slams and Masters you have thousands of matches.
Several seasons into one table
import csv
with open("closing_odds.csv", "w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(["tournament", "round", "date", "player1", "player2", "winner", "odds_p1", "odds_p2",
"best_p1", "best_p2", "bookmakers"])
for r in rows:
w.writerow([r["tournament"], r["round"], r["date"], r["player1"], r["player2"], r["winner_name"],
r["odds_p1"], r["odds_p2"], r["closing_best_p1"], r["closing_best_p2"], r["bookmakers_count"]])
match_id (bx-l81rCxw4) is BetExplorer's own match code, so a second run of the same season returns the same IDs; with incremental on it returns nothing new and charges nothing for matches.
What the archive does not have
- Opening odds. The match pages show each bookmaker's closing price; the opening price and the moves in between are not in the archive pages this mode reads. For matches from now on, the tool's Results mode reads TennisExplorer, which has opening and closing per bookmaker.
- Surface and rankings. Not on these pages; join them from the tournament's surface and a ranking history if your model needs them.
- Names as listed. List names are "Surname I."; full names come from match pages (on by default).
- Times. The time each closing price was set is read as the site's Prague time and converted to UTC.
Cost
$3.00 per 1,000 matches ($2.50 on paid plans), per-bookmaker closing odds included, plus a $0.005 start fee per run. A Grand Slam draw is about 127 matches, so a season costs about $0.39 on the free plan; Apify's free plan includes $5 of credit a month. Matches outside your filters, beyond maxResults or already returned in only-new mode are not charged. That is cheaper than the leading tools, and bookmaker odds cost nothing extra.
Links
- Tennis Odds Scraper on the Apify Store
- Example repo
- The daily side of the same tool: tennis results with opening and closing odds from every bookmaker
Top comments (2)
Fwiw I'd split retired matches out of the favourite and calibration counts. Books settle retirements differently (some void the bet if a set isn't finished, some pay whoever advances), so the closing price on those doesn't map to a clean win or loss. You already drop walkovers.
How many retirements were in the Paris and Wimbledon sets?
Good call, thanks. Retirements don't map to a clean win or loss when books settle them differently, so I've updated the post to count only finished matches (and to skip the few where both closing prices were equal, since there's no favourite).
Paris 2023 had no retirements (2 walkovers). Wimbledon 2023 women's had 4, and the closing favourite advanced in 2 of them. With finished matches only, the favourite won 69% in Paris (36 of 52) and 72% at Wimbledon (77 of 107). A few matches have also lost their closing prices on the source since my first run, so the totals moved slightly. Every row carries a status field (finished, retired, walkover, canceled), so it's a one-line filter.