From f0850b3b895ec6b127ae038a1114c4660a7727f7 Mon Sep 17 00:00:00 2001 From: Debasish Pal <48341250+debpal@users.noreply.github.com> Date: Sun, 13 Oct 2024 18:49:24 +0300 Subject: [PATCH] version 0.1.5 --- BharatFinTrack/__init__.py | 6 +- BharatFinTrack/core.py | 34 ++++++- BharatFinTrack/nse_index.py | 4 +- BharatFinTrack/nse_product.py | 6 +- BharatFinTrack/nse_tri.py | 64 ++----------- BharatFinTrack/visual.py | 172 ++++++++++++++++++++++++++++++++++ tests/test_bharatfintrack.py | 47 ++++++++-- 7 files changed, 262 insertions(+), 71 deletions(-) create mode 100644 BharatFinTrack/visual.py diff --git a/BharatFinTrack/__init__.py b/BharatFinTrack/__init__.py index 5c2f6c4..e58a2e3 100644 --- a/BharatFinTrack/__init__.py +++ b/BharatFinTrack/__init__.py @@ -1,13 +1,15 @@ from .nse_product import NSEProduct from .nse_index import NSEIndex from .nse_tri import NSETRI +from .visual import Visual __all__ = [ 'NSEProduct', 'NSEIndex', - 'NSETRI' + 'NSETRI', + 'Visual' ] -__version__ = '0.1.4' +__version__ = '0.1.5' diff --git a/BharatFinTrack/core.py b/BharatFinTrack/core.py index 1445aaf..8554d0f 100644 --- a/BharatFinTrack/core.py +++ b/BharatFinTrack/core.py @@ -4,12 +4,14 @@ import datetime import pandas import requests +import matplotlib.pyplot class Core: ''' - Core functionality of :mod:`BharatFinTrack` module. + Provides common functionality used throughout + the :mod:`BharatFinTrack` package. ''' def _excel_file_extension( @@ -35,6 +37,36 @@ def _excel_file_extension( return output + def is_valid_figure_extension( + self, + file_path: str + ) -> bool: + + ''' + Returns whether the given path is a valid figure file. + + Parameters + ---------- + file_path : str + Path of the figure file. + + Returns + ------- + bool + True if the file path is valid, False otherwise. + ''' + + figure = matplotlib.pyplot.figure( + figsize=(1, 1) + ) + file_ext = os.path.splitext(file_path)[-1][1:] + supported_ext = list(figure.canvas.get_supported_filetypes().keys()) + output = file_ext in supported_ext + + matplotlib.pyplot.close(figure) + + return output + def string_to_date( self, date_string: str diff --git a/BharatFinTrack/nse_index.py b/BharatFinTrack/nse_index.py index 12ca375..892dc63 100644 --- a/BharatFinTrack/nse_index.py +++ b/BharatFinTrack/nse_index.py @@ -14,8 +14,8 @@ class NSEIndex: ''' - Download and analyze NSE index price data - (excluding dividend reinvestment). + Provides functionality for downloading and analyzing + NSE index price data (excluding dividend reinvestment). ''' def download_daily_summary_report( diff --git a/BharatFinTrack/nse_product.py b/BharatFinTrack/nse_product.py index d25639f..aeabe7d 100644 --- a/BharatFinTrack/nse_product.py +++ b/BharatFinTrack/nse_product.py @@ -7,8 +7,8 @@ class NSEProduct: ''' - Represents characteristics of NSE (National Stock Exchange) - related financial products. + Provides functionality for accessing the characteristics of + NSE related financial products. ''' @property @@ -49,7 +49,7 @@ def save_dataframe_equity_index_parameters( Parameters ---------- excel_file : str - Excel file to save the multi-index DataFrame. + Path of an Excel file to save the multi-index DataFrame. Returns ------- diff --git a/BharatFinTrack/nse_tri.py b/BharatFinTrack/nse_tri.py index 2fed2d1..234da86 100644 --- a/BharatFinTrack/nse_tri.py +++ b/BharatFinTrack/nse_tri.py @@ -3,7 +3,7 @@ import dateutil.relativedelta import pandas import matplotlib -import warnings +import matplotlib.pyplot from .nse_product import NSEProduct from .core import Core @@ -11,8 +11,9 @@ class NSETRI: ''' - Download and analyze NSE TRI (Total Return Index) data, - including both price index and dividend reinvestment. + Provides functionality for downloading and analyzing + NSE Equity Total Return Index (TRI) data, + including both price and dividend reinvestment. ''' @property @@ -255,7 +256,7 @@ def download_daily_summary_equity_closing( # processing base DataFrame base_df = NSEProduct()._dataframe_equity_index - base_df = base_df.groupby(level='Category').head(1) if test_mode is True else base_df + base_df = base_df.groupby(level='Category').head(2) if test_mode is True else base_df base_df = base_df.reset_index() base_df = base_df.drop(columns=['ID', 'API TRI']) base_df['Base Date'] = base_df['Base Date'].apply(lambda x: x.date()) @@ -301,55 +302,6 @@ def download_daily_summary_equity_closing( return base_df - def download_equity_indices_updated_value( - self, - excel_file: str, - http_headers: typing.Optional[dict[str, str]] = None, - test_mode: bool = False - ) -> pandas.DataFrame: - - ''' - Returns updated TRI values for all NSE indices. - - Parameters - ---------- - excel_file : str, optional - Path to an Excel file to save the DataFrame. - - http_headers : dict, optional - HTTP headers for the web request. Defaults to - :attr:`BharatFinTrack.core.Core.default_http_headers` if not provided. - - test_mode : bool, optional - If True, the function will use a mocked DataFrame for testing purposes - instead of the actual data. This parameter is intended for developers - for testing purposes only and is not recommended for use by end-users. - - Returns - ------- - DataFrame - A DataFrame containing updated TRI values for all NSE indices. - - Warnings - -------- - The method :meth:`BharatFinTrack.NSETRI.download_equity_indices_updated_value` is deprecated. - Use the :meth:`BharatFinTrack.NSETRI.download_daily_summary_equity_closing` instead. - ''' - - warnings.warn( - 'download_equity_indices_updated_value(excel_file) is deprecated. Use download_daily_summary_equity_closing(excel_file) instead.', - DeprecationWarning, - stacklevel=2 - ) - - output = self.download_daily_summary_equity_closing( - excel_file=excel_file, - http_headers=http_headers, - test_mode=test_mode - ) - - return output - def sort_equity_value_from_launch( self, input_excel: str, @@ -365,7 +317,7 @@ def sort_equity_value_from_launch( Path to the input Excel file. output_excel : str - Path to an output Excel file to save the output DataFrame. + Path to an Excel file to save the output DataFrame. Returns ------- @@ -421,7 +373,7 @@ def sort_equity_cagr_from_launch( Path to the input Excel file. output_excel : str - Path to an output Excel file to save the output DataFrame. + Path to an Excel file to save the output DataFrame. Returns ------- @@ -509,7 +461,7 @@ def category_sort_equity_cagr_from_launch( Path to the input Excel file. output_excel : str - Path to an output Excel file to save the output DataFrame. + Path to an Excel file to save the output DataFrame. Returns ------- diff --git a/BharatFinTrack/visual.py b/BharatFinTrack/visual.py new file mode 100644 index 0000000..2a2e6b5 --- /dev/null +++ b/BharatFinTrack/visual.py @@ -0,0 +1,172 @@ +import typing +import pandas +import matplotlib +import matplotlib.pyplot +import matplotlib.figure +from .core import Core + + +class Visual: + + ''' + Provides functionality for plotting data. + ''' + + def plot_category_sort_index_cagr_from_launch( + self, + excel_file: str, + figure_file: str, + threshold_cagr: typing.Optional[float] = None, + threshold_close: typing.Optional[float] = None + ) -> matplotlib.figure.Figure: + + ''' + Returns a bar plot of indices' clsong value since launch. + + Parameters + ---------- + excel_file : str + Path to the input Excel file. + + figure_file : str + Path to a file to save the output figue. + + threshold_close : float, optional + Only plot indices with a closing value higher than the specified threshold. + + threshold_cagr : float, optional + Only plot indices with a CAGR (%) higher than the specified threshold. + + Returns + ------- + Figure + A bar plot displaying indices' closing values along with + CAGR (%), Multiplier (X), and Age (Y) since launch. + ''' + + # input DataFrame + df = pandas.read_excel(excel_file, index_col=[0, 1]) + df = df[df['Close Value'] >= threshold_close] if threshold_close is not None else df + df = df[df['CAGR(%)'] >= threshold_cagr] if threshold_cagr is not None else df + + # check filtered dataframe + if len(df) == 0: + raise Exception('Threshold values return an empty DataFrame.') + else: + pass + + categories = df.index.get_level_values('Category').unique() + close_date = df['Close Date'].iloc[0].strftime('%d-%b-%Y') + + # color for NSE indices category + colormap = matplotlib.colormaps.get_cmap('Set2') + category_color = { + categories[count]: colormap(count / len(categories)) for count in range(len(categories)) + } + + # figure + fig_height = int(len(df) / 3.5) + 1 if len(df) > 7 else 3 + xtick_gap = 10000 + xaxis_max = int(((df['Close Value'].max() + 20000) / xtick_gap) + 1) * xtick_gap + fig_width = int((xaxis_max / xtick_gap) * 1.2) + 1 + figure = matplotlib.pyplot.figure( + figsize=(fig_width, fig_height) + ) + subplot = figure.subplots(1, 1) + + # check validity of input figure file path + check_file = Core().is_valid_figure_extension(figure_file) + if check_file is True: + pass + else: + # close the figure to prevent a blank plot from appearing + matplotlib.pyplot.close(figure) + raise Exception('Input figure file extension is not supported.') + + # plotting indices closing values + categories_legend = set() + for count, (index, row) in enumerate(df.iterrows()): + category = index[0] + color = category_color[category] + if category not in categories_legend: + subplot.barh( + row['Index Name'], row['Close Value'], + color=color, + label=category + ) + categories_legend.add(category) + else: + subplot.barh( + row['Index Name'], row['Close Value'], + color=color + ) + age = row['Years'] + (row['Days'] / 365) + bar_label = f"({row['CAGR(%)']:.1f}%,{round(row['Close/Base'])}X,{age:.1f}Y)" + subplot.text( + row['Close Value'] + 100, count, bar_label, + va='center', + fontsize=10 + ) + + # x-axis customization + subplot.set_xlim(0, xaxis_max) + subplot.set_xticks( + list(range(0, xaxis_max + 1, xtick_gap)) + ) + xtick_labels = [ + f'{int(val / 1000)}k' for val in list(range(0, xaxis_max + 1, xtick_gap)) + ] + subplot.set_xticklabels(xtick_labels, fontsize=12) + subplot.tick_params( + axis='x', which='both', + direction='in', length=6, width=1, + top=True, bottom=True, + labeltop=True, labelbottom=True + ) + subplot.grid( + visible=True, + which='major', axis='x', + color='gray', + linestyle='--', linewidth=0.3 + ) + subplot.set_xlabel( + f'Close Value (Date: {close_date})', + fontsize=15, + labelpad=15 + ) + + # reverse y-axis + subplot.invert_yaxis() + + # y-axis customization + subplot.set_ylabel( + 'Index Name', + fontsize=20, + labelpad=15 + ) + subplot.set_ylim(len(df), -1) + + # legend + subplot.legend( + title="Index Category", + loc='lower right', + fontsize=12, + title_fontsize=12 + ) + + # figure customization + figure.suptitle( + 'NSE Equity Indices Since Launch: Closing Value Bars with CAGR (%), Multiplier (X), and Age (Y)', + fontsize=15, + y=1 + ) + figure.tight_layout() + figure.savefig( + figure_file, + bbox_inches='tight' + ) + + # close the figure to prevent duplicate plots from displaying + matplotlib.pyplot.close(figure) + + return figure diff --git a/tests/test_bharatfintrack.py b/tests/test_bharatfintrack.py index fb40233..c88047c 100644 --- a/tests/test_bharatfintrack.py +++ b/tests/test_bharatfintrack.py @@ -4,6 +4,7 @@ import tempfile import datetime import pandas +import matplotlib.pyplot @pytest.fixture(scope='class') @@ -24,6 +25,12 @@ def nse_tri(): yield BharatFinTrack.NSETRI() +@pytest.fixture(scope='class') +def visual(): + + yield BharatFinTrack.Visual() + + @pytest.fixture(scope='class') def core(): @@ -39,9 +46,11 @@ def message(): 'error_date2': "time data '20-Se-2024' does not match format '%d-%b-%Y'", 'error_date3': 'Start date 27-Sep-2024 cannot be later than end date 26-Sep-2024.', 'error_excel': 'Input file extension ".xl" does not match the required ".xlsx".', + 'error_figure': 'Input figure file extension is not supported.', 'error_folder': 'The folder path does not exist.', 'error_index1': '"INVALID" index does not exist.', - 'error_index2': '"NIFTY50 USD" index data is not available as open-source.' + 'error_index2': '"NIFTY50 USD" index data is not available as open-source.', + 'error_df': 'Threshold values return an empty DataFrame.' } @@ -282,25 +291,25 @@ def test_equity_index_price_download_updated_value( assert exc_info.value.args[0] == message['error_excel'] -@pytest.mark.filterwarnings('ignore::DeprecationWarning') -def test_equity_index_tri_download_updated_value( +def test_equity_index_tri_closing( nse_tri, - message + message, + visual ): with tempfile.TemporaryDirectory() as tmp_dir: excel_file = os.path.join(tmp_dir, 'equity.xlsx') # pass test for downloading updated TRI values of NSE equity indices - nse_tri.download_equity_indices_updated_value( + nse_tri.download_daily_summary_equity_closing( excel_file=excel_file, test_mode=True ) df = pandas.read_excel(excel_file) assert df.shape[1] == 6 - assert df.shape[0] <= 4 + assert df.shape[0] <= 8 # error test for invalid Excel file input with pytest.raises(Exception) as exc_info: - nse_tri.download_equity_indices_updated_value( + nse_tri.download_daily_summary_equity_closing( excel_file='output.xl' ) assert exc_info.value.args[0] == message['error_excel'] @@ -349,6 +358,30 @@ def test_equity_index_tri_download_updated_value( output_excel='output.xl' ) assert exc_info.value.args[0] == message['error_excel'] + # pass test for plotting of descending CAGR sort by category since launch + figure_file = os.path.join(tmp_dir, 'plot_categorical_sorted_tri_cagr.png') + assert os.path.exists(figure_file) is False + figure = visual.plot_category_sort_index_cagr_from_launch( + excel_file=os.path.join(tmp_dir, 'categorical_sorted_tri_cagr.xlsx'), + figure_file=figure_file, + ) + assert isinstance(figure, matplotlib.pyplot.Figure) + assert os.path.exists(figure_file) is True + # error test for empty DataFrame from threshold values + with pytest.raises(Exception) as exc_info: + visual.plot_category_sort_index_cagr_from_launch( + excel_file=os.path.join(tmp_dir, 'categorical_sorted_tri_cagr.xlsx'), + figure_file=os.path.join(tmp_dir, 'plot_categorical_sorted_tri_cagr.png'), + threshold_cagr=100 + ) + assert exc_info.value.args[0] == message['error_df'] + # error test for invalid figure file input + with pytest.raises(Exception) as exc_info: + visual.plot_category_sort_index_cagr_from_launch( + excel_file=os.path.join(tmp_dir, 'categorical_sorted_tri_cagr.xlsx'), + figure_file=os.path.join(tmp_dir, 'plot_categorical_sorted_tri_cagr.pn'), + ) + assert exc_info.value.args[0] == message['error_figure'] def test_update_historical_daily_data(