Skip to content

Commit f8d37ee

Browse files
committed
Add Nadaraya-Watson Envelope and Supertrend indicators, along with tests and documentation.
1 parent eec34d0 commit f8d37ee

14 files changed

Lines changed: 1491 additions & 8 deletions

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,4 +147,7 @@ bumpversion.egg-info/
147147
test.ipynb
148148
/resources
149149

150-
*.ipynb
150+
*.ipynb
151+
152+
*.html
153+
*_test.png

README.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ pip install pyindicators
4242
* [Bollinger Bands Overshoot](#bollinger-bands-overshoot)
4343
* [Average True Range (ATR)](#average-true-range-atr)
4444
* [Moving Average Envelope (MAE)](#moving-average-envelope-mae)
45+
* [Nadaraya-Watson Envelope (NWE)](#nadaraya-watson-envelope-nwe)
4546
* [Support and Resistance](#support-and-resistance)
4647
* [Fibonacci Retracement](#fibonacci-retracement)
4748
* [Golden Zone](#golden-zone)
@@ -841,6 +842,68 @@ pd_df.tail(10)
841842

842843
![MOVING_AVERAGE_ENVELOPE](https://github.com/coding-kitties/PyIndicators/blob/main/static/images/indicators/moving_average_envelope.png)
843844

845+
#### Nadaraya-Watson Envelope (NWE)
846+
847+
The Nadaraya-Watson Envelope uses Gaussian kernel regression to create a smoothed price estimate, then adds an envelope based on the mean absolute error (MAE) scaled by a multiplier. This is a non-repainting (endpoint) implementation inspired by the TradingView "Nadaraya-Watson Envelope [LuxAlgo]" indicator. It is useful for identifying overbought/oversold zones and mean-reversion opportunities.
848+
849+
Calculation:
850+
- Kernel weights: `w(i) = exp(-i² / (2 × h²))` for `i = 0..lookback-1`
851+
- Smoothed value: `sum(src[t-i] × w(i)) / sum(w(i))`
852+
- MAE: SMA of `|src - smoothed|` over the lookback period
853+
- Upper: `smoothed + mult × MAE`
854+
- Lower: `smoothed - mult × MAE`
855+
856+
```python
857+
def nadaraya_watson_envelope(
858+
data: Union[PdDataFrame, PlDataFrame],
859+
source_column: str = 'Close',
860+
bandwidth: float = 8.0,
861+
mult: float = 3.0,
862+
lookback: int = 500,
863+
upper_column: str = 'nwe_upper',
864+
lower_column: str = 'nwe_lower',
865+
middle_column: str = 'nwe_middle',
866+
) -> Union[PdDataFrame, PlDataFrame]:
867+
```
868+
869+
Example
870+
871+
```python
872+
from investing_algorithm_framework import download
873+
874+
from pyindicators import nadaraya_watson_envelope
875+
876+
pl_df = download(
877+
symbol="btc/eur",
878+
market="binance",
879+
time_frame="1d",
880+
start_date="2023-12-01",
881+
end_date="2023-12-25",
882+
save=True,
883+
storage_path="./data"
884+
)
885+
pd_df = download(
886+
symbol="btc/eur",
887+
market="binance",
888+
time_frame="1d",
889+
start_date="2023-12-01",
890+
end_date="2023-12-25",
891+
pandas=True,
892+
save=True,
893+
storage_path="./data"
894+
)
895+
896+
# Calculate Nadaraya-Watson Envelope for Polars DataFrame
897+
pl_df = nadaraya_watson_envelope(pl_df, source_column="Close", bandwidth=8.0, mult=3.0)
898+
pl_df.show(10)
899+
900+
# Calculate Nadaraya-Watson Envelope for Pandas DataFrame
901+
pd_df = nadaraya_watson_envelope(pd_df, source_column="Close", bandwidth=8.0, mult=3.0)
902+
pd_df.tail(10)
903+
```
904+
905+
![NADARAYA_WATSON_ENVELOPE](https://github.com/coding-kitties/PyIndicators/blob/main/static/images/indicators/nadaraya_watson_envelope.png)
906+
844907
### Support and Resistance
845908

846909
Indicators that help identify potential support and resistance levels in the market.

pyindicators/__init__.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@
1818
msb_signal, ob_quality_signal, get_market_structure_stats,
1919
market_structure_choch_bos, choch_bos_signal, get_choch_bos_stats,
2020
momentum_confluence, momentum_confluence_signal,
21-
get_momentum_confluence_stats
21+
get_momentum_confluence_stats,
22+
supertrend_clustering, supertrend_basic, supertrend_signal,
23+
get_supertrend_stats,
24+
nadaraya_watson_envelope
2225
)
2326
from .exceptions import PyIndicatorException
2427
from .date_range import DateRange
@@ -101,5 +104,10 @@ def get_version():
101104
'get_choch_bos_stats',
102105
'momentum_confluence',
103106
'momentum_confluence_signal',
104-
'get_momentum_confluence_stats'
107+
'get_momentum_confluence_stats',
108+
'supertrend_clustering',
109+
'supertrend_basic',
110+
'supertrend_signal',
111+
'get_supertrend_stats',
112+
'nadaraya_watson_envelope'
105113
]

pyindicators/indicators/__init__.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@
4040
momentum_confluence, momentum_confluence_signal,
4141
get_momentum_confluence_stats
4242
)
43+
from .supertrend import (
44+
supertrend_clustering, supertrend_basic, supertrend_signal,
45+
get_supertrend_stats
46+
)
47+
from .nadaraya_watson_envelope import nadaraya_watson_envelope
4348

4449
__all__ = [
4550
'sma',
@@ -104,5 +109,10 @@
104109
'get_choch_bos_stats',
105110
'momentum_confluence',
106111
'momentum_confluence_signal',
107-
'get_momentum_confluence_stats'
112+
'get_momentum_confluence_stats',
113+
'supertrend_clustering',
114+
'supertrend_basic',
115+
'supertrend_signal',
116+
'get_supertrend_stats',
117+
'nadaraya_watson_envelope'
108118
]

pyindicators/indicators/momentum_confluence.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -494,10 +494,10 @@ def _generate_reversals(
494494
# Strong bullish reversal
495495
# Conditions: overflow bearish OR very low trend wave + turning up
496496
is_overflow_bear = (overflow_bearish[i] == 1 or
497-
overflow_bearish[i - 1] == 1)
497+
overflow_bearish[i - 1] == 1)
498498
is_extreme_low = trend_wave[i] < 20
499499
is_turning_up = (trend_wave[i] > trend_wave[i - 1] and
500-
trend_wave[i - 1] <= trend_wave[i - 2])
500+
trend_wave[i - 1] <= trend_wave[i - 2])
501501

502502
if is_overflow_bear and is_turning_up:
503503
reversal_strong_bullish[i] = 1
@@ -507,10 +507,10 @@ def _generate_reversals(
507507
# Strong bearish reversal
508508
# Conditions: overflow bullish OR very high trend wave + turning down
509509
is_overflow_bull = (overflow_bullish[i] == 1 or
510-
overflow_bullish[i - 1] == 1)
510+
overflow_bullish[i - 1] == 1)
511511
is_extreme_high = trend_wave[i] > 80
512512
is_turning_down = (trend_wave[i] < trend_wave[i - 1] and
513-
trend_wave[i - 1] >= trend_wave[i - 2])
513+
trend_wave[i - 1] >= trend_wave[i - 2])
514514

515515
if is_overflow_bull and is_turning_down:
516516
reversal_strong_bearish[i] = 1
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
from typing import Union
2+
import math
3+
import numpy as np
4+
from pandas import DataFrame as PdDataFrame
5+
from polars import DataFrame as PlDataFrame
6+
import polars as pl
7+
from pyindicators.exceptions import PyIndicatorException
8+
9+
10+
def _gauss(x: float, h: float) -> float:
11+
"""Gaussian kernel function."""
12+
return math.exp(-(x * x) / (h * h * 2))
13+
14+
15+
def nadaraya_watson_envelope(
16+
data: Union[PdDataFrame, PlDataFrame],
17+
source_column: str = 'Close',
18+
bandwidth: float = 8.0,
19+
mult: float = 3.0,
20+
lookback: int = 500,
21+
upper_column: str = 'nwe_upper',
22+
lower_column: str = 'nwe_lower',
23+
middle_column: str = 'nwe_middle',
24+
) -> Union[PdDataFrame, PlDataFrame]:
25+
"""
26+
Calculate the Nadaraya-Watson Envelope indicator.
27+
28+
Uses Gaussian kernel regression to create a smoothed price estimate,
29+
then adds an envelope based on the mean absolute error (MAE) scaled
30+
by a multiplier. This is a non-repainting (endpoint) implementation.
31+
32+
Based on the TradingView "Nadaraya-Watson Envelope [LuxAlgo]" indicator.
33+
34+
Calculation:
35+
- Kernel weights: w(i) = exp(-i² / (2 * h²)) for i = 0..lookback-1
36+
- Smoothed value: sum(src[t-i] * w(i)) / sum(w(i))
37+
- MAE: SMA of |src - smoothed| over lookback period
38+
- Upper: smoothed + mult * MAE
39+
- Lower: smoothed - mult * MAE
40+
41+
Args:
42+
data: pandas or polars DataFrame with price data
43+
source_column: Column name for the source prices
44+
(default: 'Close')
45+
bandwidth: Gaussian kernel bandwidth / smoothing factor
46+
(default: 8.0). Higher values produce smoother curves.
47+
mult: Multiplier for the MAE envelope width (default: 3.0)
48+
lookback: Number of bars to use for kernel regression
49+
(default: 500)
50+
upper_column: Result column name for upper envelope
51+
(default: 'nwe_upper')
52+
lower_column: Result column name for lower envelope
53+
(default: 'nwe_lower')
54+
middle_column: Result column name for the smoothed line
55+
(default: 'nwe_middle')
56+
57+
Returns:
58+
DataFrame with added columns:
59+
- {upper_column}: Upper boundary of the envelope
60+
- {lower_column}: Lower boundary of the envelope
61+
- {middle_column}: Nadaraya-Watson smoothed estimate
62+
63+
Example:
64+
>>> import pandas as pd
65+
>>> from pyindicators import nadaraya_watson_envelope
66+
>>> df = pd.DataFrame({
67+
... 'Close': [100, 102, 101, 103, 105, 104, 106, 108, 107, 109]
68+
... })
69+
>>> result = nadaraya_watson_envelope(df, bandwidth=3, lookback=5)
70+
>>> print(result[['nwe_upper', 'nwe_middle', 'nwe_lower']].tail())
71+
"""
72+
if bandwidth <= 0:
73+
raise PyIndicatorException("Bandwidth must be greater than 0")
74+
75+
if mult < 0:
76+
raise PyIndicatorException("Multiplier must be non-negative")
77+
78+
if lookback < 1:
79+
raise PyIndicatorException("Lookback must be at least 1")
80+
81+
if source_column not in data.columns:
82+
raise PyIndicatorException(
83+
f"The source column '{source_column}' does not "
84+
"exist in the DataFrame."
85+
)
86+
87+
is_polars = isinstance(data, PlDataFrame)
88+
89+
if is_polars:
90+
src = data[source_column].to_numpy().astype(float)
91+
else:
92+
src = data[source_column].values.astype(float)
93+
94+
n = len(src)
95+
96+
# Pre-compute Gaussian kernel weights
97+
max_k = min(lookback, n)
98+
weights = np.array([_gauss(i, bandwidth) for i in range(max_k)])
99+
100+
# Compute the smoothed (endpoint) estimate for each bar
101+
smoothed = np.full(n, np.nan)
102+
103+
for t in range(n):
104+
# Number of available past bars (including current)
105+
available = min(t + 1, max_k)
106+
w = weights[:available]
107+
s = src[t - available + 1: t + 1][::-1] # most recent first
108+
smoothed[t] = np.nansum(s * w) / np.nansum(w)
109+
110+
# Compute MAE (mean absolute error) using a rolling window
111+
abs_err = np.abs(src - smoothed)
112+
mae = np.full(n, np.nan)
113+
114+
for t in range(n):
115+
window = min(t + 1, max_k)
116+
mae[t] = np.nanmean(abs_err[t - window + 1: t + 1])
117+
118+
upper = smoothed + mult * mae
119+
lower = smoothed - mult * mae
120+
121+
if is_polars:
122+
data = data.with_columns([
123+
pl.Series(name=middle_column, values=smoothed),
124+
pl.Series(name=upper_column, values=upper),
125+
pl.Series(name=lower_column, values=lower),
126+
])
127+
else:
128+
data[middle_column] = smoothed
129+
data[upper_column] = upper
130+
data[lower_column] = lower
131+
132+
return data

0 commit comments

Comments
 (0)