""" QQQ trend with a volatility target, held through TQQQ and short Treasuries. Exposure to the Nasdaq-100 on each day, from QQQ closes through the day before: E = 1{QQQ > its 200-day average} x min(cap, target / EWMA vol of QQQ) E / 3 of the book is held in TQQQ (3x daily QQQ) and the rest in SHY, so E = 1 is QQQ-like exposure with two thirds of the capital in Treasuries. The 200-day switch is the classic trend rule (Faber 2007; Gayed & Bilello 2016 with leveraged ETFs); the volatility target scales exposure down when QQQ is turbulent (Moreira & Muir 2017), which is when drawdowns cluster. Research (research/lab/vol_trend.py, synthetic 3x ETFs, one-day-late fills): 1990-2026 CAGR 16.0% vs QQQ 14.9%, MaxDD -37% vs -83%, alpha vs QQQ 6%/yr (t 2.1). It lags QQQ in long calm bull markets (1990s, 2010-16) and earns its keep in 2000-02, 2008 and 2022. """ from __future__ import annotations import numpy as np import pandas as pd from strategies.base import Strategy class QQQVolTrend(Strategy): def __init__(self, ma: int = 200, target_vol: float = 0.30, cap: float = 1.5, halflife: int = 20, signal: str = "QQQ", levered: str = "TQQQ", leverage: float = 3.0, park: str = "SHY") -> None: self.ma, self.target_vol, self.cap, self.halflife = ma, target_vol, cap, halflife self.signal, self.levered, self.leverage, self.park = signal, levered, leverage, park def exposure(self, data: pd.DataFrame) -> pd.Series: """Index exposure decided on each row's close (not yet shifted).""" px = data[self.signal].ffill() r = px.pct_change(fill_method=None) sigma = np.sqrt((r ** 2).ewm(halflife=self.halflife, min_periods=40).mean() * 252) on = (px > px.rolling(self.ma).mean()).astype(float) e = (on * (self.target_vol / sigma).clip(upper=self.cap)).fillna(0.0) e.iloc[:self.ma] = 0.0 return e def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame: e = self.exposure(data) w = pd.DataFrame(0.0, index=data.index, columns=data.columns) lev = (e / self.leverage).where(data[self.levered].notna(), 0.0) w[self.levered] = lev if self.park in w.columns: w[self.park] = (1.0 - lev).where(data[self.park].notna(), 0.0) w.iloc[:self.ma] = 0.0 return w.shift(1).fillna(0.0) __all__ = ["QQQVolTrend"]