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_prevkeyword 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.trajectoryhas 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: |
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.001
|
holdings_aware
|
bool | None
|
Whether to pass the current holdings to the optimizer as |
None
|
follow_trajectory
|
bool
|
When the optimizer returns a :attr: |
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
75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 | |
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
metrics_table
¶
metrics_table(
results: dict[str, BacktestResult],
) -> DataFrame
Assemble a tidy metrics comparison table across backtest results.
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
¶
Geometric annualized return (CAGR) of a periodic return series.
Source code in src/jaxfolio/backtest/metrics.py
annualized_volatility
¶
Annualized standard deviation of returns.
sharpe_ratio
¶
Annualized Sharpe ratio (risk_free is a per-period rate).
Source code in src/jaxfolio/backtest/metrics.py
sortino_ratio
¶
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
cumulative_returns
¶
drawdown_series
¶
Drawdown at each point: equity / running_peak - 1 (<= 0).
max_drawdown
¶
calmar_ratio
¶
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
value_at_risk
¶
Historical Value-at-Risk at confidence alpha (a positive loss).
conditional_value_at_risk
¶
Historical CVaR / expected shortfall at confidence alpha.
Source code in src/jaxfolio/backtest/metrics.py
hit_rate
¶
summary
¶
Bundle the headline metrics into a single dict for reporting/plots.