العودة إلى المدونة

كيفية بناء محوّل عملات في Django: دليل API أسعار الصرف الكامل (2026)

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

معظم تطبيقات Django التي تتعامل مع المال ستحتاج عاجلاً أو آجلاً إلى أسعار الصرف — سواء أصدرت فواتير بثلاث عملات، أو حوّلت مدفوعات Stripe إلى عملة أساسية، أو عرضت أسعارًا محلية في صفحة الدفع. الإجابة الصحيحة هي توصيل API أسعار صرف لـ Django والتعامل مع FX كمسألة محلولة. الإجابات الخاطئة هي تثبيت الأسعار في settings.py أو ضرب الـ upstream في كل طلب.

يمر هذا الدليل بتكامل بمستوى الإنتاج من البداية للنهاية. سنبني محوّل عملات صغيرًا في Django 5.x باستخدام Finexly API، وإطار التخزين المؤقت في Django للقراءات الساخنة، ونموذج للحفظ، وأمر إدارة للتحديث المجدول، ودقة Decimal كي لا نُهدر سنتات بسبب خطأ النقطة العائمة، ونقطة REST عبر Django Ninja، وواجهة htmx، وpytest مع responses لاختبارات لا تلمس الشبكة. ستحصل في النهاية على معمارية طبقة خدمة تصلح لأي مشروع Django — فوترة SaaS، تجارة إلكترونية، محاسبة، أينما يعبر المال حدودًا.

لماذا API عملات مخصص أفضل من الأسعار المثبتة

تثبيت الأسعار في settings.py هو الإجابة الخاطئة الأولى. الثانية هي الاتصال بالـ upstream في كل view. API عملات يُستخدم بشكل صحيح يمنحك أربعة أشياء لا توفرها القيم المثبتة:

  • حداثة عند الطلب. الأسعار تتغير باستمرار خلال ساعات السوق. حتى سعر عمره 24 ساعة قد يتحرك 1–2% على أزواج متقلبة مثل USD/JPY أو EUR/TRY — يكفي لمحو هامش خطة SaaS.
  • تغطية واسعة. يغطي Finexly أكثر من 170 عملة، تشمل عملات الأسواق الناشئة وأسعار CBDC المرجعية. تغطية تغذية ECB التي تأتي مع كثير من حِزم Django المفتوحة لا تتجاوز 32 عملة رئيسية. إذا دفع مستخدم واحد بالبيزو الأرجنتيني أو الليرة التركية، فالفجوة مهمة.
  • عقد واحد. شكل JSON واحد عبر نقاط latest وhistorical وconvert، بدلاً من ثلاث تغذيات upstream ملصقة معًا.
  • حصة قابلة للتنبؤ. حد موثق يمكن التفكير فيه، بدلاً من "حظر ECB لـ IPنا لأننا استعلمنا كثيرًا".

إذا كنت ما زلت توازن بين الخيارات، فإن مقارنة API عملات مجانية مقابل مدفوعة لعام 2026 وتحليل ExchangeRate-API و CurrencyLayer و Finexly يفصّلان البدائل.

ما الذي سنبنيه

وحدة Django صغيرة برأي محدد اسمها fx تقوم بخمسة أشياء جيدًا:

  1. تجلب أحدث الأسعار لأي عملة أساس من Finexly عبر صنف خدمة بأنواع محددة.
  2. تُخزّن القراءات الساخنة في كاش Django (Redis في الإنتاج، locmem في التطوير).
  3. تحفظ كل جلب في نموذج ExchangeRate كي لا يسقط الدفع إذا اضطرب الـ upstream.
  4. تُحدّث الأسعار على جدول عبر أمر إدارة + cron (أو Celery beat).
  5. تكشف نقطة JSON للتحويل ونموذج HTML مدفوعًا بـ htmx.

أدرجه كتطبيق قابل لإعادة الاستخدام في أي مشروع 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

أضف التطبيق الجديد إلى 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 لأنه يعطينا عميلًا واحدًا مع timeouts، وإعادة محاولات عبر 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 خمسة عشر دقيقة (900 ثانية) قيمة افتراضية معقولة لمعظم التطبيقات الموجّهة للمستهلك. للتداول عالي التردد ستحتاج إلى دون الثانية؛ ولتقارير المحاسبة تكفي 6 ساعات. منشور أفضل ممارسات التخزين المؤقت ومعالجة الأخطاء يناقش كيفية اختيار الرقم المناسب لحالتك.

الخطوة 3: نموذج ExchangeRate

الحفظ مهم لأنه يمنحك بديلاً عندما لا يستجيب الـ upstream، ومسار تدقيق عندما يسألك المالية: "ما السعر الذي استخدمناه في تلك الفاتورة بتاريخ 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 واحدة بدلاً من فرز.

شغّل migration:

python manage.py makemigrations fx
python manage.py migrate

الخطوة 4: طبقة الخدمة

صنف الخدمة هو المكان الذي تعيش فيه كل المنطق الذي يتحدث مع الـ upstream. الـ views لا تستدعي 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() هو المسار العام الوحيد للقراءة: جرّب الكاش، تراجع إلى الـ upstream، احفظ في طريق العودة. convert() يعالج الحالة الحدية لنفس العملة (لا تضرب في 1 بدون داع وتخاطر بإدخال تقريب)، ويُكمّم النتيجة إلى منزلتين عشريتين — قرار عرض؛ للتخزين احتفظ بدقة أعلى. _fallback_from_db هو شبكة الأمان في الإنتاج: إن أعاد Finexly 503، نقدّم آخر سعر لكل quote من قاعدة بياناتنا. لا يسقط الدفع بسبب اضطراب طرف ثالث.

retries=2 في httpx.HTTPTransport يلتقط معظم 502/503 العابرة تلقائيًا. مهلة 8 ثوانٍ متحفظة — تحت 5 ثوانٍ ضيّق جدًا عند عبور قارات، وفوق 10 ثوانٍ تعني أن upstream متذبذبًا يحطم ميزانية زمن الاستجابة.

الخطوة 5: view التحويل

الآن views. أنشئ 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: نفس الـ view وقالبان. POST عادي يعيد الصفحة كلها؛ POST مدفوع بـ htmx يعيد جزء النتيجة فقط ويُدخله 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 — تطبيقك المحمول، الـ worker الخلفي، الـ 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 يعيد payload JSON بأنواع قوية. ينشئ Django Ninja مخطط OpenAPI مجانًا على /api/docs — مفيد عندما يبدأ فريق الموبايل بالأسئلة.

الخطوة 8: تحديث مجدول عبر أمر إدارة

لمعظم التطبيقات، النمط الذكي هو تحديث خلفي دوري يُحمّي الكاش قبل وصول المستخدمين. أنشئ 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 استدعاء/يوم، يدخل بأريحية ضمن الطبقة المجانية 1,000/شهر إذا خفضت إلى كل ساعة. راجع صفحة خطط الأسعار لحصص الطبقات الأعلى إن كنت بحاجة إلى حداثة أقل من 15 دقيقة.

الخطوة 9: اختبارات لا تلمس الشبكة

الغرض كله من طبقة الخدمة أن تستطيع mocking-ها. ثبّت 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

أربعة اختبارات، صفر استدعاء شبكة، زمن إجمالي دون الثانية. هذا هو بيت القصيد.

ملاحظات الإنتاج

أشياء تنغّص حياة تطبيقات Django في الإنتاج عندما يكون FX حيًّا:

  • الحفظ في DB هو بديلك. إن تخطّيت _persist، فإن انقطاع الـ upstream يُسقط الدفع. لا تتخطَّ.
  • اندفاع الكاش (Stampede). إن ضربت 100 طلب كاشًا باردًا في آن واحد، ستطلق 100 استدعاء upstream. استخدم get_or_set من django-redis مع قفل، أو اقبل المقايضة إن كانت حركتك منخفضة.
  • انحراف العملات نهاية الأسبوع. أسواق FX تغلق. معظم الـ APIs (بما فيها Finexly) تقدّم إغلاق يوم الجمعة طوال عطلة الأسبوع؛ أسعارك لا تتحرك حتى افتتاح الإثنين. إن قمت بتسوية معاملات نهاية الأسبوع، وثّق السياسة.
  • مسار التدقيق. جدول ExchangeRate ينمو بسرعة. أضف فهرسًا partial على آخر 7 أيام، أو لفّ الصفوف الأقدم في ملخص شهري إذا سمح الامتثال.

إن أردت تعمقًا، فإن دليل التخزين المؤقت ومعالجة الأخطاء لـ API العملات يغطي حماية stampede، وقواطع الدوائر، والتدهور اللطيف بتفصيل.

الأسئلة الشائعة

أي API أسعار صرف يناسب Django أكثر؟ أي REST API يعمل، لكن قابلية الاستخدام تختلف. يعيد Finexly صيغة مسطّحة {rates: {...}} تُربط نظيفًا إلى dict في Python، ويدعم 170+ عملة، ومعه طبقة مجانية 1,000 استدعاء/شهر — تكفي لـ MVP. بدائل مثل Fixer و Open Exchange Rates لها هياكل حصص مختلفة؛ منشور المقارنة يمر على المقايضات.

هل أستخدم Decimal أم Float لأسعار الصرف في Django؟ دومًا DecimalField للتخزين وDecimal في منطق الأعمال. يُدخل Float أخطاء تقريب تتراكم عبر آلاف التحويلات؛ في كود مالي تتحول هذه الأخطاء في النهاية إلى أخطاء مرئية للمستخدم. تكلفة الأداء مهملة.

كم مرة أحدّث الأسعار؟ يعتمد على حالتك. لتطبيقات موجَّهة للمستهلك تعرض أسعارًا محلية، كل 15 دقيقة كافٍ. لتقارير المحاسبة، كل ساعة جيد. للتداول أو التحوّط اللحظي تحتاج تغذيات WebSocket دون الثانية — راجع منشورنا REST مقابل WebSocket.

هل أستطيع استخدام هذا مع Django REST Framework بدلاً من Django Ninja؟ نعم. طبقة الخدمة لا تتعلق بإطار. استبدل fx/api.py بـ APIView من DRF يستدعي ExchangeRateService().convert(...) ويُسلسل النتيجة. النموذج والخدمة وأمر الإدارة والاختبارات تبقى كما هي.

كيف أتعامل مع العملات غير الموجودة في استجابة الـ API؟ نمطان. إما أن ترفع ExchangeRateError صريحًا وتعرضه للمستخدم (UX أنظف)، أو تتراجع إلى سعر تقاطع عبر USD (أرحب لكنه أصعب في التصحيح). لمعظم التطبيقات، الإسقاط المبكر هو الخيار الصحيح.

جرّب Finexly مجانًا

مستعد لدمج أسعار الصرف اللحظية في مشروع Django خاصتك؟ احصل على مفتاح Finexly API مجانًا — دون بطاقة ائتمان. ابدأ بـ 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 →