Three-layer strategy for leveraged ETF portfolios: Layer 1: V3 regime engine (MA150) — SPY technicals for risk-on/off Layer 2: Vol-target overlay (28%, clip 0.6-1.0) — scale by realized vol Layer 3: Profit-take with hysteresis (+30% → clear to SHY, restore <20%) The profit-take exploits a structural property of 3x leveraged ETFs: after large gains, volatility drag on the inflated base erodes compound returns. Clearing the position locks in geometric gains before the drag takes effect — this is rebalancing alpha, not prediction alpha. 10y backtest (2016-2026, 10bps one-way cost): Ann 54.7%, Sharpe(rf=5%) 1.72, MaxDD -25.7%, Sortino 2.23 Also registers trend_rider_v7, trend_rider_v7_vt24, trend_rider_v7_vt32 in the trader strategy registry and ETF_STRATEGY_UNIVERSES. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
121 lines
3.9 KiB
Python
121 lines
3.9 KiB
Python
"""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"]
|