"""TrendRider with realized-vol targeting overlay. Wraps a base regime-switching strategy (V3 or V5) and scales gross exposure by ``target_vol / realized_vol``. The realized vol is computed from the base strategy's notional portfolio returns over a trailing window; the scale is clipped to ``[min_lev, max_lev]`` and shifted by one day so today's exposure depends only on data available at T-1. Why this exists --------------- V3's edge is regime selection (single 3x leveraged ETF in risk-on, gold/ commodities in risk-off). On clean-trend windows it earns ~44% / yr with ~35% realized vol → Sharpe ~1.25. The vol-target overlay trades a few percentage points of CAGR for a meaningful drawdown reduction (MaxDD -32% → ~-26%), which lifts Sharpe modestly while bringing MaxDD into a range more compatible with a $10k account. Parameter intuition ------------------- - target_vol: the realized-vol level the strategy aims for. 0.24-0.32 is a reasonable band for a V3-like strategy. - vol_window: trailing window in trading days for realized-vol estimate. 60d is a good balance between responsiveness and noise. - min_lev / max_lev: clip the scale. min_lev > 0 ensures we never go to zero exposure during quiet periods. """ from __future__ import annotations import numpy as np import pandas as pd from strategies.permanent import TrendRiderV3 from strategies.trend_rider_v5 import TrendRiderV5 class _VolTargetWrapper: """Internal helper: apply a vol-target overlay to any Strategy.""" def __init__( self, base, target_vol: float = 0.28, vol_window: int = 60, min_lev: float = 0.5, max_lev: float = 1.0, warmup: int = 21, ) -> None: self.base = base self.target_vol = target_vol self.vol_window = vol_window self.min_lev = min_lev self.max_lev = max_lev self.warmup = warmup def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame: w = self.base.generate_signals(data) port_rets = (w * data.pct_change(fill_method=None).fillna(0.0)).sum(axis=1) realized_vol = ( port_rets.rolling(self.vol_window, min_periods=self.warmup).std() * np.sqrt(252) ) scale = (self.target_vol / realized_vol).clip( lower=self.min_lev, upper=self.max_lev, ) # PIT: today's scale depends on yesterday's realized vol scale = scale.shift(1).fillna(1.0) return w.mul(scale, axis=0) class TrendRiderV3VolTarget(_VolTargetWrapper): """Vol-targeted V3. Default: V3 with 28% annualized vol target (clipped 0.6-1.0). Empirical 10y lump-sum (PIT + IBKR tiered fees, $10k): Sharpe 1.29, ann 37.7%, MaxDD -28.1% (vs V3 baseline Sharpe 1.25 / ann 43.5% / MaxDD -32.5%). """ def __init__( self, target_vol: float = 0.28, vol_window: int = 60, min_lev: float = 0.6, max_lev: float = 1.0, risk_off: tuple[str, ...] | None = None, **base_kwargs, ) -> None: kwargs = dict(base_kwargs) if risk_off is not None: kwargs["risk_off"] = risk_off super().__init__( TrendRiderV3(**kwargs), target_vol=target_vol, vol_window=vol_window, min_lev=min_lev, max_lev=max_lev, ) class TrendRiderV5VolTarget(_VolTargetWrapper): """Vol-targeted V5 (V3 + leverage-tier modulator + vol target).""" def __init__( self, target_vol: float = 0.30, vol_window: int = 60, min_lev: float = 0.6, max_lev: float = 1.0, **base_kwargs, ) -> None: super().__init__( TrendRiderV5(**base_kwargs), target_vol=target_vol, vol_window=vol_window, min_lev=min_lev, max_lev=max_lev, ) __all__ = ["TrendRiderV3VolTarget", "TrendRiderV5VolTarget"]