research(xsec): sweep cross-sectional su Hyperliquid (43 script/257 config) + verifica avversariale
Nuova harness condivisa xslib.py (panel HL certificato, score per-asset causale, book long-k/short-k vol-targeted leak-free) + 43 script in runs/ su 11 famiglie (MOM/REV/VOL/ DIST/LIQ/VAL/STRUCT/UNIV). Scoring = earns_slot (full>0 AND hold-out>0 AND marginal ADDS al portafoglio live AND corr XS01<0.6, con jackknife drop-one-month). Find: 42/257 config earns_slot=True, ma TUTTE con corr TP01 -0.2..-0.4 e PnL ~solo 2025. Verify (verify_survivors.py, 3 scettici deterministici): - S1 redundancy: cluster low-vol = UNA scommessa (XV01=XU02=1.00, XV02/XV03 r 0.44-0.67); XM09/XL02/XS06b/XR02 distinti (corr media off-diag +0.20). - S2 short-beta: cluster low-vol carica 0.44-0.70 su short-market -> NON market-neutral, e' un tilt short-alt-beta di regime. XM09(0.08)/XR02(-0.21) NON short-beta. - S3 per-anno: cluster low-vol decade (XV01/XU02 2026 -0.09); XL02 morto (2025 -0.14, 2026 -0.43); XM09 (0.82/0.50/0.74) e XR02 (0.84/0.40/2.68) positivi in tutti e 3 gli anni. Esito: nessuna sleeve nuova. Cluster low-vol RIGETTATO (regime-bet), XL02 RIGETTATO (overfit). 2 LEAD genuini (XM09 trend-gated x-sec momentum, XR02 reversal vol-gated) -> forward-monitor, non deployabili (panel 2.5y regime unico + STAT-MODE esecuzione). Portafoglio live invariato. Incluso anche options_vrp_managed.py (A/B VRP01 hold-to-expiry vs gestione attiva del doc credit-spread): la gestione attiva DISTRUGGE l'edge (combo FULL managed Sh -1.29 vs HtE +0.96, il delta-exit taglia i vincenti) -> scartata, VRP01 resta hold-to-expiry. Diari: 2026-06-20-xsec-strategies-sweep.md, 2026-06-20-vrp-active-management.md. gitignore: data/paper_portfolio/ (stato runtime paper) + scripts/research/xsec/runs/out/ (output rigenerabile). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
"""XD01 — Low-skew / anti-lottery cross-sectional strategy.
|
||||
|
||||
Score = -roll_skew(ret, 60): short high-skew "lottery" alts, long low-skew alts.
|
||||
Rationale: lottery-preference premium — investors overpay for positive-skew assets
|
||||
(right-tail lottery tickets), so they should earn lower returns; negative-skew assets
|
||||
are underpriced relative to their systematic risk.
|
||||
|
||||
Grid (<=5 calls):
|
||||
1. Baseline: "majors" (19 XS01 universe), H=10, k=5, L/S
|
||||
2. Wider universe: "all" (~49 alts), H=10, k=5, L/S
|
||||
3. Vary rebalance period: "all", H=5, k=5, L/S (more frequent)
|
||||
4. Vary top-k: "all", H=10, k=7, L/S (more diversified)
|
||||
5. Combined: -skew60 + -skew30 blend (multi-horizon), "all", H=10, k=5, L/S
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
SKEW_WIN = 60 # lookback for rolling skew (days)
|
||||
SKEW_WIN2 = 30 # shorter lookback for blend
|
||||
|
||||
|
||||
def score_anti_lottery(P, win=SKEW_WIN):
|
||||
"""Anti-lottery score: negate rolling skew so LOW-skew assets score HIGH (long)."""
|
||||
sk = xs.roll_skew(P.ret, win) # (n_days x n_assets); higher skew = lottery
|
||||
return -sk # higher = lower skew = long
|
||||
|
||||
|
||||
def score_anti_lottery_blend(P, w1=SKEW_WIN, w2=SKEW_WIN2):
|
||||
"""Multi-horizon blend of negated skews (cross-sectionally z-scored before blend)."""
|
||||
sk1 = xs.xs_zscore(-xs.roll_skew(P.ret, w1))
|
||||
sk2 = xs.xs_zscore(-xs.roll_skew(P.ret, w2))
|
||||
return 0.5 * sk1 + 0.5 * sk2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
results = []
|
||||
|
||||
# --- Run 1: majors universe, H=10, k=5, L/S ---
|
||||
print("Running XD01-v1: majors, H=10, k=5, L/S ...")
|
||||
rep1 = xs.study_xs(
|
||||
"XD01-v1-majors",
|
||||
lambda P: score_anti_lottery(P, 60),
|
||||
universe="majors",
|
||||
H=10, k=5, long_short=True,
|
||||
)
|
||||
print(xs.fmt(rep1))
|
||||
results.append(rep1)
|
||||
|
||||
# --- Run 2: all universe, H=10, k=5, L/S ---
|
||||
print("\nRunning XD01-v2: all, H=10, k=5, L/S ...")
|
||||
rep2 = xs.study_xs(
|
||||
"XD01-v2-all",
|
||||
lambda P: score_anti_lottery(P, 60),
|
||||
universe="all",
|
||||
H=10, k=5, long_short=True,
|
||||
)
|
||||
print(xs.fmt(rep2))
|
||||
results.append(rep2)
|
||||
|
||||
# --- Run 3: all, H=5 (more frequent rebalance), k=5, L/S ---
|
||||
print("\nRunning XD01-v3: all, H=5, k=5, L/S ...")
|
||||
rep3 = xs.study_xs(
|
||||
"XD01-v3-H5",
|
||||
lambda P: score_anti_lottery(P, 60),
|
||||
universe="all",
|
||||
H=5, k=5, long_short=True,
|
||||
)
|
||||
print(xs.fmt(rep3))
|
||||
results.append(rep3)
|
||||
|
||||
# --- Run 4: all, H=10, k=7, L/S (more diversified) ---
|
||||
print("\nRunning XD01-v4: all, H=10, k=7, L/S ...")
|
||||
rep4 = xs.study_xs(
|
||||
"XD01-v4-k7",
|
||||
lambda P: score_anti_lottery(P, 60),
|
||||
universe="all",
|
||||
H=10, k=7, long_short=True,
|
||||
)
|
||||
print(xs.fmt(rep4))
|
||||
results.append(rep4)
|
||||
|
||||
# --- Run 5: blend multi-horizon skew, all, H=10, k=5, L/S ---
|
||||
print("\nRunning XD01-v5: blend skew30+60, all, H=10, k=5, L/S ...")
|
||||
rep5 = xs.study_xs(
|
||||
"XD01-v5-blend",
|
||||
lambda P: score_anti_lottery_blend(P, 60, 30),
|
||||
universe="all",
|
||||
H=10, k=5, long_short=True,
|
||||
)
|
||||
print(xs.fmt(rep5))
|
||||
results.append(rep5)
|
||||
|
||||
# --- Pick best config by: earns_slot > holdout sharpe > full sharpe > distinctness ---
|
||||
def rank_key(r):
|
||||
earns = int(r["earns_slot"])
|
||||
h_sh = r["holdout"].get("sharpe", -99)
|
||||
f_sh = r["full"]["sharpe"]
|
||||
distinct = 1.0 - abs(r["corr_xs01"] or 1.0) # higher = more distinct
|
||||
verdict_score = {"ADDS": 3, "NEUTRAL": 2, "DILUTES": 1, "REDUNDANT": 0, "N/A": 0}.get(
|
||||
r["marginal"].get("verdict", "N/A"), 0)
|
||||
return (earns, verdict_score, h_sh, f_sh, distinct)
|
||||
|
||||
best = max(results, key=rank_key)
|
||||
print("\n" + "=" * 60)
|
||||
print("BEST CONFIG:")
|
||||
print(xs.fmt(best))
|
||||
print("JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,80 @@
|
||||
"""XD02 — High-skew momentum (POSITIVE sign).
|
||||
Mechanism: Score = +roll_skew(ret, 60).
|
||||
Idea: positive skew = right-tailed distribution = asset had big up-moves.
|
||||
Does positive skew predict cross-sectional outperformance in crypto alts?
|
||||
(XD01 tested negative skew; this tests the opposite hypothesis.)
|
||||
|
||||
Grid (<= 5 runs):
|
||||
1. majors, H=10, k=5, LS (baseline)
|
||||
2. all, H=10, k=5, LS (wider universe)
|
||||
3. majors, H=5, k=5, LS (faster rebalance)
|
||||
4. majors, H=10, k=5, LS, win=30 (shorter lookback)
|
||||
5. majors, H=10, k=3, LS (concentrated book)
|
||||
"""
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
# Score: positive rolling skewness of daily returns
|
||||
# Higher skew -> more right-tailed -> long this asset
|
||||
def score_skew(P, win=60):
|
||||
return xs.roll_skew(P.ret, win)
|
||||
|
||||
print("=" * 60)
|
||||
print("XD02 — HIGH-SKEW MOMENTUM (positive sign, does positive skew pay?)")
|
||||
print("=" * 60)
|
||||
|
||||
# Run 1: majors, H=10, k=5, LS, win=60
|
||||
r1 = xs.study_xs("XD02-MJ-H10-k5-w60", lambda P: score_skew(P, 60),
|
||||
universe="majors", H=10, k=5, long_short=True)
|
||||
print(xs.fmt(r1))
|
||||
print("JSON:", xs.as_json(r1))
|
||||
print()
|
||||
|
||||
# Run 2: all universe, H=10, k=5, LS, win=60
|
||||
r2 = xs.study_xs("XD02-ALL-H10-k5-w60", lambda P: score_skew(P, 60),
|
||||
universe="all", H=10, k=5, long_short=True)
|
||||
print(xs.fmt(r2))
|
||||
print("JSON:", xs.as_json(r2))
|
||||
print()
|
||||
|
||||
# Run 3: majors, H=5, k=5, LS, win=60 (faster rebalance)
|
||||
r3 = xs.study_xs("XD02-MJ-H5-k5-w60", lambda P: score_skew(P, 60),
|
||||
universe="majors", H=5, k=5, long_short=True)
|
||||
print(xs.fmt(r3))
|
||||
print("JSON:", xs.as_json(r3))
|
||||
print()
|
||||
|
||||
# Run 4: majors, H=10, k=5, LS, win=30 (shorter lookback)
|
||||
r4 = xs.study_xs("XD02-MJ-H10-k5-w30", lambda P: score_skew(P, 30),
|
||||
universe="majors", H=10, k=5, long_short=True)
|
||||
print(xs.fmt(r4))
|
||||
print("JSON:", xs.as_json(r4))
|
||||
print()
|
||||
|
||||
# Run 5: majors, H=10, k=3, LS, win=60 (concentrated)
|
||||
r5 = xs.study_xs("XD02-MJ-H10-k3-w60", lambda P: score_skew(P, 60),
|
||||
universe="majors", H=10, k=3, long_short=True)
|
||||
print(xs.fmt(r5))
|
||||
print("JSON:", xs.as_json(r5))
|
||||
print()
|
||||
|
||||
# Select best by: earns_slot > holdout sharpe > corr_xs01 (lower is better)
|
||||
results = [r1, r2, r3, r4, r5]
|
||||
earners = [r for r in results if r["earns_slot"]]
|
||||
if earners:
|
||||
best = max(earners, key=lambda r: r["holdout"].get("sharpe", 0))
|
||||
else:
|
||||
# fallback: highest holdout + positive full, then lowest xs01 corr
|
||||
pos = [r for r in results if r["full"]["sharpe"] > 0 and r["holdout"].get("sharpe", 0) > 0]
|
||||
if pos:
|
||||
best = max(pos, key=lambda r: r["holdout"].get("sharpe", 0) - abs(r.get("corr_xs01") or 0))
|
||||
else:
|
||||
best = max(results, key=lambda r: r["holdout"].get("sharpe", -99))
|
||||
|
||||
print("=" * 60)
|
||||
print("BEST CONFIG:")
|
||||
print(xs.fmt(best))
|
||||
print("JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,136 @@
|
||||
"""XD03 — Coskewness with Market
|
||||
|
||||
Mechanism: For each asset, compute rolling coskewness of asset returns
|
||||
with the equal-weight market return. Assets with LOW coskewness (they do
|
||||
not co-skew positively with the market) tend to earn a premium because
|
||||
investors disfavor assets with negative coskewness (they hurt in crashes
|
||||
when skewness matters most). Classic Harvey & Siddique (2000) anomaly.
|
||||
|
||||
Coskew(i, M) = E[(r_i - mu_i)(r_M - mu_M)^2] / (sigma_i * sigma_M^2)
|
||||
Causally computed. LOWER coskew = LONG signal.
|
||||
|
||||
Grid: 5 backtests varying (win, H, k, universe, long_short).
|
||||
"""
|
||||
|
||||
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 coskew_score(ret: np.ndarray, win: int = 60) -> np.ndarray:
|
||||
"""Rolling coskewness of each asset with the equal-weight market.
|
||||
|
||||
coskew(i, M) = E[(r_i - mu_i)(r_M - mu_M)^2] / (std_i * std_M^2)
|
||||
|
||||
Returns (n_days x n_assets). LOWER = should be LONG (earns premium).
|
||||
So for long-low, negate: score = -coskew
|
||||
"""
|
||||
n, A = ret.shape
|
||||
mkt = xs.market_ret(ret) # (n,)
|
||||
|
||||
out = np.full((n, A), np.nan)
|
||||
|
||||
# Use pandas rolling for causality
|
||||
mkt_s = pd.Series(mkt)
|
||||
|
||||
for a in range(A):
|
||||
asset_s = pd.Series(ret[:, a])
|
||||
|
||||
# Rolling window stats
|
||||
mu_a = asset_s.rolling(win, min_periods=max(10, win // 3)).mean()
|
||||
mu_m = mkt_s.rolling(win, min_periods=max(10, win // 3)).mean()
|
||||
std_a = asset_s.rolling(win, min_periods=max(10, win // 3)).std()
|
||||
std_m = mkt_s.rolling(win, min_periods=max(10, win // 3)).std()
|
||||
|
||||
# Centered series (element-wise)
|
||||
da = asset_s - mu_a
|
||||
dm = mkt_s - mu_m
|
||||
|
||||
# coskew numerator = mean(da * dm^2)
|
||||
coskew_num = (da * dm ** 2).rolling(win, min_periods=max(10, win // 3)).mean()
|
||||
|
||||
# Normalize by std_a * std_m^2
|
||||
denom = std_a * std_m ** 2
|
||||
denom = denom.replace(0, np.nan)
|
||||
|
||||
coskew = coskew_num / denom
|
||||
out[:, a] = coskew.values
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def score_fn_60(P):
|
||||
"""Long low-coskew: negate so that lower coskew = higher score."""
|
||||
return -coskew_score(P.ret, win=60)
|
||||
|
||||
|
||||
def score_fn_90(P):
|
||||
"""Longer lookback for coskewness."""
|
||||
return -coskew_score(P.ret, win=90)
|
||||
|
||||
|
||||
def score_fn_30(P):
|
||||
"""Shorter lookback — more reactive."""
|
||||
return -coskew_score(P.ret, win=30)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== XD03: Coskewness with Market ===\n")
|
||||
|
||||
results = []
|
||||
|
||||
# Run 1: baseline config (win=60, all, H=10, k=5, LS)
|
||||
print("Run 1/5: win=60, universe=all, H=10, k=5, long_short=True")
|
||||
r1 = xs.study_xs("XD03-w60-H10-k5-LS", score_fn_60,
|
||||
universe="all", H=10, k=5, long_short=True)
|
||||
print(xs.fmt(r1))
|
||||
print("JSON:", xs.as_json(r1))
|
||||
results.append(r1)
|
||||
|
||||
# Run 2: vary rebalance period (H=20, looser)
|
||||
print("\nRun 2/5: win=60, universe=all, H=20, k=5, long_short=True")
|
||||
r2 = xs.study_xs("XD03-w60-H20-k5-LS", score_fn_60,
|
||||
universe="all", H=20, k=5, long_short=True)
|
||||
print(xs.fmt(r2))
|
||||
print("JSON:", xs.as_json(r2))
|
||||
results.append(r2)
|
||||
|
||||
# Run 3: longer win=90 (more stable coskewness estimate)
|
||||
print("\nRun 3/5: win=90, universe=all, H=10, k=5, long_short=True")
|
||||
r3 = xs.study_xs("XD03-w90-H10-k5-LS", score_fn_90,
|
||||
universe="all", H=10, k=5, long_short=True)
|
||||
print(xs.fmt(r3))
|
||||
print("JSON:", xs.as_json(r3))
|
||||
results.append(r3)
|
||||
|
||||
# Run 4: majors only (19 assets, cleaner signal)
|
||||
print("\nRun 4/5: win=60, universe=majors, H=10, k=5, long_short=True")
|
||||
r4 = xs.study_xs("XD03-w60-H10-k5-LS-maj", score_fn_60,
|
||||
universe="majors", H=10, k=5, long_short=True)
|
||||
print(xs.fmt(r4))
|
||||
print("JSON:", xs.as_json(r4))
|
||||
results.append(r4)
|
||||
|
||||
# Run 5: long-only on majors (captures risk-premium differently)
|
||||
print("\nRun 5/5: win=60, universe=majors, H=10, k=5, long_only")
|
||||
r5 = xs.study_xs("XD03-w60-H10-k5-LO-maj", score_fn_60,
|
||||
universe="majors", H=10, k=5, long_short=False)
|
||||
print(xs.fmt(r5))
|
||||
print("JSON:", xs.as_json(r5))
|
||||
results.append(r5)
|
||||
|
||||
# Summary: pick best by (earns_slot, then hold-out sharpe, then full sharpe)
|
||||
def rank_key(r):
|
||||
es = 1 if r["earns_slot"] else 0
|
||||
hs = r["holdout"].get("sharpe", -99)
|
||||
fs = r["full"]["sharpe"]
|
||||
corr_ok = (r.get("corr_xs01") or 1.0) < 0.6
|
||||
return (es, int(corr_ok), hs, fs)
|
||||
|
||||
best = max(results, key=rank_key)
|
||||
|
||||
print("\n\n=== BEST CONFIG ===")
|
||||
print(xs.fmt(best))
|
||||
print("JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,114 @@
|
||||
"""XL01 — Amihud Illiquidity Premium (cross-sectional).
|
||||
|
||||
Score = rolling mean of |ret| / (close * volume) over W days (Amihud ratio).
|
||||
Higher score = more illiquid.
|
||||
|
||||
We test both signs:
|
||||
- Long illiquid (higher score = long): illiquidity premium hypothesis
|
||||
- Short illiquid (higher score = short): liquidity premium, more liquid = better
|
||||
|
||||
Grid (<=5 calls):
|
||||
1. LS W=30, all universe
|
||||
2. LS W=30, majors
|
||||
3. LS W=30, short sign (liquidity premium, flip sign)
|
||||
4. LS W=30, H=20 (slower rebal), all universe
|
||||
5. LS W=60, all universe
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
|
||||
def amihud_score(close, vol, ret, W=30):
|
||||
"""Amihud illiquidity ratio: mean(|ret| / (close * volume)) over W days.
|
||||
Higher = more illiquid.
|
||||
Values at bar i use data <= i (causal).
|
||||
"""
|
||||
# dollar volume = close * volume (notional traded)
|
||||
dollar_vol = close * vol # (n, A)
|
||||
# |return| / dollar_vol
|
||||
abs_ret = np.abs(ret) # (n, A)
|
||||
# avoid division by zero
|
||||
dv_safe = np.where(dollar_vol > 0, dollar_vol, np.nan)
|
||||
amihud_raw = abs_ret / dv_safe # (n, A)
|
||||
# rolling mean (causal)
|
||||
score = xs.roll_mean(amihud_raw, W)
|
||||
return score
|
||||
|
||||
|
||||
def score_illiquid(W=30):
|
||||
"""Long illiquid (high Amihud = illiquid -> buy)."""
|
||||
def fn(P):
|
||||
return amihud_score(P.close, P.vol, P.ret, W=W)
|
||||
return fn
|
||||
|
||||
|
||||
def score_liquid(W=30):
|
||||
"""Long liquid (flip sign: low Amihud = liquid -> buy)."""
|
||||
def fn(P):
|
||||
return -amihud_score(P.close, P.vol, P.ret, W=W)
|
||||
return fn
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("XL01 — Amihud Illiquidity Premium")
|
||||
print("="*60)
|
||||
|
||||
# 1. Baseline: long illiquid, W=30, all universe
|
||||
print("\n[1] Long ILLIQUID, W=30, universe=all, H=10, k=5, LS")
|
||||
r1 = xs.study_xs("XL01-ILL-30-all", score_illiquid(30),
|
||||
universe="all", H=10, k=5, long_short=True)
|
||||
print(xs.fmt(r1))
|
||||
print("JSON:", xs.as_json(r1))
|
||||
|
||||
# 2. Long illiquid, W=30, majors only
|
||||
print("\n[2] Long ILLIQUID, W=30, universe=majors, H=10, k=5, LS")
|
||||
r2 = xs.study_xs("XL01-ILL-30-maj", score_illiquid(30),
|
||||
universe="majors", H=10, k=5, long_short=True)
|
||||
print(xs.fmt(r2))
|
||||
print("JSON:", xs.as_json(r2))
|
||||
|
||||
# 3. Long LIQUID (flip sign), W=30, all universe
|
||||
print("\n[3] Long LIQUID (flip sign), W=30, universe=all, H=10, k=5, LS")
|
||||
r3 = xs.study_xs("XL01-LIQ-30-all", score_liquid(30),
|
||||
universe="all", H=10, k=5, long_short=True)
|
||||
print(xs.fmt(r3))
|
||||
print("JSON:", xs.as_json(r3))
|
||||
|
||||
# 4. Long illiquid, W=30, H=20 (slower rebal), all
|
||||
print("\n[4] Long ILLIQUID, W=30, universe=all, H=20, k=5, LS")
|
||||
r4 = xs.study_xs("XL01-ILL-30-H20", score_illiquid(30),
|
||||
universe="all", H=20, k=5, long_short=True)
|
||||
print(xs.fmt(r4))
|
||||
print("JSON:", xs.as_json(r4))
|
||||
|
||||
# 5. Long illiquid, W=60, all universe
|
||||
print("\n[5] Long ILLIQUID, W=60, universe=all, H=10, k=5, LS")
|
||||
r5 = xs.study_xs("XL01-ILL-60-all", score_illiquid(60),
|
||||
universe="all", H=10, k=5, long_short=True)
|
||||
print(xs.fmt(r5))
|
||||
print("JSON:", xs.as_json(r5))
|
||||
|
||||
# Summary
|
||||
results = [r1, r2, r3, r4, r5]
|
||||
print("\n" + "="*60)
|
||||
print("SUMMARY — pick best by: earns_slot > holdout > distinctness")
|
||||
for r in results:
|
||||
es = r["earns_slot"]
|
||||
fsh = r["full"]["sharpe"]
|
||||
hsh = r["holdout"].get("sharpe", 0)
|
||||
cxs = r["corr_xs01"]
|
||||
v = r["marginal"]["verdict"]
|
||||
print(f" {r['name']:30s} FULL={fsh:+.2f} HOLD={hsh:+.2f} corr_xs01={cxs} "
|
||||
f"verdict={v} earns_slot={es}")
|
||||
|
||||
# Pick best: prefer earns_slot, then hold sharpe
|
||||
best = max(results, key=lambda r: (
|
||||
r["earns_slot"],
|
||||
r["holdout"].get("sharpe", -99),
|
||||
r["full"]["sharpe"],
|
||||
))
|
||||
print(f"\nBEST CONFIG: {best['name']}")
|
||||
print(xs.fmt(best))
|
||||
print("BEST JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,118 @@
|
||||
"""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))
|
||||
@@ -0,0 +1,93 @@
|
||||
"""XL03 [LIQ] — Low-turnover anomaly.
|
||||
|
||||
Score = -roll_mean(close * volume, 30) : long low dollar-volume names.
|
||||
Idea: low-liquidity assets carry a liquidity premium and may outperform
|
||||
high-liquidity names on a risk-adjusted basis.
|
||||
|
||||
Grid (<=5 runs):
|
||||
1. baseline: universe=all, H=10, k=5, long_short=True, win=30
|
||||
2. shorter window win=10 (faster signal)
|
||||
3. longer window win=60 (more stable ranking)
|
||||
4. long-only version (long low-liq only, no shorting high-liq names)
|
||||
5. majors universe (check if effect holds in liquid-only subspace)
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
# --- score factory -----------------------------------------------------------
|
||||
|
||||
def liq_score(P, win=30):
|
||||
"""Score = -roll_mean(close * dollar_vol, win).
|
||||
CAUSAL: roll_mean at row i uses data[i-win+1..i].
|
||||
Higher score = LOWER liquidity = LONG.
|
||||
"""
|
||||
dollar_vol = P.close * P.vol # (n, A) daily dollar volume
|
||||
avg_dvol = xs.roll_mean(dollar_vol, win) # rolling mean, causal
|
||||
return -avg_dvol # negate: lower dvol -> higher score -> long
|
||||
|
||||
|
||||
# --- grid -------------------------------------------------------------------
|
||||
|
||||
print("=" * 70)
|
||||
print("XL03 [LIQ] Low-turnover anomaly — grid search")
|
||||
print("=" * 70)
|
||||
|
||||
results = []
|
||||
|
||||
# Run 1: baseline (all, H=10, k=5, LS, win=30)
|
||||
r1 = xs.study_xs("XL03-w30-all-LS",
|
||||
lambda P: liq_score(P, 30),
|
||||
universe="all", H=10, k=5, long_short=True)
|
||||
print(xs.fmt(r1))
|
||||
print("JSON:", xs.as_json(r1))
|
||||
results.append(r1)
|
||||
|
||||
# Run 2: shorter window win=10
|
||||
r2 = xs.study_xs("XL03-w10-all-LS",
|
||||
lambda P: liq_score(P, 10),
|
||||
universe="all", H=10, k=5, long_short=True)
|
||||
print(xs.fmt(r2))
|
||||
print("JSON:", xs.as_json(r2))
|
||||
results.append(r2)
|
||||
|
||||
# Run 3: longer window win=60
|
||||
r3 = xs.study_xs("XL03-w60-all-LS",
|
||||
lambda P: liq_score(P, 60),
|
||||
universe="all", H=10, k=5, long_short=True)
|
||||
print(xs.fmt(r3))
|
||||
print("JSON:", xs.as_json(r3))
|
||||
results.append(r3)
|
||||
|
||||
# Run 4: long-only (long low-liq, no short)
|
||||
r4 = xs.study_xs("XL03-w30-all-LO",
|
||||
lambda P: liq_score(P, 30),
|
||||
universe="all", H=10, k=5, long_short=False)
|
||||
print(xs.fmt(r4))
|
||||
print("JSON:", xs.as_json(r4))
|
||||
results.append(r4)
|
||||
|
||||
# Run 5: majors universe only
|
||||
r5 = xs.study_xs("XL03-w30-majors-LS",
|
||||
lambda P: liq_score(P, 30),
|
||||
universe="majors", H=10, k=5, long_short=True)
|
||||
print(xs.fmt(r5))
|
||||
print("JSON:", xs.as_json(r5))
|
||||
results.append(r5)
|
||||
|
||||
# --- pick best config -------------------------------------------------------
|
||||
print("\n" + "=" * 70)
|
||||
print("SUMMARY — best by: earns_slot first, then hold-out Sharpe, then corr_xs01 < 0.6")
|
||||
print("=" * 70)
|
||||
|
||||
def rank_key(r):
|
||||
earns = int(r["earns_slot"])
|
||||
hold_sh = r["holdout"].get("sharpe", -9) or -9
|
||||
xs01_corr = abs(r.get("corr_xs01") or 1.0)
|
||||
return (earns, hold_sh, -xs01_corr)
|
||||
|
||||
best = max(results, key=rank_key)
|
||||
print(f"\nBEST CONFIG: {best['name']}")
|
||||
print(xs.fmt(best))
|
||||
print("\nJSON (best):", xs.as_json(best))
|
||||
@@ -0,0 +1,109 @@
|
||||
"""XL04 [LIQ] — Dollar-volume momentum.
|
||||
|
||||
Score = past_return of dollar-volume (close * volume) over W=30 days.
|
||||
Idea: assets gaining LIQUIDITY / ATTENTION relative to peers will outperform.
|
||||
This is the OPPOSITE of XL03 (which went long LOW dollar-volume names).
|
||||
|
||||
Mechanism:
|
||||
dvol[i] = close[i] * vol[i] (daily dollar volume)
|
||||
score[i] = dvol[i] / dvol[i-W] - 1 (W-day return of dollar volume)
|
||||
-> long assets whose dollar volume is GROWING the fastest
|
||||
|
||||
Grid (<=5 runs):
|
||||
1. baseline: universe=all, H=10, k=5, long_short=True, W=30
|
||||
2. shorter window W=10 (faster attention signal)
|
||||
3. longer window W=60 (more stable)
|
||||
4. majors universe (19 XS01 assets — check distinctness from XS01)
|
||||
5. long-only version (long attention gainers, no shorting attention losers)
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
|
||||
# --- score factory -----------------------------------------------------------
|
||||
|
||||
def dvol_momentum_score(P, W=30):
|
||||
"""Score = W-day past return of dollar volume (close * volume).
|
||||
CAUSAL: dvol_return[i] uses dvol[i] / dvol[i-W] - 1.
|
||||
Higher score = dollar volume growing faster = LONG.
|
||||
"""
|
||||
dvol = P.close * P.vol # (n, A) daily dollar volume
|
||||
score = np.full_like(dvol, np.nan)
|
||||
# past_return style: score[i] = dvol[i] / dvol[i-W] - 1
|
||||
# guard: if dvol[i-W] == 0 -> NaN
|
||||
denom = dvol[:-W] # dvol[i-W]
|
||||
numer = dvol[W:] # dvol[i]
|
||||
with np.errstate(invalid="ignore", divide="ignore"):
|
||||
ratio = np.where(denom > 0, numer / denom - 1.0, np.nan)
|
||||
score[W:] = ratio
|
||||
return score
|
||||
|
||||
|
||||
# --- grid -------------------------------------------------------------------
|
||||
|
||||
print("=" * 70)
|
||||
print("XL04 [LIQ] Dollar-volume momentum — grid search")
|
||||
print("=" * 70)
|
||||
|
||||
results = []
|
||||
|
||||
# Run 1: baseline (all, H=10, k=5, LS, W=30)
|
||||
r1 = xs.study_xs("XL04-W30-all-LS",
|
||||
lambda P: dvol_momentum_score(P, 30),
|
||||
universe="all", H=10, k=5, long_short=True)
|
||||
print(xs.fmt(r1))
|
||||
print("JSON:", xs.as_json(r1))
|
||||
results.append(r1)
|
||||
|
||||
# Run 2: shorter window W=10 (faster attention surge)
|
||||
r2 = xs.study_xs("XL04-W10-all-LS",
|
||||
lambda P: dvol_momentum_score(P, 10),
|
||||
universe="all", H=10, k=5, long_short=True)
|
||||
print(xs.fmt(r2))
|
||||
print("JSON:", xs.as_json(r2))
|
||||
results.append(r2)
|
||||
|
||||
# Run 3: longer window W=60 (sustained attention)
|
||||
r3 = xs.study_xs("XL04-W60-all-LS",
|
||||
lambda P: dvol_momentum_score(P, 60),
|
||||
universe="all", H=10, k=5, long_short=True)
|
||||
print(xs.fmt(r3))
|
||||
print("JSON:", xs.as_json(r3))
|
||||
results.append(r3)
|
||||
|
||||
# Run 4: majors universe only (19 XS01 assets)
|
||||
r4 = xs.study_xs("XL04-W30-majors-LS",
|
||||
lambda P: dvol_momentum_score(P, 30),
|
||||
universe="majors", H=10, k=5, long_short=True)
|
||||
print(xs.fmt(r4))
|
||||
print("JSON:", xs.as_json(r4))
|
||||
results.append(r4)
|
||||
|
||||
# Run 5: long-only (attention gainers only, no shorting losers)
|
||||
r5 = xs.study_xs("XL04-W30-all-LO",
|
||||
lambda P: dvol_momentum_score(P, 30),
|
||||
universe="all", H=10, k=5, long_short=False)
|
||||
print(xs.fmt(r5))
|
||||
print("JSON:", xs.as_json(r5))
|
||||
results.append(r5)
|
||||
|
||||
# --- pick best config -------------------------------------------------------
|
||||
print("\n" + "=" * 70)
|
||||
print("SUMMARY — best by: earns_slot first, then hold-out Sharpe, then distinctness from XS01")
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
def rank_key(r):
|
||||
earns = int(r["earns_slot"])
|
||||
hold_sh = r["holdout"].get("sharpe", -9) or -9
|
||||
xs01_corr = abs(r.get("corr_xs01") or 1.0)
|
||||
full_sh = r["full"].get("sharpe", -9) or -9
|
||||
return (earns, hold_sh, full_sh, -xs01_corr)
|
||||
|
||||
|
||||
best = max(results, key=rank_key)
|
||||
print(f"\nBEST CONFIG: {best['name']}")
|
||||
print(xs.fmt(best))
|
||||
print("\nJSON (best):", xs.as_json(best))
|
||||
@@ -0,0 +1,82 @@
|
||||
"""XM01 — Single-L Momentum Sweep
|
||||
MECHANISM: Score = past_return(close, L). Long top-k / short bottom-k cross-sectionally.
|
||||
Grid: L in {20, 30, 60, 90, 120}; universe in {all, majors}; test long-short and long-only.
|
||||
Known prior: plain momentum on full 49-universe (XS01 uses 19 majors with L blend 30+90).
|
||||
Goal: confirm negative on full universe, find whether single-L differs from XS01 blend.
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
print("XM01 — Single-L Momentum Sweep")
|
||||
print("=" * 60)
|
||||
|
||||
# --- 5 targeted backtests ---
|
||||
|
||||
# 1) Full 49-universe, medium lookback L=60, LS — expected to be negative (known prior)
|
||||
rep1 = xs.study_xs(
|
||||
"XM01_ALL_L60",
|
||||
lambda P: xs.past_return(P.close, 60),
|
||||
universe="all", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep1))
|
||||
print("JSON:", xs.as_json(rep1))
|
||||
print()
|
||||
|
||||
# 2) Full universe, short lookback L=20 — does short-term momentum work?
|
||||
rep2 = xs.study_xs(
|
||||
"XM01_ALL_L20",
|
||||
lambda P: xs.past_return(P.close, 20),
|
||||
universe="all", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep2))
|
||||
print("JSON:", xs.as_json(rep2))
|
||||
print()
|
||||
|
||||
# 3) Full universe, long lookback L=120, LS — intermediate/long momentum
|
||||
rep3 = xs.study_xs(
|
||||
"XM01_ALL_L120",
|
||||
lambda P: xs.past_return(P.close, 120),
|
||||
universe="all", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep3))
|
||||
print("JSON:", xs.as_json(rep3))
|
||||
print()
|
||||
|
||||
# 4) Majors only (XS01 turf), single L=60 — compare single-L vs XS01 blend on same universe
|
||||
rep4 = xs.study_xs(
|
||||
"XM01_MAJORS_L60",
|
||||
lambda P: xs.past_return(P.close, 60),
|
||||
universe="majors", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep4))
|
||||
print("JSON:", xs.as_json(rep4))
|
||||
print()
|
||||
|
||||
# 5) Full universe, L=90, long-only top-k — momentum as selection filter (long-only)
|
||||
rep5 = xs.study_xs(
|
||||
"XM01_ALL_L90_LO",
|
||||
lambda P: xs.past_return(P.close, 90),
|
||||
universe="all", H=10, k=5, long_short=False
|
||||
)
|
||||
print(xs.fmt(rep5))
|
||||
print("JSON:", xs.as_json(rep5))
|
||||
print()
|
||||
|
||||
# Pick best: prefer earns_slot, then hold-out sharpe, then distinctness from XS01
|
||||
all_reps = [rep1, rep2, rep3, rep4, rep5]
|
||||
|
||||
def score_rep(r):
|
||||
earns = int(r["earns_slot"])
|
||||
hold_sh = r["holdout"].get("sharpe", -9)
|
||||
full_sh = r["full"]["sharpe"]
|
||||
corr_xs01 = r["corr_xs01"] or 1.0
|
||||
distinctness = 1 - abs(corr_xs01) # higher = more distinct
|
||||
return (earns, hold_sh, full_sh, distinctness)
|
||||
|
||||
best = max(all_reps, key=score_rep)
|
||||
print("=" * 60)
|
||||
print(f"BEST CONFIG: {best['name']}")
|
||||
print(xs.fmt(best))
|
||||
print("JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,88 @@
|
||||
"""XM02 — Multi-L z-blend momentum
|
||||
Score = mean of xs_zscore(past_return(close, L)) over a set of lookback windows L.
|
||||
Compare two window sets: {30,90} (XS01-like) vs {20,60,120} (extended).
|
||||
Grid: 5 study_xs calls total — vary universe / windows / H.
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
# ── score helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
def blend_mom(close, lookbacks):
|
||||
"""Mean of xs_zscore(past_return(close, L)) for each L in lookbacks."""
|
||||
scores = [xs.xs_zscore(xs.past_return(close, L)) for L in lookbacks]
|
||||
stacked = np.stack(scores, axis=2) # (n_days, n_assets, n_L)
|
||||
return np.nanmean(stacked, axis=2) # (n_days, n_assets)
|
||||
|
||||
|
||||
L_SHORT = [30, 90] # mirrors XS01 blend
|
||||
L_LONG = [20, 60, 120] # extended set
|
||||
L_WIDE = [20, 60, 90, 120] # even wider blend
|
||||
|
||||
# ── 5 backtests ───────────────────────────────────────────────────────────────
|
||||
|
||||
results = []
|
||||
|
||||
# 1. XS01-equivalent blend {30,90} on ALL universe — baseline reference
|
||||
rep1 = xs.study_xs(
|
||||
"XM02-3090-all",
|
||||
lambda P: blend_mom(P.close, [30, 90]),
|
||||
universe="all", H=10, k=5, long_short=True,
|
||||
)
|
||||
print(xs.fmt(rep1))
|
||||
results.append(rep1)
|
||||
|
||||
# 2. Extended blend {20,60,120} on ALL universe
|
||||
rep2 = xs.study_xs(
|
||||
"XM02-206012-all",
|
||||
lambda P: blend_mom(P.close, [20, 60, 120]),
|
||||
universe="all", H=10, k=5, long_short=True,
|
||||
)
|
||||
print(xs.fmt(rep2))
|
||||
results.append(rep2)
|
||||
|
||||
# 3. Extended blend {20,60,120} on MAJORS (19 alts — XS01 universe)
|
||||
rep3 = xs.study_xs(
|
||||
"XM02-206012-majors",
|
||||
lambda P: blend_mom(P.close, [20, 60, 120]),
|
||||
universe="majors", H=10, k=5, long_short=True,
|
||||
)
|
||||
print(xs.fmt(rep3))
|
||||
results.append(rep3)
|
||||
|
||||
# 4. Wide blend {20,60,90,120} on ALL, shorter rebalance H=5
|
||||
rep4 = xs.study_xs(
|
||||
"XM02-wide-H5-all",
|
||||
lambda P: blend_mom(P.close, [20, 60, 90, 120]),
|
||||
universe="all", H=5, k=5, long_short=True,
|
||||
)
|
||||
print(xs.fmt(rep4))
|
||||
results.append(rep4)
|
||||
|
||||
# 5. Wide blend on ALL, longer H=20 (less turnover)
|
||||
rep5 = xs.study_xs(
|
||||
"XM02-wide-H20-all",
|
||||
lambda P: blend_mom(P.close, [20, 60, 90, 120]),
|
||||
universe="all", H=20, k=5, long_short=True,
|
||||
)
|
||||
print(xs.fmt(rep5))
|
||||
results.append(rep5)
|
||||
|
||||
# ── pick BEST by: earns_slot > hold-out sharpe > distinctness ────────────────
|
||||
|
||||
def _score(r):
|
||||
earns = 1 if r["earns_slot"] else 0
|
||||
verdict = 1 if r["marginal"].get("verdict") == "ADDS" else 0
|
||||
hold_sh = r["holdout"].get("sharpe", -99)
|
||||
full_sh = r["full"]["sharpe"]
|
||||
dist = 1 if (r["corr_xs01"] or 1.0) < 0.6 else 0
|
||||
return (earns, verdict, hold_sh, full_sh, dist)
|
||||
|
||||
best = max(results, key=_score)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("BEST CONFIG:")
|
||||
print(xs.fmt(best))
|
||||
print("JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,98 @@
|
||||
"""XM03 — Vol-Scaled (Risk-Adjusted) Momentum
|
||||
MECHANISM: Score = past_return(close, L) / roll_std(ret, L)
|
||||
This is a Sharpe-like signal: normalises raw momentum by the volatility of that asset
|
||||
over the same window. Should favour assets that moved up *smoothly* (high Sharpe trend)
|
||||
over those that had large one-off jumps (noisy high return).
|
||||
Grid: L in {30, 60, 90}; universe in {all, majors}; long_short True/False.
|
||||
Goal: test if risk-adjusted scoring is DISTINCT from plain XS01 momentum and ADDS to the
|
||||
live TP01+XS01+VRP01 portfolio.
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
|
||||
def vol_adj_momentum(P, L: int) -> np.ndarray:
|
||||
"""Causal Sharpe-like score: past_return / roll_std(ret, L).
|
||||
Higher = long. Returns (n_days x n_assets).
|
||||
Avoid divide-by-zero by replacing 0-vol rows with NaN -> harness treats NaN as neutral.
|
||||
"""
|
||||
pr = xs.past_return(P.close, L) # causal past return over L days
|
||||
rv = xs.roll_std(P.ret, L) # causal rolling std of daily returns
|
||||
# Replace zeros/near-zeros with NaN to avoid Inf
|
||||
rv_safe = np.where(rv < 1e-8, np.nan, rv)
|
||||
score = pr / rv_safe
|
||||
return score
|
||||
|
||||
|
||||
print("XM03 — Vol-Scaled (Risk-Adjusted) Momentum")
|
||||
print("=" * 60)
|
||||
|
||||
# 1) All universe, L=30 (short horizon vol-adj)
|
||||
rep1 = xs.study_xs(
|
||||
"XM03_ALL_L30",
|
||||
lambda P: vol_adj_momentum(P, 30),
|
||||
universe="all", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep1))
|
||||
print("JSON:", xs.as_json(rep1))
|
||||
print()
|
||||
|
||||
# 2) All universe, L=60 (medium horizon)
|
||||
rep2 = xs.study_xs(
|
||||
"XM03_ALL_L60",
|
||||
lambda P: vol_adj_momentum(P, 60),
|
||||
universe="all", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep2))
|
||||
print("JSON:", xs.as_json(rep2))
|
||||
print()
|
||||
|
||||
# 3) All universe, L=90 (long horizon)
|
||||
rep3 = xs.study_xs(
|
||||
"XM03_ALL_L90",
|
||||
lambda P: vol_adj_momentum(P, 90),
|
||||
universe="all", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep3))
|
||||
print("JSON:", xs.as_json(rep3))
|
||||
print()
|
||||
|
||||
# 4) Majors only (same universe as XS01), L=60 — can vol-adj beat plain MOM on XS01 turf?
|
||||
rep4 = xs.study_xs(
|
||||
"XM03_MAJORS_L60",
|
||||
lambda P: vol_adj_momentum(P, 60),
|
||||
universe="majors", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep4))
|
||||
print("JSON:", xs.as_json(rep4))
|
||||
print()
|
||||
|
||||
# 5) All universe, L=60, long-only — does vol-adj work as selection filter?
|
||||
rep5 = xs.study_xs(
|
||||
"XM03_ALL_L60_LO",
|
||||
lambda P: vol_adj_momentum(P, 60),
|
||||
universe="all", H=10, k=5, long_short=False
|
||||
)
|
||||
print(xs.fmt(rep5))
|
||||
print("JSON:", xs.as_json(rep5))
|
||||
print()
|
||||
|
||||
|
||||
def score_rep(r):
|
||||
earns = int(r["earns_slot"])
|
||||
hold_sh = r["holdout"].get("sharpe", -9)
|
||||
full_sh = r["full"]["sharpe"]
|
||||
corr_xs01 = r.get("corr_xs01") or 1.0
|
||||
distinctness = 1 - abs(corr_xs01)
|
||||
return (earns, hold_sh, full_sh, distinctness)
|
||||
|
||||
|
||||
all_reps = [rep1, rep2, rep3, rep4, rep5]
|
||||
best = max(all_reps, key=score_rep)
|
||||
|
||||
print("=" * 60)
|
||||
print(f"BEST CONFIG: {best['name']}")
|
||||
print(xs.fmt(best))
|
||||
print("JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,72 @@
|
||||
"""XM04 — Residual / Idiosyncratic Momentum
|
||||
IDEA: Instead of raw past return, score = cumulative idiosyncratic (beta-removed) return
|
||||
over the last L days. Should be a cleaner momentum signal: strips out the common market
|
||||
component and scores assets on their STOCK-SPECIFIC performance.
|
||||
|
||||
Signal: for each day i and asset a, sum the daily residual_returns over [i-L+1 .. i].
|
||||
residual_ret[t,a] = ret[t,a] - beta_t_a * market_ret[t]
|
||||
score[i,a] = sum(residual_ret[i-L+1:i+1, a]) (causal: uses data <= i)
|
||||
|
||||
Grid (<=5 calls):
|
||||
1. XM04-L30-maj : majors universe, L=30, H=10, k=5, LS
|
||||
2. XM04-L60-maj : majors universe, L=60, H=10, k=5, LS
|
||||
3. XM04-L30-all : all universe, L=30, H=10, k=5, LS
|
||||
4. XM04-L30-maj-H5: majors, L=30, H=5, k=5, LS (faster rebal)
|
||||
5. XM04-L30-maj-LO: majors, L=30, H=10, k=5, long-only (LO)
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
|
||||
def resid_mom_score(P, L=30, beta_win=60):
|
||||
"""Cumulative residual return over the last L days.
|
||||
residual_ret[t,a] = ret[t,a] - beta(win)*market_ret[t]
|
||||
score[i,a] = rolling sum of residual_ret over window L.
|
||||
All causal: roll_beta uses data <=i, rolling sum uses [i-L+1..i].
|
||||
"""
|
||||
# daily idiosyncratic returns (n_days x n_assets), causal
|
||||
resid = xs.residual_return(P.ret, win=beta_win)
|
||||
# rolling sum over L days = cumulative idiosyncratic momentum
|
||||
score = xs.roll_mean(resid, L) * L # equiv to rolling sum (roll_mean * win)
|
||||
return score
|
||||
|
||||
|
||||
configs = [
|
||||
# name, universe, L, H, k, long_short
|
||||
("XM04-L30-maj", "majors", 30, 10, 5, True),
|
||||
("XM04-L60-maj", "majors", 60, 10, 5, True),
|
||||
("XM04-L30-all", "all", 30, 10, 5, True),
|
||||
("XM04-L30-maj-H5", "majors", 30, 5, 5, True),
|
||||
("XM04-L30-maj-LO", "majors", 30, 10, 5, False),
|
||||
]
|
||||
|
||||
results = []
|
||||
for name, univ, L, H, k, ls in configs:
|
||||
print(f"\nRunning {name} ...")
|
||||
rep = xs.study_xs(
|
||||
name,
|
||||
lambda P, _L=L: resid_mom_score(P, L=_L, beta_win=60),
|
||||
universe=univ,
|
||||
H=H,
|
||||
k=k,
|
||||
long_short=ls,
|
||||
)
|
||||
print(xs.fmt(rep))
|
||||
results.append(rep)
|
||||
|
||||
# Pick best: prefer earns_slot, then highest hold-out Sharpe, then most distinct from XS01
|
||||
def score_config(r):
|
||||
earns = r["earns_slot"]
|
||||
hold_sh = r["holdout"].get("sharpe", -99)
|
||||
full_sh = r["full"]["sharpe"]
|
||||
corr_xs = r["corr_xs01"] or 1.0
|
||||
# primary: earns_slot; secondary: holdout Sharpe; tiebreak: distinctness
|
||||
return (earns, hold_sh, full_sh, -abs(corr_xs))
|
||||
|
||||
best = max(results, key=score_config)
|
||||
print("\n" + "="*60)
|
||||
print("BEST CONFIG:")
|
||||
print(xs.fmt(best))
|
||||
print("JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,98 @@
|
||||
"""XM05 — Momentum Acceleration
|
||||
MECHANISM: Score = past_return(close, L_short) - past_return(close, L_long)
|
||||
i.e. is momentum ACCELERATING? The idea: assets that are outperforming
|
||||
recently vs. their longer-run momentum are gaining momentum -> rank them
|
||||
high. Assets that were strong long-term but are slowing down -> rank low.
|
||||
L_short=20, L_long=60 (canonical config).
|
||||
|
||||
Grid: vary universe (all/majors), H (5/10), and L_short param
|
||||
to find the best config within <=5 backtests.
|
||||
|
||||
Distinctness target: if score is correlated to raw momentum (XS01), it's just XS01.
|
||||
If acceleration captures something different (regime change, reversal of leaders), it
|
||||
could be distinct and add to portfolio.
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
print("XM05 — Momentum Acceleration (L_short - L_long)")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
def mom_accel(close, L_short, L_long):
|
||||
"""Score = short-term return minus long-term return (causal). Higher = accelerating."""
|
||||
r_short = xs.past_return(close, L_short)
|
||||
r_long = xs.past_return(close, L_long)
|
||||
return r_short - r_long
|
||||
|
||||
|
||||
# --- 5 targeted backtests ---
|
||||
|
||||
# 1) Canonical config: all universe, L_short=20, L_long=60, H=10, LS
|
||||
rep1 = xs.study_xs(
|
||||
"XM05_ALL_20_60",
|
||||
lambda P: mom_accel(P.close, 20, 60),
|
||||
universe="all", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep1))
|
||||
print("JSON:", xs.as_json(rep1))
|
||||
print()
|
||||
|
||||
# 2) Majors universe (19 XS01 assets), same canonical L_short=20, L_long=60
|
||||
rep2 = xs.study_xs(
|
||||
"XM05_MAJ_20_60",
|
||||
lambda P: mom_accel(P.close, 20, 60),
|
||||
universe="majors", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep2))
|
||||
print("JSON:", xs.as_json(rep2))
|
||||
print()
|
||||
|
||||
# 3) All universe, shorter window: L_short=10, L_long=30 (faster acceleration signal)
|
||||
rep3 = xs.study_xs(
|
||||
"XM05_ALL_10_30",
|
||||
lambda P: mom_accel(P.close, 10, 30),
|
||||
universe="all", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep3))
|
||||
print("JSON:", xs.as_json(rep3))
|
||||
print()
|
||||
|
||||
# 4) All universe, L_short=20, L_long=60, longer holding period H=20
|
||||
rep4 = xs.study_xs(
|
||||
"XM05_ALL_20_60_H20",
|
||||
lambda P: mom_accel(P.close, 20, 60),
|
||||
universe="all", H=20, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep4))
|
||||
print("JSON:", xs.as_json(rep4))
|
||||
print()
|
||||
|
||||
# 5) All universe, longer windows: L_short=30, L_long=90 (medium-term acceleration)
|
||||
rep5 = xs.study_xs(
|
||||
"XM05_ALL_30_90",
|
||||
lambda P: mom_accel(P.close, 30, 90),
|
||||
universe="all", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep5))
|
||||
print("JSON:", xs.as_json(rep5))
|
||||
print()
|
||||
|
||||
# Pick best: prefer earns_slot, then hold-out sharpe, then distinctness from XS01
|
||||
all_reps = [rep1, rep2, rep3, rep4, rep5]
|
||||
|
||||
def score_rep(r):
|
||||
earns = int(r["earns_slot"])
|
||||
hold_sh = r["holdout"].get("sharpe", -9)
|
||||
full_sh = r["full"]["sharpe"]
|
||||
corr_xs01 = r.get("corr_xs01") or 1.0
|
||||
distinctness = 1 - abs(corr_xs01) # higher = more distinct
|
||||
return (earns, hold_sh, full_sh, distinctness)
|
||||
|
||||
best = max(all_reps, key=score_rep)
|
||||
print("=" * 60)
|
||||
print(f"BEST CONFIG: {best['name']}")
|
||||
print(xs.fmt(best))
|
||||
print("JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,79 @@
|
||||
"""XM06 — 52-day-high proximity (closeness-to-recent-high momentum).
|
||||
|
||||
IDEA: Score = close / rolling_max(high, W) [closeness to recent high].
|
||||
Assets near their recent high are "in momentum"; rank them cross-sectionally.
|
||||
W in {60, 90}. Causal: rolling_max up through bar i only.
|
||||
|
||||
Grid: 5 calls max
|
||||
1. W=60, majors, H=10, k=5, L/S
|
||||
2. W=90, majors, H=10, k=5, L/S
|
||||
3. W=60, all, H=10, k=5, L/S (best-W on wider universe)
|
||||
4. W=60, all, H=5, k=5, L/S (faster rebalance)
|
||||
5. W=60, all, H=10, k=7, L/S (wider book)
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
|
||||
def score_proximity(P, W):
|
||||
"""Causal: close[i] / max(high[i-W+1 .. i]). Higher = closer to recent high = long."""
|
||||
n, A = P.close.shape
|
||||
out = np.full((n, A), np.nan)
|
||||
# rolling max of high, causal window [i-W+1 .. i]
|
||||
high_df = __import__("pandas").DataFrame(P.high)
|
||||
roll_max = high_df.rolling(W, min_periods=max(2, W // 2)).max().values
|
||||
# proximity ratio: close / recent_high (always in (0,1] if no gap-up above window)
|
||||
out = P.close / roll_max
|
||||
return out
|
||||
|
||||
|
||||
# ---- run grid ----
|
||||
|
||||
results = []
|
||||
|
||||
# 1. W=60, majors, H=10, k=5, L/S
|
||||
rep1 = xs.study_xs("XM06_W60_majors", lambda P: score_proximity(P, 60),
|
||||
universe="majors", H=10, k=5, long_short=True)
|
||||
print(xs.fmt(rep1))
|
||||
results.append(rep1)
|
||||
|
||||
# 2. W=90, majors, H=10, k=5, L/S
|
||||
rep2 = xs.study_xs("XM06_W90_majors", lambda P: score_proximity(P, 90),
|
||||
universe="majors", H=10, k=5, long_short=True)
|
||||
print(xs.fmt(rep2))
|
||||
results.append(rep2)
|
||||
|
||||
# 3. W=60, all, H=10, k=5, L/S
|
||||
rep3 = xs.study_xs("XM06_W60_all", lambda P: score_proximity(P, 60),
|
||||
universe="all", H=10, k=5, long_short=True)
|
||||
print(xs.fmt(rep3))
|
||||
results.append(rep3)
|
||||
|
||||
# 4. W=60, all, H=5, k=5, L/S (faster rebalance)
|
||||
rep4 = xs.study_xs("XM06_W60_all_H5", lambda P: score_proximity(P, 60),
|
||||
universe="all", H=5, k=5, long_short=True)
|
||||
print(xs.fmt(rep4))
|
||||
results.append(rep4)
|
||||
|
||||
# 5. W=60, all, H=10, k=7, L/S (wider book)
|
||||
rep5 = xs.study_xs("XM06_W60_all_k7", lambda P: score_proximity(P, 60),
|
||||
universe="all", H=10, k=7, long_short=True)
|
||||
print(xs.fmt(rep5))
|
||||
results.append(rep5)
|
||||
|
||||
# ---- pick best by: earns_slot > hold-out sharpe > distinctness ----
|
||||
def score_result(r):
|
||||
earns = 1 if r["earns_slot"] else 0
|
||||
adds = 1 if r["marginal"].get("verdict") == "ADDS" else 0
|
||||
hold_sh = r["holdout"].get("sharpe", -999)
|
||||
full_sh = r["full"]["sharpe"]
|
||||
corr_xs01 = abs(r.get("corr_xs01") or 1.0)
|
||||
distinct = 1 if corr_xs01 < 0.6 else 0
|
||||
return (earns, adds, hold_sh, full_sh, distinct)
|
||||
|
||||
best = max(results, key=score_result)
|
||||
print("\n=== BEST CONFIG ===")
|
||||
print(xs.fmt(best))
|
||||
print("JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,87 @@
|
||||
"""XM07 — Sharpe-rank momentum cross-sectional strategy.
|
||||
|
||||
Score = roll_mean(ret, L) / roll_std(ret, L) (realized Sharpe ratio over L days)
|
||||
Rank assets cross-sectionally each H days, long top-k / short bottom-k.
|
||||
Grid: L in {30, 60, 90}, then vary universe/H/k around the best L.
|
||||
<=5 study_xs calls total.
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
|
||||
def sharpe_score(P, L):
|
||||
"""Causal realized Sharpe = roll_mean(ret, L) / roll_std(ret, L).
|
||||
Uses daily returns (P.ret). Higher = stronger risk-adjusted momentum -> long.
|
||||
"""
|
||||
mu = xs.roll_mean(P.ret, L)
|
||||
sigma = xs.roll_std(P.ret, L)
|
||||
# avoid division by near-zero vol; set to NaN if sigma too small
|
||||
score = mu / np.where(sigma > 1e-8, sigma, np.nan)
|
||||
return score # (n_days x n_assets), higher = long
|
||||
|
||||
|
||||
# ---- Grid (5 calls) --------------------------------------------------------
|
||||
# Step 1: sweep L on "majors" universe with fixed H=10, k=5, long_short=True
|
||||
print("=" * 60)
|
||||
print("XM07 Sharpe-rank momentum — grid search")
|
||||
print("=" * 60)
|
||||
|
||||
results = {}
|
||||
|
||||
# Call 1: L=30, majors
|
||||
r1 = xs.study_xs("XM07_L30_majors", lambda P: sharpe_score(P, 30),
|
||||
universe="majors", H=10, k=5, long_short=True)
|
||||
print(xs.fmt(r1))
|
||||
results["L30_majors"] = r1
|
||||
|
||||
# Call 2: L=60, majors
|
||||
r2 = xs.study_xs("XM07_L60_majors", lambda P: sharpe_score(P, 60),
|
||||
universe="majors", H=10, k=5, long_short=True)
|
||||
print(xs.fmt(r2))
|
||||
results["L60_majors"] = r2
|
||||
|
||||
# Call 3: L=90, majors
|
||||
r3 = xs.study_xs("XM07_L90_majors", lambda P: sharpe_score(P, 90),
|
||||
universe="majors", H=10, k=5, long_short=True)
|
||||
print(xs.fmt(r3))
|
||||
results["L90_majors"] = r3
|
||||
|
||||
# Pick best L by hold-out Sharpe among the 3
|
||||
best_L_key = max(["L30_majors", "L60_majors", "L90_majors"],
|
||||
key=lambda k: results[k]["holdout"]["sharpe"])
|
||||
best_L = int(best_L_key.split("_")[0][1:]) # extract integer
|
||||
print(f"\nBest L = {best_L} (by hold-out Sharpe)")
|
||||
|
||||
# Call 4: best L on "all" universe (49 alts) to test breadth
|
||||
r4 = xs.study_xs(f"XM07_L{best_L}_all", lambda P: sharpe_score(P, best_L),
|
||||
universe="all", H=10, k=5, long_short=True)
|
||||
print(xs.fmt(r4))
|
||||
results[f"L{best_L}_all"] = r4
|
||||
|
||||
# Call 5: best L on majors, try H=20 (less frequent rebalance, lower fee drag)
|
||||
r5 = xs.study_xs(f"XM07_L{best_L}_H20", lambda P: sharpe_score(P, best_L),
|
||||
universe="majors", H=20, k=5, long_short=True)
|
||||
print(xs.fmt(r5))
|
||||
results[f"L{best_L}_H20"] = r5
|
||||
|
||||
# ---- Pick overall best config -----------------------------------------------
|
||||
print("\n" + "=" * 60)
|
||||
print("SUMMARY — picking best config")
|
||||
print("=" * 60)
|
||||
|
||||
def score_config(r):
|
||||
"""Prefer: earns_slot, then hold-out, then full Sharpe, then distinctness."""
|
||||
earns = int(r.get("earns_slot", False))
|
||||
ho = r["holdout"]["sharpe"]
|
||||
full = r["full"]["sharpe"]
|
||||
dist = 1.0 - abs(r.get("corr_xs01", 1.0)) # higher = more distinct
|
||||
return (earns, ho, full, dist)
|
||||
|
||||
best_key = max(results.keys(), key=lambda k: score_config(results[k]))
|
||||
best = results[best_key]
|
||||
|
||||
print(f"Best config: {best_key}")
|
||||
print(xs.fmt(best))
|
||||
print("JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,107 @@
|
||||
"""XM08 — Momentum Consistency (Frog-in-Pan)
|
||||
Score = past_return(close, L) * fraction_of_up_days(ret, L)
|
||||
|
||||
Smooth momentum beats jumpy. "Frog-in-pan" from Ang, Goetzmann, Schaefer (2012):
|
||||
consistent trends accumulating through many small daily gains dominate short sharp jumps.
|
||||
Score is higher (more long) when returns over L days are both large AND consistent.
|
||||
|
||||
Grid: L=60 fixed (canonical), vary universe / H / k / long_short (<=5 calls total).
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SCORE: causal frog-in-pan
|
||||
# ---------------------------------------------------------------------------
|
||||
def fip_score(P, L=60):
|
||||
"""
|
||||
score[i, a] = past_return(close[i], L) * frac_up_days(ret[i-L+1..i], L)
|
||||
Causal: only uses ret and close up to row i.
|
||||
"""
|
||||
close = P.close # (n, A)
|
||||
ret = P.ret # (n, A) simple daily returns
|
||||
|
||||
n, A = close.shape
|
||||
|
||||
# past return over L days (causal)
|
||||
pr = xs.past_return(close, L) # (n, A), nan for i < L
|
||||
|
||||
# fraction of positive days over rolling window L
|
||||
pos = (ret > 0).astype(float) # 1 if up day
|
||||
frac_up = xs.roll_mean(pos, L) # causal rolling mean -> (n, A)
|
||||
|
||||
score = pr * frac_up
|
||||
return score
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GRID (<=5 calls)
|
||||
# ---------------------------------------------------------------------------
|
||||
results = []
|
||||
|
||||
# 1. Base: majors, L=60, H=10, k=5, long_short
|
||||
rep1 = xs.study_xs(
|
||||
"XM08_majors_H10_k5_ls",
|
||||
lambda P: fip_score(P, 60),
|
||||
universe="majors", H=10, k=5, long_short=True, target_vol=0.20
|
||||
)
|
||||
print(xs.fmt(rep1))
|
||||
print("JSON:", xs.as_json(rep1))
|
||||
results.append(rep1)
|
||||
|
||||
# 2. All assets, L=60, H=10, k=5, long_short
|
||||
rep2 = xs.study_xs(
|
||||
"XM08_all_H10_k5_ls",
|
||||
lambda P: fip_score(P, 60),
|
||||
universe="all", H=10, k=5, long_short=True, target_vol=0.20
|
||||
)
|
||||
print(xs.fmt(rep2))
|
||||
print("JSON:", xs.as_json(rep2))
|
||||
results.append(rep2)
|
||||
|
||||
# 3. All assets, H=20, k=5, long_short (slower rebal)
|
||||
rep3 = xs.study_xs(
|
||||
"XM08_all_H20_k5_ls",
|
||||
lambda P: fip_score(P, 60),
|
||||
universe="all", H=20, k=5, long_short=True, target_vol=0.20
|
||||
)
|
||||
print(xs.fmt(rep3))
|
||||
print("JSON:", xs.as_json(rep3))
|
||||
results.append(rep3)
|
||||
|
||||
# 4. Majors, H=10, k=5, long-only
|
||||
rep4 = xs.study_xs(
|
||||
"XM08_majors_H10_k5_lo",
|
||||
lambda P: fip_score(P, 60),
|
||||
universe="majors", H=10, k=5, long_short=False, target_vol=0.20
|
||||
)
|
||||
print(xs.fmt(rep4))
|
||||
print("JSON:", xs.as_json(rep4))
|
||||
results.append(rep4)
|
||||
|
||||
# 5. All assets, H=10, k=7, long_short (wider top/bottom bucket)
|
||||
rep5 = xs.study_xs(
|
||||
"XM08_all_H10_k7_ls",
|
||||
lambda P: fip_score(P, 60),
|
||||
universe="all", H=10, k=7, long_short=True, target_vol=0.20
|
||||
)
|
||||
print(xs.fmt(rep5))
|
||||
print("JSON:", xs.as_json(rep5))
|
||||
results.append(rep5)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PICK BEST
|
||||
# ---------------------------------------------------------------------------
|
||||
def score_result(r):
|
||||
"""Prefer earns_slot, then hold-out sharpe, then distinctness."""
|
||||
earns = r.get("earns_slot", False)
|
||||
ho = r.get("holdout", {}).get("sharpe", -999)
|
||||
corr = abs(r.get("corr_xs01", 1.0))
|
||||
return (int(earns), ho, -corr)
|
||||
|
||||
best = max(results, key=score_result)
|
||||
print("\n=== BEST CONFIG ===")
|
||||
print(xs.fmt(best))
|
||||
print("JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,127 @@
|
||||
"""XM09 — Market-trend-gated momentum
|
||||
Score = XS momentum (past_return L=60) but ACTIVE only when the equal-weight
|
||||
market return trailing sum over L days is > 0; else 0 (flat).
|
||||
|
||||
Idea: plain cross-sectional momentum tends to fail during broad market downtrends
|
||||
(all alts fall together, 'market neutral' still bleeds). Gate it off when the market
|
||||
equal-weight trend is negative. Distinct from XS01 (plain XS mom) because it selectively
|
||||
silences the strategy in bear regimes, producing a different return pattern.
|
||||
|
||||
Grid (<=5 calls): vary universe / H / k.
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SCORE: market-trend-gated momentum
|
||||
# ---------------------------------------------------------------------------
|
||||
def xm09_score(P, L=60):
|
||||
"""
|
||||
score[i, a] = past_return(close, L)[i, a] * market_up[i]
|
||||
market_up[i] = 1 if trailing-L sum of equal-weight market daily returns > 0, else 0.
|
||||
Fully causal: uses close and ret up to row i only.
|
||||
"""
|
||||
close = P.close # (n, A)
|
||||
ret = P.ret # (n, A) simple daily returns
|
||||
|
||||
n, A = close.shape
|
||||
|
||||
# Base momentum score (causal)
|
||||
pr = xs.past_return(close, L) # (n, A), nan for i < L
|
||||
|
||||
# Equal-weight market return per day (causal, mean across assets ignoring NaN)
|
||||
mret = xs.market_ret(ret) # (n,) equal-weight market return
|
||||
|
||||
# Trailing L-day cumulative market return (causal rolling sum)
|
||||
# roll_mean(mat, win) works on 2D; use it on a column vector
|
||||
mret_2d = mret.reshape(-1, 1) # (n, 1)
|
||||
mkt_trail = xs.roll_mean(mret_2d, L) * L # approximate trailing sum via roll_mean * L
|
||||
# Actually compute exact rolling sum using cumsum trick (causal)
|
||||
mret_cumsum = np.cumsum(mret) # (n,)
|
||||
mkt_rolling_sum = np.empty(n)
|
||||
mkt_rolling_sum[:] = np.nan
|
||||
for i in range(L - 1, n):
|
||||
mkt_rolling_sum[i] = mret_cumsum[i] - (mret_cumsum[i - L] if i >= L else 0.0)
|
||||
|
||||
# Market uptrend gate: 1 when trailing sum > 0, else 0
|
||||
market_up = (mkt_rolling_sum > 0).astype(float) # (n,)
|
||||
market_up[:L - 1] = np.nan # not enough history
|
||||
|
||||
# Broadcast: score is 0 (flat) when market is down
|
||||
score = pr * market_up[:, None] # (n, A)
|
||||
|
||||
return score
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GRID (<=5 calls)
|
||||
# ---------------------------------------------------------------------------
|
||||
results = []
|
||||
|
||||
# 1. Base: majors, L=60, H=10, k=5, long_short
|
||||
rep1 = xs.study_xs(
|
||||
"XM09_majors_H10_k5_ls",
|
||||
lambda P: xm09_score(P, 60),
|
||||
universe="majors", H=10, k=5, long_short=True, target_vol=0.20
|
||||
)
|
||||
print(xs.fmt(rep1))
|
||||
print("JSON:", xs.as_json(rep1))
|
||||
results.append(rep1)
|
||||
|
||||
# 2. All assets, L=60, H=10, k=5, long_short
|
||||
rep2 = xs.study_xs(
|
||||
"XM09_all_H10_k5_ls",
|
||||
lambda P: xm09_score(P, 60),
|
||||
universe="all", H=10, k=5, long_short=True, target_vol=0.20
|
||||
)
|
||||
print(xs.fmt(rep2))
|
||||
print("JSON:", xs.as_json(rep2))
|
||||
results.append(rep2)
|
||||
|
||||
# 3. Majors, H=10, k=5, long-only (when market is up, just go long top-k)
|
||||
rep3 = xs.study_xs(
|
||||
"XM09_majors_H10_k5_lo",
|
||||
lambda P: xm09_score(P, 60),
|
||||
universe="majors", H=10, k=5, long_short=False, target_vol=0.20
|
||||
)
|
||||
print(xs.fmt(rep3))
|
||||
print("JSON:", xs.as_json(rep3))
|
||||
results.append(rep3)
|
||||
|
||||
# 4. All assets, H=20, k=5, long_short (slower rebalance)
|
||||
rep4 = xs.study_xs(
|
||||
"XM09_all_H20_k5_ls",
|
||||
lambda P: xm09_score(P, 60),
|
||||
universe="all", H=20, k=5, long_short=True, target_vol=0.20
|
||||
)
|
||||
print(xs.fmt(rep4))
|
||||
print("JSON:", xs.as_json(rep4))
|
||||
results.append(rep4)
|
||||
|
||||
# 5. Majors, H=10, k=7, long_short (wider buckets on smaller universe)
|
||||
rep5 = xs.study_xs(
|
||||
"XM09_majors_H10_k7_ls",
|
||||
lambda P: xm09_score(P, 60),
|
||||
universe="majors", H=10, k=7, long_short=True, target_vol=0.20
|
||||
)
|
||||
print(xs.fmt(rep5))
|
||||
print("JSON:", xs.as_json(rep5))
|
||||
results.append(rep5)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PICK BEST
|
||||
# ---------------------------------------------------------------------------
|
||||
def score_result(r):
|
||||
"""Prefer earns_slot, then hold-out sharpe, then distinctness from XS01."""
|
||||
earns = r.get("earns_slot", False)
|
||||
ho = r.get("holdout", {}).get("sharpe", -999)
|
||||
corr = abs(r.get("corr_xs01", 1.0))
|
||||
return (int(earns), ho, -corr)
|
||||
|
||||
best = max(results, key=score_result)
|
||||
print("\n=== BEST CONFIG ===")
|
||||
print(xs.fmt(best))
|
||||
print("JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,127 @@
|
||||
"""XM10 — Rank-weighted continuous momentum (demeaned xs_rank).
|
||||
|
||||
MECHANISM: Instead of top-k/bottom-k binary selection, weight ALL assets
|
||||
proportionally to their demeaned cross-sectional rank of past return.
|
||||
rank_i in [0,1] -> demeaned rank = rank_i - 0.5 -> scores in [-0.5, +0.5].
|
||||
L=60 (lookback ~2 months). Continuous book approximated via large k (A//2)
|
||||
and fine score (continuous rank, not discrete order).
|
||||
|
||||
The study_xs() engine still uses top-k/bottom-k for the actual rebalance,
|
||||
but by setting k=A//2 (half the universe) and using xs_rank as the score,
|
||||
the effective weight profile is nearly linear across the full distribution.
|
||||
"""
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Score: demeaned cross-sectional rank of 60-day past return
|
||||
# Higher score = longer weight. Causal: uses data up to and including bar i.
|
||||
# -----------------------------------------------------------------------
|
||||
L = 60 # lookback
|
||||
|
||||
|
||||
def score_rank_mom(P, L=60):
|
||||
"""Continuous rank-weighted momentum score.
|
||||
xs_rank -> [0,1]; demean -> [-0.5, +0.5] so it's symmetric long/short.
|
||||
"""
|
||||
pr = xs.past_return(P.close, L) # (n_days, n_assets)
|
||||
ranked = xs.xs_rank(pr) # [0,1] cross-sectionally per row
|
||||
return ranked - 0.5 # demeaned: positive = long
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Small grid: 5 studies
|
||||
# 1) majors, H=10, k=large (9 ~ A//2 of 19)
|
||||
# 2) all, H=10, k=large (24 ~ A//2 of 49)
|
||||
# 3) all, H=5, k=large (24) — faster rebalance
|
||||
# 4) all, H=10, k=large (24), L=30 — shorter lookback
|
||||
# 5) all, H=20, k=large (24) — slower rebalance
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
results = []
|
||||
|
||||
print("=== XM10 Rank-Weighted Continuous Momentum ===\n")
|
||||
|
||||
# 1) Majors universe, H=10, k=9 (A//2 of 19)
|
||||
r1 = xs.study_xs(
|
||||
"XM10-majors-H10-k9-L60",
|
||||
lambda P: score_rank_mom(P, L=60),
|
||||
universe="majors",
|
||||
H=10, k=9, long_short=True
|
||||
)
|
||||
print(xs.fmt(r1))
|
||||
print("JSON:", xs.as_json(r1))
|
||||
print()
|
||||
results.append(r1)
|
||||
|
||||
# 2) All (49 alts), H=10, k=24 (A//2)
|
||||
r2 = xs.study_xs(
|
||||
"XM10-all-H10-k24-L60",
|
||||
lambda P: score_rank_mom(P, L=60),
|
||||
universe="all",
|
||||
H=10, k=24, long_short=True
|
||||
)
|
||||
print(xs.fmt(r2))
|
||||
print("JSON:", xs.as_json(r2))
|
||||
print()
|
||||
results.append(r2)
|
||||
|
||||
# 3) All, H=5, k=24 — faster rebalance
|
||||
r3 = xs.study_xs(
|
||||
"XM10-all-H5-k24-L60",
|
||||
lambda P: score_rank_mom(P, L=60),
|
||||
universe="all",
|
||||
H=5, k=24, long_short=True
|
||||
)
|
||||
print(xs.fmt(r3))
|
||||
print("JSON:", xs.as_json(r3))
|
||||
print()
|
||||
results.append(r3)
|
||||
|
||||
# 4) All, H=10, k=24, L=30 shorter lookback
|
||||
r4 = xs.study_xs(
|
||||
"XM10-all-H10-k24-L30",
|
||||
lambda P: score_rank_mom(P, L=30),
|
||||
universe="all",
|
||||
H=10, k=24, long_short=True
|
||||
)
|
||||
print(xs.fmt(r4))
|
||||
print("JSON:", xs.as_json(r4))
|
||||
print()
|
||||
results.append(r4)
|
||||
|
||||
# 5) All, H=20, k=24, L=60 slower rebalance
|
||||
r5 = xs.study_xs(
|
||||
"XM10-all-H20-k24-L60",
|
||||
lambda P: score_rank_mom(P, L=60),
|
||||
universe="all",
|
||||
H=20, k=24, long_short=True
|
||||
)
|
||||
print(xs.fmt(r5))
|
||||
print("JSON:", xs.as_json(r5))
|
||||
print()
|
||||
results.append(r5)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Pick best by: earns_slot first, then hold-out sharpe, then distinctness
|
||||
# -----------------------------------------------------------------------
|
||||
def score_result(r):
|
||||
es = int(r.get("earns_slot", False))
|
||||
hold_sh = r["holdout"].get("sharpe", -99)
|
||||
corr_xs01 = abs(r.get("corr_xs01") or 1.0)
|
||||
distinctness = 1.0 - corr_xs01 # higher is more distinct
|
||||
# marginal verdict
|
||||
verdict = r["marginal"].get("verdict", "")
|
||||
verdict_score = {"ADDS": 3, "NEUTRAL": 1, "DILUTES": 0, "REDUNDANT": 0, "N/A": 0}.get(verdict, 0)
|
||||
return (es, verdict_score, hold_sh, distinctness)
|
||||
|
||||
results_sorted = sorted(results, key=score_result, reverse=True)
|
||||
best = results_sorted[0]
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("BEST CONFIG:")
|
||||
print(xs.fmt(best))
|
||||
print("JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,68 @@
|
||||
"""XR01 — Short-term Reversal on the Hyperliquid certified alt panel.
|
||||
|
||||
Score = -past_return(close, L) (long losers / short winners)
|
||||
Grid: L in {1, 3, 5, 7}
|
||||
Known prior: REV5 negative — confirm / diagnose.
|
||||
|
||||
We try <=5 study_xs calls:
|
||||
1. L=1 majors H=5 k=5 L/S
|
||||
2. L=3 majors H=5 k=5 L/S
|
||||
3. L=5 majors H=5 k=5 L/S (baseline to confirm negative prior)
|
||||
4. L=7 majors H=5 k=5 L/S
|
||||
5. best L (by hold-out) on universe="all" (same H/k)
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
UNIVERSE_BASE = "majors"
|
||||
H = 5
|
||||
K = 5
|
||||
|
||||
results = []
|
||||
|
||||
for L in [1, 3, 5, 7]:
|
||||
def score_fn(P, L=L):
|
||||
return -xs.past_return(P.close, L)
|
||||
|
||||
rep = xs.study_xs(
|
||||
f"XR01_L{L}_maj",
|
||||
score_fn,
|
||||
universe=UNIVERSE_BASE,
|
||||
H=H,
|
||||
k=K,
|
||||
long_short=True,
|
||||
target_vol=0.20,
|
||||
)
|
||||
print(xs.fmt(rep))
|
||||
print("JSON:", xs.as_json(rep))
|
||||
results.append((L, rep))
|
||||
|
||||
# Pick best L by hold-out Sharpe
|
||||
best_L, best_rep = max(results, key=lambda x: x[1]["holdout"]["sharpe"])
|
||||
print(f"\n=== Best L on majors: L={best_L} (hold-out Sharpe={best_rep['holdout']['sharpe']:.3f}) ===\n")
|
||||
|
||||
# Run the best L on "all" universe
|
||||
def score_best(P, L=best_L):
|
||||
return -xs.past_return(P.close, L)
|
||||
|
||||
rep_all = xs.study_xs(
|
||||
f"XR01_L{best_L}_all",
|
||||
score_best,
|
||||
universe="all",
|
||||
H=H,
|
||||
k=K,
|
||||
long_short=True,
|
||||
target_vol=0.20,
|
||||
)
|
||||
print(xs.fmt(rep_all))
|
||||
print("JSON:", xs.as_json(rep_all))
|
||||
|
||||
# Final summary: pick the overall best (by earns_slot, then hold-out)
|
||||
all_reps = [r for _, r in results] + [rep_all]
|
||||
all_reps_sorted = sorted(all_reps, key=lambda r: (r.get("earns_slot", False), r["holdout"]["sharpe"]), reverse=True)
|
||||
final = all_reps_sorted[0]
|
||||
print("\n=== FINAL BEST ===")
|
||||
print(xs.fmt(final))
|
||||
print("JSON:", xs.as_json(final))
|
||||
@@ -0,0 +1,171 @@
|
||||
"""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))
|
||||
@@ -0,0 +1,92 @@
|
||||
"""XR03 — Residual Short-Term Reversal
|
||||
Score = -(sum of residual_return over last L days)
|
||||
Idiosyncratic reversal: removes market beta before computing the short-term reversal signal.
|
||||
L in {3, 5}; beta window fixed at 60d.
|
||||
|
||||
Grid (<= 5 study_xs calls):
|
||||
1. L=3, majors, H=5, k=5, long_short=True
|
||||
2. L=5, majors, H=5, k=5, long_short=True
|
||||
3. L=3, all, H=5, k=5, long_short=True
|
||||
4. L=5, all, H=5, k=5, long_short=True
|
||||
5. Best-L from above, all, H=10, k=5, long_short=True
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
BETA_WIN = 60 # rolling beta window for residual computation
|
||||
|
||||
|
||||
def score_xr03(P, L):
|
||||
"""Causal residual reversal score.
|
||||
residual[i] = ret[i] - beta_rolling[i] * market_ret[i]
|
||||
score[i] = -sum(residual[i-L+1 .. i])
|
||||
HIGHER score = more negative recent idio returns = long (reversal)
|
||||
"""
|
||||
res = xs.residual_return(P.ret, BETA_WIN) # (n_days, n_assets)
|
||||
# rolling sum of last L residuals (causal: sum of rows [i-L+1..i])
|
||||
res_sum = xs.roll_mean(res, L) * L # roll_mean * L = roll_sum
|
||||
# reversal: negative of cumulative idio return
|
||||
score = -res_sum
|
||||
return score
|
||||
|
||||
|
||||
# --- Grid ---
|
||||
results = []
|
||||
|
||||
# 1. L=3, majors
|
||||
r1 = xs.study_xs("XR03_L3_maj", lambda P: score_xr03(P, 3),
|
||||
universe="majors", H=5, k=5, long_short=True)
|
||||
results.append(r1)
|
||||
print("=== XR03 L3 majors H5 ===")
|
||||
print(xs.fmt(r1))
|
||||
|
||||
# 2. L=5, majors
|
||||
r2 = xs.study_xs("XR03_L5_maj", lambda P: score_xr03(P, 5),
|
||||
universe="majors", H=5, k=5, long_short=True)
|
||||
results.append(r2)
|
||||
print("=== XR03 L5 majors H5 ===")
|
||||
print(xs.fmt(r2))
|
||||
|
||||
# 3. L=3, all
|
||||
r3 = xs.study_xs("XR03_L3_all", lambda P: score_xr03(P, 3),
|
||||
universe="all", H=5, k=5, long_short=True)
|
||||
results.append(r3)
|
||||
print("=== XR03 L3 all H5 ===")
|
||||
print(xs.fmt(r3))
|
||||
|
||||
# 4. L=5, all
|
||||
r4 = xs.study_xs("XR03_L5_all", lambda P: score_xr03(P, 5),
|
||||
universe="all", H=5, k=5, long_short=True)
|
||||
results.append(r4)
|
||||
print("=== XR03 L5 all H5 ===")
|
||||
print(xs.fmt(r4))
|
||||
|
||||
# 5. Best-L (by hold-out sharpe) with H=10
|
||||
# pick best L from runs 1-4
|
||||
best_so_far = max(results, key=lambda r: r["holdout"]["sharpe"])
|
||||
best_L = 3 if "L3" in best_so_far["name"] else 5
|
||||
best_univ = "all" if "all" in best_so_far["name"] else "majors"
|
||||
|
||||
r5 = xs.study_xs(f"XR03_L{best_L}_{best_univ}_H10",
|
||||
lambda P: score_xr03(P, best_L),
|
||||
universe=best_univ, H=10, k=5, long_short=True)
|
||||
results.append(r5)
|
||||
print(f"=== XR03 L{best_L} {best_univ} H10 ===")
|
||||
print(xs.fmt(r5))
|
||||
|
||||
# --- Pick best overall by marginal robustness then hold-out ---
|
||||
def score_key(r):
|
||||
earns = r.get("earns_slot", False)
|
||||
oos = r.get("marginal", {}).get("robust_oos", False)
|
||||
verdict = r.get("marginal", {}).get("verdict", "")
|
||||
adds = verdict == "ADDS"
|
||||
hold = r["holdout"]["sharpe"]
|
||||
full = r["full"]["sharpe"]
|
||||
return (int(earns), int(adds), int(oos), hold, full)
|
||||
|
||||
best = max(results, key=score_key)
|
||||
print("\n========== BEST CONFIG ==========")
|
||||
print(xs.fmt(best))
|
||||
print("JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,101 @@
|
||||
"""XR04 — Volume-shock reversal.
|
||||
|
||||
IDEA: Long recent losers that ALSO had a volume spike.
|
||||
score = -past_return(L) * (volume_z > 1)
|
||||
|
||||
The intuition: large volume + price drop signals capitulation/panic selling.
|
||||
The oversold name with elevated volume is more likely to bounce vs a name
|
||||
that drifted down quietly. L=3 is the suggested lookback.
|
||||
|
||||
Grid (<=5 calls):
|
||||
1. majors H=5 k=3 LS=True L=3 (baseline config)
|
||||
2. majors H=5 k=5 LS=True L=3 (more positions)
|
||||
3. all H=5 k=5 LS=True L=3 (wider universe)
|
||||
4. majors H=3 k=3 LS=True L=5 (slightly longer reversal)
|
||||
5. majors H=5 k=3 LS=True L=3 with volume_z threshold = 0.5 (lower bar)
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
|
||||
def vol_shock_reversal_score(P, L=3, vz_thresh=1.0):
|
||||
"""score = -past_return(L) when volume_z > vz_thresh, else 0.
|
||||
Higher score = more reversal candidate with volume spike.
|
||||
Causally computed: all data at row i uses data <=i."""
|
||||
ret_L = xs.past_return(P.close, L) # (n, A)
|
||||
vz = xs.volume_z(P.vol, 20) # rolling 20d volume z-score
|
||||
# Only count the reversal signal when volume is elevated
|
||||
mask = vz > vz_thresh # bool (n, A)
|
||||
score = np.where(mask, -ret_L, 0.0)
|
||||
# Where no data (NaN), set to NaN so harness skips
|
||||
score = np.where(np.isfinite(ret_L) & np.isfinite(vz), score, np.nan)
|
||||
return score
|
||||
|
||||
|
||||
results = []
|
||||
|
||||
# 1. Baseline: majors, H=5, k=3, L=3, vz_thresh=1.0
|
||||
r1 = xs.study_xs(
|
||||
"XR04-majors-H5k3-L3",
|
||||
lambda P: vol_shock_reversal_score(P, L=3, vz_thresh=1.0),
|
||||
universe="majors", H=5, k=3, long_short=True
|
||||
)
|
||||
print(xs.fmt(r1))
|
||||
print("JSON:", xs.as_json(r1))
|
||||
results.append(r1)
|
||||
|
||||
# 2. More positions: majors, H=5, k=5, L=3
|
||||
r2 = xs.study_xs(
|
||||
"XR04-majors-H5k5-L3",
|
||||
lambda P: vol_shock_reversal_score(P, L=3, vz_thresh=1.0),
|
||||
universe="majors", H=5, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(r2))
|
||||
print("JSON:", xs.as_json(r2))
|
||||
results.append(r2)
|
||||
|
||||
# 3. Wider universe: all, H=5, k=5, L=3
|
||||
r3 = xs.study_xs(
|
||||
"XR04-all-H5k5-L3",
|
||||
lambda P: vol_shock_reversal_score(P, L=3, vz_thresh=1.0),
|
||||
universe="all", H=5, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(r3))
|
||||
print("JSON:", xs.as_json(r3))
|
||||
results.append(r3)
|
||||
|
||||
# 4. Slightly longer reversal window: majors, H=3, k=3, L=5
|
||||
r4 = xs.study_xs(
|
||||
"XR04-majors-H3k3-L5",
|
||||
lambda P: vol_shock_reversal_score(P, L=5, vz_thresh=1.0),
|
||||
universe="majors", H=3, k=3, long_short=True
|
||||
)
|
||||
print(xs.fmt(r4))
|
||||
print("JSON:", xs.as_json(r4))
|
||||
results.append(r4)
|
||||
|
||||
# 5. Lower volume threshold: majors, H=5, k=3, L=3, vz_thresh=0.5
|
||||
r5 = xs.study_xs(
|
||||
"XR04-majors-H5k3-L3-vz05",
|
||||
lambda P: vol_shock_reversal_score(P, L=3, vz_thresh=0.5),
|
||||
universe="majors", H=5, k=3, long_short=True
|
||||
)
|
||||
print(xs.fmt(r5))
|
||||
print("JSON:", xs.as_json(r5))
|
||||
results.append(r5)
|
||||
|
||||
# Pick best by: earns_slot first, then hold-out sharpe, then distinctness
|
||||
def rank_key(r):
|
||||
earns = int(r.get("earns_slot", False))
|
||||
h_sh = r["holdout"].get("sharpe", -99)
|
||||
f_sh = r["full"].get("sharpe", -99)
|
||||
corr_xs01 = r.get("corr_xs01") or 1.0
|
||||
distinct = 1 if corr_xs01 < 0.6 else 0
|
||||
return (earns, h_sh, f_sh, distinct)
|
||||
|
||||
best = max(results, key=rank_key)
|
||||
print("\n=== BEST CONFIG ===")
|
||||
print(xs.fmt(best))
|
||||
print("JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,78 @@
|
||||
"""XR05 — Overreaction Reversal (mid-horizon)
|
||||
IDEA: Score = -past_return(close, L) for L in {20, 30}.
|
||||
Assets that ran up the most over the past 20-30 days are SHORTED (expected to mean-revert);
|
||||
assets that dropped the most are LONGED. Pure cross-sectional contrarian on multi-week moves.
|
||||
|
||||
Grid (<= 5 calls):
|
||||
1. L=20, H=10, k=5, LS, universe=majors
|
||||
2. L=30, H=10, k=5, LS, universe=majors
|
||||
3. L=20, H=5, k=5, LS, universe=majors (faster rebal)
|
||||
4. blend -PR20 and -PR30 (mean z-score), H=10, k=5, LS, universe=majors
|
||||
5. blend -PR20 and -PR30, H=10, k=5, LS, universe=all (broader universe)
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Score helpers (causal: close[i] only uses data up to bar i)
|
||||
# ------------------------------------------------------------------
|
||||
def score_rev20(P):
|
||||
return -xs.past_return(P.close, 20)
|
||||
|
||||
def score_rev30(P):
|
||||
return -xs.past_return(P.close, 30)
|
||||
|
||||
def score_rev20_fast(P):
|
||||
return -xs.past_return(P.close, 20)
|
||||
|
||||
def score_blend_majors(P):
|
||||
z20 = xs.xs_zscore(-xs.past_return(P.close, 20))
|
||||
z30 = xs.xs_zscore(-xs.past_return(P.close, 30))
|
||||
return (z20 + z30) / 2.0
|
||||
|
||||
def score_blend_all(P):
|
||||
z20 = xs.xs_zscore(-xs.past_return(P.close, 20))
|
||||
z30 = xs.xs_zscore(-xs.past_return(P.close, 30))
|
||||
return (z20 + z30) / 2.0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Grid
|
||||
# ------------------------------------------------------------------
|
||||
configs = [
|
||||
dict(name="XR05-REV20-H10-k5-majors", fn=score_rev20, universe="majors", H=10, k=5, long_short=True),
|
||||
dict(name="XR05-REV30-H10-k5-majors", fn=score_rev30, universe="majors", H=10, k=5, long_short=True),
|
||||
dict(name="XR05-REV20-H5-k5-majors", fn=score_rev20_fast, universe="majors", H=5, k=5, long_short=True),
|
||||
dict(name="XR05-BLENDz-H10-k5-majors", fn=score_blend_majors, universe="majors", H=10, k=5, long_short=True),
|
||||
dict(name="XR05-BLENDz-H10-k5-all", fn=score_blend_all, universe="all", H=10, k=5, long_short=True),
|
||||
]
|
||||
|
||||
results = []
|
||||
for c in configs:
|
||||
print(f"\nRunning {c['name']} ...")
|
||||
rep = xs.study_xs(
|
||||
c["name"],
|
||||
c["fn"],
|
||||
universe=c["universe"],
|
||||
H=c["H"],
|
||||
k=c["k"],
|
||||
long_short=c["long_short"],
|
||||
)
|
||||
print(xs.fmt(rep))
|
||||
results.append(rep)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Pick best config: earns_slot first, then hold-out sharpe, then distinctness
|
||||
# ------------------------------------------------------------------
|
||||
def _sort_key(r):
|
||||
earns = int(r["earns_slot"])
|
||||
hold_sh = r["holdout"].get("sharpe", -99)
|
||||
corr_xs01 = abs(r["corr_xs01"] or 1.0)
|
||||
return (earns, hold_sh, -corr_xs01)
|
||||
|
||||
best = max(results, key=_sort_key)
|
||||
print("\n" + "="*60)
|
||||
print("BEST CONFIG:")
|
||||
print(xs.fmt(best))
|
||||
print("JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,58 @@
|
||||
"""XS01b — Double-sort Momentum × Low-Vol
|
||||
Score = xs_zscore(past_return(close, 60)) + xs_zscore(-roll_std(ret, 30))
|
||||
Combines cross-sectional momentum with low-vol preference (lower realized vol = higher score).
|
||||
Grid: universe x H x k variations, <=5 total backtests.
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
# --- score factory ---
|
||||
def score_mom_lowvol(mom_L=60, vol_win=30):
|
||||
"""Double-sort: momentum z + low-vol z. Both causal (data <= close[i])."""
|
||||
def _score(P):
|
||||
mom = xs.xs_zscore(xs.past_return(P.close, mom_L))
|
||||
# low vol = higher score -> negate std
|
||||
lowvol = xs.xs_zscore(-xs.roll_std(P.ret, vol_win))
|
||||
return mom + lowvol
|
||||
return _score
|
||||
|
||||
|
||||
# Grid (<=5 calls total):
|
||||
# 1. Baseline: majors H10 k5 LS (19 assets, closest to XS01 universe)
|
||||
# 2. All universe H10 k5 LS
|
||||
# 3. All universe H5 k5 LS (faster rebalance)
|
||||
# 4. Majors H10 k5 LS with longer mom window (90d) to differ from XS01
|
||||
# 5. All universe H10 k7 LS (wider book)
|
||||
|
||||
configs = [
|
||||
dict(name="XS01b-MAJ-H10-k5", universe="majors", H=10, k=5, long_short=True, fn=score_mom_lowvol(60,30)),
|
||||
dict(name="XS01b-ALL-H10-k5", universe="all", H=10, k=5, long_short=True, fn=score_mom_lowvol(60,30)),
|
||||
dict(name="XS01b-ALL-H5-k5", universe="all", H=5, k=5, long_short=True, fn=score_mom_lowvol(60,30)),
|
||||
dict(name="XS01b-MAJ-H10-MOM90", universe="majors", H=10, k=5, long_short=True, fn=score_mom_lowvol(90,30)),
|
||||
dict(name="XS01b-ALL-H10-k7", universe="all", H=10, k=7, long_short=True, fn=score_mom_lowvol(60,30)),
|
||||
]
|
||||
|
||||
results = []
|
||||
for cfg in configs:
|
||||
print(f"\nRunning {cfg['name']} ...")
|
||||
fn = cfg.pop("fn")
|
||||
rep = xs.study_xs(score_fn=fn, **cfg)
|
||||
results.append(rep)
|
||||
print(xs.fmt(rep))
|
||||
print()
|
||||
|
||||
# --- pick best: prefer earns_slot, then hold-out sharpe, then corr_xs01 < 0.6
|
||||
def score_result(r):
|
||||
earns = 1 if r["earns_slot"] else 0
|
||||
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, hold_sh, full_sh, distinct)
|
||||
|
||||
best = max(results, key=score_result)
|
||||
print("\n" + "="*60)
|
||||
print("BEST CONFIG:")
|
||||
print(xs.fmt(best))
|
||||
print("JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,88 @@
|
||||
"""XS02b — Long-mom + short-rev multi-horizon
|
||||
Score = xs_zscore(past_return(close, 90)) + xs_zscore(-past_return(close, 5))
|
||||
|
||||
Long-term winners (90d) that have recently dipped (5d reversal).
|
||||
This is structurally distinct from plain XS01 momentum because it FADES the very-recent move
|
||||
while keeping the intermediate-term trend, blending momentum with mean-reversion.
|
||||
|
||||
Grid: universe x H x k (<=5 study_xs calls).
|
||||
"""
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
|
||||
def score_xs02b(P):
|
||||
"""Score = xs_zscore(90d mom) + xs_zscore(-5d return).
|
||||
Higher = long: intermediate-term winner AND short-term dipper.
|
||||
Fully causal: past_return(close, L) at row i uses close[i-L..i].
|
||||
"""
|
||||
mom_long = xs.xs_zscore(xs.past_return(P.close, 90)) # 90d momentum
|
||||
rev_short = xs.xs_zscore(-xs.past_return(P.close, 5)) # 5d reversal (negate: dip = good)
|
||||
return mom_long + rev_short
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
results = []
|
||||
|
||||
# Run 1: majors, H=10, k=5, L/S — canonical XS01-like setup but new signal
|
||||
r1 = xs.study_xs("XS02b_maj_H10_k5_LS", score_xs02b,
|
||||
universe="majors", H=10, k=5, long_short=True, target_vol=0.20)
|
||||
print(xs.fmt(r1))
|
||||
print("JSON:", xs.as_json(r1))
|
||||
results.append(r1)
|
||||
|
||||
# Run 2: all (49 alts), H=10, k=5, L/S — broader universe
|
||||
r2 = xs.study_xs("XS02b_all_H10_k5_LS", score_xs02b,
|
||||
universe="all", H=10, k=5, long_short=True, target_vol=0.20)
|
||||
print(xs.fmt(r2))
|
||||
print("JSON:", xs.as_json(r2))
|
||||
results.append(r2)
|
||||
|
||||
# Run 3: majors, H=5, k=5, L/S — faster rebalance
|
||||
r3 = xs.study_xs("XS02b_maj_H5_k5_LS", score_xs02b,
|
||||
universe="majors", H=5, k=5, long_short=True, target_vol=0.20)
|
||||
print(xs.fmt(r3))
|
||||
print("JSON:", xs.as_json(r3))
|
||||
results.append(r3)
|
||||
|
||||
# Run 4: all, H=5, k=7, L/S — broader universe, faster, wider basket
|
||||
r4 = xs.study_xs("XS02b_all_H5_k7_LS", score_xs02b,
|
||||
universe="all", H=5, k=7, long_short=True, target_vol=0.20)
|
||||
print(xs.fmt(r4))
|
||||
print("JSON:", xs.as_json(r4))
|
||||
results.append(r4)
|
||||
|
||||
# Run 5: majors, H=10, k=5, long-only — for comparison
|
||||
r5 = xs.study_xs("XS02b_maj_H10_k5_LO", score_xs02b,
|
||||
universe="majors", H=10, k=5, long_short=False, target_vol=0.20)
|
||||
print(xs.fmt(r5))
|
||||
print("JSON:", xs.as_json(r5))
|
||||
results.append(r5)
|
||||
|
||||
# ---- Summary ----
|
||||
print("\n\n=== XS02b GRID SUMMARY ===")
|
||||
for r in results:
|
||||
f = r["full"]
|
||||
h = r["holdout"]
|
||||
m = r.get("marginal", {})
|
||||
print(f" {r['name']:35s} FULL Sh={f['sharpe']:+.2f} DD={f['maxdd']:.1%}"
|
||||
f" HOLD Sh={h['sharpe']:+.2f}"
|
||||
f" corr_xs01={r.get('corr_xs01',float('nan')):+.2f}"
|
||||
f" verdict={m.get('verdict','?')}"
|
||||
f" earns_slot={r.get('earns_slot','?')}")
|
||||
|
||||
# Pick best by: earns_slot > hold-out > corr distinctness
|
||||
def sort_key(r):
|
||||
es = 1 if r.get("earns_slot") else 0
|
||||
mv = 1 if r.get("marginal", {}).get("verdict") == "ADDS" else 0
|
||||
ho = r["holdout"]["sharpe"]
|
||||
cxs = abs(r.get("corr_xs01", 1.0))
|
||||
return (es, mv, ho, -cxs)
|
||||
|
||||
best = max(results, key=sort_key)
|
||||
print(f"\nBEST CONFIG: {best['name']}")
|
||||
print(xs.fmt(best))
|
||||
print("JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,126 @@
|
||||
"""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))
|
||||
@@ -0,0 +1,83 @@
|
||||
"""XS04b — Ensemble z-vote cross-sectional strategy.
|
||||
|
||||
Score = mean of xs_zscore over {momentum90, -vol30, -skew60, -beta60}.
|
||||
Each component is z-scored cross-sectionally per row, then averaged.
|
||||
Diversified signal: momentum (strong assets), low vol (stable), negative skew
|
||||
(avoid lottery stocks), low beta (idiosyncratic leaders).
|
||||
|
||||
Grid: universe x H x k — 5 calls max.
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
|
||||
def score_matrix(P: xs.Panel) -> np.ndarray:
|
||||
"""Ensemble z-vote: mean of four xs_zscored components (causal)."""
|
||||
# 1. Momentum 90d: higher = stronger recent trend
|
||||
mom90 = xs.past_return(P.close, 90)
|
||||
z_mom = xs.xs_zscore(mom90)
|
||||
|
||||
# 2. Negative vol 30d: lower vol = more stable = prefer
|
||||
vol30 = xs.roll_std(P.ret, 30)
|
||||
z_vol = xs.xs_zscore(-vol30) # negative: lower vol -> higher score
|
||||
|
||||
# 3. Negative skew 60d: negative skew = avoid lottery/pump; prefer normal/negative-skew
|
||||
skew60 = xs.roll_skew(P.ret, 60)
|
||||
z_skew = xs.xs_zscore(-skew60) # negative: lower skew -> higher score
|
||||
|
||||
# 4. Negative beta 60d: low-beta assets have idiosyncratic edge in cross-section
|
||||
beta60 = xs.roll_beta(P.ret, 60)
|
||||
z_beta = xs.xs_zscore(-beta60) # negative: lower beta -> higher score
|
||||
|
||||
# Ensemble: simple mean across components (NaN-safe per cell)
|
||||
stack = np.stack([z_mom, z_vol, z_skew, z_beta], axis=0)
|
||||
score = np.nanmean(stack, axis=0)
|
||||
return score
|
||||
|
||||
|
||||
# ── Grid (5 calls max) ──────────────────────────────────────────────────────
|
||||
results = []
|
||||
|
||||
# 1. Majors, H=10, k=5, L/S
|
||||
r = xs.study_xs("XS04b_maj_H10_k5_ls", score_matrix, universe="majors",
|
||||
H=10, k=5, long_short=True)
|
||||
print(xs.fmt(r)); print("JSON:", xs.as_json(r))
|
||||
results.append(r)
|
||||
|
||||
# 2. Majors, H=5, k=5, L/S (faster rebalance)
|
||||
r = xs.study_xs("XS04b_maj_H5_k5_ls", score_matrix, universe="majors",
|
||||
H=5, k=5, long_short=True)
|
||||
print(xs.fmt(r)); print("JSON:", xs.as_json(r))
|
||||
results.append(r)
|
||||
|
||||
# 3. All, H=10, k=5, L/S
|
||||
r = xs.study_xs("XS04b_all_H10_k5_ls", score_matrix, universe="all",
|
||||
H=10, k=5, long_short=True)
|
||||
print(xs.fmt(r)); print("JSON:", xs.as_json(r))
|
||||
results.append(r)
|
||||
|
||||
# 4. All, H=10, k=7, L/S (wider book)
|
||||
r = xs.study_xs("XS04b_all_H10_k7_ls", score_matrix, universe="all",
|
||||
H=10, k=7, long_short=True)
|
||||
print(xs.fmt(r)); print("JSON:", xs.as_json(r))
|
||||
results.append(r)
|
||||
|
||||
# 5. Majors, H=10, k=5, long-only (avoid short-side noise)
|
||||
r = xs.study_xs("XS04b_maj_H10_k5_lo", score_matrix, universe="majors",
|
||||
H=10, k=5, long_short=False)
|
||||
print(xs.fmt(r)); print("JSON:", xs.as_json(r))
|
||||
results.append(r)
|
||||
|
||||
# ── Pick best by: earns_slot > holdout > corr_xs01 distance ─────────────────
|
||||
def score_rep(r):
|
||||
es = 1 if r.get("earns_slot") else 0
|
||||
ho = r.get("holdout", {}).get("sharpe", -99)
|
||||
dist = 1 - abs(r.get("corr_xs01", 1)) # distinctness
|
||||
return (es, ho, dist)
|
||||
|
||||
best = max(results, key=score_rep)
|
||||
print("\n=== BEST CONFIG ===")
|
||||
print(xs.fmt(best))
|
||||
print("JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,132 @@
|
||||
"""XS05b — Risk-parity momentum (inverse-vol weighted legs).
|
||||
|
||||
MECHANISM: Select top-k / bottom-k by plain 60-day momentum (same as XS01),
|
||||
but instead of equal-weighting within long/short legs, weight each asset by
|
||||
INVERSE of its own recent volatility (60-day rolling std of daily returns).
|
||||
This approximates risk-parity within the cross-sectional book: lower-vol
|
||||
assets get larger weight, so each leg contributes roughly equal risk.
|
||||
|
||||
LIMITATION / CAVEAT:
|
||||
- xslib.study_xs always equal-weights within legs (the score only determines
|
||||
SELECTION, not position sizing). We cannot pass per-asset weights directly
|
||||
through the study_xs interface.
|
||||
- Workaround: encode the inverse-vol signal INTO the score. After selecting
|
||||
the top-k / bottom-k by momentum rank, the harness will still equal-weight
|
||||
— but by blending the momentum z-score with the inverse-vol z-score we bias
|
||||
the SELECTION toward low-vol winners (i.e., the most risk-efficient longs
|
||||
rank higher). This is a partial approximation: true risk-parity would rescale
|
||||
weights post-selection; here we rescale the ranking pre-selection.
|
||||
- The blend is: score = z(mom60) + alpha * z(1/vol60), where alpha=1 gives
|
||||
equal weight to momentum rank and inverse-vol rank.
|
||||
|
||||
GRID (<=5 calls):
|
||||
1. XS05b-base : majors, H=10, k=5, L=60, alpha=1 (blend)
|
||||
2. XS05b-all : all (49 alts), H=10, k=5, L=60, alpha=1
|
||||
3. XS05b-a05 : majors, H=10, k=5, L=60, alpha=0.5 (lighter inv-vol)
|
||||
4. XS05b-a2 : majors, H=10, k=5, L=60, alpha=2.0 (heavier inv-vol)
|
||||
5. XS05b-H5 : majors, H=5, k=5, L=60, alpha=1 (faster rebalance)
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
|
||||
def score_xs05b(P, L=60, alpha=1.0):
|
||||
"""Risk-parity momentum score (causal).
|
||||
|
||||
score = z_cross(mom_L) + alpha * z_cross(inv_vol_L)
|
||||
Higher score -> more risk-efficient momentum winner -> long.
|
||||
Lower score -> more risk-efficient momentum loser -> short.
|
||||
"""
|
||||
# 1. momentum signal (L-day return, causal)
|
||||
mom = xs.past_return(P.close, L) # (n_days, n_assets), uses close[i-L:i]
|
||||
z_mom = xs.xs_zscore(mom)
|
||||
|
||||
# 2. inverse-vol signal (rolling std of daily returns, causal)
|
||||
vol = xs.roll_std(P.ret, L) # (n_days, n_assets)
|
||||
inv_vol = np.where(vol > 0, 1.0 / vol, np.nan)
|
||||
z_inv_vol = xs.xs_zscore(inv_vol)
|
||||
|
||||
# 3. blend
|
||||
score = z_mom + alpha * z_inv_vol
|
||||
return score
|
||||
|
||||
|
||||
results = {}
|
||||
|
||||
# --- Config 1: majors, H=10, k=5, alpha=1 (baseline blend) ---
|
||||
rep1 = xs.study_xs(
|
||||
"XS05b-base",
|
||||
lambda P: score_xs05b(P, L=60, alpha=1.0),
|
||||
universe="majors",
|
||||
H=10, k=5, long_short=True
|
||||
)
|
||||
results["XS05b-base"] = rep1
|
||||
print(xs.fmt(rep1))
|
||||
print("JSON:", xs.as_json(rep1))
|
||||
print()
|
||||
|
||||
# --- Config 2: all alts, H=10, k=5, alpha=1 ---
|
||||
rep2 = xs.study_xs(
|
||||
"XS05b-all",
|
||||
lambda P: score_xs05b(P, L=60, alpha=1.0),
|
||||
universe="all",
|
||||
H=10, k=5, long_short=True
|
||||
)
|
||||
results["XS05b-all"] = rep2
|
||||
print(xs.fmt(rep2))
|
||||
print("JSON:", xs.as_json(rep2))
|
||||
print()
|
||||
|
||||
# --- Config 3: majors, H=10, k=5, alpha=0.5 (lighter inv-vol) ---
|
||||
rep3 = xs.study_xs(
|
||||
"XS05b-a05",
|
||||
lambda P: score_xs05b(P, L=60, alpha=0.5),
|
||||
universe="majors",
|
||||
H=10, k=5, long_short=True
|
||||
)
|
||||
results["XS05b-a05"] = rep3
|
||||
print(xs.fmt(rep3))
|
||||
print("JSON:", xs.as_json(rep3))
|
||||
print()
|
||||
|
||||
# --- Config 4: majors, H=10, k=5, alpha=2.0 (heavier inv-vol) ---
|
||||
rep4 = xs.study_xs(
|
||||
"XS05b-a2",
|
||||
lambda P: score_xs05b(P, L=60, alpha=2.0),
|
||||
universe="majors",
|
||||
H=10, k=5, long_short=True
|
||||
)
|
||||
results["XS05b-a2"] = rep4
|
||||
print(xs.fmt(rep4))
|
||||
print("JSON:", xs.as_json(rep4))
|
||||
print()
|
||||
|
||||
# --- Config 5: majors, H=5, k=5, alpha=1 (faster rebalance) ---
|
||||
rep5 = xs.study_xs(
|
||||
"XS05b-H5",
|
||||
lambda P: score_xs05b(P, L=60, alpha=1.0),
|
||||
universe="majors",
|
||||
H=5, k=5, long_short=True
|
||||
)
|
||||
results["XS05b-H5"] = rep5
|
||||
print(xs.fmt(rep5))
|
||||
print("JSON:", xs.as_json(rep5))
|
||||
print()
|
||||
|
||||
# --- Summary ---
|
||||
print("=" * 60)
|
||||
print("SUMMARY — XS05b grid")
|
||||
print("=" * 60)
|
||||
fmt_h = f"{'Config':<16} {'FullSh':>7} {'HoldSh':>7} {'MaxDD':>7} {'CorrXS01':>9} {'EarnsSlot':>10} {'Verdict':>10}"
|
||||
print(fmt_h)
|
||||
print("-" * 70)
|
||||
for name, r in results.items():
|
||||
fs = r["full"]["sharpe"]
|
||||
hs = r["holdout"]["sharpe"]
|
||||
dd = r["full"]["maxdd"]
|
||||
cxs = r.get("corr_xs01", float("nan"))
|
||||
es = r.get("earns_slot", False)
|
||||
vd = r.get("marginal", {}).get("verdict", "N/A")
|
||||
print(f"{name:<16} {fs:>7.2f} {hs:>7.2f} {dd:>7.2f} {cxs:>9.3f} {str(es):>10} {vd:>10}")
|
||||
@@ -0,0 +1,88 @@
|
||||
"""XS06b — Correlation-to-market diversifier.
|
||||
|
||||
Score = -rolling_corr(asset_ret, market_ret, 60)
|
||||
Long the assets LEAST correlated to the equal-weight market (the "divergers"),
|
||||
short the most-correlated ones. win=60 days.
|
||||
|
||||
Idea: if cross-sectional momentum (XS01) selects by recent past return, this
|
||||
selects by structural independence from the pack — a fundamentally different
|
||||
axis. The two should be weakly correlated.
|
||||
"""
|
||||
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 score_corr_diversifier(P, win=60):
|
||||
"""Score = -rolling_corr(asset_ret, market_ret, win). Causal."""
|
||||
n, A = P.ret.shape
|
||||
mkt = xs.market_ret(P.ret) # (n,) equal-weight market
|
||||
|
||||
out = np.full((n, A), np.nan)
|
||||
mkt_s = pd.Series(mkt)
|
||||
|
||||
for a in range(A):
|
||||
asset_s = pd.Series(P.ret[:, a])
|
||||
# rolling correlation — pandas rolling corr is causal
|
||||
corr = asset_s.rolling(win, min_periods=max(10, win // 2)).corr(mkt_s)
|
||||
# score = NEGATIVE correlation: higher => less correlated => long
|
||||
out[:, a] = -corr.values
|
||||
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Grid: 5 study_xs calls max
|
||||
# - vary universe (all vs majors)
|
||||
# - vary H (rebalance freq)
|
||||
# - vary long_short
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
print("=" * 70)
|
||||
print("XS06b — Correlation-to-market diversifier (score = -roll_corr_60)")
|
||||
print("=" * 70)
|
||||
|
||||
best = None
|
||||
best_earns = False
|
||||
best_ho = -999
|
||||
|
||||
configs = [
|
||||
# (label_suffix, universe, H, k, long_short)
|
||||
("all_H10_k5_ls", "all", 10, 5, True),
|
||||
("maj_H10_k5_ls", "majors", 10, 5, True),
|
||||
("all_H5_k5_ls", "all", 5, 5, True),
|
||||
("all_H10_k5_lo", "all", 10, 5, False),
|
||||
("all_H20_k5_ls", "all", 20, 5, True),
|
||||
]
|
||||
|
||||
results = []
|
||||
for (suffix, universe, H, k, ls) in configs:
|
||||
name = f"XS06b_{suffix}"
|
||||
print(f"\n--- {name} ---")
|
||||
rep = xs.study_xs(
|
||||
name,
|
||||
lambda P: score_corr_diversifier(P, win=60),
|
||||
universe=universe,
|
||||
H=H,
|
||||
k=k,
|
||||
long_short=ls,
|
||||
target_vol=0.20,
|
||||
)
|
||||
print(xs.fmt(rep))
|
||||
print("JSON:", xs.as_json(rep))
|
||||
results.append(rep)
|
||||
|
||||
# track best: earns_slot first, then hold-out sharpe
|
||||
earns = rep.get("earns_slot", False)
|
||||
ho_sh = rep.get("holdout", {}).get("sharpe", -999)
|
||||
if (earns and not best_earns) or (earns == best_earns and ho_sh > best_ho):
|
||||
best = rep
|
||||
best_earns = earns
|
||||
best_ho = ho_sh
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("BEST CONFIG:")
|
||||
print(xs.fmt(best))
|
||||
print("JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,85 @@
|
||||
"""XS07b — Trend-quality (R^2) ranking.
|
||||
|
||||
IDEA: Score each asset by the R^2 of a linear fit of log-price over the last W bars,
|
||||
signed by the direction of the trend (positive slope = long candidate).
|
||||
Score = sign(slope) * R^2
|
||||
|
||||
High R^2 + upward slope -> strong smooth uptrend -> long.
|
||||
High R^2 + downward slope -> strong smooth downtrend -> short.
|
||||
Low R^2 -> noisy / not trending -> near-zero score.
|
||||
|
||||
W=60 canonical, but we try W=30 and W=90 too. The score is CAUSAL: for row i,
|
||||
we fit on close[i-W+1 .. i] (inclusive), using only past data.
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
|
||||
def r2_trend_score(close, W=60):
|
||||
"""
|
||||
Per-asset, rolling R^2 of linear fit on log(close), signed by slope direction.
|
||||
Returns (n_days x n_assets) matrix. Causal: row i uses close[i-W+1..i].
|
||||
"""
|
||||
n, A = close.shape
|
||||
out = np.full((n, A), np.nan)
|
||||
x = np.arange(W, dtype=float)
|
||||
x -= x.mean() # center x for numerical stability
|
||||
xss = (x ** 2).sum()
|
||||
for i in range(W - 1, n):
|
||||
log_p = np.log(close[i - W + 1: i + 1, :]) # (W, A)
|
||||
# For each asset: fit log_p = a + b*x
|
||||
# b = cov(x, log_p) / var(x)
|
||||
mean_y = log_p.mean(axis=0) # (A,)
|
||||
b = (x[:, None] * (log_p - mean_y)).sum(axis=0) / xss # (A,)
|
||||
y_hat = x[:, None] * b + mean_y # (W, A)
|
||||
ss_res = ((log_p - y_hat) ** 2).sum(axis=0)
|
||||
ss_tot = ((log_p - mean_y) ** 2).sum(axis=0)
|
||||
r2 = np.where(ss_tot > 0, 1.0 - ss_res / ss_tot, 0.0)
|
||||
# Score = sign(slope) * R^2. Ranges in [-1, 1].
|
||||
out[i] = np.sign(b) * r2
|
||||
return out
|
||||
|
||||
|
||||
def make_score_fn(W=60):
|
||||
def score_fn(P):
|
||||
return r2_trend_score(P.close, W=W)
|
||||
return score_fn
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Grid: (W, universe, H, k, long_short)
|
||||
# Keep <= 5 backtests total
|
||||
configs = [
|
||||
# Canonical: W=60, all assets, H=10, k=5, LS
|
||||
dict(name="XS07b_W60_all_H10_k5_LS", W=60, universe="all", H=10, k=5, long_short=True),
|
||||
# Shorter trend window
|
||||
dict(name="XS07b_W30_all_H10_k5_LS", W=30, universe="all", H=10, k=5, long_short=True),
|
||||
# Longer trend window
|
||||
dict(name="XS07b_W90_all_H10_k5_LS", W=90, universe="all", H=10, k=5, long_short=True),
|
||||
# Majors only (less noisy universe)
|
||||
dict(name="XS07b_W60_maj_H10_k5_LS", W=60, universe="majors", H=10, k=5, long_short=True),
|
||||
# Long-only variant (majors)
|
||||
dict(name="XS07b_W60_maj_H10_k3_LO", W=60, universe="majors", H=10, k=3, long_short=False),
|
||||
]
|
||||
|
||||
best_rep = None
|
||||
best_key = (-999, -999, False) # (earns_slot, hold_sharpe, robust_oos)
|
||||
|
||||
for cfg in configs:
|
||||
W = cfg.pop("W")
|
||||
name = cfg.pop("name")
|
||||
rep = xs.study_xs(name, make_score_fn(W=W), **cfg)
|
||||
print(xs.fmt(rep))
|
||||
hold_sh = rep["holdout"].get("sharpe", -999)
|
||||
earns = int(rep["earns_slot"])
|
||||
robust = int(rep["marginal"].get("robust_oos", False))
|
||||
key = (earns, robust, hold_sh)
|
||||
if key > best_key:
|
||||
best_key = key
|
||||
best_rep = rep
|
||||
|
||||
print("\n=== BEST CONFIG ===")
|
||||
print(xs.fmt(best_rep))
|
||||
print("JSON:", xs.as_json(best_rep))
|
||||
@@ -0,0 +1,125 @@
|
||||
"""XS08b — Lead-lag vs BTC.
|
||||
|
||||
IDEA: Score = past_return(alt, L=10) of alts CONDITIONAL on BTC having risen over the same
|
||||
window. The hypothesis: alts that lagged BTC during a BTC up-move will catch up.
|
||||
|
||||
Score at bar i:
|
||||
btc_ret_L = BTC.close[i] / BTC.close[i-L] - 1 (BTC rose L days ago to now)
|
||||
alt_ret_L = alt.close[i] / alt.close[i-L] - 1 (how much alt has moved)
|
||||
If btc_ret_L > 0:
|
||||
score = alt_ret_L (lag = low score -> buy the laggards -> REVERSE ranking needed)
|
||||
Actually: we want alts that HAVEN'T moved yet, i.e. low alt_ret when BTC is up.
|
||||
So score = -alt_ret_L (lower alt return during BTC up = more upside potential).
|
||||
If btc_ret_L <= 0:
|
||||
score = NaN (flat; no lead-lag expected when BTC is down).
|
||||
|
||||
Alternative formulation (XS08b-v2): score = btc_ret - alt_ret (gap; higher = more lag = more catch-up).
|
||||
|
||||
Grid (<=5 calls):
|
||||
1. L=10, majors, H=10, k=5, long_short=True — baseline
|
||||
2. L=10, majors, H=5, k=5, long_short=True — faster rebalance
|
||||
3. L=10, "all", H=10, k=5, long_short=True — wider universe
|
||||
4. L=10, majors, H=10, k=5, long_short=False — long-only variant
|
||||
5. L=20, majors, H=10, k=5, long_short=True — longer lookback
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Score factory
|
||||
# ---------------------------------------------------------------------------
|
||||
def make_score(L=10):
|
||||
"""Score: BTC-alt gap during BTC up-moves. Causal."""
|
||||
def score_fn(P: xs.Panel) -> np.ndarray:
|
||||
syms = P.syms
|
||||
n, A = P.close.shape
|
||||
|
||||
# BTC column index (BTC should be in the majors panel)
|
||||
if "BTC" not in syms:
|
||||
raise ValueError("BTC not in panel — use 'majors' or a universe containing BTC")
|
||||
btc_idx = syms.index("BTC")
|
||||
|
||||
# past return over L days (causal)
|
||||
pr = xs.past_return(P.close, L) # (n, A)
|
||||
|
||||
btc_pr = pr[:, btc_idx] # (n,) BTC L-day return
|
||||
|
||||
# score = BTC_return - alt_return (gap; higher gap = alt lagged more = more catch-up)
|
||||
# Only when BTC is up (btc_pr > 0); else NaN (flat)
|
||||
score = np.full((n, A), np.nan)
|
||||
btc_up = btc_pr > 0 # (n,) boolean mask
|
||||
gap = btc_pr[:, None] - pr # (n, A): positive when alt lagged BTC
|
||||
score[btc_up] = gap[btc_up]
|
||||
|
||||
return score
|
||||
return score_fn
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Grid
|
||||
# ---------------------------------------------------------------------------
|
||||
results = []
|
||||
|
||||
print("=" * 60)
|
||||
print("XS08b — Lead-lag vs BTC")
|
||||
print("=" * 60)
|
||||
|
||||
# 1. Baseline: L=10, majors, H=10, k=5, long_short
|
||||
print("\n[1/5] L=10, majors, H=10, k=5, long_short=True")
|
||||
r1 = xs.study_xs("XS08b-base", make_score(L=10),
|
||||
universe="majors", H=10, k=5, long_short=True)
|
||||
print(xs.fmt(r1))
|
||||
print("JSON:", xs.as_json(r1))
|
||||
results.append(r1)
|
||||
|
||||
# 2. Faster rebalance: H=5
|
||||
print("\n[2/5] L=10, majors, H=5, k=5, long_short=True")
|
||||
r2 = xs.study_xs("XS08b-H5", make_score(L=10),
|
||||
universe="majors", H=5, k=5, long_short=True)
|
||||
print(xs.fmt(r2))
|
||||
print("JSON:", xs.as_json(r2))
|
||||
results.append(r2)
|
||||
|
||||
# 3. Wider universe: all
|
||||
print("\n[3/5] L=10, all, H=10, k=5, long_short=True")
|
||||
r3 = xs.study_xs("XS08b-all", make_score(L=10),
|
||||
universe="all", H=10, k=5, long_short=True)
|
||||
print(xs.fmt(r3))
|
||||
print("JSON:", xs.as_json(r3))
|
||||
results.append(r3)
|
||||
|
||||
# 4. Long-only: majors, H=10
|
||||
print("\n[4/5] L=10, majors, H=10, k=5, long_short=False")
|
||||
r4 = xs.study_xs("XS08b-LO", make_score(L=10),
|
||||
universe="majors", H=10, k=5, long_short=False)
|
||||
print(xs.fmt(r4))
|
||||
print("JSON:", xs.as_json(r4))
|
||||
results.append(r4)
|
||||
|
||||
# 5. Longer lookback: L=20
|
||||
print("\n[5/5] L=20, majors, H=10, k=5, long_short=True")
|
||||
r5 = xs.study_xs("XS08b-L20", make_score(L=20),
|
||||
universe="majors", H=10, k=5, long_short=True)
|
||||
print(xs.fmt(r5))
|
||||
print("JSON:", xs.as_json(r5))
|
||||
results.append(r5)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pick best by: earns_slot first, then hold-out Sharpe, then corr_xs01 < 0.6
|
||||
# ---------------------------------------------------------------------------
|
||||
def score_result(r):
|
||||
earns = r.get("earns_slot", False)
|
||||
ho = (r.get("holdout") or {}).get("sharpe", -999)
|
||||
full = (r.get("full") or {}).get("sharpe", -999)
|
||||
corr = r.get("corr_xs01", 1.0)
|
||||
distinct = corr is None or abs(corr) < 0.6
|
||||
return (int(earns), int(distinct and ho > 0 and full > 0), ho)
|
||||
|
||||
best = max(results, key=score_result)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("BEST CONFIG:")
|
||||
print(xs.fmt(best))
|
||||
print("JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,102 @@
|
||||
"""XU01 — Momentum Universe Sweep
|
||||
MECHANISM: Best momentum z-blend (blend of past_return z-scores at L=30 and L=90),
|
||||
run on different universe sizes: majors (19), top20, top30, all (49).
|
||||
|
||||
Goal: map where cross-sectional momentum alpha lives — does expanding to top20/top30/all
|
||||
help or hurt vs the tight 19-major universe of XS01?
|
||||
|
||||
Grid (<=5 backtests):
|
||||
1. majors (19) — baseline reference, should approach XS01
|
||||
2. top20 — add one more liquid alt
|
||||
3. top30 — mid-tier liquidity
|
||||
4. all (49) — known to dilute (confirm)
|
||||
5. top30, long-only (best mid-tier config variant)
|
||||
|
||||
Signal: xs_zscore(past_return(close,30)) + xs_zscore(past_return(close,90)) — same blend as XS01.
|
||||
H=10, k=5, long_short=True (except run 5 long-only).
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
print("XU01 — Momentum Universe Sweep")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
def blend_score(P):
|
||||
"""Z-blend of 30d and 90d momentum — same signal as XS01 but on any universe."""
|
||||
z30 = xs.xs_zscore(xs.past_return(P.close, 30))
|
||||
z90 = xs.xs_zscore(xs.past_return(P.close, 90))
|
||||
return np.nanmean(np.stack([z30, z90], axis=0), axis=0)
|
||||
|
||||
|
||||
# 1) Majors (19) — baseline
|
||||
rep1 = xs.study_xs(
|
||||
"XU01_MAJORS",
|
||||
blend_score,
|
||||
universe="majors", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep1))
|
||||
print("JSON:", xs.as_json(rep1))
|
||||
print()
|
||||
|
||||
# 2) Top-20 by $-volume
|
||||
rep2 = xs.study_xs(
|
||||
"XU01_TOP20",
|
||||
blend_score,
|
||||
universe=20, H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep2))
|
||||
print("JSON:", xs.as_json(rep2))
|
||||
print()
|
||||
|
||||
# 3) Top-30 by $-volume
|
||||
rep3 = xs.study_xs(
|
||||
"XU01_TOP30",
|
||||
blend_score,
|
||||
universe=30, H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep3))
|
||||
print("JSON:", xs.as_json(rep3))
|
||||
print()
|
||||
|
||||
# 4) All (49) — expected dilution
|
||||
rep4 = xs.study_xs(
|
||||
"XU01_ALL",
|
||||
blend_score,
|
||||
universe="all", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep4))
|
||||
print("JSON:", xs.as_json(rep4))
|
||||
print()
|
||||
|
||||
# 5) Top-30, long-only — does dropping the short leg help with mid-tier names?
|
||||
rep5 = xs.study_xs(
|
||||
"XU01_TOP30_LO",
|
||||
blend_score,
|
||||
universe=30, H=10, k=5, long_short=False
|
||||
)
|
||||
print(xs.fmt(rep5))
|
||||
print("JSON:", xs.as_json(rep5))
|
||||
print()
|
||||
|
||||
|
||||
# Pick best: prefer earns_slot, then hold-out sharpe, then full sharpe, then distinctness
|
||||
all_reps = [rep1, rep2, rep3, rep4, rep5]
|
||||
|
||||
|
||||
def score_rep(r):
|
||||
earns = int(r.get("earns_slot", False))
|
||||
hold_sh = (r.get("holdout") or {}).get("sharpe", -9)
|
||||
full_sh = (r.get("full") or {}).get("sharpe", -9)
|
||||
corr_xs01 = r.get("corr_xs01") or 1.0
|
||||
distinctness = 1 - abs(corr_xs01)
|
||||
return (earns, hold_sh, full_sh, distinctness)
|
||||
|
||||
|
||||
best = max(all_reps, key=score_rep)
|
||||
print("=" * 60)
|
||||
print(f"BEST CONFIG: {best['name']}")
|
||||
print(xs.fmt(best))
|
||||
print("JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,120 @@
|
||||
"""XU02 — Rebalance/Holding Sweep (Low-Vol + Momentum)
|
||||
MECHANISM: Study how holding period H and portfolio size k interact with signal quality.
|
||||
Two signals: (1) pure momentum blend (z30+z90 same as XS01), (2) low-vol rank (short volatile, long stable).
|
||||
Goal: find whether a DIFFERENT H/k pair or signal gives something DISTINCT from XS01.
|
||||
|
||||
Hypothesis:
|
||||
- XS01 uses H=10, k=5 (momentum). A longer H reduces turnover, captures slower signal decay.
|
||||
- Low-vol selection (long stable alts, short volatile ones) is conceptually orthogonal to momentum.
|
||||
- Sweep: H in {5,10,20,30}, k in {3,5,8}, signal in {momentum, low_vol}.
|
||||
- Keep <=5 backtests: focus on the most contrasting configs.
|
||||
|
||||
Grid (5 backtests):
|
||||
1. MOM H=5 k=5 — fast rebalance, same as XS01 direction, more turnover
|
||||
2. MOM H=30 k=5 — slow rebalance, lower turnover, tests signal persistence
|
||||
3. LVOL H=10 k=5 — low-vol signal at standard H/k (conceptually distinct from momentum)
|
||||
4. LVOL H=20 k=5 — low-vol with slower rebalance
|
||||
5. LVOL H=10 k=3 — low-vol tight portfolio, more concentrated
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
print("XU02 — Rebalance/Holding Sweep (Low-Vol + Momentum)")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
def mom_blend(P):
|
||||
"""Z-blend of 30d and 90d momentum (same signal as XS01)."""
|
||||
z30 = xs.xs_zscore(xs.past_return(P.close, 30))
|
||||
z90 = xs.xs_zscore(xs.past_return(P.close, 90))
|
||||
return np.nanmean(np.stack([z30, z90], axis=0), axis=0)
|
||||
|
||||
|
||||
def low_vol(P):
|
||||
"""Low-vol signal: score = -rolling_std(ret, 30). Higher score = lower vol = long.
|
||||
Cross-sectionaly ranks alts: stable (low realized vol) go long, volatile go short."""
|
||||
rv = xs.roll_std(P.ret, 30)
|
||||
return -rv # negate: higher = lower vol = prefer long
|
||||
|
||||
|
||||
# 1) Momentum H=5 k=5 — fast rebalance (more turnover, tests short-term signal)
|
||||
rep1 = xs.study_xs(
|
||||
"XU02_MOM_H5k5",
|
||||
mom_blend,
|
||||
universe="majors", H=5, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep1))
|
||||
print("JSON:", xs.as_json(rep1))
|
||||
print()
|
||||
|
||||
# 2) Momentum H=30 k=5 — slow rebalance, lower turnover, tests signal persistence
|
||||
rep2 = xs.study_xs(
|
||||
"XU02_MOM_H30k5",
|
||||
mom_blend,
|
||||
universe="majors", H=30, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep2))
|
||||
print("JSON:", xs.as_json(rep2))
|
||||
print()
|
||||
|
||||
# 3) Low-vol H=10 k=5 — standard H/k but conceptually distinct signal
|
||||
rep3 = xs.study_xs(
|
||||
"XU02_LVOL_H10k5",
|
||||
low_vol,
|
||||
universe="majors", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep3))
|
||||
print("JSON:", xs.as_json(rep3))
|
||||
print()
|
||||
|
||||
# 4) Low-vol H=20 k=5 — slower rebalance, low-vol is a structural trait (changes slowly)
|
||||
rep4 = xs.study_xs(
|
||||
"XU02_LVOL_H20k5",
|
||||
low_vol,
|
||||
universe="majors", H=20, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep4))
|
||||
print("JSON:", xs.as_json(rep4))
|
||||
print()
|
||||
|
||||
# 5) Low-vol H=10 k=3 — tighter portfolio (top/bottom 3 most extreme)
|
||||
rep5 = xs.study_xs(
|
||||
"XU02_LVOL_H10k3",
|
||||
low_vol,
|
||||
universe="majors", H=10, k=3, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep5))
|
||||
print("JSON:", xs.as_json(rep5))
|
||||
print()
|
||||
|
||||
# Summary
|
||||
print("=" * 60)
|
||||
print("SUMMARY: All 5 configs ranked by hold-out Sharpe")
|
||||
all_reps = [rep1, rep2, rep3, rep4, rep5]
|
||||
ranked = sorted(all_reps, key=lambda r: r["holdout"].get("sharpe", -999), reverse=True)
|
||||
for r in ranked:
|
||||
h_sh = r["holdout"].get("sharpe", 0)
|
||||
f_sh = r["full"]["sharpe"]
|
||||
c_xs01 = r["corr_xs01"]
|
||||
verdict = r["marginal"].get("verdict", "N/A")
|
||||
earns = r["earns_slot"]
|
||||
print(f" {r['name']:25s} FULL={f_sh:+.2f} HOLD={h_sh:+.2f} corr_xs01={c_xs01} "
|
||||
f"verdict={verdict} earns_slot={earns}")
|
||||
|
||||
# Pick best by marginal robustness -> earns_slot -> hold-out -> distinctness
|
||||
best = None
|
||||
for r in ranked:
|
||||
if r["earns_slot"]:
|
||||
best = r
|
||||
break
|
||||
if best is None:
|
||||
# fallback: best hold-out with corr_xs01 < 0.6
|
||||
candidates = [r for r in ranked if (r.get("corr_xs01") or 1.0) < 0.6 and r["holdout"].get("sharpe", 0) > 0]
|
||||
best = candidates[0] if candidates else ranked[0]
|
||||
|
||||
print()
|
||||
print("BEST CONFIG:")
|
||||
print(xs.fmt(best))
|
||||
print("JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,137 @@
|
||||
"""XU03 — Long-Only Top-k (Alt Selection)
|
||||
MECHANISM: Low-vol / momentum LONG-ONLY top-k alt selection.
|
||||
- NOT market-neutral: goes long only the top-k alts by combined score, flat otherwise.
|
||||
- Captures alt-beta + selection effect (distinct from XS01 which is market-neutral).
|
||||
- Executable at small capital (k legs, no short book needed).
|
||||
|
||||
Signal: blend of momentum (z30+z90) and low-vol (-rv30) in a composite score.
|
||||
The combined signal selects alts that are trending UP and relatively stable.
|
||||
long_short=False -> long-only top-k, no short leg.
|
||||
|
||||
Grid (5 backtests):
|
||||
1. MOM_LO H=10 k=5 universe=majors — baseline long-only momentum
|
||||
2. MOM_LO H=10 k=5 universe=all — wider universe, more selection power
|
||||
3. COMBO H=10 k=5 universe=majors — blend momentum + low-vol (composite)
|
||||
4. COMBO H=20 k=5 universe=majors — slower rebalance, lower turnover
|
||||
5. COMBO H=10 k=3 universe=majors — tighter portfolio (top-3 only)
|
||||
|
||||
Hypothesis: long-only selection will have high corr_tp01 (market beta) but low corr_xs01
|
||||
(market-neutral XS01 cancels market beta). If the composite score selects quality alts that
|
||||
outperform TP01 (BTC/ETH only), it adds informational value.
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
print("XU03 — Long-Only Top-k (Alt Selection: Momentum + Low-Vol Composite)")
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
def mom_blend(P):
|
||||
"""Z-blend of 30d and 90d momentum — same signal as XS01 but long-only."""
|
||||
z30 = xs.xs_zscore(xs.past_return(P.close, 30))
|
||||
z90 = xs.xs_zscore(xs.past_return(P.close, 90))
|
||||
return np.nanmean(np.stack([z30, z90], axis=0), axis=0)
|
||||
|
||||
|
||||
def combo_score(P):
|
||||
"""Composite: momentum blend + low-vol preference.
|
||||
Selects alts that are trending up AND have lower realized volatility.
|
||||
Both components are cross-sectionally z-scored before blending.
|
||||
"""
|
||||
# Momentum: 30d + 90d blend
|
||||
z30 = xs.xs_zscore(xs.past_return(P.close, 30))
|
||||
z90 = xs.xs_zscore(xs.past_return(P.close, 90))
|
||||
z_mom = np.nanmean(np.stack([z30, z90], axis=0), axis=0)
|
||||
|
||||
# Low-vol: prefer stable alts (negate RV so higher = lower vol = preferred)
|
||||
rv = xs.roll_std(P.ret, 30)
|
||||
z_lvol = xs.xs_zscore(-rv)
|
||||
|
||||
# Equal blend: 50% momentum + 50% low-vol
|
||||
combo = np.nanmean(np.stack([z_mom, z_lvol], axis=0), axis=0)
|
||||
return combo
|
||||
|
||||
|
||||
# 1) Pure momentum long-only, majors universe — baseline
|
||||
rep1 = xs.study_xs(
|
||||
"XU03_MOM_LO_H10k5_majors",
|
||||
mom_blend,
|
||||
universe="majors", H=10, k=5, long_short=False
|
||||
)
|
||||
print(xs.fmt(rep1))
|
||||
print("JSON:", xs.as_json(rep1))
|
||||
print()
|
||||
|
||||
# 2) Pure momentum long-only, all universe — tests wider selection
|
||||
rep2 = xs.study_xs(
|
||||
"XU03_MOM_LO_H10k5_all",
|
||||
mom_blend,
|
||||
universe="all", H=10, k=5, long_short=False
|
||||
)
|
||||
print(xs.fmt(rep2))
|
||||
print("JSON:", xs.as_json(rep2))
|
||||
print()
|
||||
|
||||
# 3) Composite (mom + low-vol) long-only, majors — main hypothesis
|
||||
rep3 = xs.study_xs(
|
||||
"XU03_COMBO_H10k5_majors",
|
||||
combo_score,
|
||||
universe="majors", H=10, k=5, long_short=False
|
||||
)
|
||||
print(xs.fmt(rep3))
|
||||
print("JSON:", xs.as_json(rep3))
|
||||
print()
|
||||
|
||||
# 4) Composite, slower rebalance H=20 — lower turnover, more patient selection
|
||||
rep4 = xs.study_xs(
|
||||
"XU03_COMBO_H20k5_majors",
|
||||
combo_score,
|
||||
universe="majors", H=20, k=5, long_short=False
|
||||
)
|
||||
print(xs.fmt(rep4))
|
||||
print("JSON:", xs.as_json(rep4))
|
||||
print()
|
||||
|
||||
# 5) Composite, tighter k=3 — more concentrated, highest-conviction picks only
|
||||
rep5 = xs.study_xs(
|
||||
"XU03_COMBO_H10k3_majors",
|
||||
combo_score,
|
||||
universe="majors", H=10, k=3, long_short=False
|
||||
)
|
||||
print(xs.fmt(rep5))
|
||||
print("JSON:", xs.as_json(rep5))
|
||||
print()
|
||||
|
||||
# Summary
|
||||
print("=" * 70)
|
||||
print("SUMMARY: All 5 configs ranked by hold-out Sharpe")
|
||||
all_reps = [rep1, rep2, rep3, rep4, rep5]
|
||||
ranked = sorted(all_reps, key=lambda r: r["holdout"].get("sharpe", -999), reverse=True)
|
||||
for r in ranked:
|
||||
h_sh = r["holdout"].get("sharpe", 0)
|
||||
f_sh = r["full"]["sharpe"]
|
||||
c_xs01 = r["corr_xs01"]
|
||||
c_tp01 = r["corr_tp01"]
|
||||
verdict = r["marginal"].get("verdict", "N/A")
|
||||
earns = r["earns_slot"]
|
||||
print(f" {r['name']:35s} FULL={f_sh:+.2f} HOLD={h_sh:+.2f} "
|
||||
f"corr_xs01={c_xs01:+.2f} corr_tp01={c_tp01:+.2f} "
|
||||
f"verdict={verdict} earns_slot={earns}")
|
||||
|
||||
# Pick best config by: earns_slot first, then hold-out > 0 + distinct, then hold-out
|
||||
best = None
|
||||
for r in ranked:
|
||||
if r["earns_slot"]:
|
||||
best = r
|
||||
break
|
||||
if best is None:
|
||||
candidates = [r for r in ranked
|
||||
if (r.get("corr_xs01") or 1.0) < 0.6 and r["holdout"].get("sharpe", 0) > 0]
|
||||
best = candidates[0] if candidates else ranked[0]
|
||||
|
||||
print()
|
||||
print("BEST CONFIG:")
|
||||
print(xs.fmt(best))
|
||||
print("JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,138 @@
|
||||
"""XU04 — Liquidity-filtered momentum
|
||||
MECHANISM: Cross-sectional momentum, but restrict to the top-N assets by RECENT (rolling 60d)
|
||||
median dollar-volume rather than the static all-panel. The idea: momentum signal is cleaner on
|
||||
liquid names; illiquid tail adds noise. Compare:
|
||||
1. Dynamic top-20 by rolling $-vol (vs static top-20 from XU01)
|
||||
2. Dynamic top-20, adjusted momentum (skip 1d to reduce microstructure noise): L=2..31
|
||||
3. Static majors (19) with skip-1 momentum — XS01-style but skip-1 to reduce echo
|
||||
4. Dynamic top-25 rolling-liquidity blend [30,90] — slightly wider universe
|
||||
5. Dynamic top-20 rolling-liquidity blend [30,90], H=5 (faster rebalance)
|
||||
|
||||
Key difference from XS01: the UNIVERSE is determined dynamically (rolling 60d dollar-volume
|
||||
rank) rather than the fixed 19-major list. This may improve distinctness and resilience to
|
||||
liquidity shifts.
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
print("XU04 — Liquidity-filtered momentum")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
def rolling_liq_score(P, lookbacks=(30, 90), skip=0):
|
||||
"""Momentum blend on the panel, with optional skip-1 for microstructure."""
|
||||
scores = []
|
||||
for L in lookbacks:
|
||||
if skip > 0:
|
||||
# use close[i-skip] / close[i-skip-L] - 1 (causal, skip most recent bars)
|
||||
c = P.close
|
||||
out = np.full_like(c, np.nan)
|
||||
# at row i: return from i-L-skip to i-skip
|
||||
for i in range(L + skip, len(c)):
|
||||
out[i] = c[i - skip] / c[i - L - skip] - 1.0
|
||||
else:
|
||||
out = xs.past_return(P.close, L)
|
||||
scores.append(xs.xs_zscore(out))
|
||||
return np.nanmean(np.stack(scores, axis=0), axis=0)
|
||||
|
||||
|
||||
def score_dyn20_blend(P):
|
||||
"""Dynamic top-20 by rolling $-vol — blend [30,90], no skip."""
|
||||
# P already filtered to top-20 by static median; this fn gets whatever panel is loaded.
|
||||
# We do dynamic re-weighting via volume z-score gating:
|
||||
# compute rolling 60d dollar volume rank per asset; assets below median get half-weight score
|
||||
dv = P.close * P.vol # dollar volume matrix (n_days x n_assets)
|
||||
dv_roll = xs.roll_mean(dv, 60) # rolling 60d mean $-vol
|
||||
# rank liquidity cross-sectionally
|
||||
liq_rank = xs.xs_rank(dv_roll) # 0..1, higher = more liquid
|
||||
# momentum signal
|
||||
mom = rolling_liq_score(P, lookbacks=(30, 90), skip=0)
|
||||
# attenuate score of less-liquid assets (liq_rank < 0.5 -> half score)
|
||||
liq_weight = np.where(liq_rank >= 0.5, 1.0, 0.5)
|
||||
return mom * liq_weight
|
||||
|
||||
|
||||
def score_skip1(P):
|
||||
"""Majors, momentum blend [30,90] with 1-day skip (microstructure reduction)."""
|
||||
return rolling_liq_score(P, lookbacks=(30, 90), skip=1)
|
||||
|
||||
|
||||
def score_top25_blend(P):
|
||||
"""Top-25 universe, plain blend [30,90]."""
|
||||
return rolling_liq_score(P, lookbacks=(30, 90), skip=0)
|
||||
|
||||
|
||||
def score_dyn20_fast(P):
|
||||
"""Dynamic top-20 + blend [30,90], faster H=5 rebalance."""
|
||||
return score_dyn20_blend(P)
|
||||
|
||||
|
||||
# 1) Top-20 with dynamic liquidity weighting, H=10, k=5
|
||||
rep1 = xs.study_xs(
|
||||
"XU04_DYN20_H10",
|
||||
score_dyn20_blend,
|
||||
universe=20, H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep1))
|
||||
print("JSON:", xs.as_json(rep1))
|
||||
print()
|
||||
|
||||
# 2) Majors (19) with skip-1 momentum — reduces microstructure vs XS01
|
||||
rep2 = xs.study_xs(
|
||||
"XU04_MAJ_SKIP1",
|
||||
score_skip1,
|
||||
universe="majors", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep2))
|
||||
print("JSON:", xs.as_json(rep2))
|
||||
print()
|
||||
|
||||
# 3) Top-20 plain blend [30,90] no weighting, H=10 (clean baseline vs XU01)
|
||||
rep3 = xs.study_xs(
|
||||
"XU04_TOP20_PLAIN",
|
||||
lambda P: rolling_liq_score(P, lookbacks=(30, 90), skip=0),
|
||||
universe=20, H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep3))
|
||||
print("JSON:", xs.as_json(rep3))
|
||||
print()
|
||||
|
||||
# 4) Top-25, blend [30,90], H=10
|
||||
rep4 = xs.study_xs(
|
||||
"XU04_TOP25_H10",
|
||||
score_top25_blend,
|
||||
universe=25, H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep4))
|
||||
print("JSON:", xs.as_json(rep4))
|
||||
print()
|
||||
|
||||
# 5) Top-20 dynamic liq-weighted, H=5 (faster)
|
||||
rep5 = xs.study_xs(
|
||||
"XU04_DYN20_H5",
|
||||
score_dyn20_fast,
|
||||
universe=20, H=5, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep5))
|
||||
print("JSON:", xs.as_json(rep5))
|
||||
print()
|
||||
|
||||
|
||||
# Pick best
|
||||
all_reps = [rep1, rep2, rep3, rep4, rep5]
|
||||
|
||||
def score_rep(r):
|
||||
earns = int(r.get("earns_slot", False))
|
||||
hold_sh = (r.get("holdout") or {}).get("sharpe", -9)
|
||||
full_sh = (r.get("full") or {}).get("sharpe", -9)
|
||||
corr_xs01 = r.get("corr_xs01") or 1.0
|
||||
distinctness = 1 - abs(corr_xs01)
|
||||
return (earns, hold_sh, full_sh, distinctness)
|
||||
|
||||
best = max(all_reps, key=score_rep)
|
||||
print("=" * 60)
|
||||
print(f"BEST CONFIG: {best['name']}")
|
||||
print(xs.fmt(best))
|
||||
print("JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,83 @@
|
||||
"""XV01 — Low Realized-Volatility Anomaly
|
||||
MECHANISM: Score = -roll_std(ret, W) (long low-vol / short high-vol alts).
|
||||
The low-vol anomaly: lower-volatility assets tend to outperform on a risk-adjusted basis.
|
||||
Grid: W in {20, 30, 60}; universe in {all, majors}; long-short AND long-only.
|
||||
Goal: find a DISTINCT signal from XS01 (plain momentum) that ADDS to the live portfolio.
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
print("XV01 — Low Realized-Volatility Anomaly")
|
||||
print("=" * 60)
|
||||
|
||||
# --- 5 targeted backtests ---
|
||||
|
||||
# 1) Full universe, W=20 (short-term vol), LS — baseline low-vol on all alts
|
||||
rep1 = xs.study_xs(
|
||||
"XV01_ALL_W20_LS",
|
||||
lambda P: -xs.roll_std(P.ret, 20),
|
||||
universe="all", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep1))
|
||||
print("JSON:", xs.as_json(rep1))
|
||||
print()
|
||||
|
||||
# 2) Full universe, W=30, LS — medium-window vol (the main hypothesis)
|
||||
rep2 = xs.study_xs(
|
||||
"XV01_ALL_W30_LS",
|
||||
lambda P: -xs.roll_std(P.ret, 30),
|
||||
universe="all", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep2))
|
||||
print("JSON:", xs.as_json(rep2))
|
||||
print()
|
||||
|
||||
# 3) Full universe, W=60, LS — longer-window vol
|
||||
rep3 = xs.study_xs(
|
||||
"XV01_ALL_W60_LS",
|
||||
lambda P: -xs.roll_std(P.ret, 60),
|
||||
universe="all", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep3))
|
||||
print("JSON:", xs.as_json(rep3))
|
||||
print()
|
||||
|
||||
# 4) Majors only (19), W=30, LS — smaller universe, less noise
|
||||
rep4 = xs.study_xs(
|
||||
"XV01_MAJORS_W30_LS",
|
||||
lambda P: -xs.roll_std(P.ret, 30),
|
||||
universe="majors", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep4))
|
||||
print("JSON:", xs.as_json(rep4))
|
||||
print()
|
||||
|
||||
# 5) Full universe, W=30, long-only top-k (lowest vol, long only)
|
||||
# The "defensive" alt selection: pick alts with lowest realized vol
|
||||
rep5 = xs.study_xs(
|
||||
"XV01_ALL_W30_LO",
|
||||
lambda P: -xs.roll_std(P.ret, 30),
|
||||
universe="all", H=10, k=5, long_short=False
|
||||
)
|
||||
print(xs.fmt(rep5))
|
||||
print("JSON:", xs.as_json(rep5))
|
||||
print()
|
||||
|
||||
# Pick best: prefer earns_slot, then hold-out sharpe, then full sharpe, then distinctness from XS01
|
||||
all_reps = [rep1, rep2, rep3, rep4, rep5]
|
||||
|
||||
def score_rep(r):
|
||||
earns = int(r["earns_slot"])
|
||||
hold_sh = r["holdout"].get("sharpe", -9)
|
||||
full_sh = r["full"]["sharpe"]
|
||||
corr_xs01 = r["corr_xs01"] if r["corr_xs01"] is not None else 1.0
|
||||
distinctness = 1 - abs(corr_xs01) # higher = more distinct
|
||||
return (earns, hold_sh, full_sh, distinctness)
|
||||
|
||||
best = max(all_reps, key=score_rep)
|
||||
print("=" * 60)
|
||||
print(f"BEST CONFIG: {best['name']}")
|
||||
print(xs.fmt(best))
|
||||
print("JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,121 @@
|
||||
"""XV02 — Low Idiosyncratic Volatility Anomaly.
|
||||
|
||||
Score = -roll_std(residual_return(ret, beta_win=60), 30)
|
||||
(negative idiosyncratic volatility: low idio-vol = long, high idio-vol = short).
|
||||
|
||||
Distinct from total-vol because we strip the market factor first (beta*mkt),
|
||||
keeping only the firm-specific noise. In equities this is the "low idio-vol" anomaly
|
||||
(Ang et al. 2006): low idiosyncratic volatility stocks outperform. Testing if the
|
||||
same holds cross-sectionally on the HL alt panel.
|
||||
|
||||
Grid (<=5 calls total):
|
||||
1. majors H=10 k=5 LS (baseline)
|
||||
2. all H=10 k=5 LS (broader universe)
|
||||
3. majors H=5 k=5 LS (faster rebalance)
|
||||
4. majors H=10 k=4 LS (narrower book)
|
||||
5. majors H=10 k=5 LS, shorter beta window (30d) [to test sensitivity]
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SCORE: negative idiosyncratic vol over last 30 days
|
||||
# (idio ret = ret - rolling_beta_60d * market_ret, then 30d rolling std)
|
||||
# ---------------------------------------------------------------------------
|
||||
def score_idiovol(P, beta_win=60, vol_win=30):
|
||||
"""Low idiosyncratic volatility score (higher = lower idio-vol = long)."""
|
||||
idio = xs.residual_return(P.ret, beta_win) # n_days x n_assets
|
||||
idio_vol = xs.roll_std(idio, vol_win) # rolling std of idio ret
|
||||
# negate: lower vol → higher score → long
|
||||
return -idio_vol
|
||||
|
||||
|
||||
results = []
|
||||
|
||||
# ---- 1. Baseline: majors, H=10, k=5, LS (beta_win=60, vol_win=30) ----
|
||||
rep1 = xs.study_xs(
|
||||
"XV02_majors_H10k5",
|
||||
lambda P: score_idiovol(P, beta_win=60, vol_win=30),
|
||||
universe="majors",
|
||||
H=10, k=5, long_short=True,
|
||||
)
|
||||
print(xs.fmt(rep1))
|
||||
print("JSON:", xs.as_json(rep1))
|
||||
results.append(rep1)
|
||||
|
||||
# ---- 2. Broader universe: all, H=10, k=5, LS ----
|
||||
rep2 = xs.study_xs(
|
||||
"XV02_all_H10k5",
|
||||
lambda P: score_idiovol(P, beta_win=60, vol_win=30),
|
||||
universe="all",
|
||||
H=10, k=5, long_short=True,
|
||||
)
|
||||
print(xs.fmt(rep2))
|
||||
print("JSON:", xs.as_json(rep2))
|
||||
results.append(rep2)
|
||||
|
||||
# ---- 3. Faster rebalance: majors, H=5, k=5, LS ----
|
||||
rep3 = xs.study_xs(
|
||||
"XV02_majors_H5k5",
|
||||
lambda P: score_idiovol(P, beta_win=60, vol_win=30),
|
||||
universe="majors",
|
||||
H=5, k=5, long_short=True,
|
||||
)
|
||||
print(xs.fmt(rep3))
|
||||
print("JSON:", xs.as_json(rep3))
|
||||
results.append(rep3)
|
||||
|
||||
# ---- 4. Narrower book: majors, H=10, k=4, LS ----
|
||||
rep4 = xs.study_xs(
|
||||
"XV02_majors_H10k4",
|
||||
lambda P: score_idiovol(P, beta_win=60, vol_win=30),
|
||||
universe="majors",
|
||||
H=10, k=4, long_short=True,
|
||||
)
|
||||
print(xs.fmt(rep4))
|
||||
print("JSON:", xs.as_json(rep4))
|
||||
results.append(rep4)
|
||||
|
||||
# ---- 5. Shorter beta window: majors, H=10, k=5, LS, beta_win=30 ----
|
||||
rep5 = xs.study_xs(
|
||||
"XV02_majors_H10k5_bw30",
|
||||
lambda P: score_idiovol(P, beta_win=30, vol_win=30),
|
||||
universe="majors",
|
||||
H=10, k=5, long_short=True,
|
||||
)
|
||||
print(xs.fmt(rep5))
|
||||
print("JSON:", xs.as_json(rep5))
|
||||
results.append(rep5)
|
||||
|
||||
# ---- Summary ----
|
||||
print("\n=== XV02 GRID SUMMARY ===")
|
||||
for r in results:
|
||||
earns = r["earns_slot"]
|
||||
print(
|
||||
f" {r['name']:30s} FULL {r['full']['sharpe']:+.2f} "
|
||||
f"HOLD {r['holdout'].get('sharpe', 0):+.2f} "
|
||||
f"corr_xs01 {r['corr_xs01']} "
|
||||
f"marginal={r['marginal']['verdict']} "
|
||||
f"earns_slot={earns}"
|
||||
)
|
||||
|
||||
# ---- Best config: pick by earns_slot first, then hold-out ----
|
||||
earners = [r for r in results if r["earns_slot"]]
|
||||
if earners:
|
||||
best = max(earners, key=lambda r: r["holdout"].get("sharpe", -999))
|
||||
print(f"\nBEST (earns_slot): {best['name']}")
|
||||
else:
|
||||
# fallback: best hold-out Sharpe with distinct XS01 corr
|
||||
distinct = [r for r in results if (r["corr_xs01"] or 1.0) < 0.6]
|
||||
if distinct:
|
||||
best = max(distinct, key=lambda r: r["holdout"].get("sharpe", -999))
|
||||
else:
|
||||
best = max(results, key=lambda r: r["holdout"].get("sharpe", -999))
|
||||
print(f"\nBEST (hold-out, no earns_slot): {best['name']}")
|
||||
|
||||
print("\n--- BEST CONFIG ---")
|
||||
print(xs.fmt(best))
|
||||
print("JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,106 @@
|
||||
"""XV03 — Betting Against Beta (BAB) — Low-beta anomaly
|
||||
MECHANISM: Score = -roll_beta(ret, W) (long low-beta alts / short high-beta alts).
|
||||
The BAB anomaly (Frazzini & Pedersen 2014): within an asset cross-section, lower-beta
|
||||
assets deliver higher risk-adjusted returns — because levered/constrained investors
|
||||
bid up high-beta assets above fair value. Score = NEGATIVE rolling beta to equal-weight
|
||||
market, so top-ranked = lowest beta.
|
||||
|
||||
Grid: beta window W in {30, 60}; universe in {all, majors}; long-short.
|
||||
Also test: blend BAB + dispersion condition (only enter if cross-sectional vol is high).
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
print("XV03 — Betting Against Beta (BAB)")
|
||||
print("=" * 60)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Score functions
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def score_bab(ret, win):
|
||||
"""BAB score: negative rolling beta to equal-weight market (causal)."""
|
||||
beta = xs.roll_beta(ret, win) # (n,A): higher = more market exposure
|
||||
return -beta # higher score = lower beta = long candidate
|
||||
|
||||
def score_bab_beta_adj(ret, win):
|
||||
"""BAB score adjusted: z-score the negative beta cross-sectionally."""
|
||||
return xs.xs_zscore(-xs.roll_beta(ret, win))
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Run 5 targeted backtests
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# 1) All alts, W=30 (shorter window — more reactive), LS
|
||||
rep1 = xs.study_xs(
|
||||
"XV03_ALL_W30_LS",
|
||||
lambda P: score_bab(P.ret, 30),
|
||||
universe="all", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep1))
|
||||
print("JSON:", xs.as_json(rep1))
|
||||
print()
|
||||
|
||||
# 2) All alts, W=60 (longer window — more stable beta estimates), LS
|
||||
rep2 = xs.study_xs(
|
||||
"XV03_ALL_W60_LS",
|
||||
lambda P: score_bab(P.ret, 60),
|
||||
universe="all", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep2))
|
||||
print("JSON:", xs.as_json(rep2))
|
||||
print()
|
||||
|
||||
# 3) Majors only (19 XS01 assets), W=60, LS
|
||||
# Cleaner universe: major liquid alts, should reduce noise
|
||||
rep3 = xs.study_xs(
|
||||
"XV03_MAJORS_W60_LS",
|
||||
lambda P: score_bab(P.ret, 60),
|
||||
universe="majors", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep3))
|
||||
print("JSON:", xs.as_json(rep3))
|
||||
print()
|
||||
|
||||
# 4) All alts, W=60, XS-zscored BAB, shorter rebalance H=5
|
||||
# XS z-score normalizes the beta signal each day cross-sectionally
|
||||
rep4 = xs.study_xs(
|
||||
"XV03_ALL_W60_ZS_H5",
|
||||
lambda P: score_bab_beta_adj(P.ret, 60),
|
||||
universe="all", H=5, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep4))
|
||||
print("JSON:", xs.as_json(rep4))
|
||||
print()
|
||||
|
||||
# 5) BAB blend: combine W=30 and W=60 betas (multi-horizon, inspired by XS01 blend)
|
||||
# Average the two z-scored BAB signals
|
||||
rep5 = xs.study_xs(
|
||||
"XV03_ALL_BLEND3060_LS",
|
||||
lambda P: xs.xs_zscore(score_bab(P.ret, 30)) + xs.xs_zscore(score_bab(P.ret, 60)),
|
||||
universe="all", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep5))
|
||||
print("JSON:", xs.as_json(rep5))
|
||||
print()
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Pick best: earns_slot > hold-out sharpe > distinctness from XS01
|
||||
# --------------------------------------------------------------------------
|
||||
all_reps = [rep1, rep2, rep3, rep4, rep5]
|
||||
|
||||
def score_rep(r):
|
||||
earns = int(r["earns_slot"])
|
||||
hold_sh = r["holdout"].get("sharpe", -9)
|
||||
full_sh = r["full"]["sharpe"]
|
||||
corr_xs01 = r["corr_xs01"] if r["corr_xs01"] is not None else 1.0
|
||||
distinctness = 1 - abs(corr_xs01)
|
||||
return (earns, hold_sh, full_sh, distinctness)
|
||||
|
||||
best = max(all_reps, key=score_rep)
|
||||
print("=" * 60)
|
||||
print(f"BEST CONFIG: {best['name']}")
|
||||
print(xs.fmt(best))
|
||||
print("JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,88 @@
|
||||
"""XV04 — Low Downside-Vol / Semivariance
|
||||
Score = -roll_std(min(ret, 0), W)
|
||||
Only downside dispersion is penalized; upside is irrelevant.
|
||||
Buy lowest semivariance (most defensive), short highest.
|
||||
W=30 canonical; small grid over universe/H/k.
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
|
||||
def score_xv04(P, W=30):
|
||||
"""Score = -roll_std(min(ret, 0), W).
|
||||
Causal: each row uses only past W returns.
|
||||
Higher score = lower downside vol = more preferred (long).
|
||||
"""
|
||||
# clip positive returns to 0 so only downside contributes
|
||||
down = np.minimum(P.ret, 0.0)
|
||||
# rolling std of downside returns (semideviation)
|
||||
semi = xs.roll_std(down, W)
|
||||
# negate: lower semideviation = higher score = long bias
|
||||
return -semi
|
||||
|
||||
|
||||
# --- Grid: 5 studies max ---
|
||||
# 1) Canonical: all universe, W=30, H=10, k=5, L/S
|
||||
rep1 = xs.study_xs(
|
||||
"XV04-W30-H10-k5-LS",
|
||||
lambda P: score_xv04(P, W=30),
|
||||
universe="all", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep1))
|
||||
print("JSON:", xs.as_json(rep1))
|
||||
|
||||
# 2) Majors universe (higher liquidity, 19 assets)
|
||||
rep2 = xs.study_xs(
|
||||
"XV04-W30-H10-k5-LS-majors",
|
||||
lambda P: score_xv04(P, W=30),
|
||||
universe="majors", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep2))
|
||||
print("JSON:", xs.as_json(rep2))
|
||||
|
||||
# 3) Longer window W=60, all universe
|
||||
rep3 = xs.study_xs(
|
||||
"XV04-W60-H10-k5-LS",
|
||||
lambda P: score_xv04(P, W=60),
|
||||
universe="all", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep3))
|
||||
print("JSON:", xs.as_json(rep3))
|
||||
|
||||
# 4) W=30, faster rebalance H=5
|
||||
rep4 = xs.study_xs(
|
||||
"XV04-W30-H5-k5-LS",
|
||||
lambda P: score_xv04(P, W=30),
|
||||
universe="all", H=5, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep4))
|
||||
print("JSON:", xs.as_json(rep4))
|
||||
|
||||
# 5) W=30, k=3 (more concentrated)
|
||||
rep5 = xs.study_xs(
|
||||
"XV04-W30-H10-k3-LS",
|
||||
lambda P: score_xv04(P, W=30),
|
||||
universe="all", H=10, k=3, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep5))
|
||||
print("JSON:", xs.as_json(rep5))
|
||||
|
||||
# Pick best by: earns_slot first, then holdout Sharpe, then distinctness
|
||||
reps = [rep1, rep2, rep3, rep4, rep5]
|
||||
earners = [r for r in reps if r["earns_slot"]]
|
||||
if earners:
|
||||
best = max(earners, key=lambda r: r["holdout"].get("sharpe", -999))
|
||||
else:
|
||||
# fallback: positive full + hold-out + corr_xs01 < 0.6
|
||||
candidates = [r for r in reps if r["full"]["sharpe"] > 0 and r["holdout"].get("sharpe", 0) > 0
|
||||
and (r["corr_xs01"] or 1.0) < 0.6]
|
||||
if candidates:
|
||||
best = max(candidates, key=lambda r: r["holdout"].get("sharpe", -999))
|
||||
else:
|
||||
best = max(reps, key=lambda r: r["full"]["sharpe"])
|
||||
|
||||
print("\n=== BEST CONFIG ===")
|
||||
print(xs.fmt(best))
|
||||
print("JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,101 @@
|
||||
"""XV05 — Low Max-Drawdown Anomaly
|
||||
Score = -rolling_maxdrawdown(close, W) over the past W bars.
|
||||
Prefer assets with smooth price history (low drawdown) for long,
|
||||
prefer highly-drawn-down assets for short.
|
||||
|
||||
Grid: vary W (30, 60, 90), universe (majors, all), H (10).
|
||||
<=5 study_xs calls total.
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
|
||||
def rolling_maxdd(close, W):
|
||||
"""Causal rolling max-drawdown over the past W bars.
|
||||
At row i: max drawdown of the window [i-W+1 .. i].
|
||||
Returns matrix (n_days x n_assets). NaN for first W-1 rows.
|
||||
Higher = worse drawdown (more negative).
|
||||
"""
|
||||
n, A = close.shape
|
||||
out = np.full((n, A), np.nan)
|
||||
for i in range(W - 1, n):
|
||||
window = close[i - W + 1: i + 1] # shape (W, A) — causal: data <= i
|
||||
# rolling peak up to each bar within window
|
||||
peak = np.maximum.accumulate(window, axis=0)
|
||||
dd = (window - peak) / peak # drawdown at each bar (<=0)
|
||||
out[i] = np.nanmin(dd, axis=0) # worst drawdown in window (most negative)
|
||||
return out
|
||||
|
||||
|
||||
def score_fn_w60(P):
|
||||
"""Score = -maxDD(W=60): prefer LOW drawdown (smooth equity)."""
|
||||
return -rolling_maxdd(P.close, 60)
|
||||
|
||||
|
||||
def score_fn_w30(P):
|
||||
"""Score = -maxDD(W=30): shorter memory."""
|
||||
return -rolling_maxdd(P.close, 30)
|
||||
|
||||
|
||||
def score_fn_w90(P):
|
||||
"""Score = -maxDD(W=90): longer memory."""
|
||||
return -rolling_maxdd(P.close, 90)
|
||||
|
||||
|
||||
def score_fn_w60_blend(P):
|
||||
"""Blend: average score from W=30 and W=90 (multi-horizon like XS01 blend)."""
|
||||
s30 = -rolling_maxdd(P.close, 30)
|
||||
s90 = -rolling_maxdd(P.close, 90)
|
||||
return xs.xs_zscore(s30) + xs.xs_zscore(s90)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== XV05: Low Max-Drawdown Anomaly ===\n")
|
||||
|
||||
# Run 1: canonical W=60, majors universe, H=10, k=5, long-short
|
||||
print("--- Run 1: W=60, majors, H=10, k=5, LS ---")
|
||||
r1 = xs.study_xs("XV05-W60-maj", score_fn_w60, universe="majors", H=10, k=5, long_short=True)
|
||||
print(xs.fmt(r1))
|
||||
print()
|
||||
|
||||
# Run 2: W=60, all universe (49 alts)
|
||||
print("--- Run 2: W=60, all, H=10, k=5, LS ---")
|
||||
r2 = xs.study_xs("XV05-W60-all", score_fn_w60, universe="all", H=10, k=5, long_short=True)
|
||||
print(xs.fmt(r2))
|
||||
print()
|
||||
|
||||
# Run 3: W=30, majors
|
||||
print("--- Run 3: W=30, majors, H=10, k=5, LS ---")
|
||||
r3 = xs.study_xs("XV05-W30-maj", score_fn_w30, universe="majors", H=10, k=5, long_short=True)
|
||||
print(xs.fmt(r3))
|
||||
print()
|
||||
|
||||
# Run 4: W=90, majors
|
||||
print("--- Run 4: W=90, majors, H=10, k=5, LS ---")
|
||||
r4 = xs.study_xs("XV05-W90-maj", score_fn_w90, universe="majors", H=10, k=5, long_short=True)
|
||||
print(xs.fmt(r4))
|
||||
print()
|
||||
|
||||
# Run 5: blend W=30+W=90, all universe
|
||||
print("--- Run 5: Blend W30+W90, all, H=10, k=5, LS ---")
|
||||
r5 = xs.study_xs("XV05-BLENDall", score_fn_w60_blend, universe="all", H=10, k=5, long_short=True)
|
||||
print(xs.fmt(r5))
|
||||
print()
|
||||
|
||||
# Pick best by earns_slot, then hold-out sharpe, then distinctness from XS01
|
||||
results = [r1, r2, r3, r4, r5]
|
||||
names = ["W60-maj", "W60-all", "W30-maj", "W90-maj", "Blend-all"]
|
||||
|
||||
def score_key(r):
|
||||
earns = 1 if r["earns_slot"] else 0
|
||||
hold_sh = r["holdout"].get("sharpe", -99)
|
||||
full_sh = r["full"]["sharpe"]
|
||||
xs01_corr = abs(r["corr_xs01"] or 1.0)
|
||||
return (earns, hold_sh, full_sh, -xs01_corr)
|
||||
|
||||
best = max(results, key=score_key)
|
||||
print("\n=== BEST CONFIG ===")
|
||||
print(xs.fmt(best))
|
||||
print("JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,89 @@
|
||||
"""XV06 — Low Vol-of-Vol (stability of volatility)
|
||||
Score = -roll_std(roll_std(ret, inner_win), outer_win)
|
||||
Idea: assets whose volatility is most STABLE (predictable) are preferred long;
|
||||
assets with high vol-of-vol (erratic/spiky volatility) are shorted.
|
||||
Lower vol-of-vol = higher score = long bias.
|
||||
Canonical: inner_win=10, outer_win=30.
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
|
||||
def score_xv06(P, inner=10, outer=30):
|
||||
"""Score = -roll_std(roll_std(ret, inner), outer).
|
||||
Causal: each row uses only past data (rolling windows, no future leakage).
|
||||
Higher score = lower vol-of-vol = more stable volatility = preferred long.
|
||||
"""
|
||||
# inner rolling std: daily vol estimate
|
||||
inner_vol = xs.roll_std(P.ret, inner)
|
||||
# outer rolling std of that vol: vol-of-vol
|
||||
vov = xs.roll_std(inner_vol, outer)
|
||||
# negate: lower vov = higher score = long
|
||||
return -vov
|
||||
|
||||
|
||||
# --- Grid: 5 studies max ---
|
||||
# 1) Canonical: inner=10, outer=30, all universe, H=10, k=5, L/S
|
||||
rep1 = xs.study_xs(
|
||||
"XV06-i10-o30-H10-k5-LS",
|
||||
lambda P: score_xv06(P, inner=10, outer=30),
|
||||
universe="all", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep1))
|
||||
print("JSON:", xs.as_json(rep1))
|
||||
|
||||
# 2) Majors only (19 assets, better liquidity)
|
||||
rep2 = xs.study_xs(
|
||||
"XV06-i10-o30-H10-k5-LS-majors",
|
||||
lambda P: score_xv06(P, inner=10, outer=30),
|
||||
universe="majors", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep2))
|
||||
print("JSON:", xs.as_json(rep2))
|
||||
|
||||
# 3) Wider outer window: inner=10, outer=60
|
||||
rep3 = xs.study_xs(
|
||||
"XV06-i10-o60-H10-k5-LS",
|
||||
lambda P: score_xv06(P, inner=10, outer=60),
|
||||
universe="all", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep3))
|
||||
print("JSON:", xs.as_json(rep3))
|
||||
|
||||
# 4) Faster rebalance H=5
|
||||
rep4 = xs.study_xs(
|
||||
"XV06-i10-o30-H5-k5-LS",
|
||||
lambda P: score_xv06(P, inner=10, outer=30),
|
||||
universe="all", H=5, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep4))
|
||||
print("JSON:", xs.as_json(rep4))
|
||||
|
||||
# 5) More concentrated k=3
|
||||
rep5 = xs.study_xs(
|
||||
"XV06-i10-o30-H10-k3-LS",
|
||||
lambda P: score_xv06(P, inner=10, outer=30),
|
||||
universe="all", H=10, k=3, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep5))
|
||||
print("JSON:", xs.as_json(rep5))
|
||||
|
||||
# Pick best by: earns_slot first, then holdout Sharpe, then distinctness
|
||||
reps = [rep1, rep2, rep3, rep4, rep5]
|
||||
earners = [r for r in reps if r["earns_slot"]]
|
||||
if earners:
|
||||
best = max(earners, key=lambda r: r["holdout"].get("sharpe", -999))
|
||||
else:
|
||||
# fallback: positive full + hold-out + corr_xs01 < 0.6
|
||||
candidates = [r for r in reps if r["full"]["sharpe"] > 0 and r["holdout"].get("sharpe", 0) > 0
|
||||
and (r.get("corr_xs01") or 1.0) < 0.6]
|
||||
if candidates:
|
||||
best = max(candidates, key=lambda r: r["holdout"].get("sharpe", -999))
|
||||
else:
|
||||
best = max(reps, key=lambda r: r["full"]["sharpe"])
|
||||
|
||||
print("\n=== BEST CONFIG ===")
|
||||
print(xs.fmt(best))
|
||||
print("JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,85 @@
|
||||
"""XVa1 — Distance-from-MA value signal.
|
||||
|
||||
Score = -(close / roll_mean(close, W) - 1)
|
||||
Long assets furthest BELOW their rolling MA (cheap / mean-reverting).
|
||||
Short assets furthest ABOVE their rolling MA (expensive).
|
||||
|
||||
Grid: W in {60, 100}, universe all/majors, H in {10, 20}.
|
||||
Max 5 study_xs calls.
|
||||
"""
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
|
||||
def score_val(close, W):
|
||||
"""Causal value score: -(close / MA - 1). Higher = more below MA = long."""
|
||||
ma = xs.roll_mean(close, W)
|
||||
return -(close / ma - 1.0)
|
||||
|
||||
|
||||
results = []
|
||||
|
||||
# Config 1: W=60, all assets, H=10, k=5, LS
|
||||
r1 = xs.study_xs(
|
||||
"XVa1-W60-all-H10",
|
||||
lambda P: score_val(P.close, 60),
|
||||
universe="all", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(r1))
|
||||
print("JSON:", xs.as_json(r1))
|
||||
results.append(r1)
|
||||
|
||||
# Config 2: W=100, all assets, H=10, k=5, LS
|
||||
r2 = xs.study_xs(
|
||||
"XVa1-W100-all-H10",
|
||||
lambda P: score_val(P.close, 100),
|
||||
universe="all", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(r2))
|
||||
print("JSON:", xs.as_json(r2))
|
||||
results.append(r2)
|
||||
|
||||
# Config 3: W=60, majors, H=10, k=5, LS
|
||||
r3 = xs.study_xs(
|
||||
"XVa1-W60-majors-H10",
|
||||
lambda P: score_val(P.close, 60),
|
||||
universe="majors", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(r3))
|
||||
print("JSON:", xs.as_json(r3))
|
||||
results.append(r3)
|
||||
|
||||
# Config 4: W=60, all assets, H=20, k=5, LS (slower rebal)
|
||||
r4 = xs.study_xs(
|
||||
"XVa1-W60-all-H20",
|
||||
lambda P: score_val(P.close, 60),
|
||||
universe="all", H=20, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(r4))
|
||||
print("JSON:", xs.as_json(r4))
|
||||
results.append(r4)
|
||||
|
||||
# Config 5: W=100, all assets, H=20, k=5, LS
|
||||
r5 = xs.study_xs(
|
||||
"XVa1-W100-all-H20",
|
||||
lambda P: score_val(P.close, 100),
|
||||
universe="all", H=20, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(r5))
|
||||
print("JSON:", xs.as_json(r5))
|
||||
results.append(r5)
|
||||
|
||||
# Pick best by: earns_slot first, then hold-out Sharpe, then full Sharpe
|
||||
def rank_key(r):
|
||||
earns = int(r["earns_slot"])
|
||||
hold_sh = r["holdout"].get("sharpe", -99)
|
||||
full_sh = r["full"].get("sharpe", -99)
|
||||
return (earns, hold_sh, full_sh)
|
||||
|
||||
best = max(results, key=rank_key)
|
||||
print("\n=== BEST CONFIG ===")
|
||||
print(xs.fmt(best))
|
||||
print("JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,90 @@
|
||||
"""XVa2 — Cross-sectional RSI reversal.
|
||||
|
||||
Idea: compute RSI(14) per asset; score = -RSI so oversold assets go long (low RSI = long).
|
||||
This is a mean-reversion signal: buy the most oversold, short the most overbought.
|
||||
|
||||
Grid (<=5 calls):
|
||||
1. RSI(14) reversal, majors, H=10, k=5, LS
|
||||
2. RSI(14) reversal, all, H=10, k=5, LS
|
||||
3. RSI(14) reversal, all, H=5, k=5, LS (faster rebalance)
|
||||
4. RSI(7) reversal, all, H=5, k=5, LS (shorter RSI period)
|
||||
5. RSI(14) reversal, all, H=10, k=7, LS (wider basket)
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
|
||||
import altlib as al
|
||||
|
||||
|
||||
def rsi_score(close: np.ndarray, win: int = 14) -> np.ndarray:
|
||||
"""Compute -RSI(win) per asset column causally. Returns (n_days, n_assets) score matrix."""
|
||||
n, A = close.shape
|
||||
out = np.full((n, A), np.nan)
|
||||
for a in range(A):
|
||||
out[:, a] = -al.rsi(close[:, a], win)
|
||||
return out
|
||||
|
||||
|
||||
results = []
|
||||
|
||||
# 1. RSI(14) on majors, H=10, k=5, LS
|
||||
rep1 = xs.study_xs(
|
||||
"XVa2-RSI14-majors-H10-k5",
|
||||
lambda P: rsi_score(P.close, 14),
|
||||
universe="majors", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep1))
|
||||
results.append(rep1)
|
||||
|
||||
# 2. RSI(14) on all, H=10, k=5, LS
|
||||
rep2 = xs.study_xs(
|
||||
"XVa2-RSI14-all-H10-k5",
|
||||
lambda P: rsi_score(P.close, 14),
|
||||
universe="all", H=10, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep2))
|
||||
results.append(rep2)
|
||||
|
||||
# 3. RSI(14) on all, H=5, k=5, LS (faster rebalance)
|
||||
rep3 = xs.study_xs(
|
||||
"XVa2-RSI14-all-H5-k5",
|
||||
lambda P: rsi_score(P.close, 14),
|
||||
universe="all", H=5, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep3))
|
||||
results.append(rep3)
|
||||
|
||||
# 4. RSI(7) on all, H=5, k=5, LS (shorter RSI)
|
||||
rep4 = xs.study_xs(
|
||||
"XVa2-RSI7-all-H5-k5",
|
||||
lambda P: rsi_score(P.close, 7),
|
||||
universe="all", H=5, k=5, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep4))
|
||||
results.append(rep4)
|
||||
|
||||
# 5. RSI(14) on all, H=10, k=7, LS (wider basket)
|
||||
rep5 = xs.study_xs(
|
||||
"XVa2-RSI14-all-H10-k7",
|
||||
lambda P: rsi_score(P.close, 14),
|
||||
universe="all", H=10, k=7, long_short=True
|
||||
)
|
||||
print(xs.fmt(rep5))
|
||||
results.append(rep5)
|
||||
|
||||
# Pick best by: earns_slot first, then hold-out Sharpe, then distinctness from XS01
|
||||
def score_rep(r):
|
||||
earns = 1 if r["earns_slot"] else 0
|
||||
hold_sh = r["holdout"].get("sharpe", -999) or -999
|
||||
xs01_corr = abs(r["corr_xs01"] or 1.0)
|
||||
full_sh = r["full"].get("sharpe", -999) or -999
|
||||
return (earns, hold_sh, full_sh, -xs01_corr)
|
||||
|
||||
best = max(results, key=score_rep)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("BEST CONFIG:")
|
||||
print(xs.fmt(best))
|
||||
print("JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,94 @@
|
||||
"""XVa3 — Price-to-high value (mean reversion from recent highs).
|
||||
|
||||
IDEA: Score = -(close / rolling_max(close, W))
|
||||
Long the most beaten-down assets vs their rolling high (W=90).
|
||||
Negative sign: lower ratio (more beaten down) -> higher score -> long.
|
||||
|
||||
CAUSAL: rolling_max at row i uses only data[i-W+1 .. i] (pandas rolling handles this).
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs
|
||||
import numpy as np
|
||||
|
||||
|
||||
def score_pth(close, W):
|
||||
"""Price-to-high score: -(close / rolling_max(close, W)), causal."""
|
||||
import pandas as pd
|
||||
df = pd.DataFrame(close)
|
||||
roll_max = df.rolling(W, min_periods=W // 2).max().values
|
||||
ratio = close / np.where(roll_max > 0, roll_max, np.nan)
|
||||
return -ratio # lower ratio (more beaten down) -> higher score -> long
|
||||
|
||||
|
||||
# --- Grid: 5 backtests total ---
|
||||
# Config 1: canonical W=90, H=10, k=5, long-short, all universe
|
||||
r1 = xs.study_xs(
|
||||
"XVa3-W90-H10-k5-LS-all",
|
||||
lambda P: score_pth(P.close, 90),
|
||||
universe="all", H=10, k=5, long_short=True,
|
||||
)
|
||||
print(xs.fmt(r1))
|
||||
print("JSON:", xs.as_json(r1))
|
||||
print()
|
||||
|
||||
# Config 2: W=60 (shorter lookback), H=10, k=5, long-short, all universe
|
||||
r2 = xs.study_xs(
|
||||
"XVa3-W60-H10-k5-LS-all",
|
||||
lambda P: score_pth(P.close, 60),
|
||||
universe="all", H=10, k=5, long_short=True,
|
||||
)
|
||||
print(xs.fmt(r2))
|
||||
print("JSON:", xs.as_json(r2))
|
||||
print()
|
||||
|
||||
# Config 3: W=90, H=5 (faster rebalance), k=5, long-short, all
|
||||
r3 = xs.study_xs(
|
||||
"XVa3-W90-H5-k5-LS-all",
|
||||
lambda P: score_pth(P.close, 90),
|
||||
universe="all", H=5, k=5, long_short=True,
|
||||
)
|
||||
print(xs.fmt(r3))
|
||||
print("JSON:", xs.as_json(r3))
|
||||
print()
|
||||
|
||||
# Config 4: W=90, H=10, k=5, majors only (more liquid)
|
||||
r4 = xs.study_xs(
|
||||
"XVa3-W90-H10-k5-LS-majors",
|
||||
lambda P: score_pth(P.close, 90),
|
||||
universe="majors", H=10, k=5, long_short=True,
|
||||
)
|
||||
print(xs.fmt(r4))
|
||||
print("JSON:", xs.as_json(r4))
|
||||
print()
|
||||
|
||||
# Config 5: W=120 (longer lookback), H=10, k=5, long-short, all
|
||||
r5 = xs.study_xs(
|
||||
"XVa3-W120-H10-k5-LS-all",
|
||||
lambda P: score_pth(P.close, 120),
|
||||
universe="all", H=10, k=5, long_short=True,
|
||||
)
|
||||
print(xs.fmt(r5))
|
||||
print("JSON:", xs.as_json(r5))
|
||||
print()
|
||||
|
||||
# --- Pick best config ---
|
||||
# Prefer: earns_slot first, then holdout sharpe, then distinctness
|
||||
results = [r1, r2, r3, r4, r5]
|
||||
earns = [r for r in results if r["earns_slot"]]
|
||||
if earns:
|
||||
best = max(earns, key=lambda r: r["holdout"].get("sharpe", -999))
|
||||
else:
|
||||
# Fall back to positive full+hold, distinct from XS01
|
||||
candidates = [r for r in results
|
||||
if r["full"]["sharpe"] > 0
|
||||
and r["holdout"].get("sharpe", 0) > 0
|
||||
and (r["corr_xs01"] or 1.0) < 0.6]
|
||||
if candidates:
|
||||
best = max(candidates, key=lambda r: r["holdout"].get("sharpe", -999))
|
||||
else:
|
||||
best = max(results, key=lambda r: r["holdout"].get("sharpe", -999))
|
||||
|
||||
print("=== BEST CONFIG ===")
|
||||
print(xs.fmt(best))
|
||||
print("JSON:", xs.as_json(best))
|
||||
@@ -0,0 +1,145 @@
|
||||
"""verify_survivors — adversarial 'Verify' phase for the xsec sweep (2026-06-20).
|
||||
|
||||
The Find phase flagged 42/257 cross-sectional configs as earns_slot=True on the certified
|
||||
Hyperliquid panel. ALL the slot-earners share two tells: (a) strongly NEGATIVE corr to TP01
|
||||
(-0.2..-0.4), (b) PnL concentrated in 2025. Hypothesis under test (the only thing that matters
|
||||
before promoting any of them to a sleeve):
|
||||
|
||||
"These are not N independent edges. They are ONE regime bet — short the high-beta alt junk
|
||||
during the 2024-26 alt-bear — wearing many masks (low-vol, low-beta, low-corr, reversal,
|
||||
trend-gated-mom). The drop-one-month jackknife is robust only WITHIN that single regime."
|
||||
|
||||
Three skeptics, deterministic (no agents):
|
||||
S1 (distinctness/redundancy): mutual correlation matrix of the strongest survivor per family.
|
||||
If they're all mutually >0.6 correlated -> one bet, not many.
|
||||
S2 (short-beta tell): correlation of each survivor to two reference factors built on the SAME
|
||||
panel: SHORTBETA = book ranking by -roll_beta; SHORTMKT = -equal-weight alt-market return.
|
||||
A genuinely market-neutral factor should NOT load heavily on "short the market".
|
||||
S3 (single-regime): per-calendar-year Sharpe. If the edge is ~entirely 2025, the 2.5y panel
|
||||
has ONE up-for-the-factor regime and the hold-out (2025-26) cannot prove robustness.
|
||||
|
||||
Run: uv run python scripts/research/xsec/verify_survivors.py
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import xslib as xs # noqa: E402
|
||||
import altlib as al # noqa: E402 (via xslib sys.path)
|
||||
|
||||
|
||||
def roll_corr_to_market(ret, win):
|
||||
"""Rolling corr of each asset's return to the equal-weight market (causal)."""
|
||||
mkt = pd.Series(xs.market_ret(ret))
|
||||
out = np.full_like(ret, np.nan)
|
||||
for a in range(ret.shape[1]):
|
||||
out[:, a] = pd.Series(ret[:, a]).rolling(win, min_periods=max(5, win // 2)).corr(mkt).values
|
||||
return out
|
||||
|
||||
|
||||
def book(universe, score_fn, H=10, k=5, long_short=True):
|
||||
p = xs.load_panel(universe)
|
||||
return xs.xs_backtest(p, score_fn(p), H=H, k=k, long_short=long_short)
|
||||
|
||||
|
||||
# ── strongest representative survivor per family (from the Find-phase output) ──
|
||||
SURV = {
|
||||
"XV02_lowidiovol": lambda: book("majors", lambda P: -xs.roll_std(xs.residual_return(P.ret, 60), 30)),
|
||||
"XV01_lowvol": lambda: book("majors", lambda P: -xs.roll_std(P.ret, 30)),
|
||||
"XV03_lowbeta": lambda: book("all", lambda P: -xs.roll_beta(P.ret, 60)),
|
||||
"XS06b_lowcorr": lambda: book("all", lambda P: -roll_corr_to_market(P.ret, 60)),
|
||||
"XU02_lowvol_maj": lambda: book("majors", lambda P: -xs.roll_std(P.ret, 30), k=5),
|
||||
"XM09_trendgmom": lambda: book("all", lambda P: _trend_gated_mom(P, 60)),
|
||||
"XL02_voltrendmom": lambda: book("majors", lambda P: xs.xs_zscore(xs.past_return(P.close, 60)) + xs.xs_zscore(xs.volume_z(P.vol, 30))),
|
||||
"XR02_revgated": lambda: book("majors", lambda P: _vol_gated_rev(P, 3), H=3),
|
||||
}
|
||||
|
||||
|
||||
def _trend_gated_mom(P, L):
|
||||
"""XS momentum, but zeroed on days the equal-weight market trailing-sum is non-positive."""
|
||||
s = xs.past_return(P.close, L)
|
||||
mkt = xs.market_ret(P.ret)
|
||||
up = pd.Series(mkt).rolling(L, min_periods=L // 2).sum().values > 0
|
||||
out = s.copy()
|
||||
out[~up, :] = np.nan # flat (no ranking) when market not trending up
|
||||
return out
|
||||
|
||||
|
||||
def _vol_gated_rev(P, L):
|
||||
"""Short-term reversal, active only when market realized vol is in its high regime."""
|
||||
rev = -xs.past_return(P.close, L)
|
||||
mvol = pd.Series(xs.market_ret(P.ret)).rolling(20, min_periods=10).std()
|
||||
thr = mvol.expanding(min_periods=60).quantile(0.70).values
|
||||
hi = (mvol.values > thr)
|
||||
out = rev.copy()
|
||||
out[~hi, :] = np.nan
|
||||
return out
|
||||
|
||||
|
||||
# reference factors (the suspected single underlying bet)
|
||||
REF = {
|
||||
"SHORTBETA": lambda: book("all", lambda P: -xs.roll_beta(P.ret, 60)), # explicit short-high-beta
|
||||
"SHORTMKT": None, # -equal-weight alt market
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 96)
|
||||
print(" ADVERSARIAL VERIFY — are the xsec survivors one regime bet (short alt-beta) or N edges?")
|
||||
print("=" * 96)
|
||||
|
||||
series = {n: al._to_daily(f()) for n, f in SURV.items()}
|
||||
|
||||
# SHORTMKT reference = negative equal-weight alt-market daily return (vol-targeted like a book)
|
||||
p_all = xs.load_panel("all")
|
||||
mkt = pd.Series(-xs.market_ret(p_all.ret), index=p_all.index)
|
||||
series["SHORTBETA"] = al._to_daily(book("all", lambda P: -xs.roll_beta(P.ret, 60)))
|
||||
series["SHORTMKT"] = al._to_daily(mkt)
|
||||
|
||||
df = pd.DataFrame(series).dropna(how="all")
|
||||
|
||||
names = list(SURV.keys())
|
||||
# ── S1: mutual correlation matrix ─────────────────────────────────────────
|
||||
print("\n[S1] Mutual correlation matrix of survivors (>0.6 = same bet):")
|
||||
C = df[names].corr()
|
||||
hdr = " " + " ".join(f"{n[:8]:>8s}" for n in names)
|
||||
print(hdr)
|
||||
for n in names:
|
||||
row = " ".join(f"{C.loc[n, m]:>8.2f}" for m in names)
|
||||
print(f" {n:<16s} {row}")
|
||||
iu = [(a, b) for i, a in enumerate(names) for b in names[i + 1:]]
|
||||
pair_corrs = [C.loc[a, b] for a, b in iu]
|
||||
print(f" --> mean off-diagonal corr = {np.mean(pair_corrs):+.2f} "
|
||||
f"(share |r|>0.6: {np.mean([abs(x) > 0.6 for x in pair_corrs]) * 100:.0f}%)")
|
||||
|
||||
# ── S2: load on short-beta / short-market ─────────────────────────────────
|
||||
print("\n[S2] Correlation of each survivor to the suspected single bet:")
|
||||
print(f" {'survivor':<18s} {'corr_SHORTBETA':>15s} {'corr_SHORTMKT':>15s}")
|
||||
for n in names:
|
||||
cb = df[n].corr(df["SHORTBETA"])
|
||||
cm = df[n].corr(df["SHORTMKT"])
|
||||
print(f" {n:<18s} {cb:>15.2f} {cm:>15.2f}")
|
||||
|
||||
# ── S3: per-year Sharpe (single-regime test) ──────────────────────────────
|
||||
print("\n[S3] Per-calendar-year Sharpe (is the edge ~entirely 2025?):")
|
||||
print(f" {'survivor':<18s} {'2024':>8s} {'2025':>8s} {'2026':>8s}")
|
||||
for n in names:
|
||||
s = df[n].dropna()
|
||||
cells = []
|
||||
for y in (2024, 2025, 2026):
|
||||
sy = s[s.index.year == y]
|
||||
cells.append(f"{al._sh(sy):>8.2f}" if len(sy) > 20 else f"{'--':>8s}")
|
||||
print(f" {n:<18s} " + " ".join(cells))
|
||||
|
||||
print("\n" + "=" * 96)
|
||||
print(" VERDICT logic: high mutual corr + high SHORTBETA/SHORTMKT load + 2025-only Sharpe")
|
||||
print(" => one short-alt-beta regime bet on a single-regime 2.5y panel. LEAD/forward-monitor,")
|
||||
print(" NOT a sleeve (cannot prove it survives an alt-bull regime flip).")
|
||||
print("=" * 96)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,237 @@
|
||||
export const meta = {
|
||||
name: 'xsec-strategies-hyperliquid',
|
||||
description: 'Search NEW cross-sectional / multi-asset strategies on the 51 certified Hyperliquid alts, distinct from XS01: honest backtest each, verify, score marginal vs the live TP01+XS01+VRP01 stack',
|
||||
phases: [
|
||||
{ title: 'Find', detail: 'one agent per cross-sectional mechanism via shared xslib' },
|
||||
{ title: 'Verify', detail: '3 adversarial skeptics per promising finding (overfit/distinctness/short-history)' },
|
||||
{ title: 'Synthesize', detail: 'rank survivors, marginal contribution to the live portfolio' },
|
||||
],
|
||||
}
|
||||
|
||||
// fam | id | name | kind hint | idea
|
||||
const CATALOG = [
|
||||
// --- MOM: cross-sectional momentum variants ---
|
||||
['MOM','XM01','Single-L momentum sweep','Score = past_return(close,L); long top-k/short bottom-k. Grid L in {20,30,60,90,120}. Try universe "all","majors",top20. (Known: momentum on full 49-universe is NEGATIVE — confirm; majors is XS01 turf.)'],
|
||||
['MOM','XM02','Multi-L z-blend momentum','Score = mean of xs_zscore(past_return(close,L)) over L in {30,90} (and try {20,60,120}). Like XS01 blend. Compare "all" vs "majors".'],
|
||||
['MOM','XM03','Vol-scaled (risk-adj) momentum','Score = past_return(close,L) / roll_std(ret,L). Risk-adjusted momentum (Sharpe-like). Grid L in {30,60,90}.'],
|
||||
['MOM','XM04','Residual / idiosyncratic momentum','Score = cumulative residual_return(ret, win) over last L (beta-removed momentum). Cleaner than raw momentum? win=60, L in {30,60}.'],
|
||||
['MOM','XM05','Momentum acceleration','Score = past_return(close,L_short) - past_return(close,L_long) (is momentum accelerating). L_short=20,L_long=60.'],
|
||||
['MOM','XM06','52-day-high proximity','Score = close / rolling_max(high,W) (closeness to recent high). W in {60,90}.'],
|
||||
['MOM','XM07','Sharpe-rank momentum','Score = roll_mean(ret,L) / roll_std(ret,L). Rank by realized Sharpe. L in {30,60,90}.'],
|
||||
['MOM','XM08','Momentum consistency (frog-in-pan)','Score = past_return(close,L) * fraction_of_up_days(ret,L) (smooth momentum beats jumpy). L=60.'],
|
||||
['MOM','XM09','Market-trend-gated momentum','XS momentum but only ACTIVE when the equal-weight market (market_ret) is in an uptrend (trailing sum>0); else flat. L=60.'],
|
||||
['MOM','XM10','Rank-weighted continuous momentum','Instead of top-k/bottom-k, weight ALL assets by demeaned xs_rank(past_return) (continuous book). Implement weights yourself via a fine score + large k≈A/2, or note xslib top-k is the proxy. L=60.'],
|
||||
// --- REV: reversal ---
|
||||
['REV','XR01','Short-term reversal','Score = -past_return(close,L) (long losers/short winners). Grid L in {1,3,5,7}. (Known smoke: REV5 negative — confirm/diagnose.)'],
|
||||
['REV','XR02','Reversal gated by high-vol regime','Short-term reversal active only when market vol is high (panic) else flat. L=3.'],
|
||||
['REV','XR03','Residual short-term reversal','Score = -(sum of residual_return over last L). Idiosyncratic reversal (beta-removed). L in {3,5}.'],
|
||||
['REV','XR04','Volume-shock reversal','Long recent losers that ALSO had a volume spike (volume_z high): score = -past_return*(volume_z>1). L=3.'],
|
||||
['REV','XR05','Overreaction reversal (mid-horizon)','Score = -past_return(close,L) for L in {20,30} (mean-reversion of multi-week moves).'],
|
||||
// --- VOL/RISK anomalies (the frontier) ---
|
||||
['VOL','XV01','Low realized-vol anomaly','Score = -roll_std(ret,W) (long low-vol / short high-vol alts). Grid W in {20,30,60}, universe all/majors/top20, long-short AND long-only. (Smoke: ADDS — verify hard.)'],
|
||||
['VOL','XV02','Low idiosyncratic-vol anomaly','Score = -roll_std(residual_return(ret,60), 30) (low idio vol). Distinct from total vol?'],
|
||||
['VOL','XV03','Low-beta anomaly (BAB)','Score = -roll_beta(ret,60) (long low-beta / short high-beta). Betting-against-beta.'],
|
||||
['VOL','XV04','Low downside-vol / semivariance','Score = -roll_std(min(ret,0), W) (only downside dispersion). W=30.'],
|
||||
['VOL','XV05','Low max-drawdown anomaly','Score = -rolling_maxdrawdown(close,W) (prefer smooth equity). W=60.'],
|
||||
['VOL','XV06','Low vol-of-vol','Score = -roll_std(roll_std(ret,10), 30). Stability of volatility.'],
|
||||
// --- DIST: distribution shape ---
|
||||
['DIST','XD01','Low-skew / anti-lottery','Score = -roll_skew(ret,60) (short high-skew lottery alts, long low-skew). Lottery-preference premium.'],
|
||||
['DIST','XD02','High-skew momentum (opposite)','Score = +roll_skew(ret,60). Test the OTHER sign (does positive skew pay in crypto?).'],
|
||||
['DIST','XD03','Coskewness with market','Rank by rolling coskewness of asset returns with market; long low-coskew. win=60.'],
|
||||
// --- LIQ: volume / liquidity ---
|
||||
['LIQ','XL01','Amihud illiquidity premium','Score = mean(|ret| / (close*volume)) over W (illiquidity). Long illiquid? Test both signs. W=30.'],
|
||||
['LIQ','XL02','Volume-trend momentum','Score = volume_z(vol,30) combined with positive return (rising-volume winners). '],
|
||||
['LIQ','XL03','Low-turnover anomaly','Score = -roll_mean(close*volume, 30) (long low dollar-volume names). Test sign.'],
|
||||
['LIQ','XL04','Dollar-volume momentum','Score = past_return of dollar-volume (assets gaining liquidity/attention). W=30.'],
|
||||
// --- VAL: value / mean-reversion to anchor ---
|
||||
['VAL','XVa1','Distance-from-MA value','Score = -(close/roll_mean(close,W) - 1) (long the ones furthest BELOW their MA = cheap). W in {60,100}.'],
|
||||
['VAL','XVa2','Cross-sectional RSI reversal','Compute RSI(14) per asset (use al.rsi per column); score = -RSI (long oversold). '],
|
||||
['VAL','XVa3','Price-to-high value','Score = -(close / rolling_max(close,W)) (long the most beaten-down vs their high). W=90.'],
|
||||
// --- STRUCT: structure / combos / construction ---
|
||||
['STRUCT','XS01b','Double-sort momentum × low-vol','Score = xs_zscore(past_return(close,60)) + xs_zscore(-roll_std(ret,30)). Combine momentum and low-vol.'],
|
||||
['STRUCT','XS02b','Long-mom + short-rev multi-horizon','Score = xs_zscore(past_return(close,90)) + xs_zscore(-past_return(close,5)). Long-term winners that dipped short-term.'],
|
||||
['STRUCT','XS03b','Beta-hedged momentum','XS momentum book but subtract market beta exposure (score=residual momentum; or note xslib book is already ~dollar-neutral). Compare net vs market-hedged.'],
|
||||
['STRUCT','XS04b','Ensemble z-vote','Score = mean of xs_zscore over {momentum90, -vol30, -skew60, -beta60}. Diversified cross-sectional signal.'],
|
||||
['STRUCT','XS05b','Risk-parity legs (inverse-vol)','Momentum selection but weight legs by inverse own-vol (approximate via score = past_return and rely on xslib; document the limitation). L=60.'],
|
||||
['STRUCT','XS06b','Correlation-to-market diversifier','Score = -rolling_corr(asset_ret, market_ret, 60) (long alts least correlated to the pack). win=60.'],
|
||||
['STRUCT','XS07b','Trend-quality (R^2) ranking','Score = R^2 of a linear fit of log price over last W (smooth trenders). Long high-R2-up. W=60.'],
|
||||
['STRUCT','XS08b','Lead-lag vs BTC','Score = past_return(close,L) of alts conditional on BTC having risen (alts that lag BTC catch up). L=10.'],
|
||||
// --- UNIV: universe / rebalance sensitivity (same core signal, vary the frame) ---
|
||||
['UNIV','XU01','Momentum universe sweep','Best momentum z-blend, run on universe in {majors, top20, top30, all}. Where does x-sec momentum live? (Maps the small-cap dilution.)'],
|
||||
['UNIV','XU02','Rebalance/holding sweep','Low-vol or momentum with H in {5,10,20,30} and k in {3,5,8}. Turnover vs signal decay.'],
|
||||
['UNIV','XU03','Long-only top-k (alt selection)','Low-vol / momentum LONG-ONLY top-k (captures alt-beta + selection, executable at small capital unlike the 38-leg book). Note: NOT market-neutral.'],
|
||||
['UNIV','XU04','Liquidity-filtered momentum','Momentum but only on the top-20 by median dollar-volume (avoid illiquid noise). Compare to "all".'],
|
||||
]
|
||||
|
||||
const ROOT = '/opt/docker/PythagorasGoal'
|
||||
const CHEAT = `SHARED LIB (built & validated): ${ROOT}/scripts/research/xsec/xslib.py
|
||||
Top of your script: import sys; sys.path.insert(0, "${ROOT}/scripts/research/xsec"); import xslib as xs; import numpy as np
|
||||
PANEL: xs.load_panel(universe) -> Panel(.syms, .index, .close, .open, .high, .low, .vol, .ret) all numpy (n_days x n_assets).
|
||||
universe: "all" (49 alts, >=700d), "majors" (19 XS01 majors), a list of syms, or an int N (top-N by $-volume).
|
||||
Certified Hyperliquid 1d, 2024-2026 (~900 days). DVOL not here (that's BTC/ETH only).
|
||||
CAUSAL HELPERS (value at row i uses data <= i): xs.past_return(close,L), xs.roll_std/roll_mean/roll_skew/ewm_mean(mat,win),
|
||||
xs.xs_zscore(mat) (cross-sectional z per row), xs.xs_rank(mat), xs.market_ret(ret), xs.roll_beta(ret,win),
|
||||
xs.residual_return(ret,win) (idiosyncratic), xs.volume_z(vol,win). For per-asset TA (RSI etc.) loop columns with altlib (sys.path has it: import altlib as al; al.rsi(col)).
|
||||
BACKTEST/EVAL (no look-ahead: weight at bar i earns return of bar i+1 — built in):
|
||||
xs.study_xs("NAME", lambda P: score_matrix(P), universe="all", H=10, k=5, long_short=True, target_vol=0.20)
|
||||
score_matrix(P) -> np.ndarray (n_days x n_assets), HIGHER = long. Ranked cross-sectionally each H days;
|
||||
long top-k / short bottom-k (long_short) or long-only top-k. Vol-targeted, fee 0.10% RT on turnover.
|
||||
Returns {name,universe,H,k,long_short,n_assets,n_days, full:{sharpe,maxdd,ret,cagr}, holdout:{sharpe,...} (2025+),
|
||||
yearly, corr_tp01, corr_xs01, corr_active, marginal:{verdict(ADDS/REDUNDANT/DILUTES/NEUTRAL),corr,
|
||||
holdout_uplift_w20, jackknife_min_uplift, robust_oos}, earns_slot}.
|
||||
PRINT xs.fmt(rep) and print("JSON:", xs.as_json(rep)).
|
||||
THE BAR: a finding matters only if it is (1) positive FULL & hold-out 2025+, (2) DISTINCT from XS01 (corr_xs01 < 0.6 —
|
||||
else it is just XS01), (3) marginal verdict ADDS to the live portfolio with robust_oos=True (survives the OOS jackknife).
|
||||
earns_slot encodes exactly this. HONESTY: the panel is ~2.5 YEARS -> every result is SUGGESTIVE, not robust; a single
|
||||
good config or one lucky quarter is NOT an edge. Report negatives plainly (most cross-sectional signals will fail here).`
|
||||
|
||||
function finderPrompt([fam, id, name, idea]) {
|
||||
return `You are studying ONE cross-sectional / multi-asset trading mechanism on the certified Hyperliquid alt panel for PythagorasGoal. Goal of the wave: find something DISTINCT from the existing XS01 (plain cross-sectional momentum) that ADDS to the live TP01+XS01+VRP01 portfolio. Implement honestly with the shared library, backtest, report STRUCTURED results.
|
||||
|
||||
MECHANISM ${id} [${fam}] — ${name}
|
||||
IDEA: ${idea}
|
||||
|
||||
CHEATSHEET
|
||||
${CHEAT}
|
||||
|
||||
STEPS
|
||||
1. Write ${ROOT}/scripts/research/xsec/runs/${id}.py: import xslib as xs, implement the score_matrix CAUSALLY, try a SMALL grid (<=5 study_xs calls total: vary universe / H / k / long_short / a param), pick the BEST config by marginal robustness (prefer earns_slot, then hold-out, then distinctness from XS01). Print xs.fmt(rep)+"JSON:"+xs.as_json(rep) for the best.
|
||||
2. Run: cd ${ROOT} && uv run python scripts/research/xsec/runs/${id}.py (fix NaN/shape errors and re-run until it produces numbers).
|
||||
3. Fill the schema from your BEST config, HONESTLY. promising=true ONLY if earns_slot is true OR (full>0 AND hold-out>0 AND corr_xs01<0.6 AND marginal verdict is ADDS). Remember the ~2.5y caveat — be skeptical.
|
||||
|
||||
CONSTRAINTS: keep <=5 backtests (each scans ~49 assets x 900 days). Score matrices must be (n_days x n_assets), higher=long, causal. Don't fabricate — every number from a real run.
|
||||
Your final message IS the schema (data row), not prose.`
|
||||
}
|
||||
|
||||
const FIND_SCHEMA = {
|
||||
type: 'object',
|
||||
required: ['id','name','family','implemented','best_universe','best_H','best_k','long_short','full_sharpe','holdout_sharpe','worst_maxdd','corr_xs01','corr_tp01','marginal_verdict','robust_oos','earns_slot','promising','summary'],
|
||||
properties: {
|
||||
id: { type: 'string' }, name: { type: 'string' }, family: { type: 'string' },
|
||||
implemented: { type: 'boolean' },
|
||||
best_universe: { type: 'string' }, best_H: { type: 'number' }, best_k: { type: 'number' },
|
||||
long_short: { type: 'boolean' },
|
||||
full_sharpe: { type: 'number' }, holdout_sharpe: { type: 'number' },
|
||||
worst_maxdd: { type: 'number' },
|
||||
corr_xs01: { type: 'number', description: 'correlation to existing XS01 (must be <0.6 to be distinct)' },
|
||||
corr_tp01: { type: 'number' },
|
||||
marginal_verdict: { type: 'string', enum: ['ADDS','REDUNDANT','DILUTES','NEUTRAL','N/A'] },
|
||||
holdout_uplift_w20: { type: 'number' },
|
||||
robust_oos: { type: 'boolean', description: 'survives the OOS drop-best-month jackknife' },
|
||||
earns_slot: { type: 'boolean' },
|
||||
promising: { type: 'boolean' },
|
||||
summary: { type: 'string' },
|
||||
caveats: { type: 'string' },
|
||||
script_path: { type: 'string' },
|
||||
},
|
||||
}
|
||||
|
||||
function verifyPrompt(spec, find, kk) {
|
||||
const [fam, id, name] = spec
|
||||
const angles = [
|
||||
'OVERFIT TO 2.5y / SHORT-HISTORY: the panel is only 2024-2026 with a ~1.5y hold-out. Re-run the best config and its neighbors (other universe/H/k). Is the edge a plateau or one lucky cell? Split the hold-out: is it carried by ONE quarter or the partial-2026 stub? Re-check jackknife (drop-best-month). Default real=false if it leans on a short window or single config.',
|
||||
'DISTINCTNESS FROM XS01 & LEAK: is corr_xs01 really < 0.6, or is this XS01 in disguise (same momentum signal re-skinned)? Read xslib to confirm the score is causal (no future bar in rolling/beta/residual; weight at i applies to i+1). Confirm the mechanism is economically DIFFERENT from cross-sectional momentum. Default real=false if redundant with XS01 or leaky.',
|
||||
'MARGINAL & EXECUTABILITY: re-verify it ADDS to the LIVE active portfolio (marginal uplift hold-out positive AND robust_oos) — not just standalone-positive. Is the book executable (a 10-leg market-neutral alt book needs ~20k capital; a long-only top-k is lighter)? Is turnover/fee realistic? For volume/illiquidity signals, are they an artifact of thin alts? Default real=false if it does not robustly improve the live stack.',
|
||||
]
|
||||
return `You are an ADVERSARIAL SKEPTIC (#${kk + 1}) for PythagorasGoal. A finder claims cross-sectional mechanism ${id} [${fam}] "${name}" is promising on the Hyperliquid alt panel. REFUTE it — this project was wrecked once by fake edges, and here the history is only ~2.5 years so the overfit risk is HIGH. Assume false-positive until proven otherwise.
|
||||
|
||||
FINDER'S CLAIM:
|
||||
${JSON.stringify(find)}
|
||||
Run script: ${find.script_path || ROOT + '/scripts/research/xsec/runs/' + id + '.py'}
|
||||
Trusted leak-free lib: ${ROOT}/scripts/research/xsec/xslib.py
|
||||
|
||||
YOUR ANGLE: ${angles[kk % 3]}
|
||||
|
||||
Read the script, run your own checks (cd ${ROOT} && uv run python ...), quote the numbers you produce, and decide. Default to real=false when uncertain. Return ONLY the schema.`
|
||||
}
|
||||
|
||||
const VERIFY_SCHEMA = {
|
||||
type: 'object',
|
||||
required: ['id','real','confidence','reason'],
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
real: { type: 'boolean', description: 'true only if the edge survives your adversarial check AND robustly adds to the live stack' },
|
||||
confidence: { type: 'number' },
|
||||
overfit_short_history: { type: 'boolean' },
|
||||
redundant_with_xs01: { type: 'boolean' },
|
||||
leak_suspected: { type: 'boolean' },
|
||||
corrected_full_sharpe: { type: 'number' },
|
||||
corrected_holdout_sharpe: { type: 'number' },
|
||||
reason: { type: 'string', description: 'specific, with numbers you produced' },
|
||||
},
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
phase('Find')
|
||||
log(`Searching ${CATALOG.length} cross-sectional mechanisms on the 51-alt Hyperliquid panel, one agent each. Frontier: distinct from XS01, additive to the live stack.`)
|
||||
|
||||
const results = await pipeline(
|
||||
CATALOG,
|
||||
(spec) => agent(finderPrompt(spec), { label: `find:${spec[1]}`, phase: 'Find', schema: FIND_SCHEMA, model: 'sonnet', effort: 'medium' }),
|
||||
(find, spec) => {
|
||||
if (!find) return { id: spec[1], name: spec[2], family: spec[0], promising: false, verify: [] }
|
||||
if (!find.promising) return { ...find, verify: [] }
|
||||
return parallel([0, 1, 2].map((kk) => () =>
|
||||
agent(verifyPrompt(spec, find, kk), { label: `verify:${spec[1]}.${kk}`, phase: 'Verify', schema: VERIFY_SCHEMA, effort: 'high' })
|
||||
)).then((votes) => ({ ...find, verify: votes.filter(Boolean) }))
|
||||
}
|
||||
)
|
||||
|
||||
phase('Synthesize')
|
||||
const clean = results.filter(Boolean)
|
||||
const enriched = clean.map((r) => {
|
||||
const v = r.verify || []
|
||||
const realVotes = v.filter((x) => x && x.real).length
|
||||
const survived = r.promising && v.length >= 2 && realVotes >= Math.ceil(v.length / 2)
|
||||
return { ...r, real_votes: realVotes, n_verify: v.length, survived }
|
||||
})
|
||||
const survivors = enriched.filter((r) => r.survived)
|
||||
const killed = enriched.filter((r) => r.promising && !r.survived)
|
||||
log(`Find done: ${clean.length} studied. Promising: ${enriched.filter(r => r.promising).length}. Survived adversarial verify: ${survivors.length}.`)
|
||||
|
||||
const compact = enriched.map((r) => ({
|
||||
id: r.id, name: r.name, family: r.family, universe: r.best_universe, H: r.best_H, k: r.best_k, ls: r.long_short,
|
||||
full: r.full_sharpe, hold: r.holdout_sharpe, dd: r.worst_maxdd, corr_xs01: r.corr_xs01, corr_tp01: r.corr_tp01,
|
||||
marginal: r.marginal_verdict, robust: r.robust_oos, earns_slot: r.earns_slot, promising: r.promising,
|
||||
survived: r.survived, real_votes: r.real_votes, summary: r.summary,
|
||||
verify: (r.verify || []).map((x) => x ? `[real=${x.real} conf=${x.confidence}] ${x.reason}` : '').filter(Boolean),
|
||||
}))
|
||||
|
||||
const SYNTH_SCHEMA = {
|
||||
type: 'object',
|
||||
required: ['headline', 'survivors', 'ranking', 'recommendations', 'dead_families'],
|
||||
properties: {
|
||||
headline: { type: 'string', description: '2-4 sentences: did a NEW cross-sectional mechanism, distinct from XS01 and additive to the live stack, emerge — net of the ~2.5y caveat?' },
|
||||
survivors: { type: 'array', items: { type: 'object', required: ['id', 'name', 'why', 'suggested_role'], properties: {
|
||||
id: { type: 'string' }, name: { type: 'string' }, why: { type: 'string' },
|
||||
suggested_role: { type: 'string', description: 'new sleeve candidate / lead to forward-monitor / needs longer history' },
|
||||
distinct_from_xs01: { type: 'string' } } } },
|
||||
ranking: { type: 'array', items: { type: 'string' } },
|
||||
recommendations: { type: 'string', description: 'concrete: what (if anything) to deep-validate or add, weight, and how to handle the short history' },
|
||||
dead_families: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
}
|
||||
|
||||
const synthPrompt = `You are the SYNTHESIZER for a PythagorasGoal wave that searched ${CATALOG.length} CROSS-SECTIONAL / multi-asset mechanisms on the 51 certified Hyperliquid alts (1d, 2024-2026), then adversarially verified every promising one. This is the frontier the previous BTC/ETH sweep pointed to (single-asset directional is exhausted at the ~1.3 ceiling).
|
||||
|
||||
LIVE stack (do not re-derive): TP01 (TSMOM trend BTC/ETH, defensive), XS01 (cross-sectional MOMENTUM on 19 HL majors, top5/bottom5, blend+dispersion-gate, vol-target — corr ~-0.12 to TP01), VRP01 (modeled options short-vol). A NEW cross-sectional sleeve is only valuable if it is (1) robust despite the SHORT ~2.5y history, (2) DISTINCT from XS01 (corr < 0.6 — not momentum re-skinned), and (3) ADDS to the live active portfolio out-of-sample (marginal uplift + robust_oos jackknife). Honesty is prime: on 2.5 years, be very skeptical; a clean set of negatives is an acceptable outcome.
|
||||
|
||||
Full result table (verify = the skeptics' findings):
|
||||
${JSON.stringify(compact)}
|
||||
|
||||
Survivors (passed adversarial verify): ${JSON.stringify(survivors.map((s) => ({ id: s.id, name: s.name, full: s.full_sharpe, hold: s.holdout_sharpe, corr_xs01: s.corr_xs01, corr_tp01: s.corr_tp01, marginal: s.marginal_verdict, real_votes: s.real_votes })))}
|
||||
Promising-but-killed: ${JSON.stringify(killed.map((s) => ({ id: s.id, name: s.name, why: (s.verify || []).map((v) => v && v.reason).filter(Boolean) })))}
|
||||
|
||||
Produce the synthesis. Be concrete and skeptical about the short history. If a genuinely distinct, additive mechanism survived (e.g. a risk/low-vol anomaly orthogonal to momentum), say what it is, whether it is a sleeve candidate or a lead needing more history, and its correlation profile. If nothing robust survived, say so plainly.`
|
||||
|
||||
const synthesis = await agent(synthPrompt, { schema: SYNTH_SCHEMA, effort: 'high', label: 'synthesize' })
|
||||
|
||||
return {
|
||||
n_studied: clean.length,
|
||||
n_promising: enriched.filter((r) => r.promising).length,
|
||||
n_survived: survivors.length,
|
||||
survivors: survivors.map((s) => ({ id: s.id, name: s.name, family: s.family, full: s.full_sharpe, hold: s.holdout_sharpe, corr_xs01: s.corr_xs01, corr_tp01: s.corr_tp01, marginal: s.marginal_verdict, real_votes: s.real_votes, summary: s.summary })),
|
||||
promising_killed: killed.map((s) => ({ id: s.id, name: s.name })),
|
||||
all_grades: clean.map((r) => ({ id: r.id, name: r.name, full: r.full_sharpe, hold: r.holdout_sharpe, corr_xs01: r.corr_xs01, marginal: r.marginal_verdict, earns_slot: r.earns_slot, promising: r.promising })),
|
||||
synthesis,
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
"""xslib — SHARED CROSS-SECTIONAL research harness over the certified Hyperliquid alt panel.
|
||||
|
||||
Built for the "cerca altre strategie" wave (2026-06-20, follow-up to the 104-hypothesis BTC/ETH
|
||||
sweep that exhausted the single-asset directional space). The frontier the prior synthesis pointed
|
||||
to: CROSS-SECTIONAL / multi-asset mechanisms on the 51 certified Hyperliquid alts (1d, 2024-2026),
|
||||
where the ~1.3 BTC/ETH-directional ceiling does NOT bind, and DISTINCT from XS01 (plain x-sec momentum).
|
||||
|
||||
Why a new harness: the panel is N assets × ~900 days. A strategy = a per-asset SCORE computed
|
||||
causally (data <= close[i]); the harness ranks it cross-sectionally each rebalance, goes long the
|
||||
top-k / short the bottom-k (market-neutral) or long-only top-k, vol-targets, charges fee on turnover,
|
||||
and — crucially — the weight decided at bar i is applied to the return of bar i+1, so look-ahead is
|
||||
structurally impossible (same convention as src.portfolio xs_book / sleeves._xsec_returns).
|
||||
|
||||
A candidate only matters if it (a) is robust (positive FULL + hold-out 2025+ + jackknife), AND
|
||||
(b) is DISTINCT from XS01 (low correlation), AND (c) ADDS to the live TP01+XS01+VRP01 portfolio.
|
||||
CAVEAT baked in: the panel is ~2.5 years — every result is SUGGESTIVE, not robust like 6y BTC/ETH.
|
||||
|
||||
Quick start (agent script):
|
||||
import sys; sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||||
import xslib as xs, numpy as np
|
||||
p = xs.load_panel("all") # or "majors", a list, or an int N (top-N liquidity)
|
||||
score = xs.past_return(p.close, 30) # momentum: higher = long
|
||||
rep = xs.study_xs("MOM30", lambda P: xs.past_return(P.close, 30), H=10, k=5)
|
||||
print(xs.fmt(rep)); print("JSON:", xs.as_json(rep))
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import json
|
||||
import sys
|
||||
import warnings
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
# panel research has many all-NaN edge windows (rolling beta/vol on first rows) -> benign
|
||||
warnings.filterwarnings("ignore", category=RuntimeWarning)
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[3]
|
||||
if str(_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
sys.path.insert(0, str(_ROOT / "scripts" / "research" / "alt"))
|
||||
import altlib as al # noqa: E402 (reuse _sh, _dd_ret, _to_daily, HOLDOUT, metric helpers)
|
||||
|
||||
RAW = _ROOT / "data" / "raw"
|
||||
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
|
||||
FEE = 0.001 # round-trip; charged /2 per side on turnover
|
||||
|
||||
MAJORS = ["BTC", "ETH", "SOL", "BNB", "XRP", "DOGE", "AVAX", "LINK", "LTC", "ADA",
|
||||
"ARB", "OP", "SUI", "APT", "INJ", "TIA", "SEI", "NEAR", "AAVE"]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# PANEL
|
||||
# ===========================================================================
|
||||
@dataclass
|
||||
class Panel:
|
||||
syms: list
|
||||
index: pd.DatetimeIndex
|
||||
close: np.ndarray
|
||||
open: np.ndarray
|
||||
high: np.ndarray
|
||||
low: np.ndarray
|
||||
vol: np.ndarray
|
||||
ret: np.ndarray # daily simple returns, ret[0]=0
|
||||
|
||||
|
||||
@lru_cache(maxsize=16)
|
||||
def load_panel(universe="all", min_rows: int = 700) -> Panel:
|
||||
"""Common-date OHLCV panel of the certified HL alts (1d). `universe`:
|
||||
'all' -> every alt with >= min_rows of history (drops short ones e.g. ALGO/SAND),
|
||||
'majors' -> the 19 XS01 majors, a list of symbols, or an int N (top-N by median $-volume)."""
|
||||
close, vol, high, low, opn = {}, {}, {}, {}, {}
|
||||
for f in sorted(glob.glob(str(RAW / "hl_*_1d.parquet"))):
|
||||
sym = Path(f).stem.replace("hl_", "").replace("_1d", "").upper()
|
||||
d = pd.read_parquet(f)
|
||||
if len(d) < min_rows:
|
||||
continue
|
||||
idx = pd.to_datetime(d["timestamp"], unit="ms", utc=True)
|
||||
close[sym] = pd.Series(d["close"].values.astype(float), index=idx)
|
||||
vol[sym] = pd.Series(d["volume"].values.astype(float), index=idx)
|
||||
high[sym] = pd.Series(d["high"].values.astype(float), index=idx)
|
||||
low[sym] = pd.Series(d["low"].values.astype(float), index=idx)
|
||||
opn[sym] = pd.Series(d["open"].values.astype(float), index=idx)
|
||||
C = pd.concat(close, axis=1, join="inner").sort_index().dropna()
|
||||
syms = list(C.columns)
|
||||
if universe == "majors":
|
||||
syms = [s for s in MAJORS if s in syms]
|
||||
elif isinstance(universe, (list, tuple)):
|
||||
syms = [s for s in universe if s in syms]
|
||||
elif isinstance(universe, int):
|
||||
dollar = {s: float(np.nanmedian(C[s].values * pd.concat(vol, axis=1)[s].reindex(C.index).values))
|
||||
for s in syms}
|
||||
syms = sorted(syms, key=lambda s: -dollar[s])[:universe]
|
||||
C = C[syms]
|
||||
idx = C.index
|
||||
|
||||
def stack(dd):
|
||||
return pd.concat(dd, axis=1).reindex(index=idx)[syms].values.astype(float)
|
||||
cl = C.values
|
||||
ret = np.zeros_like(cl)
|
||||
ret[1:] = cl[1:] / cl[:-1] - 1.0
|
||||
return Panel(syms, idx, cl, stack(opn), stack(high), stack(low), stack(vol), ret)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# CAUSAL CROSS-SECTIONAL HELPERS (value at row i uses data <= i)
|
||||
# ===========================================================================
|
||||
def past_return(close, L):
|
||||
out = np.full_like(close, np.nan)
|
||||
out[L:] = close[L:] / close[:-L] - 1.0
|
||||
return out
|
||||
|
||||
|
||||
def roll_std(mat, win):
|
||||
return pd.DataFrame(mat).rolling(win, min_periods=max(2, win // 2)).std().values
|
||||
|
||||
|
||||
def roll_mean(mat, win):
|
||||
return pd.DataFrame(mat).rolling(win, min_periods=max(2, win // 2)).mean().values
|
||||
|
||||
|
||||
def roll_skew(mat, win):
|
||||
return pd.DataFrame(mat).rolling(win, min_periods=max(3, win // 2)).skew().values
|
||||
|
||||
|
||||
def ewm_mean(mat, span):
|
||||
return pd.DataFrame(mat).ewm(span=span, adjust=False).mean().values
|
||||
|
||||
|
||||
def xs_zscore(mat):
|
||||
"""Cross-sectional z-score per row (across assets). NaN-safe."""
|
||||
m = np.nanmean(mat, axis=1, keepdims=True)
|
||||
s = np.nanstd(mat, axis=1, keepdims=True)
|
||||
return (mat - m) / np.where(s > 0, s, np.nan)
|
||||
|
||||
|
||||
def xs_rank(mat):
|
||||
"""Cross-sectional rank in [0,1] per row (0=lowest)."""
|
||||
out = np.full_like(mat, np.nan, dtype=float)
|
||||
for i in range(mat.shape[0]):
|
||||
row = mat[i]
|
||||
ok = np.isfinite(row)
|
||||
if ok.sum() >= 2:
|
||||
r = pd.Series(row[ok]).rank().values
|
||||
out[i, ok] = (r - 1) / (ok.sum() - 1)
|
||||
return out
|
||||
|
||||
|
||||
def market_ret(ret):
|
||||
"""Equal-weight market return per day (n,)."""
|
||||
return np.nanmean(ret, axis=1)
|
||||
|
||||
|
||||
def roll_beta(ret, win):
|
||||
"""Rolling beta of each asset to the equal-weight market (n,A), causal."""
|
||||
mkt = market_ret(ret)
|
||||
ms = pd.Series(mkt)
|
||||
var = ms.rolling(win, min_periods=max(5, win // 2)).var()
|
||||
out = np.full_like(ret, np.nan)
|
||||
for a in range(ret.shape[1]):
|
||||
cov = pd.Series(ret[:, a]).rolling(win, min_periods=max(5, win // 2)).cov(ms)
|
||||
out[:, a] = (cov / var.replace(0, np.nan)).values
|
||||
return out
|
||||
|
||||
|
||||
def residual_return(ret, win):
|
||||
"""Idiosyncratic daily return = ret - beta*market (beta rolling, causal)."""
|
||||
beta = roll_beta(ret, win)
|
||||
mkt = market_ret(ret)[:, None]
|
||||
return ret - beta * mkt
|
||||
|
||||
|
||||
def volume_z(vol, win):
|
||||
m = roll_mean(vol, win)
|
||||
s = roll_std(vol, win)
|
||||
return (vol - m) / np.where(s > 0, s, np.nan)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# BACKTEST — generic cross-sectional book from a per-asset SCORE matrix.
|
||||
# score[i] (data <= i) -> rank assets -> long top-k / short bottom-k; W[i] earns dret[i+1].
|
||||
# ===========================================================================
|
||||
def xs_backtest(panel: Panel, score, H=10, k=5, long_short=True, target_vol=0.20,
|
||||
fee=FEE, vt_cap=3.0):
|
||||
px = panel.close
|
||||
n, A = px.shape
|
||||
dret = panel.ret
|
||||
score = np.asarray(score, float)
|
||||
if score.shape != (n, A):
|
||||
raise ValueError(f"score shape {score.shape} != panel {(n, A)}")
|
||||
W = np.zeros((n, A))
|
||||
w = np.zeros(A)
|
||||
for i in range(n):
|
||||
if i % H == 0:
|
||||
row = score[i]
|
||||
fin = np.isfinite(row)
|
||||
if fin.sum() >= 2 * k:
|
||||
ranked = np.where(fin, row, -np.inf)
|
||||
order = np.argsort(ranked)
|
||||
order = order[np.isfinite(ranked[order])]
|
||||
lo, hi = order[:k], order[-k:]
|
||||
w = np.zeros(A)
|
||||
if long_short:
|
||||
w[hi] = 0.5 / k
|
||||
w[lo] = -0.5 / k
|
||||
else:
|
||||
w[hi] = 1.0 / k
|
||||
W[i] = w
|
||||
gross = np.zeros(n)
|
||||
gross[1:] = np.sum(W[:-1] * dret[1:], axis=1)
|
||||
turn = np.zeros(n)
|
||||
turn[0] = np.abs(W[0]).sum()
|
||||
turn[1:] = np.abs(np.diff(W, axis=0)).sum(axis=1)
|
||||
net = gross - turn * (fee / 2.0)
|
||||
s = pd.Series(net, index=panel.index)
|
||||
rv = s.rolling(30, min_periods=15).std().shift(1) * np.sqrt(365.25)
|
||||
scale = np.clip(np.nan_to_num(target_vol / rv.replace(0, np.nan).values, nan=0.0), 0, vt_cap)
|
||||
return pd.Series(s.values * scale, index=panel.index)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# BASELINES (live stack) + MARGINAL scoring
|
||||
# ===========================================================================
|
||||
@lru_cache(maxsize=1)
|
||||
def baselines():
|
||||
"""Daily returns of the LIVE stack: TP01, XS01, and the combined active portfolio."""
|
||||
from src.portfolio.portfolio import StrategyPortfolio, to_daily
|
||||
from src.portfolio.sleeves import _tp01_returns, _xsec_returns, active_sleeves
|
||||
tp = to_daily(_tp01_returns())
|
||||
xs01 = to_daily(_xsec_returns())
|
||||
active = StrategyPortfolio(active_sleeves()).combined_daily()
|
||||
return dict(tp01=tp, xs01=xs01, active=active)
|
||||
|
||||
|
||||
def _corr(a, b):
|
||||
J = pd.concat({"a": a, "b": b}, axis=1, join="inner").dropna()
|
||||
return round(float(J["a"].corr(J["b"])), 3) if len(J) > 5 else None
|
||||
|
||||
|
||||
def marginal_vs(cand, base, weights=(0.2, 0.35)):
|
||||
"""Does `cand` improve `base`? blend uplift (full & hold-out), + OOS jackknife robustness."""
|
||||
J = pd.concat({"B": base, "C": cand}, axis=1, join="inner").dropna()
|
||||
if len(J) < 30:
|
||||
return dict(verdict="N/A", reason="overlap < 30d")
|
||||
JH = J[J.index >= HOLDOUT]
|
||||
has_h = len(JH) > 20
|
||||
out = dict(corr=_corr(J["B"], J["C"]), base_full=round(al._sh(J["B"]), 3),
|
||||
base_hold=round(al._sh(JH["B"]), 3) if has_h else None,
|
||||
cand_full=round(al._sh(J["C"]), 3), cand_hold=round(al._sh(JH["C"]), 3) if has_h else None,
|
||||
blends={})
|
||||
for w in weights:
|
||||
bf, bh = (1 - w) * J["B"] + w * J["C"], (1 - w) * JH["B"] + w * JH["C"]
|
||||
out["blends"][f"w{int(w * 100)}"] = dict(
|
||||
uplift_full=round(al._sh(bf) - al._sh(J["B"]), 3),
|
||||
uplift_hold=round(al._sh(bh) - al._sh(JH["B"]), 3) if has_h else None,
|
||||
dd=round(al._dd_ret(bf), 4))
|
||||
# OOS jackknife at w=0.2
|
||||
robust = False
|
||||
cu = jk = None
|
||||
if has_h:
|
||||
def _u(sub):
|
||||
return al._sh(0.8 * sub["B"] + 0.2 * sub["C"]) - al._sh(sub["B"])
|
||||
months = sorted(set(zip(JH.index.year, JH.index.month)))
|
||||
cu = round(_u(JH), 3)
|
||||
jk = round(min(_u(JH[~((JH.index.year == y) & (JH.index.month == m))]) for y, m in months), 3) \
|
||||
if len(months) > 1 else cu
|
||||
robust = bool(cu > 0.02 and jk > 0.0)
|
||||
out["holdout_uplift_w20"] = cu
|
||||
out["jackknife_min_uplift"] = jk
|
||||
out["robust_oos"] = robust
|
||||
up = out["blends"][f"w{int(weights[0] * 100)}"]["uplift_hold"]
|
||||
cc = out["corr"] if out["corr"] is not None else 0.0
|
||||
if cc is not None and cc > 0.85 and (up is None or abs(up) < 0.05):
|
||||
out["verdict"] = "REDUNDANT"
|
||||
elif up is not None and up >= 0.05 and robust:
|
||||
out["verdict"] = "ADDS"
|
||||
elif up is not None and up <= -0.05:
|
||||
out["verdict"] = "DILUTES"
|
||||
else:
|
||||
out["verdict"] = "NEUTRAL"
|
||||
return out
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# DRIVER
|
||||
# ===========================================================================
|
||||
def study_xs(name, score_fn, universe="all", H=10, k=5, long_short=True,
|
||||
target_vol=0.20, min_rows=700) -> dict:
|
||||
"""Backtest one cross-sectional hypothesis and score it honestly:
|
||||
FULL + hold-out 2025+ + yearly, correlation to TP01 & XS01 (distinctness),
|
||||
and marginal contribution to the LIVE active portfolio. `score_fn(panel) -> (n,A)`
|
||||
per-asset score (higher = long), computed CAUSALLY (data <= close[i])."""
|
||||
p = load_panel(universe, min_rows=min_rows)
|
||||
score = score_fn(p)
|
||||
daily = al._to_daily(xs_backtest(p, score, H=H, k=k, long_short=long_short, target_vol=target_vol))
|
||||
net = daily.values
|
||||
idx = daily.index
|
||||
full = al._metrics_from_net(net, idx)
|
||||
hmask = idx >= HOLDOUT
|
||||
hold = al._metrics_from_net(net[hmask], idx[hmask]) if hmask.sum() > 20 else dict(sharpe=0.0, n=int(hmask.sum()))
|
||||
bl = baselines()
|
||||
marg = marginal_vs(daily, bl["active"])
|
||||
earns_slot = (full["sharpe"] > 0 and hold.get("sharpe", 0) > 0
|
||||
and marg.get("verdict") == "ADDS"
|
||||
and (_corr(daily, bl["xs01"]) or 0) < 0.6) # distinct from existing x-sec
|
||||
return dict(
|
||||
name=name, universe=str(universe), H=H, k=k, long_short=long_short,
|
||||
n_assets=len(p.syms), n_days=int(len(idx)),
|
||||
full=full, holdout=hold, yearly=al._yearly(net, idx),
|
||||
corr_tp01=_corr(daily, bl["tp01"]), corr_xs01=_corr(daily, bl["xs01"]),
|
||||
corr_active=_corr(daily, bl["active"]),
|
||||
marginal=marg, earns_slot=earns_slot,
|
||||
caveat="panel ~2.5y (2024-26): suggestive, not robust",
|
||||
)
|
||||
|
||||
|
||||
def _clean(o):
|
||||
if isinstance(o, dict):
|
||||
return {k: _clean(v) for k, v in o.items()}
|
||||
if isinstance(o, (list, tuple)):
|
||||
return [_clean(x) for x in o]
|
||||
if isinstance(o, (np.floating,)):
|
||||
return round(float(o), 4)
|
||||
if isinstance(o, (np.integer,)):
|
||||
return int(o)
|
||||
if isinstance(o, (np.bool_,)):
|
||||
return bool(o)
|
||||
return o
|
||||
|
||||
|
||||
def as_json(rep):
|
||||
return json.dumps(_clean(rep), default=str)
|
||||
|
||||
|
||||
def fmt(rep):
|
||||
m = rep["marginal"]
|
||||
yr = " ".join(f"{y}:{d['ret'] * 100:+.0f}%" for y, d in rep["yearly"].items())
|
||||
return (f"=== {rep['name']} [{rep['universe']} H{rep['H']} k{rep['k']} "
|
||||
f"{'LS' if rep['long_short'] else 'LO'}] EARNS_SLOT={rep['earns_slot']}\n"
|
||||
f" FULL Sh {rep['full']['sharpe']:+.2f} DD {rep['full']['maxdd'] * 100:.0f}% "
|
||||
f"ret {rep['full']['ret'] * 100:+.0f}% | HOLD Sh {rep['holdout'].get('sharpe', 0):+.2f} "
|
||||
f"| corr TP01 {rep['corr_tp01']} XS01 {rep['corr_xs01']}\n"
|
||||
f" marginal vs active: {m.get('verdict')} (corr {m.get('corr')}, "
|
||||
f"holdUplift_w20 {m.get('holdout_uplift_w20')}, jackknife {m.get('jackknife_min_uplift')}, "
|
||||
f"robust_oos {m.get('robust_oos')}) | {yr}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("--- SMOKE TEST xslib ---")
|
||||
# 1) x-sec momentum (should resemble XS01 ballpark) ; 2) short-term reversal ; 3) low-vol
|
||||
print(fmt(study_xs("MOM30-90", lambda P: xs_zscore(past_return(P.close, 30)) + xs_zscore(past_return(P.close, 90)), H=10, k=5)))
|
||||
print(fmt(study_xs("REV5", lambda P: -past_return(P.close, 5), H=5, k=5)))
|
||||
print(fmt(study_xs("LOWVOL", lambda P: -roll_std(P.ret, 30), H=10, k=5)))
|
||||
print("\nJSON sample:", as_json(study_xs("MOM30", lambda P: past_return(P.close, 30)))[:240])
|
||||
Reference in New Issue
Block a user