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 | sí | XML de la fuente primaria, todo frente al EUR |
NBP (Polonia, api.nbp.pl) |
Banco Nacional de Polonia | no hace falta | sí | 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
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)
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)
// 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)
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)
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:
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.