Files
quant/strategies/trend_rider_voltgt.py
Gahow Wang df0a051403 feat(strategy): add TrendRider V7 — V3 + vol-target + profit-take
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>
2026-05-21 00:39:17 +08:00

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"]