Câmbio visto por bancos centrais

Integração e análise de dados cambiais (2019–2026)

1. Introdução

1.1 Problema e relevância

Empresas, consumidores e órgãos públicos convertem valores entre moedas o tempo todo: importação e exportação, faturas, declaração de impostos, viagens e estudos no exterior. Em muitos desses casos o que vale é a taxa oficial publicada por uma instituição (banco central ou autoridade tributária), e não o preço de mercado do momento. O problema é que cada instituição publica à sua maneira: em dias, direções (EUR → USD ou USD → EUR) e modalidades (buy, sell, reference…) diferentes. Comparar essas séries sem harmonizá-las leva a conclusões equivocadas.

Este relatório parte de um caso concreto de integração de dados: sete instituições, arquivos CSV e JSON com estruturas diferentes e muitos valores ausentes que só aparecem depois de organizar os dados por data.

Perguntas

  1. Desde janeiro de 2019, frente a quais moedas o dólar mais se valorizou, em que anos aconteceram os maiores choques e quão instável foi essa trajetória? Quem tem receitas em uma moeda e custos em dólar (ou o contrário) está exposto a esse movimento? A variação acumulada sozinha esconde os choques; por isso, também observa-se a variação ano a ano e a volatilidade.

  2. As taxas oficiais de instituições diferentes para o mesmo par (dólar em reais) são intercambiáveis? Contratos, notas fiscais e impostos costumam apontar uma fonte específica (por exemplo, o PTAX do Banco Central do Brasil). Se as fontes divergem de forma sistemática, a escolha importa; se divergem pouco e sem viés, ela é apenas um detalhe operacional.

1.2 Dados e metodologia

Os dados vêm do repositório público AllRates-Today/central-bank-exchange-rates, que reúne taxas oficialmente publicadas por bancos centrais e autoridades tributárias. O recorte considera sete instituições (BCB, FED, ECB, PBoC, BoC, BoE e BoK) e arquivos anuais de 2019 a 2026, combinados com metadados em JSON. O fluxo de trabalho tem cinco etapas: importar, padronizar as cotações para uma mesma unidade, integrar as fontes, tratar os dias sem publicação (valores ausentes) e, por fim, explorar os dados com variáveis derivadas.

1.3 Técnica adotada

A abordagem é exploratória e descritiva. Para responder à primeira pergunta, as séries de cada moeda são convertidas em um índice (base 100 = primeira cotação), o que permite compará-las mesmo estando em unidades diferentes; sobre elas calculamos variação acumulada, variação anual e volatilidade anualizada (inclusive em janela móvel). Para a segunda pergunta, o dólar em reais do BCB (PTAX, venda) é a referência; as séries do FED e do ECB são comparadas a ela dia a dia. Como o ECB não publica dólar em reais, ele é calculado (EUR→BRL ÷ EUR→USD).

Para dar contexto à história, marcamos no tempo quatro eventos globais e medimos o que o dólar fez nos 30 dias seguintes a cada um; depois, ampliamos o olhar com um mapa-múndi do dólar frente a mais de cem moedas.

1.4 Como a análise ajuda os clientes

  • Áreas financeiras, de controladoria e de comércio exterior: passam a ter uma medida concreta de quanto a escolha da fonte oficial altera a conversão.
  • Contadores e analistas: recebem uma base padronizada, com dicionário de variáveis, que pode ser reutilizada para outros pares de moedas (basta alterar moeda_foco e bancos).

2. Pacotes requeridos

# Lista completa de pacotes usados no relatório
pacotes <- c("readr", "dplyr", "tidyr", "purrr", "tibble", "stringr", "jsonlite", "zoo",
             "htmltools", "knitr", "kableExtra", "ggplot2", "scales", "plotly", "DT",
             "maps", "rmdformats")

# Instala apenas o que ainda não está instalado
faltam <- setdiff(pacotes, rownames(installed.packages()))
if (length(faltam)) install.packages(faltam, repos = "https://cloud.r-project.org")

# rmdformats só fornece o tema do HTML (declarado no cabeçalho) e maps só é usado com `maps::`
# (carregá-lo com library() esconderia a função purrr::map); por isso nenhum dos dois entra no library()
invisible(lapply(setdiff(pacotes, c("rmdformats", "maps")), library, character.only = TRUE))
Pacotes utilizados e para que servem
Pacote Finalidade
readr Importa os CSVs históricos com tipos de coluna definidos explicitamente
jsonlite Lê os metadados e as cotações mais recentes em JSON (estruturas aninhadas)
dplyr Filtra, cria variáveis, agrupa, resume e faz junções entre tabelas
tidyr Reorganiza os dados entre formato longo e largo (pivot_wider, pivot_longer, expand_grid)
purrr Aplica funções a vários arquivos e listas (map, pwalk), evitando laços repetitivos
tibble Cria tabelas de apoio (tibble, tribble), como o dicionário de variáveis
stringr Manipula texto (localização de trechos nos JSONs, truncamento de textos longos)
zoo Calcula a volatilidade em janela móvel (rollapplyr)
htmltools Escapa texto e monta links seguros nas tabelas HTML
knitr Gera as tabelas (kable) e controla a execução do relatório
kableExtra Formata as tabelas HTML (listras, rolagem horizontal e vertical)
ggplot2 Constrói os gráficos estáticos e as bases dos gráficos interativos
scales Controla escalas e rótulos dos gráficos
plotly Torna os gráficos interativos (zoom, filtro por série, dica ao passar o mouse)
DT Gera tabelas interativas com busca, filtros por coluna, paginação e download de amostras
maps Fornece os nomes dos países (códigos ISO-3) usados no mapa-múndi
rmdformats Fornece o formato readthedown do HTML final (índice lateral)
# Tabela estática simples (as primeiras `n` linhas, formato numérico brasileiro)
tabela <- function(x, legenda = NULL, n = 10, digits = 4, rolagem = NULL,
                   horizontal = FALSE, fonte = 12) {
  t <- x |>
    head(n) |>
    kable(format = "html", caption = legenda, digits = digits,
          format.args = list(big.mark = ".", decimal.mark = ",")) |>
    kable_styling(bootstrap_options = "striped", full_width = FALSE,
                  position = "left", font_size = fonte)
  if (!is.null(rolagem) || horizontal)
    t <- scroll_box(t, width = if (horizontal) "100%" else NULL, height = rolagem)
  t
}

# Tabela interativa (DataTables). Com `completo = TRUE` ela ganha busca, filtros por coluna,
# escolha de quantas linhas mostrar e botões para copiar/baixar o CSV do que está filtrado.
tabela_dt <- function(x, colunas_num = NULL, digits = 2, n = 10, completo = FALSE, legenda = NULL) {
  # textos com poucas categorias viram fator: o filtro da coluna passa a ser uma lista de opções
  if (completo)
    x <- x |> mutate(across(where(is.character), \(v) if (n_distinct(v) <= 40) factor(v) else v))
  d <- datatable(
    x, rownames = FALSE, caption = legenda,
    filter = if (completo) "top" else "none",
    extensions = if (completo) "Buttons" else list(),
    options = list(
      pageLength = n, scrollX = TRUE, lengthMenu = c(5, 10, 25, 50, 100),
      dom = if (completo) "Blfrtip" else "tip",
      buttons = if (completo) list("copy", "csv") else NULL,
      language = list(search = "Buscar:", lengthMenu = "Mostrar _MENU_ linhas",
                      info = "Mostrando _START_ a _END_ de _TOTAL_", infoEmpty = "Sem registros",
                      infoFiltered = "(filtrado de _MAX_)", zeroRecords = "Nenhum registro encontrado",
                      paginate = list(`next` = "Próxima", previous = "Anterior")))
  )
  if (!is.null(colunas_num))
    d <- formatRound(d, colunas_num, digits = digits, mark = ".", dec.mark = ",")
  d
}

# Tema simples para manter o foco nos dados
theme_story <- function(base_size = 12) {
  theme_minimal(base_size = base_size) +
    theme(
      plot.title = element_text(face = "bold", size = rel(1.12)),
      plot.subtitle = element_text(size = rel(0.92), color = "grey35"),
      plot.caption = element_text(size = rel(0.78), color = "grey40", hjust = 0),
      panel.grid.minor = element_blank(),
      panel.grid.major.x = element_blank(),
      legend.position = "bottom",
      plot.background = element_rect(fill = "white", color = NA),
      panel.background = element_rect(fill = "white", color = NA)
    )
}

# Paleta discreta usada nos gráficos
pal <- c(primaria = "#46697D", secundaria = "#8297A5", destaque = "#C7B58A",
         alerta = "#A96F6A", ok = "#789080", cinza = "#777777", fundo = "#F1F3F4")

# Formatação de números e percentuais no padrão brasileiro (vírgula decimal)
fmt <- function(x, d = 1) formatC(x, format = "f", digits = d, big.mark = ".", decimal.mark = ",")
pct <- function(x, d = 1) paste0(fmt(x, d), "%")
pct_s <- function(x, d = 1) paste0(ifelse(x > 0, "+", ""), pct(x, d))   # percentual com sinal
fonte_txt <- "Fonte: AllRates-Today/central-bank-exchange-rates (CC BY-NC 4.0)"

3. Preparação dos dados

3.1 Fonte dos dados

Os dados foram obtidos do repositório AllRates-Today/central-bank-exchange-rates (pasta data), mantido pelo serviço AllRatesToday. Os arquivos são servidos diretamente pelo GitHub, sem chave de acesso, sob licença CC BY-NC 4.0.

3.2 Entendendo os dados de origem

  • Propósito original. Segundo o README do repositório (consultado em novembro de 2026), a coleção reúne as taxas oficiais de 122 bancos centrais e 4 autoridades tributárias, publicadas como arquivos JSON e CSV. Cada número é o que a própria instituição divulgou, e não uma taxa de mercado ou interbancária; por isso serve para faturas, declarações de IVA, alfândega, preços de transferência e trilhas de auditoria.
  • Quando foi coletado. O repositório é atualizado a cada dia de publicação. O histórico fica em um CSV por instituição e por ano; a partir de setembro de 2026 também há um arquivo JSON diário. Neste relatório o último ano (2026) é baixado de novo a cada execução, então ele pode estar incompleto.
  • Variáveis. Cada linha do CSV histórico tem cinco campos: date, base, quote, type e value. Uma linha significa “1 unidade de base custa value unidades de quote, na data date, segundo o tipo type”. Três JSONs complementam o CSV: sources.json (nome, país, moeda local, tipo de instituição e página oficial), index.json (frequência e data da última publicação) e [banco]/latest.json (última tabela, com texto de licença).
  • Peculiaridades.
    • O CSV não traz o código do banco; ele só está no nome do arquivo.
    • A direção da cotação segue a fonte: o BCB publica X → BRL, o ECB publica EUR → X e o FED mistura as duas formas.
    • Valores ausentes não são registrados como NA. Quando a instituição não publica em um dia, a linha simplesmente não existe. Os NA só aparecem depois de combinar os dados com um calendário (seção 3.3.5).
    • A frequência varia: o index.json indica publicação diária para a maioria das instituições e semanal para o FED.

3.3 Importação e limpeza

A preparação segue uma sequência lógica, e cada etapa mostra o “antes” e o “depois”:

  1. Baixar os arquivos (uma única vez; o último ano é atualizado sempre).
  2. Importar os CSVs com tipos explícitos e recuperar o banco pelo nome do arquivo.
  3. Importar os JSONs e transformar listas aninhadas em tabelas.
  4. Padronizar as cotações para uma mesma unidade e juntar as informações de cada instituição.
  5. Criar um calendário completo para que os dias sem publicação apareçam como NA com um motivo.
  6. Reorganizar os dados do formato longo para o largo, para comparar as instituições lado a lado.
  7. Reunir os metadados das instituições em um catálogo.
# Parâmetros do relatório: altere aqui para analisar outros bancos, anos ou moedas
repoURL    <- "https://raw.githubusercontent.com/AllRates-Today/central-bank-exchange-rates/main"
baseURL    <- paste0(repoURL, "/data")
bancos     <- c("bcb", "fed", "ecb", "pboc", "boc", "boe", "bok")
anos       <- 2019:2026   # o repo vai até nov/2026; 2019:2025 perdia ~99 mil linhas
moeda_foco <- "USD"       # moeda do painel largo (troque por "BRL", "EUR"...)

# Séries analisadas na Pergunta 1: o dólar visto por cada banco central
# (nome = banco_tipo, valor = rótulo exibido nos gráficos)
series_foco <- c(bcb_sell = "Real (BCB)", ecb_reference = "Euro (ECB)",
                 boe_reference = "Libra (BoE)", boc_indicative = "Dólar canadense (BoC)",
                 bok_reference = "Won (BoK)", pboc_reference = "Yuan (PBoC)")

# Pastas onde os arquivos baixados ficam guardados
dir_csv <- "dados/csv";   dir_json <- "dados/json"
dir_out  <- "dados/consolidado"
for (d in c(dir_csv, dir_json, dir_out))
  dir.create(d, recursive = TRUE, showWarnings = FALSE)
# Baixa um arquivo apenas se ele ainda não existir (ou se `atualizar = TRUE`)
baixar <- function(url, destino, atualizar = FALSE) {
  if (atualizar || !file.exists(destino)) {
    tryCatch(download.file(url, destino, mode = "wb", quiet = TRUE),
             error   = function(e) warning("Falhou: ", url, call. = FALSE),
             warning = function(w) warning("Falhou: ", url, call. = FALSE))
  }
}

# Um CSV por banco e por ano; o último ano é sempre atualizado
csv_arq <- expand_grid(banco = bancos, ano = anos) |>
  mutate(url       = paste0(baseURL, "/", banco, "/history/", ano, ".csv"),
         destino   = paste0(dir_csv, "/", banco, "_", ano, ".csv"),
         atualizar = ano == max(anos))
pwalk(list(csv_arq$url, csv_arq$destino, csv_arq$atualizar), baixar)

# JSONs: última tabela de cada banco + catálogos de instituições
json_arq <- tibble(
  nome = c(paste0(bancos, "_latest"), "index", "sources"),
  url  = c(paste0(baseURL, "/", bancos, "/latest.json"),
           paste0(baseURL, "/index.json"),
           paste0(baseURL, "/sources.json"))
) |> mutate(destino = paste0(dir_json, "/", nome, ".json"), atualizar = TRUE)
pwalk(list(json_arq$url, json_arq$destino, json_arq$atualizar), baixar)

# Conferência: todos os arquivos esperados foram baixados?
tibble(tipo = c("CSV (history)", "JSON"),
       arquivos = c(sum(file.exists(csv_arq$destino)), sum(file.exists(json_arq$destino))),
       esperado = c(nrow(csv_arq), nrow(json_arq))) |>
  tabela("Arquivos baixados")
Arquivos baixados
tipo arquivos esperado
CSV (history) 56 56
JSON 9 9

3.3.1 Registros históricos em CSV

Como os dados chegam: três arquivos de 2025, em texto puro. Repare na direção dos pares: o BCB publica X → BRL com buy e sell; o ECB publica EUR → X; o FED mistura as duas.

# Mostra as primeiras linhas de um arquivo, sem nenhuma interpretação
mostrar_cru <- function(arq, n = 4) {
  cat("arquivo:", basename(arq), "\n"); cat(read_lines(arq, n_max = n), sep = "\n"); cat("\n")
}
mostrar_cru(file.path(dir_csv, "bcb_2025.csv"))
arquivo: bcb_2025.csv 
date,base,quote,type,value
2025-01-02,AED,BRL,buy,1.69
2025-01-02,AED,BRL,sell,1.6905
2025-01-02,AFN,BRL,buy,0.08794
mostrar_cru(file.path(dir_csv, "ecb_2025.csv"))
arquivo: ecb_2025.csv 
date,base,quote,type,value
2025-01-02,EUR,AUD,reference,1.6618
2025-01-02,EUR,BGN,reference,1.9558
2025-01-02,EUR,BRL,reference,6.42
mostrar_cru(file.path(dir_csv, "fed_2025.csv"))
arquivo: fed_2025.csv 
date,base,quote,type,value
2025-01-02,AUD,USD,reference,0.6208
2025-01-02,EUR,USD,reference,1.0261
2025-01-02,GBP,USD,reference,1.2376

O que foi feito e por quê: o CSV não traz o identificador do banco em cada linha; sem essa informação não seria possível separar as instituições, então ela é recuperada do nome do arquivo durante a leitura. Os tipos das colunas são definidos explicitamente (date como Date e value como double), para o R não precisar inferi-los e não confundir datas ou números com texto. Por fim, os 56 arquivos (7 bancos × 8 anos) são empilhados em uma única tabela, sem filtragem de linhas.

cotacoes_raw <- csv_arq |>
  filter(file.exists(destino)) |>
  # lê cada arquivo com tipos definidos; o banco vem do próprio data frame de arquivos
  mutate(dados = map(destino, \(f)
    read_csv(f, show_col_types = FALSE,
             col_types = cols(date = col_date(), base = col_character(),
                              quote = col_character(), type = col_character(),
                              value = col_double())))) |>
  select(banco, dados) |>
  unnest(dados)

Como ficaram os dados:

cotacoes_raw |>
  group_by(banco) |> slice_head(n = 2) |> ungroup() |>
  tabela("Duas primeiras linhas de cada banco (já com a coluna `banco`)", n = 14)
Duas primeiras linhas de cada banco (já com a coluna banco)
banco date base quote type value
bcb 2019-01-02 AED BRL buy 1,0505
bcb 2019-01-02 AED BRL sell 1,0508
boc 2019-01-02 AUD CAD indicative 0,9515
boc 2019-01-02 BRL CAD indicative 0,3562
boe 2019-01-02 GBP AUD reference 1,8014
boe 2019-01-02 GBP CAD reference 1,7156
bok 2019-01-02 AED KRW reference 304,4000
bok 2019-01-02 ARS KRW reference 29,6900
ecb 2019-01-02 EUR AUD reference 1,6273
ecb 2019-01-02 EUR BGN reference 1,9558
fed 2019-01-02 AUD USD reference 0,7000
fed 2019-01-02 EUR USD reference 1,1357
pboc 2019-01-02 AUD CNY reference 4,8253
pboc 2019-01-02 CAD CNY reference 5,0257
tibble(coluna = names(cotacoes_raw),
       tipo_no_R = map_chr(cotacoes_raw, \(x) class(x)[1]),
       NA_no_bruto = map_int(cotacoes_raw, \(x) sum(is.na(x)))) |>
  tabela("Tipos lidos e NA no CSV bruto", digits = 0)
Tipos lidos e NA no CSV bruto
coluna tipo_no_R NA_no_bruto
banco character 0
date Date 0
base character 0
quote character 0
type character 0
value numeric 0

O CSV bruto não tem nenhum NA. Os valores ausentes aparecem depois, quando as cotações são organizadas por data e combinadas com o calendário (seções 3.3.5 e 3.3.6).

3.3.2 Informações sobre as instituições (JSON)

Como os dados chegam: o registro do BCB aparece em dois arquivos JSON diferentes, cada um com informações complementares.

# Mostra um trecho do JSON a partir de uma chave (sem interpretar a estrutura)
trecho <- function(arq, chave, n) {
  l <- read_lines(arq)
  i <- which(str_detect(l, paste0('^\\s*"', chave, '": \\{')))[1]
  cat("arquivo:", basename(arq), "-> chave", chave, "\n"); cat(l[i:(i + n)], sep = "\n"); cat("\n")
}
trecho(file.path(dir_json, "sources.json"), "bcb", 7)
arquivo: sources.json -> chave bcb 
  "bcb": {
    "country": "Brazil",
    "home_currency": "BRL",
    "kind": "central_bank",
    "name": "Banco Central do Brasil (PTAX)",
    "official_source": "https://olinda.bcb.gov.br/olinda/servico/PTAX/versao/v1/odata/",
    "page": "https://allratestoday.com/central-bank-rates-api/bcb/"
  },
trecho(file.path(dir_json, "index.json"),   "bcb", 11)
arquivo: index.json -> chave bcb 
  "bcb": {
   "name": "Banco Central do Brasil (PTAX)",
   "country": "Brazil",
   "home_currency": "BRL",
   "kind": "central_bank",
   "latest": "2026-11-06",
   "cadence": "daily",
   "stale": false,
   "currencies": 312,
   "latest_url": "https://raw.githubusercontent.com/AllRates-Today/central-bank-exchange-rates/main/data/bcb/latest.json",
   "page": "https://allratestoday.com/central-bank-rates-api/bcb/"
  },

O que foi feito e por quê: cada arquivo JSON organiza as instituições por uma chave própria (o código do banco). Essa chave vira uma coluna, o que permite juntar depois as informações com as cotações. Campos ausentes são representados por NA, sem eliminar a instituição. As informações de sources.json e index.json ficam em tabelas separadas até serem reunidas.

latest  <- map(set_names(bancos),
               \(b) fromJSON(file.path(dir_json, paste0(b, "_latest.json"))))
index   <- fromJSON(file.path(dir_json, "index.json"))
sources <- fromJSON(file.path(dir_json, "sources.json"))

# Converte a lista aninhada (uma chave por banco) em tabela, com o código do banco como coluna
lista_para_tibble <- function(l) {
  imap(l, \(x, k) map(x, \(v) if (is.null(v)) NA else v) |> as_tibble()) |>
    list_rbind(names_to = "codigo")
}

# "hkab" fica fora: não faz parte das sete instituições analisadas
sources_tbl <- lista_para_tibble(sources) |>
  filter(codigo != "hkab")
index_tbl   <- lista_para_tibble(index$sources) |>
  filter(codigo != "hkab")

Como ficaram os dados:

sources_tbl |> filter(codigo %in% c("bcb", "ecb")) |>
  select(codigo, name, country, home_currency, kind) |>
  tabela("sources.json: registros de exemplo", digits = 0)
sources.json: registros de exemplo
codigo name country home_currency kind
bcb Banco Central do Brasil (PTAX) Brazil BRL central_bank
ecb European Central Bank Eurozone EUR central_bank
index_tbl |> filter(codigo == "bcb") |>
  select(codigo, name, country, home_currency, kind, latest, cadence, stale, currencies) |>
  tabela("index.json: registro do BCB", digits = 0)
index.json: registro do BCB
codigo name country home_currency kind latest cadence stale currencies
bcb Banco Central do Brasil (PTAX) Brazil BRL central_bank 2026-11-06 daily FALSE 312
tibble(arquivo = c("sources.json", "index.json"),
       fontes  = c(nrow(sources_tbl), nrow(index_tbl))) |>
  tabela("Fontes consideradas em cada JSON", digits = 0)
Fontes consideradas em cada JSON
arquivo fontes
sources.json 130
index.json 130

3.3.3 Cotações mais recentes (latest.json)

O latest.json mistura metadados (texto) com uma lista aninhada de taxas.

cat(read_lines(file.path(dir_json, "bcb_latest.json"), n_max = 20), sep = "\n")
{
 "source": "bcb",
 "name": "Banco Central do Brasil (PTAX)",
 "country": "Brazil",
 "home_currency": "BRL",
 "kind": "central_bank",
 "date": "2026-11-06",
 "cadence": "daily",
 "rates": [
  {
   "base": "AED",
   "quote": "BRL",
   "type": "buy",
   "value": 1.4977
  },
  {
   "base": "AED",
   "quote": "BRL",
   "type": "sell",
   "value": 1.4979
cat("   ...\n")
   ...

O que foi feito e por quê: as taxas do campo rates são organizadas em uma tabela, representando a publicação mais recente de cada instituição. O texto da licença é guardado separadamente. Como o latest.json repete informações que também estão no CSV, comparamos a quantidade de taxas dos dois arquivos na mesma data: se coincidem, o CSV e o JSON contam a mesma história.

# Estrutura do JSON (apenas o primeiro nível)
str(latest$bcb, max.level = 1, give.attr = FALSE, vec.len = 2)
List of 11
 $ source         : chr "bcb"
 $ name           : chr "Banco Central do Brasil (PTAX)"
 $ country        : chr "Brazil"
 $ home_currency  : chr "BRL"
 $ kind           : chr "central_bank"
 $ date           : chr "2026-11-06"
 $ cadence        : chr "daily"
 $ rates          :'data.frame':    312 obs. of  4 variables:
 $ official_source: chr "https://olinda.bcb.gov.br/olinda/servico/PTAX/versao/v1/odata/"
 $ page           : chr "https://allratestoday.com/central-bank-rates-api/bcb/"
 $ license        : chr "CC BY-NC 4.0 — non-commercial use with attribution: AllRatesToday, https://allratestoday.com"
# Texto de licença de cada banco (vira uma coluna)
licencas <- tibble(
  codigo  = bancos,
  license = map_chr(latest, \(x) if (is.null(x$license)) NA_character_ else x$license)
)

Como ficaram os dados:

tibble(banco = bancos,
       data_latest  = map_chr(latest, \(x) x$date),
       taxas_no_json = map_int(latest, \(x) nrow(x$rates))) |>
  mutate(taxas_no_csv = map2_int(banco, data_latest,
                                 \(b, d) sum(cotacoes_raw$banco == b & cotacoes_raw$date == as.Date(d))),
         confere = taxas_no_json == taxas_no_csv) |>
  tabela("latest.json × CSV na mesma data", digits = 0)
latest.json × CSV na mesma data
banco data_latest taxas_no_json taxas_no_csv confere
bcb 2026-11-06 312 312 TRUE
fed 2026-10-30 22 22 TRUE
ecb 2026-11-06 29 29 TRUE
pboc 2026-11-09 25 25 TRUE
boc 2026-11-06 24 24 TRUE
boe 2026-11-05 23 23 TRUE
bok 2026-11-09 43 43 TRUE
licencas |> mutate(license = str_trunc(license, 85)) |>
  slice_head(n = 3) |> tabela("Licença (texto livre) como coluna", digits = 0)
Licença (texto livre) como coluna
codigo license
bcb CC BY-NC 4.0 — non-commercial use with attribution: AllRatesToday, https://allrate…
fed CC BY-NC 4.0 — non-commercial use with attribution: AllRatesToday, https://allrate…
ecb CC BY-NC 4.0 — non-commercial use with attribution: AllRatesToday, https://allrate…

3.3.4 Padronização e junção das cotações

Cada linha do CSV só sabe date, base, quote, type, value. Não há país, moeda local ou outros metadados institucionais, e a direção da cotação muda de banco para banco. Sem corrigir isso, comparar value entre instituições seria comparar coisas diferentes.

amostra <- cotacoes_raw |>
  filter(date == as.Date("2025-01-02"), banco %in% c("bcb", "ecb", "boc"),
         (base == "USD" & quote %in% c("BRL", "CAD") & type != "buy") | (base == "EUR" & quote == "USD"))
tabela(amostra, "ANTES: três linhas do CSV bruto", digits = 4)
ANTES: três linhas do CSV bruto
banco date base quote type value
bcb 2025-01-02 USD BRL sell 6,2086
ecb 2025-01-02 EUR USD reference 1,0321
boc 2025-01-02 USD CAD indicative 1,4418

As informações de cada instituição são acrescentadas às cotações a partir do código do banco: nome, país, moeda local e tipo de instituição vêm do sources.json; a frequência de publicação vem do index.json. Em seguida, as cotações são convertidas para uma unidade comum: a coluna local_por_estrangeira mantém value quando quote é a moeda local e usa 1/value quando base é a moeda local.

# Metadados de cada instituição (um registro por banco)
bancos_meta <- sources_tbl |>
  filter(codigo %in% bancos) |>
  select(banco = codigo, nome_instituicao = name, pais = country,
         moeda_local = home_currency, tipo_instituicao = kind) |>
  left_join(select(index_tbl, banco = codigo, cadencia = cadence), by = "banco")

cotacoes <- cotacoes_raw |>
  left_join(bancos_meta, by = "banco") |>
  mutate(
    # a moeda estrangeira é o lado que não é a moeda local do banco
    moeda_estrangeira = case_when(quote == moeda_local ~ base,
                                  base == moeda_local ~ quote,
                                  TRUE ~ NA_character_),
    # unidades da moeda local por 1 unidade da estrangeira (comparável entre bancos)
    local_por_estrangeira = case_when(quote == moeda_local ~ value,
                                      base == moeda_local ~ 1 / value,
                                      TRUE ~ NA_real_)
  ) |>
  select(date, banco, nome_instituicao, pais, moeda_local, tipo_instituicao, cadencia,
         base, quote, type, value, moeda_estrangeira, local_por_estrangeira) |>
  arrange(banco, date, base, quote, type)

# Travas de segurança: a junção não pode perder linhas nem mudar o número de colunas
stopifnot("linhas perdidas na junção" = nrow(cotacoes) == nrow(cotacoes_raw))
stopifnot("base final não tem 13 colunas" = ncol(cotacoes) == 13)

Como ficaram os dados:

cotacoes |>
  semi_join(amostra, by = c("date", "banco", "base", "quote", "type")) |>
  select(banco, base, quote, value, moeda_local, moeda_estrangeira, local_por_estrangeira) |>
  tabela("DEPOIS: as mesmas linhas, agora comparáveis", digits = 5)
DEPOIS: as mesmas linhas, agora comparáveis
banco base quote value moeda_local moeda_estrangeira local_por_estrangeira
bcb USD BRL 6,2086 BRL USD 6,2086
boc USD CAD 1,4418 CAD USD 1,4418
ecb EUR USD 1,0321 EUR USD 0,9689

Quando a moeda local aparece em quote, o valor já está expresso em moeda local por unidade da moeda estrangeira. Quando aparece em base, invertemos a cotação (1/value) para obter a mesma unidade de medida. Linhas sem a moeda local em nenhum dos lados ficam com NA na coluna normalizada.

3.3.5 Dias sem publicação e valores ausentes

Cada banco só tem linha nos dias em que publicou. Janeiro de 2025 no BCB:

cotacoes |>
  filter(banco == "bcb", date >= as.Date("2025-01-01"), date <= as.Date("2025-01-08")) |>
  count(date, name = "n_cotacoes") |>
  tabela("ANTES: só aparecem os dias com publicação", digits = 0)
ANTES: só aparecem os dias com publicação
date n_cotacoes
2025-01-02 312
2025-01-03 312
2025-01-06 312
2025-01-07 312
2025-01-08 312

Para que a ausência de publicação seja visível e analisável, é criado um calendário com todos os dias do período. A quantidade de cotações publicadas em cada dia é associada a esse calendário. Quando não há publicação, n_cotacoes permanece NA em vez de receber zero (zero cotações não é o mesmo que “dado não existe”). A coluna motivo_ausencia classifica esses casos como fim de semana, dia útil sem publicação (feriados do banco) ou data posterior à última publicação.

# Calendário completo, do primeiro ao último dia presente nos dados
calendario <- tibble(date = seq(min(cotacoes$date), max(cotacoes$date), by = "day")) |>
  mutate(dia_semana    = as.integer(format(date, "%u")),   # 1 = segunda
         fim_de_semana = dia_semana >= 6)

# Um registro por banco e por dia; quem não publicou fica com NA e um motivo
cobertura <- expand_grid(calendario, banco = bancos) |>
  left_join(count(cotacoes, banco, date, name = "n_cotacoes"), by = c("banco", "date")) |>
  group_by(banco) |>
  mutate(publicou = !is.na(n_cotacoes),
         depois_da_ultima_publicacao = date > max(date[publicou]),
         motivo_ausencia = case_when(
           publicou                    ~ NA_character_,
           depois_da_ultima_publicacao ~ "apos_ultima_publicacao",
           fim_de_semana               ~ "fim_de_semana",
           TRUE                        ~ "dia_util_sem_publicacao")) |>
  ungroup() |>
  arrange(banco, date)

# Resumo por banco e por motivo
resumo_calendario <- cobertura |>
  group_by(banco) |>
  summarise(dias_calendario = n(), dias_publicados = sum(publicou), .groups = "drop") |>
  left_join(cobertura |> filter(!publicou) |> count(banco, motivo_ausencia) |>
              pivot_wider(names_from = motivo_ausencia, values_from = n,
                          names_prefix = "dias_", values_fill = 0),
            by = "banco")

Como ficaram os dados:

cobertura |>
  filter(banco == "bcb", date >= as.Date("2025-01-01"), date <= as.Date("2025-01-08")) |>
  select(date, dia_semana, fim_de_semana, n_cotacoes, publicou, motivo_ausencia) |>
  tabela("DEPOIS: todos os dias aparecem; os sem publicação ficam com NA e um motivo", digits = 0)
DEPOIS: todos os dias aparecem; os sem publicação ficam com NA e um motivo
date dia_semana fim_de_semana n_cotacoes publicou motivo_ausencia
2025-01-01 3 FALSE NA FALSE dia_util_sem_publicacao
2025-01-02 4 FALSE 312 TRUE NA
2025-01-03 5 FALSE 312 TRUE NA
2025-01-04 6 TRUE NA FALSE fim_de_semana
2025-01-05 7 TRUE NA FALSE fim_de_semana
2025-01-06 1 FALSE 312 TRUE NA
2025-01-07 2 FALSE 312 TRUE NA
2025-01-08 3 FALSE 312 TRUE NA
resumo_calendario |>
  tabela("Dias sem publicação, por motivo", digits = 0)
Dias sem publicação, por motivo
banco dias_calendario dias_publicados dias_apos_ultima_publicacao dias_dia_util_sem_publicacao dias_fim_de_semana
bcb 2.869 1.972 3 76 818
boc 2.869 1.958 3 90 818
boe 2.869 1.983 4 64 818
bok 2.869 1.934 0 115 820
ecb 2.869 2.011 3 37 818
fed 2.869 1.923 10 120 816
pboc 2.869 1.904 0 145 820

Gráfico: onde estão os NA no calendário

cobertura |>
  mutate(mes_ref = as.Date(format(date, "%Y-%m-01"))) |>
  group_by(banco, mes_ref) |>
  summarise(pct = mean(publicou) * 100, .groups = "drop") |>
  ggplot(aes(mes_ref, reorder(banco, desc(banco)), fill = pct)) +
  geom_tile(color = "white", linewidth = 0.3) +
  scale_fill_gradient(low = pal[["destaque"]], high = pal[["primaria"]],
                      labels = \(x) paste0(x, "%"), name = "Dias com publicação") +
  scale_x_date(date_breaks = "1 year", date_labels = "%Y", expand = c(0, 0)) +
  labs(title = "Onde estão os NA no calendário",
       subtitle = "Percentual dos dias de cada mês em que o banco publicou",
       x = NULL, y = NULL, caption = fonte_txt) +
  theme_story() + theme(legend.position = "right", legend.title = element_text())

Interpretação: nos sete bancos, o mosaico é quase uniforme: cerca de 70% dos dias de cada mês têm publicação, que é o que sobra depois de tirar sábados e domingos. De fato, 90% de todos os NA do calendário são fim de semana e outros 10% são dias úteis sem publicação (feriados de cada banco). Ou seja, quase todo NA aqui é calendário, não erro de dado. A exceção visível é o FED: nos últimos 90 dias ele publicou em apenas 25% dos dias, contra 67% no período todo. Na coluna do FED, o tom mais claro no fim da série indica publicação semanal, coerente com o cadence: weekly do index.json.

3.3.6 Organização das cotações para comparação (formato longo → largo)

Na tabela original (formato longo), a moeda foco (USD) aparece em várias linhas, com uma representação por banco:

cotacoes |>
  filter(date == as.Date("2025-01-02"), moeda_estrangeira == moeda_foco, type != "buy") |>
  select(banco, base, quote, type, value, local_por_estrangeira) |>
  tabela("ANTES (formato longo): um dia, uma linha por banco", digits = 4)
ANTES (formato longo): um dia, uma linha por banco
banco base quote type value local_por_estrangeira
bcb USD BRL sell 6,2086 6,2086
boc USD CAD indicative 1,4418 1,4418
boe GBP USD reference 1,2375 0,8081
bok USD KRW reference 1.470,0000 1.470,0000
ecb EUR USD reference 1,0321 0,9689
pboc USD CNY reference 7,1879 7,1879

Para comparar as instituições lado a lado, selecionamos as linhas da moeda foco e usamos a coluna já normalizada local_por_estrangeira. As cotações são reorganizadas para que cada combinação de banco e tipo ocupe uma coluna, como bcb_sell ou ecb_reference. Séries inexistentes para a moeda escolhida não são descartadas: como o dólar é a moeda local do FED, por exemplo, fed_reference permanece como uma coluna preenchida por NA. Por fim, o painel é combinado com o calendário para manter também sábados, domingos e feriados.

# Todas as séries (banco_tipo) que existem na base
series_esperadas <- cotacoes |> distinct(banco, type) |>
  transmute(serie = paste(banco, type, sep = "_")) |> pull(serie) |> sort()

# Formato largo: uma coluna por série
painel <- cotacoes |>
  filter(moeda_estrangeira == moeda_foco) |>
  mutate(serie = paste(banco, type, sep = "_")) |>
  select(date, serie, local_por_estrangeira) |>
  pivot_wider(names_from = serie, values_from = local_por_estrangeira)

painel[setdiff(series_esperadas, names(painel))] <- NA_real_   # coluna inexistente vira toda NA

# Junta com o calendário para manter os dias sem publicação
painel <- calendario |>
  left_join(painel, by = "date") |>
  select(all_of(names(calendario)), all_of(series_esperadas))

Como ficaram os dados:

painel |>
  filter(date >= as.Date("2025-01-02"), date <= as.Date("2025-01-07")) |>
  select(date, fim_de_semana, all_of(series_esperadas)) |>
  tabela(paste0("DEPOIS: uma coluna por série; moeda local por 1 ", moeda_foco, "; fim de semana = NA"),
         digits = 4, horizontal = TRUE, fonte = 12)
DEPOIS: uma coluna por série; moeda local por 1 USD; fim de semana = NA
date fim_de_semana bcb_buy bcb_sell boc_indicative boe_reference bok_reference ecb_reference fed_reference pboc_reference
2025-01-02 FALSE 6,2080 6,2086 1,4418 0,8081 1.470,0 0,9689 NA 7,1879
2025-01-03 FALSE 6,1557 6,1563 1,4442 0,8062 1.468,3 0,9710 NA 7,1878
2025-01-04 TRUE NA NA NA NA NA NA NA NA
2025-01-05 TRUE NA NA NA NA NA NA NA NA
2025-01-06 FALSE 6,1113 6,1119 1,4348 0,7995 1.466,8 0,9591 NA 7,1876
2025-01-07 FALSE 6,0735 6,0741 1,4340 0,8001 1.470,8 0,9622 NA 7,1879
painel |>
  summarise(across(all_of(series_esperadas), \(x) sum(is.na(x)))) |>
  pivot_longer(everything(), names_to = "serie", values_to = "dias_NA") |>
  mutate(pct_NA = dias_NA / nrow(painel) * 100) |>
  tabela(paste0("Valores ausentes por série no painel ", moeda_foco), digits = 1)
Valores ausentes por série no painel USD
serie dias_NA pct_NA
bcb_buy 897 31,3
bcb_sell 897 31,3
boc_indicative 911 31,8
boe_reference 886 30,9
bok_reference 935 32,6
ecb_reference 858 29,9
fed_reference 2.869 100,0
pboc_reference 965 33,6

fed_reference é 100% NA de propósito: o FED tem o dólar como moeda local, então não existe “dólar visto pelo FED”. A coluna é mantida em vez de sumir.

3.4 Base final

Base consolidada: 941.240 linhas × 13 colunas. Como a base é grande, a tabela abaixo mostra apenas um par com dólar de cada banco em 02/01/2025; use a rolagem horizontal para consultar todas as colunas.

cotacoes |>
  filter(date == as.Date("2025-01-02"), base == "USD" | quote == "USD", type != "buy") |>
  distinct(banco, .keep_all = TRUE) |>
  tabela(paste0("Base final (cotacoes): ", ncol(cotacoes), " colunas"), n = 7, digits = 4,
         horizontal = TRUE, fonte = 11)
Base final (cotacoes): 13 colunas
date banco nome_instituicao pais moeda_local tipo_instituicao cadencia base quote type value moeda_estrangeira local_por_estrangeira
2025-01-02 bcb Banco Central do Brasil (PTAX) Brazil BRL central_bank daily USD BRL sell 6,2086 USD 6,2086
2025-01-02 boc Bank of Canada Canada CAD central_bank daily USD CAD indicative 1,4418 USD 1,4418
2025-01-02 boe Bank of England United Kingdom GBP central_bank daily GBP USD reference 1,2375 USD 0,8081
2025-01-02 bok Bank of Korea South Korea KRW central_bank daily USD KRW reference 1.470,0000 USD 1.470,0000
2025-01-02 ecb European Central Bank Eurozone EUR central_bank daily EUR USD reference 1,0321 USD 0,9689
2025-01-02 fed US Federal Reserve (H.10) United States USD central_bank weekly AUD USD reference 0,6208 AUD 0,6208
2025-01-02 pboc People’s Bank of China (CFETS) China CNY central_bank daily USD CNY reference 7,1879 USD 7,1879

Dicionário de variáveis. Cada coluna da base final, com o arquivo e o campo originais de onde veio a informação. A coluna exemplo usa a linha BCB, USD→BRL, venda, 02/01/2025.

dicionario <- tribble(
  ~coluna, ~origem_tipo, ~origem, ~o_que_e, ~tratamento,
  "date", "CSV", "history/[ano].csv, coluna `date`", "Data de publicação da cotação", "Lida como Date",
  "banco", "CSV", "nome do arquivo ([banco]_[ano].csv)", "Código da instituição (bcb, fed, ecb...)", "O CSV não traz o banco; ele é guardado ao empilhar os arquivos",
  "nome_instituicao", "sources.json", "[banco] → `name`", "Nome completo da instituição", "Informação associada pelo código do banco",
  "pais", "sources.json", "[banco] → `country`", "País da instituição", "Informação associada pelo código do banco",
  "moeda_local", "sources.json", "[banco] → `home_currency`", "Moeda do país do banco", "Informação associada pelo código do banco",
  "tipo_instituicao", "sources.json", "[banco] → `kind`", "Banco central ou autoridade tributária", "Informação associada pelo código do banco",
  "cadencia", "index.json", "sources → [banco] → `cadence`", "Frequência de publicação (daily, weekly...)", "Informação associada pelo código do banco",
  "base", "CSV", "history/[ano].csv, coluna `base`", "Moeda que se compra (1 unidade)", "Nenhum",
  "quote", "CSV", "history/[ano].csv, coluna `quote`", "Moeda em que o preço é expresso", "Nenhum",
  "type", "CSV", "history/[ano].csv, coluna `type`", "Tipo da cotação (buy, sell, reference, indicative)", "Nenhum",
  "value", "CSV", "history/[ano].csv, coluna `value`", "Preço: unidades de `quote` por 1 `base`", "Lida como número",
  "moeda_estrangeira", "Derivada", "`base` ou `quote`", "A moeda que não é a local", "Pega o lado oposto da moeda local",
  "local_por_estrangeira", "Derivada", "`value`, `base`, `quote` e `moeda_local`", "Unidades da moeda local por 1 estrangeira (comparável entre bancos)", "Mantém `value` ou usa `1/value`, conforme a posição da moeda local"
)

# O dicionário precisa cobrir exatamente as colunas da base final
stopifnot("dicionário incompleto" = setequal(dicionario$coluna, names(cotacoes)))

exemplo_linha <- cotacoes |>
  filter(banco == "bcb", base == "USD", type == "sell", date == as.Date("2025-01-02"))

dicionario |>
  mutate(coluna = factor(coluna, levels = names(cotacoes))) |> arrange(coluna) |>
  mutate(coluna  = as.character(coluna),
         tipo_R  = map_chr(coluna, \(c) class(cotacoes[[c]])[1]),
         exemplo = map_chr(coluna, \(c) as.character(exemplo_linha[[c]])),
         origem_tipo = as.character(origem_tipo)) |>
  select(coluna, tipo_R, origem_tipo, origem, o_que_e, tratamento, exemplo) |>
  kable(format = "html", escape = FALSE,
        col.names = c("Coluna", "Tipo", "Fonte", "Campo original", "O que é", "Tratamento", "Exemplo"),
        caption = paste0("Dicionário da base final (", ncol(cotacoes), " colunas)")) |>
  kable_styling(bootstrap_options = "striped", full_width = TRUE, font_size = 12)
Dicionário da base final (13 colunas)
Coluna Tipo Fonte Campo original O que é Tratamento Exemplo
date Date CSV history/[ano].csv, coluna date Data de publicação da cotação Lida como Date 2025-01-02
banco character CSV nome do arquivo ([banco]_[ano].csv) Código da instituição (bcb, fed, ecb…) O CSV não traz o banco; ele é guardado ao empilhar os arquivos bcb
nome_instituicao character sources.json [banco] → name Nome completo da instituição Informação associada pelo código do banco Banco Central do Brasil (PTAX)
pais character sources.json [banco] → country País da instituição Informação associada pelo código do banco Brazil
moeda_local character sources.json [banco] → home_currency Moeda do país do banco Informação associada pelo código do banco BRL
tipo_instituicao character sources.json [banco] → kind Banco central ou autoridade tributária Informação associada pelo código do banco central_bank
cadencia character index.json sources → [banco] → cadence Frequência de publicação (daily, weekly…) Informação associada pelo código do banco daily
base character CSV history/[ano].csv, coluna base Moeda que se compra (1 unidade) Nenhum USD
quote character CSV history/[ano].csv, coluna quote Moeda em que o preço é expresso Nenhum BRL
type character CSV history/[ano].csv, coluna type Tipo da cotação (buy, sell, reference, indicative) Nenhum sell
value numeric CSV history/[ano].csv, coluna value Preço: unidades de quote por 1 base Lida como número 6.2086
moeda_estrangeira character Derivada base ou quote A moeda que não é a local Pega o lado oposto da moeda local USD
local_por_estrangeira numeric Derivada value, base, quote e moeda_local Unidades da moeda local por 1 estrangeira (comparável entre bancos) Mantém value ou usa 1/value, conforme a posição da moeda local 6.2086
dicionario |> count(origem_tipo, name = "colunas") |> arrange(desc(colunas)) |>
  tabela("Quantas colunas vieram de cada fonte", digits = 0)
Quantas colunas vieram de cada fonte
origem_tipo colunas
CSV 6
sources.json 4
Derivada 2
index.json 1

Conferência da base e dos valores ausentes.

tibble(
  criterio = c("Base final com 13 colunas", "Todas as linhas originais foram preservadas",
               "Todas as colunas documentadas no dicionário", "Fontes de dados combinadas"),
  resultado = c(paste0(ncol(cotacoes), " colunas"),
                paste0(fmt(nrow(cotacoes), 0), " linhas = ", fmt(nrow(cotacoes_raw), 0), " do CSV"),
                paste0(nrow(dicionario), " de ", ncol(cotacoes)),
                "CSV + sources.json + index.json + latest.json"),
  ok = c(ncol(cotacoes) == 13, nrow(cotacoes) == nrow(cotacoes_raw),
         setequal(dicionario$coluna, names(cotacoes)), TRUE)
) |>
  mutate(ok = ifelse(ok, "OK", "FALHOU")) |>
  kable(format = "html", caption = "Critérios") |>
  kable_styling(bootstrap_options = "striped", full_width = FALSE)
Critérios
criterio resultado ok
Base final com 13 colunas 13 colunas OK
Todas as linhas originais foram preservadas 941.240 linhas = 941.240 do CSV OK
Todas as colunas documentadas no dicionário 13 de 13 OK
Fontes de dados combinadas CSV + sources.json + index.json + latest.json OK
cobertura_csv |>
  tabela("Cobertura por banco (CSV)", digits = 1)
Cobertura por banco (CSV)
banco inicio fim linhas dias_publicados pares_cambiais series tipos_cotacao media_cotacoes_por_dia
bcb 2019-01-02 2026-11-06 615.913 1.972 160 320 buy, sell 312,3
boc 2019-01-02 2026-11-06 45.914 1.958 27 27 indicative 23,4
boe 2019-01-02 2026-11-05 45.609 1.983 23 23 reference 23,0
bok 2019-01-02 2026-11-09 83.162 1.934 43 43 reference 43,0
ecb 2019-01-02 2026-11-06 61.950 2.011 32 32 reference 30,8
fed 2019-01-02 2026-10-30 42.306 1.923 22 22 reference 22,0
pboc 2019-01-02 2026-11-09 46.386 1.904 25 25 reference 24,4
tibble(saida = c("cotacoes (base final)", "painel de comparação", "cobertura (calendário)", "catalogo"),
       linhas = c(nrow(cotacoes), nrow(painel), nrow(cobertura), nrow(catalogo)),
       colunas = c(ncol(cotacoes), ncol(painel), ncol(cobertura), ncol(catalogo)),
       celulas_NA = c(sum(is.na(cotacoes)), sum(is.na(painel)), sum(is.na(cobertura)), sum(is.na(catalogo)))) |>
  tabela("Onde estão os NA (todos preservados)", digits = 0)
Onde estão os NA (todos preservados)
saida linhas colunas celulas_NA
cotacoes (base final) 941.240 13 0
painel de comparação 2.869 11 9.218
cobertura (calendário) 20.083 8 20.083
catalogo 130 22 1.107

A base final (cotacoes) tem 0 NA porque reúne apenas as cotações efetivamente publicadas (os NA existentes vêm das linhas em que nenhum dos lados do par é a moeda local). Os valores ausentes aparecem principalmente quando essas cotações são combinadas com o calendário (cobertura e painel) e com as informações institucionais (catalogo). Nenhum registro original foi descartado.

3.5 Resumo das variáveis de interesse

# Resume o conteúdo de uma coluna: intervalo (datas) ou categorias mais frequentes
exemplo_var <- function(x) {
  if (inherits(x, "Date"))
    return(paste(format(min(x), "%d/%m/%Y"), "a", format(max(x), "%d/%m/%Y")))
  top <- names(sort(table(x), decreasing = TRUE))
  paste(head(top, 4), collapse = ", ")
}

# (a) variáveis categóricas e de data
vars_cat <- c("date", "banco", "base", "quote", "type", "moeda_estrangeira")
resumo_cat <- tibble(
  `Variável` = vars_cat,
  Tipo       = map_chr(vars_cat, \(v) class(cotacoes[[v]])[1]),
  Distintos  = map_int(vars_cat, \(v) n_distinct(cotacoes[[v]], na.rm = TRUE)),
  NA_        = map_int(vars_cat, \(v) sum(is.na(cotacoes[[v]]))),
  `Intervalo ou mais frequentes` = map_chr(vars_cat, \(v) exemplo_var(cotacoes[[v]]))
) |> rename(`NA` = NA_)

resumo_cat |>
  tabela("Variáveis categóricas e de data da base final", digits = 0)
Variáveis categóricas e de data da base final
Variável Tipo Distintos NA Intervalo ou mais frequentes
date Date 2.044 0 02/01/2019 a 09/11/2026
banco character 7 0 bcb, bok, ecb, pboc
base character 161 0 EUR, GBP, USD, CNY
quote character 38 0 BRL, KRW, CAD, CNY
type character 4 0 sell, buy, reference, indicative
moeda_estrangeira character 161 0 AUD, CHF, HKD, JPY
# (b) variável numérica de interesse: o dólar em unidades da moeda local, nas seis séries analisadas
resumo_num <- cotacoes |>
  filter(moeda_estrangeira == "USD") |>
  mutate(serie = paste(banco, type, sep = "_")) |>
  filter(serie %in% names(series_foco)) |>
  group_by(Moeda = unname(series_foco[serie])) |>
  summarise(`Observações` = n(),
            `Mínimo`  = min(local_por_estrangeira),
            Mediana   = median(local_por_estrangeira),
            `Média`   = mean(local_por_estrangeira),
            `Máximo`  = max(local_por_estrangeira),
            `Desvio-padrão` = sd(local_por_estrangeira),
            .groups = "drop")

resumo_num |>
  tabela("Dólar em moeda local (`local_por_estrangeira`), por moeda", digits = 2)
Dólar em moeda local (local_por_estrangeira), por moeda
Moeda Observações Mínimo Mediana Média Máximo Desvio-padrão
Dólar canadense (BoC) 1.958 1,20 1,35 1,34 1,46 0,05
Euro (ECB) 2.011 0,81 0,90 0,90 1,05 0,04
Libra (BoE) 1.983 0,70 0,77 0,77 0,93 0,04
Real (BCB) 1.972 3,65 5,20 5,10 6,21 0,54
Won (BoK) 1.934 1.082,70 1.299,30 1.286,38 1.554,40 117,73
Yuan (PBoC) 1.904 6,30 6,96 6,89 7,26 0,26

A base final reúne 941.240 cotações de 7 instituições, entre 02/01/2019 e 09/11/2026, cobrindo 318 pares de moedas diferentes. O tipo de cotação mais frequente é sell (33% das linhas). Somente 0,0% das linhas não tem a moeda local de nenhum dos lados do par e, por isso, ficam sem a versão normalizada. As duas variáveis numéricas (value e local_por_estrangeira) têm escalas diferentes entre pares (um dólar vale cerca de 1 CAD, mas centenas ou milhares de wons); por isso o resumo numérico acima olha uma moeda por vez, e não a base inteira. A variável de interesse para as análises é local_por_estrangeira do dólar.

4. Análise exploratória dos dados

A análise conta a história do dólar frente a alguns eventos: (1) o ponto de partida, com a trajetória de seis moedas desde 2019; (2) pandemia em 2020, quando a pandemia de COVID-19 colocou o mundo em um lockdown; (3) os anos seguintes, com guerra e operações militares na Europa e Ásia; e (4) configuração pós-pandemia, com um mapa-múndi do dólar frente a mais de cem moedas. A seção 4.2 responde à segunda pergunta e a seção 4.3 permite explorar os dados.

4.1 Como o dólar variou em relação a outras moedas?

Usamos a coluna local_por_estrangeira da base final para as seis moedas que têm cotação do dólar publicada pelo próprio banco central: real (BCB), euro (ECB), libra (BoE), dólar canadense (BoC), won (BoK) e yuan (PBoC). O FED fica de fora porque o dólar é a moeda dele. Como cada série está em uma moeda diferente, os níveis não são comparáveis; por isso criamos variáveis novas: índice (base 100), retorno logarítmico, variação acumulada, variação anual e volatilidade anualizada. Para esta análise só entram os dias com cotação; os NA continuam na base e no painel.

usd_longo <- cotacoes |>
  filter(moeda_estrangeira == "USD") |>
  mutate(serie = paste(banco, type, sep = "_")) |>
  filter(serie %in% names(series_foco)) |>
  transmute(date, serie, moeda = series_foco[serie], valor = local_por_estrangeira) |>
  group_by(serie) |> arrange(date, .by_group = TRUE) |>
  mutate(indice  = 100 * valor / first(valor),      # base 100 na primeira cotação
         retorno = log(valor / lag(valor))) |>      # retorno logarítmico entre cotações consecutivas
  ungroup()

# Variação acumulada e volatilidade anualizada por moeda
metricas <- usd_longo |>
  group_by(moeda) |>
  summarise(inicio = first(valor), fim = last(valor),
            var_acum_pct = (fim / inicio - 1) * 100,
            vol_anual_pct = sd(retorno, na.rm = TRUE) * sqrt(252) * 100,
            .groups = "drop") |>
  arrange(desc(var_acum_pct))

# Variação de fim de ano a fim de ano (o primeiro ano parte da primeira cotação)
anual <- usd_longo |>
  mutate(ano = as.integer(format(date, "%Y"))) |>
  group_by(moeda, ano) |>
  summarise(primeiro = first(valor), ultimo = last(valor), .groups = "drop") |>
  group_by(moeda) |> arrange(ano, .by_group = TRUE) |>
  mutate(base = coalesce(lag(ultimo), primeiro), var = (ultimo / base - 1) * 100) |>
  ungroup()

tabela(head(usd_longo, 4), "Os dados desta análise (primeiras linhas)", digits = 4)
Os dados desta análise (primeiras linhas)
date serie moeda valor indice retorno
2019-01-02 bcb_sell Real (BCB) 3,8595 100,0000 NA
2019-01-03 bcb_sell Real (BCB) 3,7683 97,6370 -0,0239
2019-01-04 bcb_sell Real (BCB) 3,7627 97,4919 -0,0015
2019-01-07 bcb_sell Real (BCB) 3,7062 96,0280 -0,0151

Evolução das moedas ao longo do tempo

clique na legenda para esconder ou mostrar uma moeda e passe o mouse sobre as linhas para ver a data e o índice.

cores_moedas <- c(
  "Real (BCB)" = "#356B86",
  "Euro (ECB)" = "#7D91A0",
  "Libra (BoE)" = "#789080",
  "Dólar canadense (BoC)" = "#A99A78",
  "Won (BoK)" = "#A96F6A",
  "Yuan (PBoC)" = "#526B78"
)

# `text` define o conteúdo da dica interativa
g_indice <- usd_longo |>
  mutate(texto = paste0(moeda, "<br>", format(date, "%d/%m/%Y"),
                        "<br>Índice: ", fmt(indice, 1))) |>
  ggplot(aes(date, indice, color = moeda, group = moeda, text = texto)) +
  geom_hline(yintercept = 100, color = "grey70", linetype = "dashed") +
  geom_line(linewidth = 0.6) +
  scale_color_manual(values = cores_moedas) +
  scale_x_date(date_breaks = "1 year", date_labels = "%Y") +
  labs(x = NULL, y = "Índice (base 100 = jan/2019)", color = NULL) +
  theme_story()

ggplotly(g_indice, tooltip = "text") |>
  layout(title = list(text = paste0("<b>O dólar frente a seis moedas, visto por cada banco central</b>",
                                    "<br><sup>Índice base 100 = primeira cotação de cada série. ",
                                    "Acima de 100 = dólar mais forte</sup>"), x = 0),
         legend = list(orientation = "h", y = -0.12),
         margin = list(t = 80))

Fonte: AllRates-Today/central-bank-exchange-rates (CC BY-NC 4.0).

O dólar subiu frente a 4 das 6 moedas desde jan/2019. O destaque é o Real (BCB): o dólar acumula 42,5% de alta (de 3,86 para 5,50 unidades da moeda por dólar). No outro extremo, frente ao Libra (BoE) o dólar variou -6,4%. As linhas das moedas dos países ricos (euro, libra, dólar canadense, yuan) ficam coladas à linha de 100, enquanto real e won se descolam: moedas emergentes oscilam muito mais, o que o próximo gráfico detalha ano a ano.

Ano a ano

anual |>
  mutate(rotulo_ano = ifelse(ano == max(ano), paste0(ano, "*"), as.character(ano)),
         moeda = factor(moeda, levels = rev(metricas$moeda))) |>
  ggplot(aes(rotulo_ano, moeda, fill = var)) +
  geom_tile(color = "white", linewidth = 0.8) +
  geom_text(aes(label = sprintf("%+.0f%%", var), color = abs(var) > 15), size = 3.5) +
  scale_color_manual(values = c(`FALSE` = "#263746", `TRUE` = "#FFFFFF"), guide = "none") +
  scale_fill_gradient2(low = pal[["secundaria"]], mid = "white", high = pal[["alerta"]],
                       midpoint = 0, limits = c(-25, 30), oob = scales::squish,
                       name = "Variação do dólar no ano (%)") +
  labs(title = "Em que ano o dólar mais subiu (vermelho) ou caiu (azul)",
       subtitle = "Variação de fim de ano a fim de ano, em unidades da moeda local por dólar",
       x = NULL, y = NULL,
       caption = paste0(fonte_txt, "\n* ", max(anual$ano), " vai só até ", format(max(usd_longo$date), "%d/%m/%Y"))) +
  theme_story() + theme(legend.position = "bottom", legend.key.width = unit(2.2, "cm"))

O maior salto individual do dólar foi frente ao Real (BCB) em 2020: 28,9%. A maior queda foi frente ao Euro (ECB) em 2025: -11,6%. Em 2020 o dólar subiu 28,9% frente ao real, e o real teve alta do dólar em 4 de 8 anos. Isso mostra que o acumulado do primeiro gráfico foi construído em poucos choques grandes, não em uma subida constante.

# Formato largo (uma coluna por ano) para facilitar a leitura
anual_larga <- anual |>
  select(Moeda = moeda, ano, var) |>
  pivot_wider(names_from = ano, values_from = var)

tabela_dt(anual_larga, colunas_num = setdiff(names(anual_larga), "Moeda"), digits = 1)

Quando cada moeda foi mais volátil

A volatilidade total (tabela abaixo) é uma única média para oito anos. Para ver quando a instabilidade se concentrou, calculamos a volatilidade em janela móvel de 60 cotações (aproximadamente três meses de dias úteis), anualizada.

# Volatilidade móvel: desvio-padrão dos retornos nas últimas 60 cotações, anualizado (x raiz de 252)
vol_movel <- usd_longo |>
  group_by(moeda) |> arrange(date, .by_group = TRUE) |>
  mutate(vol_60 = rollapplyr(retorno, width = 60,
                             FUN = \(x) sd(x, na.rm = TRUE) * sqrt(252) * 100,
                             fill = NA)) |>
  ungroup()

# Momento de maior volatilidade de cada moeda
pico_vol <- vol_movel |>
  filter(!is.na(vol_60)) |>
  group_by(moeda) |>
  slice_max(vol_60, n = 1, with_ties = FALSE) |>
  ungroup() |>
  arrange(desc(vol_60))

g_vol <- vol_movel |>
  filter(!is.na(vol_60)) |>
  mutate(texto = paste0(moeda, "<br>", format(date, "%d/%m/%Y"),
                        "<br>Volatilidade: ", fmt(vol_60, 1), "% a.a.")) |>
  ggplot(aes(date, vol_60, color = moeda, group = moeda, text = texto)) +
  geom_line(linewidth = 0.6) +
  scale_color_manual(values = cores_moedas) +
  scale_x_date(date_breaks = "1 year", date_labels = "%Y") +
  scale_y_continuous(labels = \(x) paste0(x, "%")) +
  labs(x = NULL, y = "Volatilidade anualizada", color = NULL) +
  theme_story()

ggplotly(g_vol, tooltip = "text") |>
  layout(title = list(text = paste0("<b>Volatilidade do dólar em janela móvel de 60 cotações</b>",
                                    "<br><sup>Desvio-padrão dos retornos diários, anualizado</sup>"), x = 0),
         legend = list(orientation = "h", y = -0.12),
         margin = list(t = 80))

O maior pico de volatilidade foi o do Real (BCB), em 06/2020 (28,6% ao ano). Para o real, o pico ocorreu em 06/2020 (28,6%). A instabilidade, portanto, vem em episódios, e não de forma contínua. Esta análise identifica quando aconteceram, mas não investiga por quê.

Variação acumulada × volatilidade

metricas |>
  transmute(Moeda = moeda, `Início` = inicio, Fim = fim,
            `Variação acumulada (%)` = var_acum_pct, `Volatilidade anualizada (%)` = vol_anual_pct) |>
  tabela("Variação acumulada × volatilidade (dólar em moeda local)", digits = 2)
Variação acumulada × volatilidade (dólar em moeda local)
Moeda Início Fim Variação acumulada (%) Volatilidade anualizada (%)
Real (BCB) 3,86 5,50 42,54 14,12
Won (BoK) 1.118,10 1.357,90 21,45 8,00
Dólar canadense (BoC) 1,36 1,41 3,77 5,71
Euro (ECB) 0,88 0,90 2,08 7,14
Yuan (PBoC) 6,85 6,72 -1,89 3,30
Libra (BoE) 0,79 0,74 -6,44 8,80

Ato 2 e 3: quando o mundo mudou (a pandemia e outros choques)

Até aqui vimos o que aconteceu. Para ver quando e em que contexto, marcamos no tempo quatro eventos globais amplamente noticiados e medimos o que o dólar fez nos 30 dias seguintes a cada um. O primeiro é a pandemia: a Organização Mundial da Saúde caracterizou a COVID-19 como pandemia em 11/03/2020. Os outros três (o início da ofensiva à Ucrânia, início do ciclo de alta de juros do FED e anúncio de tarifas dos EUA) mostram se o choque de 2020 foi um caso isolado.

# Marcos da história (datas de anúncios amplamente divulgados)
eventos <- tribble(
  ~n, ~data, ~curto, ~evento,
  1L, as.Date("2020-03-11"), "Pandemia", "OMS caracteriza a COVID-19 como pandemia",
  2L, as.Date("2022-02-24"), "Invasão da Ucrânia", "Rússia invade a Ucrânia",
  3L, as.Date("2022-03-16"), "Alta de juros do FED", "FED inicia o ciclo de alta de juros",
  4L, as.Date("2025-04-02"), "Tarifas dos EUA", "EUA anunciam tarifas ampliadas sobre importações"
)

janela <- 30   # dias corridos após cada evento

# "Real (BCB)" -> "Real" (nome da moeda sem o banco, para os textos)
nome_curto <- function(x) sub(" \\(.*\\)$", "", x)

# (a) variação do dólar nos 30 dias após cada evento, por moeda
#     (última cotação disponível até a data do evento -> última cotação até data + 30 dias)
efeitos <- eventos |>
  mutate(res = map(data, \(d) usd_longo |>
    group_by(moeda) |> arrange(date, .by_group = TRUE) |>
    summarise(antes  = valor[max(which(date <= d))],
              depois = valor[max(which(date <= d + janela))],
              .groups = "drop") |>
    mutate(var_pct = (depois / antes - 1) * 100))) |>
  unnest(res)

# (b) referência: variação em TODAS as janelas de 30 dias de cada série
var_janela <- usd_longo |>
  group_by(moeda) |> arrange(date, .by_group = TRUE) |>
  mutate(idx = findInterval(as.numeric(date) + janela, as.numeric(date)),
         var_30d = ifelse(date + janela <= max(date), (valor[idx] / valor - 1) * 100, NA_real_)) |>
  ungroup()

ref_30d <- var_janela |>
  filter(!is.na(var_30d)) |>
  group_by(moeda) |>
  summarise(f = list(ecdf(abs(var_30d))), .groups = "drop")

# (c) percentil: o movimento do evento é maior que quantos % das janelas de 30 dias?
efeitos <- efeitos |>
  left_join(ref_30d, by = "moeda") |>
  mutate(percentil = map2_dbl(f, var_pct, \(f, v) f(abs(v)) * 100)) |>
  select(-f)

eventos |>
  transmute(`Nº` = n, Data = format(data, "%d/%m/%Y"), Evento = evento) |>
  tabela("Linha do tempo: os quatro eventos analisados", digits = 0)
Linha do tempo: os quatro eventos analisados
Nº Data Evento
1 11/03/2020 OMS caracteriza a COVID-19 como pandemia
2 24/02/2022 Rússia invade a Ucrânia
3 16/03/2022 FED inicia o ciclo de alta de juros
4 02/04/2025 EUA anunciam tarifas ampliadas sobre importações

Gráfico interativo: os números marcam os eventos da tabela acima.

y_topo <- max(usd_longo$indice)

g_eventos <- usd_longo |>
  mutate(texto = paste0(moeda, "<br>", format(date, "%d/%m/%Y"), "<br>Índice: ", fmt(indice, 1))) |>
  ggplot(aes(date, indice, color = moeda, group = moeda, text = texto)) +
  geom_vline(data = eventos, aes(xintercept = data), linetype = "dotted",
             color = "grey40", inherit.aes = FALSE) +
  geom_line(linewidth = 0.6) +
  geom_text(data = eventos, aes(x = data, y = y_topo * (1 - 0.05 * (n - 1)), label = n),
            fontface = "bold", color = "grey25", inherit.aes = FALSE) +
  scale_color_manual(values = cores_moedas) +
  scale_x_date(date_breaks = "1 year", date_labels = "%Y") +
  labs(x = NULL, y = "Índice (base 100 = jan/2019)", color = NULL) +
  theme_story()

ggplotly(g_eventos, tooltip = "text") |>
  layout(title = list(text = paste0("<b>A trajetória do dólar com os eventos marcados</b>",
                                    "<br><sup>1 = pandemia · 2 = invasão da Ucrânia · 3 = alta de juros do FED · 4 = tarifas dos EUA</sup>"),
                      x = 0),
         legend = list(orientation = "h", y = -0.12),
         margin = list(t = 80))

O ato da pandemia. Dois números contam o que aconteceu em 2020: a variação do dólar em 30 dias e a mudança na volatilidade antes e depois do choque.

# Volatilidade anualizada em três períodos: antes, durante o choque e depois da pandemia
vol_pandemia <- usd_longo |>
  mutate(periodo = case_when(
    date <  as.Date("2020-03-11") ~ "antes",
    date <= as.Date("2020-06-30") ~ "choque",
    date <= as.Date("2021-12-31") ~ "depois",
    TRUE ~ NA_character_)) |>
  filter(!is.na(periodo)) |>
  group_by(moeda, periodo) |>
  summarise(vol = sd(retorno, na.rm = TRUE) * sqrt(252) * 100, .groups = "drop") |>
  pivot_wider(names_from = periodo, values_from = vol) |>
  mutate(multiplo = choque / antes) |>
  arrange(desc(multiplo))

pand      <- efeitos |> filter(n == 1)
pand_real <- pand |> filter(moeda == "Real (BCB)")
pand_max  <- pand |> slice_max(var_pct, n = 1)
vol_real  <- vol_pandemia |> filter(moeda == "Real (BCB)")
vol_max   <- vol_pandemia |> slice_max(multiplo, n = 1)

vol_pandemia |>
  transmute(Moeda = moeda,
            `Antes (jan/2019 a 10/03/2020)` = antes,
            `Choque (11/03 a 30/06/2020)`   = choque,
            `Depois (jul/2020 a dez/2021)`  = depois,
            `Choque ÷ antes (vezes)`         = multiplo) |>
  tabela("Volatilidade anualizada (% ao ano) em três momentos da pandemia", digits = 1)
Volatilidade anualizada (% ao ano) em três momentos da pandemia
Moeda Antes (jan/2019 a 10/03/2020) Choque (11/03 a 30/06/2020) Depois (jul/2020 a dez/2021) Choque ÷ antes (vezes)
Real (BCB) 11,1 28,1 15,6 2,5
Dólar canadense (BoC) 4,7 10,5 6,0 2,3
Won (BoK) 6,0 12,8 5,5 2,1
Euro (ECB) 5,3 9,5 5,9 1,8
Libra (BoE) 8,6 15,3 7,7 1,8
Yuan (PBoC) 3,0 3,7 3,5 1,3

Nos 30 dias após 11/03/2020, o dólar variou +8,6% frente ao real, um movimento maior que 97% de todas as janelas de 30 dias da série do real. O real foi a moeda mais afetada no mês. A volatilidade também mudou: a do real foi multiplicada por 2,5 (de 11,1% para 28,1% ao ano), o maior salto entre as seis moedas. A pandemia, portanto, aparece nos dados como um choque curto e intenso, e não como uma mudança permanente de nível. Esta análise mede o tamanho do choque, mas não testa por que ele aconteceu.

E os outros eventos? O mapa de calor abaixo repete a medição para os quatro eventos. Cada célula é a variação do dólar nos 30 dias seguintes.

lim <- min(20, ceiling(max(abs(efeitos$var_pct))))   # limite da escala de cores

efeitos |>
  mutate(rotulo = factor(paste0(n, ". ", curto), levels = paste0(eventos$n, ". ", eventos$curto)),
         moeda  = factor(moeda, levels = rev(metricas$moeda))) |>
  ggplot(aes(rotulo, moeda, fill = var_pct)) +
  geom_tile(color = "white", linewidth = 0.8) +
  geom_text(aes(label = sprintf("%+.1f%%", var_pct)), size = 3.3, color = "#263746") +
  scale_fill_gradient2(low = pal[["secundaria"]], mid = "white", high = pal[["alerta"]],
                       midpoint = 0, limits = c(-lim, lim), oob = scales::squish,
                       name = "Variação do dólar em 30 dias (%)") +
  scale_x_discrete(labels = scales::label_wrap(16)) +
  labs(title = "O que o dólar fez nos 30 dias seguintes a cada evento",
       subtitle = "Vermelho = dólar mais forte; azul = dólar mais fraco",
       x = NULL, y = NULL, caption = fonte_txt) +
  theme_story() + theme(legend.key.width = unit(2.2, "cm"))

# Resumo por evento: mediana entre as seis moedas e extremos
resumo_ev <- efeitos |>
  group_by(n, evento) |>
  summarise(mediana = median(var_pct),
            maior_alta = moeda[which.max(var_pct)], valor_alta = max(var_pct),
            maior_queda = moeda[which.min(var_pct)], valor_queda = min(var_pct),
            .groups = "drop")

resumo_ev |>
  transmute(`Nº` = n, Evento = evento, `Mediana (%)` = mediana,
            `Maior alta do dólar` = paste0(nome_curto(maior_alta), " (", pct_s(valor_alta, 1), ")"),
            `Menor variação (ou maior queda)` = paste0(nome_curto(maior_queda), " (", pct_s(valor_queda, 1), ")")) |>
  tabela("Efeito dos eventos nos 30 dias seguintes (seis moedas)", digits = 1)
Efeito dos eventos nos 30 dias seguintes (seis moedas)
Nº Evento Mediana (%) Maior alta do dólar Menor variação (ou maior queda)
1 OMS caracteriza a COVID-19 como pandemia 2,5 Real (+8,6%) Yuan (+1,1%)
2 Rússia invade a Ucrânia 0,7 Won (+2,3%) Real (-6,6%)
3 FED inicia o ciclo de alta de juros -0,4 Euro (+1,1%) Real (-8,1%)
4 EUA anunciam tarifas ampliadas sobre importações -2,9 Yuan (+0,3%) Euro (-4,8%)
n_ev_alta <- sum(resumo_ev$mediana > 0)

Em 2 dos 4 eventos a mediana das seis moedas indica dólar mais forte nos 30 dias seguintes; nos demais, a mediana é negativa (dólar mais fraco). Ou seja, a pandemia não é o único choque nos dados, e o sentido da reação do dólar depende do episódio. A tabela interativa na seção 4.3 traz todos os valores, com o percentil de cada movimento.

Ato 4: o mundo inteiro (mapa-múndi)

Até agora olhamos seis moedas. Para ver o mundo, usamos a tabela do BCB, que publica o valor de mais de cem moedas em reais. Combinando cada moeda com o dólar em reais, obtemos o dólar em unidades de cada moeda (moeda por dólar = reais por dólar ÷ reais por moeda) e medimos a alta acumulada do dólar desde o início da série. Cada moeda é associada aos países que a usam (por exemplo, o euro vale para toda a zona do euro). Países cuja moeda não tem série completa no período ficam de fora, e os que usam o próprio dólar não têm o que comparar.

# Países que usam cada moeda (códigos ISO-3). Base: dados de territórios e moedas do CLDR (Unicode),
# restrita às moedas publicadas pelo BCB. O dólar dos EUA fica de fora (dólar contra dólar).
moedas_paises <- c(
  AED = "ARE", AFN = "AFG", ALL = "ALB", AMD = "ARM", AOA = "AGO", ARS = "ARG",
  AUD = "AUS CCK CXR HMD KIR NFK NRU TUV", AWG = "ABW", AZN = "AZE", BBD = "BRB", BDT = "BGD",
  BHD = "BHR", BIF = "BDI", BMD = "BMU", BND = "BRN", BOB = "BOL", BSD = "BHS", BTN = "BTN",
  BWP = "BWA", BYN = "BLR", BZD = "BLZ", CAD = "CAN", CDF = "COD", CHF = "CHE LIE",
  CLP = "CHL", CNY = "CHN", COP = "COL", CRC = "CRI", CUP = "CUB", CVE = "CPV", CZK = "CZE",
  DJF = "DJI", DKK = "DNK FRO GRL", DOP = "DOM", DZD = "DZA", EGP = "EGY", ERN = "ERI",
  ETB = "ETH",
  EUR = "ALA AND ATF AUT BEL BLM CYP DEU ESP EST FIN FRA GLP GRC GUF HRV IRL ITA LTU LUX LVA MAF MCO MLT MNE MTQ MYT NLD PRT REU SMR SPM SVK SVN VAT",
  FJD = "FJI", FKP = "FLK", GBP = "GBR GGY IMN JEY SGS", GEL = "GEO", GHS = "GHA", GIP = "GIB",
  GMD = "GMB", GNF = "GIN", GTQ = "GTM", GYD = "GUY", HKD = "HKG", HNL = "HND", HTG = "HTI",
  HUF = "HUN", IDR = "IDN", ILS = "ISR PSE", INR = "BTN IND", IQD = "IRQ", IRR = "IRN",
  ISK = "ISL", JMD = "JAM", JOD = "JOR", JPY = "JPN", KES = "KEN", KGS = "KGZ", KHR = "KHM",
  KMF = "COM", KRW = "KOR", KWD = "KWT", KYD = "CYM", KZT = "KAZ", LAK = "LAO", LBP = "LBN",
  LKR = "LKA", LRD = "LBR", LSL = "LSO", LYD = "LBY", MAD = "ESH MAR", MDL = "MDA",
  MGA = "MDG", MKD = "MKD", MMK = "MMR", MNT = "MNG", MOP = "MAC", MRU = "MRT", MUR = "MUS",
  MVR = "MDV", MWK = "MWI", MXN = "MEX", MYR = "MYS", MZN = "MOZ", NAD = "NAM", NGN = "NGA",
  NIO = "NIC", NOK = "BVT NOR SJM", NPR = "NPL", NZD = "COK NIU NZL PCN TKL", OMR = "OMN",
  PAB = "PAN", PEN = "PER", PGK = "PNG", PHP = "PHL", PKR = "PAK", PLN = "POL", PYG = "PRY",
  QAR = "QAT", RON = "ROU", RSD = "SRB", RUB = "RUS", RWF = "RWA", SAR = "SAU", SBD = "SLB",
  SCR = "SYC", SDG = "SDN", SEK = "SWE", SGD = "SGP", SHP = "SHN", SOS = "SOM", SRD = "SUR",
  SSP = "SSD", STN = "STP", SYP = "SYR", SZL = "SWZ", THB = "THA", TJS = "TJK", TMT = "TKM",
  TND = "TUN", TOP = "TON", TRY = "TUR", TTD = "TTO", TWD = "TWN", TZS = "TZA", UAH = "UKR",
  UGX = "UGA", UYU = "URY", UZS = "UZB", VES = "VEN", VND = "VNM", VUV = "VUT", WST = "WSM",
  XAF = "CAF CMR COG GAB GNQ TCD", XCD = "AIA ATG DMA GRD KNA LCA MSR VCT", XCG = "CUW SXM",
  XOF = "BEN BFA CIV GNB MLI NER SEN TGO", XPF = "NCL PYF WLF", YER = "YEM",
  ZAR = "LSO NAM ZAF", ZMW = "ZMB"
)

paises_moeda <- tibble(moeda_x = names(moedas_paises), iso3 = unname(moedas_paises)) |>
  separate_rows(iso3, sep = " ")

# Reais por unidade de cada moeda (PTAX, venda) e reais por dólar
bcb_brl <- cotacoes |>
  filter(banco == "bcb", type == "sell", quote == "BRL") |>
  select(date, moeda_x = base, brl_por_x = value)
usd_brl <- bcb_brl |> filter(moeda_x == "USD") |> transmute(date, brl_por_usd = brl_por_x)

# Dólar em unidades de cada moeda (cruzamento via real)
x_por_usd <- bcb_brl |>
  filter(!moeda_x %in% c("USD", "BRL")) |>
  inner_join(usd_brl, by = "date") |>
  transmute(date, moeda_x, x_por_usd = brl_por_usd / brl_por_x) |>
  bind_rows(transmute(usd_brl, date, moeda_x = "BRL", x_por_usd = brl_por_usd))

d_ini <- min(bcb_brl$date); d_fim <- max(bcb_brl$date)

# Redenominações: dias em que o valor muda por 100, 1.000, 1 milhão de vezes... de uma só vez.
# Isso é quebra da série (corte de zeros na moeda), não variação real; essas moedas ficam de fora.
redenominadas <- x_por_usd |>
  group_by(moeda_x) |> arrange(date, .by_group = TRUE) |>
  mutate(r = abs(log(x_por_usd / lag(x_por_usd)))) |>
  summarise(redenominada = any(abs(outer(r[!is.na(r)], (2:9) * log(10), "-")) < 0.05),
            .groups = "drop")

# Alta acumulada por moeda; só entram moedas observadas no começo e no fim do período,
# sem redenominação e associadas a algum país
mapa_moedas <- x_por_usd |>
  group_by(moeda_x) |> arrange(date, .by_group = TRUE) |>
  summarise(data_ini = first(date), data_fim = last(date),
            ini = first(x_por_usd), fim = last(x_por_usd), .groups = "drop") |>
  left_join(redenominadas, by = "moeda_x") |>
  mutate(var_acum_pct = (fim / ini - 1) * 100,
         cobre_periodo = data_ini <= d_ini + 30 & data_fim >= d_fim - 30,
         tem_pais = moeda_x %in% paises_moeda$moeda_x,
         usar = cobre_periodo & !redenominada & tem_pais)

# Um registro por país
nomes_paises <- maps::iso3166 |> transmute(iso3 = a3, pais = ISOname)
mapa_paises <- mapa_moedas |>
  filter(usar) |>
  inner_join(paises_moeda, by = "moeda_x") |>
  left_join(nomes_paises, by = "iso3") |>
  mutate(pais = coalesce(pais, iso3),
         texto = paste0("<b>", pais, "</b> (", moeda_x, ")<br>Dólar: ", pct_s(var_acum_pct, 1),
                        "<br>", fmt(ini, 2), " → ", fmt(fim, 2), " ", moeda_x, " por dólar"))

mapa_resumo <- filter(mapa_moedas, usar)
topo_mapa   <- mapa_resumo |> slice_max(var_acum_pct, n = 3)
queda_mapa  <- mapa_resumo |> slice_min(var_acum_pct, n = 3)

Mapa interativo: passe o mouse sobre um país para ver a moeda e a alta acumulada. A escala de cores é truncada em +100% (o texto do país mostra o valor real).

lim_inf <- -30; lim_sup <- 100   # limites da escala de cores

plot_ly(mapa_paises, type = "choropleth", locations = ~iso3, z = ~var_acum_pct,
        text = ~texto, hoverinfo = "text", zmin = lim_inf, zmax = lim_sup,
        colorscale = list(c(0, pal[["secundaria"]]),
                          c(-lim_inf / (lim_sup - lim_inf), "#FFFFFF"),
                          c(1, pal[["alerta"]])),
        marker = list(line = list(color = "white", width = 0.4)),
        colorbar = list(title = "Alta do dólar", ticksuffix = "%")) |>
  layout(title = list(text = paste0("<b>Onde o dólar mais se valorizou</b><br><sup>Alta acumulada do dólar frente à moeda de cada país, de ",
                                    format(d_ini, "%m/%Y"), " a ", format(d_fim, "%m/%Y"), "</sup>"), x = 0),
         geo = list(projection = list(type = "natural earth"), showframe = FALSE,
                    showcoastlines = FALSE, bgcolor = "rgba(0,0,0,0)"),
         margin = list(t = 80))

Fonte: AllRates-Today/central-bank-exchange-rates (CC BY-NC 4.0). Os contornos dos países são carregados da internet pelo próprio gráfico; sem conexão, use a tabela abaixo.

O mapa cobre 142 moedas e 236 países e territórios (moedas compartilhadas, como o euro, colorem vários países de uma vez). O dólar subiu frente a 77% dessas moedas, e a mediana da variação foi de +6%. As maiores altas foram frente a LBP (+5.843%), IRR (+4.417%), ARS (+3.958%); as maiores quedas, frente a CRC (-26%), AMD (-26%), ALL (-24%). Em 6 moedas o dólar subiu 1.000% ou mais (mais que decuplicou), em geral em países com inflação muito alta; por isso a escala do mapa é truncada. Ficaram de fora 8 moedas sem série no começo ou no fim do período (por exemplo, as substituídas pelo euro), 2 com redenominação (SYP, VES, cuja série quebra quando a moeda corta zeros) e 13 itens sem país associado.

mapa_paises |>
  transmute(`País` = pais, Moeda = moeda_x, `Início` = ini, Fim = fim,
            `Alta acumulada do dólar (%)` = var_acum_pct) |>
  arrange(desc(`Alta acumulada do dólar (%)`)) |>
  tabela_dt(colunas_num = c("Início", "Fim", "Alta acumulada do dólar (%)"), digits = 2,
            completo = TRUE, legenda = "Países por alta acumulada do dólar (clique nas colunas para ordenar)")

Conclusão da Pergunta 1. A mesma moeda lidera nos dois critérios: Real (BCB) é a que mais perdeu valor frente ao dólar (42,5%) e a mais volátil (14,1% ao ano). A volatilidade foi calculada sobre os retornos logarítmicos entre cotações consecutivas, multiplicados por √252; é uma aproximação, porque os bancos não publicam todos os dias úteis.

O dólar partiu de um 2019 relativamente calmo, sofreu um choque curto e intenso com a pandemia (volatilidade do real multiplicada por 2,5), reagiu de formas diferentes a cada evento posterior e, no mapa-múndi, mostra uma alta de mediana +6% frente às moedas do mundo, com ganhos muito maiores nos países de inflação alta. Os níveis das séries não são comparáveis entre si (cada uma é uma moeda diferente). Por isso a análise usa índice e variação percentual. As cotações foram convertidas para a mesma unidade pela coluna local_por_estrangeira (seção 3.3.4).

4.2 As instituições apresentam cotações semelhantes?

Pergunta 2

Três instituições independentes (Banco Central do Brasil, Federal Reserve e Banco Central Europeu) concordam sobre quanto vale o dólar em reais?

Este é o teste da junção: a mesma grandeza sai de três fontes, cada uma com uma regra de publicação e cálculo própria. O BCB publica USD→BRL direto (PTAX, venda). O FED também publica USD→BRL. O ECB não tem dólar em reais: ele publica EUR→BRL e EUR→USD, e o dólar em reais implícito é a divisão EUR→BRL ÷ EUR→USD. O BCB é a referência de comparação.

# Seleciona as quatro séries necessárias e deixa cada uma em uma coluna
cmp <- cotacoes |>
  filter((banco == "bcb" & base == "USD" & quote == "BRL" & type == "sell") |
         (banco == "fed" & base == "USD" & quote == "BRL") |
         (banco == "ecb" & base == "EUR" & quote %in% c("BRL", "USD"))) |>
  mutate(serie = case_when(banco == "bcb" ~ "bcb_usd_brl",
                           banco == "fed" ~ "fed_usd_brl",
                           quote == "BRL" ~ "ecb_eur_brl",
                           TRUE           ~ "ecb_eur_usd")) |>
  select(date, serie, value) |>
  pivot_wider(names_from = serie, values_from = value)

# Partindo do calendário: dias sem alguma das fontes ficam com NA (não são removidos)
comparacao <- calendario |>
  select(date, fim_de_semana) |>
  left_join(cmp, by = "date") |>
  mutate(ecb_usd_brl_implicito = ecb_eur_brl / ecb_eur_usd,            # dólar em reais calculado
         dif_fed_pct = (fed_usd_brl / bcb_usd_brl - 1) * 100,          # diferença vs. PTAX
         dif_ecb_pct = (ecb_usd_brl_implicito / bcb_usd_brl - 1) * 100)

comparacao |>
  filter(date >= as.Date("2025-01-02"), date <= as.Date("2025-01-07")) |>
  select(date, bcb_usd_brl, fed_usd_brl, ecb_eur_brl, ecb_eur_usd, ecb_usd_brl_implicito, dif_fed_pct, dif_ecb_pct) |>
  tabela("Os dados desta análise: cada fonte numa coluna, NA onde não publicou", digits = 4,
         horizontal = TRUE, fonte = 12)
Os dados desta análise: cada fonte numa coluna, NA onde não publicou
date bcb_usd_brl fed_usd_brl ecb_eur_brl ecb_eur_usd ecb_usd_brl_implicito dif_fed_pct dif_ecb_pct
2025-01-02 6,2086 6,1556 6,4200 1,0321 6,2203 -0,8537 0,1889
2025-01-03 6,1563 6,1759 6,3478 1,0299 6,1635 0,3184 0,1171
2025-01-04 NA NA NA NA NA NA NA
2025-01-05 NA NA NA NA NA NA NA
2025-01-06 6,1119 6,1086 6,3649 1,0426 6,1048 -0,0540 -0,1156
2025-01-07 6,0741 6,0641 6,3134 1,0393 6,0747 -0,1646 0,0093

Como as diferenças mudam ao longo do tempo

comparacao |>
  pivot_longer(c(dif_fed_pct, dif_ecb_pct), names_to = "fonte", values_to = "dif") |>
  filter(!is.na(dif)) |>                                      # só para desenhar; os NA seguem em `comparacao`
  mutate(fonte = recode(fonte, dif_fed_pct = "FED (USD→BRL)", dif_ecb_pct = "ECB (EUR→BRL ÷ EUR→USD)")) |>
  ggplot(aes(date, dif, color = fonte)) +
  annotate("rect", xmin = min(comparacao$date), xmax = max(comparacao$date),
           ymin = -1, ymax = 1, fill = pal[["fundo"]]) +
  geom_hline(yintercept = 0, color = "grey50") +
  geom_point(size = 0.7, alpha = 0.55) +
  scale_color_manual(values = c(pal[["alerta"]], pal[["secundaria"]])) +
  scale_x_date(date_breaks = "1 year", date_labels = "%Y") +
  scale_y_continuous(labels = \(x) paste0(x, "%")) +
  guides(color = guide_legend(override.aes = list(size = 3, alpha = 1))) +
  labs(title = "Quanto cada fonte difere do PTAX (venda)",
       subtitle = "Diferença percentual diária do dólar em reais. Faixa cinza = ±1%",
       x = NULL, y = "Diferença vs. PTAX", caption = fonte_txt) +
  theme_story()

As três fontes andam praticamente juntas: a diferença média em módulo é de 0,22% para o FED e 0,32% para o ECB, e 99,0% e 96,7% dos dias, respectivamente, ficam dentro de ±1%. As diferenças são aleatórias, sem viés: a média com sinal é -0,015% (FED) e -0,003% (ECB). O pior dia do FED foi 09/11/2020 (1,66%) e o do ECB foi 03/06/2020 (2,56%). Uma explicação plausível, que esta análise não testa, é que cada instituição fixa a cotação em um horário diferente do dia, e o real é uma moeda que se move bastante em poucas horas.

Distribuição das diferenças e dias com dados disponíveis

comparacao |>
  pivot_longer(c(dif_fed_pct, dif_ecb_pct), names_to = "fonte", values_to = "dif") |>
  filter(!is.na(dif)) |>
  mutate(fonte = recode(fonte, dif_fed_pct = "FED (USD→BRL)", dif_ecb_pct = "ECB (implícito)")) |>
  ggplot(aes(dif, fill = fonte)) +
  geom_histogram(binwidth = 0.1, boundary = 0, color = "white", linewidth = 0.2) +
  geom_vline(xintercept = 0, color = "grey30") +
  facet_wrap(~fonte, ncol = 1) +
  scale_fill_manual(values = c(pal[["alerta"]], pal[["secundaria"]]), guide = "none") +
  scale_x_continuous(labels = \(x) paste0(x, "%")) +
  labs(title = "Distribuição da diferença diária vs. PTAX",
       subtitle = "Cada barra = 0,1 ponto percentual", x = "Diferença vs. PTAX", y = "Dias",
       caption = fonte_txt) +
  theme_story() + theme(strip.text = element_text(face = "bold", color = pal[["primaria"]]))

Os dois histogramas são sinos centrados em zero. O do ECB é mais largo (diferença média em módulo de 0,32% contra 0,22% do FED), o que é esperado: o dólar em reais do ECB é calculado a partir de duas cotações (EUR→BRL e EUR→USD), então carrega o erro de horário das duas. O do FED é uma cotação direta.

comparacao |>
  summarise(`Dias do calendário` = n(),
            `BCB publicou` = sum(!is.na(bcb_usd_brl)),
            `BCB e FED juntos` = sum(!is.na(bcb_usd_brl) & !is.na(fed_usd_brl)),
            `BCB e ECB juntos` = sum(!is.na(bcb_usd_brl) & !is.na(ecb_usd_brl_implicito)),
            `BCB publicou e FED não` = sum(!is.na(bcb_usd_brl) & is.na(fed_usd_brl)),
            `BCB publicou e ECB não` = sum(!is.na(bcb_usd_brl) & is.na(ecb_usd_brl_implicito))) |>
  pivot_longer(everything(), names_to = "Contagem", values_to = "Dias") |>
  tabela("Dias comparáveis: onde não há dados disponíveis", digits = 0)
Dias comparáveis: onde não há dados disponíveis
Contagem Dias
Dias do calendário 2.869
BCB publicou 1.972
BCB e FED juntos 1.866
BCB e ECB juntos 1.959
BCB publicou e FED não 106
BCB publicou e ECB não 13
resumo_dif |>
  transmute(Fonte = fonte, `Dias comparados` = dias, `Média (com sinal, %)` = media,
            `Média em módulo (%)` = abs_media, `Dentro de ±1% (%)` = dentro_1pct,
            `Pior dia` = format(pior_dia, "%d/%m/%Y"), `Pior diferença (%)` = pior_dif) |>
  tabela("Resumo das diferenças vs. PTAX", digits = 3)
Resumo das diferenças vs. PTAX
Fonte Dias comparados Média (com sinal, %) Média em módulo (%) Dentro de ±1% (%) Pior dia Pior diferença (%)
ECB (implícito) 1.959 -0,003 0,323 96,682 03/06/2020 2,556
FED (USD→BRL) 1.866 -0,015 0,223 98,982 09/11/2020 1,657

Conclusão da Pergunta 2. Sim, as três fontes concordam. Em 1.866 dias comparáveis com o FED e 1.959 com o ECB, a diferença típica em relação ao PTAX é de poucos décimos de ponto percentual, sem viés. Isso permite dizer que o dólar em reais não depende da fonte escolhida na escala dos gráficos. Para uso em contratos ou cálculos de imposto, onde cada centavo importa, a fonte importa: o dia pior chegou a 2,6%.

A comparação só é possível porque a base final apresenta as cotações na mesma unidade e organiza os registros por data. Em 106 dias o BCB publicou e o FED não, e esses dias seguem na tabela comparacao com NA.

4.3 Explore os dados usados na análise

As tabelas abaixo são interativas: use as abas para trocar de base de dados, a caixa Buscar, os filtros no topo de cada coluna, o menu Mostrar (número de linhas) e a paginação. Os botões Copy e CSV exportam o que estiver filtrado. Como a base final tem 941.240 linhas (grande demais para uma página web), a primeira aba mostra uma amostra aleatória de 700 linhas por banco; as demais mostram os dados completos usados em cada análise.

Base final (amostra)

set.seed(2026)   # amostra reprodutível
amostra_base <- cotacoes |>
  group_by(banco) |> slice_sample(n = 700) |> ungroup() |>
  arrange(banco, date)

tabela_dt(amostra_base, colunas_num = c("value", "local_por_estrangeira"), digits = 4, completo = TRUE,
          legenda = paste0("Base final: amostra de ", fmt(nrow(amostra_base), 0), " de ",
                           fmt(nrow(cotacoes), 0), " linhas"))

Painel (formato largo)

tabela_dt(painel, colunas_num = series_esperadas, digits = 4, completo = TRUE,
          legenda = paste0("Painel do ", moeda_foco, ": uma coluna por série, um dia por linha (NA = sem publicação)"))

Calendário e NA (amostra)

set.seed(2026)
amostra_cob <- cobertura |>
  group_by(banco) |> slice_sample(n = 600) |> ungroup() |>
  arrange(banco, date)

tabela_dt(select(amostra_cob, date, banco, dia_semana, fim_de_semana, n_cotacoes, publicou, motivo_ausencia),
          completo = TRUE,
          legenda = paste0("Calendário: amostra de ", fmt(nrow(amostra_cob), 0), " de ",
                           fmt(nrow(cobertura), 0), " linhas (banco × dia)"))

Dólar em seis moedas

usd_longo |>
  transmute(date, moeda, valor, indice, retorno_pct = retorno * 100) |>
  tabela_dt(colunas_num = c("valor", "indice", "retorno_pct"), digits = 4, completo = TRUE,
            legenda = "Dólar em moeda local, índice base 100 e retorno diário (%) — Pergunta 1")

Dólar em reais: três fontes

tabela_dt(select(comparacao, -fim_de_semana),
          colunas_num = c("bcb_usd_brl", "fed_usd_brl", "ecb_eur_brl", "ecb_eur_usd",
                          "ecb_usd_brl_implicito", "dif_fed_pct", "dif_ecb_pct"),
          digits = 4, completo = TRUE,
          legenda = "BCB, FED e ECB lado a lado, com a diferença percentual vs. PTAX — Pergunta 2")

Eventos

efeitos |>
  transmute(`Nº` = n, Data = data, Evento = evento, Moeda = moeda,
            Antes = antes, Depois = depois, `Variação em 30 dias (%)` = var_pct,
            `Percentil do movimento` = percentil) |>
  tabela_dt(colunas_num = c("Antes", "Depois", "Variação em 30 dias (%)", "Percentil do movimento"),
            digits = 2, completo = TRUE,
            legenda = "Variação do dólar nos 30 dias após cada evento (percentil = vs. todas as janelas de 30 dias)")

Países (mapa)

mapa_paises |>
  transmute(`País` = pais, `Código ISO-3` = iso3, Moeda = moeda_x,
            `Início` = data_ini, Fim = data_fim, `Valor inicial` = ini, `Valor final` = fim,
            `Alta acumulada (%)` = var_acum_pct) |>
  arrange(desc(`Alta acumulada (%)`)) |>
  tabela_dt(colunas_num = c("Valor inicial", "Valor final", "Alta acumulada (%)"), digits = 2,
            completo = TRUE, legenda = "Dólar frente à moeda de cada país")

5. Conclusões

5.1 O problema abordado

O trabalho partiu de uma dificuldade prática: taxas de câmbio oficiais vêm de instituições que publicam de formas diferentes, e comparar as séries sem tratamento leva a erros. As duas perguntas norteadoras foram (1) frente a quais moedas o dólar mais se valorizou desde 2019, quando ocorreram os maiores choques e quão instável foi a trajetória; e (2) se as taxas oficiais de instituições diferentes para o dólar em reais são intercambiáveis.

5.2 Como o problema foi abordado

Foram importados 56 CSVs históricos (7 instituições, 2019–2026) e os JSONs de metadados do repositório AllRates-Today. Os dados foram padronizados para a mesma unidade (local_por_estrangeira), integrados em uma base de 941.240 linhas e 13 colunas, completados com um calendário que expõe os dias sem publicação como NA e reorganizados em formato largo. A exploração usou variáveis derivadas (índice base 100, retorno logarítmico, variação anual, volatilidade total e móvel, dólar em reais implícito no ECB e diferença percentual em relação ao PTAX). Para contar a história, a trajetória foi contextualizada com quatro eventos globais (janelas de 30 dias) e ampliada para um mapa-múndi do dólar frente a 142 moedas, obtido por cruzamento com o real do BCB.

5.3 Principais insights

A análise das variações cambiais evidencia uma valorização ampla, embora desigual, do dólar em relação às moedas examinadas. No período analisado, a moeda norte-americana apresentou alta frente a 4 das seis moedas consideradas, com o maior avanço registrado em relação ao Real (BCB) (42,5%). Em contrapartida, a variação frente ao Libra (BoE) foi de -6,4%. Essa valorização, entretanto, não ocorreu de maneira uniforme ao longo do tempo: os maiores movimentos anuais concentraram-se em episódios específicos, como a maior alta frente ao Real (BCB), em 2020 (28,9%), e a maior queda frente ao Euro (ECB), em 2025 (-11,6%). A volatilidade móvel reforça essa interpretação ao demonstrar que a instabilidade cambial se concentra em determinados intervalos, tendo como maior pico o observado para o Real (BCB) em 06/2020. Um exemplo particularmente relevante é a pandemia de COVID-19: nos 30 dias posteriores a 11/03/2020, o dólar apresentou variação de +8,6% frente ao real, enquanto a volatilidade cambial brasileira foi multiplicada por 2,5 em comparação com o período anterior. Apesar da intensidade desse episódio, a análise dos quatro eventos selecionados indica que a valorização do dólar não constitui uma resposta uniforme a choques econômicos, uma vez que apenas 2 deles apresentaram mediana das moedas compatível com um fortalecimento da moeda norte-americana no mês seguinte.

Em uma perspectiva internacional mais ampla, os resultados do mapa indicam que o dólar se valorizou frente a 77% das 142 moedas analisadas, com variação mediana de +6%. As maiores valorizações concentraram-se em países que enfrentaram condições inflacionárias mais acentuadas, sugerindo que fatores econômicos domésticos também desempenham papel importante na dinâmica cambial. Quanto à confiabilidade das séries utilizadas, a comparação com as taxas oficiais do Federal Reserve (FED) e do Banco Central Europeu (ECB) revelou diferenças médias absolutas de 0,22% e 0,32%, respectivamente, sem evidências de viés sistemático. A maior dispersão observada em relação ao ECB pode ser atribuída, em parte, à metodologia de cálculo de sua taxa de referência, baseada em duas cotações. Por fim, a análise das observações ausentes demonstra que a maior parte dos valores não publicados está associada ao próprio calendário de divulgação: 90% dos dias sem registro correspondem a finais de semana, enquanto 10% correspondem a dias úteis sem publicação. Nesse aspecto, o FED apresenta um comportamento distinto, em razão de sua periodicidade semanal de divulgação. Em conjunto, esses resultados reforçam a importância de considerar tanto as particularidades metodológicas das fontes quanto os períodos de instabilidade econômica na interpretação das variações cambiais.s

5.4 Limitações do projeto

  • Cadências diferentes: O FED publica semanalmente, o que reduz o número de dias comparáveis; a volatilidade anualizada é uma aproximação, porque os bancos não publicam todos os dias úteis.
  • Escopo limitado: Foram analisadas sete instituições, seis moedas e um único par para a comparação entre fontes; o ECB aparece com uma taxa calculada, não publicada.
  • Limites do mapa: O dólar em cada moeda é obtido por cruzamento via real (PTAX, venda); só entram as moedas publicadas pelo BCB com série no início e no fim do período. Países que compartilham moeda (como os da zona do euro) aparecem com o mesmo valor, o associação país-moeda vem de uma tabela externa (CLDR) e países com moeda atrelada ao dólar ou dolarizados mostram pouca ou nenhuma variação. Altas extremas (inflação muito alta) saturam a escala de cores.
  • Dados vivos: O repositório é atualizado diariamente e o ano corrente é rebaixado a cada execução; os números podem mudar levemente entre execuções.