Skip to content

returns

Functions for computing, transforming, and summarizing portfolio returns. These metrics cover the full return workflow, from converting prices into returns and rebuilding price series to calculating cumulative, annualized, excess, and benchmark-relative performance.

returns

Return transformations and return-based summary statistics.

Convention: every scalar function accepts as_result=True to get back a portpy.explain.MetricResult (a float that also knows how to explain itself) instead of a plain float. Annualization everywhere in PortPy uses the period-count convention (years = n_periods / periods_per_year), matching empyrical/quantstats defaults, rather than actual elapsed calendar time.

simple_returns

simple_returns(
    prices: Series | DataFrame,
) -> pd.Series | pd.DataFrame

Period-over-period simple (arithmetic) returns.

Parameters:

Name Type Description Default
prices Series | DataFrame

Price series or DataFrame of price columns.

required

Returns:

Type Description
Series | DataFrame

Simple returns aligned to prices, with the first period dropped.

Source code in src/portpy/metrics/returns.py
def simple_returns(prices: pd.Series | pd.DataFrame) -> pd.Series | pd.DataFrame:
    """
    Period-over-period simple (arithmetic) returns.

    Args:
        prices: Price series or DataFrame of price columns.

    Returns:
        Simple returns aligned to `prices`, with the first period dropped.
    """
    return prices.pct_change().iloc[1:]

log_returns

log_returns(
    prices: Series | DataFrame,
) -> pd.Series | pd.DataFrame

Period-over-period continuously-compounded (log) returns.

Parameters:

Name Type Description Default
prices Series | DataFrame

Price series or DataFrame of price columns.

required

Returns:

Type Description
Series | DataFrame

Log returns aligned to prices, with the first period dropped.

Source code in src/portpy/metrics/returns.py
def log_returns(prices: pd.Series | pd.DataFrame) -> pd.Series | pd.DataFrame:
    """
    Period-over-period continuously-compounded (log) returns.

    Args:
        prices: Price series or DataFrame of price columns.

    Returns:
        Log returns aligned to `prices`, with the first period dropped.
    """
    return np.log(prices / prices.shift(1)).iloc[1:]

cumulative_returns

cumulative_returns(
    returns: Series | DataFrame,
) -> pd.Series | pd.DataFrame

Compounded cumulative return series from a series of simple returns.

Parameters:

Name Type Description Default
returns Series | DataFrame

Simple per-period returns.

required

Returns:

Type Description
Series | DataFrame

Cumulative return at each period, e.g. 0.25 for a 25% total gain.

Source code in src/portpy/metrics/returns.py
def cumulative_returns(returns: pd.Series | pd.DataFrame) -> pd.Series | pd.DataFrame:
    """
    Compounded cumulative return series from a series of simple returns.

    Args:
        returns: Simple per-period returns.

    Returns:
        Cumulative return at each period, e.g. 0.25 for a 25% total gain.
    """
    return (1.0 + returns).cumprod() - 1.0  # type: ignore[attr-defined]

prices_from_returns

prices_from_returns(
    returns: Series | DataFrame, base: float = 1.0
) -> pd.Series | pd.DataFrame

Reconstruct a price-like series from simple returns, anchored at base one period before the first return.

Parameters:

Name Type Description Default
returns Series | DataFrame

Simple per-period returns.

required
base float

Starting price level. Defaults to 1.0.

1.0

Returns:

Type Description
Series | DataFrame

A price series (or DataFrame) one period longer than returns, whose

Series | DataFrame

first value is base and which compounds returns from there.

Source code in src/portpy/metrics/returns.py
def prices_from_returns(returns: pd.Series | pd.DataFrame, base: float = 1.0) -> pd.Series | pd.DataFrame:
    """
    Reconstruct a price-like series from simple returns, anchored at `base`
    one period before the first return.

    Args:
        returns: Simple per-period returns.
        base: Starting price level. Defaults to 1.0.

    Returns:
        A price series (or DataFrame) one period longer than `returns`, whose
        first value is `base` and which compounds `returns` from there.
    """
    idx = returns.index
    if isinstance(idx, pd.DatetimeIndex):
        step = (idx[1] - idx[0]) if len(idx) >= 2 else pd.Timedelta(days=1)
        anchor_label = idx[0] - step
    else:
        anchor_label = -1

    compounded = base * (1.0 + returns).cumprod()  # type: ignore[attr-defined]
    if isinstance(returns, pd.DataFrame):
        anchor = pd.DataFrame([[base] * returns.shape[1]], index=[anchor_label], columns=returns.columns)
    else:
        anchor = pd.Series([base], index=[anchor_label], name=returns.name)
    return pd.concat([anchor, compounded])

total_return

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

Total compounded return over the full return series.

Parameters:

Name Type Description Default
returns Series

Per-period simple returns.

required
as_result bool

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

False

Returns:

Type Description
float | MetricResult

The total compounded return, e.g. 0.35 for a 35% gain.

Source code in src/portpy/metrics/returns.py
def total_return(returns: pd.Series, as_result: bool = False) -> float | MetricResult:
    """
    Total compounded return over the full return series.

    Args:
        returns: Per-period simple returns.
        as_result: If True, return a MetricResult instead of a plain float.

    Returns:
        The total compounded return, e.g. 0.35 for a 35% gain.
    """
    r = returns.dropna()
    ensure_min_observations(r, 1, "returns")
    value = float((1.0 + r).prod() - 1.0)
    return MetricResult(value, "total_return", unit="%") if as_result else value

annualized_return

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

Annualized return computed from a return series.

Parameters:

Name Type Description Default
returns Series

Per-period simple returns.

required
periods_per_year int

Number of periods in a year, used to annualize.

TRADING_DAYS_PER_YEAR
geometric bool

If True (default), compounds the total return and raises it to periods_per_year / n_periods (the CAGR of the series). If False, scales the arithmetic mean return by periods_per_year.

True
as_result bool

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

False

Returns:

Type Description
float | MetricResult

The annualized return. When geometric and the compounded growth

float | MetricResult

factor is non-positive (a total loss or worse), returns -1.0.

Source code in src/portpy/metrics/returns.py
def annualized_return(
    returns: pd.Series,
    periods_per_year: int = TRADING_DAYS_PER_YEAR,
    geometric: bool = True,
    as_result: bool = False,
) -> float | MetricResult:
    """
    Annualized return computed from a return series.

    Args:
        returns: Per-period simple returns.
        periods_per_year: Number of periods in a year, used to annualize.
        geometric: If True (default), compounds the total return and raises
            it to periods_per_year / n_periods (the CAGR of the series). If
            False, scales the arithmetic mean return by periods_per_year.
        as_result: If True, return a MetricResult instead of a plain float.

    Returns:
        The annualized return. When geometric and the compounded growth
        factor is non-positive (a total loss or worse), returns -1.0.
    """
    r = returns.dropna()
    ensure_min_observations(r, 1, "returns")
    n = len(r)
    if geometric:
        total = float((1.0 + r).prod())
        years = n / periods_per_year
        if years <= 0:
            value = np.nan
        elif total <= 0.0:
            value = -1.0
        else:
            value = total ** (1.0 / years) - 1.0
    else:
        value = float(r.mean()) * periods_per_year
    return MetricResult(value, "annualized_return", unit="%") if as_result else float(value)

cagr

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

Compound Annual Growth Rate, computed directly from a price series.

Parameters:

Name Type Description Default
prices Series

Price series spanning the measurement period.

required
periods_per_year int

Number of periods in a year, used to annualize.

TRADING_DAYS_PER_YEAR
as_result bool

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

False

Returns:

Type Description
float | MetricResult

The CAGR. Returns -1.0 if the start/end price ratio is non-positive,

float | MetricResult

rather than raising.

Source code in src/portpy/metrics/returns.py
def cagr(
    prices: pd.Series,
    periods_per_year: int = TRADING_DAYS_PER_YEAR,
    as_result: bool = False,
) -> float | MetricResult:
    """
    Compound Annual Growth Rate, computed directly from a price series.

    Args:
        prices: Price series spanning the measurement period.
        periods_per_year: Number of periods in a year, used to annualize.
        as_result: If True, return a MetricResult instead of a plain float.

    Returns:
        The CAGR. Returns -1.0 if the start/end price ratio is non-positive,
        rather than raising.
    """
    p = prices.dropna()
    ensure_min_observations(p, 2, "prices")
    n_periods = len(p) - 1
    years = n_periods / periods_per_year
    total = float(p.iloc[-1] / p.iloc[0])
    if years <= 0:
        value = np.nan
    elif total <= 0.0:
        value = -1.0
    else:
        value = total ** (1.0 / years) - 1.0
    return MetricResult(value, "cagr", unit="%") if as_result else float(value)

average_return

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

Average return per period, arithmetic (default) or geometric.

Parameters:

Name Type Description Default
returns Series

Per-period simple returns.

required
geometric bool

If True, computes exp(mean(log1p(returns))) - 1 instead of the arithmetic mean. Defaults to arithmetic.

False
as_result bool

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

False

Returns:

Type Description
float | MetricResult

The average per-period return.

Source code in src/portpy/metrics/returns.py
def average_return(returns: pd.Series, geometric: bool = False, as_result: bool = False) -> float | MetricResult:
    """
    Average return per period, arithmetic (default) or geometric.

    Args:
        returns: Per-period simple returns.
        geometric: If True, computes exp(mean(log1p(returns))) - 1 instead of
            the arithmetic mean. Defaults to arithmetic.
        as_result: If True, return a MetricResult instead of a plain float.

    Returns:
        The average per-period return.
    """
    r = returns.dropna()
    ensure_min_observations(r, 1, "returns")
    if geometric:
        value = float(np.exp(np.log1p(r).mean()) - 1.0)
    else:
        value = float(r.mean())
    return MetricResult(value, "average_return", unit="%") if as_result else value

rebased_returns

rebased_returns(
    prices: Series | DataFrame, base: float = 100.0
) -> pd.Series | pd.DataFrame

Rescale a price series so it starts at base, preserving percentage changes.

Parameters:

Name Type Description Default
prices Series | DataFrame

Price series or DataFrame of price columns.

required
base float

Starting value for the rescaled series. Defaults to 100.

100.0

Returns:

Type Description
Series | DataFrame

Rescaled price series, same shape as prices.

Source code in src/portpy/metrics/returns.py
def rebased_returns(prices: pd.Series | pd.DataFrame, base: float = 100.0) -> pd.Series | pd.DataFrame:
    """
    Rescale a price series so it starts at `base`, preserving percentage changes.

    Args:
        prices: Price series or DataFrame of price columns.
        base: Starting value for the rescaled series. Defaults to 100.

    Returns:
        Rescaled price series, same shape as `prices`.
    """
    return prices / prices.iloc[0] * base

excess_returns

excess_returns(
    returns: Series, benchmark_or_rf: Series | float
) -> pd.Series

Per-period return in excess of a benchmark series or a constant rate.

Parameters:

Name Type Description Default
returns Series

Per-period simple returns.

required
benchmark_or_rf Series | float

A benchmark return series (aligned by date) or a constant per-period rate to subtract.

required

Returns:

Type Description
Series

Per-period excess returns.

Source code in src/portpy/metrics/returns.py
def excess_returns(returns: pd.Series, benchmark_or_rf: pd.Series | float) -> pd.Series:
    """
    Per-period return in excess of a benchmark series or a constant rate.

    Args:
        returns: Per-period simple returns.
        benchmark_or_rf: A benchmark return series (aligned by date) or a
            constant per-period rate to subtract.

    Returns:
        Per-period excess returns.
    """
    if isinstance(benchmark_or_rf, pd.Series):
        joined = pd.concat([returns.rename("r"), benchmark_or_rf.rename("b")], axis=1, join="inner").dropna()
        return joined["r"] - joined["b"]
    return returns - benchmark_or_rf

active_returns

active_returns(
    returns: Series, benchmark: Series
) -> pd.Series

Per-period return relative to a benchmark series.

Parameters:

Name Type Description Default
returns Series

Per-period simple returns.

required
benchmark Series

Benchmark per-period simple returns.

required

Returns:

Type Description
Series

Per-period active (portfolio minus benchmark) returns.

Source code in src/portpy/metrics/returns.py
def active_returns(returns: pd.Series, benchmark: pd.Series) -> pd.Series:
    """
    Per-period return relative to a benchmark series.

    Args:
        returns: Per-period simple returns.
        benchmark: Benchmark per-period simple returns.

    Returns:
        Per-period active (portfolio minus benchmark) returns.
    """
    return excess_returns(returns, benchmark)