"""XS03b — Beta-hedged momentum. IDEA: Instead of plain cross-sectional momentum (XS01), use RESIDUAL momentum: score = cumulative idiosyncratic return over lookback L. The residual is ret - beta*mkt_ret (rolling beta vs equal-weight panel), so each asset's score reflects ONLY its idiosyncratic drift, stripping out shared market moves. The resulting book is already dollar-neutral (long-short) but also implicitly market-beta-neutral because the signal itself filters out mkt co-movement. WHY DISTINCT FROM XS01: Plain XS01 ranks on raw momentum; the top assets in a bull market are often the highest-beta assets (not idiosyncratic winners). Beta-hedged momentum ranks on WHAT IS LEFT after removing mkt factor: - In bull: avoids accidental overweight of market beta - In bear: avoids accidental short of low-beta (defensive) assets - Net: the book is more idiosyncratic and less correlated to raw XS momentum. GRID (5 backtests max): 1. majors, L=30, beta_win=90, H=10, k=5, LS 2. majors, L=60, beta_win=90, H=10, k=5, LS 3. all, L=30, beta_win=90, H=10, k=5, LS 4. all, L=60, beta_win=90, H=10, k=5, LS 5. all, blend L=[30,60] residuals, beta_win=90, H=10, k=5, LS (like XS01 blend) Pick best by: earns_slot > holdout_sharpe > distinctness from XS01. """ import sys sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec") import xslib as xs import numpy as np def residual_momentum(P, L, beta_win=90): """Cumulative idiosyncratic return over L days (causal). Residual daily ret = ret - rolling_beta * market_ret. Cumulate over L days to get momentum score on idiosyncratic drift. """ resid = xs.residual_return(P.ret, beta_win) # (n_days x n_assets) # Cumulate residuals over L days (causal: sum of past L residual daily rets) n, A = resid.shape cum = np.full((n, A), np.nan) for i in range(L, n): cum[i] = np.nansum(resid[i - L:i], axis=0) return cum def blend_residual_mom(P, Ls=(30, 60), beta_win=90): """Cross-sectional z-score blend of multiple lookback residual momentums.""" scores = [] for L in Ls: s = residual_momentum(P, L, beta_win) scores.append(xs.xs_zscore(s)) return np.nanmean(scores, axis=0) print("=== XS03b: Beta-hedged Momentum ===\n") results = [] # 1. majors, L=30 print("Run 1/5: majors, L=30, beta_win=90, H=10, k=5 LS") r1 = xs.study_xs( "XS03b-MAJ-L30", lambda P: residual_momentum(P, 30, 90), universe="majors", H=10, k=5, long_short=True ) print(xs.fmt(r1)) results.append(r1) # 2. majors, L=60 print("\nRun 2/5: majors, L=60, beta_win=90, H=10, k=5 LS") r2 = xs.study_xs( "XS03b-MAJ-L60", lambda P: residual_momentum(P, 60, 90), universe="majors", H=10, k=5, long_short=True ) print(xs.fmt(r2)) results.append(r2) # 3. all, L=30 print("\nRun 3/5: all, L=30, beta_win=90, H=10, k=5 LS") r3 = xs.study_xs( "XS03b-ALL-L30", lambda P: residual_momentum(P, 30, 90), universe="all", H=10, k=5, long_short=True ) print(xs.fmt(r3)) results.append(r3) # 4. all, L=60 print("\nRun 4/5: all, L=60, beta_win=90, H=10, k=5 LS") r4 = xs.study_xs( "XS03b-ALL-L60", lambda P: residual_momentum(P, 60, 90), universe="all", H=10, k=5, long_short=True ) print(xs.fmt(r4)) results.append(r4) # 5. all, blend [30,60] print("\nRun 5/5: all, blend L=[30,60], beta_win=90, H=10, k=5 LS") r5 = xs.study_xs( "XS03b-ALL-BLEND", lambda P: blend_residual_mom(P, (30, 60), 90), universe="all", H=10, k=5, long_short=True ) print(xs.fmt(r5)) results.append(r5) # --- Pick best --- def score_config(r): """Priority: earns_slot > holdout_sharpe > full_sharpe > distinctness.""" slot = 1 if r.get("earns_slot") else 0 hs = r["holdout"].get("sharpe", -9) fs = r["full"]["sharpe"] corr = r.get("corr_xs01") or 1.0 distinct = 1.0 - abs(corr) return (slot, hs, fs, distinct) best = max(results, key=score_config) print("\n\n=== BEST CONFIG ===") print(xs.fmt(best)) print("JSON:", xs.as_json(best))