Nos últimos anos, as apostas esportivas passaram a fazer parte do cotidiano no Brasil. Estão nas camisas dos clubes, nas transmissões dos jogos e nas redes sociais. Aos poucos, criou-se a ideia de que apostar pode ser uma forma de ganhar dinheiro com futebol, quase como se fosse um investimento. No entanto, a realidade estatística é menos otimista: o mercado de apostas é um sistema desenhado com uma vantagem estrutural para a casa, o que torna a busca por lucro consistente uma tarefa matematicamente improvável para a maioria dos usuários.
Este estudo é feito com a base de dados diponibilizado gratuitamente no Github do usuário Adam Gábor link do repositório.
O conjunto de dados contém estatísticas de diversas partidas de diversas ligas de 2012 a 2024. Dentre essas estatísticas, estão as odds do site Bet365, que serão alguns dos principais objetos do estudo. Serão restringidos os jogos da primeira divisão do Campeonato Brasileiro de Futebol ne apostas nos resultados das partidas. Nesta primeira parte, o objetivo é responder a seguinte pergunta: “Dada uma determinada odd para vitória do mandante, com que frequência essa vitória realmente ocorreu?”
Alguns conceitos são retirados do site do jornal Lance!.
Serão analisadas as Odds contra o desempenho real dos clubes.
Odds: “Basicamente, elas indicam exatamente quanto o apostador pode ganhar a cada real apostado. Além disso, as odds apontam uma relação inversa com a probabilidade.”
De uma forma prática, uma odd de 2.0 indica um retorno de duas vezes o valor apostado e de 50% de probabilidade do evento ocorrer (1/2 = 0,5). É o que chamamos de Probabilidade Implícita. Assim, quanto maior a odd, maior o retorno para o apostador, mas também é menos provável que o evento apostado ocorra.
Anteriormente, foi feito o carregamento das Bibliotecas do tidyverse utilizadas para manipulação e visualização dos dados e do pacote gt para criação das tabelas. Além disso, vamos carregar o arquivo Matches.csv.
Observação: clique em “Show” para ver o código utilizado, caso desejar.
# Carregamento e número de linhas e colunas do conjunto de dados
Matches <- read.csv("~/Matches.csv", header = TRUE)
cat("O dataset original possui", nrow(Matches), "linhas (jogos) e", ncol(Matches), "colunas (variáveis).")
## O dataset original possui 230557 linhas (jogos) e 48 colunas (variáveis).
A primeira coluna é chamada de Division, ela indica a qual campeonato a partida se refere. Queremos apenas analisar partidas do campeonato brasileiro.
# Códigos dos campeonatos do conjunto de dados
Matches$Division %>%
unique()
## [1] "F1" "F2" "T1" "D1" "D2" "B1" "E2" "E1" "N1" "P1" "E0" "I2"
## [13] "SP2" "SP1" "I1" "E3" "SC0" "SC1" "SC2" "SC3" "G1" "EC" "USA" "SWE"
## [25] "NOR" "IRL" "BRA" "ARG" "MEX" "JAP" "RUS" "POL" "DEN" "ROM" "AUT" "SUI"
## [37] "FIN" "CHN"
Assim, as partidas do Campeonato Brasileiro são identificadas como “BRA” nesta variável. Além disso, são 48 colunas e não iremos utilizar todas. Por isso, vamos filtrar o Brasileirão e selecionar as seguintes colunas:
As descrições das demais variáveis estão na página repositório
# Filtro e renomeação das colunas que vamos trabalhar
brasileirao <- Matches %>%
filter(Division == "BRA") %>%
select(Data = MatchDate,
Mandante = HomeTeam,
Visitante = AwayTeam,
GolsMandante = FTHome,
GolsVisitante = FTAway,
Resultado = FTResult,
OddMandante = OddHome,
OddEmpate = OddDraw,
OddVisitante = OddAway
)
# Visualização das primeiras linhas da tabela selecionada
brasileirao %>%
head() %>%
kable()
| Data | Mandante | Visitante | GolsMandante | GolsVisitante | Resultado | OddMandante | OddEmpate | OddVisitante |
|---|---|---|---|---|---|---|---|---|
| 2012-01-07 | Bahia | Internacional | 1 | 1 | D | 2.90 | 3.16 | 2.38 |
| 2012-01-07 | Coritiba | Sport Recife | 2 | 3 | A | 1.66 | 3.58 | 5.01 |
| 2012-01-07 | Portuguesa | Santos | 0 | 0 | D | 3.52 | 3.32 | 2.02 |
| 2012-01-07 | Flamengo RJ | Atletico GO | 3 | 2 | H | 1.59 | 3.71 | 5.44 |
| 2012-01-07 | Gremio | Atletico-MG | 0 | 1 | A | 1.93 | 3.31 | 3.80 |
| 2012-01-07 | Palmeiras | Figueirense | 3 | 1 | H | 2.00 | 3.28 | 3.64 |
# Visão geral das variáveis
brasileirao %>%
summary()
## Data Mandante Visitante GolsMandante
## Length:4850 Length:4850 Length:4850 Min. :0.000
## Class :character Class :character Class :character 1st Qu.:1.000
## Mode :character Mode :character Mode :character Median :1.000
## Mean :1.414
## 3rd Qu.:2.000
## Max. :7.000
## NA's :1
## GolsVisitante Resultado OddMandante OddEmpate
## Min. :0.0000 Length:4850 Min. : 1.070 Min. : 2.510
## 1st Qu.:0.0000 Class :character 1st Qu.: 1.670 1st Qu.: 3.150
## Median :1.0000 Mode :character Median : 2.030 Median : 3.300
## Mean :0.9602 Mean : 2.231 Mean : 3.485
## 3rd Qu.:2.0000 3rd Qu.: 2.530 3rd Qu.: 3.660
## Max. :6.0000 Max. :20.230 Max. :10.820
## NA's :1
## OddVisitante
## Min. : 1.150
## 1st Qu.: 2.860
## Median : 3.755
## Mean : 4.327
## 3rd Qu.: 5.280
## Max. :26.580
##
Pelo resumo temos que:
Vamos verificar qual partida é a que tem dado faltante em GolsMandante.
# Verificação do jogo com dado faltante
brasileirao %>%
filter(is.na(GolsMandante)) %>%
kable()
| Data | Mandante | Visitante | GolsMandante | GolsVisitante | Resultado | OddMandante | OddEmpate | OddVisitante |
|---|---|---|---|---|---|---|---|---|
| 2016-11-12 | Chapecoense-SC | Atletico-MG | NA | NA | 2.85 | 3.3 | 2.67 |
O jogo é Chapecoense contra Atlético Mineiro, pelo Brasileirão de 2026. Ao pesquisar o resultado para preencher os dados, foi encontrado que o jogo foi registrado como duplo W.O. após o trágico acidente aéreo envolvendo a equipe catarinense. Como é um caso de duplo W.O., preencher como um empate não é correto, assim, vamos apenas remover este registro.
Esse é um exemplo de como dados carregam contexto. Muitas vezes, um valor ausente não é apenas um problema técnico de preenchimento, mas representa um evento histórico.
# Remoção do jogo Chapecoense vs Atlético MG em 2016 (único registro faltante em GolsMandante)
brasileirao <- brasileirao %>%
filter(!is.na(GolsMandante))
Além do dado faltante, também foi possível verificar que a coluna “Data” está no formato char que não ajuda muito na manipulação dos dados. Assim, vamos utilizar ferramentas do pacote lubridate (incluído no tidyverse).
# Conversão de char para date
brasileirao <- brasileirao %>%
mutate(Data = ymd(Data))
Também vamos ajustar a variável Resultado de char para factor para facilitar nas futuras análises.
# Ajuste de Resultado como factor
brasileirao <- brasileirao %>%
mutate(Resultado = factor(case_when(
Resultado == "H" ~ "Vitória",
Resultado == "D" ~ "Empate",
Resultado == "A" ~ "Derrota"
), levels = c("Vitória", "Empate", "Derrota")))
Nos anos analisados (de 2012 a 2024) o Campeonato Brasileiro seguiu o mesmo formato de disputa: pontos corridos com 20 participantes, com jogos de ida e volta. Ou seja, cada clube enfrenta 19 adversários duas vezes. Assim, cada campeonato tem 38 rodadas com 10 jogos por rodada, portanto 380 jogos no total. Deveríamos ter 4940 partidas.
No entanto, temos 4849 registros (já considerando o jogo removido na seção anterior).
# Parâmetros gráficos
theme_set(
theme_minimal() +
theme(
plot.title = element_text(size = 18, face = "bold", hjust = 0.5, color = "#1B4F72"),
plot.subtitle = element_text(size = 11, hjust = 0.5, color = "#566573", margin = margin(b = 20)),
axis.line.x = element_line(color = "#2C3E50", size = 0.8),
axis.text = element_text(color = "#2C3E50"),
panel.grid.major = element_blank(),
panel.grid.minor = element_blank(),
plot.margin = unit(c(1, 1, 1, 1), "cm"),
legend.position = "bottom"
)
)
Cor1 <- "#1B4F72"
Cor2 <- "#2E86C1"
Cor3 <- "#AED6F1"
# Número de jogos por ano
JogosAno <- brasileirao %>%
count(Ano = year(Data)) %>%
mutate(Ano = factor(Ano))
ggplot(JogosAno, aes(x = n, y = Ano)) +
geom_col(fill = Cor2, width = 0.7) +
geom_text(aes(label = n),
hjust = -0.3,
size = 4,
fontface = "bold",
color = "#2C3E50") +
scale_x_continuous(expand = expansion(mult = c(0, 0.15))) +
labs(title = "Quantidade de Jogos por Ano",
x = "Número de Jogos",
y = NULL) +
theme_minimal() +
theme(
plot.title = element_text(size = 18, face = "bold", hjust = 0.5, color = "#1B4F72"),
plot.subtitle = element_text(size = 11, hjust = 0.5, color = "#566573", margin = margin(b = 20)),
axis.line.y = element_line(color = "#2C3E50", size = 0.8),
axis.text.y = element_text(size = 10, face = "bold", color = "#2C3E50"),
axis.text.x = element_blank(),
panel.grid.major = element_blank(),
panel.grid.minor = element_blank(),
plot.margin = unit(c(1, 1, 1, 1), "cm")
)
Assim, devemos considerar que:
# Preparação dos dados
WinrateMandante <- brasileirao %>%
drop_na() %>%
count(Resultado) %>%
mutate(Percentual = (n / sum(n)) * 100)
# Gráfico Winrate do Mandante
ggplot(WinrateMandante, aes(x = Resultado, y = Percentual, fill = Resultado)) +
geom_col(width = 0.7, color = "white", size = 0.5) + # Borda branca sutil entre as barras
geom_text(aes(label = paste0(round(Percentual, 1), "%")),
vjust = -0.8,
size = 5,
fontface = "bold",
color = "#2C3E50") +
scale_fill_manual(values = c("Vitória" = Cor1,
"Empate" = Cor2,
"Derrota" = Cor3)) +
labs(title = "Desempenho dos Mandantes",
x = NULL,
y = "Porcentagem dos Jogos") +
scale_y_continuous(limits = c(0, max(WinrateMandante$Percentual) + 10),
breaks = seq(0, 100, 10)) +
theme(axis.text.y = element_blank(),
legend.position = "none")
Quase metade dos jogos são vencidos de fatos com os mandantes, o número dos outros resultados está bastante próximo. Jogar em casa é de fato relevante para tentar prever o resultado da partida. Vamos comparar essas média observadas com as probabilidades implícitas da mediana (uma vez que as médias são influenciadas pelas odds altas), vistas na seção anterior:
# Winrate em Tabela
tabela_winrate <- data.frame(
Resultado = c("Vitória", "Empate", "Derrota"),
Observado = c("48,5%", "26,9%", "24,6%"),
Odd = c("49,2%", "30,3%", "26,6%")
)
tabela_winrate %>%
gt() %>%
cols_label(
Resultado = "Resultado do Mandante",
Observado = "Taxa de Vitória Observada",
Odd = "Mediana da Probabilidade Implícita"
) %>%
tab_style(
style = list(
cell_fill(color = "#1B4F72"),
cell_text(color = "white", weight = "bold")
),
locations = cells_column_labels()
) %>%
tab_options(
table.width = pct(80),
heading.title.font.size = px(22),
column_labels.background.color = "#1B4F72",
table.border.top.color = "transparent",
table.border.bottom.color = "#1B4F72",
heading.align = "center"
) %>%
cols_align(
align = "center",
columns = everything()
) %>%
opt_row_striping()
| Resultado do Mandante | Taxa de Vitória Observada | Mediana da Probabilidade Implícita |
|---|---|---|
| Vitória | 48,5% | 49,2% |
| Empate | 26,9% | 30,3% |
| Derrota | 24,6% | 26,6% |
Como esperado, todas as medianas das Probabilidades Implícitas são maiores que as médias observadas. No entanto, a maior diferença entre as comparações está no empate, ou seja, é neste cenário que a casa de apostas aplica sua maior margem de segurança, indicando que o mercado de empates é o menos eficiente para o apostador e o mais lucrativo para a banca.
Vamos visualizar quais os clubes melhor exercem o seu mando de campo e quais são os visitantes mais incômodos no período analisado.
# Melhores mandantes.Auxiliar e gráfico.
top_10_mandantes <- brasileirao %>%
drop_na() %>%
group_by(Mandante) %>%
summarise(
TotalJogos = n(),
VitoriasCasa = sum(Resultado == "Vitória"),
PercentualVitoria = (VitoriasCasa / TotalJogos) * 100
) %>%
slice_max(PercentualVitoria, n = 10)
ggplot(top_10_mandantes, aes(x = reorder(Mandante, PercentualVitoria), y = PercentualVitoria)) +
geom_col(fill = Cor2, width = 0.7) +
coord_flip() +
geom_text(aes(label = paste0(round(PercentualVitoria, 1), "%")),
hjust = -0.2,
size = 4.5,
fontface = "bold",
color = "#2C3E50") +
labs(title = "Top 10 Mandantes do Brasileirão (2012 - 2024)",
subtitle = "Times com maior percentual de vitória jogando em seus domínios",
x = NULL,
y = "Percentual de Vitória em Casa") +
scale_y_continuous(expand = expansion(mult = c(0, 0.15)))
# Melhores visitantes. Auxiliar e gráfico.
top_10_visitantes <- brasileirao %>%
drop_na() %>%
group_by(Visitante) %>%
summarise(
TotalJogos = n(),
VitoriasFora = sum(Resultado == "Derrota"),
PercentualVitoria = (VitoriasFora / TotalJogos) * 100
) %>%
slice_max(PercentualVitoria, n = 10)
ggplot(top_10_visitantes, aes(x = reorder(Visitante, PercentualVitoria), y = PercentualVitoria)) +
geom_col(fill = Cor2, width = 0.7) +
coord_flip() +
geom_text(aes(label = paste0(round(PercentualVitoria, 1), "%")),
hjust = -0.2,
size = 4.5,
fontface = "bold",
color = "#2C3E50") +
labs(title = "Top 10 Visitantes do Brasileirão (2012 - 2024)",
subtitle = "Times com maior percentual de vitória jogando fora de casa",
x = NULL,
y = "Percentual de Vitória Fora de Casa") +
scale_y_continuous(expand = expansion(mult = c(0, 0.15)))
Assim, considerando todo o período, os melhores visitantes venceram entre cerca de 50% a 60%. Já os melhores visitantes venceram entre cerca de 25% a 40% dos jogos fora de casa. Isso é um outro exemplo do peso do “Fator Casa”, mesmo entre os melhores clubes do país.
Vamos considerar que o favorito segundo a casa de aposta é o resultado com menor odd e, portanto, maior probabilidade implícita.
# Vamos tratar o caso que a menor odd pode ser igual para dois resultados
df_favoritos <- brasileirao %>%
drop_na() %>%
mutate(
MenorOdd = pmin(OddMandante, OddEmpate, OddVisitante),
QtdMinimos = (OddMandante == MenorOdd) + (OddEmpate == MenorOdd) + (OddVisitante == MenorOdd)
) %>%
filter(QtdMinimos == 1) %>%
mutate(
Favorito = case_when(
OddMandante == MenorOdd ~ "Vitória",
OddEmpate == MenorOdd ~ "Empate",
OddVisitante == MenorOdd ~ "Derrota"
),
Favorito = factor(Favorito, levels = c("Vitória", "Empate", "Derrota")),
FavoritoVenceu = (Favorito == Resultado)
)
resumo_favoritos <- df_favoritos %>%
group_by(Favorito) %>%
summarise(
Total_Jogos = n(),
Vitorias_Favorito = sum(FavoritoVenceu),
Taxa_Acerto = (Vitorias_Favorito / Total_Jogos) * 100
) %>%
mutate(Distribuicao_Favorito = (Total_Jogos / sum(Total_Jogos)) * 100,
Favorito = case_when(
Favorito == "Vitória" ~ "Mandante",
Favorito == "Empate" ~ "Empate",
Favorito == "Derrota" ~ "Visitante"
))
# Tabela
taxa_geral <- (sum(resumo_favoritos$Vitorias_Favorito) / sum(resumo_favoritos$Total_Jogos)) * 100
paste0("De um modo geral,", formatC(taxa_geral, format = "f", digits = 1, decimal.mark = ","), "% dos favoritos vencem seus jogos.")
## [1] "De um modo geral,51,3% dos favoritos vencem seus jogos."
resumo_favoritos %>%
select(Favorito, Total_Jogos, Distribuicao_Favorito, Taxa_Acerto) %>%
gt() %>%
fmt_integer(columns = Total_Jogos) %>%
fmt_number(
columns = c(Distribuicao_Favorito, Taxa_Acerto),
decimals = 1,
dec_mark = ",",
pattern = "{x}%"
) %>%
cols_label(
Favorito = "Favorito",
Total_Jogos = "Nº de Partidas",
Distribuicao_Favorito = "% Vezes Favorito",
Taxa_Acerto = "Taxa de Vitória Real"
) %>%
tab_style(
style = list(
cell_fill(color = "#1B4F72"),
cell_text(color = "white", weight = "bold")
),
locations = cells_column_labels()
) %>%
tab_options(
table.width = pct(80),
column_labels.background.color = "#1B4F72",
table.border.top.color = "transparent",
table.border.bottom.color = "#1B4F72",
heading.align = "center"
) %>%
cols_align(
align = "center",
columns = everything()
) %>%
opt_row_striping()
| Favorito | Nº de Partidas | % Vezes Favorito | Taxa de Vitória Real |
|---|---|---|---|
| Mandante | 3,875 | 80,1% | 53,6% |
| Empate | 2 | 0,0% | 50,0% |
| Visitante | 963 | 19,9% | 42,2% |
Nesta divisão de favoritismo, foram desconsidarados os jogos que tinham dois ou mais resultados com a mesma odd.
Com esses resultados, temos que:
# Preparação
odds_longo <- brasileirao %>%
select(OddMandante, OddEmpate, OddVisitante) %>%
pivot_longer(everything(), names_to = "Tipo_Odd", values_to = "Valor") %>%
mutate(Tipo_Odd = case_when(
Tipo_Odd == "OddMandante" ~ "Mandante",
Tipo_Odd == "OddEmpate" ~ "Empate",
Tipo_Odd == "OddVisitante" ~ "Visitante"
),
Tipo_Odd = factor(Tipo_Odd, levels = c("Mandante", "Empate", "Visitante")))
# Gráfico Boxplot com Outliers
ggplot(odds_longo, aes(x = Tipo_Odd, y = Valor, fill = Tipo_Odd)) +
geom_boxplot(staplewidth = 0.5,
linewidth = 0.7,
outlier.color = "#E74C3C",
outlier.alpha = 0.4,
outlier.shape = 16) +
stat_summary(fun = mean, geom = "point", shape = 18, size = 3, color = "white") +
scale_fill_manual(values = c("Mandante" = Cor1, "Empate" = Cor2, "Visitante" = Cor3)) +
labs(title = "Dispersão e Outliers das Odds",
x = NULL,
y = "Valor da Odd") +
theme(
legend.position = "none",
axis.line.y = element_line(color = "#2C3E50", size = 0.8),
axis.line.x = element_line(color = "#2C3E50", size = 0.8)
)
Como era esperado, pela diferença de valores entre média e mediana, temos vários outliers à direita. Vamos repetir este gráfico sem os outilers. Mas antes vamos ver os jogos das 5 maiores odds de cada resultado, por curiosidade.
# Seleção das maiores odds de cada caso
top_odd_mandante <- brasileirao %>%
mutate(Ano = year(Data)) %>%
slice_max(OddMandante, n = 5, with_ties = FALSE) %>%
mutate(Categoria = "Maiores Odds Mandante")
top_odd_empate <- brasileirao %>%
mutate(Ano = year(Data)) %>%
slice_max(OddEmpate, n = 5, with_ties = FALSE) %>%
mutate(Categoria = "Maiores Odds Empate")
top_odd_visitante <- brasileirao %>%
mutate(Ano = year(Data)) %>%
slice_max(OddVisitante, n = 5, with_ties = FALSE) %>%
mutate(Categoria = "Maiores Odds Visitante")
tabela_outliers <- bind_rows(top_odd_mandante, top_odd_empate, top_odd_visitante)
# Tabela de maiores odds
tabela_outliers %>%
select(Categoria, Mandante, Visitante, Ano, GolsMandante, GolsVisitante, OddMandante, OddEmpate, OddVisitante) %>%
gt(groupname_col = "Categoria") %>%
cols_label(
GolsMandante = "Gols (M)",
GolsVisitante = "Gols (V)",
OddMandante = "Odd M",
OddEmpate = "Odd E",
OddVisitante = "Odd V"
) %>%
tab_style(
style = list(cell_fill(color = "#F4F6F7"), cell_text(weight = "bold")),
locations = cells_row_groups()
) %>%
tab_options(
table.width = pct(100),
column_labels.background.color = "#1B4F72",
column_labels.font.weight = "bold"
) %>%
cols_align(align = "center", columns = everything()) %>%
opt_row_striping()
| Mandante | Visitante | Ano | Gols (M) | Gols (V) | Odd M | Odd E | Odd V |
|---|---|---|---|---|---|---|---|
| Maiores Odds Mandante | |||||||
| Parana | Palmeiras | 2018 | 1 | 1 | 20.23 | 6.98 | 1.15 |
| CSA | Flamengo RJ | 2019 | 0 | 2 | 11.74 | 5.64 | 1.24 |
| Sport Recife | Flamengo RJ | 2021 | 0 | 3 | 8.36 | 5.06 | 1.34 |
| Goias | Sao Paulo | 2020 | 0 | 3 | 8.28 | 4.78 | 1.36 |
| Chapecoense-SC | Flamengo RJ | 2021 | 2 | 2 | 8.18 | 4.87 | 1.36 |
| Maiores Odds Empate | |||||||
| Flamengo RJ | CSA | 2019 | 1 | 0 | 1.07 | 10.82 | 26.58 |
| Flamengo RJ | Avai | 2019 | 6 | 1 | 1.10 | 9.53 | 21.17 |
| Flamengo RJ | Avai | 2022 | 1 | 2 | 1.16 | 7.56 | 16.07 |
| Palmeiras | Avai | 2022 | 3 | 0 | 1.16 | 7.47 | 17.28 |
| Gremio | CSA | 2019 | 2 | 1 | 1.15 | 7.37 | 17.73 |
| Maiores Odds Visitante | |||||||
| Flamengo RJ | CSA | 2019 | 1 | 0 | 1.07 | 10.82 | 26.58 |
| Flamengo RJ | Avai | 2019 | 6 | 1 | 1.10 | 9.53 | 21.17 |
| Gremio | CSA | 2019 | 2 | 1 | 1.15 | 7.37 | 17.73 |
| Palmeiras | Avai | 2022 | 3 | 0 | 1.16 | 7.47 | 17.28 |
| Palmeiras | Chapecoense-SC | 2019 | 1 | 0 | 1.16 | 6.89 | 17.20 |
Dentre esses 15 jogos, a única grande “zebra” de fato foi a vitória do visitante em Flamengo x Avaí, com 6,2% de probabilidade implícita.
Agora, vamos retirar os outliers e repetir o gráfico de boxplots.
# Gráfico de Boxplots sem Outliers
ggplot(odds_longo, aes(x = Tipo_Odd, y = Valor, fill = Tipo_Odd)) +
geom_boxplot(staplewidth = 0.5,
linewidth = 0.7,
outlier.alpha = 0.4,
outlier.shape = NA) +
coord_cartesian(ylim = c(0,9)) +
stat_summary(fun = mean, geom = "point", shape = 18, size = 3, color = "white") +
scale_fill_manual(values = c("Mandante" = Cor1, "Empate" = Cor2, "Visitante" = Cor3)) +
labs(title = "Dispersão das Odds sem Outliers",
x = NULL,
y = "Valor da Odd") +
theme(
legend.position = "none",
axis.line.x = element_line(color = "#2C3E50", size = 0.8),
axis.line.y = element_line(color = "#2C3E50", size = 0.8)
)
Em conssonância com que foi visto até agora, temos uma variação maior nas odds dos visitantes. Esse fato reflete uma tática de mercado: embora o empate ocorra quase com a mesma frequência que a vitória do visitante, ele raramente é o alvo do grande volume de apostas. A casa de apostas oferece odds mais agressivas para o time visitante para atrair o interesse do público, que vê na vitória fora de casa uma oportunidade de ganho mais clara e emocionante do que no empate, ainda que o risco real seja estatisticamente equivalente.
A análise da pergunta principal será feita apenas sob a ótica da vitória do mandante. Assim, vamos plotar apenas o histograma destas odds.
ggplot(brasileirao, aes(x = OddMandante)) +
geom_histogram(aes(y = after_stat(density)),
binwidth = 1,
fill = Cor3,
color = "white",
alpha = 0.9) +
labs(
title = "Histograma das Odds dos Mandantes",
x = "Odd do Mandante",
y = "Densidade"
)
Como já havíamos visto, a maior parte dos dados está nas odds entre 1.25 e 2.50 (entre 40% e 80% de probabilidade implícita de vitória do mandante).
Entramos no conceito central desta análise: a calibração. Em Estatística, dizemos que um modelo está bem calibrado quando a probabilidade prevista para um evento converge para a frequência real observada no longo prazo. Por exemplo, se uma casa de apostas define diversas partidas com uma probabilidade de vitória de 70%, espera-se que o time mandante vença de fato aproximadamente 70% dessas partidas.
Assim, a calibração é a métrica que nos diz o quão precisa é a precificação do mercado. No contexto das apostas esportivas, uma calibração perfeita raramente ocorre devido à margem de lucro da casa de apostas, mas a tendência dos dados deve seguir uma linha lógica. Se a frequência real de vitórias for sistematicamente muito menor do que a probabilidade indicada pela Odd, temos uma evidência clara de um mercado ineficiente para o apostador, mas lucrativa para a casa.
Dessa forma, para responder à pergunta norteadora deste projeto, agrupamos as partidas em faixas de probabilidade de 10% em 10%. O objetivo é confrontar a média da probabilidade vendida pela Bet365 com a taxa de sucesso real dos clubes mandantes em cada uma dessas faixas, conforme apresentado na tabela e no gráfico a seguir:
# Separação por faixas de probabilidade
faixas_prob <- brasileirao %>%
drop_na(OddMandante, Resultado) %>%
mutate(
Prob_Implicita = (1 / OddMandante) * 100,
Faixa_Prob = cut(Prob_Implicita,
breaks = seq(0, 100, by = 10),
include.lowest = TRUE,
labels = c("0-10%", "10-20%", "20-30%", "30-40%", "40-50%",
"50-60%", "60-70%", "70-80%", "80-90%", "90-100%")
),
VitoriaMandante = (Resultado == "Vitória")
) %>%
drop_na(Faixa_Prob)
resumo_faixas <- faixas_prob %>%
group_by(Faixa_Prob) %>%
summarise(
Jogos = n(),
Vitorias = sum(VitoriaMandante),
Prob_Observada = mean(VitoriaMandante),
Prob_Implicita_Media = mean(1 / OddMandante)
) %>%
mutate(
Diferenca = Prob_Observada - Prob_Implicita_Media
)
resumo_faixas %>%
mutate(
Prob_Observada = Prob_Observada * 100,
Prob_Implicita_Media = Prob_Implicita_Media * 100,
Diferenca = Diferenca * 100
) %>%
gt() %>%
fmt_integer(columns = c(Jogos, Vitorias)) %>%
fmt_number(
columns = c(Prob_Observada, Prob_Implicita_Media, Diferenca),
decimals = 1,
dec_mark = ",",
pattern = "{x}%"
) %>%
cols_label(
Faixa_Prob = "Faixa da Odd (Mandante)",
Jogos = "Nº de Jogos",
Vitorias = "Vitórias do Mandante",
Prob_Observada = "Probabilidade Observada",
Prob_Implicita_Media = "Probabilidade Implícita Média",
Diferenca = "Diferença (Obs - Impl)"
) %>%
tab_style(
style = list(
cell_fill(color = "#1B4F72"),
cell_text(color = "white", weight = "bold")
),
locations = cells_column_labels()
) %>%
tab_options(
table.width = pct(100),
column_labels.background.color = "#1B4F72",
table.border.top.color = "transparent",
table.border.bottom.color = "#1B4F72"
) %>%
cols_align(align = "center", columns = everything()) %>%
opt_row_striping()
| Faixa da Odd (Mandante) | Nº de Jogos | Vitórias do Mandante | Probabilidade Observada | Probabilidade Implícita Média | Diferença (Obs - Impl) |
|---|---|---|---|---|---|
| 0-10% | 2 | 0 | 0,0% | 6,7% | −6,7% |
| 10-20% | 61 | 5 | 8,2% | 16,4% | −8,2% |
| 20-30% | 330 | 70 | 21,2% | 25,8% | −4,6% |
| 30-40% | 893 | 310 | 34,7% | 35,6% | −0,9% |
| 40-50% | 1,254 | 572 | 45,6% | 45,2% | 0,5% |
| 50-60% | 1,098 | 588 | 53,6% | 55,0% | −1,5% |
| 60-70% | 836 | 529 | 63,3% | 64,5% | −1,3% |
| 70-80% | 329 | 241 | 73,3% | 74,0% | −0,7% |
| 80-90% | 44 | 37 | 84,1% | 82,9% | 1,2% |
| 90-100% | 2 | 2 | 100,0% | 92,2% | 7,8% |
# Gráfico de Calibração
ggplot(resumo_faixas, aes(x = Prob_Implicita_Media, y = Prob_Observada)) +
geom_point(size = 2, color = Cor2) +
geom_abline(slope = 1, intercept = 0, linetype = 3) +
labs(
title = "Calibração das Odds do Mandante",
x = "Probabilidade Implícita Média",
y = "Taxa de Vitória Observada"
) +
theme(
axis.line.x = element_line(color = "#2C3E50", size = 0.8),
axis.line.y = element_line(color = "#2C3E50", size = 0.8)
)
Com esses resultados, chegamos à conclusão que:
Na verdade, para considerar a margem de lucro da casa, devemos considerar não só apenas a odd do Mandante, mas de todos os resultados. Assim, vamos ver como a margem da Bet365 com os jogos do Brasileirão se comportou ao longo dos anos.
# Cálculo da mragem total
df_margem <- brasileirao %>%
mutate(
Soma_Probs = (1/OddMandante + 1/OddEmpate + 1/OddVisitante) * 100,
Margem = Soma_Probs - 100,
Ano = year(Data)
)
evolucao_margem <- df_margem %>%
group_by(Ano) %>%
summarise(Margem_Media = mean(Margem)) %>%
filter(Ano >= 2012 & Ano <= 2024)
ggplot(evolucao_margem, aes(x = Ano, y = Margem_Media)) +
geom_line(color = Cor3) +
geom_point(color = Cor1, size = 2) +
labs(
title = "Evolução Margem da Bet365 nos jogos do Brasileirão",
x = "Ano",
y = "Margem (%)"
) +
geom_text(aes(label = paste0(round(Margem_Media, 1), "%")),
vjust = -0.8,
size = 3,
fontface = "bold",
color = "#2C3E50") +
scale_x_continuous(breaks = 2012:2024) +
coord_cartesian(ylim = c(0, 10)) +
theme(
axis.text.y = element_blank()
)
Assim, percebemos que a margem real é superior à da obtida apenas analisando as odds dos mandantes. Além disso, há uma tendência de queda dessa margem ao longo dos anos.
É importante lembrar que a modalidade de aposta analisada não é a única. Neste próprio dataset há outras variáveis que são objeto de aposta como número de cartões, escanteios e gols marcados, por exemplo. Assim, a margem calculada aqui não é a real dessa casa de aposta, mas apenas considerando o objeto deste estudo;
Quase metade dos jogos termina com vitória do mandante, e isso se reflete nas odds, uma vez o mandante tem maior probabilidade implícita maioria das partidas. A precificação do mercado acompanha esse padrão histórico. Os times que melhor exercem essa vantagem tiveram cerca de 60% de vitória em casa, no período analisado;
Quando comparamos probabilidades implícitas com frequências observadas, vemos que a Bet365 de modo geral é bem calibrada. Nas faixas onde está concentrada a maior parte dos jogos, a diferença entre o que foi precificado e o que de fato aconteceu é pequena. Não há evidência de distorções sistemáticas que indiquem erro grosseiro na formação das odds;
No entanto, sob a ótica da casa de apostas, o ponto central não está na taxa de acerto, mas na margem. Ao somar as probabilidades implícitas dos três resultados, fica claro que a casa opera consistentemente acima de 100%. Essa diferença é estrutural e aparece em todos os anos analisados, ainda que com leve tendência de redução ao longo do tempo. Ou seja, o mercado pode até estar bem calibrado, mas ele está calibrado com margem embutida.
A principal conclusão é que acertar mais não significa necessariamente ganhar dinheiro. Cerca de metade dos favoritos vencem, mas isso já está incorporado na odd. A aposta só seria vantajosa se a probabilidade real fosse maior do que a precificada para compensar o risco, algo que não aparece de forma consistente nos dados analisados.
Também não há uma faixa de probabilidade claramente mal precificada que indique uma estratégia simples de lucro. Pequenas diferenças existem, mas são pontuais e diluídas pela margem da casa.
A análise realizada mostra que o mercado não precisa prever perfeitamente os resultados. Ele precisa apenas precificar risco com uma vantagem estrutural. Para o apostador comum, isso significa começar sempre em desvantagem matemática. Por isso, as casas de apostas tendem a ganhar a longo prazo.