Volver al blog

Cómo crear un conversor de divisas en Django: tutorial completo de la API de tipos de cambio (2026)

V
Vlado Grigirov
May 12, 2026
Django Python Currency API Exchange Rates Tutorial Finexly

La mayoría de las aplicaciones Django que manejan dinero acaban necesitando tipos de cambio: ya sea para facturar en tres monedas, liquidar pagos de Stripe en una moneda base o mostrar precios localizados en un checkout. La respuesta correcta es enchufar una API de tipos de cambio para Django y tratar las divisas como un problema resuelto. Las respuestas erróneas son codificar a fuego los tipos en settings.py o llamar al proveedor en cada petición.

Esta guía recorre una integración de nivel productivo de principio a fin. Construiremos un pequeño conversor de divisas en Django 5.x usando la API de Finexly, el framework de caché de Django para lecturas calientes, un modelo para persistencia, un management command para refresco programado, precisión con Decimal para no perder céntimos por errores de punto flotante, un endpoint REST con Django Ninja, una UI con htmx y pytest con responses para tests que nunca tocan la red. Al final tendrás una arquitectura de capa de servicio que puedes incorporar en cualquier proyecto Django: facturación SaaS, e-commerce, contabilidad — cualquier lugar donde el dinero cruce una frontera.

Por qué una API de divisas dedicada gana a tipos codificados

Codificar tipos a fuego en settings.py es la primera respuesta errónea. La segunda es llamar al proveedor en cada vista. Una API de divisas bien usada te da cuatro cosas que los valores codificados no:

  • Frescura bajo demanda. Los tipos cambian continuamente durante las horas de mercado. Incluso un tipo de 24 horas puede moverse un 1–2% en pares volátiles como USD/JPY o EUR/TRY — suficiente para borrar el margen de un plan SaaS.
  • Cobertura amplia. Finexly cubre más de 170 divisas, incluidas emergentes y tipos de referencia CBDC. El feed del Banco Central Europeo con el que vienen muchos paquetes Django abiertos cubre unas 32 mayores. Si un solo usuario paga en pesos argentinos o liras turcas, ese hueco importa.
  • Un contrato único. Una sola forma JSON entre los endpoints latest, historical y convert, en lugar de tres feeds distintos pegados con cinta adhesiva.
  • Cuota predecible. Un límite documentado en lugar de "el BCE bloqueó nuestra IP por sondear demasiado".

Si estás valorando opciones, nuestra comparativa de API de divisas gratis vs de pago para 2026 y el análisis ExchangeRate-API vs CurrencyLayer vs Finexly repasan las alternativas en detalle.

Qué vamos a construir

Un pequeño módulo Django opinado llamado fx que hace cinco cosas bien:

  1. Obtiene los tipos más recientes para cualquier divisa base desde Finexly con una clase de servicio tipada.
  2. Cachea lecturas calientes en la caché de Django (Redis en producción, locmem en desarrollo).
  3. Persiste cada fetch en un modelo ExchangeRate para que el checkout no caiga si el proveedor falla.
  4. Refresca los tipos en una agenda mediante un management command + cron (o Celery beat).
  5. Expone un endpoint de conversión JSON y un formulario HTML potenciado con htmx.

Enchúfalo como una app reutilizable en cualquier proyecto Django existente.

Requisitos previos

Necesitas Python 3.11+, Django 5.0+ y una clave de API de Finexly. Consigue una gratis en la página de registro de Finexly — el plan gratis te da 1.000 peticiones al mes, más que suficiente para un MVP si cacheas bien.

Paso 1: monta el proyecto Django

python -m venv .venv
source .venv/bin/activate
pip install "django>=5.0,<6.0" httpx python-decouple django-ninja
django-admin startproject site .
python manage.py startapp fx

Añade la nueva app a INSTALLED_APPS en site/settings.py:

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "fx",
]

Usamos httpx en lugar de requests porque nos da un único cliente con timeouts, reintentos vía httpx.HTTPTransport(retries=...) y un cliente async que podemos sustituir más adelante. Usamos python-decouple para variables de entorno y django-ninja para el endpoint REST — ambos son mínimos y se apartan del camino.

Paso 2: configura las variables de entorno

Crea un archivo .env en la raíz del proyecto:

DEBUG=True
SECRET_KEY=change-me-in-production
FINEXLY_API_KEY=your_finexly_api_key_here
FINEXLY_BASE_URL=https://finexly.com/api/v1
FX_CACHE_TTL=900
FX_DEFAULT_BASE=USD

Luego cablea esto en site/settings.py:

from decouple import config

SECRET_KEY = config("SECRET_KEY")
DEBUG = config("DEBUG", default=False, cast=bool)

FINEXLY_API_KEY = config("FINEXLY_API_KEY")
FINEXLY_BASE_URL = config("FINEXLY_BASE_URL", default="https://finexly.com/api/v1")
FX_CACHE_TTL = config("FX_CACHE_TTL", default=900, cast=int)
FX_DEFAULT_BASE = config("FX_DEFAULT_BASE", default="USD")

CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.redis.RedisCache",
        "LOCATION": config("REDIS_URL", default="redis://127.0.0.1:6379/1"),
    }
}

En desarrollo sin Redis disponible, cambia RedisCache por LocMemCache — al resto del código le da igual.

Un TTL de 15 minutos (900 segundos) es un valor por defecto sensato para la mayoría de apps de cara al usuario. Para trading de alta frecuencia querrás sub-segundo; para informes contables, un TTL de 6 horas vale. Nuestra guía de buenas prácticas de caché y manejo de errores explica cómo elegir el número correcto para tu caso.

Paso 3: el modelo ExchangeRate

La persistencia importa porque te da un plan B cuando el proveedor no responde, y una traza de auditoría cuando finanzas te pregunte "¿qué tipo usamos en aquella factura del 3 de abril?". Crea fx/models.py:

from decimal import Decimal
from django.db import models


class ExchangeRate(models.Model):
    base = models.CharField(max_length=3, db_index=True)
    quote = models.CharField(max_length=3, db_index=True)
    rate = models.DecimalField(max_digits=20, decimal_places=10)
    fetched_at = models.DateTimeField(auto_now_add=True, db_index=True)

    class Meta:
        indexes = [
            models.Index(fields=["base", "quote", "-fetched_at"]),
        ]
        constraints = [
            models.CheckConstraint(
                check=models.Q(rate__gt=Decimal("0")),
                name="rate_positive",
            ),
        ]

    def __str__(self) -> str:
        return f"{self.base}/{self.quote} = {self.rate} @ {self.fetched_at:%Y-%m-%d %H:%M}"

Dos decisiones de diseño que merecen comentario. Primero, DecimalField, no FloatField — el error de punto flotante se acumula a lo largo de miles de conversiones, y no quieres ser la persona que le explica a finanzas por qué los libros bailan 0,03$ por factura. Diez decimales es excesivo para mostrar pero estándar para almacenar FX. Segundo, el índice compuesto (base, quote, -fetched_at) convierte "dame el tipo USD/EUR más reciente" en una sola búsqueda B-tree en vez de un sort.

Ejecuta la migración:

python manage.py makemigrations fx
python manage.py migrate

Paso 4: la capa de servicio

La clase de servicio es donde vive toda la lógica que habla con el proveedor. Las vistas nunca llaman a httpx directamente. Crea fx/services.py:

import logging
from decimal import Decimal
from typing import Mapping

import httpx
from django.conf import settings
from django.core.cache import cache

logger = logging.getLogger(__name__)


class ExchangeRateError(Exception):
    """Upstream fetch failed and no usable fallback exists."""


class ExchangeRateService:
    def __init__(
        self,
        api_key: str | None = None,
        base_url: str | None = None,
        cache_ttl: int | None = None,
        default_base: str | None = None,
    ) -> None:
        self.api_key = api_key or settings.FINEXLY_API_KEY
        self.base_url = base_url or settings.FINEXLY_BASE_URL
        self.cache_ttl = cache_ttl or settings.FX_CACHE_TTL
        self.default_base = default_base or settings.FX_DEFAULT_BASE

    def latest(self, base: str | None = None) -> Mapping[str, Decimal]:
        base = (base or self.default_base).upper()
        cache_key = f"fx:latest:{base}"

        cached = cache.get(cache_key)
        if cached is not None:
            return cached

        rates = self._fetch_latest(base)
        cache.set(cache_key, rates, self.cache_ttl)
        self._persist(base, rates)
        return rates

    def convert(self, amount: Decimal, frm: str, to: str) -> Decimal:
        frm, to = frm.upper(), to.upper()
        if frm == to:
            return amount
        rates = self.latest(frm)
        if to not in rates:
            raise ExchangeRateError(f"Currency {to} not found in {frm} rates")
        return (amount * rates[to]).quantize(Decimal("0.01"))

    def _fetch_latest(self, base: str) -> dict[str, Decimal]:
        url = f"{self.base_url}/latest"
        headers = {"Authorization": f"Bearer {self.api_key}"}
        transport = httpx.HTTPTransport(retries=2)
        try:
            with httpx.Client(transport=transport, timeout=8.0) as client:
                response = client.get(url, params={"base": base}, headers=headers)
                response.raise_for_status()
        except httpx.HTTPError as exc:
            logger.warning("Finexly fetch failed for %s: %s", base, exc)
            fallback = self._fallback_from_db(base)
            if fallback is not None:
                return fallback
            raise ExchangeRateError(str(exc)) from exc

        payload = response.json()
        rates = payload.get("rates")
        if not isinstance(rates, dict):
            raise ExchangeRateError("Malformed response: missing 'rates'")
        return {quote: Decimal(str(rate)) for quote, rate in rates.items()}

    def _fallback_from_db(self, base: str) -> dict[str, Decimal] | None:
        from .models import ExchangeRate

        recent = (
            ExchangeRate.objects.filter(base=base)
            .order_by("quote", "-fetched_at")
            .distinct("quote")
        )
        rates = {row.quote: row.rate for row in recent}
        return rates or None

    def _persist(self, base: str, rates: Mapping[str, Decimal]) -> None:
        from .models import ExchangeRate

        ExchangeRate.objects.bulk_create(
            [ExchangeRate(base=base, quote=q, rate=r) for q, r in rates.items()],
            batch_size=200,
        )

Hay mucho que desempacar. El método latest() es la única vía pública de lectura: prueba caché, recurre al proveedor, persiste de vuelta. convert() maneja el caso borde de misma divisa (no multipliques por 1 sin necesidad y arriesgues introducir redondeo) y cuantiza el resultado a dos decimales — esa es una decisión de presentación; para almacenamiento, conserva más precisión. _fallback_from_db es la red de seguridad de producción: si Finexly devuelve un 503, servimos el tipo más reciente por divisa desde nuestra propia base de datos. El checkout no cae por un fallo de un tercero.

El retries=2 en httpx.HTTPTransport atrapa la gran mayoría de 502 y 503 transitorios automáticamente. El timeout de 8 segundos es conservador — menos de 5 segundos es demasiado ajustado si cruzas continentes, y más de 10 significa que un proveedor flojo destroza tu presupuesto de latencia.

Paso 5: una vista de conversión

Ahora las vistas. Crea fx/views.py:

from decimal import Decimal, InvalidOperation

from django.http import HttpRequest, JsonResponse
from django.shortcuts import render
from django.views.decorators.http import require_http_methods

from .services import ExchangeRateError, ExchangeRateService


@require_http_methods(["GET", "POST"])
def convert_view(request: HttpRequest):
    context: dict = {"result": None}
    if request.method == "POST":
        service = ExchangeRateService()
        try:
            amount = Decimal(request.POST.get("amount", "0"))
            frm = request.POST.get("from", "USD")
            to = request.POST.get("to", "EUR")
            converted = service.convert(amount, frm, to)
            context.update({"result": converted, "from": frm, "to": to, "amount": amount})
        except (InvalidOperation, ExchangeRateError) as exc:
            context["error"] = str(exc)

    template = "fx/_result.html" if request.headers.get("HX-Request") else "fx/convert.html"
    return render(request, template, context)

La comprobación del header HX-Request es el patrón htmx: misma vista, dos plantillas. Un POST normal devuelve la página completa; un POST disparado por htmx devuelve solo el fragmento de resultado, que htmx inserta en el DOM. El usuario ve el importe convertido aparecer en su sitio con cero JavaScript escrito por ti.

Paso 6: plantillas htmx

Crea fx/templates/fx/convert.html:

{% load static %}
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Currency Converter</title>
  <script src="https://unpkg.com/htmx.org@1.9.10"></script>
  <style>
    body { font-family: system-ui, sans-serif; max-width: 480px; margin: 4rem auto; }
    input, select, button { padding: 0.5rem; font-size: 1rem; }
    .result { margin-top: 1rem; font-size: 1.4rem; }
  </style>
</head>
<body>
  <h1>Currency Converter</h1>
  <form hx-post="{% url 'fx:convert' %}" hx-target="#result" hx-swap="innerHTML">
    {% csrf_token %}
    <input type="number" name="amount" step="0.01" required placeholder="Amount">
    <input type="text" name="from" maxlength="3" required placeholder="USD" value="USD">
    <input type="text" name="to" maxlength="3" required placeholder="EUR" value="EUR">
    <button type="submit">Convert</button>
  </form>
  <div id="result">{% include "fx/_result.html" %}</div>
</body>
</html>

Y el parcial fx/templates/fx/_result.html:

{% if error %}
  <p class="error">{{ error }}</p>
{% elif result %}
  <p class="result">{{ amount }} {{ from }} = <strong>{{ result }} {{ to }}</strong></p>
{% endif %}

Cablea en fx/urls.py:

from django.urls import path
from . import views

app_name = "fx"
urlpatterns = [
    path("convert/", views.convert_view, name="convert"),
]

Y en site/urls.py:

from django.urls import include, path

urlpatterns = [
    path("", include("fx.urls")),
]

Paso 7: un endpoint REST con Django Ninja

Para los consumidores JSON — tu app móvil, tu worker en background, tu SPA frontend — expón un endpoint tipado. Crea fx/api.py:

from decimal import Decimal

from ninja import NinjaAPI, Schema
from ninja.errors import HttpError

from .services import ExchangeRateError, ExchangeRateService

api = NinjaAPI(title="FX API")


class ConvertOut(Schema):
    amount: Decimal
    from_: str
    to: str
    rate: Decimal
    result: Decimal


@api.get("/convert", response=ConvertOut)
def convert(request, amount: Decimal, from_: str, to: str):
    service = ExchangeRateService()
    try:
        rates = service.latest(from_)
        rate = rates[to.upper()]
        result = service.convert(amount, from_, to)
    except (ExchangeRateError, KeyError) as exc:
        raise HttpError(502, f"FX unavailable: {exc}") from exc
    return ConvertOut(amount=amount, from_=from_.upper(), to=to.upper(), rate=rate, result=result)

Añádelo a site/urls.py:

from fx.api import api

urlpatterns = [
    path("", include("fx.urls")),
    path("api/", api.urls),
]

Ahora GET /api/convert?amount=100&from_=USD&to=EUR devuelve un payload JSON con tipos fuertes. Django Ninja genera un esquema OpenAPI gratis en /api/docs — útil cuando tu equipo móvil empiece a preguntar.

Paso 8: refresco programado vía management command

Para la mayoría de apps, el patrón inteligente es un refresco periódico en background que calienta la caché antes de que llegue el tráfico. Crea fx/management/commands/refresh_rates.py:

from django.core.management.base import BaseCommand

from fx.services import ExchangeRateError, ExchangeRateService


class Command(BaseCommand):
    help = "Refresh exchange rates from Finexly for the configured base currencies."

    def add_arguments(self, parser):
        parser.add_argument("--bases", nargs="+", default=["USD", "EUR", "GBP"])

    def handle(self, *args, **options):
        service = ExchangeRateService()
        for base in options["bases"]:
            try:
                rates = service.latest(base)
                self.stdout.write(self.style.SUCCESS(
                    f"{base}: refreshed {len(rates)} rates"
                ))
            except ExchangeRateError as exc:
                self.stderr.write(self.style.ERROR(f"{base}: {exc}"))

Necesitarás un fx/management/__init__.py y un fx/management/commands/__init__.py vacíos para que Django encuentre el comando.

Prográmalo con cron (o Celery beat si ya usas Celery):

*/15 * * * * cd /app && /app/.venv/bin/python manage.py refresh_rates --bases USD EUR GBP JPY

Cada 15 minutos se refrescan cuatro bases. Cada refresco es una sola llamada al proveedor — 4 llamadas × 96 (intervalos al día) = 384 llamadas/día, holgadamente dentro del plan gratis de 1.000/mes si bajas a refresco horario. Consulta la página de planes de precios para las cuotas de planes superiores si necesitas frescura inferior a 15 minutos.

Paso 9: tests que nunca tocan la red

Todo el sentido de la capa de servicio es que puedas mockearla. Instala responses y pytest-django:

pip install pytest pytest-django responses

Crea pytest.ini:

[pytest]
DJANGO_SETTINGS_MODULE = site.settings
python_files = tests.py test_*.py *_tests.py

Luego fx/tests/test_services.py:

from decimal import Decimal

import pytest
import responses
from django.core.cache import cache

from fx.services import ExchangeRateError, ExchangeRateService


@pytest.fixture(autouse=True)
def _clear_cache():
    cache.clear()
    yield
    cache.clear()


@responses.activate
def test_latest_returns_rates_from_upstream(settings):
    responses.add(
        responses.GET,
        f"{settings.FINEXLY_BASE_URL}/latest",
        json={"base": "USD", "rates": {"EUR": 0.92, "GBP": 0.79}},
        status=200,
    )
    service = ExchangeRateService()
    rates = service.latest("USD")
    assert rates["EUR"] == Decimal("0.92")
    assert rates["GBP"] == Decimal("0.79")


@responses.activate
def test_convert_quantizes_to_cents(settings):
    responses.add(
        responses.GET,
        f"{settings.FINEXLY_BASE_URL}/latest",
        json={"base": "USD", "rates": {"EUR": Decimal("0.9234")}},
        status=200,
    )
    service = ExchangeRateService()
    result = service.convert(Decimal("100"), "USD", "EUR")
    assert result == Decimal("92.34")


@responses.activate
def test_upstream_failure_raises_when_no_fallback(settings):
    responses.add(
        responses.GET,
        f"{settings.FINEXLY_BASE_URL}/latest",
        status=503,
    )
    service = ExchangeRateService()
    with pytest.raises(ExchangeRateError):
        service.latest("USD")


def test_same_currency_returns_input():
    service = ExchangeRateService()
    assert service.convert(Decimal("100"), "USD", "USD") == Decimal("100")

Lánzalos:

pytest -q

Cuatro tests, cero llamadas de red, ejecución sub-segundo en total. De eso va todo.

Notas para producción

Unas cuantas cosas que muerden a las apps Django en producción cuando FX está vivo:

  • La persistencia en BD es tu plan B. Si te saltas el paso _persist, una caída del proveedor tumba el checkout. No te lo saltes.
  • Estampida de caché. Si 100 peticiones golpean una caché fría simultáneamente, dispararás 100 llamadas al proveedor. Usa el get_or_set de django-redis con lock, o acepta el trade-off si tu tráfico es bajo.
  • Deriva de divisas en fin de semana. Los mercados FX cierran. La mayoría de APIs (Finexly incluida) sirven el cierre del viernes durante el fin de semana; tus tipos no se moverán hasta la apertura del lunes. Si liquidas transacciones de fin de semana, documenta la política.
  • Traza de auditoría. La tabla ExchangeRate crece rápido. Añade un índice partial sobre los últimos 7 días, o consolida filas más antiguas en un resumen mensual si compliance lo permite.

Si necesitas profundizar más en estos patrones, nuestra guía de caché y manejo de errores en APIs de divisas cubre protección contra estampidas, circuit breakers y degradación elegante en detalle.

Preguntas frecuentes

¿Qué API de tipos de cambio funciona mejor con Django? Cualquier API REST funciona, pero la ergonomía cambia. Finexly devuelve una forma plana {rates: {...}} que mapea limpiamente a un dict de Python, soporta más de 170 divisas y trae un plan gratis de 1.000 llamadas/mes — suficiente para un MVP. Alternativas como Fixer y Open Exchange Rates tienen estructuras de cuota distintas; nuestro post comparativo repasa los compromisos.

¿Debo usar Decimal o Float para tipos de cambio en Django? Siempre DecimalField para almacenamiento y Decimal en la lógica de negocio. Float introduce errores de redondeo que se acumulan en miles de conversiones; en código financiero esos errores acaban siendo bugs visibles para el usuario. El coste de rendimiento es despreciable.

¿Con qué frecuencia debo refrescar los tipos? Depende de tu caso. Para apps de cara al usuario mostrando precios localizados, cada 15 minutos es de sobra. Para informes contables, por hora vale. Para trading o cobertura en tiempo real querrás feeds WebSocket sub-segundo — mira nuestro post REST vs WebSocket para ese patrón.

¿Puedo usar esto con Django REST Framework en lugar de Django Ninja? Sí. La capa de servicio es agnóstica al framework. Sustituye fx/api.py por un APIView de DRF que llame a ExchangeRateService().convert(...) y serialice el resultado. El modelo, el servicio, el management command y los tests siguen idénticos.

¿Cómo manejo divisas que no aparecen en la respuesta de la API? Dos patrones. O lanzas un ExchangeRateError explícito y lo sacas al usuario (UX más limpia), o recurres a una cross-rate vía USD (más permisivo pero más difícil de depurar). Para la mayoría de apps, lanzar pronto es la opción correcta.

Prueba Finexly gratis

¿Listo para integrar tipos de cambio en tiempo real en tu proyecto Django? Consigue tu clave de API de Finexly gratis — sin tarjeta de crédito. Empieza con 1.000 peticiones gratis al mes, tipos en tiempo real para más de 170 divisas y un contrato JSON que mapea limpiamente a Python. Escala según crezca tu app en nuestros planes de precios cuando lo necesites.

Vlado Grigirov

Senior Currency Markets Analyst & Financial Strategist

Vlado Grigirov is a senior currency markets analyst and financial strategist with over 14 years of experience in foreign exchange markets, cross-border finance, and currency risk management. He has wo...

View full profile →