""" 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.05–0.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