Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee82e0a056 | |||
| c52e0ab3f8 | |||
| cff5fa2bf5 |
@@ -66,3 +66,4 @@ scripts/research/blind/leaderboard.json
|
||||
# forward-monitor runtime state (regenerable, forward-only)
|
||||
data/paper_prevday/
|
||||
data/paper_combo/
|
||||
data/paper_statarb/
|
||||
|
||||
@@ -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
|
||||
DVOL-spike de-risk è **ridondante col trend** (TP01 già flat nei crash, Δ 0.00). **Lezione: per meno
|
||||
DD sul live la leva è `target_vol`, non un overlay DVOL** (20% resta canonico). Diario `2026-06-26-tp01-dvol-overlay.md`.
|
||||
- **Sweep strategie a 5 thread (2026-06-29) — 0 nuovi sleeve, 1 LEAD che rompe 2 muri su 3.** Ricerca
|
||||
parallela onesta su aree inesplorate (harness `altlib`+`xsec_v2_nonmom`, tutti i gate incl. il nuovo
|
||||
`study_family_honest`): (1) low-risk cross-sectional, (2) momentum-structure vs XS01, (3) meta-allocazione
|
||||
dinamica, (4) segnali ortogonali ETH/BTC, (5) 1-gamba a segnale. Esito: soffitto ~1.3 riconfermato; ogni
|
||||
candidato ucciso dal gate giusto (deflated-Sharpe, is_hedge, selection-on-holdout, sostituzione-XS01,
|
||||
multi-cut). Niente batte/diversifica XS01 (varianti = REDUNDANT); meta-allocazione < pesi fissi (i 4
|
||||
sleeve già quasi-risk-parity); 1-gamba a segnale = TP01 travestito (trend) o hedge a DSR<0.95.
|
||||
**LEAD forward-monitor:** **STATARB-RESID** (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:
|
||||
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.**
|
||||
|
||||
@@ -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).
|
||||
@@ -10,6 +10,7 @@ mkdir -p logs
|
||||
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_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
|
||||
# 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.
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,604 @@
|
||||
"""orthogonal_signals.py — SIGNAL-BASED, BY-CONSTRUCTION-ORTHOGONAL streams (2026-06-29).
|
||||
|
||||
TESI (richiesta utente). Il book attivo è TP01 (trend) + XS01 (cross-sectional) + VRP01
|
||||
(short-vol) + SKH01 (regime L/S). Tutti hanno beta direzionale o vol crypto. Cerchiamo uno
|
||||
stream a **beta di mercato ~0 e bassa corr al book** ma con un **EDGE DI SEGNALE reale** —
|
||||
NON la "diversification math" di uno stream a Sharpe~0 (il marginal scorer indurito la boccia:
|
||||
serve has_insample_edge, Sharpe standalone PRE-2025 >= 0.5, deflated-Sharpe sull'intera griglia,
|
||||
e selezione della cella IN-SAMPLE, mai sul max hold-out).
|
||||
|
||||
FOCUS PRIMARIO — RELATIVE-VALUE ETH/BTC (dollar-neutral, 2 gambe). La posizione è sul SPREAD
|
||||
long-ETH/short-BTC (o viceversa): r_spread[i] = pos[i-1]*(r_eth[i]-r_btc[i]) - fee*2*|Δpos|.
|
||||
Per costruzione beta_mercato ~0 -> scorrelato a TP01/SKH. VANTAGGIO: un book a 2 gambe su
|
||||
Deribit perp (BTC+ETH entrambi live) è MOLTO più vicino all'eseguibile a $600 di uno a 19 gambe
|
||||
(XS01 è STAT-MODE). Segnali sul ratio log(ETH/BTC), tutti CAUSALI (decisione <= close[i]):
|
||||
1. RATIO-MOM momentum del ratio (trend dello spread).
|
||||
2. RATIO-REV reversal di breve del ratio.
|
||||
3. RATIO-ACCEL accelerazione (2a differenza / curvatura) del ratio.
|
||||
4. VOLSPREAD vol realizzata relativa BTC vs ETH -> verso l'asset col profilo giusto.
|
||||
5. DVOLSPREAD vol IMPLICITA relativa (al.dvol BTC/ETH) -> re-valida l'ex-lead 'dvol_spread'.
|
||||
6. STATARB-RESID residuo di ETH dopo beta*BTC (rolling OLS causale) -> mean-revert il residuo.
|
||||
|
||||
SECONDARIO — CRYPTO vs MACRO (GLD/QQQ/TLT): long/short crypto vs hedge su momentum relativo,
|
||||
merge_asof backward (equity 5gg/sett vs crypto 24/7), niente look-ahead. Probabile debole.
|
||||
|
||||
GATE (tutti obbligatori, replicano altlib indurito):
|
||||
* CAUSALITÀ: prefix-check sul SPREAD (ricostruisci su prefisso, la coda deve combaciare).
|
||||
* NETTO fee 0.10% RT su 2 gambe + SWEEP (0.00-0.30% RT).
|
||||
* SELEZIONE CELLA IN-SAMPLE-ONLY (pre-2025), MAI sul max hold-out (punto cieco filone B).
|
||||
* DEFLATED-SHARPE su TUTTE le celle cercate (multiple-testing).
|
||||
* has_insample_edge: Sharpe standalone PRE-2025 >= 0.5 (no diversification-math).
|
||||
* OOS hold-out 2025+, plateau su griglia, per-anno.
|
||||
* corr vs BOOK 4-sleeve (|corr|<0.2 ideale) + beta vs mercato (50/50 BTC+ETH) ~0.
|
||||
* marginal_vs_tp01 (ADDS / HEDGE / NOISE / NEUTRAL) -> earns_slot_honest.
|
||||
* EXEC $600: haircut a 2 gambe (min $5/gamba, fee 0.10% RT) — il punto FORTE del filone.
|
||||
|
||||
ONESTÀ BRUTALE: bassa-corr da sola NON basta. dvol_spread era forward-monitor (storia DVOL
|
||||
corta + multiple-testing); ratio_accel era lead debole. Se è diversification-math o
|
||||
hold-out-fitting -> NOISE/SCARTATO con numeri.
|
||||
|
||||
Esecuzione: cd /opt/docker/PythagorasGoal && uv run python scripts/research/orthogonal_signals.py
|
||||
Idempotente, solo stdout.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import math
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(_ROOT / "scripts" / "research" / "alt"))
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
import altlib as al # noqa: E402
|
||||
|
||||
FEE_SIDE = al.FEE_SIDE # 0.0005 = 0.05%/side
|
||||
FEE_SWEEP = (0.0, 0.00025, 0.0005, 0.001, 0.0015) # per-side; ×2 legs ×2 (RT) for RT%
|
||||
HOLDOUT = al.HOLDOUT
|
||||
ANN = 365.25
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# JOINT FRAME + DOLLAR-NEUTRAL EVALUATOR (custom — eval_weights is single-asset)
|
||||
# ===========================================================================
|
||||
def build_joint(tf: str = "1d") -> pd.DataFrame:
|
||||
"""BTC/ETH allineati sull'indice comune (inner join su timestamp). Ritorna un frame con
|
||||
r_btc/r_eth (simple), close_*, log_ratio = log(ETH/BTC), datetime, timestamp."""
|
||||
b = al.get("BTC", tf)[["timestamp", "datetime", "close"]].rename(columns={"close": "cb"})
|
||||
e = al.get("ETH", tf)[["timestamp", "close"]].rename(columns={"close": "ce"})
|
||||
j = b.merge(e, on="timestamp", how="inner").sort_values("timestamp").reset_index(drop=True)
|
||||
j["r_btc"] = al.simple_returns(j["cb"].values)
|
||||
j["r_eth"] = al.simple_returns(j["ce"].values)
|
||||
j["log_ratio"] = np.log(j["ce"].values / j["cb"].values)
|
||||
return j
|
||||
|
||||
|
||||
def spread_ret(j: pd.DataFrame) -> np.ndarray:
|
||||
"""Ritorno dollar-neutral per unità di gross-per-gamba: long $1 ETH, short $1 BTC."""
|
||||
return j["r_eth"].values - j["r_btc"].values
|
||||
|
||||
|
||||
def vol_target_spread(direction: np.ndarray, j: pd.DataFrame, target_vol: float = 0.20,
|
||||
win_days: int = 30, cap: float = 2.0) -> np.ndarray:
|
||||
"""Scala una direzione in [-1,1] a vol-target sullo SPREAD (causale: vol realizzata <= i)."""
|
||||
s = spread_ret(j)
|
||||
bpd = al.bars_per_day(j)
|
||||
bpy = bpd * ANN
|
||||
vol = al.realized_vol(s, max(2, win_days * bpd), bpy)
|
||||
scal = np.where((vol > 0) & np.isfinite(vol), target_vol / vol, 0.0)
|
||||
pos = np.clip(np.nan_to_num(direction) * scal, -cap, cap)
|
||||
pos[~np.isfinite(pos)] = 0.0
|
||||
return pos
|
||||
|
||||
|
||||
def eval_spread(j: pd.DataFrame, pos: np.ndarray, fee_side: float = FEE_SIDE) -> dict:
|
||||
"""Backtest ONESTO del SPREAD. pos[i] decisa <= close[i], TENUTA durante la barra i+1
|
||||
(lo shift è qui -> niente leak). Fee su 2 GAMBE: ogni Δpos muove ETH e BTC -> 2×|Δpos|."""
|
||||
pos = np.nan_to_num(np.asarray(pos, float))
|
||||
s = spread_ret(j)
|
||||
held = np.zeros(len(pos)); held[1:] = pos[:-1]
|
||||
gross = held * s
|
||||
turn = np.abs(np.diff(held, prepend=0.0)) # turnover per-gamba
|
||||
net = gross - fee_side * 2.0 * turn # 2 gambe
|
||||
net[0] = 0.0
|
||||
idx = pd.DatetimeIndex(pd.to_datetime(j["datetime"], utc=True))
|
||||
full = al._metrics_from_net(net, idx)
|
||||
hmask = idx >= HOLDOUT
|
||||
hold = al._metrics_from_net(net[hmask], idx[hmask]) if hmask.sum() > 3 else dict(sharpe=0.0, n=0)
|
||||
return dict(full=full, holdout=hold, yearly=al._yearly(net, idx),
|
||||
tim=round(float(np.mean(held != 0)), 3),
|
||||
turnover=round(float(turn.sum() / (len(turn) / (al.bars_per_day(j) * ANN))), 1),
|
||||
net=net, idx=idx)
|
||||
|
||||
|
||||
def spread_daily(j: pd.DataFrame, pos: np.ndarray, fee_side: float = FEE_SIDE) -> pd.Series:
|
||||
"""Serie NET giornaliera del candidato spread (compounded a 1d se TF sub-daily)."""
|
||||
ev = eval_spread(j, pos, fee_side=fee_side)
|
||||
s = pd.Series(ev["net"], index=ev["idx"])
|
||||
return al._to_daily(s)
|
||||
|
||||
|
||||
def eval_spread_smallcap(j: pd.DataFrame, pos: np.ndarray, capital: float = 600.0,
|
||||
min_order: float = 5.0, fee_side: float = FEE_SIDE) -> dict:
|
||||
"""Net REALISTICO a $600 su 2 GAMBE. Un Δpos il cui nozionale PER-GAMBA |Δpos|*capital < $5
|
||||
NON si esegue (held). Le due gambe cambiano dello stesso |Δ| -> il vincolo binding è
|
||||
|Δpos|*capital >= min_order. Riporta Sharpe modellato vs realistico + haircut + n trade."""
|
||||
pos = np.clip(np.nan_to_num(np.asarray(pos, float)), -10, 10)
|
||||
held = np.empty(len(pos)); cur = 0.0; n_tr = 0
|
||||
for i in range(len(pos)):
|
||||
if abs(pos[i] - cur) * capital >= min_order:
|
||||
cur = pos[i]; n_tr += 1
|
||||
held[i] = cur
|
||||
s = spread_ret(j)
|
||||
p = np.zeros(len(held)); p[1:] = held[:-1]
|
||||
turn = np.abs(np.diff(p, prepend=0.0))
|
||||
net = p * s - fee_side * 2.0 * turn; net[0] = 0.0
|
||||
idx = pd.DatetimeIndex(pd.to_datetime(j["datetime"], utc=True))
|
||||
real = al._metrics_from_net(net, idx)
|
||||
modeled = eval_spread(j, pos, fee_side=fee_side)["full"]
|
||||
return dict(realistic=real, modeled=modeled,
|
||||
sharpe_haircut=round(modeled["sharpe"] - real["sharpe"], 3),
|
||||
n_executed_trades=int(n_tr))
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# CAUSALITY — prefix-check sul SPREAD (la coda del prefisso deve combaciare col full).
|
||||
# ===========================================================================
|
||||
def causality_spread(make_pos, tf: str = "1d", tail: int = 80, tol: float = 1e-6) -> dict:
|
||||
"""make_pos(j) -> pos sull'intero frame. Ricostruisce su prefissi; la coda deve combaciare."""
|
||||
j = build_joint(tf)
|
||||
full = np.nan_to_num(make_pos(j))
|
||||
n = len(j)
|
||||
worst = 0.0; bad = False; checked = 0
|
||||
for cut in (int(n * 0.80), int(n * 0.92)):
|
||||
if cut <= tail + 5 or cut >= n:
|
||||
continue
|
||||
sub = j.iloc[:cut].reset_index(drop=True)
|
||||
s = np.nan_to_num(make_pos(sub))
|
||||
if len(s) != cut:
|
||||
bad = True; continue
|
||||
d = np.abs(s[cut - tail:cut] - full[cut - tail:cut])
|
||||
worst = max(worst, float(np.max(d)) if len(d) else 0.0)
|
||||
checked += 1
|
||||
return dict(ok=bool((not bad) and worst <= tol), max_tail_diff=round(worst, 10), checked=checked)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SIGNAL FACTORIES — factory(tf, **p) -> make_pos(j) -> vol-targeted position on the spread
|
||||
# (direzione in [-1,1] internamente, poi vol_target_spread). 'sgn' (+/-1) testa il verso.
|
||||
# ===========================================================================
|
||||
def f_ratio_mom(tf="1d", L=30, sgn=1, tv=0.20, vw=30, cap=2.0):
|
||||
def make(j):
|
||||
lr = j["log_ratio"].values
|
||||
mom = lr - pd.Series(lr).shift(L).values # momentum L-barre del ratio
|
||||
z = al.zscore(mom, max(20, L))
|
||||
d = sgn * np.tanh(np.nan_to_num(z))
|
||||
return vol_target_spread(d, j, tv, vw, cap)
|
||||
return make
|
||||
|
||||
|
||||
def f_ratio_rev(tf="1d", L=5, sgn=-1, tv=0.20, vw=30, cap=2.0):
|
||||
def make(j):
|
||||
lr = j["log_ratio"].values
|
||||
z = al.zscore(lr, L) # deviazione di breve dal trend locale
|
||||
d = sgn * np.tanh(np.nan_to_num(z))
|
||||
return vol_target_spread(d, j, tv, vw, cap)
|
||||
return make
|
||||
|
||||
|
||||
def f_ratio_accel(tf="1d", L=20, sgn=-1, tv=0.20, vw=30, cap=2.0):
|
||||
def make(j):
|
||||
lr = pd.Series(j["log_ratio"].values)
|
||||
accel = lr - 2 * lr.shift(L) + lr.shift(2 * L) # 2a differenza (curvatura)
|
||||
z = al.zscore(np.nan_to_num(accel.values), max(20, L))
|
||||
d = sgn * np.tanh(np.nan_to_num(z))
|
||||
return vol_target_spread(d, j, tv, vw, cap)
|
||||
return make
|
||||
|
||||
|
||||
def f_volspread(tf="1d", W=30, sgn=1, tv=0.20, vw=30, cap=2.0):
|
||||
def make(j):
|
||||
bpd = al.bars_per_day(j); bpy = bpd * ANN
|
||||
vb = al.realized_vol(j["r_btc"].values, max(2, W * bpd), bpy)
|
||||
ve = al.realized_vol(j["r_eth"].values, max(2, W * bpd), bpy)
|
||||
z = al.zscore(np.nan_to_num(vb - ve), max(30, W)) # BTC più volatile di ETH?
|
||||
d = sgn * np.tanh(np.nan_to_num(z))
|
||||
return vol_target_spread(d, j, tv, vw, cap)
|
||||
return make
|
||||
|
||||
|
||||
def f_dvolspread(tf="1d", W=30, sgn=1, tv=0.20, vw=30, cap=2.0):
|
||||
def make(j):
|
||||
db = al.dvol(j, "BTC"); de = al.dvol(j, "ETH") # vol IMPLICITA causale (merge_asof)
|
||||
sp = np.nan_to_num(db - de)
|
||||
z = al.zscore(sp, max(30, W))
|
||||
d = sgn * np.tanh(np.nan_to_num(z))
|
||||
return vol_target_spread(d, j, tv, vw, cap)
|
||||
return make
|
||||
|
||||
|
||||
def f_statarb_resid(tf="1d", W=60, sgn=-1, tv=0.20, vw=30, cap=2.0):
|
||||
def make(j):
|
||||
x = np.log(j["cb"].values); y = np.log(j["ce"].values) # OLS rolling causale y~a+b x
|
||||
sx = pd.Series(x); sy = pd.Series(y)
|
||||
mx = sx.rolling(W, min_periods=W).mean()
|
||||
my = sy.rolling(W, min_periods=W).mean()
|
||||
cov = (sx * sy).rolling(W, min_periods=W).mean() - mx * my
|
||||
var = (sx * sx).rolling(W, min_periods=W).mean() - mx * mx
|
||||
beta = (cov / var.replace(0, np.nan))
|
||||
resid = (sy - (my - beta * mx) - beta * sx).values # resid al tempo i (usa <= i)
|
||||
z = al.zscore(np.nan_to_num(resid), W)
|
||||
d = sgn * np.tanh(np.nan_to_num(z)) # mean-revert il residuo
|
||||
return vol_target_spread(d, j, tv, vw, cap)
|
||||
return make
|
||||
|
||||
|
||||
FAMILIES = {
|
||||
"RATIO-MOM": (f_ratio_mom, [dict(L=L, sgn=s) for L in (15, 30, 45, 60, 90) for s in (1, -1)], ("1d",)),
|
||||
"RATIO-REV": (f_ratio_rev, [dict(L=L, sgn=s) for L in (3, 5, 8, 12, 20) for s in (1, -1)], ("1d",)),
|
||||
"RATIO-ACCEL": (f_ratio_accel, [dict(L=L, sgn=s) for L in (10, 20, 30, 45) for s in (1, -1)], ("1d",)),
|
||||
"VOLSPREAD": (f_volspread, [dict(W=W, sgn=s) for W in (10, 20, 30, 60) for s in (1, -1)], ("1d",)),
|
||||
"DVOLSPREAD": (f_dvolspread, [dict(W=W, sgn=s) for W in (15, 30, 45, 60) for s in (1, -1)], ("1d",)),
|
||||
"STATARB-RESID": (f_statarb_resid, [dict(W=W, sgn=s) for W in (30, 45, 60, 90, 120) for s in (1, -1)], ("1d",)),
|
||||
}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# BOOK + MARKET references (for corr / beta)
|
||||
# ===========================================================================
|
||||
def book_daily() -> pd.Series:
|
||||
"""Serie NET giornaliera del BOOK attivo a 4 sleeve (TP01+XS01+VRP01+SKH01)."""
|
||||
from src.portfolio.sleeves import active_sleeves
|
||||
from src.portfolio.portfolio import StrategyPortfolio
|
||||
pf = StrategyPortfolio(active_sleeves()); pf.backtest()
|
||||
s = pf.combined_daily()
|
||||
if not isinstance(s.index, pd.DatetimeIndex):
|
||||
s.index = pd.to_datetime(s.index, utc=True)
|
||||
if s.index.tz is None:
|
||||
s.index = s.index.tz_localize("UTC")
|
||||
return s.dropna()
|
||||
|
||||
|
||||
def market_daily() -> pd.Series:
|
||||
"""Mercato di riferimento: 50/50 BTC+ETH ritorni semplici giornalieri (per beta ~0)."""
|
||||
series = {}
|
||||
for a in ("BTC", "ETH"):
|
||||
df = al.get(a, "1d")
|
||||
series[a] = pd.Series(al.simple_returns(df["close"].values),
|
||||
index=pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)))
|
||||
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
|
||||
return (0.5 * J["BTC"] + 0.5 * J["ETH"]).dropna()
|
||||
|
||||
|
||||
def beta_to(cand: pd.Series, ref: pd.Series) -> tuple[float, float]:
|
||||
J = pd.concat({"c": cand, "r": ref}, axis=1, join="inner").dropna()
|
||||
if len(J) < 30 or J["r"].var() == 0:
|
||||
return float("nan"), float("nan")
|
||||
c, r = J["c"].values, J["r"].values
|
||||
beta = float(np.cov(c, r)[0, 1] / np.var(r))
|
||||
return round(beta, 3), round(float(J["c"].corr(J["r"])), 3)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# HONEST FAMILY STUDY for the SPREAD (replica study_family_honest a mano):
|
||||
# (1) cella scelta su Sharpe IN-SAMPLE (pre-HOLDOUT), MAI sul max hold-out;
|
||||
# (2) deflated-Sharpe su TUTTE le celle; (3) marginal_vs_tp01 sulla cella scelta.
|
||||
# ===========================================================================
|
||||
def _sh(s) -> float:
|
||||
return al._sh(s)
|
||||
|
||||
|
||||
def study_spread_family(name, factory, grid, tfs, refs, fee_side=FEE_SIDE, dsr_min=0.95) -> dict:
|
||||
rows = []
|
||||
for tf in tfs:
|
||||
for p in grid:
|
||||
try:
|
||||
j = build_joint(tf)
|
||||
pos = factory(tf=tf, **p)(j)
|
||||
daily = spread_daily(j, pos, fee_side=fee_side)
|
||||
except Exception as ex: # pragma: no cover
|
||||
rows.append(dict(tf=tf, params=p, err=str(ex)[:60])); continue
|
||||
ins = daily[daily.index < HOLDOUT]
|
||||
is_sh = _sh(ins) if len(ins) > 60 else float("nan")
|
||||
rows.append(dict(tf=tf, params=p, daily=daily, insample_sharpe=round(is_sh, 3),
|
||||
full_sharpe=round(_sh(daily), 3)))
|
||||
valid = [r for r in rows if r.get("insample_sharpe") is not None
|
||||
and np.isfinite(r.get("insample_sharpe", np.nan))]
|
||||
if not valid:
|
||||
return dict(name=name, chosen=None, earns_slot_honest=False, reason="no valid in-sample cell")
|
||||
chosen = max(valid, key=lambda r: r["insample_sharpe"])
|
||||
all_full = [r["full_sharpe"] for r in rows if r.get("full_sharpe") is not None]
|
||||
|
||||
j = build_joint(chosen["tf"])
|
||||
pos = factory(tf=chosen["tf"], **chosen["params"])(j)
|
||||
daily = chosen["daily"]
|
||||
ev = eval_spread(j, pos, fee_side=fee_side)
|
||||
|
||||
# fee sweep (RT% = per-side ×2 legs? No: RT per leg = 2×side; on 2 legs the cost already
|
||||
# ×2 in eval_spread. Report per-side grid -> "X%RT/leg".)
|
||||
sweep = {}
|
||||
for f in FEE_SWEEP:
|
||||
sweep[f"{2*f*100:.2f}%RT/leg"] = round(eval_spread(j, pos, fee_side=f)["full"]["sharpe"], 3)
|
||||
fee_survives = sweep.get(f"{2*0.0015*100:.2f}%RT/leg", -9) > 0
|
||||
|
||||
# marginal vs TP01
|
||||
marg = al.marginal_vs_tp01(daily)
|
||||
# deflated Sharpe over the WHOLE grid (come da istruzione)
|
||||
dsr, sr0 = al.deflated_sharpe(_sh(daily), all_full, daily)
|
||||
dsr_pass = bool(np.isfinite(dsr) and dsr >= dsr_min)
|
||||
# SENSIBILITÀ: la griglia include sgn=+1 E sgn=-1 (specchi: +Sh e -Sh) -> raddoppia la
|
||||
# dispersione dei trial e gonfia il null-max. DSR "same-sign" = deflazione sul SOLO verso
|
||||
# scelto (conta i soli lookback davvero in competizione). È il limite ottimistico onesto.
|
||||
same = [r["full_sharpe"] for r in rows
|
||||
if r.get("full_sharpe") is not None and r["params"].get("sgn") == chosen["params"].get("sgn")]
|
||||
dsr_ss, sr0_ss = al.deflated_sharpe(_sh(daily), same if len(same) >= 2 else all_full, daily)
|
||||
dsr_ss_pass = bool(np.isfinite(dsr_ss) and dsr_ss >= dsr_min)
|
||||
|
||||
# corr/beta vs BOOK & MARKET
|
||||
bbeta, bcorr = beta_to(daily, refs["book"])
|
||||
mbeta, mcorr = beta_to(daily, refs["market"])
|
||||
btcbeta, btccorr = beta_to(daily, refs["btc"])
|
||||
ethbeta, ethcorr = beta_to(daily, refs["eth"])
|
||||
|
||||
# $600 executability (2 legs)
|
||||
sc = eval_spread_smallcap(j, pos)
|
||||
|
||||
earns = bool(marg.get("marginal_verdict") == "ADDS" and marg.get("robust_oos", False)
|
||||
and marg.get("has_insample_edge", False) and not marg.get("is_hedge", False)
|
||||
and dsr_pass and fee_survives)
|
||||
|
||||
return dict(name=name, n_cells=len(all_full), chosen=chosen, rows=valid,
|
||||
ev=ev, sweep=sweep, fee_survives=fee_survives, marginal=marg,
|
||||
deflated_sharpe=round(dsr, 3) if np.isfinite(dsr) else None,
|
||||
expected_null_max=round(sr0, 3) if np.isfinite(sr0) else None, dsr_pass=dsr_pass,
|
||||
dsr_samesign=round(dsr_ss, 3) if np.isfinite(dsr_ss) else None,
|
||||
expected_null_max_ss=round(sr0_ss, 3) if np.isfinite(sr0_ss) else None,
|
||||
dsr_ss_pass=dsr_ss_pass, n_cells_samesign=len(same),
|
||||
book=dict(beta=bbeta, corr=bcorr), market=dict(beta=mbeta, corr=mcorr),
|
||||
btc=dict(beta=btcbeta, corr=btccorr), eth=dict(beta=ethbeta, corr=ethcorr),
|
||||
smallcap=sc, earns_slot_honest=earns)
|
||||
|
||||
|
||||
def verdict_for(rep: dict) -> tuple[str, str]:
|
||||
if rep.get("chosen") is None:
|
||||
return "SCARTATO", "nessuna cella valida"
|
||||
m = rep["marginal"]
|
||||
is_edge = m.get("has_insample_edge"); is_sh = m.get("cand_insample_sharpe")
|
||||
dsr = rep.get("deflated_sharpe"); mv = m.get("marginal_verdict")
|
||||
ss = rep.get("dsr_samesign"); ss_tag = f"same-sign {ss} {'pass' if rep.get('dsr_ss_pass') else 'fail'}"
|
||||
if rep["earns_slot_honest"]:
|
||||
return "SLEEVE-CANDIDATE-eseguibile", "ADDS + robust_oos + edge in-sample + DSR pass + fee/exec ok"
|
||||
if mv == "DILUTES":
|
||||
return "SCARTATO", "DILUTES (abbassa il blend del book)"
|
||||
if m.get("is_hedge"):
|
||||
return "SCARTATO", "HEDGE (paga solo quando TP01 è debole, non alpha)"
|
||||
if not is_edge:
|
||||
return "NOISE", f"no edge in-sample (Sharpe<2025 {is_sh} < 0.5) -> diversification-math"
|
||||
# ha edge in-sample reale: il discriminante è se MIGLIORA il book (marginal ADDS)
|
||||
if mv == "ADDS":
|
||||
if not rep.get("dsr_pass"):
|
||||
return "LEAD-forward-monitor", (f"ADDS + edge in-sample {is_sh} + executable, MA deflated-Sharpe "
|
||||
f"full-grid {dsr} < 0.95 ({ss_tag}) -> multiple-testing")
|
||||
if not m.get("robust_oos"):
|
||||
return "LEAD-forward-monitor", "ADDS + edge ma non robust_oos (single-window / multi-cut debole)"
|
||||
return "LEAD-forward-monitor", "ADDS ma blocco fee/exec"
|
||||
# edge standalone reale ma marginal NEUTRAL/REDUNDANT: NON migliora il book -> niente slot
|
||||
return "NEUTRAL-standalone", (f"edge in-sample {is_sh} reale ma marginal={mv}: non migliora il book "
|
||||
f"(corr~0 senza uplift = diversification-math; hold-out debole)")
|
||||
|
||||
|
||||
def print_family(rep: dict):
|
||||
print("=" * 100)
|
||||
print(f"### {rep['name']}")
|
||||
if rep.get("chosen") is None:
|
||||
print(" SCARTATO:", rep.get("reason")); return
|
||||
ch = rep["chosen"]; ev = rep["ev"]; m = rep["marginal"]
|
||||
print(f" best cell (IN-SAMPLE pick): tf={ch['tf']} params={ch['params']} "
|
||||
f"[cercate {rep['n_cells']} celle]")
|
||||
print(f" in-sample Sharpe (pre-2025) {ch['insample_sharpe']} | FULL Sharpe {ev['full']['sharpe']} "
|
||||
f"DD {ev['full']['maxdd']*100:.1f}% ret {ev['full']['ret']*100:+.0f}% | "
|
||||
f"HOLD Sharpe {ev['holdout'].get('sharpe')} ret {ev['holdout'].get('ret',0)*100:+.0f}%")
|
||||
print(f" time-in-mkt {ev['tim']} turnover/yr {ev['turnover']}")
|
||||
print(f" per-anno: " + " ".join(f"{y}:{d['ret']*100:+.0f}%(dd{d['dd']*100:.0f})"
|
||||
for y, d in ev["yearly"].items()))
|
||||
print(f" fee sweep (per-leg RT): {rep['sweep']} fee_survives={rep['fee_survives']}")
|
||||
print(f" corr vs BOOK {rep['book']['corr']} (beta {rep['book']['beta']}) | "
|
||||
f"beta vs MERCATO(50/50) {rep['market']['beta']} (corr {rep['market']['corr']})")
|
||||
print(f" corr/beta vs BTC {rep['btc']['corr']}/{rep['btc']['beta']} "
|
||||
f"vs ETH {rep['eth']['corr']}/{rep['eth']['beta']}")
|
||||
print(f" MARGINAL vs TP01: verdict={m.get('marginal_verdict')} corr_full={m.get('corr_full')} "
|
||||
f"corr_hold={m.get('corr_hold')}")
|
||||
print(f" has_insample_edge={m.get('has_insample_edge')} (standalone Sh<2025 {m.get('cand_insample_sharpe')}) "
|
||||
f"robust_oos={m.get('robust_oos')} multicut={m.get('multicut_persistent')} {m.get('multicut_uplift')}")
|
||||
print(f" blend w25: full {m['blends']['w25']['full']} (uplift {m['blends']['w25']['uplift_full']:+.3f}) "
|
||||
f"hold {m['blends']['w25']['hold']} (uplift {m['blends']['w25']['uplift_hold']}) is_hedge={m.get('is_hedge')}")
|
||||
print(f" DEFLATED-Sharpe full-grid {rep['deflated_sharpe']} (null-max {rep['expected_null_max']}, "
|
||||
f"{rep['n_cells']} celle) pass={rep['dsr_pass']} | same-sign {rep['dsr_samesign']} "
|
||||
f"(null-max {rep['expected_null_max_ss']}, {rep['n_cells_samesign']} celle) pass={rep['dsr_ss_pass']}")
|
||||
sc = rep["smallcap"]
|
||||
print(f" EXEC $600 (2 gambe): modeled Sh {sc['modeled']['sharpe']} -> realistic {sc['realistic']['sharpe']} "
|
||||
f"(haircut {sc['sharpe_haircut']}) trade eseguiti {sc['n_executed_trades']}")
|
||||
v, why = verdict_for(rep)
|
||||
print(f" >>> VERDETTO: {v} — {why}")
|
||||
print(f" earns_slot_honest = {rep['earns_slot_honest']}")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SECONDARIO — CRYPTO vs MACRO (GLD/QQQ/TLT) momentum relativo, merge_asof backward.
|
||||
# ===========================================================================
|
||||
def _eq_daily(sym: str) -> pd.Series:
|
||||
p = _ROOT / "data" / "raw" / f"eq_{sym}_1d.parquet"
|
||||
d = pd.read_parquet(p).sort_values("timestamp").reset_index(drop=True)
|
||||
idx = pd.DatetimeIndex(pd.to_datetime(d["timestamp"], unit="ms", utc=True))
|
||||
return pd.Series(d["close"].values, index=idx)
|
||||
|
||||
|
||||
def study_macro_rv(hedge: str, L: int = 60, sgn: int = 1, fee_side: float = FEE_SIDE,
|
||||
refs: dict | None = None) -> dict:
|
||||
"""Long crypto (50/50 BTC+ETH) / short hedge ETF su momentum relativo L-giorni.
|
||||
Allineamento: calendario dell'EQUITY (trading days); crypto allineata backward (no look-ahead).
|
||||
Decisione <= close[i] del giorno di trading."""
|
||||
eqc = _eq_daily(hedge)
|
||||
# crypto close 50/50 (geometric proxy: media dei log-prezzi normalizzati)
|
||||
cb = al.get("BTC", "1d"); ce = al.get("ETH", "1d")
|
||||
cbi = pd.Series(cb["close"].values, index=pd.DatetimeIndex(pd.to_datetime(cb["datetime"], utc=True)))
|
||||
cei = pd.Series(ce["close"].values, index=pd.DatetimeIndex(pd.to_datetime(ce["datetime"], utc=True)))
|
||||
cj = pd.concat({"b": cbi, "e": cei}, axis=1, join="inner").dropna()
|
||||
crypto = np.exp(0.5 * np.log(cj["b"]) + 0.5 * np.log(cj["e"])) # crypto index level
|
||||
# merge_asof backward: per ogni giorno equity, l'ultimo close crypto <= quel giorno
|
||||
L_ = pd.DataFrame({"ts": eqc.index, "eq": eqc.values}).sort_values("ts")
|
||||
R_ = pd.DataFrame({"ts": crypto.index, "cr": crypto.values}).sort_values("ts")
|
||||
mg = pd.merge_asof(L_, R_, on="ts", direction="backward").dropna()
|
||||
eqv = mg["eq"].values; crv = mg["cr"].values; idx = pd.DatetimeIndex(mg["ts"])
|
||||
r_eq = al.simple_returns(eqv); r_cr = al.simple_returns(crv)
|
||||
s = r_cr - r_eq # long crypto / short hedge
|
||||
lr = np.log(crv) - np.log(eqv) # log relative level
|
||||
mom = lr - pd.Series(lr).shift(L).values
|
||||
z = al.zscore(np.nan_to_num(mom), max(20, L))
|
||||
d = sgn * np.tanh(np.nan_to_num(z))
|
||||
# vol target on the macro spread
|
||||
vol = al.realized_vol(s, 30, ANN)
|
||||
scal = np.where((vol > 0) & np.isfinite(vol), 0.20 / vol, 0.0)
|
||||
pos = np.clip(np.nan_to_num(d) * scal, -2.0, 2.0)
|
||||
held = np.zeros(len(pos)); held[1:] = pos[:-1]
|
||||
net = held * s - fee_side * 2.0 * np.abs(np.diff(held, prepend=0.0)); net[0] = 0.0
|
||||
full = al._metrics_from_net(net, idx)
|
||||
hmask = idx >= HOLDOUT
|
||||
hold = al._metrics_from_net(net[hmask], idx[hmask]) if hmask.sum() > 3 else dict(sharpe=0.0, n=0)
|
||||
daily = al._to_daily(pd.Series(net, index=idx))
|
||||
ins = daily[daily.index < HOLDOUT]
|
||||
is_sh = round(_sh(ins), 3) if len(ins) > 60 else None
|
||||
bcorr = beta_to(daily, refs["book"])[1] if refs else None
|
||||
mbeta, mcorr = beta_to(daily, refs["market"]) if refs else (None, None)
|
||||
btcb = beta_to(daily, refs["btc"])[0] if refs else None
|
||||
return dict(hedge=hedge, L=L, sgn=sgn, full=full, holdout=hold,
|
||||
insample_sharpe=is_sh, book_corr=bcorr, mkt_beta=mbeta, mkt_corr=mcorr,
|
||||
btc_beta=btcb, yearly=al._yearly(net, idx), daily=daily)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# MAIN
|
||||
# ===========================================================================
|
||||
def main():
|
||||
print("#" * 100)
|
||||
print("# ORTHOGONAL SIGNALS — RELATIVE-VALUE ETH/BTC (dollar-neutral) + CRYPTO-vs-MACRO")
|
||||
print("#" * 100)
|
||||
j = build_joint("1d")
|
||||
print(f"Joint BTC/ETH 1d: {len(j)} barre {j['datetime'].min().date()} -> {j['datetime'].max().date()} "
|
||||
f"(hold-out {HOLDOUT.date()}+)")
|
||||
s = spread_ret(j)
|
||||
print(f"Spread r_eth-r_btc: vol annua {np.std(s)*math.sqrt(ANN)*100:.0f}% "
|
||||
f"corr(r_eth,r_btc)={np.corrcoef(j['r_eth'].values[1:], j['r_btc'].values[1:])[0,1]:.2f}")
|
||||
|
||||
# references
|
||||
print("\nCarico riferimenti BOOK / MERCATO ...")
|
||||
refs = dict(book=book_daily(), market=market_daily())
|
||||
cb = al.get("BTC", "1d"); ce = al.get("ETH", "1d")
|
||||
refs["btc"] = pd.Series(al.simple_returns(cb["close"].values),
|
||||
index=pd.DatetimeIndex(pd.to_datetime(cb["datetime"], utc=True)))
|
||||
refs["eth"] = pd.Series(al.simple_returns(ce["close"].values),
|
||||
index=pd.DatetimeIndex(pd.to_datetime(ce["datetime"], utc=True)))
|
||||
print(f" BOOK 4-sleeve: {len(refs['book'])} giorni, Sharpe {_sh(refs['book']):.2f}")
|
||||
|
||||
# causality smoke-test on one cell per family
|
||||
print("\n--- CAUSALITÀ (prefix-check sullo SPREAD, tol 1e-6) ---")
|
||||
for name, (fac, grid, tfs) in FAMILIES.items():
|
||||
p0 = grid[0]
|
||||
ck = causality_spread(fac(tf=tfs[0], **p0), tf=tfs[0])
|
||||
print(f" {name:14s} ok={ck['ok']} max_tail_diff={ck['max_tail_diff']} checked={ck['checked']}")
|
||||
|
||||
# study each family
|
||||
print("\n" + "#" * 100)
|
||||
print("# RELATIVE-VALUE ETH/BTC — selezione cella IN-SAMPLE, deflated-Sharpe su tutta la griglia")
|
||||
print("#" * 100)
|
||||
summary = []
|
||||
for name, (fac, grid, tfs) in FAMILIES.items():
|
||||
rep = study_spread_family(name, fac, grid, tfs, refs)
|
||||
print_family(rep)
|
||||
v, why = verdict_for(rep)
|
||||
ch = rep.get("chosen")
|
||||
summary.append(dict(name=name, verdict=v,
|
||||
cell=(ch["params"] if ch else None),
|
||||
full=rep["ev"]["full"]["sharpe"] if ch else None,
|
||||
hold=rep["ev"]["holdout"].get("sharpe") if ch else None,
|
||||
is_sh=rep["marginal"].get("cand_insample_sharpe") if ch else None,
|
||||
book_corr=rep["book"]["corr"] if ch else None,
|
||||
mkt_beta=rep["market"]["beta"] if ch else None,
|
||||
dsr=rep.get("deflated_sharpe") if ch else None,
|
||||
marginal=rep["marginal"].get("marginal_verdict") if ch else None,
|
||||
earns=rep.get("earns_slot_honest") if ch else False,
|
||||
haircut=rep["smallcap"]["sharpe_haircut"] if ch else None))
|
||||
|
||||
# secondary: crypto vs macro
|
||||
print("\n" + "#" * 100)
|
||||
print("# SECONDARIO — CRYPTO vs MACRO (relative momentum, merge_asof backward)")
|
||||
print("#" * 100)
|
||||
macro_rows = []
|
||||
for hedge in ("gld", "qqq", "tlt"):
|
||||
best = None
|
||||
for L in (30, 60, 90):
|
||||
for sgn in (1, -1):
|
||||
r = study_macro_rv(hedge, L=L, sgn=sgn, refs=refs)
|
||||
if best is None or (r["insample_sharpe"] or -9) > (best["insample_sharpe"] or -9):
|
||||
best = r
|
||||
macro_rows.append(best)
|
||||
print(f" {hedge.upper():4s} bestIS L={best['L']} sgn={best['sgn']}: "
|
||||
f"FULL Sh {best['full']['sharpe']} DD {best['full']['maxdd']*100:.0f}% "
|
||||
f"HOLD Sh {best['holdout'].get('sharpe')} in-sample Sh {best['insample_sharpe']} "
|
||||
f"corr->BOOK {best['book_corr']} beta->MERCATO {best['mkt_beta']} (corr {best['mkt_corr']}) "
|
||||
f"beta->BTC {best['btc_beta']}")
|
||||
print(" NOTA: la vol crypto (~47%) domina GLD/TLT/QQQ (~15%) -> r_cr-r_hedge ≈ r_cr: la 'relative"
|
||||
" momentum' è di fatto MOMENTUM CRYPTO (la gamba hedge è troppo poco volatile per neutralizzare).")
|
||||
print(" Beta-mercato ~0 solo perché il momentum entra/esce dall'esposizione; ma la corr->BOOK"
|
||||
" 0.17-0.20 (vs ~0.02 degli spread ETH/BTC) tradisce l'overlap col trend di TP01 -> NON ortogonale.")
|
||||
# marginal gate onesto sul migliore macro (per smentire il numero di Sharpe tentatore)
|
||||
bm = max(macro_rows, key=lambda r: (r["insample_sharpe"] or -9))
|
||||
mg = al.marginal_vs_tp01(bm["daily"])
|
||||
print(f" MARGINAL gate sul migliore ({bm['hedge'].upper()}): verdict={mg.get('marginal_verdict')} "
|
||||
f"corr_full={mg.get('corr_full')} blend-uplift w25 full {mg['blends']['w25']['uplift_full']:+.3f} "
|
||||
f"hold {mg['blends']['w25']['uplift_hold']} robust_oos={mg.get('robust_oos')} "
|
||||
f"-> {'overlap col trend (no slot)' if mg.get('marginal_verdict') in ('NEUTRAL','REDUNDANT','DILUTES') else 'da gate completo'}")
|
||||
|
||||
# final table
|
||||
print("\n" + "#" * 100)
|
||||
print("# SINTESI")
|
||||
print("#" * 100)
|
||||
print(f"{'segnale':14s} {'verdetto':28s} {'FULL':>5s} {'HOLD':>5s} {'IS-Sh':>5s} "
|
||||
f"{'corrBK':>6s} {'mβ':>5s} {'DSR':>5s} {'marg':>9s} {'cut':>6s} earns")
|
||||
for r in summary:
|
||||
print(f"{r['name']:14s} {r['verdict']:28s} "
|
||||
f"{str(r['full']):>5s} {str(r['hold']):>5s} {str(r['is_sh']):>5s} "
|
||||
f"{str(r['book_corr']):>6s} {str(r['mkt_beta']):>5s} {str(r['dsr']):>5s} "
|
||||
f"{str(r['marginal']):>9s} {str(r['haircut']):>6s} {r['earns']}")
|
||||
print("\nMACRO (secondario): " + " | ".join(
|
||||
f"{m['hedge'].upper()} IS={m['insample_sharpe']} HOLD={m['holdout'].get('sharpe')} bkcorr={m['book_corr']}"
|
||||
for m in macro_rows))
|
||||
|
||||
winners = [r for r in summary if r["earns"]]
|
||||
leads = [r for r in summary if r["verdict"].startswith("LEAD")]
|
||||
standalone = [r for r in summary if r["verdict"].startswith("NEUTRAL")]
|
||||
print("\n" + "=" * 100)
|
||||
print("CONCLUSIONE — c'è uno stream scorrelato CON edge reale ED eseguibile a 2 gambe?")
|
||||
print("=" * 100)
|
||||
if winners:
|
||||
print("SI -> SLEEVE-CANDIDATE eseguibile a 2 gambe:", ", ".join(r["name"] for r in winners))
|
||||
else:
|
||||
print("NO sleeve pronto. L'ORTOGONALITÀ c'è (corr->BOOK ~0.02, beta-mercato ~0.01: il filone")
|
||||
print("relative-value ETH/BTC è scorrelato per costruzione, ed è ESEGUIBILE a $600 — haircut ~0,")
|
||||
print("fee-surviving a 0.30%RT/gamba). Ma l'EDGE non passa il deflated-Sharpe:")
|
||||
if leads:
|
||||
print(" LEAD (forward-monitor, ADDS + edge in-sample ma DSR<0.95):",
|
||||
", ".join(r["name"] for r in leads))
|
||||
if standalone:
|
||||
print(" NEUTRAL-standalone (edge reale, NON migliora il book):",
|
||||
", ".join(r["name"] for r in standalone))
|
||||
print("Dettaglio nei verdetti per-segnale sopra.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -22,6 +22,7 @@ from src.version import APP_VERSION
|
||||
PAPER = PROJECT_ROOT / "data" / "paper_portfolio" / "state.json"
|
||||
PREVDAY = PROJECT_ROOT / "data" / "paper_prevday" / "state.json"
|
||||
COMBO = PROJECT_ROOT / "data" / "paper_combo" / "state.json"
|
||||
STATARB = PROJECT_ROOT / "data" / "paper_statarb" / "state.json"
|
||||
_CACHE = {"t": 0.0, "data": None}
|
||||
_TTL = 120.0
|
||||
|
||||
@@ -53,6 +54,7 @@ def build():
|
||||
paper = json.loads(PAPER.read_text()) if PAPER.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
|
||||
statarb = json.loads(STATARB.read_text()) if STATARB.exists() else None
|
||||
try:
|
||||
gtaa_w = gtaa_weights() # pesi ETF correnti azionabili (cache eq_*)
|
||||
except Exception:
|
||||
@@ -75,7 +77,7 @@ def build():
|
||||
full=bt["full"], holdout=bt["holdout"], weights=bt["weights"],
|
||||
per_sleeve=bt["per_sleeve"], yearly=bt["yearly"],
|
||||
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,
|
||||
)
|
||||
_CACHE.update(t=time.time(), data=data)
|
||||
@@ -143,6 +145,22 @@ def html():
|
||||
f"forward da {pd.Timestamp(pv['start_ts'], unit='ms').date()}")
|
||||
else:
|
||||
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.) · "
|
||||
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> · "
|
||||
f"maxDD {sa['dd_modeled']*100:.1f}% / {sa['dd_real']*100:.1f}% · "
|
||||
f"fill-haircut {(rm-rr)*100:+.2f}pp<br>"
|
||||
f"posizione spread (long ETH / short BTC per unità): <b>{sa['pos_real']:+.3f}</b> · "
|
||||
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")
|
||||
if cb:
|
||||
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>
|
||||
<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>
|
||||
<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>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
@@ -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"]))
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user