Files
ferro-ta/python/ferro_ta/analysis/backtest.py
T

1536 lines
52 KiB
Python
Raw Normal View History

2026-03-23 23:34:28 +05:30
"""
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
2026-03-30 12:45:52 +05:30
import dataclasses
import warnings
from collections import Counter
2026-03-23 23:34:28 +05:30
from collections.abc import Callable
2026-03-30 12:45:52 +05:30
from typing import Any, Optional, Union
2026-03-23 23:34:28 +05:30
import numpy as np
from numpy.typing import ArrayLike, NDArray
2026-03-30 12:45:52 +05:30
from ferro_ta._ferro_ta import CommissionModel
from ferro_ta._ferro_ta import Currency as _RustCurrency
from ferro_ta._ferro_ta import backtest_core as _rust_backtest_core
2026-03-30 12:45:52 +05:30
from ferro_ta._ferro_ta import (
backtest_multi_asset_core as _rust_backtest_multi_asset_core,
)
from ferro_ta._ferro_ta import backtest_ohlcv_core as _rust_backtest_ohlcv_core
from ferro_ta._ferro_ta import compute_performance_metrics as _rust_compute_perf_metrics
from ferro_ta._ferro_ta import drawdown_series as _rust_drawdown_series
from ferro_ta._ferro_ta import extract_trades_ohlcv as _rust_extract_trades
from ferro_ta._ferro_ta import kelly_fraction as _rust_kelly_fraction
from ferro_ta._ferro_ta import macd_crossover_signals as _rust_macd_crossover_signals
2026-03-30 12:45:52 +05:30
from ferro_ta._ferro_ta import monte_carlo_bootstrap as _rust_monte_carlo_bootstrap
from ferro_ta._ferro_ta import rsi_threshold_signals as _rust_rsi_threshold_signals
from ferro_ta._ferro_ta import sma_crossover_signals as _rust_sma_crossover_signals
2026-03-30 12:45:52 +05:30
from ferro_ta._ferro_ta import walk_forward_indices as _rust_walk_forward_indices
2026-03-23 23:34:28 +05:30
from ferro_ta.core.exceptions import FerroTAInputError, FerroTAValueError
2026-03-30 12:45:52 +05:30
# ---------------------------------------------------------------------------
# Currency system (backed by Rust via ferro_ta._ferro_ta.Currency)
# ---------------------------------------------------------------------------
# Re-export the Rust-backed Currency class as the public API.
Currency = _RustCurrency
# Built-in currency constants
INR: _RustCurrency = Currency.INR()
USD: _RustCurrency = Currency.USD()
EUR: _RustCurrency = Currency.EUR()
GBP: _RustCurrency = Currency.GBP()
JPY: _RustCurrency = Currency.JPY()
USDT: _RustCurrency = Currency.USDT()
_CURRENCIES: dict[str, _RustCurrency] = {
"INR": INR,
"USD": USD,
"EUR": EUR,
"GBP": GBP,
"JPY": JPY,
"USDT": USDT,
}
def format_currency(amount: float, currency: _RustCurrency | None = None) -> str:
"""Format *amount* using *currency*'s display style.
Uses Indian lakh/crore grouping for INR, standard grouping for others.
>>> format_currency(123456.78)
'₹1,23,456.78'
>>> format_currency(1234567.89, USD)
'$1,234,567.89'
"""
effective: _RustCurrency = currency if currency is not None else INR
return effective.format(amount)
2026-03-30 12:45:52 +05:30
2026-03-23 23:34:28 +05:30
# ---------------------------------------------------------------------------
# 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).
"""
if timeperiod < 1:
raise FerroTAValueError(f"timeperiod must be >= 1, got {timeperiod}")
c = np.asarray(close, dtype=np.float64)
return np.asarray(
_rust_rsi_threshold_signals(
c, int(timeperiod), float(oversold), float(overbought)
),
dtype=np.float64,
)
2026-03-23 23:34:28 +05:30
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).
"""
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)
return np.asarray(
_rust_sma_crossover_signals(c, int(fast), int(slow)),
dtype=np.float64,
)
2026-03-23 23:34:28 +05:30
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).
"""
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)
return np.asarray(
_rust_macd_crossover_signals(
c, int(fastperiod), int(slowperiod), int(signalperiod)
),
dtype=np.float64,
2026-03-23 23:34:28 +05:30
)
# ---------------------------------------------------------------------------
# Built-in strategy registry
# ---------------------------------------------------------------------------
2026-03-30 12:45:52 +05:30
_BENCHMARK_METRICS = frozenset(
(
"alpha",
"beta",
"tracking_error",
"information_ratio",
"benchmark_cagr",
"benchmark_sharpe",
)
)
2026-03-23 23:34:28 +05:30
_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)}.")
# ------------------------------------------------------------------
2026-03-30 12:45:52 +05:30
# Resolve strategy & compute signals
2026-03-23 23:34:28 +05:30
# ------------------------------------------------------------------
2026-03-30 12:45:52 +05:30
strategy_fn = _resolve_strategy(strategy)
signals = np.asarray(strategy_fn(c, **strategy_kwargs), dtype=np.float64)
positions, bar_returns, strategy_returns, equity = _rust_backtest_core(
c,
signals,
commission_per_trade=float(commission_per_trade),
slippage_bps=float(slippage_bps),
)
return BacktestResult(
signals=signals,
positions=np.asarray(positions, dtype=np.float64),
bar_returns=np.asarray(bar_returns, dtype=np.float64),
strategy_returns=np.asarray(strategy_returns, dtype=np.float64),
equity=np.asarray(equity, dtype=np.float64),
)
# ===========================================================================
# Advanced API — AdvancedBacktestResult, BacktestEngine, walk_forward, monte_carlo
# ===========================================================================
class AdvancedBacktestResult(BacktestResult):
"""Extended backtest result with full metrics, trade log, and drawdown series.
All ``BacktestResult`` attributes are preserved (``isinstance`` checks work).
Additional Attributes
---------------------
metrics : dict[str, float]
Full performance metrics: cagr, sharpe, sortino, calmar, max_drawdown,
avg_drawdown, max_drawdown_duration_bars, ulcer_index, omega_ratio,
win_rate, profit_factor, r_expectancy, tail_ratio, skewness, kurtosis, etc.
trades : Any
Trade log as ``pd.DataFrame`` (if pandas is installed) with columns:
entry_bar, exit_bar, direction, entry_price, exit_price, pnl_pct,
duration_bars, mae, mfe. None if no trades were extracted.
drawdown_series : NDArray[np.float64]
Per-bar drawdown (always <= 0).
fill_prices : NDArray[np.float64]
Actual fill prices per bar (NaN when flat). NaN array in close-only mode.
"""
__slots__ = BacktestResult.__slots__ + (
"metrics",
"trades",
"drawdown_series",
"fill_prices",
"currency",
"initial_capital",
"equity_abs",
)
def __init__(
self,
signals: NDArray,
positions: NDArray,
bar_returns: NDArray,
strategy_returns: NDArray,
equity: NDArray,
metrics: dict,
trades: Any,
drawdown_series: NDArray,
fill_prices: NDArray,
currency: _RustCurrency = INR,
initial_capital: float = 100_000.0,
) -> None:
super().__init__(signals, positions, bar_returns, strategy_returns, equity)
self.metrics = metrics
self.trades = trades
self.drawdown_series = drawdown_series
self.fill_prices = fill_prices
self.currency = currency
self.initial_capital = float(initial_capital)
self.equity_abs = equity * self.initial_capital
def __repr__(self) -> str: # pragma: no cover
m = self.metrics
final = (
float(self.equity_abs[-1])
if len(self.equity_abs) > 0
else self.initial_capital
)
return (
f"AdvancedBacktestResult("
f"bars={len(self.signals)}, "
f"trades={self.n_trades}, "
f"sharpe={m.get('sharpe', float('nan')):.3f}, "
f"max_dd={m.get('max_drawdown', float('nan')):.1%}, "
f"final={self.currency.format(final)})"
)
def to_equity_dataframe(self, freq: str = "B") -> Any:
"""Return equity and drawdown as a ``pd.DataFrame`` indexed by date.
Parameters
----------
freq : str
pandas date-offset alias for the synthetic DatetimeIndex (default ``"B"``).
Returns
-------
pd.DataFrame with columns ``equity``, ``equity_abs``, ``strategy_returns``,
``drawdown``, indexed by a synthetic ``pd.DatetimeIndex`` starting 2000-01-03.
Raises ``ImportError`` if pandas is not installed.
"""
try:
import pandas as pd
except ImportError as exc:
raise ImportError("pandas is required for to_equity_dataframe()") from exc
n = len(self.equity)
idx = pd.date_range("2000-01-03", periods=n, freq=freq)
return pd.DataFrame(
{
"equity": self.equity,
"equity_abs": self.equity_abs,
"strategy_returns": self.strategy_returns,
"drawdown": self.drawdown_series,
},
index=idx,
)
def summary(self) -> dict:
"""Return a concise performance summary dict.
Includes the 9 most commonly cited metrics plus n_trades,
initial_capital, final_capital, absolute_pnl, and currency.
"""
m = self.metrics
keys = (
"total_return",
"cagr",
"annualized_vol",
"sharpe",
"sortino",
"calmar",
"max_drawdown",
"win_rate",
"profit_factor",
)
result = {k: m.get(k, float("nan")) for k in keys}
result["n_trades"] = self.n_trades
result["initial_capital"] = self.initial_capital
final_capital = (
float(self.equity_abs[-1])
if len(self.equity_abs) > 0
else self.initial_capital
)
result["final_capital"] = final_capital
result["absolute_pnl"] = final_capital - self.initial_capital
result["currency"] = self.currency.code
# Include benchmark metrics if available
for key in _BENCHMARK_METRICS:
if key in m:
result[key] = m[key]
return result
def _resolve_strategy(
strategy: Union[str, Callable],
) -> Callable[..., NDArray]:
2026-03-23 23:34:28 +05:30
if isinstance(strategy, str):
if strategy not in _BUILTIN_STRATEGIES:
raise FerroTAValueError(
f"Unknown strategy '{strategy}'. "
f"Available: {sorted(_BUILTIN_STRATEGIES)}"
)
2026-03-30 12:45:52 +05:30
return _BUILTIN_STRATEGIES[strategy]
2026-03-23 23:34:28 +05:30
elif callable(strategy):
2026-03-30 12:45:52 +05:30
return strategy
raise FerroTAValueError("strategy must be a string name or a callable.")
def _pct_change(arr: NDArray) -> NDArray:
"""Percentage change with zero-price guard. Returns array of length len(arr)-1."""
return np.diff(arr) / np.where(arr[:-1] != 0, arr[:-1], 1.0)
def _kelly_stats(strategy_returns: NDArray) -> tuple[float, float, float]:
"""Extract (win_rate, avg_win, avg_loss) from strategy returns.
Returns (0, 0, 0) when there are no active trades.
"""
active = strategy_returns[np.isfinite(strategy_returns) & (strategy_returns != 0.0)]
if len(active) == 0:
return 0.0, 0.0, 0.0
wins = active[active > 0.0]
losses = active[active < 0.0]
win_rate = len(wins) / len(active)
avg_win = float(wins.mean()) if len(wins) > 0 else 0.0
avg_loss = float(np.abs(losses).mean()) if len(losses) > 0 else 0.0
return win_rate, avg_win, avg_loss
def _build_trades_df(
positions: NDArray,
fill_prices: NDArray,
high: NDArray,
low: NDArray,
initial_capital: float = 100_000.0,
) -> Any:
"""Extract trade log; returns pd.DataFrame if pandas available, else None."""
try:
import pandas as pd
except ImportError:
return None
eb, xb, d, ep, xp, pnl, dur, mae, mfe = _rust_extract_trades(
positions, fill_prices, high, low
)
if len(eb) == 0:
return pd.DataFrame(
columns=[ # type: ignore[arg-type]
2026-03-30 12:45:52 +05:30
"entry_bar",
"exit_bar",
"direction",
"entry_price",
"exit_price",
"pnl_pct",
"pnl_abs",
"duration_bars",
"mae",
"mfe",
]
)
df = pd.DataFrame(
{
"entry_bar": eb,
"exit_bar": xb,
"direction": d,
"entry_price": ep,
"exit_price": xp,
"pnl_pct": pnl,
"duration_bars": dur,
"mae": mae,
"mfe": mfe,
}
)
df["pnl_abs"] = df["pnl_pct"] * initial_capital
return df
class BacktestEngine:
"""Composable backtesting engine with a fluent builder interface.
Example
-------
>>> import numpy as np
>>> from ferro_ta.analysis.backtest import BacktestEngine
>>> close = np.cumprod(1 + np.random.randn(200) * 0.01) * 100
>>> high = close * 1.01; low = close * 0.99; open_ = close * 0.999
>>> result = (
... BacktestEngine()
... .with_commission(0.001)
... .with_slippage(5.0)
... .with_ohlcv(high=high, low=low, open_=open_)
... .with_stop_loss(0.03)
... .run(close, strategy="rsi_30_70")
... )
>>> print(result.metrics["sharpe"])
"""
def __init__(self) -> None:
self._commission: float = 0.0
self._commission_model: Optional[CommissionModel] = None
self._currency: _RustCurrency = INR
self._initial_capital: float = 100_000.0
self._slippage_bps: float = 0.0
self._slippage_pct_range: float = 0.0
self._position_sizing: str = "fixed"
self._fixed_fraction: float = 1.0
self._vol_window: int = 20
self._target_vol: float = 0.10
self._high: Optional[NDArray] = None
self._low: Optional[NDArray] = None
self._open: Optional[NDArray] = None
self._stop_loss_pct: float = 0.0
self._take_profit_pct: float = 0.0
self._trailing_stop_pct: float = 0.0
self._fill_mode: str = "market_open"
self._periods_per_year: float = 252.0
self._risk_free_rate: float = 0.0
self._benchmark_close: Optional[NDArray] = None
self._limit_prices: Optional[NDArray] = None
self._max_hold_bars: int = 0
self._breakeven_pct: float = 0.0
# Phase 2: Portfolio & Risk
self._margin_ratio: float = 0.0
self._margin_call_pct: float = 0.5
self._daily_loss_limit: float = 0.0
self._total_loss_limit: float = 0.0
self._max_asset_weight: float = 1.0
self._max_gross_exposure: float = 0.0
self._max_net_exposure: float = 0.0
def with_commission(self, rate: float) -> BacktestEngine:
"""Backward-compat: set a flat per-order fee (in base currency units)."""
self._commission = float(rate)
return self
def with_commission_model(self, model: CommissionModel) -> BacktestEngine:
"""Set a full commission+tax model (takes precedence over ``with_commission``)."""
self._commission_model = model
return self
def with_currency(
self, currency: str | _RustCurrency | None = None
) -> BacktestEngine:
"""Set display currency (default: INR)."""
if currency is None:
currency = INR
if isinstance(currency, str):
try:
currency = Currency.from_code(currency)
except Exception:
raise FerroTAValueError(
f"Unknown currency code '{currency}'. "
f"Supported: {sorted(_CURRENCIES)}"
)
self._currency = currency
return self
def with_initial_capital(self, capital: float) -> BacktestEngine:
"""Set starting capital in base currency (default: ₹1,00,000)."""
self._initial_capital = float(capital)
return self
def with_benchmark(self, benchmark_close: ArrayLike) -> BacktestEngine:
"""Set benchmark close prices for alpha/beta/tracking error computation."""
self._benchmark_close = np.asarray(benchmark_close, dtype=np.float64)
return self
def with_trailing_stop(self, pct: float) -> BacktestEngine:
"""Set trailing stop distance as a fraction (e.g. 0.02 = 2%). 0 = disabled."""
self._trailing_stop_pct = float(pct)
return self
def with_slippage(self, bps: float) -> BacktestEngine:
self._slippage_bps = float(bps)
return self
def with_ohlcv(
self,
*,
high: ArrayLike,
low: ArrayLike,
open_: ArrayLike,
) -> BacktestEngine:
self._high = np.asarray(high, dtype=np.float64)
self._low = np.asarray(low, dtype=np.float64)
self._open = np.asarray(open_, dtype=np.float64)
return self
def with_stop_loss(self, pct: float) -> BacktestEngine:
self._stop_loss_pct = float(pct)
return self
def with_take_profit(self, pct: float) -> BacktestEngine:
self._take_profit_pct = float(pct)
return self
def with_fill_mode(self, mode: str) -> BacktestEngine:
if mode not in ("market_open", "market_close"):
raise FerroTAValueError("fill_mode must be 'market_open' or 'market_close'")
self._fill_mode = mode
return self
def with_position_sizing(
self,
method: str,
fraction: float = 1.0,
vol_window: int = 20,
target_vol: float = 0.10,
) -> BacktestEngine:
valid = (
"fixed",
"kelly",
"half_kelly",
"fixed_fractional",
"volatility_target",
)
if method not in valid:
raise FerroTAValueError(f"position_sizing must be one of {valid}")
if method == "fixed_fractional" and not (0.0 < fraction <= 1.0):
raise FerroTAValueError("fixed_fractional fraction must be in (0, 1]")
self._vol_window = int(vol_window)
self._target_vol = float(target_vol)
self._position_sizing = method
self._fixed_fraction = float(fraction)
return self
def with_calendar(self, periods_per_year: float) -> BacktestEngine:
self._periods_per_year = float(periods_per_year)
return self
def with_risk_free_rate(self, rate: float) -> BacktestEngine:
self._risk_free_rate = float(rate)
return self
def with_limit_orders(self, prices: ArrayLike) -> BacktestEngine:
"""Set limit prices for entry/exit orders (requires OHLCV data via with_ohlcv).
Parameters
----------
prices : array-like, shape (n_bars,)
Limit price for each signal bar. NaN (or 0) entries use market-order fill.
Buy limit: fill only when bar low <= limit_price (execute at limit_price).
Sell limit: fill only when bar high >= limit_price (execute at limit_price).
"""
self._limit_prices = np.asarray(prices, dtype=np.float64)
return self
def with_max_hold(self, n_bars: int) -> BacktestEngine:
"""Force exit after *n_bars* bars in trade regardless of signal (requires OHLCV).
0 = disabled (default). Useful for mean-reversion strategies.
"""
if int(n_bars) < 0:
raise FerroTAValueError("max_hold n_bars must be >= 0")
self._max_hold_bars = int(n_bars)
return self
def with_slippage_pct_range(self, pct: float) -> BacktestEngine:
"""Set slippage as a fraction of the bar's high-low range (requires OHLCV).
Overrides ``with_slippage`` when both are set. Typical values: 0.050.20.
Example: pct=0.10 means slippage = 10% of bar's (high - low).
"""
self._slippage_pct_range = float(pct)
return self
def with_breakeven_stop(self, pct: float) -> BacktestEngine:
"""Move stop to entry price once profit reaches *pct* fraction (e.g. 0.02 = 2%). 0 = disabled."""
self._breakeven_pct = float(pct)
return self
def with_leverage(
self, margin_ratio: float, margin_call_pct: float = 0.5
) -> BacktestEngine:
"""Enable margin/leverage modeling. margin_ratio=0.2 means 20% margin (5x leverage).
margin_call_pct=0.5 triggers a margin call when equity falls to 50% of initial margin."""
self._margin_ratio = float(margin_ratio)
self._margin_call_pct = float(margin_call_pct)
return self
def with_loss_limits(
self, daily: float = 0.0, total: float = 0.0
) -> BacktestEngine:
"""Set circuit breakers. daily=0.02 halts after a 2% per-bar loss. total=0.20 halts after 20% drawdown."""
self._daily_loss_limit = float(daily)
self._total_loss_limit = float(total)
return self
def with_portfolio_constraints(
self,
max_asset_weight: float = 1.0,
max_gross_exposure: float = 0.0,
max_net_exposure: float = 0.0,
) -> BacktestEngine:
"""Set portfolio-level constraints for multi-asset backtests."""
self._max_asset_weight = float(max_asset_weight)
self._max_gross_exposure = float(max_gross_exposure)
self._max_net_exposure = float(max_net_exposure)
return self
def run(
self,
close: ArrayLike,
strategy: Union[str, Callable] = "rsi_30_70",
**strategy_kwargs: object,
) -> AdvancedBacktestResult:
"""Run the backtest and return an AdvancedBacktestResult."""
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)}.")
strategy_fn = _resolve_strategy(strategy)
signals = np.asarray(strategy_fn(c, **strategy_kwargs), dtype=np.float64)
cm = self._commission_model
commission_scalar = self._commission if cm is None else 0.0
ic = self._initial_capital
if self._position_sizing == "fixed_fractional":
signals = signals * self._fixed_fraction
if self._position_sizing == "volatility_target":
proxy_rets = _pct_change(c)
w = self._vol_window
# Naive rolling window is O(n·w); cumsum-of-squares is O(n) with no per-bar allocation.
# Safe for financial returns (centred near zero → no catastrophic cancellation).
cs = np.cumsum(proxy_rets)
cs2 = np.cumsum(proxy_rets**2)
pad = np.zeros(1)
s1 = cs[w - 1 :] - np.concatenate([pad, cs[: len(cs) - w]])
s2 = cs2[w - 1 :] - np.concatenate([pad, cs2[: len(cs2) - w]])
var = np.maximum(s2 / w - (s1 / w) ** 2, 0.0)
rolling_vol = np.concatenate([np.full(w, np.nan), np.sqrt(var)]) * np.sqrt(
self._periods_per_year
)
rolling_vol = np.concatenate([[np.nan], rolling_vol[: len(signals) - 1]])
with np.errstate(divide="ignore", invalid="ignore"):
# NaN positions (warm-up) have rolling_vol<=0 → else-branch produces 1.0
scale = np.where(
rolling_vol > 0,
np.clip(self._target_vol / rolling_vol, 0.0, 3.0),
1.0,
)
signals = signals * scale
use_ohlcv = (
self._high is not None and self._low is not None and self._open is not None
)
def _execute_run(sigs: NDArray) -> tuple:
if use_ohlcv:
pos, fp, br, sr, eq = _rust_backtest_ohlcv_core(
self._open,
self._high,
self._low,
c,
sigs,
self._fill_mode,
self._stop_loss_pct,
self._take_profit_pct,
self._trailing_stop_pct,
cm,
self._slippage_bps,
ic,
commission_scalar,
self._limit_prices,
self._max_hold_bars,
self._slippage_pct_range,
self._breakeven_pct,
self._periods_per_year,
self._margin_ratio,
self._margin_call_pct,
self._daily_loss_limit,
self._total_loss_limit,
)
return (
np.asarray(pos),
np.asarray(fp),
np.asarray(br),
np.asarray(sr),
np.asarray(eq),
)
pos, br, sr, eq = _rust_backtest_core(
c,
sigs,
cm,
self._slippage_bps,
ic,
commission_scalar,
)
return (
np.asarray(pos),
np.full(len(c), np.nan, dtype=np.float64),
np.asarray(br),
np.asarray(sr),
np.asarray(eq),
)
bench_returns_arr = None
if self._benchmark_close is not None and len(self._benchmark_close) == len(c):
bc = self._benchmark_close
bench_returns_arr = np.concatenate([[0.0], _pct_change(bc)])
def _compute_metrics(sr: NDArray, eq: NDArray) -> dict:
return dict(
_rust_compute_perf_metrics(
sr,
eq,
self._periods_per_year,
self._risk_free_rate,
bench_returns_arr,
)
)
# Kelly / half-Kelly: estimate fraction from a preliminary run, then re-run scaled
_kelly_kf: float = 0.0
if self._position_sizing in ("kelly", "half_kelly"):
positions, fill_prices, bar_returns, strategy_returns, equity = (
_execute_run(signals)
)
wr, aw, al = _kelly_stats(strategy_returns)
if aw > 0.0:
try:
_kelly_kf = _rust_kelly_fraction(wr, aw, al)
fraction = (
_kelly_kf
if self._position_sizing == "kelly"
else _kelly_kf / 2.0
)
signals = signals * fraction
except Exception as exc:
warnings.warn(
f"Kelly sizing failed, falling back to unit signals: {exc}",
stacklevel=2,
)
positions, fill_prices, bar_returns, strategy_returns, equity = _execute_run(
signals
)
metrics = _compute_metrics(strategy_returns, equity)
# Annotate Kelly info (reuse pre-computed fraction, avoid re-scanning returns)
if _kelly_kf > 0.0 and "kelly_fraction" not in metrics:
metrics["kelly_fraction"] = _kelly_kf
metrics["half_kelly_fraction"] = _kelly_kf / 2.0
metrics["position_size_fraction"] = (
_kelly_kf if self._position_sizing == "kelly" else _kelly_kf / 2.0
)
high_arr: NDArray = self._high if use_ohlcv and self._high is not None else c
low_arr: NDArray = self._low if use_ohlcv and self._low is not None else c
trades = _build_trades_df(positions, fill_prices, high_arr, low_arr, ic)
dd_arr, _ = _rust_drawdown_series(equity)
drawdown_series = np.asarray(dd_arr)
return AdvancedBacktestResult(
signals=signals,
positions=positions,
bar_returns=bar_returns,
strategy_returns=strategy_returns,
equity=equity,
metrics=metrics,
trades=trades,
drawdown_series=drawdown_series,
fill_prices=fill_prices,
currency=self._currency,
initial_capital=ic,
)
# ---------------------------------------------------------------------------
# Additional built-in strategies
# ---------------------------------------------------------------------------
def adx_trend_follow_strategy(
close: ArrayLike,
high: Optional[ArrayLike] = None,
low: Optional[ArrayLike] = None,
adx_period: int = 14,
adx_threshold: float = 25.0,
sma_period: int = 50,
**kwargs: object,
) -> NDArray:
"""ADX trend-following: +1 when ADX>threshold AND close>SMA, else -1."""
from ferro_ta._ferro_ta import adx as _adx
from ferro_ta._ferro_ta import sma as _sma
c = np.asarray(close, dtype=np.float64)
h = np.asarray(high, dtype=np.float64) if high is not None else c * 1.001
low_arr = np.asarray(low, dtype=np.float64) if low is not None else c * 0.999
adx_vals = np.asarray(_adx(h, low_arr, c, adx_period), dtype=np.float64)
sma_vals = np.asarray(_sma(c, sma_period), dtype=np.float64)
out = np.where(
np.isnan(adx_vals) | np.isnan(sma_vals),
np.nan,
np.where((adx_vals > adx_threshold) & (c > sma_vals), 1.0, -1.0),
)
return out
def bb_mean_revert_strategy(
close: ArrayLike,
timeperiod: int = 20,
nbdevup: float = 2.0,
nbdevdn: float = 2.0,
**kwargs: object,
) -> NDArray:
"""Bollinger Band mean reversion: +1 near lower band, -1 near upper band."""
from ferro_ta._ferro_ta import bbands as _bbands
c = np.asarray(close, dtype=np.float64)
upper, middle, lower = _bbands(c, timeperiod, nbdevup, nbdevdn)
upper = np.asarray(upper, dtype=np.float64)
lower = np.asarray(lower, dtype=np.float64)
out = np.where(
np.isnan(upper) | np.isnan(lower),
np.nan,
np.where(c <= lower, 1.0, np.where(c >= upper, -1.0, 0.0)),
)
return out
def rsi_sma_combo_strategy(
close: ArrayLike,
rsi_period: int = 14,
oversold: float = 30.0,
overbought: float = 70.0,
sma_period: int = 50,
**kwargs: object,
) -> NDArray:
"""RSI signal filtered by SMA trend: RSI oversold/overbought only in trend direction."""
from ferro_ta._ferro_ta import sma as _sma
c = np.asarray(close, dtype=np.float64)
rsi_signals = rsi_strategy(c, rsi_period, oversold, overbought)
sma_vals = np.asarray(_sma(c, sma_period), dtype=np.float64)
trend = np.where(np.isnan(sma_vals), np.nan, np.where(c > sma_vals, 1.0, -1.0))
# Only take RSI long signals in uptrend, RSI short signals in downtrend
out = np.where(
np.isnan(rsi_signals) | np.isnan(trend),
np.nan,
np.where(
(rsi_signals == 1.0) & (trend == 1.0),
1.0,
np.where((rsi_signals == -1.0) & (trend == -1.0), -1.0, 0.0),
),
)
return out
# Register additional built-in strategies
_BUILTIN_STRATEGIES["adx_trend_follow"] = adx_trend_follow_strategy
_BUILTIN_STRATEGIES["bb_mean_revert"] = bb_mean_revert_strategy
_BUILTIN_STRATEGIES["rsi_sma_combo"] = rsi_sma_combo_strategy
# ---------------------------------------------------------------------------
# WalkForwardResult + walk_forward()
# ---------------------------------------------------------------------------
@dataclasses.dataclass
class WalkForwardResult:
"""Results from walk-forward analysis.
Attributes
----------
fold_results : list[AdvancedBacktestResult]
Out-of-sample backtest result for each fold.
fold_indices : NDArray[np.int64]
Shape (n_folds, 4): [train_start, train_end, test_start, test_end].
best_params_per_fold : list[dict]
Parameter dict that scored highest in each fold's training period.
oos_equity : NDArray[np.float64]
Concatenated out-of-sample equity curve (chained, not spliced raw).
oos_metrics : dict[str, float]
Performance metrics computed on the full OOS equity curve.
param_stability : dict[str, Any]
For each param name, the most-chosen value and its selection frequency.
"""
fold_results: list
fold_indices: NDArray
best_params_per_fold: list
oos_equity: NDArray
oos_metrics: dict
param_stability: dict
def walk_forward(
close: ArrayLike,
strategy_fn: Callable,
param_grid: list,
train_bars: int,
test_bars: int,
*,
metric: str = "sharpe",
anchored: bool = False,
step_bars: int = 0,
commission_per_trade: float = 0.0,
slippage_bps: float = 0.0,
periods_per_year: float = 252.0,
) -> WalkForwardResult:
"""Walk-forward analysis with grid search on each training fold.
Parameters
----------
close : array-like
Full close price series.
strategy_fn : callable
Signal-generating function ``(close, **params) -> signals``.
param_grid : list[dict]
List of parameter dicts to test on the training set.
train_bars : int
Number of bars in each training window.
test_bars : int
Number of bars in each test (out-of-sample) window.
metric : str
Metric name from ``compute_performance_metrics`` to optimise (default "sharpe").
anchored : bool
If True, training window always starts from bar 0 (expanding window).
step_bars : int
Step between folds. 0 → non-overlapping (step = test_bars).
commission_per_trade, slippage_bps : float
Applied in both training (for metric computation) and test.
periods_per_year : float
Annualisation factor for metrics (default 252).
Returns
-------
WalkForwardResult
"""
if metric in _BENCHMARK_METRICS:
raise FerroTAValueError(
f"metric '{metric}' requires a benchmark and is not supported in walk_forward(). "
f"Use a non-benchmark metric such as 'sharpe', 'cagr', or 'sortino'."
)
c = np.asarray(close, dtype=np.float64)
n = len(c)
fold_idx = np.asarray(
_rust_walk_forward_indices(n, train_bars, test_bars, anchored, step_bars),
dtype=np.int64,
)
fold_results: list = []
best_params_per_fold: list = []
oos_returns_parts: list = []
engine_base = (
BacktestEngine()
.with_commission(commission_per_trade)
.with_slippage(slippage_bps)
.with_calendar(periods_per_year)
)
for fold in fold_idx:
tr_start, tr_end, te_start, te_end = (
int(fold[0]),
int(fold[1]),
int(fold[2]),
int(fold[3]),
)
c_train = c[tr_start:tr_end]
c_test = c[te_start:te_end]
# Grid search on training set
best_params: dict = param_grid[0] if param_grid else {}
best_score = float("-inf")
for params in param_grid:
try:
signals_train = np.asarray(
strategy_fn(c_train, **params), dtype=np.float64
)
_, _, sr_train, eq_train = _rust_backtest_core(
c_train,
signals_train,
commission_per_trade=commission_per_trade,
slippage_bps=slippage_bps,
)
sr_train = np.asarray(sr_train, dtype=np.float64)
eq_train = np.asarray(eq_train, dtype=np.float64)
fold_metrics = dict(
_rust_compute_perf_metrics(
sr_train, eq_train, periods_per_year, 0.0
)
)
score = fold_metrics.get(metric, float("-inf"))
if score > best_score:
best_score = score
best_params = params
except Exception as exc:
warnings.warn(
f"walk_forward: training fold param evaluation failed: {exc}",
stacklevel=2,
)
continue
best_params_per_fold.append(best_params)
# Test with best params
try:
test_result = engine_base.run(c_test, strategy_fn, **best_params)
except Exception as exc:
warnings.warn(
f"walk_forward: test fold failed, using flat equity: {exc}",
stacklevel=2,
)
dummy = np.ones(len(c_test))
test_result = AdvancedBacktestResult(
signals=dummy,
positions=dummy,
bar_returns=dummy,
strategy_returns=np.zeros(len(c_test)),
equity=dummy,
metrics={},
trades=None,
drawdown_series=np.zeros(len(c_test)),
fill_prices=np.full(len(c_test), np.nan),
)
fold_results.append(test_result)
oos_returns_parts.append(test_result.strategy_returns)
# Chain OOS equity curves from per-fold equity (preserves commission deductions)
if fold_results:
oos_equity_parts: list[NDArray] = []
oos_returns = np.concatenate(oos_returns_parts)
cumulative = 1.0
for fr in fold_results:
fold_eq = np.asarray(fr.equity, dtype=np.float64)
# Renormalize: fold equity starts at 1.0, scale to chain from prior fold
oos_equity_parts.append(fold_eq * cumulative)
cumulative *= float(fold_eq[-1]) if len(fold_eq) > 0 else 1.0
oos_equity = np.concatenate(oos_equity_parts)
2026-03-23 23:34:28 +05:30
else:
2026-03-30 12:45:52 +05:30
oos_returns = np.array([0.0])
oos_equity = np.array([1.0])
2026-03-23 23:34:28 +05:30
2026-03-30 12:45:52 +05:30
# OOS metrics on full concatenated curve
try:
oos_metrics = dict(
_rust_compute_perf_metrics(oos_returns, oos_equity, periods_per_year, 0.0)
)
except Exception:
oos_metrics = {}
# Parameter stability: how often each param value was chosen
param_stability: dict = {}
if best_params_per_fold:
all_keys = set().union(*[p.keys() for p in best_params_per_fold])
for key in all_keys:
vals = [p.get(key) for p in best_params_per_fold if key in p]
counts = Counter(vals)
most_common_val, most_common_count = counts.most_common(1)[0]
param_stability[key] = {
"most_chosen": most_common_val,
"frequency": most_common_count / len(best_params_per_fold),
"counts": dict(counts),
}
return WalkForwardResult(
fold_results=fold_results,
fold_indices=fold_idx,
best_params_per_fold=best_params_per_fold,
oos_equity=oos_equity,
oos_metrics=oos_metrics,
param_stability=param_stability,
)
2026-03-23 23:34:28 +05:30
2026-03-30 12:45:52 +05:30
# ---------------------------------------------------------------------------
# MonteCarloResult + monte_carlo()
# ---------------------------------------------------------------------------
@dataclasses.dataclass
class MonteCarloResult:
"""Results from Monte Carlo bootstrap simulation.
Attributes
----------
equity_curves : NDArray[np.float64]
Shape (n_sims, n_bars) — simulated equity curves.
terminal_equity : NDArray[np.float64]
Shape (n_sims,) — final equity value per simulation.
confidence_lower : NDArray[np.float64]
Lower confidence band per bar.
confidence_upper : NDArray[np.float64]
Upper confidence band per bar.
median_curve : NDArray[np.float64]
Median equity curve across simulations.
var : float
Value-at-Risk: worst ``(1-confidence)`` percentile of terminal equity.
cvar : float
Conditional VaR: mean of worst ``(1-confidence)`` fraction of terminal equity.
prob_profit : float
Fraction of simulations where terminal equity > 1.0.
n_sims : int
confidence : float
"""
equity_curves: NDArray
terminal_equity: NDArray
confidence_lower: NDArray
confidence_upper: NDArray
median_curve: NDArray
var: float
cvar: float
prob_profit: float
n_sims: int
confidence: float
def monte_carlo(
result_or_returns: Union[BacktestResult, NDArray],
n_sims: int = 1000,
confidence: float = 0.95,
seed: int = 42,
block_size: int = 1,
) -> MonteCarloResult:
"""Run Monte Carlo bootstrap simulation on strategy returns.
Parameters
----------
result_or_returns : BacktestResult or array-like
Either a ``BacktestResult`` (uses its ``strategy_returns``) or
a 1-D array of returns directly.
n_sims : int
Number of bootstrap simulations (default 1000).
confidence : float
Confidence level for bands and VaR (default 0.95).
seed : int
Random seed for reproducibility.
block_size : int
Block size for stationary block bootstrap (1 = IID resample).
Returns
-------
MonteCarloResult
"""
if isinstance(result_or_returns, BacktestResult):
returns = np.asarray(result_or_returns.strategy_returns, dtype=np.float64)
else:
returns = np.asarray(result_or_returns, dtype=np.float64)
equity_curves = np.asarray(
_rust_monte_carlo_bootstrap(returns, int(n_sims), int(seed), int(block_size)),
dtype=np.float64,
2026-03-23 23:34:28 +05:30
)
2026-03-30 12:45:52 +05:30
terminal_equity = equity_curves[:, -1]
lower_pct = (1.0 - confidence) / 2.0 * 100.0
upper_pct = (1.0 + confidence) / 2.0 * 100.0
pct_results = np.percentile(equity_curves, [lower_pct, upper_pct, 50.0], axis=0)
confidence_lower = pct_results[0]
confidence_upper = pct_results[1]
median_curve = pct_results[2]
var_threshold = np.percentile(terminal_equity, (1.0 - confidence) * 100.0)
tail = terminal_equity[terminal_equity <= var_threshold]
cvar = float(np.mean(tail)) if len(tail) > 0 else float(var_threshold)
prob_profit = float(np.mean(terminal_equity > 1.0))
return MonteCarloResult(
equity_curves=equity_curves,
terminal_equity=terminal_equity,
confidence_lower=confidence_lower,
confidence_upper=confidence_upper,
median_curve=median_curve,
var=float(var_threshold),
cvar=cvar,
prob_profit=prob_profit,
n_sims=int(n_sims),
confidence=float(confidence),
)
# ---------------------------------------------------------------------------
# Portfolio backtest
# ---------------------------------------------------------------------------
def backtest_portfolio(
close_2d: ArrayLike,
weights_2d: ArrayLike,
*,
commission_per_trade: float = 0.0,
slippage_bps: float = 0.0,
periods_per_year: float = 252.0,
parallel: bool = True,
max_asset_weight: float = 1.0,
max_gross_exposure: float = 0.0,
max_net_exposure: float = 0.0,
) -> PortfolioBacktestResult:
"""Backtest a portfolio of N assets in parallel.
Parameters
----------
close_2d : array-like, shape (n_bars, n_assets)
Close prices for each asset.
weights_2d : array-like, shape (n_bars, n_assets)
Desired position per asset per bar (lagged internally like signals).
commission_per_trade : float
Per-position-change commission (default 0).
slippage_bps : float
Slippage in basis points (default 0).
periods_per_year : float
Annualisation factor for metrics (default 252).
parallel : bool
Use rayon parallelism (default True).
Returns
-------
PortfolioBacktestResult
"""
c2d = np.ascontiguousarray(close_2d, dtype=np.float64)
w2d = np.ascontiguousarray(weights_2d, dtype=np.float64)
asset_returns, portfolio_returns, portfolio_equity = (
_rust_backtest_multi_asset_core(
c2d,
w2d,
commission_per_trade,
slippage_bps,
parallel,
max_asset_weight,
max_gross_exposure,
max_net_exposure,
)
)
asset_returns = np.asarray(asset_returns, dtype=np.float64)
portfolio_returns = np.asarray(portfolio_returns, dtype=np.float64)
portfolio_equity = np.asarray(portfolio_equity, dtype=np.float64)
metrics = dict(
_rust_compute_perf_metrics(
portfolio_returns, portfolio_equity, periods_per_year, 0.0
)
)
return PortfolioBacktestResult(
asset_returns=asset_returns,
portfolio_returns=portfolio_returns,
portfolio_equity=portfolio_equity,
metrics=metrics,
)
@dataclasses.dataclass
class PortfolioBacktestResult:
"""Result from a multi-asset portfolio backtest.
Attributes
----------
asset_returns : NDArray[np.float64]
Shape (n_bars, n_assets) — per-asset strategy returns.
portfolio_returns : NDArray[np.float64]
Shape (n_bars,) — combined portfolio returns.
portfolio_equity : NDArray[np.float64]
Shape (n_bars,) — cumulative portfolio equity.
metrics : dict[str, float]
Full performance metrics on the portfolio equity curve.
"""
asset_returns: NDArray
portfolio_returns: NDArray
portfolio_equity: NDArray
metrics: dict