Skip to content

bbstrader.btengine

Event-driven backtesting engine, plus the research and realism toolkit: execution friction models, vectorized research fast-path, optimization/walk-forward, overfitting diagnostics, risk analytics, data catalog, strategy templates, multi-timeframe resampling, and the experiment store.

btengine

Overview

This Backtesting Module provides a comprehensive suite of tools to test trading strategies in an event-driven system. It simulates the execution of trades in historical market conditions to evaluate the performance of trading strategies before applying them in live trading environments. Designed with modularity and extensibility in mind, it caters to both novices and experts in algorithmic trading.

Features

  • Event-Driven Architecture: Processes market data, generates signals, executes orders, and manages portfolio updates in response to events, closely mimicking live trading environments.
  • Historical Market Data Support: Utilizes historical OHLCV data from CSV files, Yahoo finance and MT5 terminal allowing for the testing of strategies over various market conditions and time frames.
  • Performance Metrics Calculation: Includes tools for calculating key performance indicators, such as Sharpe Ratio, Sortino Ratio, and drawdowns, to evaluate the effectiveness of trading strategies.
  • Visualization: Generates plots of the equity curve, returns, drawdowns, and other metrics for comprehensive strategy performance analysis.

Components

  • BacktestEgine: Orchestrates the backtesting process, managing events and invoking components.
  • Event: Abstract class for events, with implementations for market data, signals, fill and order events.
  • DataHandler: Abstract class for market data handling, with an implementation for CSVDataHandler, MT5DataHandler, YFDataHandler. We will add another data handling in the future such as MacroEconomic Data, Fundamental Data, TICK Data and Real-time Data.
  • Portfolio: Manages positions and calculates performance metrics, responding to market data and signals.
  • ExecutionHandler: Abstract class for order execution, with a simulated execution handler provided with an implementation for SimExecutionHandler.
  • Performance: Utility functions for calculating performance metrics and visualizing strategy performance.

Examples

from bbstrader.btengine import run_backtest from datetime import datetime run_backtest( ... symbol_list=['AAPL', 'GOOG'], ... start_date=datetime(2020, 1, 1), ... data_handler=DataHandler, ... strategy=Strategy, ... exc_handler=ExecutionHandler, ... initial_capital=500000.0, ... heartbeat=1.0 ... )

Notes

See bbstrader.btengine.backtest.run_backtest for more details on the backtesting process and its parameters.

MonteCarloResult dataclass

MonteCarloResult(terminal_returns: NDArray[float64], bands: Dict[str, NDArray[float64]], horizon: int)

Monte Carlo simulated terminal-return distribution and equity bands.

mean_terminal property

mean_terminal: float

The mean simulated terminal return.

prob_loss property

prob_loss: float

The simulated probability of a negative terminal return.

quantile

quantile(q: float) -> float

Return the q-quantile of the simulated terminal returns.

Parameters:

Name Type Description Default
q float

Quantile in the interval [0, 1].

required

Returns:

Name Type Description
float float

The terminal return at quantile q.

Source code in src/bbstrader/btengine/analytics.py
def quantile(self, q: float) -> float:
    """Return the ``q``-quantile of the simulated terminal returns.

    Args:
        q (float): Quantile in the interval [0, 1].

    Returns:
        float: The terminal return at quantile ``q``.
    """
    return float(np.quantile(self.terminal_returns, q))

BacktestEngine

BacktestEngine(symbol_list: List[str], initial_capital: float, heartbeat: float, start_date: datetime, data_handler: Type[DataHandler], execution_handler: Type[ExecutionHandler], strategy: Type[Strategy], /, **kwargs: Any)

The BacktestEngine() object encapsulates the event-handling logic and essentially ties together all of the other classes.

The BacktestEngine object is designed to carry out a nested while-loop event-driven system in order to handle the events placed on the Event Queue object. The outer while-loop is known as the "heartbeat loop" and decides the temporal resolution of the backtesting system. In a live environment this value will be a positive number, such as 600 seconds (every ten minutes). Thus the market data and positions will only be updated on this timeframe.

For the backtester described here the "heartbeat" can be set to zero, irrespective of the strategy frequency, since the data is already available by virtue of the fact it is historical! We can run the backtest at whatever speed we like, since the event-driven system is agnostic to when the data became available, so long as it has an associated timestamp.

The inner while-loop actually processes the signals and sends them to the correct component depending upon the event type. Thus the Event Queue is continually being populated and depopulated with events. This is what it means for a system to be event-driven.

The initialisation of the BacktestEngine object requires the full symbol list of traded symbols, the initial capital, the heartbeat time in milliseconds, the start datetime stamp of the backtest as well as the DataHandler, ExecutionHandler, Strategy objects and additionnal kwargs based on the ExecutionHandler, the DataHandler, and the Strategy used.

A Queue is used to hold the events. The signals, orders and fills are counted. For a MarketEvent, the Strategy object is told to recalculate new signals, while the Portfolio object is told to reindex the time. If a SignalEvent object is received the Portfolio is told to handle the new signal and convert it into a set of OrderEvents, if appropriate. If an OrderEvent is received the ExecutionHandler is sent the order to be transmitted to the broker (if in a real trading setting). Finally, if a FillEvent is received, the Portfolio will update itself to be aware of the new positions.

Initialises the backtest.

Parameters:

Name Type Description Default
symbol_list List[str]

The list of symbol strings.

required
intial_capital float

The starting capital for the portfolio.

required
heartbeat float

Backtest "heartbeat" in seconds

required
start_date datetime

The start datetime of the strategy.

required
data_handler (DataHandler)

Handles the market data feed.

required
execution_handler (ExecutionHandler)

Handles the orders/fills for trades.

required
strategy Strategy

Generates signals based on market data.

required
kwargs

Additional parameters based on the ExecutionHandler, the DataHandler, the Strategy used and the Portfolio. - show_equity (bool): Show the equity curve of the portfolio. - stats_file (str): File to save the summary stats.

required
Source code in src/bbstrader/btengine/backtest.py
def __init__(
    self,
    symbol_list: List[str],
    initial_capital: float,
    heartbeat: float,
    start_date: datetime,
    data_handler: Type[DataHandler],
    execution_handler: Type[ExecutionHandler],
    strategy: Type[Strategy],
    /,
    **kwargs: Any,
) -> None:
    """
    Initialises the backtest.

    Args:
        symbol_list (List[str]): The list of symbol strings.
        intial_capital (float): The starting capital for the portfolio.
        heartbeat (float): Backtest "heartbeat" in seconds
        start_date (datetime): The start datetime of the strategy.
        data_handler (DataHandler) : Handles the market data feed.
        execution_handler (ExecutionHandler) : Handles the orders/fills for trades.
        strategy (Strategy): Generates signals based on market data.
        kwargs : Additional parameters based on the `ExecutionHandler`,
            the `DataHandler`, the `Strategy` used and the `Portfolio`.
            - show_equity (bool): Show the equity curve of the portfolio.
            - stats_file (str): File to save the summary stats.
    """
    self.symbol_list = symbol_list
    self.initial_capital = initial_capital
    self.heartbeat = heartbeat
    self.start_date = start_date

    self.dh_cls = data_handler
    self.eh_cls = execution_handler
    self.strategy_cls = strategy
    self.kwargs = kwargs

    self.events: "queue.Queue[Events]" = queue.Queue()

    self.signals = 0
    self.orders = 0
    self.fills = 0

    self._generate_trading_instances()
    self.show_equity = kwargs.get("show_equity", False)
    self.stats_file = kwargs.get("stats_file", None)

simulate_trading

simulate_trading() -> pd.DataFrame

Simulates the backtest and outputs portfolio performance.

Returns:

Type Description
DataFrame

pd.DataFrame: The portfilio values over time (capital, equity, returns etc.)

Source code in src/bbstrader/btengine/backtest.py
def simulate_trading(self) -> pd.DataFrame:
    """
    Simulates the backtest and outputs portfolio performance.

    Returns:
        pd.DataFrame: The portfilio values over time (capital, equity, returns etc.)
    """
    self._run_backtest()
    self._output_performance()
    return self.portfolio.equity_curve

DataCatalog

DataCatalog(base_dir: Optional[str] = None, fmt: str = 'auto')

A local OHLCV cache keyed by (source, symbol, timeframe).

Parameters:

Name Type Description Default
base_dir Optional[str]

Root directory for the store. Defaults to ~/.bbstrader/data/catalog.

None
fmt str

"parquet", "csv", or "auto" (Parquet when pyarrow is installed, else CSV).

'auto'

Initialise the catalog and ensure its base directory exists.

Parameters:

Name Type Description Default
base_dir Optional[str]

Root directory for the store. Defaults to ~/.bbstrader/data/catalog.

None
fmt str

One of "parquet", "csv" or "auto" (Parquet when pyarrow is installed, else CSV).

'auto'

Raises:

Type Description
ValueError

If fmt is not one of the accepted values.

Source code in src/bbstrader/btengine/catalog.py
def __init__(self, base_dir: Optional[str] = None, fmt: str = "auto") -> None:
    """Initialise the catalog and ensure its base directory exists.

    Args:
        base_dir (Optional[str]): Root directory for the store. Defaults to
            ``~/.bbstrader/data/catalog``.
        fmt (str): One of ``"parquet"``, ``"csv"`` or ``"auto"`` (Parquet
            when pyarrow is installed, else CSV).

    Raises:
        ValueError: If ``fmt`` is not one of the accepted values.
    """
    if fmt not in ("auto", "parquet", "csv"):
        raise ValueError(f"fmt must be 'auto', 'parquet' or 'csv', got {fmt!r}.")
    self.base_dir = Path(base_dir or BBSTRADER_DIR / "data" / "catalog")
    self.base_dir.mkdir(parents=True, exist_ok=True)
    self._fmt = fmt

fmt property

fmt: str

The effective storage format after resolving auto.

has

has(source: str, symbol: str, timeframe: str) -> bool

Return True if a cached dataset exists for the key.

Source code in src/bbstrader/btengine/catalog.py
def has(self, source: str, symbol: str, timeframe: str) -> bool:
    """Return True if a cached dataset exists for the key."""
    return self._data_path(source, symbol, timeframe).exists()

metadata

metadata(source: str, symbol: str, timeframe: str) -> Optional[Dict[str, Any]]

Return the stored metadata for the key, or None if absent.

Source code in src/bbstrader/btengine/catalog.py
def metadata(
    self, source: str, symbol: str, timeframe: str
) -> Optional[Dict[str, Any]]:
    """Return the stored metadata for the key, or None if absent."""
    meta_path = self._meta_path(source, symbol, timeframe)
    if not meta_path.exists():
        return None
    return json.loads(meta_path.read_text())

is_fresh

is_fresh(source: str, symbol: str, timeframe: str, max_age_days: Optional[float] = None) -> bool

Return True if the cached dataset exists and is within max_age_days.

A max_age_days of None means "never expires" (any cached copy is fresh); a value of 0 (or negative) means the cache is always stale, independent of clock resolution.

Source code in src/bbstrader/btengine/catalog.py
def is_fresh(
    self,
    source: str,
    symbol: str,
    timeframe: str,
    max_age_days: Optional[float] = None,
) -> bool:
    """Return True if the cached dataset exists and is within ``max_age_days``.

    A ``max_age_days`` of None means "never expires" (any cached copy is
    fresh); a value of 0 (or negative) means the cache is always stale,
    independent of clock resolution.
    """
    if not self.has(source, symbol, timeframe):
        return False
    if max_age_days is None:
        return True
    # A zero/negative budget means "always reload". Handle it explicitly so
    # the result does not hinge on sub-millisecond timestamp resolution
    # (Windows clocks can read an age of exactly 0 for a just-written file).
    if max_age_days <= 0:
        return False
    meta = self.metadata(source, symbol, timeframe)
    if not meta or "fetched_at" not in meta:
        return False
    fetched_at = datetime.fromisoformat(meta["fetched_at"])
    age = datetime.now(timezone.utc) - fetched_at
    return age.total_seconds() <= max_age_days * 86400.0

get

get(source: str, symbol: str, timeframe: str) -> Optional[pd.DataFrame]

Load a cached dataset, or None if it is not present.

Source code in src/bbstrader/btengine/catalog.py
def get(self, source: str, symbol: str, timeframe: str) -> Optional[pd.DataFrame]:
    """Load a cached dataset, or None if it is not present."""
    path = self._data_path(source, symbol, timeframe)
    if not path.exists():
        return None
    if self.fmt == "parquet":
        return pd.read_parquet(path)
    return pd.read_csv(path, index_col=0, parse_dates=True)

put

put(df: DataFrame, source: str, symbol: str, timeframe: str, extra_meta: Optional[Dict[str, Any]] = None) -> Path

Persist df for the key and write a metadata sidecar.

Source code in src/bbstrader/btengine/catalog.py
def put(
    self,
    df: pd.DataFrame,
    source: str,
    symbol: str,
    timeframe: str,
    extra_meta: Optional[Dict[str, Any]] = None,
) -> Path:
    """Persist ``df`` for the key and write a metadata sidecar."""
    path = self._data_path(source, symbol, timeframe)
    if self.fmt == "parquet":
        df.to_parquet(path)
    else:
        df.to_csv(path)
    index = df.index
    meta: Dict[str, Any] = {
        "source": source,
        "symbol": symbol,
        "timeframe": timeframe,
        "rows": int(len(df)),
        "format": self.fmt,
        "fetched_at": datetime.now(timezone.utc).isoformat(),
        "start": str(index.min()) if len(index) else None,
        "end": str(index.max()) if len(index) else None,
    }
    if extra_meta:
        meta.update(extra_meta)
    self._meta_path(source, symbol, timeframe).write_text(
        json.dumps(meta, indent=2)
    )
    return path

fetch

fetch(loader: Callable[[], DataFrame], source: str, symbol: str, timeframe: str = 'D1', max_age_days: Optional[float] = None, force: bool = False, extra_meta: Optional[Dict[str, Any]] = None) -> pd.DataFrame

Return cached data if fresh, otherwise call loader and cache it.

Parameters:

Name Type Description Default
loader Callable[[], DataFrame]

Zero-argument callable returning a normalized OHLCV DataFrame (only called on a cache miss or when force is set).

required
source str

Logical source name (e.g. "yfinance").

required
symbol str

Instrument symbol.

required
timeframe str

Bar timeframe/period label used in the cache key.

'D1'
max_age_days Optional[float]

Maximum acceptable cache age; None means never expires.

None
force bool

Bypass the cache and always reload.

False
Source code in src/bbstrader/btengine/catalog.py
def fetch(
    self,
    loader: Callable[[], pd.DataFrame],
    source: str,
    symbol: str,
    timeframe: str = "D1",
    max_age_days: Optional[float] = None,
    force: bool = False,
    extra_meta: Optional[Dict[str, Any]] = None,
) -> pd.DataFrame:
    """Return cached data if fresh, otherwise call ``loader`` and cache it.

    Args:
        loader: Zero-argument callable returning a normalized OHLCV DataFrame
            (only called on a cache miss or when ``force`` is set).
        source: Logical source name (e.g. ``"yfinance"``).
        symbol: Instrument symbol.
        timeframe: Bar timeframe/period label used in the cache key.
        max_age_days: Maximum acceptable cache age; None means never expires.
        force: Bypass the cache and always reload.
    """
    if not force and self.is_fresh(source, symbol, timeframe, max_age_days):
        cached = self.get(source, symbol, timeframe)
        if cached is not None:
            return cached
    df = loader()
    self.put(df, source, symbol, timeframe, extra_meta=extra_meta)
    return df

list_datasets

list_datasets() -> List[Dict[str, Any]]

Return metadata for every dataset currently in the store.

Source code in src/bbstrader/btengine/catalog.py
def list_datasets(self) -> List[Dict[str, Any]]:
    """Return metadata for every dataset currently in the store."""
    out: List[Dict[str, Any]] = []
    for meta_file in sorted(self.base_dir.glob("*.meta.json")):
        try:
            out.append(json.loads(meta_file.read_text()))
        except (OSError, json.JSONDecodeError):
            continue
    return out

DataHandler

One of the goals of an event-driven trading system is to minimise duplication of code between the backtesting element and the live execution element. Ideally it would be optimal to utilise the same signal generation methodology and portfolio management components for both historical testing and live trading. In order for this to work the Strategy object which generates the Signals, and the Portfolio object which provides Orders based on them, must utilise an identical interface to a market feed for both historic and live running.

This motivates the concept of a class hierarchy based on a DataHandler object, which givesall subclasses an interface for providing market data to the remaining components within thesystem. In this way any subclass data handler can be "swapped out", without affecting strategy or portfolio calculation.

Specific example subclasses could include HistoricCSVDataHandler, YFinanceDataHandler, FMPDataHandler, IBMarketFeedDataHandler etc.

symbols property

symbols: List[str]

The list of symbols this handler serves.

data property

data: Dict[str, DataFrame]

The loaded market data, keyed by symbol.

labels property

labels: List[str]

The OHLCV column labels exposed by the handler.

index property

index: Union[str, List[str]]

The name(s) of the datetime index column(s).

get_latest_bar abstractmethod

get_latest_bar(symbol: str) -> pd.Series

Returns the last bar updated.

Source code in src/bbstrader/btengine/data.py
@abstractmethod
def get_latest_bar(self, symbol: str) -> pd.Series:
    """
    Returns the last bar updated.
    """
    raise NotImplementedError("Should implement get_latest_bar()")

get_latest_bars abstractmethod

get_latest_bars(symbol: str, N: int = 1, df: bool = True) -> Union[pd.DataFrame, List[pd.Series]]

Returns the last N bars updated.

Source code in src/bbstrader/btengine/data.py
@abstractmethod
def get_latest_bars(
    self, symbol: str, N: int = 1, df: bool = True
) -> Union[pd.DataFrame, List[pd.Series]]:
    """
    Returns the last N bars updated.
    """
    raise NotImplementedError("Should implement get_latest_bars()")

get_latest_bar_datetime abstractmethod

get_latest_bar_datetime(symbol: str) -> Union[datetime, pd.Timestamp]

Returns a Python datetime object for the last bar.

Source code in src/bbstrader/btengine/data.py
@abstractmethod
def get_latest_bar_datetime(self, symbol: str) -> Union[datetime, pd.Timestamp]:
    """
    Returns a Python datetime object for the last bar.
    """
    raise NotImplementedError("Should implement get_latest_bar_datetime()")

get_latest_bar_value abstractmethod

get_latest_bar_value(symbol: str, val_type: str) -> float

Returns one of the Open, High, Low, Close, Adj Close, Volume or Returns from the last bar.

Source code in src/bbstrader/btengine/data.py
@abstractmethod
def get_latest_bar_value(self, symbol: str, val_type: str) -> float:
    """
    Returns one of the Open, High, Low, Close, Adj Close, Volume or Returns
    from the last bar.
    """
    raise NotImplementedError("Should implement get_latest_bar_value()")

get_latest_bars_values abstractmethod

get_latest_bars_values(symbol: str, val_type: str, N: int = 1) -> NDArray

Returns the last N bar values from the latest_symbol list, or N-k if less available.

Source code in src/bbstrader/btengine/data.py
@abstractmethod
def get_latest_bars_values(self, symbol: str, val_type: str, N: int = 1) -> NDArray:
    """
    Returns the last N bar values from the
    latest_symbol list, or N-k if less available.
    """
    raise NotImplementedError("Should implement get_latest_bars_values()")

update_bars abstractmethod

update_bars() -> None

Pushes the latest bars to the bars_queue for each symbol in a tuple OHLCVI format: (datetime, Open, High, Low, Close, Adj Close, Volume, Retruns).

Source code in src/bbstrader/btengine/data.py
@abstractmethod
def update_bars(self) -> None:
    """
    Pushes the latest bars to the bars_queue for each symbol
    in a tuple OHLCVI format: (datetime, Open, High, Low,
    Close, Adj Close, Volume, Retruns).
    """
    raise NotImplementedError("Should implement update_bars()")

CSVDataHandler

CSVDataHandler(events: Queue[MarketEvent], symbol_list: List[str], **kwargs: Any)

Bases: BaseCSVDataHandler

CSVDataHandler is designed to read CSV files for each requested symbol from disk and provide an interface to obtain the "latest" bar in a manner identical to a live trading interface.

This class is useful when you have your own data or you want to cutomize specific data in some form based on your Strategy() .

Initialises the historic data handler by requesting the location of the CSV files and a list of symbols. It will be assumed that all files are of the form symbol.csv, where symbol is a string in the list.

Parameters:

Name Type Description Default
events Queue

The Event Queue.

required
symbol_list List[str]

A list of symbol strings.

required
csv_dir str

Absolute directory path to the CSV files.

required

NOTE: All csv fille can be stored in 'Home/.bbstrader/data/csv_data'

Source code in src/bbstrader/btengine/data.py
def __init__(
    self, events: "Queue[MarketEvent]", symbol_list: List[str], **kwargs: Any
) -> None:
    """
    Initialises the historic data handler by requesting
    the location of the CSV files and a list of symbols.
    It will be assumed that all files are of the form
    `symbol.csv`, where `symbol` is a string in the list.

    Args:
        events (Queue): The Event Queue.
        symbol_list (List[str]): A list of symbol strings.
        csv_dir (str): Absolute directory path to the CSV files.

    NOTE:
    All csv fille can be stored in 'Home/.bbstrader/data/csv_data'

    """
    csv_dir = kwargs.get("csv_dir")
    csv_dir = csv_dir or BBSTRADER_DIR / "data" / "csv_data"
    super().__init__(
        events,
        symbol_list,
        str(csv_dir),
        columns=kwargs.get("columns"),
        index_col=kwargs.get("index_col", 0),
        # csv_dir is the user's own data directory; never rewrite it.
        persist_normalized=False,
    )

MT5DataHandler

MT5DataHandler(events: Queue[MarketEvent], symbol_list: List[str], **kwargs: Any)

Bases: BaseCSVDataHandler

Downloads historical data from MetaTrader 5 (MT5) and provides an interface for accessing this data bar-by-bar, simulating a live market feed for backtesting.

Data is downloaded from MT5, saved as CSV files, and then loaded using the functionality inherited from BaseCSVDataHandler.

This class is useful when you need to get data from specific broker for different time frames.

Parameters:

Name Type Description Default
events Queue

The Event Queue for passing market events.

required
symbol_list List[str]

A list of symbol strings to download data for.

required
**kwargs Any

Keyword arguments for data retrieval: time_frame (str): MT5 time frame (e.g., 'D1' for daily). mt5_start (datetime): Start date for historical data. mt5_end (datetime): End date for historical data. data_dir (str): Directory for storing data .

{}
Note

Requires a working connection to an MT5 terminal. See bbstrader.metatrader.rates.Rates for other arguments. See bbstrader.btengine.data.BaseCSVDataHandler for other arguments.

Source code in src/bbstrader/btengine/data.py
def __init__(
    self, events: "Queue[MarketEvent]", symbol_list: List[str], **kwargs: Any
) -> None:
    """
    Args:
        events (Queue): The Event Queue for passing market events.
        symbol_list (List[str]): A list of symbol strings to download data for.
        **kwargs: Keyword arguments for data retrieval:
            time_frame (str): MT5 time frame (e.g., 'D1' for daily).
            mt5_start (datetime): Start date for historical data.
            mt5_end (datetime): End date for historical data.
            data_dir (str): Directory for storing data .

    Note:
        Requires a working connection to an MT5 terminal.
        See `bbstrader.metatrader.rates.Rates` for other arguments.
        See `bbstrader.btengine.data.BaseCSVDataHandler` for other arguments.
    """
    self.tf = kwargs.get("time_frame", "D1")
    self.start = kwargs.get("mt5_start", datetime(2000, 1, 1))
    self.end = kwargs.get("mt5_end", datetime.now())
    self.use_utc = kwargs.get("use_utc", False)
    self.filer = kwargs.get("filter", False)
    self.fill_na = kwargs.get("fill_na", False)
    self.lower_cols = kwargs.get("lower_cols", True)
    self.data_dir = kwargs.get("data_dir")
    self.use_cache = kwargs.get("use_cache", False)
    self.cache_max_age_days = kwargs.get("cache_max_age_days")
    self.symbol_list = symbol_list
    self.kwargs = kwargs
    self.kwargs["backtest"] = (
        True  # Ensure backtest mode is set to avoid InvalidBroker errors
    )

    csv_dir = self._download_and_cache_data(self.data_dir)
    super().__init__(
        events,
        symbol_list,
        str(csv_dir),
        columns=kwargs.get("columns"),
        index_col=kwargs.get("index_col", 0),
    )

YFDataHandler

YFDataHandler(events: Queue[MarketEvent], symbol_list: List[str], **kwargs: Any)

Bases: BaseCSVDataHandler

Downloads historical data from Yahoo Finance and provides an interface for accessing this data bar-by-bar, simulating a live market feed for backtesting.

Data is fetched using the yfinance library and optionally cached to disk to speed up subsequent runs.

This class is useful when working with historical daily prices.

Parameters:

Name Type Description Default
events Queue

The Event Queue for passing market events.

required
symbol_list list[str]

List of symbols to download data for.

required
yf_start str

Start date for historical data (YYYY-MM-DD).

required
yf_end str

End date for historical data (YYYY-MM-DD).

required
data_dir str

Directory for caching data .

required
Note

See bbstrader.btengine.data.BaseCSVDataHandler for other arguments.

Source code in src/bbstrader/btengine/data.py
def __init__(
    self, events: "Queue[MarketEvent]", symbol_list: List[str], **kwargs: Any
) -> None:
    """
    Args:
        events (Queue): The Event Queue for passing market events.
        symbol_list (list[str]): List of symbols to download data for.
        yf_start (str): Start date for historical data (YYYY-MM-DD).
        yf_end (str): End date for historical data (YYYY-MM-DD).
        data_dir (str, optional): Directory for caching data .

    Note:
        See `bbstrader.btengine.data.BaseCSVDataHandler` for other arguments.
    """
    self.symbol_list = symbol_list
    self.start_date = kwargs.get("yf_start")
    self.end_date = kwargs.get("yf_end", datetime.now())
    self.cache_dir = kwargs.get("data_dir")
    self.use_cache = kwargs.get("use_cache", False)
    self.cache_max_age_days = kwargs.get("cache_max_age_days")

    csv_dir = self._download_and_cache_data(self.cache_dir)

    super().__init__(
        events,
        symbol_list,
        str(csv_dir),
        columns=kwargs.get("columns"),
        index_col=kwargs.get("index_col", 0),
    )

EODHDataHandler

EODHDataHandler(events: Queue[MarketEvent], symbol_list: List[str], **kwargs: Any)

Bases: BaseCSVDataHandler

Downloads historical data from EOD Historical Data. Data is fetched using the eodhd library.

To use this class, you need to sign up for an API key at https://eodhistoricaldata.com/ and provide the key as an argument.

Parameters:

Name Type Description Default
events Queue

The Event Queue for passing market events.

required
symbol_list list[str]

List of symbols to download data for.

required
eodhd_start str

Start date for historical data (YYYY-MM-DD).

required
eodhd_end str

End date for historical data (YYYY-MM-DD).

required
data_dir str

Directory for caching data .

required
eodhd_period str

Time period for historical data (e.g., 'd', 'w', 'm', '1m', '5m', '1h').

required
eodhd_api_key str

API key for EOD Historical Data.

required
Note

See bbstrader.btengine.data.BaseCSVDataHandler for other arguments.

Source code in src/bbstrader/btengine/data.py
def __init__(
    self, events: "Queue[MarketEvent]", symbol_list: List[str], **kwargs: Any
) -> None:
    """
    Args:
        events (Queue): The Event Queue for passing market events.
        symbol_list (list[str]): List of symbols to download data for.
        eodhd_start (str): Start date for historical data (YYYY-MM-DD).
        eodhd_end (str): End date for historical data (YYYY-MM-DD).
        data_dir (str, optional): Directory for caching data .
        eodhd_period (str, optional): Time period for historical data (e.g., 'd', 'w', 'm', '1m', '5m', '1h').
        eodhd_api_key (str, optional): API key for EOD Historical Data.

    Note:
        See `bbstrader.btengine.data.BaseCSVDataHandler` for other arguments.
    """
    self.symbol_list = symbol_list
    self.start_date = kwargs.get("eodhd_start")
    self.end_date = kwargs.get("eodhd_end", datetime.now().strftime("%Y-%m-%d"))
    self.period = kwargs.get("eodhd_period", "d")
    self.cache_dir = kwargs.get("data_dir")
    self.use_cache = kwargs.get("use_cache", False)
    self.cache_max_age_days = kwargs.get("cache_max_age_days")
    self.__api_key = kwargs.get("eodhd_api_key", "demo")

    csv_dir = self._download_and_cache_data(self.cache_dir)

    super().__init__(
        events,
        symbol_list,
        str(csv_dir),
        columns=kwargs.get("columns"),
        index_col=kwargs.get("index_col", 0),
    )

FMPDataHandler

FMPDataHandler(events: Queue[MarketEvent], symbol_list: List[str], **kwargs: Any)

Bases: BaseCSVDataHandler

Downloads historical data from Financial Modeling Prep (FMP). Data is fetched using the financetoolkit library.

To use this class, you need to sign up for an API key at https://financialmodelingprep.com/developer/docs/pricing and provide the key as an argument.

Parameters:

Name Type Description Default
events Queue

The Event Queue for passing market events.

required
symbol_list list[str]

List of symbols to download data for.

required
fmp_start str

Start date for historical data (YYYY-MM-DD).

required
fmp_end str

End date for historical data (YYYY-MM-DD).

required
data_dir str

Directory for caching data .

required
fmp_period str

Time period for historical data (e.g. daily, weekly, monthly, quarterly, yearly, "1min", "5min", "15min", "30min", "1hour").

required
fmp_api_key str

API key for Financial Modeling Prep.

required
Note

See bbstrader.btengine.data.BaseCSVDataHandler for other arguments.

Source code in src/bbstrader/btengine/data.py
def __init__(
    self, events: "Queue[MarketEvent]", symbol_list: List[str], **kwargs: Any
) -> None:
    """
    Args:
        events (Queue): The Event Queue for passing market events.
        symbol_list (list[str]): List of symbols to download data for.
        fmp_start (str): Start date for historical data (YYYY-MM-DD).
        fmp_end (str): End date for historical data (YYYY-MM-DD).
        data_dir (str, optional): Directory for caching data .
        fmp_period (str, optional): Time period for historical data
            (e.g. daily, weekly, monthly, quarterly, yearly, "1min", "5min", "15min", "30min", "1hour").
        fmp_api_key (str): API key for Financial Modeling Prep.

    Note:
        See `bbstrader.btengine.data.BaseCSVDataHandler` for other arguments.
    """
    self.symbol_list = symbol_list
    self.start_date = kwargs.get("fmp_start")
    self.end_date = kwargs.get("fmp_end", datetime.now().strftime("%Y-%m-%d"))
    self.period = kwargs.get("fmp_period", "daily")
    self.cache_dir = kwargs.get("data_dir")
    self.use_cache = kwargs.get("use_cache", False)
    self.cache_max_age_days = kwargs.get("cache_max_age_days")
    self.__api_key = kwargs.get("fmp_api_key")

    csv_dir = self._download_and_cache_data(self.cache_dir)

    super().__init__(
        events,
        symbol_list,
        str(csv_dir),
        columns=kwargs.get("columns"),
        index_col=kwargs.get("index_col", 0),
    )

Event

Event is base class providing an interface for all subsequent (inherited) events, that will trigger further events in the trading infrastructure. Since in many implementations the Event objects will likely develop greater complexity, it is thus being "future-proofed" by creating a class hierarchy. The Event class is simply a way to ensure that all events have a common interface and can be handled in a consistent manner.

MarketEvent

MarketEvent()

Bases: Event

Market Events are triggered when the outer while loop of the backtesting system begins a new "heartbeat". It occurs when the DataHandler object receives a new update of market data for any symbols which are currently being tracked. It is used to trigger the Strategy object generating new trading signals. The event object simply contains an identification that it is a market event, with no other structure.

Initialises the MarketEvent.

Source code in src/bbstrader/btengine/event.py
def __init__(self) -> None:
    """
    Initialises the MarketEvent.
    """
    self.type = Events.MARKET

SignalEvent

SignalEvent(strategy_id: int, symbol: str, datetime: datetime, signal_type: Literal['LONG', 'SHORT', 'EXIT'], quantity: Union[int, float] = 100, strength: Union[int, float] = 1.0, price: Optional[Union[int, float]] = None, stoplimit: Optional[Union[int, float]] = None)

Bases: Event

The Strategy object utilises market data to create new SignalEvents. The SignalEvent contains a strategy ID, a ticker symbol, a timestamp for when it was generated, a direction (long or short) and a "strength" indicator (this is useful for mean reversion strategies) and the quantiy to buy or sell. The SignalEvents are utilised by the Portfolio object as advice for how to trade.

Initialises the SignalEvent.

Parameters:

Name Type Description Default
strategy_id int

The unique identifier for the strategy that generated the signal.

required
symbol str

The ticker symbol, e.g. 'GOOG'.

required
datetime datetime

The timestamp at which the signal was generated.

required
signal_type str

'LONG' or 'SHORT' or 'EXIT'.

required
quantity int | float

An optional integer (or float) representing the order size.

100
strength int | float

An adjustment factor "suggestion" used to scale quantity at the portfolio level. Useful for pairs strategies.

1.0
price int | float

An optional price to be used when the signal is generated.

None
stoplimit int | float

An optional stop-limit price for the signal

None
Source code in src/bbstrader/btengine/event.py
def __init__(
    self,
    strategy_id: int,
    symbol: str,
    datetime: datetime,
    signal_type: Literal["LONG", "SHORT", "EXIT"],
    quantity: Union[int, float] = 100,
    strength: Union[int, float] = 1.0,
    price: Optional[Union[int, float]] = None,
    stoplimit: Optional[Union[int, float]] = None,
) -> None:
    """
    Initialises the SignalEvent.

    Args:
        strategy_id (int): The unique identifier for the strategy that
            generated the signal.

        symbol (str): The ticker symbol, e.g. 'GOOG'.
        datetime (datetime): The timestamp at which the signal was generated.
        signal_type (str): 'LONG' or 'SHORT' or 'EXIT'.
        quantity (int | float): An optional integer (or float) representing the order size.
        strength (int | float): An adjustment factor "suggestion" used to scale
            quantity at the portfolio level. Useful for pairs strategies.
        price (int | float): An optional price to be used when the signal is generated.
        stoplimit (int | float): An optional stop-limit price for the signal
    """
    self.type = Events.SIGNAL
    self.strategy_id = strategy_id
    self.symbol = symbol
    self.datetime = datetime
    self.signal_type = signal_type
    self.quantity = quantity
    self.strength = strength
    self.price = price
    self.stoplimit = stoplimit

OrderEvent

OrderEvent(symbol: str, order_type: Literal['MKT', 'LMT', 'STP', 'STPLMT'], quantity: Union[int, float], direction: Literal['BUY', 'SELL'], price: Optional[Union[int, float]] = None, signal: Optional[str] = None)

Bases: Event

When a Portfolio object receives SignalEvents it assesses them in the wider context of the portfolio, in terms of risk and position sizing. This ultimately leads to OrderEvents that will be sent to an ExecutionHandler.

The OrderEvents is slightly more complex than a SignalEvents since it contains a quantity field in addition to the aforementioned properties of SignalEvent. The quantity is determined by the Portfolio constraints. In addition the OrderEvent has a print_order() method, used to output the information to the console if necessary.

Initialises the order type, setting whether it is a Market order ('MKT') or Limit order ('LMT'), or Stop order ('STP'). a quantity (integral or float) and its direction ('BUY' or 'SELL').

Parameters:

Name Type Description Default
symbol str

The instrument to trade.

required
order_type str

'MKT' or 'LMT' for Market or Limit.

required
quantity int | float

Non-negative number for quantity.

required
direction str

'BUY' or 'SELL' for long or short.

required
price int | float

The price at which to order.

None
signal str

The signal that generated the order.

None
Source code in src/bbstrader/btengine/event.py
def __init__(
    self,
    symbol: str,
    order_type: Literal["MKT", "LMT", "STP", "STPLMT"],
    quantity: Union[int, float],
    direction: Literal["BUY", "SELL"],
    price: Optional[Union[int, float]] = None,
    signal: Optional[str] = None,
) -> None:
    """
    Initialises the order type, setting whether it is
    a Market order ('MKT') or Limit order ('LMT'), or Stop order ('STP').
    a quantity (integral or float) and its direction ('BUY' or 'SELL').

    Args:
        symbol (str): The instrument to trade.
        order_type (str): 'MKT' or 'LMT' for Market or Limit.
        quantity (int | float): Non-negative number for quantity.
        direction (str): 'BUY' or 'SELL' for long or short.
        price (int | float): The price at which to order.
        signal (str): The signal that generated the order.
    """
    self.type = Events.ORDER
    self.symbol = symbol
    self.order_type = order_type
    self.quantity = quantity
    self.direction = direction
    self.price = price
    self.signal = signal

print_order

print_order() -> None

Outputs the values within the Order.

Source code in src/bbstrader/btengine/event.py
def print_order(self) -> None:
    """
    Outputs the values within the Order.
    """
    print(
        "Order: Symbol=%s, Type=%s, Quantity=%s, Direction=%s, Price=%s"
        % (
            self.symbol,
            self.order_type,
            self.quantity,
            self.direction,
            self.price,
        )
    )

FillEvent

FillEvent(timeindex: datetime, symbol: str, exchange: str, quantity: Union[int, float], direction: Literal['BUY', 'SELL'], fill_cost: Optional[Union[int, float]], commission: Optional[float] = None, order: Optional[str] = None)

Bases: Event

When an ExecutionHandler receives an OrderEvent it must transact the order. Once an order has been transacted it generates a FillEvent, which describes the cost of purchase or sale as well as the transaction costs, such as fees or slippage.

The FillEvent is the Event with the greatest complexity. It contains a timestamp for when an order was filled, the symbol of the order and the exchange it was executed on, the quantity of shares transacted, the actual price of the purchase and the commission incurred.

The commission is calculated using the Interactive Brokers commissions. For US API orders this commission is 1.30 USD minimum per order, with a flat rate of either 0.013 USD or 0.08 USD per share depending upon whether the trade size is below or above 500 units of stock.

Initialises the FillEvent object. Sets the symbol, exchange, quantity, direction, cost of fill and an optional commission.

If commission is not provided, the Fill object will calculate it based on the trade size and Interactive Brokers fees.

Parameters:

Name Type Description Default
timeindex datetime

The bar-resolution when the order was filled.

required
symbol str

The instrument which was filled.

required
exchange str

The exchange where the order was filled.

required
quantity int | float

The filled quantity.

required
direction str

The direction of fill ('LONG', 'SHORT', 'EXIT')

required
fill_cost int | float

Price of the shares when filled.

required
commission float | None

An optional commission sent from IB.

None
order str

The order that this fill is related

None
Source code in src/bbstrader/btengine/event.py
def __init__(
    self,
    timeindex: datetime,
    symbol: str,
    exchange: str,
    quantity: Union[int, float],
    direction: Literal["BUY", "SELL"],
    fill_cost: Optional[Union[int, float]],
    commission: Optional[float] = None,
    order: Optional[str] = None,
) -> None:
    """
    Initialises the FillEvent object. Sets the symbol, exchange,
    quantity, direction, cost of fill and an optional
    commission.

    If commission is not provided, the Fill object will
    calculate it based on the trade size and Interactive
    Brokers fees.

    Args:
        timeindex (datetime): The bar-resolution when the order was filled.
        symbol (str): The instrument which was filled.
        exchange (str): The exchange where the order was filled.
        quantity (int | float): The filled quantity.
        direction (str): The direction of fill `('LONG', 'SHORT', 'EXIT')`
        fill_cost (int | float): Price of the shares when filled.
        commission (float | None): An optional commission sent from IB.
        order (str): The order that this fill is related
    """
    self.type = Events.FILL
    self.timeindex = timeindex
    self.symbol = symbol
    self.exchange = exchange
    self.quantity = quantity
    self.direction = direction
    self.fill_cost = fill_cost
    # Calculate commission
    if commission is None:
        self.commission: float = self.calculate_ib_commission()
    else:
        self.commission = commission
    self.order = order

calculate_ib_commission

calculate_ib_commission() -> float

Calculates the fees of trading based on an Interactive Brokers fee structure for API, in USD. This does not include exchange or ECN fees. Based on "US API Directed Orders": https://www.interactivebrokers.com/en/index.php?f=commission&p=stocks2

Source code in src/bbstrader/btengine/event.py
def calculate_ib_commission(self) -> float:
    """
    Calculates the fees of trading based on an Interactive
    Brokers fee structure for API, in USD.
    This does not include exchange or ECN fees.
    Based on "US API Directed Orders":
    https://www.interactivebrokers.com/en/index.php?f=commission&p=stocks2
    """
    full_cost = 1.3
    if self.quantity <= 500:
        full_cost = max(1.3, 0.013 * self.quantity)
    else:
        full_cost = max(1.3, 0.008 * self.quantity)
    return full_cost

ExecutionHandler

The ExecutionHandler abstract class handles the interaction between a set of order objects generated by a Portfolio and the ultimate set of Fill objects that actually occur in the market.

The handlers can be used to subclass simulated brokerages or live brokerages, with identical interfaces. This allows strategies to be backtested in a very similar manner to the live trading engine.

The ExecutionHandler described here is exceedingly simple, since it fills all orders at the current market price. This is highly unrealistic, for other markets thant CFDs but serves as a good baseline for improvement.

execute_order abstractmethod

execute_order(event: OrderEvent) -> None

Takes an Order event and executes it, producing a Fill event that gets placed onto the Events queue.

Parameters:

Name Type Description Default
event OrderEvent

Contains an Event object with order information.

required
Source code in src/bbstrader/btengine/execution.py
@abstractmethod
def execute_order(self, event: OrderEvent) -> None:
    """
    Takes an Order event and executes it, producing
    a Fill event that gets placed onto the Events queue.

    Args:
        event (OrderEvent): Contains an Event object with order information.
    """
    raise NotImplementedError("Should implement execute_order()")

SimExecutionHandler

SimExecutionHandler(events: Queue[Union[FillEvent, OrderEvent]], data: DataHandler, **kwargs: Any)

Bases: ExecutionHandler

The simulated execution handler simply converts all order objects into their equivalent fill objects automatically without latency, slippage or fill-ratio issues.

This allows a straightforward "first go" test of any strategy, before implementation with a more sophisticated execution handler.

Initialises the handler, setting the event queues up internally.

Parameters:

Name Type Description Default
events Queue

The Queue of Event objects.

required
Source code in src/bbstrader/btengine/execution.py
def __init__(
    self,
    events: "Queue[Union[FillEvent, OrderEvent]]",
    data: DataHandler,
    **kwargs: Any,
) -> None:
    """
    Initialises the handler, setting the event queues
    up internally.

    Args:
        events (Queue): The Queue of Event objects.
    """
    self.events = events
    self.bardata = data
    self.logger = kwargs.get("logger") or logger
    self.commissions = kwargs.get("commission")
    self.exchange = kwargs.get("exchange", "ARCA")

    self.slippage_model = kwargs.get("slippage_model")
    self.impact_model = kwargs.get("impact_model")
    self.commission_model = kwargs.get("commission_model")
    fill_ratio = kwargs.get("fill_ratio", 1.0)
    if not 0.0 < fill_ratio <= 1.0:
        raise ValueError("fill_ratio must be in the interval (0, 1].")
    self.fill_ratio = float(fill_ratio)

    self.time_frontier = bool(kwargs.get("time_frontier", False))
    self.latency = int(kwargs.get("latency", 0))
    if self.latency < 0:
        raise ValueError("latency must be a non-negative number of bars.")
    self.fill_on = kwargs.get("fill_on", "open")
    # Each pending item is [order, bars_remaining].
    self._pending: list[list] = []

process_pending

process_pending() -> None

Fill orders held under time-frontier mode at the current (next) bar.

Called by the engine once per bar after new data arrives. Orders placed on the previous bar fill here at this bar's fill_on price.

Source code in src/bbstrader/btengine/execution.py
def process_pending(self) -> None:
    """Fill orders held under time-frontier mode at the current (next) bar.

    Called by the engine once per bar after new data arrives. Orders placed
    on the previous bar fill here at this bar's ``fill_on`` price.
    """
    if not self._pending:
        return
    still_pending: list[list] = []
    ready: list[OrderEvent] = []
    for item in self._pending:
        item[1] -= 1
        if item[1] <= 0:
            ready.append(item[0])
        else:
            still_pending.append(item)
    self._pending = still_pending
    for event in ready:
        try:
            base_price = self.bardata.get_latest_bar_value(
                event.symbol, self.fill_on
            )
        except (AttributeError, KeyError, ValueError):
            base_price = self.bardata.get_latest_bar_value(event.symbol, "close")
        self._emit_fill(event, float(base_price))

execute_order

execute_order(event: OrderEvent) -> None

Converts Order objects into Fill objects, optionally applying the configured slippage, market-impact, commission, partial-fill and time-frontier (next-bar) models.

Parameters:

Name Type Description Default
event OrderEvent

Contains an Event object with order information.

required
Source code in src/bbstrader/btengine/execution.py
def execute_order(self, event: OrderEvent) -> None:
    """
    Converts Order objects into Fill objects, optionally applying the
    configured slippage, market-impact, commission, partial-fill and
    time-frontier (next-bar) models.

    Args:
        event (OrderEvent): Contains an Event object with order information.
    """
    if event.type != Events.ORDER:
        return
    delay = self._fill_delay()
    if delay >= 1:
        # Defer the fill by `delay` bars; process_pending() fills it.
        self._pending.append([event, delay])
        self.logger.info(
            f"{event.direction} ORDER QUEUED (delay={delay} bars): "
            f"SYMBOL={event.symbol}, QUANTITY={event.quantity}",
            custom_time=self.bardata.get_latest_bar_datetime(event.symbol),
        )
        return
    base_price = event.price if self._has_friction() else None
    self._emit_fill(event, base_price)

MT5ExecutionHandler

MT5ExecutionHandler(events: Queue[Union[FillEvent, OrderEvent]], data: DataHandler, **kwargs: Any)

Bases: ExecutionHandler

The main role of MT5ExecutionHandler class is to estimate the execution fees for different asset classes on the MT5 terminal.

Generally we have four types of fees when we execute trades using the MT5 terminal (commissions, swap, spread and other fees). But most of these fees depend on the specifications of each instrument and the duration of the transaction for the swap for example.

Calculating the exact fees for each instrument would be a bit complex because our Backtest engine and the Portfolio class do not take into account the duration of each trade to apply the appropriate rate for the swap for example. So we have to use only the model of calculating the commissions for each asset class and each instrument.

The second thing that must be taken into account on MT5 is the type of account offered by the broker. Brokers have different account categories each with its specifications for each asset class and each instrument. Again considering all these conditions would make our class very complex. So we took the Raw Spread account fee calculation model from Just Market for indicies, forex, commodities and crypto. We used the Admiral Market account fee calculation model from Trade.MT5 account type for stocks and ETFs.

NOTE

This class only works with bbstrader.metatrader.data.MT5DataHandler class.

Initialises the handler, setting the event queues up internally.

Parameters:

Name Type Description Default
events Queue

The Queue of Event objects.

required
Source code in src/bbstrader/btengine/execution.py
def __init__(
    self,
    events: "Queue[Union[FillEvent, OrderEvent]]",
    data: DataHandler,
    **kwargs: Any,
) -> None:
    """
    Initialises the handler, setting the event queues up internally.

    Args:
        events (Queue): The Queue of Event objects.
    """
    self.events = events
    self.bardata = data
    self.logger = kwargs.get("logger") or logger
    self.commissions = kwargs.get("commission")
    self.exchange = kwargs.get("exchange", "MT5")
    self.__account = Account(**kwargs)

execute_order

execute_order(event: OrderEvent) -> None

Executes an Order event by converting it into a Fill event.

Parameters:

Name Type Description Default
event OrderEvent

Contains an Event object with order information.

required
Source code in src/bbstrader/btengine/execution.py
def execute_order(self, event: OrderEvent) -> None:
    """
    Executes an Order event by converting it into a Fill event.

    Args:
        event (OrderEvent): Contains an Event object with order information.
    """
    if event.type == Events.ORDER:
        symbol = event.symbol
        direction = event.direction
        quantity = event.quantity
        price = event.price
        if price is None:
            price = self.bardata.get_latest_bar_value(symbol, "close")
        lot = self._calculate_lot(symbol, quantity, price)
        fees = self._estimate_total_fees(symbol, lot, quantity, price)
        dtime = self.bardata.get_latest_bar_datetime(symbol)
        commission = self.commissions or fees
        fill_event = FillEvent(
            timeindex=dtime,  # type: ignore
            symbol=symbol,
            exchange=self.exchange,
            quantity=quantity,
            direction=direction,
            fill_cost=None,
            commission=commission,
            order=event.signal,
        )
        self.events.put(fill_event)
        log_price = event.price or 0.0
        self.logger.info(
            f"{direction} ORDER FILLED: SYMBOL={symbol}, QUANTITY={quantity}, "
            f"PRICE @{round(log_price, 5)} EXCHANGE={fill_event.exchange}",
            custom_time=fill_event.timeindex,
        )

ExperimentRecord dataclass

ExperimentRecord(id: str, name: str, params: Dict[str, Any], metrics: Dict[str, Any], created_at: str, environment: Dict[str, str] = dict())

A persisted record of one backtest/optimization run.

Attributes:

Name Type Description
id str

The unique run identifier.

name str

The human-readable run name.

params Dict[str, Any]

The parameters the run was executed with.

metrics Dict[str, Any]

The metrics produced by the run.

created_at str

The ISO-8601 UTC creation timestamp.

environment Dict[str, str]

The Python/platform environment captured at save time.

to_dict

to_dict() -> Dict[str, Any]

Return the record as a plain dict suitable for JSON serialization.

Returns:

Type Description
Dict[str, Any]

Dict[str, Any]: All fields of the record.

Source code in src/bbstrader/btengine/experiment.py
def to_dict(self) -> Dict[str, Any]:
    """Return the record as a plain dict suitable for JSON serialization.

    Returns:
        Dict[str, Any]: All fields of the record.
    """
    return asdict(self)

ExperimentStore

ExperimentStore(root: Optional[Union[str, Path]] = None)

Save, load, list and compare persisted experiment runs.

Initialise the store rooted at root and ensure it exists.

Parameters:

Name Type Description Default
root Optional[Union[str, Path]]

Directory to store runs in. Defaults to ~/.bbstrader/experiments.

None
Source code in src/bbstrader/btengine/experiment.py
def __init__(self, root: Optional[Union[str, Path]] = None) -> None:
    """Initialise the store rooted at ``root`` and ensure it exists.

    Args:
        root (Optional[Union[str, Path]]): Directory to store runs in.
            Defaults to ``~/.bbstrader/experiments``.
    """
    self.root = Path(root) if root else BBSTRADER_DIR / "experiments"
    self.root.mkdir(parents=True, exist_ok=True)

save

save(name: str, params: Dict[str, Any], metrics: Dict[str, Any], equity_curve: Optional[DataFrame] = None, run_id: Optional[str] = None, created_at: Optional[str] = None) -> str

Persist a run and return its id.

run_id/created_at may be supplied for deterministic, idempotent writes (e.g. in tests); otherwise a uuid and the current UTC time are used.

Source code in src/bbstrader/btengine/experiment.py
def save(
    self,
    name: str,
    params: Dict[str, Any],
    metrics: Dict[str, Any],
    equity_curve: Optional[pd.DataFrame] = None,
    run_id: Optional[str] = None,
    created_at: Optional[str] = None,
) -> str:
    """Persist a run and return its id.

    ``run_id``/``created_at`` may be supplied for deterministic, idempotent
    writes (e.g. in tests); otherwise a uuid and the current UTC time are
    used.
    """
    run_id = run_id or f"{name}-{uuid.uuid4().hex[:8]}"
    created_at = created_at or datetime.now(timezone.utc).isoformat()
    record = ExperimentRecord(
        id=run_id,
        name=name,
        params=params,
        metrics=metrics,
        created_at=created_at,
        environment=_environment(),
    )
    run_dir = self._run_dir(run_id)
    run_dir.mkdir(parents=True, exist_ok=True)
    (run_dir / "meta.json").write_text(
        json.dumps(record.to_dict(), indent=2, default=str)
    )
    if equity_curve is not None:
        equity_curve.to_csv(run_dir / "equity.csv")
    return run_id

load

load(run_id: str) -> ExperimentRecord

Load a previously saved run's metadata record.

Parameters:

Name Type Description Default
run_id str

The run identifier returned by :meth:save.

required

Returns:

Name Type Description
ExperimentRecord ExperimentRecord

The reconstructed record.

Raises:

Type Description
FileNotFoundError

If no run with run_id exists.

Source code in src/bbstrader/btengine/experiment.py
def load(self, run_id: str) -> ExperimentRecord:
    """Load a previously saved run's metadata record.

    Args:
        run_id (str): The run identifier returned by :meth:`save`.

    Returns:
        ExperimentRecord: The reconstructed record.

    Raises:
        FileNotFoundError: If no run with ``run_id`` exists.
    """
    meta = self._run_dir(run_id) / "meta.json"
    if not meta.exists():
        raise FileNotFoundError(f"No experiment with id {run_id!r}.")
    data = json.loads(meta.read_text())
    return ExperimentRecord(**data)

load_equity

load_equity(run_id: str) -> Optional[pd.DataFrame]

Load a run's persisted equity curve, if one was saved.

Parameters:

Name Type Description Default
run_id str

The run identifier.

required

Returns:

Type Description
Optional[DataFrame]

Optional[pd.DataFrame]: The equity curve, or None when absent.

Source code in src/bbstrader/btengine/experiment.py
def load_equity(self, run_id: str) -> Optional[pd.DataFrame]:
    """Load a run's persisted equity curve, if one was saved.

    Args:
        run_id (str): The run identifier.

    Returns:
        Optional[pd.DataFrame]: The equity curve, or None when absent.
    """
    path = self._run_dir(run_id) / "equity.csv"
    if not path.exists():
        return None
    return pd.read_csv(path, index_col=0)

list

list() -> List[ExperimentRecord]

List all saved runs, oldest first.

Returns:

Type Description
List[ExperimentRecord]

List[ExperimentRecord]: Records sorted by creation time.

Source code in src/bbstrader/btengine/experiment.py
def list(self) -> List[ExperimentRecord]:
    """List all saved runs, oldest first.

    Returns:
        List[ExperimentRecord]: Records sorted by creation time.
    """
    records = []
    for meta in self.root.glob("*/meta.json"):
        records.append(ExperimentRecord(**json.loads(meta.read_text())))
    return sorted(records, key=lambda r: r.created_at)

compare

compare(metric: Optional[str] = None, ascending: bool = False) -> pd.DataFrame

Return a leaderboard DataFrame of all runs' metrics.

Sorted by metric (descending by default) when provided.

Source code in src/bbstrader/btengine/experiment.py
def compare(
    self, metric: Optional[str] = None, ascending: bool = False
) -> pd.DataFrame:
    """Return a leaderboard DataFrame of all runs' metrics.

    Sorted by ``metric`` (descending by default) when provided.
    """
    rows = []
    for rec in self.list():
        row = {"id": rec.id, "name": rec.name, "created_at": rec.created_at}
        row.update(rec.metrics)
        rows.append(row)
    df = pd.DataFrame(rows)
    if metric and metric in df.columns:
        df = df.sort_values(metric, ascending=ascending).reset_index(drop=True)
    return df

delete

delete(run_id: str) -> None

Delete a saved run and all of its files.

A no-op when the run does not exist.

Parameters:

Name Type Description Default
run_id str

The run identifier to delete.

required
Source code in src/bbstrader/btengine/experiment.py
def delete(self, run_id: str) -> None:
    """Delete a saved run and all of its files.

    A no-op when the run does not exist.

    Args:
        run_id (str): The run identifier to delete.
    """
    run_dir = self._run_dir(run_id)
    if run_dir.exists():
        for child in run_dir.iterdir():
            child.unlink()
        run_dir.rmdir()

SlippageModel

Bases: ABC

Adjusts the execution price to account for adverse price movement.

adjusted_price abstractmethod

adjusted_price(base_price: float, direction: str, quantity: float, symbol: str, bardata: DataHandler) -> float

Return the slippage-adjusted execution price.

Source code in src/bbstrader/btengine/friction.py
@abstractmethod
def adjusted_price(
    self,
    base_price: float,
    direction: str,
    quantity: float,
    symbol: str,
    bardata: DataHandler,
) -> float:
    """Return the slippage-adjusted execution price."""

NoSlippage

Bases: SlippageModel

Fills at the unadjusted base price.

adjusted_price

adjusted_price(base_price, direction, quantity, symbol, bardata) -> float

Return base_price unchanged (see :meth:SlippageModel.adjusted_price).

Source code in src/bbstrader/btengine/friction.py
def adjusted_price(self, base_price, direction, quantity, symbol, bardata) -> float:
    """Return ``base_price`` unchanged (see :meth:`SlippageModel.adjusted_price`)."""
    return base_price

FixedSpreadSlippage

FixedSpreadSlippage(spread: float)

Bases: SlippageModel

Charges half of a fixed spread (in price units) on each fill.

Initialise the model with a fixed spread.

Parameters:

Name Type Description Default
spread float

The full bid-ask spread in price units; half is charged on each fill. Must be non-negative.

required

Raises:

Type Description
ValueError

If spread is negative.

Source code in src/bbstrader/btengine/friction.py
def __init__(self, spread: float) -> None:
    """Initialise the model with a fixed spread.

    Args:
        spread (float): The full bid-ask spread in price units; half is
            charged on each fill. Must be non-negative.

    Raises:
        ValueError: If ``spread`` is negative.
    """
    if spread < 0:
        raise ValueError("spread must be non-negative.")
    self.spread = float(spread)

adjusted_price

adjusted_price(base_price, direction, quantity, symbol, bardata) -> float

Return base_price shifted adversely by half the fixed spread.

Source code in src/bbstrader/btengine/friction.py
def adjusted_price(self, base_price, direction, quantity, symbol, bardata) -> float:
    """Return ``base_price`` shifted adversely by half the fixed spread."""
    return base_price + _direction_sign(direction) * self.spread / 2.0

PercentSlippage

PercentSlippage(pct: float)

Bases: SlippageModel

Applies a fixed percentage slippage to the base price.

Initialise the model with a fractional slippage.

Parameters:

Name Type Description Default
pct float

The slippage as a fraction of price (for example 0.001 for 10 bps). Must be non-negative.

required

Raises:

Type Description
ValueError

If pct is negative.

Source code in src/bbstrader/btengine/friction.py
def __init__(self, pct: float) -> None:
    """Initialise the model with a fractional slippage.

    Args:
        pct (float): The slippage as a fraction of price (for example
            ``0.001`` for 10 bps). Must be non-negative.

    Raises:
        ValueError: If ``pct`` is negative.
    """
    if pct < 0:
        raise ValueError("pct must be non-negative.")
    self.pct = float(pct)

adjusted_price

adjusted_price(base_price, direction, quantity, symbol, bardata) -> float

Return base_price moved adversely by the configured percentage.

Source code in src/bbstrader/btengine/friction.py
def adjusted_price(self, base_price, direction, quantity, symbol, bardata) -> float:
    """Return ``base_price`` moved adversely by the configured percentage."""
    return base_price * (1.0 + _direction_sign(direction) * self.pct)

VolatilitySlippage

VolatilitySlippage(coef: float = 1.0, window: int = 20)

Bases: SlippageModel

Slippage scaled by recent return volatility.

slippage = coef * sigma * base_price where sigma is the rolling standard deviation of returns over window bars.

Initialise the model with a volatility coefficient and window.

Parameters:

Name Type Description Default
coef float

Multiplier applied to the rolling return standard deviation to size the slippage.

1.0
window int

Number of recent bars used to estimate volatility.

20
Source code in src/bbstrader/btengine/friction.py
def __init__(self, coef: float = 1.0, window: int = 20) -> None:
    """Initialise the model with a volatility coefficient and window.

    Args:
        coef (float): Multiplier applied to the rolling return standard
            deviation to size the slippage.
        window (int): Number of recent bars used to estimate volatility.
    """
    self.coef = float(coef)
    self.window = int(window)

adjusted_price

adjusted_price(base_price, direction, quantity, symbol, bardata) -> float

Return base_price shifted by coef * sigma of recent returns.

Source code in src/bbstrader/btengine/friction.py
def adjusted_price(self, base_price, direction, quantity, symbol, bardata) -> float:
    """Return ``base_price`` shifted by ``coef * sigma`` of recent returns."""
    try:
        returns = bardata.get_latest_bars_values(symbol, "returns", N=self.window)
        sigma = float(np.nanstd(returns)) if len(returns) else 0.0
    except (AttributeError, KeyError, ValueError):
        sigma = 0.0
    return base_price * (1.0 + _direction_sign(direction) * self.coef * sigma)

VolumeParticipationSlippage

VolumeParticipationSlippage(coef: float = 0.1)

Bases: SlippageModel

Slippage proportional to the order's share of bar volume.

Initialise the model with a participation coefficient.

Parameters:

Name Type Description Default
coef float

Multiplier applied to the order's share of bar volume to size the slippage.

0.1
Source code in src/bbstrader/btengine/friction.py
def __init__(self, coef: float = 0.1) -> None:
    """Initialise the model with a participation coefficient.

    Args:
        coef (float): Multiplier applied to the order's share of bar volume
            to size the slippage.
    """
    self.coef = float(coef)

adjusted_price

adjusted_price(base_price, direction, quantity, symbol, bardata) -> float

Return base_price shifted by the order's share of bar volume.

Source code in src/bbstrader/btengine/friction.py
def adjusted_price(self, base_price, direction, quantity, symbol, bardata) -> float:
    """Return ``base_price`` shifted by the order's share of bar volume."""
    try:
        volume = float(bardata.get_latest_bar_value(symbol, "volume"))
    except (AttributeError, KeyError, ValueError):
        volume = 0.0
    if volume <= 0:
        return base_price
    participation = abs(quantity) / volume
    return base_price * (
        1.0 + _direction_sign(direction) * self.coef * participation
    )

MarketImpactModel

Bases: ABC

Adds price impact from consuming liquidity.

impact abstractmethod

impact(base_price: float, quantity: float, direction: str) -> float

Return the per-unit price impact (always adverse).

Source code in src/bbstrader/btengine/friction.py
@abstractmethod
def impact(self, base_price: float, quantity: float, direction: str) -> float:
    """Return the per-unit price impact (always adverse)."""

NoImpact

Bases: MarketImpactModel

No market impact.

impact

impact(base_price, quantity, direction) -> float

Return 0.0 regardless of order size.

Source code in src/bbstrader/btengine/friction.py
def impact(self, base_price, quantity, direction) -> float:
    """Return ``0.0`` regardless of order size."""
    return 0.0

SquareRootImpact

SquareRootImpact(coef: float = 0.1, adv: float = 1000000.0)

Bases: MarketImpactModel

The square-root impact model: impact proportional to sqrt(size / ADV).

impact = coef * base_price * sqrt(|quantity| / adv). Suitable for institution-scale sizing where impact grows sub-linearly with order size.

Initialise the model with an impact coefficient and ADV.

Parameters:

Name Type Description Default
coef float

Scales the impact; larger values model thinner books.

0.1
adv float

Average daily volume used to normalise order size. Must be positive.

1000000.0

Raises:

Type Description
ValueError

If adv is not positive.

Source code in src/bbstrader/btengine/friction.py
def __init__(self, coef: float = 0.1, adv: float = 1_000_000.0) -> None:
    """Initialise the model with an impact coefficient and ADV.

    Args:
        coef (float): Scales the impact; larger values model thinner books.
        adv (float): Average daily volume used to normalise order size. Must
            be positive.

    Raises:
        ValueError: If ``adv`` is not positive.
    """
    if adv <= 0:
        raise ValueError("adv (average daily volume) must be positive.")
    self.coef = float(coef)
    self.adv = float(adv)

impact

impact(base_price, quantity, direction) -> float

Return the adverse per-unit impact coef * price * sqrt(|qty|/adv).

Source code in src/bbstrader/btengine/friction.py
def impact(self, base_price, quantity, direction) -> float:
    """Return the adverse per-unit impact ``coef * price * sqrt(|qty|/adv)``."""
    magnitude = self.coef * base_price * math.sqrt(abs(quantity) / self.adv)
    return _direction_sign(direction) * magnitude

CommissionModel

Bases: ABC

Computes commission for a fill.

commission abstractmethod

commission(symbol: str, quantity: float, price: float) -> float

Return the commission charged for the fill.

Source code in src/bbstrader/btengine/friction.py
@abstractmethod
def commission(self, symbol: str, quantity: float, price: float) -> float:
    """Return the commission charged for the fill."""

ZeroCommission

Bases: CommissionModel

No commission.

commission

commission(symbol, quantity, price) -> float

Return 0.0 for every fill.

Source code in src/bbstrader/btengine/friction.py
def commission(self, symbol, quantity, price) -> float:
    """Return ``0.0`` for every fill."""
    return 0.0

FixedCommission

FixedCommission(amount: float)

Bases: CommissionModel

A flat fee per fill.

Initialise the model with the flat per-fill fee.

Parameters:

Name Type Description Default
amount float

The fee charged on every fill, in account currency.

required
Source code in src/bbstrader/btengine/friction.py
def __init__(self, amount: float) -> None:
    """Initialise the model with the flat per-fill fee.

    Args:
        amount (float): The fee charged on every fill, in account currency.
    """
    self.amount = float(amount)

commission

commission(symbol, quantity, price) -> float

Return the flat fee regardless of symbol, quantity or price.

Source code in src/bbstrader/btengine/friction.py
def commission(self, symbol, quantity, price) -> float:
    """Return the flat fee regardless of symbol, quantity or price."""
    return self.amount

PerShareCommission

PerShareCommission(per_share: float = 0.005, minimum: float = 1.0)

Bases: CommissionModel

A per-share/contract fee with an optional minimum.

Initialise the model with a per-share rate and floor.

Parameters:

Name Type Description Default
per_share float

Fee charged per share or contract filled.

0.005
minimum float

Minimum commission applied to any fill.

1.0
Source code in src/bbstrader/btengine/friction.py
def __init__(self, per_share: float = 0.005, minimum: float = 1.0) -> None:
    """Initialise the model with a per-share rate and floor.

    Args:
        per_share (float): Fee charged per share or contract filled.
        minimum (float): Minimum commission applied to any fill.
    """
    self.per_share = float(per_share)
    self.minimum = float(minimum)

commission

commission(symbol, quantity, price) -> float

Return per_share * |quantity| floored at minimum.

Source code in src/bbstrader/btengine/friction.py
def commission(self, symbol, quantity, price) -> float:
    """Return ``per_share * |quantity|`` floored at ``minimum``."""
    return max(self.minimum, self.per_share * abs(quantity))

PercentCommission

PercentCommission(pct: float = 0.001, minimum: float = 0.0)

Bases: CommissionModel

A commission as a percentage of notional with an optional minimum.

Initialise the model with a notional percentage and floor.

Parameters:

Name Type Description Default
pct float

Fraction of traded notional charged as commission.

0.001
minimum float

Minimum commission applied to any fill.

0.0
Source code in src/bbstrader/btengine/friction.py
def __init__(self, pct: float = 0.001, minimum: float = 0.0) -> None:
    """Initialise the model with a notional percentage and floor.

    Args:
        pct (float): Fraction of traded notional charged as commission.
        minimum (float): Minimum commission applied to any fill.
    """
    self.pct = float(pct)
    self.minimum = float(minimum)

commission

commission(symbol, quantity, price) -> float

Return pct * |quantity| * price floored at minimum.

Source code in src/bbstrader/btengine/friction.py
def commission(self, symbol, quantity, price) -> float:
    """Return ``pct * |quantity| * price`` floored at ``minimum``."""
    return max(self.minimum, self.pct * abs(quantity) * price)

IBCommission

Bases: CommissionModel

The Interactive Brokers tiered share commission used by FillEvent.

commission

commission(symbol, quantity, price) -> float

Return the IB tiered per-share commission for the fill.

Source code in src/bbstrader/btengine/friction.py
def commission(self, symbol, quantity, price) -> float:
    """Return the IB tiered per-share commission for the fill."""
    qty = abs(quantity)
    if qty <= 500:
        return max(1.30, 0.013 * qty)
    return max(1.30, 0.008 * qty)

FundingModel

Bases: ABC

Charges the per-bar carrying cost of holding an open position.

Unlike slippage, impact and commission - which apply once at the fill - a funding model is evaluated every bar a position is held, capturing the overnight/swap financing that dominates the cost of leveraged CFD and FX positions. The returned value is a cash flow (positive = a cost debited from the account, negative = a credit) so a model can charge longs while crediting shorts, or vice versa.

carry abstractmethod

carry(symbol: str, quantity: float, price: float) -> float

Return the per-bar carry cash flow for an open position.

Parameters:

Name Type Description Default
symbol str

The instrument the position is held in.

required
quantity float

The signed position size; positive for a long, negative for a short.

required
price float

The current mark-to-market price of one unit.

required

Returns:

Name Type Description
float float

The cash flow for holding the position over one bar. A

float

positive number is a cost debited from cash; a negative number is a

float

credit added to cash.

Source code in src/bbstrader/btengine/friction.py
@abstractmethod
def carry(self, symbol: str, quantity: float, price: float) -> float:
    """Return the per-bar carry cash flow for an open position.

    Args:
        symbol (str): The instrument the position is held in.
        quantity (float): The signed position size; positive for a long,
            negative for a short.
        price (float): The current mark-to-market price of one unit.

    Returns:
        float: The cash flow for holding the position over one bar. A
        positive number is a cost debited from cash; a negative number is a
        credit added to cash.
    """

NoFunding

Bases: FundingModel

Applies no carrying cost; positions are free to hold.

carry

carry(symbol: str, quantity: float, price: float) -> float

Return zero carry regardless of the position.

Parameters:

Name Type Description Default
symbol str

Unused; present for interface compatibility.

required
quantity float

Unused; present for interface compatibility.

required
price float

Unused; present for interface compatibility.

required

Returns:

Name Type Description
float float

Always 0.0.

Source code in src/bbstrader/btengine/friction.py
def carry(self, symbol: str, quantity: float, price: float) -> float:
    """Return zero carry regardless of the position.

    Args:
        symbol (str): Unused; present for interface compatibility.
        quantity (float): Unused; present for interface compatibility.
        price (float): Unused; present for interface compatibility.

    Returns:
        float: Always ``0.0``.
    """
    return 0.0

FixedRateFunding

FixedRateFunding(annual_rate: float, periods: int = 252, short_rate: Optional[float] = None)

Bases: FundingModel

A simple cost-of-carry charged as an annual rate on notional.

The per-bar cost is (annual_rate / periods) * quantity * price. Because quantity is signed, a long position is debited and a short position is credited at the same rate, mirroring a basic financing model where the holder of a long leveraged position pays to borrow. Supply short_rate to charge shorts at a different annual rate (for example a borrow fee that makes shorting a net cost rather than a credit).

Initialise the model with annualised financing rates.

Parameters:

Name Type Description Default
annual_rate float

The annual financing rate applied to long notional (for example 0.05 for 5% per year).

required
periods int

The number of bars per year used to convert the annual rate to a per-bar rate (for example 252 for daily bars). Must be positive.

252
short_rate Optional[float]

The annual rate applied to short notional. When None the long annual_rate is reused, so a short earns the symmetric credit; supply an explicit value to model an asymmetric borrow cost.

None

Raises:

Type Description
ValueError

If periods is not positive.

Source code in src/bbstrader/btengine/friction.py
def __init__(
    self,
    annual_rate: float,
    periods: int = 252,
    short_rate: Optional[float] = None,
) -> None:
    """Initialise the model with annualised financing rates.

    Args:
        annual_rate (float): The annual financing rate applied to long
            notional (for example ``0.05`` for 5% per year).
        periods (int): The number of bars per year used to convert the
            annual rate to a per-bar rate (for example ``252`` for daily
            bars). Must be positive.
        short_rate (Optional[float]): The annual rate applied to short
            notional. When ``None`` the long ``annual_rate`` is reused, so a
            short earns the symmetric credit; supply an explicit value to
            model an asymmetric borrow cost.

    Raises:
        ValueError: If ``periods`` is not positive.
    """
    if periods <= 0:
        raise ValueError("periods must be a positive number of bars per year.")
    self.annual_rate = float(annual_rate)
    self.periods = int(periods)
    self.short_rate = annual_rate if short_rate is None else float(short_rate)

carry

carry(symbol: str, quantity: float, price: float) -> float

Return the per-bar financing cash flow for the position.

Parameters:

Name Type Description Default
symbol str

Unused; the rate is instrument independent.

required
quantity float

The signed position size.

required
price float

The current mark-to-market price of one unit.

required

Returns:

Name Type Description
float float

(rate / periods) * quantity * price using the long rate

float

for positive quantities and short_rate for negative ones. A

float

positive result is debited from cash.

Source code in src/bbstrader/btengine/friction.py
def carry(self, symbol: str, quantity: float, price: float) -> float:
    """Return the per-bar financing cash flow for the position.

    Args:
        symbol (str): Unused; the rate is instrument independent.
        quantity (float): The signed position size.
        price (float): The current mark-to-market price of one unit.

    Returns:
        float: ``(rate / periods) * quantity * price`` using the long rate
        for positive quantities and ``short_rate`` for negative ones. A
        positive result is debited from cash.
    """
    if quantity == 0:
        return 0.0
    rate = self.annual_rate if quantity > 0 else self.short_rate
    return (rate / self.periods) * quantity * price

BrokerSwapFunding

BrokerSwapFunding(long_points: float, short_points: float, point_value: float = 1.0)

Bases: FundingModel

Per-unit swap points charged each bar, mirroring MT5 swap semantics.

Brokers quote a long and a short swap per lot/unit; this model charges points * |quantity| * point_value each bar, using the long or short points according to the sign of the position. Points are expressed as a cost: positive points are debited from cash and negative points (a positive swap) are credited.

Initialise the model with the broker's long/short swap points.

Parameters:

Name Type Description Default
long_points float

The swap cost per unit per bar applied to long positions. Positive debits cash; negative credits it.

required
short_points float

The swap cost per unit per bar applied to short positions, with the same sign convention as long_points.

required
point_value float

The cash value of one swap point per unit, used to convert points to account currency.

1.0
Source code in src/bbstrader/btengine/friction.py
def __init__(
    self,
    long_points: float,
    short_points: float,
    point_value: float = 1.0,
) -> None:
    """Initialise the model with the broker's long/short swap points.

    Args:
        long_points (float): The swap cost per unit per bar applied to long
            positions. Positive debits cash; negative credits it.
        short_points (float): The swap cost per unit per bar applied to short
            positions, with the same sign convention as ``long_points``.
        point_value (float): The cash value of one swap point per unit, used
            to convert points to account currency.
    """
    self.long_points = float(long_points)
    self.short_points = float(short_points)
    self.point_value = float(point_value)

carry

carry(symbol: str, quantity: float, price: float) -> float

Return the per-bar swap cash flow for the position.

Parameters:

Name Type Description Default
symbol str

Unused; swap points are supplied per model instance.

required
quantity float

The signed position size.

required
price float

Unused; swap is charged per unit, not on notional.

required

Returns:

Name Type Description
float float

points * |quantity| * point_value where points is the

float

long or short swap selected by the sign of quantity. A positive

float

result is debited from cash.

Source code in src/bbstrader/btengine/friction.py
def carry(self, symbol: str, quantity: float, price: float) -> float:
    """Return the per-bar swap cash flow for the position.

    Args:
        symbol (str): Unused; swap points are supplied per model instance.
        quantity (float): The signed position size.
        price (float): Unused; swap is charged per unit, not on notional.

    Returns:
        float: ``points * |quantity| * point_value`` where ``points`` is the
        long or short swap selected by the sign of ``quantity``. A positive
        result is debited from cash.
    """
    if quantity == 0:
        return 0.0
    points = self.long_points if quantity > 0 else self.short_points
    return points * abs(quantity) * self.point_value

Portfolio

Portfolio(bars: DataHandler, events: Queue[Union[OrderEvent, FillEvent, SignalEvent]], start_date: datetime, initial_capital: float = 100000.0, **kwargs: Any)

This describes a Portfolio() object that keeps track of the positions within a portfolio and generates orders of a fixed quantity of stock based on signals.

The portfolio order management system is possibly the most complex component of an event driven backtester. Its role is to keep track of all current market positions as well as the market value of the positions (known as the "holdings"). This is simply an estimate of the liquidation value of the position and is derived in part from the data handling facility of the backtester.

In addition to the positions and holdings management the portfolio must also be aware of risk factors and position sizing techniques in order to optimise orders that are sent to a brokerage or other form of market access.

Unfortunately, Portfolio and Order Management Systems (OMS) can become rather complex! So let's keep the Portfolio object relatively straightforward anf improve it foward.

Continuing in the vein of the Event class hierarchy a Portfolio object must be able to handle SignalEvent objects, generate OrderEvent objects and interpret FillEvent objects to update positions. Thus it is no surprise that the Portfolio objects are often the largest component of event-driven systems, in terms of lines of code (LOC).

The initialisation of the Portfolio object requires access to the bars DataHandler, the Event Queue, a start datetime stamp and an initial capital value (defaulting to 100,000 USD) and others parameter based on the Strategy requirement.

The Portfolio is designed to handle position sizing and current holdings, but will carry out trading orders by simply them to the brokerage with a predetermined fixed quantity size, if the portfolio has enough cash to place the order.

The portfolio contains the all_positions and current_positions members. The former stores a list of all previous positions recorded at the timestamp of a market data event. A position is simply the quantity of the asset held. Negative positions mean the asset has been shorted.

The latter current_positions dictionary stores contains the current positions for the last market bar update, for each symbol.

In addition to the positions data the portfolio stores holdings, which describe the current market value of the positions held. "Current market value" in this instance means the closing price obtained from the current market bar, which is clearly an approximation, but is reasonable enough for the time being. all_holdings stores the historical list of all symbol holdings, while current_holdings stores the most up to date dictionary of all symbol holdings values.

Initialises the portfolio with bars and an event queue. Also includes a starting datetime index and initial capital (USD unless otherwise stated).

Parameters:

Name Type Description Default
bars DataHandler

The DataHandler object with current market data.

required
events Queue

The Event Queue object.

required
start_date datetime

The start date (bar) of the portfolio.

required
initial_capital float

The starting capital in USD.

100000.0
kwargs dict

Additional arguments - leverage: The leverage to apply to the portfolio. - time_frame: The time frame of the bars. - session_duration: The number of trading hours in a day. - benchmark: The benchmark symbol to compare the portfolio. - output_dir: The directory to save the backtest results. - strategy_name: The name of the strategy (the name must not include 'Strategy' in it). - print_stats: Whether to print the backtest statistics.

{}
Source code in src/bbstrader/btengine/portfolio.py
def __init__(
    self,
    bars: DataHandler,
    events: "Queue[Union[OrderEvent, FillEvent, SignalEvent]]",
    start_date: datetime,
    initial_capital: float = 100000.0,
    **kwargs: Any,
) -> None:
    """
    Initialises the portfolio with bars and an event queue.
    Also includes a starting datetime index and initial capital
    (USD unless otherwise stated).

    Args:
        bars (DataHandler): The DataHandler object with current market data.
        events (Queue): The Event Queue object.
        start_date (datetime): The start date (bar) of the portfolio.
        initial_capital (float): The starting capital in USD.

        kwargs (dict): Additional arguments
            - `leverage`: The leverage to apply to the portfolio.
            - `time_frame`: The time frame of the bars.
            - `session_duration`: The number of trading hours in a day.
            - `benchmark`: The benchmark symbol to compare the portfolio.
            - `output_dir`: The directory to save the backtest results.
            - `strategy_name`: The name of the strategy  (the name must not include 'Strategy' in it).
            - `print_stats`: Whether to print the backtest statistics.
    """
    self.bars = bars
    self.events = events
    self.symbol_list = self.bars.symbols
    self.start_date = start_date
    self.initial_capital = initial_capital
    self._leverage = kwargs.get("leverage", 1)

    self.trading_hours = kwargs.get("session_duration", 23)
    self.benchmark = kwargs.get("benchmark", "SPY")
    self.output_dir = kwargs.get("output_dir", None)
    self.strategy_name = kwargs.get("strategy_name", "")
    self.print_stats = kwargs.get("print_stats", True)
    # Optional per-bar carrying cost on open positions (swap/overnight
    # financing). None keeps the original cost-free behavior.
    self.funding_model = kwargs.get("funding_model")
    timeframe = kwargs.get("time_frame", "D1")
    if timeframe not in TIMEFRAMES:
        raise ValueError("Timeframe not supported")
    if timeframe == "D1":
        self.tf = 252
    else:
        if "m" in timeframe:
            minutes = int(timeframe.replace("m", ""))
            bars_per_day = self.trading_hours * (60 / minutes)
        elif "h" in timeframe:
            hours = int(timeframe.replace("h", ""))
            bars_per_day = self.trading_hours / hours
        else:
            bars_per_day = 1  # Should not be reached given the check
        self.tf = int(252 * bars_per_day)

    self.all_positions: List[Dict[str, Any]] = self.construct_all_positions()
    self.current_positions: Dict[str, Any] = dict(
        (k, v) for k, v in [(s, 0) for s in self.symbol_list]
    )
    self.all_holdings: List[Dict[str, Any]] = self.construct_all_holdings()
    self.current_holdings: Dict[str, Any] = self.construct_current_holdings()
    self.equity_curve: Optional[pd.DataFrame] = None

    n_bars = getattr(self.bars, "n_bars", 0)
    if n_bars:
        pad = [None] * (n_bars + 1)
        self.all_positions.extend(pad)  # type: ignore[arg-type]
        self.all_holdings.extend(pad)  # type: ignore[arg-type]
    self._history_idx = 1  # Next write slot; index 0 holds the seed row.

last_holding property

last_holding: Dict[str, Any]

The most recently recorded holdings row (mark-to-market equity).

construct_all_positions

construct_all_positions() -> List[Dict[str, Any]]

Constructs the positions list using the start_date to determine when the time index will begin.

Source code in src/bbstrader/btengine/portfolio.py
def construct_all_positions(self) -> List[Dict[str, Any]]:
    """
    Constructs the positions list using the start_date
    to determine when the time index will begin.
    """
    d = dict((k, v) for k, v in [(s, 0) for s in self.symbol_list])
    d["Datetime"] = self.start_date
    return [d]

construct_all_holdings

construct_all_holdings() -> List[Dict[str, Any]]

Constructs the holdings list using the start_date to determine when the time index will begin.

Source code in src/bbstrader/btengine/portfolio.py
def construct_all_holdings(self) -> List[Dict[str, Any]]:
    """
    Constructs the holdings list using the start_date
    to determine when the time index will begin.
    """
    d = dict((k, v) for k, v in [(s, 0.0) for s in self.symbol_list])
    d["Datetime"] = self.start_date
    d["Cash"] = self.initial_capital
    d["Commission"] = 0.0
    d["Total"] = self.initial_capital
    return [d]

construct_current_holdings

construct_current_holdings() -> Dict[str, float]

This constructs the dictionary which will hold the instantaneous value of the portfolio across all symbols.

Source code in src/bbstrader/btengine/portfolio.py
def construct_current_holdings(self) -> Dict[str, float]:
    """
    This constructs the dictionary which will hold the instantaneous
    value of the portfolio across all symbols.
    """
    d = dict((k, v) for k, v in [(s, 0.0) for s in self.symbol_list])
    d["Cash"] = self.initial_capital
    d["Commission"] = 0.0
    d["Total"] = self.initial_capital
    return d

update_timeindex

update_timeindex(event: MarketEvent) -> None

Adds a new record to the positions matrix for the current market data bar. This reflects the PREVIOUS bar, i.e. all current market data at this stage is known (OHLCV). Makes use of a MarketEvent from the events queue.

Source code in src/bbstrader/btengine/portfolio.py
def update_timeindex(self, event: MarketEvent) -> None:
    """
    Adds a new record to the positions matrix for the current
    market data bar. This reflects the PREVIOUS bar, i.e. all
    current market data at this stage is known (OHLCV).
    Makes use of a MarketEvent from the events queue.
    """
    latest_datetime = self.bars.get_latest_bar_datetime(self.symbol_list[0])
    # Debit the carrying cost of every open position before booking this
    # bar's holdings, so equity reflects swap/overnight financing.
    if self.funding_model is not None:
        self._apply_funding()
    # Update positions
    # ================
    dp = dict((k, v) for k, v in [(s, 0) for s in self.symbol_list])
    dp["Datetime"] = latest_datetime
    for s in self.symbol_list:
        dp[s] = self.current_positions[s]

    # Update holdings
    # ===============
    dh = dict((k, v) for k, v in [(s, 0) for s in self.symbol_list])
    dh["Datetime"] = latest_datetime
    dh["Cash"] = self.current_holdings["Cash"]
    dh["Commission"] = self.current_holdings["Commission"]
    dh["Total"] = self.current_holdings["Cash"]
    for s in self.symbol_list:
        # Approximation to the real value
        price = self._get_price(s)
        market_value = self.current_positions[s] * price
        dh[s] = market_value
        dh["Total"] += market_value

    # Write into the preallocated history by cursor, falling back to append
    # when the bar count was unknown at construction time.
    if self._history_idx < len(self.all_holdings):
        self.all_positions[self._history_idx] = dp
        self.all_holdings[self._history_idx] = dh
    else:
        self.all_positions.append(dp)
        self.all_holdings.append(dh)
    self._history_idx += 1

update_positions_from_fill

update_positions_from_fill(fill: FillEvent) -> None

Takes a Fill object and updates the position matrix to reflect the new position.

Parameters:

Name Type Description Default
fill FillEvent

The Fill object to update the positions with.

required
Source code in src/bbstrader/btengine/portfolio.py
def update_positions_from_fill(self, fill: FillEvent) -> None:
    """
    Takes a Fill object and updates the position matrix to
    reflect the new position.

    Args:
        fill (FillEvent): The Fill object to update the positions with.
    """
    # Check whether the fill is a buy or sell
    fill_dir = 0
    if fill.direction == "BUY":
        fill_dir = 1
    if fill.direction == "SELL":
        fill_dir = -1

    # Update positions list with new quantities
    self.current_positions[fill.symbol] += fill_dir * fill.quantity

update_holdings_from_fill

update_holdings_from_fill(fill: FillEvent) -> None

Takes a Fill object and updates the holdings matrix to reflect the holdings value.

Parameters:

Name Type Description Default
fill FillEvent

The Fill object to update the holdings with.

required
Source code in src/bbstrader/btengine/portfolio.py
def update_holdings_from_fill(self, fill: FillEvent) -> None:
    """
    Takes a Fill object and updates the holdings matrix to
    reflect the holdings value.

    Args:
        fill (FillEvent): The Fill object to update the holdings with.
    """
    # Check whether the fill is a buy or sell
    fill_dir = 0
    if fill.direction == "BUY":
        fill_dir = 1
    if fill.direction == "SELL":
        fill_dir = -1

    price = (
        fill.fill_cost
        if fill.fill_cost is not None
        else self._get_price(fill.symbol)
    )
    cost = fill_dir * price * fill.quantity
    self.current_holdings[fill.symbol] += cost
    self.current_holdings["Commission"] += fill.commission
    self.current_holdings["Cash"] -= cost + fill.commission
    self.current_holdings["Total"] -= cost + fill.commission

update_fill

update_fill(event: FillEvent) -> None

Updates the portfolio current positions and holdings from a FillEvent.

Source code in src/bbstrader/btengine/portfolio.py
def update_fill(self, event: FillEvent) -> None:
    """
    Updates the portfolio current positions and holdings
    from a FillEvent.
    """
    if event.type == Events.FILL:
        self.update_positions_from_fill(event)
        self.update_holdings_from_fill(event)

generate_order

generate_order(signal: SignalEvent) -> Optional[OrderEvent]

Turns a SignalEvent into an OrderEvent.

Parameters:

Name Type Description Default
signal SignalEvent

The tuple containing Signal information.

required

Returns:

Name Type Description
OrderEvent Optional[OrderEvent]

The OrderEvent to be executed.

Source code in src/bbstrader/btengine/portfolio.py
def generate_order(self, signal: SignalEvent) -> Optional[OrderEvent]:
    """
    Turns a SignalEvent into an OrderEvent.

    Args:
        signal (SignalEvent): The tuple containing Signal information.

    Returns:
        OrderEvent: The OrderEvent to be executed.
    """
    order = None

    symbol = signal.symbol
    direction = signal.signal_type
    quantity = signal.quantity
    strength = signal.strength
    price = signal.price or self._get_price(symbol)
    cur_quantity = self.current_positions[symbol]
    mkt_quantity = round(float(quantity) * float(strength), 2)
    new_quantity = mkt_quantity * self._leverage

    if direction in ["LONG", "SHORT", "EXIT"]:
        order_type = "MKT"
    else:
        order_type = direction

    if direction == "LONG" and new_quantity > 0:
        order = OrderEvent(
            symbol, order_type, new_quantity, "BUY", price, direction
        )
    if direction == "SHORT" and new_quantity > 0:
        order = OrderEvent(
            symbol, order_type, new_quantity, "SELL", price, direction
        )

    if direction == "EXIT" and cur_quantity > 0:
        order = OrderEvent(
            symbol, order_type, abs(cur_quantity), "SELL", price, direction
        )
    if direction == "EXIT" and cur_quantity < 0:
        order = OrderEvent(
            symbol, order_type, abs(cur_quantity), "BUY", price, direction
        )

    return order

update_signal

update_signal(event: SignalEvent) -> None

Acts on a SignalEvent to generate new orders based on the portfolio logic.

Source code in src/bbstrader/btengine/portfolio.py
def update_signal(self, event: SignalEvent) -> None:
    """
    Acts on a SignalEvent to generate new orders
    based on the portfolio logic.
    """
    if event.type == Events.SIGNAL:
        order_event = self.generate_order(event)
        self.events.put(order_event)

create_equity_curve_dataframe

create_equity_curve_dataframe() -> None

Creates a pandas DataFrame from the all_holdings list of dictionaries.

Source code in src/bbstrader/btengine/portfolio.py
def create_equity_curve_dataframe(self) -> None:
    """
    Creates a pandas DataFrame from the all_holdings
    list of dictionaries.
    """
    # Drop any unused preallocated tail (None slots) before building the frame.
    curve = pd.DataFrame([h for h in self.all_holdings if h is not None])
    curve["Datetime"] = pd.to_datetime(curve["Datetime"], utc=True)
    curve.set_index("Datetime", inplace=True)
    curve["Returns"] = curve["Total"].pct_change(fill_method=None)
    curve["Equity Curve"] = (1.0 + curve["Returns"]).cumprod()
    self.equity_curve = curve

output_summary_stats

output_summary_stats() -> List[Any]

Creates a list of summary statistics for the portfolio.

Source code in src/bbstrader/btengine/portfolio.py
def output_summary_stats(self) -> List[Any]:
    """
    Creates a list of summary statistics for the portfolio.
    """
    if self.equity_curve is None:
        self.create_equity_curve_dataframe()

    total_return = self.equity_curve["Equity Curve"].iloc[-1]  # type: ignore
    returns = self.equity_curve["Returns"]  # type: ignore
    pnl = self.equity_curve["Equity Curve"]  # type: ignore

    sharpe_ratio = create_sharpe_ratio(returns, periods=self.tf)
    sortino_ratio = create_sortino_ratio(returns, periods=self.tf)
    drawdown, _, _ = create_drawdowns(pnl)
    drawdown = drawdown.fillna(0.0)
    max_dd = qs.stats.max_drawdown(returns)
    dd_details = qs.stats.drawdown_details(drawdown)
    if dd_details.empty:
        dd_duration = 0
    else:
        dd_duration = dd_details["days"].max()
    self.equity_curve["Drawdown"] = drawdown

    stats = [
        ("Total Return", f"{(total_return - 1.0) * 100.0:.2f}%"),
        ("Sharpe Ratio", f"{sharpe_ratio:.2f}"),
        ("Sortino Ratio", f"{sortino_ratio:.2f}"),
        ("Max Drawdown", f"{max_dd * 100.0:.2f}%"),
        ("Drawdown Duration", f"{dd_duration}"),
    ]
    now = datetime.now().strftime("%Y%m%d%H%M%S")
    strategy_name = self.strategy_name.replace(" ", "_")
    if self.output_dir:
        results_dir = Path(self.output_dir) / strategy_name
    else:
        results_dir = Path(".backtests") / strategy_name
    results_dir.mkdir(parents=True, exist_ok=True)

    csv_file = f"{strategy_name}_{now}_equities.csv"
    png_file = f"{strategy_name}_{now}_returns_heatmap.png"
    html_file = f"{strategy_name}_{now}_report.html"
    self.equity_curve.to_csv(results_dir / csv_file)

    if self.print_stats:
        plot_performance(self.equity_curve, self.strategy_name)
        plot_returns_and_dd(self.equity_curve, self.benchmark, self.strategy_name)
        qs.plots.monthly_heatmap(returns, savefig=f"{results_dir}/{png_file}")
        plot_monthly_yearly_returns(self.equity_curve, self.strategy_name)
        show_qs_stats(
            returns,
            self.benchmark,
            self.strategy_name,
            save_dir=f"{results_dir}/{html_file}",
        )

    return stats

BacktestStrategy

BacktestStrategy(events: Queue[Union[SignalEvent, FillEvent]], symbol_list: List[str], bars: DataHandler, **kwargs: Any)

Bases: BaseStrategy

Strategy implementation specifically for Backtesting. Handles internal state for orders, positions, trades, and cash. Simulates order execution and pending orders.

Initialize the BacktestStrategy object.

Parameters:

Name Type Description Default
events

The event queue.

required
symbol_list

The list of symbols for the strategy.

required
bars

The data handler object.

required
**kwargs

Additional keyword arguments for other classes (e.g, Portfolio, ExecutionHandler). - max_trades : The maximum number of trades allowed per symbol. - time_frame : The time frame for the strategy. - logger : The logger object for the strategy.

required
Source code in src/bbstrader/btengine/strategy.py
def __init__(
    self,
    events: "Queue[Union[SignalEvent, FillEvent]]",
    symbol_list: List[str],
    bars: DataHandler,
    **kwargs: Any,
) -> None:
    """
    Initialize the `BacktestStrategy` object.

    Args:
        events : The event queue.
        symbol_list : The list of symbols for the strategy.
        bars : The data handler object.
        **kwargs : Additional keyword arguments for other classes (e.g, Portfolio, ExecutionHandler).
            - max_trades : The maximum number of trades allowed per symbol.
            - time_frame : The time frame for the strategy.
            - logger : The logger object for the strategy.
    """
    super().__init__(symbol_list, **kwargs)
    self.events = events
    self.data = bars
    self.mode = TradingMode.BACKTEST
    self._portfolio_value = None

    self._intrabar_fills = bool(kwargs.get("intrabar_fills", False))
    self._initialize_portfolio()

cash property writable

cash: float

The latest portfolio value (cash) reported by the engine.

orders property

orders: Dict[str, Dict[str, List[SignalEvent]]]

The pending orders per symbol, keyed by order type.

trades property

trades: Dict[str, Dict[str, int]]

The executed trade counts per symbol, keyed by side.

positions property

positions: Dict[str, Dict[str, Union[int, float]]]

The open position sizes per symbol, keyed by LONG/SHORT.

holdings property

holdings: Dict[str, float]

The current mark-to-market holdings value per symbol.

get_update_from_portfolio

get_update_from_portfolio(positions: Dict[str, float], holdings: Dict[str, float]) -> None

Update the positions and holdings for the strategy from the portfolio.

Positions are the number of shares of a security that are owned in long or short. Holdings are the value (postions * price) of the security that are owned in long or short.

Parameters:

Name Type Description Default
positions

The positions for the symbols in the strategy.

required
holdings

The holdings for the symbols in the strategy.

required
Source code in src/bbstrader/btengine/strategy.py
def get_update_from_portfolio(
    self, positions: Dict[str, float], holdings: Dict[str, float]
) -> None:
    """
    Update the positions and holdings for the strategy from the portfolio.

    Positions are the number of shares of a security that are owned in long or short.
    Holdings are the value (postions * price) of the security that are owned in long or short.

    Args:
        positions : The positions for the symbols in the strategy.
        holdings : The holdings for the symbols in the strategy.
    """
    for symbol in self.symbols:
        if symbol in positions:
            if positions[symbol] > 0:
                self._positions[symbol]["LONG"] = positions[symbol]
            elif positions[symbol] < 0:
                self._positions[symbol]["SHORT"] = positions[symbol]
            else:
                self._positions[symbol]["LONG"] = 0
                self._positions[symbol]["SHORT"] = 0
        if symbol in holdings:
            self._holdings[symbol] = holdings[symbol]

update_trades_from_fill

update_trades_from_fill(event: FillEvent) -> None

This method updates the trades for the strategy based on the fill event. It is used to keep track of the number of trades executed for each order.

Source code in src/bbstrader/btengine/strategy.py
def update_trades_from_fill(self, event: FillEvent) -> None:
    """
    This method updates the trades for the strategy based on the fill event.
    It is used to keep track of the number of trades executed for each order.
    """
    if event.type == Events.FILL:
        if event.order != "EXIT":
            self._trades[event.symbol][event.order] += 1  # type: ignore
        elif event.order == "EXIT" and event.direction == "BUY":
            self._trades[event.symbol]["SHORT"] = 0
        elif event.order == "EXIT" and event.direction == "SELL":
            self._trades[event.symbol]["LONG"] = 0

get_asset_values

get_asset_values(symbol_list: List[str], window: int, value_type: str = 'returns', array: bool = True, **kwargs) -> Optional[Dict[str, Union[np.typing.NDArray, pd.Series]]]

Return the last window values of value_type for each symbol.

Parameters:

Name Type Description Default
symbol_list List[str]

The symbols to fetch values for.

required
window int

The number of most-recent bars required per symbol.

required
value_type str

The bar field to read (for example "returns", "close", "high").

'returns'
array bool

When True return NumPy arrays (NaNs dropped); when False return pandas Series sliced from the bar DataFrame.

True
kwargs

Unused; accepted for forward compatibility.

{}

Returns:

Type Description
Optional[Dict[str, Union[NDArray, Series]]]

Optional[Dict[str, Union[NDArray, pd.Series]]]: A mapping of symbol

Optional[Dict[str, Union[NDArray, Series]]]

to its last window values, or None if any symbol has fewer than

Optional[Dict[str, Union[NDArray, Series]]]

window values available.

Source code in src/bbstrader/btengine/strategy.py
def get_asset_values(
    self,
    symbol_list: List[str],
    window: int,
    value_type: str = "returns",
    array: bool = True,
    **kwargs,
) -> Optional[Dict[str, Union[np.typing.NDArray, pd.Series]]]:
    """Return the last ``window`` values of ``value_type`` for each symbol.

    Args:
        symbol_list (List[str]): The symbols to fetch values for.
        window (int): The number of most-recent bars required per symbol.
        value_type (str): The bar field to read (for example ``"returns"``,
            ``"close"``, ``"high"``).
        array (bool): When True return NumPy arrays (NaNs dropped); when
            False return pandas Series sliced from the bar DataFrame.
        kwargs: Unused; accepted for forward compatibility.

    Returns:
        Optional[Dict[str, Union[NDArray, pd.Series]]]: A mapping of symbol
        to its last ``window`` values, or None if any symbol has fewer than
        ``window`` values available.
    """
    asset_values = {}
    for asset in symbol_list:
        if array:
            values = self.data.get_latest_bars_values(asset, value_type, N=window)
            asset_values[asset] = values[~np.isnan(values)]
        else:
            values_df = self.data.get_latest_bars(asset, N=window)
            if isinstance(values_df, pd.DataFrame):
                asset_values[asset] = values_df[value_type]

    if all(len(values) >= window for values in asset_values.values()):
        return {a: v[-window:] for a, v in asset_values.items()}
    return None

calculate_signals abstractmethod

calculate_signals(event: MarketEvent) -> None

Compute trading signals for the current bar.

Subclasses implement their strategy logic here, placing orders via the buy_mkt/sell_mkt/close_positions helpers.

Parameters:

Name Type Description Default
event MarketEvent

The market event for the current bar.

required
Source code in src/bbstrader/btengine/strategy.py
@abstractmethod
def calculate_signals(self, event: MarketEvent) -> None:
    """Compute trading signals for the current bar.

    Subclasses implement their strategy logic here, placing orders via the
    ``buy_mkt``/``sell_mkt``/``close_positions`` helpers.

    Args:
        event (MarketEvent): The market event for the current bar.
    """
    ...

buy_mkt

buy_mkt(id: int, symbol: str, price: float, quantity: int, strength: float = 1.0, dtime: Optional[Union[datetime, Timestamp]] = None) -> None

Open a long position

See bbstrader.btengine.event.SignalEvent for more details on arguments.

Source code in src/bbstrader/btengine/strategy.py
def buy_mkt(
    self,
    id: int,
    symbol: str,
    price: float,
    quantity: int,
    strength: float = 1.0,
    dtime: Optional[Union[datetime, pd.Timestamp]] = None,
) -> None:
    """
    Open a long position

    See `bbstrader.btengine.event.SignalEvent` for more details on arguments.
    """
    if dtime is None:
        dtime = self.get_current_dt()
    self._send_order(id, symbol, "LONG", strength, price, quantity, dtime)

sell_mkt

sell_mkt(id: int, symbol: str, price: float, quantity: int, strength: float = 1.0, dtime: Optional[Union[datetime, Timestamp]] = None) -> None

Open a short position

See bbstrader.btengine.event.SignalEvent for more details on arguments.

Source code in src/bbstrader/btengine/strategy.py
def sell_mkt(
    self,
    id: int,
    symbol: str,
    price: float,
    quantity: int,
    strength: float = 1.0,
    dtime: Optional[Union[datetime, pd.Timestamp]] = None,
) -> None:
    """
    Open a short position

    See `bbstrader.btengine.event.SignalEvent` for more details on arguments.
    """
    if dtime is None:
        dtime = self.get_current_dt()
    self._send_order(id, symbol, "SHORT", strength, price, quantity, dtime)

close_positions

close_positions(id: int, symbol: str, price: float, quantity: int, strength: float = 1.0, dtime: Optional[Union[datetime, Timestamp]] = None) -> None

Close a position or exit all positions

See bbstrader.btengine.event.SignalEvent for more details on arguments.

Source code in src/bbstrader/btengine/strategy.py
def close_positions(
    self,
    id: int,
    symbol: str,
    price: float,
    quantity: int,
    strength: float = 1.0,
    dtime: Optional[Union[datetime, pd.Timestamp]] = None,
) -> None:
    """
    Close a position or exit all positions

    See `bbstrader.btengine.event.SignalEvent` for more details on arguments.
    """
    if dtime is None:
        dtime = self.get_current_dt()
    self._send_order(id, symbol, "EXIT", strength, price, quantity, dtime)

buy_stop

buy_stop(id: int, symbol: str, price: float, quantity: int, strength: float = 1.0, dtime: Optional[Union[datetime, Timestamp]] = None) -> None

Open a pending order to buy at a stop price

See bbstrader.btengine.event.SignalEvent for more details on arguments.

Source code in src/bbstrader/btengine/strategy.py
def buy_stop(
    self,
    id: int,
    symbol: str,
    price: float,
    quantity: int,
    strength: float = 1.0,
    dtime: Optional[Union[datetime, pd.Timestamp]] = None,
) -> None:
    """
    Open a pending order to buy at a stop price

    See `bbstrader.btengine.event.SignalEvent` for more details on arguments.
    """
    current_price = self.data.get_latest_bar_value(symbol, "close")
    if price <= current_price:
        raise ValueError(
            "The buy_stop price must be greater than the current price."
        )
    if dtime is None:
        dtime = self.get_current_dt()
    order = SignalEvent(
        id,
        symbol,
        dtime,
        "LONG",
        quantity=quantity,
        strength=strength,
        price=price,  # type: ignore
    )
    self._orders[symbol]["BSTP"].append(order)

sell_stop

sell_stop(id: int, symbol: str, price: float, quantity: int, strength: float = 1.0, dtime: Optional[Union[datetime, Timestamp]] = None) -> None

Open a pending order to sell at a stop price

See bbstrader.btengine.event.SignalEvent for more details on arguments.

Source code in src/bbstrader/btengine/strategy.py
def sell_stop(
    self,
    id: int,
    symbol: str,
    price: float,
    quantity: int,
    strength: float = 1.0,
    dtime: Optional[Union[datetime, pd.Timestamp]] = None,
) -> None:
    """
    Open a pending order to sell at a stop price

    See `bbstrader.btengine.event.SignalEvent` for more details on arguments.
    """
    current_price = self.data.get_latest_bar_value(symbol, "close")
    if price >= current_price:
        raise ValueError("The sell_stop price must be less than the current price.")
    if dtime is None:
        dtime = self.get_current_dt()
    order = SignalEvent(
        id,
        symbol,
        dtime,  # type: ignore
        "SHORT",
        quantity=quantity,
        strength=strength,
        price=price,
    )
    self._orders[symbol]["SSTP"].append(order)

buy_limit

buy_limit(id: int, symbol: str, price: float, quantity: int, strength: float = 1.0, dtime: Optional[Union[datetime, Timestamp]] = None) -> None

Open a pending order to buy at a limit price

See bbstrader.btengine.event.SignalEvent for more details on arguments.

Source code in src/bbstrader/btengine/strategy.py
def buy_limit(
    self,
    id: int,
    symbol: str,
    price: float,
    quantity: int,
    strength: float = 1.0,
    dtime: Optional[Union[datetime, pd.Timestamp]] = None,
) -> None:
    """
    Open a pending order to buy at a limit price

    See `bbstrader.btengine.event.SignalEvent` for more details on arguments.
    """
    current_price = self.data.get_latest_bar_value(symbol, "close")
    if price >= current_price:
        raise ValueError("The buy_limit price must be less than the current price.")
    if dtime is None:
        dtime = self.get_current_dt()
    order = SignalEvent(
        id,
        symbol,
        dtime,
        "LONG",
        quantity=quantity,
        strength=strength,
        price=price,  # type: ignore
    )
    self._orders[symbol]["BLMT"].append(order)

sell_limit

sell_limit(id: int, symbol: str, price: float, quantity: int, strength: float = 1.0, dtime: Optional[Union[datetime, Timestamp]] = None) -> None

Open a pending order to sell at a limit price

See bbstrader.btengine.event.SignalEvent for more details on arguments.

Source code in src/bbstrader/btengine/strategy.py
def sell_limit(
    self,
    id: int,
    symbol: str,
    price: float,
    quantity: int,
    strength: float = 1.0,
    dtime: Optional[Union[datetime, pd.Timestamp]] = None,
) -> None:
    """
    Open a pending order to sell at a limit price

    See `bbstrader.btengine.event.SignalEvent` for more details on arguments.
    """
    current_price = self.data.get_latest_bar_value(symbol, "close")
    if price <= current_price:
        raise ValueError(
            "The sell_limit price must be greater than the current price."
        )
    if dtime is None:
        dtime = self.get_current_dt()
    order = SignalEvent(
        id,
        symbol,
        dtime,  # type: ignore
        "SHORT",
        quantity=quantity,
        strength=strength,
        price=price,
    )
    self._orders[symbol]["SLMT"].append(order)

buy_stop_limit

buy_stop_limit(id: int, symbol: str, price: float, stoplimit: float, quantity: int, strength: float = 1.0, dtime: Optional[Union[datetime, Timestamp]] = None) -> None

Open a pending order to buy at a stop-limit price

See bbstrader.btengine.event.SignalEvent for more details on arguments.

Source code in src/bbstrader/btengine/strategy.py
def buy_stop_limit(
    self,
    id: int,
    symbol: str,
    price: float,
    stoplimit: float,
    quantity: int,
    strength: float = 1.0,
    dtime: Optional[Union[datetime, pd.Timestamp]] = None,
) -> None:
    """
    Open a pending order to buy at a stop-limit price

    See `bbstrader.btengine.event.SignalEvent` for more details on arguments.
    """
    current_price = self.data.get_latest_bar_value(symbol, "close")
    if price <= current_price:
        raise ValueError(
            f"The stop price {price} must be greater than the current price {current_price}."
        )
    if price >= stoplimit:
        raise ValueError(
            f"The stop-limit price {stoplimit} must be greater than the price {price}."
        )
    if dtime is None:
        dtime = self.get_current_dt()
    order = SignalEvent(
        id,
        symbol,
        dtime,  # type: ignore
        "LONG",
        quantity=quantity,
        strength=strength,
        price=price,
        stoplimit=stoplimit,
    )
    self._orders[symbol]["BSTPLMT"].append(order)

sell_stop_limit

sell_stop_limit(id: int, symbol: str, price: float, stoplimit: float, quantity: int, strength: float = 1.0, dtime: Optional[Union[datetime, Timestamp]] = None) -> None

Open a pending order to sell at a stop-limit price

See bbstrader.btengine.event.SignalEvent for more details on arguments.

Source code in src/bbstrader/btengine/strategy.py
def sell_stop_limit(
    self,
    id: int,
    symbol: str,
    price: float,
    stoplimit: float,
    quantity: int,
    strength: float = 1.0,
    dtime: Optional[Union[datetime, pd.Timestamp]] = None,
) -> None:
    """
    Open a pending order to sell at a stop-limit price

    See `bbstrader.btengine.event.SignalEvent` for more details on arguments.
    """
    current_price = self.data.get_latest_bar_value(symbol, "close")
    if price >= current_price:
        raise ValueError(
            f"The stop price {price} must be less than the current price {current_price}."
        )
    if price <= stoplimit:
        raise ValueError(
            f"The stop-limit price {stoplimit} must be less than the price {price}."
        )
    if dtime is None:
        dtime = self.get_current_dt()
    order = SignalEvent(
        id,
        symbol,
        dtime,  # type: ignore
        "SHORT",
        quantity=quantity,
        strength=strength,
        price=price,
        stoplimit=stoplimit,
    )
    self._orders[symbol]["SSTPLMT"].append(order)

check_pending_orders

check_pending_orders() -> None

Check for pending orders and handle them accordingly.

Source code in src/bbstrader/btengine/strategy.py
def check_pending_orders(self) -> None:
    """
    Check for pending orders and handle them accordingly.
    """

    def logmsg(
        order: SignalEvent,
        type: str,
        symbol: str,
        dtime: Union[datetime, pd.Timestamp],
    ) -> None:
        """Log a triggered pending order at INFO level.

        Args:
            order (SignalEvent): The pending order that was triggered.
            type (str): A label for the order type, used in the message.
            symbol (str): The instrument the order is for.
            dtime (Union[datetime, pd.Timestamp]): The trigger bar timestamp.
        """
        self.logger.info(
            f"{type} ORDER EXECUTED: SYMBOL={symbol}, QUANTITY={order.quantity}, "
            f"PRICE @ {round(order.price, 5)}",  # type: ignore
            custom_time=dtime,
        )

    def process_orders(
        order_type: str,
        condition: Callable[[SignalEvent], bool],
        execute_fn: Callable[[SignalEvent], None],
        log_label: str,
        symbol: str,
        dtime: Union[datetime, pd.Timestamp],
    ) -> None:
        """Trigger and remove pending orders of one type whose condition holds.

        Args:
            order_type (str): The pending-order bucket to scan (for example
                ``"BLMT"``, ``"SSTP"``).
            condition (Callable[[SignalEvent], bool]): Predicate deciding
                whether an order should trigger this bar.
            execute_fn (Callable[[SignalEvent], None]): Callback that turns a
                triggered order into a market order.
            log_label (str): Label passed to :func:`logmsg` for the fill log.
            symbol (str): The instrument whose orders are processed.
            dtime (Union[datetime, pd.Timestamp]): The current bar timestamp.
        """
        for order in self._orders[symbol][order_type].copy():
            if condition(order):
                execute_fn(order)
                try:
                    self._orders[symbol][order_type].remove(order)
                    assert order not in self._orders[symbol][order_type]
                except AssertionError:
                    self._orders[symbol][order_type] = [
                        o for o in self._orders[symbol][order_type] if o != order
                    ]
                logmsg(order, log_label, symbol, dtime)

    for symbol in self.symbols:
        dtime = self.data.get_latest_bar_datetime(symbol)
        latest_close = self.data.get_latest_bar_value(symbol, "close")

        if self._intrabar_fills:
            up_ref = self.data.get_latest_bar_value(symbol, "high")
            down_ref = self.data.get_latest_bar_value(symbol, "low")
        else:
            up_ref = down_ref = latest_close

        process_orders(
            "BLMT",
            lambda o: down_ref <= o.price,  # type: ignore
            lambda o: self.buy_mkt(
                o.strategy_id,
                symbol,
                o.price,
                o.quantity,
                dtime=dtime,  # type: ignore
            ),
            "BUY LIMIT",
            symbol,
            dtime,
        )

        process_orders(
            "SLMT",
            lambda o: up_ref >= o.price,  # type: ignore
            lambda o: self.sell_mkt(
                o.strategy_id,
                symbol,
                o.price,
                o.quantity,
                dtime=dtime,  # type: ignore
            ),
            "SELL LIMIT",
            symbol,
            dtime,
        )

        process_orders(
            "BSTP",
            lambda o: up_ref >= o.price,  # type: ignore
            lambda o: self.buy_mkt(
                o.strategy_id,
                symbol,
                o.price,
                o.quantity,
                dtime=dtime,  # type: ignore
            ),
            "BUY STOP",
            symbol,
            dtime,
        )

        process_orders(
            "SSTP",
            lambda o: down_ref <= o.price,  # type: ignore
            lambda o: self.sell_mkt(
                o.strategy_id,
                symbol,
                o.price,
                o.quantity,
                dtime=dtime,  # type: ignore
            ),
            "SELL STOP",
            symbol,
            dtime,
        )

        process_orders(
            "BSTPLMT",
            lambda o: up_ref >= o.price,  # type: ignore
            lambda o: self.buy_limit(
                o.strategy_id,
                symbol,
                o.stoplimit,
                o.quantity,
                dtime=dtime,  # type: ignore
            ),
            "BUY STOP LIMIT",
            symbol,
            dtime,
        )

        process_orders(
            "SSTPLMT",
            lambda o: down_ref <= o.price,  # type: ignore
            lambda o: self.sell_limit(
                o.strategy_id,
                symbol,
                o.stoplimit,
                o.quantity,
                dtime=dtime,  # type: ignore
            ),
            "SELL STOP LIMIT",
            symbol,
            dtime,
        )

MultiStrategy

MultiStrategy(strategies: List[BacktestStrategy])

Runs several strategies against one shared portfolio, cash account and clock.

The engine sees a single strategy; this adapter fans every engine callback out to each child strategy. All children post signals to the same event queue, so the shared Portfolio nets their positions and allocates one pool of capital enabling cross-strategy capital-allocation and netting tests that a single-strategy engine cannot express.

Children are typically scoped to disjoint symbol sets; when they overlap, positions net at the portfolio level and each child's trade counters track its own fills for symbols it trades.

Wrap one or more child strategies behind a single engine interface.

Parameters:

Name Type Description Default
strategies List[BacktestStrategy]

The child strategies to run against the shared portfolio. The union of their symbols becomes this adapter's symbol set.

required

Raises:

Type Description
ValueError

If strategies is empty.

Source code in src/bbstrader/btengine/strategy.py
def __init__(self, strategies: List["BacktestStrategy"]) -> None:
    """Wrap one or more child strategies behind a single engine interface.

    Args:
        strategies (List[BacktestStrategy]): The child strategies to run
            against the shared portfolio. The union of their symbols becomes
            this adapter's symbol set.

    Raises:
        ValueError: If ``strategies`` is empty.
    """
    if not strategies:
        raise ValueError("MultiStrategy requires at least one strategy.")
    self.strategies = list(strategies)
    self.symbols = sorted({s for st in self.strategies for s in st.symbols})

cash property writable

cash: float

The shared portfolio cash, read from the first child strategy.

calculate_signals

calculate_signals(event: MarketEvent) -> None

Fan the market event out to every child strategy.

Parameters:

Name Type Description Default
event MarketEvent

The market event for the current bar.

required
Source code in src/bbstrader/btengine/strategy.py
def calculate_signals(self, event: MarketEvent) -> None:
    """Fan the market event out to every child strategy.

    Args:
        event (MarketEvent): The market event for the current bar.
    """
    for st in self.strategies:
        st.calculate_signals(event)

check_pending_orders

check_pending_orders() -> None

Ask every child strategy to evaluate its pending orders.

Source code in src/bbstrader/btengine/strategy.py
def check_pending_orders(self) -> None:
    """Ask every child strategy to evaluate its pending orders."""
    for st in self.strategies:
        st.check_pending_orders()

get_update_from_portfolio

get_update_from_portfolio(positions: Dict[str, float], holdings: Dict[str, float]) -> None

Push the latest portfolio positions and holdings to each child.

Parameters:

Name Type Description Default
positions Dict[str, float]

Current position sizes per symbol.

required
holdings Dict[str, float]

Current holdings value per symbol.

required
Source code in src/bbstrader/btengine/strategy.py
def get_update_from_portfolio(
    self, positions: Dict[str, float], holdings: Dict[str, float]
) -> None:
    """Push the latest portfolio positions and holdings to each child.

    Args:
        positions (Dict[str, float]): Current position sizes per symbol.
        holdings (Dict[str, float]): Current holdings value per symbol.
    """
    for st in self.strategies:
        st.get_update_from_portfolio(positions, holdings)

update_trades_from_fill

update_trades_from_fill(event: FillEvent) -> None

Route a fill to the child strategies that trade its symbol.

Parameters:

Name Type Description Default
event FillEvent

The fill to apply to matching child strategies.

required
Source code in src/bbstrader/btengine/strategy.py
def update_trades_from_fill(self, event: FillEvent) -> None:
    """Route a fill to the child strategies that trade its symbol.

    Args:
        event (FillEvent): The fill to apply to matching child strategies.
    """
    for st in self.strategies:
        if event.symbol in st.symbols:
            st.update_trades_from_fill(event)

MultiTimeFrame

MultiTimeFrame(data: DataHandler, lookback: int = 1000)

Derive completed higher-timeframe bars from a base-timeframe DataHandler.

Use inside a strategy's calculate_signals to read slow-timeframe context while executing on the fast base feed::

mtf = MultiTimeFrame(self.data)
daily_close = mtf.htf_value(symbol, "D1")   # last *completed* daily close

Wrap a base-timeframe DataHandler for higher-timeframe access.

Parameters:

Name Type Description Default
data DataHandler

The base-timeframe data feed to resample from.

required
lookback int

Default number of base bars to pull when resampling.

1000
Source code in src/bbstrader/btengine/timeframe.py
def __init__(self, data: DataHandler, lookback: int = 1000) -> None:
    """Wrap a base-timeframe DataHandler for higher-timeframe access.

    Args:
        data (DataHandler): The base-timeframe data feed to resample from.
        lookback (int): Default number of base bars to pull when resampling.
    """
    self.data = data
    self.lookback = lookback

htf_bars

htf_bars(symbol: str, rule: str, n: Optional[int] = None, lookback: Optional[int] = None, drop_partial: bool = True) -> pd.DataFrame

Return resampled HTF bars for symbol.

With drop_partial (default) the final, possibly still-forming bucket is dropped so only completed HTF bars are visible preventing look-ahead. n limits the result to the most recent n bars.

Source code in src/bbstrader/btengine/timeframe.py
def htf_bars(
    self,
    symbol: str,
    rule: str,
    n: Optional[int] = None,
    lookback: Optional[int] = None,
    drop_partial: bool = True,
) -> pd.DataFrame:
    """Return resampled HTF bars for ``symbol``.

    With ``drop_partial`` (default) the final, possibly still-forming bucket
    is dropped so only completed HTF bars are visible preventing
    look-ahead. ``n`` limits the result to the most recent ``n`` bars.
    """
    base = self._base_bars(symbol, lookback)
    res = resample_ohlcv(base, rule)
    if drop_partial and len(res):
        # Always drop the final bucket: it may still be forming, so this
        # guarantees only completed HTF bars are visible (no look-ahead).
        res = res.iloc[:-1]
    return res.tail(n) if n else res

htf_value

htf_value(symbol: str, rule: str, val_type: str = 'close', lookback: Optional[int] = None, drop_partial: bool = True) -> Optional[float]

Latest completed HTF value for symbol (None if not enough data).

Source code in src/bbstrader/btengine/timeframe.py
def htf_value(
    self,
    symbol: str,
    rule: str,
    val_type: str = "close",
    lookback: Optional[int] = None,
    drop_partial: bool = True,
) -> Optional[float]:
    """Latest completed HTF value for ``symbol`` (None if not enough data)."""
    res = self.htf_bars(symbol, rule, lookback=lookback, drop_partial=drop_partial)
    if res.empty or val_type not in res.columns:
        return None
    return float(res[val_type].iloc[-1])

VectorizedResult dataclass

VectorizedResult(equity: NDArray[float64], returns: NDArray[float64], position: NDArray[float64], trades: List[Tuple[int, int]], init_cash: float, periods: int)

Result of a vectorized backtest with lazily computed metrics.

total_return property

total_return: float

The total return over the run as a fraction of initial capital.

num_trades property

num_trades: int

The number of completed round-trip trades.

exposure property

exposure: float

Fraction of bars spent in the market.

sharpe property

sharpe: float

The Sharpe ratio of bar returns, annualised by periods.

max_drawdown property

max_drawdown: float

Largest peak-to-trough drawdown of the equity curve (as a fraction).

win_rate property

win_rate: float

The fraction of trades whose equity rose between entry and exit.

to_frame

to_frame(index: Optional[Index] = None) -> pd.DataFrame

Return the run as a DataFrame of position, returns and equity.

Parameters:

Name Type Description Default
index Optional[Index]

An optional index (for example the price series' DatetimeIndex) to label the rows.

None

Returns:

Type Description
DataFrame

pd.DataFrame: Columns Position, Returns and Equity.

Source code in src/bbstrader/btengine/vectorized.py
def to_frame(self, index: Optional[pd.Index] = None) -> pd.DataFrame:
    """Return the run as a DataFrame of position, returns and equity.

    Args:
        index (Optional[pd.Index]): An optional index (for example the price
            series' DatetimeIndex) to label the rows.

    Returns:
        pd.DataFrame: Columns ``Position``, ``Returns`` and ``Equity``.
    """
    df = pd.DataFrame(
        {"Position": self.position, "Returns": self.returns, "Equity": self.equity}
    )
    if index is not None:
        df.index = index
    return df

summary

summary() -> dict

Return a dict of the headline metrics for the run.

Returns:

Name Type Description
dict dict

total_return, sharpe, max_drawdown, num_trades,

dict

win_rate and exposure.

Source code in src/bbstrader/btengine/vectorized.py
def summary(self) -> dict:
    """Return a dict of the headline metrics for the run.

    Returns:
        dict: ``total_return``, ``sharpe``, ``max_drawdown``, ``num_trades``,
        ``win_rate`` and ``exposure``.
    """
    return {
        "total_return": self.total_return,
        "sharpe": self.sharpe,
        "max_drawdown": self.max_drawdown,
        "num_trades": self.num_trades,
        "win_rate": self.win_rate,
        "exposure": self.exposure,
    }

SMACrossoverStrategy

SMACrossoverStrategy(events, symbol_list, bars, **kwargs)

Bases: _TemplateBase

Trend following: go long when the fast SMA crosses above the slow SMA.

kwargs

fast (int, default 10): Fast SMA window. slow (int, default 30): Slow SMA window. quantity (int, default 100): Units per trade.

Initialise the SMA crossover with fast/slow windows.

Parameters:

Name Type Description Default
events Any

The engine event queue.

required
symbol_list List[str]

The symbols traded by the strategy.

required
bars Any

The DataHandler providing market data.

required
kwargs Any

fast (default 10) and slow (default 30) SMA windows, plus the shared template options.

{}

Raises:

Type Description
ValueError

If fast is not strictly less than slow.

Source code in src/bbstrader/btengine/templates.py
def __init__(self, events, symbol_list, bars, **kwargs) -> None:
    """Initialise the SMA crossover with fast/slow windows.

    Args:
        events (Any): The engine event queue.
        symbol_list (List[str]): The symbols traded by the strategy.
        bars (Any): The DataHandler providing market data.
        kwargs (Any): ``fast`` (default 10) and ``slow`` (default 30) SMA
            windows, plus the shared template options.

    Raises:
        ValueError: If ``fast`` is not strictly less than ``slow``.
    """
    super().__init__(events, symbol_list, bars, **kwargs)
    self.fast = int(kwargs.get("fast", 10))
    self.slow = int(kwargs.get("slow", 30))
    if self.fast >= self.slow:
        raise ValueError(f"fast ({self.fast}) must be < slow ({self.slow}).")

calculate_signals

calculate_signals(event: MarketEvent) -> None

Enter long on an up-cross and exit on a down-cross of the SMAs.

Parameters:

Name Type Description Default
event MarketEvent

The market event driving the bar; ignored unless it is a MARKET event.

required
Source code in src/bbstrader/btengine/templates.py
def calculate_signals(self, event: MarketEvent) -> None:
    """Enter long on an up-cross and exit on a down-cross of the SMAs.

    Args:
        event (MarketEvent): The market event driving the bar; ignored unless
            it is a MARKET event.
    """
    if event.type != Events.MARKET:
        return
    for symbol in self.symbols:
        # Need one extra bar so we can see the cross (current vs previous).
        arr = self._closes(symbol, self.slow + 1)
        if arr is None:
            continue
        fast = ind.sma(arr, self.fast)
        slow = ind.sma(arr, self.slow)
        price = float(arr[-1])
        dt = self.data.get_latest_bar_datetime(symbol)
        crossed_up = fast[-2] <= slow[-2] and fast[-1] > slow[-1]
        crossed_down = fast[-2] >= slow[-2] and fast[-1] < slow[-1]
        if crossed_up and not self._is_long(symbol):
            self.buy_mkt(
                self.strategy_id, symbol, price, self.qty[symbol], dtime=dt
            )
        elif crossed_down and self._is_long(symbol):
            self.close_positions(
                self.strategy_id, symbol, price, self.qty[symbol], dtime=dt
            )

RSIMeanReversionStrategy

RSIMeanReversionStrategy(events, symbol_list, bars, **kwargs)

Bases: _TemplateBase

Mean reversion: buy when RSI is oversold, exit when it recovers.

kwargs

period (int, default 14): RSI lookback. oversold (float, default 30): Entry threshold. exit_level (float, default 55): Exit threshold. quantity (int, default 100): Units per trade.

Initialise the RSI mean-reversion thresholds.

Parameters:

Name Type Description Default
events Any

The engine event queue.

required
symbol_list List[str]

The symbols traded by the strategy.

required
bars Any

The DataHandler providing market data.

required
kwargs Any

period (RSI lookback, default 14), oversold (entry threshold, default 30) and exit_level (exit threshold, default 55), plus the shared template options.

{}
Source code in src/bbstrader/btengine/templates.py
def __init__(self, events, symbol_list, bars, **kwargs) -> None:
    """Initialise the RSI mean-reversion thresholds.

    Args:
        events (Any): The engine event queue.
        symbol_list (List[str]): The symbols traded by the strategy.
        bars (Any): The DataHandler providing market data.
        kwargs (Any): ``period`` (RSI lookback, default 14), ``oversold``
            (entry threshold, default 30) and ``exit_level`` (exit threshold,
            default 55), plus the shared template options.
    """
    super().__init__(events, symbol_list, bars, **kwargs)
    self.period = int(kwargs.get("period", 14))
    self.oversold = float(kwargs.get("oversold", 30.0))
    self.exit_level = float(kwargs.get("exit_level", 55.0))

calculate_signals

calculate_signals(event: MarketEvent) -> None

Buy when RSI is oversold and exit when it recovers above the level.

Parameters:

Name Type Description Default
event MarketEvent

The market event driving the bar; ignored unless it is a MARKET event.

required
Source code in src/bbstrader/btengine/templates.py
def calculate_signals(self, event: MarketEvent) -> None:
    """Buy when RSI is oversold and exit when it recovers above the level.

    Args:
        event (MarketEvent): The market event driving the bar; ignored unless
            it is a MARKET event.
    """
    if event.type != Events.MARKET:
        return
    for symbol in self.symbols:
        arr = self._closes(symbol, self.period + 2)
        if arr is None:
            continue
        rsi = ind.rsi(arr, self.period)
        latest = rsi[-1]
        if latest != latest:  # NaN guard
            continue
        price = float(arr[-1])
        dt = self.data.get_latest_bar_datetime(symbol)
        if latest <= self.oversold and not self._is_long(symbol):
            self.buy_mkt(
                self.strategy_id, symbol, price, self.qty[symbol], dtime=dt
            )
        elif latest >= self.exit_level and self._is_long(symbol):
            self.close_positions(
                self.strategy_id, symbol, price, self.qty[symbol], dtime=dt
            )

DonchianBreakoutStrategy

DonchianBreakoutStrategy(events, symbol_list, bars, **kwargs)

Bases: _TemplateBase

Breakout: go long when price closes above the prior N-bar high.

The channel is taken from the previous bar to avoid look-ahead. Exit when price closes below the prior N-bar low.

kwargs

window (int, default 20): Donchian channel lookback. quantity (int, default 100): Units per trade.

Initialise the Donchian breakout channel lookback.

Parameters:

Name Type Description Default
events Any

The engine event queue.

required
symbol_list List[str]

The symbols traded by the strategy.

required
bars Any

The DataHandler providing market data.

required
kwargs Any

window (channel lookback, default 20), plus the shared template options.

{}
Source code in src/bbstrader/btengine/templates.py
def __init__(self, events, symbol_list, bars, **kwargs) -> None:
    """Initialise the Donchian breakout channel lookback.

    Args:
        events (Any): The engine event queue.
        symbol_list (List[str]): The symbols traded by the strategy.
        bars (Any): The DataHandler providing market data.
        kwargs (Any): ``window`` (channel lookback, default 20), plus the
            shared template options.
    """
    super().__init__(events, symbol_list, bars, **kwargs)
    self.window = int(kwargs.get("window", 20))

calculate_signals

calculate_signals(event: MarketEvent) -> None

Go long on a close above the prior N-bar high; exit below the low.

Parameters:

Name Type Description Default
event MarketEvent

The market event driving the bar; ignored unless it is a MARKET event.

required
Source code in src/bbstrader/btengine/templates.py
def calculate_signals(self, event: MarketEvent) -> None:
    """Go long on a close above the prior N-bar high; exit below the low.

    Args:
        event (MarketEvent): The market event driving the bar; ignored unless
            it is a MARKET event.
    """
    if event.type != Events.MARKET:
        return
    for symbol in self.symbols:
        highs = self.get_asset_values(
            [symbol], window=self.window + 1, value_type="high"
        )
        lows = self.get_asset_values(
            [symbol], window=self.window + 1, value_type="low"
        )
        closes = self._closes(symbol, self.window + 1)
        if closes is None or not highs or not lows:
            continue
        high = highs.get(symbol)
        low = lows.get(symbol)
        if high is None or low is None or len(high) < self.window + 1:
            continue
        upper = ind.donchian(high, low, self.window)[1]
        lower = ind.donchian(high, low, self.window)[0]
        # Compare current close against the *previous* bar's channel.
        prior_upper = upper[-2]
        prior_lower = lower[-2]
        price = float(closes[-1])
        dt = self.data.get_latest_bar_datetime(symbol)
        if price > prior_upper and not self._is_long(symbol):
            self.buy_mkt(
                self.strategy_id, symbol, price, self.qty[symbol], dtime=dt
            )
        elif price < prior_lower and self._is_long(symbol):
            self.close_positions(
                self.strategy_id, symbol, price, self.qty[symbol], dtime=dt
            )

historical_var

historical_var(returns: ReturnsLike, level: float = 0.95) -> float

Historical Value-at-Risk as a positive loss fraction at level.

Source code in src/bbstrader/btengine/analytics.py
def historical_var(returns: ReturnsLike, level: float = 0.95) -> float:
    """Historical Value-at-Risk as a positive loss fraction at ``level``."""
    r = _clean(returns)
    if r.size == 0:
        return 0.0
    return float(-np.quantile(r, 1.0 - level))

parametric_var

parametric_var(returns: ReturnsLike, level: float = 0.95) -> float

Gaussian (parametric) Value-at-Risk as a positive loss fraction.

Source code in src/bbstrader/btengine/analytics.py
def parametric_var(returns: ReturnsLike, level: float = 0.95) -> float:
    """Gaussian (parametric) Value-at-Risk as a positive loss fraction."""
    r = _clean(returns)
    if r.size == 0:
        return 0.0
    z = stats.norm.ppf(1.0 - level)
    return float(-(r.mean() + r.std(ddof=1) * z))

historical_cvar

historical_cvar(returns: ReturnsLike, level: float = 0.95) -> float

Historical Conditional VaR (expected shortfall) beyond the VaR threshold.

Source code in src/bbstrader/btengine/analytics.py
def historical_cvar(returns: ReturnsLike, level: float = 0.95) -> float:
    """Historical Conditional VaR (expected shortfall) beyond the VaR threshold."""
    r = _clean(returns)
    if r.size == 0:
        return 0.0
    threshold = np.quantile(r, 1.0 - level)
    tail = r[r <= threshold]
    if tail.size == 0:
        return float(-threshold)
    return float(-tail.mean())

parametric_cvar

parametric_cvar(returns: ReturnsLike, level: float = 0.95) -> float

Gaussian Conditional VaR (expected shortfall).

Source code in src/bbstrader/btengine/analytics.py
def parametric_cvar(returns: ReturnsLike, level: float = 0.95) -> float:
    """Gaussian Conditional VaR (expected shortfall)."""
    r = _clean(returns)
    if r.size == 0:
        return 0.0
    alpha = 1.0 - level
    z = stats.norm.ppf(alpha)
    es = r.mean() - r.std(ddof=1) * stats.norm.pdf(z) / alpha
    return float(-es)

monte_carlo_bootstrap

monte_carlo_bootstrap(returns: ReturnsLike, n_sims: int = 1000, horizon: Optional[int] = None, seed: int = 0, quantiles=(0.05, 0.5, 0.95)) -> MonteCarloResult

Bootstrap the return series into equity-curve confidence bands.

Resamples the historical returns with replacement to build n_sims equity paths over horizon steps, then reports per-step percentile bands and the terminal-return distribution. Deterministic given seed.

Source code in src/bbstrader/btengine/analytics.py
def monte_carlo_bootstrap(
    returns: ReturnsLike,
    n_sims: int = 1000,
    horizon: Optional[int] = None,
    seed: int = 0,
    quantiles=(0.05, 0.5, 0.95),
) -> MonteCarloResult:
    """Bootstrap the return series into equity-curve confidence bands.

    Resamples the historical returns with replacement to build ``n_sims`` equity
    paths over ``horizon`` steps, then reports per-step percentile bands and the
    terminal-return distribution. Deterministic given ``seed``.
    """
    r = _clean(returns)
    if r.size == 0:
        raise ValueError("returns must contain at least one finite value.")
    h = horizon or r.size
    rng = np.random.default_rng(seed)
    # (n_sims, h) sampled returns -> cumulative equity paths.
    sampled = rng.choice(r, size=(n_sims, h), replace=True)
    equity_paths = np.cumprod(1.0 + sampled, axis=1)
    bands = {
        f"q{int(q * 100)}": np.quantile(equity_paths, q, axis=0) for q in quantiles
    }
    terminal = equity_paths[:, -1] - 1.0
    return MonteCarloResult(terminal_returns=terminal, bands=bands, horizon=h)

cusum_change_points

cusum_change_points(series: ReturnsLike, threshold: float = 1.0) -> NDArray[np.int_]

Detect mean-shift change points with a two-sided CUSUM filter.

threshold is in units of the series' standard deviation. Returns the indices at which the cumulative sum breaches the threshold (and resets).

Source code in src/bbstrader/btengine/analytics.py
def cusum_change_points(
    series: ReturnsLike, threshold: float = 1.0
) -> NDArray[np.int_]:
    """Detect mean-shift change points with a two-sided CUSUM filter.

    ``threshold`` is in units of the series' standard deviation. Returns the
    indices at which the cumulative sum breaches the threshold (and resets).
    """
    x = np.asarray(series, dtype=np.float64)
    x = x[~np.isnan(x)]
    if x.size == 0:
        return np.array([], dtype=int)
    mean = x.mean()
    sd = x.std(ddof=1) or 1.0
    thr = threshold * sd
    s_pos = s_neg = 0.0
    points = []
    for i, val in enumerate(x):
        diff = val - mean
        s_pos = max(0.0, s_pos + diff)
        s_neg = min(0.0, s_neg + diff)
        if s_pos > thr or s_neg < -thr:
            points.append(i)
            s_pos = s_neg = 0.0
    return np.array(points, dtype=int)

volatility_regimes

volatility_regimes(returns: ReturnsLike, window: int = 20, n_states: int = 2) -> NDArray[np.int_]

Label each bar by its volatility regime (0 = lowest vol .. n_states-1).

A lightweight, dependency-free alternative to an HMM: rolling volatility is bucketed into n_states quantile bins. Useful for conditional-performance analysis (how a strategy behaves in calm vs. turbulent regimes).

Source code in src/bbstrader/btengine/analytics.py
def volatility_regimes(
    returns: ReturnsLike, window: int = 20, n_states: int = 2
) -> NDArray[np.int_]:
    """Label each bar by its volatility regime (0 = lowest vol .. n_states-1).

    A lightweight, dependency-free alternative to an HMM: rolling volatility is
    bucketed into ``n_states`` quantile bins. Useful for conditional-performance
    analysis (how a strategy behaves in calm vs. turbulent regimes).
    """
    r = pd.Series(np.asarray(returns, dtype=np.float64))
    vol = r.rolling(window, min_periods=1).std().fillna(0.0).to_numpy()
    # Bucket by quantile edges so each state holds a comparable share of bars.
    edges = np.quantile(vol, np.linspace(0, 1, n_states + 1)[1:-1])
    return np.digitize(vol, edges).astype(int)

factor_exposure

factor_exposure(returns: ReturnsLike, factors: Union[DataFrame, Series, NDArray]) -> Dict[str, float]

OLS factor regression: alpha, factor betas and R-squared.

factors may be a single series (e.g. the market) or a DataFrame of factor returns aligned to returns. Returns alpha, one beta per factor and the regression R-squared.

Source code in src/bbstrader/btengine/analytics.py
def factor_exposure(
    returns: ReturnsLike, factors: Union[pd.DataFrame, pd.Series, NDArray]
) -> Dict[str, float]:
    """OLS factor regression: alpha, factor betas and R-squared.

    ``factors`` may be a single series (e.g. the market) or a DataFrame of
    factor returns aligned to ``returns``. Returns alpha, one beta per factor
    and the regression R-squared.
    """
    y = _clean(returns)
    F = np.asarray(factors, dtype=np.float64)
    if F.ndim == 1:
        F = F.reshape(-1, 1)
    n = min(len(y), len(F))
    y, F = y[:n], F[:n]
    X = np.column_stack([np.ones(n), F])
    coef, _, _, _ = np.linalg.lstsq(X, y, rcond=None)
    resid = y - X @ coef
    ss_res = float(np.sum(resid**2))
    ss_tot = float(np.sum((y - y.mean()) ** 2)) or 1.0
    names = (
        list(factors.columns)
        if isinstance(factors, pd.DataFrame)
        else [f"factor_{i}" for i in range(F.shape[1])]
    )
    result = {"alpha": float(coef[0]), "r_squared": 1.0 - ss_res / ss_tot}
    for name, beta in zip(names, coef[1:]):
        result[f"beta_{name}"] = float(beta)
    return result

rolling_beta

rolling_beta(returns: ReturnsLike, market: ReturnsLike, window: int = 60) -> NDArray[np.float64]

Rolling market beta (cov/var) over window bars; NaN until warmed up.

Source code in src/bbstrader/btengine/analytics.py
def rolling_beta(
    returns: ReturnsLike, market: ReturnsLike, window: int = 60
) -> NDArray[np.float64]:
    """Rolling market beta (cov/var) over ``window`` bars; NaN until warmed up."""
    r = pd.Series(np.asarray(returns, dtype=np.float64))
    m = pd.Series(np.asarray(market, dtype=np.float64))
    cov = r.rolling(window).cov(m)
    var = m.rolling(window).var()
    return (cov / var).to_numpy()

run_backtest

run_backtest(symbol_list: List[str], start_date: datetime, data_handler: Type[DataHandler], strategy: Type[Strategy], exc_handler: Optional[Type[ExecutionHandler]] = None, initial_capital: float = 100000.0, heartbeat: float = 0.0, **kwargs: Any) -> pd.DataFrame

Runs a backtest simulation based on a DataHandler, Strategy, and ExecutionHandler.

Parameters:

Name Type Description Default
symbol_list List[str]

List of symbol strings for the assets to be backtested.

required
start_date datetime

Start date of the backtest.

required
data_handler DataHandler

A subclass of the DataHandler class, responsible for managing and processing market data. Available options include CSVDataHandler, MT5DataHandler, and YFDataHandler.

required
strategy Strategy

The trading strategy to be employed during the backtest. The strategy must be a subclass of Strategy and should include the following attributes: - bars (DataHandler): The DataHandler class for the strategy. - events (Queue): Queue instance for managing events. - symbol_list (List[str]): List of symbols to trade. - mode (str): 'live' or 'backtest'.

Additional parameters specific to the strategy should be passed in **kwargs. The strategy class must implement a calculate_signals method to generate SignalEvent.

required
exc_handler ExecutionHandler

The execution handler for managing order executions. If not provided, a SimulatedExecutionHandler will be used by default. This handler must implement an execute_order method to process OrderEvent in the Backtest class.

None
initial_capital float

The initial capital for the portfolio in the backtest. Default is 100,000.

100000.0
heartbeat float

Time delay (in seconds) between iterations of the event-driven backtest loop. Default is 0.0, allowing the backtest to run as fast as possible. This could also be used as a time frame in live trading (e.g., 1m, 5m, 15m) with a live DataHandler.

0.0
**kwargs Any

Additional parameters passed to the Backtest instance, which may include strategy-specific, data handler, portfolio, or execution handler options.

{}

Returns:

Type Description
DataFrame

pd.DataFrame: The portfolio values over time (capital, equities, returns etc.).

Notes

This function generates three outputs: - A performance summary saved as an HTML file. - An equity curve of the portfolio saved as a CSV file. - Monthly returns saved as a PNG image.

Example

from examples.strategies import StockIndexSTBOTrading from bbstrader.config import config_logger from bbstrader.btengine.data import MT5DataHandler from bbstrader.btengine.execution import MT5ExecutionHandler from datetime import datetime

logger = config_logger('index_trade.log', console_log=True) symbol_list = ['[SP500]', 'GERMANY40', '[DJI30]', '[NQ100]'] start = datetime(2010, 6, 1, 2, 0, 0) kwargs = { ... 'expected_returns': {'[NQ100]': 1.5, '[SP500]': 1.5, '[DJI30]': 1.0, 'GERMANY40': 1.0}, ... 'quantities': {'[NQ100]': 15, '[SP500]': 30, '[DJI30]': 5, 'GERMANY40': 10}, ... 'max_trades': {'[NQ100]': 3, '[SP500]': 3, '[DJI30]': 3, 'GERMANY40': 3}, ... 'mt5_start': start, ... 'time_frame': '15m', ... 'strategy_name': 'SISTBO', ... } run_backtest( ... symbol_list=symbol_list, ... start_date=start, ... data_handler=MT5DataHandler, ... strategy=StockIndexSTBOTrading, ... exc_handler=MT5ExecutionHandler, ... initial_capital=100000.0, ... heartbeat=0.0, ... **kwargs ... )

Source code in src/bbstrader/btengine/backtest.py
def run_backtest(
    symbol_list: List[str],
    start_date: datetime,
    data_handler: Type[DataHandler],
    strategy: Type[Strategy],
    exc_handler: Optional[Type[ExecutionHandler]] = None,
    initial_capital: float = 100000.0,
    heartbeat: float = 0.0,
    **kwargs: Any,
) -> pd.DataFrame:
    """
    Runs a backtest simulation based on a `DataHandler`, `Strategy`, and `ExecutionHandler`.

    Args:
        symbol_list (List[str]): List of symbol strings for the assets to be backtested.

        start_date (datetime): Start date of the backtest.

        data_handler (DataHandler): A subclass of the `DataHandler` class, responsible for managing
            and processing market data. Available options include `CSVDataHandler`,
            `MT5DataHandler`, and `YFDataHandler`.

        strategy (Strategy): The trading strategy to be employed during the backtest.
            The strategy must be a subclass of `Strategy` and should include the following attributes:
            - `bars` (DataHandler): The `DataHandler` class for the strategy.
            - `events` (Queue): Queue instance for managing events.
            - `symbol_list` (List[str]): List of symbols to trade.
            - `mode` (str): 'live' or 'backtest'.

            Additional parameters specific to the strategy should be passed in `**kwargs`.
            The strategy class must implement a `calculate_signals` method to generate `SignalEvent`.

        exc_handler (ExecutionHandler, optional): The execution handler for managing order executions.
            If not provided, a `SimulatedExecutionHandler` will be used by default. This handler must
            implement an `execute_order` method to process `OrderEvent` in the `Backtest` class.

        initial_capital (float, optional): The initial capital for the portfolio in the backtest.
            Default is 100,000.

        heartbeat (float, optional): Time delay (in seconds) between iterations of the event-driven
            backtest loop. Default is 0.0, allowing the backtest to run as fast as possible. This could
            also be used as a time frame in live trading (e.g., 1m, 5m, 15m) with a live `DataHandler`.

        **kwargs: Additional parameters passed to the `Backtest` instance, which may include strategy-specific,
            data handler, portfolio, or execution handler options.

    Returns:
        pd.DataFrame: The portfolio values over time (capital, equities, returns etc.).

    Notes:
        This function generates three outputs:
            - A performance summary saved as an HTML file.
            - An equity curve of the portfolio saved as a CSV file.
            - Monthly returns saved as a PNG image.

    Example:
        >>> from examples.strategies import StockIndexSTBOTrading
        >>> from bbstrader.config import config_logger
        >>> from bbstrader.btengine.data import MT5DataHandler
        >>> from bbstrader.btengine.execution import MT5ExecutionHandler
        >>> from datetime import datetime
        >>>
        >>> logger = config_logger('index_trade.log', console_log=True)
        >>> symbol_list = ['[SP500]', 'GERMANY40', '[DJI30]', '[NQ100]']
        >>> start = datetime(2010, 6, 1, 2, 0, 0)
        >>> kwargs = {
        ...     'expected_returns': {'[NQ100]': 1.5, '[SP500]': 1.5, '[DJI30]': 1.0, 'GERMANY40': 1.0},
        ...     'quantities': {'[NQ100]': 15, '[SP500]': 30, '[DJI30]': 5, 'GERMANY40': 10},
        ...     'max_trades': {'[NQ100]': 3, '[SP500]': 3, '[DJI30]': 3, 'GERMANY40': 3},
        ...     'mt5_start': start,
        ...     'time_frame': '15m',
        ...     'strategy_name': 'SISTBO',
        ... }
        >>> run_backtest(
        ...     symbol_list=symbol_list,
        ...     start_date=start,
        ...     data_handler=MT5DataHandler,
        ...     strategy=StockIndexSTBOTrading,
        ...     exc_handler=MT5ExecutionHandler,
        ...     initial_capital=100000.0,
        ...     heartbeat=0.0,
        ...     **kwargs
        ... )
    """
    if exc_handler is None:
        execution_handler: Type[ExecutionHandler] = SimExecutionHandler
    else:
        execution_handler = exc_handler
    engine = BacktestEngine(
        symbol_list,
        initial_capital,
        heartbeat,
        start_date,
        data_handler,
        execution_handler,
        strategy,
        **kwargs,
    )
    portfolio = engine.simulate_trading()
    return portfolio

has_pyarrow

has_pyarrow() -> bool

Return True if a Parquet engine (pyarrow) is importable.

Source code in src/bbstrader/btengine/catalog.py
def has_pyarrow() -> bool:
    """Return True if a Parquet engine (pyarrow) is importable."""
    try:
        import pyarrow  # type: ignore # noqa: F401

        return True
    except ImportError:
        return False

expand_param_grid

expand_param_grid(param_grid: Dict[str, Sequence[Any]], search: str = 'grid', n_iter: Optional[int] = None, seed: int = 0) -> List[Dict[str, Any]]

Expand a parameter grid into a list of concrete parameter dicts.

Parameters:

Name Type Description Default
param_grid Dict[str, Sequence[Any]]

Mapping of parameter name to the sequence of values to try.

required
search str

"grid" for the full Cartesian product, "random" to draw n_iter random combinations.

'grid'
n_iter Optional[int]

Number of combinations to sample when search == "random".

None
seed int

Seed for the random sampler (deterministic by default).

0
Source code in src/bbstrader/btengine/optimize.py
def expand_param_grid(
    param_grid: Dict[str, Sequence[Any]],
    search: str = "grid",
    n_iter: Optional[int] = None,
    seed: int = 0,
) -> List[Dict[str, Any]]:
    """Expand a parameter grid into a list of concrete parameter dicts.

    Args:
        param_grid: Mapping of parameter name to the sequence of values to try.
        search: ``"grid"`` for the full Cartesian product, ``"random"`` to draw
            ``n_iter`` random combinations.
        n_iter: Number of combinations to sample when ``search == "random"``.
        seed: Seed for the random sampler (deterministic by default).
    """
    keys = list(param_grid.keys())
    value_lists = [list(param_grid[k]) for k in keys]
    combos = [dict(zip(keys, values)) for values in itertools.product(*value_lists)]
    if search == "grid":
        return combos
    if search == "random":
        if n_iter is None:
            raise ValueError("n_iter is required when search='random'.")
        rng = random.Random(seed)
        if n_iter >= len(combos):
            return combos
        return rng.sample(combos, n_iter)
    raise ValueError(f"Unknown search mode: {search!r} (use 'grid' or 'random').")

walk_forward

walk_forward(symbol_list: List[str], start_date: datetime, data_handler: Type[DataHandler], strategy: Type[Strategy], param_grid: Dict[str, Sequence[Any]], exc_handler: Optional[Type[ExecutionHandler]] = None, initial_capital: float = 100000.0, metric: str = 'sharpe', periods: int = 252, n_splits: int = 3, anchored: bool = True, **kwargs: Any) -> pd.DataFrame

Anchored or rolling walk-forward validation.

The full history is divided into n_splits + 1 equal segments. For each fold the in-sample window is optimized (in-process), and the best parameter set is evaluated on the next out-of-sample segment. With anchored=True the in-sample window always starts at bar 0 and grows; with anchored=False it rolls forward at a fixed length.

Returns:

Type Description
DataFrame

One row per fold: the chosen parameters plus the out-of-sample metrics.

Source code in src/bbstrader/btengine/optimize.py
def walk_forward(
    symbol_list: List[str],
    start_date: datetime,
    data_handler: Type[DataHandler],
    strategy: Type[Strategy],
    param_grid: Dict[str, Sequence[Any]],
    exc_handler: Optional[Type[ExecutionHandler]] = None,
    initial_capital: float = 100000.0,
    metric: str = "sharpe",
    periods: int = 252,
    n_splits: int = 3,
    anchored: bool = True,
    **kwargs: Any,
) -> pd.DataFrame:
    """Anchored or rolling walk-forward validation.

    The full history is divided into ``n_splits + 1`` equal segments. For each
    fold the in-sample window is optimized (in-process), and the best parameter
    set is evaluated on the next out-of-sample segment. With ``anchored=True``
    the in-sample window always starts at bar 0 and grows; with ``anchored=False``
    it rolls forward at a fixed length.

    Returns:
        One row per fold: the chosen parameters plus the out-of-sample metrics.
    """
    from queue import Queue

    execution_handler = exc_handler or SimExecutionHandler
    handler = data_handler(Queue(), symbol_list, **kwargs)
    # Keep a pristine copy so slicing per fold is non-destructive.
    handler._full_records = {  # type: ignore[attr-defined]
        s: list(handler._records[s]) for s in symbol_list
    }
    total_bars = len(handler._full_records[symbol_list[0]])  # type: ignore[attr-defined]
    if total_bars < (n_splits + 1) * 2:
        raise ValueError(
            f"Not enough bars ({total_bars}) for {n_splits} walk-forward splits."
        )
    seg = total_bars // (n_splits + 1)
    combos = expand_param_grid(param_grid)

    fold_rows: List[Dict[str, Any]] = []
    for fold in range(n_splits):
        train_hi = seg * (fold + 1)
        train_lo = 0 if anchored else seg * fold
        test_lo, test_hi = train_hi, seg * (fold + 2)

        # In-sample: pick the best parameters on the training window.
        best_params, best_score = None, None
        for params in combos:
            _slice_handler(handler, train_lo, train_hi)
            curve = _run_engine(
                symbol_list,
                start_date,
                handler,
                strategy,
                execution_handler,
                initial_capital,
                {**kwargs, **params},
            )
            score = _score_curve(curve, periods)[metric]
            better = best_score is None or (
                score < best_score if metric in _LOWER_IS_BETTER else score > best_score
            )
            if score == score and better:  # skip NaN scores
                best_params, best_score = params, score

        if best_params is None:
            best_params = combos[0]

        # Out-of-sample: evaluate the chosen parameters on the test window.
        _slice_handler(handler, test_lo, test_hi)
        oos_curve = _run_engine(
            symbol_list,
            start_date,
            handler,
            strategy,
            execution_handler,
            initial_capital,
            {**kwargs, **best_params},
        )
        fold_rows.append(
            {"fold": fold, **best_params, **_score_curve(oos_curve, periods)}
        )

    return pd.DataFrame(fold_rows)

probabilistic_sharpe_ratio

probabilistic_sharpe_ratio(sharpe: float, n_obs: int, benchmark: float = 0.0, skew: float = 0.0, kurtosis: float = 3.0) -> float

Probability that the true Sharpe exceeds benchmark (PSR).

sharpe and benchmark are per-observation (non-annualized) Sharpe ratios. skew/kurtosis are the return distribution's moments (kurtosis 3 == normal).

Source code in src/bbstrader/btengine/overfitting.py
def probabilistic_sharpe_ratio(
    sharpe: float,
    n_obs: int,
    benchmark: float = 0.0,
    skew: float = 0.0,
    kurtosis: float = 3.0,
) -> float:
    """Probability that the true Sharpe exceeds ``benchmark`` (PSR).

    ``sharpe`` and ``benchmark`` are per-observation (non-annualized) Sharpe
    ratios. ``skew``/``kurtosis`` are the return distribution's moments
    (kurtosis 3 == normal).
    """
    if n_obs < 2:
        return 0.0
    denom = math.sqrt(1.0 - skew * sharpe + (kurtosis - 1.0) / 4.0 * sharpe**2)
    if denom == 0:
        return 0.0
    z = (sharpe - benchmark) * math.sqrt(n_obs - 1) / denom
    return float(stats.norm.cdf(z))

expected_max_sharpe

expected_max_sharpe(n_trials: int, sharpe_variance: float) -> float

Expected maximum of n_trials independent Sharpe estimates.

The benchmark a strategy must beat to be considered non-random when it was selected from n_trials candidates (Bailey & Lopez de Prado).

Source code in src/bbstrader/btengine/overfitting.py
def expected_max_sharpe(n_trials: int, sharpe_variance: float) -> float:
    """Expected maximum of ``n_trials`` independent Sharpe estimates.

    The benchmark a strategy must beat to be considered non-random when it was
    selected from ``n_trials`` candidates (Bailey & Lopez de Prado).
    """
    if n_trials < 2 or sharpe_variance <= 0:
        return 0.0
    z1 = stats.norm.ppf(1.0 - 1.0 / n_trials)
    z2 = stats.norm.ppf(1.0 - 1.0 / (n_trials * math.e))
    return math.sqrt(sharpe_variance) * (
        (1.0 - _EULER_MASCHERONI) * z1 + _EULER_MASCHERONI * z2
    )

deflated_sharpe_ratio

deflated_sharpe_ratio(sharpe: float, n_obs: int, n_trials: int, sharpe_variance: float, skew: float = 0.0, kurtosis: float = 3.0) -> float

Deflated Sharpe Ratio (DSR).

PSR computed against the expected maximum Sharpe across n_trials, i.e. the probability the strategy's Sharpe is real after accounting for multiple testing. sharpe/sharpe_variance are per-observation.

Source code in src/bbstrader/btengine/overfitting.py
def deflated_sharpe_ratio(
    sharpe: float,
    n_obs: int,
    n_trials: int,
    sharpe_variance: float,
    skew: float = 0.0,
    kurtosis: float = 3.0,
) -> float:
    """Deflated Sharpe Ratio (DSR).

    PSR computed against the expected maximum Sharpe across ``n_trials``, i.e.
    the probability the strategy's Sharpe is real after accounting for multiple
    testing. ``sharpe``/``sharpe_variance`` are per-observation.
    """
    benchmark = expected_max_sharpe(n_trials, sharpe_variance)
    return probabilistic_sharpe_ratio(sharpe, n_obs, benchmark, skew, kurtosis)

cscv_pbo

cscv_pbo(performance: NDArray[float64], n_splits: int = 10, metric: Optional[Callable[[NDArray[float64]], float]] = None) -> float

Probability of Backtest Overfitting via combinatorially symmetric CV.

Parameters:

Name Type Description Default
performance NDArray[float64]

A (T, N) matrix of per-observation returns for N candidate configurations over T observations.

required
n_splits int

Number of disjoint row blocks S (must be even); IS/OOS are all C(S, S/2) balanced partitions.

10
metric Optional[Callable[[NDArray[float64]], float]]

Per-configuration score from a sub-matrix of returns. Defaults to the Sharpe ratio.

None

Returns:

Type Description
float

PBO in [0, 1]: the fraction of partitions where the in-sample best

float

configuration ranks below the out-of-sample median.

Source code in src/bbstrader/btengine/overfitting.py
def cscv_pbo(
    performance: NDArray[np.float64],
    n_splits: int = 10,
    metric: Optional[Callable[[NDArray[np.float64]], float]] = None,
) -> float:
    """Probability of Backtest Overfitting via combinatorially symmetric CV.

    Args:
        performance: A (T, N) matrix of per-observation returns for N candidate
            configurations over T observations.
        n_splits: Number of disjoint row blocks S (must be even); IS/OOS are all
            C(S, S/2) balanced partitions.
        metric: Per-configuration score from a sub-matrix of returns. Defaults to
            the Sharpe ratio.

    Returns:
        PBO in [0, 1]: the fraction of partitions where the in-sample best
        configuration ranks below the out-of-sample median.
    """
    perf = np.asarray(performance, dtype=np.float64)
    if perf.ndim != 2:
        raise ValueError("performance must be a 2-D (T, N) matrix.")
    if n_splits % 2 != 0:
        raise ValueError("n_splits must be even.")
    score = metric or _sharpe
    n_obs, n_cfg = perf.shape
    blocks = np.array_split(np.arange(n_obs), n_splits)

    logits = []
    for combo in itertools.combinations(range(n_splits), n_splits // 2):
        is_rows = np.concatenate([blocks[b] for b in combo])
        oos_rows = np.concatenate(
            [blocks[b] for b in range(n_splits) if b not in combo]
        )
        is_scores = np.array([score(perf[is_rows, c]) for c in range(n_cfg)])
        oos_scores = np.array([score(perf[oos_rows, c]) for c in range(n_cfg)])
        best = int(np.argmax(is_scores))
        # Relative rank of the IS-best config among OOS scores.
        rank = float(stats.rankdata(oos_scores)[best])
        omega = rank / (n_cfg + 1)
        omega = min(max(omega, 1e-6), 1 - 1e-6)
        logits.append(math.log(omega / (1.0 - omega)))

    logits_arr = np.array(logits)
    return float(np.mean(logits_arr <= 0.0))

combinatorial_splits

combinatorial_splits(n_obs: int, n_groups: int = 6, n_test_groups: int = 2, embargo: int = 0) -> Iterator[Tuple[NDArray[np.int_], NDArray[np.int_]]]

Yield combinatorial purged cross-validation (CPCV) train/test splits.

Observations are partitioned into n_groups contiguous blocks; every combination of n_test_groups blocks forms a test set, with the remaining blocks (minus an embargo band around each test block, to prevent leakage) as the training set. Yields C(n_groups, n_test_groups) folds.

Source code in src/bbstrader/btengine/overfitting.py
def combinatorial_splits(
    n_obs: int,
    n_groups: int = 6,
    n_test_groups: int = 2,
    embargo: int = 0,
) -> Iterator[Tuple[NDArray[np.int_], NDArray[np.int_]]]:
    """Yield combinatorial purged cross-validation (CPCV) train/test splits.

    Observations are partitioned into ``n_groups`` contiguous blocks; every
    combination of ``n_test_groups`` blocks forms a test set, with the remaining
    blocks (minus an ``embargo`` band around each test block, to prevent
    leakage) as the training set. Yields C(n_groups, n_test_groups) folds.
    """
    if n_test_groups >= n_groups:
        raise ValueError("n_test_groups must be smaller than n_groups.")
    groups = np.array_split(np.arange(n_obs), n_groups)
    for combo in itertools.combinations(range(n_groups), n_test_groups):
        test_idx = np.concatenate([groups[g] for g in combo])
        train_mask = np.ones(n_obs, dtype=bool)
        train_mask[test_idx] = False
        if embargo > 0:
            for g in combo:
                start, end = groups[g][0], groups[g][-1]
                lo = max(0, start - embargo)
                hi = min(n_obs, end + 1 + embargo)
                train_mask[lo:hi] = False
        train_idx = np.where(train_mask)[0]
        yield train_idx, test_idx

get_asset_performances

get_asset_performances(portfolio: DataFrame, assets: List[str], plot: bool = True, strategy: str = '') -> pd.Series

Calculate the performance of the assets in the portfolio.

Parameters:

Name Type Description Default
portfolio DataFrame

The portfolio DataFrame.

required
assets List[str]

The list of assets to calculate the performance for.

required
plot bool

Whether to plot the performance of the assets.

True
strategy str

The name of the strategy.

''

Returns:

Type Description
Series

pd.Series: The performance of the assets.

Source code in src/bbstrader/btengine/performance.py
def get_asset_performances(
    portfolio: pd.DataFrame,
    assets: List[str],
    plot: bool = True,
    strategy: str = "",
) -> pd.Series:
    """
    Calculate the performance of the assets in the portfolio.

    Args:
        portfolio (pd.DataFrame): The portfolio DataFrame.
        assets (List[str]): The list of assets to calculate the performance for.
        plot (bool): Whether to plot the performance of the assets.
        strategy (str): The name of the strategy.

    Returns:
        pd.Series: The performance of the assets.
    """
    asset_prices = portfolio[assets]
    asset_prices = asset_prices.abs()
    asset_prices.replace(0, np.nan, inplace=True)
    asset_prices.ffill(inplace=True)
    asset_returns = asset_prices.pct_change()
    asset_returns.replace([np.inf, -np.inf], np.nan, inplace=True)
    asset_returns.fillna(0, inplace=True)
    asset_cum_returns = (1.0 + asset_returns).cumprod()
    if plot:
        asset_cum_returns.plot(
            figsize=(12, 6), title=f"{strategy} Strategy Assets Performance"
        )
        plt.show()
    return asset_cum_returns.iloc[-1] - 1

get_perfbased_weights

get_perfbased_weights(performances: Series) -> Dict[str, float]

Calculate the weights of the assets based on their performances.

Parameters:

Name Type Description Default
performances Series

The performances of the assets.

required

Returns:

Type Description
Dict[str, float]

Dict[str, float]: The weights of the assets.

Source code in src/bbstrader/btengine/performance.py
def get_perfbased_weights(performances: pd.Series) -> Dict[str, float]:
    """
    Calculate the weights of the assets based on their performances.

    Args:
        performances (pd.Series): The performances of the assets.

    Returns:
        Dict[str, float]: The weights of the assets.
    """
    weights = (
        performances.to_frame()
        .assign(weight=performances.values / performances.sum())
        .weight.to_dict()
    )
    return weights

create_sharpe_ratio

create_sharpe_ratio(returns: Series, periods: int = 252) -> float

Create the Sharpe ratio for the strategy, based on a benchmark of zero (i.e. no risk-free rate information).

Parameters:

Name Type Description Default
returns

A pandas Series representing period percentage returns.

required
periods int

Daily (252), Hourly (2526.5), Minutely(2526.5*60) etc.

252

Returns:

Name Type Description
S float

Sharpe ratio

Source code in src/bbstrader/btengine/performance.py
def create_sharpe_ratio(returns: pd.Series, periods: int = 252) -> float:
    """
    Create the Sharpe ratio for the strategy, based on a
    benchmark of zero (i.e. no risk-free rate information).

    Args:
        returns : A pandas Series representing period percentage returns.
        periods (int): Daily (252), Hourly (252*6.5), Minutely(252*6.5*60) etc.

    Returns:
        S (float): Sharpe ratio
    """
    sharpe = qs.stats.sharpe(returns, periods=periods)
    return sharpe if isinstance(sharpe, float) else sharpe.iloc[-1]

create_sortino_ratio

create_sortino_ratio(returns: Series, periods: int = 252) -> float

Create the Sortino ratio for the strategy, based on a benchmark of zero (i.e. no risk-free rate information).

Parameters:

Name Type Description Default
returns

A pandas Series representing period percentage returns.

required
periods int

Daily (252), Hourly (2526.5), Minutely(2526.5*60) etc.

252

Returns:

Name Type Description
S float

Sortino ratio

Source code in src/bbstrader/btengine/performance.py
def create_sortino_ratio(returns: pd.Series, periods: int = 252) -> float:
    """
    Create the Sortino ratio for the strategy, based on a
    benchmark of zero (i.e. no risk-free rate information).

    Args:
        returns : A pandas Series representing period percentage returns.
        periods (int): Daily (252), Hourly (252*6.5), Minutely(252*6.5*60) etc.

    Returns:
        S (float): Sortino ratio
    """
    return qs.stats.sortino(returns, periods=periods)

create_omega_ratio

create_omega_ratio(returns: Series, periods: int = 252, rf: float = 0.0) -> float

Create the Omega ratio for the strategy.

Parameters:

Name Type Description Default
returns

A pandas Series representing period percentage returns.

required
periods int

Daily (252), Hourly (2526.5), Minutely(2526.5*60) etc.

252
rf float

Risk-free rate.

0.0

Returns:

Name Type Description
float float

Omega ratio

Source code in src/bbstrader/btengine/performance.py
def create_omega_ratio(
    returns: pd.Series, periods: int = 252, rf: float = 0.0
) -> float:
    """
    Create the Omega ratio for the strategy.

    Args:
        returns : A pandas Series representing period percentage returns.
        periods (int): Daily (252), Hourly (252*6.5), Minutely(252*6.5*60) etc.
        rf (float): Risk-free rate.

    Returns:
        float: Omega ratio
    """
    return qs.stats.omega(returns, rf=rf)

create_calmar_ratio

create_calmar_ratio(returns: Series, periods: int = 252) -> float

Create the Calmar ratio for the strategy.

Parameters:

Name Type Description Default
returns

A pandas Series representing period percentage returns.

required
periods int

Daily (252), Hourly (2526.5), Minutely(2526.5*60) etc.

252

Returns:

Name Type Description
float float

Calmar ratio

Source code in src/bbstrader/btengine/performance.py
def create_calmar_ratio(returns: pd.Series, periods: int = 252) -> float:
    """
    Create the Calmar ratio for the strategy.

    Args:
        returns : A pandas Series representing period percentage returns.
        periods (int): Daily (252), Hourly (252*6.5), Minutely(252*6.5*60) etc.

    Returns:
        float: Calmar ratio
    """
    return qs.stats.calmar(returns)

create_tail_ratio

create_tail_ratio(returns: Series) -> float

Create the Tail ratio for the strategy.

Parameters:

Name Type Description Default
returns

A pandas Series representing period percentage returns.

required

Returns:

Name Type Description
float float

Tail ratio

Source code in src/bbstrader/btengine/performance.py
def create_tail_ratio(returns: pd.Series) -> float:
    """
    Create the Tail ratio for the strategy.

    Args:
        returns : A pandas Series representing period percentage returns.

    Returns:
        float: Tail ratio
    """
    return qs.stats.tail_ratio(returns)

calculate_risk_metrics

calculate_risk_metrics(returns: Series, benchmark_returns: Series, periods: int = 252) -> Dict[str, float]

Calculate Alpha, Beta and Volatility for the strategy.

Parameters:

Name Type Description Default
returns

A pandas Series representing period percentage returns.

required
benchmark_returns

A pandas Series representing benchmark period percentage returns.

required
periods int

Daily (252), Hourly (2526.5), Minutely(2526.5*60) etc.

252

Returns:

Type Description
Dict[str, float]

Dict[str, float]: Alpha, Beta, Volatility

Source code in src/bbstrader/btengine/performance.py
def calculate_risk_metrics(
    returns: pd.Series, benchmark_returns: pd.Series, periods: int = 252
) -> Dict[str, float]:
    """
    Calculate Alpha, Beta and Volatility for the strategy.

    Args:
        returns : A pandas Series representing period percentage returns.
        benchmark_returns : A pandas Series representing benchmark period percentage returns.
        periods (int): Daily (252), Hourly (252*6.5), Minutely(252*6.5*60) etc.

    Returns:
        Dict[str, float]: Alpha, Beta, Volatility
    """
    g_beta = qs.stats.greeks(returns, benchmark_returns)
    alpha = g_beta["alpha"]
    beta = g_beta["beta"]
    volatility = qs.stats.volatility(returns, periods=periods)

    return {"alpha": alpha, "beta": beta, "volatility": volatility}

create_drawdowns

create_drawdowns(pnl: Series) -> Tuple[pd.Series, float, float]

Calculate the largest peak-to-trough drawdown of the PnL curve as well as the duration of the drawdown. Requires that the pnl_returns is a pandas Series.

Parameters:

Name Type Description Default
pnl

A pandas Series representing period percentage returns.

required

Returns:

Type Description
tuple

drawdown, duration - high-water mark, duration.

Source code in src/bbstrader/btengine/performance.py
def create_drawdowns(pnl: pd.Series) -> Tuple[pd.Series, float, float]:
    """
    Calculate the largest peak-to-trough drawdown of the PnL curve
    as well as the duration of the drawdown. Requires that the
    pnl_returns is a pandas Series.

    Args:
        pnl : A pandas Series representing period percentage returns.

    Returns:
        (tuple): drawdown, duration - high-water mark, duration.
    """
    # Calculate the cumulative returns curve
    # and set up the High Water Mark
    if pnl.empty:
        return pd.Series(dtype=float), 0.0, 0.0
    hwm = pd.Series(index=pnl.index)
    hwm.iloc[0] = 0

    # Create the drawdown and duration series
    idx = pnl.index
    drawdown = pd.Series(index=idx)
    duration = pd.Series(index=idx)

    # Loop over the index range
    for t in range(1, len(idx)):
        hwm.iloc[t] = max(hwm.iloc[t - 1], pnl.iloc[t])
        drawdown.iloc[t] = hwm.iloc[t] - pnl.iloc[t]
        duration.iloc[t] = 0 if drawdown.iloc[t] == 0 else duration.iloc[t - 1] + 1

    max_drawdown = drawdown.max() if not drawdown.empty else 0.0
    max_duration = duration.max() if not duration.empty else 0.0
    return drawdown, max_drawdown, max_duration

plot_performance

plot_performance(df: DataFrame, title: str) -> None
Plot the performance of the strategy
  • (Portfolio value, %)
  • (Period returns, %)
  • (Drawdowns, %)

Parameters:

Name Type Description Default
df DataFrame
required
title str

The title of the plot.

required

Note: The DataFrame should contain the following columns - Datetime: The timestamp of the data - Equity Curve: The portfolio value - Returns: The period returns - Drawdown: The drawdowns - Total : The total returns

Source code in src/bbstrader/btengine/performance.py
def plot_performance(df: pd.DataFrame, title: str) -> None:
    """
    Plot the performance of the strategy:
        - (Portfolio value,  %)
        - (Period returns, %)
        - (Drawdowns, %)

    Args:
        df (pd.DataFrame):
        The DataFrame containing the strategy returns and drawdowns.
        title (str): The title of the plot.

    Note:
    The DataFrame should contain the following columns
    - Datetime: The timestamp of the data
    - Equity Curve: The portfolio value
    - Returns: The period returns
    - Drawdown: The drawdowns
    - Total : The total returns
    """
    data = df.copy()
    data = data.sort_values(by="Datetime")
    # Plot three charts: Equity curve,
    # period returns, drawdowns
    fig = plt.figure(figsize=(14, 8))
    fig.suptitle(f"{title} Strategy Performance", fontsize=16)

    # Set the outer colour to white
    _require_seaborn().set_theme()

    # Plot the equity curve
    ax1 = fig.add_subplot(311, ylabel="Portfolio value, %")
    data["Equity Curve"].plot(ax=ax1, color="blue", lw=2.0)
    ax1.set_xlabel("")
    plt.grid(True)

    # Plot the returns
    ax2 = fig.add_subplot(312, ylabel="Period returns, %")
    data["Returns"].plot(ax=ax2, color="black", lw=2.0)
    ax2.set_xlabel("")
    plt.grid(True)

    # Plot Drawdown
    ax3 = fig.add_subplot(313, ylabel="Drawdowns, %")
    data["Drawdown"].plot(ax=ax3, color="red", lw=2.0)
    ax3.set_xlabel("")
    plt.grid(True)

    # Plot the figure
    plt.tight_layout()
    plt.show()

plot_returns_and_dd

plot_returns_and_dd(df: DataFrame, benchmark: str, title: str) -> None

Plot the returns and drawdowns of the strategy compared to a benchmark.

Parameters:

Name Type Description Default
df DataFrame

The DataFrame containing the strategy returns and drawdowns.

required
benchmark str

The ticker symbol of the benchmark to compare the strategy to.

required
title str

The title of the plot.

required

Note: The DataFrame should contain the following columns: - Datetime : The timestamp of the data - Equity Curve : The portfolio value - Returns : The period returns - Drawdown : The drawdowns - Total : The total returns

Source code in src/bbstrader/btengine/performance.py
def plot_returns_and_dd(df: pd.DataFrame, benchmark: str, title: str) -> None:
    """
    Plot the returns and drawdowns of the strategy
    compared to a benchmark.

    Args:
        df (pd.DataFrame):
            The DataFrame containing the strategy returns and drawdowns.
        benchmark (str):
            The ticker symbol of the benchmark to compare the strategy to.
        title (str): The title of the plot.

    Note:
    The DataFrame should contain the following columns:
    - Datetime : The timestamp of the data
    - Equity Curve : The portfolio value
    - Returns : The period returns
    - Drawdown : The drawdowns
    - Total : The total returns
    """
    # Ensure data is sorted by Datetime
    data = df.copy()
    data.reset_index(inplace=True)
    data = data.sort_values(by="Datetime")
    data.sort_values(by="Datetime", inplace=True)

    # Get the first and last Datetime values
    first_date = data["Datetime"].iloc[0]
    last_date = data["Datetime"].iloc[-1]

    # Download benchmark data from Yahoo Finance
    # To avoid errors, we use the try-except block
    # in case the benchmark is not available
    try:
        bm = yf.download(benchmark, start=first_date, end=last_date, auto_adjust=True)
        bm["log_return"] = np.log(bm["Close"] / bm["Close"].shift(1))
        # Use exponential to get cumulative returns
        bm_returns = np.exp(np.cumsum(bm["log_return"].fillna(0)))

        # Normalize bm series to start at 1.0
        bm_returns_normalized = bm_returns / bm_returns.iloc[0]
    except Exception:
        bm = None

    # Create figure and plot space
    fig, (ax1, ax2) = plt.subplots(
        2, 1, figsize=(14, 8), gridspec_kw={"height_ratios": [3, 1]}
    )

    # Plot the Equity Curve for the strategy
    ax1.plot(
        data["Datetime"], data["Equity Curve"], label="Backtest", color="green", lw=2.5
    )
    # Check benchmarck an Plot the Returns for the benchmark
    if bm is not None:
        ax1.plot(
            bm.index, bm_returns_normalized, label="benchmark", color="gray", lw=2.5
        )
        ax1.set_title(f"{title} Strategy vs. Benchmark ({benchmark})")
    else:
        ax1.set_title(f"{title} Strategy Returns")
    ax1.set_xlabel("Date")
    ax1.set_ylabel("Cumulative Returns")
    ax1.grid(True)
    ax1.legend(loc="upper left")

    # Plot the Drawdown
    ax2.fill_between(
        data["Datetime"], data["Drawdown"], 0, color="red", step="pre", alpha=0.5
    )
    ax2.plot(
        data["Datetime"], data["Drawdown"], color="red", alpha=0.6, lw=2.5
    )  # Overlay the line
    ax2.set_title("Drawdown (%)")
    ax2.set_xlabel("Date")
    ax2.set_ylabel("Drawdown")
    ax2.grid(True)

    # Display the plot
    plt.tight_layout()
    plt.show()

plot_monthly_yearly_returns

plot_monthly_yearly_returns(df: DataFrame, title: str) -> None

Plot the monthly and yearly returns of the strategy.

Parameters:

Name Type Description Default
df DataFrame
required
title str

The title of the plot.

required

Note: The DataFrame should contain the following columns: - Datetime : The timestamp of the data - Equity Curve : The portfolio value - Returns : The period returns - Drawdown : The drawdowns - Total : The total returns

Source code in src/bbstrader/btengine/performance.py
def plot_monthly_yearly_returns(df: pd.DataFrame, title: str) -> None:
    """
    Plot the monthly and yearly returns of the strategy.

    Args:
        df (pd.DataFrame):
        The DataFrame containing the strategy returns and drawdowns.
        title (str): The title of the plot.

    Note:
    The DataFrame should contain the following columns:
    - Datetime : The timestamp of the data
    - Equity Curve : The portfolio value
    - Returns : The period returns
    - Drawdown : The drawdowns
    - Total : The total returns
    """
    equity_df = df.copy()
    equity_df.reset_index(inplace=True)
    equity_df["Datetime"] = pd.to_datetime(equity_df["Datetime"])
    equity_df.set_index("Datetime", inplace=True)

    # Calculate daily returns
    equity_df["Daily Returns"] = equity_df["Total"].pct_change()

    # Group by year and month to get monthly returns
    monthly_returns = (
        equity_df["Daily Returns"]
        .groupby([equity_df.index.year, equity_df.index.month])
        .apply(lambda x: (1 + x).prod() - 1)
    )

    # Prepare monthly returns DataFrame
    monthly_returns_df = monthly_returns.unstack(level=-1) * 100
    monthly_returns_df.columns = monthly_returns_df.columns.map(
        lambda x: pd.to_datetime(str(x), format="%m").strftime("%b")
    )

    # Calculate and prepare yearly returns DataFrame
    yearly_returns_df = (
        equity_df["Total"]
        .resample("A")
        .last()
        .pct_change()
        .to_frame(name="Yearly Returns")
        * 100
    )

    # Set the aesthetics for the plots
    _require_seaborn().set_theme(style="darkgrid")

    # Initialize the matplotlib figure,
    # adjust the height_ratios to give more space to the yearly returns
    f, (ax1, ax2) = plt.subplots(
        2, 1, figsize=(12, 8), gridspec_kw={"height_ratios": [2, 1]}
    )
    f.suptitle(f"{title} Strategy Monthly and Yearly Returns")
    # Find the min and max values in the data to set the color scale range.
    vmin = monthly_returns_df.min().min()
    vmax = monthly_returns_df.max().max()
    # Define the color palette for the heatmap
    cmap = sns.diverging_palette(10, 133, sep=3, n=256, center="light")

    # Create the heatmap with the larger legend
    sns.heatmap(
        monthly_returns_df,
        annot=True,
        fmt=".1f",
        linewidths=0.5,
        ax=ax1,
        cbar_kws={"shrink": 0.8},
        cmap=cmap,
        center=0,
        vmin=vmin,
        vmax=vmax,
    )

    # Rotate the year labels on the y-axis to vertical
    ax1.set_yticklabels(ax1.get_yticklabels(), rotation=0)
    ax1.set_ylabel("")
    ax1.set_xlabel("")

    # Create the bar plot
    yearly_returns_df.plot(kind="bar", ax=ax2, legend=None, color="skyblue")

    # Set plot titles and labels
    ax1.set_title("Monthly Returns (%)")
    ax2.set_title("Yearly Returns (%)")

    # Rotate the x labels for the yearly returns bar plot
    ax2.set_xticklabels(yearly_returns_df.index.strftime("%Y"), rotation=45)
    ax2.set_xlabel("")

    # Adjust layout spacing
    plt.tight_layout()

    # Show the plot
    plt.show()

show_qs_stats

show_qs_stats(returns: Series, benchmark: str, strategy_name: str, save_dir: Optional[str] = None) -> None

Generate the full quantstats report for the strategy.

Parameters:

Name Type Description Default
returns Serie

The DataFrame containing the strategy returns and drawdowns.

required
benchmark str

The ticker symbol of the benchmark to compare the strategy to.

required
strategy_name str

The name of the strategy.

required
Source code in src/bbstrader/btengine/performance.py
def show_qs_stats(
    returns: pd.Series,
    benchmark: str,
    strategy_name: str,
    save_dir: Optional[str] = None,
) -> None:
    """
    Generate the full quantstats report for the strategy.

    Args:
        returns (pd.Serie):
            The DataFrame containing the strategy returns and drawdowns.
        benchmark (str):
            The ticker symbol of the benchmark to compare the strategy to.
        strategy_name (str): The name of the strategy.
    """
    # Load the returns data
    returns = returns.copy()

    # Drop duplicate index entries
    returns = returns[~returns.index.duplicated(keep="first")]

    # Extend pandas functionality with quantstats
    qs.extend_pandas()

    # Generate the full report with a benchmark
    qs.reports.full(returns, mode="full", benchmark=benchmark)
    qs.reports.html(returns, benchmark=benchmark, output=save_dir, title=strategy_name)

resample_ohlcv

resample_ohlcv(df: DataFrame, rule: str, *, label: str = 'left', closed: str = 'left') -> pd.DataFrame

Aggregate an OHLCV DataFrame up to a higher timeframe.

open=first, high=max, low=min, close=last, volume=sum (adj_close=last when present). Buckets with no data are dropped. df must have a DatetimeIndex.

Source code in src/bbstrader/btengine/timeframe.py
def resample_ohlcv(
    df: pd.DataFrame, rule: str, *, label: str = "left", closed: str = "left"
) -> pd.DataFrame:
    """Aggregate an OHLCV DataFrame up to a higher timeframe.

    open=first, high=max, low=min, close=last, volume=sum (adj_close=last when
    present). Buckets with no data are dropped. ``df`` must have a DatetimeIndex.
    """
    if not isinstance(df.index, pd.DatetimeIndex):
        raise TypeError("resample_ohlcv requires a DatetimeIndex.")
    agg = {}
    for col, how in (
        ("open", "first"),
        ("high", "max"),
        ("low", "min"),
        ("close", "last"),
        ("adj_close", "last"),
        ("volume", "sum"),
    ):
        if col in df.columns:
            agg[col] = how
    if "close" not in agg:
        raise ValueError("OHLCV frame must contain at least a 'close' column.")
    out = df.resample(_rule(rule), label=label, closed=closed).agg(agg)
    return out.dropna(subset=["close"])

vectorized_backtest

vectorized_backtest(close: ArrayLike, entries: ArrayLike, exits: ArrayLike, *, short_entries: Optional[ArrayLike] = None, short_exits: Optional[ArrayLike] = None, allow_short: bool = False, init_cash: float = 100000.0, fees: float = 0.0, slippage: float = 0.0, periods: int = 252) -> VectorizedResult

Run a fully vectorized signal backtest.

Parameters:

Name Type Description Default
close ArrayLike

Price series.

required
entries ArrayLike

Boolean array; True opens a long position.

required
exits ArrayLike

Boolean array; True closes the long position.

required
short_entries / short_exits

Optional short-side signals (require allow_short=True).

required
allow_short bool

Permit short positions.

False
init_cash float

Starting capital.

100000.0
fees float

Per-unit-turnover fee as a fraction of notional (e.g. 0.0005).

0.0
slippage float

Per-unit-turnover slippage as a fraction of notional.

0.0
periods int

Annualization factor for the Sharpe ratio.

252

Returns:

Name Type Description
A VectorizedResult

class:VectorizedResult with the equity curve and metrics.

Source code in src/bbstrader/btengine/vectorized.py
def vectorized_backtest(
    close: ArrayLike,
    entries: ArrayLike,
    exits: ArrayLike,
    *,
    short_entries: Optional[ArrayLike] = None,
    short_exits: Optional[ArrayLike] = None,
    allow_short: bool = False,
    init_cash: float = 100000.0,
    fees: float = 0.0,
    slippage: float = 0.0,
    periods: int = 252,
) -> VectorizedResult:
    """Run a fully vectorized signal backtest.

    Args:
        close: Price series.
        entries: Boolean array; True opens a long position.
        exits: Boolean array; True closes the long position.
        short_entries / short_exits: Optional short-side signals (require
            ``allow_short=True``).
        allow_short: Permit short positions.
        init_cash: Starting capital.
        fees: Per-unit-turnover fee as a fraction of notional (e.g. 0.0005).
        slippage: Per-unit-turnover slippage as a fraction of notional.
        periods: Annualization factor for the Sharpe ratio.

    Returns:
        A :class:`VectorizedResult` with the equity curve and metrics.
    """
    price = np.asarray(close, dtype=np.float64)
    if price.ndim != 1:
        raise ValueError("close must be a 1-D price series.")
    n = price.size
    ent = _as_bool(entries, n)
    ext = _as_bool(exits, n)
    sent = _as_bool(short_entries, n)
    sext = _as_bool(short_exits, n)
    if (sent.any() or sext.any()) and not allow_short:
        raise ValueError("short signals provided but allow_short is False.")

    pos = _build_positions(ent, ext, sent, sext, allow_short)

    # Bar returns; position from the previous bar is held into the current bar.
    bar_ret = np.zeros(n, dtype=np.float64)
    bar_ret[1:] = price[1:] / price[:-1] - 1.0
    prev_pos = np.concatenate([[0.0], pos[:-1]])
    gross = prev_pos * bar_ret

    # Trading cost charged on turnover at the bar the position changes.
    turnover = np.abs(pos - prev_pos)
    cost = (fees + slippage) * turnover
    strat_ret = gross - cost

    equity = init_cash * np.cumprod(1.0 + strat_ret)
    trades = _extract_trades(pos)
    return VectorizedResult(
        equity=equity,
        returns=strat_ret,
        position=pos,
        trades=trades,
        init_cash=init_cash,
        periods=periods,
    )

analytics

Institutional risk analytics: VaR/CVaR, Monte Carlo, regimes, factor exposure.

These functions extend the quantstats-backed :mod:bbstrader.btengine.performance metrics with ex-ante risk and attribution tools. They operate on plain return series (NumPy arrays or pandas Series) and are deterministic the Monte Carlo routines take an explicit seed so they are safe for reproducible research.

MonteCarloResult dataclass

MonteCarloResult(terminal_returns: NDArray[float64], bands: Dict[str, NDArray[float64]], horizon: int)

Monte Carlo simulated terminal-return distribution and equity bands.

mean_terminal property
mean_terminal: float

The mean simulated terminal return.

prob_loss property
prob_loss: float

The simulated probability of a negative terminal return.

quantile
quantile(q: float) -> float

Return the q-quantile of the simulated terminal returns.

Parameters:

Name Type Description Default
q float

Quantile in the interval [0, 1].

required

Returns:

Name Type Description
float float

The terminal return at quantile q.

Source code in src/bbstrader/btengine/analytics.py
def quantile(self, q: float) -> float:
    """Return the ``q``-quantile of the simulated terminal returns.

    Args:
        q (float): Quantile in the interval [0, 1].

    Returns:
        float: The terminal return at quantile ``q``.
    """
    return float(np.quantile(self.terminal_returns, q))

historical_var

historical_var(returns: ReturnsLike, level: float = 0.95) -> float

Historical Value-at-Risk as a positive loss fraction at level.

Source code in src/bbstrader/btengine/analytics.py
def historical_var(returns: ReturnsLike, level: float = 0.95) -> float:
    """Historical Value-at-Risk as a positive loss fraction at ``level``."""
    r = _clean(returns)
    if r.size == 0:
        return 0.0
    return float(-np.quantile(r, 1.0 - level))

parametric_var

parametric_var(returns: ReturnsLike, level: float = 0.95) -> float

Gaussian (parametric) Value-at-Risk as a positive loss fraction.

Source code in src/bbstrader/btengine/analytics.py
def parametric_var(returns: ReturnsLike, level: float = 0.95) -> float:
    """Gaussian (parametric) Value-at-Risk as a positive loss fraction."""
    r = _clean(returns)
    if r.size == 0:
        return 0.0
    z = stats.norm.ppf(1.0 - level)
    return float(-(r.mean() + r.std(ddof=1) * z))

historical_cvar

historical_cvar(returns: ReturnsLike, level: float = 0.95) -> float

Historical Conditional VaR (expected shortfall) beyond the VaR threshold.

Source code in src/bbstrader/btengine/analytics.py
def historical_cvar(returns: ReturnsLike, level: float = 0.95) -> float:
    """Historical Conditional VaR (expected shortfall) beyond the VaR threshold."""
    r = _clean(returns)
    if r.size == 0:
        return 0.0
    threshold = np.quantile(r, 1.0 - level)
    tail = r[r <= threshold]
    if tail.size == 0:
        return float(-threshold)
    return float(-tail.mean())

parametric_cvar

parametric_cvar(returns: ReturnsLike, level: float = 0.95) -> float

Gaussian Conditional VaR (expected shortfall).

Source code in src/bbstrader/btengine/analytics.py
def parametric_cvar(returns: ReturnsLike, level: float = 0.95) -> float:
    """Gaussian Conditional VaR (expected shortfall)."""
    r = _clean(returns)
    if r.size == 0:
        return 0.0
    alpha = 1.0 - level
    z = stats.norm.ppf(alpha)
    es = r.mean() - r.std(ddof=1) * stats.norm.pdf(z) / alpha
    return float(-es)

monte_carlo_bootstrap

monte_carlo_bootstrap(returns: ReturnsLike, n_sims: int = 1000, horizon: Optional[int] = None, seed: int = 0, quantiles=(0.05, 0.5, 0.95)) -> MonteCarloResult

Bootstrap the return series into equity-curve confidence bands.

Resamples the historical returns with replacement to build n_sims equity paths over horizon steps, then reports per-step percentile bands and the terminal-return distribution. Deterministic given seed.

Source code in src/bbstrader/btengine/analytics.py
def monte_carlo_bootstrap(
    returns: ReturnsLike,
    n_sims: int = 1000,
    horizon: Optional[int] = None,
    seed: int = 0,
    quantiles=(0.05, 0.5, 0.95),
) -> MonteCarloResult:
    """Bootstrap the return series into equity-curve confidence bands.

    Resamples the historical returns with replacement to build ``n_sims`` equity
    paths over ``horizon`` steps, then reports per-step percentile bands and the
    terminal-return distribution. Deterministic given ``seed``.
    """
    r = _clean(returns)
    if r.size == 0:
        raise ValueError("returns must contain at least one finite value.")
    h = horizon or r.size
    rng = np.random.default_rng(seed)
    # (n_sims, h) sampled returns -> cumulative equity paths.
    sampled = rng.choice(r, size=(n_sims, h), replace=True)
    equity_paths = np.cumprod(1.0 + sampled, axis=1)
    bands = {
        f"q{int(q * 100)}": np.quantile(equity_paths, q, axis=0) for q in quantiles
    }
    terminal = equity_paths[:, -1] - 1.0
    return MonteCarloResult(terminal_returns=terminal, bands=bands, horizon=h)

cusum_change_points

cusum_change_points(series: ReturnsLike, threshold: float = 1.0) -> NDArray[np.int_]

Detect mean-shift change points with a two-sided CUSUM filter.

threshold is in units of the series' standard deviation. Returns the indices at which the cumulative sum breaches the threshold (and resets).

Source code in src/bbstrader/btengine/analytics.py
def cusum_change_points(
    series: ReturnsLike, threshold: float = 1.0
) -> NDArray[np.int_]:
    """Detect mean-shift change points with a two-sided CUSUM filter.

    ``threshold`` is in units of the series' standard deviation. Returns the
    indices at which the cumulative sum breaches the threshold (and resets).
    """
    x = np.asarray(series, dtype=np.float64)
    x = x[~np.isnan(x)]
    if x.size == 0:
        return np.array([], dtype=int)
    mean = x.mean()
    sd = x.std(ddof=1) or 1.0
    thr = threshold * sd
    s_pos = s_neg = 0.0
    points = []
    for i, val in enumerate(x):
        diff = val - mean
        s_pos = max(0.0, s_pos + diff)
        s_neg = min(0.0, s_neg + diff)
        if s_pos > thr or s_neg < -thr:
            points.append(i)
            s_pos = s_neg = 0.0
    return np.array(points, dtype=int)

volatility_regimes

volatility_regimes(returns: ReturnsLike, window: int = 20, n_states: int = 2) -> NDArray[np.int_]

Label each bar by its volatility regime (0 = lowest vol .. n_states-1).

A lightweight, dependency-free alternative to an HMM: rolling volatility is bucketed into n_states quantile bins. Useful for conditional-performance analysis (how a strategy behaves in calm vs. turbulent regimes).

Source code in src/bbstrader/btengine/analytics.py
def volatility_regimes(
    returns: ReturnsLike, window: int = 20, n_states: int = 2
) -> NDArray[np.int_]:
    """Label each bar by its volatility regime (0 = lowest vol .. n_states-1).

    A lightweight, dependency-free alternative to an HMM: rolling volatility is
    bucketed into ``n_states`` quantile bins. Useful for conditional-performance
    analysis (how a strategy behaves in calm vs. turbulent regimes).
    """
    r = pd.Series(np.asarray(returns, dtype=np.float64))
    vol = r.rolling(window, min_periods=1).std().fillna(0.0).to_numpy()
    # Bucket by quantile edges so each state holds a comparable share of bars.
    edges = np.quantile(vol, np.linspace(0, 1, n_states + 1)[1:-1])
    return np.digitize(vol, edges).astype(int)

factor_exposure

factor_exposure(returns: ReturnsLike, factors: Union[DataFrame, Series, NDArray]) -> Dict[str, float]

OLS factor regression: alpha, factor betas and R-squared.

factors may be a single series (e.g. the market) or a DataFrame of factor returns aligned to returns. Returns alpha, one beta per factor and the regression R-squared.

Source code in src/bbstrader/btengine/analytics.py
def factor_exposure(
    returns: ReturnsLike, factors: Union[pd.DataFrame, pd.Series, NDArray]
) -> Dict[str, float]:
    """OLS factor regression: alpha, factor betas and R-squared.

    ``factors`` may be a single series (e.g. the market) or a DataFrame of
    factor returns aligned to ``returns``. Returns alpha, one beta per factor
    and the regression R-squared.
    """
    y = _clean(returns)
    F = np.asarray(factors, dtype=np.float64)
    if F.ndim == 1:
        F = F.reshape(-1, 1)
    n = min(len(y), len(F))
    y, F = y[:n], F[:n]
    X = np.column_stack([np.ones(n), F])
    coef, _, _, _ = np.linalg.lstsq(X, y, rcond=None)
    resid = y - X @ coef
    ss_res = float(np.sum(resid**2))
    ss_tot = float(np.sum((y - y.mean()) ** 2)) or 1.0
    names = (
        list(factors.columns)
        if isinstance(factors, pd.DataFrame)
        else [f"factor_{i}" for i in range(F.shape[1])]
    )
    result = {"alpha": float(coef[0]), "r_squared": 1.0 - ss_res / ss_tot}
    for name, beta in zip(names, coef[1:]):
        result[f"beta_{name}"] = float(beta)
    return result

rolling_beta

rolling_beta(returns: ReturnsLike, market: ReturnsLike, window: int = 60) -> NDArray[np.float64]

Rolling market beta (cov/var) over window bars; NaN until warmed up.

Source code in src/bbstrader/btengine/analytics.py
def rolling_beta(
    returns: ReturnsLike, market: ReturnsLike, window: int = 60
) -> NDArray[np.float64]:
    """Rolling market beta (cov/var) over ``window`` bars; NaN until warmed up."""
    r = pd.Series(np.asarray(returns, dtype=np.float64))
    m = pd.Series(np.asarray(market, dtype=np.float64))
    cov = r.rolling(window).cov(m)
    var = m.rolling(window).var()
    return (cov / var).to_numpy()

backtest

BacktestEngine

BacktestEngine(symbol_list: List[str], initial_capital: float, heartbeat: float, start_date: datetime, data_handler: Type[DataHandler], execution_handler: Type[ExecutionHandler], strategy: Type[Strategy], /, **kwargs: Any)

The BacktestEngine() object encapsulates the event-handling logic and essentially ties together all of the other classes.

The BacktestEngine object is designed to carry out a nested while-loop event-driven system in order to handle the events placed on the Event Queue object. The outer while-loop is known as the "heartbeat loop" and decides the temporal resolution of the backtesting system. In a live environment this value will be a positive number, such as 600 seconds (every ten minutes). Thus the market data and positions will only be updated on this timeframe.

For the backtester described here the "heartbeat" can be set to zero, irrespective of the strategy frequency, since the data is already available by virtue of the fact it is historical! We can run the backtest at whatever speed we like, since the event-driven system is agnostic to when the data became available, so long as it has an associated timestamp.

The inner while-loop actually processes the signals and sends them to the correct component depending upon the event type. Thus the Event Queue is continually being populated and depopulated with events. This is what it means for a system to be event-driven.

The initialisation of the BacktestEngine object requires the full symbol list of traded symbols, the initial capital, the heartbeat time in milliseconds, the start datetime stamp of the backtest as well as the DataHandler, ExecutionHandler, Strategy objects and additionnal kwargs based on the ExecutionHandler, the DataHandler, and the Strategy used.

A Queue is used to hold the events. The signals, orders and fills are counted. For a MarketEvent, the Strategy object is told to recalculate new signals, while the Portfolio object is told to reindex the time. If a SignalEvent object is received the Portfolio is told to handle the new signal and convert it into a set of OrderEvents, if appropriate. If an OrderEvent is received the ExecutionHandler is sent the order to be transmitted to the broker (if in a real trading setting). Finally, if a FillEvent is received, the Portfolio will update itself to be aware of the new positions.

Initialises the backtest.

Parameters:

Name Type Description Default
symbol_list List[str]

The list of symbol strings.

required
intial_capital float

The starting capital for the portfolio.

required
heartbeat float

Backtest "heartbeat" in seconds

required
start_date datetime

The start datetime of the strategy.

required
data_handler (DataHandler)

Handles the market data feed.

required
execution_handler (ExecutionHandler)

Handles the orders/fills for trades.

required
strategy Strategy

Generates signals based on market data.

required
kwargs

Additional parameters based on the ExecutionHandler, the DataHandler, the Strategy used and the Portfolio. - show_equity (bool): Show the equity curve of the portfolio. - stats_file (str): File to save the summary stats.

required
Source code in src/bbstrader/btengine/backtest.py
def __init__(
    self,
    symbol_list: List[str],
    initial_capital: float,
    heartbeat: float,
    start_date: datetime,
    data_handler: Type[DataHandler],
    execution_handler: Type[ExecutionHandler],
    strategy: Type[Strategy],
    /,
    **kwargs: Any,
) -> None:
    """
    Initialises the backtest.

    Args:
        symbol_list (List[str]): The list of symbol strings.
        intial_capital (float): The starting capital for the portfolio.
        heartbeat (float): Backtest "heartbeat" in seconds
        start_date (datetime): The start datetime of the strategy.
        data_handler (DataHandler) : Handles the market data feed.
        execution_handler (ExecutionHandler) : Handles the orders/fills for trades.
        strategy (Strategy): Generates signals based on market data.
        kwargs : Additional parameters based on the `ExecutionHandler`,
            the `DataHandler`, the `Strategy` used and the `Portfolio`.
            - show_equity (bool): Show the equity curve of the portfolio.
            - stats_file (str): File to save the summary stats.
    """
    self.symbol_list = symbol_list
    self.initial_capital = initial_capital
    self.heartbeat = heartbeat
    self.start_date = start_date

    self.dh_cls = data_handler
    self.eh_cls = execution_handler
    self.strategy_cls = strategy
    self.kwargs = kwargs

    self.events: "queue.Queue[Events]" = queue.Queue()

    self.signals = 0
    self.orders = 0
    self.fills = 0

    self._generate_trading_instances()
    self.show_equity = kwargs.get("show_equity", False)
    self.stats_file = kwargs.get("stats_file", None)
simulate_trading
simulate_trading() -> pd.DataFrame

Simulates the backtest and outputs portfolio performance.

Returns:

Type Description
DataFrame

pd.DataFrame: The portfilio values over time (capital, equity, returns etc.)

Source code in src/bbstrader/btengine/backtest.py
def simulate_trading(self) -> pd.DataFrame:
    """
    Simulates the backtest and outputs portfolio performance.

    Returns:
        pd.DataFrame: The portfilio values over time (capital, equity, returns etc.)
    """
    self._run_backtest()
    self._output_performance()
    return self.portfolio.equity_curve

run_backtest

run_backtest(symbol_list: List[str], start_date: datetime, data_handler: Type[DataHandler], strategy: Type[Strategy], exc_handler: Optional[Type[ExecutionHandler]] = None, initial_capital: float = 100000.0, heartbeat: float = 0.0, **kwargs: Any) -> pd.DataFrame

Runs a backtest simulation based on a DataHandler, Strategy, and ExecutionHandler.

Parameters:

Name Type Description Default
symbol_list List[str]

List of symbol strings for the assets to be backtested.

required
start_date datetime

Start date of the backtest.

required
data_handler DataHandler

A subclass of the DataHandler class, responsible for managing and processing market data. Available options include CSVDataHandler, MT5DataHandler, and YFDataHandler.

required
strategy Strategy

The trading strategy to be employed during the backtest. The strategy must be a subclass of Strategy and should include the following attributes: - bars (DataHandler): The DataHandler class for the strategy. - events (Queue): Queue instance for managing events. - symbol_list (List[str]): List of symbols to trade. - mode (str): 'live' or 'backtest'.

Additional parameters specific to the strategy should be passed in **kwargs. The strategy class must implement a calculate_signals method to generate SignalEvent.

required
exc_handler ExecutionHandler

The execution handler for managing order executions. If not provided, a SimulatedExecutionHandler will be used by default. This handler must implement an execute_order method to process OrderEvent in the Backtest class.

None
initial_capital float

The initial capital for the portfolio in the backtest. Default is 100,000.

100000.0
heartbeat float

Time delay (in seconds) between iterations of the event-driven backtest loop. Default is 0.0, allowing the backtest to run as fast as possible. This could also be used as a time frame in live trading (e.g., 1m, 5m, 15m) with a live DataHandler.

0.0
**kwargs Any

Additional parameters passed to the Backtest instance, which may include strategy-specific, data handler, portfolio, or execution handler options.

{}

Returns:

Type Description
DataFrame

pd.DataFrame: The portfolio values over time (capital, equities, returns etc.).

Notes

This function generates three outputs: - A performance summary saved as an HTML file. - An equity curve of the portfolio saved as a CSV file. - Monthly returns saved as a PNG image.

Example

from examples.strategies import StockIndexSTBOTrading from bbstrader.config import config_logger from bbstrader.btengine.data import MT5DataHandler from bbstrader.btengine.execution import MT5ExecutionHandler from datetime import datetime

logger = config_logger('index_trade.log', console_log=True) symbol_list = ['[SP500]', 'GERMANY40', '[DJI30]', '[NQ100]'] start = datetime(2010, 6, 1, 2, 0, 0) kwargs = { ... 'expected_returns': {'[NQ100]': 1.5, '[SP500]': 1.5, '[DJI30]': 1.0, 'GERMANY40': 1.0}, ... 'quantities': {'[NQ100]': 15, '[SP500]': 30, '[DJI30]': 5, 'GERMANY40': 10}, ... 'max_trades': {'[NQ100]': 3, '[SP500]': 3, '[DJI30]': 3, 'GERMANY40': 3}, ... 'mt5_start': start, ... 'time_frame': '15m', ... 'strategy_name': 'SISTBO', ... } run_backtest( ... symbol_list=symbol_list, ... start_date=start, ... data_handler=MT5DataHandler, ... strategy=StockIndexSTBOTrading, ... exc_handler=MT5ExecutionHandler, ... initial_capital=100000.0, ... heartbeat=0.0, ... **kwargs ... )

Source code in src/bbstrader/btengine/backtest.py
def run_backtest(
    symbol_list: List[str],
    start_date: datetime,
    data_handler: Type[DataHandler],
    strategy: Type[Strategy],
    exc_handler: Optional[Type[ExecutionHandler]] = None,
    initial_capital: float = 100000.0,
    heartbeat: float = 0.0,
    **kwargs: Any,
) -> pd.DataFrame:
    """
    Runs a backtest simulation based on a `DataHandler`, `Strategy`, and `ExecutionHandler`.

    Args:
        symbol_list (List[str]): List of symbol strings for the assets to be backtested.

        start_date (datetime): Start date of the backtest.

        data_handler (DataHandler): A subclass of the `DataHandler` class, responsible for managing
            and processing market data. Available options include `CSVDataHandler`,
            `MT5DataHandler`, and `YFDataHandler`.

        strategy (Strategy): The trading strategy to be employed during the backtest.
            The strategy must be a subclass of `Strategy` and should include the following attributes:
            - `bars` (DataHandler): The `DataHandler` class for the strategy.
            - `events` (Queue): Queue instance for managing events.
            - `symbol_list` (List[str]): List of symbols to trade.
            - `mode` (str): 'live' or 'backtest'.

            Additional parameters specific to the strategy should be passed in `**kwargs`.
            The strategy class must implement a `calculate_signals` method to generate `SignalEvent`.

        exc_handler (ExecutionHandler, optional): The execution handler for managing order executions.
            If not provided, a `SimulatedExecutionHandler` will be used by default. This handler must
            implement an `execute_order` method to process `OrderEvent` in the `Backtest` class.

        initial_capital (float, optional): The initial capital for the portfolio in the backtest.
            Default is 100,000.

        heartbeat (float, optional): Time delay (in seconds) between iterations of the event-driven
            backtest loop. Default is 0.0, allowing the backtest to run as fast as possible. This could
            also be used as a time frame in live trading (e.g., 1m, 5m, 15m) with a live `DataHandler`.

        **kwargs: Additional parameters passed to the `Backtest` instance, which may include strategy-specific,
            data handler, portfolio, or execution handler options.

    Returns:
        pd.DataFrame: The portfolio values over time (capital, equities, returns etc.).

    Notes:
        This function generates three outputs:
            - A performance summary saved as an HTML file.
            - An equity curve of the portfolio saved as a CSV file.
            - Monthly returns saved as a PNG image.

    Example:
        >>> from examples.strategies import StockIndexSTBOTrading
        >>> from bbstrader.config import config_logger
        >>> from bbstrader.btengine.data import MT5DataHandler
        >>> from bbstrader.btengine.execution import MT5ExecutionHandler
        >>> from datetime import datetime
        >>>
        >>> logger = config_logger('index_trade.log', console_log=True)
        >>> symbol_list = ['[SP500]', 'GERMANY40', '[DJI30]', '[NQ100]']
        >>> start = datetime(2010, 6, 1, 2, 0, 0)
        >>> kwargs = {
        ...     'expected_returns': {'[NQ100]': 1.5, '[SP500]': 1.5, '[DJI30]': 1.0, 'GERMANY40': 1.0},
        ...     'quantities': {'[NQ100]': 15, '[SP500]': 30, '[DJI30]': 5, 'GERMANY40': 10},
        ...     'max_trades': {'[NQ100]': 3, '[SP500]': 3, '[DJI30]': 3, 'GERMANY40': 3},
        ...     'mt5_start': start,
        ...     'time_frame': '15m',
        ...     'strategy_name': 'SISTBO',
        ... }
        >>> run_backtest(
        ...     symbol_list=symbol_list,
        ...     start_date=start,
        ...     data_handler=MT5DataHandler,
        ...     strategy=StockIndexSTBOTrading,
        ...     exc_handler=MT5ExecutionHandler,
        ...     initial_capital=100000.0,
        ...     heartbeat=0.0,
        ...     **kwargs
        ... )
    """
    if exc_handler is None:
        execution_handler: Type[ExecutionHandler] = SimExecutionHandler
    else:
        execution_handler = exc_handler
    engine = BacktestEngine(
        symbol_list,
        initial_capital,
        heartbeat,
        start_date,
        data_handler,
        execution_handler,
        strategy,
        **kwargs,
    )
    portfolio = engine.simulate_trading()
    return portfolio

run_backtest_with

run_backtest_with(engine: Literal['bbstrader', 'cerebro', 'zipline'], **kwargs: Any) -> Optional[pd.DataFrame]
Source code in src/bbstrader/btengine/backtest.py
def run_backtest_with(
    engine: Literal["bbstrader", "cerebro", "zipline"], **kwargs: Any
) -> Optional[pd.DataFrame]:
    """ """
    if engine == "bbstrader":
        return run_backtest(
            symbol_list=kwargs.get("symbol_list"),  # type: ignore
            start_date=kwargs.get("start_date"),  # type: ignore
            data_handler=kwargs.get("data_handler"),  # type: ignore
            strategy=kwargs.get("strategy"),  # type: ignore
            exc_handler=kwargs.get("exc_handler"),
            initial_capital=kwargs.get("initial_capital", 100000.0),
            heartbeat=kwargs.get("heartbeat", 0.0),
            **kwargs,
        )
    elif engine == "cerebro":
        # TODO:
        raise NotImplementedError("cerebro engine is not supported yet")
    elif engine == "zipline":
        # TODO:
        raise NotImplementedError("zipline engine is not supported yet")
    return None

catalog

A unified, cached data catalog over the existing data handlers.

Today the download handlers re-fetch on every run. The catalog adds a real local store with cache-hit semantics and point-in-time metadata, so re-runs are instant and offline-capable. Data is persisted as Parquet when pyarrow is available and transparently falls back to CSV otherwise, so a lean install (without the [catalog] extra) keeps working.

The store is intentionally decoupled from the handler plumbing: fetch takes any zero-argument loader that returns a normalized OHLCV DataFrame, which makes it trivial to back with YFDataHandler, a broker API, or a test stub.

DataCatalog

DataCatalog(base_dir: Optional[str] = None, fmt: str = 'auto')

A local OHLCV cache keyed by (source, symbol, timeframe).

Parameters:

Name Type Description Default
base_dir Optional[str]

Root directory for the store. Defaults to ~/.bbstrader/data/catalog.

None
fmt str

"parquet", "csv", or "auto" (Parquet when pyarrow is installed, else CSV).

'auto'

Initialise the catalog and ensure its base directory exists.

Parameters:

Name Type Description Default
base_dir Optional[str]

Root directory for the store. Defaults to ~/.bbstrader/data/catalog.

None
fmt str

One of "parquet", "csv" or "auto" (Parquet when pyarrow is installed, else CSV).

'auto'

Raises:

Type Description
ValueError

If fmt is not one of the accepted values.

Source code in src/bbstrader/btengine/catalog.py
def __init__(self, base_dir: Optional[str] = None, fmt: str = "auto") -> None:
    """Initialise the catalog and ensure its base directory exists.

    Args:
        base_dir (Optional[str]): Root directory for the store. Defaults to
            ``~/.bbstrader/data/catalog``.
        fmt (str): One of ``"parquet"``, ``"csv"`` or ``"auto"`` (Parquet
            when pyarrow is installed, else CSV).

    Raises:
        ValueError: If ``fmt`` is not one of the accepted values.
    """
    if fmt not in ("auto", "parquet", "csv"):
        raise ValueError(f"fmt must be 'auto', 'parquet' or 'csv', got {fmt!r}.")
    self.base_dir = Path(base_dir or BBSTRADER_DIR / "data" / "catalog")
    self.base_dir.mkdir(parents=True, exist_ok=True)
    self._fmt = fmt
fmt property
fmt: str

The effective storage format after resolving auto.

has
has(source: str, symbol: str, timeframe: str) -> bool

Return True if a cached dataset exists for the key.

Source code in src/bbstrader/btengine/catalog.py
def has(self, source: str, symbol: str, timeframe: str) -> bool:
    """Return True if a cached dataset exists for the key."""
    return self._data_path(source, symbol, timeframe).exists()
metadata
metadata(source: str, symbol: str, timeframe: str) -> Optional[Dict[str, Any]]

Return the stored metadata for the key, or None if absent.

Source code in src/bbstrader/btengine/catalog.py
def metadata(
    self, source: str, symbol: str, timeframe: str
) -> Optional[Dict[str, Any]]:
    """Return the stored metadata for the key, or None if absent."""
    meta_path = self._meta_path(source, symbol, timeframe)
    if not meta_path.exists():
        return None
    return json.loads(meta_path.read_text())
is_fresh
is_fresh(source: str, symbol: str, timeframe: str, max_age_days: Optional[float] = None) -> bool

Return True if the cached dataset exists and is within max_age_days.

A max_age_days of None means "never expires" (any cached copy is fresh); a value of 0 (or negative) means the cache is always stale, independent of clock resolution.

Source code in src/bbstrader/btengine/catalog.py
def is_fresh(
    self,
    source: str,
    symbol: str,
    timeframe: str,
    max_age_days: Optional[float] = None,
) -> bool:
    """Return True if the cached dataset exists and is within ``max_age_days``.

    A ``max_age_days`` of None means "never expires" (any cached copy is
    fresh); a value of 0 (or negative) means the cache is always stale,
    independent of clock resolution.
    """
    if not self.has(source, symbol, timeframe):
        return False
    if max_age_days is None:
        return True
    # A zero/negative budget means "always reload". Handle it explicitly so
    # the result does not hinge on sub-millisecond timestamp resolution
    # (Windows clocks can read an age of exactly 0 for a just-written file).
    if max_age_days <= 0:
        return False
    meta = self.metadata(source, symbol, timeframe)
    if not meta or "fetched_at" not in meta:
        return False
    fetched_at = datetime.fromisoformat(meta["fetched_at"])
    age = datetime.now(timezone.utc) - fetched_at
    return age.total_seconds() <= max_age_days * 86400.0
get
get(source: str, symbol: str, timeframe: str) -> Optional[pd.DataFrame]

Load a cached dataset, or None if it is not present.

Source code in src/bbstrader/btengine/catalog.py
def get(self, source: str, symbol: str, timeframe: str) -> Optional[pd.DataFrame]:
    """Load a cached dataset, or None if it is not present."""
    path = self._data_path(source, symbol, timeframe)
    if not path.exists():
        return None
    if self.fmt == "parquet":
        return pd.read_parquet(path)
    return pd.read_csv(path, index_col=0, parse_dates=True)
put
put(df: DataFrame, source: str, symbol: str, timeframe: str, extra_meta: Optional[Dict[str, Any]] = None) -> Path

Persist df for the key and write a metadata sidecar.

Source code in src/bbstrader/btengine/catalog.py
def put(
    self,
    df: pd.DataFrame,
    source: str,
    symbol: str,
    timeframe: str,
    extra_meta: Optional[Dict[str, Any]] = None,
) -> Path:
    """Persist ``df`` for the key and write a metadata sidecar."""
    path = self._data_path(source, symbol, timeframe)
    if self.fmt == "parquet":
        df.to_parquet(path)
    else:
        df.to_csv(path)
    index = df.index
    meta: Dict[str, Any] = {
        "source": source,
        "symbol": symbol,
        "timeframe": timeframe,
        "rows": int(len(df)),
        "format": self.fmt,
        "fetched_at": datetime.now(timezone.utc).isoformat(),
        "start": str(index.min()) if len(index) else None,
        "end": str(index.max()) if len(index) else None,
    }
    if extra_meta:
        meta.update(extra_meta)
    self._meta_path(source, symbol, timeframe).write_text(
        json.dumps(meta, indent=2)
    )
    return path
fetch
fetch(loader: Callable[[], DataFrame], source: str, symbol: str, timeframe: str = 'D1', max_age_days: Optional[float] = None, force: bool = False, extra_meta: Optional[Dict[str, Any]] = None) -> pd.DataFrame

Return cached data if fresh, otherwise call loader and cache it.

Parameters:

Name Type Description Default
loader Callable[[], DataFrame]

Zero-argument callable returning a normalized OHLCV DataFrame (only called on a cache miss or when force is set).

required
source str

Logical source name (e.g. "yfinance").

required
symbol str

Instrument symbol.

required
timeframe str

Bar timeframe/period label used in the cache key.

'D1'
max_age_days Optional[float]

Maximum acceptable cache age; None means never expires.

None
force bool

Bypass the cache and always reload.

False
Source code in src/bbstrader/btengine/catalog.py
def fetch(
    self,
    loader: Callable[[], pd.DataFrame],
    source: str,
    symbol: str,
    timeframe: str = "D1",
    max_age_days: Optional[float] = None,
    force: bool = False,
    extra_meta: Optional[Dict[str, Any]] = None,
) -> pd.DataFrame:
    """Return cached data if fresh, otherwise call ``loader`` and cache it.

    Args:
        loader: Zero-argument callable returning a normalized OHLCV DataFrame
            (only called on a cache miss or when ``force`` is set).
        source: Logical source name (e.g. ``"yfinance"``).
        symbol: Instrument symbol.
        timeframe: Bar timeframe/period label used in the cache key.
        max_age_days: Maximum acceptable cache age; None means never expires.
        force: Bypass the cache and always reload.
    """
    if not force and self.is_fresh(source, symbol, timeframe, max_age_days):
        cached = self.get(source, symbol, timeframe)
        if cached is not None:
            return cached
    df = loader()
    self.put(df, source, symbol, timeframe, extra_meta=extra_meta)
    return df
list_datasets
list_datasets() -> List[Dict[str, Any]]

Return metadata for every dataset currently in the store.

Source code in src/bbstrader/btengine/catalog.py
def list_datasets(self) -> List[Dict[str, Any]]:
    """Return metadata for every dataset currently in the store."""
    out: List[Dict[str, Any]] = []
    for meta_file in sorted(self.base_dir.glob("*.meta.json")):
        try:
            out.append(json.loads(meta_file.read_text()))
        except (OSError, json.JSONDecodeError):
            continue
    return out

has_pyarrow

has_pyarrow() -> bool

Return True if a Parquet engine (pyarrow) is importable.

Source code in src/bbstrader/btengine/catalog.py
def has_pyarrow() -> bool:
    """Return True if a Parquet engine (pyarrow) is importable."""
    try:
        import pyarrow  # type: ignore # noqa: F401

        return True
    except ImportError:
        return False

data

DataHandler

One of the goals of an event-driven trading system is to minimise duplication of code between the backtesting element and the live execution element. Ideally it would be optimal to utilise the same signal generation methodology and portfolio management components for both historical testing and live trading. In order for this to work the Strategy object which generates the Signals, and the Portfolio object which provides Orders based on them, must utilise an identical interface to a market feed for both historic and live running.

This motivates the concept of a class hierarchy based on a DataHandler object, which givesall subclasses an interface for providing market data to the remaining components within thesystem. In this way any subclass data handler can be "swapped out", without affecting strategy or portfolio calculation.

Specific example subclasses could include HistoricCSVDataHandler, YFinanceDataHandler, FMPDataHandler, IBMarketFeedDataHandler etc.

symbols property
symbols: List[str]

The list of symbols this handler serves.

data property
data: Dict[str, DataFrame]

The loaded market data, keyed by symbol.

labels property
labels: List[str]

The OHLCV column labels exposed by the handler.

index property
index: Union[str, List[str]]

The name(s) of the datetime index column(s).

get_latest_bar abstractmethod
get_latest_bar(symbol: str) -> pd.Series

Returns the last bar updated.

Source code in src/bbstrader/btengine/data.py
@abstractmethod
def get_latest_bar(self, symbol: str) -> pd.Series:
    """
    Returns the last bar updated.
    """
    raise NotImplementedError("Should implement get_latest_bar()")
get_latest_bars abstractmethod
get_latest_bars(symbol: str, N: int = 1, df: bool = True) -> Union[pd.DataFrame, List[pd.Series]]

Returns the last N bars updated.

Source code in src/bbstrader/btengine/data.py
@abstractmethod
def get_latest_bars(
    self, symbol: str, N: int = 1, df: bool = True
) -> Union[pd.DataFrame, List[pd.Series]]:
    """
    Returns the last N bars updated.
    """
    raise NotImplementedError("Should implement get_latest_bars()")
get_latest_bar_datetime abstractmethod
get_latest_bar_datetime(symbol: str) -> Union[datetime, pd.Timestamp]

Returns a Python datetime object for the last bar.

Source code in src/bbstrader/btengine/data.py
@abstractmethod
def get_latest_bar_datetime(self, symbol: str) -> Union[datetime, pd.Timestamp]:
    """
    Returns a Python datetime object for the last bar.
    """
    raise NotImplementedError("Should implement get_latest_bar_datetime()")
get_latest_bar_value abstractmethod
get_latest_bar_value(symbol: str, val_type: str) -> float

Returns one of the Open, High, Low, Close, Adj Close, Volume or Returns from the last bar.

Source code in src/bbstrader/btengine/data.py
@abstractmethod
def get_latest_bar_value(self, symbol: str, val_type: str) -> float:
    """
    Returns one of the Open, High, Low, Close, Adj Close, Volume or Returns
    from the last bar.
    """
    raise NotImplementedError("Should implement get_latest_bar_value()")
get_latest_bars_values abstractmethod
get_latest_bars_values(symbol: str, val_type: str, N: int = 1) -> NDArray

Returns the last N bar values from the latest_symbol list, or N-k if less available.

Source code in src/bbstrader/btengine/data.py
@abstractmethod
def get_latest_bars_values(self, symbol: str, val_type: str, N: int = 1) -> NDArray:
    """
    Returns the last N bar values from the
    latest_symbol list, or N-k if less available.
    """
    raise NotImplementedError("Should implement get_latest_bars_values()")
update_bars abstractmethod
update_bars() -> None

Pushes the latest bars to the bars_queue for each symbol in a tuple OHLCVI format: (datetime, Open, High, Low, Close, Adj Close, Volume, Retruns).

Source code in src/bbstrader/btengine/data.py
@abstractmethod
def update_bars(self) -> None:
    """
    Pushes the latest bars to the bars_queue for each symbol
    in a tuple OHLCVI format: (datetime, Open, High, Low,
    Close, Adj Close, Volume, Retruns).
    """
    raise NotImplementedError("Should implement update_bars()")

BaseCSVDataHandler

BaseCSVDataHandler(events: Queue[MarketEvent], symbol_list: List[str], csv_dir: str, columns: Optional[List[str]] = None, index_col: Union[str, int, List[str], List[int]] = 0, persist_normalized: bool = True)

Bases: DataHandler

Base class for handling data loaded from CSV files.

Initialises the data handler by requesting the location of the CSV files and a list of symbols.

Parameters:

Name Type Description Default
events

The Event Queue.

required
symbol_list

A list of symbol strings.

required
csv_dir

Absolute directory path to the CSV files.

required
columns

List of column names to use for the data.

required
index_col

Column to use as the index.

required
persist_normalized

Whether to write the normalized frame back to csv_dir. True for handlers that own csv_dir as a cache (the download handlers); False when csv_dir is the user's source directory, to avoid mutating it and to keep parallel workers that share one directory from racing on the same file.

required
Source code in src/bbstrader/btengine/data.py
def __init__(
    self,
    events: "Queue[MarketEvent]",
    symbol_list: List[str],
    csv_dir: str,
    columns: Optional[List[str]] = None,
    index_col: Union[str, int, List[str], List[int]] = 0,
    persist_normalized: bool = True,
) -> None:
    """
    Initialises the data handler by requesting the location of the CSV files
    and a list of symbols.

    Args:
        events : The Event Queue.
        symbol_list : A list of symbol strings.
        csv_dir : Absolute directory path to the CSV files.
        columns : List of column names to use for the data.
        index_col : Column to use as the index.
        persist_normalized : Whether to write the normalized frame back to
            ``csv_dir``. True for handlers that own ``csv_dir`` as a cache
            (the download handlers); False when ``csv_dir`` is the user's
            source directory, to avoid mutating it and to keep parallel
            workers that share one directory from racing on the same file.
    """
    self.events = events
    self.symbol_list = symbol_list
    self.csv_dir = csv_dir
    self.columns = columns
    self.index_col = index_col
    self._persist_normalized = persist_normalized
    self.symbol_data: Dict[str, Union[pd.DataFrame, Generator]] = {}
    self.latest_symbol_data: Dict[str, List[Any]] = {}
    # Immutable, replayable per-symbol record lists of (timestamp, Series)
    # tuples, plus a shared integer cursor. This lets a single handler be
    # replayed (see reset()) for parameter sweeps or walk-forward folds.
    self._records: Dict[str, List[Tuple[Any, pd.Series]]] = {}
    self._cursor = 0
    self.continue_backtest = True
    self._index: Optional[Union[str, List[str]]] = None
    self._load_and_process_data()
symbols property
symbols: List[str]

The list of symbols this handler serves.

data property
data: Dict[str, DataFrame]

The loaded market data, keyed by symbol.

datadir property
datadir: str

The directory the CSV files are read from.

labels property
labels: List[str]

The OHLCV column labels exposed by the handler.

index property
index: Union[str, List[str]]

The name(s) of the datetime index column(s).

n_bars property
n_bars: int

Total number of bars available in the replayable record set.

reset
reset() -> None

Rewinds the handler to the start so the same data can be replayed.

Enables parameter sweeps, walk-forward folds, and Monte Carlo passes without reconstructing the handler.

Source code in src/bbstrader/btengine/data.py
def reset(self) -> None:
    """
    Rewinds the handler to the start so the same data can be replayed.

    Enables parameter sweeps, walk-forward folds, and Monte Carlo passes
    without reconstructing the handler.
    """
    self._cursor = 0
    self.continue_backtest = True
    for s in self.symbol_list:
        self.latest_symbol_data[s] = []
get_latest_bar
get_latest_bar(symbol: str) -> pd.Series

Returns the last bar from the latest_symbol list.

Source code in src/bbstrader/btengine/data.py
def get_latest_bar(self, symbol: str) -> pd.Series:
    """
    Returns the last bar from the latest_symbol list.
    """
    try:
        bars_list = self.latest_symbol_data[symbol]
    except KeyError:
        print(f"{symbol} not available in the historical data set.")
        raise
    else:
        return bars_list[-1]
get_latest_bars
get_latest_bars(symbol: str, N: int = 1, df: bool = True) -> Union[pd.DataFrame, List[pd.Series]]

Returns the last N bars from the latest_symbol list, or N-k if less available.

Source code in src/bbstrader/btengine/data.py
def get_latest_bars(
    self, symbol: str, N: int = 1, df: bool = True
) -> Union[pd.DataFrame, List[pd.Series]]:
    """
    Returns the last N bars from the latest_symbol list,
    or N-k if less available.
    """
    try:
        bars_list = self.latest_symbol_data[symbol]
    except KeyError:
        print(f"{symbol} not available in the historical data set.")
        raise
    else:
        if df:
            df_ = pd.DataFrame([bar[1] for bar in bars_list[-N:]])
            df_.index.name = self._index  # type: ignore
            return df_
        return bars_list[-N:]
get_latest_bar_datetime
get_latest_bar_datetime(symbol: str) -> Union[datetime, pd.Timestamp]

Returns a Python datetime object for the last bar.

Source code in src/bbstrader/btengine/data.py
def get_latest_bar_datetime(self, symbol: str) -> Union[datetime, pd.Timestamp]:
    """
    Returns a Python datetime object for the last bar.
    """
    try:
        bars_list = self.latest_symbol_data[symbol]
    except KeyError:
        print(f"{symbol} not available in the historical data set.")
        raise
    else:
        return bars_list[-1][0]
get_latest_bars_datetime
get_latest_bars_datetime(symbol: str, N: int = 1) -> List[Union[datetime, pd.Timestamp]]

Returns a list of Python datetime objects for the last N bars.

Source code in src/bbstrader/btengine/data.py
def get_latest_bars_datetime(
    self, symbol: str, N: int = 1
) -> List[Union[datetime, pd.Timestamp]]:
    """
    Returns a list of Python datetime objects for the last N bars.
    """
    try:
        bars_list = self.get_latest_bars(symbol, N)  # type: ignore
    except KeyError:
        print(f"{symbol} not available in the historical data set for .")
        raise
    else:
        return [b[0] for b in bars_list]  # type: ignore
get_latest_bar_value
get_latest_bar_value(symbol: str, val_type: str) -> float

Returns one of the Open, High, Low, Close, Volume or OI values from the pandas Bar series object.

Source code in src/bbstrader/btengine/data.py
def get_latest_bar_value(self, symbol: str, val_type: str) -> float:
    """
    Returns one of the Open, High, Low, Close, Volume or OI
    values from the pandas Bar series object.
    """
    try:
        bars_list = self.latest_symbol_data[symbol]
    except KeyError:
        print(f"{symbol} not available in the historical data set.")
        raise
    else:
        try:
            return getattr(bars_list[-1][1], val_type)
        except AttributeError:
            print(
                f"Value type {val_type} not available in the historical data set for {symbol}."
            )
            raise
get_latest_bars_values
get_latest_bars_values(symbol: str, val_type: str, N: int = 1) -> NDArray

Returns the last N bar values from the latest_symbol list, or N-k if less available.

Source code in src/bbstrader/btengine/data.py
def get_latest_bars_values(self, symbol: str, val_type: str, N: int = 1) -> NDArray:
    """
    Returns the last N bar values from the
    latest_symbol list, or N-k if less available.
    """
    try:
        bars_list = self.get_latest_bars(symbol, N, df=False)
    except KeyError:
        print(f"{symbol} not available in the historical data set.")
        raise
    else:
        try:
            return np.array([getattr(b[1], val_type) for b in bars_list])
        except AttributeError:
            print(
                f"Value type {val_type} not available in the historical data set."
            )
            raise
update_bars
update_bars() -> None

Pushes the latest bar to the latest_symbol_data structure for all symbols in the symbol list.

Source code in src/bbstrader/btengine/data.py
def update_bars(self) -> None:
    """
    Pushes the latest bar to the latest_symbol_data structure
    for all symbols in the symbol list.
    """
    # A MarketEvent is emitted on every call, including the exhausting one,
    # to preserve the original generator-based semantics exactly (the final
    # event fires with no new bar appended).
    if self._cursor >= self.n_bars:
        self.continue_backtest = False
    else:
        for s in self.symbol_list:
            self.latest_symbol_data[s].append(self._records[s][self._cursor])
        self._cursor += 1
    self.events.put(MarketEvent())

CSVDataHandler

CSVDataHandler(events: Queue[MarketEvent], symbol_list: List[str], **kwargs: Any)

Bases: BaseCSVDataHandler

CSVDataHandler is designed to read CSV files for each requested symbol from disk and provide an interface to obtain the "latest" bar in a manner identical to a live trading interface.

This class is useful when you have your own data or you want to cutomize specific data in some form based on your Strategy() .

Initialises the historic data handler by requesting the location of the CSV files and a list of symbols. It will be assumed that all files are of the form symbol.csv, where symbol is a string in the list.

Parameters:

Name Type Description Default
events Queue

The Event Queue.

required
symbol_list List[str]

A list of symbol strings.

required
csv_dir str

Absolute directory path to the CSV files.

required

NOTE: All csv fille can be stored in 'Home/.bbstrader/data/csv_data'

Source code in src/bbstrader/btengine/data.py
def __init__(
    self, events: "Queue[MarketEvent]", symbol_list: List[str], **kwargs: Any
) -> None:
    """
    Initialises the historic data handler by requesting
    the location of the CSV files and a list of symbols.
    It will be assumed that all files are of the form
    `symbol.csv`, where `symbol` is a string in the list.

    Args:
        events (Queue): The Event Queue.
        symbol_list (List[str]): A list of symbol strings.
        csv_dir (str): Absolute directory path to the CSV files.

    NOTE:
    All csv fille can be stored in 'Home/.bbstrader/data/csv_data'

    """
    csv_dir = kwargs.get("csv_dir")
    csv_dir = csv_dir or BBSTRADER_DIR / "data" / "csv_data"
    super().__init__(
        events,
        symbol_list,
        str(csv_dir),
        columns=kwargs.get("columns"),
        index_col=kwargs.get("index_col", 0),
        # csv_dir is the user's own data directory; never rewrite it.
        persist_normalized=False,
    )

MT5DataHandler

MT5DataHandler(events: Queue[MarketEvent], symbol_list: List[str], **kwargs: Any)

Bases: BaseCSVDataHandler

Downloads historical data from MetaTrader 5 (MT5) and provides an interface for accessing this data bar-by-bar, simulating a live market feed for backtesting.

Data is downloaded from MT5, saved as CSV files, and then loaded using the functionality inherited from BaseCSVDataHandler.

This class is useful when you need to get data from specific broker for different time frames.

Parameters:

Name Type Description Default
events Queue

The Event Queue for passing market events.

required
symbol_list List[str]

A list of symbol strings to download data for.

required
**kwargs Any

Keyword arguments for data retrieval: time_frame (str): MT5 time frame (e.g., 'D1' for daily). mt5_start (datetime): Start date for historical data. mt5_end (datetime): End date for historical data. data_dir (str): Directory for storing data .

{}
Note

Requires a working connection to an MT5 terminal. See bbstrader.metatrader.rates.Rates for other arguments. See bbstrader.btengine.data.BaseCSVDataHandler for other arguments.

Source code in src/bbstrader/btengine/data.py
def __init__(
    self, events: "Queue[MarketEvent]", symbol_list: List[str], **kwargs: Any
) -> None:
    """
    Args:
        events (Queue): The Event Queue for passing market events.
        symbol_list (List[str]): A list of symbol strings to download data for.
        **kwargs: Keyword arguments for data retrieval:
            time_frame (str): MT5 time frame (e.g., 'D1' for daily).
            mt5_start (datetime): Start date for historical data.
            mt5_end (datetime): End date for historical data.
            data_dir (str): Directory for storing data .

    Note:
        Requires a working connection to an MT5 terminal.
        See `bbstrader.metatrader.rates.Rates` for other arguments.
        See `bbstrader.btengine.data.BaseCSVDataHandler` for other arguments.
    """
    self.tf = kwargs.get("time_frame", "D1")
    self.start = kwargs.get("mt5_start", datetime(2000, 1, 1))
    self.end = kwargs.get("mt5_end", datetime.now())
    self.use_utc = kwargs.get("use_utc", False)
    self.filer = kwargs.get("filter", False)
    self.fill_na = kwargs.get("fill_na", False)
    self.lower_cols = kwargs.get("lower_cols", True)
    self.data_dir = kwargs.get("data_dir")
    self.use_cache = kwargs.get("use_cache", False)
    self.cache_max_age_days = kwargs.get("cache_max_age_days")
    self.symbol_list = symbol_list
    self.kwargs = kwargs
    self.kwargs["backtest"] = (
        True  # Ensure backtest mode is set to avoid InvalidBroker errors
    )

    csv_dir = self._download_and_cache_data(self.data_dir)
    super().__init__(
        events,
        symbol_list,
        str(csv_dir),
        columns=kwargs.get("columns"),
        index_col=kwargs.get("index_col", 0),
    )

YFDataHandler

YFDataHandler(events: Queue[MarketEvent], symbol_list: List[str], **kwargs: Any)

Bases: BaseCSVDataHandler

Downloads historical data from Yahoo Finance and provides an interface for accessing this data bar-by-bar, simulating a live market feed for backtesting.

Data is fetched using the yfinance library and optionally cached to disk to speed up subsequent runs.

This class is useful when working with historical daily prices.

Parameters:

Name Type Description Default
events Queue

The Event Queue for passing market events.

required
symbol_list list[str]

List of symbols to download data for.

required
yf_start str

Start date for historical data (YYYY-MM-DD).

required
yf_end str

End date for historical data (YYYY-MM-DD).

required
data_dir str

Directory for caching data .

required
Note

See bbstrader.btengine.data.BaseCSVDataHandler for other arguments.

Source code in src/bbstrader/btengine/data.py
def __init__(
    self, events: "Queue[MarketEvent]", symbol_list: List[str], **kwargs: Any
) -> None:
    """
    Args:
        events (Queue): The Event Queue for passing market events.
        symbol_list (list[str]): List of symbols to download data for.
        yf_start (str): Start date for historical data (YYYY-MM-DD).
        yf_end (str): End date for historical data (YYYY-MM-DD).
        data_dir (str, optional): Directory for caching data .

    Note:
        See `bbstrader.btengine.data.BaseCSVDataHandler` for other arguments.
    """
    self.symbol_list = symbol_list
    self.start_date = kwargs.get("yf_start")
    self.end_date = kwargs.get("yf_end", datetime.now())
    self.cache_dir = kwargs.get("data_dir")
    self.use_cache = kwargs.get("use_cache", False)
    self.cache_max_age_days = kwargs.get("cache_max_age_days")

    csv_dir = self._download_and_cache_data(self.cache_dir)

    super().__init__(
        events,
        symbol_list,
        str(csv_dir),
        columns=kwargs.get("columns"),
        index_col=kwargs.get("index_col", 0),
    )

EODHDataHandler

EODHDataHandler(events: Queue[MarketEvent], symbol_list: List[str], **kwargs: Any)

Bases: BaseCSVDataHandler

Downloads historical data from EOD Historical Data. Data is fetched using the eodhd library.

To use this class, you need to sign up for an API key at https://eodhistoricaldata.com/ and provide the key as an argument.

Parameters:

Name Type Description Default
events Queue

The Event Queue for passing market events.

required
symbol_list list[str]

List of symbols to download data for.

required
eodhd_start str

Start date for historical data (YYYY-MM-DD).

required
eodhd_end str

End date for historical data (YYYY-MM-DD).

required
data_dir str

Directory for caching data .

required
eodhd_period str

Time period for historical data (e.g., 'd', 'w', 'm', '1m', '5m', '1h').

required
eodhd_api_key str

API key for EOD Historical Data.

required
Note

See bbstrader.btengine.data.BaseCSVDataHandler for other arguments.

Source code in src/bbstrader/btengine/data.py
def __init__(
    self, events: "Queue[MarketEvent]", symbol_list: List[str], **kwargs: Any
) -> None:
    """
    Args:
        events (Queue): The Event Queue for passing market events.
        symbol_list (list[str]): List of symbols to download data for.
        eodhd_start (str): Start date for historical data (YYYY-MM-DD).
        eodhd_end (str): End date for historical data (YYYY-MM-DD).
        data_dir (str, optional): Directory for caching data .
        eodhd_period (str, optional): Time period for historical data (e.g., 'd', 'w', 'm', '1m', '5m', '1h').
        eodhd_api_key (str, optional): API key for EOD Historical Data.

    Note:
        See `bbstrader.btengine.data.BaseCSVDataHandler` for other arguments.
    """
    self.symbol_list = symbol_list
    self.start_date = kwargs.get("eodhd_start")
    self.end_date = kwargs.get("eodhd_end", datetime.now().strftime("%Y-%m-%d"))
    self.period = kwargs.get("eodhd_period", "d")
    self.cache_dir = kwargs.get("data_dir")
    self.use_cache = kwargs.get("use_cache", False)
    self.cache_max_age_days = kwargs.get("cache_max_age_days")
    self.__api_key = kwargs.get("eodhd_api_key", "demo")

    csv_dir = self._download_and_cache_data(self.cache_dir)

    super().__init__(
        events,
        symbol_list,
        str(csv_dir),
        columns=kwargs.get("columns"),
        index_col=kwargs.get("index_col", 0),
    )

FMPDataHandler

FMPDataHandler(events: Queue[MarketEvent], symbol_list: List[str], **kwargs: Any)

Bases: BaseCSVDataHandler

Downloads historical data from Financial Modeling Prep (FMP). Data is fetched using the financetoolkit library.

To use this class, you need to sign up for an API key at https://financialmodelingprep.com/developer/docs/pricing and provide the key as an argument.

Parameters:

Name Type Description Default
events Queue

The Event Queue for passing market events.

required
symbol_list list[str]

List of symbols to download data for.

required
fmp_start str

Start date for historical data (YYYY-MM-DD).

required
fmp_end str

End date for historical data (YYYY-MM-DD).

required
data_dir str

Directory for caching data .

required
fmp_period str

Time period for historical data (e.g. daily, weekly, monthly, quarterly, yearly, "1min", "5min", "15min", "30min", "1hour").

required
fmp_api_key str

API key for Financial Modeling Prep.

required
Note

See bbstrader.btengine.data.BaseCSVDataHandler for other arguments.

Source code in src/bbstrader/btengine/data.py
def __init__(
    self, events: "Queue[MarketEvent]", symbol_list: List[str], **kwargs: Any
) -> None:
    """
    Args:
        events (Queue): The Event Queue for passing market events.
        symbol_list (list[str]): List of symbols to download data for.
        fmp_start (str): Start date for historical data (YYYY-MM-DD).
        fmp_end (str): End date for historical data (YYYY-MM-DD).
        data_dir (str, optional): Directory for caching data .
        fmp_period (str, optional): Time period for historical data
            (e.g. daily, weekly, monthly, quarterly, yearly, "1min", "5min", "15min", "30min", "1hour").
        fmp_api_key (str): API key for Financial Modeling Prep.

    Note:
        See `bbstrader.btengine.data.BaseCSVDataHandler` for other arguments.
    """
    self.symbol_list = symbol_list
    self.start_date = kwargs.get("fmp_start")
    self.end_date = kwargs.get("fmp_end", datetime.now().strftime("%Y-%m-%d"))
    self.period = kwargs.get("fmp_period", "daily")
    self.cache_dir = kwargs.get("data_dir")
    self.use_cache = kwargs.get("use_cache", False)
    self.cache_max_age_days = kwargs.get("cache_max_age_days")
    self.__api_key = kwargs.get("fmp_api_key")

    csv_dir = self._download_and_cache_data(self.cache_dir)

    super().__init__(
        events,
        symbol_list,
        str(csv_dir),
        columns=kwargs.get("columns"),
        index_col=kwargs.get("index_col", 0),
    )

event

Event

Event is base class providing an interface for all subsequent (inherited) events, that will trigger further events in the trading infrastructure. Since in many implementations the Event objects will likely develop greater complexity, it is thus being "future-proofed" by creating a class hierarchy. The Event class is simply a way to ensure that all events have a common interface and can be handled in a consistent manner.

MarketEvent

MarketEvent()

Bases: Event

Market Events are triggered when the outer while loop of the backtesting system begins a new "heartbeat". It occurs when the DataHandler object receives a new update of market data for any symbols which are currently being tracked. It is used to trigger the Strategy object generating new trading signals. The event object simply contains an identification that it is a market event, with no other structure.

Initialises the MarketEvent.

Source code in src/bbstrader/btengine/event.py
def __init__(self) -> None:
    """
    Initialises the MarketEvent.
    """
    self.type = Events.MARKET

SignalEvent

SignalEvent(strategy_id: int, symbol: str, datetime: datetime, signal_type: Literal['LONG', 'SHORT', 'EXIT'], quantity: Union[int, float] = 100, strength: Union[int, float] = 1.0, price: Optional[Union[int, float]] = None, stoplimit: Optional[Union[int, float]] = None)

Bases: Event

The Strategy object utilises market data to create new SignalEvents. The SignalEvent contains a strategy ID, a ticker symbol, a timestamp for when it was generated, a direction (long or short) and a "strength" indicator (this is useful for mean reversion strategies) and the quantiy to buy or sell. The SignalEvents are utilised by the Portfolio object as advice for how to trade.

Initialises the SignalEvent.

Parameters:

Name Type Description Default
strategy_id int

The unique identifier for the strategy that generated the signal.

required
symbol str

The ticker symbol, e.g. 'GOOG'.

required
datetime datetime

The timestamp at which the signal was generated.

required
signal_type str

'LONG' or 'SHORT' or 'EXIT'.

required
quantity int | float

An optional integer (or float) representing the order size.

100
strength int | float

An adjustment factor "suggestion" used to scale quantity at the portfolio level. Useful for pairs strategies.

1.0
price int | float

An optional price to be used when the signal is generated.

None
stoplimit int | float

An optional stop-limit price for the signal

None
Source code in src/bbstrader/btengine/event.py
def __init__(
    self,
    strategy_id: int,
    symbol: str,
    datetime: datetime,
    signal_type: Literal["LONG", "SHORT", "EXIT"],
    quantity: Union[int, float] = 100,
    strength: Union[int, float] = 1.0,
    price: Optional[Union[int, float]] = None,
    stoplimit: Optional[Union[int, float]] = None,
) -> None:
    """
    Initialises the SignalEvent.

    Args:
        strategy_id (int): The unique identifier for the strategy that
            generated the signal.

        symbol (str): The ticker symbol, e.g. 'GOOG'.
        datetime (datetime): The timestamp at which the signal was generated.
        signal_type (str): 'LONG' or 'SHORT' or 'EXIT'.
        quantity (int | float): An optional integer (or float) representing the order size.
        strength (int | float): An adjustment factor "suggestion" used to scale
            quantity at the portfolio level. Useful for pairs strategies.
        price (int | float): An optional price to be used when the signal is generated.
        stoplimit (int | float): An optional stop-limit price for the signal
    """
    self.type = Events.SIGNAL
    self.strategy_id = strategy_id
    self.symbol = symbol
    self.datetime = datetime
    self.signal_type = signal_type
    self.quantity = quantity
    self.strength = strength
    self.price = price
    self.stoplimit = stoplimit

OrderEvent

OrderEvent(symbol: str, order_type: Literal['MKT', 'LMT', 'STP', 'STPLMT'], quantity: Union[int, float], direction: Literal['BUY', 'SELL'], price: Optional[Union[int, float]] = None, signal: Optional[str] = None)

Bases: Event

When a Portfolio object receives SignalEvents it assesses them in the wider context of the portfolio, in terms of risk and position sizing. This ultimately leads to OrderEvents that will be sent to an ExecutionHandler.

The OrderEvents is slightly more complex than a SignalEvents since it contains a quantity field in addition to the aforementioned properties of SignalEvent. The quantity is determined by the Portfolio constraints. In addition the OrderEvent has a print_order() method, used to output the information to the console if necessary.

Initialises the order type, setting whether it is a Market order ('MKT') or Limit order ('LMT'), or Stop order ('STP'). a quantity (integral or float) and its direction ('BUY' or 'SELL').

Parameters:

Name Type Description Default
symbol str

The instrument to trade.

required
order_type str

'MKT' or 'LMT' for Market or Limit.

required
quantity int | float

Non-negative number for quantity.

required
direction str

'BUY' or 'SELL' for long or short.

required
price int | float

The price at which to order.

None
signal str

The signal that generated the order.

None
Source code in src/bbstrader/btengine/event.py
def __init__(
    self,
    symbol: str,
    order_type: Literal["MKT", "LMT", "STP", "STPLMT"],
    quantity: Union[int, float],
    direction: Literal["BUY", "SELL"],
    price: Optional[Union[int, float]] = None,
    signal: Optional[str] = None,
) -> None:
    """
    Initialises the order type, setting whether it is
    a Market order ('MKT') or Limit order ('LMT'), or Stop order ('STP').
    a quantity (integral or float) and its direction ('BUY' or 'SELL').

    Args:
        symbol (str): The instrument to trade.
        order_type (str): 'MKT' or 'LMT' for Market or Limit.
        quantity (int | float): Non-negative number for quantity.
        direction (str): 'BUY' or 'SELL' for long or short.
        price (int | float): The price at which to order.
        signal (str): The signal that generated the order.
    """
    self.type = Events.ORDER
    self.symbol = symbol
    self.order_type = order_type
    self.quantity = quantity
    self.direction = direction
    self.price = price
    self.signal = signal
print_order
print_order() -> None

Outputs the values within the Order.

Source code in src/bbstrader/btengine/event.py
def print_order(self) -> None:
    """
    Outputs the values within the Order.
    """
    print(
        "Order: Symbol=%s, Type=%s, Quantity=%s, Direction=%s, Price=%s"
        % (
            self.symbol,
            self.order_type,
            self.quantity,
            self.direction,
            self.price,
        )
    )

FillEvent

FillEvent(timeindex: datetime, symbol: str, exchange: str, quantity: Union[int, float], direction: Literal['BUY', 'SELL'], fill_cost: Optional[Union[int, float]], commission: Optional[float] = None, order: Optional[str] = None)

Bases: Event

When an ExecutionHandler receives an OrderEvent it must transact the order. Once an order has been transacted it generates a FillEvent, which describes the cost of purchase or sale as well as the transaction costs, such as fees or slippage.

The FillEvent is the Event with the greatest complexity. It contains a timestamp for when an order was filled, the symbol of the order and the exchange it was executed on, the quantity of shares transacted, the actual price of the purchase and the commission incurred.

The commission is calculated using the Interactive Brokers commissions. For US API orders this commission is 1.30 USD minimum per order, with a flat rate of either 0.013 USD or 0.08 USD per share depending upon whether the trade size is below or above 500 units of stock.

Initialises the FillEvent object. Sets the symbol, exchange, quantity, direction, cost of fill and an optional commission.

If commission is not provided, the Fill object will calculate it based on the trade size and Interactive Brokers fees.

Parameters:

Name Type Description Default
timeindex datetime

The bar-resolution when the order was filled.

required
symbol str

The instrument which was filled.

required
exchange str

The exchange where the order was filled.

required
quantity int | float

The filled quantity.

required
direction str

The direction of fill ('LONG', 'SHORT', 'EXIT')

required
fill_cost int | float

Price of the shares when filled.

required
commission float | None

An optional commission sent from IB.

None
order str

The order that this fill is related

None
Source code in src/bbstrader/btengine/event.py
def __init__(
    self,
    timeindex: datetime,
    symbol: str,
    exchange: str,
    quantity: Union[int, float],
    direction: Literal["BUY", "SELL"],
    fill_cost: Optional[Union[int, float]],
    commission: Optional[float] = None,
    order: Optional[str] = None,
) -> None:
    """
    Initialises the FillEvent object. Sets the symbol, exchange,
    quantity, direction, cost of fill and an optional
    commission.

    If commission is not provided, the Fill object will
    calculate it based on the trade size and Interactive
    Brokers fees.

    Args:
        timeindex (datetime): The bar-resolution when the order was filled.
        symbol (str): The instrument which was filled.
        exchange (str): The exchange where the order was filled.
        quantity (int | float): The filled quantity.
        direction (str): The direction of fill `('LONG', 'SHORT', 'EXIT')`
        fill_cost (int | float): Price of the shares when filled.
        commission (float | None): An optional commission sent from IB.
        order (str): The order that this fill is related
    """
    self.type = Events.FILL
    self.timeindex = timeindex
    self.symbol = symbol
    self.exchange = exchange
    self.quantity = quantity
    self.direction = direction
    self.fill_cost = fill_cost
    # Calculate commission
    if commission is None:
        self.commission: float = self.calculate_ib_commission()
    else:
        self.commission = commission
    self.order = order
calculate_ib_commission
calculate_ib_commission() -> float

Calculates the fees of trading based on an Interactive Brokers fee structure for API, in USD. This does not include exchange or ECN fees. Based on "US API Directed Orders": https://www.interactivebrokers.com/en/index.php?f=commission&p=stocks2

Source code in src/bbstrader/btengine/event.py
def calculate_ib_commission(self) -> float:
    """
    Calculates the fees of trading based on an Interactive
    Brokers fee structure for API, in USD.
    This does not include exchange or ECN fees.
    Based on "US API Directed Orders":
    https://www.interactivebrokers.com/en/index.php?f=commission&p=stocks2
    """
    full_cost = 1.3
    if self.quantity <= 500:
        full_cost = max(1.3, 0.013 * self.quantity)
    else:
        full_cost = max(1.3, 0.008 * self.quantity)
    return full_cost

execution

ExecutionHandler

The ExecutionHandler abstract class handles the interaction between a set of order objects generated by a Portfolio and the ultimate set of Fill objects that actually occur in the market.

The handlers can be used to subclass simulated brokerages or live brokerages, with identical interfaces. This allows strategies to be backtested in a very similar manner to the live trading engine.

The ExecutionHandler described here is exceedingly simple, since it fills all orders at the current market price. This is highly unrealistic, for other markets thant CFDs but serves as a good baseline for improvement.

execute_order abstractmethod
execute_order(event: OrderEvent) -> None

Takes an Order event and executes it, producing a Fill event that gets placed onto the Events queue.

Parameters:

Name Type Description Default
event OrderEvent

Contains an Event object with order information.

required
Source code in src/bbstrader/btengine/execution.py
@abstractmethod
def execute_order(self, event: OrderEvent) -> None:
    """
    Takes an Order event and executes it, producing
    a Fill event that gets placed onto the Events queue.

    Args:
        event (OrderEvent): Contains an Event object with order information.
    """
    raise NotImplementedError("Should implement execute_order()")

SimExecutionHandler

SimExecutionHandler(events: Queue[Union[FillEvent, OrderEvent]], data: DataHandler, **kwargs: Any)

Bases: ExecutionHandler

The simulated execution handler simply converts all order objects into their equivalent fill objects automatically without latency, slippage or fill-ratio issues.

This allows a straightforward "first go" test of any strategy, before implementation with a more sophisticated execution handler.

Initialises the handler, setting the event queues up internally.

Parameters:

Name Type Description Default
events Queue

The Queue of Event objects.

required
Source code in src/bbstrader/btengine/execution.py
def __init__(
    self,
    events: "Queue[Union[FillEvent, OrderEvent]]",
    data: DataHandler,
    **kwargs: Any,
) -> None:
    """
    Initialises the handler, setting the event queues
    up internally.

    Args:
        events (Queue): The Queue of Event objects.
    """
    self.events = events
    self.bardata = data
    self.logger = kwargs.get("logger") or logger
    self.commissions = kwargs.get("commission")
    self.exchange = kwargs.get("exchange", "ARCA")

    self.slippage_model = kwargs.get("slippage_model")
    self.impact_model = kwargs.get("impact_model")
    self.commission_model = kwargs.get("commission_model")
    fill_ratio = kwargs.get("fill_ratio", 1.0)
    if not 0.0 < fill_ratio <= 1.0:
        raise ValueError("fill_ratio must be in the interval (0, 1].")
    self.fill_ratio = float(fill_ratio)

    self.time_frontier = bool(kwargs.get("time_frontier", False))
    self.latency = int(kwargs.get("latency", 0))
    if self.latency < 0:
        raise ValueError("latency must be a non-negative number of bars.")
    self.fill_on = kwargs.get("fill_on", "open")
    # Each pending item is [order, bars_remaining].
    self._pending: list[list] = []
process_pending
process_pending() -> None

Fill orders held under time-frontier mode at the current (next) bar.

Called by the engine once per bar after new data arrives. Orders placed on the previous bar fill here at this bar's fill_on price.

Source code in src/bbstrader/btengine/execution.py
def process_pending(self) -> None:
    """Fill orders held under time-frontier mode at the current (next) bar.

    Called by the engine once per bar after new data arrives. Orders placed
    on the previous bar fill here at this bar's ``fill_on`` price.
    """
    if not self._pending:
        return
    still_pending: list[list] = []
    ready: list[OrderEvent] = []
    for item in self._pending:
        item[1] -= 1
        if item[1] <= 0:
            ready.append(item[0])
        else:
            still_pending.append(item)
    self._pending = still_pending
    for event in ready:
        try:
            base_price = self.bardata.get_latest_bar_value(
                event.symbol, self.fill_on
            )
        except (AttributeError, KeyError, ValueError):
            base_price = self.bardata.get_latest_bar_value(event.symbol, "close")
        self._emit_fill(event, float(base_price))
execute_order
execute_order(event: OrderEvent) -> None

Converts Order objects into Fill objects, optionally applying the configured slippage, market-impact, commission, partial-fill and time-frontier (next-bar) models.

Parameters:

Name Type Description Default
event OrderEvent

Contains an Event object with order information.

required
Source code in src/bbstrader/btengine/execution.py
def execute_order(self, event: OrderEvent) -> None:
    """
    Converts Order objects into Fill objects, optionally applying the
    configured slippage, market-impact, commission, partial-fill and
    time-frontier (next-bar) models.

    Args:
        event (OrderEvent): Contains an Event object with order information.
    """
    if event.type != Events.ORDER:
        return
    delay = self._fill_delay()
    if delay >= 1:
        # Defer the fill by `delay` bars; process_pending() fills it.
        self._pending.append([event, delay])
        self.logger.info(
            f"{event.direction} ORDER QUEUED (delay={delay} bars): "
            f"SYMBOL={event.symbol}, QUANTITY={event.quantity}",
            custom_time=self.bardata.get_latest_bar_datetime(event.symbol),
        )
        return
    base_price = event.price if self._has_friction() else None
    self._emit_fill(event, base_price)

MT5ExecutionHandler

MT5ExecutionHandler(events: Queue[Union[FillEvent, OrderEvent]], data: DataHandler, **kwargs: Any)

Bases: ExecutionHandler

The main role of MT5ExecutionHandler class is to estimate the execution fees for different asset classes on the MT5 terminal.

Generally we have four types of fees when we execute trades using the MT5 terminal (commissions, swap, spread and other fees). But most of these fees depend on the specifications of each instrument and the duration of the transaction for the swap for example.

Calculating the exact fees for each instrument would be a bit complex because our Backtest engine and the Portfolio class do not take into account the duration of each trade to apply the appropriate rate for the swap for example. So we have to use only the model of calculating the commissions for each asset class and each instrument.

The second thing that must be taken into account on MT5 is the type of account offered by the broker. Brokers have different account categories each with its specifications for each asset class and each instrument. Again considering all these conditions would make our class very complex. So we took the Raw Spread account fee calculation model from Just Market for indicies, forex, commodities and crypto. We used the Admiral Market account fee calculation model from Trade.MT5 account type for stocks and ETFs.

NOTE

This class only works with bbstrader.metatrader.data.MT5DataHandler class.

Initialises the handler, setting the event queues up internally.

Parameters:

Name Type Description Default
events Queue

The Queue of Event objects.

required
Source code in src/bbstrader/btengine/execution.py
def __init__(
    self,
    events: "Queue[Union[FillEvent, OrderEvent]]",
    data: DataHandler,
    **kwargs: Any,
) -> None:
    """
    Initialises the handler, setting the event queues up internally.

    Args:
        events (Queue): The Queue of Event objects.
    """
    self.events = events
    self.bardata = data
    self.logger = kwargs.get("logger") or logger
    self.commissions = kwargs.get("commission")
    self.exchange = kwargs.get("exchange", "MT5")
    self.__account = Account(**kwargs)
execute_order
execute_order(event: OrderEvent) -> None

Executes an Order event by converting it into a Fill event.

Parameters:

Name Type Description Default
event OrderEvent

Contains an Event object with order information.

required
Source code in src/bbstrader/btengine/execution.py
def execute_order(self, event: OrderEvent) -> None:
    """
    Executes an Order event by converting it into a Fill event.

    Args:
        event (OrderEvent): Contains an Event object with order information.
    """
    if event.type == Events.ORDER:
        symbol = event.symbol
        direction = event.direction
        quantity = event.quantity
        price = event.price
        if price is None:
            price = self.bardata.get_latest_bar_value(symbol, "close")
        lot = self._calculate_lot(symbol, quantity, price)
        fees = self._estimate_total_fees(symbol, lot, quantity, price)
        dtime = self.bardata.get_latest_bar_datetime(symbol)
        commission = self.commissions or fees
        fill_event = FillEvent(
            timeindex=dtime,  # type: ignore
            symbol=symbol,
            exchange=self.exchange,
            quantity=quantity,
            direction=direction,
            fill_cost=None,
            commission=commission,
            order=event.signal,
        )
        self.events.put(fill_event)
        log_price = event.price or 0.0
        self.logger.info(
            f"{direction} ORDER FILLED: SYMBOL={symbol}, QUANTITY={quantity}, "
            f"PRICE @{round(log_price, 5)} EXCHANGE={fill_event.exchange}",
            custom_time=fill_event.timeindex,
        )

experiment

A lightweight experiment/results store for reproducible research.

Persists each backtest/optimization run its parameters, metrics, equity curve and environment to disk so runs can be reloaded, compared leaderboard-style, and reproduced later. Metadata is JSON; the equity curve is CSV. Defaults to ~/.bbstrader/experiments but any root works.

ExperimentRecord dataclass

ExperimentRecord(id: str, name: str, params: Dict[str, Any], metrics: Dict[str, Any], created_at: str, environment: Dict[str, str] = dict())

A persisted record of one backtest/optimization run.

Attributes:

Name Type Description
id str

The unique run identifier.

name str

The human-readable run name.

params Dict[str, Any]

The parameters the run was executed with.

metrics Dict[str, Any]

The metrics produced by the run.

created_at str

The ISO-8601 UTC creation timestamp.

environment Dict[str, str]

The Python/platform environment captured at save time.

to_dict
to_dict() -> Dict[str, Any]

Return the record as a plain dict suitable for JSON serialization.

Returns:

Type Description
Dict[str, Any]

Dict[str, Any]: All fields of the record.

Source code in src/bbstrader/btengine/experiment.py
def to_dict(self) -> Dict[str, Any]:
    """Return the record as a plain dict suitable for JSON serialization.

    Returns:
        Dict[str, Any]: All fields of the record.
    """
    return asdict(self)

ExperimentStore

ExperimentStore(root: Optional[Union[str, Path]] = None)

Save, load, list and compare persisted experiment runs.

Initialise the store rooted at root and ensure it exists.

Parameters:

Name Type Description Default
root Optional[Union[str, Path]]

Directory to store runs in. Defaults to ~/.bbstrader/experiments.

None
Source code in src/bbstrader/btengine/experiment.py
def __init__(self, root: Optional[Union[str, Path]] = None) -> None:
    """Initialise the store rooted at ``root`` and ensure it exists.

    Args:
        root (Optional[Union[str, Path]]): Directory to store runs in.
            Defaults to ``~/.bbstrader/experiments``.
    """
    self.root = Path(root) if root else BBSTRADER_DIR / "experiments"
    self.root.mkdir(parents=True, exist_ok=True)
save
save(name: str, params: Dict[str, Any], metrics: Dict[str, Any], equity_curve: Optional[DataFrame] = None, run_id: Optional[str] = None, created_at: Optional[str] = None) -> str

Persist a run and return its id.

run_id/created_at may be supplied for deterministic, idempotent writes (e.g. in tests); otherwise a uuid and the current UTC time are used.

Source code in src/bbstrader/btengine/experiment.py
def save(
    self,
    name: str,
    params: Dict[str, Any],
    metrics: Dict[str, Any],
    equity_curve: Optional[pd.DataFrame] = None,
    run_id: Optional[str] = None,
    created_at: Optional[str] = None,
) -> str:
    """Persist a run and return its id.

    ``run_id``/``created_at`` may be supplied for deterministic, idempotent
    writes (e.g. in tests); otherwise a uuid and the current UTC time are
    used.
    """
    run_id = run_id or f"{name}-{uuid.uuid4().hex[:8]}"
    created_at = created_at or datetime.now(timezone.utc).isoformat()
    record = ExperimentRecord(
        id=run_id,
        name=name,
        params=params,
        metrics=metrics,
        created_at=created_at,
        environment=_environment(),
    )
    run_dir = self._run_dir(run_id)
    run_dir.mkdir(parents=True, exist_ok=True)
    (run_dir / "meta.json").write_text(
        json.dumps(record.to_dict(), indent=2, default=str)
    )
    if equity_curve is not None:
        equity_curve.to_csv(run_dir / "equity.csv")
    return run_id
load
load(run_id: str) -> ExperimentRecord

Load a previously saved run's metadata record.

Parameters:

Name Type Description Default
run_id str

The run identifier returned by :meth:save.

required

Returns:

Name Type Description
ExperimentRecord ExperimentRecord

The reconstructed record.

Raises:

Type Description
FileNotFoundError

If no run with run_id exists.

Source code in src/bbstrader/btengine/experiment.py
def load(self, run_id: str) -> ExperimentRecord:
    """Load a previously saved run's metadata record.

    Args:
        run_id (str): The run identifier returned by :meth:`save`.

    Returns:
        ExperimentRecord: The reconstructed record.

    Raises:
        FileNotFoundError: If no run with ``run_id`` exists.
    """
    meta = self._run_dir(run_id) / "meta.json"
    if not meta.exists():
        raise FileNotFoundError(f"No experiment with id {run_id!r}.")
    data = json.loads(meta.read_text())
    return ExperimentRecord(**data)
load_equity
load_equity(run_id: str) -> Optional[pd.DataFrame]

Load a run's persisted equity curve, if one was saved.

Parameters:

Name Type Description Default
run_id str

The run identifier.

required

Returns:

Type Description
Optional[DataFrame]

Optional[pd.DataFrame]: The equity curve, or None when absent.

Source code in src/bbstrader/btengine/experiment.py
def load_equity(self, run_id: str) -> Optional[pd.DataFrame]:
    """Load a run's persisted equity curve, if one was saved.

    Args:
        run_id (str): The run identifier.

    Returns:
        Optional[pd.DataFrame]: The equity curve, or None when absent.
    """
    path = self._run_dir(run_id) / "equity.csv"
    if not path.exists():
        return None
    return pd.read_csv(path, index_col=0)
list
list() -> List[ExperimentRecord]

List all saved runs, oldest first.

Returns:

Type Description
List[ExperimentRecord]

List[ExperimentRecord]: Records sorted by creation time.

Source code in src/bbstrader/btengine/experiment.py
def list(self) -> List[ExperimentRecord]:
    """List all saved runs, oldest first.

    Returns:
        List[ExperimentRecord]: Records sorted by creation time.
    """
    records = []
    for meta in self.root.glob("*/meta.json"):
        records.append(ExperimentRecord(**json.loads(meta.read_text())))
    return sorted(records, key=lambda r: r.created_at)
compare
compare(metric: Optional[str] = None, ascending: bool = False) -> pd.DataFrame

Return a leaderboard DataFrame of all runs' metrics.

Sorted by metric (descending by default) when provided.

Source code in src/bbstrader/btengine/experiment.py
def compare(
    self, metric: Optional[str] = None, ascending: bool = False
) -> pd.DataFrame:
    """Return a leaderboard DataFrame of all runs' metrics.

    Sorted by ``metric`` (descending by default) when provided.
    """
    rows = []
    for rec in self.list():
        row = {"id": rec.id, "name": rec.name, "created_at": rec.created_at}
        row.update(rec.metrics)
        rows.append(row)
    df = pd.DataFrame(rows)
    if metric and metric in df.columns:
        df = df.sort_values(metric, ascending=ascending).reset_index(drop=True)
    return df
delete
delete(run_id: str) -> None

Delete a saved run and all of its files.

A no-op when the run does not exist.

Parameters:

Name Type Description Default
run_id str

The run identifier to delete.

required
Source code in src/bbstrader/btengine/experiment.py
def delete(self, run_id: str) -> None:
    """Delete a saved run and all of its files.

    A no-op when the run does not exist.

    Args:
        run_id (str): The run identifier to delete.
    """
    run_dir = self._run_dir(run_id)
    if run_dir.exists():
        for child in run_dir.iterdir():
            child.unlink()
        run_dir.rmdir()

friction

Pluggable execution-friction models for the simulated backtester.

The default SimExecutionHandler fills instantly at the bar price with no trading costs, which optimistically biases results. These models add realistic friction slippage, market impact, commission, and partial fills so a backtest survives the jump to live trading. They are all opt-in: a handler constructed without friction models behaves exactly as before.

All models are plain, deterministic functions of the order and recent bar data, so backtests stay reproducible.

SlippageModel

Bases: ABC

Adjusts the execution price to account for adverse price movement.

adjusted_price abstractmethod
adjusted_price(base_price: float, direction: str, quantity: float, symbol: str, bardata: DataHandler) -> float

Return the slippage-adjusted execution price.

Source code in src/bbstrader/btengine/friction.py
@abstractmethod
def adjusted_price(
    self,
    base_price: float,
    direction: str,
    quantity: float,
    symbol: str,
    bardata: DataHandler,
) -> float:
    """Return the slippage-adjusted execution price."""

NoSlippage

Bases: SlippageModel

Fills at the unadjusted base price.

adjusted_price
adjusted_price(base_price, direction, quantity, symbol, bardata) -> float

Return base_price unchanged (see :meth:SlippageModel.adjusted_price).

Source code in src/bbstrader/btengine/friction.py
def adjusted_price(self, base_price, direction, quantity, symbol, bardata) -> float:
    """Return ``base_price`` unchanged (see :meth:`SlippageModel.adjusted_price`)."""
    return base_price

FixedSpreadSlippage

FixedSpreadSlippage(spread: float)

Bases: SlippageModel

Charges half of a fixed spread (in price units) on each fill.

Initialise the model with a fixed spread.

Parameters:

Name Type Description Default
spread float

The full bid-ask spread in price units; half is charged on each fill. Must be non-negative.

required

Raises:

Type Description
ValueError

If spread is negative.

Source code in src/bbstrader/btengine/friction.py
def __init__(self, spread: float) -> None:
    """Initialise the model with a fixed spread.

    Args:
        spread (float): The full bid-ask spread in price units; half is
            charged on each fill. Must be non-negative.

    Raises:
        ValueError: If ``spread`` is negative.
    """
    if spread < 0:
        raise ValueError("spread must be non-negative.")
    self.spread = float(spread)
adjusted_price
adjusted_price(base_price, direction, quantity, symbol, bardata) -> float

Return base_price shifted adversely by half the fixed spread.

Source code in src/bbstrader/btengine/friction.py
def adjusted_price(self, base_price, direction, quantity, symbol, bardata) -> float:
    """Return ``base_price`` shifted adversely by half the fixed spread."""
    return base_price + _direction_sign(direction) * self.spread / 2.0

PercentSlippage

PercentSlippage(pct: float)

Bases: SlippageModel

Applies a fixed percentage slippage to the base price.

Initialise the model with a fractional slippage.

Parameters:

Name Type Description Default
pct float

The slippage as a fraction of price (for example 0.001 for 10 bps). Must be non-negative.

required

Raises:

Type Description
ValueError

If pct is negative.

Source code in src/bbstrader/btengine/friction.py
def __init__(self, pct: float) -> None:
    """Initialise the model with a fractional slippage.

    Args:
        pct (float): The slippage as a fraction of price (for example
            ``0.001`` for 10 bps). Must be non-negative.

    Raises:
        ValueError: If ``pct`` is negative.
    """
    if pct < 0:
        raise ValueError("pct must be non-negative.")
    self.pct = float(pct)
adjusted_price
adjusted_price(base_price, direction, quantity, symbol, bardata) -> float

Return base_price moved adversely by the configured percentage.

Source code in src/bbstrader/btengine/friction.py
def adjusted_price(self, base_price, direction, quantity, symbol, bardata) -> float:
    """Return ``base_price`` moved adversely by the configured percentage."""
    return base_price * (1.0 + _direction_sign(direction) * self.pct)

VolatilitySlippage

VolatilitySlippage(coef: float = 1.0, window: int = 20)

Bases: SlippageModel

Slippage scaled by recent return volatility.

slippage = coef * sigma * base_price where sigma is the rolling standard deviation of returns over window bars.

Initialise the model with a volatility coefficient and window.

Parameters:

Name Type Description Default
coef float

Multiplier applied to the rolling return standard deviation to size the slippage.

1.0
window int

Number of recent bars used to estimate volatility.

20
Source code in src/bbstrader/btengine/friction.py
def __init__(self, coef: float = 1.0, window: int = 20) -> None:
    """Initialise the model with a volatility coefficient and window.

    Args:
        coef (float): Multiplier applied to the rolling return standard
            deviation to size the slippage.
        window (int): Number of recent bars used to estimate volatility.
    """
    self.coef = float(coef)
    self.window = int(window)
adjusted_price
adjusted_price(base_price, direction, quantity, symbol, bardata) -> float

Return base_price shifted by coef * sigma of recent returns.

Source code in src/bbstrader/btengine/friction.py
def adjusted_price(self, base_price, direction, quantity, symbol, bardata) -> float:
    """Return ``base_price`` shifted by ``coef * sigma`` of recent returns."""
    try:
        returns = bardata.get_latest_bars_values(symbol, "returns", N=self.window)
        sigma = float(np.nanstd(returns)) if len(returns) else 0.0
    except (AttributeError, KeyError, ValueError):
        sigma = 0.0
    return base_price * (1.0 + _direction_sign(direction) * self.coef * sigma)

VolumeParticipationSlippage

VolumeParticipationSlippage(coef: float = 0.1)

Bases: SlippageModel

Slippage proportional to the order's share of bar volume.

Initialise the model with a participation coefficient.

Parameters:

Name Type Description Default
coef float

Multiplier applied to the order's share of bar volume to size the slippage.

0.1
Source code in src/bbstrader/btengine/friction.py
def __init__(self, coef: float = 0.1) -> None:
    """Initialise the model with a participation coefficient.

    Args:
        coef (float): Multiplier applied to the order's share of bar volume
            to size the slippage.
    """
    self.coef = float(coef)
adjusted_price
adjusted_price(base_price, direction, quantity, symbol, bardata) -> float

Return base_price shifted by the order's share of bar volume.

Source code in src/bbstrader/btengine/friction.py
def adjusted_price(self, base_price, direction, quantity, symbol, bardata) -> float:
    """Return ``base_price`` shifted by the order's share of bar volume."""
    try:
        volume = float(bardata.get_latest_bar_value(symbol, "volume"))
    except (AttributeError, KeyError, ValueError):
        volume = 0.0
    if volume <= 0:
        return base_price
    participation = abs(quantity) / volume
    return base_price * (
        1.0 + _direction_sign(direction) * self.coef * participation
    )

MarketImpactModel

Bases: ABC

Adds price impact from consuming liquidity.

impact abstractmethod
impact(base_price: float, quantity: float, direction: str) -> float

Return the per-unit price impact (always adverse).

Source code in src/bbstrader/btengine/friction.py
@abstractmethod
def impact(self, base_price: float, quantity: float, direction: str) -> float:
    """Return the per-unit price impact (always adverse)."""

NoImpact

Bases: MarketImpactModel

No market impact.

impact
impact(base_price, quantity, direction) -> float

Return 0.0 regardless of order size.

Source code in src/bbstrader/btengine/friction.py
def impact(self, base_price, quantity, direction) -> float:
    """Return ``0.0`` regardless of order size."""
    return 0.0

SquareRootImpact

SquareRootImpact(coef: float = 0.1, adv: float = 1000000.0)

Bases: MarketImpactModel

The square-root impact model: impact proportional to sqrt(size / ADV).

impact = coef * base_price * sqrt(|quantity| / adv). Suitable for institution-scale sizing where impact grows sub-linearly with order size.

Initialise the model with an impact coefficient and ADV.

Parameters:

Name Type Description Default
coef float

Scales the impact; larger values model thinner books.

0.1
adv float

Average daily volume used to normalise order size. Must be positive.

1000000.0

Raises:

Type Description
ValueError

If adv is not positive.

Source code in src/bbstrader/btengine/friction.py
def __init__(self, coef: float = 0.1, adv: float = 1_000_000.0) -> None:
    """Initialise the model with an impact coefficient and ADV.

    Args:
        coef (float): Scales the impact; larger values model thinner books.
        adv (float): Average daily volume used to normalise order size. Must
            be positive.

    Raises:
        ValueError: If ``adv`` is not positive.
    """
    if adv <= 0:
        raise ValueError("adv (average daily volume) must be positive.")
    self.coef = float(coef)
    self.adv = float(adv)
impact
impact(base_price, quantity, direction) -> float

Return the adverse per-unit impact coef * price * sqrt(|qty|/adv).

Source code in src/bbstrader/btengine/friction.py
def impact(self, base_price, quantity, direction) -> float:
    """Return the adverse per-unit impact ``coef * price * sqrt(|qty|/adv)``."""
    magnitude = self.coef * base_price * math.sqrt(abs(quantity) / self.adv)
    return _direction_sign(direction) * magnitude

CommissionModel

Bases: ABC

Computes commission for a fill.

commission abstractmethod
commission(symbol: str, quantity: float, price: float) -> float

Return the commission charged for the fill.

Source code in src/bbstrader/btengine/friction.py
@abstractmethod
def commission(self, symbol: str, quantity: float, price: float) -> float:
    """Return the commission charged for the fill."""

ZeroCommission

Bases: CommissionModel

No commission.

commission
commission(symbol, quantity, price) -> float

Return 0.0 for every fill.

Source code in src/bbstrader/btengine/friction.py
def commission(self, symbol, quantity, price) -> float:
    """Return ``0.0`` for every fill."""
    return 0.0

FixedCommission

FixedCommission(amount: float)

Bases: CommissionModel

A flat fee per fill.

Initialise the model with the flat per-fill fee.

Parameters:

Name Type Description Default
amount float

The fee charged on every fill, in account currency.

required
Source code in src/bbstrader/btengine/friction.py
def __init__(self, amount: float) -> None:
    """Initialise the model with the flat per-fill fee.

    Args:
        amount (float): The fee charged on every fill, in account currency.
    """
    self.amount = float(amount)
commission
commission(symbol, quantity, price) -> float

Return the flat fee regardless of symbol, quantity or price.

Source code in src/bbstrader/btengine/friction.py
def commission(self, symbol, quantity, price) -> float:
    """Return the flat fee regardless of symbol, quantity or price."""
    return self.amount

PerShareCommission

PerShareCommission(per_share: float = 0.005, minimum: float = 1.0)

Bases: CommissionModel

A per-share/contract fee with an optional minimum.

Initialise the model with a per-share rate and floor.

Parameters:

Name Type Description Default
per_share float

Fee charged per share or contract filled.

0.005
minimum float

Minimum commission applied to any fill.

1.0
Source code in src/bbstrader/btengine/friction.py
def __init__(self, per_share: float = 0.005, minimum: float = 1.0) -> None:
    """Initialise the model with a per-share rate and floor.

    Args:
        per_share (float): Fee charged per share or contract filled.
        minimum (float): Minimum commission applied to any fill.
    """
    self.per_share = float(per_share)
    self.minimum = float(minimum)
commission
commission(symbol, quantity, price) -> float

Return per_share * |quantity| floored at minimum.

Source code in src/bbstrader/btengine/friction.py
def commission(self, symbol, quantity, price) -> float:
    """Return ``per_share * |quantity|`` floored at ``minimum``."""
    return max(self.minimum, self.per_share * abs(quantity))

PercentCommission

PercentCommission(pct: float = 0.001, minimum: float = 0.0)

Bases: CommissionModel

A commission as a percentage of notional with an optional minimum.

Initialise the model with a notional percentage and floor.

Parameters:

Name Type Description Default
pct float

Fraction of traded notional charged as commission.

0.001
minimum float

Minimum commission applied to any fill.

0.0
Source code in src/bbstrader/btengine/friction.py
def __init__(self, pct: float = 0.001, minimum: float = 0.0) -> None:
    """Initialise the model with a notional percentage and floor.

    Args:
        pct (float): Fraction of traded notional charged as commission.
        minimum (float): Minimum commission applied to any fill.
    """
    self.pct = float(pct)
    self.minimum = float(minimum)
commission
commission(symbol, quantity, price) -> float

Return pct * |quantity| * price floored at minimum.

Source code in src/bbstrader/btengine/friction.py
def commission(self, symbol, quantity, price) -> float:
    """Return ``pct * |quantity| * price`` floored at ``minimum``."""
    return max(self.minimum, self.pct * abs(quantity) * price)

IBCommission

Bases: CommissionModel

The Interactive Brokers tiered share commission used by FillEvent.

commission
commission(symbol, quantity, price) -> float

Return the IB tiered per-share commission for the fill.

Source code in src/bbstrader/btengine/friction.py
def commission(self, symbol, quantity, price) -> float:
    """Return the IB tiered per-share commission for the fill."""
    qty = abs(quantity)
    if qty <= 500:
        return max(1.30, 0.013 * qty)
    return max(1.30, 0.008 * qty)

FundingModel

Bases: ABC

Charges the per-bar carrying cost of holding an open position.

Unlike slippage, impact and commission - which apply once at the fill - a funding model is evaluated every bar a position is held, capturing the overnight/swap financing that dominates the cost of leveraged CFD and FX positions. The returned value is a cash flow (positive = a cost debited from the account, negative = a credit) so a model can charge longs while crediting shorts, or vice versa.

carry abstractmethod
carry(symbol: str, quantity: float, price: float) -> float

Return the per-bar carry cash flow for an open position.

Parameters:

Name Type Description Default
symbol str

The instrument the position is held in.

required
quantity float

The signed position size; positive for a long, negative for a short.

required
price float

The current mark-to-market price of one unit.

required

Returns:

Name Type Description
float float

The cash flow for holding the position over one bar. A

float

positive number is a cost debited from cash; a negative number is a

float

credit added to cash.

Source code in src/bbstrader/btengine/friction.py
@abstractmethod
def carry(self, symbol: str, quantity: float, price: float) -> float:
    """Return the per-bar carry cash flow for an open position.

    Args:
        symbol (str): The instrument the position is held in.
        quantity (float): The signed position size; positive for a long,
            negative for a short.
        price (float): The current mark-to-market price of one unit.

    Returns:
        float: The cash flow for holding the position over one bar. A
        positive number is a cost debited from cash; a negative number is a
        credit added to cash.
    """

NoFunding

Bases: FundingModel

Applies no carrying cost; positions are free to hold.

carry
carry(symbol: str, quantity: float, price: float) -> float

Return zero carry regardless of the position.

Parameters:

Name Type Description Default
symbol str

Unused; present for interface compatibility.

required
quantity float

Unused; present for interface compatibility.

required
price float

Unused; present for interface compatibility.

required

Returns:

Name Type Description
float float

Always 0.0.

Source code in src/bbstrader/btengine/friction.py
def carry(self, symbol: str, quantity: float, price: float) -> float:
    """Return zero carry regardless of the position.

    Args:
        symbol (str): Unused; present for interface compatibility.
        quantity (float): Unused; present for interface compatibility.
        price (float): Unused; present for interface compatibility.

    Returns:
        float: Always ``0.0``.
    """
    return 0.0

FixedRateFunding

FixedRateFunding(annual_rate: float, periods: int = 252, short_rate: Optional[float] = None)

Bases: FundingModel

A simple cost-of-carry charged as an annual rate on notional.

The per-bar cost is (annual_rate / periods) * quantity * price. Because quantity is signed, a long position is debited and a short position is credited at the same rate, mirroring a basic financing model where the holder of a long leveraged position pays to borrow. Supply short_rate to charge shorts at a different annual rate (for example a borrow fee that makes shorting a net cost rather than a credit).

Initialise the model with annualised financing rates.

Parameters:

Name Type Description Default
annual_rate float

The annual financing rate applied to long notional (for example 0.05 for 5% per year).

required
periods int

The number of bars per year used to convert the annual rate to a per-bar rate (for example 252 for daily bars). Must be positive.

252
short_rate Optional[float]

The annual rate applied to short notional. When None the long annual_rate is reused, so a short earns the symmetric credit; supply an explicit value to model an asymmetric borrow cost.

None

Raises:

Type Description
ValueError

If periods is not positive.

Source code in src/bbstrader/btengine/friction.py
def __init__(
    self,
    annual_rate: float,
    periods: int = 252,
    short_rate: Optional[float] = None,
) -> None:
    """Initialise the model with annualised financing rates.

    Args:
        annual_rate (float): The annual financing rate applied to long
            notional (for example ``0.05`` for 5% per year).
        periods (int): The number of bars per year used to convert the
            annual rate to a per-bar rate (for example ``252`` for daily
            bars). Must be positive.
        short_rate (Optional[float]): The annual rate applied to short
            notional. When ``None`` the long ``annual_rate`` is reused, so a
            short earns the symmetric credit; supply an explicit value to
            model an asymmetric borrow cost.

    Raises:
        ValueError: If ``periods`` is not positive.
    """
    if periods <= 0:
        raise ValueError("periods must be a positive number of bars per year.")
    self.annual_rate = float(annual_rate)
    self.periods = int(periods)
    self.short_rate = annual_rate if short_rate is None else float(short_rate)
carry
carry(symbol: str, quantity: float, price: float) -> float

Return the per-bar financing cash flow for the position.

Parameters:

Name Type Description Default
symbol str

Unused; the rate is instrument independent.

required
quantity float

The signed position size.

required
price float

The current mark-to-market price of one unit.

required

Returns:

Name Type Description
float float

(rate / periods) * quantity * price using the long rate

float

for positive quantities and short_rate for negative ones. A

float

positive result is debited from cash.

Source code in src/bbstrader/btengine/friction.py
def carry(self, symbol: str, quantity: float, price: float) -> float:
    """Return the per-bar financing cash flow for the position.

    Args:
        symbol (str): Unused; the rate is instrument independent.
        quantity (float): The signed position size.
        price (float): The current mark-to-market price of one unit.

    Returns:
        float: ``(rate / periods) * quantity * price`` using the long rate
        for positive quantities and ``short_rate`` for negative ones. A
        positive result is debited from cash.
    """
    if quantity == 0:
        return 0.0
    rate = self.annual_rate if quantity > 0 else self.short_rate
    return (rate / self.periods) * quantity * price

BrokerSwapFunding

BrokerSwapFunding(long_points: float, short_points: float, point_value: float = 1.0)

Bases: FundingModel

Per-unit swap points charged each bar, mirroring MT5 swap semantics.

Brokers quote a long and a short swap per lot/unit; this model charges points * |quantity| * point_value each bar, using the long or short points according to the sign of the position. Points are expressed as a cost: positive points are debited from cash and negative points (a positive swap) are credited.

Initialise the model with the broker's long/short swap points.

Parameters:

Name Type Description Default
long_points float

The swap cost per unit per bar applied to long positions. Positive debits cash; negative credits it.

required
short_points float

The swap cost per unit per bar applied to short positions, with the same sign convention as long_points.

required
point_value float

The cash value of one swap point per unit, used to convert points to account currency.

1.0
Source code in src/bbstrader/btengine/friction.py
def __init__(
    self,
    long_points: float,
    short_points: float,
    point_value: float = 1.0,
) -> None:
    """Initialise the model with the broker's long/short swap points.

    Args:
        long_points (float): The swap cost per unit per bar applied to long
            positions. Positive debits cash; negative credits it.
        short_points (float): The swap cost per unit per bar applied to short
            positions, with the same sign convention as ``long_points``.
        point_value (float): The cash value of one swap point per unit, used
            to convert points to account currency.
    """
    self.long_points = float(long_points)
    self.short_points = float(short_points)
    self.point_value = float(point_value)
carry
carry(symbol: str, quantity: float, price: float) -> float

Return the per-bar swap cash flow for the position.

Parameters:

Name Type Description Default
symbol str

Unused; swap points are supplied per model instance.

required
quantity float

The signed position size.

required
price float

Unused; swap is charged per unit, not on notional.

required

Returns:

Name Type Description
float float

points * |quantity| * point_value where points is the

float

long or short swap selected by the sign of quantity. A positive

float

result is debited from cash.

Source code in src/bbstrader/btengine/friction.py
def carry(self, symbol: str, quantity: float, price: float) -> float:
    """Return the per-bar swap cash flow for the position.

    Args:
        symbol (str): Unused; swap points are supplied per model instance.
        quantity (float): The signed position size.
        price (float): Unused; swap is charged per unit, not on notional.

    Returns:
        float: ``points * |quantity| * point_value`` where ``points`` is the
        long or short swap selected by the sign of ``quantity``. A positive
        result is debited from cash.
    """
    if quantity == 0:
        return 0.0
    points = self.long_points if quantity > 0 else self.short_points
    return points * abs(quantity) * self.point_value

apply_friction

apply_friction(base_price: float, direction: str, quantity: float, symbol: str, bardata: DataHandler, slippage: Optional[SlippageModel], impact: Optional[MarketImpactModel]) -> float

Return the effective fill price after slippage and market impact.

Source code in src/bbstrader/btengine/friction.py
def apply_friction(
    base_price: float,
    direction: str,
    quantity: float,
    symbol: str,
    bardata: DataHandler,
    slippage: Optional[SlippageModel],
    impact: Optional[MarketImpactModel],
) -> float:
    """Return the effective fill price after slippage and market impact."""
    price = base_price
    if slippage is not None:
        price = slippage.adjusted_price(price, direction, quantity, symbol, bardata)
    if impact is not None:
        price = price + impact.impact(base_price, quantity, direction)
    return price

optimize

Parameter optimization and walk-forward validation for the backtest engine.

This module turns the replayable data feed (DataHandler.reset() / n_bars / _records, added when the engine was hardened) into practical research tooling:

  • :func:optimize runs a grid or random search over strategy parameters, optionally across processes, and returns a ranked results table. Each worker loads its data once and replays it across every parameter combination via reset() no re-reading or re-downloading per run.
  • :func:walk_forward performs anchored or rolling walk-forward validation by slicing the in-memory columnar _records, fitting parameters in-sample and scoring them out-of-sample.

Both consume the same BaseStrategy API as live trading, so a strategy is written once and optimized without modification.

expand_param_grid

expand_param_grid(param_grid: Dict[str, Sequence[Any]], search: str = 'grid', n_iter: Optional[int] = None, seed: int = 0) -> List[Dict[str, Any]]

Expand a parameter grid into a list of concrete parameter dicts.

Parameters:

Name Type Description Default
param_grid Dict[str, Sequence[Any]]

Mapping of parameter name to the sequence of values to try.

required
search str

"grid" for the full Cartesian product, "random" to draw n_iter random combinations.

'grid'
n_iter Optional[int]

Number of combinations to sample when search == "random".

None
seed int

Seed for the random sampler (deterministic by default).

0
Source code in src/bbstrader/btengine/optimize.py
def expand_param_grid(
    param_grid: Dict[str, Sequence[Any]],
    search: str = "grid",
    n_iter: Optional[int] = None,
    seed: int = 0,
) -> List[Dict[str, Any]]:
    """Expand a parameter grid into a list of concrete parameter dicts.

    Args:
        param_grid: Mapping of parameter name to the sequence of values to try.
        search: ``"grid"`` for the full Cartesian product, ``"random"`` to draw
            ``n_iter`` random combinations.
        n_iter: Number of combinations to sample when ``search == "random"``.
        seed: Seed for the random sampler (deterministic by default).
    """
    keys = list(param_grid.keys())
    value_lists = [list(param_grid[k]) for k in keys]
    combos = [dict(zip(keys, values)) for values in itertools.product(*value_lists)]
    if search == "grid":
        return combos
    if search == "random":
        if n_iter is None:
            raise ValueError("n_iter is required when search='random'.")
        rng = random.Random(seed)
        if n_iter >= len(combos):
            return combos
        return rng.sample(combos, n_iter)
    raise ValueError(f"Unknown search mode: {search!r} (use 'grid' or 'random').")

optimize

optimize(symbol_list: List[str], start_date: datetime, data_handler: Type[DataHandler], strategy: Type[Strategy], param_grid: Dict[str, Sequence[Any]], exc_handler: Optional[Type[ExecutionHandler]] = None, initial_capital: float = 100000.0, metric: str = 'sharpe', periods: int = 252, n_jobs: int = 1, search: str = 'grid', n_iter: Optional[int] = None, seed: int = 0, **kwargs: Any) -> pd.DataFrame

Search strategy parameters and return a ranked results table.

Parameters:

Name Type Description Default
symbol_list List[str]

Symbols to backtest.

required
start_date datetime

Backtest start date.

required
data_handler Type[DataHandler]

A DataHandler subclass (class, not instance).

required
strategy Type[Strategy]

A Strategy subclass whose **kwargs accept the swept parameters.

required
param_grid Dict[str, Sequence[Any]]

Mapping of parameter name to the values to try.

required
exc_handler Optional[Type[ExecutionHandler]]

Execution handler class (defaults to SimExecutionHandler).

None
initial_capital float

Starting capital for each run.

100000.0
metric str

Column to rank by. One of total_return, sharpe, max_drawdown, final_equity.

'sharpe'
periods int

Annualization factor for the Sharpe ratio (252 daily, etc.).

252
n_jobs int

Number of worker processes. 1 runs in-process (deterministic); >1 uses a process pool.

1
search str

"grid" (full product) or "random" (sample n_iter).

'grid'
n_iter Optional[int]

Sample size for random search.

None
seed int

Seed for random search.

0
**kwargs Any

Extra keyword args forwarded to every backtest (data handler, strategy, portfolio, execution handler).

{}

Returns:

Type Description
DataFrame

A DataFrame with one row per parameter combination plus its metrics,

DataFrame

sorted best-first by metric.

Source code in src/bbstrader/btengine/optimize.py
def optimize(
    symbol_list: List[str],
    start_date: datetime,
    data_handler: Type[DataHandler],
    strategy: Type[Strategy],
    param_grid: Dict[str, Sequence[Any]],
    exc_handler: Optional[Type[ExecutionHandler]] = None,
    initial_capital: float = 100000.0,
    metric: str = "sharpe",
    periods: int = 252,
    n_jobs: int = 1,
    search: str = "grid",
    n_iter: Optional[int] = None,
    seed: int = 0,
    **kwargs: Any,
) -> pd.DataFrame:
    """Search strategy parameters and return a ranked results table.

    Args:
        symbol_list: Symbols to backtest.
        start_date: Backtest start date.
        data_handler: A ``DataHandler`` subclass (class, not instance).
        strategy: A ``Strategy`` subclass whose ``**kwargs`` accept the swept
            parameters.
        param_grid: Mapping of parameter name to the values to try.
        exc_handler: Execution handler class (defaults to ``SimExecutionHandler``).
        initial_capital: Starting capital for each run.
        metric: Column to rank by. One of ``total_return``, ``sharpe``,
            ``max_drawdown``, ``final_equity``.
        periods: Annualization factor for the Sharpe ratio (252 daily, etc.).
        n_jobs: Number of worker processes. ``1`` runs in-process
            (deterministic); ``>1`` uses a process pool.
        search: ``"grid"`` (full product) or ``"random"`` (sample ``n_iter``).
        n_iter: Sample size for random search.
        seed: Seed for random search.
        **kwargs: Extra keyword args forwarded to every backtest (data handler,
            strategy, portfolio, execution handler).

    Returns:
        A DataFrame with one row per parameter combination plus its metrics,
        sorted best-first by ``metric``.
    """
    execution_handler = exc_handler or SimExecutionHandler
    combos = expand_param_grid(param_grid, search=search, n_iter=n_iter, seed=seed)
    if not combos:
        raise ValueError("param_grid produced no parameter combinations.")

    if n_jobs <= 1:
        rows = _evaluate_combos(
            combos,
            symbol_list,
            start_date,
            data_handler,
            strategy,
            execution_handler,
            initial_capital,
            kwargs,
            periods,
        )
    else:
        rows = []
        chunks = _chunked(combos, n_jobs)
        with ProcessPoolExecutor(max_workers=n_jobs) as executor:
            futures = [
                executor.submit(
                    _evaluate_combos,
                    chunk,
                    symbol_list,
                    start_date,
                    data_handler,
                    strategy,
                    execution_handler,
                    initial_capital,
                    kwargs,
                    periods,
                )
                for chunk in chunks
            ]
            for future in futures:
                rows.extend(future.result())

    results = pd.DataFrame(rows)
    if metric not in results.columns:
        raise ValueError(
            f"Unknown metric {metric!r}; available: {list(results.columns)}."
        )
    ascending = metric in _LOWER_IS_BETTER
    param_cols = list(combos[0].keys())
    results = results.sort_values(
        by=metric, ascending=ascending, na_position="last"
    ).reset_index(drop=True)
    return results[
        param_cols + ["total_return", "sharpe", "max_drawdown", "final_equity"]
    ]

walk_forward

walk_forward(symbol_list: List[str], start_date: datetime, data_handler: Type[DataHandler], strategy: Type[Strategy], param_grid: Dict[str, Sequence[Any]], exc_handler: Optional[Type[ExecutionHandler]] = None, initial_capital: float = 100000.0, metric: str = 'sharpe', periods: int = 252, n_splits: int = 3, anchored: bool = True, **kwargs: Any) -> pd.DataFrame

Anchored or rolling walk-forward validation.

The full history is divided into n_splits + 1 equal segments. For each fold the in-sample window is optimized (in-process), and the best parameter set is evaluated on the next out-of-sample segment. With anchored=True the in-sample window always starts at bar 0 and grows; with anchored=False it rolls forward at a fixed length.

Returns:

Type Description
DataFrame

One row per fold: the chosen parameters plus the out-of-sample metrics.

Source code in src/bbstrader/btengine/optimize.py
def walk_forward(
    symbol_list: List[str],
    start_date: datetime,
    data_handler: Type[DataHandler],
    strategy: Type[Strategy],
    param_grid: Dict[str, Sequence[Any]],
    exc_handler: Optional[Type[ExecutionHandler]] = None,
    initial_capital: float = 100000.0,
    metric: str = "sharpe",
    periods: int = 252,
    n_splits: int = 3,
    anchored: bool = True,
    **kwargs: Any,
) -> pd.DataFrame:
    """Anchored or rolling walk-forward validation.

    The full history is divided into ``n_splits + 1`` equal segments. For each
    fold the in-sample window is optimized (in-process), and the best parameter
    set is evaluated on the next out-of-sample segment. With ``anchored=True``
    the in-sample window always starts at bar 0 and grows; with ``anchored=False``
    it rolls forward at a fixed length.

    Returns:
        One row per fold: the chosen parameters plus the out-of-sample metrics.
    """
    from queue import Queue

    execution_handler = exc_handler or SimExecutionHandler
    handler = data_handler(Queue(), symbol_list, **kwargs)
    # Keep a pristine copy so slicing per fold is non-destructive.
    handler._full_records = {  # type: ignore[attr-defined]
        s: list(handler._records[s]) for s in symbol_list
    }
    total_bars = len(handler._full_records[symbol_list[0]])  # type: ignore[attr-defined]
    if total_bars < (n_splits + 1) * 2:
        raise ValueError(
            f"Not enough bars ({total_bars}) for {n_splits} walk-forward splits."
        )
    seg = total_bars // (n_splits + 1)
    combos = expand_param_grid(param_grid)

    fold_rows: List[Dict[str, Any]] = []
    for fold in range(n_splits):
        train_hi = seg * (fold + 1)
        train_lo = 0 if anchored else seg * fold
        test_lo, test_hi = train_hi, seg * (fold + 2)

        # In-sample: pick the best parameters on the training window.
        best_params, best_score = None, None
        for params in combos:
            _slice_handler(handler, train_lo, train_hi)
            curve = _run_engine(
                symbol_list,
                start_date,
                handler,
                strategy,
                execution_handler,
                initial_capital,
                {**kwargs, **params},
            )
            score = _score_curve(curve, periods)[metric]
            better = best_score is None or (
                score < best_score if metric in _LOWER_IS_BETTER else score > best_score
            )
            if score == score and better:  # skip NaN scores
                best_params, best_score = params, score

        if best_params is None:
            best_params = combos[0]

        # Out-of-sample: evaluate the chosen parameters on the test window.
        _slice_handler(handler, test_lo, test_hi)
        oos_curve = _run_engine(
            symbol_list,
            start_date,
            handler,
            strategy,
            execution_handler,
            initial_capital,
            {**kwargs, **best_params},
        )
        fold_rows.append(
            {"fold": fold, **best_params, **_score_curve(oos_curve, periods)}
        )

    return pd.DataFrame(fold_rows)

overfitting

Overfitting diagnostics for strategy research.

Implements the Bailey & Lopez de Prado toolkit for distinguishing real alpha from selection bias:

  • Probabilistic and Deflated Sharpe ratios adjust an observed Sharpe for sample length, non-normality, and the number of trials that produced it.
  • CSCV PBO the probability of backtest overfitting from combinatorially symmetric cross-validation.
  • Combinatorial purged cross-validation splits multiple train/test folds with purging/embargo for leakage-free out-of-sample evaluation.

All routines are deterministic and pure NumPy/SciPy.

probabilistic_sharpe_ratio

probabilistic_sharpe_ratio(sharpe: float, n_obs: int, benchmark: float = 0.0, skew: float = 0.0, kurtosis: float = 3.0) -> float

Probability that the true Sharpe exceeds benchmark (PSR).

sharpe and benchmark are per-observation (non-annualized) Sharpe ratios. skew/kurtosis are the return distribution's moments (kurtosis 3 == normal).

Source code in src/bbstrader/btengine/overfitting.py
def probabilistic_sharpe_ratio(
    sharpe: float,
    n_obs: int,
    benchmark: float = 0.0,
    skew: float = 0.0,
    kurtosis: float = 3.0,
) -> float:
    """Probability that the true Sharpe exceeds ``benchmark`` (PSR).

    ``sharpe`` and ``benchmark`` are per-observation (non-annualized) Sharpe
    ratios. ``skew``/``kurtosis`` are the return distribution's moments
    (kurtosis 3 == normal).
    """
    if n_obs < 2:
        return 0.0
    denom = math.sqrt(1.0 - skew * sharpe + (kurtosis - 1.0) / 4.0 * sharpe**2)
    if denom == 0:
        return 0.0
    z = (sharpe - benchmark) * math.sqrt(n_obs - 1) / denom
    return float(stats.norm.cdf(z))

expected_max_sharpe

expected_max_sharpe(n_trials: int, sharpe_variance: float) -> float

Expected maximum of n_trials independent Sharpe estimates.

The benchmark a strategy must beat to be considered non-random when it was selected from n_trials candidates (Bailey & Lopez de Prado).

Source code in src/bbstrader/btengine/overfitting.py
def expected_max_sharpe(n_trials: int, sharpe_variance: float) -> float:
    """Expected maximum of ``n_trials`` independent Sharpe estimates.

    The benchmark a strategy must beat to be considered non-random when it was
    selected from ``n_trials`` candidates (Bailey & Lopez de Prado).
    """
    if n_trials < 2 or sharpe_variance <= 0:
        return 0.0
    z1 = stats.norm.ppf(1.0 - 1.0 / n_trials)
    z2 = stats.norm.ppf(1.0 - 1.0 / (n_trials * math.e))
    return math.sqrt(sharpe_variance) * (
        (1.0 - _EULER_MASCHERONI) * z1 + _EULER_MASCHERONI * z2
    )

deflated_sharpe_ratio

deflated_sharpe_ratio(sharpe: float, n_obs: int, n_trials: int, sharpe_variance: float, skew: float = 0.0, kurtosis: float = 3.0) -> float

Deflated Sharpe Ratio (DSR).

PSR computed against the expected maximum Sharpe across n_trials, i.e. the probability the strategy's Sharpe is real after accounting for multiple testing. sharpe/sharpe_variance are per-observation.

Source code in src/bbstrader/btengine/overfitting.py
def deflated_sharpe_ratio(
    sharpe: float,
    n_obs: int,
    n_trials: int,
    sharpe_variance: float,
    skew: float = 0.0,
    kurtosis: float = 3.0,
) -> float:
    """Deflated Sharpe Ratio (DSR).

    PSR computed against the expected maximum Sharpe across ``n_trials``, i.e.
    the probability the strategy's Sharpe is real after accounting for multiple
    testing. ``sharpe``/``sharpe_variance`` are per-observation.
    """
    benchmark = expected_max_sharpe(n_trials, sharpe_variance)
    return probabilistic_sharpe_ratio(sharpe, n_obs, benchmark, skew, kurtosis)

cscv_pbo

cscv_pbo(performance: NDArray[float64], n_splits: int = 10, metric: Optional[Callable[[NDArray[float64]], float]] = None) -> float

Probability of Backtest Overfitting via combinatorially symmetric CV.

Parameters:

Name Type Description Default
performance NDArray[float64]

A (T, N) matrix of per-observation returns for N candidate configurations over T observations.

required
n_splits int

Number of disjoint row blocks S (must be even); IS/OOS are all C(S, S/2) balanced partitions.

10
metric Optional[Callable[[NDArray[float64]], float]]

Per-configuration score from a sub-matrix of returns. Defaults to the Sharpe ratio.

None

Returns:

Type Description
float

PBO in [0, 1]: the fraction of partitions where the in-sample best

float

configuration ranks below the out-of-sample median.

Source code in src/bbstrader/btengine/overfitting.py
def cscv_pbo(
    performance: NDArray[np.float64],
    n_splits: int = 10,
    metric: Optional[Callable[[NDArray[np.float64]], float]] = None,
) -> float:
    """Probability of Backtest Overfitting via combinatorially symmetric CV.

    Args:
        performance: A (T, N) matrix of per-observation returns for N candidate
            configurations over T observations.
        n_splits: Number of disjoint row blocks S (must be even); IS/OOS are all
            C(S, S/2) balanced partitions.
        metric: Per-configuration score from a sub-matrix of returns. Defaults to
            the Sharpe ratio.

    Returns:
        PBO in [0, 1]: the fraction of partitions where the in-sample best
        configuration ranks below the out-of-sample median.
    """
    perf = np.asarray(performance, dtype=np.float64)
    if perf.ndim != 2:
        raise ValueError("performance must be a 2-D (T, N) matrix.")
    if n_splits % 2 != 0:
        raise ValueError("n_splits must be even.")
    score = metric or _sharpe
    n_obs, n_cfg = perf.shape
    blocks = np.array_split(np.arange(n_obs), n_splits)

    logits = []
    for combo in itertools.combinations(range(n_splits), n_splits // 2):
        is_rows = np.concatenate([blocks[b] for b in combo])
        oos_rows = np.concatenate(
            [blocks[b] for b in range(n_splits) if b not in combo]
        )
        is_scores = np.array([score(perf[is_rows, c]) for c in range(n_cfg)])
        oos_scores = np.array([score(perf[oos_rows, c]) for c in range(n_cfg)])
        best = int(np.argmax(is_scores))
        # Relative rank of the IS-best config among OOS scores.
        rank = float(stats.rankdata(oos_scores)[best])
        omega = rank / (n_cfg + 1)
        omega = min(max(omega, 1e-6), 1 - 1e-6)
        logits.append(math.log(omega / (1.0 - omega)))

    logits_arr = np.array(logits)
    return float(np.mean(logits_arr <= 0.0))

combinatorial_splits

combinatorial_splits(n_obs: int, n_groups: int = 6, n_test_groups: int = 2, embargo: int = 0) -> Iterator[Tuple[NDArray[np.int_], NDArray[np.int_]]]

Yield combinatorial purged cross-validation (CPCV) train/test splits.

Observations are partitioned into n_groups contiguous blocks; every combination of n_test_groups blocks forms a test set, with the remaining blocks (minus an embargo band around each test block, to prevent leakage) as the training set. Yields C(n_groups, n_test_groups) folds.

Source code in src/bbstrader/btengine/overfitting.py
def combinatorial_splits(
    n_obs: int,
    n_groups: int = 6,
    n_test_groups: int = 2,
    embargo: int = 0,
) -> Iterator[Tuple[NDArray[np.int_], NDArray[np.int_]]]:
    """Yield combinatorial purged cross-validation (CPCV) train/test splits.

    Observations are partitioned into ``n_groups`` contiguous blocks; every
    combination of ``n_test_groups`` blocks forms a test set, with the remaining
    blocks (minus an ``embargo`` band around each test block, to prevent
    leakage) as the training set. Yields C(n_groups, n_test_groups) folds.
    """
    if n_test_groups >= n_groups:
        raise ValueError("n_test_groups must be smaller than n_groups.")
    groups = np.array_split(np.arange(n_obs), n_groups)
    for combo in itertools.combinations(range(n_groups), n_test_groups):
        test_idx = np.concatenate([groups[g] for g in combo])
        train_mask = np.ones(n_obs, dtype=bool)
        train_mask[test_idx] = False
        if embargo > 0:
            for g in combo:
                start, end = groups[g][0], groups[g][-1]
                lo = max(0, start - embargo)
                hi = min(n_obs, end + 1 + embargo)
                train_mask[lo:hi] = False
        train_idx = np.where(train_mask)[0]
        yield train_idx, test_idx

performance

get_asset_performances

get_asset_performances(portfolio: DataFrame, assets: List[str], plot: bool = True, strategy: str = '') -> pd.Series

Calculate the performance of the assets in the portfolio.

Parameters:

Name Type Description Default
portfolio DataFrame

The portfolio DataFrame.

required
assets List[str]

The list of assets to calculate the performance for.

required
plot bool

Whether to plot the performance of the assets.

True
strategy str

The name of the strategy.

''

Returns:

Type Description
Series

pd.Series: The performance of the assets.

Source code in src/bbstrader/btengine/performance.py
def get_asset_performances(
    portfolio: pd.DataFrame,
    assets: List[str],
    plot: bool = True,
    strategy: str = "",
) -> pd.Series:
    """
    Calculate the performance of the assets in the portfolio.

    Args:
        portfolio (pd.DataFrame): The portfolio DataFrame.
        assets (List[str]): The list of assets to calculate the performance for.
        plot (bool): Whether to plot the performance of the assets.
        strategy (str): The name of the strategy.

    Returns:
        pd.Series: The performance of the assets.
    """
    asset_prices = portfolio[assets]
    asset_prices = asset_prices.abs()
    asset_prices.replace(0, np.nan, inplace=True)
    asset_prices.ffill(inplace=True)
    asset_returns = asset_prices.pct_change()
    asset_returns.replace([np.inf, -np.inf], np.nan, inplace=True)
    asset_returns.fillna(0, inplace=True)
    asset_cum_returns = (1.0 + asset_returns).cumprod()
    if plot:
        asset_cum_returns.plot(
            figsize=(12, 6), title=f"{strategy} Strategy Assets Performance"
        )
        plt.show()
    return asset_cum_returns.iloc[-1] - 1

get_perfbased_weights

get_perfbased_weights(performances: Series) -> Dict[str, float]

Calculate the weights of the assets based on their performances.

Parameters:

Name Type Description Default
performances Series

The performances of the assets.

required

Returns:

Type Description
Dict[str, float]

Dict[str, float]: The weights of the assets.

Source code in src/bbstrader/btengine/performance.py
def get_perfbased_weights(performances: pd.Series) -> Dict[str, float]:
    """
    Calculate the weights of the assets based on their performances.

    Args:
        performances (pd.Series): The performances of the assets.

    Returns:
        Dict[str, float]: The weights of the assets.
    """
    weights = (
        performances.to_frame()
        .assign(weight=performances.values / performances.sum())
        .weight.to_dict()
    )
    return weights

create_sharpe_ratio

create_sharpe_ratio(returns: Series, periods: int = 252) -> float

Create the Sharpe ratio for the strategy, based on a benchmark of zero (i.e. no risk-free rate information).

Parameters:

Name Type Description Default
returns

A pandas Series representing period percentage returns.

required
periods int

Daily (252), Hourly (2526.5), Minutely(2526.5*60) etc.

252

Returns:

Name Type Description
S float

Sharpe ratio

Source code in src/bbstrader/btengine/performance.py
def create_sharpe_ratio(returns: pd.Series, periods: int = 252) -> float:
    """
    Create the Sharpe ratio for the strategy, based on a
    benchmark of zero (i.e. no risk-free rate information).

    Args:
        returns : A pandas Series representing period percentage returns.
        periods (int): Daily (252), Hourly (252*6.5), Minutely(252*6.5*60) etc.

    Returns:
        S (float): Sharpe ratio
    """
    sharpe = qs.stats.sharpe(returns, periods=periods)
    return sharpe if isinstance(sharpe, float) else sharpe.iloc[-1]

create_sortino_ratio

create_sortino_ratio(returns: Series, periods: int = 252) -> float

Create the Sortino ratio for the strategy, based on a benchmark of zero (i.e. no risk-free rate information).

Parameters:

Name Type Description Default
returns

A pandas Series representing period percentage returns.

required
periods int

Daily (252), Hourly (2526.5), Minutely(2526.5*60) etc.

252

Returns:

Name Type Description
S float

Sortino ratio

Source code in src/bbstrader/btengine/performance.py
def create_sortino_ratio(returns: pd.Series, periods: int = 252) -> float:
    """
    Create the Sortino ratio for the strategy, based on a
    benchmark of zero (i.e. no risk-free rate information).

    Args:
        returns : A pandas Series representing period percentage returns.
        periods (int): Daily (252), Hourly (252*6.5), Minutely(252*6.5*60) etc.

    Returns:
        S (float): Sortino ratio
    """
    return qs.stats.sortino(returns, periods=periods)

create_omega_ratio

create_omega_ratio(returns: Series, periods: int = 252, rf: float = 0.0) -> float

Create the Omega ratio for the strategy.

Parameters:

Name Type Description Default
returns

A pandas Series representing period percentage returns.

required
periods int

Daily (252), Hourly (2526.5), Minutely(2526.5*60) etc.

252
rf float

Risk-free rate.

0.0

Returns:

Name Type Description
float float

Omega ratio

Source code in src/bbstrader/btengine/performance.py
def create_omega_ratio(
    returns: pd.Series, periods: int = 252, rf: float = 0.0
) -> float:
    """
    Create the Omega ratio for the strategy.

    Args:
        returns : A pandas Series representing period percentage returns.
        periods (int): Daily (252), Hourly (252*6.5), Minutely(252*6.5*60) etc.
        rf (float): Risk-free rate.

    Returns:
        float: Omega ratio
    """
    return qs.stats.omega(returns, rf=rf)

create_calmar_ratio

create_calmar_ratio(returns: Series, periods: int = 252) -> float

Create the Calmar ratio for the strategy.

Parameters:

Name Type Description Default
returns

A pandas Series representing period percentage returns.

required
periods int

Daily (252), Hourly (2526.5), Minutely(2526.5*60) etc.

252

Returns:

Name Type Description
float float

Calmar ratio

Source code in src/bbstrader/btengine/performance.py
def create_calmar_ratio(returns: pd.Series, periods: int = 252) -> float:
    """
    Create the Calmar ratio for the strategy.

    Args:
        returns : A pandas Series representing period percentage returns.
        periods (int): Daily (252), Hourly (252*6.5), Minutely(252*6.5*60) etc.

    Returns:
        float: Calmar ratio
    """
    return qs.stats.calmar(returns)

create_tail_ratio

create_tail_ratio(returns: Series) -> float

Create the Tail ratio for the strategy.

Parameters:

Name Type Description Default
returns

A pandas Series representing period percentage returns.

required

Returns:

Name Type Description
float float

Tail ratio

Source code in src/bbstrader/btengine/performance.py
def create_tail_ratio(returns: pd.Series) -> float:
    """
    Create the Tail ratio for the strategy.

    Args:
        returns : A pandas Series representing period percentage returns.

    Returns:
        float: Tail ratio
    """
    return qs.stats.tail_ratio(returns)

calculate_risk_metrics

calculate_risk_metrics(returns: Series, benchmark_returns: Series, periods: int = 252) -> Dict[str, float]

Calculate Alpha, Beta and Volatility for the strategy.

Parameters:

Name Type Description Default
returns

A pandas Series representing period percentage returns.

required
benchmark_returns

A pandas Series representing benchmark period percentage returns.

required
periods int

Daily (252), Hourly (2526.5), Minutely(2526.5*60) etc.

252

Returns:

Type Description
Dict[str, float]

Dict[str, float]: Alpha, Beta, Volatility

Source code in src/bbstrader/btengine/performance.py
def calculate_risk_metrics(
    returns: pd.Series, benchmark_returns: pd.Series, periods: int = 252
) -> Dict[str, float]:
    """
    Calculate Alpha, Beta and Volatility for the strategy.

    Args:
        returns : A pandas Series representing period percentage returns.
        benchmark_returns : A pandas Series representing benchmark period percentage returns.
        periods (int): Daily (252), Hourly (252*6.5), Minutely(252*6.5*60) etc.

    Returns:
        Dict[str, float]: Alpha, Beta, Volatility
    """
    g_beta = qs.stats.greeks(returns, benchmark_returns)
    alpha = g_beta["alpha"]
    beta = g_beta["beta"]
    volatility = qs.stats.volatility(returns, periods=periods)

    return {"alpha": alpha, "beta": beta, "volatility": volatility}

create_drawdowns

create_drawdowns(pnl: Series) -> Tuple[pd.Series, float, float]

Calculate the largest peak-to-trough drawdown of the PnL curve as well as the duration of the drawdown. Requires that the pnl_returns is a pandas Series.

Parameters:

Name Type Description Default
pnl

A pandas Series representing period percentage returns.

required

Returns:

Type Description
tuple

drawdown, duration - high-water mark, duration.

Source code in src/bbstrader/btengine/performance.py
def create_drawdowns(pnl: pd.Series) -> Tuple[pd.Series, float, float]:
    """
    Calculate the largest peak-to-trough drawdown of the PnL curve
    as well as the duration of the drawdown. Requires that the
    pnl_returns is a pandas Series.

    Args:
        pnl : A pandas Series representing period percentage returns.

    Returns:
        (tuple): drawdown, duration - high-water mark, duration.
    """
    # Calculate the cumulative returns curve
    # and set up the High Water Mark
    if pnl.empty:
        return pd.Series(dtype=float), 0.0, 0.0
    hwm = pd.Series(index=pnl.index)
    hwm.iloc[0] = 0

    # Create the drawdown and duration series
    idx = pnl.index
    drawdown = pd.Series(index=idx)
    duration = pd.Series(index=idx)

    # Loop over the index range
    for t in range(1, len(idx)):
        hwm.iloc[t] = max(hwm.iloc[t - 1], pnl.iloc[t])
        drawdown.iloc[t] = hwm.iloc[t] - pnl.iloc[t]
        duration.iloc[t] = 0 if drawdown.iloc[t] == 0 else duration.iloc[t - 1] + 1

    max_drawdown = drawdown.max() if not drawdown.empty else 0.0
    max_duration = duration.max() if not duration.empty else 0.0
    return drawdown, max_drawdown, max_duration

plot_performance

plot_performance(df: DataFrame, title: str) -> None
Plot the performance of the strategy
  • (Portfolio value, %)
  • (Period returns, %)
  • (Drawdowns, %)

Parameters:

Name Type Description Default
df DataFrame
required
title str

The title of the plot.

required

Note: The DataFrame should contain the following columns - Datetime: The timestamp of the data - Equity Curve: The portfolio value - Returns: The period returns - Drawdown: The drawdowns - Total : The total returns

Source code in src/bbstrader/btengine/performance.py
def plot_performance(df: pd.DataFrame, title: str) -> None:
    """
    Plot the performance of the strategy:
        - (Portfolio value,  %)
        - (Period returns, %)
        - (Drawdowns, %)

    Args:
        df (pd.DataFrame):
        The DataFrame containing the strategy returns and drawdowns.
        title (str): The title of the plot.

    Note:
    The DataFrame should contain the following columns
    - Datetime: The timestamp of the data
    - Equity Curve: The portfolio value
    - Returns: The period returns
    - Drawdown: The drawdowns
    - Total : The total returns
    """
    data = df.copy()
    data = data.sort_values(by="Datetime")
    # Plot three charts: Equity curve,
    # period returns, drawdowns
    fig = plt.figure(figsize=(14, 8))
    fig.suptitle(f"{title} Strategy Performance", fontsize=16)

    # Set the outer colour to white
    _require_seaborn().set_theme()

    # Plot the equity curve
    ax1 = fig.add_subplot(311, ylabel="Portfolio value, %")
    data["Equity Curve"].plot(ax=ax1, color="blue", lw=2.0)
    ax1.set_xlabel("")
    plt.grid(True)

    # Plot the returns
    ax2 = fig.add_subplot(312, ylabel="Period returns, %")
    data["Returns"].plot(ax=ax2, color="black", lw=2.0)
    ax2.set_xlabel("")
    plt.grid(True)

    # Plot Drawdown
    ax3 = fig.add_subplot(313, ylabel="Drawdowns, %")
    data["Drawdown"].plot(ax=ax3, color="red", lw=2.0)
    ax3.set_xlabel("")
    plt.grid(True)

    # Plot the figure
    plt.tight_layout()
    plt.show()

plot_returns_and_dd

plot_returns_and_dd(df: DataFrame, benchmark: str, title: str) -> None

Plot the returns and drawdowns of the strategy compared to a benchmark.

Parameters:

Name Type Description Default
df DataFrame

The DataFrame containing the strategy returns and drawdowns.

required
benchmark str

The ticker symbol of the benchmark to compare the strategy to.

required
title str

The title of the plot.

required

Note: The DataFrame should contain the following columns: - Datetime : The timestamp of the data - Equity Curve : The portfolio value - Returns : The period returns - Drawdown : The drawdowns - Total : The total returns

Source code in src/bbstrader/btengine/performance.py
def plot_returns_and_dd(df: pd.DataFrame, benchmark: str, title: str) -> None:
    """
    Plot the returns and drawdowns of the strategy
    compared to a benchmark.

    Args:
        df (pd.DataFrame):
            The DataFrame containing the strategy returns and drawdowns.
        benchmark (str):
            The ticker symbol of the benchmark to compare the strategy to.
        title (str): The title of the plot.

    Note:
    The DataFrame should contain the following columns:
    - Datetime : The timestamp of the data
    - Equity Curve : The portfolio value
    - Returns : The period returns
    - Drawdown : The drawdowns
    - Total : The total returns
    """
    # Ensure data is sorted by Datetime
    data = df.copy()
    data.reset_index(inplace=True)
    data = data.sort_values(by="Datetime")
    data.sort_values(by="Datetime", inplace=True)

    # Get the first and last Datetime values
    first_date = data["Datetime"].iloc[0]
    last_date = data["Datetime"].iloc[-1]

    # Download benchmark data from Yahoo Finance
    # To avoid errors, we use the try-except block
    # in case the benchmark is not available
    try:
        bm = yf.download(benchmark, start=first_date, end=last_date, auto_adjust=True)
        bm["log_return"] = np.log(bm["Close"] / bm["Close"].shift(1))
        # Use exponential to get cumulative returns
        bm_returns = np.exp(np.cumsum(bm["log_return"].fillna(0)))

        # Normalize bm series to start at 1.0
        bm_returns_normalized = bm_returns / bm_returns.iloc[0]
    except Exception:
        bm = None

    # Create figure and plot space
    fig, (ax1, ax2) = plt.subplots(
        2, 1, figsize=(14, 8), gridspec_kw={"height_ratios": [3, 1]}
    )

    # Plot the Equity Curve for the strategy
    ax1.plot(
        data["Datetime"], data["Equity Curve"], label="Backtest", color="green", lw=2.5
    )
    # Check benchmarck an Plot the Returns for the benchmark
    if bm is not None:
        ax1.plot(
            bm.index, bm_returns_normalized, label="benchmark", color="gray", lw=2.5
        )
        ax1.set_title(f"{title} Strategy vs. Benchmark ({benchmark})")
    else:
        ax1.set_title(f"{title} Strategy Returns")
    ax1.set_xlabel("Date")
    ax1.set_ylabel("Cumulative Returns")
    ax1.grid(True)
    ax1.legend(loc="upper left")

    # Plot the Drawdown
    ax2.fill_between(
        data["Datetime"], data["Drawdown"], 0, color="red", step="pre", alpha=0.5
    )
    ax2.plot(
        data["Datetime"], data["Drawdown"], color="red", alpha=0.6, lw=2.5
    )  # Overlay the line
    ax2.set_title("Drawdown (%)")
    ax2.set_xlabel("Date")
    ax2.set_ylabel("Drawdown")
    ax2.grid(True)

    # Display the plot
    plt.tight_layout()
    plt.show()

plot_monthly_yearly_returns

plot_monthly_yearly_returns(df: DataFrame, title: str) -> None

Plot the monthly and yearly returns of the strategy.

Parameters:

Name Type Description Default
df DataFrame
required
title str

The title of the plot.

required

Note: The DataFrame should contain the following columns: - Datetime : The timestamp of the data - Equity Curve : The portfolio value - Returns : The period returns - Drawdown : The drawdowns - Total : The total returns

Source code in src/bbstrader/btengine/performance.py
def plot_monthly_yearly_returns(df: pd.DataFrame, title: str) -> None:
    """
    Plot the monthly and yearly returns of the strategy.

    Args:
        df (pd.DataFrame):
        The DataFrame containing the strategy returns and drawdowns.
        title (str): The title of the plot.

    Note:
    The DataFrame should contain the following columns:
    - Datetime : The timestamp of the data
    - Equity Curve : The portfolio value
    - Returns : The period returns
    - Drawdown : The drawdowns
    - Total : The total returns
    """
    equity_df = df.copy()
    equity_df.reset_index(inplace=True)
    equity_df["Datetime"] = pd.to_datetime(equity_df["Datetime"])
    equity_df.set_index("Datetime", inplace=True)

    # Calculate daily returns
    equity_df["Daily Returns"] = equity_df["Total"].pct_change()

    # Group by year and month to get monthly returns
    monthly_returns = (
        equity_df["Daily Returns"]
        .groupby([equity_df.index.year, equity_df.index.month])
        .apply(lambda x: (1 + x).prod() - 1)
    )

    # Prepare monthly returns DataFrame
    monthly_returns_df = monthly_returns.unstack(level=-1) * 100
    monthly_returns_df.columns = monthly_returns_df.columns.map(
        lambda x: pd.to_datetime(str(x), format="%m").strftime("%b")
    )

    # Calculate and prepare yearly returns DataFrame
    yearly_returns_df = (
        equity_df["Total"]
        .resample("A")
        .last()
        .pct_change()
        .to_frame(name="Yearly Returns")
        * 100
    )

    # Set the aesthetics for the plots
    _require_seaborn().set_theme(style="darkgrid")

    # Initialize the matplotlib figure,
    # adjust the height_ratios to give more space to the yearly returns
    f, (ax1, ax2) = plt.subplots(
        2, 1, figsize=(12, 8), gridspec_kw={"height_ratios": [2, 1]}
    )
    f.suptitle(f"{title} Strategy Monthly and Yearly Returns")
    # Find the min and max values in the data to set the color scale range.
    vmin = monthly_returns_df.min().min()
    vmax = monthly_returns_df.max().max()
    # Define the color palette for the heatmap
    cmap = sns.diverging_palette(10, 133, sep=3, n=256, center="light")

    # Create the heatmap with the larger legend
    sns.heatmap(
        monthly_returns_df,
        annot=True,
        fmt=".1f",
        linewidths=0.5,
        ax=ax1,
        cbar_kws={"shrink": 0.8},
        cmap=cmap,
        center=0,
        vmin=vmin,
        vmax=vmax,
    )

    # Rotate the year labels on the y-axis to vertical
    ax1.set_yticklabels(ax1.get_yticklabels(), rotation=0)
    ax1.set_ylabel("")
    ax1.set_xlabel("")

    # Create the bar plot
    yearly_returns_df.plot(kind="bar", ax=ax2, legend=None, color="skyblue")

    # Set plot titles and labels
    ax1.set_title("Monthly Returns (%)")
    ax2.set_title("Yearly Returns (%)")

    # Rotate the x labels for the yearly returns bar plot
    ax2.set_xticklabels(yearly_returns_df.index.strftime("%Y"), rotation=45)
    ax2.set_xlabel("")

    # Adjust layout spacing
    plt.tight_layout()

    # Show the plot
    plt.show()

show_qs_stats

show_qs_stats(returns: Series, benchmark: str, strategy_name: str, save_dir: Optional[str] = None) -> None

Generate the full quantstats report for the strategy.

Parameters:

Name Type Description Default
returns Serie

The DataFrame containing the strategy returns and drawdowns.

required
benchmark str

The ticker symbol of the benchmark to compare the strategy to.

required
strategy_name str

The name of the strategy.

required
Source code in src/bbstrader/btengine/performance.py
def show_qs_stats(
    returns: pd.Series,
    benchmark: str,
    strategy_name: str,
    save_dir: Optional[str] = None,
) -> None:
    """
    Generate the full quantstats report for the strategy.

    Args:
        returns (pd.Serie):
            The DataFrame containing the strategy returns and drawdowns.
        benchmark (str):
            The ticker symbol of the benchmark to compare the strategy to.
        strategy_name (str): The name of the strategy.
    """
    # Load the returns data
    returns = returns.copy()

    # Drop duplicate index entries
    returns = returns[~returns.index.duplicated(keep="first")]

    # Extend pandas functionality with quantstats
    qs.extend_pandas()

    # Generate the full report with a benchmark
    qs.reports.full(returns, mode="full", benchmark=benchmark)
    qs.reports.html(returns, benchmark=benchmark, output=save_dir, title=strategy_name)

portfolio

Portfolio

Portfolio(bars: DataHandler, events: Queue[Union[OrderEvent, FillEvent, SignalEvent]], start_date: datetime, initial_capital: float = 100000.0, **kwargs: Any)

This describes a Portfolio() object that keeps track of the positions within a portfolio and generates orders of a fixed quantity of stock based on signals.

The portfolio order management system is possibly the most complex component of an event driven backtester. Its role is to keep track of all current market positions as well as the market value of the positions (known as the "holdings"). This is simply an estimate of the liquidation value of the position and is derived in part from the data handling facility of the backtester.

In addition to the positions and holdings management the portfolio must also be aware of risk factors and position sizing techniques in order to optimise orders that are sent to a brokerage or other form of market access.

Unfortunately, Portfolio and Order Management Systems (OMS) can become rather complex! So let's keep the Portfolio object relatively straightforward anf improve it foward.

Continuing in the vein of the Event class hierarchy a Portfolio object must be able to handle SignalEvent objects, generate OrderEvent objects and interpret FillEvent objects to update positions. Thus it is no surprise that the Portfolio objects are often the largest component of event-driven systems, in terms of lines of code (LOC).

The initialisation of the Portfolio object requires access to the bars DataHandler, the Event Queue, a start datetime stamp and an initial capital value (defaulting to 100,000 USD) and others parameter based on the Strategy requirement.

The Portfolio is designed to handle position sizing and current holdings, but will carry out trading orders by simply them to the brokerage with a predetermined fixed quantity size, if the portfolio has enough cash to place the order.

The portfolio contains the all_positions and current_positions members. The former stores a list of all previous positions recorded at the timestamp of a market data event. A position is simply the quantity of the asset held. Negative positions mean the asset has been shorted.

The latter current_positions dictionary stores contains the current positions for the last market bar update, for each symbol.

In addition to the positions data the portfolio stores holdings, which describe the current market value of the positions held. "Current market value" in this instance means the closing price obtained from the current market bar, which is clearly an approximation, but is reasonable enough for the time being. all_holdings stores the historical list of all symbol holdings, while current_holdings stores the most up to date dictionary of all symbol holdings values.

Initialises the portfolio with bars and an event queue. Also includes a starting datetime index and initial capital (USD unless otherwise stated).

Parameters:

Name Type Description Default
bars DataHandler

The DataHandler object with current market data.

required
events Queue

The Event Queue object.

required
start_date datetime

The start date (bar) of the portfolio.

required
initial_capital float

The starting capital in USD.

100000.0
kwargs dict

Additional arguments - leverage: The leverage to apply to the portfolio. - time_frame: The time frame of the bars. - session_duration: The number of trading hours in a day. - benchmark: The benchmark symbol to compare the portfolio. - output_dir: The directory to save the backtest results. - strategy_name: The name of the strategy (the name must not include 'Strategy' in it). - print_stats: Whether to print the backtest statistics.

{}
Source code in src/bbstrader/btengine/portfolio.py
def __init__(
    self,
    bars: DataHandler,
    events: "Queue[Union[OrderEvent, FillEvent, SignalEvent]]",
    start_date: datetime,
    initial_capital: float = 100000.0,
    **kwargs: Any,
) -> None:
    """
    Initialises the portfolio with bars and an event queue.
    Also includes a starting datetime index and initial capital
    (USD unless otherwise stated).

    Args:
        bars (DataHandler): The DataHandler object with current market data.
        events (Queue): The Event Queue object.
        start_date (datetime): The start date (bar) of the portfolio.
        initial_capital (float): The starting capital in USD.

        kwargs (dict): Additional arguments
            - `leverage`: The leverage to apply to the portfolio.
            - `time_frame`: The time frame of the bars.
            - `session_duration`: The number of trading hours in a day.
            - `benchmark`: The benchmark symbol to compare the portfolio.
            - `output_dir`: The directory to save the backtest results.
            - `strategy_name`: The name of the strategy  (the name must not include 'Strategy' in it).
            - `print_stats`: Whether to print the backtest statistics.
    """
    self.bars = bars
    self.events = events
    self.symbol_list = self.bars.symbols
    self.start_date = start_date
    self.initial_capital = initial_capital
    self._leverage = kwargs.get("leverage", 1)

    self.trading_hours = kwargs.get("session_duration", 23)
    self.benchmark = kwargs.get("benchmark", "SPY")
    self.output_dir = kwargs.get("output_dir", None)
    self.strategy_name = kwargs.get("strategy_name", "")
    self.print_stats = kwargs.get("print_stats", True)
    # Optional per-bar carrying cost on open positions (swap/overnight
    # financing). None keeps the original cost-free behavior.
    self.funding_model = kwargs.get("funding_model")
    timeframe = kwargs.get("time_frame", "D1")
    if timeframe not in TIMEFRAMES:
        raise ValueError("Timeframe not supported")
    if timeframe == "D1":
        self.tf = 252
    else:
        if "m" in timeframe:
            minutes = int(timeframe.replace("m", ""))
            bars_per_day = self.trading_hours * (60 / minutes)
        elif "h" in timeframe:
            hours = int(timeframe.replace("h", ""))
            bars_per_day = self.trading_hours / hours
        else:
            bars_per_day = 1  # Should not be reached given the check
        self.tf = int(252 * bars_per_day)

    self.all_positions: List[Dict[str, Any]] = self.construct_all_positions()
    self.current_positions: Dict[str, Any] = dict(
        (k, v) for k, v in [(s, 0) for s in self.symbol_list]
    )
    self.all_holdings: List[Dict[str, Any]] = self.construct_all_holdings()
    self.current_holdings: Dict[str, Any] = self.construct_current_holdings()
    self.equity_curve: Optional[pd.DataFrame] = None

    n_bars = getattr(self.bars, "n_bars", 0)
    if n_bars:
        pad = [None] * (n_bars + 1)
        self.all_positions.extend(pad)  # type: ignore[arg-type]
        self.all_holdings.extend(pad)  # type: ignore[arg-type]
    self._history_idx = 1  # Next write slot; index 0 holds the seed row.
last_holding property
last_holding: Dict[str, Any]

The most recently recorded holdings row (mark-to-market equity).

construct_all_positions
construct_all_positions() -> List[Dict[str, Any]]

Constructs the positions list using the start_date to determine when the time index will begin.

Source code in src/bbstrader/btengine/portfolio.py
def construct_all_positions(self) -> List[Dict[str, Any]]:
    """
    Constructs the positions list using the start_date
    to determine when the time index will begin.
    """
    d = dict((k, v) for k, v in [(s, 0) for s in self.symbol_list])
    d["Datetime"] = self.start_date
    return [d]
construct_all_holdings
construct_all_holdings() -> List[Dict[str, Any]]

Constructs the holdings list using the start_date to determine when the time index will begin.

Source code in src/bbstrader/btengine/portfolio.py
def construct_all_holdings(self) -> List[Dict[str, Any]]:
    """
    Constructs the holdings list using the start_date
    to determine when the time index will begin.
    """
    d = dict((k, v) for k, v in [(s, 0.0) for s in self.symbol_list])
    d["Datetime"] = self.start_date
    d["Cash"] = self.initial_capital
    d["Commission"] = 0.0
    d["Total"] = self.initial_capital
    return [d]
construct_current_holdings
construct_current_holdings() -> Dict[str, float]

This constructs the dictionary which will hold the instantaneous value of the portfolio across all symbols.

Source code in src/bbstrader/btengine/portfolio.py
def construct_current_holdings(self) -> Dict[str, float]:
    """
    This constructs the dictionary which will hold the instantaneous
    value of the portfolio across all symbols.
    """
    d = dict((k, v) for k, v in [(s, 0.0) for s in self.symbol_list])
    d["Cash"] = self.initial_capital
    d["Commission"] = 0.0
    d["Total"] = self.initial_capital
    return d
update_timeindex
update_timeindex(event: MarketEvent) -> None

Adds a new record to the positions matrix for the current market data bar. This reflects the PREVIOUS bar, i.e. all current market data at this stage is known (OHLCV). Makes use of a MarketEvent from the events queue.

Source code in src/bbstrader/btengine/portfolio.py
def update_timeindex(self, event: MarketEvent) -> None:
    """
    Adds a new record to the positions matrix for the current
    market data bar. This reflects the PREVIOUS bar, i.e. all
    current market data at this stage is known (OHLCV).
    Makes use of a MarketEvent from the events queue.
    """
    latest_datetime = self.bars.get_latest_bar_datetime(self.symbol_list[0])
    # Debit the carrying cost of every open position before booking this
    # bar's holdings, so equity reflects swap/overnight financing.
    if self.funding_model is not None:
        self._apply_funding()
    # Update positions
    # ================
    dp = dict((k, v) for k, v in [(s, 0) for s in self.symbol_list])
    dp["Datetime"] = latest_datetime
    for s in self.symbol_list:
        dp[s] = self.current_positions[s]

    # Update holdings
    # ===============
    dh = dict((k, v) for k, v in [(s, 0) for s in self.symbol_list])
    dh["Datetime"] = latest_datetime
    dh["Cash"] = self.current_holdings["Cash"]
    dh["Commission"] = self.current_holdings["Commission"]
    dh["Total"] = self.current_holdings["Cash"]
    for s in self.symbol_list:
        # Approximation to the real value
        price = self._get_price(s)
        market_value = self.current_positions[s] * price
        dh[s] = market_value
        dh["Total"] += market_value

    # Write into the preallocated history by cursor, falling back to append
    # when the bar count was unknown at construction time.
    if self._history_idx < len(self.all_holdings):
        self.all_positions[self._history_idx] = dp
        self.all_holdings[self._history_idx] = dh
    else:
        self.all_positions.append(dp)
        self.all_holdings.append(dh)
    self._history_idx += 1
update_positions_from_fill
update_positions_from_fill(fill: FillEvent) -> None

Takes a Fill object and updates the position matrix to reflect the new position.

Parameters:

Name Type Description Default
fill FillEvent

The Fill object to update the positions with.

required
Source code in src/bbstrader/btengine/portfolio.py
def update_positions_from_fill(self, fill: FillEvent) -> None:
    """
    Takes a Fill object and updates the position matrix to
    reflect the new position.

    Args:
        fill (FillEvent): The Fill object to update the positions with.
    """
    # Check whether the fill is a buy or sell
    fill_dir = 0
    if fill.direction == "BUY":
        fill_dir = 1
    if fill.direction == "SELL":
        fill_dir = -1

    # Update positions list with new quantities
    self.current_positions[fill.symbol] += fill_dir * fill.quantity
update_holdings_from_fill
update_holdings_from_fill(fill: FillEvent) -> None

Takes a Fill object and updates the holdings matrix to reflect the holdings value.

Parameters:

Name Type Description Default
fill FillEvent

The Fill object to update the holdings with.

required
Source code in src/bbstrader/btengine/portfolio.py
def update_holdings_from_fill(self, fill: FillEvent) -> None:
    """
    Takes a Fill object and updates the holdings matrix to
    reflect the holdings value.

    Args:
        fill (FillEvent): The Fill object to update the holdings with.
    """
    # Check whether the fill is a buy or sell
    fill_dir = 0
    if fill.direction == "BUY":
        fill_dir = 1
    if fill.direction == "SELL":
        fill_dir = -1

    price = (
        fill.fill_cost
        if fill.fill_cost is not None
        else self._get_price(fill.symbol)
    )
    cost = fill_dir * price * fill.quantity
    self.current_holdings[fill.symbol] += cost
    self.current_holdings["Commission"] += fill.commission
    self.current_holdings["Cash"] -= cost + fill.commission
    self.current_holdings["Total"] -= cost + fill.commission
update_fill
update_fill(event: FillEvent) -> None

Updates the portfolio current positions and holdings from a FillEvent.

Source code in src/bbstrader/btengine/portfolio.py
def update_fill(self, event: FillEvent) -> None:
    """
    Updates the portfolio current positions and holdings
    from a FillEvent.
    """
    if event.type == Events.FILL:
        self.update_positions_from_fill(event)
        self.update_holdings_from_fill(event)
generate_order
generate_order(signal: SignalEvent) -> Optional[OrderEvent]

Turns a SignalEvent into an OrderEvent.

Parameters:

Name Type Description Default
signal SignalEvent

The tuple containing Signal information.

required

Returns:

Name Type Description
OrderEvent Optional[OrderEvent]

The OrderEvent to be executed.

Source code in src/bbstrader/btengine/portfolio.py
def generate_order(self, signal: SignalEvent) -> Optional[OrderEvent]:
    """
    Turns a SignalEvent into an OrderEvent.

    Args:
        signal (SignalEvent): The tuple containing Signal information.

    Returns:
        OrderEvent: The OrderEvent to be executed.
    """
    order = None

    symbol = signal.symbol
    direction = signal.signal_type
    quantity = signal.quantity
    strength = signal.strength
    price = signal.price or self._get_price(symbol)
    cur_quantity = self.current_positions[symbol]
    mkt_quantity = round(float(quantity) * float(strength), 2)
    new_quantity = mkt_quantity * self._leverage

    if direction in ["LONG", "SHORT", "EXIT"]:
        order_type = "MKT"
    else:
        order_type = direction

    if direction == "LONG" and new_quantity > 0:
        order = OrderEvent(
            symbol, order_type, new_quantity, "BUY", price, direction
        )
    if direction == "SHORT" and new_quantity > 0:
        order = OrderEvent(
            symbol, order_type, new_quantity, "SELL", price, direction
        )

    if direction == "EXIT" and cur_quantity > 0:
        order = OrderEvent(
            symbol, order_type, abs(cur_quantity), "SELL", price, direction
        )
    if direction == "EXIT" and cur_quantity < 0:
        order = OrderEvent(
            symbol, order_type, abs(cur_quantity), "BUY", price, direction
        )

    return order
update_signal
update_signal(event: SignalEvent) -> None

Acts on a SignalEvent to generate new orders based on the portfolio logic.

Source code in src/bbstrader/btengine/portfolio.py
def update_signal(self, event: SignalEvent) -> None:
    """
    Acts on a SignalEvent to generate new orders
    based on the portfolio logic.
    """
    if event.type == Events.SIGNAL:
        order_event = self.generate_order(event)
        self.events.put(order_event)
create_equity_curve_dataframe
create_equity_curve_dataframe() -> None

Creates a pandas DataFrame from the all_holdings list of dictionaries.

Source code in src/bbstrader/btengine/portfolio.py
def create_equity_curve_dataframe(self) -> None:
    """
    Creates a pandas DataFrame from the all_holdings
    list of dictionaries.
    """
    # Drop any unused preallocated tail (None slots) before building the frame.
    curve = pd.DataFrame([h for h in self.all_holdings if h is not None])
    curve["Datetime"] = pd.to_datetime(curve["Datetime"], utc=True)
    curve.set_index("Datetime", inplace=True)
    curve["Returns"] = curve["Total"].pct_change(fill_method=None)
    curve["Equity Curve"] = (1.0 + curve["Returns"]).cumprod()
    self.equity_curve = curve
output_summary_stats
output_summary_stats() -> List[Any]

Creates a list of summary statistics for the portfolio.

Source code in src/bbstrader/btengine/portfolio.py
def output_summary_stats(self) -> List[Any]:
    """
    Creates a list of summary statistics for the portfolio.
    """
    if self.equity_curve is None:
        self.create_equity_curve_dataframe()

    total_return = self.equity_curve["Equity Curve"].iloc[-1]  # type: ignore
    returns = self.equity_curve["Returns"]  # type: ignore
    pnl = self.equity_curve["Equity Curve"]  # type: ignore

    sharpe_ratio = create_sharpe_ratio(returns, periods=self.tf)
    sortino_ratio = create_sortino_ratio(returns, periods=self.tf)
    drawdown, _, _ = create_drawdowns(pnl)
    drawdown = drawdown.fillna(0.0)
    max_dd = qs.stats.max_drawdown(returns)
    dd_details = qs.stats.drawdown_details(drawdown)
    if dd_details.empty:
        dd_duration = 0
    else:
        dd_duration = dd_details["days"].max()
    self.equity_curve["Drawdown"] = drawdown

    stats = [
        ("Total Return", f"{(total_return - 1.0) * 100.0:.2f}%"),
        ("Sharpe Ratio", f"{sharpe_ratio:.2f}"),
        ("Sortino Ratio", f"{sortino_ratio:.2f}"),
        ("Max Drawdown", f"{max_dd * 100.0:.2f}%"),
        ("Drawdown Duration", f"{dd_duration}"),
    ]
    now = datetime.now().strftime("%Y%m%d%H%M%S")
    strategy_name = self.strategy_name.replace(" ", "_")
    if self.output_dir:
        results_dir = Path(self.output_dir) / strategy_name
    else:
        results_dir = Path(".backtests") / strategy_name
    results_dir.mkdir(parents=True, exist_ok=True)

    csv_file = f"{strategy_name}_{now}_equities.csv"
    png_file = f"{strategy_name}_{now}_returns_heatmap.png"
    html_file = f"{strategy_name}_{now}_report.html"
    self.equity_curve.to_csv(results_dir / csv_file)

    if self.print_stats:
        plot_performance(self.equity_curve, self.strategy_name)
        plot_returns_and_dd(self.equity_curve, self.benchmark, self.strategy_name)
        qs.plots.monthly_heatmap(returns, savefig=f"{results_dir}/{png_file}")
        plot_monthly_yearly_returns(self.equity_curve, self.strategy_name)
        show_qs_stats(
            returns,
            self.benchmark,
            self.strategy_name,
            save_dir=f"{results_dir}/{html_file}",
        )

    return stats

strategy

BacktestStrategy

BacktestStrategy(events: Queue[Union[SignalEvent, FillEvent]], symbol_list: List[str], bars: DataHandler, **kwargs: Any)

Bases: BaseStrategy

Strategy implementation specifically for Backtesting. Handles internal state for orders, positions, trades, and cash. Simulates order execution and pending orders.

Initialize the BacktestStrategy object.

Parameters:

Name Type Description Default
events

The event queue.

required
symbol_list

The list of symbols for the strategy.

required
bars

The data handler object.

required
**kwargs

Additional keyword arguments for other classes (e.g, Portfolio, ExecutionHandler). - max_trades : The maximum number of trades allowed per symbol. - time_frame : The time frame for the strategy. - logger : The logger object for the strategy.

required
Source code in src/bbstrader/btengine/strategy.py
def __init__(
    self,
    events: "Queue[Union[SignalEvent, FillEvent]]",
    symbol_list: List[str],
    bars: DataHandler,
    **kwargs: Any,
) -> None:
    """
    Initialize the `BacktestStrategy` object.

    Args:
        events : The event queue.
        symbol_list : The list of symbols for the strategy.
        bars : The data handler object.
        **kwargs : Additional keyword arguments for other classes (e.g, Portfolio, ExecutionHandler).
            - max_trades : The maximum number of trades allowed per symbol.
            - time_frame : The time frame for the strategy.
            - logger : The logger object for the strategy.
    """
    super().__init__(symbol_list, **kwargs)
    self.events = events
    self.data = bars
    self.mode = TradingMode.BACKTEST
    self._portfolio_value = None

    self._intrabar_fills = bool(kwargs.get("intrabar_fills", False))
    self._initialize_portfolio()
cash property writable
cash: float

The latest portfolio value (cash) reported by the engine.

orders property
orders: Dict[str, Dict[str, List[SignalEvent]]]

The pending orders per symbol, keyed by order type.

trades property
trades: Dict[str, Dict[str, int]]

The executed trade counts per symbol, keyed by side.

positions property
positions: Dict[str, Dict[str, Union[int, float]]]

The open position sizes per symbol, keyed by LONG/SHORT.

holdings property
holdings: Dict[str, float]

The current mark-to-market holdings value per symbol.

get_update_from_portfolio
get_update_from_portfolio(positions: Dict[str, float], holdings: Dict[str, float]) -> None

Update the positions and holdings for the strategy from the portfolio.

Positions are the number of shares of a security that are owned in long or short. Holdings are the value (postions * price) of the security that are owned in long or short.

Parameters:

Name Type Description Default
positions

The positions for the symbols in the strategy.

required
holdings

The holdings for the symbols in the strategy.

required
Source code in src/bbstrader/btengine/strategy.py
def get_update_from_portfolio(
    self, positions: Dict[str, float], holdings: Dict[str, float]
) -> None:
    """
    Update the positions and holdings for the strategy from the portfolio.

    Positions are the number of shares of a security that are owned in long or short.
    Holdings are the value (postions * price) of the security that are owned in long or short.

    Args:
        positions : The positions for the symbols in the strategy.
        holdings : The holdings for the symbols in the strategy.
    """
    for symbol in self.symbols:
        if symbol in positions:
            if positions[symbol] > 0:
                self._positions[symbol]["LONG"] = positions[symbol]
            elif positions[symbol] < 0:
                self._positions[symbol]["SHORT"] = positions[symbol]
            else:
                self._positions[symbol]["LONG"] = 0
                self._positions[symbol]["SHORT"] = 0
        if symbol in holdings:
            self._holdings[symbol] = holdings[symbol]
update_trades_from_fill
update_trades_from_fill(event: FillEvent) -> None

This method updates the trades for the strategy based on the fill event. It is used to keep track of the number of trades executed for each order.

Source code in src/bbstrader/btengine/strategy.py
def update_trades_from_fill(self, event: FillEvent) -> None:
    """
    This method updates the trades for the strategy based on the fill event.
    It is used to keep track of the number of trades executed for each order.
    """
    if event.type == Events.FILL:
        if event.order != "EXIT":
            self._trades[event.symbol][event.order] += 1  # type: ignore
        elif event.order == "EXIT" and event.direction == "BUY":
            self._trades[event.symbol]["SHORT"] = 0
        elif event.order == "EXIT" and event.direction == "SELL":
            self._trades[event.symbol]["LONG"] = 0
get_asset_values
get_asset_values(symbol_list: List[str], window: int, value_type: str = 'returns', array: bool = True, **kwargs) -> Optional[Dict[str, Union[np.typing.NDArray, pd.Series]]]

Return the last window values of value_type for each symbol.

Parameters:

Name Type Description Default
symbol_list List[str]

The symbols to fetch values for.

required
window int

The number of most-recent bars required per symbol.

required
value_type str

The bar field to read (for example "returns", "close", "high").

'returns'
array bool

When True return NumPy arrays (NaNs dropped); when False return pandas Series sliced from the bar DataFrame.

True
kwargs

Unused; accepted for forward compatibility.

{}

Returns:

Type Description
Optional[Dict[str, Union[NDArray, Series]]]

Optional[Dict[str, Union[NDArray, pd.Series]]]: A mapping of symbol

Optional[Dict[str, Union[NDArray, Series]]]

to its last window values, or None if any symbol has fewer than

Optional[Dict[str, Union[NDArray, Series]]]

window values available.

Source code in src/bbstrader/btengine/strategy.py
def get_asset_values(
    self,
    symbol_list: List[str],
    window: int,
    value_type: str = "returns",
    array: bool = True,
    **kwargs,
) -> Optional[Dict[str, Union[np.typing.NDArray, pd.Series]]]:
    """Return the last ``window`` values of ``value_type`` for each symbol.

    Args:
        symbol_list (List[str]): The symbols to fetch values for.
        window (int): The number of most-recent bars required per symbol.
        value_type (str): The bar field to read (for example ``"returns"``,
            ``"close"``, ``"high"``).
        array (bool): When True return NumPy arrays (NaNs dropped); when
            False return pandas Series sliced from the bar DataFrame.
        kwargs: Unused; accepted for forward compatibility.

    Returns:
        Optional[Dict[str, Union[NDArray, pd.Series]]]: A mapping of symbol
        to its last ``window`` values, or None if any symbol has fewer than
        ``window`` values available.
    """
    asset_values = {}
    for asset in symbol_list:
        if array:
            values = self.data.get_latest_bars_values(asset, value_type, N=window)
            asset_values[asset] = values[~np.isnan(values)]
        else:
            values_df = self.data.get_latest_bars(asset, N=window)
            if isinstance(values_df, pd.DataFrame):
                asset_values[asset] = values_df[value_type]

    if all(len(values) >= window for values in asset_values.values()):
        return {a: v[-window:] for a, v in asset_values.items()}
    return None
calculate_signals abstractmethod
calculate_signals(event: MarketEvent) -> None

Compute trading signals for the current bar.

Subclasses implement their strategy logic here, placing orders via the buy_mkt/sell_mkt/close_positions helpers.

Parameters:

Name Type Description Default
event MarketEvent

The market event for the current bar.

required
Source code in src/bbstrader/btengine/strategy.py
@abstractmethod
def calculate_signals(self, event: MarketEvent) -> None:
    """Compute trading signals for the current bar.

    Subclasses implement their strategy logic here, placing orders via the
    ``buy_mkt``/``sell_mkt``/``close_positions`` helpers.

    Args:
        event (MarketEvent): The market event for the current bar.
    """
    ...
buy_mkt
buy_mkt(id: int, symbol: str, price: float, quantity: int, strength: float = 1.0, dtime: Optional[Union[datetime, Timestamp]] = None) -> None

Open a long position

See bbstrader.btengine.event.SignalEvent for more details on arguments.

Source code in src/bbstrader/btengine/strategy.py
def buy_mkt(
    self,
    id: int,
    symbol: str,
    price: float,
    quantity: int,
    strength: float = 1.0,
    dtime: Optional[Union[datetime, pd.Timestamp]] = None,
) -> None:
    """
    Open a long position

    See `bbstrader.btengine.event.SignalEvent` for more details on arguments.
    """
    if dtime is None:
        dtime = self.get_current_dt()
    self._send_order(id, symbol, "LONG", strength, price, quantity, dtime)
sell_mkt
sell_mkt(id: int, symbol: str, price: float, quantity: int, strength: float = 1.0, dtime: Optional[Union[datetime, Timestamp]] = None) -> None

Open a short position

See bbstrader.btengine.event.SignalEvent for more details on arguments.

Source code in src/bbstrader/btengine/strategy.py
def sell_mkt(
    self,
    id: int,
    symbol: str,
    price: float,
    quantity: int,
    strength: float = 1.0,
    dtime: Optional[Union[datetime, pd.Timestamp]] = None,
) -> None:
    """
    Open a short position

    See `bbstrader.btengine.event.SignalEvent` for more details on arguments.
    """
    if dtime is None:
        dtime = self.get_current_dt()
    self._send_order(id, symbol, "SHORT", strength, price, quantity, dtime)
close_positions
close_positions(id: int, symbol: str, price: float, quantity: int, strength: float = 1.0, dtime: Optional[Union[datetime, Timestamp]] = None) -> None

Close a position or exit all positions

See bbstrader.btengine.event.SignalEvent for more details on arguments.

Source code in src/bbstrader/btengine/strategy.py
def close_positions(
    self,
    id: int,
    symbol: str,
    price: float,
    quantity: int,
    strength: float = 1.0,
    dtime: Optional[Union[datetime, pd.Timestamp]] = None,
) -> None:
    """
    Close a position or exit all positions

    See `bbstrader.btengine.event.SignalEvent` for more details on arguments.
    """
    if dtime is None:
        dtime = self.get_current_dt()
    self._send_order(id, symbol, "EXIT", strength, price, quantity, dtime)
buy_stop
buy_stop(id: int, symbol: str, price: float, quantity: int, strength: float = 1.0, dtime: Optional[Union[datetime, Timestamp]] = None) -> None

Open a pending order to buy at a stop price

See bbstrader.btengine.event.SignalEvent for more details on arguments.

Source code in src/bbstrader/btengine/strategy.py
def buy_stop(
    self,
    id: int,
    symbol: str,
    price: float,
    quantity: int,
    strength: float = 1.0,
    dtime: Optional[Union[datetime, pd.Timestamp]] = None,
) -> None:
    """
    Open a pending order to buy at a stop price

    See `bbstrader.btengine.event.SignalEvent` for more details on arguments.
    """
    current_price = self.data.get_latest_bar_value(symbol, "close")
    if price <= current_price:
        raise ValueError(
            "The buy_stop price must be greater than the current price."
        )
    if dtime is None:
        dtime = self.get_current_dt()
    order = SignalEvent(
        id,
        symbol,
        dtime,
        "LONG",
        quantity=quantity,
        strength=strength,
        price=price,  # type: ignore
    )
    self._orders[symbol]["BSTP"].append(order)
sell_stop
sell_stop(id: int, symbol: str, price: float, quantity: int, strength: float = 1.0, dtime: Optional[Union[datetime, Timestamp]] = None) -> None

Open a pending order to sell at a stop price

See bbstrader.btengine.event.SignalEvent for more details on arguments.

Source code in src/bbstrader/btengine/strategy.py
def sell_stop(
    self,
    id: int,
    symbol: str,
    price: float,
    quantity: int,
    strength: float = 1.0,
    dtime: Optional[Union[datetime, pd.Timestamp]] = None,
) -> None:
    """
    Open a pending order to sell at a stop price

    See `bbstrader.btengine.event.SignalEvent` for more details on arguments.
    """
    current_price = self.data.get_latest_bar_value(symbol, "close")
    if price >= current_price:
        raise ValueError("The sell_stop price must be less than the current price.")
    if dtime is None:
        dtime = self.get_current_dt()
    order = SignalEvent(
        id,
        symbol,
        dtime,  # type: ignore
        "SHORT",
        quantity=quantity,
        strength=strength,
        price=price,
    )
    self._orders[symbol]["SSTP"].append(order)
buy_limit
buy_limit(id: int, symbol: str, price: float, quantity: int, strength: float = 1.0, dtime: Optional[Union[datetime, Timestamp]] = None) -> None

Open a pending order to buy at a limit price

See bbstrader.btengine.event.SignalEvent for more details on arguments.

Source code in src/bbstrader/btengine/strategy.py
def buy_limit(
    self,
    id: int,
    symbol: str,
    price: float,
    quantity: int,
    strength: float = 1.0,
    dtime: Optional[Union[datetime, pd.Timestamp]] = None,
) -> None:
    """
    Open a pending order to buy at a limit price

    See `bbstrader.btengine.event.SignalEvent` for more details on arguments.
    """
    current_price = self.data.get_latest_bar_value(symbol, "close")
    if price >= current_price:
        raise ValueError("The buy_limit price must be less than the current price.")
    if dtime is None:
        dtime = self.get_current_dt()
    order = SignalEvent(
        id,
        symbol,
        dtime,
        "LONG",
        quantity=quantity,
        strength=strength,
        price=price,  # type: ignore
    )
    self._orders[symbol]["BLMT"].append(order)
sell_limit
sell_limit(id: int, symbol: str, price: float, quantity: int, strength: float = 1.0, dtime: Optional[Union[datetime, Timestamp]] = None) -> None

Open a pending order to sell at a limit price

See bbstrader.btengine.event.SignalEvent for more details on arguments.

Source code in src/bbstrader/btengine/strategy.py
def sell_limit(
    self,
    id: int,
    symbol: str,
    price: float,
    quantity: int,
    strength: float = 1.0,
    dtime: Optional[Union[datetime, pd.Timestamp]] = None,
) -> None:
    """
    Open a pending order to sell at a limit price

    See `bbstrader.btengine.event.SignalEvent` for more details on arguments.
    """
    current_price = self.data.get_latest_bar_value(symbol, "close")
    if price <= current_price:
        raise ValueError(
            "The sell_limit price must be greater than the current price."
        )
    if dtime is None:
        dtime = self.get_current_dt()
    order = SignalEvent(
        id,
        symbol,
        dtime,  # type: ignore
        "SHORT",
        quantity=quantity,
        strength=strength,
        price=price,
    )
    self._orders[symbol]["SLMT"].append(order)
buy_stop_limit
buy_stop_limit(id: int, symbol: str, price: float, stoplimit: float, quantity: int, strength: float = 1.0, dtime: Optional[Union[datetime, Timestamp]] = None) -> None

Open a pending order to buy at a stop-limit price

See bbstrader.btengine.event.SignalEvent for more details on arguments.

Source code in src/bbstrader/btengine/strategy.py
def buy_stop_limit(
    self,
    id: int,
    symbol: str,
    price: float,
    stoplimit: float,
    quantity: int,
    strength: float = 1.0,
    dtime: Optional[Union[datetime, pd.Timestamp]] = None,
) -> None:
    """
    Open a pending order to buy at a stop-limit price

    See `bbstrader.btengine.event.SignalEvent` for more details on arguments.
    """
    current_price = self.data.get_latest_bar_value(symbol, "close")
    if price <= current_price:
        raise ValueError(
            f"The stop price {price} must be greater than the current price {current_price}."
        )
    if price >= stoplimit:
        raise ValueError(
            f"The stop-limit price {stoplimit} must be greater than the price {price}."
        )
    if dtime is None:
        dtime = self.get_current_dt()
    order = SignalEvent(
        id,
        symbol,
        dtime,  # type: ignore
        "LONG",
        quantity=quantity,
        strength=strength,
        price=price,
        stoplimit=stoplimit,
    )
    self._orders[symbol]["BSTPLMT"].append(order)
sell_stop_limit
sell_stop_limit(id: int, symbol: str, price: float, stoplimit: float, quantity: int, strength: float = 1.0, dtime: Optional[Union[datetime, Timestamp]] = None) -> None

Open a pending order to sell at a stop-limit price

See bbstrader.btengine.event.SignalEvent for more details on arguments.

Source code in src/bbstrader/btengine/strategy.py
def sell_stop_limit(
    self,
    id: int,
    symbol: str,
    price: float,
    stoplimit: float,
    quantity: int,
    strength: float = 1.0,
    dtime: Optional[Union[datetime, pd.Timestamp]] = None,
) -> None:
    """
    Open a pending order to sell at a stop-limit price

    See `bbstrader.btengine.event.SignalEvent` for more details on arguments.
    """
    current_price = self.data.get_latest_bar_value(symbol, "close")
    if price >= current_price:
        raise ValueError(
            f"The stop price {price} must be less than the current price {current_price}."
        )
    if price <= stoplimit:
        raise ValueError(
            f"The stop-limit price {stoplimit} must be less than the price {price}."
        )
    if dtime is None:
        dtime = self.get_current_dt()
    order = SignalEvent(
        id,
        symbol,
        dtime,  # type: ignore
        "SHORT",
        quantity=quantity,
        strength=strength,
        price=price,
        stoplimit=stoplimit,
    )
    self._orders[symbol]["SSTPLMT"].append(order)
check_pending_orders
check_pending_orders() -> None

Check for pending orders and handle them accordingly.

Source code in src/bbstrader/btengine/strategy.py
def check_pending_orders(self) -> None:
    """
    Check for pending orders and handle them accordingly.
    """

    def logmsg(
        order: SignalEvent,
        type: str,
        symbol: str,
        dtime: Union[datetime, pd.Timestamp],
    ) -> None:
        """Log a triggered pending order at INFO level.

        Args:
            order (SignalEvent): The pending order that was triggered.
            type (str): A label for the order type, used in the message.
            symbol (str): The instrument the order is for.
            dtime (Union[datetime, pd.Timestamp]): The trigger bar timestamp.
        """
        self.logger.info(
            f"{type} ORDER EXECUTED: SYMBOL={symbol}, QUANTITY={order.quantity}, "
            f"PRICE @ {round(order.price, 5)}",  # type: ignore
            custom_time=dtime,
        )

    def process_orders(
        order_type: str,
        condition: Callable[[SignalEvent], bool],
        execute_fn: Callable[[SignalEvent], None],
        log_label: str,
        symbol: str,
        dtime: Union[datetime, pd.Timestamp],
    ) -> None:
        """Trigger and remove pending orders of one type whose condition holds.

        Args:
            order_type (str): The pending-order bucket to scan (for example
                ``"BLMT"``, ``"SSTP"``).
            condition (Callable[[SignalEvent], bool]): Predicate deciding
                whether an order should trigger this bar.
            execute_fn (Callable[[SignalEvent], None]): Callback that turns a
                triggered order into a market order.
            log_label (str): Label passed to :func:`logmsg` for the fill log.
            symbol (str): The instrument whose orders are processed.
            dtime (Union[datetime, pd.Timestamp]): The current bar timestamp.
        """
        for order in self._orders[symbol][order_type].copy():
            if condition(order):
                execute_fn(order)
                try:
                    self._orders[symbol][order_type].remove(order)
                    assert order not in self._orders[symbol][order_type]
                except AssertionError:
                    self._orders[symbol][order_type] = [
                        o for o in self._orders[symbol][order_type] if o != order
                    ]
                logmsg(order, log_label, symbol, dtime)

    for symbol in self.symbols:
        dtime = self.data.get_latest_bar_datetime(symbol)
        latest_close = self.data.get_latest_bar_value(symbol, "close")

        if self._intrabar_fills:
            up_ref = self.data.get_latest_bar_value(symbol, "high")
            down_ref = self.data.get_latest_bar_value(symbol, "low")
        else:
            up_ref = down_ref = latest_close

        process_orders(
            "BLMT",
            lambda o: down_ref <= o.price,  # type: ignore
            lambda o: self.buy_mkt(
                o.strategy_id,
                symbol,
                o.price,
                o.quantity,
                dtime=dtime,  # type: ignore
            ),
            "BUY LIMIT",
            symbol,
            dtime,
        )

        process_orders(
            "SLMT",
            lambda o: up_ref >= o.price,  # type: ignore
            lambda o: self.sell_mkt(
                o.strategy_id,
                symbol,
                o.price,
                o.quantity,
                dtime=dtime,  # type: ignore
            ),
            "SELL LIMIT",
            symbol,
            dtime,
        )

        process_orders(
            "BSTP",
            lambda o: up_ref >= o.price,  # type: ignore
            lambda o: self.buy_mkt(
                o.strategy_id,
                symbol,
                o.price,
                o.quantity,
                dtime=dtime,  # type: ignore
            ),
            "BUY STOP",
            symbol,
            dtime,
        )

        process_orders(
            "SSTP",
            lambda o: down_ref <= o.price,  # type: ignore
            lambda o: self.sell_mkt(
                o.strategy_id,
                symbol,
                o.price,
                o.quantity,
                dtime=dtime,  # type: ignore
            ),
            "SELL STOP",
            symbol,
            dtime,
        )

        process_orders(
            "BSTPLMT",
            lambda o: up_ref >= o.price,  # type: ignore
            lambda o: self.buy_limit(
                o.strategy_id,
                symbol,
                o.stoplimit,
                o.quantity,
                dtime=dtime,  # type: ignore
            ),
            "BUY STOP LIMIT",
            symbol,
            dtime,
        )

        process_orders(
            "SSTPLMT",
            lambda o: down_ref <= o.price,  # type: ignore
            lambda o: self.sell_limit(
                o.strategy_id,
                symbol,
                o.stoplimit,
                o.quantity,
                dtime=dtime,  # type: ignore
            ),
            "SELL STOP LIMIT",
            symbol,
            dtime,
        )

MultiStrategy

MultiStrategy(strategies: List[BacktestStrategy])

Runs several strategies against one shared portfolio, cash account and clock.

The engine sees a single strategy; this adapter fans every engine callback out to each child strategy. All children post signals to the same event queue, so the shared Portfolio nets their positions and allocates one pool of capital enabling cross-strategy capital-allocation and netting tests that a single-strategy engine cannot express.

Children are typically scoped to disjoint symbol sets; when they overlap, positions net at the portfolio level and each child's trade counters track its own fills for symbols it trades.

Wrap one or more child strategies behind a single engine interface.

Parameters:

Name Type Description Default
strategies List[BacktestStrategy]

The child strategies to run against the shared portfolio. The union of their symbols becomes this adapter's symbol set.

required

Raises:

Type Description
ValueError

If strategies is empty.

Source code in src/bbstrader/btengine/strategy.py
def __init__(self, strategies: List["BacktestStrategy"]) -> None:
    """Wrap one or more child strategies behind a single engine interface.

    Args:
        strategies (List[BacktestStrategy]): The child strategies to run
            against the shared portfolio. The union of their symbols becomes
            this adapter's symbol set.

    Raises:
        ValueError: If ``strategies`` is empty.
    """
    if not strategies:
        raise ValueError("MultiStrategy requires at least one strategy.")
    self.strategies = list(strategies)
    self.symbols = sorted({s for st in self.strategies for s in st.symbols})
cash property writable
cash: float

The shared portfolio cash, read from the first child strategy.

calculate_signals
calculate_signals(event: MarketEvent) -> None

Fan the market event out to every child strategy.

Parameters:

Name Type Description Default
event MarketEvent

The market event for the current bar.

required
Source code in src/bbstrader/btengine/strategy.py
def calculate_signals(self, event: MarketEvent) -> None:
    """Fan the market event out to every child strategy.

    Args:
        event (MarketEvent): The market event for the current bar.
    """
    for st in self.strategies:
        st.calculate_signals(event)
check_pending_orders
check_pending_orders() -> None

Ask every child strategy to evaluate its pending orders.

Source code in src/bbstrader/btengine/strategy.py
def check_pending_orders(self) -> None:
    """Ask every child strategy to evaluate its pending orders."""
    for st in self.strategies:
        st.check_pending_orders()
get_update_from_portfolio
get_update_from_portfolio(positions: Dict[str, float], holdings: Dict[str, float]) -> None

Push the latest portfolio positions and holdings to each child.

Parameters:

Name Type Description Default
positions Dict[str, float]

Current position sizes per symbol.

required
holdings Dict[str, float]

Current holdings value per symbol.

required
Source code in src/bbstrader/btengine/strategy.py
def get_update_from_portfolio(
    self, positions: Dict[str, float], holdings: Dict[str, float]
) -> None:
    """Push the latest portfolio positions and holdings to each child.

    Args:
        positions (Dict[str, float]): Current position sizes per symbol.
        holdings (Dict[str, float]): Current holdings value per symbol.
    """
    for st in self.strategies:
        st.get_update_from_portfolio(positions, holdings)
update_trades_from_fill
update_trades_from_fill(event: FillEvent) -> None

Route a fill to the child strategies that trade its symbol.

Parameters:

Name Type Description Default
event FillEvent

The fill to apply to matching child strategies.

required
Source code in src/bbstrader/btengine/strategy.py
def update_trades_from_fill(self, event: FillEvent) -> None:
    """Route a fill to the child strategies that trade its symbol.

    Args:
        event (FillEvent): The fill to apply to matching child strategies.
    """
    for st in self.strategies:
        if event.symbol in st.symbols:
            st.update_trades_from_fill(event)

templates

Ready-to-use strategy templates (a small cookbook).

These are concrete, parameterized BacktestStrategy subclasses for the most common archetypes: trend following (SMA crossover), mean reversion (RSI), and breakout (Donchian channel). They are built entirely on the shared strategy API -- get_asset_values for data, the vectorized :mod:bbstrader.core.indicators for signals, and the buy_mkt/close_positions order helpers so they are also natural targets for :func:bbstrader.btengine.optimize.optimize.

Each template trades a single long position per symbol and is long-only, which keeps them simple to read and to optimize. Subclass or copy them as a starting point for your own ideas.

SMACrossoverStrategy

SMACrossoverStrategy(events, symbol_list, bars, **kwargs)

Bases: _TemplateBase

Trend following: go long when the fast SMA crosses above the slow SMA.

kwargs

fast (int, default 10): Fast SMA window. slow (int, default 30): Slow SMA window. quantity (int, default 100): Units per trade.

Initialise the SMA crossover with fast/slow windows.

Parameters:

Name Type Description Default
events Any

The engine event queue.

required
symbol_list List[str]

The symbols traded by the strategy.

required
bars Any

The DataHandler providing market data.

required
kwargs Any

fast (default 10) and slow (default 30) SMA windows, plus the shared template options.

{}

Raises:

Type Description
ValueError

If fast is not strictly less than slow.

Source code in src/bbstrader/btengine/templates.py
def __init__(self, events, symbol_list, bars, **kwargs) -> None:
    """Initialise the SMA crossover with fast/slow windows.

    Args:
        events (Any): The engine event queue.
        symbol_list (List[str]): The symbols traded by the strategy.
        bars (Any): The DataHandler providing market data.
        kwargs (Any): ``fast`` (default 10) and ``slow`` (default 30) SMA
            windows, plus the shared template options.

    Raises:
        ValueError: If ``fast`` is not strictly less than ``slow``.
    """
    super().__init__(events, symbol_list, bars, **kwargs)
    self.fast = int(kwargs.get("fast", 10))
    self.slow = int(kwargs.get("slow", 30))
    if self.fast >= self.slow:
        raise ValueError(f"fast ({self.fast}) must be < slow ({self.slow}).")
calculate_signals
calculate_signals(event: MarketEvent) -> None

Enter long on an up-cross and exit on a down-cross of the SMAs.

Parameters:

Name Type Description Default
event MarketEvent

The market event driving the bar; ignored unless it is a MARKET event.

required
Source code in src/bbstrader/btengine/templates.py
def calculate_signals(self, event: MarketEvent) -> None:
    """Enter long on an up-cross and exit on a down-cross of the SMAs.

    Args:
        event (MarketEvent): The market event driving the bar; ignored unless
            it is a MARKET event.
    """
    if event.type != Events.MARKET:
        return
    for symbol in self.symbols:
        # Need one extra bar so we can see the cross (current vs previous).
        arr = self._closes(symbol, self.slow + 1)
        if arr is None:
            continue
        fast = ind.sma(arr, self.fast)
        slow = ind.sma(arr, self.slow)
        price = float(arr[-1])
        dt = self.data.get_latest_bar_datetime(symbol)
        crossed_up = fast[-2] <= slow[-2] and fast[-1] > slow[-1]
        crossed_down = fast[-2] >= slow[-2] and fast[-1] < slow[-1]
        if crossed_up and not self._is_long(symbol):
            self.buy_mkt(
                self.strategy_id, symbol, price, self.qty[symbol], dtime=dt
            )
        elif crossed_down and self._is_long(symbol):
            self.close_positions(
                self.strategy_id, symbol, price, self.qty[symbol], dtime=dt
            )

RSIMeanReversionStrategy

RSIMeanReversionStrategy(events, symbol_list, bars, **kwargs)

Bases: _TemplateBase

Mean reversion: buy when RSI is oversold, exit when it recovers.

kwargs

period (int, default 14): RSI lookback. oversold (float, default 30): Entry threshold. exit_level (float, default 55): Exit threshold. quantity (int, default 100): Units per trade.

Initialise the RSI mean-reversion thresholds.

Parameters:

Name Type Description Default
events Any

The engine event queue.

required
symbol_list List[str]

The symbols traded by the strategy.

required
bars Any

The DataHandler providing market data.

required
kwargs Any

period (RSI lookback, default 14), oversold (entry threshold, default 30) and exit_level (exit threshold, default 55), plus the shared template options.

{}
Source code in src/bbstrader/btengine/templates.py
def __init__(self, events, symbol_list, bars, **kwargs) -> None:
    """Initialise the RSI mean-reversion thresholds.

    Args:
        events (Any): The engine event queue.
        symbol_list (List[str]): The symbols traded by the strategy.
        bars (Any): The DataHandler providing market data.
        kwargs (Any): ``period`` (RSI lookback, default 14), ``oversold``
            (entry threshold, default 30) and ``exit_level`` (exit threshold,
            default 55), plus the shared template options.
    """
    super().__init__(events, symbol_list, bars, **kwargs)
    self.period = int(kwargs.get("period", 14))
    self.oversold = float(kwargs.get("oversold", 30.0))
    self.exit_level = float(kwargs.get("exit_level", 55.0))
calculate_signals
calculate_signals(event: MarketEvent) -> None

Buy when RSI is oversold and exit when it recovers above the level.

Parameters:

Name Type Description Default
event MarketEvent

The market event driving the bar; ignored unless it is a MARKET event.

required
Source code in src/bbstrader/btengine/templates.py
def calculate_signals(self, event: MarketEvent) -> None:
    """Buy when RSI is oversold and exit when it recovers above the level.

    Args:
        event (MarketEvent): The market event driving the bar; ignored unless
            it is a MARKET event.
    """
    if event.type != Events.MARKET:
        return
    for symbol in self.symbols:
        arr = self._closes(symbol, self.period + 2)
        if arr is None:
            continue
        rsi = ind.rsi(arr, self.period)
        latest = rsi[-1]
        if latest != latest:  # NaN guard
            continue
        price = float(arr[-1])
        dt = self.data.get_latest_bar_datetime(symbol)
        if latest <= self.oversold and not self._is_long(symbol):
            self.buy_mkt(
                self.strategy_id, symbol, price, self.qty[symbol], dtime=dt
            )
        elif latest >= self.exit_level and self._is_long(symbol):
            self.close_positions(
                self.strategy_id, symbol, price, self.qty[symbol], dtime=dt
            )

DonchianBreakoutStrategy

DonchianBreakoutStrategy(events, symbol_list, bars, **kwargs)

Bases: _TemplateBase

Breakout: go long when price closes above the prior N-bar high.

The channel is taken from the previous bar to avoid look-ahead. Exit when price closes below the prior N-bar low.

kwargs

window (int, default 20): Donchian channel lookback. quantity (int, default 100): Units per trade.

Initialise the Donchian breakout channel lookback.

Parameters:

Name Type Description Default
events Any

The engine event queue.

required
symbol_list List[str]

The symbols traded by the strategy.

required
bars Any

The DataHandler providing market data.

required
kwargs Any

window (channel lookback, default 20), plus the shared template options.

{}
Source code in src/bbstrader/btengine/templates.py
def __init__(self, events, symbol_list, bars, **kwargs) -> None:
    """Initialise the Donchian breakout channel lookback.

    Args:
        events (Any): The engine event queue.
        symbol_list (List[str]): The symbols traded by the strategy.
        bars (Any): The DataHandler providing market data.
        kwargs (Any): ``window`` (channel lookback, default 20), plus the
            shared template options.
    """
    super().__init__(events, symbol_list, bars, **kwargs)
    self.window = int(kwargs.get("window", 20))
calculate_signals
calculate_signals(event: MarketEvent) -> None

Go long on a close above the prior N-bar high; exit below the low.

Parameters:

Name Type Description Default
event MarketEvent

The market event driving the bar; ignored unless it is a MARKET event.

required
Source code in src/bbstrader/btengine/templates.py
def calculate_signals(self, event: MarketEvent) -> None:
    """Go long on a close above the prior N-bar high; exit below the low.

    Args:
        event (MarketEvent): The market event driving the bar; ignored unless
            it is a MARKET event.
    """
    if event.type != Events.MARKET:
        return
    for symbol in self.symbols:
        highs = self.get_asset_values(
            [symbol], window=self.window + 1, value_type="high"
        )
        lows = self.get_asset_values(
            [symbol], window=self.window + 1, value_type="low"
        )
        closes = self._closes(symbol, self.window + 1)
        if closes is None or not highs or not lows:
            continue
        high = highs.get(symbol)
        low = lows.get(symbol)
        if high is None or low is None or len(high) < self.window + 1:
            continue
        upper = ind.donchian(high, low, self.window)[1]
        lower = ind.donchian(high, low, self.window)[0]
        # Compare current close against the *previous* bar's channel.
        prior_upper = upper[-2]
        prior_lower = lower[-2]
        price = float(closes[-1])
        dt = self.data.get_latest_bar_datetime(symbol)
        if price > prior_upper and not self._is_long(symbol):
            self.buy_mkt(
                self.strategy_id, symbol, price, self.qty[symbol], dtime=dt
            )
        elif price < prior_lower and self._is_long(symbol):
            self.close_positions(
                self.strategy_id, symbol, price, self.qty[symbol], dtime=dt
            )

timeframe

Multi-timeframe support: derive higher-timeframe bars from a base feed.

A common institutional pattern is to execute on a fast timeframe (e.g. 1m) while computing signals on a slower one (e.g. H1 or daily). Rather than rearchitecting the event loop into a full multi-clock model, this module lets a strategy resample the base-timeframe bars it already receives into completed higher-timeframe (HTF) bars on demand inside calculate_signals with no look-ahead.

MultiTimeFrame wraps a DataHandler; resample_ohlcv is the underlying aggregation and can be used standalone on any OHLCV frame.

MultiTimeFrame

MultiTimeFrame(data: DataHandler, lookback: int = 1000)

Derive completed higher-timeframe bars from a base-timeframe DataHandler.

Use inside a strategy's calculate_signals to read slow-timeframe context while executing on the fast base feed::

mtf = MultiTimeFrame(self.data)
daily_close = mtf.htf_value(symbol, "D1")   # last *completed* daily close

Wrap a base-timeframe DataHandler for higher-timeframe access.

Parameters:

Name Type Description Default
data DataHandler

The base-timeframe data feed to resample from.

required
lookback int

Default number of base bars to pull when resampling.

1000
Source code in src/bbstrader/btengine/timeframe.py
def __init__(self, data: DataHandler, lookback: int = 1000) -> None:
    """Wrap a base-timeframe DataHandler for higher-timeframe access.

    Args:
        data (DataHandler): The base-timeframe data feed to resample from.
        lookback (int): Default number of base bars to pull when resampling.
    """
    self.data = data
    self.lookback = lookback
htf_bars
htf_bars(symbol: str, rule: str, n: Optional[int] = None, lookback: Optional[int] = None, drop_partial: bool = True) -> pd.DataFrame

Return resampled HTF bars for symbol.

With drop_partial (default) the final, possibly still-forming bucket is dropped so only completed HTF bars are visible preventing look-ahead. n limits the result to the most recent n bars.

Source code in src/bbstrader/btengine/timeframe.py
def htf_bars(
    self,
    symbol: str,
    rule: str,
    n: Optional[int] = None,
    lookback: Optional[int] = None,
    drop_partial: bool = True,
) -> pd.DataFrame:
    """Return resampled HTF bars for ``symbol``.

    With ``drop_partial`` (default) the final, possibly still-forming bucket
    is dropped so only completed HTF bars are visible preventing
    look-ahead. ``n`` limits the result to the most recent ``n`` bars.
    """
    base = self._base_bars(symbol, lookback)
    res = resample_ohlcv(base, rule)
    if drop_partial and len(res):
        # Always drop the final bucket: it may still be forming, so this
        # guarantees only completed HTF bars are visible (no look-ahead).
        res = res.iloc[:-1]
    return res.tail(n) if n else res
htf_value
htf_value(symbol: str, rule: str, val_type: str = 'close', lookback: Optional[int] = None, drop_partial: bool = True) -> Optional[float]

Latest completed HTF value for symbol (None if not enough data).

Source code in src/bbstrader/btengine/timeframe.py
def htf_value(
    self,
    symbol: str,
    rule: str,
    val_type: str = "close",
    lookback: Optional[int] = None,
    drop_partial: bool = True,
) -> Optional[float]:
    """Latest completed HTF value for ``symbol`` (None if not enough data)."""
    res = self.htf_bars(symbol, rule, lookback=lookback, drop_partial=drop_partial)
    if res.empty or val_type not in res.columns:
        return None
    return float(res[val_type].iloc[-1])

resample_ohlcv

resample_ohlcv(df: DataFrame, rule: str, *, label: str = 'left', closed: str = 'left') -> pd.DataFrame

Aggregate an OHLCV DataFrame up to a higher timeframe.

open=first, high=max, low=min, close=last, volume=sum (adj_close=last when present). Buckets with no data are dropped. df must have a DatetimeIndex.

Source code in src/bbstrader/btengine/timeframe.py
def resample_ohlcv(
    df: pd.DataFrame, rule: str, *, label: str = "left", closed: str = "left"
) -> pd.DataFrame:
    """Aggregate an OHLCV DataFrame up to a higher timeframe.

    open=first, high=max, low=min, close=last, volume=sum (adj_close=last when
    present). Buckets with no data are dropped. ``df`` must have a DatetimeIndex.
    """
    if not isinstance(df.index, pd.DatetimeIndex):
        raise TypeError("resample_ohlcv requires a DatetimeIndex.")
    agg = {}
    for col, how in (
        ("open", "first"),
        ("high", "max"),
        ("low", "min"),
        ("close", "last"),
        ("adj_close", "last"),
        ("volume", "sum"),
    ):
        if col in df.columns:
            agg[col] = how
    if "close" not in agg:
        raise ValueError("OHLCV frame must contain at least a 'close' column.")
    out = df.resample(_rule(rule), label=label, closed=closed).agg(agg)
    return out.dropna(subset=["close"])

vectorized

A vectorized research fast-path backtester.

This is the "does this even have alpha?" loop: it evaluates entry/exit signal arrays across the entire history with NumPy, with no event queue and no path-dependent order state. It is for fast hypothesis screening over many parameter combinations orders of magnitude faster than the event-driven engine not for faithful order-state simulation (use BacktestEngine for that). The two share the same data: feed it the columnar arrays from a DataHandler (or any price series).

Signals are boolean arrays aligned to the price series; an indicator from :mod:bbstrader.core.indicators plugs in directly.

VectorizedResult dataclass

VectorizedResult(equity: NDArray[float64], returns: NDArray[float64], position: NDArray[float64], trades: List[Tuple[int, int]], init_cash: float, periods: int)

Result of a vectorized backtest with lazily computed metrics.

total_return property
total_return: float

The total return over the run as a fraction of initial capital.

num_trades property
num_trades: int

The number of completed round-trip trades.

exposure property
exposure: float

Fraction of bars spent in the market.

sharpe property
sharpe: float

The Sharpe ratio of bar returns, annualised by periods.

max_drawdown property
max_drawdown: float

Largest peak-to-trough drawdown of the equity curve (as a fraction).

win_rate property
win_rate: float

The fraction of trades whose equity rose between entry and exit.

to_frame
to_frame(index: Optional[Index] = None) -> pd.DataFrame

Return the run as a DataFrame of position, returns and equity.

Parameters:

Name Type Description Default
index Optional[Index]

An optional index (for example the price series' DatetimeIndex) to label the rows.

None

Returns:

Type Description
DataFrame

pd.DataFrame: Columns Position, Returns and Equity.

Source code in src/bbstrader/btengine/vectorized.py
def to_frame(self, index: Optional[pd.Index] = None) -> pd.DataFrame:
    """Return the run as a DataFrame of position, returns and equity.

    Args:
        index (Optional[pd.Index]): An optional index (for example the price
            series' DatetimeIndex) to label the rows.

    Returns:
        pd.DataFrame: Columns ``Position``, ``Returns`` and ``Equity``.
    """
    df = pd.DataFrame(
        {"Position": self.position, "Returns": self.returns, "Equity": self.equity}
    )
    if index is not None:
        df.index = index
    return df
summary
summary() -> dict

Return a dict of the headline metrics for the run.

Returns:

Name Type Description
dict dict

total_return, sharpe, max_drawdown, num_trades,

dict

win_rate and exposure.

Source code in src/bbstrader/btengine/vectorized.py
def summary(self) -> dict:
    """Return a dict of the headline metrics for the run.

    Returns:
        dict: ``total_return``, ``sharpe``, ``max_drawdown``, ``num_trades``,
        ``win_rate`` and ``exposure``.
    """
    return {
        "total_return": self.total_return,
        "sharpe": self.sharpe,
        "max_drawdown": self.max_drawdown,
        "num_trades": self.num_trades,
        "win_rate": self.win_rate,
        "exposure": self.exposure,
    }

vectorized_backtest

vectorized_backtest(close: ArrayLike, entries: ArrayLike, exits: ArrayLike, *, short_entries: Optional[ArrayLike] = None, short_exits: Optional[ArrayLike] = None, allow_short: bool = False, init_cash: float = 100000.0, fees: float = 0.0, slippage: float = 0.0, periods: int = 252) -> VectorizedResult

Run a fully vectorized signal backtest.

Parameters:

Name Type Description Default
close ArrayLike

Price series.

required
entries ArrayLike

Boolean array; True opens a long position.

required
exits ArrayLike

Boolean array; True closes the long position.

required
short_entries / short_exits

Optional short-side signals (require allow_short=True).

required
allow_short bool

Permit short positions.

False
init_cash float

Starting capital.

100000.0
fees float

Per-unit-turnover fee as a fraction of notional (e.g. 0.0005).

0.0
slippage float

Per-unit-turnover slippage as a fraction of notional.

0.0
periods int

Annualization factor for the Sharpe ratio.

252

Returns:

Name Type Description
A VectorizedResult

class:VectorizedResult with the equity curve and metrics.

Source code in src/bbstrader/btengine/vectorized.py
def vectorized_backtest(
    close: ArrayLike,
    entries: ArrayLike,
    exits: ArrayLike,
    *,
    short_entries: Optional[ArrayLike] = None,
    short_exits: Optional[ArrayLike] = None,
    allow_short: bool = False,
    init_cash: float = 100000.0,
    fees: float = 0.0,
    slippage: float = 0.0,
    periods: int = 252,
) -> VectorizedResult:
    """Run a fully vectorized signal backtest.

    Args:
        close: Price series.
        entries: Boolean array; True opens a long position.
        exits: Boolean array; True closes the long position.
        short_entries / short_exits: Optional short-side signals (require
            ``allow_short=True``).
        allow_short: Permit short positions.
        init_cash: Starting capital.
        fees: Per-unit-turnover fee as a fraction of notional (e.g. 0.0005).
        slippage: Per-unit-turnover slippage as a fraction of notional.
        periods: Annualization factor for the Sharpe ratio.

    Returns:
        A :class:`VectorizedResult` with the equity curve and metrics.
    """
    price = np.asarray(close, dtype=np.float64)
    if price.ndim != 1:
        raise ValueError("close must be a 1-D price series.")
    n = price.size
    ent = _as_bool(entries, n)
    ext = _as_bool(exits, n)
    sent = _as_bool(short_entries, n)
    sext = _as_bool(short_exits, n)
    if (sent.any() or sext.any()) and not allow_short:
        raise ValueError("short signals provided but allow_short is False.")

    pos = _build_positions(ent, ext, sent, sext, allow_short)

    # Bar returns; position from the previous bar is held into the current bar.
    bar_ret = np.zeros(n, dtype=np.float64)
    bar_ret[1:] = price[1:] / price[:-1] - 1.0
    prev_pos = np.concatenate([[0.0], pos[:-1]])
    gross = prev_pos * bar_ret

    # Trading cost charged on turnover at the bar the position changes.
    turnover = np.abs(pos - prev_pos)
    cost = (fees + slippage) * turnover
    strat_ret = gross - cost

    equity = init_cash * np.cumprod(1.0 + strat_ret)
    trades = _extract_trades(pos)
    return VectorizedResult(
        equity=equity,
        returns=strat_ret,
        position=pos,
        trades=trades,
        init_cash=init_cash,
        periods=periods,
    )