From 1c3c29d3660c2ebaa78b6cae00d8f751dd278cff Mon Sep 17 00:00:00 2001 From: Robert Tidball Date: Thu, 1 Oct 2026 15:23:48 +1000 Subject: [PATCH] feat(automatic-exchange-rates): add FXMacroData as a provider --- app/apps/currencies/exchange_rates/fetcher.py | 1 + .../currencies/exchange_rates/providers.py | 63 ++++++++++++++++ ..._alter_exchangerateservice_service_type.py | 31 ++++++++ app/apps/currencies/models.py | 1 + .../tests/test_fxmacrodata_provider.py | 73 +++++++++++++++++++ 5 files changed, 169 insertions(+) create mode 100644 app/apps/currencies/migrations/0025_alter_exchangerateservice_service_type.py create mode 100644 app/apps/currencies/tests/test_fxmacrodata_provider.py diff --git a/app/apps/currencies/exchange_rates/fetcher.py b/app/apps/currencies/exchange_rates/fetcher.py index c3090a61..d70f088b 100644 --- a/app/apps/currencies/exchange_rates/fetcher.py +++ b/app/apps/currencies/exchange_rates/fetcher.py @@ -18,6 +18,7 @@ PROVIDER_MAPPING = { "twelvedata": providers.TwelveDataProvider, "twelvedatamarkets": providers.TwelveDataMarketsProvider, "yfinance": providers.YFinanceMarketsProvider, + "fxmacrodata": providers.FXMacroDataProvider, } diff --git a/app/apps/currencies/exchange_rates/providers.py b/app/apps/currencies/exchange_rates/providers.py index 33ddfe62..e9275158 100644 --- a/app/apps/currencies/exchange_rates/providers.py +++ b/app/apps/currencies/exchange_rates/providers.py @@ -582,3 +582,66 @@ class YFinanceMarketsProvider(ExchangeRateProvider): ) return results + + +class FXMacroDataProvider(ExchangeRateProvider): + """Implementation for the FXMacroData API (fxmacrodata.com)""" + + BASE_URL = "https://api.fxmacrodata.com/v1/forex" + rates_inverted = False # /forex/EUR/USD returns how many USD one EUR buys + + def __init__(self, api_key: str): + super().__init__(api_key) + self.session = requests.Session() + self.session.headers.update({"X-API-Key": api_key}) + + @classmethod + def requires_api_key(cls) -> bool: + return True + + def get_rates( + self, target_currencies: QuerySet, exchange_currencies: set + ) -> List[Tuple[Currency, Currency, Decimal]]: + 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 + + pair = f"{base_currency.code}/{target_currency.code}" + + try: + response = self.session.get(f"{self.BASE_URL}/{pair}") + response.raise_for_status() + + # val is null on dates without a fixing, so use the newest non-null row + rows = [ + row + for row in response.json().get("data", []) + if row.get("val") is not None + ] + if not rows: + logger.error(f"No rate returned for {pair} from FXMacroData") + continue + + latest = max(rows, key=lambda row: row["date"]) + rate = Decimal(str(latest["val"])) + results.append((base_currency, target_currency, rate)) + except requests.RequestException as e: + logger.error(f"Error fetching rate from FXMacroData for {pair}: {e}") + except KeyError as e: + logger.error( + f"Unexpected response structure from FXMacroData for {pair}: {e}" + ) + except Exception as e: + logger.error( + f"Unexpected error processing FXMacroData data for {pair}: {e}" + ) + + return results diff --git a/app/apps/currencies/migrations/0025_alter_exchangerateservice_service_type.py b/app/apps/currencies/migrations/0025_alter_exchangerateservice_service_type.py new file mode 100644 index 00000000..f023f350 --- /dev/null +++ b/app/apps/currencies/migrations/0025_alter_exchangerateservice_service_type.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.17 on 2026-10-01 05:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("currencies", "0024_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"), + ], + max_length=255, + verbose_name="Service Type", + ), + ), + ] diff --git a/app/apps/currencies/models.py b/app/apps/currencies/models.py index 3b0fbc5d..89e4c583 100644 --- a/app/apps/currencies/models.py +++ b/app/apps/currencies/models.py @@ -106,6 +106,7 @@ class ExchangeRateService(models.Model): TWELVEDATA = "twelvedata", "TwelveData" TWELVEDATA_MARKETS = "twelvedatamarkets", "TwelveData Markets" YFINANCE = "yfinance", "Yahoo Finance" + FXMACRODATA = "fxmacrodata", "FXMacroData" class IntervalType(models.TextChoices): ON = "on", _("On") diff --git a/app/apps/currencies/tests/test_fxmacrodata_provider.py b/app/apps/currencies/tests/test_fxmacrodata_provider.py new file mode 100644 index 00000000..d4893668 --- /dev/null +++ b/app/apps/currencies/tests/test_fxmacrodata_provider.py @@ -0,0 +1,73 @@ +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 FXMacroDataProvider +from apps.currencies.models import ExchangeRateService + + +class _FakeCurrency: + def __init__(self, code, exchange_currency=None): + self.code = code + self.exchange_currency = exchange_currency + + +def _response(payload): + response = MagicMock() + response.json.return_value = payload + return response + + +class FXMacroDataProviderTests(TestCase): + def setUp(self): + self.usd = _FakeCurrency("USD") + self.eur = _FakeCurrency("EUR", exchange_currency=self.usd) + self.provider = FXMacroDataProvider("test-key") + + def test_sends_api_key_header(self): + self.assertEqual(self.provider.session.headers["X-API-Key"], "test-key") + + def test_returns_newest_non_null_rate(self): + payload = { + "data": [ + {"date": "2026-09-28", "val": None}, + {"date": "2026-09-25", "val": 0.8542}, + {"date": "2026-09-24", "val": 0.8511}, + ] + } + + with patch.object( + self.provider.session, "get", return_value=_response(payload) + ) as mock_get: + rates = self.provider.get_rates([self.eur], {self.usd}) + + mock_get.assert_called_once_with("https://api.fxmacrodata.com/v1/forex/USD/EUR") + self.assertEqual(rates, [(self.usd, self.eur, Decimal("0.8542"))]) + + def test_skips_pair_without_rates(self): + payload = {"data": [{"date": "2026-09-28", "val": None}]} + + with patch.object( + self.provider.session, "get", return_value=_response(payload) + ): + rates = self.provider.get_rates([self.eur], {self.usd}) + + self.assertEqual(rates, []) + + def test_skips_pair_on_request_error(self): + with patch.object( + self.provider.session, + "get", + side_effect=requests.RequestException("401 Unauthorized"), + ): + rates = self.provider.get_rates([self.eur], {self.usd}) + + self.assertEqual(rates, []) + + def test_is_registered_with_an_api_key(self): + self.assertTrue(FXMacroDataProvider.requires_api_key()) + self.assertIs(PROVIDER_MAPPING["fxmacrodata"], FXMacroDataProvider) + self.assertEqual(ExchangeRateService.ServiceType.FXMACRODATA, "fxmacrodata")