How to Calculate Maximum Drawdown and Time Underwater in Python

Maximum drawdown is one of the few risk metrics that requires no distributional assumptions at all — it is a direct, model-free measurement of the worst peak-to-trough decline an equity curve has actually experienced. That makes it easier to compute correctly than the Sharpe or Sortino ratio, and just as easy to misinterpret if you stop at the headline percentage without also measuring time underwater, the duration it took to recover.
This tutorial builds a complete, correct pandas implementation from a returns series through to a two-panel equity/drawdown chart, and explains why the recovery period often matters more in practice than the raw depth of the drawdown.
Unlike moment-based statistics such as the Sharpe or Sortino ratio, drawdown and time underwater require no assumption about the shape of the return distribution — they are computed directly from the path the equity curve actually traced. That makes them a useful complement to ratio-based metrics rather than a replacement: a strategy can have an excellent Sharpe ratio computed over a full sample while still having spent a disproportionate share of that sample underwater, a fact the ratio alone will never surface.
Precise definitions
Given a cumulative equity curve E_t, define the running peak as the cumulative maximum of the curve up to and including time t:
peak_t = running_max(E_0, E_1, ..., E_t)
The drawdown at time t is the percentage decline from that running peak:
drawdown_t = (E_t - peak_t) / peak_t
This value is zero whenever the equity curve is at a new high, and negative whenever it sits below its prior peak. Maximum drawdown is simply the most negative value this series ever takes:
max_drawdown = min(drawdown_t) for all t
Time underwater, sometimes called drawdown duration, is a separate concept: it is the number of consecutive periods spent below a prior peak, measured from the moment a new peak is broken until the equity curve makes a new all-time high again. A single "underwater period" starts right after a peak and ends the moment equity recovers past that peak. The length of the longest such period is the maximum time underwater — often more informative for risk budgeting than the depth of the drawdown itself.

Photo: Rafael Minguet Delgado (Pexels)
Building the equity curve and drawdown series
import numpy as np
import pandas as pd
np.random.seed(7)
# --- Step 1: build a synthetic daily returns series ---
n_days = 1500
dates = pd.bdate_range(start="2020-01-01", periods=n_days)
daily_returns = pd.Series(
np.random.normal(loc=0.0004, scale=0.011, size=n_days),
index=dates,
name="returns",
)
# --- Step 2: build the cumulative equity curve ---
# Starting capital of 1.0 (i.e., a normalized index).
equity_curve = (1 + daily_returns).cumprod()
equity_curve.name = "equity"
# --- Step 3: compute the running peak (cumulative maximum) ---
running_max = equity_curve.cummax()
# --- Step 4: compute the drawdown series ---
drawdown = (equity_curve - running_max) / running_max
drawdown.name = "drawdown"
# --- Step 5: extract the single maximum drawdown and its date ---
max_drawdown = drawdown.min()
max_drawdown_date = drawdown.idxmin()
print(f"Maximum drawdown: {max_drawdown:.2%}")
print(f"Occurred on: {max_drawdown_date.date()}")
# --- Step 6: segment underwater periods to compute time underwater ---
# A period is "underwater" whenever drawdown is strictly below zero.
is_underwater = drawdown < 0
# Every time the boolean flips from False to True, a new underwater
# episode begins. Cumulative sum of these flips gives each episode
# a unique integer id, while periods at a new high (False) get
# excluded from the grouping entirely.
period_id = (is_underwater != is_underwater.shift(1)).cumsum()
underwater_periods = (
pd.DataFrame({"underwater": is_underwater, "period_id": period_id})
.loc[is_underwater] # keep only rows that are actually underwater
.groupby("period_id")
.size()
)
max_time_underwater = underwater_periods.max()
longest_period_id = underwater_periods.idxmax()
# Recover the start and end dates of the longest underwater episode
longest_episode_dates = drawdown.index[period_id == longest_period_id]
episode_start = longest_episode_dates[0]
episode_end = longest_episode_dates[-1]
print(f"Longest time underwater: {max_time_underwater} trading days")
print(f"From {episode_start.date()} to {episode_end.date()}")
A subtlety worth flagging: this calculation treats every trading day equally, but drawdown statistics are sensitive to the return frequency you choose. A drawdown computed on daily closes will generally look larger and more frequent than one computed on weekly or monthly closes of the same underlying strategy, because intraday and day-to-day noise gets smoothed out at lower frequencies. When comparing drawdown statistics across strategies or across published track records, always confirm they were computed on the same sampling frequency, or the comparison is not meaningful.
The key idiom is in Step 6. is_underwater != is_underwater.shift(1) produces True at every point where the boolean state changes — the transition into a drawdown, and the transition back out of one. Taking the cumulative sum of these flips (.cumsum()) assigns a stable, unique integer label to each contiguous stretch, whether it is a peak stretch or an underwater stretch. Filtering with .loc[is_underwater] before grouping ensures the .groupby("period_id").size() call only measures the length of underwater episodes, not the peak stretches in between. This is a standard and correct pandas pattern for segmenting a boolean series into runs.
Plotting the equity curve and drawdown chart
The classic visualization pairs the equity curve on top with a shaded drawdown chart underneath, sharing the same x-axis so the two panels are easy to read together.
import matplotlib.pyplot as plt
fig, (ax_equity, ax_drawdown) = plt.subplots(
nrows=2, ncols=1, figsize=(12, 7), sharex=True,
gridspec_kw={"height_ratios": [2, 1]},
)
# Top panel: equity curve and its running peak
ax_equity.plot(equity_curve.index, equity_curve.values, color="steelblue", linewidth=1.2, label="Equity curve")
ax_equity.plot(running_max.index, running_max.values, color="gray", linewidth=0.8, linestyle="--", label="Running peak")
ax_equity.set_ylabel("Equity (normalized)")
ax_equity.set_title("Equity Curve and Drawdown")
ax_equity.legend(loc="upper left")
ax_equity.grid(alpha=0.3)
# Bottom panel: shaded underwater / drawdown chart
ax_drawdown.fill_between(drawdown.index, drawdown.values * 100, 0, color="firebrick", alpha=0.4)
ax_drawdown.plot(drawdown.index, drawdown.values * 100, color="firebrick", linewidth=0.8)
ax_drawdown.set_ylabel("Drawdown (%)")
ax_drawdown.set_xlabel("Date")
ax_drawdown.grid(alpha=0.3)
plt.tight_layout()
plt.savefig("equity_and_drawdown.png", dpi=150)
plt.show()
This two-panel layout makes both dimensions of drawdown risk visually explicit at once: the top panel shows how far equity has fallen from its peak in absolute terms, while the bottom panel's shaded width along the x-axis shows how long it stayed there.
Drawdown series of a sample equity curve
Handling edge cases in real data
Real return series rarely arrive as clean as the synthetic example above. A few practical adjustments are worth building into any production drawdown calculation:
- Missing data and non-trading days. If your returns series has gaps — a data feed outage, a delisting, a holiday not aligned across markets —
cummax()and the drawdown formula will still run without error, but the resulting drawdown duration can silently misrepresent the true underwater period. Reindexing to a consistent trading calendar before computing cumulative equity avoids this. - Multiple equally long underwater episodes.
.idxmax()on the grouped series returns the first episode it encounters when there is a tie. If your use case requires reporting all episodes above a duration threshold rather than just the single longest one, filter the groupedunderwater_periodsseries with a boolean condition (underwater_periods[underwater_periods >= threshold]) instead of taking a single maximum. - A drawdown still open at the end of the sample. If the equity curve's last observation is still below its all-time peak, the "current" underwater episode has no recorded end date yet. The code above correctly includes this trailing episode in the duration count, but when reporting results it is worth flagging explicitly that the drawdown has not technically recovered as of the last data point — treating it as resolved would understate the risk.
Why time underwater often matters more than depth
A drawdown figure in isolation is a snapshot; time underwater is a measure of duration, and duration is what most directly affects an allocator's or trader's ability to stay committed to a strategy. Consider two hypothetical scenarios: a strategy with a 10% maximum drawdown that takes three years to make a new high, versus a strategy with a sharper 25% drawdown that fully recovers within two months. On the headline drawdown number alone, the first strategy looks safer. In practice, a multi-year stretch of underwater equity is often harder to tolerate — it strains investor patience, complicates capital allocation decisions, and extends the period during which the strategy is generating no new profit even though it has not "failed" by any conventional definition.
This is also where survivorship and small-sample effects compound the problem: a drawdown or underwater duration measured on a short backtest window can understate what a live, multi-decade deployment might eventually produce, especially once tail events outside the sample are considered. Nassim Taleb's writing on fat-tailed risk is a useful reminder that historical drawdown statistics are a floor on what is possible, not a ceiling. Marcos López de Prado's "Advances in Financial Machine Learning" discusses drawdown and time-underwater statistics explicitly as tools for detecting strategies that are fragile in ways a Sharpe ratio alone would never reveal.
Because both drawdown depth and duration are sensitive to how a backtest was constructed — the length of the sample, the number of variants tested, and the possibility of overfitting to a particular historical window — it is worth validating these statistics with a dedicated audit rather than trusting a single backtest run. TrueVerdikt's statistical backtest-audit tool at /outils checks for exactly this kind of fragility, including insufficient sample sizes and inflated performance metrics from testing many parameter combinations. For a broader treatment of how repeated testing can distort any single performance statistic, including drawdown-based ones, see our article on the data-snooping ratio and Sharpe inflation. TrueTrueVerdikt's ebook on clean backtesting methodology also covers how to structure out-of-sample testing so that drawdown statistics remain representative of what a strategy might do going forward, rather than an artifact of curve-fitting to one historical path.
Disclaimer: This article is provided for educational and informational purposes only. It does not constitute financial, investment, or trading advice. Trading and investing involve substantial risk of loss. Past performance, backtested or live, is not indicative of future results. Always conduct your own research and consult a licensed financial advisor before making investment decisions.
From theory to practice
Measure and control a strategy's risk: drawdown, VaR, Sortino, Calmar and Omega ratios, position sizing and the Kelly criterion.
Analyse portfolio risk
