hedged_momentum: overnight_recovery_top10 hedged with QQQ futures at a rolling 126d beta earns Sharpe 0.11; alpha vs QQQ on the Tiingo panel is +5.3%/yr (t 1.10). futures_trend: Yahoo =F data is roughly usable (ES/NQ corr 0.98, ZN/GC miss on settlement time), 24-market TSMOM Sharpe 0.44, corr 0.22 with TrendRider: a crisis diversifier, not a core stream. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
146 lines
6.9 KiB
Python
146 lines
6.9 KiB
Python
"""
|
|
Quick check before building a futures-hedge pipeline: is the momentum
|
|
sleeve's alpha, with its beta hedged by short index futures (MNQ / MES), a
|
|
stream worth holding next to TrendRider?
|
|
|
|
PYTHONPATH=. uv run --with numba python -m research.lab.stream_portfolio # once: streams.pkl
|
|
PYTHONPATH=. uv run python -m research.lab.hedged_momentum
|
|
|
|
Inputs: the cached daily streams of research/lab/stream_portfolio.py
|
|
(momentum = overnight_recovery_top10 on the PIT S&P 500, TrendRider V10 on
|
|
synthetic 3x ETFs). Index returns are QQQ / SPY total return from
|
|
long_history.pkl.
|
|
|
|
Hedge: the account holds the stocks with its own cash and is short beta_t
|
|
units of index futures; a short future earns -(index - T-bill). So
|
|
hedged_t = mom_t - beta_{t-1} (idx_t - rf_t) - |d beta| * 2 bp - 0.5%/yr / 252
|
|
beta_{t-1} is the 126-day OLS beta of momentum on the index, from returns
|
|
through t-1. The 0.5%/yr covers roll and basis slippage. Variants: QQQ (MNQ,
|
|
primary) or SPY (MES), rolling or fixed beta 1.
|
|
|
|
Books (monthly reset, research/lab/us_final.monthly_blend):
|
|
trend_raw 1/2 TrendRider + 1/2 momentum
|
|
trend_hedged 1/2 TrendRider + 1/2 hedged momentum
|
|
trend TrendRider alone
|
|
|
|
The decision rule is in PREREG and is written before the run; the script
|
|
refuses to overwrite results.json.
|
|
|
|
Result (2026-10-08): STOP. C1 and C2 both fail.
|
|
Excess Sharpe, 2006-10..2026-09, halves 2006-16 / 2017-26:
|
|
- momentum unhedged: 20.3% / 0.69 (0.59 / 0.81). Alpha vs QQQ is +5.3%/yr,
|
|
t 1.10, beta 1.04. On the Tiingo-overlaid PIT panel this alpha is about half
|
|
the +9.4% (t 1.86) the pre-backfill panel gave.
|
|
- hedged, QQQ, rolling beta: 1.8% / 0.11 (0.02 / 0.23), MaxDD -66%.
|
|
Beta drifts from 0.5 to 1.8 by year, and the 126-day estimate trails it.
|
|
- hedged, SPY, beta 1 (best variant): 10.3% / 0.48 (0.49 / 0.46).
|
|
- 1/2 TrendRider + 1/2 momentum: 1.01 (0.63 / 1.47).
|
|
With hedged momentum instead: 0.85 (0.37 / 1.42).
|
|
Interpretation: about 21% of the hedged stream's vol is idiosyncratic risk
|
|
from 10 names, against an alpha of a few percent. Hedging removes the beta
|
|
that paid for the momentum sleeve.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
from research.lab.alpha import alpha_stats, bench_returns
|
|
from research.lab.factor_scan import RESULTS
|
|
from research.lab.levered_book import stats, tbill
|
|
from research.lab.us_final import monthly_blend
|
|
|
|
OUT = os.path.join(RESULTS, "hedged_momentum")
|
|
STREAMS = os.path.join(RESULTS, "stream_portfolio", "streams.pkl")
|
|
HALVES = [("2006-10-02", "2016-12-30"), ("2017-01-03", "2026-09-24")]
|
|
SPAN = (HALVES[0][0], HALVES[1][1])
|
|
TRADE, FRICTION = 0.0002, 0.005
|
|
|
|
PREREG = {
|
|
"question": "Is beta-hedged momentum (short QQQ futures, rolling 126d beta) a stream worth a futures pipeline?",
|
|
"primary": "hedged_qqq_roll126",
|
|
"continue_if_all": [
|
|
"C1 hedged_qqq_roll126 excess Sharpe >= 0.50 in both halves",
|
|
"C2 trend_hedged Sharpe >= trend_raw Sharpe + 0.10 in both halves",
|
|
],
|
|
"decision": {"CONTINUE": "build the futures hedge (MNQ) into the sleeve framework and preregister a book",
|
|
"STOP": "drop TODO 2; momentum stays an unhedged long sleeve"},
|
|
"halves": HALVES, "costs": {"hedge_trade": TRADE, "hedge_friction_per_year": FRICTION},
|
|
}
|
|
|
|
|
|
def rolling_beta(r: pd.Series, b: pd.Series, n: int = 126) -> pd.Series:
|
|
cov = r.rolling(n, min_periods=63).cov(b)
|
|
return (cov / b.rolling(n, min_periods=63).var()).shift(1)
|
|
|
|
|
|
def hedge(r: pd.Series, idx: pd.Series, rf: pd.Series, beta: pd.Series) -> pd.Series:
|
|
beta = beta.fillna(1.0)
|
|
return r - beta * (idx - rf / 252) - beta.diff().abs().fillna(0.0) * TRADE - beta * FRICTION / 252
|
|
|
|
|
|
def describe(r: pd.Series, rf: pd.Series, q: pd.Series) -> dict:
|
|
s = stats(r.loc[SPAN[0]:SPAN[1]], rf)
|
|
a = alpha_stats(r.loc[SPAN[0]:SPAN[1]], q, rf)
|
|
return {"cagr": float(s["cagr"]), "sharpe": float(s["sharpe"]), "vol": float(s["vol"]),
|
|
"mdd": float(s["mdd"]), "worst_yr": float(s["worst_yr"]),
|
|
"halves": [float(stats(r.loc[a_:b_], rf)["sharpe"]) for a_, b_ in HALVES],
|
|
"beta_qqq": float(a["beta"]), "alpha_qqq": float(a["alpha"]), "alpha_t": float(a["t"])}
|
|
|
|
|
|
def main() -> None:
|
|
os.makedirs(OUT, exist_ok=True)
|
|
res_path = os.path.join(OUT, "results.json")
|
|
if os.path.exists(res_path):
|
|
raise SystemExit(f"{res_path} exists; this study does not overwrite results")
|
|
with open(os.path.join(OUT, "prereg.json"), "w") as fh:
|
|
json.dump(PREREG, fh, indent=2)
|
|
|
|
R = pd.read_pickle(STREAMS).loc[SPAN[0]:SPAN[1]]
|
|
rf = tbill(R.index)
|
|
q, s = bench_returns(R.index, "QQQ"), bench_returns(R.index, "SPY")
|
|
mom = R["momentum"]
|
|
S = {"momentum": mom, "trend": R["trend"], "qqq": q}
|
|
for name, idx in (("qqq", q), ("spy", s)):
|
|
S[f"hedged_{name}_roll126"] = hedge(mom, idx, rf, rolling_beta(mom, idx))
|
|
S[f"hedged_{name}_beta1"] = hedge(mom, idx, rf, pd.Series(1.0, index=mom.index))
|
|
D = pd.DataFrame(S)
|
|
for k, h in (("trend_raw", "momentum"), ("trend_hedged", "hedged_qqq_roll126"),
|
|
("trend_hedged_spy", "hedged_spy_roll126")):
|
|
D[k] = monthly_blend(D[["trend", h]].fillna(0.0), {"trend": 0.5, h: 0.5})
|
|
|
|
res = {k: describe(D[k], rf, q) for k in D.columns}
|
|
beta = rolling_beta(mom, q)
|
|
res["beta_path"] = {str(y): float(v) for y, v in beta.groupby(beta.index.year).mean().items()}
|
|
for k, d in res.items():
|
|
if k == "beta_path":
|
|
continue
|
|
print(f"{k:22s} cagr {d['cagr']*100:5.1f}% xsh {d['sharpe']:5.2f} vol {d['vol']*100:4.1f}% "
|
|
f"mdd {d['mdd']*100:6.1f}% halves {d['halves'][0]:5.2f} {d['halves'][1]:5.2f} | "
|
|
f"vs QQQ beta {d['beta_qqq']:4.2f} alpha {d['alpha_qqq']*100:5.1f}% t {d['alpha_t']:4.2f}")
|
|
print("mean rolling beta to QQQ by year:", {k: round(v, 2) for k, v in res["beta_path"].items()})
|
|
m = (1 + D[["hedged_qqq_roll126", "trend", "momentum", "qqq"]]).resample("ME").prod() - 1
|
|
print("monthly corr:\n", m.corr().round(2).to_string())
|
|
yr = (1 + D[["momentum", "hedged_qqq_roll126", "trend_raw", "trend_hedged", "qqq"]]).groupby(D.index.year).prod() - 1
|
|
print((yr * 100).round(1).to_string())
|
|
|
|
h, tr, th = res["hedged_qqq_roll126"], res["trend_raw"], res["trend_hedged"]
|
|
checks = {"C1": all(x >= 0.50 for x in h["halves"]),
|
|
"C2": all(th["halves"][i] >= tr["halves"][i] + 0.10 for i in range(2))}
|
|
res["verdict"] = {"checks": checks, "decision": "CONTINUE" if all(checks.values()) else "STOP"}
|
|
print(f"checks {checks} -> {res['verdict']['decision']}")
|
|
with open(res_path, "w") as fh:
|
|
json.dump(res, fh, indent=2)
|
|
from research.lab.trials import append_trials
|
|
append_trials(os.path.join(RESULTS, "trials_us.jsonl"),
|
|
[{"name": f"hedged_momentum:{k}", **v} for k, v in res.items() if k not in ("beta_path", "verdict")],
|
|
{"study": "hedged_momentum", "role": "preregistered quick check; both halves previously inspected"})
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|