diff --git a/stock_indicators/indicators/fractal.py b/stock_indicators/indicators/fractal.py index 43d14ee5..1384ec9f 100644 --- a/stock_indicators/indicators/fractal.py +++ b/stock_indicators/indicators/fractal.py @@ -26,7 +26,12 @@ def get_fractal( def get_fractal( - quotes, left_span=None, right_span=EndType.HIGH_LOW, end_type=EndType.HIGH_LOW + quotes, + left_span=None, + right_span=None, + end_type=None, + *, + window_span=None, ): """Get Williams Fractal calculated. @@ -57,11 +62,35 @@ def get_fractal( - [Williams Fractal Reference](https://python.stockindicators.dev/indicators/Fractal/#content) - [Helper Methods](https://python.stockindicators.dev/utilities/#content) """ + # `window_span` is the documented name for the symmetric form's span. + # It is an alternative to the left/right span pair, not a third span, + # so it cannot be combined with either span argument. + if window_span is not None: + if left_span is not None or right_span is not None: + raise TypeError( + "window_span cannot be combined with left_span or right_span" + ) + left_span = window_span + + # In the symmetric form the second positional argument is `end_type`, + # not `right_span`: get_fractal(quotes, 2, EndType.CLOSE) if isinstance(right_span, EndType): - if left_span is None: - left_span = 2 + if end_type is not None and end_type != right_span: + raise ValueError( + "end_type was given twice with different values: " + f"{right_span} and {end_type}" + ) + end_type = right_span + right_span = None + + if left_span is None: + left_span = 2 + if end_type is None: + end_type = EndType.HIGH_LOW + + if right_span is None: fractal_results = CsIndicator.GetFractal[Quote]( - CsList(Quote, quotes), left_span, right_span.cs_value + CsList(Quote, quotes), left_span, end_type.cs_value ) else: fractal_results = CsIndicator.GetFractal[Quote]( diff --git a/tests/test_fractal.py b/tests/test_fractal.py index 0956815e..9179c9c1 100644 --- a/tests/test_fractal.py +++ b/tests/test_fractal.py @@ -67,6 +67,75 @@ def test_standard_span_4(self, quotes): assert r.fractal_bear is None assert r.fractal_bull is None + def test_window_span_keyword(self, quotes): + # Documented form: get_fractal(quotes, window_span=4, end_type=...). + # A non-default span, so an ignored keyword would change the result. + results = indicators.get_fractal(quotes, window_span=4) + + assert 502 == len(results) + assert 35 == len(list(filter(lambda x: x.fractal_bear is not None, results))) + assert 34 == len(list(filter(lambda x: x.fractal_bull is not None, results))) + + r = results[7] + assert r.fractal_bear is None + assert 212.53 == round(float(r.fractal_bull), 2) + + def test_window_span_with_end_type_keyword(self, quotes): + results = indicators.get_fractal(quotes, window_span=2, end_type=EndType.CLOSE) + positional = indicators.get_fractal(quotes, 2, EndType.CLOSE) + + assert 64 == len(list(filter(lambda x: x.fractal_bear is not None, results))) + assert 69 == len(list(filter(lambda x: x.fractal_bull is not None, results))) + + for keyword_result, positional_result in zip(results, positional): + assert keyword_result.fractal_bear == positional_result.fractal_bear + assert keyword_result.fractal_bull == positional_result.fractal_bull + + def test_end_type_keyword_window_form(self, quotes): + # end_type given as a keyword in the window-span form must not be + # silently ignored: it must match the positional EndType form. + results = indicators.get_fractal(quotes, 2, end_type=EndType.CLOSE) + positional = indicators.get_fractal(quotes, 2, EndType.CLOSE) + + assert 64 == len(list(filter(lambda x: x.fractal_bear is not None, results))) + assert 69 == len(list(filter(lambda x: x.fractal_bull is not None, results))) + + for keyword_result, positional_result in zip(results, positional): + assert keyword_result.fractal_bear == positional_result.fractal_bear + assert keyword_result.fractal_bull == positional_result.fractal_bull + + def test_end_type_keyword_only(self, quotes): + results = indicators.get_fractal(quotes, end_type=EndType.CLOSE) + + assert 64 == len(list(filter(lambda x: x.fractal_bear is not None, results))) + assert 69 == len(list(filter(lambda x: x.fractal_bull is not None, results))) + + def test_end_type_keyword_asymmetric_form(self, quotes): + results = indicators.get_fractal(quotes, 4, 4, end_type=EndType.HIGH_LOW) + positional = indicators.get_fractal(quotes, 4, 4, EndType.HIGH_LOW) + + for keyword_result, positional_result in zip(results, positional): + assert keyword_result.fractal_bear == positional_result.fractal_bear + assert keyword_result.fractal_bull == positional_result.fractal_bull + + def test_window_span_conflicts_raise(self, quotes): + with pytest.raises(TypeError, match="window_span cannot be combined"): + indicators.get_fractal(quotes, 2, 3, window_span=4) + with pytest.raises(TypeError, match="window_span cannot be combined"): + indicators.get_fractal(quotes, window_span=2, right_span=2) + with pytest.raises(TypeError, match="window_span cannot be combined"): + indicators.get_fractal(quotes, left_span=2, window_span=2) + + def test_end_type_conflict_raises(self, quotes): + with pytest.raises(ValueError, match="end_type was given twice"): + indicators.get_fractal(quotes, 2, EndType.CLOSE, end_type=EndType.HIGH_LOW) + + # The same value given twice is not a conflict. + results = indicators.get_fractal( + quotes, 2, EndType.CLOSE, end_type=EndType.CLOSE + ) + assert 64 == len(list(filter(lambda x: x.fractal_bear is not None, results))) + def test_no_data(self, quotes): r = indicators.get_fractal([]) assert 0 == len(r)