diff --git a/scripts/surfer/growth_discipline_backtest.py b/scripts/surfer/growth_discipline_backtest.py new file mode 100644 index 000000000..48f7bcfc6 --- /dev/null +++ b/scripts/surfer/growth_discipline_backtest.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Growth-discipline multi-premia backtest: high-equity (QQQ) core + trend crisis-alpha sleeve + +diversified book, mechanically rebalanced, optional cheap-futures leverage. vs QQQ / book / 70-30. +Also tests adding VRP (PUTW put-write) as a sleeve. Honest: execution-alpha (behavior gap, tax-loss +harvest) is real but counterfactual/account-dependent -> NOT in the backtest; noted as +1-3%/yr on top. + + python3 growth_discipline_backtest.py [--fin 0.03] +""" +import datetime +import json +import math +import os +import sys +import urllib.request + +import numpy as np + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from multistrat_paper import book_series # noqa: E402 + + +def yh(s): + try: + r = json.loads(urllib.request.urlopen(urllib.request.Request( + f"https://query1.finance.yahoo.com/v8/finance/chart/{s}?interval=1d&range=25y", + headers={"User-Agent": "Mozilla/5.0"}), timeout=30).read())["chart"]["result"][0] + a = r["indicators"].get("adjclose", [{}])[0].get("adjclose") or r["indicators"]["quote"][0]["close"] + return {datetime.datetime.utcfromtimestamp(t).strftime("%Y-%m-%d"): float(c) for t, c in zip(r["timestamp"], a) if c} + except Exception: + return {} + + +FIN = float(sys.argv[sys.argv.index("--fin") + 1]) if "--fin" in sys.argv else 0.03 + + +def build(syms): + data = {s: yh(s) for s in syms} + common = sorted(set.intersection(*[set(data[s]) for s in syms])) + yrs = (datetime.date.fromisoformat(common[-1]) - datetime.date.fromisoformat(common[0])).days / 365.25 + T = len(common) + P = {s: np.array([data[s][d] for d in common]) for s in syms} + R = {s: np.concatenate([[0], P[s][1:] / P[s][:-1] - 1]) for s in syms} + return common, yrs, T, P, R + + +def trend_sleeve(P, R, syms, T): + """12-1 TS-momentum long/short on syms, equal-risk = managed-futures proxy.""" + Pm = np.column_stack([P[s] for s in syms]); Rm = np.column_stack([R[s] for s in syms]) + lc = np.log(Pm); sg = np.zeros((T, len(syms))); sg[252:] = np.sign(lc[252:] - lc[:-252]) + vol = np.full((T, len(syms)), np.nan) + for t in range(63, T): + vol[t] = Rm[t - 63:t].std(0) + iv = 1 / np.where(vol > 0, vol, np.nan); w = np.nan_to_num(sg * iv) + g = np.abs(w).sum(1, keepdims=True); g[g == 0] = 1; w = w / g + tr = np.zeros(T); tr[1:] = np.sum(w[:-1] * Rm[1:], axis=1); return tr + + +def report(title, cands, yrs): + def stats(r): + r = np.nan_to_num(r); eq = np.cumprod(1 + r); c = eq[-1] ** (1 / yrs) - 1 + v = r.std() * math.sqrt(252); dd = float((eq / np.maximum.accumulate(eq) - 1).min()) + sh = (r.mean() * 252 - FIN) / v if v > 0 else 0 + return c, v, sh, dd + def fv(c, P0=360000, pmt=8000, years=20): + rm = c / 12; N = years * 12; gg = (1 + rm) ** N; return P0 * gg + pmt * ((gg - 1) / rm) + print(f"\n=== {title} ===") + print(f" {'strategy':>30} | CAGR | vol | Shrp* | maxDD | $360k+$8k/mo 20y") + for nm, r in cands: + c, v, sh, dd = stats(r) + print(f" {nm:>30} | {100*c:>4.1f}% | {100*v:>3.0f}% | {sh:>+4.2f} | {100*dd:>+4.0f}% | ${fv(c):>13,.0f}") + + +def main(): + # A) long history 2006-2026 + common, yrs, T, P, R = build(["QQQ", "SPY", "IEF", "GLD", "DBC"]) + tr = trend_sleeve(P, R, ["SPY", "IEF", "GLD", "DBC"], T) + R4 = np.column_stack([R[s] for s in ["SPY", "IEF", "GLD", "DBC"]]) + book, _, _ = book_series(np.column_stack([R4, tr])) + qqq = R["QQQ"] + def lev(r, L): return L * r - (L - 1) * FIN / 252 + gd70 = 0.70 * qqq + 0.15 * tr + 0.15 * book + gd60 = 0.60 * qqq + 0.20 * tr + 0.20 * book + report(f"A) Long history {common[0]}..{common[-1]} ({yrs:.0f}y), fin {100*FIN:.0f}%", [ + ("QQQ buy-hold", qqq), + ("book (diversified)", book), + ("70/30 QQQ+book", 0.7 * qqq + 0.3 * book), + ("GD-70 (70Q/15trend/15book)", gd70), + ("GD-60 (60Q/20trend/20book)", gd60), + ("GD-70 @1.2x cheap lev", lev(gd70, 1.2)), + ("GD-70 @1.3x cheap lev", lev(gd70, 1.3)), + ], yrs) + print(" *Sharpe = excess over financing. (Execution-alpha — behavior gap ~1-2%/yr, tax-loss") + print(" harvest ~0.5-1.5%/yr taxable — is REAL but counterfactual, NOT in these numbers: add on top.)") + + # B) recent window incl VRP (PUTW) + real managed-futures (DBMF) + common2, yrs2, T2, P2, R2 = build(["QQQ", "SPY", "IEF", "GLD", "DBC", "PUTW", "DBMF"]) + tr2 = trend_sleeve(P2, R2, ["SPY", "IEF", "GLD", "DBC"], T2) + book2, _, _ = book_series(np.column_stack([np.column_stack([R2[s] for s in ["SPY", "IEF", "GLD", "DBC"]]), tr2])) + q2 = R2["QQQ"]; putw = R2["PUTW"]; dbmf = R2["DBMF"] + base = 0.70 * q2 + 0.15 * dbmf + 0.15 * book2 # GD with REAL managed-futures ETF + with_vrp = 0.60 * q2 + 0.15 * dbmf + 0.15 * book2 + 0.10 * putw + report(f"B) Recent {common2[0]}..{common2[-1]} ({yrs2:.0f}y) — does VRP add?", [ + ("QQQ buy-hold", q2), + ("GD (70Q/15DBMF/15book)", base), + ("GD + 10% VRP(PUTW)", with_vrp), + ("PUTW alone (VRP)", putw), + ("DBMF alone (trend)", dbmf), + ], yrs2) + print(" carry: no clean liquid retail ETF (DBV-type funds are tiny/closed) -> not testable cleanly here.") + + +if __name__ == "__main__": + main()