DEV Community

Alper San
Alper San

Posted on Originally published at sifting.io

Maximum Drawdown in Python: Calculate Loss and Recovery Time

A backtest finishes 26% ahead. In the middle, its value falls from 120 to 90. The final return looks comfortable; the path includes a 25% loss from a previous high.

Maximum drawdown is the largest percentage decline from an earlier peak to a later trough within the data you measure. In Python, a running maximum gets you most of the way there. A useful report also needs the peak date, trough date and recovery status.

This guide calculates maximum drawdown with pandas, handles an unrecovered loss, and plots an underwater chart. You can run the worked example without an API key, then use the same calculation with a historical market data CSV.

Maximum drawdown formula

For each observation, keep the highest value seen so far:

running peak[t] = maximum value from the start through t
drawdown[t] = value[t] / running peak[t] - 1
maximum drawdown = -minimum(drawdown)
Enter fullscreen mode Exit fullscreen mode

The drawdown series is zero at a running high and negative below it. We report maximum drawdown as a positive loss magnitude: a drawdown of -0.25 becomes a maximum drawdown of 25%. Other software may return a negative number, so check the convention before combining reports.

Order matters. Comparing the highest and lowest values anywhere in a file can put the low before the high. A drawdown requires the peak to happen first. A running maximum preserves that order.

A worked example: loss and recovery are different measurements

These are illustrative values, not historical prices for a stock or fund.

Date in 2026 Value Running peak Drawdown
October 1 100 100 0%
October 2 120 120 0%
October 5 90 120 -25%
October 6 108 120 -10%
October 7 120 120 0%
October 8 126 126 0%

The largest decline runs from 120 on October 2 to 90 on October 5. The series regains that peak on October 7. Its final value of 126 is 26% above its starting value, but that return does not describe the loss along the way.

The full peak-to-recovery period lasts five calendar days. There are three observation intervals between those dates. Calling both numbers “days underwater” would give the same event two different durations.

Calculate maximum drawdown in Python with pandas

Install pandas in your project environment:

python -m pip install pandas
Enter fullscreen mode Exit fullscreen mode

The function below accepts one positive value series with a unique, chronological DatetimeIndex. It rejects missing or invalid observations instead of quietly filling them. That matters because pandas' cummax() normally skips missing values.

import math

import pandas as pd


def drawdown_report(values):
    """Report the first deepest episode in one positive, dated value series."""
    if not isinstance(values.index, pd.DatetimeIndex):
        raise ValueError("Use a DatetimeIndex.")
    if values.index.hasnans or not values.index.is_unique:
        raise ValueError("Timestamps must be valid and unique.")
    if not values.index.is_monotonic_increasing:
        raise ValueError("Put observations in chronological order.")

    values = pd.to_numeric(values, errors="raise").astype(float)
    if len(values) < 2 or not values.map(math.isfinite).all():
        raise ValueError("Provide at least two finite observations.")
    if (values <= 0).any():
        raise ValueError("This calculation requires positive values.")

    running_peak = values.cummax()
    drawdown = values / running_peak - 1.0
    report = {
        "maximum_drawdown_pct": -100 * float(drawdown.min()),
        "current_drawdown_pct": 100 * float(drawdown.iloc[-1]),
        "peak": None,
        "trough": None,
        "recovery": None,
        "peak_to_trough": None,
        "trough_to_recovery": None,
        "peak_to_recovery": None,
        "elapsed_since_peak_at_end": None,
        "peak_to_recovery_intervals": None,
    }
    if drawdown.min() == 0:
        return report, drawdown

    trough = drawdown.idxmin()  # First trough when depths tie.
    peak_value = running_peak.loc[trough]
    before = values.loc[:trough]
    peak = before[before == peak_value].index[-1]
    after = values.iloc[values.index.get_loc(trough) + 1:]
    recovered = after[after >= peak_value]
    recovery = recovered.index[0] if not recovered.empty else None

    report.update(peak=peak, trough=trough, recovery=recovery)
    report["peak_to_trough"] = trough - peak
    if recovery is None:
        report["elapsed_since_peak_at_end"] = values.index[-1] - peak
    else:
        report["trough_to_recovery"] = recovery - trough
        report["peak_to_recovery"] = recovery - peak
        report["peak_to_recovery_intervals"] = (
            values.index.get_loc(recovery) - values.index.get_loc(peak)
        )
    return report, drawdown
Enter fullscreen mode Exit fullscreen mode

Run the example:

values = pd.Series(
    [100, 120, 90, 108, 120, 126],
    index=pd.to_datetime([
        "2026-10-01", "2026-10-02", "2026-10-05",
        "2026-10-06", "2026-10-07", "2026-10-08",
    ], utc=True),
)

report, drawdown = drawdown_report(values)
print(f"Maximum drawdown: {report['maximum_drawdown_pct']:.2f}%")
print("Peak:", report["peak"].date())
print("Trough:", report["trough"].date())
print("Recovery:", report["recovery"].date())
print("Peak to recovery:", report["peak_to_recovery"])
Enter fullscreen mode Exit fullscreen mode

The result is a 25.00% maximum drawdown, with an October 2 peak, October 5 trough and October 7 recovery. The final line prints 5 days 00:00:00.

The function uses the first deepest trough when several observations tie. This follows pandas' idxmin() behavior. It then uses the last occurrence of the relevant peak before that trough. If the series reaches 120 twice before falling, the later 120 is the start of that loss episode. Recovery is the first later observation that meets or exceeds the old peak.

Those conventions make the dates reproducible. They do not claim to identify the longest underwater episode, which may be a different event from the deepest loss.

Report an unrecovered drawdown without inventing a date

Stop the example at 108, before the series regains 120:

unfinished = values.iloc[:4]
open_report, _ = drawdown_report(unfinished)

print(open_report["maximum_drawdown_pct"])       # 25.0
print(round(open_report["current_drawdown_pct"], 2))  # -10.0
print(open_report["recovery"])                   # None
print(open_report["elapsed_since_peak_at_end"])  # 4 days
Enter fullscreen mode Exit fullscreen mode

The worst observed decline is still 25%. At the end of this shorter file, the value is 10% below its peak. Recovery is unobserved within this dataset. It is not zero days, and the calculation cannot supply a future recovery date.

A report card can say:

Field Result for the shortened example
Maximum drawdown 25.00%
Current drawdown -10.00%
Peak date October 2, 2026
Trough date October 5, 2026
Recovery Not recovered by the final observation
Elapsed since peak at the end 4 calendar days

If the worst episode recovered but the series later fell again, the current drawdown describes that later decline. It should not inherit the earlier episode's recovery date. Keep “maximum drawdown” and “current drawdown” as separate fields.

Use historical stock data from a CSV export

For an AAPL analysis, use a historical CSV with one observation per daily bar. Save your input as aapl-daily.csv. Verify the date range, currency and adjustment policy before calculating the result.

This example expects Time (UTC) and Close column headers. Rename your own columns explicitly if their names differ:

frame = pd.read_csv("aapl-daily.csv")

prices = pd.Series(
    pd.to_numeric(frame["Close"], errors="raise").to_numpy(),
    index=pd.to_datetime(frame["Time (UTC)"], utc=True, errors="raise"),
    name="AAPL close",
)

stock_report, stock_drawdown = drawdown_report(prices)
print(f"Maximum drawdown: {stock_report['maximum_drawdown_pct']:.2f}%")
print("Recovery:", stock_report["recovery"])
Enter fullscreen mode Exit fullscreen mode

The CSV supplies prices; the script calculates the statistic locally. It makes no extra API requests. Keep the symbol, date range, bar interval, quote currency and price basis alongside the result, since those choices are not all encoded in the price column itself.

For a portfolio or strategy, supply a suitable equity or cumulative wealth series instead. Deposits and withdrawals can move an account balance without representing investment performance. A cash-flow-adjusted performance series avoids treating a deposit as a gain or a withdrawal as a market loss.

Plot an underwater chart

An underwater chart shows the distance below the running peak. Its zero line means the series is at a high within the selected window. A deeper negative value means a larger decline from that high.

python -m pip install matplotlib
Enter fullscreen mode Exit fullscreen mode
import matplotlib.pyplot as plt

ax = (100 * drawdown).plot(figsize=(9, 3), color="#9c3948")
ax.fill_between(drawdown.index, 100 * drawdown, 0,
                color="#9c3948", alpha=0.15)
ax.axhline(0, color="gray", linewidth=0.8)
ax.set(title="Drawdown from the running peak", ylabel="Drawdown (%)")
ax.set_xlabel("Observation timestamp (UTC)")
plt.tight_layout()
plt.show()
Enter fullscreen mode Exit fullscreen mode

Replace drawdown with stock_drawdown to plot the CSV result. Avoid presenting the illustrative series as an asset's actual performance.

Why two maximum drawdown results can disagree

The formula is short. The data choices around it deserve just as much attention.

Choice What it changes
Analysis window An earlier peak outside the file is invisible to the calculation.
Daily closes versus intraday observations Daily closes can miss a deeper loss inside a session.
Price series versus total-return series Distributions and corporate-action treatment affect the path.
Missing or duplicate observations A missing trough or repeated timestamp can distort the result.
Portfolio cash flows Raw balances can mix external transfers with investment performance.

A one-year maximum drawdown is measured within that year. If you need a peak from before the reporting start date, include the earlier data and state how you separate the peak-estimation period from the reporting period. Do not label a short sample as an all-time drawdown.

Daily OHLCV closes produce a closing-observation drawdown. They do not recover the sequence of every high and low inside each candle. Comparing every bar's high to its low can also assume an order that OHLC data does not reveal. Our guide to ambiguous OHLC backtests explains that limitation.

State the adjustment policy. Unadjusted stock prices, split-adjusted prices and a total-return series answer different questions. The adjusted versus unadjusted prices guide covers the difference. This Python function does not adjust prices or reinvest distributions for you.

Keep timestamps honest. Bar timestamps may label the start of a period rather than the instant of the closing trade. Retain the provider's convention when naming peak and trough observations. Count calendar duration from timestamps and observation intervals from positions; do not silently substitute one for the other.

How much gain is needed to recover a drawdown?

A 25% loss requires a 33.33% gain from the trough to regain the old peak. The gain is measured from the smaller value:

required recovery gain = 1 / (1 - loss fraction) - 1
Enter fullscreen mode Exit fullscreen mode
Loss from the peak Gain needed from the trough
10% 11.11%
20% 25.00%
25% 33.33%
50% 100.00%

This is arithmetic, not a forecast of when recovery will happen. Maximum drawdown describes the path in the observed sample; it does not cap a future loss.

A useful drawdown report includes the data window, observation frequency, price basis, deepest episode dates and whether recovery was observed. Keep those assumptions beside the percentage.

Top comments (0)