Performance·Metrics·Beginner

Drawdown Analysis

Compute max drawdown, drawdown duration, recovery time, and underwater curves to rigorously assess strategy risk.

drawdownriskunderwater

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

[1]
!pip install pandas numpy plotly
Requirement 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

[2]
import warnings
warnings.filterwarnings("ignore")

import pandas as pd
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots

4. 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

MetricDefinition
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 DrawdownThe mean depth of all recorded drawdown periods, indicating the typical magnitude of capital depreciation.
Max Drawdown DurationThe maximum number of time periods (bars) from a peak to the subsequent trough during the deepest drawdown.
Max Recovery DurationThe maximum number of time periods (bars) required for the equity curve to return from a trough to a new peak.
Underwater PeriodThe total cumulative number of time periods (bars) where the strategy's equity remains below a prior peak.
Calmar RatioA 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.

[3]
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.

[4]
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.

[5]
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()