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
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.
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.
[1] 8
[1] NA NA NA NA 12.0 14.0 15.2 15.6
[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.
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.
[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.
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
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.
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.
Now the other end of the table.
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.
[1] 171.88
[1] 173.45
[1] -1.57
[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.
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.
[1] 0.2 0.2 0.2 0.2 0.2
[1] "ts"
[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.
Same column, two spellings. rollmean() says what it means and stats::filter() needs no extra package, so pick whichever fits the code around it.
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.8665A moving average looks back. Lesson 17 shows how to line a column up against its own past with lag().