Skip to content

Commit 8db7656

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

File tree

3 files changed

+128
-1
lines changed

3 files changed

+128
-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: 114 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,119 @@ 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
437+
for more details.
438+
"""
439+
440+
if gbm:
441+
new_mu = mu - 0.5 * sigma**2
442+
else:
443+
new_mu = mu
444+
445+
def emdd_qp(x):
446+
""" Q function based on lookup table, for positive drifts """
447+
A = [0.0005, 0.001, 0.0015, 0.002, 0.0025, 0.005,
448+
0.0075, 0.01, 0.0125, 0.015, 0.0175, 0.02,
449+
0.0225, 0.025, 0.0275, 0.03, 0.0325, 0.035,
450+
0.0375, 0.04, 0.0425, 0.045, 0.0475, 0.05,
451+
0.055, 0.06, 0.065, 0.07, 0.075, 0.08,
452+
0.085, 0.09, 0.095, 0.1, 0.15, 0.2,
453+
0.25, 0.3, 0.35, 0.4, 0.45, 0.5,
454+
1.0, 1.5, 2.0, 2.5, 3.0, 3.5,
455+
4.0, 4.5, 5.0, 10.0, 15.0, 20.0,
456+
25.0, 30.0, 35.0, 40.0, 45.0, 50.0,
457+
100.0, 150.0, 200.0, 250.0, 300.0, 350.0,
458+
400.0, 450.0, 500.0, 1000.0, 1500.0, 2000.0,
459+
2500.0, 3000.0, 3500.0, 4000.0, 4500.0, 5000.0]
460+
B = [0.01969, 0.027694, 0.033789, 0.038896, 0.043372, 0.060721,
461+
0.073808, 0.084693, 0.094171, 0.102651, 0.110375, 0.117503,
462+
0.124142, 0.130374, 0.136259, 0.141842, 0.147162, 0.152249,
463+
0.157127, 0.161817, 0.166337, 0.170702, 0.174924, 0.179015,
464+
0.186842, 0.194248, 0.201287, 0.207999, 0.214421, 0.220581,
465+
0.226505, 0.232212, 0.237722, 0.24305, 0.288719, 0.325071,
466+
0.355581, 0.382016, 0.405415, 0.426452, 0.445588, 0.463159,
467+
0.588642, 0.668992, 0.72854, 0.775976, 0.815456, 0.849298,
468+
0.878933, 0.905305, 0.92907, 1.088998, 1.184918, 1.253794,
469+
1.307607, 1.351794, 1.389289, 1.42186, 1.450654, 1.476457,
470+
1.647113, 1.747485, 1.818873, 1.874323, 1.919671, 1.958037,
471+
1.991288, 2.02063, 2.046885, 2.219765, 2.320983, 2.392826,
472+
2.448562, 2.494109, 2.532622, 2.565985, 2.595416, 2.621743]
473+
if x > 5000:
474+
return 0.25 * np.log(x) + 0.49088
475+
elif x < 0.0005:
476+
return 0.5 * np.sqrt(np.pi * x)
477+
else:
478+
return interpolate.interp1d(A, B)([x])[0]
479+
480+
def emdd_qn(x):
481+
""" Q function based on lookup table, for negative drifts """
482+
A = [0.0005, 0.001, 0.0015, 0.002, 0.0025, 0.005,
483+
0.0075, 0.01, 0.0125, 0.015, 0.0175, 0.02,
484+
0.0225, 0.025, 0.0275, 0.03, 0.0325, 0.035,
485+
0.0375, 0.04, 0.0425, 0.045, 0.0475, 0.05,
486+
0.055, 0.06, 0.065, 0.07, 0.075, 0.08,
487+
0.085, 0.09, 0.095, 0.1, 0.15, 0.2,
488+
0.25, 0.3, 0.35, 0.4, 0.45, 0.5,
489+
0.75, 1.0, 1.25, 1.5, 1.75, 2.0,
490+
2.25, 2.5, 2.75, 3.0, 3.25, 3.5,
491+
3.75, 4.0, 4.25, 4.5, 4.75, 5.0]
492+
B = [0.019965, 0.028394, 0.034874, 0.040369, 0.045256, 0.064633,
493+
0.079746, 0.092708, 0.104259, 0.114814, 0.124608, 0.133772,
494+
0.142429, 0.150739, 0.158565, 0.166229, 0.173756, 0.180793,
495+
0.187739, 0.194489, 0.201094, 0.207572, 0.213877, 0.220056,
496+
0.231797, 0.243374, 0.254585, 0.265472, 0.27607, 0.286406,
497+
0.296507, 0.306393, 0.316066, 0.325586, 0.413136, 0.491599,
498+
0.564333, 0.633007, 0.698849, 0.762455, 0.824484, 0.884593,
499+
1.17202, 1.44552, 1.70936, 1.97074, 2.22742, 2.48396,
500+
2.73676, 2.99094, 3.24354, 3.49252, 3.74294, 3.99519,
501+
4.24274, 4.49238, 4.73859, 4.99043, 5.24083, 5.49882]
502+
if x > 5:
503+
return x + 0.5
504+
elif x < 0.0005:
505+
return 0.5 * np.sqrt(np.pi * x)
506+
else:
507+
return interpolate.interp1d(A, B)([x])[0]
508+
509+
if ((not np.isfinite(new_mu)) | (not np.isfinite(sigma)) | (sigma <= 0)):
510+
return np.nan
511+
else:
512+
alpha = new_mu / (2 * sigma**2)
513+
if new_mu == 0:
514+
return - np.sqrt(np.pi / 2) * sigma * np.sqrt(t)
515+
elif new_mu > 0:
516+
return - emdd_qp(alpha * new_mu * t) / alpha
517+
else:
518+
return emdd_qn(alpha * new_mu * t) / alpha
519+
520+
408521
def annual_return(returns, period=DAILY, annualization=None):
409522
"""
410523
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)