From 8845f31b3bbf672c8e850ff0c72911531528a03c Mon Sep 17 00:00:00 2001 From: Gahow Wang Date: Thu, 8 Oct 2026 21:17:12 -0700 Subject: [PATCH] research: risk-budgeted book of five return streams (REJECT) Preregistered: inverse-vol shares of momentum, lowrisk, TrendRider V10 (synthetic 3x ETFs, lag fills), 8-ETF TSMOM and BTC trend, 2006-10..2026-09. invvol5 1x: 12.6%/yr, Sharpe 1.07, MaxDD -18% (halves 0.82/1.38) beats the three-sleeve book (0.97, -38%) but not TrendRider V10 in 2017-26 (1.65); levered to 25% vol it drops to 0.98. Inverse vol over-weights the weakest streams; the bottleneck is stream Sharpe, not weighting. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + research/lab/stream_portfolio.py | 348 +++++++++++++++++++++++++++++++ 2 files changed, 349 insertions(+) create mode 100644 research/lab/stream_portfolio.py diff --git a/CLAUDE.md b/CLAUDE.md index 329141e..3bb296c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,6 +81,7 @@ PYTHONPATH=. uv run python -m research.lab.sector_cycle # busin uv run python -m research.lab.fetch_wiki_attention && PYTHONPATH=. uv run --with numba python -m research.lab.attention # Wikipedia-pageview attention PYTHONPATH=. uv run --with numba python -m research.lab.concentration # industry caps on the momentum sleeve PYTHONPATH=. uv run python -m research.lab.universe_midcap # overnight_recovery_top10 on S&P 500 ∪ S&P 400 (preregistered; FAIL 2026-10-08) +PYTHONPATH=. uv run --with numba python -m research.lab.stream_portfolio # risk-budgeted book of momentum/lowrisk/TrendRider/ETF TSMOM/BTC streams, 2006-26 (preregistered; REJECT 2026-10-08) uv run python -m research.lab.fetch_us_fundamentals --universe mid # EDGAR PIT facts for S&P 400 ever-members (name-checked CIKs) uv run python -m research.lab.fetch_us_fundamentals --universe small # the same for S&P 600 (IJR) PYTHONPATH=. uv run python -m research.lab.midcap_sleeve [--only h1..h4] [--universe small] # S&P 400 sleeves vs IJH; S&P 600 replication vs IJR (all REJECT 2026-10-08) diff --git a/research/lab/stream_portfolio.py b/research/lab/stream_portfolio.py new file mode 100644 index 0000000..625c62f --- /dev/null +++ b/research/lab/stream_portfolio.py @@ -0,0 +1,348 @@ +""" +A risk-budgeted book of low-correlation return streams: does weighting five +streams by risk, then levering the book to a volatility target, beat the best +single stream and the live three-sleeve book in both halves of 2006-2026? + + PYTHONPATH=. uv run python -m research.lab.fetch_long_history # once + PYTHONPATH=. uv run python -m research.lab.letf_history # once (synthetic 3x ETFs) + PYTHONPATH=. uv run --with numba python -m research.lab.stream_portfolio + +Streams (daily returns, each a separate cash account, costs inside): + momentum overnight_recovery_top10 on the PIT S&P 500 panel (Tiingo overlay), + top 10, every 10 days, buffer 2, lab engine (10 bp a side), phase-averaged + lowrisk low idiosyncratic vol + low beta, top 20, monthly, buffer 2 + trend TrendRider V10 (registry trend_rider_v10) on synthetic 3x ETFs + (research/lab/letf_history.py), weights traded one session late + ("lag", the pessimistic fill), $10k, 5 bp, $2/order + xtrend multi-asset time-series momentum on ETFs (Moskowitz, Ooi & Pedersen + 2012): SPY EFA EEM VNQ TLT IEF GLD DBC, long while the 252-day + return is positive, each sized to 10% vol (EWMA, 30-day half-life, + at most 3x), gross capped at 1x, rest in T-bills, 10 bp per unit traded + btc long BTC while above its 20-day average, else T-bills (the median + rule of research/lab/diversifiers.py's grid); exists from 2015-06 + +Weighting (stream shares reset at each month start, drift inside the month, +10 bp on the capital moved between streams; a stream enters once it has 63 +days of returns): + equal 1/N of live streams + invvol share proportional to 1 / (126-day vol of the stream) + erc equal risk contribution on the 252-day covariance +Leverage: levered_book.managed on the blended book, EWMA-20 vol, cap 2x +(Reg-T), borrow at T-bill + 1.5%, 10 bp per unit of exposure changed. + +The decision rule is written to prereg.json before the streams are built; the +script refuses to run when results.json exists. Both halves were inspected in +earlier studies (TrendRider V9/V10 knobs were tuned on 2010-2026), so neither +is independent out-of-sample data. + +Result (2026-10-08): REJECT. A2 and A3 pass, A1 fails. +Excess Sharpe, 2006-10..2026-09, halves 2006-16 / 2017-26: + + | book / stream | CAGR | Sharpe | halves | MaxDD | worst yr | + |---------------------------------|-------|--------|-----------|-------|----------| + | invvol5 (primary, 1x) | 12.6% | 1.07 | 0.82/1.38 | -18% | -5.5% | + | invvol5 vt25 (avg 1.8x) | 19.6% | 0.98 | 0.74/1.30 | -32% | -16% | + | erc5 | 13.3% | 1.12 | 0.82/1.49 | -17% | | + | equal5 (reported only) | 22.2% | 1.23 | 0.80/1.70 | -31% | -15% | + | three_sleeve_v7 (live analog) | 18.4% | 0.97 | 0.70/1.33 | -38% | -21% | + | three_sleeve_v10 | 20.5% | 1.02 | 0.68/1.45 | -39% | | + | trend (V10, synthetic, lag) | 30.7% | 1.01 | 0.46/1.65 | -54% | -38% | + | momentum | 20.3% | 0.69 | 0.59/0.81 | -69% | -55% | + | btc (2015-06-) | 64.9% | 1.25 | 1.91/1.15 | -59% | | + | xtrend | 4.8% | 0.39 | 0.37/0.42 | -20% | | + +A1 fails in both readings. The first half's best stream is btc over only +2015-06..2016-12, a weak yardstick. Without btc, lowrisk (0.67) sets the bar +and invvol5 (0.82) clears it. In the second half, trend (1.65) beats +invvol5 (1.38) either way. + +Interpretation: + - Monthly correlations are low: btc 0.09-0.17, trend/momentum 0.30, + xtrend 0.27-0.41. Diversification halves the drawdown (2008: -5% vs -21%). + - Inverse-vol puts 72% in the two weakest streams, xtrend (0.39) and + lowrisk (0.54), so the Sharpe gain over three_sleeve is only about +0.1. + - Levering the book to 25% vol costs more than that gain: Sharpe 1.07 at 1x + vs 0.98 levered, down to the three_sleeve vt25 level of 0.96-0.99. + - Exploration afterwards (not preregistered): with futures-like financing + (T-bill + 0.4% instead of + 1.5%), invvol5 vt25 is 1.03-1.04. So + financing explains about 0.05; the rest is vol timing, which does not pay. + - equal5's edge rests on btc (11% share; invvol4 without btc is 0.92) and + on V10's tuned 2017-26. + - TrendRider V10 before 2017 is a 0.46-Sharpe strategy (1990-2026 on + synthetic ETFs: 15.9% / 0.65 / -62%). +Decision: keep the current sleeves; no stream book is registered. No weighting +of these streams reaches Sharpe 1.3 over 2006-26, so "stable 50%" needs better +streams, not a better weighting. +""" + +from __future__ import annotations + +import json +import os +import pickle +import warnings + +import numpy as np +import pandas as pd + +from research.lab.diversifiers import btc_trend +from research.lab.factor_scan import RESULTS, load +from research.lab.factors import ivol, low_beta, overnight_mom, rec_126 +from research.lab.levered_book import managed, stats, tbill +from research.lab.letf_history import run as run_letf +from research.lab.us_sweep import _blend, run + +warnings.filterwarnings("ignore") +OUT = os.path.join(RESULTS, "stream_portfolio") +SPAN = ("2006-10-02", "2026-09-24") +HALVES = [("2006-10-02", "2016-12-30"), ("2017-01-03", "2026-09-24")] +BLOCKS = [("2006-10-02", "2009-12-31"), ("2010-01-01", "2014-12-31"), ("2015-01-01", "2019-12-31"), + ("2020-01-01", "2026-09-24")] +XTREND = ["SPY", "EFA", "EEM", "VNQ", "TLT", "IEF", "GLD", "DBC"] +COST = 0.001 +MIN_OBS = 63 + +PREREG = { + "question": "Does a risk-budgeted book of five return streams beat its best single stream and " + "the live three-sleeve book in both halves, with a tolerable drawdown when levered?", + "streams": ["momentum", "lowrisk", "trend (TrendRider V10, synthetic ETFs, lag fills)", + "xtrend (8-ETF TSMOM 252d, 10% vol each, gross <= 1)", "btc (sma20, 2015-06-)"], + "span": SPAN, "halves": HALVES, + "primary": "invvol5: inverse-vol (126d) shares of the five streams, monthly; levered to a 25% " + "vol target (EWMA-20, cap 2x) for the drawdown check", + "sharpe": "excess over the 3-month T-bill, daily, annualized", + "accept_if_all": [ + "A1 invvol5 (1x) Sharpe >= the highest single-stream Sharpe in each half", + "A2 invvol5 (1x) Sharpe >= three_sleeve_v7 (1/3 momentum, lowrisk, TrendRider V7 vt24 " + "synthetic lag; the live us_three_sleeve) in each half", + "A3 invvol5 at vt25 full-span MaxDD >= -35%", + ], + "reported_not_decisive": ["equal5", "erc5", "invvol4 (no btc: spot ETFs only from 2024)", + "invvol5 with qqq_trend or TrendRider V7 vt24 in place of V10", + "three_sleeve_v10", "vt20 / vt30", "block Sharpes, yearly returns"], + "decision": {"ACCEPT": "build the book as a registered sleeve portfolio and forward-test it next " + "to us_three_sleeve and trend_rider_v10", + "REJECT": "keep the current sleeves; report which check failed"}, +} + + +# --------------------------------------------------------------------------- +# streams +# --------------------------------------------------------------------------- + +def _score(factors: list[pd.DataFrame], mem: pd.DataFrame) -> pd.DataFrame: + return _blend(*[f.where(mem).rank(axis=1, pct=True) for f in factors]).where(mem) + + +def stock_streams() -> dict[str, pd.Series]: + p, _ = load("us") + mem = p.member + out = {} + for name, facs, kw in (("momentum", [overnight_mom(p), rec_126(p)], dict(top_n=10, buffer=2.0, every=10)), + ("lowrisk", [ivol(p), low_beta(p)], dict(top_n=20, buffer=2.0, every=21))): + out[name] = run(p, _score(facs, mem), [SPAN], **kw)["_rets"] + print(f"ran {name}", flush=True) + out["spy"] = p.benchmark.ffill().pct_change(fill_method=None).reindex(p.dates) + return out + + +def tsmom(close: pd.DataFrame, rf: pd.Series, assets: list[str], look: int = 252, + target: float = 0.10) -> pd.Series: + close = close[assets].ffill() + r = close.pct_change(fill_method=None) + on = (close / close.shift(look) - 1 > 0).astype(float) + vol = r.ewm(halflife=30, min_periods=60).std() * np.sqrt(252) + W = (on * (target / vol).clip(upper=3.0)).shift(1).fillna(0.0) + W = W.div(W.sum(axis=1).clip(lower=1.0), axis=0) + cash = 1 - W.sum(axis=1) + return (W * r.fillna(0.0)).sum(axis=1) + cash * rf.reindex(W.index) / 252 \ + - W.diff().abs().sum(axis=1) * COST + + +def etf_streams(days: pd.DatetimeIndex, rf: pd.Series) -> dict[str, pd.Series]: + from trader import STRATEGY_REGISTRY + with open(os.path.join("data", "research", "letf_history.pkl"), "rb") as fh: + syn = pickle.load(fh)["close"].loc["1989-01-01":] + out = {} + for name, key in (("trend", "trend_rider_v10"), ("trend_v7", "trend_rider_v7_vt24")): + out[name] = run_letf(STRATEGY_REGISTRY[key](), syn, "lag").pct_change() + print(f"ran {name}", flush=True) + with open(os.path.join("data", "research", "long_history.pkl"), "rb") as fh: + src = pickle.load(fh) + c = src["close"] + out["xtrend"] = tsmom(c.loc["2002-01-01":], rf, XTREND).loc["2004-01-01":] + btc = c["BTC-USD"].reindex(c["SPY"].dropna().loc["2014-12-01":].index, method="ffill").dropna() + out["btc"] = btc_trend(btc, rf, "sma", 20)[0].loc["2015-06-01":] + with open(os.path.join(RESULTS, "vol_trend.pkl"), "rb") as fh: + out["qqq_trend"] = pickle.load(fh)["returns"]["QQQ ma200 vt20 cap3"] + return {k: v.reindex(days) for k, v in out.items()} + + +# --------------------------------------------------------------------------- +# weighting +# --------------------------------------------------------------------------- + +def _erc(cov: np.ndarray, iters: int = 500) -> np.ndarray: + w = 1 / np.sqrt(np.diag(cov)) + w /= w.sum() + for _ in range(iters): + rc = w * (cov @ w) + w = w * np.sqrt(rc.mean() / rc) + w /= w.sum() + return w + + +def month_weights(R: pd.DataFrame, scheme: str) -> pd.DataFrame: + """Shares for each month from returns through the previous month end.""" + months = R.index.to_period("M").unique() + rows = {} + for m in months: + hist = R.loc[:m.start_time - pd.Timedelta(days=1)] + live = [c for c in R.columns if hist[c].dropna().shape[0] >= MIN_OBS] + w = pd.Series(0.0, index=R.columns) + if not live: + rows[m] = w + continue + if scheme == "equal": + w[live] = 1 / len(live) + elif scheme == "invvol": + v = hist[live].iloc[-126:].std() + w[live] = (1 / v) / (1 / v).sum() + elif scheme == "erc": + h = hist[live].iloc[-252:].dropna() + w[live] = _erc(h.cov().to_numpy()) if len(h) >= 126 else (1 / h.std()) / (1 / h.std()).sum() + rows[m] = w + return pd.DataFrame(rows).T + + +def blend(R: pd.DataFrame, W: pd.DataFrame, rf: pd.Series) -> pd.Series: + """Shares reset at month starts and drift inside; uninvested capital earns T-bills.""" + out, drifted = [], pd.Series(0.0, index=R.columns) + for m, g in R.groupby(R.index.to_period("M")): + w = W.loc[m] + cost = (w - drifted).abs().sum() * COST + grow = (1 + g.fillna(0.0)).cumprod() + cash = (1 - w.sum()) * (1 + rf.reindex(g.index).fillna(0.0) / 252).cumprod() + val = grow.mul(w, axis=1).sum(axis=1) + cash + prev = np.r_[1.0, val.values[:-1]] + r = pd.Series(val.values / prev - 1, index=g.index) + r.iloc[0] -= cost + out.append(r) + end = grow.iloc[-1] * w + drifted = end / val.iloc[-1] + return pd.concat(out) + + +# --------------------------------------------------------------------------- +# report +# --------------------------------------------------------------------------- + +def _sharpe(r: pd.Series, rf: pd.Series) -> float: + r = r.dropna() + return float(stats(r, rf)["sharpe"]) if len(r) >= 126 else float("nan") + + +def summary(r: pd.Series, rf: pd.Series) -> dict: + s = stats(r.loc[SPAN[0]:SPAN[1]], rf) + d = {k: float(v) for k, v in s.items()} + d["halves"] = [_sharpe(r.loc[a:b], rf) for a, b in HALVES] + d["blocks"] = [_sharpe(r.loc[a:b], rf) for a, b in BLOCKS] + return d + + +def line(name: str, d: dict) -> str: + return (f"{name:28s} cagr {d['cagr']*100:5.1f}% xsh {d['sharpe']:5.2f} vol {d['vol']*100:4.1f}% " + f"mdd {d['mdd']*100:6.1f}% worst yr {d['worst_yr']*100:6.1f}% | halves " + f"{d['halves'][0]:5.2f} {d['halves'][1]:5.2f} | blocks " + " ".join(f"{x:5.2f}" for x in d["blocks"])) + + +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) + + cache = os.path.join(OUT, "streams.pkl") # inputs only; results are never reused + if os.path.exists(cache): + R = pd.read_pickle(cache) + else: + S = stock_streams() + days = S["momentum"].index + S.update(etf_streams(days, tbill(days))) + R = pd.DataFrame(S).loc[:SPAN[1]] + R.to_pickle(cache) + rf = tbill(R.index) + + five = ["momentum", "lowrisk", "trend", "xtrend", "btc"] + books = { + "invvol5": (five, "invvol"), "equal5": (five, "equal"), "erc5": (five, "erc"), + "invvol4_no_btc": (["momentum", "lowrisk", "trend", "xtrend"], "invvol"), + "invvol5_qqq_trend": (["momentum", "lowrisk", "qqq_trend", "xtrend", "btc"], "invvol"), + "invvol5_trend_v7": (["momentum", "lowrisk", "trend_v7", "xtrend", "btc"], "invvol"), + "three_sleeve_v7": (["momentum", "lowrisk", "trend_v7"], "equal"), + "three_sleeve_v10": (["momentum", "lowrisk", "trend"], "equal"), + } + B, avg_w = {}, {} + for name, (cols, scheme) in books.items(): + W = month_weights(R.loc[:, cols], scheme) + B[name] = blend(R.loc[:, cols], W, rf) + avg_w[name] = W.loc[pd.Period(SPAN[0], "M"):].mean().round(3).to_dict() + for name in ("invvol5", "three_sleeve_v7", "three_sleeve_v10"): + for t in (0.20, 0.25, 0.30): + B[f"{name}_vt{int(t*100)}"] = managed(B[name], rf, t, 2.0, "ewma20")[0] + + res = {"streams": {}, "books": {}} + print("\n=== streams (1x)") + for c in R.columns: + res["streams"][c] = summary(R[c].dropna(), rf) + print(line(c, res["streams"][c])) + print("\n=== books") + for name, r in B.items(): + res["books"][name] = summary(r, rf) + print(line(name, res["books"][name])) + print("\naverage shares:") + for k, v in avg_w.items(): + print(f" {k:20s} {v}") + m = (1 + R.loc[SPAN[0]:].fillna(0.0)).resample("ME").prod() - 1 + corr = m[five + ["trend_v7", "qqq_trend", "spy"]].corr().round(2) + print("\nmonthly correlation:\n", corr.to_string()) + + # verdict + p = res["books"]["invvol5"] + best_name = [max(five, key=lambda c: np.nan_to_num(res["streams"][c]["halves"][i], nan=-9)) for i in range(2)] + best = [res["streams"][best_name[i]]["halves"][i] for i in range(2)] + ts = res["books"]["three_sleeve_v7"]["halves"] + checks = { + "A1": all(p["halves"][i] >= best[i] for i in range(2)), + "A2": all(p["halves"][i] >= ts[i] for i in range(2)), + "A3": res["books"]["invvol5_vt25"]["mdd"] >= -0.35, + } + res["verdict"] = {"checks": checks, "best_single": dict(zip(["first", "second"], zip(best_name, best))), + "decision": "ACCEPT" if all(checks.values()) else "REJECT"} + res["avg_shares"] = avg_w + res["monthly_corr"] = corr.to_dict() + yr = pd.DataFrame({k: (1 + B[k].loc[SPAN[0]:]).groupby(B[k].loc[SPAN[0]:].index.year).prod() - 1 + for k in ("invvol5", "invvol5_vt25", "three_sleeve_v7", "three_sleeve_v10")}) + yr["trend"] = (1 + R["trend"].loc[SPAN[0]:]).groupby(R.loc[SPAN[0]:].index.year).prod() - 1 + yr["spy"] = (1 + R["spy"].loc[SPAN[0]:].fillna(0.0)).groupby(R.loc[SPAN[0]:].index.year).prod() - 1 + print("\nyearly returns (%):\n", (yr * 100).round(1).to_string()) + res["yearly"] = {k: {int(y): float(v) for y, v in yr[k].items()} for k in yr} + print(f"\nbest single stream Sharpe by half: {res['verdict']['best_single']}") + print(f"invvol5 halves {p['halves']}, three_sleeve_v7 halves {ts}, invvol5_vt25 mdd " + f"{res['books']['invvol5_vt25']['mdd']:.3f}") + print(f"checks {checks} -> {res['verdict']['decision']}") + with open(res_path, "w") as fh: + json.dump(res, fh, indent=2) + pd.DataFrame(B).to_pickle(os.path.join(OUT, "books.pkl")) + + from research.lab.trials import append_trials + append_trials(os.path.join(RESULTS, "trials_us.jsonl"), + [{"name": f"stream_portfolio:{k}", **{x: y for x, y in v.items()}} + for k, v in res["books"].items()], + {"study": "stream_portfolio", "role": "preregistered; both halves previously inspected"}) + + +if __name__ == "__main__": + main()