trade-pool / trade_pool /backtester.py
tosi-n's picture
Upload folder using huggingface_hub
ce6b50a verified
Raw
History Blame Contribute Delete
6.21 kB
"""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),
}