diff --git a/stock_indicators/indicators/__init__.py b/stock_indicators/indicators/__init__.py index 3aa0c6da..65e93bd4 100644 --- a/stock_indicators/indicators/__init__.py +++ b/stock_indicators/indicators/__init__.py @@ -7,6 +7,7 @@ from .alligator import (get_alligator) from .alma import (get_alma) from .aroon import (get_aroon) +from .atr_stop import (get_atr_stop) from .atr import (get_atr) from .awesome import (get_awesome) from .basic_quotes import (get_basic_quote) diff --git a/stock_indicators/indicators/atr_stop.py b/stock_indicators/indicators/atr_stop.py new file mode 100644 index 00000000..1444b169 --- /dev/null +++ b/stock_indicators/indicators/atr_stop.py @@ -0,0 +1,83 @@ +from decimal import Decimal +from typing import Iterable, Optional, TypeVar + +from stock_indicators._cslib import CsIndicator +from stock_indicators._cstypes import List as CsList +from stock_indicators._cstypes import Decimal as CsDecimal +from stock_indicators._cstypes import to_pydecimal +from stock_indicators.indicators.common.enums import EndType +from stock_indicators.indicators.common.helpers import RemoveWarmupMixin +from stock_indicators.indicators.common.results import IndicatorResults, ResultBase +from stock_indicators.indicators.common.quote import Quote + + +def get_atr_stop(quotes: Iterable[Quote], lookback_periods: int = 21, + multiplier: float = 3, end_type: EndType = EndType.CLOSE): + """Get ATR Trailing Stop calculated. + + ATR Trailing Stop attempts to determine the primary trend of prices by using + Average True Range (ATR) band thresholds. It can indicate a buy/sell signal or a + trailing stop when the trend changes. + + Parameters: + `quotes` : Iterable[Quote] + Historical price quotes. + + `lookback_periods` : int, defaults 21 + Number of periods for ATR. + + `multiplier` : float, defaults 3 + Multiplier sets the ATR band width. + + `end_type` : EndType, defaults EndType.CLOSE + Sets basis for stop offsets (Close or High/Low). + + Returns: + `AtrStopResults[AtrStopResult]` + AtrStopResults is list of AtrStopResult with providing useful helper methods. + + See more: + - [ATR Trailing Stop Reference](https://python.stockindicators.dev/indicators/AtrStop/#content) + - [Helper Methods](https://python.stockindicators.dev/utilities/#content) + """ + results = CsIndicator.GetAtrStop[Quote](CsList(Quote, quotes), lookback_periods, multiplier, end_type.cs_value) + return AtrStopResults(results, AtrStopResult) + + +class AtrStopResult(ResultBase): + """ + A wrapper class for a single unit of ATR Trailing Stop results. + """ + + @property + def atr_stop(self) -> Optional[Decimal]: + return to_pydecimal(self._csdata.AtrStop) + + @atr_stop.setter + def atr_stop(self, value): + self._csdata.AtrStop = CsDecimal(value) + + @property + def buy_stop(self) -> Optional[Decimal]: + return to_pydecimal(self._csdata.BuyStop) + + @buy_stop.setter + def buy_stop(self, value): + self._csdata.BuyStop = CsDecimal(value) + + @property + def sell_stop(self) -> Optional[Decimal]: + return to_pydecimal(self._csdata.SellStop) + + @sell_stop.setter + def sell_stop(self, value): + self._csdata.SellStop = CsDecimal(value) + + +_T = TypeVar("_T", bound=AtrStopResult) +class AtrStopResults(RemoveWarmupMixin, IndicatorResults[_T]): + """ + A wrapper class for the list of ATR Trailing Stop results. + It is exactly same with built-in `list` except for that it provides + some useful helper methods written in CSharp implementation. + """ diff --git a/tests/test_atr_stop.py b/tests/test_atr_stop.py new file mode 100644 index 00000000..1b690160 --- /dev/null +++ b/tests/test_atr_stop.py @@ -0,0 +1,107 @@ +import pytest +from stock_indicators import indicators +from stock_indicators.indicators.common.enums import EndType + +class TestAtrStop: + def test_standard(self, quotes): + results = indicators.get_atr_stop(quotes, 21, 3, EndType.CLOSE) + + assert 502 == len(results) + assert 481 == len(list(filter(lambda x: x.atr_stop is not None, results))) + + r = results[20] + assert r.atr_stop is None + assert r.buy_stop is None + assert r.sell_stop is None + + r = results[21] + assert 211.13 == round(float(r.atr_stop), 4) + assert r.buy_stop is None + assert r.atr_stop == r.sell_stop + + r = results[151] + assert 232.7861 == round(float(r.atr_stop), 4) + assert r.buy_stop is None + assert r.atr_stop == r.sell_stop + + r = results[152] + assert 236.3913 == round(float(r.atr_stop), 4) + assert r.atr_stop == r.buy_stop + assert r.sell_stop is None + + r = results[249] + assert 253.8863 == round(float(r.atr_stop), 4) + assert r.buy_stop is None + assert r.atr_stop == r.sell_stop + + r = results[501] + assert 246.3232 == round(float(r.atr_stop), 4) + assert r.atr_stop == r.buy_stop + assert r.sell_stop is None + + def test_high_low(self, quotes): + results = indicators.get_atr_stop(quotes, 21, 3, EndType.HIGH_LOW) + + assert 502 == len(results) + assert 481 == len(list(filter(lambda x: x.atr_stop is not None, results))) + + r = results[20] + assert r.atr_stop is None + assert r.buy_stop is None + assert r.sell_stop is None + + r = results[21] + assert 210.23 == round(float(r.atr_stop), 4) + assert r.buy_stop is None + assert r.atr_stop == r.sell_stop + + r = results[69] + assert 221.0594 == round(float(r.atr_stop), 4) + assert r.buy_stop is None + assert r.atr_stop == r.sell_stop + + r = results[70] + assert 226.4624 == round(float(r.atr_stop), 4) + assert r.atr_stop == r.buy_stop + assert r.sell_stop is None + + r = results[249] + assert 253.4863 == round(float(r.atr_stop), 4) + assert r.buy_stop is None + assert r.atr_stop == r.sell_stop + + r = results[501] + assert 252.6932 == round(float(r.atr_stop), 4) + assert r.atr_stop == r.buy_stop + assert r.sell_stop is None + + + def test_bad_data(self, bad_quotes): + r = indicators.get_atr_stop(bad_quotes, 7) + + assert 502 == len(r) + + def test_no_quotes(self, quotes): + r = indicators.get_atr_stop([]) + assert 0 == len(r) + + r = indicators.get_atr_stop(quotes[:1]) + assert 1 == len(r) + + def test_removed(self, quotes): + results = indicators.get_atr_stop(quotes, 21, 3).remove_warmup_periods() + + assert 481 == len(results) + + last = results.pop() + assert 246.3232 == round(float(last.atr_stop), 4) + assert last.atr_stop == last.buy_stop + assert last.sell_stop is None + + def test_exceptions(self, quotes): + from System import ArgumentOutOfRangeException + with pytest.raises(ArgumentOutOfRangeException): + indicators.get_atr_stop(quotes, 1) + + with pytest.raises(ArgumentOutOfRangeException): + indicators.get_atr_stop(quotes, 7, 0)