How do I compute returns in a table?

Add a daily return column to a price table with dplyr::lag(), and see what goes wrong when one table holds several tickers.

You compute a return in a table with mutate(ret = Close / lag(Close) - 1). dplyr::lag() reaches back one row and hands you yesterday’s close alongside today’s.

In this lesson I add a return column to one ticker, check it against the diff() version from Lesson 8, repeat it on a second ticker, and then run lag() on all three tickers at once to show what it gets wrong.

Step 1. One ticker, oldest row first

The file is simulated. It holds three tickers in one table, three rows per date, so before anything else I pull out one ticker and sort it.

lag() reaches back one row of the table, not one calendar day, so the row above has to be the day before.

library(dplyr)

prices      <- read.csv("prices.csv")   # simulated closes, 120 rows
prices$Date <- as.Date(prices$Date)     # read.csv gives Date as character

aapl <- prices |>                       # prices itself is left untouched
  filter(Ticker == "AAA") |>           # one ticker only
  arrange(Date)                         # oldest row first

print(nrow(aapl))                       # -> [1] 40
[1] 40
print(head(aapl, 4))                    # check the sort and the ticker filter
        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
4 2026-01-07    AAA 187.24 4931962
# ->         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
# -> 4 2026-01-07    AAA 187.24 4931962

Step 2. The return column

mutate() adds a column. lag(Close) slides the closes down one place and puts NA in the gap at the top, so each row divides today’s close by the one above it. Both Close and lag(Close) are 40 long, so the division runs element by element and the answer is 40 long too.

aapl <- aapl |>                         # the result replaces aapl
  mutate(ret = Close / lag(Close) - 1)  # today over yesterday, minus one

print(head(aapl[, c("Date", "Close", "ret")], 5))  # ret alongside its close
        Date  Close          ret
1 2026-01-02 186.66           NA
2 2026-01-05 186.38 -0.001500054
3 2026-01-06 188.51  0.011428265
4 2026-01-07 187.24 -0.006737043
5 2026-01-08 189.27  0.010841700
# ->         Date  Close          ret
# -> 1 2026-01-02 186.66           NA
# -> 2 2026-01-05 186.38 -0.001500054
# -> 3 2026-01-06 188.51  0.011428265
# -> 4 2026-01-07 187.24 -0.006737043
# -> 5 2026-01-08 189.27  0.010841700

Row 1 is NA. The first day has no day before it, so it has no return. That is the same fact Lesson 8 stated as “three prices give two returns”, written differently: diff() dropped the day, lag() keeps the row and marks it NA.

There is exactly one NA, and the rest compound to the move from the first close to the last. ret[-1] drops the first element, the NA.

print(sum(is.na(aapl$ret)))                                 # -> [1] 1
[1] 1
print(round(prod(1 + aapl$ret[-1]) - 1, 6))                 # -> [1] -0.09236
[1] -0.09236
print(round(aapl$Close[nrow(aapl)] / aapl$Close[1] - 1, 6)) # -> [1] -0.09236
[1] -0.09236

AAA fell 9.24% over the 40 days.

Step 3. Why lag() and not diff()

Run the Lesson 8 line on the same closes. The numbers agree, but the lengths do not.

d <- diff(aapl$Close) / head(aapl$Close, -1)  # lesson 8, on a bare vector

print(length(d))                              # -> [1] 39
[1] 39
print(length(aapl$ret))                       # -> [1] 40
[1] 40
print(all.equal(d, aapl$ret[-1]))             # -> [1] TRUE
[1] TRUE

diff() returns one element fewer than it was given. A table column has to be as long as the table, so d cannot go into a 40-row data frame without you first pasting an NA on the front. lag() returns the same length it was given, which is why it is the one that fits in mutate().

Step 4. The second ticker

Same three lines on CCC.

msft <- prices |>
  filter(Ticker == "CCC") |>                 # second ticker
  arrange(Date) |>
  mutate(ret = Close / lag(Close) - 1)        # same calculation

print(head(msft[, c("Date", "Close", "ret")], 4))
        Date  Close         ret
1 2026-01-02 416.98          NA
2 2026-01-05 423.19 0.014892801
3 2026-01-06 423.95 0.001795884
4 2026-01-07 435.34 0.026866376
# ->         Date  Close         ret
# -> 1 2026-01-02 416.98          NA
# -> 2 2026-01-05 423.19 0.014892801
# -> 3 2026-01-06 423.95 0.001795884
# -> 4 2026-01-07 435.34 0.026866376

print(round(mean(msft$ret, na.rm = TRUE), 6)) # -> [1] 0.001199
[1] 0.001199

mean() returns NA unless you pass na.rm = TRUE, because the NA in row 1 spreads through the sum.

Step 5. The same line on all three tickers

The same mutate() runs on the whole table. Sort by ticker and date, then run it.

stacked <- prices |>
  arrange(Ticker, Date) |>                    # AAA rows, then CCC, then DDD
  mutate(ret = Close / lag(Close) - 1)        # no grouping

print(nrow(stacked))                          # -> [1] 120
[1] 120
print(sum(is.na(stacked$ret)))                # -> [1] 1
[1] 1

120 rows, three tickers, and one NA. There should be three, one at the top of each ticker. Look at where AAA ends and CCC begins.

print(stacked[39:42, c("Date", "Ticker", "Close", "ret")])
         Date Ticker  Close          ret
39 2026-02-25    AAA 171.42 -0.007066728
40 2026-02-26    AAA 169.42 -0.011667250
41 2026-01-02    CCC 416.98  1.461220635
42 2026-01-05    CCC 423.19  0.014892801
# ->          Date Ticker  Close          ret
# -> 39 2026-02-25    AAA 171.42 -0.007066728
# -> 40 2026-02-26    AAA 169.42 -0.011667250
# -> 41 2026-01-02    CCC 416.98  1.461220635
# -> 42 2026-01-05    CCC 423.19  0.014892801

Row 41 is the first day of CCC and it claims a return of 146%. lag() handed it row 40, the last close of AAA, so R divided 416.98 by 169.42.

print(round(stacked$ret[41], 6))                            # -> [1] 1.461221
[1] 1.461221
print(round(stacked$Close[41] / stacked$Close[40] - 1, 6))  # -> [1] 1.461221
[1] 1.461221

The other boundary does the same thing in the other direction.

print(stacked[c(40, 41, 80, 81), c("Date", "Ticker", "Close", "ret")])
         Date Ticker  Close           ret
40 2026-02-26    AAA 169.42 -0.0116672500
41 2026-01-02    CCC 416.98  1.4612206351
80 2026-02-26    CCC 435.94  0.0009873482
81 2026-01-02    DDD 125.18 -0.7128503923
# ->          Date Ticker  Close           ret
# -> 40 2026-02-26    AAA 169.42 -0.0116672500
# -> 41 2026-01-02    CCC 416.98  1.4612206351
# -> 80 2026-02-26    CCC 435.94  0.0009873482
# -> 81 2026-01-02    DDD 125.18 -0.7128503923

Row 81 reads CCC’s 435.94 as DDD’s yesterday and reports a 71% fall. Two rows out of 120 are wrong. Average the stacked column, then average AAA’s own column from Step 2.

print(round(mean(stacked$ret, na.rm = TRUE), 6))  # -> [1] 0.004711
[1] 0.004711
print(round(mean(aapl$ret, na.rm = TRUE), 6))     # -> [1] -0.002413
[1] -0.002413

The stacked mean is positive. The AAA mean is negative.

The fix is to tell lag() where each ticker starts and stops, with group_by(Ticker). That is Lesson 19. Until then, filter to one ticker before you take a return.

Your turn

Pull DDD out of prices, sort it by date, and add a ret column with lag(). Count the NAs, compound the rest, and check the answer against the last close over the first.

nvda <- prices |>
  filter(Ticker == "DDD") |>                 # third ticker
  arrange(Date) |>                            # oldest row first
  mutate(ret = Close / lag(Close) - 1)        # today over yesterday, minus one

print(head(nvda[, c("Date", "Close", "ret")], 4))
# ->         Date  Close          ret
# -> 1 2026-01-02 125.18           NA
# -> 2 2026-01-05 124.66 -0.004154018
# -> 3 2026-01-06 122.78 -0.015081020
# -> 4 2026-01-07 122.28 -0.004072324

print(sum(is.na(nvda$ret)))                                 # -> [1] 1
print(round(prod(1 + nvda$ret[-1]) - 1, 6))                 # -> [1] -0.140677
print(round(nvda$Close[nrow(nvda)] / nvda$Close[1] - 1, 6)) # -> [1] -0.140677