If you bet on tennis or build a model for it, the number that tells you whether you're any good is not your win rate, it's how your prices compare with the market's closing price. Beating the close consistently is the usual sign of an edge; losing to it while winning is usually luck. To measure it you need, for every match you touched, the closing odds across bookmakers, not just the one price your bookmaker showed.
This post builds that check in Python from a daily results feed, then compares the tennis data tools on the Apify Store using the prices their Store pages listed at the time of writing.
Disclosure: I'm Aurelix, and I built the Tennis Odds Scraper. It's a paid tool (prices below). Check each Store page before you buy: prices change.
What each match row holds
The source is TennisExplorer's public match lists and match pages. One row per match, ATP, WTA, Challenger, ITF and UTR, singles and doubles:
-
match_id(stable across runs),date,start_timein UTC,status,tournament,category,round,surface. - Both players with full name, seed and current singles ranking;
winner,sets_p1/sets_p2,score(7-6(3) 6-4) andset_scores, with retirements and walkovers marked. -
opening_avg_p1/p2andclosing_avg_p1/p2across bookmakers,closing_best_p1/p2,implied_prob_p1/p2with the margin removed, and theoverround. -
bookmaker_odds: for each listed bookmaker (typically 10 to 15 at ATP/WTA and Challenger level), its opening and closing price, when each was set, and how many times it moved.
Two modes: results for finished matches (days back, or a date range of up to 92 days per run) and upcoming for matches not started yet, with the latest prices.
Your bets against the closing line
Keep a bet log as a CSV with the surname of the player you backed and the odds you took:
player,odds_taken
Fils,1.62
Michelsen,4.80
pip install apify-client
import csv
import os
from apify_client import ApifyClient
client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor("aurelix/tennis-odds-scraper").call(
run_input={
"mode": "results",
"daysBack": 1,
"categories": ["atp", "wta"],
"matchType": "singles",
"onlyWithOdds": True,
}
)
matches = list(client.dataset(run["defaultDatasetId"]).iterate_items())
def find(surname):
s = surname.lower()
for m in matches:
for side in ("p1", "p2"):
if m["player1" if side == "p1" else "player2"].lower().startswith(s):
return m, side
return None, None
clv = []
with open("bets.csv", encoding="utf-8") as f:
for bet in csv.DictReader(f):
m, side = find(bet["player"])
if not m or not m[f"closing_avg_{side}"]:
print(f"{bet['player']}: no priced match found")
continue
taken, close = float(bet["odds_taken"]), m[f"closing_avg_{side}"]
won = m["winner"] == ("player1" if side == "p1" else "player2")
clv.append(taken / close - 1)
print(f"{m['title']} ({m['tournament']}, {m['round']}): took {taken}, closed {close:.2f}, "
f"best close {m[f'closing_best_{side}']}, {'won' if won else 'lost'}, CLV {taken / close - 1:+.1%}")
if clv:
print(f"Average closing-line value over {len(clv)} bets: {sum(clv) / len(clv):+.1%}")
A positive average means you got better prices than the market settled on. Run it once a day after your bets settle, or schedule it in the Apify Console and keep the rows.
Other jobs the same rows do:
-
Backtests: pull a month of finished ATP singles with
daysBack: 30and compare your model's win chance withimplied_prob_p1, scored onwinner. Rankings are the players' current ones, not as of the match, so don't use them as model inputs for old matches. -
Line movement:
bookmaker_oddsgives each bookmaker's opening and closing price with times, so you can see which books moved first and by how much. -
Order of play with prices:
upcomingmode withdaysAhead: 1,incremental: trueand a schedule every few hours returns only newly scheduled matches; a Slack webhook URL inslackWebhookUrlposts a short digest. -
Player watchlist: list surnames in
playersand setbookmakerOdds: falsefor a fast run that reads only the day lists.
Tennis data tools on the Apify Store compared
Prices per 1,000 matches from each Store page at the time of writing:
| Aurelix Tennis Odds Scraper | crawlstone/tennis-scraper | extractify-labs/flashscore-tennis-matches | parseforge/tennisexplorer-scraper | |
|---|---|---|---|---|
| Free plan | $3.00 | $8.00 | $1.00 | $7.50 |
| Bronze | $2.50 | $7.36 | $1.00 | $7.26 |
| Gold | $2.50 | $6.00 | $1.00 | $6.79 |
| Start fee per run | $0.005 | none | $0.00005 | $0.005 |
| Bookmaker odds | 10-15 bookmakers per match, opening and closing with times | one odds pair (SofaScore) | none | per-bookmaker odds not listed |
| Implied probabilities, margin removed | yes | no | no | not stated |
| Results for past dates | up to 92 days per run | by date | recent days | by date and tour |
| Only-new mode with Slack or webhook digest | yes | no | no | not stated |
| Live scores, point-by-point or match statistics | no | live scores and point-by-point | live game score | match statistics |
Where each wins: crawlstone and extractify-labs are by far the most used, and both cover live matches, which this tool doesn't; crawlstone adds point-by-point data. extractify-labs is the cheapest per match if you need results and fixtures without odds. parseforge reads the same source as this tool and adds match statistics such as aces and double faults. The Aurelix tool fits betting and modelling work: every listed bookmaker's opening and closing price with times, implied probabilities with the margin removed, finished results over a date range, and an only-new feed for schedules.
Limits
- One source, TennisExplorer: matches, bookmakers and odds as it shows them. Match-winner (home/away) odds only: no handicaps, totals or in-play odds.
- No live scores. Matches in progress are skipped until they finish.
-
Many ITF and UTR matches have few or no bookmakers.
onlyWithOddsskips unpriced ones. - Round, surface, full names and rankings come from match pages (on by default); rankings are current, not as of the match date.
- UTC days, at most 92 days per run, about 3 match pages a second: 1,000 matches take roughly 5 to 8 minutes.
Cost
You pay per match written to the dataset. Matches outside your filters, matches already returned in only-new mode and anything past maxResults are free, so a repeat run with nothing new costs only the start fee. The current price per match is on the Store page; Apify's free plan includes $5 of credit a month, about 1,600 matches.
To compare search interest around a player or tournament with the market, the Google Trends by Region tool takes player or tournament names as keywords.
Top comments (0)