import logging import time import requests from decimal import Decimal from typing import Tuple, List, Optional, Dict from django.db.models import QuerySet from apps.currencies.models import Currency, ExchangeRate from apps.currencies.exchange_rates.base import ExchangeRateProvider logger = logging.getLogger(__name__) class SynthFinanceProvider(ExchangeRateProvider): """Implementation for Synth Finance API (synthfinance.com)""" BASE_URL = "https://api.synthfinance.com/rates/live" rates_inverted = False # SynthFinance returns non-inverted rates def __init__(self, api_key: str = None): super().__init__(api_key) self.session = requests.Session() self.session.headers.update({"Authorization": f"Bearer {self.api_key}"}) def get_rates( self, target_currencies: QuerySet, exchange_currencies: set ) -> List[Tuple[Currency, Currency, Decimal]]: results = [] currency_groups = {} for currency in target_currencies: if currency.exchange_currency in exchange_currencies: group = currency_groups.setdefault(currency.exchange_currency.code, []) group.append(currency) for base_currency, currencies in currency_groups.items(): try: to_currencies = ",".join( currency.code for currency in currencies if currency.code != base_currency ) response = self.session.get( f"{self.BASE_URL}", params={"from": base_currency, "to": to_currencies}, ) response.raise_for_status() data = response.json() rates = data["data"]["rates"] for currency in currencies: if currency.code == base_currency: rate = Decimal("1") else: rate = Decimal(str(rates[currency.code])) # Return the rate as is, without inversion results.append((currency.exchange_currency, currency, rate)) credits_used = data["meta"]["credits_used"] credits_remaining = data["meta"]["credits_remaining"] logger.info( f"Synth Finance API call: {credits_used} credits used, {credits_remaining} remaining" ) except requests.RequestException as e: logger.error( f"Error fetching rates from Synth Finance API for base {base_currency}: {e}" ) except KeyError as e: logger.error( f"Unexpected response structure from Synth Finance API for base {base_currency}: {e}" ) except Exception as e: logger.error( f"Unexpected error processing Synth Finance data for base {base_currency}: {e}" ) return results class CoinGeckoFreeProvider(ExchangeRateProvider): """Implementation for CoinGecko Free API""" BASE_URL = "https://api.coingecko.com/api/v3" rates_inverted = True def __init__(self, api_key: str): super().__init__(api_key) self.session = requests.Session() self.session.headers.update({"x-cg-demo-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 = [] all_currencies = set(currency.code.lower() for currency in target_currencies) all_currencies.update(currency.code.lower() for currency in exchange_currencies) try: response = self.session.get( f"{self.BASE_URL}/simple/price", params={ "ids": ",".join(all_currencies), "vs_currencies": ",".join(all_currencies), }, ) response.raise_for_status() rates_data = response.json() for target_currency in target_currencies: if target_currency.exchange_currency in exchange_currencies: try: rate = Decimal( str( rates_data[target_currency.code.lower()][ target_currency.exchange_currency.code.lower() ] ) ) # The rate is already inverted, so we don't need to invert it again results.append( (target_currency.exchange_currency, target_currency, rate) ) except KeyError: logger.error( f"Rate not found for {target_currency.code} or {target_currency.exchange_currency.code}" ) except Exception as e: logger.error( f"Error calculating rate for {target_currency.code}: {e}" ) time.sleep(1) # CoinGecko allows 10-30 calls/minute for free tier except requests.RequestException as e: logger.error(f"Error fetching rates from CoinGecko API: {e}") return results class CoinGeckoProProvider(CoinGeckoFreeProvider): """Implementation for CoinGecko Pro API""" BASE_URL = "https://pro-api.coingecko.com/api/v3/simple/price" rates_inverted = True def __init__(self, api_key: str): super().__init__(api_key) self.session = requests.Session() self.session.headers.update({"x-cg-pro-api-key": api_key}) class SynthFinanceStockProvider(ExchangeRateProvider): """Implementation for Synth Finance API Real-Time Prices endpoint (synthfinance.com)""" BASE_URL = "https://api.synthfinance.com/tickers" rates_inverted = True def __init__(self, api_key: str = None): super().__init__(api_key) self.session = requests.Session() self.session.headers.update( {"Authorization": f"Bearer {self.api_key}", "accept": "application/json"} ) def get_rates( self, target_currencies: QuerySet, exchange_currencies: set ) -> List[Tuple[Currency, Currency, Decimal]]: results = [] for currency in target_currencies: if currency.exchange_currency not in exchange_currencies: continue try: # Same currency has rate of 1 if currency.code == currency.exchange_currency.code: rate = Decimal("1") results.append((currency.exchange_currency, currency, rate)) continue # Fetch real-time price for this ticker response = self.session.get( f"{self.BASE_URL}/{currency.code}/real-time" ) response.raise_for_status() data = response.json() # Use fair market value as the rate rate = Decimal(data["data"]["fair_market_value"]) results.append((currency.exchange_currency, currency, rate)) # Log API usage credits_used = data["meta"]["credits_used"] credits_remaining = data["meta"]["credits_remaining"] logger.info( f"Synth Finance API call for {currency.code}: {credits_used} credits used, {credits_remaining} remaining" ) except requests.RequestException as e: logger.error( f"Error fetching rate from Synth Finance API for ticker {currency.code}: {e}", exc_info=True, ) except KeyError as e: logger.error( f"Unexpected response structure from Synth Finance API for ticker {currency.code}: {e}", exc_info=True, ) except Exception as e: logger.error( f"Unexpected error processing Synth Finance data for ticker {currency.code}: {e}", exc_info=True, ) return results class TransitiveRateProvider(ExchangeRateProvider): """Calculates exchange rates through paths of existing rates""" rates_inverted = True def __init__(self, api_key: str = None): super().__init__(api_key) # API key not needed but maintaining interface @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 = [] # Get recent rates for building the graph recent_rates = ExchangeRate.objects.all() # Build currency graph currency_graph = self._build_currency_graph(recent_rates) for target in target_currencies: if ( not target.exchange_currency or target.exchange_currency not in exchange_currencies ): continue # Find path and calculate rate from_id = target.exchange_currency.id to_id = target.id path, rate = self._find_conversion_path(currency_graph, from_id, to_id) if path and rate: path_codes = [Currency.objects.get(id=cid).code for cid in path] logger.info( f"Found conversion path: {' -> '.join(path_codes)}, rate: {rate}" ) results.append((target.exchange_currency, target, rate)) else: logger.debug( f"No conversion path found for {target.exchange_currency.code}->{target.code}" ) return results @staticmethod def _build_currency_graph(rates) -> Dict[int, Dict[int, Decimal]]: """Build a graph representation of currency relationships""" graph = {} for rate in rates: # Add both directions to make the graph bidirectional if rate.from_currency_id not in graph: graph[rate.from_currency_id] = {} graph[rate.from_currency_id][rate.to_currency_id] = rate.rate if rate.to_currency_id not in graph: graph[rate.to_currency_id] = {} graph[rate.to_currency_id][rate.from_currency_id] = Decimal("1") / rate.rate return graph @staticmethod def _find_conversion_path( graph, from_id, to_id ) -> Tuple[Optional[list], Optional[Decimal]]: """Find the shortest path between currencies using breadth-first search""" if from_id not in graph or to_id not in graph: return None, None queue = [(from_id, [from_id], Decimal("1"))] visited = {from_id} while queue: current, path, current_rate = queue.pop(0) if current == to_id: return path, current_rate for neighbor, rate in graph.get(current, {}).items(): if neighbor not in visited: visited.add(neighbor) queue.append((neighbor, path + [neighbor], current_rate * rate)) return None, None