How do I build an equity curve?

Turn a column of returns into the growth of one unit of money with cumprod(1 + r), and check the last value against the prices.

An equity curve is what one unit of money is worth after each return in turn. Add 1 to every return to get a growth factor, then multiply the growth factors together as you go: cumprod(1 + r).

In this lesson I build the curve on three returns I can check by hand, then on a column of daily returns from a price file, and I show that the last value of the curve is the last close over the first.

Step 1. Three returns

cumprod() is the running product. Element 1 is the first growth factor, element 2 is the first times the second, element 3 is the first times the second times the third.

r <- c(0.10, -0.10, 0.05)   # three daily returns as decimals

print(r)                    # -> [1]  0.10 -0.10  0.05
[1]  0.10 -0.10  0.05
print(1 + r)                # -> [1] 1.10 0.90 1.05
[1] 1.10 0.90 1.05

Up 10%, down 10%, up 5%. Those three returns become the growth factors 1.10, 0.90 and 1.05.

equity <- cumprod(1 + r)    # running product down the growth factors

print(equity)               # -> [1] 1.1000 0.9900 1.0395
[1] 1.1000 0.9900 1.0395

A gain of 10% followed by a loss of 10% leaves 0.99, not 1.0. The 10% loss comes off 1.10, so it takes away 0.11 while the gain only put on 0.10.

print(equity[2])                  # -> [1] 0.99
[1] 0.99
print(1.10 * 0.90)                # -> [1] 0.99   <- the same two factors by hand
[1] 0.99
print(equity[2] == 1.10 * 0.90)   # -> [1] TRUE
[1] TRUE

The whole curve is that multiplication carried on. Here is the last value against the product written out longhand.

by_hand <- 1.10 * 0.90 * 1.05        # the three growth factors written out
last    <- equity[length(equity)]    # the final level of the curve

print(by_hand)                       # -> [1] 1.0395
[1] 1.0395
print(last)                          # -> [1] 1.0395
[1] 1.0395
print(all.equal(last, by_hand))      # -> [1] TRUE
[1] TRUE

In Lesson 8 prod(1 + r) - 1 gave the total over the whole stretch as one number. cumprod() gives the same multiplication, but keeps every step.

print(prod(1 + r) - 1)               # -> [1] 0.0395   <- Lesson 8, the single total
[1] 0.0395
print(round((last - 1) * 100, 4))    # -> [1] 3.95     <- the curve's last value, minus 1
[1] 3.95

One unit of money became 1.0395, a gain of 3.95%.

Step 2. A curve from a file of closing prices

prices.csv sits next to this lesson and holds simulated closes: 40 business days for three tickers. read.csv() reads dates as character, so as.Date() turns the Date column into real dates. The dates here are written YYYY-MM-DD, so text order and calendar order already agree and arrange(Date) returns the same rows either way. The conversion is for what real dates give you: subtracting two of them, printing them as dates, and matching them against dates in another table.

library(dplyr)

prices      <- read.csv("prices.csv")   # 120 rows, three tickers
prices$Date <- as.Date(prices$Date)     # character to Date

print(head(prices, 3))
        Date Ticker  Close  Volume
1 2026-01-02    AAA 186.66 2183494
2 2026-01-02    CCC 416.98 3117814
3 2026-01-02    DDD 125.18 7447373
# ->         Date Ticker  Close  Volume
# -> 1 2026-01-02    AAA 186.66 2183494
# -> 2 2026-01-02    CCC 416.98 3117814
# -> 3 2026-01-02    DDD 125.18 7447373

print(nrow(prices))                     # -> [1] 120
[1] 120

I want one ticker. Base R does it with a logical row index, the same idea as Lesson 5.

aapl_base <- prices[prices$Ticker == "AAA", ]     # keep the rows where Ticker is AAA
aapl_base <- aapl_base[order(aapl_base$Date), ]    # oldest first, what arrange(Date) does below

print(head(aapl_base, 3))
        Date Ticker  Close  Volume
1 2026-01-02    AAA 186.66 2183494
4 2026-01-05    AAA 186.38 8463199
7 2026-01-06    AAA 188.51 5299573
# ->         Date Ticker  Close  Volume
# -> 1 2026-01-02    AAA 186.66 2183494
# -> 4 2026-01-05    AAA 186.38 8463199
# -> 7 2026-01-06    AAA 188.51 5299573

print(nrow(aapl_base))                             # -> [1] 40
[1] 40

The row names are 1, 4, 7: base R keeps the numbering from the full table. filter() renumbers from 1, which is why the printed tables below start at row 1.

Now the dplyr version, with the returns from Lesson 15 in the same pipeline. lag(Close) is yesterday’s close on today’s row.

aapl <- prices |>
  filter(Ticker == "AAA") |>            # one ticker
  arrange(Date) |>                       # oldest first, so lag reaches back a day
  mutate(ret = Close / lag(Close) - 1)   # today's close over yesterday's, minus 1

print(head(aapl[, c("Date", "Close", "ret")], 3))
        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
# ->         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

The first row has no day before it, so lag() puts NA there. cumprod() would carry that NA all the way down the column, so the row goes.

aapl <- aapl |> filter(!is.na(ret))   # drop the leading NA row

print(nrow(aapl))                     # -> [1] 39   <- 40 closes give 39 returns
[1] 39

With 39 clean returns, the curve is one mutate().

aapl <- aapl |> mutate(equity = cumprod(1 + ret))   # one unit of money, day by day

print(head(aapl[, c("Date", "ret", "equity")], 4))
        Date          ret    equity
1 2026-01-05 -0.001500054 0.9984999
2 2026-01-06  0.011428265 1.0099111
3 2026-01-07 -0.006737043 1.0031073
4 2026-01-08  0.010841700 1.0139826
# ->         Date          ret    equity
# -> 1 2026-01-05 -0.001500054 0.9984999
# -> 2 2026-01-06  0.011428265 1.0099111
# -> 3 2026-01-07 -0.006737043 1.0031073
# -> 4 2026-01-08  0.010841700 1.0139826

The first day of the curve is just 1 + ret, because there is nothing yet to multiply by. Every day after that takes yesterday’s level times today’s growth factor.

Step 3. Check the end against the prices

Multiplying every growth factor together is the same as dividing the last close by the first, since each close cancels the one after it. So the final value of the curve has to match.

last_eq <- aapl$equity[nrow(aapl)]                    # final level of the curve
closes  <- aapl_base$Close                            # the 40 AAA closes, oldest first

print(round(last_eq, 6))                              # -> [1] 0.90764
[1] 0.90764
print(round(closes[length(closes)] / closes[1], 6))   # -> [1] 0.90764   <- last close over first
[1] 0.90764
print(c(closes[1], closes[length(closes)]))           # -> [1] 186.66 169.42
[1] 186.66 169.42

One unit of money became 0.90764. Ten thousand becomes 9076.40, because the curve scales.

print(round(10000 * last_eq, 2))     # -> [1] 9076.4
[1] 9076.4

Step 4. cumprod against cumsum

cumsum() is the running total instead of the running product. Both run down the same 39 returns.

aapl <- aapl |> mutate(added = cumsum(ret))   # running total of the returns

print(tail(aapl[, c("Date", "equity", "added")], 3))
         Date    equity       added
37 2026-02-24 0.9248902 -0.07536057
38 2026-02-25 0.9183542 -0.08242729
39 2026-02-26 0.9076396 -0.09409454
# ->          Date    equity       added
# -> 37 2026-02-24 0.9248902 -0.07536057
# -> 38 2026-02-25 0.9183542 -0.08242729
# -> 39 2026-02-26 0.9076396 -0.09409454

print(round((last_eq - 1) * 100, 4))     # -> [1] -9.236
[1] -9.236
print(round(sum(aapl$ret) * 100, 4))     # -> [1] -9.4095
[1] -9.4095

The price went from 186.66 to 169.42, which is -9.236%. The two final figures are not the same number: simple returns compound, they do not add, which is the point Lesson 8 made with prod() against sum().

Your turn

Build the equity curve for CCC from prices.csv. What is one unit of money worth at the end, and does it equal the last close over the first?

library(dplyr)

prices      <- read.csv("prices.csv")
prices$Date <- as.Date(prices$Date)

msft <- prices |>
  filter(Ticker == "CCC") |>               # one ticker
  arrange(Date)                             # oldest first

closes <- msft$Close                        # all 40 closes, kept before any row is dropped

msft <- msft |>
  mutate(ret = Close / lag(Close) - 1) |>   # daily returns
  filter(!is.na(ret)) |>                    # drop the leading NA
  mutate(equity = cumprod(1 + ret))         # the curve

last_eq <- msft$equity[nrow(msft)]                    # final level

print(round(last_eq, 6))                              # -> [1] 1.04547
print(round(closes[length(closes)] / closes[1], 6))   # -> [1] 1.04547
print(round((last_eq - 1) * 100, 4))                  # -> [1] 4.547

Lesson 19 groups the table by ticker, so a curve like this one can be built for all three at once.