From cff5fa2bf57a36baccc16c8f78ca085ae27e827a Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Mon, 29 Jun 2026 20:50:33 +0000 Subject: [PATCH] =?UTF-8?q?research(sweep):=205=20thread=20paralleli=20?= =?UTF-8?q?=E2=80=94=200=20nuovi=20sleeve,=20STATARB-RESID=20LEAD=20ortogo?= =?UTF-8?q?nale+eseguibile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ricerca onesta su aree inesplorate (harness altlib+xsec_v2_nonmom, tutti i gate incl. study_family_honest anti-selection-on-holdout). Branch main, nessun impatto live, test 143/143. 1 XSEC low-risk cousins (MAX/idio-vol/Amihud) -> 1 LEAD (IVOL), STAT-MODE, DSR 0.37<0.95 2 XSEC momentum-structure vs XS01 -> tutto REDUNDANT (sostituire XS01 distrugge hold) 3 Meta-allocazione dinamica (4 sleeve) -> pesi fissi vincono (gia quasi risk-parity) 4 Segnali ortogonali ETH/BTC (2 gambe) -> STATARB-RESID + DVOLSPREAD LEAD 5 1-gamba a segnale (MACD/RSI/Supertrend/...) -> 0/12 earns_slot (trend=TP01, MR morta, hedge) LEAD principale STATARB-RESID (mean-rev residuo ETH-b*BTC, OLS rolling, 2 gambe): primo stream INSIEME ortogonale (corr->book 0.027, beta-mkt 0.013) ED eseguibile a $600 (haircut ~0, NON STAT-MODE) -> cadono i 2 muri di XS01/opzioni. Resta solo il muro dell'edge (Sharpe 0.84, DSR 0.929 same-sign <0.95). Causalita+fee verificate dal coordinatore. Forward-monitor, non sleeve. Soffitto direzionale ~1.3 riconfermato. Diario 2026-06-29-strategy-search-5threads.md, CLAUDE.md agg. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 13 + .../2026-06-29-strategy-search-5threads.md | 101 +++ scripts/research/meta_allocation.py | 410 ++++++++++++ scripts/research/orthogonal_signals.py | 604 ++++++++++++++++++ scripts/research/signal_inout_1leg.py | 400 ++++++++++++ scripts/research/xsec_v3_lowrisk.py | 387 +++++++++++ scripts/research/xsec_v3_momstruct.py | 417 ++++++++++++ tests/test_meta_allocation.py | 83 +++ tests/test_orthogonal_signals.py | 83 +++ tests/test_signal_inout_1leg.py | 50 ++ tests/test_xsec_v3_lowrisk.py | 82 +++ tests/test_xsec_v3_momstruct.py | 62 ++ 12 files changed, 2692 insertions(+) create mode 100644 docs/diary/2026-06-29-strategy-search-5threads.md create mode 100644 scripts/research/meta_allocation.py create mode 100644 scripts/research/orthogonal_signals.py create mode 100644 scripts/research/signal_inout_1leg.py create mode 100644 scripts/research/xsec_v3_lowrisk.py create mode 100644 scripts/research/xsec_v3_momstruct.py create mode 100644 tests/test_meta_allocation.py create mode 100644 tests/test_orthogonal_signals.py create mode 100644 tests/test_signal_inout_1leg.py create mode 100644 tests/test_xsec_v3_lowrisk.py create mode 100644 tests/test_xsec_v3_momstruct.py diff --git a/CLAUDE.md b/CLAUDE.md index a9e48fb..aa6c928 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,6 +132,19 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis (hold-out +0.06) è single-window (storia DVOL <5 anni) → sotto la soglia multi-cut. Il gate DVOL-spike de-risk è **ridondante col trend** (TP01 già flat nei crash, Δ 0.00). **Lezione: per meno DD sul live la leva è `target_vol`, non un overlay DVOL** (20% resta canonico). Diario `2026-06-26-tp01-dvol-overlay.md`. +- **Sweep strategie a 5 thread (2026-06-29) — 0 nuovi sleeve, 1 LEAD che rompe 2 muri su 3.** Ricerca + parallela onesta su aree inesplorate (harness `altlib`+`xsec_v2_nonmom`, tutti i gate incl. il nuovo + `study_family_honest`): (1) low-risk cross-sectional, (2) momentum-structure vs XS01, (3) meta-allocazione + dinamica, (4) segnali ortogonali ETH/BTC, (5) 1-gamba a segnale. Esito: soffitto ~1.3 riconfermato; ogni + candidato ucciso dal gate giusto (deflated-Sharpe, is_hedge, selection-on-holdout, sostituzione-XS01, + multi-cut). Niente batte/diversifica XS01 (varianti = REDUNDANT); meta-allocazione < pesi fissi (i 4 + sleeve già quasi-risk-parity); 1-gamba a segnale = TP01 travestito (trend) o hedge a DSR<0.95. + **LEAD forward-monitor:** **STATARB-RESID** (mean-reversion del residuo ETH−β·BTC, β OLS rolling, 2 gambe) + — primo stream **insieme ortogonale (corr→book 0.027, β-mkt 0.013) ED eseguibile a $600** (haircut ~0, + NON STAT-MODE come XS01/opzioni): marginal ADDS, robust_oos, fee-survive 0.30%/gamba; resta sotto soglia + solo sull'**edge** (Sharpe 0.84, DSR 0.929 same-sign <0.95). Se una finestra forward conferma l'edge è + *deployabile*. Altri LEAD: IVOL (idio-vol XS, STAT-MODE), DVOLSPREAD (storia DVOL corta). Diario + `2026-06-29-strategy-search-5threads.md`. Script `scripts/research/{xsec_v3_lowrisk,xsec_v3_momstruct,meta_allocation,orthogonal_signals,signal_inout_1leg}.py`. - **Soffitto strutturale BTC/ETH-direzionale ~1.3** superato SOLO espandendo a un meccanismo diverso: cross-sectional su universo Hyperliquid certificato (XS01) → portafoglio Sharpe ~1.55. - **Sweep "strategie alternative" (2026-06-20) — 104 ipotesi / 153 agenti / NIENTE di nuovo regge.** diff --git a/docs/diary/2026-06-29-strategy-search-5threads.md b/docs/diary/2026-06-29-strategy-search-5threads.md new file mode 100644 index 0000000..5a05602 --- /dev/null +++ b/docs/diary/2026-06-29-strategy-search-5threads.md @@ -0,0 +1,101 @@ +# 2026-06-29 — Ricerca strategie a 5 thread paralleli: 0 nuovi sleeve, 1 LEAD che rompe 2 muri su 3 + +**Mandato.** "Cerca altre strategie" (+ "senza correlazioni, a segnale" + "a 1 gamba in/out a segnale"). +Cinque ricerche parallele su aree genuinamente inesplorate, tutte sull'harness onesto condiviso +(`altlib` + `xsec_v2_nonmom`) con TUTTI i gate: causalità (prefix-check), netto fee 0.10% RT + sweep, +OOS hold-out 2025+, **deflated-Sharpe** (multiple-testing), **gate anti-selection-on-holdout** +(`study_family_honest`: cella scelta IN-SAMPLE-only), marginal vs TP01/XS01, corr vs book, haircut $600. +Branch `main`, **nessun impatto live** (solo file nuovi, nessuno sleeve registrato). + +Premessa onesta (base-rate): il soffitto direzionale BTC/ETH ~1.3 è già confermato e lo sweep di 104 +ipotesi (2026-06-20) non produsse nulla di nuovo che reggesse. L'attesa era "quasi tutto SCARTATO". + +## Esiti per thread + +| # | Thread | File | Verdetto | +|---|--------|------|----------| +| 1 | **XSEC low-risk cousins** (MAX/lottery, idio-vol, Amihud) | `xsec_v3_lowrisk.py` | 1 LEAD (IVOL), 0 sleeve | +| 2 | **XSEC momentum-structure** (risk-adj/accel/frog-in-pan/vol-managed vs XS01) | `xsec_v3_momstruct.py` | tutto REDUNDANT/SCARTATO | +| 3 | **Meta-allocazione** (allocazione dinamica tra i 4 sleeve) | `meta_allocation.py` | pesi fissi vincono | +| 4 | **Segnali ortogonali ETH/BTC** (relative-value dollar-neutral) | `orthogonal_signals.py` | 2 LEAD (STATARB, DVOLSPREAD) | +| 5 | **1-gamba a segnale** (MACD/RSI/Supertrend/Donchian/BBands/EMA) | `signal_inout_1leg.py` | 0/12 earns_slot | + +**Netto: 0 nuovi sleeve.** Il soffitto regge. Test 143/143. + +### Thread 1 — Low-risk cross-sectional (51 alt HL) +Tre fattori mai provati (il filone C aveva fatto total-vol e BAB, non questi). **IVOL** (idiosyncratic-vol +basso, 19-major B30 H5 k8): FULL 1.06 / HOLD **1.22** / corr ~0 a XS01 e TP01 / positivo ogni anno / +uplift portafoglio HOLD +0.42/+0.50 — l'unico LEAD di valore. **MAX** e **AMIHUD-liquido** sono lo stesso +tema "evita speculativo/illiquido/volatile" in altre vesti (corr fra loro 0.33-0.59). **AMIHUD +long-illiquido SCARTATO** (premio illiquidità invertito in crypto: vincono i major liquidi). Bocciatura +del claim forte: **deflated-Sharpe 0.30-0.37 ≪ 0.95** (96 trial), storia ~2.5 anni, book 10-16 gambe → +**STAT-MODE**. Nota di rigore: il naive best-HOLD atterrava su celle in-sample-negative (holdout-fitting) → +l'agente ha auto-aggiunto il gate has_insample_edge. → **forward-monitor IVOL**. + +### Thread 2 — Momentum-structure vs XS01 +4 varianti (risk-adjusted, acceleration, frog-in-pan, vol-managed). **Nessuna batte né diversifica XS01** +(standalone 1.42). Sostituire XS01 con una variante **distrugge l'hold-out** del portafoglio (−0.58…−1.38) +— prova diretta che l'edge di XS01 sta nella sua struttura SPECIFICA (blend z-score [30,90] + gate +dispersione), non in varianti generiche. Deflated-Sharpe max 0.49. Tutte REDUNDANT/SCARTATO. + +### Thread 3 — Meta-allocazione dinamica +Vol-parity / momentum-of-sleeves / dispersion-regime / drawdown-control vs pesi fissi, con costo di +ribilancio realistico. **Nessuno batte i pesi fissi OOS.** Vol-parity = trappola da manuale (+0.09…+0.44 +sui tagli in-sample 2022-24, **−0.11 sull'hold-out 2025** → intercettato dal multi-cut). Drawdown-control +RIDONDANTE (TP01 va già flat nei crash, il gate non si attiva mai). I 4 sleeve sono già quasi-scorrelati +(corr ~0.12 max) → i pesi fissi sono già vicini al risk-parity ottimo statico. **Mantenere i pesi fissi.** + +### Thread 4 — Segnali ortogonali ETH/BTC (il risultato più notevole) +Relative-value dollar-neutral sul ratio log(ETH/BTC): 6 segnali, evaluator a 2 gambe (fee × 2), cella +scelta in-sample-only. + +| segnale | FULL/HOLD | corr→book | β-mkt | marginal | DSR (grid/same-sign) | exec $600 | verdetto | +|---|---|---|---|---|---|---|---| +| **STATARB-RESID** (mean-rev residuo ETH−β·BTC, W45) | 0.84/0.56 | **0.027** | 0.013 | **ADDS** | 0.056 / 0.929 | haircut ~0 | **LEAD** | +| DVOLSPREAD (IV relativa, W60) | 0.74/0.77 | 0.017 | 0.012 | ADDS | 0.082 / 0.907 | haircut ~0 | LEAD | +| RATIO-MOM/REV/ACCEL | 0.25-0.68 / ≤0 | ~0 | ~0 | NEUTRAL | <0.23 | ok | NEUTRAL (diversification-math) | +| VOLSPREAD | −0.24/−1.47 | ~0 | 0.013 | DILUTES | — | — | SCARTATO | + +**STATARB-RESID rompe 2 dei 3 muri storici:** è **ortogonale per costruzione** (corr→book 0.027, beta di +mercato 0.013 — meglio di SKH ~0.09) **ED eseguibile a $600** (book a 2 gambe BTC+ETH perp Deribit, +haircut ≈ 0, fee-survive fino a 0.30%/gamba → **NON STAT-MODE**, a differenza di XS01 e delle opzioni). Il +muro che **resta** è l'**edge**: Sharpe 0.84 / DSR 0.929 same-sign (ottimistico) ma comunque <0.95. +Verificato indipendentemente dal coordinatore: residuo causale (β OLS rolling backward, decisione a +close[i], return in i+1), fee a 2 gambe corretta. → **LEAD forward-monitor** (monitorabile a costo reale +~0 grazie all'eseguibilità), NON deploy. **DVOLSPREAD** ri-valida l'ex-lead `dvol_spread`: ADDS ma storia +DVOL corta (2021+) → resta forward-monitor come già noto. Secondario crypto-vs-macro = "TSMOM travestito" +(corr→book 0.17-0.20), non ortogonale. + +### Thread 5 — 1-gamba a segnale (eseguibile) +12 famiglie (MACD/RSI/Supertrend/Donchian/BBands/EMA, ±ADX) su 1d/12h/8h. **0/12 earns_slot_honest.** +**Eseguibilità validata** (haircut $600 = 0 ovunque; RSI-MR ~2.5 trade/anno) — ma è l'unica cosa che +regge. I trend-follower sono **TP01 travestito** (corr 0.44-0.79, full ~1.2-1.3 = il soffitto) e le celle +sub-daily scelte in-sample **collassano OOS** (dimostrazione da manuale del gate selection-on-holdout). La +mean-reversion è morta (BBands-MR has_insample_edge=False). I "low-corr interessanti" (RSI-MR, MACD-LS, +Donchian-LS) sono **HEDGE non alpha** (`is_hedge=True`, pagano solo quando TP01 è debole), e con +deflated-Sharpe <0.95 (RSI-MR 0.861; Donchian-LS passa DSR ma is_hedge). **A $600 un 1-gamba a segnale è +eseguibile ma non aggiunge nulla a TP01.** + +## Sintesi / cosa ho imparato + +1. **0 nuovi sleeve, soffitto ~1.3 riconfermato** dal lato direzionale e dal lato struttura-momentum. La + ricerca è onesta: ogni candidato è stato ucciso dal gate giusto (deflated-Sharpe, is_hedge, + selection-on-holdout, sostituzione-XS01, multi-cut), non da un giudizio a occhio. +2. **Il LEAD di valore è STATARB-RESID** (ETH/BTC residual mean-reversion). È il primo stream visto che è + **insieme ortogonale (β~0) ED eseguibile a 2 gambe** — cadono i due muri che bloccano XS01 (STAT-MODE) + e le opzioni. Manca solo l'edge sopra-soglia. È il candidato n.1 per il **forward-monitor**, e l'unico + che — se la finestra forward confermasse l'edge — sarebbe *deployabile* a $600 (non statistico). +3. **Forward-monitor (STAT-MODE / sub-soglia):** IVOL (idio-vol XS, 19-major), DVOLSPREAD (storia corta), + STATARB-RESID (eseguibile — il più promettente). Nessuno armato come sleeve. +4. **I gate nuovi funzionano:** `study_family_honest` (selection-on-holdout) ha intercettato i collassi + OOS dei trend 1-gamba e dei low-risk XS; il deflated-Sharpe ha tenuto sotto la soglia ogni Sharpe ~1 + su storia corta. La lezione del filone B (de-bias prima di credere) è ora applicata di default. + +## Caveat +- Universi/finestre: HL ~2.5 anni, DVOL dal 2021 → multiple-testing reale; tutti i LEAD sono sotto la + soglia deflazionata. Niente di questo va creduto come alpha finché una finestra forward non lo conferma. +- Nessuno sleeve registrato, config canonica invariata (TP01+XS01+VRP01+SKH01 a pesi fissi). Book live + intatto. Tutto il lavoro è statistico/forward, su `main` come ricerca. + +Script: `scripts/research/{xsec_v3_lowrisk,xsec_v3_momstruct,meta_allocation,orthogonal_signals,signal_inout_1leg}.py`. +Test: i rispettivi `tests/test_*.py` (143/143 verdi). diff --git a/scripts/research/meta_allocation.py b/scripts/research/meta_allocation.py new file mode 100644 index 0000000..a833ec1 --- /dev/null +++ b/scripts/research/meta_allocation.py @@ -0,0 +1,410 @@ +"""META-ALLOCATION — allocazione DINAMICA CAUSALE tra i 4 sleeve esistenti vs PESI FISSI. + +TESI (angolo nuovo, NON un 5o sleeve): il portafoglio attivo combina TP01/XS01/VRP01/SKH01 a +PESO FISSO (41.25/18.75/15/25, rinormalizzati per-riga sugli sleeve attivi — vedi +src/portfolio/portfolio.combined_daily). Domanda: una regola di allocazione DINAMICA e CAUSALE +fra gli stessi 4 sleeve batte i pesi fissi OUT-OF-SAMPLE? Cioe' c'e' meta-alpha di timing di +portafoglio, oltre ai pesi fissi? + +MECCANISMI testati (tutti CAUSALI: decisione con dati <= t-1, peso applicato in t; ribilancio +SETTIMANALE con costo sul turnover dei pesi |Δw|*cost_rate, cosi' una regola che ribilancia di +continuo PAGA il suo attrito — non si bara): + 1. VOL-PARITY — peso inverso alla vol realizzata rolling (risk-parity causale). Pure + tilt. + 2. MOMENTUM-OF-SLEEVES — sovrappesa gli sleeve con Sharpe rolling recente migliore (tilt capato). + 3. DISPERSION-REGIME — tilt verso XS01 quando la dispersione cross-section degli alt e' alta + (percentile ESPANDENTE causale), verso il resto altrimenti. + 4. DRAWDOWN-CONTROL — riduce l'esposizione aggregata (-> cash) o ribilancia verso VRP/SKH + quando il portafoglio e' in drawdown rolling (causale sull'equity propria). + +GATE / ONESTA': + - FULL e HOLD-OUT (2025-01-01+) Sharpe + maxDD, per-anno, turnover dei pesi/anno. + - Confronto vs BASE pesi-fissi sulla STESSA finestra e con lo STESSO motore (entrambi pagano il + costo di ribilancio): il miglioramento deve esserci su HOLD-OUT, non solo FULL. + - MULTI-CUT: uplift dello Sharpe a piu' date di taglio (2022/23/24/25). Robusto solo se positivo + su piu' finestre, non su una sola fortunata. + - DE-LEVERING: lo Sharpe e' scale-invariant. Se uno schema ABBASSA DD/vol ma NON alza lo Sharpe, + il taglio di DD e' solo de-levering (replicabile abbassando la leva di BASE) -> NON e' alpha di + timing. Lo riportiamo esplicitamente confrontando BASE de-levered a pari vol. + +VERDETTO per schema: BATTE-FISSO / solo-de-levering / RIDONDANTE / SCARTATO. + + uv run python scripts/research/meta_allocation.py +""" +from __future__ import annotations +import sys +from pathlib import Path +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) +import numpy as np +import pandas as pd + +from src.portfolio.sleeves import active_sleeves, XS_UNIVERSE, _HL_DIR +from src.portfolio.portfolio import metrics, yearly, HOLDOUT, DAYS_PER_YEAR + +REBAL_DAYS = 7 # ribilancio settimanale +COST_RATE = 0.0005 # 5 bps per-lato sul turnover dei pesi (Deribit taker ~ questo ordine) +VOL_WIN = 60 # finestra vol realizzata (risk-parity) +MOM_WIN = 63 # finestra Sharpe rolling (momentum-of-sleeves, ~1 trimestre) +WARMUP = 90 # giorni di warm-up: prima -> fallback ai pesi fissi + + +# ----------------------------------------------------------------------------- data +def sleeve_matrix() -> tuple[pd.DatetimeIndex, np.ndarray, np.ndarray, list[str], np.ndarray]: + """Matrice daily allineata dei 4 sleeve (outer-join). Ritorna (index, R, active, names, fixed_w). + R = rendimenti (0 dove inattivo), active = maschera bool di disponibilita'.""" + base = active_sleeves() + names = [s.name for s in base] + fixed_w = np.array([s.weight for s in base], float) + cols = {s.name: s.daily() for s in base} + J = pd.concat(cols, axis=1, join="outer").sort_index() + J = J[J.notna().any(axis=1)] + active = J.notna().values + R = np.nan_to_num(J.values, nan=0.0) + return J.index, R, active, names, fixed_w + + +def dispersion_series(index: pd.DatetimeIndex) -> np.ndarray: + """Dispersione cross-section dei rendimenti degli alt Hyperliquid (std cross-section dei ritorni + daily sull'universo XS01), allineata all'index del portafoglio. NaN dove non c'e' dato HL.""" + cols = {} + for sym in XS_UNIVERSE: + p = _HL_DIR / f"hl_{sym.lower()}_1d.parquet" + if not p.exists(): + continue + d = pd.read_parquet(p) + cols[sym] = pd.Series(d["close"].values.astype(float), + index=pd.to_datetime(d["timestamp"], unit="ms", utc=True)) + C = pd.concat(cols, axis=1, join="inner").sort_index().dropna() + ret = C.pct_change() + disp = ret.std(axis=1) # dispersione cross-section per giorno + disp.index = disp.index.normalize() + return disp.reindex(index.normalize()).values + + +# ----------------------------------------------------------------------------- weight helpers +def _renorm_rows(W: np.ndarray, active: np.ndarray, expo: np.ndarray | None = None) -> np.ndarray: + """Maschera inattivi -> 0, rinormalizza ogni riga alla esposizione `expo` (default 1).""" + Wm = W * active + rs = Wm.sum(axis=1, keepdims=True) + out = np.divide(Wm, rs, out=np.zeros_like(Wm), where=rs > 0) + if expo is not None: + out = out * expo[:, None] + return out + + +def base_weights(R, active, fixed_w) -> np.ndarray: + """Pesi FISSI rinormalizzati per-riga sugli sleeve attivi (replica combined_daily).""" + n, A = R.shape + return _renorm_rows(np.tile(fixed_w, (n, 1)), active) + + +def add_cash(W: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Appende una colonna CASH (rendimento 0) che assorbe 1 - somma-pesi (per schemi che de-levano). + Ritorna (W_aug, is_cash_active=True).""" + cash = np.clip(1.0 - W.sum(axis=1, keepdims=True), 0.0, 1.0) + return np.hstack([W, cash]) + + +# ----------------------------------------------------------------------------- engine +def simulate(R: np.ndarray, active: np.ndarray, Wtgt: np.ndarray, + period_days: int = REBAL_DAYS, cost_rate: float = COST_RATE) -> dict: + """Motore di ribilancio PERIODICO realistico, CAUSALE. + + Wtgt[t] = pesi-bersaglio decisi con dati <= t-1 (vedi costruttori schemi), una colonna CASH in + coda (rend. 0). Fra un ribilancio e l'altro i pesi DERIVANO col rendimento; ogni `period_days` + si torna al target pagando cost_rate*|v-target|. Il costo grava sul rendimento del giorno. + period_days=1, cost=0 -> rebalance-continuo (= combined_daily).""" + n = R.shape[0] + Raug = np.hstack([R, np.zeros((n, 1))]) # colonna cash + v = Wtgt[0].copy() # equity iniziale = 1.0, allocata al target + out = np.zeros(n) + turn_tot = 0.0 + n_rebal = 0 + for t in range(n): + E_start = float(v.sum()) + if t > 0 and (t % period_days == 0) and E_start > 0: + target = Wtgt[t] * E_start + turn = float(np.abs(v - target).sum()) + v = Wtgt[t] * (E_start - cost_rate * turn) + turn_tot += turn / E_start + n_rebal += 1 + v = v * (1.0 + Raug[t]) + E_end = float(v.sum()) + out[t] = E_end / E_start - 1.0 if E_start > 0 else 0.0 + years = n / DAYS_PER_YEAR + return dict(daily=pd.Series(out), + turnover_per_year=turn_tot / years if years > 0 else 0.0, + n_rebalances=n_rebal) + + +# ----------------------------------------------------------------------------- schemes (causal Wtgt builders, with cash col) +def scheme_base(index, R, active, fixed_w, **_): + return add_cash(base_weights(R, active, fixed_w)) + + +def _rolling_vol(R, active, win): + """Vol realizzata rolling per-sleeve, SHIFTATA di 1 (causale: usa <= t-1).""" + df = pd.DataFrame(np.where(active, R, np.nan)) + vol = df.rolling(win, min_periods=max(10, win // 2)).std().shift(1).values + return vol + + +def scheme_volpar_pure(index, R, active, fixed_w, win=VOL_WIN, **_): + """Risk-parity puro: w_i ∝ 1/vol_i sugli sleeve attivi (causale). Warm-up -> BASE.""" + vol = _rolling_vol(R, active, win) + inv = np.divide(1.0, vol, out=np.zeros_like(vol), where=(vol > 0) & np.isfinite(vol)) + W = _renorm_rows(inv, active & np.isfinite(vol) & (vol > 0)) + bw = base_weights(R, active, fixed_w) + bad = W.sum(axis=1) <= 0 + W[bad] = bw[bad] + W[:WARMUP] = bw[:WARMUP] + return add_cash(W) + + +def scheme_volpar_tilt(index, R, active, fixed_w, win=VOL_WIN, **_): + """Tilt dei pesi FISSI per inverso-vol: w_i ∝ fixed_i / vol_i (ancorato ai pesi fissi).""" + vol = _rolling_vol(R, active, win) + inv = np.divide(1.0, vol, out=np.zeros_like(vol), where=(vol > 0) & np.isfinite(vol)) + W = _renorm_rows(fixed_w[None, :] * inv, active & np.isfinite(vol) & (vol > 0)) + bw = base_weights(R, active, fixed_w) + bad = W.sum(axis=1) <= 0 + W[bad] = bw[bad] + W[:WARMUP] = bw[:WARMUP] + return add_cash(W) + + +def scheme_momentum(index, R, active, fixed_w, win=MOM_WIN, tilt=0.5, cap=0.55, **_): + """Momentum-of-sleeves: tilt dei pesi fissi per lo Sharpe rolling z-scored (causale), capato. + w_i ∝ fixed_i * (1 + tilt*z_i)+, z = standardizzazione cross-sleeve dello Sharpe rolling. + Cap per non concentrare. Warm-up / regime piatto -> BASE.""" + df = pd.DataFrame(np.where(active, R, np.nan)) + mu = df.rolling(win, min_periods=win // 2).mean().shift(1).values + sd = df.rolling(win, min_periods=win // 2).std().shift(1).values + sh = np.divide(mu, sd, out=np.full_like(mu, np.nan), where=(sd > 0)) * np.sqrt(DAYS_PER_YEAR) + n, A = R.shape + W = np.zeros((n, A)) + bw = base_weights(R, active, fixed_w) + for t in range(n): + m = active[t] & np.isfinite(sh[t]) + if m.sum() < 2 or t < WARMUP: + W[t] = bw[t]; continue + z = np.zeros(A); s = sh[t][m] + zsd = s.std() + if zsd > 0: + z[m] = (sh[t][m] - s.mean()) / zsd + raw = fixed_w * np.clip(1.0 + tilt * z, 0.0, None) * m + if raw.sum() <= 0: + W[t] = bw[t]; continue + w = raw / raw.sum() + for _ in range(3): # impone il cap iterando + over = w > cap + if not over.any(): + break + excess = (w[over] - cap).sum() + w[over] = cap + room = m & ~over + if room.sum() == 0 or w[room].sum() == 0: + break + w[room] += excess * w[room] / w[room].sum() + W[t] = w / w.sum() + return add_cash(W) + + +def scheme_dispersion(index, R, active, fixed_w, pct=60, minhist=120, boost=2.0, **_): + """Dispersion-regime: quando la dispersione cross-section degli alt supera il percentile + ESPANDENTE causale (pct), boost del peso XS01; sotto, XS01 -> 0 e redistribuito. Pesi fissi + altrove. XS01 attivo solo dal 2024 (prima: BASE).""" + disp = dispersion_series(index) + n, A = R.shape + names_idx = 1 # XS01 e' la colonna 1 (vedi active_sleeves) + bw = base_weights(R, active, fixed_w) + W = bw.copy() + hist = [] + high = np.zeros(n, bool) + for t in range(n): + d = disp[t - 1] if t > 0 else np.nan # causale: dispersione <= t-1 + if np.isfinite(d): + thr = np.percentile(hist, pct) if len(hist) >= minhist else np.inf + high[t] = d >= thr + hist.append(d) + for t in range(n): + if t < WARMUP or not active[t, names_idx]: + continue + raw = fixed_w.copy() + raw[names_idx] *= boost if high[t] else 0.05 # boost XS in regime disperso, quasi-spento altrove + W[t] = _renorm_rows(raw[None, :], active[t][None, :])[0] + return add_cash(W) + + +def scheme_dd_cash(index, R, active, fixed_w, dd_thr=0.05, floor=0.5, win=0, **_): + """Drawdown-control (DE-LEVERING esplicito): traccia l'equity di BASE (causale, shiftata), + se il drawdown corrente > dd_thr riduce l'esposizione aggregata a `floor` (resto in CASH). + E' il caso-test del de-levering: ci aspettiamo DD piu' basso ma Sharpe NON piu' alto.""" + bw = base_weights(R, active, fixed_w) + base_daily = simulate(R, active, add_cash(bw))["daily"].values + eq = np.cumprod(1.0 + base_daily) + pk = np.maximum.accumulate(eq) + dd = (pk - eq) / pk # drawdown realizzato + expo = np.ones(R.shape[0]) + for t in range(R.shape[0]): + d = dd[t - 1] if t > 0 else 0.0 # causale + expo[t] = floor if d > dd_thr else 1.0 + expo[:WARMUP] = 1.0 + W = bw * expo[:, None] + return add_cash(W) + + +def scheme_dd_defensive(index, R, active, fixed_w, dd_thr=0.05, **_): + """Drawdown-control DIFENSIVO: in drawdown ribilancia verso VRP01(2)/SKH01(3) (scorrelati), + via TP01(0)/XS01(1). Pienamente investito (no cash) -> isola il timing dal de-levering.""" + bw = base_weights(R, active, fixed_w) + base_daily = simulate(R, active, add_cash(bw))["daily"].values + eq = np.cumprod(1.0 + base_daily) + pk = np.maximum.accumulate(eq) + dd = (pk - eq) / pk + n, A = R.shape + defensive = np.array([0.10, 0.10, 0.35, 0.45]) # VRP/SKH pesati in DD + W = bw.copy() + for t in range(n): + d = dd[t - 1] if t > 0 else 0.0 + if t >= WARMUP and d > dd_thr: + W[t] = _renorm_rows(defensive[None, :], active[t][None, :])[0] + return add_cash(W) + + +SCHEMES = [ + ("BASE (pesi fissi)", scheme_base), + ("VOLPAR pure (1/vol)", scheme_volpar_pure), + ("VOLPAR tilt (fix/vol)", scheme_volpar_tilt), + ("MOMENTUM-of-sleeves", scheme_momentum), + ("DISPERSION-regime->XS", scheme_dispersion), + ("DRAWDOWN-ctrl (cash)", scheme_dd_cash), + ("DRAWDOWN-ctrl (defens.)", scheme_dd_defensive), +] + +CUTS = ["2022-01-01", "2023-01-01", "2024-01-01", "2025-01-01"] + + +# ----------------------------------------------------------------------------- run +def run(): + index, R, active, names, fixed_w = sleeve_matrix() + print("=" * 100) + print(" META-ALLOCATION — allocazione dinamica causale tra i 4 sleeve vs PESI FISSI") + print(f" sleeve: {names}") + print(f" pesi fissi: {dict(zip(names, np.round(fixed_w, 4)))}") + print(f" finestra {index.min().date()} -> {index.max().date()} | n={len(index)} giorni | " + f"hold-out {HOLDOUT.date()}+ | ribilancio {REBAL_DAYS}g | costo {COST_RATE*1e4:.0f}bps/lato") + print("=" * 100) + + results = {} + for label, fn in SCHEMES: + Wtgt = fn(index, R, active, fixed_w) + sim = simulate(R, active, Wtgt) + d = pd.Series(sim["daily"].values, index=index) + results[label] = dict(daily=d, turnover=sim["turnover_per_year"], W=Wtgt) + + base_d = results["BASE (pesi fissi)"]["daily"] + mb_full = metrics(base_d) + mb_hold = metrics(base_d[base_d.index >= HOLDOUT]) + + print(f"\n {'SCHEMA':<26s} | {'FULL Sh':>7s} {'CAGR':>7s} {'DD':>6s} | {'HOLD Sh':>7s} {'HOLD ret':>8s} {'DD':>6s} | turn/y") + print(" " + "-" * 96) + for label, _ in SCHEMES: + d = results[label]["daily"] + mf = metrics(d); mh = metrics(d[d.index >= HOLDOUT]) + print(f" {label:<26s} | {mf['sharpe']:>7.2f} {mf['cagr']*100:>6.1f}% {mf['maxdd']*100:>5.1f}% | " + f"{mh['sharpe']:>7.2f} {mh['ret']*100:>+7.1f}% {mh['maxdd']*100:>5.1f}% | {results[label]['turnover']:>5.2f}") + + print(f"\n delta vs BASE (FULL Sh {mb_full['sharpe']:.2f} / HOLD Sh {mb_hold['sharpe']:.2f}):") + print(f" {'SCHEMA':<26s} | {'ΔFULL Sh':>9s} {'ΔHOLD Sh':>9s} {'ΔFULL DD':>9s} {'ΔHOLD DD':>9s} | corr(BASE)") + print(" " + "-" * 96) + for label, _ in SCHEMES: + if label.startswith("BASE"): + continue + d = results[label]["daily"] + mf = metrics(d); mh = metrics(d[d.index >= HOLDOUT]) + corr = float(np.corrcoef(d.values, base_d.values)[0, 1]) + print(f" {label:<26s} | {mf['sharpe']-mb_full['sharpe']:>+9.2f} {mh['sharpe']-mb_hold['sharpe']:>+9.2f} " + f"{(mf['maxdd']-mb_full['maxdd'])*100:>+8.1f}% {(mh['maxdd']-mb_hold['maxdd'])*100:>+8.1f}% | {corr:>6.3f}") + + # ---- MULTI-CUT: uplift Sharpe a piu' date di taglio (anti-overfit hold-out singolo) ---- + print("\n MULTI-CUT — ΔSharpe (schema − BASE) su finestre [cut, fine]:") + header = " " + f"{'SCHEMA':<26s} | " + " ".join(f"{c[:4]:>7s}" for c in CUTS) + print(header); print(" " + "-" * (len(header) - 2)) + for label, _ in SCHEMES: + if label.startswith("BASE"): + continue + d = results[label]["daily"] + row = [] + for c in CUTS: + lo = pd.Timestamp(c, tz="UTC") + sd = metrics(d[d.index >= lo])["sharpe"] + sb = metrics(base_d[base_d.index >= lo])["sharpe"] + row.append(f"{sd-sb:>+7.2f}") + print(f" {label:<26s} | " + " ".join(row)) + + # ---- DE-LEVERING check: BASE de-levered alla vol dello schema -> stesso DD? ---- + print("\n DE-LEVERING check (Sharpe e' scale-invariant: DD-piu'-basso a pari-Sharpe = solo de-lever):") + print(f" {'SCHEMA':<26s} | {'vol/volBASE':>11s} | {'DD schema':>9s} {'DD BASE@volSchema':>18s}") + print(" " + "-" * 70) + vol_base = base_d.std() + dd_base = mb_full["maxdd"] + for label, _ in SCHEMES: + if label.startswith("BASE"): + continue + d = results[label]["daily"] + ratio = d.std() / vol_base if vol_base > 0 else 1.0 + # BASE riscalato alla stessa vol dello schema -> il suo DD a quella leva + dd_base_scaled = metrics(base_d * ratio)["maxdd"] + print(f" {label:<26s} | {ratio:>11.3f} | {metrics(d)['maxdd']*100:>8.1f}% {dd_base_scaled*100:>17.1f}%") + + # ---- PER-ANNO dei due piu' interessanti vs BASE ---- + print("\n PER-ANNO ret% (BASE vs schemi):") + yb = yearly(base_d) + yrs = sorted(yb.keys()) + print(" " + f"{'SCHEMA':<26s} | " + " ".join(f"{y:>7d}" for y in yrs)) + print(" " + "-" * (28 + 8 * len(yrs))) + for label, _ in SCHEMES: + d = results[label]["daily"]; yd = yearly(d) + print(f" {label:<26s} | " + " ".join(f"{yd.get(y,{'ret':0})['ret']*100:>+6.1f}%" for y in yrs)) + + # ---- VERDETTI ---- + print("\n VERDETTI (BATTE-FISSO richiede ΔHOLD Sh > +0.10 E multi-cut maggioritario positivo E" + " non solo de-levering):") + vol_base = base_d.std() + for label, _ in SCHEMES: + if label.startswith("BASE"): + continue + d = results[label]["daily"] + mf = metrics(d); mh = metrics(d[d.index >= HOLDOUT]) + dfull = mf["sharpe"] - mb_full["sharpe"] + dhold = mh["sharpe"] - mb_hold["sharpe"] + cut_ups = [] + for c in CUTS: + lo = pd.Timestamp(c, tz="UTC") + cut_ups.append(metrics(d[d.index >= lo])["sharpe"] - metrics(base_d[base_d.index >= lo])["sharpe"]) + n_pos = sum(1 for x in cut_ups if x > 0.02) + vr = d.std() / vol_base if vol_base > 0 else 1.0 + dd_lower = mf["maxdd"] < mb_full["maxdd"] - 0.005 + is_delever = (vr < 0.97) and dd_lower and (dfull <= 0.03) # vol giu', DD giu', Sharpe non meglio + if dhold > 0.10 and dfull > -0.05 and n_pos >= 3: + verdict, why = "BATTE-FISSO", f"ΔHOLD {dhold:+.2f}, multi-cut {n_pos}/4 positivi, FULL non peggiore" + elif (dhold <= -0.10) or (n_pos == 0 and dfull < -0.07): + verdict, why = "SCARTATO", f"peggio OOS (ΔFULL {dfull:+.2f}, ΔHOLD {dhold:+.2f}, multi-cut {n_pos}/4 con turn/y {results[label]['turnover']:.1f})" + elif is_delever: + verdict, why = "solo-de-levering", f"vol {vr:.2f}×BASE, DD {mf['maxdd']*100:.1f}%<{mb_full['maxdd']*100:.1f}% ma Sharpe non meglio (ΔFULL {dfull:+.2f}) -> replicabile abbassando la leva" + else: + why = (f"≈BASE OOS (ΔHOLD {dhold:+.2f}); FULL ΔSh {dfull:+.2f}, ΔDD {(mf['maxdd']-mb_full['maxdd'])*100:+.1f}%" + + (" [marginale in-sample, nullo su hold-out]" if abs(dfull) >= 0.03 else "")) + verdict = "RIDONDANTE" + print(f" {label:<26s} -> {verdict:<16s} {why}") + + print("\n" + "=" * 100) + print(" CONCLUSIONE: vedi i verdetti sopra. Soglia BATTE-FISSO deliberatamente alta (anti-overfit):") + print(" l'allocazione dinamica deve battere i pesi fissi su HOLD-OUT *e* multi-cut, non su una") + print(" finestra fortunata, e non per solo de-levering (replicabile abbassando target_vol/leva).") + print("=" * 100) + + +if __name__ == "__main__": + run() diff --git a/scripts/research/orthogonal_signals.py b/scripts/research/orthogonal_signals.py new file mode 100644 index 0000000..97dce93 --- /dev/null +++ b/scripts/research/orthogonal_signals.py @@ -0,0 +1,604 @@ +"""orthogonal_signals.py — SIGNAL-BASED, BY-CONSTRUCTION-ORTHOGONAL streams (2026-06-29). + +TESI (richiesta utente). Il book attivo è TP01 (trend) + XS01 (cross-sectional) + VRP01 +(short-vol) + SKH01 (regime L/S). Tutti hanno beta direzionale o vol crypto. Cerchiamo uno +stream a **beta di mercato ~0 e bassa corr al book** ma con un **EDGE DI SEGNALE reale** — +NON la "diversification math" di uno stream a Sharpe~0 (il marginal scorer indurito la boccia: +serve has_insample_edge, Sharpe standalone PRE-2025 >= 0.5, deflated-Sharpe sull'intera griglia, +e selezione della cella IN-SAMPLE, mai sul max hold-out). + +FOCUS PRIMARIO — RELATIVE-VALUE ETH/BTC (dollar-neutral, 2 gambe). La posizione è sul SPREAD +long-ETH/short-BTC (o viceversa): r_spread[i] = pos[i-1]*(r_eth[i]-r_btc[i]) - fee*2*|Δpos|. +Per costruzione beta_mercato ~0 -> scorrelato a TP01/SKH. VANTAGGIO: un book a 2 gambe su +Deribit perp (BTC+ETH entrambi live) è MOLTO più vicino all'eseguibile a $600 di uno a 19 gambe +(XS01 è STAT-MODE). Segnali sul ratio log(ETH/BTC), tutti CAUSALI (decisione <= close[i]): + 1. RATIO-MOM momentum del ratio (trend dello spread). + 2. RATIO-REV reversal di breve del ratio. + 3. RATIO-ACCEL accelerazione (2a differenza / curvatura) del ratio. + 4. VOLSPREAD vol realizzata relativa BTC vs ETH -> verso l'asset col profilo giusto. + 5. DVOLSPREAD vol IMPLICITA relativa (al.dvol BTC/ETH) -> re-valida l'ex-lead 'dvol_spread'. + 6. STATARB-RESID residuo di ETH dopo beta*BTC (rolling OLS causale) -> mean-revert il residuo. + +SECONDARIO — CRYPTO vs MACRO (GLD/QQQ/TLT): long/short crypto vs hedge su momentum relativo, +merge_asof backward (equity 5gg/sett vs crypto 24/7), niente look-ahead. Probabile debole. + +GATE (tutti obbligatori, replicano altlib indurito): + * CAUSALITÀ: prefix-check sul SPREAD (ricostruisci su prefisso, la coda deve combaciare). + * NETTO fee 0.10% RT su 2 gambe + SWEEP (0.00-0.30% RT). + * SELEZIONE CELLA IN-SAMPLE-ONLY (pre-2025), MAI sul max hold-out (punto cieco filone B). + * DEFLATED-SHARPE su TUTTE le celle cercate (multiple-testing). + * has_insample_edge: Sharpe standalone PRE-2025 >= 0.5 (no diversification-math). + * OOS hold-out 2025+, plateau su griglia, per-anno. + * corr vs BOOK 4-sleeve (|corr|<0.2 ideale) + beta vs mercato (50/50 BTC+ETH) ~0. + * marginal_vs_tp01 (ADDS / HEDGE / NOISE / NEUTRAL) -> earns_slot_honest. + * EXEC $600: haircut a 2 gambe (min $5/gamba, fee 0.10% RT) — il punto FORTE del filone. + +ONESTÀ BRUTALE: bassa-corr da sola NON basta. dvol_spread era forward-monitor (storia DVOL +corta + multiple-testing); ratio_accel era lead debole. Se è diversification-math o +hold-out-fitting -> NOISE/SCARTATO con numeri. + +Esecuzione: cd /opt/docker/PythagorasGoal && uv run python scripts/research/orthogonal_signals.py +Idempotente, solo stdout. +""" +from __future__ import annotations + +import sys +import math +import warnings +from pathlib import Path + +import numpy as np +import pandas as pd + +warnings.filterwarnings("ignore") + +_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(_ROOT / "scripts" / "research" / "alt")) +sys.path.insert(0, str(_ROOT)) +import altlib as al # noqa: E402 + +FEE_SIDE = al.FEE_SIDE # 0.0005 = 0.05%/side +FEE_SWEEP = (0.0, 0.00025, 0.0005, 0.001, 0.0015) # per-side; ×2 legs ×2 (RT) for RT% +HOLDOUT = al.HOLDOUT +ANN = 365.25 + + +# =========================================================================== +# JOINT FRAME + DOLLAR-NEUTRAL EVALUATOR (custom — eval_weights is single-asset) +# =========================================================================== +def build_joint(tf: str = "1d") -> pd.DataFrame: + """BTC/ETH allineati sull'indice comune (inner join su timestamp). Ritorna un frame con + r_btc/r_eth (simple), close_*, log_ratio = log(ETH/BTC), datetime, timestamp.""" + b = al.get("BTC", tf)[["timestamp", "datetime", "close"]].rename(columns={"close": "cb"}) + e = al.get("ETH", tf)[["timestamp", "close"]].rename(columns={"close": "ce"}) + j = b.merge(e, on="timestamp", how="inner").sort_values("timestamp").reset_index(drop=True) + j["r_btc"] = al.simple_returns(j["cb"].values) + j["r_eth"] = al.simple_returns(j["ce"].values) + j["log_ratio"] = np.log(j["ce"].values / j["cb"].values) + return j + + +def spread_ret(j: pd.DataFrame) -> np.ndarray: + """Ritorno dollar-neutral per unità di gross-per-gamba: long $1 ETH, short $1 BTC.""" + return j["r_eth"].values - j["r_btc"].values + + +def vol_target_spread(direction: np.ndarray, j: pd.DataFrame, target_vol: float = 0.20, + win_days: int = 30, cap: float = 2.0) -> np.ndarray: + """Scala una direzione in [-1,1] a vol-target sullo SPREAD (causale: vol realizzata <= i).""" + s = spread_ret(j) + bpd = al.bars_per_day(j) + bpy = bpd * ANN + vol = al.realized_vol(s, max(2, win_days * bpd), bpy) + scal = np.where((vol > 0) & np.isfinite(vol), target_vol / vol, 0.0) + pos = np.clip(np.nan_to_num(direction) * scal, -cap, cap) + pos[~np.isfinite(pos)] = 0.0 + return pos + + +def eval_spread(j: pd.DataFrame, pos: np.ndarray, fee_side: float = FEE_SIDE) -> dict: + """Backtest ONESTO del SPREAD. pos[i] decisa <= close[i], TENUTA durante la barra i+1 + (lo shift è qui -> niente leak). Fee su 2 GAMBE: ogni Δpos muove ETH e BTC -> 2×|Δpos|.""" + pos = np.nan_to_num(np.asarray(pos, float)) + s = spread_ret(j) + held = np.zeros(len(pos)); held[1:] = pos[:-1] + gross = held * s + turn = np.abs(np.diff(held, prepend=0.0)) # turnover per-gamba + net = gross - fee_side * 2.0 * turn # 2 gambe + net[0] = 0.0 + idx = pd.DatetimeIndex(pd.to_datetime(j["datetime"], utc=True)) + full = al._metrics_from_net(net, idx) + hmask = idx >= HOLDOUT + hold = al._metrics_from_net(net[hmask], idx[hmask]) if hmask.sum() > 3 else dict(sharpe=0.0, n=0) + return dict(full=full, holdout=hold, yearly=al._yearly(net, idx), + tim=round(float(np.mean(held != 0)), 3), + turnover=round(float(turn.sum() / (len(turn) / (al.bars_per_day(j) * ANN))), 1), + net=net, idx=idx) + + +def spread_daily(j: pd.DataFrame, pos: np.ndarray, fee_side: float = FEE_SIDE) -> pd.Series: + """Serie NET giornaliera del candidato spread (compounded a 1d se TF sub-daily).""" + ev = eval_spread(j, pos, fee_side=fee_side) + s = pd.Series(ev["net"], index=ev["idx"]) + return al._to_daily(s) + + +def eval_spread_smallcap(j: pd.DataFrame, pos: np.ndarray, capital: float = 600.0, + min_order: float = 5.0, fee_side: float = FEE_SIDE) -> dict: + """Net REALISTICO a $600 su 2 GAMBE. Un Δpos il cui nozionale PER-GAMBA |Δpos|*capital < $5 + NON si esegue (held). Le due gambe cambiano dello stesso |Δ| -> il vincolo binding è + |Δpos|*capital >= min_order. Riporta Sharpe modellato vs realistico + haircut + n trade.""" + pos = np.clip(np.nan_to_num(np.asarray(pos, float)), -10, 10) + held = np.empty(len(pos)); cur = 0.0; n_tr = 0 + for i in range(len(pos)): + if abs(pos[i] - cur) * capital >= min_order: + cur = pos[i]; n_tr += 1 + held[i] = cur + s = spread_ret(j) + p = np.zeros(len(held)); p[1:] = held[:-1] + turn = np.abs(np.diff(p, prepend=0.0)) + net = p * s - fee_side * 2.0 * turn; net[0] = 0.0 + idx = pd.DatetimeIndex(pd.to_datetime(j["datetime"], utc=True)) + real = al._metrics_from_net(net, idx) + modeled = eval_spread(j, pos, fee_side=fee_side)["full"] + return dict(realistic=real, modeled=modeled, + sharpe_haircut=round(modeled["sharpe"] - real["sharpe"], 3), + n_executed_trades=int(n_tr)) + + +# =========================================================================== +# CAUSALITY — prefix-check sul SPREAD (la coda del prefisso deve combaciare col full). +# =========================================================================== +def causality_spread(make_pos, tf: str = "1d", tail: int = 80, tol: float = 1e-6) -> dict: + """make_pos(j) -> pos sull'intero frame. Ricostruisce su prefissi; la coda deve combaciare.""" + j = build_joint(tf) + full = np.nan_to_num(make_pos(j)) + n = len(j) + worst = 0.0; bad = False; checked = 0 + for cut in (int(n * 0.80), int(n * 0.92)): + if cut <= tail + 5 or cut >= n: + continue + sub = j.iloc[:cut].reset_index(drop=True) + s = np.nan_to_num(make_pos(sub)) + if len(s) != cut: + bad = True; continue + d = np.abs(s[cut - tail:cut] - full[cut - tail:cut]) + worst = max(worst, float(np.max(d)) if len(d) else 0.0) + checked += 1 + return dict(ok=bool((not bad) and worst <= tol), max_tail_diff=round(worst, 10), checked=checked) + + +# =========================================================================== +# SIGNAL FACTORIES — factory(tf, **p) -> make_pos(j) -> vol-targeted position on the spread +# (direzione in [-1,1] internamente, poi vol_target_spread). 'sgn' (+/-1) testa il verso. +# =========================================================================== +def f_ratio_mom(tf="1d", L=30, sgn=1, tv=0.20, vw=30, cap=2.0): + def make(j): + lr = j["log_ratio"].values + mom = lr - pd.Series(lr).shift(L).values # momentum L-barre del ratio + z = al.zscore(mom, max(20, L)) + d = sgn * np.tanh(np.nan_to_num(z)) + return vol_target_spread(d, j, tv, vw, cap) + return make + + +def f_ratio_rev(tf="1d", L=5, sgn=-1, tv=0.20, vw=30, cap=2.0): + def make(j): + lr = j["log_ratio"].values + z = al.zscore(lr, L) # deviazione di breve dal trend locale + d = sgn * np.tanh(np.nan_to_num(z)) + return vol_target_spread(d, j, tv, vw, cap) + return make + + +def f_ratio_accel(tf="1d", L=20, sgn=-1, tv=0.20, vw=30, cap=2.0): + def make(j): + lr = pd.Series(j["log_ratio"].values) + accel = lr - 2 * lr.shift(L) + lr.shift(2 * L) # 2a differenza (curvatura) + z = al.zscore(np.nan_to_num(accel.values), max(20, L)) + d = sgn * np.tanh(np.nan_to_num(z)) + return vol_target_spread(d, j, tv, vw, cap) + return make + + +def f_volspread(tf="1d", W=30, sgn=1, tv=0.20, vw=30, cap=2.0): + def make(j): + bpd = al.bars_per_day(j); bpy = bpd * ANN + vb = al.realized_vol(j["r_btc"].values, max(2, W * bpd), bpy) + ve = al.realized_vol(j["r_eth"].values, max(2, W * bpd), bpy) + z = al.zscore(np.nan_to_num(vb - ve), max(30, W)) # BTC più volatile di ETH? + d = sgn * np.tanh(np.nan_to_num(z)) + return vol_target_spread(d, j, tv, vw, cap) + return make + + +def f_dvolspread(tf="1d", W=30, sgn=1, tv=0.20, vw=30, cap=2.0): + def make(j): + db = al.dvol(j, "BTC"); de = al.dvol(j, "ETH") # vol IMPLICITA causale (merge_asof) + sp = np.nan_to_num(db - de) + z = al.zscore(sp, max(30, W)) + d = sgn * np.tanh(np.nan_to_num(z)) + return vol_target_spread(d, j, tv, vw, cap) + return make + + +def f_statarb_resid(tf="1d", W=60, sgn=-1, tv=0.20, vw=30, cap=2.0): + def make(j): + x = np.log(j["cb"].values); y = np.log(j["ce"].values) # OLS rolling causale y~a+b x + sx = pd.Series(x); sy = pd.Series(y) + mx = sx.rolling(W, min_periods=W).mean() + my = sy.rolling(W, min_periods=W).mean() + cov = (sx * sy).rolling(W, min_periods=W).mean() - mx * my + var = (sx * sx).rolling(W, min_periods=W).mean() - mx * mx + beta = (cov / var.replace(0, np.nan)) + resid = (sy - (my - beta * mx) - beta * sx).values # resid al tempo i (usa <= i) + z = al.zscore(np.nan_to_num(resid), W) + d = sgn * np.tanh(np.nan_to_num(z)) # mean-revert il residuo + return vol_target_spread(d, j, tv, vw, cap) + return make + + +FAMILIES = { + "RATIO-MOM": (f_ratio_mom, [dict(L=L, sgn=s) for L in (15, 30, 45, 60, 90) for s in (1, -1)], ("1d",)), + "RATIO-REV": (f_ratio_rev, [dict(L=L, sgn=s) for L in (3, 5, 8, 12, 20) for s in (1, -1)], ("1d",)), + "RATIO-ACCEL": (f_ratio_accel, [dict(L=L, sgn=s) for L in (10, 20, 30, 45) for s in (1, -1)], ("1d",)), + "VOLSPREAD": (f_volspread, [dict(W=W, sgn=s) for W in (10, 20, 30, 60) for s in (1, -1)], ("1d",)), + "DVOLSPREAD": (f_dvolspread, [dict(W=W, sgn=s) for W in (15, 30, 45, 60) for s in (1, -1)], ("1d",)), + "STATARB-RESID": (f_statarb_resid, [dict(W=W, sgn=s) for W in (30, 45, 60, 90, 120) for s in (1, -1)], ("1d",)), +} + + +# =========================================================================== +# BOOK + MARKET references (for corr / beta) +# =========================================================================== +def book_daily() -> pd.Series: + """Serie NET giornaliera del BOOK attivo a 4 sleeve (TP01+XS01+VRP01+SKH01).""" + from src.portfolio.sleeves import active_sleeves + from src.portfolio.portfolio import StrategyPortfolio + pf = StrategyPortfolio(active_sleeves()); pf.backtest() + s = pf.combined_daily() + if not isinstance(s.index, pd.DatetimeIndex): + s.index = pd.to_datetime(s.index, utc=True) + if s.index.tz is None: + s.index = s.index.tz_localize("UTC") + return s.dropna() + + +def market_daily() -> pd.Series: + """Mercato di riferimento: 50/50 BTC+ETH ritorni semplici giornalieri (per beta ~0).""" + series = {} + for a in ("BTC", "ETH"): + df = al.get(a, "1d") + series[a] = pd.Series(al.simple_returns(df["close"].values), + index=pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True))) + J = pd.concat(series, axis=1, join="inner").fillna(0.0) + return (0.5 * J["BTC"] + 0.5 * J["ETH"]).dropna() + + +def beta_to(cand: pd.Series, ref: pd.Series) -> tuple[float, float]: + J = pd.concat({"c": cand, "r": ref}, axis=1, join="inner").dropna() + if len(J) < 30 or J["r"].var() == 0: + return float("nan"), float("nan") + c, r = J["c"].values, J["r"].values + beta = float(np.cov(c, r)[0, 1] / np.var(r)) + return round(beta, 3), round(float(J["c"].corr(J["r"])), 3) + + +# =========================================================================== +# HONEST FAMILY STUDY for the SPREAD (replica study_family_honest a mano): +# (1) cella scelta su Sharpe IN-SAMPLE (pre-HOLDOUT), MAI sul max hold-out; +# (2) deflated-Sharpe su TUTTE le celle; (3) marginal_vs_tp01 sulla cella scelta. +# =========================================================================== +def _sh(s) -> float: + return al._sh(s) + + +def study_spread_family(name, factory, grid, tfs, refs, fee_side=FEE_SIDE, dsr_min=0.95) -> dict: + rows = [] + for tf in tfs: + for p in grid: + try: + j = build_joint(tf) + pos = factory(tf=tf, **p)(j) + daily = spread_daily(j, pos, fee_side=fee_side) + except Exception as ex: # pragma: no cover + rows.append(dict(tf=tf, params=p, err=str(ex)[:60])); continue + ins = daily[daily.index < HOLDOUT] + is_sh = _sh(ins) if len(ins) > 60 else float("nan") + rows.append(dict(tf=tf, params=p, daily=daily, insample_sharpe=round(is_sh, 3), + full_sharpe=round(_sh(daily), 3))) + valid = [r for r in rows if r.get("insample_sharpe") is not None + and np.isfinite(r.get("insample_sharpe", np.nan))] + if not valid: + return dict(name=name, chosen=None, earns_slot_honest=False, reason="no valid in-sample cell") + chosen = max(valid, key=lambda r: r["insample_sharpe"]) + all_full = [r["full_sharpe"] for r in rows if r.get("full_sharpe") is not None] + + j = build_joint(chosen["tf"]) + pos = factory(tf=chosen["tf"], **chosen["params"])(j) + daily = chosen["daily"] + ev = eval_spread(j, pos, fee_side=fee_side) + + # fee sweep (RT% = per-side ×2 legs? No: RT per leg = 2×side; on 2 legs the cost already + # ×2 in eval_spread. Report per-side grid -> "X%RT/leg".) + sweep = {} + for f in FEE_SWEEP: + sweep[f"{2*f*100:.2f}%RT/leg"] = round(eval_spread(j, pos, fee_side=f)["full"]["sharpe"], 3) + fee_survives = sweep.get(f"{2*0.0015*100:.2f}%RT/leg", -9) > 0 + + # marginal vs TP01 + marg = al.marginal_vs_tp01(daily) + # deflated Sharpe over the WHOLE grid (come da istruzione) + dsr, sr0 = al.deflated_sharpe(_sh(daily), all_full, daily) + dsr_pass = bool(np.isfinite(dsr) and dsr >= dsr_min) + # SENSIBILITÀ: la griglia include sgn=+1 E sgn=-1 (specchi: +Sh e -Sh) -> raddoppia la + # dispersione dei trial e gonfia il null-max. DSR "same-sign" = deflazione sul SOLO verso + # scelto (conta i soli lookback davvero in competizione). È il limite ottimistico onesto. + same = [r["full_sharpe"] for r in rows + if r.get("full_sharpe") is not None and r["params"].get("sgn") == chosen["params"].get("sgn")] + dsr_ss, sr0_ss = al.deflated_sharpe(_sh(daily), same if len(same) >= 2 else all_full, daily) + dsr_ss_pass = bool(np.isfinite(dsr_ss) and dsr_ss >= dsr_min) + + # corr/beta vs BOOK & MARKET + bbeta, bcorr = beta_to(daily, refs["book"]) + mbeta, mcorr = beta_to(daily, refs["market"]) + btcbeta, btccorr = beta_to(daily, refs["btc"]) + ethbeta, ethcorr = beta_to(daily, refs["eth"]) + + # $600 executability (2 legs) + sc = eval_spread_smallcap(j, pos) + + earns = bool(marg.get("marginal_verdict") == "ADDS" and marg.get("robust_oos", False) + and marg.get("has_insample_edge", False) and not marg.get("is_hedge", False) + and dsr_pass and fee_survives) + + return dict(name=name, n_cells=len(all_full), chosen=chosen, rows=valid, + ev=ev, sweep=sweep, fee_survives=fee_survives, marginal=marg, + deflated_sharpe=round(dsr, 3) if np.isfinite(dsr) else None, + expected_null_max=round(sr0, 3) if np.isfinite(sr0) else None, dsr_pass=dsr_pass, + dsr_samesign=round(dsr_ss, 3) if np.isfinite(dsr_ss) else None, + expected_null_max_ss=round(sr0_ss, 3) if np.isfinite(sr0_ss) else None, + dsr_ss_pass=dsr_ss_pass, n_cells_samesign=len(same), + book=dict(beta=bbeta, corr=bcorr), market=dict(beta=mbeta, corr=mcorr), + btc=dict(beta=btcbeta, corr=btccorr), eth=dict(beta=ethbeta, corr=ethcorr), + smallcap=sc, earns_slot_honest=earns) + + +def verdict_for(rep: dict) -> tuple[str, str]: + if rep.get("chosen") is None: + return "SCARTATO", "nessuna cella valida" + m = rep["marginal"] + is_edge = m.get("has_insample_edge"); is_sh = m.get("cand_insample_sharpe") + dsr = rep.get("deflated_sharpe"); mv = m.get("marginal_verdict") + ss = rep.get("dsr_samesign"); ss_tag = f"same-sign {ss} {'pass' if rep.get('dsr_ss_pass') else 'fail'}" + if rep["earns_slot_honest"]: + return "SLEEVE-CANDIDATE-eseguibile", "ADDS + robust_oos + edge in-sample + DSR pass + fee/exec ok" + if mv == "DILUTES": + return "SCARTATO", "DILUTES (abbassa il blend del book)" + if m.get("is_hedge"): + return "SCARTATO", "HEDGE (paga solo quando TP01 è debole, non alpha)" + if not is_edge: + return "NOISE", f"no edge in-sample (Sharpe<2025 {is_sh} < 0.5) -> diversification-math" + # ha edge in-sample reale: il discriminante è se MIGLIORA il book (marginal ADDS) + if mv == "ADDS": + if not rep.get("dsr_pass"): + return "LEAD-forward-monitor", (f"ADDS + edge in-sample {is_sh} + executable, MA deflated-Sharpe " + f"full-grid {dsr} < 0.95 ({ss_tag}) -> multiple-testing") + if not m.get("robust_oos"): + return "LEAD-forward-monitor", "ADDS + edge ma non robust_oos (single-window / multi-cut debole)" + return "LEAD-forward-monitor", "ADDS ma blocco fee/exec" + # edge standalone reale ma marginal NEUTRAL/REDUNDANT: NON migliora il book -> niente slot + return "NEUTRAL-standalone", (f"edge in-sample {is_sh} reale ma marginal={mv}: non migliora il book " + f"(corr~0 senza uplift = diversification-math; hold-out debole)") + + +def print_family(rep: dict): + print("=" * 100) + print(f"### {rep['name']}") + if rep.get("chosen") is None: + print(" SCARTATO:", rep.get("reason")); return + ch = rep["chosen"]; ev = rep["ev"]; m = rep["marginal"] + print(f" best cell (IN-SAMPLE pick): tf={ch['tf']} params={ch['params']} " + f"[cercate {rep['n_cells']} celle]") + print(f" in-sample Sharpe (pre-2025) {ch['insample_sharpe']} | FULL Sharpe {ev['full']['sharpe']} " + f"DD {ev['full']['maxdd']*100:.1f}% ret {ev['full']['ret']*100:+.0f}% | " + f"HOLD Sharpe {ev['holdout'].get('sharpe')} ret {ev['holdout'].get('ret',0)*100:+.0f}%") + print(f" time-in-mkt {ev['tim']} turnover/yr {ev['turnover']}") + print(f" per-anno: " + " ".join(f"{y}:{d['ret']*100:+.0f}%(dd{d['dd']*100:.0f})" + for y, d in ev["yearly"].items())) + print(f" fee sweep (per-leg RT): {rep['sweep']} fee_survives={rep['fee_survives']}") + print(f" corr vs BOOK {rep['book']['corr']} (beta {rep['book']['beta']}) | " + f"beta vs MERCATO(50/50) {rep['market']['beta']} (corr {rep['market']['corr']})") + print(f" corr/beta vs BTC {rep['btc']['corr']}/{rep['btc']['beta']} " + f"vs ETH {rep['eth']['corr']}/{rep['eth']['beta']}") + print(f" MARGINAL vs TP01: verdict={m.get('marginal_verdict')} corr_full={m.get('corr_full')} " + f"corr_hold={m.get('corr_hold')}") + print(f" has_insample_edge={m.get('has_insample_edge')} (standalone Sh<2025 {m.get('cand_insample_sharpe')}) " + f"robust_oos={m.get('robust_oos')} multicut={m.get('multicut_persistent')} {m.get('multicut_uplift')}") + print(f" blend w25: full {m['blends']['w25']['full']} (uplift {m['blends']['w25']['uplift_full']:+.3f}) " + f"hold {m['blends']['w25']['hold']} (uplift {m['blends']['w25']['uplift_hold']}) is_hedge={m.get('is_hedge')}") + print(f" DEFLATED-Sharpe full-grid {rep['deflated_sharpe']} (null-max {rep['expected_null_max']}, " + f"{rep['n_cells']} celle) pass={rep['dsr_pass']} | same-sign {rep['dsr_samesign']} " + f"(null-max {rep['expected_null_max_ss']}, {rep['n_cells_samesign']} celle) pass={rep['dsr_ss_pass']}") + sc = rep["smallcap"] + print(f" EXEC $600 (2 gambe): modeled Sh {sc['modeled']['sharpe']} -> realistic {sc['realistic']['sharpe']} " + f"(haircut {sc['sharpe_haircut']}) trade eseguiti {sc['n_executed_trades']}") + v, why = verdict_for(rep) + print(f" >>> VERDETTO: {v} — {why}") + print(f" earns_slot_honest = {rep['earns_slot_honest']}") + + +# =========================================================================== +# SECONDARIO — CRYPTO vs MACRO (GLD/QQQ/TLT) momentum relativo, merge_asof backward. +# =========================================================================== +def _eq_daily(sym: str) -> pd.Series: + p = _ROOT / "data" / "raw" / f"eq_{sym}_1d.parquet" + d = pd.read_parquet(p).sort_values("timestamp").reset_index(drop=True) + idx = pd.DatetimeIndex(pd.to_datetime(d["timestamp"], unit="ms", utc=True)) + return pd.Series(d["close"].values, index=idx) + + +def study_macro_rv(hedge: str, L: int = 60, sgn: int = 1, fee_side: float = FEE_SIDE, + refs: dict | None = None) -> dict: + """Long crypto (50/50 BTC+ETH) / short hedge ETF su momentum relativo L-giorni. + Allineamento: calendario dell'EQUITY (trading days); crypto allineata backward (no look-ahead). + Decisione <= close[i] del giorno di trading.""" + eqc = _eq_daily(hedge) + # crypto close 50/50 (geometric proxy: media dei log-prezzi normalizzati) + cb = al.get("BTC", "1d"); ce = al.get("ETH", "1d") + cbi = pd.Series(cb["close"].values, index=pd.DatetimeIndex(pd.to_datetime(cb["datetime"], utc=True))) + cei = pd.Series(ce["close"].values, index=pd.DatetimeIndex(pd.to_datetime(ce["datetime"], utc=True))) + cj = pd.concat({"b": cbi, "e": cei}, axis=1, join="inner").dropna() + crypto = np.exp(0.5 * np.log(cj["b"]) + 0.5 * np.log(cj["e"])) # crypto index level + # merge_asof backward: per ogni giorno equity, l'ultimo close crypto <= quel giorno + L_ = pd.DataFrame({"ts": eqc.index, "eq": eqc.values}).sort_values("ts") + R_ = pd.DataFrame({"ts": crypto.index, "cr": crypto.values}).sort_values("ts") + mg = pd.merge_asof(L_, R_, on="ts", direction="backward").dropna() + eqv = mg["eq"].values; crv = mg["cr"].values; idx = pd.DatetimeIndex(mg["ts"]) + r_eq = al.simple_returns(eqv); r_cr = al.simple_returns(crv) + s = r_cr - r_eq # long crypto / short hedge + lr = np.log(crv) - np.log(eqv) # log relative level + mom = lr - pd.Series(lr).shift(L).values + z = al.zscore(np.nan_to_num(mom), max(20, L)) + d = sgn * np.tanh(np.nan_to_num(z)) + # vol target on the macro spread + vol = al.realized_vol(s, 30, ANN) + scal = np.where((vol > 0) & np.isfinite(vol), 0.20 / vol, 0.0) + pos = np.clip(np.nan_to_num(d) * scal, -2.0, 2.0) + held = np.zeros(len(pos)); held[1:] = pos[:-1] + net = held * s - fee_side * 2.0 * np.abs(np.diff(held, prepend=0.0)); net[0] = 0.0 + full = al._metrics_from_net(net, idx) + hmask = idx >= HOLDOUT + hold = al._metrics_from_net(net[hmask], idx[hmask]) if hmask.sum() > 3 else dict(sharpe=0.0, n=0) + daily = al._to_daily(pd.Series(net, index=idx)) + ins = daily[daily.index < HOLDOUT] + is_sh = round(_sh(ins), 3) if len(ins) > 60 else None + bcorr = beta_to(daily, refs["book"])[1] if refs else None + mbeta, mcorr = beta_to(daily, refs["market"]) if refs else (None, None) + btcb = beta_to(daily, refs["btc"])[0] if refs else None + return dict(hedge=hedge, L=L, sgn=sgn, full=full, holdout=hold, + insample_sharpe=is_sh, book_corr=bcorr, mkt_beta=mbeta, mkt_corr=mcorr, + btc_beta=btcb, yearly=al._yearly(net, idx), daily=daily) + + +# =========================================================================== +# MAIN +# =========================================================================== +def main(): + print("#" * 100) + print("# ORTHOGONAL SIGNALS — RELATIVE-VALUE ETH/BTC (dollar-neutral) + CRYPTO-vs-MACRO") + print("#" * 100) + j = build_joint("1d") + print(f"Joint BTC/ETH 1d: {len(j)} barre {j['datetime'].min().date()} -> {j['datetime'].max().date()} " + f"(hold-out {HOLDOUT.date()}+)") + s = spread_ret(j) + print(f"Spread r_eth-r_btc: vol annua {np.std(s)*math.sqrt(ANN)*100:.0f}% " + f"corr(r_eth,r_btc)={np.corrcoef(j['r_eth'].values[1:], j['r_btc'].values[1:])[0,1]:.2f}") + + # references + print("\nCarico riferimenti BOOK / MERCATO ...") + refs = dict(book=book_daily(), market=market_daily()) + cb = al.get("BTC", "1d"); ce = al.get("ETH", "1d") + refs["btc"] = pd.Series(al.simple_returns(cb["close"].values), + index=pd.DatetimeIndex(pd.to_datetime(cb["datetime"], utc=True))) + refs["eth"] = pd.Series(al.simple_returns(ce["close"].values), + index=pd.DatetimeIndex(pd.to_datetime(ce["datetime"], utc=True))) + print(f" BOOK 4-sleeve: {len(refs['book'])} giorni, Sharpe {_sh(refs['book']):.2f}") + + # causality smoke-test on one cell per family + print("\n--- CAUSALITÀ (prefix-check sullo SPREAD, tol 1e-6) ---") + for name, (fac, grid, tfs) in FAMILIES.items(): + p0 = grid[0] + ck = causality_spread(fac(tf=tfs[0], **p0), tf=tfs[0]) + print(f" {name:14s} ok={ck['ok']} max_tail_diff={ck['max_tail_diff']} checked={ck['checked']}") + + # study each family + print("\n" + "#" * 100) + print("# RELATIVE-VALUE ETH/BTC — selezione cella IN-SAMPLE, deflated-Sharpe su tutta la griglia") + print("#" * 100) + summary = [] + for name, (fac, grid, tfs) in FAMILIES.items(): + rep = study_spread_family(name, fac, grid, tfs, refs) + print_family(rep) + v, why = verdict_for(rep) + ch = rep.get("chosen") + summary.append(dict(name=name, verdict=v, + cell=(ch["params"] if ch else None), + full=rep["ev"]["full"]["sharpe"] if ch else None, + hold=rep["ev"]["holdout"].get("sharpe") if ch else None, + is_sh=rep["marginal"].get("cand_insample_sharpe") if ch else None, + book_corr=rep["book"]["corr"] if ch else None, + mkt_beta=rep["market"]["beta"] if ch else None, + dsr=rep.get("deflated_sharpe") if ch else None, + marginal=rep["marginal"].get("marginal_verdict") if ch else None, + earns=rep.get("earns_slot_honest") if ch else False, + haircut=rep["smallcap"]["sharpe_haircut"] if ch else None)) + + # secondary: crypto vs macro + print("\n" + "#" * 100) + print("# SECONDARIO — CRYPTO vs MACRO (relative momentum, merge_asof backward)") + print("#" * 100) + macro_rows = [] + for hedge in ("gld", "qqq", "tlt"): + best = None + for L in (30, 60, 90): + for sgn in (1, -1): + r = study_macro_rv(hedge, L=L, sgn=sgn, refs=refs) + if best is None or (r["insample_sharpe"] or -9) > (best["insample_sharpe"] or -9): + best = r + macro_rows.append(best) + print(f" {hedge.upper():4s} bestIS L={best['L']} sgn={best['sgn']}: " + f"FULL Sh {best['full']['sharpe']} DD {best['full']['maxdd']*100:.0f}% " + f"HOLD Sh {best['holdout'].get('sharpe')} in-sample Sh {best['insample_sharpe']} " + f"corr->BOOK {best['book_corr']} beta->MERCATO {best['mkt_beta']} (corr {best['mkt_corr']}) " + f"beta->BTC {best['btc_beta']}") + print(" NOTA: la vol crypto (~47%) domina GLD/TLT/QQQ (~15%) -> r_cr-r_hedge ≈ r_cr: la 'relative" + " momentum' è di fatto MOMENTUM CRYPTO (la gamba hedge è troppo poco volatile per neutralizzare).") + print(" Beta-mercato ~0 solo perché il momentum entra/esce dall'esposizione; ma la corr->BOOK" + " 0.17-0.20 (vs ~0.02 degli spread ETH/BTC) tradisce l'overlap col trend di TP01 -> NON ortogonale.") + # marginal gate onesto sul migliore macro (per smentire il numero di Sharpe tentatore) + bm = max(macro_rows, key=lambda r: (r["insample_sharpe"] or -9)) + mg = al.marginal_vs_tp01(bm["daily"]) + print(f" MARGINAL gate sul migliore ({bm['hedge'].upper()}): verdict={mg.get('marginal_verdict')} " + f"corr_full={mg.get('corr_full')} blend-uplift w25 full {mg['blends']['w25']['uplift_full']:+.3f} " + f"hold {mg['blends']['w25']['uplift_hold']} robust_oos={mg.get('robust_oos')} " + f"-> {'overlap col trend (no slot)' if mg.get('marginal_verdict') in ('NEUTRAL','REDUNDANT','DILUTES') else 'da gate completo'}") + + # final table + print("\n" + "#" * 100) + print("# SINTESI") + print("#" * 100) + print(f"{'segnale':14s} {'verdetto':28s} {'FULL':>5s} {'HOLD':>5s} {'IS-Sh':>5s} " + f"{'corrBK':>6s} {'mβ':>5s} {'DSR':>5s} {'marg':>9s} {'cut':>6s} earns") + for r in summary: + print(f"{r['name']:14s} {r['verdict']:28s} " + f"{str(r['full']):>5s} {str(r['hold']):>5s} {str(r['is_sh']):>5s} " + f"{str(r['book_corr']):>6s} {str(r['mkt_beta']):>5s} {str(r['dsr']):>5s} " + f"{str(r['marginal']):>9s} {str(r['haircut']):>6s} {r['earns']}") + print("\nMACRO (secondario): " + " | ".join( + f"{m['hedge'].upper()} IS={m['insample_sharpe']} HOLD={m['holdout'].get('sharpe')} bkcorr={m['book_corr']}" + for m in macro_rows)) + + winners = [r for r in summary if r["earns"]] + leads = [r for r in summary if r["verdict"].startswith("LEAD")] + standalone = [r for r in summary if r["verdict"].startswith("NEUTRAL")] + print("\n" + "=" * 100) + print("CONCLUSIONE — c'è uno stream scorrelato CON edge reale ED eseguibile a 2 gambe?") + print("=" * 100) + if winners: + print("SI -> SLEEVE-CANDIDATE eseguibile a 2 gambe:", ", ".join(r["name"] for r in winners)) + else: + print("NO sleeve pronto. L'ORTOGONALITÀ c'è (corr->BOOK ~0.02, beta-mercato ~0.01: il filone") + print("relative-value ETH/BTC è scorrelato per costruzione, ed è ESEGUIBILE a $600 — haircut ~0,") + print("fee-surviving a 0.30%RT/gamba). Ma l'EDGE non passa il deflated-Sharpe:") + if leads: + print(" LEAD (forward-monitor, ADDS + edge in-sample ma DSR<0.95):", + ", ".join(r["name"] for r in leads)) + if standalone: + print(" NEUTRAL-standalone (edge reale, NON migliora il book):", + ", ".join(r["name"] for r in standalone)) + print("Dettaglio nei verdetti per-segnale sopra.") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/signal_inout_1leg.py b/scripts/research/signal_inout_1leg.py new file mode 100644 index 0000000..62a4078 --- /dev/null +++ b/scripts/research/signal_inout_1leg.py @@ -0,0 +1,400 @@ +"""signal_inout_1leg — 1-GAMBA a SEGNALI classici (entrata+uscita) su BTC/ETH (2026-06-29). + +TESI +---- +Un filone con un VANTAGGIO STRUTTURALE diverso dai book multi-gamba (XS01, CC01, basis, +short-vol) che restano STAT-MODE perche' a $600 non si eseguono: qui ogni strategia e' a +**1 SOLA GAMBA** (un singolo asset, BTC o ETH) con entrata E uscita gestite da SEGNALI +CLASSICI (MACD, RSI, Supertrend/ATR-trail, Donchian, Bollinger, EMA-cross, MACD+ADX). +Turnover basso => realmente ESEGUIBILE a $600 (cap $300/asset, min $5). Il vantaggio del +filone NON e' lo Sharpe assoluto — e' l'eseguibilita'. + +MA la lezione del progetto e' brutale: un 1-gamba direzionale su BTC/ETH o e' + (a) TREND-FOLLOWING -> corr ~0.7-0.9 a TP01, marginal REDUNDANT (TP01 travestito), oppure + (b) MEAN-REVERSION -> morto sul feed reale (fade negativo anche a fee zero, v2.0.0), o + muore di fee a sub-daily. +Il soffitto direzionale BTC/ETH e' ~1.3 (= TP01). Quindi NON si giudica sullo Sharpe +assoluto ma sul MARGINALE vs TP01 (earns_slot), e la cella si sceglie IN-SAMPLE-ONLY +(no peeking del hold-out — il punto cieco del filone B / intraday-ERM). + +GATE (tutti dall'harness condiviso altlib, leak-free by construction) +--------------------------------------------------------------------- + 1. study_family_honest(name, factory, grid, tfs): + - sceglie la cella per Sharpe IN-SAMPLE (pre-2025), MAI per max hold-out; + - study_marginal sulla cella scelta (corr vs TP01, blend uplift full/hold, + is_hedge, has_insample_edge, robust_oos multi-cut) -> earns_slot; + - deflated-Sharpe (Bailey & Lopez de Prado) su TUTTE le celle del grid (multiple-testing); + - earns_slot_honest = earns_slot AND DSR>=0.95. + 2. causality_ok: la costruzione del segnale e' causale (max_tail_diff ~0, no peeking). + 3. eval_weights_smallcap(capital=600, min_order=5): HAIRCUT $600 + n. trade eseguiti reali + (il punto FORTE del filone — turnover basso = eseguibile davvero). + 4. fee-sweep 0.00-0.20% RT (dentro study_weights) a frequenza reale. + 5. day_boundary_robust: per completezza (segnali di prezzo => INVARIANT atteso). + +Decisione finale per ogni segnale: + SLEEVE-CANDIDATE-eseguibile -> earns_slot_honest=True AND haircut ~0 (executable). RARO. + LEAD-forward-monitor -> marginal ADDS ma DSR/hold corto, o edge esile: monitor. + REDUNDANT-vs-TP01 -> trend travestito (corr alta, uplift ~0). + SCARTATO -> abs FAIL (negativo), DILUTES, o morte per fee. + +USO: cd /opt/docker/PythagorasGoal && uv run python scripts/research/signal_inout_1leg.py +Idempotente, solo stdout. NON committa (lo fa il coordinatore). +""" +from __future__ import annotations + +import sys + +import numpy as np +import pandas as pd + +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt") +import altlib as al # noqa: E402 + +CERTIFIED = al.CERTIFIED +TFS = ("1d", "12h", "8h") + + +# =========================================================================== +# INDICATORI extra (causali) non gia' in altlib +# =========================================================================== +def macd_lines(c: np.ndarray, fast: int, slow: int, sig: int): + """MACD = EMA(fast) - EMA(slow); signal = EMA(MACD, sig). EMA adjust=False => causale.""" + macd = al.ema(c, fast) - al.ema(c, slow) + signal = al.ema(macd, sig) + return macd, signal + + +def supertrend_dir(df: pd.DataFrame, atr_win: int, mult: float) -> np.ndarray: + """Supertrend classico -> direzione {+1 up, -1 down}. CAUSALE: dir[i] usa close[i] e le + bande finali calcolate fino a i-1 (mai high/low di i come prezzo d'ingresso).""" + h = df["high"].values.astype(float) + l = df["low"].values.astype(float) + c = df["close"].values.astype(float) + a = al.atr(df, atr_win) + hl2 = (h + l) / 2.0 + upper = hl2 + mult * a + lower = hl2 - mult * a + n = len(c) + fu = np.zeros(n) + fl = np.zeros(n) + d = np.ones(n) + for i in range(n): + if i == 0 or not np.isfinite(a[i]): + fu[i], fl[i], d[i] = upper[i], lower[i], 1.0 + continue + fu[i] = upper[i] if (upper[i] < fu[i - 1] or c[i - 1] > fu[i - 1]) else fu[i - 1] + fl[i] = lower[i] if (lower[i] > fl[i - 1] or c[i - 1] < fl[i - 1]) else fl[i - 1] + if c[i] > fu[i - 1]: + d[i] = 1.0 + elif c[i] < fl[i - 1]: + d[i] = -1.0 + else: + d[i] = d[i - 1] + return d + + +def adx(df: pd.DataFrame, win: int = 14) -> np.ndarray: + """ADX di Wilder (forza di trend), causale via EWM. Usato come gate anti-whipsaw.""" + h = df["high"].values.astype(float) + l = df["low"].values.astype(float) + up = np.zeros(len(h)) + dn = np.zeros(len(h)) + up[1:] = h[1:] - h[:-1] + dn[1:] = l[:-1] - l[1:] + plus_dm = np.where((up > dn) & (up > 0), up, 0.0) + minus_dm = np.where((dn > up) & (dn > 0), dn, 0.0) + atr_ = al.atr(df, win) + den = np.where(atr_ > 0, atr_, np.nan) + plus_di = 100 * pd.Series(plus_dm).ewm(alpha=1 / win, adjust=False).mean().values / den + minus_di = 100 * pd.Series(minus_dm).ewm(alpha=1 / win, adjust=False).mean().values / den + s = plus_di + minus_di + dx = 100 * np.abs(plus_di - minus_di) / np.where(s > 0, s, np.nan) + return pd.Series(dx).ewm(alpha=1 / win, adjust=False).mean().values + + +def _hold(pos_raw: np.ndarray) -> np.ndarray: + """ffill dello stato fra i segnali (NaN = mantieni posizione precedente), start flat.""" + return pd.Series(pos_raw).ffill().fillna(0.0).values + + +# =========================================================================== +# FACTORY dei segnali — ognuna: factory(tf, **params) -> target_fn(df)->posizione causale +# (tf passa solo per firma; la granularita' la sceglie candidate_daily caricando get(asset,tf)) +# =========================================================================== +def make_macd(mode: str): + def factory(tf, fast, slow, sig): + def target(df): + c = df["close"].values.astype(float) + m, s = macd_lines(c, fast, slow, sig) + if mode == "LF": + return np.where(m > s, 1.0, 0.0) + return np.where(m > s, 1.0, -1.0) + return target + return factory + + +def make_rsi(): + """Mean-reversion long-flat: entra quando RSIoverbought, HOLD in mezzo.""" + def factory(tf, win, oversold, overbought): + def target(df): + c = df["close"].values.astype(float) + ind = al.rsi(c, win) + raw = np.full(len(c), np.nan) + raw[ind < oversold] = 1.0 + raw[ind > overbought] = 0.0 + return _hold(raw) + return target + return factory + + +def make_supertrend(mode: str): + def factory(tf, atr_win, mult): + def target(df): + d = supertrend_dir(df, atr_win, mult) + return np.clip(d, 0, None) if mode == "LF" else d + return target + return factory + + +def make_donchian(mode: str): + """Turtle: long su breakout del canale alto, esce/short sul canale basso, HOLD in mezzo.""" + def factory(tf, win): + def target(df): + hi, lo = al.donchian(df, win) + c = df["close"].values.astype(float) + raw = np.full(len(c), np.nan) + raw[c > hi] = 1.0 + raw[c < lo] = 0.0 if mode == "LF" else -1.0 + return _hold(raw) + return target + return factory + + +def make_bbands(mode: str): + """MR: long sotto banda bassa, esce al ritorno sopra la media. BO: long sopra banda alta, + flat/short sotto banda bassa.""" + def factory(tf, win, k): + def target(df): + c = df["close"].values.astype(float) + up, mid, lo = al.bbands(c, win, k) + raw = np.full(len(c), np.nan) + if mode == "MR": + raw[c < lo] = 1.0 + raw[c > mid] = 0.0 + else: # breakout + raw[c > up] = 1.0 + raw[c < lo] = 0.0 + return _hold(raw) + return target + return factory + + +def make_ema(mode: str): + def factory(tf, fast, slow): + def target(df): + c = df["close"].values.astype(float) + ef, es = al.ema(c, fast), al.ema(c, slow) + if mode == "LF": + return np.where(ef > es, 1.0, 0.0) + return np.where(ef > es, 1.0, -1.0) + return target + return factory + + +def make_macd_adx(): + """MACD long-flat gated da ADX (entra long solo se MACD>signal E trend abbastanza forte).""" + def factory(tf, fast, slow, sig, adx_win, adx_thr): + def target(df): + c = df["close"].values.astype(float) + m, s = macd_lines(c, fast, slow, sig) + a = adx(df, adx_win) + return np.where((m > s) & (a > adx_thr), 1.0, 0.0) + return target + return factory + + +# =========================================================================== +# GRID +# =========================================================================== +def macd_grid(): + g = [] + for fast in (8, 12, 16): + for slow in (21, 26, 34): + for sig in (9,): + if fast < slow: + g.append(dict(fast=fast, slow=slow, sig=sig)) + return g + + +RSI_GRID = [dict(win=14, oversold=os, overbought=ob) + for os in (25, 30, 35) for ob in (60, 65, 70)] +SUPER_GRID = [dict(atr_win=w, mult=m) for w in (10, 14, 20) for m in (2.0, 2.5, 3.0)] +DONCH_GRID = [dict(win=w) for w in (20, 30, 40, 55)] +BB_GRID = [dict(win=20, k=k) for k in (2.0, 2.5)] +EMA_GRID = [dict(fast=f, slow=s) for f in (10, 20, 30) for s in (50, 100, 200) if f < s] +MACD_ADX_GRID = [dict(fast=12, slow=26, sig=9, adx_win=14, adx_thr=t) for t in (15, 20, 25)] + + +# =========================================================================== +# DRIVER +# =========================================================================== +def _yr_line(absolute: dict) -> str: + """Per-anno minimo-fra-asset dalla parte assoluta (cella scelta), TF best.""" + cells = absolute.get("cells", []) + if not cells: + return "" + c = cells[0] + out = [] + for a, pa in c["per_asset"].items(): + yr = " ".join(f"{y}:{(d['ret'] if isinstance(d, dict) else d) * 100:+.0f}%" + for y, d in pa["yearly"].items()) + out.append(f" {a}: {yr}") + return "\n".join(out) + + +def classify(rep: dict, haircut_ok: bool) -> tuple[str, str]: + """Verdetto a 4 etichette + motivo di una riga.""" + if rep.get("chosen") is None: + return "SCARTATO", "nessuna cella in-sample valida" + m = rep["marginal"]["marginal"] + mv = rep["marginal"]["marginal_verdict"] + abs_grade = rep["marginal"]["abs_grade"] + corr = m.get("corr_full") + uph = (m.get("blends", {}).get("w25", {}) or {}).get("uplift_hold") + hsh = m.get("cand_hold_sharpe") + trend_like = corr is not None and corr >= 0.5 + if rep.get("earns_slot_honest"): + if haircut_ok: + return "SLEEVE-CANDIDATE-eseguibile", "earns_slot_honest=True + haircut $600 ~0 (extra-scettico: possibile selection/fee artifact)" + return "LEAD-forward-monitor", "earns_slot_honest=True ma haircut $600 non trascurabile" + if mv == "HEDGE": + return "LEAD-forward-monitor", "HEDGE: low-corr ma paga solo quando TP01 e' debole (non alpha standing); DSR/abs sotto soglia" + if mv == "DILUTES": + return "SCARTATO", "DILUTES: trascina giu' il blend TP01 (no edge marginale)" + if abs_grade == "FAIL": + if trend_like: + return "REDUNDANT-vs-TP01", f"trend = TP01 travestito (corr {corr}); la cella in-sample-best (sub-daily) overfitta -> hold-Sh {hsh} OOS" + return "SCARTATO", f"abs FAIL: la cella in-sample-best non generalizza OOS (hold-Sh {hsh})" + if mv == "REDUNDANT" or trend_like: + return "REDUNDANT-vs-TP01", f"trend travestito: corr {corr} a TP01, marginal {mv}, uplift-hold non persistente" + if mv == "ADDS": + return "LEAD-forward-monitor", "marginal ADDS ma deflated-Sharpe non passa (multiple-testing)" + return "REDUNDANT-vs-TP01", f"{mv}: nessun uplift marginale robusto (corr {corr})" + + +def run_family(name: str, factory, grid, tfs=TFS): + print("\n" + "=" * 100) + print(f"### {name} (grid {len(grid)} celle x {len(tfs)} TF = {len(grid) * len(tfs)} prove)") + print("=" * 100) + rep = al.study_family_honest(name, factory, grid, tfs) + ch = rep.get("chosen") + if ch is None: + print(f" -> {rep.get('reason', 'no chosen')}") + v, why = classify(rep, False) + print(f" VERDETTO: {v} — {why}") + return rep, (name, v, why, None) + + fn = factory(tf=ch["tf"], **ch["params"]) + m = rep["marginal"]["marginal"] + mv = rep["marginal"]["marginal_verdict"] + absr = rep["marginal"]["absolute"] + bl = m.get("blends", {}) + w25 = bl.get("w25", {}) + w50 = bl.get("w50", {}) + + print(f" cella scelta IN-SAMPLE: tf={ch['tf']} {ch['params']} " + f"insample-Sh={ch['insample_sharpe']} full-Sh={ch['full_sharpe']}") + print(f" ABS grade={rep['marginal']['abs_grade']} | cand full-Sh={m.get('cand_full_sharpe')} " + f"hold-Sh={m.get('cand_hold_sharpe')} (TP01 full {m.get('tp01_full_sharpe')}/hold {m.get('tp01_hold_sharpe')})") + print(f" MARGINAL={mv} corr->TP01 full={m.get('corr_full')} hold={m.get('corr_hold')} " + f"is_hedge={m.get('is_hedge')} has_insample_edge={m.get('has_insample_edge')} robust_oos={m.get('robust_oos')}") + print(f" blend w25: full {w25.get('full')} (uplift {w25.get('uplift_full')}) " + f"hold {w25.get('hold')} (uplift {w25.get('uplift_hold')}) DD {w25.get('dd')}") + print(f" blend w50: full {w50.get('full')} (uplift {w50.get('uplift_full')}) " + f"hold {w50.get('hold')} (uplift {w50.get('uplift_hold')})") + print(f" multicut uplift {m.get('multicut_uplift')} persistent={m.get('multicut_persistent')}") + print(f" DEFLATED-SHARPE={rep.get('deflated_sharpe')} (null-max {rep.get('expected_null_max')}, " + f"n_cells {rep.get('n_cells')}) dsr_pass={rep.get('dsr_pass')}") + print(f" >>> earns_slot(marginal)={rep['earns_slot_marginal']} EARNS_SLOT_HONEST={rep['earns_slot_honest']}") + + # causalita' + caus = al.causality_ok(fn, tf=ch["tf"]) + print(f" causality_ok={caus['ok']} max_tail_diff={caus['max_tail_diff']}") + + # HAIRCUT $600 + n. trade (il punto forte del filone) + print(" ESEGUIBILITA' $600 (haircut + n.trade eseguiti reali):") + haircut_ok = True + for a in CERTIFIED: + df = al.get(a, ch["tf"]) + tgt = al._call_target(fn, df, a) + sc = al.eval_weights_smallcap(df, tgt, capital=600, min_order=5) + print(f" {a}: modeled Sh={sc['modeled']['sharpe']} real$600 Sh={sc['realistic']['sharpe']} " + f"haircut={sc['sharpe_haircut']} n_trade_eseguiti={sc['n_executed_trades']} " + f"turnover/yr={sc['executed_turnover_per_year']}") + if abs(sc["sharpe_haircut"]) > 0.25: + haircut_ok = False + + # day-boundary (segnale di prezzo => INVARIANT atteso) + try: + db = al.day_boundary_robust(fn, tf=ch["tf"]) + print(f" day_boundary: {db['verdict']} (spread {db.get('spread')})") + except Exception as e: # noqa + print(f" day_boundary: skip ({type(e).__name__})") + + # per-anno + yr = _yr_line(absr) + if yr: + print(" per-anno (cella scelta):") + print(yr) + + v, why = classify(rep, haircut_ok) + print(f" VERDETTO: {v} — {why}") + return rep, (name, v, why, rep.get("earns_slot_honest")) + + +def main(): + print("#" * 100) + print("# SIGNAL-IN/OUT 1-GAMBA — MACD/RSI/Supertrend/Donchian/Bollinger/EMA/MACD+ADX su BTC,ETH") + print(f"# TP01 baseline daily full Sharpe = {round(al._sh(al.tp01_baseline_daily()), 3)} (il soffitto da battere al MARGINE)") + print(f"# TF testati: {TFS} | fee 0.10% RT + sweep 0..0.20% | capitale reale $600") + print("#" * 100) + + summary = [] + families = [ + ("MACD-LF (long-flat)", make_macd("LF"), macd_grid()), + ("MACD-LS (long-short)", make_macd("LS"), macd_grid()), + ("RSI-MR (mean-rev long-flat)", make_rsi(), RSI_GRID), + ("SUPERTREND-LF", make_supertrend("LF"), SUPER_GRID), + ("SUPERTREND-LS", make_supertrend("LS"), SUPER_GRID), + ("DONCHIAN-LF (turtle)", make_donchian("LF"), DONCH_GRID), + ("DONCHIAN-LS (turtle)", make_donchian("LS"), DONCH_GRID), + ("BBANDS-MR (mean-rev)", make_bbands("MR"), BB_GRID), + ("BBANDS-BO (breakout)", make_bbands("BO"), BB_GRID), + ("EMA-CROSS-LF", make_ema("LF"), EMA_GRID), + ("EMA-CROSS-LS", make_ema("LS"), EMA_GRID), + ("MACD+ADX-LF (gated)", make_macd_adx(), MACD_ADX_GRID), + ] + for nm, fac, grid in families: + try: + _, row = run_family(nm, fac, grid) + summary.append(row) + except Exception as e: # noqa + import traceback + print(f"\n[ERRORE in {nm}] {type(e).__name__}: {e}") + traceback.print_exc() + summary.append((nm, "ERRORE", str(e), None)) + + print("\n" + "#" * 100) + print("# SOMMARIO FINALE") + print("#" * 100) + for nm, v, why, esh in summary: + flag = " <<< ESEGUIBILE+SCORRELATO" if esh else "" + print(f" {nm:32s} -> {v}{flag}") + print(f" {why}") + any_slot = any(esh for *_, esh in summary) + print("\nCONCLUSIONE: c'e' un 1-gamba a segnale che AGGIUNGE oltre TP01 ED e' eseguibile a $600?") + print(f" -> {'SI (verificare extra-scetticismo: selection/fee artifact)' if any_slot else 'NO — tutto REDUNDANT (trend=TP01) o SCARTATO (MR morta / fee). Risultato valido: base-rate confermata.'}") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/xsec_v3_lowrisk.py b/scripts/research/xsec_v3_lowrisk.py new file mode 100644 index 0000000..47a3862 --- /dev/null +++ b/scripts/research/xsec_v3_lowrisk.py @@ -0,0 +1,387 @@ +"""XSEC v3 — fattori cross-sectional "low-risk cousins" su ~51 alt Hyperliquid (1d, STAT-MODE). + +TESI (filone C, terza ondata). I primi due filoni cross-sectional hanno coperto: momentum (XS01, +sleeve attivo), reversal/idio-reversal, TOTAL-low-vol (LOWVOL) e betting-against-beta (BAB). +Restano TRE anomalie "cugine del low-risk", documentate in equity ma MAI provate qui, che POTREBBERO +diversificare il portafoglio essendo strutturalmente diverse dal momentum e dal total-vol: + + 1. MAX (lottery-demand, Bali-Cakici-Whitelaw 2011). Gli asset col MASSIMO rendimento giornaliero + piu' alto nelle ultime B sedute attraggono domanda "da lotteria" e poi sottoperformano. + SHORT high-max / LONG low-max -> score = -max(daily_ret over B). + Diverso dal momentum (e' la coda destra recente, non il trend) e dal total-vol (un singolo + estremo, non la dispersione). + + 2. IVOL (idiosyncratic vol, Ang-Hodrick-Xing-Zhang 2006). SHORT alta vol del RESIDUO + (dopo aver tolto beta*mercato su finestra B) / LONG bassa. score = -ivol_residuo. + DIVERSO da LOWVOL (gia' provato in v2) che usa la vol TOTALE: qui si toglie prima il fattore + di mercato, isolando il rischio idiosincratico (ortogonale a BAB, che e' il beta sistematico). + + 3. AMIHUD (illiquidity, Amihud 2002). Ranking su |ret|/dollar_volume medio su B (dollar_volume = + volume_coin * close, perche' il volume HL e' in coin -> va dollarizzato per confrontare asset). + Tesi standard: premio di illiquidita' -> LONG illiquido / SHORT liquido. In crypto il segno e' + incerto (flight-to-quality verso i major liquidi), quindi si provano ENTRAMBI i segni e si tiene + quello con tesi economica + edge: AMIHUD_ILLIQ (score=+amihud) vs AMIHUD_LIQ (score=-amihud). + +GATE OBBLIGATORI (CLAUDE.md + parita' con xsec_v2_nonmom): + - Griglia B in {20,30,60} x H in {5,10} x k in {5,8}, su ENTRAMBI gli universi (51-all, 19-major). + - CAUSALE: score a close[i], peso tenuto in i+1 (engine shifta W[i-1]*dret[i]); vol=0 gata. + Verifica prefix-consistency (xv.causality_prefix_check) sul best: ok=True, max_tail_diff~0. + - NETTO fee 0.10% RT su ogni gamba a ogni ribilancio (engine) + turnover/anno riportato. + - DEFLATED Sharpe (Bailey-Lopez de Prado) sul best, con TUTTI gli Sharpe FULL testati come trial + (multiple-testing): serve DSR>0.95 per un claim forte. + - corr vs XS01 e vs TP01 (vogliamo |corrXS|<0.6 per diversificare). + - Uplift del portafoglio 4->5 sleeve a 10% e 15% (active_sleeves, non modificati). + - Per-anno (breadth) + HOLD-OUT (2025-01-01+). + - ANTI-selection-on-holdout: il best e' scelto per HOLD massimo; si riporta ANCHE il best scelto + per Sharpe IN-SAMPLE (<2025) e si verifica che il deflated-Sharpe (che usa il FULL, in-sample + incluso) regga comunque. + +CAVEAT immutabili: storia ~2.5 anni (deflated-Sharpe + multiple-testing), book a molte gambe NON +eseguibile a $600 -> STAT-MODE / forward-monitor, MAI deploy. Nessuno sleeve registrato. + + uv run python scripts/research/xsec_v3_lowrisk.py +""" +from __future__ import annotations +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) +sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research")) + +import numpy as np +import pandas as pd + +import xsec_v2_nonmom as xv # HARNESS collaudato (engine, metriche, statistica, portafoglio) + +HOLDOUT = xv.HOLDOUT +metrics = xv.metrics + + +# =========================================================================== +# SCORE BUILDERS — "low-risk cousins". Tutti CAUSALI (dati <= i). Convenzione +# engine: long ALTO score / short BASSO score (vol=0 gata automaticamente). +# =========================================================================== +def make_max(PX, B): + """MAX / lottery-demand: massimo rendimento giornaliero nelle ultime B sedute. + score = -max -> long low-max / short high-max (la 'lotteria' sottoperforma).""" + px, n, A, DR, m = xv._precompute(PX) + ROLLMAX = DR.rolling(B, min_periods=int(0.6 * B)).max().values + def score_at(i): + mx = ROLLMAX[i] + valid = np.isfinite(mx) & np.isfinite(px[i]) + return -mx, valid + return score_at, B + 1 + + +def make_ivol(PX, B): + """IVOL: vol del RESIDUO dopo beta*mercato su finestra B (OLS in-window, esatto: + var_resid = var(y) - beta^2*var(m) con beta = cov/var, >= 0 per costruzione). + score = -ivol -> long bassa idio-vol / short alta (anomalia Ang et al.).""" + px, n, A, DR, m = xv._precompute(PX) + beta, varm = xv._rolling_beta(DR, m, B) # beta (n,A), varm (n,) + mp = int(0.6 * B) + ExDR = DR.rolling(B, min_periods=mp).mean() + ExDR2 = (DR * DR).rolling(B, min_periods=mp).mean() + varDR = (ExDR2 - ExDR ** 2).values # var population (coerente con _rolling_beta) + resid_var = varDR - (beta ** 2) * varm[:, None] + ivol = np.sqrt(np.clip(resid_var, 0.0, None)) + def score_at(i): + iv = ivol[i] + valid = np.isfinite(iv) & np.isfinite(beta[i]) & np.isfinite(px[i]) + return -iv, valid + return score_at, B + 1 + + +def make_amihud(PX, VOL, B, sign): + """AMIHUD illiquidity: media su B di |ret| / dollar_volume (volume_coin*close). + sign=+1 -> LONG illiquido (premio di illiquidita'); sign=-1 -> LONG liquido.""" + px, n, A, DR, m = xv._precompute(PX) + dvol = (VOL * PX).replace(0, np.nan) # dollarizza il volume in coin + illiq = (DR.abs() / dvol).replace([np.inf, -np.inf], np.nan) + AMI = illiq.rolling(B, min_periods=int(0.6 * B)).mean().values + def score_at(i): + a = AMI[i] + valid = np.isfinite(a) & np.isfinite(px[i]) + return sign * a, valid + return score_at, B + 1 + + +def amihud_builder(VOL_full, sign): + """Builder (PX,cfg) per AMIHUD che richiede il volume: chiude su VOL_full e lo RIALLINEA a + PX.index. Cosi' la causality_prefix_check (che tronca PX a PXc=PX[:cut] e chiama builder(PXc)) + riceve automaticamente VOLc = VOL_full.reindex(PXc.index) -> nessun look-ahead dal volume.""" + def builder(PX, p): + VOL = VOL_full.reindex(PX.index) + return make_amihud(PX, VOL, p["B"], sign) + return builder + + +# Griglia condivisa (parita' con i gate): B x H x k. +BHK = [dict(B=B, H=H, k=k) for B in (20, 30, 60) for H in (5, 10) for k in (5, 8)] + + +def build_mechanisms(VOL): + """Catalogo per-universo: AMIHUD va legato al VOL dell'universo corrente.""" + return { + "MAX": (lambda PX, p: make_max(PX, p["B"]), BHK), + "IVOL": (lambda PX, p: make_ivol(PX, p["B"]), BHK), + "AMIHUD_ILLIQ": (amihud_builder(VOL, +1), BHK), # long illiquido / short liquido + "AMIHUD_LIQ": (amihud_builder(VOL, -1), BHK), # long liquido / short illiquido + } + + +# Tesi economica per il verdetto AMIHUD (quale segno ha senso se mostra edge). +AMIHUD_THESIS = { + "AMIHUD_ILLIQ": "premio di illiquidita' (long illiquido / short liquido)", + "AMIHUD_LIQ": "flight-to-quality verso i major liquidi (long liquido / short illiquido)", +} + + +# =========================================================================== +# Helper +# =========================================================================== +def insample_sharpe(daily): + pre = daily[daily.index < HOLDOUT] + return metrics(pre)["sharpe"] if len(pre) > 30 else float("nan") + + +def per_year(daily): + _, yrs = xv.yr_breadth(daily) + years = [int(y) for y, _ in daily.groupby(daily.index.year)] + return [(y, round(v, 3)) for y, v in zip(years, yrs)] + + +def uplift_for(cand_daily, base, bf, bh, fractions=(0.10, 0.15)): + """Uplift portafoglio 4->5 sleeve riusando le CACHE di `base` (Sleeve cached). Ritorna + {fr: (cf, ch, wgt)} e il best combinato (dFULL+dHOLD).""" + cand_fn = lambda: cand_daily + out, best = {}, None + for fr in fractions: + wraw = fr / (1.0 - fr) # cand_frac ~ fr (sum_base=1) + cand = xv.Sleeve("XSV3_cand", wraw, cand_fn) + pf1 = xv.StrategyPortfolio(base + [cand]) + cf = metrics(pf1.combined_daily()) + ch = metrics(pf1.combined_daily(lo=HOLDOUT)) + wgt = pf1.weights().get("XSV3_cand", 0.0) + out[fr] = (cf, ch, wgt) + d = (cf["sharpe"] - bf["sharpe"]) + (ch["sharpe"] - bh["sharpe"]) + best = d if best is None else max(best, d) + return out, best + + +INSAMPLE_EDGE = 0.5 # gate del progetto (scorer indurito): edge standalone PRE-holdout >=0.5 + + +def robust_candidate(rows): + """Candidato GIUDICATO: NON il best-by-HOLD nudo (che premia il holdout-fitting: una config + negativa in-sample con HOLD alto e' overfit alla finestra OOS, lezione dello scorer indurito), + ma il best fra le config con EDGE IN-SAMPLE (>=0.5) E HOLD>0, ordinate per Sharpe BILANCIATO + (insample+hold)/2. Se nessuna ha in-sample edge -> None (il meccanismo non ha edge reale, + qualunque HOLD alto e' artefatto di selezione).""" + elig = [r for r in rows if np.isfinite(r["insample"]) and r["insample"] >= INSAMPLE_EDGE and r["hold"] > 0] + if not elig: + return None + return max(elig, key=lambda r: 0.5 * (r["insample"] + r["hold"])) + + +def verdict(cand, dsr, caus_ok, uplift_best, has_isedge): + full, hold, corrXS = cand["full"], cand["hold"], cand["corrXS"] + diversifies = abs(corrXS) < 0.6 + helps = (uplift_best is not None) and uplift_best > 0.10 + if not has_isedge: + return "SCARTATO", "nessuna config con edge in-sample>=0.5 + HOLD>0 (qualunque HOLD alto e' selezione-su-holdout)" + strong = (dsr > 0.95) and (hold > 0.30) and (full > 0.70) and caus_ok and diversifies + if strong and helps: + return "SLEEVE-CANDIDATE", "edge robusto (DSR>0.95, in-sample+OOS, causale, diversifica, alza il portafoglio)" + if (full > 0.5 and hold > 0.0 and diversifies and caus_ok) and (helps or dsr > 0.50): + return "LEAD-forward-monitor", "edge in-sample E OOS coerente + diversifica, ma DSR<0.95 (96 trial, storia ~2.5y)" + if full > 0.3 and hold > 0.0: + return "DEBOLE", "segno giusto ma Sharpe/robustezza insufficienti" + return "SCARTATO", "no edge (full/hold non positivi o non diversifica)" + + +# =========================================================================== +# MAIN +# =========================================================================== +def main(): + print("=" * 104) + print(" XSEC v3 — LOW-RISK COUSINS cross-sectional su Hyperliquid (MAX / IVOL / AMIHUD) — STAT-MODE") + print("=" * 104) + + tp_daily = xv.tp01_sleeve().daily() + xs_daily = xv.xsec_sleeve().daily() + print(" riferimenti corr: TP01 (trend, deployable) e XS01 (momentum cross-sec, sleeve attivo).") + + universes = {"51-all": None, "19-major": xv.XS_UNIVERSE} + mats = {} + for uname, u in universes.items(): + PX, VOL = xv.load_matrix(u) + mats[uname] = (PX, VOL) + print(f" universo {uname:<9}: {PX.shape[1]:>2} asset, {PX.shape[0]} giorni " + f"[{PX.index[0].date()} -> {PX.index[-1].date()}]") + + # ---- griglia completa: raccoglie tutte le righe + tutti gli Sharpe FULL (trial DSR) ---- + MECHS = ("MAX", "IVOL", "AMIHUD_ILLIQ", "AMIHUD_LIQ") + rows_by_mech = {mn: [] for mn in MECHS} + all_sr = [] + builders = {} # (uname, mech) -> builder (per causality) + for uname, (PX, VOL) in mats.items(): + mechs = build_mechanisms(VOL) + print("\n" + "#" * 104) + print(f"# UNIVERSO {uname}") + print("#" * 104) + for mn in MECHS: + builder, cfgs = mechs[mn] + builders[(uname, mn)] = builder + rows = xv.run_grid(PX, VOL, mn, builder, cfgs, xs_daily, tp_daily, uname) + for r in rows: + r["uni"] = uname + r["mech"] = mn + r["insample"] = insample_sharpe(r["daily"]) + rows_by_mech[mn].extend(rows) + all_sr.extend([r["full"] for r in rows]) + if not rows: + print(f"\n [{mn}] nessuna config valida") + continue + pos_full = sum(r["full"] > 0 for r in rows) + pos_hold = sum(r["hold"] > 0 for r in rows) + print(f"\n [{mn}] {len(rows)} config | plateau FULL>0: {pos_full}/{len(rows)}" + f" | HOLD>0: {pos_hold}/{len(rows)}") + print(f" {'cfg':<18}{'FULL':>7}{'inS':>7}{'HOLD':>7}{'DD%':>6}{'ret%':>7}" + f"{'anni+':>7}{'corrXS':>8}{'corrTP':>8}{'turn/y':>8}") + for r in sorted(rows, key=lambda r: -r["hold"])[:3]: + print(f" {xv.tag(r['cfg']):<18}{r['full']:>7.2f}{r['insample']:>7.2f}{r['hold']:>7.2f}" + f"{r['dd']*100:>6.0f}{r['ret']*100:>+7.0f}{r['pct']*100:>6.0f}%" + f"{r['corrXS']:>+8.2f}{r['corrTP']:>+8.2f}{r['turn']:>8.0f}") + + print(f"\n TRIAL TOTALI testati (per deflated-Sharpe): {len([s for s in all_sr if np.isfinite(s)])}") + + # ---- base portafoglio una sola volta (Sleeve cached, riusati per ogni candidato) ---- + base = xv.active_sleeves() + pf0 = xv.StrategyPortfolio(base); pf0.backtest() + bf = metrics(pf0.combined_daily()); bh = metrics(pf0.combined_daily(lo=HOLDOUT)) + + # ---- analisi per meccanismo ---- + # CANDIDATO GIUDICATO = robust_candidate (edge in-sample>=0.5 E HOLD>0, best bilanciato): evita la + # trappola del best-by-HOLD nudo (che premia config negative in-sample = overfit alla finestra OOS). + # Si riportano comunque, per trasparenza/anti-cherry, anche il naive best-HOLD e il best-inSAMPLE. + summary = [] + chosen_daily = {} # mech -> serie del candidato giudicato (per corr-matrix) + for mn in MECHS: + rows = rows_by_mech[mn] + print("\n" + "=" * 104) + print(f" MECCANISMO {mn}") + print("=" * 104) + if not rows: + print(" nessuna config valida -> SCARTATO") + summary.append((mn, "SCARTATO", "nessuna config valida", None)) + continue + + naive_hold = max(rows, key=lambda r: r["hold"]) + best_is = max(rows, key=lambda r: (r["insample"] if np.isfinite(r["insample"]) else -9)) + cand = robust_candidate(rows) + has_isedge = cand is not None + print(f" naive best-HOLD : [{naive_hold['uni']}] {xv.tag(naive_hold['cfg']):<16} " + f"FULL {naive_hold['full']:+.2f} inS {naive_hold['insample']:+.2f} HOLD {naive_hold['hold']:+.2f}" + f" {'(in-sample NEGATIVO -> holdout-fit)' if naive_hold['insample'] < INSAMPLE_EDGE else ''}") + print(f" best-inSAMPLE : [{best_is['uni']}] {xv.tag(best_is['cfg']):<16} " + f"FULL {best_is['full']:+.2f} inS {best_is['insample']:+.2f} HOLD {best_is['hold']:+.2f}" + f" (anti-selection-on-holdout)") + if not has_isedge: + print(f" CANDIDATO GIUDICATO: NESSUNA config con in-sample>=0.5 E HOLD>0 " + f"-> il meccanismo NON ha edge reale (ogni HOLD alto e' selezione-su-holdout).") + verd, why = verdict(naive_hold, float("nan"), True, None, False) + print(f"\n >>> VERDETTO {mn}: {verd} — {why}." + + (f" tesi: {AMIHUD_THESIS[mn]}" if mn in AMIHUD_THESIS else "")) + summary.append((mn, verd, why, dict(uni=naive_hold["uni"], cfg=xv.tag(naive_hold["cfg"]), + full=naive_hold["full"], hold=naive_hold["hold"], dd=naive_hold["dd"], + dsr=float("nan"), corrXS=naive_hold["corrXS"], up=None, isedge=False))) + continue + + daily = cand["daily"] + chosen_daily[mn] = daily + f, h, pct = xv.evalcfg(daily) + dsr, sr0 = xv.deflated_sharpe(f["sharpe"], all_sr, daily) + caus = xv.causality_prefix_check(*mats[cand["uni"]], builders[(cand["uni"], mn)], cand["cfg"]) + ups, up_best = uplift_for(daily, base, bf, bh) + + print(f" CANDIDATO GIUDICATO (in-sample>=0.5 & HOLD>0, best bilanciato):") + print(f" [{cand['uni']}] {xv.tag(cand['cfg']):<16} FULL {cand['full']:+.2f} " + f"inSAMPLE {cand['insample']:+.2f} HOLD {cand['hold']:+.2f} (in-sample EDGE = OK)") + print(f" standalone: DD {f['maxdd']*100:.0f}% ret {f['ret']*100:+.0f}% " + f"anni+ {pct*100:.0f}% turnover/y {cand['turn']:.0f}") + print(f" corr vs XS01 {cand['corrXS']:+.2f} | corr vs TP01 {cand['corrTP']:+.2f}") + print(f" CAUSALITA' prefix-check: ok={caus['ok']} max_tail_diff={caus['max_tail_diff']:.2e}") + print(f" DEFLATED Sharpe (N={len([s for s in all_sr if np.isfinite(s)])} trial): {dsr:.3f}" + f" | soglia Sharpe-max-null annualizz. {sr0:.2f} (serve DSR>0.95)") + print(f" per-anno: {per_year(daily)}") + print(f" UPLIFT portafoglio (base FULL {bf['sharpe']:.2f} / HOLD {bh['sharpe']:.2f}):") + for fr, (cf, ch, wgt) in ups.items(): + print(f" +cand @{wgt*100:>4.1f}% FULL {cf['sharpe']:.2f} ({cf['sharpe']-bf['sharpe']:+.2f})" + f" DD {cf['maxdd']*100:.0f}% | HOLD {ch['sharpe']:.2f} ({ch['sharpe']-bh['sharpe']:+.2f})") + + verd, why = verdict(cand, dsr, caus["ok"], up_best, has_isedge) + extra = f" tesi: {AMIHUD_THESIS[mn]}" if mn in AMIHUD_THESIS else "" + print(f"\n >>> VERDETTO {mn}: {verd} — {why}.{extra}") + summary.append((mn, verd, why, dict(uni=cand["uni"], cfg=xv.tag(cand["cfg"]), full=cand["full"], + hold=cand["hold"], dd=f["maxdd"], dsr=dsr, corrXS=cand["corrXS"], + up=up_best, isedge=True))) + + # ---- redundancy check: i 3 'low-risk cousins' sono UNA scommessa o TRE? ---- + if len(chosen_daily) >= 2: + print("\n" + "=" * 104) + print(" RIDONDANZA — correlazione tra i candidati giudicati (sono lo stesso bet 'evita-speculativo'?)") + print("=" * 104) + names = list(chosen_daily.keys()) + print(" " + "".join(f"{n[:8]:>10}" for n in names)) + for a in names: + line = f" {a[:8]:<10}" + for b in names: + line += f"{xv._corr(chosen_daily[a], chosen_daily[b]):>10.2f}" + print(line) + print(" NB: corr alta tra i candidati = sono la stessa anomalia low-risk in tre vesti, non tre edge.") + + # ---- AMIHUD: scegli il segno con tesi economica + edge ---- + print("\n" + "=" * 104) + print(" AMIHUD — scelta del segno (tesi economica + edge)") + print("=" * 104) + a_ill = next((s for s in summary if s[0] == "AMIHUD_ILLIQ"), None) + a_liq = next((s for s in summary if s[0] == "AMIHUD_LIQ"), None) + for s in (a_ill, a_liq): + if s and s[3]: + print(f" {s[0]:<14} FULL {s[3]['full']:+.2f} HOLD {s[3]['hold']:+.2f} " + f"DSR {s[3]['dsr']:.2f} corrXS {s[3]['corrXS']:+.2f} -> {s[1]} ({AMIHUD_THESIS[s[0]]})") + cand_signs = [s for s in (a_ill, a_liq) if s and s[3] and s[3]["full"] > 0 and s[3]["hold"] > 0] + if cand_signs: + win = max(cand_signs, key=lambda s: 0.5 * (s[3]["full"] + s[3]["hold"])) + print(f" -> segno con edge+tesi: {win[0]} ({AMIHUD_THESIS[win[0]]})") + else: + print(" -> NESSUN segno mostra edge positivo (full>0 e hold>0): AMIHUD SCARTATO in entrambi i versi.") + + # ---- sintesi finale ---- + print("\n" + "=" * 104) + print(" SINTESI FINALE — c'e' un sopravvissuto reale?") + print("=" * 104) + for mn, verd, why, info in summary: + if info: + print(f" {mn:<14} {verd:<22} FULL {info['full']:+.2f} HOLD {info['hold']:+.2f} " + f"DSR {info['dsr']:.2f} corrXS {info['corrXS']:+.2f} upliftBest {info['up'] if info['up'] is not None else float('nan'):+.2f}") + else: + print(f" {mn:<14} {verd}") + survivors = [s for s in summary if s[1] == "SLEEVE-CANDIDATE"] + leads = [s for s in summary if s[1] == "LEAD-forward-monitor"] + if survivors: + print(f"\n SOPRAVVISSUTO: {', '.join(s[0] for s in survivors)} (sleeve-candidate, comunque STAT-MODE).") + elif leads: + print(f"\n Nessuno sleeve-candidate. LEAD da forward-monitor: {', '.join(s[0] for s in leads)}.") + else: + print("\n NESSUN sopravvissuto: tutti DEBOLE/SCARTATO. Risultato valido (la maggior parte muore).") + + print("\n CAVEAT immutabili: storia ~2.5 anni (deflated-Sharpe + multiple-testing), book a molte") + print(" gambe NON eseguibile a $600 -> STAT-MODE / forward-monitor, MAI deploy. Nessuno sleeve") + print(" registrato: e' solo lavoro statistico (vincoli del filone C).") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/xsec_v3_momstruct.py b/scripts/research/xsec_v3_momstruct.py new file mode 100644 index 0000000..7df0a34 --- /dev/null +++ b/scripts/research/xsec_v3_momstruct.py @@ -0,0 +1,417 @@ +"""XSEC v3 — varianti STRUTTURALI di momentum cross-sectional su Hyperliquid (STAT-MODE). + +TESI (filone XS). XS01 (sleeve attivo) e' momentum cross-sectional sui 19 major: blend di lookback +[30,90] (z-score cross-sectional mediato) + gate di dispersione, vol-target 20%. Lezione del +progetto (diari 2026-06-19/20): "i margini su XS sono nella STRUTTURA DEL SEGNALE, non nel numero +di asset". Quindi NON allarghiamo l'universo: testiamo 4 COSTRUZIONI di momentum STRUTTURALMENTE +diverse e chiediamo se MIGLIORANO o DIVERSIFICANO XS01 (o se sono solo XS01 travestito). + +Varianti (tutte L/S dollar-neutral, top-k/bottom-k, CAUSALI; long alto score / short basso score): + RAMOM - RISK-ADJUSTED momentum: score = ritorno cumulato su L / vol realizzata su L + (momentum "Sharpe-like", non grezzo). Penalizza i trend rumorosi. + ACCEL - momentum ACCELERATION: score = mom(L_breve) - mom(L_lungo), la curvatura/2a differenza + del trend relativo (chi sta accelerando vs chi sta decelerando). + FIP - FROG-IN-THE-PAN / information discreteness: score = sign(mom) * ID, dove + ID = |%giorni-su - %giorni-giu| su L. Privilegia i trend LISCI (path consistente). + VOLSC - VOLATILITY-MANAGED momentum (Moreira-Muir): selezione = momentum, ma la LEVA del book + e' scalata dall'inverso della vol di MERCATO cross-section recente (rischia di piu' a + mercato calmo, meno in tempesta) invece del vol-target sulla vol della STRATEGIA. + +GIUDIZIO = MARGINALE vs XS01, non assoluto. Una variant con corr~0.9 a XS01 e Sharpe simile NON +aggiunge nulla (e' XS01 travestito). Per ognuna calcolo: (a) corr vs XS01 e TP01; (b) uplift del +PORTAFOGLIO 4->5 sleeve a 10%/15%; (c) SOSTITUZIONE di XS01 con la variant a parita' di peso. Vince +solo se DIVERSIFICA (corr<0.7) E migliora l'hold-out aggiunta, OPPURE DOMINA XS01 a parita' di slot. + +GATE (CLAUDE.md, metodologia obbligatoria): + 1. griglia L in {30,60,90} (Ls/Ll per ACCEL), H in {5,10}, k in {5,8}, ENTRAMBI universi (51/19). + 2. CAUSALE: score a close[i], peso tenuto in i+1 (engine shifta); vol=0 gata; prefix-check ok. + 3. NETTO fee 0.10% RT su ogni gamba/ribilancio + turnover; sweep fee monotona (test). + 4. DEFLATED Sharpe sul best con TUTTI gli Sharpe FULL come trial (multiple-testing; serve >0.95). + 5. per-anno + HOLD-OUT 2025-01-01. ANTI selection-on-holdout: riporto best per IN-SAMPLE(<2025) + E best per HOLD, e verifico col deflated-Sharpe. + 6. CAVEAT IMMUTABILE: book a molte gambe NON eseguibile a $600 -> STAT-MODE, MAI deploy. + + uv run python scripts/research/xsec_v3_momstruct.py +""" +from __future__ import annotations +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research")) +sys.path.insert(0, str(PROJECT_ROOT)) + +import numpy as np +import pandas as pd + +import xsec_v2_nonmom as xv # harness collaudato (load_matrix, xs_engine, evalcfg, ...) +from src.portfolio.sleeves import XS_UNIVERSE + +DPY = xv.DPY +TV = xv.TV +FEE = xv.FEE +HOLDOUT = xv.HOLDOUT + + +# =========================================================================== +# SCORE BUILDERS — closure score_at(i)->(score[A], valid[A]) + warmup. CAUSALI (dati <= i). +# Modellati su make_mom/make_resid di xsec_v2_nonmom.py. +# =========================================================================== +def make_ramom(PX, L): + """Risk-adjusted momentum: score = (px[i]/px[i-L]-1) / std(ritorni giornalieri su L).""" + px, n, A, DR, _ = xv._precompute(PX) + RVL = DR.rolling(L, min_periods=int(0.8 * L)).std().values + def score_at(i): + if i - L < 0: + return np.full(A, np.nan), np.zeros(A, bool) + r = px[i] / px[i - L] - 1.0 + rv = RVL[i] + with np.errstate(invalid="ignore", divide="ignore"): + score = r / rv + valid = np.isfinite(score) & np.isfinite(px[i]) & np.isfinite(px[i - L]) & (rv > 0) + return score, valid + return score_at, L + 1 + + +def make_accel(PX, Ls, Ll): + """Acceleration: score = mom(Ls) - mom(Ll) (Ls |.| alto.""" + px, n, A, DR, _ = xv._precompute(PX) + up = (DR > 0).astype(float).where(DR.notna()) + dn = (DR < 0).astype(float).where(DR.notna()) + mp = int(0.8 * L) + UPc = up.rolling(L, min_periods=mp).sum().values + DNc = dn.rolling(L, min_periods=mp).sum().values + CNT = DR.rolling(L, min_periods=mp).count().values + def score_at(i): + if i - L < 0: + return np.full(A, np.nan), np.zeros(A, bool) + r = px[i] / px[i - L] - 1.0 + c = CNT[i] + with np.errstate(invalid="ignore", divide="ignore"): + pu = UPc[i] / c + pdn = DNc[i] / c + idd = np.abs(pu - pdn) + score = np.sign(r) * idd + valid = (np.isfinite(px[i]) & np.isfinite(px[i - L]) & np.isfinite(idd) & (c >= mp)) + return score, valid + return score_at, L + 1 + + +# =========================================================================== +# ENGINE volatility-managed (VOLSC): selezione momentum top-k/bottom-k IDENTICA a xs_engine, ma il +# vol-target NON e' sulla vol della STRATEGIA bensi' sull'inverso della vol di MERCATO cross-section +# (equal-weight) recente (Moreira-Muir). Distinzione strutturale unica da XS01. CAUSALE (shift(1)). +# =========================================================================== +def xs_engine_mktvol(PX, VOL, score_at, H, k, B_mkt=20, target_vol=TV, fee=FEE, min_assets=10, + warmup=0, cap=3.0): + px = PX.values + vol = VOL.values + n, A = px.shape + dret = np.full((n, A), np.nan) + dret[1:] = px[1:] / px[:-1] - 1.0 + W = np.zeros((n, A)) + w = np.zeros(A) + for i in range(n): + if i >= warmup and i % H == 0: + score, valid = score_at(i) + valid = valid & np.isfinite(score) & (vol[i] > 0) + idxv = np.where(valid)[0] + if len(idxv) >= min_assets: + kk = min(k, len(idxv) // 2) + order = idxv[np.argsort(score[idxv])] + lo, hi = order[:kk], order[-kk:] + w = np.zeros(A) + w[hi] = 0.5 / kk + w[lo] = -0.5 / kk + else: + w = np.zeros(A) + W[i] = w + gross = np.zeros(n) + gross[1:] = np.nansum(W[:-1] * np.nan_to_num(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=PX.index) + # vol-target sulla vol di MERCATO (equal-weight), causale (shift 1): leva alta a mercato calmo + mkt = PX.pct_change().mean(axis=1) + sig_mkt = mkt.rolling(B_mkt, min_periods=int(0.6 * B_mkt)).std().shift(1) * np.sqrt(DPY) + scale = np.clip(np.nan_to_num(target_vol / sig_mkt.replace(0, np.nan).values, nan=0.0), 0, cap) + turn_py = float(turn.sum() / (n / DPY)) if n else 0.0 + return pd.Series(s.values * scale, index=PX.index), turn_py + + +def caus_check_mktvol(PX, VOL, builder, cfg, B_mkt=20, frac=0.85, tail=60, tol=1e-9): + """Prefix-check di causalita' per il pipeline VOLSC (engine custom): ricostruisce su un prefisso + e confronta la coda con la run completa. Look-ahead -> divergenza.""" + sa, warm = builder(PX, cfg) + full, _ = xs_engine_mktvol(PX, VOL, sa, cfg["H"], cfg["k"], B_mkt=B_mkt, warmup=warm) + cut = int(len(PX) * frac) + PXc, VOLc = PX.iloc[:cut], VOL.iloc[:cut] + sa2, warm2 = builder(PXc, cfg) + pre, _ = xs_engine_mktvol(PXc, VOLc, sa2, cfg["H"], cfg["k"], B_mkt=B_mkt, warmup=warm2) + lo = max(0, cut - tail) + a = full.values[lo:cut] + b = pre.values[lo:cut] + worst = float(np.max(np.abs(a - b))) if len(a) else float("nan") + return dict(ok=bool(worst <= tol), max_tail_diff=worst, cut=cut, tail=len(a)) + + +# =========================================================================== +# REGISTRY varianti: builder(PX,p)->(score_at,warm), griglia config, engine. +# =========================================================================== +def variants(): + Lg = (30, 60, 90) + Hk = [dict(H=H, k=k) for H in (5, 10) for k in (5, 8)] + accel_pairs = [(30, 60), (30, 90), (60, 90)] + return { + "RAMOM": dict( + builder=lambda PX, p: make_ramom(PX, p["L"]), + cfgs=[dict(L=L, **hk) for L in Lg for hk in Hk], + engine="std", B_mkt=None), + "ACCEL": dict( + builder=lambda PX, p: make_accel(PX, p["Ls"], p["Ll"]), + cfgs=[dict(Ls=ls, Ll=ll, **hk) for (ls, ll) in accel_pairs for hk in Hk], + engine="std", B_mkt=None), + "FIP": dict( + builder=lambda PX, p: make_fip(PX, p["L"]), + cfgs=[dict(L=L, **hk) for L in Lg for hk in Hk], + engine="std", B_mkt=None), + "VOLSC": dict( + builder=lambda PX, p: xv.make_mom(PX, p["L"], +1), + cfgs=[dict(L=L, **hk) for L in Lg for hk in Hk], + engine="mktvol", B_mkt=20), + } + + +def run_variant_cfg(PX, VOL, v, p): + sa, warm = v["builder"](PX, p) + if v["engine"] == "mktvol": + s, turn = xs_engine_mktvol(PX, VOL, sa, p["H"], p["k"], B_mkt=v["B_mkt"], warmup=warm) + else: + s, turn = xv.xs_engine(PX, VOL, sa, p["H"], p["k"], warmup=warm) + return xv.to_daily(s), turn + + +def tag(p): + return " ".join(f"{kk}{vv}" for kk, vv in p.items()) + + +def run_grid(PX, VOL, v, xs_daily, tp_daily, uname): + rows = [] + for p in v["cfgs"]: + daily, turn = run_variant_cfg(PX, VOL, v, p) + if daily.std() == 0 or len(daily) < 60: + continue + f, h, pct = xv.evalcfg(daily) + ins = xv.metrics(daily[daily.index < HOLDOUT])["sharpe"] + rows.append(dict(cfg=p, uni=uname, daily=daily, full=f["sharpe"], hold=h["sharpe"], + ins=ins, dd=f["maxdd"], ret=f["ret"], pct=pct, + corrXS=xv._corr(daily, xs_daily), corrTP=xv._corr(daily, tp_daily), + turn=turn)) + return rows + + +# =========================================================================== +# PORTAFOGLIO — base cablata una sola volta (cache sleeve riusate per uplift+sostituzione). +# =========================================================================== +_BASE = None +_BASE_M = None + + +def _base(): + global _BASE, _BASE_M + if _BASE is None: + _BASE = xv.active_sleeves() + pf = xv.StrategyPortfolio(_BASE) + pf.backtest() # warma le cache degli sleeve + _BASE_M = (xv.metrics(pf.combined_daily()), xv.metrics(pf.combined_daily(lo=HOLDOUT))) + return _BASE, _BASE_M + + +def add_uplift(daily, fr): + base, _ = _base() + wraw = fr / (1.0 - fr) + cand = xv.Sleeve("XSV3_cand", wraw, lambda d=daily: d) + pf = xv.StrategyPortfolio(base + [cand]) + return (xv.metrics(pf.combined_daily()), xv.metrics(pf.combined_daily(lo=HOLDOUT)), + pf.weights().get("XSV3_cand", 0.0)) + + +def substitute_xs01(daily): + base, _ = _base() + sub = [xv.Sleeve("XSV3_sub", s.weight, lambda d=daily: d) if s.name == "XS01_xsec_hl" else s + for s in base] + pf = xv.StrategyPortfolio(sub) + return xv.metrics(pf.combined_daily()), xv.metrics(pf.combined_daily(lo=HOLDOUT)) + + +# =========================================================================== +# REPORT +# =========================================================================== +def per_year(daily): + out = [] + for y, g in daily.groupby(daily.index.year): + out.append((int(y), round(float((1 + g).prod() - 1), 3))) + return out + + +def variant_verdict(pick, up_best, sub_full_d, sub_hold_d, caus_ok): + cx = abs(pick["corrXS"]) + if not caus_ok: + return "SCARTATO", "non causale (prefix-check fallito)" + if pick["full"] <= 0.3 or pick["hold"] <= 0: + return "SCARTATO", f"standalone debole (FULL {pick['full']:+.2f}, HOLD {pick['hold']:+.2f})" + dominates = (sub_full_d > 0.02 and sub_hold_d > 0.05) + diversifies = (cx < 0.7) and (up_best[1] > 0.05) # up_best=(Δfull,Δhold) + if dominates: + return "MIGLIORA-XS01", f"sostituendo XS01 il book sale FULL {sub_full_d:+.2f} / HOLD {sub_hold_d:+.2f}" + if diversifies: + return "DIVERSIFICA", f"corrXS {pick['corrXS']:+.2f}<0.7 e uplift HOLD aggiunta {up_best[1]:+.2f}" + if cx >= 0.7: + return "REDUNDANT", f"corrXS {pick['corrXS']:+.2f} alta -> momentum XS01 travestito" + return "REDUNDANT", f"scorrelata (corrXS {pick['corrXS']:+.2f}) ma non additiva (uplift HOLD {up_best[1]:+.2f}, sub HOLD {sub_hold_d:+.2f})" + + +def main(): + print("=" * 104) + print(" XSEC v3 — VARIANTI STRUTTURALI di momentum cross-sectional (RAMOM/ACCEL/FIP/VOLSC) — STAT-MODE") + print("=" * 104) + + tp_daily = xv.tp01_sleeve().daily() + xs_daily = xv.xsec_sleeve().daily() + print(" riferimenti: XS01 (momentum blend+gate, sleeve attivo) e TP01 (trend BTC/ETH).") + xs_f = xv.metrics(xs_daily) + xs_h = xv.metrics(xs_daily[xs_daily.index >= HOLDOUT]) + print(f" XS01 standalone: FULL Sh {xs_f['sharpe']:.2f} DD {xs_f['maxdd']*100:.0f}% | " + f"HOLD Sh {xs_h['sharpe']:.2f}") + + universes = {"51-all": None, "19-major": XS_UNIVERSE} + mats = {} + for uname, u in universes.items(): + PX, VOL = xv.load_matrix(u) + mats[uname] = (PX, VOL) + print(f" universo {uname:<9}: {PX.shape[1]} asset, {PX.shape[0]} giorni " + f"[{PX.index[0].date()} -> {PX.index[-1].date()}]") + + vdefs = variants() + all_full = [] + per_var_rows = {} + for vname, v in vdefs.items(): + rows_all = [] + for uname, (PX, VOL) in mats.items(): + rows = run_grid(PX, VOL, v, xs_daily, tp_daily, uname) + rows_all += rows + all_full += [r["full"] for r in rows] + per_var_rows[vname] = rows_all + + base, (bf, bh) = _base() + print(f"\n BASE portafoglio (4 sleeve attivi): FULL Sh {bf['sharpe']:.2f} DD {bf['maxdd']*100:.0f}%" + f" | HOLD Sh {bh['sharpe']:.2f} DD {bh['maxdd']*100:.0f}%") + + summary = [] + for vname, v in vdefs.items(): + rows = per_var_rows[vname] + if not rows: + print(f"\n [{vname}] nessuna config valida.") + continue + n = len(rows) + pos_full = sum(r["full"] > 0 for r in rows) + pos_hold = sum(r["hold"] > 0 for r in rows) + pick_ins = max(rows, key=lambda r: (r["ins"], r["full"])) # selezione ONESTA (in-sample) + pick_hold = max(rows, key=lambda r: r["hold"]) # ceiling ottimistico + + print("\n" + "#" * 104) + print(f"# {vname} | {n} config x2 universi | plateau FULL>0 {pos_full}/{n} | HOLD>0 {pos_hold}/{n}") + print("#" * 104) + print(f" {'pick':<12}{'cfg':<24}{'uni':<10}{'FULL':>6}{'INS':>6}{'HOLD':>6}{'DD%':>6}" + f"{'ret%':>7}{'an+':>6}{'crXS':>7}{'crTP':>7}{'t/y':>7}") + for lbl, r in (("by-INS<2025", pick_ins), ("by-HOLD", pick_hold)): + print(f" {lbl:<12}{tag(r['cfg']):<24}{r['uni']:<10}{r['full']:>6.2f}{r['ins']:>6.2f}" + f"{r['hold']:>6.2f}{r['dd']*100:>6.0f}{r['ret']*100:>+7.0f}{r['pct']*100:>5.0f}%" + f"{r['corrXS']:>+7.2f}{r['corrTP']:>+7.2f}{r['turn']:>7.0f}") + # top-3 per IN-SAMPLE per leggere il plateau + print(" --- top-3 by IN-SAMPLE Sharpe (plateau) ---") + for r in sorted(rows, key=lambda r: -r["ins"])[:3]: + print(f" {tag(r['cfg']):<24}{r['uni']:<10}FULL {r['full']:+.2f} INS {r['ins']:+.2f}" + f" HOLD {r['hold']:+.2f} corrXS {r['corrXS']:+.2f}") + + # ---- gate sul pick_ins (selezione onesta) ---- + pick = pick_ins + v_uni = pick["uni"] + PX, VOL = mats[v_uni] + if v["engine"] == "mktvol": + caus = caus_check_mktvol(PX, VOL, v["builder"], pick["cfg"], B_mkt=v["B_mkt"]) + else: + caus = xv.causality_prefix_check(PX, VOL, v["builder"], pick["cfg"]) + dsr, sr0 = xv.deflated_sharpe(pick["full"], all_full, pick["daily"]) + print(f" CAUSALITA' (prefix-check) ok={caus['ok']} max_tail_diff={caus['max_tail_diff']:.2e}") + print(f" DEFLATED Sharpe (N={len([s for s in all_full if np.isfinite(s)])} trial GLOBALI): " + f"{dsr:.3f} | soglia Sharpe-max-null {sr0:.2f} (serve >0.95)") + print(f" per-anno (pick-INS): {per_year(pick['daily'])}") + + # ---- portafoglio: uplift 4->5 e SOSTITUZIONE di XS01 (a parita' di peso) ---- + print(" UPLIFT (aggiunta come 5o sleeve):") + up_best = (-9.0, -9.0) + for fr in (0.10, 0.15): + cf, ch, wgt = add_uplift(pick["daily"], fr) + df_, dh_ = cf["sharpe"] - bf["sharpe"], ch["sharpe"] - bh["sharpe"] + print(f" @{wgt*100:>4.1f}% FULL {cf['sharpe']:.2f} ({df_:+.2f}) DD {cf['maxdd']*100:.0f}%" + f" | HOLD {ch['sharpe']:.2f} ({dh_:+.2f})") + if (df_ + dh_) > (up_best[0] + up_best[1]): + up_best = (df_, dh_) + sf, sh = substitute_xs01(pick["daily"]) + sub_full_d, sub_hold_d = sf["sharpe"] - bf["sharpe"], sh["sharpe"] - bh["sharpe"] + print(f" SOSTITUZIONE XS01->{vname} (peso {base[1].weight:.4f}): " + f"FULL {sf['sharpe']:.2f} ({sub_full_d:+.2f}) DD {sf['maxdd']*100:.0f}%" + f" | HOLD {sh['sharpe']:.2f} ({sub_hold_d:+.2f})") + + verdict, why = variant_verdict(pick, up_best, sub_full_d, sub_hold_d, caus["ok"]) + print(f" >>> VERDETTO {vname}: {verdict} — {why}") + summary.append(dict(name=vname, pick=pick, dsr=dsr, caus=caus["ok"], up=up_best, + sub=(sub_full_d, sub_hold_d), verdict=verdict)) + + # ---- SINTESI ---- + print("\n" + "=" * 104) + print(" SINTESI — giudizio MARGINALE vs XS01 (sleeve attivo)") + print("=" * 104) + print(f" {'variant':<8}{'FULL':>6}{'HOLD':>6}{'DD%':>6}{'corrXS':>8}{'corrTP':>8}{'DSR':>7}" + f"{'+upHOLD':>9}{'subHOLD':>9} verdetto") + for s in summary: + p = s["pick"] + print(f" {s['name']:<8}{p['full']:>6.2f}{p['hold']:>6.2f}{p['dd']*100:>6.0f}" + f"{p['corrXS']:>+8.2f}{p['corrTP']:>+8.2f}{s['dsr']:>7.3f}{s['up'][1]:>+9.2f}" + f"{s['sub'][1]:>+9.2f} {s['verdict']}") + + winners = [s for s in summary if s["verdict"] in ("MIGLIORA-XS01", "DIVERSIFICA")] + print("\n CONCLUSIONE:") + if not winners: + print(" NESSUNA variante batte o diversifica davvero XS01. Tutte sono momentum-family ad") + print(" alta corr con XS01 e/o non additive al portafoglio -> REDUNDANT/SCARTATO. La") + print(" struttura del segnale (risk-adj/accel/smoothness/vol-timing) NON apre uno slot nuovo.") + else: + for s in winners: + print(f" {s['name']}: {s['verdict']} (forward-monitor). corrXS {s['pick']['corrXS']:+.2f}, " + f"+upHOLD {s['up'][1]:+.2f}, subHOLD {s['sub'][1]:+.2f}, DSR {s['dsr']:.3f}.") + print("\n CAVEAT (immutabili): storia ~2.5 anni (deflated-Sharpe + multiple-testing); book a molte") + print(" gambe NON eseguibile a $600 -> STAT-MODE / forward-monitor, MAI deploy. Nessuno sleeve") + print(" registrato: e' lavoro statistico (vincoli del filone XS).") + + +if __name__ == "__main__": + main() diff --git a/tests/test_meta_allocation.py b/tests/test_meta_allocation.py new file mode 100644 index 0000000..2dddb5c --- /dev/null +++ b/tests/test_meta_allocation.py @@ -0,0 +1,83 @@ +"""Test minimali per scripts/research/meta_allocation.py. + +Verifica le proprieta' STRUTTURALI dell'harness di meta-allocazione (non l'edge — quello e' nel +report): (1) i pesi-bersaglio + cash sommano a 1 per riga; (2) gli sleeve inattivi pesano 0; +(3) lo schema vol-parity e' CAUSALE (un cambio dei rendimenti in t+k non altera i pesi <= t); +(4) il cap del momentum e' rispettato; (5) il motore di simulazione conserva (vol nulla -> equity +piatta) e il costo di ribilancio aumenta col turnover. +""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import numpy as np +import pandas as pd +import pytest + +from scripts.research import meta_allocation as M + + +def _toy(n=400, A=4, seed=0): + rng = np.random.default_rng(seed) + R = rng.normal(0.0005, 0.01, size=(n, A)) + active = np.ones((n, A), bool) + index = pd.date_range("2020-01-01", periods=n, freq="1D", tz="UTC") + fixed_w = np.array([0.4125, 0.1875, 0.15, 0.25]) + return index, R, active, fixed_w + + +def test_weights_plus_cash_sum_to_one(): + index, R, active, fixed_w = _toy() + for fn in (M.scheme_base, M.scheme_volpar_pure, M.scheme_volpar_tilt, + M.scheme_momentum, M.scheme_dd_cash, M.scheme_dd_defensive): + W = fn(index, R, active, fixed_w) # ultima colonna = cash + s = W.sum(axis=1) + assert np.allclose(s, 1.0, atol=1e-9), f"{fn.__name__}: righe non sommano a 1 (max dev {np.abs(s-1).max():.2e})" + assert (W >= -1e-12).all(), f"{fn.__name__}: pesi negativi" + + +def test_inactive_sleeves_get_zero_weight(): + index, R, active, fixed_w = _toy() + active[:, 2] = False # spegni lo sleeve 2 ovunque + W = M.scheme_base(index, R, active, fixed_w) + assert np.allclose(W[:, 2], 0.0), "uno sleeve inattivo riceve peso non nullo" + assert np.allclose(W.sum(axis=1), 1.0) + + +def test_volparity_is_causal(): + """Un cambio dei rendimenti da t0 in poi NON deve alterare i pesi calcolati per t < t0.""" + index, R, active, fixed_w = _toy(n=400) + t0 = 360 + W1 = M.scheme_volpar_pure(index, R, active, fixed_w) + R2 = R.copy(); R2[t0:] *= 50.0 # shock futuro enorme + W2 = M.scheme_volpar_pure(index, R2, active, fixed_w) + assert np.allclose(W1[:t0], W2[:t0]), "VOL-PARITY non causale: pesi passati dipendono dal futuro" + + +def test_momentum_respects_cap(): + index, R, active, fixed_w = _toy() + cap = 0.55 + W = M.scheme_momentum(index, R, active, fixed_w, cap=cap) + sleeve_w = W[:, :-1] # escludi cash + assert sleeve_w.max() <= cap + 1e-6, f"cap momentum violato: max {sleeve_w.max():.3f} > {cap}" + + +def test_simulate_flat_when_no_returns(): + index, R, active, fixed_w = _toy() + Rz = np.zeros_like(R) + W = M.scheme_base(index, Rz, active, fixed_w) + sim = M.simulate(Rz, active, W, cost_rate=0.0) + assert np.allclose(sim["daily"].values, 0.0, atol=1e-12), "equity non piatta con rendimenti nulli e costo zero" + + +def test_rebalance_cost_increases_with_turnover(): + """Uno schema ad alto turnover (vol-parity) deve pagare piu' costo del peso-fisso (basso turnover).""" + index, R, active, fixed_w = _toy(seed=3) + Wb = M.scheme_base(index, R, active, fixed_w) + Wv = M.scheme_volpar_pure(index, R, active, fixed_w) + tb = M.simulate(R, active, Wb)["turnover_per_year"] + tv = M.simulate(R, active, Wv)["turnover_per_year"] + assert tv > tb, f"il vol-parity dovrebbe avere turnover > peso-fisso (got {tv:.2f} vs {tb:.2f})" + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-q"])) diff --git a/tests/test_orthogonal_signals.py b/tests/test_orthogonal_signals.py new file mode 100644 index 0000000..b83353d --- /dev/null +++ b/tests/test_orthogonal_signals.py @@ -0,0 +1,83 @@ +"""Test minimali per orthogonal_signals.py — CAUSALITÀ dollar-neutral + dollar-neutrality (beta~0). + +Lo scopo è blindare le due proprietà su cui poggia tutto il filone relative-value ETH/BTC: + 1. l'evaluator dollar-neutral è CAUSALE: pos[i] decisa a close[i] è tenuta SOLO durante la + barra i+1 -> una decisione presa all'ultima barra non può toccare il backtest (no look-ahead), + e il prefix-check sul segnale combacia con la coda del full. + 2. la fee è caricata su 2 GAMBE (ETH + BTC). + 3. dollar-neutrality: un segnale temporizzato sul ratio ha beta di mercato ~0 (ortogonale per + costruzione) — il cuore della richiesta (stream scorrelato al book direzionale). +""" +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts" / "research")) +import orthogonal_signals as o # noqa: E402 + + +def _synthetic_joint(n: int = 60, seed: int = 0) -> pd.DataFrame: + ts = pd.date_range("2022-01-01", periods=n, freq="D", tz="UTC") + rng = np.random.default_rng(seed) + cb = 100 * np.cumprod(1 + rng.normal(0, 0.02, n)) + ce = 100 * np.cumprod(1 + rng.normal(0, 0.03, n)) + j = pd.DataFrame({"timestamp": ts.view("int64") // 10**6, "datetime": ts, "cb": cb, "ce": ce}) + j["r_btc"] = o.al.simple_returns(cb) + j["r_eth"] = o.al.simple_returns(ce) + j["log_ratio"] = np.log(ce / cb) + return j + + +def test_position_held_next_bar_only(): + """Una posizione nota a close[k] muove SOLO il ritorno della barra k+1 (eseguibile, no leak).""" + j = _synthetic_joint() + k = 10 + pos = np.zeros(len(j)); pos[k] = 1.0 + ev = o.eval_spread(j, pos, fee_side=0.0) + nz = np.nonzero(np.abs(ev["net"]) > 1e-12)[0] + assert list(nz) == [k + 1], f"posizione a k={k} deve toccare solo k+1, trovato {nz}" + expected = j["r_eth"].values[k + 1] - j["r_btc"].values[k + 1] + assert abs(ev["net"][k + 1] - expected) < 1e-12 + + +def test_last_bar_decision_cannot_leak(): + """Una decisione presa SOLO all'ultima barra non può influenzare il backtest (è tenuta su una + barra i+1 che non esiste) -> net identicamente 0. Guardia anti-look-ahead strutturale.""" + j = _synthetic_joint() + pos = np.zeros(len(j)); pos[-1] = 9.0 + ev = o.eval_spread(j, pos, fee_side=0.0) + assert np.allclose(ev["net"], 0.0) + + +def test_fee_charged_on_two_legs(): + """La fee è su 2 gambe: ogni Δpos paga fee_side su ETH E su BTC -> costo totale = fee*2*turnover.""" + j = _synthetic_joint() + pos = np.zeros(len(j)); pos[5] = 1.0 # held: entra a 6 (Δ=1), esce a 7 (Δ=1) -> turnover=2 + f = 0.001 + ev0 = o.eval_spread(j, pos, fee_side=0.0) + evf = o.eval_spread(j, pos, fee_side=f) + total_fee = float((ev0["net"] - evf["net"]).sum()) + assert abs(total_fee - f * 2 * 2) < 1e-12, total_fee + + +def test_prefix_causality_real_signal(): + """Prefix-check su dati reali: ricostruendo il segnale su un prefisso, la coda combacia col full.""" + ck = o.causality_spread(o.f_statarb_resid(W=60), tf="1d") + assert ck["ok"] and ck["checked"] >= 1, ck + ck2 = o.causality_spread(o.f_ratio_mom(L=30), tf="1d") + assert ck2["ok"], ck2 + + +def test_dollar_neutral_low_market_beta(): + """Dollar-neutrality: un segnale temporizzato sul ratio ha beta di mercato (50/50 BTC+ETH) ~0. + È la proprietà 'ortogonale per costruzione' richiesta dallo studio.""" + j = o.build_joint("1d") + pos = o.f_statarb_resid(W=60)(j) + daily = o.spread_daily(j, pos) + mkt = o.market_daily() + beta, corr = o.beta_to(daily, mkt) + assert abs(beta) < 0.10, f"beta di mercato non ~0: {beta}" + assert abs(corr) < 0.20, f"corr di mercato troppo alta: {corr}" diff --git a/tests/test_signal_inout_1leg.py b/tests/test_signal_inout_1leg.py new file mode 100644 index 0000000..9a0e845 --- /dev/null +++ b/tests/test_signal_inout_1leg.py @@ -0,0 +1,50 @@ +"""Test minimale per scripts/research/signal_inout_1leg.py: + - la costruzione del segnale MACD e' CAUSALE (no look-ahead): causality_ok ok, tail-diff ~0; + - una cella esegue end-to-end (study_weights ritorna un verdetto valido). +Veloce: solo BTC a 1d, una cella. +""" +import sys + +import numpy as np + +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt") +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research") +import altlib as al # noqa: E402 +import signal_inout_1leg as sig # noqa: E402 + + +def test_macd_target_is_causal(): + """Un target MACD costruito con EMA(adjust=False) non deve guardare al futuro.""" + fn = sig.make_macd("LF")(tf="1d", fast=12, slow=26, sig=9) + c = al.causality_ok(fn, tf="1d") + assert c["ok"], c + assert c["max_tail_diff"] <= 1e-6, c + + +def test_macd_position_values_and_hold(): + """Long-flat in {0,1}; long-short in {-1,1}; nessun NaN.""" + df = al.get("BTC", "1d") + lf = sig.make_macd("LF")(tf="1d", fast=12, slow=26, sig=9)(df) + ls = sig.make_macd("LS")(tf="1d", fast=12, slow=26, sig=9)(df) + assert len(lf) == len(df) and len(ls) == len(df) + assert set(np.unique(lf)).issubset({0.0, 1.0}) + assert set(np.unique(ls)).issubset({-1.0, 1.0}) + assert np.isfinite(lf).all() and np.isfinite(ls).all() + + +def test_one_cell_executes_end_to_end(): + """study_weights su una cella MACD-LF deve produrre un verdetto valido.""" + fn = sig.make_macd("LF")(tf="1d", fast=12, slow=26, sig=9) + rep = al.study_weights("MACD-LF-test", fn, tfs=("1d",)) + assert rep["verdict"]["grade"] in ("PASS", "WEAK", "FAIL") + assert rep["cells"] and rep["cells"][0]["per_asset"] + + +def test_supertrend_and_rsi_targets_run(): + """Supertrend (stateful) e RSI (mean-rev) producono posizioni causali eseguibili.""" + df = al.get("BTC", "1d") + st = sig.make_supertrend("LF")(tf="1d", atr_win=14, mult=2.5)(df) + rs = sig.make_rsi()(tf="1d", win=14, oversold=30, overbought=65)(df) + assert len(st) == len(df) and len(rs) == len(df) + assert np.isfinite(st).all() and np.isfinite(rs).all() + assert al.causality_ok(sig.make_supertrend("LF")(tf="1d", atr_win=14, mult=2.5), tf="1d")["ok"] diff --git a/tests/test_xsec_v3_lowrisk.py b/tests/test_xsec_v3_lowrisk.py new file mode 100644 index 0000000..c878b4b --- /dev/null +++ b/tests/test_xsec_v3_lowrisk.py @@ -0,0 +1,82 @@ +"""Test del filone C v3: cross-sectional 'low-risk cousins' (MAX / IVOL / AMIHUD) su Hyperliquid +(scripts/research/xsec_v3_lowrisk). Verifica i GATE strutturali, non i numeri esatti (storia corta): + - lo script importa ed esegue (catalogo meccanismi costruibile, una cella di engine gira); + - i meccanismi sono CAUSALI (prefix-consistency bit-a-bit), incluso AMIHUD che richiede il + riallineamento del volume sul prefisso (path piu' delicato); + - la selezione 'robust_candidate' RIFIUTA il holdout-fitting (config negativa in-sample con HOLD + alto) come prescritto dallo scorer indurito del progetto; + - IVOL sui 19 major ha edge in-sample positivo (il LEAD principale). +""" +from __future__ import annotations +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PROJECT_ROOT)) +sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research")) + +import numpy as np +import pytest + +import importlib.util +_spec = importlib.util.spec_from_file_location( + "xsec_v3_lowrisk", PROJECT_ROOT / "scripts" / "research" / "xsec_v3_lowrisk.py") +xv3 = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(xv3) + +xv = xv3.xv # harness collaudato riusato dal modulo v3 +from src.portfolio.portfolio import to_daily, metrics + + +@pytest.fixture(scope="module") +def majors(): + return xv.load_matrix(xv.XS_UNIVERSE) + + +def test_imports_and_builds_mechanisms(majors): + """Lo script importa e il catalogo dei 3 'low-risk cousins' (+ entrambi i segni AMIHUD) e' costruibile.""" + _, VOL = majors + mechs = xv3.build_mechanisms(VOL) + assert set(mechs) == {"MAX", "IVOL", "AMIHUD_ILLIQ", "AMIHUD_LIQ"} + for mn, (_builder, cfgs) in mechs.items(): + assert len(cfgs) == 12 # B{20,30,60} x H{5,10} x k{5,8} + + +def test_engine_executes_a_cell(majors): + """Esegue una cella dell'engine (IVOL B30 H5 k8 sui 19 major): serie giornaliera finita, std>0, + turnover>0 e Sharpe FULL positivo robusto (il LEAD documentato).""" + PX, VOL = majors + score_at, warm = xv3.make_ivol(PX, 30) + s, turn = xv.xs_engine(PX, VOL, score_at, H=5, k=8, warmup=warm) + d = to_daily(s) + assert np.isfinite(d.values).all() and d.std() > 0 + assert turn > 0 + assert metrics(d)["sharpe"] > 0.5 # IVOL 19-major = LEAD (edge in-sample+OOS) + + +@pytest.mark.parametrize("mech,cfg", [ + ("MAX", dict(B=60, H=5, k=5)), + ("IVOL", dict(B=30, H=5, k=8)), + ("AMIHUD_ILLIQ", dict(B=30, H=10, k=5)), # path col riallineamento del volume +]) +def test_mechanism_is_causal(majors, mech, cfg): + """Nessun look-ahead: ricostruito su un prefisso, la coda combacia bit-a-bit con la run completa. + Per AMIHUD verifica anche che il volume sia riallineato al prefisso (non al full-sample).""" + PX, VOL = majors + builder, _ = xv3.build_mechanisms(VOL)[mech] + res = xv.causality_prefix_check(PX, VOL, builder, cfg) + assert res["ok"], f"{mech} look-ahead: max_tail_diff={res['max_tail_diff']}" + assert res["max_tail_diff"] == 0.0 + + +def test_robust_candidate_rejects_holdout_fit(): + """La selezione GIUDICATA scarta il holdout-fitting: una config NEGATIVA in-sample con HOLD alto + non e' eleggibile; serve in-sample>=0.5 E HOLD>0. Se nessuna ha edge in-sample -> None.""" + rows = [ + dict(insample=-1.2, hold=1.0, full=0.1), # holdout-fit -> escluso + dict(insample=0.9, hold=0.8, full=0.95), # edge in-sample + OOS -> eleggibile + dict(insample=0.6, hold=-0.2, full=0.3), # HOLD<0 -> escluso + ] + c = xv3.robust_candidate(rows) + assert c is not None and c["insample"] == 0.9 + assert xv3.robust_candidate([dict(insample=0.1, hold=2.0, full=0.0)]) is None diff --git a/tests/test_xsec_v3_momstruct.py b/tests/test_xsec_v3_momstruct.py new file mode 100644 index 0000000..a7edf88 --- /dev/null +++ b/tests/test_xsec_v3_momstruct.py @@ -0,0 +1,62 @@ +"""Test del filone XS-v3: varianti STRUTTURALI di momentum cross-sectional +(scripts/research/xsec_v3_momstruct). Verifica i GATE strutturali, non i numeri esatti (storia +corta, ricerca): gli engine sono CAUSALI (prefix-consistency, zero look-ahead, anche quello +volatility-managed custom) e una cella della griglia ESEGUE producendo una serie finita non degenere. +""" +from __future__ import annotations +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PROJECT_ROOT)) +import numpy as np + +from src.portfolio.sleeves import XS_UNIVERSE + +import importlib.util +_spec = importlib.util.spec_from_file_location( + "xsec_v3_momstruct", PROJECT_ROOT / "scripts" / "research" / "xsec_v3_momstruct.py") +v3 = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(v3) +xv = v3.xv + + +def _majors(): + return xv.load_matrix(XS_UNIVERSE) + + +def test_std_engine_variants_are_causal(): + """RAMOM/ACCEL/FIP usano xs_engine: ricostruiti su un prefisso, la coda combacia bit-a-bit + con la run completa (gate #2 della metodologia).""" + PX, VOL = _majors() + vdefs = v3.variants() + for name, cfg in (("RAMOM", dict(L=30, H=10, k=5)), + ("ACCEL", dict(Ls=30, Ll=60, H=5, k=8)), + ("FIP", dict(L=60, H=10, k=5))): + res = xv.causality_prefix_check(PX, VOL, vdefs[name]["builder"], cfg) + assert res["ok"], f"{name} look-ahead: max_tail_diff={res['max_tail_diff']}" + assert res["max_tail_diff"] == 0.0 + + +def test_volscaled_engine_is_causal(): + """L'engine volatility-managed custom (vol-target sulla vol di MERCATO, shift 1) e' causale.""" + PX, VOL = _majors() + v = v3.variants()["VOLSC"] + res = v3.caus_check_mktvol(PX, VOL, v["builder"], dict(L=60, H=5, k=8), B_mkt=v["B_mkt"]) + assert res["ok"], f"VOLSC look-ahead: max_tail_diff={res['max_tail_diff']}" + assert res["max_tail_diff"] == 0.0 + + +def test_grid_cell_executes_finite(): + """Una cella di ogni variante esegue e produce una serie GIORNALIERA finita e non degenere.""" + PX, VOL = _majors() + vdefs = v3.variants() + for name, cfg in (("RAMOM", dict(L=60, H=10, k=5)), + ("ACCEL", dict(Ls=30, Ll=90, H=5, k=8)), + ("FIP", dict(L=90, H=10, k=8)), + ("VOLSC", dict(L=60, H=5, k=8))): + daily, turn = v3.run_variant_cfg(PX, VOL, vdefs[name], cfg) + assert len(daily) > 60 + assert np.isfinite(daily.values).all() + assert daily.std() > 0 + assert turn > 0