Early in the 2026-27 NHL season, home teams are beating their prices. A difference between what a market priced and what happened is only interesting if chance struggles to produce it, and with closing probabilities for every game you can work out exactly how often chance would. This notebook does that with the exact distribution of a season of unequal games, then asks how many more games it would take to settle the question.

The NHL home ice study has the plain-English write-up. The file has one row per game, from opening night to 28 October.

Load the data

u <- paste0("https://raw.githubusercontent.com/edushinka/sports-closing-price-studies/",
            "main/data/nhl_2026_closing_prices_vs_results.csv")
nhl <- read.csv(u, encoding = "UTF-8")
nhl$date_utc <- as.Date(nhl$date_utc)
nrow(nhl); range(nhl$date_utc); summary(nhl$n_books)
## [1] 133
## [1] "2026-10-07" "2026-10-28"
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
##       7       7       7       7       7       7

Where the probability column comes from

A moneyline pair adds up to more than 100 per cent, and the excess is the bookmaker’s margin. Scale the two implied chances so they sum to 1 and you have the no-vig probability. Here it is for a home team at -135 against an away team at +115:

implied <- function(american) {
  ifelse(american < 0, -american / (100 - american), 100 / (100 + american))
}
no_vig <- function(home, away) {
  h <- implied(home); a <- implied(away)
  c(home = h / (h + a), away = a / (h + a), margin = h + a - 1)
}
round(no_vig(-135, 115), 4)
##   home   away margin 
## 0.5526 0.4474 0.0396

The no-vig calculator does the same sum in a browser. In the file, this has been done for each book separately and then averaged across books.

Expected wins, actual wins

The sum of the probabilities is the number of home wins the market expected.

p <- nhl$home_no_vig_close_prob
y <- nhl$home_won
n_won <- sum(y)
c(games = length(y), expected = round(sum(p), 1), actual = n_won)
##    games expected   actual 
##    133.0     69.5     76.0

Home teams won 76 of 133, or 57.1 per cent, against 69.5 wins expected at a 52.2 per cent average price.

The exact distribution of home wins

Each game has its own probability, so the number of home wins is not binomial. It follows a Poisson-binomial distribution, and you can compute that exactly by adding one game at a time: after each game, the chance of k wins is the chance of k before times a loss, plus the chance of k - 1 before times a win.

pois_binom <- function(p) {
  d <- 1
  for (pi in p) d <- c(d * (1 - pi), 0) + c(0, d * pi)
  d
}
d <- pois_binom(p)
wins <- seq_along(d) - 1
round(sum(d), 10)                       # should be 1
## [1] 1
tail_p <- sum(d[wins >= n_won])
two_sided <- sum(d[d <= d[wins == n_won]])
round(c(one_sided = tail_p, two_sided = two_sided), 4)
## one_sided two_sided 
##    0.1431    0.2511
keep <- d > 1e-4
barplot(d[keep], names.arg = wins[keep], col = ifelse(wins[keep] == n_won, "#2563eb", "grey80"),
        border = NA, las = 2, cex.names = 0.7,
        xlab = "Home wins in the sample", ylab = "Probability",
        main = "Home wins if every closing price were exactly right (actual in blue)")

cdf <- cumsum(d)
c(low = wins[which(cdf >= 0.025)[1]], high = wins[which(cdf >= 0.975)[1]])
##  low high 
##   58   81

If every price were exactly right, 95 per cent of samples would land between 58 and 81 home wins. The actual 76 sits comfortably inside. Six and a half wins above the price sounds like a lot, and a sample of correct prices would produce a result at least that far out about one time in four.

How the gap built up

Sort by date and keep a running total of wins minus expected wins. A real shift tends to climb steadily. A hot few nights shows up as one jump.

o <- order(nhl$date_utc)
run <- cumsum(y[o] - p[o])
plot(seq_along(run), run, type = "l", lwd = 2, col = "#2563eb",
     xlab = "Games, in date order", ylab = "Home wins above price",
     main = "NHL 2026-27: running home wins above the closing price")
abline(h = 0, lty = 2, col = "grey60")

The same thing by week of the season:

wk <- aggregate(cbind(priced = home_no_vig_close_prob, won = home_won) ~ week,
                data = nhl, FUN = sum)
wk$games <- as.vector(table(nhl$week))
wk$above_price <- round(wk$won - wk$priced, 1)
wk$priced <- round(wk$priced, 1)
knitr::kable(wk[, c("week", "games", "priced", "won", "above_price")],
             col.names = c("Week", "Games", "Expected home wins", "Home wins", "Above price"))
Week Games Expected home wins Home wins Above price
1 35 18.2 18 -0.2
2 48 25.7 29 3.3
3 42 21.4 26 4.6
4 8 4.2 3 -1.2

Weeks 2 and 3 produced the whole gap. Weeks 1 and 4 came in at or just under the price, though week 4 has only 8 games so far.

Did the prices sort the games?

A flat rate for every game is the simplest possible forecast. Compare the Brier score of the closing prices against that.

brier <- function(f) mean((f - y)^2)
c(closing = round(brier(p), 4), flat_rate = round(brier(mean(y)), 4), coin = 0.25)
##   closing flat_rate      coin 
##    0.2285    0.2449    0.2500

Lower is better. The closing prices beat the flat rate even though the flat rate was given the actual win rate in advance, so the prices were telling good games from bad ones while running a little low on home teams overall.

How many games would settle it?

Treat the average price as a fixed target and ask how many games a one-sample test needs to detect a gap this size, with 80 per cent power at the usual 5 per cent level.

p0 <- mean(p); p1 <- mean(y)
n_needed <- ((qnorm(0.975) * sqrt(p0 * (1 - p0)) + qnorm(0.8) * sqrt(p1 * (1 - p1))) / (p1 - p0))^2
ceiling(n_needed)
## [1] 812

That is more than half of a 1,312-game regular season, and it assumes the gap really is as big as it looks so far. If the true gap is smaller, the games needed grow with the square of the shrinkage: half the gap takes about four times as many.

Goal difference

Wins are a coarse measure, so check goals as well.

nhl$margin <- nhl$home_score - nhl$away_score
one_goal <- abs(nhl$margin) == 1
c(mean_margin = round(mean(nhl$margin), 2),
  one_goal_games = sum(one_goal),
  home_wins_in_them = sum(nhl$home_won[one_goal]))
##       mean_margin    one_goal_games home_wins_in_them 
##              0.07             53.00             38.00

Home teams are barely ahead on goals: 0.07 a game. The win surplus comes from one-goal games, where they have won 38 of 53. One-goal games swing on small events, and the file does not separate regulation from overtime or shootouts, so this adds to the case for waiting on more data.

Data and credit

The CSV is CC BY 4.0, archived with the DOI 10.5281/zenodo.23774011 and kept in the sports-closing-price-studies repository on GitHub. Closing moneylines come from The Odds API, final scores from ESPN. Other leagues and the method are on the Bet Better studies page.