Measure how far an equity curve sits below its own running peak with cummax, take the worst one, and date the fall on real prices.
A drawdown is how far the equity curve sits below its own highest point so far. If one unit of money reached 1.155 and is now worth 0.8316, the drawdown is 0.8316 / 1.155 - 1, which is -28%. The maximum drawdown is the worst of those numbers over the whole curve.
In this lesson I compute drawdowns on six returns small enough to check by hand, then on AAA buy and hold from a price file, where I date the deepest fall and count the days the curve took to climb back.
Step 1. Six returns with one visible fall
Start from Lesson 18: cumprod(1 + r) turns returns into the value of one unit of money, day by day.
r <-c(0.10, 0.05, -0.20, -0.10, 0.15, 0.08) # six returns, two of them lossesequity <-cumprod(1+ r) # one unit of money, step by stepprint(round(equity, 4)) # -> [1] 1.1000 1.1550 0.9240 0.8316 0.9563 1.0328
[1] 1.1000 1.1550 0.9240 0.8316 0.9563 1.0328
The curve peaks at 1.1550 on step 2, falls to 0.8316 on step 4, and ends at 1.0328.
cummax() is the running maximum, the same family as cumprod(). Element k is the largest value seen up to and including k, so the result never goes down.
The peak rises to 1.155 on step 2 and stays there, because nothing later beats it. Divide the curve by that line and subtract 1, and you have the drawdown on every step.
dd <- equity / peak -1# how far below the running peakprint(round(dd, 4)) # -> [1] 0.0000 0.0000 -0.2000 -0.2800 -0.1720 -0.1058
[1] 0.0000 0.0000 -0.2000 -0.2800 -0.1720 -0.1058
Zero means the curve is at a new high. Every other value is negative. The worst one is the maximum drawdown, and which.min() says where it happened.
max_dd <-min(dd) # the deepest point, a negative numberprint(round(max_dd, 4)) # -> [1] -0.28
[1] -0.28
print(round(max_dd *100, 2)) # -> [1] -28
[1] -28
print(which.min(dd)) # -> [1] 4 <- position of the deepest point
[1] 4
Check step 4 by hand. The curve is at 0.8316, the peak behind it is the 1.155 set on step 2, and 0.8316 / 1.155 is 0.72.
print(round(equity[4] / equity[2] -1, 4)) # -> [1] -0.28 <- same, peak written out
[1] -0.28
The last two lines are the same sum. cummax() only saves you from hunting for element 2 yourself.
Step 2. AAA, buy and hold
prices.csv sits next to this lesson and holds simulated daily closes for four tickers over six years. read.csv() hands Date back as character, so as.Date() makes it a real date, which I need later to say when the fall happened.
library(dplyr) # filter, arrange, mutate, lagpx <-read.csv("prices.csv") # simulated closes, four tickerspx$Date <-as.Date(px$Date) # character to dateprint(dim(px)) # -> [1] 6264 4
[1] 6264 4
print(head(px, 3))
Date Ticker Close Volume
1 2020-01-01 AAA 75.08 25594073
2 2020-01-01 BBB 144.61 10012162
3 2020-01-01 CCC 160.26 24917606
One ticker, daily returns, then the three columns from Step 1 in one mutate().
aapl <- px |>filter(Ticker =="AAA") |># one tickerarrange(Date) |># oldest first, so lag reaches back a daymutate(ret = Close /lag(Close) -1) |># daily simple returnsfilter(!is.na(ret)) |># drop the first day, which has no returnmutate(equity =cumprod(1+ ret), # buy and hold, one unit of moneypeak =cummax(equity), # running high water markdd = equity / peak -1) # drawdown on every dayprint(nrow(aapl)) # -> [1] 1565
The first three days each set a new high, so peak equals equity and dd is zero. Day 4 falls back and the drawdown opens up.
max_dd <-min(aapl$dd) # worst day of the whole sampleprint(round(max_dd *100, 2)) # -> [1] -29.82
[1] -29.82
print(sum(aapl$dd ==0)) # -> [1] 93 <- days that set a new high
[1] 93
AAA buy and hold lost 29.82% from its peak at the worst point, and spent 93 of 1565 days at a new high.
Step 3. When did it happen
which.min() gives the row of the deepest day, and indexing the date column with that row gives the date. The peak that the fall started from is the highest level before that row, so which.max() runs on equity[1:i] and not on the whole column.
i <-which.min(aapl$dd) # row of the deepest drawdownj <-which.max(aapl$equity[1:i]) # row of the peak that came before itprint(aapl$Date[j]) # -> [1] "2022-05-03"
print(i - j) # -> [1] 298 <- trading days peak to trough
[1] 298
One unit of money was worth 1.8705 on 2022-05-03 and 1.3127 on 2023-06-23. That is 1.3127 / 1.8705 - 1, the -29.82% above. The rows either side of each date show what the columns are doing there.
On 2022-05-03 the drawdown is exactly zero, which is what a peak looks like. From 2022-05-04 onwards peak is stuck at 1.870538 and every dd is measured against it.
Getting back means dd returning to zero. I look only at the rows from the trough onwards and take the first one.
back <-which(aapl$dd[i:nrow(aapl)] ==0)[1] # first day back at a new high, counted from iprint(aapl$Date[i + back -1]) # -> [1] "2024-06-07"
[1] "2024-06-07"
print(back -1) # -> [1] 250 <- trading days trough to recovery
[1] 250
print((back -1) + (i - j)) # -> [1] 548 <- peak to recovery
[1] 548
298 trading days down, 250 back up: 548 trading days, a bit over two calendar years, before the 2022-05-03 level was seen again. The depth and that wait are two different facts about the same fall, and the maximum drawdown reports only the depth.
Step 4. All four tickers
group_by() from Lesson 19 runs the whole calculation inside each ticker, so no curve reaches into the next one.
all_dd <- px |>group_by(Ticker) |># one block per tickerarrange(Date, .by_group =TRUE) |># date order inside each blockmutate(ret = Close /lag(Close) -1) |># returns stop at the block edgefilter(!is.na(ret)) |># drop each ticker's first daymutate(equity =cumprod(1+ ret), # buy and hold per tickerdd = equity /cummax(equity) -1) |>summarise(max_dd =round(min(dd) *100, 2), # deepest fall, in percentworst = Date[which.min(dd)]) # the day it happenedprint(as.data.frame(all_dd))