Skip to content

Commit 29cbf6f

Browse files
committed
ENH: add expected max drawdown function (#90)
1 parent b026b4e commit 29cbf6f

File tree

3 files changed

+127
-1
lines changed

3 files changed

+127
-1
lines changed

empyrical/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
downside_risk,
4444
excess_sharpe,
4545
max_drawdown,
46+
expected_max_drawdown,
4647
omega_ratio,
4748
roll_alpha,
4849
roll_alpha_aligned,

empyrical/stats.py

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import pandas as pd
2020
import numpy as np
2121
from math import pow
22-
from scipy import stats, optimize
22+
from scipy import stats, optimize, interpolate
2323
from six import iteritems
2424
from sys import float_info
2525

@@ -405,6 +405,118 @@ def max_drawdown(returns, out=None):
405405
roll_max_drawdown = _create_unary_vectorized_roll_function(max_drawdown)
406406

407407

408+
def expected_max_drawdown(mu, sigma, t, gbm=False):
409+
"""
410+
Determines the expected maximum drawdown of a brownian motion,
411+
given drift and diffusion
412+
413+
If a geometric Brownian motion with stochastic differential equation
414+
dS(t) = Mu0 * S(t) * dt + Sigma0 * S(t) * dW(t) ,
415+
it converts to the form here by Ito's Lemma with X(t) = log(S(t)) such that
416+
Mu = Mu0 - 0.5 * Sigma0^2
417+
Sigma = Sigma0 .
418+
419+
Parameters
420+
----------
421+
mu : float
422+
The drift term of a Brownian motion with drift.
423+
sigma : float
424+
The diffusion term of a Brownian motion with drift.
425+
t : float
426+
A time period of interest
427+
gbp : bool
428+
If true, compute for geometric brownian motion
429+
430+
Returns
431+
-------
432+
expected_max_drawdown : float
433+
434+
Note
435+
-----
436+
See http://www.cs.rpi.edu/~magdon/ps/journal/drawdown_journal.pdf for more details.
437+
"""
438+
439+
if gbm:
440+
new_mu = mu - 0.5 * sigma**2
441+
else:
442+
new_mu = mu
443+
444+
def emdd_qp(x):
445+
""" Return value for Q functions based on lookup table, for positive drifts"""
446+
A = [ 0.0005, 0.001, 0.0015, 0.002, 0.0025, 0.005,
447+
0.0075, 0.01, 0.0125, 0.015, 0.0175, 0.02,
448+
0.0225, 0.025, 0.0275, 0.03, 0.0325, 0.035,
449+
0.0375, 0.04, 0.0425, 0.045, 0.0475, 0.05,
450+
0.055, 0.06, 0.065, 0.07, 0.075, 0.08,
451+
0.085, 0.09, 0.095, 0.1, 0.15, 0.2,
452+
0.25, 0.3, 0.35, 0.4, 0.45, 0.5,
453+
1.0, 1.5, 2.0, 2.5, 3.0, 3.5,
454+
4.0, 4.5, 5.0, 10.0, 15.0, 20.0,
455+
25.0, 30.0, 35.0, 40.0, 45.0, 50.0,
456+
100.0, 150.0, 200.0, 250.0, 300.0, 350.0,
457+
400.0, 450.0, 500.0, 1000.0, 1500.0, 2000.0,
458+
2500.0, 3000.0, 3500.0, 4000.0, 4500.0, 5000.0 ]
459+
B = [ 0.01969, 0.027694, 0.033789, 0.038896, 0.043372, 0.060721,
460+
0.073808, 0.084693, 0.094171, 0.102651, 0.110375, 0.117503,
461+
0.124142, 0.130374, 0.136259, 0.141842, 0.147162, 0.152249,
462+
0.157127, 0.161817, 0.166337, 0.170702, 0.174924, 0.179015,
463+
0.186842, 0.194248, 0.201287, 0.207999, 0.214421, 0.220581,
464+
0.226505, 0.232212, 0.237722, 0.24305, 0.288719, 0.325071,
465+
0.355581, 0.382016, 0.405415, 0.426452, 0.445588, 0.463159,
466+
0.588642, 0.668992, 0.72854, 0.775976, 0.815456, 0.849298,
467+
0.878933, 0.905305, 0.92907, 1.088998, 1.184918, 1.253794,
468+
1.307607, 1.351794, 1.389289, 1.42186, 1.450654, 1.476457,
469+
1.647113, 1.747485, 1.818873, 1.874323, 1.919671, 1.958037,
470+
1.991288, 2.02063, 2.046885, 2.219765, 2.320983, 2.392826,
471+
2.448562, 2.494109, 2.532622, 2.565985, 2.595416, 2.621743 ]
472+
if x > 5000:
473+
return 0.25 * np.log(x) + 0.49088
474+
elif x < 0.0005:
475+
return 0.5 * np.sqrt(np.pi * x)
476+
else:
477+
return interpolate.interp1d(A, B)([x])[0]
478+
479+
def emdd_qn(x):
480+
""" Return value for Q functions based on lookup table, for negative drifts"""
481+
A = [ 0.0005, 0.001, 0.0015, 0.002, 0.0025, 0.005,
482+
0.0075, 0.01, 0.0125, 0.015, 0.0175, 0.02,
483+
0.0225, 0.025, 0.0275, 0.03, 0.0325, 0.035,
484+
0.0375, 0.04, 0.0425, 0.045, 0.0475, 0.05,
485+
0.055, 0.06, 0.065, 0.07, 0.075, 0.08,
486+
0.085, 0.09, 0.095, 0.1, 0.15, 0.2,
487+
0.25, 0.3, 0.35, 0.4, 0.45, 0.5,
488+
0.75, 1.0, 1.25, 1.5, 1.75, 2.0,
489+
2.25, 2.5, 2.75, 3.0, 3.25, 3.5,
490+
3.75, 4.0, 4.25, 4.5, 4.75, 5.0 ]
491+
B = [ 0.019965, 0.028394, 0.034874, 0.040369, 0.045256, 0.064633,
492+
0.079746, 0.092708, 0.104259, 0.114814, 0.124608, 0.133772,
493+
0.142429, 0.150739, 0.158565, 0.166229, 0.173756, 0.180793,
494+
0.187739, 0.194489, 0.201094, 0.207572, 0.213877, 0.220056,
495+
0.231797, 0.243374, 0.254585, 0.265472, 0.27607, 0.286406,
496+
0.296507, 0.306393, 0.316066, 0.325586, 0.413136, 0.491599,
497+
0.564333, 0.633007, 0.698849, 0.762455, 0.824484, 0.884593,
498+
1.17202, 1.44552, 1.70936, 1.97074, 2.22742, 2.48396,
499+
2.73676, 2.99094, 3.24354, 3.49252, 3.74294, 3.99519,
500+
4.24274, 4.49238, 4.73859, 4.99043, 5.24083, 5.49882 ]
501+
if x > 5:
502+
return x + 0.5
503+
elif x < 0.0005:
504+
return 0.5 * np.sqrt(np.pi * x)
505+
else:
506+
return interpolate.interp1d(A, B)([x])[0]
507+
508+
if ((not np.isfinite(new_mu)) | (not np.isfinite(sigma)) | (sigma <= 0)):
509+
return np.nan
510+
else:
511+
alpha = new_mu / (2 * sigma**2)
512+
if new_mu == 0:
513+
return - np.sqrt(np.pi / 2) * sigma * np.sqrt(t)
514+
elif new_mu > 0:
515+
return - emdd_qp(alpha * new_mu * t) / alpha
516+
else:
517+
return emdd_qn(alpha * new_mu * t) / alpha
518+
519+
408520
def annual_return(returns, period=DAILY, annualization=None):
409521
"""
410522
Determines the mean annual growth rate of returns. This is equivilent

empyrical/tests/test_stats.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,19 @@ def test_max_drawdown_translation(self, returns, constant):
282282
assert max_dd <= raised_dd
283283
assert depressed_dd <= max_dd
284284

285+
@parameterized.expand([
286+
(0, 1, 1, False, -1.2533141373155001),
287+
(1, 1, 1, False, -0.926318),
288+
(-1, 1, 1, False, -1.769186),
289+
(0, 1, 1, True, -1.477444),
290+
])
291+
def test_expected_max_drawdown(self, mu, sigma, t, gbm, expected):
292+
assert_almost_equal(
293+
self.empyrical.expected_max_drawdown(mu, sigma, t, gbm),
294+
expected,
295+
DECIMAL_PLACES,
296+
)
297+
285298
@parameterized.expand([
286299
(mixed_returns, empyrical.DAILY, 1.9135925373194231),
287300
(weekly_returns, empyrical.WEEKLY, 0.24690830513998208),

0 commit comments

Comments
 (0)