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