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>
186 lines
7.9 KiB
Python
186 lines
7.9 KiB
Python
"""
|
|
Synthetic leveraged ETFs back to 1986, and the trend strategies run on them.
|
|
|
|
The live ETF panel starts in 2016 and the TrendRider research in 2010, so
|
|
neither saw the 2000-2002 Nasdaq crash or 2008 with 3x ETFs. This rebuilds
|
|
the TrendRider basket from its underlying indices and replays it.
|
|
|
|
PYTHONPATH=. uv run python -m research.lab.fetch_long_history # once
|
|
PYTHONPATH=. uv run python -m research.lab.letf_history
|
|
|
|
Synthetic series (daily):
|
|
SPY S&P 500 total return: SPY (1993-), VFINX before that
|
|
QQQ Nasdaq-100 total return: QQQ (1999-03-), ^NDX (or FRED NASDAQ100)
|
|
+ 0.5%/yr dividends before
|
|
UPRO / TQQQ 3 x underlying - 2 x (T-bill + 0.4%) - 0.95%/yr expense, daily reset
|
|
SHY 2-year Treasury from DGS2 (duration 1.9); TLT from DGS30 (duration 17;
|
|
DGS10 changes fill the 2002-2006 gap in DGS30)
|
|
GLD gold futures GC=F from 2000-08 (- 0.4%/yr); cash (the SHY series) before
|
|
DBC DBC from 2006-02; cash before (TrendRider's risk-off pick then has no
|
|
commodity leg)
|
|
|
|
The synthetic 3x series are checked against the real TQQQ/UPRO. Strategies
|
|
are run in two fill modes that bracket the live book:
|
|
close weights from closes through t-1 traded at the close of t-1 (optimistic)
|
|
lag the same weights traded one session later (pessimistic; live trades
|
|
the same session on the open's information)
|
|
Book: $10k, 5 bp, $2 per order, 10% resize band (as trend_rider_long.py).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import pickle
|
|
import warnings
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
from main import simulate_live_book, strategy_positions
|
|
from strategies.base import Strategy
|
|
from trader import RESIZE_BAND, STRATEGY_REGISTRY
|
|
|
|
warnings.filterwarnings("ignore")
|
|
SRC = os.path.join("data", "research", "long_history.pkl")
|
|
OUT = os.path.join("data", "research", "letf_history.pkl")
|
|
FIN_SPREAD, EXPENSE = 0.004, 0.0095
|
|
BLOCKS = [("1990-01-01", "1999-12-31"), ("2000-01-01", "2002-12-31"), ("2003-01-01", "2007-12-31"),
|
|
("2008-01-01", "2009-12-31"), ("2010-01-01", "2015-12-31"), ("2016-01-01", "2026-09-25"),
|
|
("1990-01-01", "2026-09-25")]
|
|
|
|
|
|
def _splice(primary: pd.Series, backfill_ret: pd.Series, index: pd.DatetimeIndex) -> pd.Series:
|
|
"""Daily returns: the primary series where it exists, backfill before it."""
|
|
rp = primary.reindex(index).pct_change(fill_method=None)
|
|
rb = backfill_ret.reindex(index)
|
|
first = primary.first_valid_index()
|
|
return rb.where(index <= first, rp).fillna(0.0) if first is not None else rb.fillna(0.0)
|
|
|
|
|
|
def build(src: dict) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
"""(synthetic closes, real closes for validation) on the S&P 500 calendar from 1986."""
|
|
c, fred = src["close"], src["fred"].ffill()
|
|
idx = c["^GSPC"].dropna().loc["1986-01-01":].index
|
|
rf = fred["DTB3"].reindex(idx.union(fred.index)).ffill().reindex(idx) / 100
|
|
y2 = fred["DGS2"].reindex(idx.union(fred.index)).ffill().reindex(idx) / 100
|
|
y30 = fred["DGS30"].reindex(idx.union(fred.index)).ffill(limit=5).reindex(idx) / 100
|
|
y10 = fred["DGS10"].reindex(idx.union(fred.index)).ffill().reindex(idx) / 100
|
|
dy30 = y30.diff().where(y30.notna() & y30.shift(1).notna(), y10.diff()).fillna(0.0)
|
|
y30f = y30.fillna(y10)
|
|
|
|
spx_bf = c["VFINX"].reindex(idx).pct_change(fill_method=None)
|
|
spx_bf = spx_bf.fillna(c["^GSPC"].reindex(idx).pct_change(fill_method=None) + 0.03 / 252)
|
|
spx = _splice(c["SPY"], spx_bf, idx)
|
|
ndx_px = c["^NDX"] if "^NDX" in c else fred["NASDAQ100"]
|
|
ndx_bf = ndx_px.reindex(idx).pct_change(fill_method=None) + 0.005 / 252
|
|
ndx = _splice(c["QQQ"], ndx_bf, idx)
|
|
|
|
def letf(r: pd.Series, lev: float) -> pd.Series:
|
|
return lev * r - (lev - 1) * (rf + FIN_SPREAD) / 252 - EXPENSE / 252
|
|
|
|
shy = y2.shift(1) / 252 - 1.9 * y2.diff().fillna(0.0)
|
|
tlt = y30f.shift(1) / 252 - 17.0 * dy30
|
|
gold = c["GC=F"].reindex(idx).ffill().pct_change(fill_method=None) - 0.004 / 252
|
|
gld = gold.where(idx > c["GC=F"].first_valid_index(), shy)
|
|
dbc = c["DBC"].reindex(idx).ffill().pct_change(fill_method=None)
|
|
dbc = dbc.where(idx > c["DBC"].first_valid_index(), shy)
|
|
rets = pd.DataFrame({"SPY": spx, "QQQ": ndx, "UPRO": letf(spx, 3), "TQQQ": letf(ndx, 3),
|
|
"SSO": letf(spx, 2), "QLD": letf(ndx, 2), "SHY": shy, "TLT": tlt,
|
|
"GLD": gld, "DBC": dbc}).fillna(0.0)
|
|
syn = 100 * (1 + rets).cumprod()
|
|
real = c.reindex(idx)[["SPY", "QQQ", "UPRO", "TQQQ", "SSO", "QLD", "SHY", "TLT", "GLD", "DBC"]]
|
|
return syn, real
|
|
|
|
|
|
def validate(syn: pd.DataFrame, real: pd.DataFrame) -> None:
|
|
print("synthetic vs real (overlap): daily corr, annual return real / synthetic")
|
|
for t in ["UPRO", "TQQQ", "SSO", "QLD", "SHY", "TLT", "GLD"]:
|
|
r = real[t].pct_change(fill_method=None)
|
|
s = syn[t].pct_change(fill_method=None)
|
|
ok = r.notna() & s.notna()
|
|
r, s = r[ok], s[ok]
|
|
yrs = len(r) / 252
|
|
print(f" {t:5s} {r.index[0].date()}- corr {r.corr(s):.4f} real {((1 + r).prod() ** (1 / yrs) - 1) * 100:6.2f}%"
|
|
f" syn {((1 + s).prod() ** (1 / yrs) - 1) * 100:6.2f}%")
|
|
|
|
|
|
class HoldRotation(Strategy):
|
|
"""Gayed & Bilello (2016) style: hold ``on`` while the signal is above its
|
|
``ma``-day average, else ``off``."""
|
|
|
|
def __init__(self, on: str, off: str = "SHY", signal: str = "SPY", ma: int = 200):
|
|
self.on, self.off, self.signal, self.ma = on, off, signal, ma
|
|
|
|
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
|
|
s = data[self.signal]
|
|
up = s > s.rolling(self.ma).mean()
|
|
w = pd.DataFrame(0.0, index=data.index, columns=data.columns)
|
|
w[self.on] = up.astype(float)
|
|
w[self.off] = (~up).astype(float)
|
|
w.iloc[:self.ma] = 0.0
|
|
return w.shift(1).fillna(0.0)
|
|
|
|
|
|
class Hold(Strategy):
|
|
def __init__(self, asset: str):
|
|
self.asset = asset
|
|
|
|
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
|
|
w = pd.DataFrame(0.0, index=data.index, columns=data.columns)
|
|
w[self.asset] = 1.0
|
|
return w
|
|
|
|
|
|
def strategies() -> dict[str, Strategy]:
|
|
out = {k: STRATEGY_REGISTRY[k]() for k in ("trend_rider_v7_vt24", "trend_rider_v7_vt36")}
|
|
out.update({"ma200 TQQQ/SHY": HoldRotation("TQQQ"), "ma200 UPRO/SHY": HoldRotation("UPRO"),
|
|
"ma200 SSO/SHY": HoldRotation("SSO"), "hold SPY": Hold("SPY"), "hold QQQ": Hold("QQQ"),
|
|
"hold TQQQ": Hold("TQQQ"), "hold UPRO": Hold("UPRO")})
|
|
return out
|
|
|
|
|
|
def run(strategy: Strategy, close: pd.DataFrame, mode: str) -> pd.Series:
|
|
held = strategy_positions(strategy, close)
|
|
if mode == "lag":
|
|
held = held.shift(1).fillna(0.0)
|
|
eq, _ = simulate_live_book(held, close, 10_000, 0.0005, 2.0, 0, 0, 50.0, RESIZE_BAND)
|
|
return eq
|
|
|
|
|
|
def block_stats(eq: pd.Series) -> list[str]:
|
|
r = eq.pct_change().fillna(0.0)
|
|
out = []
|
|
for a, b in BLOCKS:
|
|
x = r.loc[a:b]
|
|
if len(x) < 100:
|
|
out.append(" n/a")
|
|
continue
|
|
e = (1 + x).cumprod()
|
|
cagr = e.iloc[-1] ** (252 / len(x)) - 1
|
|
sh = x.mean() / x.std() * np.sqrt(252) if x.std() > 0 else 0.0
|
|
out.append(f"{cagr*100:6.1f}%/{sh:4.2f}/{((e / e.cummax()) - 1).min()*100:4.0f}%")
|
|
return out
|
|
|
|
|
|
def main() -> None:
|
|
with open(SRC, "rb") as fh:
|
|
src = pickle.load(fh)
|
|
syn, real = build(src)
|
|
validate(syn, real)
|
|
close = syn.loc["1989-01-01":]
|
|
head = " ".join(f"{a[:4]}-{b[2:4]:>2s}".rjust(16) for a, b in BLOCKS)
|
|
print(f"\nCAGR/Sharpe/MaxDD by block (synthetic ETFs; GLD = cash before 2000-09, DBC = cash before 2006-02)")
|
|
print(f"{'':30s}{head}")
|
|
eqs = {}
|
|
for name, strat in strategies().items():
|
|
for mode in ("close", "lag"):
|
|
eq = run(strat, close, mode)
|
|
eqs[f"{name} [{mode}]"] = eq
|
|
print(f"{name + ' [' + mode + ']':30s}" + " ".join(s.rjust(16) for s in block_stats(eq)), flush=True)
|
|
with open(OUT, "wb") as fh:
|
|
pickle.dump({"close": syn, "equity": eqs}, fh)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|