Measure how far an equity curve sits below its own running peak, and how long it stays there.
A drawdown is how far the equity curve sits below its own highest point so far. If one unit of money grew to 1.20 and then fell to 0.90, the drawdown is 0.90 / 1.20 - 1, which is -25%. The maximum drawdown is the worst of those numbers over the whole curve.
In this lesson I compute the drawdown of a six-point curve I can check by hand, then on AAA: the deepest fall, the day it hit bottom, the peak it fell from, and how many trading days it took to get back to that peak.
Step 1. Six points
cummax() is the running maximum, in the same family as cumprod() from Lesson 21. Row 0 is the first value, row 1 is the larger of the first two, row 2 is the largest of the first three. It never goes down.
import pandas as pdequity = pd.Series([1.00, 1.20, 1.10, 0.90, 1.05, 1.30]) # six points I can check by handpeak = equity.cummax() # highest value up to and including this rowprint(peak)# -> 0 1.0# -> 1 1.2# -> 2 1.2# -> 3 1.2# -> 4 1.2# -> 5 1.3# -> dtype: float64
The curve reached 1.20 on row 1, so the peak stays at 1.20 through rows 2, 3 and 4 even while the curve falls. On row 5 the curve makes a new high and the peak follows it up.
The drawdown is the curve divided by that peak, minus 1.
Drawdown is zero on every row that sets a new high, and negative everywhere else. It is never positive, because the curve can never be above its own running maximum.
Row 3 by hand: the curve is at 0.90 and the peak behind it is 1.20.
Row 5 is a new high, so the curve ends with no drawdown at all, even though it lost a quarter of its value along the way.
Step 2. AAA, buy and hold
prices.csv sits next to this lesson and holds simulated data: daily closes for AAA, CCC, DDD and BBB from 2020 to 2025. I take AAA, turn the closes into returns, and compound them into an equity curve.
prices = pd.read_csv("prices.csv", parse_dates=["Date"]) # simulated closes, four tickersaapl = prices[prices["Ticker"] =="AAA"].set_index("Date") # one ticker, dates as the indexret = aapl["Close"].pct_change().dropna() # daily returns, first row has noneequity = (1+ ret).cumprod() # one unit of money compounded forwardprint(len(equity)) # -> 1565print(f"{equity.iloc[-1] -1:.2%}") # -> 165.45%
1565
165.45%
Buying and holding turned one unit of money into 2.65. Now the same two lines as Step 1, on 1565 rows instead of six.
peak = equity.cummax() # the high water mark the curve has reacheddrawdown = equity / peak -1# zero on a new high, negative everywhere elsemax_dd = drawdown.min() # the deepest fall in the sampletrough_date = drawdown.idxmin() # the day that fall bottomed outprint(f"{max_dd:.2%}") # -> -29.82%print(trough_date.date()) # -> 2023-06-23
-29.82%
2023-06-23
The worst point of the whole sample is 23 June 2023, where the curve was 29.82% below the best level it had reached before that day.
print((drawdown <0).sum(), "of", len(drawdown), "days below the peak")# -> 1472 of 1565 days below the peak
1472 of 1565 days below the peak
Step 3. From the peak, to the bottom, and back
Three dates describe the fall: the peak it started from, the trough, and the day the curve got back to the old peak. The peak date is the highest point of the curve up to the trough, which is .idxmax() on the slice ending at the trough.
peak_date = equity.loc[:trough_date].idxmax() # the high the fall started fromprint(peak_date.date()) # -> 2022-05-03print(round(equity.loc[peak_date], 4)) # -> 1.8705print(round(equity.loc[trough_date], 4)) # -> 1.3127
The fall itself took 298 trading days. equity.loc[peak_date:trough_date] is the slice from one date to the other, and its length counts both ends, so I subtract one to count the steps between them.
For the recovery I take everything from the trough onwards and keep the rows that are back at the old peak level. The first of those is the day the drawdown closed.
after = equity.loc[trough_date:] # everything from the trough onwardsrecovered = after[after >= equity.loc[peak_date]] # the days back at the old peak levelback_date = recovered.index[0] # the first of them closes the drawdownprint(back_date.date()) # -> 2024-06-07print(len(equity.loc[trough_date:back_date]) -1) # -> 250print(len(equity.loc[peak_date:back_date]) -1) # -> 548
2024-06-07
250
548
AAA took 298 trading days to fall 29.82% and another 250 to climb back, 548 days from the old peak to the new one.
Step 4. All four tickers
The same three lines work on any close column, so I put them in a function and run it on each ticker.
def drawdown_of(close): # works on any close column equity = (1+ close.pct_change().dropna()).cumprod() # returns compounded into a curvereturn equity / equity.cummax() -1# the curve over its own running peakfor ticker in ["AAA", "CCC", "DDD", "BBB"]: close = prices[prices["Ticker"] == ticker].set_index("Date")["Close"] # one ticker at a time dd = drawdown_of(close) # its drawdown seriesprint(f"{ticker:5s}{dd.min():7.2%}{dd.idxmin().date()}")# -> AAA -29.82% 2023-06-23# -> CCC -32.95% 2023-11-27# -> DDD -55.97% 2023-06-28# -> BBB -20.17% 2025-11-06
DDD fell 55.97% from its peak and BBB fell 20.17%. Over the same six years DDD returned 272.55% and BBB returned 107.03%.
Your turn
Take DDD. Find the peak date its worst drawdown started from, how many trading days the fall took, and whether the curve got back to that peak before the end of the sample.
TipShow answer
import pandas as pdprices = pd.read_csv("prices.csv", parse_dates=["Date"])nvda = prices[prices["Ticker"] =="DDD"].set_index("Date") # one ticker, dates as the indexequity = (1+ nvda["Close"].pct_change().dropna()).cumprod() # buy and hold from the first closepeak = equity.cummax() # the high water mark so fardrawdown = equity / peak -1# how far below it, as a fractiontrough_date = drawdown.idxmin() # the worst daypeak_date = equity.loc[:trough_date].idxmax() # the high it fell fromprint(f"{drawdown.min():.2%}") # -> -55.97%print(peak_date.date()) # -> 2022-10-03print(trough_date.date()) # -> 2023-06-28print(len(equity.loc[peak_date:trough_date]) -1) # -> 192after = equity.loc[trough_date:] # from the trough to the end of the samplerecovered = after[after >= equity.loc[peak_date]] # days back at or above the old peakprint(len(recovered)) # -> 11print(recovered.index[0].date()) # -> 2025-10-29print(len(equity.loc[trough_date:recovered.index[0]]) -1) # -> 610
DDD fell for 192 trading days and needed 610 more to get back. Only 11 days in the sample sit at or above that old peak, and they are the last 11.