Skip to content
All library documents

Measuring Drawdown Depth and Recovery Length Across Episodes

Article Quant Q&A · Author: user89135

Summary

The document addresses how to measure more than the largest drawdown: it seeks the count, average depth, and average length of drawdown episodes that exceed a chosen duration threshold. It presents two approaches using price data in Pandas. One calculates returns, a cumulative account value, running highs, and drawdown relative to those highs; it then uses high-water-mark indices to estimate episode lengths and depths. The other groups consecutive observations into drawdown regimes, ending an episode when the series returns to its prior high, and aggregates statistics for regimes that pass a duration filter.

The answers offer code and an artificial price-series example. The regime method also illustrates comparing the computed worst episode with a direct maximum-drawdown calculation. Results depend on definitions: elapsed calendar days and observation counts differ, and the example approaches filter episodes in different ways. The code is presented as a practical starting point, not a formal standard; users should check recovery, threshold, indexing, and averaging conventions against their own analysis needs.

Key ideas

  • Define drawdown as the decline in account value or price from its running high.
  • Segment the series into episodes that end when a new high is reached.
  • Filter episodes by a chosen duration threshold before averaging depth or length.
  • High-water-mark indices and grouped drawdown regimes are two proposed ways to identify episodes.
  • Clarify whether duration means observations or elapsed calendar days, and verify indexing and recovery conventions.

Tags

Full text
# Average drawdown and average drawdown length in Python


# Average drawdown and average drawdown length in Python












I'm trying to use Python to give me more information about drawdowns than just the max drawdown and the duration of the max drawdown. I would like to determine the number of drawdowns that have occurred (beyond a certain day count threshold), the average drawdown, and the average drawdown length. I found this question with an answer about the max draw, and the length of the max draw, but after reading the comments, I'm unsure what to make of it. I also found this question which seems to give a different max drawdown, so I'm a bit confused. I think the second one is what I'm looking for, but I don't want a max; I want an average drawdown that has lasted more than a number of days (say five days).

My dataset is a Pandas dataframe with prices. This is what I have so far, but now I'm stuck on how to proceed:

```
def avg_dd(df, column='close'):

    df['simple_ret'] = df[column].pct_change().fillna(0)
    df['cum_ret'] = (1 + df['simple_ret']).cumprod() - 1
    df['nav'] = ((1 + df['cum_ret']) * 100).fillna(100)
    df['hwm'] = df['nav'].cummax()
    df['dd'] = df['nav'] / df['hwm'] - 1
```

From here, my idea was to use the `hwm` column as an index that increments each time it hits a new high, and the distance between them was the length of that temporary drawdown.

Does anyone have a source or reference that can help me out?

## Answer by amdopt (score 1, accepted)

https://quant.stackexchange.com/a/75532

A slightly different approach than @oronimbus. Hopefully, between both answers, you can accomplish your goal.

The below function takes a Pandas Dataframe, `df`, with prices (not returns) in a column called `close`, does all the necessary calculations, and returns the number of drawdowns, the average depth of all the draws, and the average length of time (in days) to recover the draw (i.e., achieve a new high). On top of the information that the function returns, all the data needed for calculations are added to the dataframe as columns so that you can examine them and understand what I did to get to the output.

```
import pandas as pd
import numpy as np

def avg_dd(df, column='close'):

    df['simple_ret'] = df[column].pct_change().fillna(0)
    df['cum_ret'] = (1 + df['simple_ret']).cumprod() - 1
    df['nav'] = ((1 + df['cum_ret']) * 100).fillna(100)
    df['hwm'] = df['nav'].cummax()
    df['dd'] = df['nav'] / df['hwm'] - 1
    df['hwm_idx'] = (df['nav']
                     .expanding(min_periods=1)
                     .apply(lambda x: x.argmax())
                     .fillna(0)
                     .astype(int))
    df['dd_length'] = (df['hwm_idx'] - df['hwm_idx'].shift(1) - 1).fillna(0)
    df['dd_length'] = df['dd_length'][df['dd_length'] > 5]
    df['dd_length'].fillna(0, inplace=True)
    dd_end_idx = df['hwm_idx'].loc[df['dd_length'] != 0]
    temp_dd_days = df['dd_length'].loc[df['dd_length'] != 0]
    dd_start_idx = dd_end_idx - temp_dd_days
    temp_dd = [min(df['dd'].loc[df.index[int(dd_start_idx[i])]:
                                df.index[int(dd_end_idx[i])]])
               for i in range(len(dd_end_idx))]
    num_dd = len(temp_dd)
    avg_dd = np.average(temp_dd)
    avg_dd_length = (df['dd_length'][df['dd_length'] > 0]).mean()

    return num_dd, avg_dd, avg_dd_length
```

The return of the function is a tuple with the information you are looking for. Happy to explain what is going on in the code if need be, but I think it's pretty easy to figure it out.

Good luck!

## Answer by oronimbus (score 2)

https://quant.stackexchange.com/a/75525

I've created a solution that hopefully works for you. Not sure if this is exactly what you had in mind. Anyways, first I'll create some random test data. This gives us a series that somewhat resembles a real asset:

```
import pandas as pd
import numpy as np

np.random.seed(1)
rand = np.random.normal(size=750)
delta_S = (0.05 * 1 / 252 + 0.2 * rand * np.sqrt(1 / 252))
df = pd.DataFrame(S, columns=["S"], index=pd.bdate_range("2010-01-01","2014-01-01")[:750]).add(1).cumprod().mul(100)
```

Next we calculate the drawdown. I've excluded the first observation and then created a flag `is_dd` whenever the drawdown reaches 0. This is meant to delineate a drawdown period, i.e. each drawdown period comes to an end when we reach the zero mark. Next, I identify the drawdown regimes/periods using the `ne` operator and a time series shift. Finally, I remove observations that are just zero because they're not of interest here.

```
dd = df / df.cummax() - 1
dd = dd.iloc[1:]
dd["is_dd"] = np.where(np.isclose(dd, 0), 1, 0)
dd["regime"] = dd["is_dd"].ne(dd["is_dd"].shift(1)).cumsum()
dd = dd[~np.isclose(dd["S"], 0)]
dd.drop(columns="is_dd", inplace=True)
```

Next, you can run some analytics, like average drawdown, length etc. My solution for getting periods of at least 5 days is not very neat but it works:

```
def days_to_trough(x):
    t_date = x[x == x.min()].index
    return (t_date - x.index[0]).days

def days_of_drawdown(x):
    return (x.index[-1] - x.index[0]).days

atleast_5 = dd.groupby("regime")["S"].count().gt(5).replace(False, np.nan).dropna().index
dd[dd["regime"].isin(atleast_5)].groupby("regime")["S"].agg([days_of_drawdown, "min", "mean", days_to_trough]).round(3).T
```

Note if you call the `min` function on this you'll get a max drawdown of 15% (see regime 51) which matches what you get by simple computation of the MDD on the series. It uses the value from 2011-04-14 and 2011-08-12 in my artificial time series and is calculated as `129.503421/152.829372 - 1`.

Finally, a bit of viz:

```
fig, ax = plt.subplots(figsize=(13,5))
dd["S"].plot(title="MaxDD vs regimes", ax=ax)
xpos = dd.reset_index().groupby("regime").first()["index"]

for x in xpos:
    ax.axvline(x=x, color='r', linestyle='-', alpha=0.5)
```

Each vertical line delineates a new drawdown period:

Hope this helps!

Shown in full with attribution under the source's licence. Licence: CC BY-SA 4.0 (Stack Exchange)

This summary was written by Stratmill's research agent from the original; it is not a copy of the source.