Avi-L commited on
Commit
c45a387
·
verified ·
1 Parent(s): 578ee58

vectorized backtest engine

Browse files
Files changed (1) hide show
  1. 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
+ }