Skip to content

Metrics

All single-series metrics (sharpe, sortino, calmar, omega, max_drawdown, ulcer_index, value_at_risk, cvar) take a returns Series and a small number of keyword arguments (periods_per_year, risk_free, alpha, target) with sensible defaults for daily data. The batch_* variants accept a dict of named series or a wide DataFrame and dispatch to the Rust-accelerated kernel when available — see Rust kernels. returns_stats / batch_summary produce the same Series / DataFrame shape used by the tear sheet, so custom reporting code can share the formatting layer.

fundcloud.metrics

Portfolio analytics.

Free functions on returns. Every function accepts a pd.Series (single strategy) or a pd.DataFrame (panel of strategies as columns) and returns the same shape: a scalar stays a scalar for Series input, a Series indexed by column name for DataFrame input.

Organised into six concerns:

  • :mod:fundcloud.metrics.core — scalar metrics independent of a benchmark (return, risk, risk-adjusted, higher moments).
  • :mod:fundcloud.metrics.benchmark — benchmark-relative metrics (alpha, beta, capture ratios, Treynor, information ratio).
  • :mod:fundcloud.metrics.periods — calendar-period aggregates (monthly / yearly tables, best/worst, positive/negative counts).
  • :mod:fundcloud.metrics.rolling — rolling-window metric series (Sharpe / Sortino / volatility / beta / drawdown).
  • :mod:fundcloud.metrics.summary — :func:metrics one-shot bundle and :func:drawdown_details episode table.
  • :mod:fundcloud.metrics.batch — GIL-released batch variants over large panels via the Rust kernels.

sharpe

sharpe(
    returns: Series,
    *,
    risk_free: float | None = ...,
    periods_per_year: int | None = ...,
) -> float
sharpe(
    returns: DataFrame,
    *,
    risk_free: float | None = ...,
    periods_per_year: int | None = ...,
) -> pd.Series
sharpe(
    returns: Series | DataFrame,
    *,
    risk_free: float | None = None,
    periods_per_year: int | None = None,
) -> float | pd.Series

Annualised Sharpe ratio.

Uses the sample standard deviation (ddof=1). Returns are assumed to be simple per-period returns; for log returns the formula is the same numerator and denominator.

Examples:

>>> import pandas as pd, numpy as np
>>> rng = np.random.default_rng(0)
>>> r = pd.Series(rng.normal(0.0005, 0.01, 252))
>>> round(sharpe(r, periods_per_year=252), 2)
0.7
>>> # Works on a DataFrame too — returns a Series indexed by column:
>>> panel = pd.DataFrame({"a": r, "b": -r})
>>> isinstance(sharpe(panel), pd.Series)
True
Source code in python/fundcloud/metrics/core.py
def sharpe(
    returns: pd.Series | pd.DataFrame,
    *,
    risk_free: float | None = None,
    periods_per_year: int | None = None,
) -> float | pd.Series:
    """Annualised Sharpe ratio.

    Uses the **sample** standard deviation (``ddof=1``). Returns are assumed
    to be simple per-period returns; for log returns the formula is the same
    numerator and denominator.

    Examples
    --------
    >>> import pandas as pd, numpy as np
    >>> rng = np.random.default_rng(0)
    >>> r = pd.Series(rng.normal(0.0005, 0.01, 252))
    >>> round(sharpe(r, periods_per_year=252), 2)  # doctest: +SKIP
    0.7
    >>> # Works on a DataFrame too — returns a Series indexed by column:
    >>> panel = pd.DataFrame({"a": r, "b": -r})
    >>> isinstance(sharpe(panel), pd.Series)
    True
    """
    df = _to_df(returns)
    ppy = _periods(periods_per_year)
    rf_pp = _rf_per_period(risk_free, ppy)
    excess = df - rf_pp
    mu = excess.mean()
    sigma = excess.std(ddof=1)
    out = (mu / sigma) * np.sqrt(ppy)
    out = out.replace([np.inf, -np.inf], np.nan)
    return _collapse(out, returns)

sortino

sortino(
    returns: Series,
    *,
    target: float = ...,
    periods_per_year: int | None = ...,
) -> float
sortino(
    returns: DataFrame,
    *,
    target: float = ...,
    periods_per_year: int | None = ...,
) -> pd.Series
sortino(
    returns: Series | DataFrame,
    *,
    target: float = 0.0,
    periods_per_year: int | None = None,
) -> float | pd.Series

Annualised Sortino ratio.

Downside deviation uses only periods with returns strictly below target and divides by the sample count (ddof=0).

Source code in python/fundcloud/metrics/core.py
def sortino(
    returns: pd.Series | pd.DataFrame,
    *,
    target: float = 0.0,
    periods_per_year: int | None = None,
) -> float | pd.Series:
    """Annualised Sortino ratio.

    Downside deviation uses only periods with returns strictly below
    ``target`` and divides by the sample count (``ddof=0``).
    """
    df = _to_df(returns)
    ppy = _periods(periods_per_year)
    diff = df - target
    downside = diff.clip(upper=0.0)
    # pop std with mean=0 => sqrt(mean(x^2))
    dd = np.sqrt((downside**2).mean())
    mu = diff.mean()
    out = (mu / dd) * np.sqrt(ppy)
    out = out.replace([np.inf, -np.inf], np.nan)
    return _collapse(out, returns)

calmar

calmar(
    returns: Series, *, periods_per_year: int | None = ...
) -> float
calmar(
    returns: DataFrame,
    *,
    periods_per_year: int | None = ...,
) -> pd.Series
calmar(
    returns: Series | DataFrame,
    *,
    periods_per_year: int | None = None,
) -> float | pd.Series

Annualised return divided by absolute max drawdown.

Source code in python/fundcloud/metrics/core.py
def calmar(
    returns: pd.Series | pd.DataFrame,
    *,
    periods_per_year: int | None = None,
) -> float | pd.Series:
    """Annualised return divided by absolute max drawdown."""
    df = _to_df(returns)
    ppy = _periods(periods_per_year)
    ann_ret = (1.0 + df).prod() ** (ppy / max(len(df), 1)) - 1.0
    mdd = max_drawdown(df).abs()
    out = ann_ret / mdd
    out = out.replace([np.inf, -np.inf], np.nan)
    return _collapse(out, returns)

omega

omega(returns: Series, *, target: float = ...) -> float
omega(
    returns: DataFrame, *, target: float = ...
) -> pd.Series
omega(
    returns: Series | DataFrame, *, target: float = 0.0
) -> float | pd.Series

Omega ratio at target threshold.

Ratio of the expected gain above target to expected loss below.

Source code in python/fundcloud/metrics/core.py
def omega(returns: pd.Series | pd.DataFrame, *, target: float = 0.0) -> float | pd.Series:
    """Omega ratio at ``target`` threshold.

    Ratio of the expected gain above target to expected loss below.
    """
    df = _to_df(returns)
    diff = df - target
    gains = diff.clip(lower=0.0).sum()
    losses = -diff.clip(upper=0.0).sum()
    out = gains / losses
    out = out.replace([np.inf, -np.inf], np.nan)
    return _collapse(out, returns)

drawdown_series

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

Drawdown at each timestamp: wealth / running_max - 1.

Always ≤ 0.

Source code in python/fundcloud/metrics/core.py
def drawdown_series(returns: pd.Series | pd.DataFrame) -> pd.Series | pd.DataFrame:
    """Drawdown at each timestamp: ``wealth / running_max - 1``.

    Always ≤ 0.
    """
    wealth = (1.0 + returns).cumprod()
    peak = wealth.cummax()
    return wealth / peak - 1.0

max_drawdown

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

Largest peak-to-trough loss (negative number).

Source code in python/fundcloud/metrics/core.py
def max_drawdown(returns: pd.Series | pd.DataFrame) -> float | pd.Series:
    """Largest peak-to-trough loss (negative number)."""
    dd = drawdown_series(_to_df(returns))
    out = dd.min()
    return _collapse(out, returns)

ulcer_index

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

Ulcer Index: RMS of drawdowns, in percent.

Source code in python/fundcloud/metrics/core.py
def ulcer_index(returns: pd.Series | pd.DataFrame) -> float | pd.Series:
    """Ulcer Index: RMS of drawdowns, in percent."""
    dd_pct = drawdown_series(_to_df(returns)) * 100.0
    out = np.sqrt((dd_pct**2).mean())
    return _collapse(out, returns)

cvar

cvar(returns: Series, *, alpha: float = ...) -> float
cvar(
    returns: DataFrame, *, alpha: float = ...
) -> pd.Series
cvar(
    returns: Series | DataFrame, *, alpha: float = 0.95
) -> float | pd.Series

Conditional Value-at-Risk (Expected Shortfall) at confidence alpha.

Returns a loss as a negative number — the mean of returns below the (1 - alpha) quantile.

Source code in python/fundcloud/metrics/core.py
def cvar(returns: pd.Series | pd.DataFrame, *, alpha: float = 0.95) -> float | pd.Series:
    """Conditional Value-at-Risk (Expected Shortfall) at confidence ``alpha``.

    Returns a **loss** as a negative number — the mean of returns below the
    ``(1 - alpha)`` quantile.
    """
    if not 0.0 < alpha < 1.0:
        raise ValueError("alpha must be in (0, 1)")
    df = _to_df(returns)
    q = df.quantile(1.0 - alpha)
    out = pd.Series(index=df.columns, dtype=float)
    for c in df.columns:
        mask = df[c] <= q[c]
        out[c] = df.loc[mask, c].mean() if mask.any() else np.nan
    return _collapse(out, returns)

value_at_risk

value_at_risk(
    returns: Series, *, alpha: float = ...
) -> float
value_at_risk(
    returns: DataFrame, *, alpha: float = ...
) -> pd.Series
value_at_risk(
    returns: Series | DataFrame, *, alpha: float = 0.95
) -> float | pd.Series

Historical Value-at-Risk at confidence alpha.

Returns a loss as a negative number (the (1-alpha) quantile of returns).

Source code in python/fundcloud/metrics/core.py
def value_at_risk(returns: pd.Series | pd.DataFrame, *, alpha: float = 0.95) -> float | pd.Series:
    """Historical Value-at-Risk at confidence ``alpha``.

    Returns a **loss** as a negative number (the (1-alpha) quantile of returns).
    """
    if not 0.0 < alpha < 1.0:
        raise ValueError("alpha must be in (0, 1)")
    df = _to_df(returns)
    out = df.quantile(1.0 - alpha)
    return _collapse(out, returns)

returns_stats

returns_stats(
    returns: Series | DataFrame,
    *,
    risk_free: float | None = None,
    periods_per_year: int | None = None,
    cvar_alpha: float = 0.95,
) -> pd.DataFrame

Bundle of the common metrics into a single, scannable summary table.

Rows are metrics, columns are strategies.

Source code in python/fundcloud/metrics/core.py
def returns_stats(
    returns: pd.Series | pd.DataFrame,
    *,
    risk_free: float | None = None,
    periods_per_year: int | None = None,
    cvar_alpha: float = 0.95,
) -> pd.DataFrame:
    """Bundle of the common metrics into a single, scannable summary table.

    Rows are metrics, columns are strategies.
    """
    df = _to_df(returns)
    ppy = _periods(periods_per_year)
    n = len(df)
    total_return = (1.0 + df).prod() - 1.0
    cagr = (1.0 + df).prod() ** (ppy / max(n, 1)) - 1.0
    ann_vol = df.std(ddof=1) * np.sqrt(ppy)
    rows = {
        "periods": pd.Series(n, index=df.columns),
        "total_return": total_return,
        "cagr": cagr,
        "ann_volatility": ann_vol,
        "sharpe": sharpe(df, risk_free=risk_free, periods_per_year=ppy),
        "sortino": sortino(df, periods_per_year=ppy),
        "calmar": calmar(df, periods_per_year=ppy),
        "max_drawdown": max_drawdown(df),
        "ulcer_index": ulcer_index(df),
        "cvar": cvar(df, alpha=cvar_alpha),
        "omega": omega(df),
    }
    return pd.DataFrame(rows).T

batch_sharpe

batch_sharpe(
    strategies: Mapping[str, Series | DataFrame],
    *,
    risk_free: float | None = None,
    periods_per_year: int | None = None,
) -> pd.Series
Source code in python/fundcloud/metrics/batch.py
def batch_sharpe(
    strategies: Mapping[str, pd.Series | pd.DataFrame],
    *,
    risk_free: float | None = None,
    periods_per_year: int | None = None,
) -> pd.Series:
    rows = {
        name: _core.sharpe(
            _reduce_returns(r),
            risk_free=risk_free,
            periods_per_year=periods_per_year,
        )
        for name, r in strategies.items()
    }
    return pd.Series(rows, name="sharpe", dtype=float)

batch_sortino

batch_sortino(
    strategies: Mapping[str, Series | DataFrame],
    *,
    target: float = 0.0,
    periods_per_year: int | None = None,
) -> pd.Series
Source code in python/fundcloud/metrics/batch.py
def batch_sortino(
    strategies: Mapping[str, pd.Series | pd.DataFrame],
    *,
    target: float = 0.0,
    periods_per_year: int | None = None,
) -> pd.Series:
    rows = {
        name: _core.sortino(_reduce_returns(r), target=target, periods_per_year=periods_per_year)
        for name, r in strategies.items()
    }
    return pd.Series(rows, name="sortino", dtype=float)

batch_max_drawdown

batch_max_drawdown(
    strategies: Mapping[str, Series | DataFrame],
) -> pd.Series
Source code in python/fundcloud/metrics/batch.py
def batch_max_drawdown(
    strategies: Mapping[str, pd.Series | pd.DataFrame],
) -> pd.Series:
    rows = {name: _core.max_drawdown(_reduce_returns(r)) for name, r in strategies.items()}
    return pd.Series(rows, name="max_drawdown", dtype=float)

batch_cvar

batch_cvar(
    strategies: Mapping[str, Series | DataFrame],
    *,
    alpha: float = 0.95,
) -> pd.Series
Source code in python/fundcloud/metrics/batch.py
def batch_cvar(
    strategies: Mapping[str, pd.Series | pd.DataFrame],
    *,
    alpha: float = 0.95,
) -> pd.Series:
    rows = {name: _core.cvar(_reduce_returns(r), alpha=alpha) for name, r in strategies.items()}
    return pd.Series(rows, name="cvar", dtype=float)

batch_summary

batch_summary(
    strategies: Mapping[str, Series | DataFrame],
    *,
    risk_free: float | None = None,
    periods_per_year: int | None = None,
    cvar_alpha: float = 0.95,
) -> pd.DataFrame

One row per strategy, standard metrics as columns.

Source code in python/fundcloud/metrics/batch.py
def batch_summary(
    strategies: Mapping[str, pd.Series | pd.DataFrame],
    *,
    risk_free: float | None = None,
    periods_per_year: int | None = None,
    cvar_alpha: float = 0.95,
) -> pd.DataFrame:
    """One row per strategy, standard metrics as columns."""
    if not strategies:
        return pd.DataFrame()
    rows = {}
    for name, r in strategies.items():
        s = _reduce_returns(r)
        rows[name] = _core.returns_stats(
            s,
            risk_free=risk_free,
            periods_per_year=periods_per_year,
            cvar_alpha=cvar_alpha,
        ).iloc[:, 0]
    out = pd.DataFrame(rows).T
    # Enforce float dtype; sklearn/skfolio sometimes hands us object columns.
    return out.apply(pd.to_numeric, errors="coerce").replace([np.inf, -np.inf], np.nan)