Skip to content

Commit a784f14

Browse files
committed
fix(fetcher): resolve minor currencies against non-USD bases via inverse pair
Cash positions in less-traded currencies (UAH, in the reported case) were silently dropping out of every total when the portfolio used a non-USD base. Yahoo only publishes the major pairs in one direction -- ``EURUAH=X`` exists but ``UAHEUR=X`` doesn't -- so a portfolio with UAH cash and an EUR base couldn't be valued at all for that row, which rendered as ``N/A`` in the table and made the freshly-added aggregate footer ``N/A`` too. Falling back to the inverse ticker when the direct one is missing recovers the rate for every minor currency Yahoo carries against the base in either direction, without forcing every user to add USD as an intermediate hop in their YAML. The fallback only fires when the direct pass leaves something unresolved, so the common case still costs a single yfinance call. Signed-off-by: Igor Opaniuk <igor.opaniuk@gmail.com>
1 parent 176bd59 commit a784f14

2 files changed

Lines changed: 81 additions & 13 deletions

File tree

src/stonks_cli/fetcher.py

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -378,9 +378,13 @@ def fetch_forex_rates(
378378
) -> dict[str, float]:
379379
"""Return exchange rates: 1 unit of currency -> how many base units.
380380
381-
Uses yfinance forex pairs (e.g. EURUSD=X for EUR->USD).
382-
The base currency is always included as 1.0. Currencies for which
383-
no rate can be fetched are omitted from the result.
381+
Uses yfinance forex pairs (e.g. ``EURUSD=X`` for EUR->USD). When
382+
a direct ``{currency}{base}=X`` ticker isn't published by Yahoo
383+
(common for minor / less-traded currencies like ``UAH`` against
384+
non-USD bases), the inverse pair ``{base}{currency}=X`` is
385+
attempted and the rate reciprocated. The base currency is
386+
always included as 1.0. Currencies for which neither pair
387+
resolves are omitted from the result.
384388
385389
Args:
386390
currencies: ISO 4217 currency codes (e.g. ['EUR', 'GBP']).
@@ -396,17 +400,39 @@ def fetch_forex_rates(
396400
if not non_base:
397401
return rates
398402

399-
symbols = [f"{c}{base}=X" for c in non_base]
400-
close = _yf_download_close(
401-
symbols, period="1d", description="forex", auto_adjust=False
403+
# First pass: direct ``{c}{base}=X`` pairs.
404+
direct_syms = [f"{c}{base}=X" for c in non_base]
405+
direct_close = _yf_download_close(
406+
direct_syms, period="1d", description="forex", auto_adjust=False
407+
)
408+
direct = (
409+
_last_close_per_symbol(direct_close, direct_syms)
410+
if direct_close is not None
411+
else {}
402412
)
403-
if close is None:
404-
return rates
405413

406-
last = _last_close_per_symbol(close, symbols)
407-
for currency, symbol in zip(non_base, symbols):
408-
if symbol in last:
409-
rates[currency] = last[symbol]
414+
# Second pass: for each currency Yahoo didn't return directly,
415+
# try the inverse ``{base}{c}=X`` pair and reciprocate. Yahoo
416+
# publishes ``EURUAH=X`` but not ``UAHEUR=X``, for instance.
417+
missing = [c for c in non_base if f"{c}{base}=X" not in direct]
418+
inverse: dict[str, float] = {}
419+
if missing:
420+
inverse_syms = [f"{base}{c}=X" for c in missing]
421+
inverse_close = _yf_download_close(
422+
inverse_syms, period="1d", description="forex", auto_adjust=False
423+
)
424+
if inverse_close is not None:
425+
inverse = _last_close_per_symbol(inverse_close, inverse_syms)
426+
427+
for currency in non_base:
428+
direct_sym = f"{currency}{base}=X"
429+
if direct_sym in direct:
430+
rates[currency] = direct[direct_sym]
431+
continue
432+
inverse_sym = f"{base}{currency}=X"
433+
inv_rate = inverse.get(inverse_sym)
434+
if inv_rate and inv_rate != 0:
435+
rates[currency] = 1.0 / inv_rate
410436
return rates
411437

412438
def fetch_stock_detail(self, symbol: str) -> StockDetail:

tests/test_fetcher.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,12 +380,54 @@ def test_returns_base_only_on_empty_download(self, mock_dl, fetcher: PriceFetche
380380

381381
@patch("stonks_cli.fetcher.yf.download")
382382
def test_omits_currency_with_no_data(self, mock_dl, fetcher: PriceFetcher):
383-
# Only EUR returned, GBP missing from columns
383+
# Only EUR returned, GBP missing from columns -- and the inverse
384+
# ``USDGBP=X`` lookup also returns nothing, so GBP is dropped.
384385
mock_dl.return_value = _close_df({"EURUSD=X": 1.085})
385386
rates = fetcher.fetch_forex_rates(["EUR", "GBP"], base="USD")
386387
assert "EUR" in rates
387388
assert "GBP" not in rates
388389

390+
@patch("stonks_cli.fetcher.yf.download")
391+
def test_falls_back_to_inverse_pair_when_direct_missing(
392+
self, mock_dl, fetcher: PriceFetcher
393+
):
394+
# Regression: UAH had no Yahoo entry for ``UAHEUR=X`` so the rate
395+
# silently dropped, rendering ``N/A`` in the portfolio cash row
396+
# even though Yahoo did publish the inverse ``EURUAH=X``. The
397+
# fetcher now falls back to the inverse pair and reciprocates.
398+
# First call (direct ``{currency}{base}=X``) returns only EUR.
399+
# Second call (inverse ``{base}{currency}=X``) returns EURUAH=X.
400+
direct = _close_df({"USDEUR=X": 0.86})
401+
inverse = _close_df({"EURUAH=X": 51.68})
402+
mock_dl.side_effect = [direct, inverse]
403+
rates = fetcher.fetch_forex_rates(["USD", "UAH"], base="EUR")
404+
assert rates["EUR"] == 1.0
405+
assert rates["USD"] == pytest.approx(0.86)
406+
# 1 / 51.68 -- 1 UAH in EUR
407+
assert rates["UAH"] == pytest.approx(1.0 / 51.68)
408+
409+
@patch("stonks_cli.fetcher.yf.download")
410+
def test_inverse_zero_rate_treated_as_missing(self, mock_dl, fetcher: PriceFetcher):
411+
# If Yahoo were to return a bogus zero rate for the inverse pair
412+
# we'd divide by zero -- guard against that and drop the
413+
# currency rather than emit ``inf``.
414+
direct = _close_df({"USDEUR=X": 0.86})
415+
inverse = _close_df({"EURUAH=X": 0.0})
416+
mock_dl.side_effect = [direct, inverse]
417+
rates = fetcher.fetch_forex_rates(["USD", "UAH"], base="EUR")
418+
assert "UAH" not in rates
419+
420+
@patch("stonks_cli.fetcher.yf.download")
421+
def test_no_inverse_call_when_all_direct_resolved(
422+
self, mock_dl, fetcher: PriceFetcher
423+
):
424+
# When every requested currency has a direct pair, the inverse
425+
# fallback path should be skipped entirely -- no point paying
426+
# for a second yfinance call.
427+
mock_dl.return_value = _close_df({"EURUSD=X": 1.085, "GBPUSD=X": 1.27})
428+
fetcher.fetch_forex_rates(["EUR", "GBP"], base="USD")
429+
assert mock_dl.call_count == 1
430+
389431

390432
def _make_ticker(exchange_code: str | None):
391433
"""Return a mock yf.Ticker whose fast_info.exchange is *exchange_code*."""

0 commit comments

Comments
 (0)