Introdução

Objetivo

Este estudo analisa a relação entre a taxa SELIC e o comportamento das ações listadas na B3 no período de 2015 a 2022.

Perguntas-chave:

  • Como os ciclos de juros (SELIC) impactam a rentabilidade dos ativos?
  • Existe diferença de desempenho entre ativos mais líquidos e mais voláteis?
  • Como a bolsa se compara ao retorno acumulado de um investimento 100% em SELIC?

Metodologia

  • Dados: Preços diários de ações da B3 + indicadores econômicos mensais
  • Filtros: Liquidez mínima (Volume médio ≥ 1M), presença em todos os anos (2015–2022), símbolos com 5 caracteres para considerar apenas empresas (remover fundos de investimentos)
  • Métricas: volatilidade, rentabilidade acumulada
  • Benchmark: Retorno acumulado da SELIC

Pacotes necessários

Carregamento

library(readr)     
library(dplyr)     
library(tidyr)     
library(lubridate) 
library(ggplot2)   
library(zoo)       
library(slider)    
library(scales)    

Pré-processamento

Importação

# Caminhos dos arquivos
stocks_path <- "bovespa_stocks.csv"
macro_path  <- "economic_indicators.csv"

# Le arquivos
stocks_raw <- readr::read_csv(stocks_path, show_col_types = FALSE)
macro_raw  <- readr::read_csv(macro_path, show_col_types = FALSE)

# Conversão de datas
stocks_raw <- stocks_raw %>% mutate(Date = as.Date(Date))
macro_raw  <- macro_raw  %>% mutate(Date = as.Date(Date))

Filtros aplicados

# Período: 2015-01-01 a 2022-12-31
start_date <- as.Date("2015-01-01")
end_date   <- as.Date("2022-12-31")

stocks_period <- stocks_raw %>% filter(Date >= start_date & Date <= end_date)

# Remover duplicidades
stocks_period <- stocks_period %>% distinct(Symbol, Date, .keep_all = TRUE)

# Filtro 1: Liquidez (Volume médio >= 1,000,000)
liq_stats <- stocks_period %>% 
  group_by(Symbol) %>% 
  summarize(mean_vol = mean(Volume, na.rm = TRUE))

keep_liquid <- liq_stats %>% filter(mean_vol >= 1000000) %>% pull(Symbol)
stocks_period <- stocks_period %>% filter(Symbol %in% keep_liquid)

# Filtro 2: Símbolos com exatamente 5 caracteres (ex: ITUB4, PETR4).
# Este filtro ajuda a removermos ativos que nao sao empresas (fundos de investimentos, por exemplo)
stocks_period <- stocks_period %>% filter(nchar(Symbol) == 5)

# Filtro 3: Presença em todos os anos do período
anos_periodo <- seq(lubridate::year(start_date), lubridate::year(end_date))
anos_por_symbol <- stocks_period %>%
  mutate(ano = lubridate::year(Date)) %>%
  distinct(Symbol, ano) %>%
  count(Symbol, name = "anos_com_dados")

full_coverage <- anos_por_symbol %>%
  filter(anos_com_dados >= length(anos_periodo)) %>%
  pull(Symbol)

stocks_final <- stocks_period %>% filter(Symbol %in% full_coverage)

Indicadores macro

# Extrair apenas SELIC
macro <- macro_raw %>% 
  select(Date, selic = `Taxa Selic`) %>%
  filter(Date >= start_date & Date <= end_date)

# Forward fill: repetir o valor enquanto nos registros seguintes for nulo
macro <- macro %>% 
  arrange(Date) %>%
  mutate(selic_ff = zoo::na.locf(selic, na.rm = FALSE))

# Variação da SELIC
macro <- macro %>% 
  mutate(selic_chg = selic_ff - lag(selic_ff))

Consolidação/Merge

# Merge diário por Date
final_data <- stocks_final %>% 
  left_join(macro %>% select(Date, selic_ff, selic_chg), 
            by = "Date")

# Calcular retornos e volatilidade por Symbol
final_data <- final_data %>% 
  group_by(Symbol) %>% 
  arrange(Date, .by_group = TRUE) %>% 
  mutate(
    ret_d = Close / lag(Close) - 1,
    vol21 = slider::slide_dbl(ret_d, sd, .before = 20, .complete = TRUE)
  ) %>%
  ungroup()

Resumo do dataset

Dataset de ações (bovespa_stocks.csv):

Coluna Descrição
Date Data da observação (formato YYYY-MM-DD)
Symbol Ticker do ativo
Open Preço de abertura
High Preço máximo do dia
Low Preço mínimo do dia
Close Preço de fechamento
Adj Close Preço de fechamento ajustado (splits, dividendos)
Volume Volume financeiro negociado

Dataset de indicadores macro (economic_indicators.csv):

Coluna Descrição
Date Data da divulgação/vigência
Taxa Selic Taxa básica de juros (% a.a.)
IPCA Índice de preços ao consumidor amplo (% acumulado)
IGP-M Índice geral de preços - mercado (% acumulado)
INPC Índice nacional de preços ao consumidor (% acumulado)
Desemprego PNADC Taxa de desemprego PNAD Contínua (%)

Análise exploratória

Rentabilidade acumulada: Média vs SELIC

# Retorno médio diário agregado
agg_daily <- final_data %>% 
  group_by(Date) %>% 
  summarize(ret_mean = mean(ret_d, na.rm = TRUE))

# Caminho acumulado da média dos ativos
market_cum <- agg_daily %>%
  arrange(Date) %>%
  mutate(mkt_cum = cumprod(1 + coalesce(ret_mean, 0)) - 1)

# Caminho acumulado SELIC (taxa anual -> fator diário)
selic_path <- macro %>%
  arrange(Date) %>%
  mutate(
    selic_daily_ret = (1 + selic_ff/100)^(1/252) - 1,
    selic_cum = cumprod(1 + selic_daily_ret) - 1
  ) %>%
  select(Date, selic_cum)

# Juntar
plot_df <- market_cum %>%
  left_join(selic_path, by = "Date") %>%
  tidyr::pivot_longer(cols = c(mkt_cum, selic_cum), names_to = "Serie", values_to = "ret_cum") %>%
  mutate(Serie = dplyr::recode(Serie, mkt_cum = "Média Ativos (igual-ponderada)", selic_cum = "SELIC (100%)"))

ggplot(plot_df, aes(Date, ret_cum, color = Serie)) +
  geom_line(size = 1) +
  scale_y_continuous(labels = scales::percent_format(accuracy = 1)) +
  scale_color_manual(values = c("Média Ativos (igual-ponderada)" = "steelblue", "SELIC (100%)" = "firebrick")) +
  labs(title = "Rentabilidade acumulada: Média dos ativos filtrados vs investimento 100% SELIC",
       subtitle = "Período: 2015–2022",
       x = "Data", y = "Retorno acumulado", color = NULL) +
  theme_minimal() +
  theme(legend.position = "top")

Interpretação:

  • Linha azul: retorno médio diário igual-ponderado dos ativos filtrados, composto ao longo do tempo
  • Linha vermelha: retorno de um investimento que replica 100% a taxa SELIC
  • Períodos de divergência indicam sobre/subperformance relativa da bolsa vs renda fixa

Top 10 ativos por rentabilidade acumulada

# Calcular rentabilidade acumulada por ativo
rent_by_asset <- final_data %>%
  group_by(Symbol) %>%
  summarize(
    first_price = first(Close[!is.na(Close)]),
    last_price  = last(Close[!is.na(Close)]),
    ret_cum = (last_price / first_price) - 1,
    mean_vol = mean(Volume, na.rm = TRUE)
  ) %>%
  arrange(desc(ret_cum))

# Top 10
top10_rent <- rent_by_asset %>%
  slice_head(n = 10) %>%
  mutate(pct = ret_cum, 
         label = paste0(Symbol, "\n", scales::percent(pct, accuracy = 0.1))) %>%
  arrange(pct) %>%
  mutate(Symbol = factor(Symbol, levels = Symbol))

ggplot(top10_rent, aes(x = "", y = abs(pct), fill = Symbol)) +
  geom_col(width = 1, color = "white") +
  coord_polar(theta = "y") +
  geom_text(aes(label = label), position = position_stack(vjust = 0.5), size = 3.5) +
  labs(title = "Top 10 ativos com maior rentabilidade acumulada (2015–2022)",
       x = NULL, y = NULL) +
  theme_void() +
  theme(legend.position = "none")

Volume diário agregado

agg_volume <- final_data %>%
  group_by(Date) %>%
  summarize(volume_total = sum(Volume, na.rm = TRUE))

ggplot(agg_volume, aes(Date, volume_total)) +
  geom_line(color = "darkorange", alpha = 0.8) +
  geom_smooth(se = FALSE, color = "black", method = "loess", span = 0.15) +
  scale_y_continuous(labels = scales::comma) +
  labs(title = "Volume diário total negociado (ativos filtrados)",
       subtitle = "Linha preta: tendência suavizada",
       x = "Data", y = "Volume total") +
  theme_minimal()

Evolução da SELIC

ggplot(macro, aes(Date, selic_ff)) +
  geom_line(color = "firebrick", size = 1) +
  labs(title = "Taxa SELIC ao longo do período (2015–2022)",
       x = "Data", y = "SELIC (% a.a.)") +
  theme_minimal()

Ciclos identificados:

  • 2015–2016: Patamar elevado (>14%)
  • 2017–2019: Tendência de queda
  • 2020: Mínima histórica (~2%)
  • 2021–2022: Ciclo de alta acentuado

Volatilidade e grupos

Grupos por volatilidade

# Calcular volatilidade por ativo
vol_stats <- final_data %>%
  group_by(Symbol) %>%
  summarize(
    vol_symbol = sd(ret_d, na.rm = TRUE),
    first_price = first(Close[!is.na(Close)]),
    last_price  = last(Close[!is.na(Close)]),
    cum_ret = (last_price / first_price) - 1,
    n_obs = sum(!is.na(ret_d))
  ) %>%
  filter(n_obs > 50, !is.na(vol_symbol))

if (nrow(vol_stats) > 1) {
  median_vol <- median(vol_stats$vol_symbol, na.rm = TRUE)
  vol_stats <- vol_stats %>% 
    mutate(vol_group = if_else(vol_symbol > median_vol, "Mais voláteis", "Menos voláteis"))

  # Caminho acumulado por grupo
  group_paths <- final_data %>%
    semi_join(vol_stats, by = "Symbol") %>%
    left_join(vol_stats %>% select(Symbol, vol_group), by = "Symbol") %>%
    group_by(vol_group, Date) %>%
    summarize(group_ret = mean(ret_d, na.rm = TRUE), .groups = "drop") %>%
    arrange(vol_group, Date) %>%
    group_by(vol_group) %>%
    mutate(group_cum = cumprod(1 + coalesce(group_ret, 0)) - 1) %>%
    ungroup()

  ggplot(group_paths, aes(Date, group_cum, color = vol_group)) +
    geom_line(size = 1) +
    scale_y_continuous(labels = scales::percent_format(accuracy = 1)) +
    scale_color_manual(values = c("Mais voláteis" = "tomato", "Menos voláteis" = "steelblue")) +
    labs(title = "Rentabilidade acumulada por grupo de volatilidade",
         subtitle = "Grupos definidos pela mediana do desvio-padrão dos retornos diários",
         x = "Data", y = "Retorno acumulado", color = "Grupo") +
    theme_minimal() +
    theme(legend.position = "top")
} else {
  cat("Dados insuficientes para grupos de volatilidade.\n")
}

Top 10 mais voláteis

if (exists("vol_stats") && nrow(vol_stats) > 0) {
  top10_vol <- vol_stats %>% 
    arrange(desc(vol_symbol)) %>% 
    slice_head(n = 10) %>%
    mutate(pct = vol_symbol / sum(vol_symbol), 
           label = paste0(Symbol, " ", scales::percent(pct, accuracy = 0.1))) %>%
    arrange(pct) %>% 
    mutate(Symbol = factor(Symbol, levels = Symbol))

  ggplot(top10_vol, aes(x = "", y = pct, fill = Symbol)) +
    geom_col(width = 1, color = "white") +
    coord_polar(theta = "y") +
    geom_text(aes(label = label), position = position_stack(vjust = 0.5), size = 3) +
    labs(title = "Top 10 ativos mais voláteis",
         x = NULL, y = NULL) +
    theme_void() +
    theme(legend.position = "none")
}

Top 10 menos voláteis

if (exists("vol_stats") && nrow(vol_stats) > 0) {
  bottom10_vol <- vol_stats %>% 
    arrange(vol_symbol) %>% 
    slice_head(n = 10) %>%
    mutate(pct = vol_symbol / sum(vol_symbol), 
           label = paste0(Symbol, " ", scales::percent(pct, accuracy = 0.1))) %>%
    arrange(pct) %>% 
    mutate(Symbol = factor(Symbol, levels = Symbol))

  ggplot(bottom10_vol, aes(x = "", y = pct, fill = Symbol)) +
    geom_col(width = 1, color = "white") +
    coord_polar(theta = "y") +
    geom_text(aes(label = label), position = position_stack(vjust = 0.5), size = 3) +
    labs(title = "Top 10 ativos menos voláteis",
         x = NULL, y = NULL) +
    theme_void() +
    theme(legend.position = "none")
}