Skip to content
GetProfitable
Search

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=False for 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).apply with a small function, then again as a matrix operation with np.convolve on the numpy array. Confirm they match. Time both on 100,000 rows with %timeit in a notebook and note the difference.

Recap

  • rolling(n) gives trailing windows; the first n - 1 values are NaN and should stay that way.
  • SMA is rolling(n).mean(); EMA is ewm(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.

A fast and a slow moving average crossingA jagged price line with two smoother average lines through it; the fast average dips below the slow one on the left and cuts back above it in the middle, where a circle marks the crossing.pricefast averageslow averagefast crosses belowfast crosses abovethe slow averageAverages of recent closes; the fast one reacts sooner than the slow one.
Fast and slow moving averages crossing. A moving average is the average of the last few closing prices, redrawn each period. An average over fewer periods turns sooner than one over many, so the two lines cross whenever the recent pace of the market changes.
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.
Breakout and retestPrice stalls under one level, pushes above it, comes back to touch it from above, then continues higher.pricetimeold resistancenow support1price keeps stalling2breaks above3pulls back and retests it4and carries on
Breakout and retest. Price stalls under the same level several times, pushes above it, then drops back to touch it from above before carrying on. That touch is the retest, where the old ceiling is tried as a floor. A break that falls straight back under it is a false breakout.