"""
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
