What is a drawdown?

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 losses
equity <- cumprod(1 + r)                            # one unit of money, step by step

print(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.

peak <- cummax(equity)     # highest level reached so far

print(round(peak, 4))      # -> [1] 1.100 1.155 1.155 1.155 1.155 1.155
[1] 1.100 1.155 1.155 1.155 1.155 1.155

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 peak

print(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 number

print(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], 4))                    # -> [1] 0.8316
[1] 0.8316
print(round(peak[4], 4))                      # -> [1] 1.155
[1] 1.155
print(round(equity[4] / peak[4] - 1, 4))      # -> [1] -0.28
[1] -0.28
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, lag

px      <- read.csv("prices.csv")       # simulated closes, four tickers
px$Date <- as.Date(px$Date)             # character to date

print(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
# ->         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 ticker
  arrange(Date) |>                         # oldest first, so lag reaches back a day
  mutate(ret = Close / lag(Close) - 1) |>  # daily simple returns
  filter(!is.na(ret)) |>                   # drop the first day, which has no return
  mutate(equity = cumprod(1 + ret),        # buy and hold, one unit of money
         peak   = cummax(equity),          # running high water mark
         dd     = equity / peak - 1)       # drawdown on every day

print(nrow(aapl))                          # -> [1] 1565
[1] 1565
print(head(aapl[, c("Date", "Close", "equity", "peak", "dd")], 4))
        Date Close   equity     peak          dd
1 2020-01-02 76.94 1.024774 1.024774  0.00000000
2 2020-01-03 77.34 1.030101 1.030101  0.00000000
3 2020-01-06 78.32 1.043154 1.043154  0.00000000
4 2020-01-07 77.50 1.032232 1.043154 -0.01046987
# ->         Date Close   equity     peak          dd
# -> 1 2020-01-02 76.94 1.024774 1.024774  0.00000000
# -> 2 2020-01-03 77.34 1.030101 1.030101  0.00000000
# -> 3 2020-01-06 78.32 1.043154 1.043154  0.00000000
# -> 4 2020-01-07 77.50 1.032232 1.043154 -0.01046987

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 sample

print(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 drawdown
j <- which.max(aapl$equity[1:i])              # row of the peak that came before it

print(aapl$Date[j])                           # -> [1] "2022-05-03"
[1] "2022-05-03"
print(aapl$Date[i])                           # -> [1] "2023-06-23"
[1] "2023-06-23"
print(round(c(aapl$equity[j], aapl$equity[i]), 4))   # -> [1] 1.8705 1.3127
[1] 1.8705 1.3127
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.

print(aapl[(j - 1):(j + 1), c("Date", "equity", "peak", "dd")])
          Date   equity     peak          dd
608 2022-05-02 1.819259 1.829249 -0.00546090
609 2022-05-03 1.870538 1.870538  0.00000000
610 2022-05-04 1.827251 1.870538 -0.02314156
# ->           Date   equity     peak          dd
# -> 608 2022-05-02 1.819259 1.829249 -0.00546090
# -> 609 2022-05-03 1.870538 1.870538  0.00000000
# -> 610 2022-05-04 1.827251 1.870538 -0.02314156

print(aapl[(i - 1):(i + 1), c("Date", "equity", "peak", "dd")])
          Date   equity     peak         dd
906 2023-06-22 1.313399 1.870538 -0.2978496
907 2023-06-23 1.312733 1.870538 -0.2982056
908 2023-06-26 1.351625 1.870538 -0.2774138
# ->           Date   equity     peak         dd
# -> 906 2023-06-22 1.313399 1.870538 -0.2978496
# -> 907 2023-06-23 1.312733 1.870538 -0.2982056
# -> 908 2023-06-26 1.351625 1.870538 -0.2774138

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 i

print(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 ticker
  arrange(Date, .by_group = TRUE) |>             # date order inside each block
  mutate(ret = Close / lag(Close) - 1) |>        # returns stop at the block edge
  filter(!is.na(ret)) |>                         # drop each ticker's first day
  mutate(equity = cumprod(1 + ret),              # buy and hold per ticker
         dd     = equity / cummax(equity) - 1) |>
  summarise(max_dd = round(min(dd) * 100, 2),    # deepest fall, in percent
            worst  = Date[which.min(dd)])        # the day it happened

print(as.data.frame(all_dd))
  Ticker max_dd      worst
1    AAA -29.82 2023-06-23
2    BBB -20.17 2025-11-06
3    CCC -32.95 2023-11-27
4    DDD -55.97 2023-06-28
# ->   Ticker max_dd      worst
# -> 1    AAA -29.82 2023-06-23
# -> 2    BBB -20.17 2025-11-06
# -> 3    CCC -32.95 2023-11-27
# -> 4    DDD -55.97 2023-06-28

DDD fell 55.97% from its peak, BBB 20.17%. Same six years, same code, and one curve went down almost three times as far as the other.

Your turn

Take DDD on its own and count how many of its days sat more than 20% below the running peak, as a share of all its days.

library(dplyr)

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

nvda <- px |>
  filter(Ticker == "DDD") |>                  # one ticker
  arrange(Date) |>                             # oldest first
  mutate(ret = Close / lag(Close) - 1) |>      # daily returns
  filter(!is.na(ret)) |>                       # drop the leading NA
  mutate(equity = cumprod(1 + ret),            # buy and hold curve
         dd     = equity / cummax(equity) - 1) # drawdown every day

deep <- sum(nvda$dd < -0.20)                   # days more than 20% below the peak

print(round(min(nvda$dd) * 100, 2))            # -> [1] -55.97
print(nvda$Date[which.min(nvda$dd)])           # -> [1] "2023-06-28"
print(deep)                                    # -> [1] 732
print(round(deep / nrow(nvda) * 100, 1))       # -> [1] 46.8

Nearly half of DDD’s days were spent more than 20% under water.

Lesson 27 puts the drawdown next to the return and the volatility, and turns the three into the usual set of numbers for a strategy.