Skip to content

distributions

Functions for analyzing the statistical distribution of portfolio returns. These metrics summarize return characteristics, assess normality, measure the frequency and magnitude of gains and losses, and provide data for tables and visualizations.

distributions

Descriptive statistics and shape of the return distribution.

describe

describe(returns: Series) -> pd.Series

Standard descriptive statistics (count, mean, std, min/max, quartiles) plus skew and kurtosis, using pandas' bias-adjusted Fisher-Pearson sample estimators.

Parameters:

Name Type Description Default
returns Series

Periodic returns.

required

Returns:

Type Description
Series

A Series indexed by statistic name (count, mean, std, min, 25%,

Series

50%, 75%, max, skew, kurtosis).

Source code in src/portpy/metrics/distributions.py
def describe(returns: pd.Series) -> pd.Series:
    """
    Standard descriptive statistics (count, mean, std, min/max, quartiles)
    plus skew and kurtosis, using pandas' bias-adjusted Fisher-Pearson
    sample estimators.

    Args:
        returns: Periodic returns.

    Returns:
        A Series indexed by statistic name (count, mean, std, min, 25%,
        50%, 75%, max, skew, kurtosis).
    """
    r = returns.dropna()
    ensure_min_observations(r, 2, "returns")
    base = r.describe()
    extra = pd.Series({"skew": r.skew(), "kurtosis": r.kurtosis()})
    return pd.concat([base, extra])

normality_test

normality_test(returns: Series) -> dict

Jarque-Bera test of whether returns look Normally distributed.

Parameters:

Name Type Description Default
returns Series

Periodic returns. Requires at least 8 observations.

required

Returns:

Type Description
dict

A dict with "statistic" (the JB test statistic), "p_value", and

dict

"is_normal" (True if p_value > 0.05).

Source code in src/portpy/metrics/distributions.py
def normality_test(returns: pd.Series) -> dict:
    """
    Jarque-Bera test of whether returns look Normally distributed.

    Args:
        returns: Periodic returns. Requires at least 8 observations.

    Returns:
        A dict with "statistic" (the JB test statistic), "p_value", and
        "is_normal" (True if p_value > 0.05).
    """
    r = returns.dropna()
    ensure_min_observations(r, 8, "returns")
    stat, p_value = scipy_stats.jarque_bera(r)
    result: dict[str, object] = {
        "statistic": float(stat),
        "p_value": float(p_value),
        "is_normal": bool(p_value > 0.05),
    }
    result["_portpy_explain_name"] = "normality_test"
    return result

best_worst_periods

best_worst_periods(returns: Series, n: int = 5) -> dict

The n best and n worst individual periods, each as a Series sorted from most extreme to least extreme.

Parameters:

Name Type Description Default
returns Series

Periodic returns.

required
n int

Number of best/worst periods to return.

5

Returns:

Type Description
dict

A dict with keys "best" and "worst", each a Series of the top-n

dict

returns.

Source code in src/portpy/metrics/distributions.py
def best_worst_periods(returns: pd.Series, n: int = 5) -> dict:
    """
    The n best and n worst individual periods, each as a Series sorted from
    most extreme to least extreme.

    Args:
        returns: Periodic returns.
        n: Number of best/worst periods to return.

    Returns:
        A dict with keys "best" and "worst", each a Series of the top-n
        returns.
    """
    r = returns.dropna()
    ensure_min_observations(r, 1, "returns")
    return {
        "best": r.sort_values(ascending=False).head(n),
        "worst": r.sort_values(ascending=True).head(n),
    }

win_rate

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

Fraction of periods with a strictly positive return.

Parameters:

Name Type Description Default
returns Series

Periodic returns.

required
as_result bool

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

False

Returns:

Type Description
float | MetricResult

The win rate, or a MetricResult wrapping it.

Source code in src/portpy/metrics/distributions.py
def win_rate(returns: pd.Series, as_result: bool = False) -> float | MetricResult:
    """
    Fraction of periods with a strictly positive return.

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

    Returns:
        The win rate, or a `MetricResult` wrapping it.
    """
    r = returns.dropna()
    ensure_min_observations(r, 1, "returns")
    value = float((r > 0).mean())
    return MetricResult(value, "win_rate", unit="%") if as_result else value

win_loss_ratio

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

Average winning period's return divided by the average (absolute) losing period's return. Zero-return periods count toward neither bucket.

Parameters:

Name Type Description Default
returns Series

Periodic returns.

required
as_result bool

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

False

Returns:

Type Description
float | MetricResult

The win/loss ratio, or a MetricResult wrapping it. 0.0 if there

float | MetricResult

are no wins or no losses.

Source code in src/portpy/metrics/distributions.py
def win_loss_ratio(returns: pd.Series, as_result: bool = False) -> float | MetricResult:
    """
    Average winning period's return divided by the average (absolute)
    losing period's return. Zero-return periods count toward neither bucket.

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

    Returns:
        The win/loss ratio, or a `MetricResult` wrapping it. 0.0 if there
        are no wins or no losses.
    """
    r = returns.dropna()
    ensure_min_observations(r, 1, "returns")
    wins = r[r > 0]
    losses = r[r < 0]
    value = safe_divide(float(wins.mean()) if len(wins) else 0.0, float(-losses.mean()) if len(losses) else 0.0)
    return MetricResult(value, "win_loss_ratio") if as_result else value

positive_periods_pct

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

Alias of win_rate, kept for API compatibility with the common naming convention.

Parameters:

Name Type Description Default
returns Series

Periodic returns.

required
as_result bool

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

False

Returns:

Type Description
float | MetricResult

The fraction of positive periods, or a MetricResult wrapping it.

Source code in src/portpy/metrics/distributions.py
def positive_periods_pct(returns: pd.Series, as_result: bool = False) -> float | MetricResult:
    """
    Alias of `win_rate`, kept for API compatibility with the common naming
    convention.

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

    Returns:
        The fraction of positive periods, or a `MetricResult` wrapping it.
    """
    r = returns.dropna()
    value = win_rate(r, as_result=False)
    return MetricResult(value, "positive_periods_pct", unit="%") if as_result else value

monthly_returns_table

monthly_returns_table(returns: Series) -> pd.DataFrame

Calendar table of compounded returns: rows are years, columns are Jan-Dec, plus a Year total column.

Parameters:

Name Type Description Default
returns Series

Periodic returns, finer than monthly (e.g. daily/weekly).

required

Returns:

Type Description
DataFrame

A DataFrame indexed by year with one column per calendar month

DataFrame

(compounded return) plus a "Year" column (compounded annual return).

Source code in src/portpy/metrics/distributions.py
def monthly_returns_table(returns: pd.Series) -> pd.DataFrame:
    """
    Calendar table of compounded returns: rows are years, columns are
    Jan-Dec, plus a Year total column.

    Args:
        returns: Periodic returns, finer than monthly (e.g. daily/weekly).

    Returns:
        A DataFrame indexed by year with one column per calendar month
        (compounded return) plus a "Year" column (compounded annual return).
    """
    r = returns.dropna()
    ensure_min_observations(r, 1, "returns")

    monthly = (1.0 + r).resample("ME").prod() - 1.0
    df = monthly.to_frame("ret")
    df["year"] = df.index.year
    df["month"] = df.index.month
    table = df.pivot(index="year", columns="month", values="ret")
    table = table.reindex(columns=range(1, 13))
    table.columns = [_calendar.month_abbr[m] for m in range(1, 13)]

    annual = (1.0 + r).groupby(r.index.year).prod() - 1.0
    table["Year"] = annual
    table.index.name = "year"
    return table

return_histogram_data

return_histogram_data(
    returns: Series, bins: int = 50
) -> tuple[np.ndarray, np.ndarray]

Histogram of the return distribution, computed via numpy.histogram.

Parameters:

Name Type Description Default
returns Series

Periodic returns.

required
bins int

Number of histogram bins.

50

Returns:

Type Description
tuple[ndarray, ndarray]

A tuple of (counts, bin_edges).

Source code in src/portpy/metrics/distributions.py
def return_histogram_data(returns: pd.Series, bins: int = 50) -> tuple[np.ndarray, np.ndarray]:
    """
    Histogram of the return distribution, computed via `numpy.histogram`.

    Args:
        returns: Periodic returns.
        bins: Number of histogram bins.

    Returns:
        A tuple of (counts, bin_edges).
    """
    r = returns.dropna()
    ensure_min_observations(r, 2, "returns")
    return np.histogram(r.to_numpy(), bins=bins)