ブログに戻る

Djangoで通貨コンバーターを作る方法:為替レートAPI完全チュートリアル(2026年版)

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

お金を扱うDjangoアプリの多くは、遅かれ早かれ為替レートが必要になります — 3通貨で請求する、Stripeのペイアウトをホーム通貨に戻す、チェックアウトでローカライズされた価格を表示する、いずれの場合でも。正解は Django向けの為替レートAPI をつないでFXを「解決済みの問題」として扱うことです。誤った答えは、settings.pyにレートをハードコードすることと、リクエストごとにアップストリームを叩くことです。

このガイドでは、本番グレードの統合をエンドツーエンドで進めます。Django 5.x で小さな通貨コンバーターを、Finexly API、ホットリード用のDjangoキャッシュフレームワーク、永続化用のモデル、定期リフレッシュ用のmanagement command、浮動小数点誤差でセントを失わない Decimal 精度、Django Ninja経由のRESTエンドポイント、htmx駆動のUI、そしてネットワークに触らない pytest + responses テストで構築します。終わるころには、SaaS課金、EC、会計など、お金が国境を越えるあらゆるDjangoプロジェクトに差し込めるサービスクラス・アーキテクチャが手に入ります。

専用通貨APIがハードコードに勝る理由

settings.py にレートをハードコードするのは最初の間違い。次の間違いは、ビューごとにアップストリームを呼ぶことです。正しく使われた専用通貨APIは、ハードコードされた値では得られない4つを与えてくれます:

  • 必要なときの鮮度。レートは平日の市場時間中、絶え間なく動きます。USD/JPY や EUR/TRY のような変動の大きいペアでは、24時間古いレートでも1〜2%動き得ます — SaaSプランの利益を吹き飛ばすには十分です。
  • 広いカバレッジ。Finexly は新興国通貨や CBDC 参照レートを含む170以上の通貨をカバーします。多くのオープンなDjangoパッケージに同梱される ECB フィードは32 メジャー程度。ユーザー1人でもアルゼンチン・ペソやトルコ・リラで支払うなら、このギャップは効いてきます。
  • 単一の契約。latesthistoricalconvert 各エンドポイントで同じJSON形式 — 3つのアップストリームをガムテープで貼り合わせる必要はありません。
  • 予測可能なクォータ。「ポーリングしすぎて ECB にIPを止められた」ではなく、設計可能な明文化された制限。

まだ選択肢を比較している方は、2026年 無料 vs 有料 通貨API比較ExchangeRate-API vs CurrencyLayer vs Finexly の記事で代替案を細かく検討しています。

作るもの

意見の強い小さなDjangoモジュール fx で、5つのことをきちんとやります:

  1. 型付きサービスクラスで、任意のベース通貨に対する最新レートをFinexlyから取得。
  2. ホットリードをDjangoキャッシュ(本番はRedis、開発はlocmem)にキャッシュ。
  3. 各フェッチを ExchangeRate モデルに永続化 — アップストリームが瞬断してもチェックアウトを落とさない。
  4. management command + cron(または Celery beat)で定期的にレートをリフレッシュ。
  5. JSON 変換エンドポイントと htmx 駆動の HTML フォームを公開。

既存の Django プロジェクトに再利用可能アプリとして差し込めます。

前提条件

Python 3.11+、Django 5.0+、Finexly の API キーが必要です。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

新しいアプリを site/settings.pyINSTALLED_APPS に追加:

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

requests ではなく httpx を使うのは、タイムアウト、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 が無いなら RedisCacheLocMemCache に差し替えるだけ — 他のコードは関知しません。

15分の TTL(900 秒)はほとんどのコンシューマ向けアプリで妥当なデフォルト。HFT ならサブ秒、会計レポートなら6時間 TTL でも構いません。キャッシュとエラー処理のベストプラクティス で、自分のユースケースに合った数字の選び方を解説しています。

ステップ3:ExchangeRate モデル

永続化は重要です — アップストリームが落ちたときのフォールバックになり、財務に「4月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}"

設計上ふたつ触れておく価値があります。第1に、FloatField ではなく DecimalField — 浮動小数点誤差は数千件の換算で積み上がり、請求書ごとに帳簿が $0.03 ずれている理由を財務に説明するエンジニアになりたくはないはず。10桁は表示には過剰ですが FX 保存の標準です。第2に、(base, quote, -fetched_at) の複合インデックスは「最新の USD/EUR レートをくれ」をソートではなく1回の B-tree シークに変えます。

マイグレーションを実行:

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を掛けて丸めを混入させない)を扱い、結果を小数2桁に量子化します — これは表示上の選択。保存はもっと精度を残します。_fallback_from_db は本番のセーフティネット:Finexly が 503 を返したら、自前のDBから quote ごとの最新レートで応答します。サードパーティの瞬断でチェックアウトを落とさないためです。

httpx.HTTPTransportretries=2 は、過渡的な 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 パターン:同じビュー、2 つのテンプレート。通常 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:Django Ninja で REST エンドポイント

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 は /api/docs に OpenAPI スキーマを無料で生成 — モバイルチームが質問してきたときに便利です。

ステップ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__.pyfx/management/commands/__init__.py も必要です。

cron(既に Celery を使っているなら Celery beat)でスケジュール:

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

15分ごとに4つのベースをリフレッシュ。各リフレッシュは1コール — 4コール × 96(1日あたりの間隔数)= 1日384コール、毎時に落とせば Finexly の月1,000無料枠に余裕で収まります。15分未満の鮮度が必要な場合は 料金プラン ページで上位の枠を確認してください。

ステップ9:ネットワークに触らないテスト

サービス層の存在意義はモックできることです。responsespytest-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

テスト4件、ネットワークコール0、合計実行時間サブ秒。これがすべての要点です。

本番運用メモ

FX が本番で動き出すと Django アプリを噛んでくることがいくつかあります:

  • DB 永続化はフォールバック。_persist を飛ばすと、アップストリーム障害がそのままチェックアウト障害になります。飛ばさないこと。
  • キャッシュスタンピード。100リクエストが冷たいキャッシュに同時に当たれば、100コール上流に飛びます。django-redisget_or_set でロックするか、トラフィックが低いなら割り切る。
  • 週末の通貨ドリフト。FX 市場は閉まります。多くのAPI(Finexly含む)は週末も金曜終値を提供 — 月曜オープンまでレートは動きません。週末取引を決済するならポリシーを文書化してください。
  • 監査トレイル。ExchangeRate テーブルは速く伸びます。直近7日に partial インデックスを付ける、コンプライアンスが許せば古い行を月次サマリにロールアップする、など。

もう一段深く知りたい方は、キャッシュとエラー処理ガイド でスタンピード保護、サーキットブレーカー、優雅な縮退を詳しく扱っています。

よくある質問

Django に最も合う為替レートAPIは? どの REST API でも動きますが、人間工学が違います。Finexly は Python の dict に綺麗に対応する平らな {rates: {...}} 形を返し、170以上の通貨をサポート、月1,000コールの無料枠付き — MVP には十分です。Fixer や Open Exchange Rates などはクォータ構造が異なります;比較記事 でトレードオフを解説しています。

Django で為替レートは Decimal と Float のどちらを使うべき? 保存は常に DecimalField、ビジネスロジックは常に Decimal。Float の丸め誤差は数千件の換算で積み上がり、金融コードでは最終的にユーザーに見えるバグになります。パフォーマンスコストは無視できます。

レートはどのくらいの頻度でリフレッシュすべき? ユースケース次第。ローカライズ価格を表示するコンシューマアプリなら15分間隔で十分。会計レポートなら毎時で OK。トレーディングやリアルタイムヘッジならサブ秒の WebSocket フィードが必要 — REST vs WebSocket 記事 を参照。

Django Ninja の代わりに Django REST Framework でも使える? はい。サービス層はフレームワークに非依存です。fx/api.pyExchangeRateService().convert(...) を呼んで結果をシリアライズする DRF の APIView に置き換えるだけ。モデル、サービス、management command、テストは全部そのまま。

API レスポンスに無い通貨はどう扱う? 2つのパターン。明示的に ExchangeRateError を投げてユーザーに見せる(UX が綺麗)か、USD 経由のクロスレートにフォールバック(寛容だがデバッグしにくい)。ほとんどのアプリでは早めに投げるのが正解。

Finexly を無料で試す

Django プロジェクトにリアルタイム為替レートを統合する準備はできましたか?Finexly の無料 API キーを取得 — クレジットカード不要。月1,000リクエストの無料枠、170以上の通貨のリアルタイムレート、Python にぴったり合う JSON 契約から始められます。必要に応じて 料金プラン でスケールアップ。

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 →

この記事を共有する