From e49753c7f9ca8a83c8836a50ea489c803ff73cfb Mon Sep 17 00:00:00 2001 From: marcvanduyn Date: Mon, 21 Apr 2025 22:14:49 +0200 Subject: [PATCH] Add up_and_down_trends indicator --- pyindicators/__init__.py | 2 + pyindicators/date_range.py | 56 ++++++++ pyindicators/indicators/__init__.py | 2 + pyindicators/indicators/up_and_down_trends.py | 135 ++++++++++++++++++ 4 files changed, 195 insertions(+) create mode 100644 pyindicators/date_range.py create mode 100644 pyindicators/indicators/up_and_down_trends.py diff --git a/pyindicators/__init__.py b/pyindicators/__init__.py index 49c5510..f34eb62 100644 --- a/pyindicators/__init__.py +++ b/pyindicators/__init__.py @@ -6,6 +6,7 @@ has_values_above_threshold, has_values_below_threshold, is_down_trend, \ is_up_trend from .exceptions import PyIndicatorException +from .date_range import DateRange __all__ = [ 'sma', @@ -35,4 +36,5 @@ 'PyIndicatorException', 'is_down_trend', 'is_up_trend', + 'DateRange', ] diff --git a/pyindicators/date_range.py b/pyindicators/date_range.py new file mode 100644 index 0000000..a737f58 --- /dev/null +++ b/pyindicators/date_range.py @@ -0,0 +1,56 @@ +from datetime import datetime +from typing import Union + + +class DateRange: + """ + DateRange class. This class is used to define a date range and the name of + the range. Also, it can be used to store trading metadata such as + classification of the trend (Up or Down). + """ + + def __init__( + self, + start_date: datetime, + end_date: datetime, + name: str, + up_trend: bool = False, + down_trend: bool = False + ): + self.start_date = start_date + self.end_date = end_date + self.name = name + self._up_trend = up_trend + self._down_trend = down_trend + + @property + def up_trend(self) -> Union[bool, None]: + + if self._up_trend and not self._down_trend: + return True + else: + return None + + @up_trend.setter + def up_trend(self, value: bool): + self._up_trend = value + + @property + def down_trend(self) -> Union[bool, None]: + + if self._down_trend and not self._up_trend: + return True + else: + return None + + @down_trend.setter + def down_trend(self, value: bool): + self._down_trend = value + + def __str__(self): + return f"DateRange({self.start_date}, {self.end_date}, {self.name})" + + def __repr__(self): + return f"DateRange(Name: {self.name} " + \ + f"Start date: {self.start_date} " + \ + f"End date: {self.end_date})" diff --git a/pyindicators/indicators/__init__.py b/pyindicators/indicators/__init__.py index 4d2fa80..56f9b93 100644 --- a/pyindicators/indicators/__init__.py +++ b/pyindicators/indicators/__init__.py @@ -14,6 +14,7 @@ has_values_below_threshold from .is_down_trend import is_down_trend from .is_up_trend import is_up_trend +from .up_and_down_trends import up_and_downtrends __all__ = [ 'sma', @@ -42,4 +43,5 @@ 'has_values_below_threshold', 'is_down_trend', 'is_up_trend', + 'up_and_downtrends' ] diff --git a/pyindicators/indicators/up_and_down_trends.py b/pyindicators/indicators/up_and_down_trends.py new file mode 100644 index 0000000..b62fb48 --- /dev/null +++ b/pyindicators/indicators/up_and_down_trends.py @@ -0,0 +1,135 @@ +from typing import Union, List +from datetime import timedelta + +from pandas import DataFrame as PdDataFrame +from polars import DataFrame as PlDataFrame +import pandas as pd + +from .exponential_moving_average import ema +from .utils import is_above +from pyindicators.date_range import DateRange +from pyindicators.exceptions import PyIndicatorException + + +def up_and_downtrends( + data: Union[PdDataFrame, PlDataFrame] +) -> List[DateRange]: + """ + Function to get the up and down trends of a pandas dataframe. + + Params: + data: pd.Dataframe - instance of pandas Dateframe + containing OHLCV data. + + Returns: + List of date ranges that with up_trend and down_trend + flags specified. + """ + + # Check if the data is larger then 200 data points + if len(data) < 200: + raise PyIndicatorException( + "The data must be larger than 200 data " + + "points to determine up and down trends." + ) + + if isinstance(data, PlDataFrame): + # Convert Polars DataFrame to Pandas DataFrame + data = data.to_pandas() + + selection = data.copy() + selection = ema( + selection, + source_column="Close", + period=50, + result_column="SMA_Close_50" + ) + selection = ema( + selection, + source_column="Close", + period=200, + result_column="SMA_Close_200" + ) + + # Make selections based on the trend + current_trend = None + start_date_range = selection.index[0] + date_ranges = [] + + for idx, row in enumerate(selection.itertuples(index=True), start=1): + selected_rows = selection.iloc[:idx] + + # Check if last row is null for the SMA_50 and SMA_200 + if pd.isnull(selected_rows["SMA_Close_50"].iloc[-1]) \ + or pd.isnull(selected_rows["SMA_Close_200"].iloc[-1]): + continue + + if is_above( + selected_rows, + fast_column="SMA_Close_50", + slow_column="SMA_Close_200" + ): + if current_trend != 'Up': + + if current_trend is not None: + end_date = selection.loc[ + row.Index - timedelta(days=1) + ].name + date_ranges.append( + DateRange( + start_date=start_date_range, + end_date=end_date, + name=current_trend, + down_trend=True + ) + ) + start_date_range = row.Index + current_trend = 'Up' + else: + current_trend = 'Up' + start_date_range = row.Index + else: + + if current_trend != 'Down': + + if current_trend is not None: + end_date = selection.loc[ + row.Index - timedelta(days=1) + ].name + date_ranges.append( + DateRange( + start_date=start_date_range, + end_date=end_date, + name=current_trend, + up_trend=True + ) + ) + start_date_range = row.Index + current_trend = 'Down' + else: + current_trend = 'Down' + start_date_range = row.Index + + if current_trend is not None: + end_date = selection.index[-1] + + if current_trend == 'Up': + date_ranges.append( + DateRange( + start_date=start_date_range, + end_date=end_date, + name=current_trend, + up_trend=True + ) + ) + else: + date_ranges.append( + DateRange( + start_date=start_date_range, + end_date=end_date, + name=current_trend, + down_trend=True + ) + ) + + return date_ranges