File size: 7,815 Bytes
0821f38 | 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 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 | """Walk-forward backtest engine for the MODWT mid-band strategy.
Every signal value is generated using only information available at that point
in time:
- Price window ends at t-1 (strictly past)
- Signal applied to return from t to t+1 (via .shift(1))
- Vol estimator uses 60-day rolling realized vol of PAST returns
Transaction costs: `cost_bps` basis points per side on absolute notional
turnover, deducted from each rebalance return.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from .signal import compute_signal, volatility_target
from .stats import Stats, perf
# βββββββββββββββββββββββββββ constants βββββββββββββββββββββββββββββββββββββββ
LOOKBACK = 1024 # bars (~4 years daily); >> 8Γ max wavelet scale (128 bars)
STEP = 5 # weekly rebalance
VOL_WINDOW = 60 # rolling realized vol window (bars)
VOL_TARGET = 0.10 # annualized target vol
MAX_LEVERAGE = 2.0
WAVELET = "sym8"
LEVEL = 6
SIG_LEVELS = [4, 5]
SLOPE_WINDOW = 40
COST_BPS = 1.0 # per side in basis points
# βββββββββββββββββββββββββββ SMA benchmark βββββββββββββββββββββββββββββββββββ
def _sma_signal(prices: pd.Series, fast: int = 50, slow: int = 200) -> pd.Series:
"""50/200 SMA crossover signal: +1 above, -1 below, aligned to prices."""
sma_fast = prices.rolling(fast).mean()
sma_slow = prices.rolling(slow).mean()
raw = np.sign(sma_fast - sma_slow).fillna(0)
return raw
# βββββββββββββββββββββββββββ main loop βββββββββββββββββββββββββββββββββββββββ
def run_backtest(
price_series: pd.Series,
lookback: int = LOOKBACK,
step: int = STEP,
wavelet: str = WAVELET,
level: int = LEVEL,
sig_levels: list[int] | None = None,
slope_window: int = SLOPE_WINDOW,
vol_window: int = VOL_WINDOW,
vol_target: float = VOL_TARGET,
max_leverage: float = MAX_LEVERAGE,
cost_bps: float = COST_BPS,
) -> dict:
"""Walk-forward MODWT mid-band strategy backtest.
Args:
price_series: pd.Series of prices with DatetimeIndex, daily frequency.
lookback: bars of history used per MODWT window.
step: rebalance frequency in bars.
wavelet: PyWavelets wavelet.
level: MODWT depth.
sig_levels: detail levels to combine into mid-band signal.
slope_window: safe-series slope estimation window.
vol_window: rolling realized vol window for vol targeting.
cost_bps: transaction cost per side in basis points.
Returns:
dict with keys:
"strategy_stats": Stats for MODWT strategy
"bh_stats": Stats for buy-and-hold
"sma_stats": Stats for 50/200 SMA cross benchmark
"positions": pd.Series of daily positions
"raw_signals": pd.Series of Β±1/0 raw signals (weekly)
"equity_curve": pd.Series of strategy cumulative returns
"bh_equity": pd.Series of buy-and-hold cumulative returns
"sma_equity": pd.Series of SMA cumulative returns
"daily_returns": pd.Series of strategy daily P&L
"""
if sig_levels is None:
sig_levels = SIG_LEVELS
prices = price_series.dropna().copy()
log_prices = np.log(prices)
daily_ret = log_prices.diff().fillna(0)
n = len(prices)
if n < lookback + step:
raise ValueError(
f"Price series too short ({n} bars) for lookback={lookback} + step={step}"
)
# ββ rolling realized vol (past-only) βββββββββββββββββββββββββββββββββββββ
realized_vol = daily_ret.rolling(vol_window).std() * np.sqrt(252)
realized_vol = realized_vol.fillna(daily_ret.expanding().std() * np.sqrt(252))
# ββ generate signals on rebalance dates ββββββββββββββββββββββββββββββββββ
raw_signal = pd.Series(0.0, index=prices.index)
position = pd.Series(0.0, index=prices.index)
first_signal_idx = lookback # earliest bar where we have a full window
for i in range(first_signal_idx, n, step):
window_log = log_prices.iloc[i - lookback: i].values
sig = compute_signal(
window_log,
wavelet=wavelet,
level=level,
sig_levels=sig_levels,
slope_window=slope_window,
)
vol_today = float(realized_vol.iloc[i - 1])
sized = volatility_target(sig, vol_today, vol_target=vol_target, max_leverage=max_leverage)
raw_signal.iloc[i] = sig
# Hold until next rebalance
end = min(i + step, n)
position.iloc[i:end] = sized
# T+1 execution: position decided at t applied to return t β t+1
position_lagged = position.shift(1).fillna(0)
# ββ transaction costs βββββββββββββββββββββββββββββββββββββββββββββββββββββ
turnover = position_lagged.diff().abs()
cost = turnover * (cost_bps / 10_000)
# ββ strategy daily returns ββββββββββββββββββββββββββββββββββββββββββββββββ
strat_ret = position_lagged * daily_ret - cost
# ββ benchmarks ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Buy-and-hold (always long 1Γ)
bh_ret = daily_ret.copy()
# SMA 50/200 crossover β same T+1 and cost model
sma_pos = _sma_signal(prices).shift(1).fillna(0)
sma_turnover = sma_pos.diff().abs()
sma_cost = sma_turnover * (cost_bps / 10_000)
sma_ret = sma_pos * daily_ret - sma_cost
# ββ equity curves ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Trim burn-in period (first `lookback` bars had no signals)
start_idx = first_signal_idx + step
strat_ret_trim = strat_ret.iloc[start_idx:]
bh_ret_trim = bh_ret.iloc[start_idx:]
sma_ret_trim = sma_ret.iloc[start_idx:]
pos_trim = position_lagged.iloc[start_idx:]
equity = (1 + strat_ret_trim).cumprod()
bh_equity = (1 + bh_ret_trim).cumprod()
sma_equity = (1 + sma_ret_trim).cumprod()
# ββ statistics ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
strategy_stats = perf(
strat_ret_trim, pos_trim, name="MODWT Mid-Band",
benchmark=bh_ret_trim,
)
bh_stats = perf(
bh_ret_trim, pd.Series(1.0, index=bh_ret_trim.index),
name="Buy & Hold", benchmark=bh_ret_trim,
)
sma_stats = perf(
sma_ret_trim, sma_pos.iloc[start_idx:],
name="50/200 SMA", benchmark=bh_ret_trim,
)
return {
"strategy_stats": strategy_stats,
"bh_stats": bh_stats,
"sma_stats": sma_stats,
"positions": pos_trim,
"raw_signals": raw_signal.iloc[start_idx:],
"equity_curve": equity,
"bh_equity": bh_equity,
"sma_equity": sma_equity,
"daily_returns": strat_ret_trim,
}
|