diff --git a/app/apps/currencies/exchange_rates/fetcher.py b/app/apps/currencies/exchange_rates/fetcher.py index 382dc98..6784209 100644 --- a/app/apps/currencies/exchange_rates/fetcher.py +++ b/app/apps/currencies/exchange_rates/fetcher.py @@ -4,13 +4,7 @@ from datetime import timedelta from django.db.models import QuerySet from django.utils import timezone -from apps.currencies.exchange_rates.providers import ( - SynthFinanceProvider, - SynthFinanceStockProvider, - CoinGeckoFreeProvider, - CoinGeckoProProvider, - TransitiveRateProvider, -) +import apps.currencies.exchange_rates.providers as providers from apps.currencies.models import ExchangeRateService, ExchangeRate, Currency logger = logging.getLogger(__name__) @@ -18,11 +12,12 @@ logger = logging.getLogger(__name__) # Map service types to provider classes PROVIDER_MAPPING = { - "synth_finance": SynthFinanceProvider, - "synth_finance_stock": SynthFinanceStockProvider, - "coingecko_free": CoinGeckoFreeProvider, - "coingecko_pro": CoinGeckoProProvider, - "transitive": TransitiveRateProvider, + "synth_finance": providers.SynthFinanceProvider, + "synth_finance_stock": providers.SynthFinanceStockProvider, + "coingecko_free": providers.CoinGeckoFreeProvider, + "coingecko_pro": providers.CoinGeckoProProvider, + "transitive": providers.TransitiveRateProvider, + "frankfurter": providers.FrankfurterProvider, } diff --git a/app/apps/currencies/exchange_rates/providers.py b/app/apps/currencies/exchange_rates/providers.py index 5cdd69a..36e4ea9 100644 --- a/app/apps/currencies/exchange_rates/providers.py +++ b/app/apps/currencies/exchange_rates/providers.py @@ -306,3 +306,87 @@ class TransitiveRateProvider(ExchangeRateProvider): queue.append((neighbor, path + [neighbor], current_rate * rate)) return None, None + + +class FrankfurterProvider(ExchangeRateProvider): + """Implementation for the Frankfurter API (frankfurter.dev)""" + + BASE_URL = "https://api.frankfurter.dev/v1/latest" + rates_inverted = ( + False # Frankfurter returns non-inverted rates (e.g., 1 EUR = 1.1 USD) + ) + + def __init__(self, api_key: str = None): + """ + Initializes the provider. The Frankfurter API does not require an API key, + so the api_key parameter is ignored. + """ + super().__init__(api_key) + self.session = requests.Session() + + @classmethod + def requires_api_key(cls) -> bool: + return False + + def get_rates( + self, target_currencies: QuerySet, exchange_currencies: set + ) -> List[Tuple[Currency, Currency, Decimal]]: + results = [] + currency_groups = {} + # Group target currencies by their exchange (base) currency to minimize API calls + for currency in target_currencies: + if currency.exchange_currency in exchange_currencies: + group = currency_groups.setdefault(currency.exchange_currency.code, []) + group.append(currency) + + # Make one API call for each base currency + for base_currency, currencies in currency_groups.items(): + try: + # Create a comma-separated list of target currency codes + to_currencies = ",".join( + currency.code + for currency in currencies + if currency.code != base_currency + ) + + # If there are no target currencies other than the base, skip the API call + if not to_currencies: + # Handle the case where the only request is for the base rate (e.g., USD to USD) + for currency in currencies: + if currency.code == base_currency: + results.append( + (currency.exchange_currency, currency, Decimal("1")) + ) + continue + + response = self.session.get( + self.BASE_URL, + params={"base": base_currency, "symbols": to_currencies}, + ) + response.raise_for_status() + data = response.json() + rates = data["rates"] + + # Process the returned rates + for currency in currencies: + if currency.code == base_currency: + # The rate for the base currency to itself is always 1 + rate = Decimal("1") + else: + rate = Decimal(str(rates[currency.code])) + + results.append((currency.exchange_currency, currency, rate)) + + except requests.RequestException as e: + logger.error( + f"Error fetching rates from Frankfurter API for base {base_currency}: {e}" + ) + except KeyError as e: + logger.error( + f"Unexpected response structure from Frankfurter API for base {base_currency}: {e}" + ) + except Exception as e: + logger.error( + f"Unexpected error processing Frankfurter data for base {base_currency}: {e}" + ) + return results diff --git a/app/apps/currencies/migrations/0017_alter_exchangerateservice_service_type.py b/app/apps/currencies/migrations/0017_alter_exchangerateservice_service_type.py new file mode 100644 index 0000000..5a4d2f3 --- /dev/null +++ b/app/apps/currencies/migrations/0017_alter_exchangerateservice_service_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.5 on 2025-08-16 22:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('currencies', '0016_alter_exchangerate_automatic'), + ] + + operations = [ + migrations.AlterField( + model_name='exchangerateservice', + name='service_type', + field=models.CharField(choices=[('synth_finance', 'Synth Finance'), ('synth_finance_stock', 'Synth Finance Stock'), ('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)'), ('transitive', 'Transitive (Calculated from Existing Rates)'), ('frankfurter', 'Frankfurter')], max_length=255, verbose_name='Service Type'), + ), + ] diff --git a/app/apps/currencies/models.py b/app/apps/currencies/models.py index ededa5e..95867d8 100644 --- a/app/apps/currencies/models.py +++ b/app/apps/currencies/models.py @@ -99,6 +99,7 @@ class ExchangeRateService(models.Model): COINGECKO_FREE = "coingecko_free", "CoinGecko (Demo/Free)" COINGECKO_PRO = "coingecko_pro", "CoinGecko (Pro)" TRANSITIVE = "transitive", "Transitive (Calculated from Existing Rates)" + FRANKFURTER = "frankfurter", "Frankfurter" class IntervalType(models.TextChoices): ON = "on", _("On")