Skip to content

drawdowns

All functions here take a price series (or any cumulative-value series) - not a returns series. Use Portfolio.price_index() or portpy.metrics.returns.prices_from_returns if you only have returns.

drawdowns

Drawdown analysis: depth, duration, recovery, and tail risk of peak-to-trough declines.

All functions here take a price series (or any cumulative-value series, e.g. a synthetic index built from (1 + returns).cumprod()) - not a returns series.

drawdown_series

drawdown_series(prices: Series) -> pd.Series

Percentage decline from the running peak at every point in time.

Parameters:

Name Type Description Default
prices Series

Price (or cumulative-value) series, e.g. (1 + returns).cumprod().

required

Returns:

Type Description
Series

A Series of the same index as prices, always <= 0.

Source code in src/portpy/metrics/drawdowns.py
def drawdown_series(prices: pd.Series) -> pd.Series:
    """
    Percentage decline from the running peak at every point in time.

    Args:
        prices: Price (or cumulative-value) series, e.g. (1 + returns).cumprod().

    Returns:
        A Series of the same index as `prices`, always <= 0.
    """
    p = prices.dropna()
    running_max = p.cummax()
    dd = p / running_max - 1.0
    return dd.rename("drawdown")

max_drawdown

max_drawdown(
    prices: Series, as_result: bool = False
) -> float | MetricResult

The single deepest peak-to-trough decline.

Parameters:

Name Type Description Default
prices Series

Price (or cumulative-value) series.

required
as_result bool

If True, return a MetricResult instead of a plain float.

False

Returns:

Type Description
float | MetricResult

A negative number (or 0.0 if the series never declined), or a

float | MetricResult

MetricResult wrapping it.

Source code in src/portpy/metrics/drawdowns.py
def max_drawdown(prices: pd.Series, as_result: bool = False) -> float | MetricResult:
    """
    The single deepest peak-to-trough decline.

    Args:
        prices: Price (or cumulative-value) series.
        as_result: If True, return a MetricResult instead of a plain float.

    Returns:
        A negative number (or 0.0 if the series never declined), or a
        MetricResult wrapping it.
    """
    p = prices.dropna()
    ensure_min_observations(p, 1, "prices")
    value = float(drawdown_series(p).min())
    return MetricResult(value, "max_drawdown", unit="%") if as_result else value

drawdown_duration

drawdown_duration(prices: Series) -> pd.Series

At every point in time, how many periods have passed since the last new high.

Parameters:

Name Type Description Default
prices Series

Price (or cumulative-value) series.

required

Returns:

Type Description
Series

A Series of period counts since the last peak, same index as prices.

Source code in src/portpy/metrics/drawdowns.py
def drawdown_duration(prices: pd.Series) -> pd.Series:
    """
    At every point in time, how many periods have passed since the last new high.

    Args:
        prices: Price (or cumulative-value) series.

    Returns:
        A Series of period counts since the last peak, same index as `prices`.
    """
    p = prices.dropna()
    n = len(p)
    positions = np.arange(n)
    running_max = p.cummax()
    at_peak = p.to_numpy() >= running_max.to_numpy()
    last_peak_pos = np.where(at_peak, positions, -1)
    last_peak_pos = np.maximum.accumulate(last_peak_pos)
    duration = positions - last_peak_pos
    return pd.Series(duration, index=p.index, name="drawdown_duration")

time_to_recovery

time_to_recovery(
    prices: Series, as_result: bool = False
) -> float | MetricResult | None

Periods it took to recover from the single worst drawdown.

Parameters:

Name Type Description Default
prices Series

Price (or cumulative-value) series.

required
as_result bool

If True, return a MetricResult instead of a plain float.

False

Returns:

Type Description
float | MetricResult | None

The number of periods to recovery, or None if the worst drawdown

float | MetricResult | None

hasn't recovered by the end of the series.

Source code in src/portpy/metrics/drawdowns.py
def time_to_recovery(prices: pd.Series, as_result: bool = False) -> float | MetricResult | None:
    """
    Periods it took to recover from the single worst drawdown.

    Args:
        prices: Price (or cumulative-value) series.
        as_result: If True, return a MetricResult instead of a plain float.

    Returns:
        The number of periods to recovery, or None if the worst drawdown
        hasn't recovered by the end of the series.
    """
    p = prices.dropna()
    episodes = _drawdown_episodes(p)
    if episodes.empty:
        return None
    worst = episodes.sort_values("depth").iloc[0]
    if not worst["recovered"]:
        return None
    value = float(worst["duration_to_recovery"])
    return MetricResult(value, "time_to_recovery") if as_result else value

top_n_drawdowns

top_n_drawdowns(prices: Series, n: int = 5) -> pd.DataFrame

The n deepest drawdown episodes, sorted worst-first.

Parameters:

Name Type Description Default
prices Series

Price (or cumulative-value) series.

required
n int

Maximum number of episodes to return.

5

Returns:

Type Description
DataFrame

A DataFrame with columns start, trough, end, depth,

DataFrame

duration_to_trough, duration_to_recovery, recovered (see

DataFrame

_drawdown_episodes); empty if there were no episodes.

Source code in src/portpy/metrics/drawdowns.py
def top_n_drawdowns(prices: pd.Series, n: int = 5) -> pd.DataFrame:
    """
    The n deepest drawdown episodes, sorted worst-first.

    Args:
        prices: Price (or cumulative-value) series.
        n: Maximum number of episodes to return.

    Returns:
        A DataFrame with columns start, trough, end, depth,
        duration_to_trough, duration_to_recovery, recovered (see
        `_drawdown_episodes`); empty if there were no episodes.
    """
    p = prices.dropna()
    ensure_min_observations(p, 2, "prices")
    episodes = _drawdown_episodes(p)
    if episodes.empty:
        return episodes
    return episodes.sort_values("depth").head(n).reset_index(drop=True)

average_drawdown

average_drawdown(
    prices: Series, as_result: bool = False
) -> float | MetricResult

Mean depth across all distinct drawdown episodes (not a continuous time-average - see pain_index for that).

Parameters:

Name Type Description Default
prices Series

Price (or cumulative-value) series.

required
as_result bool

If True, return a MetricResult instead of a plain float.

False

Returns:

Type Description
float | MetricResult

The average episode depth (0.0 if there were no episodes).

Source code in src/portpy/metrics/drawdowns.py
def average_drawdown(prices: pd.Series, as_result: bool = False) -> float | MetricResult:
    """
    Mean depth across all distinct drawdown episodes (not a continuous
    time-average - see pain_index for that).

    Args:
        prices: Price (or cumulative-value) series.
        as_result: If True, return a MetricResult instead of a plain float.

    Returns:
        The average episode depth (0.0 if there were no episodes).
    """
    p = prices.dropna()
    episodes = _drawdown_episodes(p)
    value = float(episodes["depth"].mean()) if not episodes.empty else 0.0
    return MetricResult(value, "average_drawdown", unit="%") if as_result else value

drawdown_at_risk

drawdown_at_risk(
    prices: Series,
    confidence: float = DEFAULT_CONFIDENCE_LEVEL,
    as_result: bool = False,
) -> float | MetricResult

Value-at-Risk applied to the drawdown series (DaR): the drawdown level breached only (1 - confidence) of the time. See conditional_drawdown_at_risk for the mean-of-tail analogue (CDaR).

Parameters:

Name Type Description Default
prices Series

Price (or cumulative-value) series.

required
confidence float

Confidence level in (0, 1), e.g. 0.95 for 95%.

DEFAULT_CONFIDENCE_LEVEL
as_result bool

If True, return a MetricResult instead of a plain float.

False

Returns:

Type Description
float | MetricResult

The drawdown percentile at confidence (a negative number, or 0.0).

Source code in src/portpy/metrics/drawdowns.py
def drawdown_at_risk(
    prices: pd.Series,
    confidence: float = DEFAULT_CONFIDENCE_LEVEL,
    as_result: bool = False,
) -> float | MetricResult:
    """
    Value-at-Risk applied to the drawdown series (DaR): the drawdown level
    breached only (1 - confidence) of the time. See
    conditional_drawdown_at_risk for the mean-of-tail analogue (CDaR).

    Args:
        prices: Price (or cumulative-value) series.
        confidence: Confidence level in (0, 1), e.g. 0.95 for 95%.
        as_result: If True, return a MetricResult instead of a plain float.

    Returns:
        The drawdown percentile at `confidence` (a negative number, or 0.0).
    """
    validate_confidence(confidence)
    p = prices.dropna()
    ensure_min_observations(p, 2, "prices")
    dd = drawdown_series(p)
    value = float(np.percentile(dd, 100 * (1.0 - confidence)))
    return MetricResult(value, "drawdown_at_risk", unit="%") if as_result else value