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 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 21:17:12 -07:00
parent 0f9554e77f
commit 8845f31b3b
2 changed files with 349 additions and 0 deletions

View File

@@ -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)

View File

@@ -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()