3 Commits

Author SHA1 Message Date
Adriano Dal Pastro ee82e0a056 feat(dashboard): box forward-monitor STATARB-RESID (lead ortogonale+eseguibile)
Aggiunge il forward-monitor STATARB-RESID al dashboard accanto a PREVDAY (sezione
③·c FORWARD-MONITOR): carica data/paper_statarb/state.json, mostra doppio libro
MODELED/REAL-$600, ret/maxDD/fill-haircut, posizione spread corrente e giorni forward.
Warn aggiornato: LEAD sotto deflated-Sharpe -> forward per confermare l'edge, non deploy.

Smoke-test html() OK (box presente, dati live). Test 146/146.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:20:56 +00:00
Adriano Dal Pastro c52e0ab3f8 feat(forward): cabla STATARB-RESID nel forward-monitor PAPER (lead ortogonale ETH/BTC)
Forward-monitor del LEAD dello sweep 2026-06-29 (relative-value ETH/BTC, dollar-neutral 2 gambe),
il primo stream insieme ORTOGONALE (corr->book 0.027, beta-mkt 0.013) ED eseguibile a $600.

- scripts/live/paper_statarb.py: forward-only, doppio libro MODELED($2000)/REAL-$600 (haircut fill),
  riusa il segnale ESATTO di orthogonal_signals.py (niente reimplementazione). Config CONGELATA
  W=45 sgn=+1.
- Cablato in scripts/cron_daily.sh accanto a paper_prevday. Stato runtime in data/paper_statarb/
  (gitignored).
- test tests/test_paper_statarb.py (frozen config + advance forward/idempotente + haircut $600 basso).

Correzione di etichetta (verificata): la cella vincente e' sgn=+1 -> NON mean-reversion ma
relative-MOMENTUM sul residuo (dislocazioni ETH-vs-BTC continuano a 1d; sgn=-1 perde -1.4 IS).
Diario + CLAUDE.md aggiornati. Test 146/146. Nessun deploy, forward-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 20:58:29 +00:00
Adriano Dal Pastro cff5fa2bf5 research(sweep): 5 thread paralleli — 0 nuovi sleeve, STATARB-RESID LEAD ortogonale+eseguibile
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) <noreply@anthropic.com>
2026-06-29 20:50:33 +00:00
17 changed files with 2960 additions and 2 deletions
+1
View File
@@ -66,3 +66,4 @@ scripts/research/blind/leaderboard.json
# forward-monitor runtime state (regenerable, forward-only) # forward-monitor runtime state (regenerable, forward-only)
data/paper_prevday/ data/paper_prevday/
data/paper_combo/ data/paper_combo/
data/paper_statarb/
+17
View File
@@ -132,6 +132,23 @@ 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 (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 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`. 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** (relative-MOMENTUM del residuo ETH−β·BTC, β OLS rolling, 2 gambe,
cella vincente **sgn=+1**: le dislocazioni ETH-vs-BTC CONTINUANO a 1d, la MR pura sgn=1 perde) — 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). **CABLATO in forward-monitor PAPER:**
`scripts/live/paper_statarb.py` (W=45/sgn=+1 congelati, doppio libro MODELED/REAL-$600), nel cron
giornaliero accanto a `paper_prevday`; test `tests/test_paper_statarb.py`. Se la finestra forward conferma
l'edge è *deployabile* (2 gambe BTC/ETH perp). 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: - **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. 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.** - **Sweep "strategie alternative" (2026-06-20) — 104 ipotesi / 153 agenti / NIENTE di nuovo regge.**
@@ -0,0 +1,106 @@
# 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** (residual relative-mom ETH−β·BTC, W45 **sgn+1**) | 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. **⚠ Correzione di etichetta:** la cella vincente è
**sgn=+1**, NON sgn=1 → NON è mean-reversion ma **momentum relativo sul residuo** (long lo spread quando
ETH è ricco vs il suo fit su BTC: le dislocazioni ETH-vs-BTC *continuano* a 1d; la MR pura sgn=1 perde
1.4 in-sample). L'ortogonalità (β~0) viene dalla struttura dollar-neutral, non dal verso. → **LEAD
forward-monitor** (monitorabile a costo reale ~0), NON deploy. **CABLATO:** `scripts/live/paper_statarb.py`
(config CONGELATA W=45/sgn=+1, doppio libro MODELED/REAL-$600, riusa il segnale esatto di
`orthogonal_signals.py`), aggiunto al cron giornaliero accanto a `paper_prevday`; test `tests/test_paper_statarb.py`.
**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).
+1
View File
@@ -10,6 +10,7 @@ mkdir -p logs
uv run python scripts/research/fetch_dvol.py # DVOL (per ricerca opzioni) uv run python scripts/research/fetch_dvol.py # DVOL (per ricerca opzioni)
uv run python scripts/live/paper_portfolio.py # avanza paper TP01+XS01 uv run python scripts/live/paper_portfolio.py # avanza paper TP01+XS01
uv run python scripts/live/paper_prevday.py # forward-monitor lead prevday-breakout (PAPER, non deploy) uv run python scripts/live/paper_prevday.py # forward-monitor lead prevday-breakout (PAPER, non deploy)
uv run python scripts/live/paper_statarb.py # forward-monitor lead STATARB-RESID ETH/BTC ortogonale (PAPER, non deploy)
# NB: l'esecuzione Deribit e' passata al BOOK (TP01+SKH01 nettati) via scripts/cron_book.sh a # NB: l'esecuzione Deribit e' passata al BOOK (TP01+SKH01 nettati) via scripts/cron_book.sh a
# cadenza ORARIA (SKH01 e' a 230m: il daily mancherebbe gli ingressi). live_execute.py # cadenza ORARIA (SKH01 e' a 230m: il daily mancherebbe gli ingressi). live_execute.py
# (TP01-only) NON va piu' eseguito qui, sennò i due farebbero a pugni sullo stesso strumento. # (TP01-only) NON va piu' eseguito qui, sennò i due farebbero a pugni sullo stesso strumento.
+176
View File
@@ -0,0 +1,176 @@
"""FORWARD-MONITOR — STATARB-RESID (relative-value ETH/BTC, dollar-neutral 2 gambe), PAPER.
NON è esecuzione reale. È il monitoraggio forward-only del LEAD dello sweep 2026-06-29 (thread
"segnali ortogonali"): il PRIMO stream visto che è INSIEME ortogonale al book (corr→book 0.027,
beta-mkt 0.013) ED eseguibile a $600 (book a 2 gambe BTC+ETH perp, NON STAT-MODE come XS01). Resta
sotto la soglia solo sull'EDGE (Sharpe ~0.84, deflated-Sharpe <0.95 su storia ~2.5 anni). Questo
monitor accumula la finestra OUT-OF-SAMPLE VERA per decidere se l'edge regge — stesso trattamento di
PREVDAY/STA05/XS01 STAT-MODE. Nessun edge creduto prima.
ⓘ NATURA DEL SEGNALE (onesto): la cella vincente è W=45 **sgn=+1**, NON sgn=-1. Cioè il residuo di
ETH dopo β·BTC (β OLS rolling causale) viene seguito in MOMENTUM (long lo spread quando ETH è ricco
vs il suo fit su BTC), non in mean-reversion: le dislocazioni relative ETH-vs-BTC CONTINUANO a 1d
(la MR pura, sgn=-1, perde -1.4 in-sample). È relative-MOMENTUM, dollar-neutral → ortogonale al
trend assoluto di TP01.
CONFIG CONGELATA (dallo sweep, selezione IN-SAMPLE-only, mai sul max hold-out):
W=45, sgn=+1, tf=1d, vol-target 20%, leva cap 2x. Riusa il segnale ESATTO di
scripts/research/orthogonal_signals.py (nessuna reimplementazione → niente drift).
DESIGN (onesto, come paper_prevday):
- Legge BTC/ETH 1d certificati (build_joint), calcola il segnale CAUSALE su tutta la storia.
- Alla prima esecuzione fissa l'INCEPTION all'ultima barra 1d chiusa (forward-only: lo storico
NON entra nel PnL di paper).
- Ogni run processa le NUOVE barre 1d chiuse: applica il rendimento dello spread tenuto,
addebita le fee su 2 GAMBE (ogni Δpos muove ETH e BTC → 2×|Δpos|), registra i flip di segno.
- DUE libri in parallelo (onestà sull'esecuzione a piccolo capitale):
* MODELED : capitale nominale $2000, ribilanciamento continuo (fee su ogni |Δpos|).
* REAL-$600: capitale reale $600, salta i ribilanci di nozionale-per-gamba < min_order ($5)
→ il gap MODELED-REAL = l'haircut di fill reale (qui ~0: turnover bassissimo a 1d).
- Per barre fresche aggiornare prima i dati: uv run python scripts/analysis/rebuild_history.py --asset BTC ETH
Stato: data/paper_statarb/{state.json, trades.jsonl, returns.jsonl} (append-only).
uv run python scripts/live/paper_statarb.py # avanza col dato disponibile
uv run python scripts/live/paper_statarb.py --status # solo stato, non avanza
uv run python scripts/live/paper_statarb.py --reset # azzera (riparte da ora)
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research"))
# Segnale ESATTO dello sweep (nessuna reimplementazione → identità garantita col backtest).
from orthogonal_signals import build_joint, f_statarb_resid, spread_ret # noqa: E402
STATE_DIR = PROJECT_ROOT / "data" / "paper_statarb"
STATE_FILE = STATE_DIR / "state.json"
TRADES_FILE = STATE_DIR / "trades.jsonl"
RETURNS_FILE = STATE_DIR / "returns.jsonl"
# --- CONFIG CONGELATA (frozen) -----------------------------------------------------------
W_FROZEN = 45
SGN_FROZEN = +1
FEE_SIDE = 0.0005 # 0.05%/side = 0.10% round-trip (Deribit taker)
MODELED_CAPITAL = 2000.0 # nominale, ribilanciamento continuo
REAL_CAPITAL = 600.0 # capitale mainnet reale
MIN_ORDER = 5.0 # min order Deribit -> sotto, il conto vero NON ribilancia
def _signal(j: pd.DataFrame):
"""(ts, dt, pos, sr): posizione vol-targeted sullo spread (decisa <= close[i]) + ritorno spread."""
pos = np.nan_to_num(f_statarb_resid(W=W_FROZEN, sgn=SGN_FROZEN)(j))
return (j["timestamp"].values.astype("int64"),
pd.to_datetime(j["datetime"]).values, pos, spread_ret(j))
def _state_io(write: dict | None = None):
if write is not None:
STATE_DIR.mkdir(parents=True, exist_ok=True)
STATE_FILE.write_text(json.dumps(write, indent=2))
return write
return json.loads(STATE_FILE.read_text()) if STATE_FILE.exists() else None
def _append(path: Path, rec: dict):
STATE_DIR.mkdir(parents=True, exist_ok=True)
with open(path, "a") as f:
f.write(json.dumps(rec) + "\n")
def init_state(j: pd.DataFrame) -> dict:
ts, _, pos, _ = _signal(j)
last = int(ts[-1])
return dict(start_ts=last, last_ts=last, n_bars=0,
pos_modeled=float(pos[-1]), pos_real=float(pos[-1]),
cap_modeled=MODELED_CAPITAL, cap_real=REAL_CAPITAL,
peak_modeled=MODELED_CAPITAL, peak_real=REAL_CAPITAL,
dd_modeled=0.0, dd_real=0.0, n_trades=0)
def advance(st: dict, j: pd.DataFrame) -> dict:
ts, dt, pos, sr = _signal(j)
new = [i for i in range(len(ts)) if ts[i] > st["last_ts"]]
if not new:
return st
pm, pr = st["pos_modeled"], st["pos_real"] # posizioni TENUTE (decise alla barra precedente)
cm, cr = st["cap_modeled"], st["cap_real"]
pkm, pkr, ddm, ddr = st["peak_modeled"], st["peak_real"], st["dd_modeled"], st["dd_real"]
ntr = st.get("n_trades", 0)
for i in new:
s = float(sr[i])
tgt = float(pos[i]) # bersaglio deciso a close[i] (eseguito da i+1)
# MODELED: rendimento dello spread tenuto durante la barra i, poi ribilancia al target
net_m = pm * s - FEE_SIDE * 2.0 * abs(tgt - pm)
# REAL-$600: salta i ribilanci sub-min_order (nozionale per-gamba |Δpos|*cap < $5)
executed = abs(tgt - pr) * REAL_CAPITAL >= MIN_ORDER
new_pr = tgt if executed else pr
net_r = pr * s - FEE_SIDE * 2.0 * abs(new_pr - pr)
if np.sign(new_pr) != np.sign(pr):
_append(TRADES_FILE, dict(ts=int(ts[i]), dt=str(pd.Timestamp(dt[i])),
action="FLIP", from_pos=round(pr, 4), to_pos=round(new_pr, 4)))
ntr += 1
cm *= (1.0 + max(net_m, -0.99)); cr *= (1.0 + max(net_r, -0.99))
pkm = max(pkm, cm); pkr = max(pkr, cr)
ddm = max(ddm, (pkm - cm) / pkm if pkm > 0 else 0.0)
ddr = max(ddr, (pkr - cr) / pkr if pkr > 0 else 0.0)
pm, pr = tgt, new_pr
_append(RETURNS_FILE, dict(ts=int(ts[i]), dt=str(pd.Timestamp(dt[i])),
net_modeled=round(net_m, 6), net_real=round(net_r, 6),
pos=round(new_pr, 4), cap_modeled=round(cm, 2), cap_real=round(cr, 2)))
st.update(last_ts=int(ts[new[-1]]), n_bars=st.get("n_bars", 0) + len(new),
pos_modeled=pm, pos_real=pr, cap_modeled=cm, cap_real=cr,
peak_modeled=pkm, peak_real=pkr, dd_modeled=ddm, dd_real=ddr, n_trades=ntr)
return st
def print_status(st: dict, j: pd.DataFrame):
last_data = int(j["timestamp"].iloc[-1])
days = (last_data - st["start_ts"]) / 86400_000
rm = st["cap_modeled"] / MODELED_CAPITAL - 1
rr = st["cap_real"] / REAL_CAPITAL - 1
print("\n STATARB-RESID forward-monitor (PAPER — relative-value ETH/BTC, ortogonale, NON deploy)")
print(f" config CONGELATA: W={W_FROZEN} sgn={SGN_FROZEN:+d} (residual relative-momentum, dollar-neutral)")
print(f" forward da {pd.Timestamp(st['start_ts'], unit='ms', tz='UTC').date()} "
f"({st['n_bars']} barre 1d ~{days:.0f}g) flip di segno: {st['n_trades']}")
print(f" posizione spread corrente (long ETH / short BTC per unità): {st['pos_real']:+.3f}")
print(f" MODELED ($2000 nominale): {rm*100:+6.2f}% eq ${st['cap_modeled']:.2f} maxDD {st['dd_modeled']*100:.1f}%")
print(f" REAL-$600 (min-order $5) : {rr*100:+6.2f}% eq ${st['cap_real']:.2f} maxDD {st['dd_real']*100:.1f}%")
print(f" -> fill-haircut MODELED-REAL: {(rm-rr)*100:+.2f} pp (atteso ~0: turnover basso a 1d)")
print(f" log: {RETURNS_FILE}\n")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--status", action="store_true")
ap.add_argument("--reset", action="store_true")
args = ap.parse_args()
j = build_joint("1d")
if args.reset:
for p in (STATE_FILE, TRADES_FILE, RETURNS_FILE):
if p.exists():
p.unlink()
st = init_state(j); _state_io(st)
print("forward-monitor STATARB-RESID inizializzato (forward-only da ora).")
print_status(st, j); return
st = _state_io()
if st is None:
st = init_state(j); _state_io(st)
print("forward-monitor STATARB-RESID inizializzato (forward-only da ora).")
print_status(st, j); return
if not args.status:
st = advance(st, j); _state_io(st)
print_status(st, j)
if __name__ == "__main__":
main()
+410
View File
@@ -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()
+604
View File
@@ -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} {'':>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()
+400
View File
@@ -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 RSI<oversold, esce quando RSI>overbought, 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()
+387
View File
@@ -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()
+417
View File
@@ -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<Ll), entrambi a close[i]."""
px, n, A, *_ = xv._precompute(PX)
def score_at(i):
if i - Ll < 0:
return np.full(A, np.nan), np.zeros(A, bool)
r_s = px[i] / px[i - Ls] - 1.0
r_l = px[i] / px[i - Ll] - 1.0
score = r_s - r_l
valid = np.isfinite(px[i]) & np.isfinite(px[i - Ls]) & np.isfinite(px[i - Ll])
return score, valid
return score_at, Ll + 1
def make_fip(PX, L):
"""Frog-in-the-pan / information discreteness: score = sign(mom_L) * |%up - %down| su L.
%up/%down calcolati sui SOLI giorni osservati (NaN pre-listing esclusi). Trend lisci -> |.| 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()
+21 -2
View File
@@ -22,6 +22,7 @@ from src.version import APP_VERSION
PAPER = PROJECT_ROOT / "data" / "paper_portfolio" / "state.json" PAPER = PROJECT_ROOT / "data" / "paper_portfolio" / "state.json"
PREVDAY = PROJECT_ROOT / "data" / "paper_prevday" / "state.json" PREVDAY = PROJECT_ROOT / "data" / "paper_prevday" / "state.json"
COMBO = PROJECT_ROOT / "data" / "paper_combo" / "state.json" COMBO = PROJECT_ROOT / "data" / "paper_combo" / "state.json"
STATARB = PROJECT_ROOT / "data" / "paper_statarb" / "state.json"
_CACHE = {"t": 0.0, "data": None} _CACHE = {"t": 0.0, "data": None}
_TTL = 120.0 _TTL = 120.0
@@ -53,6 +54,7 @@ def build():
paper = json.loads(PAPER.read_text()) if PAPER.exists() else None paper = json.loads(PAPER.read_text()) if PAPER.exists() else None
prevday = json.loads(PREVDAY.read_text()) if PREVDAY.exists() else None prevday = json.loads(PREVDAY.read_text()) if PREVDAY.exists() else None
combo = json.loads(COMBO.read_text()) if COMBO.exists() else None combo = json.loads(COMBO.read_text()) if COMBO.exists() else None
statarb = json.loads(STATARB.read_text()) if STATARB.exists() else None
try: try:
gtaa_w = gtaa_weights() # pesi ETF correnti azionabili (cache eq_*) gtaa_w = gtaa_weights() # pesi ETF correnti azionabili (cache eq_*)
except Exception: except Exception:
@@ -75,7 +77,7 @@ def build():
full=bt["full"], holdout=bt["holdout"], weights=bt["weights"], full=bt["full"], holdout=bt["holdout"], weights=bt["weights"],
per_sleeve=bt["per_sleeve"], yearly=bt["yearly"], per_sleeve=bt["per_sleeve"], yearly=bt["yearly"],
positions=pf.current_positions(), spark=spark, paper=paper, prevday=prevday, positions=pf.current_positions(), spark=spark, paper=paper, prevday=prevday,
combo=combo, gtaa_weights=gtaa_w, deribit=deribit, combo=combo, statarb=statarb, gtaa_weights=gtaa_w, deribit=deribit,
shadow=shadow, trades=trades, freq=freq, bh=None, shadow=shadow, trades=trades, freq=freq, bh=None,
) )
_CACHE.update(t=time.time(), data=data) _CACHE.update(t=time.time(), data=data)
@@ -143,6 +145,22 @@ def html():
f"forward da {pd.Timestamp(pv['start_ts'], unit='ms').date()}") f"forward da {pd.Timestamp(pv['start_ts'], unit='ms').date()}")
else: else:
prevday_html = "non inizializzato (gira <code>scripts/live/paper_prevday.py</code>)" prevday_html = "non inizializzato (gira <code>scripts/live/paper_prevday.py</code>)"
sa = d.get("statarb")
if sa:
days = (sa["last_ts"] - sa["start_ts"]) / 86400_000
rm = sa["cap_modeled"] / 2000.0 - 1
rr = sa["cap_real"] / 600.0 - 1
statarb_html = (
f"<b>${sa['cap_modeled']:,.2f}</b> modeled (€2k nom.) &nbsp;·&nbsp; "
f"<b>${sa['cap_real']:,.2f}</b> real-$600 (min-order $5)<br>"
f"ret <b>{rm*100:+.2f}%</b> / <b>{rr*100:+.2f}%</b> &nbsp;·&nbsp; "
f"maxDD {sa['dd_modeled']*100:.1f}% / {sa['dd_real']*100:.1f}% &nbsp;·&nbsp; "
f"fill-haircut {(rm-rr)*100:+.2f}pp<br>"
f"posizione spread (long ETH / short BTC per unità): <b>{sa['pos_real']:+.3f}</b> &nbsp;·&nbsp; "
f"{sa['n_bars']} barre 1d (~{days:.0f}g) · {sa['n_trades']} flip · "
f"forward da {pd.Timestamp(sa['start_ts'], unit='ms').date()}")
else:
statarb_html = "non inizializzato (gira <code>scripts/live/paper_statarb.py</code>)"
cb = d.get("combo") cb = d.get("combo")
if cb: if cb:
cdays = (pd.Timestamp(cb["last"]) - pd.Timestamp(cb["start"])).days cdays = (pd.Timestamp(cb["last"]) - pd.Timestamp(cb["start"])).days
@@ -288,7 +306,8 @@ th{{color:#8a93a0;font-weight:500}}.y{{display:inline-block;background:#161b22;b
<p class=warn>⚠️ Accumulo = proiezione condizionata (storico bull crypto → pianifica sul conservativo); nessuna leva; SKH01 research/forward-monitor (solo TP01 armato live). A €50/g servono ~€177k @conservativo: la via è capitale+tempo, non leva.</p> <p class=warn>⚠️ Accumulo = proiezione condizionata (storico bull crypto → pianifica sul conservativo); nessuna leva; SKH01 research/forward-monitor (solo TP01 armato live). A €50/g servono ~€177k @conservativo: la via è capitale+tempo, non leva.</p>
<div class="section">③·c FORWARD-MONITOR — lead paper (non deploy)</div> <div class="section">③·c FORWARD-MONITOR — lead paper (non deploy)</div>
<div class=box><b>PREVDAY range-breakout</b> — lead ORTOGONALE a TP01 (corr ~0.15 full / ~0 hold; marginal ADDS, non-hedge, robusto allo shift del confine-giorno). Forward-only, <b>nessuna esecuzione reale</b>:<br>{prevday_html}</div> <div class=box><b>PREVDAY range-breakout</b> — lead ORTOGONALE a TP01 (corr ~0.15 full / ~0 hold; marginal ADDS, non-hedge, robusto allo shift del confine-giorno). Forward-only, <b>nessuna esecuzione reale</b>:<br>{prevday_html}</div>
<p class=warn>⚠️ LEAD in osservazione, NON deployato. Sopravvissuto alla verifica avversariale dell'onda intraday; lo teniamo in paper per validarlo fuori-campione-vero. I due libri (modeled vs real-$600) mostrano l'haircut di fill che lo scettico aveva segnalato.</p> <div class=box><b>STATARB-RESID ETH/BTC</b> — lead ORTOGONALE <b>ed ESEGUIBILE</b> (relative-momentum del residuo ETH−β·BTC, dollar-neutral 2 gambe; corr→book 0.03, β-mkt 0.01; marginal ADDS). Primo stream insieme scorrelato ED eseguibile a $600 (haircut ~0, <b>NON STAT-MODE</b>). Forward-only, <b>nessuna esecuzione reale</b>:<br>{statarb_html}</div>
<p class=warn>⚠️ LEAD in osservazione, NON deployati. PREVDAY è sopravvissuto alla verifica avversariale dell'onda intraday. STATARB-RESID (sweep 5-thread 2026-06-29) rompe i muri di ortogonalità ed eseguibilità ma resta sotto il deflated-Sharpe (Sharpe ~0.84, DSR <0.95 su storia ~2.5 anni) → il forward serve a confermare l'edge fuori-campione-vero prima di un eventuale deploy a 2 gambe. I due libri (modeled vs real-$600) mostrano l'haircut di fill (atteso ~0 a 1d).</p>
<p class=warn>⚠️ Paper/monitor. XS01 e' STAT-MODE (book a 19 gambe market-neutral, non eseguibile a €2k, storia ~2.5 anni). VRP01 = lead short-vol MODELLATO (non deploy pieno). SKH01 (Skyhook dual-TF regime+breakout, BTC/ETH) = diversificatore quasi-ortogonale (corr ~0.09) aggiunto @25%: alza il FULL Sharpe del portafoglio 1.68→2.13 e dimezza il DD (14→8%) — RESEARCH/forward-monitor (book a 230m, causalita' verificata su harness ma costi reali e codice d'esecuzione da validare prima del deploy). TP01 e' l'unico deployable pieno.</p> <p class=warn>⚠️ Paper/monitor. XS01 e' STAT-MODE (book a 19 gambe market-neutral, non eseguibile a €2k, storia ~2.5 anni). VRP01 = lead short-vol MODELLATO (non deploy pieno). SKH01 (Skyhook dual-TF regime+breakout, BTC/ETH) = diversificatore quasi-ortogonale (corr ~0.09) aggiunto @25%: alza il FULL Sharpe del portafoglio 1.68→2.13 e dimezza il DD (14→8%) — RESEARCH/forward-monitor (book a 230m, causalita' verificata su harness ma costi reali e codice d'esecuzione da validare prima del deploy). TP01 e' l'unico deployable pieno.</p>
</body></html>""" </body></html>"""
+83
View File
@@ -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"]))
+83
View File
@@ -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}"
+60
View File
@@ -0,0 +1,60 @@
"""Lock del forward-monitor STATARB-RESID (scripts/live/paper_statarb.py):
* config CONGELATA (W=45, sgn=+1) — non deve cambiare silenziosamente;
* advance() processa SOLO le barre forward ed è IDEMPOTENTE alla punta;
* a 1d il libro REAL-$600 traccia il MODELED (haircut di fill piccolo).
Il segnale è quello esatto dello sweep (orthogonal_signals) → niente reimplementazione.
"""
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts" / "live"))
import paper_statarb as ps # noqa: E402
def _fresh_state(j, back):
ts, _, pos, _ = ps._signal(j)
start = int(ts[-back - 1])
return dict(start_ts=start, last_ts=start, n_bars=0,
pos_modeled=float(pos[-back - 1]), pos_real=float(pos[-back - 1]),
cap_modeled=ps.MODELED_CAPITAL, cap_real=ps.REAL_CAPITAL,
peak_modeled=ps.MODELED_CAPITAL, peak_real=ps.REAL_CAPITAL,
dd_modeled=0.0, dd_real=0.0, n_trades=0)
def _redirect(tmp_path, monkeypatch):
monkeypatch.setattr(ps, "STATE_DIR", tmp_path)
monkeypatch.setattr(ps, "STATE_FILE", tmp_path / "state.json")
monkeypatch.setattr(ps, "RETURNS_FILE", tmp_path / "returns.jsonl")
monkeypatch.setattr(ps, "TRADES_FILE", tmp_path / "trades.jsonl")
def test_frozen_config():
"""La cella vincente in-sample è W=45 sgn=+1 (relative-momentum, non mean-reversion)."""
assert ps.W_FROZEN == 45
assert ps.SGN_FROZEN == +1
def test_advance_processes_forward_then_idempotent(tmp_path, monkeypatch):
_redirect(tmp_path, monkeypatch)
j = ps.build_joint("1d")
ts, *_ = ps._signal(j)
st = _fresh_state(j, back=60)
st = ps.advance(st, j)
assert st["n_bars"] == 60 # ha processato le 60 barre forward
assert st["last_ts"] == int(ts[-1])
assert st["cap_modeled"] != ps.MODELED_CAPITAL # l'equity si è mossa
# idempotente: ri-avanzare alla punta non aggiunge barre né muove l'equity
eq_before = st["cap_modeled"]
st2 = ps.advance(dict(st), j)
assert st2["n_bars"] == 60
assert st2["cap_modeled"] == eq_before
def test_real600_tracks_modeled_low_turnover(tmp_path, monkeypatch):
_redirect(tmp_path, monkeypatch)
j = ps.build_joint("1d")
st = ps.advance(_fresh_state(j, back=250), j)
rm = st["cap_modeled"] / ps.MODELED_CAPITAL - 1
rr = st["cap_real"] / ps.REAL_CAPITAL - 1
assert abs(rm - rr) < 0.05 # haircut di fill piccolo a 1d ($600)
+50
View File
@@ -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"]
+82
View File
@@ -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
+62
View File
@@ -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