From 961256047959d3cc338064708d7a99d35d931059 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Sat, 20 Jun 2026 21:36:57 +0000 Subject: [PATCH] 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) --- .gitignore | 4 + .../diary/2026-06-20-vrp-active-management.md | 43 +++ .../diary/2026-06-20-xsec-strategies-sweep.md | 133 +++++++ scripts/research/options_vrp_managed.py | 164 ++++++++ scripts/research/xsec/runs/XD01.py | 109 ++++++ scripts/research/xsec/runs/XD02.py | 80 ++++ scripts/research/xsec/runs/XD03.py | 136 +++++++ scripts/research/xsec/runs/XL01.py | 114 ++++++ scripts/research/xsec/runs/XL02.py | 118 ++++++ scripts/research/xsec/runs/XL03.py | 93 +++++ scripts/research/xsec/runs/XL04.py | 109 ++++++ scripts/research/xsec/runs/XM01.py | 82 ++++ scripts/research/xsec/runs/XM02.py | 88 +++++ scripts/research/xsec/runs/XM03.py | 98 +++++ scripts/research/xsec/runs/XM04.py | 72 ++++ scripts/research/xsec/runs/XM05.py | 98 +++++ scripts/research/xsec/runs/XM06.py | 79 ++++ scripts/research/xsec/runs/XM07.py | 87 +++++ scripts/research/xsec/runs/XM08.py | 107 ++++++ scripts/research/xsec/runs/XM09.py | 127 +++++++ scripts/research/xsec/runs/XM10.py | 127 +++++++ scripts/research/xsec/runs/XR01.py | 68 ++++ scripts/research/xsec/runs/XR02.py | 171 +++++++++ scripts/research/xsec/runs/XR03.py | 92 +++++ scripts/research/xsec/runs/XR04.py | 101 +++++ scripts/research/xsec/runs/XR05.py | 78 ++++ scripts/research/xsec/runs/XS01b.py | 58 +++ scripts/research/xsec/runs/XS02b.py | 88 +++++ scripts/research/xsec/runs/XS03b.py | 126 ++++++ scripts/research/xsec/runs/XS04b.py | 83 ++++ scripts/research/xsec/runs/XS05b.py | 132 +++++++ scripts/research/xsec/runs/XS06b.py | 88 +++++ scripts/research/xsec/runs/XS07b.py | 85 +++++ scripts/research/xsec/runs/XS08b.py | 125 ++++++ scripts/research/xsec/runs/XU01.py | 102 +++++ scripts/research/xsec/runs/XU02.py | 120 ++++++ scripts/research/xsec/runs/XU03.py | 137 +++++++ scripts/research/xsec/runs/XU04.py | 138 +++++++ scripts/research/xsec/runs/XV01.py | 83 ++++ scripts/research/xsec/runs/XV02.py | 121 ++++++ scripts/research/xsec/runs/XV03.py | 106 ++++++ scripts/research/xsec/runs/XV04.py | 88 +++++ scripts/research/xsec/runs/XV05.py | 101 +++++ scripts/research/xsec/runs/XV06.py | 89 +++++ scripts/research/xsec/runs/XVa1.py | 85 +++++ scripts/research/xsec/runs/XVa2.py | 90 +++++ scripts/research/xsec/runs/XVa3.py | 94 +++++ scripts/research/xsec/verify_survivors.py | 145 +++++++ scripts/research/xsec/wf_xsec.js | 237 ++++++++++++ scripts/research/xsec/xslib.py | 358 ++++++++++++++++++ 50 files changed, 5457 insertions(+) create mode 100644 docs/diary/2026-06-20-vrp-active-management.md create mode 100644 docs/diary/2026-06-20-xsec-strategies-sweep.md create mode 100644 scripts/research/options_vrp_managed.py create mode 100644 scripts/research/xsec/runs/XD01.py create mode 100644 scripts/research/xsec/runs/XD02.py create mode 100644 scripts/research/xsec/runs/XD03.py create mode 100644 scripts/research/xsec/runs/XL01.py create mode 100644 scripts/research/xsec/runs/XL02.py create mode 100644 scripts/research/xsec/runs/XL03.py create mode 100644 scripts/research/xsec/runs/XL04.py create mode 100644 scripts/research/xsec/runs/XM01.py create mode 100644 scripts/research/xsec/runs/XM02.py create mode 100644 scripts/research/xsec/runs/XM03.py create mode 100644 scripts/research/xsec/runs/XM04.py create mode 100644 scripts/research/xsec/runs/XM05.py create mode 100644 scripts/research/xsec/runs/XM06.py create mode 100644 scripts/research/xsec/runs/XM07.py create mode 100644 scripts/research/xsec/runs/XM08.py create mode 100644 scripts/research/xsec/runs/XM09.py create mode 100644 scripts/research/xsec/runs/XM10.py create mode 100644 scripts/research/xsec/runs/XR01.py create mode 100644 scripts/research/xsec/runs/XR02.py create mode 100644 scripts/research/xsec/runs/XR03.py create mode 100644 scripts/research/xsec/runs/XR04.py create mode 100644 scripts/research/xsec/runs/XR05.py create mode 100644 scripts/research/xsec/runs/XS01b.py create mode 100644 scripts/research/xsec/runs/XS02b.py create mode 100644 scripts/research/xsec/runs/XS03b.py create mode 100644 scripts/research/xsec/runs/XS04b.py create mode 100644 scripts/research/xsec/runs/XS05b.py create mode 100644 scripts/research/xsec/runs/XS06b.py create mode 100644 scripts/research/xsec/runs/XS07b.py create mode 100644 scripts/research/xsec/runs/XS08b.py create mode 100644 scripts/research/xsec/runs/XU01.py create mode 100644 scripts/research/xsec/runs/XU02.py create mode 100644 scripts/research/xsec/runs/XU03.py create mode 100644 scripts/research/xsec/runs/XU04.py create mode 100644 scripts/research/xsec/runs/XV01.py create mode 100644 scripts/research/xsec/runs/XV02.py create mode 100644 scripts/research/xsec/runs/XV03.py create mode 100644 scripts/research/xsec/runs/XV04.py create mode 100644 scripts/research/xsec/runs/XV05.py create mode 100644 scripts/research/xsec/runs/XV06.py create mode 100644 scripts/research/xsec/runs/XVa1.py create mode 100644 scripts/research/xsec/runs/XVa2.py create mode 100644 scripts/research/xsec/runs/XVa3.py create mode 100644 scripts/research/xsec/verify_survivors.py create mode 100644 scripts/research/xsec/wf_xsec.js create mode 100644 scripts/research/xsec/xslib.py diff --git a/.gitignore b/.gitignore index df4f1e1..64ff7e0 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,7 @@ logs/ # feed backup pre-rebuild (binari rigenerabili, NON in git) + stato paper trader (runtime) data/_feed_backup/ data/paper_trend/ +data/paper_portfolio/ + +# output grezzo dello sweep di ricerca xsec (rigenerabile dagli script in runs/) +scripts/research/xsec/runs/out/ diff --git a/docs/diary/2026-06-20-vrp-active-management.md b/docs/diary/2026-06-20-vrp-active-management.md new file mode 100644 index 0000000..2f1905d --- /dev/null +++ b/docs/diary/2026-06-20-vrp-active-management.md @@ -0,0 +1,43 @@ +# VRP01 + gestione attiva intra-trade — A/B onesto (NEGATIVO) + +**Data:** 2026-06-20 +**Script:** `scripts/research/options_vrp_managed.py` +**Esito:** la gestione attiva del documento credit-spread **distrugge l'edge**. VRP01 +**hold-to-expiry resta superiore.** → scartata. + +## Cosa testava + +Innesta sul put credit spread di VRP01 le regole intra-trade del doc `strategia-credit-spread-eth`: +profit-take 50% del credito, stop-loss 1.5× il credito, **VOL-STOP** (chiudi se DVOL sale ≥10 punti +dall'apertura — regola crypto-specifica nuova), **delta-exit** (chiudi se |delta| short put ≥0.30), +time-stop 7 DTE. A/B sugli **stessi ingressi gated** (VRP>0 + IV-rank>0.30) e dati certificati; +MTM giornaliero dello spread via BS sul path certificato + DVOL reale (causale). +BASE = hold-to-expiry (come VRP01) vs MANAGED = stesso trade gestito. + +## Risultato (combo 50/50 BTC+ETH, sleeve-level) + +| variante | Sharpe | DD | ret | HOLD Sh | +|----------|--------|------|------|---------| +| 14d hold-to-expiry (BASE) | **0.96** | 11.7% | +39% | +1.52 | +| 14d + solo vol-stop | 0.12 | 10.1% | +3% | +1.01 | +| 14d FULL managed | **−1.29** | 14.8% | −15% | −1.17 | + +Per-asset: la gestione FULL ribalta entrambi (ETH 0.33→−1.15, BTC 1.88→−0.89). Il **delta-exit** +domina le uscite (18-25 trade su ~33-45) e taglia i vincenti prima della decadenza theta; persino +il **vol-stop da solo** quasi azzera il ritorno (combo Sh 0.12). Win-rate crolla 80-94% → ~40%. + +## Lettura + +Per un venditore di premio short-vol l'edge È la decadenza theta tenuta fino a scadenza: ogni +uscita anticipata (delta, vol-stop, PT) **monetizza meno theta e/o realizza la coda** invece di +lasciarla riassorbire. Le regole di "difesa" del doc azionario/ETH non trasferiscono al VRP crypto +modellato: l'unica gestione che non danneggia è **non gestire** (hold-to-expiry, come VRP01 già fa). + +**Caveat invariato:** premio MODELLATO su DVOL ATM (no skew) + nessun fill di stress reale → tutto +ciò resta a livello di LEAD, non deploy. Ma la conclusione relativa (BASE > MANAGED) è robusta +perché è un A/B sugli **stessi** trade e dati. + +## Azione + +Nessuna modifica a VRP01 (`sleeves._vrp_combo_returns`, hold-to-expiry). Script conservato come +riferimento dell'esperimento scartato. diff --git a/docs/diary/2026-06-20-xsec-strategies-sweep.md b/docs/diary/2026-06-20-xsec-strategies-sweep.md new file mode 100644 index 0000000..16d260a --- /dev/null +++ b/docs/diary/2026-06-20-xsec-strategies-sweep.md @@ -0,0 +1,133 @@ +# Sweep strategie cross-sectional su Hyperliquid (xsec) — 43 script / 257 config + +**Data:** 2026-06-20 +**Harness:** `scripts/research/xsec/xslib.py` (nuovo) + 43 script in `scripts/research/xsec/runs/` +**Verifica:** `scripts/research/xsec/verify_survivors.py` (3 scettici, deterministico) +**Esito in una riga:** niente di deployabile; il cluster vincente appariscente è **una sola +scommessa di regime (short alt-beta)**, ma **2 lead genuini** (XM09 trend-gated x-sec momentum, +XR02 reversal vol-gated) sopravvivono a tutti gli scettici → **forward-monitor, non sleeve.** + +## Contesto e motivazione + +Dopo che il sweep BTC/ETH a 104 ipotesi (`2026-06-20-alt-strategies-100agent-sweep.md`) ha +esaurito lo spazio direzionale single-asset confermando il soffitto ~1.3, la frontiera indicata era +**cross-sectional / multi-asset** sul panel Hyperliquid certificato, dove quel soffitto non vincola +e dove c'è spazio DISTINTO da XS01 (x-sec momentum semplice sui 19 major). + +Nuova harness condivisa `xslib.py`: il panel è N asset × ~810 giorni (universo `all` = **49 alt** +con ≥700g dopo il fix backfill; `majors` = 19 di XS01). Una strategia = uno **score per-asset +causale** (dati ≤ close[i]); l'harness lo classifica cross-section ad ogni ribilanciamento, va long +i top-k / short i bottom-k (market-neutral) o long-only, vol-targeta al 20%, addebita fee sul +turnover, e — strutturalmente leak-free — il peso deciso a `i` incassa il return di `i+1` (stessa +convenzione di `src.portfolio` xs_book / `sleeves._xsec_returns`). + +**Scoring onesto** (`study_xs`): un candidato guadagna `earns_slot=True` SOLO se +`full Sharpe>0 AND hold-out 2025+ Sharpe>0 AND marginal_vs(active)=="ADDS" AND corr(XS01)<0.6`. +`ADDS` a sua volta richiede `holdUplift_w20 ≥ 0.05 AND robust_oos` (uplift hold-out >0.02 **e** +jackknife drop-one-month tutti positivi). È il marginal scorer del sweep precedente, portato sul +cross-sectional: si giudica **l'apporto al portafoglio live** (TP01+XS01+VRP01), non lo Sharpe +assoluto. + +**Caveat cotto dentro l'harness:** il panel è **~2.5 anni** (2024-26). Ogni risultato è +SUGGESTIVO, non robusto come i 6 anni di BTC/ETH. E l'hold-out (2025-26) è **un singolo regime** +(alt-bear/chop relativo a BTC). + +## Find phase — 43 script, 257 sotto-config + +11 famiglie cross-sectional: MOM (varianti momentum), REV (reversal), VOL/RISK (low-vol, low-beta, +BAB, semivarianza, vol-of-vol), DIST (skew/coskew lottery), LIQ (Amihud/turnover/volume), +VAL (distanza da MA, RSI), STRUCT (double-sort, ensemble z-vote, risk-parity, low-corr, trend-R², +lead-lag BTC), UNIV (sweep di universo). **Esito: 42/257 config `earns_slot=True`.** + +Sembra molto. Ma **due tell** accomunano quasi tutti gli slot-earner: +1. corr a TP01 **fortemente negativa** (−0.2…−0.4) — è *per questo* che "aggiungono"; +2. PnL **concentrato nel 2025** (ritorni +22%…+84% nel 2025). + +Top per Sharpe/uplift (rappresentante per famiglia): + +| id | meccanismo | univ | FULL Sh | HOLD Sh | upliftHold | jackknife | corr TP01 | corr XS01 | +|----|-----------|------|---------|---------|-----------|-----------|-----------|-----------| +| XR02-L3-p70-maj | reversal gated alta-vol | maj | 1.40 | **2.27** | 1.078 | 0.744 | 0.02 | 0.08 | +| XV02_majors_H10k5 | low **idio**-vol | maj | 1.32 | 1.95 | 1.196 | 0.792 | −0.20 | −0.06 | +| XL02-vz60r20-maj | vol-trend momentum | maj | **1.83** | 1.84 | 0.568 | 0.125 | 0.13 | 0.08 | +| XM09_all | trend-gated x-sec mom | all | 1.29 | 1.59 | 0.556 | 0.355 | −0.07 | 0.25 | +| XS01b-MAJ | double-sort mom×low-vol | maj | 1.36 | 1.23 | 0.427 | 0.16 | −0.29 | 0.38 | +| XU02/XV01 lowvol | low realized-vol | maj | 1.05 | 0.98 | 0.425 | 0.186 | −0.34 | 0.16 | +| XV03 lowbeta (BAB) | −beta | all | 0.36 | 0.71 | 0.22 | 0.051 | −0.38 | 0.19 | +| XS06b lowcorr | −corr(asset,market) | all | 0.74 | 1.00 | 0.286 | 0.092 | −0.19 | 0.18 | + +## Verify phase — 3 scettici (`verify_survivors.py`) + +Ipotesi sotto test: *"non sono N edge indipendenti, ma UNA scommessa di regime — short la +spazzatura high-beta nell'alt-bear 2024-26 — travestita da 30 maschere; il jackknife è robusto solo +DENTRO quel regime."* Ricostruito il book più forte per famiglia e: + +**S1 — matrice di correlazione mutua (>0.6 = stessa scommessa).** Esito SFUMATO: +- Il cluster low-vol È una sola scommessa: **XV01 = XU02 = 1.00** (identici), XV01↔XV02 0.65, + XV01↔XV03 0.67, XV02↔XV03 0.44. +- MA **XM09, XL02, XS06b, XR02 sono distinti** dal cluster e tra loro (corr media off-diagonale + solo **+0.20**, solo 18% delle coppie |r|>0.6). L'ipotesi "tutto una scommessa" è **parzialmente + falsa**. + +**S2 — carico su short-beta / short-market** (factor di riferimento sullo stesso panel: +SHORTBETA = book su −beta; SHORTMKT = −market alt equal-weight): +- **Cluster low-vol = short-alt-beta confermato:** XV03 1.00/0.70, XV01/XU02 **0.67/0.64**, + XV02 0.44/0.37. *Non* market-neutral: è un tilt short del mercato alt. +- **NON short-beta:** XM09 0.08/0.15, XR02 −0.21/−0.18, XL02 0.19/0.26, XS06b 0.36/0.39. + +**S3 — Sharpe per anno solare (l'edge è ~solo 2025?):** + +| survivor | 2024 | 2025 | 2026 | +|----------|------|------|------| +| XV02_lowidiovol | 0.07 | 1.87 | 2.12 | +| XV01/XU02 lowvol | 1.17 | 1.52 | **−0.09** | +| XV03_lowbeta | −0.25 | 0.98 | 0.12 | +| XS06b_lowcorr | 0.26 | 1.34 | 0.32 | +| **XM09_trendgmom** | **0.82** | **0.50** | **0.74** | +| XL02_voltrendmom | 0.30 | **−0.14** | **−0.43** | +| **XR02_revgated** | **0.84** | **0.40** | **2.68** | + +## Conclusioni (oneste) + +1. **Cluster low-vol / low-beta (XV01, XU02, XV02 in parte, XV03) = tilt short-alt-beta di regime.** + S2 lo inchioda (carico 0.44-0.70 su short-market): non è un fattore market-neutral, è "short la + spazzatura" mentre gli alt sanguinano vs BTC. XV01/XU02 **già in decadimento (2026 −0.09).** Non + può dimostrare di sopravvivere a un flip alt-bull. → **RIGETTATO come sleeve.** Conferma + l'osservazione 4874 (XS04b = regime-dependent short-beta tilt) generalizzata all'intera famiglia. + +2. **XL02 (vol-trend momentum) = overfit al panel iniziale.** FULL Sharpe più alto (1.83) ma S3 lo + uccide: 2025 −0.14, 2026 −0.43. Il numero full è guidato dal 2024, ora è morto. → **RIGETTATO.** + +3. **2 LEAD genuini** — distinti (S1), NON short-beta (S2), positivi in **tutti e 3 gli anni** (S3): + - **XM09 — cross-sectional momentum gated dal trend di mercato.** Long top-k/short bottom-k alt, + attivo solo quando la somma trailing del mercato equal-weight è >0. Sharpe 0.82/0.50/0.74, + short-beta-load 0.08, corr TP01 −0.07, uplift hold 0.556 / jackknife 0.355. È il candidato più + regime-robusto. **Caveat:** stessa FAMIGLIA di XS01 (x-sec momentum) su universo più largo (49) + con gate diverso (trend di mercato vs dispersione) → più un **possibile affinamento di XS01** + che una sleeve nuova; corr XS01 0.25, ma marginal scorer dice che ADDS oltre XS01. + - **XR02 — short-term reversal gated da alta-vol.** Reversal a 3g attivo solo quando la vol + realizzata di mercato è nel regime alto (>p70 espandente). Sharpe 0.84/0.40/**2.68**, + short-beta-load −0.21, corr a tutto il resto ~0/negativa, hold-out Sharpe 2.27. Microstruttura + reale (overreaction in panico). **Caveat:** H=3 → **turnover alto**; il reversal vive proprio + sull'illiquidità che lo rende costoso da eseguire (l'harness addebita fee sul turnover e regge, + ma il fill reale su alt minori è ottimistico). + +## Perché NON deployabili adesso (caveat trasversali) + +- **Panel ~2.5 anni a regime unico.** Anche i 2 lead hanno hold-out = 2025-26 = stesso macro-regime. + Suggestivi, non robusti come i 6 anni BTC/ETH. +- **STAT-MODE di esecuzione.** Un book cross-sectional a 10-19 gambe (long-k+short-k) su alt non è + eseguibile col capitale attuale (conto reale ~$600; servono ~$20k per gambe sensate, come già + notato per XS01). Sono segnali da monitorare, non ordini. +- **Lezione confermata (di nuovo):** su un panel corto a regime unico il jackknife drop-one-month + certifica la robustezza DENTRO il regime, non ATTRAVERSO i regimi. Il discriminante decisivo è + stato **S2 (carico su short-beta) + S3 (consistenza per-anno)**, non lo Sharpe né l'uplift + hold-out (che il cluster regime-bet aveva altissimi: upliftHold fino a 1.20). + +## Azioni + +- **Nessuna modifica al portafoglio live** (TP01 55% + XS01 25% + VRP01 20% invariato). +- **Forward-monitor** i 2 lead (XM09, XR02) quando il panel HL accumula un secondo regime. +- **XM09 come affinamento candidato di XS01** (gate trend di mercato + universo 49) da valutare a + parità di sleeve, NON come sleeve aggiuntiva, in una prossima iterazione. +- Harness `xslib.py` + 43 script + `verify_survivors.py` committati come riferimento riusabile. diff --git a/scripts/research/options_vrp_managed.py b/scripts/research/options_vrp_managed.py new file mode 100644 index 0000000..27b959d --- /dev/null +++ b/scripts/research/options_vrp_managed.py @@ -0,0 +1,164 @@ +"""VRP01 + GESTIONE ATTIVA (test del doc 'strategia-credit-spread-eth', 2026-06-20). + +Innesta sul put credit spread di VRP01 le regole di gestione intra-trade del documento: + - profit-take 50% del credito + - stop-loss stretto 1.5x il credito (debito di chiusura) + - VOL-STOP: chiudi se DVOL sale >=10 punti dall'apertura (regola crypto-specifica, NUOVA) + - delta-exit: chiudi se |delta| dello short put >= 0.30 (niente rolling/difesa) + - time-stop 7 DTE +Confronto A/B ONESTO sugli STESSI ingressi gated (VRP>0 + IV-rank>0.30) e dati certificati: + BASE = hold-to-expiry (come VRP01) vs MANAGED = stesso trade con la gestione attiva. +Il MTM giornaliero dello spread usa BS sul path certificato + DVOL reale (causale: decisione al +giorno j con dati <= j). CAVEAT invariato: premio MODELLATO su DVOL ATM (no skew), nessun fill di +stress reale -> LEAD, non deploy. Qui misuriamo solo SE la gestione attiva taglia la coda. + + uv run python scripts/research/options_vrp_managed.py +""" +from __future__ import annotations +import sys +from pathlib import Path + +import numpy as np +import pandas as pd +from scipy.stats import norm + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from src.data.downloader import load_data +from src.strategies.trend_portfolio import resample_1d +from src.portfolio.portfolio import to_daily, metrics, HOLDOUT +from src.portfolio.sleeves import _bs_put, _strike_from_delta, VRP_CFG, _HL_DIR + +CFG = dict(VRP_CFG) # short_delta -0.28, long_delta -0.10, f 1.0, gate_ivr 0.30, crash_skip 0.90, fee_frac 0.125 + + +def _put_delta_mag(S, K, T, sig): + if T <= 0 or sig <= 0: + return 1.0 if S < K else 0.0 + d1 = (np.log(S / K) + 0.5 * sig ** 2 * T) / (sig * np.sqrt(T)) + return float(norm.cdf(-d1)) # |delta| dello short put (=N(-d1)) + + +def simulate(asset: str, tenor_d: int, mode: str = "hte"): + """mode: 'hte' hold-to-expiry | 'full' tutte le regole | 'volstop' solo vol-stop DVOL+10 (+PT50). + Ritorna (serie rendimenti per-trade indicizzata alla data di uscita, dict conteggio exit).""" + manage = mode != "hte" + full = mode == "full" + df = resample_1d(load_data(asset, "1h")) + s = pd.Series(df["close"].values.astype(float), index=pd.to_datetime(df["datetime"])) + if s.index.tz is None: + s.index = s.index.tz_localize("UTC") + dv = pd.read_parquet(_HL_DIR / f"dvol_{asset.lower()}.parquet") + d = pd.Series(dv["close"].values.astype(float), index=pd.to_datetime(dv["timestamp"], unit="ms", utc=True)) + J = pd.concat({"px": s, "dvol": d}, axis=1, join="inner").sort_index().dropna() + px = J["px"].values + dvf = J["dvol"].values / 100.0 + idx = J.index + n = len(px) + tn = tenor_d + f, fee = CFG["f"], CFG["fee_frac"] + rets, exits = {}, {} + i = 60 + while i + tn < n: + S0, sig0 = px[i], dvf[i] + # --- gates d'ingresso identici a VRP01 (causali) --- + skip = False + if i >= 31: + rv = np.std(np.diff(np.log(px[i - 30:i + 1]))) * np.sqrt(365.25) + if (sig0 - rv) <= 0: # VRP>0 + skip = True + if not skip and i >= 60: + ivr = float((dvf[:i] < dvf[i]).mean()) # IV-rank espandente causale + if ivr < CFG["gate_ivr"] or ivr > CFG["crash_skip"]: + skip = True + if skip: + i += tn + continue + T0 = tn / 365.25 + Ks = _strike_from_delta(S0, T0, sig0, CFG["short_delta"]) + Kl = _strike_from_delta(S0, T0, sig0, CFG["long_delta"]) + net_prem = (_bs_put(S0, Ks, T0, sig0) - _bs_put(S0, Kl, T0, sig0)) * f + if net_prem <= 0: + i += tn + continue + reason, pnl, exit_j = None, None, i + tn + if manage: + for j in range(i + 1, i + tn): # giorni STRETTAMENTE prima della scadenza + Trem = (i + tn - j) / 365.25 + Sj, sigj = px[j], dvf[j] + sval = _bs_put(Sj, Ks, Trem, sigj) - _bs_put(Sj, Kl, Trem, sigj) # MTM dello spread + if sval <= 0.5 * net_prem: + reason, pnl, exit_j = "PT50", net_prem - sval, j; break + if (sigj - sig0) >= 0.10: # VOL-STOP (la regola crypto nuova del doc) + reason, pnl, exit_j = "VOLSTOP", net_prem - sval, j; break + if full and sval >= 1.5 * net_prem: + reason, pnl, exit_j = "SL150", net_prem - sval, j; break + if full and _put_delta_mag(Sj, Ks, Trem, sigj) >= 0.30: + reason, pnl, exit_j = "DELTA", net_prem - sval, j; break + if full and (i + tn - j) <= 7: + reason, pnl, exit_j = "TIME7", net_prem - sval, j; break + if reason is None: # scadenza + S1 = px[i + tn] + payoff = max(0.0, Ks - S1) - max(0.0, Kl - S1) + pnl, reason, exit_j = net_prem - payoff, "expiry", i + tn + pnl -= fee * abs(net_prem) # fee d'ingresso (su entrambe le gambe via net_prem) + if reason != "expiry": + pnl -= fee * abs(net_prem) # fee di chiusura anticipata (ricompro lo spread) + rets[idx[exit_j]] = pnl / Ks + exits[reason] = exits.get(reason, 0) + 1 + i += tn + return pd.Series(rets).sort_index(), exits + + +def daily(series): + if series.empty: + return series + days = pd.date_range(series.index.min().normalize(), series.index.max().normalize(), freq="1D", tz="UTC") + out = pd.Series(0.0, index=days) + out.loc[series.index.normalize()] = series.values + return out + + +def report(label, perTrade): + dl = to_daily(daily(perTrade)) + m = metrics(dl) + mh = metrics(dl[dl.index >= HOLDOUT]) + wins = float((perTrade > 0).mean()) * 100 + worst = float(perTrade.min()) * 100 + print(f" {label:<22s} n={len(perTrade):>3d} win={wins:>4.0f}% ret={m['ret']*100:>+6.0f}% " + f"Sh={m['sharpe']:>5.2f} DD={m['maxdd']*100:>4.1f}% HOLD Sh={mh['sharpe']:>+5.2f} " + f"worst-trade={worst:>+5.1f}%") + return dl + + +def main(): + print("=" * 100) + print(" VRP01 hold-to-expiry vs GESTIONE ATTIVA (vol-stop DVOL+10, SL 1.5x, PT50, delta-exit, 7DTE)") + print(" Stessi ingressi gated (VRP>0 + IV-rank>0.30), dati certificati, premio MODELLATO su DVOL (no skew)") + print("=" * 100) + combos = {} + for asset in ("ETH", "BTC"): + print(f"\n--- {asset} ---") + report("VRP01 live (7d HtE)", simulate(asset, 7, "hte")[0]) # riferimento live + # confronto equo a tenor 14 (range del doc), STESSI ingressi + b14, _ = simulate(asset, 14, "hte") + v14, exv = simulate(asset, 14, "volstop") # SOLO vol-stop (la regola nuova) + m14, exm = simulate(asset, 14, "full") # tutte le regole del doc + report("14d hold-to-expiry", b14) + report("14d +vol-stop only", v14); print(f" exit volstop: {exv}") + report("14d FULL managed", m14); print(f" exit full: {exm}") + combos[asset] = dict(base14=daily(b14), vol14=daily(v14), man14=daily(m14)) + + # combo 50/50 BTC+ETH (come lo sleeve VRP01) — il confronto che conta per il portafoglio + print("\n--- COMBO 50/50 BTC+ETH (sleeve-level) ---") + for tag, key in (("14d hold-to-expiry", "base14"), ("14d +vol-stop only", "vol14"), ("14d FULL managed", "man14")): + J = pd.concat({"B": combos["BTC"][key], "E": combos["ETH"][key]}, axis=1, join="outer").fillna(0.0) + combo = to_daily(0.5 * J["B"] + 0.5 * J["E"]) + m, mh = metrics(combo), metrics(combo[combo.index >= HOLDOUT]) + print(f" {tag:<22s} Sh={m['sharpe']:>5.2f} DD={m['maxdd']*100:>4.1f}% ret={m['ret']*100:>+6.0f}% " + f"HOLD Sh={mh['sharpe']:>+5.2f}") + print("\n Lettura: la gestione attiva VALE se taglia maxDD e worst-trade SENZA distruggere Sharpe/ritorno.") + print(" Caveat invariato: premio modellato su DVOL ATM (no skew) + nessun fill di stress reale -> LEAD, non deploy.") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/xsec/runs/XD01.py b/scripts/research/xsec/runs/XD01.py new file mode 100644 index 0000000..5158be5 --- /dev/null +++ b/scripts/research/xsec/runs/XD01.py @@ -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)) diff --git a/scripts/research/xsec/runs/XD02.py b/scripts/research/xsec/runs/XD02.py new file mode 100644 index 0000000..a185f89 --- /dev/null +++ b/scripts/research/xsec/runs/XD02.py @@ -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)) diff --git a/scripts/research/xsec/runs/XD03.py b/scripts/research/xsec/runs/XD03.py new file mode 100644 index 0000000..ab79eb5 --- /dev/null +++ b/scripts/research/xsec/runs/XD03.py @@ -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)) diff --git a/scripts/research/xsec/runs/XL01.py b/scripts/research/xsec/runs/XL01.py new file mode 100644 index 0000000..918ecc9 --- /dev/null +++ b/scripts/research/xsec/runs/XL01.py @@ -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)) diff --git a/scripts/research/xsec/runs/XL02.py b/scripts/research/xsec/runs/XL02.py new file mode 100644 index 0000000..26dcb82 --- /dev/null +++ b/scripts/research/xsec/runs/XL02.py @@ -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)) diff --git a/scripts/research/xsec/runs/XL03.py b/scripts/research/xsec/runs/XL03.py new file mode 100644 index 0000000..aaecf5c --- /dev/null +++ b/scripts/research/xsec/runs/XL03.py @@ -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)) diff --git a/scripts/research/xsec/runs/XL04.py b/scripts/research/xsec/runs/XL04.py new file mode 100644 index 0000000..112a604 --- /dev/null +++ b/scripts/research/xsec/runs/XL04.py @@ -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)) diff --git a/scripts/research/xsec/runs/XM01.py b/scripts/research/xsec/runs/XM01.py new file mode 100644 index 0000000..386a83d --- /dev/null +++ b/scripts/research/xsec/runs/XM01.py @@ -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)) diff --git a/scripts/research/xsec/runs/XM02.py b/scripts/research/xsec/runs/XM02.py new file mode 100644 index 0000000..3f56a5e --- /dev/null +++ b/scripts/research/xsec/runs/XM02.py @@ -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)) diff --git a/scripts/research/xsec/runs/XM03.py b/scripts/research/xsec/runs/XM03.py new file mode 100644 index 0000000..393feae --- /dev/null +++ b/scripts/research/xsec/runs/XM03.py @@ -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)) diff --git a/scripts/research/xsec/runs/XM04.py b/scripts/research/xsec/runs/XM04.py new file mode 100644 index 0000000..f04ad41 --- /dev/null +++ b/scripts/research/xsec/runs/XM04.py @@ -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)) diff --git a/scripts/research/xsec/runs/XM05.py b/scripts/research/xsec/runs/XM05.py new file mode 100644 index 0000000..f86af47 --- /dev/null +++ b/scripts/research/xsec/runs/XM05.py @@ -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)) diff --git a/scripts/research/xsec/runs/XM06.py b/scripts/research/xsec/runs/XM06.py new file mode 100644 index 0000000..82888a3 --- /dev/null +++ b/scripts/research/xsec/runs/XM06.py @@ -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)) diff --git a/scripts/research/xsec/runs/XM07.py b/scripts/research/xsec/runs/XM07.py new file mode 100644 index 0000000..bc25c90 --- /dev/null +++ b/scripts/research/xsec/runs/XM07.py @@ -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)) diff --git a/scripts/research/xsec/runs/XM08.py b/scripts/research/xsec/runs/XM08.py new file mode 100644 index 0000000..c376810 --- /dev/null +++ b/scripts/research/xsec/runs/XM08.py @@ -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)) diff --git a/scripts/research/xsec/runs/XM09.py b/scripts/research/xsec/runs/XM09.py new file mode 100644 index 0000000..9c5b21a --- /dev/null +++ b/scripts/research/xsec/runs/XM09.py @@ -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)) diff --git a/scripts/research/xsec/runs/XM10.py b/scripts/research/xsec/runs/XM10.py new file mode 100644 index 0000000..fe7199b --- /dev/null +++ b/scripts/research/xsec/runs/XM10.py @@ -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)) diff --git a/scripts/research/xsec/runs/XR01.py b/scripts/research/xsec/runs/XR01.py new file mode 100644 index 0000000..025d3d7 --- /dev/null +++ b/scripts/research/xsec/runs/XR01.py @@ -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)) diff --git a/scripts/research/xsec/runs/XR02.py b/scripts/research/xsec/runs/XR02.py new file mode 100644 index 0000000..74d028e --- /dev/null +++ b/scripts/research/xsec/runs/XR02.py @@ -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)) diff --git a/scripts/research/xsec/runs/XR03.py b/scripts/research/xsec/runs/XR03.py new file mode 100644 index 0000000..75b7e01 --- /dev/null +++ b/scripts/research/xsec/runs/XR03.py @@ -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)) diff --git a/scripts/research/xsec/runs/XR04.py b/scripts/research/xsec/runs/XR04.py new file mode 100644 index 0000000..cd8f956 --- /dev/null +++ b/scripts/research/xsec/runs/XR04.py @@ -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)) diff --git a/scripts/research/xsec/runs/XR05.py b/scripts/research/xsec/runs/XR05.py new file mode 100644 index 0000000..b29f748 --- /dev/null +++ b/scripts/research/xsec/runs/XR05.py @@ -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)) diff --git a/scripts/research/xsec/runs/XS01b.py b/scripts/research/xsec/runs/XS01b.py new file mode 100644 index 0000000..f274f66 --- /dev/null +++ b/scripts/research/xsec/runs/XS01b.py @@ -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)) diff --git a/scripts/research/xsec/runs/XS02b.py b/scripts/research/xsec/runs/XS02b.py new file mode 100644 index 0000000..449754d --- /dev/null +++ b/scripts/research/xsec/runs/XS02b.py @@ -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)) diff --git a/scripts/research/xsec/runs/XS03b.py b/scripts/research/xsec/runs/XS03b.py new file mode 100644 index 0000000..b525cc7 --- /dev/null +++ b/scripts/research/xsec/runs/XS03b.py @@ -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)) diff --git a/scripts/research/xsec/runs/XS04b.py b/scripts/research/xsec/runs/XS04b.py new file mode 100644 index 0000000..c79c53e --- /dev/null +++ b/scripts/research/xsec/runs/XS04b.py @@ -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)) diff --git a/scripts/research/xsec/runs/XS05b.py b/scripts/research/xsec/runs/XS05b.py new file mode 100644 index 0000000..e3f276a --- /dev/null +++ b/scripts/research/xsec/runs/XS05b.py @@ -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}") diff --git a/scripts/research/xsec/runs/XS06b.py b/scripts/research/xsec/runs/XS06b.py new file mode 100644 index 0000000..4081a67 --- /dev/null +++ b/scripts/research/xsec/runs/XS06b.py @@ -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)) diff --git a/scripts/research/xsec/runs/XS07b.py b/scripts/research/xsec/runs/XS07b.py new file mode 100644 index 0000000..1c99bd7 --- /dev/null +++ b/scripts/research/xsec/runs/XS07b.py @@ -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)) diff --git a/scripts/research/xsec/runs/XS08b.py b/scripts/research/xsec/runs/XS08b.py new file mode 100644 index 0000000..a3ec87e --- /dev/null +++ b/scripts/research/xsec/runs/XS08b.py @@ -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)) diff --git a/scripts/research/xsec/runs/XU01.py b/scripts/research/xsec/runs/XU01.py new file mode 100644 index 0000000..2672a2c --- /dev/null +++ b/scripts/research/xsec/runs/XU01.py @@ -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)) diff --git a/scripts/research/xsec/runs/XU02.py b/scripts/research/xsec/runs/XU02.py new file mode 100644 index 0000000..979c17a --- /dev/null +++ b/scripts/research/xsec/runs/XU02.py @@ -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)) diff --git a/scripts/research/xsec/runs/XU03.py b/scripts/research/xsec/runs/XU03.py new file mode 100644 index 0000000..536fe97 --- /dev/null +++ b/scripts/research/xsec/runs/XU03.py @@ -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)) diff --git a/scripts/research/xsec/runs/XU04.py b/scripts/research/xsec/runs/XU04.py new file mode 100644 index 0000000..be147f1 --- /dev/null +++ b/scripts/research/xsec/runs/XU04.py @@ -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)) diff --git a/scripts/research/xsec/runs/XV01.py b/scripts/research/xsec/runs/XV01.py new file mode 100644 index 0000000..bfdce42 --- /dev/null +++ b/scripts/research/xsec/runs/XV01.py @@ -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)) diff --git a/scripts/research/xsec/runs/XV02.py b/scripts/research/xsec/runs/XV02.py new file mode 100644 index 0000000..5ef7b57 --- /dev/null +++ b/scripts/research/xsec/runs/XV02.py @@ -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)) diff --git a/scripts/research/xsec/runs/XV03.py b/scripts/research/xsec/runs/XV03.py new file mode 100644 index 0000000..4930e6c --- /dev/null +++ b/scripts/research/xsec/runs/XV03.py @@ -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)) diff --git a/scripts/research/xsec/runs/XV04.py b/scripts/research/xsec/runs/XV04.py new file mode 100644 index 0000000..433915f --- /dev/null +++ b/scripts/research/xsec/runs/XV04.py @@ -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)) diff --git a/scripts/research/xsec/runs/XV05.py b/scripts/research/xsec/runs/XV05.py new file mode 100644 index 0000000..fa79e24 --- /dev/null +++ b/scripts/research/xsec/runs/XV05.py @@ -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)) diff --git a/scripts/research/xsec/runs/XV06.py b/scripts/research/xsec/runs/XV06.py new file mode 100644 index 0000000..59e42de --- /dev/null +++ b/scripts/research/xsec/runs/XV06.py @@ -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)) diff --git a/scripts/research/xsec/runs/XVa1.py b/scripts/research/xsec/runs/XVa1.py new file mode 100644 index 0000000..fd89ef9 --- /dev/null +++ b/scripts/research/xsec/runs/XVa1.py @@ -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)) diff --git a/scripts/research/xsec/runs/XVa2.py b/scripts/research/xsec/runs/XVa2.py new file mode 100644 index 0000000..7d07e18 --- /dev/null +++ b/scripts/research/xsec/runs/XVa2.py @@ -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)) diff --git a/scripts/research/xsec/runs/XVa3.py b/scripts/research/xsec/runs/XVa3.py new file mode 100644 index 0000000..55e670c --- /dev/null +++ b/scripts/research/xsec/runs/XVa3.py @@ -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)) diff --git a/scripts/research/xsec/verify_survivors.py b/scripts/research/xsec/verify_survivors.py new file mode 100644 index 0000000..0412660 --- /dev/null +++ b/scripts/research/xsec/verify_survivors.py @@ -0,0 +1,145 @@ +"""verify_survivors — adversarial 'Verify' phase for the xsec sweep (2026-06-20). + +The Find phase flagged 42/257 cross-sectional configs as earns_slot=True on the certified +Hyperliquid panel. ALL the slot-earners share two tells: (a) strongly NEGATIVE corr to TP01 +(-0.2..-0.4), (b) PnL concentrated in 2025. Hypothesis under test (the only thing that matters +before promoting any of them to a sleeve): + + "These are not N independent edges. They are ONE regime bet — short the high-beta alt junk + during the 2024-26 alt-bear — wearing many masks (low-vol, low-beta, low-corr, reversal, + trend-gated-mom). The drop-one-month jackknife is robust only WITHIN that single regime." + +Three skeptics, deterministic (no agents): + S1 (distinctness/redundancy): mutual correlation matrix of the strongest survivor per family. + If they're all mutually >0.6 correlated -> one bet, not many. + S2 (short-beta tell): correlation of each survivor to two reference factors built on the SAME + panel: SHORTBETA = book ranking by -roll_beta; SHORTMKT = -equal-weight alt-market return. + A genuinely market-neutral factor should NOT load heavily on "short the market". + S3 (single-regime): per-calendar-year Sharpe. If the edge is ~entirely 2025, the 2.5y panel + has ONE up-for-the-factor regime and the hold-out (2025-26) cannot prove robustness. + +Run: uv run python scripts/research/xsec/verify_survivors.py +""" +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import xslib as xs # noqa: E402 +import altlib as al # noqa: E402 (via xslib sys.path) + + +def roll_corr_to_market(ret, win): + """Rolling corr of each asset's return to the equal-weight market (causal).""" + mkt = pd.Series(xs.market_ret(ret)) + out = np.full_like(ret, np.nan) + for a in range(ret.shape[1]): + out[:, a] = pd.Series(ret[:, a]).rolling(win, min_periods=max(5, win // 2)).corr(mkt).values + return out + + +def book(universe, score_fn, H=10, k=5, long_short=True): + p = xs.load_panel(universe) + return xs.xs_backtest(p, score_fn(p), H=H, k=k, long_short=long_short) + + +# ── strongest representative survivor per family (from the Find-phase output) ── +SURV = { + "XV02_lowidiovol": lambda: book("majors", lambda P: -xs.roll_std(xs.residual_return(P.ret, 60), 30)), + "XV01_lowvol": lambda: book("majors", lambda P: -xs.roll_std(P.ret, 30)), + "XV03_lowbeta": lambda: book("all", lambda P: -xs.roll_beta(P.ret, 60)), + "XS06b_lowcorr": lambda: book("all", lambda P: -roll_corr_to_market(P.ret, 60)), + "XU02_lowvol_maj": lambda: book("majors", lambda P: -xs.roll_std(P.ret, 30), k=5), + "XM09_trendgmom": lambda: book("all", lambda P: _trend_gated_mom(P, 60)), + "XL02_voltrendmom": lambda: book("majors", lambda P: xs.xs_zscore(xs.past_return(P.close, 60)) + xs.xs_zscore(xs.volume_z(P.vol, 30))), + "XR02_revgated": lambda: book("majors", lambda P: _vol_gated_rev(P, 3), H=3), +} + + +def _trend_gated_mom(P, L): + """XS momentum, but zeroed on days the equal-weight market trailing-sum is non-positive.""" + s = xs.past_return(P.close, L) + mkt = xs.market_ret(P.ret) + up = pd.Series(mkt).rolling(L, min_periods=L // 2).sum().values > 0 + out = s.copy() + out[~up, :] = np.nan # flat (no ranking) when market not trending up + return out + + +def _vol_gated_rev(P, L): + """Short-term reversal, active only when market realized vol is in its high regime.""" + rev = -xs.past_return(P.close, L) + mvol = pd.Series(xs.market_ret(P.ret)).rolling(20, min_periods=10).std() + thr = mvol.expanding(min_periods=60).quantile(0.70).values + hi = (mvol.values > thr) + out = rev.copy() + out[~hi, :] = np.nan + return out + + +# reference factors (the suspected single underlying bet) +REF = { + "SHORTBETA": lambda: book("all", lambda P: -xs.roll_beta(P.ret, 60)), # explicit short-high-beta + "SHORTMKT": None, # -equal-weight alt market +} + + +def main(): + print("=" * 96) + print(" ADVERSARIAL VERIFY — are the xsec survivors one regime bet (short alt-beta) or N edges?") + print("=" * 96) + + series = {n: al._to_daily(f()) for n, f in SURV.items()} + + # SHORTMKT reference = negative equal-weight alt-market daily return (vol-targeted like a book) + p_all = xs.load_panel("all") + mkt = pd.Series(-xs.market_ret(p_all.ret), index=p_all.index) + series["SHORTBETA"] = al._to_daily(book("all", lambda P: -xs.roll_beta(P.ret, 60))) + series["SHORTMKT"] = al._to_daily(mkt) + + df = pd.DataFrame(series).dropna(how="all") + + names = list(SURV.keys()) + # ── S1: mutual correlation matrix ───────────────────────────────────────── + print("\n[S1] Mutual correlation matrix of survivors (>0.6 = same bet):") + C = df[names].corr() + hdr = " " + " ".join(f"{n[:8]:>8s}" for n in names) + print(hdr) + for n in names: + row = " ".join(f"{C.loc[n, m]:>8.2f}" for m in names) + print(f" {n:<16s} {row}") + iu = [(a, b) for i, a in enumerate(names) for b in names[i + 1:]] + pair_corrs = [C.loc[a, b] for a, b in iu] + print(f" --> mean off-diagonal corr = {np.mean(pair_corrs):+.2f} " + f"(share |r|>0.6: {np.mean([abs(x) > 0.6 for x in pair_corrs]) * 100:.0f}%)") + + # ── S2: load on short-beta / short-market ───────────────────────────────── + print("\n[S2] Correlation of each survivor to the suspected single bet:") + print(f" {'survivor':<18s} {'corr_SHORTBETA':>15s} {'corr_SHORTMKT':>15s}") + for n in names: + cb = df[n].corr(df["SHORTBETA"]) + cm = df[n].corr(df["SHORTMKT"]) + print(f" {n:<18s} {cb:>15.2f} {cm:>15.2f}") + + # ── S3: per-year Sharpe (single-regime test) ────────────────────────────── + print("\n[S3] Per-calendar-year Sharpe (is the edge ~entirely 2025?):") + print(f" {'survivor':<18s} {'2024':>8s} {'2025':>8s} {'2026':>8s}") + for n in names: + s = df[n].dropna() + cells = [] + for y in (2024, 2025, 2026): + sy = s[s.index.year == y] + cells.append(f"{al._sh(sy):>8.2f}" if len(sy) > 20 else f"{'--':>8s}") + print(f" {n:<18s} " + " ".join(cells)) + + print("\n" + "=" * 96) + print(" VERDICT logic: high mutual corr + high SHORTBETA/SHORTMKT load + 2025-only Sharpe") + print(" => one short-alt-beta regime bet on a single-regime 2.5y panel. LEAD/forward-monitor,") + print(" NOT a sleeve (cannot prove it survives an alt-bull regime flip).") + print("=" * 96) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/xsec/wf_xsec.js b/scripts/research/xsec/wf_xsec.js new file mode 100644 index 0000000..4534567 --- /dev/null +++ b/scripts/research/xsec/wf_xsec.js @@ -0,0 +1,237 @@ +export const meta = { + name: 'xsec-strategies-hyperliquid', + description: 'Search NEW cross-sectional / multi-asset strategies on the 51 certified Hyperliquid alts, distinct from XS01: honest backtest each, verify, score marginal vs the live TP01+XS01+VRP01 stack', + phases: [ + { title: 'Find', detail: 'one agent per cross-sectional mechanism via shared xslib' }, + { title: 'Verify', detail: '3 adversarial skeptics per promising finding (overfit/distinctness/short-history)' }, + { title: 'Synthesize', detail: 'rank survivors, marginal contribution to the live portfolio' }, + ], +} + +// fam | id | name | kind hint | idea +const CATALOG = [ + // --- MOM: cross-sectional momentum variants --- + ['MOM','XM01','Single-L momentum sweep','Score = past_return(close,L); long top-k/short bottom-k. Grid L in {20,30,60,90,120}. Try universe "all","majors",top20. (Known: momentum on full 49-universe is NEGATIVE — confirm; majors is XS01 turf.)'], + ['MOM','XM02','Multi-L z-blend momentum','Score = mean of xs_zscore(past_return(close,L)) over L in {30,90} (and try {20,60,120}). Like XS01 blend. Compare "all" vs "majors".'], + ['MOM','XM03','Vol-scaled (risk-adj) momentum','Score = past_return(close,L) / roll_std(ret,L). Risk-adjusted momentum (Sharpe-like). Grid L in {30,60,90}.'], + ['MOM','XM04','Residual / idiosyncratic momentum','Score = cumulative residual_return(ret, win) over last L (beta-removed momentum). Cleaner than raw momentum? win=60, L in {30,60}.'], + ['MOM','XM05','Momentum acceleration','Score = past_return(close,L_short) - past_return(close,L_long) (is momentum accelerating). L_short=20,L_long=60.'], + ['MOM','XM06','52-day-high proximity','Score = close / rolling_max(high,W) (closeness to recent high). W in {60,90}.'], + ['MOM','XM07','Sharpe-rank momentum','Score = roll_mean(ret,L) / roll_std(ret,L). Rank by realized Sharpe. L in {30,60,90}.'], + ['MOM','XM08','Momentum consistency (frog-in-pan)','Score = past_return(close,L) * fraction_of_up_days(ret,L) (smooth momentum beats jumpy). L=60.'], + ['MOM','XM09','Market-trend-gated momentum','XS momentum but only ACTIVE when the equal-weight market (market_ret) is in an uptrend (trailing sum>0); else flat. L=60.'], + ['MOM','XM10','Rank-weighted continuous momentum','Instead of top-k/bottom-k, weight ALL assets by demeaned xs_rank(past_return) (continuous book). Implement weights yourself via a fine score + large k≈A/2, or note xslib top-k is the proxy. L=60.'], + // --- REV: reversal --- + ['REV','XR01','Short-term reversal','Score = -past_return(close,L) (long losers/short winners). Grid L in {1,3,5,7}. (Known smoke: REV5 negative — confirm/diagnose.)'], + ['REV','XR02','Reversal gated by high-vol regime','Short-term reversal active only when market vol is high (panic) else flat. L=3.'], + ['REV','XR03','Residual short-term reversal','Score = -(sum of residual_return over last L). Idiosyncratic reversal (beta-removed). L in {3,5}.'], + ['REV','XR04','Volume-shock reversal','Long recent losers that ALSO had a volume spike (volume_z high): score = -past_return*(volume_z>1). L=3.'], + ['REV','XR05','Overreaction reversal (mid-horizon)','Score = -past_return(close,L) for L in {20,30} (mean-reversion of multi-week moves).'], + // --- VOL/RISK anomalies (the frontier) --- + ['VOL','XV01','Low realized-vol anomaly','Score = -roll_std(ret,W) (long low-vol / short high-vol alts). Grid W in {20,30,60}, universe all/majors/top20, long-short AND long-only. (Smoke: ADDS — verify hard.)'], + ['VOL','XV02','Low idiosyncratic-vol anomaly','Score = -roll_std(residual_return(ret,60), 30) (low idio vol). Distinct from total vol?'], + ['VOL','XV03','Low-beta anomaly (BAB)','Score = -roll_beta(ret,60) (long low-beta / short high-beta). Betting-against-beta.'], + ['VOL','XV04','Low downside-vol / semivariance','Score = -roll_std(min(ret,0), W) (only downside dispersion). W=30.'], + ['VOL','XV05','Low max-drawdown anomaly','Score = -rolling_maxdrawdown(close,W) (prefer smooth equity). W=60.'], + ['VOL','XV06','Low vol-of-vol','Score = -roll_std(roll_std(ret,10), 30). Stability of volatility.'], + // --- DIST: distribution shape --- + ['DIST','XD01','Low-skew / anti-lottery','Score = -roll_skew(ret,60) (short high-skew lottery alts, long low-skew). Lottery-preference premium.'], + ['DIST','XD02','High-skew momentum (opposite)','Score = +roll_skew(ret,60). Test the OTHER sign (does positive skew pay in crypto?).'], + ['DIST','XD03','Coskewness with market','Rank by rolling coskewness of asset returns with market; long low-coskew. win=60.'], + // --- LIQ: volume / liquidity --- + ['LIQ','XL01','Amihud illiquidity premium','Score = mean(|ret| / (close*volume)) over W (illiquidity). Long illiquid? Test both signs. W=30.'], + ['LIQ','XL02','Volume-trend momentum','Score = volume_z(vol,30) combined with positive return (rising-volume winners). '], + ['LIQ','XL03','Low-turnover anomaly','Score = -roll_mean(close*volume, 30) (long low dollar-volume names). Test sign.'], + ['LIQ','XL04','Dollar-volume momentum','Score = past_return of dollar-volume (assets gaining liquidity/attention). W=30.'], + // --- VAL: value / mean-reversion to anchor --- + ['VAL','XVa1','Distance-from-MA value','Score = -(close/roll_mean(close,W) - 1) (long the ones furthest BELOW their MA = cheap). W in {60,100}.'], + ['VAL','XVa2','Cross-sectional RSI reversal','Compute RSI(14) per asset (use al.rsi per column); score = -RSI (long oversold). '], + ['VAL','XVa3','Price-to-high value','Score = -(close / rolling_max(close,W)) (long the most beaten-down vs their high). W=90.'], + // --- STRUCT: structure / combos / construction --- + ['STRUCT','XS01b','Double-sort momentum × low-vol','Score = xs_zscore(past_return(close,60)) + xs_zscore(-roll_std(ret,30)). Combine momentum and low-vol.'], + ['STRUCT','XS02b','Long-mom + short-rev multi-horizon','Score = xs_zscore(past_return(close,90)) + xs_zscore(-past_return(close,5)). Long-term winners that dipped short-term.'], + ['STRUCT','XS03b','Beta-hedged momentum','XS momentum book but subtract market beta exposure (score=residual momentum; or note xslib book is already ~dollar-neutral). Compare net vs market-hedged.'], + ['STRUCT','XS04b','Ensemble z-vote','Score = mean of xs_zscore over {momentum90, -vol30, -skew60, -beta60}. Diversified cross-sectional signal.'], + ['STRUCT','XS05b','Risk-parity legs (inverse-vol)','Momentum selection but weight legs by inverse own-vol (approximate via score = past_return and rely on xslib; document the limitation). L=60.'], + ['STRUCT','XS06b','Correlation-to-market diversifier','Score = -rolling_corr(asset_ret, market_ret, 60) (long alts least correlated to the pack). win=60.'], + ['STRUCT','XS07b','Trend-quality (R^2) ranking','Score = R^2 of a linear fit of log price over last W (smooth trenders). Long high-R2-up. W=60.'], + ['STRUCT','XS08b','Lead-lag vs BTC','Score = past_return(close,L) of alts conditional on BTC having risen (alts that lag BTC catch up). L=10.'], + // --- UNIV: universe / rebalance sensitivity (same core signal, vary the frame) --- + ['UNIV','XU01','Momentum universe sweep','Best momentum z-blend, run on universe in {majors, top20, top30, all}. Where does x-sec momentum live? (Maps the small-cap dilution.)'], + ['UNIV','XU02','Rebalance/holding sweep','Low-vol or momentum with H in {5,10,20,30} and k in {3,5,8}. Turnover vs signal decay.'], + ['UNIV','XU03','Long-only top-k (alt selection)','Low-vol / momentum LONG-ONLY top-k (captures alt-beta + selection, executable at small capital unlike the 38-leg book). Note: NOT market-neutral.'], + ['UNIV','XU04','Liquidity-filtered momentum','Momentum but only on the top-20 by median dollar-volume (avoid illiquid noise). Compare to "all".'], +] + +const ROOT = '/opt/docker/PythagorasGoal' +const CHEAT = `SHARED LIB (built & validated): ${ROOT}/scripts/research/xsec/xslib.py +Top of your script: import sys; sys.path.insert(0, "${ROOT}/scripts/research/xsec"); import xslib as xs; import numpy as np +PANEL: xs.load_panel(universe) -> Panel(.syms, .index, .close, .open, .high, .low, .vol, .ret) all numpy (n_days x n_assets). + universe: "all" (49 alts, >=700d), "majors" (19 XS01 majors), a list of syms, or an int N (top-N by $-volume). + Certified Hyperliquid 1d, 2024-2026 (~900 days). DVOL not here (that's BTC/ETH only). +CAUSAL HELPERS (value at row i uses data <= i): xs.past_return(close,L), xs.roll_std/roll_mean/roll_skew/ewm_mean(mat,win), + xs.xs_zscore(mat) (cross-sectional z per row), xs.xs_rank(mat), xs.market_ret(ret), xs.roll_beta(ret,win), + xs.residual_return(ret,win) (idiosyncratic), xs.volume_z(vol,win). For per-asset TA (RSI etc.) loop columns with altlib (sys.path has it: import altlib as al; al.rsi(col)). +BACKTEST/EVAL (no look-ahead: weight at bar i earns return of bar i+1 — built in): + xs.study_xs("NAME", lambda P: score_matrix(P), universe="all", H=10, k=5, long_short=True, target_vol=0.20) + score_matrix(P) -> np.ndarray (n_days x n_assets), HIGHER = long. Ranked cross-sectionally each H days; + long top-k / short bottom-k (long_short) or long-only top-k. Vol-targeted, fee 0.10% RT on turnover. + Returns {name,universe,H,k,long_short,n_assets,n_days, full:{sharpe,maxdd,ret,cagr}, holdout:{sharpe,...} (2025+), + yearly, corr_tp01, corr_xs01, corr_active, marginal:{verdict(ADDS/REDUNDANT/DILUTES/NEUTRAL),corr, + holdout_uplift_w20, jackknife_min_uplift, robust_oos}, earns_slot}. + PRINT xs.fmt(rep) and print("JSON:", xs.as_json(rep)). +THE BAR: a finding matters only if it is (1) positive FULL & hold-out 2025+, (2) DISTINCT from XS01 (corr_xs01 < 0.6 — + else it is just XS01), (3) marginal verdict ADDS to the live portfolio with robust_oos=True (survives the OOS jackknife). + earns_slot encodes exactly this. HONESTY: the panel is ~2.5 YEARS -> every result is SUGGESTIVE, not robust; a single + good config or one lucky quarter is NOT an edge. Report negatives plainly (most cross-sectional signals will fail here).` + +function finderPrompt([fam, id, name, idea]) { + return `You are studying ONE cross-sectional / multi-asset trading mechanism on the certified Hyperliquid alt panel for PythagorasGoal. Goal of the wave: find something DISTINCT from the existing XS01 (plain cross-sectional momentum) that ADDS to the live TP01+XS01+VRP01 portfolio. Implement honestly with the shared library, backtest, report STRUCTURED results. + +MECHANISM ${id} [${fam}] — ${name} +IDEA: ${idea} + +CHEATSHEET +${CHEAT} + +STEPS +1. Write ${ROOT}/scripts/research/xsec/runs/${id}.py: import xslib as xs, implement the score_matrix CAUSALLY, try a SMALL grid (<=5 study_xs calls total: vary universe / H / k / long_short / a param), pick the BEST config by marginal robustness (prefer earns_slot, then hold-out, then distinctness from XS01). Print xs.fmt(rep)+"JSON:"+xs.as_json(rep) for the best. +2. Run: cd ${ROOT} && uv run python scripts/research/xsec/runs/${id}.py (fix NaN/shape errors and re-run until it produces numbers). +3. Fill the schema from your BEST config, HONESTLY. promising=true ONLY if earns_slot is true OR (full>0 AND hold-out>0 AND corr_xs01<0.6 AND marginal verdict is ADDS). Remember the ~2.5y caveat — be skeptical. + +CONSTRAINTS: keep <=5 backtests (each scans ~49 assets x 900 days). Score matrices must be (n_days x n_assets), higher=long, causal. Don't fabricate — every number from a real run. +Your final message IS the schema (data row), not prose.` +} + +const FIND_SCHEMA = { + type: 'object', + required: ['id','name','family','implemented','best_universe','best_H','best_k','long_short','full_sharpe','holdout_sharpe','worst_maxdd','corr_xs01','corr_tp01','marginal_verdict','robust_oos','earns_slot','promising','summary'], + properties: { + id: { type: 'string' }, name: { type: 'string' }, family: { type: 'string' }, + implemented: { type: 'boolean' }, + best_universe: { type: 'string' }, best_H: { type: 'number' }, best_k: { type: 'number' }, + long_short: { type: 'boolean' }, + full_sharpe: { type: 'number' }, holdout_sharpe: { type: 'number' }, + worst_maxdd: { type: 'number' }, + corr_xs01: { type: 'number', description: 'correlation to existing XS01 (must be <0.6 to be distinct)' }, + corr_tp01: { type: 'number' }, + marginal_verdict: { type: 'string', enum: ['ADDS','REDUNDANT','DILUTES','NEUTRAL','N/A'] }, + holdout_uplift_w20: { type: 'number' }, + robust_oos: { type: 'boolean', description: 'survives the OOS drop-best-month jackknife' }, + earns_slot: { type: 'boolean' }, + promising: { type: 'boolean' }, + summary: { type: 'string' }, + caveats: { type: 'string' }, + script_path: { type: 'string' }, + }, +} + +function verifyPrompt(spec, find, kk) { + const [fam, id, name] = spec + const angles = [ + 'OVERFIT TO 2.5y / SHORT-HISTORY: the panel is only 2024-2026 with a ~1.5y hold-out. Re-run the best config and its neighbors (other universe/H/k). Is the edge a plateau or one lucky cell? Split the hold-out: is it carried by ONE quarter or the partial-2026 stub? Re-check jackknife (drop-best-month). Default real=false if it leans on a short window or single config.', + 'DISTINCTNESS FROM XS01 & LEAK: is corr_xs01 really < 0.6, or is this XS01 in disguise (same momentum signal re-skinned)? Read xslib to confirm the score is causal (no future bar in rolling/beta/residual; weight at i applies to i+1). Confirm the mechanism is economically DIFFERENT from cross-sectional momentum. Default real=false if redundant with XS01 or leaky.', + 'MARGINAL & EXECUTABILITY: re-verify it ADDS to the LIVE active portfolio (marginal uplift hold-out positive AND robust_oos) — not just standalone-positive. Is the book executable (a 10-leg market-neutral alt book needs ~20k capital; a long-only top-k is lighter)? Is turnover/fee realistic? For volume/illiquidity signals, are they an artifact of thin alts? Default real=false if it does not robustly improve the live stack.', + ] + return `You are an ADVERSARIAL SKEPTIC (#${kk + 1}) for PythagorasGoal. A finder claims cross-sectional mechanism ${id} [${fam}] "${name}" is promising on the Hyperliquid alt panel. REFUTE it — this project was wrecked once by fake edges, and here the history is only ~2.5 years so the overfit risk is HIGH. Assume false-positive until proven otherwise. + +FINDER'S CLAIM: +${JSON.stringify(find)} +Run script: ${find.script_path || ROOT + '/scripts/research/xsec/runs/' + id + '.py'} +Trusted leak-free lib: ${ROOT}/scripts/research/xsec/xslib.py + +YOUR ANGLE: ${angles[kk % 3]} + +Read the script, run your own checks (cd ${ROOT} && uv run python ...), quote the numbers you produce, and decide. Default to real=false when uncertain. Return ONLY the schema.` +} + +const VERIFY_SCHEMA = { + type: 'object', + required: ['id','real','confidence','reason'], + properties: { + id: { type: 'string' }, + real: { type: 'boolean', description: 'true only if the edge survives your adversarial check AND robustly adds to the live stack' }, + confidence: { type: 'number' }, + overfit_short_history: { type: 'boolean' }, + redundant_with_xs01: { type: 'boolean' }, + leak_suspected: { type: 'boolean' }, + corrected_full_sharpe: { type: 'number' }, + corrected_holdout_sharpe: { type: 'number' }, + reason: { type: 'string', description: 'specific, with numbers you produced' }, + }, +} + +// =========================================================================== +phase('Find') +log(`Searching ${CATALOG.length} cross-sectional mechanisms on the 51-alt Hyperliquid panel, one agent each. Frontier: distinct from XS01, additive to the live stack.`) + +const results = await pipeline( + CATALOG, + (spec) => agent(finderPrompt(spec), { label: `find:${spec[1]}`, phase: 'Find', schema: FIND_SCHEMA, model: 'sonnet', effort: 'medium' }), + (find, spec) => { + if (!find) return { id: spec[1], name: spec[2], family: spec[0], promising: false, verify: [] } + if (!find.promising) return { ...find, verify: [] } + return parallel([0, 1, 2].map((kk) => () => + agent(verifyPrompt(spec, find, kk), { label: `verify:${spec[1]}.${kk}`, phase: 'Verify', schema: VERIFY_SCHEMA, effort: 'high' }) + )).then((votes) => ({ ...find, verify: votes.filter(Boolean) })) + } +) + +phase('Synthesize') +const clean = results.filter(Boolean) +const enriched = clean.map((r) => { + const v = r.verify || [] + const realVotes = v.filter((x) => x && x.real).length + const survived = r.promising && v.length >= 2 && realVotes >= Math.ceil(v.length / 2) + return { ...r, real_votes: realVotes, n_verify: v.length, survived } +}) +const survivors = enriched.filter((r) => r.survived) +const killed = enriched.filter((r) => r.promising && !r.survived) +log(`Find done: ${clean.length} studied. Promising: ${enriched.filter(r => r.promising).length}. Survived adversarial verify: ${survivors.length}.`) + +const compact = enriched.map((r) => ({ + id: r.id, name: r.name, family: r.family, universe: r.best_universe, H: r.best_H, k: r.best_k, ls: r.long_short, + full: r.full_sharpe, hold: r.holdout_sharpe, dd: r.worst_maxdd, corr_xs01: r.corr_xs01, corr_tp01: r.corr_tp01, + marginal: r.marginal_verdict, robust: r.robust_oos, earns_slot: r.earns_slot, promising: r.promising, + survived: r.survived, real_votes: r.real_votes, summary: r.summary, + verify: (r.verify || []).map((x) => x ? `[real=${x.real} conf=${x.confidence}] ${x.reason}` : '').filter(Boolean), +})) + +const SYNTH_SCHEMA = { + type: 'object', + required: ['headline', 'survivors', 'ranking', 'recommendations', 'dead_families'], + properties: { + headline: { type: 'string', description: '2-4 sentences: did a NEW cross-sectional mechanism, distinct from XS01 and additive to the live stack, emerge — net of the ~2.5y caveat?' }, + survivors: { type: 'array', items: { type: 'object', required: ['id', 'name', 'why', 'suggested_role'], properties: { + id: { type: 'string' }, name: { type: 'string' }, why: { type: 'string' }, + suggested_role: { type: 'string', description: 'new sleeve candidate / lead to forward-monitor / needs longer history' }, + distinct_from_xs01: { type: 'string' } } } }, + ranking: { type: 'array', items: { type: 'string' } }, + recommendations: { type: 'string', description: 'concrete: what (if anything) to deep-validate or add, weight, and how to handle the short history' }, + dead_families: { type: 'array', items: { type: 'string' } }, + }, +} + +const synthPrompt = `You are the SYNTHESIZER for a PythagorasGoal wave that searched ${CATALOG.length} CROSS-SECTIONAL / multi-asset mechanisms on the 51 certified Hyperliquid alts (1d, 2024-2026), then adversarially verified every promising one. This is the frontier the previous BTC/ETH sweep pointed to (single-asset directional is exhausted at the ~1.3 ceiling). + +LIVE stack (do not re-derive): TP01 (TSMOM trend BTC/ETH, defensive), XS01 (cross-sectional MOMENTUM on 19 HL majors, top5/bottom5, blend+dispersion-gate, vol-target — corr ~-0.12 to TP01), VRP01 (modeled options short-vol). A NEW cross-sectional sleeve is only valuable if it is (1) robust despite the SHORT ~2.5y history, (2) DISTINCT from XS01 (corr < 0.6 — not momentum re-skinned), and (3) ADDS to the live active portfolio out-of-sample (marginal uplift + robust_oos jackknife). Honesty is prime: on 2.5 years, be very skeptical; a clean set of negatives is an acceptable outcome. + +Full result table (verify = the skeptics' findings): +${JSON.stringify(compact)} + +Survivors (passed adversarial verify): ${JSON.stringify(survivors.map((s) => ({ id: s.id, name: s.name, full: s.full_sharpe, hold: s.holdout_sharpe, corr_xs01: s.corr_xs01, corr_tp01: s.corr_tp01, marginal: s.marginal_verdict, real_votes: s.real_votes })))} +Promising-but-killed: ${JSON.stringify(killed.map((s) => ({ id: s.id, name: s.name, why: (s.verify || []).map((v) => v && v.reason).filter(Boolean) })))} + +Produce the synthesis. Be concrete and skeptical about the short history. If a genuinely distinct, additive mechanism survived (e.g. a risk/low-vol anomaly orthogonal to momentum), say what it is, whether it is a sleeve candidate or a lead needing more history, and its correlation profile. If nothing robust survived, say so plainly.` + +const synthesis = await agent(synthPrompt, { schema: SYNTH_SCHEMA, effort: 'high', label: 'synthesize' }) + +return { + n_studied: clean.length, + n_promising: enriched.filter((r) => r.promising).length, + n_survived: survivors.length, + survivors: survivors.map((s) => ({ id: s.id, name: s.name, family: s.family, full: s.full_sharpe, hold: s.holdout_sharpe, corr_xs01: s.corr_xs01, corr_tp01: s.corr_tp01, marginal: s.marginal_verdict, real_votes: s.real_votes, summary: s.summary })), + promising_killed: killed.map((s) => ({ id: s.id, name: s.name })), + all_grades: clean.map((r) => ({ id: r.id, name: r.name, full: r.full_sharpe, hold: r.holdout_sharpe, corr_xs01: r.corr_xs01, marginal: r.marginal_verdict, earns_slot: r.earns_slot, promising: r.promising })), + synthesis, +} diff --git a/scripts/research/xsec/xslib.py b/scripts/research/xsec/xslib.py new file mode 100644 index 0000000..a60b906 --- /dev/null +++ b/scripts/research/xsec/xslib.py @@ -0,0 +1,358 @@ +"""xslib — SHARED CROSS-SECTIONAL research harness over the certified Hyperliquid alt panel. + +Built for the "cerca altre strategie" wave (2026-06-20, follow-up to the 104-hypothesis BTC/ETH +sweep that exhausted the single-asset directional space). The frontier the prior synthesis pointed +to: CROSS-SECTIONAL / multi-asset mechanisms on the 51 certified Hyperliquid alts (1d, 2024-2026), +where the ~1.3 BTC/ETH-directional ceiling does NOT bind, and DISTINCT from XS01 (plain x-sec momentum). + +Why a new harness: the panel is N assets × ~900 days. A strategy = a per-asset SCORE computed +causally (data <= close[i]); the harness ranks it cross-sectionally each rebalance, goes long the +top-k / short the bottom-k (market-neutral) or long-only top-k, vol-targets, charges fee on turnover, +and — crucially — the weight decided at bar i is applied to the return of bar i+1, so look-ahead is +structurally impossible (same convention as src.portfolio xs_book / sleeves._xsec_returns). + +A candidate only matters if it (a) is robust (positive FULL + hold-out 2025+ + jackknife), AND +(b) is DISTINCT from XS01 (low correlation), AND (c) ADDS to the live TP01+XS01+VRP01 portfolio. +CAVEAT baked in: the panel is ~2.5 years — every result is SUGGESTIVE, not robust like 6y BTC/ETH. + +Quick start (agent script): + import sys; sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec") + import xslib as xs, numpy as np + p = xs.load_panel("all") # or "majors", a list, or an int N (top-N liquidity) + score = xs.past_return(p.close, 30) # momentum: higher = long + rep = xs.study_xs("MOM30", lambda P: xs.past_return(P.close, 30), H=10, k=5) + print(xs.fmt(rep)); print("JSON:", xs.as_json(rep)) +""" +from __future__ import annotations + +import glob +import json +import sys +import warnings +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path + +import numpy as np +import pandas as pd + +# panel research has many all-NaN edge windows (rolling beta/vol on first rows) -> benign +warnings.filterwarnings("ignore", category=RuntimeWarning) + +_ROOT = Path(__file__).resolve().parents[3] +if str(_ROOT) not in sys.path: + sys.path.insert(0, str(_ROOT)) +sys.path.insert(0, str(_ROOT / "scripts" / "research" / "alt")) +import altlib as al # noqa: E402 (reuse _sh, _dd_ret, _to_daily, HOLDOUT, metric helpers) + +RAW = _ROOT / "data" / "raw" +HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC") +FEE = 0.001 # round-trip; charged /2 per side on turnover + +MAJORS = ["BTC", "ETH", "SOL", "BNB", "XRP", "DOGE", "AVAX", "LINK", "LTC", "ADA", + "ARB", "OP", "SUI", "APT", "INJ", "TIA", "SEI", "NEAR", "AAVE"] + + +# =========================================================================== +# PANEL +# =========================================================================== +@dataclass +class Panel: + syms: list + index: pd.DatetimeIndex + close: np.ndarray + open: np.ndarray + high: np.ndarray + low: np.ndarray + vol: np.ndarray + ret: np.ndarray # daily simple returns, ret[0]=0 + + +@lru_cache(maxsize=16) +def load_panel(universe="all", min_rows: int = 700) -> Panel: + """Common-date OHLCV panel of the certified HL alts (1d). `universe`: + 'all' -> every alt with >= min_rows of history (drops short ones e.g. ALGO/SAND), + 'majors' -> the 19 XS01 majors, a list of symbols, or an int N (top-N by median $-volume).""" + close, vol, high, low, opn = {}, {}, {}, {}, {} + for f in sorted(glob.glob(str(RAW / "hl_*_1d.parquet"))): + sym = Path(f).stem.replace("hl_", "").replace("_1d", "").upper() + d = pd.read_parquet(f) + if len(d) < min_rows: + continue + idx = pd.to_datetime(d["timestamp"], unit="ms", utc=True) + close[sym] = pd.Series(d["close"].values.astype(float), index=idx) + vol[sym] = pd.Series(d["volume"].values.astype(float), index=idx) + high[sym] = pd.Series(d["high"].values.astype(float), index=idx) + low[sym] = pd.Series(d["low"].values.astype(float), index=idx) + opn[sym] = pd.Series(d["open"].values.astype(float), index=idx) + C = pd.concat(close, axis=1, join="inner").sort_index().dropna() + syms = list(C.columns) + if universe == "majors": + syms = [s for s in MAJORS if s in syms] + elif isinstance(universe, (list, tuple)): + syms = [s for s in universe if s in syms] + elif isinstance(universe, int): + dollar = {s: float(np.nanmedian(C[s].values * pd.concat(vol, axis=1)[s].reindex(C.index).values)) + for s in syms} + syms = sorted(syms, key=lambda s: -dollar[s])[:universe] + C = C[syms] + idx = C.index + + def stack(dd): + return pd.concat(dd, axis=1).reindex(index=idx)[syms].values.astype(float) + cl = C.values + ret = np.zeros_like(cl) + ret[1:] = cl[1:] / cl[:-1] - 1.0 + return Panel(syms, idx, cl, stack(opn), stack(high), stack(low), stack(vol), ret) + + +# =========================================================================== +# CAUSAL CROSS-SECTIONAL HELPERS (value at row i uses data <= i) +# =========================================================================== +def past_return(close, L): + out = np.full_like(close, np.nan) + out[L:] = close[L:] / close[:-L] - 1.0 + return out + + +def roll_std(mat, win): + return pd.DataFrame(mat).rolling(win, min_periods=max(2, win // 2)).std().values + + +def roll_mean(mat, win): + return pd.DataFrame(mat).rolling(win, min_periods=max(2, win // 2)).mean().values + + +def roll_skew(mat, win): + return pd.DataFrame(mat).rolling(win, min_periods=max(3, win // 2)).skew().values + + +def ewm_mean(mat, span): + return pd.DataFrame(mat).ewm(span=span, adjust=False).mean().values + + +def xs_zscore(mat): + """Cross-sectional z-score per row (across assets). NaN-safe.""" + m = np.nanmean(mat, axis=1, keepdims=True) + s = np.nanstd(mat, axis=1, keepdims=True) + return (mat - m) / np.where(s > 0, s, np.nan) + + +def xs_rank(mat): + """Cross-sectional rank in [0,1] per row (0=lowest).""" + out = np.full_like(mat, np.nan, dtype=float) + for i in range(mat.shape[0]): + row = mat[i] + ok = np.isfinite(row) + if ok.sum() >= 2: + r = pd.Series(row[ok]).rank().values + out[i, ok] = (r - 1) / (ok.sum() - 1) + return out + + +def market_ret(ret): + """Equal-weight market return per day (n,).""" + return np.nanmean(ret, axis=1) + + +def roll_beta(ret, win): + """Rolling beta of each asset to the equal-weight market (n,A), causal.""" + mkt = market_ret(ret) + ms = pd.Series(mkt) + var = ms.rolling(win, min_periods=max(5, win // 2)).var() + out = np.full_like(ret, np.nan) + for a in range(ret.shape[1]): + cov = pd.Series(ret[:, a]).rolling(win, min_periods=max(5, win // 2)).cov(ms) + out[:, a] = (cov / var.replace(0, np.nan)).values + return out + + +def residual_return(ret, win): + """Idiosyncratic daily return = ret - beta*market (beta rolling, causal).""" + beta = roll_beta(ret, win) + mkt = market_ret(ret)[:, None] + return ret - beta * mkt + + +def volume_z(vol, win): + m = roll_mean(vol, win) + s = roll_std(vol, win) + return (vol - m) / np.where(s > 0, s, np.nan) + + +# =========================================================================== +# BACKTEST — generic cross-sectional book from a per-asset SCORE matrix. +# score[i] (data <= i) -> rank assets -> long top-k / short bottom-k; W[i] earns dret[i+1]. +# =========================================================================== +def xs_backtest(panel: Panel, score, H=10, k=5, long_short=True, target_vol=0.20, + fee=FEE, vt_cap=3.0): + px = panel.close + n, A = px.shape + dret = panel.ret + score = np.asarray(score, float) + if score.shape != (n, A): + raise ValueError(f"score shape {score.shape} != panel {(n, A)}") + W = np.zeros((n, A)) + w = np.zeros(A) + for i in range(n): + if i % H == 0: + row = score[i] + fin = np.isfinite(row) + if fin.sum() >= 2 * k: + ranked = np.where(fin, row, -np.inf) + order = np.argsort(ranked) + order = order[np.isfinite(ranked[order])] + lo, hi = order[:k], order[-k:] + w = np.zeros(A) + if long_short: + w[hi] = 0.5 / k + w[lo] = -0.5 / k + else: + w[hi] = 1.0 / k + W[i] = w + gross = np.zeros(n) + gross[1:] = np.sum(W[:-1] * dret[1:], axis=1) + turn = np.zeros(n) + turn[0] = np.abs(W[0]).sum() + turn[1:] = np.abs(np.diff(W, axis=0)).sum(axis=1) + net = gross - turn * (fee / 2.0) + s = pd.Series(net, index=panel.index) + rv = s.rolling(30, min_periods=15).std().shift(1) * np.sqrt(365.25) + scale = np.clip(np.nan_to_num(target_vol / rv.replace(0, np.nan).values, nan=0.0), 0, vt_cap) + return pd.Series(s.values * scale, index=panel.index) + + +# =========================================================================== +# BASELINES (live stack) + MARGINAL scoring +# =========================================================================== +@lru_cache(maxsize=1) +def baselines(): + """Daily returns of the LIVE stack: TP01, XS01, and the combined active portfolio.""" + from src.portfolio.portfolio import StrategyPortfolio, to_daily + from src.portfolio.sleeves import _tp01_returns, _xsec_returns, active_sleeves + tp = to_daily(_tp01_returns()) + xs01 = to_daily(_xsec_returns()) + active = StrategyPortfolio(active_sleeves()).combined_daily() + return dict(tp01=tp, xs01=xs01, active=active) + + +def _corr(a, b): + J = pd.concat({"a": a, "b": b}, axis=1, join="inner").dropna() + return round(float(J["a"].corr(J["b"])), 3) if len(J) > 5 else None + + +def marginal_vs(cand, base, weights=(0.2, 0.35)): + """Does `cand` improve `base`? blend uplift (full & hold-out), + OOS jackknife robustness.""" + J = pd.concat({"B": base, "C": cand}, axis=1, join="inner").dropna() + if len(J) < 30: + return dict(verdict="N/A", reason="overlap < 30d") + JH = J[J.index >= HOLDOUT] + has_h = len(JH) > 20 + out = dict(corr=_corr(J["B"], J["C"]), base_full=round(al._sh(J["B"]), 3), + base_hold=round(al._sh(JH["B"]), 3) if has_h else None, + cand_full=round(al._sh(J["C"]), 3), cand_hold=round(al._sh(JH["C"]), 3) if has_h else None, + blends={}) + for w in weights: + bf, bh = (1 - w) * J["B"] + w * J["C"], (1 - w) * JH["B"] + w * JH["C"] + out["blends"][f"w{int(w * 100)}"] = dict( + uplift_full=round(al._sh(bf) - al._sh(J["B"]), 3), + uplift_hold=round(al._sh(bh) - al._sh(JH["B"]), 3) if has_h else None, + dd=round(al._dd_ret(bf), 4)) + # OOS jackknife at w=0.2 + robust = False + cu = jk = None + if has_h: + def _u(sub): + return al._sh(0.8 * sub["B"] + 0.2 * sub["C"]) - al._sh(sub["B"]) + months = sorted(set(zip(JH.index.year, JH.index.month))) + cu = round(_u(JH), 3) + jk = round(min(_u(JH[~((JH.index.year == y) & (JH.index.month == m))]) for y, m in months), 3) \ + if len(months) > 1 else cu + robust = bool(cu > 0.02 and jk > 0.0) + out["holdout_uplift_w20"] = cu + out["jackknife_min_uplift"] = jk + out["robust_oos"] = robust + up = out["blends"][f"w{int(weights[0] * 100)}"]["uplift_hold"] + cc = out["corr"] if out["corr"] is not None else 0.0 + if cc is not None and cc > 0.85 and (up is None or abs(up) < 0.05): + out["verdict"] = "REDUNDANT" + elif up is not None and up >= 0.05 and robust: + out["verdict"] = "ADDS" + elif up is not None and up <= -0.05: + out["verdict"] = "DILUTES" + else: + out["verdict"] = "NEUTRAL" + return out + + +# =========================================================================== +# DRIVER +# =========================================================================== +def study_xs(name, score_fn, universe="all", H=10, k=5, long_short=True, + target_vol=0.20, min_rows=700) -> dict: + """Backtest one cross-sectional hypothesis and score it honestly: + FULL + hold-out 2025+ + yearly, correlation to TP01 & XS01 (distinctness), + and marginal contribution to the LIVE active portfolio. `score_fn(panel) -> (n,A)` + per-asset score (higher = long), computed CAUSALLY (data <= close[i]).""" + p = load_panel(universe, min_rows=min_rows) + score = score_fn(p) + daily = al._to_daily(xs_backtest(p, score, H=H, k=k, long_short=long_short, target_vol=target_vol)) + net = daily.values + idx = daily.index + full = al._metrics_from_net(net, idx) + hmask = idx >= HOLDOUT + hold = al._metrics_from_net(net[hmask], idx[hmask]) if hmask.sum() > 20 else dict(sharpe=0.0, n=int(hmask.sum())) + bl = baselines() + marg = marginal_vs(daily, bl["active"]) + earns_slot = (full["sharpe"] > 0 and hold.get("sharpe", 0) > 0 + and marg.get("verdict") == "ADDS" + and (_corr(daily, bl["xs01"]) or 0) < 0.6) # distinct from existing x-sec + return dict( + name=name, universe=str(universe), H=H, k=k, long_short=long_short, + n_assets=len(p.syms), n_days=int(len(idx)), + full=full, holdout=hold, yearly=al._yearly(net, idx), + corr_tp01=_corr(daily, bl["tp01"]), corr_xs01=_corr(daily, bl["xs01"]), + corr_active=_corr(daily, bl["active"]), + marginal=marg, earns_slot=earns_slot, + caveat="panel ~2.5y (2024-26): suggestive, not robust", + ) + + +def _clean(o): + if isinstance(o, dict): + return {k: _clean(v) for k, v in o.items()} + if isinstance(o, (list, tuple)): + return [_clean(x) for x in o] + if isinstance(o, (np.floating,)): + return round(float(o), 4) + if isinstance(o, (np.integer,)): + return int(o) + if isinstance(o, (np.bool_,)): + return bool(o) + return o + + +def as_json(rep): + return json.dumps(_clean(rep), default=str) + + +def fmt(rep): + m = rep["marginal"] + yr = " ".join(f"{y}:{d['ret'] * 100:+.0f}%" for y, d in rep["yearly"].items()) + return (f"=== {rep['name']} [{rep['universe']} H{rep['H']} k{rep['k']} " + f"{'LS' if rep['long_short'] else 'LO'}] EARNS_SLOT={rep['earns_slot']}\n" + f" FULL Sh {rep['full']['sharpe']:+.2f} DD {rep['full']['maxdd'] * 100:.0f}% " + f"ret {rep['full']['ret'] * 100:+.0f}% | HOLD Sh {rep['holdout'].get('sharpe', 0):+.2f} " + f"| corr TP01 {rep['corr_tp01']} XS01 {rep['corr_xs01']}\n" + f" marginal vs active: {m.get('verdict')} (corr {m.get('corr')}, " + f"holdUplift_w20 {m.get('holdout_uplift_w20')}, jackknife {m.get('jackknife_min_uplift')}, " + f"robust_oos {m.get('robust_oos')}) | {yr}") + + +if __name__ == "__main__": + print("--- SMOKE TEST xslib ---") + # 1) x-sec momentum (should resemble XS01 ballpark) ; 2) short-term reversal ; 3) low-vol + print(fmt(study_xs("MOM30-90", lambda P: xs_zscore(past_return(P.close, 30)) + xs_zscore(past_return(P.close, 90)), H=10, k=5))) + print(fmt(study_xs("REV5", lambda P: -past_return(P.close, 5), H=5, k=5))) + print(fmt(study_xs("LOWVOL", lambda P: -roll_std(P.ret, 30), H=10, k=5))) + print("\nJSON sample:", as_json(study_xs("MOM30", lambda P: past_return(P.close, 30)))[:240])