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))
|
||||
Reference in New Issue
Block a user