Skip to content

Backtest

See the Backtesting guide for the walk-forward methodology and worked examples.

Engine

engine

A vectorized walk-forward backtester.

The backtester is optimizer-agnostic: it takes any callable optimizer(returns_window) -> PortfolioResult and rebalances on a fixed schedule, applying linear transaction costs on turnover. It returns a :class:BacktestResult holding the strategy return series, weight history, and a metrics summary — ready for the plotting utilities.

BacktestResult dataclass

BacktestResult(
    name: str,
    returns: Series,
    weights: DataFrame,
    turnover: Series,
    metrics: dict[str, float] = dict(),
)

Output of a backtest run.

backtest

backtest(
    returns: DataFrame,
    optimizer: Optimizer,
    *,
    name: str | None = None,
    lookback: int = 252,
    rebalance_every: int = 21,
    transaction_cost: float = 0.001,
    periods_per_year: int = 252,
    risk_free: float = 0.0,
) -> BacktestResult

Walk-forward backtest of a single optimizer.

Parameters:

Name Type Description Default
returns DataFrame

Asset return panel (rows = periods, columns = assets).

required
optimizer Optimizer

Callable mapping a trailing return window to a :class:PortfolioResult.

required
lookback int

Number of trailing periods handed to the optimizer at each rebalance.

252
rebalance_every int

Rebalance frequency in periods (21 ≈ monthly for daily data).

21
transaction_cost float

Proportional cost per unit of turnover (one-way), e.g. 0.0010 = 10bps.

0.001

Returns:

Type Description
BacktestResult

Net-of-cost strategy returns, weight history, turnover, and metrics.

Source code in src/jaxfolio/backtest/engine.py
def backtest(
    returns: pd.DataFrame,
    optimizer: Optimizer,
    *,
    name: str | None = None,
    lookback: int = 252,
    rebalance_every: int = 21,
    transaction_cost: float = 0.0010,
    periods_per_year: int = 252,
    risk_free: float = 0.0,
) -> BacktestResult:
    """Walk-forward backtest of a single optimizer.

    Parameters
    ----------
    returns:
        Asset return panel (rows = periods, columns = assets).
    optimizer:
        Callable mapping a trailing return window to a :class:`PortfolioResult`.
    lookback:
        Number of trailing periods handed to the optimizer at each rebalance.
    rebalance_every:
        Rebalance frequency in periods (21 ≈ monthly for daily data).
    transaction_cost:
        Proportional cost per unit of turnover (one-way), e.g. ``0.0010`` = 10bps.

    Returns
    -------
    BacktestResult
        Net-of-cost strategy returns, weight history, turnover, and metrics.
    """
    assets = list(returns.columns)
    n = len(assets)
    dates = returns.index
    ret_vals = returns.to_numpy()

    weights = np.zeros(n)
    weight_hist: dict[pd.Timestamp, np.ndarray] = {}
    turnover_hist: dict[pd.Timestamp, float] = {}
    strat_returns = np.zeros(len(returns))

    for t in range(lookback, len(returns)):
        # Rebalance at the cadence; otherwise let weights drift with returns.
        if (t - lookback) % rebalance_every == 0:
            window = returns.iloc[t - lookback : t]
            result = optimizer(window)
            target = _align_weights(result, assets)
            cost = transaction_cost * np.abs(target - weights).sum()
            turnover_hist[dates[t]] = float(np.abs(target - weights).sum())
            weights = target
        else:
            cost = 0.0

        period_ret = ret_vals[t]
        gross = float(np.dot(weights, period_ret))
        strat_returns[t] = gross - cost
        weight_hist[dates[t]] = weights.copy()

        # Drift weights with realized returns (buy-and-hold between rebalances).
        grown = weights * (1.0 + period_ret)
        total = grown.sum()
        if total > 0:
            weights = grown / total

    ret_series = pd.Series(strat_returns, index=dates, name=name or "strategy")
    ret_series = ret_series.iloc[lookback:]
    w_df = pd.DataFrame.from_dict(weight_hist, orient="index", columns=assets)
    to_series = pd.Series(turnover_hist, name="turnover")

    summary = M.summary(ret_series, risk_free=risk_free, periods_per_year=periods_per_year)
    summary["avg_turnover"] = float(to_series.mean()) if len(to_series) else 0.0
    return BacktestResult(
        name=name or "strategy",
        returns=ret_series,
        weights=w_df,
        turnover=to_series,
        metrics=summary,
    )

compare

compare(
    returns: DataFrame,
    optimizers: dict[str, Optimizer],
    **kwargs,
) -> dict[str, BacktestResult]

Backtest several optimizers on the same data and return a name->result map.

Source code in src/jaxfolio/backtest/engine.py
def compare(
    returns: pd.DataFrame,
    optimizers: dict[str, Optimizer],
    **kwargs,
) -> dict[str, BacktestResult]:
    """Backtest several optimizers on the same data and return a name->result map."""
    return {name: backtest(returns, opt, name=name, **kwargs) for name, opt in optimizers.items()}

metrics_table

metrics_table(
    results: dict[str, BacktestResult],
) -> DataFrame

Assemble a tidy metrics comparison table across backtest results.

Source code in src/jaxfolio/backtest/engine.py
def metrics_table(results: dict[str, BacktestResult]) -> pd.DataFrame:
    """Assemble a tidy metrics comparison table across backtest results."""
    rows = {name: res.metrics for name, res in results.items()}
    return pd.DataFrame(rows).T

Metrics

metrics

Performance and risk metrics for return series.

All functions accept a 1-D array-like of periodic returns and return plain floats. Annualization uses periods_per_year (252 for daily by default).

annualized_return

annualized_return(
    returns, periods_per_year: int = _PPY
) -> float

Geometric annualized return (CAGR) of a periodic return series.

Source code in src/jaxfolio/backtest/metrics.py
def annualized_return(returns, periods_per_year: int = _PPY) -> float:
    """Geometric annualized return (CAGR) of a periodic return series."""
    r = _to_array(returns)
    if r.size == 0:
        return 0.0
    growth = np.prod(1.0 + r)
    years = r.size / periods_per_year
    if growth <= 0:
        return -1.0
    return float(growth ** (1.0 / years) - 1.0)

annualized_volatility

annualized_volatility(
    returns, periods_per_year: int = _PPY
) -> float

Annualized standard deviation of returns.

Source code in src/jaxfolio/backtest/metrics.py
def annualized_volatility(returns, periods_per_year: int = _PPY) -> float:
    """Annualized standard deviation of returns."""
    r = _to_array(returns)
    return float(np.std(r, ddof=1) * np.sqrt(periods_per_year)) if r.size > 1 else 0.0

sharpe_ratio

sharpe_ratio(
    returns,
    risk_free: float = 0.0,
    periods_per_year: int = _PPY,
) -> float

Annualized Sharpe ratio (risk_free is a per-period rate).

Source code in src/jaxfolio/backtest/metrics.py
def sharpe_ratio(returns, risk_free: float = 0.0, periods_per_year: int = _PPY) -> float:
    """Annualized Sharpe ratio (``risk_free`` is a per-period rate)."""
    r = _to_array(returns) - risk_free
    sd = np.std(r, ddof=1)
    if sd == 0:
        return 0.0
    return float(np.mean(r) / sd * np.sqrt(periods_per_year))

sortino_ratio

sortino_ratio(
    returns,
    risk_free: float = 0.0,
    periods_per_year: int = _PPY,
) -> float

Annualized Sortino ratio (downside-deviation denominator).

With no downside (zero denominator), returns +inf for positive mean excess return, -inf for negative, and 0 for a flat series — so a downside-free strategy ranks above one with drawdowns rather than tying at 0.

Source code in src/jaxfolio/backtest/metrics.py
def sortino_ratio(returns, risk_free: float = 0.0, periods_per_year: int = _PPY) -> float:
    """Annualized Sortino ratio (downside-deviation denominator).

    With no downside (zero denominator), returns ``+inf`` for positive mean
    excess return, ``-inf`` for negative, and ``0`` for a flat series — so a
    downside-free strategy ranks above one with drawdowns rather than tying at 0.
    """
    r = _to_array(returns) - risk_free
    downside = r[r < 0]
    dd = np.sqrt(np.mean(downside**2)) if downside.size else 0.0
    mean = float(np.mean(r)) if r.size else 0.0
    if dd == 0:
        return float(np.sign(mean) * np.inf) if mean != 0 else 0.0
    return float(mean / dd * np.sqrt(periods_per_year))

cumulative_returns

cumulative_returns(returns) -> ndarray

Cumulative wealth path starting from 1.0 (equity curve).

Source code in src/jaxfolio/backtest/metrics.py
def cumulative_returns(returns) -> np.ndarray:
    """Cumulative wealth path starting from 1.0 (equity curve)."""
    r = _to_array(returns)
    return np.cumprod(1.0 + r)

drawdown_series

drawdown_series(returns) -> ndarray

Drawdown at each point: equity / running_peak - 1 (<= 0).

Source code in src/jaxfolio/backtest/metrics.py
def drawdown_series(returns) -> np.ndarray:
    """Drawdown at each point: ``equity / running_peak - 1`` (<= 0)."""
    equity = cumulative_returns(returns)
    peak = np.maximum.accumulate(equity)
    return equity / peak - 1.0

max_drawdown

max_drawdown(returns) -> float

Maximum peak-to-trough drawdown (a negative number).

Source code in src/jaxfolio/backtest/metrics.py
def max_drawdown(returns) -> float:
    """Maximum peak-to-trough drawdown (a negative number)."""
    dd = drawdown_series(returns)
    return float(dd.min()) if dd.size else 0.0

calmar_ratio

calmar_ratio(
    returns, periods_per_year: int = _PPY
) -> float

Annualized return divided by the absolute max drawdown.

With no drawdown (zero denominator), returns +inf for a positive annualized return, -inf for negative, and 0 for flat — so a drawdown-free strategy is not mis-ranked as the worst performer.

Source code in src/jaxfolio/backtest/metrics.py
def calmar_ratio(returns, periods_per_year: int = _PPY) -> float:
    """Annualized return divided by the absolute max drawdown.

    With no drawdown (zero denominator), returns ``+inf`` for a positive
    annualized return, ``-inf`` for negative, and ``0`` for flat — so a
    drawdown-free strategy is not mis-ranked as the worst performer.
    """
    ann = annualized_return(returns, periods_per_year)
    mdd = abs(max_drawdown(returns))
    if mdd == 0:
        return float(np.sign(ann) * np.inf) if ann != 0 else 0.0
    return float(ann / mdd)

value_at_risk

value_at_risk(returns, alpha: float = 0.95) -> float

Historical Value-at-Risk at confidence alpha (a positive loss).

Source code in src/jaxfolio/backtest/metrics.py
def value_at_risk(returns, alpha: float = 0.95) -> float:
    """Historical Value-at-Risk at confidence ``alpha`` (a positive loss)."""
    r = _to_array(returns)
    if r.size == 0:
        return 0.0
    return float(-np.quantile(r, 1.0 - alpha))

conditional_value_at_risk

conditional_value_at_risk(
    returns, alpha: float = 0.95
) -> float

Historical CVaR / expected shortfall at confidence alpha.

Source code in src/jaxfolio/backtest/metrics.py
def conditional_value_at_risk(returns, alpha: float = 0.95) -> float:
    """Historical CVaR / expected shortfall at confidence ``alpha``."""
    r = _to_array(returns)
    if r.size == 0:
        return 0.0
    var = -value_at_risk(r, alpha)
    tail = r[r <= var]
    return float(-tail.mean()) if tail.size else float(-var)

hit_rate

hit_rate(returns) -> float

Fraction of periods with a positive return.

Source code in src/jaxfolio/backtest/metrics.py
def hit_rate(returns) -> float:
    """Fraction of periods with a positive return."""
    r = _to_array(returns)
    return float(np.mean(r > 0)) if r.size else 0.0

summary

summary(
    returns,
    risk_free: float = 0.0,
    periods_per_year: int = _PPY,
) -> dict[str, float]

Bundle the headline metrics into a single dict for reporting/plots.

Source code in src/jaxfolio/backtest/metrics.py
def summary(returns, risk_free: float = 0.0, periods_per_year: int = _PPY) -> dict[str, float]:
    """Bundle the headline metrics into a single dict for reporting/plots."""
    return {
        "annual_return": annualized_return(returns, periods_per_year),
        "annual_volatility": annualized_volatility(returns, periods_per_year),
        "sharpe": sharpe_ratio(returns, risk_free, periods_per_year),
        "sortino": sortino_ratio(returns, risk_free, periods_per_year),
        "max_drawdown": max_drawdown(returns),
        "calmar": calmar_ratio(returns, periods_per_year),
        "var_95": value_at_risk(returns, 0.95),
        "cvar_95": conditional_value_at_risk(returns, 0.95),
        "hit_rate": hit_rate(returns),
    }