Download algotrader/engine.py from ParallelLLC/algorithmic_trading: direct link, hf CLI and curl.
- Browser
- Download file 4.97 kB
-
https://huggingface.co/ParallelLLC/algorithmic_trading/resolve/main/algotrader/engine.py
- Command line
-
hf download hf://ParallelLLC/algorithmic_trading/algotrader/engine.py
-
curl -L -o engine.py https://huggingface.co/ParallelLLC/algorithmic_trading/resolve/main/algotrader/engine.py
4.97 kB
| """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 | |
| # Turnover is measured against the *drifted* weight, not the previous | |
| # target. Holding a full-notional long needs no rebalancing (the position | |
| # and the portfolio grow together), but a short does: lose 10% on a 100% | |
| # short and the weight drifts to -82%, so staying at -100% costs a trade. | |
| # See portfolio.py for the same formula in matrix form. | |
| 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)) | |