diff --git a/i18n/en.yml b/i18n/en.yml index 2da4c52a399..479b64c3187 100644 --- a/i18n/en.yml +++ b/i18n/en.yml @@ -470,6 +470,7 @@ en: stocks/qa/decompose: decomposition in cyclic-trend, season, and residuals of prices stocks/qa/cusum: detects abrupt changes using cumulative sum algorithm of prices stocks/qa/capm: capital asset pricing model + stocks/qa/beta: display beta w.r.t to any reference ticker stocks/pred/load: load new ticker stocks/pred/pick: pick target column for analysis stocks/pred/_ticker: Ticker diff --git a/openbb_terminal/stocks/quantitative_analysis/beta_model.py b/openbb_terminal/stocks/quantitative_analysis/beta_model.py new file mode 100644 index 00000000000..56a672166df --- /dev/null +++ b/openbb_terminal/stocks/quantitative_analysis/beta_model.py @@ -0,0 +1,63 @@ +import pandas as pd +from scipy import stats +from openbb_terminal.stocks import stocks_helper + + +def beta_model( + stock_ticker: str, + ref_ticker: str, + stock: pd.DataFrame = None, + ref: pd.DataFrame = None, +) -> tuple[pd.Series, pd.Series, float, float]: + """Calculate beta for a ticker and a reference ticker. + + Parameters + ---------- + stock_ticker : str + A ticker to calculate beta for + ref_ticker : str + A reference ticker for the beta calculation (default in terminal is SPY) + stock : pd.DataFrame + stock_ticker price data + ref : pd.DataFrame + ref_ticker price data + + Returns + ------- + sr : pd.Series + stock_ticker close-to-close returns + rr : pd.Series + ref_ticker close-to-close returns + beta : float + alpha : float + """ + if stock is None: + stock = stocks_helper.load(stock_ticker) + else: + # TODO: When loaded in the stocks menu, the stock df columns are all + # lowercase but when loaded via stocks_helper.load(ticker) they start + # with an uppercase char. This should be consistent. + stock = stock.rename({"close": "Close"}, axis=1) + if ref is None: + ref = stocks_helper.load(ref_ticker) + if ref.empty: + raise Exception("Invalid ref ticker") + else: + ref = ref.rename({"close": "Close"}, axis=1) + + # join returns + sr = 100 * stock["Close"].pct_change().to_frame() + sr = sr.rename({"Close": "Stock Pct Ret"}, axis=1) + rr = 100 * ref["Close"].pct_change().to_frame() + rr = rr.rename({"Close": "Ref Pct Ret"}, axis=1) + df = sr.merge(rr, how="outer", left_index=True, right_index=True) + df = df.dropna() + sr = df["Stock Pct Ret"].tolist() + rr = df["Ref Pct Ret"].tolist() + + # compute lin reg + model = stats.linregress(rr, sr) + beta = model.slope + alpha = model.intercept + + return sr, rr, beta, alpha diff --git a/openbb_terminal/stocks/quantitative_analysis/beta_view.py b/openbb_terminal/stocks/quantitative_analysis/beta_view.py new file mode 100644 index 00000000000..d9a8b75dd57 --- /dev/null +++ b/openbb_terminal/stocks/quantitative_analysis/beta_view.py @@ -0,0 +1,54 @@ +"""Beta view""" +__docformat__ = "numpy" + +import logging + +import matplotlib.pyplot as plt +import pandas as pd +from openbb_terminal.decorators import log_start_end +from openbb_terminal.rich_config import console +from openbb_terminal.stocks.quantitative_analysis.beta_model import beta_model + +logger = logging.getLogger(__name__) + + +@log_start_end(log=logger) +def beta_view( + stock_ticker: str, + ref_ticker: str, + stock: pd.DataFrame = None, + ref: pd.DataFrame = None, +) -> None: + """Display the beta scatterplot + linear regression. + + Parameters + ---------- + stock_ticker : str + A ticker to calculate beta for + ref_ticker : str + A reference ticker for the beta calculation (default in terminal is SPY) + stock : pd.DataFrame + stock_ticker price data + ref : pd.DataFrame + ref_ticker price data + """ + try: + sr, rr, beta, alpha = beta_model(stock_ticker, ref_ticker, stock, ref) + except Exception as e: + if str(e) == "Invalid ref ticker": + console.print(str(e) + "\n") + return + raise e + + fig, ax = plt.subplots() + ax.scatter(rr, sr) # plot returns + ax.plot(ax.get_xlim(), [x * beta + alpha for x in ax.get_xlim()]) # plot lin reg + ax.set( + xlabel=f"{ref_ticker} Returns (%)", + ylabel=f"{stock_ticker} Returns (%)", + title=f"Beta of {stock_ticker} with respect to {ref_ticker}", + ) + beta_text = f"Raw Beta={round(beta, 2)}\nAlpha={round(alpha, 2)}" + ax.text(0.9, 0.1, beta_text, horizontalalignment="right", transform=ax.transAxes) + fig.show() + console.print() diff --git a/openbb_terminal/stocks/quantitative_analysis/qa_controller.py b/openbb_terminal/stocks/quantitative_analysis/qa_controller.py index fd228272227..7896de9b61c 100644 --- a/openbb_terminal/stocks/quantitative_analysis/qa_controller.py +++ b/openbb_terminal/stocks/quantitative_analysis/qa_controller.py @@ -23,6 +23,7 @@ from openbb_terminal.helper_funcs import ( from openbb_terminal.menu import session from openbb_terminal.parent_classes import StockBaseController from openbb_terminal.rich_config import console, MenuText +from openbb_terminal.stocks.quantitative_analysis.beta_view import beta_view from openbb_terminal.stocks.quantitative_analysis.factors_view import capm_view # pylint: disable=C0302 @@ -56,6 +57,7 @@ class QaController(StockBaseController): "goodness", "unitroot", "capm", + "beta", "var", "es", "sh", @@ -148,6 +150,7 @@ class QaController(StockBaseController): mt.add_cmd("decompose") mt.add_cmd("cusum") mt.add_cmd("capm") + mt.add_cmd("beta") console.print(text=mt.menu_text, menu="Stocks - Quantitative Analysis") def custom_reset(self): @@ -752,6 +755,34 @@ class QaController(StockBaseController): if ns_parser: capm_view(self.ticker) + @log_start_end(log=logger) + def call_beta(self, other_args: List[str]): + """Call the beta command on loaded ticker""" + parser = argparse.ArgumentParser( + add_help=False, + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + prog="beta", + description=""" + Displays a scatter plot demonstrating the beta of two stocks or ETFs. + """, + ) + parser.add_argument( + "-r", + "--ref", + action="store", + dest="ref", + type=str, + default="SPY", + help=""" + Reference ticker used for beta calculation. + """, + ) + ns_parser = self.parse_known_args_and_warn( + parser, other_args, EXPORT_ONLY_RAW_DATA_ALLOWED + ) + if ns_parser: + beta_view(self.ticker, ns_parser.ref, stock=self.stock) + @log_start_end(log=logger) def call_var(self, other_args: List[str]): """Process var command""" diff --git a/scripts/test_stocks_qa.openbb b/scripts/test_stocks_qa.openbb index 9ef8c9c4248..35a9a342f2d 100644 --- a/scripts/test_stocks_qa.openbb +++ b/scripts/test_stocks_qa.openbb @@ -27,6 +27,7 @@ raw decompose cusum capm +beta var es sh diff --git a/tests/openbb_terminal/stocks/quantitative_analysis/json/test_beta_model/test_beta_model.json b/tests/openbb_terminal/stocks/quantitative_analysis/json/test_beta_model/test_beta_model.json new file mode 100644 index 00000000000..64737a6efca --- /dev/null +++ b/tests/openbb_terminal/stocks/quantitative_analysis/json/test_beta_model/test_beta_model.json @@ -0,0 +1 @@ +[[100.0, 50.0, 33.33333333333333, 25.0, 19.999999999999996, 16.666666666666675, 14.28571428571428, 12.5, 11.111111111111116], [50.0, 66.66666666666667, 39.99999999999999, 0.0, 28.57142857142858, 122.22222222222223, -110.00000000000001, 150.0, -100.0], 0.05941184834947979, 29.79941672703988] \ No newline at end of file diff --git a/tests/openbb_terminal/stocks/quantitative_analysis/test_beta_model.py b/tests/openbb_terminal/stocks/quantitative_analysis/test_beta_model.py new file mode 100644 index 00000000000..839f4a4c871 --- /dev/null +++ b/tests/openbb_terminal/stocks/quantitative_analysis/test_beta_model.py @@ -0,0 +1,19 @@ +# IMPORTATION STANDARD + +# IMPORTATION THIRDPARTY +import pandas as pd +import pytest + +# IMPORTATION INTERNAL +from openbb_terminal.stocks.quantitative_analysis.beta_model import beta_model + + +@pytest.mark.vcr +def test_beta_model(recorder): + stock = pd.DataFrame.from_dict({"close": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]}) + ref = pd.DataFrame.from_dict({"close": [2, 3, 5, 7, 7, 9, 20, -2, -5, 0]}) + + result_tuple = beta_model( + stock_ticker="TSLA", ref_ticker="XLK", stock=stock, ref=ref + ) + recorder.capture(result_tuple) diff --git a/tests/openbb_terminal/stocks/quantitative_analysis/test_qa_controller.py b/tests/openbb_terminal/stocks/quantitative_analysis/test_qa_controller.py index 11c64f76a3d..fe3d2af5151 100644 --- a/tests/openbb_terminal/stocks/quantitative_analysis/test_qa_controller.py +++ b/tests/openbb_terminal/stocks/quantitative_analysis/test_qa_controller.py @@ -507,6 +507,13 @@ def test_call_func_expect_queue(expected_queue, queue, func): [QA_CONTROLLER.ticker], dict(), ), + ( + "call_beta", + ["--ref=XLK"], + "beta_view", + [QA_CONTROLLER.ticker, "XLK"], + {"stock": QA_CONTROLLER.stock}, + ), ], ) def test_call_func( diff --git a/tests/openbb_terminal/stocks/quantitative_analysis/txt/test_qa_controller/test_print_help.txt b/tests/openbb_terminal/stocks/quantitative_analysis/txt/test_qa_controller/test_print_help.txt index 0b3087de606..4b8fc689d76 100644 --- a/tests/openbb_terminal/stocks/quantitative_analysis/txt/test_qa_controller/test_print_help.txt +++ b/tests/openbb_terminal/stocks/quantitative_analysis/txt/test_qa_controller/test_print_help.txt @@ -32,4 +32,5 @@ Other: 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 + beta display beta w.r.t to any reference ticker diff --git a/website/content/terminal/common/qa/beta/_index.md b/website/content/terminal/common/qa/beta/_index.md new file mode 100644 index 00000000000..5489ff6fcc5 --- /dev/null +++ b/website/content/terminal/common/qa/beta/_index.md @@ -0,0 +1,12 @@ +```text +usage: beta [--ref REF_TICKER] [-h] +``` + +Displays scatter plot of returns of a stock against a reference ticker and calculates the line of best fit through them to compute alpha and beta. + +``` +optional arguments: + -r, --ref Specify the reference ticker to use (default: SPY) + -h, --help show this help message (default: False) +``` +![beta](https://user-images.githubusercontent.com/62662248/180587175-5e548915-0d3e-44cf-bbff-3655d51e1d64.png) \ No newline at end of file