2
$\begingroup$

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?

$\endgroup$
2
  • 2
    $\begingroup$ Good choice to ignore the information in the first link you referenced. Amazingly, the code in the accepted answer does not give a correct max draw yet has five upvotes and an acceptance, but I digress. You are on the right track using hwm as an index to seek temporary drawdown lengths. Specifically, look for when the hwm stops increasing (the start of an interim draw) and when it restarts increasing (the end of the interim draw), the difference in those will give you the length of the interim drawdown. $\endgroup$
    – amdopt
    May 11, 2023 at 16:03
  • $\begingroup$ @amdopt Thanks. That makes sense. I'll give it a try. $\endgroup$
    – user89135
    May 11, 2023 at 16:06

2 Answers 2

1
$\begingroup$

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!

$\endgroup$
2
  • $\begingroup$ Thanks. I'll give this a try too. $\endgroup$
    – user89135
    May 13, 2023 at 14:00
  • $\begingroup$ FYI pandas tables are mutable so any changes made to the table inside the function will affect the original table as well $\endgroup$
    – oronimbus
    May 13, 2023 at 17:20
2
$\begingroup$

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)

enter image description here

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

enter image description here

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: enter image description here

Hope this helps!

$\endgroup$
1
  • $\begingroup$ Thanks for this. This is helpful. I'll try out your solution. $\endgroup$
    – user89135
    May 13, 2023 at 13:38

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.