Bet Better BET BETTER PRO

R at Bet Better

How often tennis favourites win, by surface, in R

2 November 2026. Base R plus ggplot2.

How often does the favourite win on hard courts, clay and grass? A table answers that in a few lines of R. Whether surface is the reason is a harder question, and this data shows why: the raw table has a clear leader that fades once you account for one other column. The data is two open CSVs of closing prices and results, written up in the ATP favourites study and the WTA favourites study.

1. Load both tours

Same columns in both files, so rbind() works once each has a tour label.

base <- paste0("https://raw.githubusercontent.com/edushinka/sports-closing-price-studies/",
               "main/data/")
atp <- read.csv(paste0(base, "atp_2024_2026_closing_prices_vs_results.csv"))
wta <- read.csv(paste0(base, "wta_2024_2026_closing_prices_vs_results.csv"))
atp$tour <- "ATP"; wta$tour <- "WTA"
m <- rbind(atp, wta)
nrow(m)
table(m$tour, m$surface)
[1] 3499
     
           Clay Grass Hard
  ATP   27  401   139 1155
  WTA    8  340   145 1284

The first column is blank: 35 matches have no surface recorded, so they go. Note the coverage too. These are Grand Slams and big tour events from January 2024 to October 2026, not every match on either tour.

2. Fold each match to the favourite

Tennis has no home side. In these files home_team is just the alphabetically first player, so the probability has to be turned around whenever that player is the underdog.

m <- subset(m, surface != "")
m$surface <- factor(m$surface, levels = c("Hard", "Clay", "Grass"))
p <- m$home_no_vig_close_prob
m$fav_p   <- pmax(p, 1 - p)
m$fav_won <- ifelse(p >= 0.5, m$home_won, 1 - m$home_won)
c(matches = nrow(m), priced = round(mean(m$fav_p), 3), won = round(mean(m$fav_won), 3))
 matches   priced      won 
3464.000    0.695    0.712 

Across the 3,464 matches, favourites were priced at 69.5 per cent and won 71.2 per cent.

3. The table by surface

A small helper keeps the next few tables short. Pass it any grouping columns.

tab <- function(...) {
  g <- list(...)
  a <- aggregate(m[c("fav_p", "fav_won")], g, mean)
  a$matches <- aggregate(m$fav_won, g, length)$x
  a$gap <- a$fav_won - a$fav_p
  a[c("fav_p", "fav_won", "gap")] <- round(a[c("fav_p", "fav_won", "gap")], 3)
  a
}
tab(surface = m$surface)
  surface fav_p fav_won matches   gap
1    Hard 0.691   0.701    2439 0.010
2    Clay 0.704   0.735     741 0.031
3   Grass 0.707   0.743     284 0.036

Favourites won 70.1 per cent on hard courts, 73.5 per cent on clay and 74.3 per cent on grass. Grass comes top, and the gap to the price is widest there too.

4. Split by tour

The helper takes two groups as easily as one.

tab(tour = m$tour, surface = m$surface)
  tour surface fav_p fav_won matches    gap
1  ATP    Hard 0.689   0.704    1155  0.015
2  WTA    Hard 0.693   0.698    1284  0.005
3  ATP    Clay 0.697   0.728     401  0.031
4  WTA    Clay 0.712   0.744     340  0.032
5  ATP   Grass 0.728   0.806     139  0.078
6  WTA   Grass 0.686   0.683     145 -0.003

The grass number is almost all men. ATP grass favourites won 80.6 per cent of 139 matches; WTA grass favourites won 68.3 per cent of 145, right on their price. On hard and clay the two tours look alike.

5. How wide are the intervals?

Exact binomial intervals from binom.test() for the win rate on each surface.

ci <- t(sapply(split(m$fav_won, m$surface),
               function(x) binom.test(sum(x), length(x))$conf.int))
round(ci, 3)
       [,1]  [,2]
Hard  0.682 0.719
Clay  0.702 0.767
Grass 0.688 0.793

Grass runs from 68.8 to 79.3 per cent, which overlaps both other surfaces. With 284 matches there is not much to separate.

6. Does surface add anything to the price?

The raw table has a flaw: favourites on different surfaces were not priced the same. An offset logistic regression allows for that, because each match starts from its own closing price and the surface terms only pick up what the price missed. Comparing the model with and without surface is a likelihood-ratio test.

base_fit <- glm(fav_won ~ 1 + offset(qlogis(fav_p)), binomial, data = m)
surf_fit <- glm(fav_won ~ surface + offset(qlogis(fav_p)), binomial, data = m)
round(summary(surf_fit)$coefficients, 3)
anova(base_fit, surf_fit, test = "Chisq")
             Estimate Std. Error z value Pr(>|z|)
(Intercept)     0.048      0.046   1.051    0.293
surfaceClay     0.119      0.098   1.220    0.222
surfaceGrass    0.149      0.148   1.006    0.314
Analysis of Deviance Table

Model 1: fav_won ~ 1 + offset(qlogis(fav_p))
Model 2: fav_won ~ surface + offset(qlogis(fav_p))
  Resid. Df Resid. Dev Df Deviance Pr(>Chi)
1      3463     3855.0                     
2      3461     3852.7  2   2.2101   0.3312

Clay and grass both lean positive, but neither term is close to significant, and the joint test gives p = 0.33.

7. The column that explains grass

Men play best of five at the Grand Slams and best of three nearly everywhere else. Grass here is almost entirely Wimbledon, so men's grass and best of five are nearly the same thing. A longer match gives the better player more time, which would lift favourites on any surface.

m$format <- paste0("best of ", m$best_of)
tab(format = m$format, surface = m$surface)
format_fit <- glm(fav_won ~ format + offset(qlogis(fav_p)), binomial, data = m)
full_fit <- glm(fav_won ~ format + surface + offset(qlogis(fav_p)), binomial, data = m)
round(summary(full_fit)$coefficients, 3)
anova(format_fit, full_fit, test = "Chisq")
     format surface fav_p fav_won matches    gap
1 best of 3    Hard 0.683   0.690    1981  0.007
2 best of 5    Hard 0.727   0.749     458  0.022
3 best of 3    Clay 0.696   0.721     544  0.025
4 best of 5    Clay 0.727   0.777     197  0.050
5 best of 3   Grass 0.687   0.685     146 -0.002
6 best of 5   Grass 0.728   0.804     138  0.077
                Estimate Std. Error z value Pr(>|z|)
(Intercept)        0.022      0.048   0.456    0.648
formatbest of 5    0.155      0.099   1.569    0.117
surfaceClay        0.107      0.098   1.095    0.273
surfaceGrass       0.105      0.151   0.697    0.486
Analysis of Deviance Table

Model 1: fav_won ~ format + offset(qlogis(fav_p))
Model 2: fav_won ~ format + surface + offset(qlogis(fav_p))
  Resid. Df Resid. Dev Df Deviance Pr(>Chi)
1      3462     3851.8                     
2      3460     3850.3  2   1.4932    0.474

Best-of-three grass favourites won 68.5 per cent against a 68.7 per cent price, so the whole grass effect sits in the best-of-five row. With format in the model the surface test moves to p = 0.47, and best of five carries the largest term at 0.155. That term is not significant either, at p = 0.12, so the fair reading is modest: this sample does not show surface moving results beyond the price, and format is the better lead to follow as more matches come in.

8. Plot it

Points above the dashed line won more than priced.

library(ggplot2)
s <- tab(surface = m$surface)
ggplot(s, aes(fav_p, fav_won, label = surface)) +
  geom_abline(slope = 1, intercept = 0, linetype = 2) +
  geom_point(aes(size = matches)) +
  geom_text(vjust = -1) +
  coord_equal(xlim = c(0.6, 0.8), ylim = c(0.6, 0.8)) +
  labs(x = "Priced", y = "Won", size = "Matches",
       title = "Tennis favourites by surface, 2024 to 2026")

Where the data comes from

Both CSVs are in the sports-closing-price-studies repository on GitHub, CC BY 4.0, next to a base R script that reproduces the favourite bands. Prices are closing match-winner prices with each book's margin removed, averaged across books. To turn a single price of your own into a probability, use the implied probability calculator. The rest of the R series is on R at Bet Better.

18+. Analysis, not advice.

Get tomorrow's free pick by email

Free. One email a day, tops. Unsubscribe in one tap.

18+ only. Please gamble responsibly.