How do I build an equity curve?

Turn a column of returns into the growth of one unit of money with (1 + r).cumprod().

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: (1 + r).cumprod().

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

Step 1. Three returns

cumprod() is the running product. Row 0 is the first growth factor, row 1 is the first times the second, row 2 is the first times the second times the third.

import pandas as pd

r = pd.Series([0.10, -0.10, 0.05])

print(1 + r)
# -> 0    1.10
# -> 1    0.90
# -> 2    1.05
# -> dtype: float64

equity = (1 + r).cumprod()

print(equity)
# -> 0    1.1000
# -> 1    0.9900
# -> 2    1.0395
# -> dtype: float64
0    1.10
1    0.90
2    1.05
dtype: float64
0    1.1000
1    0.9900
2    1.0395
dtype: float64

A gain of 10% followed by a loss of 10% leaves 0.99, not 1.0. The 10% loss is taken off 1.10, so it removes 0.11 and puts back less than the 0.10 that was gained.

print(round(equity.iloc[1], 10))     # -> 0.99
print(round(1.10 * 0.90, 10))        # -> 0.99
0.99
0.99

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

by_hand = 1.10 * 0.90 * 1.05

print(round(by_hand, 10))              # -> 1.0395
print(round(equity.iloc[-1], 10))      # -> 1.0395
print(equity.iloc[-1] == by_hand)      # -> True
1.0395
1.0395
True

One unit of money became 1.0395, so the three returns compounded to a gain of 3.95%.

print(f"{equity.iloc[-1] - 1:.4%}")    # -> 3.9500%
3.9500%

Step 2. A curve from a file of closing prices

prices.csv sits next to this lesson and holds simulated data: 40 business days of closes for three tickers. I keep AAPL, reset the index so the rows count from 0, and turn the closes into returns with pct_change() from Lesson 17.

prices = pd.read_csv("prices.csv")

aapl  = prices[prices["Ticker"] == "AAPL"]
close = aapl["Close"].reset_index(drop=True)

print(close.head(3))
# -> 0    186.66
# -> 1    186.38
# -> 2    188.51
# -> Name: Close, dtype: float64

print(len(close))            # -> 40
0    186.66
1    186.38
2    188.51
Name: Close, dtype: float64
40

The first return has no day before it, so pct_change() puts NaN in row 0. dropna() takes that row out.

ret = close.pct_change()

print(ret.head(3))
# -> 0         NaN
# -> 1   -0.001500
# -> 2    0.011428
# -> Name: Close, dtype: float64

ret = ret.dropna()

print(len(ret))              # -> 39    <- 40 closes give 39 returns
0         NaN
1   -0.001500
2    0.011428
Name: Close, dtype: float64
39

Now the curve. The index starts at 1 because row 0 went with the NaN.

equity = (1 + ret).cumprod()

print(pd.DataFrame({"ret": ret, "equity": equity}).head(4))
# ->         ret    equity
# -> 1 -0.001500  0.998500
# -> 2  0.011428  1.009911
# -> 3 -0.006737  1.003107
# -> 4  0.010842  1.013983
        ret    equity
1 -0.001500  0.998500
2  0.011428  1.009911
3 -0.006737  1.003107
4  0.010842  1.013983

The first day of the curve is just 1 + ret, since there is nothing yet to multiply by. Every day after that multiplies yesterday’s level by 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, so the final value of the curve has to match.

print(round(equity.iloc[-1], 6))                    # -> 0.90764
print(round(close.iloc[-1] / close.iloc[0], 6))     # -> 0.90764

print(close.iloc[0], close.iloc[-1])                # -> 186.66 169.42
0.90764
0.90764
186.66 169.42

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

print(round((10000 * equity).iloc[-1], 2))          # -> 9076.4
9076.4

Step 4. cumprod against cumsum

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

print(pd.DataFrame({"cumprod": equity, "cumsum": ret.cumsum()}).tail(3))
# ->      cumprod    cumsum
# -> 37  0.924890 -0.075361
# -> 38  0.918354 -0.082427
# -> 39  0.907640 -0.094095

print(f"{equity.iloc[-1] - 1:.4%}")        # -> -9.2360%
print(f"{ret.cumsum().iloc[-1]:.4%}")      # -> -9.4095%
     cumprod    cumsum
37  0.924890 -0.075361
38  0.918354 -0.082427
39  0.907640 -0.094095
-9.2360%
-9.4095%

The price went from 186.66 to 169.42, which is -9.2360%.

Your turn

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

import pandas as pd

prices = pd.read_csv("prices.csv")

msft  = prices[prices["Ticker"] == "MSFT"]
close = msft["Close"].reset_index(drop=True)

ret    = close.pct_change().dropna()
equity = (1 + ret).cumprod()

print(round(equity.iloc[-1], 6))                   # -> 1.04547
print(round(close.iloc[-1] / close.iloc[0], 6))    # -> 1.04547
print(f"{equity.iloc[-1] - 1:.4%}")                # -> 4.5470%