From 7a15d05acad574e10a460577aeb776092cff4b81 Mon Sep 17 00:00:00 2001 From: R5dan Date: Mon, 23 Dec 2024 13:37:47 +0000 Subject: [PATCH 1/5] Update user types for Screener --- tests/test_screener.py | 3 +++ yfinance/screener/screener.py | 28 ++++++++++++++++------------ 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/tests/test_screener.py b/tests/test_screener.py index 0ec6c201e..739fa5818 100644 --- a/tests/test_screener.py +++ b/tests/test_screener.py @@ -12,6 +12,9 @@ def setUpClass(self): self.screener = Screener() self.query = EquityQuery('gt',['eodprice',3]) + def test_screener_types(self): + self.assertEqual(['aggressive_small_caps', 'day_gainers', 'day_losers', 'growth_technology_stocks', 'most_actives', 'most_shorted_stocks', 'small_cap_gainers', 'undervalued_growth_stocks', 'undervalued_large_caps', 'conservative_foreign_funds', 'high_yield_bond', 'portfolio_anchors', 'solid_large_growth_funds', 'solid_midcap_growth_funds', 'top_mutual_funds'], list(PREDEFINED_SCREENER_BODY_MAP.keys())) + def test_set_default_body(self): result = self.screener.set_default_body(self.query) diff --git a/yfinance/screener/screener.py b/yfinance/screener/screener.py index 01ff667b1..06c2a8ff7 100644 --- a/yfinance/screener/screener.py +++ b/yfinance/screener/screener.py @@ -1,4 +1,5 @@ -from typing import Dict +from _collections_abc import dict_keys +from typing import Literal, TypedDict from yfinance import utils from yfinance.data import YfData @@ -8,6 +9,9 @@ _SCREENER_URL_ = f"{_BASE_URL_}/v1/finance/screener" +_REQUIRED_BODY_TYPE_ = TypedDict("_REQUIRED_BODY_TYPE_", {'offset': int, 'size': int, 'sortField': str, 'sortType': str, 'quoteType': str, 'query': dict, 'userId': str, 'userIdType': str}) +_BODY_TYPE_ = TypedDict("_BODY_TYPE_", {'offset': int, 'size': int, 'sortField': str, 'sortType': str, 'quoteType': str, 'query': dict, 'userId': str, 'userIdType': str}, total=False) + class Screener: """ The `Screener` class is used to execute the queries and return the filtered results. @@ -30,18 +34,18 @@ def __init__(self, session=None, proxy=None): self.session = session self._data: YfData = YfData(session=session) - self._body: Dict = {} - self._response: Dict = {} + self._body: '_REQUIRED_BODY_TYPE_' = {} # type: ignore # Type is invalid till body is set + self._response: 'dict' = {} self._body_updated = False self._accepted_body_keys = {"offset","size","sortField","sortType","quoteType","query","userId","userIdType"} self._predefined_bodies = PREDEFINED_SCREENER_BODY_MAP.keys() @property - def body(self) -> Dict: + def body(self) -> '_REQUIRED_BODY_TYPE_': return self._body @property - def response(self) -> Dict: + def response(self) -> 'dict': """ Fetch screen result @@ -60,14 +64,14 @@ def response(self) -> Dict: @dynamic_docstring({"predefined_screeners": generate_list_table_from_dict_of_dict(PREDEFINED_SCREENER_BODY_MAP,bullets=False)}) @property - def predefined_bodies(self) -> Dict: + def predefined_bodies(self) -> 'dict_keys': """ Predefined Screeners {predefined_screeners} """ 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") -> 'Screener': + 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. @@ -104,7 +108,7 @@ def set_default_body(self, query: Query, offset: int = 0, size: int = 100, sortF } return self - def set_predefined_body(self, predefined_key: str) -> 'Screener': + def set_predefined_body(self, predefined_key: 'Literal["aggressive_small_caps", "day_gainers", "day_losers", "growth_technology_stocks", "most_actives", "most_shorted_stocks", "small_cap_gainers", "undervalued_growth_stocks", "undervalued_large_caps", "conservative_foreign_funds", "high_yield_bond", "portfolio_anchors", "solid_large_growth_funds", "solid_midcap_growth_funds", "top_mutual_funds"]') -> 'Screener': """ Set a predefined body @@ -134,12 +138,12 @@ def set_predefined_body(self, predefined_key: str) -> 'Screener': self._body = body return self - def set_body(self, body: Dict) -> 'Screener': + def set_body(self, body: '_REQUIRED_BODY_TYPE_') -> 'Screener': """ Set the fully custom body using dictionary input Args: - body (Dict): full query body + body (dict): full query body Returns: Screener: self @@ -171,7 +175,7 @@ def set_body(self, body: Dict) -> 'Screener': self._body = body return self - def patch_body(self, values: Dict) -> 'Screener': + def patch_body(self, values: '_BODY_TYPE_') -> 'Screener': """ Patch parts of the body using dictionary input @@ -203,7 +207,7 @@ def _validate_body(self) -> None: if self._body["size"] > 250: raise ValueError("Yahoo limits query size to 250. Please decrease the size of the query.") - def _fetch(self) -> Dict: + def _fetch(self) -> 'dict': params_dict = {"corsDomain": "finance.yahoo.com", "formatted": "false", "lang": "en-US", "region": "US"} response = self._data.post(_SCREENER_URL_, body=self.body, user_agent_headers=self._data.user_agent_headers, params=params_dict, proxy=self.proxy) response.raise_for_status() From a6226c7b6a9dac2dc1aa700c2656b12634169b04 Mon Sep 17 00:00:00 2001 From: R5dan Date: Mon, 30 Dec 2024 23:45:29 +0000 Subject: [PATCH 2/5] Add predefined queries --- yfinance/const.py | 56 +++++++++++++++++++--------- yfinance/screener/screener.py | 70 ++++++++++++++++++++++------------- 2 files changed, 83 insertions(+), 43 deletions(-) diff --git a/yfinance/const.py b/yfinance/const.py index 2735f2544..ac5f54ba9 100644 --- a/yfinance/const.py +++ b/yfinance/const.py @@ -1,3 +1,5 @@ +from typing import Literal + _QUERY1_URL_ = 'https://query1.finance.yahoo.com' _BASE_URL_ = 'https://query2.finance.yahoo.com' _ROOT_URL_ = 'https://finance.yahoo.com' @@ -531,20 +533,40 @@ "highest_controversy"} } -PREDEFINED_SCREENER_BODY_MAP = { - 'aggressive_small_caps': {"offset":0,"size":25,"sortField":"eodvolume","sortType":"desc","quoteType":"equity","query":{"operator":"and","operands":[{"operator":"or","operands":[{"operator":"eq","operands":["exchange","NMS"]},{"operator":"eq","operands":["exchange","NYQ"]}]},{"operator":"or","operands":[{"operator":"LT","operands":["epsgrowth.lasttwelvemonths",15]}]}]},"userId":"","userIdType":"guid"}, - 'day_gainers': {"offset":0,"size":25,"sortField":"percentchange","sortType":"DESC","quoteType":"EQUITY","query":{"operator":"AND","operands":[{"operator":"gt","operands":["percentchange",3]},{"operator":"eq","operands":["region","us"]},{"operator":"or","operands":[{"operator":"BTWN","operands":["intradaymarketcap",2000000000,10000000000]},{"operator":"BTWN","operands":["intradaymarketcap",10000000000,100000000000]},{"operator":"GT","operands":["intradaymarketcap",100000000000]}]},{"operator":"gte","operands":["intradayprice",5]},{"operator":"gt","operands":["dayvolume",15000]}]},"userId":"","userIdType":"guid"}, - 'day_losers': {"offset":0,"size":25,"sortField":"percentchange","sortType":"ASC","quoteType":"EQUITY","query":{"operator":"AND","operands":[{"operator":"lt","operands":["percentchange",-2.5]},{"operator":"eq","operands":["region","us"]},{"operator":"or","operands":[{"operator":"BTWN","operands":["intradaymarketcap",2000000000,10000000000]},{"operator":"BTWN","operands":["intradaymarketcap",10000000000,100000000000]},{"operator":"GT","operands":["intradaymarketcap",100000000000]}]},{"operator":"gte","operands":["intradayprice",5]},{"operator":"gt","operands":["dayvolume",20000]}]},"userId":"","userIdType":"guid"}, - 'growth_technology_stocks': {"offset":0,"size":25,"sortField":"eodvolume","sortType":"desc","quoteType":"equity","query":{"operator":"and","operands":[{"operator":"or","operands":[{"operator":"BTWN","operands":["quarterlyrevenuegrowth.quarterly",50,100]},{"operator":"GT","operands":["quarterlyrevenuegrowth.quarterly",100]},{"operator":"BTWN","operands":["quarterlyrevenuegrowth.quarterly",25,50]}]},{"operator":"or","operands":[{"operator":"BTWN","operands":["epsgrowth.lasttwelvemonths",25,50]},{"operator":"BTWN","operands":["epsgrowth.lasttwelvemonths",50,100]},{"operator":"GT","operands":["epsgrowth.lasttwelvemonths",100]}]},{"operator":"eq","operands":["sector","Technology"]},{"operator":"or","operands":[{"operator":"eq","operands":["exchange","NMS"]},{"operator":"eq","operands":["exchange","NYQ"]}]}]},"userId":"","userIdType":"guid"}, - 'most_actives': {"offset":0,"size":25,"sortField":"dayvolume","sortType":"DESC","quoteType":"EQUITY","query":{"operator":"AND","operands":[{"operator":"eq","operands":["region","us"]},{"operator":"or","operands":[{"operator":"BTWN","operands":["intradaymarketcap",10000000000,100000000000]},{"operator":"GT","operands":["intradaymarketcap",100000000000]},{"operator":"BTWN","operands":["intradaymarketcap",2000000000,10000000000]}]},{"operator":"gt","operands":["dayvolume",5000000]}]},"userId":"","userIdType":"guid"}, - 'most_shorted_stocks': {"size":25,"offset":0,"sortField":"short_percentage_of_shares_outstanding.value","sortType":"DESC","quoteType":"EQUITY","topOperator":"AND","query":{"operator":"AND","operands":[{"operator":"or","operands":[{"operator":"EQ","operands":["region","us"]}]},{"operator":"gt","operands":["intradayprice",1]},{"operator":"gt","operands":["avgdailyvol3m",200000]}]},"userId":"","userIdType":"guid"}, - 'small_cap_gainers': {"offset":0,"size":25,"sortField":"eodvolume","sortType":"desc","quoteType":"equity","query":{"operator":"and","operands":[{"operator":"lt","operands":["intradaymarketcap",2000000000]},{"operator":"or","operands":[{"operator":"eq","operands":["exchange","NMS"]},{"operator":"eq","operands":["exchange","NYQ"]}]}]},"userId":"","userIdType":"guid"}, - 'undervalued_growth_stocks': {"offset":0,"size":25,"sortType":"DESC","sortField":"eodvolume","quoteType":"EQUITY","query":{"operator":"and","operands":[{"operator":"or","operands":[{"operator":"BTWN","operands":["peratio.lasttwelvemonths",0,20]}]},{"operator":"or","operands":[{"operator":"LT","operands":["pegratio_5y",1]}]},{"operator":"or","operands":[{"operator":"BTWN","operands":["epsgrowth.lasttwelvemonths",25,50]},{"operator":"BTWN","operands":["epsgrowth.lasttwelvemonths",50,100]},{"operator":"GT","operands":["epsgrowth.lasttwelvemonths",100]}]},{"operator":"or","operands":[{"operator":"eq","operands":["exchange","NMS"]},{"operator":"eq","operands":["exchange","NYQ"]}]}]},"userId":"","userIdType":"guid"}, - 'undervalued_large_caps': {"offset":0,"size":25,"sortField":"eodvolume","sortType":"desc","quoteType":"equity","query":{"operator":"and","operands":[{"operator":"or","operands":[{"operator":"BTWN","operands":["peratio.lasttwelvemonths",0,20]}]},{"operator":"lt","operands":["pegratio_5y",1]},{"operator":"btwn","operands":["intradaymarketcap",10000000000,100000000000]},{"operator":"or","operands":[{"operator":"eq","operands":["exchange","NMS"]},{"operator":"eq","operands":["exchange","NYQ"]}]}]},"userId":"","userIdType":"guid"}, - 'conservative_foreign_funds': {"offset":0,"size":25,"sortType":"DESC","sortField":"fundnetassets","quoteType":"MUTUALFUND","query":{"operator":"and","operands":[{"operator":"or","operands":[{"operator":"EQ","operands":["categoryname","Foreign Large Value"]},{"operator":"EQ","operands":["categoryname","Foreign Large Blend"]},{"operator":"EQ","operands":["categoryname","Foreign Large Growth"]},{"operator":"EQ","operands":["categoryname","Foreign Small/Mid Growth"]},{"operator":"EQ","operands":["categoryname","Foreign Large Blend"]},{"operator":"EQ","operands":["categoryname","Foreign Small/Mid Blend"]},{"operator":"EQ","operands":["categoryname","Foreign Small/Mid Value"]},{"operator":"EQ","operands":["categoryname","Foreign Small/Mid Blend"]},{"operator":"EQ","operands":["categoryname","Foreign Small/Mid Value"]},{"operator":"EQ","operands":["categoryname","Foreign Small/Mid Blend"]},{"operator":"EQ","operands":["categoryname","Foreign Small/Mid Value"]},{"operator":"EQ","operands":["categoryname","Foreign Small/Mid Blend"]},{"operator":"EQ","operands":["categoryname","Foreign Small/Mid Value"]}]},{"operator":"or","operands":[{"operator":"EQ","operands":["performanceratingoverall",4]},{"operator":"EQ","operands":["performanceratingoverall",5]}]},{"operator":"lt","operands":["initialinvestment",100001]},{"operator":"lt","operands":["annualreturnnavy1categoryrank",50]},{"operator":"or","operands":[{"operator":"EQ","operands":["riskratingoverall",1]},{"operator":"EQ","operands":["riskratingoverall",3]},{"operator":"EQ","operands":["riskratingoverall",2]}]},{"operator":"or","operands":[{"operator":"eq","operands":["exchange","NAS"]}]}]},"userId":"","userIdType":"guid"}, - 'high_yield_bond': {"offset":0,"size":25,"sortType":"DESC","sortField":"fundnetassets","quoteType":"MUTUALFUND","query":{"operator":"and","operands":[{"operator":"or","operands":[{"operator":"EQ","operands":["performanceratingoverall",4]},{"operator":"EQ","operands":["performanceratingoverall",5]}]},{"operator":"lt","operands":["initialinvestment",100001]},{"operator":"lt","operands":["annualreturnnavy1categoryrank",50]},{"operator":"or","operands":[{"operator":"EQ","operands":["riskratingoverall",1]},{"operator":"EQ","operands":["riskratingoverall",3]},{"operator":"EQ","operands":["riskratingoverall",2]}]},{"operator":"or","operands":[{"operator":"EQ","operands":["categoryname","High Yield Bond"]}]},{"operator":"or","operands":[{"operator":"eq","operands":["exchange","NAS"]}]}]},"userId":"","userIdType":"guid"}, - 'portfolio_anchors': {"offset":0,"size":25,"sortType":"DESC","sortField":"fundnetassets","quoteType":"MUTUALFUND","query":{"operator":"and","operands":[{"operator":"or","operands":[{"operator":"EQ","operands":["categoryname","Large Blend"]}]},{"operator":"or","operands":[{"operator":"EQ","operands":["performanceratingoverall",4]},{"operator":"EQ","operands":["performanceratingoverall",5]}]},{"operator":"lt","operands":["initialinvestment",100001]},{"operator":"lt","operands":["annualreturnnavy1categoryrank",50]},{"operator":"or","operands":[{"operator":"eq","operands":["exchange","NAS"]}]}]},"userId":"","userIdType":"guid"}, - 'solid_large_growth_funds': {"offset":0,"size":25,"sortType":"DESC","sortField":"fundnetassets","quoteType":"MUTUALFUND","query":{"operator":"and","operands":[{"operator":"or","operands":[{"operator":"EQ","operands":["categoryname","Large Growth"]}]},{"operator":"or","operands":[{"operator":"EQ","operands":["performanceratingoverall",5]},{"operator":"EQ","operands":["performanceratingoverall",4]}]},{"operator":"lt","operands":["initialinvestment",100001]},{"operator":"lt","operands":["annualreturnnavy1categoryrank",50]},{"operator":"or","operands":[{"operator":"eq","operands":["exchange","NAS"]}]}]},"userId":"","userIdType":"guid"}, - 'solid_midcap_growth_funds': {"offset":0,"size":25,"sortType":"DESC","sortField":"fundnetassets","quoteType":"MUTUALFUND","query":{"operator":"and","operands":[{"operator":"or","operands":[{"operator":"EQ","operands":["categoryname","Mid-Cap Growth"]}]},{"operator":"or","operands":[{"operator":"EQ","operands":["performanceratingoverall",5]},{"operator":"EQ","operands":["performanceratingoverall",4]}]},{"operator":"lt","operands":["initialinvestment",100001]},{"operator":"lt","operands":["annualreturnnavy1categoryrank",50]},{"operator":"or","operands":[{"operator":"eq","operands":["exchange","NAS"]}]}]},"userId":"","userIdType":"guid"}, - 'top_mutual_funds': {"offset":0,"size":25,"sortType":"DESC","sortField":"percentchange","quoteType":"MUTUALFUND","query":{"operator":"and","operands":[{"operator":"gt","operands":["intradayprice",15]},{"operator":"or","operands":[{"operator":"EQ","operands":["performanceratingoverall",5]},{"operator":"EQ","operands":["performanceratingoverall",4]}]},{"operator":"gt","operands":["initialinvestment",1000]},{"operator":"or","operands":[{"operator":"eq","operands":["exchange","NAS"]}]}]},"userId":"","userIdType":"guid"} -} \ No newline at end of file +PREDEFINED_SCREENERS_TYPE = Literal[ + "aggressive_small_caps", + "day_gainers", + "fifty_two_wk_gainers", + "day_losers", + "growth_technology_stocks", + "most_actives", + "most_shorted_stocks", + "small_cap_gainers", + "undervalued_growth_stocks", + "undervalued_large_caps", + "conservative_foreign_funds", + "high_yield_bond", + "portfolio_anchors", + "solid_large_growth_funds", + "solid_midcap_growth_funds", + "top_mutual_funds" +] + +PREDEFINED_SCREENERS:'list[PREDEFINED_SCREENERS_TYPE]' = [ + "aggressive_small_caps", + "day_gainers", + "fifty_two_wk_gainers", + "day_losers", + "growth_technology_stocks", + "most_actives", + "most_shorted_stocks", + "small_cap_gainers", + "undervalued_growth_stocks", + "undervalued_large_caps", + "conservative_foreign_funds", + "high_yield_bond", + "portfolio_anchors", + "solid_large_growth_funds", + "solid_midcap_growth_funds", + "top_mutual_funds" +] \ No newline at end of file diff --git a/yfinance/screener/screener.py b/yfinance/screener/screener.py index 06c2a8ff7..ecc2649c5 100644 --- a/yfinance/screener/screener.py +++ b/yfinance/screener/screener.py @@ -1,18 +1,19 @@ -from _collections_abc import dict_keys -from typing import Literal, TypedDict +from typing import TypedDict, Union from yfinance import utils from yfinance.data import YfData -from yfinance.const import _BASE_URL_, PREDEFINED_SCREENER_BODY_MAP +from yfinance.const import _BASE_URL_, PREDEFINED_SCREENERS, PREDEFINED_SCREENERS_TYPE from .screener_query import Query -from ..utils import dynamic_docstring, generate_list_table_from_dict_of_dict _SCREENER_URL_ = f"{_BASE_URL_}/v1/finance/screener" +_PREDEFINED_URL_ = f"{_SCREENER_URL_}/predefined/saved" _REQUIRED_BODY_TYPE_ = TypedDict("_REQUIRED_BODY_TYPE_", {'offset': int, 'size': int, 'sortField': str, 'sortType': str, 'quoteType': str, 'query': dict, 'userId': str, 'userIdType': str}) _BODY_TYPE_ = TypedDict("_BODY_TYPE_", {'offset': int, 'size': int, 'sortField': str, 'sortType': str, 'quoteType': str, 'query': dict, 'userId': str, 'userIdType': str}, total=False) +_EMPTY_DICT_ = TypedDict("_EMPTY_DICT_", {}, total=False) class Screener: + PREDEFINED_SCREENERS = PREDEFINED_SCREENERS """ The `Screener` class is used to execute the queries and return the filtered results. @@ -34,14 +35,14 @@ def __init__(self, session=None, proxy=None): self.session = session self._data: YfData = YfData(session=session) - self._body: '_REQUIRED_BODY_TYPE_' = {} # type: ignore # Type is invalid till body is set + self._body: 'Union[_REQUIRED_BODY_TYPE_, _EMPTY_DICT_]' = {} self._response: 'dict' = {} self._body_updated = False self._accepted_body_keys = {"offset","size","sortField","sortType","quoteType","query","userId","userIdType"} - self._predefined_bodies = PREDEFINED_SCREENER_BODY_MAP.keys() + self.predefined = False @property - def body(self) -> '_REQUIRED_BODY_TYPE_': + def body(self) -> 'Union[_REQUIRED_BODY_TYPE_, _EMPTY_DICT_]': return self._body @property @@ -62,15 +63,6 @@ def response(self) -> 'dict': self._body_updated = False return self._response - @dynamic_docstring({"predefined_screeners": generate_list_table_from_dict_of_dict(PREDEFINED_SCREENER_BODY_MAP,bullets=False)}) - @property - def predefined_bodies(self) -> 'dict_keys': - """ - Predefined Screeners - {predefined_screeners} - """ - 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") -> 'Screener': """ Set the default body using a custom query. @@ -107,8 +99,8 @@ def set_default_body(self, query: 'Query', offset: 'int' = 0, size: 'int' = 100, "userIdType": userIdType } return self - - def set_predefined_body(self, predefined_key: 'Literal["aggressive_small_caps", "day_gainers", "day_losers", "growth_technology_stocks", "most_actives", "most_shorted_stocks", "small_cap_gainers", "undervalued_growth_stocks", "undervalued_large_caps", "conservative_foreign_funds", "high_yield_bond", "portfolio_anchors", "solid_large_growth_funds", "solid_midcap_growth_funds", "top_mutual_funds"]') -> 'Screener': + @classmethod + def set_predefined_body(cls, predefined_key: 'PREDEFINED_SCREENERS_TYPE') -> 'Screener': """ Set a predefined body @@ -130,12 +122,9 @@ def set_predefined_body(self, predefined_key: 'Literal["aggressive_small_caps", :attr:`Screener.predefined_bodies ` supported predefined screens """ - body = PREDEFINED_SCREENER_BODY_MAP.get(predefined_key, None) - if not body: - raise ValueError(f'Invalid key {predefined_key} provided for predefined screener') - + self = cls() self._body_updated = True - self._body = body + self.predefined = predefined_key return self def set_body(self, body: '_REQUIRED_BODY_TYPE_') -> 'Screener': @@ -173,6 +162,7 @@ def set_body(self, body: '_REQUIRED_BODY_TYPE_') -> 'Screener': self._body_updated = True self._body = body + self.predefined = False return self def patch_body(self, values: '_BODY_TYPE_') -> 'Screener': @@ -198,6 +188,7 @@ def patch_body(self, values: '_BODY_TYPE_') -> 'Screener': self._body_updated = True for k in values: self._body[k] = values[k] + self.predefined = False return self def _validate_body(self) -> None: @@ -213,12 +204,32 @@ def _fetch(self) -> 'dict': response.raise_for_status() return response.json() + def _fetch_predefined(self) -> 'dict': + params_dict = { + "count": 25, + "formatted": True, + "scrIds": self.predefined, + "sortField": "", + "sortType": None, + "start": 0, + "useRecordsResponse": False, + "fields": ["ticker", "symbol", "longName", "sparkline", "shortName", "regularMarketPrice", "regularMarketChange", "regularMarketChangePercent", "regularMarketVolume", "averageDailyVolume3Month", "marketCap", "trailingPE", "regularMarketOpen"], + "lang": "en-US", + "region": "US" + } + response = self._data.get(_PREDEFINED_URL_, user_agent_headers=self._data.user_agent_headers, params=params_dict, proxy=self.proxy) + response.raise_for_status() + return response.json() + def _fetch_and_parse(self) -> None: response = None - self._validate_body() - + try: - response = self._fetch() + if self.predefined != False: + response = self._fetch_predefined() + else: + self._validate_body() + response = self._fetch() self._response = response['finance']['result'][0] except Exception as e: logger = utils.get_yf_logger() @@ -227,3 +238,10 @@ def _fetch_and_parse(self) -> None: logger.debug("-------------") logger.debug(f" {response}") logger.debug("-------------") + + @property + def quotes(self) -> 'list[dict]': + if self.predefined: + return self.response.get("quotes", []) + + return self.response.get("records", []) From b3b6785fa18d2290486727293a7fbd99fd44a6e3 Mon Sep 17 00:00:00 2001 From: R5dan Date: Tue, 31 Dec 2024 00:16:47 +0000 Subject: [PATCH 3/5] Move var after doc string --- yfinance/screener/screener.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/yfinance/screener/screener.py b/yfinance/screener/screener.py index ecc2649c5..51fd96bd8 100644 --- a/yfinance/screener/screener.py +++ b/yfinance/screener/screener.py @@ -13,13 +13,14 @@ _EMPTY_DICT_ = TypedDict("_EMPTY_DICT_", {}, total=False) class Screener: - PREDEFINED_SCREENERS = PREDEFINED_SCREENERS """ The `Screener` class is used to execute the queries and return the filtered results. The Screener class provides methods to set and manipulate the body of a screener request, fetch and parse the screener results, and access predefined screener bodies. """ + PREDEFINED_SCREENERS = PREDEFINED_SCREENERS + def __init__(self, session=None, proxy=None): """ Args: From 035ee227c859a32ab29b88364efad1d708f78682 Mon Sep 17 00:00:00 2001 From: R5dan Date: Wed, 1 Jan 2025 01:21:43 +0000 Subject: [PATCH 4/5] Redo Screener --- yfinance/__init__.py | 4 +- yfinance/screener/__init__.py | 13 +- yfinance/screener/query.py | 384 +++++++++++++++++++++++++ yfinance/screener/screener.py | 419 +++++++++++++++++----------- yfinance/screener/screener_query.py | 145 ---------- yfinance/utils.py | 15 +- 6 files changed, 664 insertions(+), 316 deletions(-) create mode 100644 yfinance/screener/query.py delete mode 100644 yfinance/screener/screener_query.py diff --git a/yfinance/__init__.py b/yfinance/__init__.py index bb79ca98f..e16ff4f2a 100644 --- a/yfinance/__init__.py +++ b/yfinance/__init__.py @@ -28,8 +28,6 @@ from .cache import set_tz_cache_location from .domain.sector import Sector from .domain.industry import Industry -from .screener.screener import Screener -from .screener.screener_query import EquityQuery __version__ = version.version __author__ = "Ran Aroussi" @@ -38,4 +36,4 @@ warnings.filterwarnings('default', category=DeprecationWarning, module='^yfinance') __all__ = ['download', 'Search', 'Ticker', 'Tickers', 'enable_debug_mode', 'set_tz_cache_location', 'Sector', - 'Industry', 'EquityQuery', 'Screener'] + 'Industry'] diff --git a/yfinance/screener/__init__.py b/yfinance/screener/__init__.py index 3254bdcc9..b20d68013 100644 --- a/yfinance/screener/__init__.py +++ b/yfinance/screener/__init__.py @@ -1,4 +1,13 @@ from .screener import Screener -from .screener_query import EquityQuery +from .query import Query, Validator +from .query import Market, Region, Category, Sector, Industry, Exchange, PeerGroup +from .query import QuarterlyRevenueGrowth, EpsGrowth, IntradayMarketCap, IntradayPrice, DayVolume, PercentChange, PeRatio, PegRatio, InitialInvestment, PerformanceRating, RiskRating, AnnualReturnRank, FundNetAssets +from .query import EQ, AND, OR, BTWN, GT, LT, GTE, LTE -__all__ = ['EquityQuery', 'Screener'] \ No newline at end of file +__all__ = [ + "Query", "Validator", "Screener", + + "Market", "Region", "Category", "Sector", "Industry", "Exchange", "PeerGroup", + "QuarterlyRevenueGrowth", "EpsGrowth", "IntradayMarketCap", "IntradayPrice", "DayVolume", "PercentChange", "PeRatio", "PegRatio", "InitialInvestment", "PerformanceRating", "RiskRating", "AnnualReturnRank", "FundNetAssets", + "EQ", "AND", "OR", "BTWN", "GT", "LT", "GTE", "LTE" + ] \ No newline at end of file diff --git a/yfinance/screener/query.py b/yfinance/screener/query.py new file mode 100644 index 000000000..1e8d63515 --- /dev/null +++ b/yfinance/screener/query.py @@ -0,0 +1,384 @@ +from typing import Any, Union, Literal, Optional, overload, TypeVar + +from yfinance.utils import pop + +T = TypeVar('T') + +OPERATORS = ["OR", "AND", "EQ", "BTWN", "GT", "LT", "GTE", "LTE"] +_OPERATORS = Literal["OR", "AND", "EQ", "BTWN", "GT", "LT", "GTE", "LTE"] + +class Query: + """ + A class representing a query structure for the Yahoo Finance screener. + + This class manages a collection of validators that form a complete screener query. + + Attributes: + children (list[Validator]): List of validator objects that make up the query + """ + + def __init__(self, child: 'Validator'): + self.validator:'Validator' = child + + def set_property(self, property: 'str', value: 'Any'): + """ + Sets a property value for all child validators. + + Args: + property (str): The property name to set + value (Any): The value to set for the property + """ + raise NotImplemented + + def to_dict(self) -> 'dict': + """ + Converts the query to a dictionary format. + + Returns: + dict: A dictionary representation of the query with AND operator and operands + """ + return self.validator.to_dict() + + @classmethod + def from_dict(cls, data: 'dict') -> 'Query': + """ + Create a new Query instance from a dictionary representation. + + Args: + data (dict): The dictionary representation of the query + + Returns: + Query: A new Query instance with the specified parameters + + Raises: + Exception: If the dictionary does not contain a valid query + """ + + child = Validator.from_dict(data) + return Query(child) + + + + + +class Validator: + """ + A class that validates and processes screener query conditions. + + This class handles the validation and processing of individual query conditions, + supporting various operators and comparison types. + + Attributes: + _operator (Optional[_OPERATORS]): The comparison operator + _value (Any): The value to compare against + _primary (Any): The primary key or field to check + """ + + def __init__(self, operator: 'Optional[_OPERATORS]'=None, primary:'Any'=None, operands: 'Union[list[Union[Any, Query]], Union[Any, Query]]'=None): + """ + Initialize a new Validator instance. + + Args: + operator (Optional[_OPERATORS]): The comparison operator to use + primary (Any, optional): The primary key or field to check + value (Union[Any, Query], optional): The value to compare against + + Raises: + ValueError: If an invalid operator is provided + """ + operands = [operands] if not isinstance(operands, list) else operands + + if operator == None: + pass + elif operator.upper() not in OPERATORS: + raise ValueError(f"Invalid operator '{operator}'. Must be one of {OPERATORS}") + else: + operator = operator.upper() # type: ignore + self._operator = operator + self._operands = operands + self._primary = primary + + @property + def primary_key(self) -> 'Any': + """The primary key or field being checked.""" + return self._primary + + @primary_key.setter + def primary_key(self, value: 'Any'): + """Set the primary key.""" + self._primary = value + + @property + def other(self) -> 'Any': + """The other operands.""" + return self._operands + + @other.setter + def other(self, value: 'Any'): + """Set the other operands. Usually use `other.append()`""" + self._operands = value + + @property + def operator(self) -> 'Union[_OPERATORS, None]': + """The comparison operator.""" + return self._operator # type: ignore + + @operator.setter + def operator(self, value: '_OPERATORS'): + """Set the comparison operator.""" + self._operator = value + + @property + def operands(self) -> 'list[Union[Any, Query]]': + """List of operands for the comparison.""" + return [self.primary_key, *self.other] + + @operands.setter + def operands(self, value: 'list[Union[Any, Query]]'): + """ + Raises: + Exception: Always raises an exception as operands cannot be set directly + """ + raise Exception("Cannot set operands directly: set primary_key or value instead") + + def to_dict(self) -> 'dict': + """ + Convert the validator to a dictionary format. + + Returns: + dict: Dictionary representation of the validator + + Raises: + Exception: If any required fields are missing + """ + if None in self.operands: + raise Exception("Can not use to_dict() on a base validator, use empty string for single operand") + + def validate(operand): + if isinstance(operand, Validator): + print("VALIDATOR: ", operand) + return operand.to_dict() + else: + print("OTHER: ", operand) + return operand + + + print("OPERANDS: ", self.operands) + return { + "operator": self.operator, + "operands": [validate(operand) for operand in self.operands if operand != ""] + } + + @classmethod + def from_dict(cls, data: 'Union[dict, T]', allow: 'bool'=True, raise_errors: 'bool'=True) -> 'Union[Validator, T]': + """ + Create a new Validator instance from a dictionary representation. + + Args: + data (dict): The dictionary representation of the validator + allow (bool, optional): Whether to allow the non dictionary representation of the validator: allows for return of int or str. Defaults to True. + raise_errors (bool, optional): Whether to raise errors (excludes errors handled by allow=True). Defaults to True. + + Returns: + Validator: A new Validator instance with the specified parameters + + Raises: + Exception: If the dictionary does not contain a valid validator + """ + try: + if isinstance(data, dict): + if not list(data.keys()) == ["operator", "operands"]: + raise Exception("Invalid validator: must contain 'operator' and 'operands' keys") + return Validator( + operator=data["operator"], + primary=Validator.from_dict(pop(data["operands"], 0, ""), raise_errors=False), + operands=[Validator.from_dict(value, raise_errors=False) for value in data["operands"]] # Entire list is used because primary is removed above + ) + else: + if allow == False: + raise Exception("Invalid validator: must be a dictionary when allow=False") + + return data + except Exception as e: + if raise_errors: + raise e + return "" + + @overload + def __call__(self, *, operator:'_OPERATORS'=None, value: 'Union[Any, Query]'=None) -> 'Validator': ... + + @overload + def __call__(self, *, value: 'Union[Any, Query]'=None) -> 'Validator': ... + + @overload + def __call__(self, *, primary:'Any'=None, value: 'Union[Any, Query]'=None) -> 'Validator': ... + + @overload + def __call__(self, *, primary:'Any'=None): ... + + def __call__(self, *, operator: 'Optional[_OPERATORS]'=None, primary:'Any'=None, operands: 'Union[list[Union[Any, Query]], Union[Any, Query]]'=None) -> 'Validator': + """ + Create a new Validator with the provided arguments. + + Parameters + ---------- + operator : _OPERATORS, optional + The operator to use for comparison. Only valid when value is provided. + primary : Any, optional + The primary key to check against. Only valid when value is provided. + value : Union[Any, Query], optional + The value to compare against. + + Returns + ------- + Validator + A new Validator instance with the specified parameters. + + Raises + ------ + ValueError + If invalid combination of arguments is provided. + + Examples + -------- + # Method 1: Operator and value + EPSGrowth(operator='GT', value=100) + + # Method 2: Primary and value + EQ(primary='price', value=50) + + # Method 3: Value only + Market(value='NASDAQ') + """ + operands = [operands] if not isinstance(operands, list) else operands + if operator is not None and operands is not None: + # Operator and value provided + # Method 1: Return a new Validator for checking the primary (`EPSGrowth` etc was called) + return Validator( + operator=operator, + primary=self.primary_key, + operands=operands + ) + elif operands is not None: + # operands provided + # Method 2: Return a new Validator for checking primary key exactly (`Market` etc was called) + return Validator( + operator=self.operator, + primary=self.primary_key, + operands=operands + ) + elif primary is not None and operands is not None: + # Primary and operands provided + # Method 3: Return a new Validator for the operand (`EQ` etc was called) + return Validator( + operator=self.operator, + primary=primary, + operands=operands + ) + elif primary is not None and self.operator == "EQ": + # Primary provided + # Method 4: Return a new Validator for use in Method 2 + # For use internally only + return Validator( + operator="EQ", + primary=primary, + operands=None + ) + elif primary is not None and self.operator is None: + # Primary provided + # Method 5: Return a new Validator for use in Method 1 + # For use internally only + return Validator( + operator=None, + primary=primary, + operands=None + ) + else: + # Invalid arguments provided + # Raise error + raise operandsError( + f"Invalid arguments provided: {primary=}, {operands=} and {operator=}\n" + "Valid arguments are:\n" + " - operator and operands: Return a new Validator for checking the primary key (`EPSGrowth` etc)\n" + " - primary and operands: Return a new Validator for the operand (`EQ`, `AND` etc)\n" + " - operands: Return a new Validator for checking primary key exactly (`Market` etc)\n" + ) + + + def check(self, value:'Any', this:'bool' = False) -> 'bool': + """ + Check if a value matches the validation criteria. + + Args: + value (Any): The value to check + this (bool, optional): If True, only check exact primary key match for this Validator. If false, will check all child validators. Defaults to False. + + Returns: + bool: True if the value matches the criteria, False otherwise + """ + if this: + return self.primary_key == value + + if self.primary_key == value: + return True + + for operand in self.operands: + if isinstance(operand, Validator): + if operand.check(value): + return True + + return False + + def set(self, key: 'str', value: 'Any') -> 'Validator': + """ + Set a property value for this validator and its operands. + + Args: + key (str): The property key to set + value (Any): The value to set for the property + + Returns: + Validator: The validator instance for method chaining + """ + if key == self.primary_key: + self._operands = [value] + else: + for operand in self.operands: + if isinstance(operand, Validator): + operand.set(key, value) + +# Common query operators +AND = Validator("AND") # Logical AND operator +OR = Validator("OR") # Logical OR operator +EQ = Validator("EQ") # Equals operator +BTWN = Validator("BTWN") # Between operator +GT = Validator("GT") # Greater than operator +LT = Validator("LT") # Less than operator +GTE = Validator("GTE") # Greater than or equal operator +LTE = Validator("LTE") # Less than or equal operator + +# Common field validators +Market = EQ(primary="exchange") # Market/Exchange filter +Region = EQ(primary="region") # Region filter +Category = EQ(primary="categoryname") # Category filter +Sector = EQ(primary="sector") # Sector filter +Industry = EQ(primary="industry") # Industry filter +Exchange = EQ(primary="exchange") # Exchange filter +PeerGroup = EQ(primary="peer_group") # Peer group filter + +_Base = Validator(None) # Base validator for creating custom filters + +# Common financial metrics validators +QuarterlyRevenueGrowth = _Base(primary="quarterlyrevenuegrowth.quarterly") +EpsGrowth = _Base(primary="epsgrowth.lasttwelvemonths") +IntradayMarketCap = _Base(primary="intradaymarketcap") +IntradayPrice = _Base(primary="intradayprice") +DayVolume = _Base(primary="dayvolume") +PercentChange = _Base(primary="percentchange") +PeRatio = _Base(primary="peratio.lasttwelvemonths") +PegRatio = _Base(primary="pegratio_5y") +InitialInvestment = _Base(primary="initialinvestment") +PerformanceRating = _Base(primary="performanceratingoverall") +RiskRating = _Base(primary="riskratingoverall") +AnnualReturnRank = _Base(primary="annualreturnnavy1categoryrank") +FundNetAssets = _Base(primary="fundnetassets") diff --git a/yfinance/screener/screener.py b/yfinance/screener/screener.py index 51fd96bd8..9628bb75a 100644 --- a/yfinance/screener/screener.py +++ b/yfinance/screener/screener.py @@ -1,137 +1,235 @@ -from typing import TypedDict, Union - -from yfinance import utils +import logging +from typing_extensions import TypedDict +from yfinance.const import _BASE_URL_, PREDEFINED_SCREENERS from yfinance.data import YfData -from yfinance.const import _BASE_URL_, PREDEFINED_SCREENERS, PREDEFINED_SCREENERS_TYPE -from .screener_query import Query +from yfinance import utils +from .query import Query + +from typing import Any, TypedDict _SCREENER_URL_ = f"{_BASE_URL_}/v1/finance/screener" _PREDEFINED_URL_ = f"{_SCREENER_URL_}/predefined/saved" -_REQUIRED_BODY_TYPE_ = TypedDict("_REQUIRED_BODY_TYPE_", {'offset': int, 'size': int, 'sortField': str, 'sortType': str, 'quoteType': str, 'query': dict, 'userId': str, 'userIdType': str}) -_BODY_TYPE_ = TypedDict("_BODY_TYPE_", {'offset': int, 'size': int, 'sortField': str, 'sortType': str, 'quoteType': str, 'query': dict, 'userId': str, 'userIdType': str}, total=False) +_REQUIRED_BODY_TYPE_ = TypedDict("_REQUIRED_BODY_TYPE_", {"offset": int, "size": int, "sortField": str, "sortType": str, "quoteType": str, "userId": str, "userIdType": str}) +_BODY_TYPE_ = TypedDict("_BODY_TYPE_", {"offset": int, "size": int, "sortField": str, "sortType": str, "quoteType": str, "query": dict, "userId": str, "userIdType": str}, total=False) _EMPTY_DICT_ = TypedDict("_EMPTY_DICT_", {}, total=False) -class Screener: - """ - The `Screener` class is used to execute the queries and return the filtered results. +class PredefinedScreener: + def __init__(self, key:'str', count:'int'=25, session=None, proxy=None, timeout=30): + self.key = key + self._data = YfData(session=session) + self.proxy = proxy + self.fields = ["ticker", "symbol", "longName", "sparkline", "shortName", "regularMarketPrice", "regularMarketChange", "regularMarketChangePercent", "regularMarketVolume", "averageDailyVolume3Month", "marketCap", "trailingPE", "regularMarketOpen"] + self.timeout = timeout + self._response = None + self.count = count + self.logger = logging.getLogger("yfinance") - The Screener class provides methods to set and manipulate the body of a screener request, - fetch and parse the screener results, and access predefined screener bodies. - """ - PREDEFINED_SCREENERS = PREDEFINED_SCREENERS + def _fetch(self): + params_dict = { + "count": self.count, + "formatted": True, + "scrIds": self.key, + "sortField": "", + "sortType": None, + "start": 0, + "useRecordsResponse": False, + "fields": self.fields, + "lang": "en-US", + "region": "US" + } + self._response = self._data.get(url=_PREDEFINED_URL_, params=params_dict, proxy=self.proxy) + self._response.raise_for_status() + self._response = self._response.json()["finance"]["result"][0] + return self._response - def __init__(self, session=None, proxy=None): + def set_fields(self, fields: 'list[str]'): """ + Set the fields to include in the screener. + Args: - session (requests.Session, optional): A requests session object to be used for making HTTP requests. Defaults to None. - proxy (str, optional): A proxy URL to be used for making HTTP requests. Defaults to None. + fields: The fields to include. - .. seealso:: - - :attr:`Screener.predefined_bodies ` - supported predefined screens + Returns: + The Screener object. """ - self.proxy = proxy - self.session = session - - self._data: YfData = YfData(session=session) - self._body: 'Union[_REQUIRED_BODY_TYPE_, _EMPTY_DICT_]' = {} - self._response: 'dict' = {} - self._body_updated = False - self._accepted_body_keys = {"offset","size","sortField","sortType","quoteType","query","userId","userIdType"} - self.predefined = False + if not isinstance(fields, list): + raise TypeError("Fields must be a list of strings") + elif len(fields) == 0: + raise ValueError("Fields must be a list of strings") + elif not isinstance(fields[0], str): + raise TypeError("Fields must be a list of strings") + + self.fields = fields + return self + + def set_config(self, proxy=None, session=None, timeout=None): + """ + Set the proxy, session, and timeout for the screener. + + Args: + proxy: The proxy to use. + session: The session to use. + timeout: The timeout to use. + + Returns: + The Screener object. + """ + if proxy is not None: + self.proxy = proxy + if session is not None: + self.session = session + self._data = YfData(session=self.session) + + if timeout is not None: + self.timeout = timeout + + return self + @property - def body(self) -> 'Union[_REQUIRED_BODY_TYPE_, _EMPTY_DICT_]': - return self._body + def quotes(self) -> 'list[dict]': + """ + Get the quotes from the screener. + + Returns: + The quotes from the screener. + """ + return self.response.get("quotes", []) @property def response(self) -> 'dict': """ - Fetch screen result - - Example: - - .. code-block:: python - - result = screener.response - symbols = [quote['symbol'] for quote in result['quotes']] - """ - if self._body_updated or self._response is None: - self._fetch_and_parse() + Get the response from the screener. - self._body_updated = False + Returns: + The response from the screener. + """ + if self._response is None: + self._fetch() return self._response - 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. - 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: - .. code-block:: python +class Screener: + """ + The `Screener` class allows you to screen stocks based on various criteria using either custom queries or predefined screens. - screener.set_default_body(qf) - """ - self._body_updated = True + The screener supports two main ways of filtering: + 1. Custom queries using the `EquityQuery` class to filter based on specific criteria + 2. Predefined screens for common stock screening strategies - self._body = { - "offset": offset, - "size": size, - "sortField": sortField, - "sortType": sortType, - "quoteType": quoteType, - "query": query.to_dict(), - "userId": userId, - "userIdType": userIdType - } - return self - @classmethod - def set_predefined_body(cls, predefined_key: 'PREDEFINED_SCREENERS_TYPE') -> 'Screener': - """ - Set a predefined body + Args: + query (Query): The query to use for screening. Must be an instance of `EquityQuery`. + session (Optional[Session]): The requests Session object to use for making HTTP requests. + proxy (Optional[str]): The proxy URL to use for requests. - Args: - predefined_key (str): key to one of predefined screens + Example: + Screen for technology stocks with price > $50 using custom query: - Returns: - Screener: self + .. code-block:: python - Example: + import yfinance as yf + + # Create query for tech stocks over $50 + tech = yf.EquityQuery('eq', ['sector', 'Technology']) + price = yf.EquityQuery('gt', ['eodprice', 50]) + query = yf.EquityQuery('and', [tech, price]) - .. code-block:: python + # Create and run screener + screener = yf.Screener(query) + results = screener.quotes - screener.set_predefined_body('day_gainers') - - - .. seealso:: + Use a predefined screener: - :attr:`Screener.predefined_bodies ` - supported predefined screens + .. code-block:: python + + # Use predefined "day_gainers" screen + screener = yf.Screener.predefined_body("day_gainers") + gainers = screener.quotes + """ + + PREDEFINED_SCREENERS = PREDEFINED_SCREENERS + ACCEPTED_BODY_KEYS = {"offset","size","sortField","sortType","quoteType","query","userId","userIdType"} + + + def __init__(self, query:'Query'=None, sort_field:'str'="ticker", session=None, proxy=None): + self._query = query + self.session = session + self.proxy = proxy + self._data = YfData(session=self.session) + self._logger = logging.getLogger("yfinance") + self._response = None + self.body = {"offset": 0, "size": 25, "sortField": sort_field, "sortType": "desc", "quoteType": "equity", "userId": "", "userIdType": "guid"} + + + def set_query(self, data: 'dict', session=None, proxy=None) -> 'Screener': """ - self = cls() - self._body_updated = True - self.predefined = predefined_key + Create the query from a dictionary representation. + + Args: + data (dict): The dictionary representation of the query + + Returns: + Screener: A new Screener instance with the specified parameters + + Raises: + Exception: If the dictionary does not contain a valid query + """ + self.query = Query.from_dict(data) + return self + + @classmethod + def from_dict(cls, data: 'dict', session=None, proxy=None) -> 'Screener': + """ + Create the query from a dictionary representation. + + Args: + data (dict): The dictionary representation of the query + + Returns: + Screener: A new Screener instance with the specified parameters + + Raises: + Exception: If the dictionary does not contain a valid query + """ + self = cls(Query.from_dict(data), session=session, proxy=proxy) return self + + @property + def query(self) -> 'Query': + """ + Get the query from the screener. + + Returns: + Query: The query from the screener. + """ + return self._query + + @query.setter + def query(self, value: 'Query'): + """ + Set the query. + + Args: + value (Query): The query to set. + + Returns: + Screener: The screener instance for method chaining. + """ + self._query = value + return self + def set_body(self, body: '_REQUIRED_BODY_TYPE_') -> 'Screener': """ Set the fully custom body using dictionary input + .. warning:: + Do not use this method to set query + Query will be set automatically from `Screener.query` + Args: body (dict): full query body @@ -148,101 +246,94 @@ def set_body(self, body: '_REQUIRED_BODY_TYPE_') -> 'Screener': "sortField": "ticker", "sortType": "desc", "quoteType": "equity", - "query": qf.to_dict(), "userId": "", "userIdType": "guid" }) """ - missing_keys = [key for key in self._accepted_body_keys if key not in body] + missing_keys = [key for key in Screener.ACCEPTED_BODY_KEYS if key not in body] if missing_keys: raise ValueError(f"Missing required keys in body: {missing_keys}") - extra_keys = [key for key in body if key not in self._accepted_body_keys] + extra_keys = [key for key in body if key not in Screener.ACCEPTED_BODY_KEYS] if extra_keys: raise ValueError(f"Body contains extra keys: {extra_keys}") + if "query" in body.keys(): + raise ValueError("Query must not be set here: set query") + self._body_updated = True self._body = body - self.predefined = False return self - def patch_body(self, values: '_BODY_TYPE_') -> 'Screener': + + def patch_body(self, offset: 'int' = 0, size: 'int' = 100, sortField: 'str' = "ticker", sortType: 'str' = "desc", quoteType: 'str' = "equity", userId: 'str' = "", userIdType: 'str' = "guid"): """ - Patch parts of the body using dictionary input - - Args: - body (Dict): partial query body - + Patch the body for the screener. + + Args: + query: The query to use. + offset: The offset to use. + size: The size to use. + sortField: The sortField to use. + sortType: The sortType to use. + quoteType: The quoteType to use. + userId: The userId to use. + userIdType: The userIdType to use. + Returns: - Screener: self + Screener: A new Screener instance with the specified parameters + + Raises: + Exception: If the dictionary does not contain a valid query + """ + return - Example: - .. code-block:: python - screener.patch_body({"offset": 100}) + @staticmethod + def predefined(query: 'str', count:'int'=25, session=None, proxy=None, timeout=30) -> 'PredefinedScreener': """ - extra_keys = [key for key in values if key not in self._accepted_body_keys] - if extra_keys: - raise ValueError(f"Body contains extra keys: {extra_keys}") + Set the predefined body for the screener. - self._body_updated = True - for k in values: - self._body[k] = values[k] - self.predefined = False - 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") + Args: + predefined_body: The predefined body to use. - if self._body["size"] > 250: - raise ValueError("Yahoo limits query size to 250. Please decrease the size of the query.") + Returns: + The Screener object. + """ + if query not in Screener.PREDEFINED_SCREENERS: + raise ValueError(f"Invalid predefined query: '{query}'. Valid queries are: {', '.join(Screener.PREDEFINED_SCREENERS)}") - def _fetch(self) -> 'dict': - params_dict = {"corsDomain": "finance.yahoo.com", "formatted": "false", "lang": "en-US", "region": "US"} - response = self._data.post(_SCREENER_URL_, body=self.body, user_agent_headers=self._data.user_agent_headers, params=params_dict, proxy=self.proxy) - response.raise_for_status() - return response.json() + return PredefinedScreener(query, count, session=session, proxy=proxy, timeout=timeout) - def _fetch_predefined(self) -> 'dict': - params_dict = { - "count": 25, - "formatted": True, - "scrIds": self.predefined, - "sortField": "", - "sortType": None, - "start": 0, - "useRecordsResponse": False, - "fields": ["ticker", "symbol", "longName", "sparkline", "shortName", "regularMarketPrice", "regularMarketChange", "regularMarketChangePercent", "regularMarketVolume", "averageDailyVolume3Month", "marketCap", "trailingPE", "regularMarketOpen"], - "lang": "en-US", - "region": "US" - } - response = self._data.get(_PREDEFINED_URL_, user_agent_headers=self._data.user_agent_headers, params=params_dict, proxy=self.proxy) + def _fetch(self): + if self.body is None: + raise ValueError("No body set for screener") + + if self.query is None: + raise ValueError("No query set for screener") + + params_dict = {"corsDomain": "finance.yahoo.com", "formatted": "false", "lang": "en-US", "region": "US"} + + body = self.body.copy() # Copying the body so that query is not added to the original + print("BODY", body) + body["query"] = self.query.to_dict() + + response = self._data.post(_SCREENER_URL_, body=body, user_agent_headers=self._data.user_agent_headers, params=params_dict, proxy=self.proxy) + print(body["query"]) + print(response.text) response.raise_for_status() - return response.json() - - def _fetch_and_parse(self) -> None: - response = None - - try: - if self.predefined != False: - response = self._fetch_predefined() - else: - self._validate_body() - response = self._fetch() - self._response = response['finance']['result'][0] - except Exception as e: - logger = utils.get_yf_logger() - logger.error(f"Failed to get screener data for '{self._body.get('query', 'query not set')}' reason: {e}") - logger.debug("Got response: ") - logger.debug("-------------") - logger.debug(f" {response}") - logger.debug("-------------") + + self._response = response.json() @property - def quotes(self) -> 'list[dict]': - if self.predefined: - return self.response.get("quotes", []) + def response(self) -> 'dict[str, Any]': + """ + Get the results from the screener. - return self.response.get("records", []) + Returns: + The results from the screener. + """ + if self._response == None: + self._fetch() + return self._response \ No newline at end of file diff --git a/yfinance/screener/screener_query.py b/yfinance/screener/screener_query.py deleted file mode 100644 index 65c937591..000000000 --- a/yfinance/screener/screener_query.py +++ /dev/null @@ -1,145 +0,0 @@ -from abc import ABC, abstractmethod -import numbers -from typing import List, Union, Dict - -from yfinance.const import EQUITY_SCREENER_EQ_MAP, EQUITY_SCREENER_FIELDS -from yfinance.exceptions import YFNotImplementedError -from ..utils import dynamic_docstring, generate_list_table_from_dict - -class Query(ABC): - def __init__(self, operator: str, operand: Union[numbers.Real, str, List['Query']]): - self.operator = operator - self.operands = operand - - @abstractmethod - def to_dict(self) -> Dict: - raise YFNotImplementedError('to_dict() needs to be implemented by children classes') - -class EquityQuery(Query): - """ - The `EquityQuery` class constructs filters for stocks based on specific criteria such as region, sector, exchange, and peer group. - - The queries support operators: `GT` (greater than), `LT` (less than), `BTWN` (between), `EQ` (equals), and logical operators `AND` and `OR` for combining multiple conditions. - - Example: - Screen for stocks where the end-of-day price is greater than 3. - - .. code-block:: python - - gt = yf.EquityQuery('gt', ['eodprice', 3]) - - Screen for stocks where the average daily volume over the last 3 months is less than a very large number. - - .. code-block:: python - - lt = yf.EquityQuery('lt', ['avgdailyvol3m', 99999999999]) - - Screen for stocks where the intraday market cap is between 0 and 100 million. - - .. code-block:: python - - btwn = yf.EquityQuery('btwn', ['intradaymarketcap', 0, 100000000]) - - Screen for stocks in the Technology sector. - - .. code-block:: python - - eq = yf.EquityQuery('eq', ['sector', 'Technology']) - - Combine queries using AND/OR. - - .. code-block:: python - - qt = yf.EquityQuery('and', [gt, lt]) - qf = yf.EquityQuery('or', [qt, btwn, eq]) - """ - def __init__(self, operator: str, operand: Union[numbers.Real, str, List['EquityQuery']]): - """ - .. seealso:: - - :attr:`EquityQuery.valid_operand_fields ` - supported operand values for query - :attr:`EquityQuery.valid_eq_operand_map ` - supported `EQ query operand parameters` - """ - operator = operator.upper() - - if not isinstance(operand, list): - raise TypeError('Invalid operand type') - if len(operand) <= 0: - raise ValueError('Invalid field for Screener') - - if operator in {'OR','AND'}: - self._validate_or_and_operand(operand) - elif operator == 'EQ': - self._validate_eq_operand(operand) - elif operator == 'BTWN': - self._validate_btwn_operand(operand) - elif operator in {'GT','LT'}: - self._validate_gt_lt(operand) - else: - raise ValueError('Invalid Operator Value') - - self.operator = operator - self.operands = operand - self._valid_eq_operand_map = EQUITY_SCREENER_EQ_MAP - self._valid_operand_fields = EQUITY_SCREENER_FIELDS - - @dynamic_docstring({"valid_eq_operand_map_table": generate_list_table_from_dict(EQUITY_SCREENER_EQ_MAP)}) - @property - def valid_eq_operand_map(self) -> Dict: - """ - Valid Operand Map for Operator "EQ" - {valid_eq_operand_map_table} - """ - return self._valid_eq_operand_map - - @dynamic_docstring({"valid_operand_fields_table": generate_list_table_from_dict(EQUITY_SCREENER_FIELDS)}) - @property - def valid_operand_fields(self) -> Dict: - """ - Valid Operand Fields - {valid_operand_fields_table} - """ - return self._valid_operand_fields - - def _validate_or_and_operand(self, operand: List['EquityQuery']) -> None: - if len(operand) <= 1: - raise ValueError('Operand must be length longer than 1') - if all(isinstance(e, EquityQuery) for e in operand) is False: - raise TypeError('Operand must be type EquityQuery for OR/AND') - - def _validate_eq_operand(self, operand: List[Union[str, numbers.Real]]) -> None: - if len(operand) != 2: - raise ValueError('Operand must be length 2 for EQ') - - if not any(operand[0] in fields_by_type for fields_by_type in EQUITY_SCREENER_FIELDS.values()): - raise ValueError('Invalid field for Screener') - if operand[0] not in EQUITY_SCREENER_EQ_MAP: - raise ValueError('Invalid EQ key') - if operand[1] not in EQUITY_SCREENER_EQ_MAP[operand[0]]: - raise ValueError('Invalid EQ value') - - def _validate_btwn_operand(self, operand: List[Union[str, numbers.Real]]) -> None: - if len(operand) != 3: - raise ValueError('Operand must be length 3 for BTWN') - if not any(operand[0] in fields_by_type for fields_by_type in EQUITY_SCREENER_FIELDS.values()): - raise ValueError('Invalid field for Screener') - if isinstance(operand[1], numbers.Real) is False: - raise TypeError('Invalid comparison type for BTWN') - if isinstance(operand[2], numbers.Real) is False: - raise TypeError('Invalid comparison type for BTWN') - - def _validate_gt_lt(self, operand: List[Union[str, numbers.Real]]) -> None: - if len(operand) != 2: - raise ValueError('Operand must be length 2 for GT/LT') - if not any(operand[0] in fields_by_type for fields_by_type in EQUITY_SCREENER_FIELDS.values()): - raise ValueError('Invalid field for Screener') - if isinstance(operand[1], numbers.Real) is False: - raise TypeError('Invalid comparison type for GT/LT') - - def to_dict(self) -> Dict: - return { - "operator": self.operator, - "operands": [operand.to_dict() if isinstance(operand, EquityQuery) else operand for operand in self.operands] - } \ No newline at end of file diff --git a/yfinance/utils.py b/yfinance/utils.py index 54820d1af..a38d25c39 100644 --- a/yfinance/utils.py +++ b/yfinance/utils.py @@ -29,7 +29,7 @@ from functools import lru_cache, wraps from inspect import getmembers from types import FunctionType -from typing import List, Optional +from typing import List, Optional, TypeVar, Any import numpy as _np import pandas as _pd @@ -40,6 +40,8 @@ from yfinance import const +T = TypeVar('T') + 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'} @@ -994,4 +996,13 @@ def generate_list_table_from_dict_of_dict(data: dict, bullets: bool=True) -> str table += f" - {value}\n" else: table += f" - {value_str}\n" - return table \ No newline at end of file + return table + +def pop(l:'list[T]', index:'int'=-1, default:'Any'=None) -> 'Any': + print(f"POP: {l=} {index=} {default=}") + try: + return l.pop(index) + except IndexError as e: + if default != None: + return default + raise e \ No newline at end of file From 60dcaa233f132133f622fda93912253354f65b9d Mon Sep 17 00:00:00 2001 From: R5dan Date: Thu, 2 Jan 2025 20:14:29 +0000 Subject: [PATCH 5/5] Import screener package in main package --- yfinance/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/yfinance/__init__.py b/yfinance/__init__.py index e16ff4f2a..ba951107f 100644 --- a/yfinance/__init__.py +++ b/yfinance/__init__.py @@ -28,6 +28,7 @@ from .cache import set_tz_cache_location from .domain.sector import Sector from .domain.industry import Industry +from . import screener __version__ = version.version __author__ = "Ran Aroussi"