From 9335170ea66bab97faf9f2502d1a82e6a0961ef7 Mon Sep 17 00:00:00 2001 From: Patryk Kocielnik Date: Thu, 2 Jan 2025 21:10:20 +0100 Subject: [PATCH 1/3] Fix MOSP: Use Zacks for growth estimates (closes #91). --- isthisstockgood/Active/Zacks.py | 32 +++++++++++++++++++++++++ isthisstockgood/DataFetcher.py | 42 ++++++++++++++++++++++----------- tests/test_DataSources.py | 15 +++++++++--- tests/test_api.py | 5 +++- 4 files changed, 76 insertions(+), 18 deletions(-) create mode 100644 isthisstockgood/Active/Zacks.py diff --git a/isthisstockgood/Active/Zacks.py b/isthisstockgood/Active/Zacks.py new file mode 100644 index 0000000..826aa92 --- /dev/null +++ b/isthisstockgood/Active/Zacks.py @@ -0,0 +1,32 @@ +import re + +class Zacks: + def __init__(self, ticker_symbol): + base_url = "https://www.zacks.com/stock/quote" + + self.url = f"{base_url}/{ticker_symbol}/detailed-earning-estimates" + self.ticker_symbol = ticker_symbol + self.five_year_growth_rate = None + self.maintenance_capital_expenditures = None + + def parse(self, response, **kwargs): + if response.status_code != 200: + return + + if not response.text: + return + + try: + self.five_year_growth_rate = self.get_growth_rate(response.text) + except: + self.five_year_growth_rate = None + + def get_growth_rate(self, text): + lines = text.split("\n") + + for i, line in enumerate(lines): + if "Next 5 Years" in line: + result = lines[i+1] + + estimate = re.sub(r"[^\d\.]", "", result) + return float(estimate) diff --git a/isthisstockgood/DataFetcher.py b/isthisstockgood/DataFetcher.py index e7caa7f..a75a6d4 100644 --- a/isthisstockgood/DataFetcher.py +++ b/isthisstockgood/DataFetcher.py @@ -4,6 +4,7 @@ from requests_futures.sessions import FuturesSession from isthisstockgood.Active.MSNMoney import MSNMoney from isthisstockgood.Active.YahooFinance import YahooFinanceAnalysis +from isthisstockgood.Active.Zacks import Zacks from threading import Lock logger = logging.getLogger("IsThisStockGood") @@ -42,7 +43,7 @@ def fetchDataForTickerSymbol(ticker): # Make all network request asynchronously to build their portion of # the json results. data_fetcher.fetch_msn_money_data() - data_fetcher.fetch_yahoo_finance_analysis() + data_fetcher.fetch_growth_rate() # Wait for each RPC result before proceeding. @@ -50,9 +51,9 @@ def fetchDataForTickerSymbol(ticker): rpc.result() msn_money = data_fetcher.msn_money - yahoo_finance_analysis = data_fetcher.yahoo_finance_analysis + future_growth_rate = data_fetcher.future_growth_rate # NOTE: Some stocks won't have analyst growth rates, such as newly listed stocks or some foreign stocks. - five_year_growth_rate = yahoo_finance_analysis.five_year_growth_rate if yahoo_finance_analysis else 0 + five_year_growth_rate = future_growth_rate.five_year_growth_rate if future_growth_rate else 0 # TODO: Use TTM EPS instead of most recent year margin_of_safety_price, sticker_price = \ _calculateMarginOfSafetyPrice(msn_money.equity_growth_rates[-1], msn_money.pe_low, msn_money.pe_high, msn_money.eps[-1], five_year_growth_rate) @@ -125,7 +126,7 @@ def __init__(self,): self.rpcs = [] self.ticker_symbol = '' self.msn_money = None - self.yahoo_finance_analysis = None + self.future_growth_rate = None self.yahoo_finance_chart = None self.error = False @@ -200,25 +201,38 @@ def parse_msn_money_annual_statement_data(self, response, *args, **kwargs): result = response.text self.msn_money.parse_annual_report_data(result) - def fetch_yahoo_finance_analysis(self): - self.yahoo_finance_analysis = YahooFinanceAnalysis(self.ticker_symbol) + def fetch_growth_rate_estimate(self): + self.future_growth_rate = YahooFinanceAnalysis(self.ticker_symbol) session = self._create_session() - rpc = session.get(self.yahoo_finance_analysis.url, allow_redirects=True, hooks={ - 'response': self.parse_yahoo_finance_analysis, + rpc = session.get(self.future_growth_rate.url, allow_redirects=True, hooks={ + 'response': self.parse_growth_rate_estimate, }) self.rpcs.append(rpc) + def fetch_growth_rate(self): + session = self._create_session() + self.future_growth_rate = Zacks(self.ticker_symbol) + + rpc = session.get( + self.future_growth_rate.url, + allow_redirects=True, + hooks={ + 'response': self.future_growth_rate.parse, + } + ) + self.rpcs.append(rpc) + # Called asynchronously upon completion of the URL fetch from - # `fetch_yahoo_finance_analysis`. - def parse_yahoo_finance_analysis(self, response, *args, **kwargs): + # `fetch_growth_rate_estimate`. + def parse_growth_rate_estimate(self, response, *args, **kwargs): if response.status_code != 200: return - if not self.yahoo_finance_analysis: + if not self.future_growth_rate: return result = response.text - success = self.yahoo_finance_analysis.parse_analyst_five_year_growth_rate(result) + success = self.future_growth_rate.parse_analyst_five_year_growth_rate(result) if not success: - self.yahoo_finance_analysis = None + self.future_growth_rate = None def fetch_yahoo_finance_chart(self): self.yahoo_finance_chart = YahooFinanceChart(self.ticker_symbol) @@ -229,7 +243,7 @@ def fetch_yahoo_finance_chart(self): self.rpcs.append(rpc) # Called asynchronously upon completion of the URL fetch from - # `fetch_yahoo_finance_analysis`. + # `fetch_growth_rate_estimate`. def parse_yahoo_finance_chart(self, response, *args, **kwargs): if response.status_code != 200: return diff --git a/tests/test_DataSources.py b/tests/test_DataSources.py index 1677d0a..cfc0f56 100644 --- a/tests/test_DataSources.py +++ b/tests/test_DataSources.py @@ -32,6 +32,15 @@ def test_msn_money(): assert data.last_year_net_income > 0.0 assert data.total_debt >= 0.0 +def test_future_growth_rate(): + test_ticker = 'MSFT' + test_name = 'Microsoft Corp' + + data = get_growth_rate(test_ticker) + + assert data.ticker_symbol == test_ticker + assert float(data.five_year_growth_rate) > 0.0 + def get_msn_money_data(ticker): data_fetcher = DataFetcher() data_fetcher.ticker_symbol = ticker @@ -46,16 +55,16 @@ def get_msn_money_data(ticker): return CompanyInfo(**vars(data_fetcher.msn_money)) -def get_yahoo_data(ticker): +def get_growth_rate(ticker): data_fetcher = DataFetcher() data_fetcher.ticker_symbol = ticker # Make all network request asynchronously to build their portion of # the json results. - data_fetcher.fetch_yahoo_finance_analysis() + data_fetcher.fetch_growth_rate() # Wait for each RPC result before proceeding. for rpc in data_fetcher.rpcs: rpc.result() - return data_fetcher.yahoo_finance_analysis + return data_fetcher.future_growth_rate diff --git a/tests/test_api.py b/tests/test_api.py index c5c6c14..ec31c12 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -22,7 +22,10 @@ def test_get_data(): res = test_client.get('/api/ticker/nvda') assert res.status_code == 200 - assert res.json['debt_payoff_time'] >= 0 + data = res.json + assert data['debt_payoff_time'] >= 0 + assert data['sticker_price'] > 0.0 + assert data['payback_time'] > 1 def test_get_ten_cap_price(): app = create_app(fetchDataForTickerSymbol) From 73e83043b2b53d72810c1b7bd36e56bcab1e61e4 Mon Sep 17 00:00:00 2001 From: Patryk Kocielnik Date: Thu, 23 Jan 2025 20:59:30 +0100 Subject: [PATCH 2/3] Restore Yahoo Finance analysis. In case it returns in the future (CR). --- isthisstockgood/DataFetcher.py | 48 +++++++++++++++++++++------------- tests/test_DataSources.py | 4 +-- 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/isthisstockgood/DataFetcher.py b/isthisstockgood/DataFetcher.py index a75a6d4..02ffc94 100644 --- a/isthisstockgood/DataFetcher.py +++ b/isthisstockgood/DataFetcher.py @@ -43,7 +43,8 @@ def fetchDataForTickerSymbol(ticker): # Make all network request asynchronously to build their portion of # the json results. data_fetcher.fetch_msn_money_data() - data_fetcher.fetch_growth_rate() + data_fetcher.fetch_yahoo_finance_analysis() + data_fetcher.fetch_zacks_analysis() # Wait for each RPC result before proceeding. @@ -51,9 +52,9 @@ def fetchDataForTickerSymbol(ticker): rpc.result() msn_money = data_fetcher.msn_money - future_growth_rate = data_fetcher.future_growth_rate + yahoo_finance_analysis = data_fetcher.yahoo_finance_analysis # NOTE: Some stocks won't have analyst growth rates, such as newly listed stocks or some foreign stocks. - five_year_growth_rate = future_growth_rate.five_year_growth_rate if future_growth_rate else 0 + five_year_growth_rate = yahoo_finance_analysis.five_year_growth_rate if yahoo_finance_analysis else 0 # TODO: Use TTM EPS instead of most recent year margin_of_safety_price, sticker_price = \ _calculateMarginOfSafetyPrice(msn_money.equity_growth_rates[-1], msn_money.pe_low, msn_money.pe_high, msn_money.eps[-1], five_year_growth_rate) @@ -126,7 +127,8 @@ def __init__(self,): self.rpcs = [] self.ticker_symbol = '' self.msn_money = None - self.future_growth_rate = None + self.yahoo_finance_analysis = None + self.zacks_analysis = None self.yahoo_finance_chart = None self.error = False @@ -201,38 +203,48 @@ def parse_msn_money_annual_statement_data(self, response, *args, **kwargs): result = response.text self.msn_money.parse_annual_report_data(result) - def fetch_growth_rate_estimate(self): - self.future_growth_rate = YahooFinanceAnalysis(self.ticker_symbol) + def fetch_yahoo_finance_analysis(self): + self.yahoo_finance_analysis = YahooFinanceAnalysis(self.ticker_symbol) session = self._create_session() - rpc = session.get(self.future_growth_rate.url, allow_redirects=True, hooks={ - 'response': self.parse_growth_rate_estimate, + rpc = session.get(self.yahoo_finance_analysis.url, allow_redirects=True, hooks={ + 'response': self.parse_yahoo_finance_analysis, }) self.rpcs.append(rpc) - def fetch_growth_rate(self): + # Called asynchronously upon completion of the URL fetch from + # `fetch_yahoo_finance_analysis`. + def parse_yahoo_finance_analysis(self, response, *args, **kwargs): + if response.status_code != 200: + return + if not self.yahoo_finance_analysis: + return + result = response.text + success = self.yahoo_finance_analysis.parse_analyst_five_year_growth_rate(result) + if not success: + self.yahoo_finance_analysis = None + + def fetch_zacks_analysis(self): session = self._create_session() - self.future_growth_rate = Zacks(self.ticker_symbol) + self.zacks_analysis = Zacks(self.ticker_symbol) rpc = session.get( - self.future_growth_rate.url, + self.zacks_analysis.url, allow_redirects=True, hooks={ - 'response': self.future_growth_rate.parse, + 'response': self.zacks_analysis.parse, } ) self.rpcs.append(rpc) - # Called asynchronously upon completion of the URL fetch from - # `fetch_growth_rate_estimate`. def parse_growth_rate_estimate(self, response, *args, **kwargs): if response.status_code != 200: return - if not self.future_growth_rate: + if not self.zacks_analysis: return result = response.text - success = self.future_growth_rate.parse_analyst_five_year_growth_rate(result) + success = self.zacks_analysis.parse_analyst_five_year_growth_rate(result) if not success: - self.future_growth_rate = None + self.zacks_analysis = None def fetch_yahoo_finance_chart(self): self.yahoo_finance_chart = YahooFinanceChart(self.ticker_symbol) @@ -243,7 +255,7 @@ def fetch_yahoo_finance_chart(self): self.rpcs.append(rpc) # Called asynchronously upon completion of the URL fetch from - # `fetch_growth_rate_estimate`. + # `fetch_yahoo_finance_analysis`. def parse_yahoo_finance_chart(self, response, *args, **kwargs): if response.status_code != 200: return diff --git a/tests/test_DataSources.py b/tests/test_DataSources.py index cfc0f56..78f0e13 100644 --- a/tests/test_DataSources.py +++ b/tests/test_DataSources.py @@ -61,10 +61,10 @@ def get_growth_rate(ticker): # Make all network request asynchronously to build their portion of # the json results. - data_fetcher.fetch_growth_rate() + data_fetcher.fetch_zacks_analysis() # Wait for each RPC result before proceeding. for rpc in data_fetcher.rpcs: rpc.result() - return data_fetcher.future_growth_rate + return data_fetcher.zacks_analysis From d5e311d076d9f70835bbfa4cd586343e9d95067b Mon Sep 17 00:00:00 2001 From: Patryk Kocielnik Date: Thu, 23 Jan 2025 21:05:48 +0100 Subject: [PATCH 3/3] Add Zacks analysis as backup for Yahoo Finance analysis. On no data from Yahoo Finance, use the estimate from Zacks. --- isthisstockgood/DataFetcher.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/isthisstockgood/DataFetcher.py b/isthisstockgood/DataFetcher.py index 02ffc94..3e74db0 100644 --- a/isthisstockgood/DataFetcher.py +++ b/isthisstockgood/DataFetcher.py @@ -53,8 +53,12 @@ def fetchDataForTickerSymbol(ticker): msn_money = data_fetcher.msn_money yahoo_finance_analysis = data_fetcher.yahoo_finance_analysis + zacks_analysis = data_fetcher.zacks_analysis # NOTE: Some stocks won't have analyst growth rates, such as newly listed stocks or some foreign stocks. - five_year_growth_rate = yahoo_finance_analysis.five_year_growth_rate if yahoo_finance_analysis else 0 + five_year_growth_rate = \ + yahoo_finance_analysis.five_year_growth_rate if yahoo_finance_analysis \ + else zacks_analysis.five_year_growth_rate if zacks_analysis \ + else 0 # TODO: Use TTM EPS instead of most recent year margin_of_safety_price, sticker_price = \ _calculateMarginOfSafetyPrice(msn_money.equity_growth_rates[-1], msn_money.pe_low, msn_money.pe_high, msn_money.eps[-1], five_year_growth_rate)