AlgoPro UniversityCourse home
Part 3 · Lesson

Computing indicators from scratch

SMA, EMA, RSI, ATR and Bollinger Bands — the maths behind each, implemented in pandas and numpy so you never trust a black box again.

Indicators are just functions of price designed to expose something the raw series hides: trend, momentum, volatility, or stretch. Libraries exist, but you should build the core ones once by hand — because a strategy you do not understand is a strategy you cannot debug when it loses money.

Simple Moving Average (SMA)

The SMA is the mean of the last n closes. It smooths noise to reveal trend — but it lags, because every one of those n bars gets equal weight, including stale ones.

pythonSMA — the rolling mean
def sma(series, window):
    class="c"># min_periods=window => no value until we truly have n bars,
    class="c"># so we never fabricate an average from partial data.
    return series.rolling(window=window, min_periods=window).mean()

df[class="s">"sma_20"] = sma(df[class="s">"close"], class="n">20)
df[class="s">"sma_50"] = sma(df[class="s">"close"], class="n">50)

Exponential Moving Average (EMA)

The EMA fixes the SMA lag by weighting recent bars more heavily, decaying older ones geometrically. The smoothing factor is alpha = 2 / (n + 1); each new value is alpha * price + (1 - alpha) * previous_ema. pandas gives it to you directly, but here is the recurrence so you see there is no magic.

pythonEMA — recency-weighted, two ways
import numpy as np

def ema_manual(series, span):
    alpha = class="n">2 / (span + class="n">1)
    out = np.empty(len(series))
    out[class="n">0] = series.iloc[class="n">0]                 class="c"># seed with the first price
    for i in range(class="n">1, len(series)):
        out[i] = alpha * series.iloc[i] + (class="n">1 - alpha) * out[i - class="n">1]
    return pd.Series(out, index=series.index)

class="c"># The idiomatic, vectorised pandas equivalent:
df[class="s">"ema_20"] = df[class="s">"close"].ewm(span=class="n">20, adjust=False).mean()

class="c"># Sanity check: manual and built-in agree.
assert np.allclose(ema_manual(df[class="s">"close"], class="n">20), df[class="s">"ema_20"])
Price with SMA(20) and EMA(20) overlaysbar 1bar 24
The EMA (violet) hugs price more tightly than the SMA (green) because it discounts old bars — faster to turn, but also quicker to whipsaw. Neither predicts; both describe.

RSI — the momentum oscillator

The Relative Strength Index measures the balance of recent up-moves versus down-moves, mapped onto a 0-100 scale. Above ~70 is conventionally "overbought", below ~30 "oversold". The maths: average the gains and the losses over n bars, take their ratio RS, then RSI = 100 - 100 / (1 + RS). Wilder used an EMA-style smoothing, which we replicate.

pythonRSI with Wilder's smoothing
def rsi(series, period=class="n">14):
    delta = series.diff()
    gain = delta.clip(lower=class="n">0)            class="c"># positive moves, else class="n">0
    loss = -delta.clip(upper=class="n">0)           class="c"># magnitude of negative moves

    class="c"># Wilder's smoothing == EMA with alpha = class="n">1/period.
    avg_gain = gain.ewm(alpha=class="n">1 / period, min_periods=period, adjust=False).mean()
    avg_loss = loss.ewm(alpha=class="n">1 / period, min_periods=period, adjust=False).mean()

    rs = avg_gain / avg_loss
    return class="n">100 - (class="n">100 / (class="n">1 + rs))

df[class="s">"rsi_14"] = rsi(df[class="s">"close"], class="n">14)
class="c"># By construction: class="n">0 <= RSI <= class="n">100.

ATR — how much it moves

The Average True Range measures volatility in price units — invaluable for sizing stops and positions. Its cleverness is the true range, which accounts for overnight gaps by taking the largest of three distances: today high-low, and the gap from the previous close to today high or low. ATR is then the smoothed average of that.

pythonATR via true range
def atr(high, low, close, period=class="n">14):
    prev_close = close.shift(class="n">1)
    tr = pd.concat([
        (high - low),                     class="c"># todayclass="s">'s range
        (high - prev_close).abs(),        class="c"># gap up from prior close
        (low - prev_close).abs(),         class="c"># gap down from prior close
    ], axis=class="n">1).max(axis=class="n">1)                class="c"># true range = the largest
    return tr.ewm(alpha=class="n">1 / period, min_periods=period, adjust=False).mean()

df["atr_14"] = atr(df["high"], df["low"], df["close"], class="n">14)
class="c"># A 2x-ATR stop below entry adapts to each instrument's volatility.

Bollinger Bands — volatility envelope

Bollinger Bands wrap a moving average in a band of k standard deviations (usually 20-period SMA, k = 2). Price tends to spend most of its time inside them — so touches of the outer band flag *statistical* stretch, feeding mean-reversion ideas. The band width itself is a volatility gauge: narrow bands ("the squeeze") often precede big moves.

pythonBollinger Bands from mean and std
def bollinger(series, window=class="n">20, k=class="n">2):
    mid = series.rolling(window, min_periods=window).mean()
    sd  = series.rolling(window, min_periods=window).std(ddof=class="n">0)
    upper = mid + k * sd
    lower = mid - k * sd
    class="c"># %B: where price sits in the band (class="n">0 = lower, class="n">1 = upper).
    pct_b = (series - lower) / (upper - lower)
    return mid, upper, lower, pct_b

df[class="s">"bb_mid"], df[class="s">"bb_up"], df[class="s">"bb_lo"], df[class="s">"bb_pctb"] = bollinger(df[class="s">"close"])

You can now generate the raw material of most retail strategies from first principles. But every one of these formulas assumes clean input. In the final lesson we make sure that assumption holds.