diff --git a/README.md b/README.md index bd47e8f3..6f558c19 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Yahoo! finance API is intended for personal use only.** - `Ticker`: single ticker data - `Tickers`: multiple tickers' data - `download`: download market data for multiple tickers +- `Search`: quotes and news from search - `Sector` and `Industry`: sector and industry information - `EquityQuery` and `Screener`: build query to screen market diff --git a/doc/source/reference/examples/search.py b/doc/source/reference/examples/search.py new file mode 100644 index 00000000..20ca1fd1 --- /dev/null +++ b/doc/source/reference/examples/search.py @@ -0,0 +1,7 @@ +import yfinance as yf + +# get list of quotes +quotes = yf.Search("AAPL", max_results=10).quotes + +# get list of news +news = yf.Search("Google", news_count=10).news \ No newline at end of file diff --git a/doc/source/reference/index.rst b/doc/source/reference/index.rst index f1807da2..bbfa5455 100644 --- a/doc/source/reference/index.rst +++ b/doc/source/reference/index.rst @@ -15,6 +15,7 @@ The following are the publicly available classes, and functions exposed by the ` - :attr:`Ticker `: Class for accessing single ticker data. - :attr:`Tickers `: Class for handling multiple tickers. +- :attr:`Search `: Class for accessing search results. - :attr:`Sector `: Domain class for accessing sector information. - :attr:`Industry `: Domain class for accessing industry information. - :attr:`download `: Function to download market data for multiple tickers. @@ -32,6 +33,7 @@ The following are the publicly available classes, and functions exposed by the ` yfinance.stock yfinance.financials yfinance.analysis + yfinance.search yfinance.sector_industry yfinance.functions diff --git a/doc/source/reference/yfinance.search.rst b/doc/source/reference/yfinance.search.rst new file mode 100644 index 00000000..8ab2e5c7 --- /dev/null +++ b/doc/source/reference/yfinance.search.rst @@ -0,0 +1,22 @@ +===================== +Search & News +===================== + +.. currentmodule:: yfinance + + +Class +------------ +The `Search` module, allows you to access search data in a Pythonic way. + +.. autosummary:: + :toctree: api/ + + Search + +Search Sample Code +------------------ +The `Search` module, allows you to access search data in a Pythonic way. + +.. literalinclude:: examples/search.py + :language: python diff --git a/tests/test_screener.py b/tests/test_screener.py index 17f4988a..0ec6c201 100644 --- a/tests/test_screener.py +++ b/tests/test_screener.py @@ -13,7 +13,7 @@ def setUpClass(self): self.query = EquityQuery('gt',['eodprice',3]) def test_set_default_body(self): - self.screener.set_default_body(self.query) + result = self.screener.set_default_body(self.query) self.assertEqual(self.screener.body['offset'], 0) self.assertEqual(self.screener.body['size'], 100) @@ -23,11 +23,13 @@ def test_set_default_body(self): self.assertEqual(self.screener.body['query'], self.query.to_dict()) self.assertEqual(self.screener.body['userId'], '') self.assertEqual(self.screener.body['userIdType'], 'guid') + self.assertEqual(self.screener, result) def test_set_predefined_body(self): k = 'most_actives' - self.screener.set_predefined_body(k) + result = self.screener.set_predefined_body(k) self.assertEqual(self.screener.body, PREDEFINED_SCREENER_BODY_MAP[k]) + self.assertEqual(self.screener, result) def test_set_predefined_body_invalid_key(self): with self.assertRaises(ValueError): @@ -44,9 +46,10 @@ def test_set_body(self): "userId": "", "userIdType": "guid" } - self.screener.set_body(body) + result = self.screener.set_body(body) self.assertEqual(self.screener.body, body) + self.assertEqual(self.screener, result) def test_set_body_missing_keys(self): body = { @@ -87,10 +90,11 @@ def test_patch_body(self): } self.screener.set_body(initial_body) patch_values = {"size": 50} - self.screener.patch_body(patch_values) + result = self.screener.patch_body(patch_values) self.assertEqual(self.screener.body['size'], 50) self.assertEqual(self.screener.body['query'], self.query.to_dict()) + self.assertEqual(self.screener, result) def test_patch_body_extra_keys(self): initial_body = { @@ -108,6 +112,22 @@ def test_patch_body_extra_keys(self): with self.assertRaises(ValueError): self.screener.patch_body(patch_values) + @patch('yfinance.screener.screener.YfData.post') + def test_set_large_size_in_body(self, mock_post): + body = { + "offset": 0, + "size": 251, # yahoo limits at 250 + "sortField": "ticker", + "sortType": "desc", + "quoteType": "equity", + "query": self.query.to_dict(), + "userId": "", + "userIdType": "guid" + } + + with self.assertRaises(ValueError): + self.screener.set_body(body).response + @patch('yfinance.screener.screener.YfData.post') def test_fetch(self, mock_post): mock_response = MagicMock() diff --git a/tests/test_search.py b/tests/test_search.py new file mode 100644 index 00000000..9e074e5c --- /dev/null +++ b/tests/test_search.py @@ -0,0 +1,31 @@ +import unittest + +from tests.context import yfinance as yf + + +class TestSearch(unittest.TestCase): + def test_valid_query(self): + search = yf.Search(query="AAPL", max_results=5, news_count=3) + + self.assertEqual(len(search.quotes), 5) + self.assertEqual(len(search.news), 3) + self.assertIn("AAPL", search.quotes[0]['symbol']) + + def test_invalid_query(self): + search = yf.Search(query="XYZXYZ") + + self.assertEqual(len(search.quotes), 0) + self.assertEqual(len(search.news), 0) + + def test_empty_query(self): + search = yf.Search(query="") + + self.assertEqual(len(search.quotes), 0) + self.assertEqual(len(search.news), 0) + + def test_fuzzy_query(self): + search = yf.Search(query="Appel", enable_fuzzy_query=True) + + # Check if the fuzzy search retrieves relevant results despite the typo + self.assertGreater(len(search.quotes), 0) + self.assertIn("AAPL", search.quotes[0]['symbol']) diff --git a/yfinance/__init__.py b/yfinance/__init__.py index 7a825558..bb79ca98 100644 --- a/yfinance/__init__.py +++ b/yfinance/__init__.py @@ -20,6 +20,7 @@ # from . import version +from .search import Search from .ticker import Ticker from .tickers import Tickers from .multi import download @@ -36,5 +37,5 @@ import warnings warnings.filterwarnings('default', category=DeprecationWarning, module='^yfinance') -__all__ = ['download', 'Ticker', 'Tickers', 'enable_debug_mode', 'set_tz_cache_location', 'Sector', 'Industry', - 'EquityQuery','Screener'] +__all__ = ['download', 'Search', 'Ticker', 'Tickers', 'enable_debug_mode', 'set_tz_cache_location', 'Sector', + 'Industry', 'EquityQuery', 'Screener'] diff --git a/yfinance/base.py b/yfinance/base.py index 3bca8471..b2eda5e3 100644 --- a/yfinance/base.py +++ b/yfinance/base.py @@ -535,26 +535,45 @@ def get_isin(self, proxy=None) -> Optional[str]: self._isin = data.split(search_str)[1].split('"')[0].split('|')[0] return self._isin - def get_news(self, proxy=None) -> list: + def get_news(self, count=10, tab="news", proxy=None) -> list: + """Allowed options for tab: "news", "all", "press releases""" if self._news: return self._news - # Getting data from json - url = f"{_BASE_URL_}/v1/finance/search?q={self.ticker}" - data = self._data.cache_get(url=url, proxy=proxy) + logger = utils.get_yf_logger() + + tab_queryrefs = { + "all": "newsAll", + "news": "latestNews", + "press releases": "pressRelease", + } + + query_ref = tab_queryrefs.get(tab.lower()) + if not query_ref: + raise ValueError(f"Invalid tab name '{tab}'. Choose from: {', '.join(tab_queryrefs.keys())}") + + url = f"{_ROOT_URL_}/xhr/ncp?queryRef={query_ref}&serviceKey=ncp_fin" + payload = { + "serviceConfig": { + "snippetCount": count, + "s": [self.ticker] + } + } + + data = self._data.post(url, body=payload, proxy=proxy) if data is None or "Will be right back" in data.text: raise RuntimeError("*** YAHOO! FINANCE IS CURRENTLY DOWN! ***\n" "Our engineers are working quickly to resolve " "the issue. Thank you for your patience.") try: data = data.json() - except (_json.JSONDecodeError): - logger = utils.get_yf_logger() + except _json.JSONDecodeError: logger.error(f"{self.ticker}: Failed to retrieve the news and received faulty response instead.") data = {} - # parse news - self._news = data.get("news", []) + news = data.get("data", {}).get("tickerStream", {}).get("stream", []) + + self._news = [article for article in news if not article.get('ad', [])] return self._news @utils.log_indent_decorator diff --git a/yfinance/multi.py b/yfinance/multi.py index 7da0b3ff..490189f1 100644 --- a/yfinance/multi.py +++ b/yfinance/multi.py @@ -36,7 +36,7 @@ @utils.log_indent_decorator def download(tickers, start=None, end=None, actions=False, threads=True, - ignore_tz=None, group_by='column', auto_adjust=False, back_adjust=False, + ignore_tz=None, group_by='column', auto_adjust=True, back_adjust=False, repair=False, keepna=False, progress=True, period="max", interval="1d", prepost=False, proxy=None, rounding=False, timeout=10, session=None, multi_level_index=True) -> Union[_pd.DataFrame, None]: @@ -65,7 +65,7 @@ def download(tickers, start=None, end=None, actions=False, threads=True, Include Pre and Post market data in results? Default is False auto_adjust: bool - Adjust all OHLC automatically? Default is False + Adjust all OHLC automatically? Default is True repair: bool Detect currency unit 100x mixups and attempt repair Default is False diff --git a/yfinance/screener/screener.py b/yfinance/screener/screener.py index cf6e1688..01ff667b 100644 --- a/yfinance/screener/screener.py +++ b/yfinance/screener/screener.py @@ -67,9 +67,22 @@ def predefined_bodies(self) -> Dict: """ return self._predefined_bodies - def set_default_body(self, query: Query, offset: int = 0, size: int = 100, sortField: str = "ticker", sortType: str = "desc", quoteType: str = "equity", userId: str = "", userIdType: str = "guid") -> None: + def set_default_body(self, query: Query, offset: int = 0, size: int = 100, sortField: str = "ticker", sortType: str = "desc", quoteType: str = "equity", userId: str = "", userIdType: str = "guid") -> 'Screener': """ - Set the default body using a custom query + Set the default body using a custom query. + + Args: + query (Query): The Query object to set as the body. + offset (Optional[int]): The offset for the results. Defaults to 0. + size (Optional[int]): The number of results to return. Defaults to 100. Maximum is 250 as set by Yahoo. + sortField (Optional[str]): The field to sort the results by. Defaults to "ticker". + sortType (Optional[str]): The type of sorting (e.g., "asc" or "desc"). Defaults to "desc". + quoteType (Optional[str]): The type of quote (e.g., "equity"). Defaults to "equity". + userId (Optional[str]): The user ID. Defaults to an empty string. + userIdType (Optional[str]): The type of user ID (e.g., "guid"). Defaults to "guid". + + Returns: + Screener: self Example: @@ -89,11 +102,18 @@ def set_default_body(self, query: Query, offset: int = 0, size: int = 100, sortF "userId": userId, "userIdType": userIdType } + return self - def set_predefined_body(self, k: str) -> None: + def set_predefined_body(self, predefined_key: str) -> 'Screener': """ Set a predefined body + Args: + predefined_key (str): key to one of predefined screens + + Returns: + Screener: self + Example: .. code-block:: python @@ -106,16 +126,23 @@ def set_predefined_body(self, k: str) -> None: :attr:`Screener.predefined_bodies ` supported predefined screens """ - body = PREDEFINED_SCREENER_BODY_MAP.get(k, None) + body = PREDEFINED_SCREENER_BODY_MAP.get(predefined_key, None) if not body: - raise ValueError(f'Invalid key {k} provided for predefined screener') + raise ValueError(f'Invalid key {predefined_key} provided for predefined screener') self._body_updated = True self._body = body + return self - def set_body(self, body: Dict) -> None: + def set_body(self, body: Dict) -> 'Screener': """ - Set the fully custom body + Set the fully custom body using dictionary input + + Args: + body (Dict): full query body + + Returns: + Screener: self Example: @@ -142,11 +169,17 @@ def set_body(self, body: Dict) -> None: self._body_updated = True self._body = body + return self - - def patch_body(self, values: Dict) -> None: + def patch_body(self, values: Dict) -> 'Screener': """ - Patch parts of the body + Patch parts of the body using dictionary input + + Args: + body (Dict): partial query body + + Returns: + Screener: self Example: @@ -161,10 +194,14 @@ def patch_body(self, values: Dict) -> None: self._body_updated = True for k in values: self._body[k] = values[k] + return self def _validate_body(self) -> None: if not all(k in self._body for k in self._accepted_body_keys): raise ValueError("Missing required keys in body") + + if self._body["size"] > 250: + raise ValueError("Yahoo limits query size to 250. Please decrease the size of the query.") def _fetch(self) -> Dict: params_dict = {"corsDomain": "finance.yahoo.com", "formatted": "false", "lang": "en-US", "region": "US"} diff --git a/yfinance/search.py b/yfinance/search.py new file mode 100644 index 00000000..7dbafc93 --- /dev/null +++ b/yfinance/search.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# yfinance - market data downloader +# https://github.com/ranaroussi/yfinance +# +# Copyright 2017-2019 Ran Aroussi +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import json as _json + +from . import utils +from .const import _BASE_URL_ +from .data import YfData + + +class Search: + def __init__(self, query, max_results=8, news_count=8, enable_fuzzy_query=False, + session=None, proxy=None, timeout=30, raise_errors=True): + """ + Fetches and organizes search results from Yahoo Finance, including stock quotes and news articles. + + Args: + query: The search query (ticker symbol or company name). + max_results: Maximum number of stock quotes to return (default 8). + news_count: Number of news articles to include (default 8). + enable_fuzzy_query: Enable fuzzy search for typos (default False). + session: Custom HTTP session for requests (default None). + proxy: Proxy settings for requests (default None). + timeout: Request timeout in seconds (default 30). + raise_errors: Raise exceptions on error (default True). + """ + self.query = query + self.max_results = max_results + self.enable_fuzzy_query = enable_fuzzy_query + self.news_count = news_count + self.session = session + self.proxy = proxy + self.timeout = timeout + self.raise_errors = raise_errors + + self._data = YfData(session=self.session) + self._logger = utils.get_yf_logger() + + self._response = self._fetch_results() + self._quotes = self._response.get("quotes", []) + self._news = self._response.get("news", []) + + def _fetch_results(self): + url = f"{_BASE_URL_}/v1/finance/search" + params = { + "q": self.query, + "quotesCount": self.max_results, + "enableFuzzyQuery": self.enable_fuzzy_query, + "newsCount": self.news_count, + "quotesQueryId": "tss_match_phrase_query", + "newsQueryId": "news_cie_vespa" + } + + self._logger.debug(f'{self.query}: Yahoo GET parameters: {str(dict(params))}') + + data = self._data.cache_get(url=url, params=params, proxy=self.proxy, timeout=self.timeout) + if data is None or "Will be right back" in data.text: + raise RuntimeError("*** YAHOO! FINANCE IS CURRENTLY DOWN! ***\n" + "Our engineers are working quickly to resolve " + "the issue. Thank you for your patience.") + try: + data = data.json() + except _json.JSONDecodeError: + self._logger.error(f"{self.query}: Failed to retrieve the news and received faulty response instead.") + data = {} + + return data + + @property + def quotes(self): + """Get the quotes from the search results.""" + return self._quotes + + @property + def news(self): + """Get the news from the search results.""" + return self._news diff --git a/yfinance/utils.py b/yfinance/utils.py index 612e70b9..54820d1a 100644 --- a/yfinance/utils.py +++ b/yfinance/utils.py @@ -39,7 +39,6 @@ from pytz import UnknownTimeZoneError from yfinance import const -from .const import _BASE_URL_ user_agent_headers = { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.95 Safari/537.36'} @@ -189,24 +188,27 @@ def is_isin(string): def get_all_by_isin(isin, proxy=None, session=None): if not (is_isin(isin)): raise ValueError("Invalid ISIN number") + + # Deferred this to prevent circular imports + from .search import Search + session = session or _requests - url = f"{_BASE_URL_}/v1/finance/search?q={isin}" - data = session.get(url=url, proxies=proxy, headers=user_agent_headers) - try: - data = data.json() - ticker = data.get('quotes', [{}])[0] - return { - 'ticker': { - 'symbol': ticker['symbol'], - 'shortname': ticker['shortname'], - 'longname': ticker.get('longname',''), - 'type': ticker['quoteType'], - 'exchange': ticker['exchDisp'], - }, - 'news': data.get('news', []) - } - except Exception: - return {} + search = Search(query=isin, max_results=1, session=session, proxy=proxy) + + # Extract the first quote and news + ticker = search.quotes[0] if search.quotes else {} + news = search.news + + return { + 'ticker': { + 'symbol': ticker.get('symbol', ''), + 'shortname': ticker.get('shortname', ''), + 'longname': ticker.get('longname', ''), + 'type': ticker.get('quoteType', ''), + 'exchange': ticker.get('exchDisp', ''), + }, + 'news': news + } def get_ticker_by_isin(isin, proxy=None, session=None):