Skip to content

Commit 9fbba8d

Browse files
committed
Add new indicators: EMA Trend Ribbon and Zero-Lag EMA Envelope, along with tests and documentation updates.
1 parent d710457 commit 9fbba8d

10 files changed

Lines changed: 1115 additions & 5 deletions

File tree

README.md

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ pip install pyindicators
2929
* [Weighted Moving Average (WMA)](#weighted-moving-average-wma)
3030
* [Simple Moving Average (SMA)](#simple-moving-average-sma)
3131
* [Exponential Moving Average (EMA)](#exponential-moving-average-ema)
32+
* [EMA Trend Ribbon](#ema-trend-ribbon)
3233
* [SuperTrend](#supertrend)
3334
* [SuperTrend Clustering](#supertrend-clustering)
3435
* [Momentum and Oscillators](#momentum-and-oscillators)
@@ -45,6 +46,7 @@ pip install pyindicators
4546
* [Average True Range (ATR)](#average-true-range-atr)
4647
* [Moving Average Envelope (MAE)](#moving-average-envelope-mae)
4748
* [Nadaraya-Watson Envelope (NWE)](#nadaraya-watson-envelope-nwe)
49+
* [Zero-Lag EMA Envelope (ZLEMA)](#zero-lag-ema-envelope-zlema)
4850
* [Support and Resistance](#support-and-resistance)
4951
* [Fibonacci Retracement](#fibonacci-retracement)
5052
* [Golden Zone](#golden-zone)
@@ -906,10 +908,140 @@ pd_df.tail(10)
906908

907909
![NADARAYA_WATSON_ENVELOPE](https://github.com/coding-kitties/PyIndicators/blob/main/static/images/indicators/nadaraya_watson_envelope.png)
908910

911+
#### Zero-Lag EMA Envelope (ZLEMA)
912+
913+
The Zero-Lag EMA Envelope combines a Zero-Lag Exponential Moving Average (ZLEMA) with ATR-based bands and multi-bar swing confirmation. The ZLEMA compensates for the inherent lag of a standard EMA by using a lag-compensated source (`close + (close - close[lag])`). Trend state is confirmed when multiple consecutive bars close beyond a band while the ZLEMA slope agrees.
914+
915+
Calculation:
916+
- `lag = floor((length - 1) / 2)`
917+
- `compensated = close + (close - close[lag])`
918+
- `ZLEMA = EMA(compensated, length)`
919+
- `Upper = ZLEMA + ATR × mult`
920+
- `Lower = ZLEMA - ATR × mult`
921+
- Bull: close > Upper for N bars AND ZLEMA rising
922+
- Bear: close < Lower for N bars AND ZLEMA falling
923+
924+
```python
925+
def zero_lag_ema_envelope(
926+
data: Union[PdDataFrame, PlDataFrame],
927+
source_column: str = 'Close',
928+
length: int = 200,
929+
mult: float = 2.0,
930+
atr_length: int = 21,
931+
confirm_bars: int = 2,
932+
upper_column: str = 'zlema_upper',
933+
lower_column: str = 'zlema_lower',
934+
middle_column: str = 'zlema_middle',
935+
trend_column: str = 'zlema_trend',
936+
signal_column: str = 'zlema_signal',
937+
) -> Union[PdDataFrame, PlDataFrame]:
938+
```
939+
940+
Example
941+
942+
```python
943+
from investing_algorithm_framework import download
944+
945+
from pyindicators import zero_lag_ema_envelope
946+
947+
pl_df = download(
948+
symbol="btc/eur",
949+
market="binance",
950+
time_frame="1d",
951+
start_date="2023-12-01",
952+
end_date="2023-12-25",
953+
save=True,
954+
storage_path="./data"
955+
)
956+
pd_df = download(
957+
symbol="btc/eur",
958+
market="binance",
959+
time_frame="1d",
960+
start_date="2023-12-01",
961+
end_date="2023-12-25",
962+
pandas=True,
963+
save=True,
964+
storage_path="./data"
965+
)
966+
967+
# Calculate Zero-Lag EMA Envelope for Polars DataFrame
968+
pl_df = zero_lag_ema_envelope(pl_df, source_column="Close", length=200, mult=2.0)
969+
pl_df.show(10)
970+
971+
# Calculate Zero-Lag EMA Envelope for Pandas DataFrame
972+
pd_df = zero_lag_ema_envelope(pd_df, source_column="Close", length=200, mult=2.0)
973+
pd_df.tail(10)
974+
```
975+
976+
![ZERO_LAG_EMA_ENVELOPE](https://github.com/coding-kitties/PyIndicators/blob/main/static/images/indicators/zero_lag_ema_envelope.png)
977+
909978
### Trend Following
910979

911980
Indicators that combine trend detection with adaptive trailing stops.
912981

982+
#### EMA Trend Ribbon
983+
984+
The EMA Trend Ribbon uses 9 Exponential Moving Averages with increasing periods to visualise trend strength and direction. At each bar the slope of every EMA is checked over a smoothing window; when a threshold number of EMAs agree on direction (default 7 out of 9) the trend is classified as bullish or bearish.
985+
986+
Calculation:
987+
- Compute 9 EMAs with periods [8, 14, 20, 26, 32, 38, 44, 50, 60]
988+
- An EMA is "rising" when `EMA[t] >= EMA[t - smoothing_period]`
989+
- `bullish_count` = number of rising EMAs
990+
- `bearish_count` = number of falling EMAs
991+
- Trend = 1 if `bullish_count >= threshold`, -1 if `bearish_count >= threshold`, else 0
992+
993+
```python
994+
def ema_trend_ribbon(
995+
data: Union[PdDataFrame, PlDataFrame],
996+
source_column: str = 'Close',
997+
ema_lengths: Optional[List[int]] = None, # default [8,14,20,26,32,38,44,50,60]
998+
smoothing_period: int = 2,
999+
threshold: int = 7,
1000+
trend_column: str = 'ema_ribbon_trend',
1001+
bullish_count_column: str = 'ema_ribbon_bullish_count',
1002+
bearish_count_column: str = 'ema_ribbon_bearish_count',
1003+
ema_column_prefix: str = 'ema_ribbon',
1004+
) -> Union[PdDataFrame, PlDataFrame]:
1005+
```
1006+
1007+
Example
1008+
1009+
```python
1010+
from investing_algorithm_framework import download
1011+
1012+
from pyindicators import ema_trend_ribbon
1013+
1014+
pl_df = download(
1015+
symbol="btc/eur",
1016+
market="binance",
1017+
time_frame="1d",
1018+
start_date="2023-12-01",
1019+
end_date="2023-12-25",
1020+
save=True,
1021+
storage_path="./data"
1022+
)
1023+
pd_df = download(
1024+
symbol="btc/eur",
1025+
market="binance",
1026+
time_frame="1d",
1027+
start_date="2023-12-01",
1028+
end_date="2023-12-25",
1029+
pandas=True,
1030+
save=True,
1031+
storage_path="./data"
1032+
)
1033+
1034+
# Calculate EMA Trend Ribbon for Polars DataFrame
1035+
pl_df = ema_trend_ribbon(pl_df, source_column="Close")
1036+
pl_df.show(10)
1037+
1038+
# Calculate EMA Trend Ribbon for Pandas DataFrame
1039+
pd_df = ema_trend_ribbon(pd_df, source_column="Close")
1040+
pd_df.tail(10)
1041+
```
1042+
1043+
![EMA_TREND_RIBBON](https://github.com/coding-kitties/PyIndicators/blob/main/static/images/indicators/ema_trend_ribbon.png)
1044+
9131045
#### SuperTrend
9141046

9151047
The SuperTrend indicator uses a fixed ATR multiplier factor to create a trend-following trailing stop. When the price is above the SuperTrend line the trend is bullish; when below, bearish. Trend changes generate buy/sell signals.

pyindicators/__init__.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121
get_momentum_confluence_stats,
2222
supertrend_clustering, supertrend, supertrend_signal,
2323
get_supertrend_stats,
24-
nadaraya_watson_envelope
24+
nadaraya_watson_envelope,
25+
zero_lag_ema_envelope,
26+
ema_trend_ribbon
2527
)
2628
from .exceptions import PyIndicatorException
2729
from .date_range import DateRange
@@ -109,5 +111,7 @@ def get_version():
109111
'supertrend',
110112
'supertrend_signal',
111113
'get_supertrend_stats',
112-
'nadaraya_watson_envelope'
114+
'nadaraya_watson_envelope',
115+
'zero_lag_ema_envelope',
116+
'ema_trend_ribbon'
113117
]

pyindicators/indicators/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@
4545
get_supertrend_stats
4646
)
4747
from .nadaraya_watson_envelope import nadaraya_watson_envelope
48+
from .zero_lag_ema_envelope import zero_lag_ema_envelope
49+
from .ema_trend_ribbon import ema_trend_ribbon
4850

4951
__all__ = [
5052
'sma',
@@ -114,5 +116,7 @@
114116
'supertrend',
115117
'supertrend_signal',
116118
'get_supertrend_stats',
117-
'nadaraya_watson_envelope'
119+
'nadaraya_watson_envelope',
120+
'zero_lag_ema_envelope',
121+
'ema_trend_ribbon'
118122
]
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
"""
2+
EMA Trend Ribbon Indicator
3+
4+
Uses a set of 9 Exponential Moving Averages with increasing periods to
5+
visualise trend strength and direction. When the majority of EMAs are
6+
rising the trend is bullish; when most are falling it is bearish.
7+
"""
8+
from typing import Union, List, Optional
9+
10+
import numpy as np
11+
from pandas import DataFrame as PdDataFrame
12+
from polars import DataFrame as PlDataFrame
13+
import polars as pl
14+
15+
from pyindicators.exceptions import PyIndicatorException
16+
17+
18+
def _calc_ema(src: np.ndarray, period: int) -> np.ndarray:
19+
"""Compute EMA over a numpy array."""
20+
n = len(src)
21+
alpha = 2.0 / (period + 1)
22+
out = np.empty(n)
23+
out[0] = src[0]
24+
25+
for i in range(1, n):
26+
out[i] = src[i] * alpha + out[i - 1] * (1.0 - alpha)
27+
28+
return out
29+
30+
31+
def ema_trend_ribbon(
32+
data: Union[PdDataFrame, PlDataFrame],
33+
source_column: str = 'Close',
34+
ema_lengths: Optional[List[int]] = None,
35+
smoothing_period: int = 2,
36+
threshold: int = 7,
37+
trend_column: str = 'ema_ribbon_trend',
38+
bullish_count_column: str = 'ema_ribbon_bullish_count',
39+
bearish_count_column: str = 'ema_ribbon_bearish_count',
40+
ema_column_prefix: str = 'ema_ribbon',
41+
) -> Union[PdDataFrame, PlDataFrame]:
42+
"""
43+
Calculate the EMA Trend Ribbon indicator.
44+
45+
Computes 9 EMAs with increasing periods and determines the overall
46+
trend by counting how many EMAs are rising vs falling over a
47+
smoothing period. When *threshold* or more EMAs agree on
48+
direction the trend is classified as bullish (1) or bearish (-1);
49+
otherwise neutral (0).
50+
51+
Calculation:
52+
- For each EMA period, compute EMA(source, period)
53+
- An EMA is "rising" when EMA[t] >= EMA[t - smoothing_period]
54+
- bullish_count = number of rising EMAs
55+
- bearish_count = number of falling EMAs
56+
- trend = 1 if bullish_count >= threshold,
57+
-1 if bearish_count >= threshold, else 0
58+
59+
Args:
60+
data: pandas or polars DataFrame with price data.
61+
source_column: Column name for the source prices
62+
(default: 'Close').
63+
ema_lengths: List of EMA periods (default: [8, 14, 20, 26,
64+
32, 38, 44, 50, 60]).
65+
smoothing_period: Number of bars to look back when
66+
determining EMA slope direction (default: 2).
67+
threshold: Minimum number of EMAs that must agree for a
68+
bullish or bearish classification (default: 7, out of 9).
69+
trend_column: Result column name for the trend state
70+
(default: 'ema_ribbon_trend').
71+
1 = bullish, -1 = bearish, 0 = neutral.
72+
bullish_count_column: Result column name for the count of
73+
rising EMAs (default: 'ema_ribbon_bullish_count').
74+
bearish_count_column: Result column name for the count of
75+
falling EMAs (default: 'ema_ribbon_bearish_count').
76+
ema_column_prefix: Prefix for individual EMA result columns
77+
(default: 'ema_ribbon'). Each EMA is stored as
78+
``{prefix}_{period}``.
79+
80+
Returns:
81+
DataFrame with added columns:
82+
- {ema_column_prefix}_{period}: Each individual EMA line
83+
- {bullish_count_column}: Number of rising EMAs at each bar
84+
- {bearish_count_column}: Number of falling EMAs at each bar
85+
- {trend_column}: Trend state (1 / -1 / 0)
86+
87+
Example:
88+
>>> import pandas as pd
89+
>>> from pyindicators import ema_trend_ribbon
90+
>>> df = pd.DataFrame({
91+
... 'Close': [100, 102, 101, 103, 105, 104, 106, 108, 107, 109]
92+
... })
93+
>>> result = ema_trend_ribbon(df, smoothing_period=1, threshold=5)
94+
>>> print(result[['ema_ribbon_trend']].tail())
95+
"""
96+
if ema_lengths is None:
97+
ema_lengths = [8, 14, 20, 26, 32, 38, 44, 50, 60]
98+
99+
# --- Validation -------------------------------------------------------
100+
if smoothing_period < 1:
101+
raise PyIndicatorException(
102+
"Smoothing period must be at least 1"
103+
)
104+
105+
if threshold < 1:
106+
raise PyIndicatorException("Threshold must be at least 1")
107+
108+
if threshold > len(ema_lengths):
109+
raise PyIndicatorException(
110+
f"Threshold ({threshold}) cannot exceed the number of "
111+
f"EMAs ({len(ema_lengths)})"
112+
)
113+
114+
if len(ema_lengths) < 2:
115+
raise PyIndicatorException(
116+
"At least 2 EMA lengths are required"
117+
)
118+
119+
for length in ema_lengths:
120+
if length < 1:
121+
raise PyIndicatorException(
122+
f"All EMA lengths must be at least 1, got {length}"
123+
)
124+
125+
if source_column not in data.columns:
126+
raise PyIndicatorException(
127+
f"The column '{source_column}' does not exist "
128+
"in the DataFrame."
129+
)
130+
131+
is_polars = isinstance(data, PlDataFrame)
132+
133+
# --- Extract source array ---------------------------------------------
134+
if is_polars:
135+
src = data[source_column].to_numpy().astype(float)
136+
else:
137+
src = data[source_column].values.astype(float)
138+
139+
n = len(src)
140+
141+
# --- Compute all EMAs -------------------------------------------------
142+
ema_arrays = []
143+
144+
for length in ema_lengths:
145+
ema_arrays.append(_calc_ema(src, length))
146+
147+
# --- Slope counting ---------------------------------------------------
148+
bullish_count = np.zeros(n, dtype=int)
149+
bearish_count = np.zeros(n, dtype=int)
150+
151+
for ema_vals in ema_arrays:
152+
for t in range(n):
153+
prev_idx = t - smoothing_period
154+
155+
if prev_idx < 0:
156+
# Not enough history; treat as neutral
157+
continue
158+
159+
if ema_vals[t] >= ema_vals[prev_idx]:
160+
bullish_count[t] += 1
161+
else:
162+
bearish_count[t] += 1
163+
164+
# --- Trend classification ---------------------------------------------
165+
trend = np.zeros(n, dtype=int)
166+
167+
for t in range(n):
168+
if bullish_count[t] >= threshold:
169+
trend[t] = 1
170+
elif bearish_count[t] >= threshold:
171+
trend[t] = -1
172+
173+
# --- Write results back -----------------------------------------------
174+
if is_polars:
175+
new_cols = []
176+
177+
for length, ema_vals in zip(ema_lengths, ema_arrays):
178+
col_name = f"{ema_column_prefix}_{length}"
179+
new_cols.append(pl.Series(name=col_name, values=ema_vals))
180+
181+
new_cols.append(
182+
pl.Series(name=bullish_count_column,
183+
values=bullish_count.tolist())
184+
)
185+
new_cols.append(
186+
pl.Series(name=bearish_count_column,
187+
values=bearish_count.tolist())
188+
)
189+
new_cols.append(
190+
pl.Series(name=trend_column, values=trend.tolist())
191+
)
192+
data = data.with_columns(new_cols)
193+
else:
194+
for length, ema_vals in zip(ema_lengths, ema_arrays):
195+
col_name = f"{ema_column_prefix}_{length}"
196+
data[col_name] = ema_vals
197+
198+
data[bullish_count_column] = bullish_count
199+
data[bearish_count_column] = bearish_count
200+
data[trend_column] = trend
201+
202+
return data

0 commit comments

Comments
 (0)