Blog'a Dön

Django'da Döviz Çevirici Nasıl Yapılır: Eksiksiz Döviz Kuru API Eğitimi (2026)

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

Parayla ilgili çoğu Django uygulaması, er ya da geç döviz kurlarına ihtiyaç duyar — üç para biriminde fatura kesin, Stripe ödemelerini ana paraya geri çevirin ya da ödeme sayfasında yerelleştirilmiş fiyat gösterin. Doğru cevap Django için bir döviz kuru API'si takmak ve FX'i çözülmüş bir problem gibi ele almaktır. Yanlış cevaplar: settings.py içinde kurları sabit kodlamak ya da her istekte upstream'e gitmektir.

Bu rehber, production-grade bir entegrasyonu baştan sona gezdiriyor. Django 5.x üzerinde Finexly API, sıcak okumalar için Django cache framework'ü, kalıcılık için bir model, planlı tazeleme için bir management command, kuruşları kayan noktada kaybetmemek için Decimal hassasiyet, Django Ninja üzerinden REST endpoint, htmx-destekli UI ve ağa hiç dokunmayan pytest + responses testleri kullanarak küçük bir döviz çevirici inşa edeceğiz. Sonunda elinizde her Django projesine takılabilir bir servis katmanı mimarisi olacak — SaaS faturalama, e-ticaret, muhasebe, paranın sınır geçtiği her yer.

Neden adanmış bir döviz API'si sabit kodlanmış kurları yener

settings.py içinde kur sabit kodlamak ilk yanlış cevap. İkincisi her view'da upstream'i çağırmak. Doğru kullanılan adanmış bir döviz API'si, sabit değerlerin veremeyeceği dört şey verir:

  • İhtiyaç anında tazelik. Kurlar piyasa saatlerinde sürekli değişir. 24 saatlik bayat bir kur bile USD/JPY veya EUR/TRY gibi volatil çiftlerde %1–2 hareket edebilir — bir SaaS planının marjını silmek için yeter.
  • Geniş kapsama. Finexly, gelişen piyasalar ve CBDC referans kurları dahil 170+ para birimini kapsar. Pek çok açık kaynak Django paketiyle gelen ECB beslemesi yaklaşık 32 majörü kapsar. Tek bir kullanıcı Arjantin pesosu veya Türk lirasıyla ödüyorsa, bu boşluk önemlidir.
  • Tek bir sözleşme. latest, historical ve convert endpoint'lerinde tek bir JSON biçimi — üç farklı upstream beslemesini bantla birbirine yapıştırmak yok.
  • Tahmin edilebilir kota. Üzerine düşünebileceğiniz, dokümante edilmiş bir limit, "ECB IP'mizi çok poll ettiğimiz için engelledi" demek yerine.

Hâlâ seçenekleri tartıyorsanız, 2026 için ücretsiz vs ücretli döviz API karşılaştırması ve ExchangeRate-API vs CurrencyLayer vs Finexly yazıları alternatifleri detayıyla gezer.

Ne inşa edeceğiz

Beş işi iyi yapan, fikir sahibi küçük bir Django modülü fx:

  1. Tipli bir servis sınıfıyla Finexly'den herhangi bir baz para için en güncel kurları çeker.
  2. Sıcak okumaları Django cache'inde (production'da Redis, dev'de locmem) tutar.
  3. Her fetch'i bir ExchangeRate modeline kalıcı yazar; upstream bir an gitse bile ödeme çökmez.
  4. Bir management command + cron (veya Celery beat) ile kurları planlı yeniler.
  5. Bir JSON dönüştürme endpoint'i ve htmx-destekli HTML formu sunar.

Mevcut herhangi bir Django projesine yeniden kullanılabilir bir app olarak takın.

Ön koşullar

Python 3.11+, Django 5.0+ ve bir Finexly API anahtarına ihtiyacınız var. Finexly kayıt sayfasından ücretsiz alın — ücretsiz katman ayda 1.000 istek verir, doğru cache'lerseniz bir MVP için fazlasıyla yeter.

Adım 1: Django projesini kurun

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

Yeni app'i site/settings.py içindeki INSTALLED_APPS'e ekleyin:

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

requests yerine httpx kullanıyoruz çünkü tek bir istemciyle timeout'lar, httpx.HTTPTransport(retries=...) üzerinden retries ve daha sonra değiştirebileceğimiz bir async istemci sunuyor. Ortam değişkenleri için python-decouple, REST endpoint için django-ninja — ikisi de minimal ve yoldan çekiliyor.

Adım 2: Ortam değişkenlerini ayarlayın

Proje kökünde bir .env oluşturun:

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

Sonra site/settings.py'ye bağlayın:

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

Dev'de Redis yoksa RedisCache'i LocMemCache ile değiştirin — kodun geri kalanı umursamıyor.

15 dakikalık TTL (900 saniye) çoğu tüketici-yönelik uygulama için makul bir varsayılan. Yüksek frekanslı işlem için sub-saniye gerekir; muhasebe raporları için 6 saatlik TTL bile iyi. Önbellekleme ve hata yönetimi en iyi uygulamaları yazımız kendi durumunuz için doğru sayıyı seçme yolunu tartışır.

Adım 3: ExchangeRate modeli

Kalıcılık önemli, çünkü upstream'e ulaşılamadığında bir fallback ve finans "3 Nisan'daki o faturada hangi kuru kullandık?" diye sorduğunda denetim izi sağlar. fx/models.py oluşturun:

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}"

İki tasarım kararına dikkat. Birincisi, FloatField değil DecimalField — kayan nokta hatası binlerce dönüşümde birikir ve fatura başına 0,03 $ sapan defterleri finansa anlatan mühendis olmak istemezsiniz. On ondalık basamak gösterim için fazla, FX depolama için standarttır. İkincisi, (base, quote, -fetched_at) birleşik dizin "bana en son USD/EUR kurunu ver" sorgusunu sort yerine tek bir B-tree seek'e çevirir.

Migration'ı çalıştırın:

python manage.py makemigrations fx
python manage.py migrate

Adım 4: Servis katmanı

Servis sınıfı, upstream ile konuşan tüm mantığın yaşadığı yerdir. View'lar asla doğrudan httpx çağırmaz. fx/services.py oluşturun:

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,
        )

Burada çok şey oluyor, dağıtalım. latest() tek genel okuma yoludur: cache'i dene, upstream'e geri düş, dönerken kalıcı yaz. convert() aynı para birimi sınır durumunu ele alır (1 ile gereksiz çarpıp yuvarlama riski almayın) ve sonucu iki ondalığa quantize eder — bu bir sunum tercihi; depolama için daha fazla hassasiyet tutun. _fallback_from_db production emniyet ağıdır: Finexly 503 döndürürse, kendi DB'mizden quote başına en yeni kuru sunarız. Ödeme üçüncü taraf sorunu yüzünden çökmez.

httpx.HTTPTransport üzerindeki retries=2, geçici 502/503'lerin büyük çoğunluğunu otomatik yakalar. 8 saniyelik timeout muhafazakar — 5 saniyenin altı kıtaları aşıyorsanız çok dar, 10 saniyenin üstü titrek bir upstream gecikme bütçenizi mahveder.

Adım 5: Dönüştürme view'i

Şimdi view'lar. fx/views.py oluşturun:

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 başlığını kontrol etmek htmx paternidir: aynı view, iki şablon. Normal bir POST tüm sayfayı; htmx-tetikli bir POST yalnızca sonuç parçacığını döndürür ve htmx onu DOM'a sokuştur. Kullanıcı dönüştürülmüş miktarı yerinde belirir görür, yazdığınız JavaScript miktarı sıfır.

Adım 6: htmx şablonları

fx/templates/fx/convert.html oluşturun:

{% 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>

Kısmi 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 içinde bağlayın:

from django.urls import path
from . import views

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

site/urls.py içinde:

from django.urls import include, path

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

Adım 7: Django Ninja ile REST endpoint

JSON tüketicileri için — mobil uygulama, arka plan worker'ı, frontend SPA — tipli bir endpoint sunun. fx/api.py oluşturun:

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'ye ekleyin:

from fx.api import api

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

Artık GET /api/convert?amount=100&from_=USD&to=EUR güçlü tipli bir JSON payload döndürür. Django Ninja /api/docs adresinde OpenAPI şemasını ücretsiz üretir — mobil takım sorular sormaya başladığında işe yarar.

Adım 8: Management command ile planlı tazeleme

Çoğu uygulama için akıllı patern, kullanıcılar gelmeden cache'i ısıtan periyodik bir arka plan tazelemesi'dir. fx/management/commands/refresh_rates.py oluşturun:

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

Django'nun komutu bulması için boş fx/management/__init__.py ve fx/management/commands/__init__.py da gerek.

cron (veya zaten Celery çalıştırıyorsanız Celery beat) ile planlayın:

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

Her 15 dakikada bir dört baz tazelenir. Her tazeleme bir upstream çağrısı — 4 çağrı × 96 (günlük aralık sayısı) = günde 384 çağrı, saatlik aralığa düşerseniz Finexly'nin aylık 1.000 ücretsiz limitine rahatça sığar. 15 dakikanın altında tazelik gerekirse fiyatlandırma planları sayfasından üst kademe kotalara bakın.

Adım 9: Ağa hiç dokunmayan testler

Servis katmanının tüm anlamı onu mock edebilmektir. responses ve pytest-django kurun:

pip install pytest pytest-django responses

pytest.ini oluşturun:

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

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

Çalıştırın:

pytest -q

Dört test, sıfır ağ çağrısı, toplam sub-saniye çalışma süresi. Tüm mesele bu.

Production notları

FX canlıyken Django uygulamalarını ısıran birkaç şey:

  • DB kalıcılığı sizin fallback'inizdir. _persist adımını atlarsanız, upstream kesintisi ödemeyi düşürür. Atlamayın.
  • Cache stampede. 100 istek soğuk bir cache'e aynı anda vurursa, 100 upstream çağrısı atarsınız. django-redis'in get_or_set'ini kilitle kullanın veya trafiğiniz düşükse trade-off'u kabullenin.
  • Hafta sonu kur sürüklenmesi. FX piyasaları kapanır. Çoğu API (Finexly dahil) tüm hafta sonu boyunca Cuma kapanışını sunar; kurlar pazartesi açılışına kadar hareket etmez. Hafta sonu işlemlerini takas ediyorsanız politikayı belgeleyin.
  • Denetim izi. ExchangeRate tablosu hızlı büyür. Son 7 güne partial indeks ekleyin veya uyum izin veriyorsa daha eski satırları aylık özete sarın.

Bu paternleri daha derinden incelemek isterseniz, döviz API caching ve hata yönetimi rehberi stampede koruması, circuit breaker'lar ve zarif düşüşü detaylıca işler.

Sık sorulan sorular

Django ile en iyi hangi döviz kuru API'si çalışır? Herhangi bir REST API çalışır, ama ergonomi farklıdır. Finexly, Python dict'ine temiz eşlenen düz bir {rates: {...}} formu döndürür, 170+ para birimini destekler ve aylık 1.000 çağrılık ücretsiz tier ile gelir — MVP için yeter. Fixer ve Open Exchange Rates gibi alternatiflerin farklı kota yapıları vardır; karşılaştırma yazımız trade-off'ları gezer.

Django'da kurlar için Decimal mı Float mı? Depolama için her zaman DecimalField, iş mantığında her zaman Decimal. Float, binlerce dönüşümde birikip kullanıcıya görünür hatalara dönen yuvarlama hataları getirir. Performans bedeli ihmal edilebilir.

Kurları ne sıklıkla tazelemeli? Kullanım durumuna bağlı. Yerelleştirilmiş fiyat gösteren tüketici uygulamaları için 15 dakikada bir yeter. Muhasebe raporları için saatlik iyidir. Trading veya gerçek zamanlı hedging için sub-saniye WebSocket beslemeleri gerekir — bu patern için REST vs WebSocket yazımıza bakın.

Django REST Framework ile Django Ninja yerine bunu kullanabilir miyim? Evet. Servis katmanı framework-bağımsızdır. fx/api.py'yi ExchangeRateService().convert(...) çağırıp sonucu serileştiren bir DRF APIView ile değiştirin. Model, servis, management command ve testler aynen kalır.

API yanıtında olmayan para birimlerini nasıl ele alırım? İki patern. Ya açık bir ExchangeRateError fırlatıp kullanıcıya gösterin (daha temiz UX), ya da USD üzerinden bir cross-rate'e geri düşün (daha hoşgörülü ama hata ayıklaması zor). Çoğu uygulama için erken fırlatma doğru seçim.

Finexly'yi ücretsiz deneyin

Django projenize gerçek zamanlı döviz kurlarını entegre etmeye hazır mısınız? Ücretsiz Finexly API anahtarınızı alın — kredi kartı gerekmez. Ayda 1.000 ücretsiz istek, 170+ para birimi için gerçek zamanlı kurlar ve Python'a temiz eşlenen bir JSON sözleşmesiyle başlayın. İhtiyaç doğdukça fiyatlandırma planlarımızla ölçeklendirin.

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 →