feat(automatic-exchange-rates): Add Yahoo Finance as a provider via yfinance

This commit is contained in:
Herculino Trotta
2026-07-18 15:42:51 -03:00
parent 75f23168ab
commit 743951a862
7 changed files with 684 additions and 0 deletions
@@ -17,6 +17,7 @@ PROVIDER_MAPPING = {
"frankfurter": providers.FrankfurterProvider,
"twelvedata": providers.TwelveDataProvider,
"twelvedatamarkets": providers.TwelveDataMarketsProvider,
"yfinance": providers.YFinanceMarketsProvider,
}
@@ -503,3 +503,82 @@ class TwelveDataMarketsProvider(ExchangeRateProvider):
)
return results
class YFinanceMarketsProvider(ExchangeRateProvider):
"""Fetch market prices for Yahoo Finance symbols using yfinance.
Currency codes are passed to Yahoo Finance verbatim. For example, use
``PETR4.SA`` for Petrobras on B3 or ``AAPL`` for Apple. The configured
exchange currency is treated as the currency of the Yahoo quote.
"""
rates_inverted = True
def __init__(self, api_key: str = None, ticker_factory=None):
super().__init__(api_key)
self._ticker_factory = ticker_factory
@classmethod
def requires_api_key(cls) -> bool:
return False
def _get_ticker_factory(self):
if self._ticker_factory is None:
try:
import yfinance as yf
except ImportError as exc:
raise RuntimeError(
"The yfinance package is required for the Yahoo Finance provider."
) from exc
self._ticker_factory = yf.Ticker
return self._ticker_factory
def get_rates(
self, target_currencies: QuerySet, exchange_currencies: set
) -> List[Tuple[Currency, Currency, Decimal]]:
results = []
ticker_factory = self._get_ticker_factory()
for asset in target_currencies:
exchange_currency = asset.exchange_currency
if exchange_currency not in exchange_currencies:
continue
try:
history = ticker_factory(asset.code).history(
period="5d", interval="1h", auto_adjust=False
)
if history is None or history.empty:
logger.warning(
"YFinanceMarkets: no history returned for %s", asset.code
)
continue
try:
latest_close = history["Close"].dropna().iloc[-1]
except (IndexError, KeyError, TypeError):
logger.warning(
"YFinanceMarkets: no close price returned for %s", asset.code
)
continue
rate = Decimal(str(latest_close))
if not rate.is_finite() or rate <= 0:
logger.warning(
"YFinanceMarkets: invalid close price %r for %s",
latest_close,
asset.code,
)
continue
results.append((exchange_currency, asset, rate))
except Exception as exc:
logger.error(
"YFinanceMarkets: error fetching %s: %s", asset.code, exc
)
return results
@@ -0,0 +1,30 @@
# Generated by Django 5.2.15 on 2026-07-18 17:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("currencies", "0023_add_failure_count"),
]
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"),
],
max_length=255,
verbose_name="Service Type",
),
),
]
+1
View File
@@ -105,6 +105,7 @@ class ExchangeRateService(models.Model):
FRANKFURTER = "frankfurter", "Frankfurter"
TWELVEDATA = "twelvedata", "TwelveData"
TWELVEDATA_MARKETS = "twelvedatamarkets", "TwelveData Markets"
YFINANCE = "yfinance", "Yahoo Finance"
class IntervalType(models.TextChoices):
ON = "on", _("On")
@@ -0,0 +1,93 @@
from decimal import Decimal
from unittest import TestCase
from apps.currencies.exchange_rates.fetcher import PROVIDER_MAPPING
from apps.currencies.exchange_rates.providers import YFinanceMarketsProvider
from apps.currencies.models import ExchangeRateService
class _FakeSeries:
def __init__(self, values):
self._values = values
def dropna(self):
return _FakeSeries([value for value in self._values if value is not None])
@property
def iloc(self):
return self
def __getitem__(self, index):
return self._values[index]
class _FakeHistory:
def __init__(self, close_values):
self._close_values = close_values
self.empty = not close_values
def __getitem__(self, field):
if field != "Close":
raise KeyError(field)
return _FakeSeries(self._close_values)
class _FakeCurrency:
def __init__(self, code, exchange_currency=None):
self.code = code
self.exchange_currency = exchange_currency
class _FakeTicker:
def __init__(self, history):
self.history_result = history
self.history_kwargs = None
def history(self, **kwargs):
self.history_kwargs = kwargs
return self.history_result
class YFinanceMarketsProviderTests(TestCase):
def setUp(self):
self.brl = _FakeCurrency("BRL")
self.asset = _FakeCurrency("AAPL", exchange_currency=self.brl)
def test_returns_latest_hourly_close_using_symbol_verbatim(self):
ticker = _FakeTicker(_FakeHistory([36.90, None, 37.42]))
requested_symbols = []
def ticker_factory(symbol):
requested_symbols.append(symbol)
return ticker
provider = YFinanceMarketsProvider(ticker_factory=ticker_factory)
rates = provider.get_rates([self.asset], {self.brl})
self.assertEqual(rates, [(self.brl, self.asset, Decimal("37.42"))])
self.assertEqual(requested_symbols, ["AAPL"])
self.assertEqual(
ticker.history_kwargs,
{"period": "5d", "interval": "1h", "auto_adjust": False},
)
def test_passes_brazilian_symbol_verbatim_and_skips_empty_history(self):
self.asset.code = "PETR4.SA"
ticker = _FakeTicker(_FakeHistory([]))
requested_symbols = []
provider = YFinanceMarketsProvider(
ticker_factory=lambda symbol: requested_symbols.append(symbol)
or ticker
)
rates = provider.get_rates([self.asset], {self.brl})
self.assertEqual(rates, [])
self.assertEqual(requested_symbols, ["PETR4.SA"])
def test_is_registered_without_an_api_key(self):
self.assertFalse(YFinanceMarketsProvider.requires_api_key())
self.assertIs(PROVIDER_MAPPING["yfinance"], YFinanceMarketsProvider)
self.assertEqual(ExchangeRateService.ServiceType.YFINANCE, "yfinance")