Masters Fantasy Golf Tournament Dashboard

Sports Analytics
Cron
GoogleSheets
Web Scaping
Building an automated Masters fantasy pool dashboard using R and GoogleSheets.
Published

June 12, 2022

This blog posts shows how I use the cronR package to automate a fantasy golf pool with some friends for the golf major tournaments.

The process takes four steps:

  1. Collect the leader board data
  2. Prepare the data
  3. Publish the data to GoogleSheets
  4. Automate the script with cronR

Lets use the 2022 Masters tournament as an example of how we set up the pipeline.

Collecting Leaderboard Data

I’ve found success using two different methods here. Either through rvest to web scrape the leader board data off the web, i.e. ESPN Leaderboard, or through an API. The API route takes more work, but I’ve found the ESPN Developer API to work well during the tournament. Here is the endpoint: https://site.api.espn.com/apis/site/v2/sports/golf/pga/scoreboard.

For this demo, we will use the web scraping approach, as there is no guarantee the API will work in the future.

# packages 
library(rvest)
library(googlesheets4) 
library(cronR)
library(kableExtra) 
library(dplyr)
library(tidyr)
url = "https://www.espn.com/golf/leaderboard?tournamentId=401353232"

content = read_html(url)
scores = content %>% 
  html_table() %>%
  bind_rows() %>%
  select(-1)

scores %>%
  kbl() %>%
  kable_paper("striped") %>%
  scroll_box(height = "500px")
POS PLAYER SCORE R1 R2 R3 R4 TOT EARNINGS FEDEX PTS
1 Scottie Scheffler -10 69 67 71 71 278 $2,700,000 600
2 Rory McIlroy -7 73 73 71 64 281 $1,620,000 330
T3 Shane Lowry -5 73 68 73 69 283 $870,000 180
T3 Cameron Smith -5 68 74 68 73 283 $870,000 180
5 Collin Morikawa -4 73 70 74 67 284 $600,000 120
T6 Corey Conners -3 70 73 72 70 285 $521,250 105
T6 Will Zalatoris -3 71 72 75 67 285 $521,250 105
T8 Sungjae Im -1 67 74 71 75 287 $450,000 91
T8 Justin Thomas -1 76 67 72 72 287 $450,000 91
T10 Charl Schwartzel E 72 69 73 74 288 $390,000 80
T10 Cameron Champ E 72 75 71 70 288 $390,000 80
T12 Dustin Johnson +1 69 73 75 72 289 $330,000 70
T12 Danny Willett +1 69 74 73 73 289 $330,000 70
T14 Kevin Na +2 71 71 79 69 290 $225,333 55
T14 Jason Kokrak +2 70 76 71 73 290 $225,333 55
T14 Min Woo Lee +2 73 75 72 70 290 $225,333 0
T14 Lee Westwood +2 72 74 73 71 290 $225,333 55
T14 Harry Higgs +2 71 75 73 71 290 $225,333 55
T14 Tommy Fleetwood +2 75 72 70 73 290 $225,333 55
T14 Hideki Matsuyama +2 72 69 77 72 290 $225,333 55
T14 Matt Fitzpatrick +2 71 73 76 70 290 $225,333 55
T14 Talor Gooch +2 72 74 73 71 290 $225,333 55
T23 J.J. Spaun +3 74 70 75 72 291 $138,000 41
T23 Robert MacIntyre +3 73 73 76 69 291 $138,000 0
T23 Sergio Garcia +3 72 74 74 71 291 $138,000 41
T23 Harold Varner III +3 71 71 80 69 291 $138,000 41
T27 Viktor Hovland +4 72 76 71 73 292 $111,000 35
T27 Séamus Power +4 74 74 74 70 292 $111,000 35
T27 Jon Rahm +4 74 72 77 69 292 $111,000 35
T30 Marc Leishman +5 73 75 71 74 293 $93,150 28
T30 Russell Henley +5 73 74 76 70 293 $93,150 28
T30 Hudson Swafford +5 77 69 73 74 293 $93,150 28
T30 Lucas Glover +5 72 76 72 73 293 $93,150 28
T30 Sepp Straka +5 74 72 76 71 293 $93,150 28
T35 Joaquin Niemann +6 69 74 77 74 294 $75,563 22
T35 Webb Simpson +6 71 74 73 76 294 $75,563 22
T35 Tony Finau +6 71 75 74 74 294 $75,563 22
T35 Patrick Reed +6 74 73 73 74 294 $75,563 22
T39 Patrick Cantlay +7 70 75 79 71 295 $63,000 18
T39 Tom Hoge +7 73 74 75 73 295 $63,000 18
T39 Si Woo Kim +7 76 70 73 76 295 $63,000 18
T39 Bubba Watson +7 73 73 78 71 295 $63,000 18
43 Billy Horschel +8 74 73 79 70 296 $55,500 15
T44 Kevin Kisner +9 75 70 75 77 297 $51,000 13
T44 Christiaan Bezuidenhout +9 73 71 77 76 297 $51,000 13
46 Cam Davis +12 75 73 79 73 300 $46,500 12
47 Tiger Woods +13 71 74 78 78 301 $43,500 11
T48 Adam Scott +14 74 74 80 74 302 $40,050 10
T48 Max Homa +14 74 73 77 78 302 $40,050 10
T50 Mackenzie Hughes +15 73 75 77 78 303 $37,350 9
T50 Daniel Berger +15 71 75 77 80 303 $37,350 9
52 Tyrrell Hatton +17 72 74 79 80 305 $36,000 8
- Brian Harman CUT 74 75 -- -- 149 -- 0
- Padraig Harrington CUT 74 75 -- -- 149 -- 0
- Takumi Kanaya CUT 75 74 -- -- 149 -- 0
- Zach Johnson CUT 74 75 -- -- 149 -- 0
- K.H. Lee CUT 74 75 -- -- 149 -- 0
- Sam Burns CUT 75 74 -- -- 149 -- 0
- Lucas Herbert CUT 74 76 -- -- 150 -- 0
- Mike Weir CUT 74 76 -- -- 150 -- 0
- Jordan Spieth CUT 74 76 -- -- 150 -- 0
- Brooks Koepka CUT 75 75 -- -- 150 -- 0
- Ryan Palmer CUT 75 75 -- -- 150 -- 0
- Xander Schauffele CUT 74 77 -- -- 151 -- 0
- Keita Nakajima CUT 72 79 -- -- 151 -- 0
- Austin Greaser (a) CUT 74 77 -- -- 151 -- 0
- Stewart Cink CUT 76 75 -- -- 151 -- 0
- Abraham Ancer CUT 72 79 -- -- 151 -- 0
- Luke List CUT 77 75 -- -- 152 -- 0
- Francesco Molinari CUT 78 74 -- -- 152 -- 0
- Bernhard Langer CUT 76 76 -- -- 152 -- 0
- Gary Woodland CUT 75 77 -- -- 152 -- 0
- Justin Rose CUT 76 76 -- -- 152 -- 0
- Erik van Rooyen CUT 73 79 -- -- 152 -- 0
- Guido Migliozzi CUT 75 77 -- -- 152 -- 0
- Cameron Young CUT 77 77 -- -- 154 -- 0
- Fred Couples CUT 75 79 -- -- 154 -- 0
- Garrick Higgo CUT 72 83 -- -- 155 -- 0
- Larry Mize CUT 77 78 -- -- 155 -- 0
- James Piot CUT 81 74 -- -- 155 -- 0
- Aaron Jarvis (a) CUT 81 74 -- -- 155 -- 0
- Bryson DeChambeau CUT 76 80 -- -- 156 -- 0
- Sandy Lyle CUT 82 76 -- -- 158 -- 0
- Vijay Singh CUT 78 80 -- -- 158 -- 0
- Matthew Wolff CUT 81 78 -- -- 159 -- 0
- Thomas Pieters CUT 79 80 -- -- 159 -- 0
- Stewart Hagestad (a) CUT 79 81 -- -- 160 -- 0
- José María Olazábal CUT 77 84 -- -- 161 -- 0
- Laird Shepherd CUT 81 85 -- -- 166 -- 0
- Louis Oosthuizen WD 76 -- -- -- 76 -- 0
- Paul Casey WD -- -- -- -- -- -- 0

The rules of our pool were to each pick 6 players. We would count the four lowest scores each day, and otherwise assign a score of +8 if you failed to have four players make the cut. The winner is the one with the lowest score for their team at the end of the tournament.

Join fantasy picks

We each fill out our teams by a snake draft often on Zoom. For the 2021 Masters, we filled our our results in a GoogleSheet. We can use the googlesheets4 package to read in data from our GoogleSheet of picks into R.

ss = "https://docs.google.com/spreadsheets/d/14nW_AWYil-jBQ2lC54k_lETGw_O031no-Q3FYvtwsUs/edit#gid=0"
picks = read_sheet(ss, sheet = "draft")

picks %>%
  pivot_wider(id_cols = Round, names_from = team, values_from = player) %>%
  kbl() %>%
  kable_paper("striped")
Round Bray Bret Craig Jacko John Jordan Mike Rocco Trev
1 Justin Thomas Rory McIlroy Collin Morikawa Brooks Koepka Scottie Scheffler Cameron Smith Viktor Hovland Jon Rahm Dustin Johnson
3 Shane Lowry Corey Conners Paul Casey Daniel Berger Tommy Fleetwood Sam Burns Abraham Ancer Bryson DeChambeau Matt Fitzpatrick
2 Louis Oosthuizen Jordan Spieth Hideki Matsuyama Will Zalatoris Tiger Woods Joaquin Niemann Patrick Cantlay Tony Finau Xander Schauffele
4 Tyrrell Hatton Sungjae Im Max Homa Kevin Kisner Adam Scott Justin Rose Patrick Reed Billy Horschel Russell Henley
5 Bubba Watson Jason Kokrak Kevin Na Cameron Champ Marc Leishman Sergio Garcia Lee Westwood Webb Simpson Talor Gooch
6 Seamus Power Tom Hoge Thomas Pieters Mackenzie Hughes Gary Woodland Si Woo Kim Lucas Herbert Francesco Molinari Cameron Young
# join scoreboard data & reshape
results = picks %>%
  select(-Round) %>%
  left_join(scores %>% select(-c("POS", "SCORE", "TOT", "EARNINGS", "FEDEX PTS") ), 
            by = c("player" = "PLAYER")) %>%
  pivot_longer(c("R1", "R2", "R3", "R4"), names_to = "round", values_to = "score") %>%
  mutate(score = ifelse(score == "--", 80, as.numeric(score))) # set MCs to 80

results %>%
  kbl() %>%
  kable_paper("striped") %>%
  scroll_box(height = "500px")
team player round score
Bray Justin Thomas R1 76
Bray Justin Thomas R2 67
Bray Justin Thomas R3 72
Bray Justin Thomas R4 72
Bray Shane Lowry R1 73
Bray Shane Lowry R2 68
Bray Shane Lowry R3 73
Bray Shane Lowry R4 69
Bray Louis Oosthuizen R1 76
Bray Louis Oosthuizen R2 80
Bray Louis Oosthuizen R3 80
Bray Louis Oosthuizen R4 80
Bray Tyrrell Hatton R1 72
Bray Tyrrell Hatton R2 74
Bray Tyrrell Hatton R3 79
Bray Tyrrell Hatton R4 80
Bray Bubba Watson R1 73
Bray Bubba Watson R2 73
Bray Bubba Watson R3 78
Bray Bubba Watson R4 71
Bray Seamus Power R1 NA
Bray Seamus Power R2 NA
Bray Seamus Power R3 NA
Bray Seamus Power R4 NA
Bret Jordan Spieth R1 74
Bret Jordan Spieth R2 76
Bret Jordan Spieth R3 80
Bret Jordan Spieth R4 80
Bret Rory McIlroy R1 73
Bret Rory McIlroy R2 73
Bret Rory McIlroy R3 71
Bret Rory McIlroy R4 64
Bret Corey Conners R1 70
Bret Corey Conners R2 73
Bret Corey Conners R3 72
Bret Corey Conners R4 70
Bret Sungjae Im R1 67
Bret Sungjae Im R2 74
Bret Sungjae Im R3 71
Bret Sungjae Im R4 75
Bret Tom Hoge R1 73
Bret Tom Hoge R2 74
Bret Tom Hoge R3 75
Bret Tom Hoge R4 73
Bret Jason Kokrak R1 70
Bret Jason Kokrak R2 76
Bret Jason Kokrak R3 71
Bret Jason Kokrak R4 73
Craig Collin Morikawa R1 73
Craig Collin Morikawa R2 70
Craig Collin Morikawa R3 74
Craig Collin Morikawa R4 67
Craig Hideki Matsuyama R1 72
Craig Hideki Matsuyama R2 69
Craig Hideki Matsuyama R3 77
Craig Hideki Matsuyama R4 72
Craig Paul Casey R1 80
Craig Paul Casey R2 80
Craig Paul Casey R3 80
Craig Paul Casey R4 80
Craig Max Homa R1 74
Craig Max Homa R2 73
Craig Max Homa R3 77
Craig Max Homa R4 78
Craig Thomas Pieters R1 79
Craig Thomas Pieters R2 80
Craig Thomas Pieters R3 80
Craig Thomas Pieters R4 80
Craig Kevin Na R1 71
Craig Kevin Na R2 71
Craig Kevin Na R3 79
Craig Kevin Na R4 69
Jacko Brooks Koepka R1 75
Jacko Brooks Koepka R2 75
Jacko Brooks Koepka R3 80
Jacko Brooks Koepka R4 80
Jacko Will Zalatoris R1 71
Jacko Will Zalatoris R2 72
Jacko Will Zalatoris R3 75
Jacko Will Zalatoris R4 67
Jacko Daniel Berger R1 71
Jacko Daniel Berger R2 75
Jacko Daniel Berger R3 77
Jacko Daniel Berger R4 80
Jacko Kevin Kisner R1 75
Jacko Kevin Kisner R2 70
Jacko Kevin Kisner R3 75
Jacko Kevin Kisner R4 77
Jacko Cameron Champ R1 72
Jacko Cameron Champ R2 75
Jacko Cameron Champ R3 71
Jacko Cameron Champ R4 70
Jacko Mackenzie Hughes R1 73
Jacko Mackenzie Hughes R2 75
Jacko Mackenzie Hughes R3 77
Jacko Mackenzie Hughes R4 78
John Scottie Scheffler R1 69
John Scottie Scheffler R2 67
John Scottie Scheffler R3 71
John Scottie Scheffler R4 71
John Tiger Woods R1 71
John Tiger Woods R2 74
John Tiger Woods R3 78
John Tiger Woods R4 78
John Adam Scott R1 74
John Adam Scott R2 74
John Adam Scott R3 80
John Adam Scott R4 74
John Tommy Fleetwood R1 75
John Tommy Fleetwood R2 72
John Tommy Fleetwood R3 70
John Tommy Fleetwood R4 73
John Marc Leishman R1 73
John Marc Leishman R2 75
John Marc Leishman R3 71
John Marc Leishman R4 74
John Gary Woodland R1 75
John Gary Woodland R2 77
John Gary Woodland R3 80
John Gary Woodland R4 80
Jordan Cameron Smith R1 68
Jordan Cameron Smith R2 74
Jordan Cameron Smith R3 68
Jordan Cameron Smith R4 73
Jordan Sam Burns R1 75
Jordan Sam Burns R2 74
Jordan Sam Burns R3 80
Jordan Sam Burns R4 80
Jordan Joaquin Niemann R1 69
Jordan Joaquin Niemann R2 74
Jordan Joaquin Niemann R3 77
Jordan Joaquin Niemann R4 74
Jordan Sergio Garcia R1 72
Jordan Sergio Garcia R2 74
Jordan Sergio Garcia R3 74
Jordan Sergio Garcia R4 71
Jordan Justin Rose R1 76
Jordan Justin Rose R2 76
Jordan Justin Rose R3 80
Jordan Justin Rose R4 80
Jordan Si Woo Kim R1 76
Jordan Si Woo Kim R2 70
Jordan Si Woo Kim R3 73
Jordan Si Woo Kim R4 76
Mike Viktor Hovland R1 72
Mike Viktor Hovland R2 76
Mike Viktor Hovland R3 71
Mike Viktor Hovland R4 73
Mike Patrick Cantlay R1 70
Mike Patrick Cantlay R2 75
Mike Patrick Cantlay R3 79
Mike Patrick Cantlay R4 71
Mike Patrick Reed R1 74
Mike Patrick Reed R2 73
Mike Patrick Reed R3 73
Mike Patrick Reed R4 74
Mike Abraham Ancer R1 72
Mike Abraham Ancer R2 79
Mike Abraham Ancer R3 80
Mike Abraham Ancer R4 80
Mike Lee Westwood R1 72
Mike Lee Westwood R2 74
Mike Lee Westwood R3 73
Mike Lee Westwood R4 71
Mike Lucas Herbert R1 74
Mike Lucas Herbert R2 76
Mike Lucas Herbert R3 80
Mike Lucas Herbert R4 80
Rocco Francesco Molinari R1 78
Rocco Francesco Molinari R2 74
Rocco Francesco Molinari R3 80
Rocco Francesco Molinari R4 80
Rocco Jon Rahm R1 74
Rocco Jon Rahm R2 72
Rocco Jon Rahm R3 77
Rocco Jon Rahm R4 69
Rocco Tony Finau R1 71
Rocco Tony Finau R2 75
Rocco Tony Finau R3 74
Rocco Tony Finau R4 74
Rocco Bryson DeChambeau R1 76
Rocco Bryson DeChambeau R2 80
Rocco Bryson DeChambeau R3 80
Rocco Bryson DeChambeau R4 80
Rocco Billy Horschel R1 74
Rocco Billy Horschel R2 73
Rocco Billy Horschel R3 79
Rocco Billy Horschel R4 70
Rocco Webb Simpson R1 71
Rocco Webb Simpson R2 74
Rocco Webb Simpson R3 73
Rocco Webb Simpson R4 76
Trev Dustin Johnson R1 69
Trev Dustin Johnson R2 73
Trev Dustin Johnson R3 75
Trev Dustin Johnson R4 72
Trev Xander Schauffele R1 74
Trev Xander Schauffele R2 77
Trev Xander Schauffele R3 80
Trev Xander Schauffele R4 80
Trev Matt Fitzpatrick R1 71
Trev Matt Fitzpatrick R2 73
Trev Matt Fitzpatrick R3 76
Trev Matt Fitzpatrick R4 70
Trev Russell Henley R1 73
Trev Russell Henley R2 74
Trev Russell Henley R3 76
Trev Russell Henley R4 70
Trev Talor Gooch R1 72
Trev Talor Gooch R2 74
Trev Talor Gooch R3 73
Trev Talor Gooch R4 71
Trev Cameron Young R1 77
Trev Cameron Young R2 77
Trev Cameron Young R3 80
Trev Cameron Young R4 80

Prepare Data & Compute Sidebets

Now we have the scores read in, lets compute the day-by-day scores for each team. Recall, this is the four lowest scores per team per round.

pool_results = results %>%
  group_by(team, round) %>%
  arrange(team, round, score) %>%
  mutate(count_score = ifelse(rank(score, ties.method = "first") <= 4, 1, 0)) %>%
  summarise(value = sum(score * count_score) - 72 * 4) %>%
  ungroup()

total_scores = pool_results %>% 
  pivot_wider(id_cols = team, names_from = round, values_from = value) %>%
  group_by(team) %>%
  mutate(Total = sum(across())) %>%
  arrange(Total)

total_scores %>%
  kbl() %>%
  kable_paper("striped")
team R1 R2 R3 R4 Total
Bret -8 6 -3 -8 -13
John -1 -1 2 4 4
Jordan -4 4 4 6 10
Trev -3 6 12 -5 10
Craig 2 -5 19 -2 14
Jacko -1 4 10 4 17
Mike -2 10 8 1 17
Rocco 2 5 15 1 23
Bray NA NA NA NA NA
player_scores = results %>%
  pivot_wider(id_cols = c("player", "team"), names_from = round, values_from = score)

player_scores %>%
  select(-team) %>%
  kbl(col.names = c("Team", "Round 1", "Round 2", "Round 3", "Round 4")) %>%
  pack_rows(index = table(player_scores$team)) %>%
  kable_paper() %>%
  scroll_box(height = "500px")
Team Round 1 Round 2 Round 3 Round 4
Bray
Justin Thomas 76 67 72 72
Shane Lowry 73 68 73 69
Louis Oosthuizen 76 80 80 80
Tyrrell Hatton 72 74 79 80
Bubba Watson 73 73 78 71
Seamus Power NA NA NA NA
Bret
Jordan Spieth 74 76 80 80
Rory McIlroy 73 73 71 64
Corey Conners 70 73 72 70
Sungjae Im 67 74 71 75
Tom Hoge 73 74 75 73
Jason Kokrak 70 76 71 73
Craig
Collin Morikawa 73 70 74 67
Hideki Matsuyama 72 69 77 72
Paul Casey 80 80 80 80
Max Homa 74 73 77 78
Thomas Pieters 79 80 80 80
Kevin Na 71 71 79 69
Jacko
Brooks Koepka 75 75 80 80
Will Zalatoris 71 72 75 67
Daniel Berger 71 75 77 80
Kevin Kisner 75 70 75 77
Cameron Champ 72 75 71 70
Mackenzie Hughes 73 75 77 78
John
Scottie Scheffler 69 67 71 71
Tiger Woods 71 74 78 78
Adam Scott 74 74 80 74
Tommy Fleetwood 75 72 70 73
Marc Leishman 73 75 71 74
Gary Woodland 75 77 80 80
Jordan
Cameron Smith 68 74 68 73
Sam Burns 75 74 80 80
Joaquin Niemann 69 74 77 74
Sergio Garcia 72 74 74 71
Justin Rose 76 76 80 80
Si Woo Kim 76 70 73 76
Mike
Viktor Hovland 72 76 71 73
Patrick Cantlay 70 75 79 71
Patrick Reed 74 73 73 74
Abraham Ancer 72 79 80 80
Lee Westwood 72 74 73 71
Lucas Herbert 74 76 80 80
Rocco
Francesco Molinari 78 74 80 80
Jon Rahm 74 72 77 69
Tony Finau 71 75 74 74
Bryson DeChambeau 76 80 80 80
Billy Horschel 74 73 79 70
Webb Simpson 71 74 73 76
Trev
Dustin Johnson 69 73 75 72
Xander Schauffele 74 77 80 80
Matt Fitzpatrick 71 73 76 70
Russell Henley 73 74 76 70
Talor Gooch 72 74 73 71
Cameron Young 77 77 80 80

Write to GoogleSheets

Lets write our total table to a GoogleSheet where everyone in the competition can view. The googlesheet can be viewed here.

# add data to sheet
player_scores %>%
  select(team, player, R1, R2, R3, R4) %>%
  write_sheet(ss, sheet = "leaderboard")

# add a total leader board table
ss %>%
  range_write(total_scores, sheet = "leaderboard", range = "H1")

# add date last updated 
ss %>%
  range_write(data = data.frame(Last_updated = as.character(Sys.time())),
              sheet = "leaderboard",
              range = "O2")

From here, we can build plots off of the data ranges in our GoogleSheet, and update the formatting however we would like.

Automate with cronR

cronR is a unix/linux tool that allows us to schedule R scripts. We are able to set jobs that will run on specific intervals. For example, we can have our leader board update every 15 minutes during the tournament to provide live updates.

Setting the cron job

We use cron_add() to set the cron job. I found when using MacOS that I had to allow cron permissions in system preferences. I found this tutorial to be helpful.

Once you install the package, you can also use the RStudio addin, found at Addins > Schedule R scripts on Linux/Unix.

wd = getwd()
script = file.path(wd, "index.rmd")
cmd = cron_rscript(script, 
                   log_append = TRUE, 
                   log_timestamp = TRUE)
cron_add(command = cmd, 
         frequency = "*/15 * * * *", 
         id = "2021-masters-pool", 
         description = "update masters pool every 15 mintues")

Viewing and Removing cron jobs

This is as simple as using cron_ls() to list the cron jobs, and cron_rm() to remove jobs by their id.

cron_ls()

cron_rm("2021-masters-pool")

Other Comments

While we web scraped the leader board data, I find the API can allow you to get richer data. Specifically hole-by-hole scores. This data allows for other side bets. Specifically having skins or a horse race. The downside is the API changes based on the status of the current event, therefore often requires daily checking.

Additionally, you can pull the individual scorecard data from the ESPN player pages. For example, see Rory McIlroy here.

GoogleSheets also have a read_html() function, which allow you to read the raw data straight into the GoogleSheet. This can remove needing to use R, however you lose the flexibility of being able to update the sheet at whatever period you wish, and you are limited if you wish to do more complex analysis.