Part of Pandas for Finance

Pandas for Finance: Rolling Windows: Moving Avg, Bollinger, Volatility, Drawdown

Celest KimCelest Kim

Video: Rolling Windows: Moving Avg, Bollinger, Volatility, Drawdown | Pandas for Finance Ep11 by CelesteAI

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

df["Close"].rolling(50).mean() — 50-day moving average. .rolling(20).std() — 20-day rolling std. Bollinger Bands are mean ± 2σ. .expanding().max() for running maximum (drawdown calc). The technical-analysis toolbox in pandas.

A rolling window walks N rows at a time, applying a function. It’s how every moving average, Bollinger band, and volatility metric in finance gets computed.

Moving averages

import pandas as pd

df = pd.read_parquet("data/prices.parquet")
aapl = df[df["Ticker"] == "AAPL"].copy().sort_values("Date").set_index("Date")

aapl["MA50"] = aapl["Close"].rolling(50).mean()
aapl["MA200"] = aapl["Close"].rolling(200).mean()

print(aapl[["Close", "MA50", "MA200"]].tail())
              Close      MA50     MA200
Date
2024-12-26   258.20    241.03    222.56
2024-12-27   255.59    241.55    222.91
2024-12-30   252.20    242.04    223.27
2024-12-31   250.42    242.51    223.62

rolling(N).mean() produces NaN for the first N-1 rows (not enough data), then the rolling average from there.

For the famous golden cross (50-day above 200-day):

aapl["GoldenCross"] = aapl["MA50"] > aapl["MA200"]
crosses = aapl["GoldenCross"].diff()
print(aapl[crosses].head())  # days the cross flipped

Bollinger Bands

window = 20
mean = aapl["Close"].rolling(window).mean()
std = aapl["Close"].rolling(window).std()

aapl["BBUpper"] = mean + 2 * std
aapl["BBLower"] = mean - 2 * std
aapl["BBMid"] = mean

print(aapl[["Close", "BBLower", "BBMid", "BBUpper"]].tail())

The 20-day moving average ± 2 standard deviations. Most action stays inside; touches and breakouts flag turning points (depending on who you ask).

Rolling volatility

aapl["DailyReturn"] = aapl["Close"].pct_change()

# 30-day rolling std, annualized
aapl["Vol30"] = aapl["DailyReturn"].rolling(30).std() * (252 ** 0.5)

print(aapl["Vol30"].tail())
Date
2024-12-26    0.1721
2024-12-27    0.1724
2024-12-30    0.1750
2024-12-31    0.1791

17.91% annualized vol over the trailing 30 days. The standard “is the market quiet or stressed?” gauge.

Rolling Sharpe

window = 60
roll_return = aapl["DailyReturn"].rolling(window).mean() * 252
roll_vol = aapl["DailyReturn"].rolling(window).std() * (252 ** 0.5)
aapl["Sharpe60"] = (roll_return - 0.04) / roll_vol

print(aapl["Sharpe60"].tail())

A rolling 60-day Sharpe (assuming 4% rf). Fairer view than one number for the whole period.

Expanding windows

expanding() is “all data up to here,” not just the last N. Used for cumulative max/min:

aapl["AllTimeHigh"] = aapl["Close"].expanding().max()
print(aapl[["Close", "AllTimeHigh"]].tail())

Each row holds the max value seen up to that point. Combined with a wealth curve, this is the drawdown calculation:

aapl["DailyReturn"] = aapl["Close"].pct_change().fillna(0)
aapl["Wealth"] = (1 + aapl["DailyReturn"]).cumprod()
aapl["Peak"] = aapl["Wealth"].expanding().max()
aapl["Drawdown"] = aapl["Wealth"] / aapl["Peak"] - 1

print(f"Max drawdown: {aapl['Drawdown'].min():.2%}")
print(f"Date: {aapl['Drawdown'].idxmin().date()}")
Max drawdown: -31.43%
Date: 2022-12-28

The 2022 tech selloff in one number.

Custom rolling functions

For arbitrary window math, .apply():

def annualized_sharpe(returns, rf=0.04):
  if returns.std() == 0:
    return float("nan")
  return (returns.mean() * 252 - rf) / (returns.std() * (252 ** 0.5))

aapl["Sharpe60"] = aapl["DailyReturn"].rolling(60).apply(annualized_sharpe)

Slower than vectorized rolling, but works for anything.

Rolling beta vs SPY

prices = pd.read_parquet("data/prices.parquet")
wide = prices.pivot(index="Date", columns="Ticker", values="Adj Close")
returns = wide.pct_change()

# Rolling 60-day beta of AAPL vs SPY
window = 60
cov = returns["AAPL"].rolling(window).cov(returns["SPY"])
var = returns["SPY"].rolling(window).var()
beta = cov / var

print(beta.tail())

Beta = correlation × volatility ratio. cov / var is the regression coefficient — exactly that.

EWMA — exponentially weighted

rolling() weights every data point in the window equally. ewm() weights recent points more:

aapl["EMA20"] = aapl["Close"].ewm(span=20, adjust=False).mean()
aapl["EMAVol"] = aapl["DailyReturn"].ewm(span=30).std() * (252 ** 0.5)

span=N is roughly equivalent to a window of N. Used in MACD, GARCH-lite models, and many exponential decay schemes.

Try it

rolling.py:

import pandas as pd

pd.set_option("display.float_format", lambda x: f"{x:,.4f}")

df = pd.read_parquet("data/prices.parquet")
aapl = df[df["Ticker"] == "AAPL"].copy().set_index("Date").sort_index()
aapl["DailyReturn"] = aapl["Close"].pct_change()

print("=== Moving averages ===")
aapl["MA50"] = aapl["Close"].rolling(50).mean()
aapl["MA200"] = aapl["Close"].rolling(200).mean()
print(aapl[["Close", "MA50", "MA200"]].tail())

print("\n=== Bollinger Bands (20-day, 2σ) ===")
m = aapl["Close"].rolling(20).mean()
s = aapl["Close"].rolling(20).std()
aapl["BBUpper"] = m + 2 * s
aapl["BBLower"] = m - 2 * s
print(aapl[["Close", "BBLower", "BBUpper"]].tail())

print("\n=== Rolling 30-day volatility (annualized) ===")
aapl["Vol30"] = aapl["DailyReturn"].rolling(30).std() * (252 ** 0.5)
print(aapl["Vol30"].tail().map(lambda x: f"{x:.2%}"))

print("\n=== Drawdown ===")
aapl["Wealth"] = (1 + aapl["DailyReturn"].fillna(0)).cumprod()
aapl["Peak"] = aapl["Wealth"].expanding().max()
aapl["DD"] = aapl["Wealth"] / aapl["Peak"] - 1
print(f"Max drawdown: {aapl['DD'].min():.2%}")
print(f"Date: {aapl['DD'].idxmin().date()}")

Common stumbles

Rolling without sort. Rolling is order-sensitive. Sort by Date first.

min_periods confusion. Default for rolling(N) is min_periods=N — first N-1 are NaN. Set min_periods=1 to start computing immediately (with smaller window).

std() on a single value. Returns NaN. Rolling 1-day std is meaningless; use ≥ 2.

Annualizing vol. Use * sqrt(252), not * 252. Vol scales with sqrt of time, not linearly.

EWMA adjust=True vs False. Default is True (adjusts initial bias). False matches the “exponential moving average” you’d see in trading platforms.

Drawdown signs. wealth / peak - 1 is negative when below peak. peak / wealth - 1 is positive — use whichever convention your audience expects.

Cross-ticker rolling without groupby. df["Close"].rolling(50).mean() rolls across the boundary between tickers. Always df.groupby("Ticker")["Close"].rolling(50).mean() for long format.

What’s next

Ep 12: cleaning real market data — splits, dividends, missing days, outliers.

Recap

series.rolling(N).func() for moving stats; .expanding() for cumulative. Bollinger = mean ± 2*std over a window. Annualized vol = daily_std * sqrt(252). Drawdown = wealth / wealth.expanding().max() - 1. Rolling Sharpe and rolling beta are simple combinations. EWMA (.ewm(span=N).mean()) for exponentially-weighted recent emphasis. For multi-ticker, always groupby("Ticker") first.

Next episode: cleaning real market data.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.