Validate Market region and stop returning misleading status data
Yahoo's markettime endpoint silently ignores the `market` parameter and
always returns U.S. data, so `Market("EUROPE").status` previously returned
U.S. status while pretending to be European. The summary endpoint does
honor the parameter and is unaffected.
- Introduce `MarketRegion(str, Enum)` as the single source of truth for
the supported region values, exposed as `yfinance.MarketRegion`.
- Validate `Market(...)` input against the enum and raise `ValueError`
on unknown regions.
- Detect the markettime mismatch: when a non-`US` region is requested
but the response id is `us`, set `status` to `None` and log a warning
rather than returning misleading data.
- Document the markettime limitation and add tests covering validation
and the EUROPE/US fetch behavior.
Closes #2784
This commit is contained in:
@@ -38,4 +38,10 @@ There are 8 different markets available in Yahoo Finance.
|
||||
* RATES
|
||||
* COMMODITIES
|
||||
* CURRENCIES
|
||||
* CRYPTOCURRENCIES
|
||||
* CRYPTOCURRENCIES
|
||||
|
||||
.. note::
|
||||
Only `Market.summary` returns regional data for all of the values above.
|
||||
`Market.status` is backed by Yahoo's `markettime` endpoint, which currently
|
||||
ignores the `market` parameter and only returns U.S. data; for any non-`US`
|
||||
market, `status` will therefore be `None` and a warning is logged.
|
||||
@@ -0,0 +1,56 @@
|
||||
import unittest
|
||||
|
||||
from tests.context import yfinance as yf
|
||||
|
||||
|
||||
class TestMarketValidation(unittest.TestCase):
|
||||
def test_string_input(self):
|
||||
m = yf.Market("EUROPE")
|
||||
self.assertEqual(m.market, "EUROPE")
|
||||
|
||||
def test_enum_input(self):
|
||||
m = yf.Market(yf.MarketRegion.EUROPE)
|
||||
self.assertEqual(m.market, "EUROPE")
|
||||
self.assertIsInstance(m.market, str)
|
||||
|
||||
def test_invalid_market_raises(self):
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
yf.Market("FR")
|
||||
self.assertIn("FR", str(ctx.exception))
|
||||
self.assertIn("EUROPE", str(ctx.exception))
|
||||
|
||||
def test_market_region_members(self):
|
||||
expected = {
|
||||
"US", "GB", "ASIA", "EUROPE",
|
||||
"RATES", "COMMODITIES", "CURRENCIES", "CRYPTOCURRENCIES",
|
||||
}
|
||||
self.assertEqual({m.value for m in yf.MarketRegion}, expected)
|
||||
|
||||
def test_market_region_str_equality(self):
|
||||
self.assertEqual(yf.MarketRegion.EUROPE, "EUROPE")
|
||||
|
||||
|
||||
class TestMarketFetch(unittest.TestCase):
|
||||
def test_us_summary_and_status(self):
|
||||
m = yf.Market("US")
|
||||
self.assertIsInstance(m.summary, dict)
|
||||
self.assertGreater(len(m.summary), 0)
|
||||
self.assertIsNotNone(m.status)
|
||||
self.assertEqual(m.status.get("id"), "us")
|
||||
|
||||
def test_europe_summary_returns_regional_exchanges(self):
|
||||
m = yf.Market("EUROPE")
|
||||
self.assertIsInstance(m.summary, dict)
|
||||
# EUROPE summary should not be dominated by U.S. exchanges
|
||||
self.assertGreater(len(m.summary), 0)
|
||||
self.assertNotIn("SNP", m.summary)
|
||||
|
||||
def test_non_us_status_is_none(self):
|
||||
# Yahoo's markettime endpoint silently ignores `market` and returns
|
||||
# U.S. data; Market should surface this as None rather than mislead.
|
||||
m = yf.Market("EUROPE")
|
||||
self.assertIsNone(m.status)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -31,7 +31,7 @@ from .utils import enable_debug_mode
|
||||
from .cache import set_tz_cache_location
|
||||
from .domain.sector import Sector
|
||||
from .domain.industry import Industry
|
||||
from .domain.market import Market
|
||||
from .domain.market import Market, MarketRegion
|
||||
from .config import YfConfig as config
|
||||
from .data import Auth
|
||||
|
||||
@@ -44,7 +44,7 @@ __author__ = "Ran Aroussi"
|
||||
import warnings
|
||||
warnings.filterwarnings('default', category=DeprecationWarning, module='^yfinance')
|
||||
|
||||
__all__ = ['download', 'Market', 'Search', 'Lookup', 'Ticker', 'Tickers', 'enable_debug_mode', 'set_tz_cache_location',
|
||||
__all__ = ['download', 'Market', 'MarketRegion', 'Search', 'Lookup', 'Ticker', 'Tickers', 'enable_debug_mode', 'set_tz_cache_location',
|
||||
'Sector', 'Industry', 'WebSocket', 'AsyncWebSocket', 'Calendars', 'Auth']
|
||||
# screener stuff:
|
||||
__all__ += ['EquityQuery', 'FundQuery', 'ETFQuery', 'screen', 'PREDEFINED_SCREENER_QUERIES']
|
||||
|
||||
@@ -1,14 +1,39 @@
|
||||
import datetime as dt
|
||||
import json as _json
|
||||
from enum import Enum
|
||||
|
||||
from ..config import YfConfig
|
||||
from ..const import _QUERY1_URL_
|
||||
from ..data import utils, YfData
|
||||
from ..exceptions import YFDataException
|
||||
|
||||
|
||||
class MarketRegion(str, Enum):
|
||||
"""Market regions accepted by Yahoo's ``quote/marketSummary`` endpoint.
|
||||
|
||||
Members are plain strings, so ``MarketRegion.EUROPE == "EUROPE"`` and
|
||||
``Market("EUROPE")`` continues to work. Pass an enum member for IDE
|
||||
autocomplete and static checking: ``Market(MarketRegion.EUROPE)``.
|
||||
"""
|
||||
US = "US"
|
||||
GB = "GB"
|
||||
ASIA = "ASIA"
|
||||
EUROPE = "EUROPE"
|
||||
RATES = "RATES"
|
||||
COMMODITIES = "COMMODITIES"
|
||||
CURRENCIES = "CURRENCIES"
|
||||
CRYPTOCURRENCIES = "CRYPTOCURRENCIES"
|
||||
|
||||
|
||||
class Market:
|
||||
def __init__(self, market:'str', session=None, timeout=30):
|
||||
self.market = market
|
||||
def __init__(self, market, session=None, timeout=30):
|
||||
try:
|
||||
self.market = MarketRegion(market).value
|
||||
except ValueError:
|
||||
valid = [m.value for m in MarketRegion]
|
||||
raise ValueError(
|
||||
f"Unknown market {market!r}. Valid markets: {valid}"
|
||||
) from None
|
||||
self.session = session
|
||||
self.timeout = timeout
|
||||
|
||||
@@ -75,11 +100,22 @@ class Market:
|
||||
self._status = self._status['finance']['marketTimes'][0]['marketTime'][0]
|
||||
self._status['timezone'] = self._status['timezone'][0]
|
||||
del self._status['time'] # redundant
|
||||
# Yahoo's markettime endpoint silently ignores the `market` param
|
||||
# and always returns U.S. data. Detect the mismatch so callers
|
||||
# aren't misled into believing they got regional status data.
|
||||
if self.market != "US" and self._status.get("id") == "us":
|
||||
self._logger.warning(
|
||||
f"{self.market}: Yahoo markettime endpoint does not support "
|
||||
f"market={self.market!r}; status data unavailable."
|
||||
)
|
||||
self._status = None
|
||||
except Exception as e:
|
||||
if not YfConfig.debug.hide_exceptions:
|
||||
raise
|
||||
self._logger.error(f"{self.market}: Failed to parse market status")
|
||||
self._logger.debug(f"{type(e)}: {e}")
|
||||
if self._status is None:
|
||||
return
|
||||
try:
|
||||
self._status.update({
|
||||
"open": dt.datetime.fromisoformat(self._status["open"]),
|
||||
|
||||
Reference in New Issue
Block a user