"""Causal replay backtester — the RL verifier's beating heart. Ports tradewatch's paper_ledger.py accounting into a vectorised, deterministic, LEAK-PROOF backtest. A strategy is a pure function: def strategy(features_up_to_t: dict[str, float], position: float) -> float # returns target position in [-1, 1] (fraction of equity; <0 = short) The engine calls it bar-by-bar, passing ONLY features at indices <= t. The strategy can never see the future because future bars are not in the dict it receives. Fills happen at the NEXT bar's open-equivalent (close[t] used as decision price, close[t+1] as fill realisation) with slippage + fees, so a decision at t cannot be filled at t's hindsight-perfect price. Metrics: Sharpe (primary), CAGR, max drawdown, equity/cash exposure, turnover, win rate. Benchmarks: buy-and-hold, MA-crossover, z-score mean-reversion. """ from __future__ import annotations from dataclasses import dataclass, field import numpy as np from .features import feature_frame, sma, zscore @dataclass class BacktestResult: equity_curve: np.ndarray returns: np.ndarray sharpe: float cagr: float max_drawdown: float turnover: float win_rate: float final_equity: float avg_exposure: float n_trades: int error: str | None = None meta: dict = field(default_factory=dict) def to_dict(self) -> dict: return { "sharpe": round(self.sharpe, 4), "cagr": round(self.cagr, 4), "max_drawdown": round(self.max_drawdown, 4), "turnover": round(self.turnover, 4), "win_rate": round(self.win_rate, 4), "final_equity": round(self.final_equity, 4), "avg_exposure": round(self.avg_exposure, 4), "n_trades": self.n_trades, "error": self.error, } # Periods-per-year for annualisation (daily bars by default; 24*365 for hourly). def _ann_factor(bars_per_year: float) -> float: return float(np.sqrt(bars_per_year)) def _metrics(equity: np.ndarray, exposures: np.ndarray, bars_per_year: float, error: str | None = None) -> BacktestResult: equity = np.asarray(equity, dtype=float) if len(equity) < 2 or np.any(~np.isfinite(equity)) or np.any(equity <= 0): return BacktestResult(equity, np.zeros(1), 0.0, 0.0, 1.0, 0.0, 0.0, float(equity[-1]) if len(equity) else 0.0, 0.0, 0, error=error or "degenerate equity") rets = np.diff(equity) / equity[:-1] sd = rets.std() sharpe = float(rets.mean() / sd * _ann_factor(bars_per_year)) if sd > 1e-12 else 0.0 n = len(equity) years = n / bars_per_year cagr = float((equity[-1] / equity[0]) ** (1 / years) - 1) if years > 0 and equity[0] > 0 else 0.0 peak = np.maximum.accumulate(equity) max_dd = float(np.max((peak - equity) / peak)) if len(peak) else 0.0 exp = np.asarray(exposures, dtype=float) turnover = float(np.sum(np.abs(np.diff(np.insert(exp, 0, 0.0))))) n_trades = int(np.sum(np.abs(np.diff(np.insert(exp, 0, 0.0))) > 1e-9)) win_rate = float(np.mean(rets > 0)) if len(rets) else 0.0 return BacktestResult(equity, rets, sharpe, cagr, max_dd, turnover, win_rate, float(equity[-1]), float(np.mean(np.abs(exp))), n_trades, error=error) def run_backtest( close: np.ndarray, strategy_fn, *, fee_bps: float = 30.0, # 0.30% per unit turnover (DEX-realistic) slippage_bps: float = 20.0, # 0.20% adverse slippage on fills bars_per_year: float = 365.0, allow_short: bool = True, warmup: int = 50, # bars reserved for feature warmup (no trading) ) -> BacktestResult: """Step the strategy causally over `close`, return performance metrics. Fill model: decision made on info up to t, position realised against return from t -> t+1, minus fees/slippage proportional to position change. """ close = np.asarray(close, dtype=float) if len(close) < warmup + 5: return _metrics(np.array([1.0, 1.0]), np.zeros(1), bars_per_year, error="too few bars") feats = feature_frame(close) rets = np.zeros(len(close)) rets[1:] = np.diff(close) / np.where(close[:-1] == 0, np.nan, close[:-1]) rets = np.nan_to_num(rets) equity = [1.0] exposures = [] pos = 0.0 cost_rate = (fee_bps + slippage_bps) / 1e4 for t in range(warmup, len(close) - 1): # features visible at t (scalar snapshot, causal by construction) view = {k: (float(v[t]) if np.isfinite(v[t]) else 0.0) for k, v in feats.items()} try: target = float(strategy_fn(view, pos)) except Exception as e: # noqa: BLE001 - strategy bugs => penalised, not crash return _metrics(np.array(equity + [equity[-1]]), np.array(exposures + [pos]), bars_per_year, error=f"strategy raised: {type(e).__name__}: {e}") if not np.isfinite(target): target = 0.0 lo = -1.0 if allow_short else 0.0 target = max(lo, min(1.0, target)) trade_cost = abs(target - pos) * cost_rate # equity evolves with NEXT bar's realised return at the chosen exposure pnl = target * rets[t + 1] new_equity = equity[-1] * (1.0 + pnl - trade_cost) equity.append(new_equity) exposures.append(target) pos = target return _metrics(np.array(equity), np.array(exposures), bars_per_year) # ── Benchmarks (the anti-overfit baselines a strategy must beat) ────────────── def _buy_and_hold(view, pos): return 1.0 def _ma_crossover(view, pos): return 1.0 if view.get("sma_10", 0) > view.get("sma_20", 0) else 0.0 def _zscore_meanrev(view, pos): z = view.get("zscore_20", 0.0) if z < -1.5: return 1.0 if z > 1.5: return -1.0 return pos # hold def benchmarks(close: np.ndarray, **kw) -> dict[str, BacktestResult]: return { "buy_and_hold": run_backtest(close, _buy_and_hold, **kw), "ma_crossover": run_backtest(close, _ma_crossover, **kw), "zscore_meanrev": run_backtest(close, _zscore_meanrev, **kw), }