vectorized backtest engine
Browse files- crypto_eval/engine.py +72 -0
crypto_eval/engine.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Backtest engine: net PnL from positions, bars and cost models.
|
| 2 |
+
|
| 3 |
+
PnL at bar t (bar covers [t, t+freq)) accrues from holding the position
|
| 4 |
+
decided at t through the bar. Decision-time discipline is the strategy's
|
| 5 |
+
responsibility (use shift_bars in crypto_eval.strategies); a strategy
|
| 6 |
+
that peeks is caught downstream by the purge/embargo audit and by the
|
| 7 |
+
forward-return structure here.
|
| 8 |
+
|
| 9 |
+
Fees + slippage are charged on position changes (turnover), funding is
|
| 10 |
+
accrued at funding ticks per costs.funding_cost.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
import pandas as pd
|
| 17 |
+
|
| 18 |
+
from .costs import funding_cost, fee, slippage_cost
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def run_backtest(
|
| 22 |
+
bars: pd.DataFrame,
|
| 23 |
+
positions: pd.Series,
|
| 24 |
+
funding: pd.Series,
|
| 25 |
+
fill_side: str = "taker",
|
| 26 |
+
adv_notional: float = 5e7, # Binance BTCUSDT spot ADV, conservative
|
| 27 |
+
initial_capital: float = 1_000_000.0,
|
| 28 |
+
) -> dict:
|
| 29 |
+
if not positions.index.equals(bars.index):
|
| 30 |
+
raise ValueError("positions index must equal bars index")
|
| 31 |
+
if positions.isna().any():
|
| 32 |
+
raise ValueError("positions contain NaN (indeterminate signal)")
|
| 33 |
+
|
| 34 |
+
if isinstance(funding, pd.DataFrame):
|
| 35 |
+
funding = funding["funding_rate"]
|
| 36 |
+
|
| 37 |
+
close = bars["close"]
|
| 38 |
+
pos = positions
|
| 39 |
+
|
| 40 |
+
# per-bar PnL: position decided at t earns the bar's return
|
| 41 |
+
bar_ret = close.pct_change()
|
| 42 |
+
# position at t held over [t, t+1h): PnL uses bar_ret at t+1
|
| 43 |
+
pnl_per_bar = pos * bar_ret.shift(-1) * close
|
| 44 |
+
# the final bar has no realized forward return; treat as flat/zero
|
| 45 |
+
pnl_per_bar = pnl_per_bar.fillna(0.0)
|
| 46 |
+
|
| 47 |
+
# turnover: position changes at t fill at bar t's close
|
| 48 |
+
dpos = pos.diff().fillna(pos.iloc[0])
|
| 49 |
+
turnover_notional = (dpos.abs() * close)
|
| 50 |
+
fees = turnover_notional.map(lambda n: fee(n, fill_side))
|
| 51 |
+
slips = turnover_notional.map(lambda n: slippage_cost(n, adv_notional))
|
| 52 |
+
|
| 53 |
+
fund = funding_cost(pos, funding, close)
|
| 54 |
+
|
| 55 |
+
total_cost = fees + slips + fund
|
| 56 |
+
net_pnl = pnl_per_bar - total_cost
|
| 57 |
+
net_ret = net_pnl / initial_capital
|
| 58 |
+
|
| 59 |
+
return {
|
| 60 |
+
"net_pnl": net_pnl,
|
| 61 |
+
"net_return": net_ret,
|
| 62 |
+
"gross_pnl": pnl_per_bar,
|
| 63 |
+
"fees": fees,
|
| 64 |
+
"slippage": slips,
|
| 65 |
+
"funding": fund,
|
| 66 |
+
"turnover_notional": turnover_notional,
|
| 67 |
+
"total_fees": float(fees.sum()),
|
| 68 |
+
"total_slippage": float(slips.sum()),
|
| 69 |
+
"total_funding": float(fund.sum()),
|
| 70 |
+
"gross_total_pnl": float(pnl_per_bar.sum()),
|
| 71 |
+
"net_total_pnl": float(net_pnl.sum()),
|
| 72 |
+
}
|