"""A vectorbt backtester of given target weights, its market conventions in its config.
``WeightsVectorBt`` is the backtester ``quantlab.api.backtest`` runs: a caller's frame
names its own price columns and its own market, so the ``MarketSpec`` comes from the
``WeightsBacktestConfig`` rather than from a class constant. It has no signal rule of its
own and takes no model; its only entry point is ``run_weights()``. A run given an
``output_dir`` writes the usual run directory (``quantlab.runs.backtest_run``), whose
recipe names this class with the market fields in its config and whose copy of a price
or benchmark ``FrameDataset`` is named relative to the run directory. The directory is
therefore self-contained: ``BacktestRun.open(run_dir).rebuild_backtester()`` rebuilds
the backtester, and ``run_weights`` given the run's ``weights()`` replays the run.
"""
import xarray as xr
from quantlab.backtest.engine_vectorbt import VectorBtBacktester
from quantlab.backtest.base import MarketSpec
from quantlab.backtest.config import WeightsBacktestConfig
[docs]
class WeightsVectorBt(VectorBtBacktester):
"""Backtest given target weights with vectorbt, on the market its config describes.
``MARKET`` is built from the config's ``fill_price_column``,
``valuation_price_column``, ``trading_days_per_year`` and
``session_minutes_per_day`` whenever a config is assigned. Everything else is
``VectorBtBacktester``'s: a weight formed at bar t fills at bar t+1's fill price,
and the metrics cover the whole window.
Parameters
----------
config : WeightsBacktestConfig
The price dataset, window, costs, optional benchmark and market conventions;
``model`` and ``model_mode`` must be ``None``.
Raises
------
TypeError
If ``config`` is not a ``WeightsBacktestConfig``.
ValueError
If the config carries a model, a price column name is empty, the day or session
length is below 1, or the recorded ``top_n`` or ``direction`` is invalid.
Examples
--------
>>> import numpy as np
>>> import pandas as pd
>>> import xarray as xr
>>> from quantlab.backtest.predefined.weights import WeightsVectorBt
>>> from quantlab.backtest.config import WeightsBacktestConfig
>>> from quantlab.dataset.memory import FrameDataset
>>> bars = pd.bdate_range("2024-01-01", periods=5)
>>> prices = FrameDataset(pd.DataFrame({
... "timestamp": np.repeat(bars, 2),
... "symbol": ["AAA", "BBB"] * 5,
... "open": [10.0, 20.0, 11.0, 20.0, 12.0, 21.0, 12.0, 22.0, 13.0, 22.0],
... "close": [10.5, 20.0, 11.5, 20.5, 12.0, 21.5, 12.5, 22.0, 13.0, 22.5],
... }))
>>> backtester = WeightsVectorBt(WeightsBacktestConfig(
... price_dataset=prices, start_date="2024-01-01", end_date="2024-01-05",
... output_dir=None, rebalance_periods=1, fees=0.0, slippage=0.0,
... fill_price_column="open", valuation_price_column="close",
... trading_days_per_year=252, session_minutes_per_day=390,
... ))
>>> backtester.MARKET.fill_price_column
'open'
>>> weights = xr.DataArray(
... [[1.0, 0.0]] + [[np.nan, np.nan]] * 4, dims=("timestamp", "symbol"),
... coords={"timestamp": bars, "symbol": ["AAA", "BBB"]},
... )
>>> result = backtester.run_weights(weights)
>>> result.simulation.value.values.round(2).tolist()
[1000000.0, 1045454.55, 1090909.09, 1136363.64, 1181818.18]
Kept on disk, the run rebuilds from its directory and replays its weights:
>>> import dataclasses, tempfile
>>> from quantlab.runs.backtest_run import BacktestRun
>>> kept = WeightsVectorBt(
... dataclasses.replace(backtester.config, output_dir=tempfile.mkdtemp())
... ).run_weights(weights)
>>> run = BacktestRun.open(kept.run_dir)
>>> replay = run.rebuild_backtester().run_weights(run.weights())
>>> replay.simulation.value.values.round(2).tolist()
[1000000.0, 1045454.55, 1090909.09, 1136363.64, 1181818.18]
"""
config_cls = WeightsBacktestConfig
def __init__(self, config: WeightsBacktestConfig):
"""Build ``MARKET`` from ``config``, then assign it; see the class docstring."""
# The config setter refuses a backtester without a MARKET before it
# validates anything, so the spec must exist before the first
# assignment. A config of the wrong class is left to the setter's type check.
if isinstance(config, WeightsBacktestConfig):
self.MARKET = self._market_of(config)
super().__init__(config)
@staticmethod
def _market_of(config: WeightsBacktestConfig) -> MarketSpec:
"""Return the market conventions ``config`` describes."""
return MarketSpec(
fill_price_column=config.fill_price_column,
valuation_price_column=config.valuation_price_column,
trading_days_per_year=config.trading_days_per_year,
session_minutes_per_day=config.session_minutes_per_day,
)
def _validate_config(self) -> None:
"""Refuse a model and invalid market fields, then rebuild ``MARKET``.
Raises
------
ValueError
See the class docstring.
"""
config = self.config
if config.model is not None:
raise ValueError(
f"{self.class_name} backtests given weights only, through "
f"run_weights(); config.model must be None, got "
f"{type(config.model).__name__}"
)
for name in ("fill_price_column", "valuation_price_column"):
value = getattr(config, name)
if not isinstance(value, str) or not value:
raise ValueError(
f"{self.class_name}: {name} must be a non-empty column name, got "
f"{value!r}"
)
for name in ("trading_days_per_year", "session_minutes_per_day"):
value = getattr(config, name)
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
raise ValueError(
f"{self.class_name}: {name} must be an integer >= 1, got {value!r}"
)
if config.direction not in (None, "long_only", "long_short"):
raise ValueError(
f"{self.class_name}: direction must be 'long_only', 'long_short' or "
f"None, got {config.direction!r}"
)
top_n = config.top_n
if top_n is not None and (
isinstance(top_n, bool) or not isinstance(top_n, int) or top_n < 1
):
raise ValueError(
f"{self.class_name}: top_n must be an integer >= 1 or None, got {top_n!r}"
)
self.MARKET = self._market_of(config)
def _generate_signals(
self, predictions: xr.Dataset, prices: xr.Dataset, delisted: xr.DataArray
) -> xr.Dataset:
"""Refuse: this backtester has no signal rule; weights come to ``run_weights``.
Raises
------
ValueError
Always.
"""
raise ValueError(
f"{self.class_name} turns no predictions into weights; backtest given "
f"weights with run_weights()"
)