Relatório

Arquivos baixados
tipo arquivos esperado
CSV (history) 56 56
JSON 9 9

1. Dados e tratamento

1.1 Registros históricos em CSV

O relatório acompanha os dados cambiais de sete instituições, entre 2019 e 2026, a partir de arquivos CSV e JSON. Primeiro, os dados são lidos e integrados; em seguida, as cotações são normalizadas para permitir comparações. Por fim, analisamos a evolução do dólar frente a seis moedas e verificamos a concordância entre BCB, FED e ECB. Os valores ausentes (NA) são preservados durante a combinação das informações e das datas.

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

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: O CSV não traz o identificador do banco em cada linha; essa informação é recuperada do nome do arquivo durante a leitura. Os tipos das colunas são definidos explicitamente (date como Date e value como double), evitando que o R precise inferi-los. 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)) |>
  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 2.5 e 2.6).

1.2 Informações sobre as instituições

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

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-05",
   "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: Cada arquivo JSON organiza as instituições por uma chave própria. Essa chave passa a identificar cada linha nas tabelas criadas durante a leitura. Campos ausentes são representados por NA, sem eliminar a instituição. As informações de sources.json e index.json permanecem em tabelas separadas até serem reunidas com as cotações.

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"))

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")
}
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-05 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 129
index.json 129

1.3 Cotações mais recentes

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-05",
 "cadence": "daily",
 "rates": [
  {
   "base": "AED",
   "quote": "BRL",
   "type": "buy",
   "value": 1.487
  },
  {
   "base": "AED",
   "quote": "BRL",
   "type": "sell",
   "value": 1.4873
cat("   ...\n")
   ...

O que foi feito: As taxas do campo rates são organizadas em uma tabela para representar 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 aparecem no CSV, comparamos a quantidade de taxas dos dois arquivos para verificar se os registros coincidem na mesma data.

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-05"
 $ 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"
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-05 312 312 TRUE
fed 2026-10-30 22 22 TRUE
ecb 2026-11-05 29 29 TRUE
pboc 2026-11-05 25 25 TRUE
boc 2026-11-04 24 24 TRUE
boe 2026-11-04 23 23 TRUE
bok 2026-11-05 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…

1.4 Como as cotações foram reunidas e padronizadas

Cada linha do CSV só sabe date, base, quote, type, value. Não há país, moeda local ou outros metadados institucionais.

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 arquivo 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 utiliza 1/value quando base é a moeda local.

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(
    moeda_estrangeira = case_when(quote == moeda_local ~ base,
                                  base == moeda_local ~ quote,
                                  TRUE ~ NA_character_),
    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
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.

1.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

É criado um calendário com todos os dias entre 02/01/2019 e a data mais recente de cada banco. Depois, a quantidade de cotações publicadas em cada dia é associada a esse calendário. Quando não há publicação, n_cotacoes permanece como NA, em vez de receber zero. A coluna motivo_ausencia classifica esses casos como fim de semana, dia útil sem publicação ou data posterior à última publicação do banco.

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)

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_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_dia_util_sem_publicacao dias_fim_de_semana dias_apos_ultima_publicacao
bcb 2.865 1.971 76 818 0
boc 2.865 1.956 90 818 1
boe 2.865 1.982 64 818 1
bok 2.865 1.932 115 818 0
ecb 2.865 2.010 37 818 0
fed 2.865 1.923 120 816 6
pboc 2.865 1.902 145 818 0

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 27% 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.

1.6 Organização das cotações para comparação

Na tabela original, 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

São selecionadas as linhas da moeda foco e utilizada 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.

series_esperadas <- cotacoes |> distinct(banco, type) |>
  transmute(serie = paste(banco, type, sep = "_")) |> pull(serie) |> sort()

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

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 894 31,2
bcb_sell 894 31,2
boc_indicative 909 31,7
boe_reference 883 30,8
bok_reference 933 32,6
ecb_reference 855 29,8
fed_reference 2.865 100,0
pboc_reference 963 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.

1.7 Reunião das informações de cada instituição

A mesma fonte (bcb) aparece em dois arquivos JSON, cada um com campos diferentes:

sources_tbl |> filter(codigo == "bcb") |> select(codigo, name, country) |>
  tabela("sources.json", digits = 0)
sources.json
codigo name country
bcb Banco Central do Brasil (PTAX) Brazil
index_tbl |> filter(codigo == "bcb") |> select(codigo, latest, cadence, stale, currencies) |>
  tabela("index.json", digits = 0)
index.json
codigo latest cadence stale currencies
bcb 2026-11-05 daily FALSE 312

As informações de sources.json e index.json são reunidas em uma única tabela, usando o código da instituição para identificar os registros correspondentes. Uma instituição presente em apenas um dos arquivos também é mantida, e os campos sem informação permanecem como NA. Quando um dado aparece em apenas uma das fontes, esse valor é aproveitado. A licença vem do latest.json, enquanto as estatísticas de cobertura são calculadas a partir dos arquivos CSV.

cobertura_csv <- cotacoes |>
  group_by(banco) |>
  summarise(inicio = min(date), fim = max(date), linhas = n(),
            dias_publicados = n_distinct(date),
            pares_cambiais  = n_distinct(base, quote),
            series          = n_distinct(base, quote, type),
            tipos_cotacao   = paste(sort(unique(type)), collapse = ", "),
            media_cotacoes_por_dia = n() / n_distinct(date),
            .groups = "drop")

catalogo <- sources_tbl |>
  full_join(index_tbl, by = "codigo", suffix = c("", ".idx")) |>
  mutate(name          = coalesce(name, name.idx),
         country       = coalesce(country, country.idx),
         home_currency = coalesce(home_currency, home_currency.idx),
         kind          = coalesce(kind, kind.idx),
         page          = coalesce(page, page.idx)) |>
  select(-ends_with(".idx")) |>
  left_join(licencas, by = "codigo") |>
  left_join(rename(cobertura_csv, codigo = banco), by = "codigo") |>
  mutate(no_escopo = codigo %in% bancos)

Como ficaram os dados:

origem_col <- function(col) case_when(
  col == "codigo" ~ "chave das 2 fontes",
  col %in% c("name", "country", "home_currency", "kind", "official_source", "page") ~ "sources.json (completa com index.json)",
  col %in% c("latest", "cadence", "stale", "currencies", "latest_url") ~ "index.json",
  col == "license" ~ "[banco]/latest.json",
  col %in% names(cobertura_csv) ~ "CSV history (calculado)",
  TRUE ~ "derivada")

catalogo |>
  filter(codigo == "bcb") |>
  mutate(across(everything(), \(x) as.character(x))) |>
  mutate(across(-any_of(c("official_source", "page", "latest_url")),
                \(x) str_trunc(x, 38))) |>
  pivot_longer(-codigo, names_to = "coluna", values_to = "valor") |>
  mutate(
    origem = origem_col(coluna),
    rotulo_link = case_when(
      coluna == "official_source" ~ "API oficial do BCB (PTAX)",
      coluna == "page" ~ "Página da fonte na base de dados",
      coluna == "latest_url" ~ "Dados mais recentes do BCB",
      TRUE ~ NA_character_
    ),
    valor_exibido = case_when(
      is.na(valor) ~ "—",
      !is.na(rotulo_link) & str_detect(valor, "^https://") ~ paste0(
        '<a href="', htmltools::htmlEscape(valor, attribute = TRUE),
        '" target="_blank" rel="noopener noreferrer">', rotulo_link, '</a>'
      ),
      TRUE ~ htmltools::htmlEscape(str_trunc(valor, 38))
    )
  ) |>
  select(coluna, bcb = valor_exibido, origem) |>
  kable(format = "html", escape = FALSE,
        col.names = c("Informação", "Registro do BCB", "Origem"),
        caption = "Registro do BCB no catálogo") |>
  kable_styling(bootstrap_options = "striped", full_width = FALSE,
                position = "left", font_size = 12) |>
  scroll_box(width = "100%", height = "420px")
Registro do BCB no catálogo
Informação Registro do BCB Origem
country Brazil sources.json (completa com index.json)
home_currency BRL sources.json (completa com index.json)
kind central_bank sources.json (completa com index.json)
name Banco Central do Brasil (PTAX) sources.json (completa com index.json)
official_source API oficial do BCB (PTAX) sources.json (completa com index.json)
page Página da fonte na base de dados sources.json (completa com index.json)
latest 2026-11-05 index.json
cadence daily index.json
stale FALSE index.json
currencies 312 index.json
latest_url Dados mais recentes do BCB index.json
license CC BY-NC 4.0 — non-commercial use w… [banco]/latest.json
inicio 2019-01-02 CSV history (calculado)
fim 2026-11-05 CSV history (calculado)
linhas 615601 CSV history (calculado)
dias_publicados 1971 CSV history (calculado)
pares_cambiais 160 CSV history (calculado)
series 320 CSV history (calculado)
tipos_cotacao buy, sell CSV history (calculado)
media_cotacoes_por_dia 312.329274479959 CSV history (calculado)
no_escopo TRUE derivada

O catálogo reúne os metadados disponíveis em sources.json e index.json, complementados pela licença de cada arquivo latest.json e pelas estatísticas calculadas a partir dos CSVs.

2. Base final

2.1 Tabela final

Base consolidada: 940.692 linhas × 13 colunas. A tabela abaixo mostra 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

2.2 O significado e a origem de cada coluna

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"
)

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

2.3 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 940.692 linhas = 940.692 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-05 615.601 1.971 160 320 buy, sell 312,3
boc 2019-01-02 2026-11-04 45.866 1.956 27 27 indicative 23,4
boe 2019-01-02 2026-11-04 45.586 1.982 23 23 reference 23,0
bok 2019-01-02 2026-11-05 83.076 1.932 43 43 reference 43,0
ecb 2019-01-02 2026-11-05 61.921 2.010 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-05 46.336 1.902 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) 940.692 13 0
painel de comparação 2.865 11 9.196
cobertura (calendário) 20.055 8 20.055
catalogo 129 22 1.098

A base final (cotacoes) tem 0 NA porque reúne apenas as cotações efetivamente publicadas. Os valores ausentes aparecem 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. Como o dólar variou em relação a outras moedas?

Pergunta

Desde janeiro de 2019, frente a quais moedas o dólar mais subiu e em que ano aconteceram os maiores choques?

Usa 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. Para esta análise só entram os dias com cotação; os NA continuam na base e no painel.

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)")

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),
         retorno = log(valor / lag(valor))) |>
  ungroup()

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))

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

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

ggplot(usd_longo, aes(date, indice, color = moeda)) +
  geom_hline(yintercept = 100, color = "grey70", linetype = "dashed") +
  geom_line(linewidth = 0.7) +
  scale_color_manual(values = cores_moedas) +
  scale_x_date(date_breaks = "1 year", date_labels = "%Y") +
  labs(title = "O dólar frente a seis moedas, visto por cada banco central",
       subtitle = "Índice base 100 = primeira cotação de cada série (jan/2019). Acima de 100 = dólar mais forte",
       x = NULL, y = "Índice", caption = fonte_txt) +
  theme_story()

O dólar subiu frente a 4 das 6 moedas desde jan/2019. O destaque é o Real (BCB): o dólar acumula 41,5% de alta (de 3,86 para 5,46 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.

Volatilidade e principais resultados

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,46 41,52 14,12
Won (BoK) 1.118,10 1.340,70 19,91 7,99
Dólar canadense (BoC) 1,36 1,40 2,65 5,70
Euro (ECB) 0,88 0,89 1,54 7,14
Yuan (PBoC) 6,85 6,72 -1,94 3,30
Libra (BoE) 0,79 0,74 -6,41 8,80

Conclusão. A mesma moeda lidera nos dois critérios: Real (BCB) é a que mais perdeu valor frente ao dólar (41,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.

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 2.4).

4. As instituições apresentam cotações semelhantes?

Pergunta

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 (dólar em reais) 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.

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,
         dif_fed_pct = (fed_usd_brl / bcb_usd_brl - 1) * 100,
         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.865
BCB publicou 1.971
BCB e FED juntos 1.866
BCB e ECB juntos 1.958
BCB publicou e FED não 105
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.958 -0,003 0,323 96,680 03/06/2020 2,556
FED (USD→BRL) 1.866 -0,015 0,223 98,982 09/11/2020 1,657

Conclusão

Sim, as três fontes concordam. Em 1.866 dias comparáveis com o FED e 1.958 com o ECB, a diferença típica em relação ao PTAX é de poucos décimos de ponto percentual, sem viés. Para o storytelling, 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 105 dias o BCB publicou e o FED não, e esses dias seguem na tabela comparacao com NA (só ficam de fora do cálculo da diferença).