Skip to content
GetProfitable
Search

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.

  1. delta = close.diff()
  2. gain = delta where positive, else 0; loss = -delta where negative, else 0
  3. avg_gain, avg_loss = Wilder averages of those
  4. RS = 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.py with bounds and monotone-case tests.

See it drawn

Original diagrams for the ideas on this page. Illustrative, not real market data.

A range beside a trendOne chart swinging between a flat floor and ceiling, another stepping upwards inside a pair of sloping lines.Range-boundresistancesupportprice bounces between two levelsTrendingthe trend channelhigher highs and higher lowsA range has two flat edges; a trend has two sloping ones.
Range versus trend. On the left price keeps bouncing between the same floor and ceiling, which is a range. On the right each high and each low is higher than the last, inside a pair of sloping lines called a channel.
A simple and an exponential moving average over the same pricesOne price line with two smoothed lines drawn through it; the exponential average bends away from the simple average as soon as price turns, and sits between price and the simple average all the way down.SAME PRICES, TWO AVERAGES (8 PERIODS)the EMA turns down firstand stays nearer to price8-day SMA8-day EMApriceIllustrative prices. Both averages smooth the same series over the same span.
Simple versus exponential averages. Both lines average the last eight prices, but the exponential version gives the newest prices the most weight while the simple version treats them all alike. That is why the exponential line bends first when price turns and then tracks it more closely.
How a call option's delta changes with the underlying priceAn S-shaped curve rising from zero, passing through about a half at the strike, and flattening near one.Delta of a call option1.000.5008090110120Out of the moneyAt the moneyIn the money1.00 means it moves one-for-one with the stockdelta ≈ 0.50 at the strikeStrike 100Underlying price
Delta across the range of prices. Delta says how much a call's price moves for a one-point move in the stock. Far below the strike it is near 0 and the option barely reacts; at the strike it is about 0.50; far above it approaches 1 and tracks the stock.

Finished this module? Take the module quiz.