Source code for fynance.backtest.engine

#!/usr/bin/env python3
# coding: utf-8

""" Vectorized backtest engine.

The core artery of the library: turn a position book + asset returns (or
prices) + a cost model into a :class:`~fynance.backtest.result.BacktestResult`.
Pure numpy, strictly causal.

"""

from __future__ import annotations

# Built-in packages
from typing import Any

# Third-party packages
import numpy as np

# Local packages
from fynance.backtest.result import BacktestResult

__all__ = ['backtest']


[docs] def backtest( data: Any, positions: Any, cost: Any = None, capital: float = 1.0, returns_input: bool = True, shift: bool = True, ) -> BacktestResult: """ Run a vectorized backtest. Parameters ---------- data : array-like Asset returns (default) or prices (``returns_input=False``). Shape ``(T,)`` for a single asset or ``(T, n_assets)`` for a book. positions : array-like Position/weight series aligned with ``data``. cost : CostModel, optional Per-step transaction cost model (e.g. :class:`~fynance.backtest.cost.ProportionalCost`). capital : float Initial capital. returns_input : bool If ``True`` (default) ``data`` are returns; otherwise prices (converted to pct returns; the first observation is dropped). shift : bool If ``True`` (default) positions are shifted one step (the position decided at ``t`` earns the return at ``t+1``), guaranteeing causality. Returns ------- BacktestResult Equity, net/gross returns, positions and per-step costs. """ arr = np.asarray(data, dtype=np.float64) pos = np.asarray(positions, dtype=np.float64) if returns_input: returns = arr else: returns = arr[1:] / arr[:-1] - 1.0 if pos.shape[0] == arr.shape[0]: pos = pos[1:] if pos.shape[0] != returns.shape[0]: raise ValueError( f"positions length {pos.shape[0]} != returns length " f"{returns.shape[0]}" ) # Causal shift: position decided at t earns the return at t+1. if shift: pos_eff = np.zeros_like(pos) pos_eff[1:] = pos[:-1] else: pos_eff = pos gross = pos_eff * returns if gross.ndim == 2: gross = gross.sum(axis=1) costs = ( np.asarray(cost(pos), dtype=np.float64) if cost is not None else np.zeros(returns.shape[0], dtype=np.float64) ) net = gross - costs equity = capital * np.cumprod(1.0 + net) return BacktestResult( equity=equity, returns=net, gross_returns=gross, positions=pos, costs=costs, )