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)
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
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
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"])
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
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, choose a historical window and daily bars in the SiftingIO market data export tool. Sign in to use a historical range with your API key; the guest preview covers the latest 24 hours and is not a long-term risk dataset. Save the CSV as aapl-daily.csv.
SiftingIO's CSV uses Time (UTC) and Close as column headers. This loader reads those actual fields:
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"])
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
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()
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
| 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.
Start with a clearly defined price series#
A useful drawdown report has more than a percentage. Include the data window, observation frequency, price basis, deepest episode's dates and whether recovery was observed.
SiftingIO's US stock data provides real-time prices and historical data, with 25+ years of coverage where available by instrument and dataset. The platform also covers forex, commodities and crypto. Choose the series appropriate to your analysis, export it or use the historical stock bars API, and keep the input assumptions with your result.



