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.
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.99print(round(1.10*0.90, 10)) # -> 0.99 <- the same two factors by hand
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# the three growth factors written outprint(round(by_hand, 10)) # -> 1.0395print(round(equity.iloc[-1], 10)) # -> 1.0395print(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% <- final level minus 1
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 AAA, 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") # 40 business days for three tickersaapl = prices[prices["Ticker"] =="AAA"] # keep the AAA rows onlyclose = aapl["Close"].reset_index(drop=True) # renumber the rows from 0print(close.head(3))# -> 0 186.66# -> 1 186.38# -> 2 188.51# -> Name: Close, dtype: float64print(len(close)) # -> 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() # each close against the one beforeprint(ret.head(3))# -> 0 NaN# -> 1 -0.001500# -> 2 0.011428# -> Name: Close, dtype: float64ret = ret.dropna() # row 0 held the NaN, so it goesprint(len(ret)) # -> 39 <- 40 closes give 39 returns
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.90764print(round(close.iloc[-1] / close.iloc[0], 6)) # -> 0.90764 <- last close over firstprint(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 <- the curve just scales
9076.4
Step 4. cumprod against cumsum
cumsum() is the running total instead of the running product. Both run down the same 39 returns.