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.
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.
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"])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.
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.
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.
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.