RSI and ATR from scratch
Lesson 9 · about 13 min
Two more indicators complete the toolkit this course needs: RSI, an oscillator used for mean reversion, and ATR, a volatility measure used for stops and position sizing. Both are "Wilder" indicators, named after their inventor, and both use a smoothing that is not the EMA from the previous lesson. Getting the smoothing right is the entire content of matching your numbers to a charting platform.
Wilder smoothing
Wilder's moving average is an EMA with α = 1 / n rather than 2 / (n + 1). A 14-period Wilder average therefore has the same weight decay as a 27-period standard EMA. In pandas that is ewm(alpha=1 / n, adjust=False).
import numpy as np
import pandas as pd
from src.data import synthetic_ohlcv
def wilder(series: pd.Series, n: int) -> pd.Series:
"""Wilder's smoothing: EMA with alpha = 1/n, recursive form."""
return series.ewm(alpha=1.0 / n, adjust=False).mean()
RSI
RSI compares the average size of up moves to the average size of down moves over a window, mapped to 0–100.
delta = close.diff()gain = deltawhere positive, else 0;loss = -deltawhere negative, else 0avg_gain,avg_loss= Wilder averages of thoseRS = avg_gain / avg_loss;RSI = 100 − 100 / (1 + RS)
def rsi(close: pd.Series, n: int = 14) -> pd.Series:
"""Relative Strength Index with Wilder smoothing."""
delta = close.diff()
gain = delta.clip(lower=0.0)
loss = (-delta).clip(lower=0.0)
avg_gain = wilder(gain, n)
avg_loss = wilder(loss, n)
rs = avg_gain / avg_loss.replace(0.0, np.nan)
out = 100.0 - 100.0 / (1.0 + rs)
return out.fillna(100.0).where(avg_gain.notna(), np.nan)
The replace(0.0, np.nan) avoids dividing by zero when there have been no down moves at all; in that case RS is infinite and RSI is 100, which the fillna(100.0) restores. The final where keeps the very first bar NaN, where diff() had nothing to compare.
Sanity checks that catch most mistakes:
bars = synthetic_ohlcv(500, seed=31)
r = rsi(bars["close"])
assert r.dropna().between(0, 100).all()
rising = pd.Series(np.arange(1.0, 60.0))
assert rsi(rising).iloc[-1] > 99 # only gains -> RSI near 100
falling = pd.Series(np.arange(60.0, 1.0, -1.0))
assert rsi(falling).iloc[-1] < 1 # only losses -> RSI near 0
print(r.tail(3).round(2))
Some libraries seed the first Wilder average with a simple mean of the first n values rather than starting from the first value. As with the EMA, the two converge; the difference is confined to the warm-up, which you discard anyway.
True range and ATR
The true range of a bar is the largest of three distances: high to low, high to previous close, and low to previous close. The last two exist so that an overnight gap counts as movement even if the bar's own range is small.
def true_range(bars: pd.DataFrame) -> pd.Series:
prev_close = bars["close"].shift(1)
ranges = pd.concat(
[
bars["high"] - bars["low"],
(bars["high"] - prev_close).abs(),
(bars["low"] - prev_close).abs(),
],
axis=1,
)
return ranges.max(axis=1)
def atr(bars: pd.DataFrame, n: int = 14) -> pd.Series:
"""Average True Range with Wilder smoothing, in price units."""
return wilder(true_range(bars), n)
On the first bar prev_close is NaN, so the two gap distances are NaN and max(axis=1) falls back to high minus low, which is what you want.
bars["tr"] = true_range(bars)
bars["atr14"] = atr(bars)
print(bars[["high", "low", "close", "tr", "atr14"]].tail(3).round(3))
assert (bars["tr"] >= bars["high"] - bars["low"] - 1e-12).all()
ATR is in the same units as price. An ATR of 2.5 on a $100 stock means the typical daily range, including gaps, is about $2.50. Dividing by price gives a scale-free version that lets you compare instruments:
bars["atr_pct"] = bars["atr14"] / bars["close"]
print(f"typical daily range: {bars['atr_pct'].iloc[-1]:.2%} of price")
Why ATR matters more than RSI
RSI is one oscillator among dozens, and its edge, if any, is small and regime-dependent. ATR is different: it is the input to two decisions every system makes regardless of strategy. Where does the stop go (some multiple of ATR from entry, so that ordinary noise does not stop you out)? How large is the position (dollars at risk divided by the ATR-based stop distance)? Module 5 builds both. If you take one indicator from this module into every future project, take ATR.
Key idea: RSI and ATR use Wilder smoothing (alpha = 1/n, not 2/(n+1)). Build them once with tests, discard the warm-up, and expect your values to match a chart only after about three windows of data.
Putting the indicator module together
# src/indicators.py
import numpy as np
import pandas as pd
def sma(s: pd.Series, n: int) -> pd.Series:
return s.rolling(n).mean()
def ema(s: pd.Series, n: int) -> pd.Series:
return s.ewm(span=n, adjust=False).mean()
def wilder(s: pd.Series, n: int) -> pd.Series:
return s.ewm(alpha=1.0 / n, adjust=False).mean()
def rsi(close: pd.Series, n: int = 14) -> pd.Series:
delta = close.diff()
avg_gain = wilder(delta.clip(lower=0.0), n)
avg_loss = wilder((-delta).clip(lower=0.0), n)
rs = avg_gain / avg_loss.replace(0.0, np.nan)
return (100.0 - 100.0 / (1.0 + rs)).fillna(100.0).where(avg_gain.notna(), np.nan)
def true_range(bars: pd.DataFrame) -> pd.Series:
pc = bars["close"].shift(1)
return pd.concat(
[bars["high"] - bars["low"], (bars["high"] - pc).abs(), (bars["low"] - pc).abs()], axis=1
).max(axis=1)
def atr(bars: pd.DataFrame, n: int = 14) -> pd.Series:
return wilder(true_range(bars), n)
Add tests in tests/test_indicators.py for the bounds of RSI, the monotone cases, and tr >= high - low. Then the module is done and you will not need to touch it again in this course.
Try it: Compute RSI(14) with Wilder smoothing and again with a standard EMA (
span=14). Plot both for 100 bars. Then count how many bars each version spends below 30. The difference is why "RSI below 30" means different things on different platforms unless you know the smoothing.
Recap
- Wilder smoothing is
ewm(alpha=1/n, adjust=False); a 14-period Wilder average decays like a 27-period EMA. - RSI = 100 − 100 / (1 + avg_gain / avg_loss); guard the zero-loss case.
- True range includes gaps by comparing to the previous close; ATR is its Wilder average, in price units.
- ATR drives stop distance and position size in every system; it is the most reusable indicator here.
- Keep all indicators in
src/indicators.pywith bounds and monotone-case tests.
See it drawn
Original diagrams for the ideas on this page. Illustrative, not real market data.