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,historicaletconvert, 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 :
- Récupère les derniers taux pour n'importe quelle devise de base depuis Finexly via une classe de service typée.
- Cache les lectures chaudes dans le cache Django (Redis en prod, locmem en dev).
- Persiste chaque fetch dans un modèle
ExchangeRatepour que le checkout ne tombe pas si le fournisseur hoquette. - Rafraîchit les taux selon un planning via management command + cron (ou Celery beat).
- 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 fxAjoutez 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=USDPuis 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 JPYToutes 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 responsesCréez pytest.ini :
[pytest]
DJANGO_SETTINGS_MODULE = site.settings
python_files = tests.py test_*.py *_tests.pyPuis 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 -qQuatre 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_setdedjango-redisavec 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
ExchangeRategrossit vite. Ajoutez un indexpartialsur 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.
Explore More
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 →