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
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.
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.
[1] 0.10 -0.10 0.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.
[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.
[1] 0.99
[1] 0.99
[1] TRUE
The whole curve is that multiplication carried on. Here is the last value against the product written out longhand.
[1] 1.0395
[1] 1.0395
[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.
[1] 0.0395
[1] 3.95
One unit of money became 1.0395, a gain of 3.95%.
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.
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
[1] 120
I want one ticker. Base R does it with a logical row index, the same idea as Lesson 5.
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
[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.
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.
[1] 39
With 39 clean returns, the curve is one mutate().
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.
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.
[1] 0.90764
[1] 0.90764
[1] 186.66 169.42
One unit of money became 0.90764. Ten thousand becomes 9076.40, because the curve scales.
cumsum() is the running total instead of the running product. Both run down the same 39 returns.
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
[1] -9.236
[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().
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.547Lesson 19 groups the table by ticker, so a curve like this one can be built for all three at once.