"""XR02 — Short-term Reversal gated by high-vol regime (L=3). MECHANISM: Plain reversal: short the recent winners, long the recent losers (L=3 days lookback). GATE: only active when market volatility is HIGH — defined as the 30-day rolling std of the equal-weight market return exceeding its own 90-day expanding percentile (p70 threshold). In low-vol / calm regimes, the book is flat (score = NaN -> no position). Rationale: short-term reversal is a classic effect but is often diluted by trend in calm regimes. In panic / high-vol regimes (sharp market moves), mean-reversion / liquidity provision logic is stronger (overshoot + reversal). Gate concentrates the signal in those regimes while avoiding trend-contamination in smooth uptrends. Causal: all quantities computed at close[i], applied to return of bar i+1. """ import sys sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec") import xslib as xs import numpy as np import pandas as pd def rev_gate_score(P, L=3, vol_win=30, baseline_win=90, vol_pct=70, use_residual=False): """Short-term reversal score gated by high market-vol regime. Parameters ---------- P : Panel L : int Reversal lookback in days (price L days ago vs today). vol_win : int Rolling window for realised market-vol (std of equal-weight market return). baseline_win : int Expanding window for computing the percentile threshold on market-vol. vol_pct : float Percentile threshold: market vol must exceed this percentile to be active. use_residual : bool If True, compute reversal on idiosyncratic (market-beta-neutral) returns instead of raw. """ n, A = P.close.shape # --- 1. Reversal score: negative of L-day return (higher = long the loser) --- raw_ret = xs.past_return(P.close, L) # (n, A), causal: uses close[i-L..i] if use_residual: # use idiosyncratic cumulative return instead of total resid = xs.residual_return(P.ret, 30) # (n, A), causal # cumulate idiosyncratic over L days resid_cum = np.full_like(raw_ret, np.nan) for lag in range(1, L + 1): shifted = np.roll(resid, lag, axis=0) shifted[:lag] = np.nan resid_cum = np.nansum([resid_cum, resid], axis=0) signal = -resid_cum else: signal = -raw_ret # reversal: short winners, long losers # --- 2. Market-vol regime gate (expanding percentile, causal) --- mkt = xs.market_ret(P.ret) # (n,) equal-weight mkt return mkt_vol = pd.Series(mkt).rolling(vol_win, min_periods=max(5, vol_win // 2)).std().values # expanding percentile of mkt_vol up to each row i (causal) thresh = np.full(n, np.nan) for i in range(baseline_win, n): hist = mkt_vol[max(0, i - baseline_win):i + 1] hist = hist[np.isfinite(hist)] if len(hist) >= 10: thresh[i] = np.nanpercentile(hist, vol_pct) # gate: active only when mkt_vol > threshold (high-vol regime) active = (mkt_vol > thresh) # (n,) boolean, NaN -> False active[~np.isfinite(mkt_vol) | ~np.isfinite(thresh)] = False # --- 3. Apply gate: set score to NaN when flat --- score = signal.copy() score[~active, :] = np.nan return score # --------------------------------------------------------------------------- # GRID — <=5 study_xs calls # Config space: L in {3, 5}, vol_pct in {60, 70}, universe in {majors, all} # --------------------------------------------------------------------------- print("=" * 70) print("XR02: Short-term Reversal gated by high-vol regime") print("=" * 70) results = [] # Config 1: L=3, pct=70, majors (baseline config) print("\n[1/5] L=3, vol_pct=70, universe=majors") rep1 = xs.study_xs( "XR02-L3-p70-maj", lambda P: rev_gate_score(P, L=3, vol_win=30, baseline_win=90, vol_pct=70), universe="majors", H=3, k=5, long_short=True, ) print(xs.fmt(rep1)) results.append(rep1) # Config 2: L=3, pct=70, all (wider universe) print("\n[2/5] L=3, vol_pct=70, universe=all") rep2 = xs.study_xs( "XR02-L3-p70-all", lambda P: rev_gate_score(P, L=3, vol_win=30, baseline_win=90, vol_pct=70), universe="all", H=3, k=5, long_short=True, ) print(xs.fmt(rep2)) results.append(rep2) # Config 3: L=3, pct=60 (more permissive gate), majors print("\n[3/5] L=3, vol_pct=60, universe=majors") rep3 = xs.study_xs( "XR02-L3-p60-maj", lambda P: rev_gate_score(P, L=3, vol_win=30, baseline_win=90, vol_pct=60), universe="majors", H=3, k=5, long_short=True, ) print(xs.fmt(rep3)) results.append(rep3) # Config 4: L=5, pct=70, majors (longer lookback) print("\n[4/5] L=5, vol_pct=70, universe=majors") rep4 = xs.study_xs( "XR02-L5-p70-maj", lambda P: rev_gate_score(P, L=5, vol_win=30, baseline_win=90, vol_pct=70), universe="majors", H=5, k=5, long_short=True, ) print(xs.fmt(rep4)) results.append(rep4) # Config 5: L=3, pct=70, majors, H=5 (slower rebalance) print("\n[5/5] L=3, vol_pct=70, universe=majors, H=5") rep5 = xs.study_xs( "XR02-L3-p70-maj-H5", lambda P: rev_gate_score(P, L=3, vol_win=30, baseline_win=90, vol_pct=70), universe="majors", H=5, k=5, long_short=True, ) print(xs.fmt(rep5)) results.append(rep5) # --------------------------------------------------------------------------- # BEST: pick by earns_slot, then holdout sharpe, then distinctness # --------------------------------------------------------------------------- def score_result(r): earns = int(r.get("earns_slot", False)) hold_sh = r["holdout"].get("sharpe", -99) full_sh = r["full"].get("sharpe", -99) corr_xs01 = r.get("corr_xs01") or 1.0 # prefer earns_slot, then hold-out, then distinctness, then full return (earns, hold_sh, full_sh, -abs(corr_xs01)) best = max(results, key=score_result) print("\n" + "=" * 70) print("BEST CONFIG:") print(xs.fmt(best)) print("JSON:", xs.as_json(best))