- 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>
246 lines
12 KiB
Python
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()
|