Retour au blog

Comment créer un convertisseur de devises avec Django : tutoriel complet d'API de taux de change (2026)

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

La plupart des applications Django qui touchent à l'argent ont, tôt ou tard, besoin de taux de change — qu'il s'agisse de facturer en trois devises, de reconvertir des paiements Stripe vers une devise de référence, ou d'afficher des prix localisés à la caisse. La bonne réponse est de brancher une API de taux de change pour Django et de traiter le FX comme un problème résolu. Les mauvaises réponses : coder en dur les taux dans settings.py ou appeler le fournisseur à chaque requête.

Ce guide parcourt une intégration prête pour la production de bout en bout. Nous allons construire un petit convertisseur de devises en Django 5.x avec l'API Finexly, le framework de cache de Django pour les lectures chaudes, un modèle pour la persistance, une management command pour le rafraîchissement planifié, la précision Decimal pour ne pas perdre des centimes à cause du flottant, un endpoint REST via Django Ninja, une UI htmx, et pytest avec responses pour des tests qui ne touchent jamais le réseau. À la fin vous aurez une architecture de service layer à brancher dans n'importe quel projet Django — facturation SaaS, e-commerce, comptabilité, partout où l'argent traverse une frontière.

Pourquoi une API de devises dédiée bat les taux codés en dur

Coder les taux en dur dans settings.py est la première mauvaise réponse. La seconde est d'appeler le fournisseur à chaque vue. Une API de devises correctement utilisée vous donne quatre choses que des valeurs codées en dur ne peuvent pas :

  • Fraîcheur à la demande. Les taux varient en continu pendant les heures de marché. Même un taux vieux de 24 h peut bouger de 1 à 2 % sur des paires volatiles comme USD/JPY ou EUR/TRY — assez pour effacer la marge d'un plan SaaS.
  • Couverture large. Finexly couvre plus de 170 devises, y compris les marchés émergents et les taux de référence CBDC. Le flux de la BCE livré avec beaucoup de paquets Django open source en couvre une trentaine. Si un seul utilisateur paie en peso argentin ou en livre turque, cet écart compte.
  • Un contrat unique. Une seule forme JSON pour les endpoints latest, historical et convert, plutôt que trois flux différents scotchés ensemble.
  • Un quota prévisible. Une limite documentée que l'on peut raisonner, plutôt que « la BCE a bloqué notre IP parce qu'on l'a trop sondée ».

Si vous pesez encore les options, notre comparatif API de devises gratuites vs payantes pour 2026 et notre analyse ExchangeRate-API vs CurrencyLayer vs Finexly détaillent les alternatives.

Ce qu'on va construire

Un petit module Django assumé, appelé fx, qui fait cinq choses bien :

  1. Récupère les derniers taux pour n'importe quelle devise de base depuis Finexly via une classe de service typée.
  2. Cache les lectures chaudes dans le cache Django (Redis en prod, locmem en dev).
  3. Persiste chaque fetch dans un modèle ExchangeRate pour que le checkout ne tombe pas si le fournisseur hoquette.
  4. Rafraîchit les taux selon un planning via management command + cron (ou Celery beat).
  5. Expose un endpoint JSON de conversion et un formulaire HTML piloté par htmx.

À brancher comme app réutilisable dans n'importe quel projet Django existant.

Prérequis

Il vous faut Python 3.11+, Django 5.0+ et une clé d'API Finexly. Récupérez-en une gratuite sur la page d'inscription Finexly — le palier gratuit donne 1 000 requêtes par mois, largement assez pour un MVP si vous cachez correctement.

Étape 1 : monter le projet 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

Ajoutez la nouvelle app à INSTALLED_APPS dans site/settings.py :

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

On utilise httpx plutôt que requests parce qu'il fournit un client unique avec timeouts, retries via httpx.HTTPTransport(retries=...) et un client async qu'on peut substituer plus tard. On utilise python-decouple pour les variables d'environnement et django-ninja pour l'endpoint REST — les deux sont minimalistes et n'encombrent pas.

Étape 2 : configurer les variables d'environnement

Créez un fichier .env à la racine du projet :

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

Puis branchez-les dans 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 dev sans Redis sous la main, remplacez RedisCache par LocMemCache — le reste du code s'en moque.

Un TTL de 15 minutes (900 secondes) est un défaut sensé pour la majorité des apps grand public. Pour du trading haute fréquence il faut du sub-seconde ; pour des rapports comptables, 6 heures suffisent. Notre billet sur les bonnes pratiques de cache et de gestion d'erreurs explique comment choisir le bon chiffre pour votre cas.

Étape 3 : le modèle ExchangeRate

La persistance compte : elle vous donne un repli quand le fournisseur est injoignable et une piste d'audit quand la finance demande « quel taux a-t-on utilisé sur la facture du 3 avril ? ». Créez 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}"

Deux choix de conception méritent un commentaire. D'abord, DecimalField, pas FloatField — l'erreur en virgule flottante se compose sur des milliers de conversions, et vous ne voulez pas être l'ingénieur qui explique à la finance pourquoi les livres dérivent de 0,03 $ par facture. Dix décimales c'est excessif pour l'affichage, standard pour le stockage FX. Ensuite, l'index composite (base, quote, -fetched_at) transforme « donne-moi le dernier taux USD/EUR » en un seul seek B-tree au lieu d'un tri.

Lancez la migration :

python manage.py makemigrations fx
python manage.py migrate

Étape 4 : la couche de service

La classe de service est l'endroit où vit toute la logique qui parle au fournisseur. Les vues n'appellent jamais httpx directement. Créez 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,
        )

Il se passe beaucoup de choses ici, décortiquons. latest() est le seul chemin de lecture public : essayer le cache, retomber sur le fournisseur, persister au retour. convert() gère le cas limite même devise (ne multipliez pas par 1 inutilement et ne risquez pas d'introduire de l'arrondi) et quantize le résultat à deux décimales — c'est un choix d'affichage ; pour le stockage, gardez plus de précision. _fallback_from_db est le filet de sécurité en prod : si Finexly renvoie 503, on sert le dernier taux par quote depuis notre propre base. Le checkout ne tombe pas à cause d'un hoquet tiers.

retries=2 sur httpx.HTTPTransport rattrape la grande majorité des 502/503 transitoires automatiquement. Le timeout de 8 secondes est conservateur — en dessous de 5 s c'est trop juste si vous traversez des continents, au-dessus de 10 s un fournisseur instable ruine votre budget de latence.

Étape 5 : une vue de conversion

Les vues maintenant. Créez 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)

Le contrôle du header HX-Request est le motif htmx : même vue, deux templates. Un POST normal renvoie la page entière ; un POST piloté par htmx renvoie juste le fragment de résultat, que htmx injecte dans le DOM. L'utilisateur voit le montant converti apparaître sur place avec zéro JavaScript écrit par vous.

Étape 6 : les templates htmx

Créez 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>

Et le partial 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 %}

Câblez dans fx/urls.py :

from django.urls import path
from . import views

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

Et dans site/urls.py :

from django.urls import include, path

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

Étape 7 : un endpoint REST avec Django Ninja

Pour les consommateurs JSON — votre appli mobile, votre worker d'arrière-plan, votre SPA front — exposez un endpoint typé. Créez 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)

Ajoutez-le à site/urls.py :

from fx.api import api

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

Maintenant GET /api/convert?amount=100&from_=USD&to=EUR renvoie un payload JSON fortement typé. Django Ninja génère un schéma OpenAPI gratos sur /api/docs — pratique quand l'équipe mobile commence à poser des questions.

Étape 8 : rafraîchissement planifié via management command

Pour la majorité des apps, le bon motif est un rafraîchissement périodique en arrière-plan qui préchauffe le cache avant que les utilisateurs n'arrivent. Créez 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}"))

Il faut aussi un fx/management/__init__.py et un fx/management/commands/__init__.py vides pour que Django trouve la commande.

Planifiez avec cron (ou Celery beat si vous avez déjà Celery) :

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

Toutes les 15 minutes, quatre bases sont rafraîchies. Chaque rafraîchissement = un appel au fournisseur — 4 appels × 96 (intervalles par jour) = 384 appels/jour, largement dans le palier gratuit de 1 000/mois de Finexly si vous descendez à l'heure. Voir la page tarifs pour les quotas des paliers supérieurs si vous avez besoin de fraîcheur inférieure à 15 minutes.

Étape 9 : des tests qui ne touchent jamais le réseau

Tout l'intérêt de la couche de service est de pouvoir la mocker. Installez responses et pytest-django :

pip install pytest pytest-django responses

Créez pytest.ini :

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

Puis 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")

Lancez :

pytest -q

Quatre tests, zéro appel réseau, temps total sub-seconde. C'est tout l'enjeu.

Notes de production

Quelques pièges qui mordent les apps Django en prod une fois FX en place :

  • La persistance en base est votre filet. Si vous sautez _persist, une panne fournisseur fait tomber le checkout. Ne sautez pas.
  • Cache stampede. Si 100 requêtes tapent un cache froid simultanément, vous tirez 100 appels au fournisseur. Utilisez le get_or_set de django-redis avec lock, ou acceptez le compromis si votre trafic est faible.
  • Dérive des devises le week-end. Les marchés FX ferment. La plupart des API (Finexly comprise) servent la clôture du vendredi tout le week-end ; vos taux ne bougent qu'à l'ouverture lundi. Si vous réglez des transactions le week-end, documentez la politique.
  • Piste d'audit. La table ExchangeRate grossit vite. Ajoutez un index partial sur les 7 derniers jours, ou rolllez les lignes anciennes en un résumé mensuel si la compliance le permet.

Pour aller plus loin sur ces motifs, notre guide cache et gestion d'erreurs API de devises couvre la protection contre les stampedes, les circuit breakers et la dégradation gracieuse en détail.

Questions fréquentes

Quelle API de taux de change fonctionne le mieux avec Django ? N'importe quelle API REST marche, mais l'ergonomie diffère. Finexly renvoie une forme plate {rates: {...}} qui se mappe proprement à un dict Python, supporte plus de 170 devises et offre un palier gratuit de 1 000 appels/mois — assez pour un MVP. Des alternatives comme Fixer et Open Exchange Rates ont des structures de quota différentes ; notre comparatif parcourt les compromis.

Decimal ou Float pour les taux de change dans Django ? Toujours DecimalField pour le stockage et Decimal dans la logique métier. Float introduit des erreurs d'arrondi qui se composent sur des milliers de conversions ; en code financier ces erreurs finissent par devenir des bugs visibles par l'utilisateur. Le coût en perf est négligeable.

À quelle fréquence rafraîchir les taux ? Ça dépend de votre cas. Pour des apps grand public affichant des prix localisés, toutes les 15 minutes suffisent. Pour des rapports comptables, à l'heure ça va. Pour du trading ou du hedging en temps réel, il faut des flux WebSocket sub-seconde — voir notre billet REST vs WebSocket pour ce motif.

Puis-je utiliser ça avec Django REST Framework au lieu de Django Ninja ? Oui. La couche de service est agnostique au framework. Remplacez fx/api.py par une APIView DRF qui appelle ExchangeRateService().convert(...) et sérialise le résultat. Le modèle, le service, la management command et les tests restent identiques.

Comment gérer les devises absentes de la réponse de l'API ? Deux motifs. Soit lever un ExchangeRateError explicite et l'afficher à l'utilisateur (UX plus propre), soit retomber sur un cross-rate via USD (plus permissif mais plus dur à déboguer). Pour la plupart des apps, échouer tôt est le bon choix.

Essayez Finexly gratuitement

Prêt à intégrer des taux de change temps réel dans votre projet Django ? Récupérez votre clé d'API Finexly gratuite — pas de carte bancaire. Commencez avec 1 000 requêtes gratuites par mois, des taux temps réel pour 170+ devises et un contrat JSON qui se mappe proprement à Python. Montez en charge sur nos tarifs quand vous en aurez besoin.

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 →

Partager cet article