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>
173 lines
7.5 KiB
Python
173 lines
7.5 KiB
Python
"""
|
|
How much return can risk-managed leverage add to the US sleeves, and at what
|
|
drawdown? A small account can borrow (Reg-T margin, at most 2x) where a fund
|
|
with a mandate cannot; the question is whether scaling exposure by recent
|
|
volatility lets that leverage be taken without the tail that static leverage
|
|
brings.
|
|
|
|
PYTHONPATH=. uv run python -m research.lab.levered_book
|
|
|
|
Input: daily sleeve returns from research/lab/us_final.py (and, when present,
|
|
short_horizon.py). Leverage is applied to the sleeve's return stream:
|
|
|
|
exposure_t = clip(target / vol_hat_{t-1}, 0, cap) (vol_hat from returns through t-1)
|
|
r_t = exposure_t * sleeve_t - max(exposure_t - 1, 0) * (T-bill + spread)
|
|
- |exposure_t - exposure_{t-1}| * cost
|
|
|
|
Exposure only moves when it is more than ``band`` away from the current one
|
|
(fewer orders). Stock sleeves may go to 2x (Reg-T); the trend sleeve already
|
|
holds 3x ETFs, which brokers margin at 75-100%, so it is capped at 1x.
|
|
|
|
Vol estimators: realized over the last 126 days (Barroso & Santa-Clara 2015)
|
|
and EWMA with a 20-day half-life. Targets are round numbers, not fitted.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import warnings
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
from research.lab.factor_scan import RESULTS
|
|
from research.lab.us_final import monthly_blend
|
|
|
|
warnings.filterwarnings("ignore")
|
|
|
|
SPAN = ("2011-01-03", "2026-09-24")
|
|
LONG = ("2006-09-27", "2026-09-24")
|
|
SPREAD = 0.015 # IBKR benchmark + 1.5% under $100k, +1% to $1M
|
|
COST = 0.001 # one-way, per unit of exposure changed
|
|
RF_PATH = os.path.join("data", "research", "ext", "fred_dtb3.csv")
|
|
|
|
|
|
def tbill(index: pd.DatetimeIndex) -> pd.Series:
|
|
"""Daily 3-month T-bill rate (annual, decimal) from FRED DTB3."""
|
|
rf = pd.read_csv(RF_PATH, index_col=0, parse_dates=True).iloc[:, 0]
|
|
rf = pd.to_numeric(rf, errors="coerce").ffill() / 100
|
|
return rf.reindex(index.union(rf.index)).ffill().reindex(index)
|
|
|
|
|
|
def vol_hat(r: pd.Series, kind: str) -> pd.Series:
|
|
if kind == "rv126":
|
|
v = np.sqrt((r ** 2).rolling(126, min_periods=63).mean() * 252)
|
|
else:
|
|
v = np.sqrt((r ** 2).ewm(halflife=20, min_periods=63).mean() * 252)
|
|
return v.shift(1)
|
|
|
|
|
|
def banded(target: pd.Series, band: float) -> pd.Series:
|
|
out = np.empty(len(target))
|
|
cur = np.nan
|
|
for i, t in enumerate(target.to_numpy()):
|
|
if np.isnan(t):
|
|
out[i] = cur if not np.isnan(cur) else 1.0
|
|
continue
|
|
if np.isnan(cur) or abs(t - cur) > band * max(cur, 0.25):
|
|
cur = t
|
|
out[i] = cur
|
|
return pd.Series(out, index=target.index)
|
|
|
|
|
|
def lever(r: pd.Series, rf: pd.Series, exposure: pd.Series) -> pd.Series:
|
|
borrow = (exposure - 1).clip(lower=0) * (rf.reindex(r.index) + SPREAD) / 252
|
|
trade = exposure.diff().abs().fillna(0.0) * COST
|
|
return exposure * r - borrow - trade
|
|
|
|
|
|
def managed(r: pd.Series, rf: pd.Series, target: float, cap: float, kind: str = "rv126",
|
|
band: float = 0.1) -> tuple[pd.Series, pd.Series]:
|
|
exp = banded((target / vol_hat(r, kind)).clip(upper=cap), band)
|
|
return lever(r, rf, exp), exp
|
|
|
|
|
|
def stats(r: pd.Series, rf: pd.Series) -> dict:
|
|
r = r.dropna()
|
|
e = (1 + r).cumprod()
|
|
yrs = len(r) / 252
|
|
yr = (1 + r).groupby(r.index.year).prod() - 1
|
|
roll3 = e.pct_change(756).dropna()
|
|
return {"cagr": e.iloc[-1] ** (1 / yrs) - 1,
|
|
"sharpe": (r - rf.reindex(r.index) / 252).mean() / r.std() * np.sqrt(252),
|
|
"vol": r.std() * np.sqrt(252), "mdd": (e / e.cummax() - 1).min(),
|
|
"worst_yr": yr.min(), "yrs_below_20": int((yr.iloc[1:-1] < 0.20).sum()),
|
|
"n_yrs": len(yr) - 2, "worst_3y": (1 + roll3.min()) ** (1 / 3) - 1}
|
|
|
|
|
|
def fmt(name: str, s: dict, exp: pd.Series | None = None) -> str:
|
|
x = f" avg exp {exp.mean():.2f}" if exp is not None else ""
|
|
return (f"{name:46s} cagr {s['cagr']*100:5.1f}% xsh {s['sharpe']:.2f} vol {s['vol']*100:4.1f}% "
|
|
f"mdd {s['mdd']*100:6.1f}% worst yr {s['worst_yr']*100:6.1f}% worst 3y/yr {s['worst_3y']*100:5.1f}% "
|
|
f"yrs<20% {s['yrs_below_20']}/{s['n_yrs']}{x}")
|
|
|
|
|
|
def main() -> None:
|
|
R = pd.read_pickle(os.path.join(RESULTS, "us_final_sleeves.pkl"))
|
|
extra = os.path.join(RESULTS, "short_horizon_sleeves.pkl")
|
|
if os.path.exists(extra):
|
|
R = R.join(pd.read_pickle(extra), how="left")
|
|
rf = tbill(R.index)
|
|
|
|
print("=== single sleeves, static vs vol-managed (2006- for stock sleeves, 2010- trend)")
|
|
for c in ["momentum", "lowrisk", "trend", "spy"]:
|
|
r = R[c].dropna()
|
|
cap = 1.0 if c == "trend" else 2.0
|
|
print(fmt(f"{c} as is", stats(r, rf)))
|
|
for target in (0.15, 0.25):
|
|
for kind in ("rv126", "ewma20"):
|
|
m, e = managed(r, rf, target, cap, kind)
|
|
print(fmt(f" {c} vt{int(target*100)} {kind} cap {cap:.0f}x", stats(m, rf), e))
|
|
|
|
span = R.loc[SPAN[0]:SPAN[1]].fillna(0.0)
|
|
three = {"momentum": 1 / 3, "lowrisk": 1 / 3, "trend": 1 / 3}
|
|
book = monthly_blend(span, three)
|
|
print("\n=== 3-sleeve book 2011-2026: static leverage vs managed")
|
|
for L in (1.0, 1.5, 2.0, 2.5):
|
|
e = pd.Series(L, index=book.index)
|
|
print(fmt(f"static {L:.1f}x", stats(lever(book, rf, e), rf), e))
|
|
for target in (0.20, 0.25, 0.30, 0.35, 0.40):
|
|
for kind in ("rv126", "ewma20"):
|
|
m, e = managed(book, rf, target, 2.5, kind)
|
|
print(fmt(f"book vt{int(target*100)} {kind} cap 2.5x", stats(m, rf), e))
|
|
|
|
print("\n=== sleeve-level management, then equal capital (stock sleeves cap 2x, trend 1x)")
|
|
for target in (0.15, 0.20, 0.25, 0.30):
|
|
parts = {}
|
|
for c in ("momentum", "lowrisk", "trend"):
|
|
cap = 1.0 if c == "trend" else 2.0
|
|
parts[c], _ = managed(R[c].dropna(), rf, target, cap)
|
|
P = pd.DataFrame(parts).loc[SPAN[0]:SPAN[1]].fillna(0.0)
|
|
print(fmt(f"sleeves vt{int(target*100)} rv126, 1/3 each", stats(monthly_blend(P, three), rf)))
|
|
|
|
print("\n=== the same leverage through 2008 (momentum + lowrisk 50/50, 2006-; no trend sleeve before 2010)")
|
|
two = monthly_blend(R.loc[LONG[0]:LONG[1]].fillna(0.0), {"momentum": .5, "lowrisk": .5})
|
|
for L in (1.0, 2.0):
|
|
e = pd.Series(L, index=two.index)
|
|
print(fmt(f"static {L:.1f}x", stats(lever(two, rf, e), rf), e))
|
|
for target in (0.25, 0.35):
|
|
m, e = managed(two, rf, target, 2.5, "ewma20")
|
|
print(fmt(f"vt{int(target*100)} ewma20 cap 2.5x", stats(m, rf), e))
|
|
|
|
print("\n=== alpha left after hedging market beta (rolling 126d beta, futures hedge), 2011-")
|
|
spy = R["spy"]
|
|
hedged = {}
|
|
for c in ["momentum", "lowrisk", "trend"] + [c for c in R.columns if c.endswith("e21")]:
|
|
r = R[c]
|
|
cov = (r * spy).rolling(126, min_periods=63).mean() - r.rolling(126, min_periods=63).mean() * spy.rolling(126, min_periods=63).mean()
|
|
beta = (cov / spy.rolling(126, min_periods=63).var()).shift(1).clip(0, 2)
|
|
hedged[c] = r - beta * (spy - rf / 252)
|
|
print(fmt(f"hedged {c} (avg beta {beta.loc[SPAN[0]:].mean():.2f})", stats(hedged[c].loc[SPAN[0]:].dropna(), rf)))
|
|
|
|
print("\n=== yearly, book vt30 ewma20 cap 2.5x vs 3-sleeve 1x vs SPY")
|
|
m, e = managed(book, rf, 0.30, 2.5, "ewma20")
|
|
yr = pd.DataFrame({"vt30": (1 + m).groupby(m.index.year).prod() - 1,
|
|
"1x": (1 + book).groupby(book.index.year).prod() - 1,
|
|
"spy": (1 + span["spy"]).groupby(span.index.year).prod() - 1,
|
|
"avg_exp": e.groupby(e.index.year).mean()})
|
|
print((yr * [100, 100, 100, 1]).round(1).to_string())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|