Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 70 additions & 31 deletions tests/test_ticker.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,37 +357,76 @@ def test_download(self):
else:
self.assertTrue((~((hours >= 7) & (hours <= 19))).any())

# Hopefully one day we find an equivalent "requests_cache" that works with "curl_cffi"
# def test_no_expensive_calls_introduced(self):
# """
# Make sure calling history to get price data has not introduced more calls to yahoo than absolutely necessary.
# As doing other type of scraping calls than "query2.finance.yahoo.com/v8/finance/chart" to yahoo website
# will quickly trigger spam-block when doing bulk download of history data.
# """
# symbol = "GOOGL"
# period = "1y"
# with requests_cache.CachedSession(backend="memory") as session:
# ticker = yf.Ticker(symbol, session=session)
# ticker.history(period=period)
# actual_urls_called = [r.url for r in session.cache.filter()]

# # Remove 'crumb' argument
# for i in range(len(actual_urls_called)):
# u = actual_urls_called[i]
# parsed_url = urlparse(u)
# query_params = parse_qs(parsed_url.query)
# query_params.pop('crumb', None)
# query_params.pop('cookie', None)
# u = urlunparse(parsed_url._replace(query=urlencode(query_params, doseq=True)))
# actual_urls_called[i] = u
# actual_urls_called = tuple(actual_urls_called)

# expected_urls = [
# f"https://query2.finance.yahoo.com/v8/finance/chart/{symbol}?interval=1d&range=1d", # ticker's tz
# f"https://query2.finance.yahoo.com/v8/finance/chart/{symbol}?events=div%2Csplits%2CcapitalGains&includePrePost=False&interval=1d&range={period}"
# ]
# for url in actual_urls_called:
# self.assertTrue(url in expected_urls, f"Unexpected URL called: {url}")
@staticmethod
def _mock_history_response(symbol):
payload = {
"chart": {
"result": [{
"meta": {
"currency": "USD",
"symbol": symbol,
"instrumentType": "EQUITY",
"exchangeTimezoneName": "America/New_York",
"regularMarketPrice": 102.0,
"validRanges": ["1d", "5d", "1mo", "1y"],
},
"timestamp": [1710163800, 1710250200],
"indicators": {
"quote": [{
"open": [100.0, 101.0],
"high": [103.0, 104.0],
"low": [99.0, 100.0],
"close": [102.0, 103.0],
"volume": [1000, 1100],
}],
"adjclose": [{"adjclose": [102.0, 103.0]}],
},
}],
"error": None,
}
}
response = MagicMock()
response.text = json.dumps(payload)
response.json.return_value = payload
return response

def test_range_history_makes_single_request(self):
"""A range request should get the ticker timezone from its chart response."""
symbol = "MOCK-RANGE"
response = self._mock_history_response(symbol)
tz_cache = MagicMock()

with patch("yfinance.cache.get_tz_cache", return_value=tz_cache), \
patch("yfinance.data.YfData.get", return_value=response) as mock_get, \
patch("yfinance.data.YfData.cache_get", return_value=response) as mock_cache_get:
ticker = yf.Ticker(symbol)
data = ticker.history(period="1y")

self.assertFalse(data.empty)
mock_get.assert_called_once()
mock_cache_get.assert_not_called()
self.assertEqual(mock_get.call_args.kwargs["params"]["range"], "1y")
self.assertEqual(ticker._tz, "America/New_York")
tz_cache.store.assert_called_once_with(symbol, "America/New_York")

def test_date_history_fetches_timezone_before_request(self):
"""Explicit dates need the exchange timezone before conversion to epochs."""
symbol = "MOCK-DATES"
response = self._mock_history_response(symbol)
tz_cache = MagicMock()
tz_cache.lookup.return_value = None
ticker = yf.Ticker(symbol)

with patch("yfinance.cache.get_tz_cache", return_value=tz_cache), \
patch.object(ticker, "_fetch_ticker_tz", return_value="America/New_York") as mock_fetch_tz, \
patch("yfinance.data.YfData.cache_get", return_value=response) as mock_cache_get:
data = ticker.history(start="2024-03-11", end="2024-03-13")

self.assertFalse(data.empty)
mock_fetch_tz.assert_called_once_with(10)
mock_cache_get.assert_called_once()
tz_cache.lookup.assert_called_once_with(symbol)
tz_cache.store.assert_called_once_with(symbol, "America/New_York")

def test_dividends(self):
data = self.ticker.dividends
Expand Down
18 changes: 16 additions & 2 deletions yfinance/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,23 @@ def history(self, *args, **kwargs) -> pd.DataFrame:

def _lazy_load_price_history(self):
if self._price_history is None:
self._price_history = PriceHistory(self._data, self.ticker, self._get_ticker_tz(timeout=10))
# Range-based chart responses include the timezone, so defer
# fetching it until PriceHistory knows the request needs it.
self._price_history = PriceHistory(
self._data,
self.ticker,
self._tz,
tz_getter=self._get_ticker_tz,
tz_setter=self._set_ticker_tz,
)
return self._price_history

def _set_ticker_tz(self, tz):
if not utils.is_valid_timezone(tz):
return
cache.get_tz_cache().store(self.ticker, tz)
self._tz = tz

def _get_ticker_tz(self, timeout):
if self._tz is not None:
return self._tz
Expand All @@ -161,7 +175,7 @@ def _get_ticker_tz(self, timeout):
tz = self.info[k]
break
if utils.is_valid_timezone(tz):
c.store(self.ticker, tz)
self._set_ticker_tz(tz)
else:
tz = None

Expand Down
22 changes: 19 additions & 3 deletions yfinance/scrapers/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@
from yfinance.exceptions import YFDataException, YFInvalidPeriodError, YFPricesMissingError, YFRateLimitError, YFTzMissingError

class PriceHistory:
def __init__(self, data, ticker, tz, session=None):
def __init__(self, data, ticker, tz, session=None, tz_getter=None, tz_setter=None):
self._data = data
self.ticker = ticker.upper()
self.tz = tz
self._tz_getter = tz_getter
self._tz_setter = tz_setter
self.session = session or new_session()

self._history_cache = {}
Expand All @@ -34,6 +36,18 @@ def __init__(self, data, ticker, tz, session=None):

self._last_error = None

def _get_tz(self):
if self.tz is None and self._tz_getter is not None:
self.tz = self._tz_getter(timeout=10)
return self.tz

def _set_tz(self, tz):
if not utils.is_valid_timezone(tz):
return
self.tz = tz
if self._tz_setter is not None:
self._tz_setter(tz)

@utils.log_indent_decorator
def history(self, period=period_default, interval="1d",
start=None, end=None, prepost=False, actions=True,
Expand Down Expand Up @@ -102,7 +116,7 @@ def history(self, period=period_default, interval="1d",
raise ValueError("Yahoo's interval '5d' is nonsense, not supported with repair")
if start is None and end is None and period is not None:
# Convert period to start -> end
tz = self.tz
tz = self._get_tz()
if tz is None:
# Every valid ticker has a timezone. A missing timezone is a problem.
_exception = YFTzMissingError(self.ticker)
Expand All @@ -127,7 +141,7 @@ def history(self, period=period_default, interval="1d",
end_user = end
if start or end or (period and period.lower() == "max"):
# Check can get TZ. Fail => probably delisted
tz = self.tz
tz = self._get_tz()
if tz is None:
# Every valid ticker has a timezone. A missing timezone is a problem.
_exception = YFTzMissingError(self.ticker)
Expand Down Expand Up @@ -246,6 +260,8 @@ def history(self, period=period_default, interval="1d",

self._history_metadata = meta
self._history_metadata['YF repair?'] = repair
if self.tz is None:
self._set_tz(meta.get("exchangeTimezoneName"))

intraday = params["interval"][-1] in ("m", 'h')
_price_data_debug = ''
Expand Down