Bet Better BET BETTER PRO

R at Bet Better › R for Betting-Market Data

Chapter 3. The bookmaker margin index

20 October 2026. Edward Glush. Code run in R 4.4.1 against the live open data on that day.

Chapters 1 and 2 worked on one market. This chapter uses a public file that applies the same sums to hundreds of thousands of markets a day, and asks a simple question: how big is the bookmaker's cut, and does it change?

Data and licence. Dataset: the Bet Better Margin Index, updated daily. CSV: https://betbetter.world/studies/margin-index?format=csv. Licence CC BY 4.0. Aggregates only: no prices and no bookmaker names. The file starts with comment lines that begin with #.

3.1 Load the file

The CSV starts with two comment lines: the title and the licence. Tell read.csv() to skip them with comment.char = "#". Then turn the date column into a real date.

u <- "https://betbetter.world/studies/margin-index?format=csv"
mi <- read.csv(u, comment.char = "#")
mi$as_of_date <- as.Date(mi$as_of_date)
str(mi)
range(mi$as_of_date)
## 'data.frame':	634 obs. of  8 variables:
##  $ as_of_date  : Date, format: "2026-07-01" ...
##  $ market_class: chr  "h2h" "h2h" "spreads" "totals" ...
##  $ shape       : chr  "2way" "3way" "2way" "2way" ...
##  $ markets_n   : int  3632 1763 2100 2561 2894 1767 1675 2093 2614 1861 ...
##  $ books_n     : int  32 37 30 31 32 38 30 31 32 37 ...
##  $ mean_pct    : num  5.05 6.88 4.92 5.45 5.04 ...
##  $ median_pct  : num  5 6.74 4.71 4.76 5 ...
##  $ dropped_pct : num  0.38 0.23 0 0 0.1 0.45 0 0 0.04 0.21 ...
## [1] "2026-07-01" "2026-10-19"

Each row is one day and one kind of market. The columns are:

  • market_class: head to head (h2h), spreads, totals, player_props or other.
  • shape: two outcomes (2way) or three (3way, a soccer match with a draw).
  • markets_n and books_n: how many markets and how many bookmakers went into that day's figure.
  • mean_pct and median_pct: the average and middle margin, in per cent, worked out as in chapter 1.
  • dropped_pct: the share of markets thrown out as broken (for example, a missing side).

There is no sport column. The open file is split by market type and shape only. If you need a sport split, the study page describes what is in each class, and chapter 5 has a file that does carry a sport column.

3.2 How many rows of each kind?

table(mi$market_class, mi$shape)
##               
##                2way 3way
##   h2h           111  111
##   other          83    0
##   player_props  107    0
##   spreads       111    0
##   totals        111    0

Most classes have a row for every day. The other class starts later, so it has fewer rows. Keep that in mind when you compare averages: a class that starts later covers a different stretch of the calendar.

3.3 Average margin by market type

Join class and shape into one label, then take the mean of the daily figures for each label.

mi$market <- paste(mi$market_class, mi$shape)
by_market <- aggregate(cbind(mean_pct, median_pct) ~ market, data = mi, FUN = mean)
by_market[, 2:3] <- round(by_market[, 2:3], 2)
by_market[order(by_market$mean_pct), ]
##              market mean_pct median_pct
## 1          h2h 2way     4.87       4.74
## 5      spreads 2way     5.31       4.91
## 6       totals 2way     5.99       5.77
## 3        other 2way     6.56       6.52
## 2          h2h 3way     7.52       7.28
## 4 player_props 2way     7.65       7.12

The pattern is clear. Two-way head-to-head markets carry the smallest cut, a little under 5 per cent. Spreads come next, then totals. Three-way soccer markets and player props carry the most, around 7.5 per cent. A bet on a player prop gives up about half as much again as a bet on a match winner.

That table gives every day the same weight. A day with few markets counts as much as a busy one. A weighted mean uses markets_n as the weight:

w <- tapply(mi$mean_pct * mi$markets_n, mi$market, sum) / tapply(mi$markets_n, mi$market, sum)
round(sort(w), 2)
##          h2h 2way      spreads 2way       totals 2way        other 2way 
##              4.96              5.38              6.14              6.63 
##          h2h 3way player_props 2way 
##              7.42              7.60 

The order stays the same. The figures move a little, because the count of markets grew a lot over the period, so later days carry more weight.

3.4 How steady is it?

A range tells you more than a mean. Here are the 10th, 50th and 90th percentiles of the daily figure for each market type.

tapply(mi$mean_pct, mi$market, function(x) round(quantile(x, c(.1, .5, .9)), 2))
## $`h2h 2way`
##  10%  50%  90% 
## 4.71 4.85 5.06 
## 
## $`h2h 3way`
##  10%  50%  90% 
## 7.14 7.56 7.83 
## 
## $`other 2way`
##  10%  50%  90% 
## 6.37 6.60 6.85 
## 
## $`player_props 2way`
##  10%  50%  90% 
## 7.43 7.66 7.81 
## 
## $`spreads 2way`
##  10%  50%  90% 
## 4.87 5.26 5.80 
## 
## $`totals 2way`
##  10%  50%  90% 
## 5.38 6.06 6.33 

Head-to-head two-way margins barely move: a band of about a third of a point. Spreads and totals swing more. That makes sense. The mix of sports in those classes changes through the year as seasons start and end, and different sports carry different margins.

3.5 By month

mi$month <- format(mi$as_of_date, "%Y-%m")
round(tapply(mi$mean_pct, list(mi$month, mi$market), mean), 2)
##         h2h 2way h2h 3way other 2way player_props 2way spreads 2way
## 2026-07     4.78     7.64       4.70              7.78         4.96
## 2026-08     4.82     7.56       6.59              7.71         5.65
## 2026-09     4.92     7.46       6.57              7.57         5.39
## 2026-10     5.03     7.35       6.79              7.48         5.20
##         totals 2way
## 2026-07        5.57
## 2026-08        6.22
## 2026-09        6.12
## 2026-10        6.09

A simple straight-line fit gives the drift per 30 days for one class:

h2 <- subset(mi, market == "h2h 2way")
fit <- lm(mean_pct ~ as.numeric(as_of_date), data = h2)
round(coef(fit)[2] * 30, 3)   # points per 30 days
## as.numeric(as_of_date) 
##                  0.081 

Read this with care. The number of bookmakers in the sample also changed over these months, and so did the mix of sports. A drift in the index can come from a change in who is in the sample, not a change in what any one bookmaker charges. The books_n column lets you check:

aggregate(books_n ~ month, data = h2, FUN = range)
##     month books_n.1 books_n.2
## 1 2026-07        31        52
## 2 2026-08        32        53
## 3 2026-09        45        68
## 4 2026-10        64        70

The panel grew from 31 to 52 books a day in July to 64 to 70 in October. So the small rise in the head-to-head figure may come from new books joining, not from any one book raising its prices. To study a single bookmaker you would need prices by book, which this open file does not include.

3.6 Plot it over time

Base graphics are enough. Draw an empty frame, then one line per market type.

cols <- c("h2h 2way" = "#1f77b4", "h2h 3way" = "#d62728",
          "spreads 2way" = "#2ca02c", "totals 2way" = "#9467bd",
          "player_props 2way" = "#ff7f0e", "other 2way" = "#7f7f7f")
plot(NULL, xlim = range(mi$as_of_date), ylim = range(mi$mean_pct),
     xlab = "Date", ylab = "Average margin (%)",
     main = "Bookmaker margin by market type", xaxt = "n")
axis.Date(1, mi$as_of_date)
for (m in names(cols)) {
  d <- mi[mi$market == m, ]
  lines(d$as_of_date, d$mean_pct, col = cols[m], lwd = 2)
}
legend("topright", legend = names(cols), col = cols, lwd = 2, bty = "n", cex = 0.8)

The chart shows six fairly flat lines in three layers: head to head at the bottom, spreads and totals in the middle, three-way and props at the top. The steps you can see in some lines line up with changes in the sample, which is why the last section matters.

3.7 What this tells a bettor, and what it does not

The margin is a cost, paid on every bet, win or lose. A smaller margin means more of each dollar comes back on average. The index says nothing about whether any one price is right. It only says how much is built in across the market. Chapter 4 looks at the other half: once the margin is gone, are the prices any good?

Try it yourself

  • Plot median_pct against mean_pct for each class. Where are they far apart, and what does that say about the shape of the spread of margins?
  • Use dropped_pct to find the days with the most broken markets. Do those days have odd margins?
  • Re-read the file tomorrow and check that the old rows did not change.

Data licensed CC BY 4.0, free to reuse with a credit. Model estimates, not advice. 18+. If gambling is causing you harm: gamblinghelponline.org.au.

Get tomorrow's free pick by email

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

18+ only. Please gamble responsibly.