Files
quant-trading/strategies/qqq_trend.py
Gahow Wang ad569f1cea feat: us_momentum_qqq_trend; alpha-vs-QQQ research across new data sources
Register us_momentum_qqq_trend: half overnight_recovery_top10, half a QQQ
trend sleeve (strategies/qqq_trend.py: 200-day switch times a 30% vol target,
held as TQQQ + SHY). Mixed stock+ETF, US only (extra tickers QQQ, SHY, TQQQ).

Research (research/lab/): alpha against QQQ with a 2006-16 design / 2017-26
holdout split (alpha.py, qqq_scan.py, beat_qqq.py); leverage and drawdown
frontier (levered_book.py, us_target50.py); synthetic 3x ETFs from 1990 and
TrendRider/QQQ-trend stress tests (fetch_long_history.py, letf_history.py,
vol_trend.py); short-horizon sleeves, diversifiers, industry momentum,
earnings-announcement returns; new point-in-time data sources: SEC Form 4
insider trades (fetch_insider.py, insider.py), 10-K/10-Q text similarity,
8-K earnings dates and SIC codes (fetch_lazy_prices.py, lazy_prices.py), and
an S&P 400/600 universe from iShares holdings (fetch_us_mid.py, panel_mid.py).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 13:40:15 +08:00

57 lines
2.4 KiB
Python

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