diff --git a/scripts/surfer/equity_lowvol_detail.py b/scripts/surfer/equity_lowvol_detail.py new file mode 100644 index 000000000..5d913d25b --- /dev/null +++ b/scripts/surfer/equity_lowvol_detail.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Decompose the equity low-vol +0.74 in detail: alpha vs beta, which leg, turnover, concentration. + +Questions: (1) Is it cross-sectional ALPHA or a structural short-beta directional bet? +(2) Which leg carries it — long low-vol (deployable, no borrow) or short high-vol (expensive)? +(3) Turnover (is the cost charge fair?). (4) P&L concentration over time. (5) Beta-neutral residual. +""" +import math +import os +import sys + +import numpy as np + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from equity_factor_gate import load, roll # noqa: E402 +from signal_sweep import validate, sharpe_t # noqa: E402 +import torch # noqa: E402 + +DEV = "cuda" if torch.cuda.is_available() else "cpu" +DV_FLOOR, VOL_PCT, COST = 1e7, 0.60, 10.0 + + +def main(): + insts, days, close, dvol = load() + T, N = close.shape + lc = np.log(close) + R = np.zeros((T, N)); R[1:] = lc[1:] - lc[:-1]; R = np.where(np.isfinite(R), R, 0.0) + dv30 = roll(np.mean, np.nan_to_num(dvol), 30) + vol63 = roll(np.std, R, 63) + year = (1970 + days / 365.25).astype(int) + T_ = lambda x: torch.tensor(np.asarray(x)[np.isfinite(np.asarray(x))], device=DEV, dtype=torch.float64) + + univ = np.zeros((T, N), bool) + for t in range(T): + e = np.where((dv30[t] > DV_FLOOR) & np.isfinite(close[t]) & np.isfinite(vol63[t]))[0] + if len(e) > 50: + univ[t, e[vol63[t, e] >= np.quantile(vol63[t, e], VOL_PCT)]] = True + + def held(w, K=5): + a = 2.0 / 6 + for t in range(1, T): + w[t] = a * w[t] + (1 - a) * w[t - 1] + wh = w.copy(); last = 0 + for t in range(T): + if t % K == 0: + last = t + wh[t] = w[last] + return wh + + def leg(which, q=0.10): + """EW long basket of a decile: 'low'=lowest-vol, 'high'=highest-vol, 'all'=whole cohort.""" + w = np.zeros((T, N)) + for t in range(T): + e = np.where(univ[t])[0] + if len(e) < 20: + continue + if which == "all": + w[t, e] = 1.0 / len(e) + else: + order = e[np.argsort(vol63[t, e])] # ascending vol + k = max(int(q * len(e)), 5) + sel = order[:k] if which == "low" else order[-k:] + w[t, sel] = 1.0 / k + w = held(w) + pnl = np.sum(w[:-1] * R[1:], axis=1) - np.sum(np.abs(w[1:] - w[:-1]), axis=1) * COST / 1e4 + turn = float(np.mean(np.sum(np.abs(w[1:] - w[:-1]), axis=1))) + return pnl, turn + + lo, lo_turn = leg("low") + hi, hi_turn = leg("high") + mkt, _ = leg("all") + ls = lo - hi # dollar-neutral L/S (gross of the extra cost already in legs) + sr = sharpe_t + + print(f"\n===== EQUITY LOW-VOL +0.74 DECOMPOSITION (cohort liquid>${DV_FLOOR/1e6:.0f}M & vol>{int(VOL_PCT*100)}pct) =====") + print(f"cohort EW (beta/market) Sharpe {sr(T_(mkt)):+.2f}") + print(f"LONG leg (low-vol decile) Sharpe {sr(T_(lo)):+.2f} alpha-vs-cohort {sr(T_(lo-mkt)):+.2f} turn/period {lo_turn:.2f}") + print(f"HIGH-vol decile Sharpe {sr(T_(hi)):+.2f} (short it -> +{sr(T_(mkt-hi)):+.2f} short-alpha-vs-cohort)") + print(f"L/S (low - high) Sharpe {sr(T_(ls)):+.2f}") + + # beta decomposition of L/S vs cohort market + a, b = np.asarray(mkt), np.asarray(ls) + m = np.isfinite(a) & np.isfinite(b); x, yv = a[m], b[m] + beta = float(np.cov(x, yv)[0, 1] / (np.var(x) + 1e-12)) + resid = yv - beta * x + print(f"\nL/S beta to cohort-market = {beta:+.2f} (negative = structural short-beta tilt)") + print(f"L/S beta-NEUTRAL residual alpha Sharpe = {sr(T_(resid)):+.2f} " + f"(this is the TRUE cross-sectional alpha; if ~0 it was just short-beta)") + + # long-leg deployable check (no borrow) + vlo = validate(lo - mkt, days, 30) # long-leg market-neutralized (long decile vs cohort) + print(f"\nLONG-leg alpha (deployable, no borrow): full {vlo['full']:+.2f} OOS {vlo['oos']:+.2f} CPCVmed {vlo['med']:+.2f} DSR {vlo['dsr']:.2f}") + + # per-year + concentration + print("per-year L/S: " + " ".join(f"{y}:{sr(T_(ls[year[1:]==y])):+.2f}" for y in range(2023,2027) if (year[1:]==y).sum()>40)) + p = np.nan_to_num(ls); tot = p.sum(); top = np.sort(p)[-10:].sum() + print(f"P&L concentration: top-10 days = {100*top/ (tot+1e-12):.0f}% of total (high => episodic/fragile)") + print(f"turnover: low-leg {lo_turn:.2f}/period, high-leg {hi_turn:.2f}/period (low => slow signal, cost charge fair)") + print("\nREAD: if beta-neutral residual ~0 => short-beta bet (fragile); if long-leg alpha DSR>0.5 => deployable long-only edge.") + + +if __name__ == "__main__": + main()