Skip to content

Commit

Permalink
Implement Screener
Browse files Browse the repository at this point in the history
- Add a new class Screener, Query, and EquityQuery
- Refactor YfData.get() to enable YfData.post() to all of get's implementations
- Add test for the Screener
- Add new set and map to const

Screener can be used to filter yahoo finance. This can be used to get the top gainers of the day, and more customized queries that a user may want to automate.
  • Loading branch information
ericpien committed Oct 1, 2024
1 parent 3535fb9 commit 048378e
Show file tree
Hide file tree
Showing 8 changed files with 682 additions and 4 deletions.
81 changes: 81 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,87 @@ software_ticker = software.ticker
software_ticker.history()
```

### Market Screener
The `Screener` module allows you to screen the market based on specified queries.

#### Query Construction
To create a query, you can use the `EquityQuery` class to construct your filters step by step. The queries are flexible, supporting various operators like `GT` (greater than), `LT` (less than), `BTWN` (between), `EQ` (equals), and logical operators `AND` and `OR` for combining multiple conditions.

A map of key/values that can go into `eq`'s operands is available via `valid_eq_map` property and the full set of items that can be accessed via `valid_fields` property.

```Python
import yfinance as yf

# Example query to filter stocks
gt = yf.EquityQuery('gt', ['eodprice', 3])
lt = yf.EquityQuery('lt', ['avgdailyvol3m', 99999999999])
btwn = yf.EquityQuery('btwn', ['intradaymarketcap', 0, 100000000])
eq = yf.EquityQuery('eq', ['sector', 'Technology'])

# Combine queries using AND/OR
qt = yf.EquityQuery('and', [gt, lt])
qf = yf.EquityQuery('or', [qt, btwn, eq])

# checking available fields
print(qf.valid_eq_map)
print(qf.valid_fields)
```

In the above example:

- `gt`: Filters for stocks where the end-of-day price is greater than 3.
- `lt`: Filters for stocks where the average daily volume over the last 3 months is less than a very large number.
- `btwn`: Filters for stocks where the intraday market cap is between 0 and 100 million.
- `eq`: Filters for stocks in the Technology sector.

#### Screener
The `Screener` class is used to execute the queries and return the filtered results. You can set a custom body for the screener or use predefined configurations.

The full list of predefined_bodies can be found via `predefined_bodies` property, or on https://finance.yahoo.com/screener/:

```Python
import yfinance as yf

# Create a screener instance
screener = yf.Screener()

# Set the default body using a custom query
screener.set_default_body(qf)

# Set predefined body
screener.set_predefined_body('day_gainers')

# Set the fully custom body
# The keys below are required fields for the request body
screener.set_body({
"offset": 0,
"size": 100,
"sortField": "ticker",
"sortType": "desc",
"quoteType": "equity",
"query": qf.to_dict(),
"userId": "",
"userIdType": "guid"
})

# Patch parts of the body
screener.patch_body({"offset": 100})

# view the current body of the screener
screener.body

# view the available predefined screens
screener.predefined_bodies

# Fetch and display the result json
result = screener.response
print(results)

# save the queried symbols
symbols = [quote['symbol'] for quote in result['quotes']]
```


### Logging

`yfinance` now uses the `logging` module to handle messages, default behaviour is only print errors. If debugging, use `yf.enable_debug_mode()` to switch logging to debug with custom formatting.
Expand Down
133 changes: 133 additions & 0 deletions tests/test_screener.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
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


class TestScreener(unittest.TestCase):

@classmethod
def setUpClass(self):
self.screener = Screener()
self.query = EquityQuery('gt',['eodprice',3])

def test_set_default_body(self):
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')

def test_set_predefined_body(self):
k = 'most_actives'
self.screener.set_predefined_body(k)
self.assertEqual(self.screener.body, PREDEFINED_SCREENER_BODY_MAP[k])

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"
}
self.screener.set_body(body)

self.assertEqual(self.screener.body, body)

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}
self.screener.patch_body(patch_values)

self.assertEqual(self.screener.body['size'], 50)
self.assertEqual(self.screener.body['query'], self.query.to_dict())

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)

@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):
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'})

if __name__ == '__main__':
unittest.main()
5 changes: 4 additions & 1 deletion yfinance/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,14 @@
from .cache import set_tz_cache_location
from .domain.sector import Sector
from .domain.industry import Industry
from .screener.screener import Screener
from .screener.screener_query import EquityQuery

__version__ = version.version
__author__ = "Ran Aroussi"

import warnings
warnings.filterwarnings('default', category=DeprecationWarning, module='^yfinance')

__all__ = ['download', 'Ticker', 'Tickers', 'enable_debug_mode', 'set_tz_cache_location', 'Sector', 'Industry']
__all__ = ['download', 'Ticker', 'Tickers', 'enable_debug_mode', 'set_tz_cache_location', 'Sector', 'Industry',
'EquityQuery','Screener']
Loading

0 comments on commit 048378e

Please sign in to comment.