"""Comparison Analysis Controller Module""" __docformat__ = "numpy" import argparse import logging import random from datetime import datetime, timedelta from typing import List import yfinance as yf from openbb_terminal.custom_prompt_toolkit import NestedCompleter from openbb_terminal import feature_flags as obbff from openbb_terminal.decorators import log_start_end from openbb_terminal.helper_funcs import ( EXPORT_BOTH_RAW_DATA_AND_FIGURES, EXPORT_ONLY_RAW_DATA_ALLOWED, check_non_negative, check_positive, valid_date, ) from openbb_terminal.menu import session from openbb_terminal.parent_classes import BaseController from openbb_terminal.rich_config import console, MenuText, get_ordered_list_sources from openbb_terminal.stocks.comparison_analysis import ( finbrain_view, finnhub_model, finviz_compare_model, finviz_compare_view, marketwatch_view, polygon_model, yahoo_finance_view, ) # pylint: disable=E1121,C0302,R0904 # TODO: HELP WANTED! This controller still has some view functionality that should be # refactored in order to implement an API wrapper. Use the discovery controller # as an example. logger = logging.getLogger(__name__) class ComparisonAnalysisController(BaseController): """Comparison Analysis Controller class""" CHOICES_COMMANDS = [ "ticker", "get", "set", "add", "rmv", "historical", "hcorr", "volume", "income", "balance", "cashflow", "sentiment", "scorr", "overview", "valuation", "financial", "ownership", "performance", "technical", "tsne", ] choices_ohlca = ["o", "h", "l", "c", "a"] CHOICES_MENUS: List = list() PATH = "/stocks/ca/" CHOICES_GENERATION = True def __init__(self, similar: List[str] = None, queue: List[str] = None): """Constructor""" super().__init__(queue) self.ticker = "" self.user = "" if similar: self.similar = similar if len(similar) == 1: self.ticker = self.similar[0].upper() else: self.similar = [] if session and obbff.USE_PROMPT_TOOLKIT: choices: dict = self.choices_default self.completer = NestedCompleter.from_nested_dict(choices) def call_exit(self, _) -> None: """Process exit terminal command from forecast menu.""" self.save_class() for _ in range(self.PATH.count("/") + 1): self.queue.insert(0, "quit") def print_help(self): """Print help""" mt = MenuText("stocks/ca/", 80) mt.add_cmd("ticker") mt.add_raw("\n") mt.add_param("_ticker", self.ticker) mt.add_raw("\n") mt.add_cmd("tsne", self.ticker) mt.add_cmd("get", self.ticker) mt.add_raw("\n") mt.add_cmd("set") mt.add_cmd("add") mt.add_cmd("rmv") mt.add_raw("\n") mt.add_param("_similar", ", ".join(self.similar)) mt.add_raw("\n") mt.add_cmd("historical", self.similar and len(self.similar) > 1) mt.add_cmd("hcorr", self.similar and len(self.similar) > 1) mt.add_cmd("volume", self.similar and len(self.similar) > 1) mt.add_cmd("income", self.similar and len(self.similar) > 1) mt.add_cmd("balance", self.similar and len(self.similar) > 1) mt.add_cmd("cashflow", self.similar and len(self.similar) > 1) mt.add_cmd("sentiment", self.similar and len(self.similar) > 1) mt.add_cmd("scorr", self.similar and len(self.similar) > 1) mt.add_cmd("overview", self.similar and len(self.similar) > 1) mt.add_cmd("valuation", self.similar and len(self.similar) > 1) mt.add_cmd("financial", self.similar and len(self.similar) > 1) mt.add_cmd("ownership", self.similar and len(self.similar) > 1) mt.add_cmd("performance", self.similar and len(self.similar) > 1) mt.add_cmd("technical", self.similar and len(self.similar) > 1) console.print(text=mt.menu_text, menu="Stocks - Comparison Analysis") def custom_reset(self): """Class specific component of reset command""" if self.similar: return ["stocks", "ca", f"set {','.join(self.similar)}"] return [] # TODO: Figure out if this function is actually needed here @log_start_end(log=logger) def call_ticker(self, other_args: List[str]): """Process ticker command""" parser = argparse.ArgumentParser( add_help=False, formatter_class=argparse.ArgumentDefaultsHelpFormatter, prog="ticker", description="""Set ticker to extract similar from""", ) parser.add_argument( "-t", "--ticker", dest="ticker", type=str, required="-h" not in other_args, help="Ticker get similar tickers from", ) if other_args and "-" not in other_args[0][0]: other_args.insert(0, "-t") ns_parser = self.parse_known_args_and_warn(parser, other_args) if ns_parser: if "," in ns_parser.ticker: console.print("Only one ticker must be selected!") else: stock_data = yf.download( ns_parser.ticker, progress=False, ) if stock_data.empty: console.print( f"The ticker '{ns_parser.ticker}' provided does not exist!" ) else: self.ticker = ns_parser.ticker.upper() @log_start_end(log=logger) def call_tsne(self, other_args: List[str]): """Process tsne command""" parser = argparse.ArgumentParser( add_help=False, formatter_class=argparse.ArgumentDefaultsHelpFormatter, prog="tsne", description="""Get similar companies to compare with using sklearn TSNE.""", ) parser.add_argument( "-r", "--learnrate", default=200, dest="lr", type=check_non_negative, help="TSNE Learning rate. Typical values are between 50 and 200", ) parser.add_argument( "-l", "--limit", default=10, dest="limit", type=check_positive, help="Limit of stocks to retrieve. The subsample will occur randomly.", ) parser.add_argument( "-p", "--no_plot", action="store_true", default=False, dest="no_plot" ) if other_args and "-" not in other_args[0][0]: other_args.insert(0, "-l") ns_parser = self.parse_known_args_and_warn(parser, other_args) if ns_parser: if self.ticker: self.similar = yahoo_finance_view.display_sp500_comps_tsne( self.ticker, lr=ns_parser.lr, no_plot=ns_parser.no_plot, limit=ns_parser.limit, ) self.similar = [self.ticker] + self.similar console.print( f"[ML] Similar Companies: {', '.join(self.similar)}", "\n" ) else: console.print( "You need to 'set' a ticker to get similar companies from first! This is " "for example done by running 'ticker gme'" ) @log_start_end(log=logger) def call_get(self, other_args: List[str]): """Process get command""" parser = argparse.ArgumentParser( add_help=False, formatter_class=argparse.ArgumentDefaultsHelpFormatter, prog="get", description="""Get similar companies from selected data source (default: Finviz) to compare with.""", ) parser.add_argument( "-u", "--us_only", action="store_true", default=False, dest="us_only", help="Show only stocks from the US stock exchanges. Works only with Polygon", ) parser.add_argument( "-n", "--nocountry", action="store_true", default=False, dest="b_no_country", help="Similar stocks from finviz using only Industry and Sector.", ) parser.add_argument( "-l", "--limit", default=10, dest="limit", type=check_positive, help="Limit of stocks to retrieve.", ) if other_args and "-" not in other_args[0][0]: other_args.insert(0, "-l") ns_parser = self.parse_known_args_and_warn(parser, other_args) if ns_parser: if self.ticker: if ns_parser.source == "Finviz": if ns_parser.b_no_country: compare_list = ["Sector", "Industry"] else: compare_list = ["Sector", "Industry", "Country"] self.similar = finviz_compare_model.get_similar_companies( self.ticker, compare_list ) if self.similar is None: return self.user = "Finviz" if self.ticker.upper() in self.similar: self.similar.remove(self.ticker.upper()) if len(self.similar) > ns_parser.limit: random.shuffle(self.similar) self.similar = sorted(self.similar[: ns_parser.limit]) console.print( f"The limit of stocks to compare are {ns_parser.limit}. The subsample will occur randomly.\n", ) if self.similar: self.similar = [self.ticker] + self.similar console.print( f"[{self.user}] Similar Companies: {', '.join(self.similar)}", "\n", ) elif ns_parser.source == "Polygon": self.similar = polygon_model.get_similar_companies( self.ticker, ns_parser.us_only ) if self.similar is None: return self.user = "Polygon" if self.ticker.upper() in self.similar: self.similar.remove(self.ticker.upper()) if len(self.similar) > ns_parser.limit: random.shuffle(self.similar) self.similar = sorted(self.similar[: ns_parser.limit]) console.print( f"The limit of stocks to compare are {ns_parser.limit}. The subsample will occur randomly.\n", ) if self.similar: self.similar = [self.ticker] + self.similar console.print( f"[{self.user}] Similar Companies: {', '.join(self.similar)}", "\n", ) elif ns_parser.source == "Finnhub": self.similar = finnhub_model.get_similar_companies(self.ticker) self.user = "Finnhub" if self.ticker.upper() in self.similar: self.similar.remove(self.ticker.upper()) if len(self.similar) > ns_parser.limit: random.shuffle(self.similar) self.similar = sorted(self.similar[: ns_parser.limit]) console.print( f"The limit of stocks to compare are {ns_parser.limit}. The subsample will occur randomly.\n", ) if self.similar: self.similar = [self.ticker] + self.similar console.print( f"[{self.user}] Similar Companies: {', '.join(self.similar)}", "\n", ) else: console.print( f"Use a valid data source: {', '.join(get_ordered_list_sources(f'{self.PATH}get'))}" ) else: console.print( "You need to 'set' a ticker to get similar companies from first!" ) @log_start_end(log=logger) def call_add(self, other_args: List[str]): """Process add command""" parser = argparse.ArgumentParser( add_help=False, formatter_class=argparse.ArgumentDefaultsHelpFormatter, prog="add", description="""Add similar tickers to compare with.""", ) parser.add_argument( "-s", "--similar", dest="l_similar", type=lambda s: [str(item).upper() for item in s.split(",")], default=[], help="Tickers to add to similar list", ) if other_args and "-" not in other_args[0][0]: other_args.insert(0, "-s") ns_parser = self.parse_known_args_and_warn(parser, other_args) if ns_parser: if self.similar: self.similar = list(set(self.similar + ns_parser.l_similar)) else: self.similar = ns_parser.l_similar self.user = "Custom" console.print( f"[{self.user}] Similar Companies: {', '.join(self.similar)}", "\n" ) @log_start_end(log=logger) def call_rmv(self, other_args: List[str]): """Process rmv command""" parser = argparse.ArgumentParser( add_help=False, formatter_class=argparse.ArgumentDefaultsHelpFormatter, prog="rmv", description="""Remove similar tickers to compare with.""", ) parser.add_argument( "-s", "--similar", dest="l_similar", type=lambda s: [str(item).upper() for item in s.split(",")], default=[], help="Tickers to remove from similar list", ) if other_args and "-" not in other_args[0][0]: other_args.insert(0, "-s") ns_parser = self.parse_known_args_and_warn(parser, other_args) if ns_parser: if ns_parser.l_similar: for symbol in ns_parser.l_similar: if symbol in self.similar: self.similar.remove(symbol) else: console.print( f"Ticker {symbol} does not exist in similar list to be removed" ) console.print( f"[{self.user}] Similar Companies: {', '.join(self.similar)}" ) else: self.similar = [] console.print() self.user = "Custom" @log_start_end(log=logger) def call_set(self, other_args: List[str]): """Process set command""" parser = argparse.ArgumentParser( add_help=False, formatter_class=argparse.ArgumentDefaultsHelpFormatter, prog="set", description="""Select similar companies to compare with.""", ) parser.add_argument( "-s", "--similar", dest="l_similar", type=lambda s: [str(item).upper() for item in s.split(",")], default=[], help="similar companies to compare with.", ) if other_args and "-" not in other_args[0][0]: other_args.insert(0, "-s") ns_parser = self.parse_known_args_and_warn(parser, other_args) if ns_parser: self.similar = list(set(ns_parser.l_similar)) self.user = "Custom" console.print( f"[{self.user}] Similar Companies: {', '.join(self.similar)}", "\n" ) @log_start_end(log=logger) def call_historical(self, other_args: List[str]): """Process historical command""" parser = argparse.ArgumentParser( add_help=False, formatter_class=argparse.ArgumentDefaultsHelpFormatter, prog="historical", description="""Historical price comparison between similar companies.""", ) parser.add_argument( "-t", "--type", action="store", dest="type_candle", type=str, choices=self.choices_ohlca, default="a", # in case it's adjusted close help="Candle data to use: o-open, h-high, l-low, c-close, a-adjusted close.", ) parser.add_argument( "-n", "--normalize", action="store_true", dest="normalize", default=False, help="Flag to normalize all prices on same 0-1 scale", ) parser.add_argument( "-s", "--start", type=valid_date, default=(datetime.now() - timedelta(days=366)).strftime("%Y-%m-%d"), dest="start", help="The starting date (format YYYY-MM-DD) of the stock", ) if other_args and "-" not in other_args[0][0]: other_args.insert(0, "-t") ns_parser = self.parse_known_args_and_warn( parser, other_args, EXPORT_BOTH_RAW_DATA_AND_FIGURES ) if ns_parser: if self.similar and len(self.similar) > 1: yahoo_finance_view.display_historical( similar=self.similar, start_date=ns_parser.start.strftime("%Y-%m-%d"), candle_type=ns_parser.type_candle, normalize=ns_parser.normalize, export=ns_parser.export, ) else: console.print( "Please make sure there are more than 1 similar tickers selected. \n" ) @log_start_end(log=logger) def call_hcorr(self, other_args: List[str]): """Process historical correlation command""" parser = argparse.ArgumentParser( add_help=False, formatter_class=argparse.ArgumentDefaultsHelpFormatter, prog="hcorr", description=""" Correlation heatmap based on historical price comparison between similar companies. """, ) parser.add_argument( "-t", "--type", action="store", dest="type_candle", type=str, choices=self.choices_ohlca, default="a", # in case it's adjusted close help="Candle data to use: o-open, h-high, l-low, c-close, a-adjusted close, r-returns.", ) parser.add_argument( "-s", "--start", type=valid_date, default=(datetime.now() - timedelta(days=366)).strftime("%Y-%m-%d"), dest="start", help="The starting date (format YYYY-MM-DD) of the stock", ) parser.add_argument( "--display-full-matrix", action="store_true", help="Display all matrix values, rather than masking off half.", ) if other_args and "-" not in other_args[0][0]: other_args.insert(0, "-t") ns_parser = self.parse_known_args_and_warn( parser, other_args, EXPORT_BOTH_RAW_DATA_AND_FIGURES, raw=True ) if ns_parser: if self.similar and len(self.similar) > 1: yahoo_finance_view.display_correlation( similar=self.similar, start_date=ns_parser.start.strftime("%Y-%m-%d"), candle_type=ns_parser.type_candle, export=ns_parser.export, display_full_matrix=ns_parser.display_full_matrix, raw=ns_parser.raw, ) else: console.print("Please make sure there are similar tickers selected. \n") @log_start_end(log=logger) def call_income(self, other_args: List[str]): """Process income command""" parser = argparse.ArgumentParser( add_help=False, formatter_class=argparse.ArgumentDefaultsHelpFormatter, prog="income", description=""" Prints either yearly or quarterly income statement the company, and compares it against similar companies. """, ) parser.add_argument( "-q", "--quarter", action="store_true", default=False, dest="b_quarter", help="Quarter financial data flag.", ) parser.add_argument( "-t", "--timeframe", dest="s_timeframe", type=str, default=None, help="Specify yearly/quarterly timeframe. Default is last.", ) if other_args and "-" not in other_args[0][0]: other_args.insert(0, "-t") ns_parser = self.parse_known_args_and_warn( parser, other_args, EXPORT_ONLY_RAW_DATA_ALLOWED ) if ns_parser: marketwatch_view.display_income_comparison( symbols=self.similar, timeframe=ns_parser.s_timeframe, quarter=ns_parser.b_quarter, export=ns_parser.export, ) @log_start_end(log=logger) def call_volume(self, other_args: List[str]): """Process volume command""" parser = argparse.ArgumentParser( add_help=False, formatter_class=argparse.ArgumentDefaultsHelpFormatter, prog="volume", description="""Historical volume comparison between similar companies. """, ) parser.add_argument( "-s", "--start", type=valid_date, default=(datetime.now() - timedelta(days=366)).strftime("%Y-%m-%d"), dest="start", help="The starting date (format YYYY-MM-DD) of the stock", ) if other_args and "-" not in other_args[0][0]: other_args.insert(0, "-s") ns_parser = self.parse_known_args_and_warn( parser, other_args, EXPORT_BOTH_RAW_DATA_AND_FIGURES ) if ns_parser: if self.similar and len(self.similar) > 1: yahoo_finance_view.display_volume( similar=self.similar, start_date=ns_parser.start.strftime("%Y-%m-%d"), export=ns_parser.export, ) else: console.print("Please make sure there are similar tickers selected. \n") @log_start_end(log=logger) def call_balance(self, other_args: List[str]): """Process balance command""" parser = argparse.ArgumentParser( add_help=False, formatter_class=argparse.ArgumentDefaultsHelpFormatter, prog="balance", description=""" Prints either yearly or quarterly balance statement the company, and compares it against similar companies. """, ) parser.add_argument( "-q", "--quarter", action="store_true", default=False, dest="b_quarter", help="Quarter financial data flag.", ) parser.add_argument( "-t", "--timeframe", dest="s_timeframe", type=str, default=None, help="Specify yearly/quarterly timeframe. Default is last.", ) ns_parser = self.parse_known_args_and_warn( parser, other_args, EXPORT_ONLY_RAW_DATA_ALLOWED ) if ns_parser: marketwatch_view.display_balance_comparison( symbols=self.similar, timeframe=ns_parser.s_timeframe, quarter=ns_parser.b_quarter, export=ns_parser.export, ) @log_start_end(log=logger) def call_cashflow(self, other_args: List[str]): """Process cashflow command""" parser = argparse.ArgumentParser( add_help=False, formatter_class=argparse.ArgumentDefaultsHelpFormatter, prog="cashflow", description=""" Prints either yearly or quarterly cashflow statement the company, and compares it against similar companies. """, ) parser.add_argument( "-q", "--quarter", action="store_true", default=False, dest="b_quarter", help="Quarter financial data flag.", ) parser.add_argument( "-t", "--timeframe", dest="s_timeframe", type=str, default=None, help="Specify year/quarter of the cashflow statement to be retrieved. The format for year is YYYY and for " "quarter is DD-MMM-YYY (for example, 30-Sep-2021). Default is last year/quarter.", ) ns_parser = self.parse_known_args_and_warn( parser, other_args, EXPORT_ONLY_RAW_DATA_ALLOWED ) if ns_parser: marketwatch_view.display_cashflow_comparison( symbols=self.similar, timeframe=ns_parser.s_timeframe, quarter=ns_parser.b_quarter, export=ns_parser.export, ) @log_start_end(log=logger) def call_sentiment(self, other_args: List[str]): """Process sentiment command""" parser = argparse.ArgumentParser( add_help=False, formatter_class=argparse.ArgumentDefaultsHelpFormatter, prog="sentiment_compare", description=""" FinBrain's sentiment comparison across similar tickers. """, ) parser.add_argument( "-r", "--raw", action="store_true", default=False, help="Display raw sentiment data", dest="raw", ) ns_parser = self.parse_known_args_and_warn( parser, other_args, EXPORT_ONLY_RAW_DATA_ALLOWED ) if ns_parser: if self.similar and len(self.similar) > 1: finbrain_view.display_sentiment_compare( similar=self.similar, raw=ns_parser.raw, export=ns_parser.export, ) else: console.print( "Please make sure there are more than 1 similar tickers selected. \n" ) @log_start_end(log=logger) def call_scorr(self, other_args: List[str]): """Process sentiment correlation command""" parser = argparse.ArgumentParser( add_help=False, formatter_class=argparse.ArgumentDefaultsHelpFormatter, prog="sentiment_compare", description=""" FinBrain's sentiment correlation across similar tickers. """, ) parser.add_argument( "-r", "--raw", action="store_true", default=False, help="Display raw sentiment data", dest="raw", ) ns_parser = self.parse_known_args_and_warn( parser, other_args, EXPORT_ONLY_RAW_DATA_ALLOWED ) if ns_parser: if self.similar and len(self.similar) > 1: finbrain_view.display_sentiment_correlation( similar=self.similar, raw=ns_parser.raw, export=ns_parser.export, ) else: console.print("Please make sure there are similar tickers selected. \n") @log_start_end(log=logger) def call_overview(self, other_args: List[str]): """Process overview command""" parser = argparse.ArgumentParser( add_help=False, formatter_class=argparse.ArgumentDefaultsHelpFormatter, prog="overview", description=""" Prints screener data of similar companies. [Source: Finviz] """, ) ns_parser = self.parse_known_args_and_warn( parser, other_args, EXPORT_ONLY_RAW_DATA_ALLOWED ) if ns_parser: if self.similar and len(self.similar) > 1: finviz_compare_view.screener( similar=self.similar, data_type="overview", export=ns_parser.export, ) else: console.print( "Please make sure there are more than 1 similar tickers selected. \n" ) @log_start_end(log=logger) def call_valuation(self, other_args: List[str]): """Process valuation command""" parser = argparse.ArgumentParser( add_help=False, formatter_class=argparse.ArgumentDefaultsHelpFormatter, prog="valuation", description=""" Prints screener data of similar companies. [Source: Finviz] """, ) ns_parser = self.parse_known_args_and_warn( parser, other_args, EXPORT_ONLY_RAW_DATA_ALLOWED ) if ns_parser: if self.similar and len(self.similar) > 1: finviz_compare_view.screener( similar=self.similar, data_type="valuation", export=ns_parser.export, ) else: console.print( "Please make sure there are more than 1 similar tickers selected. \n" ) @log_start_end(log=logger) def call_financial(self, other_args: List[str]): """Process financial command""" parser = argparse.ArgumentParser( add_help=False, formatter_class=argparse.ArgumentDefaultsHelpFormatter, prog="financial", description=""" Prints screener data of similar companies. [Source: Finviz] """, ) ns_parser = self.parse_known_args_and_warn( parser, other_args, EXPORT_ONLY_RAW_DATA_ALLOWED ) if ns_parser: if self.similar and len(self.similar) > 1: finviz_compare_view.screener( similar=self.similar, data_type="financial", export=ns_parser.export, ) else: console.print( "Please make sure there are more than 1 similar tickers selected. \n" ) @log_start_end(log=logger) def call_ownership(self, other_args: List[str]): """Process ownership command""" parser = argparse.ArgumentParser( add_help=False, formatter_class=argparse.ArgumentDefaultsHelpFormatter, prog="ownership", description=""" Prints screener data of similar companies. [Source: Finviz] """, ) ns_parser = self.parse_known_args_and_warn( parser, other_args, EXPORT_ONLY_RAW_DATA_ALLOWED ) if ns_parser: if self.similar and len(self.similar) > 1: finviz_compare_view.screener( similar=self.similar, data_type="ownership", export=ns_parser.export, ) else: console.print( "Please make sure there are more than 1 similar tickers selected. \n" ) @log_start_end(log=logger) def call_performance(self, other_args: List[str]): """Process performance command""" parser = argparse.ArgumentParser( add_help=False, formatter_class=argparse.ArgumentDefaultsHelpFormatter, prog="performance", description=""" Prints screener data of similar companies. [Source: Finviz] """, ) ns_parser = self.parse_known_args_and_warn( parser, other_args, EXPORT_ONLY_RAW_DATA_ALLOWED ) if ns_parser: if self.similar and len(self.similar) > 1: finviz_compare_view.screener( similar=self.similar, data_type="performance", export=ns_parser.export, ) else: console.print( "Please make sure there are more than 1 similar tickers selected. \n" ) @log_start_end(log=logger) def call_technical(self, other_args: List[str]): """Process technical command""" parser = argparse.ArgumentParser( add_help=False, formatter_class=argparse.ArgumentDefaultsHelpFormatter, prog="technical", description=""" Prints screener data of similar companies. [Source: Finviz] """, ) ns_parser = self.parse_known_args_and_warn( parser, other_args, EXPORT_ONLY_RAW_DATA_ALLOWED ) if ns_parser: if self.similar and len(self.similar) > 1: finviz_compare_view.screener( similar=self.similar, data_type="technical", export=ns_parser.export, ) else: console.print( "Please make sure there are more than 1 similar tickers selected. \n" )