What is a moving average?

Compute the mean of the last n values at every row with zoo::rollmean, add five and ten day averages to a price table, and match them in base R.

A moving average is the mean of the last n values, worked out again at every row. With n of five, row 5 averages rows 1 to 5, row 6 averages rows 2 to 6, and so on down the column. The first four rows have no answer, because there are not yet five values behind them.

In this lesson I compute one on a short vector you can check by hand, then add a five day and a ten day average to one ticker’s closes, and reproduce the five day one in base R.

Step 1. Five values at a time

rollmean() comes from the zoo package. k is the window length, fill = NA puts NA where a window does not fit, and align = "right" makes the window end on the current position.

library(zoo)
library(dplyr)

x <- c(10, 12, 14, 11, 13, 20, 18, 16)  # eight values, short enough to check by hand
print(length(x))                        # -> [1] 8
[1] 8
ma5 <- rollmean(x, k = 5, fill = NA, align = "right")  # mean of the last five, at every position

print(ma5)              # -> [1]   NA   NA   NA   NA 12.0 14.0 15.2 15.6
[1]   NA   NA   NA   NA 12.0 14.0 15.2 15.6
print(length(ma5))      # -> [1] 8
[1] 8

Eight values in, eight values out. The first four are NA. That stretch of NA is the warm-up: a five day average needs five days.

Now check three of the numbers yourself. Position 5 is the mean of positions 1 to 5, position 6 the mean of 2 to 6, and position 8 the mean of 4 to 8.

print(mean(x[1:5]))     # -> [1] 12       <- matches ma5 at position 5
[1] 12
print(mean(x[2:6]))     # -> [1] 14       <- matches ma5 at position 6
[1] 14
print(x[4:8])           # -> [1] 11 13 20 18 16
[1] 11 13 20 18 16
print(mean(x[4:8]))     # -> [1] 15.6     <- matches ma5 at position 8
[1] 15.6

Step 2. Why the window has to end on the current row

align = "right" is the only setting that uses no future data. Ask for align = "center" and the window sits either side of the current position, so row 3 averages rows 1 to 5.

print(rollmean(x, k = 5, fill = NA, align = "center"))  # window straddles the current position
[1]   NA   NA 12.0 14.0 15.2 15.6   NA   NA
# -> [1]   NA   NA 12.0 14.0 15.2 15.6   NA   NA

The same four numbers, moved two places up. Under the centred version, the value sitting on row 3 already contains rows 4 and 5. On a price series that means today’s average knows tomorrow’s price, and any rule built on it could not have been traded. Write align = "right" every time.

Step 3. Two averages on one ticker

prices.csv holds simulated daily closes for three tickers over 40 business days. read.csv() returns Date as text, so I convert it with as.Date() before sorting on it.

prices <- read.csv("prices.csv")        # 40 business days for three tickers
prices$Date <- as.Date(prices$Date)     # real dates, so sorting is chronological

aapl <- prices |>                       # build the one ticker table
  filter(Ticker == "AAA") |>           # keep the AAA rows only
  arrange(Date)                         # oldest row first, which rollmean assumes

print(nrow(aapl))                       # -> [1] 40
[1] 40
print(head(aapl, 3))                    # three oldest rows, one ticker only
        Date Ticker  Close  Volume
1 2026-01-02    AAA 186.66 2183494
2 2026-01-05    AAA 186.38 8463199
3 2026-01-06    AAA 188.51 5299573
# ->         Date Ticker  Close  Volume
# -> 1 2026-01-02    AAA 186.66 2183494
# -> 2 2026-01-05    AAA 186.38 8463199
# -> 3 2026-01-06    AAA 188.51 5299573

rollmean() reads the vector in the order it is given, so an unsorted table gives an average of whatever rows happen to be adjacent.

mutate() adds both averages as columns in one call.

aapl <- aapl |>
  mutate(
    ma5  = rollmean(Close, k = 5,  fill = NA, align = "right"),  # five day mean as a new column
    ma10 = rollmean(Close, k = 10, fill = NA, align = "right")   # ten day mean, longer warm-up
  )

print(head(aapl[, c("Date", "Close", "ma5", "ma10")], 6))  # four columns, first six rows
        Date  Close     ma5 ma10
1 2026-01-02 186.66      NA   NA
2 2026-01-05 186.38      NA   NA
3 2026-01-06 188.51      NA   NA
4 2026-01-07 187.24      NA   NA
5 2026-01-08 189.27 187.612   NA
6 2026-01-09 185.32 187.344   NA
# ->         Date  Close     ma5 ma10
# -> 1 2026-01-02 186.66      NA   NA
# -> 2 2026-01-05 186.38      NA   NA
# -> 3 2026-01-06 188.51      NA   NA
# -> 4 2026-01-07 187.24      NA   NA
# -> 5 2026-01-08 189.27 187.612   NA
# -> 6 2026-01-09 185.32 187.344   NA

ma5 starts on row 5 and ma10 has not started yet. A window of k costs you k - 1 rows.

print(sum(is.na(aapl$ma5)))    # -> [1] 4
[1] 4
print(sum(is.na(aapl$ma10)))   # -> [1] 9
[1] 9

Now the other end of the table.

print(tail(aapl[, c("Date", "Close", "ma5", "ma10")], 5))  # the same four columns at the end
         Date  Close     ma5    ma10
36 2026-02-20 174.17 173.694 177.617
37 2026-02-23 171.75 173.178 176.817
38 2026-02-24 172.64 172.954 175.886
39 2026-02-25 171.42 172.502 174.767
40 2026-02-26 169.42 171.880 173.449
# ->        Date  Close     ma5    ma10
# -> 36 2026-02-20 174.17 173.694 177.617
# -> 37 2026-02-23 171.75 173.178 176.817
# -> 38 2026-02-24 172.64 172.954 175.886
# -> 39 2026-02-25 171.42 172.502 174.767
# -> 40 2026-02-26 169.42 171.880 173.449

tail() keeps the original row numbers, so those are rows 36 to 40 of 40. On the final row the two averages differ.

last <- tail(aapl, 1)  # the final row on its own

print(round(last$ma5, 2))                 # -> [1] 171.88
[1] 171.88
print(round(last$ma10, 2))                # -> [1] 173.45
[1] 173.45
print(round(last$ma5 - last$ma10, 2))     # -> [1] -1.57
[1] -1.57
print(round(mean(aapl$Close[36:40]), 3))  # -> [1] 171.88   <- ma5 by hand
[1] 171.88

The five day average is 1.57 below the ten day one, because the two windows cover different stretches of the column. Comparing them is Lesson 23.

Step 4. The same numbers in base R

stats::filter() slides a set of weights along a vector. Give it five weights of 1/5 and you have a five day mean. sides = 1 restricts it to the current value and the ones before it, which is what align = "right" did.

I write stats::filter in full because dplyr has its own filter() and it is the one loaded on top.

print(rep(1/5, 5))                      # -> [1] 0.2 0.2 0.2 0.2 0.2
[1] 0.2 0.2 0.2 0.2 0.2
base_ma5 <- stats::filter(aapl$Close, rep(1/5, 5), sides = 1)  # weighted sum of the last five

print(class(base_ma5))                  # -> [1] "ts"
[1] "ts"
print(round(head(as.numeric(base_ma5), 6), 4))  # same first six values as ma5
[1]      NA      NA      NA      NA 187.612 187.344
# -> [1]      NA      NA      NA      NA 187.612 187.344

stats::filter() hands back a time series object rather than a plain vector, so I strip it with as.numeric() before comparing. all.equal() checks two numeric vectors match to within floating point tolerance.

print(all.equal(as.numeric(base_ma5), aapl$ma5))   # -> [1] TRUE
[1] TRUE

Same column, two spellings. rollmean() says what it means and stats::filter() needs no extra package, so pick whichever fits the code around it.

Your turn

Take CCC out of prices.csv, sort it by date, and add a 20 day moving average with mutate(). How many rows come back NA? Check the last value against mean() of the last 20 closes.

msft <- prices |>
  filter(Ticker == "CCC") |>
  arrange(Date) |>
  mutate(ma20 = rollmean(Close, k = 20, fill = NA, align = "right"))

print(sum(is.na(msft$ma20)))            # -> [1] 19      <- k - 1 rows of warm-up
print(tail(msft[, c("Date", "Close", "ma20")], 3))
# ->          Date  Close     ma20
# -> 38 2026-02-24 429.00 436.3085
# -> 39 2026-02-25 435.51 436.2230
# -> 40 2026-02-26 435.94 435.8665

print(round(mean(tail(msft$Close, 20)), 4))   # -> [1] 435.8665

A moving average looks back. Lesson 17 shows how to line a column up against its own past with lag().