Torna al Blog

Come costruire un convertitore di valute in Django: tutorial completo dell'API tassi di cambio (2026)

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

La maggior parte delle app Django che toccano denaro prima o poi avrà bisogno di tassi di cambio — che si tratti di fatturare in tre valute, riconvertire payout di Stripe in una valuta base, o renderizzare prezzi localizzati al checkout. La risposta giusta è collegare un'API tassi di cambio per Django e trattare FX come un problema risolto. Le risposte sbagliate: hard-codare i tassi in settings.py oppure chiamare l'upstream a ogni richiesta.

Questa guida attraversa un'integrazione production-grade dall'inizio alla fine. Costruiremo un piccolo convertitore di valute in Django 5.x usando l'API Finexly, il framework di cache di Django per le letture calde, un model per la persistenza, un management command per il refresh schedulato, precisione Decimal per non perdere centesimi nel floating-point, un endpoint REST via Django Ninja, una UI htmx, e pytest con responses per test che non toccano mai la rete. Alla fine avrai un'architettura a service class da incastrare in qualsiasi progetto Django — billing SaaS, e-commerce, contabilità, ovunque il denaro varchi un confine.

Perché un'API di valute dedicata batte i tassi hard-coded

Hard-codare i tassi in settings.py è la prima risposta sbagliata. La seconda è chiamare l'upstream a ogni vista. Un'API di valute usata bene ti dà quattro cose che i valori hard-coded non possono:

  • Freschezza on demand. I tassi si muovono di continuo durante l'orario di mercato. Anche un tasso vecchio di 24 ore può muoversi dell'1–2% su coppie volatili come USD/JPY o EUR/TRY — abbastanza da cancellare il margine di un piano SaaS.
  • Copertura ampia. Finexly copre 170+ valute, incluse emergenti e tassi di riferimento CBDC. Il feed BCE fornito con tanti pacchetti Django open ne copre circa 32 majors. Se un solo utente paga in peso argentino o lira turca, quel gap conta.
  • Un solo contratto. Una sola forma JSON tra gli endpoint latest, historical e convert, invece di tre feed upstream incollati con lo scotch.
  • Quota prevedibile. Un limite documentato su cui ragionare, invece di "la BCE ha bannato il nostro IP per polling eccessivo".

Se stai ancora valutando, il nostro confronto API valute gratis vs a pagamento per il 2026 e l'analisi ExchangeRate-API vs CurrencyLayer vs Finexly passano in rassegna le alternative nel dettaglio.

Cosa costruiamo

Un piccolo modulo Django con opinioni forti chiamato fx che fa cinque cose bene:

  1. Scarica i tassi più recenti per una qualsiasi valuta base da Finexly via una service class tipizzata.
  2. Memorizza in cache le letture calde nella cache di Django (Redis in produzione, locmem in dev).
  3. Persiste ogni fetch in un model ExchangeRate così il checkout non cade se l'upstream singhiozza.
  4. Aggiorna i tassi a calendario via management command + cron (o Celery beat).
  5. Espone un endpoint JSON di conversione e una form HTML pilotata da htmx.

Collegalo come app riutilizzabile in qualsiasi progetto Django esistente.

Prerequisiti

Servono Python 3.11+, Django 5.0+ e una chiave API Finexly. Prendine una gratis dalla pagina di registrazione Finexly — il tier free dà 1.000 richieste al mese, più che sufficiente per un MVP se cachiamo correttamente.

Step 1: monta il progetto 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

Aggiungi la nuova app in INSTALLED_APPS in site/settings.py:

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

Usiamo httpx invece di requests perché ci dà un client unico con timeout, retry via httpx.HTTPTransport(retries=...) e un client async che si può sostituire più avanti. Usiamo python-decouple per le variabili d'ambiente e django-ninja per l'endpoint REST — entrambi minimi e non invasivi.

Step 2: configura le variabili d'ambiente

Crea un file .env nella root del progetto:

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

Poi collegalo a 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"),
    }
}

In dev senza Redis, sostituisci RedisCache con LocMemCache — al resto del codice non importa.

Un TTL di 15 minuti (900 secondi) è un default sensato per la maggior parte delle app consumer. Per trading high-frequency vuoi sub-secondo; per report contabili va bene anche un TTL di 6 ore. Il nostro post best practice di cache ed error handling discute come scegliere il numero giusto per il tuo caso.

Step 3: il model ExchangeRate

La persistenza conta perché ti dà un fallback quando l'upstream è irraggiungibile e una traccia d'audit quando il finance chiede "che tasso abbiamo usato in quella fattura del 3 aprile?". 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}"

Due scelte di design da sottolineare. Primo, DecimalField, non FloatField — l'errore in virgola mobile si accumula su migliaia di conversioni e non vuoi essere l'ingegnere che spiega al finance perché i libri sballano di 0,03 $ a fattura. Dieci decimali sono troppi per la visualizzazione ma standard per lo storage FX. Secondo, l'indice composto (base, quote, -fetched_at) trasforma "dammi l'ultimo tasso USD/EUR" in un singolo seek B-tree invece di un sort.

Esegui la migration:

python manage.py makemigrations fx
python manage.py migrate

Step 4: il livello di servizio

La service class è dove vive tutta la logica che parla con l'upstream. Le view non chiamano mai httpx direttamente. 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,
        )

C'è molto da spacchettare. latest() è l'unico percorso pubblico di lettura: prova la cache, ripiega sull'upstream, persiste sulla via del ritorno. convert() gestisce il caso limite stessa valuta (non moltiplicare per 1 senza motivo rischiando arrotondamento) e quantizza il risultato a due decimali — scelta di presentazione; per lo storage tieni più precisione. _fallback_from_db è la rete di sicurezza in produzione: se Finexly torna 503, serviamo dal nostro DB l'ultimo tasso per quote. Il checkout non cade per un singhiozzo di terze parti.

Il retries=2 su httpx.HTTPTransport cattura la grande maggioranza dei 502/503 transitori in automatico. Timeout di 8 secondi è conservativo — sotto i 5s è troppo stretto se attraversi continenti, sopra i 10s un upstream traballante distrugge il tuo budget di latenza.

Step 5: una view di conversione

Ora le view. 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)

Il controllo dell'header HX-Request è il pattern htmx: stessa view, due template. Un POST normale restituisce l'intera pagina; un POST pilotato da htmx restituisce solo il frammento di risultato, che htmx incolla nel DOM. L'utente vede l'importo convertito comparire sul posto, con zero JavaScript scritto da te.

Step 6: template 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>

E il parziale 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 %}

Collega in fx/urls.py:

from django.urls import path
from . import views

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

E in site/urls.py:

from django.urls import include, path

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

Step 7: un endpoint REST con Django Ninja

Per i consumatori JSON — la tua app mobile, il worker in background, la SPA frontend — esponi un endpoint tipizzato. 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)

Aggiungilo a site/urls.py:

from fx.api import api

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

Ora GET /api/convert?amount=100&from_=USD&to=EUR restituisce un payload JSON fortemente tipizzato. Django Ninja genera uno schema OpenAPI gratis su /api/docs — utile quando il team mobile inizia a chiedere.

Step 8: refresh schedulato via management command

Per la maggior parte delle app il pattern intelligente è un refresh periodico in background che scalda la cache prima che arrivino gli utenti. 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}"))

Servono anche fx/management/__init__.py e fx/management/commands/__init__.py vuoti affinché Django trovi il comando.

Schedulalo con cron (o Celery beat se già usi Celery):

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

Ogni 15 minuti vengono aggiornate quattro basi. Ogni refresh è una chiamata upstream — 4 chiamate × 96 (intervalli al giorno) = 384 chiamate/giorno, ampiamente dentro il free tier Finexly da 1.000/mese se scendi a ogni ora. Vedi la pagina piani per le quote di tier superiori se serve freschezza sub-15 minuti.

Step 9: test che non toccano mai la rete

Il senso del service layer è che lo si può mockare. Installa responses e 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

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

Lanciali:

pytest -q

Quattro test, zero chiamate di rete, runtime totale sub-secondo. Questo è tutto il punto.

Note di produzione

Alcune cose che mordono le app Django in produzione quando FX è live:

  • La persistenza in DB è il tuo fallback. Se salti _persist, un guasto upstream stende il checkout. Non saltarlo.
  • Cache stampede. Se 100 richieste colpiscono insieme una cache fredda, scateni 100 chiamate upstream. Usa il get_or_set di django-redis con lock, o accetta il compromesso se il traffico è basso.
  • Drift valuta nei weekend. I mercati FX chiudono. La maggior parte delle API (Finexly inclusa) serve la chiusura del venerdì per tutto il weekend; i tassi non si muovono fino all'apertura del lunedì. Se regoli transazioni nel weekend, documenta la policy.
  • Audit trail. La tabella ExchangeRate cresce in fretta. Aggiungi un indice partial sugli ultimi 7 giorni, oppure rolla righe più vecchie in un riepilogo mensile se la compliance lo permette.

Per un approfondimento, la nostra guida cache ed error handling per API di valute copre stampede protection, circuit breaker e graceful degradation nel dettaglio.

Domande frequenti

Quale API tassi di cambio funziona meglio con Django? Qualunque API REST funziona, ma l'ergonomia cambia. Finexly restituisce una forma piatta {rates: {...}} che mappa pulita a un dict Python, supporta 170+ valute e ha un free tier di 1.000 chiamate/mese — basta per un MVP. Alternative come Fixer e Open Exchange Rates hanno strutture di quota diverse; il nostro post di confronto passa in rassegna i compromessi.

Decimal o Float per i tassi di cambio in Django? Sempre DecimalField per lo storage e Decimal nella logica di business. Float introduce errori di arrotondamento che si compongono su migliaia di conversioni; nel codice finanziario diventano bug visibili all'utente. Il costo in performance è trascurabile.

Ogni quanto aggiornare i tassi? Dipende dal caso. Per app consumer che mostrano prezzi localizzati, ogni 15 minuti basta. Per report contabili, ogni ora va bene. Per trading o hedging in tempo reale servono feed WebSocket sub-secondo — vedi il nostro post REST vs WebSocket.

Posso usarlo con Django REST Framework invece di Django Ninja? Sì. Il service layer è agnostico al framework. Sostituisci fx/api.py con un APIView DRF che chiama ExchangeRateService().convert(...) e serializza il risultato. Model, service, management command e test restano identici.

Come gestire valute non presenti nella risposta dell'API? Due pattern. O sollevare un ExchangeRateError esplicito ed esporlo all'utente (UX più pulita), o ripiegare su un cross-rate via USD (più permissivo ma più difficile da debuggare). Per la maggior parte delle app, fallire presto è la scelta giusta.

Prova Finexly gratis

Pronto a integrare tassi di cambio in tempo reale nel tuo progetto Django? Ottieni la tua chiave API Finexly gratis — niente carta di credito. Inizia con 1.000 richieste gratis al mese, tassi in tempo reale per 170+ valute e un contratto JSON che mappa pulito a Python. Scala sui nostri piani quando serve.

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 →

Condividi questo articolo