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