CMS y plataformas 14 min de lectura

Scraping de tipos de cambio: APIs, fuentes, código y almacenamiento de datos

Recopilación de tipos de cambio: APIs oficiales de bancos centrales, agregadores, scraping de sitios bancarios y almacenamiento del histórico para análisis.

EW
Equipo Web-Scraping.es
Recopilación de datos para las necesidades del negocio
Publicado: 20 marzo 2025

El tipo de cambio es una de esas magnitudes que parecen inofensivas («solo un número»), pero que, manejadas sin cuidado, se convierten en una fuente de errores difíciles de rastrear: descuadres de céntimos, un nominal mal aplicado, el tipo «perdido» del fin de semana, el error de redondeo acumulado en el informe anual. Este artículo es un repaso práctico de dónde obtener los tipos de cambio (el Banco Central Europeo, otros bancos centrales y los servicios internacionales), cómo extraerlos en cinco lenguajes, en qué tipo de datos almacenarlos y por qué los tipos de los bancos comerciales son una historia aparte con sus propios agregadores.


1. De dónde salen los tipos de cambio

Conviene separar desde el principio dos tipos de cambio radicalmente distintos:

El tipo oficial o de referencia del banco central. Un único valor por fecha, sin compra/venta. Es el tipo «contable»: con él se calculan impuestos, aranceles, contabilidad y contratos. Es estable, se publica según un calendario (normalmente una vez al día) y casi todos los bancos centrales ofrecen una fuente gratuita y legible por máquina.

Los tipos de los bancos comerciales y las casas de cambio. Cada banco aplica su propio tipo de compra y de venta con su spread; cambia a lo largo del día, difiere del oficial y depende de la entidad, la ciudad, el importe y de si la operación es en efectivo o por transferencia. Aquí no hay una fuente única: de eso se ocupan los agregadores (sección 6).

Para la mayoría de las tareas (contabilidad, precios multidivisa, conversores) basta el tipo oficial de referencia. Si la tarea es «mostrar al usuario dónde comprar dólares más barato», hacen falta los tipos bancarios.


2. El Banco Central Europeo y otros bancos centrales

Casi todos los bancos centrales publican sus datos gratis y sin clave. Los formatos varían: en unos casos un JSON cuidado, en otros XML, a veces CSV.

Fuente Qué publica Acceso Formato
BCE — tipos de referencia del euro ~30 divisas frente al EUR, un valor por día hábil https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml, sin clave XML
BCE — histórico eurofxref-hist-90d.xml (últimos 90 días) y eurofxref-hist.xml (serie desde 1999) sin clave XML/CSV
NBP (Banco Nacional de Polonia) tablas A/B (tipo medio) y C (compra/venta) api.nbp.pl, sin clave JSON/XML
Banco de México tipo de cambio FIX y series históricas API SIE, con token gratuito JSON
Banco de Canadá series de tipos de cambio API Valet, sin clave JSON/CSV

Los endpoints se citan tal como estaban al escribir el artículo. Los bancos cambian su infraestructura de vez en cuando, así que antes de pasar a producción conviene contrastarlos con la página oficial para desarrolladores de cada institución.

A qué prestar atención en los datos de los bancos centrales:

  • El nominal (nominal / scale / quant). El tipo no siempre se publica por 1 unidad: muchas fuentes cotizan las divisas de bajo valor unitario por 10, 100 o 1000 unidades (es habitual verlo con el yen japonés o el forinto húngaro). Ignorar ese campo es el error clásico que devuelve un tipo inflado 100 veces.
  • La codificación. No todos los feeds llegan en UTF-8: todavía hay fuentes que publican XML o CSV en codificaciones heredadas (windows-1252, ISO-8859-1). Léalas «tal cual» y obtendrá caracteres corruptos en los nombres.
  • El separador decimal. Algunas fuentes europeas separan la parte decimal con coma (74,1234) en lugar de punto, sobre todo en las exportaciones CSV.
  • El calendario. El BCE publica sus tipos de referencia una vez por día hábil, hacia las 16:00 CET; en fines de semana y festivos no hay valor nuevo. Otros bancos centrales fijan el tipo la víspera o tras la sesión de mercado, cada uno con sus propias reglas.

3. APIs internacionales

Cuando se necesitan tipos cruzados, muchas divisas o una única fuente «todo frente a USD/EUR», resultan más cómodos los servicios internacionales.

Servicio Fuente de datos Clave Límite gratuito Notas
Frankfurter (api.frankfurter.dev) BCE no hace falta sin límites mensuales (solo protección antiabuso) ~30 divisas, histórico desde 1999, open source, se puede autoalojar
BCE directo (archivo eurofxref-daily.xml en ecb.europa.eu) BCE no hace falta XML de la fuente primaria, todo frente al EUR
NBP (Polonia, api.nbp.pl) Banco Nacional de Polonia no hace falta la tabla C incluye compra/venta
exchangerate-api.com mezcla de varios bancos centrales necesaria ~1500/mes 160+ divisas, punto medio promediado
Open Exchange Rates agregado necesaria 1000/mes en el plan gratuito solo base USD
Fixer.io / currencylayer (apilayer) BCE y otros necesaria ~100/mes
exchangerate.host (apilayer) agregado necesaria plan gratuito limitado conversión e histórico; requiere clave desde su paso a apilayer
currencyapi.com agregado necesaria ~300/mes incluye cripto
Twelve Data / Alpha Vantage cotizaciones de mercado necesaria límites forex intradía, no tipo «contable»

El matiz clave de los agregadores internacionales: sus tipos son un punto medio indicativo (midpoint, sin spread). Van muy bien para convertir precios de forma orientativa en e-commerce o para dashboards, pero no sirven para operar en el mercado de divisas real ni para calcular el importe exacto que un banco cargará en una conversión. Frankfurter y el BCE, además, solo publican tipos los días hábiles: una petición para el 1 de enero devuelve el tipo del último día hábil, y la respuesta incluye el campo date con la fecha real del tipo — guíese por ese campo y no por la fecha solicitada.


4. Cinco soluciones en distintos lenguajes

Para mostrar la variedad de fuentes y lenguajes, cada ejemplo apunta a una fuente o a un corte de datos distinto. En todos se insiste deliberadamente en dos cosas: respetar el nominal o la base de cotización y almacenar el valor sin float binario.

4.1. PHP — BCE (XML, namespaces, BCMath)

php
<?php
declare(strict_types=1);

/**
 * Devuelve los tipos de referencia del BCE frente al euro como cadenas.
 * Cadenas + BCMath: para no perder precisión con float.
 */
function fetchEcbRates(): array
{
    $raw = file_get_contents('https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml');
    if ($raw === false) {
        throw new RuntimeException('No se pudieron obtener los datos del BCE');
    }

    $xml = simplexml_load_string($raw);
    if ($xml === false) {
        throw new RuntimeException('Error al parsear el XML');
    }

    // El feed usa un espacio de nombres por defecto: hay que registrarlo para XPath
    $xml->registerXPathNamespace('e', 'http://www.ecb.int/vocabulary/2002-08-01/eurofxref');

    $rates = [];
    foreach ($xml->xpath('//e:Cube[@currency]') as $cube) {
        $code  = (string) $cube['currency'];   // USD, GBP, JPY...
        $value = (string) $cube['rate'];       // unidades de divisa por 1 EUR
        $rates[$code] = $value;                // guardamos la cadena tal cual
    }
    return $rates;
}

$rates = fetchEcbRates();
echo "EUR->USD: {$rates['USD']}\n";
// Tipo cruzado USD->JPY con aritmética de cadenas (sin float)
echo 'USD->JPY: ' . bcdiv($rates['JPY'], $rates['USD'], 6) . "\n";

Aquí lo ilustrativo es doble. Primero, el espacio de nombres: sin registerXPathNamespace, la consulta XPath devuelve un resultado vacío aunque los datos estén ahí. Segundo, bcdiv: el BCE lo cotiza todo frente al euro, así que cualquier tipo cruzado sale de una división, y hacerla con aritmética de cadenas evita pasar por float. Requiere la extensión bcmath.

4.2. Python — NBP (JSON, Decimal)

python
from decimal import Decimal, getcontext
import requests

getcontext().prec = 28  # margen de precisión de sobra


def fetch_nbp_rates() -> dict[str, Decimal]:
    """Tipos medios oficiales del Banco Nacional de Polonia (tipo Decimal)."""
    resp = requests.get(
        "https://api.nbp.pl/api/exchangerates/tables/A/",
        params={"format": "json"},
        timeout=10,
    )
    resp.raise_for_status()

    table = resp.json()[0]  # la tabla A llega como lista con un solo elemento
    rates: dict[str, Decimal] = {}
    for item in table["rates"]:
        code = item["code"]                      # USD, EUR, CHF...
        rates[code] = Decimal(str(item["mid"]))  # tipo medio de la tabla A
    return rates


if __name__ == "__main__":
    rates = fetch_nbp_rates()
    print(f"USD: {rates['USD']:.4f} PLN")
    print(f"EUR: {rates['EUR']:.4f} PLN")

El punto esencial es Decimal(str(value)), y no Decimal(value). Si el número ya llegó como float, envolverlo en str fija exactamente la representación decimal que venía en el JSON. La tabla C del mismo API, por cierto, publica tipos de compra y venta (bid/ask), útil cuando necesita el spread además del tipo medio.

4.3. JavaScript / Node.js — ExchangeRate-API (JSON, fetch nativo)

javascript
// Node 18+: fetch viene integrado
async function fetchOpenErApiRates() {
  const res = await fetch("https://open.er-api.com/v6/latest/EUR");
  if (!res.ok) throw new Error(`HTTP ${res.status}`);

  const data = await res.json(); // ojo: aquí los números JSON ya son double
  if (data.result !== "success") throw new Error("Respuesta inesperada del API");

  return {
    base: data.base_code,               // "EUR"
    updated: data.time_last_update_utc, // fecha real de la última actualización
    rates: data.rates,                  // { USD: ..., MXN: ..., ... }
  };
}

fetchOpenErApiRates().then(({ base, updated, rates }) => {
  console.log(`Base ${base}, actualizado: ${updated}`);
  console.log(`EUR->USD: ${rates.USD}`);
  console.log(`EUR->MXN: ${rates.MXN}`);
});

Es el endpoint abierto (sin clave, con atribución al proveedor) de exchangerate-api.com, el servicio que ya vimos en la tabla. JavaScript no tiene un tipo decimal nativo: number es un double IEEE 754, y en cuanto res.json() parsea la respuesta, los valores ya viven en ese formato. Para mostrar un tipo de cambio basta; para cálculos monetarios se usa una librería como decimal.js o big.js alimentada con cadenas y, si hace falta exactitud bit a bit, se conserva el cuerpo crudo de la respuesta (res.text()) y se extrae el literal de ahí.

4.4. Go — histórico del BCE (XML, tipado estricto)

go
package main

import (
    "encoding/xml"
    "fmt"
    "io"
    "net/http"
    "time"
)

// Estructuras para el XML del BCE: elemento Cube anidado en tres niveles
type Envelope struct {
    Days []Day `xml:"Cube>Cube"`
}

type Day struct {
    Date  string `xml:"time,attr"` // fecha del tipo: 2025-03-20
    Rates []Rate `xml:"Cube"`
}

type Rate struct {
    Currency string `xml:"currency,attr"` // USD, GBP...
    Value    string `xml:"rate,attr"`     // unidades por 1 EUR
}

func fetchEcbHistory() ([]Day, error) {
    url := "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist-90d.xml"

    client := &http.Client{Timeout: 10 * time.Second}
    resp, err := client.Get(url)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return nil, err
    }

    var env Envelope
    if err := xml.Unmarshal(body, &env); err != nil {
        return nil, err
    }
    return env.Days, nil
}

func main() {
    days, err := fetchEcbHistory()
    if err != nil {
        panic(err)
    }
    latest := days[0] // el feed llega ordenado del día más reciente al más antiguo
    fmt.Printf("Tipos del %s:\n", latest.Date)
    for _, r := range latest.Rates {
        if r.Currency == "USD" || r.Currency == "GBP" {
            // guardamos la cadena cruda; para la aritmética, shopspring/decimal
            fmt.Printf("EUR->%s: %s\n", r.Currency, r.Value)
        }
    }
}

Go no trae un tipo decimal en la biblioteca estándar, así que el tipo de cambio se conserva como cadena y para los cálculos se recurre a github.com/shopspring/decimal. El archivo de 90 días complementa al ejemplo de PHP con el corte «histórico»: con una sola petición se obtiene la serie reciente completa, ideal para poblar la base la primera vez. Tenga en cuenta que la serie no contiene filas de fines de semana ni festivos: al buscar «el tipo del día X» hay que estar preparado para retroceder hasta el día hábil anterior.

4.5. C# / .NET — Frankfurter (BCE, fuente internacional, decimal)

c#
using System.Net.Http.Json;
using System.Text.Json.Serialization;

public record FrankfurterResponse(
    [property: JsonPropertyName("base")] string Base,
    [property: JsonPropertyName("date")] string Date,
    [property: JsonPropertyName("rates")] Dictionary<string, decimal> Rates
);

public static class CurrencyClient
{
    private static readonly HttpClient Http = new();

    public static async Task<FrankfurterResponse> FetchEcbRatesAsync(string baseCcy = "EUR")
    {
        var url = $"https://api.frankfurter.dev/v1/latest?base={baseCcy}";
        return await Http.GetFromJsonAsync<FrankfurterResponse>(url)
               ?? throw new InvalidOperationException("Respuesta vacía de Frankfurter");
    }
}

class Program
{
    static async Task Main()
    {
        var data = await CurrencyClient.FetchEcbRatesAsync("USD");
        Console.WriteLine($"Fecha del tipo: {data.Date}"); // fecha real del BCE
        Console.WriteLine($"USD->EUR: {data.Rates["EUR"]}");
        Console.WriteLine($"USD->GBP: {data.Rates["GBP"]}");
    }
}

System.Text.Json deserializa los números JSON directamente a decimal (leyendo el literal de texto), así que la precisión no se pierde. En .NET, decimal es el tipo correcto tanto para los tipos de cambio como para el dinero.


5. En qué tipo de datos almacenar el tipo de cambio

Es, probablemente, la gran cuestión técnica del tema — y donde más se falla.

Por qué no float/double

Los números binarios de coma flotante (IEEE 754) no pueden representar con exactitud la mayoría de las fracciones decimales. 0.1 + 0.2 no es igual a 0.3. Con un solo tipo de cambio no se nota, pero al multiplicar por importes, reconvertir varias veces y agregar por periodos, los errores se acumulan — y en el informe financiero aparecen descuadres de céntimos inexplicables que no pasan la conciliación. Para dinero y tipos de cambio, float/double están prohibidos.

Qué usar

Nivel Elección correcta
Base de datos DECIMAL / NUMERIC con precision y scale fijos
Python decimal.Decimal
PHP BCMath / cadenas (o una librería Money)
Java / C# BigDecimal / decimal
Go github.com/shopspring/decimal
JavaScript decimal.js / big.js (almacenar como cadena)

Cuántos decimales

Los tipos suelen publicarse con 4--6 decimales, pero las divisas con cifras grandes por unidad (IDR, COP, CLP) producen partes enteras voluminosas. Un compromiso seguro para la base de datos es NUMERIC(20, 6); para el «tipo por 1 unidad» normalizado a veces se reserva más precisión, por ejemplo NUMERIC(24, 10), para que la división por el nominal no pierda dígitos.

Qué guardar además del propio número

Un tipo de cambio sin contexto no vale nada. El registro mínimo útil contiene la fuente, ambas divisas, el nominal, la fecha de vigencia y la clase de tipo:

sql
CREATE TABLE exchange_rates (
    id            BIGSERIAL PRIMARY KEY,
    source        VARCHAR(32)   NOT NULL,   -- 'ECB', 'NBP', 'BANXICO', 'FRANKFURTER'
    base_ccy      CHAR(3)       NOT NULL,   -- divisa en la que se expresa el tipo: EUR, PLN, MXN
    quote_ccy     CHAR(3)       NOT NULL,   -- divisa cotizada: USD, GBP...
    nominal       INTEGER       NOT NULL DEFAULT 1,
    rate          NUMERIC(20,6) NOT NULL,   -- tipo por `nominal` unidades (como en la fuente)
    rate_per_one  NUMERIC(24,10) NOT NULL,  -- tipo normalizado por 1 unidad
    rate_type     VARCHAR(8)    NOT NULL DEFAULT 'official', -- official | buy | sell
    effective_date DATE         NOT NULL,   -- fecha en la que rige el tipo
    fetched_at    TIMESTAMPTZ   NOT NULL DEFAULT now(),
    UNIQUE (source, base_ccy, quote_ccy, rate_type, effective_date)
);

Conviene guardar tanto el rate «crudo» (tal como lo entregó la fuente, con su nominal) como el rate_per_one normalizado: el primero sirve para conciliar con la fuente primaria; el segundo, para los cálculos. Los códigos de divisa, según el estándar ISO 4217 (tres letras): elimina de raíz el problema de las grafías dispares.

Un apunte aparte sobre los importes monetarios (no los tipos): a menudo se almacenan como enteros en unidades menores — céntimos, centavos. Es decir, 19.99 USD = 1999. Eso elimina la aritmética fraccionaria por completo. Pero los tipos de cambio no se guardan así: necesitan la fracción decimal.


6. Tipos bancarios y agregadores

El tipo oficial de referencia es uno solo. Pero quien va a cambiar divisas ve números muy distintos: cada banco tiene su tipo de compra y de venta, con spread, y cambia a lo largo del día. Reunir los tipos de decenas de entidades en un solo lugar es, precisamente, el trabajo de los agregadores.

Ejemplos de fuentes de este ecosistema:

  • Referencias de mercado: XE, Wise o el propio buscador de Google muestran el tipo medio de mercado — útil como referencia, no como precio de ventanilla.
  • Comparadores de envío de dinero: servicios como Monito comparan comisiones y tipos efectivos de varios proveedores de remesas.
  • Portales financieros locales: en mercados con varios tipos de cambio simultáneos, el seguimiento lo hacen los medios; el caso clásico es Argentina, donde portales como Ámbito o DolarHoy publican a diario el dólar oficial, el «blue» y los tipos financieros.
  • Webs de bancos y casas de cambio: el tipo de ventanilla de cada entidad se publica en su propia web y, cuando no hay API, se extrae del HTML con scraping.

Matiz técnico: muchos agregadores no ofrecen un API abierto y los datos hay que extraerlos del HTML. Aquí es crítico jugar limpio: respetar el robots.txt y las condiciones de uso, no acribillar el sitio a peticiones (rate limiting, caché) y, a ser posible, citar la fuente. Algunos servicios sí tienen API, pero de pago. Los datos de los bancos centrales, en cambio, suelen poder reutilizarse libremente.

Si construye su propio agregador, una arquitectura razonable es un conjunto de recolectores independientes (uno por fuente) → una capa de normalización (códigos a ISO 4217, tipo por unidad, separación de compra/venta) → un almacén único → su propio API por encima. En el modelo de datos, a diferencia de los tipos oficiales, aparecen dimensiones obligatorias: la entidad, el tipo de operación (compra/venta, efectivo/transferencia), a veces la ciudad y el importe, y casi siempre la hora exacta de captura, porque estos tipos viven minutos.


7. Consejos prácticos

  • Guarde en caché. Los bancos centrales actualizan el tipo una vez al día (rara vez dos). Golpear el API más a menudo de lo que cambian los datos es inútil y dañino: cachee en el lado de la aplicación.
  • Tenga en cuenta fines de semana y festivos. Los días no hábiles no se publica tipo nuevo: normalmente se devuelve el último. Mire siempre la fecha del tipo en la respuesta, no la solicitada.
  • Prevea un plan B (fallback). Cualquier fuente puede caerse. Conviene tener una de reserva (por ejemplo, un API internacional como Frankfurter u otra fuente oficial) y timeouts con reintentos de espera exponencial.
  • Normalice en la entrada. Recodificar los feeds heredados, cambiar la coma decimal por punto, dividir por el nominal, llevar los códigos a ISO 4217: todo eso es mejor hacerlo al cargar, y guardar en la base solo datos limpios.
  • Vigile las anomalías. Un salto brusco del tipo, de varias veces, suele ser señal de un error de la fuente o de la extracción (¡el nominal olvidado!) antes que de un acontecimiento real. La comprobación simple «desviación frente a ayer no mayor que N%» caza la mayoría de estos casos.

8. Dónde se aplica

  • E-commerce — precios multidivisa, tarifas localizadas para el comprador.
  • Contabilidad — conversión de operaciones al tipo oficial de la fecha, impuestos, aranceles.
  • Fintech, monederos, intercambio P2P — conversión y visualización de saldos.
  • Analítica y dashboards de BI — unificar ingresos multidivisa en una sola moneda.
  • Conversores y servicios de viajes — recálculo rápido para el usuario.
  • Contratos y facturación — fijación del tipo en la fecha de emisión de la factura.

En resumen

El scraping de tipos de cambio es una tarea en la que el 80% de la dificultad no está en la petición HTTP, sino en los detalles: el nominal, la codificación, el separador decimal, los fines de semana y — lo más importante — la elección de un tipo de datos decimal en lugar de float en todo el recorrido, del extractor a la base. Para los tipos oficiales casi siempre existe una fuente gratuita del propio banco central; para los bancarios hacen falta agregadores y un scraping cuidadoso y respetuoso con la fuente. Sume la caché y la normalización en la entrada, y obtendrá datos en los que se puede confiar para cálculos financieros.