Files
my-python-backteat/ferro-ta-main/python/ferro_ta/analysis/backtest.py
T
2026-07-09 05:08:16 +08:00

1536 lines
52 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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
import dataclasses
import warnings
from collections import Counter
from collections.abc import Callable
from typing import Any, Optional, Union
import numpy as np
from numpy.typing import ArrayLike, NDArray
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
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
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
from ferro_ta._ferro_ta import walk_forward_indices as _rust_walk_forward_indices
from ferro_ta.core.exceptions import FerroTAInputError, FerroTAValueError
# ---------------------------------------------------------------------------
# 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)
# ---------------------------------------------------------------------------
# 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,
)
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,
)
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,
)
# ---------------------------------------------------------------------------
# Built-in strategy registry
# ---------------------------------------------------------------------------
_BENCHMARK_METRICS = frozenset(
(
"alpha",
"beta",
"tracking_error",
"information_ratio",
"benchmark_cagr",
"benchmark_sharpe",
)
)
_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 & compute signals
# ------------------------------------------------------------------
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]:
if isinstance(strategy, str):
if strategy not in _BUILTIN_STRATEGIES:
raise FerroTAValueError(
f"Unknown strategy '{strategy}'. "
f"Available: {sorted(_BUILTIN_STRATEGIES)}"
)
return _BUILTIN_STRATEGIES[strategy]
elif callable(strategy):
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]
"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)
else:
oos_returns = np.array([0.0])
oos_equity = np.array([1.0])
# 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,
)
# ---------------------------------------------------------------------------
# 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,
)
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