| """Vectorised, look-ahead-free backtest engine. |
| |
| Contract |
| -------- |
| A strategy emits ``target[t]``: the exposure it wants, decided using only |
| information available at the close of bar ``t``. The engine holds |
| ``position[t] = target[t - lag]`` during bar ``t`` and credits it with that |
| bar's close-to-close return. With the default ``lag=1`` this means "decide on |
| today's close, hold the position through tomorrow" -- the single place where |
| look-ahead could sneak in, and it is one line. |
| |
| Costs are charged on exposure *changes*, so a strategy that flips daily pays |
| for it. Short exposure additionally accrues a borrow fee. |
| """ |
|
|
| from __future__ import annotations |
|
|
| from typing import Optional |
|
|
| import numpy as np |
| import pandas as pd |
|
|
| from .metrics import compute_metrics, infer_periods_per_year |
| from .types import BacktestResult, CostModel |
|
|
| __all__ = ["run_backtest", "bars_to_returns"] |
|
|
|
|
| def bars_to_returns(df: pd.DataFrame) -> pd.Series: |
| """Close-to-close simple returns.""" |
| return df["close"].astype(float).pct_change().fillna(0.0) |
|
|
|
|
| def run_backtest( |
| df: pd.DataFrame, |
| target: pd.Series, |
| costs: CostModel | None = None, |
| lag: int = 1, |
| max_leverage: float = 1.0, |
| allow_short: bool = True, |
| initial_capital: float = 100_000.0, |
| periods_per_year: Optional[int] = None, |
| rf: float = 0.0, |
| meta: Optional[dict] = None, |
| ) -> BacktestResult: |
| """Run one backtest and return equity, returns and the full metric bundle.""" |
| if df.empty: |
| raise ValueError("Cannot backtest an empty price frame") |
| if lag < 1: |
| raise ValueError("lag must be >= 1; lag=0 would trade on unavailable information") |
|
|
| costs = costs or CostModel() |
| ppy = periods_per_year or infer_periods_per_year(df.index) |
|
|
| asset_ret = bars_to_returns(df) |
|
|
| target = target.reindex(df.index).astype(float).fillna(0.0) |
| lower = -max_leverage if allow_short else 0.0 |
| target = target.clip(lower, max_leverage) |
|
|
| position = target.shift(lag).fillna(0.0) |
|
|
| gross = position * asset_ret |
|
|
| |
| |
| |
| |
| |
| growth = (1.0 + gross).replace(0.0, np.nan) |
| drifted = (position * (1.0 + asset_ret)) / growth |
| previous = drifted.shift(1).fillna(0.0) |
| traded = position - previous |
| trade_cost = traded.abs() * (costs.one_way_bps / 1e4) |
|
|
| borrow_cost = position.clip(upper=0.0).abs() * (costs.short_borrow_bps / 1e4) / ppy |
| total_cost = trade_cost + borrow_cost |
|
|
| net = gross - total_cost |
| equity = initial_capital * (1.0 + net).cumprod() |
| benchmark_equity = initial_capital * (1.0 + asset_ret).cumprod() |
|
|
| result = BacktestResult( |
| equity=equity, |
| returns=net, |
| gross_returns=gross, |
| position=position, |
| target=target, |
| costs=total_cost, |
| benchmark_equity=benchmark_equity, |
| metrics=compute_metrics(net, equity, position, ppy, rf), |
| benchmark_metrics=compute_metrics(asset_ret, benchmark_equity, None, ppy, rf), |
| meta={ |
| "lag": lag, |
| "commission_bps": costs.commission_bps, |
| "slippage_bps": costs.slippage_bps, |
| "short_borrow_bps": costs.short_borrow_bps, |
| "max_leverage": max_leverage, |
| "allow_short": allow_short, |
| "initial_capital": initial_capital, |
| "periods_per_year": ppy, |
| **(meta or {}), |
| }, |
| ) |
| result.metrics["cost_drag_ann"] = float(total_cost.sum() / max(result.metrics.get("years", 1e-9), 1e-9)) |
| result.metrics["gross_sharpe"] = float( |
| compute_metrics(gross, initial_capital * (1.0 + gross).cumprod(), None, ppy, rf).get("sharpe", 0.0) |
| ) |
| return result |
|
|
|
|
| def fast_sharpe( |
| asset_ret: np.ndarray, |
| target: np.ndarray, |
| one_way_bps: float, |
| lag: int, |
| periods_per_year: int, |
| ) -> float: |
| """Numpy-only Sharpe for hot loops (permutation tests, PBO grids). |
| |
| Mirrors :func:`run_backtest` exactly for the no-borrow case; it exists only |
| because building a DataFrame 1000 times is the difference between a Space |
| that answers in 4 seconds and one nobody waits for. |
| """ |
| n = asset_ret.size |
| position = np.empty(n, dtype=float) |
| position[:lag] = 0.0 |
| position[lag:] = target[:-lag] if lag else target |
| gross = position * asset_ret |
| traded = np.empty(n, dtype=float) |
| traded[0] = position[0] |
| traded[1:] = np.diff(position) |
| net = gross - np.abs(traded) * (one_way_bps / 1e4) |
| net = net[np.isfinite(net)] |
| if net.size < 2: |
| return 0.0 |
| sd = net.std(ddof=1) |
| if not np.isfinite(sd) or sd < 1e-12: |
| return 0.0 |
| return float(net.mean() / sd * np.sqrt(periods_per_year)) |
|
|