diff --git a/doc/source/reference/index.rst b/doc/source/reference/index.rst index 5858a1a6..51237a79 100644 --- a/doc/source/reference/index.rst +++ b/doc/source/reference/index.rst @@ -20,7 +20,8 @@ The following are the publicly available classes, and functions exposed by the ` - :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. -- :attr:`EquityQuery `: Class to build equity market query. +- :attr:`EquityOperation `: Class to build equity market operation. +- :attr:`Query `: Class to build query. - :attr:`Screener `: Class to screen the market using defined query. - :attr:`enable_debug_mode `: Function to enable debug mode for logging. - :attr:`set_tz_cache_location `: Function to set the timezone cache location. @@ -37,6 +38,7 @@ The following are the publicly available classes, and functions exposed by the ` yfinance.marketsummary yfinance.search yfinance.sector_industry + yfinance.screener yfinance.functions yfinance.funds_data diff --git a/doc/source/reference/yfinance.functions.rst b/doc/source/reference/yfinance.functions.rst index ef83454b..5206e441 100644 --- a/doc/source/reference/yfinance.functions.rst +++ b/doc/source/reference/yfinance.functions.rst @@ -13,25 +13,6 @@ The `download` function allows you to retrieve market data for multiple tickers download -Query Market Data -~~~~~~~~~~~~~~~~~~~~~ -The `Sector` and `Industry` modules allow you to access the sector and industry information. - -.. autosummary:: - :toctree: api/ - - EquityQuery - Screener - -.. seealso:: - :attr:`EquityQuery.valid_operand_fields ` - supported operand values for query - :attr:`EquityQuery.valid_eq_operand_map ` - supported `EQ query operand parameters` - :attr:`Screener.predefined_bodies ` - supported predefined screens - - Enable Debug Mode ~~~~~~~~~~~~~~~~~ Enables logging of debug information for the `yfinance` package. diff --git a/doc/source/reference/yfinance.screener.rst b/doc/source/reference/yfinance.screener.rst new file mode 100644 index 00000000..b2eaba64 --- /dev/null +++ b/doc/source/reference/yfinance.screener.rst @@ -0,0 +1,27 @@ +========================= +Screener & Query +========================= + +.. currentmodule:: yfinance + +Query Market Data +~~~~~~~~~~~~~~~~~~~~~ +The `Sector` and `Industry` modules allow you to access the sector and industry information. + +.. autosummary:: + :toctree: api/ + + EquityQuery + FundQuery + screen + +.. seealso:: + :attr:`EquityQuery.valid_fields ` + supported operand values for query + :attr:`EquityQuery.valid_values ` + supported `EQ query operand parameters` + :attr:`EquityQuery.valid_fields ` + supported operand values for query + :attr:`EquityQuery.valid_values ` + supported `EQ query operand parameters` + \ No newline at end of file diff --git a/tests/test_screener.py b/tests/test_screener.py index 0ec6c201..89412433 100644 --- a/tests/test_screener.py +++ b/tests/test_screener.py @@ -1,153 +1,38 @@ import unittest from unittest.mock import patch, MagicMock -from yfinance.const import PREDEFINED_SCREENER_BODY_MAP -from yfinance.screener.screener import Screener -from yfinance.screener.screener_query import EquityQuery +from yfinance.screener.screener import screen +from yfinance.screener.query import EquityQuery class TestScreener(unittest.TestCase): @classmethod def setUpClass(self): - self.screener = Screener() self.query = EquityQuery('gt',['eodprice',3]) - - def test_set_default_body(self): - result = self.screener.set_default_body(self.query) - - self.assertEqual(self.screener.body['offset'], 0) - self.assertEqual(self.screener.body['size'], 100) - self.assertEqual(self.screener.body['sortField'], 'ticker') - self.assertEqual(self.screener.body['sortType'], 'desc') - self.assertEqual(self.screener.body['quoteType'], 'equity') - 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' - 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): - self.screener.set_predefined_body('invalid_key') - - def test_set_body(self): - body = { - "offset": 0, - "size": 100, - "sortField": "ticker", - "sortType": "desc", - "quoteType": "equity", - "query": self.query.to_dict(), - "userId": "", - "userIdType": "guid" - } - 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 = { - "offset": 0, - "size": 100, - "sortField": "ticker", - "sortType": "desc", - "quoteType": "equity" - } - with self.assertRaises(ValueError): - self.screener.set_body(body) - - def test_set_body_extra_keys(self): - body = { - "offset": 0, - "size": 100, - "sortField": "ticker", - "sortType": "desc", - "quoteType": "equity", - "query": self.query.to_dict(), - "userId": "", - "userIdType": "guid", - "extraKey": "extraValue" - } - with self.assertRaises(ValueError): - self.screener.set_body(body) - - def test_patch_body(self): - initial_body = { - "offset": 0, - "size": 100, - "sortField": "ticker", - "sortType": "desc", - "quoteType": "equity", - "query": self.query.to_dict(), - "userId": "", - "userIdType": "guid" - } - self.screener.set_body(initial_body) - patch_values = {"size": 50} - 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 = { - "offset": 0, - "size": 100, - "sortField": "ticker", - "sortType": "desc", - "quoteType": "equity", - "query": self.query.to_dict(), - "userId": "", - "userIdType": "guid" - } - self.screener.set_body(initial_body) - patch_values = {"extraKey": "extraValue"} - with self.assertRaises(ValueError): - self.screener.patch_body(patch_values) + self.predefined = 'aggressive_small_caps' @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 + screen(self.query, size=251) - @patch('yfinance.screener.screener.YfData.post') - def test_fetch(self, mock_post): + @patch('yfinance.data.YfData.post') + def test_fetch_query(self, mock_post): mock_response = MagicMock() - mock_response.json.return_value = {'finance': {'result': [{}]}} + mock_response.json.return_value = {'finance': {'result': [{'key': 'value'}]}} mock_post.return_value = mock_response - self.screener.set_default_body(self.query) - response = self.screener._fetch() - - self.assertEqual(response, {'finance': {'result': [{}]}}) + response = screen(self.query) + self.assertEqual(response, {'key': 'value'}) - @patch('yfinance.screener.screener.YfData.post') - def test_fetch_and_parse(self, mock_post): + @patch('yfinance.data.YfData.get') + def test_fetch_predefined(self, mock_get): mock_response = MagicMock() mock_response.json.return_value = {'finance': {'result': [{'key': 'value'}]}} - mock_post.return_value = mock_response + mock_get.return_value = mock_response - self.screener.set_default_body(self.query) - self.screener._fetch_and_parse() - self.assertEqual(self.screener.response, {'key': 'value'}) + response = screen(self.predefined) + self.assertEqual(response, {'key': 'value'}) if __name__ == '__main__': unittest.main() \ No newline at end of file diff --git a/yfinance/__init__.py b/yfinance/__init__.py index e2cefd55..2f78eb46 100644 --- a/yfinance/__init__.py +++ b/yfinance/__init__.py @@ -28,15 +28,17 @@ 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 from .domain.market import Market +from .screener.query import EquityQuery, FundQuery +from .screener.screener import screen, PREDEFINED_SCREENER_QUERIES + __version__ = version.version __author__ = "Ran Aroussi" import warnings warnings.filterwarnings('default', category=DeprecationWarning, module='^yfinance') -__all__ = ['download', 'Market', 'Search', 'Ticker', 'Tickers', 'enable_debug_mode', 'set_tz_cache_location', 'Sector', - 'Industry', 'EquityQuery', 'Screener'] +__all__ = ['download', 'Market', 'Search', 'Ticker', 'Tickers', 'enable_debug_mode', 'set_tz_cache_location', 'Sector', 'Industry'] +# screener stuff: +__all__ += ['EquityQuery', 'FundQuery', 'screen', 'PREDEFINED_SCREENER_QUERIES'] \ No newline at end of file diff --git a/yfinance/const.py b/yfinance/const.py index 2735f254..b4a1fd3c 100644 --- a/yfinance/const.py +++ b/yfinance/const.py @@ -305,21 +305,92 @@ 'utilities-independent-power-producers', 'utilities-regulated-water'} } + +def merge_two_level_dicts(dict1, dict2): + result = dict1.copy() + for key, value in dict2.items(): + if key in result: + # If both are sets, merge them + if isinstance(value, set) and isinstance(result[key], set): + result[key] = result[key] | value + # If both are dicts, merge their contents + elif isinstance(value, dict) and isinstance(result[key], dict): + result[key] = { + k: (result[key].get(k, set()) | v if isinstance(v, set) + else v) if k in result[key] + else v + for k, v in value.items() + } + else: + result[key] = value + return result + EQUITY_SCREENER_EQ_MAP = { - "region": { - "za", "ve", "vn", "us", "tw", "th", "tr", "sr", "sg", "sa", "se", "ru", "ro", "qa", "pt", "pk", "pl", - "ph", "nz", "nl", "mx", "pe", "no", "my", "lv", "lt", "kw", "jp", "is", "il", "lk", "kr", "it", "in", - "ie", "hu", "id", "hk", "gb", "fi", "eg", "dk", "gr", "fr", "es", "ee", "de", "cz", "cl", "ca", "be", - "at", "cn", "br", "au", "ar", "ch" + "exchange": { + 'ar': {'BUE'}, + 'at': {'VIE'}, + 'au': {'ASX'}, + 'be': {'BRU'}, + 'br': {'SAO'}, + 'ca': {'CNQ', 'NEO', 'TOR', 'VAN'}, + 'ch': {'EBS'}, + 'cl': {'SGO'}, + 'cn': {'SHH', 'SHZ'}, + 'co': {'BVC'}, + 'cz': {'PRA'}, + 'de': {'BER', 'DUS', 'FRA', 'HAM', 'GER', 'MUN', 'STU'}, + 'dk': {'CPH'}, + 'ee': {'TAL'}, + 'eg': {'CAI'}, + 'es': {'MCE'}, + 'fi': {'HEL'}, + 'fr': {'PAR'}, + 'gb': {'AQS', 'IOB', 'LSE'}, + 'gr': {'ATH'}, + 'hk': {'HKG'}, + 'hu': {'BUD'}, + 'id': {'JKT'}, + 'ie': {'ISE'}, + 'il': {'TLV'}, + 'in': {'BSE', 'NSI'}, + 'is': {'ICE'}, + 'it': {'MIL'}, + 'jp': {'FKA', 'JPX', 'SAP'}, + 'kr': {'KOE', 'KSC'}, + 'kw': {'KUW'}, + 'lk': {}, + 'lt': {'LIT'}, + 'lv': {'RIS'}, + 'mx': {'MEX'}, + 'my': {'KLS'}, + 'nl': {'AMS'}, + 'no': {'OSL'}, + 'nz': {'NZE'}, + 'pe': {}, + 'ph': {'PHP', 'PHS'}, + 'pk': {}, + 'pl': {'WSE'}, + 'pt': {'LIS'}, + 'qa': {'DOH'}, + 'ro': {'BVB'}, + 'ru': {}, + 'sa': {'SAU'}, + 'se': {'STO'}, + 'sg': {'SES'}, + 'sr': {}, + 'th': {'SET'}, + 'tr': {'IST'}, + 'tw': {'TAI', 'TWO'}, + 'us': {'ASE', 'BTS', 'CXI', 'NCM', 'NGM', 'NMS', 'NYQ', 'OEM', 'OQB', 'OQX', 'PCX', 'PNK', 'YHD'}, + 've': {'CCS'}, + 'vn': {}, + 'za': {'JNB'} }, "sector": { "Basic Materials", "Industrials", "Communication Services", "Healthcare", "Real Estate", "Technology", "Energy", "Utilities", "Financial Services", "Consumer Defensive", "Consumer Cyclical" }, - "exchanges": { - "NMS", "NAS", "YHD", "NYQ", "NGM", "NCM", "BSE" - }, "peer_group": { "US Fund Equity Energy", "US CE Convertibles", @@ -426,20 +497,42 @@ "Banks" } } +EQUITY_SCREENER_EQ_MAP['region'] = EQUITY_SCREENER_EQ_MAP['exchange'].keys() +ordered_keys = ['region'] + [k for k in EQUITY_SCREENER_EQ_MAP.keys() if k != 'region'] +EQUITY_SCREENER_EQ_MAP = {k:EQUITY_SCREENER_EQ_MAP[k] for k in ordered_keys} +FUND_SCREENER_EQ_MAP = { + "exchange": { + 'us': {'NAS'} + } +} +COMMON_SCREENER_FIELDS = { + "price":{ + "eodprice", + "intradaypricechange", + "intradayprice" + }, + "eq_fields": { + "exchange"}, +} +FUND_SCREENER_FIELDS = { + "eq_fields": { + "categoryname", + "performanceratingoverall", + "initialinvestment", + "annualreturnnavy1categoryrank", + "riskratingoverall"} +} +FUND_SCREENER_FIELDS = merge_two_level_dicts(FUND_SCREENER_FIELDS, COMMON_SCREENER_FIELDS) EQUITY_SCREENER_FIELDS = { "eq_fields": { "region", "sector", - "peer_group", - "exchanges"}, + "peer_group"}, "price":{ - "eodprice", - "intradaypricechange", "lastclosemarketcap.lasttwelvemonths", "percentchange", "lastclose52weekhigh.lasttwelvemonths", "fiftytwowkpercentchange", - "intradayprice", "lastclose52weeklow.lasttwelvemonths", "intradaymarketcap"}, "trading":{ @@ -530,21 +623,4 @@ "social_score", "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 +EQUITY_SCREENER_FIELDS = merge_two_level_dicts(EQUITY_SCREENER_FIELDS, COMMON_SCREENER_FIELDS) diff --git a/yfinance/screener/__init__.py b/yfinance/screener/__init__.py index 3254bdcc..b3e31224 100644 --- a/yfinance/screener/__init__.py +++ b/yfinance/screener/__init__.py @@ -1,4 +1,4 @@ -from .screener import Screener -from .screener_query import EquityQuery +from .query import EquityQuery +from .screener import screen, PREDEFINED_SCREENER_QUERIES -__all__ = ['EquityQuery', 'Screener'] \ No newline at end of file +__all__ = ['EquityQuery', 'FundQuery', 'screen', 'PREDEFINED_SCREENER_QUERIES'] diff --git a/yfinance/screener/query.py b/yfinance/screener/query.py new file mode 100644 index 00000000..81214a7d --- /dev/null +++ b/yfinance/screener/query.py @@ -0,0 +1,218 @@ +from abc import ABC, abstractmethod +import numbers +from typing import List, Union, Dict, TypeVar, Tuple + +from yfinance.const import EQUITY_SCREENER_EQ_MAP, EQUITY_SCREENER_FIELDS +from yfinance.const import FUND_SCREENER_EQ_MAP, FUND_SCREENER_FIELDS +from yfinance.exceptions import YFNotImplementedError +from ..utils import dynamic_docstring, generate_list_table_from_dict_universal + +T = TypeVar('T', bound=Union[str, numbers.Real]) + +class QueryBase(ABC): + def __init__(self, operator: str, operand: Union[ List['QueryBase'], Tuple[str, Tuple[Union[str, numbers.Real], ...]] ]): + operator = operator.upper() + + if not isinstance(operand, list): + raise TypeError('Invalid operand type') + if len(operand) <= 0: + raise ValueError('Invalid field for EquityQuery') + + if operator == 'IS-IN': + self._validate_isin_operand(operand) + elif 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','GTE','LTE'}: + self._validate_gt_lt(operand) + else: + raise ValueError('Invalid Operator Value') + + self.operator = operator + self.operands = operand + + @property + @abstractmethod + def valid_fields(self) -> List: + raise YFNotImplementedError('valid_fields() needs to be implemented by child') + + @property + @abstractmethod + def valid_values(self) -> Dict: + raise YFNotImplementedError('valid_values() needs to be implemented by child') + + def _validate_or_and_operand(self, operand: List['QueryBase']) -> None: + if len(operand) <= 1: + raise ValueError('Operand must be length longer than 1') + if all(isinstance(e, QueryBase) for e in operand) is False: + raise TypeError(f'Operand must be type {type(self)} 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 self.valid_fields.values()): + raise ValueError(f'Invalid field for {type(self)} "{operand[0]}"') + if operand[0] in self.valid_values: + vv = self.valid_values[operand[0]] + if isinstance(vv, dict): + # this data structure is slightly different to generate better docs, + # need to unpack here. + vv = set().union(*[e for e in vv.values()]) + if operand[1] not in vv: + raise ValueError(f'Invalid EQ value "{operand[1]}"') + + 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 self.valid_fields.values()): + raise ValueError(f'Invalid field for {type(self)}') + 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 self.valid_fields.values()): + raise ValueError(f'Invalid field for {type(self)} "{operand[0]}"') + if isinstance(operand[1], numbers.Real) is False: + raise TypeError('Invalid comparison type for GT/LT') + + def _validate_isin_operand(self, operand: List['QueryBase']) -> None: + if len(operand) < 2: + raise ValueError('Operand must be length 2+ for IS-IN') + + if not any(operand[0] in fields_by_type for fields_by_type in self.valid_fields.values()): + raise ValueError(f'Invalid field for {type(self)} "{operand[0]}"') + if operand[0] in self.valid_values: + vv = self.valid_values[operand[0]] + if isinstance(vv, dict): + # this data structure is slightly different to generate better docs, + # need to unpack here. + vv = set().union(*[e for e in vv.values()]) + for i in range(1, len(operand)): + if operand[i] not in vv: + raise ValueError(f'Invalid EQ value "{operand[i]}"') + + def to_dict(self) -> Dict: + op = self.operator + ops = self.operands + if self.operator == 'IS-IN': + # Expand to OR of EQ queries + op = 'OR' + ops = [type(self)('EQ', [self.operands[0], v]) for v in self.operands[1:]] + return { + "operator": op, + "operands": [o.to_dict() if isinstance(o, QueryBase) else o for o in ops] + } + + def __repr__(self, indent=0) -> str: + indent_str = " " * indent + class_name = self.__class__.__name__ + + if isinstance(self.operands, list): + # For list operands, check if they contain any QueryBase objects + if any(isinstance(op, QueryBase) for op in self.operands): + # If there are nested queries, format them with newlines + operands_str = ",\n".join( + f"{indent_str} {op.__repr__(indent + 1) if isinstance(op, QueryBase) else repr(op)}" + for op in self.operands + ) + return f"{class_name}({self.operator}, [\n{operands_str}\n{indent_str}])" + else: + # For lists of simple types, keep them on one line + return f"{class_name}({self.operator}, {repr(self.operands)})" + else: + # Handle single operand + return f"{class_name}({self.operator}, {repr(self.operands)})" + + def __str__(self) -> str: + return self.__repr__() + + +class EquityQuery(QueryBase): + """ + The `EquityQuery` class constructs filters for stocks based on specific criteria such as region, sector, exchange, and peer group. + + Start with value operations: `EQ` (equals), `IS-IN` (is in), `BTWN` (between), `GT` (greater than), `LT` (less than), `GTE` (greater or equal), `LTE` (less or equal). + + Combine them with logical operations: `AND`, `OR`. + + Example: + Predefined Yahoo query `aggressive_small_caps`: + + .. code-block:: python + + from yfinance import EquityQuery + + EquityQuery('and', [ + EquityQuery('is-in', ['exchange', 'NMS', 'NYQ']), + EquityQuery('lt', ["epsgrowth.lasttwelvemonths", 15]) + ]) + """ + + @dynamic_docstring({"valid_operand_fields_table": generate_list_table_from_dict_universal(EQUITY_SCREENER_FIELDS)}) + @property + def valid_fields(self) -> Dict: + """ + Valid operands, grouped by category. + {valid_operand_fields_table} + """ + return EQUITY_SCREENER_FIELDS + + @dynamic_docstring({"valid_values_table": generate_list_table_from_dict_universal(EQUITY_SCREENER_EQ_MAP, concat_keys=['exchange'])}) + @property + def valid_values(self) -> Dict: + """ + Most operands take number values, but some have a restricted set of valid values. + {valid_values_table} + """ + return EQUITY_SCREENER_EQ_MAP + + +class FundQuery(QueryBase): + """ + The `FundQuery` class constructs filters for mutual funds based on specific criteria such as region, sector, exchange, and peer group. + + Start with value operations: `EQ` (equals), `IS-IN` (is in), `BTWN` (between), `GT` (greater than), `LT` (less than), `GTE` (greater or equal), `LTE` (less or equal). + + Combine them with logical operations: `AND`, `OR`. + + Example: + Predefined Yahoo query `solid_large_growth_funds`: + + .. code-block:: python + + from yfinance import FundQuery + + FundQuery('and', [ + FundQuery('eq', ['categoryname', 'Large Growth']), + FundQuery('is-in', ['performanceratingoverall', 4, 5]), + FundQuery('lt', ['initialinvestment', 100001]), + FundQuery('lt', ['annualreturnnavy1categoryrank', 50]), + FundQuery('eq', ['exchange', 'NAS']) + ]) + """ + @dynamic_docstring({"valid_operand_fields_table": generate_list_table_from_dict_universal(FUND_SCREENER_FIELDS)}) + @property + def valid_fields(self) -> Dict: + """ + Valid operands, grouped by category. + {valid_operand_fields_table} + """ + return FUND_SCREENER_FIELDS + + @dynamic_docstring({"valid_values_table": generate_list_table_from_dict_universal(FUND_SCREENER_EQ_MAP)}) + @property + def valid_values(self) -> Dict: + """ + Most operands take number values, but some have a restricted set of valid values. + {valid_values_table} + """ + return FUND_SCREENER_EQ_MAP + diff --git a/yfinance/screener/screener.py b/yfinance/screener/screener.py index 01ff667b..60d84c31 100644 --- a/yfinance/screener/screener.py +++ b/yfinance/screener/screener.py @@ -1,225 +1,180 @@ -from typing import Dict +from .query import EquityQuery as EqyQy +from .query import FundQuery as FndQy +from .query import QueryBase, EquityQuery, FundQuery -from yfinance import utils +from yfinance.const import _BASE_URL_ from yfinance.data import YfData -from yfinance.const import _BASE_URL_, PREDEFINED_SCREENER_BODY_MAP -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" +from ..utils import dynamic_docstring, generate_list_table_from_dict_universal -class Screener: - """ - The `Screener` class is used to execute the queries and return the filtered results. +from typing import Union +import requests - 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. +_SCREENER_URL_ = f"{_BASE_URL_}/v1/finance/screener" +_PREDEFINED_URL_ = f"{_SCREENER_URL_}/predefined/saved" + +PREDEFINED_SCREENER_BODY_DEFAULTS = { + "offset":0, "size":25, "userId":"","userIdType":"guid" +} + +PREDEFINED_SCREENER_QUERIES = { + 'aggressive_small_caps': {"sortField":"eodvolume", "sortType":"desc", + "query": EqyQy('and', [EqyQy('is-in', ['exchange', 'NMS', 'NYQ']), EqyQy('lt', ["epsgrowth.lasttwelvemonths", 15])])}, + 'day_gainers': {"sortField":"percentchange", "sortType":"DESC", + "query": EqyQy('and', [EqyQy('gt', ['percentchange', 3]), EqyQy('eq', ['region', 'us']), EqyQy('gte', ['intradaymarketcap', 2000000000]), EqyQy('gte', ['intradayprice', 5]), EqyQy('gt', ['dayvolume', 15000])])}, + 'day_losers': {"sortField":"percentchange", "sortType":"ASC", + "query": EqyQy('and', [EqyQy('lt', ['percentchange', -2.5]), EqyQy('eq', ['region', 'us']), EqyQy('gte', ['intradaymarketcap', 2000000000]), EqyQy('gte', ['intradayprice', 5]), EqyQy('gt', ['dayvolume', 20000])])}, + 'growth_technology_stocks': {"sortField":"eodvolume", "sortType":"desc", + "query": EqyQy('and', [EqyQy('gte', ['quarterlyrevenuegrowth.quarterly', 25]), EqyQy('gte', ['epsgrowth.lasttwelvemonths', 25]), EqyQy('eq', ['sector', 'Technology']), EqyQy('is-in', ['exchange', 'NMS', 'NYQ'])])}, + 'most_actives': {"sortField":"dayvolume", "sortType":"DESC", + "query": EqyQy('and', [EqyQy('eq', ['region', 'us']), EqyQy('gte', ['intradaymarketcap', 2000000000]), EqyQy('gt', ['dayvolume', 5000000])])}, + 'most_shorted_stocks': {"size":25, "offset":0, "sortField":"short_percentage_of_shares_outstanding.value", "sortType":"DESC", + "query": EqyQy('and', [EqyQy('eq', ['region', 'us']), EqyQy('gt', ['intradayprice', 1]), EqyQy('gt', ['avgdailyvol3m', 200000])])}, + 'small_cap_gainers': {"sortField":"eodvolume", "sortType":"desc", + "query": EqyQy("and", [EqyQy("lt", ["intradaymarketcap",2000000000]), EqyQy("is-in", ["exchange", "NMS", "NYQ"])])}, + 'undervalued_growth_stocks': {"sortType":"DESC", "sortField":"eodvolume", + "query": EqyQy('and', [EqyQy('btwn', ['peratio.lasttwelvemonths', 0, 20]), EqyQy('lt', ['pegratio_5y', 1]), EqyQy('gte', ['epsgrowth.lasttwelvemonths', 25]), EqyQy('is-in', ['exchange', 'NMS', 'NYQ'])])}, + 'undervalued_large_caps': {"sortField":"eodvolume", "sortType":"desc", + "query": EqyQy('and', [EqyQy('btwn', ['peratio.lasttwelvemonths', 0, 20]), EqyQy('lt', ['pegratio_5y', 1]), EqyQy('btwn', ['intradaymarketcap', 10000000000, 100000000000]), EqyQy('is-in', ['exchange', 'NMS', 'NYQ'])])}, + 'conservative_foreign_funds': {"sortType":"DESC", "sortField":"fundnetassets", + "query": FndQy('and', [FndQy('is-in', ['categoryname', 'Foreign Large Value', 'Foreign Large Blend', 'Foreign Large Growth', 'Foreign Small/Mid Growth', 'Foreign Small/Mid Blend', 'Foreign Small/Mid Value']), FndQy('is-in', ['performanceratingoverall', 4, 5]), FndQy('lt', ['initialinvestment', 100001]), FndQy('lt', ['annualreturnnavy1categoryrank', 50]), FndQy('is-in', ['riskratingoverall', 1, 2, 3]), FndQy('eq', ['exchange', 'NAS'])])}, + 'high_yield_bond': {"sortType":"DESC", "sortField":"fundnetassets", + "query": FndQy('and', [FndQy('is-in', ['performanceratingoverall', 4, 5]), FndQy('lt', ['initialinvestment', 100001]), FndQy('lt', ['annualreturnnavy1categoryrank', 50]), FndQy('is-in', ['riskratingoverall', 1, 2, 3]), FndQy('eq', ['categoryname', 'High Yield Bond']), FndQy('eq', ['exchange', 'NAS'])])}, + 'portfolio_anchors': {"sortType":"DESC", "sortField":"fundnetassets", + "query": FndQy('and', [FndQy('eq', ['categoryname', 'Large Blend']), FndQy('is-in', ['performanceratingoverall', 4, 5]), FndQy('lt', ['initialinvestment', 100001]), FndQy('lt', ['annualreturnnavy1categoryrank', 50]), FndQy('eq', ['exchange', 'NAS'])])}, + 'solid_large_growth_funds': {"sortType":"DESC", "sortField":"fundnetassets", + "query": FndQy('and', [FndQy('eq', ['categoryname', 'Large Growth']), FndQy('is-in', ['performanceratingoverall', 4, 5]), FndQy('lt', ['initialinvestment', 100001]), FndQy('lt', ['annualreturnnavy1categoryrank', 50]), FndQy('eq', ['exchange', 'NAS'])])}, + 'solid_midcap_growth_funds': {"sortType":"DESC", "sortField":"fundnetassets", + "query": FndQy('and', [FndQy('eq', ['categoryname', 'Mid-Cap Growth']), FndQy('is-in', ['performanceratingoverall', 4, 5]), FndQy('lt', ['initialinvestment', 100001]), FndQy('lt', ['annualreturnnavy1categoryrank', 50]), FndQy('eq', ['exchange', 'NAS'])])}, + 'top_mutual_funds': {"sortType":"DESC", "sortField":"percentchange", + "query": FndQy('and', [FndQy('gt', ['intradayprice', 15]), FndQy('is-in', ['performanceratingoverall', 4, 5]), FndQy('gt', ['initialinvestment', 1000]), FndQy('eq', ['exchange', 'NAS'])])} +} + +@dynamic_docstring({"predefined_screeners": generate_list_table_from_dict_universal(PREDEFINED_SCREENER_QUERIES, bullets=True, title='Predefined queries (Dec-2024)')}) +def screen(query: Union[str, EquityQuery, FundQuery], + offset: int = None, + size: int = None, + sortField: str = None, + sortAsc: bool = None, + userId: str = None, + userIdType: str = None, + session = None, proxy = None): """ - def __init__(self, session=None, proxy=None): - """ - 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. - - .. seealso:: - - :attr:`Screener.predefined_bodies ` - supported predefined screens - """ - self.proxy = proxy - self.session = session - - self._data: YfData = YfData(session=session) - self._body: 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() - - @property - def body(self) -> Dict: - return self._body - - @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() - - 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: - """ - 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. - - 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 - - screener.set_default_body(qf) - """ - self._body_updated = True - - self._body = { - "offset": offset, - "size": size, - "sortField": sortField, - "sortType": sortType, - "quoteType": quoteType, - "query": query.to_dict(), - "userId": userId, - "userIdType": userIdType - } - return self - - 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 - - screener.set_predefined_body('day_gainers') - - - .. seealso:: - - :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._body_updated = True - self._body = body - return self - - def set_body(self, body: Dict) -> 'Screener': - """ - Set the fully custom body using dictionary input - - Args: - body (Dict): full query body - - Returns: - Screener: self - - Example: - - .. code-block:: python - - screener.set_body({ - "offset": 0, - "size": 100, - "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] - 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] - if extra_keys: - raise ValueError(f"Body contains extra keys: {extra_keys}") - - self._body_updated = True - self._body = body - return self - - def patch_body(self, values: Dict) -> 'Screener': - """ - Patch parts of the body using dictionary input - - Args: - body (Dict): partial query body - - Returns: - Screener: self - - Example: - - .. code-block:: python - - screener.patch_body({"offset": 100}) - """ - 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}") - - 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"} - 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() - - def _fetch_and_parse(self) -> None: - response = None - self._validate_body() - + Run a screen: predefined query, or custom query. + + :Parameters: + * Defaults only apply if query = EquityQuery or FundQuery + query : str | Query: + The query to execute, either name of predefined or custom query. + For predefined list run yf.PREDEFINED_SCREENER_QUERIES.keys() + offset : int + The offset for the results. Default 0. + size : int + number of results to return. Default 100, maximum 250 (Yahoo) + sortField : str + field to sort by. Default "ticker" + sortAsc : bool + Sort ascending? Default False + userId : str + The user ID. Default empty. + userIdType : str + Type of user ID (e.g., "guid"). Default "guid". + + Example: predefined query + .. code-block:: python + + import yfinance as yf + response = yf.screen("aggressive_small_caps") + + Example: custom query + .. code-block:: python + + import yfinance as yf + from yfinance import EquityQuery + q = EquityQuery('and', [ + EquityQuery('gt', ['percentchange', 3]), + EquityQuery('eq', ['region', 'us']) + ]) + response = yf.screen(q, sortField = 'percentchange', sortAsc = True) + + To access predefineds query code + .. code-block:: python + + import yfinance as yf + query = yf.PREDEFINED_SCREENER_QUERIES['aggressive_small_caps'] + + {predefined_screeners} + """ + + # Only use defaults when user NOT give a predefined, because + # Yahoo's predefined endpoint auto-applies defaults. Also, + # that endpoint might be ignoring these fields. + defaults = { + 'offset': 0, + 'size': 25, + 'sortField': 'ticker', + 'sortAsc': False, + 'userId': "", + 'userIdType': "guid" + } + + if size is not None and size > 250: + raise ValueError("Yahoo limits query size to 250, reduce size.") + + fields = dict(locals()) + for k in ['query', 'session', 'proxy']: + if k in fields: + del fields[k] + + params_dict = {"corsDomain": "finance.yahoo.com", "formatted": "false", "lang": "en-US", "region": "US"} + + post_query = None + if isinstance(query, str): + # post_query = PREDEFINED_SCREENER_QUERIES[query] + # Switch to Yahoo's predefined endpoint + _data = YfData(session=session) + params_dict['scrIds'] = query + for k,v in fields.items(): + if v is not None: + params_dict[k] = v + resp = _data.get(url=_PREDEFINED_URL_, params=params_dict, proxy=proxy) try: - 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("-------------") + resp.raise_for_status() + except requests.exceptions.HTTPError: + if query not in PREDEFINED_SCREENER_QUERIES: + print(f"yfinance.screen: '{query}' is probably not a predefined query.") + raise + return resp.json()["finance"]["result"][0] + + elif isinstance(query, QueryBase): + # Prepare other fields + for k in defaults: + if k not in fields or fields[k] is None: + fields[k] = defaults[k] + fields['sortType'] = 'ASC' if fields['sortAsc'] else 'DESC' + del fields['sortAsc'] + + post_query = fields + post_query['query'] = query + + else: + raise ValueError(f'Query must be type str or QueryBase, not "{type(query)}"') + + if query is None: + raise ValueError('No query provided') + + if isinstance(post_query['query'], EqyQy): + post_query['quoteType'] = 'EQUITY' + elif isinstance(post_query['query'], FndQy): + post_query['quoteType'] = 'MUTUALFUND' + post_query['query'] = post_query['query'].to_dict() + + # Fetch + _data = YfData(session=session) + response = _data.post(_SCREENER_URL_, + body=post_query, + user_agent_headers=_data.user_agent_headers, + params=params_dict, + proxy=proxy) + response.raise_for_status() + return response.json()['finance']['result'][0] diff --git a/yfinance/screener/screener_query.py b/yfinance/screener/screener_query.py deleted file mode 100644 index 65c93759..00000000 --- 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 bee90dfe..66781287 100644 --- a/yfinance/utils.py +++ b/yfinance/utils.py @@ -943,10 +943,12 @@ def decorator(func): return func return decorator -def _generate_table_configurations() -> str: +def _generate_table_configurations(title = None) -> str: import textwrap - table = textwrap.dedent(""" - .. list-table:: Permitted Keys/Values + if title is None: + title = "Permitted Keys/Values" + table = textwrap.dedent(f""" + .. list-table:: {title} :widths: 25 75 :header-rows: 1 @@ -956,34 +958,134 @@ def _generate_table_configurations() -> str: return table -def generate_list_table_from_dict(data: dict, bullets: bool=True) -> str: +def generate_list_table_from_dict(data: dict, bullets: bool=True, title: str=None) -> str: """ Generate a list-table for the docstring showing permitted keys/values. """ - table = _generate_table_configurations() - for key, values in data.items(): - value_str = ', '.join(sorted(values)) - table += f" * - {key}\n" - if bullets: - table += " -\n" + table = _generate_table_configurations(title) + for k in sorted(data.keys()): + values = data[k] + table += ' '*3 + f"* - {k}\n" + lengths = [len(str(v)) for v in values] + if bullets and max(lengths) > 5: + table += ' '*5 + "-\n" for value in sorted(values): - table += f" - {value}\n" + table += ' '*7 + f"- {value}\n" else: - table += f" - {value_str}\n" + value_str = ', '.join(sorted(values)) + table += ' '*5 + f"- {value_str}\n" return table -def generate_list_table_from_dict_of_dict(data: dict, bullets: bool=True) -> str: +# def generate_list_table_from_dict_of_dict(data: dict, bullets: bool=True, title: str=None) -> str: +# """ +# Generate a list-table for the docstring showing permitted keys/values. +# """ +# table = _generate_table_configurations(title) +# for k in sorted(data.keys()): +# values = data[k] +# table += ' '*3 + f"* - {k}\n" +# if bullets: +# table += ' '*5 + "-\n" +# for value in sorted(values): +# table += ' '*7 + f"- {value}\n" +# else: +# table += ' '*5 + f"- {values}\n" +# return table + + +def generate_list_table_from_dict_universal(data: dict, bullets: bool=True, title: str=None, concat_keys=[]) -> str: """ Generate a list-table for the docstring showing permitted keys/values. """ - table = _generate_table_configurations() - for key, values in data.items(): - value_str = values - table += f" * - {key}\n" - if bullets: - table += " -\n" - for value in sorted(values): - table += f" - {value}\n" + table = _generate_table_configurations(title) + for k in data.keys(): + values = data[k] + + table += ' '*3 + f"* - {k}\n" + if isinstance(values, dict): + table_add = '' + + concat_short_lines = k in concat_keys + + if bullets: + k_keys = sorted(list(values.keys())) + current_line = '' + block_format = 'query' in k_keys + for i in range(len(k_keys)): + k2 = k_keys[i] + k2_values = values[k2] + k2_values_str = None + if isinstance(k2_values, set): + k2_values = list(k2_values) + elif isinstance(k2_values, dict) and len(k2_values) == 0: + k2_values = [] + if isinstance(k2_values, list): + k2_values = sorted(k2_values) + all_scalar = all(isinstance(k2v, (int, float, str)) for k2v in k2_values) + if all_scalar: + k2_values_str = _re.sub(r"[{}\[\]']", "", str(k2_values)) + + if k2_values_str is None: + k2_values_str = str(k2_values) + + if len(current_line) > 0 and (len(current_line) + len(k2_values_str) > 40): + # new line + table_add += current_line + '\n' + current_line = '' + + if concat_short_lines: + if current_line == '': + current_line += ' '*5 + if i == 0: + # Only add dash to first + current_line += "- " + else: + current_line += " " + # Don't draw bullet points: + current_line += '| ' + else: + current_line += '. ' + current_line += f"{k2}: " + k2_values_str + else: + table_add += ' '*5 + if i == 0: + # Only add dash to first + table_add += "- " + else: + table_add += " " + + if '\n' in k2_values_str: + # Block format multiple lines + table_add += '| ' + f"{k2}: " + "\n" + k2_values_str_lines = k2_values_str.split('\n') + for j in range(len(k2_values_str_lines)): + line = k2_values_str_lines[j] + table_add += ' '*7 + '|' + ' '*5 + line + if j < len(k2_values_str_lines)-1: + table_add += "\n" + else: + if block_format: + table_add += '| ' + else: + table_add += '* ' + table_add += f"{k2}: " + k2_values_str + + table_add += "\n" + if current_line != '': + table_add += current_line + '\n' + else: + table_add += ' '*5 + f"- {values}\n" + + table += table_add + else: - table += f" - {value_str}\n" - return table \ No newline at end of file + lengths = [len(str(v)) for v in values] + if bullets and max(lengths) > 5: + table += ' '*5 + "-\n" + for value in sorted(values): + table += ' '*7 + f"- {value}\n" + else: + value_str = ', '.join(sorted(values)) + table += ' '*5 + f"- {value_str}\n" + + return table