Files
quant-trading/research/lab/vol_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

126 lines
5.3 KiB
Python

"""
Leveraged trend without TrendRider's machinery: a trend filter times a
volatility target, implemented with 3x ETFs, tested on synthetic ETFs from
1990 and with parameters picked walk-forward.
PYTHONPATH=. uv run python -m research.lab.fetch_long_history # once
PYTHONPATH=. uv run python -m research.lab.vol_trend
Exposure to the index (SPY or QQQ, total return) decided on closes through
t-1:
E_t = 1{index > its n-day average} x min(cap, target / sigma_t)
sigma_t is the index's EWMA vol (20-day half-life). E/3 is held in the 3x
ETF (UPRO / TQQQ, synthetic: research/lab/letf_history.py), the rest in the
2-year Treasury series. Holdings drift; the book trades back to target when
the signal flips or E is more than 25% off; 10 bp per unit traded. Fills:
"lag" trades at the close after the decision (pessimistic), "close" at the
decision close (optimistic).
Moreira & Muir (2017) and Harvey et al. (2018) motivate the vol target;
Gayed & Bilello (2016) the moving-average switch. The grid is small and
round; the walk-forward chain picks, each January, the configuration with
the best Sharpe over the previous ten years.
"""
from __future__ import annotations
import itertools
import os
import pickle
import warnings
import numpy as np
import pandas as pd
from research.lab.letf_history import BLOCKS, SRC, build
warnings.filterwarnings("ignore")
OUT = os.path.join("data", "research", "results", "vol_trend.pkl")
COST, BAND = 0.001, 0.25
GRID = list(itertools.product(("SPY", "QQQ"), (100, 150, 200), (0.15, 0.20, 0.25, 0.30, 0.40), (1.5, 3.0)))
LETF = {"SPY": "UPRO", "QQQ": "TQQQ"}
def backtest(syn: pd.DataFrame, index: str, n: int, target: float, cap: float,
lag: bool = True) -> tuple[pd.Series, pd.Series]:
px = syn[index]
r = px.pct_change().fillna(0.0)
on = (px > px.rolling(n).mean()).astype(float)
sigma = np.sqrt((r ** 2).ewm(halflife=20, min_periods=40).mean() * 252)
E = (on * (target / sigma).clip(upper=cap)).fillna(0.0)
E.iloc[:n] = 0.0
tgt = (E / 3.0).shift(2 if lag else 1).fillna(0.0).to_numpy() # row t = weight held over t
rl = syn[LETF[index]].pct_change().fillna(0.0).to_numpy()
rs = syn["SHY"].pct_change().fillna(0.0).to_numpy()
w, out, held = 0.0, np.empty(len(tgt)), np.empty(len(tgt))
for t in range(len(tgt)):
if t > 0:
g = w * (1 + rl[t]) + (1 - w) * (1 + rs[t])
out[t] = g - 1
w = w * (1 + rl[t]) / g
else:
out[t] = 0.0
want = tgt[t + 1] if t + 1 < len(tgt) else w
if (want == 0) != (w == 0) or abs(want - w) > BAND * max(w, 1e-9):
out[t] -= abs(want - w) * COST * 2 # sell one leg, buy the other
w = want
held[t] = w * 3
return pd.Series(out, index=syn.index), pd.Series(held, index=syn.index)
def sharpe(r: pd.Series) -> float:
return float(r.mean() / r.std() * np.sqrt(252)) if r.std() > 0 else 0.0
def block_line(r: pd.Series) -> str:
cells = []
for a, b in BLOCKS:
x = r.loc[a:b]
e = (1 + x).cumprod()
cells.append(f"{(e.iloc[-1] ** (252 / len(x)) - 1)*100:6.1f}%/{sharpe(x):4.2f}/{((e / e.cummax()) - 1).min()*100:4.0f}%")
return " ".join(c.rjust(17) for c in cells)
def walk_forward(R: pd.DataFrame, lookback_years: int = 10, start: int = 2000) -> tuple[pd.Series, dict]:
parts, picks = [], {}
for y in range(start, R.index[-1].year + 1):
hist = R.loc[f"{y - lookback_years}-01-01":f"{y - 1}-12-31"]
best = hist.apply(sharpe).idxmax()
picks[y] = best
parts.append(R.loc[f"{y}-01-01":f"{y}-12-31", best])
return pd.concat(parts), picks
def main() -> None:
with open(SRC, "rb") as fh:
syn, _ = build(pickle.load(fh))
syn = syn.loc["1989-01-01":]
R, H, params = {}, {}, {}
for idx, n, tv, cap in GRID:
key = f"{idx} ma{n} vt{int(tv*100)} cap{cap:g}"
params[key] = (idx, n, tv, cap)
R[key], H[key] = backtest(syn, idx, n, tv, cap)
R = pd.DataFrame(R).loc["1990-01-01":]
head = " ".join(f"{a[:4]}-{b[2:4]:>2s}".rjust(17) for a, b in BLOCKS)
full = R.apply(sharpe).sort_values(ascending=False)
print(f"CAGR/Sharpe/MaxDD by block, lag fills, synthetic 3x ETFs\n{'':28s}{head}")
for k in list(full.index[:8]) + list(full.index[len(full) // 2:len(full) // 2 + 2]) + list(full.index[-2:]):
print(f"{k:28s}{block_line(R[k])} avg E {H[k].loc['1990':].mean():.2f}")
print(f"\nfull-period Sharpe over the {len(full)}-config grid: median {full.median():.2f}, "
f"range {full.min():.2f}-{full.max():.2f}")
wf, picks = walk_forward(R)
print(f"\nwalk-forward (best trailing-10y Sharpe each January), 2000-2026:\n{'':28s}{head}")
print(f"{'walk-forward chain':28s}{block_line(pd.concat([R.iloc[:, 0].loc[:'1999'] * np.nan, wf]).fillna(0.0))}")
print("picks:", {y: p for y, p in picks.items() if y in (2000, 2005, 2010, 2015, 2020, 2025, 2026)})
close_mode = {k: backtest(syn, *params[k], lag=False)[0].loc["1990-01-01":] for k in full.index[:3]}
print("\nsame top configs with close fills (optimistic):")
for k, r in close_mode.items():
print(f"{k:28s}{block_line(r)}")
os.makedirs(os.path.dirname(OUT), exist_ok=True)
with open(OUT, "wb") as fh:
pickle.dump({"returns": R, "exposure": pd.DataFrame(H), "walk_forward": wf, "picks": picks}, fh)
if __name__ == "__main__":
main()