From 6076d71fcb86523d556be84c2549bad65b4ce4ec Mon Sep 17 00:00:00 2001 From: Anand B Pillai Date: Sat, 5 Aug 2023 18:43:21 +0530 Subject: [PATCH 01/33] Momentum Indicators - RSI, MACD ... (#120) Implementation of RSI momentum indicator, ref -> https://github.com/fmilthaler/FinQuant/issues/119 --- finquant/momentum_indicators.py | 164 ++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 finquant/momentum_indicators.py diff --git a/finquant/momentum_indicators.py b/finquant/momentum_indicators.py new file mode 100644 index 00000000..ac1939b2 --- /dev/null +++ b/finquant/momentum_indicators.py @@ -0,0 +1,164 @@ +""" This module provides function(s) to compute momentum indicators +used in technical analysis such as RSI """ + +import matplotlib.pyplot as plt +import pandas as pd + +def relative_strength_index(data, window_length: int = 14, oversold: int = 30, + overbought: int = 70, standalone: bool = False) -> None: + """ Computes and visualizes a RSI graph, + plotted along with the prices in another sub-graph + for comparison. + + Ref: https://www.investopedia.com/terms/r/rsi.asp + + :Input + :data: pandas.Series or pandas.DataFrame with stock prices in columns + :window_length: Window length to compute RSI, default being 14 days + :oversold: Standard level for oversold RSI, default being 30 + :overbought: Standard level for overbought RSI, default being 70 + :standalone: Plot only the RSI graph + """ + if not isinstance(data, (pd.Series, pd.DataFrame)): + raise ValueError( + "data is expected to be of type pandas.Series or pandas.DataFrame" + ) + if isinstance(data, pd.DataFrame) and not len(data.columns.values) == 1: + raise ValueError("data is expected to have only one column.") + # checking integer fields + for field in (window_length, oversold, overbought): + if not isinstance(field, int): + raise ValueError(f"{field} must be an integer.") + # validating levels + if oversold >= overbought: + raise ValueError("oversold level should be < overbought level") + if oversold >= 100 or overbought >= 100: + raise ValueError("levels should be < 100") + # converting data to pd.DataFrame if it is a pd.Series (for subsequent function calls): + if isinstance(data, pd.Series): + data = data.to_frame() + # get the stock key + stock = data.keys()[0] + # calculate price differences + data['diff'] = data.diff(1) + # calculate gains and losses + data['gain'] = data['diff'].clip(lower = 0).round(2) + data['loss'] = data['diff'].clip(upper = 0).abs().round(2) + # placeholder + wl = window_length + # calculate rolling window mean gains and losses + data['avg_gain'] = data['gain'].rolling(window = wl, min_periods = wl).mean() + data['avg_loss'] = data['loss'].rolling(window = wl, min_periods = wl).mean() + # calculate WMS (wilder smoothing method) averages + for i, row in enumerate(data['avg_gain'].iloc[wl+1:]): + data['avg_gain'].iloc[i+wl+1] = (data['avg_gain'].iloc[i+wl]*(wl-1) +data['gain'].iloc[i+wl+1])/wl + for i, row in enumerate(data['avg_loss'].iloc[wl+1:]): + data['avg_loss'].iloc[i+wl+1] =(data['avg_loss'].iloc[i+wl]*(wl-1) + data['loss'].iloc[i+wl+1])/wl + # calculate RS values + data['rs'] = data['avg_gain']/data['avg_loss'] + # calculate RSI + data['rsi'] = 100 - (100/(1.0 + data['rs'])) + # Plot it + if standalone: + # Single plot + fig = plt.figure() + ax = fig.add_subplot(111) + ax.axhline(y = oversold, color = 'g', linestyle = '--') + ax.axhline(y = overbought, color = 'r', linestyle ='--') + data['rsi'].plot(ylabel = 'RSI', xlabel = 'Date', ax = ax, grid = True) + plt.title("RSI Plot") + plt.legend() + else: + # RSI against price in 2 plots + fig, ax = plt.subplots(2, 1, sharex=True, sharey=False) + ax[0].axhline(y = oversold, color = 'g', linestyle = '--') + ax[0].axhline(y = overbought, color = 'r', linestyle ='--') + ax[0].set_title('RSI + Price Plot') + # plot 2 graphs in 2 colors + colors = plt.rcParams["axes.prop_cycle"]() + data['rsi'].plot(ylabel = 'RSI', ax = ax[0], grid = True, color = next(colors)["color"], legend=True) + data[stock].plot(xlabel = 'Date', ylabel = 'Price', ax = ax[1], grid = True, + color = next(colors)["color"], legend = True) + plt.legend() + +def macd(data, longer_ema_window: int = 26, shorter_ema_window: int = 12, + signal_ema_window: int = 9, standalone: bool = False) -> None: + """ + Computes and visualizes a MACD (Moving Average Convergence Divergence) + plotted along with price chart in another sub-graph for comparison. + + Ref: https://www.alpharithms.com/calculate-macd-python-272222/ + + :Input + :data: pandas.Series or pandas.DataFrame with stock prices in columns + :longer_ema_window: Window length (in days) for the longer EMA + :shorter_ema_window: Window length (in days) for the shorter EMA + :signal_ema_window: Window length (in days) for the signal + :standalone: If true, plot only the MACD signal + """ + + if not isinstance(data, (pd.Series, pd.DataFrame)): + raise ValueError( + "data is expected to be of type pandas.Series or pandas.DataFrame" + ) + if isinstance(data, pd.DataFrame) and not len(data.columns.values) == 1: + raise ValueError("data is expected to have only one column.") + # checking integer fields + for field in (longer_ema_window, shorter_ema_window, signal_ema_window): + if not isinstance(field, int): + raise ValueError(f"{field} must be an integer.") + # validating windows + if longer_ema_window < shorter_ema_window: + raise ValueError("longer ema window should be > shorter ema window") + if longer_ema_window < signal_ema_window: + raise ValueError("longer ema window should be > signal ema window") + + # converting data to pd.DataFrame if it is a pd.Series (for subsequent function calls): + if isinstance(data, pd.Series): + data = data.to_frame() + # get the stock key + stock = data.keys()[0] + # calculate EMA short period + ema_short = data.ewm(span=shorter_ema_window, adjust=False, min_periods=shorter_ema_window).mean() + # calculate EMA long period + ema_long = data.ewm(span=longer_ema_window, adjust=False, min_periods=longer_ema_window).mean() + # Subtract the longwer window EMA from the shorter window EMA to get the MACD + data['macd'] = ema_long - ema_short + # Get the signal window MACD for the Trigger line + data['macd_s'] = data['macd'].ewm(span=signal_ema_window, adjust=False, min_periods=signal_ema_window).mean() + # Calculate the difference between the MACD - Trigger for the Convergence/Divergence value + data['diff'] = data['macd'] - data['macd_s'] + hist = data['diff'] + + # Plot it + if standalone: + fig=plt.figure() + ax = fig.add_subplot(111) + data['macd'].plot(ylabel = 'MACD', xlabel='Date', ax = ax, grid = True, label='MACD', color='green', + linewidth=1.5, legend=True) + hist.plot(ax = ax, grid = True, label='diff', color='black', linewidth=0.5, legend=True) + data['macd_s'].plot(ax = ax, grid = True, label='SIGNAL', color='red', linewidth=1.5, legend=True) + + for i in range(len(hist)): + if hist[i] < 0: + ax.bar(data.index[i], hist[i], color = 'orange') + else: + ax.bar(data.index[i], hist[i], color = 'black') + else: + # RSI against price in 2 plots + fig, ax = plt.subplots(2, 1, sharex=True, sharey=False) + ax[0].set_title('MACD + Price Plot') + data['macd'].plot(ylabel = 'MACD', xlabel='Date', ax = ax[0], grid = True, + label='MACD', color='green', linewidth=1.5, legend=True) + hist.plot(ax = ax[0], grid = True, label='diff', color='black', linewidth=0.5, legend=True) + data['macd_s'].plot(ax = ax[0], grid = True, label='SIGNAL', color='red', linewidth=1.5, legend=True) + + for i in range(len(hist)): + if hist[i] < 0: + ax[0].bar(data.index[i], hist[i], color = 'orange') + else: + ax[0].bar(data.index[i], hist[i], color = 'black') + + data[stock].plot(xlabel = 'Date', ylabel = 'Price', ax = ax[1], grid = True, + color = 'orange', legend = True) + plt.legend() From cea6b3133caa818168b29a4817348f5dd9168080 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 5 Aug 2023 13:14:29 +0000 Subject: [PATCH 02/33] Automated version changes --- version | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version b/version index bf4c4f6a..7ad36c63 100644 --- a/version +++ b/version @@ -1,2 +1,2 @@ -version=0.4.1 -release=0.4.1 +version=0.7.0 +release=0.7.0 From fe1b7fb62e0a458f973ab37fab130e15064dde79 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 5 Aug 2023 13:14:29 +0000 Subject: [PATCH 03/33] Updating README files --- README.md | 2 +- README.tex.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 73378f17..3550f831 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ pypi - pypi + pypi GitHub Actions diff --git a/README.tex.md b/README.tex.md index e54f4ae1..7e0c2668 100644 --- a/README.tex.md +++ b/README.tex.md @@ -7,7 +7,7 @@ pypi - pypi + pypi GitHub Actions From ba864a873d98f447e05bd270bc1cfafaaaea14a4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 5 Aug 2023 13:14:30 +0000 Subject: [PATCH 04/33] Automated formatting changes --- finquant/momentum_indicators.py | 166 ++++++++++++++++++++++---------- 1 file changed, 114 insertions(+), 52 deletions(-) diff --git a/finquant/momentum_indicators.py b/finquant/momentum_indicators.py index ac1939b2..0e86e58c 100644 --- a/finquant/momentum_indicators.py +++ b/finquant/momentum_indicators.py @@ -4,9 +4,15 @@ import matplotlib.pyplot as plt import pandas as pd -def relative_strength_index(data, window_length: int = 14, oversold: int = 30, - overbought: int = 70, standalone: bool = False) -> None: - """ Computes and visualizes a RSI graph, + +def relative_strength_index( + data, + window_length: int = 14, + oversold: int = 30, + overbought: int = 70, + standalone: bool = False, +) -> None: + """Computes and visualizes a RSI graph, plotted along with the prices in another sub-graph for comparison. @@ -40,49 +46,67 @@ def relative_strength_index(data, window_length: int = 14, oversold: int = 30, # get the stock key stock = data.keys()[0] # calculate price differences - data['diff'] = data.diff(1) + data["diff"] = data.diff(1) # calculate gains and losses - data['gain'] = data['diff'].clip(lower = 0).round(2) - data['loss'] = data['diff'].clip(upper = 0).abs().round(2) + data["gain"] = data["diff"].clip(lower=0).round(2) + data["loss"] = data["diff"].clip(upper=0).abs().round(2) # placeholder wl = window_length # calculate rolling window mean gains and losses - data['avg_gain'] = data['gain'].rolling(window = wl, min_periods = wl).mean() - data['avg_loss'] = data['loss'].rolling(window = wl, min_periods = wl).mean() + data["avg_gain"] = data["gain"].rolling(window=wl, min_periods=wl).mean() + data["avg_loss"] = data["loss"].rolling(window=wl, min_periods=wl).mean() # calculate WMS (wilder smoothing method) averages - for i, row in enumerate(data['avg_gain'].iloc[wl+1:]): - data['avg_gain'].iloc[i+wl+1] = (data['avg_gain'].iloc[i+wl]*(wl-1) +data['gain'].iloc[i+wl+1])/wl - for i, row in enumerate(data['avg_loss'].iloc[wl+1:]): - data['avg_loss'].iloc[i+wl+1] =(data['avg_loss'].iloc[i+wl]*(wl-1) + data['loss'].iloc[i+wl+1])/wl + for i, row in enumerate(data["avg_gain"].iloc[wl + 1 :]): + data["avg_gain"].iloc[i + wl + 1] = ( + data["avg_gain"].iloc[i + wl] * (wl - 1) + data["gain"].iloc[i + wl + 1] + ) / wl + for i, row in enumerate(data["avg_loss"].iloc[wl + 1 :]): + data["avg_loss"].iloc[i + wl + 1] = ( + data["avg_loss"].iloc[i + wl] * (wl - 1) + data["loss"].iloc[i + wl + 1] + ) / wl # calculate RS values - data['rs'] = data['avg_gain']/data['avg_loss'] + data["rs"] = data["avg_gain"] / data["avg_loss"] # calculate RSI - data['rsi'] = 100 - (100/(1.0 + data['rs'])) + data["rsi"] = 100 - (100 / (1.0 + data["rs"])) # Plot it if standalone: # Single plot fig = plt.figure() ax = fig.add_subplot(111) - ax.axhline(y = oversold, color = 'g', linestyle = '--') - ax.axhline(y = overbought, color = 'r', linestyle ='--') - data['rsi'].plot(ylabel = 'RSI', xlabel = 'Date', ax = ax, grid = True) + ax.axhline(y=oversold, color="g", linestyle="--") + ax.axhline(y=overbought, color="r", linestyle="--") + data["rsi"].plot(ylabel="RSI", xlabel="Date", ax=ax, grid=True) plt.title("RSI Plot") plt.legend() else: # RSI against price in 2 plots fig, ax = plt.subplots(2, 1, sharex=True, sharey=False) - ax[0].axhline(y = oversold, color = 'g', linestyle = '--') - ax[0].axhline(y = overbought, color = 'r', linestyle ='--') - ax[0].set_title('RSI + Price Plot') + ax[0].axhline(y=oversold, color="g", linestyle="--") + ax[0].axhline(y=overbought, color="r", linestyle="--") + ax[0].set_title("RSI + Price Plot") # plot 2 graphs in 2 colors colors = plt.rcParams["axes.prop_cycle"]() - data['rsi'].plot(ylabel = 'RSI', ax = ax[0], grid = True, color = next(colors)["color"], legend=True) - data[stock].plot(xlabel = 'Date', ylabel = 'Price', ax = ax[1], grid = True, - color = next(colors)["color"], legend = True) + data["rsi"].plot( + ylabel="RSI", ax=ax[0], grid=True, color=next(colors)["color"], legend=True + ) + data[stock].plot( + xlabel="Date", + ylabel="Price", + ax=ax[1], + grid=True, + color=next(colors)["color"], + legend=True, + ) plt.legend() -def macd(data, longer_ema_window: int = 26, shorter_ema_window: int = 12, - signal_ema_window: int = 9, standalone: bool = False) -> None: + +def macd( + data, + longer_ema_window: int = 26, + shorter_ema_window: int = 12, + signal_ema_window: int = 9, + standalone: bool = False, +) -> None: """ Computes and visualizes a MACD (Moving Average Convergence Divergence) plotted along with price chart in another sub-graph for comparison. @@ -111,7 +135,7 @@ def macd(data, longer_ema_window: int = 26, shorter_ema_window: int = 12, if longer_ema_window < shorter_ema_window: raise ValueError("longer ema window should be > shorter ema window") if longer_ema_window < signal_ema_window: - raise ValueError("longer ema window should be > signal ema window") + raise ValueError("longer ema window should be > signal ema window") # converting data to pd.DataFrame if it is a pd.Series (for subsequent function calls): if isinstance(data, pd.Series): @@ -119,46 +143,84 @@ def macd(data, longer_ema_window: int = 26, shorter_ema_window: int = 12, # get the stock key stock = data.keys()[0] # calculate EMA short period - ema_short = data.ewm(span=shorter_ema_window, adjust=False, min_periods=shorter_ema_window).mean() + ema_short = data.ewm( + span=shorter_ema_window, adjust=False, min_periods=shorter_ema_window + ).mean() # calculate EMA long period - ema_long = data.ewm(span=longer_ema_window, adjust=False, min_periods=longer_ema_window).mean() + ema_long = data.ewm( + span=longer_ema_window, adjust=False, min_periods=longer_ema_window + ).mean() # Subtract the longwer window EMA from the shorter window EMA to get the MACD - data['macd'] = ema_long - ema_short + data["macd"] = ema_long - ema_short # Get the signal window MACD for the Trigger line - data['macd_s'] = data['macd'].ewm(span=signal_ema_window, adjust=False, min_periods=signal_ema_window).mean() + data["macd_s"] = ( + data["macd"] + .ewm(span=signal_ema_window, adjust=False, min_periods=signal_ema_window) + .mean() + ) # Calculate the difference between the MACD - Trigger for the Convergence/Divergence value - data['diff'] = data['macd'] - data['macd_s'] - hist = data['diff'] - + data["diff"] = data["macd"] - data["macd_s"] + hist = data["diff"] + # Plot it if standalone: - fig=plt.figure() + fig = plt.figure() ax = fig.add_subplot(111) - data['macd'].plot(ylabel = 'MACD', xlabel='Date', ax = ax, grid = True, label='MACD', color='green', - linewidth=1.5, legend=True) - hist.plot(ax = ax, grid = True, label='diff', color='black', linewidth=0.5, legend=True) - data['macd_s'].plot(ax = ax, grid = True, label='SIGNAL', color='red', linewidth=1.5, legend=True) - + data["macd"].plot( + ylabel="MACD", + xlabel="Date", + ax=ax, + grid=True, + label="MACD", + color="green", + linewidth=1.5, + legend=True, + ) + hist.plot( + ax=ax, grid=True, label="diff", color="black", linewidth=0.5, legend=True + ) + data["macd_s"].plot( + ax=ax, grid=True, label="SIGNAL", color="red", linewidth=1.5, legend=True + ) + for i in range(len(hist)): if hist[i] < 0: - ax.bar(data.index[i], hist[i], color = 'orange') + ax.bar(data.index[i], hist[i], color="orange") else: - ax.bar(data.index[i], hist[i], color = 'black') + ax.bar(data.index[i], hist[i], color="black") else: # RSI against price in 2 plots fig, ax = plt.subplots(2, 1, sharex=True, sharey=False) - ax[0].set_title('MACD + Price Plot') - data['macd'].plot(ylabel = 'MACD', xlabel='Date', ax = ax[0], grid = True, - label='MACD', color='green', linewidth=1.5, legend=True) - hist.plot(ax = ax[0], grid = True, label='diff', color='black', linewidth=0.5, legend=True) - data['macd_s'].plot(ax = ax[0], grid = True, label='SIGNAL', color='red', linewidth=1.5, legend=True) - + ax[0].set_title("MACD + Price Plot") + data["macd"].plot( + ylabel="MACD", + xlabel="Date", + ax=ax[0], + grid=True, + label="MACD", + color="green", + linewidth=1.5, + legend=True, + ) + hist.plot( + ax=ax[0], grid=True, label="diff", color="black", linewidth=0.5, legend=True + ) + data["macd_s"].plot( + ax=ax[0], grid=True, label="SIGNAL", color="red", linewidth=1.5, legend=True + ) + for i in range(len(hist)): if hist[i] < 0: - ax[0].bar(data.index[i], hist[i], color = 'orange') + ax[0].bar(data.index[i], hist[i], color="orange") else: - ax[0].bar(data.index[i], hist[i], color = 'black') + ax[0].bar(data.index[i], hist[i], color="black") - data[stock].plot(xlabel = 'Date', ylabel = 'Price', ax = ax[1], grid = True, - color = 'orange', legend = True) - plt.legend() + data[stock].plot( + xlabel="Date", + ylabel="Price", + ax=ax[1], + grid=True, + color="orange", + legend=True, + ) + plt.legend() From afe4d60606faa86931b04e09eb03f2cd4ea08cfd Mon Sep 17 00:00:00 2001 From: Anand B Pillai Date: Tue, 15 Aug 2023 17:37:06 +0530 Subject: [PATCH 05/33] momentum indicators (2nd PR) #132 (#132) Ref -> https://github.com/fmilthaler/FinQuant/issues/119 --- example/Example-Analysis.py | 43 ++++++++++++ finquant/momentum_indicators.py | 62 +++++++---------- tests/test_momentum_indicators.py | 110 ++++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+), 38 deletions(-) create mode 100644 tests/test_momentum_indicators.py diff --git a/example/Example-Analysis.py b/example/Example-Analysis.py index ba0f4682..b9fa5ef3 100644 --- a/example/Example-Analysis.py +++ b/example/Example-Analysis.py @@ -309,3 +309,46 @@ # print(pf.data.loc[pf.data.index.year == 2017].head(3)) + +# + +# ## Momentum Indicators +# `FinQuant` provides a module `finquant.momentum_indicators` to compute and +# visualize a number of momentum indicators. Currently RSI (Relative Strength Index) +# and MACD (Moving Average Convergence Divergence) indicators are available. +# See below. + +# +# plot the RSI (Relative Strength Index) for disney stock proces +from finquant.momentum_indicators import relative_strength_index as rsi + +# get stock data for disney +dis = pf.get_stock("WIKI/DIS").data.copy(deep=True) + +# plot RSI - by default this plots RSI against the price in two graphs +rsi(dis) +plt.show() + +# plot RSI with custom arguments +rsi(dis, oversold = 20, overbought = 80) +plt.show() + +# plot RSI standalone graph +rsi(dis, oversold = 20, overbought = 80, standalone=True) +plt.show() + +# +# plot MACD for disney stock proces +from finquant.momentum_indicators import macd + +# plot MACD - by default this plots RSI against the price in two graphs +macd(dis) +plt.show() + +# plot MACD using custom arguments +macd(dis, longer_ema_window = 30, shorter_ema_window = 15, signal_ema_window = 10) +plt.show() + +# plot MACD standalone graph +macd(standlone = True) +plt.show() diff --git a/finquant/momentum_indicators.py b/finquant/momentum_indicators.py index 0e86e58c..6091bc23 100644 --- a/finquant/momentum_indicators.py +++ b/finquant/momentum_indicators.py @@ -1,18 +1,13 @@ """ This module provides function(s) to compute momentum indicators -used in technical analysis such as RSI """ +used in technical analysis such as RSI, MACD etc. """ import matplotlib.pyplot as plt import pandas as pd +def relative_strength_index(data, window_length: int = 14, oversold: int = 30, + overbought: int = 70, standalone: bool = False) -> None: -def relative_strength_index( - data, - window_length: int = 14, - oversold: int = 30, - overbought: int = 70, - standalone: bool = False, -) -> None: - """Computes and visualizes a RSI graph, + """ Computes and visualizes a RSI graph, plotted along with the prices in another sub-graph for comparison. @@ -73,33 +68,24 @@ def relative_strength_index( # Single plot fig = plt.figure() ax = fig.add_subplot(111) - ax.axhline(y=oversold, color="g", linestyle="--") - ax.axhline(y=overbought, color="r", linestyle="--") - data["rsi"].plot(ylabel="RSI", xlabel="Date", ax=ax, grid=True) + ax.axhline(y = oversold, color = 'g', linestyle = '--') + ax.axhline(y = overbought, color = 'r', linestyle ='--') + data['rsi'].plot(ylabel = 'RSI', xlabel = 'Date', ax = ax, grid = True) plt.title("RSI Plot") plt.legend() else: # RSI against price in 2 plots fig, ax = plt.subplots(2, 1, sharex=True, sharey=False) - ax[0].axhline(y=oversold, color="g", linestyle="--") - ax[0].axhline(y=overbought, color="r", linestyle="--") - ax[0].set_title("RSI + Price Plot") + ax[0].axhline(y = oversold, color = 'g', linestyle = '--') + ax[0].axhline(y = overbought, color = 'r', linestyle ='--') + ax[0].set_title('RSI + Price Plot') # plot 2 graphs in 2 colors colors = plt.rcParams["axes.prop_cycle"]() - data["rsi"].plot( - ylabel="RSI", ax=ax[0], grid=True, color=next(colors)["color"], legend=True - ) - data[stock].plot( - xlabel="Date", - ylabel="Price", - ax=ax[1], - grid=True, - color=next(colors)["color"], - legend=True, - ) + data['rsi'].plot(ylabel = 'RSI', ax = ax[0], grid = True, color = next(colors)["color"], legend=True) + data[stock].plot(xlabel = 'Date', ylabel = 'Price', ax = ax[1], grid = True, + color = next(colors)["color"], legend = True) plt.legend() - - + def macd( data, longer_ema_window: int = 26, @@ -135,8 +121,8 @@ def macd( if longer_ema_window < shorter_ema_window: raise ValueError("longer ema window should be > shorter ema window") if longer_ema_window < signal_ema_window: - raise ValueError("longer ema window should be > signal ema window") - + raise ValueError("longer ema window should be > signal ema window") + # converting data to pd.DataFrame if it is a pd.Series (for subsequent function calls): if isinstance(data, pd.Series): data = data.to_frame() @@ -183,11 +169,11 @@ def macd( ax=ax, grid=True, label="SIGNAL", color="red", linewidth=1.5, legend=True ) - for i in range(len(hist)): - if hist[i] < 0: - ax.bar(data.index[i], hist[i], color="orange") + for i, key in enumerate(hist.index): + if hist[key] < 0: + ax.bar(data.index[i], hist[key], color = 'orange') else: - ax.bar(data.index[i], hist[i], color="black") + ax.bar(data.index[i], hist[key], color = 'black') else: # RSI against price in 2 plots fig, ax = plt.subplots(2, 1, sharex=True, sharey=False) @@ -209,11 +195,11 @@ def macd( ax=ax[0], grid=True, label="SIGNAL", color="red", linewidth=1.5, legend=True ) - for i in range(len(hist)): - if hist[i] < 0: - ax[0].bar(data.index[i], hist[i], color="orange") + for i, key in enumerate(hist.index): + if hist[key] < 0: + ax.bar(data.index[i], hist[key], color = 'orange') else: - ax[0].bar(data.index[i], hist[i], color="black") + ax.bar(data.index[i], hist[key], color = 'black') data[stock].plot( xlabel="Date", diff --git a/tests/test_momentum_indicators.py b/tests/test_momentum_indicators.py new file mode 100644 index 00000000..f3b091f6 --- /dev/null +++ b/tests/test_momentum_indicators.py @@ -0,0 +1,110 @@ +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd + +from finquant.momentum_indicators import ( + relative_strength_index as rsi, + macd, +) + +plt.switch_backend("Agg") + +def test_rsi(): + x = np.sin(np.linspace(1, 10, 100)) + xlabel_orig = "Date" + ylabel_orig = "Price" + df = pd.DataFrame({"Stock": x}, index=np.linspace(1, 10, 100)) + df.index.name = "Date" + rsi(df) + # get data from axis object + ax = plt.gca() + # ax.lines[0] is the data we passed to plot_bollinger_band + line1 = ax.lines[0] + stock_plot = line1.get_xydata() + xlabel_plot = ax.get_xlabel() + ylabel_plot = ax.get_ylabel() + # tests + assert (df['Stock'].index.values == stock_plot[:, 0]).all() + assert (df["Stock"].values == stock_plot[:, 1]).all() + assert xlabel_orig == xlabel_plot + assert ylabel_orig == ylabel_plot + +def test_rsi_standalone(): + x = np.sin(np.linspace(1, 10, 100)) + xlabel_orig = "Date" + ylabel_orig = "RSI" + labels_orig = ['rsi'] + title_orig = 'RSI Plot' + df = pd.DataFrame({"Stock": x}, index=np.linspace(1, 10, 100)) + df.index.name = "Date" + rsi(df, standalone=True) + # get data from axis object + ax = plt.gca() + # ax.lines[2] is the RSI data + line1 = ax.lines[2] + rsi_plot = line1.get_xydata() + xlabel_plot = ax.get_xlabel() + ylabel_plot = ax.get_ylabel() + print (xlabel_plot, ylabel_plot) + # tests + assert (df['rsi'].index.values == rsi_plot[:, 0]).all() + # for comparing values, we need to remove nan + a, b = df['rsi'].values, rsi_plot[:, 1] + a, b = map(lambda x: x[~np.isnan(x)], (a, b)) + assert (a == b).all() + labels_plot = ax.get_legend_handles_labels()[1] + title_plot = ax.get_title() + assert labels_plot == labels_orig + assert xlabel_plot == xlabel_orig + assert ylabel_plot == ylabel_orig + assert title_plot == title_orig + +def test_macd(): + x = np.sin(np.linspace(1, 10, 100)) + xlabel_orig = "Date" + ylabel_orig = "Price" + df = pd.DataFrame({"Stock": x}, index=np.linspace(1, 10, 100)) + df.index.name = "Date" + macd(df) + # get data from axis object + ax = plt.gca() + # ax.lines[0] is the data we passed to plot_bollinger_band + line1 = ax.lines[0] + stock_plot = line1.get_xydata() + xlabel_plot = ax.get_xlabel() + ylabel_plot = ax.get_ylabel() + # tests + assert (df['Stock'].index.values == stock_plot[:, 0]).all() + assert (df["Stock"].values == stock_plot[:, 1]).all() + assert xlabel_orig == xlabel_plot + assert ylabel_orig == ylabel_plot + +def test_macd_standalone(): + labels_orig = ['MACD', 'diff', 'SIGNAL'] + x = np.sin(np.linspace(1, 10, 100)) + xlabel_orig = "Date" + ylabel_orig = "MACD" + df = pd.DataFrame({"Stock": x}, index=np.linspace(1, 10, 100)) + df.index.name = "Date" + macd(df, standalone=True) + # get data from axis object + ax = plt.gca() + labels_plot = ax.get_legend_handles_labels()[1] + xlabel_plot = ax.get_xlabel() + ylabel_plot = ax.get_ylabel() + assert labels_plot == labels_orig + assert xlabel_plot == xlabel_orig + assert ylabel_plot == ylabel_orig + # ax.lines[0] is macd data + # ax.lines[1] is diff data + # ax.lines[2] is macd_s data + # tests + for i, key in ((0, 'macd'), (1, 'diff'), (2, 'macd_s')): + line = ax.lines[i] + data_plot = line.get_xydata() + # tests + assert (df[key].index.values == data_plot[:, 0]).all() + # for comparing values, we need to remove nan + a, b = df[key].values, data_plot[:, 1] + a, b = map(lambda x: x[~np.isnan(x)], (a, b)) + assert (a == b).all() From e01f5159426fb0f5ce2dee761710f29ae81cd4c0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 15 Aug 2023 12:14:59 +0000 Subject: [PATCH 06/33] Automated formatting changes --- example/Example-Analysis.py | 8 ++--- finquant/momentum_indicators.py | 52 ++++++++++++++++++++----------- tests/test_momentum_indicators.py | 40 +++++++++++++----------- 3 files changed, 58 insertions(+), 42 deletions(-) diff --git a/example/Example-Analysis.py b/example/Example-Analysis.py index 5d2ace30..f7f342a7 100644 --- a/example/Example-Analysis.py +++ b/example/Example-Analysis.py @@ -337,11 +337,11 @@ plt.show() # plot RSI with custom arguments -rsi(dis, oversold = 20, overbought = 80) +rsi(dis, oversold=20, overbought=80) plt.show() # plot RSI standalone graph -rsi(dis, oversold = 20, overbought = 80, standalone=True) +rsi(dis, oversold=20, overbought=80, standalone=True) plt.show() # @@ -353,9 +353,9 @@ plt.show() # plot MACD using custom arguments -macd(dis, longer_ema_window = 30, shorter_ema_window = 15, signal_ema_window = 10) +macd(dis, longer_ema_window=30, shorter_ema_window=15, signal_ema_window=10) plt.show() # plot MACD standalone graph -macd(standlone = True) +macd(standlone=True) plt.show() diff --git a/finquant/momentum_indicators.py b/finquant/momentum_indicators.py index 6091bc23..b6f75217 100644 --- a/finquant/momentum_indicators.py +++ b/finquant/momentum_indicators.py @@ -4,10 +4,15 @@ import matplotlib.pyplot as plt import pandas as pd -def relative_strength_index(data, window_length: int = 14, oversold: int = 30, - overbought: int = 70, standalone: bool = False) -> None: - """ Computes and visualizes a RSI graph, +def relative_strength_index( + data, + window_length: int = 14, + oversold: int = 30, + overbought: int = 70, + standalone: bool = False, +) -> None: + """Computes and visualizes a RSI graph, plotted along with the prices in another sub-graph for comparison. @@ -68,24 +73,33 @@ def relative_strength_index(data, window_length: int = 14, oversold: int = 30, # Single plot fig = plt.figure() ax = fig.add_subplot(111) - ax.axhline(y = oversold, color = 'g', linestyle = '--') - ax.axhline(y = overbought, color = 'r', linestyle ='--') - data['rsi'].plot(ylabel = 'RSI', xlabel = 'Date', ax = ax, grid = True) + ax.axhline(y=oversold, color="g", linestyle="--") + ax.axhline(y=overbought, color="r", linestyle="--") + data["rsi"].plot(ylabel="RSI", xlabel="Date", ax=ax, grid=True) plt.title("RSI Plot") plt.legend() else: # RSI against price in 2 plots fig, ax = plt.subplots(2, 1, sharex=True, sharey=False) - ax[0].axhline(y = oversold, color = 'g', linestyle = '--') - ax[0].axhline(y = overbought, color = 'r', linestyle ='--') - ax[0].set_title('RSI + Price Plot') + ax[0].axhline(y=oversold, color="g", linestyle="--") + ax[0].axhline(y=overbought, color="r", linestyle="--") + ax[0].set_title("RSI + Price Plot") # plot 2 graphs in 2 colors colors = plt.rcParams["axes.prop_cycle"]() - data['rsi'].plot(ylabel = 'RSI', ax = ax[0], grid = True, color = next(colors)["color"], legend=True) - data[stock].plot(xlabel = 'Date', ylabel = 'Price', ax = ax[1], grid = True, - color = next(colors)["color"], legend = True) + data["rsi"].plot( + ylabel="RSI", ax=ax[0], grid=True, color=next(colors)["color"], legend=True + ) + data[stock].plot( + xlabel="Date", + ylabel="Price", + ax=ax[1], + grid=True, + color=next(colors)["color"], + legend=True, + ) plt.legend() - + + def macd( data, longer_ema_window: int = 26, @@ -121,8 +135,8 @@ def macd( if longer_ema_window < shorter_ema_window: raise ValueError("longer ema window should be > shorter ema window") if longer_ema_window < signal_ema_window: - raise ValueError("longer ema window should be > signal ema window") - + raise ValueError("longer ema window should be > signal ema window") + # converting data to pd.DataFrame if it is a pd.Series (for subsequent function calls): if isinstance(data, pd.Series): data = data.to_frame() @@ -171,9 +185,9 @@ def macd( for i, key in enumerate(hist.index): if hist[key] < 0: - ax.bar(data.index[i], hist[key], color = 'orange') + ax.bar(data.index[i], hist[key], color="orange") else: - ax.bar(data.index[i], hist[key], color = 'black') + ax.bar(data.index[i], hist[key], color="black") else: # RSI against price in 2 plots fig, ax = plt.subplots(2, 1, sharex=True, sharey=False) @@ -197,9 +211,9 @@ def macd( for i, key in enumerate(hist.index): if hist[key] < 0: - ax.bar(data.index[i], hist[key], color = 'orange') + ax.bar(data.index[i], hist[key], color="orange") else: - ax.bar(data.index[i], hist[key], color = 'black') + ax.bar(data.index[i], hist[key], color="black") data[stock].plot( xlabel="Date", diff --git a/tests/test_momentum_indicators.py b/tests/test_momentum_indicators.py index f3b091f6..e0164241 100644 --- a/tests/test_momentum_indicators.py +++ b/tests/test_momentum_indicators.py @@ -2,13 +2,12 @@ import numpy as np import pandas as pd -from finquant.momentum_indicators import ( - relative_strength_index as rsi, - macd, -) +from finquant.momentum_indicators import macd +from finquant.momentum_indicators import relative_strength_index as rsi plt.switch_backend("Agg") + def test_rsi(): x = np.sin(np.linspace(1, 10, 100)) xlabel_orig = "Date" @@ -18,23 +17,24 @@ def test_rsi(): rsi(df) # get data from axis object ax = plt.gca() - # ax.lines[0] is the data we passed to plot_bollinger_band + # ax.lines[0] is the data we passed to plot_bollinger_band line1 = ax.lines[0] stock_plot = line1.get_xydata() xlabel_plot = ax.get_xlabel() ylabel_plot = ax.get_ylabel() # tests - assert (df['Stock'].index.values == stock_plot[:, 0]).all() + assert (df["Stock"].index.values == stock_plot[:, 0]).all() assert (df["Stock"].values == stock_plot[:, 1]).all() assert xlabel_orig == xlabel_plot assert ylabel_orig == ylabel_plot - + + def test_rsi_standalone(): x = np.sin(np.linspace(1, 10, 100)) xlabel_orig = "Date" ylabel_orig = "RSI" - labels_orig = ['rsi'] - title_orig = 'RSI Plot' + labels_orig = ["rsi"] + title_orig = "RSI Plot" df = pd.DataFrame({"Stock": x}, index=np.linspace(1, 10, 100)) df.index.name = "Date" rsi(df, standalone=True) @@ -45,20 +45,21 @@ def test_rsi_standalone(): rsi_plot = line1.get_xydata() xlabel_plot = ax.get_xlabel() ylabel_plot = ax.get_ylabel() - print (xlabel_plot, ylabel_plot) + print(xlabel_plot, ylabel_plot) # tests - assert (df['rsi'].index.values == rsi_plot[:, 0]).all() + assert (df["rsi"].index.values == rsi_plot[:, 0]).all() # for comparing values, we need to remove nan - a, b = df['rsi'].values, rsi_plot[:, 1] + a, b = df["rsi"].values, rsi_plot[:, 1] a, b = map(lambda x: x[~np.isnan(x)], (a, b)) assert (a == b).all() labels_plot = ax.get_legend_handles_labels()[1] title_plot = ax.get_title() assert labels_plot == labels_orig assert xlabel_plot == xlabel_orig - assert ylabel_plot == ylabel_orig + assert ylabel_plot == ylabel_orig assert title_plot == title_orig + def test_macd(): x = np.sin(np.linspace(1, 10, 100)) xlabel_orig = "Date" @@ -68,19 +69,20 @@ def test_macd(): macd(df) # get data from axis object ax = plt.gca() - # ax.lines[0] is the data we passed to plot_bollinger_band + # ax.lines[0] is the data we passed to plot_bollinger_band line1 = ax.lines[0] stock_plot = line1.get_xydata() xlabel_plot = ax.get_xlabel() ylabel_plot = ax.get_ylabel() # tests - assert (df['Stock'].index.values == stock_plot[:, 0]).all() + assert (df["Stock"].index.values == stock_plot[:, 0]).all() assert (df["Stock"].values == stock_plot[:, 1]).all() assert xlabel_orig == xlabel_plot assert ylabel_orig == ylabel_plot - + + def test_macd_standalone(): - labels_orig = ['MACD', 'diff', 'SIGNAL'] + labels_orig = ["MACD", "diff", "SIGNAL"] x = np.sin(np.linspace(1, 10, 100)) xlabel_orig = "Date" ylabel_orig = "MACD" @@ -94,12 +96,12 @@ def test_macd_standalone(): ylabel_plot = ax.get_ylabel() assert labels_plot == labels_orig assert xlabel_plot == xlabel_orig - assert ylabel_plot == ylabel_orig + assert ylabel_plot == ylabel_orig # ax.lines[0] is macd data # ax.lines[1] is diff data # ax.lines[2] is macd_s data # tests - for i, key in ((0, 'macd'), (1, 'diff'), (2, 'macd_s')): + for i, key in ((0, "macd"), (1, "diff"), (2, "macd_s")): line = ax.lines[i] data_plot = line.get_xydata() # tests From 370d0b2682ea7d6fb039bca406084c37d7a99833 Mon Sep 17 00:00:00 2001 From: Frank Milthaler Date: Tue, 26 Sep 2023 14:10:41 +0200 Subject: [PATCH 07/33] levels should be >0 and <100 --- finquant/momentum_indicators.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/finquant/momentum_indicators.py b/finquant/momentum_indicators.py index b6f75217..27bb1256 100644 --- a/finquant/momentum_indicators.py +++ b/finquant/momentum_indicators.py @@ -38,8 +38,8 @@ def relative_strength_index( # validating levels if oversold >= overbought: raise ValueError("oversold level should be < overbought level") - if oversold >= 100 or overbought >= 100: - raise ValueError("levels should be < 100") + if not (0 < oversold < 100) or not(0 < overbought < 100): + raise ValueError("levels should be > 0 and < 100") # converting data to pd.DataFrame if it is a pd.Series (for subsequent function calls): if isinstance(data, pd.Series): data = data.to_frame() From f3871d97a68e45a7be471a1f4532cea13e684ec2 Mon Sep 17 00:00:00 2001 From: Frank Milthaler Date: Tue, 26 Sep 2023 14:40:47 +0200 Subject: [PATCH 08/33] minor modifications to relative_strength_index --- finquant/momentum_indicators.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/finquant/momentum_indicators.py b/finquant/momentum_indicators.py index 27bb1256..817325cd 100644 --- a/finquant/momentum_indicators.py +++ b/finquant/momentum_indicators.py @@ -16,7 +16,7 @@ def relative_strength_index( plotted along with the prices in another sub-graph for comparison. - Ref: https://www.investopedia.com/terms/r/rsi.asp + Ref: https://www.investopedia.com/terms/r/rsi.asp :Input :data: pandas.Series or pandas.DataFrame with stock prices in columns @@ -46,24 +46,22 @@ def relative_strength_index( # get the stock key stock = data.keys()[0] # calculate price differences - data["diff"] = data.diff(1) + data["diff"] = data.diff(periods=1) # calculate gains and losses - data["gain"] = data["diff"].clip(lower=0).round(2) - data["loss"] = data["diff"].clip(upper=0).abs().round(2) + data["gain"] = data["diff"].clip(lower=0) + data["loss"] = data["diff"].clip(upper=0).abs() # placeholder wl = window_length # calculate rolling window mean gains and losses data["avg_gain"] = data["gain"].rolling(window=wl, min_periods=wl).mean() data["avg_loss"] = data["loss"].rolling(window=wl, min_periods=wl).mean() # calculate WMS (wilder smoothing method) averages - for i, row in enumerate(data["avg_gain"].iloc[wl + 1 :]): - data["avg_gain"].iloc[i + wl + 1] = ( - data["avg_gain"].iloc[i + wl] * (wl - 1) + data["gain"].iloc[i + wl + 1] - ) / wl - for i, row in enumerate(data["avg_loss"].iloc[wl + 1 :]): - data["avg_loss"].iloc[i + wl + 1] = ( - data["avg_loss"].iloc[i + wl] * (wl - 1) + data["loss"].iloc[i + wl + 1] - ) / wl + lambda_wsm = lambda avg_gain_loss, gain_loss, window_length: (avg_gain_loss * (window_length - 1) + gain_loss) / window_length + # ignore SettingWithCopyWarning for the below operation + with pd.option_context('mode.chained_assignment', None): + for gain_or_loss in ["gain", "loss"]: + for i, row in enumerate(data[f"avg_{gain_or_loss}"].iloc[wl + 1:]): + data[f"avg_{gain_or_loss}"].iloc[i + wl + 1] = lambda_wsm(data[f"avg_{gain_or_loss}"].iloc[i + wl], data[gain_or_loss].iloc[i + wl + 1], wl) # calculate RS values data["rs"] = data["avg_gain"] / data["avg_loss"] # calculate RSI @@ -98,6 +96,7 @@ def relative_strength_index( legend=True, ) plt.legend() + return data["rsi"] def macd( From 3ac143b725cd9ab7c3dbfd766796221448ff0a9f Mon Sep 17 00:00:00 2001 From: Frank Milthaler Date: Tue, 26 Sep 2023 15:28:15 +0200 Subject: [PATCH 09/33] some changes to the rsi plots --- finquant/momentum_indicators.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/finquant/momentum_indicators.py b/finquant/momentum_indicators.py index 817325cd..c8631a9a 100644 --- a/finquant/momentum_indicators.py +++ b/finquant/momentum_indicators.py @@ -43,8 +43,6 @@ def relative_strength_index( # converting data to pd.DataFrame if it is a pd.Series (for subsequent function calls): if isinstance(data, pd.Series): data = data.to_frame() - # get the stock key - stock = data.keys()[0] # calculate price differences data["diff"] = data.diff(periods=1) # calculate gains and losses @@ -67,35 +65,37 @@ def relative_strength_index( # calculate RSI data["rsi"] = 100 - (100 / (1.0 + data["rs"])) # Plot it + stock_name = data.keys()[0] if standalone: # Single plot fig = plt.figure() ax = fig.add_subplot(111) - ax.axhline(y=oversold, color="g", linestyle="--") - ax.axhline(y=overbought, color="r", linestyle="--") + ax.axhline(y=overbought, color="r", linestyle="dashed", label="overbought") + ax.axhline(y=oversold, color="g", linestyle="dashed", label="oversold") + ax.set_ylim(0, 100) data["rsi"].plot(ylabel="RSI", xlabel="Date", ax=ax, grid=True) plt.title("RSI Plot") - plt.legend() + plt.legend(loc='center left', bbox_to_anchor=(1, 0.5)) else: # RSI against price in 2 plots fig, ax = plt.subplots(2, 1, sharex=True, sharey=False) - ax[0].axhline(y=oversold, color="g", linestyle="--") - ax[0].axhline(y=overbought, color="r", linestyle="--") + ax[0].axhline(y=overbought, color="r", linestyle="dashed", label="overbought") + ax[0].axhline(y=oversold, color="g", linestyle="dashed", label="oversold") ax[0].set_title("RSI + Price Plot") + ax[0].set_ylim(0, 100) # plot 2 graphs in 2 colors colors = plt.rcParams["axes.prop_cycle"]() data["rsi"].plot( ylabel="RSI", ax=ax[0], grid=True, color=next(colors)["color"], legend=True - ) - data[stock].plot( + ).legend(loc='center left', bbox_to_anchor=(1, 0.5)) + data[stock_name].plot( xlabel="Date", ylabel="Price", ax=ax[1], grid=True, color=next(colors)["color"], legend=True, - ) - plt.legend() + ).legend(loc='center left', bbox_to_anchor=(1, 0.5)) return data["rsi"] From 2c684047cb198fc50ceb7e749176d9b6d3303850 Mon Sep 17 00:00:00 2001 From: Frank Milthaler Date: Tue, 26 Sep 2023 16:07:15 +0200 Subject: [PATCH 10/33] minor change for macd --- finquant/momentum_indicators.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/finquant/momentum_indicators.py b/finquant/momentum_indicators.py index c8631a9a..f0db7e12 100644 --- a/finquant/momentum_indicators.py +++ b/finquant/momentum_indicators.py @@ -139,8 +139,7 @@ def macd( # converting data to pd.DataFrame if it is a pd.Series (for subsequent function calls): if isinstance(data, pd.Series): data = data.to_frame() - # get the stock key - stock = data.keys()[0] + # calculate EMA short period ema_short = data.ewm( span=shorter_ema_window, adjust=False, min_periods=shorter_ema_window @@ -162,6 +161,7 @@ def macd( hist = data["diff"] # Plot it + stock_name = data.keys()[0] if standalone: fig = plt.figure() ax = fig.add_subplot(111) @@ -214,7 +214,7 @@ def macd( else: ax.bar(data.index[i], hist[key], color="black") - data[stock].plot( + data[stock_name].plot( xlabel="Date", ylabel="Price", ax=ax[1], From 594d427f47b68fa0af4f8aedfcbf61ea63ad0939 Mon Sep 17 00:00:00 2001 From: Frank Milthaler Date: Wed, 27 Sep 2023 15:35:15 +0200 Subject: [PATCH 11/33] adding mplfinance package to requirements for MACD plot --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 3216f4da..2fad55b9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ numpy>=1.22.0 scipy>=1.2.0 pandas>=2.0 matplotlib>=3.0 +mplfinance>=0.12.10b0 quandl>=3.4.5 yfinance>=0.1.43 scikit-learn>=1.3.0 \ No newline at end of file From 8d8748ff99a42042b81ddd7851c72d255729d9ac Mon Sep 17 00:00:00 2001 From: Frank Milthaler Date: Sun, 1 Oct 2023 15:40:52 +0200 Subject: [PATCH 12/33] Changing MACD to the example from mplfinance and adjusting functions for new type validations. Also changing docstrings. --- finquant/momentum_indicators.py | 276 +++++++++++++++++++------------- 1 file changed, 161 insertions(+), 115 deletions(-) diff --git a/finquant/momentum_indicators.py b/finquant/momentum_indicators.py index f0db7e12..f8c96557 100644 --- a/finquant/momentum_indicators.py +++ b/finquant/momentum_indicators.py @@ -1,15 +1,21 @@ """ This module provides function(s) to compute momentum indicators used in technical analysis such as RSI, MACD etc. """ +import datetime import matplotlib.pyplot as plt +import mplfinance as mpf import pandas as pd +from finquant.utils import all_list_ele_in_other +from finquant.data_types import INT, SERIES_OR_DATAFRAME +from finquant.type_utilities import type_validation +from typing import List def relative_strength_index( - data, - window_length: int = 14, - oversold: int = 30, - overbought: int = 70, + data: SERIES_OR_DATAFRAME, + window_length: INT = 14, + oversold: INT = 30, + overbought: INT = 70, standalone: bool = False, ) -> None: """Computes and visualizes a RSI graph, @@ -18,12 +24,15 @@ def relative_strength_index( Ref: https://www.investopedia.com/terms/r/rsi.asp - :Input - :data: pandas.Series or pandas.DataFrame with stock prices in columns - :window_length: Window length to compute RSI, default being 14 days - :oversold: Standard level for oversold RSI, default being 30 - :overbought: Standard level for overbought RSI, default being 70 - :standalone: Plot only the RSI graph + :param data: A series/dataframe of daily stock prices + :type data: :py:data:`~.finquant.data_types.SERIES_OR_DATAFRAME` + :param window_length: Window length to compute RSI, default being 14 days + :type window_length: :py:data:`~.finquant.data_types.INT` + :param oversold: Standard level for oversold RSI, default being 30 + :type oversold: :py:data:`~.finquant.data_types.INT` + :param overbought: Standard level for overbought RSI, default being 70 + :type overbought: :py:data:`~.finquant.data_types.INT` + :param standalone: Plot only the RSI graph """ if not isinstance(data, (pd.Series, pd.DataFrame)): raise ValueError( @@ -99,127 +108,164 @@ def relative_strength_index( return data["rsi"] -def macd( - data, - longer_ema_window: int = 26, - shorter_ema_window: int = 12, - signal_ema_window: int = 9, - standalone: bool = False, +#Generating colors for MACD histogram +def gen_macd_color(df: pd.DataFrame) -> List[str]: + """ + Generate a list of color codes based on MACD histogram values in a DataFrame. + + This function takes a DataFrame containing MACD histogram values ('MACDh') and + assigns colors to each data point based on the direction of change in MACD values. + + :param df: A series/dataframe of MACD histogram values + + :return: A list of color codes corresponding to each data point in the DataFrame. + + Note: + - This function assumes that the DataFrame contains a column named 'MACDh'. + - The color assignments are based on the comparison of each data point with its + previous data point in the 'MACDh' column. + + Example usage: + ``` + import pandas as pd + from typing import List + + # Create a DataFrame with MACD histogram values + df = pd.DataFrame({'MACDh': [0.5, -0.2, 0.8, -0.6, 0.2]}) + + # Generate MACD color codes + colors = gen_macd_color(df) + print(colors) # Output: ['#26A69A', '#FFCDD2', '#26A69A', '#FFCDD2', '#26A69A'] + ``` + """ + type_validation(df=df) + macd_color = [] + macd_color.clear() + for i in range (0, len(df["MACDh"])): + if df["MACDh"].iloc[i] >= 0 and df["MACDh"].iloc[i-1] < df["MACDh"].iloc[i]: + macd_color.append('#26A69A') # green + elif df["MACDh"].iloc[i] >= 0 and df["MACDh"].iloc[i-1] > df["MACDh"].iloc[i]: + macd_color.append('#B2DFDB') # faint green + elif df["MACDh"].iloc[i] < 0 and df["MACDh"].iloc[i-1] > df["MACDh"].iloc[i] : + macd_color.append('#FF5252') # red + elif df["MACDh"].iloc[i] < 0 and df["MACDh"].iloc[i-1] < df["MACDh"].iloc[i] : + macd_color.append('#FFCDD2') # faint red + else: + macd_color.append('#000000') + return macd_color + + +def mpl_macd( + data: SERIES_OR_DATAFRAME, + longer_ema_window: INT = 26, + shorter_ema_window: INT = 12, + signal_ema_window: INT = 9, + stock_name: str = None ) -> None: """ - Computes and visualizes a MACD (Moving Average Convergence Divergence) - plotted along with price chart in another sub-graph for comparison. + Generate a Matplotlib candlestick chart with MACD (Moving Average Convergence Divergence) indicators. + + Ref: https://github.com/matplotlib/mplfinance/blob/master/examples/indicators/macd_histogram_gradient.ipynb + + This function creates a candlestick chart using the given stock price data and overlays + MACD, MACD Signal Line, and MACD Histogram indicators. The MACD is calculated by taking + the difference between two Exponential Moving Averages (EMAs) of the closing price. + - Ref: https://www.alpharithms.com/calculate-macd-python-272222/ + :param data: Time series data containing stock price information. If a + DataFrame is provided, it should have columns 'Open', 'Close', 'High', 'Low', and 'Volume'. + Else, stock price data for given time frame is downloaded again. + :type data: :py:data:`~.finquant.data_types.SERIES_OR_DATAFRAME` + :param longer_ema_window: Optional, window size for the longer-term EMA (default is 26). + :type longer_ema_window: :py:data:`~.finquant.data_types.INT` + :param shorter_ema_window: Optional, window size for the shorter-term EMA (default is 12). + :type shorter_ema_window: :py:data:`~.finquant.data_types.INT` + :param signal_ema_window: Optional, window size for the signal line EMA (default is 9). + :type signal_ema_window: :py:data:`~.finquant.data_types.INT` + :param stock_name: Optional, name of the stock for labeling purposes (default is None). - :Input - :data: pandas.Series or pandas.DataFrame with stock prices in columns - :longer_ema_window: Window length (in days) for the longer EMA - :shorter_ema_window: Window length (in days) for the shorter EMA - :signal_ema_window: Window length (in days) for the signal - :standalone: If true, plot only the MACD signal + Note: + - If the input data is a DataFrame, it should contain columns 'Open', 'Close', 'High', 'Low', and 'Volume'. + - If the input data is a Series, it should have a valid name. + - The longer EMA window should be greater than or equal to the shorter EMA window and signal EMA window. + + Example usage: + ``` + import pandas as pd + from mplfinance.original_flavor import plot as mpf + + # Create a DataFrame or Series with stock price data + data = pd.read_csv('stock_data.csv', index_col='Date', parse_dates=True) + mpl_macd(data, longer_ema_window=26, shorter_ema_window=12, signal_ema_window=9, stock_name='AAPL') + ``` """ + # Type validations: + type_validation(data=data, longer_ema_window=longer_ema_window, shorter_ema_window=shorter_ema_window, signal_ema_window=signal_ema_window, name=stock_name) - if not isinstance(data, (pd.Series, pd.DataFrame)): - raise ValueError( - "data is expected to be of type pandas.Series or pandas.DataFrame" - ) - if isinstance(data, pd.DataFrame) and not len(data.columns.values) == 1: - raise ValueError("data is expected to have only one column.") - # checking integer fields - for field in (longer_ema_window, shorter_ema_window, signal_ema_window): - if not isinstance(field, int): - raise ValueError(f"{field} must be an integer.") # validating windows if longer_ema_window < shorter_ema_window: raise ValueError("longer ema window should be > shorter ema window") if longer_ema_window < signal_ema_window: raise ValueError("longer ema window should be > signal ema window") - # converting data to pd.DataFrame if it is a pd.Series (for subsequent function calls): + # Taking care of potential column header clash, removing "WIKI/" (which comes from legacy quandl) + if stock_name is None: + stock_name = data.name + if "WIKI/" in stock_name: + stock_name = stock_name.replace("WIKI/", "") if isinstance(data, pd.Series): data = data.to_frame() + # Remove prefix substring from column headers + data.columns = data.columns.str.replace("WIKI/", "") - # calculate EMA short period - ema_short = data.ewm( - span=shorter_ema_window, adjust=False, min_periods=shorter_ema_window - ).mean() - # calculate EMA long period - ema_long = data.ewm( - span=longer_ema_window, adjust=False, min_periods=longer_ema_window - ).mean() - # Subtract the longwer window EMA from the shorter window EMA to get the MACD - data["macd"] = ema_long - ema_short - # Get the signal window MACD for the Trigger line - data["macd_s"] = ( - data["macd"] - .ewm(span=signal_ema_window, adjust=False, min_periods=signal_ema_window) - .mean() - ) - # Calculate the difference between the MACD - Trigger for the Convergence/Divergence value - data["diff"] = data["macd"] - data["macd_s"] - hist = data["diff"] + # Check if required columns are present, if data is a pd.DataFrame, else re-download stock price data: + re_download_stock_data = True + if isinstance(data, pd.DataFrame) and all_list_ele_in_other(["Open", "Close", "High", "Low", "Volume"], data.columns): + re_download_stock_data = False + if re_download_stock_data: + # download additional price data 'Open' for given stock and timeframe: + from finquant.portfolio import _yfinance_request + start_date = data.index.min() - datetime.timedelta(days=31) + end_date = data.index.max() + datetime.timedelta(days=1) + df = _yfinance_request([stock_name], start_date=start_date, end_date=end_date) + # dropping second level of column header that yfinance returns + df.columns = df.columns.droplevel(1) + else: + df = data - # Plot it - stock_name = data.keys()[0] - if standalone: - fig = plt.figure() - ax = fig.add_subplot(111) - data["macd"].plot( - ylabel="MACD", - xlabel="Date", - ax=ax, - grid=True, - label="MACD", - color="green", - linewidth=1.5, - legend=True, - ) - hist.plot( - ax=ax, grid=True, label="diff", color="black", linewidth=0.5, legend=True - ) - data["macd_s"].plot( - ax=ax, grid=True, label="SIGNAL", color="red", linewidth=1.5, legend=True - ) + # Get the shorter_ema_window-day EMA of the closing price + k = df['Close'].ewm(span=shorter_ema_window, adjust=False, min_periods=shorter_ema_window).mean() + # Get the longer_ema_window-day EMA of the closing price + d = df['Close'].ewm(span=longer_ema_window, adjust=False, min_periods=longer_ema_window).mean() - for i, key in enumerate(hist.index): - if hist[key] < 0: - ax.bar(data.index[i], hist[key], color="orange") - else: - ax.bar(data.index[i], hist[key], color="black") - else: - # RSI against price in 2 plots - fig, ax = plt.subplots(2, 1, sharex=True, sharey=False) - ax[0].set_title("MACD + Price Plot") - data["macd"].plot( - ylabel="MACD", - xlabel="Date", - ax=ax[0], - grid=True, - label="MACD", - color="green", - linewidth=1.5, - legend=True, - ) - hist.plot( - ax=ax[0], grid=True, label="diff", color="black", linewidth=0.5, legend=True - ) - data["macd_s"].plot( - ax=ax[0], grid=True, label="SIGNAL", color="red", linewidth=1.5, legend=True - ) + # Subtract the longer_ema_window-day EMA from the shorter_ema_window-Day EMA to get the MACD + macd = k - d + # Get the signal_ema_window-Day EMA of the MACD for the Trigger line + macd_s = macd.ewm(span=signal_ema_window, adjust=False, min_periods=signal_ema_window).mean() + # Calculate the difference between the MACD - Trigger for the Convergence/Divergence value + macd_h = macd - macd_s - for i, key in enumerate(hist.index): - if hist[key] < 0: - ax.bar(data.index[i], hist[key], color="orange") - else: - ax.bar(data.index[i], hist[key], color="black") + # Add all of our new values for the MACD to the dataframe + df['MACD'] = df.index.map(macd) + df['MACDh'] = df.index.map(macd_h) + df['MACDs'] = df.index.map(macd_s) - data[stock_name].plot( - xlabel="Date", - ylabel="Price", - ax=ax[1], - grid=True, - color="orange", - legend=True, - ) - plt.legend() + # plot macd + macd_color = gen_macd_color(df) + apds = [ + mpf.make_addplot(macd, color='#2962FF', panel=1), + mpf.make_addplot(macd_s, color='#FF6D00', panel=1), + mpf.make_addplot(macd_h, type='bar', width=0.7, panel=1, color=macd_color, alpha=1, secondary_y=True), + ] + fig, axes = mpf.plot( + df, + volume=True, + type="candle", + style="yahoo", + addplot=apds, + volume_panel=2, + figsize=(20, 10), + returnfig=True + ) + axes[2].legend(["MACD"], loc='upper left') + axes[3].legend(["Signal"], loc='lower left') From 137ce1313e3e266b7710b107b6b6e0d574d95f65 Mon Sep 17 00:00:00 2001 From: Frank Milthaler Date: Sun, 1 Oct 2023 15:44:53 +0200 Subject: [PATCH 13/33] Adding utils module and adjusting type validation module --- finquant/type_utilities.py | 8 ++++++-- finquant/utils.py | 10 ++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 finquant/utils.py diff --git a/finquant/type_utilities.py b/finquant/type_utilities.py index 25d86fd7..3a874663 100644 --- a/finquant/type_utilities.py +++ b/finquant/type_utilities.py @@ -64,7 +64,7 @@ def _check_type( if element_type is not None: if isinstance(arg_values, pd.DataFrame) and not all( - arg_values.dtypes == element_type + [np.issubdtype(value_type, element_type) for value_type in arg_values.dtypes] ): validation_failed = True @@ -114,7 +114,7 @@ def _check_empty_data(arg_name: str, arg_values: Any) -> None: ], ] = { # DataFrames, Series, Array: - "data": ((pd.Series, pd.DataFrame), np.floating), + "data": ((pd.Series, pd.DataFrame), np.number), "pf_allocation": (pd.DataFrame, None), "returns_df": (pd.DataFrame, np.floating), "returns_series": (pd.Series, np.floating), @@ -124,6 +124,7 @@ def _check_empty_data(arg_name: str, arg_values: Any) -> None: "initial_weights": (np.ndarray, np.floating), "weights_array": (np.ndarray, np.floating), "cov_matrix": ((np.ndarray, pd.DataFrame), np.floating), + "df": (pd.DataFrame, None), # Lists: "names": ((List, np.ndarray), str), "cols": ((List, np.ndarray), str), @@ -150,6 +151,9 @@ def _check_empty_data(arg_name: str, arg_values: Any) -> None: "freq": ((int, np.integer), None), "span": ((int, np.integer), None), "num_trials": ((int, np.integer), None), + "longer_ema_window": ((int, np.integer), None), + "shorter_ema_window": ((int, np.integer), None), + "signal_ema_window": ((int, np.integer), None), # NUMERICs: "investment": ((int, np.integer, float, np.floating), None), "dividend": ((int, np.integer, float, np.floating), None), diff --git a/finquant/utils.py b/finquant/utils.py new file mode 100644 index 00000000..28d62f89 --- /dev/null +++ b/finquant/utils.py @@ -0,0 +1,10 @@ +from typing import List +from finquant.data_types import LIST_DICT_KEYS, ELEMENT_TYPE + + +def all_list_ele_in_other( + # l_1: List, l_2: List + l_1: LIST_DICT_KEYS[ELEMENT_TYPE], l_2: LIST_DICT_KEYS[ELEMENT_TYPE] +) -> bool: + """Returns True if all elements of list l1 are found in list l2.""" + return all(ele in l_2 for ele in l_1) From 674e663e0b110d12b6c8d90886c3292c89a852bb Mon Sep 17 00:00:00 2001 From: Frank Milthaler Date: Sun, 1 Oct 2023 15:46:32 +0200 Subject: [PATCH 14/33] Adjusting Example-Analysis --- example/Example-Analysis.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/example/Example-Analysis.py b/example/Example-Analysis.py index f7f342a7..23da6a4a 100644 --- a/example/Example-Analysis.py +++ b/example/Example-Analysis.py @@ -346,16 +346,15 @@ # # plot MACD for disney stock proces -from finquant.momentum_indicators import macd +from finquant.momentum_indicators import mpl_macd + +# using short time frame of data due to plot warnings from matplotlib/mplfinance +dis = dis[0: 300] # plot MACD - by default this plots RSI against the price in two graphs -macd(dis) +mpl_macd(dis) plt.show() # plot MACD using custom arguments -macd(dis, longer_ema_window=30, shorter_ema_window=15, signal_ema_window=10) -plt.show() - -# plot MACD standalone graph -macd(standlone=True) +mpl_macd(dis, longer_ema_window=30, shorter_ema_window=15, signal_ema_window=10) plt.show() From 44ba2075cd20080d050a919712d60310f700b2cd Mon Sep 17 00:00:00 2001 From: Frank Milthaler Date: Sun, 1 Oct 2023 15:49:01 +0200 Subject: [PATCH 15/33] Auto formatting --- example/Example-Analysis.py | 2 +- finquant/momentum_indicators.py | 109 +++++++++++++++++++++----------- finquant/type_utilities.py | 5 +- finquant/utils.py | 6 +- 4 files changed, 82 insertions(+), 40 deletions(-) diff --git a/example/Example-Analysis.py b/example/Example-Analysis.py index 23da6a4a..ebea765c 100644 --- a/example/Example-Analysis.py +++ b/example/Example-Analysis.py @@ -349,7 +349,7 @@ from finquant.momentum_indicators import mpl_macd # using short time frame of data due to plot warnings from matplotlib/mplfinance -dis = dis[0: 300] +dis = dis[0:300] # plot MACD - by default this plots RSI against the price in two graphs mpl_macd(dis) diff --git a/finquant/momentum_indicators.py b/finquant/momentum_indicators.py index f8c96557..cc1a1af2 100644 --- a/finquant/momentum_indicators.py +++ b/finquant/momentum_indicators.py @@ -1,15 +1,16 @@ """ This module provides function(s) to compute momentum indicators used in technical analysis such as RSI, MACD etc. """ import datetime +from typing import List import matplotlib.pyplot as plt import mplfinance as mpf import pandas as pd -from finquant.utils import all_list_ele_in_other from finquant.data_types import INT, SERIES_OR_DATAFRAME from finquant.type_utilities import type_validation -from typing import List +from finquant.utils import all_list_ele_in_other + def relative_strength_index( data: SERIES_OR_DATAFRAME, @@ -47,7 +48,7 @@ def relative_strength_index( # validating levels if oversold >= overbought: raise ValueError("oversold level should be < overbought level") - if not (0 < oversold < 100) or not(0 < overbought < 100): + if not (0 < oversold < 100) or not (0 < overbought < 100): raise ValueError("levels should be > 0 and < 100") # converting data to pd.DataFrame if it is a pd.Series (for subsequent function calls): if isinstance(data, pd.Series): @@ -63,12 +64,21 @@ def relative_strength_index( data["avg_gain"] = data["gain"].rolling(window=wl, min_periods=wl).mean() data["avg_loss"] = data["loss"].rolling(window=wl, min_periods=wl).mean() # calculate WMS (wilder smoothing method) averages - lambda_wsm = lambda avg_gain_loss, gain_loss, window_length: (avg_gain_loss * (window_length - 1) + gain_loss) / window_length + lambda_wsm = ( + lambda avg_gain_loss, gain_loss, window_length: ( + avg_gain_loss * (window_length - 1) + gain_loss + ) + / window_length + ) # ignore SettingWithCopyWarning for the below operation - with pd.option_context('mode.chained_assignment', None): + with pd.option_context("mode.chained_assignment", None): for gain_or_loss in ["gain", "loss"]: - for i, row in enumerate(data[f"avg_{gain_or_loss}"].iloc[wl + 1:]): - data[f"avg_{gain_or_loss}"].iloc[i + wl + 1] = lambda_wsm(data[f"avg_{gain_or_loss}"].iloc[i + wl], data[gain_or_loss].iloc[i + wl + 1], wl) + for i, row in enumerate(data[f"avg_{gain_or_loss}"].iloc[wl + 1 :]): + data[f"avg_{gain_or_loss}"].iloc[i + wl + 1] = lambda_wsm( + data[f"avg_{gain_or_loss}"].iloc[i + wl], + data[gain_or_loss].iloc[i + wl + 1], + wl, + ) # calculate RS values data["rs"] = data["avg_gain"] / data["avg_loss"] # calculate RSI @@ -84,7 +94,7 @@ def relative_strength_index( ax.set_ylim(0, 100) data["rsi"].plot(ylabel="RSI", xlabel="Date", ax=ax, grid=True) plt.title("RSI Plot") - plt.legend(loc='center left', bbox_to_anchor=(1, 0.5)) + plt.legend(loc="center left", bbox_to_anchor=(1, 0.5)) else: # RSI against price in 2 plots fig, ax = plt.subplots(2, 1, sharex=True, sharey=False) @@ -96,7 +106,7 @@ def relative_strength_index( colors = plt.rcParams["axes.prop_cycle"]() data["rsi"].plot( ylabel="RSI", ax=ax[0], grid=True, color=next(colors)["color"], legend=True - ).legend(loc='center left', bbox_to_anchor=(1, 0.5)) + ).legend(loc="center left", bbox_to_anchor=(1, 0.5)) data[stock_name].plot( xlabel="Date", ylabel="Price", @@ -104,11 +114,11 @@ def relative_strength_index( grid=True, color=next(colors)["color"], legend=True, - ).legend(loc='center left', bbox_to_anchor=(1, 0.5)) + ).legend(loc="center left", bbox_to_anchor=(1, 0.5)) return data["rsi"] -#Generating colors for MACD histogram +# Generating colors for MACD histogram def gen_macd_color(df: pd.DataFrame) -> List[str]: """ Generate a list of color codes based on MACD histogram values in a DataFrame. @@ -141,17 +151,17 @@ def gen_macd_color(df: pd.DataFrame) -> List[str]: type_validation(df=df) macd_color = [] macd_color.clear() - for i in range (0, len(df["MACDh"])): - if df["MACDh"].iloc[i] >= 0 and df["MACDh"].iloc[i-1] < df["MACDh"].iloc[i]: - macd_color.append('#26A69A') # green - elif df["MACDh"].iloc[i] >= 0 and df["MACDh"].iloc[i-1] > df["MACDh"].iloc[i]: - macd_color.append('#B2DFDB') # faint green - elif df["MACDh"].iloc[i] < 0 and df["MACDh"].iloc[i-1] > df["MACDh"].iloc[i] : - macd_color.append('#FF5252') # red - elif df["MACDh"].iloc[i] < 0 and df["MACDh"].iloc[i-1] < df["MACDh"].iloc[i] : - macd_color.append('#FFCDD2') # faint red + for i in range(0, len(df["MACDh"])): + if df["MACDh"].iloc[i] >= 0 and df["MACDh"].iloc[i - 1] < df["MACDh"].iloc[i]: + macd_color.append("#26A69A") # green + elif df["MACDh"].iloc[i] >= 0 and df["MACDh"].iloc[i - 1] > df["MACDh"].iloc[i]: + macd_color.append("#B2DFDB") # faint green + elif df["MACDh"].iloc[i] < 0 and df["MACDh"].iloc[i - 1] > df["MACDh"].iloc[i]: + macd_color.append("#FF5252") # red + elif df["MACDh"].iloc[i] < 0 and df["MACDh"].iloc[i - 1] < df["MACDh"].iloc[i]: + macd_color.append("#FFCDD2") # faint red else: - macd_color.append('#000000') + macd_color.append("#000000") return macd_color @@ -160,7 +170,7 @@ def mpl_macd( longer_ema_window: INT = 26, shorter_ema_window: INT = 12, signal_ema_window: INT = 9, - stock_name: str = None + stock_name: str = None, ) -> None: """ Generate a Matplotlib candlestick chart with MACD (Moving Average Convergence Divergence) indicators. @@ -200,7 +210,13 @@ def mpl_macd( ``` """ # Type validations: - type_validation(data=data, longer_ema_window=longer_ema_window, shorter_ema_window=shorter_ema_window, signal_ema_window=signal_ema_window, name=stock_name) + type_validation( + data=data, + longer_ema_window=longer_ema_window, + shorter_ema_window=shorter_ema_window, + signal_ema_window=signal_ema_window, + name=stock_name, + ) # validating windows if longer_ema_window < shorter_ema_window: @@ -220,11 +236,14 @@ def mpl_macd( # Check if required columns are present, if data is a pd.DataFrame, else re-download stock price data: re_download_stock_data = True - if isinstance(data, pd.DataFrame) and all_list_ele_in_other(["Open", "Close", "High", "Low", "Volume"], data.columns): + if isinstance(data, pd.DataFrame) and all_list_ele_in_other( + ["Open", "Close", "High", "Low", "Volume"], data.columns + ): re_download_stock_data = False if re_download_stock_data: # download additional price data 'Open' for given stock and timeframe: from finquant.portfolio import _yfinance_request + start_date = data.index.min() - datetime.timedelta(days=31) end_date = data.index.max() + datetime.timedelta(days=1) df = _yfinance_request([stock_name], start_date=start_date, end_date=end_date) @@ -234,28 +253,46 @@ def mpl_macd( df = data # Get the shorter_ema_window-day EMA of the closing price - k = df['Close'].ewm(span=shorter_ema_window, adjust=False, min_periods=shorter_ema_window).mean() + k = ( + df["Close"] + .ewm(span=shorter_ema_window, adjust=False, min_periods=shorter_ema_window) + .mean() + ) # Get the longer_ema_window-day EMA of the closing price - d = df['Close'].ewm(span=longer_ema_window, adjust=False, min_periods=longer_ema_window).mean() + d = ( + df["Close"] + .ewm(span=longer_ema_window, adjust=False, min_periods=longer_ema_window) + .mean() + ) # Subtract the longer_ema_window-day EMA from the shorter_ema_window-Day EMA to get the MACD macd = k - d # Get the signal_ema_window-Day EMA of the MACD for the Trigger line - macd_s = macd.ewm(span=signal_ema_window, adjust=False, min_periods=signal_ema_window).mean() + macd_s = macd.ewm( + span=signal_ema_window, adjust=False, min_periods=signal_ema_window + ).mean() # Calculate the difference between the MACD - Trigger for the Convergence/Divergence value macd_h = macd - macd_s # Add all of our new values for the MACD to the dataframe - df['MACD'] = df.index.map(macd) - df['MACDh'] = df.index.map(macd_h) - df['MACDs'] = df.index.map(macd_s) + df["MACD"] = df.index.map(macd) + df["MACDh"] = df.index.map(macd_h) + df["MACDs"] = df.index.map(macd_s) # plot macd macd_color = gen_macd_color(df) apds = [ - mpf.make_addplot(macd, color='#2962FF', panel=1), - mpf.make_addplot(macd_s, color='#FF6D00', panel=1), - mpf.make_addplot(macd_h, type='bar', width=0.7, panel=1, color=macd_color, alpha=1, secondary_y=True), + mpf.make_addplot(macd, color="#2962FF", panel=1), + mpf.make_addplot(macd_s, color="#FF6D00", panel=1), + mpf.make_addplot( + macd_h, + type="bar", + width=0.7, + panel=1, + color=macd_color, + alpha=1, + secondary_y=True, + ), ] fig, axes = mpf.plot( df, @@ -265,7 +302,7 @@ def mpl_macd( addplot=apds, volume_panel=2, figsize=(20, 10), - returnfig=True + returnfig=True, ) - axes[2].legend(["MACD"], loc='upper left') - axes[3].legend(["Signal"], loc='lower left') + axes[2].legend(["MACD"], loc="upper left") + axes[3].legend(["Signal"], loc="lower left") diff --git a/finquant/type_utilities.py b/finquant/type_utilities.py index 3a874663..5925a3a6 100644 --- a/finquant/type_utilities.py +++ b/finquant/type_utilities.py @@ -64,7 +64,10 @@ def _check_type( if element_type is not None: if isinstance(arg_values, pd.DataFrame) and not all( - [np.issubdtype(value_type, element_type) for value_type in arg_values.dtypes] + [ + np.issubdtype(value_type, element_type) + for value_type in arg_values.dtypes + ] ): validation_failed = True diff --git a/finquant/utils.py b/finquant/utils.py index 28d62f89..630e8758 100644 --- a/finquant/utils.py +++ b/finquant/utils.py @@ -1,10 +1,12 @@ from typing import List -from finquant.data_types import LIST_DICT_KEYS, ELEMENT_TYPE + +from finquant.data_types import ELEMENT_TYPE, LIST_DICT_KEYS def all_list_ele_in_other( # l_1: List, l_2: List - l_1: LIST_DICT_KEYS[ELEMENT_TYPE], l_2: LIST_DICT_KEYS[ELEMENT_TYPE] + l_1: LIST_DICT_KEYS[ELEMENT_TYPE], + l_2: LIST_DICT_KEYS[ELEMENT_TYPE], ) -> bool: """Returns True if all elements of list l1 are found in list l2.""" return all(ele in l_2 for ele in l_1) From 15d6927ebcc5bcfad89eb3606c8cbf132c5c7284 Mon Sep 17 00:00:00 2001 From: Frank Milthaler Date: Sun, 1 Oct 2023 16:38:30 +0200 Subject: [PATCH 16/33] Return fig and axes for testing purposes --- finquant/momentum_indicators.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/finquant/momentum_indicators.py b/finquant/momentum_indicators.py index cc1a1af2..14a607ee 100644 --- a/finquant/momentum_indicators.py +++ b/finquant/momentum_indicators.py @@ -171,7 +171,7 @@ def mpl_macd( shorter_ema_window: INT = 12, signal_ema_window: INT = 9, stock_name: str = None, -) -> None: +): """ Generate a Matplotlib candlestick chart with MACD (Moving Average Convergence Divergence) indicators. @@ -306,3 +306,5 @@ def mpl_macd( ) axes[2].legend(["MACD"], loc="upper left") axes[3].legend(["Signal"], loc="lower left") + + return fig, axes From 7fed1bf3435645a013812ecb9b8de65e710aec2e Mon Sep 17 00:00:00 2001 From: Frank Milthaler Date: Sun, 1 Oct 2023 16:38:47 +0200 Subject: [PATCH 17/33] fixing tests --- tests/test_momentum_indicators.py | 80 ++++++++++++------------------- 1 file changed, 31 insertions(+), 49 deletions(-) diff --git a/tests/test_momentum_indicators.py b/tests/test_momentum_indicators.py index e0164241..db0853bd 100644 --- a/tests/test_momentum_indicators.py +++ b/tests/test_momentum_indicators.py @@ -1,8 +1,9 @@ import matplotlib.pyplot as plt import numpy as np import pandas as pd +import pytest -from finquant.momentum_indicators import macd +from finquant.momentum_indicators import mpl_macd from finquant.momentum_indicators import relative_strength_index as rsi plt.switch_backend("Agg") @@ -17,7 +18,6 @@ def test_rsi(): rsi(df) # get data from axis object ax = plt.gca() - # ax.lines[0] is the data we passed to plot_bollinger_band line1 = ax.lines[0] stock_plot = line1.get_xydata() xlabel_plot = ax.get_xlabel() @@ -33,7 +33,7 @@ def test_rsi_standalone(): x = np.sin(np.linspace(1, 10, 100)) xlabel_orig = "Date" ylabel_orig = "RSI" - labels_orig = ["rsi"] + labels_orig = ["overbought", "oversold", "rsi"] title_orig = "RSI Plot" df = pd.DataFrame({"Stock": x}, index=np.linspace(1, 10, 100)) df.index.name = "Date" @@ -60,53 +60,35 @@ def test_rsi_standalone(): assert title_plot == title_orig -def test_macd(): +def test_mpl_macd(): + axes0_ylabel_orig = "Price" + axes4_ylabel_orig = "Volume $10^{6}$" + # Create sample data for testing x = np.sin(np.linspace(1, 10, 100)) - xlabel_orig = "Date" - ylabel_orig = "Price" - df = pd.DataFrame({"Stock": x}, index=np.linspace(1, 10, 100)) - df.index.name = "Date" - macd(df) - # get data from axis object - ax = plt.gca() - # ax.lines[0] is the data we passed to plot_bollinger_band - line1 = ax.lines[0] - stock_plot = line1.get_xydata() - xlabel_plot = ax.get_xlabel() - ylabel_plot = ax.get_ylabel() - # tests - assert (df["Stock"].index.values == stock_plot[:, 0]).all() - assert (df["Stock"].values == stock_plot[:, 1]).all() - assert xlabel_orig == xlabel_plot - assert ylabel_orig == ylabel_plot + df = pd.DataFrame({"Close": x}, index=pd.date_range("2015-01-01", periods=100, freq="D")) + df.name = "DIS" + # Call mpl_macd function + fig, axes = mpl_macd(df) -def test_macd_standalone(): - labels_orig = ["MACD", "diff", "SIGNAL"] + axes0_ylabel_plot = axes[0].get_ylabel() + axes4_ylabel_plot = axes[4].get_ylabel() + + # Check if the function returned valid figures and axes objects + assert isinstance(fig, plt.Figure) + assert isinstance(axes, list) + assert len(axes) == 6 # Assuming there are six subplots in the returned figure + assert axes0_ylabel_orig == axes0_ylabel_plot + assert axes4_ylabel_orig == axes4_ylabel_plot + +def test_mpl_macd_invalid_window_parameters(): + # Create sample data with invalid window parameters x = np.sin(np.linspace(1, 10, 100)) - xlabel_orig = "Date" - ylabel_orig = "MACD" - df = pd.DataFrame({"Stock": x}, index=np.linspace(1, 10, 100)) - df.index.name = "Date" - macd(df, standalone=True) - # get data from axis object - ax = plt.gca() - labels_plot = ax.get_legend_handles_labels()[1] - xlabel_plot = ax.get_xlabel() - ylabel_plot = ax.get_ylabel() - assert labels_plot == labels_orig - assert xlabel_plot == xlabel_orig - assert ylabel_plot == ylabel_orig - # ax.lines[0] is macd data - # ax.lines[1] is diff data - # ax.lines[2] is macd_s data - # tests - for i, key in ((0, "macd"), (1, "diff"), (2, "macd_s")): - line = ax.lines[i] - data_plot = line.get_xydata() - # tests - assert (df[key].index.values == data_plot[:, 0]).all() - # for comparing values, we need to remove nan - a, b = df[key].values, data_plot[:, 1] - a, b = map(lambda x: x[~np.isnan(x)], (a, b)) - assert (a == b).all() + df = pd.DataFrame({"Close": x}, index=pd.date_range("2015-01-01", periods=100, freq="D")) + df.name = "DIS" + + # Call mpl_macd function with invalid window parameters and check for ValueError + with pytest.raises(ValueError): + mpl_macd(df, longer_ema_window=10, shorter_ema_window=20, signal_ema_window=30) + with pytest.raises(ValueError): + mpl_macd(df, longer_ema_window=10, shorter_ema_window=5, signal_ema_window=30) From 1860dd8c260f9436030b2e3e8aa7e962311620f1 Mon Sep 17 00:00:00 2001 From: Frank Milthaler Date: Sun, 1 Oct 2023 16:39:48 +0200 Subject: [PATCH 18/33] Auto formatting --- tests/test_momentum_indicators.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/test_momentum_indicators.py b/tests/test_momentum_indicators.py index db0853bd..5afedeaa 100644 --- a/tests/test_momentum_indicators.py +++ b/tests/test_momentum_indicators.py @@ -65,7 +65,9 @@ def test_mpl_macd(): axes4_ylabel_orig = "Volume $10^{6}$" # Create sample data for testing x = np.sin(np.linspace(1, 10, 100)) - df = pd.DataFrame({"Close": x}, index=pd.date_range("2015-01-01", periods=100, freq="D")) + df = pd.DataFrame( + {"Close": x}, index=pd.date_range("2015-01-01", periods=100, freq="D") + ) df.name = "DIS" # Call mpl_macd function @@ -81,10 +83,13 @@ def test_mpl_macd(): assert axes0_ylabel_orig == axes0_ylabel_plot assert axes4_ylabel_orig == axes4_ylabel_plot + def test_mpl_macd_invalid_window_parameters(): # Create sample data with invalid window parameters x = np.sin(np.linspace(1, 10, 100)) - df = pd.DataFrame({"Close": x}, index=pd.date_range("2015-01-01", periods=100, freq="D")) + df = pd.DataFrame( + {"Close": x}, index=pd.date_range("2015-01-01", periods=100, freq="D") + ) df.name = "DIS" # Call mpl_macd function with invalid window parameters and check for ValueError From 3bd40e1e26923bca416e81de5f63b5e4f5585863 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 1 Oct 2023 14:40:29 +0000 Subject: [PATCH 19/33] Automated version changes --- version | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version b/version index 7ad36c63..37902e99 100644 --- a/version +++ b/version @@ -1,2 +1,2 @@ -version=0.7.0 -release=0.7.0 +version=0.8.0 +release=0.8.0 From 419dd02eb982fe856fab3201882335247721450b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 1 Oct 2023 14:40:29 +0000 Subject: [PATCH 20/33] Updating README files --- README.md | 2 +- README.tex.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0f1b8cc1..b72c34fc 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ pypi - pypi + pypi GitHub Actions diff --git a/README.tex.md b/README.tex.md index 6dd741fd..48721624 100644 --- a/README.tex.md +++ b/README.tex.md @@ -7,7 +7,7 @@ pypi - pypi + pypi GitHub Actions From 88f07fe445226ec7eee70f613a60defb305f5451 Mon Sep 17 00:00:00 2001 From: Frank Milthaler Date: Sun, 1 Oct 2023 17:04:16 +0200 Subject: [PATCH 21/33] Fixing MatplotlibDeprecationWarning --- tests/test_momentum_indicators.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_momentum_indicators.py b/tests/test_momentum_indicators.py index 5afedeaa..7e0789fa 100644 --- a/tests/test_momentum_indicators.py +++ b/tests/test_momentum_indicators.py @@ -6,6 +6,7 @@ from finquant.momentum_indicators import mpl_macd from finquant.momentum_indicators import relative_strength_index as rsi +plt.close("all") plt.switch_backend("Agg") From 05d701f8224e0124e79ab4a2dde537d1034f66c9 Mon Sep 17 00:00:00 2001 From: Frank Milthaler Date: Sun, 1 Oct 2023 17:23:25 +0200 Subject: [PATCH 22/33] fixing pylint issues --- finquant/momentum_indicators.py | 77 +++++++++++++++++---------------- finquant/type_utilities.py | 6 +-- 2 files changed, 41 insertions(+), 42 deletions(-) diff --git a/finquant/momentum_indicators.py b/finquant/momentum_indicators.py index 14a607ee..3ad964b4 100644 --- a/finquant/momentum_indicators.py +++ b/finquant/momentum_indicators.py @@ -10,6 +10,10 @@ from finquant.data_types import INT, SERIES_OR_DATAFRAME from finquant.type_utilities import type_validation from finquant.utils import all_list_ele_in_other +from finquant.portfolio import _yfinance_request + +def calculate_wilder_smoothing_averages(avg_gain_loss, gain_loss, window_length): + return (avg_gain_loss * (window_length - 1) + gain_loss) / window_length def relative_strength_index( @@ -48,7 +52,7 @@ def relative_strength_index( # validating levels if oversold >= overbought: raise ValueError("oversold level should be < overbought level") - if not (0 < oversold < 100) or not (0 < overbought < 100): + if not 0 < oversold < 100 or not 0 < overbought < 100: raise ValueError("levels should be > 0 and < 100") # converting data to pd.DataFrame if it is a pd.Series (for subsequent function calls): if isinstance(data, pd.Series): @@ -58,26 +62,25 @@ def relative_strength_index( # calculate gains and losses data["gain"] = data["diff"].clip(lower=0) data["loss"] = data["diff"].clip(upper=0).abs() - # placeholder - wl = window_length # calculate rolling window mean gains and losses - data["avg_gain"] = data["gain"].rolling(window=wl, min_periods=wl).mean() - data["avg_loss"] = data["loss"].rolling(window=wl, min_periods=wl).mean() - # calculate WMS (wilder smoothing method) averages - lambda_wsm = ( - lambda avg_gain_loss, gain_loss, window_length: ( - avg_gain_loss * (window_length - 1) + gain_loss - ) - / window_length + data["avg_gain"] = ( + data["gain"].rolling(window=window_length, min_periods=window_length).mean() + ) + data["avg_loss"] = ( + data["loss"].rolling(window=window_length, min_periods=window_length).mean() ) # ignore SettingWithCopyWarning for the below operation with pd.option_context("mode.chained_assignment", None): for gain_or_loss in ["gain", "loss"]: - for i, row in enumerate(data[f"avg_{gain_or_loss}"].iloc[wl + 1 :]): - data[f"avg_{gain_or_loss}"].iloc[i + wl + 1] = lambda_wsm( - data[f"avg_{gain_or_loss}"].iloc[i + wl], - data[gain_or_loss].iloc[i + wl + 1], - wl, + for idx, _ in enumerate( + data[f"avg_{gain_or_loss}"].iloc[window_length + 1 :] + ): + data[f"avg_{gain_or_loss}"].iloc[ + idx + window_length + 1 + ] = calculate_wilder_smoothing_averages( + data[f"avg_{gain_or_loss}"].iloc[idx + window_length], + data[gain_or_loss].iloc[idx + window_length + 1], + window_length, ) # calculate RS values data["rs"] = data["avg_gain"] / data["avg_loss"] @@ -88,29 +91,29 @@ def relative_strength_index( if standalone: # Single plot fig = plt.figure() - ax = fig.add_subplot(111) - ax.axhline(y=overbought, color="r", linestyle="dashed", label="overbought") - ax.axhline(y=oversold, color="g", linestyle="dashed", label="oversold") - ax.set_ylim(0, 100) - data["rsi"].plot(ylabel="RSI", xlabel="Date", ax=ax, grid=True) + axis = fig.add_subplot(111) + axis.axhline(y=overbought, color="r", linestyle="dashed", label="overbought") + axis.axhline(y=oversold, color="g", linestyle="dashed", label="oversold") + axis.set_ylim(0, 100) + data["rsi"].plot(ylabel="RSI", xlabel="Date", ax=axis, grid=True) plt.title("RSI Plot") plt.legend(loc="center left", bbox_to_anchor=(1, 0.5)) else: # RSI against price in 2 plots - fig, ax = plt.subplots(2, 1, sharex=True, sharey=False) - ax[0].axhline(y=overbought, color="r", linestyle="dashed", label="overbought") - ax[0].axhline(y=oversold, color="g", linestyle="dashed", label="oversold") - ax[0].set_title("RSI + Price Plot") - ax[0].set_ylim(0, 100) + fig, axis = plt.subplots(2, 1, sharex=True, sharey=False) + axis[0].axhline(y=overbought, color="r", linestyle="dashed", label="overbought") + axis[0].axhline(y=oversold, color="g", linestyle="dashed", label="oversold") + axis[0].set_title("RSI + Price Plot") + axis[0].set_ylim(0, 100) # plot 2 graphs in 2 colors colors = plt.rcParams["axes.prop_cycle"]() data["rsi"].plot( - ylabel="RSI", ax=ax[0], grid=True, color=next(colors)["color"], legend=True + ylabel="RSI", ax=axis[0], grid=True, color=next(colors)["color"], legend=True ).legend(loc="center left", bbox_to_anchor=(1, 0.5)) data[stock_name].plot( xlabel="Date", ylabel="Price", - ax=ax[1], + ax=axis[1], grid=True, color=next(colors)["color"], legend=True, @@ -151,14 +154,14 @@ def gen_macd_color(df: pd.DataFrame) -> List[str]: type_validation(df=df) macd_color = [] macd_color.clear() - for i in range(0, len(df["MACDh"])): - if df["MACDh"].iloc[i] >= 0 and df["MACDh"].iloc[i - 1] < df["MACDh"].iloc[i]: + for idx in range(0, len(df["MACDh"])): + if df["MACDh"].iloc[idx] >= 0 and df["MACDh"].iloc[idx - 1] < df["MACDh"].iloc[idx]: macd_color.append("#26A69A") # green - elif df["MACDh"].iloc[i] >= 0 and df["MACDh"].iloc[i - 1] > df["MACDh"].iloc[i]: + elif df["MACDh"].iloc[idx] >= 0 and df["MACDh"].iloc[idx - 1] > df["MACDh"].iloc[idx]: macd_color.append("#B2DFDB") # faint green - elif df["MACDh"].iloc[i] < 0 and df["MACDh"].iloc[i - 1] > df["MACDh"].iloc[i]: + elif df["MACDh"].iloc[idx] < 0 and df["MACDh"].iloc[idx - 1] > df["MACDh"].iloc[idx]: macd_color.append("#FF5252") # red - elif df["MACDh"].iloc[i] < 0 and df["MACDh"].iloc[i - 1] < df["MACDh"].iloc[i]: + elif df["MACDh"].iloc[idx] < 0 and df["MACDh"].iloc[idx - 1] < df["MACDh"].iloc[idx]: macd_color.append("#FFCDD2") # faint red else: macd_color.append("#000000") @@ -242,8 +245,6 @@ def mpl_macd( re_download_stock_data = False if re_download_stock_data: # download additional price data 'Open' for given stock and timeframe: - from finquant.portfolio import _yfinance_request - start_date = data.index.min() - datetime.timedelta(days=31) end_date = data.index.max() + datetime.timedelta(days=1) df = _yfinance_request([stock_name], start_date=start_date, end_date=end_date) @@ -253,20 +254,20 @@ def mpl_macd( df = data # Get the shorter_ema_window-day EMA of the closing price - k = ( + macd_k = ( df["Close"] .ewm(span=shorter_ema_window, adjust=False, min_periods=shorter_ema_window) .mean() ) # Get the longer_ema_window-day EMA of the closing price - d = ( + macd_d = ( df["Close"] .ewm(span=longer_ema_window, adjust=False, min_periods=longer_ema_window) .mean() ) # Subtract the longer_ema_window-day EMA from the shorter_ema_window-Day EMA to get the MACD - macd = k - d + macd = macd_k - macd_d # Get the signal_ema_window-Day EMA of the MACD for the Trigger line macd_s = macd.ewm( span=signal_ema_window, adjust=False, min_periods=signal_ema_window diff --git a/finquant/type_utilities.py b/finquant/type_utilities.py index 5925a3a6..749449bd 100644 --- a/finquant/type_utilities.py +++ b/finquant/type_utilities.py @@ -64,10 +64,8 @@ def _check_type( if element_type is not None: if isinstance(arg_values, pd.DataFrame) and not all( - [ - np.issubdtype(value_type, element_type) - for value_type in arg_values.dtypes - ] + np.issubdtype(value_type, element_type) + for value_type in arg_values.dtypes ): validation_failed = True From 0006823a746c570c6359f241ead80c465760a258 Mon Sep 17 00:00:00 2001 From: Frank Milthaler Date: Sun, 1 Oct 2023 17:34:22 +0200 Subject: [PATCH 23/33] Auto formatting and adding docstrings --- finquant/momentum_indicators.py | 101 ++++++++++++++++++++++++-------- 1 file changed, 75 insertions(+), 26 deletions(-) diff --git a/finquant/momentum_indicators.py b/finquant/momentum_indicators.py index 3ad964b4..28fa783f 100644 --- a/finquant/momentum_indicators.py +++ b/finquant/momentum_indicators.py @@ -7,12 +7,41 @@ import mplfinance as mpf import pandas as pd -from finquant.data_types import INT, SERIES_OR_DATAFRAME +from finquant.data_types import FLOAT, INT, SERIES_OR_DATAFRAME +from finquant.portfolio import _yfinance_request from finquant.type_utilities import type_validation from finquant.utils import all_list_ele_in_other -from finquant.portfolio import _yfinance_request -def calculate_wilder_smoothing_averages(avg_gain_loss, gain_loss, window_length): + +def calculate_wilder_smoothing_averages( + avg_gain_loss: FLOAT, gain_loss: FLOAT, window_length: INT +) -> FLOAT: + """ + Calculate Wilder's Smoothing Averages. + + Wilder's Smoothing Averages are used in technical analysis, particularly for + calculating indicators like the Relative Strength Index (RSI). This function + takes the average gain/loss, the current gain/loss, and the window length as + input and returns the smoothed average. + + :param avg_gain_loss: The previous average gain/loss. + :type avg_gain_loss: :py:data:`~.finquant.data_types.FLOAT` + :param gain_loss: The current gain or loss. + :type gain_loss: :py:data:`~.finquant.data_types.FLOAT` + :param window_length: The length of the smoothing window. + :type window_length: :py:data:`~.finquant.data_types.FLOAT` + + :return: The Wilder's smoothed average value. + :rtype: :py:data:`~.finquant.data_types.FLOAT` + + Example: + + .. code-block:: python + + calculate_wilder_smoothing_averages(10.0, 5.0, 14) + + """ + return (avg_gain_loss * (window_length - 1) + gain_loss) / window_length @@ -108,7 +137,11 @@ def relative_strength_index( # plot 2 graphs in 2 colors colors = plt.rcParams["axes.prop_cycle"]() data["rsi"].plot( - ylabel="RSI", ax=axis[0], grid=True, color=next(colors)["color"], legend=True + ylabel="RSI", + ax=axis[0], + grid=True, + color=next(colors)["color"], + legend=True, ).legend(loc="center left", bbox_to_anchor=(1, 0.5)) data[stock_name].plot( xlabel="Date", @@ -138,30 +171,44 @@ def gen_macd_color(df: pd.DataFrame) -> List[str]: - The color assignments are based on the comparison of each data point with its previous data point in the 'MACDh' column. - Example usage: - ``` - import pandas as pd - from typing import List + Example: + + .. code-block:: python - # Create a DataFrame with MACD histogram values - df = pd.DataFrame({'MACDh': [0.5, -0.2, 0.8, -0.6, 0.2]}) + import pandas as pd + from typing import List + + # Create a DataFrame with MACD histogram values + df = pd.DataFrame({'MACDh': [0.5, -0.2, 0.8, -0.6, 0.2]}) + + # Generate MACD color codes + colors = gen_macd_color(df) + print(colors) # Output: ['#26A69A', '#FFCDD2', '#26A69A', '#FFCDD2', '#26A69A'] - # Generate MACD color codes - colors = gen_macd_color(df) - print(colors) # Output: ['#26A69A', '#FFCDD2', '#26A69A', '#FFCDD2', '#26A69A'] - ``` """ type_validation(df=df) macd_color = [] macd_color.clear() for idx in range(0, len(df["MACDh"])): - if df["MACDh"].iloc[idx] >= 0 and df["MACDh"].iloc[idx - 1] < df["MACDh"].iloc[idx]: + if ( + df["MACDh"].iloc[idx] >= 0 + and df["MACDh"].iloc[idx - 1] < df["MACDh"].iloc[idx] + ): macd_color.append("#26A69A") # green - elif df["MACDh"].iloc[idx] >= 0 and df["MACDh"].iloc[idx - 1] > df["MACDh"].iloc[idx]: + elif ( + df["MACDh"].iloc[idx] >= 0 + and df["MACDh"].iloc[idx - 1] > df["MACDh"].iloc[idx] + ): macd_color.append("#B2DFDB") # faint green - elif df["MACDh"].iloc[idx] < 0 and df["MACDh"].iloc[idx - 1] > df["MACDh"].iloc[idx]: + elif ( + df["MACDh"].iloc[idx] < 0 + and df["MACDh"].iloc[idx - 1] > df["MACDh"].iloc[idx] + ): macd_color.append("#FF5252") # red - elif df["MACDh"].iloc[idx] < 0 and df["MACDh"].iloc[idx - 1] < df["MACDh"].iloc[idx]: + elif ( + df["MACDh"].iloc[idx] < 0 + and df["MACDh"].iloc[idx - 1] < df["MACDh"].iloc[idx] + ): macd_color.append("#FFCDD2") # faint red else: macd_color.append("#000000") @@ -202,15 +249,17 @@ def mpl_macd( - If the input data is a Series, it should have a valid name. - The longer EMA window should be greater than or equal to the shorter EMA window and signal EMA window. - Example usage: - ``` - import pandas as pd - from mplfinance.original_flavor import plot as mpf + Example: + + .. code-block:: python + + import pandas as pd + from mplfinance.original_flavor import plot as mpf + + # Create a DataFrame or Series with stock price data + data = pd.read_csv('stock_data.csv', index_col='Date', parse_dates=True) + mpl_macd(data, longer_ema_window=26, shorter_ema_window=12, signal_ema_window=9, stock_name='DIS') - # Create a DataFrame or Series with stock price data - data = pd.read_csv('stock_data.csv', index_col='Date', parse_dates=True) - mpl_macd(data, longer_ema_window=26, shorter_ema_window=12, signal_ema_window=9, stock_name='AAPL') - ``` """ # Type validations: type_validation( From d1cc80f9ba9738166de8db14d14ed877c8289a9a Mon Sep 17 00:00:00 2001 From: Frank Milthaler Date: Sun, 1 Oct 2023 17:43:51 +0200 Subject: [PATCH 24/33] Some changes to momentum_indicators to satisfy mypy. More needed. --- finquant/momentum_indicators.py | 19 +++++++++---------- finquant/type_utilities.py | 3 +-- finquant/utils.py | 3 --- 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/finquant/momentum_indicators.py b/finquant/momentum_indicators.py index 28fa783f..b65dca4c 100644 --- a/finquant/momentum_indicators.py +++ b/finquant/momentum_indicators.py @@ -1,7 +1,7 @@ """ This module provides function(s) to compute momentum indicators used in technical analysis such as RSI, MACD etc. """ import datetime -from typing import List +from typing import List, Optional import matplotlib.pyplot as plt import mplfinance as mpf @@ -121,8 +121,8 @@ def relative_strength_index( # Single plot fig = plt.figure() axis = fig.add_subplot(111) - axis.axhline(y=overbought, color="r", linestyle="dashed", label="overbought") - axis.axhline(y=oversold, color="g", linestyle="dashed", label="oversold") + axis.axhline(y=float(overbought), color="r", linestyle="dashed", label="overbought") + axis.axhline(y=float(oversold), color="g", linestyle="dashed", label="oversold") axis.set_ylim(0, 100) data["rsi"].plot(ylabel="RSI", xlabel="Date", ax=axis, grid=True) plt.title("RSI Plot") @@ -130,8 +130,8 @@ def relative_strength_index( else: # RSI against price in 2 plots fig, axis = plt.subplots(2, 1, sharex=True, sharey=False) - axis[0].axhline(y=overbought, color="r", linestyle="dashed", label="overbought") - axis[0].axhline(y=oversold, color="g", linestyle="dashed", label="oversold") + axis[0].axhline(y=float(overbought), color="r", linestyle="dashed", label="overbought") + axis[0].axhline(y=float(oversold), color="g", linestyle="dashed", label="oversold") axis[0].set_title("RSI + Price Plot") axis[0].set_ylim(0, 100) # plot 2 graphs in 2 colors @@ -217,10 +217,10 @@ def gen_macd_color(df: pd.DataFrame) -> List[str]: def mpl_macd( data: SERIES_OR_DATAFRAME, - longer_ema_window: INT = 26, - shorter_ema_window: INT = 12, - signal_ema_window: INT = 9, - stock_name: str = None, + longer_ema_window: Optional[INT] = 26, + shorter_ema_window: Optional[INT] = 12, + signal_ema_window: Optional[INT] = 9, + stock_name: Optional[str] = None, ): """ Generate a Matplotlib candlestick chart with MACD (Moving Average Convergence Divergence) indicators. @@ -231,7 +231,6 @@ def mpl_macd( MACD, MACD Signal Line, and MACD Histogram indicators. The MACD is calculated by taking the difference between two Exponential Moving Averages (EMAs) of the closing price. - :param data: Time series data containing stock price information. If a DataFrame is provided, it should have columns 'Open', 'Close', 'High', 'Low', and 'Volume'. Else, stock price data for given time frame is downloaded again. diff --git a/finquant/type_utilities.py b/finquant/type_utilities.py index 749449bd..b1692dea 100644 --- a/finquant/type_utilities.py +++ b/finquant/type_utilities.py @@ -64,8 +64,7 @@ def _check_type( if element_type is not None: if isinstance(arg_values, pd.DataFrame) and not all( - np.issubdtype(value_type, element_type) - for value_type in arg_values.dtypes + np.issubdtype(value_type, element_type) for value_type in arg_values.dtypes ): validation_failed = True diff --git a/finquant/utils.py b/finquant/utils.py index 630e8758..518c5ea7 100644 --- a/finquant/utils.py +++ b/finquant/utils.py @@ -1,10 +1,7 @@ -from typing import List - from finquant.data_types import ELEMENT_TYPE, LIST_DICT_KEYS def all_list_ele_in_other( - # l_1: List, l_2: List l_1: LIST_DICT_KEYS[ELEMENT_TYPE], l_2: LIST_DICT_KEYS[ELEMENT_TYPE], ) -> bool: From d359fad387a1b6020456368604888ca92268f20a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 1 Oct 2023 15:44:24 +0000 Subject: [PATCH 25/33] Automated formatting changes --- finquant/momentum_indicators.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/finquant/momentum_indicators.py b/finquant/momentum_indicators.py index b65dca4c..49f0f93a 100644 --- a/finquant/momentum_indicators.py +++ b/finquant/momentum_indicators.py @@ -121,7 +121,9 @@ def relative_strength_index( # Single plot fig = plt.figure() axis = fig.add_subplot(111) - axis.axhline(y=float(overbought), color="r", linestyle="dashed", label="overbought") + axis.axhline( + y=float(overbought), color="r", linestyle="dashed", label="overbought" + ) axis.axhline(y=float(oversold), color="g", linestyle="dashed", label="oversold") axis.set_ylim(0, 100) data["rsi"].plot(ylabel="RSI", xlabel="Date", ax=axis, grid=True) @@ -130,8 +132,12 @@ def relative_strength_index( else: # RSI against price in 2 plots fig, axis = plt.subplots(2, 1, sharex=True, sharey=False) - axis[0].axhline(y=float(overbought), color="r", linestyle="dashed", label="overbought") - axis[0].axhline(y=float(oversold), color="g", linestyle="dashed", label="oversold") + axis[0].axhline( + y=float(overbought), color="r", linestyle="dashed", label="overbought" + ) + axis[0].axhline( + y=float(oversold), color="g", linestyle="dashed", label="oversold" + ) axis[0].set_title("RSI + Price Plot") axis[0].set_ylim(0, 100) # plot 2 graphs in 2 colors From d967e231025d87d7312ed42235f16907a00b499f Mon Sep 17 00:00:00 2001 From: Frank Milthaler Date: Mon, 2 Oct 2023 12:15:11 +0200 Subject: [PATCH 26/33] renaming mpl_macd to plot_macd --- example/Example-Analysis.py | 6 +-- finquant/momentum_indicators.py | 61 +++++++++++++++---------------- tests/test_momentum_indicators.py | 8 ++-- 3 files changed, 37 insertions(+), 38 deletions(-) diff --git a/example/Example-Analysis.py b/example/Example-Analysis.py index ebea765c..f71ca1ba 100644 --- a/example/Example-Analysis.py +++ b/example/Example-Analysis.py @@ -346,15 +346,15 @@ # # plot MACD for disney stock proces -from finquant.momentum_indicators import mpl_macd +from finquant.momentum_indicators import plot_macd # using short time frame of data due to plot warnings from matplotlib/mplfinance dis = dis[0:300] # plot MACD - by default this plots RSI against the price in two graphs -mpl_macd(dis) +plot_macd(dis) plt.show() # plot MACD using custom arguments -mpl_macd(dis, longer_ema_window=30, shorter_ema_window=15, signal_ema_window=10) +plot_macd(dis, longer_ema_window=30, shorter_ema_window=15, signal_ema_window=10) plt.show() diff --git a/finquant/momentum_indicators.py b/finquant/momentum_indicators.py index 49f0f93a..03f6bf4f 100644 --- a/finquant/momentum_indicators.py +++ b/finquant/momentum_indicators.py @@ -13,36 +13,6 @@ from finquant.utils import all_list_ele_in_other -def calculate_wilder_smoothing_averages( - avg_gain_loss: FLOAT, gain_loss: FLOAT, window_length: INT -) -> FLOAT: - """ - Calculate Wilder's Smoothing Averages. - - Wilder's Smoothing Averages are used in technical analysis, particularly for - calculating indicators like the Relative Strength Index (RSI). This function - takes the average gain/loss, the current gain/loss, and the window length as - input and returns the smoothed average. - - :param avg_gain_loss: The previous average gain/loss. - :type avg_gain_loss: :py:data:`~.finquant.data_types.FLOAT` - :param gain_loss: The current gain or loss. - :type gain_loss: :py:data:`~.finquant.data_types.FLOAT` - :param window_length: The length of the smoothing window. - :type window_length: :py:data:`~.finquant.data_types.FLOAT` - - :return: The Wilder's smoothed average value. - :rtype: :py:data:`~.finquant.data_types.FLOAT` - - Example: - - .. code-block:: python - - calculate_wilder_smoothing_averages(10.0, 5.0, 14) - - """ - - return (avg_gain_loss * (window_length - 1) + gain_loss) / window_length def relative_strength_index( @@ -160,6 +130,35 @@ def relative_strength_index( return data["rsi"] +def calculate_wilder_smoothing_averages( + avg_gain_loss: FLOAT, gain_loss: FLOAT, window_length: INT +) -> FLOAT: + """ + Calculate Wilder's Smoothing Averages. + + Wilder's Smoothing Averages are used in technical analysis, particularly for + calculating indicators like the Relative Strength Index (RSI). This function + takes the average gain/loss, the current gain/loss, and the window length as + input and returns the smoothed average. + + :param avg_gain_loss: The previous average gain/loss. + :type avg_gain_loss: :py:data:`~.finquant.data_types.FLOAT` + :param gain_loss: The current gain or loss. + :type gain_loss: :py:data:`~.finquant.data_types.FLOAT` + :param window_length: The length of the smoothing window. + :type window_length: :py:data:`~.finquant.data_types.FLOAT` + + :return: The Wilder's smoothed average value. + :rtype: :py:data:`~.finquant.data_types.FLOAT` + + Example: + + .. code-block:: python + + calculate_wilder_smoothing_averages(10.0, 5.0, 14) + + """ + return (avg_gain_loss * (window_length - 1) + gain_loss) / window_length # Generating colors for MACD histogram def gen_macd_color(df: pd.DataFrame) -> List[str]: """ @@ -221,7 +220,7 @@ def gen_macd_color(df: pd.DataFrame) -> List[str]: return macd_color -def mpl_macd( +def plot_macd( data: SERIES_OR_DATAFRAME, longer_ema_window: Optional[INT] = 26, shorter_ema_window: Optional[INT] = 12, diff --git a/tests/test_momentum_indicators.py b/tests/test_momentum_indicators.py index 7e0789fa..ae0a6d4e 100644 --- a/tests/test_momentum_indicators.py +++ b/tests/test_momentum_indicators.py @@ -3,7 +3,7 @@ import pandas as pd import pytest -from finquant.momentum_indicators import mpl_macd +from finquant.momentum_indicators import plot_macd from finquant.momentum_indicators import relative_strength_index as rsi plt.close("all") @@ -72,7 +72,7 @@ def test_mpl_macd(): df.name = "DIS" # Call mpl_macd function - fig, axes = mpl_macd(df) + fig, axes = plot_macd(df) axes0_ylabel_plot = axes[0].get_ylabel() axes4_ylabel_plot = axes[4].get_ylabel() @@ -95,6 +95,6 @@ def test_mpl_macd_invalid_window_parameters(): # Call mpl_macd function with invalid window parameters and check for ValueError with pytest.raises(ValueError): - mpl_macd(df, longer_ema_window=10, shorter_ema_window=20, signal_ema_window=30) + plot_macd(df, longer_ema_window=10, shorter_ema_window=20, signal_ema_window=30) with pytest.raises(ValueError): - mpl_macd(df, longer_ema_window=10, shorter_ema_window=5, signal_ema_window=30) + plot_macd(df, longer_ema_window=10, shorter_ema_window=5, signal_ema_window=30) From 889d62b12030ce4a60aeb5f7ab3611847beddfef Mon Sep 17 00:00:00 2001 From: Frank Milthaler Date: Mon, 2 Oct 2023 12:34:33 +0200 Subject: [PATCH 27/33] renaming rsi function to plot_rsi --- example/Example-Analysis.py | 2 +- finquant/momentum_indicators.py | 2 +- tests/test_momentum_indicators.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/example/Example-Analysis.py b/example/Example-Analysis.py index f71ca1ba..79ca9f01 100644 --- a/example/Example-Analysis.py +++ b/example/Example-Analysis.py @@ -327,7 +327,7 @@ # # plot the RSI (Relative Strength Index) for disney stock proces -from finquant.momentum_indicators import relative_strength_index as rsi +from finquant.momentum_indicators import plot_relative_strength_index as rsi # get stock data for disney dis = pf.get_stock("WIKI/DIS").data.copy(deep=True) diff --git a/finquant/momentum_indicators.py b/finquant/momentum_indicators.py index 03f6bf4f..59cd8124 100644 --- a/finquant/momentum_indicators.py +++ b/finquant/momentum_indicators.py @@ -15,7 +15,6 @@ -def relative_strength_index( data: SERIES_OR_DATAFRAME, window_length: INT = 14, oversold: INT = 30, @@ -85,6 +84,7 @@ def relative_strength_index( data["rs"] = data["avg_gain"] / data["avg_loss"] # calculate RSI data["rsi"] = 100 - (100 / (1.0 + data["rs"])) +def plot_relative_strength_index( # Plot it stock_name = data.keys()[0] if standalone: diff --git a/tests/test_momentum_indicators.py b/tests/test_momentum_indicators.py index ae0a6d4e..ebe01f70 100644 --- a/tests/test_momentum_indicators.py +++ b/tests/test_momentum_indicators.py @@ -4,7 +4,7 @@ import pytest from finquant.momentum_indicators import plot_macd -from finquant.momentum_indicators import relative_strength_index as rsi +from finquant.momentum_indicators import plot_relative_strength_index as rsi plt.close("all") plt.switch_backend("Agg") From 8dace7ae401f2f6c0f40561679d150314505e869 Mon Sep 17 00:00:00 2001 From: Frank Milthaler Date: Mon, 2 Oct 2023 13:13:03 +0200 Subject: [PATCH 28/33] refactoring momentum_indicators code --- finquant/momentum_indicators.py | 263 ++++++++++++++++++++------------ finquant/type_utilities.py | 4 + 2 files changed, 168 insertions(+), 99 deletions(-) diff --git a/finquant/momentum_indicators.py b/finquant/momentum_indicators.py index 59cd8124..a8a603a9 100644 --- a/finquant/momentum_indicators.py +++ b/finquant/momentum_indicators.py @@ -13,17 +13,44 @@ from finquant.utils import all_list_ele_in_other +def calculate_wilder_smoothing_averages( + avg_gain_loss: FLOAT, gain_loss: FLOAT, window_length: INT +) -> FLOAT: + """ + Calculate Wilder's Smoothing Averages. + + Wilder's Smoothing Averages are used in technical analysis, particularly for + calculating indicators like the Relative Strength Index (RSI). This function + takes the average gain/loss, the current gain/loss, and the window length as + input and returns the smoothed average. + + :param avg_gain_loss: The previous average gain/loss. + :type avg_gain_loss: :py:data:`~.finquant.data_types.FLOAT` + :param gain_loss: The current gain or loss. + :type gain_loss: :py:data:`~.finquant.data_types.FLOAT` + :param window_length: The length of the smoothing window. + :type window_length: :py:data:`~.finquant.data_types.FLOAT` + + :return: The Wilder's smoothed average value. + :rtype: :py:data:`~.finquant.data_types.FLOAT` + + Example: + + .. code-block:: python + + calculate_wilder_smoothing_averages(10.0, 5.0, 14) + + """ + return (avg_gain_loss * (window_length - 1) + gain_loss) / window_length +def calculate_relative_strength_index( data: SERIES_OR_DATAFRAME, window_length: INT = 14, oversold: INT = 30, overbought: INT = 70, - standalone: bool = False, -) -> None: - """Computes and visualizes a RSI graph, - plotted along with the prices in another sub-graph - for comparison. +) -> pd.Series: + """Computes the relative strength index of given stock price data. Ref: https://www.investopedia.com/terms/r/rsi.asp @@ -35,26 +62,26 @@ :type oversold: :py:data:`~.finquant.data_types.INT` :param overbought: Standard level for overbought RSI, default being 70 :type overbought: :py:data:`~.finquant.data_types.INT` - :param standalone: Plot only the RSI graph + + :return: A Series of RSI values. """ - if not isinstance(data, (pd.Series, pd.DataFrame)): - raise ValueError( - "data is expected to be of type pandas.Series or pandas.DataFrame" - ) - if isinstance(data, pd.DataFrame) and not len(data.columns.values) == 1: - raise ValueError("data is expected to have only one column.") - # checking integer fields - for field in (window_length, oversold, overbought): - if not isinstance(field, int): - raise ValueError(f"{field} must be an integer.") + # Type validations: + type_validation( + data=data, + window_length=window_length, + oversold=oversold, + overbought=overbought, + ) # validating levels if oversold >= overbought: raise ValueError("oversold level should be < overbought level") if not 0 < oversold < 100 or not 0 < overbought < 100: raise ValueError("levels should be > 0 and < 100") + # converting data to pd.DataFrame if it is a pd.Series (for subsequent function calls): if isinstance(data, pd.Series): data = data.to_frame() + # calculate price differences data["diff"] = data.diff(periods=1) # calculate gains and losses @@ -84,9 +111,47 @@ data["rs"] = data["avg_gain"] / data["avg_loss"] # calculate RSI data["rsi"] = 100 - (100 / (1.0 + data["rs"])) + return data["rsi"] + def plot_relative_strength_index( - # Plot it + data: SERIES_OR_DATAFRAME, + window_length: INT = 14, + oversold: INT = 30, + overbought: INT = 70, + standalone: bool = False, +) -> None: + """Computes and visualizes a RSI graph, + plotted along with the prices in another sub-graph + for comparison. + + Ref: https://www.investopedia.com/terms/r/rsi.asp + + :param data: A series/dataframe of daily stock prices + :type data: :py:data:`~.finquant.data_types.SERIES_OR_DATAFRAME` + :param window_length: Window length to compute RSI, default being 14 days + :type window_length: :py:data:`~.finquant.data_types.INT` + :param oversold: Standard level for oversold RSI, default being 30 + :type oversold: :py:data:`~.finquant.data_types.INT` + :param overbought: Standard level for overbought RSI, default being 70 + :type overbought: :py:data:`~.finquant.data_types.INT` + :param standalone: Plot only the RSI graph + """ + + # converting data to pd.DataFrame if it is a pd.Series (for subsequent function calls): + if isinstance(data, pd.Series): + data = data.to_frame() + # Get stock name: stock_name = data.keys()[0] + + # compute RSI: + data["rsi"] = calculate_relative_strength_index( + data, + window_length=window_length, + oversold=oversold, + overbought=overbought + ) + + # Plot it if standalone: # Single plot fig = plt.figure() @@ -127,38 +192,8 @@ def plot_relative_strength_index( color=next(colors)["color"], legend=True, ).legend(loc="center left", bbox_to_anchor=(1, 0.5)) - return data["rsi"] - - -def calculate_wilder_smoothing_averages( - avg_gain_loss: FLOAT, gain_loss: FLOAT, window_length: INT -) -> FLOAT: - """ - Calculate Wilder's Smoothing Averages. - - Wilder's Smoothing Averages are used in technical analysis, particularly for - calculating indicators like the Relative Strength Index (RSI). This function - takes the average gain/loss, the current gain/loss, and the window length as - input and returns the smoothed average. - - :param avg_gain_loss: The previous average gain/loss. - :type avg_gain_loss: :py:data:`~.finquant.data_types.FLOAT` - :param gain_loss: The current gain or loss. - :type gain_loss: :py:data:`~.finquant.data_types.FLOAT` - :param window_length: The length of the smoothing window. - :type window_length: :py:data:`~.finquant.data_types.FLOAT` - - :return: The Wilder's smoothed average value. - :rtype: :py:data:`~.finquant.data_types.FLOAT` - - Example: - - .. code-block:: python - calculate_wilder_smoothing_averages(10.0, 5.0, 14) - """ - return (avg_gain_loss * (window_length - 1) + gain_loss) / window_length # Generating colors for MACD histogram def gen_macd_color(df: pd.DataFrame) -> List[str]: """ @@ -191,6 +226,7 @@ def gen_macd_color(df: pd.DataFrame) -> List[str]: print(colors) # Output: ['#26A69A', '#FFCDD2', '#26A69A', '#FFCDD2', '#26A69A'] """ + # Type validations: type_validation(df=df) macd_color = [] macd_color.clear() @@ -220,51 +256,31 @@ def gen_macd_color(df: pd.DataFrame) -> List[str]: return macd_color -def plot_macd( +def re_download_stock_data( + data: SERIES_OR_DATAFRAME, + stock_name: str +) -> pd.DataFrame: + # Type validations: + type_validation( + data=data, + name=stock_name, + ) + # download additional price data 'Open' for given stock and timeframe: + start_date = data.index.min() - datetime.timedelta(days=31) + end_date = data.index.max() + datetime.timedelta(days=1) + df = _yfinance_request([stock_name], start_date=start_date, end_date=end_date) + # dropping second level of column header that yfinance returns + df.columns = df.columns.droplevel(1) + return df + + +def calculate_macd( data: SERIES_OR_DATAFRAME, longer_ema_window: Optional[INT] = 26, shorter_ema_window: Optional[INT] = 12, signal_ema_window: Optional[INT] = 9, stock_name: Optional[str] = None, -): - """ - Generate a Matplotlib candlestick chart with MACD (Moving Average Convergence Divergence) indicators. - - Ref: https://github.com/matplotlib/mplfinance/blob/master/examples/indicators/macd_histogram_gradient.ipynb - - This function creates a candlestick chart using the given stock price data and overlays - MACD, MACD Signal Line, and MACD Histogram indicators. The MACD is calculated by taking - the difference between two Exponential Moving Averages (EMAs) of the closing price. - - :param data: Time series data containing stock price information. If a - DataFrame is provided, it should have columns 'Open', 'Close', 'High', 'Low', and 'Volume'. - Else, stock price data for given time frame is downloaded again. - :type data: :py:data:`~.finquant.data_types.SERIES_OR_DATAFRAME` - :param longer_ema_window: Optional, window size for the longer-term EMA (default is 26). - :type longer_ema_window: :py:data:`~.finquant.data_types.INT` - :param shorter_ema_window: Optional, window size for the shorter-term EMA (default is 12). - :type shorter_ema_window: :py:data:`~.finquant.data_types.INT` - :param signal_ema_window: Optional, window size for the signal line EMA (default is 9). - :type signal_ema_window: :py:data:`~.finquant.data_types.INT` - :param stock_name: Optional, name of the stock for labeling purposes (default is None). - - Note: - - If the input data is a DataFrame, it should contain columns 'Open', 'Close', 'High', 'Low', and 'Volume'. - - If the input data is a Series, it should have a valid name. - - The longer EMA window should be greater than or equal to the shorter EMA window and signal EMA window. - - Example: - - .. code-block:: python - - import pandas as pd - from mplfinance.original_flavor import plot as mpf - - # Create a DataFrame or Series with stock price data - data = pd.read_csv('stock_data.csv', index_col='Date', parse_dates=True) - mpl_macd(data, longer_ema_window=26, shorter_ema_window=12, signal_ema_window=9, stock_name='DIS') - - """ +) -> pd.DataFrame: # Type validations: type_validation( data=data, @@ -291,18 +307,13 @@ def plot_macd( data.columns = data.columns.str.replace("WIKI/", "") # Check if required columns are present, if data is a pd.DataFrame, else re-download stock price data: - re_download_stock_data = True + download_stock_data_again = True if isinstance(data, pd.DataFrame) and all_list_ele_in_other( ["Open", "Close", "High", "Low", "Volume"], data.columns ): - re_download_stock_data = False - if re_download_stock_data: - # download additional price data 'Open' for given stock and timeframe: - start_date = data.index.min() - datetime.timedelta(days=31) - end_date = data.index.max() + datetime.timedelta(days=1) - df = _yfinance_request([stock_name], start_date=start_date, end_date=end_date) - # dropping second level of column header that yfinance returns - df.columns = df.columns.droplevel(1) + download_stock_data_again = False + if download_stock_data_again: + df = re_download_stock_data(data, stock_name=stock_name) else: df = data @@ -332,14 +343,68 @@ def plot_macd( df["MACD"] = df.index.map(macd) df["MACDh"] = df.index.map(macd_h) df["MACDs"] = df.index.map(macd_s) + return df + +def plot_macd( + data: SERIES_OR_DATAFRAME, + longer_ema_window: Optional[INT] = 26, + shorter_ema_window: Optional[INT] = 12, + signal_ema_window: Optional[INT] = 9, + stock_name: Optional[str] = None, +): + """ + Generate a Matplotlib candlestick chart with MACD (Moving Average Convergence Divergence) indicators. + + Ref: https://github.com/matplotlib/mplfinance/blob/master/examples/indicators/macd_histogram_gradient.ipynb + + This function creates a candlestick chart using the given stock price data and overlays + MACD, MACD Signal Line, and MACD Histogram indicators. The MACD is calculated by taking + the difference between two Exponential Moving Averages (EMAs) of the closing price. + + :param data: Time series data containing stock price information. If a + DataFrame is provided, it should have columns 'Open', 'Close', 'High', 'Low', and 'Volume'. + Else, stock price data for given time frame is downloaded again. + :type data: :py:data:`~.finquant.data_types.SERIES_OR_DATAFRAME` + :param longer_ema_window: Optional, window size for the longer-term EMA (default is 26). + :type longer_ema_window: :py:data:`~.finquant.data_types.INT` + :param shorter_ema_window: Optional, window size for the shorter-term EMA (default is 12). + :type shorter_ema_window: :py:data:`~.finquant.data_types.INT` + :param signal_ema_window: Optional, window size for the signal line EMA (default is 9). + :type signal_ema_window: :py:data:`~.finquant.data_types.INT` + :param stock_name: Optional, name of the stock for labeling purposes (default is None). + + Note: + - If the input data is a DataFrame, it should contain columns 'Open', 'Close', 'High', 'Low', and 'Volume'. + - If the input data is a Series, it should have a valid name. + - The longer EMA window should be greater than or equal to the shorter EMA window and signal EMA window. + + Example: + + .. code-block:: python + + import pandas as pd + from mplfinance.original_flavor import plot as mpf + + # Create a DataFrame or Series with stock price data + data = pd.read_csv('stock_data.csv', index_col='Date', parse_dates=True) + plot_macd(data, longer_ema_window=26, shorter_ema_window=12, signal_ema_window=9, stock_name='DIS') + + """ + # calculate MACD: + df = calculate_macd( + data, + longer_ema_window, + shorter_ema_window, + signal_ema_window + ) # plot macd macd_color = gen_macd_color(df) apds = [ - mpf.make_addplot(macd, color="#2962FF", panel=1), - mpf.make_addplot(macd_s, color="#FF6D00", panel=1), + mpf.make_addplot(df["MACD"], color="#2962FF", panel=1), + mpf.make_addplot(df["MACDs"], color="#FF6D00", panel=1), mpf.make_addplot( - macd_h, + df["MACDh"], type="bar", width=0.7, panel=1, diff --git a/finquant/type_utilities.py b/finquant/type_utilities.py index b1692dea..f51eae7e 100644 --- a/finquant/type_utilities.py +++ b/finquant/type_utilities.py @@ -154,6 +154,9 @@ def _check_empty_data(arg_name: str, arg_values: Any) -> None: "longer_ema_window": ((int, np.integer), None), "shorter_ema_window": ((int, np.integer), None), "signal_ema_window": ((int, np.integer), None), + "window_length": ((int, np.integer), None), + "oversold": ((int, np.integer), None), + "overbought": ((int, np.integer), None), # NUMERICs: "investment": ((int, np.integer, float, np.floating), None), "dividend": ((int, np.integer, float, np.floating), None), @@ -163,6 +166,7 @@ def _check_empty_data(arg_name: str, arg_values: Any) -> None: "save_weights": (bool, None), "verbose": (bool, None), "defer_update": (bool, None), + "standalone": (bool, None), } type_callable_dict: Dict[ From 4ee796c164842e73681da452c019b46e0dfd2539 Mon Sep 17 00:00:00 2001 From: Frank Milthaler Date: Mon, 2 Oct 2023 13:15:44 +0200 Subject: [PATCH 29/33] refactor --- finquant/momentum_indicators.py | 20 +------------------- finquant/utils.py | 26 +++++++++++++++++++++++++- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/finquant/momentum_indicators.py b/finquant/momentum_indicators.py index a8a603a9..a4a0800d 100644 --- a/finquant/momentum_indicators.py +++ b/finquant/momentum_indicators.py @@ -10,7 +10,7 @@ from finquant.data_types import FLOAT, INT, SERIES_OR_DATAFRAME from finquant.portfolio import _yfinance_request from finquant.type_utilities import type_validation -from finquant.utils import all_list_ele_in_other +from finquant.utils import all_list_ele_in_other, re_download_stock_data def calculate_wilder_smoothing_averages( @@ -256,24 +256,6 @@ def gen_macd_color(df: pd.DataFrame) -> List[str]: return macd_color -def re_download_stock_data( - data: SERIES_OR_DATAFRAME, - stock_name: str -) -> pd.DataFrame: - # Type validations: - type_validation( - data=data, - name=stock_name, - ) - # download additional price data 'Open' for given stock and timeframe: - start_date = data.index.min() - datetime.timedelta(days=31) - end_date = data.index.max() + datetime.timedelta(days=1) - df = _yfinance_request([stock_name], start_date=start_date, end_date=end_date) - # dropping second level of column header that yfinance returns - df.columns = df.columns.droplevel(1) - return df - - def calculate_macd( data: SERIES_OR_DATAFRAME, longer_ema_window: Optional[INT] = 26, diff --git a/finquant/utils.py b/finquant/utils.py index 518c5ea7..4b4ef43a 100644 --- a/finquant/utils.py +++ b/finquant/utils.py @@ -1,4 +1,10 @@ -from finquant.data_types import ELEMENT_TYPE, LIST_DICT_KEYS +import datetime + +import pandas as pd + +from finquant.data_types import ELEMENT_TYPE, LIST_DICT_KEYS, SERIES_OR_DATAFRAME +from finquant.portfolio import _yfinance_request +from finquant.type_utilities import type_validation def all_list_ele_in_other( @@ -7,3 +13,21 @@ def all_list_ele_in_other( ) -> bool: """Returns True if all elements of list l1 are found in list l2.""" return all(ele in l_2 for ele in l_1) + + +def re_download_stock_data( + data: SERIES_OR_DATAFRAME, + stock_name: str +) -> pd.DataFrame: + # Type validations: + type_validation( + data=data, + name=stock_name, + ) + # download additional price data 'Open' for given stock and timeframe: + start_date = data.index.min() - datetime.timedelta(days=31) + end_date = data.index.max() + datetime.timedelta(days=1) + df = _yfinance_request([stock_name], start_date=start_date, end_date=end_date) + # dropping second level of column header that yfinance returns + df.columns = df.columns.droplevel(1) + return df From 11e689088b277c8159fe00d83a0474d10c683c7d Mon Sep 17 00:00:00 2001 From: Frank Milthaler Date: Mon, 2 Oct 2023 13:18:08 +0200 Subject: [PATCH 30/33] autoformat --- finquant/momentum_indicators.py | 14 ++++---------- finquant/utils.py | 10 ++-------- 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/finquant/momentum_indicators.py b/finquant/momentum_indicators.py index a4a0800d..9b192c00 100644 --- a/finquant/momentum_indicators.py +++ b/finquant/momentum_indicators.py @@ -113,6 +113,7 @@ def calculate_relative_strength_index( data["rsi"] = 100 - (100 / (1.0 + data["rs"])) return data["rsi"] + def plot_relative_strength_index( data: SERIES_OR_DATAFRAME, window_length: INT = 14, @@ -145,10 +146,7 @@ def plot_relative_strength_index( # compute RSI: data["rsi"] = calculate_relative_strength_index( - data, - window_length=window_length, - oversold=oversold, - overbought=overbought + data, window_length=window_length, oversold=oversold, overbought=overbought ) # Plot it @@ -327,6 +325,7 @@ def calculate_macd( df["MACDs"] = df.index.map(macd_s) return df + def plot_macd( data: SERIES_OR_DATAFRAME, longer_ema_window: Optional[INT] = 26, @@ -373,12 +372,7 @@ def plot_macd( """ # calculate MACD: - df = calculate_macd( - data, - longer_ema_window, - shorter_ema_window, - signal_ema_window - ) + df = calculate_macd(data, longer_ema_window, shorter_ema_window, signal_ema_window) # plot macd macd_color = gen_macd_color(df) diff --git a/finquant/utils.py b/finquant/utils.py index 4b4ef43a..12f8571d 100644 --- a/finquant/utils.py +++ b/finquant/utils.py @@ -15,15 +15,9 @@ def all_list_ele_in_other( return all(ele in l_2 for ele in l_1) -def re_download_stock_data( - data: SERIES_OR_DATAFRAME, - stock_name: str -) -> pd.DataFrame: +def re_download_stock_data(data: SERIES_OR_DATAFRAME, stock_name: str) -> pd.DataFrame: # Type validations: - type_validation( - data=data, - name=stock_name, - ) + type_validation(data=data, name=stock_name) # download additional price data 'Open' for given stock and timeframe: start_date = data.index.min() - datetime.timedelta(days=31) end_date = data.index.max() + datetime.timedelta(days=1) From 892d9d199e2601f3e3da33fac85666d6b6cb616e Mon Sep 17 00:00:00 2001 From: Frank Milthaler Date: Mon, 2 Oct 2023 15:45:55 +0200 Subject: [PATCH 31/33] additional adjustments and adding tests for momentum indicators --- finquant/momentum_indicators.py | 24 ++- finquant/type_utilities.py | 3 + finquant/utils.py | 12 +- tests/test_momentum_indicators.py | 341 +++++++++++++++++++++++++----- 4 files changed, 322 insertions(+), 58 deletions(-) diff --git a/finquant/momentum_indicators.py b/finquant/momentum_indicators.py index 9b192c00..12e7da2c 100644 --- a/finquant/momentum_indicators.py +++ b/finquant/momentum_indicators.py @@ -41,7 +41,16 @@ def calculate_wilder_smoothing_averages( calculate_wilder_smoothing_averages(10.0, 5.0, 14) """ - return (avg_gain_loss * (window_length - 1) + gain_loss) / window_length + # Type validations: + type_validation( + avg_gain_loss=avg_gain_loss, + gain_loss=gain_loss, + window_length=window_length, + ) + # validating window_length value range + if window_length <= 0: + raise ValueError("Error: window_length must be > 0.") + return (avg_gain_loss * (window_length - 1) + gain_loss) / float(window_length) def calculate_relative_strength_index( @@ -78,6 +87,9 @@ def calculate_relative_strength_index( if not 0 < oversold < 100 or not 0 < overbought < 100: raise ValueError("levels should be > 0 and < 100") + if window_length > len(data): + raise ValueError("Error: window_length must be <= len(data).") + # converting data to pd.DataFrame if it is a pd.Series (for subsequent function calls): if isinstance(data, pd.Series): data = data.to_frame() @@ -260,6 +272,7 @@ def calculate_macd( shorter_ema_window: Optional[INT] = 12, signal_ema_window: Optional[INT] = 9, stock_name: Optional[str] = None, + num_days_predate_stock_price: Optional[INT] = 31, ) -> pd.DataFrame: # Type validations: type_validation( @@ -268,6 +281,7 @@ def calculate_macd( shorter_ema_window=shorter_ema_window, signal_ema_window=signal_ema_window, name=stock_name, + num_days_predate_stock_price=num_days_predate_stock_price ) # validating windows @@ -293,25 +307,25 @@ def calculate_macd( ): download_stock_data_again = False if download_stock_data_again: - df = re_download_stock_data(data, stock_name=stock_name) + df = re_download_stock_data(data, stock_name=stock_name, num_days_predate_stock_price=num_days_predate_stock_price) else: df = data # Get the shorter_ema_window-day EMA of the closing price - macd_k = ( + ema_short = ( df["Close"] .ewm(span=shorter_ema_window, adjust=False, min_periods=shorter_ema_window) .mean() ) # Get the longer_ema_window-day EMA of the closing price - macd_d = ( + ema_long = ( df["Close"] .ewm(span=longer_ema_window, adjust=False, min_periods=longer_ema_window) .mean() ) # Subtract the longer_ema_window-day EMA from the shorter_ema_window-Day EMA to get the MACD - macd = macd_k - macd_d + macd = ema_short - ema_long # Get the signal_ema_window-Day EMA of the MACD for the Trigger line macd_s = macd.ewm( span=signal_ema_window, adjust=False, min_periods=signal_ema_window diff --git a/finquant/type_utilities.py b/finquant/type_utilities.py index f51eae7e..e46f441d 100644 --- a/finquant/type_utilities.py +++ b/finquant/type_utilities.py @@ -157,10 +157,13 @@ def _check_empty_data(arg_name: str, arg_values: Any) -> None: "window_length": ((int, np.integer), None), "oversold": ((int, np.integer), None), "overbought": ((int, np.integer), None), + "num_days_predate_stock_price": ((int, np.integer), None), # NUMERICs: "investment": ((int, np.integer, float, np.floating), None), "dividend": ((int, np.integer, float, np.floating), None), "target": ((int, np.integer, float, np.floating), None), + "avg_gain_loss": ((int, np.integer, float, np.floating), None), + "gain_loss": ((int, np.integer, float, np.floating), None), # Booleans: "plot": (bool, None), "save_weights": (bool, None), diff --git a/finquant/utils.py b/finquant/utils.py index 12f8571d..d06a13f1 100644 --- a/finquant/utils.py +++ b/finquant/utils.py @@ -1,8 +1,8 @@ import datetime - +from typing import Optional import pandas as pd -from finquant.data_types import ELEMENT_TYPE, LIST_DICT_KEYS, SERIES_OR_DATAFRAME +from finquant.data_types import ELEMENT_TYPE, LIST_DICT_KEYS, SERIES_OR_DATAFRAME, INT from finquant.portfolio import _yfinance_request from finquant.type_utilities import type_validation @@ -15,11 +15,13 @@ def all_list_ele_in_other( return all(ele in l_2 for ele in l_1) -def re_download_stock_data(data: SERIES_OR_DATAFRAME, stock_name: str) -> pd.DataFrame: +def re_download_stock_data(data: SERIES_OR_DATAFRAME, stock_name: str, num_days_predate_stock_price: Optional[INT] = 0) -> pd.DataFrame: # Type validations: - type_validation(data=data, name=stock_name) + type_validation(data=data, name=stock_name, num_days_predate_stock_price=num_days_predate_stock_price) + if num_days_predate_stock_price < 0: + raise ValueError("Error: num_days_predate_stock_price must be >= 0.") # download additional price data 'Open' for given stock and timeframe: - start_date = data.index.min() - datetime.timedelta(days=31) + start_date = data.index.min() - datetime.timedelta(days=num_days_predate_stock_price) end_date = data.index.max() + datetime.timedelta(days=1) df = _yfinance_request([stock_name], start_date=start_date, end_date=end_date) # dropping second level of column header that yfinance returns diff --git a/tests/test_momentum_indicators.py b/tests/test_momentum_indicators.py index ebe01f70..921ac808 100644 --- a/tests/test_momentum_indicators.py +++ b/tests/test_momentum_indicators.py @@ -3,56 +3,136 @@ import pandas as pd import pytest -from finquant.momentum_indicators import plot_macd -from finquant.momentum_indicators import plot_relative_strength_index as rsi +from finquant.momentum_indicators import ( + calculate_relative_strength_index, + calculate_wilder_smoothing_averages, + plot_macd, gen_macd_color, calculate_macd +) +from finquant.momentum_indicators import plot_relative_strength_index +from finquant.utils import re_download_stock_data + plt.close("all") plt.switch_backend("Agg") -def test_rsi(): - x = np.sin(np.linspace(1, 10, 100)) - xlabel_orig = "Date" - ylabel_orig = "Price" - df = pd.DataFrame({"Stock": x}, index=np.linspace(1, 10, 100)) - df.index.name = "Date" - rsi(df) - # get data from axis object - ax = plt.gca() - line1 = ax.lines[0] - stock_plot = line1.get_xydata() - xlabel_plot = ax.get_xlabel() - ylabel_plot = ax.get_ylabel() - # tests - assert (df["Stock"].index.values == stock_plot[:, 0]).all() - assert (df["Stock"].values == stock_plot[:, 1]).all() - assert xlabel_orig == xlabel_plot - assert ylabel_orig == ylabel_plot +# Define a sample dataframe for testing +price_data = np.array([100, 102, 105, 103, 108, 110, 107, 109, 112, 115, 120, 118, 121, 124, 125, 126]).astype(np.float64) +data = pd.DataFrame( + {"Close": price_data} +) +macd_data = pd.DataFrame({ + 'Date': pd.date_range(start='2022-01-01', periods=16, freq='D'), + 'DIS': price_data, +}).set_index('Date', inplace=False) +macd_data.name = "DIS" -def test_rsi_standalone(): - x = np.sin(np.linspace(1, 10, 100)) +def test_calculate_wilder_smoothing_averages(): + # Test with the example values + result = calculate_wilder_smoothing_averages(10.0, 5.0, 14) + assert ( + abs(result - 9.642857142857142) <= 1e-15 + ) # The expected result calculated manually + + # Test with zero average gain/loss + result = calculate_wilder_smoothing_averages(0.0, 5.0, 14) + assert ( + abs(result - 0.35714285714285715) <= 1e-15 + ) # The expected result calculated manually + + # Test with zero current gain/loss + result = calculate_wilder_smoothing_averages(10.0, 0.0, 14) + assert ( + abs(result - 9.285714285714286) <= 1e-15 + ) # The expected result calculated manually + + # Test with window length of 1 + result = calculate_wilder_smoothing_averages(10.0, 5.0, 1) + assert ( + abs(result - 5.0) <= 1e-15 + ) # Since window length is 1, the result should be the current gain/loss + + # Test with negative values + result = calculate_wilder_smoothing_averages(-10.0, -5.0, 14) + assert ( + abs(result - -9.642857142857142) <= 1e-15 + ) # The expected result calculated manually + + # Test with very large numbers + result = calculate_wilder_smoothing_averages(1e20, 1e20, int(1e20)) + assert ( + abs(result - 1e20) <= 1e-15 + ) # The expected result is the same as input due to the large window length + + # Test with non-float input (should raise an exception) + with pytest.raises(TypeError): + calculate_wilder_smoothing_averages("10.0", 5.0, 14) + + # Test with window length of 0 (should raise an exception) + with pytest.raises(ValueError): + calculate_wilder_smoothing_averages(10.0, 5.0, 0) + + # Test with negative window length (should raise an exception) + with pytest.raises(ValueError): + calculate_wilder_smoothing_averages(10.0, 5.0, -14) + + +def test_calculate_relative_strength_index(): + rsi = calculate_relative_strength_index(data["Close"]) + + # Check if the result is a Pandas Series + assert isinstance(rsi, pd.Series) + + # Check the length of the result + assert len(rsi.dropna()) == len(data) - 14 + + # Check the first RSI value + assert np.isclose(rsi.dropna().iloc[0], 82.051282, rtol=1e-4) + + # Check the last RSI value + assert np.isclose(rsi.iloc[-1], 82.53358925143954, rtol=1e-4) + + # Check that the RSI values are within the range [0, 100] + assert (rsi.dropna() >= 0).all() and (rsi.dropna() <= 100).all() + + # Check for window_length > data length, should raise a ValueError + with pytest.raises(ValueError): + calculate_relative_strength_index(data["Close"], window_length=17) + + # Check for oversold >= overbought, should raise a ValueError + with pytest.raises(ValueError): + calculate_relative_strength_index(data["Close"], oversold=70, overbought=70) + + # Check for invalid levels, should raise a ValueError + with pytest.raises(ValueError): + calculate_relative_strength_index(data["Close"], oversold=150, overbought=80) + + with pytest.raises(ValueError): + calculate_relative_strength_index(data["Close"], oversold=20, overbought=120) + + # Check for empty input data, should raise a ValueError + with pytest.raises(ValueError): + calculate_relative_strength_index(pd.Series([])) + + # Check for non-Pandas Series input, should raise a TypeError + with pytest.raises(TypeError): + calculate_relative_strength_index(list(data["Close"])) + + +def test_plot_relative_strength_index_standalone(): + # Test standalone mode xlabel_orig = "Date" ylabel_orig = "RSI" labels_orig = ["overbought", "oversold", "rsi"] title_orig = "RSI Plot" - df = pd.DataFrame({"Stock": x}, index=np.linspace(1, 10, 100)) - df.index.name = "Date" - rsi(df, standalone=True) + plot_relative_strength_index(data['Close'], standalone=True) # get data from axis object ax = plt.gca() # ax.lines[2] is the RSI data - line1 = ax.lines[2] - rsi_plot = line1.get_xydata() xlabel_plot = ax.get_xlabel() ylabel_plot = ax.get_ylabel() - print(xlabel_plot, ylabel_plot) # tests - assert (df["rsi"].index.values == rsi_plot[:, 0]).all() - # for comparing values, we need to remove nan - a, b = df["rsi"].values, rsi_plot[:, 1] - a, b = map(lambda x: x[~np.isnan(x)], (a, b)) - assert (a == b).all() labels_plot = ax.get_legend_handles_labels()[1] title_plot = ax.get_title() assert labels_plot == labels_orig @@ -61,7 +141,186 @@ def test_rsi_standalone(): assert title_plot == title_orig -def test_mpl_macd(): +def test_plot_relative_strength_index_not_standalone(): + # Test non-standalone mode + xlabel_orig = "Date" + ylabel_orig = "Price" + plot_relative_strength_index(data['Close'], standalone=False) + # get data from axis object + ax = plt.gca() + line1 = ax.lines[0] + stock_plot = line1.get_xydata() + xlabel_plot = ax.get_xlabel() + ylabel_plot = ax.get_ylabel() + # tests + assert (data["Close"].index.values == stock_plot[:, 0]).all() + assert (data["Close"].values == stock_plot[:, 1]).all() + assert xlabel_orig == xlabel_plot + assert ylabel_orig == ylabel_plot + + +def test_gen_macd_color_valid_input(): + # Test with valid input + macd_df = pd.DataFrame({'MACDh': [0.5, -0.2, 0.8, -0.6, 0.2]}) + colors = gen_macd_color(macd_df) + + # Check that the result is a list + assert isinstance(colors, list) + + # Check the length of the result + assert len(colors) == len(macd_df) + + # Check color assignments based on MACD values + assert colors == ['#26A69A', '#FF5252', '#26A69A', '#FF5252', '#26A69A'] + + +def test_gen_macd_color_green(): + # Test with a DataFrame where MACD values are consistently positive, should return + # all green colors + positive_df = pd.DataFrame({'MACDh': [0.5, 0.6, 0.7, 0.8, 0.9]}) + colors = gen_macd_color(positive_df) + + # Check that the result is a list of all green colors + assert colors == ["#B2DFDB", "#26A69A", "#26A69A", "#26A69A", "#26A69A"] + + +def test_gen_macd_color_faint_green(): + # Test with a DataFrame where MACD values are consistently positive but decreasing, + # should return all faint green colors + faint_green_df = pd.DataFrame({'MACDh': [0.5, 0.4, 0.3, 0.2, 0.1]}) + colors = gen_macd_color(faint_green_df) + + # Check that the result is a list of all faint green colors + assert colors == ["#26A69A", "#B2DFDB", "#B2DFDB", "#B2DFDB", "#B2DFDB"] + + +def test_gen_macd_color_red(): + # Test with a DataFrame where MACD values are consistently negative, + # should return all red colors + negative_df = pd.DataFrame({'MACDh': [-0.5, -0.6, -0.7, -0.8, -0.9]}) + colors = gen_macd_color(negative_df) + + # Check that the result is a list of all red colors + assert colors == ["#FFCDD2", "#FF5252", "#FF5252", "#FF5252", "#FF5252"] + + +def test_gen_macd_color_faint_red(): + # Test with a DataFrame where MACD values are consistently negative but decreasing, + # should return all faint red colors + faint_red_df = pd.DataFrame({'MACDh': [-0.5, -0.4, -0.3, -0.2, -0.1]}) + colors = gen_macd_color(faint_red_df) + + # Check that the result is a list of all faint red colors + assert colors == ["#FF5252", "#FFCDD2", "#FFCDD2", "#FFCDD2", "#FFCDD2"] + + + +def test_gen_macd_color_single_element(): + # Test with a DataFrame containing a single element, should return a list with one color + single_element_df = pd.DataFrame({'MACDh': [0.5]}) + colors = gen_macd_color(single_element_df) + + # Check that the result is a list with one color + assert colors == ["#000000"] + + +def test_gen_macd_color_empty_input(): + # Test with an empty DataFrame, should return an empty list + empty_df = pd.DataFrame(columns=['MACDh']) + with pytest.raises(ValueError): + colors = gen_macd_color(empty_df) + + +def test_gen_macd_color_missing_column(): + # Test with a DataFrame missing 'MACDh' column, should raise a KeyError + df_missing_column = pd.DataFrame({'NotMACDh': [0.5, -0.2, 0.8, -0.6, 0.2]}) + + with pytest.raises(KeyError): + gen_macd_color(df_missing_column) + + +def test_gen_macd_color_no_color_change(): + # Test with a DataFrame where MACD values don't change, should return all black colors + no_change_df = pd.DataFrame({'MACDh': [0.5, 0.5, 0.5, 0.5, 0.5]}) + colors = gen_macd_color(no_change_df) + + # Check that the result is a list of all black colors + assert colors == ["#000000", "#000000", "#000000", "#000000", "#000000"] + + + + + +def test_calculate_macd_valid_input(): + # Test with valid input + result = calculate_macd(macd_data, num_days_predate_stock_price=0) + + # Check that the result is a DataFrame + assert isinstance(result, pd.DataFrame) + + # Check the length of the result + assert len(result) == 10 + # not == len(macd_data) here, as we currently re-download data, weekends are not considered + + # Check that the required columns ('MACD', 'MACDh', 'MACDs') are present in the result + assert all(col in result.columns for col in ['MACD', 'MACDh', 'MACDs']) + + +def test_calculate_macd_correct_values(): + # Test for correct values in 'MACD', 'MACDh', and 'MACDs' columns + longer_ema_window = 10 + shorter_ema_window = 7 + signal_ema_window = 4 + df = re_download_stock_data(macd_data, stock_name="DIS", num_days_predate_stock_price=0) + result = calculate_macd( + macd_data, + longer_ema_window=longer_ema_window, + shorter_ema_window=shorter_ema_window, + signal_ema_window=signal_ema_window, + num_days_predate_stock_price = 0 + ) + + # Calculate expected values manually (using the provided df) + ema_short = df['Close'].ewm(span=shorter_ema_window, adjust=False, min_periods=shorter_ema_window).mean() + ema_long = df['Close'].ewm(span=longer_ema_window, adjust=False, min_periods=longer_ema_window).mean() + macd = ema_short - ema_long + macd.name = "MACD" + signal = macd.ewm(span=signal_ema_window, adjust=False, min_periods=signal_ema_window).mean() + macd_h = macd - signal + + # Check that the calculated values match the values in the DataFrame + assert all(result['MACD'].dropna() == macd.dropna()) + assert all(result['MACDh'].dropna() == macd_h.dropna()) + assert all(result['MACDs'].dropna() == signal.dropna()) + + +def test_calculate_macd_custom_windows(): + # Test with custom EMA window values + result = calculate_macd(macd_data, longer_ema_window=30, shorter_ema_window=15, signal_ema_window=10) + + # Check that the result is a DataFrame + assert isinstance(result, pd.DataFrame) + + # Check that the required columns ('MACD', 'MACDh', 'MACDs') are present in the result + assert all(col in result.columns for col in ['MACD', 'MACDh', 'MACDs']) + + +def test_calculate_macd_invalid_windows(): + # Test with invalid window values, should raise ValueError + with pytest.raises(ValueError): + calculate_macd(macd_data, longer_ema_window=10, shorter_ema_window=20, signal_ema_window=15) + with pytest.raises(ValueError): + plot_macd(macd_data, longer_ema_window=10, shorter_ema_window=5, signal_ema_window=30) + + + + + + + + + +def test_plot_macd(): axes0_ylabel_orig = "Price" axes4_ylabel_orig = "Volume $10^{6}$" # Create sample data for testing @@ -84,17 +343,3 @@ def test_mpl_macd(): assert axes0_ylabel_orig == axes0_ylabel_plot assert axes4_ylabel_orig == axes4_ylabel_plot - -def test_mpl_macd_invalid_window_parameters(): - # Create sample data with invalid window parameters - x = np.sin(np.linspace(1, 10, 100)) - df = pd.DataFrame( - {"Close": x}, index=pd.date_range("2015-01-01", periods=100, freq="D") - ) - df.name = "DIS" - - # Call mpl_macd function with invalid window parameters and check for ValueError - with pytest.raises(ValueError): - plot_macd(df, longer_ema_window=10, shorter_ema_window=20, signal_ema_window=30) - with pytest.raises(ValueError): - plot_macd(df, longer_ema_window=10, shorter_ema_window=5, signal_ema_window=30) From 53cd5f861a9398b2fa223864670ac07665504499 Mon Sep 17 00:00:00 2001 From: Frank Milthaler Date: Mon, 2 Oct 2023 15:46:26 +0200 Subject: [PATCH 32/33] auto formatting --- finquant/momentum_indicators.py | 8 ++- finquant/utils.py | 19 ++++-- tests/test_momentum_indicators.py | 105 ++++++++++++++++-------------- 3 files changed, 78 insertions(+), 54 deletions(-) diff --git a/finquant/momentum_indicators.py b/finquant/momentum_indicators.py index 12e7da2c..f1d2d45c 100644 --- a/finquant/momentum_indicators.py +++ b/finquant/momentum_indicators.py @@ -281,7 +281,7 @@ def calculate_macd( shorter_ema_window=shorter_ema_window, signal_ema_window=signal_ema_window, name=stock_name, - num_days_predate_stock_price=num_days_predate_stock_price + num_days_predate_stock_price=num_days_predate_stock_price, ) # validating windows @@ -307,7 +307,11 @@ def calculate_macd( ): download_stock_data_again = False if download_stock_data_again: - df = re_download_stock_data(data, stock_name=stock_name, num_days_predate_stock_price=num_days_predate_stock_price) + df = re_download_stock_data( + data, + stock_name=stock_name, + num_days_predate_stock_price=num_days_predate_stock_price, + ) else: df = data diff --git a/finquant/utils.py b/finquant/utils.py index d06a13f1..e9c34d92 100644 --- a/finquant/utils.py +++ b/finquant/utils.py @@ -1,8 +1,9 @@ import datetime from typing import Optional + import pandas as pd -from finquant.data_types import ELEMENT_TYPE, LIST_DICT_KEYS, SERIES_OR_DATAFRAME, INT +from finquant.data_types import ELEMENT_TYPE, INT, LIST_DICT_KEYS, SERIES_OR_DATAFRAME from finquant.portfolio import _yfinance_request from finquant.type_utilities import type_validation @@ -15,13 +16,23 @@ def all_list_ele_in_other( return all(ele in l_2 for ele in l_1) -def re_download_stock_data(data: SERIES_OR_DATAFRAME, stock_name: str, num_days_predate_stock_price: Optional[INT] = 0) -> pd.DataFrame: +def re_download_stock_data( + data: SERIES_OR_DATAFRAME, + stock_name: str, + num_days_predate_stock_price: Optional[INT] = 0, +) -> pd.DataFrame: # Type validations: - type_validation(data=data, name=stock_name, num_days_predate_stock_price=num_days_predate_stock_price) + type_validation( + data=data, + name=stock_name, + num_days_predate_stock_price=num_days_predate_stock_price, + ) if num_days_predate_stock_price < 0: raise ValueError("Error: num_days_predate_stock_price must be >= 0.") # download additional price data 'Open' for given stock and timeframe: - start_date = data.index.min() - datetime.timedelta(days=num_days_predate_stock_price) + start_date = data.index.min() - datetime.timedelta( + days=num_days_predate_stock_price + ) end_date = data.index.max() + datetime.timedelta(days=1) df = _yfinance_request([stock_name], start_date=start_date, end_date=end_date) # dropping second level of column header that yfinance returns diff --git a/tests/test_momentum_indicators.py b/tests/test_momentum_indicators.py index 921ac808..e9074008 100644 --- a/tests/test_momentum_indicators.py +++ b/tests/test_momentum_indicators.py @@ -4,27 +4,30 @@ import pytest from finquant.momentum_indicators import ( + calculate_macd, calculate_relative_strength_index, calculate_wilder_smoothing_averages, - plot_macd, gen_macd_color, calculate_macd + gen_macd_color, + plot_macd, + plot_relative_strength_index, ) -from finquant.momentum_indicators import plot_relative_strength_index from finquant.utils import re_download_stock_data - plt.close("all") plt.switch_backend("Agg") # Define a sample dataframe for testing -price_data = np.array([100, 102, 105, 103, 108, 110, 107, 109, 112, 115, 120, 118, 121, 124, 125, 126]).astype(np.float64) -data = pd.DataFrame( - {"Close": price_data} -) -macd_data = pd.DataFrame({ - 'Date': pd.date_range(start='2022-01-01', periods=16, freq='D'), - 'DIS': price_data, -}).set_index('Date', inplace=False) +price_data = np.array( + [100, 102, 105, 103, 108, 110, 107, 109, 112, 115, 120, 118, 121, 124, 125, 126] +).astype(np.float64) +data = pd.DataFrame({"Close": price_data}) +macd_data = pd.DataFrame( + { + "Date": pd.date_range(start="2022-01-01", periods=16, freq="D"), + "DIS": price_data, + } +).set_index("Date", inplace=False) macd_data.name = "DIS" @@ -126,7 +129,7 @@ def test_plot_relative_strength_index_standalone(): ylabel_orig = "RSI" labels_orig = ["overbought", "oversold", "rsi"] title_orig = "RSI Plot" - plot_relative_strength_index(data['Close'], standalone=True) + plot_relative_strength_index(data["Close"], standalone=True) # get data from axis object ax = plt.gca() # ax.lines[2] is the RSI data @@ -145,7 +148,7 @@ def test_plot_relative_strength_index_not_standalone(): # Test non-standalone mode xlabel_orig = "Date" ylabel_orig = "Price" - plot_relative_strength_index(data['Close'], standalone=False) + plot_relative_strength_index(data["Close"], standalone=False) # get data from axis object ax = plt.gca() line1 = ax.lines[0] @@ -161,7 +164,7 @@ def test_plot_relative_strength_index_not_standalone(): def test_gen_macd_color_valid_input(): # Test with valid input - macd_df = pd.DataFrame({'MACDh': [0.5, -0.2, 0.8, -0.6, 0.2]}) + macd_df = pd.DataFrame({"MACDh": [0.5, -0.2, 0.8, -0.6, 0.2]}) colors = gen_macd_color(macd_df) # Check that the result is a list @@ -171,13 +174,13 @@ def test_gen_macd_color_valid_input(): assert len(colors) == len(macd_df) # Check color assignments based on MACD values - assert colors == ['#26A69A', '#FF5252', '#26A69A', '#FF5252', '#26A69A'] + assert colors == ["#26A69A", "#FF5252", "#26A69A", "#FF5252", "#26A69A"] def test_gen_macd_color_green(): # Test with a DataFrame where MACD values are consistently positive, should return # all green colors - positive_df = pd.DataFrame({'MACDh': [0.5, 0.6, 0.7, 0.8, 0.9]}) + positive_df = pd.DataFrame({"MACDh": [0.5, 0.6, 0.7, 0.8, 0.9]}) colors = gen_macd_color(positive_df) # Check that the result is a list of all green colors @@ -187,7 +190,7 @@ def test_gen_macd_color_green(): def test_gen_macd_color_faint_green(): # Test with a DataFrame where MACD values are consistently positive but decreasing, # should return all faint green colors - faint_green_df = pd.DataFrame({'MACDh': [0.5, 0.4, 0.3, 0.2, 0.1]}) + faint_green_df = pd.DataFrame({"MACDh": [0.5, 0.4, 0.3, 0.2, 0.1]}) colors = gen_macd_color(faint_green_df) # Check that the result is a list of all faint green colors @@ -197,7 +200,7 @@ def test_gen_macd_color_faint_green(): def test_gen_macd_color_red(): # Test with a DataFrame where MACD values are consistently negative, # should return all red colors - negative_df = pd.DataFrame({'MACDh': [-0.5, -0.6, -0.7, -0.8, -0.9]}) + negative_df = pd.DataFrame({"MACDh": [-0.5, -0.6, -0.7, -0.8, -0.9]}) colors = gen_macd_color(negative_df) # Check that the result is a list of all red colors @@ -207,17 +210,16 @@ def test_gen_macd_color_red(): def test_gen_macd_color_faint_red(): # Test with a DataFrame where MACD values are consistently negative but decreasing, # should return all faint red colors - faint_red_df = pd.DataFrame({'MACDh': [-0.5, -0.4, -0.3, -0.2, -0.1]}) + faint_red_df = pd.DataFrame({"MACDh": [-0.5, -0.4, -0.3, -0.2, -0.1]}) colors = gen_macd_color(faint_red_df) # Check that the result is a list of all faint red colors assert colors == ["#FF5252", "#FFCDD2", "#FFCDD2", "#FFCDD2", "#FFCDD2"] - def test_gen_macd_color_single_element(): # Test with a DataFrame containing a single element, should return a list with one color - single_element_df = pd.DataFrame({'MACDh': [0.5]}) + single_element_df = pd.DataFrame({"MACDh": [0.5]}) colors = gen_macd_color(single_element_df) # Check that the result is a list with one color @@ -226,14 +228,14 @@ def test_gen_macd_color_single_element(): def test_gen_macd_color_empty_input(): # Test with an empty DataFrame, should return an empty list - empty_df = pd.DataFrame(columns=['MACDh']) + empty_df = pd.DataFrame(columns=["MACDh"]) with pytest.raises(ValueError): colors = gen_macd_color(empty_df) def test_gen_macd_color_missing_column(): # Test with a DataFrame missing 'MACDh' column, should raise a KeyError - df_missing_column = pd.DataFrame({'NotMACDh': [0.5, -0.2, 0.8, -0.6, 0.2]}) + df_missing_column = pd.DataFrame({"NotMACDh": [0.5, -0.2, 0.8, -0.6, 0.2]}) with pytest.raises(KeyError): gen_macd_color(df_missing_column) @@ -241,16 +243,13 @@ def test_gen_macd_color_missing_column(): def test_gen_macd_color_no_color_change(): # Test with a DataFrame where MACD values don't change, should return all black colors - no_change_df = pd.DataFrame({'MACDh': [0.5, 0.5, 0.5, 0.5, 0.5]}) + no_change_df = pd.DataFrame({"MACDh": [0.5, 0.5, 0.5, 0.5, 0.5]}) colors = gen_macd_color(no_change_df) # Check that the result is a list of all black colors assert colors == ["#000000", "#000000", "#000000", "#000000", "#000000"] - - - def test_calculate_macd_valid_input(): # Test with valid input result = calculate_macd(macd_data, num_days_predate_stock_price=0) @@ -263,7 +262,7 @@ def test_calculate_macd_valid_input(): # not == len(macd_data) here, as we currently re-download data, weekends are not considered # Check that the required columns ('MACD', 'MACDh', 'MACDs') are present in the result - assert all(col in result.columns for col in ['MACD', 'MACDh', 'MACDs']) + assert all(col in result.columns for col in ["MACD", "MACDh", "MACDs"]) def test_calculate_macd_correct_values(): @@ -271,53 +270,64 @@ def test_calculate_macd_correct_values(): longer_ema_window = 10 shorter_ema_window = 7 signal_ema_window = 4 - df = re_download_stock_data(macd_data, stock_name="DIS", num_days_predate_stock_price=0) + df = re_download_stock_data( + macd_data, stock_name="DIS", num_days_predate_stock_price=0 + ) result = calculate_macd( macd_data, longer_ema_window=longer_ema_window, shorter_ema_window=shorter_ema_window, signal_ema_window=signal_ema_window, - num_days_predate_stock_price = 0 + num_days_predate_stock_price=0, ) # Calculate expected values manually (using the provided df) - ema_short = df['Close'].ewm(span=shorter_ema_window, adjust=False, min_periods=shorter_ema_window).mean() - ema_long = df['Close'].ewm(span=longer_ema_window, adjust=False, min_periods=longer_ema_window).mean() + ema_short = ( + df["Close"] + .ewm(span=shorter_ema_window, adjust=False, min_periods=shorter_ema_window) + .mean() + ) + ema_long = ( + df["Close"] + .ewm(span=longer_ema_window, adjust=False, min_periods=longer_ema_window) + .mean() + ) macd = ema_short - ema_long macd.name = "MACD" - signal = macd.ewm(span=signal_ema_window, adjust=False, min_periods=signal_ema_window).mean() + signal = macd.ewm( + span=signal_ema_window, adjust=False, min_periods=signal_ema_window + ).mean() macd_h = macd - signal # Check that the calculated values match the values in the DataFrame - assert all(result['MACD'].dropna() == macd.dropna()) - assert all(result['MACDh'].dropna() == macd_h.dropna()) - assert all(result['MACDs'].dropna() == signal.dropna()) + assert all(result["MACD"].dropna() == macd.dropna()) + assert all(result["MACDh"].dropna() == macd_h.dropna()) + assert all(result["MACDs"].dropna() == signal.dropna()) def test_calculate_macd_custom_windows(): # Test with custom EMA window values - result = calculate_macd(macd_data, longer_ema_window=30, shorter_ema_window=15, signal_ema_window=10) + result = calculate_macd( + macd_data, longer_ema_window=30, shorter_ema_window=15, signal_ema_window=10 + ) # Check that the result is a DataFrame assert isinstance(result, pd.DataFrame) # Check that the required columns ('MACD', 'MACDh', 'MACDs') are present in the result - assert all(col in result.columns for col in ['MACD', 'MACDh', 'MACDs']) + assert all(col in result.columns for col in ["MACD", "MACDh", "MACDs"]) def test_calculate_macd_invalid_windows(): # Test with invalid window values, should raise ValueError with pytest.raises(ValueError): - calculate_macd(macd_data, longer_ema_window=10, shorter_ema_window=20, signal_ema_window=15) + calculate_macd( + macd_data, longer_ema_window=10, shorter_ema_window=20, signal_ema_window=15 + ) with pytest.raises(ValueError): - plot_macd(macd_data, longer_ema_window=10, shorter_ema_window=5, signal_ema_window=30) - - - - - - - + plot_macd( + macd_data, longer_ema_window=10, shorter_ema_window=5, signal_ema_window=30 + ) def test_plot_macd(): @@ -342,4 +352,3 @@ def test_plot_macd(): assert len(axes) == 6 # Assuming there are six subplots in the returned figure assert axes0_ylabel_orig == axes0_ylabel_plot assert axes4_ylabel_orig == axes4_ylabel_plot - From 5e58a24324ecb888a496ca0791c08a5946f7bdb3 Mon Sep 17 00:00:00 2001 From: Frank Milthaler Date: Mon, 2 Oct 2023 15:47:54 +0200 Subject: [PATCH 33/33] fixing pylint complaints --- finquant/momentum_indicators.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/finquant/momentum_indicators.py b/finquant/momentum_indicators.py index f1d2d45c..3474e1cf 100644 --- a/finquant/momentum_indicators.py +++ b/finquant/momentum_indicators.py @@ -1,6 +1,5 @@ """ This module provides function(s) to compute momentum indicators used in technical analysis such as RSI, MACD etc. """ -import datetime from typing import List, Optional import matplotlib.pyplot as plt @@ -8,7 +7,6 @@ import pandas as pd from finquant.data_types import FLOAT, INT, SERIES_OR_DATAFRAME -from finquant.portfolio import _yfinance_request from finquant.type_utilities import type_validation from finquant.utils import all_list_ele_in_other, re_download_stock_data @@ -390,7 +388,13 @@ def plot_macd( """ # calculate MACD: - df = calculate_macd(data, longer_ema_window, shorter_ema_window, signal_ema_window) + df = calculate_macd( + data, + longer_ema_window, + shorter_ema_window, + signal_ema_window, + stock_name=stock_name, + ) # plot macd macd_color = gen_macd_color(df)