Rolling windows, SMA and EMA
Lesson 8 · about 12 min
A moving average is a rolling window with a mean inside it. Once you can write that yourself, you can write any indicator, because they are all windows with some arithmetic inside. This lesson builds the simple and exponential moving averages from scratch, explains the one parameter of ewm that everyone gets wrong, and shows how to test an indicator against a hand-computed value.
Rolling windows
Series.rolling(n) produces a window object; the aggregation you call on it runs over each trailing block of n values.
import numpy as np
import pandas as pd
from src.data import synthetic_ohlcv
bars = synthetic_ohlcv(300, seed=21)
close = bars["close"]
print(close.rolling(5).mean().head(7).round(3))
print(close.rolling(20).std().tail(3).round(4))
print(bars["high"].rolling(20).max().tail(3).round(2)) # 20-bar high
The first n - 1 values are NaN because there are not enough bars yet. That is correct behaviour; do not pass min_periods=1 to make them go away unless you have a reason, because an "average" of two bars pretending to be a 20-bar average will generate signals during the warm-up that a live system would never see.
Windows are trailing. The value at bar t uses bars t - n + 1 through t inclusive, so it includes the current bar's close. Whether that is usable at the time depends on when you act; Module 5 covers execution timing.
SMA from scratch
def sma(series: pd.Series, n: int) -> pd.Series:
"""Simple moving average over the trailing n bars."""
if n < 1:
raise ValueError("window must be >= 1")
return series.rolling(n).mean()
That is the whole indicator. A useful property for testing: the SMA at bar t equals the SMA at t - 1 plus (close[t] - close[t - n]) / n.
s = sma(close, 10)
recurrence = s.shift(1) + (close - close.shift(10)) / 10
assert np.allclose(s.iloc[11:], recurrence.iloc[11:])
EMA and the adjust parameter
The exponential moving average weights recent bars more. Its recursive definition is:
EMA[t] = α × close[t] + (1 − α) × EMA[t − 1], with α = 2 / (n + 1)
pandas implements this with ewm, but its default adjust=True computes a different, weighted-average form that converges to the recursive one only after many bars. Charting platforms and every other trader use the recursive form. Set adjust=False.
def ema(series: pd.Series, n: int) -> pd.Series:
"""Exponential moving average with span n (alpha = 2 / (n + 1)), seeded from the first value."""
if n < 1:
raise ValueError("span must be >= 1")
return series.ewm(span=n, adjust=False).mean()
Verify against a hand-written loop, which is the definition you will find in any textbook:
def ema_loop(values: np.ndarray, n: int) -> np.ndarray:
alpha = 2.0 / (n + 1)
out = np.empty(len(values))
out[0] = values[0]
for i in range(1, len(values)):
out[i] = alpha * values[i] + (1 - alpha) * out[i - 1]
return out
assert np.allclose(ema(close, 12).to_numpy(), ema_loop(close.to_numpy(), 12))
If you drop adjust=False, this assertion fails for the early part of the series. Two people comparing "the same" EMA crossover strategy and getting different trades in the first few months have almost always hit this.
Note the seeding: the loop starts the EMA at the first close. Some platforms seed with the SMA of the first n bars instead. Both converge, but they differ for roughly 3n bars, so your backtest's first few trades may not match a chart. It does not matter which you choose as long as you know which you chose and discard the warm-up.
Handling the warm-up honestly
Every indicator has a warm-up period during which its value is either NaN (SMA) or not yet converged (EMA). The clean approach: compute all indicators, then drop the first max_window bars before generating any signal.
def with_indicators(bars: pd.DataFrame, fast: int = 20, slow: int = 50) -> pd.DataFrame:
out = bars.copy()
out["sma_fast"] = sma(out["close"], fast)
out["sma_slow"] = sma(out["close"], slow)
out["ema_fast"] = ema(out["close"], fast)
out["ema_slow"] = ema(out["close"], slow)
warmup = 3 * slow
return out.iloc[warmup:]
feat = with_indicators(bars)
print(feat[["close", "sma_fast", "sma_slow", "ema_fast", "ema_slow"]].tail(3).round(2))
out = bars.copy() matters: pandas will otherwise warn about, or silently perform, in-place modification of the caller's frame. Functions that take a DataFrame and return a new one are far easier to test and to chain.
Key idea: Every indicator is a trailing window plus arithmetic. Write it yourself once, test it against a loop or a recurrence, and use
adjust=Falsefor anything exponential so that your numbers match everyone else's.
Other rolling statistics you will need
vol_20 = close.pct_change().rolling(20).std() * np.sqrt(252) # annualised realised vol
zscore_20 = (close - sma(close, 20)) / close.rolling(20).std() # distance from mean in std devs
donchian_hi = bars["high"].rolling(20).max().shift(1) # yesterday's 20-bar high
donchian_lo = bars["low"].rolling(20).min().shift(1)
The shift(1) on the Donchian channel is deliberate: a breakout rule compares today's high to the highest high of the previous 20 bars. Without the shift, today's high is always less than or equal to a window that includes it, and the breakout never fires. This is a small instance of the general point in Module 5: think about whether the value at bar t was known before bar t happened.
Plot to sanity-check:
import matplotlib.pyplot as plt
ax = feat[["close", "sma_fast", "sma_slow"]].tail(150).plot(figsize=(10, 4), title="Close with SMAs")
plt.tight_layout()
plt.show()
Try it: Implement a weighted moving average (weights 1, 2, ..., n, most recent heaviest) using
rolling(n).applywith a small function, then again as a matrix operation withnp.convolveon the numpy array. Confirm they match. Time both on 100,000 rows with%timeitin a notebook and note the difference.
Recap
rolling(n)gives trailing windows; the firstn - 1values are NaN and should stay that way.- SMA is
rolling(n).mean(); EMA isewm(span=n, adjust=False).mean(). - Test indicators against a plain loop or a recurrence relation.
- Drop a warm-up period (about three times the longest window) before generating signals.
- Indicator functions take a frame and return a new one; never mutate the caller's data.
See it drawn
Original diagrams for the ideas on this page. Illustrative, not real market data.