"""XL02 [LIQ] — Volume-trend momentum IDEA: Score = volume_z(vol, 30) combined with positive return (rising-volume winners). Assets with above-average volume AND positive momentum rank highest. Assets with above-average volume AND negative momentum rank lowest (i.e., short). Mechanism intuition: - Volume surge signals conviction / participation. - When paired with rising price (trend direction) it confirms breakout. - When paired with falling price it confirms distribution / breakdown. - Pure volume without price direction is ambiguous (could be capitulation or breakout). Score variants explored (<=5 total): 1. vol_z(30) * ret(10) -- product: vol-amplified short-term return 2. vol_z(30) * ret(30) -- product: vol-amplified medium return 3. blend: 0.5*xs_z(vol_z*ret10) + 0.5*xs_z(ret30) -- add momentum anchor 4. Same blend but long-only (avoid short vol-breakdown which may just be panic) 5. vol_z(60) * ret(20) -- wider lookback, majors universe """ import sys sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec") import xslib as xs import numpy as np # ── Score factory ──────────────────────────────────────────────────────────── def score_vol_trend(P, vol_win=30, ret_win=10): """product: volume_z * past_return — higher = rising on high volume""" vz = xs.volume_z(P.vol, vol_win) # (n, A) causal rr = xs.past_return(P.close, ret_win) # (n, A) causal score = vz * rr return score def score_vol_trend_blend(P, vol_win=30, ret_win_short=10, ret_win_long=30, w_blend=0.5): """Blend vol*ret with standalone momentum to add a stable anchor""" vz = xs.volume_z(P.vol, vol_win) rr_short = xs.past_return(P.close, ret_win_short) rr_long = xs.past_return(P.close, ret_win_long) signal1 = xs.xs_zscore(vz * rr_short) signal2 = xs.xs_zscore(rr_long) return w_blend * signal1 + (1 - w_blend) * signal2 # ── Grid (5 calls) ─────────────────────────────────────────────────────────── results = [] # 1. vol_z(30) * ret(10) — LS, all universe r1 = xs.study_xs( "XL02-vz30r10", lambda P: score_vol_trend(P, vol_win=30, ret_win=10), universe="all", H=10, k=5, long_short=True, ) results.append(r1) print(xs.fmt(r1)) print("JSON:", xs.as_json(r1)) print() # 2. vol_z(30) * ret(30) — LS, all universe r2 = xs.study_xs( "XL02-vz30r30", lambda P: score_vol_trend(P, vol_win=30, ret_win=30), universe="all", H=10, k=5, long_short=True, ) results.append(r2) print(xs.fmt(r2)) print("JSON:", xs.as_json(r2)) print() # 3. blend: vol*ret(10) + mom(30) — LS, all universe r3 = xs.study_xs( "XL02-blend", lambda P: score_vol_trend_blend(P, vol_win=30, ret_win_short=10, ret_win_long=30, w_blend=0.5), universe="all", H=10, k=5, long_short=True, ) results.append(r3) print(xs.fmt(r3)) print("JSON:", xs.as_json(r3)) print() # 4. blend long-only (avoid shorting high-vol breakdowns) r4 = xs.study_xs( "XL02-blend-LO", lambda P: score_vol_trend_blend(P, vol_win=30, ret_win_short=10, ret_win_long=30, w_blend=0.5), universe="all", H=10, k=5, long_short=False, ) results.append(r4) print(xs.fmt(r4)) print("JSON:", xs.as_json(r4)) print() # 5. vol_z(60) * ret(20) — majors universe, tighter r5 = xs.study_xs( "XL02-vz60r20-maj", lambda P: score_vol_trend(P, vol_win=60, ret_win=20), universe="majors", H=10, k=5, long_short=True, ) results.append(r5) print(xs.fmt(r5)) print("JSON:", xs.as_json(r5)) print() # ── Pick best ──────────────────────────────────────────────────────────────── def score_result(r): """Higher is better: prefer earns_slot, then hold-out, then full.""" m = r["marginal"] earns = int(r["earns_slot"]) * 10 hold_sh = r["holdout"].get("sharpe", -99) full_sh = r["full"]["sharpe"] distinct = 1 if (r["corr_xs01"] or 1.0) < 0.6 else 0 return earns + distinct + hold_sh + 0.3 * full_sh best = max(results, key=score_result) print("=" * 60) print(f"BEST CONFIG: {best['name']}") print(xs.fmt(best)) print("JSON:", xs.as_json(best))