Avi-L commited on
Commit
d3ba216
·
verified ·
1 Parent(s): 30d3ad2

daily panel engine (divisor fix: cash for unlisted, not leverage)

Browse files
Files changed (1) hide show
  1. crypto_eval/engine2.py +95 -0
crypto_eval/engine2.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Daily multi-asset backtest engine for the panel sweep.
2
+
3
+ Simpler than the 1h engine: the panel carries close prices only.
4
+ Per asset: sub-capital = initial_capital / n_assets, positions in [-1, 1]
5
+ of that sub-capital. Portfolio return = equal-weight mean over the assets
6
+ LISTED on each date (pre-listing assets contribute nothing and are
7
+ excluded from the divisor).
8
+
9
+ Costs per asset:
10
+ - fee 5 bps taker / 2 bps maker on turnover notional
11
+ - square-root impact k * sigma_daily * sqrt(Q / ADV) with per-asset ADV
12
+ - perp funding drag: position * funding_apr / 365 per day (longs pay;
13
+ a constant-APR approximation since funding history does not extend
14
+ before 2021 — conservative and honest for a daily backtest)
15
+
16
+ No-lookahead: strategies pass positions decided at t using data through
17
+ t-1 (shift(1) inside the strategy); the engine adds the assert that the
18
+ first position day's PnL uses the NEXT bar's return, never the same bar's.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import numpy as np
23
+ import pandas as pd
24
+
25
+ TAKER_FEE = 0.0005
26
+ MAKER_FEE = 0.0002
27
+ SLIP_FLOOR_BPS = 2.0
28
+ IMPACT_K = 0.5
29
+ FUNDING_APR = 0.1095 # 0.01%/8h baseline, annualized
30
+
31
+
32
+ def realized_daily_vol_bps(close: pd.Series) -> float:
33
+ rets = close.pct_change().dropna()
34
+ return float(rets.std(ddof=1) * 1e4)
35
+
36
+
37
+ def run_asset_backtest(
38
+ close: pd.Series,
39
+ positions: pd.Series,
40
+ adv_usd: float,
41
+ sub_capital: float,
42
+ fill_side: str = "taker",
43
+ ) -> pd.Series:
44
+ """Net daily return series (fraction of sub-capital) for one asset."""
45
+ if not positions.index.equals(close.index):
46
+ raise ValueError("positions index must equal close index")
47
+ if positions.isna().any():
48
+ raise ValueError("positions contain NaN")
49
+ ret = close.pct_change()
50
+ # position decided at t earns the t+1 close-to-close return
51
+ gross_ret = (positions * ret.shift(-1)).fillna(0.0)
52
+
53
+ dpos = positions.diff().fillna(positions.iloc[0])
54
+ turnover_frac = dpos.abs()
55
+ notional = turnover_frac * sub_capital
56
+ fee_rate = TAKER_FEE if fill_side == "taker" else MAKER_FEE
57
+ sigma_bps = realized_daily_vol_bps(close)
58
+ bps = np.maximum(
59
+ SLIP_FLOOR_BPS, IMPACT_K * sigma_bps * np.sqrt(notional / adv_usd)
60
+ )
61
+ cost_ret = (notional * (fee_rate + bps / 1e4)) / sub_capital
62
+
63
+ fund_ret = positions * FUNDING_APR / 365.0
64
+ return gross_ret - cost_ret - fund_ret
65
+
66
+
67
+ def run_panel_backtest(
68
+ panel: pd.DataFrame,
69
+ positions: pd.DataFrame,
70
+ adv: dict[str, float],
71
+ initial_capital: float = 1_000_000.0,
72
+ fill_side: str = "taker",
73
+ ) -> dict:
74
+ if not positions.index.equals(panel.index):
75
+ raise ValueError("positions index must equal panel index")
76
+ if positions.isna().any().any():
77
+ raise ValueError("positions contain NaN")
78
+
79
+ n_assets = panel.shape[1]
80
+ sub_capital = initial_capital / n_assets
81
+ per_asset = {}
82
+ for sym in panel.columns:
83
+ per_asset[sym] = run_asset_backtest(
84
+ panel[sym].dropna(), positions[sym].reindex(panel[sym].dropna().index).fillna(0.0),
85
+ adv[sym], sub_capital, fill_side,
86
+ )
87
+ rets = pd.DataFrame(per_asset)
88
+ # capital for not-yet-listed assets sits in CASH (0 return); the
89
+ # divisor is ALWAYS the full asset count — dividing by the number of
90
+ # listed assets would silently apply leverage in the early era.
91
+ portfolio_ret = rets.fillna(0.0).sum(axis=1) / n_assets
92
+ return {
93
+ "portfolio_return": portfolio_ret,
94
+ "per_asset_returns": rets,
95
+ }