| tipo | arquivos | esperado |
|---|---|---|
| CSV (history) | 56 | 56 |
| JSON | 9 | 9 |
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)
| 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)
| 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).
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)
| 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)
| 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)
| arquivo | fontes |
|---|---|
| sources.json | 129 |
| index.json | 129 |
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)
| 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)
| 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… |
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)
| 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)
| 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.
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)
| 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)
| 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)
| 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.
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)
| 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)
| 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)
| 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.
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)
| 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)
| 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")
| 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.
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)
| 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 |
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)
| 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)
| origem_tipo | colunas |
|---|---|
| CSV | 6 |
| sources.json | 4 |
| Derivada | 2 |
| index.json | 1 |
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)
| 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)
| 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)
| 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.
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)
| 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 |
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.
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.
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)
| 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).
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)
| 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 |
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.
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)
| 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)
| 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 |
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).