Files
quant-trading/research/lab/managed_futures_book.py
Gahow Wang 65b8db7a12 research: 50%-target search — conservative book, daily quick checks, Alpaca intraday
- managed_futures_book: three sleeves + managed futures (DBMF, tsmom24 proxy
  before 2019-05) cuts MaxDD -37% -> -27% but CAGR 16-19%, Sharpe unchanged
  (REJECT).
- small_cap_reversal / turn_of_month / short_vol: preregistered quick checks,
  all STOP (net of spread costs, post-publication decay, post-2013 VRP 0.37).
- fetch_alpaca: resume-safe Alpaca SIP intraday bars 2016+ (1-min ETFs,
  5-min liquid S&P 1500), keys from .env and never logged.
- intraday_momentum: the SPY noise-area strategy replicates (yearly corr
  1.00 with the paper) but SPY Sharpe 0.99, ~0 after publication, and only
  QQQ of five other ETFs works (STOP).
- orb_stocks_in_play: preregistered 5-min ORB replication (not yet run).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:58:47 -07:00

246 lines
12 KiB
Python

"""
The conservative US book: does adding a managed-futures (futures trend)
sleeve to the live three-sleeve book give a steadier book with 20%+ a year
and a drawdown no worse than -30%, without lowering the Sharpe in either half
of 2006-2026?
PYTHONPATH=. uv run --with numba python -m research.lab.stream_portfolio # once: streams.pkl
PYTHONPATH=. uv run python -m research.lab.futures_trend # once: returns.pkl
PYTHONPATH=. uv run python -m research.lab.managed_futures_book [--no-proxy]
Streams (daily, costs inside; research/lab/stream_portfolio.py streams.pkl):
momentum overnight_recovery_top10, PIT S&P 500, lab engine
lowrisk lowrisk_top20 (ivol + low beta), lab engine
trend_v7 trend_rider_v7_vt24 on synthetic 3x ETFs, "lag" fills (the live sleeve)
trend_v7_vt36 trend_rider_v7_vt36, same engine (built here)
trend trend_rider_v10, same engine (reported only)
mf managed futures, the sleeve live trading can hold in a cash account:
DBMF (iMGP DBi Managed Futures ETF, replicates the SG CTA index with
futures; adjusted closes) from its first full day 2019-05-09; before
that the 24-market futures TSMOM of research/lab/futures_trend.py
(tsmom24) less DBMF's 0.85%/yr fee. The proxy is not DBMF: their
monthly correlation over 2019-26 is 0.64.
Book: sleeves held as separate accounts in one book, as
strategies/sleeve_portfolio.py does live: shares drift with each sleeve's
return and are reset to the base shares at a month start when any share is
more than 25% (relative) off its base; 10 bp on the capital moved.
All of 2006-2026 was inspected in earlier studies (TrendRider was tuned on
2010-2026; the futures sleeve's blend was explored in futures_trend.py), so
this is a consistency gate, not out-of-sample evidence. The forward test is
the evidence. The rule is written to prereg.json before the run, and the
script refuses to overwrite results.json.
Result (2026-10-08): REJECT. Every candidate passes G2 and fails G1 and G3.
Excess Sharpe, 2006-10..2026-09, halves 2006-16 / 2017-26:
| book | CAGR | Sharpe | halves | MaxDD | worst yr |
|------------------------------|-------|--------|-------------|-------|----------|
| three_sleeve_v7 (baseline) | 18.9% | 0.98 | 0.697/1.332 | -37% | -22.3% |
| four_sleeve_v7 | 16.3% | 1.01 | 0.763/1.301 | -27% | -11.3% |
| four_sleeve_v7_vt36 | 17.2% | 1.00 | 0.723/1.325 | -29% | -12.0% |
| three_sleeve_no_lowrisk | 19.1% | 1.01 | 0.721/1.329 | -28% | -8.9% |
| four_sleeve_v10 (reported) | 18.0% | 1.06 | 0.759/1.421 | -28% | -10.4% |
| mf stream (proxy -> DBMF) | 6.2% | 0.37 | 0.29/0.50 | -35% | -25.4% |
- G1 misses in 2017-26 by 0.003-0.03 Sharpe: a tie, not an improvement.
- G3: CAGR 16-19% is short of 20%.
- The futures sleeve cuts MaxDD by about a quarter (-37% -> -27%) and
halves the worst year (2008: -22% -> -9..-12%). It costs 0-2.6 pp of
CAGR and leaves the Sharpe where it was (monthly corr with the other
sleeves 0.01-0.24).
- DBMF era, 2019-05..2026-09 (reported only): baseline 25.7% / 1.27 /
-18%; four_sleeve_v7 21.9% / 1.28 / -15%; three_sleeve_no_lowrisk
27.4% / 1.32 / -18%.
Interpretation: in a 1x cash account these streams give about 16-19% a year
at Sharpe ~1.0 over 2006-26. The "Sharpe 1.2, 20-30%" range came from
equal5 (with BTC) and the 2017-26 half, not from this book.
Decision: register nothing by this rule.
"""
from __future__ import annotations
import argparse
import json
import os
import pickle
import numpy as np
import pandas as pd
from research.lab.factor_scan import RESULTS
from research.lab.levered_book import stats, tbill
OUT = os.path.join(RESULTS, "managed_futures_book")
STREAMS = os.path.join(RESULTS, "stream_portfolio", "streams.pkl")
FUTURES = os.path.join(RESULTS, "futures_trend", "returns.pkl")
SPAN = ("2006-10-02", "2026-09-24")
HALVES = [("2006-10-02", "2016-12-30"), ("2017-01-03", "2026-09-24")]
DBMF_START = "2019-05-09"
DBMF_FEE = 0.0085
BAND, COST = 0.25, 0.001
CANDIDATES = {
"four_sleeve_v7": ["momentum", "lowrisk", "trend_v7", "mf"],
"four_sleeve_v7_vt36": ["momentum", "lowrisk", "trend_v7_vt36", "mf"],
"three_sleeve_no_lowrisk": ["momentum", "trend_v7", "mf"],
}
REPORTED = {
"three_sleeve_v7": ["momentum", "lowrisk", "trend_v7"],
"four_sleeve_v10": ["momentum", "lowrisk", "trend", "mf"],
"four_sleeve_v7_tsmom_only": ["momentum", "lowrisk", "trend_v7", "tsmom24"],
"four_sleeve_v7_etf_xtrend": ["momentum", "lowrisk", "trend_v7", "xtrend"],
}
PREREG = {
"question": "Is momentum + lowrisk + TrendRider V7 + managed futures (equal shares) a steadier book "
"with CAGR >= 20% and MaxDD >= -30%, no worse than the live three-sleeve book in either half?",
"span": SPAN, "halves": HALVES,
"candidates": CANDIDATES,
"baseline": "three_sleeve_v7 (live us_three_sleeve: 1/3 momentum, lowrisk, trend_rider_v7_vt24), same blend",
"mf_stream": f"DBMF from {DBMF_START}; before it tsmom24 (futures_trend.py) less {DBMF_FEE:.2%}/yr",
"blend": f"equal base shares, drift, reset at a month start when any share is > {BAND:.0%} off; "
f"{COST*1e4:.0f} bp on capital moved",
"sharpe": "excess over the 3-month T-bill, daily, annualized",
"gates_per_candidate": [
"G1 Sharpe >= three_sleeve_v7 Sharpe in each half",
"G2 full-span MaxDD >= -30%",
"G3 full-span CAGR >= 20%",
],
"selection": "among candidates passing G1-G3, the highest min-half Sharpe -> ACCEPT; else among those "
"passing G1-G2, the highest CAGR -> PARTIAL (meets the risk goal, not the return goal); "
"else REJECT",
"decision": {"ACCEPT": "register the selected book and forward-test it as the conservative book",
"PARTIAL": "register it for forward testing, labeled as below the 20% return goal",
"REJECT": "register nothing; report which gate failed"},
"reported_not_decisive": list(REPORTED) + ["DBMF-era (2019-05..) stats", "yearly returns",
"monthly correlations"],
"boundary": "all periods previously inspected; synthetic 3x ETFs before 2010; the mf proxy before "
"2019-05 is a different strategy from DBMF",
}
def band_blend(R: pd.DataFrame, rf: pd.Series) -> tuple[pd.Series, pd.DataFrame]:
"""Book return of equal-share sleeves with drift and a banded month-start reset.
A sleeve with no return yet (before its first observation) holds T-bills."""
X = R.copy()
for c in X:
X[c] = X[c].fillna(rf.reindex(X.index).fillna(0.0) / 252)
n = X.shape[1]
base = np.full(n, 1.0 / n)
s = base.copy()
months = X.index.to_period("M")
out, shares = np.empty(len(X)), np.empty((len(X), n))
vals = X.to_numpy()
for k in range(len(X)):
cost = 0.0
if k > 0 and months[k] != months[k - 1] and np.max(np.abs(s / base - 1)) > BAND:
cost = np.abs(s - base).sum() * COST
s = base.copy()
shares[k] = s
g = s * (1 + vals[k])
out[k] = g.sum() - 1 - cost
s = g / g.sum()
return pd.Series(out, index=X.index), pd.DataFrame(shares, index=X.index, columns=X.columns)
def summary(r: pd.Series, rf: pd.Series) -> dict:
r = r.loc[SPAN[0]:SPAN[1]]
d = {k: float(v) for k, v in stats(r, rf).items()}
d["halves"] = [float(stats(r.loc[a:b], rf)["sharpe"]) for a, b in HALVES]
return d
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--no-proxy", action="store_true")
if not ap.parse_args().no_proxy:
from research.lab.fetch_us import _proxy_env
_proxy_env()
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)
days = R.index
rf = tbill(days)
from research.lab.fetch_long_history import fetch_yahoo
from research.lab.letf_history import run as run_letf
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":]
R["trend_v7_vt36"] = run_letf(STRATEGY_REGISTRY["trend_rider_v7_vt36"](), syn, "lag").pct_change().reindex(days)
fut = pd.read_pickle(FUTURES)["tsmom24"].reindex(days)
dbmf_px, _ = fetch_yahoo(["DBMF"], start="2019-01-01")
dbmf = dbmf_px["DBMF"].pct_change(fill_method=None).reindex(days).loc[DBMF_START:]
R["tsmom24"] = fut
R["mf"] = pd.concat([fut.loc[:DBMF_START].iloc[:-1] - DBMF_FEE / 252, dbmf]).reindex(days)
B, avg_shares = {}, {}
for name, cols in {**CANDIDATES, **REPORTED}.items():
B[name], sh = band_blend(R.loc[SPAN[0]:SPAN[1], cols], rf)
avg_shares[name] = sh.mean().round(3).to_dict()
res = {"streams": {}, "books": {}}
fmt = lambda k, d: (f"{k: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}")
print("=== streams")
for c in ["momentum", "lowrisk", "trend_v7", "trend_v7_vt36", "trend", "mf", "tsmom24", "xtrend"]:
res["streams"][c] = summary(R[c].dropna(), rf)
print(fmt(c, res["streams"][c]))
print("=== books")
for k, r in B.items():
res["books"][k] = summary(r, rf)
print(fmt(k, res["books"][k]))
# DBMF era: the real instrument, reported only
res["dbmf_era"] = {}
print(f"=== DBMF era {DBMF_START}..{SPAN[1]} (reported only)")
for k in ["three_sleeve_v7", *CANDIDATES]:
r = B[k].loc[DBMF_START:]
res["dbmf_era"][k] = {x: float(v) for x, v in stats(r, rf).items()}
print(f" {k:28s} cagr {res['dbmf_era'][k]['cagr']*100:5.1f}% xsh {res['dbmf_era'][k]['sharpe']:5.2f} "
f"mdd {res['dbmf_era'][k]['mdd']*100:6.1f}%")
m = (1 + R.loc[SPAN[0]:SPAN[1], ["momentum", "lowrisk", "trend_v7", "mf"]].fillna(0.0)).resample("ME").prod() - 1
print("monthly corr:\n", m.corr().round(2).to_string())
res["monthly_corr"] = m.corr().round(3).to_dict()
yr = pd.DataFrame({k: (1 + B[k]).groupby(B[k].index.year).prod() - 1
for k in ["three_sleeve_v7", *CANDIDATES]})
print("yearly %:\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}
res["avg_shares"] = avg_shares
base = res["books"]["three_sleeve_v7"]["halves"]
gates = {}
for k in CANDIDATES:
d = res["books"][k]
gates[k] = {"G1": all(d["halves"][i] >= base[i] for i in range(2)),
"G2": d["mdd"] >= -0.30, "G3": d["cagr"] >= 0.20}
full = [k for k, g in gates.items() if all(g.values())]
risk = [k for k, g in gates.items() if g["G1"] and g["G2"]]
if full:
sel, decision = max(full, key=lambda k: min(res["books"][k]["halves"])), "ACCEPT"
elif risk:
sel, decision = max(risk, key=lambda k: res["books"][k]["cagr"]), "PARTIAL"
else:
sel, decision = None, "REJECT"
res["verdict"] = {"gates": gates, "selected": sel, "decision": decision}
print(f"gates {gates}\n-> {decision} {sel}")
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"managed_futures_book:{k}", **v} for k, v in res["books"].items()],
{"study": "managed_futures_book", "role": "preregistered gate; all periods previously inspected"})
if __name__ == "__main__":
main()