389 lines
13 KiB
Python
389 lines
13 KiB
Python
"""
|
|
Minimal Backtesting Harness
|
|
============================
|
|
|
|
A lightweight vectorized backtester that uses ferro_ta indicators as the engine.
|
|
|
|
**Scope** (minimal harness):
|
|
- Vectorized approach: compute indicators once, then apply a signal function over bars.
|
|
- Single-asset, long-only or long/short, no leverage.
|
|
- Optional **commission** (per trade) and **slippage** (basis points) for more realistic equity.
|
|
- Returns a :class:`BacktestResult` with signals, positions, and equity curve.
|
|
|
|
For production backtesting consider `backtrader`, `zipline`, or `vectorbt`.
|
|
|
|
Quick start
|
|
-----------
|
|
>>> import numpy as np
|
|
>>> from ferro_ta.analysis.backtest import backtest, rsi_strategy
|
|
>>>
|
|
>>> # Generate synthetic OHLCV data
|
|
>>> np.random.seed(42)
|
|
>>> n = 100
|
|
>>> close = np.cumprod(1 + np.random.randn(n) * 0.01) * 100
|
|
>>> volume = np.random.randint(1_000, 10_000, n).astype(float)
|
|
>>>
|
|
>>> result = backtest(close, volume=volume, strategy="rsi_30_70")
|
|
>>> print(result) # BacktestResult(bars=100, trades=…, final_equity=…)
|
|
|
|
API
|
|
---
|
|
backtest(close, *, high=None, low=None, open=None, volume=None,
|
|
strategy="rsi_30_70", commission_per_trade=0, slippage_bps=0, **kwargs)
|
|
Run the backtester and return a :class:`BacktestResult`. Optional
|
|
commission (subtracted from equity on each position change) and slippage
|
|
(basis points; applied as a cost on the bar where position changes).
|
|
|
|
rsi_strategy(close, timeperiod=14, oversold=30, overbought=70)
|
|
Built-in RSI oversold/overbought strategy; returns a signal array.
|
|
|
|
sma_crossover_strategy(close, fast=10, slow=30)
|
|
Built-in SMA crossover strategy; returns a signal array.
|
|
|
|
macd_crossover_strategy(close, fastperiod=12, slowperiod=26, signalperiod=9)
|
|
Built-in MACD line/signal crossover strategy; returns a signal array.
|
|
|
|
BacktestResult
|
|
Dataclass-like container with signals, positions, returns, equity arrays.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
from typing import Optional, Union
|
|
|
|
import numpy as np
|
|
from numpy.typing import ArrayLike, NDArray
|
|
|
|
from ferro_ta.core.exceptions import FerroTAInputError, FerroTAValueError
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# BacktestResult
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class BacktestResult:
|
|
"""Container for backtesting output.
|
|
|
|
Attributes
|
|
----------
|
|
signals : NDArray[np.float64]
|
|
Array of +1 (long), -1 (short), or 0 (flat) for every bar.
|
|
positions : NDArray[np.float64]
|
|
Lagged signals — position held *during* each bar (shift by 1 to
|
|
avoid look-ahead bias).
|
|
bar_returns : NDArray[np.float64]
|
|
Per-bar return of the underlying close price (pct change).
|
|
strategy_returns : NDArray[np.float64]
|
|
``positions * bar_returns`` — strategy return at each bar.
|
|
equity : NDArray[np.float64]
|
|
Cumulative equity curve starting at 1.0.
|
|
n_trades : int
|
|
Number of position changes.
|
|
final_equity : float
|
|
Terminal equity value.
|
|
"""
|
|
|
|
__slots__ = (
|
|
"signals",
|
|
"positions",
|
|
"bar_returns",
|
|
"strategy_returns",
|
|
"equity",
|
|
"n_trades",
|
|
"final_equity",
|
|
)
|
|
|
|
def __init__(
|
|
self,
|
|
signals: NDArray[np.float64],
|
|
positions: NDArray[np.float64],
|
|
bar_returns: NDArray[np.float64],
|
|
strategy_returns: NDArray[np.float64],
|
|
equity: NDArray[np.float64],
|
|
) -> None:
|
|
self.signals = signals
|
|
self.positions = positions
|
|
self.bar_returns = bar_returns
|
|
self.strategy_returns = strategy_returns
|
|
self.equity = equity
|
|
self.n_trades = int(np.sum(np.diff(positions) != 0))
|
|
self.final_equity = float(equity[-1]) if len(equity) > 0 else 1.0
|
|
|
|
def __repr__(self) -> str: # pragma: no cover
|
|
return (
|
|
f"BacktestResult("
|
|
f"bars={len(self.signals)}, "
|
|
f"trades={self.n_trades}, "
|
|
f"final_equity={self.final_equity:.4f})"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Built-in strategies
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def rsi_strategy(
|
|
close: ArrayLike,
|
|
timeperiod: int = 14,
|
|
oversold: float = 30.0,
|
|
overbought: float = 70.0,
|
|
) -> NDArray[np.float64]:
|
|
"""RSI oversold / overbought signal generator.
|
|
|
|
Returns
|
|
-------
|
|
signals : ndarray of float64
|
|
+1 where RSI <= oversold (buy signal), -1 where RSI >= overbought
|
|
(sell signal), 0 otherwise. NaN during the RSI warm-up period.
|
|
|
|
Parameters
|
|
----------
|
|
close : array-like
|
|
Close prices.
|
|
timeperiod : int
|
|
RSI look-back period (default 14).
|
|
oversold : float
|
|
RSI level below which a long (+1) signal is generated (default 30).
|
|
overbought : float
|
|
RSI level above which a short (-1) signal is generated (default 70).
|
|
"""
|
|
from ferro_ta import RSI # local import to avoid circular dep
|
|
|
|
if timeperiod < 1:
|
|
raise FerroTAValueError(f"timeperiod must be >= 1, got {timeperiod}")
|
|
|
|
c = np.asarray(close, dtype=np.float64)
|
|
rsi = np.asarray(RSI(c, timeperiod=timeperiod), dtype=np.float64)
|
|
signals = np.where(rsi <= oversold, 1.0, np.where(rsi >= overbought, -1.0, 0.0))
|
|
signals[np.isnan(rsi)] = np.nan
|
|
return signals
|
|
|
|
|
|
def sma_crossover_strategy(
|
|
close: ArrayLike,
|
|
fast: int = 10,
|
|
slow: int = 30,
|
|
) -> NDArray[np.float64]:
|
|
"""SMA fast/slow crossover strategy.
|
|
|
|
Returns
|
|
-------
|
|
signals : ndarray of float64
|
|
+1 when fast SMA > slow SMA (uptrend), -1 when fast SMA < slow SMA
|
|
(downtrend), NaN during the warm-up window.
|
|
|
|
Parameters
|
|
----------
|
|
close : array-like
|
|
Close prices.
|
|
fast : int
|
|
Fast SMA period (default 10).
|
|
slow : int
|
|
Slow SMA period (default 30).
|
|
"""
|
|
from ferro_ta import SMA # local import
|
|
|
|
if fast < 1:
|
|
raise FerroTAValueError(f"fast must be >= 1, got {fast}")
|
|
if slow < 1:
|
|
raise FerroTAValueError(f"slow must be >= 1, got {slow}")
|
|
if fast >= slow:
|
|
raise FerroTAValueError(f"fast ({fast}) must be less than slow ({slow})")
|
|
|
|
c = np.asarray(close, dtype=np.float64)
|
|
sma_fast = np.asarray(SMA(c, timeperiod=fast), dtype=np.float64)
|
|
sma_slow = np.asarray(SMA(c, timeperiod=slow), dtype=np.float64)
|
|
signals = np.where(sma_fast > sma_slow, 1.0, -1.0).astype(np.float64)
|
|
# Warm-up: NaN where either MA is NaN
|
|
warmup = np.isnan(sma_fast) | np.isnan(sma_slow)
|
|
signals[warmup] = np.nan
|
|
return signals
|
|
|
|
|
|
def macd_crossover_strategy(
|
|
close: ArrayLike,
|
|
fastperiod: int = 12,
|
|
slowperiod: int = 26,
|
|
signalperiod: int = 9,
|
|
) -> NDArray[np.float64]:
|
|
"""MACD line / signal line crossover strategy.
|
|
|
|
Returns
|
|
-------
|
|
signals : ndarray of float64
|
|
+1 when MACD line > signal line (uptrend), -1 when MACD line < signal line
|
|
(downtrend), NaN during the MACD warm-up window.
|
|
|
|
Parameters
|
|
----------
|
|
close : array-like
|
|
Close prices.
|
|
fastperiod : int
|
|
Fast EMA period (default 12).
|
|
slowperiod : int
|
|
Slow EMA period (default 26).
|
|
signalperiod : int
|
|
Signal line EMA period (default 9).
|
|
"""
|
|
from ferro_ta import MACD # local import
|
|
|
|
if fastperiod < 1 or slowperiod < 1 or signalperiod < 1:
|
|
raise FerroTAValueError("MACD periods must be >= 1")
|
|
if fastperiod >= slowperiod:
|
|
raise FerroTAValueError(
|
|
f"fastperiod ({fastperiod}) must be less than slowperiod ({slowperiod})"
|
|
)
|
|
|
|
c = np.asarray(close, dtype=np.float64)
|
|
macd_line, signal_line, _ = MACD(
|
|
c, fastperiod=fastperiod, slowperiod=slowperiod, signalperiod=signalperiod
|
|
)
|
|
macd_line = np.asarray(macd_line, dtype=np.float64)
|
|
signal_line = np.asarray(signal_line, dtype=np.float64)
|
|
signals = np.where(macd_line > signal_line, 1.0, -1.0).astype(np.float64)
|
|
warmup = np.isnan(macd_line) | np.isnan(signal_line)
|
|
signals[warmup] = np.nan
|
|
return signals
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Built-in strategy registry
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_BUILTIN_STRATEGIES: dict[str, Callable[..., NDArray[np.float64]]] = {
|
|
"rsi_30_70": rsi_strategy,
|
|
"sma_crossover": sma_crossover_strategy,
|
|
"macd_crossover": macd_crossover_strategy,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main backtest entry point
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def backtest(
|
|
close: ArrayLike,
|
|
*,
|
|
high: Optional[ArrayLike] = None,
|
|
low: Optional[ArrayLike] = None,
|
|
open: Optional[ArrayLike] = None,
|
|
volume: Optional[ArrayLike] = None,
|
|
strategy: Union[str, Callable[..., NDArray[np.float64]]] = "rsi_30_70",
|
|
commission_per_trade: float = 0.0,
|
|
slippage_bps: float = 0.0,
|
|
**strategy_kwargs: object,
|
|
) -> BacktestResult:
|
|
"""Run a vectorized backtest on *close* prices using *strategy*.
|
|
|
|
Parameters
|
|
----------
|
|
close : array-like
|
|
Close prices (required).
|
|
high, low, open, volume : array-like, optional
|
|
Additional OHLCV data. Passed to the strategy function if it accepts
|
|
them (via ``**strategy_kwargs``); currently unused by the built-in
|
|
strategies.
|
|
strategy : str or callable
|
|
Either a name of a built-in strategy (``"rsi_30_70"``,
|
|
``"sma_crossover"``, or ``"macd_crossover"``) or a callable with
|
|
signature ``(close, **kwargs) -> ndarray`` that returns a signal array.
|
|
commission_per_trade : float, optional
|
|
Fixed commission deducted from equity on each position change (default 0).
|
|
slippage_bps : float, optional
|
|
Slippage in basis points (1 bps = 0.01%) applied as a cost on the bar
|
|
where the position changes (default 0).
|
|
**strategy_kwargs
|
|
Extra keyword arguments forwarded to the strategy function
|
|
(e.g. ``timeperiod=14``, ``oversold=30``).
|
|
|
|
Returns
|
|
-------
|
|
BacktestResult
|
|
Container with signals, positions, equity curve, and trade count.
|
|
|
|
Raises
|
|
------
|
|
FerroTAValueError
|
|
If a named strategy is unknown.
|
|
FerroTAInputError
|
|
If ``close`` is too short (< 2 bars) or contains non-finite values.
|
|
|
|
Notes
|
|
-----
|
|
Commission is subtracted from equity immediately after each position change.
|
|
Slippage is applied by reducing the strategy return on the bar where the
|
|
position changes by ``slippage_bps / 10000`` (one-way).
|
|
"""
|
|
c = np.asarray(close, dtype=np.float64)
|
|
if c.ndim != 1:
|
|
raise FerroTAInputError("close must be a 1-D array.")
|
|
if len(c) < 2:
|
|
raise FerroTAInputError(f"close must have at least 2 bars, got {len(c)}.")
|
|
|
|
# ------------------------------------------------------------------
|
|
# Resolve strategy
|
|
# ------------------------------------------------------------------
|
|
if isinstance(strategy, str):
|
|
if strategy not in _BUILTIN_STRATEGIES:
|
|
raise FerroTAValueError(
|
|
f"Unknown strategy '{strategy}'. "
|
|
f"Available: {sorted(_BUILTIN_STRATEGIES)}"
|
|
)
|
|
strategy_fn: Callable[..., NDArray[np.float64]] = _BUILTIN_STRATEGIES[strategy]
|
|
elif callable(strategy):
|
|
strategy_fn = strategy
|
|
else:
|
|
raise FerroTAValueError("strategy must be a string name or a callable.")
|
|
|
|
# ------------------------------------------------------------------
|
|
# Compute signals
|
|
# ------------------------------------------------------------------
|
|
signals = np.asarray(strategy_fn(c, **strategy_kwargs), dtype=np.float64)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Positions: lag signals by 1 bar to avoid look-ahead bias
|
|
# ------------------------------------------------------------------
|
|
positions = np.empty_like(signals)
|
|
positions[0] = 0.0
|
|
positions[1:] = signals[:-1]
|
|
# Replace NaN in positions with 0 (flat)
|
|
positions = np.nan_to_num(positions, nan=0.0)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Returns
|
|
# ------------------------------------------------------------------
|
|
bar_returns: np.ndarray = np.empty(len(c), dtype=np.float64)
|
|
bar_returns[0] = 0.0
|
|
bar_returns[1:] = np.diff(c) / c[:-1]
|
|
|
|
strategy_returns = positions * bar_returns
|
|
|
|
# Slippage: on each position change, reduce return by slippage_bps/10000 (one-way)
|
|
if slippage_bps > 0:
|
|
position_changed = np.concatenate([[False], positions[1:] != positions[:-1]])
|
|
strategy_returns = strategy_returns.copy()
|
|
strategy_returns[position_changed] -= slippage_bps / 10_000.0
|
|
|
|
# Cumulative equity: with optional commission per trade
|
|
if commission_per_trade <= 0:
|
|
equity = np.cumprod(1.0 + strategy_returns)
|
|
else:
|
|
equity = np.empty(len(c), dtype=np.float64)
|
|
equity[0] = 1.0
|
|
position_changed = np.concatenate([[False], positions[1:] != positions[:-1]])
|
|
for i in range(1, len(c)):
|
|
equity[i] = equity[i - 1] * (1.0 + strategy_returns[i])
|
|
if position_changed[i]:
|
|
equity[i] -= commission_per_trade
|
|
|
|
return BacktestResult(
|
|
signals=signals,
|
|
positions=positions,
|
|
bar_returns=bar_returns,
|
|
strategy_returns=strategy_returns,
|
|
equity=np.asarray(equity, dtype=np.float64),
|
|
)
|