Skip to content

performance

Functions for evaluating risk-adjusted portfolio performance. These metrics measure how efficiently a portfolio converts risk into return using different definitions of risk, including total volatility, downside risk, drawdowns, and benchmark-relative performance.

performance

Risk-adjusted performance ratios.

As in the rest of PortPy, rf/mar/threshold parameters are annual rates, de-annualized internally via geometric compounding.

sharpe_ratio

sharpe_ratio(
    returns: Series,
    rf: float = DEFAULT_RISK_FREE_RATE,
    annualized: bool = True,
    periods_per_year: int = TRADING_DAYS_PER_YEAR,
    as_result: bool = False,
) -> float | MetricResult

Mean excess return over its own standard deviation - the classic risk-adjusted return measure.

Parameters:

Name Type Description Default
returns Series

Periodic return series.

required
rf float

Annual risk-free rate, de-annualized internally to a constant per-period rate.

DEFAULT_RISK_FREE_RATE
annualized bool

If True, scale the result by sqrt(periods_per_year).

True
periods_per_year int

Number of periods per year, used for annualization.

TRADING_DAYS_PER_YEAR
as_result bool

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

False

Returns:

Type Description
float | MetricResult

The Sharpe ratio, or a MetricResult wrapping it.

Source code in src/portpy/metrics/performance.py
def sharpe_ratio(
    returns: pd.Series,
    rf: float = DEFAULT_RISK_FREE_RATE,
    annualized: bool = True,
    periods_per_year: int = TRADING_DAYS_PER_YEAR,
    as_result: bool = False,
) -> float | MetricResult:
    """
    Mean excess return over its own standard deviation - the classic
    risk-adjusted return measure.

    Args:
        returns: Periodic return series.
        rf: Annual risk-free rate, de-annualized internally to a constant
            per-period rate.
        annualized: If True, scale the result by sqrt(periods_per_year).
        periods_per_year: Number of periods per year, used for annualization.
        as_result: If True, return a MetricResult instead of a plain float.

    Returns:
        The Sharpe ratio, or a MetricResult wrapping it.
    """

    r = returns.dropna()
    ensure_min_observations(r, 2, "returns")
    rf_period = periodic_rate_from_annual(rf, periods_per_year)
    excess = r - rf_period
    value = safe_divide(float(excess.mean()), float(excess.std(ddof=1)))
    if annualized:
        value *= np.sqrt(periods_per_year)
    return MetricResult(value, "sharpe_ratio") if as_result else value

sortino_ratio

sortino_ratio(
    returns: Series,
    mar: float = DEFAULT_MAR,
    annualized: bool = True,
    periods_per_year: int = TRADING_DAYS_PER_YEAR,
    as_result: bool = False,
) -> float | MetricResult

Like Sharpe, but penalizes only downside deviation below mar, not total volatility.

Parameters:

Name Type Description Default
returns Series

Periodic return series.

required
mar float

Annual minimum acceptable return, de-annualized internally.

DEFAULT_MAR
annualized bool

If True, scale the result by sqrt(periods_per_year).

True
periods_per_year int

Number of periods per year, used for annualization.

TRADING_DAYS_PER_YEAR
as_result bool

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

False

Returns:

Type Description
float | MetricResult

The Sortino ratio, or a MetricResult wrapping it.

Source code in src/portpy/metrics/performance.py
def sortino_ratio(
    returns: pd.Series,
    mar: float = DEFAULT_MAR,
    annualized: bool = True,
    periods_per_year: int = TRADING_DAYS_PER_YEAR,
    as_result: bool = False,
) -> float | MetricResult:
    """
    Like Sharpe, but penalizes only downside deviation below `mar`, not
    total volatility.

    Args:
        returns: Periodic return series.
        mar: Annual minimum acceptable return, de-annualized internally.
        annualized: If True, scale the result by sqrt(periods_per_year).
        periods_per_year: Number of periods per year, used for annualization.
        as_result: If True, return a MetricResult instead of a plain float.

    Returns:
        The Sortino ratio, or a MetricResult wrapping it.
    """

    r = returns.dropna()
    ensure_min_observations(r, 2, "returns")
    mar_period = periodic_rate_from_annual(mar, periods_per_year)
    dd_period = downside_deviation(r, mar=mar, annualized=False, periods_per_year=periods_per_year)
    value = safe_divide(float((r - mar_period).mean()), float(dd_period))
    if annualized:
        value *= np.sqrt(periods_per_year)
    return MetricResult(value, "sortino_ratio") if as_result else value

calmar_ratio

calmar_ratio(
    returns: Series,
    periods_per_year: int = TRADING_DAYS_PER_YEAR,
    as_result: bool = False,
) -> float | MetricResult

Annualized return divided by the absolute worst drawdown - reward per unit of worst-case pain.

Parameters:

Name Type Description Default
returns Series

Periodic return series.

required
periods_per_year int

Number of periods per year, used for annualization.

TRADING_DAYS_PER_YEAR
as_result bool

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

False

Returns:

Type Description
float | MetricResult

The Calmar ratio, or NaN if the series had no drawdown.

Source code in src/portpy/metrics/performance.py
def calmar_ratio(
    returns: pd.Series,
    periods_per_year: int = TRADING_DAYS_PER_YEAR,
    as_result: bool = False,
) -> float | MetricResult:
    """
    Annualized return divided by the absolute worst drawdown - reward per
    unit of worst-case pain.

    Args:
        returns: Periodic return series.
        periods_per_year: Number of periods per year, used for annualization.
        as_result: If True, return a MetricResult instead of a plain float.

    Returns:
        The Calmar ratio, or NaN if the series had no drawdown.
    """

    r = returns.dropna()
    ensure_min_observations(r, 2, "returns")
    ann_ret = annualized_return(r, periods_per_year=periods_per_year, geometric=True)
    synthetic_prices = prices_from_returns(r)
    mdd = max_drawdown(synthetic_prices)
    value = ann_ret / abs(mdd) if mdd < 0 else np.nan
    return MetricResult(value, "calmar_ratio") if as_result else value

omega_ratio

omega_ratio(
    returns: Series,
    threshold: float = 0.0,
    periods_per_year: int = TRADING_DAYS_PER_YEAR,
    as_result: bool = False,
) -> float | MetricResult

Ratio of the probability-weighted sum of gains to losses above/below a threshold return.

Parameters:

Name Type Description Default
returns Series

Periodic return series.

required
threshold float

Annual required return; must be > -1.

0.0
periods_per_year int

Number of periods per year, used to de-annualize threshold.

TRADING_DAYS_PER_YEAR
as_result bool

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

False

Returns:

Type Description
float | MetricResult

The Omega ratio, or NaN if there were no losses below the threshold.

Raises:

Type Description
ValueError

If threshold is <= -1.

Source code in src/portpy/metrics/performance.py
def omega_ratio(
    returns: pd.Series,
    threshold: float = 0.0,
    periods_per_year: int = TRADING_DAYS_PER_YEAR,
    as_result: bool = False,
) -> float | MetricResult:
    """
    Ratio of the probability-weighted sum of gains to losses above/below a
    threshold return.

    Args:
        returns: Periodic return series.
        threshold: Annual required return; must be > -1.
        periods_per_year: Number of periods per year, used to de-annualize
            `threshold`.
        as_result: If True, return a MetricResult instead of a plain float.

    Returns:
        The Omega ratio, or NaN if there were no losses below the threshold.

    Raises:
        ValueError: If `threshold` is <= -1.
    """

    r = returns.dropna()
    ensure_min_observations(r, 2, "returns")
    if threshold <= -1:
        raise ValueError("threshold must be > -1 (it represents an annual required return).")
    thresh_period = periodic_rate_from_annual(threshold, periods_per_year)
    diff = r - thresh_period
    numer = float(diff[diff > 0].sum())
    denom = float(-diff[diff < 0].sum())
    value = numer / denom if denom > 0 else np.nan
    return MetricResult(value, "omega_ratio") if as_result else value

information_ratio

information_ratio(
    returns: Series,
    benchmark: Series,
    annualized: bool = True,
    periods_per_year: int = TRADING_DAYS_PER_YEAR,
    as_result: bool = False,
) -> float | MetricResult

Active return over tracking error - how consistently a strategy beats its benchmark.

Parameters:

Name Type Description Default
returns Series

Periodic return series.

required
benchmark Series

Periodic benchmark return series, aligned to returns.

required
annualized bool

If True, scale the result by sqrt(periods_per_year).

True
periods_per_year int

Number of periods per year, used for annualization.

TRADING_DAYS_PER_YEAR
as_result bool

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

False

Returns:

Type Description
float | MetricResult

The information ratio, or a MetricResult wrapping it.

Source code in src/portpy/metrics/performance.py
def information_ratio(
    returns: pd.Series,
    benchmark: pd.Series,
    annualized: bool = True,
    periods_per_year: int = TRADING_DAYS_PER_YEAR,
    as_result: bool = False,
) -> float | MetricResult:
    """
    Active return over tracking error - how consistently a strategy beats
    its benchmark.

    Args:
        returns: Periodic return series.
        benchmark: Periodic benchmark return series, aligned to `returns`.
        annualized: If True, scale the result by sqrt(periods_per_year).
        periods_per_year: Number of periods per year, used for annualization.
        as_result: If True, return a MetricResult instead of a plain float.

    Returns:
        The information ratio, or a MetricResult wrapping it.
    """

    r, b = align_pair(returns, benchmark)
    ensure_min_observations(r, 2, "returns")
    diff = r - b
    value = safe_divide(float(diff.mean()), float(diff.std(ddof=1)))
    if annualized:
        value *= np.sqrt(periods_per_year)
    return MetricResult(value, "information_ratio") if as_result else value

treynor_ratio

treynor_ratio(
    returns: Series,
    beta: float,
    rf: float = DEFAULT_RISK_FREE_RATE,
    annualized: bool = True,
    periods_per_year: int = TRADING_DAYS_PER_YEAR,
    as_result: bool = False,
) -> float | MetricResult

Excess return per unit of systematic (beta) risk, rather than per unit of total volatility.

Parameters:

Name Type Description Default
returns Series

Periodic return series.

required
beta float

Pre-computed beta of returns against the relevant benchmark (see portpy.metrics.risk.beta).

required
rf float

Annual risk-free rate, de-annualized internally to a constant per-period rate.

DEFAULT_RISK_FREE_RATE
annualized bool

If True, scale the result by periods_per_year (linear, not sqrt, scaling).

True
periods_per_year int

Number of periods per year, used for annualization.

TRADING_DAYS_PER_YEAR
as_result bool

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

False

Returns:

Type Description
float | MetricResult

The Treynor ratio, or a MetricResult wrapping it.

Source code in src/portpy/metrics/performance.py
def treynor_ratio(
    returns: pd.Series,
    beta: float,
    rf: float = DEFAULT_RISK_FREE_RATE,
    annualized: bool = True,
    periods_per_year: int = TRADING_DAYS_PER_YEAR,
    as_result: bool = False,
) -> float | MetricResult:
    """
    Excess return per unit of systematic (beta) risk, rather than per unit
    of total volatility.

    Args:
        returns: Periodic return series.
        beta: Pre-computed beta of `returns` against the relevant benchmark
            (see `portpy.metrics.risk.beta`).
        rf: Annual risk-free rate, de-annualized internally to a constant
            per-period rate.
        annualized: If True, scale the result by periods_per_year (linear,
            not sqrt, scaling).
        periods_per_year: Number of periods per year, used for annualization.
        as_result: If True, return a MetricResult instead of a plain float.

    Returns:
        The Treynor ratio, or a MetricResult wrapping it.
    """

    r = returns.dropna()
    ensure_min_observations(r, 2, "returns")
    rf_period = periodic_rate_from_annual(rf, periods_per_year)
    mean_excess = float((r - rf_period).mean())
    value = safe_divide(mean_excess, beta)
    if annualized:
        value *= periods_per_year
    return MetricResult(value, "treynor_ratio") if as_result else value

m2_measure

m2_measure(
    returns: Series,
    benchmark: Series,
    rf: float = DEFAULT_RISK_FREE_RATE,
    periods_per_year: int = TRADING_DAYS_PER_YEAR,
    as_result: bool = False,
) -> float | MetricResult

Modigliani risk-adjusted performance: the return the portfolio would have earned levered/delevered to match the benchmark's volatility. Expressed in the same units as an annual return, unlike Sharpe.

Parameters:

Name Type Description Default
returns Series

Periodic return series.

required
benchmark Series

Periodic benchmark return series, aligned to returns.

required
rf float

Annual risk-free rate, used as-is (not de-annualized).

DEFAULT_RISK_FREE_RATE
periods_per_year int

Number of periods per year, used for annualization.

TRADING_DAYS_PER_YEAR
as_result bool

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

False

Returns:

Type Description
float | MetricResult

The M2 measure, expressed as an annualized return.

Source code in src/portpy/metrics/performance.py
def m2_measure(
    returns: pd.Series,
    benchmark: pd.Series,
    rf: float = DEFAULT_RISK_FREE_RATE,
    periods_per_year: int = TRADING_DAYS_PER_YEAR,
    as_result: bool = False,
) -> float | MetricResult:
    """
    Modigliani risk-adjusted performance: the return the portfolio would
    have earned levered/delevered to match the benchmark's volatility.
    Expressed in the same units as an annual return, unlike Sharpe.

    Args:
        returns: Periodic return series.
        benchmark: Periodic benchmark return series, aligned to `returns`.
        rf: Annual risk-free rate, used as-is (not de-annualized).
        periods_per_year: Number of periods per year, used for annualization.
        as_result: If True, return a MetricResult instead of a plain float.

    Returns:
        The M2 measure, expressed as an annualized return.
    """

    r, b = align_pair(returns, benchmark)
    ensure_min_observations(r, 2, "returns")
    sharpe_p = sharpe_ratio(r, rf=rf, annualized=True, periods_per_year=periods_per_year)
    vol_b = volatility(b, annualized=True, periods_per_year=periods_per_year)
    value = rf + float(sharpe_p) * float(vol_b)
    return MetricResult(value, "m2_measure", unit="%") if as_result else value

sterling_ratio

sterling_ratio(
    returns: Series,
    n: int = 5,
    periods_per_year: int = TRADING_DAYS_PER_YEAR,
    drawdown_adjustment: float = 0.0,
    as_result: bool = False,
) -> float | MetricResult

Annualized return divided by the average magnitude of the n worst drawdowns.

Uses a simplified, widely-used variant (average of the n largest historical drawdowns) rather than the original definition's calendar-year drawdowns.

Parameters:

Name Type Description Default
returns Series

Periodic return series.

required
n int

Number of worst drawdown episodes to average.

5
periods_per_year int

Number of periods per year, used for annualization.

TRADING_DAYS_PER_YEAR
drawdown_adjustment float

Added to the averaged drawdown denominator before dividing. Defaults to 0.0; the classic convention uses 0.10.

0.0
as_result bool

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

False

Returns:

Type Description
float | MetricResult

The Sterling ratio, or NaN if there were no drawdown episodes.

Source code in src/portpy/metrics/performance.py
def sterling_ratio(
    returns: pd.Series,
    n: int = 5,
    periods_per_year: int = TRADING_DAYS_PER_YEAR,
    drawdown_adjustment: float = 0.0,
    as_result: bool = False,
) -> float | MetricResult:
    """
    Annualized return divided by the average magnitude of the n worst
    drawdowns.

    Uses a simplified, widely-used variant (average of the n largest
    historical drawdowns) rather than the original definition's
    calendar-year drawdowns.

    Args:
        returns: Periodic return series.
        n: Number of worst drawdown episodes to average.
        periods_per_year: Number of periods per year, used for annualization.
        drawdown_adjustment: Added to the averaged drawdown denominator before
            dividing. Defaults to 0.0; the classic convention uses 0.10.
        as_result: If True, return a MetricResult instead of a plain float.

    Returns:
        The Sterling ratio, or NaN if there were no drawdown episodes.
    """

    r = returns.dropna()
    ensure_min_observations(r, 2, "returns")
    ann_ret = annualized_return(r, periods_per_year=periods_per_year, geometric=True)
    synthetic_prices = prices_from_returns(r)
    worst = top_n_drawdowns(synthetic_prices, n=n)
    if worst.empty:
        return MetricResult(np.nan, "sterling_ratio") if as_result else np.nan
    avg_dd = float(worst["depth"].abs().mean()) + drawdown_adjustment
    value = ann_ret / avg_dd if avg_dd > 0 else np.nan
    return MetricResult(value, "sterling_ratio") if as_result else value

burke_ratio

burke_ratio(
    returns: Series,
    n: int = 5,
    rf: float = DEFAULT_RISK_FREE_RATE,
    periods_per_year: int = TRADING_DAYS_PER_YEAR,
    as_result: bool = False,
) -> float | MetricResult

Excess annualized return over the root-sum-square of the n worst drawdowns.

Similar in spirit to sterling_ratio, but squares the drawdowns before combining them, penalizing a single deep drawdown more than several moderate ones of the same total size.

Parameters:

Name Type Description Default
returns Series

Periodic return series.

required
n int

Number of worst drawdown episodes to include.

5
rf float

Annual risk-free rate, used as-is (not de-annualized).

DEFAULT_RISK_FREE_RATE
periods_per_year int

Number of periods per year, used for annualization.

TRADING_DAYS_PER_YEAR
as_result bool

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

False

Returns:

Type Description
float | MetricResult

The Burke ratio, or NaN if there were no drawdown episodes.

Source code in src/portpy/metrics/performance.py
def burke_ratio(
    returns: pd.Series,
    n: int = 5,
    rf: float = DEFAULT_RISK_FREE_RATE,
    periods_per_year: int = TRADING_DAYS_PER_YEAR,
    as_result: bool = False,
) -> float | MetricResult:
    """
    Excess annualized return over the root-sum-square of the n worst
    drawdowns.

    Similar in spirit to sterling_ratio, but squares the drawdowns before
    combining them, penalizing a single deep drawdown more than several
    moderate ones of the same total size.

    Args:
        returns: Periodic return series.
        n: Number of worst drawdown episodes to include.
        rf: Annual risk-free rate, used as-is (not de-annualized).
        periods_per_year: Number of periods per year, used for annualization.
        as_result: If True, return a MetricResult instead of a plain float.

    Returns:
        The Burke ratio, or NaN if there were no drawdown episodes.
    """

    r = returns.dropna()
    ensure_min_observations(r, 2, "returns")
    ann_ret = annualized_return(r, periods_per_year=periods_per_year, geometric=True)
    synthetic_prices = prices_from_returns(r)
    worst = top_n_drawdowns(synthetic_prices, n=n)
    if worst.empty:
        return MetricResult(np.nan, "burke_ratio") if as_result else np.nan
    rss = float(np.sqrt(np.sum(np.square(worst["depth"]))))
    value = (ann_ret - rf) / rss if rss > 0 else np.nan
    return MetricResult(value, "burke_ratio") if as_result else value

gain_to_pain_ratio

gain_to_pain_ratio(
    returns: Series, as_result: bool = False
) -> float | MetricResult

Sum of all returns divided by the sum of absolute losses.

Parameters:

Name Type Description Default
returns Series

Periodic return series.

required
as_result bool

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

False

Returns:

Type Description
float | MetricResult

The gain-to-pain ratio.

Source code in src/portpy/metrics/performance.py
def gain_to_pain_ratio(returns: pd.Series, as_result: bool = False) -> float | MetricResult:
    """
    Sum of all returns divided by the sum of absolute losses.

    Args:
        returns: Periodic return series.
        as_result: If True, return a MetricResult instead of a plain float.

    Returns:
        The gain-to-pain ratio.
    """

    r = returns.dropna()
    ensure_min_observations(r, 1, "returns")
    losses = float(-r[r < 0].sum())
    value = safe_divide(float(r.sum()), losses)
    return MetricResult(value, "gain_to_pain_ratio") if as_result else value

kappa_three_ratio

kappa_three_ratio(
    returns: Series,
    mar: float = DEFAULT_MAR,
    periods_per_year: int = TRADING_DAYS_PER_YEAR,
    as_result: bool = False,
) -> float | MetricResult

Like Sortino, but penalizes shortfalls using a third-order (cubed) lower partial moment - more sensitive to a handful of severe shortfalls than Sortino's squared penalty.

Parameters:

Name Type Description Default
returns Series

Periodic return series.

required
mar float

Annual minimum acceptable return, de-annualized internally.

DEFAULT_MAR
periods_per_year int

Number of periods per year, used to de-annualize mar.

TRADING_DAYS_PER_YEAR
as_result bool

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

False

Returns:

Type Description
float | MetricResult

The Kappa-3 ratio (unannualized, unlike sibling sortino_ratio), or a

float | MetricResult

MetricResult wrapping it.

Source code in src/portpy/metrics/performance.py
def kappa_three_ratio(
    returns: pd.Series,
    mar: float = DEFAULT_MAR,
    periods_per_year: int = TRADING_DAYS_PER_YEAR,
    as_result: bool = False,
) -> float | MetricResult:
    """
    Like Sortino, but penalizes shortfalls using a third-order (cubed) lower
    partial moment - more sensitive to a handful of severe shortfalls than
    Sortino's squared penalty.

    Args:
        returns: Periodic return series.
        mar: Annual minimum acceptable return, de-annualized internally.
        periods_per_year: Number of periods per year, used to de-annualize `mar`.
        as_result: If True, return a MetricResult instead of a plain float.

    Returns:
        The Kappa-3 ratio (unannualized, unlike sibling sortino_ratio), or a
        MetricResult wrapping it.
    """

    r = returns.dropna()
    ensure_min_observations(r, 2, "returns")
    mar_period = periodic_rate_from_annual(mar, periods_per_year)
    shortfall = np.maximum(mar_period - r, 0.0)
    lpm3 = float(np.mean(np.power(shortfall, 3)))
    denom = lpm3 ** (1.0 / 3.0) if lpm3 > 0 else 0.0
    value = safe_divide(float((r - mar_period).mean()), denom)
    return MetricResult(value, "kappa_three_ratio") if as_result else value

upside_potential_ratio

upside_potential_ratio(
    returns: Series,
    mar: float = DEFAULT_MAR,
    periods_per_year: int = TRADING_DAYS_PER_YEAR,
    as_result: bool = False,
) -> float | MetricResult

Average upside above mar divided by downside deviation below mar - rewards asymmetric upside.

Parameters:

Name Type Description Default
returns Series

Periodic return series.

required
mar float

Annual minimum acceptable return, de-annualized internally.

DEFAULT_MAR
periods_per_year int

Number of periods per year, used to de-annualize mar.

TRADING_DAYS_PER_YEAR
as_result bool

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

False

Returns:

Type Description
float | MetricResult

The upside potential ratio (unannualized, unlike sibling sortino_ratio).

Source code in src/portpy/metrics/performance.py
def upside_potential_ratio(
    returns: pd.Series,
    mar: float = DEFAULT_MAR,
    periods_per_year: int = TRADING_DAYS_PER_YEAR,
    as_result: bool = False,
) -> float | MetricResult:
    """
    Average upside above `mar` divided by downside deviation below `mar` -
    rewards asymmetric upside.

    Args:
        returns: Periodic return series.
        mar: Annual minimum acceptable return, de-annualized internally.
        periods_per_year: Number of periods per year, used to de-annualize `mar`.
        as_result: If True, return a MetricResult instead of a plain float.

    Returns:
        The upside potential ratio (unannualized, unlike sibling sortino_ratio).
    """

    r = returns.dropna()
    ensure_min_observations(r, 2, "returns")
    mar_period = periodic_rate_from_annual(mar, periods_per_year)
    upside = float(np.mean(np.maximum(r - mar_period, 0.0)))
    dd_period = downside_deviation(r, mar=mar, annualized=False, periods_per_year=periods_per_year)
    value = safe_divide(upside, float(dd_period))
    return MetricResult(value, "upside_potential_ratio") if as_result else value