Simplify Screener & improve its docs

- screener now just a function, not a class
- add 'FundQuery' query class
- add 'IS-IN' operator
- fix 'GTE' & 'LTE' operators
- more exchanges
Predefined tweaks:
- convert predefined query strings to use EquityQuery (better docs)
- send predefined queries to Yahoo's predefined endpoint
- expose PREDEFINED_SCREENER_QUERIES via __init__.py
- screen() argument defaults don't apply to predefineds
This commit is contained in:
ValueRaider
2025-01-05 17:18:54 +00:00
parent ffa697cea1
commit dee9a55e43
11 changed files with 672 additions and 569 deletions
+3 -1
View File
@@ -20,7 +20,8 @@ The following are the publicly available classes, and functions exposed by the `
- :attr:`Sector <yfinance.Sector>`: Domain class for accessing sector information.
- :attr:`Industry <yfinance.Industry>`: Domain class for accessing industry information.
- :attr:`download <yfinance.download>`: Function to download market data for multiple tickers.
- :attr:`EquityQuery <yfinance.EquityQuery>`: Class to build equity market query.
- :attr:`EquityOperation <yfinance.EquityOperation>`: Class to build equity market operation.
- :attr:`Query <yfinance.Query>`: Class to build query.
- :attr:`Screener <yfinance.Screener>`: Class to screen the market using defined query.
- :attr:`enable_debug_mode <yfinance.enable_debug_mode>`: Function to enable debug mode for logging.
- :attr:`set_tz_cache_location <yfinance.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
@@ -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 <yfinance.EquityQuery.valid_operand_fields>`
supported operand values for query
:attr:`EquityQuery.valid_eq_operand_map <yfinance.EquityQuery.valid_eq_operand_map>`
supported `EQ query operand parameters`
:attr:`Screener.predefined_bodies <yfinance.Screener.predefined_bodies>`
supported predefined screens
Enable Debug Mode
~~~~~~~~~~~~~~~~~
Enables logging of debug information for the `yfinance` package.
@@ -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 <yfinance.EquityQuery.valid_fields>`
supported operand values for query
:attr:`EquityQuery.valid_values <yfinance.EquityQuery.valid_values>`
supported `EQ query operand parameters`
:attr:`EquityQuery.valid_fields <yfinance.EquityQuery.valid_fields>`
supported operand values for query
:attr:`EquityQuery.valid_values <yfinance.EquityQuery.valid_values>`
supported `EQ query operand parameters`
+17 -132
View File
@@ -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):
mock_response = MagicMock()
mock_response.json.return_value = {'finance': {'result': [{}]}}
mock_post.return_value = mock_response
self.screener.set_default_body(self.query)
response = self.screener._fetch()
self.assertEqual(response, {'finance': {'result': [{}]}})
@patch('yfinance.screener.screener.YfData.post')
def test_fetch_and_parse(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': [{'key': 'value'}]}}
mock_post.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.query)
self.assertEqual(response, {'key': 'value'})
@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_get.return_value = mock_response
response = screen(self.predefined)
self.assertEqual(response, {'key': 'value'})
if __name__ == '__main__':
unittest.main()
+6 -4
View File
@@ -28,15 +28,17 @@ from .utils import enable_debug_mode
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']
+107 -31
View File
@@ -305,21 +305,92 @@ SECTOR_INDUSTY_MAPPING = {
'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 @@ EQUITY_SCREENER_EQ_MAP = {
"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 @@ EQUITY_SCREENER_FIELDS = {
"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"}
}
EQUITY_SCREENER_FIELDS = merge_two_level_dicts(EQUITY_SCREENER_FIELDS, COMMON_SCREENER_FIELDS)
+3 -3
View File
@@ -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']
__all__ = ['EquityQuery', 'FundQuery', 'screen', 'PREDEFINED_SCREENER_QUERIES']
+218
View File
@@ -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
+166 -211
View File
@@ -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
from ..utils import dynamic_docstring, generate_list_table_from_dict_universal
from typing import Union
import requests
_SCREENER_URL_ = f"{_BASE_URL_}/v1/finance/screener"
_PREDEFINED_URL_ = f"{_SCREENER_URL_}/predefined/saved"
class Screener:
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):
"""
The `Screener` class is used to execute the queries and return the filtered results.
Run a screen: predefined query, or custom query.
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.
: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}
"""
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 <yfinance.Screener.predefined_bodies>`
supported predefined screens
"""
self.proxy = proxy
self.session = session
# 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"
}
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()
if size is not None and size > 250:
raise ValueError("Yahoo limits query size to 250, reduce size.")
@property
def body(self) -> Dict:
return self._body
@property
def response(self) -> Dict:
"""
Fetch screen result
fields = dict(locals())
for k in ['query', 'session', 'proxy']:
if k in fields:
del fields[k]
Example:
params_dict = {"corsDomain": "finance.yahoo.com", "formatted": "false", "lang": "en-US", "region": "US"}
.. 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 <yfinance.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()
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]
-145
View File
@@ -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 <yfinance.EquityQuery.valid_operand_fields>`
supported operand values for query
:attr:`EquityQuery.valid_eq_operand_map <yfinance.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]
}
+125 -23
View File
@@ -943,10 +943,12 @@ def dynamic_docstring(placeholders: dict):
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
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