Merge pull request #611 from fxmacrodata/fxmacrodata-exchange-rates

feat(automatic-exchange-rates): add FXMacroData as a provider
This commit is contained in:
Herculino Trotta authored and GitHub committed 2026-10-03 14:05:32 -03:00
commit 187e9d3148
5 files changed
+169

No files matched your search

@@ -18,6 +18,7 @@ PROVIDER_MAPPING = {
"twelvedata": providers.TwelveDataProvider,
"twelvedatamarkets": providers.TwelveDataMarketsProvider,
"yfinance": providers.YFinanceMarketsProvider,
"fxmacrodata": providers.FXMacroDataProvider,
}
@@ -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
@@ -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",
),
),
]
+1
View File
@@ -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")
@@ -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")