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.

Two optional capabilities extend that contract for cost-aware strategies, both inert for optimizers that do not use them:

  • Holdings injection. An optimizer that declares a w_prev keyword is handed the current portfolio at each rebalance, so it can price the trade it is about to recommend instead of optimizing in a vacuum.
  • Trajectory execution. An optimizer that returns a :attr:~jaxfolio.types.PortfolioResult.trajectory has its planned weight path executed step by step over the following periods, paying cost on each step, rather than having every row but the first discarded.

Optimizer module-attribute

Optimizer = Callable[[pl.DataFrame], PortfolioResult]

The minimum optimizer contract: a trailing return window in, a result out.

A callable may additionally declare a keyword-only w_prev parameter, which :func:backtest fills with the currently held weights (see :func:_accepts_holdings). Note that vector does not necessarily sum to one — it is the all-zero flat book before the first rebalance, and drifts between rebalances thereafter — so a holdings-aware optimizer must not assume it is a valid portfolio.

BacktestResult dataclass

BacktestResult(
    name: str,
    returns: DataFrame,
    weights: DataFrame,
    turnover: DataFrame,
    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,
    holdings_aware: bool | None = None,
    follow_trajectory: bool = True,
) -> 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
holdings_aware bool | None

Whether to pass the current holdings to the optimizer as w_prev. None (default) auto-detects it from the optimizer's signature. True forces injection — use this for a lambda wrapper that hides the parameter, and note it raises TypeError if the optimizer does not accept it. False forces the plain single-argument call.

None
follow_trajectory bool

When the optimizer returns a :attr:PortfolioResult.trajectory, execute that planned path step by step over the periods after the rebalance, paying transaction cost on each step. False trades to the first row only and then drifts — the useful comparison arm for measuring what path-execution actually buys. Inert for optimizers returning no path.

True

Returns:

Type Description
BacktestResult

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

Notes

turnover is indexed by trading period. For an optimizer without a trajectory those are exactly the rebalance dates (unchanged behavior); when a path is being executed, the intermediate steps appear too.

Realized turnover here will not equal a multi-period optimizer's planned metadata["turnover_path"]: the plan assumes no drift, whereas the engine lets weights drift with returns and then trades from the drifted book.

Source code in src/jaxfolio/backtest/engine.py
def backtest(
    returns: pl.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,
    holdings_aware: bool | None = None,
    follow_trajectory: bool = True,
) -> 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.
    holdings_aware:
        Whether to pass the current holdings to the optimizer as ``w_prev``.
        ``None`` (default) auto-detects it from the optimizer's signature.
        ``True`` forces injection — use this for a ``lambda`` wrapper that hides
        the parameter, and note it raises ``TypeError`` if the optimizer does not
        accept it. ``False`` forces the plain single-argument call.
    follow_trajectory:
        When the optimizer returns a :attr:`PortfolioResult.trajectory`, execute
        that planned path step by step over the periods after the rebalance,
        paying transaction cost on each step. ``False`` trades to the first row
        only and then drifts — the useful comparison arm for measuring what
        path-execution actually buys. Inert for optimizers returning no path.

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

    Notes
    -----
    ``turnover`` is indexed by *trading* period. For an optimizer without a
    trajectory those are exactly the rebalance dates (unchanged behavior); when a
    path is being executed, the intermediate steps appear too.

    Realized turnover here will not equal a multi-period optimizer's planned
    ``metadata["turnover_path"]``: the plan assumes no drift, whereas the engine
    lets weights drift with returns and then trades from the drifted book.
    """
    assets = asset_columns(returns)
    n = len(assets)
    date_col = date_column(returns)
    dates = returns.get_column(date_col).to_list() if date_col else list(range(returns.height))
    ret_vals = returns.select(assets).to_numpy()

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

    pass_holdings = _accepts_holdings(optimizer) if holdings_aware is None else holdings_aware
    traj: np.ndarray | None = None  # planned path still awaiting execution
    step = 0  # next row of ``traj`` to trade into

    for t in range(lookback, len(returns)):
        target: np.ndarray | None = None

        # Rebalance at the cadence; otherwise execute a planned step, else drift.
        if (t - lookback) % rebalance_every == 0:
            window = returns.slice(t - lookback, lookback)
            result = (
                optimizer(window, w_prev=weights.copy()) if pass_holdings else optimizer(window)
            )
            # Row 0 of any trajectory *is* ``result.weights`` by contract, so the
            # rebalance-period trade is identical with or without a path.
            target = _align_weights(result, assets)
            traj = _align_trajectory(result, assets) if follow_trajectory else None
            step = 1
            if traj is not None and traj.shape[0] <= 1:
                traj = None  # nothing left to execute
        elif traj is not None and step < traj.shape[0]:
            # Trade from the *drifted* book toward the planned row: the drift
            # happened over the period, the trade happens now, so the turnover
            # actually paid is |row_k - drifted|, not |row_k - row_{k-1}|.
            target = traj[step]
            step += 1
            if step >= traj.shape[0]:
                traj = None  # path exhausted -> drift until the next rebalance

        if target is not None:
            turn = float(np.abs(target - weights).sum())
            cost = transaction_cost * turn
            turnover_hist[dates[t]] = turn
            weights = target
        else:
            cost = 0.0
        total_cost += cost

        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

    result_name = name or "strategy"
    result_dates = dates[lookback:]
    ret_data = {result_name: strat_returns[lookback:]}
    if date_col:
        ret_data = {date_col: result_dates, **ret_data}
    ret_series = pl.DataFrame(ret_data)
    hist_dates = list(weight_hist)
    hist_values = np.vstack(list(weight_hist.values())) if weight_hist else np.empty((0, n))
    weight_data = dict(zip(assets, hist_values.T, strict=True))
    if date_col:
        weight_data = {date_col: hist_dates, **weight_data}
    w_df = pl.DataFrame(weight_data)
    turn_dates = list(turnover_hist)
    turn_data = {"turnover": list(turnover_hist.values())}
    if date_col:
        turn_data = {date_col: turn_dates, **turn_data}
    to_series = pl.DataFrame(turn_data)

    summary = M.summary(ret_series, risk_free=risk_free, periods_per_year=periods_per_year)
    summary["avg_turnover"] = (
        float(to_series.get_column("turnover").mean()) if len(to_series) else 0.0
    )
    summary["total_cost"] = float(total_cost)
    return BacktestResult(
        name=result_name,
        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: pl.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]) -> pl.DataFrame:
    """Assemble a tidy metrics comparison table across backtest results."""
    return pl.DataFrame([{"strategy": name, **res.metrics} for name, res in results.items()])

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),
    }