DEV Community

Elio Liberatore
Elio Liberatore

Posted on

Projecting a tennis match while it's still being played, without point-by-point data

The problem: ESPN gives you a score, not a probability

Public tennis data is thin compared to what's available for team sports. ESPN's tennis API returns the score of every ATP/WTA match, live or finished — sets won, current game, current point when it bothers to update — but nothing that looks like win probability, and no point-by-point log you could replay.

That's a problem if you want to answer the question a live match actually raises: given the score right now, who wins? A pre-match number computed from rankings goes stale the moment the first point is played. You need something that reacts to the score without needing data ESPN simply doesn't expose.

Start with a number you can actually get: ranking points

Before a ball is hit, the only signal worth trusting is each player's current ATP or WTA ranking points — public, updated weekly, and already a decent proxy for recent form. A Bradley-Terry model turns the ratio of two players' points into a pre-match win probability:

p = pointsA^k / (pointsA^k + pointsB^k)
Enter fullscreen mode Exit fullscreen mode

k controls how much a ranking gap matters; k = 1 (plain ratio) turned out to minimize error against live Kalshi prices when I backtested it against five liquid ATP markets. A second parameter blends that raw probability toward a coin flip, because ranking points alone say nothing about surface, current form or head-to-head — the kind of thing a market prices in and a Bradley-Terry model on points cannot see.

That gets you a solid pre-match number. It does not get you a live one.

Turning a match probability into a set probability, backwards

Here's the trick: a Grand Slam player up two sets to love is not simply "60% to win the match" scaled up. What actually changes when a set is won is the number of sets still needed, not some vague match-level confidence score. So the real quantity to simulate isn't "the match" — it's "the sets that are left."

That means I need a per-set win probability, not a per-match one. And I only have the match one.

The fix is to invert it. For a best-of-three match, the probability of winning the match given a per-set probability q has a closed form:

P(match | q) = q² + 2·q²·(1 − q)
Enter fullscreen mode Exit fullscreen mode

(win in two sets, or win the decider after splitting the first two.) I already know P(match) from the Bradley-Terry step — call it p. So I solve for q such that P(match | q) = p, by bisection: pick a q in [0, 1], compute P(match | q), and narrow the interval until it converges on p. A dozen iterations gets you machine precision; there's no need for anything fancier.

function invertSetProbability(matchProb, matchFn, lo = 0, hi = 1) {
  for (let i = 0; i < 40; i++) {
    const mid = (lo + hi) / 2;
    if (matchFn(mid) < matchProb) lo = mid; else hi = mid;
  }
  return (lo + hi) / 2;
}
Enter fullscreen mode Exit fullscreen mode

Best-of-five gets the same treatment with its own (longer) closed form. Now I have a per-set probability that, run back through the formula, reproduces the pre-match number exactly — which means it's the right one to carry into the live simulation.

From set probability to a live score

Once a match is actually in progress, the question becomes simple: from the current score — sets won by each player — how many more sets does each need, and what's the chance of winning that many more out of what's left, at probability q per set? That's just a binomial tail, and I compute it with a quick Monte Carlo simulation (thousands of trials of "flip the coin at probability q until someone reaches the target") rather than deriving a closed form for every possible remaining-sets combination — simpler to write, and cheap enough to run per match.

The result: a player already up one set shows a higher live probability than their pre-match number, without ever touching point-by-point data ESPN doesn't provide. The signal is coarse — set-level, not point-level — but it's honest about what it does and doesn't know, and it's free.

The format detail that almost broke best-of-five

One wrinkle: ESPN's own periods field, which should say whether a match is best-of-three or best-of-five, is unreliable — it reported 5 even for an ATP 250 event that's best-of-three. Trusting it would have inverted the wrong formula for most matches. The fix was to ignore it and detect the format from the tournament name instead: best-of-five only for the four ATP Grand Slams, best-of-three everywhere else — including the WTA majors, which are best-of-three regardless of prestige. A field that lies less than 50% of the time is worse than no field at all, because it's confident about being wrong.

A production crash that unit tests didn't catch

The model and the live projection both checked out against static fixtures — 29 passing tests. The first real end-to-end run against live ESPN and Kalshi data crashed 55 matches in, with a schema validation error on an undefined player name.

The cause: ESPN's tennis scoreboard mixes doubles draws into the same feed as singles, undifferentiated by any simple flag. In a doubles match, each side is a team/roster object, not an athlete — so the code path that reads athlete.displayName returned undefined for every doubles competitor, and JSON.stringify silently drops undefined values, which then failed a required field check downstream. Unlike ESPN's own explicit "TBD" placeholder for a genuinely unresolved future-round singles match, this wasn't a "no data yet" case — it was a shape mismatch that looked like missing data until you traced it back.

The fix was two-fold: skip doubles groups (checked by both the group label and the shape of the competitor side, since either can be wrong on its own), and make sure the name-reading function always returns a string, falling back to "TBD" only for a genuinely absent name. Two regression tests were added reproducing the exact draw shape that crashed. Static fixtures had been drawn from a single real match capture; the actual scoreboard on a random Tuesday has draws unit tests never saw. Rankings and prediction markets are singles-only anyway, so doubles rows were never wanted in the output — but "not wanted" and "safe to silently produce broken JSON for" are different bugs, and only one of them showed up until a real run hit it.

What this adds up to

Nothing here needs anything ESPN doesn't already give away for free: rankings, a live score, set counts. The engineering is in turning a static pre-match number into something that reacts to a live score without inventing data that isn't there — bisection to get from "match probability" to "set probability," a small Monte Carlo to get from "set probability" back to "match probability, live" — and in not trusting a field just because it exists.


The model behind this is the Tennis Match Winner Monte Carlo Actor — live and upcoming ATP/WTA singles matches, priced against Kalshi's KXATPMATCH/KXWTAMATCH prediction markets.

Top comments (0)