A window that slides
Today's price means little on its own; traders compare it with the recent past. A rolling window keeps the last n observations and moves forward one step at a time, so each date gets a statistic computed from its own recent history: a moving average, a recent volatility, a recent high. In pandas, series.rolling(n) describes the window and a statistic such as .mean() computes it on every position at once.
import pandas as pd
prices = pd.Series(
[100.0, 102, 101, 105, 107, 106, 110],
index=pd.bdate_range("2026-03-02", periods=7),
name="close",
)
moving_average = prices.rolling(3).mean()
print(moving_average.round(2).tolist())
# [nan, nan, 101.0, 102.67, 104.33, 106.0, 107.67]
print(prices.rolling(3).max().tolist()) # [nan, nan, 102.0, 105.0, 107.0, 107.0, 110.0] the 3-day highThe start of the series
The first n - 1 positions do not have a full window yet, so the result is NaN there. That is honest: an average of two prices is not a 3-day average. If you really want a value earlier, min_periods=1 computes with what is available, and expanding() uses everything since the start instead of a fixed window. Statistics that need several points, such as the standard deviation, are NaN until enough observations have arrived. A series that already begins with NaN, like simple returns, pushes the first valid window one step later.
# min_periods=1: use whatever is available at the start
print(prices.rolling(3, min_periods=1).mean().round(2).tolist()) # [100.0, 101.0, 101.0, 102.67, 104.33, 106.0, 107.67]
# expanding(): everything since the first day, not a fixed window
print(prices.expanding().mean().round(2).tolist()) # [100.0, 101.0, 101.0, 102.0, 103.0, 103.5, 104.43]
# Rolling volatility of the returns: the first return is NaN, so three returns exist only from the 4th day
returns = prices.pct_change()
print(returns.rolling(3).std().round(4).tolist()) # [nan, nan, nan, 0.0249, 0.0248, 0.0246, 0.0237]From windows to signals
Rolling statistics become trading signals when you compare them. A z-score says how far today's price is from its own recent mean, in units of its recent standard deviation: (price - rolling mean) / rolling std. Build the rolling object once (r = prices.rolling(3)) and use it twice. A moving-average crossover compares a fast average with a slow one: while the fast one is above, the recent trend is up. A comparison gives booleans, and astype("int64") turns them into a 0/1 signal. Comparisons with NaN are False, so the signal is 0 until both averages exist.
# z-score of each price against its own 3-day window
rolling = prices.rolling(3)
zscore = (prices - rolling.mean()) / rolling.std()
print(zscore.round(2).tolist()) # [nan, nan, 0.0, 1.12, 0.87, 0.0, 1.12]# Moving-average crossover: 1 while the 2-day average is above the 4-day average
fast = prices.rolling(2).mean()
slow = prices.rolling(4).mean()
signal = (fast > slow).astype("int64")
print(signal.tolist()) # [0, 0, 0, 1, 1, 1, 1]
# Trade on the next day: the signal of today's close cannot be traded before tomorrow
print(signal.shift(1).fillna(0).astype("int64").tolist()) # [0, 0, 0, 0, 1, 1, 1]center=True puts the current row in the middle of the window, using future prices: it looks great on a chart and cannot be traded. A signal computed from today's close should be applied tomorrow, hence shift(1).series.rolling(n).mean()(orstd(),max()...) computes a statistic over the lastnobservations at every position.- The first
n - 1entries areNaN;min_periodsandexpanding()change that on purpose. - Z-scores and moving-average crossovers turn windows into signals; shift a signal before trading it, and avoid
center=True.
Next up: handling missing data, what to do with the NaN values that real price tables always contain.
Lab · what's graded
- Smooth the prices with
moving_average25 pts - Measure recent risk with
rolling_volatility25 pts - Standardise each price against its window with
rolling_zscore25 pts - Turn two averages into a signal with
ma_crossover25 pts