Team Ai
Modelpublic

ParallelLLC/algorithmic_trading

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
27likes32downloads
engine.py141 linesDownload Raw Back to algotrader
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