Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ 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)
- Added `title`, `y_axis_label`, and `series_label` parameters to `plot_support_resistance` and `plot_sup_res_date` for customizable plot titles, y-axis labels, and series legend labels (thanks xeonvs)

0.1.8
Expand Down
113 changes: 113 additions & 0 deletions tests/test_trendln.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from trendln import (
calc_support_resistance,
get_extrema,
get_levels,
METHOD_NAIVE,
METHOD_NAIVECONSEC,
METHOD_NUMDIFF,
Expand Down Expand Up @@ -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)
74 changes: 74 additions & 0 deletions trendln/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading