mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-11-13 12:23:44 +01:00
feat(automatic-exchange-rates): add central bank tables as a provider
This commit is contained in:
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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user