Source code for fynance.backtest.result

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

""" Backtest result value object.

:class:`BacktestResult` is the engine's output and the hand-off to metrics and
reporting. It holds numpy arrays and computes a standard performance summary.

"""

from __future__ import annotations

# Built-in packages
from dataclasses import dataclass
from typing import Any

# Third-party packages
import numpy as np
from numpy.typing import NDArray

# Local packages
from fynance.core import PriceSeries

__all__ = ['BacktestResult']


[docs] @dataclass class BacktestResult: """ Output of :func:`~fynance.backtest.engine.backtest`. Attributes ---------- equity : numpy.ndarray Equity curve. returns : numpy.ndarray Net strategy returns (after costs). gross_returns : numpy.ndarray Strategy returns before costs. positions : numpy.ndarray Position/weight book used. costs : numpy.ndarray Per-step transaction costs. index : numpy.ndarray, optional Temporal index carried from the input. asset_gross_returns : numpy.ndarray, optional Per-asset gross return contributions ``(T, N)`` for a multi-asset book (they sum to :attr:`gross_returns`); ``None`` for a single-asset run. cost_components : dict of str to numpy.ndarray, optional Per-step cost broken down by component (e.g. ``transaction`` vs ``market_impact``); the values sum to :attr:`costs`. Populated when the cost model exposes the optional ``components`` convention, else ``None``. Methods ------- to_numpy to_price_series to_pandas summary trades trade_summary """ equity: NDArray returns: NDArray gross_returns: NDArray positions: NDArray costs: NDArray index: NDArray | None = None asset_gross_returns: NDArray | None = None cost_components: dict[str, NDArray] | None = None
[docs] def to_numpy(self) -> NDArray: """ Return the equity curve as a numpy array. """ return np.asarray(self.equity)
[docs] def to_price_series(self) -> PriceSeries: """ Return the equity curve as a :class:`PriceSeries`. """ return PriceSeries(self.equity, index=self.index, name="equity")
[docs] def to_pandas(self) -> Any: """ Return this result as a :class:`pandas.DataFrame` (lazy import). One row per time step. Always carries ``equity``, ``returns``, ``gross_returns`` and ``costs``. :attr:`positions` becomes a single ``positions`` column for a single-asset ``(T,)`` book, or ``pos_0``..``pos_{N-1}`` columns for a multi-asset ``(T, N)`` book. When present, :attr:`asset_gross_returns` similarly expands into ``asset_gross_return_0``..``asset_gross_return_{N-1}`` columns, and each :attr:`cost_components` entry becomes a ``cost_<name>`` column. The DataFrame index is :attr:`index` when set, else pandas' default ``RangeIndex``. Raises ------ ImportError If pandas is not installed, with a clear, actionable message. Examples -------- >>> from fynance.backtest import backtest >>> res = backtest(np.array([0.01, 0.02, -0.01]), np.ones(3)) >>> list(res.to_pandas().columns) ['equity', 'returns', 'gross_returns', 'costs', 'positions'] """ try: import pandas as pd except ImportError as exc: raise ImportError("install pandas to use to_pandas()") from exc data: dict[str, NDArray] = { "equity": self.equity, "returns": self.returns, "gross_returns": self.gross_returns, "costs": self.costs, } if self.positions.ndim == 2: for i in range(self.positions.shape[1]): data[f"pos_{i}"] = self.positions[:, i] else: data["positions"] = self.positions if self.asset_gross_returns is not None: for i in range(self.asset_gross_returns.shape[1]): data[f"asset_gross_return_{i}"] = self.asset_gross_returns[:, i] if self.cost_components is not None: for name, values in self.cost_components.items(): data[f"cost_{name}"] = values return pd.DataFrame(data, index=self.index)
[docs] def summary(self, period: int = 252) -> dict[str, float]: """ Standard performance summary. Delegates the risk-adjusted ratios and drawdown to :func:`fynance.metrics.summary` (computed on the equity curve) and adds, from the strategy's own data, the hit-rate, total transaction cost and the trading-profile churn (``n_sign_changes`` / ``trades_per_year``, summed over the book — see :func:`fynance.metrics.sign_changes`). """ from fynance.metrics import summary as _metric_summary from fynance.metrics.trading import sign_changes, trades_per_year out = _metric_summary(self.equity, period=period) r = self.returns[~np.isnan(self.returns)] out["hit_rate"] = float((r > 0).mean()) if r.size else 0.0 out["total_cost"] = float(np.nansum(self.costs)) out["n_sign_changes"] = float(np.sum(sign_changes(self.positions))) out["trades_per_year"] = float( np.sum(trades_per_year(self.positions, period=period))) return out
[docs] def trades(self) -> NDArray: """ Round-trip trades extracted from the strategy's own arrays. Delegates to :func:`fynance.metrics.trades.extract_trades` on :attr:`positions` and :attr:`returns`, taken exactly as stored (see that function's docstring for the alignment convention this relies on). Returns ------- numpy.ndarray Structured array, one row per trade -- see :func:`~fynance.metrics.trades.extract_trades`. """ from fynance.metrics.trades import extract_trades return extract_trades(self.positions, self.returns)
[docs] def trade_summary(self) -> dict[str, float]: """ Summary statistics of the strategy's round-trip trades. Delegates to :func:`fynance.metrics.trades.trade_summary` on :meth:`trades`. Returns ------- dict of str to float See :func:`~fynance.metrics.trades.trade_summary`. """ from fynance.metrics.trades import trade_summary as _trade_summary return _trade_summary(self.trades())