feat(automatic-exchange-rates): add central bank tables as a provider

This commit is contained in:
cahthuranag committed 2026-11-06 23:27:53 +00:00
1 parent c05bdcf2bf
commit 8f361e03d1
5 files changed
+380

No files matched your search

@@ -19,6 +19,7 @@ PROVIDER_MAPPING = {
"twelvedatamarkets": providers.TwelveDataMarketsProvider,
"yfinance": providers.YFinanceMarketsProvider,
"fxmacrodata": providers.FXMacroDataProvider,
"allratestoday": providers.AllRatesTodayProvider,
}
@@ -1,4 +1,5 @@
import logging
import re
import time
import requests
@@ -681,3 +682,134 @@ class FXMacroDataProvider(ExchangeRateProvider):
)
return results
class AllRatesTodayProvider(ExchangeRateProvider):
"""
Implementation for the central bank tables served by allratestoday.com.
Each service reads the latest published table of one central bank. The API
Key field holds the bank code (e.g. "bcb" or "rbi"), as this endpoint needs
no key. An empty field reads the ECB table.
"""
BASE_URL = "https://allratestoday.com/api/open/central-bank"
DEFAULT_BANK = "ecb"
rates_inverted = False # A base/quote row says how many quote one base buys
# A pair can have several rows. Prefer a published mid, else the mean of a buy
# and sell quote. Period averages are left out, they are not a current rate
MID_TYPES = (
"reference",
"middle",
"official",
"indicative",
"close",
"spot",
"informational",
)
BUY_SELL_TYPES = (("buy", "sell"), ("tt_buy", "tt_sell"))
def __init__(self, api_key: str = None):
super().__init__(api_key)
self.bank = (api_key or self.DEFAULT_BANK).strip().lower()
self.session = requests.Session()
@classmethod
def requires_api_key(cls) -> bool:
return False
def _parse_table(self, rows: list) -> Dict[Tuple[str, str], Decimal]:
"""Reduce the table to one rate per pair, in both directions"""
grouped = {}
for row in rows:
if not isinstance(row, dict) or not row.get("value"):
continue
pair = (row.get("base"), row.get("quote"))
grouped.setdefault(pair, {})[row.get("type")] = Decimal(str(row["value"]))
rates = {}
for (base, quote), values in grouped.items():
rate = next((values[t] for t in self.MID_TYPES if t in values), None)
if rate is None:
rate = next(
(
(values[buy] + values[sell]) / 2
for buy, sell in self.BUY_SELL_TYPES
if buy in values and sell in values
),
None,
)
if rate is None or rate <= 0:
continue
rates[(base, quote)] = rate
rates.setdefault((quote, base), self.invert_rate(rate))
return rates
@staticmethod
def _find_rate(
rates: Dict[Tuple[str, str], Decimal], base: str, quote: str
) -> Optional[Decimal]:
"""Look up a pair, crossing it through a shared currency if needed"""
if (base, quote) in rates:
return rates[(base, quote)]
for (from_code, via), rate in rates.items():
if from_code == base and (via, quote) in rates:
return rate * rates[(via, quote)]
return None
def get_rates(
self, target_currencies: QuerySet, exchange_currencies: set
) -> List[Tuple[Currency, Currency, Decimal]]:
results = []
if not re.fullmatch(r"[a-z0-9_-]{2,20}", self.bank):
logger.error("Invalid AllRatesToday bank code, it should look like 'bcb'")
return results
try:
response = self.session.get(f"{self.BASE_URL}/{self.bank}", timeout=30)
response.raise_for_status()
data = response.json()
if data.get("stale"):
logger.warning(
f"AllRatesToday table for {self.bank} is stale, "
f"last published on {data.get('rate_date')}"
)
rates = self._parse_table(data["rates"])
except requests.RequestException as e:
logger.error(f"Error fetching AllRatesToday table for {self.bank}: {e}")
return results
except (KeyError, TypeError, ValueError, AttributeError, ArithmeticError) as e:
logger.error(
f"Unexpected response structure from AllRatesToday "
f"for {self.bank}: {e}"
)
return results
for target_currency in target_currencies:
if target_currency.exchange_currency not in exchange_currencies:
continue
base_currency = target_currency.exchange_currency
if base_currency.code == target_currency.code:
results.append((base_currency, target_currency, Decimal("1")))
continue
rate = self._find_rate(rates, base_currency.code, target_currency.code)
if rate is None:
logger.error(
f"No rate for {base_currency.code}/{target_currency.code} "
f"in the AllRatesToday table for {self.bank}"
)
continue
results.append((base_currency, target_currency, rate))
return results
@@ -0,0 +1,35 @@
# Generated by Django 5.2.17 on 2026-11-06 22:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("currencies", "0025_alter_exchangerateservice_service_type"),
]
operations = [
migrations.AlterField(
model_name="exchangerateservice",
name="service_type",
field=models.CharField(
choices=[
("coingecko_free", "CoinGecko (Demo/Free)"),
("coingecko_pro", "CoinGecko (Pro)"),
("transitive", "Transitive (Calculated from Existing Rates)"),
("frankfurter", "Frankfurter"),
("twelvedata", "TwelveData"),
("twelvedatamarkets", "TwelveData Markets"),
("yfinance", "Yahoo Finance"),
("fxmacrodata", "FXMacroData"),
(
"allratestoday",
"AllRatesToday Central Banks (bank code as API key)",
),
],
max_length=255,
verbose_name="Service Type",
),
),
]
+4
View File
@@ -107,6 +107,10 @@ class ExchangeRateService(models.Model):
TWELVEDATA_MARKETS = "twelvedatamarkets", "TwelveData Markets"
YFINANCE = "yfinance", "Yahoo Finance"
FXMACRODATA = "fxmacrodata", "FXMacroData"
ALLRATESTODAY = (
"allratestoday",
"AllRatesToday Central Banks (bank code as API key)",
)
class IntervalType(models.TextChoices):
ON = "on", _("On")
@@ -0,0 +1,208 @@
from decimal import Decimal
from unittest import TestCase
from unittest.mock import MagicMock, patch
import requests
from apps.currencies.exchange_rates.fetcher import PROVIDER_MAPPING
from apps.currencies.exchange_rates.providers import AllRatesTodayProvider
from apps.currencies.models import ExchangeRateService
class _FakeCurrency:
def __init__(self, code, exchange_currency=None):
self.code = code
self.exchange_currency = exchange_currency
def _row(base, quote, value, type_="reference"):
return {"base": base, "quote": quote, "type": type_, "value": value}
def _response(payload):
response = MagicMock()
response.status_code = 200
response.json.return_value = payload
return response
class AllRatesTodayProviderTests(TestCase):
def setUp(self):
self.brl = _FakeCurrency("BRL")
self.usd = _FakeCurrency("USD")
self.eur = _FakeCurrency("EUR")
self.provider = AllRatesTodayProvider("bcb")
def _get_rates(self, rows, target, base):
target.exchange_currency = base
with patch.object(
self.provider.session, "get", return_value=_response({"rates": rows})
):
return self.provider.get_rates([target], {base})
def test_requests_the_configured_bank(self):
self.usd.exchange_currency = self.brl
payload = {"rates": [_row("USD", "BRL", 5.4)]}
with patch.object(
self.provider.session, "get", return_value=_response(payload)
) as mock_get:
self.provider.get_rates([self.usd], {self.brl})
mock_get.assert_called_once_with(
"https://allratestoday.com/api/open/central-bank/bcb", timeout=30
)
def test_fetches_one_table_for_all_pairs(self):
self.usd.exchange_currency = self.brl
self.eur.exchange_currency = self.brl
payload = {"rates": [_row("USD", "BRL", 5.4), _row("EUR", "BRL", 6.2)]}
with patch.object(
self.provider.session, "get", return_value=_response(payload)
) as mock_get:
rates = self.provider.get_rates([self.usd, self.eur], {self.brl})
mock_get.assert_called_once()
self.assertEqual(len(rates), 2)
def test_empty_field_reads_the_ecb_table(self):
self.assertEqual(AllRatesTodayProvider(None).bank, "ecb")
self.assertEqual(AllRatesTodayProvider("").bank, "ecb")
def test_normalizes_the_bank_code(self):
self.assertEqual(AllRatesTodayProvider(" BCB ").bank, "bcb")
def test_rejects_malformed_bank_code_without_a_request(self):
provider = AllRatesTodayProvider("../latest/USD")
self.usd.exchange_currency = self.brl
with patch.object(provider.session, "get") as mock_get:
rates = provider.get_rates([self.usd], {self.brl})
mock_get.assert_not_called()
self.assertEqual(rates, [])
def test_uses_a_row_in_the_requested_direction(self):
rates = self._get_rates([_row("EUR", "USD", 1.16)], self.usd, self.eur)
self.assertEqual(rates, [(self.eur, self.usd, Decimal("1.16"))])
def test_inverts_a_row_in_the_opposite_direction(self):
# bcb quotes foreign currencies in BRL, so BRL -> USD has to be inverted
rates = self._get_rates([_row("USD", "BRL", 5)], self.brl, self.usd)
self.assertEqual(rates, [(self.usd, self.brl, Decimal("5"))])
rates = self._get_rates([_row("USD", "BRL", 5)], self.usd, self.brl)
self.assertEqual(rates, [(self.brl, self.usd, Decimal("0.2"))])
def test_crosses_pairs_through_the_table_home_currency(self):
rows = [_row("USD", "BRL", 5), _row("EUR", "BRL", 6)]
rates = self._get_rates(rows, self.eur, self.usd)
self.assertEqual(len(rates), 1)
self.assertAlmostEqual(rates[0][2], Decimal("0.8333333"), places=6)
def test_same_currency_is_one(self):
rates = self._get_rates([_row("USD", "BRL", 5)], self.usd, self.usd)
self.assertEqual(rates, [(self.usd, self.usd, Decimal("1"))])
def test_prefers_the_published_mid_over_buy_and_sell(self):
rows = [
_row("USD", "BRL", 5.3, "buy"),
_row("USD", "BRL", 5.4, "middle"),
_row("USD", "BRL", 5.7, "sell"),
]
rates = self._get_rates(rows, self.brl, self.usd)
self.assertEqual(rates, [(self.usd, self.brl, Decimal("5.4"))])
def test_averages_buy_and_sell_without_a_mid(self):
rows = [_row("USD", "BRL", 4, "buy"), _row("USD", "BRL", 6, "sell")]
rates = self._get_rates(rows, self.brl, self.usd)
self.assertEqual(rates, [(self.usd, self.brl, Decimal("5"))])
def test_averages_tt_buy_and_sell_without_a_mid(self):
rows = [
_row("USD", "MUR", 44.0, "tt_buy"),
_row("USD", "MUR", 46.0, "tt_sell"),
]
result = self._get_rates(rows, self.usd, _FakeCurrency("MUR"))
self.assertEqual(result[0][2], Decimal("1") / Decimal("45.0"))
def test_ignores_rows_of_other_types(self):
rows = [_row("USD", "BRL", 5, "monthly_average")]
self.assertEqual(self._get_rates(rows, self.brl, self.usd), [])
def test_skips_pair_missing_from_the_table(self):
rows = [_row("USD", "BRL", 5)]
self.assertEqual(self._get_rates(rows, self.eur, self.usd), [])
def test_ignores_zero_and_malformed_rows(self):
rows = ["nope", _row("USD", "BRL", 0), _row("EUR", "BRL", 6)]
rates = self._get_rates(rows, self.brl, self.eur)
self.assertEqual(rates, [(self.eur, self.brl, Decimal("6"))])
def test_logs_a_stale_table_and_still_uses_it(self):
self.usd.exchange_currency = self.brl
payload = {
"stale": True,
"rate_date": "2026-10-01",
"rates": [_row("BRL", "USD", 0.2)],
}
with patch.object(
self.provider.session, "get", return_value=_response(payload)
):
with self.assertLogs(
"apps.currencies.exchange_rates.providers", level="WARNING"
) as logs:
rates = self.provider.get_rates([self.usd], {self.brl})
self.assertIn("2026-10-01", logs.output[0])
self.assertEqual(rates, [(self.brl, self.usd, Decimal("0.2"))])
def test_returns_nothing_on_request_error(self):
self.usd.exchange_currency = self.brl
with patch.object(
self.provider.session,
"get",
side_effect=requests.RequestException("404 Not Found"),
):
rates = self.provider.get_rates([self.usd], {self.brl})
self.assertEqual(rates, [])
def test_returns_nothing_on_malformed_body(self):
self.usd.exchange_currency = self.brl
for payload in ({}, [], {"rates": None}):
with patch.object(
self.provider.session, "get", return_value=_response(payload)
):
self.assertEqual(self.provider.get_rates([self.usd], {self.brl}), [])
def test_returns_nothing_on_non_json_body(self):
self.usd.exchange_currency = self.brl
response = _response({})
response.json.side_effect = ValueError("not json")
with patch.object(self.provider.session, "get", return_value=response):
self.assertEqual(self.provider.get_rates([self.usd], {self.brl}), [])
def test_is_registered_without_an_api_key(self):
self.assertFalse(AllRatesTodayProvider.requires_api_key())
self.assertIs(PROVIDER_MAPPING["allratestoday"], AllRatesTodayProvider)
self.assertEqual(ExchangeRateService.ServiceType.ALLRATESTODAY, "allratestoday")