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_time in 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) and set_scores, with retirements and walkovers marked.
  • opening_avg_p1 / p2 and closing_avg_p1 / p2 across bookmakers, closing_best_p1 / p2, implied_prob_p1 / p2 with the margin removed, and the overround.
  • 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: 30 and compare your model's win chance with implied_prob_p1, scored on winner. 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_odds gives 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: upcoming mode with daysAhead: 1, incremental: true and a schedule every few hours returns only newly scheduled matches; a Slack webhook URL in slackWebhookUrl posts a short digest.
  • Player watchlist: list surnames in players and set bookmakerOdds: false for 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. onlyWithOdds skips 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.

Links