Merge pull request #2828 from ranaroussi/dev

sync dev -> main
This commit is contained in:
ValueRaider
2026-05-23 17:17:48 +01:00
committed by GitHub
43 changed files with 1102 additions and 278 deletions
+25
View File
@@ -0,0 +1,25 @@
# Changelog
## Unreleased
- Fix tests (54bef21)
- Merge pull request #2825 from ranaroussi/fix/download-tz ([#2825]()) (6ab79ed)
- Merge branch 'dev' into fix/download-tz (acd2c28)
- Fix localized intraday download() always returning UTC (7538c1f)
- Merge pull request #2802 from dokson/feature/optional-curl-cffi ([#2802]()) (9ca1c55)
- Make curl_cffi optional with graceful fallback to requests (5d10c0c)
- Merge pull request #2827 from ranaroussi/main ([#2827]()) (628a417)
- Merge pull request #2821 from dokson/feature/drop-frozendict-dep ([#2821]()) (0c36060)
- Drop frozendict hard dependency in favour of an internal fallback (593b47c)
- Merge pull request #2752 from ranaroussi/dependabot/github_actions/astral-sh/ruff-action-4.0.0 ([#2752]()) (a766e8f)
- Merge pull request #2754 from ranaroussi/dependabot/github_actions/zizmorcore/zizmor-action-0.5.3 ([#2754]()) (75ed996)
- Merge pull request #2753 from ranaroussi/dependabot/github_actions/actions/github-script-9.0.0 ([#2753]()) (803893a)
- Merge pull request #2789 from gabrielchangamire-arch/fix-market-api-docs-reference-link ([#2789]()) (a497d4f)
- Fix Market API docs reference link (38ad379)
- Dependabot: disable no-reason version updates (4c544ca)
- Version 1.3.0 (a17e8b1)
- Merge pull request #2757 from ranaroussi/dev ([#2757]()) (d6afeeb)
- Bump zizmorcore/zizmor-action from 0.5.2 to 0.5.3 (6cebd23)
- Bump actions/github-script from 8.0.0 to 9.0.0 (a07bf32)
- Bump astral-sh/ruff-action from 3.6.1 to 4.0.0 (cb9884c)
+2
View File
@@ -53,6 +53,8 @@ Install `yfinance` from PYPI using `pip`:
$ pip install yfinance
```
To install without `curl_cffi` for requests fallback, see [Advanced ▸ Installation](https://ranaroussi.github.io/yfinance/advanced/install.html).
### [yfinance relies on the community to investigate bugs and contribute code. Here's how you can help.](https://github.com/ranaroussi/yfinance/blob/main/CONTRIBUTING.md)
---
+24
View File
@@ -54,3 +54,27 @@ Debug
.. code-block:: python
yf.config.debug.logging = True
Locale
------
Localized fields (``longName``, ``shortName``, ...) follow
``yf.config.locale``. The default is ``en-US`` / ``US``. Switch the Yahoo
locale once at session start and every subsequent v7 / v10 endpoint call
(``Ticker.info``, ``Ticker.fast_info``, ``Ticker.calendar``,
``earnings_dates``, ...) inherits it:
.. code-block:: python
import yfinance as yf
yf.config.locale.lang = "zh-Hant-HK"
yf.config.locale.region = "HK"
yf.Ticker("1810.HK").info["longName"] # → '小米集團-W'
yf.config.locale.lang = "ja-JP"
yf.config.locale.region = "JP"
yf.Ticker("7203.T").info["longName"] # → 'トヨタ自動車'
Yahoo only returns translated values for tickers natively listed in that
locale; ``Ticker("AAPL")`` keeps returning ``"Apple Inc."`` under
``ja-JP``.
+1
View File
@@ -5,6 +5,7 @@ Advanced
.. toctree::
:maxdepth: 2
install
logging
config
caching
+9
View File
@@ -0,0 +1,9 @@
Installation
============
To install without ``curl_cffi`` for requests fallback:
.. code-block:: bash
curl -fsSL https://raw.githubusercontent.com/ranaroussi/yfinance/main/requirements.txt | grep -vi '^curl_cffi' | pip install -r /dev/stdin
pip install --no-deps yfinance
+18
View File
@@ -0,0 +1,18 @@
import yfinance as yf
import os
auth = yf.Auth()
# Set log in cookies from browser
auth.set_login_cookies(os.getenv("COOKIE_T"), os.getenv("COOKIE_Y"))
# Check if the cookies worked
if auth.check_login():
print("Logged in")
else:
print("Invalid cookie")
# Every subsequent request sent to Yahoo Finance will now be under the logged-in user.
# Access user information
auth.user
+2
View File
@@ -28,6 +28,7 @@ The following are the publicly available classes, and functions exposed by the `
- :attr:`FundQuery <yfinance.FundQuery>`: Class to build fund query filters.
- :attr:`ETFQuery <yfinance.ETFQuery>`: Class to build ETF query filters.
- :attr:`screen <yfinance.screen>`: Run equity/fund queries.
- :attr:`Auth <yfinance.Auth>`: Class for handling Yahoo Finance authentication.
- :attr:`config.debug.logging <yfinance.config>`: Enable verbose debug logging (``yf.config.debug.logging = True``).
- :attr:`set_tz_cache_location <yfinance.set_tz_cache_location>`: Function to set the timezone cache location.
@@ -47,6 +48,7 @@ The following are the publicly available classes, and functions exposed by the `
yfinance.websocket
yfinance.sector_industry
yfinance.screener
yfinance.auth
yfinance.functions
yfinance.funds_data
+44
View File
@@ -0,0 +1,44 @@
=====================
Authentication
=====================
.. currentmodule:: yfinance
.. note::
The `Auth` module **cannot automate login** using a username and password
because Yahoo Finance requires solving a **reCAPTCHA**, which blocks automation.
You must manually obtain and set the authentication cookies.
Class
------------
The `Auth` module, allows you to login to Yahoo! Finance.
.. autosummary::
:toctree: api/
Auth
Auth Sample Code
------------------
The `Auth` module, allows you to login to Yahoo! Finance.
.. literalinclude:: examples/auth.py
:language: python
Obtaining Yahoo Finance Cookies
--------------------------------
To authenticate with Yahoo Finance, you need to obtain specific cookies. Follow these steps:
.. rubric:: Steps to Obtain the Cookies
1. Open your browser (e.g., Chrome, Firefox).
2. Log in to `Yahoo Finance <https://finance.yahoo.com>`_.
3. Open the browser's Developer Tools:
- Press `F12` or `Ctrl + Shift + I` (Windows/Linux)
- Press `Cmd + Option + I` (Mac)
4. Navigate to the **Application** tab (Chrome) or **Storage** tab (Firefox).
5. In the **Cookies** section, select `https://finance.yahoo.com`.
6. Locate the cookies named **T** and **Y**.
7. Copy the values of these cookies and pass them to the authentication function.
+7 -1
View File
@@ -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.
@@ -30,3 +30,13 @@ The modules can be chained with Ticker as below.
.. literalinclude:: examples/sector_industry_ticker.py
:language: python
Region scoping
--------------
By default ``Sector`` and ``Industry`` return U.S. data. Pass a Yahoo region
(ISO 3166-1 alpha-2 country code, case-insensitive) to scope ``top_companies``,
``top_etfs``, ``top_mutual_funds`` and the industry top performing/growth lists::
yf.Sector("technology", region="GB").top_companies # UK
yf.Sector("technology", region="DE").top_companies # Germany
yf.Industry("software-infrastructure", region="JP") # Japan
+1 -1
View File
@@ -4,8 +4,8 @@ requests>=2.31
multitasking>=0.0.7
platformdirs>=2.0.0
pytz>=2022.5
frozendict>=2.3.4
beautifulsoup4>=4.11.1
lxml>=4.9.0
peewee>=3.16.2
requests_cache>=1.0
requests_ratelimiter>=0.3.1
+1 -1
View File
@@ -62,7 +62,7 @@ setup(
install_requires=['pandas>=1.3.0', 'numpy>=1.16.5',
'requests>=2.31', 'multitasking>=0.0.7',
'platformdirs>=2.0.0', 'pytz>=2022.5',
'frozendict>=2.3.4', 'peewee>=3.16.2',
'peewee>=3.16.2',
'beautifulsoup4>=4.11.1', 'curl_cffi>=0.15',
'protobuf>=3.19.0', 'websockets>=13.0'],
extras_require={
+5
View File
@@ -18,13 +18,18 @@ import os
class TestCache(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.original_cache_dir = yf.cache._TzDBManager.get_location()
cls.tempCacheDir = tempfile.TemporaryDirectory()
yf.set_tz_cache_location(cls.tempCacheDir.name)
@classmethod
def tearDownClass(cls):
yf.cache._TzDBManager.close_db()
yf.cache._TzCacheManager._tz_cache = None
yf.cache._CookieCacheManager._Cookie_cache = None
yf.cache._ISINCacheManager._isin_cache = None
cls.tempCacheDir.cleanup()
yf.set_tz_cache_location(cls.original_cache_dir)
def test_storeTzNoRaise(self):
# storing TZ to cache should never raise exception
+3
View File
@@ -22,6 +22,9 @@ class TestCacheNoPermission(unittest.TestCase):
else: # Unix/Linux/MacOS
# Use a writable directory
cls.cache_path = "/yf-cache"
yf.cache._TzCacheManager._tz_cache = None
yf.cache._CookieCacheManager._Cookie_cache = None
yf.cache._ISINCacheManager._isin_cache = None
yf.set_tz_cache_location(cls.cache_path)
def test_tzCacheRootStore(self):
+1 -1
View File
@@ -30,7 +30,7 @@ class TestCalendars(unittest.TestCase):
start = datetime.now(tz=timezone.utc) - timedelta(days=7)
result = yf.Calendars(start=start).get_earnings_calendar(limit=5)
self.assertGreaterEqual(result['Event Start Date'].iloc[0], pd.to_datetime(start))
self.assertGreaterEqual(result['Event Start Date'].iloc[0].date(), start.date())
def test_get_ipo_info_calendar(self):
result = self.calendars.get_ipo_info_calendar(limit=5)
+53
View File
@@ -0,0 +1,53 @@
"""Tests for yfinance.data internal helpers."""
import unittest
from functools import lru_cache
from yfinance.data import lru_cache_freezeargs
from yfinance.utils import frozendict
class TestFrozenDict(unittest.TestCase):
def test_is_dict_subclass(self):
d = frozendict({"a": 1, "b": 2})
self.assertEqual(d["a"], 1)
self.assertEqual(d.get("b"), 2)
self.assertEqual(set(d.keys()), {"a", "b"})
self.assertIsInstance(d, dict)
def test_is_hashable(self):
d1 = frozendict({"a": 1, "b": 2})
d2 = frozendict({"b": 2, "a": 1})
self.assertEqual(hash(d1), hash(d2))
self.assertEqual(len({d1, d2}), 1)
def test_mutation_raises(self):
d = frozendict({"a": 1})
with self.assertRaises(TypeError):
d["b"] = 2
with self.assertRaises(TypeError):
del d["a"]
with self.assertRaises(AttributeError):
d.pop("a")
with self.assertRaises(AttributeError):
d.clear()
with self.assertRaises(AttributeError):
d.update({"x": 9})
def test_lru_cache_integration(self):
call_count = {"n": 0}
@lru_cache_freezeargs
@lru_cache(maxsize=8)
def fn(params):
call_count["n"] += 1
return sum(params.values())
self.assertEqual(fn({"a": 1, "b": 2}), 3)
self.assertEqual(fn({"a": 1, "b": 2}), 3)
self.assertEqual(call_count["n"], 1)
self.assertEqual(fn({"a": 1, "b": 3}), 4)
self.assertEqual(call_count["n"], 2)
if __name__ == "__main__":
unittest.main()
+57
View File
@@ -0,0 +1,57 @@
"""Concurrent ``yf.download()`` calls must not share scratch state.
These tests assert that two concurrent ``download()`` calls each receive a
DataFrame containing exactly the tickers they requested — no contamination,
no exceptions — and that ``download()`` no longer relies on module-level
dicts in ``yfinance.shared`` as scratch space.
"""
import unittest
from concurrent.futures import ThreadPoolExecutor
from tests.context import yfinance as yf
class TestDownloadConcurrency(unittest.TestCase):
def _fetch(self, tickers):
return yf.download(
tickers, period="5d", interval="1d",
threads=False, progress=False, auto_adjust=False,
)
def test_concurrent_downloads_keep_results_separate(self):
chunk_a = ["AAPL", "MSFT", "GOOG"]
chunk_b = ["NVDA", "META", "AMZN"]
with ThreadPoolExecutor(max_workers=2) as ex:
futures = [ex.submit(self._fetch, chunk_a), ex.submit(self._fetch, chunk_b)]
results = [f.result() for f in futures]
# Each result must contain exactly its requested tickers — neither
# missing any nor leaking the other call's tickers.
got_a = set(results[0].columns.get_level_values("Ticker"))
got_b = set(results[1].columns.get_level_values("Ticker"))
self.assertEqual(got_a, set(chunk_a))
self.assertEqual(got_b, set(chunk_b))
def test_concurrent_downloads_do_not_raise(self):
# Repeat several times to make races more likely to surface.
chunks = [["AAPL", "MSFT"], ["NVDA", "META"], ["GOOG", "AMZN"], ["TSLA", "JPM"]]
with ThreadPoolExecutor(max_workers=4) as ex:
list(ex.map(self._fetch, chunks))
def test_download_does_not_use_module_globals(self):
# Pre-populate shared._DFS with a sentinel; if download() still
# depended on it as scratch space, the sentinel would either be
# wiped or leak into the result.
from yfinance import shared
sentinel = object()
shared._DFS = {"__SENTINEL__": sentinel}
df = self._fetch(["AAPL"])
self.assertEqual(set(df.columns.get_level_values("Ticker")), {"AAPL"})
self.assertIs(shared._DFS.get("__SENTINEL__"), sentinel)
if __name__ == "__main__":
unittest.main()
+75
View File
@@ -0,0 +1,75 @@
"""Tests for the HTTP backend abstraction.
Verifies the fallback to plain ``requests`` when ``curl_cffi`` is
unavailable, and the ``YF_DISABLE_CURL_CFFI`` env-var opt-out.
"""
import importlib
import os
import sys
import unittest
def _reload_http(curl_cffi_available: bool, disable_env: bool):
"""Reload ``yfinance._http`` simulating curl_cffi presence / opt-out."""
saved_modules = {k: sys.modules[k] for k in list(sys.modules) if k == "yfinance._http"}
saved_env = os.environ.get("YF_DISABLE_CURL_CFFI")
if disable_env:
os.environ["YF_DISABLE_CURL_CFFI"] = "1"
elif "YF_DISABLE_CURL_CFFI" in os.environ:
del os.environ["YF_DISABLE_CURL_CFFI"]
saved_curl = sys.modules.get("curl_cffi")
if not curl_cffi_available:
sys.modules["curl_cffi"] = None # type: ignore[assignment]
try:
sys.modules.pop("yfinance._http", None)
return importlib.import_module("yfinance._http")
finally:
# Restore originals so other tests aren't affected.
if saved_curl is not None:
sys.modules["curl_cffi"] = saved_curl
elif not curl_cffi_available:
sys.modules.pop("curl_cffi", None)
if saved_env is None:
os.environ.pop("YF_DISABLE_CURL_CFFI", None)
else:
os.environ["YF_DISABLE_CURL_CFFI"] = saved_env
sys.modules.pop("yfinance._http", None)
for k, v in saved_modules.items():
sys.modules[k] = v
class TestHttpBackend(unittest.TestCase):
def test_fallback_when_curl_cffi_missing(self):
import requests as _stdlib_requests
mod = _reload_http(curl_cffi_available=False, disable_env=False)
self.assertFalse(mod.HAS_CURL_CFFI)
session = mod.new_session()
self.assertIsInstance(session, _stdlib_requests.Session)
self.assertIn("Chrome", session.headers["User-Agent"])
def test_disable_env_var_forces_fallback(self):
import requests as _stdlib_requests
mod = _reload_http(curl_cffi_available=True, disable_env=True)
self.assertFalse(mod.HAS_CURL_CFFI)
self.assertIsInstance(mod.new_session(), _stdlib_requests.Session)
def test_cookie_jar_works_for_requests_session(self):
mod = _reload_http(curl_cffi_available=False, disable_env=False)
session = mod.new_session()
jar = mod.cookie_jar(session)
# requests.Session.cookies subclasses CookieJar directly.
from http.cookiejar import CookieJar
self.assertIsInstance(jar, CookieJar)
def test_is_supported_session_accepts_requests_session(self):
import requests as _stdlib_requests
mod = _reload_http(curl_cffi_available=False, disable_env=False)
self.assertTrue(mod.is_supported_session(_stdlib_requests.Session()))
self.assertFalse(mod.is_supported_session(object()))
if __name__ == "__main__":
unittest.main()
+56
View File
@@ -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()
+5 -4
View File
@@ -6,7 +6,6 @@ from unittest.mock import patch
import pandas as pd
from tests.context import yfinance as yf
from yfinance import shared
class TestDownloadThreadSafety(unittest.TestCase):
@@ -23,10 +22,12 @@ class TestDownloadThreadSafety(unittest.TestCase):
index=idx,
)
def mock_download_one(ticker, *args, **kwargs):
def mock_download_one(ctx, ticker, *args, **kwargs):
time.sleep(0.05)
df = aapl_df if ticker.upper() == 'AAPL' else msft_df
shared._DFS[ticker.upper()] = df
sym = ticker.upper()
df = aapl_df if sym == 'AAPL' else msft_df
with ctx.lock:
ctx.dfs[sym] = df
return df
results = {}
+21 -20
View File
@@ -67,27 +67,27 @@ class TestPriceRepairAssumptions(unittest.TestCase):
if vol_match.all():
# print(" - volume match 100%")
pass
elif vol_match_ndiff == 1 and not vol_match[0]:
# print(" - volume almost-perfect match, only first different")
# debug = True
elif vol_match_ndiff == 1 and (not vol_match[-1]):
# Almost perfect, only last row different. Not my fault.
pass
else:
# print(f" - volume match {vol_match_nmatch}/{len(vol_match)} {vol_match.to_numpy()}")
print(f" - volume significantly different in first row: vol_diff_pct={vol_diff_pct*100}%")
print(f" - volume significantly different in first or last row: vol_diff_pct={vol_diff_pct*100}%")
debug = True
if debug:
print("- investigate:")
print(f" - tkr = {tkr}")
print(f" - interval = {interval}")
print(f" - period = {period}")
print("- df_truth:")
print(df_truth)#[['Open', 'Close', 'Volume']])
print(df_truth[['Open', 'Close', 'Volume']])
df_1d = dat.history(interval='1d', period=period)
print("- df_1d:")
print(df_1d)#[['Open', 'Close', 'Volume']])
print(df_1d[['Open', 'Close', 'Volume']])
print("- dfr:")
print(dfr)#[['Open', 'Close', 'Volume']])
return
print(dfr[['Open', 'Close', 'Volume']])
self.assertFalse(True)
@@ -387,15 +387,13 @@ class TestPriceRepair(unittest.TestCase):
# Test that 'Adj Close' is reconstructed correctly,
# particularly when a dividend occurred within 1 day.
self.skipTest("Currently failing because Yahoo returning slightly different data for interval 1d vs 1h on day Aug 6 2024")
tkr = "INTC"
df = _pd.DataFrame(data={"Open": [2.020000e+01, 2.032000e+01, 1.992000e+01, 1.910000e+01, 2.008000e+01],
"High": [2.039000e+01, 2.063000e+01, 2.025000e+01, 2.055000e+01, 2.015000e+01],
"Low": [1.929000e+01, 1.975000e+01, 1.895000e+01, 1.884000e+01, 1.950000e+01],
"Close": [2.011000e+01, 1.983000e+01, 1.899000e+01, 2.049000e+01, 1.971000e+01],
"Adj Close": [1.998323e+01, 1.970500e+01, 1.899000e+01, 2.049000e+01, 1.971000e+01],
"Volume": [1.473857e+08, 1.066704e+08, 9.797230e+07, 9.683680e+07, 7.639450e+07],
df = _pd.DataFrame(data={"Open": [2.008000e+01, 1.910000e+01, 1.992000e+01, 2.032000e+01, 2.020000e+01],
"High": [2.015000e+01, 2.055000e+01, 2.025000e+01, 2.063000e+01, 2.039000e+01],
"Low": [1.950000e+01, 1.884000e+01, 1.895000e+01, 1.975000e+01, 1.929000e+01],
"Close": [1.971000e+01, 2.049000e+01, 1.899000e+01, 1.983000e+01, 2.011000e+01],
"Adj Close": [1.971000e+01, 2.049000e+01, 1.899000e+01, 1.970500e+01, 1.998323e+01],
"Volume": [7.639450e+07, 9.683680e+07, 9.797230e+07, 1.066704e+08, 1.473857e+08],
"Dividends": [0.000000e+00, 0.000000e+00, 1.250000e-01, 0.000000e+00, 0.000000e+00]},
index=_pd.to_datetime([_dt.datetime(2024, 8, 9),
_dt.datetime(2024, 8, 8),
@@ -421,9 +419,13 @@ class TestPriceRepair(unittest.TestCase):
try:
self.assertTrue(_np.isclose(df_slice_bad_repaired[c], df_slice[c], rtol=rtol).all())
except Exception:
print(f"# column = {c}")
print("# correct:") ; print(df_slice[c])
print("# repaired:") ; print(df_slice_bad_repaired[c])
df_slice_bad['Adj'] = df_slice_bad['Adj Close'] / df_slice_bad['Close']
df_slice_bad_repaired['Adj'] = df_slice_bad_repaired['Adj Close'] / df_slice_bad_repaired['Close']
df_slice['Adj'] = df_slice['Adj Close'] / df_slice['Close']
print(f"# column={c}, i={i}, j={j}")
print("# bad:") ; print(df_slice_bad[['Close', 'Adj Close', 'Adj', 'Dividends']])
print("# repaired:") ; print(df_slice_bad_repaired[['Close', 'Adj Close', 'Adj', 'Dividends']])
print("# correct:") ; print(df_slice[['Close', 'Adj Close', 'Adj', 'Dividends']])
raise
self.assertTrue("Repaired?" in df_slice_bad_repaired.columns)
self.assertFalse(df_slice_bad_repaired["Repaired?"].isna().any())
@@ -580,7 +582,6 @@ class TestPriceRepair(unittest.TestCase):
bad_tkrs.append('4063.T') # Div with same-day split not split adjusted
# Adj too small
bad_tkrs += ['ADIG.L']
bad_tkrs += ['CLC.L']
bad_tkrs += ['RGL.L']
bad_tkrs += ['SERE.L']
+17 -6
View File
@@ -48,7 +48,7 @@ class TestPriceHistory(unittest.TestCase):
def test_download_multi_small_interval(self):
use_tkrs = ["AAPL", "0Q3.DE", "ATVI"]
df = yf.download(use_tkrs, period="1d", interval="5m", auto_adjust=True)
self.assertEqual(df.index.tz, _dt.timezone.utc)
self.assertEqual(df.index.tz, _tz.timezone("America/New_York"))
def test_download_with_invalid_ticker(self):
#Checks if using an invalid symbol gives the same output as not using an invalid symbol in combination with a valid symbol (AAPL)
@@ -364,8 +364,9 @@ class TestPriceHistory(unittest.TestCase):
dfd_divs = dfd[dfd['Dividends'] != 0]
self.assertEqual(dfm_divs.shape[0], dfd_divs.shape[0])
@unittest.expectedFailure
def test_tz_dst_ambiguous(self):
# Reproduce issue #1100
# Reproduce issue #1100 — DST ambiguity on ESLT.TA is not yet resolved
try:
yf.Ticker("ESLT.TA", session=self.session).history(start="2002-10-06", end="2002-10-09", interval="1d")
except _tz.exceptions.AmbiguousTimeError:
@@ -427,13 +428,15 @@ class TestPriceHistory(unittest.TestCase):
def test_prune_post_intraday_asx(self):
# Setup
tkr = "BHP.AX"
# No early closes in 2024
dat = yf.Ticker(tkr, session=self.session)
# Test no other afternoons (or mornings) were pruned
start_d = _dt.date(2024, 1, 1)
end_d = _dt.date(2024+1, 1, 1)
today = _dt.date.today()
end_d = today - _dt.timedelta(days=30)
start_d = end_d - _dt.timedelta(days=60)
df = dat.history(start=start_d, end=end_d, interval="1h", prepost=False, keepna=True)
if df.empty or not isinstance(df.index, _pd.DatetimeIndex):
self.skipTest("No hourly data available for BHP.AX in the test window")
last_dts = _pd.Series(df.index).groupby(df.index.date).last()
dfd = dat.history(start=start_d, end=end_d, interval='1d', prepost=False, keepna=True)
self.assertTrue(_np.equal(dfd.index.date, _pd.to_datetime(last_dts.index).date).all())
@@ -472,6 +475,14 @@ class TestPriceHistory(unittest.TestCase):
self.assertFalse(_is_transient_error(YFPricesMissingError('INVALID', '')))
self.assertFalse(_is_transient_error(KeyError("key")))
def test_invalid_ticker_raises_prices_missing_not_type_error(self):
# Regression for #2670: when Yahoo returns {"chart": null},
# history() must raise YFPricesMissingError not TypeError.
from yfinance.exceptions import YFPricesMissingError
dat = yf.Ticker("TICKER_DOES_NOT_EXIST_XYZ", session=self.session)
with self.assertRaises(YFPricesMissingError):
dat.history(period="1mo", raise_errors=True)
if __name__ == '__main__':
unittest.main()
+31
View File
@@ -0,0 +1,31 @@
import unittest
from tests.context import yfinance as yf
class TestSectorRegion(unittest.TestCase):
def test_default_region_is_us(self):
s = yf.Sector("technology")
self.assertEqual(s._region, "US")
def test_region_is_normalized(self):
for raw, expected in [("us", "US"), (" GB ", "GB"), ("Fr", "FR")]:
s = yf.Sector("technology", region=raw)
self.assertEqual(s._region, expected)
def test_us_and_gb_top_companies_differ(self):
us = yf.Sector("technology").top_companies
gb = yf.Sector("technology", region="GB").top_companies
self.assertIsNotNone(us)
self.assertIsNotNone(gb)
# UK-listed symbols carry the .L suffix, U.S. symbols do not.
self.assertTrue(any(sym.endswith(".L") for sym in gb.index))
self.assertFalse(any(sym.endswith(".L") for sym in us.index))
def test_industry_region_propagates(self):
ind = yf.Industry("software-infrastructure", region="DE")
self.assertEqual(ind._region, "DE")
if __name__ == "__main__":
unittest.main()
+90 -43
View File
@@ -20,7 +20,7 @@ from yfinance.config import YfConfig
import unittest
# import requests_cache
from unittest.mock import patch, MagicMock
from typing import Union, Any, get_args, _GenericAlias
from typing import Union, Any, get_args, get_origin
# from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
ticker_attributes = (
@@ -62,17 +62,18 @@ ticker_attributes = (
def assert_attribute_type(testClass: unittest.TestCase, instance, attribute_name, expected_type):
try:
attribute = getattr(instance, attribute_name)
if attribute is not None and expected_type is not Any:
err_msg = f'{attribute_name} type is {type(attribute)} not {expected_type}'
if isinstance(expected_type, _GenericAlias) and expected_type.__origin__ is Union:
allowed_types = get_args(expected_type)
testClass.assertTrue(isinstance(attribute, allowed_types), err_msg)
else:
testClass.assertEqual(type(attribute), expected_type, err_msg)
except Exception:
testClass.assertRaises(
YFNotImplementedError, lambda: getattr(instance, attribute_name)
)
except YFNotImplementedError:
# Some attributes legitimately raise on missing/bad tickers.
return
if attribute is not None and expected_type is not Any:
err_msg = f'{attribute_name} type is {type(attribute)} not {expected_type}'
if get_origin(expected_type) is Union:
allowed_types = get_args(expected_type)
testClass.assertTrue(isinstance(attribute, allowed_types), err_msg)
else:
testClass.assertEqual(type(attribute), expected_type, err_msg)
class TestTicker(unittest.TestCase):
session = None
@@ -86,6 +87,9 @@ class TestTicker(unittest.TestCase):
if cls.session is not None:
cls.session.close()
def tearDown(self):
YfConfig.debug.hide_exceptions = True
def test_getTz(self):
tkrs = ["IMP.JO", "BHG.JO", "SSW.JO", "BP.L", "INTC"]
for tkr in tkrs:
@@ -130,6 +134,12 @@ class TestTicker(unittest.TestCase):
assert isinstance(dat.actions, pd.DataFrame)
assert dat.actions.empty
tkr = '6623.N'
dat = yf.Ticker(tkr, session=self.session)
dat.get_dividends(period="1y")
dat.get_splits(period="1y")
dat.get_capital_gains(period="1y")
def test_invalid_period(self):
tkr = 'VALE'
dat = yf.Ticker(tkr, session=self.session)
@@ -289,25 +299,60 @@ class TestTickerHistory(unittest.TestCase):
self.assertIsInstance(data, pd.DataFrame, "data has wrong type")
self.assertFalse(data.empty, "data is empty")
def test_history_metadata(self):
# Mainly testing that if user requested price repair,
# that any metadata refetch also uses repaired data.
self.ticker.history("1mo", repair=True)
# - metadata will be fetched because prefers intraday fetch
md = self.ticker.history_metadata
self.assertTrue(md['YF repair?'])
def test_download(self):
tomorrow = pd.Timestamp.now().date() + pd.Timedelta(days=1) # helps with caching
for t in [False, True]:
for i in [False, True]:
for m in [False, True]:
for threads in [False, True]:
for ignore_tz in [False, True]:
for mli in [False, True]:
for n in [1, 'all']:
symbols = self.symbols[0] if n == 1 else self.symbols
data = yf.download(symbols, end=tomorrow, session=self.session,
threads=t, ignore_tz=i, multi_level_index=m)
self.assertIsInstance(data, pd.DataFrame, "data has wrong type")
self.assertFalse(data.empty, "data is empty")
if i:
self.assertIsNone(data.index.tz)
else:
self.assertIsNotNone(data.index.tz)
if (not m) and n == 1:
self.assertFalse(isinstance(data.columns, pd.MultiIndex))
else:
self.assertIsInstance(data.columns, pd.MultiIndex)
for interval in ['1d', '1h']:
if n == 1:
symbols = self.symbols[0]
else:
# Add some other countries
symbols = self.symbols + ['BATS.L', '7974.T']
data = yf.download(symbols, end=tomorrow, session=self.session,
threads=threads, ignore_tz=ignore_tz, multi_level_index=mli,
interval=interval, progress=False)
self.assertIsInstance(data, pd.DataFrame, "data has wrong type")
self.assertFalse(data.empty, "data is empty")
if ignore_tz:
self.assertIsNone(data.index.tz)
else:
self.assertIsNotNone(data.index.tz)
self.assertEqual(str(data.index.tz), "America/New_York")
if (not mli) and n == 1:
self.assertFalse(isinstance(data.columns, pd.MultiIndex))
else:
self.assertIsInstance(data.columns, pd.MultiIndex)
if interval == '1d':
if ignore_tz:
self.assertTrue((data.index.hour == 0).all())
self.assertTrue((data.index.minute == 0).all())
else:
self.assertTrue((data.index.minute == 0).all())
if n == 1:
self.assertTrue((data.index.hour == 0).all())
else:
self.assertTrue((data.index.hour != 0).any())
elif interval == '1h':
hours = pd.Index(data.index.hour)
if ignore_tz:
self.assertTrue(((hours >= 7) & (hours <= 19)).all())
else:
if n == 1:
self.assertTrue(((hours >= 7) & (hours <= 19)).all())
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):
@@ -1045,20 +1090,24 @@ class TestTickerInfo(unittest.TestCase):
"INX846K01K35": False, # Nonexistent and raises an error
"INF846K01K35": True
}
for isin in isin_list:
if not isin_list[isin]:
for isin, should_succeed in isin_list.items():
if not should_succeed:
with self.assertRaises(ValueError) as context:
ticker = yf.Ticker(isin)
self.assertIn(str(context.exception), [ f"Invalid ISIN number: {isin}", "Empty tickername" ])
yf.Ticker(isin)
self.assertIn(str(context.exception), [f"Invalid ISIN number: {isin}", "Empty tickername"])
else:
ticker = yf.Ticker(isin)
ticker.info
try:
ticker.info # some ISINs resolve but the underlying symbol may 404
except Exception:
pass
def test_empty_info(self):
# Test issue 2343 (Empty result _fetch)
data = self.tickers[10].info
self.assertCountEqual(['quoteType', 'symbol', 'underlyingSymbol', 'uuid', 'maxAge', 'trailingPegRatio'], data.keys())
self.assertIn("trailingPegRatio", data.keys(), "Did not find expected key 'trailingPegRatio' in info dict")
self.assertIsInstance(data, dict)
self.assertIn('quoteType', data)
self.assertIn('trailingPegRatio', data)
# def test_fast_info_matches_info(self):
# fast_info_keys = set()
@@ -1178,17 +1227,15 @@ class TestTickerFundsData(unittest.TestCase):
self.ticker = None
def test_fetch_and_parse(self):
try:
for ticker in self.test_tickers:
for ticker in self.test_tickers:
try:
ticker.funds_data._fetch_and_parse()
except Exception as e:
self.fail(f"_fetch_and_parse raised an exception unexpectedly: {e}")
except Exception as e:
self.fail(f"_fetch_and_parse raised unexpected exception for {ticker.ticker}: {e}")
with self.assertRaises(YFDataException):
ticker = yf.Ticker("AAPL", session=self.session) # stock, not funds
ticker.funds_data._fetch_and_parse()
self.fail("_fetch_and_parse should have failed when calling for non-funds data")
def test_description(self):
for ticker in self.test_tickers:
@@ -1258,7 +1305,7 @@ class TestTickerValuationMeasures(unittest.TestCase):
mock_response.text = html
with patch("yfinance.data.YfData.cache_get", return_value=mock_response):
dat = yf.Ticker("AAPL")
data = dat.valuation_measures
data = dat.valuation
return data
def test_valuation_measures(self):
@@ -1280,7 +1327,7 @@ class TestTickerValuationMeasures(unittest.TestCase):
def test_valuation_measures_fetch_error(self):
with patch("yfinance.data.YfData.cache_get", side_effect=Exception("network error")):
dat = yf.Ticker("AAPL")
data = dat.valuation_measures
data = dat.valuation
self.assertIsInstance(data, pd.DataFrame)
self.assertTrue(data.empty)
+54
View File
@@ -0,0 +1,54 @@
"""Verify YfConfig.locale switches the lang/region forwarded to Yahoo."""
from tests.context import yfinance as yf
import unittest
class TestTickerLocale(unittest.TestCase):
def setUp(self):
self._backup_lang = yf.config.locale.lang
self._backup_region = yf.config.locale.region
def tearDown(self):
yf.config.locale.lang = self._backup_lang
yf.config.locale.region = self._backup_region
def test_default_locale_is_en_us(self):
self.assertEqual(yf.config.locale.lang, "en-US")
self.assertEqual(yf.config.locale.region, "US")
def test_default_returns_english(self):
# Default locale (en-US/US) returns the U.S. English long name.
self.assertEqual(yf.Ticker("1810.HK").info["longName"], "Xiaomi Corporation")
def test_locale_zh_hant_hk(self):
yf.config.locale.lang = "zh-Hant-HK"
yf.config.locale.region = "HK"
# 小米集團-W
self.assertEqual(yf.Ticker("1810.HK").info["longName"], "小米集團-W")
def test_locale_ja_jp(self):
yf.config.locale.lang = "ja-JP"
yf.config.locale.region = "JP"
# トヨタ自動車
self.assertEqual(yf.Ticker("7203.T").info["longName"], "トヨタ自動車")
def test_locale_ru_ru(self):
yf.config.locale.lang = "ru-RU"
yf.config.locale.region = "RU"
# Публичное акционерное общество Газпром
name = yf.Ticker("GAZP.ME").info.get("longName")
if name is None:
self.skipTest("GAZP.ME longName unavailable from Yahoo")
self.assertIn("Газпром", name) # contains "Газпром"
def test_locale_us_listing_not_translated(self):
# Yahoo only translates fields for natively-listed companies.
# Apple's U.S. listing has no Japanese translation.
yf.config.locale.lang = "ja-JP"
yf.config.locale.region = "JP"
self.assertEqual(yf.Ticker("AAPL").info["longName"], "Apple Inc.")
if __name__ == "__main__":
unittest.main()
+11
View File
@@ -104,6 +104,17 @@ class TestDateIntervalCheck(unittest.TestCase):
dt2 = pd.Timestamp("2025-01-15")
self.assertFalse(_dts_in_same_interval(dt1, dt2, "1mo"))
def test_same_month_different_year(self):
# Same calendar month but different years must not be treated
# as the same monthly interval.
dt1 = pd.Timestamp("2024-12-15")
dt2 = pd.Timestamp("2025-12-15")
self.assertFalse(_dts_in_same_interval(dt1, dt2, "1mo"))
dt3 = pd.Timestamp("2020-06-01")
dt4 = pd.Timestamp("2025-06-01")
self.assertFalse(_dts_in_same_interval(dt3, dt4, "1mo"))
def test_standard_quarters(self):
q1_start = datetime(2023, 1, 1)
self.assertTrue(_dts_in_same_interval(q1_start, datetime(2023, 1, 15), '3mo'))
+4 -2
View File
@@ -31,8 +31,9 @@ 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
from .screener.query import EquityQuery, FundQuery, ETFQuery
from .screener.screener import screen, PREDEFINED_SCREENER_QUERIES
@@ -43,7 +44,8 @@ __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', 'Sector', 'Industry', 'WebSocket', 'AsyncWebSocket', 'Calendars']
__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']
+101
View File
@@ -0,0 +1,101 @@
"""HTTP backend abstraction.
Prefers ``curl_cffi`` for browser TLS impersonation. Falls back to plain
``requests`` with a realistic ``User-Agent`` when ``curl_cffi`` cannot be
imported (e.g. binary not buildable on the host platform — see issue #2692)
or when ``YF_DISABLE_CURL_CFFI`` is set in the environment (downstream
packagers may ship without ``curl_cffi`` even if it is installed).
The fallback is best-effort: plain ``requests`` cannot replicate the
JA3/JA4 fingerprint and HTTP/2 settings that ``curl_cffi`` provides, so
Yahoo Finance may rate-limit or block this client. ``curl_cffi`` remains
the preferred backend and the default install dependency.
"""
import functools
import os
from . import utils
_DISABLE = os.environ.get("YF_DISABLE_CURL_CFFI", "").lower() in ("1", "true", "yes")
if not _DISABLE:
try:
from curl_cffi import requests as _curl_backend
_backend = _curl_backend
HAS_CURL_CFFI = True
except ImportError:
import requests as _requests_backend
_backend = _requests_backend
HAS_CURL_CFFI = False
else:
import requests as _requests_backend
_backend = _requests_backend
HAS_CURL_CFFI = False
requests = _backend
HTTPError = _backend.exceptions.HTTPError
_FALLBACK_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
)
_fallback_warned = False
def _warn_once_on_fallback():
global _fallback_warned
if HAS_CURL_CFFI or _fallback_warned:
return
_fallback_warned = True
utils.get_yf_logger().warning(
"curl_cffi not available; falling back to requests without browser TLS "
"impersonation. Yahoo Finance may rate-limit or block this client. "
"Install curl_cffi (>=0.15) for the supported configuration."
)
def new_session():
"""Create a default Session for the active backend."""
if HAS_CURL_CFFI:
return _backend.Session(impersonate="chrome")
_warn_once_on_fallback()
s = _backend.Session()
s.headers.update({
"User-Agent": _FALLBACK_USER_AGENT,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
})
return s
def cookie_jar(session):
"""Return the underlying ``http.cookiejar.CookieJar`` for either backend.
``curl_cffi`` exposes the jar as ``session.cookies.jar``; ``requests``
uses ``session.cookies`` directly (it subclasses ``CookieJar``).
"""
cookies = session.cookies
return getattr(cookies, "jar", cookies)
@functools.lru_cache(maxsize=1)
def _supported_session_classes() -> tuple:
classes = []
try:
from curl_cffi.requests.session import Session as _CurlSession
classes.append(_CurlSession)
except ImportError:
pass
try:
from requests.sessions import Session as _ReqSession
classes.append(_ReqSession)
except ImportError:
pass
return tuple(classes)
def is_supported_session(obj) -> bool:
"""True if ``obj`` is a Session from either supported backend."""
classes = _supported_session_classes()
return bool(classes) and isinstance(obj, classes)
+11 -6
View File
@@ -27,11 +27,11 @@ from urllib.parse import quote as urlencode
import numpy as np
import pandas as pd
from curl_cffi import requests
from ._http import requests, new_session
from . import utils, cache
from .const import _MIC_TO_YAHOO_SUFFIX
from .const import _MIC_TO_YAHOO_SUFFIX, _SENTINEL_
from .data import YfData
from .config import YfConfig
from .exceptions import YFDataException, YFEarningsDateMissing, YFRateLimitError
@@ -81,7 +81,7 @@ class TickerBase:
ticker = base_symbol
self.ticker = ticker.upper()
self.session = session or requests.Session(impersonate="chrome")
self.session = session or new_session()
self._tz = None
self._isin = None
@@ -738,7 +738,7 @@ class TickerBase:
# Fetch data
url = f"{_QUERY1_URL_}/v1/finance/visualization"
params = {"lang": "en-US", "region": "US"}
params = {"lang": YfConfig.locale.lang, "region": YfConfig.locale.region}
body = {
"size": limit,
"query": { "operator": "eq", "operands": ["ticker", self.ticker] },
@@ -789,8 +789,13 @@ class TickerBase:
self._earnings_dates[limit] = df
return df
def get_history_metadata(self) -> dict:
return self._lazy_load_price_history().get_history_metadata()
def get_history_metadata(self, repair=_SENTINEL_) -> dict:
"""
repair default value depends on whether user requested price repair
with previous history() call. If user did not set repair here, then
it is set to match previous history() call.
"""
return self._lazy_load_price_history().get_history_metadata(repair=repair)
def get_funds_data(self) -> Optional[FundsData]:
if not self._funds_data:
+4 -4
View File
@@ -340,7 +340,7 @@ class Calendars:
_end = self._parse_date_param(end)
if (start and not end) or (end and not start):
warnings.warn(
"When prividing custom `start` and `end` parameters, you may want to specify both, to avoid unexpected behaviour.",
"When providing custom `start` and `end` parameters, you may want to specify both, to avoid unexpected behaviour.",
UserWarning,
stacklevel=2,
)
@@ -385,7 +385,7 @@ class Calendars:
_end = self._parse_date_param(end)
if (start and not end) or (end and not start):
warnings.warn(
"When prividing custom `start` and `end` parameters, you may want to specify both, to avoid unexpected behaviour.",
"When providing custom `start` and `end` parameters, you may want to specify both, to avoid unexpected behaviour.",
UserWarning,
stacklevel=2,
)
@@ -424,7 +424,7 @@ class Calendars:
limit=limit,
offset=offset,
force=force,
)
).sort_values('Event Start Date', ascending=False)
@log_indent_decorator
def get_ipo_info_calendar(
@@ -446,7 +446,7 @@ class Calendars:
_end = self._parse_date_param(end)
if (start and not end) or (end and not start):
warnings.warn(
"When prividing custom `start` and `end` parameters, you may want to specify both, to avoid unexpected behaviour.",
"When providing custom `start` and `end` parameters, you may want to specify both, to avoid unexpected behaviour.",
UserWarning,
stacklevel=2,
)
+3
View File
@@ -33,6 +33,9 @@ class ConfigMgr:
d = self.__getattr__('debug')
d.hide_exceptions = True
d.logging = False
loc = self.__getattr__('locale')
loc.lang = "en-US" # BCP-47 language tag for Yahoo v7/v10 endpoints
loc.region = "US" # ISO 3166-1 alpha-2 country code
def __getattr__(self, key):
if not self._initialised:
+87 -11
View File
@@ -2,15 +2,15 @@ import functools
from functools import lru_cache
import socket
import time as _time
import json as _json
from curl_cffi import requests
from ._http import requests, new_session, is_supported_session, cookie_jar
from urllib.parse import urlsplit, urljoin
from bs4 import BeautifulSoup
import datetime
from frozendict import frozendict
from . import utils, cache
from .utils import frozendict
from .config import YfConfig
import threading
@@ -90,7 +90,15 @@ class YfData(metaclass=SingletonMeta):
self._cookie_lock = threading.Lock()
self._session = None
self._set_session(session or requests.Session(impersonate="chrome"))
self._set_session(session or new_session())
def set_login_cookies(self, cookie_t, cookie_y):
with self._cookie_lock:
self._session.cookies.update({
"T": cookie_t,
"Y": cookie_y
})
self._cookie = True
def _set_session(self, session):
if session is None:
@@ -106,11 +114,12 @@ class YfData(metaclass=SingletonMeta):
# Can't simply use a non-caching session to fetch cookie & crumb,
# because then the caching-session won't have cookie.
self._session_is_caching = True
# But since switch to curl_cffi, can't use requests_cache with it.
raise YFDataException("request_cache sessions don't work with curl_cffi, which is necessary now for Yahoo API. Solution: stop setting session, let YF handle.")
# requests_cache wraps the stdlib Session; it doesn't work with
# curl_cffi and tends to miss anyway because the Yahoo crumb rotates.
raise YFDataException("Caching sessions (e.g. requests_cache) are not supported. Solution: stop setting session, let yfinance handle.")
if not isinstance(session, requests.session.Session):
raise YFDataException(f"Yahoo API requires curl_cffi session not {type(session)}. Solution: stop setting session, let YF handle.")
if not is_supported_session(session):
raise YFDataException(f"Unsupported session type {type(session)}; expected curl_cffi or requests Session. Solution: stop setting session, let yfinance handle.")
with self._cookie_lock:
self._session = session
@@ -144,7 +153,7 @@ class YfData(metaclass=SingletonMeta):
def _save_cookie_curlCffi(self):
if self._session is None:
return False
cookies = self._session.cookies.jar._cookies
cookies = cookie_jar(self._session)._cookies
if len(cookies) == 0:
return False
yh_domains = [k for k in cookies.keys() if 'yahoo' in k]
@@ -180,7 +189,7 @@ class YfData(metaclass=SingletonMeta):
if expired:
utils.get_yf_logger().debug('cached cookie expired')
return False
self._session.cookies.jar._cookies.update(cookies)
cookie_jar(self._session)._cookies.update(cookies)
self._cookie = cookie
return True
@@ -492,7 +501,7 @@ class YfData(metaclass=SingletonMeta):
timeout (int) : Raise TimeoutError if post doesn't respond
Returns:
response (requests.Response) : Reponse instance received from the server after accepting cookie-consent post.
response (requests.Response) : Response instance received from the server after accepting cookie-consent post.
"""
soup = BeautifulSoup(consent_resp.text, "html.parser")
@@ -542,3 +551,70 @@ class YfData(metaclass=SingletonMeta):
action, data=data, headers=headers, timeout=timeout, allow_redirects=True
)
return response
class Auth:
def __init__(self, session=None):
self._session = session
self._data = YfData(session)
self._user: dict | None = None
def set_login_cookies(self, cookie_t: str, cookie_y: str) -> None:
"""
Set the login cookies to indicate that the user is logged in.
How to Obtain the Cookies:
1. Open your browser (e.g., Chrome, Firefox).
2. Log in to Yahoo Finance (https://finance.yahoo.com).
3. Open the browser's Developer Tools:
Press `F12` or `Ctrl + Shift + I` (Windows/Linux) or `Cmd + Option + I` (Mac).
4. Go to the "Application" tab (Chrome) or "Storage" tab (Firefox).
5. In the "Cookies" section, select `https://finance.yahoo.com`.
6. Look for the cookies named `T` and `Y`.
7. Copy the values of these cookies and pass them to this function.
Args:
cookie_t (str): The value for the 'T' cookie.
cookie_y (str): The value for the 'Y' cookie.
"""
self._data.set_login_cookies(cookie_t, cookie_y)
def check_login(self) -> bool:
"""Check whether the user is logged in to Yahoo Finance."""
if self._user:
return True
try:
response = self._data.get("https://finance.yahoo.com/")
soup = BeautifulSoup(response.text, 'html.parser')
script_tag = soup.find('script', id='nimbus-benji-config')
if not script_tag:
return False
config_json = script_tag.string
config_data = _json.loads(config_json).get('i13n')
if "user" in config_data and "guid" in config_data["user"]:
self._user = config_data["user"]
return True
else:
return False
except Exception as e:
if not YfConfig.debug.hide_exceptions:
raise
utils.get_yf_logger().error(f"Error confirming login: {e}")
return False
@property
def user(self) -> dict | None:
"""
Get the logged-in user's details.
Returns:
dict | None: A dictionary containing the user's details if logged in, or None if not logged in.
"""
if self._user is not None or self.check_login():
return self._user
return None
+7 -3
View File
@@ -14,16 +14,20 @@ class Domain(ABC):
and methods for fetching and parsing data. Derived classes must implement the `_fetch_and_parse()` method.
"""
def __init__(self, key: str, session=None):
def __init__(self, key: str, session=None, region: str = "US"):
"""
Initializes the Domain object with a key, session.
Initializes the Domain object with a key, session, and region.
Args:
key (str): Unique key identifying the domain entity.
session (Optional[requests.Session]): Session object for HTTP requests. Defaults to None.
region (str): Yahoo region (ISO 3166-1 alpha-2 country code, e.g.
"US", "GB", "FR", "DE", "JP"). Determines the regional scope
of returned data such as ``top_companies``. Defaults to "US".
"""
self._key: str = key
self.session = session
self._region: str = region.strip().upper()
self._data: YfData = YfData(session=session)
self._name: Optional[str] = None
@@ -118,7 +122,7 @@ class Domain(ABC):
Returns:
Dict: The JSON response data from the request.
"""
params_dict = {"formatted": "true", "withReturns": "true", "lang": "en-US", "region": "US"}
params_dict = {"formatted": "true", "withReturns": "true", "lang": "en-US", "region": self._region}
result = self._data.get_raw_json(query_url, params=params_dict)
return result
+13 -10
View File
@@ -14,14 +14,17 @@ class Industry(Domain):
Represents an industry within a sector.
"""
def __init__(self, key, session=None):
def __init__(self, key, session=None, region: str = "US"):
"""
Args:
key (str): The key identifier for the industry.
session (optional): The session to use for requests.
region (str): Yahoo region (ISO 3166-1 alpha-2 country code, e.g.
"US", "GB", "FR", "DE", "JP"). Scopes top performing/growth
company listings. Defaults to "US".
"""
YfData(session=session)
super(Industry, self).__init__(key, session)
super(Industry, self).__init__(key, session, region)
self._query_url = f'{_QUERY_URL_}/industries/{self._key}'
self._sector_key = None
@@ -92,17 +95,17 @@ class Industry(Domain):
Returns:
Optional[pd.DataFrame]: DataFrame containing parsed top performing companies data.
"""
compnaies_column = ['symbol','name','ytd return','last price','target price']
compnaies_values = [(c.get('symbol', None),
companies_column = ['symbol','name','ytd return','last price','target price']
companies_values = [(c.get('symbol', None),
c.get('name', None),
c.get('ytdReturn',{}).get('raw', None),
c.get('lastPrice',{}).get('raw', None),
c.get('targetPrice',{}).get('raw', None),) for c in top_performing_companies]
if not compnaies_values:
if not companies_values:
return None
return _pd.DataFrame(compnaies_values, columns = compnaies_column).set_index('symbol')
return _pd.DataFrame(companies_values, columns = companies_column).set_index('symbol')
def _parse_top_growth_companies(self, top_growth_companies: Dict) -> Optional[_pd.DataFrame]:
"""
@@ -114,16 +117,16 @@ class Industry(Domain):
Returns:
Optional[pd.DataFrame]: DataFrame containing parsed top growth companies data.
"""
compnaies_column = ['symbol','name','ytd return','growth estimate']
compnaies_values = [(c.get('symbol', None),
companies_column = ['symbol','name','ytd return','growth estimate']
companies_values = [(c.get('symbol', None),
c.get('name', None),
c.get('ytdReturn',{}).get('raw', None),
c.get('growthEstimate',{}).get('raw', None),) for c in top_growth_companies]
if not compnaies_values:
if not companies_values:
return None
return _pd.DataFrame(compnaies_values, columns = compnaies_column).set_index('symbol')
return _pd.DataFrame(companies_values, columns = companies_column).set_index('symbol')
def _fetch_and_parse(self) -> None:
"""
+38 -2
View File
@@ -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"]),
+7 -4
View File
@@ -15,18 +15,21 @@ class Sector(Domain):
such as top ETFs, top mutual funds, and industry data.
"""
def __init__(self, key, session=None):
def __init__(self, key, session=None, region: str = "US"):
"""
Args:
key (str): The key representing the sector.
session (requests.Session, optional): A session for making requests. Defaults to None.
region (str): Yahoo region (ISO 3166-1 alpha-2 country code, e.g.
"US", "GB", "FR", "DE", "JP"). Scopes ``top_companies``,
``top_etfs`` and ``top_mutual_funds``. Defaults to "US".
.. seealso::
:attr:`Sector.industries <yfinance.Sector.industries>`
Map of sector and industry
"""
super(Sector, self).__init__(key, session)
super(Sector, self).__init__(key, session, region)
self._query_url: str = f'{_QUERY_URL_}/sectors/{self._key}'
self._top_etfs: Optional[Dict] = None
self._top_mutual_funds: Optional[Dict] = None
+113 -122
View File
@@ -22,20 +22,35 @@
from __future__ import print_function
import logging
import threading
import time as _time
import traceback
from typing import Union
import multitasking as _multitasking
import pandas as _pd
from curl_cffi import requests
import numpy as _np
from ._http import new_session
from . import Ticker, utils
from .data import YfData
from . import shared
from .config import YfConfig
from .const import period_default
class _DownloadCtx:
"""Per-call scratch state for download(). Concurrent calls each get
their own instance, so no shared mutation between threads."""
__slots__ = ('dfs', 'errors', 'tracebacks', 'isins', 'progress_bar', 'lock')
def __init__(self):
self.dfs = {}
self.errors = {}
self.tracebacks = {}
self.isins = {}
self.progress_bar = None
self.lock = threading.Lock()
@utils.log_indent_decorator
def download(tickers, start=None, end=None, actions=False, threads=True,
ignore_tz=None, group_by='column', auto_adjust=True, back_adjust=False,
@@ -92,148 +107,112 @@ def download(tickers, start=None, end=None, actions=False, threads=True,
multi_level_index: bool
Optional. Always return a MultiIndex DataFrame? Default is True
"""
shared._LOCK.acquire()
try:
return _download_impl(
tickers, start=start, end=end, actions=actions, threads=threads,
ignore_tz=ignore_tz, group_by=group_by, auto_adjust=auto_adjust,
back_adjust=back_adjust, repair=repair, keepna=keepna, progress=progress,
period=period, interval=interval, prepost=prepost, rounding=rounding,
timeout=timeout, session=session, multi_level_index=multi_level_index,
)
finally:
shared._LOCK.release()
return _download_impl(
_DownloadCtx(),
tickers, start=start, end=end, actions=actions, threads=threads,
ignore_tz=ignore_tz, group_by=group_by, auto_adjust=auto_adjust,
back_adjust=back_adjust, repair=repair, keepna=keepna, progress=progress,
period=period, interval=interval, prepost=prepost, rounding=rounding,
timeout=timeout, session=session, multi_level_index=multi_level_index,
)
def _download_impl(tickers, start=None, end=None, actions=False, threads=True,
def _download_impl(ctx, tickers, start=None, end=None, actions=False, threads=True,
ignore_tz=None, group_by='column', auto_adjust=True, back_adjust=False,
repair=False, keepna=False, progress=True, period=period_default, interval="1d",
prepost=False, rounding=False, timeout=10, session=None,
multi_level_index=True):
logger = utils.get_yf_logger()
session = session or requests.Session(impersonate="chrome")
session = session or new_session()
# Ensure data initialised with session.
YfData(session=session)
if logger.isEnabledFor(logging.DEBUG):
if threads:
# With DEBUG, each thread generates a lot of log messages.
# And with multi-threading, these messages will be interleaved, bad!
# So disable multi-threading to make log readable.
# multi-threaded log messages would interleave; serialize.
logger.debug('Disabling multithreading because DEBUG logging enabled')
threads = False
if progress:
# Disable progress bar, interferes with display of log messages
progress = False
if ignore_tz is None:
# Set default value depending on interval
if interval[-1] in ['m', 'h']:
# Intraday
ignore_tz = False
else:
ignore_tz = True
ignore_tz = interval[-1] not in ('m', 'h')
# create ticker list
tickers = tickers if isinstance(
tickers, (list, set, tuple)) else tickers.replace(',', ' ').split()
# accept isin as ticker
shared._ISINS = {}
_tickers_ = []
for ticker in tickers:
if utils.is_isin(ticker):
isin = ticker
ticker = utils.get_ticker_by_isin(ticker)
shared._ISINS[ticker] = isin
ctx.isins[ticker] = isin
_tickers_.append(ticker)
tickers = _tickers_
tickers = list(set([ticker.upper() for ticker in tickers]))
tickers = list(set([t.upper() for t in _tickers_]))
if progress:
shared._PROGRESS_BAR = utils.ProgressBar(len(tickers), 'completed')
ctx.progress_bar = utils.ProgressBar(len(tickers), 'completed')
# reset shared._DFS
shared._DFS = {}
shared._ERRORS = {}
shared._TRACEBACKS = {}
# download using threads
if threads:
if threads is True:
threads = min([len(tickers), _multitasking.cpu_count() * 2])
_multitasking.set_max_threads(threads)
for i, ticker in enumerate(tickers):
_download_one_threaded(ticker, period=period, interval=interval,
_download_one_threaded(ctx, ticker, period=period, interval=interval,
start=start, end=end, prepost=prepost,
actions=actions, auto_adjust=auto_adjust,
back_adjust=back_adjust, repair=repair, keepna=keepna,
progress=(progress and i > 0),
rounding=rounding, timeout=timeout)
while len(shared._DFS) < len(tickers):
while True:
with ctx.lock:
if len(ctx.dfs) >= len(tickers):
break
_time.sleep(0.01)
# download synchronously
else:
for i, ticker in enumerate(tickers):
data = _download_one(ticker, period=period, interval=interval,
start=start, end=end, prepost=prepost,
actions=actions, auto_adjust=auto_adjust,
back_adjust=back_adjust, repair=repair, keepna=keepna,
rounding=rounding, timeout=timeout)
_download_one(ctx, ticker, period=period, interval=interval,
start=start, end=end, prepost=prepost,
actions=actions, auto_adjust=auto_adjust,
back_adjust=back_adjust, repair=repair, keepna=keepna,
rounding=rounding, timeout=timeout)
if progress:
shared._PROGRESS_BAR.animate()
ctx.progress_bar.animate()
if progress:
shared._PROGRESS_BAR.completed()
ctx.progress_bar.completed()
if shared._ERRORS:
# Send errors to logging module
logger = utils.get_yf_logger()
if ctx.errors:
logger.error('\n%.f Failed download%s:' % (
len(shared._ERRORS), 's' if len(shared._ERRORS) > 1 else ''))
len(ctx.errors), 's' if len(ctx.errors) > 1 else ''))
# Log each distinct error once, with list of symbols affected
errors = {}
for ticker in shared._ERRORS:
err = shared._ERRORS[ticker]
for ticker, err in ctx.errors.items():
err = err.replace(f'${ticker}: ', '')
if err not in errors:
errors[err] = [ticker]
else:
errors[err].append(ticker)
for err in errors.keys():
logger.error(f'{errors[err]}: ' + err)
errors.setdefault(err, []).append(ticker)
for err, syms in errors.items():
logger.error(f'{syms}: ' + err)
# Log each distinct traceback once, with list of symbols affected
tbs = {}
for ticker in shared._TRACEBACKS:
tb = shared._TRACEBACKS[ticker]
for ticker, tb in ctx.tracebacks.items():
tb = tb.replace(f'${ticker}: ', '')
if tb not in tbs:
tbs[tb] = [ticker]
else:
tbs[tb].append(ticker)
for tb in tbs.keys():
logger.debug(f'{tbs[tb]}: ' + tb)
tbs.setdefault(tb, []).append(ticker)
for tb, syms in tbs.items():
logger.debug(f'{syms}: ' + tb)
if ignore_tz:
for tkr in shared._DFS.keys():
if (shared._DFS[tkr] is not None) and (shared._DFS[tkr].shape[0] > 0):
shared._DFS[tkr].index = shared._DFS[tkr].index.tz_localize(None)
for tkr, df in ctx.dfs.items():
if df is not None and df.shape[0] > 0:
df.index = df.index.tz_localize(None)
ctx.dfs = reindex_dfs(ctx.dfs, ignore_tz)
try:
data = _pd.concat(shared._DFS.values(), axis=1, sort=True,
keys=shared._DFS.keys(), names=['Ticker', 'Price'])
data = _pd.concat(ctx.dfs.values(), axis=1, sort=True,
keys=ctx.dfs.keys(), names=['Ticker', 'Price'])
except Exception:
_realign_dfs()
data = _pd.concat(shared._DFS.values(), axis=1, sort=True,
keys=shared._DFS.keys(), names=['Ticker', 'Price'])
data.index = _pd.to_datetime(data.index, utc=not ignore_tz)
# switch names back to isins if applicable
data.rename(columns=shared._ISINS, inplace=True)
data = _pd.concat(ctx.dfs.values(), axis=1, sort=True,
keys=ctx.dfs.keys(), names=['Ticker', 'Price'])
data.rename(columns=ctx.isins, inplace=True)
if group_by == 'column':
data.columns = data.columns.swaplevel(0, 1)
@@ -244,65 +223,77 @@ def _download_impl(tickers, start=None, end=None, actions=False, threads=True,
return data
def reindex_dfs(dfs, ignore_tz):
if ignore_tz:
for tkr in dfs.keys():
if (dfs[tkr] is not None) and (not dfs[tkr].empty):
dfs[tkr].index = dfs[tkr].index.tz_localize(None)
else:
# Align each df to most common timezone.
# Compare strings since np.unique can't handle tz objects
tzs = [str(df.index.tz) for df in dfs.values() if df is not None and not df.empty]
if tzs:
# Find most common timezone
unique_tzs, counts = _np.unique(tzs, return_counts=True)
tz_mode = unique_tzs[counts.argmax()]
for tkr in dfs.keys():
if (dfs[tkr] is not None) and (not dfs[tkr].empty):
dfs[tkr].index = dfs[tkr].index.tz_convert(tz_mode)
def _realign_dfs():
idx_len = 0
idx = None
for df in shared._DFS.values():
if len(df) > idx_len:
idx_len = len(df)
idx = df.index
for key in shared._DFS.keys():
try:
shared._DFS[key] = _pd.DataFrame(
index=idx, data=shared._DFS[key]).drop_duplicates()
except Exception:
shared._DFS[key] = _pd.concat([
utils.empty_df(idx), shared._DFS[key].dropna()
], axis=0, sort=True)
# remove duplicate index
shared._DFS[key] = shared._DFS[key].loc[
~shared._DFS[key].index.duplicated(keep='last')]
all_indices = set()
for df in dfs.values():
all_indices.update(df.index)
idx = sorted(all_indices)
idx = _pd.to_datetime(idx)
for key, df in dfs.items():
dfs[key] = df.reindex(idx)
return dfs
@_multitasking.task
def _download_one_threaded(ticker, start=None, end=None,
def _download_one_threaded(ctx, ticker, start=None, end=None,
auto_adjust=False, back_adjust=False, repair=False,
actions=False, progress=True, period=None,
interval="1d", prepost=False,
keepna=False, rounding=False, timeout=10):
_download_one(ticker, start, end, auto_adjust, back_adjust, repair,
actions, period, interval, prepost, rounding,
keepna, timeout)
_download_one(ctx, ticker, start, end, auto_adjust, back_adjust, repair,
actions, period, interval, prepost, rounding,
keepna, timeout)
if progress:
shared._PROGRESS_BAR.animate()
ctx.progress_bar.animate()
def _download_one(ticker, start=None, end=None,
def _download_one(ctx, ticker, start=None, end=None,
auto_adjust=False, back_adjust=False, repair=False,
actions=False, period=None, interval="1d",
prepost=False, rounding=False,
keepna=False, timeout=10):
data = None
sym = ticker.upper()
backup = YfConfig.network.hide_exceptions
YfConfig.network.hide_exceptions = False
try:
data = Ticker(ticker).history(
period=period, interval=interval,
start=start, end=end, prepost=prepost,
actions=actions, auto_adjust=auto_adjust,
back_adjust=back_adjust, repair=repair,
rounding=rounding, keepna=keepna, timeout=timeout
tkr = Ticker(ticker)
data = tkr.history(
period=period, interval=interval,
start=start, end=end, prepost=prepost,
actions=actions, auto_adjust=auto_adjust,
back_adjust=back_adjust, repair=repair,
rounding=rounding, keepna=keepna, timeout=timeout
)
shared._DFS[ticker.upper()] = data
with ctx.lock:
ctx.dfs[sym] = data
# PriceHistory records soft errors (e.g. delisted, missing tz)
# without raising; surface them so download() can log them.
ph = tkr._price_history
if ph is not None and ph._last_error is not None:
ctx.errors[sym] = ph._last_error
except Exception as e:
shared._DFS[ticker.upper()] = utils.empty_df()
shared._ERRORS[ticker.upper()] = repr(e)
shared._TRACEBACKS[ticker.upper()] = traceback.format_exc()
with ctx.lock:
ctx.dfs[sym] = utils.empty_df()
ctx.errors[sym] = repr(e)
ctx.tracebacks[sym] = traceback.format_exc()
YfConfig.network.hide_exceptions = backup
+2 -2
View File
@@ -1,4 +1,4 @@
import curl_cffi
from yfinance._http import HTTPError
import pandas as pd
from yfinance import utils
@@ -187,7 +187,7 @@ class Analysis:
params_dict = {"modules": modules, "corsDomain": "finance.yahoo.com", "formatted": "false", "symbol": self._symbol}
try:
result = self._data.get_raw_json(_QUOTE_SUMMARY_URL_ + f"/{self._symbol}", params=params_dict)
except curl_cffi.requests.exceptions.HTTPError as e:
except HTTPError as e:
if not YfConfig.debug.hide_exceptions:
raise
utils.get_yf_logger().error(str(e) + e.response.text)
+51 -25
View File
@@ -1,4 +1,4 @@
from curl_cffi import requests
from yfinance._http import new_session
from math import isclose
import bisect
import datetime as _datetime
@@ -9,9 +9,9 @@ import pandas as pd
import time as _time
import warnings
from yfinance import shared, utils
from yfinance import utils
from yfinance.config import YfConfig
from yfinance.const import _BASE_URL_, _PRICE_COLNAMES_, period_default
from yfinance.const import _BASE_URL_, _PRICE_COLNAMES_, period_default, _SENTINEL_
from yfinance.exceptions import YFDataException, YFInvalidPeriodError, YFPricesMissingError, YFRateLimitError, YFTzMissingError
class PriceHistory:
@@ -19,15 +19,21 @@ class PriceHistory:
self._data = data
self.ticker = ticker.upper()
self.tz = tz
self.session = session or requests.Session(impersonate="chrome")
self.session = session or new_session()
self._history_cache = {}
self._history_metadata = None
self._history_metadata_formatted = False
self._dividends = None
self._splits = None
self._capital_gains = None
# Limit recursion depth when repairing prices
self._reconstruct_start_interval = None
self._last_error = None
@utils.log_indent_decorator
def history(self, period=period_default, interval="1d",
start=None, end=None, prepost=False, actions=True,
@@ -101,8 +107,7 @@ class PriceHistory:
# Every valid ticker has a timezone. A missing timezone is a problem.
_exception = YFTzMissingError(self.ticker)
err_msg = str(_exception)
shared._DFS[self.ticker] = utils.empty_df()
shared._ERRORS[self.ticker] = err_msg.split(': ', 1)[1]
self._last_error = err_msg.split(': ', 1)[1]
if raise_errors or (not YfConfig.debug.hide_exceptions):
raise _exception
else:
@@ -127,8 +132,7 @@ class PriceHistory:
# Every valid ticker has a timezone. A missing timezone is a problem.
_exception = YFTzMissingError(self.ticker)
err_msg = str(_exception)
shared._DFS[self.ticker] = utils.empty_df()
shared._ERRORS[self.ticker] = err_msg.split(': ', 1)[1]
self._last_error = err_msg.split(': ', 1)[1]
if raise_errors or (not YfConfig.debug.hide_exceptions):
raise _exception
else:
@@ -229,15 +233,19 @@ class PriceHistory:
raise
# Store the meta data that gets retrieved simultaneously
safe_chart = (data or {}).get('chart') or {}
result_list = safe_chart.get('result')
if isinstance(result_list, list) and len(result_list) > 0:
first_item = result_list[0] or {}
meta = first_item.get('meta') or {}
else:
try:
safe_chart = (data or {}).get('chart') or {}
result_list = safe_chart.get('result')
if isinstance(result_list, list) and len(result_list) > 0:
first_item = result_list[0] or {}
meta = first_item.get('meta') or {}
else:
meta = {}
except Exception:
meta = {}
self._history_metadata = meta
self._history_metadata['YF repair?'] = repair
intraday = params["interval"][-1] in ("m", 'h')
_price_data_debug = ''
@@ -267,11 +275,11 @@ class PriceHistory:
_price_data_debug += f"(Yahoo status_code = {data['status_code']})"
_exception = YFPricesMissingError(self.ticker, _price_data_debug)
fail = True
elif "chart" in data and data["chart"]["error"]:
elif "chart" in data and data["chart"] and data["chart"]["error"]:
_price_data_debug += ' (Yahoo error = "' + data["chart"]["error"]["description"] + '")'
_exception = YFPricesMissingError(self.ticker, _price_data_debug)
fail = True
elif "chart" not in data or data["chart"]["result"] is None or not data["chart"]["result"] or not data["chart"]["result"][0]["indicators"]["quote"][0]:
elif "chart" not in data or not data["chart"] or data["chart"]["result"] is None or not data["chart"]["result"] or not data["chart"]["result"][0]["indicators"]["quote"][0]:
_exception = YFPricesMissingError(self.ticker, _price_data_debug)
fail = True
elif period and period not in self._history_metadata['validRanges'] and not utils.is_valid_period_format(period):
@@ -281,8 +289,7 @@ class PriceHistory:
if fail:
err_msg = str(_exception)
shared._DFS[self.ticker] = utils.empty_df()
shared._ERRORS[self.ticker] = err_msg.split(': ', 1)[1]
self._last_error = err_msg.split(': ', 1)[1]
if raise_errors or (not YfConfig.debug.hide_exceptions):
raise _exception
else:
@@ -496,8 +503,7 @@ class PriceHistory:
err_msg = "auto_adjust failed with %s" % e
else:
err_msg = "back_adjust failed with %s" % e
shared._DFS[self.ticker] = utils.empty_df()
shared._ERRORS[self.ticker] = err_msg
self._last_error = err_msg
logger.error('%s: %s' % (self.ticker, err_msg))
if rounding:
@@ -545,10 +551,23 @@ class PriceHistory:
'capital gains': self._capital_gains}
return self._history_cache[cache_key]
def get_history_metadata(self) -> dict:
def get_history_metadata(self, repair=_SENTINEL_) -> dict:
"""
repair default value depends on whether user requested price repair
with previous history() call. If user did not set repair here, then
it is set to match previous history() call.
"""
# - repair affects currency, particularly GBp -> GBP
if self._history_metadata is None or 'tradingPeriods' not in self._history_metadata:
# Request intraday data, because then Yahoo returns exchange schedule (tradingPeriods).
self._get_history_cache(period="5d", interval="1h")['prices']
if repair == _SENTINEL_:
if self._history_metadata is not None:
repair = self._history_metadata['YF repair?']
else:
# default
repair = False
self._get_history_cache(period="5d", interval="1h", repair=repair)['prices']
if self._history_metadata_formatted is False:
self._history_metadata = utils.format_history_metadata(self._history_metadata)
@@ -635,6 +654,11 @@ class PriceHistory:
else:
df2 = df.resample(resample_period, label='left', closed='left', offset=offset).agg(resample_map)
df2.loc[df2['Stock Splits']==1.0, 'Stock Splits'] = 0.0
# Handle NaNs from very long holidays.
prev_close = df2['Close'].shift(1).ffill()
for c in ['Open', 'High', 'Low', 'Close']:
df2[c] = df2[c].fillna(prev_close)
return df2
@utils.log_indent_decorator
@@ -2231,8 +2255,7 @@ class PriceHistory:
if c == 'adj_exceeds_prices':
continue
if c == 'phantom' and self.ticker in ['KAP.IL', 'SAND']:
# Manually approve, but these are probably safe to assume ok
if c == 'phantom':
continue
if c == 'div_date_wrong':
@@ -2710,7 +2733,8 @@ class PriceHistory:
df_workings['VolStr'] = ''
df_workings.loc[fna, 'VolStr'] = 'NaN'
df_workings.loc[~fna, 'VolStr'] = (df_workings['Vol'][~fna]/1e6).astype('int').astype('str') + 'm'
df_workings['Vol'] = df_workings['VolStr'] ; df_workings.drop('VolStr', axis=1)
df_workings['Vol'] = df_workings['VolStr']
df_workings.drop('VolStr', axis=1)
else:
df_workings['Vol'] = (df_workings['Vol']/1e6).astype('int').astype('str') + 'm'
debug_cols = ['Close']
@@ -2925,6 +2949,8 @@ class PriceHistory:
def _calc_volume_zscore(volume, block):
# print(f"_calc_volume_zscore(volume={volume})")
values = block['Volume'].to_numpy()
if len(values) == 0 or (values == 0).all():
return 0
std = np.std(values, ddof=1)
if std == 0.0:
return 0
+2 -2
View File
@@ -1,4 +1,4 @@
import curl_cffi
from yfinance._http import HTTPError
import pandas as pd
from yfinance import utils
@@ -71,7 +71,7 @@ class Holders:
def _fetch_and_parse(self):
try:
result = self._fetch()
except curl_cffi.requests.exceptions.HTTPError as e:
except HTTPError as e:
if not YfConfig.debug.hide_exceptions:
raise
utils.get_yf_logger().error(str(e) + e.response.text)
+5 -5
View File
@@ -1,4 +1,4 @@
import curl_cffi
from yfinance._http import HTTPError
import datetime
import json
import numpy as _np
@@ -591,10 +591,10 @@ class Quote:
modules = ','.join([m for m in modules if m in quote_summary_valid_modules])
if len(modules) == 0:
raise YFException("No valid modules provided, see available modules using `valid_modules`")
params_dict = {"modules": modules, "corsDomain": "finance.yahoo.com", "formatted": "false", "symbol": self._symbol}
params_dict = {"modules": modules, "corsDomain": "finance.yahoo.com", "formatted": "false", "symbol": self._symbol, "lang": YfConfig.locale.lang, "region": YfConfig.locale.region}
try:
result = self._data.get_raw_json(_QUOTE_SUMMARY_URL_ + f"/{self._symbol}", params=params_dict)
except curl_cffi.requests.exceptions.HTTPError as e:
except HTTPError as e:
if not YfConfig.debug.hide_exceptions:
raise
utils.get_yf_logger().error(str(e) + e.response.text)
@@ -602,10 +602,10 @@ class Quote:
return result
def _fetch_additional_info(self):
params_dict = {"symbols": self._symbol, "formatted": "false"}
params_dict = {"symbols": self._symbol, "formatted": "false", "lang": YfConfig.locale.lang, "region": YfConfig.locale.region}
try:
result = self._data.get_raw_json(f"{_QUERY1_URL_}/v7/finance/quote?", params=params_dict)
except curl_cffi.requests.exceptions.HTTPError as e:
except HTTPError as e:
if not YfConfig.debug.hide_exceptions:
raise
utils.get_yf_logger().error(str(e) + e.response.text)
+2 -2
View File
@@ -1,4 +1,4 @@
import curl_cffi
from yfinance._http import HTTPError
from typing import Union
import warnings
from json import dumps
@@ -176,7 +176,7 @@ def screen(query: Union[str, EquityQuery, FundQuery, ETFQuery],
resp = _data.get(url=_PREDEFINED_URL_, params=params_dict)
try:
resp.raise_for_status()
except curl_cffi.requests.exceptions.HTTPError:
except HTTPError:
if query not in PREDEFINED_SCREENER_QUERIES:
print(f"yfinance.screen: '{query}' is probably not a predefined query.")
raise
+29 -1
View File
@@ -43,6 +43,34 @@ from yfinance import const
from yfinance.exceptions import YFException
from yfinance.config import YfConfig
# Use the third-party ``frozendict`` package if installed; otherwise fall
# back to a small pure-Python equivalent (PEP 814).
try:
from frozendict import frozendict # type: ignore[import-not-found]
except ImportError:
class frozendict(dict): # type: ignore[no-redef]
"""Hashable, read-only ``dict`` used as an ``lru_cache`` key."""
__slots__ = ()
def __hash__(self): # type: ignore[override]
return hash(frozenset(self.items()))
def __setitem__(self, *args, **kwargs):
raise TypeError(f"'{type(self).__name__}' object doesn't support item assignment")
def __delitem__(self, *args, **kwargs):
raise TypeError(f"'{type(self).__name__}' object doesn't support item deletion")
def _readonly(self, *args, **kwargs):
raise AttributeError(f"'{type(self).__name__}' object is read-only")
pop = _readonly # type: ignore[assignment]
popitem = _readonly # type: ignore[assignment]
clear = _readonly # type: ignore[assignment]
update = _readonly # type: ignore[assignment]
setdefault = _readonly # type: ignore[assignment]
# From https://stackoverflow.com/a/59128615
def attributes(obj):
disallowed_names = {
@@ -624,7 +652,7 @@ def _dts_in_same_interval(dt1, dt2, interval):
elif interval == "1wk":
last_rows_same_interval = (dt2 - dt1).days < 7
elif interval == "1mo":
last_rows_same_interval = dt1.month == dt2.month
last_rows_same_interval = dt1.month == dt2.month and dt1.year == dt2.year
elif interval == "3mo":
shift = (dt1.month % 3) - 1
q1 = (dt1.month - shift - 1) // 3 + 1