"""Quantitative Analysis Controller Module""" __docformat__ = "numpy" import argparse import difflib from typing import List, Union from datetime import datetime, timedelta import numpy as np import pandas as pd from prompt_toolkit.completion import NestedCompleter from gamestonk_terminal.common.quantitative_analysis import ( qa_view, rolling_view, ) from gamestonk_terminal import feature_flags as gtff from gamestonk_terminal.stocks import stocks_helper from gamestonk_terminal.helper_funcs import ( EXPORT_ONLY_RAW_DATA_ALLOWED, get_flair, check_positive, check_proportion_range, parse_known_args_and_warn, try_except, system_clear, valid_date, ) from gamestonk_terminal.menu import session from gamestonk_terminal.stocks.quantitative_analysis.factors_view import capm_view class QaController: """Quantitative Analysis Controller class""" # Command choices CHOICES = [ "cls", "home", "h", "?", "help", "q", "quit", "..", "exit", "r", "reset", ] CHOICES_COMMANDS = [ "load", "pick", "raw", "summary", "hist", "cdf", "bw", "rolling", "decompose", "cusum", "acf", "spread", "quantile", "skew", "kurtosis", "normality", "qqplot", "unitroot", "goodness", "unitroot", "capm", ] CHOICES += CHOICES_COMMANDS stock_interval = [1, 5, 15, 30, 60] stock_sources = ["yf", "av", "iex"] def __init__( self, ticker: str, start: datetime, interval: str, stock: pd.DataFrame, queue: List[str] = None, ): """Constructor""" stock["Returns"] = stock["Adj Close"].pct_change() stock["LogRet"] = np.log(stock["Adj Close"]) - np.log( stock["Adj Close"].shift(1) ) stock = stock.rename(columns={"Adj Close": "AdjClose"}) stock = stock.dropna() self.stock = stock self.ticker = ticker self.start = start self.interval = interval self.target = "Returns" self.df_columns = list(stock.columns) self.qa_parser = argparse.ArgumentParser(add_help=False, prog="qa") self.qa_parser.add_argument( "cmd", choices=self.CHOICES, ) self.completer: Union[None, NestedCompleter] = None if session and gtff.USE_PROMPT_TOOLKIT: choices: dict = {c: {} for c in self.CHOICES} choices["pick"]["-t"] = {c: None for c in self.df_columns} choices["pick"]["--target"] = {c: None for c in self.df_columns} choices["load"]["-i"] = {c: None for c in self.stock_interval} choices["load"]["--interval"] = {c: None for c in self.stock_interval} choices["load"]["--source"] = {c: None for c in self.stock_sources} self.completer = NestedCompleter.from_nested_dict(choices) if queue: self.queue = queue else: self.queue = list() def print_help(self): """Print help""" s_intraday = (f"Intraday {self.interval}", "Daily")[self.interval == "1440min"] if self.start: stock_str = f"{s_intraday} Stock: {self.ticker} (from {self.start.strftime('%Y-%m-%d')})" else: stock_str = f"{s_intraday} Stock: {self.ticker}" help_str = f""" Quantitative Analysis: {stock_str} Target Column: {self.target} Statistics: summary brief summary statistics of loaded stock. normality normality statistics and tests unitroot unit root test for stationarity (ADF, KPSS) Plots: hist histogram with density plot cdf cumulative distribution function bw box and whisker plot acf (partial) auto-correlation function differentials of prices qqplot residuals against standard normal curve Rolling Metrics: rolling rolling mean and std deviation of prices spread rolling variance and std deviation of prices quantile rolling median and quantile of prices skew rolling skewness of distribution of prices kurtosis rolling kurtosis of distribution of prices Other: raw print raw data decompose decomposition in cyclic-trend, season, and residuals of prices cusum detects abrupt changes using cumulative sum algorithm of prices capm capital asset pricing model """ print(help_str) def switch(self, an_input: str): """Process and dispatch input Returns ------- List[str] List of commands in the queue to execute """ # Empty command if not an_input: print("") return self.queue # Navigation slash is being used if "/" in an_input: actions = an_input.split("/") # Absolute path is specified if not actions[0]: an_input = "home" # Relative path so execute first instruction else: an_input = actions[0] # Add all instructions to the queue for cmd in actions[1:][::-1]: if cmd: self.queue.insert(0, cmd) (known_args, other_args) = self.qa_parser.parse_known_args(an_input.split()) # Redirect commands to their correct functions if known_args.cmd: if known_args.cmd in ("..", "q"): known_args.cmd = "quit" elif known_args.cmd in ("?", "h"): known_args.cmd = "help" elif known_args.cmd == "r": known_args.cmd = "reset" getattr( self, "call_" + known_args.cmd, lambda _: "Command not recognized!", )(other_args) return self.queue def call_cls(self, _): """Process cls command""" system_clear() def call_home(self, _): """Process home command""" self.queue.insert(0, "quit") self.queue.insert(0, "quit") def call_help(self, _): """Process help command""" self.print_help() def call_quit(self, _): """Process quit menu command""" print("") self.queue.insert(0, "quit") def call_exit(self, _): """Process exit terminal command""" self.queue.insert(0, "quit") self.queue.insert(0, "quit") self.queue.insert(0, "quit") def call_reset(self, _): """Process reset command""" self.queue.insert(0, "qa") if self.ticker: self.queue.insert(0, f"load {self.ticker}") self.queue.insert(0, "stocks") self.queue.insert(0, "reset") self.queue.insert(0, "quit") self.queue.insert(0, "quit") def call_load(self, other_args: List[str]): """Process load command""" parser = argparse.ArgumentParser( add_help=False, formatter_class=argparse.ArgumentDefaultsHelpFormatter, prog="load", description="Load stock ticker to perform analysis on. When the data source is 'yf', an Indian ticker can be" " loaded by using '.NS' at the end, e.g. 'SBIN.NS'. See available market in" " https://help.yahoo.com/kb/exchanges-data-providers-yahoo-finance-sln2310.html.", ) parser.add_argument( "-t", "--ticker", action="store", dest="ticker", required="-h" not in other_args, help="Stock ticker", ) 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( "-e", "--end", type=valid_date, default=datetime.now().strftime("%Y-%m-%d"), dest="end", help="The ending date (format YYYY-MM-DD) of the stock", ) parser.add_argument( "-i", "--interval", action="store", dest="interval", type=int, default=1440, choices=self.stock_interval, help="Intraday stock minutes", ) parser.add_argument( "--source", action="store", dest="source", choices=self.stock_sources, default="yf", help="Source of historical data.", ) parser.add_argument( "-p", "--prepost", action="store_true", default=False, dest="prepost", help="Pre/After market hours. Only works for 'yf' source, and intraday data", ) # For the case where a user uses: 'load BB' if other_args and "-t" not in other_args and "-h" not in other_args: other_args.insert(0, "-t") ns_parser = parse_known_args_and_warn(parser, other_args) if ns_parser: df_stock_candidate = stocks_helper.load( ns_parser.ticker, ns_parser.start, ns_parser.interval, ns_parser.end, ns_parser.prepost, ns_parser.source, ) if not df_stock_candidate.empty: self.stock = df_stock_candidate if "." in ns_parser.ticker: self.ticker = ns_parser.ticker.upper().split(".")[0] else: self.ticker = ns_parser.ticker.upper() self.start = ns_parser.start self.interval = str(ns_parser.interval) + "min" self.stock["Returns"] = self.stock["Adj Close"].pct_change() self.stock["LogRet"] = np.log(self.stock["Adj Close"]) - np.log( self.stock["Adj Close"].shift(1) ) self.stock = self.stock.rename(columns={"Adj Close": "AdjClose"}) self.stock = self.stock.dropna() @try_except def call_pick(self, other_args: List[str]): """Process pick command""" parser = argparse.ArgumentParser( formatter_class=argparse.ArgumentDefaultsHelpFormatter, add_help=False, prog="pick", description=""" Change target variable """, ) parser.add_argument( "-t", "--target", dest="target", choices=list(self.stock.columns), help="Select variable to analyze", ) if other_args and "-t" not in other_args and "-h" not in other_args: other_args.insert(0, "-t") ns_parser = parse_known_args_and_warn(parser, other_args) if ns_parser: self.target = ns_parser.target print("") @try_except def call_raw(self, other_args: List[str]): parser = argparse.ArgumentParser( formatter_class=argparse.ArgumentDefaultsHelpFormatter, add_help=False, prog="raw", description=""" Print raw data to console """, ) parser.add_argument( "-n", "--num", help="Number to show", type=check_positive, default=20, dest="num", ) parser.add_argument( "-d", "--descend", action="store_true", default=False, dest="descend", help="Sort in descending order", ) ns_parser = parse_known_args_and_warn( parser, other_args, export_allowed=EXPORT_ONLY_RAW_DATA_ALLOWED ) if ns_parser: qa_view.display_raw( self.stock[self.target], num=ns_parser.num, sort="", des=ns_parser.descend, export=ns_parser.export, ) @try_except def call_summary(self, other_args: List[str]): """Process summary command""" parser = argparse.ArgumentParser( formatter_class=argparse.ArgumentDefaultsHelpFormatter, add_help=False, prog="summary", description=""" Summary statistics """, ) ns_parser = parse_known_args_and_warn( parser, other_args, export_allowed=EXPORT_ONLY_RAW_DATA_ALLOWED ) if ns_parser: qa_view.display_summary(df=self.stock, export=ns_parser.export) @try_except def call_hist(self, other_args: List[str]): """Process hist command""" parser = argparse.ArgumentParser( formatter_class=argparse.ArgumentDefaultsHelpFormatter, add_help=False, prog="hist", description=""" Histogram with density and rug """, ) parser.add_argument( "-b", "--bins", type=check_positive, default=15, dest="n_bins" ) ns_parser = parse_known_args_and_warn(parser, other_args) if ns_parser: qa_view.display_hist( name=self.ticker, df=self.stock, target=self.target, bins=ns_parser.n_bins, ) @try_except def call_cdf(self, other_args: List[str]): """Process cdf command""" parser = argparse.ArgumentParser( formatter_class=argparse.ArgumentDefaultsHelpFormatter, add_help=False, prog="cdf", description=""" Cumulative distribution function """, ) ns_parser = parse_known_args_and_warn( parser, other_args, export_allowed=EXPORT_ONLY_RAW_DATA_ALLOWED ) if ns_parser: qa_view.display_cdf( name=self.ticker, df=self.stock, target=self.target, export=ns_parser.export, ) @try_except def call_bw(self, other_args: List[str]): """Process bwy command""" parser = argparse.ArgumentParser( formatter_class=argparse.ArgumentDefaultsHelpFormatter, add_help=False, prog="bw", description=""" Box and Whisker plot """, ) parser.add_argument( "-y", "--yearly", action="store_true", default=False, dest="year", help="Flag to show yearly bw plot", ) ns_parser = parse_known_args_and_warn(parser, other_args) if ns_parser: qa_view.display_bw( name=self.ticker, df=self.stock, target=self.target, yearly=ns_parser.year, ) @try_except def call_decompose(self, other_args: List[str]): """Process decompose command""" parser = argparse.ArgumentParser( add_help=False, formatter_class=argparse.ArgumentDefaultsHelpFormatter, prog="decompose", description=""" Decompose time series as: - Additive Time Series = Level + CyclicTrend + Residual + Seasonality - Multiplicative Time Series = Level * CyclicTrend * Residual * Seasonality """, ) parser.add_argument( "-m", "--multiplicative", action="store_true", default=False, dest="multiplicative", help="decompose using multiplicative model instead of additive", ) ns_parser = parse_known_args_and_warn( parser, other_args, export_allowed=EXPORT_ONLY_RAW_DATA_ALLOWED ) if ns_parser: qa_view.display_seasonal( name=self.ticker, df=self.stock, target=self.target, multiplicative=ns_parser.multiplicative, export=ns_parser.export, ) @try_except def call_cusum(self, other_args: List[str]): """Process cusum command""" parser = argparse.ArgumentParser( add_help=False, formatter_class=argparse.ArgumentDefaultsHelpFormatter, prog="cusum", description=""" Cumulative sum algorithm (CUSUM) to detect abrupt changes in data """, ) parser.add_argument( "-t", "--threshold", dest="threshold", type=float, default=( max(self.stock[self.target].values) - min(self.stock[self.target].values) ) / 40, help="threshold", ) parser.add_argument( "-d", "--drift", dest="drift", type=float, default=( max(self.stock[self.target].values) - min(self.stock[self.target].values) ) / 80, help="drift", ) ns_parser = parse_known_args_and_warn(parser, other_args) if ns_parser: qa_view.display_cusum( df=self.stock, target=self.target, threshold=ns_parser.threshold, drift=ns_parser.drift, ) @try_except def call_acf(self, other_args: List[str]): """Process acf command""" parser = argparse.ArgumentParser( add_help=False, formatter_class=argparse.ArgumentDefaultsHelpFormatter, prog="acf", description=""" Auto-Correlation and Partial Auto-Correlation Functions for diff and diff diff stock data """, ) parser.add_argument( "-l", "--lags", dest="lags", type=check_positive, default=15, help="maximum lags to display in plots", ) ns_parser = parse_known_args_and_warn(parser, other_args) if ns_parser: if self.target != "AdjClose": print( "Target not AdjClose. For best results, use `pick AdjClose` first." ) qa_view.display_acf( name=self.ticker, df=self.stock, target=self.target, lags=ns_parser.lags, ) @try_except def call_rolling(self, other_args: List[str]): """Process rolling command""" parser = argparse.ArgumentParser( add_help=False, formatter_class=argparse.ArgumentDefaultsHelpFormatter, prog="rolling", description=""" Rolling mean and std deviation """, ) parser.add_argument( "-l", "--length", action="store", dest="n_length", type=check_positive, default=14, help="Window length", ) ns_parser = parse_known_args_and_warn( parser, other_args, export_allowed=EXPORT_ONLY_RAW_DATA_ALLOWED ) if ns_parser: rolling_view.display_mean_std( name=self.ticker, df=self.stock, target=self.target, length=ns_parser.n_length, export=ns_parser.export, ) @try_except def call_spread(self, other_args: List[str]): """Process spread command""" parser = argparse.ArgumentParser( add_help=False, formatter_class=argparse.ArgumentDefaultsHelpFormatter, prog="spread", description="""Shows rolling spread measurement """, ) parser.add_argument( "-l", "--length", action="store", dest="n_length", type=check_positive, default=14, help="Window length", ) ns_parser = parse_known_args_and_warn( parser, other_args, export_allowed=EXPORT_ONLY_RAW_DATA_ALLOWED ) if ns_parser: rolling_view.display_spread( name=self.ticker, df=self.stock, target=self.target, length=ns_parser.n_length, export=ns_parser.export, ) @try_except def call_quantile(self, other_args: List[str]): """Process quantile command""" parser = argparse.ArgumentParser( add_help=False, formatter_class=argparse.ArgumentDefaultsHelpFormatter, prog="quantile", description=""" The quantiles are values which divide the distribution such that there is a given proportion of observations below the quantile. For example, the median is a quantile. The median is the central value of the distribution, such that half the points are less than or equal to it and half are greater than or equal to it. By default, q is set at 0.5, which effectively is median. Change q to get the desired quantile (0 0: # If the command is quitting the menu we want to return in here if qa_controller.queue[0] in ("q", "..", "quit"): print("") if len(qa_controller.queue) > 1: return qa_controller.queue[1:] return [] # Consume 1 element from the queue an_input = qa_controller.queue[0] qa_controller.queue = qa_controller.queue[1:] # Print the current location because this was an instruction and we want user to know what was the action if an_input and an_input.split(" ")[0] in qa_controller.CHOICES_COMMANDS: print(f"{get_flair()} /stocks/qa/ $ {an_input}") # Get input command from user else: # Display help menu when entering on this menu from a level above if an_input == "HELP_ME": qa_controller.print_help() # Get input from user using auto-completion if session and gtff.USE_PROMPT_TOOLKIT and qa_controller.completer: try: an_input = session.prompt( f"{get_flair()} /stocks/qa/ $ ", completer=qa_controller.completer, search_ignore_case=True, ) except KeyboardInterrupt: # Exit in case of keyboard interrupt an_input = "exit" # Get input from user without auto-completion else: an_input = input(f"{get_flair()} /stocks/qa/ $ ") try: # Process the input command qa_controller.queue = qa_controller.switch(an_input) except SystemExit: print( f"\nThe command '{an_input}' doesn't exist on the /stocks/qa menu.", end="", ) similar_cmd = difflib.get_close_matches( an_input.split(" ")[0] if " " in an_input else an_input, qa_controller.CHOICES, n=1, cutoff=0.7, ) if similar_cmd: if " " in an_input: candidate_input = ( f"{similar_cmd[0]} {' '.join(an_input.split(' ')[1:])}" ) if candidate_input == an_input: an_input = "" qa_controller.queue = [] print("\n") continue an_input = candidate_input else: an_input = similar_cmd[0] print(f" Replacing by '{an_input}'.") qa_controller.queue.insert(0, an_input) else: print("\n")