Insights
How to Calculate Max Drawdown in Python
Alphanume Team · June 2, 2026
The peak-to-trough metric in a few lines.
Most performance stats reward you for returns; max drawdown python practitioners care about punishes you for the pain of getting there. It measures the worst peak-to-trough decline an equity curve ever suffered — the number that determines whether a live investor would have stayed in or bailed. This tutorial builds the full toolkit: the drawdown series, the max_drawdown() function, duration analysis, and the Calmar ratio. For context on the broader risk-adjusted picture, see computing Sharpe and Sortino ratios in Python.
Building the equity curve and the running maximum
Drawdown is measured on the equity curve — the compounded value of a dollar invested — not on the raw return series. Starting from a series of daily returns, (1 + r).cumprod() gives you that curve. From there, pandas does most of the work: .cummax() rolls the running high-water mark forward with a single call, never looking back at a lower value.
import numpy as np
import pandas as pd
def build_drawdown_series(returns: pd.Series) -> pd.Series:
"""
Convert a daily return series into a drawdown series.
Parameters
----------
returns : pd.Series
Daily returns (e.g. 0.01 for +1 %).
Returns
-------
pd.Series
Drawdown at each date (0.0 to -1.0).
"""
equity = (1 + returns).cumprod()
running_max = equity.cummax()
drawdown = equity / running_max - 1
return drawdown
The division equity / running_max is always <= 1, so subtracting 1 gives a series that is 0.0 at every new peak and negative in every valley. The deepest negative value is your max drawdown. Using the equity curve rather than summing raw returns matters: a -3 % day after a -2 % day is not the same as two independent -2.5 % days, and the compounding path is exactly what a real account experiences. Always use total-return equity — one that reinvests dividends — so you do not manufacture phantom drawdowns at every ex-dividend date.
The max_drawdown() function
Wrapping the calculation in a single function lets you slot it into any pipeline without repeating the equity-curve construction.
def max_drawdown(returns: pd.Series) -> float:
"""
Return the maximum drawdown as a negative float (e.g. -0.34 = -34 %).
Parameters
----------
returns : pd.Series
Daily returns.
Returns
-------
float
Maximum drawdown (<= 0).
"""
return float(build_drawdown_series(returns).min())
The result is a negative float — convention in quantitative finance is to report it as a positive magnitude ("the strategy had a 34 % max drawdown"), but storing the raw negative value keeps arithmetic consistent and avoids sign confusion when you feed it into ratios downstream. Call abs(max_drawdown(returns)) only at the display layer.
Finding peak date, trough date, and recovery date
The depth alone is incomplete. A -20 % drawdown that recovered in six weeks is a very different experience from one that took three years. Locating the dates of the peak, the trough, and the eventual recovery — when the equity curve first returned to its prior high — tells the full story. The longest drawdown duration is often the more psychologically damaging number: investors can stomach a sharp crash; they struggle to stay in through an eighteen-month underwater stretch.
def drawdown_dates(returns: pd.Series) -> dict:
"""
Return the peak date, trough date, recovery date, and duration in days
for the maximum drawdown episode.
'recovery_date' is None if the strategy never recovered by the last date.
'duration_days' is measured from peak to recovery (or peak to end if
no recovery occurred).
"""
equity = (1 + returns).cumprod()
running_max = equity.cummax()
drawdown = equity / running_max - 1
trough_date = drawdown.idxmin()
trough_value = drawdown[trough_date]
# Peak is the last date on or before trough where equity was at its max
peak_date = equity[:trough_date].idxmax()
# Recovery: first date after trough where equity >= equity at peak
peak_equity = equity[peak_date]
post_trough = equity[trough_date:]
recovered = post_trough[post_trough >= peak_equity]
if len(recovered) > 0:
recovery_date = recovered.index[0]
duration_days = (recovery_date - peak_date).days
else:
recovery_date = None
duration_days = (equity.index[-1] - peak_date).days
return {
"peak_date": peak_date,
"trough_date": trough_date,
"recovery_date": recovery_date,
"max_drawdown": trough_value,
"duration_days": duration_days,
}
The equity[:trough_date].idxmax() slice finds the last high-water mark before the trough — that is the moment the strategy was at full strength before everything went wrong. The recovery check uses a simple threshold: the equity curve must return to the level it held on peak day, not merely to a new all-time high, so partial recoveries from secondary drawdowns are handled correctly. If the series ends underwater, recovery_date is None and duration is measured to the last observation.
The Calmar ratio
The Calmar ratio divides the annualised return by the absolute value of max drawdown. It answers the question a portfolio allocator actually asks: how much return did you earn per unit of the worst pain you inflicted? A Calmar above 1.0 means you returned more than you drew down; anything below 0.5 raises eyebrows in most institutional contexts. For plotting an equity curve in Python alongside the drawdown shading, you will want both the equity and drawdown series already computed.
def calmar_ratio(returns: pd.Series, periods_per_year: int = 252) -> float:
"""
Calmar ratio: annualised return divided by absolute max drawdown.
Parameters
----------
returns : pd.Series
Daily returns.
periods_per_year : int
Trading days per year (252 for equities, 365 for crypto).
Returns
-------
float
Calmar ratio. Returns NaN if max drawdown is zero.
"""
n = len(returns)
total_return = float((1 + returns).prod())
ann_return = total_return ** (periods_per_year / n) - 1
mdd = abs(max_drawdown(returns))
if mdd == 0:
return float("nan")
return ann_return / mdd
The annualisation uses the geometric formula — total_return ** (periods_per_year / n) — which compounds correctly over any observation window. Avoid the arithmetic shortcut of multiplying the mean daily return by 252; it overstates returns in volatile series by ignoring the variance drag.
Worked example
Putting it all together with a synthetic return series that includes a deliberate drawdown episode:
rng = np.random.default_rng(42)
dates = pd.date_range("2022-01-03", periods=756, freq="B")
returns = pd.Series(rng.normal(0.0004, 0.012, len(dates)), index=dates)
# Inject a -25 % drawdown episode from row 200 to 350
returns.iloc[200:350] += -0.002
print(f"Max drawdown : {max_drawdown(returns):.2%}")
print(f"Calmar ratio : {calmar_ratio(returns):.2f}")
info = drawdown_dates(returns)
print(f"Peak : {info['peak_date'].date()}")
print(f"Trough : {info['trough_date'].date()}")
if info["recovery_date"]:
print(f"Recovery : {info['recovery_date'].date()}")
else:
print("Recovery : still underwater")
print(f"Duration : {info['duration_days']} days")
The injected drift shifts a 150-day window into a gradual grind down, which typically produces a longer duration than a single shock day would. Run it yourself and compare the duration to a version where you replace the spread-out drift with a single large negative day — the depth may be similar but the recovery date will differ substantially.
Subtleties worth knowing
Max drawdown is intuitive but not the whole picture. A few things to keep in mind before you promote it to your primary risk metric.
Close-based vs. intraday drawdown. Computing on daily closes understates the true intraday pain. A day that opened -8 % and closed -1 % shows up as a -1 % observation in a close-based series. If you are sizing positions or setting stop-losses, use bid-ask mid or minute bars where available.
Average drawdown and the Ulcer Index. Max drawdown is the single worst episode; it tells you nothing about how often or how deeply the strategy was underwater on an average day. The average drawdown — drawdown[drawdown < 0].mean() — and the Ulcer Index (sqrt(mean(drawdown**2))) both capture persistent underwater exposure that a lucky single-path max drawdown misses entirely. A strategy with a modest max drawdown but a high Ulcer Index is underwater almost constantly; that matters for drawdown-sensitive mandates.
Length of history matters. Max drawdown is a sample statistic that grows monotonically with the observation window. A three-year backtest with a 15 % max drawdown will typically show a deeper drawdown over twenty years just because there are more opportunities for an extreme sequence. Normalise comparisons to the same time span, or use rolling-window drawdown distributions instead of a single lifetime figure.
For the full field list and endpoint details, consult the API documentation. Drawdown sits naturally alongside return-to-risk ratios — once you have both, you have the two numbers most allocators ask for first.