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>
57 lines
2.4 KiB
Python
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"]
|