diff --git a/scripts/surfer/portfolio_harvest3.py b/scripts/surfer/portfolio_harvest3.py new file mode 100644 index 000000000..b69d85d10 --- /dev/null +++ b/scripts/surfer/portfolio_harvest3.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""The one evidence-backed non-crypto lever: add a diversified TREND sleeve to 60/40. + +Research verdict: trend/managed-futures is the only premium genuinely uncorrelated-to-equity AND +convex in crises (made money in 2022 when stocks+bonds both fell). We tested trend standalone +(marginal) and as a filter (hurt) — never as a DIVERSIFYING SLEEVE on 60/40, the specific thing +the evidence supports. Build a long/SHORT diversified time-series-momentum book (vol-targeted), +blend with 60/40 at various weights, measure Sharpe + maxDD + crisis-year behavior. Honest +question: does 60/40 + trend beat plain 60/40 on risk-adjusted terms (~0.7 -> ~0.8-0.9)? +""" +import math +import os +import sys + +import numpy as np + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from signal_sweep import load_panel, build_returns # noqa: E402 + +TVOL = 0.10 + + +def metrics(r): + r = np.asarray(r); r = r[np.isfinite(r)] + if len(r) < 50 or r.std() == 0: + return dict(sr=float("nan"), ann=float("nan"), dd=float("nan"), cal=float("nan")) + ann = r.mean() * 252; vol = r.std() * math.sqrt(252) + eq = np.cumprod(1 + r); dd = float((eq / np.maximum.accumulate(eq) - 1).min()) + return dict(sr=ann / vol, ann=ann, dd=dd, cal=ann / (abs(dd) + 1e-9)) + + +def voltarget(r, win=63): + out = np.zeros_like(r) + for t in range(win, len(r)): + rv = np.std(r[t - win:t]) * math.sqrt(252) + out[t] = r[t] * min(3.0, TVOL / (rv + 1e-9)) + return out + + +def main(): + roots, days, close, op, inst = load_panel()[:5] + lc, R, ov, intr = build_returns(close, op, inst) + T, N = close.shape + year = (1970 + days / 365.25).astype(int) + vol63 = np.full_like(lc, np.nan) + for t in range(63, T): + vol63[t] = np.nanstd(R[t - 63:t], axis=0) + iv = 1.0 / np.where(vol63 > 0, vol63, np.nan) + avail = np.isfinite(close) & np.isfinite(vol63) & (vol63 > 0) + + es, zn = roots.index("ES"), roots.index("ZN") + r6040 = 0.6 * R[1:, es] + 0.4 * R[1:, zn] # plain 60/40 (daily returns) + + # diversified long/SHORT time-series-momentum (CTA) sleeve, vol-targeted + tsig = np.full_like(lc, np.nan) + tsig[252:] = np.sign(lc[252:] - lc[:-252]) # long if 12mo up, short if down + w = np.where(avail & np.isfinite(tsig), tsig * iv, 0.0) + g = np.sum(np.abs(w), axis=1, keepdims=True); g[g == 0] = 1 + w = w / g + rtrend_raw = np.sum(w[:-1] * R[1:], axis=1) + rtrend = voltarget(rtrend_raw) # the managed-futures sleeve + + # normalize both legs to ~TVOL so blend weights are meaningful, then blend + def norm(r): + return voltarget(r) + base = norm(r6040) + sleeve = rtrend + yr = year[1:] + + print(f"\n===== 60/40 + TREND SLEEVE ({N}-asset long/short CTA, vol-targeted) =====") + print(f"{'blend':>22} {'Sharpe':>7} {'ann%':>6} {'maxDD%':>7} {'Calmar':>7}") + blends = [("100% 60/40", 1.0, 0.0), ("80/20 +trend", 0.8, 0.2), ("70/30 +trend", 0.7, 0.3), + ("60/40 +trend", 0.6, 0.4), ("50/50 +trend", 0.5, 0.5), ("100% trend", 0.0, 1.0)] + rows = {} + for nm, a, b in blends: + r = a * base + b * sleeve + rows[nm] = r; m = metrics(r) + print(f"{nm:>22} {m['sr']:>+7.2f} {100*m['ann']:>+6.1f} {100*m['dd']:>+7.1f} {m['cal']:>7.2f}") + # correlation of the two legs (the diversification engine) + mask = np.isfinite(base) & np.isfinite(sleeve) & (base != 0) & (sleeve != 0) + corr = np.corrcoef(base[mask], sleeve[mask])[0, 1] + print(f"\n corr(60/40, trend) = {corr:+.2f} (negative/low = the diversification that lifts Sharpe + cuts DD)") + print(" CRISIS-YEAR check — Sharpe by year (does trend save the equity/bond drawdowns?):") + for nm in ["100% 60/40", "70/30 +trend"]: + r = rows[nm] + py = " ".join(f"{y}:{metrics(r[yr==y])['sr']:+.1f}" for y in [2015, 2018, 2020, 2022, 2025] if (yr == y).sum() > 100) + print(f" {nm:>14}: {py}") + print("\nVERDICT: if 70/30 or 60/40+trend Sharpe > plain 60/40 AND maxDD smaller (esp 2022) = the evidence-backed") + print("lever works; ~0.7 -> ~0.8-0.9 with crisis protection. If not, 60/40 is genuinely the retail ceiling.") + + +if __name__ == "__main__": + main()