Назад к блогу

Как создать конвертер валют в Django: полное руководство по API курсов валют (2026)

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

Большинство Django-приложений, работающих с деньгами, рано или поздно нуждаются в курсах валют — будь то выставление счетов в трёх валютах, пересчёт выплат Stripe в базовую валюту или вывод локализованных цен на чекауте. Правильный ответ — подключить API курсов валют для Django и считать FX решённой задачей. Неправильные ответы — захардкодить курсы в settings.py или дёргать апстрим в каждом запросе.

Это руководство проходит production-grade интеграцию от и до. Мы соберём небольшой конвертер валют на Django 5.x с использованием API Finexly, фреймворка кэширования Django для горячих чтений, модели для персистентности, management command для планового обновления, точности Decimal (чтобы не терять копейки на плавающей точке), REST-эндпоинта через Django Ninja, htmx-UI и pytest с responses для тестов, не трогающих сеть. В итоге у вас будет архитектура сервисного слоя, которую можно вставить в любой Django-проект — SaaS-биллинг, e-commerce, бухгалтерия, везде где деньги пересекают границу.

Почему выделенный API валют лучше захардкоженных курсов

Захардкодить курсы в settings.py — первый неправильный ответ. Второй — звать апстрим в каждой вью. Правильно использованный выделенный API валют даёт вам четыре вещи, недоступные хардкоду:

  • Свежесть по требованию. Курсы непрерывно меняются в часы рынка. Даже 24-часовой устаревший курс на волатильных парах вроде USD/JPY или EUR/TRY может пройти 1–2% — этого достаточно, чтобы съесть маржу SaaS-плана.
  • Широкий охват. Finexly покрывает 170+ валют, включая развивающиеся рынки и референсные курсы CBDC. Фид ЕЦБ, с которым идут многие открытые Django-пакеты, покрывает около 32 мажоров. Если хоть один пользователь платит в аргентинских песо или турецких лирах, эта дыра имеет значение.
  • Единый контракт. Одна JSON-форма для эндпоинтов latest, historical и convert, вместо трёх разных апстрим-фидов, склеенных скотчем.
  • Предсказуемая квота. Задокументированный лимит, который можно осмыслить, а не «ЕЦБ забанил наш IP за слишком частые опросы».

Если вы ещё взвешиваете варианты, наш сравнительный обзор бесплатных и платных API валют для 2026 и разбор ExchangeRate-API vs CurrencyLayer vs Finexly детально проходят альтернативы.

Что мы построим

Небольшой мнения-богатый Django-модуль fx, который делает пять вещей хорошо:

  1. Тянет свежие курсы для любой базовой валюты из Finexly через типизированный сервис-класс.
  2. Кэширует горячие чтения в кэше Django (Redis в проде, locmem в деве).
  3. Сохраняет каждый fetch в модель ExchangeRate, чтобы чекаут не лёг при сбое апстрима.
  4. Обновляет курсы по расписанию через management command + cron (или Celery beat).
  5. Выставляет JSON-эндпоинт конверсии и htmx-HTML-форму.

Вставляется как переиспользуемое app-приложение в любой существующий Django-проект.

Предварительные требования

Нужен Python 3.11+, Django 5.0+ и ключ API Finexly. Получите бесплатно на странице регистрации Finexly — бесплатный тариф даёт 1 000 запросов в месяц, более чем достаточно для MVP при грамотном кэшировании.

Шаг 1: разверните 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

Добавьте новое app в INSTALLED_APPS в site/settings.py:

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

Мы используем httpx вместо requests, потому что он даёт один клиент с таймаутами, ретраями через httpx.HTTPTransport(retries=...) и async-клиентом, на который можно перейти позже. python-decouple — для переменных окружения, django-ninja — для REST-эндпоинта. Оба минимальны и не мешают.

Шаг 2: настройте переменные окружения

Создайте .env в корне проекта:

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

Проводите это в 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"),
    }
}

В деве без Redis замените RedisCache на LocMemCache — остальному коду всё равно.

TTL 15 минут (900 секунд) — разумный дефолт для большинства пользовательских приложений. Для HFT нужно sub-second; для бухгалтерских отчётов хватит и 6-часового TTL. Пост лучшие практики кэширования и обработки ошибок разбирает, как выбрать число под свой случай.

Шаг 3: модель ExchangeRate

Персистентность важна потому что даёт фолбэк, когда апстрим недоступен, и аудит-след, когда бухгалтерия спросит «какой курс мы использовали в счёте от 3 апреля?». Создайте 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}"

Два решения, заслуживающих комментария. Первое: DecimalField, не FloatField — погрешность плавающей точки накапливается по тысячам конверсий, и вы не хотите быть инженером, объясняющим финансам, почему книги расходятся на $0,03 на счёт. Десять знаков после запятой избыточны для отображения, но стандартны для хранения FX. Второе: составной индекс (base, quote, -fetched_at) превращает «дай мне последний USD/EUR-курс» в один B-tree-seek, а не сортировку.

Запустите миграцию:

python manage.py makemigrations fx
python manage.py migrate

Шаг 4: сервисный слой

Сервис-класс — место, где живёт вся логика общения с апстримом. Вью никогда не зовут httpx напрямую. Создайте 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,
        )

Здесь много всего, давайте разберём. Метод latest() — единственный публичный путь чтения: попробовать кэш, упасть к апстриму, сохранить на обратном пути. convert() обрабатывает одинаковую валюту (не множьте на 1 без нужды, чтобы не словить округление) и квантует результат до двух знаков — это про отображение; для хранения берите больше точности. _fallback_from_db — production-страховка: если Finexly возвращает 503, отдаём из своей БД самый свежий курс на каждый quote. Чекаут не падает из-за сбоя третьей стороны.

retries=2 в httpx.HTTPTransport автоматически ловит подавляющее большинство переходящих 502/503. Таймаут 8 секунд консервативный — меньше 5 секунд слишком тесно, если вы пересекаете континенты; больше 10 — мерцающий апстрим рушит ваш бюджет латентности.

Шаг 5: вью конверсии

Теперь вью. Создайте 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)

Проверка хедера HX-Request — htmx-паттерн: одна вью, два шаблона. Обычный POST отдаёт страницу целиком; htmx-POST отдаёт только фрагмент результата, который htmx подставляет в DOM. Пользователь видит конвертированную сумму на месте — JavaScript, написанного вами, ноль.

Шаг 6: htmx-шаблоны

Создайте 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>

И парциал 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 %}

Пропроводите в fx/urls.py:

from django.urls import path
from . import views

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

И в site/urls.py:

from django.urls import include, path

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

Шаг 7: REST-эндпоинт с Django Ninja

Для JSON-потребителей — мобильного приложения, фонового воркера, фронтенд-SPA — выставьте типизированный эндпоинт. Создайте 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)

Добавьте в site/urls.py:

from fx.api import api

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

Теперь GET /api/convert?amount=100&from_=USD&to=EUR возвращает строго типизированный JSON. Django Ninja бесплатно генерирует OpenAPI-схему на /api/docs — пригодится, когда мобильная команда начнёт задавать вопросы.

Шаг 8: плановое обновление через management command

Для большинства приложений умный паттерн — периодическое фоновое обновление, прогревающее кэш до прихода пользователей. Создайте 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}"))

Нужны пустые fx/management/__init__.py и fx/management/commands/__init__.py, чтобы Django нашёл команду.

Планируйте cron-ом (или Celery beat, если уже крутится Celery):

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

Каждые 15 минут обновляются четыре базы. Каждое обновление — один upstream-вызов; 4 × 96 (интервалов в день) = 384 вызова в день — комфортно укладывается в бесплатный лимит Finexly 1 000/мес, если перейти на часовой интервал. Для более высокой свежести смотрите тарифные планы.

Шаг 9: тесты без сети

Весь смысл сервисного слоя — что его можно замокать. Установите responses и pytest-django:

pip install pytest pytest-django responses

Создайте pytest.ini:

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

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

Запустите:

pytest -q

Четыре теста, ноль сетевых вызовов, общее время — sub-second. Именно в этом и смысл.

Заметки для продакшна

Несколько вещей, которые кусают Django-приложения, когда FX уже живёт в проде:

  • Персистентность в БД — ваш фолбэк. Если пропустить _persist, падение апстрима тут же кладёт чекаут. Не пропускайте.
  • Cache stampede. Если 100 запросов одновременно ударят в холодный кэш, вы выстрелите 100 вызовов в апстрим. Используйте get_or_set django-redis с локом, либо смиритесь, если трафик низкий.
  • Дрейф валют по выходным. FX-рынки закрываются. Большинство API (Finexly в их числе) отдают пятничный close весь уикенд; ваши курсы не двинутся до открытия в понедельник. Если рассчитываете транзакции по выходным — задокументируйте политику.
  • Аудит-след. Таблица ExchangeRate растёт быстро. Добавьте partial индекс на последние 7 дней или сворачивайте старые строки в месячные агрегаты, если compliance позволяет.

Если нужно глубже — наш гайд по кэшированию и обработке ошибок разбирает stampede protection, circuit breakers и graceful degradation в деталях.

Часто задаваемые вопросы

Какой API курсов валют лучше всего работает с Django? Любой REST-API подойдёт, но эргономика разная. Finexly возвращает плоский {rates: {...}}, чисто маппящийся в Python-dict, поддерживает 170+ валют и идёт с бесплатным тарифом 1 000 вызовов/мес — хватит на MVP. Альтернативы вроде Fixer и Open Exchange Rates имеют другую структуру квот; наш сравнительный пост проходит трейд-оффы.

Decimal или Float для курсов валют в Django? Всегда DecimalField для хранения и Decimal в бизнес-логике. Float вносит ошибки округления, которые накапливаются по тысячам конверсий; в финансовом коде эти ошибки в итоге становятся видимыми пользователю багами. Стоимость по производительности — пренебрежимо мала.

Как часто обновлять курсы? Зависит от случая. Для пользовательских приложений с локализованными ценами 15 минут более чем достаточно. Для бухгалтерских отчётов раз в час подойдёт. Для трейдинга или хеджирования в реальном времени нужны sub-second WebSocket-фиды — об этом паттерне в нашем посте REST vs WebSocket.

Можно использовать это с Django REST Framework вместо Django Ninja? Да. Сервисный слой не зависит от фреймворка. Замените fx/api.py на DRF-APIView, который зовёт ExchangeRateService().convert(...) и сериализует результат. Модель, сервис, management command и тесты — идентичны.

Как обрабатывать валюты, которых нет в ответе API? Два паттерна. Либо явно бросать ExchangeRateError и показывать пользователю (чище UX), либо падать на кросс-курс через USD (мягче, но сложнее дебажить). Для большинства приложений ранний бросок — правильный выбор.

Попробуйте Finexly бесплатно

Готовы интегрировать курсы валют в реальном времени в свой Django-проект? Получите бесплатный API-ключ Finexly — без карты. Начните с 1 000 бесплатных запросов в месяц, курсами в реальном времени для 170+ валют и JSON-контрактом, чисто маппящимся в Python. Масштабируйтесь на наших тарифах по мере роста.

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 →