Drawdown Analysis
Compute max drawdown, drawdown duration, recovery time, and underwater curves to rigorously assess strategy risk.
Performance Metrics — Drawdown Analysis
1. Overview
This notebook provides a comprehensive analysis of drawdown metrics, which are crucial for evaluating the risk and performance of trading strategies. It includes data generation, a custom drawdown calculation function, and a visualization of strategy versus market drawdown.
2. Dependency Installation
!pip install pandas numpy plotlyRequirement already satisfied: pandas in /usr/local/lib/python3.12/dist-packages (2.2.2) Requirement already satisfied: numpy in /usr/local/lib/python3.12/dist-packages (2.0.2) Requirement already satisfied: plotly in /usr/local/lib/python3.12/dist-packages (5.24.1) Requirement already satisfied: python-dateutil>=2.8.2 in /usr/local/lib/python3.12/dist-packages (from pandas) (2.9.0.post0) Requirement already satisfied: pytz>=2020.1 in /usr/local/lib/python3.12/dist-packages (from pandas) (2025.2) Requirement already satisfied: tzdata>=2022.7 in /usr/local/lib/python3.12/dist-packages (from pandas) (2026.1) Requirement already satisfied: tenacity>=6.2.0 in /usr/local/lib/python3.12/dist-packages (from plotly) (9.1.4) Requirement already satisfied: packaging in /usr/local/lib/python3.12/dist-packages (from plotly) (26.1) Requirement already satisfied: six>=1.5 in /usr/local/lib/python3.12/dist-packages (from python-dateutil>=2.8.2->pandas) (1.17.0)
3. Library Imports
import warnings
warnings.filterwarnings("ignore")
import pandas as pd
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots4. Drawdown Definition and Significance
A drawdown quantifies the decline of an equity curve from its most recent peak to its lowest subsequent trough before a new peak is established. This metric is critical for evaluating investment strategy risk.
4.1. Key Drawdown Metrics
| Metric | Definition |
|---|---|
| Drawdown (%) | The percentage decline from the most recent equity peak: (Current Equity − Peak Equity) / Peak Equity × 100. |
| Maximum Drawdown (MDD) | The largest peak-to-trough decline observed over the strategy's entire history, representing the worst-case loss. |
| Average Drawdown | The mean depth of all recorded drawdown periods, indicating the typical magnitude of capital depreciation. |
| Max Drawdown Duration | The maximum number of time periods (bars) from a peak to the subsequent trough during the deepest drawdown. |
| Max Recovery Duration | The maximum number of time periods (bars) required for the equity curve to return from a trough to a new peak. |
| Underwater Period | The total cumulative number of time periods (bars) where the strategy's equity remains below a prior peak. |
| Calmar Ratio | A risk-adjusted return metric: `Annualized Return / |
4.2. Importance of Drawdown Analysis
Drawdown analysis is paramount because it provides a realistic assessment of a strategy's viability and psychological impact. A strategy yielding a high annual return but suffering severe, prolonged drawdowns may be impractical, as investors often exit during periods of significant capital impairment, missing subsequent recoveries. Therefore, return must always be evaluated in conjunction with the associated drawdown risk.
5. Strategy Logic
The simulated trading strategy is based on a simple moving average crossover system applied to a generated price series.
5.1. Strategy Rules
- Entry Condition: A long position is initiated when the short-term Moving Average (MA) crosses above the long-term Moving Average.
- Exit Condition: The long position is closed when the short-term MA crosses below the long-term MA.
- Transaction Costs: A fixed transaction cost of 0.05% is applied to each trade (entry or exit).
5.2. Moving Average Calculation
- Fast Moving Average: Calculated over a 10-period window of the closing price.
- Slow Moving Average: Calculated over a 30-period window of the closing price.
This strategy aims to capture trends by following the direction indicated by the moving average crossovers.
6. Data Generation
This section details the generation of synthetic price data and the construction of an equity curve based on the defined trading strategy. A market equity curve is also generated for comparative analysis.
def generate_data(periods: int) -> pd.DataFrame:
"""
Generates synthetic financial data (OHLCV) for a specified number of periods.
Parameters
----------
periods : int
The number of time periods to generate data for.
Returns
-------
pd.DataFrame
A DataFrame containing 'open', 'high', 'low', 'close' prices,
'volume', and 'datetime' index.
"""
start_date = pd.to_datetime("2024-01-01 00:00:00+00:00")
datetime_index = pd.date_range(start_date, periods=periods, freq="1min", tz="UTC")
price_data = []; last_close = 42000
volatility_scale = 0.005; wick_scale = 0.002
for _ in range(periods):
open_price = last_close + np.random.normal(0, last_close * volatility_scale * 0.1)
close_price = open_price + np.random.normal(0, last_close * volatility_scale)
body_high = max(open_price, close_price)
body_low = min(open_price, close_price)
high_price = max(body_high + abs(np.random.normal(0, last_close * wick_scale)),
open_price, close_price)
low_price = min(body_low - abs(np.random.normal(0, last_close * wick_scale)),
open_price, close_price)
if high_price < low_price:
high_price, low_price = low_price, high_price
price_data.append({
"open": max(1, int(open_price)),
"high": max(1, int(high_price)),
"low": max(1, int(low_price)),
"close": max(1, int(close_price)),
})
last_close = close_price
df = pd.DataFrame(price_data, index=datetime_index)
df.index.name = "datetime"
df["volume"] = np.random.uniform(100.0, 500.0, periods)
df["datetime"] = df.index.to_series()
return df.reset_index(drop=True)
df = generate_data(500)
# --- Build equity curve ---
df["fast_ma"] = df["close"].rolling(10).mean()
df["slow_ma"] = df["close"].rolling(30).mean()
df["position"] = np.where(df["fast_ma"] > df["slow_ma"], 1, 0)
df["trade"] = df["position"].diff().abs()
df["strategy_return"] = df["position"].shift(1).fillna(0) * df["close"].pct_change() - df["trade"] * 0.0005
df["equity"] = 10_000 * (1 + df["strategy_return"]).cumprod()
df["market_equity"] = 10_000 * (1 + df["close"].pct_change()).cumprod()
df = df.dropna().reset_index(drop=True)
display(df[["datetime","close","equity"]].head(10))| datetime | close | equity | |
|---|---|---|---|
| 0 | 2024-01-01 00:29:00+00:00 | 42068 | 10000.000000 |
| 1 | 2024-01-01 00:30:00+00:00 | 42409 | 10000.000000 |
| 2 | 2024-01-01 00:31:00+00:00 | 42106 | 9995.000000 |
| 3 | 2024-01-01 00:32:00+00:00 | 42079 | 9988.590818 |
| 4 | 2024-01-01 00:33:00+00:00 | 42095 | 9992.388852 |
| 5 | 2024-01-01 00:34:00+00:00 | 42190 | 10014.939676 |
| 6 | 2024-01-01 00:35:00+00:00 | 42173 | 10010.904265 |
| 7 | 2024-01-01 00:36:00+00:00 | 42064 | 9985.030162 |
| 8 | 2024-01-01 00:37:00+00:00 | 42038 | 9978.858357 |
| 9 | 2024-01-01 00:38:00+00:00 | 41541 | 9860.881941 |
7. Drawdown Analysis Function
This section defines a function to compute various drawdown statistics from an equity curve and presents the results for the generated strategy equity.
def drawdown_analysis(
equity: pd.Series,
datetime: pd.Series = None,
) -> dict:
"""
Computes comprehensive drawdown statistics for an equity curve.
Parameters
----------
equity : pd.Series
Equity curve as a pandas Series.
datetime : pd.Series, optional
Corresponding datetime Series for period duration reporting.
Returns
-------
dict
A dictionary containing various drawdown metrics:
- `drawdown_series`: Per-bar drawdown percentage Series.
- `max_drawdown_pct`: Maximum drawdown as a percentage (negative).
- `avg_drawdown_pct`: Average drawdown depth across all drawdown periods.
- `max_dd_duration_bars`: Length of the deepest drawdown in bars.
- `underwater_bars`: Total bars spent below a prior equity peak.
- `calmar_ratio`: Annualized return divided by absolute max drawdown.
- `drawdown_periods`: DataFrame of individual drawdown episodes.
"""
peak = equity.cummax()
drawdown = (equity - peak) / peak * 100
max_dd = drawdown.min()
avg_dd = drawdown[drawdown < 0].mean() if (drawdown < 0).any() else 0.0
underwater= int((drawdown < 0).sum())
# Identify individual drawdown episodes
in_dd = drawdown < 0
episodes = []
start_idx = None
for i, val in enumerate(in_dd):
if val and start_idx is None:
start_idx = i
elif not val and start_idx is not None:
ep_dd = drawdown.iloc[start_idx:i].min()
ep_trough= drawdown.iloc[start_idx:i].idxmin()
episodes.append({
"start_bar": start_idx,
"end_bar": i,
"duration_bars":i - start_idx,
"max_dd_pct": round(ep_dd, 4),
"trough_bar": ep_trough,
})
start_idx = None
# Max drawdown duration
max_dd_dur = max([e["duration_bars"] for e in episodes], default=0)
# Calmar ratio (annualized return at 1-minute frequency)
ann_return = equity.pct_change().mean() * 525_600
calmar = ann_return / abs(max_dd / 100) if max_dd != 0 else np.nan
return {
"drawdown_series": drawdown,
"max_drawdown_pct": round(max_dd, 4),
"avg_drawdown_pct": round(avg_dd, 4),
"max_dd_duration_bars": max_dd_dur,
"underwater_bars": underwater,
"calmar_ratio": round(calmar, 4) if not np.isnan(calmar) else None,
"drawdown_periods": pd.DataFrame(episodes),
}
dd_result = drawdown_analysis(df["equity"], df["datetime"])
print("--- Drawdown Statistics ---")
print(f" Max Drawdown : {dd_result['max_drawdown_pct']:.4f}%")
print(f" Avg Drawdown : {dd_result['avg_drawdown_pct']:.4f}%")
print(f" Max DD Duration : {dd_result['max_dd_duration_bars']} bars")
print(f" Bars Underwater : {dd_result['underwater_bars']}")
print(f" Calmar Ratio : {dd_result['calmar_ratio']}")
if len(dd_result["drawdown_periods"]) > 0:
print("\n--- Top 5 Drawdown Episodes ---")
display(
dd_result["drawdown_periods"]
.sort_values("max_dd_pct")
.head(5)
.reset_index(drop=True)
)--- Drawdown Statistics --- Max Drawdown : -4.0128% Avg Drawdown : -1.6129% Max DD Duration : 107 bars Bars Underwater : 415 Calmar Ratio : 4818.5095 --- Top 5 Drawdown Episodes ---
| start_bar | end_bar | duration_bars | max_dd_pct | trough_bar | |
|---|---|---|---|---|---|
| 0 | 160 | 267 | 107 | -4.0128 | 198 |
| 1 | 284 | 338 | 54 | -2.7839 | 331 |
| 2 | 6 | 75 | 69 | -2.7000 | 54 |
| 3 | 349 | 452 | 103 | -2.5643 | 446 |
| 4 | 111 | 125 | 14 | -2.2507 | 112 |
7.1. Function Component Explanation
The drawdown_analysis function computes various drawdown metrics. Key components are described below:
peak = equity.cummax(): Calculates the cumulative maximum of the equity curve. At any point, this represents the highest equity value attained up to and including the current period.drawdown = (equity − peak) / peak × 100: Determines the percentage decline from the most recent peak. This value is always less than or equal to zero, equaling zero when a new equity peak is achieved.- Episode Detection: The internal logic iteratively identifies distinct drawdown episodes. A new episode begins when the equity curve falls below its previous peak and concludes when a new peak is established. Each episode's start, end, duration, and maximum depth are recorded, facilitating granular analysis.
underwater_bars: Represents the total number of time periods (bars) during which the strategy's equity remains below a prior peak. This metric quantifies the recovery efficiency; strategies that recover swiftly exhibit a lower cumulative underwater period.
8. Visualization of Drawdown
This section presents a visualization comparing the equity curve and drawdown profiles of the simulated strategy against a simple buy-and-hold market approach. This allows for direct comparison of performance and risk characteristics.
fig = make_subplots(
rows=3, cols=1, shared_xaxes=True,
subplot_titles=[
"Equity Curve — Strategy vs Buy-and-Hold",
"Drawdown (%) — Strategy",
"Drawdown (%) — Buy-and-Hold",
],
row_heights=[0.45, 0.3, 0.25],
)
# --- Equity curves ---
fig.add_trace(go.Scatter(
x=df["datetime"], y=df["equity"],
mode="lines", name="Strategy Equity",
line=dict(color="green", width=2)), row=1, col=1)
fig.add_trace(go.Scatter(
x=df["datetime"], y=df["market_equity"],
mode="lines", name="Buy-and-Hold",
line=dict(color="gray", width=1.5, dash="dash")), row=1, col=1)
# --- Strategy drawdown ---
fig.add_trace(go.Scatter(
x=df["datetime"], y=dd_result["drawdown_series"],
mode="lines", name="Strategy DD (%)",
fill="tozeroy",
line=dict(color="red", width=1)), row=2, col=1)
# --- Market drawdown ---
mkt_dd = drawdown_analysis(df["market_equity"])
fig.add_trace(go.Scatter(
x=df["datetime"], y=mkt_dd["drawdown_series"],
mode="lines", name="Market DD (%)",
fill="tozeroy",
line=dict(color="orange", width=1)), row=3, col=1)
# Max drawdown annotation
max_dd_idx = dd_result["drawdown_series"].idxmin()
max_dd_datetime = df["datetime"].iloc[max_dd_idx]
# Add vertical line for max drawdown
fig.add_shape(
type="line",
x0=max_dd_datetime, y0=0, x1=max_dd_datetime, y1=1,
yref='paper',
line=dict(dash="dash", color="darkred"),
row=2, col=1,
)
# Add annotation text for max drawdown
fig.add_annotation(
x=max_dd_datetime,
y=0.9, # Position the text near the top of the subplot
yref='paper',
text=f"MDD {dd_result['max_drawdown_pct']:.2f}%",
showarrow=True,
arrowhead=1,
row=2, col=1,
bgcolor="rgba(255, 255, 255, 0.7)", # Add a background for readability
bordercolor="darkred",
borderwidth=1,
font=dict(color="darkred"),
)
fig.update_layout(
title_text="Drawdown Analysis — Strategy vs Buy-and-Hold",
xaxis_rangeslider_visible=False,
height=850,
xaxis3_title="Datetime",
yaxis_title="Portfolio Value ($)",
yaxis2_title="Drawdown (%)",
yaxis3_title="Drawdown (%)",
)
fig.show()