Você pede a PTAX de uma data, a API responde 200 OK e devolve uma lista vazia. Sem erro, sem aviso. Pior: às vezes ela devolve uma cotação, só que de outro dia. Eu sei como é. Você confere a fórmula, confere o filtro, e o número continua sumindo ou aparecendo errado.

A PTAX do Banco Central quase nunca reclama de parâmetro errado: ela responde com o que achou, e muitas vezes não achou nada. Foi assim que montamos o dashboard de câmbio da Chora API, e é o que este artigo mostra.

Você vai consultar o dólar do dia, voltar ao dia útil anterior quando a data não tem cotação, comparar abertura e fechamento, trazer o histórico inteiro de uma vez e tratar as datas duplicadas. O código em Python foi executado aqui. As consultas em Power Query foram montadas pela documentação oficial, e você testa no seu Power BI Desktop. Bora lá.

Requisitos

  • API Olinda, serviço PTAX, versão v1 (protocolo OData). Pública: sem login, sem chave.
  • Para a versão Power Query: Power BI Desktop ou Excel com Power Query, conector Web com acesso Anônimo.
  • Para a versão Python: Python 3.10 ou mais novo, só com a biblioteca padrão (urllib, json, datetime).
  • Demo ao vivo: choraapi.com.br/dashboards/cambio-ptax/ busca a mesma API direto do navegador.

Válido em: novembro/2026.

O que é a PTAX

A PTAX é a taxa de câmbio de referência do Banco Central. Desde 01/07/2011, ela é a média aritmética das taxas obtidas em quatro consultas diárias aos dealers de câmbio. A API entrega, para cada dia, cinco boletins: uma abertura, três intermediários e um fechamento. O boletim de fechamento (o "PTAX") é a média dos boletins do dia.

Dá para conferir com os números reais de 06/11/2026, na compra do dólar: (5,5042 + 5,5174 + 5,4882 + 5,4930) ÷ 4 = 5,5007, o mesmo valor do fechamento.

Os recursos que você vai usar:

RecursoO que devolve
CotacaoDolarDiaCompra e venda do dólar em uma data
CotacaoDolarPeriodoCompra e venda do dólar, uma linha por dia, em um período
CotacaoMoedaDiaOs boletins do dia de uma moeda, com paridade e tipoBoletim
CotacaoMoedaPeriodoOs boletins de um período de uma moeda
MoedasA lista das 10 moedas disponíveis

1 Chame a função do dia

Os recursos de cotação são funções OData: o nome tem parênteses e o valor de cada parâmetro vai em outro lugar da URL, como alias com @. A data tem três regras: formato MM-DD-AAAA, entre aspas simples, e o mês vem primeiro.

curl -s "https://olinda.bcb.gov.br/olinda/servico/PTAX/versao/v1/odata/CotacaoDolarDia(dataCotacao=@dataCotacao)?@dataCotacao='11-06-2026'&\$format=json"
{"value":[{"cotacaoCompra":5.50070,"cotacaoVenda":5.50130,"dataHoraCotacao":"2026-11-06 13:06:13.966362"}]}

dataHoraCotacao vem como texto, com a hora do fechamento. Os números vêm como número, não como texto.

Atenção

a data 06-11-2026 não dá erro. A API lê como mês 06, dia 11, e devolve a cotação de 11 de junho de 2026. Para os dias de 1 a 12 de cada mês, dia e mês trocam sem nenhum aviso. Formate a data pelo código (%m-%d-%Y) e não monte a string à mão.

2 Dia sem cotação: volte ao dia útil anterior

Peça a PTAX de um sábado, de um feriado ou de uma data futura e a resposta é 200 com "value":[]. Não é erro: é a API dizendo que não há boletim naquele dia.

Quem precisa da "PTAX vigente" (um contrato que pede a PTAX do dia útil anterior, por exemplo) resolve voltando dia a dia até achar uma cotação. Este script foi executado em 07/11/2026, um sábado:

import json, time, urllib.error, urllib.request
from datetime import date, timedelta

BASE = "https://olinda.bcb.gov.br/olinda/servico/PTAX/versao/v1/odata"

def baixar(url, tentativas=3):
    for n in range(1, tentativas + 1):
        try:
            with urllib.request.urlopen(url, timeout=60) as r:
                return json.loads(r.read())["value"]
        except urllib.error.HTTPError as e:
            if e.code in (502, 503, 504) and n < tentativas:
                time.sleep(5 * n)
                continue
            raise

def mmddaaaa(d):                       # a PTAX quer MM-DD-AAAA, e a data vai entre aspas simples
    return d.strftime("%m-%d-%Y")

def dolar_dia(d):
    url = (f"{BASE}/CotacaoDolarDia(dataCotacao=@dataCotacao)"
           f"?@dataCotacao='{mmddaaaa(d)}'&$format=json")
    return baixar(url)                 # lista vazia = fim de semana, feriado ou data futura (não é erro)

def ptax_do_dia_ou_anterior(d, limite=10):
    for volta in range(limite):
        achou = dolar_dia(d - timedelta(days=volta))
        if achou:
            return d - timedelta(days=volta), achou[0]
    raise RuntimeError(f"nenhuma cotação nos {limite} dias até {d}")

dia, cot = ptax_do_dia_ou_anterior(date(2026, 11, 7))        # sábado
print("pedido 07/11/2026 (sábado) ->", dia, cot["cotacaoCompra"], cot["cotacaoVenda"], cot["dataHoraCotacao"][11:16])
pedido 07/11/2026 (sábado) -> 2026-11-06 5.5007 5.5013 13:06

Por que assim:

  • A retentativa em 502, 503 e 504 é a mesma defesa que usamos com as APIs do Banco Central: elas falham de vez em quando e passam na segunda tentativa.
  • O limite de 10 dias evita um laço infinito se o parâmetro estiver errado. Um feriado prolongado tem poucos dias seguidos sem cotação.
  • timedelta em vez de mexer no dia do mês, para o código não quebrar na virada de mês e em 29/02.

Quais dias úteis ficam sem cotação? Em 2026, até 06/11, foram 10: 01/01, 16/02, 17/02, 03/04, 21/04, 01/05, 04/06, 07/09, 12/10 e 02/11. Pelas datas, são os feriados bancários nacionais. Para não depender de uma lista sua de feriados, deixe a API dizer.

3 Abertura × fechamento: os boletins do dia

Para ver os cinco boletins de uma moeda, use CotacaoMoedaDia. Este código foi executado em 07/11/2026 e compara a abertura com o fechamento de 06/11:

def boletins(moeda, d):
    url = (f"{BASE}/CotacaoMoedaDia(moeda=@moeda,dataCotacao=@dataCotacao)"
           f"?@moeda='{moeda}'&@dataCotacao='{mmddaaaa(d)}'&$format=json")
    return sorted(baixar(url), key=lambda r: r["dataHoraCotacao"])

bs = boletins("USD", date(2026, 11, 6))
for b in bs: print(f'{b["tipoBoletim"]:<16} {b["dataHoraCotacao"][11:16]}  compra {b["cotacaoCompra"]:.4f}  venda {b["cotacaoVenda"]:.4f}')
abertura = bs[0]; fech = bs[-1]
print("variação abertura -> fechamento (venda): %+.4f" % (fech["cotacaoVenda"] - abertura["cotacaoVenda"]))
media = round(sum(b["cotacaoCompra"] for b in bs[:4]) / 4, 4)
print("média dos 4 primeiros boletins (compra):", media, "| fechamento PTAX:", round(fech["cotacaoCompra"], 4))
Abertura         10:02  compra 5.5042  venda 5.5048
Intermediário    11:09  compra 5.5174  venda 5.5180
Intermediário    12:06  compra 5.4882  venda 5.4888
Intermediário    13:06  compra 5.4930  venda 5.4936
Fechamento PTAX  13:06  compra 5.5007  venda 5.5013
variação abertura -> fechamento (venda): -0.0035
média dos 4 primeiros boletins (compra): 5.5007 | fechamento PTAX: 5.5007

O último intermediário e o fechamento saem com o mesmo horário (13:06:13); os microssegundos desempatam, e por isso ordenar por dataHoraCotacao e pegar o último funciona.

Atenção

o mesmo boletim tem dois nomes. No recurso do dia (CotacaoMoedaDia), o fechamento vem como Fechamento PTAX. No recurso de período (CotacaoMoedaPeriodo), vem como Fechamento. Então $filter=tipoBoletim eq 'Fechamento' funciona no período e devolve vazio no dia, e eq 'Fechamento PTAX' faz o oposto. O swagger oficial fala em "Abertura, Intermediário, Fechamento Interbancário ou Fechamento" e não menciona Fechamento PTAX. Confira o valor que o recurso que você usa devolve.

4 Outras moedas: tipo A e tipo B

Moedas lista as 10 moedas: AUD, CAD, CHF, DKK, EUR, GBP, JPY, NOK, SEK e USD. Cada uma tem um tipo, que muda o sentido da paridade:

  • Tipo A (CAD, CHF, DKK, JPY, NOK, SEK, USD): a paridade é a quantidade da moeda por 1 dólar.
  • Tipo B (AUD, EUR, GBP): a paridade é a quantidade de dólares por 1 unidade da moeda.

Não calcule a cotação em reais pela paridade: ela já vem pronta em cotacaoCompra e cotacaoVenda. Para o fechamento de 06/11/2026 (consulta CotacaoMoedaDia com $filter=tipoBoletim eq 'Fechamento PTAX'):

EUR (tipo B)  paridade 1.1144 / 1.1146   cotação em reais 6.13 / 6.1317
JPY (tipo A)  paridade 158.23 / 158.27   cotação em reais 0.03476 / 0.03477

Na prática: o euro vale 1,1144 dólar e custa R$ 6,13. O iene tem 158,23 unidades por dólar e custa R$ 0,03476.

5 O histórico inteiro, sem limite de 10 anos

Quem usa a SGS do Banco Central conhece o limite: desde 26/03/2025, séries diárias aceitam no máximo 10 anos por consulta. A Olinda PTAX não tem esse limite. Este código pede 27 anos em uma chamada só e foi executado em 07/11/2026:

def dolar_periodo(inicio, fim):
    mdy = lambda d: d.strftime("%m-%d-%Y")
    url = (f"{BASE}/CotacaoDolarPeriodo(dataInicial=@dataInicial,dataFinalCotacao=@dataFinalCotacao)"
           f"?@dataInicial='{mdy(inicio)}'&@dataFinalCotacao='{mdy(fim)}'&$format=json")
    return baixar(url)

historico = dolar_periodo(date(1999, 1, 1), date(2026, 11, 6))   # 27 anos em um pedido só
datas = [r["dataHoraCotacao"][:10] for r in historico]
print(len(historico), "linhas")
print(datas[0], historico[0]["cotacaoVenda"], "->", datas[-1], historico[-1]["cotacaoVenda"])
print("datas repetidas:", len(datas) - len(set(datas)))
6995 linhas
1999-01-04 1.2078 -> 2026-11-06 5.5013
datas repetidas: 2

A resposta tem cerca de 630 KB e voltou em menos de um segundo. E repare na última linha: 2 datas repetidas. CotacaoDolarPeriodo devolve, em geral, uma linha por dia útil, mas não é garantido. Em 21/12/2001 a mesma linha veio duas vezes, e em 23/04/2025 vieram dois fechamentos que diferem só nos milissegundos (13:06:30.416 e 13:06:30.443). Quem soma ou conta por data duplica esses dois dias.

A correção é guardar uma linha por dia, a última por dataHoraCotacao:

por_dia = {}
for r in sorted(historico, key=lambda r: r["dataHoraCotacao"]):
    por_dia[r["dataHoraCotacao"][:10]] = r          # a última linha do dia vence
print(len(historico), "linhas ->", len(por_dia), "dias únicos")
6995 linhas -> 6993 dias únicos

É a mesma regra do dashboard: "se a API repetir o dia, vale a última".

Atenção

a regra de "cinco boletins por dia" vale para os dados recentes. Em 21/12/2001, o dia tinha 18 linhas: uma abertura, 14 intermediários (de meia em meia hora), um fechamento interbancário, um intermediário às 17:59 com venda 0.0 e dois fechamentos PTAX. Se você traz boletins de anos antigos com CotacaoMoedaPeriodo, filtre por tipoBoletim.

6 Power Query: o dólar por período

Conforme a documentação oficial do Web.Contents (link nas referências); não executado aqui. Teste no seu Power BI Desktop antes de publicar.

let
    Inicio = #date(2026, 1, 1),
    Fim = Date.From(DateTime.LocalNow()),
    Mdy = (d as date) as text => "'" & Date.ToText(d, "MM-dd-yyyy", "en-US") & "'",
    Fonte = Json.Document(Web.Contents("https://olinda.bcb.gov.br", [
        RelativePath = "olinda/servico/PTAX/versao/v1/odata/CotacaoDolarPeriodo(dataInicial=@dataInicial,dataFinalCotacao=@dataFinalCotacao)",
        Query = [
            #"@dataInicial" = Mdy(Inicio),
            #"@dataFinalCotacao" = Mdy(Fim),
            #"$format" = "json"
        ]
    ])),
    Linhas = Fonte[value],
    Tabela = if List.IsEmpty(Linhas)
        then #table(type table [Data = date, Compra = number, Venda = number], {})
        else Table.FromRecords(Linhas),
    Tipada = if List.IsEmpty(Linhas) then Tabela else Table.TransformColumnTypes(
        Table.RenameColumns(
            Table.TransformColumns(Tabela, {{"dataHoraCotacao", each Text.Start(_, 10), type text}}),
            {{"dataHoraCotacao", "Data"}, {"cotacaoCompra", "Compra"}, {"cotacaoVenda", "Venda"}}),
        {{"Data", type date}, {"Compra", type number}, {"Venda", type number}}, "en-US")
in
    Tipada

Por que montar assim:

  • RelativePath e Query em vez de concatenar a URL. O serviço do Power BI não atualiza fonte dinâmica, aquela cuja URL só se conhece quando a consulta roda. Com a base fixa no primeiro argumento do Web.Contents, a atualização agendada funciona.
  • Date.ToText(d, "MM-dd-yyyy", "en-US"). A cultura fixa em en-US garante o mês na frente mesmo num Power BI em português. É a defesa contra a troca de 06-11 por 11-06 do passo 1.
  • O if List.IsEmpty. Se Linhas vier vazia (período sem cotação), Table.FromRecords gera uma tabela sem colunas, e as etapas seguintes falhariam. A tabela vazia com as colunas certas evita isso.
  • Text.Start(_, 10) pega os 10 primeiros caracteres de dataHoraCotacao (2026-11-06) e deixa a data limpa.

Para as duas datas repetidas do passo 5, acrescente ao final um Table.Distinct pela coluna Data. Ele fica com a primeira ocorrência de cada data; como as duas linhas repetidas trazem os mesmos valores de compra e venda, o resultado é o mesmo.

Para os boletins do dia de uma moeda, transforme a consulta em função:

(moeda as text, dia as date) as table =>
let
    Mdy = "'" & Date.ToText(dia, "MM-dd-yyyy", "en-US") & "'",
    Fonte = Json.Document(Web.Contents("https://olinda.bcb.gov.br", [
        RelativePath = "olinda/servico/PTAX/versao/v1/odata/CotacaoMoedaDia(moeda=@moeda,dataCotacao=@dataCotacao)",
        Query = [#"@moeda" = "'" & moeda & "'", #"@dataCotacao" = Mdy, #"$format" = "json"]
    ])),
    Tabela = Table.FromRecords(Fonte[value])
in
    Tabela

Também não executado. Dois pontos que não consegui testar sem o Power BI: se o Web.Contents aceita os parênteses e o @ dentro do RelativePath e das chaves do Query, e como ele monta a URL final. O que eu verifiquei foi o lado da API: a Olinda aceita a URL com @ e aspas codificadas (%40dataCotacao=%2711-06-2026%27) e com os parênteses codificados.

Erros comuns

Erro 1

Cotação vazia (200 com value vazio):

{"@odata.context":"https://was-p.bcnet.bcb.gov.br/olinda/servico/PTAX/versao/v1/odata$metadata#_CotacaoDolarDia","value":[]}

Foi exatamente essa resposta, executada em 06/11/2026, para: data em formato ISO ('2026-11-06'), moeda em minúscula ('usd'), moeda inexistente ('XXX'), data inicial maior que a final, fim de semana, feriado e data futura. Causa: a função não valida o parâmetro; sem dado, devolve lista vazia. Correção: trate value vazio como "sem cotação", use o formato MM-DD-AAAA e o símbolo em maiúscula (USD).

Erro 2

Data lida com o dia e o mês trocados, sem erro:

@dataCotacao='06-11-2026'  ->  dataHoraCotacao: 2026-06-11 13:09:12

Causa: a API lê MM-DD-AAAA. Correção: formate a data pelo código, como no passo 1.

Erro 3

Data sem aspas (HTTP 400):

/*{
  "codigo" : 400,
  "mensagem" : "The part '11-06-2026' is not defined."
}*/

Causa: @dataCotacao=11-06-2026 sem as aspas simples. Correção: @dataCotacao='11-06-2026'.

Erro 4

Recurso inexistente (HTTP 400):

"mensagem" : "Cannot find EntitySet, Singleton, ActionImport or FunctionImport with name 'CotacaoDolar'."

Causa: faltou Dia ou Periodo no nome do recurso. Correção: CotacaoDolarDia ou CotacaoDolarPeriodo.

Erro 5

Campo com maiúscula errada no filtro (HTTP 400):

"mensagem" : "The property 'tipoboletim', used in a query expression, is not defined in type 'br.gov.bcb.olinda.servico.PTAX.TipoCotacaoMoeda'."

Causa: o OData diferencia maiúscula de minúscula. Correção: tipoBoletim.

Erro 6

Filtro de boletim que não casa (200 vazio): tipoBoletim eq 'Fechamento PTAX' em CotacaoMoedaPeriodo devolveu 0 linhas, e eq 'Fechamento' em CotacaoMoedaDia também. Causa: o fechamento tem dois nomes (passo 3). Correção: use o nome do recurso que você chama.

Dica

os erros 400 da Olinda vêm com o JSON entre /* e */. Se o seu código tentar ler direto como JSON, vai dar erro de parse em cima do erro original. Tire o /* e o */ e leia o miolo (é o que fazemos no artigo sobre o Boletim Focus, com o código pronto).

Bônus

Dica bônus: confira o seu número no dashboard

Montou a consulta no Power BI ou no Python? Abra o dashboard de câmbio, escolha a mesma data e compare os boletins do dia com os da sua consulta. Se a cotação bater, a data está certa. Se vier de outro dia, volte ao passo 1 e confira o formato.

Conclusão

Agora você consulta a PTAX sabendo que ela não grita quando você erra: confere o formato MM-DD-AAAA, trata a lista vazia como dia sem cotação, volta ao dia útil anterior quando precisa e guarda uma linha por dia. E traz 27 anos de histórico de uma vez, sem o limite de 10 anos da SGS.

Se você quer ligar o Power BI a APIs como essa e montar o painel com dados que se atualizam sozinhos, é isso que o curso Chora API ensina, com Linguagem M e DAX no contexto real. Qualquer dúvida, comenta aqui, na comunidade #conectados.

Quer ligar o Power BI a APIs como essas?

Na Formação Chora API você aprende a ligar o Power BI a uma API e montar o painel com dados atualizados, com Linguagem M e DAX no contexto do curso.

Ver a Formação →

Referências

Documentação oficial (consultada em 07/11/2026):