Большинство 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, который делает пять вещей хорошо:
- Тянет свежие курсы для любой базовой валюты из Finexly через типизированный сервис-класс.
- Кэширует горячие чтения в кэше Django (Redis в проде, locmem в деве).
- Сохраняет каждый fetch в модель
ExchangeRate, чтобы чекаут не лёг при сбое апстрима. - Обновляет курсы по расписанию через management command + cron (или Celery beat).
- Выставляет 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_setdjango-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. Масштабируйтесь на наших тарифах по мере роста.
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 →