ParallelLLC/algorithmic_trading
2732
1"""Vectorised, look-ahead-free backtest engine.2 3Contract4--------5A strategy emits ``target[t]``: the exposure it wants, decided using only6information available at the close of bar ``t``. The engine holds7``position[t] = target[t - lag]`` during bar ``t`` and credits it with that8bar's close-to-close return. With the default ``lag=1`` this means "decide on9today's close, hold the position through tomorrow" -- the single place where10look-ahead could sneak in, and it is one line.11 12Costs are charged on exposure *changes*, so a strategy that flips daily pays13for it. Short exposure additionally accrues a borrow fee.14"""15 16from __future__ import annotations17 18from typing import Optional19 20import numpy as np21import pandas as pd22 23from .metrics import compute_metrics, infer_periods_per_year24from .types import BacktestResult, CostModel25 26__all__ = ["run_backtest", "bars_to_returns"]27 28 29def bars_to_returns(df: pd.DataFrame) -> pd.Series:30 """Close-to-close simple returns."""31 return df["close"].astype(float).pct_change().fillna(0.0)32 33 34def run_backtest(35 df: pd.DataFrame,36 target: pd.Series,37 costs: CostModel | None = None,38 lag: int = 1,39 max_leverage: float = 1.0,40 allow_short: bool = True,41 initial_capital: float = 100_000.0,42 periods_per_year: Optional[int] = None,43 rf: float = 0.0,44 meta: Optional[dict] = None,45) -> BacktestResult:46 """Run one backtest and return equity, returns and the full metric bundle."""47 if df.empty:48 raise ValueError("Cannot backtest an empty price frame")49 if lag < 1:50 raise ValueError("lag must be >= 1; lag=0 would trade on unavailable information")51 52 costs = costs or CostModel()53 ppy = periods_per_year or infer_periods_per_year(df.index)54 55 asset_ret = bars_to_returns(df)56 57 target = target.reindex(df.index).astype(float).fillna(0.0)58 lower = -max_leverage if allow_short else 0.059 target = target.clip(lower, max_leverage)60 61 position = target.shift(lag).fillna(0.0)62 63 gross = position * asset_ret64 65 # Turnover is measured against the *drifted* weight, not the previous66 # target. Holding a full-notional long needs no rebalancing (the position67 # and the portfolio grow together), but a short does: lose 10% on a 100%68 # short and the weight drifts to -82%, so staying at -100% costs a trade.69 # See portfolio.py for the same formula in matrix form.70 growth = (1.0 + gross).replace(0.0, np.nan)71 drifted = (position * (1.0 + asset_ret)) / growth72 previous = drifted.shift(1).fillna(0.0)73 traded = position - previous74 trade_cost = traded.abs() * (costs.one_way_bps / 1e4)75 76 borrow_cost = position.clip(upper=0.0).abs() * (costs.short_borrow_bps / 1e4) / ppy77 total_cost = trade_cost + borrow_cost78 79 net = gross - total_cost80 equity = initial_capital * (1.0 + net).cumprod()81 benchmark_equity = initial_capital * (1.0 + asset_ret).cumprod()82 83 result = BacktestResult(84 equity=equity,85 returns=net,86 gross_returns=gross,87 position=position,88 target=target,89 costs=total_cost,90 benchmark_equity=benchmark_equity,91 metrics=compute_metrics(net, equity, position, ppy, rf),92 benchmark_metrics=compute_metrics(asset_ret, benchmark_equity, None, ppy, rf),93 meta={94 "lag": lag,95 "commission_bps": costs.commission_bps,96 "slippage_bps": costs.slippage_bps,97 "short_borrow_bps": costs.short_borrow_bps,98 "max_leverage": max_leverage,99 "allow_short": allow_short,100 "initial_capital": initial_capital,101 "periods_per_year": ppy,102 **(meta or {}),103 },104 )105 result.metrics["cost_drag_ann"] = float(total_cost.sum() / max(result.metrics.get("years", 1e-9), 1e-9))106 result.metrics["gross_sharpe"] = float(107 compute_metrics(gross, initial_capital * (1.0 + gross).cumprod(), None, ppy, rf).get("sharpe", 0.0)108 )109 return result110 111 112def fast_sharpe(113 asset_ret: np.ndarray,114 target: np.ndarray,115 one_way_bps: float,116 lag: int,117 periods_per_year: int,118) -> float:119 """Numpy-only Sharpe for hot loops (permutation tests, PBO grids).120 121 Mirrors :func:`run_backtest` exactly for the no-borrow case; it exists only122 because building a DataFrame 1000 times is the difference between a Space123 that answers in 4 seconds and one nobody waits for.124 """125 n = asset_ret.size126 position = np.empty(n, dtype=float)127 position[:lag] = 0.0128 position[lag:] = target[:-lag] if lag else target129 gross = position * asset_ret130 traded = np.empty(n, dtype=float)131 traded[0] = position[0]132 traded[1:] = np.diff(position)133 net = gross - np.abs(traded) * (one_way_bps / 1e4)134 net = net[np.isfinite(net)]135 if net.size < 2:136 return 0.0137 sd = net.std(ddof=1)138 if not np.isfinite(sd) or sd < 1e-12:139 return 0.0140 return float(net.mean() / sd * np.sqrt(periods_per_year))141 