Two numbers can describe the same tennis match and still be answering different questions. A historical Elo model summarizes past results. A bookmaker price incorporates a market, a margin, timing and information that may never appear in a match-result table.
Their disagreement is worth investigating. It is not, by itself, evidence that one of them is wrong.
We'll build a chart that makes those disagreements visible, then use player history to turn the biggest gap into a research question.
Disclosure: I maintain the CrawlPlant Actors used here. This article demonstrates data analysis, not a tested betting strategy. No profitability result is claimed.
Run this project: Get the code, sample data and finished outputs on GitHub. To fetch your own data, use the live recipe and follow the .env setup guide to add your APIFY_TOKEN. Actor calls can incur charges; the setup explains per-run limits.
Keep the player orientation fixed
Both probabilities must refer to the same player. player1EloWinProbability and odds.player1Probability do; a winner-oriented dataset does not have the same orientation.
Install apify-client, set APIFY_TOKEN, and use this helper. Calling an Actor can incur charges; set your run spending limit in Apify. Odds and form can add charges beyond the base match row.
import os
import json
import math
from pathlib import Path
def call_actor(slug, run_input):
from apify_client import ApifyClient
client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor(f"crawlplant/{slug}").call(run_input=run_input)
if not run or run["status"] != "SUCCEEDED":
raise RuntimeError(f"Actor did not succeed: {run and run['status']}")
rows = list(client.dataset(run["defaultDatasetId"]).iterate_items())
if len(rows) >= run_input["maxItems"]:
raise RuntimeError("Result reached maxItems; narrow the range or increase the cap before claiming completeness.")
return rows
For a forward-looking watchlist, request scheduled matches:
rows = call_actor("flashscore-tennis", {
"mode": "matches",
"dateFrom": "tomorrow",
"status": "scheduled",
"tours": ["atp", "wta"],
"includeOdds": True,
"includeH2H": False,
"oddsCountry": "GB",
"maxItems": 200,
})
The odds geography affects available bookmakers. Keep it fixed when comparing snapshots. If no matches or no odds are available, an empty shortlist is a legitimate result.
Compare probabilities, not decimal prices
For a single bookmaker with two decimal prices, a simple proportional margin adjustment is:
p1 = (1 / odds1) / ((1 / odds1) + (1 / odds2))
The Actor's player1Probability is a consensus of these adjusted probabilities across available bookmakers. This removes the quoted overround using one specific method; it does not make the number an objective truth about the match. Do not replace it with the reciprocal of the best price from each of two different bookmakers.
def market_comparison(rows):
out = []
for row in rows:
elo = row.get("player1EloWinProbability")
market = (row.get("odds") or {}).get("player1Probability")
if elo is None or market is None:
continue
if not all(isinstance(p, (int, float)) and math.isfinite(p) and 0 <= p <= 1 for p in (elo, market)):
raise ValueError("Probabilities must be finite numbers between zero and one")
out.append({"matchId": row["matchId"], "player1": row["player1Name"], "player2": row["player2Name"],
"elo": elo, "market": market, "gap_pp": 100 * (elo - market), "status": row.get("status")})
return sorted(out, key=lambda x: abs(x["gap_pp"]), reverse=True)
shortlist = market_comparison(rows)
for row in shortlist[:8]:
print(row["player1"], "vs", row["player2"],
f"Elo {row['elo']:.1%}", f"market {row['market']:.1%}",
f"gap {row['gap_pp']:+.1f} percentage points")

The illustration uses an existing October 22, 2026 Actor result, not the scheduled-match request above. Eighteen of its 20 rows have both probabilities. In that saved data, Machac versus Khachanov has 40.34% from Elo and 30.74% from the market for Machac: a 9.60 percentage-point gap.
These are finished-match records. The chart is a retrospective comparison of stored pre-match Elo and retrieved post-match odds. It must not be presented as a watchlist that was available before those matches began. Closing prices may have been frozen at different times by different bookmakers.
Investigate a gap with a second Actor
An unusual gap suggests questions: was there little recent play, a surface change, or a retirement in the player's recent history?
history = call_actor("tennis-match-history", {
"playerUrls": ["Tomas Machac"],
"status": "finished",
"maxItems": 2000,
})
# For this historical example, keep only matches from earlier UTC days.
# This deliberately excludes the target day rather than relying on an unknown start time.
past = [m for m in history if m.get("date") and m["date"] < "2026-10-22"]
recent = sorted(past, key=lambda m: m.get("startTime") or m["date"], reverse=True)[:10]
for match in recent:
print(match["date"], match["surface"], match["winnerName"], match["score"])
That history provides context, not a causal explanation. Avoid writing “the market moved because of fatigue” unless you have evidence connecting the two. A player who appears tired in a table may have had a very different schedule in reality.
The time dimension is part of the data
For a real pre-match study, save your own timestamped snapshots before the start, along with the Actor input and the returned prices. Evaluate predictions on later matches that were not used to choose your rules. Historical corrections can also revise a reconstructed rating, so distinguish a current reconstruction from an archived prediction.
Use Flashscore Tennis for the comparison and Tennis Match History for follow-up. For a match whose scoreline still leaves questions, move to point-by-point. The useful output is a prioritized set of things to investigate, with the timestamps and missing data still attached.
Follow one actual disagreement into history
For Machac–Khachanov, the saved comparison is 40.34% Elo versus 30.74% market for Machac, a +9.60 percentage-point gap. A separate cached-history read, retrieved on October 23, supplies these three earlier records:
| Date | Tournament | Recorded score, source player order | Match |
|---|---|---|---|
| 2026-10-21 | Almaty | 2–6 0–6 | vXZBea7K |
| 2026-10-16 | Jinan | 4–6 6–3 4–6 | pG7nqUH5 |
| 2026-10-15 | Jinan | 6–2 6–1 | xnIWM9Qs |
The follow-up uses the resolved player ID S44E7UYr and keeps only completed matches with recorded end times before October 22 UTC. These records are questions to inspect, not an explanation of the price gap. The source includes both participants and the winner; do not read a source-oriented score as automatically being Machac's score.
The companion renders the complete comparison chart to assets/04-elo-market.png and .svg, and saves the checked history in reports/market-followup.md and .json. No new Apify run was used for this supplemental cached read.
Download and reproduce the example
Download the offline companion ZIP. Extract it, open a terminal in that folder and run:
python -m pip install -r requirements.txt
python render.py
The package includes the dated sample data, calculation functions, complete chart renderers and finished reports. It generates the series' charts and reports together; no API token or paid Actor/model call is required. The live-fetch code earlier in this article is a separate option and can produce different counts.
Inspect the sources: chart renderer, AI charts, report generation, and data notes. Both PNG and SVG outputs are written to assets/; reports go to reports/.
Keep building: Browse all 48 runnable examples, including Python, JavaScript and AI workflows across the CrawlPlant Actors.
Built with CrawlPlant's tennis Actors. Examples use dated source snapshots; fresh runs can return different results.
Explore the complete series →

