From 90b86aa638154b98bd2dbc3ad72c121818db530b Mon Sep 17 00:00:00 2001 From: Gregory Morse Date: Thu, 9 Apr 2026 10:41:37 +0200 Subject: [PATCH] feat: add get_levels() for support/resistance level query (closes #11) Given the return value of calc_support_resistance, a series index, and the current price, get_levels() evaluates every computed trend line at that index and classifies the results as support (level <= price) or resistance (level > price). Each level entry is (level, strength, slope, intercept) sorted by proximity to price (up to n levels each). Risk-to-reward ratios are also returned for each resistance level relative to the nearest support. --- CHANGELOG.md | 9 ++++ setup.py | 2 +- tests/test_trendln.py | 113 ++++++++++++++++++++++++++++++++++++++++++ trendln/__init__.py | 74 +++++++++++++++++++++++++++ 4 files changed, 197 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07ab373..f0b3887 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ Change Log =========== +0.1.11 +------- +- Added `get_levels(calc_result, x, price, n=3)` function: given a pre-computed + `calc_support_resistance` result, evaluates all trend lines at a given series + index to return the nearest support levels, nearest resistance levels (each as + `(level, strength, slope, intercept)` sorted by proximity) and the + risk-to-reward ratios of each resistance level versus the nearest support + (closes #11) + 0.1.8 ------- - Initial release diff --git a/setup.py b/setup.py index f265d15..9eacdc7 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ setup( name='trendln', - version="0.1.10", + version="0.1.11", description='Support and Resistance Trend lines Calculator for Financial Analysis', long_description=long_description, long_description_content_type='text/markdown', diff --git a/tests/test_trendln.py b/tests/test_trendln.py index eac5ea3..79f2db8 100644 --- a/tests/test_trendln.py +++ b/tests/test_trendln.py @@ -17,6 +17,7 @@ from trendln import ( calc_support_resistance, get_extrema, + get_levels, METHOD_NAIVE, METHOD_NAIVECONSEC, METHOD_NUMDIFF, @@ -276,3 +277,115 @@ def test_mismatched_tuple_lengths(self): def test_tuple_both_none_raises(self): with pytest.raises(ValueError): calc_support_resistance((None, None)) + + +# --------------------------------------------------------------------------- +# get_levels +# --------------------------------------------------------------------------- + +# DATA_SIMPLE: flat support at 0.0 (3 pivot points), flat resistance at 3.0 +# (4 pivot points). Trend lines are exact so evaluated level == intercept +# regardless of x. + +class TestGetLevels: + """Tests for trendln.get_levels().""" + + def _result(self, price=1.5, x=20, n=3): + calc = calc_support_resistance(DATA_SIMPLE, extmethod=METHOD_NAIVE) + return get_levels(calc, x, price, n) + + # --- basic classification and ordering --- + + def test_support_below_price(self): + supports, resistances, _ = self._result(price=1.5) + assert len(supports) == 1 + inf_lvl, strength, slope, intercept = supports[0] + assert math.isclose(inf_lvl, 0.0) + assert strength == 3 + assert math.isclose(slope, 0.0) + assert math.isclose(intercept, 0.0) + + def test_resistance_above_price(self): + _, resistances, _ = self._result(price=1.5) + assert len(resistances) == 1 + r_lvl, strength, slope, intercept = resistances[0] + assert math.isclose(r_lvl, 3.0) + assert strength == 4 + assert math.isclose(slope, 0.0) + assert math.isclose(intercept, 3.0) + + # --- risk-to-reward ratio --- + + def test_rr_ratio_basic(self): + # price=1.5, support=0.0, resistance=3.0 -> RR=(3.0-1.5)/(1.5-0.0)=1.0 + _, _, rr = self._result(price=1.5) + assert len(rr) == 1 + assert math.isclose(rr[0], 1.0) + + def test_rr_ratio_asymmetric(self): + # price=0.5, support=0.0, resistance=3.0 -> RR=2.5/0.5=5.0 + _, _, rr = self._result(price=0.5) + assert len(rr) == 1 + assert math.isclose(rr[0], 5.0) + + # --- edge cases --- + + def test_no_support_gives_none_rr(self): + # price below all trend lines -> no supports, both levels are resistance + supports, resistances, rr = self._result(price=-1.0) + assert len(supports) == 0 + # both 0.0 and 3.0 are above -1.0 -> resistance + assert len(resistances) == 2 + assert all(r is None for r in rr) + + def test_price_at_support_gives_inf_rr(self): + # support level exactly equals price -> risk == 0 -> inf + _, _, rr = self._result(price=0.0) + assert len(rr) == 1 + assert rr[0] == float('inf') + + def test_price_at_resistance_no_resistance(self): + # 3.0 is not strictly > price so listed as support, nothing above + supports, resistances, rr = self._result(price=3.0) + assert len(resistances) == 0 + assert rr == [] + # both lines (0.0 and 3.0) are <= 3.0 -> both supports + assert len(supports) == 2 + + def test_n_limits_results(self): + # Only one support and one resistance exist in DATA_SIMPLE, so n doesn't clip further, + # but verifying the parameter is honoured when n=0. + supports, resistances, rr = self._result(price=1.5, n=0) + assert supports == [] + assert resistances == [] + assert rr == [] + + def test_supports_sorted_nearest_first(self): + # Create data with multiple support levels so ordering can be checked. + # Use a data set where both mintrend and maxtrend produce levels below price. + calc = calc_support_resistance(DATA_SIMPLE, extmethod=METHOD_NAIVE) + supports, _, _ = get_levels(calc, 20, 4.0) # price above all lines (0.0 & 3.0) + levels = [s[0] for s in supports] + # Nearest to 4.0 first: 3.0 then 0.0 + assert math.isclose(levels[0], 3.0) + assert math.isclose(levels[1], 0.0) + + def test_resistances_sorted_nearest_first(self): + calc = calc_support_resistance(DATA_SIMPLE, extmethod=METHOD_NAIVE) + _, resistances, _ = get_levels(calc, 20, -1.0) # price below all lines + levels = [r[0] for r in resistances] + # Nearest to -1.0 first: 0.0 then 3.0 + assert math.isclose(levels[0], 0.0) + assert math.isclose(levels[1], 3.0) + + # --- invalid input --- + + def test_invalid_calc_result_raises(self): + with pytest.raises(ValueError): + get_levels(None, 10, 1.5) + + def test_invalid_calc_result_single_side_raises(self): + # Single-side result (4-tuple) is not accepted + single = calc_support_resistance((DATA_SIMPLE, None)) + with pytest.raises(ValueError): + get_levels(single, 10, 1.5) diff --git a/trendln/__init__.py b/trendln/__init__.py index 83df05d..3a8b092 100644 --- a/trendln/__init__.py +++ b/trendln/__init__.py @@ -751,6 +751,80 @@ def calc_all(idxs, h, isMin): if hmin is None: return (extremaIdxs, pmax, maxtrend, maxwindows) return (extremaIdxs[0], pmin, mintrend, minwindows), (extremaIdxs[1], pmax, maxtrend, maxwindows) +def get_levels(calc_result, x, price, n=3): + """Evaluate computed trend lines at a given index to find support and + resistance levels, their strength, and risk-to-reward ratios. + + Parameters + ---------- + calc_result : tuple + The full 2-tuple return value of :func:`calc_support_resistance`, + i.e. ``((minimaIdxs, pmin, mintrend, minwindows), + (maximaIdxs, pmax, maxtrend, maxwindows))``. + x : int + Series index at which to evaluate trend lines. Use + ``len(h) - 1`` to query the most recent bar. + price : float + Observed price at index *x*, used to classify each extrapolated + level as support (``level <= price``) or resistance + (``level > price``). + n : int, optional + Maximum number of support and resistance levels to return each + (default 3). + + Returns + ------- + support_levels : list of (float, int, float, float) + Up to *n* support levels at or below *price*, sorted nearest-first. + Each entry is ``(level, strength, slope, intercept)`` where + *level* is the trend line value extrapolated to index *x*, + *strength* is the number of pivot points the line passes through, + and *slope* / *intercept* are the line coefficients. + resistance_levels : list of (float, int, float, float) + Up to *n* resistance levels above *price*, same format. + rr_ratios : list of float or None + Risk-to-reward ratio for each ``resistance_levels[i]``: + ``(resistance_level - price) / (price - nearest_support)``. + Each entry is ``None`` when no support level exists, or ``inf`` + when the nearest support equals *price* (zero risk). + """ + if not (isinstance(calc_result, tuple) and len(calc_result) == 2 and + isinstance(calc_result[0], tuple) and len(calc_result[0]) == 4 and + isinstance(calc_result[1], tuple) and len(calc_result[1]) == 4): + raise ValueError( + 'calc_result must be the full 2-tuple output of calc_support_resistance') + + def _extract(trend): + levels = [] + for points, result in trend: + slope, intercept = result[0], result[1] + levels.append((slope * x + intercept, len(points), slope, intercept)) + return levels + + _, _, mintrend, _ = calc_result[0] + _, _, maxtrend, _ = calc_result[1] + + all_levels = _extract(mintrend) + _extract(maxtrend) + + supports = sorted( + [t for t in all_levels if t[0] <= price], + key=lambda t: price - t[0])[:n] + resistances = sorted( + [t for t in all_levels if t[0] > price], + key=lambda t: t[0] - price)[:n] + + nearest_support = supports[0][0] if supports else None + rr_ratios = [] + for r_level, _, _, _ in resistances: + if nearest_support is None: + rr_ratios.append(None) + else: + risk = price - nearest_support + rr_ratios.append( + float('inf') if risk == 0 else (r_level - price) / risk) + + return supports, resistances, rr_ratios + def plot_sup_res_date(hist, idx, numbest = 2, fromwindows = True, pctbound=0.1, extmethod = METHOD_NUMDIFF, method=METHOD_NSQUREDLOGN, window=125, errpct = 0.005, hough_scale=0.01, hough_prob_iter=10, sortError=False, accuracy=2):