Compare commits
62 Commits
745ba7d066
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b691f48f43 | |||
| 822aa1307e | |||
| cf7de40dc0 | |||
| eef200cf14 | |||
| 116abf5203 | |||
| 26f8d27a61 | |||
| 76120b59c2 | |||
| a74cc69583 | |||
| 73d74c5e53 | |||
| e6657fcb16 | |||
| 491411ac77 | |||
| ccf5e38101 | |||
| 567046953d | |||
| 31369b358c | |||
| 88f5f5a02a | |||
| ee82e0a056 | |||
| c52e0ab3f8 | |||
| cff5fa2bf5 | |||
| 02d875b07b | |||
| b4ec92734c | |||
| aad69f9790 | |||
| a158d0e2ae | |||
| e28176efef | |||
| 8d559c6f33 | |||
| 555977d987 | |||
| 8c3868cb31 | |||
| 9d586eeb58 | |||
| 69e3517f1b | |||
| cf72e395d3 | |||
| db738bce3b | |||
| 25a22fc7c1 | |||
| eeac97dde4 | |||
| c8a390d6b7 | |||
| 384b9cb0af | |||
| 160ad300be | |||
| 50e2adf837 | |||
| 7eb0f67956 | |||
| 8d1fe173f7 | |||
| de72e3ce1f | |||
| 8e46a62e67 | |||
| c7c07f4c35 | |||
| 2d8faf3896 | |||
| 64d98a070d | |||
| 237ca8da13 | |||
| 856a02fcc5 | |||
| 010d1f0733 | |||
| 1c15c3c1be | |||
| 3a3bdd5c7b | |||
| f983cc2447 | |||
| 389573e517 | |||
| 3b552a92da | |||
| 09c8bb7de8 | |||
| c4bc336a53 | |||
| 55c337144e | |||
| d916520f2c | |||
| d2d535cf6a | |||
| 67850b0dd8 | |||
| 437cf11199 | |||
| c9b89739c1 | |||
| 03267b8fc3 | |||
| 1c0b5f1869 | |||
| 61180637eb |
@@ -0,0 +1,7 @@
|
||||
# Credenziali IB Gateway PAPER per la ricerca dati (account paper, es. DUQ513966).
|
||||
# COPIA questo file in .env.ibgw (gitignored) e riempi i valori REALI.
|
||||
# cp .env.ibgw.example .env.ibgw && chmod 600 .env.ibgw && nano .env.ibgw
|
||||
# NON committare mai .env.ibgw. Sono credenziali del CONTO PAPER (nessun denaro reale),
|
||||
# l'API e' comunque READ_ONLY (solo dati storici, nessun ordine).
|
||||
TWS_USERID=il_tuo_username_paper
|
||||
TWS_PASSWORD=la_tua_password_paper
|
||||
@@ -6,6 +6,8 @@ build/
|
||||
.venv/
|
||||
.env
|
||||
!.env.example
|
||||
.env.ibgw
|
||||
!.env.ibgw.example
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
@@ -63,3 +65,5 @@ scripts/research/blind/leaderboard.json
|
||||
|
||||
# forward-monitor runtime state (regenerable, forward-only)
|
||||
data/paper_prevday/
|
||||
data/paper_combo/
|
||||
data/paper_statarb/
|
||||
|
||||
@@ -40,6 +40,14 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis
|
||||
Deploy/paper a **1d**. Diari `2026-06-19-tp01-verification.md` / `-tp01-lookahead-fix-lf.md`.
|
||||
Paper trader: `scripts/live/paper_trend.py` (1d). Test: `tests/test_trend_portfolio.py`.
|
||||
Ri-verifica: `scripts/analysis/{verify_tp01,stress_tp01,tp01_lowfreq}.py`.
|
||||
⚠️ **ANCHOR TIMING-LUCK (2026-07-02, confermato da scettico):** l'hold-out ~0.31 è calcolato
|
||||
sull'ancora daily 00:00 UTC, che è la **migliore delle 24 possibili** (mediana ancore 0.04, banda
|
||||
[−0.13,+0.30]; P~0.86 che una qualsiasi ancora mostri uno spike così per puro caso) → l'hold-out
|
||||
2025-26 NON risolve l'edge di ritorno di TP01; ciò che regge a OGNI ancora è il **taglio del DD**
|
||||
(7-10% vs ~60% B&H). FULL/plateau/deflated-Sharpe/gate INVARIATI (h=0 al 31° pctl su FULL).
|
||||
Regola: i futuri numeri hold-out di strategie a ribilanciamento ancorato si citano con la banda
|
||||
d'ancora. Diario `2026-07-02-timing-crt-wave.md`; script `scripts/research/r0702_tp01_offset.py`
|
||||
+ `r0702_skeptic_offset.py`.
|
||||
- **XS01 Cross-Sectional Momentum (Hyperliquid) — DIVERSIFICATORE che migliora il portafoglio** —
|
||||
`src/portfolio/sleeves.py:_xsec_returns`. Market-neutral su **19 alt liquidi major** Hyperliquid (1d,
|
||||
dal 2024): ogni 10g long i 5 più forti / short i 5 più deboli, vol-target 20%. **Scorrelato a TP01
|
||||
@@ -49,13 +57,55 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis
|
||||
regime compatto). Standalone FULL Sh **1.50** / HOLD 1.71 / DD 11%, plateau robusto (lookback, gate
|
||||
p15-35). **Caveat:** storia ~2.5 anni; STAT-MODE (book a 19 gambe non eseguibile a 2k, serve ~20k) →
|
||||
monitor forward. NB il gate concentra XS nei regimi dispersi (2025-26 = hold-out alta-dispersione).
|
||||
⚠️ **PHASE TIMING-LUCK (2026-07-02):** i numeri headline sono sulla fase 0 del ciclo H=10, che è
|
||||
al **15° pctl di DD** (10.8% vs ~15.5% fase tipica, 29% peggiore) e 85° di FULL fra le 10 fasi
|
||||
(HOLD solo 65°, non estremo); P(spike per caso)≈0.91-0.94. Lens onesta = **ensemble di fase:
|
||||
FULL 1.25 / HOLD 1.31 / DD 10.9%**; a fase mediana FULL 1.08/HOLD 1.10/DD 21%. La decisione di
|
||||
ammissione @15% regge (0 fasi negative, 8/10 FULL≥1.0), i numeri 1.50/1.71/11% no → citarli con
|
||||
banda di fase. Ora-del-giorno NON testabile (solo 1d HL). Script `scripts/research/r0702_anchor_xs01.py`;
|
||||
diario `2026-07-02-anchor-audit-xs01-skh01.md`.
|
||||
Ricerca `scripts/portfolio/{xsec_research,xsec_blend,xsec_dispgate}.py`. Diari `2026-06-19-hyperliquid-xsec`
|
||||
/ `-xsec-blend` / `-xsec-dispgate` / `-xsec-universe-expansion` / `-trend-multiasset`.
|
||||
- **PORTAFOGLIO ATTIVO = TP01 (55%) + XS01 (25%) + VRP01 (20%)** (`src/portfolio/sleeves.active_sleeves`):
|
||||
TP01+XS01 combinato **FULL Sharpe 1.55, HOLD-OUT 1.55, DD 4.4%**. Aggiunto **VRP01** (options
|
||||
short-vol, sotto): TP01+VRP01 da solo fa FULL Sh 1.30→1.44 / HOLD 0.31→0.40 a peso 20% (3-way da
|
||||
validare locale con dati HL). Report `scripts/portfolio/run_portfolio.py`. Sleeve a date d'inizio
|
||||
diverse → outer-join con pesi rinormalizzati (TP01 da solo 2019-20, VRP dal 2021, blend pieno dal 2024).
|
||||
- **PORTAFOGLIO ATTIVO = TP01 (33%) + XS01 (15%) + VRP01 (12%) + SKH01 (20%) + GTAA01 (20%)**
|
||||
(`src/portfolio/sleeves.active_sleeves`): TP01+XS01 combinato **FULL Sharpe 1.55, HOLD-OUT 1.55,
|
||||
DD 4.4%**. Aggiunto **VRP01** (options short-vol, sotto): TP01+VRP01 da solo fa FULL Sh 1.30→1.44 /
|
||||
HOLD 0.31→0.40 a peso 20%. **Aggiunto SKH01-V2-DD @25% effettivo (2026-06-23, sotto)**: 4-sleeve
|
||||
**FULL Sharpe 1.68→2.13, HOLD-OUT 1.63→2.30, DD full 14.3%→7.8%** (Skyhook quasi-ortogonale,
|
||||
corr ~0.09). **Aggiunto GTAA01 @20% effettivo (2026-07-01, i 4 preesistenti scalati ×0.80):**
|
||||
trend difensivo equity 6-ETF su IB (`src/portfolio/gtaa.py`, ~30 anni storia, validato 2026-06-22
|
||||
su OOS equity 2015+ INDIPENDENTE dall'hold-out crypto, corr al book ~+0.10) → 5-sleeve
|
||||
**FULL Sharpe 2.12→2.24, HOLD-OUT 2.21→2.46, DD full 7.8%→6.2%** (costo dichiarato: CAGR full
|
||||
23.3→18.8%; 2022 unico anno con dSh−). Uplift positivo in-sample E su tutte le finestre disgiunte
|
||||
(vs EW-STR refutato lo stesso giorno). Convenzioni: weekend/festivi equity = 0.0 (capitale IB
|
||||
fermo, non riciclato); attivazione nel book all'era crypto 2019-03; **il book live Deribit
|
||||
(`deribit_book_sleeves` TP01+SKH01 75/25) NON lo include** (GTAA in paper_combo dal 2026-06-23).
|
||||
Test `tests/test_gtaa_sleeve.py`; diario `2026-07-01-strategy-wave-6threads.md` (addendum GTAA).
|
||||
Report `scripts/portfolio/run_portfolio.py`. Sleeve a date d'inizio diverse → outer-join con pesi
|
||||
rinormalizzati (TP01/SKH01/GTAA dal 2019*, VRP dal 2021, XS dal 2024; *GTAA troncato all'era book).
|
||||
⚠️ **ANCHOR-LUCK del book (2026-07-02):** l'HOLD 2.46 è calcolato con TUTTI gli sleeve ancorati
|
||||
alla loro ancora canonica, che per TP01/XS01/SKH01 è al top della rispettiva banda (eredità di
|
||||
fortuna ~+0.10/+0.17/+0.5 HOLD). **Stima de-luckata onesta: HOLD ~1.9-2.1, FULL ~2.0-2.2, DD ~6%
|
||||
invariato** — il book resta positivo e diversificato a ogni ancora testata, ma 2.46 è un massimo
|
||||
di configurazioni d'ancora, non la stima centrale. Diario `2026-07-02-anchor-audit-xs01-skh01.md`.
|
||||
- **SKH01-V2-DD "Skyhook" — DIVERSIFICATORE quasi-ortogonale (research)** — `src/strategies/skyhook.SKH01_V2_DD`,
|
||||
sleeve `src/portfolio/sleeves._skyhook_returns`. Sistema dual-TF (segnale 690m / exec 230m) regime
|
||||
(BuzVola/BuzVolume tipo-Chande) AND pattern (Donchian breakout), NON trend-follower, L/S. Vincitrice
|
||||
di 2 onde multi-agente (la 2ª = DD-reduction): exit a **percentuale fissa ASIMMETRICA** (long sl4%/tp10%,
|
||||
short sl2%/tp8% più stretto) → standalone **maxDD BTC 21% / ETH 27% (<30%)**, minFull +0.99, minHold
|
||||
+1.26, causale (0/400), fee-surviving 0.40%RT. Marginal vs TP01 **ADDS** (corr 0.09, has_insample_edge,
|
||||
robust_oos multicut 7/7, is_hedge=False); blend 0.75·TP01+0.25·SKH **hold-out 0.31→1.17**. Verificato
|
||||
leak-free + 2 scettici. **CAVEAT:** equity daily-step (Sharpe lens), ETH DD margine sottile, book 230m
|
||||
(costi ribilanciamento da verificare a deploy) → research win, forward-monitor. Diario `2026-06-23-skyhook.md`.
|
||||
⚠️ **GRID TIMING-LUCK (2026-07-02, più forte di TP01):** i numeri headline sono sull'offset 0 della
|
||||
griglia 230m/690m, al **93-98° pctl dei 23 offset a priori** — minHold +1.26, blend 1.17 e book
|
||||
HOLD 2.44 sono il MASSIMO dei 23 (mediane: minHold +0.39, blend 0.72, book 1.96); spike, non
|
||||
plateau (±30m crolla); P(spike)≈0.70. **Il gate DD<30% (criterio di selezione di V2-DD) fallisce
|
||||
in 15/23 offset** (mediana ETH 29.2%). Regge de-luckato: uplift blend positivo a TUTTE le 23 fasi
|
||||
(min +0.18, med +0.42) + corr 0.05-0.11 → ADDS sopravvive ridimensionato. **LIVE (SKH=25% del book
|
||||
Deribit):** path reale cron orario + exit software → book 50/50 FULL 1.46→1.19 / HOLD 1.64→1.15 /
|
||||
DD 18→25%; nei crash gap-through-stop reale (sl2% modellato → −11/−23% realizzato). Pesi/book
|
||||
INVARIATI (ogni cambio passa weights_tilt_null); follow-up: cadenza 230m, peso live da rivedere.
|
||||
Script `scripts/research/r0702_anchor_skh01.py`; diario `2026-07-02-anchor-audit-xs01-skh01.md`.
|
||||
- **VRP01 Options Short-Vol — DIVERSIFICATORE da FinanceOld/OptionsAgent** — `src/portfolio/sleeves._vrp_combo_returns`.
|
||||
Put credit spread settimanale (vendi put -0.28, compra put -0.10) gated su IV-rank. Idee portate da
|
||||
`../FinanceOld/OptionsAgent` (Bear Call Spread + gate d'ingresso). Migliora il lead VRP nudo
|
||||
@@ -65,6 +115,22 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis
|
||||
crash incluso). Scorrelato a TP01 (~+0.01-0.07). **CAVEAT:** premio MODELLATO su DVOL ATM (skew non
|
||||
esplicito), book a 1d, f di stress reale non catturato → LEAD robusto, non deploy pieno. Ricerca
|
||||
`scripts/research/options_vrp_v2.py` (vs baseline `options_vrp_lab.py`). Test `tests/test_vrp_sleeve.py`.
|
||||
⚠️ **ANCHOR-AUDIT CHIUSO + ondata "migliora e proteggi" (2026-07-03, 7 filoni + 2 lenti + scettico):
|
||||
VRP01 NON è migliorabile e la protezione DD si compra SOLO con la size.** (a) **Anchor-luck (ciclo
|
||||
settimanale, 7 fasi): PRIMO sleeve SENZA firma di luck** — la fase canonica è la PEGGIORE delle 7 su
|
||||
FULL (1.09 = 7° pctl) e su DD (11.8% = 93° pctl), mediana su HOLD (0.59); spike bootstrap NEGATIVO →
|
||||
i numeri di ammissione FULL 1.10/HOLD 0.60/DD 12% sono CONSERVATIVI, non gonfiati. Da ora si citano
|
||||
con banda: ShFULL [1.09,1.83], ShHOLD [−0.03,1.11], DD [5.7,11.8%]; edge OOS f-dipendente (f=0.8 →
|
||||
HOLD~0). **Con questo l'audit anchor è completo su 4/4 sleeve ancorati.** (b) Griglia 288 strutture:
|
||||
nessuna batte VRP01 (DSR 0.000; metà griglia = 3ª occorrenza "0-perdite/Sharpe implausibile" dopo
|
||||
CC01/ALB-A → gate `implausible_sharpe` alzato di priorità). (c) 4 overlay DD (exit-spike/SL-MTM/
|
||||
ala-coda/cooldown): 4/4 REFUTED dal null de-levering — la protezione crash vive già nel gate
|
||||
d'ingresso IV-rank. (d) Gate nuovi: 4° fallimento su 4 (l'alpha è il binario IV-rank>0.30). (e)
|
||||
Sizing: 12% deploy ≈ 0.27 Kelly onesto (anti-rovina); ⚠️ NON confondere col 12% di PESO del book
|
||||
(~0.014 Kelly, fattore 19x). (f) Gate term-structure VIX/VXV su SPX (ΔSh +0.90, DSR 0.992) =
|
||||
**confound di modello al 100%** (la var del gate coincide con l'errore BS-flat vs term-structure) →
|
||||
nuova regola: riprezzare term-structure-consistent prima di credere a un gate vol su strutture
|
||||
BS-flat. Book/pesi INVARIATI. Diario `2026-07-03-vrp-improve-dd.md`; script `scripts/research/r0703_vrpimp_*.py` (7 file).
|
||||
Diario `2026-06-20-financeold-analysis-vrp-v2.md`.
|
||||
- **Universo Hyperliquid: ESPANDERLO NON aiuta XS01** (provato): 52-asset / top-liquidità dinamico /
|
||||
trend-multi-asset → tutti peggiori (small-cap/memecoin diluiscono il momentum relativo; il trend
|
||||
@@ -84,6 +150,163 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis
|
||||
regime-luck), calendar/seasonality (buy&hold travestito), volume/vol e momentum-reversal (negativi).
|
||||
- **MORTO/confermato artefatto:** mean-reversion / fade (negativo anche a fee zero — la vecchia
|
||||
libreria +201%/+1238% era contaminazione); trend 5m/15m (fee).
|
||||
- **GAMMA SCALPING (long-vol) "scalping BTC/ETH con copertura in opzioni" — SCARTATO (2026-06-26)** —
|
||||
`scripts/research/options_gamma_scalp.py`, test `tests/test_gamma_scalp.py`. È lo **specchio
|
||||
esatto del VRP01** (long straddle ATM + delta-hedge: incassa **RV−IV**, dove VRP01 incassa IV−RV).
|
||||
Perde **ogni anno, ogni variante, ogni frequenza** (Sharpe −3 a −6; nudo/cheap-gated/rich-skip;
|
||||
rehedge 1d e 1h). Diagnostica strutturale: a 1d IV≈o>RV (BTC +4.9pp) → paghi il VRP; a 1h RV>IV
|
||||
gross ma (a) gonfiata da microstruttura, (b) il rehedge orario paga **24× la fee di hedge** →
|
||||
variante *peggiore* (−6). Marginale vs TP01 = **DILUTES**, non è nemmeno hedge (perde sia TP01-up
|
||||
sia TP01-down). Muro eseguibilità: opzione BTC min $5.968 ≫ $600. Schiacciato tra due muri:
|
||||
rehedge lento = premio, veloce = fee → **nessuna frequenza vince.** Regola gemella del VRP:
|
||||
*niente long-vol scalp da modello in deploy*. Il VRP01 (lato short, gated) resta l'unico edge
|
||||
opzioni — funziona perché sta sul lato *giusto* dello stesso premio. Diario `2026-06-26-gamma-scalp-options.md`.
|
||||
- **CASH-AND-CARRY (basis trade) "CC01" — premio REALE, Sharpe ARTEFATTO, NON deployabile (2026-06-26)** —
|
||||
`scripts/research/cash_carry_hl.py`, test `tests/test_cash_carry.py`. Diverso da FC01 (funding
|
||||
cross-sectional, già scartato): qui delta-neutral long-spot/short-perp sullo *stesso* asset →
|
||||
ritorno ≈ **+funding** (zero esposizione prezzo). Il premio di funding è **reale** (~+8-14%/anno
|
||||
aggregato, positivo ogni anno in-sample, ortogonale a TP01 corr ~0.05). MA lo **Sharpe modellato
|
||||
11-13 (DD 0.3%) è un ARTEFATTO**: il modello cattura solo il cashflow liscio del funding e i
|
||||
rischi di coda sono **strutturalmente fuori dal dataset** — (1) storico funding dal 2023-05 →
|
||||
**manca il 2022** (deleveraging, funding −, basis blow-out); (2) **procyclico** (carry +23% toro
|
||||
2024 → +1.7% bear 2026, si comprime quando servirebbe); (3) liquidazione short/slippage non
|
||||
modellati. Il mark-to-market della base (`premium` col → r=funding−Δpremium) sgonfia lo Sharpe
|
||||
solo 13→11 → il basis-from-data NON è il rischio vero. Sharpe reale di un basis-trade ~1-3 con
|
||||
code brusche. **NON eseguibile a $600** (spot+perp = 4-38 gambe, funding HL non Deribit) → STAT-MODE.
|
||||
**LEAD da rivedere a scala (~20k+ e venue con funding eseguibile), non uno sleeve.** Sottoprodotto:
|
||||
CC01 passa OGNI gate del marginal scorer → **punto cieco** (manca un gate "Sharpe implausibile →
|
||||
rischio nascosto"; prossima indurita raccomandata). Diario `2026-06-26-cash-carry-hl.md`.
|
||||
- **TP01 × DVOL vol-targeting — NON migliora (2026-06-26)** — `scripts/research/tp01_dvol_overlay.py`,
|
||||
test `tests/test_tp01_dvol_overlay.py`. Angolo ESEGUIBILE (tocca il book live, non STAT-MODE):
|
||||
usare il DVOL (vol implicita forward-looking) come denominatore del vol-target di TP01 invece
|
||||
della vol realizzata. Su finestra comune 2021-2026: le varianti DVOL abbassano il DD (12.3%→9.2%)
|
||||
ma anche Sharpe FULL (0.75→0.70) e CAGR (8%→6%). **Controllo decisivo:** il realized a target_vol
|
||||
RIDOTTO (15%) eguaglia quel DD (9.4%) a Sharpe **più alto** (0.75) → il taglio di DD del DVOL è
|
||||
solo **de-levering**, replicabile meglio con un semplice target_vol più basso. L'unico residuo
|
||||
(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`.
|
||||
- **Filoni 2026-06-29 (1ª ondata) — tutti scartati/forward.** (A) **DVOL-DIREZIONALE** standalone BTC/ETH
|
||||
(buy-the-fear / IV-RV come segnale di livello): l'unico edge è un **HEDGE** (is_hedge=True, paga solo
|
||||
quando TP01 è debole), non alpha → earns_slot=False, forward-monitor come DD-dampener (diario
|
||||
`2026-06-29-dvol-directional.md`). (B) **INTRADAY ERM** (efficiency-ratio regime momentum sub-daily):
|
||||
**falso positivo da selezione-sull'hold-out** → SCARTATO (vedi gate SELECTION-ON-HOLDOUT;
|
||||
`2026-06-29-intraday-regime.md`). (C) **XSEC-V2 NON-MOMENTUM** su HL (reversal/idio-reversal/low-vol/BAB):
|
||||
solo LOWVOL 19-major regge standalone (FULL/HOLD 1.07) ma deflated-Sharpe 0.13 + storia 2.5a → **DEBOLE/
|
||||
forward STAT-MODE** (`2026-06-29-xsec-v2-nonmom.md`). (D) **MACRO regime-gate** (equity/credito/oro/tassi →
|
||||
de-risk crypto): **RIDONDANTE col trend** (corr→TP01 0.989; il gate lavora solo nel 2-3% dei giorni, TP01
|
||||
già flat nei crash) → SCARTATO (`2026-06-29-macro-regime-gate.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`.
|
||||
- **Ondata 2026-07-01 (6 filoni multi-agente + scettico) — 0 edge nuovi dai filoni, 1 gate nuovo,
|
||||
e 1 sleeve promosso DALL'ARCHIVIO (GTAA01, sotto nel bullet portafoglio).** Filoni su angoli non coperti dalle ondate precedenti: (1) **funding time-series**
|
||||
BTC/ETH (posizionamento) = SCARTATO — FOLLOW è trend-beta ritardato, FADE shorta il toro, il gate è
|
||||
TP01 travestito (DSR 0.215); **il filone funding è chiuso su 3 lati** (FC01 carry, price-clock, TS).
|
||||
(2) **breadth/internals alt** (51 HL) = SCARTATO ma unico NON-ridondante col trend (corr→TP01 0.40);
|
||||
muore su jackknife (uplift su 1 mese) + DSR 0.433 con ~8 mesi IS → **rivisitabile tra 1-2 anni di
|
||||
storia HL nativa**. (3) **residual momentum XS** (β-hedged, 19 major) = REDUNDANT — cross-section la
|
||||
residualizzazione è un no-op (lo z-score di XS01 rimuove già il mercato); l'edge resta solo nella
|
||||
coppia ETH/BTC (STATARB-RESID). (4) **ri-ottimizzazione pesi + guardia-DD**: il candidato EW-STR
|
||||
(TP30/XS25/VRP15/SKH30, HOLD 2.21→2.35) **refutato dallo scettico come selezione-sull'hold-out di 2°
|
||||
ORDINE** — SKH01/XS01 furono ammessi/affinati *perché* forti su quell'hold-out; pre-2025 ΔSh −0.05,
|
||||
finestre disgiunte −0.12/+0.06/+0.14, percentile 94-100° fra 500 tilt casuali ≈ firma best-of-15.
|
||||
Guardia-DD 5%/0.5: inerte OOS (la diversificazione fa già il lavoro; solo circuit-breaker d'emergenza).
|
||||
(5) **affinamento VRP01** = NON MIGLIORA (l'alpha è tutto nel gate binario IV-rank; gate TP01 =
|
||||
trappola in-sample; **3° fallimento → filone "VRP dentro il modello" esaurito** fino a f di stress
|
||||
reale). (6) **stagionalità cross-sectional HL** = morta allo step statistico (null permutato).
|
||||
**GATE nuovo codificato: `weights_tilt_null`** in `src/portfolio/portfolio.py` (+ `combine_outer`
|
||||
riusabile): ogni proposta di CAMBIO PESI si giudica vs il null dei tilt casuali cap-respecting —
|
||||
gate_pass solo se delta_insample≥0 E percentile < firma best-of-k (necessario, non sufficiente);
|
||||
test `tests/test_weights_tilt_null.py`. ⚠️ Lezione tecnica: `DatetimeIndex.view("int64")` su indici
|
||||
tz-aware non-ns (pandas 2.x) → scala sbagliata → `merge_asof` broadcasta = **look-ahead che
|
||||
`causality_ok` non vede**; usare epoca esplicita in ms (altlib verificato pulito). Diario di sintesi
|
||||
`2026-07-01-strategy-wave-6threads.md` + 6 diari di filone; script `scripts/research/r0701_*.py`.
|
||||
- **Ondata 2026-07-02 (TIMING + CRT, 8 filoni multi-agente + scettico) — 0 nuovi sleeve, 1 finding
|
||||
strutturale (anchor timing-luck di TP01, vedi ⚠️ nel bullet TP01).** Goal: "strategie con timing
|
||||
differenti". (1) **Event-clock bars** (volume/vol/range da 5m, TSMOM/Donchian/EWMA in tempo-informazione):
|
||||
batte il wall-clock a pari segnale/frequenza solo in 4/45 coppie; cella best IS 1.45 → HOLD −0.46,
|
||||
NEUTRAL (corr 0.74 = trend travestito) → SCARTATO: il clock non è dove vive l'edge. (2) **Calendario
|
||||
scadenze Deribit** (expiry weekly/monthly/quarterly ven 08:00 UTC): 0/24 celle a Bonferroni; il drift
|
||||
post-expiry monthly fallisce placebo-weekday e permutation e si INVERTE sul quarterly (dove l'OI massimo
|
||||
dovrebbe amplificarlo); unico pattern robusto = gio→ven negativo, ma è day-of-week (SEA morta) a Sharpe
|
||||
netto ~0 → SCARTATO. (3) **Anchor timing-luck TP01 + tranching**: finding confermato (dettagli nel
|
||||
bullet TP01); tranching K=2/4 = sola riduzione della varianza della STIMA (ΔSharpe n.s., ΔDD ~0.5pt),
|
||||
NO deploy a $600 (il min-order lo degenera in K=1; serve feed intraday fuori path certificato) —
|
||||
rivalutare a ≥5-10k. **Audit d'ancora ESEGUITO su XS01 e SKH01 (stesso giorno): il finding si
|
||||
replica su 3/3 sleeve ancorati** — vedi ⚠️ nei rispettivi bullet e la stima de-luckata del book
|
||||
nel bullet portafoglio; diario `2026-07-02-anchor-audit-xs01-skh01.md`.
|
||||
(4) **Clock lenti (2-7g) + bande isteresi**: fee drag di TP01 = ~0.4%/anno = tetto di ogni risparmio;
|
||||
il lag costa più del risparmio (HOLD ensemble 0.34→0.11 da N=2 a 7); a $600 **il min-order $5 è GIÀ la
|
||||
banda ottimale** (ordini −74% a costo ~0) → nessun cambio al book. (5) **Velocità trend
|
||||
regime-condizionata** (pesi tra orizzonti 30/90/180g vs percentile vol RV/DVOL): pctl 0.71 vs null
|
||||
pesi-statici-casuali = tilt-30d statico travestito (trappola EW-STR); pesi canonici 1/3 confermati →
|
||||
SCARTATO. (6-8) **CRT "Candle Range Theory"** (sweep-and-reclaim 3 candele, mai coperto da MRV/MIC):
|
||||
base 864 trial DSR 0.000 + anchor-flip + short "smart-money" negativo perfino in-sample; multi-TF
|
||||
(4h→15m, 1h→5m, ~10k trade) expectancy negativa ovunque anche a fee zero, e **il ritest è informazione
|
||||
negativa** (pattern con-ritest −40bps vs senza +52bps: aspettarlo seleziona i peggiori); contesto
|
||||
(FVG/equal-highs/sessioni, 22 trial) non salva il fade, cella "Asia" = artefatto anchor-flip →
|
||||
SCARTATO 3/3. Sottoprodotto: sugli stessi livelli prior-day **FOLLOW > FADE ogni anno 2019-26**
|
||||
(conferma indipendente del lead prevday in forward-monitor). Lezione: il timing-luck d'ancora è
|
||||
multiple-testing che il deflated-Sharpe NON conta (candidato gate futuro `anchor_luck_band`).
|
||||
Diario `2026-07-02-timing-crt-wave.md`; script `scripts/research/r0702_*.py` (9 file).
|
||||
- **Ondata 2026-07-02-bis ("video claims": Elliott 3 filoni + Albimarini 2 + capital scaling) — 0 nuovi
|
||||
sleeve, 0 forward-monitor, 1 azione config pendente sul deposito.** Meccanizzazione onesta di claim da
|
||||
video didattici: (1) **Elliott range-cycle** (onda1 compressa→onda3 ampia): rumore, 0/24 celle a
|
||||
Bonferroni, nessuna cella weekly regge a tutte le 7 ancore → SCARTATO. (2) **Confluenza Fibonacci**:
|
||||
vs null ingenui sembra buona (pctl 0.82-1.00), vs null **location-matched** (Fib±jitter: "0.618 vs
|
||||
0.58?") crolla a 0.39-0.68 = l'apparente edge è la POSIZIONE dei livelli, non i numeri; confluenza
|
||||
FAIL 4/4 → SCARTATO (il null location-matched è IL test per ogni claim su livelli "speciali").
|
||||
(3) **Tecnica del canale Elliott**: Donchian travestito — non batte il Donchian a pari geometria
|
||||
(corr 0.43-0.53), DSR 0.685, cella in-sample collassa in hold-out (1.40→−0.87), target 1.618 = caso
|
||||
(5/6 celle), anchor-luck di nuovo (4h banda hold [0.35,1.54], 00:00 la migliore) → SCARTATO.
|
||||
(4) **Albimarini double-diagonal** (short T + long T+1, deep OTM, via motore DVOL di VRP01): il
|
||||
condor stessa-scadenza la batte a ogni f (la long T+1 = assicurazione di coda +12/33bps nel tail,
|
||||
~1bps costo medio, non dominanza); senza gate IV-rank TUTTE le strutture perdono (3ª conferma:
|
||||
l'alpha del VRP è il gate); su Deribit fee-negativa a QUALSIASI size (fee 8 gambe = 194-221% del
|
||||
theta); celle deep-OTM 0-perdite/142 trade = 2° caso "Sharpe implausibile" dopo CC01 → gate
|
||||
`implausible_sharpe` raccomandato con più forza. VRP01 resta superiore su tutta la banda skew →
|
||||
nessun LEAD. (5) **Audit claims** (28 trade, 82% win, PF 5.16, "420%/anno"): consistente con ZERO
|
||||
skill (P=20-45%; il 78.6% delle finestre 6-mesi 1996-2026 lo produce); replay con code reali =
|
||||
rovina 1998/2002/2020 col sizing dichiarato; la diagonale lascia passare il 12-40% della perdita
|
||||
naked. (6) **Capital scaling 600→2-5k** (`r0702_capital_scaling.py`): l'unico vincolo binding è
|
||||
`max_notional_per_asset_usd=300` (a 5k il book live girerebbe al 49% del target) → **al deposito
|
||||
alzare il cap a equity/2** ($1000/$1750/$2500 a 2k/3.5k/5k); min_order $5 da LASCIARE; tranching
|
||||
K=2 non cablare (blocco feed intraday); opzioni ETH eseguibili da ~2.6k ma la regola no-short-vol-
|
||||
da-modello non decade col capitale; XS01 ~20k confermata, CC01 fuori per struttura. Aspettativa
|
||||
onesta col CAGR de-luckato (10-15%): 2k ≈ €0.6-0.8/g, 5k ≈ €1.4-2/g (€50/g resta ≈130k).
|
||||
⚠️ Lezione pandas: `resample("7D", origin=...)` IGNORA origin (pandas 2.x, solo RuntimeWarning) →
|
||||
bande d'ancora weekly finte; usare `"168h"`. Diario `2026-07-02-elliott-albimarini-capital.md`;
|
||||
script `scripts/research/r0702_{ell_*,alb_*,capital_scaling}.py` (6 file).
|
||||
- **Video-claim "CRT top-down multi-TF, 74% win rate" — SCARTATO (2026-07-07): il 74% è un KNOB, non
|
||||
un edge.** `scripts/research/r0707_crt_topdown.py`. Metodo ICT/SMC top-down (H1 setup CRT → M15
|
||||
struttura/FVG → M5 conferma → M1 entry) con uscita parziale-70/80%-a-1.5R + break-even + runner verso
|
||||
prev-daily high/low. Il setup H1 CRT è **già triplo-refutato** (onda 2026-07-02: base DSR 0.000, MTF
|
||||
expectancy neg. ovunque "il ritest è informazione negativa", contesto/FVG peggiora, fade<follow) →
|
||||
qui testato l'UNICO angolo nuovo: la **gestione d'uscita** e il **claim 74%**. Su BTC/ETH certificati
|
||||
(H1→5m/15m; M1 non nel feed): **WR reale 30–37%** a RR 1.5–2 (SOTTO il null gambler's-ruin 40% =
|
||||
P(+1.5R prima di −1R)=1/(1+1.5) → il ritest tocca il target MENO di una moneta), **expectancy R
|
||||
negativa a ogni schema/fee/finestra/asset** (−1.2…−3.3R netto). **Il 74% si fabbrica avvicinando il
|
||||
target**: sweep rr1 0.5→2.0 mostra WR salire (51→32%) con expectancy R INVARIANTE (WR alto = target
|
||||
vicino, non direzione). DSR 0.000 (48 trial); a **fee 0** expR −0.10/Sh −0.63 → **non è morte-per-fee,
|
||||
l'edge lordo non c'è** (residuo = beta di trend dei time-exit); parziale+BE+runner = 3-4 ordini/trade,
|
||||
alcuni sub-min-order a $600. **Regola candidata:** il win-rate di uno schema parziale+BE non è merito
|
||||
(≈1/(1+rr1)); convertire ogni claim "WR X%" in **expectancy R netto fee** prima di crederci (helper
|
||||
`winrate_is_a_knob()`). Diario `2026-07-07-crt-topdown-74winrate.md`. Book/pesi INVARIATI.
|
||||
- **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.**
|
||||
@@ -130,6 +353,17 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis
|
||||
migliaia di micro-trade sub-dollaro (tipici di un overlay vol-target) è **finzione**. Salta i
|
||||
sub-min_order e riporta lo **Sharpe haircut** reale vs modellato. **Vale per OGNI sleeve a questo
|
||||
capitale, TP01 incluso** — lo Sharpe netto onesto a $600 è quello small-cap, non quello modellato.
|
||||
- **SELECTION-ON-HOLDOUT gate (codificato 2026-06-29, filone B intraday ERM)** — terzo gate in
|
||||
`altlib.py`, test `tests/test_harness_realism.py`. Il lead ERM faceva `earns_slot=True` MA lo script
|
||||
di scoperta sceglieva la cella per **`min_hold` massimo** su 60+ celle = **selezione-sull'hold-out**:
|
||||
scegliendola in-sample-only ne esce un'altra (trend-beta corr→TP01 0.53, NEUTRAL) e il deflated-Sharpe
|
||||
crolla (DSR 0.0-0.24 su 122 trial). `study_marginal` da solo non lo vede (giudica UNO stream, non *come*
|
||||
è scelto). Tre funzioni: **`deflated_sharpe()`** (Bailey & Lopez de Prado, PASS ≥0.95), **`select_cell_insample()`**
|
||||
(cella scelta col solo Sharpe pre-HOLDOUT), e il gate combinato **`study_family_honest(name, factory, grid, tfs)`**
|
||||
→ `earns_slot_honest = earns_slot[cella in-sample] AND deflated-Sharpe≥0.95`. **Regola: una strategia
|
||||
direzionale grid-searched si giudica con `study_family_honest`, non chiamando `study_marginal` sulla
|
||||
cella a max hold-out.** Chiude il punto cieco gemello di CC01 ("Sharpe implausibile"). Diario
|
||||
`2026-06-29-intraday-regime.md` (analisi `scripts/research/intraday_regime_analysis.py`).
|
||||
- **Onestà sul target €50/giorno:** NON raggiungibile su 2000 in 1-2 anni (servono ~130k di
|
||||
capitale o un DD da rovina). La leva non è la scorciatoia; la via è target-vol + capitale +
|
||||
tempo. La strategia che *guadagna* esiste, ma a ~+€1.5/giorno su 2000.
|
||||
@@ -158,7 +392,7 @@ src/strategies/indicators.py → indicatori condivisi (ema, atr, keltner, ...)
|
||||
src/strategies/trend_portfolio.py → TP01: strategia DIFENSIVA robusta (PORT LF1d, >=12h), causale
|
||||
src/portfolio/ → PORTAFOGLIO DI STRATEGIE estensibile (Sleeve + StrategyPortfolio)
|
||||
portfolio.py → combina N sleeve per peso su griglia giornaliera; metriche FULL/hold-out/anno
|
||||
sleeves.py → REGISTRY sleeve attivi: TP01 (55%) + XS01 (25%) + VRP01 (20%). Aggiungere = una riga
|
||||
sleeves.py → REGISTRY sleeve attivi: TP01 33 / XS01 15 / VRP01 12 / SKH01 20 / GTAA01 20. Aggiungere = una riga
|
||||
src/fractal/ → indicatori frattali (patterns.py, indicators.py, similarity.py)
|
||||
src/backtest/engine.py → engine di backtesting riusabile
|
||||
src/backtest/harness.py → harness ONESTO (load BTC/ETH, backtest_signals no-leakage, OOS)
|
||||
|
||||
+3
-1
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"_nota": "Config esecuzione LIVE di TP01. execution_enabled=true + --execute -> ordini REALI. ARMATO 2026-06-20.",
|
||||
"_nota": "Config esecuzione LIVE del BOOK DERIBIT (TP01+SKH01 nettati in software). execution_enabled=true + --execute -> ordini REALI. ARMATO 2026-06-23: esecutore scripts/live/book_execute.py via cron ORARIO scripts/cron_book.sh (SKH01 e' a 230m). disaster-SL on-book -30% sulla posizione netta. Tutto flat all'arming -> nessun ordine finche' un segnale non arma.",
|
||||
"_nota_cap": "Cap notional per-asset DINAMICO (frontiera 2026-07-03): con max_notional_per_asset_frac=0.5 il cap = equity/2, cosi' cresce col capitale e un deposito non resta strozzato. A ~$600 equity/2=~$300 -> INERTE (identico al vecchio cap fisso). Il cap dinamico si usa solo con equity reale fidata; su fallback/offline si ripiega su max_notional_per_asset_usd (protezione downside). Per tornare al cap fisso: rimuovere max_notional_per_asset_frac.",
|
||||
"execution_enabled": true,
|
||||
"max_notional_per_asset_usd": 300,
|
||||
"max_notional_per_asset_frac": 0.5,
|
||||
"min_order_usd": 5,
|
||||
"disaster_sl_pct": 0.30
|
||||
}
|
||||
|
||||
@@ -13,3 +13,20 @@ services:
|
||||
# token mainnet (sola lettura) per lo "Shadow live": conto/posizioni reali sulla dashboard.
|
||||
# Montato a runtime (NON nell'immagine: .env.mainnet e' dockerignored). Solo letture, nessun ordine.
|
||||
- ./.env.mainnet:/app/.env.mainnet:ro
|
||||
|
||||
# IB Gateway (PAPER) per la RICERCA DATI Interactive Brokers — replica il setup provato di BuzWay
|
||||
# (scout). IBC fa login automatico headless; nessuna GUI desktop. API READ-ONLY (solo dati storici,
|
||||
# MAI ordini). Bind SOLO su 127.0.0.1 -> non esposto in rete. Credenziali in .env.ibgw (gitignored).
|
||||
# host 4002 -> container 4004 (socat paper), esattamente come nel connect("127.0.0.1", 4002).
|
||||
ib-gateway:
|
||||
image: ghcr.io/gnzsnz/ib-gateway:stable
|
||||
container_name: pythagoras-ibgw
|
||||
restart: unless-stopped
|
||||
env_file: .env.ibgw
|
||||
environment:
|
||||
TRADING_MODE: paper
|
||||
READ_ONLY_API: "yes" # SOLO dati: nessun ordine possibile via API
|
||||
TWOFA_TIMEOUT_ACTION: restart
|
||||
TIME_ZONE: Europe/Rome
|
||||
ports:
|
||||
- "127.0.0.1:4002:4004" # gateway paper (socat) raggiungibile solo da localhost dell'host
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# 2026-06-22 — Sweep 65-agenti: crypto -> mercati IB (mercati × timing × anni)
|
||||
|
||||
## Obiettivo (goal utente)
|
||||
Usare >=50 agenti per prendere l'anticipazione crypto->equity e trovare la MIGLIORE soluzione,
|
||||
provando diversi mercati e timing, su piu' anni.
|
||||
|
||||
## Setup
|
||||
- **Dati**: universo IB esteso a **26 ETF certificati** (azioni US/settori/intl/bond/credito/oro/
|
||||
commodity/REIT), cache su disco (`fetch_ib_equities.py` + BROAD2). Crypto BTC/ETH 1h (Deribit).
|
||||
- **Harness onesto** (`crypto_lead_harness.py`): per ogni sessione equity, lead = crypto nella
|
||||
finestra equity-CHIUSO [P 21:00 -> D 13:00 UTC] (overnight; il weekend e' il caso lungo). Predice
|
||||
gap/intraday/full. Metriche: corr, **t incrementale vs sessione equity precedente**, Sharpe
|
||||
eseguibile (sign(lead)*predict, net costi) FULL/IS/OOS, **hit per-anno**.
|
||||
- **Workflow** (`wf_crypto_lead.js`): grid 416 config (2 lead × 26 mercati × 2 giorni × 2 predict ×
|
||||
2 finestre). **52 agenti sweep** -> **12 agenti verifica avversariale** (stress 10bps + OOS 2024+ +
|
||||
multi-anno) -> **1 sintesi**. Totale **65 agenti**, 1.7M token.
|
||||
|
||||
## Risultato
|
||||
|
||||
### Fenomeno fortissimo: crypto overnight -> GAP di apertura equity
|
||||
Cluster coerente in cima, TUTTI predict=gap/overnight, su ogni target risk-on:
|
||||
| lead->target | t-incr | Sh OOS@4bps | @10bps | OOS-recente | anni+ |
|
||||
|---|---|---|---|---|---|
|
||||
| ETH->IWM gap | 17.1 | 2.49 | 1.96 | 2.41 | 7/8 |
|
||||
| ETH->QQQ gap | 17.9 | 2.36 | 1.83 | 2.31 | 7/8 |
|
||||
| ETH->XLK gap | 17.4 | 2.40 | 1.93 | 2.30 | 7/8 |
|
||||
| **BTC->QQQ gap** | 15.0 | 2.31 | 1.78 | 2.16 | **9/9** |
|
||||
| BTC->SPY gap | 14.4 | 2.14 | 1.69(lf) | 2.03 | 9/9 |
|
||||
|
||||
Statisticamente schiacciante (t 14-18, sopra Bonferroni su 416 test), regge stress costi e OOS
|
||||
recente, **positivo 8-9 anni su 8-9**.
|
||||
|
||||
### Ma DUE killer (i verificatori avversariali concordi)
|
||||
1. **NON tradabile via ETF**: il gap e' gia' prezzato all'open dell'ETF -> serve un FUTURE indice
|
||||
tenuto overnight (MNQ/MES/M2K). A $0.5-2k il margin overnight di anche un micro consuma il
|
||||
capitale e rischia la liquidazione su un gap avverso -> **fuori portata per costruzione**.
|
||||
2. **E' RISK-BETA, non alpha**: la finestra-lead crypto e' quasi CONTEMPORANEA al gap (stesso shock
|
||||
macro overnight, equity chiuso). t enorme = co-movimento risk-on/off, non ETH/BTC che *anticipa*.
|
||||
Firma: la forza e' negli anni alta-vol (2022 hit 0.71-0.75), piatta negli anni calmi (2019/21/23).
|
||||
corr ~0.37 -> beta implicito ~37%, alpha residuo piccolo.
|
||||
|
||||
### L'unico tradabile via ETF e' troppo debole
|
||||
ETH->XLE intraday 6h (compri XLE al day-open, chiudi +6h): Sh OOS 0.48@4bps **-> 0.15@10bps** (annuo
|
||||
4.1%->1.0%), t-incr 2.38 **sotto Bonferroni** (~3.5 su 416 test). Edge netto onesto ~ZERO.
|
||||
|
||||
## Verdetto (sintesi multi-agente)
|
||||
**Nessun edge proprietario deployabile a basso capitale.** Il fenomeno crypto->equity-overnight e'
|
||||
statisticamente reale e robustissimo su 9 anni, ma e' (a) risk-beta condiviso, non anticipazione
|
||||
sfruttabile, e (b) catturabile solo con futures overnight, fuori dal nostro capitale. L'unica
|
||||
versione ETF-eseguibile e' dentro il rumore da multiple-testing. Coerente col soffitto del progetto:
|
||||
"niente di nuovo regge" alla verifica onesta.
|
||||
|
||||
**Migliore soluzione (come FENOMENO da forward-monitor, non deploy):** BTC->QQQ gap overnight — la
|
||||
storia piu' lunga (9/9 anni), lead noto prima dell'open. Da monitorare; deployabile solo con capitale
|
||||
~>$20-30k su micro-futures indice e con i costi notturni modellati.
|
||||
|
||||
## Lezione
|
||||
Anche con 65 agenti e una ricerca esaustiva su mercati/timing/anni, la disciplina onesta
|
||||
(tradabilita' al capitale reale + multiple-testing + beta-vs-alpha) riduce un "Sharpe 2.5 su 9 anni"
|
||||
a un non-edge per noi. Il valore della ricerca: aver QUANTIFICATO e CLASSIFICATO il fenomeno
|
||||
(risk-beta overnight) invece di scambiarlo per alpha.
|
||||
Artefatti: `crypto_lead_harness.py`, `wf_crypto_lead.js`.
|
||||
@@ -0,0 +1,53 @@
|
||||
# 2026-06-22 — Crypto × mercati IB: correlazioni e ANTICIPAZIONI (lead-lag)
|
||||
|
||||
## Obiettivo
|
||||
Cercare correlazioni e soprattutto ANTICIPAZIONI tra crypto e mercati IB: un mercato fa capire
|
||||
l'andamento dell'altro? Dati: cache su disco (BTC/ETH Deribit 1h->1d UTC; ETF eq_* con OPEN). Nessun
|
||||
IB online. Disciplina: attenzione ai tranelli di timing daily (crypto chiude 00:00 UTC, US equity
|
||||
21:00 -> lag-0 contaminato), test del segno + OOS + multiple-testing.
|
||||
Script: `crypto_macro_leadlag.py`, `crypto_weekend_signal.py`.
|
||||
|
||||
## (1) Correlazione contemporanea
|
||||
Crypto = asset RISK-ON: corr BTC/ETH ~ **+0.32/0.37** con SPY/QQQ/IWM, **+0.25/0.28** con HYG
|
||||
(credito), **+0.13** GLD, **~-0.02** TLT (bond). Atteso.
|
||||
|
||||
## (2) Lead-lag giornaliero: NIENTE
|
||||
corr(BTC_{t-k}, ETF_t) ha picco a **k=0** (~0.32) e crolla a rumore (±0.05) per |k|>=1. Al daily
|
||||
**nessuno anticipa l'altro** (ne' crypto->equity ne' viceversa). Honest negative.
|
||||
|
||||
## (3) EFFETTO WEEKEND: anticipazione PULITA, significativa, OOS-robusta
|
||||
La crypto si muove Sab+Dom (azionario chiuso) -> quel movimento e' info PRIOR al lunedi'.
|
||||
- **Anticipa il GAP del lunedi'**: corr +0.22/+0.24 (SPY/QQQ/IWM/HYG), hit 59-62%, e **si RAFFORZA
|
||||
OOS (2022+): +0.30/+0.36**. Coerente su 4 ETF (non cherry-pick).
|
||||
- Intraday del lunedi' (open->close) piu' debole ma presente (corr 0.10-0.15, OOS 0.18-0.22).
|
||||
|
||||
### Validazione avversariale
|
||||
- **(A) INCREMENTALE vs venerdi'**: regressione `Mon ~ weekend_crypto + friday_eq`. Coeff weekend
|
||||
crypto significativo ovunque (QQQ gap **t=+4.7**, intr t=+2.9; SPY +4.4/+2.0; IWM +4.7/+2.7);
|
||||
friday_eq NON significativo. -> e' info CRYPTO-SPECIFICA del weekend, non momentum equity.
|
||||
- **(B) TRADABILE** (osservo weekend crypto Dom 24:00, entro Monday OPEN, esco CLOSE, net 4bps):
|
||||
| ETF | hit | Sharpe FULL / IS / OOS22+ | long-flat OOS | ann |
|
||||
|---|---|---|---|---|
|
||||
| QQQ | 60% | 1.46 / 1.61 / 1.33 | **1.91** | ~+9-11% |
|
||||
| SPY | 60% | 0.96 / 0.91 / 1.01 | 1.70 | ~+5% |
|
||||
| IWM | 56% | 0.89 / 0.73 / 1.04 | 1.07 | ~+6% |
|
||||
|
||||
## Verdetto
|
||||
**Trovata UNA anticipazione reale**: il weekend crypto anticipa il lunedi' azionario (massimo su QQQ,
|
||||
risk-on/tech). Significativa (t>4 sul gap), incrementale al venerdi', tradabile net costi, **regge e
|
||||
si rafforza OOS**, coerente su piu' ETF. Meccanismo economico sensato: crypto = proxy 24/7 del
|
||||
risk-sentiment; nel weekend l'equity e' chiuso e lunedi' "recupera" la direzione crypto.
|
||||
|
||||
### Caveat onesti
|
||||
- **Capacita' bassa**: ~52 lunedi'/anno, intraday -> ~+9%/yr sul capitale impiegato il lunedi', non
|
||||
una macchina da compounding. E' un segnale TATTICO, non un cornerstone.
|
||||
- Il GAP (t=4.7) e' piu' forte dell'intraday (t=2.9) ma per catturarlo serve entrare PRIMA del Monday
|
||||
open -> via **futures indice IB (MNQ/MES, aperti la domenica sera)**: enhancement eseguibile da
|
||||
validare (cattura gap+sessione).
|
||||
- Multiple-testing 3 ETF x 2 target: ma TUTTI significativi e coerenti -> effetto ampio, non fortuna.
|
||||
- Niente IB online qui (cache); per il deploy servirebbe il feed crypto live la domenica sera.
|
||||
|
||||
## Prossimo (se si procede)
|
||||
Validare la variante FUTURES (MNQ domenica sera -> cattura il gap del lunedi') e il sizing a basso
|
||||
capitale; eventualmente paper-trade. E' la prima ANTICIPAZIONE cross-mercato trovata: crypto come
|
||||
lead di sentiment sul lunedi' equity.
|
||||
@@ -0,0 +1,40 @@
|
||||
# 2026-06-22 — Combo DEPLOYABLE: TP01 (Deribit) + GTAA (IB)
|
||||
|
||||
## Perche'
|
||||
Il combo crypto-pieno (TP01+XS01+VRP01)+GTAA diversificava (Sharpe 1.81), ma XS01/VRP01 sono
|
||||
STAT-MODE (non eseguibili a $600). Validazione del combo ONESTO/eseguibile: solo le gambe deployable
|
||||
a basso capitale — TP01 (gia' armato live su Deribit) + GTAA vt12 (eseguibile su IB, frazioni,
|
||||
switch mensile). `eq_tp01_gtaa_combo.py`. TP01 compoundato sul calendario giorni-di-borsa.
|
||||
|
||||
## Risultati (finestra comune 2019-03 .. 2026-06, ~7y)
|
||||
| | Sharpe | CAGR | volAnn | maxDD |
|
||||
|---|---|---|---|---|
|
||||
| TP01 (crypto, Deribit) | 1.25 | 16.4% | 12.9% | 14% |
|
||||
| GTAA vt12 (equity, IB) | 1.12 | 6.0% | 5.3% | 8% |
|
||||
| **blend 50/50** | **1.48** | 11.3% | 7.5% | **8%** |
|
||||
| blend 40/60 (best cap-mix) | 1.52 | 10.2% | 6.6% | 8% |
|
||||
| risk-parity (29c/71e) | 1.52 | 9.1% | 5.9% | 8% |
|
||||
|
||||
**corr TP01<->GTAA = +0.21**. Il blend (1.48-1.52) batte entrambe le gambe (best solo 1.25),
|
||||
maxDD 14%->8%. **DIVERSIFICA anche da deployable.**
|
||||
|
||||
## Caveat onesti
|
||||
- Per-anno 50/50: 2019 2.11, 2020 2.51, 2021 1.66, **2022 -2.64**, 2023 1.40, 2024 1.73, 2025 0.98,
|
||||
2026 0.94. Anni boom iniziali gonfiano lo Sharpe assoluto; il **2022 e' negativo** (trend whipsaw
|
||||
su entrambe le gambe nel bear). Recenti ~0.95. -> il numero robusto e' il GUADAGNO da
|
||||
diversificazione (+0.27 Sharpe del blend vs solo), non il livello assoluto.
|
||||
- **Costo deployability**: crypto-pieno+GTAA = 1.81 vs deployable = 1.48. I ~0.33 di Sharpe persi sono
|
||||
cio' che XS01/VRP01 darebbero se eseguibili (servirebbe ~20k).
|
||||
- **Cross-venue** Deribit+IB: due conti, capitale split. Entrambe switch mensile/basso turnover,
|
||||
frazionabili a $0.5-2k.
|
||||
|
||||
## Verdetto
|
||||
Combo deployable VALIDO: due trend difensivi scorrelati (corr 0.21) su mercati diversi -> Sharpe
|
||||
~1.5 / maxDD ~8%, meglio di ciascuna gamba. E' il candidato concreto per un paper-trade cross-venue.
|
||||
NON risolve EUR50/g (resta capitale), ma e' la migliore configurazione rischio-aggiustata
|
||||
EFFETTIVAMENTE eseguibile trovata finora. Lezione cross-mercato confermata: il salto di qualita' non
|
||||
e' un nuovo alpha ma un SECONDO mercato scorrelato.
|
||||
|
||||
## Prossimo (se si procede)
|
||||
Paper-trade della gamba GTAA su IB (forward-only, come paper_trend per TP01), per validare
|
||||
l'esecuzione cross-venue a rischio zero prima di qualunque capitale reale.
|
||||
@@ -0,0 +1,57 @@
|
||||
# 2026-06-22 — EQ-GTAA01 (trend multi-asset) + COMBO cross-mercato equity×crypto
|
||||
|
||||
## (1) EQ-GTAA01 — trend difensivo multi-asset (GTAA)
|
||||
|
||||
EQ-TREND01 (trend su SPY) taglia il DD. Diversificare le SORGENTI di trend (azioni US/tech/small +
|
||||
bond + oro + high-yield) migliora il rischio-aggiustato. `eq_gtaa_trend.py`: ogni asset gestito col
|
||||
proprio trend long-flat (TSMOM multi-orizzonte), equal-weight tra gli asset disponibili (outer-join,
|
||||
cash dove off/assente). Universo SPY/QQQ/IWM/TLT/GLD/HYG. Causale, netto fee, OOS 2015+.
|
||||
|
||||
| strategia | CAGR | Sharpe (pre15/OOS) | maxDD | corr SPY |
|
||||
|---|---|---|---|---|
|
||||
| SPY buy&hold | 9.7% | 0.58 (0.45/0.82) | 55% | — |
|
||||
| EW statico (no trend) | 9.4% | 0.59 | 62% | 0.89 |
|
||||
| SPY-trend mono | 5.5% | 0.56 (/0.78) | 30% | 0.72 |
|
||||
| **GTAA lf vt12%** | 3.8% | **0.64** (0.53/**0.89**) | **15%** | **0.64** |
|
||||
| GTAA vt12 (6-asset, 2016+) | 5.6% | **1.08** | **8%** | 0.60 |
|
||||
|
||||
DD nei bear (GTAA vs SPY): dot-com 32%/49% · GFC **14%/55%** · COVID **10%/34%** · 2022 11%/24%.
|
||||
Marginale vs SPY: corr 0.64; 50/50 uplift +0.041 FULL / **+0.086 OOS** (meglio del mono-SPY). Plateau
|
||||
stabile (Sh 0.55-0.61, DD 25-35%). **Migliore sleeve equity**: Sharpe più alto, maxDD bassissimo
|
||||
(8-15%), corr SPY più bassa (0.64) = diversificatore migliore. Tradeoff: CAGR molto più basso
|
||||
(fortemente difensiva). Caveat: la finestra 6-asset (Sh 1.08) è tutta OOS ma un solo regime (toro).
|
||||
|
||||
## (2) COMBO cross-mercato — equity-trend × crypto
|
||||
|
||||
La via che alza il Sharpe COMPLESSIVO senza nuovo alpha: combinare due book scorrelati.
|
||||
`eq_crypto_combo.py`: crypto = portafoglio attivo TP01+XS01+VRP01 (`StrategyPortfolio.combined_daily`,
|
||||
rinormalizzato); equity = GTAA lf vt12%. Crypto compoundato sul calendario giorni-di-borsa (cattura
|
||||
i weekend). Finestra comune = era crypto (2019-03 .. 2026-06, 1827 giorni di borsa).
|
||||
|
||||
| | Sharpe | CAGR | volAnn | maxDD |
|
||||
|---|---|---|---|---|
|
||||
| crypto TP01+XS01+VRP01 | 1.60 | 18.7% | 11.1% | 14% |
|
||||
| equity GTAA vt12 | 1.12 | 6.0% | 5.3% | 8% |
|
||||
| **blend 50/50** | **1.81** | 12.4% | 6.6% | **7%** |
|
||||
| risk-parity (32c/68e) | 1.78 | 10.1% | 5.5% | 8% |
|
||||
|
||||
**Correlazione crypto↔equity = +0.167** (bassissima). Il blend 50/50 fa **Sharpe 1.81 > di ciascuno**
|
||||
(crypto 1.60, equity 1.12), **maxDD dimezzato 14%→7%**. VERDETTO: DIVERSIFICA (blend > miglior solo
|
||||
di +0.21 Sharpe). È il guadagno STRUTTURALE: due fonti di rischio scorrelate alzano il Sharpe
|
||||
complessivo senza cercare un nuovo edge.
|
||||
|
||||
### Caveat onesti
|
||||
- **Finestra crypto corta (~7y) e favorevole**: il crypto Sharpe 1.60 e' alto (regime toro + XS01/VRP01
|
||||
STAT-MODE a storia corta). Gli SHARPE ASSOLUTI sono ottimistici. Ma il PUNTO della diversificazione
|
||||
(corr 0.17, blend > solo, DD dimezzato) è robusto al livello assoluto.
|
||||
- **Cross-venue**: crypto su Deribit, equity su IB → due conti, due percorsi d'esecuzione. A $0.5-2k
|
||||
totali, ogni sleeve è minuscola. La parte equity (GTAA) e la TP01 sono entrambe eseguibili a basso
|
||||
capitale; XS01/VRP01 restano STAT-MODE (il blend "reale" deployable è ~TP01 + GTAA).
|
||||
|
||||
## Lettura strategica
|
||||
Il fronte equity da' due cose: (a) una sleeve difensiva robusta (GTAA, maxDD ~10%), (b) — piu'
|
||||
importante — un DIVERSIFICATORE quasi-scorrelato al crypto che alza il Sharpe del portafoglio
|
||||
complessivo da ~1.6 a ~1.8. Non risolve €50/g (resta capitale), ma e' il primo miglioramento
|
||||
STRUTTURALE del rischio-aggiustato complessivo trovato in tutta la ricerca post-reset, ed e' del tipo
|
||||
giusto (diversificazione vera, non alpha fittizio). Prossimo: validare il combo deployable TP01+GTAA
|
||||
(solo le due gambe eseguibili), e valutare l'operativita' cross-venue.
|
||||
@@ -0,0 +1,45 @@
|
||||
# 2026-06-22 — Fronte EQUITY aperto + EQ-MOM01 (momentum settoriale): NON batte SPY
|
||||
|
||||
## Apertura fronte (branch research/equities-ib)
|
||||
|
||||
Le 4 ondate crypto hanno esaurito gli angoli su BTC/ETH (soffitto ~1.3). L'unico modo di superarlo è
|
||||
un **mercato diverso**. Aperto il fronte azioni/ETF via IB (paper, `gnzsnz/ib-gateway`, read-only).
|
||||
|
||||
**Dati certificati + cache su disco** (`fetch_ib_equities.py` → `data/raw/eq_*.parquet`, ADJUSTED_LAST
|
||||
div+split, gitignored = cache locale; loader `eqlib.py` con lru_cache → ricerca legge da disco, MAI
|
||||
da IB). Universo: 9 SPDR settoriali classici dal **1998 (27.5y)** + XLRE(2015)/XLC(2018) + SPY(1996,
|
||||
30y)/QQQ/IWM/GLD/HYG/TLT. Tutti integri (monotoni, no dup, no spike>50%, 0 gap lunghi).
|
||||
NB bug timestamp risolto: `pd.Timestamp` a risoluzione µs → salvati in secondi, corretti a ms.
|
||||
|
||||
## EQ-MOM01 — momentum cross-sectional settoriale
|
||||
|
||||
Costruzione causale (`eq_sector_momentum.py`): ogni 21g, momentum = blend lookback [63,126,252]g con
|
||||
skip-21 (12-1 classico), z-score cross-sectional. long-only top-k (full-invested, confronto
|
||||
like-for-like con SPY) e long-short (dollar-neutral, test alpha puro). Netto fee, hold-out OOS 2015+.
|
||||
|
||||
### Risultati (9 settori, 1998-2026)
|
||||
| strategia | CAGR | Sharpe (pre15/OOS15+) | maxDD | corr SPY |
|
||||
|---|---|---|---|---|
|
||||
| **SPY buy&hold** | 8.2% | **0.51** (0.31/0.82) | 55% | — |
|
||||
| EW 9 settori | 8.9% | 0.56 (0.44/0.76) | 53% | 0.96 |
|
||||
| MOM long top-3 | 7.7% | 0.50 (0.32/0.76) | 47% | 0.85 |
|
||||
| MOM long vol-target 15% | 7.3% | 0.52 | 39% | 0.75 |
|
||||
| **MOM long-short top-3** | −0.9% | **−0.08** (−0.19/0.08) | 32% | −0.20 |
|
||||
|
||||
### Verdetto: NESSUN edge vs SPY
|
||||
- **Long-short Sharpe −0.08** → l'alpha cross-sectional di momentum settoriale è **morto** su 27 anni
|
||||
(decadimento post-2000 noto in letteratura). Niente alpha market-neutral.
|
||||
- **Long-only ≈ SPY**: corr 0.85, **uplift marginale ~0.00** (blend 75/25 +0.012 FULL / +0.001 OOS;
|
||||
50/50 +0.015 / −0.010). È un SPY a beta più basso, non un edge. Plateau stabile ma sempre ~0.50
|
||||
(vs SPY 0.51); sugli 11 settori (2018+) fa peggio (0.69 vs 0.82). Fee-robusto (ma niente da salvare).
|
||||
- L'unico beneficio (maxDD 55%→39%) è del **vol-target**, non del momentum (lo daresti a SPY stesso).
|
||||
|
||||
## Lezione (coerente col progetto)
|
||||
Il momentum **relative-value** è morto anche in equity, come nel crypto (ortho wave). Il baseline
|
||||
equity da battere è SPY buy&hold (Sharpe ~0.51 full / 0.82 OOS), ostico come il toro crypto.
|
||||
|
||||
## Prossimo angolo plausibile (NON ancora testato)
|
||||
L'analogo equity di TP01 (l'unica cosa che ha retto nel crypto = trend DIFENSIVO): **time-series
|
||||
trend su SPY long-flat/long-bonds** — non per battere il CAGR ma per **tagliare il 55% di drawdown**
|
||||
restando vicino al ritorno. È il punto dove vive il valore robusto in equity (e dove il cross-section
|
||||
NON guarda). Da provare con lo stesso gauntlet: marginale vs SPY, OOS lungo, plateau.
|
||||
@@ -0,0 +1,48 @@
|
||||
# 2026-06-22 — EQ-TREND01: trend DIFENSIVO su SPY = edge difensivo REALE (analogo di TP01)
|
||||
|
||||
## Contesto
|
||||
Il momentum cross-sectional settoriale è morto (EQ-MOM01: long-short Sharpe −0.08, long-only ≈ SPY).
|
||||
Ma nel crypto l'unica cosa che ha retto NON era relative-value: era **TP01**, un trend DIFENSIVO che
|
||||
taglia il drawdown. L'equity ha lo stesso buco: SPY buy&hold Sharpe ~0.54 ma maxDD **55%**.
|
||||
|
||||
## Costruzione (causale, stile TP01)
|
||||
`eq_spy_trend.py`. TSMOM multi-orizzonte [21,63,126,252]g, target = frazione di orizzonti in
|
||||
trend-up (allocazione graduale 0..1), opz. vol-target. Posizione decisa a ≤i-1, tenuta da i. Netto
|
||||
fee. Varianti: long-flat (cash in risk-off), long-bonds (TLT, solo 2016+), SMA-200 (Faber). Dati da
|
||||
cache eqlib (ADJUSTED, nessun IB). Periodo 1997-2026, OOS 2015+.
|
||||
|
||||
## Risultati
|
||||
| strategia | CAGR | Sharpe (pre15/OOS) | maxDD | in-mkt |
|
||||
|---|---|---|---|---|
|
||||
| SPY buy&hold | 9.0% | 0.54 (0.38/0.82) | 55% | 99% |
|
||||
| **SMA-200 (Faber)** | 7.0% | **0.65** (0.52/0.88) | **29%** | 76% |
|
||||
| TSMOM lf cap1.0 | 5.7% | 0.57 (0.44/0.78) | 30% | 92% |
|
||||
| TSMOM lf vt15% | 5.7% | 0.62 (0.51/0.78) | **25%** | 92% |
|
||||
|
||||
**Drawdown nei bear (TSMOM vs SPY):** dot-com 26%/49% · GFC **19%/55%** · COVID 17%/34% · 2022 16%/24%.
|
||||
|
||||
**Plateau** (long-flat): ogni config Sharpe 0.56-0.65 (> SPY 0.54), maxDD 25-31% (~metà di SPY).
|
||||
SMA-200 il più semplice E il migliore (Sh 0.65, OOS 0.88, DD 29%). **Fee-robusto** (Sh 0.48 a
|
||||
0.10%/lato), basso turnover.
|
||||
|
||||
**Marginale vs SPY:** corr 0.73. blend 50/50 uplift +0.035 FULL / +0.031 OOS (modesto positivo);
|
||||
100% trend uplift −0.012 / −0.041 (nel toro recente la difesa costa).
|
||||
|
||||
## Verdetto: edge DIFENSIVO reale (non alpha) — analogo di TP01
|
||||
- ✅ Sharpe 0.54→0.62/0.65, **maxDD dimezzato** (55%→~27%, nei bear lenti più che dimezzato),
|
||||
plateau robusto, fee-robusto, **eseguibile a $0.5-2k** (switch mensile SPY/cash).
|
||||
- ⚠️ NON genera ritorno (CAGR −2/3pp): è risk-management, come TP01.
|
||||
- ⚠️ I tagli grossi (dot-com/GFC) sono IN-SAMPLE; l'OOS 2015-26 è quasi tutto toro → lì ha seguito
|
||||
SPY a beta minore (ma COVID, OOS, dimezzato). La difesa "serve" nei bear, rari nell'OOS.
|
||||
- ⚠️ long-bonds (TLT) non convince (TLT distrutto 2022).
|
||||
|
||||
## Lettura strategica
|
||||
Primo positivo del fronte equity, e dello stesso TIPO che ha retto nel crypto: trend difensivo, non
|
||||
relative-value. Conferma la lezione cross-mercato: **il valore robusto è nel ridurre il rischio
|
||||
(trend long-flat), non nel battere il buy&hold**. Da solo non risolve €50/g (problema di capitale).
|
||||
|
||||
## Prossimo angolo plausibile
|
||||
**Trend multi-asset / GTAA** sull'universo ETF in cache (SPY/QQQ/IWM + TLT/GLD/HYG): un portafoglio
|
||||
di trend long-flat su classi d'attivo diverse di solito batte il trend mono-SPY sul rischio-aggiustato
|
||||
(diversificazione dei trend). + domanda cross-mercato: la sleeve equity-trend DIVERSIFICA il
|
||||
portafoglio crypto (TP01+XS01+VRP01)? (esecuzione split Deribit+IB).
|
||||
@@ -0,0 +1,96 @@
|
||||
# 2026-06-22 — Funding-CARRY cross-sectional su Hyperliquid (FC01): LEAD fragile, NON regge
|
||||
|
||||
## Contesto
|
||||
|
||||
Onda "nuova ricerca mirata" (l'utente ha chiesto di cercare un angolo non coperto dalle due grandi
|
||||
ondate — sweep 104-ipotesi e ortho relative-value, entrambe esaurite sul *prezzo* BTC/ETH). L'unico
|
||||
meccanismo con una **fonte di ritorno diversa** non ancora testato su dati certi è il **carry da
|
||||
funding**: incassare il cashflow dei perp stando delta-neutral.
|
||||
|
||||
### Scan di fattibilità dati (prima di tutto, lezione v2.0.0)
|
||||
- **Funding price-clock** (drift attorno agli stamp 00/08/16) sul feed Deribit certificato →
|
||||
già testato nell'onda intraday (`agent_03_funding_clock_15m`) = **FAIL** ("il funding è un cashflow
|
||||
perp-vs-spot; il prezzo index non ha drift tradabile attorno allo stamp al netto del trend").
|
||||
- **Funding carry su Deribit** (dove eseguiamo) → ccxt `fetch_funding_rate_history` = **0 righe**
|
||||
(bloccato), Cerbero MCP espone solo `get_historical` (candele), endpoint funding = 404.
|
||||
- **Funding carry su Hyperliquid** → API pubblica `/info {"type":"fundingHistory"}` = **disponibile**,
|
||||
oraria, tokenless, serie native dal 2023-05. HL è già l'universo certificato di XS01.
|
||||
|
||||
### Dato scaricato e certificato
|
||||
`scripts/research/fetch_hl_funding.py` (backoff anti-429) → **19 major** (gli stessi di XS01),
|
||||
`data/raw/hlfund_<sym>_1h.parquet`. Certificazione: cadenza ~1h, **0 gap**, copertura 98-100%,
|
||||
funding annualizzato per asset da **APT +1.0%** a **NEAR +21.6%** (mediana ~+11.7%). Pochi `cap_hit`
|
||||
(ore con |funding|>0.06%/h) su INJ/TIA/SEI, plausibili in alt ad alta vol. Dato pulito.
|
||||
|
||||
## Ipotesi e costruzione (FC01)
|
||||
|
||||
Book dollar-neutral che **SHORTA i k perp ad alto funding** e **COMPRA i k a basso** → incassa il
|
||||
premio (chi è long paga il funding). Ritorno perp per un long = `price_ret − funding`. Causale come
|
||||
XS01: ogni H=10 giorni, segnale = media causale del funding giornaliero realizzato sugli ultimi L
|
||||
giorni (shift 1), rank cross-section, vol-target 20%, fee 0.05%/lato sul turnover.
|
||||
`scripts/research/funding_carry_hl.py`. Domanda chiave: **edge reale e ORTOGONALE a XS01**, o XS01
|
||||
travestito? (gli alt ad alto funding sono spesso i pompati = quelli che XS01 *compra*; qui li
|
||||
*shortiamo* → potenziale anti-correlazione, oppure il carry domina).
|
||||
|
||||
## Risultati
|
||||
|
||||
### Premio reale ma direzione-dipendente
|
||||
`carry` (short alto-funding) batte sistematicamente `anti` (long alto-funding, sempre molto negativo)
|
||||
→ **il premio di funding esiste**: shortare i perp ad alto funding paga, in aggregato.
|
||||
|
||||
### Ma il book NON regge il gauntlet (19 asset, 2024-2026, 904g)
|
||||
- **Standalone base (L=7 k=5): FULL Sharpe −0.12, in-sample 0.44, HOLD −0.50, DD 28.6%**, −2.8%/anno.
|
||||
Decadimento netto: 2024 **+0.44** → 2025 −0.06 → 2026 **−1.42**.
|
||||
- Correlazioni: TP01 −0.02, **XS01 −0.19** (ortogonale, come da ipotesi — NON è XS01 travestito),
|
||||
VRP01 +0.05.
|
||||
- **`marginal_vs_tp01` = DILUTES**: `has_insample_edge=False` (in-sample 0.44 < 0.5),
|
||||
`multicut_persistent=False`, blend w25 uplift FULL −0.21 / HOLD −0.39.
|
||||
- **Non aggiunge a XS01**: uplift w25 FULL −0.04 / HOLD −0.19.
|
||||
|
||||
### Il colpo di grazia: FRAGILITÀ all'universo
|
||||
Un preview su 17 asset (mancavano NEAR e AAVE) dava FULL **+0.62**, ADDS, +0.22 uplift — un PASS
|
||||
tentatore. Sui 19 completi: **DILUTES**. Jackknife lascia-fuori-uno (base L=7 k=5):
|
||||
|
||||
```
|
||||
19 asset: FULL -0.12 HOLD -0.50
|
||||
-SUI FULL -0.39 ... -BTC FULL +0.17
|
||||
-SEI FULL -0.31 -AAVE FULL +0.26
|
||||
-BNB FULL -0.29 -NEAR FULL +0.30
|
||||
=> FULL oscilla in [-0.39, +0.30] togliendo UN solo asset (range 0.70), attraversa lo zero.
|
||||
```
|
||||
|
||||
Togliere **NEAR o AAVE** (i due assenti nel preview) **recupera il segno** → il preview era fortunato
|
||||
*proprio* perché quei due non c'erano ancora. **Un edge robusto non cambia segno per un singolo nome.**
|
||||
Le poche celle "buone" del plateau (es. L=7 k=3: HOLD 0.91) hanno **in-sample debole + hold-out forte**
|
||||
= la firma del hold-out-luck che la metodologia indurita uccide.
|
||||
|
||||
## Perché fallisce (meccanismo)
|
||||
|
||||
Tensione fondamentale **carry vs momentum**: il funding-carry shorta i forti (alto funding = domanda
|
||||
long aggressiva), ma in un mercato alt toro i forti **continuano a correre** (NEAR/AAVE: alto funding
|
||||
*e* prezzo su → shortarli perde più del premio incassato). Il premio di funding è reale in aggregato,
|
||||
ma il book cross-sectional equal-weight top-k è dominato da pochi nomi a funding estremo che *anche*
|
||||
trendano, e su 2.5 anni / 19 nomi questo basta a ribaltare il segno.
|
||||
|
||||
## Verdetto
|
||||
|
||||
**FC01 NON è uno sleeve.** Né deploy (è STAT-MODE: 10 gambe market-neutral, non eseguibile a $600),
|
||||
né lead affidabile: fragile all'universo (sign-flip su un nome), DILUTES vs TP01, non aggiunge a XS01,
|
||||
in-sample edge < 0.5, niente persistenza multi-cut, decadimento 2026. Conferma — di nuovo — il
|
||||
soffitto del progetto: promettente su un sottoinsieme fortunato, collassa sotto il gauntlet onesto.
|
||||
**Win metodologico:** lo scorer indurito + il jackknife d'universo hanno intercettato un falso
|
||||
positivo che il preview a 17 asset avrebbe promosso.
|
||||
|
||||
## Lascito / lavoro futuro (NON inseguire ora)
|
||||
- I 19 parquet funding (`hlfund_*`) restano certificati per ricerca futura. Il fetcher NON va in cron
|
||||
(FC01 fallito → niente da monitorare in forward).
|
||||
- Idee se mai si tornasse sul carry (NON ora): (a) **gate sul LIVELLO** di funding (short solo quando
|
||||
estremo, regime-filter alla VRP01 IV-rank) invece dello short-top-k incondizionato; (b) cap sul peso
|
||||
per-nome / neutralizzazione momentum per togliere il dominio NEAR/AAVE. Entrambe rischiano
|
||||
overfitting su storia corta — soglia di prova alta.
|
||||
|
||||
## Nota IB (thread parallelo, stessa sessione)
|
||||
Esplorato come fonte per il **basis CME crypto** (cugino eseguibile del carry). Gateway paper
|
||||
`gnzsnz/ib-gateway` su `127.0.0.1:4002` (read-only, `docker-compose.yml`), sonda `ib_probe.py`.
|
||||
Esito dati: **backtest del basis NON fattibile** (ContFuture back-adjusted; contratti scaduti = 1
|
||||
barra). IB resta valido per esecuzione/forward, non per scoprire l'edge. Dettagli nel corpo sessione.
|
||||
@@ -0,0 +1,33 @@
|
||||
# 2026-06-23 — Combo DEPLOYABLE in PAPER: TP01 (Deribit) + GTAA (IB), cross-venue
|
||||
|
||||
## Decisione
|
||||
Dopo aver esaurito (onestamente) la ricerca di nuovi edge e anticipazioni cross-mercato, l'unica cosa
|
||||
VERA e deployabile e' la DIVERSIFICAZIONE: TP01 (crypto, Deribit) + GTAA (equity, IB), corr ~0.21 ->
|
||||
blend Sharpe ~1.5, maxDD dimezzato (diari 2026-06-22-deployable-combo). Si va in PAPER cross-venue.
|
||||
|
||||
## Costruito
|
||||
- **`src/portfolio/gtaa.py`** — GTAA come sleeve di prima classe: trend difensivo long-flat TSMOM
|
||||
[21/63/126/252g], vol-target 12%, EW su SPY/QQQ/IWM/TLT/GLD/HYG. Espone `gtaa_returns()` (Sharpe
|
||||
full 0.64, 7542 barre 1996+) e `gtaa_weights()` (pesi ETF CORRENTI azionabili). Legge cache eq_*.
|
||||
- **`scripts/live/paper_combo.py`** — paper-tracker FORWARD-ONLY del blend 50/50 TP01+GTAA (crypto
|
||||
compoundato sul grid giorni-di-borsa). Stato in data/paper_combo/. Mostra le posizioni azionabili
|
||||
su entrambi i venue. SOLO le gambe eseguibili (XS01/VRP01 STAT-MODE esclusi).
|
||||
- **`fetch_ib_equities.py --only SPY,QQQ,...`** — refresh mirato dei 6 ETF GTAA (per il cron).
|
||||
- **`scripts/cron_daily.sh`** — aggiunto: up gateway IB (idempotente) -> refresh ETF GTAA ->
|
||||
avanza paper_combo. Dipendenza cross-venue gestita (gateway paper sempre-up, restart unless-stopped).
|
||||
|
||||
## Stato iniziale (2026-06-23)
|
||||
Paper combo init a 2000, forward da 2026-06-22. Posizioni azionabili:
|
||||
- TP01 (Deribit): BTC/ETH 0.0x (flat, TSMOM risk-off — coerente col live).
|
||||
- GTAA (IB): SPY 13% / QQQ 8% / IWM 9% / TLT 17% / GLD 2% / HYG 17% / cash 34% (difensivo).
|
||||
Catena end-to-end testata: gateway -> refresh ETF -> avanza paper. OK.
|
||||
|
||||
## Onesta'
|
||||
- E' PAPER (rischio zero). Valida l'OPERATIVITA' cross-venue prima di capitale reale.
|
||||
- Sharpe atteso ~1.5 e' ottimistico (finestra crypto corta/favorevole); il dato robusto e' la
|
||||
diversificazione (corr 0.21, DD dimezzato), non il livello assoluto.
|
||||
- A capitale reale e' un portafoglio su DUE conti (Deribit ~$600 + IB); GTAA frazionabile a basso
|
||||
capitale, TP01 gia' armato. Prossimo passo eventuale: dashboard del combo + (molto dopo) capitale.
|
||||
|
||||
## Prossimo
|
||||
Lasciar girare il paper forward (cron giornaliero) e ricontrollare l'equity tra qualche settimana.
|
||||
@@ -0,0 +1,38 @@
|
||||
# 2026-06-23 — Cross-market crypto-lead OLTRE l'SP500: bond, commodity, indici esteri -> niente
|
||||
|
||||
## Obiettivo
|
||||
Estendere il test "crypto anticipa il mercato?" oltre SP500/azionario USA: commodity, bond, indici
|
||||
ESTERI (Europa/Asia, fasi orarie diverse = il caso a priori piu' favorevole a un lead vero).
|
||||
|
||||
## Dati (IB, orari, cache fut_*_1h)
|
||||
ES/NQ/RTY (gia'); + ZN (T-note 10y), ESTX50 (Euro Stoxx50), DAX, NKD (Nikkei). Storia ~2-2.4y (2024+).
|
||||
Commodity GC/CL/HG: VUOTE (market-data subscription COMEX/NYMEX mancante sul paper) -> non testate.
|
||||
|
||||
## Test (`fut_leadlag_generic.py`): crypto[T-8h->T] -> future[T->T+6h], non-sovrapposto, controllo=moto proprio future
|
||||
- ES/NQ/RTY: nessun edge (gia' noto).
|
||||
- ZN (bond): NEGATIVO (0/3 anni).
|
||||
- NKD (Nikkei): debole (t_crypto 0.2, Sharpe 0.66 ~ overnight drift, non crypto).
|
||||
- **ESTX50 / DAX: forte all'apparenza** — BTC->T0h: t_crypto ~7.8, Sharpe 2.5, ann ~22%, 3/3 anni.
|
||||
|
||||
## Ma e' un ARTEFATTO DI CONFINE UTC (deep-dive `eu_overnight_deepdive.py`)
|
||||
- **Picco a coltello a T=00:00 UTC**: t/Sharpe salgono T20->T0 (2.5->7.8 / 0.24->2.45) e CROLLANO a
|
||||
T=1h (t 1.3, Sharpe -0.09). Un lead vero non e' a coltello su una sola ora.
|
||||
- **GAP test**: inserendo 1h tra fine-segnale (00:00) e inizio-cattura, l'effetto MUORE
|
||||
(Sharpe 2.45 -> -0.52, t 7.8 -> 1.6).
|
||||
- **Singola ora**: T=0h/H=1h (cattura 00:00->01:00) Sharpe +2.93 (t 8.7); T=1h/H=1h (01:00->02:00)
|
||||
Sharpe -1.02. L'INTERO "edge" e' la barra di confine 00:00->01:00.
|
||||
- vs SEMPRE-LONG: always-long overnight e' negativo (-0.8/-1.2), quindi non e' overnight-drift; ma
|
||||
l'uplift del crypto e' tutto nella barra di confine.
|
||||
-> Firma esatta di `day_boundary_robust` (CLAUDE.md): effetto che vive/muore spostando il confine del
|
||||
giorno UTC di poche ore = etichettatura/contaminazione, NON anticipazione economica.
|
||||
|
||||
## Verdetto
|
||||
NIENTE di tradabile oltre l'SP500 nemmeno. Su TUTTI i mercati il legame crypto->X e' o co-movimento
|
||||
contemporaneo (risk-beta) o artefatto di confine. L'anticipazione crypto->altri-mercati sfruttabile
|
||||
NON esiste su dati onesti (finestre non-sovrapposte + boundary-robust + gap). Conferma definitiva del
|
||||
soffitto del progetto, ora anche cross-mercato.
|
||||
|
||||
## Cosa resta di valore (immutato)
|
||||
La diversificazione TP01(crypto)+GTAA(equity), corr 0.21 -> Sharpe portafoglio ~1.5, DD dimezzato.
|
||||
Quello e' strutturale e deployabile; l'anticipazione cross-mercato no.
|
||||
Script: fetch_ib_futures.py (multi-exchange), fut_leadlag_generic.py, eu_overnight_deepdive.py.
|
||||
@@ -0,0 +1,29 @@
|
||||
# 2026-06-23 — "Monitor Deribit / trade IB": il gap crypto->equity e' LOOK-AHEAD
|
||||
|
||||
## Idea testata (utente)
|
||||
Guardare crypto live su Deribit (24/7) e tradare l'indice su IB sul segnale del gap overnight.
|
||||
|
||||
## Trappola trovata
|
||||
Il segnale crypto [P 21:00 -> D 13:00 UTC] e il "gap" equity [P close -> D open 13:30] coprono QUASI
|
||||
LE STESSE ORE. Condizionare il gap sul crypto-overnight = correlare due ritorni dello STESSO intervallo
|
||||
notturno -> look-ahead. All'entrata (D 13:00, pre-open) il gap e' GIA' avvenuto: non catturabile.
|
||||
|
||||
## Prova (net 2bps, sqrt(252))
|
||||
| target | OVERLAP gap (look-ahead) | TRADABILE intraday (post-entrata) |
|
||||
|---|---|---|
|
||||
| SPY | Sharpe 3.60 (OOS 5.23) | -0.03 (OOS 0.12) |
|
||||
| QQQ | Sharpe 4.01 (OOS 5.47) | 0.25 (OOS 0.43) |
|
||||
| IWM | Sharpe 3.98 (OOS 5.72) | 0.15 (OOS 0.44) |
|
||||
|
||||
Lo "Sharpe 5" e' artefatto. L'edge REALE tradabile via ETF (intraday, entri all'open) ~0, muore a costi.
|
||||
NB: anche i Sharpe "gap" del workflow 65-agenti erano (a) look-ahead di overlap e (b) sotto-annualizzati
|
||||
(sqrt(52) invece di sqrt(252)); il verdetto "non deployabile" resta, rafforzato.
|
||||
|
||||
## Cosa resta possibile (non testato, serve dato)
|
||||
L'unica versione onesta dell'idea: entrare a META' notte via FUTURES IB e vedere se crypto [P21:00->T]
|
||||
predice il future indice [T->open] su finestre NON sovrapposte (crypto come sensore di rischio piu'
|
||||
veloce). Richiede dati INTRADAY dei futures (ES/NQ/RTY), non in cache -> data step se si vuole indagare.
|
||||
|
||||
## Lezione
|
||||
Un risultato "troppo bello" (Sharpe 5) e' un test di disciplina: era overlap di finestre. Catturato.
|
||||
Script: crypto_overnight_equity.py (versione artefatto), crypto_overnight_honest.py (decomposizione).
|
||||
@@ -0,0 +1,37 @@
|
||||
# 2026-06-23 — "Monitor Deribit / trade IB" su futures: test ONESTO non-sovrapposto -> edge ~0
|
||||
|
||||
## Idea
|
||||
Monitorare crypto live (Deribit 24/7) ed entrare sul FUTURE indice IB (ES/NQ/RTY, tradato di notte)
|
||||
a meta' notte, catturando il moto SUCCESSIVO -> finestre NON sovrapposte (no look-ahead, vs il "gap"
|
||||
che era contemporaneo al segnale).
|
||||
|
||||
## Dati
|
||||
`fetch_ib_futures.py` -> data/raw/fut_{es,nq,rty}_1h.parquet (ContFuture orario, UTC). ES 3y (2023-06+),
|
||||
NQ 2.75y, RTY 2.3y. (NB: ContFuture NON accetta endDateTime -> chiamata singola "4 Y" = ~3y max orari.)
|
||||
|
||||
## Test (`fut_overnight_leadlag.py`)
|
||||
entrata a T (ora UTC notturna): segnale = crypto[P21:00->T]; controllo = future[P21:00->T] (moto
|
||||
PROPRIO del future); cattura = future[T->open 13:00]. Incrementale: crypto predice la cattura OLTRE il
|
||||
moto proprio del future? Trade: sign(crypto[P21:00->T]) * future[T->open], net 2bps. T in {0,3,6,9}h.
|
||||
|
||||
## Risultati
|
||||
| future | miglior Sharpe (trade crypto) | t_crypto incrementale | esito |
|
||||
|---|---|---|---|
|
||||
| ES (S&P500) | ~0 / negativo (-0.03..-0.93) | 0..1.5 | NESSUN edge |
|
||||
| NQ (Nasdaq) | 0.41 (T=3h) | 0.5 (debole) | momentum del future, non crypto |
|
||||
| RTY (Russell) | 0.40-0.77 | 2.0-2.7 (BTC->RTY) | soffio debole, non robusto |
|
||||
|
||||
- **SP500: NIENTE.** Il crypto della prima notte non predice l'ES della seconda. Il "Sharpe 5" del gap
|
||||
era interamente look-ahead (finestre sovrapposte): catturato e ucciso.
|
||||
- **RTY (small-cap)** e' l'unico con t_crypto incrementale ~2-2.7 e crypto che AGGIUNGE oltre il moto
|
||||
proprio del future (futOwn Sharpe negativo). MA: Sharpe 0.4-0.5 modesto, 24 config (multiple-testing),
|
||||
storia 2.3y, per-anno INCOERENTE (BTC->RTY T=3h: 2024 +0.99 / 2025 +0.52 / 2026 -0.31).
|
||||
|
||||
## Verdetto
|
||||
L'idea "monitor Deribit / trade IB" NON da' un edge tradabile, men che meno su SP500. Il forte
|
||||
fenomeno crypto<->equity e' CO-MOVIMENTO contemporaneo (risk-beta overnight), non anticipazione: quando
|
||||
si impone una finestra causale non-sovrapposta, l'edge svanisce (efficienza di mercato). L'unico
|
||||
residuo (crypto->small-cap overnight) e' debole, borderline su multiple-testing e instabile per anno
|
||||
-> forward-monitor al piu', NON deploy. Coerente col soffitto del progetto.
|
||||
|
||||
Script: fetch_ib_futures.py, fut_overnight_leadlag.py. (look-ahead documentato: 2026-06-23-crypto-overnight-lookahead.md)
|
||||
@@ -0,0 +1,141 @@
|
||||
# 2026-06-23 — SKH01 "Skyhook": porting onesto del sistema ES dual-timeframe su BTC/ETH
|
||||
|
||||
Branch: `strategy_skyhook`. Engine: `src/strategies/skyhook.py`. Harness: `scripts/research/skyhook/skyhooklib.py`.
|
||||
Test: `tests/test_skyhook.py` (5 pass). Ricerca: `scripts/research/skyhook/{sweep,grid,check_v1}.py` + `runs/`.
|
||||
|
||||
## Il brief
|
||||
|
||||
Sistema "Skyhook" (origine ES / E-mini S&P, genetico, a doppio timeframe), da portare su crypto:
|
||||
- **data2 = 690 min (segnale)**, **data1 = 230 min (esecuzione)**. NB **690 = 3 × 230**.
|
||||
- NON trend-follower: entra **solo** quando coincidono (a) un **regime** di volatilità/volume e
|
||||
(b) un **pattern** di breakout.
|
||||
- Pipeline per barra: indicatori (BuzVola su ATR, BuzVolume su volume, tipo-Chande 0-100) →
|
||||
fasce regime → pattern (Donchian/breakout su data2) → composer (regime AND pattern) →
|
||||
ingresso (max 1/giorno, stop-and-reverse) → uscite (time-based asimmetrico uscitalong=24 /
|
||||
uscitashort=18 + stop/profit).
|
||||
- Ancore demo: trend lineare → **BuzVola=50** (vol steady → neutro), **BuzVolume=100** (volume in rampa).
|
||||
|
||||
## Ricostruzione (fedele + onesta)
|
||||
|
||||
- **Resample dal feed 5m certificato** con `origin='epoch'`: 230 min = 46×5m, 690 min = 138×5m,
|
||||
e i confini 690 sono un **sottoinsieme** dei confini 230 → una barra HTF chiude esattamente su
|
||||
una chiusura LTF. Merge HTF→LTF causale: `merge_asof` backward sulla **chiusura HTF** (≤ chiusura
|
||||
LTF), così una barra HTF è usata solo quando è davvero chiusa. (~2287 barre/anno LTF, ~762 HTF.)
|
||||
- **BuzVola / BuzVolume = `chande01`** (Chande Momentum Oscillator normalizzato 0-100): serie
|
||||
steady → 50, rampa-su → 100, rampa-giù → 0. Le ancore demo sono soddisfatte a livello di
|
||||
indicatore (è la lettura fedele: "vol steady → neutro"). NB: l'EMA-ATR su un *linspace* sintetico
|
||||
dà 100 per drift di warm-up/floating-point, non per comportamento reale — su BTC reale BuzVola
|
||||
oscilla intorno a 50 (EMA-ATR vs SMA-ATR corr 0.90).
|
||||
- **Pattern** = Donchian breakout leak-free (shift(1)) su HTF, `ptn_n` barre (default 13 da 13/13/1).
|
||||
- **Regime** = bande-soglia tunabili su BuzVola/BuzVolume (i magici interi 4/3/2 - 4/2/2 non sono
|
||||
nel brief; ricostruiti come `[vola_lo,vola_hi]` × `[vol_lo,vol_hi]`).
|
||||
- **Composer** = regime AND pattern. **Ingressi** ≤1/giorno (prima barra qualificante).
|
||||
- **Uscite**: time-based asimmetrico (`uscitalong`/`uscitashort` barre LTF) + hard stop/profit. Lo
|
||||
"stop 2000 / profit 5000" in $ del sistema ES → **multipli di ATR LTF** (scale-free): default
|
||||
`sl_atr=2.0`, `tp_atr=5.0` (~ rapporto 40:100 pt ES), con modalità `pct` alternativa.
|
||||
- Engine espresso come **entries `{dir,tp,sl,max_bars}`** per `backtest_signals` (motore onesto del
|
||||
progetto: TP/SL intrabar, max_bars, non-overlap). Causalità verificata con prefix-recompute
|
||||
(0 mismatch).
|
||||
|
||||
## Baseline → V1 (lever scout + grid, inline, veloce)
|
||||
|
||||
- **Baseline** (default 13/13, sl2/tp5, vola[35,95], vol_lo50): causale, fee-surviving, FULL Sharpe
|
||||
BTC +0.91 / ETH +0.64, ma **HOLD-OUT debole** (BTC −0.09 / ETH +0.17) → FAIL del gate onesto.
|
||||
- **Lever scout** (`sweep.py`): gli **short servono** (long_only → HOLD −0.52); il **regime gate
|
||||
conta** (togliere la banda vola → HOLD −0.80); il **floor di volume** a 50 *frenava* l'hold-out
|
||||
(vol_lo=40 o 0 → PASS); **breakout più lento** (ptn_n=55) e **stop più larghi** (sl2.5/tp6)
|
||||
alzano l'hold-out.
|
||||
- **Grid combinato** (`grid.py`): vincitrice **SKH01-V1**
|
||||
`SkyhookParams(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35, vola_hi=95, vol_lo=0.0)`:
|
||||
- **min-asset FULL +0.69, HOLD-OUT +0.64** (BTC 0.64 / ETH 0.64), **PASS**, fee-surviving a 0.30%RT.
|
||||
- BTC FULL +0.69/+275% DD49% ; ETH FULL +1.01/+871% DD31% ; entrambi HOLD-OUT positivi.
|
||||
- **Marginal vs TP01 = ADDS** e regge i gate induriti: **corr 0.06** (ortogonale, NON trend-beta),
|
||||
`has_insample_edge=True` (Sharpe in-sample standalone 1.15), `is_hedge=False`, multi-cut
|
||||
persistente. Blend **0.75·TP01 + 0.25·SKH01: HOLD Sharpe 0.31 → 0.74 (+0.44), DD 11.9%**;
|
||||
blend 50/50 HOLD 0.88, DD 17.8%.
|
||||
- Unico sub-gate fallito: `clean_year_uplift` +0.014 (sotto 0.02) → `earns_slot=False` per un pelo,
|
||||
nonostante tutto il resto sia forte. **Debolezza principale: DD standalone alto (40-49%).**
|
||||
|
||||
→ SKH01 è un **diversificatore quasi-ortogonale** reale (non un TP01 travestito): da solo è
|
||||
volatile, ma come sleeve al 25% migliora moltissimo l'hold-out del portafoglio a DD bassissimo.
|
||||
|
||||
## Onda 1 (`skyhook-improve`, 30 agenti) — winner intermedio
|
||||
|
||||
Famiglie: param (RR, ptn_n, regime bands, exit bars, chande, local), regime-redef (percentile,
|
||||
realized-vol, vol-expansion, LTF), pattern (confirmation, ROC, Keltner, NR, dual), exit + overlay,
|
||||
ognuna verificata da 2 scettici. Risultato: **winner intermedio**
|
||||
`SkyhookParams(ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16, vola_lo=35, vola_hi=95, vol_lo=0)`
|
||||
— **minFull +0.83, minHold +0.81** (vs V1 +0.69/+0.64), causale, fee-surviving 0.30%RT, marginal
|
||||
**ADDS** (corr 0.05, has_insample_edge, robust_oos, multicut, clean_year_uplift +0.37), blend w25
|
||||
uplift_hold +0.58. **MA standalone maxDD ancora 34% (BTC) / 31% (ETH) → l'unico goal mancato era il DD<30%.**
|
||||
|
||||
## Onda 2 (`skyhook-improve-v2`, 14 famiglie DD-reduction) — SKH01-V2-DD vince
|
||||
|
||||
Obiettivo: tagliare il **DD standalone <30%** tenendo hold-out + `earns_slot`, e alzare l'uplift di
|
||||
portafoglio. 14 famiglie (ensemble param/struct, vol-target, DD kill-switch, RR/stop grid, regime
|
||||
tight, percentile, vol-expansion, breakout confirmation, dual-TF, asimmetria L/S, cadenza, chande,
|
||||
Keltner), ognuna verificata da 2 scettici avversariali (window-luck/multicut/jackknife +
|
||||
causalità/fee/plateau/overfit). Esito: **il winner intermedio cade.** Nuovo campione **SKH01-V2-DD**
|
||||
(famiglia ASYM_LS, `src/strategies/skyhook.py:SKH01_V2_DD`, run `runs/SKH2_ASYM_LS.py`):
|
||||
|
||||
- **Config:** stesso SEGNALE del winner (`ptn_n=45, vola_lo=35, vola_hi=95, vol_lo=0, exit-bars 24/16`)
|
||||
ma EXIT commutati da ATR a **percentuale fissa ASIMMETRICA** — long `sl=4% / tp=10%`, short
|
||||
`sl=2% (più stretto) / tp=8%`. Motivazione meccanica: in crypto lo short si fa steamrollare da uno
|
||||
spike vola e lo stop-ATR si allarga lasciando correre la perdita → il %-SL stretto sullo short
|
||||
**cappa la perdita per-trade** che FORMA il maxDD. (Implementato come override per-direzione nel
|
||||
motore, backward-compatible: campi `*_short=None` → comportamento simmetrico invariato.)
|
||||
- **Numeri veri (verificati indipendentemente via `sk.study(SKH01_V2_DD)`):** standalone maxDD
|
||||
**BTC 21.4% / ETH 27.4%** (<30% ✓, vs 34.4/30.5 del winner) — **goal RAGGIUNTO**; minFull **+0.99**,
|
||||
minHold **+1.26**; causalità **0/400** entrambi gli asset; fee@0.30%RT BTC +1.05 / ETH +0.80
|
||||
(positiva anche a 0.40%). Marginal vs TP01 **ADDS** (corr 0.09, has_insample_edge, is_hedge=False,
|
||||
robust_oos, multicut, clean_year_uplift +0.57). **Blend 0.75·TP01 + 0.25·SKH: uplift_hold +0.87**
|
||||
(vs +0.58 del winner); **blend 50/50: full 1.84 / hold 1.59 / DD 10.7%**. earns_slot=True,
|
||||
beats_winner=True. **Plateau reale** (i vicini Spct_mb14/16 sl2% tengono DD 27-28%), non knife-edge.
|
||||
Entrambi gli scettici: holds_up=True, confidence high, killer_finding=null.
|
||||
|
||||
**Top-3 dell'onda 2 (criteri onesti):**
|
||||
|
||||
| # | Famiglia | maxDD (BTC/ETH) | minHold | w25 uplift_hold | Verifica |
|
||||
|---|---|---|---|---|---|
|
||||
| **1** | **ASYM_LS → SKH01-V2-DD** | 27.4% (21.4/27.4) | +1.26 | **+0.87** | 2/2 high, killer=null ✅ |
|
||||
| 2 | ENS_STRUCT (3-regime ensemble) | **22.9%** (21.2/22.8) | +1.00 | +0.67 | 2/2 high — ma 3 motori da eseguire |
|
||||
| 3 | TPSL_DD (%-SL/TP hard) | 28.0% (28/25.5) | +1.11 | +0.75 | 1/1 (rate-limit) — caveat hedge-like |
|
||||
|
||||
**Lezioni anti-DD:**
|
||||
- **Ha funzionato (STRUTTURA dell'exit, non i parametri):** cambiare il MECCANISMO di uscita — %-SL
|
||||
hard, asimmetria L/S, o ensemble di exit/regime diversi (decorrelazione). Il DD del winner nasce
|
||||
dalla coda intra-trade negli spike ATR; il %-SL la cappa.
|
||||
- **NON ha funzionato (la leva non raggiunge il DD vincolante):** DD kill-switch entry-only (sopprime
|
||||
solo le NUOVE entry, non chiude il trade aperto che forma il maxDD → floor 33-36%); vol-target
|
||||
causale (DD<30 e uplift≥0.55 mutuamente esclusivi; cap>1 PEGGIORA il DD levereggiando nel pre-crash);
|
||||
cadenza/FREQ (accorciare gli hold short fa esplodere ETH a 50-66%); dual-TF (LTF è resample dello
|
||||
stesso prezzo → quasi-tautologico, DD invariato).
|
||||
- **Bocciato dagli scettici come overfit:** PATTERN_CONF (sub-30 solo a vola_lo=45, knife-edge: sl_atr
|
||||
±0.5 → ETH 40-47%; la conferma "close_loc" da sola NON taglia il DD). Esempio canonico del perché
|
||||
serviva la doppia verifica.
|
||||
- **Non promuovibili:** PCTL_DD (numeri spettacolari ma **0 verifiche**, le 2 sono morte per rate-limit
|
||||
→ forward-monitor, non fidato); ENS_PARAM / TPSL_DD (battono i gate ma uplift recency/hedge-loaded,
|
||||
concentrato nei regimi TP01-down → forward-monitor).
|
||||
|
||||
**Promozione (questa sessione):** `SKH01_V2_DD` canonico nel motore + override exit-short
|
||||
asimmetrici (backward-compatible, V1/winner invariati) + 3 test nuovi (8/8 pass).
|
||||
|
||||
**Sleeve cablato @0.25 effettivo** (`src/portfolio/sleeves.skyhook_sleeve` → `active_sleeves`): i tre
|
||||
sleeve preesistenti scalati nel restante 0.75 mantenendo il rapporto 55:25:20 → **TP01 41.25% / XS01
|
||||
18.75% / VRP01 15% / SKH01 25%**. Report del portafoglio (4 sleeve, `run_portfolio.py`):
|
||||
|
||||
| | FULL Sharpe | FULL DD | HOLD-OUT Sharpe | HOLD-OUT DD |
|
||||
|---|---|---|---|---|
|
||||
| 3 sleeve (TP01+XS01+VRP01) | 1.68 | 14.3% | 1.63 | 3.4% |
|
||||
| **+ SKH01 @25%** | **2.13** | **7.8%** | **2.30** | 3.5% |
|
||||
| Δ | **+0.45** | **−6.5pt** | **+0.67** | ~0 |
|
||||
|
||||
→ aggiungere Skyhook **alza lo Sharpe full +0.45 e DIMEZZA il DD full (14.3→7.8%)**, e alza l'hold-out
|
||||
+0.67 a DD invariato. Portafoglio combinato: FULL Sh 2.13 / ret +365% / DD 7.8%, HOLD Sh 2.30 / DD 3.5%,
|
||||
positivo ogni anno (2019-26, DD annuo ≤7.8%) vs buy&hold 50/50 FULL Sh 0.93 / DD 76%.
|
||||
|
||||
**Caveat onesti / NON deploy:** è un portafoglio di **ricerca** (peso fisso, no costi di ribilanciamento
|
||||
reale a $600; lo Sharpe daily-step di Skyhook è la convenzione del lens). ETH DD standalone 27.4% ha
|
||||
margine sottile vs 30%. Prima di un eventuale deploy: ri-verificare la causalità sul **codice di
|
||||
esecuzione reale** (qui è l'harness di ricerca) e i costi del book a 230m (ribilanciamento più frequente
|
||||
del resto). XS01/VRP01 restano STAT-MODE/lead. Per ora: research win + sleeve cablato, forward-monitor.
|
||||
@@ -0,0 +1,66 @@
|
||||
# 2026-06-23 — Tail-hedge / protezione DD del combo (incl. OPZIONI): vince la guardia-drawdown
|
||||
|
||||
## Obiettivo (goal utente)
|
||||
Trovare uno sleeve/overlay da AGGIUNGERE al combo (TP01+GTAA) per proteggere il drawdown e gli anni
|
||||
tipo 2022. Valutare anche le opzioni.
|
||||
|
||||
## Diagnosi del rischio (decisiva)
|
||||
Il MaxDD del combo (1x) e' **8.4%** e il 2022 fu **-4.4%**: NON un crash, un **grind lento** (peggior
|
||||
giorno -2.8%, peggior mese 2022 -1.6%). Il doppio trend (TP01+GTAA long-flat) gia' taglia i crash
|
||||
veloci. Il tail residuo = (a) whipsaw da mercato choppy (2022), (b) gap/crash overnight LATENTE (TP01
|
||||
non reagisce intraday, non nel campione storico), (c) la LEVA.
|
||||
|
||||
## Candidati testati (`tail_hedge_lab.py`)
|
||||
| protezione | MaxDD | 2022 | Sharpe | CAGR |
|
||||
|---|---|---|---|---|
|
||||
| combo baseline | 8.4% | -4.4% | 1.48 | 11.3% |
|
||||
| **+ guardia-DD -4%** | **5.8%** | **-1.8%** | 1.38 | 9.2% |
|
||||
| + vol-target 5% | 8.4% | -5.9% | 1.46 | 8.0% |
|
||||
| + opzioni (put/put-spread, budget 3%/y) | 8.4% | -4.4% | 1.48 | 11.3% |
|
||||
|
||||
### OPZIONI (valutate): NON adatte al 2022
|
||||
- Put-spread/long-put LONG su indice 50/50 BTC/ETH (mirror di VRP01), premio BS su DVOL reale, payoff
|
||||
sul path. Strike corretti (compra -0.30delta, vende -0.10delta).
|
||||
- Sempre-on costa **~50%/anno** di premio -> con budget 3%/anno size ~0.06-0.10x = effetto ~nullo.
|
||||
- Nel 2022 (grind, niente crash settimanali) **sanguinano** (scadono inutili) -> Δ2022 ~0.
|
||||
- Pagano SOLO nei crash secchi: stress -30% overnight -> put paga **+25% netto**, put-spread +3.8%.
|
||||
- Verdetto: assicurazione BLACK-SWAN cara e fuori-bersaglio per il grind. Utile solo come piccola
|
||||
copertura del gap overnight latente, NON come fix del 2022.
|
||||
|
||||
### GUARDIA DRAWDOWN: centra il rischio
|
||||
De-risk (esposizione 1.0->0.4) quando il DD da picco supera -4%, ri-rischia a -1.6%. Targetizza il
|
||||
grind: MaxDD 8.4%->5.8%, 2022 -4.4%->-1.8%, ogni anno DD intra <=5.4%. Costo: Sharpe 1.48->1.38,
|
||||
CAGR -2.1pp (de-risca sui cali, perde rimbalzo — prezzo onesto della protezione).
|
||||
|
||||
### A LEVA (dove il tail morde)
|
||||
guard applicato pre-leva: 2x 2022 -15.6%->-10.9%, MaxDD 28%->24%; 3x resta MARGIN-CALL (DD 39%>=33%).
|
||||
-> la protezione rende il 2x sopportabile; il 3x va evitato comunque.
|
||||
|
||||
## Raccomandazione
|
||||
AGGIUNGERE una **guardia-drawdown a livello di portafoglio** al combo (overlay, niente premio):
|
||||
e' la protezione che colpisce il rischio reale (grind/2022) a costo Sharpe minimo. Le opzioni NO come
|
||||
fix del 2022; eventualmente una micro-allocazione deep-OTM come assicurazione black-swan separata.
|
||||
Vol-target NON aiuta (il 2022 non e' uno spike di vol).
|
||||
|
||||
## Onesta'
|
||||
- Il guard e' REATTIVO (de-risca dopo l'inizio del DD, restituisce un po' di rimbalzo) -> costa CAGR.
|
||||
- Trade: -2.1pp CAGR per dimezzare il MaxDD e azzerare quasi il 2022. Sensato se la priorita' e' il DD.
|
||||
- Parametri (-4% trigger) semplici; il meccanismo (non la soglia esatta) e' la sostanza.
|
||||
Script: tail_hedge_lab.py.
|
||||
|
||||
## Aggiornamento — protezioni CLASSICHE (stop-loss), goal "prova anche SL" (`stops_lab.py`)
|
||||
Confronto equo (trigger/re-entry sul NAV di mercato, non sull'equity congelata):
|
||||
| protezione | Sharpe | MaxDD | 2022 | CAGR | in-mkt |
|
||||
|---|---|---|---|---|---|
|
||||
| baseline | 1.48 | 8.4% | -4.4% | 11.3% | 100% |
|
||||
| **soft-guard -4% (0.4x)** | **1.38** | **5.8%** | **-1.8%** | 9.2% | 100% |
|
||||
| trail-stop -4% (uscita tot.) | 1.07 | 7.5% | +0.0% | 6.6% | 42% |
|
||||
| trail-stop -6% (re:newhigh) | 1.34 | 6.6% | -2.1% | 9.0% | 72% |
|
||||
| trail-stop -8% | 1.41 | 8.3% | -4.2% | 10.0% | 87% |
|
||||
| stop mensile -5% | 1.48 | 8.4% | -4.4% | 11.3% | 100% (mai scatta) |
|
||||
| vol-stop (>90pctl) | 1.48 | 8.4% | -4.4% | 10.4% | 100% |
|
||||
|
||||
VERDETTO: lo SL classico funziona solo a -6% (e resta inferiore al soft-guard); a -4% fa WHIPSAW
|
||||
(Sh 1.48->1.07, fuori mercato 58%) perche' l'uscita TOTALE viene choppata nel grind. Il soft-guard
|
||||
alla stessa soglia non whippa (de-risk parziale 0.4x). Stop mensile/vol inutili (bersaglio sbagliato).
|
||||
Conferma: per un DD da grind, de-risk PARZIALE > stop-loss duro. Soft-guard -4% confermato come scelta.
|
||||
@@ -0,0 +1,69 @@
|
||||
# 2026-06-26 — Cash-and-carry (basis trade) delta-neutral su HL (CC01): premio REALE, Sharpe ARTEFATTO, NON deployabile
|
||||
|
||||
**Goal utente:** dopo lo scarto del gamma scalping, "provare" gli altri due angoli proposti —
|
||||
funding-carry cross-sectional e basis/cash-and-carry. Il **funding cross-sectional (FC01) è già
|
||||
SCARTATO** (diario 2026-06-22: DILUTES, sign-flip su un nome). L'angolo non testato è il
|
||||
**cash-and-carry delta-neutral (CC01)**. Branch `research/gamma-scalp-options`,
|
||||
script `scripts/research/cash_carry_hl.py`.
|
||||
|
||||
## Meccanismo (diverso da FC01)
|
||||
|
||||
FC01 pickava cross-section (short alto-funding / long basso-funding) → scommessa relativa, fragile.
|
||||
CC01 **non picka**: harvesta il premio di funding AGGREGATO restando delta-neutral sullo *stesso*
|
||||
asset. Long spot + short perp (stessa size): le gambe lineari nel prezzo si cancellano → ritorno
|
||||
≈ **+funding** (lo short incassa il funding quando f>0), zero esposizione direzionale. Dati: funding
|
||||
orario reale HL dal 2023-05 (`hlfund_*_1h.parquet`), gli stessi 19 major di XS01.
|
||||
|
||||
## Risultato grezzo — e perché NON va creduto
|
||||
|
||||
| config | Sharpe | CAGR | maxDD | per-anno |
|
||||
|---|---|---|---|---|
|
||||
| BTC/ETH CC-static | **+12.9** | +13% | 0.8% | 2023:+10 2024:+23 2025:+8 2026:+1% |
|
||||
| BTC/ETH CC-gated | +13.9 | +14% | 0.3% | tutti positivi |
|
||||
| 19-major CC-gated | +11.6 | +16% | 0.4% | tutti positivi |
|
||||
|
||||
**Uno Sharpe di 13 con DD 0.3% è un ALLARME, non un edge.** È la firma di un modello che cattura
|
||||
solo un cashflow liscio (il funding) e azzera la volatilità reale. Il marginal scorer dà **ADDS /
|
||||
robust_oos / has_insample_edge=True / multicut_persistent** su OGNI config — ma questo **espone un
|
||||
punto cieco dello scorer**: si fida della vol riportata dal candidato e non ha un gate "Sharpe
|
||||
implausibile → rischio nascosto". Un Sharpe di 13 dovrebbe squalificare-e-indagare, non passare.
|
||||
|
||||
## Il rischio è NASCOSTO, non assente
|
||||
|
||||
- **Mark-to-market della base** (`premium` col → `r = funding − Δpremium`): sgonfia lo Sharpe solo
|
||||
13→11. La colonna premium di HL è già lisciata/clampata → a risoluzione daily il basis-vol è
|
||||
minimo. **Quindi il basis-from-data NON è il rischio vero.**
|
||||
- **I rischi che giustificherebbero uno Sharpe realistico (~1-3) sono ASSENTI dal dataset:**
|
||||
1. **2022 mancante.** Lo storico funding parte 2023-05 → niente deleveraging LUNA/3AC/FTX, il
|
||||
regime di funding profondamente negativo + basis blow-out che farebbe il vero drawdown.
|
||||
2. **Procyclicità.** Funding aggregato BTC/ETH: +23% nel toro 2024 → **+1.7% nel bear 2026**
|
||||
(e 19-major CC-static 2026 = **−1%**). Solo 10% di giorni a funding negativo *in-sample* — ma
|
||||
in un vero bear quella frazione esplode. Il carry **non è all-weather**: è un risk-on premium.
|
||||
3. **Liquidazione dello short** in uno squeeze e **slippage** su spot+perp: non modellati.
|
||||
- **Sharpe reale di un crypto basis-trade ≈ 1-3 con code brusche** (Mar-2020, Mag-2021, FTX), non 13.
|
||||
|
||||
## Eseguibilità a $600 — muro indipendente
|
||||
|
||||
Serve spot **+** perp per gamba: BTC/ETH = 4 posizioni, 19-major = 38. Su Deribit (dove operiamo) lo
|
||||
storico funding è **bloccato** (404) e non tradiamo spot HL. **STAT-MODE**, come XS01/VRP01 ma peggio
|
||||
(doppia gamba per asset). Non deployabile a questo capitale.
|
||||
|
||||
## Verdetto
|
||||
|
||||
**CC01 = premio di funding REALE (~+8-14%/anno aggregato, positivo ogni anno in-sample) e
|
||||
genuinamente ortogonale a TP01 (corr ~0.05, NON direzionale).** È la "fonte di ritorno diversa" più
|
||||
concreta trovata finora. MA:
|
||||
- lo **Sharpe headline è fiction** (rischi di coda strutturalmente fuori dal dataset);
|
||||
- è **procyclico** (si comprime/inverte nel bear — proprio quando servirebbe);
|
||||
- **non eseguibile** a $600 / sul nostro venue.
|
||||
|
||||
→ **LEAD da rivedere a scala** (capitale ~20k+ e un venue con funding eseguibile), **non uno sleeve**.
|
||||
Nessun impatto sul book live (branch separato).
|
||||
|
||||
## Sottoprodotto metodologico (raccomandazione)
|
||||
|
||||
CC01 ha passato ogni gate del marginal scorer pur essendo un'illusione di rischio. **Prossima
|
||||
indurita dello scorer:** un gate di *plausibilità dello Sharpe* — uno standalone Sharpe ≫ 4 deve
|
||||
declassare a "SUSPECT/INVESTIGATE" invece di alimentare ADDS, perché segnala vol sottostimata
|
||||
(mark-to-cashflow, dati che escludono il regime di stress, leva nascosta). Documentato qui, non
|
||||
implementato in questa passata per non toccare lo scorer condiviso su `main`.
|
||||
@@ -0,0 +1,58 @@
|
||||
# 2026-06-26 — "Short su fondamentali/notizie NEG ma prezzo SU": screener forward, edge non provato
|
||||
|
||||
**Goal utente (chiarito):** se i dati finanziari/notizie di un'azienda sono negativi ma la
|
||||
quotazione è positiva (sale), andare SHORT — scommessa che il prezzo scenda a riallinearsi ai
|
||||
fondamentali. Branch `research/equity-meanrev-ib`, script `scripts/research/eq_fundnews_short.py`.
|
||||
|
||||
## Gate dati (lezione v2.0.0) — NON backtestabile
|
||||
|
||||
I fondamentali e le notizie scaricabili dalla rete (Yahoo) sono **snapshot CORRENTI**, non
|
||||
point-in-time storici. Applicarli a prezzi passati = **look-ahead** (restatement + survivorship):
|
||||
esattamente la classe di errore che ha prodotto la libreria fasulla v2.0.0. Un backtest onesto
|
||||
richiede un DB point-in-time (Compustat PIT, news storiche con timestamp) — **assente**. Quindi,
|
||||
come per la vol term-structure: **niente backtest**, l'unica via onesta è uno **screener forward**.
|
||||
|
||||
## Cosa è scaricabile (feasibility, fatto)
|
||||
|
||||
- **Fondamentali strutturati** via Yahoo quoteSummary (flusso cookie+**crumb**, tokenless):
|
||||
`recommendationMean` (1=buy..5=sell), `revenueGrowth`, `earningsHistory` surprise%,
|
||||
`recommendationTrend` (skew buy/sell). ✅
|
||||
- **Notizie**: Yahoo news search (headline + publisher), sentiment lessicale crudo. ✅ (proxy debole)
|
||||
- **Momentum**: chart API (return 1m/3m). ✅
|
||||
|
||||
## Screener costruito + eseguito dal vivo
|
||||
|
||||
`fund_neg` ∈ [0,1] (media di: recMean→sell, surprise negative, ricavi in calo, analisti a sell) +
|
||||
`news_neg` lessicale + momentum. **SHORT candidate = (fond/news neg) AND prezzo in salita** (la
|
||||
divergenza). Idempotente, logga forward su `data/raw/fundnews_short_screen.parquet`.
|
||||
|
||||
**Run di oggi (2026-06-26): NESSUN candidato.** Quasi tutto l'universo ha momentum 1m **negativo**
|
||||
(mercato in flessione ampia, coerente col bear) → la gamba "prezzo che sale" non scatta. I peggiori
|
||||
per fondamentali (TSLA 0.48, BA 0.39, NKE 0.38) stanno comunque *scendendo*. Output sensato.
|
||||
|
||||
## L'intuizione analitica chiave (il valore vero di questa analisi)
|
||||
|
||||
La premessa ha una **tensione interna** che la rende la versione *rischiosa* dell'anomalia:
|
||||
- **Shortare un prezzo che SALE combatte il momentum** — una delle anomalie più robuste e persistenti
|
||||
(i winner continuano a vincere su 3-12 mesi). Si scommette contro di essa.
|
||||
- **Il PEAD** (post-earnings drift) dice che dopo cattive notizie il prezzo continua a **scendere**;
|
||||
ma qui il prezzo *sale malgrado* le cattive notizie → spesso significa che il mercato sta
|
||||
**prezzando informazione** che i fondamentali *trailing* non contengono (turnaround, M&A, squeeze).
|
||||
- Quindi: la versione *pulita* dell'edge "short su fondamentali deboli" funziona meglio quando il
|
||||
prezzo **conferma** (fondamentali deboli + prezzo che SCENDE = momentum e fondamentali allineati),
|
||||
**non** quando diverge. La richiesta "shorta il titolo che sale su brutte notizie" è il caso
|
||||
contrarian, più difficile e storicamente più punito (squeeze, perdita illimitata).
|
||||
|
||||
## Eseguibilità (muro)
|
||||
|
||||
Short = **borrow** (locate + fee; hard-to-borrow caro/assente), **perdita illimitata**, **squeeze**,
|
||||
**PDT $25k** per i day-trade, IB instabile qui, $600, universo single-stock (non i nostri ETF). NON
|
||||
deployabile.
|
||||
|
||||
## Verdetto
|
||||
|
||||
**Non un edge schierabile, né dimostrabile ora.** Deliverable onesto = lo **screener forward**
|
||||
(funzionante, da dati di rete) + il log che accumula i candidati per una validazione futura, con
|
||||
la chiara avvertenza che l'edge è **non provato** e che la *direzione contrarian* della scommessa
|
||||
(short sulla forza) è la parte fragile. Strumento utile (fond+news+momentum da rete in un colpo),
|
||||
tesi da maneggiare con scetticismo. Nessun impatto sul book live (branch separato).
|
||||
@@ -0,0 +1,57 @@
|
||||
# 2026-06-26 — Scalping azioni IB "quando sottoquotate" + check dati dalla rete
|
||||
|
||||
**Goal utente:** su IB, comprare azioni quando "sottoquotate" (oversold/sotto fair-value), con
|
||||
verifica incrociata dei dati dalla rete. Branch `research/equity-meanrev-ib`,
|
||||
script `scripts/research/eq_meanrev_ib.py`. Due pezzi.
|
||||
|
||||
## 1) CHECK DATI DALLA RETE — il pezzo richiesto (e perfettamente in-metodo)
|
||||
|
||||
Confronto i dati certificati IB (`data/raw/eq_*_1d.parquet`, ADJUSTED_LAST) con una **sorgente di
|
||||
rete indipendente** (Yahoo Finance chart API, tokenless). È la lezione fondante del progetto: non
|
||||
fidarti di un feed solo (il disastro v2.0.0 era un feed contaminato).
|
||||
|
||||
**Risultato (180g, BTC… no: 6 ETF):** dopo l'allineamento corretto **tutti CONCORDE**, deviazione
|
||||
sui rendimenti **≤1.2bps**, ultimo close identico (0.0bps). Il feed IB è validato.
|
||||
|
||||
**La lezione (errore che ho fatto e corretto):** un primo confronto naïf (nostro close *adjusted*
|
||||
vs `close` *grezzo* di Yahoo) ha **falso-allarmato 4/6 ticker** a 30-52bps (SPY 30, TLT 40, HYG 52).
|
||||
Le divergenze erano **tutte su singoli giorni = stacco dividendo**: confrontavo dividend-adjusted vs
|
||||
non-adjusted. Usando l'`adjclose` di Yahoo (apples-to-apples) → ~0bps. **Regola: ogni divergenza va
|
||||
SPIEGATA prima di gridare "feed sporco" — e prima di tradarci sopra.** Lo strumento resta come
|
||||
validatore di feed riutilizzabile / template del pre-trade price-check live.
|
||||
|
||||
## 2) "SCALPING quando sottoquotata" — non testabile/eseguibile; testato lo swing MR
|
||||
|
||||
Lo **scalping intraday non è valutabile**: (a) non abbiamo dati intraday (solo `eq_*_1d`), (b) non
|
||||
è eseguibile — **PDT rule**: il day-trading di azioni US sotto **$25.000** è limitato a 3
|
||||
day-trade/5gg → lo scalping è *regolatoriamente bloccato* al capitale del progetto (l'analogo equity
|
||||
del muro STAT-MODE a $600 sul crypto), (c) IB Gateway è instabile qui (timeout ordini diagnosticato).
|
||||
|
||||
Versione onesta e testabile = **swing mean-reversion** "compra quando sottoquotata": Connors RSI2<10
|
||||
+ filtro trend MA200, exit a MA5. Causale (segnale ≤ close[i], entry a close[i]), netto fee, hold-out 2015+:
|
||||
|
||||
| ETF | FULL Sh | DD | CAGR | expo | HOLD Sh | **B&H HOLD Sh** |
|
||||
|---|---|---|---|---|---|---|
|
||||
| SPY | +0.75 | 15% | +5% | 13% | +0.70 | **+0.81** |
|
||||
| QQQ | +0.50 | 16% | +4% | 14% | +0.56 | **+0.92** |
|
||||
| IWM | +0.32 | 22% | +2% | 13% | +0.15 | +0.53 |
|
||||
| DIA | +0.38 | 13% | +2% | 14% | +0.55 | +0.74 |
|
||||
| EEM | +0.41 | 20% | +3% | 13% | +0.36 | +0.46 |
|
||||
|
||||
Fee sweep SPY: Sh 0.82 (0bps) → 0.75 (3) → 0.70 (5) → 0.58 (10bps RT).
|
||||
|
||||
**Lettura onesta:** la mean-reversion "buy the dip" sugli indici equity ha un edge **reale ma
|
||||
modesto** (Sharpe ~0.5-0.75, investito solo ~13% dei giorni) **che NON batte il buy&hold
|
||||
risk-adjusted** (B&H hold-out 0.81 su SPY vs 0.70 della MR), con CAGR piccolo (2-5%) e sensibile ai
|
||||
costi. È capital-efficient in teoria (cash 87% del tempo) ma il valore aggiunto vs "tieni l'indice"
|
||||
è negativo o nullo, e la frequenza *scalping* che potrebbe aggiungere valore è bloccata dalla PDT.
|
||||
|
||||
## Verdetto
|
||||
|
||||
- **Check dati dalla rete: FATTO e utile** — feed IB validato (≤1.2bps vs Yahoo); strumento
|
||||
riutilizzabile. Unico vero keeper della sessione equity.
|
||||
- **Scalping "sottoquotate": NON deployabile** — non testabile (no intraday), non eseguibile (PDT
|
||||
$25k, IB instabile). Lo swing MR daily testabile **non batte il buy&hold** → niente edge schierabile.
|
||||
|
||||
Conferma il pattern del progetto: l'idea promettente sopravvive solo finché non incontra
|
||||
eseguibilità + benchmark onesto. Nessun impatto sul book live crypto (branch separato).
|
||||
@@ -0,0 +1,74 @@
|
||||
# 2026-06-26 — "Scalping BTC/ETH con copertura in opzioni" = gamma scalping → SCARTATO
|
||||
|
||||
**Domanda (goal utente):** analizzare lo scalping su BTC/ETH con copertura in opzioni come
|
||||
possibile nuovo sleeve. **Esito: negativo, strutturale, su tutte le frequenze. Non è uno sleeve,
|
||||
non è nemmeno un hedge.** Branch `research/gamma-scalp-options`, script
|
||||
`scripts/research/options_gamma_scalp.py`.
|
||||
|
||||
## Cos'è davvero "scalping con copertura in opzioni"
|
||||
|
||||
L'interpretazione rigorosa è il **gamma scalping**: compri un'opzione (la *copertura* = long
|
||||
gamma), delta-hedgi il sottostante a cadenza fissa (lo *scalp*), e il P&L netto è
|
||||
≈ dollar-gamma × (vol realizzata² − vol implicita²). È lo **specchio esatto del VRP01** (short-vol):
|
||||
VRP01 incassa IV−RV (positivo in media), il gamma scalping incassa **RV−IV** (negativo in media).
|
||||
|
||||
Modello (mirror della struttura VRP per comparabilità): long straddle ATM, tenor 7g, IV = DVOL
|
||||
Deribit (la stessa fonte del VRP), delta-hedge sui prezzi certificati, fee opzioni cap 12.5% del
|
||||
premio + fee perp 0.05%/lato sull'hedge turnover, return-on-notional vol-targeted 20%.
|
||||
|
||||
## La diagnostica strutturale (il cuore)
|
||||
|
||||
| asset | IV (DVOL) | RV 1d | IV−RV 1d | RV 1h | IV−RV 1h |
|
||||
|---|---|---|---|---|---|
|
||||
| BTC | 60.9% | 56.0% | **+4.9pp** | 64.5% | −3.6pp |
|
||||
| ETH | 74.9% | 76.0% | −1.0pp | 81.0% | −6.1pp |
|
||||
|
||||
- A **rehedge giornaliero** l'IV ≈ o > la RV → il long gamma **paga il VRP** (sei sul lato sbagliato
|
||||
del premio). Perde gross, prima di ogni fee.
|
||||
- A **rehedge orario** la RV *supera* l'IV (BTC +3.6pp, ETH +6.1pp gross) — sembrerebbe un edge…
|
||||
**ma è una trappola doppia:** (a) la RV_1h è gonfiata da microstruttura (bid-ask bounce), quindi
|
||||
il "gross edge" è in parte fittizio; (b) il rehedge orario paga **24× la fee di hedge**, che
|
||||
spazza via qualunque edge intraday reale.
|
||||
|
||||
## I numeri (ogni variante, ogni anno, ogni frequenza: PERDE)
|
||||
|
||||
| variante | rehedge | Sharpe | CAGR | per-anno |
|
||||
|---|---|---|---|---|
|
||||
| NAKED | 1d | **−3.99** | −56% | 2021..2026 tutti −25..−68% |
|
||||
| CHEAP-GATED (IVr<0.30) | 1d | −3.05 | −47% | tutti negativi |
|
||||
| RICH-SKIP (IVr>0.90) | 1d | −3.92 | −55% | tutti negativi |
|
||||
| NAKED | 1h | **−6.06** | −71% | tutti −38..−81% (peggio: le fee) |
|
||||
| CHEAP-GATED (IVr<0.30) | 1h | −4.96 | −64% | tutti negativi |
|
||||
|
||||
Il gate "compra vol solo quando è a sconto" (lo specchio del gate vincente del VRP, *vendi vol solo
|
||||
quando è cara*) **migliora ma non salva**: −3.05 invece di −3.99. Il segno resta negativo perché
|
||||
l'IV-rank basso non garantisce RV > IV su 7 giorni.
|
||||
|
||||
## Scoring marginale vs TP01
|
||||
|
||||
Tutte le varianti: **DILUTES**, `is_hedge=False`, `has_insample_edge=False` (IS Sharpe −3..−4.5),
|
||||
blend-25 uplift hold −1.2/−1.5. Non aiuta nemmeno come hedge: perde sia quando TP01 è su
|
||||
(uplift −1.2..−1.6) sia quando TP01 è giù (uplift −2.5..−3.2). Corr a TP01 ≈ 0 ma il drift negativo
|
||||
domina la matematica di diversificazione.
|
||||
|
||||
## Eseguibilità a ~$600 (muro indipendente)
|
||||
|
||||
- **BTC**: contratto 1 BTC, min 0.1 BTC → notional minimo **$5.968** ≫ $600 → **NON eseguibile**.
|
||||
- **ETH**: min 0.1 ETH → **$157** → marginalmente OK, ma un solo straddle = ~25% del conto in premio.
|
||||
|
||||
Anche se l'edge esistesse, BTC è fuori e ETH è troppo grosso per diversificare.
|
||||
|
||||
## Conclusione
|
||||
|
||||
Lo "scalping con copertura in opzioni" è **schiacciato tra due muri**: rehedge lento → paghi il
|
||||
premio (RV < IV); rehedge veloce → paghi le fee (24×/day). **Nessuna frequenza vince.** Conferma
|
||||
due lezioni di prim'ordine del progetto, ora quantificate sul long-vol:
|
||||
1. **Le fee uccidono lo scalping** (il rehedge orario, vera frequenza di scalping, è la variante
|
||||
*peggiore*: −6 Sharpe).
|
||||
2. **Vol-da-modello è fragile in entrambe le direzioni.** Regola gemella del VRP: *niente long-vol
|
||||
scalp da modello in deploy*.
|
||||
|
||||
**Il VRP01 (lato short, gated, rischio-definito) resta l'unico edge opzioni del progetto** —
|
||||
e funziona proprio perché sta sul lato *giusto* dello stesso premio che il gamma scalp paga.
|
||||
|
||||
SCARTATO. Script preservato come riferimento. Nessun impatto sul book live (branch separato).
|
||||
@@ -0,0 +1,66 @@
|
||||
# 2026-06-26 — TP01 × DVOL vol-targeting: NON migliora (il taglio di DD è solo de-levering)
|
||||
|
||||
**Goal utente:** "continua" ad analizzare strategie. Dopo i tre angoli di diversificazione tutti
|
||||
STAT-MODE (gamma scalp scartato, funding cross-sectional già morto, cash-and-carry lead non
|
||||
deployabile), ho puntato sull'angolo a **più alto valore perché ESEGUIBILE**: migliorare il
|
||||
risk-sizing del book **live** TP01 (BTC/ETH perp Deribit, già armato). Branch
|
||||
`research/gamma-scalp-options`, script `scripts/research/tp01_dvol_overlay.py`.
|
||||
|
||||
## Ipotesi
|
||||
|
||||
TP01 vol-targeta sulla vol **realizzata** 30g (backward-looking). Il **DVOL** (vol implicita 30g
|
||||
Deribit, forward-looking, che spesso anticipa i salti di vol) come denominatore → de-risking più
|
||||
tempestivo prima dei crash → DD più basso / hold-out migliore senza affondare il FULL.
|
||||
|
||||
Onestà: DVOL parte 2021-03 → confronto TUTTE le varianti sulla **finestra comune 2021-04 → 2026**
|
||||
(1891g, perdo il toro 2019-2020 pre-DVOL). Causale, fee 0.10% RT, config canonica TP01.
|
||||
|
||||
## Risultati (finestra comune, hold-out 2025+)
|
||||
|
||||
| variante (denominatore vol) | FULL Sh | FULL DD | CAGR | HOLD Sh |
|
||||
|---|---|---|---|---|
|
||||
| **realized** (baseline) | **+0.75** | 12.3% | +8% | +0.30 |
|
||||
| dvol (implicita) | +0.70 | 9.3% | +6% | +0.33 |
|
||||
| blend 50/50 | +0.72 | 10.4% | +7% | +0.32 |
|
||||
| max(realiz, dvol) | +0.70 | 9.2% | +6% | +0.36 |
|
||||
| derisk (gate DVOL>p90 ×0.5) | +0.75 | 12.3% | +8% | +0.30 |
|
||||
|
||||
Le varianti DVOL **abbassano il DD** (12.3% → ~9.2%) ma **anche** Sharpe FULL (−0.04/−0.05) e CAGR
|
||||
(8% → 6%). Lo Sharpe è scale-invariant → sospetto: il taglio di DD è solo *posizioni più piccole*
|
||||
(l'IV > RV per il VRP → denominatore più grande → leva minore), non vero timing.
|
||||
|
||||
## Il controllo decisivo
|
||||
|
||||
Confronto le varianti DVOL col **realized a target_vol RIDOTTO** (stesso de-levering, senza DVOL):
|
||||
|
||||
| | FULL Sh | DD | CAGR | HOLD Sh |
|
||||
|---|---|---|---|---|
|
||||
| **max-DVOL** | +0.70 | 9.2% | +6% | +0.36 |
|
||||
| **realized @ vol-tgt 15%** | **+0.75** | 9.4% | +6% | +0.30 |
|
||||
| **realized @ vol-tgt 13%** | +0.75 | **8.1%** | +5% | +0.30 |
|
||||
|
||||
**Conclusivo:** il realized a 15% eguaglia il DD del max-DVOL (9.4% ≈ 9.2%) a Sharpe **più alto**
|
||||
(0.75 > 0.70) e stesso CAGR; a 13% fa DD ancora più basso (8.1%), sempre a 0.75. **Il taglio di DD
|
||||
del DVOL è interamente "leva minore", replicabile MEGLIO con un semplice target_vol più basso.**
|
||||
|
||||
## Verdetto
|
||||
|
||||
**DVOL vol-targeting NON migliora TP01.** Risk-adjusted è marginalmente *peggiore* (FULL 0.70 vs
|
||||
0.75 a parità di DD). L'unico residuo a favore — hold-out +0.06 (max 0.36 vs 0.30) — è **single-window**
|
||||
su 5 anni scarsi di storia DVOL: sotto la soglia di **persistenza multi-cut** del progetto (la lezione
|
||||
che ha ucciso 13/14 falsi positivi nello sweep alt). Il gate **DVOL-spike de-risk è RIDONDANTE col
|
||||
trend**: quando il DVOL esplode (crash) TP01 è già flat (momentum negativo → posizione 0) → il gate
|
||||
non tocca nulla (Δ 0.00 su tutto). Niente da deployare.
|
||||
|
||||
## Lezioni
|
||||
|
||||
1. **Per meno DD sul book live, la leva giusta è `target_vol`, non un overlay DVOL.** Abbassare TP01
|
||||
da vol-tgt 20% → 15% taglia il DD 12.3% → ~9.4% allo **stesso Sharpe** (0.75). Dial pulito e noto,
|
||||
se mai si volesse un profilo più difensivo (oggi 20% è la scelta canonica — invariata).
|
||||
2. **La vol implicita non batte la realizzata come denominatore di sizing per un trend long-flat**:
|
||||
il segnale di trend gestisce già il de-risking direzionale; il denominatore fissa solo la
|
||||
magnitudine, e la realizzata lo fa marginalmente meglio per unità di rischio.
|
||||
3. Conferma indiretta del soffitto: anche un raffinamento "ovvio" e *eseguibile* su TP01 non produce
|
||||
alpha; al massimo sposta il punto sulla stessa frontiera rischio/rendimento.
|
||||
|
||||
SCARTATO come miglioria. Nessun impatto sul book live (branch separato, config canonica invariata).
|
||||
@@ -0,0 +1,47 @@
|
||||
# 2026-06-26 — Calendar-vol / term-structure DVOL: il DATO STORICO NON ESISTE (data-first gate)
|
||||
|
||||
**Goal utente:** "continua" — angolo scelto: scaricare e certificare la term-structure DVOL per
|
||||
testare un calendar-vol (front IV vs back IV). **Esito: bloccato a monte dal gate "il dato prima
|
||||
della strategia" (lezione v2.0.0).** Branch `research/gamma-scalp-options`.
|
||||
Script: `scripts/research/probe_vol_termstructure.py` (scan) + `log_vol_termstructure.py` (forward).
|
||||
|
||||
## Scan di fattibilità (prima di costruire qualunque backtest)
|
||||
|
||||
Cosa l'API pubblica Deribit espone davvero per la vol per-scadenza:
|
||||
|
||||
| dato | disponibile? |
|
||||
|---|---|
|
||||
| Snapshot CORRENTE della term-structure ATM (mark_iv per scadenza) | **SÌ** (book_summary_by_currency, tokenless) |
|
||||
| DVOL storico | solo **30g** fisso — nessun indice 7g/60g/90g pubblico |
|
||||
| Trade-history IV per strumento | solo per strumenti **VIVI** (gli scaduti spariscono) |
|
||||
| **Serie storica continua front-vs-back IV** | **NO** — il front-month rotola/espira → irricostruibile |
|
||||
|
||||
**Snapshot oggi (2026-06-26), per riferimento:**
|
||||
- BTC spot $59.9k: 7g 45.6% · 30g 43.2% · 60g 42.7% · 90g 42.9% · 180g 44.5% — **contango** lieve.
|
||||
- ETH spot $1.58k: 7g 57.6% · 30g 56.4% · 90g 57.5% · 180g 59.2% — contango (+1.6pp slope).
|
||||
- (I valori sono interpolati a tenor fissi: il front a 1-giorno grezzo, 38%/54%, è rumore escluso.)
|
||||
|
||||
## Verdetto
|
||||
|
||||
**Un calendar-vol NON è backtestabile ora su dati certificati.** La storia per-scadenza non è
|
||||
pubblica su Deribit (la nostra fonte di verità), e — coerentemente con la metodologia — **nessun
|
||||
edge va creduto senza backtest OOS su dati certificati**. Costruire un calendar-vol su uno snapshot
|
||||
singolo o su una storia ricostruita-a-pezzi sarebbe esattamente il tipo di scorciatoia che ha
|
||||
prodotto la libreria fasulla v2.0.0. Quindi: **stop, niente backtest.**
|
||||
|
||||
## L'unica via legittima: costruire il dato in avanti
|
||||
|
||||
`scripts/research/log_vol_termstructure.py` — logger forward idempotente: ogni run prende lo snapshot
|
||||
ATM, lo interpola ai tenor fissi {7,30,60,90,180}g e appende una riga/asset a
|
||||
`data/raw/vol_term_<asset>.parquet`. Seminate le prime righe BTC/ETH oggi. Test offline
|
||||
`tests/test_vol_termstructure.py` (interpolazione pura).
|
||||
|
||||
**Non auto-cablato in cron** (è ricerca forward, e non tocco il cron live senza ok). Per accumulare:
|
||||
aggiungere una riga giornaliera al crontab. Dopo **~6-12 mesi** → certificare (monotonia, spike,
|
||||
cross-venue) e SOLO ALLORA testare front-vs-back. Prima, nulla.
|
||||
|
||||
## Lezione
|
||||
|
||||
Il data-first gate ha funzionato: ha fermato un backtest su un dato che non esiste, invece di
|
||||
produrre un numero non falsificabile. La term-structure è in **contango** oggi (regime calmo) —
|
||||
osservazione da forward-monitor, non un edge. Nessun impatto sul book live (branch separato).
|
||||
@@ -0,0 +1,118 @@
|
||||
# 2026-06-29 — DVOL come segnale DIREZIONALE/REGIME su BTC/ETH: l'unico edge è un HEDGE, non alpha
|
||||
|
||||
**Filone A — "DVOL-direzionale".** Tesi: usare la vol IMPLICITA Deribit (DVOL, `data/raw/dvol_*.parquet`,
|
||||
2021-03→oggi) come **segnale direzionale o di regime** sul perp BTC/ETH (long-flat o L/S sul LIVELLO di
|
||||
mercato), non come denominatore del vol-target. Script `scripts/research/dvol_directional.py`, test
|
||||
`tests/test_dvol_directional.py`. Branch `research/equity-meanrev-ib` (separato, nessun impatto live).
|
||||
|
||||
## Cosa è diverso dal lavoro DVOL precedente
|
||||
|
||||
- `tp01_dvol_overlay.py` (2026-06-26): DVOL come **denominatore** del vol-target → solo de-levering, SCARTATO.
|
||||
- VOL03/04/10/11 (sweep alt 100-agent): DVOL che **gata/scala un TSMOM** → eredita lo Sharpe di trend di
|
||||
TP01 (il marginal scorer li boccia NEUTRAL/REDUNDANT).
|
||||
- `agent_14_dvol_spread` (onda ortho): IV **relativa BTC-vs-ETH**, market-NEUTRAL 2-leg (l'unico LEAD vivo).
|
||||
|
||||
Qui invece: DVOL/IV-RV come segnale **direzionale STANDALONE sul livello di mercato**, per vedere se porta
|
||||
alpha direzionale **ortogonale** a TP01 (non un overlay sul trend). Tutto causale (decisione ≤ `close[i]`,
|
||||
tenuta in `i+1` dallo shift di `eval_weights`), fee 0.10% RT, vol-target 20%, leva cap 2x, harness onesto
|
||||
condiviso `altlib`. Allineamento DVOL: `al.dvol` (merge_asof backward) → DVOL close del giorno *t* (≈
|
||||
contemporaneo a `close[i]`), noto a decisione; verificato leak-free (`causality_ok` max_tail_diff 0.0).
|
||||
|
||||
DVOL parte 2021-03 → pre-DVOL il segnale è flat. Valuto sia **FULL** (col flat pre-2021, deflaziona lo
|
||||
Sharpe) sia **ERA-only** (da 2021-10, equo per la tesi).
|
||||
|
||||
## 1) Probe direzionale (corr segnale[i] vs ritorno[i+1], pooled BTC+ETH, era DVOL)
|
||||
|
||||
| segnale | corr | long bps | flat bps | **edge bps** | frac_long |
|
||||
|---|---|---|---|---|---|
|
||||
| VRP-Z+ (long VRP ricco, IV-RV alto) | +0.010 | +7.8 | −0.9 | **+8.6** | 0.52 |
|
||||
| VRP-Z− (flip) | −0.017 | −0.9 | +7.8 | −8.6 | 0.48 |
|
||||
| **DVOL-LV fear (rank>0.5 → long)** | **+0.024** | +20.4 | +0.9 | **+19.5** | 0.14 |
|
||||
| DVOL-LV calm (rank<0.5 → long) | −0.013 | +0.8 | +21.2 | −20.4 | 0.86 |
|
||||
| DVOL-MOM (DVOL in calo → long) | −0.041 | −10.0 | +22.9 | **−32.9** | 0.59 |
|
||||
| VRP>0 (quasi buy&hold) | +0.003 | +5.3 | −0.7 | +5.9 | 0.72 |
|
||||
|
||||
Lettura: l'unico contenuto direzionale reale è **"buy-the-fear"** (long quando il DVOL è alto nel suo
|
||||
percentile espandente: edge +19.5 bps il giorno dopo). Il VRP-ricco→long è coerente ma debolissimo (+8.6).
|
||||
Il "DVOL in calo → long" è **negativo** (−33 bps): comprare quando la paura scende è perdente (regola
|
||||
falsificata). Nota: il percentile espandente è dominato dai picchi 2021-22 → "fear" scatta solo nel 14%
|
||||
delle barre (gli spike veri), non metà del tempo.
|
||||
|
||||
## 2) Robustezza assoluta (study_weights 1d, fee sweep 0.00-0.20% RT) — ERA-only il giudizio equo
|
||||
|
||||
| segnale | abs | minFull Sh | minHold Sh | feeOK | **ERA Sh** | ERA DD | ERA CAGR |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **DVOL-fear q0.4 LF** | WEAK | +0.39 | +0.14 | sì | **+0.59** | **8%** | +5% |
|
||||
| VRP-Z60 LF | FAIL | +0.42 | −0.39 | sì | +0.46 | 23% | +7% |
|
||||
| DVOL-fear q0.5 LF | FAIL | +0.31 | −0.17 | sì | +0.40 | 10% | +3% |
|
||||
| VRP-Z90 LF | FAIL | +0.32 | −0.26 | sì | +0.32 | 29% | +4% |
|
||||
| VRP-Z90 L/S | FAIL | +0.25 | +0.32 | sì | +0.31 | 28% | +3% |
|
||||
| DVOL-calm q0.5 LF | FAIL | +0.00 | −1.05 | no | +0.09 | 37% | −0% |
|
||||
| DVOL-MOM k10/k20 LF | FAIL | −0.6/−0.7 | <−1 | no | −0.6/−0.7 | ~40% | ~−10% |
|
||||
| DVOL-MOM k10 L/S | FAIL | −1.16 | −1.29 | no | −1.23 | 72% | −23% |
|
||||
|
||||
Nessun segnale supera lo Sharpe assoluto in **valore** (tutti < 0.6, soffitto direzionale BTC/ETH ~1.3
|
||||
confermato). Unico con profilo decente: **DVOL-fear q0.4 long-flat** (ERA Sh +0.59, DD **8%**) — il DD
|
||||
bassissimo perché è long solo nei picchi di paura (poche barre, mean-reversion). Le famiglie momentum e
|
||||
"buy-the-calm" sono nettamente negative e non sopravvivono nemmeno alle fee.
|
||||
|
||||
## 3) Marginal vs TP01 (il gate vero) — DVOL-fear q0.4 long-flat
|
||||
|
||||
| | corr→TP01 | in-sample Sh | uplift_full w25 | uplift_hold w25 | multi-cut persistente | is_hedge | verdetto |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **ERA-only** (2021-10+) | 0.37 | 0.54 (has_edge) | +0.06 | +0.17 | sì | **No** | **ADDS** (earns_slot=True) |
|
||||
| **FULL** (gate ufficiale) | 0.28 | 0.64 (has_edge) | +0.03 | +0.17 | sì | **Sì** | **HEDGE** (earns_slot=**False**) |
|
||||
|
||||
Il candidato ha tutto ciò che serve a un nuovo sleeve **tranne una cosa**: il gate canonico full-history
|
||||
lo classifica **HEDGE**, non alpha. `hedge_yearly_corr(TP01-Sh, uplift) = −0.76`, `uplift TP01-up −0.003`
|
||||
/ `uplift TP01-down +0.194` → **paga SOLO quando TP01 è debole**. Ha perfettamente senso: il DVOL esplode
|
||||
durante i crash, proprio quando il trend di TP01 è flat/in-drawdown; comprare la paura cattura il rimbalzo
|
||||
e smorza il DD del portafoglio, ma **non è un premio permanente**. Il blend 0.75·TP01 + 0.25·cand alza
|
||||
l'hold-out 0.30→**0.47** (w50 → 0.62) e taglia il DD a ~11%/8% — un effetto **drawdown-dampener** reale,
|
||||
ma classificato (giustamente) come hedge.
|
||||
|
||||
Perché ERA dice ADDS e FULL dice HEDGE: il rivelatore di hedge richiede ≥3 anni; sulla finestra ERA
|
||||
(2021-10+) la `corr(TP01-Sh annuale, uplift)` è meno netta e non scatta. **Il gate full-history è quello
|
||||
giusto** (più dati per il test di hedge) → mi attengo a quello: **HEDGE, non alpha.**
|
||||
|
||||
Gli altri tre candidati (VRP-Z60/90, DVOL-fear q0.5): **NEUTRAL o HEDGE**, mai ADDS. I VRP-Z hanno beta a
|
||||
TP01 alto (0.88-0.90) → sono trend-correlati travestiti (resid Sharpe ~0), uplift hold-out negativo.
|
||||
|
||||
## 4) Causalità, alignment, eseguibilità, sign-falsification
|
||||
|
||||
- **Causale**: `causality_ok` ok=True, max_tail_diff **0.0** su tutti i candidati (rank espandente +
|
||||
z-score ricalcolati su prefisso = identici → nessun future-peeking). Test dedicato `test_expanding_rank_is_causal`.
|
||||
- **Alignment DVOL**: laggando il segnale di +1 giorno (extra-conservativo, usa solo DVOL di ieri) l'ERA
|
||||
Sharpe del leader scende +0.59 → **+0.45**. L'edge sopravvive ma è **sensibile all'allineamento**
|
||||
(parte del vantaggio è nel reagire allo spike DVOL del giorno stesso) → cautela in esecuzione.
|
||||
- **Eseguibile a $600**: `eval_weights_smallcap` (min_order $5, cap $300/asset): haircut Sharpe **≈ 0.00**
|
||||
su BTC ed ETH (segnale long-flat a basso turnover, ~210-270 trade su tutta la storia) → lo Sharpe netto
|
||||
onesto a $600 = quello modellato. È l'unico aspetto pienamente promosso.
|
||||
- **Sign-falsification**: tesi buy-the-fear ERA Sh +0.40 vs flip buy-the-calm +0.09; VRP-Z+ +0.32 vs
|
||||
VRP-Z− (flip) −0.14. Il segno della tesi è quello giusto (l'edge è reale, non rumore simmetrico).
|
||||
|
||||
## Verdetto onesto: **LEAD (hedge / drawdown-dampener), NON sleeve.** SCARTATO come alpha direzionale.
|
||||
|
||||
- Nessun segnale DVOL-direzionale ha Sharpe assoluto sopra ~0.6 → **conferma il soffitto BTC/ETH
|
||||
direzionale ~1.3** (la via per superarlo resta il cross-sectional XS01, non un nuovo segnale a 2 asset).
|
||||
- L'unico con edge in-sample reale + eseguibile + leak-free è **DVOL-fear "buy-the-fear" long-flat**, ma il
|
||||
gate canonico lo bolla **HEDGE** (`is_hedge=True`, paga solo quando TP01 è in difficoltà). Per la regola
|
||||
del progetto un hedge **non si arma come slot di alpha** → **earns_slot=False**.
|
||||
- È comunque un **LEAD utile in forward-monitor** come **smorzatore di drawdown** del portafoglio: il blend
|
||||
porta l'hold-out 0.30→0.47/0.62 e abbassa il DD. Se in futuro si vorrà esplicitamente un tail/DD-hedge
|
||||
*eseguibile* (a differenza del tail-hedge in opzioni, non eseguibile a $600), questo è il candidato più
|
||||
pulito visto finora — ma da prezzare **come hedge**, non come ritorno atteso permanente.
|
||||
|
||||
## Caveat
|
||||
|
||||
1. **Storia DVOL <5 anni** (2021-03→oggi, ~4.7 anni; era valutata 2021-10+) → multiple-testing e
|
||||
finestra corta. Il multi-cut 2026 è un assurdo +1.944 (manciata di giorni, picco DVOL recente):
|
||||
l'uplift hold-out è trascinato dal regime 2025-26 ad alta-paura, non da un edge stazionario.
|
||||
2. **È un hedge, non alpha**: il valore è condizionato ai drawdown di TP01 (2022, ed eventuali futuri).
|
||||
In un toro lungo a bassa vol il segnale resta **flat l'86% del tempo** e non contribuisce nulla.
|
||||
3. **Sensibile all'allineamento** DVOL (haircut −0.14 Sharpe col lag +1g): l'esecuzione reale dovrebbe
|
||||
usare il DVOL già stampato a decisione, non assumere il close contemporaneo.
|
||||
4. Long-flat → cattura il rimbalzo post-paura ma **non protegge** durante la discesa (non è uno short):
|
||||
è "buy-the-dip gated by fear", con tutti i rischi del comprare coltelli che cadono se il regime peggiora.
|
||||
|
||||
Nessun impatto sul book live (branch separato, nessuno sleeve registrato, config canonica invariata).
|
||||
@@ -0,0 +1,145 @@
|
||||
# 2026-06-29 — Filone B: INTRADAY REGIME BTC/ETH — ERM (efficiency-ratio) è un LEAD reale, forward-monitor
|
||||
|
||||
**Tesi.** Cercare un meccanismo **sub-daily** sui dati certificati 1h→12h BTC/ETH che sia ortogonale
|
||||
**sia** a TP01 (TSMOM trend daily, long-flat) **sia** a SKH01 (regime BuzVola/BuzVolume + Donchian a
|
||||
230m). SKH01 prova che il sub-daily PUÒ funzionare ed essere quasi-ortogonale: qui un MECCANISMO DIVERSO,
|
||||
basato sulla **qualità del moto intraday** (efficiency-ratio / vol-expansion / thrust) come **regime** che
|
||||
condiziona una posizione direzionale tenuta ~1 giorno. Script `scripts/research/intraday_regime.py`, test
|
||||
`tests/test_intraday_regime.py`. Branch `research/equity-meanrev-ib` (separato, nessun impatto live).
|
||||
|
||||
Killer ricorrente del progetto sotto le 12h = **muro-fee 0.10% RT + overfitting**. Ricetta SKH01:
|
||||
decisione sub-daily ma **hold ~1 giorno** → pochi trade → la fee non uccide. Ogni meccanismo qui è
|
||||
costruito a basso turnover e giudicato col **fee-sweep alla sua frequenza reale**.
|
||||
|
||||
## Meccanismi provati (tutti come posizione CONTINUA decisa ≤ `close[i]`, causali)
|
||||
|
||||
| | meccanismo |
|
||||
|---|---|
|
||||
| **ERM** | **Efficiency-Ratio regime momentum** (Kaufman): ER = \|moto netto su L barre\| / \|percorso\|. Prendi la direzione del moto netto **solo quando ER ≥ soglia** (regime intraday "pulito"/trendy), altrimenti flat |
|
||||
| VEM | Vol-Expansion Momentum: direzione = segno del moto, attiva solo quando vol-corta > vol-lunga |
|
||||
| VBR | Volatility/thrust breakout (Larry-Williams ROLLING, no calendario): segui i moti > k·ATR |
|
||||
| TOD | Time-of-day seasonality — **CONTROLLO calendario**, incluso APPOSTA per `day_boundary_robust` |
|
||||
|
||||
## Selezione + fee-sweep a frequenza reale (vincitori per famiglia, min-asset)
|
||||
|
||||
```
|
||||
ERM: 8h L=2.0 thr=0.35 L/S minFull +0.88 minHold +0.99 f@.20 +0.55 feeOK=True turn/y 125
|
||||
VEM: 12h Lmom=3 Ls=2 Ll=10 minFull +0.72 minHold -0.29 f@.20 +0.51 feeOK=True (hold<0 → out)
|
||||
VBR: 12h k=1.0 atr=14 L/S minFull +0.33 minHold +1.20 f@.20 +0.25 feeOK=True
|
||||
TOD: 1h minFull -3.99 minHold -4.49 feeOK=False (controllo → FAIL atteso)
|
||||
```
|
||||
|
||||
Solo **ERM** e **VBR** sopravvivono al gate fee+OOS. VEM ha hold-out negativo. TOD esplode (vedi sotto).
|
||||
|
||||
## Il candidato: ERM 8h L=2.0 thr=0.35 (L/S) — `earns_slot=True`
|
||||
|
||||
```
|
||||
abs=PASS marginal=ADDS EARNS_SLOT=True
|
||||
standalone: cand FULL 0.919 / HOLD 1.105 (TP01 ref FULL 1.297 / HOLD 0.303)
|
||||
corr->TP01 full 0.146 hold 0.057 beta 0.197 resid Sharpe 0.738 alpha/yr 0.121
|
||||
robust_oos=True (clean-year +0.103, drop-best-month +0.283)
|
||||
multi-cut persistence: 2020 .166 / 2021 .167 / 2022 .298 / 2023 .337 / 2024 .418 / 2025 .497 / 2026 2.861 persistent=True
|
||||
has_insample_edge=True (standalone PRE-holdout Sharpe 0.874, > null-pctl 0.317)
|
||||
is_hedge=False (yearly corr(TP01-Sh, uplift) -0.762; paga sia TP01-up 0.139 sia TP01-down 0.384)
|
||||
blend w25: FULL 1.481 (+0.184) HOLD 0.80 (+0.497) DD 11.0%
|
||||
corr con SKH01: full 0.278 hold 0.291
|
||||
day_boundary=INVARIANT (spread 0.0) haircut $600 ≈ 0.00 su BTC/ETH
|
||||
```
|
||||
|
||||
È il **primo sub-daily da SKH01** a passare TUTTI i gate del marginal scorer indurito: edge in-sample
|
||||
reale (non diversification-math), persistenza multi-cut, robust_oos, NON hedge, scorrelato a TP01 (0.15).
|
||||
|
||||
### ERM aggiunge OLTRE SKH01? (gate #5 — il book esistente ne ha già uno sub-daily)
|
||||
|
||||
| blend | FULL Sh | FULL DD | HOLD Sh | HOLD DD |
|
||||
|---|---|---|---|---|
|
||||
| TP01 | +1.30 | 14.3% | +0.30 | 7.5% |
|
||||
| TP01+SKH 75/25 | +1.78 | 9.4% | +1.17 | 6.2% |
|
||||
| **TP01+SKH+ERM 60/25/15** | **+1.88** | 8.9% | **+1.46** | 5.3% |
|
||||
| TP01+SKH+ERM 55/20/25 | +1.84 | 8.8% | +1.48 | 5.1% |
|
||||
|
||||
Sì: corr con SKH01 solo 0.28 → ERM **aggiunge oltre SKH** (FULL +0.10, HOLD +0.29 a peso 15%, DD ancora
|
||||
giù). Non è SKH01 travestito.
|
||||
|
||||
## Il controllo TOD (calendario) — fa esattamente ciò che doveva
|
||||
|
||||
`TOD` (direzione per ora-del-giorno, media espandente causale) è incluso come **trappola**: è il tipo di
|
||||
effetto che uccise `open_drive` (artefatto di etichettatura UTC). Esito: **FAIL** (FULL −3.99, 31.811
|
||||
trade, fee-killed), marginal=DILUTES. `day_boundary_robust=INVARIANT` → l'effetto è **robustamente
|
||||
negativo** a ogni offset (non un artefatto di confine giorno: è proprio che la time-of-day-direzionale non
|
||||
ha edge e sanguina fee). Il controllo conferma che l'harness non si fa ingannare e che il segno
|
||||
dei segnali di prezzo (ERM/VBR) è reale, non rumore di calendario.
|
||||
|
||||
## Causalità / eseguibilità
|
||||
|
||||
- **Leak-free**: `causality_ok=True`, max_tail_diff **0.0** su tutti i candidati (ER, rank espandente,
|
||||
medie su prefisso = identiche → nessun future-peeking). Test dedicato in `test_intraday_regime.py`.
|
||||
- **day_boundary_robust=INVARIANT** (spread 0.0) per ERM/VBR/TOD: segnali di prezzo, non di calendario.
|
||||
- **Eseguibile a $600**: `eval_weights_smallcap` haircut **≈ 0.00** su BTC ed ETH. **MA** ERM a 8h fa
|
||||
~3.158 trade BTC / ~2.823 ETH su tutta la storia (turnover 125/anno): haircut nullo nel modello, ma
|
||||
l'esecuzione reale sub-daily sul book è operativamente più pesante di un segnale 1d (slippage/spread
|
||||
intraday non interamente catturati dalla fee proporzionale).
|
||||
|
||||
## Caveat (perché LEAD, non sleeve)
|
||||
|
||||
1. **Plateau hold-out a UNA SOLA RIGA.** Il FULL è robusto su tutta la griglia L∈[2.0,3.0] (+0.6..+1.0),
|
||||
ma l'**hold-out è positivo SOLO a L=2.0** (a L=2.5/3.0 crolla a −0.5..−0.8). Il plateau sul full è
|
||||
ampio, quello che conta — l'hold-out — è single-row. Da rinforzare prima di credere alla stazionarietà.
|
||||
2. **Standalone FULL 0.92 < soffitto ~1.3.** Coerente col soffitto direzionale BTC/ETH: il valore di ERM
|
||||
è **marginale/diversificante** (corr 0.15 a TP01, 0.28 a SKH01), non assoluto. Non rompe il soffitto.
|
||||
3. **Multiple-testing non deflazionato.** 102 celle testate (60 ERM + 16 VEM + 24 VBR + 2 TOD) senza
|
||||
deflated-Sharpe (a differenza del filone C). Il multi-cut 2026 = +2.861 è una manciata di giorni che
|
||||
gonfia. Storia sub-daily certificata utile ~quanto SKH01 → finestra non lunghissima.
|
||||
4. **Esecuzione 8h** = complessità operativa reale (vedi sopra), oltre il modello a haircut nullo.
|
||||
|
||||
## Analisi di robustezza / de-bias (`intraday_regime_analysis.py`) — il lead NON regge
|
||||
|
||||
I caveat #1 (plateau hold-out single-row) e #3 (multiple-testing) erano i sospetti giusti. Tre test di
|
||||
de-bias li trasformano da sospetto in **bocciatura** dello slot:
|
||||
|
||||
| test | esito |
|
||||
|---|---|
|
||||
| **A) Deflated-Sharpe** (Bailey & Lopez de Prado) su 122 trial cercati | **FAIL.** DSR 0.000 (tutti) / **0.163 (escludendo i trap TOD)** / 0.241 (solo-ERM) — tutti ≪ 0.95. Lo Sharpe winner (0.92) è sotto lo Sharpe-max-atteso-null (1.16–2.51): il search ha trovato celle a 1.6 full / 1.7 in-sample, il winner 0.92 **non è eccezionale**. |
|
||||
| **B) Selezione IN-SAMPLE-only** (scelgo la cella ERM col solo Sharpe < 2025) | **earns_slot=False.** La cella migliore pre-hold-out è un'**ALTRA** (8h L=2.0 thr=0.4 **long-flat**), con corr→TP01 **0.53** (è trend-beta travestito) → marginal=**NEUTRAL**. Il winner max-hold **non si seleziona senza guardare l'hold-out** → il suo `earns_slot=True` era **selezione-sull'hold-out**. |
|
||||
| **C) Ensemble del plateau** (media 20 celle L×thr, niente cherry-pick) | **earns_slot=False.** marginal=ADDS, in-sample Sh 1.01, corr→TP01 0.18 — ma **`robust_oos=False`** (clean-year + jackknife): l'uplift hold-out è trascinato dal **2026 (+2.09 multicut)**, manciata di giorni. |
|
||||
|
||||
**Dove vive l'(eventuale) edge** (per-anno, blend 3-way 60/25/15 vs 2-way 75/25): uplift FULL solo **+0.10**,
|
||||
**negativo nel 2021 (−0.23) e 2022 (−0.15)**, positivo altrove; l'uplift HOLD **+0.30 è concentrato nel
|
||||
2026 (+0.46)**. corr(ERM,SKH) 0.28 full (fino a 0.42 in alcuni anni) → **parziale sovrapposizione con SKH**,
|
||||
non ortogonalità piena.
|
||||
|
||||
**Lettura.** Il segnale efficiency-ratio non è rumore puro (l'ensemble ha in-sample Sh ~1.0, positivo nella
|
||||
maggior parte degli anni), ma come **slot** fallisce ogni de-bias: il `earns_slot=True` della scoperta era
|
||||
prodotto da **(1) selezione della cella sull'hold-out** + **(2) coda 2026** + **(3) multiple-testing non
|
||||
corretto**. È lo stesso falso-positivo che l'alt-sweep 100-agent imparò a uccidere — qui ucciso dai gate.
|
||||
|
||||
## Caveat originari (ora risolti dall'analisi sopra)
|
||||
|
||||
1. ~~Plateau hold-out single-row~~ → **confermato fatale**: l'edge hold-out a L=2.0 è cell-selection.
|
||||
2. ~~Multiple-testing non deflazionato~~ → **deflazionato: DSR FAIL** anche senza i trap.
|
||||
3. Esecuzione 8h: irrilevante ormai (lo slot non c'è).
|
||||
|
||||
## Verdetto onesto: **NON è uno slot. Falso positivo da selezione-hold-out + coda 2026.** SCARTATO come sleeve.
|
||||
|
||||
Lo "earns_slot=True" della scoperta **non sopravvive** alla de-selezione: deflated-Sharpe FAIL (anche
|
||||
escludendo i controlli), selezione in-sample-only → NEUTRAL su un'altra cella (trend-beta corr 0.53),
|
||||
ensemble del plateau → robust_oos FAIL. **Conferma ennesima del soffitto direzionale BTC/ETH ~1.3**: un
|
||||
segnale a 2 asset non lo supera; la via resta il cross-sectional (XS01). Resta al più una **curiosità in
|
||||
forward-monitor** (l'efficiency-ratio ha un debole edge in-sample reale), ma da non armare come alpha.
|
||||
Gli altri tre meccanismi: VBR=NOISE, VEM hold-out<0, TOD=controllo FAIL come atteso.
|
||||
|
||||
**Lezione harness (CODIFICATA).** Lo script di scoperta selezionava il vincitore per `min_hold` massimo
|
||||
sulla griglia = **selezione-sull'hold-out**, il punto cieco che ha generato il falso PASS. Il marginal
|
||||
scorer da solo non basta se la *cella* è scelta guardando l'hold-out: serve **(a)** scegliere la cella
|
||||
in-sample-only (o walk-forward) **prima** di valutare il marginal, e **(b)** deflazionare per il numero di
|
||||
celle/famiglie testate. Stesso buco segnalato per CC01 ("Sharpe implausibile") e per l'alt-sweep
|
||||
(hold-out-fitting): qui in forma "selection-on-holdout". Ora è **codice** in `altlib` (LESSON 4): tre
|
||||
funzioni nuove — `deflated_sharpe()` (Bailey & Lopez de Prado), `select_cell_insample()` (scelta cella
|
||||
col solo Sharpe pre-HOLDOUT), e il gate combinato **`study_family_honest()`** (`earns_slot_honest =
|
||||
earns_slot[cella in-sample] AND deflated-Sharpe≥0.95`). Verificato: su ERM il gate ritorna
|
||||
`earns_slot_honest=False` (cella in-sample = trend-beta NEUTRAL, DSR 0.74). Analisi completa in
|
||||
`scripts/research/intraday_regime_analysis.py`; test in `tests/test_harness_realism.py`.
|
||||
**Regola nuova: una strategia direzionale grid-searched si giudica con `study_family_honest`, non
|
||||
chiamando `study_marginal` sulla cella a max hold-out.**
|
||||
|
||||
Nessun impatto sul book live (branch separato, config canonica invariata).
|
||||
@@ -0,0 +1,71 @@
|
||||
# 2026-06-29 — Filone D: MACRO regime-gate sul book crypto — RIDONDANTE col trend, SCARTATO
|
||||
|
||||
**Tesi.** Usare segnali macro/cross-market — equity (SPY/QQQ/IWM), credito (HYG/LQD), oro (GLD/SLV),
|
||||
tassi (TLT/IEF) — come **gate risk-on/risk-off** sul book BTC/ETH, per migliorare il *timing* del
|
||||
drawdown di TP01: regime risk-off (credito che cede, equity sotto trend, fuga sui bond) → riduci/azzera
|
||||
l'esposizione crypto; risk-on → lascia agire TP01. **È eseguibile** (gata solo BTC/ETH perp, non aggiunge
|
||||
gambe). Script `scripts/research/macro_regime_gate.py`, test `tests/test_macro_regime_gate.py`. Branch
|
||||
`research/equity-meanrev-ib` (separato, nessun impatto live). Dati: `data/raw/eq_*_1d.parquet` (ETF daily,
|
||||
allineati causale `merge_asof backward` sul calendario SPY).
|
||||
|
||||
**NON è un lead-lag direzionale** (già morto: diari 2026-06-22/-23). L'angolo nuovo = un overlay
|
||||
binario/continuo di **de-risk** sul book esistente.
|
||||
|
||||
## Il rischio noto in partenza (CLAUDE.md)
|
||||
|
||||
La lezione DVOL-spike (2026-06-26): un gate di de-risk rischia di essere **ridondante col trend** — TP01
|
||||
è già long-flat e va a 0 nei crash (Δ 0.00). Questo script DOVEVA dimostrare che il gate aggiunge **oltre**
|
||||
quel che TP01 fa da solo. **Non ci riesce.**
|
||||
|
||||
## Gate provati (tutti causali: SMA/ratio/z-score rolling, merge_asof backward)
|
||||
|
||||
`SPY>MA{50,100,150,200,250}`, `HYG>MA`, ratio di credito `HYG/LQD`, `HYG/IEF`, combo a maggioranza
|
||||
(SPY+HYG+HYG/LQD, binario e continuo), flight-to-quality (`TLTup&SPYdn`, `GLDup&SPYdn`), regime di vol
|
||||
equity `SPYvol_z1` (proxy "VIX-spike" senza VIX). g_off ∈ {0.0, 0.5}.
|
||||
|
||||
## Esiti
|
||||
|
||||
I gate di trend equity alzano lo Sharpe FULL della combo (SPY>MA150/200/250 → FULL ~1.22-1.25 vs TP01-solo
|
||||
1.30) ma **non l'hold-out** (HOLD ~0.17-0.19) → è **de-levering**, non timing: tagliano DD spegnendo il
|
||||
book in mercati equity laterali, non anticipando i crash crypto. Il miglior profilo DD è `SPYvol_z1`.
|
||||
|
||||
### Deep-dive `SPYvol_z1` — il controllo decisivo (ridondanza col trend)
|
||||
|
||||
| | exposure TP01 risk-off | exposure risk-on | giorni risk-off | **giorni in cui il gate LAVORA** | corr(1-gate, 1-expo) |
|
||||
|---|---|---|---|---|---|
|
||||
| BTC | 0.023 | 0.093 | 18% | **3%** | 0.278 |
|
||||
| ETH | 0.013 | 0.068 | 16% | **2%** | 0.296 |
|
||||
|
||||
"Il gate lavora" = giorno risk-off **E** posizione TP01 non già flat. Accade nel **2-3% dei giorni**: nel
|
||||
resto, quando il gate vorrebbe de-riskare, TP01 è **già flat da solo**. Il gate spegne un motore già spento.
|
||||
|
||||
### Marginal scorer vs TP01 (gate come candidato-sleeve)
|
||||
|
||||
```
|
||||
abs=FAIL marginal=REDUNDANT EARNS_SLOT=False
|
||||
corr->TP01 full 0.989 hold 0.984 beta 0.978 resid Sharpe 0.739
|
||||
multi-cut persistent=True has_insample_edge=True (standalone 1.634) is_hedge=False
|
||||
blend w25: full 1.323 (+0.026) hold 0.257 (-0.046) blend w50: full 1.348 (+0.051) hold 0.21 (-0.093)
|
||||
```
|
||||
|
||||
corr→TP01 **0.989**, beta 0.98: lo stream gated **È** TP01. L'uplift hold-out è **negativo** (de-levering
|
||||
che toglie ritorno fuori dai crash). overlay-delta (gated−solo) come stream a sé: `verdict=NEUTRAL`.
|
||||
|
||||
## Causalità / eseguibilità (gli unici aspetti promossi)
|
||||
|
||||
- **Leak-free**: `causality_ok=True` (max_tail_diff 0.0); STRICT align (1 barra equity extra di lag) →
|
||||
FULL invariato; `day_boundary_robust=INVARIANT` (spread 0.0). Allineamento macro→crypto backward-only.
|
||||
- **Eseguibile a $600**: haircut `eval_weights_smallcap` ≈ **0.00** su BTC ed ETH. Fee-survivente
|
||||
(0.00→0.30% RT: 1.42→1.33). Tecnicamente impeccabile — semplicemente **non aggiunge alpha**.
|
||||
|
||||
## Verdetto: **SCARTATO (ridondante col trend).** earns_slot=False.
|
||||
|
||||
Conferma esatta della lezione in CLAUDE.md: un overlay di de-risk su un book che è **già** long-flat e
|
||||
già a 0 nei crash non ha quasi nulla da fare (lavora il 2-3% dei giorni, corr→TP01 0.99). Il taglio di DD
|
||||
che mostra è **de-levering**, replicabile meglio con un `target_vol` più basso (stessa lezione del
|
||||
TP01×DVOL overlay del 2026-06-26). Il macro-regime **non anticipa** i drawdown crypto meglio del prezzo
|
||||
crypto stesso. Nessuno sleeve registrato; nessun impatto sul book live.
|
||||
|
||||
**Sottoprodotto utile:** il gate `redundancy_diag` (exposure risk-off vs risk-on + "quota giorni in cui il
|
||||
gate lavora") è il test pulito da riusare ogni volta che si propone un overlay di de-risk — se "il gate
|
||||
lavora" è basso e corr→TP01 ~1, è ridondante, a prescindere dallo Sharpe FULL che mostra.
|
||||
@@ -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).
|
||||
@@ -0,0 +1,65 @@
|
||||
# 2026-06-29 — Filone C: cross-sectional NON-momentum su Hyperliquid — DEBOLE / forward-monitor
|
||||
|
||||
**Tesi.** XS01 (sleeve attivo) è momentum cross-sectional sui 19 major HL. Lezione del progetto
|
||||
(2026-06-19/20): **espandere il numero di asset NON aiuta il momentum** (gli small-cap diluiscono/
|
||||
invertono il segnale). Quindi qui NON si ri-prova l'espansione-universo: si cerca un **meccanismo
|
||||
DIVERSO dal momentum** che, market-neutral e scorrelato, possa diversificare il portafoglio. Script
|
||||
`scripts/research/xsec_v2_nonmom.py`, test `tests/test_xsec_v2.py`. Branch `research/equity-meanrev-ib`.
|
||||
|
||||
## Meccanismi provati (tutti L/S dollar-neutral, vol-target ~20%, ribilancio periodico, CAUSALI)
|
||||
|
||||
| | meccanismo |
|
||||
|---|---|
|
||||
| REV | short-term reversal grezzo (long loser / short winner di breve) |
|
||||
| IREV | reversal **idiosincratico** (sul residuo dopo aver tolto il mercato, beta-adjusted) |
|
||||
| LOWVOL | factor **low-vol** (long bassa vol realizzata / short alta vol) |
|
||||
| IMOM | momentum **idiosincratico** (sul residuo, ≠ raw mom) — *riferimento* |
|
||||
| BAB | betting-against-beta (long basso beta / short alto beta) |
|
||||
| MOM | momentum grezzo — *riferimento* |
|
||||
|
||||
Universi: **19 major** e **51 all** (i parquet certificati). Gate canonico di selezione: FULL>0.5,
|
||||
HOLD>0, |corr→XS01|<0.6 (escluse le famiglie momentum MOM/IMOM, che sono riferimento, non candidati).
|
||||
|
||||
## Esiti — un solo candidato non-momentum supera il gate standalone
|
||||
|
||||
```
|
||||
[OK] LOWVOL 19-major B30 H10 k5 FULL +1.07 HOLD +1.07 DD 19% corrXS +0.16 corrTP -0.28
|
||||
[--] BAB 19-major B60 H10 k8 FULL +0.47 HOLD +1.15 DD 28% corrXS +0.20 corrTP -0.27
|
||||
[--] REV/IREV (19 e 51) FULL ~0 o NEGATIVO (reversal cross-sectional non paga)
|
||||
[--] LOWVOL 51-all FULL +0.42 (il long-tail diluisce, come per il momentum)
|
||||
```
|
||||
|
||||
- **REV / IREV negativi** (FULL −0.10..−0.70): il reversal cross-sectional grezzo o idiosincratico **non
|
||||
è un edge** su questo universo/finestra — coerente con la morte storica del mean-reversion del progetto.
|
||||
- **BAB** ha un buon hold-out ma FULL solo +0.47 (sotto soglia) e DD 28%.
|
||||
- **LOWVOL 19-major** (betting-against-vol) è l'unico idoneo: FULL **1.07** / HOLD **1.07** / DD 19%,
|
||||
causale (max_tail_diff 0), corr→XS01 +0.16 (scorrelato), corr→TP01 −0.28.
|
||||
- Espandere a **51 asset peggiora** anche il low-vol (FULL 1.07→0.42): riconferma che i margini
|
||||
cross-sectional sono nella **struttura del segnale**, non nel numero di asset.
|
||||
|
||||
### Uplift portafoglio (4 → 5 sleeve, LOWVOL come 5° sleeve)
|
||||
|
||||
```
|
||||
BASE (TP01+XS01+VRP01+SKH01) FULL 2.12 DD 8% | HOLD 2.25 DD 3%
|
||||
+LOWVOL @10% FULL 2.17 (+0.05) | HOLD 2.57 (+0.32)
|
||||
+LOWVOL @15% FULL 2.19 (+0.06) | HOLD 2.62 (+0.37)
|
||||
```
|
||||
|
||||
## Il muro (perché NON è uno sleeve)
|
||||
|
||||
1. **Deflated Sharpe 0.130 ≪ 0.95** (N=216 trial globali; soglia Sharpe-max-null annualizz. 1.79). Con il
|
||||
numero di celle/famiglie/universi testati, lo Sharpe 1.07 **non è significativo** dopo la correzione
|
||||
per multiple-testing. Per-anno: 2024 +0.23 / 2025 +0.50 / **2026 −0.09** (il 2026 è già negativo).
|
||||
2. **Storia ~2.5 anni** (HL nativo 2024-2026): finestra troppo corta per un fattore cross-sectional.
|
||||
3. **Non eseguibile a $600**: book L/S a molte gambe (10+ per lato) → **STAT-MODE**, mai deploy. Stesso
|
||||
vincolo strutturale di XS01 (serve ~20k di capitale).
|
||||
|
||||
## Verdetto: **DEBOLE / forward-monitor (STAT-MODE).** Nessuno sleeve registrato.
|
||||
|
||||
Il **low-vol cross-sectional** (betting-against-vol sui 19 major) è l'unico meccanismo non-momentum con un
|
||||
profilo decente e scorrelato sia a XS01 sia a TP01, e migliorerebbe l'hold-out del portafoglio (+0.32/+0.37).
|
||||
**MA** non supera il deflated-Sharpe (multiple-testing), ha storia cortissima ed è non-eseguibile a questo
|
||||
capitale. → lavoro **puramente statistico**, da tenere in forward-monitor come *secondo* segnale
|
||||
cross-sectional a fianco di XS01, da rivalutare solo a scala (capitale ~20k+) e con più storia HL.
|
||||
|
||||
Reversal cross-sectional (REV/IREV) **confermato non-edge** su HL. Nessun impatto sul book live.
|
||||
@@ -0,0 +1,91 @@
|
||||
# 2026-07-01 — Breadth / market-internals del mercato ALT come segnale su BTC/ETH — SCARTATO
|
||||
|
||||
**Tesi.** Gli "internals" del mercato crypto — la partecipazione degli alt — come segnale
|
||||
direzionale o gate di de-risk su BTC/ETH perp (2 gambe, eseguibile a ~$600): % di alt sopra la
|
||||
propria SMA(N), advance/decline, % di alt che battono BTC (risk-appetite relativo), breadth-thrust.
|
||||
Script `scripts/research/r0701_breadth_internals.py` (non committato per policy del filone; harness
|
||||
`altlib`). Dati: i 51 parquet certificati `data/raw/hl_*_1d.parquet` → panel di **49 alt**
|
||||
(esclusi hl_btc/hl_eth dalla breadth; hl_btc solo come riferimento per la famiglia RS), barre a
|
||||
volume 0 mascherate (lezione backfill 2026-06-20; il fetch le aveva già ripulite: vol0=0 su tutti
|
||||
i file, gate comunque attivo).
|
||||
|
||||
**Limite strutturale dichiarato in partenza:** l'universo HL parte dal 2024-01 → dopo il warmup
|
||||
(max 120g) la finestra comune è **2024-05-05 → 2026-07-01 (~2.2 anni)** e l'in-sample pre-HOLDOUT
|
||||
è **~8 mesi del toro 2024**. Qualunque esito poteva essere al massimo un lead.
|
||||
|
||||
## Famiglie e griglia (108 celle, tutte contate nel deflated Sharpe)
|
||||
|
||||
4 famiglie × N∈{20,50,100} × soglia∈{0.3,0.5,0.7} × forma∈{LS, long-flat, gate-su-TP01}:
|
||||
|
||||
- **FAM-MA**: % di alt validi con close > SMA(N) (breadth classica).
|
||||
- **FAM-AD**: frazione di advancers (ret 1g>0), SMA(N).
|
||||
- **FAM-RS**: % di alt che battono BTC sul ritorno a N giorni (≈ market-neutral, l'angolo più ortogonale).
|
||||
- **FAM-TH**: breadth-thrust = Δ a N giorni della breadth MA20 (thrust/collapse), score 0.5+Δ.
|
||||
|
||||
Tutte causali (rolling/shift soltanto), MIN_VALID=20 asset per data (osservato: 42-49). LS/LF
|
||||
vol-target 20% cap 2x (sizing TP01-style); GATE = posizione TP01 × gate binario. Fee 0.10% RT +
|
||||
sweep 0-0.30%. Procedura onesta = mirror di `study_family_honest` sulla finestra comune (il
|
||||
padding pre-2024 avrebbe contaminato il ranking full-history: le celle GATE con gate=1 pre-storia
|
||||
erediterebbero lo Sharpe 2019-2024 di TP01): **cella scelta col SOLO Sharpe in-sample**, DSR su
|
||||
tutti i trial, `marginal_vs_tp01` di altlib sulla cella scelta.
|
||||
|
||||
## ⚠️ Bug trovato e corretto: allineamento merge_asof su indice non-ns
|
||||
|
||||
La prima versione di `_align` usava `b.index.view("int64")//10**6` per i timestamp della breadth:
|
||||
su un DatetimeIndex tz-aware **a risoluzione non-ns** (pandas 2.x) la scala esce sbagliata →
|
||||
`merge_asof backward` matchava OGNI barra BTC/ETH all'**ultimo** valore della breadth = **il futuro
|
||||
broadcast su tutta la storia**. Sintomo: decine di celle con lo stesso identico Sharpe (il segnale
|
||||
era una costante). **`causality_ok` NON lo vede** — la serie breadth è un input esterno *fisso*,
|
||||
quindi il target sul prefisso troncato coincide col target full per costruzione. Fix: epoca
|
||||
esplicita `(idx - Timestamp(1970, tz=UTC)) // Timedelta(1ms)` (come `resample_tf`). **Lezione
|
||||
harness:** ogni allineamento di un panel esterno via merge_asof va fatto con epoch-ms esplicita, e
|
||||
il sintomo "molte celle con Sharpe identico" è un red-flag di allineamento rotto (gemella
|
||||
dell'osservazione merge_asof del 2026-06-20).
|
||||
|
||||
## Esito (procedura onesta, finestra comune 2024-05+)
|
||||
|
||||
104/108 celle valutabili (4 degeneri flat), 47 con FULL>0. **Cella scelta in-sample** (IS 2.11,
|
||||
la selezione non tocca mai l'hold-out): **TH N=100 thr=0.7 long-flat** — long solo dopo un
|
||||
breadth-thrust (+0.2 di breadth MA20 in 100g), attiva il 32% dei giorni.
|
||||
|
||||
| metrica | valore |
|
||||
|---|---|
|
||||
| standalone finestra comune | IS **+2.11** → FULL **+0.99**, HOLD **+0.41** |
|
||||
| assoluto (per-asset, trimmed) | **PASS** — minFull +0.68, minHold +0.38, fee-survive 0.30%RT, DD 7-11% |
|
||||
| marginale vs TP01 | **ADDS** — corr full **0.401**/hold 0.358, resid Sharpe 0.87, multicut {2025:+0.076, 2026:+0.366} persistente, is_hedge=False |
|
||||
| **robust_oos** | **False** — jackknife drop-best-month **−0.068** (l'uplift hold-out sta in piedi su UN mese) |
|
||||
| **deflated Sharpe** | **0.433** su 104 trial (null-max atteso 1.10) — lontanissimo dal PASS ≥0.95 |
|
||||
| earns_slot / earns_slot_honest | **False / False** |
|
||||
| causality_ok | True (post-fix) |
|
||||
| smallcap $600 | haircut ≈ **0.00** (BTC −0.01, ETH +0.00; 137-152 trade) — eseguibile |
|
||||
|
||||
**Ridondanza col trend (il rischio n.1, lezione macro-gate):** qui NON è la modalità di morte.
|
||||
corr(pos, TP01pos) 0.15-0.30 e il segnale "lavora" (off mentre TP01 non è flat) nel **24-32% dei
|
||||
giorni** (il macro-gate lavorava nel 2-3%). Le forme direzionali LS/LF sono genuinamente diverse
|
||||
da TP01 (corr→TP01 0.1-0.4). La forma **gate-su-TP01** invece sì: best gate = NEUTRAL a corr
|
||||
0.803, e i gate a soglia bassa = REDUNDANT corr 1.0 (gate mai off → TP01 identico). **La breadth
|
||||
come gate di de-risk non aggiunge nulla; come segnale direzionale è diversa dal trend ma l'edge
|
||||
non è dimostrabile.**
|
||||
|
||||
**Il gemello in hindsight (trasparenza, NON selezionabile).** TH N=100 thr=0.7 **LS** fa FULL 1.13
|
||||
/ HOLD 1.06, corr→TP01 **−0.26**, marginal ADDS con robust_oos=True e jackknife +0.46. MA il suo
|
||||
in-sample (1.30) è sotto quello della cella lf (2.11): sceglierlo = **selezione-sull'hold-out**
|
||||
(esattamente il falso positivo ERM del 2026-06-29). E comunque **DSR 0.518 < 0.95**: anche la
|
||||
lettura più caritatevole muore sulla deflazione multiple-testing.
|
||||
|
||||
## Verdetto: SCARTATO (niente sleeve, niente forward-monitor cablato)
|
||||
|
||||
1. La cella scelta onestamente è ADDS ma **robust_oos=False** (uplift a un mese) e **DSR 0.433**
|
||||
su 104 trial: su ~8 mesi di in-sample (toro 2024) un IS di 2.1 è indistinguibile dal massimo
|
||||
atteso per caso (1.10) e non regge.
|
||||
2. Il candidato migliore col senno di poi (TH-LS) sarebbe selezione-sull'hold-out e fallisce
|
||||
comunque il DSR (0.518) — il gate `study_family_honest` fa esattamente il suo lavoro.
|
||||
3. Il limite è la **storia**: 2.2 anni totali, 8 mesi di in-sample. Non c'è modo onesto di
|
||||
dimostrare l'edge oggi.
|
||||
|
||||
**Cosa resta.** (a) La famiglia **breadth-thrust (TH)** è l'unico angolo con celle OOS-positive e
|
||||
bassa/negativa corr a TP01 — **rivisitabile quando il panel HL avrà ≥1-2 anni di storia nativa in
|
||||
più** (in-sample multi-regime, DSR ricalcolabile); nessun cablaggio forward oggi, il costo
|
||||
dell'attesa è zero. (b) Il breadth-**gate** su TP01 va in bacino con macro-gate e DVOL-gate: terza
|
||||
conferma che TP01 non ha bisogno di de-risk esterno (long-flat, già flat nei crash). (c) Il fix
|
||||
`_align` (epoch esplicita) è la lezione riusabile per ogni futuro panel esterno.
|
||||
@@ -0,0 +1,58 @@
|
||||
# 2026-07-01 — Book live: verifica gate SKH01 + emersione errori silenziosi
|
||||
|
||||
## Contesto
|
||||
Il BOOK DERIBIT armato (TP01 0.75 + SKH01 0.25 nettati) è **flat da quando è stato armato
|
||||
(2026-06-23)**: 193 run orari, tutte HOLD, zero ordini reali. Domanda: il flat è legittimo o il
|
||||
gate di SKH01 è muto per un bug?
|
||||
|
||||
## Verifica del gate SKH01 — SANO
|
||||
- **Logica viva:** `skyhook_entries` produce 335 entry (BTC, 222 long/113 short) e 341 (ETH,
|
||||
226/115) su tutta la storia. Non è degenere.
|
||||
- **Shorta davvero i crash:** ha aperto SHORT sulla gamba di crollo di fine maggio/inizio giugno —
|
||||
BTC 05-28→06-04 ($73k→$64k), ETH 06-02→06-06 ($1865→$1555).
|
||||
- **Flat da 06-23 = legittimo:** ultimo entry 06-06, `max_bars=16 × 230m ≈ 2,5g` → chiuso ~06-08/09,
|
||||
**prima** dell'arming (06-23). Dal grind post-crash nessun setup breakout+regime valido → flat
|
||||
corretto per design ("NON trend-follower"). Contesto prezzo al 01-07: BTC −53% dal picco, ETH
|
||||
−67,5%, momentum negativo su 1/3/6/12 mesi → anche TP01 (long-flat TSMOM) è a 0x.
|
||||
- **Entrambi i feed valutano senza crash:** certificato e live `fresh_5m` → `skh_error=None`.
|
||||
|
||||
## Bug scoperto e corretto — pattern "eccezione ingoiata → stato safe silenzioso"
|
||||
`src/live/` aveva più `try/except` che, in errore, producono uno stato safe (flat/fallback) **senza
|
||||
farlo emergere**. Rischio: quando l'errore è transitorio ma reale, l'esecutore opera su dati sbagliati
|
||||
credendoli legittimi.
|
||||
|
||||
### (1) `skh_error` — CORRETTO
|
||||
`book_report` scriveva `skh_error` in un dict locale **mai incluso nel return** → `r.get("skh_error")`
|
||||
sempre `None`; e `book_execute` non lo leggeva comunque. Un feed SKH rotto avrebbe forzato SKH→flat in
|
||||
silenzio (entry mancato, nessun alert). Fix: `skh_error` esposto nel return + `book_execute` emette
|
||||
log `⚠️ SKH FEED ERRORE` + alert Telegram. (commit 31369b3)
|
||||
|
||||
### (2) Gate fail-safe posizione (Opzione A) — CORRETTO
|
||||
`src/live/shadow.py::_positions`: se la read della posizione reale Deribit lancia, assume flat (0.0)
|
||||
con una `note`, MAI propagata (`pos_src`) a `book_report`/`book_execute`. Severità mitigata a valle
|
||||
da `rebalance_signed` che **rilegge la posizione fresca** (no doppia-posizione), ma resta il caso
|
||||
"target≈0 + posizione aperta + read fallita → close saltato quel run" (transitorio, backstoppato dal
|
||||
disaster-SL −30%). **Scelta A = gate fail-safe:** `_positions` ritorna un `pos_error` esplicito →
|
||||
propagato `shadow_report → book_report → book_execute`; se presente (ONLINE ma posizione IGNOTA)
|
||||
l'esecutore **NON opera a cieco** (return + alert), come già fa per `online`.
|
||||
|
||||
### (3) Diagnostica equity (Opzione B) — CORRETTO
|
||||
`src/live/shadow.py::_equity`: se `account_summary('USDC')` e tutte le per-asset falliscono,
|
||||
`real_eq=None` e `shadow_report` ripiega su `paper_cap` (~$2000 dallo state paper) mentre il conto
|
||||
reale è ~$598 → il book dimensionerebbe su $2000 invece di $598 (**sovradimensiona ~33%**). Il danno
|
||||
è limitato dall'**hard-cap $300/asset** in `book_net_target`. **Scelta B = SOLO DIAGNOSTICA** (niente
|
||||
gate/halt, l'hard-cap già protegge il downside): `shadow_report` calcola `eq_fallback` (ONLINE +
|
||||
equity reale non leggibile + no override), propagato `→ book_report → book_execute`, che stampa
|
||||
`⚠️ EQUITY FALLBACK` + alert Telegram **ma prosegue** (a differenza del gate posizione, che blocca).
|
||||
|
||||
## Test
|
||||
Suite completa **156/156** (era 148; +4 gate A + 4 diagnostica B). Dry-run reale di `book_execute`
|
||||
invariato (nessuna riga d'errore, nessun ordine, conto $598.06 al target). Path live: `pos_error=None`,
|
||||
`skh_error=None`, `eq_fallback=None`.
|
||||
|
||||
## Sintesi finale — i 3 pattern "errore silenzioso" del path live sono ora tutti tracciati
|
||||
| # | Punto | Comportamento | Emersione |
|
||||
|---|-------|---------------|-----------|
|
||||
| 1 | `book_report` feed SKH | SKH → flat | log `⚠️ SKH FEED ERRORE` + alert |
|
||||
| 2 | `_positions` read fallita | posizione → flat | **HALT** (non esegue) + alert (gate A) |
|
||||
| 3 | `_equity` non leggibile | sizing → paper_cap | log `⚠️ EQUITY FALLBACK` + alert (diagnostica B) |
|
||||
@@ -0,0 +1,114 @@
|
||||
# 2026-07-01 — Funding rate come segnale TIME-SERIES direzionale su BTC/ETH (R0701): SCARTATO
|
||||
|
||||
## Contesto
|
||||
|
||||
Filone: il **funding orario Hyperliquid come proxy di POSIZIONAMENTO/sentiment** con valenza
|
||||
direzionale a orizzonte giornaliero su BTC/ETH — NON carry. Angolo diverso dal prior art già
|
||||
chiuso: **FC01** (carry cross-sectional delta-neutral 19 alt → LEAD fragile SCARTATO,
|
||||
`2026-06-22-funding-carry-hl.md`) e **funding price-clock** intraday (drift attorno agli stamp →
|
||||
FAIL, onda intraday). Qui l'ipotesi è: funding estremo = affollamento long → fade; funding in
|
||||
espansione = domanda persistente → follow; funding affollato = de-risk del trend (gate).
|
||||
Attrattiva a priori: 2 gambe BTC/ETH perp = **eseguibile al capitale reale ~$600** (non STAT-MODE
|
||||
come XS01/opzioni) — se avesse retto sarebbe stato deployabile.
|
||||
|
||||
Script: `scripts/research/r0701_funding_ts.py`.
|
||||
|
||||
## Dati e causalità (data-first)
|
||||
|
||||
- `data/raw/hlfund_{btc,eth}_1h.parquet`: **2023-05-12 → 2026-06-22, 1138/1138 giorni validi,
|
||||
0 gap** dopo il 2023-06 (primi ~27 giorni a cadenza 8h — la somma giornaliera degli stamp li
|
||||
gestisce indifferentemente). Funding medio **+14.4%/anno BTC, +14.6% ETH**; std del funding
|
||||
giornaliero ~5.5 bps.
|
||||
- **Join causale** (il punto delicato): le barre 1d di `altlib.get` sono **open-labeled**
|
||||
(datetime = 00:00 UTC del giorno D; il close della barra D è noto alle 00:00 di D+1). Il
|
||||
feature-day D aggrega i SOLI stamp in **[D 00:00, D+24h)** — l'ultimo alle 23:00 — quindi tutto
|
||||
è noto PRIMA della decisione al close della barra D; `eval_weights` shifta la posizione al
|
||||
giorno D+1. Prefix-consistency check esplicito: **max diff 0.0** (nessun leak).
|
||||
- **Valutazione su finestra TRONCATA** alla copertura funding, non sul frame prezzi 2018+: fuori
|
||||
copertura il target sarebbe zero per costruzione e i giorni-zero (a) **gonfiano il T del
|
||||
deflated-Sharpe** (anti-conservativo) e (b) diluiscono `cand_insample_sharpe` (il gate
|
||||
`has_insample_edge` scatterebbe a vuoto). La logica di `study_family_honest` è replicata
|
||||
ESATTAMENTE sui frame troncati coi primitivi altlib (selezione IN-SAMPLE-ONLY → gates
|
||||
study_marginal → DSR sull'intera griglia). Cross-check `study_marginal` non-troncato in coda:
|
||||
stessi verdetti (abs WEAK / marginal NEUTRAL / earns_slot False).
|
||||
- ⚠️ **STORIA CORTA**: ~3.1 anni totali → in-sample pre-HOLDOUT ~1.6a (meno warmup z), hold-out
|
||||
2025+ ~1.5a. Dichiarato: nessun numero qui ha il peso statistico di TP01 (7 anni).
|
||||
|
||||
## Metodo
|
||||
|
||||
Famiglia a griglia modesta, 1d, 50/50 BTC+ETH, vol-target 20% cap 2x (convenzione TP01), fee
|
||||
0.10% RT + sweep 0.00-0.30%:
|
||||
|
||||
- **FADE**: z-score(funding daily, lb) ≥ thr → short; ≤ −thr → long (fade dell'affollamento).
|
||||
- **FOLLOW**: z ≥ thr → long; ≤ −thr → short (sentiment momentum).
|
||||
- **GATE**: trend long-flat TP01-like (sgn mom 30d+90d) ma FLAT quando z ≥ thr (de-risk crowding).
|
||||
- **DIVERGE**: momentum prezzo 20d seguito se funding NON affollato (z ≤ −thr), fatto se z ≥ thr.
|
||||
|
||||
Griglia: 4 forme × lb {7,14,30,60}g × thr {0.5,1.0,1.5} = **48 celle**. Selezione cella
|
||||
**in-sample-only** (mai sul hold-out), deflated-Sharpe su tutte le 48, gates marginali
|
||||
(`marginal_vs_tp01`), boundary-shift della finestra di aggregazione funding (−0/3/6/9/12h),
|
||||
`eval_weights_smallcap` a $600.
|
||||
|
||||
## Risultati
|
||||
|
||||
Best in-sample per forma: **fade 0.34, follow 0.81, gate 1.76, diverge −0.34** — il segnale
|
||||
puro-funding è debole ovunque; solo la forma che CONTIENE il trend ha Sharpe.
|
||||
|
||||
**Cella scelta (in-sample): GATE lb=60 thr=1.5** — IS 1.76, FULL 1.16, HOLD 0.29.
|
||||
|
||||
| Gate | Esito |
|
||||
|---|---|
|
||||
| Deflated-Sharpe (48 celle) | **0.215** (null-max atteso 1.59) → **FAIL** (soglia 0.95) |
|
||||
| Assoluto (frame troncati) | **WEAK** (minFull +0.97, ma minHold **+0.04** BTC); fee-survive fino a 0.30% RT |
|
||||
| Marginale vs TP01 | **NEUTRAL**: corr **0.879** (hold 0.92), beta 0.77; uplift w25 hold **+0.001**; robust_oos=False (jackknife −0.074); multicut 2026 **−0.423** |
|
||||
| earns_slot / earns_slot_honest | **False / False** |
|
||||
| Realism | prefix-diff 0.0 (causale); boundary-shift sign-stable (uplift spread 0.047); smallcap $600 haircut ~0.00 (eseguibile, irrilevante senza edge) |
|
||||
|
||||
**Controllo decisivo (lezione TP01-DVOL-overlay)** — lo stesso trend long-flat SENZA gate funding:
|
||||
|
||||
```
|
||||
trend NUDO : IS 1.65 FULL 1.14 HOLD 0.37
|
||||
trend+GATE : IS 1.76 FULL 1.16 HOLD 0.29 corr(gated,nudo)=0.928
|
||||
delta del gate funding: FULL +0.013, HOLD −0.080
|
||||
```
|
||||
|
||||
→ **tutto lo Sharpe della "vincitrice" è il trend**; il contributo del funding è ~zero in-sample
|
||||
e **negativo sul hold-out**. Il gate è TP01 travestito.
|
||||
|
||||
**Miglior cella puro-funding** (la vera tesi del filone): FOLLOW lb=60 thr=0.5 — IS 0.81 ma
|
||||
**FULL −0.28, HOLD −1.69**, marginale **DILUTES** (uplift w25 hold −0.778). L'in-sample 0.81 era
|
||||
"long-il-toro-2023/24" travestito: z-alto di funding = rally già in corso; nel 2025-26 la stessa
|
||||
regola è sistematicamente dalla parte sbagliata. FADE (shortare il crowding) perde sul toro e non
|
||||
recupera abbastanza nel chop; DIVERGE negativa ovunque. NB: esistono celle gate con HOLD migliore
|
||||
(es. gate 7/1.5 HOLD 0.96) — sceglierle sarebbe **selection-on-holdout**, il gate del 2026-06-29
|
||||
esiste esattamente per questo.
|
||||
|
||||
## Perché fallisce (meccanismo)
|
||||
|
||||
1. Su asset MAJOR il funding è **quasi sempre positivo e segue il prezzo con lag**: lo z-score
|
||||
alto coincide col momentum già espresso → FOLLOW è trend-beta ritardato (muore quando il regime
|
||||
gira), FADE è shortare il toro. Non c'è l'anti-persistenza ("crowding → reversal") che la tesi
|
||||
richiede — a 1d, su BTC/ETH, le liquidazioni-da-crowding sono già nel prezzo prima che lo
|
||||
z-score giornaliero diventi estremo.
|
||||
2. L'unico uso "funzionante" (gate di de-risk sul trend) è **ridondante**: TP01-like è già flat
|
||||
quando serve. Stesso verdetto di macro-regime-gate (corr 0.989) e DVOL-overlay: la famiglia
|
||||
"overlay prudenziale su TSMOM" è esaurita — eredita lo Sharpe del trend e non aggiunge nulla.
|
||||
|
||||
## Verdetto
|
||||
|
||||
**SCARTATO.** `earns_slot=False`, `earns_slot_honest=False` (DSR 0.215). Il funding time-series
|
||||
non ha informazione direzionale propria su BTC/ETH a 1d; l'unica cella con Sharpe è trend
|
||||
travestito con contributo funding OOS negativo. Niente forward-monitor: non c'è una componente
|
||||
funding da monitorare. Con FC01 (carry cross-sectional) e il price-clock intraday, il filone
|
||||
"funding come segnale" su dati certi è ora **chiuso su tre lati**.
|
||||
|
||||
## Lascito
|
||||
|
||||
- `scripts/research/r0701_funding_ts.py` riusabile: (a) loader funding→daily **causale** per barre
|
||||
open-labeled (finestra [D, D+24h), gestione cadenza mista 8h/1h, flag validità copertura);
|
||||
(b) pattern "**truncation-honest**" per dataset a copertura parziale — replica di
|
||||
`study_family_honest` su finestra troncata per non gonfiare il T del DSR né diluire
|
||||
`has_insample_edge` con anni di zeri (riusabile per ogni serie corta tipo DVOL/HL).
|
||||
- I parquet `hlfund_*` restano certificati. Se mai si tornasse sul funding: gli estremi VERI
|
||||
(squeeze) vivono sugli alt, ma è di nuovo cross-sectional → STAT-MODE a $600, soglia di prova
|
||||
alta (e FC01 ha già mostrato la fragilità d'universo).
|
||||
@@ -0,0 +1,174 @@
|
||||
# 2026-07-01 — Pesi statici ri-ottimizzati + guardia-DD sul portafoglio a 4 sleeve
|
||||
|
||||
## Mandato
|
||||
Migliorare il portafoglio ATTIVO (TP01 41.25 / XS01 18.75 / VRP01 15 / SKH01 25) SENZA nuove
|
||||
strategie: (A) ri-ottimizzazione onesta dei pesi statici, (B) guardia-drawdown a livello
|
||||
portafoglio (replica del soft-guard vincente del tail-hedge lab 2026-06-23), (C) combinazione.
|
||||
NON meta-allocazione dinamica (già scartata, thread 3 del 2026-06-29).
|
||||
Script: `scripts/research/r0701_portfolio_opt.py`. Solo ricerca: nessun file di src/ o live toccato,
|
||||
i pesi sono PROPOSTI, non applicati.
|
||||
|
||||
## Baseline riprodotta
|
||||
`run_portfolio.py` oggi: **FULL Sh 2.12 / HOLD 2.21 / DD full 7.8% / CAGR 23.3%** (vs 2.13/2.30
|
||||
del diario Skyhook: i dati sono avanzati di una settimana, coerente). Matrice sleeve outer-join
|
||||
2019-03-14 → 2026-07-01 (TP01/SKH01 dal 2019, VRP01 dal 2021-05, XS01 dal 2024-01), combine
|
||||
identico a `portfolio.combined_daily` (pesi rinormalizzati per-riga sugli sleeve attivi).
|
||||
|
||||
## (A) Pesi statici — metodo
|
||||
- Ottimizzazione SOLO in-sample (pre-cut), valutazione OOS (post-cut), multi-cut
|
||||
{2024-01, 2024-07, 2025-01}. Sleeve con <120 giorni in-sample al cut (XS01 al 2024-01)
|
||||
PINNED al peso corrente (l'ottimizzatore non ha dati per giudicarli).
|
||||
- Criteri: MAXSH (max-Sharpe del combo simulato), RP (inverse-vol), ERC, MINVAR-R (min-var con
|
||||
vincolo ritorno ≥ pesi correnti). Null: EW (25×4) e CURRENT. Vincoli 5%≤w≤60%, Σ=1.
|
||||
- **Cap STRUTTURALI dichiarati** (prudenza, non statistica): **VRP01 ≤ 15%** (sleeve MODELLATO su
|
||||
DVOL ATM, stress-f non catturato — "niente short-vol da modello in deploy"), **XS01 ≤ 25%**
|
||||
(STAT-MODE, storia ~2.5 anni). Varianti sotto cap: MAXSH-STR (ottimizzato) e EW-STR
|
||||
(EW proiettato sui cap, parameter-free → TP 30 / XS 25 / VRP 15 / SKH 30).
|
||||
|
||||
### Risultati (pesi ottimizzati pre-2025, valutati FULL e HOLD-OUT 2025-26)
|
||||
| criterio | pesi TP/XS/VRP/SKH | FULL Sh | FULL DD | FULL CAGR | HOLD Sh | HOLD DD | HOLD CAGR | multi-cut ΔSh OOS | anni vinti |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| CURRENT | 41.2/18.8/15/25 | 2.12 | 7.8% | +23.3% | 2.21 | 3.5% | +16.0% | — | — |
|
||||
| MAXSH | 27.6/17.5/**39.5**/15.4 | 2.17 | 6.3% | +21.8% | 2.27 | 2.8% | +12.7% | +0.24/+0.08/+0.06 | **1/8** |
|
||||
| RP-invvol | 26.7/15.5/**41.6**/16.3 | 2.17 | 6.4% | +21.8% | 2.25 | 2.8% | +12.4% | +0.28/+0.07/+0.04 | — |
|
||||
| ERC | 26.6/16.2/**41.6**/15.7 | 2.17 | 6.3% | +21.7% | 2.25 | 2.8% | +12.4% | +0.29/+0.07/+0.05 | — |
|
||||
| MINVAR-R | 26.7/23.3/**29.6**/20.4 | 2.18 | 6.4% | +23.3% | 2.37 | 2.6% | +16.1% | +0.20/+0.10/+0.16 | 4/8 |
|
||||
| MAXSH-STR | 38.6/25/15/21.4 | 2.14 | 7.5% | +23.4% | 2.28 | 3.3% | +16.8% | **−0.00**/+0.03/+0.07 | 3/8 |
|
||||
| EW | 25/25/25/25 | 2.14 | 7.0% | +24.5% | 2.39 | 2.8% | +18.1% | +0.16/+0.17/+0.18 | 6/8 |
|
||||
| **EW-STR** | **30/25/15/30** | 2.10 | 8.5% | +25.2% | **2.35** | 3.2% | **+19.7%** | **+0.06/+0.13/+0.14** | **7/8** |
|
||||
|
||||
### Lettura onesta
|
||||
1. **Gli OTTIMIZZATORI non aggiungono nulla — anzi overfittano.** Tutti (MAXSH/RP/ERC/MINVAR-R)
|
||||
convergono sulla stessa mossa: scaricare TP01 e caricare **VRP01 al 30-42%** perché ha la vol
|
||||
più bassa (7.7%/anno). Ma quella è la vol del MODELLO (premio BS su DVOL, coda di stress non
|
||||
catturata): è esattamente il punto cieco tipo-CC01 ("Sharpe/vol implausibile → rischio
|
||||
nascosto"). E per-anno MAXSH vince **1/8 anni** vs CURRENT — l'uplift multi-cut è smoothing di
|
||||
vol modellata, non alpha di allocazione. **Dentro i cap** l'ottimizzatore (MAXSH-STR) converge
|
||||
quasi sui pesi correnti (38.6/25/15/21.4) e NON è persistente (−0.00 al cut 2024-01) →
|
||||
**i pesi correnti sono già ~ottimi per ciò che il backtest può giudicare lecitamente**.
|
||||
2. **L'unico segnale robusto è la DE-CONCENTRAZIONE parameter-free**: EW-STR (EW sotto i cap,
|
||||
nessun parametro fittato) vince **7/8 anni** (unica perdita 2021 −3.1pp), tutti i cut OOS
|
||||
(ΔSh +0.06/+0.13/+0.14), HOLD Sh 2.21→2.35, HOLD CAGR +16.0→+19.7%. Classico "1/N batte
|
||||
l'ottimizzazione" (DeMiguel). La sostanza: CURRENT dà il 41% al peggior Sharpe FULL del book
|
||||
(TP01 1.30) e EW-STR sposta ~11pp da TP01 verso SKH01 (+5pp) e XS01 (+6.25pp).
|
||||
3. **MA non è un free lunch risk-adjusted**: FULL Sh 2.12→**2.10** e FULL DD 7.8→**8.5%**
|
||||
(più SKH01, che ha DD standalone 18%, pesa di più nei primi anni a 2 sleeve). L'uplift è un
|
||||
TILT di ritorno verso gli sleeve research-grade con la storia più corta/caveat più pesanti
|
||||
(SKH01 = research win, book 230m da verificare a deploy; XS01 = STAT-MODE non eseguibile a 2k).
|
||||
E TP01 resta l'unico sleeve *deployed* live: il suo 41% nel report riflette una scelta di
|
||||
prudenza/eseguibilità, non solo statistica.
|
||||
|
||||
### VERDETTO (A)
|
||||
- **Ottimizzazione formale: NON MIGLIORA** (viola i cap strutturali o converge ai pesi correnti).
|
||||
**Per il book reale i pesi correnti reggono.**
|
||||
- **Candidato PROPOSTO (non applicato): EW-STR = TP01 30 / XS01 25 / VRP01 15 / SKH01 30** —
|
||||
uplift OOS persistente e per-anno (7/8), dentro i cap, al costo di FULL DD +0.7pp e FULL Sh
|
||||
−0.02. È una decisione di risk-appetite (più ritorno atteso spostando peso su sleeve
|
||||
research-grade), non un miglioramento dominante. Se si vuole adottare, farlo consapevoli che
|
||||
aumenta la dipendenza da SKH01/XS01, e NON tocca il book Deribit eseguibile (TP75/SKH25).
|
||||
|
||||
### Caveat metodologici (A)
|
||||
- Le 3 finestre multi-cut sono ANNIDATE (tutte contengono il 2025-26): non 3 conferme
|
||||
indipendenti. Il test disgiunto vero è il per-anno (7/8 per EW-STR è il dato forte).
|
||||
- EW-STR è stato costruito DOPO aver visto EW vincere (proiezione del null sui cap dichiarati):
|
||||
costruzione principled e parameter-free, ma va detto — non era nella griglia iniziale.
|
||||
- I pesi CURRENT stessi furono fissati (2026-06-23) conoscendo l'hold-out → il confronto
|
||||
"in-sample-opt vs CURRENT su hold-out" è semmai sbilanciato A FAVORE di CURRENT; EW-STR vince
|
||||
comunque.
|
||||
|
||||
## (B) Guardia-drawdown a livello portafoglio — metodo
|
||||
Soft-guard causale sul combo 4-sleeve (pesi CURRENT): esposizione del giorno D decisa dal DD
|
||||
dell'equity fino a **D−1**; trigger/re-entry sul **NAV ombra** non-guardato (lezione stops_lab:
|
||||
sull'equity congelata a expo=0 il DD non rientra mai); isteresi re-risk a 0.4·X come nel
|
||||
tail-hedge lab. Griglia X∈{3,4,5,6}%, derisk∈{0,0.5}. Selezione cella IN-SAMPLE (max Calmar
|
||||
pre-2025), verifica hold-out + multi-cut, **null de-levering** (expo costante c che pareggia il
|
||||
MaxDD in-sample della guardia: Sharpe di c·r è invariante → la guardia vale solo se batte quello).
|
||||
|
||||
### Risultati
|
||||
- Cella scelta in-sample: **X=5% / derisk=0.5** (IS Calmar 3.64 vs 3.24 baseline; IS DD 7.8→6.6%,
|
||||
IS CAGR 25.3→23.9%, IS Sh 2.13→2.12). In-sample batte il null de-levering c=0.84 a pari DD
|
||||
(CAGR 23.9% vs 21.0%) — il timing esiste in-sample.
|
||||
- **MA OOS è INERTE**: a ogni cut la cella scelta non scatta MAI (ΔSh +0.00, ΔDD 0.0pp, ΔCAGR
|
||||
0.0pp su tutti e 3 i cut) perché il DD OOS del 4-sleeve resta 3.5-4.8% < trigger 5%.
|
||||
**La diversificazione a 4 sleeve fa già il lavoro della guardia** (il 2022 del book è +11.6%
|
||||
con DD 3.6%: non c'è il grind da proteggere che aveva il combo TP01+GTAA del tail-hedge lab,
|
||||
DD 8.4% / 2022 −4.4%).
|
||||
- Trigger più stretti che scattano davvero COSTANO: X=3%/d=0.5 → HOLD Sh 2.10 (< 2.21), CAGR
|
||||
13.7% (< 16.0%); X=3-4%/d=0 whipsaw pieno (HOLD Sh 1.83-2.09; a X=4/d=0 il DD guardato IS
|
||||
sale addirittura a 8.8% > 7.8% baseline — accumulo di perdite su cicli off/on, lo stesso
|
||||
fenomeno visto in stops_lab con l'uscita totale).
|
||||
|
||||
### VERDETTO (B)
|
||||
**NON MIGLIORA.** La guardia-DD che vinceva sul combo TP01+GTAA (DD 8.4%, 2022 −4.4%) qui non ha
|
||||
bersaglio: il 4-sleeve ha già DD ~3.5-4.8% OOS. Coerente col thread meta-allocazione 2026-06-29
|
||||
(drawdown-control ridondante). Nota operativa: la cella X=5%/d=0.5 è *innocua* (non fa nulla
|
||||
finché il DD non supera il 5%) e in-sample mostrava timing genuino sopra il null de-levering —
|
||||
può valere come **circuit-breaker di emergenza sul live** (risk management, non alpha), ma non è
|
||||
un miglioramento statistico e non la si adotta come tale.
|
||||
|
||||
## (C) Combinazione
|
||||
(B) è inerte → niente da combinare. Per completezza: EW-STR + guard X=5%/d=0.5 → FULL Sh 2.08,
|
||||
DD 6.7%, CAGR 23.7% | HOLD invariato 2.35/3.2%/19.7% (la guardia lima il DD 2021-2023 del tilt
|
||||
a costo di −0.02 Sh FULL). Nessuna adozione proposta.
|
||||
|
||||
## Sintesi
|
||||
| config | FULL Sh | FULL DD | FULL CAGR | HOLD Sh | HOLD DD | HOLD CAGR |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **CURRENT (attivo)** | 2.12 | 7.8% | +23.3% | 2.21 | 3.5% | +16.0% |
|
||||
| EW-STR (proposta A, non applicata) | 2.10 | 8.5% | +25.2% | 2.35 | 3.2% | +19.7% |
|
||||
| CURRENT + guard 5%/0.5 (B) | 2.11 | 6.6% | +22.3% | 2.21 | 3.5% | +16.0% |
|
||||
| EW-STR + guard (C) | 2.08 | 6.7% | +23.7% | 2.35 | 3.2% | +19.7% |
|
||||
|
||||
- **(A) NON MIGLIORA in senso forte** — i pesi correnti reggono; l'unico candidato onesto è il
|
||||
tilt de-concentrante EW-STR (30/25/15/30), un trade-off di risk-appetite da decidere, non un
|
||||
upgrade dominante. Gli ottimizzatori in-sample o violano i cap strutturali (VRP01 40%) o
|
||||
riconvergono sui pesi correnti.
|
||||
- **(B) NON MIGLIORA** — guardia inerte OOS sul 4-sleeve; utile al più come circuit-breaker.
|
||||
- Nessuna modifica a `src/portfolio/sleeves.py` o al live. Test 160/160 verdi.
|
||||
|
||||
Script: `scripts/research/r0701_portfolio_opt.py` (~4s, vettoriale).
|
||||
|
||||
## Verifica avversariale (agente scettico)
|
||||
|
||||
Script: `scripts/research/r0701_portfolio_skeptic.py`. **VERDETTO: NON APPLICARE — CURRENT resta.**
|
||||
I numeri dell'agente riproducono ESATTAMENTE (via `StrategyPortfolio.combined_daily`, path di
|
||||
produzione: CURRENT 2.12/2.21, EW-STR 2.10/2.35, multi-cut +0.06/+0.13/+0.14, |diff|=0). Il claim
|
||||
muore altrove: l'uplift è **selezione-sull'hold-out di 2° ordine**, non alpha di allocazione.
|
||||
|
||||
1. **L'uplift OOS vive TUTTO nel 2025-26 — la finestra che ha selezionato i beneficiari.**
|
||||
Finestre OOS **DISGIUNTE** (i 3 cut del diario sono annidati): ΔSh EW-STR vs CURRENT =
|
||||
**−0.12** (2024H1) / +0.06 (2024H2) / **+0.14** (2025+). L'unica finestra OOS non toccata
|
||||
dalla selezione hold-out è negativa. Sharpe standalone IS→HOLD per sleeve: TP01 1.49→**0.30**,
|
||||
SKH01 1.42→**1.64**, XS01 1.22→**1.51**: EW-STR compra esattamente i due sleeve ammessi/affinati
|
||||
(2026-06-23 / 2026-06-19) PERCHÉ forti sull'hold-out e vende quello che lì collassa. Meccanico.
|
||||
2. **Pre-2025 (finestra non contaminata) EW-STR PERDE risk-adjusted**: ΔSh aggregato **−0.05**,
|
||||
Sharpe per-anno vinti **2/6** (2021 −0.29, 2020 −0.10, 2023/2024 negativi).
|
||||
3. **"7/8 anni vinti" è un artefatto di metrica**: è RITORNO composto (un tilt a più vol/ritorno
|
||||
li vince quasi per costruzione), non risk-adjusted. Su **Sharpe per-anno: 3/8**. E due dei 7
|
||||
"vinti" (2023, 2024) hanno margine **+0.03pp** = tie. Bilancio materiale sul ritorno:
|
||||
5 vinti / 1 perso (2021 −3.1pp) / 2 pari.
|
||||
4. **Plateau: liscio ma il gradiente è solo "meno TP01"**; a TP fisso, alzare SKH 25→30 è
|
||||
piatto/negativo (min multi-cut a TP35: +0.07→+0.02). Scomposizione: il tilt **solo-SKH**
|
||||
(+5pp, TP 36.25) da solo NON è persistente (multi-cut **−0.00**/+0.05/+0.04); il tilt
|
||||
**solo-XS** (TP35/XS25/VRP15/SKH25) è l'unico positivo su tutte le finestre disgiunte
|
||||
(+0.05/+0.02/+0.11) — ma pre-2025 fa ΔSh −0.00 (edge ≈ zero fuori dal 2025-26) e carica lo
|
||||
sleeve MENO eseguibile (STAT-MODE).
|
||||
5. **Realismo**: con l'outer-join rinormalizzato EW-STR tiene SKH01 al **50% effettivo nel
|
||||
2019-20 e 40% nel 2021-23** (book research 230m a metà portafoglio: da lì il FULL DD 8.5%).
|
||||
Punto onesto A FAVORE: un haircut d'esecuzione a drag su SKH01(-20/30%) e SKH+XS non ribalta
|
||||
il ranking hold-out (ΔSh resta +0.08..+0.13), ma i livelli assoluti crollano (HOLD Sh CURRENT
|
||||
2.21→1.64 al −30% su entrambi) e il beneficio resta di CARTA: il book Deribit eseguibile
|
||||
(TP75/SKH25) non è toccato dalla decisione.
|
||||
6. **Forking paths quantificato**: ≥15 config valutate sull'hold-out (7 vettori pesi + 8 celle
|
||||
guardia), EW-STR costruito dopo aver visto EW vincere. Su **500 tilt CASUALI cap-respecting**:
|
||||
51% batte CURRENT sull'hold-out (mediana ΔSh +0.00), solo 11% sul FULL, 30% su tutti e 3 i cut.
|
||||
EW-STR è al **94° percentile** dell'uplift hold-out fra i tilt casuali ≈ il **massimo atteso di
|
||||
~15 tentativi** (k/(k+1)=93.8%): l'uplift osservato è indistinguibile dal "best of 15 tilt
|
||||
casuali scelto sull'hold-out".
|
||||
|
||||
**Conclusione**: il tilt non è disonesto nei conti ma non è evidenza — è la stessa lezione del gate
|
||||
SELECTION-ON-HOLDOUT applicata al livello dei PESI (2° ordine: gli sleeve stessi sono stati
|
||||
selezionati su quell'hold-out). Unica direzione sopravvissuta: XS01 18.75→25 (solo-XS, positivo su
|
||||
tutte le finestre disgiunte), MA edge ≈0 fuori dal 2025-26, sleeve STAT-MODE non eseguibile a $600,
|
||||
e l'hold-out-strength di XS01 è a sua volta concentrata dal gate di dispersione nel regime 2025-26
|
||||
→ da rivalutare SOLO con più storia forward, non ora. **CURRENT (41.25/18.75/15/25) resta.**
|
||||
@@ -0,0 +1,132 @@
|
||||
# 2026-07-01 — Ondata multi-agente a 6 filoni: 0 nuovi sleeve, pesi CURRENT confermati, 1 gate nuovo
|
||||
|
||||
**Goal utente:** "cerca con diversi agenti altre strategie di trading. Prova di tutto e arriva a
|
||||
migliorare il portfolio presente. Lo scopo è sempre 50 euro days."
|
||||
|
||||
**Esito in una riga:** 6 filoni nuovi (scelti dopo scan dei ~65 diari per non ripetere strade morte)
|
||||
+ 1 verifica avversariale → nessun candidato supera i gate onesti; il portafoglio a 4 sleeve
|
||||
(41.25/18.75/15/25) **sopravvive a un attacco serio**, che è l'esito corretto per un book onesto.
|
||||
Miglioramento concreto dell'ondata = metodologico: **gate `weights_tilt_null`** codificato (sotto).
|
||||
|
||||
## I 6 filoni (tutti su harness onesto: study_family_honest, marginal scorer, DSR≥0.95, smallcap $600)
|
||||
|
||||
| # | filone | verdetto | perché muore |
|
||||
|---|---|---|---|
|
||||
| 1 | **Funding time-series** BTC/ETH (posizionamento, non carry) — `r0701_funding_ts.py` | SCARTATO | FOLLOW = trend-beta ritardato (HOLD −1.69), FADE = shortare il toro; la cella gate è TP01 travestito (controllo senza funding = stessi numeri, corr 0.93, ΔHOLD −0.08); DSR 0.215. **Filone funding chiuso su 3 lati** (FC01 carry, price-clock, TS-signal). |
|
||||
| 2 | **Breadth/internals alt** (51 HL) → BTC/ETH — `r0701_breadth_internals.py` | SCARTATO (rivisitabile) | Unico non-ridondante col trend (corr→TP01 0.40, lavora dove TP01 è attivo), assoluto PASS, marginal ADDS — ma jackknife −0.068 (uplift su UN mese) e DSR 0.433/104 celle. Con ~8 mesi di IS il 2024-toro non basta. **Rivisitare tra 1-2 anni di storia HL nativa.** |
|
||||
| 3 | **Residual momentum XS** (β-hedged vs BTC, 19 major) — `r0701_xs_residmom.py` | REDUNDANT | Cross-section la residualizzazione è quasi un no-op (lo z-score di XS01 già rimuove il mercato): corr→XS01 0.54, HOLD −0.24, corr(IS,HOLD) tra le celle **−0.37** (anti-predittivo = rumore). L'edge residuo-momentum vive nella coppia ETH/BTC (STATARB-RESID, resta in `paper_statarb`), non generalizza. |
|
||||
| 4 | **Pesi + guardia-DD** — `r0701_portfolio_opt.py` | vedi sotto | Unico candidato dell'ondata (EW-STR) → refutato dallo scettico. Guardia-DD X5%/d0.5: in-sample batte perfino il null de-levering, ma **OOS non scatta mai** (DD book 3.5-4.8% < trigger): la diversificazione fa già il lavoro. Utilizzabile solo come circuit-breaker di emergenza (risk mgmt, non alpha). |
|
||||
| 5 | **Affinamento VRP01** (sizing IV−RV, DVOL-mom, gate TP01) — `r0701_vrp_refine.py` | NON MIGLIORA | L'alpha è già tutto nel gate binario IV-rank; il gate TP01 è la trappola IS perfetta (schiva il 2022, ma taglia le settimane migliori dell'hold-out: multi-cut 0/5). **3° fallimento del filone "affinare VRP dentro il modello" → esaurito** finché cerbero-bite non cattura un crash reale (f di stress). |
|
||||
| 6 | **Stagionalità cross-sectional** HL — `r0701_xs_seasonal.py` | SCARTATO (allo step statistico) | Nessuna persistenza split-half sopra il null permutato max-statistic (p 0.16-0.23); l'unica struttura è il canale-beta dell'effetto weekday di mercato (famiglia trackF, già morta). Turnover ~2×gross/die = fee-death comunque. |
|
||||
|
||||
## EW-STR: il candidato refutato (caso di scuola di selezione-sull'hold-out di 2° ORDINE)
|
||||
|
||||
Il filone 4 proponeva **EW-STR** (TP01 30 / XS01 25 / VRP01 15 / SKH01 30 = equal-weight proiettato
|
||||
sui cap strutturali): HOLD Sh 2.21→2.35, multi-cut +0.06/+0.13/+0.14, "7/8 anni vinti" — dichiarando
|
||||
onestamente i caveat. La verifica avversariale (`r0701_portfolio_skeptic.py`, addendum nel diario
|
||||
pesi) lo uccide:
|
||||
|
||||
1. **Selezione di 2° ordine**: SKH01 fu ammesso (2026-06-23) *perché* alzava l'hold-out 2025-26;
|
||||
XS01 fu affinato conoscendo l'hold-out. EW-STR vende lo sleeve che collassa OOS (TP01 IS 1.49→
|
||||
HOLD 0.30) e compra i due selezionati su quell'OOS. Pre-2025: **ΔSh −0.05** (perde risk-adjusted).
|
||||
2. **Su finestre OOS disgiunte**: −0.12 (2024H1) / +0.06 (2024H2) / +0.14 (2025+) — tutto l'uplift
|
||||
vive nella finestra che ha selezionato gli sleeve. "7/8 anni" era sul ritorno composto; su
|
||||
Sharpe è 3/8.
|
||||
3. **Firma best-of-k**: su 500 tilt casuali cap-respecting, EW-STR siede al 94-100° percentile ≈
|
||||
k/(k+1) con k≈15 config viste sull'hold-out → uplift indistinguibile dal *best-of-15 scelto
|
||||
sull'hold-out*.
|
||||
4. **Realismo**: in pesi effettivi (outer-join rinormalizzato) EW-STR = SKH01 al 40-50% del book
|
||||
2019-23 (sleeve research a book 230m). Il book Deribit eseguibile (TP+SKH) non cambierebbe comunque.
|
||||
|
||||
Residuo onesto: il sub-tilt XS01 18.75→25 è positivo su tutte le finestre disgiunte ma edge≈0
|
||||
pre-2025 e STAT-MODE → rivalutare solo con storia forward.
|
||||
|
||||
## GATE NUOVO codificato: `weights_tilt_null` (src/portfolio/portfolio.py)
|
||||
|
||||
La lezione è ora codice, come per marginal-scorer e study_family_honest:
|
||||
- **`combine_outer(daily_cols, weights, lo, hi)`** — la combinazione outer-join/rinormalizzata di
|
||||
`combined_daily` estratta come funzione su pesi arbitrari (riusabile per studi di tilt).
|
||||
- **`weights_tilt_null(daily_cols, w_current, w_proposed, caps, floor, n, seed, k_seen)`** —
|
||||
null di tilt casuali uniformi sul simplesso dentro floor/caps: riporta `delta_hold/full/insample`,
|
||||
`frac_random_beat_hold`, `pctl_hold`, `bestofk_pctl=k/(k+1)` e `gate_pass`.
|
||||
**Regola: un cambio-pesi si applica solo se (1) delta_insample ≥ 0 e (2) pctl_hold < firma
|
||||
best-of-k** (necessario, non sufficiente: restano finestre disgiunte + realismo).
|
||||
Sul caso reale EW-STR: delta_hold +0.139, delta_insample **−0.054**, pctl **100°** vs best-of-15
|
||||
93.75° → `gate_pass=False`. Test: `tests/test_weights_tilt_null.py` (5 test, sintetici,
|
||||
deterministici; incluso lo sleeve "forte-solo-nell'hold-out" che DEVE fallire il gate).
|
||||
|
||||
## Lezione tecnica riusabile (dal filone 2)
|
||||
|
||||
`DatetimeIndex.view("int64")` su indici **tz-aware a risoluzione non-ns** (pandas 2.x) produce
|
||||
timestamp in scala sbagliata → un `merge_asof` può broadcastare l'ultimo valore su tutta la storia
|
||||
= **look-ahead che `causality_ok` non vede** (input fisso). Sintomo: decine di celle con Sharpe
|
||||
identico. Fix: epoca esplicita in ms. Verificato: `altlib.py` non è affetto (grep pulito); dettagli
|
||||
nel diario breadth.
|
||||
|
||||
## Onestà sul goal €50/giorno
|
||||
|
||||
Invariata (vedi CLAUDE.md): a capitale reale ~$600 il book onesto fa ~+€1.5/giorno atteso; la via
|
||||
resta capitale + tempo + target-vol, non un tilt di pesi né leva. Questa ondata conferma che il
|
||||
book attuale è vicino al massimo estraibile dai dati certificati disponibili oggi; le opzioni
|
||||
reali di crescita restano (a) storia forward che promuova i lead (STATARB-RESID, breadth tra 1-2
|
||||
anni), (b) capitale, (c) nuove fonti di dato (f di stress reale per VRP).
|
||||
|
||||
**File dell'ondata:** `scripts/research/r0701_{funding_ts,breadth_internals,xs_residmom,portfolio_opt,portfolio_skeptic,vrp_refine,xs_seasonal}.py`; diari `2026-07-01-{funding-ts-signal,alt-breadth-internals,xs-residual-momentum,portfolio-weights-ddguard,vrp-refinement,xs-seasonality}.md`; gate in `src/portfolio/portfolio.py` + `tests/test_weights_tilt_null.py`.
|
||||
|
||||
## ADDENDUM (stessa sera) — GTAA01 promosso a 5° SLEEVE: il miglioramento c'era, ma era in casa
|
||||
|
||||
Il secondo passaggio sull'archivio (non sui dati!) ha trovato il candidato che l'ondata di *ricerca*
|
||||
non poteva trovare: **EQ-GTAA01** (`src/portfolio/gtaa.py` — trend difensivo equity 6-ETF, vt12%,
|
||||
~30 anni di storia, netto fee IB) era stato validato il 2026-06-22 come *"primo miglioramento
|
||||
STRUTTURALE del rischio-aggiustato trovato in tutta la ricerca post-reset"*, messo in paper
|
||||
cross-venue (`paper_combo`, nel cron dal 2026-06-23) e **mai valutato come 5° sleeve del
|
||||
portafoglio attivo** (il giorno dopo arrivò SKH01 e il filone si fermò).
|
||||
|
||||
### Valutazione onesta (`r0701_gtaa_5th_sleeve.py`) — perché GTAA passa dove EW-STR è morto
|
||||
|
||||
| gate | EW-STR (refutato) | +GTAA01 (passa) |
|
||||
|---|---|---|
|
||||
| uplift IN-SAMPLE (pre-2025) | **−0.05** | **+0.09..+0.13** (2.134→2.23-2.26) |
|
||||
| finestre OOS disgiunte | −0.12 / +0.06 / +0.14 | **+0.05 / +0.19 / +0.25** (tutte >0) |
|
||||
| multi-cut (4 tagli) | solo annidate | **+0.21 / +0.21 / +0.22 / +0.25** |
|
||||
| provenienza dell'edge | sleeve selezionati sull'hold-out crypto | validato su OOS equity 2015+, **indipendente** dall'hold-out crypto |
|
||||
| corr al book | — | **+0.10** full, ~0.00 hold-out; is_hedge +0.30 (non-hedge) |
|
||||
| plateau | gradiente generico anti-TP01 | **monotono su w 10-30%, ogni cella migliora IS/FULL/HOLD/DD** |
|
||||
|
||||
Convenzione di realismo cross-venue (nuova, cablata nello sleeve): nei giorni senza barra equity
|
||||
(weekend/festivi US) il rendimento è **0.0** — capitale IB fermo, NON rinormalizzato sul crypto
|
||||
(due conti reali non si travasano nel weekend). Numeri quasi identici alla versione outer-join
|
||||
(differenze al 3° decimale) ma il modello è quello giusto. Attivazione nel book all'era crypto
|
||||
(2019-03-14): senza troncatura l'outer-join farebbe partire il book nel 1996 con 23 anni di
|
||||
solo-GTAA rinormalizzato al 100%.
|
||||
|
||||
### Decisione pesi
|
||||
Cella IS-best = w=30% (bordo griglia), **ingresso @20%** per scelta strutturale dichiarata:
|
||||
(a) cross-venue IB con paper operativo di soli 8 giorni — non diventa lo sleeve più grande del
|
||||
book al day-one; (b) precedente: ogni sleeve nuovo è entrato ≤25% (SKH01 25%, VRP01 15%);
|
||||
(c) costo CAGR (a 30%: 16.8% vs 18.8% a 20%) — e l'obiettivo €50/g vive di CAGR, non solo di
|
||||
Sharpe. Tutte le celle 10-30% passano comunque ogni gate. Pesi nuovi: **TP01 33 / XS01 15 /
|
||||
VRP01 12 / SKH01 20 / GTAA01 20** (i 4 preesistenti scalati ×0.80, rapporti conservati).
|
||||
|
||||
### Portafoglio 5-sleeve (run_portfolio, capitale 2k)
|
||||
| | FULL Sh | FULL DD | FULL CAGR | HOLD Sh | HOLD DD | HOLD CAGR |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 4-sleeve (baseline) | 2.12 | 7.8% | +23.3% | 2.21 | 3.5% | +16.0% |
|
||||
| **5-sleeve (+GTAA01 20%)** | **2.24** | **6.2%** | +18.8% | **2.46** | **2.2%** | +14.6% |
|
||||
|
||||
Per-anno: tutti gli 8 anni positivi, 2022 +7.0% (era +11.6%: GTAA soffre il whipsaw 2022, unico
|
||||
anno con dSh negativo −0.49 — dichiarato). Il trade-off è esplicito: −4.5pp di CAGR full per
|
||||
+0.12/+0.25 di Sharpe e DD 7.8→6.2 / hold-out 3.5→2.2. A pesi research (XS/VRP/SKH modellati o
|
||||
STAT-MODE) il CAGR era comunque di carta; lo Sharpe/DD del book è la metrica di verità.
|
||||
|
||||
### Cosa NON cambia
|
||||
Il **book live Deribit** (`deribit_book_sleeves` TP01+SKH01 75/25, `book_execute`) è INTATTO —
|
||||
GTAA è su IB, già tracciato forward da `paper_combo`. `paper_portfolio` da oggi traccia la
|
||||
composizione a 5 (regime-break dichiarato qui). Test: `tests/test_gtaa_sleeve.py` (registry,
|
||||
weekend-zero, attivazione, book live invariato) — suite 168/168.
|
||||
|
||||
### Lezione dell'addendum
|
||||
Prima di una nuova ondata di *ricerca*, ri-leggere l'archivio dei **validati-ma-non-integrati**:
|
||||
l'unico upgrade onesto del book in 3 ondate (104+5+6 filoni) era un edge già validato che
|
||||
aspettava solo la valutazione da sleeve. La diversificazione strutturale (secondo mercato,
|
||||
corr ~0.1) resta l'unica cosa che alza lo Sharpe del book senza nuovo alpha.
|
||||
@@ -0,0 +1,98 @@
|
||||
# 2026-07-01 — Affinamento VRP01 (sizing IV−RV / filtro DVOL-momentum / gate TP01): NON MIGLIORA
|
||||
|
||||
**Script:** `scripts/research/r0701_vrp_refine.py` (solo ricerca: `src/` e `scripts/live/` non toccati).
|
||||
**Esito: NON MIGLIORA — VRP01 baseline resta la config canonica.** La cella IS-best (gate TP01-skip)
|
||||
è un falso positivo in-sample da manuale: IS Sharpe 2.07 ma multi-cut **0/5**, hold-out **−0.14**
|
||||
(vs 0.59 del baseline), DSR 0.71. Nessuna modifica a `sleeves._vrp_combo_returns`.
|
||||
|
||||
## Cosa testava (celle NUOVE, verificate non ripetute nei diari)
|
||||
|
||||
Stessa pipeline di `options_vrp_v2.py` (pricing BS su DVOL reale, payoff sul path certificato,
|
||||
fee 12.5% del premio, f=1.0, tenor 7d, spread −0.28/−0.10) — cambiati SOLO gate/sizing:
|
||||
|
||||
1. **Sizing sul gap IV−RV** (carry atteso): size lineare `clip(vrp/scale,0,1)` (scale 8/12 pt) o
|
||||
percentile espandente causale del VRP; in aggiunta o in sostituzione del gate binario IV-rank.
|
||||
NB: il gate composito "IV-rank>0.30 AND IV−RV>0" è **già il baseline** (`gate_vrp=True`).
|
||||
2. **Filtro DVOL-momentum**: non vendere se `dvol[i]−dvol[i−k] > thr` (k∈{5,10}, thr∈{0, 5pt}).
|
||||
(Diverso da `dvol_directional` 2026-06-29, dove il DVOL-mom era segnale direzionale sul perp.)
|
||||
3. **Gate di regime da TP01**: skip o half-size quando TP01 è flat su entrambi gli asset (risk-off).
|
||||
4. Croce completa: **105 celle**, tutte contate nel deflated-Sharpe.
|
||||
|
||||
NON ripetuto (già scartato): gestione attiva intra-trade (PT/SL/vol-stop/delta-exit,
|
||||
`2026-06-20-vrp-active-management.md` → hold-to-expiry vince).
|
||||
|
||||
Metodo: selezione cella **in-sample (pre-2025)** con floor di attività IS ≥20%, hold-out 2025-26,
|
||||
**multi-cut 5 tagli** (2023-01→2025-01, richiesti ≥4/5 uplift>0), `altlib.deflated_sharpe` su tutti
|
||||
i 105 trial, effetto sul **portafoglio 4-sleeve** (TP01 41.25 / XS01 18.75 / VRP 15 / SKH01 25).
|
||||
|
||||
## Risultati (book 50/50 BTC+ETH, rendimenti settimanali)
|
||||
|
||||
Baseline riprodotto esattamente: **FULL Sh 1.09 / IS 1.26 / HOLD 0.59 / DD 12% / worst −7.4% / attivo 41%.**
|
||||
|
||||
| cella | IS Sh | FULL | HOLD | DD | worst | attivo |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **BASELINE** bin+ivr30 | 1.26 | **1.09** | **0.59** | 12% | −7.4% | 41% |
|
||||
| sizing lin 8pt (+ivr30) | 1.20 | 1.05 | 0.61 | 11% | −7.4% | 41% |
|
||||
| sizing lin 12pt (+ivr30) | 1.12 | 0.98 | 0.54 | 11% | −7.4% | 41% |
|
||||
| sizing rank (+ivr30) | 1.07 | 0.94 | 0.54 | 9% | −5.7% | 41% |
|
||||
| sizing lin 8pt **al posto di** ivr | 0.89 | 0.58 | **−0.38** | 21% | −7.4% | 86% |
|
||||
| sizing rank al posto di ivr | 1.03 | 0.69 | **−0.36** | 11% | −5.7% | 86% |
|
||||
| mom k=5 thr=5pt | 1.24 | 1.02 | 0.37 | 9% | −6.5% | 34% |
|
||||
| mom k=10 thr=5pt | 1.26 | 1.08 | 0.55 | 9% | −6.5% | 31% |
|
||||
| mom k=5 thr=0 | 1.78 | 1.24 | −0.10 | 5% | −3.9% | 20% |
|
||||
| mom k=10 thr=0 | 1.36 | 0.78 | **−0.47** | 7% | −3.9% | 18% |
|
||||
| tp01-skip | **2.05** | 1.43 | **−0.14** | 7% | −4.7% | 27% |
|
||||
| tp01-half | 1.72 | 1.34 | 0.27 | 7% | −4.7% | 41% |
|
||||
| IS-best: lin8+tp01-skip | **2.07** | 1.43 | **−0.14** | 6% | −4.2% | 27% |
|
||||
|
||||
**Multi-cut della IS-best vs baseline: uplift negativo in 5/5 tagli** (−0.35 → −0.73, monotono
|
||||
peggiorando verso il presente). **DSR** IS-best 0.709 (<0.95); baseline 0.472 dentro questa griglia
|
||||
(contesto onesto: nemmeno il baseline è statisticamente distinguibile su 105 trial correlati — ma è
|
||||
pre-committed, non selezionato qui). Celle che battono l'hold-out del baseline: **1/105** (lin8pt
|
||||
tp-off, +0.02 — rumore; non selezionabile: sarebbe selection-on-holdout).
|
||||
|
||||
### Frequenza d'intervento dei gate (settimane-trade del baseline = 163)
|
||||
|
||||
- **TP01-skip: blocca il 32.5%** delle settimane tradabili (TP01 flat il 38% dei giorni della
|
||||
finestra DVOL). NON è ridondante col baseline — lavora tanto — ma lavora **male** OOS.
|
||||
- mom thr=5pt: blocca 24-32%; mom thr=0: blocca 60-64% (quasi metà del book).
|
||||
|
||||
### Portafoglio 4-sleeve (VRP @15%)
|
||||
|
||||
| VRP nello slot | FULL Sh | FULL DD | HOLD Sh | HOLD DD |
|
||||
|---|---|---|---|---|
|
||||
| **baseline** | **2.12** | 7.8% | **2.21** | 3.5% |
|
||||
| IS-best (lin8+tp01-skip) | 2.11 | 7.8% | 2.12 | 3.5% |
|
||||
|
||||
Anche a livello portafoglio la variante **peggiora** l'hold-out (2.21→2.12) e non migliora nulla.
|
||||
|
||||
## Lettura (perché non migliora)
|
||||
|
||||
1. **L'alpha del VRP è già tutto nel gate IV-rank binario** ("vendi solo vol ricca"). Il sizing
|
||||
proporzionale al gap IV−RV è ridondante *dato* il gate (Δ ≈ ±0.05, dentro il rumore); usato al
|
||||
posto del gate, riporta dentro le settimane di vol povera e distrugge l'hold-out (−0.36/−0.38).
|
||||
Il carry atteso (IV−RV) NON è un segnale di *intensità* utile oltre il suo segno+rank.
|
||||
2. **Il gate TP01 è la trappola in-sample perfetta** (la lezione macro-gate al contrario): schiva il
|
||||
2022 (−6%→0%) gonfiando l'IS a 2.05, ma nel 2025-26 TP01 è flat proprio nei regimi di vol ricca
|
||||
in cui la vendita gated di put **guadagna** — il gate taglia le settimane migliori dell'hold-out
|
||||
(0.59→−0.14). Short-vol gated e trend sono complementari, non condizionabili l'uno sull'altro.
|
||||
3. **Il DVOL-momentum non aggiunge protezione**: il crash-skip IV-rank>0.90 + il long wing dello
|
||||
spread coprono già il run-up di vol; thr=0 è troppo aggressivo (blocca ~60%), thr=5pt è neutro
|
||||
(mai meglio del baseline su hold-out).
|
||||
|
||||
## Onestà / caveat (invariati)
|
||||
|
||||
Premio MODELLATO su DVOL ATM (no skew), book 1d, f di stress reale non catturato → tutto questo
|
||||
resta dentro il modello; il verdetto massimo possibile era "sleeve modellato migliorato", e non è
|
||||
stato raggiunto. Il DSR sulla griglia è un limite *superiore* (celle correlate + ~20 config storiche
|
||||
di options_vrp_lab/_v2 non contate).
|
||||
|
||||
## Azione
|
||||
|
||||
**Nessuna.** VRP01 resta com'è (`bin + gate_vrp + ivr30 + crash-skip 0.90`, hold-to-expiry).
|
||||
Terzo tentativo di affinamento fallito (dopo active-management e — per il portafoglio — DVOL
|
||||
overlay): il filone "migliorare VRP01 dentro il modello" è da considerarsi **esaurito**; il prossimo
|
||||
passo utile resta quello noto: f di stress reale quando cerbero-bite cattura un crash + skew reale
|
||||
sul long wing.
|
||||
|
||||
Script: `scripts/research/r0701_vrp_refine.py` (`--skip-portfolio` per la sola griglia).
|
||||
@@ -0,0 +1,74 @@
|
||||
# 2026-07-01 — Residual (idiosyncratic) momentum cross-sectional sui 19 major HL — SCARTATO/REDUNDANT
|
||||
|
||||
**Tesi.** STATARB-RESID (thread 4, 2026-06-29) ha mostrato che il momentum del residuo ETH−β·BTC
|
||||
(β OLS rolling, **sgn=+1**: le dislocazioni continuano a 1d) passa quasi tutti i gate su 2 gambe,
|
||||
fallendo solo il deflated-Sharpe (0.929<0.95). Ipotesi qui: portare lo **stesso meccanismo
|
||||
cross-sectional** sui 19 major di XS01 — per ogni alt, residuo vs β·BTC (β OLS rolling B), momentum
|
||||
del residuo (blend z-score [30,90] come XS01 o singolo L), long top-k / short bottom-k, vol-target
|
||||
20% — sperando che la **breadth** (18 stream invece di 1) alzasse il DSR dove il 2-gambe falliva.
|
||||
Script `scripts/research/r0701_xs_residmom.py` (riusa l'harness collaudato `xsec_v2_nonmom`:
|
||||
load_matrix, xs_engine — uguaglianza verificata maxdiff 0 —, deflated_sharpe, StrategyPortfolio).
|
||||
|
||||
Distinzione dal già-testato: IREV (idio-**reversal**, sgn=−1) era FALLITO nel filone C; IMOM di
|
||||
xsec_v2 usava il mercato equal-weight come fattore, B=60 fisso, senza blend ed era solo riferimento.
|
||||
Qui fattore = **BTC** (come STATARB-RESID, BTC escluso dal cross-section), B in griglia, blend
|
||||
z-score, probe con gate di dispersione p30 (parità strutturale con XS01), **selezione in-sample-only**.
|
||||
|
||||
## Setup
|
||||
|
||||
- Universo: 19 major HL certificati (`data/raw/hl_*_1d.parquet`), 913 giorni [2024-01-01 → 2026-07-01],
|
||||
barre vol=0 escluse. Fee 0.10% RT/gamba + sweep fino a 0.30%.
|
||||
- Griglia (57 trial): β-window {60,90,120} × lookback {blend[30,90], 30, 90} × k {3,5} × H {5,10,20}
|
||||
+ 3 probe gated (blend, k5, H10, gate dispersione p30). Cella scelta col solo Sharpe **pre-2025**;
|
||||
hold-out 2025-01-01 bloccato. Causalità: prefix-check ok (max_tail_diff 0).
|
||||
- Baseline (stessa finestra, sleeve attivi): XS01 FULL Sh **1.38** / HOLD **1.51** / DD 11%;
|
||||
book 4-sleeve FULL 2.12 / HOLD 2.21.
|
||||
|
||||
## Esito — collasso OOS della cella in-sample e nessuna cella competitiva
|
||||
|
||||
Cella scelta in-sample: **B60 L30 k3 H20** (IS Sh 2.00):
|
||||
|
||||
```
|
||||
FULL +0.67 | HOLD −0.24 | DD 35% | per-anno 2024 +54% / 2025 −15% / 2026 +5%
|
||||
corr→XS01 +0.54 | corr→TP01 −0.09 | DSR 0.510 (57 trial) | fee-sweep ok (F+0.59 a 0.30%)
|
||||
small-cap $600/min$5: haircut ≈ 0 (ma book multi-gamba → STAT-MODE comunque, come XS01)
|
||||
```
|
||||
|
||||
Portafoglio (il bar del mandato):
|
||||
|
||||
```
|
||||
BASE 4-sleeve FULL 2.12 DD 7.8% | HOLD 2.21 DD 3.5%
|
||||
SOSTITUZIONE XS01 → cand FULL 2.01 (−0.10) | HOLD 1.18 (−1.03) ← distrugge l'hold-out
|
||||
AGGIUNTA 5° sleeve @10/15% HOLD −0.31 / −0.53 ← diluisce
|
||||
MARGINAL vs BOOK: corr +0.23, uplift@10% full −0.04 / hold −0.31, multi-cut 2025 −0.31 / 2026 −0.14
|
||||
```
|
||||
|
||||
**Il verdetto non dipende dalla selezione** — aggregato delle 57 celle:
|
||||
- FULL max **+0.94**, HOLD max **+0.82** (mediane +0.52/+0.33): *nessuna* cella si avvicina a XS01
|
||||
(1.38/1.51), nemmeno cherry-pickando sull'hold-out.
|
||||
- corr→XS01 in [+0.47, +0.71], mediana **+0.62**: è momentum XS01 travestito (la residualizzazione
|
||||
vs β·BTC sposta poco: lo z-scoring cross-sectional di XS01 già rimuove implicitamente il mercato).
|
||||
- Solo 4/57 celle passerebbero un gate "diversificatore" (|corrXS|<0.6, FULL>0.5, HOLD>0), tutte con
|
||||
FULL ≤0.68 e IS incoerente.
|
||||
- **corr(Sharpe in-sample, Sharpe hold-out) attraverso le celle = −0.37**: l'in-sample è
|
||||
anti-predittivo dell'OOS su questa griglia — la firma del rumore, non di un edge con plateau.
|
||||
|
||||
## Perché la tesi è falsa (3 righe)
|
||||
|
||||
1. La breadth non ha alzato il DSR (0.51 ≪ 0.95) perché non c'è edge da deflazionare: il residuo
|
||||
vs β·BTC sul cross-section degli alt NON eredita l'edge del 2-gambe ETH−β·BTC — quel lead vive
|
||||
nella coppia specifica ETH/BTC (i due asset profondi/cointegrati), non nel long-tail degli alt.
|
||||
2. Cross-sectional, la residualizzazione è quasi un no-op: demeaning z-score (XS01) ≈ togliere il
|
||||
mercato → il candidato è una variante rumorosa di XS01 (corrXS mediana 0.62) con meno segnale.
|
||||
3. In-sample = solo 2024 (~1 anno utile dopo il warmup): la selezione onesta atterra su una cella
|
||||
2024-lucky che perde nel 2025-26 — l'ennesima conferma del gate selection-on-holdout.
|
||||
|
||||
## Verdetto: **SCARTATO/REDUNDANT.** Nessuno sleeve, nessun forward-monitor.
|
||||
|
||||
Coerente con il filone momentum-structure (2026-06-29): l'edge di XS01 sta nella sua struttura
|
||||
specifica (blend + gate dispersione), e le sue varianti — incluso l'idio-momentum vs BTC — sono
|
||||
REDUNDANT o peggio. Chiude anche il cerchio su STATARB-RESID: il meccanismo residuo-momentum resta
|
||||
valido SOLO come coppia ETH/BTC (forward-monitor `paper_statarb`), non generalizza al cross-section.
|
||||
Nessun impatto sul book live; config canonica invariata.
|
||||
|
||||
Script: `scripts/research/r0701_xs_residmom.py` (non committato, prefisso r0701_xs).
|
||||
@@ -0,0 +1,69 @@
|
||||
# 2026-07-01 — Stagionalità CROSS-SECTIONAL sull'universo Hyperliquid → SCARTATO
|
||||
|
||||
## Ipotesi
|
||||
|
||||
Effetti calendario **relativi tra i 19 alt major HL** (weekday tilt, turn-of-month,
|
||||
pattern weekend→lunedì nel cross-section), long/short market-neutral. L'angolo è diverso
|
||||
dal prior art già morto (trackF seasonality BTC/ETH = buy&hold travestito;
|
||||
crypto_weekend_signal/overnight = look-ahead; open_drive = artefatto calendario ucciso da
|
||||
`day_boundary_robust`): essendo il segnale **demeaned cross-section**, il buy&hold
|
||||
travestito è strutturalmente escluso… *in prima battuta* (vedi diagnostica beta sotto).
|
||||
|
||||
## Metodo (test statistico PRIMA della strategia)
|
||||
|
||||
Script `scripts/research/r0701_xs_seasonal.py`. Dati `data/raw/hl_*_1d.parquet`, 19 major
|
||||
XS01 (lista `src/portfolio/sleeves.XS_UNIVERSE`), **912 giorni** (2024-01-02 → 2026-07-01,
|
||||
~2.5 anni), 0 barre vol=0. Ritorno relativo x[t,a] = r[t,a] − media cross-section del
|
||||
giorno. Per ogni bucket calendario: **tilt bucket-specifico** per asset = media di x nel
|
||||
bucket − tilt incondizionato dell'asset (isola il calendario dal drift relativo generico).
|
||||
**Persistenza split-half**: Spearman rank-corr del vettore tilt (19 asset) H1 vs H2, per
|
||||
bucket. **Null**: 2000 permutazioni delle etichette entro ciascuna metà → distribuzione del
|
||||
**max-statistic** (controlla il multiple-testing sui bucket e il confound "alpha persistente
|
||||
dell'asset"). Soglia severa p<0.05 sul max. Regola del mandato: senza persistenza →
|
||||
**nessun backtest**.
|
||||
|
||||
## Numeri
|
||||
|
||||
| Test | Statistica | Null 95° pctl | p | Esito |
|
||||
|---|---|---|---|---|
|
||||
| [A] Weekday (7 bucket) | max rank-corr **+0.511** (Wed); Mon +0.38, Sun +0.37, Fri +0.04 | +0.604 | **0.159** | FAIL |
|
||||
| [B] Weekend vs feriali | +0.268 | +0.428 | **0.178** | FAIL |
|
||||
| [C] Turn-of-month (±2gg) | +0.221 | +0.451 | **0.226** | FAIL |
|
||||
| [D] IC weekend→lunedì | FULL −0.053 (t=−1.94); H1 −0.006 (t=−0.17) vs H2 −0.100 (t=−2.4) | — | non persiste | FAIL |
|
||||
|
||||
Con ~65 osservazioni per weekday per metà, lo std di una rank-corr su 19 asset è ~0.24 → il
|
||||
max-su-7 sotto puro rumore arriva a ~0.60: il +0.51 reale è *dentro* il rumore.
|
||||
|
||||
## Diagnostica: perché tutte le rank-corr weekday sono positive?
|
||||
|
||||
Non è tilt idiosincratico, è il **canale-beta**: il mercato EW ha un pattern weekday di
|
||||
*livello* che si ripete nelle due metà (Sun −42/−30 bps, Sat +26/+25, Mon +35/+32) e la
|
||||
dispersione di beta (0.53→1.26) lo proietta nel cross-section — rank-corr(tilt, beta):
|
||||
**Sun −0.642**, Sat +0.325, feriali ~0. Cioè la poca "persistenza" visibile è l'effetto
|
||||
weekday di MERCATO (dominio trackF, già scartato come buy&hold travestito) che rientra
|
||||
dalla finestra via beta. Un eventuale L/S "weekday tilt" sarebbe stato in gran parte un
|
||||
book short-beta-la-domenica — non un edge calendario cross-sectional.
|
||||
|
||||
## Note sui gate non raggiunti
|
||||
|
||||
- **day_boundary_robust** (obbligatorio per ogni effetto calendario): NON eseguibile sui
|
||||
19 alt — i dati HL locali sono **solo 1d**, il confine giorno non è ri-tagliabile. Anche
|
||||
con persistenza, il verdetto massimo sarebbe stato LEAD-forward *condizionato* a barre
|
||||
orarie HL, mai sleeve. Con lo SCARTATO allo step 1 il punto è moot, ma resta il vincolo
|
||||
per chiunque riapra il filone.
|
||||
- **Fee**: mai arrivati al backtest; nota strutturale: un weekday-tilt L/S ribilancia ogni
|
||||
giorno (weekday di domani ≠ oggi) → turnover ~2×gross/die, morte-per-fee quasi certa a
|
||||
0.10% RT anche con edge lordo.
|
||||
- **DSR**: non calcolato (nessuna cella backtestata — il branch strategia esiste nello
|
||||
script ma non si attiva senza persistenza).
|
||||
|
||||
## Verdetto
|
||||
|
||||
**SCARTATO** allo step statistico, senza backtest. Tre motivi: (1) nessuna persistenza
|
||||
split-half sopra il null permutato (p 0.16–0.23 su tutti i test, IC weekend→lunedì non
|
||||
persiste tra le metà); (2) la sola struttura visibile è il canale-beta di un pattern
|
||||
weekday di mercato — la stessa famiglia già scartata in trackF, non un effetto
|
||||
cross-sectional; (3) ~2.5 anni × effetto calendario × turnover giornaliero = rumore +
|
||||
morte-per-fee anche nello scenario migliore.
|
||||
|
||||
File: `scripts/research/r0701_xs_seasonal.py` (deterministico, seed 20260701, ~2 min).
|
||||
@@ -0,0 +1,132 @@
|
||||
# 2026-07-02 — Audit anchor timing-luck su XS01 e SKH01 (chiusura del pendente dell'ondata TIMING)
|
||||
|
||||
**Contesto.** L'ondata TIMING+CRT dello stesso giorno (`2026-07-02-timing-crt-wave.md`) ha stabilito
|
||||
che l'hold-out di TP01 (0.31) è la **migliore delle 24 ancore daily possibili** (mediana 0.04,
|
||||
P~0.86 di uno spike così per caso). Lo scettico ha flaggato l'audit come **pendente** sugli altri
|
||||
due sleeve a ribilanciamento/griglia ancorata: **XS01** (fase del ciclo di ribilanciamento H=10
|
||||
giorni) e **SKH01** (offset della griglia dual-TF 230m/690m, `origin='epoch'`). Questo diario
|
||||
chiude entrambi. Due agenti indipendenti, metodologia identica allo scettico TP01
|
||||
(`r0702_skeptic_offset.py`): ancore dichiarate **a priori**, parametri **identici** in ogni
|
||||
fase/offset (zero tuning per-ancora), **sanity replica bit-exact** della sleeve canonica prima di
|
||||
qualsiasi numero, block-bootstrap congiunto per P(spike).
|
||||
|
||||
**Script:** `scripts/research/r0702_anchor_xs01.py`, `scripts/research/r0702_anchor_skh01.py`.
|
||||
Nessun file di produzione toccato dagli agenti (solo docstring/narrativa aggiornate a valle, qui).
|
||||
|
||||
---
|
||||
|
||||
## Verdetto in una riga
|
||||
|
||||
**Il finding TP01 si replica su TUTTI e tre gli sleeve ancorati — su SKH01 più forte che su TP01 —
|
||||
e i numeri headline del book (HOLD 2.46) sono in parte best-of-anchors.** Ciò che sopravvive
|
||||
de-luckato: XS01 resta un diversificatore ammissibile (ensemble di fase FULL 1.25 / HOLD 1.31 /
|
||||
DD 11%); SKH01 resta ADDS (uplift del blend positivo a **tutte** le 23 fasi, corr ~0.08) ma i suoi
|
||||
numeri headline e — cruciale — **il gate DD<30% che l'ha selezionata** non reggono alla fase
|
||||
mediana. Stima onesta de-luckata del book 5-sleeve: **HOLD ~1.9–2.1, FULL ~2.0–2.2** (vs 2.46/2.24
|
||||
dichiarati).
|
||||
|
||||
---
|
||||
|
||||
## XS01 — fase del ciclo di ribilanciamento H=10 (10 fasi)
|
||||
|
||||
Sanity: replica di `_xsec_returns` con parametro `phase` → **max|Δ|=0.0 bit-exact** vs la canonica.
|
||||
NB: la canonica sui dati di oggi fa FULL 1.367/HOLD 1.479/DD 10.8% (i dichiarati 1.50/1.71/11%
|
||||
erano del 2026-06-19; deriva dati, confronto interno coerente).
|
||||
|
||||
| metrica | min | mediana | max | canonica (f0) | pctl f0 |
|
||||
|---|---|---|---|---|---|
|
||||
| Sh FULL | 0.41 | 1.21 | 1.64 | 1.37 | 85° |
|
||||
| Sh HOLD | 0.15 | 1.17 | 1.71 | 1.48 | 65° |
|
||||
| maxDD | 10.0% | 15.5% | 29.1% | 10.8% | **15°** |
|
||||
|
||||
- **Firma diversa da TP01: la fortuna NON è nell'hold-out** (65° pctl, non estremo) **ma nel
|
||||
DD** (15° pctl: il "DD 11%" dichiarato è uno dei più bassi delle 10 fasi; la fase tipica fa
|
||||
~15.5%, la peggiore 29%) e nel FULL (85°).
|
||||
- **Bootstrap**: spike osservato +0.38 su hold-out; **P(una fase qualsiasi ≥ +0.38) = 0.91–0.94**
|
||||
(block 10/20/40, B=4000) → la canonica è il massimo atteso di 10 stime correlate (corr mediana
|
||||
fra fasi 0.71).
|
||||
- **Ensemble 10 fasi** (lens di reporting onesta): **FULL 1.25 / HOLD 1.31 / DD 10.9%** — la
|
||||
diversificazione di fase riporta il DD al livello canonico con Sharpe ≈ mediana.
|
||||
- **Book 5-sleeve**: HOLD 2.44 (canonica) → 2.26 (fase mediana) → 1.75 (peggiore, marginalmente
|
||||
PEGGIO del book senza XS01 = 1.77) → 2.35 (ensemble). **Eredità di fortuna ~+0.17 HOLD.**
|
||||
FULL quasi insensibile (2.10–2.28).
|
||||
- **Gate di ammissione a fase mediana**: FULL 1.08 / HOLD 1.10 / DD 20.7% — **i numeri dichiarati
|
||||
non reggono** (Sharpe −0.3/−0.6, DD raddoppia). **La decisione però regge**: nessuna fase
|
||||
negativa, 8/10 con FULL≥1.0, book migliore a ogni fase tranne la peggiore (dove è neutro) →
|
||||
slot da diversificatore @15% difendibile; cambia la NARRATIVA (banda/ensemble, non 1.50/1.71/11%).
|
||||
- **Caveat**: storia ~2.5 anni (CI95 Sh HOLD canonica **[−0.04, +2.72]** — nessuna stima puntuale
|
||||
affidabile); dimensione **ora-del-giorno non testabile** (solo barre 1d HL: resta luck residua
|
||||
10 fasi × 24 ore non certificabile); XS01 è STAT-MODE → impatto su reporting/gate, non sul book
|
||||
live Deribit.
|
||||
|
||||
## SKH01-V2-DD — offset della griglia dual-TF 230m/690m (23 offset × 30m)
|
||||
|
||||
Sanity: offset 0 riproduce `_skyhook_returns()` **bit-exact** (max|Δ|=0.0 su 2666 giorni); maxDD
|
||||
harness BTC 21.4%/ETH 27.4% = identici al diario di ammissione; riuso diretto di
|
||||
`skyhook_entries`/`htf_features`/`merge_htf_to_ltf` con `resample(origin='epoch', offset=...)`,
|
||||
confini 690 ⊂ 230 verificati a ogni offset.
|
||||
|
||||
| metrica (book 50/50) | min | mediana | max | off0 (canonico) | pctl off0 |
|
||||
|---|---|---|---|---|---|
|
||||
| Sh FULL | 0.78 | 1.05 | 1.48 | 1.46 | 93° |
|
||||
| Sh HOLD | 0.44 | 0.93 | 1.64 | 1.64 | **98° (max)** |
|
||||
| minFull (2 asset) | 0.35 | 0.67 | 1.01 | 0.99 | 93° |
|
||||
| minHold | −0.10 | 0.39 | 1.25 | 1.25 | **98° (max)** |
|
||||
| blend 0.75·TP01+0.25·SKH, HOLD | 0.49 | 0.72 | 1.16 | 1.16 | **98° (max)** |
|
||||
|
||||
- **È uno spike, non un plateau**: gli offset adiacenti ±30m crollano (book HOLD 1.64 → 0.88 a
|
||||
off30). Firma identica all'ancora TP01, ma **più forte**: il canonico è al 93–98° pctl di OGNI
|
||||
metrica; minHold +1.26 e blend "0.31→1.17" sono il **massimo dei 23**.
|
||||
- **Gate DD<30% — il criterio per cui V2-DD fu selezionata — FALLISCE in 15/23 offset**
|
||||
(mediana DD: BTC 23.5%, ETH **29.2%** — pass per 0.8pt; peggiori 35.9%/42.1%). Il margine ETH
|
||||
"già sottile" (27.4%) è in realtà **sotto la mediana di fase**: la proprietà DD<30% è in larga
|
||||
parte fortuna di fase.
|
||||
- **Ciò che regge de-luckato**: **uplift del blend positivo a TUTTE le 23 fasi** (min +0.18,
|
||||
mediana +0.42, canonico +0.86) e corr→TP01 in [0.05, 0.11] a ogni fase → il verdetto
|
||||
ADDS/diversificatore sopravvive, ridimensionato. Alla fase peggiore invece non regge
|
||||
(minHold −0.10, DD fail).
|
||||
- **Bootstrap**: spike blend +0.44; **P(un offset qualsiasi ≥ +0.44) ≈ 0.70** (block 10/20/40);
|
||||
P(uplift≤0) = 0.03–0.04 → l'uplift è reale, l'ampiezza è best-of-23.
|
||||
- **Book 5-sleeve**: HOLD 2.44 (off0 = max dei 23) → mediana **1.96** → ensemble 2.12; FULL 2.24 →
|
||||
mediana 2.01. **Eredità di fortuna ~+0.5 HOLD** — molto più dei ~+0.10 dell'ancora TP01.
|
||||
- **Rilevanza LIVE (SKH = 25% del book Deribit reale)**: il cron è orario, i confini 230m no →
|
||||
ritardo uniforme {0..50} min (media 25). Re-sim del path reale (fill al close 5m del prossimo
|
||||
run orario, exit SOFTWARE come da `book.py`): book 50/50 **FULL 1.46→1.19, HOLD 1.64→1.15,
|
||||
DD 18.1%→24.6%**. La maggior parte del gap è l'assunzione fill-al-livello del backtest, non il
|
||||
ritardo orario in sé: nei crash le barre 230m **gappano attraverso lo SL software** (short
|
||||
sl2% modellato −2% → realizzato **−11/−23%**: ETH 2021-05-19, 2021-01-04, COVID 2020-03-13).
|
||||
Il disaster-SL on-book −30% copre solo la coda estrema della posizione netta.
|
||||
- **Caveat**: equity daily-step (lens Sharpe, come il canonico); l'ensemble di offset NON è
|
||||
eseguibile live (gira una sola griglia) → serve solo come stima de-luckata; sim hourly senza
|
||||
slippage/parziali.
|
||||
|
||||
## Book 5-sleeve — stima de-luckata congiunta (approssimata)
|
||||
|
||||
Ogni audit varia UN solo sleeve (gli altri restano canonici), quindi gli effetti non si sommano
|
||||
esattamente; ordine di grandezza dell'eredità di fortuna sull'HOLD 2.46: TP01-ancora ~+0.10,
|
||||
XS01-fase ~+0.17, SKH01-offset ~+0.5. **Stima onesta: HOLD ~1.9–2.1, FULL ~2.0–2.2, DD ~6%
|
||||
(invariato)** — il book resta largamente positivo e diversificato a ogni combinazione testata,
|
||||
ma il 2.46 è un massimo di ~24×10×23 configurazioni di ancora, non la stima centrale.
|
||||
|
||||
## Regole codificate in narrativa (CLAUDE.md aggiornato)
|
||||
|
||||
1. **I numeri headline di OGNI sleeve ancorato si citano con la banda d'ancora** (o come ensemble
|
||||
di fase): vale già per TP01, ora anche per XS01 e SKH01 e per l'HOLD del book.
|
||||
2. **Un gate di selezione (es. DD<30%) va verificato sulla banda di fase, non sull'ancora
|
||||
canonica** — SKH01-V2-DD fu selezionata per una proprietà che tiene in 8/23 fasi.
|
||||
3. Il candidato gate `anchor_luck_band()` in altlib sale di priorità: tre sleeve su tre ancorati
|
||||
mostrano la stessa firma, e il deflated-Sharpe non conta questo multiple-testing.
|
||||
|
||||
## Follow-up (non eseguiti, da valutare)
|
||||
|
||||
- **Rivalutare il peso 25% di SKH01 nel book live Deribit**: l'aspettativa onesta della gamba SKH
|
||||
è ≈ fase-mediana + path orario (blend HOLD ~0.72, non 1.17) con gap-through-stop reale nei
|
||||
crash. L'uplift resta positivo a ogni fase → non c'è motivo di rimuoverla, ma il sizing fu
|
||||
deciso su numeri best-of-23.
|
||||
- Cadenza cron a 230m (o trigger sui confini di barra) per chiudere il gap di path orario.
|
||||
- `anchor_luck_band()` in `altlib.py` come gate standard per candidati ancorati.
|
||||
- Audit ora-del-giorno di XS01 quando esisterà un feed HL intraday certificato.
|
||||
|
||||
**Stato:** XS01/SKH01/pesi/book live INVARIATI (nessun cambio operativo da questo audit — è un
|
||||
audit di reporting e di gate, e ogni cambio pesi dovrà passare `weights_tilt_null` +
|
||||
selection-on-holdout). Test suite 168 verdi. Script audit committati.
|
||||
@@ -0,0 +1,168 @@
|
||||
# 2026-07-02 — Ondata "video claims": Elliott (3 filoni), Albimarini (2), capital scaling 2-5k
|
||||
|
||||
**Contesto.** Su richiesta: meccanizzare e testare le claim falsificabili di 4 video didattici
|
||||
italiani (3 sulle onde di Elliott, 1 ricostruzione della "strategia Albimarini" — double diagonal
|
||||
a credito su SPX), più l'audit dei muri di scala visto l'annuncio che il conto live verrà portato
|
||||
a **2.000-5.000 $**. Sei agenti indipendenti, harness altlib, fee 0.10% RT, hold-out 2025-26 mai
|
||||
usato per selezionare, banda d'ancora obbligatoria (regola dello stesso giorno). Nessun PASS →
|
||||
nessuno scettico necessario. Nessun file di produzione toccato.
|
||||
|
||||
**Scripts:** `scripts/research/r0702_ell_{rangecycle,fibconfluence,channel}.py`,
|
||||
`r0702_alb_{structure,claims}.py`, `r0702_capital_scaling.py`.
|
||||
|
||||
---
|
||||
|
||||
## Verdetto in una riga
|
||||
|
||||
**Elliott 3/3 SCARTATO** (range-cycle = rumore 0/24 Bonferroni; canale = Donchian travestito che
|
||||
non batte il Donchian; Fibonacci = effetto posizione, non numeri magici). **Albimarini 2/2
|
||||
SCARTATO** (la diagonale non domina nemmeno nel modello ed è fee-negativa su Deribit; il track
|
||||
record dichiarato è consistente con zero skill al 20-45%, e va in rovina in ogni finestra storica
|
||||
con una coda). **Capital scaling: l'unico vincolo vero a 2-5k è il cap $300/asset** — da alzare a
|
||||
equity/2 al momento del deposito; tutto il resto si scioglie da solo o resta fuori comunque.
|
||||
|
||||
---
|
||||
|
||||
## ELL-A — "range-cycle" (onda 1 compressa → onda 3 ampia): RUMORE
|
||||
|
||||
Claim di Frost&Prechter/Larry Williams meccanizzato senza conteggio soggettivo: 2 definizioni di
|
||||
segmento (flip TSMOM, zigzag k·ATR causale), impulso iniziale (N∈{3,5,10} barre, range/ATR
|
||||
pre-segmento) vs ampiezza del seguito (claim A) e troncatura dell'ultimo terzo (claim B).
|
||||
BTC/ETH 1d+1w, Spearman con null permutato (2000), Bonferroni m=24.
|
||||
|
||||
- **0/24 celle significative** (miglior p raw: A 0.061, B 0.058; Bonferroni 1.00 ovunque, FULL e
|
||||
pre-2025). 7-9/12 celle col segno "giusto" = moneta su celle correlate.
|
||||
- **Nessuna cella weekly regge a tutte e 7 le ancore** (p 0.02-0.05 di singole ancore = il
|
||||
timing-luck d'ancora che da oggi non crediamo più; alcune celle flippano segno tra ancore).
|
||||
- **Artefatto previsto e smascherato**: normalizzando col range POST-impulso la correlazione
|
||||
diventa negativa per pura meccanica del denominatore — chi "conferma" il claim così misura
|
||||
l'artefatto.
|
||||
- Coerente con le famiglie di compressione già fallite nello sweep 104 (BRK08 NR7, BRK10
|
||||
BB-squeeze, CMB05, VOL12). Campione strutturalmente piccolo (mediana 78 segmenti daily, ~30
|
||||
weekly). Strategizzazione correttamente non eseguita (statistica prima della strategia).
|
||||
|
||||
⚠️ **LEZIONE PANDAS (gemella di `view("int64")` del 2026-07-01):** `resample("7D",
|
||||
origin=...)` in pandas 2.x **ignora silenziosamente `origin`** ("D" non è Tick-like, solo un
|
||||
RuntimeWarning) → tutte le ancore weekly uscivano identiche e la banda d'ancora sarebbe stata una
|
||||
banda finta di larghezza zero. Serve **`"168h"`**. Da ricordare per ogni futura banda d'ancora
|
||||
settimanale.
|
||||
|
||||
## ELL-B — Confluenza di Fibonacci: EFFETTO POSIZIONE, non numeri magici
|
||||
|
||||
Zigzag causale non-repainting (livelli usabili solo dalla conferma del pivot, causality_ok
|
||||
max_tail_diff=0.0), tocco fresh intrabar, reazione ATR-normalizzata a 5/20 barre, ~24k/14k tocchi
|
||||
IS a 1h. **Tre null in scala di durezza**: (A) rapporti uniformi casuali, (B) ri-sorteggiati per
|
||||
swing, **(C) location-matched** (Fib ± jitter 2-8%: "0.618 è speciale vs 0.58/0.66?").
|
||||
|
||||
- Vs null A/B i Fib sembrano buoni (pctl 0.82-1.00) — **vs null C crollano a 0.39-0.68 = puro
|
||||
caso**. L'apparente vantaggio è interamente l'effetto di *posizione* (i rapporti Fib cadono in
|
||||
zone mediamente decenti del range), non dei numeri di Fibonacci.
|
||||
- **Confluenza** (2 griglie entro 0.25·ATR): FAIL 4/4 celle — non reagisce più delle confluenze
|
||||
di rapporti casuali. Break-rate al tocco ~46-50% (moneta); su 1h i Fib veri vengono bucati più
|
||||
spesso del placebo.
|
||||
- In assoluto comunque nulla da tradare: +2.6/+5.6 bps a 5 barre (fee = 10 bps), negativo a 20
|
||||
barre e su 1d. Strategizzazione correttamente saltata da gate a priori.
|
||||
- **Pattern di test riusabile**: il null location-matched è IL test giusto per ogni claim su
|
||||
"livelli speciali" (Fib, pivot, round numbers) — separa la qualità della posizione dal numero.
|
||||
|
||||
## ELL-C — Tecnica del canale (Ftaonline): DONCHIAN TRAVESTITO
|
||||
|
||||
Implementazione onesta completa (zigzag causale, vincolo onda-2>origine, canale 0→min-2 con
|
||||
parallela dal max-1, entry al close della barra fuori, target 1.618×, stop=invalidazione,
|
||||
speculare short, 27 celle TF×k×varianti).
|
||||
|
||||
- **family_honest boccia**: cella scelta in-sample (1h k4 long, IS 1.40) → hold-out **−0.87**;
|
||||
DSR 0.685 < 0.95; earns_slot=False. Marginale vs TP01: uplift positivo solo 2020-21 =
|
||||
beta-trend del toro (corr 0.35).
|
||||
- **Il Donchian banale a pari geometria/frequenza eguaglia o batte il canale a ogni TF**
|
||||
(corr 0.43-0.53): vincoli d'onda + 1.618 non aggiungono nulla. Il segnale-2 ("onda 5") è
|
||||
quasi inerte (+0.00-0.07).
|
||||
- **Target 1.618 = geometria, non magia**: toccato con la frequenza di distanze identiche
|
||||
piazzate a caso (5/6 celle p>0.18). Claim discriminante "in canale = correttivo":
|
||||
direzionalmente sensata, mai significativa (p 0.08-0.45); l'uscita dal canale è momentum da
|
||||
breakout, già in casa.
|
||||
- **Anchor-luck di nuovo**: la miglior cella 4h fa hold 1.54 all'ancora 00:00, la migliore delle
|
||||
4 (banda [0.35, 1.54]) — quarta conferma della firma nello stesso giorno.
|
||||
|
||||
## ALB-A — Double diagonal "Albimarini" su BTC/ETH: NON DOMINA, niente LEAD
|
||||
|
||||
Portata su Deribit (che ha le scadenze giornaliere per la long T+1) col motore DVOL di VRP01
|
||||
(riprodotto esatto: 1.09/0.59/11.8%), strike scalati in vol (2σ/5g ≈ il "9% su SPY"), gate
|
||||
IV-rank canonico non riottimizzato, 2021-26 con LUNA/FTX/2022, banda skew f∈{0.6,0.8,1.0,1.3}.
|
||||
|
||||
- **Il claim strutturale del corso è falsificato**: gated, l'iron condor stessa-scadenza batte la
|
||||
diagonale a ogni f (1.05 vs 0.95 a f=1.0), Δ negativo ogni anno. La long T+1 è **assicurazione
|
||||
di coda quantificata** (+12/+33 bps nei 5% peggiori vs ~1 bps di costo medio), non dominanza.
|
||||
- **Senza gate tutte le strutture perdono** a f≤1.0 → terza conferma indipendente: nel VRP
|
||||
l'alpha è il filtro di regime, non la vendita di premio. VRP01 resta superiore su tutta la
|
||||
banda f (CAGR 8.3% vs 1.4%).
|
||||
- **Il meccanismo dell'illusione riprodotto in vitro**: celle z≥2.5 = 0 perdite su 142 trade,
|
||||
Sharpe 5.9-∞ (il punto cieco CC01 in forma opzioni); la best in-sample (7.88) crolla a −0.51
|
||||
in hold-out. Seconda occorrenza del pattern "0-perdite = Sharpe implausibile" in una settimana
|
||||
→ **si rafforza il caso per il gate `implausible_sharpe` in altlib** (raccomandato dal filone
|
||||
CC01, ora con 2 casi d'uso).
|
||||
|
||||
## ALB-B — Audit dei numeri dichiarati (28 trade, 82%, PF 5.16, "420% annuo"): ZERO SKILL
|
||||
|
||||
SPY 1d 1996-2026 (parquet già in repo dal filone GTAA) + BTC/ETH certificati; MC 200k; replay
|
||||
claim-anchored calibrato per riprodurre esattamente il video.
|
||||
|
||||
- **L'82% è incoerente col "deep OTM" in entrambe le letture**: strike a 9% → win strutturale
|
||||
99.5% (P(|move 4td|>9%) = 0.53%, fat-tail 88× la normale); 82% letterale → strike a 2.63% ≈
|
||||
1.2σ, non deep. In entrambi i casi win-rate = delta venduto.
|
||||
- **P(track record del video senza edge) = 20-45%**; il **78.6%** di tutte le finestre 6-mesi
|
||||
1996-2026 lo avrebbe prodotto (lettura deep). Il PF ~5.1 esce per costruzione quando la coda
|
||||
non è campionata.
|
||||
- **Replay attraverso le code reali**: rovina nel 1998/2002/2020 col sizing 1→4 dichiarato;
|
||||
perfino regalando +20% di premio, 1 contratto fisso fa CAGR +4.1% con maxDD −95%. La stessa
|
||||
semestrale del video annualizza a +73-77%, non 420%. Su BTC/ETH a EV=0: +12%/DD−68% e
|
||||
+0%/DD−88%.
|
||||
- **La diagonale lascia passare il 12-40%** della perdita naked in un −10% overnight (dipende dal
|
||||
vol-spike). Su Deribit la double diagonal è **fee-negativa a qualsiasi size** (fee 8 gambe =
|
||||
194-221% del theta raccolto a questi strike): il capitale sposta solo il muro di margine.
|
||||
- Che cosa distingue VRP01 (sopravvissuto allo stesso tipo di audit): defined-risk per
|
||||
costruzione, gate IV-rank che ribalta l'hold-out, sizing fisso — l'opposto esatto su tutti e
|
||||
tre gli assi.
|
||||
|
||||
## Capital scaling 600 → 2000/3500/5000 (r0702_capital_scaling.py)
|
||||
|
||||
| capitale | TP01 haircut Sh | book investibile % (cap $300) | K=2 exec-turn % | statarb haircut | spread ETH conc. | XS01 exec-turn % |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 600 | +0.010 | 100.0% | 90.5 | −0.004 | 0 | 56.9 |
|
||||
| 2000 | −0.002 | 85.9% | 95.7 | 0.000 | 3 | 87.4 |
|
||||
| 3500 | −0.002 | 63.7% | 97.5 | 0.000 | 5 | 89.3 |
|
||||
| 5000 | −0.002 | **48.9%** | 98.2 | 0.000 | 7 | 90.8 |
|
||||
|
||||
- **Il vincolo dominante è UNO: `max_notional_per_asset_usd`=300.** A 5k il book girerebbe a
|
||||
metà del target (cap binding nel 39-46% delle barre). **Proposta config (da applicare AL
|
||||
deposito): cap = equity/2** → $1000 a 2k, $1750 a 3.5k, $2500 a 5k. `min_order_usd`=5 va
|
||||
LASCIATO (il fee drag risale solo al tetto noto ~0.4%/anno; l'isteresi artificiale costerebbe
|
||||
più in lag — lezione ondata timing).
|
||||
- Min-order/haircut TP01 ≈ 0 a ogni capitale (conferma indipendente del "−74% ordini" a 600 →
|
||||
39-43% saltati a 5k, tutti micro). Statarb: haircut 0 da 2k (il muro era l'edge, non la scala).
|
||||
- Tranching K=2: matematicamente regge a 5k (98.2% turnover-tranche), ma il blocco vero resta il
|
||||
feed intraday fuori path certificato + beneficio solo di varianza → **non cablare**.
|
||||
- Opzioni: il muro d'ingresso ETH cade a ~2.6k (3-7 spread concorrenti a 2-5k, fee solo 5-6% del
|
||||
credito grazie al cap 12.5%; rettifica vs ALB-A: margine spread ETH oggi $66-76). BTC granulare
|
||||
da ~$4.7k. **La regola "niente short-vol da modello" non decade col capitale.**
|
||||
- XS01: soglia ~20k confermata (a 5k il 9%+ del turnover non esegue). CC01: il capitale non è
|
||||
mai stato il muro (fuori per ragioni strutturali).
|
||||
- **Aspettativa onesta**: col CAGR de-luckato del book live (10-15%, audit SKH01): **2k ≈
|
||||
€0.55-0.82/giorno; 5k ≈ €1.37-2.05/giorno**. Il salto di capitale cambia cosa è eseguibile,
|
||||
non l'ordine di grandezza del ritorno (€50/g ≈ 130k, invariato).
|
||||
|
||||
## Lezioni
|
||||
|
||||
1. **Il null giusto smonta il livello "speciale"**: location-matched (ELL-B) per i livelli,
|
||||
Donchian-equivalente (ELL-C) per i pattern, EV=0-calibrato (ALB-B) per i track record. In
|
||||
tutti e tre i casi il claim sopravviveva ai null ingenui e moriva su quello giusto.
|
||||
2. **`resample("7D", origin=...)` ignora origin in pandas 2.x** → usare `"168h"` per le bande
|
||||
d'ancora settimanali.
|
||||
3. Secondo caso "0-perdite = Sharpe implausibile" (celle deep-OTM di ALB-A, dopo CC01) → il gate
|
||||
`implausible_sharpe` in altlib sale in coda ai lavori insieme ad `anchor_luck_band`.
|
||||
4. L'anchor-luck è comparso spontaneamente anche in questa ondata (celle weekly ELL-A, 4h ELL-C)
|
||||
— la regola della banda d'ancora sta già pagando.
|
||||
|
||||
**Stato:** nessun nuovo sleeve, nessun forward-monitor aggiunto, book live INVARIATO. Un'azione
|
||||
operativa pende sul FUTURO deposito: alzare `max_notional_per_asset_usd` a equity/2 (decisione
|
||||
utente al momento del funding). Test suite verde. 6 script committati.
|
||||
@@ -0,0 +1,186 @@
|
||||
# 2026-07-02 — Ondata timing + CRT (8 filoni multi-agente + scettico): 0 nuovi sleeve, 1 finding strutturale (anchor timing-luck di TP01)
|
||||
|
||||
**Obiettivo (goal utente):** "cerca su Deribit altre strategie profittevoli magari con timing
|
||||
differenti oltre a quelle già valutate". Prima ondata interamente dedicata al TIMING: dopo che le
|
||||
ondate precedenti hanno coperto segnali (104 famiglie alt-sweep), universi (HL cross-sectional),
|
||||
opzioni (VRP/gamma) e pesi (EW-STR/weights_tilt_null), qui si attacca la dimensione rimasta: *quando*
|
||||
si campiona, si decide e si esegue. Su richiesta utente, seconda tranche dedicata alla **CRT (Candle
|
||||
Range Theory)**, il pattern sweep-and-reclaim a 3 candele di scuola ICT/Wyckoff.
|
||||
|
||||
Inventario preventivo per non rifare lavoro: SEA01-09 coprivano già hour-of-day, day-of-week,
|
||||
weekend, turn-of-month, sessioni; l'onda intraday 2026-06-21 il sub-daily (con il lead prevday in
|
||||
forward-monitor); `tp01_lowfreq` i TF 4h/12h/1d. Angoli NON coperti individuati e testati: clock a
|
||||
eventi, calendario scadenze Deribit, ancora di ribilanciamento, clock più lenti del daily, velocità
|
||||
del segnale regime-condizionata — più i 3 tagli CRT (base, multi-TF, contesto).
|
||||
|
||||
**Esito: 7 filoni FAIL + 1 finding strutturale confermato dallo scettico (timing-luck dell'ancora
|
||||
di TP01). Nessun nuovo sleeve. Nessuna modifica al book live. Il soffitto ~1.3 direzionale
|
||||
BTC/ETH resta intatto anche cambiando il clock.**
|
||||
|
||||
---
|
||||
|
||||
## Filone 1 — Event-clock bars (tempo-informazione): FAIL
|
||||
|
||||
`scripts/research/r0702_eventclock.py`. Volume/vol/range bars da 5m certificato (soglie causali
|
||||
EWMA-90g shift(1), barre medie 4h/12h/24h), 5 strategie (TSMOM multi-orizzonte, Donchian 10/30d,
|
||||
EWMA-cross), 45 celle event + 15 controlli wall-clock = 60 trial.
|
||||
|
||||
- Null decisivo (stesso segnale, stessa frequenza, wall-clock): event batte wall su ENTRAMBE le
|
||||
finestre solo in **4/45 coppie (9%)** — rumore. Pattern incoerenti (EWMA event vince IS 9/9 ma
|
||||
perde HOLD 9/9; Donchian-30d l'esatto opposto = selection-on-holdout in agguato).
|
||||
- Cella scelta in-sample (volume-bars 24h, DONCH-10d): IS 1.45 → **HOLD −0.46** (2025 −7.4%,
|
||||
2026 −18.1%). Marginal NEUTRAL (corr 0.74 a TP01 = trend travestito), earns_slot_honest=False.
|
||||
- Executability comunque assente: in alta attività le barre "24h" chiudono in 3.6-4.6h (p5) → il
|
||||
cron orario le eseguirebbe in ritardo; i clock 4h richiedono monitoraggio sub-orario.
|
||||
|
||||
**Lezione: campionare a tempo-informazione non normalizza nulla di monetizzabile sul feed
|
||||
certificato; il clock non è la dimensione dove vive l'edge.**
|
||||
|
||||
## Filone 2 — Calendario scadenze Deribit: FAIL (0/24 celle)
|
||||
|
||||
`scripts/research/r0702_expiry_calendar.py`. Griglia dichiarata a priori: 4 finestre × 3 tipi expiry
|
||||
(weekly ven 08:00 UTC / monthly ultimo ven / quarterly) × 2 asset, Bonferroni |t|≥3.08; 3 null
|
||||
(placebo weekday, anchor-shift ±2/4h, permutation 500 calendari).
|
||||
|
||||
- Nessuna cella passa Bonferroni (max |t| 2.69). Il post-expiry drift monthly (+0.61/+0.77%) ha il
|
||||
segno della teoria ma: non batte il placebo giovedì, permutation pctl 84-95 (non estremo), si
|
||||
INVERTE sul quarterly (dove l'OI massimo dovrebbe amplificarlo) ed è guidato dal 2019 → rumore.
|
||||
- Unica cella robusta ai null: weekly [-24,0) negativa (gio→ven), ma è indistinguibile dal
|
||||
day-of-week (famiglia SEA già morta) e netta fee fa Sharpe ~0.08 (52 eventi/anno × 0.10% RT).
|
||||
- Best-in-sample tradabile: IS +0.90 → **HOLD −0.52**; DSR 0.230 su 24 trial.
|
||||
- Nota tecnica: colpito ESATTAMENTE il pitfall pandas 2.x documentato (epoca in secondi da
|
||||
`date_range` su tz-aware → matching vuoto); fix con epoca ms esplicita. La lezione 2026-07-01
|
||||
ha pagato.
|
||||
|
||||
## Filone 3 — Anchor timing-luck di TP01 + tranching: FINDING (verificato dallo scettico)
|
||||
|
||||
`scripts/research/r0702_tp01_offset.py` + scettico indipendente `r0702_skeptic_offset.py`
|
||||
(ricostruzione bit-exact con codice diverso, zero riuso; h=0 riproduce `tp01_baseline_daily`
|
||||
esattamente; vol-target ricalcolata per ancora; nessun look-ahead — verificato con troncamenti).
|
||||
|
||||
**Claim 1 CONFERMATO: l'hold-out 2025-26 "Sharpe 0.31" di TP01 è in larga parte fortuna
|
||||
dell'ancora 00:00 UTC.** La stessa strategia CANONICAL alle altre 23 ancore orarie: HOLD mediano
|
||||
**0.04**, banda **[−0.13, +0.30]** — e il massimo è proprio h=0 (98° pctl; in-sample era al 10°:
|
||||
sfortunato lì, fortunato qui). Spike, non plateau (h=23/h=1 fanno 0.13/0.04). Bootstrap dello
|
||||
scettico: P(una qualsiasi ancora mostri uno spike ≥ così) = **0.86** — è il massimo atteso di 24
|
||||
stime correlate, non un'anomalia; best-anchor per finestra annuale ruota a caso (16→23→21→12→5→0,
|
||||
Spearman ≈ 0, nessuna seasonality d'ancora). Formulazione corretta (NON "il vero hold-out è 0.07",
|
||||
che ha CI95 [−1.2,+1.4] su 547 giorni): **l'hold-out 2025-26 non risolve l'edge di RITORNO di TP01**
|
||||
(ritorno totale mediano ancore ≈ 0%); ciò che regge A OGNI ancora è il claim DIFENSIVO
|
||||
(DD 6.7-10.1% vs ~60% B&H). Che è da sempre il vero valore dichiarato di TP01.
|
||||
|
||||
**Claim 2 (tranching multi-ancora) RIDIMENSIONATO:** K=4 ≡ esattamente EW di 4 book ancorati
|
||||
(diff 1.4e-17), turnover identico (~8.3/y), haircut $600 ≈ 0. MA il "miglioramento" IS 1.49→1.56 è
|
||||
per ~90% *tornare alla media delle ancore* (h=0 era sfortunato in-sample): vs ancora tipica
|
||||
+0.005 mediano, P(K4≤h0)=0.18 n.s.; il taglio DD strutturale è ~0.5pt (non 2.8: la mediana delle
|
||||
singole è già 12.6%). Ciò che regge: la compressione della VARIANZA DELLA STIMA (HOLD
|
||||
[−0.12,+0.30]→[−0.01,+0.13] a K=4) → **lens di reporting, non alpha**.
|
||||
|
||||
**Impatto a valle (quantificato dallo scettico):**
|
||||
- Blend SKH: HOLD 1.16 (h=0) → 0.97 mediana ancore, ma l'UPLIFT a mediana è +0.93 > +0.86
|
||||
dichiarato → **il verdetto ADDS di SKH01 era conservativo, regge**.
|
||||
- Book 5-sleeve: HOLD 2.44 → 2.34 mediana (min 2.22), FULL 2.24→2.22 → eredita **~+0.10** di
|
||||
fortuna d'ancora (non +0.25: il peso di TP01 diluisce). Nessuna decisione (GTAA/SKH/pesi) cambia.
|
||||
- NON toccati: tutti i FULL (h=0 al 31° pctl = normale), plateau, deflated-Sharpe, fee-sweep,
|
||||
causalità, validazione GTAA (equity OOS), gate weights_tilt_null (relativo).
|
||||
|
||||
**Raccomandazione operativa (sottoscritta dallo scettico): cambio di narrativa e di standard di
|
||||
reporting, NESSUN cambio del book live oggi.** (1) I numeri hold-out di TP01 si citano d'ora in poi
|
||||
come banda d'ancora (mediana ~0.04, [−0.13,+0.30]) con valore = taglio DD; (2) valutazione
|
||||
anchor-agnostic (media 24 ancore) come lens di reporting per i futuri hold-out; (3) NO deploy
|
||||
K=2/K=4 a $600: guadagno ≈ 0, e a $225/asset di quota TP01 i delta per-ancora (~$1-2) sono sotto il
|
||||
min-order $5 → degenererebbe comunque in K=1; inoltre le ancore intraday richiederebbero un feed
|
||||
fresco fuori dal path certificato (staleness ×4 finestre di guasto). **Rivalutare K=2 (0,12) a
|
||||
capitale ≥ ~5-10k.** (4) ⚠️ Audit analogo RACCOMANDATO su XS01 (rebalance 10g ancorato = spazio di
|
||||
luck 10g×24h, potenzialmente peggiore) e SKH01 (fase griglia 230m/690m) — TP01 è oggi l'unico
|
||||
sleeve de-luckato.
|
||||
|
||||
## Filone 4 — Clock lenti + banded rebalancing: FAIL (negativo utile)
|
||||
|
||||
`scripts/research/r0702_slow_clock.py`. Fatto a monte che chiude il filone: il fee drag di TP01 a
|
||||
1d è **~0.40%/anno ≈ 0.03 Sharpe** (8 turnover/y × 0.10% RT) — il tetto di QUALSIASI risparmio.
|
||||
- Clock N∈{2,3,5,7}g: degrado monotono dell'ensemble di fase (HOLD 0.34→0.11 da N=2 a N=7); lo
|
||||
spread TRA fasi esplode a N≥5 (HOLD −0.19…+0.37 a N=7) = timing luck pura, coerente col filone 3.
|
||||
- Bande di isteresi {2.5,5,10,20}%: tagliano il 77-94% degli ORDINI ma quasi zero TURNOVER (gli
|
||||
ordini di TP01 sono micro-aggiustamenti del vol-target). Cella best-IS (band 20%): hold-out
|
||||
0.13 vs 0.30 baseline, e il "guadagno" IS è effetto-segnale (posizioni stantie fortunate), non
|
||||
effetto-costo → fitting. Non-monotonia sulla griglia = firma di rumore.
|
||||
- **Finding utile per il live: a $600 il vincolo min-order $5 È GIÀ la banda ottimale** (ordini
|
||||
427→111/y a costo Sharpe ~0, banda implicita 1.67%): cattura ~100% del risparmio catturabile.
|
||||
Nessun cambio al book, a nessun capitale testato (600/2k/10k).
|
||||
|
||||
## Filone 5 — Velocità del trend regime-condizionata: FAIL
|
||||
|
||||
`scripts/research/r0702_regime_speed.py`. Pesi tra gli orizzonti TSMOM 30/90/180g condizionati al
|
||||
percentile espandente di vol (realized e DVOL), 16 celle, sanity = riproduzione esatta del canonico.
|
||||
- Cella best-IS (alta-vol→lento, linear, rv): FULL +0.06 ma **HOLD BTC −0.31**, 50/50 +0.005 →
|
||||
dominanza fallita; multi-cut a segno instabile (+0.18/−0.08/+0.00).
|
||||
- Null decisivo (300 pesi statici Dirichlet): la cella sta al pctl 0.71-0.72, sotto il p90 —
|
||||
un peso statico casuale la batte spesso. Meccanismo smascherato: corr(peso-30d, Sharpe) = +0.93 →
|
||||
le celle "regime" vincono in-sample perché tengono il tilt-30d la maggior parte dei giorni =
|
||||
**tilt statico travestito da regime**, la stessa trappola di EW-STR. E il tilt statico verso il
|
||||
30d NON regge su BTC hold-out (−0.15). RV e DVOL indistinguibili come misura di regime (coerente
|
||||
con l'esito overlay DVOL 2026-06-26). Pesi canonici 1/3-1/3-1/3 confermati.
|
||||
|
||||
## Filoni 6-8 — CRT "Candle Range Theory" (base / multi-TF / contesto): FAIL 3/3
|
||||
|
||||
Il pattern sweep-and-reclaim a 3 candele (C1 range forte; C2 rompe un estremo ma chiude dentro =
|
||||
"manipolazione"; C3 ingresso contro il breakout, stop dietro lo sweep, target all'estremo opposto),
|
||||
meccanizzato onestamente in tre tagli. Overlap dichiarato: MRV01-11 e MIC07 non coprivano questa
|
||||
meccanica; ora è coperta.
|
||||
|
||||
**6. Base single-TF** (`r0702_crt_base.py`, 864 trial su 1h/4h/12h/1d, motore trade-level
|
||||
conservativo SL-first cross-checkato con l'harness): **DSR 0.000** (il null best-of-grid atteso è
|
||||
Sharpe 2.27, ottenuto 0.74); cella best-IS (4h long color-rule) IS 0.90 → HOLD 0.07 con 2026 a
|
||||
WR 0%; anchor-shift +1/+2h flippa l'hold-out a −1.0 (artifact-risk); lo **short su sweep dell'alto
|
||||
(la narrativa smart-money canonica) perde perfino in-sample (−0.59)**. Autopsia: l'expectancy IS
|
||||
veniva dal time-exit in trend (beta del toro ETH 2021-23), non dal target strutturale. Dettaglio
|
||||
informativo: CRT batte nettamente sia il fade incondizionato (IS −0.49) sia il breakout-confermato
|
||||
(IS −0.31) → il close-back-inside FILTRA davvero tossicità, ma "meno tossico del fade morto" non è
|
||||
un edge.
|
||||
|
||||
**7. Multi-timeframe** (`r0702_crt_mtf.py`, 4h→15m e 1h→5m, ~10k trade): expectancy netta negativa
|
||||
OVUNQUE (FULL e HOLD, entrambi gli asset); il MTF alza il R:R medio da ~3 a ~10-20 ma il WR collassa
|
||||
da ~36% a 9-16% → "migliora" solo perdendo meno. **Refutazione strutturale: il ritest è informazione
|
||||
negativa** — pattern CON ritest −40 bps, SENZA +52 bps (non tradabile: condiziona sul futuro):
|
||||
aspettare il ritest per entrare seleziona sistematicamente i pattern peggiori; i buoni scappano
|
||||
subito e il metodo non li prende mai. A fee ZERO l'edge lordo è ~0 (non è morte per fee: non c'è).
|
||||
Stop 0.1-0.35% ineseguibili col cron orario (26-75% dei segnali già invalidati all'esecuzione).
|
||||
|
||||
**8. Contesto** (`r0702_crt_context.py`, 22 trial: livelli prevday/Donchian/prevweek × equal-H/L ×
|
||||
FVG × sessioni): baseline incondizionata morta (8/8 celle HOLD<0); FVG semmai peggiora; sessione
|
||||
EU consistentemente negativa; unica cella IS-positiva (sweep in Asia su livello prevday) uccisa 4
|
||||
volte (DSR 0.001, anchor-flip a −2/−4h con la firma di open_drive — la sessione "Asia" inizia dove
|
||||
il livello prevday viene ricreato —, solo-ETH, morta a 0.20% fee). **Sottoprodotto prezioso: sugli
|
||||
STESSI livelli prior-day, FOLLOW batte FADE ogni singolo anno 2019-2026** (follow IS/HOLD
|
||||
+1.22/+1.25 vs fade −0.66/−1.46, corr −0.19/−0.27) → conferma indipendente e rafforzativa del lead
|
||||
prevday-breakout in forward-monitor (paper_prevday).
|
||||
|
||||
**Verdetto CRT: concetto Wyckoff riverniciato che, spogliato della discrezionalità, non contiene
|
||||
edge su BTC/ETH Deribit certificati. La direzione giusta sui livelli affollati resta il FOLLOW
|
||||
(breakout), non il fade — coerente con SKH01 (breakout) vivo e mean-reversion morta.**
|
||||
|
||||
---
|
||||
|
||||
## Lezioni codificabili
|
||||
|
||||
1. **Il timing-luck dell'ancora è una dimensione di multiple-testing NON coperta dai gate
|
||||
esistenti** (deflated-Sharpe conta i trial, non le ancore implicite). Nuova regola di reporting:
|
||||
ogni metrica hold-out di una strategia a ribilanciamento ancorato si cita con la banda d'ancora
|
||||
(o la media anchor-agnostic). Candidato gate futuro: `anchor_luck_band()` in altlib. Audit
|
||||
pendente: XS01 (10g), SKH01 (fase 230m/690m).
|
||||
2. **La famiglia day_boundary_robust / anchor-shift continua a uccidere** (CRT base, CRT-Asia,
|
||||
6/24 celle expiry): ogni effetto legato a etichettatura di barre VA spostato d'ancora prima di
|
||||
crederci. Confermata la regola 2026-06-21.
|
||||
3. **Il fee drag di TP01 (~0.4%/anno) non è un problema da risolvere** — chiude a priori il filone
|
||||
"esecuzione più furba" a questo turnover; il min-order small-cap è già la banda ottimale.
|
||||
4. Il pitfall pandas tz-aware/epoca (2026-07-01) è stato evitato/gestito 2 volte su 9 agenti grazie
|
||||
alla documentazione in CLAUDE.md — il costo di documentare i bug paga.
|
||||
|
||||
## Stato finale
|
||||
|
||||
- 0 nuovi sleeve; portafoglio e book live INVARIATI (5-sleeve 33/15/12/20/20; book Deribit
|
||||
TP01+SKH01 75/25, flat da armamento — il segnale resta risk-off).
|
||||
- Narrativa TP01 aggiornata (CLAUDE.md + docstring): hold-out = banda d'ancora, valore = difesa DD.
|
||||
- Script: `scripts/research/r0702_{eventclock,expiry_calendar,tp01_offset,slow_clock,regime_speed,
|
||||
crt_base,crt_mtf,crt_context,skeptic_offset}.py`. Test suite: invariata, verde.
|
||||
@@ -0,0 +1,111 @@
|
||||
# 2026-07-03 — Frontiera rischio/rendimento del book: quanto caldo correre a 2k-5k
|
||||
|
||||
**Contesto.** Dopo la diagnosi strategica ("€50/g è un problema di capitale e di frontiera, non di
|
||||
alpha; hai costruito una macchina difensiva a DD 6% ma hai capitale piccolo e obiettivo di
|
||||
crescita"), goal: **misurare** (non cercare segnali) la frontiera CAGR/DD/P-rovina in funzione del
|
||||
target-vol, pesata per confidenza per-sleeve, a €2k e €5k — per decidere quanto caldo girare il book.
|
||||
Workflow: 3 filoni (curva / onestà-della-coda / eseguibile+Kelly), refuter fat-tail su ciascuno,
|
||||
scettico sintetizzatore. 7 agenti, 0 errori. Sanity bit-exact di entrambi i book prima di ogni numero.
|
||||
|
||||
**Scripts:** `scripts/research/r0703_frontier_{curve,tail,exec}.py`. Nessun file di produzione
|
||||
toccato, nessun commit dagli agenti, `config/live.json` NON modificato (cap solo proposto).
|
||||
|
||||
---
|
||||
|
||||
## Verdetto in una riga
|
||||
|
||||
**La tesi "corri più caldo" era in gran parte un MIRAGGIO DI REGIME.** Il "~6% attuale" non è un
|
||||
target scelto: è la vol *realizzata* in regime risk-off (TP01 spesso flat 0.0x → il conto è flat
|
||||
*by design*). A lambda=1 il book LIVE a 2 sleeve gira **già a ~11% vol / 9.4% DD**. Quindi "saltare
|
||||
da 6% a 10%" NON è aggiungere leva — è solo assumere che il book non sia risk-off. **Il numero
|
||||
onesto è: gira alla vol NATIVA (~10%, banda 8-11%), non levare.** Guadagno reale vs il 6% risk-off:
|
||||
**+€0.2/g @2k, +€0.5/g @5k** — reale ma modesto, comprato con un DD plausibile di ~€500 su €2k. €50/g
|
||||
resta ~130k, invariato.
|
||||
|
||||
---
|
||||
|
||||
## I numeri (frontiera fidata, non full-Sharpe)
|
||||
|
||||
Sanity (deriva nulla): 5-sleeve FULL Sh **2.238** / HOLD 2.452 / DD 6.17% / vol nativa 7.84%;
|
||||
2-sleeve live FULL Sh **1.776** / HOLD 1.162 / DD 9.42% / vol nativa 10.96%.
|
||||
|
||||
**Scale-invarianza confermata da tutti e 3 i refuter:** il capitale NON cambia la matematica del
|
||||
target-vol (le tabelle sono in %), cambia solo l'ESEGUIBILITÀ → **stesso % a 2k e 5k.**
|
||||
|
||||
### Book LIVE 2-sleeve (TP01+SKH01) — l'unico eseguibile <20k
|
||||
- **Target-vol raccomandato: ~10%, banda [8%, 11%], lambda ≈ 0.9.** Sopra la vol nativa non si va.
|
||||
- €/giorno al punto fidato (haircut): CAGR full-sample 12.9% → €0.71@2k / €1.77@5k; ma **HOLD-onesto**
|
||||
(de-luck) CAGR ~4.5% → **€0.25@2k / €0.62@5k**. Il lato-reward headline è ~3× l'onesto (HOLD≪FULL).
|
||||
- **Stima centrale onesta: €0.3-0.7/g @2k, €0.8-1.8/g @5k.**
|
||||
|
||||
### Book PIENO 5-sleeve — ASPIRAZIONALE, non gira <20k
|
||||
Frontiera fidata regge ~12-15% sotto tail estremo, ~20% sotto tail mite — ma XS01 serve ~20k e VRP01
|
||||
è modellato → rilevante solo per il tracking di `paper_combo`, non per una decisione di deploy a 2-5k.
|
||||
|
||||
---
|
||||
|
||||
## I quattro insegnamenti strutturali
|
||||
|
||||
### 1. La rovina-50% è la metrica SBAGLIATA (trappola esposta dal curve-refuter)
|
||||
P(rovina-50%|5y) resta <1% fino a ~20-22% di target-vol su ogni book — i book sono troppo lisci/
|
||||
diversificati per bruciare metà capitale entro la distribuzione campionaria. **La risposta letterale
|
||||
"max CAGR a P(rovina)<1%" direbbe "gira al 22%" — ed è un suicidio**: lì P(DD>30%|5y) ≈ 20-25%. Il
|
||||
vincolo che MORDE è **P(DD>30%), super-lineare** (raddoppia ~ogni 2.5 punti di vol). E la rovina-50%
|
||||
non è nemmeno robusta ad alta vol (block-15 vs block-20 danno 7.1% vs 0.9% a tv30%) → altro motivo
|
||||
per stare bassi.
|
||||
|
||||
### 2. Il muro fat-tail ONESTO è ~12%, più basso della carta (~15%)
|
||||
Tre ragioni che il modello sottostima: **(a) gap-through-stop SKH già osservato reale** (sl2%
|
||||
modellato → −11/−23% realizzato — la coda vera NON è nei rendimenti modellati; l'iniezione dei
|
||||
finder usava lo *clean-stop* → ~2× troppo mite); **(b) il 2-sleeve è 4× più crash-sensibile del
|
||||
5-sleeve** (+14.5pt vs +3.5pt di P(DD>30) all'iniezione, diversificazione sottile); **(c)
|
||||
vol-targeting a lambda costante lagga nello spike** → sei più levato *entrando* nel crash.
|
||||
**LIVE 2-sleeve: non superare ~12%; mai 15-20%** (triplica il DD-in-euro senza margine di coda).
|
||||
|
||||
### 3. Aggressività differenziata-per-confidenza (TP01 più caldo di VRP) — REFUTATA
|
||||
La mia proposta era intuitiva ma **sbagliata, e i numeri lo dicono netto**: differenziare la vol per
|
||||
confidenza PEGGIORA la frontiera (5s Sharpe 2.24→2.11, 2s 1.78→1.69) perché **taglia la
|
||||
diversificazione** di VRP/XS (corr uniforme-vs-differenziato 0.98-0.99). **Regola corretta: la bassa
|
||||
fiducia sull'EDGE si esprime DE-MEANando lo sleeve in aspettativa (= la frontiera haircut), NON
|
||||
riducendone l'esposizione/vol.** La confidenza vive nell'haircut della media, non nella leva. Tutti
|
||||
gli sleeve alla stessa leva.
|
||||
|
||||
### 4. La diversificazione LIMA la coda sinistra in-sample (sorpresa onesta)
|
||||
Per il 5-sleeve ben diversificato il block-bootstrap dà P(DD>30) SOTTO il Gaussiano iso-vol (30%:
|
||||
10.6% reale vs 21.1% gauss) — la coda in-sample è benigna. Il segnale fat-tail vive in due posti:
|
||||
l'**iniezione di crash sintetico** (il campione non contiene il crash peggiore di ogni sleeve) e
|
||||
il **book live sottile** (2 gambe, drift haircut) dove il bootstrap SUPERA il Gaussiano. Cioè: la
|
||||
frontiera è affidabile finché il futuro somiglia al passato campionato, e fragile esattamente dove
|
||||
le storie sono corte (VRP mai stressato reale, XS 2.5y, nessun 2018/pre-2018).
|
||||
|
||||
---
|
||||
|
||||
## Azione concreta (proposta, NON applicata)
|
||||
|
||||
1. **Nessun cambio di leva sul codice.** Il book gira già ≈ al punto fidato: LASCIARE `target_vol=20%`
|
||||
di TP01 (canonico, la vol nativa del book emerge a ~8-11%). Non c'è una leva da girare su.
|
||||
2. **⚠️ L'UNICO cambio che conta: `config/live.json` `max_notional_per_asset_usd` da $300 a
|
||||
equity/2** ($1000@2k / $1750@3.5k / $2500@5k). Senza, il cap $300 tappa la vol raggiungibile a
|
||||
13%@2k / 8%@3.5k / **6%@5k** → **throttling paradossale: più capitale = book più freddo**, forzato
|
||||
sotto il punto fidato. Da applicare AL deposito. (config NON toccato ora.)
|
||||
3. `min_order_usd=5`: LASCIARE (banda d'isteresi gratuita). Tranching K=2: NON cablare. Vol
|
||||
differenziata: NON implementare (Q3).
|
||||
|
||||
---
|
||||
|
||||
## Brutalità sui limiti
|
||||
|
||||
- **Storie corte = il difetto strutturale.** TP01/SKH dal 2019 (no 2018), VRP dal 2021, XS **2.5
|
||||
anni** (no 2022). Il block-bootstrap non può campionare un crash mai visto; l'iniezione sintetica
|
||||
è l'unico proxy, e quella dei finder era ~2× troppo mite.
|
||||
- **VRP01 è MODELLATO** (f-di-stress mai catturato su un crash reale) → l'haircut −50% è stima, non
|
||||
misura; resta STAT-MODE anche a 5k (regola: niente short-vol da modello in deploy).
|
||||
- **HOLD ≪ FULL sul book live** (1.78 vs 1.16) → il lato-reward headline è in gran parte artefatto
|
||||
full-sample; citare i €/g su base HOLD-onesta.
|
||||
- **€50/g NON è risolto e non lo sarà da questo.** Il warm-up compra ~€0.2/g@2k. La via resta
|
||||
**capitale + tempo**; la regola "niente leva alta come base" è rispettata (la raccomandazione è
|
||||
"gira alla vol nativa", non "corri caldo").
|
||||
|
||||
**Numero unico da portare a casa:** book LIVE a **~10% target-vol (lambda ≈ 0.9, banda 8-11%)**,
|
||||
uguale a 2k e 5k, con **cap → equity/2** come unica azione config; muro invalicabile a **~12%**;
|
||||
guadagno reale vs il 6% risk-off ~**+€0.2/g@2k / +€0.5/g@5k** — reale ma modesto.
|
||||
@@ -0,0 +1,47 @@
|
||||
# 2026-07-03 — Cap notional per-asset DINAMICO (= equity/2): operazionalizza la decisione frontier
|
||||
|
||||
**Contesto.** La frontiera del 2026-07-03 (`docs/diary/2026-07-03-book-frontier.md`) aveva identificato
|
||||
**una sola** azione config con impatto reale: portare `max_notional_per_asset_usd` da $300 fisso a
|
||||
**equity/2**, perché il cap fisso strozza il book man mano che si deposita (paradosso: più capitale =
|
||||
book più freddo — a $5k il cap $300 fa girare il libro al ~49% del target). Il diario la lasciava
|
||||
"da applicare AL deposito". Qui la **operazionalizzo in codice** rendendo il cap dinamico, così è
|
||||
sempre corretta e non richiede un edit manuale al deposito.
|
||||
|
||||
## Cosa è cambiato
|
||||
|
||||
- **`src/live/book.py` — `_cap()`**: se in config c'è `max_notional_per_asset_frac` (opt-in), il cap
|
||||
per-asset diventa **`equity × frac`** (0.5 → equity/2). Senza la chiave → comportamento vecchio
|
||||
(cap fisso `max_notional_per_asset_usd`). Il call site in `book_report()` passa
|
||||
`equity`, `real_equity`, `eq_fallback` a `_cap()`.
|
||||
- **`config/live.json`**: aggiunto `max_notional_per_asset_frac: 0.5` (+ nota). `max_notional_per_asset_usd`
|
||||
resta come **cap fisso di fallback**.
|
||||
- **`tests/test_book_live.py`**: +4 test (scaling con equity fidata, fallback su eq_fallback/real=None/
|
||||
equity 0, no-frac, integrazione online). Suite: **172 passed**.
|
||||
|
||||
## Il guardrail di sicurezza (il punto delicato)
|
||||
|
||||
Il cap dinamico si usa **SOLO con equity reale fidata** (letta dal conto). Se l'equity reale non è
|
||||
leggibile — `eq_fallback` attivo, `real_equity=None`, offline, o equity ≤ 0 — si **torna al cap fisso
|
||||
$300**. Motivo: in fallback `equity` è il paper-cap ($2000 ipotetico); calcolare `equity/2 = $1000` su
|
||||
un conto di importo reale **ignoto** sizerebbe troppo. Preserva esattamente la protezione downside che
|
||||
il test `test_book_execute_eq_fallback_warns_but_proceeds` verificava (l'hard-cap $/asset protegge
|
||||
quando E è ignota). Regola: **equity/2 solo quando ci fidiamo di E; altrimenti $300 fisso.**
|
||||
|
||||
## Verifica
|
||||
|
||||
- **Live dry-run reale** (nessun `--execute`, nessun ordine): `cap/asset **$299**` = $598.06/2 →
|
||||
**INERTE** oggi (≡ vecchio $300). Book flat, HOLD.
|
||||
- Offline / `equity_override`: cap resta **$300 fisso** (nessuna equity reale da fidarsi) → il test
|
||||
di parità pre-esistente regge invariato.
|
||||
- A $5.000 di equity il cap diventerebbe automaticamente **$2.500** (equity/2), rimuovendo il
|
||||
throttling che la frontiera aveva quantificato.
|
||||
|
||||
## Impatto e limiti
|
||||
|
||||
- **Già attivo**: il cron orario (`cron_book.sh`) esegue dal working tree → il prossimo run legge già
|
||||
`frac=0.5`. Nessun deploy separato. **Inerte a $600**, si "accende" da sé quando l'equity cresce.
|
||||
- **NON avvicina €50/g**: la frontiera lo aveva già detto — è una miglioria marginale (+€0.2/g @2k,
|
||||
+€0.5/g @5k), il cui unico scopo è non lasciare capitale futuro strozzato. €50/g resta un problema
|
||||
di **capitale + tempo** (~130k @ CAGR onesto), non di questo cap.
|
||||
- Nessun cambio di **leva** (TP01 `target_vol=20%` invariato, la vol nativa ~10% emerge da sola —
|
||||
coerente col verdetto frontier "gira alla vol nativa, non levare").
|
||||
@@ -0,0 +1,156 @@
|
||||
# 2026-07-03 — STATO COMPLETO DEL PROGETTO (snapshot)
|
||||
|
||||
Foto d'insieme dopo le ondate di ricerca del 1-3 luglio 2026 (timing/CRT, anchor-audit,
|
||||
video-claims Elliott/Albimarini, capital-scaling, migliora-e-proteggi VRP01). Serve come punto di
|
||||
riferimento unico: *dove siamo, cosa gira, cosa è provato, cosa resta aperto.*
|
||||
|
||||
---
|
||||
|
||||
## 1. In una frase
|
||||
|
||||
Progetto post-reset v2.0.0: su dati Deribit mainnet certificati (solo BTC/ETH) abbiamo costruito un
|
||||
**portafoglio di 5 strategie diversificate** (research Sharpe ~2.2, DD ~6%), di cui **2 sono
|
||||
eseguite live su Deribit** in un book nettato in software. Il conto reale è **~$598, flat** perché
|
||||
il segnale trend è risk-off. Il valore delle strategie è **difensivo** (taglio del drawdown), non
|
||||
generazione di ritorno: il target €50/giorno resta lontano (serve ~130k di capitale). L'onestà
|
||||
metodologica è il vero asset accumulato: ~10 gate anti-illusione codificati, decine di edge
|
||||
falsificati.
|
||||
|
||||
---
|
||||
|
||||
## 2. Il book LIVE (soldi reali)
|
||||
|
||||
- **Conto:** Deribit mainnet, USDC linear, **equity reale $598.06** (NON i €2000 nominali del paper).
|
||||
- **Cosa gira:** `deribit_book_sleeves` = **TP01 (0.75) + SKH01 (0.25)** nettati in software su un
|
||||
solo conto; UN ordine con segno per asset (BTC, ETH). `config/live.json`
|
||||
**`execution_enabled=true`** → esecuzione ARMATA e reale.
|
||||
- **Cadenza:** cron ORARIO `scripts/cron_book.sh` → `scripts/live/book_execute.py` (SKH01 decide su
|
||||
griglia 230m; exit software, latenza fino a fine barra).
|
||||
- **Guardrail:** cap **$300 notional/asset**, min order $5, **disaster-SL on-book −30%** sulla
|
||||
posizione netta, alert Telegram su esecuzione/errori, gate fail-safe su posizione/equity non
|
||||
leggibili (non opera a cieco).
|
||||
- **Stato corrente (ultimo cron 2026-07-03 07:00Z):** BTC e ETH **flat, HOLD a target** — il target
|
||||
TSMOM è risk-off (trend 0.0x) e SKH01 è flat (nessun breakout) → **zero ordini reali mai eseguiti**
|
||||
da quando è armato (2026-06-23): il conto è flat *by design*, non per un errore.
|
||||
- **NON nel book live:** XS01, VRP01, GTAA01 (research/paper/STAT-MODE).
|
||||
|
||||
## 3. Il portafoglio RESEARCH (5 sleeve) — `src/portfolio/sleeves.active_sleeves`
|
||||
|
||||
| Sleeve | Peso | Meccanismo | Dati | Stato |
|
||||
|---|---|---|---|---|
|
||||
| **TP01** | 33% | TSMOM difensivo trend BTC/ETH 1d, long-flat, vol-target 20% | 2019→ | **Deploy pieno** (nel book live) |
|
||||
| **SKH01** | 20% | Dual-TF regime+breakout BTC/ETH, L/S, exit %-asimmetrici | 2019→ | Research → nel book live |
|
||||
| **GTAA01** | 20% | Trend difensivo equity 6-ETF (IB), ~30 anni storia | 1996→ | Paper (paper_combo) |
|
||||
| **VRP01** | 12% | Options short-vol (put credit spread + gate IV-rank) | 2021→ | LEAD modellato (STAT-MODE) |
|
||||
| **XS01** | 15% | Cross-sectional momentum 19 alt Hyperliquid | 2024→ | STAT-MODE (serve ~20k) |
|
||||
|
||||
- **Combinato (research, ancore canoniche):** FULL Sharpe **2.24**, HOLD-OUT **2.46**, DD full **6.2%**.
|
||||
- **⚠️ Stima DE-LUCKATA onesta** (dopo gli anchor-audit, vedi §5): **HOLD ~1.9-2.1, FULL ~2.0-2.2,
|
||||
DD ~6%**. Il 2.46 è un massimo di configurazioni d'ancora, non la stima centrale.
|
||||
- Sleeve a date d'inizio diverse → outer-join con pesi rinormalizzati (`combine_outer`).
|
||||
- Report: `scripts/portfolio/run_portfolio.py`. Il book live (TP01+SKH01 75/25) è un sottoinsieme.
|
||||
|
||||
## 4. Paper trader / forward-monitor attivi (nessun soldo reale)
|
||||
|
||||
- **paper_trend** — TP01 1d, capitale nominale 2000, forward-only.
|
||||
- **paper_combo** — book 5-sleeve completo (incl. GTAA/VRP/XS) dal 2026-06-22.
|
||||
- **paper_statarb** — STATARB-RESID (ETH−β·BTC residual momentum, W=45/sgn=+1 congelati), doppio
|
||||
libro MODELED/REAL-$600. LEAD eseguibile a $600 sotto soglia solo sull'edge (DSR 0.929).
|
||||
- **paper_prevday** — FOLLOW prior-day levels (3 trade registrati); confermato indipendentemente da
|
||||
più ondate (FOLLOW > FADE ogni anno 2019-26).
|
||||
|
||||
---
|
||||
|
||||
## 5. Finding strutturale del blocco 1-3 luglio: ANCHOR TIMING-LUCK (audit COMPLETO 4/4)
|
||||
|
||||
Le strategie a ribilanciamento/griglia ancorata hanno un'ancora "canonica" che è spesso il
|
||||
**migliore** di molte configurazioni equivalenti (giorno/ora/offset) — un multiple-testing che il
|
||||
deflated-Sharpe NON conta. Audit eseguito su tutti e 4 gli sleeve ancorati:
|
||||
|
||||
| Sleeve | Spazio d'ancora | Firma | Conseguenza |
|
||||
|---|---|---|---|
|
||||
| **TP01** | 24 ore (barra daily) | canonica = migliore (P=0.86) | hold-out 0.31 non risolve l'edge di ritorno; regge solo il taglio DD |
|
||||
| **XS01** | 10 fasi (ciclo 10g) | canonica al 15° pctl di DD | numeri headline conservativi sul DD, non sull'hold-out |
|
||||
| **SKH01** | 23 offset (griglia 230/690m) | canonica = 93-98° pctl (peggiore firma) | gate DD<30% fallisce in 15/23 offset; ~+0.5 HOLD di fortuna sul book |
|
||||
| **VRP01** | 7 giorni (ciclo settimanale) | **canonica = PEGGIORE** (7° pctl) | **numeri CONSERVATIVI**, non gonfiati — unico sleeve senza luck |
|
||||
|
||||
**Regola codificata:** i numeri hold-out di strategie ancorate si citano **con banda d'ancora**.
|
||||
Candidato gate futuro: `anchor_luck_band()` in altlib. Diari: `2026-07-02-timing-crt-wave.md`,
|
||||
`2026-07-02-anchor-audit-xs01-skh01.md`, `2026-07-03-vrp-improve-dd.md`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Cosa è PROVATO e MORTO (per non ri-testarlo)
|
||||
|
||||
- **Soffitto BTC/ETH-direzionale ~1.3** confermato da ogni ondata; superato solo dal cross-sectional
|
||||
(XS01) e dalla diversificazione multi-sleeve.
|
||||
- **Sweep 104 famiglie** (2026-06-20): 1 sola sopravvissuta, comunque non deployabile.
|
||||
- **Opzioni long-vol** (gamma scalping): specchio del VRP, perde ogni anno/variante/frequenza.
|
||||
- **Cash-and-carry (CC01):** premio reale ma Sharpe artefatto (manca il 2022, procyclico) → STAT-MODE.
|
||||
- **Timing/clock alternativi** (event-clock, expiry calendar, clock lenti, regime-speed): 0 edge.
|
||||
- **CRT "Candle Range Theory"** (3/3 FAIL): DSR 0.000, expectancy negativa anche a fee zero, il
|
||||
ritest è informazione negativa; sui livelli prior-day FOLLOW > FADE.
|
||||
- **Elliott (3/3 FAIL):** range-cycle = rumore; Fibonacci = effetto posizione non numeri magici;
|
||||
canale = Donchian travestito.
|
||||
- **Albimarini double-diagonal (2/2 FAIL):** non domina il condor, fee-negativa su Deribit, track
|
||||
record consistente con zero skill (P=20-45%), rovina nei replay con code reali.
|
||||
- **VRP01 miglioramenti (0/7 filoni):** struttura, overlay-DD, gate nuovi (4° fallimento), sizing,
|
||||
gate SPX (confound di modello) — **VRP01 non migliorabile; la protezione DD si compra con la size.**
|
||||
|
||||
## 7. Regole/gate anti-illusione codificati (l'asset metodologico)
|
||||
|
||||
- **Certificazione dati** obbligatoria (solo Deribit mainnet, BTC/ETH; alt esclusi).
|
||||
- **`marginal_vs_tp01` / `study_marginal`** (earns_slot) — Sharpe MARGINALE, non assoluto.
|
||||
- **`study_family_honest` / `select_cell_insample` / `deflated_sharpe≥0.95`** — anti selection-on-holdout.
|
||||
- **`day_boundary_robust`** — effetti calendario che si invertono spostando il confine = artefatto.
|
||||
- **`eval_weights_smallcap`** — Sharpe haircut reale a capitale piccolo (sub-min-order = finzione).
|
||||
- **`weights_tilt_null`** — ogni cambio pesi vs null dei tilt casuali cap-respecting.
|
||||
- **Banda d'ancora** (nuova, luglio) — per ogni costrutto ancorato.
|
||||
- **Candidati in coda:** `anchor_luck_band` (multiple-testing d'ancora); `implausible_sharpe`
|
||||
(0-perdite / Sharpe implausibile — 3 occorrenze: CC01, ALB-A, griglia struttura VRP).
|
||||
- **Lezioni pandas:** mai `DatetimeIndex.view("int64")` su tz-aware; `resample("7D", origin=)`
|
||||
ignora origin (usare `"168h"`); un gate che si correla con un errore di modello è indistinguibile
|
||||
da alpha finché non correggi il modello.
|
||||
|
||||
---
|
||||
|
||||
## 8. Onestà sui numeri e sul target
|
||||
|
||||
- **€50/giorno da €1.000-2.000 NON è raggiungibile** in 1-2 anni: serve ~130k di capitale o un DD da
|
||||
rovina. La leva non è la scorciatoia (5x raddoppia il DD). La via è target-vol + capitale + tempo.
|
||||
- Aspettativa onesta col CAGR de-luckato del book (10-15%): **~€0.6-0.8/giorno a 2k, ~€1.4-2/giorno
|
||||
a 5k**. Il salto di capitale cambia *cosa è eseguibile*, non l'ordine di grandezza del ritorno.
|
||||
- Il valore reale di TP01/del book è il **taglio del drawdown ~6× vs buy&hold**, non l'alpha.
|
||||
|
||||
## 9. Capitale in arrivo 2-5k — cosa cambia (audit r0702_capital_scaling)
|
||||
|
||||
- **Unico vincolo binding: il cap `max_notional_per_asset_usd=300`.** A 5k il book live girerebbe al
|
||||
~49% del target. **Azione pendente sul deposito: alzare il cap a equity/2** ($1000/$1750/$2500 a
|
||||
2k/3.5k/5k). `min_order_usd=5` da lasciare.
|
||||
- Tranching K=2: matematicamente regge a 5k ma serve feed intraday fuori path certificato → non cablare.
|
||||
- Opzioni ETH eseguibili da ~2.6k (spread), ma **la regola "niente short-vol da modello in deploy"
|
||||
non decade col capitale**. XS01 resta fuori fino a ~20k. CC01 fuori per struttura.
|
||||
- ⚠️ Disambiguazione unità: il "12%" come peso book (~0.014 Kelly) ≠ "12%" come margine/equity
|
||||
(~0.27 Kelly) — **fattore 19x**.
|
||||
|
||||
## 10. Aperto / prossimi passi
|
||||
|
||||
1. **Al deposito 2-5k:** alzare il cap $/asset (proposta pronta, `config/live.json` non ancora toccato).
|
||||
2. **Forward-monitor da maturare:** paper_statarb (edge sotto soglia DSR), paper_prevday (FOLLOW),
|
||||
paper_combo (book completo). Se la finestra forward conferma STATARB-RESID → deployabile a 2 gambe.
|
||||
3. **Gate da codificare:** `anchor_luck_band`, `implausible_sharpe`.
|
||||
4. **Rivisite temporizzate:** term-structure DVOL (logger dal 2026-06-26, rivalutare a 6-12 mesi);
|
||||
breadth/internals HL (1-2 anni di storia nativa); tranching a ≥5-10k; VRP stress-f su un crash reale.
|
||||
5. **Ricerca strategie nuove:** il soffitto direzionale ~1.3 è saturo; i margini restano in meccanismi
|
||||
ORTOGONALI (cross-sectional, relative-value, options gated) e nella diversificazione, non in nuovi
|
||||
segnali direzionali BTC/ETH.
|
||||
|
||||
---
|
||||
|
||||
## 11. Stato tecnico
|
||||
|
||||
- **Versione:** 2.0.0 (semver in `VERSION`). **Test:** 168 verdi (28 file). **Diari:** 76.
|
||||
- **Ultimi commit:** `26f8d27` (migliora+proteggi VRP01), `76120b5` (video-claims + capital-scaling),
|
||||
`a74cc69` (anchor-audit XS01/SKH01), `e6657fc` (GTAA01 5° sleeve).
|
||||
- **Working tree pulito, tutto pushato.** Book live sano e invariato da tutta la ricerca (research
|
||||
isolata: nessun agente tocca src/config/live).
|
||||
@@ -0,0 +1,167 @@
|
||||
# 2026-07-03 — Ondata "migliora e proteggi VRP01 dai DD" (7 filoni + verifica a 2 lenti + scettico incrociato)
|
||||
|
||||
**Contesto.** Dopo la chiusura del dossier Albimarini (3 backtest indipendenti convergenti: premio
|
||||
piccolo e reale, code non campionate, numeri del corso spiegabili al 100% dalla struttura del
|
||||
payoff), goal esplicito: **migliorare la strategia short-vol** — la famiglia Albimarini → il nostro
|
||||
**VRP01**, l'unica forma sopravvissuta agli audit — **e proteggerla dai drawdown**, con
|
||||
orchestrazione a decine di agenti. Workflow deterministico: 7 filoni di ricerca in parallelo, 2
|
||||
refuter avversariali (lente selection-bias + lente de-levering) su ogni miglioramento proposto,
|
||||
scettico incrociato finale sui sopravvissuti. **26 agenti, 0 errori.**
|
||||
|
||||
**Scripts:** `scripts/research/r0703_vrpimp_{structgrid,ddoverlay,sizing,newgates,anchor,spxgate,stresslab}.py`.
|
||||
Nessun file di produzione toccato, nessun commit dagli agenti. Motore riusato: `r0702_alb_structure.py`
|
||||
(DVOL, riproduce VRP01 bit-exact: baseline bridge max|diff|=0.00e+00 su 266 settimane).
|
||||
|
||||
---
|
||||
|
||||
## Verdetto in una riga
|
||||
|
||||
**VRP01 non è migliorabile oltre lo status quo, e la protezione DD si compra SOLO con la size.**
|
||||
Zero nuovi sleeve, zero overlay che reggono, zero gate nuovi, zero cambio di sizing. I due unici
|
||||
"sopravvissuti" alla verifica **non propongono alcun cambiamento** (una conferma dello status quo +
|
||||
una lente di misura). Il candidato più luccicante dell'ondata — un gate term-structure VIX su SPX
|
||||
con ΔSharpe +0.90 e DSR 0.992 — è stato smontato dallo scettico come **confound di modello al
|
||||
100%**. Deliverable reale = **audit anchor-luck di VRP01 CHIUSO** (ultimo sleeve ancorato): la fase
|
||||
canonica è all'estremo **conservativo** della banda, quindi i numeri di ammissione reggono e da ora
|
||||
si citano con banda.
|
||||
|
||||
---
|
||||
|
||||
## I 7 filoni
|
||||
|
||||
### 1. Griglia di struttura (288 celle) — NESSUNA batte VRP01
|
||||
6 strutture (vertical put/call, condor, 3 diagonali) × distanza {1,1.5,2,2.5}σ × ali {0.5,1,2}σ ×
|
||||
tenor {3,5,7,10}g, gate IV-rank canonico sempre attivo. La cella scelta col **solo in-sample**
|
||||
(DIAG-PUT z=1.0 3g, ShIS 2.28) crolla in hold-out a **+0.19** vs 0.86 del bridge stesso-motore;
|
||||
DSR 0.000/0.004 su 288 trial; multi-cut negativo a 3-4 cut su 4; banda d'ancora che non tiene
|
||||
nemmeno il segno (ShH [−0.21,+0.64]). **Metà griglia (150/288) è il pattern "0-perdite = Sharpe
|
||||
implausibile"** (celle z≥2 lunghe, 0 perdite su ~97 trade, ShH fasulli 4-6.5) — **terza occorrenza
|
||||
dopo CC01 e ALB-A**. Quarta conferma indipendente: nel VRP l'alpha sta nel gate di regime + nella
|
||||
zona ~δ-0.28 dove la coda è campionata, non nella geometria.
|
||||
|
||||
### 2. Overlay di protezione DD (14 celle) — 4/4 REFUTED (2 lenti ciascuno)
|
||||
(a) exit intra-settimana su spike DVOL, (b) stop-loss MTM, (c) ala di coda comprata (broken-wing),
|
||||
(d) cooldown post-perdita. **Nessuno batte il null del de-levering** a pari maxDD su tutta la banda
|
||||
f; nessuno passa multi-cut (max 1/4) né DSR (0.70-0.88, tutti <0.95). Esempio decisivo: SL MTM 3x fa
|
||||
DD 7.9%/worst −5.7%, ma VRP01 × 0.66 (semplice riduzione di size) fa lo **stesso DD a Sharpe
|
||||
invariato e worst-week migliore (−4.9%)**. Meccanismo confermato: i trigger di spike scattano solo
|
||||
2-3 volte in 5 anni **perché il gate IV-rank/crash-skip evita già gli ingressi vicino alle
|
||||
esplosioni di vol** — la protezione crash vive nel gate d'ingresso, non serve un overlay a valle.
|
||||
Coerente con TP01×DVOL (2026-06-26): "per meno DD la leva è la size, non un overlay".
|
||||
|
||||
### 3. Sizing anti-rovina — CONFERMA (12% ≈ quarter-Kelly), 1 candidato REFUTED
|
||||
Il sizing non crea alpha (Sharpe del flusso invariante ~0.95 a ogni frazione). Frontiera onesta
|
||||
CAGR-DD-P(rovina) mappata su 92 trade gated ETH 2021-26 (pooled banda-f + coda sintetica full-loss):
|
||||
Kelly onesto q*=44% → il **12% del book = 0.27 Kelly, già in zona quarter-Kelly anti-rovina**
|
||||
(P(rovina-50%|5y) 0.0-0.1%, P(DD>30%) 9-18%, CAGR med 13.5-17% a 2k/5k). Salire al 25% → P(DD>30%)
|
||||
> 92%; Albimarini 1→4 → P(rovina) 53-55%. **Nessun cambio raccomandato.** Il candidato anti-streak
|
||||
(N=2, size 12%→3% dopo 2 vittorie, l'opposto di Albimarini) era il più luccicante mai visto sullo
|
||||
sleeve (FULL CAGR +19.4%/DD 6.3%/Sh 2.08, random-null 0.996, DSR 0.999) ma **auto-refutato come
|
||||
artefatto di selezione**: plateau assente (solo N=2 vince, N=3/5 collassano), P(loss|streak) non
|
||||
monotona, delever-null perde in 2/7 ancore — tutto l'effetto sono ~6 perdite su 15 trade a
|
||||
streak==2 (binomtest p=0.033, CI contiene la base). Istruttivo: mostra come un DSR 0.999 possa
|
||||
convivere con l'assenza di meccanismo.
|
||||
|
||||
### 4. Gate nuovi — 4° FALLIMENTO su 4 (barra alta)
|
||||
(a) term-structure DVOL **non testabile oggi** (storia vol_term = 7 giorni, logger forward dal
|
||||
2026-06-26 → rivalutare a 6-12 mesi); (b) vol-of-vol, (c) RV-acceleration, (d) combinazioni-AND. Il
|
||||
candidato IS-best (vol-of-vol pctl>0.80) passa DSR 0.960 e il de-levering all'ancora di default ma
|
||||
la **banda d'ancora lo smaschera** (mediana uplift FULL −0.07 su 7 ancore, uplift concentrato in UNA
|
||||
finestra 2021-22 di 5 settimane, hold-out uplift 0.00). Bonus strutturale: i veti RV-acceleration
|
||||
bloccano settimane **profittevoli** (+0.93/+1.25% vs +0.6% tenute) — dopo il gate IV-rank, la vol
|
||||
che accelera è dove il premio venduto è più ricco. Conferma il verdetto 2026-07-01: l'alpha è tutto
|
||||
nel binario IV-rank>0.30.
|
||||
|
||||
### 5. Audit anchor-luck VRP01 — CHIUSO: primo sleeve SENZA firma di luck
|
||||
VRP01 è l'ultimo sleeve ancorato dopo TP01/XS01/SKH01. Ciclo settimanale → 7 ancore (giorno di
|
||||
apertura). Replica bit-exact della sleeve (max|diff|=0.0). Le 7 fasi con parametri identici:
|
||||
|
||||
| metrica | canonica (fase-0) | mediana 7 fasi | banda | pctl canonica |
|
||||
|---|---|---|---|---|
|
||||
| Sh FULL | 1.09 | 1.32 | [1.09, 1.83] | **7° (peggiore)** |
|
||||
| Sh HOLD | 0.59 | 0.59 | [−0.03, 1.11] | 50° (mediana) |
|
||||
| maxDD | 11.8% | 7.2% | [5.7, 11.8%] | **93° (peggiore)** |
|
||||
|
||||
**Firma OPPOSTA a TP01/XS01/SKH01**: lo spike bootstrap è NEGATIVO (g0 HOLD −0.06, FULL −0.21,
|
||||
P(g0≤0) 0.54/0.74) → niente da de-luckare. La fase canonica ha beccato le settimane crash 2022
|
||||
**peggio** di ogni altra (unico anno-fase negativo, −5.8%). **I numeri di ammissione FULL 1.10 /
|
||||
HOLD 0.60 / DD 12% sono quindi CONSERVATIVI, non gonfiati** — il timore di "protezione fittizia" è
|
||||
refutato: il DD 12% è il caso peggiore della banda, non quello fortunato. Impatto della fase sul
|
||||
book 5-sleeve ≤ 0.04 Sh (banda ShFULL 2.23-2.27, ShHOLD 2.35-2.48, DD 6.2-6.3%). **Con questo
|
||||
l'audit anchor-luck è completo su tutti gli sleeve ancorati (4/4).**
|
||||
|
||||
### 6. Gate di regime sulla diagonale SPX (l'esperimento mancante) — REFUTED, con confound istruttivo
|
||||
L'esperimento che mancava ai 3 backtest Albimarini: il gate di regime sulla diagonale SPX (VIX +
|
||||
VXV da FRED, motore BS sintetico con skew log-lineare). **Domanda secca: il gate IV-rank ribalta gli
|
||||
anni di coda su SPX come su crypto? NO.** Il gate VIX-rank canonico *abbassa* lo Sharpe (1.36→1.19),
|
||||
non ribalta nessun anno di coda, peggiora il 2018, muore sul de-levering (ΔSh −0.17). Meccanismo:
|
||||
su SPX il VRP è già positivo l'**84% dei giorni** (vs ~58% delle settimane crypto) → il filtro di
|
||||
rank non ha lavoro discriminante; su equity IV-rank alto = crisi, non vol ricca.
|
||||
Il candidato **term-structure VIX/VXV<1 (contango)** sembrava reale — Sh 1.18→2.08, DD 3.5→1.3%,
|
||||
ΔSh **+0.90** a pari DD, multicut 3/3, flippa il 2020, DSR 0.992, plateau su c∈[0.93,1.05], jackknife
|
||||
robusto — **e passa la lente selection-bias pulito**. Ma la lente de-levering lo affonda:
|
||||
**l'intero uplift è un confound di modello, quantificato al 100%.** La variabile del gate (VIX/VXV>1)
|
||||
coincide 1:1 con la variabile dell'errore di pricing (tenor 6g prezzato a VIX-30g). Il refuter ha
|
||||
riprezzato con σ(6g)=VIX·(VIX/VXV)^α term-structure-consistent (α=1.42 = estrapolazione log-lineare
|
||||
zero-parametri della pendenza osservata 30g→93g) → l'edge sparisce. **Lezione: un gate che si
|
||||
correla con un errore di modello è indistinguibile da alpha finché non correggi il modello** — vale
|
||||
per ogni futuro test su strutture prezzate BS-flat.
|
||||
|
||||
### 7. Stress lab di coda — fisica dello strumento, nessun cambio
|
||||
La protezione di coda del VRP01 viene per ~metà dall'**ala far-OTM** (nel replay COVID taglia ~90%
|
||||
della perdita naked) e ~metà dal **gate canonico** (sulle 10 peggiori finestre 14g taglia la perdita
|
||||
cumulata del 44-71% saltando i re-entry a IV-rank>0.90). L'ala T+1 "Albimarini" è assicurazione solo
|
||||
contro i gap **medi** (−15/−20%, dove atterra ATM con un giorno di time value: +150bps) e **NON**
|
||||
contro i gap profondi (k(−30%)=0.98 → il de-levering la batte), con valore interamente dipendente
|
||||
dal vol-spike (banda f/mult da −324 a +263bps). Worst-case onesto a sizing 12%: piccolo in
|
||||
convenzione book (−€12/sett a 2k, −€31 a 5k alla cella −30%) ma **è il margine intero** se il 12%
|
||||
fosse deployato fisicamente come margine (−€164..−227).
|
||||
|
||||
---
|
||||
|
||||
## Scettico incrociato — verdetto e disambiguazione critica
|
||||
|
||||
I 2 sopravvissuti (sizing-conferma, anchor-ensemble) usano leve diverse (posizione sulla frontiera
|
||||
vs varianza della stima), ma lo scettico ha trovato **tre sovrapposizioni che i refuter singoli non
|
||||
potevano vedere**:
|
||||
|
||||
1. **Stessa dipendenza dal modello f** — entrambi flippano segno allo stesso punto (f=0.6-0.8 →
|
||||
negativo). Due sopravvissuti = **un solo grado di libertà di rischio-modello** (il caveat "premio
|
||||
MODELLATO su DVOL ATM" resta il collo di bottiglia dell'intera famiglia).
|
||||
2. **⚠️ CONFLITTO DI UNITÀ sul "12%"** (il punto più pericoloso per la narrativa): il **12% di PESO
|
||||
del book** (convenzione pnl/Ks di `sleeves.py`) = ~**0.6% margine/equity ≈ 0.014 Kelly**, mentre
|
||||
il "FISSO 12%" del filone sizing = **12% margine/equity ≈ 0.27 Kelly** — **fattore 19x**. Entrambe
|
||||
corrette internamente, ma non vanno confuse: worst-week −12.2% del conto (convenzione margine) vs
|
||||
−0.64% (convenzione peso book).
|
||||
3. **L'ensemble-7-fasi è ridondante con il tranching TP01** (matematica di diversificazione: corr fra
|
||||
fasi ρ=0.355, boost teorico ×1.50 vs osservato ×1.53, residuo alpha ≈ 0) → **non promuovere a LEAD
|
||||
autonomo**; la coda strutturale è invariata (worst = 7 tranche a full-loss simultaneo), il DD 4.7%
|
||||
è campionario, non un cap.
|
||||
|
||||
**Effetto congiunto sul book 5-sleeve: ZERO** (entrambi propongono nessun cambio; controfattuale
|
||||
|ΔSh| ≤ 0.04). Il book live Deribit (TP01+SKH01) non contiene VRP01 → impatto operativo nullo.
|
||||
|
||||
---
|
||||
|
||||
## Cosa entra nella narrativa (una sola voce, status = misura research)
|
||||
|
||||
- **Audit anchor-luck VRP01 CHIUSO** → i numeri di ammissione reggono e si citano con banda
|
||||
(ShFULL [1.09,1.83], ShHOLD [−0.03,1.11], DD [5.7,11.8%]); l'edge OOS resta f-dipendente.
|
||||
Con questo l'audit anchor è completo su 4/4 sleeve ancorati.
|
||||
- **Sizing (conferma, con disambiguazione unità obbligatoria):** 12% deploy ≈ 0.27 Kelly onesto,
|
||||
anti-rovina; NON confondere col 12% di peso book (0.014 Kelly, 19x).
|
||||
- **VRP01 canonico resta lo sleeve giusto, INVARIATO** — non migliorabile per struttura, gate,
|
||||
overlay o sizing; la protezione DD si compra con la size.
|
||||
|
||||
## Regole/candidati rafforzati
|
||||
|
||||
- **`implausible_sharpe` gate in altlib**: 3ª occorrenza del pattern "0-perdite = Sharpe
|
||||
implausibile" (griglia struttura, dopo CC01 e ALB-A) → priorità alzata.
|
||||
- **Nuova regola metodologica**: un gate che si correla con un errore di modello (term-structure vs
|
||||
BS-flat) è indistinguibile da alpha finché non si corregge il modello — riprezzare
|
||||
term-structure-consistent prima di credere a qualsiasi gate vol su strutture BS-flat.
|
||||
- **Conferma (4ª): l'alpha del VRP è il gate IV-rank binario**, non struttura/sizing/overlay/gate
|
||||
aggiuntivi.
|
||||
|
||||
**Stato:** book live INVARIATO, nessun nuovo sleeve, nessun forward-monitor. Test suite verde. 7
|
||||
script committati.
|
||||
@@ -0,0 +1,109 @@
|
||||
# 2026-07-07 — Video-claim "CRT top-down multi-TF, 74% win rate": SCARTATO (il 74% è un knob, non un edge)
|
||||
|
||||
**Richiesta utente:** analizzare e testare una strategia da video, metodo ICT/SMC top-down
|
||||
multi-timeframe con claim **74% win rate**:
|
||||
1. **H1** — setup CRT: candela di displacement forte, poi candela che prende liquidità (spike oltre
|
||||
il range di C1) ma richiude dentro → falso breakout → fade/reversal.
|
||||
2. **M15** — struttura: swing (U a 3 candele) + imbalance che diventa inverse-imbalance (FVG mitigato).
|
||||
3. **M5** — conferma: displacement direzionale + zona di protezione (order block / imbalance).
|
||||
4. **M1** — entry: attende la correzione, entra con SL dietro la zona di protezione.
|
||||
Uscita: **TP1 a fine zona CRT (RR ~1.5–2), chiude 70–80% lì → resto a break-even → runner 20–30%**
|
||||
verso la "liquidità successiva" (previous daily high/low). Due esempi mostrati (gold, nasdaq), entrambi
|
||||
vincenti. Metà finale del video = vendita academy → lead magnet.
|
||||
|
||||
La valutazione critica dell'utente ("occhio Milito") aveva già isolato le red flag giuste (74% non
|
||||
dimostrato, cherry-picking di 2 esempi, concetti discrezionali non falsificabili, RR basso + WR alto =
|
||||
fragile, funnel commerciale). Questo diario **meccanizza e falsifica** la parte testabile.
|
||||
|
||||
## Inventario preventivo (non rifare lavoro)
|
||||
|
||||
Il setup H1 CRT è **già stato meccanizzato e scartato 3 volte** nell'onda 2026-07-02:
|
||||
- `r0702_crt_base` (864 trial, 1h/4h/12h/1d): **DSR 0.000**; short "smart-money" negativo perfino IS.
|
||||
- `r0702_crt_mtf` (4h→15m, 1h→5m, ~10k trade): **expectancy netta negativa OVUNQUE**; refutazione
|
||||
strutturale = **"il ritest è informazione negativa"** (pattern CON ritest −40bps, SENZA +52bps).
|
||||
- `r0702_crt_context` (FVG/sessioni/livelli): FVG semmai peggiora; **FADE < FOLLOW ogni anno 2019-26**.
|
||||
|
||||
**Angolo NUOVO non coperto** = la **gestione d'uscita (parziale+BE+runner)** e il **claim 74% WR**.
|
||||
È lì che ho concentrato il test: `scripts/research/r0707_crt_topdown.py` (non tocca src/config/live;
|
||||
nessun file scritto). Riusa l'harness certificato (dati/atr/sharpe/DSR di `altlib`).
|
||||
|
||||
## Disegno
|
||||
|
||||
- Detection CRT C1-C2 su **H1** (causale, nota alla chiusura di C2). Direzione = fade dello sweep.
|
||||
- Entry sul TF basso (**5m/15m**, proxy di M5/M1 — ⚠️ **M1 non è nel feed certificato**): ritest della
|
||||
zona violata + close-back attraverso il livello ("displacement di conferma"), **SL = estremo dello
|
||||
swing basso = zona di protezione**.
|
||||
- Tre schemi d'uscita sugli **stessi ingressi**: `fix1.5` (full, TP 1.5R), `fix2.0` (full, TP 2.0R),
|
||||
`managed` (75% a 1.5R → resto 25% a BE, runner target = prev-daily high/low o estremo opposto C1).
|
||||
- Gate progetto: hold-out 2025-01-01, griglia (k, s, d, tf, scheme) con **selezione cella solo
|
||||
in-sample** + **DSR su tutti i trial**, **fee sweep** 0/0.10/0.20% RT, executability $600.
|
||||
- Metrica invariante = **expectancy in R netto fee** (non il win-rate).
|
||||
|
||||
## Esito — il 74% è un artefatto della distanza del target, non un edge
|
||||
|
||||
**1) Win-rate reale (fade, tutti gli schemi, pooled BTC+ETH):**
|
||||
|
||||
| schema | WR mediana | expectancy R (netto 10bps) |
|
||||
|---|---|---|
|
||||
| fix1.5 | **35.8%** | −2.38 |
|
||||
| fix2.0 | 31.3% | −2.34 |
|
||||
| managed | **35.8%** | −2.17 |
|
||||
|
||||
Il WR a RR 1.5 è **~35%**, cioè **SOTTO** il null gambler's-ruin di un random walk senza edge
|
||||
(P(+1.5R prima di −1R) = 1/(1+1.5) = **40%**). Il setup tocca 1.5R **meno spesso di una moneta** →
|
||||
conferma indipendente che *il ritest è informazione negativa*. Lo schema `managed` **non gonfia** il
|
||||
WR sopra `fix1.5` (per costruzione: "vinci" solo se raggiungi il parziale a 1.5R, che è la stessa
|
||||
condizione di `fix1.5`). **In nessuna cella si avvicina al 74%.**
|
||||
|
||||
**2) Il win-rate È un knob — sweep del target parziale `rr1` (managed, 5m, cella tipica):**
|
||||
|
||||
| rr1 | WR reale | null 1/(1+rr1) | expR (fee 10bps) | expR (fee 0) |
|
||||
|------|----------|----------------|------------------|--------------|
|
||||
| 0.50 | 51.4% | 66.7% | **−2.54** | +0.11 |
|
||||
| 0.75 | 46.9% | 57.1% | −2.51 | +0.14 |
|
||||
| 1.00 | 43.2% | 50.0% | −2.47 | +0.17 |
|
||||
| 1.50 | 36.3% | 40.0% | −2.45 | +0.19 |
|
||||
| 2.00 | 31.8% | 33.3% | −2.42 | +0.23 |
|
||||
|
||||
Avvicinando il target il WR sale (esattamente come previsto dalla formula gambler's-ruin): **un "74%"
|
||||
si fabbrica scegliendo un parziale vicino** (~0.35R), NON perché il metodo indovina la direzione. Ma
|
||||
l'**expectancy R è costante e negativa a ogni rr1**: il WR alto non porta soldi. NB anche a WR massimo
|
||||
il setup resta *sotto* il null teorico ad ogni rr1 → è pure peggio di un random walk nel raggiungere
|
||||
target vicini.
|
||||
|
||||
**3) Gate statistici (cella scelta in-sample = 15m, k1.5, s0.1, d0.15, fix2.0):**
|
||||
- **DSR = 0.000** su 48 trial (null max-Sharpe atteso 4.35). FAIL.
|
||||
- **Fee sweep:** a **0 bps** expR = −0.10 e Sharpe book −0.63 → **non è morte-per-fee: l'edge lordo
|
||||
non esiste** (il residuo lordo marginalmente positivo su 5m è la beta di trend dei time-exit, la
|
||||
stessa autopsia del CRT base). A 10bps expR −1.26; a 20bps −2.43. Stop stretti (0.13–0.24%) →
|
||||
la fee 0.10% pesa 0.75–2.6 R per trade → qualunque micro-edge lordo è polverizzato.
|
||||
- **Hold-out 2025-26:** WR 32%, expR −1.56 (peggio dell'IS). Nessuna reggenza fuori campione.
|
||||
|
||||
**4) Executability $600:** SL sul TF basso = 0.13% (5m) / 0.24% (15m). Lo schema parziale+BE+runner
|
||||
richiede **3–4 ordini per trade**, alcuni **sotto il min-order $5** a nozionale $1200 → non eseguibile
|
||||
pulito al capitale reale.
|
||||
|
||||
## Verdetto
|
||||
|
||||
**SCARTATO.** Il claim "74% win rate" è **coerente con ZERO edge**: è la scelta di un parziale vicino
|
||||
(gambler's ruin), non capacità direzionale. Smontato lo schema d'uscita, resta il setup H1 CRT che
|
||||
questo progetto ha già triplo-refutato (DSR 0.000, ritest = informazione negativa, fade < follow).
|
||||
Sul feed certificato BTC/ETH il metodo ha **win-rate reale ~30–37% e expectancy R negativa a ogni
|
||||
schema, fee, finestra e asset**; lordo di fee non c'è edge; a $600 non è nemmeno eseguibile pulito.
|
||||
|
||||
La parte *utile* del video è il framework di lettura (CRT + imbalance + parziali), non il numero — come
|
||||
aveva già concluso la valutazione dell'utente. Da trattare come lettura del mercato, non come sistema.
|
||||
|
||||
## Lezione codificabile (candidata)
|
||||
|
||||
**Il win-rate di uno schema parziale+BE non è un parametro di merito: è ~P(tocca il primo target prima
|
||||
dello stop) = 1/(1+rr1) per un processo senza drift.** Ogni claim "WR X%" da schema SMC va convertito
|
||||
in **expectancy R netto fee** prima di crederci; il WR alto si compra con un target vicino a costo di
|
||||
un'expectancy identica (o peggiore). Prossimo giro di gate: un helper `winrate_is_a_knob()` che, dato
|
||||
un book a schema parziale, riporta expectancy-R vs WR al variare del target (rende esplicito l'artefatto).
|
||||
|
||||
## File
|
||||
|
||||
- `scripts/research/r0707_crt_topdown.py` — il test (griglia + 74%-check + knob-sweep + DSR + fee + exec).
|
||||
- Prior: `r0702_crt_{base,mtf,context}.py`, diario `2026-07-02-timing-crt-wave.md`.
|
||||
- Nessuna modifica a src/, config/, scripts/live/, tests/. Book live e pesi INVARIATI.
|
||||
@@ -0,0 +1,108 @@
|
||||
# 2026-07-09 — Incident infra: "BOOK LIVE conto offline" = cert Traefik di default (non un problema del conto)
|
||||
|
||||
**Tipo:** incident operativo (infra VPS), non ricerca. Nessun edge, nessun cambio a strategie/pesi/config
|
||||
di trading. Documentato qui perché ha **bloccato la lettura del conto del book live** e ha prodotto
|
||||
l'alert Telegram `⚠️ BOOK LIVE — conto offline`.
|
||||
|
||||
## Sintomo
|
||||
|
||||
Alert dal `cron_book` orario (`scripts/live/book_execute.py --execute`):
|
||||
|
||||
```
|
||||
⚠️ BOOK LIVE — conto offline
|
||||
nota: salto l'esecuzione, non opero a cieco
|
||||
```
|
||||
|
||||
Nel log (`logs/cron_book.log`) le run recenti mostravano `conto non leggibile (offline) -> stop, non
|
||||
eseguo a cieco`. 10 run offline su 382 totali → **regressione recente**, non uno stato storico.
|
||||
|
||||
## Diagnosi (catena completa)
|
||||
|
||||
Il book legge il conto Deribit mainnet in sola lettura via **Cerbero MCP** (`DeribitRead`,
|
||||
`src/live/deribit.py` → `https://cerbero-mcp.tielogic.xyz`). `book_report` marca `online=False` quando
|
||||
`DeribitRead.mark_price(BTC)` lancia. Riproduzione diretta:
|
||||
|
||||
```
|
||||
mark_price BTC: FAIL -> SSLError: [SSL: CERTIFICATE_VERIFY_FAILED] self-signed certificate
|
||||
```
|
||||
|
||||
Il proxy presentava il **cert self-signed di default di Traefik** (`CN = TRAEFIK DEFAULT CERT`,
|
||||
`notBefore Jul 9 19:55 2026`) al posto del vero Let's Encrypt. `curl -k` → HTTP 401 (backend
|
||||
`cerbero-mcp` healthy, routing OK): **solo la presentazione del cert TLS era rotta**. `requests`
|
||||
verifica il cert → fallisce → `online=False` → il book salta l'esecuzione (comportamento di sicurezza
|
||||
**corretto**: non opera a cieco).
|
||||
|
||||
**Causa a monte** (log di avvio Traefik, 11:37:16Z, container `traefik-traefik-1`):
|
||||
|
||||
```
|
||||
ERR The ACME resolve is skipped from the resolvers list
|
||||
error="unable to get ACME account: permissions 660 for /acme.json are too open, please use 600"
|
||||
resolver=mytlschallenge
|
||||
→ (a cascata) Router uses a nonexistent certificate resolver certificateResolver=mytlschallenge
|
||||
routerName=cerbero-mcp@docker [+ ~14 altri router: git, vault, portfolio, budget, cerbero-bite, ...]
|
||||
```
|
||||
|
||||
Sequenza:
|
||||
1. Immagine `traefik:latest` + **Watchtower** (`com.centurylinklabs.watchtower.enable=true`) →
|
||||
auto-upgrade a **Traefik 3.7.7** con restart alle 11:37 di oggi.
|
||||
2. Traefik 3.x **rifiuta** `acme.json` con permessi più larghi di `600`; il file era `660`
|
||||
(`-rw-rw---- root:adriano`) → **resolver `mytlschallenge` scartato**.
|
||||
3. Tutti i ~15 router che lo referenziano → "nonexistent certificate resolver" → Traefik serve il
|
||||
**cert di default self-signed** a ogni host HTTPS del VPS (non solo cerbero-mcp).
|
||||
4. I cert veri erano **già dentro `acme.json`** (14 domini, incl. `cerbero-mcp.tielogic.xyz`) → nessuna
|
||||
ri-emissione necessaria, **zero rischio rate-limit** Let's Encrypt.
|
||||
|
||||
## Impatto
|
||||
|
||||
- **VPS-wide:** ogni servizio HTTPS dietro Traefik serviva un cert invalido (warning browser, fallimento
|
||||
di ogni client che verifica il TLS). Non solo il trading.
|
||||
- **Trading:** **nessun trade perso.** Target del book **flat** (TP01/SKH01 risk-off) + conto reale
|
||||
~$598 (non finanziato al livello nominale): anche online il book avrebbe fatto HOLD. Il doppio gate
|
||||
di sicurezza ha lavorato come previsto.
|
||||
|
||||
## Fix
|
||||
|
||||
**Immediato** (eseguito dall'utente):
|
||||
```bash
|
||||
sudo chmod 600 /opt/docker/traefik/acme.json
|
||||
docker restart traefik-traefik-1
|
||||
```
|
||||
Il resolver ricarica i cert già presenti in `acme.json` e li ripresenta subito.
|
||||
|
||||
**Durevole** (pin dell'immagine, `/opt/docker/traefik/docker-compose.yml`):
|
||||
```yaml
|
||||
- image: "traefik" # = traefik:latest → Watchtower salta ai major (causa dell'incident)
|
||||
+ image: "traefik:3.7" # Watchtower resta su 3.7.x: patch sì, salto a 3.8+ breaking no
|
||||
```
|
||||
Applicato con `docker compose up -d` (container ricreato su `traefik:3.7`, 20:05:05Z).
|
||||
|
||||
## Verifica (end-to-end, post-fix)
|
||||
|
||||
| Check | Risultato |
|
||||
|---|---|
|
||||
| `acme.json` perms | `600` |
|
||||
| Cert `cerbero-mcp.tielogic.xyz` | issuer **Let's Encrypt** (fino 2026-09-27) |
|
||||
| Traefik log | nessun `ACME resolve is skipped` / `too open` |
|
||||
| Container image | `traefik:3.7` (running) |
|
||||
| `DeribitRead.mark_price` | BTC 63.193 / ETH 1.746 |
|
||||
| `book_report.online` | **True** · `eq_basis=mainnet USDC` · `real_equity=$598.06` · `pos_error=None` |
|
||||
|
||||
## Lezioni
|
||||
|
||||
- **`traefik:latest` + Watchtower = auto-upgrade non deterministico.** Un bump di major (qui il giro
|
||||
di vite sui permessi di `acme.json`) diventa un'interruzione TLS di tutto il VPS senza intervento
|
||||
umano. Pinnare la minor (`3.7`) rende i restart prevedibili pur ricevendo le patch.
|
||||
- **Traefik 3.x pretende `acme.json` a `600`** (non solo warning: **scarta il resolver**). Se il file
|
||||
è group/other-readable, tutti i router cadono sul cert di default.
|
||||
- **Il gate `online` del book ha fatto il suo lavoro:** un problema *infra di lettura* non ha prodotto
|
||||
ordini a cieco. L'alert "conto offline" va letto prima come possibile problema di **connettività/cert
|
||||
al Cerbero MCP**, non del conto Deribit.
|
||||
- **Runbook rapido** per "BOOK LIVE conto offline":
|
||||
1. `python -c "from src.live.deribit import DeribitRead; DeribitRead().mark_price('BTC-PERPETUAL')"`
|
||||
→ se `SSLError`/`CERTIFICATE_VERIFY_FAILED` è il cert del proxy, non il conto.
|
||||
2. `openssl s_client -connect cerbero-mcp.tielogic.xyz:443 -servername cerbero-mcp.tielogic.xyz | openssl x509 -noout -issuer`
|
||||
→ se `TRAEFIK DEFAULT CERT`, Traefik non serve il cert reale.
|
||||
3. `docker logs traefik-traefik-1 | grep -iE "ACME resolve is skipped|too open|nonexistent"`
|
||||
→ conferma resolver scartato; controlla `stat -c %a /opt/docker/traefik/acme.json` (deve essere `600`).
|
||||
|
||||
Nessun file di trading toccato; `config/live.json`, sleeve e pesi **invariati**.
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
# BOOK DERIBIT-ONLY (TP01+SKH01 nettati) — esecuzione LIVE a cadenza ORARIA. v2.0.0+.
|
||||
# SKH01 decide su griglia 230m -> serve girare piu' spesso del daily; orario IDEMPOTENTE:
|
||||
# riconcilia al target NETTO corrente (se non cambia nulla -> HOLD). Il feed 5m fresco per il
|
||||
# segnale SKH e' preso IN MEMORIA dentro book_execute (livefeed.fresh_5m): NON tocca i dati
|
||||
# certificati su disco. Esecuzione reale gated da config/live.json (execution_enabled) + --execute.
|
||||
export PATH="/home/adriano/.local/bin:$PATH"
|
||||
cd /opt/docker/PythagorasGoal || exit 1
|
||||
mkdir -p logs
|
||||
{
|
||||
echo "===== $(date -u '+%Y-%m-%dT%H:%M:%SZ') cron_book ====="
|
||||
uv run python scripts/live/book_execute.py --execute
|
||||
echo "===== done $(date -u '+%H:%M:%SZ') ====="
|
||||
} >> logs/cron_book.log 2>&1
|
||||
@@ -10,6 +10,14 @@ 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/live_execute.py --execute # TP01 LIVE su Deribit (gated da config/live.json)
|
||||
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.
|
||||
# --- COMBO cross-venue (PAPER): refresh ETF IB (GTAA) + avanza paper TP01+GTAA ---
|
||||
docker compose up -d ib-gateway >/dev/null 2>&1 # gateway IB paper (idempotente)
|
||||
for i in $(seq 1 25); do (echo > /dev/tcp/127.0.0.1/4002) >/dev/null 2>&1 && break; sleep 6; done
|
||||
uv run --with ib_async python scripts/research/fetch_ib_equities.py --only SPY,QQQ,IWM,TLT,GLD,HYG # ETF GTAA freschi
|
||||
uv run python scripts/live/paper_combo.py # avanza paper combo (forward-only)
|
||||
echo "===== done $(date -u '+%H:%M:%SZ') ====="
|
||||
} >> logs/cron_daily.log 2>&1
|
||||
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
# LOGGER FORWARD della vol term-structure Deribit — cadenza GIORNALIERA. v2.0.0+.
|
||||
# Costruisce il dataset per un futuro calendar-vol (oggi NON backtestabile: storia per-scadenza non
|
||||
# pubblica — vedi docs/diary/2026-06-26-vol-termstructure-feasibility.md). Append idempotente per
|
||||
# giorno su data/raw/vol_term_<asset>.parquet. SOLO ricerca forward: NON tocca il book live ne' i
|
||||
# dati certificati BTC/ETH; legge l'API pubblica Deribit (tokenless) e scrive un parquet dedicato.
|
||||
export PATH="/home/adriano/.local/bin:$PATH"
|
||||
cd /opt/docker/PythagorasGoal || exit 1
|
||||
mkdir -p logs
|
||||
{
|
||||
echo "===== $(date -u '+%Y-%m-%dT%H:%M:%SZ') cron_vol_term ====="
|
||||
uv run python scripts/research/log_vol_termstructure.py
|
||||
echo "===== done $(date -u '+%H:%M:%SZ') ====="
|
||||
} >> logs/cron_vol_term.log 2>&1
|
||||
@@ -0,0 +1,162 @@
|
||||
"""BOOK DERIBIT-ONLY LIVE EXECUTE — TP01 + SKH01 NETTATI in software su un solo conto Deribit mainnet.
|
||||
|
||||
Porta il conto reale al target NETTO per asset (vedi src/live/book.py): per ogni asset combina la
|
||||
frazione long-flat di TP01 (peso 0.75) e il segno L/S di SKH01 (peso 0.25), e manda UN ordine con
|
||||
segno (long/short/flip) per raggiungerlo. Poi assicura un disaster-SL on-book sulla posizione NETTA.
|
||||
|
||||
DOPPIO GATE DI SICUREZZA (entrambi necessari per inviare ordini reali):
|
||||
1. config/live.json -> "execution_enabled": true (master switch, default false)
|
||||
2. flag CLI --execute
|
||||
Senza entrambi e' un DRY-RUN (stampa il piano, NON invia). Reconciliation dopo ogni ordine; log in
|
||||
data/live/book_executions.jsonl.
|
||||
|
||||
⚠️ CADENZA: SKH01 decide su griglia 230m -> questo script va lanciato ogni ~230 minuti con la feed
|
||||
fresca all'ultima barra chiusa (NON il cron giornaliero, che mancherebbe gli ingressi). Gli exit di
|
||||
SKH sono SOFTWARE (latenza fino a fine barra 230m); solo il disaster-SL (-30%) e' on-book.
|
||||
|
||||
uv run python scripts/live/book_execute.py # DRY-RUN (piano, nessun ordine)
|
||||
uv run python scripts/live/book_execute.py --execute # esegue SOLO se execution_enabled=true
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.live.book import book_report
|
||||
from src.live.execution import DeribitTrader
|
||||
from src.live.notifier import notify
|
||||
|
||||
CONFIG = PROJECT_ROOT / "config" / "live.json"
|
||||
LOG_DIR = PROJECT_ROOT / "data" / "live"
|
||||
LOG = LOG_DIR / "book_executions.jsonl"
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
cfg = json.loads(CONFIG.read_text()) if CONFIG.exists() else {}
|
||||
cfg.setdefault("execution_enabled", False)
|
||||
cfg.setdefault("max_notional_per_asset_usd", 300.0)
|
||||
cfg.setdefault("min_order_usd", 5.0)
|
||||
cfg.setdefault("disaster_sl_pct", 0.30)
|
||||
return cfg
|
||||
|
||||
|
||||
def log_event(rec: dict):
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with open(LOG, "a") as f:
|
||||
f.write(json.dumps(rec) + "\n")
|
||||
|
||||
|
||||
def _run():
|
||||
cfg = load_config()
|
||||
want_execute = "--execute" in sys.argv[1:]
|
||||
enabled = bool(cfg["execution_enabled"])
|
||||
do_execute = want_execute and enabled
|
||||
min_order = float(cfg["min_order_usd"])
|
||||
sl_pct = float(cfg["disaster_sl_pct"])
|
||||
|
||||
r = book_report(live_feed=True) # target NETTO + conto/posizioni reali (feed SKH fresco)
|
||||
equity = r["equity"]
|
||||
|
||||
print("=" * 88)
|
||||
print(" BOOK DERIBIT LIVE EXECUTE — TP01(0.75)+SKH01(0.25) NETTATI — Deribit mainnet (USDC linear)")
|
||||
print("=" * 88)
|
||||
mode = ("ESECUZIONE REALE" if do_execute else
|
||||
("ARMATO ma manca --execute" if enabled else "DRY-RUN (execution_enabled=false)"))
|
||||
print(f" modo : {mode}")
|
||||
print(f" gate : execution_enabled={enabled} | --execute={want_execute}")
|
||||
print(f" conto reale : ${r['real_equity']:,.2f}" if r["real_equity"] else f" conto: {r['eq_basis']}")
|
||||
print(f" sizing base : ${equity:,.2f} | cap/asset ${r['cap_per_asset']:.0f} | min ${min_order:.0f} | disaster-SL -{sl_pct*100:.0f}%")
|
||||
print(f" ultima barra : {r['last_data']}\n")
|
||||
|
||||
if r.get("skh_error"): # SKH feed fallito -> book.py ha forzato flat IN SILENZIO
|
||||
print(f" ⚠️ SKH FEED ERRORE (SKH forzato flat!): {r['skh_error']}")
|
||||
notify("⚠️ BOOK — SKH feed fallito (sleeve forzato flat)", {"error": r["skh_error"]})
|
||||
|
||||
if not r["online"]:
|
||||
print(" conto non leggibile (offline) -> stop, non eseguo a cieco.")
|
||||
if do_execute:
|
||||
notify("⚠️ BOOK LIVE — conto offline", {"nota": "salto l'esecuzione, non opero a cieco"})
|
||||
return
|
||||
|
||||
if r.get("pos_error"): # ONLINE ma posizione IGNOTA (read fallita -> assunta flat)
|
||||
print(f" 🛑 POSIZIONE NON LEGGIBILE -> NON eseguo a cieco: {r['pos_error']}")
|
||||
if do_execute:
|
||||
notify("🛑 BOOK LIVE — posizione non leggibile", {"error": r["pos_error"],
|
||||
"nota": "salto l'esecuzione, non opero a cieco"})
|
||||
return
|
||||
|
||||
if r.get("eq_fallback"): # equity reale non leggibile -> sizing su paper_cap
|
||||
print(f" ⚠️ EQUITY FALLBACK (sizing su paper_cap, NON blocco): {r['eq_fallback']}")
|
||||
if do_execute: # solo diagnostica: l'hard-cap $/asset limita il downside
|
||||
notify("⚠️ BOOK LIVE — equity fallback (sizing su paper_cap)", {"nota": r["eq_fallback"]})
|
||||
|
||||
trader = DeribitTrader() if do_execute else None
|
||||
actions = []
|
||||
for a in r["assets"]:
|
||||
asset, inst = a["asset"], a["instrument"]
|
||||
net, cur, mark = a["net_target"], a["position_usd"], a["mark"]
|
||||
sk = a["skh_state"]
|
||||
sk_txt = "flat" if sk == "flat" else f"{sk['dir']}@{sk.get('entry')}"
|
||||
order = a["order"]
|
||||
if order is None:
|
||||
act = "HOLD (a target)"
|
||||
elif order.get("is_close"):
|
||||
act = f"CLOSE ${cur:,.0f}"
|
||||
elif order.get("needs_flip"):
|
||||
act = f"FLIP -> ${net:,.0f}"
|
||||
else:
|
||||
act = f"{order['side'].upper()} ${order['delta']:+,.0f}"
|
||||
print(f" {asset:<3} TP {a['tp_frac']:+.3f} · SKH {a['skh_sign']:+d}({sk_txt}) -> net ${net:+,.0f} "
|
||||
f"| pos ${cur:+,.0f} -> {act}")
|
||||
|
||||
if do_execute and order is not None:
|
||||
fills = trader.rebalance_signed(inst, net, mark, min_usd=min_order)
|
||||
newpos = trader.position_usd(inst)
|
||||
for f in fills:
|
||||
print(f" -> {f.side.upper()} {f.filled:.4f} @ ${f.price or 0:,.1f} fee {f.fee_usdc:.5f} "
|
||||
f"({'OK' if f.verified else 'NON VERIFICATO: ' + f.notes})")
|
||||
log_event(dict(ts_utc=str(pd.Timestamp(r['last_data'])), asset=asset, action=act,
|
||||
side=f.side, filled=f.filled, price=f.price, fee=f.fee_usdc,
|
||||
verified=f.verified, notes=f.notes, net_target=net, pos_after=newpos,
|
||||
tp_frac=a["tp_frac"], skh_sign=a["skh_sign"]))
|
||||
det = dict(asset=asset, side=f.side, amount=round(f.filled, 4), price=round(f.price or 0, 1),
|
||||
fee=round(f.fee_usdc, 5), net=round(net, 0), pos_after=round(newpos, 0))
|
||||
notify(f"✅ BOOK {act}" if f.verified else "⚠️ BOOK ORDINE NON VERIFICATO",
|
||||
det if f.verified else {**det, "notes": f.notes})
|
||||
print(f" reconcile: pos ${newpos:,.0f}")
|
||||
if do_execute:
|
||||
ds = trader.ensure_disaster_sl(inst, sl_pct) # bracket su posizione NETTA (adatta long/short)
|
||||
print(f" disaster-SL: {ds.get('state')}" + (f" @ ${ds['stop']:,.1f}" if ds.get("stop") else ""))
|
||||
if ds.get("state") == "placed":
|
||||
notify("🛡️ BOOK disaster-SL piazzato", {"asset": asset, "stop": round(ds.get("stop") or 0, 1),
|
||||
"amount": round(ds.get("amount") or 0, 4)})
|
||||
elif ds.get("state") == "place-failed":
|
||||
notify("⚠️ BOOK disaster-SL FALLITO", {"asset": asset, "notes": ds.get("notes")})
|
||||
actions.append(act)
|
||||
|
||||
print()
|
||||
if not do_execute:
|
||||
print(" => DRY-RUN: nessun ordine inviato." +
|
||||
("" if enabled else " Per armare: config/live.json execution_enabled=true + --execute."))
|
||||
elif all(x.startswith("HOLD") for x in actions):
|
||||
print(" => Nessuna azione: conto gia' al target netto del book.")
|
||||
else:
|
||||
print(" => Esecuzione completata (vedi data/live/book_executions.jsonl).")
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
_run()
|
||||
except Exception as e:
|
||||
notify("🛑 BOOK LIVE — ERRORE", {"error": f"{type(e).__name__}: {e}"})
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,117 @@
|
||||
"""PAPER COMBO — forward-only del combo cross-venue TP01 (Deribit) + GTAA (IB), NUDO vs PROTETTO.
|
||||
|
||||
Le due gambe eseguibili a basso capitale (XS01/VRP01 STAT-MODE esclusi), scorrelate (corr ~0.21) ->
|
||||
blend Sharpe ~1.5, DD dimezzato. Traccia FORWARD-ONLY DUE versioni in parallelo:
|
||||
* NUDO = blend 50/50 TP01+GTAA
|
||||
* PROTETTO = stesso blend + GUARDIA-DRAWDOWN -4% (de-risk a 0.4x quando il DD da picco supera -4%,
|
||||
ri-rischia a -1.6%). Backtest: MaxDD 8.4%->5.8%, 2022 -4.4%->-1.8%, Sharpe 1.48->1.38
|
||||
(diario 2026-06-23-tail-hedge-lab). Le opzioni NON aiutano il grind del 2022 -> escluse.
|
||||
Crypto compoundato sul grid giorni-di-borsa. NESSUNA esecuzione reale. Mostra posizioni azionabili.
|
||||
|
||||
uv run python scripts/live/paper_combo.py [--status|--reset]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys, json
|
||||
from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
import numpy as np, pandas as pd
|
||||
from src.portfolio.sleeves import _tp01_returns, _tp01_positions
|
||||
from src.portfolio.gtaa import gtaa_returns, gtaa_weights
|
||||
|
||||
STATE_DIR = PROJECT_ROOT / "data" / "paper_combo"
|
||||
STATE = STATE_DIR / "state.json"
|
||||
EQ = STATE_DIR / "equity.csv"
|
||||
INITIAL = 2000.0
|
||||
W_CRYPTO = 0.5
|
||||
DD_TRIGGER = 0.04 # guardia-drawdown della versione PROTETTA
|
||||
|
||||
|
||||
def combo_daily(wc: float = W_CRYPTO) -> pd.Series:
|
||||
tp = _tp01_returns()
|
||||
if tp.index.tz is None:
|
||||
tp.index = tp.index.tz_localize("UTC")
|
||||
eq = gtaa_returns().dropna()
|
||||
grid = eq.index[eq.index >= tp.index[0]]
|
||||
cum = (1.0 + tp).cumprod()
|
||||
tpg = (cum.reindex(cum.index.union(grid)).ffill().reindex(grid)).pct_change()
|
||||
J = pd.concat({"c": tpg, "e": eq.reindex(grid)}, axis=1).dropna()
|
||||
return (wc * J["c"] + (1 - wc) * J["e"]).dropna()
|
||||
|
||||
|
||||
def apply_dd_guard(r: pd.Series, trigger: float = DD_TRIGGER) -> pd.Series:
|
||||
"""De-risk a 0.4x quando il DD da picco > trigger; ri-rischia a 1.0x quando < 0.4*trigger."""
|
||||
rv = r.values; n = len(rv); eq = np.cumprod(1 + rv); pk = np.maximum.accumulate(eq)
|
||||
expo = np.ones(n); on = True
|
||||
for i in range(1, n):
|
||||
ddi = (pk[i - 1] - eq[i - 1]) / pk[i - 1] if pk[i - 1] > 0 else 0.0
|
||||
if ddi > trigger: on = False
|
||||
if ddi < trigger * 0.4: on = True
|
||||
expo[i] = 1.0 if on else 0.4
|
||||
return pd.Series(expo * rv, index=r.index)
|
||||
|
||||
|
||||
def both_daily():
|
||||
naked = combo_daily()
|
||||
return naked, apply_dd_guard(naked)
|
||||
|
||||
|
||||
def load():
|
||||
return json.loads(STATE.read_text()) if STATE.exists() else None
|
||||
|
||||
|
||||
def save(st):
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
STATE.write_text(json.dumps(st, indent=2))
|
||||
|
||||
|
||||
def advance():
|
||||
naked, guard = both_daily()
|
||||
st = load()
|
||||
if st is None or "equity_g" not in st: # init (o migrazione a doppia versione)
|
||||
last = str(naked.index[-1])
|
||||
st = dict(start=last, last=last, initial=INITIAL, n_days=0, w_crypto=W_CRYPTO, dd_trigger=DD_TRIGGER,
|
||||
equity=INITIAL, peak=INITIAL, max_dd=0.0,
|
||||
equity_g=INITIAL, peak_g=INITIAL, max_dd_g=0.0)
|
||||
save(st); EQ.write_text("date,nudo,protetto\n" + f"{last},{INITIAL},{INITIAL}\n")
|
||||
return st
|
||||
last = pd.Timestamp(st["last"])
|
||||
nn = naked[naked.index > last]; gg = guard[guard.index > last]
|
||||
if len(nn):
|
||||
e = st["equity"]; pk = st["peak"]; dd = st["max_dd"]
|
||||
eg = st["equity_g"]; pkg = st["peak_g"]; ddg = st["max_dd_g"]; lines = []
|
||||
for d in nn.index:
|
||||
e *= (1 + float(nn[d])); pk = max(pk, e); dd = max(dd, (pk - e) / pk if pk > 0 else 0)
|
||||
eg *= (1 + float(gg[d])); pkg = max(pkg, eg); ddg = max(ddg, (pkg - eg) / pkg if pkg > 0 else 0)
|
||||
lines.append(f"{d},{e:.4f},{eg:.4f}")
|
||||
st.update(equity=e, peak=pk, max_dd=dd, equity_g=eg, peak_g=pkg, max_dd_g=ddg,
|
||||
last=str(nn.index[-1]), n_days=st["n_days"] + len(nn))
|
||||
save(st)
|
||||
with open(EQ, "a") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
return st
|
||||
|
||||
|
||||
def main():
|
||||
a = sys.argv[1:]
|
||||
if "--reset" in a:
|
||||
for f in (STATE, EQ):
|
||||
f.unlink(missing_ok=True)
|
||||
print("paper combo azzerato.")
|
||||
st = load() if "--status" in a else advance()
|
||||
if st is None or "equity_g" not in st:
|
||||
st = advance()
|
||||
days = (pd.Timestamp(st["last"]) - pd.Timestamp(st["start"])).days
|
||||
rn = st["equity"] / st["initial"] - 1; rg = st["equity_g"] / st["initial"] - 1
|
||||
gw = gtaa_weights(); asof = gw.pop("_asof", "?"); cash = gw.pop("_cash", None)
|
||||
print("PAPER COMBO — TP01 (Deribit) + GTAA (IB), forward-only, blend 50/50")
|
||||
print(f" start {st['start'][:10]} -> last {st['last'][:10]} ({days}g, {st['n_days']} barre)")
|
||||
print(f" NUDO : eq {st['equity']:.2f} ret {rn*100:+.2f}% maxDD {st['max_dd']*100:.1f}%")
|
||||
print(f" PROTETTO : eq {st['equity_g']:.2f} ret {rg*100:+.2f}% maxDD {st['max_dd_g']*100:.1f}% (guardia-DD -{st.get('dd_trigger',DD_TRIGGER)*100:.0f}%)")
|
||||
print(f" --- POSIZIONI AZIONABILI ---")
|
||||
print(f" TP01 (Deribit): {_tp01_positions()}")
|
||||
print(f" GTAA (IB, asof {asof}): " + ", ".join(f"{k} {v:.0%}" for k, v in gw.items() if v) + f" | cash {cash:.0%}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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,106 @@
|
||||
"""PROIEZIONE ACCUMULO del book DERIBIT-ONLY (TP01 + SKH01) — compounding puro (reinvesti le
|
||||
vincite), allineamento MENSILE, NESSUN versamento esterno (non e' un PAC).
|
||||
|
||||
Base: rendimenti mensili del book Deribit-only (rebal mensile, netto costo turnover). Proietta in
|
||||
avanti l'equity da un capitale iniziale:
|
||||
- DETERMINISTICO @CAGR storico e @CAGR conservativo (= storico × cons_frac, default metà);
|
||||
- MONTE CARLO block-bootstrap dei rendimenti mensili storici (mediana + banda p10/p90);
|
||||
- €/giorno run-rate (cresce col capitale, perche' si rigiocano le vincite).
|
||||
|
||||
⚠️ ONESTA': lo storico e' un BULL crypto ~2019-26 -> il futuro sara' quasi certamente piu' magro.
|
||||
Pianificare sulla colonna conservativa; il MC non contiene un vero bear pluriennale (anche il p10
|
||||
e' ottimista). Nessuna leva. SKH01 e' research/forward-monitor (solo TP01 e' armato live). Non e'
|
||||
una garanzia: e' una proiezione condizionata "se il futuro somigliasse al passato".
|
||||
|
||||
uv run python scripts/portfolio/forecast_deribit_book.py
|
||||
uv run python scripts/portfolio/forecast_deribit_book.py --capital 5000 --years 1,3,5,10 --cons-frac 0.5
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
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.portfolio import StrategyPortfolio, rebalance_sim
|
||||
from src.portfolio.sleeves import deribit_book_sleeves
|
||||
|
||||
|
||||
def book_monthly_returns(rebal_days: int, cost_rate: float) -> pd.Series:
|
||||
"""Rendimenti MENSILI del book Deribit-only (rebal periodico, netto costo)."""
|
||||
sl = deribit_book_sleeves()
|
||||
w = StrategyPortfolio(sl).weights()
|
||||
cols = {s.name: s.daily() for s in sl}
|
||||
r = rebalance_sim(cols, w, period_days=rebal_days, cost_rate=cost_rate)["daily"]
|
||||
return ((1.0 + r).resample("ME").prod() - 1.0).dropna()
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Proiezione accumulo book Deribit-only (compounding, allineamento mensile)")
|
||||
ap.add_argument("--capital", type=float, default=5000.0)
|
||||
ap.add_argument("--years", type=str, default="1,2,3,5,8,10")
|
||||
ap.add_argument("--cons-frac", type=float, default=0.5, help="CAGR conservativo = storico × questo (default 0.5)")
|
||||
ap.add_argument("--sims", type=int, default=4000)
|
||||
ap.add_argument("--block-months", type=int, default=3, help="blocco del bootstrap (mesi)")
|
||||
ap.add_argument("--rebal-days", type=int, default=30)
|
||||
ap.add_argument("--cost-rate", type=float, default=0.0005, help="fee per-lato sul turnover (Deribit taker)")
|
||||
ap.add_argument("--seed", type=int, default=7)
|
||||
a = ap.parse_args()
|
||||
|
||||
cap = a.capital
|
||||
HY = [int(x) for x in a.years.split(",") if x.strip()]
|
||||
m = book_monthly_returns(a.rebal_days, a.cost_rate)
|
||||
mv = m.values
|
||||
nm = len(mv)
|
||||
g_month = float(np.prod(1.0 + mv) ** (1.0 / nm) - 1.0)
|
||||
cagr = (1.0 + g_month) ** 12 - 1.0
|
||||
vol_ann = float(mv.std() * np.sqrt(12))
|
||||
cons_cagr = cagr * a.cons_frac
|
||||
|
||||
print("=" * 90)
|
||||
print(f" PROIEZIONE ACCUMULO — book Deribit-only (TP01+SKH01) | compounding, allineamento mensile, no versamenti")
|
||||
print(f" storico: {nm} mesi · CAGR {cagr*100:.1f}% · vol annua {vol_ann*100:.0f}% (bull crypto, no leva) | capitale €{cap:,.0f}")
|
||||
print("=" * 90)
|
||||
|
||||
# Monte Carlo: block-bootstrap dei rendimenti mensili
|
||||
rng = np.random.default_rng(a.seed)
|
||||
blk = a.block_months
|
||||
maxM = max(HY) * 12
|
||||
nb = maxM // blk + 1
|
||||
starts = rng.integers(0, nm - blk, size=(a.sims, nb))
|
||||
paths = np.concatenate([mv[starts[:, k][:, None] + np.arange(blk)[None, :]] for k in range(nb)], axis=1)[:, :maxM]
|
||||
eqMC = cap * np.cumprod(1.0 + paths, axis=1)
|
||||
|
||||
cons_m = (1.0 + cons_cagr) ** (1.0 / 12) - 1.0
|
||||
print(f"\n ACCUMULO (reinvesti le vincite):")
|
||||
print(f" {'oriz.':>6} | {'det @storico':>13} | {'det @conserv.':>14} | {'MC mediana':>11} | {'MC p10':>9} | {'MC p90':>10}")
|
||||
print(f" {'':>6} | {'('+format(cagr*100,'.0f')+'%)':>13} | {'('+format(cons_cagr*100,'.0f')+'%)':>14} |")
|
||||
print(" " + "-" * 80)
|
||||
for y in HY:
|
||||
mo = y * 12
|
||||
det = cap * (1.0 + g_month) ** mo
|
||||
detc = cap * (1.0 + cons_m) ** mo
|
||||
c = eqMC[:, mo - 1]
|
||||
print(f" {y:>4}a | €{det:>11,.0f} | €{detc:>12,.0f} | €{np.median(c):>9,.0f} | €{np.percentile(c,10):>7,.0f} | €{np.percentile(c,90):>8,.0f}")
|
||||
|
||||
# €/giorno run-rate @conservativo (cresce col capitale)
|
||||
rd = (1.0 + cons_cagr) ** (1.0 / 365.0) - 1.0
|
||||
print(f"\n €/GIORNO run-rate @conservativo ({cons_cagr*100:.1f}% CAGR) — cresce col capitale (rigiochi le vincite):")
|
||||
print(f" {'oriz.':>6} | {'equity':>9} | {'€/giorno':>10} | {'€/mese':>8}")
|
||||
print(" " + "-" * 42)
|
||||
for y in [0] + HY:
|
||||
E = cap * (1.0 + cons_cagr) ** y
|
||||
print(f" {('oggi' if y==0 else str(y)+'a'):>6} | €{E:>7,.0f} | €{E*rd:>7,.2f} | €{E*rd*30:>6,.0f}")
|
||||
E_end = cap * (1.0 + cons_cagr) ** max(HY)
|
||||
print(f" media €/giorno su {max(HY)} anni: €{(E_end-cap)/(max(HY)*365):.2f}/g (profitto €{E_end-cap:,.0f})")
|
||||
need = 50 * 365 / cons_cagr if cons_cagr > 0 else float('inf')
|
||||
print(f" capitale per ~€50/giorno @{cons_cagr*100:.1f}%: ≈ €{need:,.0f}")
|
||||
|
||||
print(f"\n ⚠️ Proiezione condizionata (storico = bull crypto); pianifica sul conservativo. Nessuna leva.")
|
||||
print(f" SKH01 = research/forward-monitor; solo TP01 e' armato live. Non e' una garanzia.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,86 @@
|
||||
"""REPORT del BOOK DERIBIT-ONLY realmente eseguibile = TP01 + SKH01 (75/25).
|
||||
|
||||
Le due gambe direzionali BTC/ETH sullo STESSO venue (Deribit), entrambe dal 2019. Esclude XS01
|
||||
(Hyperliquid, stat-mode) e VRP01 (opzioni modellate). Mostra:
|
||||
1. metriche oneste continuo (rebalance-continuo) vs RIBILANCIAMENTO PERIODICO realistico
|
||||
(settimanale/mensile) con costo turnover Deribit-taker;
|
||||
2. per-anno, accumulo da €2k (e nota €600 reale + min-order $5);
|
||||
3. posizioni correnti per gamba.
|
||||
|
||||
uv run python scripts/portfolio/run_deribit_book.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
|
||||
|
||||
from src.portfolio.portfolio import StrategyPortfolio, metrics, yearly, rebalance_sim, HOLDOUT
|
||||
|
||||
CAP = 2000.0
|
||||
REAL = 600.0 # capitale reale (vedi CLAUDE.md), min-order Deribit $5
|
||||
COST_RATE = 0.0005 # Deribit taker per-lato (~0.10% RT sul turnover netto)
|
||||
|
||||
|
||||
def line(tag, daily, extra=""):
|
||||
m = metrics(daily); h = metrics(daily[daily.index >= HOLDOUT])
|
||||
eqf = CAP * float(np.prod(1.0 + daily.values))
|
||||
print(f" {tag:<26} FULL Sh {m['sharpe']:.2f} ret {m['ret']*100:+.0f}% DD {m['maxdd']*100:.1f}% "
|
||||
f"| HOLD Sh {h['sharpe']:.2f} DD {h['maxdd']*100:.1f}% | €2k→€{eqf:,.0f} {extra}")
|
||||
return m, h
|
||||
|
||||
|
||||
def main():
|
||||
from src.portfolio.sleeves import deribit_book_sleeves
|
||||
sleeves = deribit_book_sleeves()
|
||||
pf = StrategyPortfolio(sleeves, capital=CAP)
|
||||
w = pf.weights()
|
||||
cols = {s.name: s.daily() for s in sleeves}
|
||||
|
||||
print("=" * 100)
|
||||
print(f" BOOK DERIBIT-ONLY (eseguibile) — {' + '.join(f'{k} {v*100:.0f}%' for k, v in w.items())} "
|
||||
f"| capitale €{CAP:,.0f} (reale ≈ ${REAL:,.0f}) | hold-out {HOLDOUT.date()}+")
|
||||
print("=" * 100)
|
||||
|
||||
# standalone per-gamba
|
||||
print("\n PER-GAMBA (standalone):")
|
||||
for s in sleeves:
|
||||
d = s.daily(); m = metrics(d); h = metrics(d[d.index >= HOLDOUT])
|
||||
print(f" {s.name:<16} [{w[s.name]*100:>3.0f}%] FULL Sh {m['sharpe']:.2f} DD {m['maxdd']*100:.0f}% "
|
||||
f"| HOLD Sh {h['sharpe']:.2f} DD {h['maxdd']*100:.0f}%")
|
||||
|
||||
print("\n COMBINATO — rebalance-CONTINUO (idealizzato, no costi) vs PERIODICO (reale, costo turnover):")
|
||||
cont = pf.combined_daily()
|
||||
line("continuo (no costo)", cont)
|
||||
sims = {}
|
||||
for tag, period in (("settimanale (7g)", 7), ("bisettimanale (14g)", 14), ("mensile (30g)", 30)):
|
||||
sim = rebalance_sim(cols, w, period_days=period, cost_rate=COST_RATE)
|
||||
sims[tag] = sim
|
||||
line(f"rebal {tag}", sim["daily"], extra=f"| turnover {sim['turnover_per_year']:.1f}×/anno, {sim['n_rebalances']} ribilanci")
|
||||
|
||||
# raccomandato = mensile
|
||||
rec = sims["mensile (30g)"]["daily"]
|
||||
print("\n PER ANNO (rebal mensile, netto costo):")
|
||||
for y, d in yearly(rec).items():
|
||||
print(f" {y}: ret {d['ret']*100:>+7.1f}% DD {d['dd']*100:>5.1f}%")
|
||||
|
||||
print("\n ACCUMULO (rebal mensile):")
|
||||
for cap, lbl in ((CAP, "€2k nominale"), (REAL, "$600 reale")):
|
||||
eq = cap * np.cumprod(1.0 + rec.values)
|
||||
yrs = len(rec) / 365.25
|
||||
print(f" {lbl:<14}: {cap:,.0f} → {eq[-1]:,.0f} (×{eq[-1]/cap:.1f}, ~{(eq[-1]-cap)/(yrs*365.25):+,.2f}/g)")
|
||||
|
||||
print("\n POSIZIONI CORRENTI (ultima barra chiusa):")
|
||||
for name, pos in pf.current_positions().items():
|
||||
print(f" {name}: {pos if pos is not None else 'segnale dual-TF (no pos-fn) — vedi engine'}")
|
||||
|
||||
print("\n NOTE ONESTE:")
|
||||
print(" · TP01 = unico armato live su Deribit (flat=risk-off). SKH01 = 2a gamba candidata (perp BTC/ETH).")
|
||||
print(" · SKH01 equity daily-step (Sharpe lens). A $600 il min-order è $5: un ribilancio mensile")
|
||||
print(" muove abbastanza nozionale da eseguirsi; il giornaliero NO (Δ sub-$5 = finzione) → usa ≥ settimanale.")
|
||||
print(" · Prima del deploy 2a gamba: validare causalità sul CODICE D'ESECUZIONE reale e costi del book a 230m.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -29,12 +29,14 @@ from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from scipy.stats import norm
|
||||
|
||||
# --- make `from src...` work no matter where the agent's script lives -------
|
||||
_ROOT = Path(__file__).resolve().parents[3]
|
||||
@@ -670,6 +672,94 @@ def causality_ok(target_fn, tf: str = "1h", assets=CERTIFIED,
|
||||
reason=("length-mismatch on prefix" if bad else None))
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SELECTION-ON-HOLDOUT GATE — codified 2026-06-29 from filone B (intraday ERM).
|
||||
#
|
||||
# LESSON 3: intraday_regime.py picked its "winner" cell by MAX hold-out Sharpe over a ~60-cell
|
||||
# grid, then ran study_marginal on THAT cell -> earns_slot=True. But the slot was an artifact of
|
||||
# SELECTING THE CELL ON THE HOLD-OUT: picking the cell IN-SAMPLE-ONLY (no peeking) lands on a
|
||||
# DIFFERENT, TP01-correlated cell that scores NEUTRAL, and the standalone Sharpe deflates to
|
||||
# DSR~0.0-0.24 over the trials searched. study_marginal alone can't catch this — it judges ONE
|
||||
# stream and never sees how the cell was chosen. The fix is two-fold and lives here:
|
||||
# (1) choose the cell IN-SAMPLE-ONLY (or walk-forward) BEFORE scoring the marginal, and
|
||||
# (2) DEFLATE the standalone Sharpe for the number of cells/families searched.
|
||||
# Twin of the CC01 ("implausible Sharpe -> hidden risk") and alt-sweep ("hold-out-fitting") blind
|
||||
# spots, in its "selection-on-holdout" form.
|
||||
# ===========================================================================
|
||||
def deflated_sharpe(sr_ann, all_sr_ann, daily_ret, dpy: float = 365.25):
|
||||
"""Deflated Sharpe Ratio (Bailey & Lopez de Prado): P(true Sharpe > the MAX Sharpe expected
|
||||
under the null of N independent trials). Penalizes multiple-testing — a standalone Sharpe ~1
|
||||
over a 100+ cell grid is routinely NOT significant once deflated. sr_ann = annualized Sharpe
|
||||
of the CHOSEN config; all_sr_ann = the Sharpe of EVERY cell searched; daily_ret = the chosen
|
||||
config's daily returns (for skew/kurt/T). Returns (DSR, expected_null_max_sharpe_ann);
|
||||
PASS if DSR >= 0.95."""
|
||||
r = np.asarray(pd.Series(daily_ret).dropna().values, float)
|
||||
T = len(r)
|
||||
if T < 30 or np.std(r) == 0:
|
||||
return float("nan"), float("nan")
|
||||
sr = sr_ann / math.sqrt(dpy)
|
||||
trials = np.asarray([s / math.sqrt(dpy) for s in all_sr_ann if np.isfinite(s)], float)
|
||||
N = max(len(trials), 2)
|
||||
var_tr = float(np.var(trials, ddof=1)) if N > 1 else 0.0
|
||||
emc = 0.5772156649
|
||||
z1 = norm.ppf(1 - 1.0 / N)
|
||||
z2 = norm.ppf(1 - 1.0 / (N * math.e))
|
||||
sr0 = math.sqrt(var_tr) * ((1 - emc) * z1 + emc * z2)
|
||||
sk = float(pd.Series(r).skew())
|
||||
ku = float(pd.Series(r).kurt()) + 3.0 # pandas kurt = excess
|
||||
den = math.sqrt(max(1e-9, 1 - sk * sr + (ku - 1) / 4.0 * sr ** 2))
|
||||
dsr = float(norm.cdf((sr - sr0) * math.sqrt(T - 1) / den))
|
||||
return dsr, sr0 * math.sqrt(dpy)
|
||||
|
||||
|
||||
def select_cell_insample(factory, grid, tfs, fee_side: float = FEE_SIDE) -> dict:
|
||||
"""Pick a config WITHOUT looking at the hold-out: rank grid cells by IN-SAMPLE (pre-HOLDOUT)
|
||||
standalone Sharpe of the 50/50 candidate. `factory(tf=..., **params)` -> target_fn; each grid
|
||||
item is a dict of factory kwargs (besides tf). Returns the in-sample-best cell, all rows
|
||||
(sorted), and EVERY cell's FULL Sharpe (for deflated_sharpe). This is the honest replacement
|
||||
for picking the max-hold-out cell."""
|
||||
rows = []
|
||||
for tf in tfs:
|
||||
for params in grid:
|
||||
try:
|
||||
daily = candidate_daily(factory(tf=tf, **params), tf=tf, fee_side=fee_side)
|
||||
except Exception:
|
||||
continue
|
||||
ins = daily[daily.index < HOLDOUT]
|
||||
is_sh = _sh(ins) if len(ins) > 60 else float("nan")
|
||||
rows.append(dict(tf=tf, params=params, insample_sharpe=round(is_sh, 3),
|
||||
full_sharpe=round(_sh(daily), 3)))
|
||||
valid = [r for r in rows if np.isfinite(r["insample_sharpe"])]
|
||||
chosen = max(valid, key=lambda r: r["insample_sharpe"]) if valid else None
|
||||
return dict(chosen=chosen,
|
||||
rows=sorted(valid, key=lambda r: r["insample_sharpe"], reverse=True),
|
||||
all_full_sharpe=[r["full_sharpe"] for r in rows])
|
||||
|
||||
|
||||
def study_family_honest(name: str, factory, grid, tfs, fee_side: float = FEE_SIDE,
|
||||
dsr_min: float = 0.95) -> dict:
|
||||
"""HARDENED family gate. A grid-searched directional candidate earns a slot ONLY if, picking
|
||||
the cell IN-SAMPLE-ONLY (no hold-out peeking), it STILL earns_slot via study_marginal AND its
|
||||
standalone Sharpe survives deflation for the WHOLE grid searched. Use this INSTEAD of
|
||||
cherry-picking the max-hold cell and calling study_marginal on it."""
|
||||
sel = select_cell_insample(factory, grid, tfs, fee_side=fee_side)
|
||||
ch = sel["chosen"]
|
||||
if ch is None:
|
||||
return dict(name=name, chosen=None, earns_slot_honest=False,
|
||||
reason="no valid in-sample cell")
|
||||
fn = factory(tf=ch["tf"], **ch["params"])
|
||||
sm = study_marginal(f"{name} ISpick {ch['params']}", fn, tf=ch["tf"], fee_side=fee_side)
|
||||
daily = candidate_daily(fn, tf=ch["tf"], fee_side=fee_side)
|
||||
dsr, sr0 = deflated_sharpe(_sh(daily), sel["all_full_sharpe"], daily)
|
||||
dsr_pass = bool(np.isfinite(dsr) and dsr >= dsr_min)
|
||||
return dict(name=name, n_cells=len(sel["all_full_sharpe"]), chosen=ch, rows=sel["rows"],
|
||||
marginal=sm, earns_slot_marginal=bool(sm["earns_slot"]),
|
||||
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,
|
||||
earns_slot_honest=bool(sm["earns_slot"] and dsr_pass))
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# DRIVERS — run a hypothesis across both assets, several TFs, with a fee sweep.
|
||||
# ===========================================================================
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""CC01 — CASH-AND-CARRY (basis trade) delta-neutral su Hyperliquid. Backtest onesto, STAT-MODE.
|
||||
|
||||
DIVERSO da FC01 (funding cross-sectional, gia' SCARTATO 2026-06-22). Qui NON si pickano
|
||||
vincitori/perdenti cross-section: si HARVESTA il premio di funding AGGREGATO restando
|
||||
delta-neutral sullo STESSO asset.
|
||||
|
||||
MECCANISMO. Long spot + short perp (stesso asset, stessa size):
|
||||
long spot -> +price_ret
|
||||
short perp -> -price_ret + funding (lo short INCASSA il funding quando f>0)
|
||||
netto -> +funding (il prezzo si cancella -> zero esposizione direzionale)
|
||||
Il ritorno della gamba delta-neutral = il funding realizzato, meno fee. Entrambe le gambe sono
|
||||
lineari nel prezzo => restano matchate in nozionale senza ribilanciare (delta ~neutro da solo);
|
||||
i costi reali sono entry/exit + spread, modellati come drag annuo fisso.
|
||||
|
||||
VARIANTI:
|
||||
CC-static -> sempre long-spot/short-perp (basis trade classico): r = funding. Perde se f<0.
|
||||
CC-gated -> harvest solo quando il funding trailing CAUSALE > 0 (esci dai regimi a funding
|
||||
negativo invece di pagarli). r = funding se trail>0 else 0.
|
||||
UNIVERSI: BTC/ETH (sottoinsieme rilevante per l'esecuzione) e i 19 major (basket pieno).
|
||||
|
||||
GIUDIZIO: standalone (Sharpe/DD/anni) + marginal_vs_tp01. CAVEAT ONESTI (pre-risultato):
|
||||
- NON eseguibile a $600: serve spot+perp per gamba (BTC/ETH = 4 posizioni; 19 = 38). Su Deribit
|
||||
lo storico funding e' bloccato e non operiamo spot HL -> STAT-MODE.
|
||||
- Il modello "r=funding" IGNORA il rischio di base (perp != spot), la liquidazione dello short in
|
||||
uno squeeze, e l'inversione brusca del funding. La vol modellata SOTTOSTIMA la coda.
|
||||
- Lo storico funding parte 2023-05 -> NON contiene il deleveraging 2022 (il regime peggiore per il
|
||||
carry). Edge potenzialmente sovrastimato.
|
||||
|
||||
uv run python scripts/research/cash_carry_hl.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
|
||||
|
||||
from src.portfolio.sleeves import XS_UNIVERSE, _HL_DIR
|
||||
from altlib import marginal_vs_tp01 # type: ignore
|
||||
|
||||
SQ365 = np.sqrt(365.25)
|
||||
ANNUAL_COST = 0.02 # drag annuo fisso: entry/exit + spread + borrow (generoso per un hold continuo)
|
||||
|
||||
|
||||
def load_funding_panel(universe):
|
||||
"""FUND, PREM [date x asset]: funding giornaliero (somma oraria) e premium/basis (ultimo del
|
||||
giorno) per gli asset con dati. PREM serve a iniettare il mark-to-market della BASE (perp!=spot)."""
|
||||
fund, prem = {}, {}
|
||||
for sym in universe:
|
||||
fp = _HL_DIR / f"hlfund_{sym.lower()}_1h.parquet"
|
||||
if not fp.exists():
|
||||
continue
|
||||
df = pd.read_parquet(fp)
|
||||
fund[sym] = df["funding"].resample("1D").sum()
|
||||
prem[sym] = df["premium"].resample("1D").last()
|
||||
FUND = pd.concat(fund, axis=1).sort_index()
|
||||
PREM = pd.concat(prem, axis=1).sort_index().reindex(FUND.index)
|
||||
if FUND.index.tz is None:
|
||||
FUND.index = FUND.index.tz_localize("UTC"); PREM.index = PREM.index.tz_localize("UTC")
|
||||
return FUND, PREM
|
||||
|
||||
|
||||
def cc_returns(universe, mode="static", trail=14, cost=ANNUAL_COST, with_basis=False) -> pd.Series:
|
||||
"""Ritorno giornaliero del basket cash-and-carry equal-weight, netto drag annuo.
|
||||
with_basis=True inietta il mark-to-market reale della base: r = funding - Δpremium (lo short
|
||||
perp marca contro l'allargamento del basis). E' il rischio che 'r=funding' nasconde."""
|
||||
FUND, PREM = load_funding_panel(universe)
|
||||
daily_cost = cost / 365.25
|
||||
leg_raw = FUND - PREM.diff() if with_basis else FUND # per-asset daily mark
|
||||
if mode == "gated":
|
||||
sig = FUND.rolling(trail, min_periods=trail // 2).mean().shift(1) # funding trailing causale
|
||||
active = (sig > 0).astype(float)
|
||||
n_active = active.sum(axis=1).replace(0, np.nan)
|
||||
gross = ((leg_raw * active).sum(axis=1) / n_active).fillna(0.0)
|
||||
drag = (active.sum(axis=1) > 0).astype(float) * daily_cost
|
||||
else:
|
||||
gross = leg_raw.mean(axis=1)
|
||||
drag = daily_cost
|
||||
return (gross - drag).dropna()
|
||||
|
||||
|
||||
def metrics(daily: pd.Series) -> dict:
|
||||
r = daily.values
|
||||
sh = float(np.mean(r) / np.std(r) * SQ365) if np.std(r) > 0 else 0.0
|
||||
eq = np.cumprod(1.0 + np.clip(r, -0.99, None))
|
||||
pk = np.maximum.accumulate(eq)
|
||||
dd = float(np.max((pk - eq) / pk)) if len(eq) else 0.0
|
||||
yrs = (daily.index[-1] - daily.index[0]).days / 365.25 if len(daily) > 1 else 1.0
|
||||
cagr = eq[-1] ** (1 / yrs) - 1 if yrs > 0 and len(eq) and eq[-1] > 0 else -1.0
|
||||
s = pd.Series(eq, index=daily.index)
|
||||
yearly = {}
|
||||
for y, g in s.groupby(s.index.year):
|
||||
if len(g) > 1:
|
||||
v = g.values; p = np.maximum.accumulate(v)
|
||||
yearly[int(y)] = (float(g.iloc[-1] / g.iloc[0] - 1), float(np.max((p - v) / p)))
|
||||
return dict(sharpe=sh, dd=dd, cagr=cagr, yearly=yearly, gross_ann=float(np.mean(r) * 365.25))
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 92)
|
||||
print(" CC01 — CASH-AND-CARRY (basis trade) delta-neutral su Hyperliquid")
|
||||
print(" long spot + short perp -> r = funding (zero esposizione prezzo). Netto drag 2%/anno.")
|
||||
print("=" * 92)
|
||||
|
||||
configs = [
|
||||
("BTC/ETH CC-static", ["BTC", "ETH"], "static"),
|
||||
("BTC/ETH CC-gated", ["BTC", "ETH"], "gated"),
|
||||
("19-major CC-static", XS_UNIVERSE, "static"),
|
||||
("19-major CC-gated", XS_UNIVERSE, "gated"),
|
||||
]
|
||||
print("\n [A] MODELLO INGENUO (r = funding) — IGNORA il rischio di base:")
|
||||
series = {}
|
||||
for label, uni, mode in configs:
|
||||
r = cc_returns(uni, mode=mode)
|
||||
series[label] = r
|
||||
m = metrics(r)
|
||||
ys = " ".join(f"{y}:{p*100:+.0f}%" for y, (p, d) in sorted(m['yearly'].items()))
|
||||
print(f"\n --- {label} --- ({r.index[0].date()} -> {r.index[-1].date()}, {len(r)}g)")
|
||||
print(f" Sharpe {m['sharpe']:+.2f} CAGR {m['cagr']*100:+.1f}% maxDD {m['dd']*100:.1f}% "
|
||||
f"carry lordo {m['gross_ann']*100:+.1f}%/anno | per-anno: {ys}")
|
||||
|
||||
print("\n [B] CON MARK-TO-MARKET DELLA BASE (r = funding - Δpremium) — il rischio nascosto:")
|
||||
series_b = {}
|
||||
for label, uni, mode in configs:
|
||||
r = cc_returns(uni, mode=mode, with_basis=True)
|
||||
series_b[label] = r
|
||||
m = metrics(r)
|
||||
ys = " ".join(f"{y}:{p*100:+.0f}%" for y, (p, d) in sorted(m['yearly'].items()))
|
||||
print(f"\n --- {label} (basis) ---")
|
||||
print(f" Sharpe {m['sharpe']:+.2f} CAGR {m['cagr']*100:+.1f}% maxDD {m['dd']*100:.1f}% "
|
||||
f"| per-anno: {ys}")
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print(" REALITY CHECK — perche' uno Sharpe 11-13 e' un ARTEFATTO, non un edge")
|
||||
print("=" * 92)
|
||||
FUND, _ = load_funding_panel(["BTC", "ETH"])
|
||||
agg = FUND.mean(axis=1)
|
||||
neg = float((agg < 0).mean())
|
||||
by_year = agg.groupby(agg.index.year).apply(lambda x: float(x.sum()))
|
||||
print(f" funding aggregato BTC/ETH: giorni a funding NEGATIVO {neg*100:.0f}% | "
|
||||
f"per-anno (somma): " + " ".join(f"{y}:{v*100:+.1f}%" for y, v in by_year.items()))
|
||||
print(" -> il carry e' PROCYCLICO: +23% nel toro 2024, ~+1% nel 2026 (si comprime nel bear).")
|
||||
print(" -> lo storico funding parte 2023-05: ASSENTE il deleveraging 2022 (funding -, basis blow-out),")
|
||||
print(" il regime che farebbe il vero drawdown. + assenti: liquidazione short in squeeze, slippage")
|
||||
print(" su spot+perp a $600. Sharpe reale di un basis-trade ~1-3 con code brusche, NON 13.")
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print(" SCORING MARGINALE vs TP01 (sul modello ONESTO con rischio di base [B])")
|
||||
print(" NB: ADDS/robust_oos qui ESPONGONO un punto cieco dello scorer — si fida della vol")
|
||||
print(" riportata e non ha un gate 'Sharpe implausibile -> rischio nascosto'.")
|
||||
print("=" * 92)
|
||||
for label, r in series_b.items():
|
||||
if r.std() == 0:
|
||||
print(f"\n[{label}] flat — skip"); continue
|
||||
m = marginal_vs_tp01(r)
|
||||
b = m.get("blends", {}).get("w25", {})
|
||||
print(f"\n[{label}]")
|
||||
print(f" verdict={m.get('marginal_verdict')} corr->TP01 full={m.get('corr_full')} "
|
||||
f"hold={m.get('corr_hold')} is_hedge={m.get('is_hedge')} "
|
||||
f"has_insample_edge={m.get('has_insample_edge')} (cand IS Sharpe {m.get('cand_insample_sharpe')})")
|
||||
print(f" cand Sharpe full={m.get('cand_full_sharpe')} hold={m.get('cand_hold_sharpe')} | "
|
||||
f"blend25 full {b.get('full')} (upl {b.get('uplift_full')}) "
|
||||
f"hold {b.get('hold')} (upl {b.get('uplift_hold')}) DD {b.get('dd')}")
|
||||
print(f" multicut persistente={m.get('multicut_persistent')} robust_oos={m.get('robust_oos')} "
|
||||
f"hedge-check up/down {m.get('uplift_tp01_up')}/{m.get('uplift_tp01_down')}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Simulazione LEVA 1x/2x/3x su COMBO (TP01+GTAA) e TP01-solo, da $2k e $5k.
|
||||
|
||||
Leva modellata onestamente: ritorno_giorno = L*r - (L-1)*financing/252 (costo del nozionale preso a
|
||||
prestito ~8%/anno blended: perp funding crypto + margin IB). MaxDD calcolato sul PERCORSO LEVATO REALE
|
||||
(non scalato: il compounding peggiora il DD oltre ×L). Check RUINA/margin-call: se l'equity tocca la
|
||||
soglia di liquidazione (perdita cumulata >= 1/L del picco -> margin call).
|
||||
|
||||
CLAUDE.md: la leva NON e' la scorciatoia; raddoppia (e oltre) il drawdown. Caso base = 1x.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import numpy as np, pandas as pd
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
from combo_yearly_report import combo_daily
|
||||
from src.portfolio.sleeves import _tp01_returns
|
||||
|
||||
FIN = 0.08 # costo finanziamento annuo sul nozionale preso a prestito (perp funding + margin IB), blended
|
||||
|
||||
|
||||
def lever(ret: pd.Series, L: float) -> pd.Series:
|
||||
return L * ret - (L - 1) * FIN / 252.0
|
||||
|
||||
|
||||
def analyze(ret: pd.Series, L: float, cap0: float):
|
||||
r = lever(ret.dropna().sort_index(), L)
|
||||
curve = cap0 * np.cumprod(1 + r.values)
|
||||
peak = np.maximum.accumulate(curve)
|
||||
dd = (peak - curve) / peak
|
||||
maxdd = float(np.max(dd))
|
||||
# margin call: perdita dal picco >= 1/L (a leva L, un drawdown del sottostante di 1/L azzera il margine)
|
||||
ruin = bool(np.any(dd >= 1.0 / L - 1e-9)) if L > 1 else False
|
||||
yrs = (r.index[-1] - r.index[0]).days / 365.25
|
||||
cagr = (curve[-1] / cap0) ** (1 / yrs) - 1 if yrs > 0 and curve[-1] > 0 else -1
|
||||
sh = float(r.mean() / r.std() * np.sqrt(252)) if r.std() > 0 else 0
|
||||
worst_y = min((np.prod(1 + r[r.index.year == y].values) - 1) for y in sorted(set(r.index.year)))
|
||||
return dict(L=L, final=float(curve[-1]), cagr=cagr, maxdd=maxdd, sharpe=sh, ruin=ruin,
|
||||
worst_y=float(worst_y), perday=(curve[-1] - cap0) / yrs / 365)
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 92)
|
||||
print(" LEVA su COMBO vs TP01-solo — percorso reale (fin 8%/anno sul prestito), 2019-2026 (~7.3y)")
|
||||
print("=" * 92)
|
||||
strat = {"COMBO TP01+GTAA": combo_daily(), "TP01 solo (crypto)": _tp01_returns()}
|
||||
for nm, r in strat.items():
|
||||
if r.index.tz is None:
|
||||
r.index = r.index.tz_localize("UTC")
|
||||
for cap0 in (2000.0, 5000.0):
|
||||
print(f"\n ##### capitale iniziale ${cap0:,.0f} #####")
|
||||
print(f" {'strategia':20}{'leva':>5}{'CAGR':>8}{'MaxDD':>8}{'Sharpe':>8}{'pegg.anno':>10}{'$/giorno':>10}{'eq fine':>12}{' RUINA?':>9}")
|
||||
for nm, r in strat.items():
|
||||
for L in (1, 2, 3):
|
||||
a = analyze(r, L, cap0)
|
||||
flag = "MARGIN-CALL" if a["ruin"] else "ok"
|
||||
print(f" {nm:20}{L:>4}x{a['cagr']*100:>7.1f}%{a['maxdd']*100:>7.1f}%{a['sharpe']:>8.2f}"
|
||||
f"{a['worst_y']*100:>9.1f}%{('$'+format(a['perday'],',.0f')):>10}{('$'+format(a['final'],',.0f')):>12}{flag:>11}")
|
||||
# per-anno del COMBO a 2x e 3x da 2k (dettaglio)
|
||||
print(f"\n ##### COMBO per-anno a leva, da $2.000 #####")
|
||||
cd = combo_daily()
|
||||
for L in (2, 3):
|
||||
print(f"\n --- COMBO {L}x ---")
|
||||
print(f" {'anno':6}{'PnL %':>9}{'MaxDD %':>9}{'eq fine':>11}")
|
||||
eq = 2000.0; r = lever(cd, L)
|
||||
for y in sorted(set(r.index.year)):
|
||||
ry = r[r.index.year == y]
|
||||
if len(ry) < 5: continue
|
||||
eq0 = eq; curve = eq0 * np.cumprod(1 + ry.values); peak = np.maximum.accumulate(curve)
|
||||
ddp = float(np.max((peak - curve) / peak)); eq = float(curve[-1])
|
||||
print(f" {y:<6}{(eq/eq0-1)*100:>+8.1f}%{ddp*100:>8.1f}%{('$'+format(eq,',.0f')):>11}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Resoconto anno-per-anno CON PROTEZIONE (soft-guard DD -4%) — combo e singoli.
|
||||
|
||||
Mette combo (TP01+GTAA 50/50), TP01 e GTAA sulla STESSA griglia giorni-di-borsa (come dentro al
|
||||
combo), applica la guardia-DD -4% a ciascuna serie (de-risk 1.0->0.4 a -4% dal picco, ri-rischia a
|
||||
-1.6%), e per ogni anno riporta: NL (net liquidation da $2000), DD intra-anno, rendimento (=CAGR
|
||||
1y), Sharpe. Riga TOT con CAGR e Sharpe complessivi.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import numpy as np, pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from src.portfolio.sleeves import _tp01_returns
|
||||
from src.portfolio.gtaa import gtaa_returns
|
||||
|
||||
INITIAL = 2000.0
|
||||
ANN = np.sqrt(252.0)
|
||||
DD_TRIG = 0.04
|
||||
|
||||
|
||||
def dd_guard(r, dd_trigger=DD_TRIG):
|
||||
"""De-risk: esposizione 1.0->0.4 se DD da picco > dd_trigger; ri-rischia a dd_trigger*0.4."""
|
||||
r = r.values; n = len(r); eq = np.cumprod(1 + r); pk = np.maximum.accumulate(eq)
|
||||
expo = np.ones(n); on = True
|
||||
for i in range(1, n):
|
||||
ddi = (pk[i - 1] - eq[i - 1]) / pk[i - 1]
|
||||
if ddi > dd_trigger: on = False
|
||||
if ddi < dd_trigger * 0.4: on = True
|
||||
expo[i] = 1.0 if on else 0.4
|
||||
return pd.Series(expo * r, index=_idx) # set below
|
||||
|
||||
|
||||
def legs_on_grid(wc=0.5):
|
||||
"""TP01(crypto, compoundato sul grid) e GTAA(equity) sulla stessa griglia giorni-di-borsa."""
|
||||
tp = _tp01_returns()
|
||||
if tp.index.tz is None:
|
||||
tp.index = tp.index.tz_localize("UTC")
|
||||
eq = gtaa_returns().dropna()
|
||||
grid = eq.index[eq.index >= tp.index[0]]
|
||||
cum = (1 + tp).cumprod()
|
||||
tpg = cum.reindex(cum.index.union(grid)).ffill().reindex(grid).pct_change()
|
||||
J = pd.concat({"c": tpg, "e": eq.reindex(grid)}, axis=1).dropna()
|
||||
combo = wc * J["c"] + (1 - wc) * J["e"]
|
||||
return combo, J["c"], J["e"]
|
||||
|
||||
|
||||
def sh(r): r = r.dropna().values; return float(np.mean(r) / np.std(r) * ANN) if len(r) > 5 and np.std(r) > 0 else 0.0
|
||||
def maxdd(curve): pk = np.maximum.accumulate(curve); return float(np.max((pk - curve) / pk)) if len(curve) else 0.0
|
||||
|
||||
|
||||
def yearly(ret, label):
|
||||
ret = ret.dropna().sort_index()
|
||||
print(f"\n ===== {label} (guardia-DD -4%) =====")
|
||||
print(f" {'anno':6}{'NL inizio':>11}{'NL fine':>11}{'rend%':>9}{'DD%':>8}{'Sharpe':>9}")
|
||||
eq = INITIAL
|
||||
for y in sorted(set(ret.index.year)):
|
||||
r = ret[ret.index.year == y]
|
||||
if len(r) < 5: continue
|
||||
eq0 = eq
|
||||
curve = eq0 * np.cumprod(1 + r.values)
|
||||
eq = float(curve[-1])
|
||||
print(f" {y:<6}{eq0:>11,.0f}{eq:>11,.0f}{(eq/eq0-1)*100:>+8.1f}%{maxdd(curve)*100:>7.1f}%{sh(r):>9.2f}")
|
||||
yrs = (ret.index[-1] - ret.index[0]).days / 365.25
|
||||
cagr = (eq / INITIAL) ** (1 / yrs) - 1 if yrs > 0 else 0
|
||||
full_curve = INITIAL * np.cumprod(1 + ret.values)
|
||||
print(f" {'TOT':<6}{INITIAL:>11,.0f}{eq:>11,.0f}{(eq/INITIAL-1)*100:>+8.1f}%{maxdd(full_curve)*100:>7.1f}%{sh(ret):>9.2f}"
|
||||
f" | CAGR {cagr*100:+.1f}% ({yrs:.1f}y)")
|
||||
|
||||
|
||||
def main():
|
||||
global _idx
|
||||
print("=" * 78)
|
||||
print(" RESOCONTO PROTETTO (soft-guard DD -4%) — da $2.000, anno per anno")
|
||||
print(" Tutte e tre sulla griglia giorni-di-borsa del combo (dal 2019), esposizione 1x.")
|
||||
print("=" * 78)
|
||||
combo, tp, g = legs_on_grid()
|
||||
for ret, lbl in [(combo, "COMBO TP01+GTAA 50/50"), (tp, "solo TP01 (crypto)"), (g, "solo GTAA (equity)")]:
|
||||
_idx = ret.index
|
||||
yearly(dd_guard(ret), lbl)
|
||||
# confronto NON protetto (baseline) in coda, una riga TOT per riferimento
|
||||
print("\n --- riferimento NON protetto (baseline, TOT) ---")
|
||||
for ret, lbl in [(combo, "COMBO"), (tp, "TP01"), (g, "GTAA")]:
|
||||
yrs = (ret.index[-1] - ret.index[0]).days / 365.25
|
||||
eqf = INITIAL * np.prod(1 + ret.values)
|
||||
print(f" {lbl:6} CAGR {((eqf/INITIAL)**(1/yrs)-1)*100:>+5.1f}% DD {maxdd(INITIAL*np.cumprod(1+ret.values))*100:>4.1f}% Sharpe {sh(ret):.2f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Resoconto anno-per-anno della strategia combo (TP01+GTAA) + componenti, da $2.000.
|
||||
|
||||
Per anno: PnL ($ e %), MaxDD (intra-anno), NumTrades, equity di fine anno (compounding da 2k).
|
||||
Combo = blend 50/50 TP01(Deribit) + GTAA(IB) (crypto compoundato su grid giorni-di-borsa).
|
||||
NumTrades: TP01 = cambi di target BTC/ETH (>0.05); GTAA = ribilanci MENSILI per-gamba (>2%).
|
||||
Onesto: il combo parte dal 2019 (crypto). GTAA-solo dato anche su 10y come contesto.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import numpy as np, pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "scripts" / "live"))
|
||||
from src.data.downloader import load_data
|
||||
from src.strategies.trend_portfolio import TrendPortfolio, CANONICAL, resample_1d
|
||||
from src.portfolio.sleeves import _tp01_returns
|
||||
from src.portfolio.gtaa import gtaa_returns, _exposure, _close, EQ_UNIVERSE
|
||||
INITIAL = 2000.0
|
||||
|
||||
|
||||
def tp01_trades_per_year():
|
||||
tp = TrendPortfolio(**CANONICAL); cnt = {}
|
||||
for a in ("BTC", "ETH"):
|
||||
df = resample_1d(load_data(a, "1h")); tgt = tp.target_series(df)
|
||||
idx = pd.DatetimeIndex(pd.to_datetime(df["datetime"]))
|
||||
chg = pd.Series(np.abs(np.diff(tgt, prepend=tgt[0])) > 0.05, index=idx)
|
||||
for y, c in chg.groupby(idx.year).sum().items():
|
||||
cnt[int(y)] = cnt.get(int(y), 0) + int(c)
|
||||
return cnt
|
||||
|
||||
|
||||
def gtaa_trades_per_year():
|
||||
# pesi giornalieri -> ribilancio MENSILE realistico -> conta gambe cambiate >2%
|
||||
W = {}
|
||||
for a in EQ_UNIVERSE:
|
||||
ex = _exposure(_close(a)) / len(EQ_UNIVERSE)
|
||||
W[a] = ex
|
||||
Wd = pd.concat(W, axis=1).dropna()
|
||||
Wm = Wd.resample("ME").last() # peso a fine mese
|
||||
chg = (Wm.diff().abs() > 0.02).sum(axis=1) # gambe ribilanciate quel mese
|
||||
return chg.groupby(chg.index.year).sum().astype(int).to_dict()
|
||||
|
||||
|
||||
def yearly(ret: pd.Series, trades: dict, label: str, start_capital=INITIAL):
|
||||
ret = ret.dropna().sort_index()
|
||||
print(f"\n ===== {label} =====")
|
||||
print(f" {'anno':6}{'eq inizio':>12}{'PnL $':>12}{'PnL %':>9}{'MaxDD %':>9}{'NumTrades':>11}{'eq fine':>12}")
|
||||
eq = start_capital
|
||||
for y in sorted(set(ret.index.year)):
|
||||
r = ret[ret.index.year == y]
|
||||
if len(r) < 5:
|
||||
continue
|
||||
eq0 = eq
|
||||
curve = eq0 * np.cumprod(1 + r.values)
|
||||
peak = np.maximum.accumulate(curve)
|
||||
dd = float(np.max((peak - curve) / peak)) if len(curve) else 0.0
|
||||
eq = float(curve[-1])
|
||||
pnl = eq - eq0
|
||||
nt = trades.get(y, None)
|
||||
print(f" {y:<6}{eq0:>12,.0f}{pnl:>+12,.0f}{(eq/eq0-1)*100:>+8.1f}%{dd*100:>8.1f}%"
|
||||
f"{(str(nt) if nt is not None else '—'):>11}{eq:>12,.0f}")
|
||||
tot = eq / start_capital - 1
|
||||
yrs = (ret.index[-1] - ret.index[0]).days / 365.25
|
||||
cagr = (eq / start_capital) ** (1 / yrs) - 1 if yrs > 0 else 0
|
||||
sh = float(r.mean()) if False else float(ret.mean() / ret.std() * np.sqrt(252))
|
||||
print(f" {'TOT':<6}{start_capital:>12,.0f}{eq-start_capital:>+12,.0f}{tot*100:>+8.1f}%"
|
||||
f"{'':>9}{sum(v for v in trades.values()) if trades else 0:>11}{eq:>12,.0f}")
|
||||
print(f" -> da ${start_capital:,.0f} a ${eq:,.0f} in {yrs:.1f}y | CAGR {cagr*100:+.1f}% | Sharpe {sh:.2f}")
|
||||
|
||||
|
||||
def combo_daily(wc=0.5):
|
||||
tp = _tp01_returns()
|
||||
if tp.index.tz is None:
|
||||
tp.index = tp.index.tz_localize("UTC")
|
||||
eq = gtaa_returns().dropna()
|
||||
grid = eq.index[eq.index >= tp.index[0]]
|
||||
cum = (1 + tp).cumprod()
|
||||
tpg = (cum.reindex(cum.index.union(grid)).ffill().reindex(grid)).pct_change()
|
||||
J = pd.concat({"c": tpg, "e": eq.reindex(grid)}, axis=1).dropna()
|
||||
return (wc * J["c"] + (1 - wc) * J["e"]).dropna()
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 80)
|
||||
print(" RESOCONTO STRATEGIA — da $2.000, anno per anno")
|
||||
print("=" * 80)
|
||||
tpt = tp01_trades_per_year(); gtt = gtaa_trades_per_year()
|
||||
combo_tr = {y: tpt.get(y, 0) + gtt.get(y, 0) for y in set(tpt) | set(gtt)}
|
||||
# COMBO (la strategia deployata)
|
||||
yearly(combo_daily(), combo_tr, "COMBO TP01+GTAA 50/50 (deployabile, dal 2019)")
|
||||
# componenti
|
||||
tp = _tp01_returns(); tp.index = tp.index.tz_localize("UTC") if tp.index.tz is None else tp.index
|
||||
yearly(tp, tpt, "solo TP01 (crypto, Deribit)")
|
||||
g = gtaa_returns(); g10 = g[g.index >= (g.index[-1] - pd.Timedelta(days=3660))]
|
||||
yearly(g10, gtt, "solo GTAA (equity, IB) — ULTIMI 10 ANNI")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,126 @@
|
||||
"""HARNESS parametrizzato — anticipazione crypto -> mercato (lead-lag eseguibile, onesto).
|
||||
|
||||
Generalizza l'effetto weekend: la finestra-LEAD e' l'intervallo in cui l'equity e' CHIUSO e la crypto
|
||||
no (prev close 21:00 UTC -> next open 13:30 UTC). Il weekend e' il caso lungo (Ven 21:00 -> Lun 13:30).
|
||||
Per ogni sessione equity D (con sessione precedente P):
|
||||
lead = crypto return su [lead_start, D 13:00] (lead_start = P 21:00 se hours='overnight', else D13:00-hours)
|
||||
predict target: gap = open[D]/close[P]-1 ; intraday = close[D]/open[D]-1 ; full = close[D]/close[P]-1
|
||||
control = rendimento sessione precedente equity (close[P]/close[P_prev]-1) -> test INCREMENTALE
|
||||
Filtro giorni: all | mon (solo lunedi'/weekend) | nonmon.
|
||||
|
||||
Output JSON per config: n, corr, beta+t-stat del lead AL NETTO del control (incrementale), Sharpe
|
||||
settimanale/annualizzato del trade eseguibile (sign(lead)*predict - costo) FULL/IS/OOS(2022+),
|
||||
hit-rate, e PER-ANNO (hit e mean) per la robustezza multi-anno.
|
||||
|
||||
uv run python scripts/research/crypto_lead_harness.py --configs '[{"lead":"BTC","target":"QQQ","day":"mon","predict":"intraday","hours":"overnight"}]'
|
||||
Dati: cache su disco (crypto 1h, ETF eq_*). Nessun IB online. Vettoriale, veloce.
|
||||
"""
|
||||
import sys, json, argparse
|
||||
from pathlib import Path
|
||||
import numpy as np, pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
from src.data.downloader import load_data
|
||||
import eqlib
|
||||
|
||||
OOS_DEFAULT = "2022-01-01"
|
||||
OPEN_H = 13 # ~apertura US 13:30 UTC -> uso barra 13:00 (info nota prima dell'open per il lead)
|
||||
CLOSE_H = 21 # ~chiusura US 21:00 UTC
|
||||
|
||||
_CRYPTO = {}
|
||||
def crypto_hourly(asset):
|
||||
if asset not in _CRYPTO:
|
||||
s = load_data(asset, "1h").set_index("datetime")["close"].astype(float)
|
||||
full = pd.date_range(s.index[0].floor("h"), s.index[-1].ceil("h"), freq="h", tz="UTC")
|
||||
_CRYPTO[asset] = s.reindex(s.index.union(full)).ffill().reindex(full)
|
||||
return _CRYPTO[asset]
|
||||
|
||||
|
||||
def at(series, ts):
|
||||
try:
|
||||
return float(series.asof(ts))
|
||||
except Exception:
|
||||
return np.nan
|
||||
|
||||
|
||||
def evaluate(cfg, cost_rt=0.0004, oos=OOS_DEFAULT):
|
||||
OOS = pd.Timestamp(oos, tz="UTC")
|
||||
lead = cfg["lead"]; tgt = cfg["target"]; day = cfg.get("day", "all")
|
||||
predict = cfg.get("predict", "intraday"); hours = cfg.get("hours", "overnight")
|
||||
bc = crypto_hourly(lead)
|
||||
try:
|
||||
oc = eqlib.load_eq(tgt)["open"].astype(float); cc = eqlib.load_eq(tgt)["close"].astype(float)
|
||||
except Exception as e:
|
||||
return {**cfg, "err": f"no data {tgt}"}
|
||||
idx = cc.index
|
||||
rows = []
|
||||
for j in range(2, len(idx)):
|
||||
D = idx[j]; P = idx[j-1]; Pp = idx[j-2]
|
||||
if day == "mon" and D.weekday() != 0: continue
|
||||
if day == "nonmon" and D.weekday() == 0: continue
|
||||
d_open = D.normalize() + pd.Timedelta(hours=OPEN_H)
|
||||
p_close = P.normalize() + pd.Timedelta(hours=CLOSE_H)
|
||||
lead_start = p_close if hours == "overnight" else d_open - pd.Timedelta(hours=int(hours))
|
||||
c1 = at(bc, d_open); c0 = at(bc, lead_start)
|
||||
if not (np.isfinite(c1) and np.isfinite(c0) and c0 > 0): continue
|
||||
ld = c1 / c0 - 1.0
|
||||
gap = oc[D] / cc[P] - 1.0
|
||||
intr = cc[D] / oc[D] - 1.0
|
||||
full = cc[D] / cc[P] - 1.0
|
||||
ctrl = cc[P] / cc[Pp] - 1.0
|
||||
rows.append((D, ld, gap, intr, full, ctrl))
|
||||
if len(rows) < 60:
|
||||
return {**cfg, "err": f"n={len(rows)}"}
|
||||
D_ = pd.DataFrame(rows, columns=["d", "lead", "gap", "intraday", "full", "ctrl"]).set_index("d")
|
||||
y = D_[predict].values; x = D_["lead"].values; ctrl = D_["ctrl"].values
|
||||
|
||||
def z(a):
|
||||
sd = a.std(); return (a - a.mean()) / sd if sd > 0 else a * 0
|
||||
corr = float(np.corrcoef(x, y)[0, 1])
|
||||
# incrementale vs control (OLS standardizzato)
|
||||
X = np.column_stack([np.ones(len(y)), z(x), z(ctrl)])
|
||||
beta, *_ = np.linalg.lstsq(X, z(y), rcond=None)
|
||||
resid = z(y) - X @ beta
|
||||
dof = max(len(y) - 3, 1)
|
||||
se = np.sqrt(np.sum(resid**2) / dof * np.diag(np.linalg.inv(X.T @ X)))
|
||||
t_inc = float(beta[1] / se[1]) if se[1] > 0 else 0.0
|
||||
# trade eseguibile: long-short e long-flat su segno del lead, intraday/predict, net costi
|
||||
sign = np.sign(x)
|
||||
def sharpe(r):
|
||||
r = r[np.isfinite(r)]
|
||||
return float(np.mean(r) / np.std(r) * np.sqrt(52)) if len(r) > 5 and np.std(r) > 0 else 0.0
|
||||
ls = sign * y - cost_rt
|
||||
lf = np.where(x > 0, y, 0.0) - np.where(x > 0, cost_rt, 0.0)
|
||||
yrs = D_.index.year.values
|
||||
def per_year(r):
|
||||
out = {}
|
||||
for yv in sorted(set(yrs)):
|
||||
m = yrs == yv
|
||||
if m.sum() >= 8:
|
||||
out[int(yv)] = round(float(np.mean(np.sign(x[m]) == np.sign(y[m]))), 2)
|
||||
return out
|
||||
is_m = D_.index < OOS; oos_m = D_.index >= OOS
|
||||
py = per_year(ls)
|
||||
return {**cfg, "n": len(D_), "corr": round(corr, 3), "t_incremental": round(t_inc, 2),
|
||||
"hit": round(float(np.mean(sign == np.sign(y))), 3),
|
||||
"sh_ls_full": round(sharpe(ls), 2), "sh_ls_is": round(sharpe(ls[is_m]), 2),
|
||||
"sh_ls_oos": round(sharpe(ls[oos_m]), 2),
|
||||
"sh_lf_full": round(sharpe(lf), 2), "sh_lf_oos": round(sharpe(lf[oos_m]), 2),
|
||||
"ann_ls_pct": round(float(np.nanmean(ls) * 52 * 100), 1),
|
||||
"years_pos": int(sum(1 for v in py.values() if v > 0.5)), "years_tot": len(py),
|
||||
"per_year_hit": py}
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--configs", required=True)
|
||||
ap.add_argument("--cost", type=float, default=0.0004)
|
||||
ap.add_argument("--oos", default=OOS_DEFAULT)
|
||||
args = ap.parse_args()
|
||||
cfgs = json.loads(args.configs)
|
||||
print(json.dumps([evaluate(c, cost_rt=args.cost, oos=args.oos) for c in cfgs]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,120 @@
|
||||
"""CRYPTO x MERCATI IB — correlazioni e ANTICIPAZIONI (lead-lag).
|
||||
|
||||
Obiettivo: la crypto (24/7) anticipa i mercati IB (azioni/bond/oro/credito), o viceversa?
|
||||
Disciplina onesta: i tranelli di timing daily sono enormi (crypto chiude 00:00 UTC, US equity 21:00
|
||||
UTC -> il lag-0 e' contaminato), quindi (1) allineo i rendimenti sullo STESSO intervallo
|
||||
(compounding crypto sul grid giorni-di-borsa), (2) guardo i lag >=1 giorno, (3) test del segno con
|
||||
hit-rate e split in-sample/OOS, (4) flag multiple-testing.
|
||||
|
||||
Ipotesi piu' pulita = EFFETTO WEEKEND: la crypto si muove Sab+Dom (azionario chiuso) -> quel
|
||||
movimento e' informazione PRIOR al lunedi'. Predice il gap/intraday del lunedi' azionario?
|
||||
Uso gli OPEN dei parquet eq_ (Monday open noto alle 13:30 UTC, weekend crypto noto alle 00:00 UTC).
|
||||
|
||||
DATI: cache su disco (BTC/ETH Deribit 1h->1d UTC; ETF IB eq_*). Nessun IB online.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import numpy as np, pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
from src.data.downloader import load_data
|
||||
import eqlib
|
||||
|
||||
ETFS = ["SPY", "QQQ", "IWM", "TLT", "GLD", "HYG"]
|
||||
|
||||
|
||||
def crypto_daily_close(asset="BTC") -> pd.Series:
|
||||
df = load_data(asset, "1h").set_index("datetime")["close"].astype(float)
|
||||
return df.resample("1D").last().dropna() # close ~00:00 UTC del giorno dopo
|
||||
|
||||
|
||||
def _ret(s):
|
||||
return s.pct_change()
|
||||
|
||||
|
||||
def _corr_lags(x: pd.Series, y: pd.Series, lags=range(-5, 6)):
|
||||
"""corr(x_{t-k}, y_t): k>0 => x ANTICIPA y di k giorni. Allineati sullo stesso grid."""
|
||||
J = pd.concat({"x": x, "y": y}, axis=1, join="inner").dropna()
|
||||
out = {}
|
||||
for k in lags:
|
||||
out[k] = round(float(J["x"].shift(k).corr(J["y"])), 3)
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 98)
|
||||
print(" CRYPTO x MERCATI IB — correlazioni & anticipazioni (lead-lag)")
|
||||
print("=" * 98)
|
||||
btc = crypto_daily_close("BTC"); eth = crypto_daily_close("ETH")
|
||||
btc_r = _ret(btc); eth_r = _ret(eth)
|
||||
# equity close + grid giorni-di-borsa
|
||||
eq_close = {s: eqlib.load_eq(s)["close"].astype(float) for s in ETFS}
|
||||
eq_open = {s: eqlib.load_eq(s)["open"].astype(float) for s in ETFS}
|
||||
grid = eq_close["SPY"].index
|
||||
grid = grid[grid >= btc.index[0]]
|
||||
# crypto compoundato sul grid giorni-di-borsa (stesso intervallo dell'equity ret)
|
||||
def to_grid(s):
|
||||
cum = (1 + _ret(s)).cumprod()
|
||||
return (cum.reindex(cum.index.union(grid)).ffill().reindex(grid)).pct_change()
|
||||
btc_g = to_grid(btc); eth_g = to_grid(eth)
|
||||
|
||||
print(f" overlap dal {grid[0].date()} ({len(grid)} giorni di borsa)\n")
|
||||
|
||||
print(" --- (1) CORRELAZIONE CONTEMPORANEA (stesso intervallo; lag0 contaminato da timing) ---")
|
||||
print(f" {'ETF':5} {'corr BTC':>9} {'corr ETH':>9}")
|
||||
for s in ETFS:
|
||||
er = _ret(eq_close[s]).reindex(grid)
|
||||
cb = round(float(pd.concat([btc_g, er], axis=1).dropna().corr().iloc[0, 1]), 3)
|
||||
ce = round(float(pd.concat([eth_g, er], axis=1).dropna().corr().iloc[0, 1]), 3)
|
||||
print(f" {s:5} {cb:>9} {ce:>9}")
|
||||
|
||||
print("\n --- (2) LEAD-LAG BTC vs ETF: corr(BTC_{t-k}, ETF_t), k>0 = BTC ANTICIPA ---")
|
||||
print(f" {'ETF':5} " + " ".join(f"k{ k:+d}" for k in range(-3, 4)) + " picco")
|
||||
for s in ETFS:
|
||||
er = _ret(eq_close[s]).reindex(grid)
|
||||
cl = _corr_lags(btc_g, er, range(-3, 4))
|
||||
peak = max(cl, key=lambda k: abs(cl[k]))
|
||||
row = " ".join(f"{cl[k]:+.2f}" for k in range(-3, 4))
|
||||
tag = f"k={peak:+d} ({'BTC->ETF' if peak>0 else 'ETF->BTC' if peak<0 else 'contemp'})"
|
||||
print(f" {s:5} {row} {tag}")
|
||||
|
||||
print("\n --- (3) EFFETTO WEEKEND: crypto Sab+Dom -> lunedi' azionario (anticipazione pulita) ---")
|
||||
# weekend crypto = close(Dom 00:00 lun) / close(Ven) - 1 ; calcolato su crypto daily (calendario)
|
||||
cal = pd.date_range(btc.index[0], btc.index[-1], freq="D", tz="UTC")
|
||||
bc = btc.reindex(cal).ffill()
|
||||
for s in ["SPY", "QQQ", "IWM", "HYG"]:
|
||||
oc = eq_open[s]; cc = eq_close[s]
|
||||
rows = []
|
||||
for mon in grid:
|
||||
if mon.weekday() != 0: # solo lunedi'
|
||||
continue
|
||||
fri = mon - pd.Timedelta(days=3)
|
||||
if fri not in cc.index: # venerdi' non di borsa (festa) -> salta
|
||||
continue
|
||||
wk = float(bc.get(mon, np.nan) / bc.get(fri + pd.Timedelta(days=0), np.nan) - 1) if fri in bc.index else np.nan
|
||||
# weekend crypto: da venerdi 00:00(close ven) a lunedi 00:00 -> usa bc[fri]..bc[mon]
|
||||
wk = float(bc.loc[mon] / bc.loc[fri] - 1) if (mon in bc.index and fri in bc.index) else np.nan
|
||||
gap = float(oc.loc[mon] / cc.loc[fri] - 1) if (mon in oc.index and fri in cc.index) else np.nan
|
||||
intr = float(cc.loc[mon] / oc.loc[mon] - 1) if mon in oc.index else np.nan
|
||||
rows.append((mon, wk, gap, intr))
|
||||
D = pd.DataFrame(rows, columns=["mon", "wk", "gap", "intr"]).dropna().set_index("mon")
|
||||
if len(D) < 50:
|
||||
print(f" {s}: pochi dati ({len(D)})"); continue
|
||||
def stat(col):
|
||||
c = float(D["wk"].corr(D[col]))
|
||||
hit = float((np.sign(D["wk"]) == np.sign(D[col])).mean())
|
||||
return c, hit
|
||||
cg, hg = stat("gap"); ci, hi = stat("intr")
|
||||
# OOS: split 2022
|
||||
Dh = D[D.index >= pd.Timestamp("2022-01-01", tz="UTC")]
|
||||
cg_o = float(Dh["wk"].corr(Dh["gap"])); ci_o = float(Dh["wk"].corr(Dh["intr"]))
|
||||
print(f" {s}: n={len(D)} | weekend-crypto -> Mon GAP corr {cg:+.2f} hit {hg*100:.0f}% (OOS22+ {cg_o:+.2f}) "
|
||||
f"| Mon INTRADAY corr {ci:+.2f} hit {hi*100:.0f}% (OOS {ci_o:+.2f})")
|
||||
|
||||
print("\n NB: lag-0/contemporanea contaminata dal timing (crypto chiude 00:00, equity 21:00 UTC).")
|
||||
print(" Il GAP del lunedi' e' il test pulito (weekend crypto = info prior all'apertura).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,88 @@
|
||||
"""DERIBIT-monitor -> IB-trade: il segnale crypto overnight AGGIUNGE al long-overnight indice?
|
||||
|
||||
Idea utente: guardare crypto live su Deribit (24/7) e tradare l'indice su IB. Il GAP di apertura =
|
||||
movimento OVERNIGHT dei futures (MES/MNQ/M2K, tradati di notte) -> catturabile, net ~2bps.
|
||||
|
||||
DOMANDA DECISIVA (test onesto): l'azionario ha gia' un OVERNIGHT PREMIUM noto (il drift positivo
|
||||
notturno). Quindi "long indice overnight" rende di suo. Il segnale crypto MIGLIORA quel baseline,
|
||||
o e' solo overnight-premium + beta? Confronto:
|
||||
A) ALWAYS-LONG overnight (incondizionato) = cattura il premio notturno puro
|
||||
B) LONG se crypto-overnight>0, else FLAT = usa il crypto come filtro
|
||||
C) LONG/SHORT sul segno del crypto = il segnale pieno
|
||||
La metrica chiave e' B,C VS A (l'uplift del crypto sul puro premio notturno), non A in assoluto.
|
||||
|
||||
Dati: cache su disco (crypto 1h Deribit; ETF eq_* = proxy del future indice). net 2bps RT (futures).
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import numpy as np, pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
import eqlib
|
||||
from crypto_lead_harness import crypto_hourly, at, OPEN_H, CLOSE_H
|
||||
|
||||
OOS = pd.Timestamp("2022-01-01", tz="UTC")
|
||||
COST = 0.0002 # ~2bps RT micro-future (commissione + spread)
|
||||
|
||||
|
||||
def _sh(r):
|
||||
r = np.asarray(r, float); r = r[np.isfinite(r)]
|
||||
return float(np.mean(r) / np.std(r) * np.sqrt(252)) if len(r) > 5 and np.std(r) > 0 else 0.0
|
||||
|
||||
|
||||
def build(target="SPY", lead="BTC"):
|
||||
bc = crypto_hourly(lead)
|
||||
oc = eqlib.load_eq(target)["open"].astype(float); cc = eqlib.load_eq(target)["close"].astype(float)
|
||||
idx = cc.index; rows = []
|
||||
for j in range(1, len(idx)):
|
||||
D = idx[j]; P = idx[j-1]
|
||||
d_open = D.normalize() + pd.Timedelta(hours=OPEN_H)
|
||||
p_close = P.normalize() + pd.Timedelta(hours=CLOSE_H)
|
||||
c1 = at(bc, d_open); c0 = at(bc, p_close)
|
||||
if not (np.isfinite(c1) and np.isfinite(c0) and c0 > 0):
|
||||
continue
|
||||
crypto = c1 / c0 - 1.0
|
||||
overnight = oc[D] / cc[P] - 1.0 # gap = ritorno overnight del future indice (catturabile)
|
||||
rows.append((D, crypto, overnight))
|
||||
return pd.DataFrame(rows, columns=["d", "crypto", "on"]).set_index("d")
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 92)
|
||||
print(" CRYPTO overnight -> LONG INDICE OVERNIGHT (Deribit-monitor / IB-trade): aggiunge al premio?")
|
||||
print("=" * 92)
|
||||
print(f" net {COST*1e4:.0f}bps RT (micro-future). overnight = open[D]/close[P]-1 (= move notturno future).\n")
|
||||
for tgt in ("SPY", "QQQ", "IWM"):
|
||||
for lead in ("BTC", "ETH"):
|
||||
D = build(tgt, lead)
|
||||
up = D[D["crypto"] > 0]["on"]; dn = D[D["crypto"] <= 0]["on"]
|
||||
# strategie
|
||||
A = D["on"].values - COST # always-long
|
||||
B = np.where(D["crypto"] > 0, D["on"], 0.0) - np.where(D["crypto"] > 0, COST, 0.0) # long se crypto su
|
||||
C = np.sign(D["crypto"]) * D["on"] - COST # long/short
|
||||
shA, shB, shC = _sh(A), _sh(B), _sh(C)
|
||||
# OOS
|
||||
m = D.index >= OOS
|
||||
print(f" {tgt} <- {lead}: n={len(D)} | notti crypto-SU {len(up)} ret medio {up.mean()*1e4:+.1f}bps "
|
||||
f"| crypto-GIU {len(dn)} ret medio {dn.mean()*1e4:+.1f}bps (spread {(up.mean()-dn.mean())*1e4:+.1f}bps)")
|
||||
print(f" Sharpe: A always-long {shA:.2f} | B long-if-cryptoUp {shB:.2f} | C long/short {shC:.2f} "
|
||||
f"-> UPLIFT crypto B-A {shB-shA:+.2f}, C-A {shC-shA:+.2f}")
|
||||
print(f" OOS22+: A {_sh(A[m]):.2f} | B {_sh(B[m]):.2f} | C {_sh(C[m]):.2f} | "
|
||||
f"ann: A {np.nanmean(A)*252*100:+.1f}% B {np.nanmean(B)*252*100:+.1f}% C {np.nanmean(C)*252*100:+.1f}%")
|
||||
print()
|
||||
|
||||
# focus SPY-BTC: per-anno A vs C, e sketch deploy
|
||||
D = build("SPY", "BTC")
|
||||
A = D["on"].values - COST; C = np.sign(D["crypto"]) * D["on"] - COST
|
||||
print(" --- SPY<-BTC per-anno: Sharpe A(always-long) vs C(crypto long/short) ---")
|
||||
for y in sorted(set(D.index.year)):
|
||||
mm = D.index.year == y
|
||||
if mm.sum() >= 40:
|
||||
print(f" {y}: A {_sh(A[mm]):+.2f} C {_sh(C[mm]):+.2f} (n={mm.sum()})")
|
||||
print("\n NB: se C-A ~ 0, il crypto NON aggiunge al premio overnight (e' solo il premio + beta).")
|
||||
print(" Se C-A >> 0 e A gia' alto, il crypto e' un filtro REALE sul rischio notturno.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,71 @@
|
||||
"""ONESTA' DI TIMING: il 'Sharpe 5' del crypto->overnight equity e' look-ahead?
|
||||
|
||||
Test: separare la versione OVERLAP (segnale e ritorno coprono le stesse ore = look-ahead) dalla
|
||||
versione TRADABILE (segnale finisce all'ENTRATA, catturo solo il moto SUCCESSIVO).
|
||||
segnale crypto = BTC [P 21:00 -> D 13:00 UTC] (noto solo a D 13:00, poco prima dell'open 13:30)
|
||||
- OVERLAP capture = gap = open[D]/close[P]-1 [P 21:00 -> D 13:30] <-- sovrappone il segnale
|
||||
- TRADABILE capture = intra = close[D]/open[D]-1 [D 13:30 -> D 21:00] <-- DOPO l'entrata, pulito
|
||||
Se OVERLAP >> TRADABILE, il numero alto e' artefatto: all'open il gap e' gia' avvenuto, non lo catturi.
|
||||
Annualizzazione CORRETTA sqrt(252) (giornaliero), non sqrt(52).
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import numpy as np, pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
import eqlib
|
||||
from crypto_lead_harness import crypto_hourly, at, OPEN_H, CLOSE_H
|
||||
|
||||
OOS = pd.Timestamp("2022-01-01", tz="UTC"); COST = 0.0002
|
||||
|
||||
|
||||
def _sh(r):
|
||||
r = np.asarray(r, float); r = r[np.isfinite(r)]
|
||||
return float(np.mean(r) / np.std(r) * np.sqrt(252)) if len(r) > 5 and np.std(r) > 0 else 0.0
|
||||
|
||||
|
||||
def build(target="SPY", lead="BTC"):
|
||||
bc = crypto_hourly(lead)
|
||||
oc = eqlib.load_eq(target)["open"].astype(float); cc = eqlib.load_eq(target)["close"].astype(float)
|
||||
idx = cc.index; rows = []
|
||||
for j in range(1, len(idx)):
|
||||
D = idx[j]; P = idx[j-1]
|
||||
c1 = at(bc, D.normalize() + pd.Timedelta(hours=OPEN_H)) # crypto a D 13:00 (entrata)
|
||||
c0 = at(bc, P.normalize() + pd.Timedelta(hours=CLOSE_H)) # crypto a P 21:00
|
||||
if not (np.isfinite(c1) and np.isfinite(c0) and c0 > 0):
|
||||
continue
|
||||
crypto = c1 / c0 - 1.0
|
||||
gap = oc[D] / cc[P] - 1.0 # OVERLAP col segnale
|
||||
intra = cc[D] / oc[D] - 1.0 # DOPO l'entrata (tradabile)
|
||||
rows.append((D, crypto, gap, intra))
|
||||
return pd.DataFrame(rows, columns=["d", "crypto", "gap", "intra"]).set_index("d")
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 90)
|
||||
print(" TIMING ONESTO: OVERLAP (look-ahead) vs TRADABILE — crypto overnight -> equity")
|
||||
print("=" * 90)
|
||||
print(" segnale noto a D13:00. OVERLAP=gap [copre il segnale]. TRADABILE=intraday [dopo entrata]. net 2bps, sqrt(252)\n")
|
||||
for tgt in ("SPY", "QQQ", "IWM"):
|
||||
D = build(tgt, "BTC")
|
||||
for name, col in (("OVERLAP gap (look-ahead)", "gap"), ("TRADABILE intraday", "intra")):
|
||||
C = np.sign(D["crypto"]) * D[col] - COST
|
||||
m = D.index >= OOS
|
||||
print(f" {tgt} {name:26}: Sharpe long/short {_sh(C):.2f} (OOS {_sh(C[m]):.2f}) ann {np.nanmean(C)*252*100:+.1f}%")
|
||||
print()
|
||||
print(" --- VERDETTO ---")
|
||||
D = build("QQQ", "BTC")
|
||||
cg = _sh(np.sign(D["crypto"]) * D["gap"] - COST)
|
||||
ci = _sh(np.sign(D["crypto"]) * D["intra"] - COST)
|
||||
print(f" QQQ: gap(overlap) Sharpe {cg:.2f} vs intraday(tradabile) Sharpe {ci:.2f}")
|
||||
print(f" -> il moto che il crypto 'predice' avviene NELLA finestra del segnale (overnight), non dopo.")
|
||||
print(f" All'entrata (D13:00, pre-open) il gap e' gia' realizzato -> NON catturabile con l'ETF.")
|
||||
print(f" Cio' che resta da catturare (intraday) ha Sharpe ~{ci:.1f}: l'edge tradabile e' quello.")
|
||||
print(f" Per catturare PARTE dell'overnight servirebbe entrare a META' notte via FUTURES IB e")
|
||||
print(f" testare se il crypto [P21:00->T] predice il future [T->open] (finestre NON sovrapposte):")
|
||||
print(f" richiede dati intraday dei futures indice (ES/NQ), che NON abbiamo in cache -> data step.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,97 @@
|
||||
"""WEEKEND CRYPTO -> LUNEDI' AZIONARIO — validazione avversariale dell'anticipazione.
|
||||
|
||||
L'analisi lead-lag ha trovato UNA anticipazione pulita: il movimento crypto del weekend (Sab+Dom,
|
||||
azionario chiuso) anticipa il lunedi' azionario (gap corr ~0.24, OOS piu' forte). Prima di crederci,
|
||||
due test scettici:
|
||||
(A) INCREMENTALE: aggiunge info OLTRE il rendimento del VENERDI'? (o e' solo momentum equity?)
|
||||
regressione Mon ~ weekend_crypto + friday_equity ; il coeff del crypto resta significativo?
|
||||
(B) TRADABILE: segnale eseguibile = osservo weekend crypto (noto Dom 24:00 UTC), entro al Monday
|
||||
OPEN, esco al Monday CLOSE. Net di costi (4 bps RT ETF). Sharpe/hit/OOS vs sempre-long lunedi'.
|
||||
|
||||
DATI: cache su disco (BTC Deribit 1h->1d; ETF eq_* con OPEN). Nessun IB online.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import numpy as np, pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
from src.data.downloader import load_data
|
||||
import eqlib
|
||||
|
||||
OOS = pd.Timestamp("2022-01-01", tz="UTC")
|
||||
COST_RT = 0.0004 # 4 bps round-trip ETF (entry open + exit close)
|
||||
|
||||
|
||||
def _sh_weekly(r):
|
||||
r = np.asarray(pd.Series(r).dropna(), float)
|
||||
return float(np.mean(r) / np.std(r) * np.sqrt(52)) if len(r) > 2 and np.std(r) > 0 else 0.0
|
||||
|
||||
|
||||
def build(asset_etf="QQQ"):
|
||||
btc = load_data("BTC", "1h").set_index("datetime")["close"].astype(float).resample("1D").last()
|
||||
cal = pd.date_range(btc.index[0], btc.index[-1], freq="D", tz="UTC")
|
||||
bc = btc.reindex(cal).ffill()
|
||||
oc = eqlib.load_eq(asset_etf)["open"].astype(float)
|
||||
cc = eqlib.load_eq(asset_etf)["close"].astype(float)
|
||||
rows = []
|
||||
for mon in cc.index:
|
||||
if mon.weekday() != 0:
|
||||
continue
|
||||
fri = mon - pd.Timedelta(days=3)
|
||||
thu = mon - pd.Timedelta(days=4)
|
||||
if fri not in cc.index or fri not in bc.index or mon not in bc.index:
|
||||
continue
|
||||
wk = bc.loc[mon] / bc.loc[fri] - 1.0 # weekend crypto (Ven 00:00 -> Lun 00:00)
|
||||
fri_eq = (cc.loc[fri] / cc.loc[thu] - 1.0) if thu in cc.index else np.nan # rendimento venerdi'
|
||||
gap = oc.loc[mon] / cc.loc[fri] - 1.0
|
||||
intr = cc.loc[mon] / oc.loc[mon] - 1.0 # tradabile: open->close lunedi'
|
||||
rows.append((mon, wk, fri_eq, gap, intr))
|
||||
return pd.DataFrame(rows, columns=["mon", "wk", "fri_eq", "gap", "intr"]).dropna().set_index("mon")
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 92)
|
||||
print(" WEEKEND CRYPTO -> LUNEDI' AZIONARIO — validazione avversariale")
|
||||
print("=" * 92)
|
||||
for etf in ("QQQ", "SPY", "IWM"):
|
||||
D = build(etf)
|
||||
print(f"\n ===== {etf} (n={len(D)} lunedi', {D.index[0].date()}..{D.index[-1].date()}) =====")
|
||||
|
||||
# (A) INCREMENTALE vs venerdi' — regressione OLS standardizzata, t-stat su weekend_crypto
|
||||
for tgt in ("gap", "intr"):
|
||||
y = (D[tgt] - D[tgt].mean()) / D[tgt].std()
|
||||
x1 = (D["wk"] - D["wk"].mean()) / D["wk"].std()
|
||||
x2 = (D["fri_eq"] - D["fri_eq"].mean()) / D["fri_eq"].std()
|
||||
X = np.column_stack([np.ones(len(D)), x1.values, x2.values])
|
||||
beta, *_ = np.linalg.lstsq(X, y.values, rcond=None)
|
||||
resid = y.values - X @ beta
|
||||
se = np.sqrt(np.sum(resid**2) / (len(D) - 3) * np.diag(np.linalg.inv(X.T @ X)))
|
||||
t_wk = beta[1] / se[1]; t_fri = beta[2] / se[2]
|
||||
partial = float(pd.Series(resid).corr(x1)) # ~ contributo crypto al netto del resto
|
||||
print(f" [{tgt:4}] beta_weekendCrypto {beta[1]:+.3f} (t={t_wk:+.1f}) | "
|
||||
f"beta_fridayEq {beta[2]:+.3f} (t={t_fri:+.1f}) -> crypto {'INCREMENTALE' if abs(t_wk)>2 else 'non signif.'}")
|
||||
|
||||
# (B) TRADABILE: long lunedi' intraday se weekend crypto > 0, short se < 0 (net costi)
|
||||
sig = np.sign(D["wk"].values)
|
||||
gross = sig * D["intr"].values
|
||||
net = gross - COST_RT
|
||||
D2 = D.assign(net=net)
|
||||
full = D2["net"]; oos = D2[D2.index >= OOS]["net"]; ins = D2[D2.index < OOS]["net"]
|
||||
bh = D["intr"] # baseline: sempre-long lunedi' intraday
|
||||
hit = float((np.sign(gross) > 0).mean()) if False else float((sig == np.sign(D["intr"].values)).mean())
|
||||
print(f" TRADE (long/short Mon intraday su segno weekend-crypto, net {COST_RT*1e4:.0f}bps):")
|
||||
print(f" hit-rate segno {hit*100:.0f}% | Sharpe(sett.) FULL {_sh_weekly(full):.2f} IS {_sh_weekly(ins):.2f} OOS22+ {_sh_weekly(oos):.2f}")
|
||||
print(f" ritorno medio/lun {full.mean()*1e4:+.1f}bps (net) | baseline sempre-long {bh.mean()*1e4:+.1f}bps | "
|
||||
f"ann.~{full.mean()*52*100:+.1f}%")
|
||||
# long-flat (piu' realistico: long se crypto su, altrimenti cash)
|
||||
lf = np.where(D["wk"].values > 0, D["intr"].values, 0.0) - np.where(D["wk"].values > 0, COST_RT, 0.0)
|
||||
lfs = pd.Series(lf, index=D.index)
|
||||
print(f" variante LONG-FLAT (long se crypto su, else cash): Sharpe FULL {_sh_weekly(lfs):.2f} "
|
||||
f"OOS {_sh_weekly(lfs[lfs.index>=OOS]):.2f} ann.~{lfs.mean()*52*100:+.1f}%")
|
||||
|
||||
print("\n NB: ~52 lunedi'/anno -> Sharpe settimanale; OOS = 2022+. Multiple-testing: 3 ETF x 2 target.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,352 @@
|
||||
"""DVOL-DIRECTIONAL — la vol IMPLICITA (Deribit DVOL) come SEGNALE DIREZIONALE/REGIME sul perp BTC/ETH.
|
||||
|
||||
Filone A. Diverso da TUTTO il lavoro DVOL precedente:
|
||||
* `tp01_dvol_overlay.py` (2026-06-26): DVOL come DENOMINATORE del vol-target -> solo de-levering, SCARTATO.
|
||||
* VOL03/04/10/11 (sweep alt): DVOL che GATA/SCALA un TSMOM (eredita lo Sharpe di trend di TP01 ->
|
||||
il marginal scorer li boccia NEUTRAL/REDUNDANT).
|
||||
* agent_14_dvol_spread (onda ortho): IV RELATIVA BTC-vs-ETH, market-NEUTRAL 2-leg (l'unico LEAD).
|
||||
Qui invece: usare DVOL/IV-RV come segnale DIREZIONALE STANDALONE sul LIVELLO di mercato (long-flat o
|
||||
L/S sul perp), per provare se il DVOL porta alpha DIREZIONALE ORTOGONALE a TP01 (non un overlay sul trend).
|
||||
|
||||
Angoli (tutti CAUSALI: decisi <= close[i], tenuti in i+1 dallo shift di eval_weights):
|
||||
VRP-Z : z-score causale del vol-risk-premium (IV-RV). VRP ricco (IV>>RV, paura sovra-prezzata)
|
||||
=> LONG underlying (fear-reversal, l'analogo direzionale dell'edge VRP); flip = falsifica.
|
||||
DVOL-LV : percentile espandente causale del DVOL. "Buy-the-fear" (rank alto => long) vs
|
||||
"buy-the-calm" (rank basso => long).
|
||||
DVOL-MOM : DVOL vs ema(DVOL,k). DVOL in calo (risk-on) => long; in salita (risk-off) => flat (o short, L/S).
|
||||
VRP-LS : variante long-short del VRP-Z (short quando VRP compresso).
|
||||
|
||||
DVOL parte 2021-03 -> pre-DVOL il segnale e' FLAT (0). Valuto sia FULL (con flat pre-periodo, deflaziona
|
||||
lo Sharpe) sia DVOL-ERA-ONLY (giusto per la tesi). Gate: study_weights (abs+fee sweep), marginal_vs_tp01
|
||||
(earns_slot), causality_ok, eval_weights_smallcap ($600), sign-falsification. Storia DVOL <5 anni -> caveat.
|
||||
|
||||
uv run python scripts/research/dvol_directional.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
|
||||
import altlib as al # noqa: E402
|
||||
|
||||
# warmup: prima data DVOL (2021-03-24) + finestra max (z/rank) -> evita celle a campione corto
|
||||
DVOL_ERA = pd.Timestamp("2021-10-01", tz="UTC")
|
||||
TVOL = 0.20
|
||||
VOLWIN = 30
|
||||
LEVCAP = 2.0
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# CONTESTO causale per-asset (IV, RV, VRP) — tutto <= close[i]
|
||||
# ===========================================================================
|
||||
def _ctx(df: pd.DataFrame, asset: str):
|
||||
c = df["close"].values.astype(float)
|
||||
r = al.simple_returns(c)
|
||||
bpd = al.bars_per_day(df)
|
||||
iv = al.dvol(df, asset) / 100.0 # IV implicita 30d annualizzata (causale)
|
||||
rv = al.realized_vol(r, max(2, 30 * bpd), bpd * 365.25) # RV trailing 30d annualizzata
|
||||
return c, r, bpd, iv, rv
|
||||
|
||||
|
||||
_RANK_CACHE: dict = {}
|
||||
|
||||
|
||||
def _expanding_rank_arr(x: np.ndarray, min_p: int = 120) -> np.ndarray:
|
||||
"""Percentile espandente causale: rank[i] = frazione di x[:i] < x[i].
|
||||
Usa SOLO barre STRETTAMENTE precedenti + il valore corrente (noto a close[i]) — niente
|
||||
future-peeking. Calcolato sull'array PASSATO (cosi' su un prefisso ridà i valori del prefisso,
|
||||
=> causality_ok lo verifica davvero). O(n^2), n~3k OK."""
|
||||
finite = np.isfinite(x)
|
||||
out = np.full(len(x), np.nan)
|
||||
for i in range(min_p, len(x)):
|
||||
if not finite[i]:
|
||||
continue
|
||||
prev = x[:i][finite[:i]]
|
||||
if len(prev) >= min_p // 2:
|
||||
out[i] = float(np.mean(prev < x[i]))
|
||||
return out
|
||||
|
||||
|
||||
def dvol_rank(df: pd.DataFrame, asset: str, min_p: int = 120) -> np.ndarray:
|
||||
x = np.asarray(al.dvol(df, asset), float)
|
||||
key = (asset, len(x), round(float(np.nan_to_num(x[-1])), 4))
|
||||
c = _RANK_CACHE.get(key)
|
||||
if c is None:
|
||||
c = _expanding_rank_arr(x, min_p)
|
||||
_RANK_CACHE[key] = c
|
||||
return c
|
||||
|
||||
|
||||
def _finalize(direction: np.ndarray, df: pd.DataFrame, long_only: bool) -> np.ndarray:
|
||||
d = np.nan_to_num(direction, nan=0.0)
|
||||
if long_only:
|
||||
d = np.clip(d, 0.0, 1.0)
|
||||
return al.vol_target(d, df, TVOL, VOLWIN, LEVCAP)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# FAMIGLIE DI SEGNALI (factory -> target_fn(df, asset))
|
||||
# ===========================================================================
|
||||
def make_vrp_z(win=90, thr=0.0, sign=+1, long_only=True):
|
||||
"""LONG quando z(IV-RV) * sign > thr. sign=+1 => long su VRP RICCO (fear-reversal)."""
|
||||
def fn(df, asset):
|
||||
c, r, bpd, iv, rv = _ctx(df, asset)
|
||||
vrp = iv - rv
|
||||
z = al.zscore(vrp, max(5, win * bpd))
|
||||
sig = sign * z
|
||||
if long_only:
|
||||
d = np.where(sig > thr, 1.0, 0.0)
|
||||
else:
|
||||
d = np.clip(np.tanh(sig), -1.0, 1.0)
|
||||
return _finalize(d, df, long_only)
|
||||
return fn
|
||||
|
||||
|
||||
def make_dvol_level(q=0.5, side="fear", long_only=True):
|
||||
"""side='fear': long quando rank DVOL > q (buy-the-fear). side='calm': long quando rank < q."""
|
||||
def fn(df, asset):
|
||||
rk = dvol_rank(df, asset)
|
||||
if side == "fear":
|
||||
base = (rk > q)
|
||||
else:
|
||||
base = (rk < q)
|
||||
if long_only:
|
||||
d = np.where(base, 1.0, 0.0)
|
||||
else:
|
||||
d = np.where(base, 1.0, -1.0)
|
||||
return _finalize(d, df, long_only)
|
||||
return fn
|
||||
|
||||
|
||||
def make_dvol_mom(k=10, long_only=True):
|
||||
"""DVOL in calo (dvol < ema(dvol,k)) => risk-on => long; in salita => flat (o short se L/S)."""
|
||||
def fn(df, asset):
|
||||
dv = al.dvol(df, asset)
|
||||
em = al.ema(np.nan_to_num(dv, nan=np.nan), k)
|
||||
falling = dv < em
|
||||
if long_only:
|
||||
d = np.where(falling, 1.0, 0.0)
|
||||
else:
|
||||
d = np.where(falling, 1.0, -1.0)
|
||||
d = np.where(np.isfinite(dv) & np.isfinite(em), d, 0.0)
|
||||
return _finalize(d, df, long_only)
|
||||
return fn
|
||||
|
||||
|
||||
def make_vrp_positive(long_only=True):
|
||||
"""Baseline 'quasi buy&hold': long quando IV>RV (VRP positivo, ~80% del tempo)."""
|
||||
def fn(df, asset):
|
||||
c, r, bpd, iv, rv = _ctx(df, asset)
|
||||
d = np.where((iv - rv) > 0, 1.0, 0.0)
|
||||
return _finalize(d, df, long_only)
|
||||
return fn
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# DIAGNOSTICA: il DVOL ha CONTENUTO DIREZIONALE? (probe corr signal[i] vs r[i+1])
|
||||
# ===========================================================================
|
||||
def _raw_direction(fn_dir, df, asset):
|
||||
"""Estrae la DIREZIONE grezza (pre-vol-target) per la probe: rifa' il calcolo del segno."""
|
||||
return fn_dir(df, asset)
|
||||
|
||||
|
||||
def probe(name, dir_fn, era_only=True):
|
||||
"""corr(dir[i], r[i+1]) e media r[i+1] per dir>0 vs dir<=0, pooled BTC+ETH (1d)."""
|
||||
dd, rr = [], []
|
||||
for a in al.CERTIFIED:
|
||||
df = al.get(a, "1d")
|
||||
idx = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True))
|
||||
d = np.nan_to_num(dir_fn(df, a), nan=0.0)
|
||||
r = al.simple_returns(df["close"].values.astype(float))
|
||||
rnext = np.roll(r, -1); rnext[-1] = 0.0
|
||||
mask = np.ones(len(df), bool)
|
||||
if era_only:
|
||||
mask &= np.asarray(idx >= DVOL_ERA)
|
||||
mask[-1] = False
|
||||
dd.append(d[mask]); rr.append(rnext[mask])
|
||||
d = np.concatenate(dd); r = np.concatenate(rr)
|
||||
corr = float(np.corrcoef(d, r)[0, 1]) if np.std(d) > 0 and np.std(r) > 0 else 0.0
|
||||
up = float(np.mean(r[d > 0])) * 1e4 if (d > 0).any() else 0.0 # bps
|
||||
dn = float(np.mean(r[d <= 0])) * 1e4 if (d <= 0).any() else 0.0
|
||||
return dict(name=name, corr=round(corr, 4), n=int(len(d)),
|
||||
long_bps=round(up, 1), flat_bps=round(dn, 1), edge_bps=round(up - dn, 1),
|
||||
frac_long=round(float(np.mean(d > 0)), 3))
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# MARGINAL su finestra DVOL-ERA (piu' giusto della full che include il flat pre-2021)
|
||||
# ===========================================================================
|
||||
def cand_daily_era(target_fn, tf="1d", start=DVOL_ERA, fee_side=al.FEE_SIDE) -> pd.Series:
|
||||
series = {}
|
||||
for a in al.CERTIFIED:
|
||||
df = al.get(a, tf)
|
||||
ev = al.eval_weights(df, al._call_target(target_fn, df, a), fee_side=fee_side)
|
||||
series[a] = pd.Series(ev["net"], index=ev["idx"])
|
||||
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
|
||||
d = al._to_daily(0.5 * J[al.CERTIFIED[0]] + 0.5 * J[al.CERTIFIED[1]])
|
||||
return d[d.index >= start]
|
||||
|
||||
|
||||
def era_full_sharpe(target_fn, tf="1d"):
|
||||
"""Sharpe/DD del candidato sulla SOLA era DVOL (no flat pre-2021), 50/50."""
|
||||
s = cand_daily_era(target_fn, tf)
|
||||
r = s.values
|
||||
sh = float(np.mean(r) / np.std(r) * np.sqrt(365.25)) if np.std(r) > 0 else 0.0
|
||||
eq = np.cumprod(1 + r); pk = np.maximum.accumulate(eq)
|
||||
dd = float(np.max((pk - eq) / pk)) if len(eq) else 0.0
|
||||
yrs = (s.index[-1] - s.index[0]).days / 365.25 if len(s) > 1 else 1.0
|
||||
cagr = eq[-1] ** (1 / yrs) - 1 if yrs > 0 and eq[-1] > 0 else -1.0
|
||||
return dict(sharpe=round(sh, 3), dd=round(dd, 4), cagr=round(cagr, 4), n=len(s))
|
||||
|
||||
|
||||
def earns_slot_era(target_fn, tf="1d"):
|
||||
"""Replica del gate study_marginal MA sulla candidate-daily ristretta all'era DVOL (inner-join
|
||||
con TP01 baseline limita anche TP01 alla stessa finestra). Piu' equo per un segnale DVOL-only."""
|
||||
marg = al.marginal_vs_tp01(cand_daily_era(target_fn, tf))
|
||||
v = marg.get("marginal_verdict")
|
||||
earns = (v == "ADDS" and marg.get("robust_oos", False)
|
||||
and marg.get("beats_noise_null", False) and not marg.get("is_hedge", False))
|
||||
return marg, earns
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# MAIN
|
||||
# ===========================================================================
|
||||
def hr(t=""):
|
||||
print("=" * 100)
|
||||
if t:
|
||||
print(t)
|
||||
print("=" * 100)
|
||||
|
||||
|
||||
def main():
|
||||
hr("DVOL-DIREZIONALE — DVOL/IV-RV come segnale DIREZIONALE standalone su BTC/ETH (perp). 1d, causale.")
|
||||
print(f" Era DVOL valutata da {DVOL_ERA.date()} (warmup z/rank). FULL include flat pre-2021 (deflaziona Sharpe).")
|
||||
print(f" vol-target {TVOL:.0%}, leva cap {LEVCAP}x, fee 0.10% RT. TP01 baseline come riferimento marginale.\n")
|
||||
|
||||
# ---- 1) PROBE: contenuto direzionale (corr signal[i] vs r[i+1]) ---------
|
||||
hr("1) PROBE DIREZIONALE — il DVOL predice il ritorno del giorno dopo? (pooled BTC+ETH, era DVOL)")
|
||||
probes = [
|
||||
("VRP-Z+ (long VRP ricco)", make_vrp_z(90, 0.0, +1)),
|
||||
("VRP-Z- (long VRP basso)", make_vrp_z(90, 0.0, -1)),
|
||||
("DVOL-LV fear (rank>0.5)", make_dvol_level(0.5, "fear")),
|
||||
("DVOL-LV calm (rank<0.5)", make_dvol_level(0.5, "calm")),
|
||||
("DVOL-MOM falling=long", make_dvol_mom(10)),
|
||||
("VRP>0 (quasi buy&hold)", make_vrp_positive()),
|
||||
]
|
||||
print(f" {'segnale':<28}{'corr':>9}{'long bps':>10}{'flat bps':>10}{'edge bps':>10}{'frac_long':>10}")
|
||||
for nm, fn in probes:
|
||||
p = probe(nm, fn)
|
||||
print(f" {nm:<28}{p['corr']:>+9.4f}{p['long_bps']:>+10.1f}{p['flat_bps']:>+10.1f}"
|
||||
f"{p['edge_bps']:>+10.1f}{p['frac_long']:>10.3f}")
|
||||
print(" (edge bps = ritorno medio giorno-dopo quando long MENO quando flat; >0 = il segnale separa)\n")
|
||||
|
||||
# ---- 2) ROBUSTEZZA ASSOLUTA (study_weights) + fee sweep + era-only ------
|
||||
hr("2) ROBUSTEZZA ASSOLUTA — study_weights su BTC+ETH (1d), fee sweep 0.00-0.20% RT")
|
||||
grid = {
|
||||
"VRP-Z90 long-flat": make_vrp_z(90, 0.0, +1, True),
|
||||
"VRP-Z60 long-flat": make_vrp_z(60, 0.0, +1, True),
|
||||
"VRP-Z120 long-flat": make_vrp_z(120, 0.0, +1, True),
|
||||
"VRP-Z90 L/S": make_vrp_z(90, 0.0, +1, False),
|
||||
"DVOL-fear q0.4 LF": make_dvol_level(0.4, "fear", True),
|
||||
"DVOL-fear q0.5 LF": make_dvol_level(0.5, "fear", True),
|
||||
"DVOL-calm q0.5 LF": make_dvol_level(0.5, "calm", True),
|
||||
"DVOL-MOM k10 LF": make_dvol_mom(10, True),
|
||||
"DVOL-MOM k20 LF": make_dvol_mom(20, True),
|
||||
"DVOL-MOM k10 L/S": make_dvol_mom(10, False),
|
||||
}
|
||||
reps = {}
|
||||
for nm, fn in grid.items():
|
||||
rep = al.study_weights(nm, fn, tfs=("1d",))
|
||||
reps[nm] = rep
|
||||
era = era_full_sharpe(fn)
|
||||
v = rep["verdict"]
|
||||
c = rep["cells"][0]
|
||||
print(f" {nm:<22} abs={v['grade']:<4} minFull={c['min_asset_full_sharpe']:+.2f} "
|
||||
f"minHold={c['min_asset_holdout_sharpe']:+.2f} feeOK={c['fee_survives']!s:<5} "
|
||||
f"|| ERA-only Sh={era['sharpe']:+.2f} DD={era['dd']*100:.0f}% CAGR={era['cagr']*100:+.0f}%")
|
||||
print(" (FULL include 2018-2021 flat => Sharpe basso e' atteso; ERA-only e' il giudizio equo del segnale)\n")
|
||||
|
||||
# candidati da portare al marginal: i migliori per ERA Sharpe + abs non-FAIL
|
||||
ranked = sorted(grid.items(), key=lambda kv: era_full_sharpe(kv[1])["sharpe"], reverse=True)
|
||||
top = ranked[:4]
|
||||
|
||||
# ---- 3) MARGINAL vs TP01 (era DVOL) — il gate vero -----------------------
|
||||
hr("3) MARGINAL vs TP01 (finestra DVOL-era) — earns_slot? (il gate decisivo)")
|
||||
for nm, fn in top:
|
||||
marg, earns = earns_slot_era(fn)
|
||||
bl = marg.get("blends", {}).get("w25", {})
|
||||
print(f"\n --- {nm} ---")
|
||||
print(f" verdetto={marg.get('marginal_verdict')} EARNS_SLOT(era)={earns}")
|
||||
print(f" corr->TP01 full {marg.get('corr_full')} hold {marg.get('corr_hold')} "
|
||||
f"beta {marg.get('beta_to_tp01')} resid Sh {marg.get('resid_sharpe_full')}")
|
||||
print(f" cand standalone full {marg.get('cand_full_sharpe')}/hold {marg.get('cand_hold_sharpe')} "
|
||||
f"in-sample Sh {marg.get('cand_insample_sharpe')} has_edge={marg.get('has_insample_edge')}")
|
||||
print(f" blend w25: full {bl.get('full')} (uplift {bl.get('uplift_full')}) "
|
||||
f"hold {bl.get('hold')} (uplift {bl.get('uplift_hold')}) DD {bl.get('dd')}")
|
||||
print(f" multi-cut {marg.get('multicut_uplift')} persistent={marg.get('multicut_persistent')} "
|
||||
f"robust_oos={marg.get('robust_oos')} is_hedge={marg.get('is_hedge')}")
|
||||
|
||||
# ---- 3b) study_marginal canonico (full history, gate di progetto) --------
|
||||
hr("3b) study_marginal CANONICO (full history, include flat pre-DVOL) — gate ufficiale di progetto")
|
||||
leader_nm, leader_fn = top[0]
|
||||
sm = al.study_marginal(leader_nm, leader_fn, tf="1d")
|
||||
print(al.fmt_marginal(sm))
|
||||
|
||||
# ---- 4) CAUSALITA' (look-ahead guard) -----------------------------------
|
||||
hr("4) CAUSALITA' — causality_ok (ricalcolo su prefisso, nessun future-peeking)")
|
||||
for nm, fn in top:
|
||||
co = al.causality_ok(fn, tf="1d")
|
||||
print(f" {nm:<22} ok={co['ok']!s:<5} max_tail_diff={co['max_tail_diff']} checked={co['checked']}")
|
||||
|
||||
# robustezza alignment DVOL: lag +1 giorno (extra-conservativo) sul leader
|
||||
hr("4b) ROBUSTEZZA ALIGNMENT — DVOL laggato +1g (extra-conservativo) sul leader: l'edge sopravvive?")
|
||||
|
||||
def _lag(fn):
|
||||
# ricostruisce il segnale con dvol shiftato di 1 barra (usa solo DVOL di IERI)
|
||||
def g(df, asset):
|
||||
# monkey: usa al.dvol ma shift -> approssimo rifacendo via wrapper sul ctx non e' banale;
|
||||
# piu' semplice: confronto Sharpe era con e senza lag costruendo direzione laggata generica.
|
||||
base = fn(df, asset)
|
||||
lagged = np.zeros_like(base); lagged[1:] = base[:-1]
|
||||
return lagged
|
||||
return g
|
||||
lag_era = era_full_sharpe(_lag(leader_fn))
|
||||
base_era = era_full_sharpe(leader_fn)
|
||||
print(f" {leader_nm}: ERA Sh base {base_era['sharpe']:+.2f} -> con segnale laggato +1g "
|
||||
f"{lag_era['sharpe']:+.2f} (calo grande = edge fragile all'alignment)")
|
||||
|
||||
# ---- 5) ESEGUIBILITA' $600 — eval_weights_smallcap ----------------------
|
||||
hr("5) ESEGUIBILITA' a $600 — eval_weights_smallcap (min_order $5, haircut reale vs modellato)")
|
||||
for nm, fn in top:
|
||||
for a in al.CERTIFIED:
|
||||
df = al.get(a, "1d")
|
||||
sc = al.eval_weights_smallcap(df, al._call_target(fn, df, a), capital=600, min_order=5)
|
||||
print(f" {nm:<22} {a}: modellato Sh {sc['modeled']['sharpe']:+.2f} -> reale $600 "
|
||||
f"Sh {sc['realistic']['sharpe']:+.2f} (haircut {sc['sharpe_haircut']:+.2f}) "
|
||||
f"trade eseguiti {sc['n_executed_trades']}")
|
||||
|
||||
# ---- 6) SIGN-FALSIFICATION sul leader -----------------------------------
|
||||
hr("6) SIGN-FALSIFICATION — invertire il segno del leader deve PEGGIORARE (altrimenti e' rumore)")
|
||||
# leader e' VRP-Z+ se nel top; provo l'inverso esplicito su VRP-Z e DVOL
|
||||
flips = [
|
||||
("VRP-Z90 sign +1 (tesi)", make_vrp_z(90, 0.0, +1, True)),
|
||||
("VRP-Z90 sign -1 (flip)", make_vrp_z(90, 0.0, -1, True)),
|
||||
("DVOL-fear q0.5 (tesi)", make_dvol_level(0.5, "fear", True)),
|
||||
("DVOL-calm q0.5 (flip)", make_dvol_level(0.5, "calm", True)),
|
||||
]
|
||||
for nm, fn in flips:
|
||||
e = era_full_sharpe(fn)
|
||||
print(f" {nm:<26} ERA Sh {e['sharpe']:+.2f} DD {e['dd']*100:.0f}% CAGR {e['cagr']*100:+.0f}%")
|
||||
|
||||
hr()
|
||||
print("FINE. Leggere il verdetto onesto nel diario docs/diary/2026-06-29-dvol-directional.md")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,84 @@
|
||||
"""CROSS-MARKET COMBO — la sleeve equity-trend DIVERSIFICA il portafoglio crypto?
|
||||
|
||||
(2) dopo EQ-GTAA01. La via che alza il Sharpe COMPLESSIVO senza cercare nuovo alpha: combinare due
|
||||
book scorrelati su mercati diversi. crypto = portafoglio attivo TP01+XS01+VRP01 (src.portfolio);
|
||||
equity = GTAA lf vt12% (la migliore sleeve equity, corr SPY 0.64, maxDD ~15%). Se la correlazione
|
||||
crypto<->equity e' bassa, il blend ha Sharpe > di ciascuno.
|
||||
|
||||
ALLINEAMENTO ONESTO: crypto e' calendario-giornaliero (7gg), equity giorni di borsa. Compoundo i
|
||||
rendimenti crypto sul grid dei giorni di borsa (cattura i weekend) prima di combinare. Finestra =
|
||||
era crypto (TP01 dal 2019). Esecuzione split Deribit+IB (lo noto: e' un portafoglio cross-venue).
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import numpy as np, pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
from eq_sector_momentum import _sh, _cagr, _dd
|
||||
from eq_gtaa_trend import gtaa
|
||||
from eq_spy_trend import tsmom_exposure, backtest, _series
|
||||
from src.portfolio.sleeves import active_sleeves
|
||||
from src.portfolio.portfolio import StrategyPortfolio
|
||||
|
||||
ANN = np.sqrt(252.0)
|
||||
|
||||
|
||||
def _ann_vol(r):
|
||||
return float(np.std(np.asarray(r, float)) * ANN)
|
||||
|
||||
|
||||
def compound_to_grid(daily: pd.Series, grid: pd.DatetimeIndex) -> pd.Series:
|
||||
"""Compounda una serie di rendimenti (calendario) sul grid dato (giorni di borsa): per ogni data
|
||||
del grid, somma-composta i rendimenti dal punto precedente."""
|
||||
cum = (1.0 + daily).cumprod()
|
||||
cum = cum.reindex(cum.index.union(grid)).ffill().reindex(grid)
|
||||
return (cum / cum.shift(1) - 1.0).dropna()
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 96)
|
||||
print(" CROSS-MARKET COMBO — equity-trend (GTAA) x crypto (TP01+XS01+VRP01)")
|
||||
print("=" * 96)
|
||||
# crypto blend (rinormalizzato, date diverse)
|
||||
crypto = StrategyPortfolio(active_sleeves()).combined_daily()
|
||||
if crypto.index.tz is None:
|
||||
crypto.index = crypto.index.tz_localize("UTC")
|
||||
# equity sleeve (giorni di borsa)
|
||||
eq = gtaa(target_vol=0.12).dropna()
|
||||
# allinea: compounda crypto sui giorni di borsa dell'equity
|
||||
grid = eq.index[eq.index >= crypto.index[0]]
|
||||
cr = compound_to_grid(crypto, grid)
|
||||
J = pd.concat({"crypto": cr, "equity": eq.reindex(cr.index)}, axis=1).dropna()
|
||||
print(f" finestra comune {J.index[0].date()}..{J.index[-1].date()} ({len(J)} giorni di borsa)\n")
|
||||
|
||||
c, e = J["crypto"], J["equity"]
|
||||
print(" --- STANDALONE (sulla finestra comune) ---")
|
||||
for nm, r in (("crypto TP01+XS01+VRP01", c), ("equity GTAA vt12", e)):
|
||||
print(f" {nm:24} Sh {_sh(r):>5.2f} CAGR {_cagr(r.values,r.index)*100:>5.1f}% volAnn {_ann_vol(r)*100:>4.1f}% maxDD {_dd(r.values)*100:>4.0f}%")
|
||||
print(f"\n --- CORRELAZIONE crypto <-> equity = {c.corr(e):+.3f} (bassa = diversifica) ---")
|
||||
|
||||
print("\n --- BLEND (capitale) ---")
|
||||
print(f" {'mix':18} {'Sharpe':>7} {'CAGR%':>6} {'volAnn%':>7} {'maxDD%':>6}")
|
||||
for wc in (1.0, 0.75, 0.5, 0.25, 0.0):
|
||||
b = wc * c + (1 - wc) * e
|
||||
print(f" crypto {int(wc*100):>3}/{int((1-wc)*100):<3} eq {_sh(b):>7.2f} {_cagr(b.values,b.index)*100:>6.1f} {_ann_vol(b)*100:>7.1f} {_dd(b.values)*100:>6.0f}")
|
||||
|
||||
# risk-parity (peso inverso alla vol) — il blend "giusto" quando le vol differiscono
|
||||
vc, ve = _ann_vol(c), _ann_vol(e)
|
||||
wc_rp = (1/vc) / (1/vc + 1/ve)
|
||||
b_rp = wc_rp * c + (1 - wc_rp) * e
|
||||
print(f"\n --- RISK-PARITY (inv-vol: crypto {wc_rp*100:.0f}% / eq {(1-wc_rp)*100:.0f}%) ---")
|
||||
print(f" Sharpe {_sh(b_rp):.2f} CAGR {_cagr(b_rp.values,b_rp.index)*100:.1f}% volAnn {_ann_vol(b_rp)*100:.1f}% maxDD {_dd(b_rp.values)*100:.0f}%")
|
||||
|
||||
# verdetto: il blender batte il migliore dei due?
|
||||
best_solo = max(_sh(c), _sh(e))
|
||||
best_blend = max(_sh(0.5*c+0.5*e), _sh(b_rp))
|
||||
print(f"\n --- VERDETTO ---")
|
||||
print(f" miglior standalone Sharpe = {best_solo:.2f} | miglior blend Sharpe = {best_blend:.2f} "
|
||||
f"-> {'DIVERSIFICA (blend > solo)' if best_blend > best_solo + 0.03 else 'nessun guadagno netto'}")
|
||||
print(f" (nota: portafoglio cross-venue Deribit+IB; finestra crypto corta ~{len(J)//252}y)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,194 @@
|
||||
"""EQ-FUNDNEWS-SHORT — "fondamentali/notizie NEGATIVI ma prezzo SU -> short". Screener FORWARD.
|
||||
|
||||
Idea utente: se i dati finanziari/notizie di un'azienda sono negativi ma la quotazione e' positiva
|
||||
(sale), andare SHORT (scommessa che il prezzo scenda a riallinearsi ai fondamentali).
|
||||
|
||||
GATE DATI (lezione v2.0.0, PRIMA della strategia): NON backtestabile su dati certi.
|
||||
- I fondamentali da rete (Yahoo) sono SNAPSHOT CORRENTI, non point-in-time storici. Applicarli a
|
||||
prezzi passati = LOOK-AHEAD (restatement/survivorship). E' la trappola che ha creato la libreria
|
||||
fasulla v2.0.0. -> nessun backtest. Serve un DB point-in-time (Compustat PIT / news storiche), assente.
|
||||
- Quindi: come per la vol term-structure, l'unica via onesta e' uno SCREENER FORWARD che genera i
|
||||
candidati short OGGI da dati di rete e li LOGGA in avanti. L'edge resta NON PROVATO finche' non
|
||||
accumulato e validato forward. Questo script NON afferma un edge: produce candidati + li registra.
|
||||
|
||||
SEGNALE (tutto da rete, tokenless):
|
||||
- "dati finanziari negativi" = score strutturato da Yahoo quoteSummary (crumb flow):
|
||||
recommendationMean alto (->sell), earnings surprise recenti negative, revenueGrowth<0,
|
||||
recommendationTrend sbilanciato a sell.
|
||||
- "notizie negative" = sentiment lessicale crudo sulle headline (Yahoo news search).
|
||||
- "quotazione positiva" = momentum 1m/3m > 0 (chart API).
|
||||
-> SHORT candidate = (fond_neg alto OPPURE news_neg alto) AND momentum positivo (la DIVERGENZA).
|
||||
|
||||
ESEGUIBILITA' (muro): shortare richiede BORROW (locate+fee, hard-to-borrow caro/assente), perdita
|
||||
illimitata, squeeze; PDT $25k per i day-trade; IB instabile qui; $600 di capitale; universo single-stock
|
||||
(non i nostri ETF). Shortare la FORZA combatte il momentum (anomalia forte) -> premessa rischiosa.
|
||||
|
||||
uv run python scripts/research/eq_fundnews_short.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import requests
|
||||
|
||||
RAW = ROOT / "data" / "raw"
|
||||
H = {"User-Agent": "Mozilla/5.0"}
|
||||
|
||||
# universo dimostrativo: large/mid cap liquide, vari settori
|
||||
UNIVERSE = ["AAPL", "MSFT", "NVDA", "TSLA", "META", "AMZN", "INTC", "F", "GM", "BA",
|
||||
"DIS", "PYPL", "NKE", "PFE", "T", "WBA", "XOM", "KO", "CVX", "AMD"]
|
||||
|
||||
NEG_WORDS = {"downgrade", "miss", "missed", "cut", "cuts", "lawsuit", "probe", "fraud", "plunge",
|
||||
"plunges", "warn", "warns", "warning", "slump", "loss", "losses", "weak", "weakness",
|
||||
"decline", "declines", "fall", "falls", "drop", "sinks", "slashes", "recall", "halt",
|
||||
"bankruptcy", "default", "layoff", "layoffs", "sell-off", "bearish", "underperform"}
|
||||
POS_WORDS = {"beat", "beats", "upgrade", "surge", "surges", "record", "strong", "raise", "raises",
|
||||
"soar", "soars", "rally", "jumps", "tops", "bullish", "outperform", "growth"}
|
||||
|
||||
|
||||
def yahoo_session():
|
||||
s = requests.Session(); s.headers.update(H)
|
||||
s.get("https://fc.yahoo.com/", timeout=20)
|
||||
crumb = s.get("https://query2.finance.yahoo.com/v1/test/getcrumb", timeout=20).text.strip()
|
||||
return s, crumb
|
||||
|
||||
|
||||
def fund_neg_score(rec_mean, surp, rev_g, sell_skew):
|
||||
"""Pura: score di negativita' fondamentale [0..1] dai componenti disponibili (media)."""
|
||||
comp = []
|
||||
if rec_mean is not None:
|
||||
comp.append(float(np.clip((rec_mean - 1) / 4, 0, 1))) # 1=buy ->0, 5=sell ->1
|
||||
if surp:
|
||||
comp.append(1.0 if np.mean(surp[:2]) < 0 else 0.0) # ultime 2 surprise negative
|
||||
if rev_g is not None:
|
||||
comp.append(1.0 if rev_g < 0 else float(max(0.0, 1 - rev_g * 5)))
|
||||
if sell_skew is not None:
|
||||
comp.append(float(np.clip(sell_skew * 3, 0, 1)))
|
||||
return float(np.mean(comp)) if comp else None
|
||||
|
||||
|
||||
def headline_sentiment(titles):
|
||||
"""Pura: frazione di sentiment negativo sulle headline (lessico crudo). None se nessun hit."""
|
||||
neg = pos = 0
|
||||
for t in titles:
|
||||
w = set(t.lower().replace(",", " ").replace(".", " ").split())
|
||||
neg += len(w & NEG_WORDS); pos += len(w & POS_WORDS)
|
||||
tot = neg + pos
|
||||
return (neg / tot) if tot else 0.0, neg, pos
|
||||
|
||||
|
||||
def momentum(sym):
|
||||
u = f"https://query1.finance.yahoo.com/v8/finance/chart/{sym}?range=3mo&interval=1d"
|
||||
j = requests.get(u, headers=H, timeout=25).json()["chart"]["result"][0]
|
||||
c = pd.Series(j["indicators"]["quote"][0]["close"]).dropna().values
|
||||
if len(c) < 25:
|
||||
return None
|
||||
m1 = c[-1] / c[-21] - 1.0 # ~1 mese
|
||||
m3 = c[-1] / c[0] - 1.0 # ~3 mesi
|
||||
return dict(last=float(c[-1]), mom_1m=float(m1), mom_3m=float(m3))
|
||||
|
||||
|
||||
def fundamentals(s, crumb, sym):
|
||||
mods = "financialData,recommendationTrend,earningsHistory"
|
||||
u = f"https://query2.finance.yahoo.com/v10/finance/quoteSummary/{sym}?modules={mods}&crumb={crumb}"
|
||||
res = s.get(u, timeout=25).json()["quoteSummary"]["result"][0]
|
||||
fd = res.get("financialData", {})
|
||||
rec_mean = fd.get("recommendationMean", {}).get("raw")
|
||||
rev_g = fd.get("revenueGrowth", {}).get("raw")
|
||||
eh = res.get("earningsHistory", {}).get("history", [])
|
||||
surp = [h.get("surprisePercent", {}).get("raw") for h in eh if h.get("surprisePercent")]
|
||||
rt = res.get("recommendationTrend", {}).get("trend", [])
|
||||
sell_skew = None
|
||||
if rt:
|
||||
t = rt[0]; tot = sum(t.get(k, 0) for k in ("strongBuy", "buy", "hold", "sell", "strongSell"))
|
||||
sell_skew = (t.get("sell", 0) + t.get("strongSell", 0)) / tot if tot else None
|
||||
return dict(fund_neg=fund_neg_score(rec_mean, surp, rev_g, sell_skew),
|
||||
rec_mean=rec_mean, rev_growth=rev_g, last_surprise=surp[0] if surp else None,
|
||||
sell_skew=sell_skew)
|
||||
|
||||
|
||||
def news_sentiment(sym):
|
||||
u = f"https://query1.finance.yahoo.com/v1/finance/search?q={sym}&newsCount=8"esCount=0"
|
||||
news = requests.get(u, headers=H, timeout=25).json().get("news", [])
|
||||
if not news:
|
||||
return dict(news_neg=None, n=0)
|
||||
nn, neg, pos = headline_sentiment([n.get("title", "") for n in news])
|
||||
return dict(news_neg=nn, n=len(news), neg_hits=neg, pos_hits=pos)
|
||||
|
||||
|
||||
def screen(universe):
|
||||
s, crumb = yahoo_session()
|
||||
rows = []
|
||||
for sym in universe:
|
||||
try:
|
||||
mom = momentum(sym)
|
||||
if mom is None:
|
||||
continue
|
||||
fun = fundamentals(s, crumb, sym)
|
||||
nw = news_sentiment(sym)
|
||||
fneg = fun["fund_neg"]
|
||||
nneg = nw["news_neg"]
|
||||
rising = mom["mom_1m"] > 0.02 # quotazione positiva
|
||||
fund_bad = fneg is not None and fneg >= 0.5
|
||||
news_bad = nneg is not None and nneg >= 0.5
|
||||
short_cand = rising and (fund_bad or news_bad)
|
||||
rows.append(dict(sym=sym, mom_1m=mom["mom_1m"], mom_3m=mom["mom_3m"],
|
||||
fund_neg=fneg, news_neg=nneg, rec_mean=fun["rec_mean"],
|
||||
rev_growth=fun["rev_growth"], last_surprise=fun["last_surprise"],
|
||||
short_cand=short_cand))
|
||||
time.sleep(0.3)
|
||||
except Exception as e:
|
||||
rows.append(dict(sym=sym, error=repr(e)[:60]))
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 100)
|
||||
print(" EQ-FUNDNEWS-SHORT — divergenza fondamentali/notizie NEG vs prezzo SU -> short candidate")
|
||||
print(" (SCREENER FORWARD da dati di rete — NON un backtest: edge non provato, vedi header)")
|
||||
print("=" * 100)
|
||||
df = screen(UNIVERSE)
|
||||
ok = df[df.get("error").isna()] if "error" in df else df
|
||||
ok = ok.sort_values("fund_neg", ascending=False, na_position="last")
|
||||
print(f" {'sym':5} {'mom1m':>7} {'mom3m':>7} {'fund_neg':>8} {'news_neg':>8} "
|
||||
f"{'recMean':>7} {'revGr':>7} {'surp':>7} SHORT?")
|
||||
for _, r in ok.iterrows():
|
||||
fn = f"{r['fund_neg']:.2f}" if pd.notna(r['fund_neg']) else " n/a"
|
||||
nn = f"{r['news_neg']:.2f}" if pd.notna(r['news_neg']) else " n/a"
|
||||
rm = f"{r['rec_mean']:.2f}" if pd.notna(r['rec_mean']) else " n/a"
|
||||
rg = f"{r['rev_growth']*100:+.0f}%" if pd.notna(r['rev_growth']) else " n/a"
|
||||
sp = f"{r['last_surprise']*100:+.0f}%" if pd.notna(r['last_surprise']) else " n/a"
|
||||
flag = " <<< SHORT" if r["short_cand"] else ""
|
||||
print(f" {r['sym']:5} {r['mom_1m']*100:>+6.1f}% {r['mom_3m']*100:>+6.1f}% {fn:>8} {nn:>8} "
|
||||
f"{rm:>7} {rg:>7} {sp:>7}{flag}")
|
||||
cands = ok[ok["short_cand"]]["sym"].tolist()
|
||||
print(f"\n CANDIDATI SHORT oggi (fond/news neg + prezzo su): {cands or 'nessuno'}")
|
||||
|
||||
# log forward (idempotente per giorno)
|
||||
today = pd.Timestamp.now("UTC").normalize()
|
||||
ok2 = ok.copy(); ok2.insert(0, "date", today)
|
||||
fp = RAW / "fundnews_short_screen.parquet"
|
||||
hist = pd.read_parquet(fp) if fp.exists() else pd.DataFrame()
|
||||
if len(hist):
|
||||
hist = hist[hist["date"] != today]
|
||||
pd.concat([hist, ok2], ignore_index=True).to_parquet(fp, index=False)
|
||||
print(f" -> snapshot loggato in {fp.name} (forward dataset; serve accumulo+validazione)")
|
||||
|
||||
print("\n" + "=" * 100)
|
||||
print(" ONESTA' / ESEGUIBILITA'")
|
||||
print("=" * 100)
|
||||
print(" - NON backtestabile: fondamentali = snapshot correnti, non point-in-time -> look-ahead (v2.0.0).")
|
||||
print(" - Premessa RISCHIOSA: shortare un prezzo che SALE combatte il momentum (anomalia forte);")
|
||||
print(" il rialzo 'malgrado' notizie cattive spesso PREZZA info che i fondamentali trailing non hanno.")
|
||||
print(" - Eseguibilita': borrow (locate/fee, hard-to-borrow), perdita illimitata, squeeze, PDT $25k,")
|
||||
print(" IB instabile, $600. -> NON deployabile. Deliverable = screener forward + log, edge da provare.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,109 @@
|
||||
"""EQ-GTAA01 — Trend difensivo MULTI-ASSET (GTAA) sull'universo ETF.
|
||||
|
||||
EQ-TREND01 ha mostrato che il trend long-flat su SPY taglia il DD (analogo TP01). La diversificazione
|
||||
delle SORGENTI di trend (azioni US/tech/small + bond + oro + high-yield) di solito migliora il
|
||||
rischio-aggiustato del trend mono-asset. Qui: ogni asset gestito col proprio trend long-flat
|
||||
(TSMOM multi-orizzonte), equal-weight tra gli asset DISPONIBILI (la quota "off" o assente -> cash).
|
||||
|
||||
DATI: cache eqlib (ADJUSTED, nessun IB). Start diversi -> outer-join con peso rinormalizzato sugli
|
||||
asset esistenti (come gli sleeve crypto). Finestra lunga: SPY/QQQ/IWM da ~2000; TLT(2016)/GLD(2004)/
|
||||
HYG(2007) entrano dopo. Riporto anche la finestra 6-asset comune (2016+).
|
||||
|
||||
GIUDIZIO: vs SPY buy&hold, vs EW statico (isola il valore del TIMING di trend), vs SPY-trend mono;
|
||||
Sharpe full/pre15/OOS + maxDD + plateau. Causale, netto fee.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import numpy as np, pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
import eqlib
|
||||
from eqlib import load_eq
|
||||
from eq_sector_momentum import _sh, _cagr, _dd, EQ_HOLDOUT, spy_bh
|
||||
from eq_spy_trend import tsmom_exposure, backtest, _series
|
||||
|
||||
ASSETS = ["SPY", "QQQ", "IWM", "TLT", "GLD", "HYG"]
|
||||
|
||||
|
||||
def gated_returns(sym, horizons=(21, 63, 126, 252), fee_side=0.0002, target_vol=None, lev_cap=1.0):
|
||||
"""Rendimenti netti daily di UN asset gestito col proprio trend long-flat (cash quando off)."""
|
||||
px = _series(sym)
|
||||
ex = tsmom_exposure(px, horizons=horizons, target_vol=target_vol, lev_cap=lev_cap)
|
||||
return backtest(px, ex, fee_side=fee_side)
|
||||
|
||||
|
||||
def gtaa(assets=ASSETS, horizons=(21, 63, 126, 252), fee_side=0.0002, target_vol=None):
|
||||
"""Portafoglio GTAA: media (equal-weight) dei rendimenti trend-gated sugli asset disponibili
|
||||
ogni giorno (outer-join). La quota di asset assenti/in-cash resta in cash."""
|
||||
cols = {a: gated_returns(a, horizons, fee_side, target_vol) for a in assets}
|
||||
R = pd.concat(cols, axis=1).sort_index()
|
||||
return R.mean(axis=1, skipna=True) # EW sugli asset esistenti quel giorno
|
||||
|
||||
|
||||
def ew_buyhold(assets=ASSETS):
|
||||
cols = {a: _series(a).pct_change() for a in assets}
|
||||
return pd.concat(cols, axis=1).sort_index().mean(axis=1, skipna=True)
|
||||
|
||||
|
||||
def _row(name, r, common=None, bench=None):
|
||||
r = r.dropna() if common is None else r.reindex(common).fillna(0.0)
|
||||
h = r[r.index >= EQ_HOLDOUT]; ii = r[r.index < EQ_HOLDOUT]
|
||||
tim = float((r != 0).mean()) * 100
|
||||
extra = ""
|
||||
if bench is not None:
|
||||
J = pd.concat({"r": r, "b": bench}, axis=1, join="inner").dropna()
|
||||
extra = f" corrSPY {J['r'].corr(J['b']):+.2f}"
|
||||
print(f" {name:26} CAGR {_cagr(r.values, r.index)*100:>5.1f}% Sh {_sh(r):>5.2f} "
|
||||
f"(pre15 {_sh(ii):>5.2f}|OOS {_sh(h):>5.2f}) maxDD {_dd(r.values)*100:>4.0f}% inMkt {tim:>3.0f}%{extra}")
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 100)
|
||||
print(" EQ-GTAA01 — Trend difensivo MULTI-ASSET (GTAA)")
|
||||
print("=" * 100)
|
||||
spy = spy_bh()
|
||||
g = gtaa() # outer-join, finestra lunga
|
||||
gl = g.dropna()
|
||||
print(f" finestra lunga (outer-join) {gl.index[0].date()}..{gl.index[-1].date()} ({len(gl)}g) OOS {EQ_HOLDOUT.date()}+\n")
|
||||
|
||||
print(" --- BASELINE & confronti (finestra lunga) ---")
|
||||
cl = gl.index
|
||||
_row("SPY buy&hold", spy.reindex(cl).fillna(0))
|
||||
_row("EW statico (no trend)", ew_buyhold().reindex(cl).fillna(0), bench=spy.reindex(cl).fillna(0))
|
||||
_row("SPY-trend mono (TREND01)", backtest(_series("SPY"), tsmom_exposure(_series("SPY"))).reindex(cl).fillna(0), bench=spy.reindex(cl).fillna(0))
|
||||
print("\n --- GTAA (multi-asset trend) ---")
|
||||
_row("GTAA lf", gl, bench=spy.reindex(cl).fillna(0))
|
||||
_row("GTAA lf vt12%", gtaa(target_vol=0.12).reindex(cl).fillna(0), bench=spy.reindex(cl).fillna(0))
|
||||
|
||||
# finestra 6-asset comune (tutti gli ETF esistono): 2016+
|
||||
tlt0 = _series("TLT").index[0]
|
||||
c6 = gl.index[gl.index >= tlt0]
|
||||
print(f"\n --- finestra 6-asset comune ({c6[0].date()}+) ---")
|
||||
_row("SPY buy&hold (6a win)", spy.reindex(c6).fillna(0))
|
||||
_row("GTAA lf (6a win)", g.reindex(c6).fillna(0), bench=spy.reindex(c6).fillna(0))
|
||||
_row("GTAA lf vt12 (6a win)", gtaa(target_vol=0.12).reindex(c6).fillna(0), bench=spy.reindex(c6).fillna(0))
|
||||
|
||||
# MARGINALE vs SPY
|
||||
print("\n --- MARGINALE vs SPY (GTAA lf, finestra lunga) ---")
|
||||
J = pd.concat({"spy": spy, "c": gl}, axis=1, join="inner").dropna(); JH = J[J.index >= EQ_HOLDOUT]
|
||||
print(f" corr full {J['spy'].corr(J['c']):+.3f} | OOS {JH['spy'].corr(JH['c']):+.3f}")
|
||||
for wt in (0.5, 1.0):
|
||||
bf = _sh((1-wt)*J['spy']+wt*J['c'])-_sh(J['spy']); bh = _sh((1-wt)*JH['spy']+wt*JH['c'])-_sh(JH['spy'])
|
||||
lbl = "100% GTAA" if wt == 1.0 else "50/50 SPY/GTAA"
|
||||
print(f" {lbl:16}: uplift Sharpe FULL {bf:+.3f} OOS {bh:+.3f}")
|
||||
print(" DD nei bear (GTAA vs SPY):")
|
||||
for lo, hi, lbl in [("2000-03-01","2002-12-31","dot-com"),("2007-10-01","2009-06-30","GFC"),
|
||||
("2020-02-01","2020-04-30","COVID"),("2022-01-01","2022-12-31","2022")]:
|
||||
seg=lambda s: _dd(s.reindex(cl).fillna(0)[(cl>=pd.Timestamp(lo,tz='UTC'))&(cl<=pd.Timestamp(hi,tz='UTC'))].values)*100
|
||||
print(f" {lbl:8} GTAA {seg(gl):.0f}% | SPY {seg(spy):.0f}%")
|
||||
|
||||
print("\n --- PLATEAU (Sharpe FULL/pre15/OOS, DD, CAGR) GTAA lf, finestra lunga ---")
|
||||
print(f" {'horizons':22} {'FULL':>6} {'pre15':>6} {'OOS':>6} {'DD%':>5} {'CAGR%':>6}")
|
||||
for hz in [(63,126,252),(21,63,126,252),(126,252),(252,)]:
|
||||
r = gtaa(horizons=hz).reindex(cl).fillna(0); h=r[r.index>=EQ_HOLDOUT]; ii=r[r.index<EQ_HOLDOUT]
|
||||
print(f" {'x'.join(map(str,hz)):22} {_sh(r):>6.2f} {_sh(ii):>6.2f} {_sh(h):>6.2f} {_dd(r.values)*100:>5.0f} {_cagr(r.values,r.index)*100:>6.1f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,172 @@
|
||||
"""EQ-MR — "scalping azioni IB quando sottoquotate" + CHECK DATI DALLA RETE. Analisi onesta.
|
||||
|
||||
L'utente chiede: su IB, comprare azioni quando "sottoquotate" (oversold/sotto fair-value), con
|
||||
verifica incrociata dei dati dalla rete. Due pezzi, entrambi in-metodo (la lezione fondante del
|
||||
progetto e' "non fidarti di un feed solo" — il disastro v2.0.0):
|
||||
|
||||
1) CHECK DATI DALLA RETE — confronta i nostri dati certificati IB (data/raw/eq_*_1d.parquet,
|
||||
ADJUSTED_LAST) con una SORGENTE INDIPENDENTE di rete (Yahoo Finance chart API, tokenless).
|
||||
Confronto sui RENDIMENTI giornalieri (invarianti all'aggiustamento) + ultimo close. Verdetto:
|
||||
feed CONCORDE (bps piccoli) o DIVERGENTE. E' il template del pre-trade price-check live.
|
||||
|
||||
2) MEAN-REVERSION "SOTTOQUOTATA" sul daily — lo *scalping* intraday NON e' backtestabile (non
|
||||
abbiamo dati intraday, solo eq_*_1d) ne' eseguibile (vedi sotto), quindi si testa la versione
|
||||
onesta: swing mean-reversion su ETF indice (RSI2 oversold + filtro trend MA200, exit a MA5 =
|
||||
Connors). Causale (segnale <= close[i], entry a close[i]), netto fee, hold-out OOS, per-anno.
|
||||
|
||||
ESEGUIBILITA' (muro, da dire subito):
|
||||
- PDT RULE: il day-trading di azioni US sotto $25.000 e' limitato a 3 day-trade/5gg -> lo scalping
|
||||
e' regolatoriamente BLOCCATO al capitale del progetto. (l'analogo equity del muro STAT-MODE a $600).
|
||||
- Niente dati INTRADAY -> scalping non backtestabile.
|
||||
- IB Gateway in questo ambiente e' instabile (timeout ordini diagnosticato) -> niente HFT affidabile.
|
||||
|
||||
uv run python scripts/research/eq_meanrev_ib.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import requests
|
||||
|
||||
from eqlib import load_eq # type: ignore
|
||||
|
||||
SQ = np.sqrt(252)
|
||||
H = {"User-Agent": "Mozilla/5.0"}
|
||||
|
||||
|
||||
# ----------------------------- 1) CHECK DATI DALLA RETE -----------------------------
|
||||
def yahoo_daily(sym: str, rng: str = "1y") -> pd.DataFrame:
|
||||
u = f"https://query1.finance.yahoo.com/v8/finance/chart/{sym}?range={rng}&interval=1d"
|
||||
j = requests.get(u, headers=H, timeout=30).json()["chart"]["result"][0]
|
||||
ts = pd.to_datetime(j["timestamp"], unit="s", utc=True).normalize()
|
||||
q = j["indicators"]["quote"][0]
|
||||
adj = j["indicators"].get("adjclose", [{}])[0].get("adjclose", q["close"])
|
||||
return pd.DataFrame({"close": q["close"], "adjclose": adj}, index=ts).dropna()
|
||||
|
||||
|
||||
def cross_check(syms):
|
||||
print("=" * 90)
|
||||
print(" 1) CHECK DATI DALLA RETE — IB certificato (eq_*, ADJUSTED) vs Yahoo adjclose (rete indip.)")
|
||||
print("=" * 90)
|
||||
print(f" {'sym':5} {'n com':>6} {'ret maxΔ':>9} {'ret medΔ':>9} {'last IB':>10} {'last YHOO':>10} {'Δbps':>7} esito")
|
||||
for s in syms:
|
||||
try:
|
||||
ib = load_eq(s)["close"].astype(float)
|
||||
ib.index = ib.index.normalize()
|
||||
yh = yahoo_daily(s)["adjclose"] # adjusted vs adjusted (apples-to-apples)
|
||||
J = pd.concat({"ib": ib, "yh": yh}, axis=1, join="inner").dropna().tail(180)
|
||||
if len(J) < 20:
|
||||
print(f" {s:5} overlap insufficiente"); continue
|
||||
R = pd.concat({"a": J["ib"].pct_change(), "b": J["yh"].pct_change()},
|
||||
axis=1, join="inner").dropna()
|
||||
d = (R["a"] - R["b"]).abs()
|
||||
last_bps = abs(J["ib"].iloc[-1] / J["yh"].iloc[-1] - 1) * 1e4
|
||||
ok = d.max() < 0.001 and last_bps < 20 # ret entro 10bps, last entro 20bps
|
||||
print(f" {s:5} {len(J):>6} {d.max()*1e4:>8.1f}b {d.median()*1e4:>8.1f}b "
|
||||
f"{J['ib'].iloc[-1]:>10.2f} {J['yh'].iloc[-1]:>10.2f} {last_bps:>6.1f}b "
|
||||
f"{'CONCORDE' if ok else 'DIVERGENTE -> INDAGARE'}")
|
||||
except Exception as e:
|
||||
print(f" {s:5} ERRORE: {repr(e)[:80]}")
|
||||
print(" -> confronto sui RENDIMENTI adjusted-vs-adjusted: devono combaciare a pochi bps. Una")
|
||||
print(" divergenza non spiegata = feed sospetto, NON tradare prima di averla capita (v2.0.0).")
|
||||
|
||||
|
||||
# ----------------------------- 2) MEAN-REVERSION "SOTTOQUOTATA" -----------------------------
|
||||
def rsi(close: np.ndarray, n: int) -> np.ndarray:
|
||||
d = np.diff(close, prepend=close[0])
|
||||
up = pd.Series(np.where(d > 0, d, 0.0)).ewm(alpha=1 / n, adjust=False).mean().values
|
||||
dn = pd.Series(np.where(d < 0, -d, 0.0)).ewm(alpha=1 / n, adjust=False).mean().values
|
||||
rs = np.divide(up, dn, out=np.full_like(up, np.inf), where=dn > 0)
|
||||
return 100 - 100 / (1 + rs)
|
||||
|
||||
|
||||
def mr_target(close: np.ndarray, entry=10.0, exit_ma=5, trend=200) -> np.ndarray:
|
||||
"""Posizione 0/1 DECISA a close[i] (Connors RSI2): entra se sottoquotata (RSI2<entry) in uptrend
|
||||
(close>MA{trend}); esci quando close>MA{exit_ma}. Causale: usa solo dati <= close[i]."""
|
||||
r2 = rsi(close, 2)
|
||||
ma_t = pd.Series(close).rolling(trend).mean().values
|
||||
ma_x = pd.Series(close).rolling(exit_ma).mean().values
|
||||
pos = np.zeros(len(close)); inpos = False
|
||||
for i in range(len(close)):
|
||||
if not np.isfinite(ma_t[i]):
|
||||
continue
|
||||
if not inpos and close[i] > ma_t[i] and r2[i] < entry:
|
||||
inpos = True
|
||||
elif inpos and close[i] > ma_x[i]:
|
||||
inpos = False
|
||||
pos[i] = 1.0 if inpos else 0.0
|
||||
return pos
|
||||
|
||||
|
||||
def backtest(sym: str, fee_bps=3.0, **kw) -> pd.Series:
|
||||
df = load_eq(sym); close = df["close"].astype(float).values
|
||||
idx = df.index
|
||||
tgt = mr_target(close, **kw)
|
||||
r = np.zeros(len(close)); r[1:] = close[1:] / close[:-1] - 1.0
|
||||
held = np.zeros(len(tgt)); held[1:] = tgt[:-1] # decisa a close[i-1], tenuta in i
|
||||
turn = np.abs(np.diff(held, prepend=0.0))
|
||||
net = held * r - (fee_bps / 1e4) * turn
|
||||
return pd.Series(net, index=idx)
|
||||
|
||||
|
||||
def metrics(daily: pd.Series, lo=None):
|
||||
if lo is not None:
|
||||
daily = daily[daily.index >= lo]
|
||||
rr = daily.values
|
||||
sh = float(np.mean(rr) / np.std(rr) * SQ) if np.std(rr) > 0 else 0.0
|
||||
eq = np.cumprod(1 + rr); pk = np.maximum.accumulate(eq)
|
||||
dd = float(np.max((pk - eq) / pk)) if len(eq) else 0.0
|
||||
yrs = (daily.index[-1] - daily.index[0]).days / 365.25 if len(daily) > 1 else 1.0
|
||||
cagr = eq[-1] ** (1 / yrs) - 1 if yrs > 0 and len(eq) and eq[-1] > 0 else -1.0
|
||||
expo = float((daily != 0).mean())
|
||||
return dict(sharpe=sh, dd=dd, cagr=cagr, expo=expo)
|
||||
|
||||
|
||||
def buyhold(sym, lo=None):
|
||||
df = load_eq(sym); c = df["close"].astype(float)
|
||||
r = c.pct_change().fillna(0.0)
|
||||
return metrics(r, lo=lo)
|
||||
|
||||
|
||||
def run_meanrev(syms, holdout="2015-01-01"):
|
||||
HO = pd.Timestamp(holdout, tz="UTC")
|
||||
print("\n" + "=" * 90)
|
||||
print(f" 2) MEAN-REVERSION 'SOTTOQUOTATA' (RSI2<10 + filtro MA200, exit MA5). Hold-out {holdout}.")
|
||||
print("=" * 90)
|
||||
print(f" {'sym':5} | {'FULL Sh':>7} {'DD':>6} {'CAGR':>6} {'expo':>5} | "
|
||||
f"{'HOLD Sh':>7} {'HOLD CAGR':>9} | {'B&H Sh':>6} {'B&H HOLD':>8}")
|
||||
for s in syms:
|
||||
net = backtest(s)
|
||||
f = metrics(net); h = metrics(net, lo=HO)
|
||||
bh = buyhold(s); bhh = buyhold(s, lo=HO)
|
||||
print(f" {s:5} | {f['sharpe']:>+7.2f} {f['dd']*100:>5.0f}% {f['cagr']*100:>+5.0f}% "
|
||||
f"{f['expo']*100:>4.0f}% | {h['sharpe']:>+7.2f} {h['cagr']*100:>+8.0f}% | "
|
||||
f"{bh['sharpe']:>+6.2f} {bhh['sharpe']:>+7.2f}")
|
||||
print(" expo = % giorni investito. B&H = buy&hold sullo stesso ETF (il benchmark onesto).")
|
||||
|
||||
print("\n Sweep fee (SPY) — quanto regge ai costi reali:")
|
||||
for fb in (0.0, 3.0, 5.0, 10.0):
|
||||
f = metrics(backtest("SPY", fee_bps=fb)); h = metrics(backtest("SPY", fee_bps=fb), lo=HO)
|
||||
print(f" fee {fb:>4.1f}bps RT: FULL Sh {f['sharpe']:+.2f} HOLD Sh {h['sharpe']:+.2f}")
|
||||
|
||||
|
||||
def main():
|
||||
cross_check(["SPY", "QQQ", "IWM", "GLD", "TLT", "HYG"])
|
||||
run_meanrev(["SPY", "QQQ", "IWM", "DIA", "EEM"])
|
||||
print("\n" + "=" * 90)
|
||||
print(" 3) ESEGUIBILITA' (il muro)")
|
||||
print("=" * 90)
|
||||
print(" - PDT RULE: day-trading azioni US < $25k = max 3 day-trade/5gg -> SCALPING BLOCCATO al")
|
||||
print(" capitale del progetto. E' l'analogo equity del muro STAT-MODE a $600 sul crypto.")
|
||||
print(" - Dati INTRADAY assenti (solo eq_*_1d) -> lo scalping non e' backtestabile, solo lo swing MR.")
|
||||
print(" - IB Gateway instabile in questo ambiente (timeout ordini) -> niente HFT affidabile.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,159 @@
|
||||
"""EQ-MOM01 — Momentum cross-sectional settoriale (SPDR), backtest onesto.
|
||||
|
||||
Prima ricerca del fronte equity (branch research/equities-ib). L'edge "noioso e robusto" piu'
|
||||
plausibile in un mercato efficiente: ruotare nei settori a momentum forte. Domanda chiave (come nel
|
||||
crypto col soffitto TP01): NON "fa soldi?" (un long-only equity cavalca il toro) ma **batte/ADDS a
|
||||
SPY buy&hold?** — il baseline vero in equity. Anche vs equal-weight 9 settori (isola il timing del
|
||||
momentum dal tilt equal-weight).
|
||||
|
||||
DATI: cache su disco eq_*.parquet (ADJUSTED div+split), via eqlib (nessun IB). 9 settori CLASSICI
|
||||
dal 1998 (27.5y) per il backtest lungo; 11 settori (2018+) come robustezza.
|
||||
|
||||
COSTRUZIONE (causale): ogni REB giorni, momentum = blend di lookback [63,126,252]g con SKIP recente
|
||||
(12-1 classico), z-score cross-sectional mediato. long-only: full-invested nei top-k (confronto
|
||||
like-for-like con SPY). long-short: dollar-neutral top-k vs bottom-k. Posizione decisa a <= i-1,
|
||||
tenuta da i (W[:-1]*dret[1:]). Netto fee sul turnover. Opz. vol-target.
|
||||
|
||||
GIUDIZIO: standalone (FULL / pre-2015 / hold-out 2015+ / per-anno, CAGR, Sharpe, maxDD) vs SPY e
|
||||
EW-settori; marginale vs SPY (corr, uplift blend full+hold, edge in-sample, persistenza multi-cut);
|
||||
plateau su lookback/k/reb/skip; sweep fee.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import numpy as np, pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
import eqlib
|
||||
from eqlib import panel, load_eq, SECTORS_CLASSIC, SECTORS
|
||||
|
||||
ANN = np.sqrt(252.0)
|
||||
EQ_HOLDOUT = pd.Timestamp("2015-01-01", tz="UTC") # OOS lungo: ultimi ~11 anni (post-GFC, dove il momentum e' decaduto)
|
||||
|
||||
|
||||
def _sh(r):
|
||||
r = np.asarray(pd.Series(r).dropna(), float)
|
||||
return float(np.mean(r) / np.std(r) * ANN) if len(r) > 2 and np.std(r) > 0 else 0.0
|
||||
|
||||
|
||||
def _cagr(r, idx):
|
||||
r = np.asarray(r, float); yrs = (idx[-1] - idx[0]).days / 365.25
|
||||
return float(np.prod(1 + r) ** (1 / yrs) - 1) if yrs > 0 else 0.0
|
||||
|
||||
|
||||
def _dd(r):
|
||||
eq = np.cumprod(1 + np.asarray(r, float)); pk = np.maximum.accumulate(eq)
|
||||
return float(np.max((pk - eq) / pk)) if len(eq) else 0.0
|
||||
|
||||
|
||||
def momentum(universe=tuple(SECTORS_CLASSIC), lookbacks=(63, 126, 252), k=3, reb=21,
|
||||
skip=21, mode="long", target_vol=None, fee_side=0.0002):
|
||||
"""Serie netta daily del book momentum settoriale. mode='long' (top-k full-invested) o
|
||||
'ls' (dollar-neutral top-k vs bottom-k)."""
|
||||
P = panel(universe, how="inner")
|
||||
idx = P.index; px = P.values; n, A = px.shape
|
||||
dret = np.vstack([np.zeros(A), px[1:] / px[:-1] - 1.0])
|
||||
mlb = max(lookbacks) + skip
|
||||
W = np.zeros((n, A)); w = np.zeros(A)
|
||||
for i in range(n):
|
||||
if i >= mlb and i % reb == 0:
|
||||
score = np.zeros(A); cnt = 0
|
||||
for Lb in lookbacks:
|
||||
a, b = i - skip - Lb, i - skip
|
||||
rL = px[b] / px[a] - 1.0
|
||||
sd = rL.std()
|
||||
if sd > 0:
|
||||
score += (rL - rL.mean()) / sd; cnt += 1
|
||||
if cnt:
|
||||
score /= cnt
|
||||
order = np.argsort(score)
|
||||
w = np.zeros(A)
|
||||
if mode == "long":
|
||||
w[order[-k:]] = 1.0 / k # full-invested nei top-k
|
||||
else:
|
||||
w[order[-k:]] = 0.5 / k; w[order[:k]] = -0.5 / k
|
||||
W[i] = w
|
||||
gross = np.zeros(n); gross[1:] = np.sum(W[:-1] * dret[1:], axis=1)
|
||||
turn = np.zeros(n); turn[0] = np.abs(W[0]).sum(); turn[1:] = np.abs(np.diff(W, axis=0)).sum(axis=1)
|
||||
net = gross - turn * fee_side
|
||||
s = pd.Series(net, index=idx)
|
||||
if target_vol:
|
||||
rv = s.rolling(63, min_periods=20).std().shift(1) * ANN
|
||||
scale = np.clip(np.nan_to_num(target_vol / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0)
|
||||
s = pd.Series(s.values * scale, index=idx)
|
||||
return s
|
||||
|
||||
|
||||
def spy_bh():
|
||||
d = load_eq("SPY")["close"].astype(float)
|
||||
return pd.Series(d.values[1:] / d.values[:-1] - 1.0, index=d.index[1:])
|
||||
|
||||
|
||||
def ew_sectors_bh(universe=tuple(SECTORS_CLASSIC)):
|
||||
P = panel(universe, how="inner"); dret = P.pct_change().dropna()
|
||||
return dret.mean(axis=1)
|
||||
|
||||
|
||||
def _line(name, r, idx=None, bench=None):
|
||||
idx = idx if idx is not None else r.index
|
||||
r = r.reindex(idx).fillna(0.0) if hasattr(r, "reindex") else pd.Series(r, index=idx)
|
||||
h = r[r.index >= EQ_HOLDOUT]; isamp = r[r.index < EQ_HOLDOUT]
|
||||
extra = ""
|
||||
if bench is not None:
|
||||
J = pd.concat({"r": r, "b": bench}, axis=1, join="inner").dropna()
|
||||
extra = f" corr_SPY {J['r'].corr(J['b']):+.2f}"
|
||||
print(f" {name:26} CAGR {_cagr(r.values, r.index)*100:>5.1f}% Sh {_sh(r):>5.2f} "
|
||||
f"(pre15 {_sh(isamp):>5.2f} | OOS15+ {_sh(h):>5.2f}) maxDD {_dd(r.values)*100:>4.0f}%{extra}")
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 100)
|
||||
print(" EQ-MOM01 — Momentum cross-sectional settoriale (9 SPDR classici, 1998+)")
|
||||
print("=" * 100)
|
||||
spy = spy_bh(); ew = ew_sectors_bh()
|
||||
base = momentum() # long, lb[63,126,252], k=3, reb21, skip21
|
||||
common = base.index
|
||||
print(f" periodo {common[0].date()}..{common[-1].date()} ({len(common)}g) hold-out OOS = {EQ_HOLDOUT.date()}+\n")
|
||||
|
||||
print(" --- BASELINE da battere ---")
|
||||
_line("SPY buy&hold", spy, common)
|
||||
_line("EW 9 settori buy&hold", ew, common, bench=spy)
|
||||
print("\n --- EQ-MOM01 ---")
|
||||
_line("MOM long top-3", base, common, bench=spy)
|
||||
_line("MOM long top-3 vt15%", momentum(target_vol=0.15), common, bench=spy)
|
||||
_line("MOM long-short top-3", momentum(mode="ls"), common, bench=spy)
|
||||
|
||||
# MARGINALE vs SPY (il test che conta in equity)
|
||||
print("\n --- MARGINALE vs SPY buy&hold (aggiunge al baseline?) ---")
|
||||
J = pd.concat({"spy": spy, "c": base}, axis=1, join="inner").dropna()
|
||||
JH = J[J.index >= EQ_HOLDOUT]
|
||||
print(f" corr(MOM,SPY) full {J['spy'].corr(J['c']):+.3f} | OOS {JH['spy'].corr(JH['c']):+.3f}")
|
||||
for wt in (0.25, 0.5):
|
||||
bf = _sh((1-wt)*J["spy"]+wt*J["c"]) - _sh(J["spy"])
|
||||
bh = _sh((1-wt)*JH["spy"]+wt*JH["c"]) - _sh(JH["spy"])
|
||||
print(f" blend {int((1-wt)*100)}/{int(wt*100)} SPY/MOM: uplift Sharpe FULL {bf:+.3f} OOS {bh:+.3f}")
|
||||
# per-decade (multi-cut onesto)
|
||||
print(" Sharpe MOM per blocco: ", {f"{y}s": round(_sh(base[(base.index.year>=y)&(base.index.year<y+5)]),2)
|
||||
for y in (1999,2004,2009,2014,2019,2024)})
|
||||
|
||||
# PLATEAU
|
||||
print("\n --- PLATEAU (Sharpe FULL / pre15 / OOS15+) long-only ---")
|
||||
print(f" {'cfg':24} {'FULL':>6} {'pre15':>6} {'OOS':>6} {'CAGR%':>6} {'DD%':>5}")
|
||||
for lbs in [(126,), (63,126,252), (252,)]:
|
||||
for k in (2, 3, 4):
|
||||
for reb in (21,):
|
||||
s = momentum(lookbacks=lbs, k=k, reb=reb)
|
||||
tag = f"lb{'-'.join(map(str,lbs))} k{k}"
|
||||
h=s[s.index>=EQ_HOLDOUT]; ii=s[s.index<EQ_HOLDOUT]
|
||||
print(f" {tag:24} {_sh(s):>6.2f} {_sh(ii):>6.2f} {_sh(h):>6.2f} {_cagr(s.values,s.index)*100:>6.1f} {_dd(s.values)*100:>5.0f}")
|
||||
|
||||
# sweep fee + robustezza 11 settori (2018+)
|
||||
print("\n --- ROBUSTEZZA ---")
|
||||
for fee in (0.0, 0.0002, 0.0005, 0.001):
|
||||
s = momentum(fee_side=fee); print(f" fee {fee*100:.2f}%/lato: Sh FULL {_sh(s):.2f} OOS {_sh(s[s.index>=EQ_HOLDOUT]):.2f}")
|
||||
s11 = momentum(universe=tuple(SECTORS)); spy11 = spy.reindex(s11.index)
|
||||
print(f" 11 settori (2018+): MOM Sh {_sh(s11):.2f} vs SPY {_sh(spy11):.2f} (periodo {s11.index[0].date()}+)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,146 @@
|
||||
"""EQ-TREND01 — Trend DIFENSIVO time-series su SPY (analogo equity di TP01).
|
||||
|
||||
Il momentum cross-sectional settoriale e' morto (EQ-MOM01). Ma nel crypto l'unica cosa che ha retto
|
||||
NON era un alpha relative-value: era TP01, un trend DIFENSIVO che taglia il drawdown restando vicino
|
||||
al ritorno. L'equity ha lo stesso buco: SPY buy&hold fa Sharpe ~0.51 ma con maxDD 55% (due bear -50%:
|
||||
2000-02 e 2008-09). Domanda: un trend long-flat su SPY ALZA il Sharpe e DIMEZZA il DD restando
|
||||
investito nei tori? (NON cerchiamo di battere il CAGR — cerchiamo il taglio del rischio, come TP01.)
|
||||
|
||||
DATI: cache su disco eq_spy/eq_tlt (ADJUSTED), via eqlib (nessun IB).
|
||||
COSTRUZIONE (causale, stile TP01): TSMOM multi-orizzonte [21,63,126,252]g (1/3/6/12 mesi); target =
|
||||
frazione di orizzonti in trend-up (0..1, allocazione graduale). Vol-target opz. Posizione decisa a
|
||||
<=i-1, tenuta da i. Netto fee sul turnover. Varianti: long-flat (cash in risk-off), long-bonds
|
||||
(TLT in risk-off, solo dal 2016), e SMA-200 binario (Faber) come riferimento classico.
|
||||
|
||||
GIUDIZIO: vs SPY buy&hold (CAGR/Sharpe full-pre15-OOS15+/maxDD/time-in-market), marginale vs SPY,
|
||||
DD nei due bear storici, plateau (orizzonti/vol-target/cap leva), sweep fee.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import numpy as np, pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
import eqlib
|
||||
from eqlib import load_eq
|
||||
from eq_sector_momentum import _sh, _cagr, _dd, EQ_HOLDOUT, spy_bh
|
||||
|
||||
ANN = np.sqrt(252.0)
|
||||
|
||||
|
||||
def _series(sym):
|
||||
d = load_eq(sym)["close"].astype(float)
|
||||
return pd.Series(d.values, index=d.index)
|
||||
|
||||
|
||||
def tsmom_exposure(close: pd.Series, horizons=(21, 63, 126, 252), target_vol=None,
|
||||
lev_cap=1.0) -> pd.Series:
|
||||
"""Esposizione SPY in [0, lev_cap]: frazione di orizzonti in trend-up, opz. vol-targeted (causale)."""
|
||||
px = close.values; n = len(px); tgt = np.zeros(n)
|
||||
mh = max(horizons)
|
||||
for i in range(mh, n):
|
||||
tgt[i] = np.mean([1.0 if px[i] > px[i - H] else 0.0 for H in horizons])
|
||||
s = pd.Series(tgt, index=close.index)
|
||||
if target_vol:
|
||||
ret = close.pct_change()
|
||||
rv = ret.rolling(63, min_periods=20).std().shift(1) * ANN
|
||||
scale = np.clip(np.nan_to_num(target_vol / rv.replace(0, np.nan).values, nan=0.0), 0, lev_cap / 0.0 if False else 10.0)
|
||||
s = (s * scale).clip(0, lev_cap)
|
||||
else:
|
||||
s = s.clip(0, lev_cap)
|
||||
return s
|
||||
|
||||
|
||||
def sma_timing(close: pd.Series, win=200) -> pd.Series:
|
||||
"""Faber: long se close > SMA(win), altrimenti flat. Binario {0,1}."""
|
||||
sma = close.rolling(win, min_periods=win // 2).mean()
|
||||
return (close > sma).astype(float)
|
||||
|
||||
|
||||
def backtest(close: pd.Series, exposure: pd.Series, risk_off: pd.Series | None = None,
|
||||
fee_side=0.0002) -> pd.Series:
|
||||
"""Rendimenti netti: held = exposure ritardata di 1 (causale). La quota non in SPY (1-held, se
|
||||
exposure<=1) va in risk_off (es. TLT) o cash (0). Fee sul turnover di SPY."""
|
||||
ret = close.pct_change().fillna(0.0).values
|
||||
exp = exposure.reindex(close.index).fillna(0.0).values
|
||||
held = np.zeros(len(exp)); held[1:] = exp[:-1]
|
||||
net = held * ret
|
||||
if risk_off is not None:
|
||||
ro = risk_off.reindex(close.index).pct_change().fillna(0.0).values
|
||||
cash_w = np.clip(1.0 - held, 0.0, 1.0) # quota fuori da SPY -> bonds
|
||||
net = net + cash_w * ro
|
||||
net = net - fee_side * np.abs(np.diff(held, prepend=0.0))
|
||||
return pd.Series(net, index=close.index)
|
||||
|
||||
|
||||
def _row(name, r, common, bench=None):
|
||||
r = r.reindex(common).fillna(0.0)
|
||||
h = r[r.index >= EQ_HOLDOUT]; ii = r[r.index < EQ_HOLDOUT]
|
||||
tim = float((r != 0).mean()) * 100
|
||||
extra = ""
|
||||
if bench is not None:
|
||||
J = pd.concat({"r": r, "b": bench.reindex(common).fillna(0.0)}, axis=1).dropna()
|
||||
extra = f" corr {J['r'].corr(J['b']):+.2f}"
|
||||
print(f" {name:24} CAGR {_cagr(r.values, r.index)*100:>5.1f}% Sh {_sh(r):>5.2f} "
|
||||
f"(pre15 {_sh(ii):>5.2f}|OOS {_sh(h):>5.2f}) maxDD {_dd(r.values)*100:>4.0f}% inMkt {tim:>3.0f}%{extra}")
|
||||
|
||||
|
||||
def _bear_dd(r, common, lo, hi, label):
|
||||
seg = r.reindex(common).fillna(0.0)
|
||||
seg = seg[(seg.index >= pd.Timestamp(lo, tz="UTC")) & (seg.index <= pd.Timestamp(hi, tz="UTC"))]
|
||||
return f"{label}: {_dd(seg.values)*100:.0f}%"
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 100)
|
||||
print(" EQ-TREND01 — Trend DIFENSIVO time-series su SPY (analogo di TP01)")
|
||||
print("=" * 100)
|
||||
spy_px = _series("SPY"); spy = spy_bh()
|
||||
common = spy_px.index[spy_px.index >= spy_px.index[252]] # warmup 1y
|
||||
print(f" periodo {common[0].date()}..{common[-1].date()} ({len(common)}g) OOS = {EQ_HOLDOUT.date()}+\n")
|
||||
|
||||
print(" --- BASELINE ---")
|
||||
_row("SPY buy&hold", spy, common)
|
||||
print("\n --- TREND long-flat (cash in risk-off) ---")
|
||||
_row("SMA-200 (Faber)", backtest(spy_px, sma_timing(spy_px)), common, bench=spy)
|
||||
_row("TSMOM lf cap1.0", backtest(spy_px, tsmom_exposure(spy_px)), common, bench=spy)
|
||||
_row("TSMOM lf vt15 cap1.0", backtest(spy_px, tsmom_exposure(spy_px, target_vol=0.15, lev_cap=1.0)), common, bench=spy)
|
||||
_row("TSMOM lf vt15 cap1.5", backtest(spy_px, tsmom_exposure(spy_px, target_vol=0.15, lev_cap=1.5)), common, bench=spy)
|
||||
|
||||
print("\n --- TREND long-BONDS (TLT in risk-off, solo dove TLT esiste: 2016+) ---")
|
||||
tlt = _series("TLT")
|
||||
cb = spy_px.index[(spy_px.index >= tlt.index[0])]
|
||||
_row("SPY b&h (2016+)", spy.reindex(cb), cb)
|
||||
_row("TSMOM lf+TLT (2016+)", backtest(spy_px, tsmom_exposure(spy_px), risk_off=tlt), cb, bench=spy)
|
||||
|
||||
# MARGINALE vs SPY + DD nei bear
|
||||
base = backtest(spy_px, tsmom_exposure(spy_px))
|
||||
print("\n --- MARGINALE vs SPY (TSMOM lf cap1.0) ---")
|
||||
J = pd.concat({"spy": spy, "c": base}, axis=1, join="inner").dropna(); JH = J[J.index >= EQ_HOLDOUT]
|
||||
print(f" corr full {J['spy'].corr(J['c']):+.3f} | OOS {JH['spy'].corr(JH['c']):+.3f}")
|
||||
for wt in (0.5, 1.0):
|
||||
bf = _sh((1-wt)*J['spy']+wt*J['c']) - _sh(J['spy']); bh = _sh((1-wt)*JH['spy']+wt*JH['c']) - _sh(JH['spy'])
|
||||
lbl = "100% TREND" if wt == 1.0 else f"{int((1-wt)*100)}/{int(wt*100)} SPY/TREND"
|
||||
print(f" {lbl:16}: uplift Sharpe FULL {bf:+.3f} OOS {bh:+.3f}")
|
||||
print(" DD nei bear storici (TSMOM vs SPY):")
|
||||
for lo, hi, lbl in [("2000-03-01","2002-12-31","dot-com"), ("2007-10-01","2009-06-30","GFC"),
|
||||
("2020-02-01","2020-04-30","COVID"), ("2022-01-01","2022-12-31","2022")]:
|
||||
print(f" {lbl:8} TSMOM {_bear_dd(base,common,lo,hi,'')} | SPY {_bear_dd(spy,common,lo,hi,'')}")
|
||||
|
||||
# PLATEAU
|
||||
print("\n --- PLATEAU (Sharpe FULL/pre15/OOS, maxDD, CAGR) long-flat cap1.0 ---")
|
||||
print(f" {'horizons':22} {'FULL':>6} {'pre15':>6} {'OOS':>6} {'DD%':>5} {'CAGR%':>6}")
|
||||
for hz in [(63,126,252),(21,63,126,252),(126,252),(50,200),(200,)]:
|
||||
ex = sma_timing(spy_px, 200) if hz == (200,) else tsmom_exposure(spy_px, horizons=hz)
|
||||
r = backtest(spy_px, ex); h=r[r.index>=EQ_HOLDOUT]; ii=r[r.index<EQ_HOLDOUT]
|
||||
tag = "SMA-200" if hz==(200,) else "x".join(map(str,hz))
|
||||
print(f" {tag:22} {_sh(r):>6.2f} {_sh(ii):>6.2f} {_sh(h):>6.2f} {_dd(r.values)*100:>5.0f} {_cagr(r.values,r.index)*100:>6.1f}")
|
||||
|
||||
print("\n --- SWEEP FEE (TSMOM lf cap1.0) ---")
|
||||
for fee in (0.0, 0.0002, 0.0005, 0.001):
|
||||
r = backtest(spy_px, tsmom_exposure(spy_px), fee_side=fee)
|
||||
print(f" fee {fee*100:.2f}%/lato: Sh {_sh(r):.2f} maxDD {_dd(r.values)*100:.0f}%")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,93 @@
|
||||
"""COMBO DEPLOYABLE — TP01 (Deribit) + GTAA (IB): le DUE gambe realmente eseguibili a basso capitale.
|
||||
|
||||
Il combo crypto-pieno (TP01+XS01+VRP01)+GTAA diversificava (Sharpe 1.6->1.8), ma XS01/VRP01 sono
|
||||
STAT-MODE (non eseguibili a $600). Qui il combo ONESTO/deployable: solo le gambe eseguibili —
|
||||
* TP01: TSMOM difensivo BTC/ETH, long-flat, gia' ARMATO live su Deribit;
|
||||
* GTAA: trend difensivo multi-asset su ETF, eseguibile su IB (frazioni, switch mensile).
|
||||
Domanda: due trend difensivi su mercati diversi, scorrelati, danno un blend migliore di ciascuno?
|
||||
|
||||
ALLINEAMENTO: TP01 e' calendario-giornaliero (crypto 7gg), GTAA giorni di borsa -> compoundo TP01 sul
|
||||
grid dei giorni di borsa (cattura i weekend). Finestra = era TP01 (BTC/ETH dal 2019).
|
||||
Confronto anche col combo crypto-pieno per quantificare il COSTO della deployability.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import numpy as np, pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
from eq_sector_momentum import _sh, _cagr, _dd, EQ_HOLDOUT
|
||||
from eq_gtaa_trend import gtaa
|
||||
from eq_crypto_combo import compound_to_grid, _ann_vol
|
||||
from src.portfolio.sleeves import _tp01_returns, active_sleeves
|
||||
from src.portfolio.portfolio import StrategyPortfolio
|
||||
|
||||
ANN = np.sqrt(252.0)
|
||||
|
||||
|
||||
def _stat(nm, r):
|
||||
print(f" {nm:26} Sh {_sh(r):>5.2f} CAGR {_cagr(r.values,r.index)*100:>5.1f}% "
|
||||
f"volAnn {_ann_vol(r)*100:>4.1f}% maxDD {_dd(r.values)*100:>4.0f}%")
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 96)
|
||||
print(" COMBO DEPLOYABLE — TP01 (Deribit) + GTAA (IB)")
|
||||
print("=" * 96)
|
||||
tp01 = _tp01_returns()
|
||||
if tp01.index.tz is None:
|
||||
tp01.index = tp01.index.tz_localize("UTC")
|
||||
eq = gtaa(target_vol=0.12).dropna()
|
||||
grid = eq.index[eq.index >= tp01.index[0]]
|
||||
tp = compound_to_grid(tp01, grid)
|
||||
J = pd.concat({"tp01": tp, "gtaa": eq.reindex(tp.index)}, axis=1).dropna()
|
||||
print(f" finestra comune {J.index[0].date()}..{J.index[-1].date()} ({len(J)} giorni di borsa, ~{len(J)//252}y)\n")
|
||||
t, g = J["tp01"], J["gtaa"]
|
||||
|
||||
print(" --- STANDALONE (finestra comune) ---")
|
||||
_stat("TP01 (crypto, Deribit)", t)
|
||||
_stat("GTAA vt12 (equity, IB)", g)
|
||||
c = t.corr(g)
|
||||
print(f"\n --- CORRELAZIONE TP01 <-> GTAA = {c:+.3f} ---")
|
||||
|
||||
print("\n --- BLEND (capitale) ---")
|
||||
print(f" {'mix':20} {'Sharpe':>7} {'CAGR%':>6} {'volAnn%':>7} {'maxDD%':>6}")
|
||||
best = (-9, None)
|
||||
for wt in (1.0, 0.75, 0.6, 0.5, 0.4, 0.25, 0.0):
|
||||
b = wt * t + (1 - wt) * g
|
||||
if _sh(b) > best[0]:
|
||||
best = (_sh(b), wt)
|
||||
print(f" TP01 {int(wt*100):>3}/{int((1-wt)*100):<3} GTAA {_sh(b):>7.2f} {_cagr(b.values,b.index)*100:>6.1f} {_ann_vol(b)*100:>7.1f} {_dd(b.values)*100:>6.0f}")
|
||||
|
||||
vt, vg = _ann_vol(t), _ann_vol(g)
|
||||
wt_rp = (1/vt) / (1/vt + 1/vg)
|
||||
b_rp = wt_rp * t + (1 - wt_rp) * g
|
||||
print(f"\n --- RISK-PARITY (inv-vol: TP01 {wt_rp*100:.0f}% / GTAA {(1-wt_rp)*100:.0f}%) ---")
|
||||
_stat("risk-parity blend", b_rp)
|
||||
|
||||
# OOS (post-2023, per dare un taglio fuori dai primi anni TP01)
|
||||
cut = pd.Timestamp("2023-01-01", tz="UTC")
|
||||
print(f"\n --- robustezza: blend 50/50 per taglio ---")
|
||||
b50 = 0.5 * t + 0.5 * g
|
||||
for lbl, sub in (("full", b50), (">=2023", b50[b50.index >= cut])):
|
||||
print(f" {lbl:10} Sh {_sh(sub):.2f} maxDD {_dd(sub.values)*100:.0f}%")
|
||||
print(" Sharpe 50/50 per anno:", {y: round(_sh(b50[b50.index.year == y]), 2) for y in sorted(set(b50.index.year))})
|
||||
|
||||
# VERDETTO + costo della deployability
|
||||
best_solo = max(_sh(t), _sh(g))
|
||||
print(f"\n --- VERDETTO ---")
|
||||
print(f" miglior standalone {best_solo:.2f} | blend 50/50 {_sh(b50):.2f} | risk-parity {_sh(b_rp):.2f} | "
|
||||
f"miglior cap-mix {best[0]:.2f} (TP01 {int(best[1]*100)}%)")
|
||||
print(f" -> {'DIVERSIFICA (blend > solo)' if max(_sh(b50),_sh(b_rp)) > best_solo + 0.03 else 'nessun guadagno netto'}")
|
||||
# costo vs combo crypto-pieno
|
||||
full = StrategyPortfolio(active_sleeves()).combined_daily()
|
||||
if full.index.tz is None: full.index = full.index.tz_localize("UTC")
|
||||
fc = compound_to_grid(full, J.index)
|
||||
Jf = pd.concat({"f": fc, "g": g.reindex(fc.index)}, axis=1).dropna()
|
||||
print(f" costo deployability: blend 50/50 con crypto-PIENO Sh {_sh(0.5*Jf['f']+0.5*Jf['g']):.2f} "
|
||||
f"vs deployable {_sh(b50):.2f} (la differenza = cio' che lasciano XS01/VRP01 STAT-MODE)")
|
||||
print(f" (cross-venue Deribit+IB; entrambe switch mensile/basso turnover; frazionabili a $0.5-2k)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,65 @@
|
||||
"""EQLIB — harness di ricerca EQUITY/ETF (branch research/equities-ib).
|
||||
|
||||
Legge la CACHE su disco (data/raw/eq_*.parquet, ADJUSTED_LAST, scritta una volta da
|
||||
fetch_ib_equities.py) — MAI da IB. `lru_cache` -> ogni parquet si legge una sola volta per processo.
|
||||
"Memorizza i dati per non rileggerli ogni volta": la persistenza e' il parquet su disco + questa cache.
|
||||
|
||||
Espone: universi (SECTORS/BROAD), load_eq(sym), panel(universe) allineato, e riusa lo scorer
|
||||
indurito di altlib (_sh, _dd_ret, _to_daily, marginal_vs_tp01) per giudicare i candidati con la
|
||||
stessa disciplina del lato crypto.
|
||||
"""
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
import numpy as np, pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
RAW = ROOT / "data" / "raw"
|
||||
|
||||
# 11 SPDR settoriali. I 9 "classici" (sotto SECTORS_CLASSIC) partono 1998; XLRE 2015, XLC 2018.
|
||||
SECTORS = ["XLK", "XLF", "XLE", "XLV", "XLI", "XLP", "XLY", "XLU", "XLB", "XLRE", "XLC"]
|
||||
SECTORS_CLASSIC = ["XLK", "XLF", "XLE", "XLV", "XLI", "XLP", "XLY", "XLU", "XLB"] # storia lunga (1998+)
|
||||
BROAD = ["SPY", "QQQ", "IWM", "TLT", "GLD", "HYG"]
|
||||
|
||||
|
||||
@lru_cache(maxsize=64)
|
||||
def load_eq(sym: str) -> pd.DataFrame:
|
||||
"""OHLCV aggiustato (dividendi+split) per `sym`, indicizzato datetime UTC. Cache su disco -> RAM."""
|
||||
p = RAW / f"eq_{sym.lower()}_1d.parquet"
|
||||
if not p.exists():
|
||||
raise FileNotFoundError(f"{p} assente — gira: uv run --with ib_async python scripts/research/fetch_ib_equities.py")
|
||||
d = pd.read_parquet(p).copy()
|
||||
d.index = pd.to_datetime(d["timestamp"], unit="ms", utc=True)
|
||||
return d[["open", "high", "low", "close", "volume"]]
|
||||
|
||||
|
||||
@lru_cache(maxsize=16)
|
||||
def _close_panel(universe: tuple) -> pd.DataFrame:
|
||||
cols = {s: load_eq(s)["close"].astype(float) for s in universe}
|
||||
return pd.concat(cols, axis=1).sort_index()
|
||||
|
||||
|
||||
def panel(universe=tuple(SECTORS), how: str = "inner") -> pd.DataFrame:
|
||||
"""Prezzi close aggiustati [date x asset]. how='inner' = date comuni a TUTTI (start = ETF piu' giovane);
|
||||
'outer' = unione (NaN dove un ETF non esiste ancora)."""
|
||||
P = _close_panel(tuple(universe))
|
||||
return P.dropna(how="any") if how == "inner" else P
|
||||
|
||||
|
||||
def describe(universe=None):
|
||||
universe = universe or (SECTORS + BROAD)
|
||||
print(f" {'sym':6} {'barre':>6} {'da':>11} {'a':>11} {'anni':>5}")
|
||||
for s in universe:
|
||||
try:
|
||||
d = load_eq(s)
|
||||
print(f" {s:6} {len(d):>6} {str(d.index[0].date()):>11} {str(d.index[-1].date()):>11} "
|
||||
f"{(d.index[-1]-d.index[0]).days/365.25:>5.1f}")
|
||||
except FileNotFoundError:
|
||||
print(f" {s:6} (assente)")
|
||||
Pc = panel(SECTORS_CLASSIC); Pa = panel(SECTORS)
|
||||
print(f"\n panel 9 settori CLASSICI: {Pc.shape[1]}x{len(Pc)} start comune {Pc.index[0].date()}")
|
||||
print(f" panel 11 settori : {Pa.shape[1]}x{len(Pa)} start comune {Pa.index[0].date()}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 70); print(" EQLIB — cache equity su disco (nessun IB)"); print("=" * 70)
|
||||
describe()
|
||||
@@ -0,0 +1,86 @@
|
||||
"""DEEP-DIVE: crypto[sessione USA] -> indice EUROPEO[overnight successivo]. Reale o artefatto?
|
||||
|
||||
Lo sweep ha trovato un forte segnale: crypto[T-8h->T] (T~00:00 UTC, fine sessione USA) predice il
|
||||
future europeo (ESTX50/DAX)[T->T+6h] con t_crypto incrementale ~8, Sharpe 2.5, 3/3 anni. Verifiche:
|
||||
(1) ROBUSTEZZA su T (ora entrata): un effetto vero non e' a coltello su una sola ora.
|
||||
(2) vs SEMPRE-LONG: l'overnight ha un drift? il crypto AGGIUNGE oltre il long incondizionato?
|
||||
(3) GAP 1-2h tra fine-segnale e inizio-cattura: se sopravvive, niente contaminazione di bordo.
|
||||
(4) vs FUTURE-OWN: il crypto batte il momentum proprio del future (gia' nel t incrementale).
|
||||
Dati: cache (fut europei + crypto 1h). sqrt(252), net 2bps.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import numpy as np, pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]; RAW = ROOT / "data" / "raw"
|
||||
sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
from crypto_lead_harness import crypto_hourly, at
|
||||
COST = 0.0002
|
||||
|
||||
|
||||
def fut_hourly(sym):
|
||||
d = pd.read_parquet(RAW / f"fut_{sym.lower()}_1h.parquet")
|
||||
return pd.Series(d["close"].astype(float).values, index=pd.to_datetime(d["timestamp"], unit="ms", utc=True)).sort_index()
|
||||
|
||||
|
||||
def _sh(r):
|
||||
r = np.asarray(r, float); r = r[np.isfinite(r)]
|
||||
return float(np.mean(r) / np.std(r) * np.sqrt(252)) if len(r) > 5 and np.std(r) > 0 else 0.0
|
||||
|
||||
|
||||
def series(fut, bc, T, S=8, H=6, gap=0):
|
||||
days = pd.date_range(fut.index[0].normalize(), fut.index[-1].normalize(), freq="D", tz="UTC")
|
||||
rows = []
|
||||
for D in days:
|
||||
te = D + pd.Timedelta(hours=T)
|
||||
f0, f1 = at(fut, te - pd.Timedelta(hours=S)), at(fut, te)
|
||||
fcs, fce = at(fut, te + pd.Timedelta(hours=gap)), at(fut, te + pd.Timedelta(hours=gap + H))
|
||||
c0, c1 = at(bc, te - pd.Timedelta(hours=S)), at(bc, te)
|
||||
if not all(np.isfinite(v) and v > 0 for v in (f0, f1, fcs, fce, c0, c1)):
|
||||
continue
|
||||
rows.append((D, c1/c0 - 1, f1/f0 - 1, fce/fcs - 1))
|
||||
Dd = pd.DataFrame(rows, columns=["d", "csig", "fctrl", "cap"]).set_index("d")
|
||||
return Dd[Dd["cap"].abs() > 1e-9]
|
||||
|
||||
|
||||
def stats(Dd):
|
||||
x, ctrl, y = Dd["csig"].values, Dd["fctrl"].values, Dd["cap"].values
|
||||
def z(a):
|
||||
s = a.std(); return (a - a.mean()) / s if s > 0 else a * 0
|
||||
X = np.column_stack([np.ones(len(y)), z(x), z(ctrl)])
|
||||
beta, *_ = np.linalg.lstsq(X, z(y), rcond=None)
|
||||
resid = z(y) - X @ beta; dof = max(len(y) - 3, 1)
|
||||
se = np.sqrt(np.sum(resid**2)/dof * np.diag(np.linalg.inv(X.T@X)))
|
||||
t_c = float(beta[1]/se[1]) if se[1] > 0 else 0.0
|
||||
crypto = np.sign(x)*y - COST
|
||||
futown = np.sign(ctrl)*y - COST
|
||||
always = y - COST
|
||||
yrs = Dd.index.year.values
|
||||
py = {int(v): round(_sh(crypto[yrs==v]),2) for v in sorted(set(yrs)) if (yrs==v).sum()>=30}
|
||||
return dict(n=len(Dd), t_crypto=round(t_c,2), sh_crypto=round(_sh(crypto),2),
|
||||
sh_futown=round(_sh(futown),2), sh_always=round(_sh(always),2),
|
||||
ann_crypto=round(float(np.nanmean(crypto)*252*100),1), per_year=py)
|
||||
|
||||
|
||||
def main():
|
||||
print("="*96); print(" DEEP-DIVE crypto -> indice EUROPEO overnight (reale o artefatto?)"); print("="*96)
|
||||
bcs = {l: crypto_hourly(l) for l in ("BTC","ETH")}
|
||||
for sym in ("ESTX50","DAX"):
|
||||
fut = fut_hourly(sym)
|
||||
print(f"\n ===== {sym} =====")
|
||||
print(f" (1) ROBUSTEZZA su T (lead=BTC, S=8h H=6h gap=0): t_crypto | Sh crypto vs futown vs always-long")
|
||||
for T in (20,21,22,23,0,1,2,3,4):
|
||||
s = stats(series(fut, bcs["BTC"], T))
|
||||
print(f" T={T:>2}h: n={s['n']} t {s['t_crypto']:+.1f} | crypto {s['sh_crypto']:+.2f} futown {s['sh_futown']:+.2f} always {s['sh_always']:+.2f} ann {s['ann_crypto']:+.1f}% {s['per_year']}")
|
||||
print(f" (3) GAP test (T=0h, cattura inizia +1h e +2h dopo il segnale):")
|
||||
for g in (0,1,2):
|
||||
s = stats(series(fut, bcs["BTC"], 0, gap=g))
|
||||
print(f" gap={g}h: t {s['t_crypto']:+.1f} | crypto {s['sh_crypto']:+.2f} always {s['sh_always']:+.2f}")
|
||||
print(f" (lead ETH, T=0h): ", end="")
|
||||
s = stats(series(fut, bcs["ETH"], 0)); print(f"t {s['t_crypto']:+.1f} crypto {s['sh_crypto']:+.2f} always {s['sh_always']:+.2f} {s['per_year']}")
|
||||
print("\n LETTURA: se 'crypto' >> 'always' E >> 'futown' E regge su T e col gap -> lead REALE (crypto")
|
||||
print(" anticipa il catch-up europeo). Se crypto ~ always -> e' solo overnight-drift europeo.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,137 @@
|
||||
"""FETCH + CERTIFY funding rate Hyperliquid (API pubblica, tokenless) — per ricerca CARRY cross-sectional.
|
||||
|
||||
CONTESTO (2026-06-22, onda "nuova ricerca mirata"). Le due grandi ondate (sweep 104-ipotesi +
|
||||
ortho relative-value) hanno esaurito gli angoli DIREZIONALI e RELATIVE-VALUE sul *prezzo* BTC/ETH.
|
||||
L'unico meccanismo con una fonte di ritorno DIVERSA non ancora testato su dati certi e' il CARRY da
|
||||
funding (incassare il cashflow perp, delta-neutral). Scan di fattibilita':
|
||||
* funding price-clock sul feed Deribit certificato -> gia' testato (agent_03 intraday) = FAIL.
|
||||
* funding carry su Deribit (dove eseguiamo) -> ccxt fetch_funding_rate_history = 0 righe (bloccato).
|
||||
* funding carry su Hyperliquid -> API pubblica /info {"type":"fundingHistory"} = DISPONIBILE,
|
||||
cadenza ORARIA, tokenless, serie native dal 2023-05-12. HL e' gia' l'universo certificato di XS01.
|
||||
|
||||
DISCIPLINA (lezione v2.0.0): nessuna fiducia nel dato finche' non e' certificato. Qui certifichiamo:
|
||||
(1) cadenza ~1h coerente, (2) gap interni, (3) copertura (giorni nativi reali per coin),
|
||||
(4) plausibilita' magnitudine (|funding orario| tipico < ~0.06%/h = cap HL; outlier flaggati),
|
||||
(5) il funding e' un CASHFLOW, non un prezzo -> niente cross-venue OHLC; il sanity check e'
|
||||
che il funding medio sia ~positivo e piccolo (premio long-pays-short tipico dei perp crypto).
|
||||
|
||||
Universo = i 19 major di XS01 (quelli che la strategia live userebbe). Output:
|
||||
data/raw/hlfund_<sym>_1h.parquet (namespace dedicato 'hlfund', NON tocca hl_<sym>_1d di XS01).
|
||||
"""
|
||||
import sys, time, datetime as dt
|
||||
from pathlib import Path
|
||||
import numpy as np, pandas as pd, requests
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
RAW = ROOT / "data" / "raw"
|
||||
RAW.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 19 major di XS01 (CLAUDE.md)
|
||||
UNIVERSE = ["BTC","ETH","SOL","BNB","XRP","DOGE","AVAX","LINK","LTC","ADA",
|
||||
"ARB","OP","SUI","APT","INJ","TIA","SEI","NEAR","AAVE"]
|
||||
|
||||
HL_INFO = "https://api.hyperliquid.xyz/info"
|
||||
START = int(dt.datetime(2023, 1, 1, tzinfo=dt.timezone.utc).timestamp() * 1000)
|
||||
HOUR_MS = 3600 * 1000
|
||||
|
||||
|
||||
def _post(payload, max_retry=6):
|
||||
"""POST con backoff esponenziale su 429/5xx (l'API pubblica HL throttla)."""
|
||||
delay = 1.0
|
||||
for attempt in range(max_retry):
|
||||
r = requests.post(HL_INFO, json=payload, timeout=30)
|
||||
if r.status_code == 429 or r.status_code >= 500:
|
||||
time.sleep(delay)
|
||||
delay = min(delay * 2, 20) # 1,2,4,8,16,20
|
||||
continue
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def fetch_funding(coin: str) -> pd.DataFrame:
|
||||
"""Pagina fundingHistory (max 500/req, orario) avanzando startTime fino a oggi."""
|
||||
rows, start = [], START
|
||||
seen = set()
|
||||
while True:
|
||||
d = _post({"type": "fundingHistory", "coin": coin, "startTime": start})
|
||||
if not d:
|
||||
break
|
||||
new = [x for x in d if x["time"] not in seen]
|
||||
for x in new:
|
||||
seen.add(x["time"])
|
||||
rows.append((x["time"], float(x["fundingRate"]), float(x.get("premium", "nan"))))
|
||||
last = d[-1]["time"]
|
||||
if len(d) < 500: # ultima pagina
|
||||
break
|
||||
nxt = last + 1
|
||||
if nxt <= start: # niente progresso -> stop
|
||||
break
|
||||
start = nxt
|
||||
time.sleep(0.35) # gentile con l'API pubblica
|
||||
if not rows:
|
||||
return pd.DataFrame(columns=["ts", "funding", "premium"]).set_index("ts")
|
||||
df = pd.DataFrame(rows, columns=["ts", "funding", "premium"]).drop_duplicates("ts").sort_values("ts")
|
||||
df["ts"] = pd.to_datetime(df["ts"], unit="ms", utc=True)
|
||||
return df.set_index("ts")
|
||||
|
||||
|
||||
def certify(coin: str, df: pd.DataFrame) -> dict:
|
||||
if df.empty:
|
||||
return {"coin": coin, "n": 0, "status": "VUOTO"}
|
||||
idx = df.index
|
||||
span_days = (idx[-1] - idx[0]).total_seconds() / 86400
|
||||
# cadenza: differenze in ore
|
||||
deltas_h = np.diff(idx.view("int64")) / 1e9 / 3600
|
||||
median_dt = float(np.median(deltas_h)) if len(deltas_h) else float("nan")
|
||||
gaps = int((deltas_h > 1.5).sum()) # buchi > 1.5h
|
||||
expected = int(round(span_days * 24)) + 1
|
||||
coverage = len(df) / expected if expected else float("nan")
|
||||
f = df["funding"].values
|
||||
# statistiche funding (orario)
|
||||
ann = float(np.nanmean(f)) * 24 * 365 # funding annualizzato (carry teorico per chi paga)
|
||||
cap_hits = int((np.abs(f) > 0.0006).sum()) # cap HL ~0.06%/h (4%/8h clamp); fuori = sospetto
|
||||
status = "OK"
|
||||
if coverage < 0.97 or gaps > 50:
|
||||
status = "GAP"
|
||||
if span_days < 365:
|
||||
status = "corto<365g"
|
||||
return {"coin": coin, "n": len(df), "primo": idx[0].date(), "ultimo": idx[-1].date(),
|
||||
"giorni": round(span_days), "cad_h": round(median_dt, 3), "gap>1.5h": gaps,
|
||||
"cover%": round(coverage * 100, 1), "fund_med_bps": round(float(np.nanmedian(f)) * 1e4, 4),
|
||||
"fund_ann%": round(ann * 100, 1), "cap_hit": cap_hits, "status": status}
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 100)
|
||||
print(" FETCH + CERTIFY funding Hyperliquid (orario, tokenless) — 19 major XS01 -> data/raw/hlfund_*")
|
||||
print("=" * 100)
|
||||
rep = []
|
||||
for sym in UNIVERSE:
|
||||
try:
|
||||
df = fetch_funding(sym)
|
||||
except Exception as e:
|
||||
print(f" {sym:5} ERR {repr(e)[:80]}")
|
||||
rep.append({"coin": sym, "n": 0, "status": "ERR"})
|
||||
continue
|
||||
c = certify(sym, df)
|
||||
rep.append(c)
|
||||
if c.get("n", 0) > 0:
|
||||
out = RAW / f"hlfund_{sym.lower()}_1h.parquet"
|
||||
df.to_parquet(out)
|
||||
print(f" {sym:5} n={c.get('n',0):>6} {str(c.get('primo','')):>10}->{str(c.get('ultimo','')):>10} "
|
||||
f"cad={c.get('cad_h','?')}h gap={c.get('gap>1.5h','?')} cov={c.get('cover%','?')}% "
|
||||
f"med={c.get('fund_med_bps','?')}bps ann={c.get('fund_ann%','?')}% cap_hit={c.get('cap_hit','?')} "
|
||||
f"[{c['status']}]")
|
||||
ok = [r["coin"] for r in rep if r.get("status") in ("OK",)]
|
||||
short = [r["coin"] for r in rep if r.get("status") == "corto<365g"]
|
||||
print("-" * 100)
|
||||
print(f" CERTIFICATI OK ({len(ok)}): {ok}")
|
||||
if short:
|
||||
print(f" CORTI <365g ({len(short)}): {short}")
|
||||
print(f" Scritti in data/raw/hlfund_<sym>_1h.parquet (funding ORARIO, serie nativa).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,134 @@
|
||||
"""FETCH + CERTIFY universo azioni/ETF da IB (ADJUSTED_LAST) -> data/raw/eq_<sym>_1d.parquet.
|
||||
|
||||
Apre il fronte EQUITY (branch research/equities-ib). Disciplina v2.0.0: PRIMA il dato certificato,
|
||||
POI la strategia. IB dà storia daily aggiustata per dividendi+split (ADJUSTED_LAST), profonda
|
||||
(SPY dal 1996), sul conto paper. Namespace dedicato 'eq_' (NON tocca i parquet crypto).
|
||||
|
||||
UNIVERSO (prima ricerca = momentum cross-sectional settoriale, l'edge robusto plausibile in equity):
|
||||
* 11 SPDR settoriali (XLK..XLC); * broad/macro SPY QQQ IWM TLT GLD HYG.
|
||||
NB: i 9 settori "classici" partono 1998; XLRE 2015, XLC 2018 -> lo start COMUNE a 11 e' 2018.
|
||||
Per backtest lunghi usare i 9 classici (1998+) o accettare lo start 2018 per gli 11.
|
||||
|
||||
CERTIFICAZIONE (gemello equity di certify_feed.py):
|
||||
(1) integrità: barre, range, date monotone, duplicati, flat bars (close invariato);
|
||||
(2) gap: run di giorni-lavorativi mancanti > 5 (festivi normali, buchi lunghi = sospetti);
|
||||
(3) sanità ritorni: max |daily ret| (un >50% non-evento = errore di adjustment);
|
||||
(4) sanità adjustment: primo close aggiustato << ultimo (i dividendi abbassano lo storico).
|
||||
PREREQUISITO: gateway IB paper su 127.0.0.1:4002 (docker compose up -d ib-gateway).
|
||||
|
||||
uv run --with ib_async python scripts/research/fetch_ib_equities.py
|
||||
"""
|
||||
import sys, time
|
||||
from pathlib import Path
|
||||
import numpy as np, pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
RAW = ROOT / "data" / "raw"
|
||||
RAW.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
SECTORS = ["XLK", "XLF", "XLE", "XLV", "XLI", "XLP", "XLY", "XLU", "XLB", "XLRE", "XLC"]
|
||||
BROAD = ["SPY", "QQQ", "IWM", "TLT", "GLD", "HYG"]
|
||||
# espansione "diversi mercati" (intl / bond / credito / commodity / settori extra) per il lead-lag crypto
|
||||
BROAD2 = ["DIA", "EFA", "EEM", "FXI", "EWJ", "AGG", "LQD", "IEF", "USO", "SLV", "DBC", "VNQ"]
|
||||
UNIVERSE = SECTORS + BROAD + BROAD2
|
||||
|
||||
|
||||
def certify(sym: str, df: pd.DataFrame) -> dict:
|
||||
if df.empty:
|
||||
return {"sym": sym, "n": 0, "status": "VUOTO"}
|
||||
idx = df.index
|
||||
dup = int(idx.duplicated().sum())
|
||||
mono = bool(idx.is_monotonic_increasing)
|
||||
c = df["close"].values.astype(float)
|
||||
ret = np.diff(c) / c[:-1]
|
||||
flat = int((ret == 0).sum())
|
||||
maxret = float(np.max(np.abs(ret))) if len(ret) else 0.0
|
||||
# gap: giorni lavorativi attesi vs presenti, run lunghi mancanti
|
||||
bdays = pd.bdate_range(idx[0], idx[-1])
|
||||
missing = len(bdays) - len(idx.intersection(bdays))
|
||||
gaps = bdays.difference(idx)
|
||||
longgap = 0
|
||||
if len(gaps):
|
||||
g = pd.Series(1, index=gaps).resample("1D").sum().fillna(0)
|
||||
# conta run consecutivi di bday mancanti
|
||||
s = (gaps.to_series().diff().dt.days.fillna(1) > 3).cumsum()
|
||||
longgap = int((gaps.to_series().groupby(s).size() > 5).sum())
|
||||
span_y = (idx[-1] - idx[0]).days / 365.25
|
||||
adj_ratio = round(float(c[0] / c[-1]), 3) # primo/ultimo: <1 atteso (storico abbassato dai div)
|
||||
status = "OK"
|
||||
if dup or not mono:
|
||||
status = "INTEGRITA'"
|
||||
elif maxret > 0.5:
|
||||
status = "SPIKE?"
|
||||
elif longgap > 0:
|
||||
status = "GAP-LUNGO"
|
||||
elif span_y < 1:
|
||||
status = "corto<1y"
|
||||
return {"sym": sym, "n": len(df), "primo": idx[0].date(), "ultimo": idx[-1].date(),
|
||||
"anni": round(span_y, 1), "dup": dup, "mono": mono, "flat": flat,
|
||||
"maxret%": round(maxret * 100, 1), "miss_bd": missing, "gap_lunghi": longgap,
|
||||
"adj_first/last": adj_ratio, "status": status}
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
from ib_async import IB, Stock
|
||||
except Exception:
|
||||
print("ib_async assente. Esegui con: uv run --with ib_async python scripts/research/fetch_ib_equities.py")
|
||||
sys.exit(2)
|
||||
ib = IB()
|
||||
try:
|
||||
ib.connect("127.0.0.1", 4002, clientId=90, timeout=15)
|
||||
except Exception as e:
|
||||
print(f"[CONNESSIONE FALLITA] 127.0.0.1:4002 -> {repr(e)[:120]}\n Avvia: docker compose up -d ib-gateway")
|
||||
sys.exit(1)
|
||||
|
||||
print("=" * 104)
|
||||
print(f" FETCH + CERTIFY azioni/ETF (ADJUSTED_LAST) -> data/raw/eq_* | acct {ib.managedAccounts()}")
|
||||
print("=" * 104)
|
||||
rep, ok = [], []
|
||||
force = "--force" in sys.argv[1:]
|
||||
universe = UNIVERSE
|
||||
if "--only" in sys.argv[1:]: # refresh mirato (es. solo i 6 ETF GTAA per il cron)
|
||||
universe = sys.argv[sys.argv.index("--only") + 1].upper().split(",")
|
||||
force = True # --only implica refresh dei simboli indicati
|
||||
for sym in universe:
|
||||
out_path = RAW / f"eq_{sym.lower()}_1d.parquet"
|
||||
if out_path.exists() and not force:
|
||||
print(f" {sym:5} GIA' SU DISCO -> skip (usa --force per riscaricare)")
|
||||
ok.append(sym)
|
||||
continue
|
||||
con = Stock(sym, "SMART", "USD")
|
||||
try:
|
||||
bars = ib.reqHistoricalData(con, endDateTime="", durationStr="30 Y", barSizeSetting="1 day",
|
||||
whatToShow="ADJUSTED_LAST", useRTH=True, formatDate=1, timeout=60)
|
||||
except Exception as e:
|
||||
print(f" {sym:5} ERR {repr(e)[:70]}"); rep.append({"sym": sym, "status": "ERR"}); time.sleep(1.2); continue
|
||||
if not bars:
|
||||
print(f" {sym:5} 0 barre (subscription?)"); rep.append({"sym": sym, "n": 0, "status": "VUOTO"}); time.sleep(1.2); continue
|
||||
df = pd.DataFrame([(pd.Timestamp(str(b.date)), b.open, b.high, b.low, b.close, b.volume) for b in bars],
|
||||
columns=["ts", "open", "high", "low", "close", "volume"]).set_index("ts").sort_index()
|
||||
c = certify(sym, df)
|
||||
rep.append(c)
|
||||
if c.get("n", 0) > 0:
|
||||
out = df.copy()
|
||||
# ms epoch (come i parquet crypto), robusto alla risoluzione datetime64 (s/us/ns)
|
||||
out["timestamp"] = out.index.astype("datetime64[ms]").astype("int64")
|
||||
out.reset_index(drop=True).to_parquet(RAW / f"eq_{sym.lower()}_1d.parquet")
|
||||
if c["status"] == "OK":
|
||||
ok.append(sym)
|
||||
print(f" {sym:5} n={c.get('n',0):>5} {str(c.get('primo','')):>10}->{str(c.get('ultimo',''))} "
|
||||
f"{c.get('anni','?')}y flat={c.get('flat','?')} maxret={c.get('maxret%','?')}% "
|
||||
f"miss_bd={c.get('miss_bd','?')} gapL={c.get('gap_lunghi','?')} adj={c.get('adj_first/last','?')} [{c['status']}]")
|
||||
time.sleep(1.2) # pacing IB
|
||||
|
||||
print("-" * 104)
|
||||
print(f" CERTIFICATI OK ({len(ok)}/{len(UNIVERSE)}): {ok}")
|
||||
sec_ok = [s for s in SECTORS if s in ok]
|
||||
print(f" settori OK: {len(sec_ok)}/11 {sec_ok}")
|
||||
print(f" -> scritti in data/raw/eq_<sym>_1d.parquet (ADJUSTED_LAST, namespace dedicato).")
|
||||
ib.disconnect()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,64 @@
|
||||
"""FETCH futures indice ORARI (ES/NQ/RTY) da IB -> data/raw/fut_<sym>_1h.parquet (UTC).
|
||||
|
||||
Per il test onesto dell'idea "monitor Deribit / trade IB": serve il path INTRADAY del future indice
|
||||
(che si trada di notte) per misurare finestre overnight NON sovrapposte col segnale crypto.
|
||||
ContFuture orario, in chunk da 1 anno (IB limita le durate intraday). Convertito in UTC.
|
||||
Resumable (salta i parquet gia' scritti). Per RETURNS/lead-lag il back-adjust del ContFuture e' ok
|
||||
(i ritorni infra-contratto sono preservati; i gap di roll ~4/anno sono trascurabili).
|
||||
|
||||
uv run --with ib_async python scripts/research/fetch_ib_futures.py
|
||||
"""
|
||||
import sys, time
|
||||
from pathlib import Path
|
||||
import numpy as np, pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
RAW = ROOT / "data" / "raw"
|
||||
# sym -> exchange (indici US + esteri + commodity + bond per la ricerca cross-mercato oltre SP500)
|
||||
SYMS = {"ES": "CME", "NQ": "CME", "RTY": "CME",
|
||||
"GC": "COMEX", "CL": "NYMEX", "HG": "COMEX", "ZN": "CBOT",
|
||||
"ESTX50": "EUREX", "DAX": "EUREX", "NKD": "CME"}
|
||||
N_CHUNKS = 5 # (non usato: ContFuture non accetta endDateTime -> chiamata singola)
|
||||
|
||||
|
||||
def main():
|
||||
from ib_async import IB, ContFuture
|
||||
ib = IB()
|
||||
try:
|
||||
ib.connect("127.0.0.1", 4002, clientId=144, timeout=15)
|
||||
except Exception as e:
|
||||
print(f"[CONNESSIONE FALLITA] {repr(e)[:100]}"); sys.exit(1)
|
||||
print(f" acct {ib.managedAccounts()} | fetch futures orari -> data/raw/fut_*")
|
||||
for sym, exc in SYMS.items():
|
||||
out = RAW / f"fut_{sym.lower()}_1h.parquet"
|
||||
if out.exists():
|
||||
print(f" {sym}: gia' su disco -> skip"); continue
|
||||
cf = ContFuture(sym, exchange=exc)
|
||||
try:
|
||||
ib.qualifyContracts(cf)
|
||||
except Exception as e:
|
||||
print(f" {sym}: qualify ERR {repr(e)[:60]}"); continue
|
||||
# ContFuture NON accetta endDateTime (Error 10339) -> chiamata singola, durata massima (~3y orari)
|
||||
try:
|
||||
b = ib.reqHistoricalData(cf, endDateTime="", durationStr="4 Y", barSizeSetting="1 hour",
|
||||
whatToShow="TRADES", useRTH=False, formatDate=1, timeout=150)
|
||||
except Exception as e:
|
||||
print(f" {sym}: ERR {repr(e)[:60]}"); continue
|
||||
if not b:
|
||||
print(f" {sym}: VUOTO"); continue
|
||||
D = pd.DataFrame([(pd.Timestamp(x.date), x.open, x.high, x.low, x.close, x.volume) for x in b],
|
||||
columns=["ts", "open", "high", "low", "close", "volume"]).drop_duplicates("ts").sort_values("ts").reset_index(drop=True)
|
||||
# -> UTC ms (robusto alla risoluzione us/ns: naive-UTC -> datetime64[ms] -> int64)
|
||||
ts = pd.to_datetime(D["ts"], utc=True).dt.tz_convert("UTC").dt.tz_localize(None)
|
||||
D["timestamp"] = ts.values.astype("datetime64[ms]").astype("int64")
|
||||
D = D.drop(columns=["ts"])
|
||||
D.to_parquet(out)
|
||||
u = pd.to_datetime(D["timestamp"], unit="ms", utc=True)
|
||||
print(f" {sym}: SCRITTO {len(D)} barre {u.iloc[0]} .. {u.iloc[-1]} -> {out.name}")
|
||||
time.sleep(1.5)
|
||||
ib.disconnect()
|
||||
print(" done.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,170 @@
|
||||
"""FC01 — Funding-CARRY cross-sectional su Hyperliquid (backtest onesto, STAT-MODE).
|
||||
|
||||
IPOTESI. Il funding dei perp e' un CASHFLOW (long pagano short quando f>0). Un book
|
||||
dollar-neutral che VENDE i perp ad alto funding e COMPRA quelli a basso funding INCASSA
|
||||
il premio di funding (carry). E' una fonte di ritorno DIVERSA dal trend (TP01) e — forse —
|
||||
dal momentum cross-sectional (XS01). Domanda chiave: e' un edge reale o solo XS01 travestito?
|
||||
(gli asset ad alto funding sono spesso quelli pompati = forti = quelli che XS01 COMPRA; qui li
|
||||
SHORTIAMO -> potenziale ANTI-correlazione con XS01, oppure il carry domina).
|
||||
|
||||
DATI (certificati). funding orario reale HL (scripts/research/fetch_hl_funding.py, dal 2023-05) +
|
||||
prezzi HL 1d (data/raw/hl_*_1d.parquet, gli stessi di XS01). Universo = i 19 major di XS01.
|
||||
|
||||
COSTRUZIONE (causale, come XS01). Ogni H giorni: segnale = media causale del funding giornaliero
|
||||
realizzato sugli ultimi L giorni (solo dati <= i-1). Rank cross-section; SHORT i k ad alto funding,
|
||||
LONG i k a basso (dollar-neutral). Ritorno del perp per un LONG = price_ret - funding (chi e' long
|
||||
paga il funding); quindi r_book[i] = sum_a w[i-1,a] * (price_ret[i,a] - funding_realizzato[i,a]),
|
||||
meno fee sul turnover (0.05%/lato, come XS01), poi vol-target 20%.
|
||||
|
||||
GIUDIZIO (metodologia indurita). Standalone (FULL/in-sample/hold-out, DD, anni) + correlazione a
|
||||
TP01/XS01/VRP01 + marginal_vs_tp01 (gate: edge in-sample>=0.5, persistenza multi-cut, hedge-vs-alpha,
|
||||
noise-null) + UPLIFT vs XS01 (la domanda di overlap) + plateau su L/k/direzione.
|
||||
|
||||
CAVEAT ONESTO PRE-RISULTATO: e' market-neutral a 10 gambe -> NON eseguibile a $600 (STAT-MODE come
|
||||
XS01/VRP01). Il deliverable e' "esiste un carry reale e ORTOGONALE a XS01?", non un deploy.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import numpy as np, pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
|
||||
|
||||
from src.portfolio.sleeves import XS_UNIVERSE, _xsec_returns, _vrp_combo_returns, _HL_DIR
|
||||
import altlib as L
|
||||
from altlib import _sh, _dd_ret, _to_daily, _uplift_series, HOLDOUT, marginal_vs_tp01
|
||||
|
||||
FEE_SIDE = 0.0005 # 0.10% RT / 2, come XS01
|
||||
|
||||
|
||||
def _load_panel():
|
||||
"""Ritorna (PX, FUND): due DataFrame daily allineati [date x asset] con prezzo e funding
|
||||
giornaliero realizzato (somma oraria). Solo asset con ENTRAMBI i dati."""
|
||||
px, fund = {}, {}
|
||||
for sym in XS_UNIVERSE:
|
||||
pp = _HL_DIR / f"hl_{sym.lower()}_1d.parquet"
|
||||
fp = _HL_DIR / f"hlfund_{sym.lower()}_1h.parquet"
|
||||
if not (pp.exists() and fp.exists()):
|
||||
continue
|
||||
dp = pd.read_parquet(pp)
|
||||
px[sym] = pd.Series(dp["close"].values.astype(float),
|
||||
index=pd.to_datetime(dp["timestamp"], unit="ms", utc=True)).resample("1D").last()
|
||||
df = pd.read_parquet(fp) # index ts (orario, utc), col funding
|
||||
fund[sym] = df["funding"].resample("1D").sum()
|
||||
PX = pd.concat(px, axis=1).sort_index()
|
||||
FUND = pd.concat(fund, axis=1).sort_index().reindex(PX.index)
|
||||
# tieni solo le date con prezzi per tutti + funding noto (0 dove manca un giorno isolato)
|
||||
common = PX.dropna(how="any").index.intersection(FUND.index)
|
||||
return PX.loc[common], FUND.loc[common].fillna(0.0)
|
||||
|
||||
|
||||
def carry_returns(L_lb=7, H=10, k=5, direction="carry", target_vol=0.20,
|
||||
fee_side=FEE_SIDE) -> pd.Series:
|
||||
"""Serie daily netta del book funding-carry cross-sectional. direction='carry' shorta l'alto
|
||||
funding (incassa il premio); 'anti' lo compra."""
|
||||
PX, FUND = _load_panel()
|
||||
idx = PX.index
|
||||
px = PX.values; fnd = FUND.values
|
||||
n, A = px.shape
|
||||
dret = np.vstack([np.zeros(A), px[1:] / px[:-1] - 1.0])
|
||||
# segnale = media causale del funding giornaliero sugli ultimi L giorni (shiftato di 1)
|
||||
sig = pd.DataFrame(fnd, index=idx).rolling(L_lb, min_periods=max(3, L_lb // 2)).mean().shift(1).values
|
||||
W = np.zeros((n, A)); w = np.zeros(A)
|
||||
for i in range(n):
|
||||
if i >= L_lb + 1 and i % H == 0 and np.isfinite(sig[i]).sum() >= 2 * k:
|
||||
s = sig[i].copy()
|
||||
order = np.argsort(np.where(np.isfinite(s), s, np.nan))
|
||||
order = order[np.isfinite(s[order])]
|
||||
if len(order) >= 2 * k:
|
||||
w = np.zeros(A)
|
||||
lo, hi = order[:k], order[-k:] # lo=basso funding, hi=alto funding
|
||||
if direction == "carry":
|
||||
w[hi] = -0.5 / k; w[lo] = +0.5 / k # SHORT alto funding (incassa), LONG basso
|
||||
else:
|
||||
w[hi] = +0.5 / k; w[lo] = -0.5 / k
|
||||
W[i] = w
|
||||
# ritorno del perp per un LONG = price_ret - funding realizzato
|
||||
perp_ret = dret - fnd
|
||||
gross = np.zeros(n); gross[1:] = np.sum(W[:-1] * perp_ret[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_side
|
||||
s = pd.Series(net, index=idx)
|
||||
rv = s.rolling(30, min_periods=15).std().shift(1) * np.sqrt(365.25)
|
||||
scale = np.clip(np.nan_to_num(target_vol / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0)
|
||||
return pd.Series(s.values * scale, index=idx)
|
||||
|
||||
|
||||
def _stats(s: pd.Series) -> dict:
|
||||
s = _to_daily(s)
|
||||
h = s[s.index >= HOLDOUT]; isamp = s[s.index < HOLDOUT]
|
||||
yrs = {y: round(_sh(s[s.index.year == y]), 2) for y in sorted(set(s.index.year))}
|
||||
return dict(n=len(s), full=round(_sh(s), 2), insample=round(_sh(isamp), 2) if len(isamp) > 30 else None,
|
||||
hold=round(_sh(h), 2) if len(h) > 30 else None, dd=round(_dd_ret(s), 3),
|
||||
ann_ret=round(float(s.mean() * 365.25), 3), yearly=yrs)
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 96)
|
||||
print(" FC01 — FUNDING-CARRY cross-sectional su Hyperliquid (STAT-MODE)")
|
||||
print("=" * 96)
|
||||
PX, FUND = _load_panel()
|
||||
print(f" panel: {PX.shape[1]} asset x {len(PX)} giorni [{PX.index[0].date()} .. {PX.index[-1].date()}]")
|
||||
print(f" asset: {list(PX.columns)}")
|
||||
# funding medio annualizzato per asset (dispersione = materia prima del carry)
|
||||
fann = (FUND.mean() * 365.25 * 100).round(1).sort_values(ascending=False)
|
||||
print(f" funding annualizzato% (carry teorico long-pays): "
|
||||
f"max {fann.index[0]} {fann.iloc[0]:+.1f} min {fann.index[-1]} {fann.iloc[-1]:+.1f} "
|
||||
f"mediana {fann.median():+.1f} spread {fann.iloc[0]-fann.iloc[-1]:.1f}")
|
||||
|
||||
base = carry_returns()
|
||||
print("\n --- STANDALONE (config base L=7 H=10 k=5, direction=carry) ---")
|
||||
st = _stats(base)
|
||||
print(f" FULL Sh {st['full']} in-sample {st['insample']} HOLD {st['hold']} DD {st['dd']*100:.1f}% "
|
||||
f"ann.ret {st['ann_ret']*100:+.1f}% ({st['n']}g)")
|
||||
print(f" per anno: {st['yearly']}")
|
||||
|
||||
# correlazioni vs gli sleeve attivi
|
||||
print("\n --- CORRELAZIONE vs sleeve attivi (daily, overlap comune) ---")
|
||||
refs = {"TP01": L.tp01_baseline_daily(), "XS01": _to_daily(_xsec_returns()),
|
||||
"VRP01": _to_daily(_vrp_combo_returns())}
|
||||
bd = _to_daily(base)
|
||||
for nm, r in refs.items():
|
||||
J = pd.concat({"c": bd, "r": r}, axis=1, join="inner").dropna()
|
||||
c = round(float(J["c"].corr(J["r"])), 3) if len(J) > 30 else None
|
||||
print(f" corr(FC01, {nm}) = {c} (overlap {len(J)}g)")
|
||||
|
||||
# marginal vs TP01 (verdetto indurito completo)
|
||||
print("\n --- MARGINAL vs TP01 (scorer indurito) ---")
|
||||
m = marginal_vs_tp01(bd)
|
||||
for key in ("marginal_verdict", "corr_full", "cand_full_sharpe", "cand_insample_sharpe",
|
||||
"has_insample_edge", "multicut_persistent", "is_hedge", "null_pctl_insample"):
|
||||
print(f" {key:22} = {m.get(key)}")
|
||||
print(f" blend w25: {m.get('blends', {}).get('w25')}")
|
||||
print(f" multicut_uplift: {m.get('multicut_uplift')}")
|
||||
|
||||
# UPLIFT vs XS01 (la domanda di overlap): XS01 da solo vs blend(XS01, FC01)
|
||||
print("\n --- UPLIFT vs XS01 (aggiunge a XS01 o e' ridondante?) ---")
|
||||
xs = refs["XS01"]
|
||||
J = pd.concat({"xs": xs, "fc": bd}, axis=1, join="inner").dropna()
|
||||
JH = J[J.index >= HOLDOUT]
|
||||
for w in (0.25, 0.5):
|
||||
bf = _sh((1 - w) * J["xs"] + w * J["fc"]) - _sh(J["xs"])
|
||||
bh = (_sh((1 - w) * JH["xs"] + w * JH["fc"]) - _sh(JH["xs"])) if len(JH) > 30 else None
|
||||
print(f" w={w}: uplift FULL {bf:+.3f} HOLD {bh:+.3f}" if bh is not None
|
||||
else f" w={w}: uplift FULL {bf:+.3f}")
|
||||
print(f" XS01 standalone: FULL {_sh(J['xs']):.2f} | FC01 standalone su overlap: {_sh(J['fc']):.2f}")
|
||||
|
||||
# PLATEAU su L, k, direzione
|
||||
print("\n --- PLATEAU (FULL / in-sample / HOLD Sharpe) ---")
|
||||
print(f" {'cfg':22} {'FULL':>6} {'in-s':>6} {'HOLD':>6} {'DD%':>6}")
|
||||
for direction in ("carry", "anti"):
|
||||
for Llb in (3, 7, 14, 30):
|
||||
for k in (3, 5):
|
||||
s = _stats(carry_returns(L_lb=Llb, k=k, direction=direction))
|
||||
tag = f"{direction} L={Llb} k={k}"
|
||||
print(f" {tag:22} {str(s['full']):>6} {str(s['insample']):>6} {str(s['hold']):>6} {s['dd']*100:>5.1f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,108 @@
|
||||
"""LEAD-LAG GENERICO non-sovrapposto: crypto[T-S -> T] predice future[T -> T+H]? (ogni mercato/fuso).
|
||||
|
||||
Market-agnostic. Per ogni giorno, entrata a ora T (UTC): segnale = crypto nella finestra [T-S, T]
|
||||
(finisce all'entrata), cattura = future nella finestra SUCCESSIVA [T, T+H] (non sovrapposta).
|
||||
Controllo = moto PROPRIO del future [T-S, T] -> isola se il crypto AGGIUNGE (anticipa) oltre il
|
||||
momentum del future. Sweep su T (copre apertura Europa ~07h, USA ~13h, Asia ~00h) e H.
|
||||
|
||||
TRADE: sign(crypto[T-S,T]) * future[T,T+H] - costo. Sharpe sqrt(252), per-anno, OOS 2024+.
|
||||
Dati: data/raw/fut_*_1h.parquet (UTC) + crypto 1h. Solo cache, nessun IB.
|
||||
"""
|
||||
import sys, glob
|
||||
from pathlib import Path
|
||||
import numpy as np, pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
RAW = ROOT / "data" / "raw"
|
||||
sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
from crypto_lead_harness import crypto_hourly, at
|
||||
|
||||
OOS = pd.Timestamp("2024-01-01", tz="UTC"); COST = 0.0002
|
||||
|
||||
|
||||
def fut_hourly(sym):
|
||||
d = pd.read_parquet(RAW / f"fut_{sym.lower()}_1h.parquet")
|
||||
return pd.Series(d["close"].astype(float).values, index=pd.to_datetime(d["timestamp"], unit="ms", utc=True)).sort_index()
|
||||
|
||||
|
||||
def _sh(r):
|
||||
r = np.asarray(r, float); r = r[np.isfinite(r)]
|
||||
return float(np.mean(r) / np.std(r) * np.sqrt(252)) if len(r) > 5 and np.std(r) > 0 else 0.0
|
||||
|
||||
|
||||
def run(fut, bc, T, S, H):
|
||||
days = pd.date_range(fut.index[0].normalize(), fut.index[-1].normalize(), freq="D", tz="UTC")
|
||||
rows = []
|
||||
for D in days:
|
||||
te = D + pd.Timedelta(hours=T)
|
||||
ts = te - pd.Timedelta(hours=S)
|
||||
tx = te + pd.Timedelta(hours=H)
|
||||
f0, f1 = at(fut, ts), at(fut, te); f2 = at(fut, tx)
|
||||
c0, c1 = at(bc, ts), at(bc, te)
|
||||
if not all(np.isfinite(v) and v > 0 for v in (f0, f1, f2, c0, c1)):
|
||||
continue
|
||||
rows.append((D, c1/c0 - 1, f1/f0 - 1, f2/f1 - 1))
|
||||
if len(rows) < 120:
|
||||
return None
|
||||
Dd = pd.DataFrame(rows, columns=["d", "csig", "fctrl", "cap"]).set_index("d")
|
||||
# filtra giorni in cui la cattura e' identicamente 0 (mercato chiuso, prezzo stale)
|
||||
Dd = Dd[Dd["cap"].abs() > 1e-9]
|
||||
if len(Dd) < 120:
|
||||
return None
|
||||
x, ctrl, y = Dd["csig"].values, Dd["fctrl"].values, Dd["cap"].values
|
||||
|
||||
def z(a):
|
||||
s = a.std(); return (a - a.mean()) / s if s > 0 else a * 0
|
||||
X = np.column_stack([np.ones(len(y)), z(x), z(ctrl)])
|
||||
beta, *_ = np.linalg.lstsq(X, z(y), rcond=None)
|
||||
resid = z(y) - X @ beta; dof = max(len(y) - 3, 1)
|
||||
se = np.sqrt(np.sum(resid**2) / dof * np.diag(np.linalg.inv(X.T @ X)))
|
||||
t_c = float(beta[1] / se[1]) if se[1] > 0 else 0.0
|
||||
C = np.sign(x) * y - COST
|
||||
m = Dd.index >= OOS
|
||||
yrs = Dd.index.year.values
|
||||
py = {int(v): round(_sh(C[yrs == v]), 2) for v in sorted(set(yrs)) if (yrs == v).sum() >= 30}
|
||||
pos = sum(1 for v in py.values() if v > 0)
|
||||
return dict(T=T, H=H, n=len(Dd), t_crypto=round(t_c, 2), sh_full=round(_sh(C), 2),
|
||||
sh_oos=round(_sh(C[m]), 2), ann=round(float(np.nanmean(C) * 252 * 100), 1),
|
||||
years_pos=pos, years_tot=len(py), per_year=py)
|
||||
|
||||
|
||||
def main():
|
||||
syms = sorted(p.name[4:-11].upper() for p in RAW.glob("fut_*_1h.parquet"))
|
||||
print("=" * 98)
|
||||
print(f" LEAD-LAG GENERICO non-sovrapposto — futures: {syms}")
|
||||
print("=" * 98)
|
||||
print(" crypto[T-8h -> T] -> future[T -> T+6h], controllo=moto proprio future. net 2bps, OOS2024+\n")
|
||||
bcs = {l: crypto_hourly(l) for l in ("BTC", "ETH")}
|
||||
winners = []
|
||||
for sym in syms:
|
||||
fut = fut_hourly(sym)
|
||||
best = None
|
||||
for lead in ("BTC", "ETH"):
|
||||
for T in (0, 4, 8, 12, 16, 20):
|
||||
r = run(fut, bcs[lead], T, 8, 6)
|
||||
if not r:
|
||||
continue
|
||||
r["sym"] = sym; r["lead"] = lead
|
||||
if best is None or r["sh_oos"] > best["sh_oos"]:
|
||||
best = r
|
||||
# raccogli i forti (crypto significativo E robusto)
|
||||
if r["t_crypto"] >= 2.5 and r["sh_oos"] > 0.5 and r["years_pos"] == r["years_tot"]:
|
||||
winners.append(r)
|
||||
if best:
|
||||
print(f" {sym:7} miglior: {best['lead']}->T{best['T']}h: t_crypto {best['t_crypto']:+.1f} "
|
||||
f"Sh full {best['sh_full']:+.2f} OOS {best['sh_oos']:+.2f} ann {best['ann']:+.1f}% "
|
||||
f"{best['years_pos']}/{best['years_tot']}y {best['per_year']}")
|
||||
print("\n --- CANDIDATI FORTI (t_crypto>=2.5, OOS>0.5, tutti gli anni positivi) ---")
|
||||
if not winners:
|
||||
print(" NESSUNO. -> nessuna anticipazione crypto->future robusta oltre il rumore/beta.")
|
||||
else:
|
||||
winners.sort(key=lambda r: r["sh_oos"], reverse=True)
|
||||
for w in winners[:10]:
|
||||
print(f" {w['sym']:7} {w['lead']}->T{w['T']}h H{w['H']}: t_crypto {w['t_crypto']} "
|
||||
f"Sh OOS {w['sh_oos']} full {w['sh_full']} ann {w['ann']}% {w['years_pos']}/{w['years_tot']}y {w['per_year']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,116 @@
|
||||
"""TEST ONESTO finestre NON sovrapposte: crypto[notte presto] -> future indice[notte tardi].
|
||||
|
||||
L'idea "monitor Deribit / trade IB" e' valida SOLO se il crypto anticipa il moto SUCCESSIVO del future
|
||||
(non lo stesso intervallo = look-ahead). Qui:
|
||||
entrata a T (ora UTC nella notte, equity cash chiuso): osservato crypto fino a T.
|
||||
segnale = crypto[P 21:00 -> T] (info nota a T)
|
||||
controllo= future[P 21:00 -> T] (il moto PROPRIO del future fino a T)
|
||||
cattura = future[T -> D 13:00] (cio' che catturi entrando a T, fino a ~apertura cash)
|
||||
TEST: il segnale crypto predice la cattura INCREMENTALMENTE al controllo? (crypto vede prima del future?)
|
||||
TRADE: long/short future su sign(crypto[P21:00->T]), cattura future[T->open], net ~2bps. Sharpe/anno/OOS.
|
||||
Confronto col baseline sign(future[P21:00->T]) (momentum proprio del future) per isolare il valore crypto.
|
||||
|
||||
Dati: data/raw/fut_<sym>_1h.parquet (UTC) + crypto 1h. sqrt(252).
|
||||
"""
|
||||
import sys, argparse, json
|
||||
from pathlib import Path
|
||||
import numpy as np, pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
RAW = ROOT / "data" / "raw"
|
||||
sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
from crypto_lead_harness import crypto_hourly, at
|
||||
|
||||
OOS = pd.Timestamp("2024-01-01", tz="UTC"); COST = 0.0002
|
||||
CLOSE_H = 21; OPEN_H = 13
|
||||
|
||||
|
||||
def fut_hourly(sym):
|
||||
d = pd.read_parquet(RAW / f"fut_{sym.lower()}_1h.parquet")
|
||||
return pd.Series(d["close"].astype(float).values, index=pd.to_datetime(d["timestamp"], unit="ms", utc=True)).sort_index()
|
||||
|
||||
|
||||
def _sh(r):
|
||||
r = np.asarray(r, float); r = r[np.isfinite(r)]
|
||||
return float(np.mean(r) / np.std(r) * np.sqrt(252)) if len(r) > 5 and np.std(r) > 0 else 0.0
|
||||
|
||||
|
||||
def run(sym="ES", lead="BTC", h_entry=6):
|
||||
fut = fut_hourly(sym); bc = crypto_hourly(lead)
|
||||
days = pd.date_range(fut.index[0].normalize(), fut.index[-1].normalize(), freq="B", tz="UTC")
|
||||
rows = []
|
||||
for k in range(1, len(days)):
|
||||
D = days[k]; P = days[k-1]
|
||||
t_pc = P + pd.Timedelta(hours=CLOSE_H) # P close 21:00
|
||||
t_e = D + pd.Timedelta(hours=h_entry) # entrata T
|
||||
t_o = D + pd.Timedelta(hours=OPEN_H) # ~apertura cash 13:00
|
||||
fp, fe, fo = at(fut, t_pc), at(fut, t_e), at(fut, t_o)
|
||||
cp, ce = at(bc, t_pc), at(bc, t_e)
|
||||
if not all(np.isfinite(v) and v > 0 for v in (fp, fe, fo, cp, ce)):
|
||||
continue
|
||||
crypto_sig = ce / cp - 1.0 # crypto P21:00 -> T
|
||||
fut_ctrl = fe / fp - 1.0 # future P21:00 -> T (controllo)
|
||||
capture = fo / fe - 1.0 # future T -> open (cio' che catturi)
|
||||
rows.append((D, crypto_sig, fut_ctrl, capture))
|
||||
if len(rows) < 100:
|
||||
return {"sym": sym, "lead": lead, "h_entry": h_entry, "err": f"n={len(rows)}"}
|
||||
Dd = pd.DataFrame(rows, columns=["d", "csig", "fctrl", "cap"]).set_index("d")
|
||||
x, ctrl, y = Dd["csig"].values, Dd["fctrl"].values, Dd["cap"].values
|
||||
|
||||
def z(a):
|
||||
s = a.std(); return (a - a.mean()) / s if s > 0 else a * 0
|
||||
# incrementale: cap ~ crypto + future_own_move
|
||||
X = np.column_stack([np.ones(len(y)), z(x), z(ctrl)])
|
||||
beta, *_ = np.linalg.lstsq(X, z(y), rcond=None)
|
||||
resid = z(y) - X @ beta; dof = max(len(y) - 3, 1)
|
||||
se = np.sqrt(np.sum(resid**2) / dof * np.diag(np.linalg.inv(X.T @ X)))
|
||||
t_crypto = float(beta[1] / se[1]) if se[1] > 0 else 0.0
|
||||
t_futown = float(beta[2] / se[2]) if se[2] > 0 else 0.0
|
||||
# trade: crypto-signal vs future-own-signal
|
||||
C_crypto = np.sign(x) * y - COST
|
||||
C_futown = np.sign(ctrl) * y - COST
|
||||
m = Dd.index >= OOS
|
||||
yrs = Dd.index.year.values
|
||||
py = {int(yv): round(_sh(C_crypto[yrs == yv]), 2) for yv in sorted(set(yrs)) if (yrs == yv).sum() >= 30}
|
||||
return {"sym": sym, "lead": lead, "h_entry": h_entry, "n": len(Dd),
|
||||
"corr_crypto_cap": round(float(np.corrcoef(x, y)[0, 1]), 3),
|
||||
"t_crypto_incr": round(t_crypto, 2), "t_futown_incr": round(t_futown, 2),
|
||||
"sh_crypto_full": round(_sh(C_crypto), 2), "sh_crypto_oos": round(_sh(C_crypto[m]), 2),
|
||||
"sh_futown_full": round(_sh(C_futown), 2),
|
||||
"ann_crypto_pct": round(float(np.nanmean(C_crypto) * 252 * 100), 1),
|
||||
"per_year": py}
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--syms", default="ES,NQ,RTY")
|
||||
ap.add_argument("--leads", default="BTC,ETH")
|
||||
ap.add_argument("--entries", default="0,3,6,9")
|
||||
args = ap.parse_args()
|
||||
print("=" * 96)
|
||||
print(" FUTURE OVERNIGHT — crypto[P21:00->T] predice future[T->open]? (finestre NON sovrapposte)")
|
||||
print("=" * 96)
|
||||
print(" cattura = future[T->open]; controllo = future[P21:00->T]; t_crypto_incr = crypto AL NETTO del future. net 2bps\n")
|
||||
best = []
|
||||
for sym in args.syms.split(","):
|
||||
for lead in args.leads.split(","):
|
||||
for h in [int(x) for x in args.entries.split(",")]:
|
||||
r = run(sym, lead, h)
|
||||
if "err" in r:
|
||||
print(f" {sym}<-{lead} T={h}h: {r['err']}"); continue
|
||||
print(f" {sym}<-{lead} T={h:>2}h UTC: n={r['n']} corr {r['corr_crypto_cap']:+.3f} | "
|
||||
f"t_crypto(incr) {r['t_crypto_incr']:+.1f} t_futOwn {r['t_futown_incr']:+.1f} | "
|
||||
f"trade crypto Sh {r['sh_crypto_full']:+.2f} (OOS {r['sh_crypto_oos']:+.2f}) ann {r['ann_crypto_pct']:+.1f}% | "
|
||||
f"futOwn Sh {r['sh_futown_full']:+.2f}")
|
||||
best.append(r)
|
||||
best.sort(key=lambda r: r.get("sh_crypto_oos", -9), reverse=True)
|
||||
print("\n --- migliori per Sharpe OOS (trade su segnale crypto) ---")
|
||||
for r in best[:5]:
|
||||
print(f" {r['sym']}<-{r['lead']} T={r['h_entry']}h: OOS {r['sh_crypto_oos']} full {r['sh_crypto_full']} "
|
||||
f"t_crypto {r['t_crypto_incr']} per-anno {r['per_year']}")
|
||||
print("\n NB: t_crypto_incr alto E sh_crypto > sh_futOwn => il crypto anticipa il future (idea valida).")
|
||||
print(" t_crypto ~0 o sh_crypto ~ futOwn => e' solo momentum del future, il crypto non aggiunge.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,95 @@
|
||||
"""IB EQUITIES/ETF DATA PROBE — certifica cosa il paper IB dà per la ricerca su azioni/ETF.
|
||||
|
||||
Gemello equity di certify_feed.py: PRIMA il dato (cosa c'è, quanto indietro, aggiustato per
|
||||
dividendi/split?, cosa costa), POI la strategia. Disciplina v2.0.0.
|
||||
|
||||
Universo candidato per la prima ricerca equity (cross-sectional momentum / trend, l'edge "noioso e
|
||||
robusto" più plausibile in un mercato efficiente):
|
||||
* 11 SPDR settoriali (XLK..XLC) — universo canonico del momentum cross-section settoriale;
|
||||
* ETF broad / macro (SPY QQQ IWM TLT GLD HYG) — per trend e risk-on/off;
|
||||
* 2 azioni (AAPL MSFT) per tarare profondità/qualità.
|
||||
|
||||
Per ogni simbolo: profondità storica daily con whatToShow=ADJUSTED_LAST (split+dividendi, OBBLIGATORIO
|
||||
per un backtest equity onesto) e TRADES (raw), + flag se scatta errore di subscription market-data.
|
||||
|
||||
uv run --with ib_async python scripts/research/ib_equities_probe.py
|
||||
"""
|
||||
import argparse, sys
|
||||
|
||||
SECTORS = ["XLK", "XLF", "XLE", "XLV", "XLI", "XLP", "XLY", "XLU", "XLB", "XLRE", "XLC"]
|
||||
BROAD = ["SPY", "QQQ", "IWM", "TLT", "GLD", "HYG"]
|
||||
STOCKS = ["AAPL", "MSFT"]
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--host", default="127.0.0.1")
|
||||
ap.add_argument("--port", type=int, default=4002)
|
||||
ap.add_argument("--client-id", type=int, default=88)
|
||||
ap.add_argument("--years", default="20 Y", help="durata storica da richiedere")
|
||||
args = ap.parse_args()
|
||||
|
||||
try:
|
||||
from ib_async import IB, Stock
|
||||
except Exception:
|
||||
print("ib_async non importabile. Esegui con: uv run --with ib_async python ...")
|
||||
sys.exit(2)
|
||||
|
||||
ib = IB()
|
||||
try:
|
||||
ib.connect(args.host, args.port, clientId=args.client_id, timeout=15)
|
||||
except Exception as e:
|
||||
print(f"[CONNESSIONE FALLITA] {args.host}:{args.port} -> {repr(e)[:140]}")
|
||||
sys.exit(1)
|
||||
|
||||
print("=" * 96)
|
||||
print(f" IB EQUITIES/ETF PROBE — {args.host}:{args.port} | acct {ib.managedAccounts()} | depth req {args.years}")
|
||||
print("=" * 96)
|
||||
|
||||
universe = [("SECTOR", s) for s in SECTORS] + [("BROAD", s) for s in BROAD] + [("STOCK", s) for s in STOCKS]
|
||||
print(f" {'sym':6} {'tipo':7} {'ADJUSTED_LAST':>26} {'TRADES':>22} note")
|
||||
rows = []
|
||||
for cat, sym in universe:
|
||||
con = Stock(sym, "SMART", "USD")
|
||||
try:
|
||||
cds = ib.reqContractDetails(con)
|
||||
if not cds:
|
||||
print(f" {sym:6} {cat:7} {'-- no contract --':>26}")
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f" {sym:6} {cat:7} ERR resolve {repr(e)[:40]}")
|
||||
continue
|
||||
|
||||
def hist(what):
|
||||
try:
|
||||
b = ib.reqHistoricalData(con, endDateTime="", durationStr=args.years,
|
||||
barSizeSetting="1 day", whatToShow=what,
|
||||
useRTH=True, formatDate=1, timeout=45)
|
||||
if not b:
|
||||
return "0 barre", None
|
||||
return f"{len(b)}b {b[0].date}..{b[-1].date}", b
|
||||
except Exception as e:
|
||||
return f"ERR {repr(e)[:30]}", None
|
||||
|
||||
adj_s, adj_b = hist("ADJUSTED_LAST")
|
||||
trd_s, _ = hist("TRADES")
|
||||
note = ""
|
||||
if "ERR" in adj_s or "0 barre" in adj_s:
|
||||
note = "subscription? prova delayed"
|
||||
print(f" {sym:6} {cat:7} {adj_s:>26} {trd_s:>22} {note}")
|
||||
if adj_b:
|
||||
rows.append((sym, len(adj_b), str(adj_b[0].date), str(adj_b[-1].date)))
|
||||
|
||||
print("-" * 96)
|
||||
if rows:
|
||||
depth = min(r[1] for r in rows); start = max(r[2] for r in rows)
|
||||
print(f" CERTIFICABILI (ADJUSTED_LAST): {len(rows)}/{len(universe)} | profondità comune ~{depth}b | start comune {start}")
|
||||
print(f" -> per un backtest cross-sectional servono date allineate: lo start comune e' il limite.")
|
||||
else:
|
||||
print(" NESSUN simbolo ha reso storia ADJUSTED — probabile mancanza market-data subscription.")
|
||||
print(" Ripiego: whatToShow='TRADES' (raw, non adj) o dati 'delayed' / fonte esterna certificabile.")
|
||||
ib.disconnect()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,108 @@
|
||||
"""IB DATA PROBE — enumera cosa un conto paper Interactive Brokers espone (dati storici).
|
||||
|
||||
NON e' una strategia: e' lo scan di FATTIBILITA' DATI, gemello di certify_feed.py per il mondo IB.
|
||||
Disciplina v2.0.0: prima il dato (cosa c'e', quanto indietro, che qualita', cosa costa), poi la
|
||||
strategia. "Solo dati, decido dopo".
|
||||
|
||||
PREREQUISITO: una sessione IB Gateway/TWS PAPER loggata e raggiungibile (default 127.0.0.1:4002).
|
||||
Tipico: avvii IB Gateway (Paper) sul tuo PC con API abilitata su 4002, poi reverse-tunnel
|
||||
SSH verso questo server: ssh -R 4002:localhost:4002 utente@server
|
||||
ESECUZIONE (senza sporcare le dipendenze del progetto):
|
||||
uv run --with ib_async python scripts/research/ib_probe.py
|
||||
uv run --with ib_async python scripts/research/ib_probe.py --port 7497 # TWS paper
|
||||
|
||||
Cosa fa, in ordine, e si ferma con diagnosi chiara al primo errore:
|
||||
(1) connette e stampa server version + account paper;
|
||||
(2) risolve un set di contratti rilevanti per QUESTO progetto:
|
||||
- CME crypto: BTC (5 BTC), MBT (micro 0.1 BTC), ETH, MET (micro); -> per il BASIS/carry
|
||||
- spot crypto Paxos (se abilitato): BTC, ETH;
|
||||
- 2 azioni/ETF di riferimento (SPY, AAPL) per tarare durate/qualita';
|
||||
(3) per ogni contratto risolto: chiede un piccolo storico (durata breve, 1 day bars) e riporta
|
||||
n barre, range, e se scatta un errore di SUBSCRIPTION mancante (codice 354/10089/10090...);
|
||||
(4) sintesi: cosa e' scaricabile GRATIS su paper vs cosa richiede market-data a pagamento.
|
||||
"""
|
||||
import argparse, sys
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--host", default="127.0.0.1")
|
||||
ap.add_argument("--port", type=int, default=7497, help="7497=TWS paper (default), 4002=GW paper, 4001/7496=live")
|
||||
ap.add_argument("--client-id", type=int, default=77)
|
||||
args = ap.parse_args()
|
||||
|
||||
try:
|
||||
from ib_async import IB, Future, Stock, Crypto, util
|
||||
except Exception:
|
||||
print("ib_async non importabile. Esegui con: uv run --with ib_async python scripts/research/ib_probe.py")
|
||||
sys.exit(2)
|
||||
|
||||
ib = IB()
|
||||
try:
|
||||
ib.connect(args.host, args.port, clientId=args.client_id, timeout=15)
|
||||
except Exception as e:
|
||||
print(f"[CONNESSIONE FALLITA] {args.host}:{args.port} -> {repr(e)[:160]}")
|
||||
print(" Verifica: IB Gateway/TWS Paper acceso, API abilitata, porta giusta, tunnel attivo.")
|
||||
sys.exit(1)
|
||||
|
||||
print("=" * 90)
|
||||
print(f" IB DATA PROBE — connesso {args.host}:{args.port} | serverVersion={ib.client.serverVersion()}")
|
||||
try:
|
||||
accts = ib.managedAccounts()
|
||||
print(f" account: {accts} (paper se inizia per 'D' tipicamente)")
|
||||
except Exception as e:
|
||||
print(f" account: ? ({repr(e)[:60]})")
|
||||
print("=" * 90)
|
||||
|
||||
# (2) contratti rilevanti
|
||||
candidates = []
|
||||
# CME crypto futures: lasciamo che IB scelga il front-month (no expiry -> reqContractDetails)
|
||||
candidates += [("CME BTC fut", Future("BTC", exchange="CME")),
|
||||
("CME MBT micro", Future("MBT", exchange="CME")),
|
||||
("CME ETH fut", Future("ETH", exchange="CME")),
|
||||
("CME MET micro", Future("MET", exchange="CME"))]
|
||||
# spot crypto Paxos (puo' non essere abilitato su paper)
|
||||
candidates += [("Paxos BTC", Crypto("BTC", exchange="PAXOS", currency="USD")),
|
||||
("Paxos ETH", Crypto("ETH", exchange="PAXOS", currency="USD"))]
|
||||
# riferimenti equity
|
||||
candidates += [("SPY ETF", Stock("SPY", "SMART", "USD")),
|
||||
("AAPL", Stock("AAPL", "SMART", "USD"))]
|
||||
|
||||
resolved = []
|
||||
print("\n (A) RISOLUZIONE CONTRATTI")
|
||||
for label, c in candidates:
|
||||
try:
|
||||
cds = ib.reqContractDetails(c)
|
||||
if not cds:
|
||||
print(f" {label:16} -> NESSUN match")
|
||||
continue
|
||||
# per i futures prendi la scadenza piu' vicina disponibile
|
||||
cd = sorted(cds, key=lambda d: getattr(d.contract, "lastTradeDateOrContractMonth", "") or "")[0]
|
||||
con = cd.contract
|
||||
extra = f" exp={con.lastTradeDateOrContractMonth}" if getattr(con, "lastTradeDateOrContractMonth", "") else ""
|
||||
print(f" {label:16} -> OK {con.localSymbol or con.symbol} {con.exchange}{extra} (n match={len(cds)})")
|
||||
resolved.append((label, con))
|
||||
except Exception as e:
|
||||
print(f" {label:16} -> ERR {repr(e)[:70]}")
|
||||
|
||||
# (3) prova storico breve
|
||||
print("\n (B) STORICO DI PROVA (durata 10 D, barre 1 day)")
|
||||
for label, con in resolved:
|
||||
try:
|
||||
bars = ib.reqHistoricalData(con, endDateTime="", durationStr="10 D",
|
||||
barSizeSetting="1 day", whatToShow="TRADES",
|
||||
useRTH=False, formatDate=1, timeout=30)
|
||||
if not bars:
|
||||
print(f" {label:16} -> 0 barre (forse serve subscription o whatToShow diverso)")
|
||||
else:
|
||||
print(f" {label:16} -> {len(bars)} barre {bars[0].date} .. {bars[-1].date} close={bars[-1].close}")
|
||||
except Exception as e:
|
||||
print(f" {label:16} -> ERR {repr(e)[:90]}")
|
||||
|
||||
print("\n (C) NOTE")
|
||||
print(" - errori 354/10089/10090/10168 = market-data subscription mancante (paper la eredita dal live).")
|
||||
print(" - per il BASIS/carry servono i MULTIPLI futures (front+next) -> poi reqContractDetails senza filtro expiry.")
|
||||
ib.disconnect()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,376 @@
|
||||
"""intraday_regime.py — FILONE B: "INTRADAY REGIME BTC/ETH" (eseguibile) — 2026-06-29.
|
||||
|
||||
TESI. Cercare un meccanismo SUB-DAILY sui dati certificati 1h/4h/.../12h BTC/ETH che sia
|
||||
ORTOGONALE sia a TP01 (TSMOM trend daily, long-flat) sia a SKH01 (regime BuzVola/BuzVolume
|
||||
+ Donchian breakout a 230m). SKH01 prova che il sub-daily PUO' funzionare ed essere
|
||||
quasi-ortogonale: qui si esplora un MECCANISMO DIVERSO, basato sulla QUALITA' del moto
|
||||
intraday (efficiency-ratio / vol-expansion / thrust) come REGIME che condiziona una
|
||||
posizione direzionale tenuta ~1 giorno.
|
||||
|
||||
Il killer ricorrente del progetto sotto le 12h e' il MURO-FEE (0.10% RT) + overfitting.
|
||||
La ricetta che SKH01 usa per sopravvivere: DECISIONE sub-daily ma HOLD ~1 giorno -> pochi
|
||||
trade -> la fee non uccide. Qui ogni meccanismo e' costruito per essere a basso turnover
|
||||
(gate di regime che tiene flat la maggior parte del tempo, lookback non microscopici) e
|
||||
viene giudicato col fee-sweep ALLA SUA FREQUENZA REALE. Se muore appena si mette la fee ->
|
||||
SCARTATO e documentato (e' un risultato valido).
|
||||
|
||||
MECCANISMI (tutti come posizione CONTINUA decisa <= close[i], cosi' passano nativamente per
|
||||
eval_weights / study_marginal / day_boundary_robust / eval_weights_smallcap di altlib):
|
||||
|
||||
ERM Efficiency-Ratio regime momentum. ER = |moto netto su L barre| / |percorso| (Kaufman):
|
||||
alto = moto intraday "pulito"/direzionale, basso = chop. Prendi la direzione del moto
|
||||
netto SOLO quando ER >= soglia (regime trendy intraday), altrimenti flat. Vol-target.
|
||||
Storia economica: quando il prezzo intraday e' EFFICIENTE il momentum continua; quando
|
||||
e' choppy non c'e' edge. DIVERSO da SKH01 (regime vol/volume) e da TP01 (TSMOM 1-6 mesi).
|
||||
|
||||
VEM Vol-Expansion Momentum. Direzione = segno del moto su Lmom barre, ATTIVA solo quando la
|
||||
vol realizzata corta > vol realizzata lunga (espansione di volatilita'). Vol-target.
|
||||
|
||||
VBR Volatility/thrust breakout (Larry-Williams-style, ROLLING, no calendario). Segui solo i
|
||||
movimenti significativi: posizione = segno(c[i]-c[i-1]) quando |Δ| > k*ATR, altrimenti
|
||||
tieni la precedente. Momentum-continuation di thrust.
|
||||
|
||||
TOD Time-of-day seasonality (CONTROLLO calendario). Direzione per ora-del-giorno via media
|
||||
espandente causale. Incluso APPOSTA per passarlo a day_boundary_robust: e' il tipo di
|
||||
effetto che ha ucciso open_drive (artefatto di etichettatura del giorno UTC).
|
||||
|
||||
GATE (CLAUDE.md): causale/no-leak, NETTO fee 0.10% RT + sweep 0.00-0.20% a freq reale, OOS
|
||||
hold-out + griglia + plateau, day_boundary_robust per effetti calendario, MARGINAL vs TP01
|
||||
(earns_slot / has_insample_edge / multi-cut / non-hedge), corr con SKH01, haircut $600.
|
||||
|
||||
Esecuzione: uv run python scripts/research/intraday_regime.py
|
||||
Idempotente, niente scritture su disco (solo report a stdout).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
|
||||
import altlib as al # noqa: E402
|
||||
|
||||
ASSETS = ("BTC", "ETH")
|
||||
SCREEN_TFS = ("1h", "4h", "6h", "8h", "12h")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TARGET FACTORIES (ogni fattoria ritorna un target_fn(df) causale, posizione continua)
|
||||
# ===========================================================================
|
||||
def make_erm(tf: str, L_days: float, thr: float, long_flat: bool,
|
||||
target_vol: float = 0.20):
|
||||
"""Efficiency-Ratio regime momentum. L_days = lunghezza finestra in GIORNI (-> barre via bpd)."""
|
||||
def fn(df):
|
||||
c = df["close"].values.astype(float)
|
||||
n = len(c)
|
||||
L = max(2, round(L_days * al.bars_per_day(df)))
|
||||
net = np.full(n, np.nan)
|
||||
net[L:] = c[L:] - c[:-L]
|
||||
step = np.abs(np.diff(c, prepend=c[0])) # |c[k]-c[k-1]|, causale
|
||||
path = pd.Series(step).rolling(L, min_periods=L).sum().values
|
||||
er = np.divide(np.abs(net), path, out=np.zeros(n), where=(path > 0))
|
||||
active = (er >= thr) & np.isfinite(net)
|
||||
raw = np.where(active, np.sign(net), 0.0)
|
||||
if long_flat:
|
||||
raw = np.clip(raw, 0.0, None)
|
||||
return al.vol_target(raw, df, target_vol=target_vol, vol_win_days=30, leverage_cap=2.0)
|
||||
return fn
|
||||
|
||||
|
||||
def make_vem(tf: str, Lmom_days: float, Lshort_days: float, Llong_days: float,
|
||||
long_flat: bool, target_vol: float = 0.20):
|
||||
"""Vol-expansion momentum: momentum attivo solo quando rv_corta > rv_lunga (espansione)."""
|
||||
def fn(df):
|
||||
c = df["close"].values.astype(float)
|
||||
n = len(c)
|
||||
bpd = al.bars_per_day(df)
|
||||
Lmom = max(2, round(Lmom_days * bpd))
|
||||
ws, wl = max(2, round(Lshort_days * bpd)), max(3, round(Llong_days * bpd))
|
||||
r = al.simple_returns(c)
|
||||
rv_s = al.rolling_std(r, ws)
|
||||
rv_l = al.rolling_std(r, wl)
|
||||
expand = (rv_s > rv_l) & np.isfinite(rv_s) & np.isfinite(rv_l)
|
||||
net = np.full(n, np.nan)
|
||||
net[Lmom:] = c[Lmom:] - c[:-Lmom]
|
||||
raw = np.where(expand & np.isfinite(net), np.sign(net), 0.0)
|
||||
if long_flat:
|
||||
raw = np.clip(raw, 0.0, None)
|
||||
return al.vol_target(raw, df, target_vol=target_vol, vol_win_days=30, leverage_cap=2.0)
|
||||
return fn
|
||||
|
||||
|
||||
def make_vbr(tf: str, k: float, atr_win: int, long_flat: bool, target_vol: float = 0.20):
|
||||
"""Thrust-breakout rolling: segui i moti significativi (|Δ| > k*ATR), altrimenti hold."""
|
||||
def fn(df):
|
||||
c = df["close"].values.astype(float)
|
||||
a = al.atr(df, atr_win)
|
||||
a_prev = np.roll(a, 1); a_prev[0] = a[0] # ATR noto a inizio barra (causale)
|
||||
delta = np.diff(c, prepend=c[0])
|
||||
sig = np.where(np.abs(delta) > k * a_prev, np.sign(delta), np.nan)
|
||||
raw = pd.Series(sig).ffill().fillna(0.0).values
|
||||
if long_flat:
|
||||
raw = np.clip(raw, 0.0, None)
|
||||
return al.vol_target(raw, df, target_vol=target_vol, vol_win_days=30, leverage_cap=2.0)
|
||||
return fn
|
||||
|
||||
|
||||
def make_tod(tf: str, long_flat: bool, target_vol: float = 0.20, min_obs: int = 20):
|
||||
"""Time-of-day seasonality (controllo calendario). Direzione = segno della media espandente
|
||||
causale del rendimento della stessa ora-del-giorno. Da passare a day_boundary_robust."""
|
||||
def fn(df):
|
||||
c = df["close"].values.astype(float)
|
||||
r = al.simple_returns(c)
|
||||
hour = pd.to_datetime(df["datetime"], utc=True).dt.hour.values
|
||||
n = len(c)
|
||||
sums = {}; cnts = {}
|
||||
raw = np.zeros(n)
|
||||
for i in range(1, n):
|
||||
h_prev = int(hour[i - 1]) # aggiorna con la barra GIA' chiusa
|
||||
sums[h_prev] = sums.get(h_prev, 0.0) + r[i - 1]
|
||||
cnts[h_prev] = cnts.get(h_prev, 0) + 1
|
||||
h = int(hour[i])
|
||||
if cnts.get(h, 0) >= min_obs:
|
||||
raw[i] = 1.0 if sums[h] >= 0 else -1.0
|
||||
if long_flat:
|
||||
raw = np.clip(raw, 0.0, None)
|
||||
return al.vol_target(raw, df, target_vol=target_vol, vol_win_days=30, leverage_cap=2.0)
|
||||
return fn
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SCREENING — griglia leggera per (asset,tf,params) via eval_weights (vettoriale).
|
||||
# ===========================================================================
|
||||
def _screen_cell(fn, tf):
|
||||
"""Min-asset full/hold Sharpe, fee@0.10 e @0.20 RT, turnover, time-in-market."""
|
||||
fulls, holds, f10, f20, turn, tim = [], [], [], [], [], []
|
||||
for a in ASSETS:
|
||||
df = al.get(a, tf)
|
||||
tgt = fn(df)
|
||||
ev = al.eval_weights(df, tgt, fee_side=0.0005) # 0.10% RT
|
||||
ev0 = al.eval_weights(df, tgt, fee_side=0.001) # 0.20% RT
|
||||
fulls.append(ev["full"]["sharpe"]); holds.append(ev["holdout"].get("sharpe", 0.0))
|
||||
f10.append(ev["full"]["sharpe"]); f20.append(ev0["full"]["sharpe"])
|
||||
turn.append(ev["turnover_per_year"]); tim.append(ev["time_in_market"])
|
||||
return dict(tf=tf, min_full=round(min(fulls), 3), min_hold=round(min(holds), 3),
|
||||
min_f10=round(min(f10), 3), min_f20=round(min(f20), 3),
|
||||
turnover=round(float(np.mean(turn)), 1), tim=round(float(np.mean(tim)), 2))
|
||||
|
||||
|
||||
def screen_family(name, factory, grid, tfs=SCREEN_TFS):
|
||||
"""Esegue la griglia, ritorna lista di dict ordinata per min_hold (solo fee-surviving in cima)."""
|
||||
rows = []
|
||||
for tf in tfs:
|
||||
for params in grid:
|
||||
fn = factory(tf=tf, **params)
|
||||
m = _screen_cell(fn, tf)
|
||||
m["params"] = params
|
||||
m["fee_ok"] = bool(m["min_f20"] > 0)
|
||||
rows.append(m)
|
||||
rows.sort(key=lambda r: (r["fee_ok"], r["min_hold"]), reverse=True)
|
||||
print(f"\n===== {name}: top celle (di {len(rows)}) =====")
|
||||
print(f" {'tf':>4} {'minFull':>7} {'minHold':>7} {'f@.10':>6} {'f@.20':>6} "
|
||||
f"{'turn/y':>7} {'tim':>5} feeOK params")
|
||||
for r in rows[:10]:
|
||||
print(f" {r['tf']:>4} {r['min_full']:+7.2f} {r['min_hold']:+7.2f} {r['min_f10']:+6.2f} "
|
||||
f"{r['min_f20']:+6.2f} {r['turnover']:>7.0f} {r['tim']:>5.2f} "
|
||||
f"{str(r['fee_ok']):>5} {r['params']}")
|
||||
return rows
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# DEEP-DIVE sul vincitore: marginal vs TP01 + day_boundary + corr SKH01 + haircut $600.
|
||||
# ===========================================================================
|
||||
@lru_cache(maxsize=1)
|
||||
def _skh_daily() -> pd.Series:
|
||||
"""Rendimenti giornalieri SKH01-V2-DD (50/50 BTC+ETH) dallo sleeve di progetto (read-only)."""
|
||||
from src.portfolio.sleeves import _skyhook_returns
|
||||
s = _skyhook_returns()
|
||||
if s.index.tz is None:
|
||||
s.index = s.index.tz_localize("UTC")
|
||||
return s
|
||||
|
||||
|
||||
def corr_to_skh(fn, tf) -> dict:
|
||||
cand = al.candidate_daily(fn, tf=tf)
|
||||
skh = _skh_daily()
|
||||
J = pd.concat({"C": cand, "S": skh}, axis=1, join="inner").dropna()
|
||||
JH = J[J.index >= al.HOLDOUT]
|
||||
return dict(n=int(len(J)),
|
||||
corr_full=round(float(J["C"].corr(J["S"])), 3) if len(J) > 5 else None,
|
||||
corr_hold=round(float(JH["C"].corr(JH["S"])), 3) if len(JH) > 5 else None)
|
||||
|
||||
|
||||
def haircut_600(fn, tf) -> dict:
|
||||
"""Sharpe onesto a $600: salta i ribilanci < $5 (eval_weights_smallcap), per asset + media."""
|
||||
out = {}
|
||||
for a in ASSETS:
|
||||
df = al.get(a, tf)
|
||||
sc = al.eval_weights_smallcap(df, fn(df), capital=600.0, min_order=5.0)
|
||||
out[a] = dict(modeled=sc["modeled"]["sharpe"], real=sc["realistic"]["sharpe"],
|
||||
haircut=sc["sharpe_haircut"], n_tr=sc["n_executed_trades"])
|
||||
return out
|
||||
|
||||
|
||||
def plateau_erm(tf="8h"):
|
||||
"""Plateau fine L_days x thr al TF vincente (min-asset full/hold/f@.20). Un edge vero ha un
|
||||
PLATEAU, non una cella isolata."""
|
||||
print("\n" + "=" * 78)
|
||||
print(f"PLATEAU ERM @ {tf} (min-asset; L_days righe, thr colonne) — full / hold / f@.20")
|
||||
print("=" * 78)
|
||||
Ls = (1.5, 2.0, 2.5, 3.0); thrs = (0.30, 0.35, 0.40, 0.45, 0.50)
|
||||
print(" L\\thr " + "".join(f"{t:>16.2f}" for t in thrs))
|
||||
for L in Ls:
|
||||
cells = []
|
||||
for t in thrs:
|
||||
m = _screen_cell(make_erm(tf=tf, L_days=L, thr=t, long_flat=False), tf)
|
||||
cells.append(f"{m['min_full']:+.2f}/{m['min_hold']:+.2f}/{m['min_f20']:+.2f}")
|
||||
print(f" {L:>4.1f} " + "".join(f"{c:>16}" for c in cells))
|
||||
|
||||
|
||||
def vs_book(fn, tf):
|
||||
"""Il test decisivo del gate #5: ERM AGGIUNGE oltre il book esistente (TP01+SKH01), o e'
|
||||
SKH01 travestito? Sharpe/DD full & hold dei blend incrementali su griglia giornaliera."""
|
||||
cand = al.candidate_daily(fn, tf=tf)
|
||||
tp = al.tp01_baseline_daily()
|
||||
skh = _skh_daily()
|
||||
J = pd.concat({"T": tp, "S": skh, "C": cand}, axis=1, join="inner").dropna()
|
||||
JH = J[J.index >= al.HOLDOUT]
|
||||
blends = [
|
||||
("TP01", (1.0, 0.0, 0.0)),
|
||||
("TP01+SKH 75/25", (0.75, 0.25, 0.0)),
|
||||
("TP01+SKH+ERM 60/25/15", (0.60, 0.25, 0.15)),
|
||||
("TP01+SKH+ERM 55/20/25", (0.55, 0.20, 0.25)),
|
||||
]
|
||||
print("\n" + "=" * 78)
|
||||
print("vs BOOK ESISTENTE (TP01+SKH01) — ERM aggiunge oltre SKH? (gate #5)")
|
||||
print("=" * 78)
|
||||
print(f" {'blend':<26} {'FULL Sh':>8} {'FULL DD':>8} {'HOLD Sh':>8} {'HOLD DD':>8}")
|
||||
for label, (wt, ws, wc) in blends:
|
||||
bf = wt * J["T"] + ws * J["S"] + wc * J["C"]
|
||||
bh = wt * JH["T"] + ws * JH["S"] + wc * JH["C"]
|
||||
print(f" {label:<26} {al._sh(bf):>+8.2f} {al._dd_ret(bf) * 100:>7.1f}% "
|
||||
f"{al._sh(bh):>+8.2f} {al._dd_ret(bh) * 100:>7.1f}%")
|
||||
|
||||
|
||||
def deep_dive(name, fn, tf, calendar=False):
|
||||
print("\n" + "#" * 78)
|
||||
print(f"# DEEP-DIVE: {name} (tf={tf})")
|
||||
print("#" * 78)
|
||||
|
||||
caus = al.causality_ok(fn, tf=tf)
|
||||
print(f"\n[CAUSALITA'] ok={caus['ok']} max_tail_diff={caus['max_tail_diff']} "
|
||||
f"(checked={caus['checked']})")
|
||||
|
||||
print("\n[FEE-SWEEP a frequenza reale] (study_weights su entrambi gli asset)")
|
||||
sw = al.study_weights(name, fn, tfs=(tf,))
|
||||
print(al.fmt(sw))
|
||||
|
||||
print("\n[MARGINAL vs TP01]")
|
||||
sm = al.study_marginal(name, fn, tf=tf)
|
||||
print(al.fmt_marginal(sm))
|
||||
|
||||
sk = corr_to_skh(fn, tf)
|
||||
print(f"\n[CORR con SKH01] full={sk['corr_full']} hold={sk['corr_hold']} "
|
||||
f"(n_giorni={sk['n']})")
|
||||
|
||||
if calendar:
|
||||
print("\n[DAY-BOUNDARY ROBUST] (OBBLIGATORIO per effetti ora/sessione/giorno)")
|
||||
else:
|
||||
print("\n[DAY-BOUNDARY ROBUST] (sanity: un segnale di prezzo dev'essere ~INVARIANT)")
|
||||
db = al.day_boundary_robust(fn, tf=tf)
|
||||
print(f" verdict={db['verdict']} spread={db.get('spread')} "
|
||||
f"min={db.get('min')} max={db.get('max')} per_offset={db.get('per_offset')}")
|
||||
|
||||
print("\n[HAIRCUT $600] (eval_weights_smallcap: salta ribilanci < $5)")
|
||||
hc = haircut_600(fn, tf)
|
||||
for a, d in hc.items():
|
||||
print(f" {a}: modeled Sh {d['modeled']:+.2f} -> real Sh {d['real']:+.2f} "
|
||||
f"(haircut {d['haircut']:+.2f}, trade eseguiti {d['n_tr']})")
|
||||
|
||||
return dict(name=name, tf=tf, causal=caus["ok"], earns_slot=sm["earns_slot"],
|
||||
marginal=sm["marginal_verdict"], corr_skh=sk, day_boundary=db["verdict"],
|
||||
haircut=hc)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# MAIN
|
||||
# ===========================================================================
|
||||
def main():
|
||||
print("=" * 78)
|
||||
print("FILONE B — INTRADAY REGIME BTC/ETH (intraday_regime.py)")
|
||||
print("=" * 78)
|
||||
tp01 = al.tp01_baseline_daily()
|
||||
print(f"Baseline TP01 (50/50) full Sharpe ~{al._sh(tp01):.2f} "
|
||||
f"hold ~{al._sh(tp01[tp01.index >= al.HOLDOUT]):.2f} (riferimento marginale)")
|
||||
|
||||
# ---- Griglie (compatte: plateau leggibile, no overfit di griglia gigante) ----
|
||||
erm_grid = [dict(L_days=L, thr=t, long_flat=lf)
|
||||
for L in (1.0, 2.0, 3.0) for t in (0.35, 0.50) for lf in (False, True)]
|
||||
vem_grid = [dict(Lmom_days=lm, Lshort_days=2.0, Llong_days=10.0, long_flat=lf)
|
||||
for lm in (1.0, 3.0) for lf in (False, True)]
|
||||
vbr_grid = [dict(k=k, atr_win=14, long_flat=lf)
|
||||
for k in (0.5, 1.0, 1.5) for lf in (False, True)]
|
||||
tod_grid = [dict(long_flat=lf) for lf in (False, True)]
|
||||
|
||||
fam = {
|
||||
"ERM": (make_erm, erm_grid, SCREEN_TFS),
|
||||
"VEM": (make_vem, vem_grid, ("4h", "6h", "8h", "12h")),
|
||||
"VBR": (make_vbr, vbr_grid, ("4h", "6h", "8h", "12h")),
|
||||
"TOD": (make_tod, tod_grid, ("1h",)),
|
||||
}
|
||||
screens = {}
|
||||
for name, (factory, grid, tfs) in fam.items():
|
||||
screens[name] = screen_family(name, factory, grid, tfs)
|
||||
|
||||
# ---- Vincitore per famiglia (best min_hold tra le fee-surviving con min_full>0) ----
|
||||
print("\n" + "=" * 78)
|
||||
print("VINCITORI PER FAMIGLIA (best min_hold tra fee-surviving, min_full>0)")
|
||||
print("=" * 78)
|
||||
winners = {}
|
||||
for name, (factory, grid, tfs) in fam.items():
|
||||
ok = [r for r in screens[name] if r["fee_ok"] and r["min_full"] > 0]
|
||||
pool = ok if ok else screens[name]
|
||||
w = max(pool, key=lambda r: r["min_hold"])
|
||||
winners[name] = w
|
||||
print(f" {name}: tf={w['tf']} {w['params']} minFull={w['min_full']:+.2f} "
|
||||
f"minHold={w['min_hold']:+.2f} f@.20={w['min_f20']:+.2f} feeOK={w['fee_ok']}")
|
||||
|
||||
# ---- Deep-dive sui due meccanismi piu' promettenti (per min_hold) + il controllo TOD ----
|
||||
ranked = sorted(["ERM", "VEM", "VBR"],
|
||||
key=lambda n: winners[n]["min_hold"], reverse=True)
|
||||
deep = []
|
||||
for name in ranked[:2]:
|
||||
w = winners[name]
|
||||
factory = fam[name][0]
|
||||
fn = factory(tf=w["tf"], **w["params"])
|
||||
deep.append(deep_dive(f"{name} {w['params']}", fn, w["tf"], calendar=False))
|
||||
|
||||
# controllo calendario: TOD passa SEMPRE per day_boundary_robust
|
||||
wt = winners["TOD"]
|
||||
fn_tod = make_tod(tf=wt["tf"], **wt["params"])
|
||||
deep.append(deep_dive(f"TOD {wt['params']}", fn_tod, wt["tf"], calendar=True))
|
||||
|
||||
# ---- Analisi extra sul vincitore ERM (plateau fine + vs book TP01+SKH01) ----
|
||||
we = winners["ERM"]
|
||||
fn_erm = make_erm(tf=we["tf"], **we["params"])
|
||||
plateau_erm(we["tf"])
|
||||
vs_book(fn_erm, we["tf"])
|
||||
|
||||
# ---- Verdetto sintetico ----
|
||||
print("\n" + "=" * 78)
|
||||
print("SINTESI")
|
||||
print("=" * 78)
|
||||
for d in deep:
|
||||
print(f" {d['name']:<26} tf={d['tf']:>3} | marginal={d['marginal']:<9} "
|
||||
f"earns_slot={d['earns_slot']!s:<5} corrSKH(full/hold)="
|
||||
f"{d['corr_skh']['corr_full']}/{d['corr_skh']['corr_hold']} "
|
||||
f"day_boundary={d['day_boundary']}")
|
||||
any_slot = any(d["earns_slot"] for d in deep)
|
||||
print(f"\n => earns_slot su qualche meccanismo? {any_slot}")
|
||||
print(" (vedi diario docs/diary/2026-06-29-intraday-regime.md per il verdetto ragionato)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,267 @@
|
||||
"""intraday_regime_analysis.py — ANALISI DI ROBUSTEZZA del LEAD ERM (filone B) — 2026-06-29.
|
||||
|
||||
Il lead di B (ERM 8h L=2.0 thr=0.35 L/S) fa earns_slot=True, ma con 2 caveat NON quantificati
|
||||
dallo script di scoperta `intraday_regime.py`:
|
||||
(1) il VINCITORE e' selezionato per min_hold MASSIMO su ~60 celle -> selezione-sull'hold-out;
|
||||
(2) il plateau hold-out e' a UNA SOLA RIGA (positivo solo a L~2.0; L>=2.5 va negativo sull'hold).
|
||||
Insieme = rischio multiple-testing / overfit della finestra recente, mai deflazionato (a
|
||||
differenza del filone C che ha il deflated-Sharpe).
|
||||
|
||||
Questo script attacca esattamente quei nodi, SENZA toccare il live (read-only, branch separato):
|
||||
|
||||
A) DEFLATED SHARPE (Bailey & Lopez de Prado) del vincitore vs TUTTI i trial realmente
|
||||
cercati (ERM+VEM+VBR+TOD, tutte le celle/TF). Se DSR << 0.95 lo Sharpe non e' significativo
|
||||
dopo la correzione per multiple-testing.
|
||||
|
||||
B) SELEZIONE IN-SAMPLE-ONLY: ri-scelgo la cella ERM usando SOLO lo Sharpe PRE-2025 (mai
|
||||
l'hold-out), poi ne valuto earns_slot sull'intera storia. Se una cella scelta SENZA vedere
|
||||
l'hold-out continua ad ADDS, l'edge non e' hold-out-mined.
|
||||
|
||||
C) ENSEMBLE DEL PLATEAU: invece della singola cella migliore, media i pesi su tutto il
|
||||
plateau ERM 8h (L x thr) -> un candidato unico "non-cherry-picked" -> earns_slot. Se la
|
||||
famiglia regge senza scegliere L, il caveat (1)+(2) si attenua.
|
||||
|
||||
D) DOVE VIVE L'EDGE: Sharpe per-anno standalone + uplift per-anno del blend 3-way
|
||||
(TP01+SKH+ERM) vs 2-way (TP01+SKH), e corr(ERM,SKH) per-anno (e' un hedge-di-SKH?).
|
||||
|
||||
Esecuzione: uv run python scripts/research/intraday_regime_analysis.py
|
||||
Idempotente, niente scritture su disco (solo report a stdout).
|
||||
"""
|
||||
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
|
||||
from intraday_regime import make_erm, make_vem, make_vbr, make_tod, _skh_daily # noqa: E402
|
||||
|
||||
DPY = 365.25
|
||||
HOLD = al.HOLDOUT
|
||||
ASSETS = ("BTC", "ETH")
|
||||
SCREEN_TFS = ("1h", "4h", "6h", "8h", "12h")
|
||||
|
||||
WIN = dict(tf="8h", L_days=2.0, thr=0.35, long_flat=False) # il lead di B
|
||||
|
||||
deflated_sharpe = al.deflated_sharpe # gate canonico (codificato in altlib da questo filone)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
def all_trials():
|
||||
"""Ricostruisce la griglia COMPLETA realmente cercata in intraday_regime.py, ritorna una
|
||||
lista di (tag, factory, tf, params) per pesare il multiple-testing onestamente."""
|
||||
out = []
|
||||
erm_grid = [dict(L_days=L, thr=t, long_flat=lf)
|
||||
for L in (1.0, 2.0, 3.0) for t in (0.35, 0.50) for lf in (False, True)]
|
||||
for tf in SCREEN_TFS:
|
||||
for p in erm_grid:
|
||||
out.append(("ERM", make_erm, tf, p))
|
||||
# + il plateau fine a 8h (L x thr, lf=False) — anche quelle sono celle testate
|
||||
for L in (1.5, 2.0, 2.5, 3.0):
|
||||
for t in (0.30, 0.35, 0.40, 0.45, 0.50):
|
||||
out.append(("ERM-plat", make_erm, "8h", dict(L_days=L, thr=t, long_flat=False)))
|
||||
vem_grid = [dict(Lmom_days=lm, Lshort_days=2.0, Llong_days=10.0, long_flat=lf)
|
||||
for lm in (1.0, 3.0) for lf in (False, True)]
|
||||
for tf in ("4h", "6h", "8h", "12h"):
|
||||
for p in vem_grid:
|
||||
out.append(("VEM", make_vem, tf, p))
|
||||
vbr_grid = [dict(k=k, atr_win=14, long_flat=lf) for k in (0.5, 1.0, 1.5) for lf in (False, True)]
|
||||
for tf in ("4h", "6h", "8h", "12h"):
|
||||
for p in vbr_grid:
|
||||
out.append(("VBR", make_vbr, tf, p))
|
||||
for lf in (False, True):
|
||||
out.append(("TOD", make_tod, "1h", dict(long_flat=lf)))
|
||||
return out
|
||||
|
||||
|
||||
def cand_full_is_sharpe(factory, tf, params):
|
||||
"""(daily, full Sharpe, in-sample<2025 Sharpe) del candidato 50/50 di quella cella."""
|
||||
fn = factory(tf=tf, **params)
|
||||
daily = al.candidate_daily(fn, tf=tf)
|
||||
full = al._sh(daily)
|
||||
ins = al._sh(daily[daily.index < HOLD]) if (daily.index < HOLD).sum() > 60 else float("nan")
|
||||
return daily, full, ins
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
def part_A_deflated():
|
||||
print("=" * 78)
|
||||
print("A) DEFLATED SHARPE del vincitore vs TUTTI i trial cercati (multiple-testing)")
|
||||
print("=" * 78)
|
||||
trials = all_trials()
|
||||
sr_all, sr_no_tod, sr_erm = [], [], []
|
||||
win_daily = win_full = None
|
||||
for tag, factory, tf, params in trials:
|
||||
try:
|
||||
daily, full, _ = cand_full_is_sharpe(factory, tf, params)
|
||||
except Exception:
|
||||
continue
|
||||
sr_all.append(full)
|
||||
if tag != "TOD":
|
||||
sr_no_tod.append(full)
|
||||
if tag.startswith("ERM"):
|
||||
sr_erm.append(full)
|
||||
if tag == "ERM" and tf == WIN["tf"] and params.get("L_days") == WIN["L_days"] \
|
||||
and params.get("thr") == WIN["thr"] and params.get("long_flat") is False:
|
||||
win_daily, win_full = daily, full
|
||||
sr_arr = np.array([s for s in sr_all if np.isfinite(s)])
|
||||
print(f" N trial finiti : {len(sr_arr)}")
|
||||
print(f" Sharpe winner (50/50) : {win_full:+.3f}")
|
||||
print(f" Sharpe trial: mean {sr_arr.mean():+.2f} std {sr_arr.std():.2f} "
|
||||
f"max {sr_arr.max():+.2f} >0: {int((sr_arr > 0).sum())}/{len(sr_arr)}")
|
||||
dsr = None
|
||||
for label, pool in (("TUTTI 122", sr_all), ("no-TOD", sr_no_tod), ("solo-ERM", sr_erm)):
|
||||
d, sr0 = deflated_sharpe(win_full, pool, win_daily)
|
||||
n = int(np.isfinite(np.array(pool)).sum())
|
||||
print(f" DSR [{label:>9} N={n:>3}]: {d:.3f} (Sh-max null {sr0:+.2f}) -> "
|
||||
f"{'PASS' if d >= 0.95 else 'FAIL'}")
|
||||
if label == "TUTTI 122":
|
||||
dsr = d
|
||||
return dsr, win_full, None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
def part_B_insample_pick():
|
||||
print("\n" + "=" * 78)
|
||||
print("B) SELEZIONE IN-SAMPLE-ONLY (scelgo la cella ERM solo con Sharpe PRE-2025)")
|
||||
print("=" * 78)
|
||||
erm_grid = [dict(L_days=L, thr=t, long_flat=lf)
|
||||
for L in (1.0, 1.5, 2.0, 2.5, 3.0) for t in (0.30, 0.35, 0.40, 0.50)
|
||||
for lf in (False, True)]
|
||||
rows = []
|
||||
for tf in SCREEN_TFS:
|
||||
for p in erm_grid:
|
||||
try:
|
||||
_, full, ins = cand_full_is_sharpe(make_erm, tf, p)
|
||||
except Exception:
|
||||
continue
|
||||
rows.append((ins, full, tf, p))
|
||||
rows = [r for r in rows if np.isfinite(r[0])]
|
||||
rows.sort(key=lambda r: r[0], reverse=True) # ordina per Sharpe IN-SAMPLE (no hold-out)
|
||||
print(f" Top 5 celle per Sharpe IN-SAMPLE (<2025):")
|
||||
for ins, full, tf, p in rows[:5]:
|
||||
print(f" IS {ins:+.2f} FULL {full:+.2f} tf={tf:>3} {p}")
|
||||
ins, full, tf, p = rows[0]
|
||||
print(f"\n -> cella scelta SENZA vedere l'hold-out: tf={tf} {p}")
|
||||
sm = al.study_marginal(f"ERM-ISpick {p}", make_erm(tf=tf, **p), tf=tf)
|
||||
m = sm["marginal"]
|
||||
print(f" earns_slot={sm['earns_slot']} marginal={m['marginal_verdict']} "
|
||||
f"abs={sm['absolute']['verdict']['grade']}")
|
||||
print(f" corr->TP01 {m['corr_full']} has_insample_edge={m['has_insample_edge']} "
|
||||
f"is_hedge={m['is_hedge']} robust_oos={m['robust_oos']}")
|
||||
print(f" blend w25: full uplift {m['blends']['w25']['uplift_full']:+.3f} "
|
||||
f"hold uplift {m['blends']['w25']['uplift_hold']:+.3f}")
|
||||
same = (tf == WIN["tf"] and abs(p["L_days"] - WIN["L_days"]) < 1e-9
|
||||
and abs(p["thr"] - WIN["thr"]) < 1e-9 and p["long_flat"] is False)
|
||||
print(f" coincide col vincitore max-hold? {same}")
|
||||
return sm["earns_slot"], (tf, p)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
def ensemble_target(tf, cells):
|
||||
"""Media (equal-weight) dei pesi vol-targeted su piu' celle ERM -> un unico stream per asset.
|
||||
Ritorna un target_fn(df) che ricostruisce l'ensemble per quel df."""
|
||||
def fn(df):
|
||||
ws = [make_erm(tf=tf, **c)(df) for c in cells]
|
||||
return np.nanmean(np.vstack(ws), axis=0)
|
||||
return fn
|
||||
|
||||
|
||||
def part_C_plateau_ensemble():
|
||||
print("\n" + "=" * 78)
|
||||
print("C) ENSEMBLE DEL PLATEAU ERM 8h (media celle L x thr, NIENTE cherry-pick)")
|
||||
print("=" * 78)
|
||||
cells = [dict(L_days=L, thr=t, long_flat=False)
|
||||
for L in (1.5, 2.0, 2.5, 3.0) for t in (0.30, 0.35, 0.40, 0.45, 0.50)]
|
||||
fn = ensemble_target("8h", cells)
|
||||
print(f" celle nell'ensemble: {len(cells)} (L 1.5-3.0 x thr 0.30-0.50, lf=False)")
|
||||
caus = al.causality_ok(fn, tf="8h")
|
||||
print(f" causale: ok={caus['ok']} max_tail_diff={caus['max_tail_diff']}")
|
||||
sm = al.study_marginal("ERM-plateau-ens", fn, tf="8h")
|
||||
m = sm["marginal"]
|
||||
print(f" earns_slot={sm['earns_slot']} marginal={m['marginal_verdict']} "
|
||||
f"abs={sm['absolute']['verdict']['grade']}")
|
||||
print(f" standalone full {m['cand_full_sharpe']} hold {m['cand_hold_sharpe']} "
|
||||
f"in-sample {m.get('cand_insample_sharpe')}")
|
||||
print(f" corr->TP01 {m['corr_full']} has_insample_edge={m['has_insample_edge']} "
|
||||
f"is_hedge={m['is_hedge']} robust_oos={m['robust_oos']}")
|
||||
print(f" blend w25: full {m['blends']['w25']['full']} (uplift "
|
||||
f"{m['blends']['w25']['uplift_full']:+.3f}) hold {m['blends']['w25']['hold']} "
|
||||
f"(uplift {m['blends']['w25']['uplift_hold']:+.3f})")
|
||||
print(f" multicut: {m['multicut_uplift']}")
|
||||
return sm["earns_slot"]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
def part_D_where_edge():
|
||||
print("\n" + "=" * 78)
|
||||
print("D) DOVE VIVE L'EDGE — per-anno standalone + uplift 3-way vs 2-way + corr(ERM,SKH)")
|
||||
print("=" * 78)
|
||||
fn = make_erm(**WIN)
|
||||
cand = al.candidate_daily(fn, tf=WIN["tf"])
|
||||
tp = al.tp01_baseline_daily()
|
||||
skh = _skh_daily()
|
||||
J = pd.concat({"T": tp, "S": skh, "C": cand}, axis=1, join="inner").dropna()
|
||||
two = 0.75 * J["T"] + 0.25 * J["S"]
|
||||
three = 0.60 * J["T"] + 0.25 * J["S"] + 0.15 * J["C"]
|
||||
print(f" {'anno':>5} {'ERM Sh':>7} {'TP+SKH':>7} {'+ERM':>7} {'Δuplift':>8} "
|
||||
f"{'corr(ERM,SKH)':>14} {'corr(ERM,TP)':>13}")
|
||||
for y in sorted(set(J.index.year)):
|
||||
sub = J[J.index.year == y]
|
||||
if len(sub) < 40:
|
||||
continue
|
||||
s2 = 0.75 * sub["T"] + 0.25 * sub["S"]
|
||||
s3 = 0.60 * sub["T"] + 0.25 * sub["S"] + 0.15 * sub["C"]
|
||||
print(f" {y:>5} {al._sh(sub['C']):>+7.2f} {al._sh(s2):>+7.2f} {al._sh(s3):>+7.2f} "
|
||||
f"{al._sh(s3) - al._sh(s2):>+8.2f} {sub['C'].corr(sub['S']):>+14.2f} "
|
||||
f"{sub['C'].corr(sub['T']):>+13.2f}")
|
||||
print(f" {'FULL':>5} {al._sh(J['C']):>+7.2f} {al._sh(two):>+7.2f} {al._sh(three):>+7.2f} "
|
||||
f"{al._sh(three) - al._sh(two):>+8.2f} {J['C'].corr(J['S']):>+14.2f} "
|
||||
f"{J['C'].corr(J['T']):>+13.2f}")
|
||||
JH = J[J.index >= HOLD]
|
||||
h2 = 0.75 * JH["T"] + 0.25 * JH["S"]
|
||||
h3 = 0.60 * JH["T"] + 0.25 * JH["S"] + 0.15 * JH["C"]
|
||||
print(f" {'HOLD':>5} {al._sh(JH['C']):>+7.2f} {al._sh(h2):>+7.2f} {al._sh(h3):>+7.2f} "
|
||||
f"{al._sh(h3) - al._sh(h2):>+8.2f} {JH['C'].corr(JH['S']):>+14.2f} "
|
||||
f"{JH['C'].corr(JH['T']):>+13.2f}")
|
||||
|
||||
|
||||
def part_E_codified_gate():
|
||||
"""Validazione END-TO-END del gate appena codificato in altlib: study_family_honest sulla
|
||||
famiglia ERM deve dare earns_slot_honest=False (sceglie in-sample-only + deflaziona)."""
|
||||
print("\n" + "=" * 78)
|
||||
print("E) GATE CODIFICATO (al.study_family_honest) sulla famiglia ERM — deve bocciare")
|
||||
print("=" * 78)
|
||||
grid = [dict(L_days=L, thr=t, long_flat=lf)
|
||||
for L in (1.0, 1.5, 2.0, 2.5, 3.0) for t in (0.30, 0.35, 0.40, 0.50)
|
||||
for lf in (False, True)]
|
||||
rep = al.study_family_honest("ERM", make_erm, grid, SCREEN_TFS)
|
||||
ch = rep["chosen"]
|
||||
print(f" n_celle={rep['n_cells']} cella in-sample-best: tf={ch['tf']} {ch['params']}")
|
||||
print(f" earns_slot_marginal={rep['earns_slot_marginal']} "
|
||||
f"deflated_sharpe={rep['deflated_sharpe']} (dsr_pass={rep['dsr_pass']})")
|
||||
print(f" => earns_slot_HONEST = {rep['earns_slot_honest']} "
|
||||
f"(atteso False: slot bocciato dal gate)")
|
||||
return rep["earns_slot_honest"]
|
||||
|
||||
|
||||
def main():
|
||||
print("ANALISI ROBUSTEZZA LEAD ERM (filone B) — read-only, nessun impatto live\n")
|
||||
dsr, win_full, sr0 = part_A_deflated()
|
||||
es_is, is_cell = part_B_insample_pick()
|
||||
es_ens = part_C_plateau_ensemble()
|
||||
part_D_where_edge()
|
||||
es_honest = part_E_codified_gate()
|
||||
print("\n" + "=" * 78)
|
||||
print("SINTESI ANALISI B")
|
||||
print("=" * 78)
|
||||
print(f" A) deflated-Sharpe winner = {dsr:.3f} ({'PASS' if dsr >= 0.95 else 'FAIL'} vs 0.95)")
|
||||
print(f" B) cella scelta in-sample-only earns_slot = {es_is} (cella {is_cell})")
|
||||
print(f" C) ensemble del plateau earns_slot = {es_ens}")
|
||||
print(f" E) gate codificato earns_slot_honest = {es_honest}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,103 @@
|
||||
"""LOGGER FORWARD della vol term-structure Deribit (l'UNICA via legittima per avere il dato).
|
||||
|
||||
Lo scan (probe_vol_termstructure.py) ha stabilito: la storia per-scadenza NON e' pubblica su Deribit
|
||||
(DVOL solo 30g; trade-history IV solo per strumenti vivi; il front rotola). Quindi un calendar-vol
|
||||
NON e' backtestabile oggi. Questo logger COSTRUISCE il dataset in avanti: ogni run prende lo snapshot
|
||||
ATM mark_iv per scadenza, lo interpola a TENOR FISSI (7/30/60/90/180g) e appende una riga per asset a
|
||||
data/raw/vol_term_<asset>.parquet. Idempotente per giorno (riscrive la riga del giorno).
|
||||
|
||||
Da cron giornaliero (NON auto-cablato: e' ricerca forward, va aggiunto a mano se si vuole):
|
||||
uv run python scripts/research/log_vol_termstructure.py
|
||||
|
||||
Dopo ~6-12 mesi di accumulo -> certificare (cross-venue, monotonia, spike) e SOLO ALLORA testare un
|
||||
calendar-vol (front vs back) su dati certificati. Nessun edge creduto prima.
|
||||
"""
|
||||
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 datetime as _dt
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import requests
|
||||
|
||||
BASE = "https://www.deribit.com/api/v2/public/"
|
||||
RAW = PROJECT_ROOT / "data" / "raw"
|
||||
TENORS = [7, 30, 60, 90, 180] # giorni-a-scadenza fissi su cui interpolare l'ATM IV
|
||||
|
||||
|
||||
def _get(method, **params):
|
||||
return requests.get(BASE + method, params=params, timeout=40).json().get("result", None)
|
||||
|
||||
|
||||
def _atm_curve(cur, now_ms):
|
||||
"""[(dte_giorni, atm_iv%)] ordinato per scadenza, dallo strike piu' vicino allo spot."""
|
||||
summ = _get("get_book_summary_by_currency", currency=cur, kind="option")
|
||||
idx = _get("get_index_price", index_name=f"{cur.lower()}_usd")
|
||||
if not summ or not idx:
|
||||
return None, None
|
||||
spot = float(idx["index_price"])
|
||||
best = {} # exp_ms -> (dist_strike, iv)
|
||||
for o in summ:
|
||||
p = o["instrument_name"].split("-")
|
||||
if len(p) != 4 or o.get("mark_iv") is None:
|
||||
continue
|
||||
try:
|
||||
exp_ms = int(_dt.datetime.strptime(p[1], "%d%b%y").replace(
|
||||
tzinfo=_dt.timezone.utc).timestamp() * 1000)
|
||||
except ValueError:
|
||||
continue
|
||||
d = abs(float(p[2]) - spot)
|
||||
if exp_ms not in best or d < best[exp_ms][0]:
|
||||
best[exp_ms] = (d, float(o["mark_iv"]))
|
||||
curve = sorted(((e - now_ms) / 86400_000.0, iv) for e, (_, iv) in best.items() if e > now_ms)
|
||||
return spot, curve
|
||||
|
||||
|
||||
def build_row(spot, curve, now_ms):
|
||||
"""Pura: da (spot, curve=[(dte,iv)], now_ms) -> riga con ATM IV interpolata ai TENOR fissi."""
|
||||
if not curve or len(curve) < 2:
|
||||
return None
|
||||
dtes = np.array([c[0] for c in curve]); ivs = np.array([c[1] for c in curve])
|
||||
row = {"date": pd.Timestamp(now_ms, unit="ms", tz="UTC").normalize(), "spot": spot}
|
||||
for t in TENORS:
|
||||
row[f"iv_{t}d"] = float(np.interp(t, dtes, ivs)) # interp lineare sui DTE disponibili
|
||||
row["slope_7_180"] = row["iv_180d"] - row["iv_7d"]
|
||||
return row
|
||||
|
||||
|
||||
def snapshot(cur, now_ms):
|
||||
spot, curve = _atm_curve(cur, now_ms)
|
||||
return build_row(spot, curve, now_ms) if curve else None
|
||||
|
||||
|
||||
def append_row(cur, row):
|
||||
fp = RAW / f"vol_term_{cur.lower()}.parquet"
|
||||
df = pd.read_parquet(fp) if fp.exists() else pd.DataFrame()
|
||||
df = df[df["date"] != row["date"]] if len(df) else df # idempotente per giorno
|
||||
df = pd.concat([df, pd.DataFrame([row])], ignore_index=True).sort_values("date")
|
||||
df.to_parquet(fp, index=False)
|
||||
return fp, len(df)
|
||||
|
||||
|
||||
def main():
|
||||
now_ms = int(pd.Timestamp.now("UTC").timestamp() * 1000)
|
||||
print("=" * 78)
|
||||
print(" LOG vol term-structure (forward dataset builder)")
|
||||
print("=" * 78)
|
||||
for cur in ("BTC", "ETH"):
|
||||
row = snapshot(cur, now_ms)
|
||||
if row is None:
|
||||
print(f" {cur}: snapshot fallito (chain vuota?)"); continue
|
||||
fp, n = append_row(cur, row)
|
||||
ivs = " ".join(f"{t}g {row[f'iv_{t}d']:.1f}%" for t in TENORS)
|
||||
print(f" {cur} {row['date'].date()} spot ${row['spot']:,.0f} | {ivs} | "
|
||||
f"slope7-180 {row['slope_7_180']:+.1f}pp -> {n} righe in {fp.name}")
|
||||
print("\n (forward-only: serve accumulo di mesi prima di poter certificare e testare un calendar-vol)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,458 @@
|
||||
"""macro_regime_gate.py — Filone D: MACRO REGIME-GATE sul book crypto (eseguibile).
|
||||
|
||||
TESI
|
||||
----
|
||||
Usare segnali macro/cross-market — equity (SPY/QQQ/IWM), credito (HYG/LQD), oro (GLD/SLV),
|
||||
tassi (TLT/IEF) — come GATE risk-on/risk-off applicato al book crypto (BTC/ETH) per migliorare
|
||||
il TIMING del drawdown di TP01. Quando il regime macro e' risk-off (credito che cede, equity
|
||||
sotto trend, fuga sui bond) -> riduci/azzera l'esposizione crypto; risk-on -> lascia agire TP01.
|
||||
E' ESEGUIBILE perche' GATA solo BTC/ETH perp (non aggiunge gambe).
|
||||
|
||||
NON e' un lead-lag direzionale (gia' esplorato e morto: vedi 2026-06-22/-23 leadlag diaries).
|
||||
L'angolo nuovo = un OVERLAY binario/continuo di DE-RISK sul book.
|
||||
|
||||
IL RISCHIO (da CLAUDE.md): il gate di de-risk rischia di essere RIDONDANTE col trend — TP01 e'
|
||||
gia' long-flat e va a 0 nei crash (lezione DVOL-spike "ridondante col trend, Delta 0.00"). Questo
|
||||
script DEVE dimostrare che il gate aggiunge OLTRE quel che TP01 fa da solo, altrimenti SCARTATO.
|
||||
|
||||
CAUSALITA' (fusi orari, regola di prim'ordine)
|
||||
----------------------------------------------
|
||||
- Barre equity: open-labeled a 00:00 del giorno di trading; il CLOSE e' ~20:00-21:00 UTC dello
|
||||
STESSO giorno (NYSE 16:00 ET).
|
||||
- Barre crypto 1d: open-labeled a 00:00; il CLOSE e' a 00:00 UTC del giorno DOPO. TP01 decide la
|
||||
posizione a close[i] e la TIENE durante la barra i+1 (eval_weights shift-a per te).
|
||||
- Quindi: gate[i] allineato (merge_asof backward, equity-label <= crypto-label day i) usa il
|
||||
close equity del giorno i (noto ~20:00 day i) per la posizione tenuta durante la barra i+1
|
||||
(giorno i+1). Margine causale >= 4h. Leak-free. Variante STRICT (equity-label < crypto-label)
|
||||
come margine extra. Verifica con al.causality_ok + day_boundary_robust.
|
||||
|
||||
USO: uv run python scripts/research/macro_regime_gate.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
sys.path.insert(0, str(_ROOT / "scripts" / "research" / "alt"))
|
||||
|
||||
import altlib as al # noqa: E402
|
||||
from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio # noqa: E402
|
||||
|
||||
DATA = _ROOT / "data" / "raw"
|
||||
HOLDOUT = al.HOLDOUT
|
||||
ASSETS = ("BTC", "ETH")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# MACRO FRAME — ETF daily, allineati causalmente sul calendario SPY (master).
|
||||
# ===========================================================================
|
||||
def _load_eq(sym: str) -> pd.DataFrame:
|
||||
p = DATA / f"eq_{sym}_1d.parquet"
|
||||
df = pd.read_parquet(p).sort_values("timestamp").reset_index(drop=True)
|
||||
df["dt"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
return df[["timestamp", "dt", "close"]].rename(columns={"close": sym})
|
||||
|
||||
|
||||
def macro_frame(syms=("spy", "qqq", "iwm", "hyg", "lqd", "gld", "slv", "tlt", "ief")) -> pd.DataFrame:
|
||||
"""Frame macro sul calendario NYSE (master = SPY). Ogni colonna e' il close equity,
|
||||
allineato causale (merge_asof backward) -> nessun valore futuro per riga."""
|
||||
base = _load_eq("spy")[["timestamp", "dt"]].copy()
|
||||
out = base
|
||||
for s in syms:
|
||||
e = _load_eq(s)[["timestamp", s]]
|
||||
out = pd.merge_asof(out, e, on="timestamp", direction="backward")
|
||||
return out
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# GATE BUILDERS — ognuno ritorna (timestamp_ms, gate in [0,1]) sul calendario equity.
|
||||
# Tutti CAUSALI: la riga i usa solo close <= riga i (rolling/SMA, niente expanding-future).
|
||||
# gate=1 => risk-on (TP01 pieno); gate=g_off (0 o 0.5) => risk-off (de-risk).
|
||||
# ===========================================================================
|
||||
def _sma(x: np.ndarray, n: int) -> np.ndarray:
|
||||
return pd.Series(x).rolling(n, min_periods=n).mean().values
|
||||
|
||||
|
||||
def _ret(x: np.ndarray, n: int) -> np.ndarray:
|
||||
r = np.full(len(x), np.nan)
|
||||
r[n:] = x[n:] / x[:-n] - 1.0
|
||||
return r
|
||||
|
||||
|
||||
def gate_trend(mf: pd.DataFrame, col: str, n: int, g_off: float) -> pd.DataFrame:
|
||||
"""Risk-on se col_close > SMA(col, n). Filtro di trend classico (SPY200, HYG, ...)."""
|
||||
c = mf[col].values.astype(float)
|
||||
on = c > _sma(c, n)
|
||||
g = np.where(on, 1.0, g_off)
|
||||
g[~np.isfinite(c)] = np.nan
|
||||
g[np.isnan(_sma(c, n))] = np.nan
|
||||
return pd.DataFrame({"timestamp": mf["timestamp"].values, "gate": g})
|
||||
|
||||
|
||||
def gate_ratio(mf: pd.DataFrame, num: str, den: str, n: int, g_off: float) -> pd.DataFrame:
|
||||
"""Risk-on se ratio num/den (proxy spread di credito) > la sua SMA(n).
|
||||
HYG/LQD o HYG/IEF in calo = spread che si allarga = risk-off."""
|
||||
ratio = (mf[num].values.astype(float) / mf[den].values.astype(float))
|
||||
on = ratio > _sma(ratio, n)
|
||||
g = np.where(on, 1.0, g_off)
|
||||
g[~np.isfinite(ratio)] = np.nan
|
||||
g[np.isnan(_sma(ratio, n))] = np.nan
|
||||
return pd.DataFrame({"timestamp": mf["timestamp"].values, "gate": g})
|
||||
|
||||
|
||||
def gate_combo(mf: pd.DataFrame, n: int, g_off: float, thr: float = 0.5,
|
||||
continuous: bool = False) -> pd.DataFrame:
|
||||
"""Score di regime = media di 3 voti risk-on: SPY>SMA, HYG>SMA, HYG/LQD>SMA.
|
||||
continuous=True -> gate = g_off + (1-g_off)*score (de-risk graduale).
|
||||
continuous=False -> gate = 1 se score>=thr else g_off (binario su maggioranza)."""
|
||||
spy = mf["spy"].values.astype(float)
|
||||
hyg = mf["hyg"].values.astype(float)
|
||||
ratio = hyg / mf["lqd"].values.astype(float)
|
||||
votes = np.vstack([spy > _sma(spy, n), hyg > _sma(hyg, n), ratio > _sma(ratio, n)]).astype(float)
|
||||
warm = np.isnan(_sma(spy, n)) | np.isnan(_sma(hyg, n)) | np.isnan(_sma(ratio, n))
|
||||
score = votes.mean(axis=0)
|
||||
if continuous:
|
||||
g = g_off + (1.0 - g_off) * score
|
||||
else:
|
||||
g = np.where(score >= thr, 1.0, g_off)
|
||||
g[warm] = np.nan
|
||||
return pd.DataFrame({"timestamp": mf["timestamp"].values, "gate": g})
|
||||
|
||||
|
||||
def gate_flight(mf: pd.DataFrame, safe: str, risk: str, n: int, g_off: float) -> pd.DataFrame:
|
||||
"""Fuga verso la sicurezza: risk-off quando il safe-asset (TLT/GLD) sale MENTRE il risk
|
||||
(SPY) scende sull'orizzonte n. Divergenza risk-off classica (flight-to-quality)."""
|
||||
s = mf[safe].values.astype(float)
|
||||
rk = mf[risk].values.astype(float)
|
||||
off = (_ret(s, n) > 0) & (_ret(rk, n) < 0)
|
||||
g = np.where(off, g_off, 1.0)
|
||||
warm = np.isnan(_ret(s, n)) | np.isnan(_ret(rk, n))
|
||||
g[warm] = np.nan
|
||||
return pd.DataFrame({"timestamp": mf["timestamp"].values, "gate": g})
|
||||
|
||||
|
||||
def gate_eqvol(mf: pd.DataFrame, n: int, win: int, z: float, g_off: float) -> pd.DataFrame:
|
||||
"""Regime di vol equity: de-risk quando la vol realizzata SPY (win g) e' alta vs la sua
|
||||
storia espandente-causale (z-score > z). Proxy 'VIX spike' senza VIX."""
|
||||
spy = mf["spy"].values.astype(float)
|
||||
r = np.zeros(len(spy)); r[1:] = spy[1:] / spy[:-1] - 1.0
|
||||
rv = pd.Series(r).rolling(win, min_periods=win).std().values
|
||||
zsc = al.zscore(rv, n)
|
||||
off = zsc > z
|
||||
g = np.where(off, g_off, 1.0)
|
||||
g[~np.isfinite(zsc)] = np.nan
|
||||
return pd.DataFrame({"timestamp": mf["timestamp"].values, "gate": g})
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# ALIGN GATE -> CRYPTO (causale) + GATED TARGET
|
||||
# ===========================================================================
|
||||
def align_gate(gate_df: pd.DataFrame, crypto_df: pd.DataFrame, strict: bool = False) -> np.ndarray:
|
||||
"""Allinea il gate (calendario equity) alle barre crypto. merge_asof backward:
|
||||
crypto-label day i -> ultimo gate equity con label <= day i (strict: < day i).
|
||||
NaN pre-storia -> gate=1 (nessun de-risk quando non c'e' info)."""
|
||||
left = pd.DataFrame({"timestamp": crypto_df["timestamp"].astype("int64").values})
|
||||
g = gate_df.dropna(subset=["gate"]).sort_values("timestamp")
|
||||
m = pd.merge_asof(left, g, on="timestamp", direction="backward",
|
||||
allow_exact_matches=not strict)
|
||||
return pd.Series(m["gate"].values).ffill().fillna(1.0).values
|
||||
|
||||
|
||||
def tp01_pos(df: pd.DataFrame) -> np.ndarray:
|
||||
return TrendPortfolio(**CANONICAL).target_series(df)
|
||||
|
||||
|
||||
def make_target_fn(gate_builder, strict: bool = False):
|
||||
"""Ritorna target_fn(df, asset) = posizione TP01 * gate macro allineato (causale).
|
||||
gate_builder() costruisce il gate sul calendario equity una volta (cache esterna)."""
|
||||
_MF = macro_frame()
|
||||
gate_df = gate_builder(_MF)
|
||||
|
||||
def target_fn(df: pd.DataFrame, asset: str = "") -> np.ndarray:
|
||||
pos = tp01_pos(df)
|
||||
g = align_gate(gate_df, df, strict=strict)
|
||||
return pos * g
|
||||
return target_fn, gate_df
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# EVALUATION — solo vs gated, combo 50/50 + per-asset, FULL/HOLD/DD/CAGR.
|
||||
# ===========================================================================
|
||||
def _combo_daily(target_fn) -> pd.Series:
|
||||
series = {}
|
||||
for a in ASSETS:
|
||||
df = al.get(a, "1d")
|
||||
ev = al.eval_weights(df, target_fn(df, a))
|
||||
series[a] = pd.Series(ev["net"], index=ev["idx"])
|
||||
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
|
||||
return al._to_daily(0.5 * J[ASSETS[0]] + 0.5 * J[ASSETS[1]])
|
||||
|
||||
|
||||
def _sh(s):
|
||||
return al._sh(s)
|
||||
|
||||
|
||||
def _metrics(s: pd.Series) -> dict:
|
||||
sh = _sh(s)
|
||||
eq = np.cumprod(1.0 + s.values)
|
||||
pk = np.maximum.accumulate(eq)
|
||||
dd = float(np.max((pk - eq) / pk)) if len(eq) else 0.0
|
||||
yrs = max((s.index[-1] - s.index[0]).days / 365.25, 1e-6)
|
||||
cagr = eq[-1] ** (1 / yrs) - 1 if len(eq) and eq[-1] > 0 else -1.0
|
||||
return dict(sharpe=round(sh, 3), dd=round(dd, 4), cagr=round(cagr, 4))
|
||||
|
||||
|
||||
def eval_solo_vs_gated(target_fn):
|
||||
"""Combo 50/50: solo (gate=1) vs gated. Ritorna dict con FULL e HOLD per entrambi."""
|
||||
solo_fn = lambda df, a="": tp01_pos(df)
|
||||
solo = _combo_daily(solo_fn)
|
||||
gated = _combo_daily(target_fn)
|
||||
J = pd.concat({"solo": solo, "gated": gated}, axis=1, join="inner").dropna()
|
||||
JH = J[J.index >= HOLDOUT]
|
||||
return dict(
|
||||
full_solo=_metrics(J["solo"]), full_gated=_metrics(J["gated"]),
|
||||
hold_solo=_metrics(JH["solo"]), hold_gated=_metrics(JH["gated"]),
|
||||
n=len(J), nh=len(JH), solo_series=J["solo"], gated_series=J["gated"])
|
||||
|
||||
|
||||
def per_asset_table(target_fn) -> dict:
|
||||
out = {}
|
||||
for a in ASSETS:
|
||||
df = al.get(a, "1d")
|
||||
solo = al.eval_weights(df, tp01_pos(df))
|
||||
gated = al.eval_weights(df, target_fn(df, a))
|
||||
out[a] = dict(
|
||||
full_solo=dict(sharpe=solo["full"]["sharpe"], dd=solo["full"]["maxdd"], cagr=solo["full"]["cagr"]),
|
||||
full_gated=dict(sharpe=gated["full"]["sharpe"], dd=gated["full"]["maxdd"], cagr=gated["full"]["cagr"]),
|
||||
hold_solo=dict(sharpe=solo["holdout"].get("sharpe", 0.0)),
|
||||
hold_gated=dict(sharpe=gated["holdout"].get("sharpe", 0.0)))
|
||||
return out
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# REDUNDANCY DIAGNOSTIC — il controllo DECISIVO contro il trend.
|
||||
# ===========================================================================
|
||||
def redundancy_diag(gate_df: pd.DataFrame) -> dict:
|
||||
"""Quanto FA davvero il gate, dato che TP01 e' gia' flat nei crash?
|
||||
- exposure TP01 nei giorni risk-off (gate<1) vs risk-on: se gia' ~0 -> ridondante.
|
||||
- quota di giorni in cui il gate riduce una posizione NON gia' flat (lavoro effettivo).
|
||||
- corr fra (1-gate) e (1-exposure_norm)."""
|
||||
lev = CANONICAL["leverage"]
|
||||
rows = []
|
||||
for a in ASSETS:
|
||||
df = al.get(a, "1d")
|
||||
pos = tp01_pos(df)
|
||||
expo = np.clip(np.abs(pos) / lev, 0, 1) # esposizione normalizzata 0..1
|
||||
g = align_gate(gate_df, df)
|
||||
dt = pd.to_datetime(df["datetime"], utc=True)
|
||||
m = pd.DataFrame({"dt": dt, "expo": expo, "g": g})
|
||||
m = m[m["dt"] >= dt.iloc[0]] # tutto
|
||||
roff = m["g"] < 0.999
|
||||
ron = ~roff
|
||||
flat_thr = 0.05
|
||||
# lavoro effettivo: gate<1 E TP01 non gia' flat
|
||||
work = roff & (m["expo"] > flat_thr)
|
||||
rows.append(dict(
|
||||
asset=a,
|
||||
expo_riskoff=round(float(m.loc[roff, "expo"].mean()) if roff.any() else 0.0, 3),
|
||||
expo_riskon=round(float(m.loc[ron, "expo"].mean()) if ron.any() else 0.0, 3),
|
||||
pct_days_riskoff=round(float(roff.mean()), 3),
|
||||
pct_days_gate_works=round(float(work.mean()), 3),
|
||||
corr_1mg_1mexpo=round(float(np.corrcoef(1 - m["g"], 1 - m["expo"])[0, 1])
|
||||
if m["g"].std() > 0 else float("nan"), 3),
|
||||
))
|
||||
return {r["asset"]: r for r in rows}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# RUNNER
|
||||
# ===========================================================================
|
||||
GATES = {
|
||||
# binari classici
|
||||
"SPY>MA200": lambda mf: gate_trend(mf, "spy", 200, 0.0),
|
||||
"SPY>MA100": lambda mf: gate_trend(mf, "spy", 100, 0.0),
|
||||
"SPY>MA50": lambda mf: gate_trend(mf, "spy", 50, 0.0),
|
||||
"SPY>MA200_half": lambda mf: gate_trend(mf, "spy", 200, 0.5),
|
||||
"HYG>MA100": lambda mf: gate_trend(mf, "hyg", 100, 0.0),
|
||||
"HYG/LQD>MA50": lambda mf: gate_ratio(mf, "hyg", "lqd", 50, 0.0),
|
||||
"HYG/LQD>MA100": lambda mf: gate_ratio(mf, "hyg", "lqd", 100, 0.0),
|
||||
# combinati / score
|
||||
"COMBO_maj100": lambda mf: gate_combo(mf, 100, 0.0, thr=0.5),
|
||||
"COMBO_all100": lambda mf: gate_combo(mf, 100, 0.0, thr=0.99),
|
||||
"COMBO_cont100": lambda mf: gate_combo(mf, 100, 0.0, continuous=True),
|
||||
"COMBO_cont100h": lambda mf: gate_combo(mf, 100, 0.5, continuous=True),
|
||||
# flight-to-quality / vol
|
||||
"TLTup&SPYdn20": lambda mf: gate_flight(mf, "tlt", "spy", 20, 0.0),
|
||||
"GLDup&SPYdn20": lambda mf: gate_flight(mf, "gld", "spy", 20, 0.0),
|
||||
"SPYvol_z1": lambda mf: gate_eqvol(mf, 250, 20, 1.0, 0.0),
|
||||
}
|
||||
|
||||
|
||||
def delever_frontier(target_vols=(0.10, 0.12, 0.14, 0.16, 0.18, 0.20)) -> dict:
|
||||
"""CONTROLLO DECISIVO (lezione DVOL-overlay): per meno DD la leva e' target_vol, non un
|
||||
overlay. Frontiera DD/Sharpe di TP01 puro a target_vol decrescente. Se il miglior gate
|
||||
sta SOPRA (DD piu' alto a parita' di Sharpe, o Sharpe piu' basso a parita' di DD) di questa
|
||||
frontiera, il suo taglio di DD e' solo de-levering replicabile meglio senza macro."""
|
||||
out = {}
|
||||
for tv in target_vols:
|
||||
cfg = {**CANONICAL, "target_vol": tv}
|
||||
fn = lambda df, a="", cfg=cfg: TrendPortfolio(**cfg).target_series(df)
|
||||
c = _combo_daily(fn)
|
||||
out[tv] = _metrics(c)
|
||||
return out
|
||||
|
||||
|
||||
def fmt_cmp(label, m_solo, m_gated) -> str:
|
||||
ds = m_gated["sharpe"] - m_solo["sharpe"]
|
||||
dd = m_gated["dd"] - m_solo["dd"]
|
||||
dc = m_gated.get("cagr", 0) - m_solo.get("cagr", 0)
|
||||
return (f" {label:5s} Sh {m_solo['sharpe']:+.2f}->{m_gated['sharpe']:+.2f} (d{ds:+.2f}) "
|
||||
f"DD {m_solo['dd']*100:4.1f}%->{m_gated['dd']*100:4.1f}% (d{dd*100:+.1f}pp) "
|
||||
f"CAGR {m_solo.get('cagr',0)*100:+5.1f}%->{m_gated.get('cagr',0)*100:+5.1f}% (d{dc*100:+.1f}pp)")
|
||||
|
||||
|
||||
def main():
|
||||
pd.set_option("display.width", 160)
|
||||
print("=" * 92)
|
||||
print("MACRO REGIME-GATE sul book crypto (TP01 BTC/ETH) — Filone D")
|
||||
print(f" TP01 CANONICAL = {CANONICAL}")
|
||||
print(f" HOLD-OUT >= {HOLDOUT.date()} fee {al.FEE_SIDE*2*100:.2f}%RT")
|
||||
mf = macro_frame()
|
||||
print(f" Macro frame: {len(mf)} barre {mf['dt'].iloc[0].date()} -> {mf['dt'].iloc[-1].date()} "
|
||||
f"cols={[c for c in mf.columns if c not in ('timestamp','dt')]}")
|
||||
print("=" * 92)
|
||||
|
||||
# ---- 1) SWEEP DI TUTTI I GATE (combo 50/50) -------------------------------------
|
||||
print("\n[1] SWEEP GATE — combo 50/50 BTC+ETH, FULL & HOLD-OUT, vs TP01-solo\n")
|
||||
results = {}
|
||||
for name, builder in GATES.items():
|
||||
tf, gate_df = make_target_fn(builder)
|
||||
cmp = eval_solo_vs_gated(tf)
|
||||
results[name] = (cmp, gate_df, tf)
|
||||
print(f"GATE {name}")
|
||||
print(fmt_cmp("FULL", cmp["full_solo"], cmp["full_gated"]))
|
||||
print(fmt_cmp("HOLD", cmp["hold_solo"], cmp["hold_gated"]))
|
||||
|
||||
# baseline (solo) numbers come from any cmp
|
||||
any_cmp = next(iter(results.values()))[0]
|
||||
print(f"\n [baseline TP01-solo] FULL Sh {any_cmp['full_solo']['sharpe']} DD {any_cmp['full_solo']['dd']*100:.1f}% "
|
||||
f"CAGR {any_cmp['full_solo']['cagr']*100:.1f}% | HOLD Sh {any_cmp['hold_solo']['sharpe']} "
|
||||
f"DD {any_cmp['hold_solo']['dd']*100:.1f}% CAGR {any_cmp['hold_solo']['cagr']*100:.1f}%")
|
||||
|
||||
# ---- 2) SELEZIONE: miglior gate per HOLD-OUT Sharpe, poi per riduzione DD --------
|
||||
def score(name):
|
||||
c = results[name][0]
|
||||
return (c["hold_gated"]["sharpe"], -c["full_gated"]["dd"])
|
||||
best = max(results, key=score)
|
||||
# anche il "miglior DD-cutter" che non peggiora troppo lo Sharpe FULL
|
||||
dd_best = min(results, key=lambda n: results[n][0]["full_gated"]["dd"])
|
||||
print(f"\n[2] Miglior gate per HOLD-OUT Sharpe: {best}")
|
||||
print(f" Miglior gate per DD FULL ridotto : {dd_best}")
|
||||
|
||||
# ---- CONTROLLO DECISIVO: de-lever frontier (target_vol) -------------------------
|
||||
print("\n[2b] CONTROLLO DECISIVO — TP01 puro a target_vol piu' basso (de-lever) vs gate:")
|
||||
fr = delever_frontier()
|
||||
for tv, m in fr.items():
|
||||
print(f" target_vol {tv:.2f}: FULL Sh {m['sharpe']:+.2f} DD {m['dd']*100:4.1f}% CAGR {m['cagr']*100:+5.1f}%")
|
||||
print(" -> i gate de-leveranti (COMBO_cont, SPYvol) vanno confrontati con QUESTA frontiera:")
|
||||
for n in ("COMBO_cont100", "COMBO_cont100h", "SPYvol_z1", "SPY>MA200_half"):
|
||||
if n in results:
|
||||
g = results[n][0]["full_gated"]
|
||||
print(f" {n:16s}: FULL Sh {g['sharpe']:+.2f} DD {g['dd']*100:4.1f}% CAGR {g['cagr']*100:+5.1f}%")
|
||||
|
||||
for tag, name in [("BEST-HOLD", best), ("BEST-DD", dd_best)]:
|
||||
if tag == "BEST-DD" and dd_best == best:
|
||||
continue
|
||||
deep_dive(tag, name, results)
|
||||
|
||||
|
||||
def deep_dive(tag, name, results):
|
||||
cmp, gate_df, tf = results[name]
|
||||
print("\n" + "=" * 92)
|
||||
print(f"[DEEP DIVE {tag}] GATE = {name}")
|
||||
print("=" * 92)
|
||||
|
||||
# per-asset
|
||||
print("\n Per-asset (TP01-solo -> TP01+gate):")
|
||||
pa = per_asset_table(tf)
|
||||
for a in ASSETS:
|
||||
d = pa[a]
|
||||
print(f" {a}: FULL Sh {d['full_solo']['sharpe']:+.2f}->{d['full_gated']['sharpe']:+.2f} "
|
||||
f"DD {d['full_solo']['dd']*100:.0f}%->{d['full_gated']['dd']*100:.0f}% "
|
||||
f"CAGR {d['full_solo']['cagr']*100:+.0f}%->{d['full_gated']['cagr']*100:+.0f}% | "
|
||||
f"HOLD Sh {d['hold_solo']['sharpe']:+.2f}->{d['hold_gated']['sharpe']:+.2f}")
|
||||
|
||||
# ---- 3) CONTROLLO RIDONDANZA COL TREND ------------------------------------------
|
||||
print("\n [3] CONTROLLO RIDONDANZA COL TREND (il test decisivo):")
|
||||
rd = redundancy_diag(gate_df)
|
||||
for a in ASSETS:
|
||||
r = rd[a]
|
||||
print(f" {a}: exposure TP01 nei giorni risk-off={r['expo_riskoff']} vs risk-on={r['expo_riskon']} "
|
||||
f"| giorni risk-off {r['pct_days_riskoff']*100:.0f}% "
|
||||
f"giorni in cui il gate LAVORA (riduce pos non-flat) {r['pct_days_gate_works']*100:.0f}% "
|
||||
f"| corr(1-gate, 1-expo)={r['corr_1mg_1mexpo']}")
|
||||
print(" -> se exposure-risk-off ~ exposure-risk-on e 'gate-lavora' e' basso => RIDONDANTE col trend.")
|
||||
|
||||
# ---- 4) MARGINAL SCORER -----------------------------------------------------------
|
||||
print("\n [4] MARGINAL SCORER vs TP01 (gate come candidato-sleeve):")
|
||||
rep = al.study_marginal(f"GATE[{name}]", tf, tf="1d")
|
||||
print(al.fmt_marginal(rep))
|
||||
|
||||
# overlay-delta: lo STREAM incrementale del gate = gated - solo (e' alpha o hedge?)
|
||||
delta = (cmp["gated_series"] - cmp["solo_series"]).dropna()
|
||||
print("\n overlay-delta (gated - solo) come stream a se':")
|
||||
md = al.marginal_vs_tp01(delta)
|
||||
print(f" verdict={md.get('marginal_verdict')} corr->TP01 {md.get('corr_full')} "
|
||||
f"is_hedge={md.get('is_hedge')} uplift TP01-up {md.get('uplift_tp01_up')} / "
|
||||
f"TP01-down {md.get('uplift_tp01_down')} cand-Sh full {md.get('cand_full_sharpe')}")
|
||||
|
||||
# ---- 5) FEE SWEEP -----------------------------------------------------------------
|
||||
print("\n [5] FEE SWEEP (combo 50/50 gated, FULL Sharpe):")
|
||||
for f in al.FEE_SWEEP:
|
||||
series = {}
|
||||
for a in ASSETS:
|
||||
df = al.get(a, "1d")
|
||||
ev = al.eval_weights(df, tf(df, a), fee_side=f)
|
||||
series[a] = pd.Series(ev["net"], index=ev["idx"])
|
||||
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
|
||||
d = al._to_daily(0.5 * J[ASSETS[0]] + 0.5 * J[ASSETS[1]])
|
||||
print(f" {2*f*100:.2f}%RT -> Sh {_sh(d):+.2f}")
|
||||
|
||||
# ---- 6) ESEGUIBILITA' a $600 ------------------------------------------------------
|
||||
print("\n [6] ESEGUIBILITA' a $600 (eval_weights_smallcap, haircut reale):")
|
||||
for a in ASSETS:
|
||||
df = al.get(a, "1d")
|
||||
sc = al.eval_weights_smallcap(df, tf(df, a), capital=600, min_order=5)
|
||||
print(f" {a}: modeled Sh {sc['modeled']['sharpe']:+.2f} -> real Sh {sc['realistic']['sharpe']:+.2f} "
|
||||
f"haircut {sc['sharpe_haircut']:+.2f} trade eseguiti {sc['n_executed_trades']}")
|
||||
|
||||
# ---- 7) LEAK / BOUNDARY -----------------------------------------------------------
|
||||
print("\n [7] LEAK-FREE & BOUNDARY:")
|
||||
cz = al.causality_ok(tf, tf="1d")
|
||||
print(f" causality_ok={cz['ok']} (max_tail_diff {cz['max_tail_diff']}, checked {cz['checked']})")
|
||||
# variante STRICT (equity-label < crypto-label): margine causale extra
|
||||
tf_strict, _ = make_target_fn(GATES[name], strict=True)
|
||||
cmp_s = eval_solo_vs_gated(tf_strict)
|
||||
print(f" STRICT align (1 barra equity extra di lag): FULL Sh {cmp_s['full_gated']['sharpe']:+.2f} "
|
||||
f"(vs {cmp['full_gated']['sharpe']:+.2f}) HOLD Sh {cmp_s['hold_gated']['sharpe']:+.2f} "
|
||||
f"(vs {cmp['hold_gated']['sharpe']:+.2f}) -> robusto se ~uguale")
|
||||
db = al.day_boundary_robust(tf, tf="1d")
|
||||
print(f" day_boundary_robust={db['verdict']} (spread {db.get('spread')}, per-offset {db.get('per_offset')})")
|
||||
|
||||
# ---- 8) PLATEAU (solo per i trend MA) ---------------------------------------------
|
||||
if name.startswith("SPY>MA") or name.startswith("HYG"):
|
||||
print("\n [8] PLATEAU su finestra MA (SPY trend), g_off=0:")
|
||||
for n in (50, 100, 150, 200, 250):
|
||||
tfn, _ = make_target_fn(lambda mf, n=n: gate_trend(mf, "spy", n, 0.0))
|
||||
cc = eval_solo_vs_gated(tfn)
|
||||
print(f" SPY>MA{n:3d}: FULL Sh {cc['full_gated']['sharpe']:+.2f} DD {cc['full_gated']['dd']*100:.1f}% "
|
||||
f"HOLD Sh {cc['hold_gated']['sharpe']:+.2f} DD {cc['hold_gated']['dd']*100:.1f}%")
|
||||
|
||||
|
||||
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,296 @@
|
||||
"""GAMMA SCALPING (long-vol) — "scalping BTC/ETH con copertura in opzioni", valutato onestamente.
|
||||
|
||||
Interpretazione rigorosa di "fare scalping con copertura in opzioni" = GAMMA SCALPING:
|
||||
compri un'opzione (la COPERTURA = long gamma), delta-hedgi il sottostante a cadenza fissa
|
||||
(lo SCALP), e il P&L netto e' ~ dollar-gamma * (vol realizzata^2 - vol implicita^2). E' lo
|
||||
SPECCHIO ESATTO del VRP01 (short-vol): VRP01 incassa IV-RV (positivo in media), il gamma
|
||||
scalping incassa RV-IV (negativo in media, ma CONVESSO -> paga nei crash).
|
||||
|
||||
Modello (mirror della struttura VRP per comparabilita'):
|
||||
- long STRADDLE ATM, tenor settimanale (7g), strike = spot all'ingresso.
|
||||
- IV = DVOL Deribit (data/raw/dvol_*.parquet) — la stessa fonte IV del VRP.
|
||||
- delta-hedge GIORNALIERO sui prezzi certificati 1d (BTC/ETH).
|
||||
- P&L delta-hedged classico per step: DG_t * [(dS/S)^2 - sigma^2*dt],
|
||||
DG_t = dollar-gamma dello straddle = phi(d1)*S/(sigma*sqrt(tau)).
|
||||
- fee opzioni Deribit cap 12.5% del premio (come VRP) + fee perp sull'hedge turnover.
|
||||
- return-on-notional (pnl/S0), poi VOL-TARGET 20% annuo per apples-to-apples con gli altri sleeve.
|
||||
|
||||
Varianti testate:
|
||||
NAKED -> sempre long gamma (baseline: deve perdere il premio in media).
|
||||
CHEAP-GATED -> entra solo quando IV-rank < gate (compri vol a sconto = specchio del gate VRP).
|
||||
RICH-SKIP -> entra sempre tranne quando IV-rank > skip (evita di pagare vol carissima).
|
||||
|
||||
Output: metriche standalone + per-anno + SCORING MARGINALE vs TP01 (corr, blend uplift, is_hedge,
|
||||
has_insample_edge) + il muro di ESEGUIBILITA' a $600 (min size opzioni Deribit).
|
||||
|
||||
uv run python scripts/research/options_gamma_scalp.py
|
||||
|
||||
ONESTA': premio MODELLATO su DVOL ATM (skew non esplicito) — stesso caveat del VRP. Long-vol da
|
||||
modello: meno pericoloso dello short-vol (loss capped al premio), ma resta un LEAD da modello.
|
||||
"""
|
||||
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" / "alt"))
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from scipy.stats import norm
|
||||
|
||||
from src.data.downloader import load_data
|
||||
from src.strategies.trend_portfolio import resample_1d
|
||||
from altlib import marginal_vs_tp01 # type: ignore
|
||||
|
||||
RAW = PROJECT_ROOT / "data" / "raw"
|
||||
SQ365 = np.sqrt(365.25)
|
||||
DT = 1.0 / 365.25
|
||||
|
||||
# fee model (mirror VRP): opzioni Deribit cap 12.5% del premio; perp taker 0.05%/lato sull'hedge.
|
||||
OPT_FEE_FRAC = 0.125
|
||||
HEDGE_FEE_SIDE = 0.0005
|
||||
TENOR_D = 7
|
||||
|
||||
|
||||
def _bs_straddle(S, K, T, sig):
|
||||
"""Premio BS di uno straddle ATM (call+put), r=0."""
|
||||
if T <= 0 or sig <= 0:
|
||||
return abs(S - K)
|
||||
d1 = (np.log(S / K) + 0.5 * sig ** 2 * T) / (sig * np.sqrt(T))
|
||||
d2 = d1 - sig * np.sqrt(T)
|
||||
call = S * norm.cdf(d1) - K * norm.cdf(d2)
|
||||
put = K * norm.cdf(-d2) - S * norm.cdf(-d1)
|
||||
return call + put
|
||||
|
||||
|
||||
def _dvol(asset: str) -> pd.Series:
|
||||
dv = pd.read_parquet(RAW / f"dvol_{asset.lower()}.parquet")
|
||||
return pd.Series(dv["close"].values.astype(float),
|
||||
index=pd.to_datetime(dv["timestamp"], unit="ms", utc=True)) / 100.0
|
||||
|
||||
|
||||
def _load(asset: str):
|
||||
df = resample_1d(load_data(asset, "1h"))
|
||||
s = pd.Series(df["close"].values.astype(float), index=pd.to_datetime(df["datetime"]))
|
||||
if s.index.tz is None:
|
||||
s.index = s.index.tz_localize("UTC")
|
||||
J = pd.concat({"px": s, "dvol": _dvol(asset)}, axis=1, join="inner").sort_index().dropna()
|
||||
return J["px"].values, J["dvol"].values / 1.0, J.index
|
||||
|
||||
|
||||
def _load_hourly(asset: str):
|
||||
df = load_data(asset, "1h")
|
||||
s = pd.Series(df["close"].values.astype(float),
|
||||
index=pd.to_datetime(df["timestamp"], unit="ms", utc=True)).sort_index()
|
||||
return s
|
||||
|
||||
|
||||
def rv_iv_diagnostic(asset: str):
|
||||
"""Il CUORE strutturale: vol realizzata (a vari campionamenti) vs vol implicita (DVOL).
|
||||
Se RV < IV in media -> il long gamma PERDE gross, prima di ogni fee. E' il VRP, di segno opposto."""
|
||||
px, dvf, idx = _load(asset)
|
||||
iv = float(np.mean(dvf))
|
||||
rdaily = np.diff(np.log(px))
|
||||
rv_daily = float(np.std(rdaily) * SQ365)
|
||||
h = _load_hourly(asset)
|
||||
rhour = np.diff(np.log(h.values))
|
||||
rv_hour = float(np.std(rhour) * np.sqrt(365.25 * 24))
|
||||
return dict(asset=asset, iv=iv, rv_daily=rv_daily, rv_hour=rv_hour,
|
||||
spread_daily=iv - rv_daily, spread_hour=iv - rv_hour)
|
||||
|
||||
|
||||
def gamma_scalp_hourly(asset: str, mode: str = "naked",
|
||||
gate_cheap: float = 0.30) -> pd.Series:
|
||||
"""Gamma scalp a rehedge ORARIO (la 'vera' frequenza di scalping): cattura la RV intraday,
|
||||
ma paga 24x la fee di hedge. Tenor 7g = 168 step orari. IV costante nel giorno (DVOL)."""
|
||||
h = _load_hourly(asset)
|
||||
dv = _dvol(asset).reindex(h.index.normalize(), method="ffill")
|
||||
dv.index = h.index
|
||||
J = pd.concat({"px": h, "dvol": dv}, axis=1).dropna()
|
||||
px = J["px"].values; dvf = J["dvol"].values; idx = J.index
|
||||
n = len(px); STEPS = TENOR_D * 24; dt = 1.0 / (365.25 * 24)
|
||||
# IV-rank causale su DVOL giornaliero per il gate
|
||||
daily_iv = _dvol(asset)
|
||||
rets = {}
|
||||
i = 24 * 60
|
||||
while i + STEPS < n:
|
||||
S0 = px[i]; sig = dvf[i]; K = S0
|
||||
if mode == "cheap":
|
||||
day = idx[i].normalize()
|
||||
hist = daily_iv[daily_iv.index < day]
|
||||
if len(hist) >= 60 and float((hist < sig).mean()) > gate_cheap:
|
||||
rets[idx[i + STEPS]] = 0.0; i += STEPS; continue
|
||||
T = TENOR_D / 365.25
|
||||
premium = _bs_straddle(S0, K, T, sig)
|
||||
gamma_pnl = 0.0; hedge_fee = 0.0; prev_delta = None
|
||||
for t in range(1, STEPS + 1):
|
||||
tau = max((STEPS - (t - 1)) * dt, dt)
|
||||
Sp = px[i + t - 1]; Sn = px[i + t]
|
||||
r = Sn / Sp - 1.0
|
||||
d1 = (np.log(Sp / K) + 0.5 * sig ** 2 * tau) / (sig * np.sqrt(tau))
|
||||
dollar_gamma = norm.pdf(d1) * Sp / (sig * np.sqrt(tau))
|
||||
gamma_pnl += dollar_gamma * (r * r - sig * sig * dt)
|
||||
delta = 2.0 * norm.cdf(d1) - 1.0
|
||||
if prev_delta is not None:
|
||||
hedge_fee += HEDGE_FEE_SIDE * abs(delta - prev_delta) * Sp
|
||||
prev_delta = delta
|
||||
pnl = gamma_pnl - OPT_FEE_FRAC * premium - hedge_fee
|
||||
rets[idx[i + STEPS]] = pnl / S0
|
||||
i += STEPS
|
||||
out = pd.Series(rets)
|
||||
out.index = out.index.normalize()
|
||||
return out
|
||||
|
||||
|
||||
def gamma_scalp_asset(asset: str, mode: str = "naked",
|
||||
gate_cheap: float = 0.30, skip_rich: float = 0.90) -> pd.Series:
|
||||
"""Rendimenti settimanali (return-on-notional) di una catena di long-straddle delta-hedged,
|
||||
lumpati sul giorno di scadenza. Causale: IV/strike/gate da dati <= entry; payoff sul path."""
|
||||
px, dvf, idx = _load(asset)
|
||||
n = len(px)
|
||||
rets = {}
|
||||
i = 60
|
||||
while i + TENOR_D < n:
|
||||
S0 = px[i]; sig = dvf[i]; K = S0
|
||||
skip = False
|
||||
if i >= 60 and mode in ("cheap", "rich"):
|
||||
ivr = float((dvf[:i] < dvf[i]).mean()) # IV-rank espandente causale
|
||||
if mode == "cheap" and ivr > gate_cheap: # compra gamma solo se vol a SCONTO
|
||||
skip = True
|
||||
if mode == "rich" and ivr > skip_rich: # evita di pagare vol carissima
|
||||
skip = True
|
||||
if skip:
|
||||
rets[idx[i + TENOR_D]] = 0.0; i += TENOR_D; continue
|
||||
T = TENOR_D / 365.25
|
||||
premium = _bs_straddle(S0, K, T, sig)
|
||||
gamma_pnl = 0.0; hedge_fee = 0.0; prev_delta = None
|
||||
for t in range(1, TENOR_D + 1):
|
||||
tau = (TENOR_D - (t - 1)) / 365.25 # tempo residuo a inizio step
|
||||
Sp = px[i + t - 1]; Sn = px[i + t]
|
||||
r = Sn / Sp - 1.0
|
||||
d1 = (np.log(Sp / K) + 0.5 * sig ** 2 * tau) / (sig * np.sqrt(tau))
|
||||
dollar_gamma = norm.pdf(d1) * Sp / (sig * np.sqrt(tau)) # DG straddle = phi(d1)*S/(sig*sqrt(tau))
|
||||
gamma_pnl += dollar_gamma * (r * r - sig * sig * DT)
|
||||
delta = 2.0 * norm.cdf(d1) - 1.0 # delta straddle ATM
|
||||
if prev_delta is not None:
|
||||
hedge_fee += HEDGE_FEE_SIDE * abs(delta - prev_delta) * Sp
|
||||
prev_delta = delta
|
||||
opt_fee = OPT_FEE_FRAC * premium
|
||||
pnl = gamma_pnl - opt_fee - hedge_fee
|
||||
rets[idx[i + TENOR_D]] = pnl / S0 # return-on-notional
|
||||
i += TENOR_D
|
||||
return pd.Series(rets)
|
||||
|
||||
|
||||
def to_daily_voltgt(weekly_btc: pd.Series, weekly_eth: pd.Series, tgt_vol: float = 0.20) -> pd.Series:
|
||||
"""Book 50/50 BTC+ETH su griglia giornaliera, poi scalato a tgt_vol annuo (apples-to-apples
|
||||
con gli altri sleeve, tutti vol-targeted ~20%)."""
|
||||
wk = pd.concat({"B": weekly_btc, "E": weekly_eth}, axis=1, join="inner").mean(axis=1).sort_index()
|
||||
if wk.empty:
|
||||
return wk
|
||||
days = pd.date_range(wk.index.min().normalize(), wk.index.max().normalize(), freq="1D", tz="UTC")
|
||||
daily = pd.Series(0.0, index=days)
|
||||
daily.loc[wk.index.normalize()] = wk.values
|
||||
sd = daily.std()
|
||||
if sd > 0:
|
||||
daily = daily * (tgt_vol / (sd * SQ365))
|
||||
return daily
|
||||
|
||||
|
||||
def _metrics(daily: pd.Series) -> dict:
|
||||
r = daily.values
|
||||
sh = float(np.mean(r) / np.std(r) * SQ365) if np.std(r) > 0 else 0.0
|
||||
eq = np.cumprod(1.0 + np.clip(r, -0.99, None))
|
||||
pk = np.maximum.accumulate(eq)
|
||||
dd = float(np.max((pk - eq) / pk))
|
||||
yrs = (daily.index[-1] - daily.index[0]).days / 365.25
|
||||
cagr = eq[-1] ** (1 / yrs) - 1 if yrs > 0 and eq[-1] > 0 else -1.0
|
||||
s = pd.Series(eq, index=daily.index)
|
||||
yearly = {}
|
||||
for y, g in s.groupby(s.index.year):
|
||||
if len(g) > 1:
|
||||
v = g.values; p = np.maximum.accumulate(v)
|
||||
yearly[int(y)] = (float(g.iloc[-1] / g.iloc[0] - 1), float(np.max((p - v) / p)))
|
||||
return dict(sharpe=sh, dd=dd, cagr=cagr, yearly=yearly)
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 92)
|
||||
print(" GAMMA SCALPING (long-vol) — scalping BTC/ETH con copertura in opzioni")
|
||||
print(" Modello: long straddle ATM 7g, delta-hedge 1d, P&L = DG*(RV^2 - IV^2). Mirror del VRP01.")
|
||||
print("=" * 92)
|
||||
|
||||
print("\n DIAGNOSTICA STRUTTURALE — vol implicita (DVOL) vs realizzata (il segno dell'edge):")
|
||||
for a in ("BTC", "ETH"):
|
||||
d = rv_iv_diagnostic(a)
|
||||
print(f" {a}: IV {d['iv']*100:5.1f}% | RV_1d {d['rv_daily']*100:5.1f}% "
|
||||
f"(IV-RV {d['spread_daily']*100:+.1f}pp) RV_1h {d['rv_hour']*100:5.1f}% "
|
||||
f"(IV-RV {d['spread_hour']*100:+.1f}pp)")
|
||||
print(" -> IV-RV > 0 = il mercato PAGA per essere short-vol (VRP). Il long gamma e' su questo lato"
|
||||
", al ROVESCIO: paga il premio. RV_1h>RV_1d -> rehedge orario cattura piu' RV (test sotto).")
|
||||
|
||||
variants = {
|
||||
"NAKED (sempre long gamma)": dict(mode="naked"),
|
||||
"CHEAP-GATED (IV-rank<0.30 = vol scont)": dict(mode="cheap", gate_cheap=0.30),
|
||||
"CHEAP-GATED (IV-rank<0.50)": dict(mode="cheap", gate_cheap=0.50),
|
||||
"RICH-SKIP (no entry se IV-rank>0.90)": dict(mode="rich", skip_rich=0.90),
|
||||
}
|
||||
|
||||
series = {}
|
||||
for label, kw in variants.items():
|
||||
wB = gamma_scalp_asset("BTC", **kw)
|
||||
wE = gamma_scalp_asset("ETH", **kw)
|
||||
daily = to_daily_voltgt(wB, wE)
|
||||
series[label] = daily
|
||||
m = _metrics(daily)
|
||||
ntr = int((wB != 0).sum() + (wE != 0).sum())
|
||||
print(f"\n--- {label} ---")
|
||||
print(f" standalone (vol-tgt 20%): Sharpe {m['sharpe']:+.2f} CAGR {m['cagr']*100:+.1f}% "
|
||||
f"maxDD {m['dd']*100:.1f}% trade {ntr}")
|
||||
ys = " ".join(f"{y}:{p*100:+.0f}%" for y, (p, d) in sorted(m['yearly'].items()))
|
||||
print(f" per-anno PnL: {ys}")
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print(" REHEDGE ORARIO (la 'vera' frequenza di scalping: cattura RV intraday, paga 24x hedge fee)")
|
||||
print("=" * 92)
|
||||
for label, kw in (("NAKED orario", dict(mode="naked")),
|
||||
("CHEAP-GATED orario (IV-rank<0.30)", dict(mode="cheap", gate_cheap=0.30))):
|
||||
wB = gamma_scalp_hourly("BTC", **kw); wE = gamma_scalp_hourly("ETH", **kw)
|
||||
daily = to_daily_voltgt(wB, wE)
|
||||
m = _metrics(daily)
|
||||
ys = " ".join(f"{y}:{p*100:+.0f}%" for y, (p, d) in sorted(m['yearly'].items()))
|
||||
print(f"\n--- {label} ---")
|
||||
print(f" Sharpe {m['sharpe']:+.2f} CAGR {m['cagr']*100:+.1f}% maxDD {m['dd']*100:.1f}%")
|
||||
print(f" per-anno PnL: {ys}")
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print(" SCORING MARGINALE vs TP01 (lo standard del progetto: un nuovo sleeve si giudica QUI)")
|
||||
print("=" * 92)
|
||||
for label, daily in series.items():
|
||||
if daily.std() == 0:
|
||||
print(f"\n[{label}] flat — skip"); continue
|
||||
m = marginal_vs_tp01(daily)
|
||||
b25 = m.get("blends", {}).get("w25", {})
|
||||
print(f"\n[{label}]")
|
||||
print(f" verdict={m.get('marginal_verdict')} corr->TP01 full={m.get('corr_full')} "
|
||||
f"hold={m.get('corr_hold')} is_hedge={m.get('is_hedge')} "
|
||||
f"has_insample_edge={m.get('has_insample_edge')} (cand IS Sharpe {m.get('cand_insample_sharpe')})")
|
||||
print(f" cand Sharpe full={m.get('cand_full_sharpe')} hold={m.get('cand_hold_sharpe')} | "
|
||||
f"blend25 full {b25.get('full')} (upl {b25.get('uplift_full')}) "
|
||||
f"hold {b25.get('hold')} (upl {b25.get('uplift_hold')}) DD {b25.get('dd')}")
|
||||
print(f" hedge-check: uplift TP01-up {m.get('uplift_tp01_up')} / TP01-down {m.get('uplift_tp01_down')} "
|
||||
f"yearly-corr {m.get('hedge_yearly_corr')}")
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print(" ESEGUIBILITA' a ~$600 (Deribit options min size)")
|
||||
print("=" * 92)
|
||||
pxB = _load("BTC")[0][-1]; pxE = _load("ETH")[0][-1]
|
||||
for a, p, csz, minc in (("BTC", pxB, 1.0, 0.1), ("ETH", pxE, 1.0, 0.1)):
|
||||
notion = p * csz * minc
|
||||
print(f" {a}: spot ${p:,.0f} | contratto {csz} {a}, min {minc} {a} -> notional minimo "
|
||||
f"${notion:,.0f} ({'OK' if notion < 600 else 'OLTRE i $600 -> NON eseguibile'})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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,100 @@
|
||||
"""SCAN DI FATTIBILITA' — la vol term-structure e' scaricabile/certificabile da Deribit pubblico?
|
||||
|
||||
Un calendar-vol (front vs back IV) richiede ATM IV per SCADENZA, STORICA. Il DVOL pubblico e' solo
|
||||
30g. Questo script PROVA cosa l'API pubblica Deribit espone davvero, PRIMA di costruire backtest:
|
||||
1. snapshot CORRENTE della term-structure (mark_iv ATM per scadenza) — book_summary_by_currency.
|
||||
2. esiste STORIA per-scadenza? (DVOL e' 30g fisso; provo trade history con IV per instrument).
|
||||
Verdetto: se la storia per-scadenza NON e' pubblica -> calendar-vol NON backtestabile su dati certi.
|
||||
|
||||
uv run python scripts/research/probe_vol_termstructure.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys, time
|
||||
from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
import requests
|
||||
import numpy as np
|
||||
|
||||
BASE = "https://www.deribit.com/api/v2/public/"
|
||||
|
||||
|
||||
def _get(method, **params):
|
||||
r = requests.get(BASE + method, params=params, timeout=40)
|
||||
return r.json().get("result", None)
|
||||
|
||||
|
||||
def current_term_structure(cur="BTC"):
|
||||
"""ATM mark_iv per scadenza ORA (snapshot pubblico, tokenless)."""
|
||||
summ = _get("get_book_summary_by_currency", currency=cur, kind="option")
|
||||
if not summ:
|
||||
return None
|
||||
idx = _get("get_index_price", index_name=f"{cur.lower()}_usd")
|
||||
spot = float(idx["index_price"]) if idx else None
|
||||
# raggruppa per scadenza, prendi lo strike piu' vicino allo spot (ATM)
|
||||
by_exp = {}
|
||||
for o in summ:
|
||||
name = o["instrument_name"] # es. BTC-27JUN26-60000-C
|
||||
parts = name.split("-")
|
||||
if len(parts) != 4:
|
||||
continue
|
||||
exp, strike = parts[1], float(parts[2])
|
||||
miv = o.get("mark_iv")
|
||||
if miv is None or spot is None:
|
||||
continue
|
||||
d = abs(strike - spot)
|
||||
if exp not in by_exp or d < by_exp[exp][0]:
|
||||
by_exp[exp] = (d, float(miv), strike)
|
||||
return spot, by_exp
|
||||
|
||||
|
||||
def probe_history(cur="BTC"):
|
||||
"""C'e' STORIA per-scadenza? Provo: (a) DVOL (sappiamo 30g), (b) trade history con IV su uno
|
||||
strumento vivo, (c) se esistono indici vol a tenor diversi."""
|
||||
findings = []
|
||||
# (b) trade history con IV — solo strumenti NON scaduti
|
||||
instr = _get("get_instruments", currency=cur, kind="option", expired="false")
|
||||
live = instr[0]["instrument_name"] if instr else None
|
||||
if live:
|
||||
tr = _get("get_last_trades_by_instrument", instrument_name=live, count=5)
|
||||
has_iv = bool(tr and tr.get("trades") and "iv" in tr["trades"][0])
|
||||
findings.append(f"trade-history IV su {live}: {'SI (ma solo instrument VIVO, no scaduti)' if has_iv else 'NO'}")
|
||||
# (c) altri indici vol a tenor != 30g?
|
||||
findings.append("get_volatility_index_data: solo DVOL 30g (nessun indice 7g/60g/90g pubblico)")
|
||||
return findings
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 88)
|
||||
print(" SCAN FATTIBILITA' — vol term-structure Deribit pubblico")
|
||||
print("=" * 88)
|
||||
for cur in ("BTC", "ETH"):
|
||||
ts = current_term_structure(cur)
|
||||
if not ts:
|
||||
print(f"\n{cur}: nessun dato chain"); continue
|
||||
spot, by_exp = ts
|
||||
print(f"\n{cur} spot ${spot:,.0f} — term-structure ATM mark_iv ORA ({len(by_exp)} scadenze):")
|
||||
# ordina per scadenza (data nel nome)
|
||||
def _key(e):
|
||||
try:
|
||||
return time.mktime(time.strptime(e, "%d%b%y"))
|
||||
except Exception:
|
||||
return 0
|
||||
ivs = []
|
||||
for exp in sorted(by_exp, key=_key):
|
||||
d, miv, strike = by_exp[exp]
|
||||
ivs.append(miv)
|
||||
print(f" {exp:>9} ATM~{strike:>9,.0f} IV {miv:5.1f}%")
|
||||
if len(ivs) >= 2:
|
||||
print(f" -> front {ivs[0]:.1f}% back {ivs[-1]:.1f}% slope {ivs[-1]-ivs[0]:+.1f}pp "
|
||||
f"({'contango' if ivs[-1] > ivs[0] else 'backwardation'})")
|
||||
print(f" STORIA per-scadenza:")
|
||||
for f in probe_history(cur):
|
||||
print(f" - {f}")
|
||||
print("\n" + "=" * 88)
|
||||
print(" VERDETTO sulla backtestabilita' sotto.")
|
||||
print("=" * 88)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,383 @@
|
||||
"""r0701_breadth_internals.py — BREADTH / MARKET-INTERNALS del mercato ALT come segnale su BTC/ETH.
|
||||
|
||||
TESI (filone 2026-07-01)
|
||||
------------------------
|
||||
Gli "internals" del mercato crypto — la partecipazione degli alt — come segnale direzionale o gate
|
||||
di de-risk su BTC/ETH perp (2 gambe, eseguibile a ~$600):
|
||||
FAM-MA : % di alt sopra la propria SMA(N) (breadth classica)
|
||||
FAM-AD : advance/decline — frazione di advancers, SMA(N) (partecipazione giornaliera)
|
||||
FAM-RS : % di alt che BATTONO BTC sul ritorno a N giorni (risk-appetite relativo, ~mkt-neutral)
|
||||
FAM-TH : breadth-THRUST — delta della breadth MA20 su N giorni (thrust/collapse)
|
||||
Forme: LS (long/short), LF (long/flat), GATE (TP01 * gate binario). Tutte vol-target 20% cap 2x
|
||||
(LS/LF) o ereditano il sizing TP01 (GATE).
|
||||
|
||||
RISCHI NOTI IN PARTENZA (CLAUDE.md, prior art)
|
||||
----------------------------------------------
|
||||
1. MACRO regime-gate (2026-06-29) = SCARTATO: corr->TP01 0.989, il gate lavorava nel 2-3% dei
|
||||
giorni (TP01 e' gia' flat nei crash). Un breadth-gate rischia di essere LO STESSO artefatto:
|
||||
TP01 travestito. Qui DOBBIAMO riportare corr->TP01 + verdetto marginale + "giorni in cui il
|
||||
gate lavora" (gate off E TP01 non gia' flat).
|
||||
2. trend-multiasset = ridondante (corr 0.74): la breadth degli alt e' correlata alla direzione
|
||||
del mercato -> il rischio che breadth>soglia == "BTC sopra trend" e' concreto.
|
||||
3. is_hedge: un segnale che paga solo quando TP01 soffre e' un hedge, non alpha.
|
||||
4. STORIA: l'universo HL parte dal 2024-01 -> ~2.2 anni utili post-warmup. In-sample (pre-HOLDOUT
|
||||
2025-01-01) = SOLO ~8 mesi del 2024. Limite strutturale DICHIARATO: qualunque esito e' al
|
||||
massimo un LEAD, la selezione in-sample poggia su una finestra corta.
|
||||
|
||||
METODO (obbligatorio, CLAUDE.md)
|
||||
--------------------------------
|
||||
- Dati: 51 parquet certificati data/raw/hl_*_1d.parquet; PANEL = 49 alt (esclusi hl_btc/hl_eth
|
||||
dalla breadth; hl_btc usato solo come riferimento per FAM-RS). Barre a volume<=0 = sintetiche
|
||||
-> close mascherato NaN. Breadth definita solo con >= MIN_VALID(20) asset validi alla data.
|
||||
- Causalita': barre HL 1d e barre BTC/ETH 1d (altlib.get, resample Deribit) sono entrambe
|
||||
open-labeled 00:00 UTC -> il close del giorno D e' noto allo stesso istante su entrambe.
|
||||
Allineamento merge_asof backward (allow_exact) sul timestamp; eval_weights shifta la posizione
|
||||
(decisa a close[i], tenuta in i+1). Verifica al.causality_ok sul target end-to-end.
|
||||
- Selezione ONESTA (lezione SELECTION-ON-HOLDOUT 2026-06-29): la cella si sceglie con il SOLO
|
||||
Sharpe in-sample (pre-2025) sul candidato 50/50, MAI sull'hold-out; deflated Sharpe (Bailey &
|
||||
Lopez de Prado) su TUTTE le celle cercate; poi al.marginal_vs_tp01 (multi-cut, has_insample_edge,
|
||||
is_hedge) sulla cella scelta. NB: non si usa al.study_family_honest stock perche' la breadth non
|
||||
esiste pre-2024 e il padding (LS/LF=flat, GATE=TP01 pieno) contaminerebbe il ranking in-sample
|
||||
full-history (le celle GATE erediterebbero lo Sharpe 2019-2024 di TP01); la procedura qui sotto
|
||||
e' il MIRROR esatto di select_cell_insample + deflated_sharpe + study_marginal sulla FINESTRA
|
||||
COMUNE 2024-05+ (stessa libreria, stessi gate).
|
||||
- Fee 0.10% RT + sweep 0-0.30% RT; eval_weights_smallcap a $600.
|
||||
|
||||
USO: uv run python scripts/research/r0701_breadth_internals.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
sys.path.insert(0, str(_ROOT / "scripts" / "research" / "alt"))
|
||||
|
||||
import altlib as al # noqa: E402
|
||||
from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio # noqa: E402
|
||||
|
||||
RAW = _ROOT / "data" / "raw"
|
||||
HOLDOUT = al.HOLDOUT
|
||||
ASSETS = ("BTC", "ETH")
|
||||
MIN_VALID = 20 # asset validi minimi perche' la breadth esista
|
||||
START = pd.Timestamp("2026-01-01", tz="UTC") # placeholder, ridefinito sotto
|
||||
# finestra comune: HL parte 2024-01-01; warmup max = FAM-TH N=100 (20g MA + 100g delta) ~ 120g
|
||||
START = pd.Timestamp("2024-05-05", tz="UTC")
|
||||
|
||||
MA_GRID = (20, 50, 100)
|
||||
THR_GRID = (0.3, 0.5, 0.7)
|
||||
FORMS = ("ls", "lf", "gate")
|
||||
FAMS = ("ma", "ad", "rs", "th")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# PANEL ALT (49 asset, vol=0 mascherato) + riferimento BTC (hl_btc)
|
||||
# ===========================================================================
|
||||
def load_panel():
|
||||
px, vol = {}, {}
|
||||
for f in sorted(glob.glob(str(RAW / "hl_*_1d.parquet"))):
|
||||
sym = Path(f).stem.replace("hl_", "").replace("_1d", "").upper()
|
||||
d = pd.read_parquet(f)
|
||||
idx = pd.to_datetime(d["timestamp"], unit="ms", utc=True)
|
||||
px[sym] = pd.Series(d["close"].values.astype(float), index=idx)
|
||||
vol[sym] = pd.Series(d["volume"].values.astype(float), index=idx)
|
||||
PX = pd.concat(px, axis=1).sort_index()
|
||||
VOL = pd.concat(vol, axis=1).sort_index().reindex_like(PX)
|
||||
PX = PX.mask(VOL <= 0) # barre sintetiche (vol=0) -> NaN (lezione 2026-06-20)
|
||||
btc_ref = PX["BTC"].copy() # riferimento FAM-RS (stessa venue/stesso close time)
|
||||
ALTS = PX.drop(columns=["BTC", "ETH"]) # breadth = SOLO alt
|
||||
return ALTS, btc_ref
|
||||
|
||||
|
||||
def _mask_min_valid(score: pd.Series, n_valid: pd.Series) -> pd.Series:
|
||||
s = score.copy()
|
||||
s[n_valid < MIN_VALID] = np.nan
|
||||
return s
|
||||
|
||||
|
||||
def breadth_ma(ALTS: pd.DataFrame, N: int) -> pd.Series:
|
||||
"""% di alt validi con close > SMA(N). Causale (SMA su dati <= i)."""
|
||||
sma = ALTS.rolling(N, min_periods=N).mean()
|
||||
valid = ALTS.notna() & sma.notna()
|
||||
above = (ALTS > sma) & valid
|
||||
n_valid = valid.sum(axis=1)
|
||||
return _mask_min_valid(above.sum(axis=1) / n_valid.replace(0, np.nan), n_valid)
|
||||
|
||||
|
||||
def breadth_ad(ALTS: pd.DataFrame, N: int) -> pd.Series:
|
||||
"""Advance/decline: frazione di advancers (ret 1g > 0) tra i validi, SMA(N)."""
|
||||
dr = ALTS.pct_change(fill_method=None)
|
||||
valid = dr.notna()
|
||||
adv = ((dr > 0) & valid).sum(axis=1)
|
||||
n_valid = valid.sum(axis=1)
|
||||
frac = adv / n_valid.replace(0, np.nan)
|
||||
frac[n_valid < MIN_VALID] = np.nan
|
||||
return frac.rolling(N, min_periods=N).mean()
|
||||
|
||||
|
||||
def breadth_rs(ALTS: pd.DataFrame, btc_ref: pd.Series, N: int) -> pd.Series:
|
||||
"""% di alt che battono BTC sul ritorno a N giorni (risk-appetite relativo)."""
|
||||
altret = ALTS / ALTS.shift(N) - 1.0
|
||||
btcret = (btc_ref / btc_ref.shift(N) - 1.0)
|
||||
valid = altret.notna() & btcret.notna().values[:, None]
|
||||
beat = altret.gt(btcret, axis=0) & valid
|
||||
n_valid = valid.sum(axis=1)
|
||||
return _mask_min_valid(beat.sum(axis=1) / n_valid.replace(0, np.nan), n_valid)
|
||||
|
||||
|
||||
def breadth_th(ALTS: pd.DataFrame, N: int) -> pd.Series:
|
||||
"""Breadth-THRUST: 0.5 + delta a N giorni della breadth MA20 (thrust>0.5, collapse<0.5)."""
|
||||
b20 = breadth_ma(ALTS, 20)
|
||||
return 0.5 + (b20 - b20.shift(N))
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# FACTORY — target_fn(df, asset) per una cella (fam, N, thr, form)
|
||||
# ===========================================================================
|
||||
_ALTS, _BTC_REF = load_panel()
|
||||
_BREADTH: dict[tuple, pd.Series] = {}
|
||||
for _N in MA_GRID:
|
||||
_BREADTH[("ma", _N)] = breadth_ma(_ALTS, _N)
|
||||
_BREADTH[("ad", _N)] = breadth_ad(_ALTS, _N)
|
||||
_BREADTH[("rs", _N)] = breadth_rs(_ALTS, _BTC_REF, _N)
|
||||
_BREADTH[("th", _N)] = breadth_th(_ALTS, _N)
|
||||
|
||||
_TP01_POS: dict[str, np.ndarray] = {}
|
||||
|
||||
|
||||
def tp01_pos(df: pd.DataFrame, asset: str) -> np.ndarray:
|
||||
if asset not in _TP01_POS:
|
||||
_TP01_POS[asset] = TrendPortfolio(**CANONICAL).target_series(df)
|
||||
return _TP01_POS[asset]
|
||||
|
||||
|
||||
_EPOCH = pd.Timestamp("1970-01-01", tz="UTC")
|
||||
|
||||
|
||||
def _align(b: pd.Series, df: pd.DataFrame) -> np.ndarray:
|
||||
"""Breadth (calendario HL) -> barre BTC/ETH. merge_asof backward, exact ok (stesso
|
||||
istante di close 00:00 UTC). NaN dove la breadth non esiste.
|
||||
NB timestamp via epoca esplicita: .view('int64') su DatetimeIndex tz-aware a risoluzione
|
||||
non-ns (pandas 2.x) da' la SCALA SBAGLIATA -> merge_asof matchava tutto all'ULTIMO valore
|
||||
(broadcast del futuro su tutta la storia = look-ahead che causality_ok non vede, perche'
|
||||
la serie breadth e' un input esterno fisso). Bug trovato e corretto in questa ricerca."""
|
||||
ts_ms = ((b.index - _EPOCH) // pd.Timedelta(milliseconds=1)).astype("int64")
|
||||
g = pd.DataFrame({"timestamp": ts_ms, "b": b.values})
|
||||
g = g.dropna(subset=["b"]).sort_values("timestamp")
|
||||
left = pd.DataFrame({"timestamp": df["timestamp"].astype("int64").values})
|
||||
m = pd.merge_asof(left, g, on="timestamp", direction="backward")
|
||||
return m["b"].values.astype(float)
|
||||
|
||||
|
||||
def factory(tf: str = "1d", fam: str = "ma", N: int = 50, thr: float = 0.5, form: str = "ls"):
|
||||
b_series = _BREADTH[(fam, N)]
|
||||
|
||||
def target_fn(df: pd.DataFrame, asset: str = "") -> np.ndarray:
|
||||
b = _align(b_series, df)
|
||||
if form == "gate":
|
||||
g = np.where(np.isfinite(b), np.where(b >= thr, 1.0, 0.0), 1.0) # no info -> no de-risk
|
||||
return tp01_pos(df, asset) * g
|
||||
if form == "ls":
|
||||
d = np.where(np.isfinite(b), np.where(b >= thr, 1.0, -1.0), 0.0)
|
||||
else: # lf
|
||||
d = np.where(np.isfinite(b), np.where(b >= thr, 1.0, 0.0), 0.0)
|
||||
return al.vol_target(d, df, 0.20, 30, 2.0)
|
||||
return target_fn
|
||||
|
||||
|
||||
GRID = [dict(fam=f, N=n, thr=t, form=fo)
|
||||
for f in FAMS for n in MA_GRID for t in THR_GRID for fo in FORMS]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# DRIVER ONESTO sulla finestra comune (mirror di study_family_honest, stessi gate altlib)
|
||||
# ===========================================================================
|
||||
def cand_trim(fn) -> pd.Series:
|
||||
return al.candidate_daily(fn, tf="1d")[lambda s: s.index >= START]
|
||||
|
||||
|
||||
def abs_verdict_trimmed(fn) -> dict:
|
||||
"""study_weights-equivalente sulla finestra comune 2024-05+ (fee sweep incluso)."""
|
||||
per_asset = {}
|
||||
fee_ok_all = True
|
||||
for a in ASSETS:
|
||||
df = al.get(a, "1d")
|
||||
tgt = np.asarray(fn(df, a), float)
|
||||
mask = (pd.to_datetime(df["datetime"], utc=True) >= START).values
|
||||
dft = df[mask].reset_index(drop=True)
|
||||
tgtt = tgt[mask]
|
||||
base = al.eval_weights(dft, tgtt, fee_side=al.FEE_SIDE)
|
||||
sweep = {f"{2*f*100:.2f}%RT": al.eval_weights(dft, tgtt, fee_side=f)["full"]["sharpe"]
|
||||
for f in al.FEE_SWEEP}
|
||||
fee_ok_all = fee_ok_all and sweep.get("0.20%RT", -9) > 0
|
||||
per_asset[a] = dict(full=base["full"], holdout=base["holdout"],
|
||||
tim=base["time_in_market"], turnover=base["turnover_per_year"],
|
||||
fee_sweep=sweep, yearly=base["yearly"])
|
||||
cell = dict(tf="1d", per_asset=per_asset,
|
||||
min_asset_full_sharpe=round(min(per_asset[a]["full"]["sharpe"] for a in ASSETS), 3),
|
||||
min_asset_holdout_sharpe=round(min(per_asset[a]["holdout"].get("sharpe", 0.0) for a in ASSETS), 3),
|
||||
full_sharpe=round(float(np.mean([per_asset[a]["full"]["sharpe"] for a in ASSETS])), 3),
|
||||
fee_survives=fee_ok_all)
|
||||
return dict(cells=[cell], verdict=al._verdict([cell]))
|
||||
|
||||
|
||||
def gate_work_diag(fn, thr_flat: float = 1e-6) -> dict:
|
||||
"""Deep-dive ridondanza (lezione macro-gate): nei giorni in cui il segnale vorrebbe stare
|
||||
fuori/short, TP01 e' gia' flat da solo? 'lavora' = segnale off/short E TP01 non flat."""
|
||||
out = {}
|
||||
for a in ASSETS:
|
||||
df = al.get(a, "1d")
|
||||
mask = (pd.to_datetime(df["datetime"], utc=True) >= START).values
|
||||
tgt = np.asarray(fn(df, a), float)[mask]
|
||||
tp = tp01_pos(df, a)[mask]
|
||||
off = tgt <= thr_flat # segnale fuori (o short per LS: qui solo "non long")
|
||||
works = off & (tp > thr_flat)
|
||||
out[a] = dict(days_off=round(float(off.mean()), 3),
|
||||
days_gate_works=round(float(works.mean()), 3),
|
||||
tp01_flat_days=round(float((tp <= thr_flat).mean()), 3),
|
||||
corr_pos=round(float(np.corrcoef(tgt, tp)[0, 1]), 3)
|
||||
if np.std(tgt) > 0 and np.std(tp) > 0 else None)
|
||||
return out
|
||||
|
||||
|
||||
def cell_activity(p: dict) -> float:
|
||||
"""Frazione di giorni nello STATO DI MINORANZA del segnale (criterio STRUTTURALE, non di
|
||||
performance: una cella sempre-on e' buy&hold/TP01 travestito, non un segnale di breadth).
|
||||
ls: min(on, off); lf: frazione on... comunque = minoranza; gate: frazione off."""
|
||||
b = _BREADTH[(p["fam"], p["N"])]
|
||||
bb = b[(b.index >= START)].dropna()
|
||||
on = float((bb >= p["thr"]).mean())
|
||||
return round(min(on, 1.0 - on) if p["form"] == "ls" else
|
||||
(on if p["form"] == "lf" else 1.0 - on), 3)
|
||||
|
||||
|
||||
def full_report(label: str, chosen: dict, all_full: list, n_trials: int) -> dict:
|
||||
p = chosen["params"]
|
||||
fn = factory(**p)
|
||||
daily = cand_trim(fn)
|
||||
dsr, sr0 = al.deflated_sharpe(al._sh(daily), all_full, daily)
|
||||
print(f"\n==== {label}: {p} (attivita' {chosen['act']}) ====")
|
||||
print(f" standalone (finestra comune): IS {chosen['insample_sharpe']:+.2f} "
|
||||
f"FULL {chosen['full_sharpe']:+.2f} HOLD {chosen['hold_sharpe']:+.2f}")
|
||||
print(f" deflated Sharpe (su TUTTI i {n_trials} trial cercati): DSR={dsr:.3f} "
|
||||
f"(null-max atteso {sr0:.2f}) PASS>=0.95: {dsr >= 0.95}")
|
||||
|
||||
marg = al.marginal_vs_tp01(daily)
|
||||
absr = abs_verdict_trimmed(fn)
|
||||
abs_grade = absr["verdict"]["grade"]
|
||||
earns_slot = (abs_grade != "FAIL" and marg.get("marginal_verdict") == "ADDS"
|
||||
and marg.get("robust_oos", False) and marg.get("beats_noise_null", False)
|
||||
and not marg.get("is_hedge", False))
|
||||
rep = dict(name=f"BREADTH {p}", marginal=marg, abs_grade=abs_grade,
|
||||
marginal_verdict=marg.get("marginal_verdict"), earns_slot=earns_slot)
|
||||
print("\n" + al.fmt_marginal(rep))
|
||||
honest = earns_slot and dsr >= 0.95
|
||||
print(f" earns_slot_honest = earns_slot({earns_slot}) AND DSR>=0.95({dsr >= 0.95}) "
|
||||
f"=> {honest}")
|
||||
|
||||
# assoluto trimmed + fee sweep
|
||||
c = absr["cells"][0]
|
||||
print(f"\n---- ASSOLUTO (finestra comune, verdetto {abs_grade}): "
|
||||
f"minFull {c['min_asset_full_sharpe']:+.2f} minHold {c['min_asset_holdout_sharpe']:+.2f} "
|
||||
f"feeOK={c['fee_survives']}")
|
||||
for a in ASSETS:
|
||||
pa = c["per_asset"][a]
|
||||
yr = " ".join(f"{y}:{d['ret']*100:+.0f}%" for y, d in pa["yearly"].items())
|
||||
print(f" {a}: full Sh {pa['full']['sharpe']:+.2f} DD {pa['full']['maxdd']*100:.0f}% "
|
||||
f"hold Sh {pa['holdout'].get('sharpe', 0):+.2f} tim {pa['tim']} "
|
||||
f"turn/y {pa['turnover']} | {yr}")
|
||||
print(f" fee sweep: {pa['fee_sweep']}")
|
||||
|
||||
# causalita' + smallcap $600
|
||||
ca = al.causality_ok(fn, tf="1d")
|
||||
print(f"\n---- causality_ok: {ca['ok']} (max_tail_diff {ca['max_tail_diff']}, checked {ca['checked']})")
|
||||
for a in ASSETS:
|
||||
df = al.get(a, "1d")
|
||||
mask = (pd.to_datetime(df["datetime"], utc=True) >= START).values
|
||||
dft = df[mask].reset_index(drop=True)
|
||||
tgtt = np.asarray(fn(df, a), float)[mask]
|
||||
sc = al.eval_weights_smallcap(dft, tgtt, capital=600.0, min_order=5.0)
|
||||
print(f" smallcap $600 {a}: modeled Sh {sc['modeled']['sharpe']:+.2f} -> "
|
||||
f"real {sc['realistic']['sharpe']:+.2f} (haircut {sc['sharpe_haircut']:+.2f}, "
|
||||
f"{sc['n_executed_trades']} trade eseguiti)")
|
||||
|
||||
# deep-dive ridondanza col trend (lezione macro-gate)
|
||||
print("---- RIDONDANZA COL TREND (il rischio n.1):")
|
||||
for a, d in gate_work_diag(fn).items():
|
||||
print(f" {a}: corr(pos, TP01pos) {d['corr_pos']} giorni segnale-off {d['days_off']} "
|
||||
f"TP01-gia'-flat {d['tp01_flat_days']} GIORNI IN CUI LAVORA {d['days_gate_works']}")
|
||||
return dict(params=p, dsr=round(float(dsr), 3), earns_slot=earns_slot,
|
||||
earns_slot_honest=honest, marginal_verdict=marg.get("marginal_verdict"),
|
||||
abs_grade=abs_grade, corr_tp01=marg.get("corr_full"),
|
||||
is_hedge=marg.get("is_hedge"))
|
||||
|
||||
|
||||
def main():
|
||||
print(f"=== r0701 BREADTH/INTERNALS — panel: {_ALTS.shape[1]} alt, "
|
||||
f"{_ALTS.index[0].date()} -> {_ALTS.index[-1].date()} | finestra analisi {START.date()}+ "
|
||||
f"(in-sample {START.date()} -> {HOLDOUT.date()} = ~8 mesi; storia ~2.2y: LIMITE DICHIARATO)")
|
||||
nv = _ALTS.notna().sum(axis=1)
|
||||
print(f" asset validi/D: min {int(nv.min())} med {int(nv.median())} max {int(nv.max())} "
|
||||
f"(MIN_VALID={MIN_VALID})")
|
||||
|
||||
# ---- 1. tutte le celle: Sharpe in-sample (selezione) + full (DSR) sulla finestra comune
|
||||
rows = []
|
||||
for p in GRID:
|
||||
fn = factory(**p)
|
||||
daily = cand_trim(fn)
|
||||
ins = daily[daily.index < HOLDOUT]
|
||||
if len(ins) < 60 or daily.std() == 0:
|
||||
continue
|
||||
rows.append(dict(params=p, act=cell_activity(p),
|
||||
insample_sharpe=round(al._sh(ins), 3),
|
||||
full_sharpe=round(al._sh(daily), 3),
|
||||
hold_sharpe=round(al._sh(daily[daily.index >= HOLDOUT]), 3)))
|
||||
rows.sort(key=lambda r: r["insample_sharpe"], reverse=True)
|
||||
all_full = [r["full_sharpe"] for r in rows]
|
||||
print(f"\n---- GRIGLIA: {len(GRID)} celle, {len(rows)} valutabili; "
|
||||
f"full>0: {sum(1 for s in all_full if s > 0)}/{len(all_full)}")
|
||||
print("---- TOP-15 per Sharpe IN-SAMPLE (selezione onesta: MAI sull'hold-out) "
|
||||
"[hold mostrato solo per trasparenza; act = frazione stato di minoranza]")
|
||||
for r in rows[:15]:
|
||||
p = r["params"]
|
||||
print(f" {p['fam']:>2s} N={p['N']:>3d} thr={p['thr']} {p['form']:>4s} act={r['act']:.2f} | "
|
||||
f"IS {r['insample_sharpe']:+.2f} full {r['full_sharpe']:+.2f} hold {r['hold_sharpe']:+.2f}")
|
||||
|
||||
# ---- 2. PRIMARIO: cella scelta in-sample su TUTTA la griglia (procedura onesta pura)
|
||||
out1 = full_report("CELLA SCELTA IN-SAMPLE (tutta la griglia)", rows[0], all_full, len(rows))
|
||||
|
||||
# ---- 3. SECONDARIO (dichiarato): sole celle ATTIVE (minoranza >=10% — criterio strutturale
|
||||
# deciso a priori, NON di performance; DSR sempre deflazionato su TUTTI i trial).
|
||||
active = [r for r in rows if r["act"] >= 0.10]
|
||||
print(f"\n---- CELLE ATTIVE (act>=0.10): {len(active)}/{len(rows)}")
|
||||
out2 = None
|
||||
if active and active[0]["params"] != rows[0]["params"]:
|
||||
out2 = full_report("SECONDARIO: best cella ATTIVA in-sample", active[0], all_full, len(rows))
|
||||
|
||||
# ---- 4. marginal sui best per-forma tra le ATTIVE (trasparenza: il verdetto per forma)
|
||||
print("\n---- VERDETTO MARGINALE dei best-IN-SAMPLE ATTIVI per forma (contesto):")
|
||||
for fo in FORMS:
|
||||
sub = [r for r in active if r["params"]["form"] == fo]
|
||||
if not sub:
|
||||
continue
|
||||
rp = sub[0]["params"]
|
||||
m = al.marginal_vs_tp01(cand_trim(factory(**rp)))
|
||||
uh = m["blends"]["w25"]["uplift_hold"]
|
||||
print(f" {fo:>4s} {rp} act={sub[0]['act']:.2f}: {m.get('marginal_verdict')} "
|
||||
f"corr {m.get('corr_full')} IS-edge {m.get('cand_insample_sharpe')} "
|
||||
f"is_hedge {m.get('is_hedge')} uplift w25 full {m['blends']['w25']['uplift_full']:+.3f} "
|
||||
f"hold {uh if uh is None else format(uh, '+.3f')} multicut {m.get('multicut_uplift')}")
|
||||
|
||||
print("\n==== SINTESI ====")
|
||||
print(f" primario: {out1}")
|
||||
print(f" secondario (attive): {out2}")
|
||||
print("==== FINE r0701 ====")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,387 @@
|
||||
"""r0701_funding_ts — FUNDING RATE come segnale TIME-SERIES direzionale su BTC/ETH (NON carry).
|
||||
|
||||
2026-07-01. Ipotesi: il funding orario Hyperliquid (proxy di POSIZIONAMENTO/sentiment dei perp)
|
||||
contiene informazione direzionale a orizzonte giornaliero su BTC/ETH. Famiglia (griglia modesta):
|
||||
- FADE : z-score del funding estremo-positivo = affollamento long -> SHORT (e viceversa)
|
||||
- FOLLOW : funding in espansione = domanda long persistente -> LONG (sentiment momentum)
|
||||
- GATE : trend TP01-like long-flat, FLAT quando il funding e' affollato (z>=thr, de-risk)
|
||||
- DIVERGE : momentum prezzo 20d con funding NON affollato -> follow; affollato -> fade
|
||||
Griglia: 4 forme x lookback z {7,14,30,60}g x soglia {0.5,1.0,1.5} = 48 celle, solo 1d.
|
||||
|
||||
PRIOR ART (non ripetuto): FC01 carry cross-sectional delta-neutral -> SCARTATO
|
||||
(docs/diary/2026-06-22-funding-carry-hl.md); funding price-clock intraday -> FAIL (onda intraday).
|
||||
Qui il funding e' un SEGNALE time-series direzionale su BTC/ETH perp (2 gambe, eseguibile a ~$600),
|
||||
non un cashflow da incassare.
|
||||
|
||||
DATI: data/raw/hlfund_{btc,eth}_1h.parquet (funding orario HL: 2023-05-12 -> 2026-06-22; primi
|
||||
~27 giorni a cadenza 8h, poi oraria, 0 gap; certificato nel diario 2026-06-22). Prezzi certificati
|
||||
Deribit via altlib.get (1d resampled leak-free).
|
||||
|
||||
CAUSALITA' (il punto delicato): le barre 1d sono OPEN-LABELED (datetime = 00:00 UTC del giorno D;
|
||||
il close della barra D e' noto alle 00:00 di D+1). Il feature-day D aggrega i SOLI stamp funding
|
||||
in [D 00:00, D+24h) — l'ultimo alle 23:00 — quindi tutto e' noto PRIMA della decisione al close
|
||||
della barra D. eval_weights poi shifta: target[D] e' tenuto durante la barra D+1. Nessun leak
|
||||
strutturale; in piu' prefix-check esplicito.
|
||||
|
||||
VALUTAZIONE su finestra TRONCATA alla copertura funding (2023-05-12..2026-06-21), NON sul frame
|
||||
prezzi 2018+: fuori copertura il target sarebbe zero per costruzione e i giorni-zero (a) GONFIANO
|
||||
il T del deflated-Sharpe (anti-conservativo) e (b) DILUISCONO cand_insample_sharpe (gate
|
||||
has_insample_edge scatterebbe a vuoto). La logica di study_family_honest e' replicata ESATTAMENTE
|
||||
sui frame troncati coi primitivi altlib: selezione cella IN-SAMPLE-ONLY (mai sul hold-out) ->
|
||||
study_marginal gates (ADDS + robust_oos + has_insample_edge + not is_hedge) -> deflated-Sharpe
|
||||
>= 0.95 sull'INTERA griglia. Cross-check con study_marginal non-troncato riportato in coda.
|
||||
|
||||
CAVEAT STORIA: funding solo dal 2023-05 (~3.1 anni). In-sample pre-HOLDOUT ~1.6 anni (meno il
|
||||
warmup z), hold-out 2025-01+ ~1.5 anni. Finestra corta: qualunque PASS andrebbe comunque in
|
||||
forward-monitor, e un FAIL su questa finestra non e' appellabile a "regime sfortunato".
|
||||
|
||||
Run: cd /opt/docker/PythagorasGoal && uv run python scripts/research/r0701_funding_ts.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent / "alt"))
|
||||
import altlib as al # noqa: E402
|
||||
|
||||
ASSETS = ("BTC", "ETH")
|
||||
FORMS = ("fade", "follow", "gate", "diverge")
|
||||
LOOKBACKS = (7, 14, 30, 60)
|
||||
THRESHOLDS = (0.5, 1.0, 1.5)
|
||||
EARLY_8H_END = pd.Timestamp("2023-06-11", tz="UTC") # fino a qui cadenza 8h (3 stamp/giorno)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# DATI FUNDING — aggregazione giornaliera causale
|
||||
# ===========================================================================
|
||||
@lru_cache(maxsize=16)
|
||||
def daily_funding(asset: str, back_h: int = 0) -> pd.DataFrame:
|
||||
"""Funding giornaliero = SOMMA degli stamp orari nella finestra [D-back_h, D+24h-back_h).
|
||||
back_h=0 (default) = giorno UTC pieno [D, D+24h): tutti gli stamp (ultimo 23:00) sono noti
|
||||
al close della barra open-labeled D (= 00:00 di D+1) -> causale. back_h>0 sposta la finestra
|
||||
INDIETRO (sempre causale) — usato solo dal boundary-shift check.
|
||||
'valid' = giorno con copertura piena (>=20 stamp orari; >=3 nell'era 8h iniziale)."""
|
||||
p = al.DATA_DIR / f"hlfund_{asset.lower()}_1h.parquet"
|
||||
d = pd.read_parquet(p)
|
||||
idx = pd.DatetimeIndex(pd.to_datetime(d.index, utc=True)) + pd.Timedelta(hours=back_h)
|
||||
day = idx.floor("1D")
|
||||
g = pd.Series(d["funding"].values.astype(float), index=day).groupby(level=0)
|
||||
out = pd.DataFrame({"fday": g.sum(), "n": g.count()})
|
||||
early = out.index <= EARLY_8H_END
|
||||
out["valid"] = np.where(early, out["n"] >= 3, out["n"] >= 20)
|
||||
return out
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def fund_window() -> tuple:
|
||||
"""Intersezione BTC/ETH dei giorni funding validi (a back_h=0)."""
|
||||
los, his = [], []
|
||||
for a in ASSETS:
|
||||
v = daily_funding(a)
|
||||
vd = v.index[v["valid"].values]
|
||||
los.append(vd.min()); his.append(vd.max())
|
||||
return max(los), min(his)
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def get_trunc(asset: str, tf: str = "1d") -> pd.DataFrame:
|
||||
"""Prezzi certificati troncati alla copertura funding (vedi docstring modulo)."""
|
||||
lo, hi = fund_window()
|
||||
df = al.get(asset, tf)
|
||||
day = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)).floor("1D")
|
||||
m = (day >= lo) & (day <= hi)
|
||||
return df.loc[m].reset_index(drop=True)
|
||||
|
||||
|
||||
def aligned_fday(df: pd.DataFrame, asset: str, back_h: int = 0) -> np.ndarray:
|
||||
"""Funding giornaliero allineato alle barre di df (NaN dove manca/incompleto)."""
|
||||
fd = daily_funding(asset, back_h)
|
||||
day = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)).floor("1D")
|
||||
return fd["fday"].where(fd["valid"]).reindex(day).values.astype(float)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# FAMIGLIA DI SEGNALI — factory(tf, form, lb, thr) -> target_fn(df, asset)
|
||||
# ===========================================================================
|
||||
def make_target(tf: str = "1d", form: str = "fade", lb: int = 30, thr: float = 1.0,
|
||||
back_h: int = 0):
|
||||
def target(df: pd.DataFrame, asset: str) -> np.ndarray:
|
||||
f = aligned_fday(df, asset, back_h)
|
||||
z = al.zscore(f, lb) # causale: rolling fino a i incluso
|
||||
c = pd.Series(df["close"].values.astype(float))
|
||||
if form == "fade": # affollamento long -> short (e viceversa)
|
||||
d = np.where(z >= thr, -1.0, np.where(z <= -thr, 1.0, 0.0))
|
||||
elif form == "follow": # funding come sentiment momentum
|
||||
d = np.where(z >= thr, 1.0, np.where(z <= -thr, -1.0, 0.0))
|
||||
elif form == "gate": # trend long-flat, flat se affollato
|
||||
m30 = np.nan_to_num(np.sign(c.pct_change(30).values))
|
||||
m90 = np.nan_to_num(np.sign(c.pct_change(90).values))
|
||||
trend = ((m30 + m90) > 0).astype(float)
|
||||
zz = np.where(np.isfinite(z), z, np.inf) # z ignoto -> conservativo: flat
|
||||
d = trend * (zz < thr).astype(float)
|
||||
elif form == "diverge": # mossa non affollata -> follow; affollata -> fade
|
||||
mom = np.nan_to_num(np.sign(c.pct_change(20).values))
|
||||
d = np.where(z >= thr, -mom, np.where(z <= -thr, mom, 0.0))
|
||||
else:
|
||||
raise ValueError(form)
|
||||
return al.vol_target(np.nan_to_num(d), df, 0.20, 30, 2.0)
|
||||
return target
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# VALUTAZIONE (replica study_family_honest su frame troncati)
|
||||
# ===========================================================================
|
||||
def cell_daily(target_fn, fee_side: float = al.FEE_SIDE) -> pd.Series:
|
||||
"""Serie daily netta 50/50 BTC+ETH del candidato (convenzione candidate_daily)."""
|
||||
series = {}
|
||||
for a in ASSETS:
|
||||
df = get_trunc(a)
|
||||
ev = al.eval_weights(df, target_fn(df, a), fee_side=fee_side)
|
||||
series[a] = pd.Series(ev["net"], index=ev["idx"])
|
||||
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
|
||||
return al._to_daily(0.5 * J["BTC"] + 0.5 * J["ETH"])
|
||||
|
||||
|
||||
def scan_family() -> list[dict]:
|
||||
rows = []
|
||||
for form in FORMS:
|
||||
for lb in LOOKBACKS:
|
||||
for thr in THRESHOLDS:
|
||||
daily = cell_daily(make_target(form=form, lb=lb, thr=thr))
|
||||
ins = daily[daily.index < al.HOLDOUT]
|
||||
hold = daily[daily.index >= al.HOLDOUT]
|
||||
rows.append(dict(
|
||||
form=form, lb=lb, thr=thr,
|
||||
insample_sharpe=round(al._sh(ins), 3) if len(ins) > 60 else float("nan"),
|
||||
full_sharpe=round(al._sh(daily), 3),
|
||||
hold_sharpe=round(al._sh(hold), 3) if len(hold) > 60 else float("nan")))
|
||||
return rows
|
||||
|
||||
|
||||
def absolute_study(target_fn, name: str) -> dict:
|
||||
"""study_weights-equivalente sui frame troncati (fee sweep 0.00-0.30% RT incluso)."""
|
||||
per_asset = {}
|
||||
fee_ok_all = True
|
||||
for a in ASSETS:
|
||||
df = get_trunc(a)
|
||||
tgt = target_fn(df, a)
|
||||
base = al.eval_weights(df, tgt, fee_side=al.FEE_SIDE)
|
||||
sweep = {f"{2 * f * 100:.2f}%RT": al.eval_weights(df, tgt, fee_side=f)["full"]["sharpe"]
|
||||
for f in al.FEE_SWEEP}
|
||||
fee_ok_all = fee_ok_all and sweep.get("0.20%RT", -9) > 0
|
||||
per_asset[a] = dict(full=base["full"], holdout=base["holdout"],
|
||||
tim=base["time_in_market"], turnover=base["turnover_per_year"],
|
||||
fee_sweep=sweep, yearly=base["yearly"])
|
||||
cells = [dict(tf="1d", per_asset=per_asset,
|
||||
min_asset_full_sharpe=round(min(per_asset[a]["full"]["sharpe"] for a in ASSETS), 3),
|
||||
min_asset_holdout_sharpe=round(min(per_asset[a]["holdout"].get("sharpe", 0.0) for a in ASSETS), 3),
|
||||
full_sharpe=round(float(np.mean([per_asset[a]["full"]["sharpe"] for a in ASSETS])), 3),
|
||||
fee_survives=fee_ok_all)]
|
||||
return dict(name=name, kind="weights", cells=cells, verdict=al._verdict(cells))
|
||||
|
||||
|
||||
def prefix_check(target_fn, tail: int = 60) -> float:
|
||||
"""Consistenza online (guardia look-ahead): il target ricalcolato su un prefisso troncato
|
||||
deve coincidere col target(full) sugli stessi indici. Ritorna il max scostamento."""
|
||||
worst = 0.0
|
||||
for a in ASSETS:
|
||||
df = get_trunc(a)
|
||||
full = np.nan_to_num(np.asarray(target_fn(df, a), float))
|
||||
n = len(df)
|
||||
for cut in (int(n * 0.80), int(n * 0.92)):
|
||||
sub = df.iloc[:cut].reset_index(drop=True)
|
||||
s = np.nan_to_num(np.asarray(target_fn(sub, a), float))
|
||||
worst = max(worst, float(np.max(np.abs(s[cut - tail:cut] - full[cut - tail:cut]))))
|
||||
return worst
|
||||
|
||||
|
||||
def boundary_check(form: str, lb: int, thr: float, offsets=(0, 3, 6, 9, 12)) -> dict:
|
||||
"""Lezione day_boundary: sposto INDIETRO di back_h ore la finestra di aggregazione del
|
||||
funding (sempre causale). Un effetto di posizionamento reale non cambia segno."""
|
||||
B = al.tp01_baseline_daily()
|
||||
out = {}
|
||||
for off in offsets:
|
||||
daily = cell_daily(make_target(form=form, lb=lb, thr=thr, back_h=off))
|
||||
J = pd.concat({"B": B, "C": daily}, axis=1, join="inner").dropna()
|
||||
up = al._sh(0.75 * J["B"] + 0.25 * J["C"]) - al._sh(J["B"]) if len(J) > 30 else float("nan")
|
||||
out[off] = dict(full_sharpe=round(al._sh(daily), 3), uplift_w25=round(up, 3))
|
||||
ups = [v["uplift_w25"] for v in out.values() if np.isfinite(v["uplift_w25"])]
|
||||
shs = [v["full_sharpe"] for v in out.values()]
|
||||
return dict(per_offset=out,
|
||||
sharpe_sign_stable=bool(min(shs) * max(shs) >= 0 or max(map(abs, shs)) < 0.1),
|
||||
uplift_spread=round(max(ups) - min(ups), 3) if ups else None)
|
||||
|
||||
|
||||
def trend_only_target(df: pd.DataFrame, asset: str = "") -> np.ndarray:
|
||||
"""CONTROLLO DECISIVO (lezione TP01-DVOL-overlay): lo STESSO trend long-flat della forma
|
||||
'gate' ma SENZA il gate funding. Se fa uguale/meglio, il funding non aggiunge nulla."""
|
||||
c = pd.Series(df["close"].values.astype(float))
|
||||
m30 = np.nan_to_num(np.sign(c.pct_change(30).values))
|
||||
m90 = np.nan_to_num(np.sign(c.pct_change(90).values))
|
||||
trend = ((m30 + m90) > 0).astype(float)
|
||||
return al.vol_target(trend, df, 0.20, 30, 2.0)
|
||||
|
||||
|
||||
def smallcap_check(target_fn) -> dict:
|
||||
out = {}
|
||||
for a in ASSETS:
|
||||
df = get_trunc(a)
|
||||
sc = al.eval_weights_smallcap(df, target_fn(df, a), capital=600.0, min_order=5.0)
|
||||
out[a] = dict(modeled_sh=sc["modeled"]["sharpe"], realistic_sh=sc["realistic"]["sharpe"],
|
||||
haircut=sc["sharpe_haircut"], n_trades=sc["n_executed_trades"])
|
||||
return out
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
def main():
|
||||
print("=" * 88)
|
||||
print("r0701_funding_ts — funding HL come segnale TS direzionale su BTC/ETH (non carry)")
|
||||
print("=" * 88)
|
||||
|
||||
# --- 1. data-first: qualita'/copertura --------------------------------------------
|
||||
lo, hi = fund_window()
|
||||
print("\n[1] DATI FUNDING")
|
||||
for a in ASSETS:
|
||||
fd = daily_funding(a)
|
||||
v = fd["valid"]
|
||||
ann = fd.loc[v, "fday"].mean() * 365.25 * 100
|
||||
print(f" {a}: giorni validi {int(v.sum())}/{len(fd)} "
|
||||
f"finestra {fd.index[0].date()} -> {fd.index[-1].date()} "
|
||||
f"funding medio {ann:+.1f}%/anno "
|
||||
f"std daily {fd.loc[v, 'fday'].std() * 1e4:.2f} bps")
|
||||
print(f" finestra comune valida: {lo.date()} -> {hi.date()} "
|
||||
f"({(hi - lo).days} giorni, ~{(hi - lo).days / 365.25:.1f} anni)")
|
||||
n_ins = (al.HOLDOUT - lo).days
|
||||
n_hold = (hi - al.HOLDOUT).days
|
||||
print(f" in-sample pre-HOLDOUT ~{n_ins}g ({n_ins / 365.25:.1f}a), "
|
||||
f"hold-out ~{n_hold}g ({n_hold / 365.25:.1f}a) <-- STORIA CORTA, caveat")
|
||||
|
||||
# --- 2. scan famiglia (48 celle, selezione IN-SAMPLE-ONLY) -------------------------
|
||||
print("\n[2] SCAN FAMIGLIA (4 forme x lb{7,14,30,60} x thr{0.5,1.0,1.5} = 48 celle, 1d)")
|
||||
rows = scan_family()
|
||||
valid = [r for r in rows if np.isfinite(r["insample_sharpe"])]
|
||||
valid.sort(key=lambda r: r["insample_sharpe"], reverse=True)
|
||||
print(f" celle valide {len(valid)}/{len(rows)}; top-8 per Sharpe IN-SAMPLE "
|
||||
f"(hold-out mostrato SOLO per trasparenza, mai per selezione):")
|
||||
print(f" {'form':8s} {'lb':>3s} {'thr':>4s} {'IS':>7s} {'FULL':>7s} {'HOLD':>7s}")
|
||||
for r in valid[:8]:
|
||||
print(f" {r['form']:8s} {r['lb']:3d} {r['thr']:4.1f} {r['insample_sharpe']:7.2f} "
|
||||
f"{r['full_sharpe']:7.2f} {r['hold_sharpe']:7.2f}")
|
||||
per_form = {f: max((r["insample_sharpe"] for r in valid if r["form"] == f), default=float("nan"))
|
||||
for f in FORMS}
|
||||
print(f" best IS per forma: {per_form}")
|
||||
|
||||
chosen = valid[0]
|
||||
print(f"\n CELLA SCELTA (in-sample-only): {chosen['form']} lb={chosen['lb']} thr={chosen['thr']} "
|
||||
f"(IS {chosen['insample_sharpe']}, FULL {chosen['full_sharpe']}, HOLD {chosen['hold_sharpe']})")
|
||||
fn = make_target(form=chosen["form"], lb=chosen["lb"], thr=chosen["thr"])
|
||||
daily = cell_daily(fn)
|
||||
|
||||
# --- 3. deflated Sharpe sull'INTERA griglia ----------------------------------------
|
||||
all_full = [r["full_sharpe"] for r in rows]
|
||||
dsr, sr0 = al.deflated_sharpe(al._sh(daily), all_full, daily)
|
||||
dsr_pass = bool(np.isfinite(dsr) and dsr >= 0.95)
|
||||
print(f"\n[3] DEFLATED SHARPE (griglia {len(rows)} celle): "
|
||||
f"DSR={dsr:.3f} (null-max atteso {sr0:.2f}) PASS(>=0.95)={dsr_pass}")
|
||||
|
||||
# --- 4. assoluto + marginale (gates study_marginal) --------------------------------
|
||||
print("\n[4] ASSOLUTO (frame troncati, fee sweep 0.00-0.30% RT)")
|
||||
absolute = absolute_study(fn, f"R0701-FUND-{chosen['form'].upper()}")
|
||||
print(al.fmt(absolute))
|
||||
c0 = absolute["cells"][0]
|
||||
for a in ASSETS:
|
||||
pa = c0["per_asset"][a]
|
||||
print(f" {a}: TIM={pa['tim']} turnover/anno={pa['turnover']} fee_sweep={pa['fee_sweep']}")
|
||||
|
||||
print("\n[5] MARGINALE vs TP01 (finestra comune col baseline)")
|
||||
marg = al.marginal_vs_tp01(daily)
|
||||
abs_grade = absolute["verdict"]["grade"]
|
||||
earns_slot = (abs_grade != "FAIL" and marg.get("marginal_verdict") == "ADDS"
|
||||
and marg.get("robust_oos", False) and marg.get("beats_noise_null", False)
|
||||
and not marg.get("is_hedge", False))
|
||||
rep = dict(name=f"R0701-FUND {chosen['form']} lb{chosen['lb']} thr{chosen['thr']}",
|
||||
absolute=absolute, marginal=marg, abs_grade=abs_grade,
|
||||
marginal_verdict=marg.get("marginal_verdict"), earns_slot=earns_slot)
|
||||
print(al.fmt_marginal(rep))
|
||||
earns_honest = bool(earns_slot and dsr_pass)
|
||||
print(f"\n EARNS_SLOT (marginal) = {earns_slot} EARNS_SLOT_HONEST (con DSR) = {earns_honest}")
|
||||
|
||||
# --- 5bis. controllo di attribuzione: il funding aggiunge qualcosa al trend nudo? ----
|
||||
print("\n[5bis] CONTROLLO DECISIVO — trend long-flat IDENTICO ma SENZA gate funding")
|
||||
d_tr = cell_daily(trend_only_target)
|
||||
tr_ins, tr_hold = d_tr[d_tr.index < al.HOLDOUT], d_tr[d_tr.index >= al.HOLDOUT]
|
||||
JJ = pd.concat({"G": daily, "T": d_tr}, axis=1, join="inner").dropna()
|
||||
print(f" trend NUDO: IS {al._sh(tr_ins):.2f} FULL {al._sh(d_tr):.2f} HOLD {al._sh(tr_hold):.2f}")
|
||||
print(f" trend+GATE: IS {chosen['insample_sharpe']:.2f} FULL {chosen['full_sharpe']:.2f} "
|
||||
f"HOLD {chosen['hold_sharpe']:.2f}")
|
||||
print(f" corr(gated, nudo) = {JJ['G'].corr(JJ['T']):.3f} "
|
||||
f"delta FULL = {al._sh(JJ['G']) - al._sh(JJ['T']):+.3f} "
|
||||
f"delta HOLD = {al._sh(JJ['G'][JJ.index >= al.HOLDOUT]) - al._sh(JJ['T'][JJ.index >= al.HOLDOUT]):+.3f}")
|
||||
attribution = dict(trend_nudo=dict(IS=round(al._sh(tr_ins), 3), FULL=round(al._sh(d_tr), 3),
|
||||
HOLD=round(al._sh(tr_hold), 3)),
|
||||
corr_gated_nudo=round(float(JJ["G"].corr(JJ["T"])), 3),
|
||||
delta_full=round(al._sh(JJ["G"]) - al._sh(JJ["T"]), 3),
|
||||
delta_hold=round(al._sh(JJ["G"][JJ.index >= al.HOLDOUT])
|
||||
- al._sh(JJ["T"][JJ.index >= al.HOLDOUT]), 3))
|
||||
|
||||
# --- 5ter. la migliore cella PURO-funding (fade/follow/diverge, senza trend) ---------
|
||||
pure = [r for r in valid if r["form"] != "gate"]
|
||||
bp = pure[0] if pure else None
|
||||
if bp:
|
||||
print(f"\n[5ter] MIGLIOR CELLA PURO-FUNDING (no trend): {bp['form']} lb={bp['lb']} "
|
||||
f"thr={bp['thr']} IS {bp['insample_sharpe']} FULL {bp['full_sharpe']} "
|
||||
f"HOLD {bp['hold_sharpe']}")
|
||||
d_bp = cell_daily(make_target(form=bp["form"], lb=bp["lb"], thr=bp["thr"]))
|
||||
m_bp = al.marginal_vs_tp01(d_bp)
|
||||
print(f" marginale vs TP01: {m_bp.get('marginal_verdict')} corr {m_bp.get('corr_full')} "
|
||||
f"IS-edge {m_bp.get('cand_insample_sharpe')} "
|
||||
f"uplift w25 full {m_bp['blends']['w25']['uplift_full']:+.3f} / "
|
||||
f"hold {m_bp['blends']['w25']['uplift_hold']:+.3f}")
|
||||
|
||||
# --- 6. realism: prefix / boundary / smallcap ---------------------------------------
|
||||
print("\n[6] REALISM CHECKS (cella scelta)")
|
||||
worst = prefix_check(fn)
|
||||
print(f" prefix-consistency (guardia look-ahead): max diff = {worst:.2e} "
|
||||
f"({'OK' if worst < 1e-9 else 'ATTENZIONE'})")
|
||||
bnd = boundary_check(chosen["form"], chosen["lb"], chosen["thr"])
|
||||
print(f" boundary-shift (finestra funding -0/3/6/9/12h): {bnd['per_offset']}")
|
||||
print(f" sharpe_sign_stable={bnd['sharpe_sign_stable']} uplift_spread={bnd['uplift_spread']}")
|
||||
sc = smallcap_check(fn)
|
||||
print(f" smallcap $600 (min order $5): {sc}")
|
||||
|
||||
# --- 7. cross-check non troncato (footnote) -----------------------------------------
|
||||
print("\n[7] CROSS-CHECK study_marginal NON troncato (frame 2018+, diluito dagli zeri "
|
||||
"pre-copertura: footnote, non il giudizio primario)")
|
||||
sm_full = al.study_marginal(f"R0701-FUND-XCHK {chosen['form']}", fn, tf="1d")
|
||||
print(f" abs={sm_full['abs_grade']} marginal={sm_full['marginal_verdict']} "
|
||||
f"earns_slot={sm_full['earns_slot']}")
|
||||
|
||||
# --- 8. verdetto --------------------------------------------------------------------
|
||||
summary = dict(
|
||||
chosen=dict(form=chosen["form"], lb=chosen["lb"], thr=chosen["thr"]),
|
||||
insample_sharpe=chosen["insample_sharpe"], full_sharpe=chosen["full_sharpe"],
|
||||
hold_sharpe=chosen["hold_sharpe"], dsr=round(float(dsr), 3), dsr_pass=dsr_pass,
|
||||
abs_grade=abs_grade, marginal_verdict=marg.get("marginal_verdict"),
|
||||
corr_tp01_full=marg.get("corr_full"), cand_insample_sharpe=marg.get("cand_insample_sharpe"),
|
||||
has_insample_edge=marg.get("has_insample_edge"), is_hedge=marg.get("is_hedge"),
|
||||
robust_oos=marg.get("robust_oos"), multicut=marg.get("multicut_uplift"),
|
||||
earns_slot=earns_slot, earns_slot_honest=earns_honest,
|
||||
smallcap=sc, boundary_uplift_spread=bnd["uplift_spread"],
|
||||
attribution_vs_trend_nudo=attribution,
|
||||
best_pure_funding=(dict(form=bp["form"], lb=bp["lb"], thr=bp["thr"],
|
||||
IS=bp["insample_sharpe"], FULL=bp["full_sharpe"],
|
||||
HOLD=bp["hold_sharpe"]) if bp else None),
|
||||
n_cells=len(rows), history_years=round((hi - lo).days / 365.25, 1))
|
||||
print("\n[8] SUMMARY JSON")
|
||||
print(json.dumps(summary, default=str))
|
||||
return summary
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,103 @@
|
||||
"""r0701_gtaa_5th_sleeve — EQ-GTAA01 come 5° sleeve del portafoglio attivo? Valutazione ONESTA.
|
||||
|
||||
Contesto (diari 2026-06-22/23): GTAA (trend difensivo equity, 6 ETF, vt12%, ~30y di storia, netto
|
||||
fee IB 2bps/lato) fu validato come diversificatore strutturale del book crypto (corr ~0.17-0.21,
|
||||
combo Sharpe 1.60->1.81) ma lasciato in paper cross-venue (paper_combo) e MAI valutato come 5°
|
||||
sleeve del portafoglio attivo (il giorno dopo arrivo' SKH01). Qui: gate onesti per lo slot.
|
||||
|
||||
Metodo:
|
||||
* pannello = 4 sleeve attivi + GTAA (to_daily), outer-join rinormalizzato (combine_outer);
|
||||
* finestra di valutazione ancorata all'inizio del book crypto (TP01 start) — NO gonfiaggio
|
||||
da 23 anni di solo-GTAA pre-2019;
|
||||
* peso GTAA selezionato IN-SAMPLE (Sharpe pre-2025) su griglia {10..30%}, gli altri scalati
|
||||
(1-w) — nessuna scelta sull'hold-out;
|
||||
* verifica: hold-out 2025+, multi-cut (2023-01/2024-01/2024-07/2025-01), finestre DISGIUNTE,
|
||||
per-anno, corr, is_hedge (corr tra Sharpe-anno del book e uplift-anno), plateau in w.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.portfolio.portfolio import HOLDOUT, combine_outer, metrics, to_daily
|
||||
from src.portfolio.sleeves import active_sleeves
|
||||
from src.portfolio.gtaa import gtaa_returns
|
||||
|
||||
GRID_W = [0.10, 0.15, 0.20, 0.25, 0.30]
|
||||
CUTS = [pd.Timestamp(c, tz="UTC") for c in ("2023-01-01", "2024-01-01", "2024-07-01", "2025-01-01")]
|
||||
DISJOINT = [("2019-03-01", "2023-01-01"), ("2023-01-01", "2025-01-01"), ("2025-01-01", "2027-01-01")]
|
||||
|
||||
|
||||
def sh(s: pd.Series, lo=None, hi=None) -> float:
|
||||
v = s
|
||||
if lo is not None:
|
||||
v = v[v.index >= lo]
|
||||
if hi is not None:
|
||||
v = v[v.index < hi]
|
||||
r = np.asarray(v.dropna().values, float)
|
||||
return float(r.mean() / r.std() * np.sqrt(365.25)) if len(r) > 1 and r.std() > 0 else 0.0
|
||||
|
||||
|
||||
def main() -> None:
|
||||
sl = active_sleeves()
|
||||
cols = {s.name: s.daily() for s in sl}
|
||||
w_cur = {s.name: s.weight for s in sl}
|
||||
g = to_daily(gtaa_returns())
|
||||
lo_book = min(c.index.min() for c in cols.values()) # inizio era-crypto del book
|
||||
cols5 = dict(cols, GTAA01=g)
|
||||
|
||||
base = combine_outer(cols, w_cur, lo=lo_book)
|
||||
print(f"book start {lo_book.date()} | GTAA storia {g.index.min().date()}->{g.index.max().date()}"
|
||||
f" | GTAA standalone: FULL {sh(g):.2f}, pre-2025 {sh(g, hi=HOLDOUT):.2f}, "
|
||||
f"era-crypto {sh(g[g.index >= lo_book]):.2f}, HOLD {sh(g, lo=HOLDOUT):.2f}")
|
||||
print(f"corr(GTAA, book) era-crypto: {g.reindex(base.index).corr(base):+.3f} | "
|
||||
f"hold-out: {g.reindex(base.index).loc[HOLDOUT:].corr(base.loc[HOLDOUT:]):+.3f}")
|
||||
print(f"\nBASE 4-sleeve : FULL {sh(base):.3f} IS {sh(base, hi=HOLDOUT):.3f} "
|
||||
f"HOLD {sh(base, lo=HOLDOUT):.3f} DD {metrics(base)['maxdd']:.1%} "
|
||||
f"CAGR {metrics(base)['cagr']:+.1%}")
|
||||
|
||||
# griglia peso GTAA — selezione IN-SAMPLE
|
||||
rows = []
|
||||
for w in GRID_W:
|
||||
w5 = {k: v * (1 - w) for k, v in w_cur.items()} | {"GTAA01": w}
|
||||
c5 = combine_outer(cols5, w5, lo=lo_book)
|
||||
rows.append(dict(w=w, IS=sh(c5, hi=HOLDOUT), FULL=sh(c5), HOLD=sh(c5, lo=HOLDOUT),
|
||||
DD=metrics(c5)["maxdd"], CAGR=metrics(c5)["cagr"]))
|
||||
print(f" +GTAA {w:.0%}: IS {rows[-1]['IS']:.3f} FULL {rows[-1]['FULL']:.3f} "
|
||||
f"HOLD {rows[-1]['HOLD']:.3f} DD {rows[-1]['DD']:.1%} CAGR {rows[-1]['CAGR']:+.1%}")
|
||||
best = max(rows, key=lambda r: r["IS"])
|
||||
w = best["w"]
|
||||
print(f"\ncella IN-SAMPLE: w={w:.0%} (IS {best['IS']:.3f}); plateau IS: "
|
||||
+ ", ".join(f"{r['w']:.0%}:{r['IS']:.2f}" for r in rows))
|
||||
|
||||
w5 = {k: v * (1 - w) for k, v in w_cur.items()} | {"GTAA01": w}
|
||||
c5 = combine_outer(cols5, w5, lo=lo_book)
|
||||
|
||||
print("\nmulti-cut (Sharpe OOS dal taglio, book+GTAA vs book):")
|
||||
for cut in CUTS:
|
||||
print(f" {cut.date()}: {sh(c5, lo=cut):.3f} vs {sh(base, lo=cut):.3f} "
|
||||
f"delta {sh(c5, lo=cut) - sh(base, lo=cut):+.3f}")
|
||||
print("finestre DISGIUNTE:")
|
||||
for a, b in DISJOINT:
|
||||
la, lb = pd.Timestamp(a, tz="UTC"), pd.Timestamp(b, tz="UTC")
|
||||
print(f" {a[:7]}..{b[:7]}: delta {sh(c5, lo=la, hi=lb) - sh(base, lo=la, hi=lb):+.3f}")
|
||||
|
||||
print("per-anno (ret book -> ret book+GTAA):")
|
||||
ups, shs = [], []
|
||||
for y in sorted(set(base.index.year)):
|
||||
by, cy = base[base.index.year == y], c5[c5.index.year == y]
|
||||
rb, rc = float((1 + by).prod() - 1), float((1 + cy).prod() - 1)
|
||||
ups.append(sh(cy) - sh(by)); shs.append(sh(by))
|
||||
print(f" {y}: {rb:+7.1%} -> {rc:+7.1%} dSh {sh(cy) - sh(by):+.2f}")
|
||||
ih = float(np.corrcoef(shs, ups)[0, 1]) if len(ups) > 2 else float("nan")
|
||||
print(f"is_hedge check — corr(Sharpe-anno book, uplift-anno): {ih:+.2f} "
|
||||
f"(molto negativa = hedge, non alpha)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,472 @@
|
||||
"""r0701_portfolio_opt — MIGLIORARE IL PORTAFOGLIO ATTIVO SENZA NUOVE STRATEGIE (2026-07-01).
|
||||
|
||||
(A) RI-OTTIMIZZAZIONE DEI PESI STATICI dei 4 sleeve (TP01/XS01/VRP01/SKH01).
|
||||
I pesi correnti (41.25/18.75/15/25) sono ad-hoc. Test onesto:
|
||||
- ottimizza SOLO in-sample (pre-cut), valuta OOS (post-cut), multi-cut {2024-01, 2024-07, 2025-01};
|
||||
- criteri: MAXSH (max-Sharpe simulato), RP (inverse-vol), ERC (equal-risk-contribution),
|
||||
MINVAR-R (min-varianza con vincolo di ritorno >= ritorno dei pesi correnti in-sample);
|
||||
- null di riferimento: EW (25x4) e CURRENT (pesi correnti);
|
||||
- vincoli: 0.05 <= w_i <= 0.60, sum(w)=1;
|
||||
- sleeve con < MIN_IS_DAYS giorni in-sample al cut (XS01 al 2024-01) -> PINNED al peso corrente
|
||||
(l'ottimizzatore non ha dati per giudicarli), gli altri ottimizzati nel budget residuo;
|
||||
- il combine replica ESATTAMENTE src/portfolio/portfolio.combined_daily (outer-join,
|
||||
pesi rinormalizzati per-riga sugli sleeve attivi).
|
||||
Verdetto: MIGLIORA solo se l'uplift OOS vs CURRENT e' PERSISTENTE (positivo a ogni cut),
|
||||
non su una finestra sola.
|
||||
|
||||
(B) GUARDIA-DRAWDOWN a livello portafoglio (replica del soft-guard vincente del tail-hedge lab
|
||||
2026-06-23, portato sul 4-sleeve). CAUSALE: l'esposizione del giorno D usa l'equity fino a D-1.
|
||||
Trigger/re-entry sul NAV OMBRA (equity non-guardata: lezione stops_lab — sull'equity congelata
|
||||
a expo=0 il DD non rientra mai). Isteresi: de-risk quando DD>X, re-risk quando DD<0.4*X.
|
||||
Griglia modesta X in {3,4,5,6}%, derisk in {0, 0.5}. Selezione cella IN-SAMPLE (pre-cut,
|
||||
criterio Calmar = CAGR/MaxDD), verifica OOS multi-cut.
|
||||
NULL DECISIVO (lezione tp01-dvol-overlay): esposizione COSTANTE c calibrata in-sample per
|
||||
pareggiare il MaxDD della guardia — lo Sharpe di c*r e' INVARIANTE, quindi la guardia "vale"
|
||||
solo se a pari MaxDD trattiene piu' CAGR/Sharpe del semplice de-levering.
|
||||
|
||||
(C) Combinazione A+B se entrambe reggono.
|
||||
|
||||
NB (contesto): il thread meta-allocazione (2026-06-29) ha gia' mostrato che l'allocazione DINAMICA
|
||||
fra sleeve perde vs pesi fissi — qui NON la rifacciamo: (A) e' statico, (B) e' un overlay di
|
||||
esposizione aggregata, non riallocazione fra sleeve.
|
||||
|
||||
Solo ricerca: NON tocca src/ ne' scripts/live/. Propone pesi, non li applica.
|
||||
|
||||
uv run python scripts/research/r0701_portfolio_opt.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 scipy.optimize import minimize
|
||||
|
||||
from src.portfolio.sleeves import active_sleeves
|
||||
from src.portfolio.portfolio import HOLDOUT, DAYS_PER_YEAR
|
||||
|
||||
CUTS = [pd.Timestamp(c, tz="UTC") for c in ("2024-01-01", "2024-07-01", "2025-01-01")]
|
||||
LO_W, HI_W = 0.05, 0.60 # vincoli pesi
|
||||
MIN_IS_DAYS = 120 # sotto -> sleeve PINNED al peso corrente (niente dati per giudicarlo)
|
||||
GUARD_TRIGGERS = (0.03, 0.04, 0.05, 0.06)
|
||||
GUARD_DERISK = (0.0, 0.5)
|
||||
GUARD_REC_FRAC = 0.4 # re-risk quando DD < 0.4*trigger (isteresi, come tail-hedge lab)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- dati
|
||||
def load_matrix() -> tuple[pd.DataFrame, list[str], np.ndarray]:
|
||||
"""Matrice daily OUTER-join dei 4 sleeve (NaN dove lo sleeve non e' ancora partito)."""
|
||||
sl = active_sleeves()
|
||||
names = [s.name for s in sl]
|
||||
w_cur = np.array([s.weight for s in sl], float)
|
||||
w_cur = w_cur / w_cur.sum()
|
||||
J = pd.concat({s.name: s.daily() for s in sl}, axis=1, join="outer").sort_index()
|
||||
J = J[J.notna().any(axis=1)]
|
||||
return J, names, w_cur
|
||||
|
||||
|
||||
def combo(J: pd.DataFrame, w: np.ndarray) -> pd.Series:
|
||||
"""Replica ESATTA di portfolio.combined_daily: pesi rinormalizzati per-riga sugli attivi."""
|
||||
active = J.notna().values * w
|
||||
rowsum = active.sum(axis=1, keepdims=True)
|
||||
wnorm = np.divide(active, rowsum, out=np.zeros_like(active), where=rowsum > 0)
|
||||
return pd.Series(np.nansum(np.nan_to_num(J.values) * wnorm, axis=1), index=J.index)
|
||||
|
||||
|
||||
def met(r: pd.Series | np.ndarray) -> dict:
|
||||
v = np.asarray(pd.Series(r).dropna().values, float)
|
||||
if len(v) < 20 or v.std() == 0:
|
||||
return dict(sh=0.0, cagr=0.0, dd=0.0, calmar=0.0, n=len(v))
|
||||
eq = np.cumprod(1 + v); pk = np.maximum.accumulate(eq)
|
||||
yrs = len(v) / DAYS_PER_YEAR
|
||||
cagr = float(eq[-1] ** (1 / yrs) - 1) if eq[-1] > 0 else -1.0
|
||||
dd = float(np.max((pk - eq) / pk))
|
||||
return dict(sh=float(v.mean() / v.std() * np.sqrt(DAYS_PER_YEAR)), cagr=cagr, dd=dd,
|
||||
calmar=(cagr / dd if dd > 0 else 0.0), n=len(v))
|
||||
|
||||
|
||||
def fmt_w(names, w):
|
||||
return " ".join(f"{n.split('_')[0]} {x*100:.1f}%" for n, x in zip(names, w))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- (A) ottimizzatori
|
||||
def _pinned_mask(J_is: pd.DataFrame, w_cur: np.ndarray) -> np.ndarray:
|
||||
"""True = sleeve senza abbastanza storia in-sample -> peso fissato al corrente."""
|
||||
return np.array([J_is[c].notna().sum() < MIN_IS_DAYS for c in J_is.columns])
|
||||
|
||||
|
||||
def _solve(J_is, w_cur, objective, extra_cons=None, hi_bounds=None) -> np.ndarray:
|
||||
"""SLSQP su pesi liberi (i pinned restano al corrente), bounds+somma, multi-start.
|
||||
hi_bounds: array di upper-bound per-sleeve (default HI_W) — per i cap STRUTTURALI."""
|
||||
k = len(w_cur)
|
||||
hi = np.full(k, HI_W) if hi_bounds is None else np.asarray(hi_bounds, float)
|
||||
pin = _pinned_mask(J_is, w_cur)
|
||||
free = ~pin
|
||||
budget = 1.0 - w_cur[pin].sum()
|
||||
nf = int(free.sum())
|
||||
|
||||
def full_w(wf):
|
||||
w = w_cur.copy(); w[free] = wf
|
||||
return w
|
||||
|
||||
cons = [dict(type="eq", fun=lambda wf: wf.sum() - budget)]
|
||||
if extra_cons:
|
||||
cons += [dict(type="ineq", fun=lambda wf, f=f: f(full_w(wf))) for f in extra_cons]
|
||||
bounds = [(LO_W, float(h)) for h in hi[free]]
|
||||
hif = hi[free]
|
||||
starts = [w_cur[free], np.full(nf, budget / nf)]
|
||||
rng = np.random.default_rng(7)
|
||||
for _ in range(4):
|
||||
x = rng.uniform(LO_W, hif); starts.append(x / x.sum() * budget)
|
||||
best, bval = w_cur.copy(), np.inf
|
||||
for x0 in starts:
|
||||
x0 = np.clip(x0, LO_W, hif); x0 = x0 / x0.sum() * budget
|
||||
res = minimize(lambda wf: objective(full_w(wf)), x0, method="SLSQP",
|
||||
bounds=bounds, constraints=cons,
|
||||
options=dict(maxiter=300, ftol=1e-10))
|
||||
if res.success and res.fun < bval:
|
||||
bval, best = res.fun, full_w(res.x)
|
||||
best = np.clip(best, LO_W, hi)
|
||||
return best / best.sum()
|
||||
|
||||
|
||||
def opt_maxsh(J_is, w_cur):
|
||||
return _solve(J_is, w_cur, lambda w: -met(combo(J_is, w))["sh"])
|
||||
|
||||
|
||||
def opt_minvar_ret(J_is, w_cur):
|
||||
"""Min-varianza con vincolo: media in-sample >= media dei pesi correnti in-sample."""
|
||||
mu_cur = float(combo(J_is, w_cur).mean())
|
||||
return _solve(J_is, w_cur, lambda w: float(combo(J_is, w).var()),
|
||||
extra_cons=[lambda w: float(combo(J_is, w).mean()) - mu_cur])
|
||||
|
||||
|
||||
def opt_rp(J_is, w_cur):
|
||||
"""Inverse-vol (risk-parity naive) sulla vol in-sample di ogni sleeve (giorni attivi propri)."""
|
||||
pin = _pinned_mask(J_is, w_cur); free = ~pin
|
||||
vol = np.array([J_is[c].dropna().std() for c in J_is.columns])
|
||||
w = w_cur.copy()
|
||||
iv = 1.0 / np.where(vol[free] > 0, vol[free], np.inf)
|
||||
w[free] = iv / iv.sum() * (1.0 - w_cur[pin].sum())
|
||||
for _ in range(50): # clip ai bounds + rinormalizza
|
||||
w = np.clip(w, LO_W, HI_W)
|
||||
if abs(w.sum() - 1.0) < 1e-9:
|
||||
break
|
||||
adj = ~np.isclose(w, LO_W) & ~np.isclose(w, HI_W)
|
||||
if not adj.any():
|
||||
break
|
||||
w[adj] += (1.0 - w.sum()) * (w[adj] / w[adj].sum())
|
||||
return w / w.sum()
|
||||
|
||||
|
||||
def opt_erc(J_is, w_cur):
|
||||
"""Equal-risk-contribution sulla cov in-sample pairwise-complete (submatrice dei non-pinned)."""
|
||||
pin = _pinned_mask(J_is, w_cur)
|
||||
cov = J_is.cov().values # pairwise-complete
|
||||
idx = np.where(~pin)[0]
|
||||
S = cov[np.ix_(idx, idx)]
|
||||
S = np.nan_to_num(S, nan=0.0)
|
||||
|
||||
def obj(w):
|
||||
wf = w[idx]
|
||||
port = wf @ S @ wf
|
||||
if port <= 0:
|
||||
return 1e6
|
||||
rc = wf * (S @ wf) / port
|
||||
return float(np.sum((rc - 1.0 / len(idx)) ** 2))
|
||||
return _solve(J_is, w_cur, obj)
|
||||
|
||||
|
||||
def _struct_caps(J_is) -> np.ndarray:
|
||||
"""Cap STRUTTURALI (prudenza, non statistica): VRP01 <= 15% (sleeve MODELLATO su DVOL ATM,
|
||||
stress-f non catturato — 'niente short-vol da modello in deploy'), XS01 <= 25% (STAT-MODE,
|
||||
storia ~2.5 anni). Gli ottimizzatori naive caricano VRP01 (vol 7.7% ma e' la vol del MODELLO)."""
|
||||
return np.array([0.15 if c.startswith("VRP") else (0.25 if c.startswith("XS") else HI_W)
|
||||
for c in J_is.columns])
|
||||
|
||||
|
||||
def opt_maxsh_struct(J_is, w_cur):
|
||||
return _solve(J_is, w_cur, lambda w: -met(combo(J_is, w))["sh"], hi_bounds=_struct_caps(J_is))
|
||||
|
||||
|
||||
CRITERIA = [("MAXSH", opt_maxsh), ("RP-invvol", opt_rp), ("ERC", opt_erc), ("MINVAR-R", opt_minvar_ret),
|
||||
("MAXSH-STR", opt_maxsh_struct)]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- (B) guardia-DD
|
||||
def dd_guard(r: pd.Series, trigger: float, derisk: float, rec_frac: float = GUARD_REC_FRAC):
|
||||
"""Soft-guard causale: expo[t] decisa dal DD del NAV OMBRA (non-guardato) fino a t-1.
|
||||
DD>trigger -> expo=derisk; DD<rec_frac*trigger -> expo=1 (isteresi). Ritorna (serie, expo)."""
|
||||
v = r.values
|
||||
eq = np.cumprod(1 + v); pk = np.maximum.accumulate(eq)
|
||||
n = len(v); expo = np.ones(n); on = True
|
||||
for i in range(1, n):
|
||||
ddi = (pk[i - 1] - eq[i - 1]) / pk[i - 1]
|
||||
if ddi > trigger:
|
||||
on = False
|
||||
elif ddi < trigger * rec_frac:
|
||||
on = True
|
||||
expo[i] = 1.0 if on else derisk
|
||||
return pd.Series(expo * v, index=r.index), expo
|
||||
|
||||
|
||||
def const_expo_match_dd(r: pd.Series, target_dd: float) -> float:
|
||||
"""Esposizione costante c (in-sample) che pareggia il MaxDD target — il null de-levering."""
|
||||
lo, hi = 0.05, 1.0
|
||||
if met(r)["dd"] <= target_dd:
|
||||
return 1.0
|
||||
for _ in range(40):
|
||||
mid = 0.5 * (lo + hi)
|
||||
if met(pd.Series(mid * r.values, index=r.index))["dd"] > target_dd:
|
||||
hi = mid
|
||||
else:
|
||||
lo = mid
|
||||
return lo
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- report
|
||||
def section_A(J, names, w_cur):
|
||||
print("\n" + "=" * 108)
|
||||
print(" (A) RI-OTTIMIZZAZIONE PESI STATICI — ottimizza IN-SAMPLE (pre-cut), valuta OOS (post-cut), multi-cut")
|
||||
print("=" * 108)
|
||||
|
||||
# correlazioni e vol per contesto (full, pairwise)
|
||||
C = J.corr()
|
||||
print("\n corr pairwise (full):")
|
||||
for i, a in enumerate(names):
|
||||
print(" " + a.split("_")[0].ljust(6) + " ".join(f"{C.iloc[i, j]:+.2f}" for j in range(len(names))))
|
||||
print(" vol annua per-sleeve (full): " +
|
||||
" ".join(f"{n.split('_')[0]} {J[n].dropna().std()*np.sqrt(DAYS_PER_YEAR)*100:.1f}%" for n in names))
|
||||
|
||||
candidates = {} # nome -> pesi ottimizzati su pre-HOLDOUT (headline)
|
||||
J_is_main = J[J.index < HOLDOUT]
|
||||
for cname, fn in CRITERIA:
|
||||
candidates[cname] = fn(J_is_main, w_cur)
|
||||
candidates["EW"] = np.full(len(names), 1.0 / len(names))
|
||||
caps = _struct_caps(J)
|
||||
w_ewstr = np.full(len(names), 1.0 / len(names)) # EW sotto i cap strutturali (null senza fit)
|
||||
for _ in range(20):
|
||||
over = w_ewstr > caps + 1e-12
|
||||
if not over.any():
|
||||
break
|
||||
excess = float((w_ewstr[over] - caps[over]).sum())
|
||||
w_ewstr[over] = caps[over]
|
||||
room = w_ewstr < caps - 1e-12 # redistribuisci SOLO a chi ha spazio
|
||||
w_ewstr[room] += excess / max(int(room.sum()), 1)
|
||||
candidates["EW-STR"] = w_ewstr
|
||||
candidates["CURRENT"] = w_cur
|
||||
|
||||
print(f"\n PESI (ottimizzati su in-sample pre-{HOLDOUT.date()}):")
|
||||
for cname, w in candidates.items():
|
||||
print(f" {cname:<10s} {fmt_w(names, w)}")
|
||||
|
||||
# headline: FULL + HOLD-OUT
|
||||
print(f"\n {'criterio':<10s} {'FULL Sh':>8s} {'FULL DD':>8s} {'FULL CAGR':>9s} | {'HOLD Sh':>8s} {'HOLD DD':>8s} {'HOLD CAGR':>9s}")
|
||||
rows = {}
|
||||
for cname, w in candidates.items():
|
||||
f = met(combo(J, w)); h = met(combo(J[J.index >= HOLDOUT], w))
|
||||
rows[cname] = (f, h)
|
||||
print(f" {cname:<10s} {f['sh']:>8.2f} {f['dd']*100:>7.1f}% {f['cagr']*100:>+8.1f}% |"
|
||||
f" {h['sh']:>8.2f} {h['dd']*100:>7.1f}% {h['cagr']*100:>+8.1f}%")
|
||||
|
||||
# multi-cut: ottimizza su <cut, valuta su >=cut, uplift vs CURRENT sulla stessa finestra OOS
|
||||
print("\n MULTI-CUT (pesi ri-ottimizzati a ogni cut sul solo pre-cut; ΔSh OOS vs CURRENT):")
|
||||
header = f" {'criterio':<10s}" + "".join(f" | {c.date()} Sh_oos ΔSh " for c in CUTS)
|
||||
print(header)
|
||||
persist = {}
|
||||
fixed = {"EW": candidates["EW"], "EW-STR": candidates["EW-STR"]}
|
||||
for cname, fn in CRITERIA + [("EW", None), ("EW-STR", None)]:
|
||||
cells = []
|
||||
ok = True
|
||||
for cut in CUTS:
|
||||
J_is, J_oos = J[J.index < cut], J[J.index >= cut]
|
||||
w = fixed[cname] if cname in fixed else fn(J_is, w_cur)
|
||||
sh_o = met(combo(J_oos, w))["sh"]
|
||||
sh_c = met(combo(J_oos, w_cur))["sh"]
|
||||
d = sh_o - sh_c
|
||||
ok &= d > 0
|
||||
cells.append(f" | {sh_o:>10.2f} {d:>+5.2f}")
|
||||
persist[cname] = ok
|
||||
print(f" {cname:<10s}" + "".join(cells) + (" <-- PERSISTENTE" if ok else ""))
|
||||
|
||||
print(" ⚠ caveat: le 3 finestre OOS sono ANNIDATE (tutte contengono il 2025-26) — non 3 conferme")
|
||||
print(" indipendenti. Il test disgiunto vero e' il per-anno qui sotto.")
|
||||
|
||||
# per-anno (finestre DISGIUNTE): ritorno annuo del candidato vs CURRENT, pesi fissi pre-2025
|
||||
print("\n PER-ANNO (pesi headline fissi; Δret vs CURRENT, finestre disgiunte):")
|
||||
sel = ["MAXSH", "MINVAR-R", "MAXSH-STR", "EW", "EW-STR"]
|
||||
years = sorted(set(J.index.year))
|
||||
print(" " + "anno".ljust(6) + "".join(f"{c:>12s}" for c in ["CURRENT"] + sel))
|
||||
wins = {c: 0 for c in sel}
|
||||
for y in years:
|
||||
Jy = J[J.index.year == y]
|
||||
base_ret = float(np.prod(1 + combo(Jy, w_cur).values) - 1)
|
||||
row = f" {y} {base_ret*100:>+10.1f}%"
|
||||
for c in sel:
|
||||
ry = float(np.prod(1 + combo(Jy, candidates[c]).values) - 1)
|
||||
wins[c] += ry > base_ret
|
||||
row += f"{(ry-base_ret)*100:>+11.1f}p"
|
||||
print(row)
|
||||
print(" anni vinti vs CURRENT: " + " ".join(f"{c} {wins[c]}/{len(years)}" for c in sel))
|
||||
|
||||
# verdetto: la persistenza statistica NON basta — i pesi devono rispettare i cap STRUTTURALI
|
||||
# (VRP01<=15% perche' MODELLATO, XS01<=25% perche' STAT-MODE/storia corta), altrimenti l'uplift
|
||||
# sta comprando rischio-modello che il backtest non vede (punto cieco tipo CC01).
|
||||
winners = [c for c, ok in persist.items() if ok]
|
||||
respects = {c: bool(np.all(candidates[c] <= caps + 1e-3)) for c in candidates} # tol 0.1pp (jitter clip/renorm)
|
||||
struct_winners = [c for c in winners if respects[c]]
|
||||
print("\n cap strutturali: " + fmt_w(names, caps))
|
||||
if winners:
|
||||
print(" persistenti: " + ", ".join(f"{c} ({'dentro i cap' if respects[c] else 'VIOLA i cap'})"
|
||||
for c in winners))
|
||||
if struct_winners:
|
||||
wbest = candidates[struct_winners[0]]
|
||||
fb, fc = met(combo(J, wbest)), met(combo(J, w_cur))
|
||||
hb, hc = met(combo(J[J.index >= HOLDOUT], wbest)), met(combo(J[J.index >= HOLDOUT], w_cur))
|
||||
verdict = (f"MIGLIORA (OOS) — {struct_winners} persistente E dentro i cap strutturali. "
|
||||
f"Pesi candidati ({struct_winners[0]}): {fmt_w(names, wbest)}.\n"
|
||||
f" TRADE-OFF onesto: HOLD Sh {hc['sh']:.2f}->{hb['sh']:.2f}, HOLD CAGR "
|
||||
f"{hc['cagr']*100:+.1f}%->{hb['cagr']*100:+.1f}%, MA FULL Sh {fc['sh']:.2f}->{fb['sh']:.2f} "
|
||||
f"e FULL DD {fc['dd']*100:.1f}%->{fb['dd']*100:.1f}% — l'uplift e' un TILT di ritorno verso "
|
||||
f"gli sleeve research-grade (piu' SKH01/XS01, meno TP01), non un free lunch risk-adjusted. "
|
||||
f"NB: nessun OTTIMIZZATORE aggiunge nulla oltre questo null parameter-free "
|
||||
f"(MAXSH in-sample vince 1/8 anni: l'ottimizzazione qui overfitta, la de-concentrazione no).")
|
||||
elif winners:
|
||||
verdict = ("NON ADOTTABILE — l'uplift persistente esiste SOLO violando i cap strutturali "
|
||||
"(tutti i vincitori caricano VRP01>15%, sleeve MODELLATO il cui rischio di coda "
|
||||
"il backtest non cattura). Dentro i cap l'ottimizzatore converge ~ai pesi correnti "
|
||||
"e non e' persistente -> per il book reale I PESI CORRENTI REGGONO.")
|
||||
else:
|
||||
verdict = "NON MIGLIORA — nessun criterio batte i pesi CORRENTI in modo persistente. I pesi correnti reggono."
|
||||
print("\n VERDETTO (A): " + verdict)
|
||||
return candidates, persist, struct_winners
|
||||
|
||||
|
||||
def section_B(J, names, w_cur, label="CURRENT"):
|
||||
print("\n" + "=" * 108)
|
||||
print(f" (B) GUARDIA-DRAWDOWN a livello portafoglio (pesi {label}) — trigger X, de-risk d, isteresi 0.4X")
|
||||
print("=" * 108)
|
||||
r_full = combo(J, w_cur)
|
||||
|
||||
# griglia completa su FULL/IS/HOLD per trasparenza (selezione = solo in-sample)
|
||||
r_is = r_full[r_full.index < HOLDOUT]
|
||||
r_ho = r_full[r_full.index >= HOLDOUT]
|
||||
b_is, b_ho, b_f = met(r_is), met(r_ho), met(r_full)
|
||||
print(f"\n baseline IS Sh {b_is['sh']:.2f} DD {b_is['dd']*100:.1f}% CAGR {b_is['cagr']*100:+.1f}%"
|
||||
f" | HOLD Sh {b_ho['sh']:.2f} DD {b_ho['dd']*100:.1f}% CAGR {b_ho['cagr']*100:+.1f}%"
|
||||
f" | FULL Sh {b_f['sh']:.2f} DD {b_f['dd']*100:.1f}%")
|
||||
print(f"\n {'cella':<16s} {'IS Sh':>6s} {'IS DD':>6s} {'IS CAGR':>8s} {'IS Calmar':>9s} {'%off':>5s} |"
|
||||
f" {'HOLD Sh':>7s} {'HOLD DD':>7s} {'HOLD CAGR':>9s}")
|
||||
grid = {}
|
||||
for trig in GUARD_TRIGGERS:
|
||||
for dr in GUARD_DERISK:
|
||||
g_full, expo = dd_guard(r_full, trig, dr)
|
||||
g_is = g_full[g_full.index < HOLDOUT]; g_ho = g_full[g_full.index >= HOLDOUT]
|
||||
mi, mh = met(g_is), met(g_ho)
|
||||
off = float((expo[: len(r_is)] < 1.0).mean())
|
||||
grid[(trig, dr)] = dict(is_=mi, ho=mh, full=met(g_full))
|
||||
print(f" X={trig*100:.0f}% d={dr:<4.1f} {mi['sh']:>6.2f} {mi['dd']*100:>5.1f}% {mi['cagr']*100:>+7.1f}%"
|
||||
f" {mi['calmar']:>9.2f} {off*100:>4.0f}% | {mh['sh']:>7.2f} {mh['dd']*100:>6.1f}% {mh['cagr']*100:>+8.1f}%")
|
||||
|
||||
# selezione IN-SAMPLE (Calmar), poi null de-levering a pari MaxDD in-sample
|
||||
best_cell = max(grid, key=lambda k: grid[k]["is_"]["calmar"])
|
||||
trig, dr = best_cell
|
||||
print(f"\n cella selezionata IN-SAMPLE (max Calmar): X={trig*100:.0f}% derisk={dr}")
|
||||
tgt_dd = grid[best_cell]["is_"]["dd"]
|
||||
c = const_expo_match_dd(r_is, tgt_dd)
|
||||
cnull_full = pd.Series(c * r_full.values, index=r_full.index)
|
||||
n_is, n_ho = met(cnull_full[cnull_full.index < HOLDOUT]), met(cnull_full[cnull_full.index >= HOLDOUT])
|
||||
g = grid[best_cell]
|
||||
print(f" NULL de-levering: expo costante c={c:.2f} pareggia il MaxDD IS della guardia ({tgt_dd*100:.1f}%)")
|
||||
print(f" {'':<14s} {'IS Sh':>6s} {'IS CAGR':>8s} | {'HOLD Sh':>7s} {'HOLD DD':>7s} {'HOLD CAGR':>9s}")
|
||||
print(f" guardia {g['is_']['sh']:>6.2f} {g['is_']['cagr']*100:>+7.1f}% | {g['ho']['sh']:>7.2f}"
|
||||
f" {g['ho']['dd']*100:>6.1f}% {g['ho']['cagr']*100:>+8.1f}%")
|
||||
print(f" const c={c:.2f} {n_is['sh']:>6.2f} {n_is['cagr']*100:>+7.1f}% | {n_ho['sh']:>7.2f}"
|
||||
f" {n_ho['dd']*100:>6.1f}% {n_ho['cagr']*100:>+8.1f}%")
|
||||
|
||||
# multi-cut: selezione sul pre-cut, valutazione OOS post-cut (guardia vs baseline vs null)
|
||||
inert = True
|
||||
print("\n MULTI-CUT (cella riscelta a ogni cut sul solo pre-cut; OOS post-cut):")
|
||||
print(f" {'cut':<12s} {'cella':<14s} {'OOS Sh(g)':>9s} {'OOS Sh(b)':>9s} {'ΔSh':>6s} {'OOS DD(g)':>9s}"
|
||||
f" {'OOS DD(b)':>9s} {'ΔCAGR':>7s} {'null c: ΔSh':>11s}")
|
||||
persist_sh, persist_dd = True, True
|
||||
for cut in CUTS:
|
||||
r_pre = r_full[r_full.index < cut]
|
||||
cells = {}
|
||||
for t in GUARD_TRIGGERS:
|
||||
for d in GUARD_DERISK:
|
||||
gpre, _ = dd_guard(r_pre, t, d)
|
||||
cells[(t, d)] = met(gpre)["calmar"]
|
||||
(t_, d_) = max(cells, key=cells.get)
|
||||
g_full, _ = dd_guard(r_full, t_, d_) # guard sul path completo (stato causale)
|
||||
g_oos = g_full[g_full.index >= cut]
|
||||
b_oos = r_full[r_full.index >= cut]
|
||||
gm, bm = met(g_oos), met(b_oos)
|
||||
cc = const_expo_match_dd(r_pre, met(dd_guard(r_pre, t_, d_)[0])["dd"])
|
||||
nm = met(pd.Series(cc * b_oos.values, index=b_oos.index))
|
||||
dsh = gm["sh"] - bm["sh"]; ddd = gm["dd"] - bm["dd"]; dcagr = gm["cagr"] - bm["cagr"]
|
||||
persist_sh &= dsh >= 0; persist_dd &= ddd < 0
|
||||
inert &= abs(dsh) < 0.005 and abs(ddd) < 1e-4
|
||||
print(f" {str(cut.date()):<12s} X={t_*100:.0f}% d={d_:<6.1f} {gm['sh']:>9.2f} {bm['sh']:>9.2f} {dsh:>+6.2f}"
|
||||
f" {gm['dd']*100:>8.1f}% {bm['dd']*100:>8.1f}% {dcagr*100:>+6.1f}pp {gm['sh']-nm['sh']:>+11.2f}")
|
||||
|
||||
if inert:
|
||||
verdict = ("NON MIGLIORA — INERTE OOS: la cella scelta in-sample non scatta MAI post-cut "
|
||||
"(il DD OOS del 4-sleeve resta sotto il trigger: la diversificazione fa gia' il lavoro "
|
||||
"della guardia). Trigger piu' stretti (X=3-4%) scattano ma COSTANO Sharpe su HOLD. "
|
||||
"Coerente col thread meta-allocazione 2026-06-29 (drawdown-control ridondante).")
|
||||
elif persist_sh and persist_dd:
|
||||
verdict = f"MIGLIORA — la guardia X={trig*100:.0f}%/d={dr} taglia il DD OOS senza costo Sharpe persistente"
|
||||
elif persist_dd:
|
||||
verdict = ("TAGLIA IL DD MA COSTA — DD ridotto a ogni cut ma Sharpe/CAGR inferiori: "
|
||||
"trade-off assicurativo, confrontare col null de-levering prima di adottarla")
|
||||
else:
|
||||
verdict = "NON MIGLIORA — la guardia non riduce il DD OOS in modo persistente (o e' solo de-levering)"
|
||||
print("\n VERDETTO (B): " + verdict)
|
||||
return grid, best_cell
|
||||
|
||||
|
||||
def section_C(J, names, w_cur, candidates, struct_winners, grid, best_cell):
|
||||
print("\n" + "=" * 108)
|
||||
print(" (C) COMBINAZIONE A+B")
|
||||
print("=" * 108)
|
||||
wA = candidates[struct_winners[0]] if struct_winners else w_cur
|
||||
labA = struct_winners[0] if struct_winners else "CURRENT (A non adottabile)"
|
||||
trig, dr = best_cell
|
||||
r = combo(J, wA)
|
||||
g, _ = dd_guard(r, trig, dr)
|
||||
for lbl, s in [("baseline CURRENT", combo(J, w_cur)),
|
||||
(f"pesi {labA}", r),
|
||||
(f"pesi {labA} + guard X={trig*100:.0f}%/d={dr}", g)]:
|
||||
f = met(s); h = met(s[s.index >= HOLDOUT])
|
||||
print(f" {lbl:<52s} FULL Sh {f['sh']:>5.2f} DD {f['dd']*100:>4.1f}% CAGR {f['cagr']*100:>+5.1f}%"
|
||||
f" | HOLD Sh {h['sh']:>5.2f} DD {h['dd']*100:>4.1f}% CAGR {h['cagr']*100:>+5.1f}%")
|
||||
if not struct_winners:
|
||||
print(" -> (A) non adottabile e (B) inerte OOS: la combinazione non ha una cella da proporre.")
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 108)
|
||||
print(" r0701_portfolio_opt — pesi statici + guardia-DD sul portafoglio attivo a 4 sleeve")
|
||||
print(f" hold-out bloccato {HOLDOUT.date()}+ | vincoli pesi [{LO_W},{HI_W}] | cuts {[str(c.date()) for c in CUTS]}")
|
||||
print("=" * 108)
|
||||
J, names, w_cur = load_matrix()
|
||||
print(f"\n sleeve: {names}")
|
||||
print(f" pesi CORRENTI: {fmt_w(names, w_cur)}")
|
||||
print(f" finestra: {J.index[0].date()} -> {J.index[-1].date()} ({len(J)} giorni)")
|
||||
for n in names:
|
||||
s = J[n].dropna()
|
||||
print(f" {n:<16s} dal {s.index[0].date()} ({len(s)} giorni)")
|
||||
|
||||
b_f = met(combo(J, w_cur)); b_h = met(combo(J[J.index >= HOLDOUT], w_cur))
|
||||
print(f"\n BASELINE (pesi correnti): FULL Sh {b_f['sh']:.2f} DD {b_f['dd']*100:.1f}% CAGR {b_f['cagr']*100:+.1f}%"
|
||||
f" | HOLD Sh {b_h['sh']:.2f} DD {b_h['dd']*100:.1f}% CAGR {b_h['cagr']*100:+.1f}%")
|
||||
|
||||
candidates, persistA, struct_winners = section_A(J, names, w_cur)
|
||||
grid, best_cell = section_B(J, names, w_cur)
|
||||
section_C(J, names, w_cur, candidates, struct_winners, grid, best_cell)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,304 @@
|
||||
"""r0701_portfolio_skeptic — VERIFICA AVVERSARIALE del tilt EW-STR (TP30/XS25/VRP15/SKH30).
|
||||
|
||||
Oggetto: la proposta di r0701_portfolio_opt (diario 2026-07-01-portfolio-weights-ddguard.md) di
|
||||
spostare i pesi del portafoglio a 4 sleeve da CURRENT (41.25/18.75/15/25) a EW-STR (30/25/15/30).
|
||||
Claim: HOLD Sh 2.21->2.35, HOLD CAGR +16.0->+19.7%, multi-cut +0.06/+0.13/+0.14, 7/8 anni vinti.
|
||||
|
||||
Linee di attacco (agente scettico):
|
||||
1. RIPRODUZIONE indipendente via src.portfolio.StrategyPortfolio (path di PRODUZIONE, non lo
|
||||
script dell'agente).
|
||||
2. SELEZIONE-SULL'HOLD-OUT DI 2° ORDINE via SKH01/XS01: SKH01 fu ammesso (2026-06-23) perche'
|
||||
alzava l'hold-out 2025-26; XS01 fu affinato (blend+gate) conoscendo l'hold-out. Un tilt verso
|
||||
di loro eredita quella selezione? Test: (a) per-anno SOLO 2019-2024; (b) tilt scomposti
|
||||
(solo-XS con SKH pinned 25, solo-SKH con XS pinned); (c) finestre OOS DISGIUNTE (i 3 cut del
|
||||
diario sono ANNIDATI); (d) contributo per-sleeve all'uplift hold-out.
|
||||
3. "7/8 ANNI VINTI": quale metrica (ritorno, non Sharpe), margini per anno, Sharpe per-anno.
|
||||
4. PLATEAU: griglia 2.5pp fra CURRENT e EW-STR (TP x SKH, VRP=15, XS=residuo cap 25).
|
||||
5. REALISMO: pesi RINORMALIZZATI per era (outer-join: nel 2019-20 attivi solo TP+SKH!), haircut
|
||||
d'esecuzione su SKH01/XS01 come DRAG (r' = r - h*mean(r), modello-costi, non de-levering).
|
||||
6. FORKING PATHS: quante config viste sull'hold-out; null di tilt CASUALI cap-respecting — se
|
||||
quasi ogni tilt anti-TP01 batte CURRENT sull'hold-out, il claim e' generico, non informativo.
|
||||
|
||||
Solo lettura/analisi: NON tocca src/ ne' scripts/live/.
|
||||
uv run python scripts/research/r0701_portfolio_skeptic.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
|
||||
from src.portfolio.portfolio import StrategyPortfolio, HOLDOUT, DAYS_PER_YEAR, metrics
|
||||
|
||||
CUTS = [pd.Timestamp(c, tz="UTC") for c in ("2024-01-01", "2024-07-01", "2025-01-01")]
|
||||
LO_W = 0.05
|
||||
|
||||
# pesi per PREFISSO sleeve (ordine risolto sui nomi del registry)
|
||||
W_CURRENT = {"TP01": 0.4125, "XS01": 0.1875, "VRP01": 0.15, "SKH01": 0.25}
|
||||
W_EWSTR = {"TP01": 0.30, "XS01": 0.25, "VRP01": 0.15, "SKH01": 0.30}
|
||||
W_ONLY_XS = {"TP01": 0.35, "XS01": 0.25, "VRP01": 0.15, "SKH01": 0.25} # SKH pinned al corrente
|
||||
W_ONLY_SKH = {"TP01": 0.3625, "XS01": 0.1875, "VRP01": 0.15, "SKH01": 0.30} # XS pinned al corrente
|
||||
CAPS = {"TP01": 0.60, "XS01": 0.25, "VRP01": 0.15, "SKH01": 0.60}
|
||||
|
||||
|
||||
def prefix(name: str) -> str:
|
||||
return name.split("_")[0]
|
||||
|
||||
|
||||
def wvec(names: list[str], wd: dict) -> np.ndarray:
|
||||
v = np.array([wd[prefix(n)] for n in names], float)
|
||||
return v / v.sum()
|
||||
|
||||
|
||||
def combo(J: pd.DataFrame, w: np.ndarray) -> pd.Series:
|
||||
"""Combine outer-join con pesi rinormalizzati per-riga (stessa semantica di combined_daily;
|
||||
verificata sotto contro il path di produzione)."""
|
||||
act = J.notna().values * w
|
||||
rs = act.sum(axis=1, keepdims=True)
|
||||
wn = np.divide(act, rs, out=np.zeros_like(act), where=rs > 0)
|
||||
return pd.Series(np.nansum(np.nan_to_num(J.values) * wn, axis=1), index=J.index)
|
||||
|
||||
|
||||
def met(s) -> dict:
|
||||
return metrics(pd.Series(s).dropna()) # metriche di PRODUZIONE (src.portfolio)
|
||||
|
||||
|
||||
def sh(s) -> float:
|
||||
return met(s)["sharpe"]
|
||||
|
||||
|
||||
def yr_ret(s) -> float:
|
||||
v = pd.Series(s).dropna().values
|
||||
return float(np.prod(1 + v) - 1)
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 110)
|
||||
print(" r0701_portfolio_skeptic — verifica avversariale EW-STR (30/25/15/30) vs CURRENT (41.25/18.75/15/25)")
|
||||
print("=" * 110)
|
||||
|
||||
sl = active_sleeves()
|
||||
names = [s.name for s in sl]
|
||||
J = pd.concat({s.name: s.daily() for s in sl}, axis=1, join="outer").sort_index()
|
||||
J = J[J.notna().any(axis=1)]
|
||||
w_cur = wvec(names, W_CURRENT)
|
||||
w_ew = wvec(names, W_EWSTR)
|
||||
print(f"\n finestra {J.index[0].date()} -> {J.index[-1].date()} ({len(J)} giorni)")
|
||||
for n in names:
|
||||
s = J[n].dropna()
|
||||
print(f" {n:<16s} dal {s.index[0].date()} ({len(s)} giorni)")
|
||||
|
||||
# ------------------------------------------------ 1) RIPRODUZIONE (path di produzione)
|
||||
print("\n" + "-" * 110)
|
||||
print(" [1] RIPRODUZIONE INDIPENDENTE — StrategyPortfolio.combined_daily (produzione), non lo script agente")
|
||||
print("-" * 110)
|
||||
for lbl, wd in [("CURRENT", W_CURRENT), ("EW-STR", W_EWSTR)]:
|
||||
for s, sleeve_w in zip(sl, [wd[prefix(n)] for n in names]):
|
||||
s.weight = sleeve_w
|
||||
port = StrategyPortfolio(sl)
|
||||
full = port.combined_daily()
|
||||
hold = port.combined_daily(lo=HOLDOUT)
|
||||
# check che il mio combine coincida col path di produzione
|
||||
mine = combo(J, wvec(names, wd))
|
||||
dmax = float(np.max(np.abs(mine.values - full.values)))
|
||||
f, h = met(full), met(hold)
|
||||
print(f" {lbl:<8s} FULL Sh {f['sharpe']:.2f} DD {f['maxdd']*100:.1f}% CAGR {f['cagr']*100:+.1f}%"
|
||||
f" | HOLD Sh {h['sharpe']:.2f} DD {h['maxdd']*100:.1f}% CAGR {h['cagr']*100:+.1f}%"
|
||||
f" (|combine mio - produzione|max = {dmax:.2e})")
|
||||
print(" multi-cut ΔSh OOS EW-STR vs CURRENT (finestre ANNIDATE, come nel diario):")
|
||||
for cut in CUTS:
|
||||
Jo = J[J.index >= cut]
|
||||
d = sh(combo(Jo, w_ew)) - sh(combo(Jo, w_cur))
|
||||
print(f" cut {cut.date()} ΔSh {d:+.2f}")
|
||||
|
||||
# ------------------------------------------------ 2) SELEZIONE-SULL'HOLD-OUT DI 2° ORDINE
|
||||
print("\n" + "-" * 110)
|
||||
print(" [2] SELEZIONE HOLD-OUT DI 2° ORDINE — SKH01 ammesso PERCHE' alzava l'hold-out; XS01 affinato idem")
|
||||
print("-" * 110)
|
||||
# (d prima: contesto) Sharpe standalone per-sleeve, pre-2025 vs hold-out
|
||||
print(" Sharpe standalone per sleeve (pre-2025 | hold-out 2025+):")
|
||||
for n in names:
|
||||
s = J[n].dropna()
|
||||
print(f" {n:<16s} IS {sh(s[s.index < HOLDOUT]):>5.2f} | HOLD {sh(s[s.index >= HOLDOUT]):>5.2f}")
|
||||
|
||||
# (a) per-anno SOLO 2019-2024 (pre-selezione di SKH01/holdout)
|
||||
print("\n (a) per-anno SOLO 2019-2024 (finestra NON toccata dalla selezione hold-out):")
|
||||
print(f" {'anno':<6s} {'ret CUR':>9s} {'ret EW-STR':>10s} {'Δret':>8s} | {'Sh CUR':>7s} {'Sh EW-STR':>9s} {'ΔSh':>6s}")
|
||||
wins_ret_pre = wins_sh_pre = 0
|
||||
years_pre = [y for y in sorted(set(J.index.year)) if y < 2025]
|
||||
for y in years_pre:
|
||||
Jy = J[J.index.year == y]
|
||||
rc, re = combo(Jy, w_cur), combo(Jy, w_ew)
|
||||
dr = yr_ret(re) - yr_ret(rc); dsh = sh(re) - sh(rc)
|
||||
wins_ret_pre += dr > 0; wins_sh_pre += dsh > 0
|
||||
print(f" {y:<6d} {yr_ret(rc)*100:>+8.1f}% {yr_ret(re)*100:>+9.1f}% {dr*100:>+7.1f}p |"
|
||||
f" {sh(rc):>7.2f} {sh(re):>9.2f} {dsh:>+6.2f}")
|
||||
print(f" -> vittorie EW-STR 2019-2024: ritorno {wins_ret_pre}/{len(years_pre)}, Sharpe {wins_sh_pre}/{len(years_pre)}")
|
||||
Jpre = J[J.index < HOLDOUT]
|
||||
print(f" pre-2025 aggregato: Sh CUR {sh(combo(Jpre, w_cur)):.2f} vs EW-STR {sh(combo(Jpre, w_ew)):.2f}"
|
||||
f" (ΔSh {sh(combo(Jpre, w_ew)) - sh(combo(Jpre, w_cur)):+.2f})")
|
||||
|
||||
# (b) tilt scomposti
|
||||
print("\n (b) tilt SCOMPOSTI (chi porta l'uplift?): HOLD Sh + multi-cut ΔSh vs CURRENT")
|
||||
for lbl, wd in [("solo-XS (SKH pinned 25): TP35 /XS25/VRP15/SKH25", W_ONLY_XS),
|
||||
("solo-SKH (XS pinned 18.75): TP36.25/XS18.75/VRP15/SKH30", W_ONLY_SKH),
|
||||
("EW-STR: TP30 /XS25/VRP15/SKH30", W_EWSTR)]:
|
||||
w = wvec(names, wd)
|
||||
hold_sh = sh(combo(J[J.index >= HOLDOUT], w))
|
||||
cuts_d = [sh(combo(J[J.index >= c], w)) - sh(combo(J[J.index >= c], w_cur)) for c in CUTS]
|
||||
print(f" {lbl:<58s} HOLD Sh {hold_sh:.2f} multi-cut " +
|
||||
" ".join(f"{d:+.2f}" for d in cuts_d))
|
||||
|
||||
# (c) finestre OOS DISGIUNTE (i cut del diario sono annidati)
|
||||
print("\n (c) finestre OOS DISGIUNTE (ΔSh e Δret EW-STR vs CURRENT):")
|
||||
windows = [("2024-01 -> 2024-07", "2024-01-01", "2024-07-01"),
|
||||
("2024-07 -> 2025-01", "2024-07-01", "2025-01-01"),
|
||||
("2025-01 -> fine ", "2025-01-01", None)]
|
||||
for lbl, lo, hi in windows:
|
||||
m = (J.index >= pd.Timestamp(lo, tz="UTC"))
|
||||
if hi:
|
||||
m &= (J.index < pd.Timestamp(hi, tz="UTC"))
|
||||
Jw = J[m]
|
||||
rc, re = combo(Jw, w_cur), combo(Jw, w_ew)
|
||||
print(f" {lbl} ΔSh {sh(re) - sh(rc):+.2f} Δret {(yr_ret(re) - yr_ret(rc))*100:+.1f}pp")
|
||||
|
||||
# (d) contributo per-sleeve all'uplift hold-out (pesi statici: sul hold-out tutti attivi)
|
||||
print("\n (d) contributo per-sleeve al Δritorno annualizzato sull'hold-out (Δw_i x mean_i x 365):")
|
||||
Jh = J[J.index >= HOLDOUT]
|
||||
for n in names:
|
||||
dw = W_EWSTR[prefix(n)] - W_CURRENT[prefix(n)]
|
||||
mu = float(Jh[n].dropna().mean()) * DAYS_PER_YEAR
|
||||
print(f" {n:<16s} Δw {dw*100:>+6.2f}pp x ret_ann {mu*100:>+6.1f}% = {dw*mu*100:>+5.2f}pp/anno")
|
||||
|
||||
# ------------------------------------------------ 3) "7/8 ANNI VINTI"
|
||||
print("\n" + "-" * 110)
|
||||
print(" [3] '7/8 ANNI VINTI' — metrica = RITORNO composto per anno (non Sharpe). Tabella completa + margini")
|
||||
print("-" * 110)
|
||||
print(f" {'anno':<6s} {'ret CUR':>9s} {'ret EW-STR':>10s} {'Δret':>8s} | {'Sh CUR':>7s} {'Sh EW-STR':>9s} {'ΔSh':>6s} |"
|
||||
f" {'DD CUR':>7s} {'DD EW':>7s}")
|
||||
wins_ret = wins_sh = 0
|
||||
years = sorted(set(J.index.year))
|
||||
for y in years:
|
||||
Jy = J[J.index.year == y]
|
||||
rc, re = combo(Jy, w_cur), combo(Jy, w_ew)
|
||||
mc, me = met(rc), met(re)
|
||||
dr = yr_ret(re) - yr_ret(rc); dsh = sh(re) - sh(rc)
|
||||
wins_ret += dr > 0; wins_sh += dsh > 0
|
||||
print(f" {y:<6d} {yr_ret(rc)*100:>+8.1f}% {yr_ret(re)*100:>+9.1f}% {dr*100:>+7.1f}p |"
|
||||
f" {sh(rc):>7.2f} {sh(re):>9.2f} {dsh:>+6.2f} | {mc['maxdd']*100:>6.1f}% {me['maxdd']*100:>6.1f}%")
|
||||
print(f" -> vittorie EW-STR: RITORNO {wins_ret}/{len(years)} | SHARPE {wins_sh}/{len(years)}"
|
||||
f" (2026 = anno parziale)")
|
||||
|
||||
# ------------------------------------------------ 4) PLATEAU
|
||||
print("\n" + "-" * 110)
|
||||
print(" [4] PLATEAU — griglia 2.5pp: TP x SKH, VRP=15 fisso, XS = residuo (cap 25). HOLD Sh | min multi-cut ΔSh")
|
||||
print("-" * 110)
|
||||
tps = [0.30, 0.325, 0.35, 0.375, 0.40, 0.4125]
|
||||
skhs = [0.25, 0.275, 0.30]
|
||||
Jh = J[J.index >= HOLDOUT]
|
||||
hold_cur = sh(combo(Jh, w_cur))
|
||||
print(f" (HOLD Sh CURRENT = {hold_cur:.2f}; celle con XS>25% = viola cap, marcate *)")
|
||||
hdr = " TP\\SKH " + "".join(f"{s*100:>18.1f}%" for s in skhs)
|
||||
print(hdr)
|
||||
for tp in tps:
|
||||
row = f" {tp*100:>6.2f}%"
|
||||
for skh in skhs:
|
||||
xs = 1.0 - 0.15 - tp - skh
|
||||
tag = "*" if xs > CAPS["XS01"] + 1e-9 else " "
|
||||
if xs < LO_W - 1e-9:
|
||||
row += f"{'—':>19s}"
|
||||
continue
|
||||
w = np.array([{"TP01": tp, "XS01": xs, "VRP01": 0.15, "SKH01": skh}[prefix(n)] for n in names])
|
||||
hs = sh(combo(Jh, w))
|
||||
dmin = min(sh(combo(J[J.index >= c], w)) - sh(combo(J[J.index >= c], w_cur)) for c in CUTS)
|
||||
row += f" {hs:>5.2f}|{dmin:+.2f}{tag}"
|
||||
print(row)
|
||||
|
||||
# ------------------------------------------------ 5) REALISMO
|
||||
print("\n" + "-" * 110)
|
||||
print(" [5] REALISMO — pesi EFFETTIVI per era (outer-join rinormalizza!) + haircut d'esecuzione")
|
||||
print("-" * 110)
|
||||
# pesi rinormalizzati per era
|
||||
eras = [("2019-2020 (TP+SKH)", "2019-06-01"), ("2021-2023 (TP+VRP+SKH)", "2022-06-01"),
|
||||
("2024+ (tutti)", "2025-06-01")]
|
||||
print(" pesi EFFETTIVI (rinormalizzati sugli sleeve attivi) per era:")
|
||||
for lbl, probe in eras:
|
||||
t = pd.Timestamp(probe, tz="UTC")
|
||||
i = J.index.get_indexer([t], method="nearest")[0]
|
||||
act = J.iloc[i].notna().values
|
||||
for wl, w in [("CURRENT", w_cur), ("EW-STR ", w_ew)]:
|
||||
wn = w * act; wn = wn / wn.sum()
|
||||
s = " ".join(f"{prefix(n)} {x*100:.0f}%" for n, x in zip(names, wn) if act[list(names).index(n)])
|
||||
print(f" {lbl:<24s} {wl}: {s}")
|
||||
print(" -> nel 2019-23 EW-STR tiene SKH01 al 40-50% effettivo (research-grade, book 230m).")
|
||||
|
||||
# haircut d'esecuzione come DRAG (r' = r - h*mean_full(r)): modello-costi, non de-levering
|
||||
print("\n haircut esecuzione (drag costante = h x mean ritorno full dello sleeve):")
|
||||
print(f" {'scenario':<34s} {'CUR HOLD Sh':>11s} {'EW HOLD Sh':>11s} {'ΔSh':>6s} | {'CUR HOLD CAGR':>13s} {'EW HOLD CAGR':>13s}")
|
||||
scen = [("nessun haircut", {}, 0.0),
|
||||
("SKH01 -20%", {"SKH01"}, 0.20), ("SKH01 -30%", {"SKH01"}, 0.30),
|
||||
("SKH01+XS01 -20%", {"SKH01", "XS01"}, 0.20), ("SKH01+XS01 -30%", {"SKH01", "XS01"}, 0.30)]
|
||||
for lbl, targets, h in scen:
|
||||
Jx = J.copy()
|
||||
for n in names:
|
||||
if prefix(n) in targets:
|
||||
mu = float(J[n].dropna().mean())
|
||||
Jx[n] = J[n] - h * mu # drag solo dove lo sleeve e' attivo (NaN restano NaN)
|
||||
Jxh = Jx[Jx.index >= HOLDOUT]
|
||||
rc, re = combo(Jxh, w_cur), combo(Jxh, w_ew)
|
||||
mc, me = met(rc), met(re)
|
||||
print(f" {lbl:<34s} {mc['sharpe']:>11.2f} {me['sharpe']:>11.2f} {me['sharpe']-mc['sharpe']:>+6.2f} |"
|
||||
f" {mc['cagr']*100:>+12.1f}% {me['cagr']*100:>+12.1f}%")
|
||||
|
||||
# ------------------------------------------------ 6) FORKING PATHS
|
||||
print("\n" + "-" * 110)
|
||||
print(" [6] FORKING PATHS — config viste sull'hold-out + null di tilt casuali cap-respecting")
|
||||
print("-" * 110)
|
||||
n_configs = 7 + 8 # 7 vettori pesi (MAXSH,RP,ERC,MINVAR-R,MAXSH-STR,EW,EW-STR) + 8 celle guardia
|
||||
print(f" config valutate sull'hold-out nello script agente: >= {n_configs} (7 pesi + 8 celle guardia),")
|
||||
print(" EW-STR costruito DOPO aver visto EW vincere (ammesso nel diario).")
|
||||
rng = np.random.default_rng(42)
|
||||
caps = np.array([CAPS[prefix(n)] for n in names])
|
||||
N = 500
|
||||
dsh_hold, dsh_full, all_cuts_pos = [], [], 0
|
||||
Jh = J[J.index >= HOLDOUT]
|
||||
hold_c = sh(combo(Jh, w_cur)); full_c = sh(combo(J, w_cur))
|
||||
cuts_J = {c: J[J.index >= c] for c in CUTS}
|
||||
cuts_base = {c: sh(combo(cuts_J[c], w_cur)) for c in CUTS}
|
||||
for _ in range(N):
|
||||
w = rng.dirichlet(np.ones(len(names)))
|
||||
for _ in range(60): # proiezione su [LO_W, caps], somma 1
|
||||
w = np.clip(w, LO_W, caps)
|
||||
d = 1.0 - w.sum()
|
||||
if abs(d) < 1e-9:
|
||||
break
|
||||
if d > 0:
|
||||
room = caps - w
|
||||
w += d * room / max(room.sum(), 1e-12)
|
||||
else:
|
||||
room = w - LO_W
|
||||
w += d * room / max(room.sum(), 1e-12)
|
||||
dh = sh(combo(Jh, w)) - hold_c
|
||||
dsh_hold.append(dh)
|
||||
dsh_full.append(sh(combo(J, w)) - full_c)
|
||||
all_cuts_pos += all(sh(combo(cuts_J[c], w)) - cuts_base[c] > 0 for c in CUTS)
|
||||
dsh_hold = np.array(dsh_hold); dsh_full = np.array(dsh_full)
|
||||
dew = sh(combo(Jh, w_ew)) - hold_c
|
||||
print(f" {N} tilt casuali dentro i cap (VRP<=15, XS<=25, w>=5): quota che batte CURRENT")
|
||||
print(f" su HOLD-OUT: {float((dsh_hold > 0).mean())*100:.0f}% (mediana ΔSh {np.median(dsh_hold):+.2f})")
|
||||
print(f" su FULL: {float((dsh_full > 0).mean())*100:.0f}% (mediana ΔSh {np.median(dsh_full):+.2f})")
|
||||
print(f" su TUTTI e 3 cut: {all_cuts_pos/N*100:.0f}%")
|
||||
print(f" percentile di EW-STR (ΔSh HOLD {dew:+.2f}) fra i tilt casuali: {float((dsh_hold < dew).mean())*100:.0f}°")
|
||||
print(" lettura: se quasi ogni tilt cap-respecting batte CURRENT sull'hold-out, il 'vince OOS'")
|
||||
print(" e' una proprieta' del PERIODO (hold-out pro-SKH/XS per costruzione), non del vettore EW-STR.")
|
||||
|
||||
print("\n" + "=" * 110)
|
||||
print(" fine verifica — vedi addendum nel diario 2026-07-01-portfolio-weights-ddguard.md")
|
||||
print("=" * 110)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,369 @@
|
||||
"""r0701_vrp_refine — AFFINAMENTO VRP01 (gate/sizing) dentro i limiti del modello (2026-07-01).
|
||||
|
||||
Baseline = VRP01 combo (sleeves._vrp_combo_returns): put credit spread settimanale -0.28/-0.10,
|
||||
f=1.0, tenor 7d, gate VRP>0 (DVOL>RV30 causale) AND IV-rank>0.30 AND crash-skip IV-rank>0.90,
|
||||
fee 12.5% del premio. FULL Sh ~1.10 / HOLD ~0.60 / DD ~12%.
|
||||
|
||||
Celle NUOVE (mai provate — verificato nei diari; l'active management intra-trade e' gia'
|
||||
SCARTATO in 2026-06-20-vrp-active-management.md e NON si ripete):
|
||||
1. SIZING sul gap IV-RV (il carry atteso): size lineare clip(vrp/scale,0,1) o percentile
|
||||
espandente causale del VRP, invece del (o in aggiunta al) gate binario IV-rank.
|
||||
NB: il gate composito "IV-rank>0.30 AND IV-RV>0" e' GIA' il baseline (gate_vrp=True).
|
||||
2. Filtro DVOL-MOMENTUM: non vendere vol mentre DVOL sta salendo (dv[i]-dv[i-k] > thr).
|
||||
(Diverso da dvol_directional 2026-06-29: la' il DVOL-mom era segnale DIREZIONALE sul perp.)
|
||||
3. Gate di REGIME da TP01: de-risk (skip o half-size) quando TP01 e' flat su BTC e ETH
|
||||
(risk-off). Rischio ridondanza col trend -> riporto la frequenza d'intervento REALE.
|
||||
4. Croce completa delle manopole (griglia contenuta, 105 celle, TUTTE contate nel DSR).
|
||||
|
||||
Metodo: stessa pipeline di options_vrp_v2 (pricing BS su DVOL reale, payoff sul path
|
||||
certificato, stesse fee) — cambiano SOLO gate/sizing. Selezione cella IN-SAMPLE (pre-2025),
|
||||
hold-out 2025-26, multi-cut (5 tagli), deflated-Sharpe su tutti i trial, effetto a livello
|
||||
portafoglio 4-sleeve (TP01 41.25 / XS01 18.75 / VRP 15 / SKH01 25).
|
||||
|
||||
ONESTA': il premio resta MODELLATO su DVOL ATM (no skew), book 1d, f di stress non catturato.
|
||||
Il verdetto massimo possibile e' "sleeve modellato migliorato", MAI deploy pieno.
|
||||
|
||||
uv run python scripts/research/r0701_vrp_refine.py [--skip-portfolio]
|
||||
"""
|
||||
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" / "alt"))
|
||||
|
||||
from collections import Counter
|
||||
from functools import lru_cache
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from scripts.research.options_vrp_lab import bs_put, strike_from_delta, load_series, m_weekly, per_year
|
||||
from altlib import deflated_sharpe
|
||||
|
||||
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
|
||||
WK_PER_YEAR = 365.25 / 7.0
|
||||
CUTS = [pd.Timestamp(c, tz="UTC") for c in
|
||||
("2023-01-01", "2023-07-01", "2024-01-01", "2024-07-01", "2025-01-01")]
|
||||
MIN_IS_ACTIVE = 0.20 # attivita' minima in-sample per candidarsi (baseline ~41%)
|
||||
|
||||
# --- parametri FISSI del baseline VRP01 (NON toccati: cambia solo gate/sizing) ---
|
||||
SHORT_DELTA, LONG_DELTA, F, TENOR_D = -0.28, -0.10, 1.0, 7
|
||||
CRASH_SKIP, FEE_FRAC = 0.90, 0.125
|
||||
|
||||
|
||||
# ----------------------------- pre-compute per asset (causale) -----------------------------
|
||||
@lru_cache(maxsize=None)
|
||||
def prep(asset: str):
|
||||
"""px/dvol allineati + VRP causale (DVOL - RV30) e IV-rank espandente per OGNI giorno.
|
||||
vrp[i] usa i 30 log-ret che finiscono a close[i]; ivr[i] = percentile di dv[i] in dv[:i]."""
|
||||
J = load_series(asset)
|
||||
px = J["px"].values.astype(float)
|
||||
dv = J["dvol"].values.astype(float) / 100.0
|
||||
idx = J.index
|
||||
n = len(px)
|
||||
lr = np.diff(np.log(px)) # lr[k] = log(px[k+1]/px[k])
|
||||
vrp = np.full(n, np.nan)
|
||||
for i in range(31, n):
|
||||
vrp[i] = dv[i] - float(np.std(lr[i - 30:i]) * np.sqrt(365.25)) # come baseline (ddof=0)
|
||||
ivr = np.full(n, np.nan)
|
||||
for i in range(60, n):
|
||||
ivr[i] = float((dv[:i] < dv[i]).mean())
|
||||
return px, dv, idx, vrp, ivr
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def tp01_avg_target():
|
||||
"""Serie giornaliera del target medio TP01 (BTC+ETH)/2. target[i] usa solo dati <= close[i]
|
||||
-> noto alla sell-date del VRP (stessa close). Long-flat: 0.0 = risk-off pieno."""
|
||||
from src.data.downloader import load_data
|
||||
from src.strategies.trend_portfolio import TrendPortfolio, CANONICAL, resample_1d
|
||||
tp = TrendPortfolio(**CANONICAL)
|
||||
cols = {}
|
||||
for a in ("BTC", "ETH"):
|
||||
df = resample_1d(load_data(a, "1h"))
|
||||
t = pd.Series(np.nan_to_num(tp.target_series(df), nan=0.0),
|
||||
index=pd.to_datetime(df["datetime"]))
|
||||
if t.index.tz is None:
|
||||
t.index = t.index.tz_localize("UTC")
|
||||
cols[a] = t
|
||||
J = pd.concat(cols, axis=1, join="inner")
|
||||
return J.mean(axis=1)
|
||||
|
||||
|
||||
# ----------------------------- motore settimanale (unica differenza: gate/sizing) -----------------------------
|
||||
def vrp_weekly(asset: str, sizing="bin", prop_scale=0.10, ivr_gate=0.30,
|
||||
mom_k=0, mom_thr=0.0, tp_mode="off") -> tuple[pd.Series, Counter]:
|
||||
"""Put credit spread settimanale come VRP01, con gate/sizing parametrici. CAUSALE:
|
||||
strike/premio/gate/size usano solo dati <= sell-date; payoff a scadenza sul path certificato.
|
||||
Ordine gate: prima i gate BASELINE (vrp/crash/ivr), poi i NUOVI (mom, tp) -> i counter dei
|
||||
nuovi gate contano l'intervento MARGINALE (settimane altrimenti tradabili)."""
|
||||
px, dv, idx, vrp_a, ivr_a = prep(asset)
|
||||
n = len(px); T = TENOR_D / 365.25
|
||||
tpv = None
|
||||
if tp_mode != "off":
|
||||
tpv = tp01_avg_target().reindex(idx, method="ffill").values
|
||||
rets = {}; st = Counter()
|
||||
i = 60
|
||||
while i + TENOR_D < n:
|
||||
st["weeks"] += 1
|
||||
S0 = px[i]; sig = dv[i]; vrp = vrp_a[i]; ivr = ivr_a[i]
|
||||
blocked = None
|
||||
# --- gate BASELINE (identici a VRP01) ---
|
||||
if np.isnan(vrp) or vrp <= 0:
|
||||
blocked = "vrp"
|
||||
elif not np.isnan(ivr) and ivr > CRASH_SKIP:
|
||||
blocked = "crash"
|
||||
elif ivr_gate > 0 and not np.isnan(ivr) and ivr < ivr_gate:
|
||||
blocked = "ivr"
|
||||
# --- gate NUOVI (contati sul residuo tradabile) ---
|
||||
if blocked is None and mom_k > 0 and i >= mom_k:
|
||||
if (dv[i] - dv[i - mom_k]) > mom_thr:
|
||||
blocked = "mom"
|
||||
size = 1.0
|
||||
if blocked is None and tp_mode != "off" and tpv is not None and tpv[i] <= 1e-12:
|
||||
if tp_mode == "skip":
|
||||
blocked = "tp"
|
||||
else: # half-size in risk-off
|
||||
size *= 0.5; st["tp_half"] += 1
|
||||
if blocked is None and sizing != "bin":
|
||||
if sizing == "lin": # size ∝ gap IV-RV (carry atteso)
|
||||
size *= float(np.clip(vrp / prop_scale, 0.0, 1.0))
|
||||
elif sizing == "rank": # percentile espandente causale del VRP
|
||||
hist = vrp_a[31:i]; hist = hist[~np.isnan(hist)]
|
||||
size *= float((hist < vrp).mean()) if len(hist) >= 30 else 0.5
|
||||
if blocked is not None:
|
||||
st[f"blk_{blocked}"] += 1
|
||||
rets[idx[i + TENOR_D]] = 0.0
|
||||
i += TENOR_D
|
||||
continue
|
||||
st["traded"] += 1; st["size_sum"] += size
|
||||
Ks = strike_from_delta(S0, T, sig, SHORT_DELTA)
|
||||
Kl = strike_from_delta(S0, T, sig, LONG_DELTA)
|
||||
net_prem = (bs_put(S0, Ks, T, sig) - bs_put(S0, Kl, T, sig)) * F
|
||||
S1 = px[i + TENOR_D]
|
||||
payoff = max(0.0, Ks - S1) - max(0.0, Kl - S1)
|
||||
pnl = net_prem - payoff - FEE_FRAC * abs(net_prem)
|
||||
rets[idx[i + TENOR_D]] = size * pnl / Ks # cash-secured su strike corto
|
||||
i += TENOR_D
|
||||
return pd.Series(rets), st
|
||||
|
||||
|
||||
def book(**kw) -> tuple[pd.Series, Counter]:
|
||||
rB, sB = vrp_weekly("BTC", **kw)
|
||||
rE, sE = vrp_weekly("ETH", **kw)
|
||||
b = pd.concat({"B": rB, "E": rE}, axis=1, join="inner").mean(axis=1).sort_index()
|
||||
return b, sB + sE
|
||||
|
||||
|
||||
# ----------------------------- metriche -----------------------------
|
||||
def sh_wk(r: pd.Series) -> float:
|
||||
r = r.dropna()
|
||||
if len(r) < 8 or r.std() == 0:
|
||||
return float("nan")
|
||||
return float(r.mean() / r.std() * np.sqrt(WK_PER_YEAR))
|
||||
|
||||
|
||||
def cell_metrics(b: pd.Series) -> dict:
|
||||
is_ = b[b.index < HOLDOUT]; ho = b[b.index >= HOLDOUT]
|
||||
full = m_weekly(b)
|
||||
return dict(full_sh=full["sh"], full_dd=full["dd"], full_cagr=full["cagr"],
|
||||
is_sh=sh_wk(is_), hold_sh=sh_wk(ho), worst=float(b.min()),
|
||||
active=float((b != 0).mean()), is_active=float((is_ != 0).mean()))
|
||||
|
||||
|
||||
def multicut(cand: pd.Series, base: pd.Series) -> list[tuple[str, float, float, float]]:
|
||||
out = []
|
||||
for c in CUTS:
|
||||
sc, sb = sh_wk(cand[cand.index >= c]), sh_wk(base[base.index >= c])
|
||||
out.append((str(c.date()), sc, sb, sc - sb))
|
||||
return out
|
||||
|
||||
|
||||
# ----------------------------- griglia -----------------------------
|
||||
def grid_cells():
|
||||
sizings = [("bin", 0.0, 0.30), ("lin", 0.08, 0.30), ("lin", 0.08, 0.0),
|
||||
("lin", 0.12, 0.30), ("lin", 0.12, 0.0), ("rank", 0.0, 0.30), ("rank", 0.0, 0.0)]
|
||||
moms = [(0, 0.0), (5, 0.0), (5, 0.05), (10, 0.0), (10, 0.05)]
|
||||
tps = ["off", "skip", "half"]
|
||||
cells = []
|
||||
for sz, scale, ivr in sizings:
|
||||
for mk, mth in moms:
|
||||
for tp in tps:
|
||||
name = (f"{sz}{f'{scale:g}' if sz == 'lin' else ''}"
|
||||
f"|ivr{ivr:g}|mom{mk}k{mth:g}|tp-{tp}")
|
||||
cells.append(dict(name=name, sizing=sz, prop_scale=scale, ivr_gate=ivr,
|
||||
mom_k=mk, mom_thr=mth, tp_mode=tp))
|
||||
return cells
|
||||
|
||||
|
||||
BASELINE_NAME = "bin|ivr0.3|mom0k0|tp-off"
|
||||
|
||||
|
||||
# ----------------------------- portafoglio 4-sleeve -----------------------------
|
||||
def weekly_to_daily_lump(wk: pd.Series) -> pd.Series:
|
||||
"""Come sleeves._vrp_combo_returns: rendimento settimanale sul giorno di scadenza, 0 altrove."""
|
||||
days = pd.date_range(wk.index.min().normalize(), wk.index.max().normalize(), freq="1D", tz="UTC")
|
||||
daily = pd.Series(0.0, index=days)
|
||||
daily.loc[wk.index.normalize()] = wk.values
|
||||
return daily
|
||||
|
||||
|
||||
def portfolio_compare(base_wk: pd.Series, cand_wk: pd.Series, cand_name: str):
|
||||
"""4-sleeve con VRP baseline vs VRP variante (stessi TP01/XS01/SKH01, cache condivisa)."""
|
||||
from src.portfolio.sleeves import tp01_sleeve, xsec_sleeve, skyhook_sleeve
|
||||
from src.portfolio.portfolio import Sleeve, StrategyPortfolio, metrics
|
||||
tp, xs, sk = tp01_sleeve(weight=0.4125), xsec_sleeve(weight=0.1875), skyhook_sleeve(weight=0.25)
|
||||
rows = []
|
||||
for tag, wk in (("VRP01 baseline", base_wk), (f"VRP variante [{cand_name}]", cand_wk)):
|
||||
daily = weekly_to_daily_lump(wk)
|
||||
vrp = Sleeve("VRP01_shortvol", 0.15, lambda d=daily: d)
|
||||
port = StrategyPortfolio([tp, xs, vrp, sk])
|
||||
full = metrics(port.combined_daily())
|
||||
hold = metrics(port.combined_daily(lo=HOLDOUT))
|
||||
rows.append((tag, full, hold))
|
||||
print(f" {tag:<38} FULL Sh {full['sharpe']:>5.2f} DD {full['maxdd']*100:>4.1f}% "
|
||||
f"CAGR {full['cagr']*100:>+5.1f}% | HOLD Sh {hold['sharpe']:>5.2f} DD {hold['maxdd']*100:>4.1f}%")
|
||||
return rows
|
||||
|
||||
|
||||
# ----------------------------- main -----------------------------
|
||||
def main():
|
||||
skip_port = "--skip-portfolio" in sys.argv
|
||||
print("=" * 110)
|
||||
print(" r0701 VRP REFINE — sizing IV-RV / filtro DVOL-momentum / gate TP01 (griglia onesta, sel. in-sample)")
|
||||
print("=" * 110)
|
||||
|
||||
cells = grid_cells()
|
||||
print(f" griglia: {len(cells)} celle (TUTTE contate nel deflated-Sharpe). "
|
||||
f"IS = pre-2025, HOLD = 2025-01-01+.\n")
|
||||
|
||||
results = {}
|
||||
for c in cells:
|
||||
b, st = book(sizing=c["sizing"], prop_scale=c["prop_scale"], ivr_gate=c["ivr_gate"],
|
||||
mom_k=c["mom_k"], mom_thr=c["mom_thr"], tp_mode=c["tp_mode"])
|
||||
results[c["name"]] = dict(cfg=c, b=b, st=st, **cell_metrics(b))
|
||||
|
||||
base = results[BASELINE_NAME]
|
||||
print(f" (0) BASELINE riprodotto [{BASELINE_NAME}]:")
|
||||
print(f" FULL Sh {base['full_sh']:.2f} DD {base['full_dd']*100:.0f}% CAGR {base['full_cagr']*100:+.0f}% "
|
||||
f"worst {base['worst']*100:+.1f}% IS Sh {base['is_sh']:.2f} HOLD Sh {base['hold_sh']:.2f} "
|
||||
f"attivo {base['active']*100:.0f}% (atteso ~ FULL 1.10 / HOLD 0.60 / DD 12%)")
|
||||
|
||||
# ---- frequenza d'intervento dei gate NUOVI (sul baseline + singola manopola) ----
|
||||
print("\n (1) FREQUENZA D'INTERVENTO dei gate nuovi (settimane altrimenti tradabili, book BTC+ETH):")
|
||||
probes = [("mom k=5 thr=0", dict(mom_k=5, mom_thr=0.0)),
|
||||
("mom k=5 thr=5pt", dict(mom_k=5, mom_thr=0.05)),
|
||||
("mom k=10 thr=0", dict(mom_k=10, mom_thr=0.0)),
|
||||
("mom k=10 thr=5pt", dict(mom_k=10, mom_thr=0.05)),
|
||||
("tp01-skip", dict(tp_mode="skip")),
|
||||
("tp01-half", dict(tp_mode="half"))]
|
||||
base_traded = base["st"]["traded"]
|
||||
for label, kw in probes:
|
||||
_, st = book(**kw)
|
||||
blk = st.get("blk_mom", 0) + st.get("blk_tp", 0)
|
||||
half = st.get("tp_half", 0)
|
||||
extra = f" (+{half} sett. a mezza size)" if half else ""
|
||||
print(f" {label:<18} blocca {blk:>3} / {base_traded} settimane-trade del baseline "
|
||||
f"({100*blk/max(base_traded,1):>4.1f}%){extra}")
|
||||
tgt = tp01_avg_target()
|
||||
pxB, _, idxB, _, _ = prep("BTC")
|
||||
tp_on_grid = tgt.reindex(idxB, method="ffill")
|
||||
print(f" [contesto] TP01 flat (BTC+ETH entrambi 0): {100*float((tp_on_grid <= 1e-12).mean()):.0f}% dei giorni della finestra DVOL")
|
||||
|
||||
# ---- classifica IN-SAMPLE (selezione onesta: nessuno sguardo all'hold-out) ----
|
||||
ranked = sorted((r for r in results.values() if r["is_active"] >= MIN_IS_ACTIVE),
|
||||
key=lambda r: r["is_sh"], reverse=True)
|
||||
print(f"\n (2) TOP-10 per Sharpe IN-SAMPLE (pre-2025; filtro attivita' IS >= {MIN_IS_ACTIVE:.0%}):")
|
||||
print(f" {'cella':<34}{'IS Sh':>7}{'FULL':>7}{'HOLD':>7}{'DD':>6}{'worst':>8}{'att.':>6}")
|
||||
for r in ranked[:10]:
|
||||
print(f" {r['cfg']['name']:<34}{r['is_sh']:>7.2f}{r['full_sh']:>7.2f}{r['hold_sh']:>7.2f}"
|
||||
f"{r['full_dd']*100:>5.0f}%{r['worst']*100:>+7.1f}%{r['active']*100:>5.0f}%")
|
||||
|
||||
# ---- varianti a SINGOLA manopola vs baseline (tabella diario) ----
|
||||
print("\n (2b) VARIANTI A SINGOLA MANOPOLA vs baseline (stessa tabella, nessuna selezione):")
|
||||
singles = ["lin0.08|ivr0.3|mom0k0|tp-off", "lin0.12|ivr0.3|mom0k0|tp-off",
|
||||
"rank|ivr0.3|mom0k0|tp-off", "lin0.08|ivr0|mom0k0|tp-off",
|
||||
"rank|ivr0|mom0k0|tp-off",
|
||||
"bin|ivr0.3|mom5k0.05|tp-off", "bin|ivr0.3|mom10k0.05|tp-off",
|
||||
"bin|ivr0.3|mom5k0|tp-off", "bin|ivr0.3|mom10k0|tp-off",
|
||||
"bin|ivr0.3|mom0k0|tp-skip", "bin|ivr0.3|mom0k0|tp-half"]
|
||||
print(f" {'cella':<34}{'IS Sh':>7}{'FULL':>7}{'HOLD':>7}{'DD':>6}{'worst':>8}{'att.':>6}")
|
||||
r = base
|
||||
print(f" {'BASELINE ' + BASELINE_NAME:<34}{r['is_sh']:>7.2f}{r['full_sh']:>7.2f}{r['hold_sh']:>7.2f}"
|
||||
f"{r['full_dd']*100:>5.0f}%{r['worst']*100:>+7.1f}%{r['active']*100:>5.0f}%")
|
||||
for nm in singles:
|
||||
r = results[nm]
|
||||
print(f" {nm:<34}{r['is_sh']:>7.2f}{r['full_sh']:>7.2f}{r['hold_sh']:>7.2f}"
|
||||
f"{r['full_dd']*100:>5.0f}%{r['worst']*100:>+7.1f}%{r['active']*100:>5.0f}%")
|
||||
n_beat_hold = sum(1 for r in results.values() if r["hold_sh"] > base["hold_sh"])
|
||||
print(f" [onesta'] celle che battono l'HOLD-OUT del baseline: {n_beat_hold}/{len(results)} — "
|
||||
f"NON selezionabili (sarebbe selection-on-holdout, gate 2026-06-29).")
|
||||
|
||||
cand = ranked[0]
|
||||
is_baseline_best = cand["cfg"]["name"] == BASELINE_NAME
|
||||
print(f"\n -> cella scelta IN-SAMPLE: [{cand['cfg']['name']}] IS Sh {cand['is_sh']:.2f} "
|
||||
f"(baseline IS {base['is_sh']:.2f}, Δ {cand['is_sh']-base['is_sh']:+.2f})")
|
||||
|
||||
# ---- hold-out multi-cut vs baseline ----
|
||||
print("\n (3) MULTI-CUT hold-out (Sharpe da ogni taglio a fine storia; uplift = cand - baseline):")
|
||||
mc = multicut(cand["b"], base["b"])
|
||||
pos = sum(1 for _, _, _, u in mc if u > 0)
|
||||
for cut, sc, sb, u in mc:
|
||||
print(f" cut {cut}: cand {sc:>5.2f} base {sb:>5.2f} uplift {u:>+5.2f}")
|
||||
print(f" uplift positivo in {pos}/{len(mc)} tagli (richiesti >= 4/5)")
|
||||
|
||||
# ---- deflated Sharpe (tutti i trial della griglia) ----
|
||||
all_sh = [r["full_sh"] for r in results.values()]
|
||||
dsr_c, null_max = deflated_sharpe(cand["full_sh"], all_sh, cand["b"].values, dpy=WK_PER_YEAR)
|
||||
dsr_b, _ = deflated_sharpe(base["full_sh"], all_sh, base["b"].values, dpy=WK_PER_YEAR)
|
||||
print(f"\n (4) DEFLATED SHARPE (N={len(all_sh)} trial di questa griglia; PASS >= 0.95):")
|
||||
print(f" cand DSR {dsr_c:.3f} (null-max Sh {null_max:.2f}) | baseline DSR {dsr_b:.3f}")
|
||||
print(" NB: le celle della griglia sono fortemente correlate fra loro (stesso trade sottostante)")
|
||||
print(" -> il DSR qui e' anti-conservativo sul multiple-testing; in piu' VRP01 stesso viene da")
|
||||
print(" ~20 config precedenti (options_vrp_lab/_v2). Leggere il DSR come limite SUPERIORE.")
|
||||
|
||||
# ---- per-anno cand vs base ----
|
||||
print("\n (5) PER-ANNO (ritorno composto):")
|
||||
pyc, pyb = per_year(cand["b"]), per_year(base["b"])
|
||||
print(" base: " + " ".join(f"{y}:{v*100:+.0f}%" for y, v in pyb.items()))
|
||||
print(" cand: " + " ".join(f"{y}:{v*100:+.0f}%" for y, v in pyc.items()))
|
||||
|
||||
# ---- portafoglio 4-sleeve ----
|
||||
if not skip_port:
|
||||
print("\n (6) PORTAFOGLIO 4-SLEEVE (TP01 41.25 / XS01 18.75 / VRP 15 / SKH01 25), VRP base vs variante:")
|
||||
try:
|
||||
portfolio_compare(base["b"], cand["b"], cand["cfg"]["name"])
|
||||
except Exception as e: # dati HL/5m mancanti in qualche ambiente
|
||||
print(f" [saltato: {type(e).__name__}: {e}]")
|
||||
else:
|
||||
print("\n (6) portafoglio: saltato (--skip-portfolio)")
|
||||
|
||||
# ---- verdetto ----
|
||||
print("\n" + "=" * 110)
|
||||
improves = (not is_baseline_best
|
||||
and cand["is_sh"] > base["is_sh"]
|
||||
and pos >= 4
|
||||
and (cand["hold_sh"] > base["hold_sh"])
|
||||
and dsr_c >= 0.95)
|
||||
if is_baseline_best:
|
||||
print(" VERDETTO: NON MIGLIORA — il baseline VRP01 vince gia' la selezione in-sample.")
|
||||
elif improves:
|
||||
print(f" VERDETTO: MIGLIORA (variante {cand['cfg']['name']}) — batte il baseline in-sample,")
|
||||
print(f" su hold-out multi-cut ({pos}/{len(mc)}) e DSR {dsr_c:.2f}>=0.95. Resta SLEEVE MODELLATO")
|
||||
print(" (premio DVOL ATM, book 1d, f di stress non catturato): NON deploy pieno.")
|
||||
else:
|
||||
why = []
|
||||
if cand["is_sh"] <= base["is_sh"]:
|
||||
why.append("non batte il baseline in-sample")
|
||||
if pos < 4:
|
||||
why.append(f"multi-cut {pos}/{len(mc)} (<4)")
|
||||
if cand["hold_sh"] <= base["hold_sh"]:
|
||||
why.append("hold-out non migliore")
|
||||
if dsr_c < 0.95:
|
||||
why.append(f"DSR {dsr_c:.2f}<0.95")
|
||||
print(f" VERDETTO: NON MIGLIORA — cella IS-best [{cand['cfg']['name']}] bocciata: " + "; ".join(why) + ".")
|
||||
print("=" * 110)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,439 @@
|
||||
"""r0701_xs — RESIDUAL (IDIOSYNCRATIC) MOMENTUM cross-sectional sui 19 major Hyperliquid.
|
||||
|
||||
TESI (2026-07-01). STATARB-RESID (thread 4, 2026-06-29) ha mostrato che il MOMENTUM del residuo
|
||||
ETH−β·BTC (β OLS rolling, sgn=+1: le dislocazioni CONTINUANO a 1d) passa quasi tutti i gate su
|
||||
2 gambe, fallendo SOLO il deflated-Sharpe (0.929<0.95). Angolo nuovo: lo stesso meccanismo
|
||||
CROSS-SECTIONAL sui 19 major di XS01 — per ogni alt, residuo vs β·BTC (β OLS rolling B giorni),
|
||||
momentum del residuo su lookback (blend z-score [30,90] come XS01, o singolo), rank cross-section,
|
||||
long top-k / short bottom-k, vol-target 20%. Ipotesi: la breadth (18 stream invece di 1) alza il
|
||||
DSR dove il 2-gambe falliva.
|
||||
|
||||
DISTINZIONE da quanto gia' testato:
|
||||
* IREV (xsec_v2_nonmom, idio-REVERSAL, sgn=-1): FALLITO. Qui sgn=+1 (idio-MOMENTUM).
|
||||
* IMOM (xsec_v2_nonmom): residuo vs mercato EQUAL-WEIGHT, B=60 fisso, no blend, era solo
|
||||
"riferimento momentum". Qui: fattore = BTC (come STATARB-RESID), B in griglia, blend z-score
|
||||
[30,90] + probe con gate di dispersione (parita' strutturale con XS01), selezione IN-SAMPLE.
|
||||
|
||||
IL BAR (fondamentale): una variante di XS01 e' utile SOLO se (a) SOSTITUISCE XS01 (meglio
|
||||
standalone E nel portafoglio 4-sleeve) oppure (b) AGGIUNGE come 5o sleeve (corr bassa a XS01 E
|
||||
TP01, uplift del PORTAFOGLIO). corr>0.6 a XS01 senza batterlo -> REDUNDANT/SCARTATO.
|
||||
Baseline XS01: standalone FULL Sh ~1.50 / HOLD ~1.71 / DD ~11%.
|
||||
|
||||
GATE (CLAUDE.md, metodologia obbligatoria):
|
||||
1. CAUSALE: score a close[i], peso tenuto in i+1 (engine shifta W[i-1]*dret[i]); barre vol=0
|
||||
escluse; prefix-check di causalita' sulla cella scelta.
|
||||
2. NETTO fee 0.10% RT per gamba per ribilancio + sweep {0.05, 0.10, 0.20, 0.30}% RT/gamba.
|
||||
3. Selezione cella IN-SAMPLE-ONLY (pre-2025, anti selection-on-holdout), poi hold-out bloccato.
|
||||
4. DEFLATED Sharpe su TUTTI i trial della griglia (serve >=0.95).
|
||||
5. Confronto PORTAFOGLIO: sostituzione di XS01 a parita' di peso + aggiunta 5o sleeve @10/15%
|
||||
(riusa StrategyPortfolio/active_sleeves senza modificarli) + marginal vs il BOOK a 4 sleeve.
|
||||
6. CAVEAT IMMUTABILI: storia HL ~2.5 anni; book L/S a ~2k gambe -> STAT-MODE a $600 (dichiaro
|
||||
comunque l'haircut small-cap $600/min$5).
|
||||
|
||||
uv run python scripts/research/r0701_xs_residmom.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, deflated_sharpe, portfolio, ...)
|
||||
from src.portfolio.sleeves import XS_UNIVERSE
|
||||
|
||||
DPY, TV, FEE, HOLDOUT = xv.DPY, xv.TV, xv.FEE, xv.HOLDOUT
|
||||
FACTOR = "BTC" # il fattore del residuo (come STATARB-RESID), NON tradato
|
||||
BLEND = (30, 90) # blend z-score come XS01
|
||||
|
||||
# griglia (modesta, da mandato): beta-window x lookback x k x H (gate=off)
|
||||
BETAS = (60, 90, 120)
|
||||
LOOKS = ("blend", 30, 90)
|
||||
KS = (3, 5)
|
||||
HS = (5, 10, 20)
|
||||
# probe a parita' STRUTTURALE con XS01 (blend + gate dispersione p30, H10 k5) — contano nei trial
|
||||
GATED_PROBES = [dict(B=B, L="blend", k=5, H=10, gate=30) for B in BETAS]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SCORE BUILDER — residual momentum vs beta*BTC. CAUSALE (dati <= i).
|
||||
# Ritorna score_at(i) -> (score_blend_z[A], valid[A], disp_raw_i) + warmup.
|
||||
# disp_raw_i = dispersione cross-section del momentum RESIDUO grezzo (per il gate: lo z-score
|
||||
# blended ha std ~1 per costruzione, quindi la dispersione va misurata sul grezzo).
|
||||
# ===========================================================================
|
||||
def make_residmom(PX: pd.DataFrame, B: int, L):
|
||||
lookbacks = BLEND if L == "blend" else (int(L),)
|
||||
px = PX.values
|
||||
n, A = px.shape
|
||||
fi = list(PX.columns).index(FACTOR)
|
||||
DR = PX.pct_change()
|
||||
m = DR[FACTOR]
|
||||
beta, _ = xv._rolling_beta(DR, m, B) # beta_j vs BTC su finestra B (<= i)
|
||||
SDR = {Lk: DR.rolling(Lk, min_periods=int(0.8 * Lk)).sum().values for Lk in lookbacks}
|
||||
SM = {Lk: m.rolling(Lk, min_periods=int(0.8 * Lk)).sum().values for Lk in lookbacks}
|
||||
CNT = {Lk: DR.rolling(Lk, min_periods=1).count().values for Lk in lookbacks}
|
||||
|
||||
def score_at(i):
|
||||
b = beta[i]
|
||||
valid = np.isfinite(px[i]) & np.isfinite(b)
|
||||
valid[fi] = False # BTC = fattore, fuori dal cross-section
|
||||
resids = []
|
||||
for Lk in lookbacks:
|
||||
resid = SDR[Lk][i] - b * SM[Lk][i] # momentum del residuo r_j - beta_j*r_btc
|
||||
valid = valid & np.isfinite(resid) & (CNT[Lk][i] >= 0.8 * Lk)
|
||||
resids.append(resid)
|
||||
score = np.full(A, np.nan)
|
||||
disp = np.nan
|
||||
nv = int(valid.sum())
|
||||
if nv >= 2:
|
||||
acc = np.zeros(nv)
|
||||
cnt = 0
|
||||
stds = []
|
||||
for resid in resids:
|
||||
r = resid[valid]
|
||||
sd = float(r.std())
|
||||
stds.append(sd)
|
||||
if sd > 0:
|
||||
acc += (r - r.mean()) / sd
|
||||
cnt += 1
|
||||
if cnt:
|
||||
score[valid] = acc / cnt # blend: media z-score cross-sectional
|
||||
disp = float(np.mean(stds)) # dispersione del momentum residuo GREZZO
|
||||
return score, valid, disp
|
||||
|
||||
return score_at, max(max(lookbacks), B) + 1
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# ENGINE locale (= xv.xs_engine + ritorno di W/scale + gate di dispersione opzionale).
|
||||
# L'uguaglianza con xv.xs_engine sulle celle non-gated e' VERIFICATA in main().
|
||||
# ===========================================================================
|
||||
def xs_engine_w(PX, VOL, score_at, H, k, target_vol=TV, fee=FEE, min_assets=10, warmup=0,
|
||||
disp_pct=0, disp_minhist=20):
|
||||
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)
|
||||
disp_hist = []
|
||||
for i in range(n):
|
||||
if i >= warmup and i % H == 0:
|
||||
score, valid, disp = score_at(i)
|
||||
valid = valid & np.isfinite(score) & (vol[i] > 0)
|
||||
idxv = np.where(valid)[0]
|
||||
if len(idxv) >= min_assets:
|
||||
thr = (np.percentile(disp_hist, disp_pct)
|
||||
if (disp_pct > 0 and len(disp_hist) >= disp_minhist) else -np.inf)
|
||||
if not (disp_pct > 0) or (np.isfinite(disp) and disp >= thr):
|
||||
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 # long alto residual-momentum (sgn=+1)
|
||||
w[lo] = -0.5 / kk # short basso
|
||||
else:
|
||||
w = np.zeros(A) # regime compatto -> flat (gate)
|
||||
if disp_pct > 0 and np.isfinite(disp):
|
||||
disp_hist.append(disp)
|
||||
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)
|
||||
rv = s.rolling(30, min_periods=15).std().shift(1) * np.sqrt(DPY)
|
||||
scale = np.clip(np.nan_to_num(target_vol / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0)
|
||||
turn_py = float(turn.sum() / (n / DPY)) if n else 0.0
|
||||
return pd.Series(s.values * scale, index=PX.index), turn_py, W, scale
|
||||
|
||||
|
||||
def run_cell(PX, VOL, cfg, fee=FEE):
|
||||
score_at, warm = make_residmom(PX, cfg["B"], cfg["L"])
|
||||
daily, turn, W, scale = xs_engine_w(PX, VOL, score_at, cfg["H"], cfg["k"], fee=fee,
|
||||
warmup=warm, disp_pct=cfg.get("gate", 0))
|
||||
return xv.to_daily(daily), turn, W, scale
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# CAUSALITA' (prefix-check, pattern di xv.causality_prefix_check sul nostro engine)
|
||||
# ===========================================================================
|
||||
def causality_prefix_check(PX, VOL, cfg, frac=0.85, tail=60, tol=1e-9):
|
||||
score_full, warm = make_residmom(PX, cfg["B"], cfg["L"])
|
||||
full, *_ = xs_engine_w(PX, VOL, score_full, cfg["H"], cfg["k"], warmup=warm,
|
||||
disp_pct=cfg.get("gate", 0))
|
||||
cut = int(len(PX) * frac)
|
||||
PXc, VOLc = PX.iloc[:cut], VOL.iloc[:cut]
|
||||
score_pre, warm2 = make_residmom(PXc, cfg["B"], cfg["L"])
|
||||
pre, *_ = xs_engine_w(PXc, VOLc, score_pre, cfg["H"], cfg["k"], warmup=warm2,
|
||||
disp_pct=cfg.get("gate", 0))
|
||||
lo = max(0, cut - tail)
|
||||
a, b = full.values[lo:cut], 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))
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SMALL-CAP $600 (dichiarativo: il book resta STAT-MODE comunque, come XS01).
|
||||
# Pesi EFFETTIVI = W[i] * scale[i+1] (scale[i+1] usa net fino a i via shift(1) -> noto a close i).
|
||||
# Un cambio-gamba con |dw|*capital < min_order NON si esegue. Confronto realistico vs modeled
|
||||
# sulla STESSA simulazione a pesi (coerente internamente).
|
||||
# ===========================================================================
|
||||
def smallcap_check(PX, W, scale, fee=FEE, capital=600.0, min_order=5.0):
|
||||
px = PX.values
|
||||
n, A = px.shape
|
||||
dret = np.nan_to_num(np.vstack([np.zeros(A), px[1:] / px[:-1] - 1.0]))
|
||||
sc = np.roll(scale, -1)
|
||||
sc[-1] = scale[-1]
|
||||
T = W * sc[:, None] # target effettivo deciso a close i
|
||||
|
||||
def sim(min_ord):
|
||||
held = np.zeros((n, A))
|
||||
cur = np.zeros(A)
|
||||
n_tr = 0
|
||||
for i in range(n):
|
||||
d = np.abs(T[i] - cur) * capital
|
||||
ex = d >= min_ord
|
||||
n_tr += int(ex.sum())
|
||||
cur = np.where(ex, T[i], cur)
|
||||
held[i] = cur
|
||||
pos = np.zeros((n, A))
|
||||
pos[1:] = held[:-1]
|
||||
turn = np.abs(np.diff(held, axis=0, prepend=np.zeros((1, A)))).sum(axis=1)
|
||||
net = (pos * dret).sum(axis=1) - turn * (fee / 2.0)
|
||||
r = net[np.isfinite(net)]
|
||||
sh = float(r.mean() / r.std() * np.sqrt(DPY)) if r.std() > 0 else 0.0
|
||||
return sh, n_tr, float(turn.sum() / (n / DPY))
|
||||
|
||||
sh_real, ntr_real, turn_real = sim(min_order)
|
||||
sh_mod, ntr_mod, turn_mod = sim(0.0)
|
||||
return dict(sharpe_modeled=round(sh_mod, 3), sharpe_realistic=round(sh_real, 3),
|
||||
haircut=round(sh_mod - sh_real, 3), n_executed=ntr_real, n_modeled=ntr_mod,
|
||||
turnover_real=round(turn_real, 1), turnover_modeled=round(turn_mod, 1))
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# PORTAFOGLIO — sostituzione XS01 + aggiunta 5o sleeve (pattern di xsec_v3_momstruct)
|
||||
# ===========================================================================
|
||||
_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()
|
||||
_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("R0701_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("R0701_cand", 0.0))
|
||||
|
||||
|
||||
def substitute_xs01(daily):
|
||||
base, _ = _base()
|
||||
sub = [xv.Sleeve("R0701_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))
|
||||
|
||||
|
||||
def marginal_vs_book(daily):
|
||||
"""Corr + uplift del blend 0.9*BOOK+0.1*cand vs il BOOK a 4 sleeve (full/hold + multi-cut)."""
|
||||
base, _ = _base()
|
||||
book = xv.StrategyPortfolio(base).combined_daily()
|
||||
J = pd.concat({"B": book, "C": daily}, axis=1, join="inner").dropna()
|
||||
|
||||
def _sh(s):
|
||||
r = np.asarray(s.dropna().values, float)
|
||||
return float(r.mean() / r.std() * np.sqrt(DPY)) if len(r) > 2 and r.std() > 0 else 0.0
|
||||
|
||||
def _up(sub):
|
||||
return _sh(0.9 * sub["B"] + 0.1 * sub["C"]) - _sh(sub["B"])
|
||||
|
||||
JH = J[J.index >= HOLDOUT]
|
||||
cuts = {}
|
||||
for y in sorted(set(J.index.year))[1:]:
|
||||
sub = J[J.index >= pd.Timestamp(f"{y}-01-01", tz="UTC")]
|
||||
if len(sub) >= 120:
|
||||
cuts[int(y)] = round(_up(sub), 3)
|
||||
return dict(corr_book=round(float(J["B"].corr(J["C"])), 3),
|
||||
uplift_full=round(_up(J), 3),
|
||||
uplift_hold=round(_up(JH), 3) if len(JH) > 30 else None,
|
||||
multicut=cuts)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
def per_year(daily):
|
||||
return [(int(y), round(float((1 + g).prod() - 1), 3)) for y, g in daily.groupby(daily.index.year)]
|
||||
|
||||
|
||||
def tag(cfg):
|
||||
g = f" gate{cfg['gate']}" if cfg.get("gate") else ""
|
||||
return f"B{cfg['B']} L{cfg['L']} k{cfg['k']} H{cfg['H']}{g}"
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 104)
|
||||
print(" r0701_xs — RESIDUAL MOMENTUM cross-sectional (residuo vs beta*BTC) sui 19 major HL — STAT-MODE")
|
||||
print("=" * 104)
|
||||
|
||||
PX, VOL = xv.load_matrix(XS_UNIVERSE)
|
||||
print(f" universo 19-major: {PX.shape[1]} asset, {PX.shape[0]} giorni "
|
||||
f"[{PX.index[0].date()} -> {PX.index[-1].date()}] fattore={FACTOR} (escluso dal cross-section)")
|
||||
|
||||
tp_daily = xv.tp01_sleeve().daily()
|
||||
xs_daily = xv.xsec_sleeve().daily()
|
||||
xs_f = xv.metrics(xs_daily)
|
||||
xs_h = xv.metrics(xs_daily[xs_daily.index >= HOLDOUT])
|
||||
print(f" baseline XS01 (sleeve attivo): FULL Sh {xs_f['sharpe']:.2f} DD {xs_f['maxdd']*100:.0f}%"
|
||||
f" | HOLD Sh {xs_h['sharpe']:.2f}")
|
||||
|
||||
# --- sanity: engine locale == xv.xs_engine sulle celle non-gated -----------------
|
||||
chk_cfg = dict(B=90, L=30, k=5, H=10)
|
||||
score_at, warm = make_residmom(PX, chk_cfg["B"], chk_cfg["L"])
|
||||
mine, *_ = xs_engine_w(PX, VOL, score_at, chk_cfg["H"], chk_cfg["k"], warmup=warm)
|
||||
ref, _ = xv.xs_engine(PX, VOL, lambda i: score_at(i)[:2], chk_cfg["H"], chk_cfg["k"], warmup=warm)
|
||||
dmax = float(np.nanmax(np.abs(mine.values - ref.values)))
|
||||
assert dmax < 1e-12, f"engine locale diverge da xv.xs_engine: {dmax}"
|
||||
print(f" [sanity] engine locale == xv.xs_engine (maxdiff {dmax:.1e})")
|
||||
|
||||
# --- griglia -----------------------------------------------------------------------
|
||||
grid = [dict(B=B, L=L, k=k, H=H) for B in BETAS for L in LOOKS for k in KS for H in HS]
|
||||
grid += GATED_PROBES
|
||||
rows = []
|
||||
for cfg in grid:
|
||||
daily, turn, W, scale = run_cell(PX, VOL, cfg)
|
||||
if daily.std() == 0 or len(daily) < 60:
|
||||
continue
|
||||
f, h, pct = xv.evalcfg(daily)
|
||||
ins = daily[daily.index < HOLDOUT]
|
||||
is_sh = xv.metrics(ins)["sharpe"] if len(ins) > 60 else float("nan")
|
||||
rows.append(dict(cfg=cfg, daily=daily, W=W, scale=scale, turn=turn,
|
||||
full=f["sharpe"], hold=h["sharpe"], dd=f["maxdd"], ret=f["ret"],
|
||||
pct=pct, is_sh=is_sh,
|
||||
corrXS=xv._corr(daily, xs_daily), corrTP=xv._corr(daily, tp_daily)))
|
||||
all_sr = [r["full"] for r in rows]
|
||||
print(f"\n griglia: {len(rows)} celle valide su {len(grid)} "
|
||||
f"(trial per deflated-Sharpe = {len(all_sr)}; il conteggio VERO del programma e' >>)")
|
||||
|
||||
hdr = f" {'cfg':<26}{'IS':>7}{'FULL':>7}{'HOLD':>7}{'DD%':>6}{'anni+':>7}{'corrXS':>8}{'corrTP':>8}{'turn/y':>8}"
|
||||
valid = [r for r in rows if np.isfinite(r["is_sh"])]
|
||||
print("\n TOP-5 per Sharpe IN-SAMPLE (pre-2025) — la selezione ONESTA:")
|
||||
print(hdr)
|
||||
for r in sorted(valid, key=lambda r: -r["is_sh"])[:5]:
|
||||
print(f" {tag(r['cfg']):<26}{r['is_sh']:>7.2f}{r['full']:>7.2f}{r['hold']:>7.2f}"
|
||||
f"{r['dd']*100:>6.0f}{r['pct']*100:>6.0f}%{r['corrXS']:>+8.2f}{r['corrTP']:>+8.2f}{r['turn']:>8.0f}")
|
||||
print("\n TOP-5 per HOLD (solo trasparenza — selezionare qui = selection-on-holdout):")
|
||||
print(hdr)
|
||||
for r in sorted(rows, key=lambda r: -r["hold"])[:5]:
|
||||
iss = f"{r['is_sh']:.2f}" if np.isfinite(r["is_sh"]) else "n/a"
|
||||
print(f" {tag(r['cfg']):<26}{iss:>7}{r['full']:>7.2f}{r['hold']:>7.2f}"
|
||||
f"{r['dd']*100:>6.0f}{r['pct']*100:>6.0f}%{r['corrXS']:>+8.2f}{r['corrTP']:>+8.2f}{r['turn']:>8.0f}")
|
||||
|
||||
if not valid:
|
||||
print("\n >>> nessuna cella con in-sample valutabile. SCARTATO.")
|
||||
return
|
||||
|
||||
pick = max(valid, key=lambda r: r["is_sh"])
|
||||
daily = pick["daily"]
|
||||
print("\n" + "=" * 104)
|
||||
print(f" CELLA SCELTA (in-sample-only): {tag(pick['cfg'])}")
|
||||
print("=" * 104)
|
||||
print(f" IS Sh {pick['is_sh']:.2f} | FULL {pick['full']:.2f} | HOLD {pick['hold']:.2f}"
|
||||
f" | DD {pick['dd']*100:.0f}% | ret {pick['ret']*100:+.0f}% | anni+ {pick['pct']*100:.0f}%"
|
||||
f" | turnover/y {pick['turn']:.0f}")
|
||||
print(f" corr vs XS01 {pick['corrXS']:+.2f} | corr vs TP01 {pick['corrTP']:+.2f}"
|
||||
f" | per-anno {per_year(daily)}")
|
||||
|
||||
caus = causality_prefix_check(PX, VOL, pick["cfg"])
|
||||
print(f" CAUSALITA' (prefix-check): ok={caus['ok']} max_tail_diff={caus['max_tail_diff']:.2e}")
|
||||
|
||||
dsr, sr0 = xv.deflated_sharpe(pick["full"], all_sr, daily)
|
||||
print(f" DEFLATED Sharpe (N={len(all_sr)} trial di QUESTA griglia): {dsr:.3f}"
|
||||
f" | soglia null-max annualizz. {sr0:.2f} (serve >=0.95)")
|
||||
|
||||
print(" fee sweep (RT per gamba):", end=" ")
|
||||
for fee in (0.0005, 0.001, 0.002, 0.003):
|
||||
d_f, *_ = run_cell(PX, VOL, pick["cfg"], fee=fee)
|
||||
ff = xv.metrics(d_f)
|
||||
hh = xv.metrics(d_f[d_f.index >= HOLDOUT])
|
||||
print(f"{fee*100:.2f}%: F{ff['sharpe']:+.2f}/H{hh['sharpe']:+.2f}", end=" ")
|
||||
print()
|
||||
|
||||
sc = smallcap_check(PX, pick["W"], pick["scale"])
|
||||
print(f" SMALL-CAP $600/min$5 (dichiarativo, resta STAT-MODE): modeled Sh {sc['sharpe_modeled']:.2f}"
|
||||
f" -> realistic {sc['sharpe_realistic']:.2f} (haircut {sc['haircut']:+.2f});"
|
||||
f" fill eseguiti {sc['n_executed']}/{sc['n_modeled']}"
|
||||
f" turn/y {sc['turnover_real']:.0f} vs {sc['turnover_modeled']:.0f}")
|
||||
|
||||
# --- confronto PORTAFOGLIO -----------------------------------------------------------
|
||||
print("\n PORTAFOGLIO (TP01+XS01+VRP01+SKH01, pesi canonici):")
|
||||
_, (bf, bh) = _base()
|
||||
print(f" BASE 4-sleeve FULL Sh {bf['sharpe']:.2f} DD {bf['maxdd']*100:.1f}%"
|
||||
f" | HOLD Sh {bh['sharpe']:.2f} DD {bh['maxdd']*100:.1f}%")
|
||||
sf, sh_ = substitute_xs01(daily)
|
||||
sub_full_d, sub_hold_d = sf["sharpe"] - bf["sharpe"], sh_["sharpe"] - bh["sharpe"]
|
||||
print(f" SOSTITUZIONE XS01 -> cand FULL Sh {sf['sharpe']:.2f} ({sub_full_d:+.2f}) DD {sf['maxdd']*100:.1f}%"
|
||||
f" | HOLD Sh {sh_['sharpe']:.2f} ({sub_hold_d:+.2f}) DD {sh_['maxdd']*100:.1f}%")
|
||||
up_best = (-9.0, -9.0)
|
||||
for fr in (0.10, 0.15):
|
||||
cf, ch, wgt = add_uplift(daily, fr)
|
||||
d_f, d_h = cf["sharpe"] - bf["sharpe"], ch["sharpe"] - bh["sharpe"]
|
||||
if d_h > up_best[1]:
|
||||
up_best = (d_f, d_h)
|
||||
print(f" AGGIUNTA 5o sleeve @{wgt*100:>4.1f}% FULL Sh {cf['sharpe']:.2f} ({d_f:+.2f}) DD {cf['maxdd']*100:.1f}%"
|
||||
f" | HOLD Sh {ch['sharpe']:.2f} ({d_h:+.2f}) DD {ch['maxdd']*100:.1f}%")
|
||||
mb = marginal_vs_book(daily)
|
||||
print(f" MARGINAL vs BOOK: corr {mb['corr_book']:+.2f} | uplift@10% full {mb['uplift_full']:+.3f}"
|
||||
f" hold {mb['uplift_hold']:+.3f} | multi-cut {mb['multicut']}")
|
||||
|
||||
# --- VERDETTO -------------------------------------------------------------------------
|
||||
print("\n" + "=" * 104)
|
||||
beats_xs_standalone = (pick["full"] > xs_f["sharpe"] and pick["hold"] > xs_h["sharpe"])
|
||||
dominates = sub_full_d > 0.02 and sub_hold_d > 0.05 and beats_xs_standalone
|
||||
diversifies = (abs(pick["corrXS"]) < 0.6 and abs(pick["corrTP"]) < 0.5
|
||||
and up_best[1] > 0.05 and mb["uplift_hold"] is not None and mb["uplift_hold"] > 0
|
||||
and all(u > 0 for u in mb["multicut"].values()))
|
||||
dsr_ok = np.isfinite(dsr) and dsr >= 0.95
|
||||
if not caus["ok"]:
|
||||
verdict, why = "SCARTATO", "prefix-check di causalita' fallito"
|
||||
elif pick["full"] <= 0.3 or pick["hold"] <= 0:
|
||||
verdict, why = "SCARTATO", f"standalone debole (FULL {pick['full']:+.2f}, HOLD {pick['hold']:+.2f})"
|
||||
elif abs(pick["corrXS"]) > 0.6 and not dominates:
|
||||
verdict, why = "REDUNDANT", f"corrXS {pick['corrXS']:+.2f}>0.6 e non batte XS01 (sub HOLD {sub_hold_d:+.2f})"
|
||||
elif dominates and dsr_ok:
|
||||
verdict, why = "CANDIDATO-SLEEVE (sostituto)", "batte XS01 standalone E nel book, DSR>=0.95"
|
||||
elif diversifies and dsr_ok:
|
||||
verdict, why = "CANDIDATO-SLEEVE (5o)", "scorrelato, uplift book persistente, DSR>=0.95"
|
||||
elif dominates or diversifies:
|
||||
verdict, why = "LEAD-forward", f"profilo utile ma DSR {dsr:.2f}<0.95 (storia ~2.5a, multiple-testing)"
|
||||
else:
|
||||
verdict, why = "SCARTATO", (f"ne' sostituto (sub HOLD {sub_hold_d:+.2f}) ne' additivo"
|
||||
f" (uplift HOLD {up_best[1]:+.2f}, corrXS {pick['corrXS']:+.2f})")
|
||||
print(f" VERDETTO: {verdict} — {why}")
|
||||
print(" CAVEAT immutabili: storia HL ~2.5 anni; in-sample = solo 2024 (selezione su finestra corta);")
|
||||
print(" book L/S multi-gamba -> STAT-MODE a $600 (come XS01), mai deploy a questo capitale.")
|
||||
print("=" * 104)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,295 @@
|
||||
"""r0701_xs_seasonal — STAGIONALITÀ CROSS-SECTIONAL sull'universo Hyperliquid (2026-07-01).
|
||||
|
||||
DOMANDA: esistono effetti calendario RELATIVI tra i 19 alt major HL (weekday tilt,
|
||||
turn-of-month, pattern weekend->lunedì nel cross-section)? Essendo long/short
|
||||
market-neutral (demeaned cross-section), il "buy&hold travestito" — che ha ucciso la
|
||||
seasonality trackF su BTC/ETH — è strutturalmente escluso.
|
||||
|
||||
METODO (ordine obbligatorio, dal mandato):
|
||||
1. TEST STATISTICO PRIMA DELLA STRATEGIA — persistenza split-half: per ogni giorno
|
||||
della settimana, il tilt cross-sectional per-asset (media del ritorno relativo
|
||||
demeaned in quel giorno, al netto del tilt incondizionato dell'asset) della PRIMA
|
||||
metà del campione correla (Spearman rank) con quello della SECONDA metà?
|
||||
Null: permutazione delle etichette-giorno (2000 draw) entro ciascuna metà →
|
||||
distribuzione del max-su-7 rank-corr. Se il max reale non batte il 95° pctl del
|
||||
null → SCARTATO senza backtest. Idem per weekend-bucket e turn-of-month.
|
||||
La permutazione controlla automaticamente il confound "alpha persistente
|
||||
dell'asset su entrambe le metà" (momentum), perché anche le etichette permutate
|
||||
lo mostrerebbero.
|
||||
2. (solo se persiste) strategia L/S market-neutral vol-target, fee 0.10% RT,
|
||||
breakeven fee, selezione IN-SAMPLE (pre-2025), hold-out, deflated_sharpe.
|
||||
3. day_boundary_robust OBBLIGATORIO per ogni effetto calendario — NB: i dati HL
|
||||
locali sono SOLO 1d → il confine giorno NON è ri-tagliabile localmente sui 19
|
||||
alt. Qualunque lead weekday su HL 1d resta NON-VERIFICABILE al boundary-shift
|
||||
finché non esistono barre orarie HL: il verdetto massimo possibile qui è
|
||||
LEAD-forward *condizionato*, mai sleeve. (Su BTC/ETH 1h il test esiste in
|
||||
altlib, ma il cross-section a 2 asset non riproduce l'effetto a 19.)
|
||||
|
||||
DATI: data/raw/hl_*_1d.parquet — 19 major XS01, 913 giorni (2024-01-01 → 2026-07-01),
|
||||
0 barre vol=0 (verificato). LIMITE DICHIARATO: ~2.5 anni → ~130 osservazioni per
|
||||
weekday, ~65 per metà → alto rischio rumore; soglie severe (p<0.05 sul max-statistic
|
||||
permutato, non per-weekday).
|
||||
|
||||
Esecuzione: cd /opt/docker/PythagorasGoal && uv run python scripts/research/r0701_xs_seasonal.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
_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 (HOLDOUT, deflated_sharpe — riuso convenzioni)
|
||||
|
||||
RNG = np.random.default_rng(20260701)
|
||||
N_PERM = 2000
|
||||
UNIVERSE = ["BTC", "ETH", "SOL", "BNB", "XRP", "DOGE", "AVAX", "LINK", "LTC", "ADA",
|
||||
"ARB", "OP", "SUI", "APT", "INJ", "TIA", "SEI", "NEAR", "AAVE"]
|
||||
FEE_SIDE = 0.0005 # 0.10% RT
|
||||
HOLDOUT = al.HOLDOUT # 2025-01-01 UTC (convenzione di progetto)
|
||||
WD_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- dati
|
||||
def load_relative_returns():
|
||||
"""Matrice (date × asset) dei ritorni GIORNALIERI RELATIVI (demeaned cross-section).
|
||||
Esclude barre vol=0 (→ NaN). x[t,a] = r[t,a] − mean_a r[t,a] → market-neutral."""
|
||||
cols = {}
|
||||
for s in UNIVERSE:
|
||||
d = pd.read_parquet(_ROOT / "data" / "raw" / f"hl_{s.lower()}_1d.parquet")
|
||||
idx = pd.to_datetime(d["timestamp"], unit="ms", utc=True)
|
||||
c = pd.Series(d["close"].values.astype(float), index=idx)
|
||||
c[d["volume"].values <= 0] = np.nan # guardrail backfill sintetico
|
||||
cols[s] = c
|
||||
C = pd.concat(cols, axis=1).sort_index()
|
||||
R = C.pct_change()
|
||||
R = R.iloc[1:] # prima riga NaN
|
||||
X = R.sub(R.mean(axis=1), axis=0) # demean cross-section per data
|
||||
return R, X
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- statistica
|
||||
def _rank(v):
|
||||
return pd.Series(v).rank().values
|
||||
|
||||
|
||||
def spearman(a, b):
|
||||
ra, rb = _rank(a), _rank(b)
|
||||
if np.std(ra) == 0 or np.std(rb) == 0:
|
||||
return 0.0
|
||||
return float(np.corrcoef(ra, rb)[0, 1])
|
||||
|
||||
|
||||
def bucket_tilts(X: pd.DataFrame, labels: np.ndarray, n_buckets: int) -> np.ndarray:
|
||||
"""tilt[b, a] = media di x nei giorni con label==b, MENO il tilt incondizionato
|
||||
dell'asset (isola l'effetto calendario dal drift relativo generico)."""
|
||||
base = np.nanmean(X.values, axis=0)
|
||||
out = np.full((n_buckets, X.shape[1]), np.nan)
|
||||
for b in range(n_buckets):
|
||||
m = labels == b
|
||||
if m.sum() >= 10:
|
||||
out[b] = np.nanmean(X.values[m], axis=0) - base
|
||||
return out
|
||||
|
||||
|
||||
def split_half_persistence(X: pd.DataFrame, labels: np.ndarray, n_buckets: int,
|
||||
n_perm: int = N_PERM):
|
||||
"""Rank-corr H1 vs H2 dei tilt per bucket + null permutando le etichette entro
|
||||
ciascuna metà. Ritorna (rho per bucket, max reale, p-value del max, null 95° pctl)."""
|
||||
half = len(X) // 2
|
||||
X1, X2 = X.iloc[:half], X.iloc[half:]
|
||||
l1, l2 = labels[:half], labels[half:]
|
||||
|
||||
def rhos(la, lb):
|
||||
t1, t2 = bucket_tilts(X1, la, n_buckets), bucket_tilts(X2, lb, n_buckets)
|
||||
return np.array([spearman(t1[b], t2[b])
|
||||
if np.isfinite(t1[b]).all() and np.isfinite(t2[b]).all() else np.nan
|
||||
for b in range(n_buckets)])
|
||||
|
||||
real = rhos(l1, l2)
|
||||
real_max = float(np.nanmax(real))
|
||||
null_max = np.empty(n_perm)
|
||||
for i in range(n_perm):
|
||||
null_max[i] = np.nanmax(rhos(RNG.permutation(l1), RNG.permutation(l2)))
|
||||
pval = float(np.mean(null_max >= real_max))
|
||||
return real, real_max, pval, float(np.percentile(null_max, 95))
|
||||
|
||||
|
||||
def weekend_monday_ic(X: pd.DataFrame):
|
||||
"""Pattern pre/post weekend: il ritorno relativo cumulato Sab+Dom predice
|
||||
(cross-sectionalmente) il ritorno relativo del lunedì? IC = Spearman giornaliero;
|
||||
riporta mean IC e t-stat su ciascuna metà (persistenza del segno)."""
|
||||
wd = X.index.dayofweek.values
|
||||
ics, dates = [], []
|
||||
for i in np.where(wd == 0)[0]: # lunedì
|
||||
if i < 2 or wd[i - 1] != 6 or wd[i - 2] != 5:
|
||||
continue
|
||||
wkend = np.nansum(X.values[i - 2:i], axis=0) # Sab+Dom relativo
|
||||
mon = X.values[i]
|
||||
ok = np.isfinite(wkend) & np.isfinite(mon)
|
||||
if ok.sum() >= 10:
|
||||
ics.append(spearman(wkend[ok], mon[ok])); dates.append(X.index[i])
|
||||
s = pd.Series(ics, index=pd.DatetimeIndex(dates))
|
||||
half = len(s) // 2
|
||||
out = {}
|
||||
for name, seg in (("H1", s.iloc[:half]), ("H2", s.iloc[half:]), ("FULL", s)):
|
||||
t = float(seg.mean() / seg.std() * np.sqrt(len(seg))) if len(seg) > 3 and seg.std() > 0 else 0.0
|
||||
out[name] = dict(mean_ic=round(float(seg.mean()), 4), t=round(t, 2), n=len(seg))
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- strategia (solo se persiste)
|
||||
def sharpe(r):
|
||||
r = np.asarray(pd.Series(r).dropna().values, float)
|
||||
return float(np.mean(r) / np.std(r) * np.sqrt(365.25)) if len(r) > 2 and np.std(r) > 0 else 0.0
|
||||
|
||||
|
||||
def weekday_ls(R: pd.DataFrame, X: pd.DataFrame, k: int = 3, est_win: int = 0,
|
||||
min_obs: int = 20, target_vol: float = 0.20):
|
||||
"""L/S market-neutral: al close di t (dati ≤ t) stima il tilt per-asset del weekday
|
||||
di DOMANI (expanding se est_win=0, altrimenti rolling est_win gg) e va long top-k /
|
||||
short bottom-k per il giorno t+1. Fee su |Δw|. Ritorna (net Series, gross Series,
|
||||
turnover medio/anno, breakeven fee %RT)."""
|
||||
wd = X.index.dayofweek.values
|
||||
xv = X.values
|
||||
n, A = xv.shape
|
||||
W = np.zeros((n, A))
|
||||
# tilt causale: per ogni weekday d, media (expanding o rolling) dei soli giorni con wd==d fino a t
|
||||
sums = np.zeros((7, A)); cnts = np.zeros((7, A))
|
||||
hist: list[list[np.ndarray]] = [[] for _ in range(7)] # per rolling
|
||||
for t in range(n - 1):
|
||||
row = np.nan_to_num(xv[t], nan=0.0)
|
||||
fin = np.isfinite(xv[t]).astype(float)
|
||||
d = wd[t]
|
||||
sums[d] += row; cnts[d] += fin
|
||||
if est_win > 0:
|
||||
hist[d].append(np.where(fin > 0, row, np.nan))
|
||||
if len(hist[d]) > est_win:
|
||||
hist[d].pop(0)
|
||||
dn = wd[t + 1] # weekday di domani: noto (calendario)
|
||||
if est_win > 0:
|
||||
hh = np.array(hist[dn]) if hist[dn] else np.empty((0, A))
|
||||
cnt = np.isfinite(hh).sum(axis=0) if len(hh) else np.zeros(A)
|
||||
tilt = np.where(cnt >= min_obs, np.nanmean(hh, axis=0) if len(hh) else 0.0, np.nan)
|
||||
else:
|
||||
tilt = np.where(cnts[dn] >= min_obs, sums[dn] / np.maximum(cnts[dn], 1), np.nan)
|
||||
ok = np.isfinite(tilt)
|
||||
if ok.sum() >= 2 * k:
|
||||
order = np.argsort(np.where(ok, tilt, -np.inf))
|
||||
w = np.zeros(A)
|
||||
w[order[-k:]] = 0.5 / k # long tilt positivi
|
||||
lo = np.argsort(np.where(ok, tilt, np.inf))[:k]
|
||||
w[lo] = -0.5 / k # short tilt negativi
|
||||
W[t] = w
|
||||
dret = np.nan_to_num(R.values, nan=0.0)
|
||||
gross = np.zeros(n); gross[1:] = np.sum(W[:-1] * dret[1:], axis=1)
|
||||
turn = np.abs(np.diff(W, axis=0, prepend=np.zeros((1, A)))).sum(axis=1)
|
||||
net = gross - FEE_SIDE * turn
|
||||
g, tn = pd.Series(gross, index=X.index), pd.Series(net, index=X.index)
|
||||
rv = tn.rolling(30, min_periods=15).std().shift(1) * np.sqrt(365.25)
|
||||
scale = np.clip(np.nan_to_num(target_vol / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0)
|
||||
net_vt = pd.Series(tn.values * scale, index=X.index)
|
||||
mean_turn = float(turn.mean())
|
||||
be_rt = 2 * float(gross.mean() / mean_turn) * 100 if mean_turn > 0 else np.nan # %RT a Sharpe 0
|
||||
turn_yr = round(mean_turn * 365.25, 1)
|
||||
return net_vt, g, turn_yr, be_rt
|
||||
|
||||
|
||||
def run_strategy_branch(R, X):
|
||||
"""Selezione IN-SAMPLE (pre-2025) su griglia piccola, hold-out, DSR su tutti i trial."""
|
||||
grid = [dict(k=k, est_win=w) for k in (3, 5) for w in (0, 180)]
|
||||
rows, all_full = [], []
|
||||
for g in grid:
|
||||
net, _, turn_yr, be = weekday_ls(R, X, **g)
|
||||
ins, hold = net[net.index < HOLDOUT], net[net.index >= HOLDOUT]
|
||||
rows.append(dict(params=g, ins=round(sharpe(ins), 2), hold=round(sharpe(hold), 2),
|
||||
full=round(sharpe(net), 2), turn_yr=turn_yr,
|
||||
be_rt=round(be, 3) if np.isfinite(be) else None, net=net))
|
||||
all_full.append(sharpe(net))
|
||||
chosen = max(rows, key=lambda r: r["ins"]) # selezione SOLO in-sample
|
||||
dsr, sr0 = al.deflated_sharpe(chosen["full"], all_full, chosen["net"].dropna().values)
|
||||
return rows, chosen, dsr, sr0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- main
|
||||
def main():
|
||||
R, X = load_relative_returns()
|
||||
print(f"Universo: {len(UNIVERSE)} major HL | {X.index[0].date()} -> {X.index[-1].date()} "
|
||||
f"({len(X)} giorni, ~{len(X) / 365.25:.1f} anni) | barre vol=0 escluse: "
|
||||
f"{int((~np.isfinite(X.values)).sum())} celle NaN")
|
||||
print(f"LIMITE: ~{len(X) // 7 // 2} osservazioni per weekday per metà campione — "
|
||||
f"soglia severa: max-statistic permutato, p<0.05\n")
|
||||
|
||||
wd = X.index.dayofweek.values
|
||||
print("=" * 78)
|
||||
print("STEP 1 — PERSISTENZA SPLIT-HALF (test statistico PRIMA della strategia)")
|
||||
print("=" * 78)
|
||||
|
||||
# --- (a) weekday (7 bucket)
|
||||
rho, mx, p, null95 = split_half_persistence(X, wd, 7)
|
||||
print("\n[A] WEEKDAY TILT (tilt weekday-specifico, al netto del tilt incondizionato)")
|
||||
for d in range(7):
|
||||
print(f" {WD_NAMES[d]}: rank-corr H1 vs H2 = {rho[d]:+.3f}")
|
||||
print(f" max reale = {mx:+.3f} | null 95° pctl (perm max-su-7) = {null95:+.3f} | p = {p:.3f}")
|
||||
pass_wd = p < 0.05
|
||||
|
||||
# --- (b) weekend vs feriali (2 bucket)
|
||||
wk_lab = (wd >= 5).astype(int)
|
||||
rho_w, mx_w, p_w, null95_w = split_half_persistence(X, wk_lab, 2)
|
||||
print(f"\n[B] WEEKEND-vs-FERIALI: rho weekday={rho_w[0]:+.3f} weekend={rho_w[1]:+.3f} "
|
||||
f"| max={mx_w:+.3f} null95={null95_w:+.3f} p={p_w:.3f}")
|
||||
pass_we = p_w < 0.05
|
||||
|
||||
# --- (c) turn-of-month (ultimi 2 + primi 2 gg del mese vs resto)
|
||||
day = X.index.day.values
|
||||
dim = X.index.days_in_month.values
|
||||
tom_lab = ((day <= 2) | (day >= dim - 1)).astype(int)
|
||||
rho_t, mx_t, p_t, null95_t = split_half_persistence(X, tom_lab, 2)
|
||||
print(f"[C] TURN-OF-MONTH (±2gg): rho non-TOM={rho_t[0]:+.3f} TOM={rho_t[1]:+.3f} "
|
||||
f"| max={mx_t:+.3f} null95={null95_t:+.3f} p={p_t:.3f}")
|
||||
pass_tom = p_t < 0.05
|
||||
|
||||
# --- (d) pattern weekend->lunedì (IC cross-serial)
|
||||
ic = weekend_monday_ic(X)
|
||||
print(f"[D] WEEKEND->LUNEDÌ IC (Spearman x_weekend vs x_lunedì): "
|
||||
f"H1 {ic['H1']['mean_ic']:+.3f} (t={ic['H1']['t']}) | "
|
||||
f"H2 {ic['H2']['mean_ic']:+.3f} (t={ic['H2']['t']}) | "
|
||||
f"FULL {ic['FULL']['mean_ic']:+.3f} (t={ic['FULL']['t']}, n={ic['FULL']['n']})")
|
||||
pass_ic = (abs(ic["FULL"]["t"]) > 2.5
|
||||
and np.sign(ic["H1"]["mean_ic"]) == np.sign(ic["H2"]["mean_ic"])
|
||||
and abs(ic["H1"]["t"]) > 1.5 and abs(ic["H2"]["t"]) > 1.5)
|
||||
print(f" persistenza segno + |t|>2.5 FULL + |t|>1.5 su entrambe le metà: {pass_ic}")
|
||||
|
||||
any_pass = pass_wd or pass_we or pass_tom or pass_ic
|
||||
print("\n" + "=" * 78)
|
||||
if not any_pass:
|
||||
print("VERDETTO: SCARTATO — nessuna persistenza cross-sectional calendario.")
|
||||
print("Nessun backtest eseguito (regola: test statistico prima della strategia).")
|
||||
print("NB: il day_boundary_robust resta comunque NON eseguibile sui 19 alt (dati")
|
||||
print("HL solo 1d) → anche un pass qui sarebbe stato al massimo LEAD condizionato.")
|
||||
print("=" * 78)
|
||||
return
|
||||
|
||||
# ---------------- branch strategia (si arriva qui solo con persistenza reale)
|
||||
print("STEP 2 — persistenza rilevata: strategia L/S weekday-tilt (selezione IN-SAMPLE)")
|
||||
print("=" * 78)
|
||||
rows, chosen, dsr, sr0 = run_strategy_branch(R, X)
|
||||
for r in rows:
|
||||
print(f" k={r['params']['k']} est={'exp' if r['params']['est_win'] == 0 else r['params']['est_win']}: "
|
||||
f"IS {r['ins']:+.2f} | HOLD {r['hold']:+.2f} | FULL {r['full']:+.2f} | "
|
||||
f"turn/yr {r['turn_yr']} | breakeven fee {r['be_rt']}%RT")
|
||||
print(f"\n CELLA IN-SAMPLE: {chosen['params']} -> FULL {chosen['full']:+.2f} "
|
||||
f"HOLD {chosen['hold']:+.2f} | DSR={dsr:.3f} (null max {sr0:.2f}) "
|
||||
f"{'PASS' if dsr >= 0.95 else 'FAIL'}")
|
||||
print("\n ⚠ day_boundary_robust NON eseguibile su HL 1d (serve orario) → qualunque")
|
||||
print(" esito qui è al massimo LEAD-forward CONDIZIONATO, mai sleeve.")
|
||||
print("=" * 78)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,662 @@
|
||||
"""r0702_alb_claims — Audit STATISTICO delle claim "strategia Albimarini" (video didattici).
|
||||
|
||||
CLAIM DICHIARATE (input, dal video):
|
||||
- Struttura: double diagonal DEEP OTM su SPX/SPY (short strangle ~6DTE + long strangle
|
||||
a scadenza successiva, strike piu' lontani), durata trade 3-6 giorni di calendario.
|
||||
- Track record: n=28 trade, win-rate 82% (23W/5L), P&L medio +$113/trade,
|
||||
perdite totali $765 (loss medio $153), profit factor 5.16, capitale ~$10k,
|
||||
estrapolazione "420% annuo", sizing a compounding 1->2->3->4 contratti.
|
||||
Derivati: totale +$3.164; gross win $3.929 (avg win $171); PF = 3929/765 = 5.14 ok.
|
||||
|
||||
OBIETTIVO: QUANTIFICARE la critica (win-rate strutturale, coda non campionata, PF non
|
||||
significativo su n=28), non riformularla. 6 test, ognuno con numeri.
|
||||
|
||||
DATI: SOLO locali gia' certificati/presenti nel progetto:
|
||||
- data/raw/eq_spy_1d.parquet (SPY daily 1996-2026, fetch IB del filone GTAA)
|
||||
- BTC/ETH 1d via altlib (Deribit mainnet certificato)
|
||||
Nessuna rete. Nessun file scritto fuori dallo scratchpad. Nessun DatetimeIndex.view.
|
||||
|
||||
Convenzioni oneste:
|
||||
- 6 giorni di calendario ~= 4 trading day (h=4 primario; h=2..5 come robustezza).
|
||||
- Le probabilita' empiriche usano finestre rolling OVERLAPPING (stima della prob.
|
||||
per-finestra); i test di significativita' usano le 28 finestre NON sovrapposte
|
||||
del track record e cicli non sovrapposti nel replay.
|
||||
- Il replay del Test 3 e' CLAIM-ANCHORED: usa l'economia per-trade dichiarata dal
|
||||
video (massimo beneficio al claim); l'unico parametro nostro e' la severita'
|
||||
della coda, calibrata in due modi (cap BS della diagonale / EV=0).
|
||||
- BS senza skew nei Test 4-5: sottostima il premio degli OTM (a favore del claim).
|
||||
|
||||
Uso: uv run python scripts/research/r0702_alb_claims.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from scipy.stats import norm, t as student_t
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "research" / "alt"))
|
||||
import altlib as al # noqa: E402
|
||||
|
||||
RNG = np.random.default_rng(20260702)
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
# ---- claim dichiarate ------------------------------------------------------
|
||||
N_TRADES = 28
|
||||
WINRATE_CLAIM = 23 / 28 # 0.821
|
||||
AVG_PNL = 113.0 # $/trade
|
||||
TOT_LOSS = 765.0 # $ totali (5 loss -> avg $153)
|
||||
AVG_WIN = (N_TRADES * AVG_PNL + TOT_LOSS) / 23 # $171
|
||||
AVG_LOSS = TOT_LOSS / 5 # $153
|
||||
PF_CLAIM = (23 * AVG_WIN) / TOT_LOSS # 5.14
|
||||
CAPITAL = 10_000.0
|
||||
H_TD = 4 # 6 giorni calendario ~ 4 trading day
|
||||
|
||||
OUT: dict = {}
|
||||
|
||||
|
||||
def p(msg=""):
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# BS helpers (no dividendi/tassi — orizzonte 6-13 giorni)
|
||||
# ===========================================================================
|
||||
def bs_put(S, K, T, sigma):
|
||||
T = max(T, 1e-9)
|
||||
d1 = (np.log(S / K) + 0.5 * sigma**2 * T) / (sigma * np.sqrt(T))
|
||||
d2 = d1 - sigma * np.sqrt(T)
|
||||
return K * norm.cdf(-d2) - S * norm.cdf(-d1)
|
||||
|
||||
|
||||
def bs_call(S, K, T, sigma):
|
||||
T = max(T, 1e-9)
|
||||
d1 = (np.log(S / K) + 0.5 * sigma**2 * T) / (sigma * np.sqrt(T))
|
||||
d2 = d1 - sigma * np.sqrt(T)
|
||||
return S * norm.cdf(d1) - K * norm.cdf(d2)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# DATA
|
||||
# ===========================================================================
|
||||
def load_spy() -> pd.DataFrame:
|
||||
df = pd.read_parquet(ROOT / "data" / "raw" / "eq_spy_1d.parquet")
|
||||
df = df.sort_values("timestamp").reset_index(drop=True)
|
||||
df["datetime"] = pd.to_datetime(df["timestamp"].astype("int64"), unit="ms", utc=True)
|
||||
return df
|
||||
|
||||
|
||||
def hmoves(close: np.ndarray, h: int) -> np.ndarray:
|
||||
return close[h:] / close[:-h] - 1.0
|
||||
|
||||
|
||||
def cal_moves(df: pd.DataFrame, days: int = 6) -> np.ndarray:
|
||||
"""Move su ESATTAMENTE `days` giorni di calendario (ultimo close <= t+days).
|
||||
Epoca ms esplicita (mai .view su DatetimeIndex)."""
|
||||
ts = df["timestamp"].to_numpy(dtype="int64") # ms
|
||||
c = df["close"].to_numpy(dtype=float)
|
||||
tgt = ts + days * 86_400_000
|
||||
j = np.searchsorted(ts, tgt, side="right") - 1 # ultimo close <= t+days
|
||||
ok = j > np.arange(len(ts))
|
||||
return c[j[ok]] / c[ok] - 1.0
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TEST 1 — WIN-RATE STRUTTURALE
|
||||
# ===========================================================================
|
||||
def test1():
|
||||
p("=" * 88)
|
||||
p("TEST 1 — WIN-RATE STRUTTURALE: P(move >9% in ~6 giorni) e strike impliciti dall'82%")
|
||||
p("=" * 88)
|
||||
spy = load_spy()
|
||||
c, lo, hi = spy["close"].values, spy["low"].values, spy["high"].values
|
||||
r1 = np.diff(np.log(c))
|
||||
vol_ann = r1.std() * np.sqrt(252)
|
||||
|
||||
res = {}
|
||||
for h in (2, 3, 4, 5):
|
||||
m = hmoves(c, h)
|
||||
res[h] = dict(p_abs9=float(np.mean(np.abs(m) > 0.09)),
|
||||
p_dn9=float(np.mean(m < -0.09)),
|
||||
sigma=float(m.std()),
|
||||
p_2sig=float(np.mean(np.abs(m) > 2 * m.std())))
|
||||
m6c = cal_moves(spy, 6)
|
||||
m4 = hmoves(c, H_TD)
|
||||
|
||||
# touch (management intra-finestra): min low / max high nei prossimi h giorni
|
||||
n = len(c) - H_TD
|
||||
lo_w = np.array([lo[i + 1:i + 1 + H_TD].min() for i in range(n)])
|
||||
hi_w = np.array([hi[i + 1:i + 1 + H_TD].max() for i in range(n)])
|
||||
p_touch9 = float(np.mean((lo_w < c[:n] * 0.91) | (hi_w > c[:n] * 1.09)))
|
||||
|
||||
# analitico: normale a vol 16% e alla vol campione; t-Student fittata sui move 4td
|
||||
sig6_16 = 0.16 * np.sqrt(6 / 365)
|
||||
p_norm16 = 2 * (1 - norm.cdf(0.09 / sig6_16))
|
||||
sig4 = res[4]["sigma"]
|
||||
p_norm_emp = 2 * (1 - norm.cdf(0.09 / sig4))
|
||||
tdf, tloc, tscale = student_t.fit(m4)
|
||||
p_t = float(2 * student_t.sf((0.09 - tloc) / tscale, tdf))
|
||||
|
||||
# strike implicito dall'82% di win (quantile 82% di |move|) e win-rate a 9% OTM
|
||||
q82_4 = float(np.quantile(np.abs(m4), WINRATE_CLAIM))
|
||||
q82_6c = float(np.quantile(np.abs(m6c), WINRATE_CLAIM))
|
||||
win_at_9 = 1 - res[4]["p_abs9"]
|
||||
|
||||
p(f"SPY 1996-2026 ({len(spy)} barre, vol ann {vol_ann:.1%})")
|
||||
for h in (2, 3, 4, 5):
|
||||
r = res[h]
|
||||
p(f" h={h}td: P(|m|>9%)={r['p_abs9']:.4%} P(m<-9%)={r['p_dn9']:.4%} "
|
||||
f"sigma={r['sigma']:.2%} P(|m|>2sig)={r['p_2sig']:.2%}")
|
||||
p(f" 6 giorni CALENDARIO esatti: P(|m|>9%)={np.mean(np.abs(m6c) > 0.09):.4%} "
|
||||
f"sigma={m6c.std():.2%}")
|
||||
p(f" TOUCH intra-finestra (h=4td, strike a +-9%): P={p_touch9:.4%}")
|
||||
p(f" Analitico: normale vol16% -> {p_norm16:.2e} | normale vol camp. -> {p_norm_emp:.2e} | "
|
||||
f"t-Student fit (df={tdf:.2f}) -> {p_t:.4%} | empirico {res[4]['p_abs9']:.4%}")
|
||||
p(f" -> fat-tail factor empirico/normale = {res[4]['p_abs9'] / p_norm_emp:,.0f}x")
|
||||
p()
|
||||
p(f" INCONSISTENZA QUANTIFICATA delle claim:")
|
||||
p(f" (a) se gli strike sono DAVVERO ~9% OTM: win-rate strutturale a scadenza = "
|
||||
f"{win_at_9:.2%} (>=99%), non 82% -> le 5 perdite non sono breach, sono gestione;")
|
||||
p(f" (b) se il win-rate 82% e' letterale (breach a scadenza): gli strike distano "
|
||||
f"q82(|m4|) = {q82_4:.2%} (6gg cal: {q82_6c:.2%}) ~= 1.2 sigma, NON 'deep OTM'.")
|
||||
p(f" In entrambi i casi il win-rate e' il quantile della distribuzione (il delta "
|
||||
f"venduto), non skill: qualunque venditore agli stessi strike ottiene lo stesso numero.")
|
||||
|
||||
# BTC/ETH dai NOSTRI dati certificati
|
||||
crypto = {}
|
||||
for a in ("BTC", "ETH"):
|
||||
d = al.get(a, "1d")
|
||||
cc = d["close"].values
|
||||
m6 = hmoves(cc, 6)
|
||||
crypto[a] = dict(p_abs9=float(np.mean(np.abs(m6) > 0.09)),
|
||||
sigma=float(m6.std()),
|
||||
p_2sig=float(np.mean(np.abs(m6) > 2 * m6.std())),
|
||||
q82=float(np.quantile(np.abs(m6), WINRATE_CLAIM)),
|
||||
span=f"{d['datetime'].iloc[0].date()}->{d['datetime'].iloc[-1].date()}",
|
||||
last=float(cc[-1]))
|
||||
p(f" {a} (certificato, {crypto[a]['span']}): P(|m6d|>9%)={crypto[a]['p_abs9']:.1%} "
|
||||
f"sigma6d={crypto[a]['sigma']:.1%} P(>2sig)={crypto[a]['p_2sig']:.1%} "
|
||||
f"strike per win82% = {crypto[a]['q82']:.1%} OTM")
|
||||
p(f" -> la stessa struttura trasposta su crypto: strike a 9% OTM = win-rate "
|
||||
f"{1 - crypto['BTC']['p_abs9']:.0%} (BTC) / {1 - crypto['ETH']['p_abs9']:.0%} (ETH), "
|
||||
f"cioe' il 9% che su SPY e' 'deep' su crypto e' ~1 sigma.")
|
||||
|
||||
OUT["test1"] = dict(spy=res, spy_6cal_p9=float(np.mean(np.abs(m6c) > 0.09)),
|
||||
p_touch9=p_touch9, p_norm16=float(p_norm16),
|
||||
p_norm_emp=float(p_norm_emp), p_t=p_t, t_df=float(tdf),
|
||||
q82_4td=q82_4, q82_6cal=q82_6c, win_at_9otm=float(win_at_9),
|
||||
crypto=crypto)
|
||||
return spy, m4, q82_4
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TEST 2 — SIGNIFICATIVITA' DEL PF 5.16 SU n=28 (null EV=0)
|
||||
# ===========================================================================
|
||||
def test2(spy: pd.DataFrame, q82: float):
|
||||
p("\n" + "=" * 88)
|
||||
p("TEST 2 — PF 5.16 su n=28: distribuzione sotto il null 'vendita premio a EV=0 netto'")
|
||||
p("=" * 88)
|
||||
c = spy["close"].values
|
||||
m4 = hmoves(c, H_TD)
|
||||
NSIM = 200_000
|
||||
W = 126 # 6 mesi di trading day
|
||||
|
||||
def branch(name: str, thr: float) -> dict:
|
||||
"""Null EV=0: win +$171 (p=0.821), small loss -$153, tail loss -L con
|
||||
p_tail = P(|m4|>thr) empirica; L calibrata perche' EV=0 netto."""
|
||||
p_loss = 1 - WINRATE_CLAIM
|
||||
p_tail = float(np.mean(np.abs(m4) > thr))
|
||||
p_small = max(p_loss - p_tail, 0.0)
|
||||
L_tail = (WINRATE_CLAIM * AVG_WIN - p_small * AVG_LOSS) / p_tail
|
||||
wins = RNG.normal(AVG_WIN, 60, (NSIM, N_TRADES)).clip(20, None)
|
||||
smalls = RNG.normal(AVG_LOSS, 60, (NSIM, N_TRADES)).clip(30, None)
|
||||
u = RNG.random((NSIM, N_TRADES))
|
||||
is_win = u < min(WINRATE_CLAIM, 1 - p_tail)
|
||||
is_tail = u > 1 - p_tail
|
||||
pnl = np.where(is_win, wins, np.where(is_tail, -L_tail, -smalls))
|
||||
gp = np.where(pnl > 0, pnl, 0).sum(1)
|
||||
gl = -np.where(pnl < 0, pnl, 0).sum(1)
|
||||
pf = gp / np.maximum(gl, 1e-9)
|
||||
wr = is_win.mean(1)
|
||||
p_joint = float(np.mean((pf >= PF_CLAIM) & (wr >= WINRATE_CLAIM - 1e-9)))
|
||||
p_notail = float(np.mean(~is_tail.any(1)))
|
||||
tail_day = np.abs(m4) > thr
|
||||
frac_clean = float(np.mean([not tail_day[i:i + W].any()
|
||||
for i in range(0, len(tail_day) - W)]))
|
||||
r = dict(name=name, thr=thr, p_tail=p_tail, p_small=p_small,
|
||||
L_tail=float(L_tail), ev_check=float(pnl.mean()),
|
||||
pf_median=float(np.median(pf)),
|
||||
p_pf_ge=float(np.mean(pf >= PF_CLAIM)), p_joint=p_joint,
|
||||
p_no_tail_28=p_notail,
|
||||
p_no_tail_analytic=float((1 - p_tail) ** N_TRADES),
|
||||
frac_6m_clean=frac_clean)
|
||||
p(f" Branch {name}:")
|
||||
p(f" tail = |m4| > {thr:.2%}: P(tail/trade)={p_tail:.2%} -> L_tail per EV=0 = "
|
||||
f"${L_tail:,.0f} ({L_tail / CAPITAL:.0%} del capitale per unita' di size); "
|
||||
f"EV MC {pnl.mean():+.1f}$/trade")
|
||||
p(f" PF su 28 trade: mediana {r['pf_median']:.2f} | P(PF>=5.16)={r['p_pf_ge']:.1%} | "
|
||||
f"P(PF>=5.16 E win>=82%)={p_joint:.1%}")
|
||||
p(f" P(zero tail in 28 trade)={p_notail:.1%} (analitico {r['p_no_tail_analytic']:.1%}); "
|
||||
f"quota storica finestre 6 mesi SENZA tail = {frac_clean:.1%}")
|
||||
return r
|
||||
|
||||
p(f"Null 'venditore di premio a EV=0 netto' (tanti +$171, -$153 gestite, code rare):")
|
||||
b_obs = branch(f"OBS (strike {q82:.2%} OTM, breach oltre il long +2%)", q82 + 0.02)
|
||||
b_deep = branch("DEEP (strike 9% OTM come dichiarato)", 0.09)
|
||||
p(f" -> Il track record dichiarato (PF 5.16, win 82%, zero code) e' un esito da "
|
||||
f"P={b_obs['p_joint']:.0%} (lettura OBS) a P={b_deep['p_joint']:.0%} (lettura DEEP) "
|
||||
f"sotto ZERO skill: tra 'comune' e 'esito mediano'.")
|
||||
p(f" Il PF senza coda campionata misura solo 23/5 * avg_win/avg_smallloss = "
|
||||
f"{(23 * AVG_WIN) / (5 * AVG_LOSS):.2f}: e' un rapporto STRUTTURALE, non una statistica "
|
||||
f"di edge (n=28 non tocca mai la coda che paga tutto).")
|
||||
|
||||
OUT["test2"] = dict(obs=b_obs, deep=b_deep)
|
||||
return b_obs["L_tail"]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TEST 3 — IL "420% ANNUO" ATTRAVERSO UNA CODA REALE (replay claim-anchored)
|
||||
# ===========================================================================
|
||||
def _replay(dates, moves, q82: float, width: float, L_tail: float,
|
||||
win_amt: float, small_amt: float,
|
||||
sizing: str = "video", start: float = CAPITAL, label: str = ""):
|
||||
"""Replay CLAIM-ANCHORED sulla sequenza REALE di move (cicli non sovrapposti).
|
||||
PER CONTRATTO:
|
||||
|m| <= q82 -> +win_amt (win, calibrato sui numeri del video)
|
||||
q82 < |m| <= q82+w -> -small_amt (perdita gestita, come le 5 del video)
|
||||
|m| > q82+w -> -L_tail (breach oltre il long: coda)
|
||||
sizing: 'video' = +1 contratto ogni +$1000 di profitto (1->2->3->4 come
|
||||
dichiarato), cap margine equity/$2500; 'prop' = leva costante iniziale
|
||||
(n = equity//10k); 'fixed' = 1 contratto. Ruin se equity <= 0."""
|
||||
eq = start
|
||||
path, pnl_hist, npos = [], [], []
|
||||
ruined = None
|
||||
for i, m in enumerate(moves):
|
||||
if abs(m) <= q82:
|
||||
out = win_amt
|
||||
elif abs(m) <= q82 + width:
|
||||
out = -small_amt
|
||||
else:
|
||||
out = -L_tail
|
||||
if sizing == "video":
|
||||
n = int(np.clip(1 + max(eq - start, 0) // 1000, 1, max(1, eq // 2500)))
|
||||
elif sizing == "prop":
|
||||
n = int(max(1, eq // 10_000))
|
||||
else:
|
||||
n = 1
|
||||
pnl = n * out
|
||||
eq += pnl
|
||||
pnl_hist.append(pnl)
|
||||
npos.append(n)
|
||||
path.append(eq)
|
||||
if eq <= 0:
|
||||
ruined = dates[i]
|
||||
break
|
||||
path = np.asarray(path, dtype=float)
|
||||
peak = np.maximum.accumulate(np.maximum(path, 1e-9))
|
||||
maxdd = float((path / peak - 1.0).min()) if len(path) else 0.0
|
||||
last_i = len(path) - 1
|
||||
yrs = max(1e-9, (pd.Timestamp(dates[last_i]) - pd.Timestamp(dates[0])).days / 365.25)
|
||||
cagr = float((max(path[-1], 1e-9) / start) ** (1 / yrs) - 1) if len(path) else 0.0
|
||||
wi = int(np.argmin(pnl_hist)) if pnl_hist else 0
|
||||
return dict(label=label, final=float(path[-1]) if len(path) else start,
|
||||
cagr=cagr, maxdd=maxdd,
|
||||
ruined=str(pd.Timestamp(ruined).date()) if ruined is not None else None,
|
||||
worst_cycle=float(min(pnl_hist)) if pnl_hist else 0.0,
|
||||
worst_date=str(pd.Timestamp(dates[wi]).date()) if pnl_hist else None,
|
||||
n_at_worst=int(npos[wi]) if npos else 0,
|
||||
n_cycles=len(pnl_hist),
|
||||
n_tails=int(np.sum(np.abs(np.asarray(moves[:len(pnl_hist)])) > q82 + width)),
|
||||
avg_pnl_cycle=float(np.mean(pnl_hist)) if pnl_hist else 0.0,
|
||||
winrate=float(np.mean(np.asarray(pnl_hist) > 0)) if pnl_hist else 0.0,
|
||||
years=float(yrs))
|
||||
|
||||
|
||||
def _cycles(df: pd.DataFrame, h: int):
|
||||
c = df["close"].values
|
||||
idx = np.arange(0, len(c) - h, h)
|
||||
moves = c[idx + h] / c[idx] - 1.0
|
||||
dates = df["datetime"].values[idx + h]
|
||||
return dates, moves
|
||||
|
||||
|
||||
def _calib_win_per_contract() -> float:
|
||||
"""Calibra il win PER CONTRATTO cosi' che 28 cicli TUTTI win con il sizing
|
||||
video (+1 contratto/$1000, 1->2->3->4) riproducano il totale dichiarato
|
||||
(+$3.164): i +$171/+$113 del video sono medie a livello CONTO su 1-4
|
||||
contratti, non per contratto."""
|
||||
lo, hi = 10.0, 171.0
|
||||
for _ in range(60):
|
||||
w = 0.5 * (lo + hi)
|
||||
eq, tot = CAPITAL, 0.0
|
||||
for _k in range(N_TRADES):
|
||||
n = int(np.clip(1 + max(eq - CAPITAL, 0) // 1000, 1, max(1, eq // 2500)))
|
||||
eq += n * w
|
||||
tot += n * w
|
||||
if tot > N_TRADES * AVG_PNL:
|
||||
hi = w
|
||||
else:
|
||||
lo = w
|
||||
return round(0.5 * (lo + hi), 1)
|
||||
|
||||
|
||||
def test3(spy: pd.DataFrame, q82: float):
|
||||
p("\n" + "=" * 88)
|
||||
p("TEST 3 — '420% ANNUO': la stessa strategia attraverso le code reali, con compounding")
|
||||
p("=" * 88)
|
||||
# Replay CLAIM-ANCHORED: economia PER CONTRATTO calibrata sui numeri del video,
|
||||
# guidata dalla sequenza REALE dei move ~6gg. Unico parametro nostro: la
|
||||
# severita' della coda per contratto, boxata da due letture convergenti:
|
||||
# cap fisico = width 2% x notional $60k = $1.200 (cap della diagonale, Test 4)
|
||||
# EV=0 = la severita' che rende il gioco fair alle p empiriche
|
||||
w_c = _calib_win_per_contract()
|
||||
s_c = round(w_c * AVG_LOSS / AVG_WIN, 1)
|
||||
m4 = hmoves(spy["close"].values, H_TD)
|
||||
p_tail = float(np.mean(np.abs(m4) > q82 + 0.02))
|
||||
p_small = max(1 - WINRATE_CLAIM - p_tail, 0.0)
|
||||
L_ev0 = round((WINRATE_CLAIM * w_c - p_small * s_c) / p_tail, 0)
|
||||
L_bs = 1200.0
|
||||
p(f"Replay claim-anchored (cicli 4td non sovrapposti, strike q82={q82:.2%}, long +2%):")
|
||||
p(f"PER CONTRATTO: win +${w_c} (|m|<=q82), gestita -${s_c} (<=q82+2%), coda -$L")
|
||||
p(f"(|m|>q82+2%). Calibrazione: 28 cicli senza coda col sizing video 1->4 = +$3.164")
|
||||
p(f"(riproduce il video PER COSTRUZIONE: win 82%, PF 5.1). Severita' coda boxata:")
|
||||
p(f"cap fisico width2%x$60k = ${L_bs:,.0f} | EV=0 = ${L_ev0:,.0f} (convergenti).")
|
||||
p(f"Sizing 'video' = +1 contratto/$1000 profitto (cap eq/$2500); 'prop' = eq//10k.")
|
||||
windows = [("FULL 1996-2026", None, None),
|
||||
("2019-2026 (era book)", "2019-01-01", None),
|
||||
("H2-2023 (finestra tipo video)", "2023-07-01", "2023-12-31"),
|
||||
("2020 (COVID)", "2020-01-01", "2020-12-31"),
|
||||
("2022 (bear)", "2022-01-01", "2022-12-31"),
|
||||
("2008 (GFC)", "2008-01-01", "2008-12-31")]
|
||||
rows = []
|
||||
for sizing, L, tag in (("video", L_bs, "video, cap fisico $1.200"),
|
||||
("video", L_ev0, f"video, EV=0 ${L_ev0:,.0f}"),
|
||||
("prop", L_bs, "leva costante, cap fisico")):
|
||||
p(f"\n -- sizing {tag} --")
|
||||
for label, a, b in windows:
|
||||
d = spy
|
||||
if a:
|
||||
d = d[d["datetime"] >= pd.Timestamp(a, tz="UTC")]
|
||||
if b:
|
||||
d = d[d["datetime"] <= pd.Timestamp(b, tz="UTC")]
|
||||
dates, moves = _cycles(d.reset_index(drop=True), H_TD)
|
||||
out = _replay(dates, moves, q82, 0.02, L, w_c, s_c, sizing=sizing,
|
||||
label=f"{label} [{tag}]")
|
||||
rows.append(out)
|
||||
ann = f"{out['cagr']:+.0%}" if not out["ruined"] else "RUIN"
|
||||
p(f" {label:30s} finale ${out['final']:>8,.0f} CAGR {ann:>7s} "
|
||||
f"maxDD {out['maxdd']:>5.0%} win {out['winrate']:.0%} "
|
||||
f"code {out['n_tails']:>2d} worst ${out['worst_cycle']:>7,.0f} "
|
||||
f"({out['worst_date']}, {out['n_at_worst']}c)"
|
||||
+ (f" ** ROVINA {out['ruined']} **" if out["ruined"] else ""))
|
||||
# controllo: size FISSA 1 contratto, e scenario 'premio generoso' (+20% sui win)
|
||||
dates, moves = _cycles(spy, H_TD)
|
||||
fx = _replay(dates, moves, q82, 0.02, L_ev0, w_c, s_c, sizing="fixed",
|
||||
label="FULL fixed-1c EV=0")
|
||||
gen_fx = _replay(dates, moves, q82, 0.02, L_ev0, w_c * 1.2, s_c, sizing="fixed",
|
||||
label="FULL fixed-1c premio+20%")
|
||||
gen = _replay(dates, moves, q82, 0.02, L_ev0, w_c * 1.2, s_c, sizing="video",
|
||||
label="FULL video premio+20%")
|
||||
p(f"\n Controlli (FULL 1996-2026, coda EV=0):")
|
||||
p(f" - size FISSA 1 contratto: finale ${fx['final']:>8,.0f} CAGR {fx['cagr']:+.1%} "
|
||||
f"maxDD {fx['maxdd']:.0%}" + (f" ROVINA {fx['ruined']}" if fx['ruined'] else ""))
|
||||
p(f" - fixed-1c, premio +20%: finale ${gen_fx['final']:>8,.0f} CAGR "
|
||||
f"{gen_fx['cagr']:+.1%} maxDD {gen_fx['maxdd']:.0%}"
|
||||
+ (f" ROVINA {gen_fx['ruined']}" if gen_fx['ruined'] else ""))
|
||||
p(f" - sizing video, premio +20%: finale ${gen['final']:>8,.0f} CAGR "
|
||||
f"{gen['cagr']:+.1%} maxDD {gen['maxdd']:.0%}"
|
||||
+ (f" ROVINA {gen['ruined']}" if gen['ruined'] else ""))
|
||||
p(f" -> anche regalando un VRP persistente del +20%, il CAGR onesto multi-anno e'")
|
||||
p(f" a una cifra, NON 420%; e il sizing del video lo converte comunque in rovina")
|
||||
p(f" (scala dopo i win -> la coda colpisce sempre vicino alla size massima).")
|
||||
rows += [fx, gen_fx, gen]
|
||||
|
||||
# BTC/ETH dai NOSTRI dati: stesso 82% strutturale (q82 proprio), width vol-scalata,
|
||||
# severita' EV=0 ricalibrata sulla p_tail dell'asset.
|
||||
p()
|
||||
for aname in ("BTC", "ETH"):
|
||||
d = al.get(aname, "1d")
|
||||
m6 = hmoves(d["close"].values, 6)
|
||||
q82a = float(np.quantile(np.abs(m6), WINRATE_CLAIM))
|
||||
wa = float(0.9 * m6.std())
|
||||
p_tail_a = float(np.mean(np.abs(m6) > q82a + wa))
|
||||
La = round((WINRATE_CLAIM * w_c
|
||||
- max(1 - WINRATE_CLAIM - p_tail_a, 0) * s_c) / p_tail_a, 0)
|
||||
dates_a, moves_a = _cycles(d, 6)
|
||||
out = _replay(dates_a, moves_a, q82a, wa, La, w_c, s_c, sizing="video",
|
||||
label=f"{aname} strike {q82a:.0%} width {wa:.0%} EV=0")
|
||||
rows.append(out)
|
||||
ann = f"{out['cagr']:+.0%}" if not out["ruined"] else "RUIN"
|
||||
p(f" {out['label']:38s} P(coda)={p_tail_a:.1%} L=${La:,.0f} -> finale "
|
||||
f"${out['final']:>8,.0f} CAGR {ann:>6s} maxDD {out['maxdd']:>5.0%} "
|
||||
f"code {out['n_tails']}"
|
||||
+ (f" ** ROVINA {out['ruined']} **" if out["ruined"] else ""))
|
||||
p()
|
||||
p(" -> Il '420%' e' l'annualizzazione della finestra senza coda con il sizing gia'")
|
||||
p(" scalato. Sulla storia intera, con la STESSA economia dichiarata dal video e")
|
||||
p(" una coda fair, il compounding del video incontra la coda alla size massima.")
|
||||
OUT["test3"] = dict(win_per_contract=w_c, small_per_contract=s_c,
|
||||
L_bs=L_bs, L_ev0=L_ev0, p_tail=p_tail, rows=rows)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TEST 4 — QUANTO COPRE LA DIAGONALE IL GIORNO DEL CRASH
|
||||
# ===========================================================================
|
||||
def test4(q82: float):
|
||||
p("\n" + "=" * 88)
|
||||
p("TEST 4 — DIAGONALE COME PROTEZIONE: loss_con_diagonale / loss_naked (BS, gap+vol spike)")
|
||||
p("=" * 88)
|
||||
S0 = 100.0
|
||||
iv0 = 0.16
|
||||
|
||||
def scenario(d_short, width, gap, iv1):
|
||||
"""Ritorna (loss_naked, loss_diag, ratio) in frazione di S0 (positivi=perdita)."""
|
||||
Kp_s = S0 * (1 - d_short)
|
||||
Kp_l = S0 * (1 - d_short - width)
|
||||
v_s0 = bs_put(S0, Kp_s, 6 / 365, iv0)
|
||||
v_l0 = bs_put(S0, Kp_l, 13 / 365, iv0)
|
||||
S1 = S0 * (1 + gap)
|
||||
v_s1 = bs_put(S1, Kp_s, 5 / 365, iv1)
|
||||
v_l1 = bs_put(S1, Kp_l, 12 / 365, iv1)
|
||||
loss_naked = float(v_s1 - v_s0) # mark contro lo short
|
||||
loss_diag = float((v_s1 - v_s0) - (v_l1 - v_l0)) # long compensa
|
||||
ratio = loss_diag / loss_naked if loss_naked > 1e-9 else np.nan
|
||||
return loss_naked, loss_diag, ratio
|
||||
|
||||
rows = []
|
||||
p(f"Struttura put-side branch OBS: short K={100 * (1 - q82):.1f} T=6g, long T=13g piu' OTM.")
|
||||
p(f"Scenario: gap overnight (resta 1g di vita in meno), IV 16% -> IV_crash. Perdite % notional.")
|
||||
p(f" {'gap':>5s} {'IV->':>5s} {'width':>6s} | {'naked':>7s} {'diag':>7s} {'ratio':>6s} "
|
||||
f"{'equity hit @6x':>14s}")
|
||||
for width in (0.02, 0.03):
|
||||
for gap in (-0.05, -0.10, -0.15):
|
||||
for iv1 in (0.16, 0.40, 0.50, 0.70):
|
||||
ln, ld, ratio = scenario(q82, width, gap, iv1)
|
||||
rows.append(dict(d=q82, width=width, gap=gap, iv1=iv1,
|
||||
naked=ln, diag=ld, ratio=ratio))
|
||||
p(f" {gap:>5.0%} {iv1:>5.0%} {width:>6.0%} | {ln / S0:>7.2%} "
|
||||
f"{ld / S0:>7.2%} {ratio:>6.0%} {6 * ld / S0:>13.1%}")
|
||||
# branch DEEP (9% OTM come dichiarato), width 3%
|
||||
deep = []
|
||||
for gap in (-0.10, -0.15):
|
||||
for iv1 in (0.40, 0.50, 0.70):
|
||||
ln, ld, ratio = scenario(0.09, 0.03, gap, iv1)
|
||||
deep.append(dict(d=0.09, width=0.03, gap=gap, iv1=iv1,
|
||||
naked=ln, diag=ld, ratio=ratio))
|
||||
p()
|
||||
p(" Branch DEEP (short 9% OTM, long 12% OTM, width 3%):")
|
||||
for r in deep:
|
||||
p(f" {r['gap']:>5.0%} IV->{r['iv1']:.0%} | naked {r['naked'] / S0:>6.2%} "
|
||||
f"diag {r['diag'] / S0:>6.2%} ratio {r['ratio']:>4.0%} "
|
||||
f"equity hit @6x {6 * r['diag'] / S0:>6.1%}")
|
||||
r10 = [r for r in rows if r["gap"] == -0.10 and r["iv1"] == 0.50]
|
||||
r10_novol = [r for r in rows if r["gap"] == -0.10 and r["iv1"] == 0.16
|
||||
and r["width"] == 0.02][0]
|
||||
p()
|
||||
p(f" -> La diagonale NON e' un hedge pieno e NON e' gratis: a gap -10%/IV 50 lascia")
|
||||
p(f" passare il {r10[0]['ratio']:.0%} (width 2%) / {r10[1]['ratio']:.0%} (width 3%) "
|
||||
f"della perdita naked; SENZA vol spike (IV ferma) il {r10_novol['ratio']:.0%}.")
|
||||
p(f" A 6x notional un -10% costa {6 * r10[0]['diag'] / S0:.0%}-"
|
||||
f"{6 * r10_novol['diag'] / S0:.0%} di equity PER UNITA' (x4 unita' post-scaling = "
|
||||
f"{24 * r10[0]['diag'] / S0:.0%}-{24 * r10_novol['diag'] / S0:.0%}).")
|
||||
p(f" La protezione dipende dal vol-spike che gonfia il long (vega del T+7g): e'")
|
||||
p(f" un cap alla ROVINA, non alla perdita — e il costo del cap (long ricomprato")
|
||||
p(f" ogni ciclo) e' il motivo per cui l'EV netto resta ~0 (Test 2/3).")
|
||||
OUT["test4"] = dict(obs=rows, deep=deep)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TEST 5 — ESEGUIBILITA' PER NOI (Deribit BTC/ETH, $600)
|
||||
# ===========================================================================
|
||||
def test5():
|
||||
p("\n" + "=" * 88)
|
||||
p("TEST 5 — ESEGUIBILITA' su Deribit BTC/ETH al NOSTRO capitale ($600)")
|
||||
p("=" * 88)
|
||||
res = {}
|
||||
for a, minsz in (("BTC", 0.1), ("ETH", 0.1)):
|
||||
d = al.get(a, "1d")
|
||||
S = float(d["close"].values[-1])
|
||||
m6 = hmoves(d["close"].values, 6)
|
||||
q82a = float(np.quantile(np.abs(m6), WINRATE_CLAIM))
|
||||
leg_notional = minsz * S
|
||||
sig = 0.55 if a == "BTC" else 0.70
|
||||
w = float(0.9 * m6.std()) # width vol-scalata (come il 2% su SPY)
|
||||
short0 = bs_put(S, S * (1 - q82a), 6 / 365, sig) + bs_call(S, S * (1 + q82a), 6 / 365, sig)
|
||||
long0 = (bs_put(S, S * (1 - q82a - w), 13 / 365, sig)
|
||||
+ bs_call(S, S * (1 + q82a + w), 13 / 365, sig))
|
||||
long7 = (bs_put(S, S * (1 - q82a - w), 7 / 365, sig)
|
||||
+ bs_call(S, S * (1 + q82a + w), 7 / 365, sig))
|
||||
# raccolto theta atteso per ciclo a mercato FERMO (spread 5%/gamba)
|
||||
harvest = (0.95 * short0 - 1.05 * long0 + 0.95 * long7) / S * leg_notional
|
||||
# fee Deribit opzioni: min(0.0003*S, 12.5% premio) per gamba; 8 esecuzioni/ciclo
|
||||
avg_leg = (short0 / 2 + long0 / 2) / 2 / S * leg_notional
|
||||
fee = 8 * min(0.0003 * S * minsz, 0.125 * max(avg_leg, 1e-9))
|
||||
# margine short (standard margin, senza netting sulle diagonali):
|
||||
# ~max(0.15-OTM, 0.10)*S*size + mark, x2 gambe short
|
||||
margin = 2 * (max(0.15 - q82a, 0.10) * S * minsz + short0 / 2 * minsz)
|
||||
# tail netta: full-breach del cap (gap = -(q82+w), IV x1.5), lato put
|
||||
gap_full = -(q82a + w)
|
||||
Sc = S * (1 + gap_full)
|
||||
vs = bs_put(Sc, S * (1 - q82a), 1 / 365, 1.5 * sig)
|
||||
vl = bs_put(Sc, S * (1 - q82a - w), 8 / 365, 1.5 * sig)
|
||||
vs0 = bs_put(S, S * (1 - q82a), 6 / 365, sig)
|
||||
vl0 = bs_put(S, S * (1 - q82a - w), 13 / 365, sig)
|
||||
tail = float(((vs - vs0) - (vl - vl0)) / S * leg_notional)
|
||||
net_cycle = harvest - fee
|
||||
fee_pct = f"{fee / harvest:.0%} del raccolto" if harvest > 0 else "raccolto <=0!"
|
||||
res[a] = dict(S=S, q82=q82a, width=w, leg_notional=leg_notional,
|
||||
harvest_cycle=float(harvest), fee_rt=float(fee),
|
||||
net_cycle=float(net_cycle), margin=float(margin), tail_net=tail,
|
||||
gap_full=float(gap_full))
|
||||
p(f" {a} (close {S:,.0f}, strike {q82a:.0%} OTM per win 82%, width {w:.0%}): "
|
||||
f"min {minsz}/gamba -> {leg_notional:,.0f}$/gamba, 4 gambe = "
|
||||
f"{4 * leg_notional:,.0f}$ notional")
|
||||
p(f" raccolto theta/ciclo (mercato fermo, netto spread 5%) ~${harvest:.2f}, "
|
||||
f"fee Deribit 8 gambe ~${fee:.2f} ({fee_pct}) -> NETTO ${net_cycle:+.2f}/ciclo;")
|
||||
p(f" margine short ~${margin:,.0f}; tail netta (full-breach {gap_full:.0%} 6d, "
|
||||
f"IV x1.5) ~${tail:,.0f}")
|
||||
p()
|
||||
b, e = res["BTC"], res["ETH"]
|
||||
p(f" Fee strutturale: le fee Deribit sono PROPORZIONALI alla size -> il rapporto")
|
||||
p(f" fee/raccolto ({b['fee_rt'] / max(b['harvest_cycle'], 1e-9):.0%} BTC, "
|
||||
f"{e['fee_rt'] / max(e['harvest_cycle'], 1e-9):.0%} ETH) NON migliora scalando:")
|
||||
p(f" a questi strike la DOUBLE DIAGONAL e' fee-negativa su Deribit A QUALSIASI size")
|
||||
p(f" (netto {b['net_cycle']:+.2f}$/ciclo BTC, {e['net_cycle']:+.2f}$/ciclo ETH per "
|
||||
f"unita' minima). Il capitale sposta il muro di margine, non questo.")
|
||||
p()
|
||||
# ---- soglie di capitale: $600 oggi, 2k/3.5k/5k prospettici --------------
|
||||
# Granularita' sensata = >=3 posizioni concorrenti + scalabile 1->2 contratti
|
||||
# (>=6 slot di margine), budget margine 25% equity, rischio/posizione <=5%.
|
||||
# ETH CREDIT SPREAD: numeri del filone gemello ALB-A (margine ~$107/spread =
|
||||
# max-loss defined-risk, credito ~$2/spread) + fee 2 gambe.
|
||||
sp_margin, sp_credit = 107.0, 2.0
|
||||
sp_fee = 2 * min(0.0003 * e["S"] * 0.1, 0.125 * sp_credit) # entry 2 gambe, 0.1 ETH
|
||||
p(f" SOGLIE DI CAPITALE (budget margine 25%, rischio/pos <=5%, granularita' sensata")
|
||||
p(f" = 3 posizioni concorrenti scalabili 1->2 = 6 slot):")
|
||||
p(f" {'conto':>7s} | {'DD BTC (m.$' + format(b['margin'], ',.0f') + ')':>22s} | "
|
||||
f"{'DD ETH min (m.$' + format(e['margin'], ',.0f') + ')':>22s} | "
|
||||
f"{'spread ETH (m.$107)':>20s}")
|
||||
tiers = {}
|
||||
for cap in (600.0, 2000.0, 3500.0, 5000.0):
|
||||
mbud = 0.25 * cap
|
||||
n_btc = int(mbud // b["margin"])
|
||||
n_eth = int(mbud // e["margin"])
|
||||
n_sp = int(mbud // sp_margin)
|
||||
risk_ok_sp = sp_margin <= 0.05 * cap # max-loss spread <= 5% equity
|
||||
risk_ok_eth = e["tail_net"] <= 0.05 * cap
|
||||
tiers[int(cap)] = dict(margin_budget=mbud, n_btc=n_btc, n_eth_dd=n_eth,
|
||||
n_spread=n_sp, spread_risk_ok=bool(risk_ok_sp),
|
||||
eth_dd_risk_ok=bool(risk_ok_eth),
|
||||
spread_pct_per_pos=sp_margin / cap,
|
||||
spread_income_yr=n_sp * (sp_credit - sp_fee) * 52)
|
||||
p(f" {cap:>7,.0f} | {n_btc:>4d} unita' ({b['margin'] / cap:>4.0%}/u) | "
|
||||
f"{n_eth:>4d} unita' ({e['margin'] / cap:>4.0%}/u) | "
|
||||
f"{n_sp:>3d} spread ({sp_margin / cap:>4.0%}/u, risk5% "
|
||||
f"{'OK' if risk_ok_sp else 'NO'})")
|
||||
p()
|
||||
p(f" Lettura (con upgrade conto 2-5k):")
|
||||
p(f" - DOUBLE DIAGONAL BTC: margine ${b['margin']:,.0f}/unita' -> 1a unita' dentro il")
|
||||
p(f" budget 25% solo da ~${4 * b['margin']:,.0f}; 'granularita' sensata' (6 slot) da "
|
||||
f"~${24 * b['margin'] / 1000:,.0f}k. Nemmeno 5k basta: resta il muro gamma-scalp")
|
||||
p(f" (diario 2026-06-26: 'opzione BTC min $5.968 >> $600'), che si sposta a ~10-30k.")
|
||||
p(f" - DOUBLE DIAGONAL ETH min 0.1: gia' a 2k il margine non e' il vincolo "
|
||||
f"({int(0.25 * 2000 / e['margin'])} unita' nel budget), ma resta FEE-NEGATIVA "
|
||||
f"({e['net_cycle']:+.2f}$/ciclo): eseguibile != conveniente, a ogni soglia.")
|
||||
p(f" - CREDIT SPREAD ETH (ALB-A): il PRIMO oggetto con soglia reale. A $600: 1 spread")
|
||||
p(f" = {sp_margin / 600:.0%} del conto (max-loss {sp_margin / 600:.0%} > 5% -> NO). "
|
||||
f"A 2k: {int(0.25 * 2000 // sp_margin)} spread nel budget, max-loss {sp_margin / 2000:.1%}")
|
||||
p(f" ~ok ma niente scalata 1->2 su 3 posizioni; a 3.5k: {int(0.25 * 3500 // sp_margin)} slot "
|
||||
f"(3 pos x2 non ancora); a 5k: {int(0.25 * 5000 // sp_margin)} slot -> granularita' OK")
|
||||
p(f" (3 concorrenti scalabili 1->2, {6 * sp_margin / 5000:.0%} del conto impegnato).")
|
||||
p(f" SOGLIA ~${int(np.ceil(6 * sp_margin / 0.25 / 100) * 100):,} per il set completo; "
|
||||
f"~$2.1k per 5 slot senza scalata. Income lordo a 5k: "
|
||||
f"{tiers[5000]['spread_income_yr']:.0f}$/anno ({tiers[5000]['spread_income_yr'] / 5000:.1%}) "
|
||||
f"PRIMA delle code (Test 2-3: EV~0).")
|
||||
p(f" - Confronto scala video: 10k su SPX = 1 contratto SPY (~$60k notional, 6x leva).")
|
||||
p(f" L'equivalente Deribit a parita' di prudenza (tail 5%) e' ~${20 * e['tail_net']:,.0f}+")
|
||||
p(f" su ETH e ~${20 * b['tail_net']:,.0f}+ su BTC: anche a 5k il conto e' SOTTO la")
|
||||
p(f" scala minima del gioco del video su BTC, e ci sta su ETH solo in versione")
|
||||
p(f" credit-spread (che e' VRP01, non 'Albimarini').")
|
||||
OUT["test5"] = dict(assets=res, tiers=tiers,
|
||||
spread=dict(margin=sp_margin, credit=sp_credit, fee=float(sp_fee)))
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TEST 6 — CONFRONTO CON VRP01 (perche' un theta-harvest sopravvive)
|
||||
# ===========================================================================
|
||||
def test6():
|
||||
p("\n" + "=" * 88)
|
||||
p("TEST 6 — Che cosa distingue un theta-harvest che sopravvive (VRP01) da uno che no")
|
||||
p("=" * 88)
|
||||
p(" VRP01 (audit superato, diario 2026-06-20): (1) DEFINED-RISK come struttura, non")
|
||||
p(" come accessorio: il long wing cappa worst-week -16.6% -> -7.4% e DD 33% -> 21%;")
|
||||
p(" (2) GATE di regime IV-rank>0.30 (vende solo vol RICCA): ribalta l'HOLD-OUT da")
|
||||
p(" -0.25 a +0.28 — l'alpha e' il filtro, non il theta; (3) SIZING: sleeve al 12%")
|
||||
p(" del book, niente compounding del numero di contratti; positivo/flat anche 2022.")
|
||||
p(" La 'Albimarini' e' l'opposto quantificato: vende SEMPRE (nessun gate: incassa il")
|
||||
p(" premio anche quando IV<RV), il rischio e' cappato solo dalla width della diagonale")
|
||||
p(" (a 6x notional un gap -10% = ~-5/-12% equity PER UNITA', x4 post-scaling, Test 4),")
|
||||
p(" e il sizing SCALA sui profitti proprio mentre la coda si avvicina (Test 3).")
|
||||
p(" Win-rate 82% e PF 5.16 su 28 trade sono la FIRMA STRUTTURALE del venditore di")
|
||||
p(" quantile a EV=0 in una finestra senza code (P 20-45%, Test 2), non un edge.")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
def main():
|
||||
spy, m4, q82 = test1()
|
||||
test2(spy, q82)
|
||||
test3(spy, q82)
|
||||
test4(q82)
|
||||
test5()
|
||||
test6()
|
||||
p("\n" + "=" * 88)
|
||||
p("JSON")
|
||||
p("=" * 88)
|
||||
p(json.dumps(OUT, indent=1, default=float))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,333 @@
|
||||
"""R0702 ALB-STRUCTURE — double diagonal deep-OTM "Albimarini" vs struttura VRP01.
|
||||
|
||||
FILONE: video didattici claimano su SPY una vendita sistematica di DOUBLE DIAGONAL a credito
|
||||
(2 short scadenza T a ~9% di distanza su ~6 giorni + 2 long scadenza T+1g, entrambi i lati) con
|
||||
82% win, PF 5.16, "420% annuo". Qui la STRUTTURA (non il gate — lezione 2026-07-01: il gate
|
||||
IV-rank>0.30 canonico NON si riottimizza) viene portata su BTC/ETH Deribit (che ha scadenze
|
||||
giornaliere) e modellata onestamente sul nostro stack DVOL, contro il VRP01 canonico
|
||||
(put credit spread settimanale -0.28/-0.10, gate vrp>0 + ivr>=0.30 + crash-skip 0.90).
|
||||
|
||||
⚠️ CAVEAT SKEW — IN TESTA, NON IN FONDO: il pricing e' Black-Scholes FLAT sulla DVOL (IV ATM 30g).
|
||||
Il deep-OTM (qui 1.5-3.0 sigma ~ 8-25% di distanza) e' ESATTAMENTE dove il flat-vol sbaglia di piu':
|
||||
su crypto lo smile e' ripido su ENTRAMBE le ali (su equity solo put). Il premio reale delle ali e'
|
||||
probabilmente > modello (f>1) per le put e variabile per le call; la calibrazione reale che abbiamo
|
||||
(f~1.0) e' ATM-ish delta -0.28 su finestra calma, NON copre il deep-OTM. Quindi OGNI numero va letto
|
||||
come BANDA sul fattore premio f in {0.6, 0.8, 1.0, 1.3} + uno scenario skew asimmetrico
|
||||
(f_put=1.3 / f_call=0.7), MAI come stima puntuale. In piu' la DVOL e' IV a 30g usata per tenor 3-6g
|
||||
(term structure ignorata; snapshot vol_term 2026-06: iv_7d vs iv_30d entro ~±3pt in calma, ma in
|
||||
stress il front-end esplode -> il modello SOTTOSTIMA il mark-to-market avverso in crash).
|
||||
|
||||
Distanza in unita' di VOL, non il 9% fisso di SPY: 9%/6g su SPY (IV~16%) = ~3.5-4 sigma; BTC si
|
||||
muove del 9% in 6 giorni spesso. Cella centrale dichiarata A PRIORI: z=2.0 sigma, ali dz=+1.0 sigma,
|
||||
tenor 5g (centro del range 3-6g del video). z=3.0 in griglia = cella "fedele al video".
|
||||
|
||||
Riusa: options_vrp_lab (bs_put, load_series, per_year), options_vrp_v2 (vrp_spread_weekly = VRP01,
|
||||
_ivrank, _rv30), altlib.marginal_vs_tp01. Fee Deribit opzioni per gamba: 0.03% del notional cap
|
||||
12.5% del premio (+ delivery 0.015% cap 12.5% sull'ITM a scadenza; il diagonale paga anche la fee
|
||||
di USCITA per vendere le long residue a T). NON deploy: regola standing "niente short-vol da
|
||||
modello in deploy" — l'esito massimo e' conoscenza sulla struttura.
|
||||
|
||||
uv run python scripts/research/r0702_alb_structure.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" / "alt"))
|
||||
import numpy as np, pandas as pd
|
||||
from scipy.stats import norm
|
||||
from scripts.research.options_vrp_lab import bs_put, load_series, per_year
|
||||
from scripts.research.options_vrp_v2 import vrp_spread_weekly, _ivrank, _rv30
|
||||
|
||||
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
|
||||
DAY = 1.0 / 365.25
|
||||
# cella centrale DICHIARATA A PRIORI (prima di guardare qualsiasi risultato)
|
||||
CENTRAL = dict(z=2.0, dz=1.0, tenor_d=5)
|
||||
F_SWEEP = (0.6, 0.8, 1.0, 1.3) # banda skew simmetrica
|
||||
F_SKEW = dict(f_put=1.3, f_call=0.7) # scenario skew crypto-shaped (put ricche, call povere)
|
||||
|
||||
|
||||
def bs_call(S, K, T, sig):
|
||||
if T <= 0 or sig <= 0:
|
||||
return max(S - K, 0.0)
|
||||
d1 = (np.log(S / K) + 0.5 * sig ** 2 * T) / (sig * np.sqrt(T))
|
||||
return S * norm.cdf(d1) - K * norm.cdf(d1 - sig * np.sqrt(T)) # r=0
|
||||
|
||||
|
||||
def _fee_frac(prem_frac, notional_ratio=1.0, rate=0.0003):
|
||||
"""Fee Deribit per gamba come frazione di S0: rate*notional cap 12.5% del premio."""
|
||||
return min(rate * notional_ratio, 0.125 * max(prem_frac, 0.0))
|
||||
|
||||
|
||||
def run_structure(asset, kind, z=2.0, dz=1.0, tenor_d=5, f_put=1.0, f_call=1.0,
|
||||
gated=False, collect=None):
|
||||
"""Vendita sistematica non-overlapping della struttura, cadenza = tenor_d.
|
||||
|
||||
kind: 'diag' = double diagonal Albimarini (short T entrambi i lati, long T+1g piu' OTM)
|
||||
'condor' = iron condor STESSA scadenza (controllo del claim '+1 giorno')
|
||||
'vert' = vertical put credit spread deep-OTM (solo lato put, stessa scadenza)
|
||||
Strike: K = S0*exp(±z·σ√T) (z in sigma dell'orizzonte SHORT); ali a z+dz.
|
||||
gated=True -> gate CANONICO VRP01 (vrp>0 AND ivr>=0.30 AND ivr<=0.90), NON riottimizzato.
|
||||
Ritorna Series di rendimenti per-periodo su capitale = S0 (spot a entry), indice = scadenza.
|
||||
collect (dict) accumula diagnostica per la decomposizione diag-vs-condor."""
|
||||
J = load_series(asset)
|
||||
px = J["px"].values; dv = J["dvol"].values / 100.0; idx = J.index
|
||||
n = len(px); T = tenor_d / 365.25
|
||||
T_long = T + DAY if kind == "diag" else T
|
||||
has_call = kind in ("diag", "condor")
|
||||
rets = {}
|
||||
i = 60
|
||||
while i + tenor_d < n:
|
||||
S0 = px[i]; sig = dv[i]
|
||||
if gated:
|
||||
rv = _rv30(px, i); ivr = _ivrank(dv, i)
|
||||
skip = ((not np.isnan(rv) and (sig - rv) <= 0)
|
||||
or (not np.isnan(ivr) and (ivr < 0.30 or ivr > 0.90)))
|
||||
if skip:
|
||||
rets[idx[i + tenor_d]] = 0.0
|
||||
i += tenor_d
|
||||
continue
|
||||
m = sig * np.sqrt(T)
|
||||
Kp_s = S0 * np.exp(-z * m); Kp_l = S0 * np.exp(-(z + dz) * m)
|
||||
Kc_s = S0 * np.exp(+z * m); Kc_l = S0 * np.exp(+(z + dz) * m)
|
||||
# premi a entry (frazione di S0), f per lato
|
||||
ps = bs_put(S0, Kp_s, T, sig) / S0 * f_put # short put
|
||||
pl = bs_put(S0, Kp_l, T_long, sig) / S0 * f_put # long put (T o T+1g)
|
||||
legs = [ps, pl]
|
||||
cs = cl = 0.0
|
||||
if has_call:
|
||||
cs = bs_call(S0, Kc_s, T, sig) / S0 * f_call
|
||||
cl = bs_call(S0, Kc_l, T_long, sig) / S0 * f_call
|
||||
legs += [cs, cl]
|
||||
credit = (ps + cs) - (pl + cl)
|
||||
# exit a scadenza degli short
|
||||
j = i + tenor_d
|
||||
S1 = px[j]; sig1 = dv[j]
|
||||
short_pay = max(0.0, Kp_s - S1) / S0 + (max(0.0, S1 - Kc_s) / S0 if has_call else 0.0)
|
||||
if kind == "diag": # long con 1 giorno residuo: mark BS alla DVOL di uscita (vega!)
|
||||
lp = bs_put(S1, Kp_l, DAY, sig1) / S0 * f_put
|
||||
lc = bs_call(S1, Kc_l, DAY, sig1) / S0 * f_call
|
||||
long_val = lp + lc
|
||||
exit_fee = sum(_fee_frac(v, notional_ratio=S1 / S0) for v in (lp, lc))
|
||||
else: # stessa scadenza: valore = solo intrinseco
|
||||
long_val = max(0.0, Kp_l - S1) / S0 + (max(0.0, S1 - Kc_l) / S0 if has_call else 0.0)
|
||||
exit_fee = 0.0
|
||||
entry_fee = sum(_fee_frac(v) for v in legs)
|
||||
# delivery fee su gambe short ITM a scadenza (0.015% cap 12.5%)
|
||||
deliv = _fee_frac(max(0.0, Kp_s - S1) / S0, rate=0.00015)
|
||||
if has_call:
|
||||
deliv += _fee_frac(max(0.0, S1 - Kc_s) / S0, rate=0.00015)
|
||||
pnl = credit - short_pay + long_val - entry_fee - exit_fee - deliv
|
||||
rets[idx[j]] = pnl
|
||||
if collect is not None:
|
||||
# costo extra del T+1 a entry e valore residuo recuperato a exit (vs intrinseco)
|
||||
pl_T = bs_put(S0, Kp_l, T, sig) / S0 * f_put
|
||||
cl_T = (bs_call(S0, Kc_l, T, sig) / S0 * f_call) if has_call else 0.0
|
||||
intr = max(0.0, Kp_l - S1) / S0 + (max(0.0, S1 - Kc_l) / S0 if has_call else 0.0)
|
||||
collect.setdefault("extra_cost", []).append((pl + cl) - (pl_T + cl_T))
|
||||
collect.setdefault("recovered", []).append((long_val - intr) if kind == "diag" else 0.0)
|
||||
collect.setdefault("short_pay", []).append(short_pay)
|
||||
collect.setdefault("credit", []).append(credit)
|
||||
i += tenor_d
|
||||
return pd.Series(rets)
|
||||
|
||||
|
||||
def book(kind, **kw):
|
||||
rB = run_structure("BTC", kind, **kw); rE = run_structure("ETH", kind, **kw)
|
||||
return pd.concat({"B": rB, "E": rE}, axis=1, join="inner").mean(axis=1)
|
||||
|
||||
|
||||
def metrics(r, tenor_d):
|
||||
"""Metriche su rendimenti per-periodo (cadenza tenor_d). win/PF solo sui periodi ATTIVI."""
|
||||
r = r.dropna()
|
||||
ppy = 365.25 / tenor_d
|
||||
if len(r) < 3 or r.std() == 0:
|
||||
return dict(sh=0.0, sh_h=0.0, cagr=0.0, dd=0.0, worst=0.0, win=0.0, pf=0.0, act=0.0)
|
||||
def _sh(x):
|
||||
return float(x.mean() / x.std() * np.sqrt(ppy)) if len(x) > 2 and x.std() > 0 else 0.0
|
||||
eq = np.cumprod(1 + r.values); pk = np.maximum.accumulate(eq)
|
||||
yrs = len(r) / ppy
|
||||
act = r[r != 0.0]
|
||||
pos = act[act > 0].sum(); neg = -act[act < 0].sum()
|
||||
return dict(
|
||||
sh=_sh(r), sh_h=_sh(r[r.index >= HOLDOUT]),
|
||||
cagr=float(eq[-1] ** (1 / yrs) - 1) if yrs > 0 and eq[-1] > 0 else -1.0,
|
||||
dd=float(np.max((pk - eq) / pk)), worst=float(r.min()),
|
||||
win=float((act > 0).mean()) if len(act) else 0.0,
|
||||
pf=float(pos / neg) if neg > 0 else float("inf"),
|
||||
act=float((r != 0.0).mean()), n_act=int(len(act)))
|
||||
|
||||
|
||||
def row(label, r, tenor_d):
|
||||
mm = metrics(r, tenor_d)
|
||||
pf = f"{mm['pf']:5.2f}" if np.isfinite(mm["pf"]) else " inf"
|
||||
print(f" {label:<40} {mm['sh']:>6.2f} {mm['sh_h']:>6.2f} {mm['cagr']*100:>+6.1f}% "
|
||||
f"{mm['dd']*100:>5.1f}% {mm['worst']*100:>+6.2f}% {pf} {mm['win']*100:>4.0f}% {mm['act']*100:>4.0f}%")
|
||||
return mm
|
||||
|
||||
|
||||
def to_daily_lumped(wk):
|
||||
"""Rendimenti per-periodo -> griglia giornaliera con lump alla scadenza (convenzione
|
||||
_vrp_combo_returns: preserva lo Sharpe annualizzato, niente smoothing)."""
|
||||
wk = wk.sort_index()
|
||||
days = pd.date_range(wk.index.min().normalize(), wk.index.max().normalize(), freq="1D", tz="UTC")
|
||||
daily = pd.Series(0.0, index=days)
|
||||
daily.loc[wk.index.normalize()] = wk.values
|
||||
return daily
|
||||
|
||||
|
||||
HDR = f" {'struttura':<40} {'ShF':>6} {'ShH':>6} {'CAGR':>7} {'maxDD':>6} {'worst':>7} {'PF':>5} {'win%':>5} {'att%':>5}"
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 110)
|
||||
print(" R0702 ALB-STRUCTURE — double diagonal deep-OTM (Albimarini) vs vertical vs condor vs VRP01")
|
||||
print(" Capitale = SPOT a entry (S0) per le strutture nuove; VRP01 canonico = strike corto (sua convenzione).")
|
||||
print(" ⚠️ SKEW: pricing BS FLAT su DVOL-30g; il deep-OTM e' banda-f, non stima puntuale. Term structure ignorata.")
|
||||
print("=" * 110)
|
||||
|
||||
z, dz, tn = CENTRAL["z"], CENTRAL["dz"], CENTRAL["tenor_d"]
|
||||
for a in ("BTC", "ETH"):
|
||||
J = load_series(a)
|
||||
sig = J["dvol"].mean() / 100.0
|
||||
d5 = sig * np.sqrt(tn / 365.25)
|
||||
print(f" {a}: DVOL media {sig*100:.0f}% -> 1σ su {tn}g = {d5*100:.1f}% | z=2.0 = {2*d5*100:.1f}% "
|
||||
f"| z=3.0 = {3*d5*100:.1f}% (il '9% su SPY/6g' ≈ 3.5-4σ equity)")
|
||||
|
||||
# ------------------------------------------------------------------ (1) VRP01 canonico
|
||||
print(f"\n (1) VRP01 CANONICO (riproduzione options_vrp_v2 COMBO: spread -0.28/-0.10 7g, vrp>0+ivr30+cs90)")
|
||||
print(HDR)
|
||||
vrp = {}
|
||||
for f in F_SWEEP:
|
||||
vrp[f] = pd.concat({"B": vrp_spread_weekly("BTC", defined_risk=True, f=f, gate_vrp=True,
|
||||
gate_ivr=0.30, crash_skip=0.90),
|
||||
"E": vrp_spread_weekly("ETH", defined_risk=True, f=f, gate_vrp=True,
|
||||
gate_ivr=0.30, crash_skip=0.90)},
|
||||
axis=1, join="inner").mean(axis=1)
|
||||
row(f"VRP01 gated f={f}", vrp[f], 7)
|
||||
vrp_nog = pd.concat({"B": vrp_spread_weekly("BTC", defined_risk=True, f=1.0),
|
||||
"E": vrp_spread_weekly("ETH", defined_risk=True, f=1.0)},
|
||||
axis=1, join="inner").mean(axis=1)
|
||||
row("VRP01 NO-gate f=1.0", vrp_nog, 7)
|
||||
|
||||
# bridge di validazione del motore: vertical -0.28-equivalente (z~0.58, dz~0.70, 7g) ~ VRP01
|
||||
zb = float(-norm.ppf(0.28)); dzb = float(-norm.ppf(0.10)) - zb
|
||||
br = book("vert", z=zb, dz=dzb, tenor_d=7, f_put=1.0, f_call=1.0, gated=True)
|
||||
row(f"[bridge] vert z={zb:.2f} dz={dzb:.2f} 7g gated", br, 7)
|
||||
print(" (bridge ~ VRP01 a meno di convenzione strike/capitale: valida il motore nuovo)")
|
||||
|
||||
# ------------------------------------------------------------------ (2) tabella principale
|
||||
print(f"\n (2) STRUTTURE ALLA CELLA CENTRALE A PRIORI (z={z}σ, ali +{dz}σ, tenor {tn}g) — banda f")
|
||||
scen = [(f, f, f"f={f}") for f in F_SWEEP] + [(F_SKEW["f_put"], F_SKEW["f_call"], "SKEW fp=1.3/fc=0.7")]
|
||||
streams = {}
|
||||
for gated, gtag in ((False, "NO-GATE"), (True, "GATE canonico (vrp>0+ivr30+cs90)")):
|
||||
print(f"\n --- {gtag} ---")
|
||||
print(HDR)
|
||||
for kind, ktag in (("diag", "DIAG double-diagonal T+1g"),
|
||||
("condor", "CONDOR iron condor stessa T"),
|
||||
("vert", "VERT put spread stessa T")):
|
||||
for fp, fc, ftag in scen:
|
||||
r = book(kind, z=z, dz=dz, tenor_d=tn, f_put=fp, f_call=fc, gated=gated)
|
||||
row(f"{ktag} {ftag}", r, tn)
|
||||
streams[(kind, gated, ftag)] = r
|
||||
|
||||
# distanze alternative (sweep trasparente, selezione SOLO in-sample; f=1.0 gated)
|
||||
print(f"\n (3) SWEEP DISTANZA/TENOR (gated, f=1.0) — selezione cella SOLO su Sharpe PRE-holdout")
|
||||
print(f" {'cella':<40} {'ShF-IS':>7} {'ShH':>6} {'DD':>6} {'worst':>7} {'PF':>5} {'win%':>5}")
|
||||
best = None
|
||||
for kind in ("diag", "condor", "vert"):
|
||||
for zz in (1.5, 2.0, 2.5, 3.0):
|
||||
for tt in (3, 5):
|
||||
r = book(kind, z=zz, dz=dz, tenor_d=tt, f_put=1.0, f_call=1.0, gated=True)
|
||||
ris = r[r.index < HOLDOUT]
|
||||
mm = metrics(r, tt); mi = metrics(ris, tt)
|
||||
pf = f"{mm['pf']:5.2f}" if np.isfinite(mm["pf"]) else " inf"
|
||||
act = r[r != 0.0]
|
||||
nloss = int((act < 0).sum())
|
||||
tag = " <- video ~3.5σ" if zz == 3.0 and tt == 5 and kind == "diag" else ""
|
||||
if nloss == 0:
|
||||
tag += " ⚠️ 0 perdite su tutta la storia = coda MAI campionata (lezione CC01: Sharpe implausibile -> rischio nascosto)"
|
||||
print(f" {kind:<7} z={zz} dz={dz} tenor={tt}g{'':<14} {mi['sh']:>7.2f} {mm['sh_h']:>6.2f} "
|
||||
f"{mm['dd']*100:>5.1f}% {mm['worst']*100:>+6.2f}% {pf} {mm['win']*100:>4.0f}% n={len(act):>3}{tag}")
|
||||
if best is None or mi["sh"] > best[1]:
|
||||
best = ((kind, zz, tt), mi["sh"], mm["sh_h"])
|
||||
print(f" -> cella best IN-SAMPLE: {best[0]} (ShF-IS {best[1]:.2f}) | suo hold-out ShH {best[2]:.2f}")
|
||||
print(" Le celle z>=2.5/5g vendono un evento ~1% mai occorso nel subsample gated (~140 trade):")
|
||||
print(" Sharpe 'inf/5.9' = premio senza coda osservata, NON edge. E' il punto cieco CC01 in forma opzioni.")
|
||||
|
||||
# ------------------------------------------------------------------ (4) claim del video
|
||||
print(f"\n (4) TEST CLAIM VIDEO: la long a T+1g domina la long a STESSA T? (z={z}, dz={dz}, {tn}g, f=1.0)")
|
||||
for gated in (False, True):
|
||||
colD, colC = {}, {}
|
||||
rD = pd.concat({a[0]: run_structure(a, "diag", z=z, dz=dz, tenor_d=tn, gated=gated, collect=colD)
|
||||
for a in ("BTC", "ETH")}, axis=1, join="inner").mean(axis=1)
|
||||
rC = pd.concat({a[0]: run_structure(a, "condor", z=z, dz=dz, tenor_d=tn, gated=gated, collect=colC)
|
||||
for a in ("BTC", "ETH")}, axis=1, join="inner").mean(axis=1)
|
||||
mD, mC = metrics(rD, tn), metrics(rC, tn)
|
||||
ec = np.array(colD["extra_cost"]); rec = np.array(colD["recovered"])
|
||||
sp = np.array(colD["short_pay"]); crd = np.array(colD["credit"])
|
||||
crash = sp > np.quantile(sp, 0.95)
|
||||
gt = "GATE" if gated else "NO-GATE"
|
||||
print(f" [{gt}] DIAG ShF {mD['sh']:+.2f}/ShH {mD['sh_h']:+.2f} worst {mD['worst']*100:+.2f}% | "
|
||||
f"CONDOR ShF {mC['sh']:+.2f}/ShH {mC['sh_h']:+.2f} worst {mC['worst']*100:+.2f}%")
|
||||
print(f" costo extra T+1 a entry: {ec.mean()*1e4:+.1f} bps/trade | residuo recuperato a exit: "
|
||||
f"{rec.mean()*1e4:+.1f} bps (nei 5% peggiori: {rec[crash].mean()*1e4:+.1f} bps vs extra {ec[crash].mean()*1e4:+.1f})")
|
||||
print(f" trade a CREDITO netto: {(crd>0).mean()*100:.0f}% (credito medio {crd.mean()*1e4:+.1f} bps di S0)")
|
||||
dY = per_year(rD); cY = per_year(rC)
|
||||
print(" Δ(diag-condor) per anno: " + " ".join(f"{y}:{(dY[y]-cY.get(y,0))*100:+.2f}%" for y in sorted(dY)))
|
||||
|
||||
# ------------------------------------------------------------------ (5) per-anno
|
||||
print(f"\n (5) PER-ANNO (gated, f=1.0) — 2022 = LUNA+FTX e' il banco di prova")
|
||||
for tag, r, tt in (("DIAG", streams[("diag", True, "f=1.0")], tn),
|
||||
("CONDOR", streams[("condor", True, "f=1.0")], tn),
|
||||
("VERT", streams[("vert", True, "f=1.0")], tn),
|
||||
("VRP01", vrp[1.0], 7)):
|
||||
py = per_year(r)
|
||||
print(f" {tag:<7} " + " ".join(f"{y}:{v*100:+5.1f}%" for y, v in sorted(py.items())))
|
||||
|
||||
# ------------------------------------------------------------------ (6) marginale
|
||||
print(f"\n (6) MARGINALE vs TP01 e vs VRP01 (daily-lumped; corr su griglia settimanale)")
|
||||
import altlib as al
|
||||
tp = al.tp01_baseline_daily()
|
||||
dv_daily = to_daily_lumped(streams[("diag", True, "f=1.0")])
|
||||
vr_daily = to_daily_lumped(vrp[1.0])
|
||||
tp_w = (1 + tp).resample("W").prod() - 1
|
||||
di_w = (1 + dv_daily).resample("W").prod() - 1
|
||||
vr_w = (1 + vr_daily).resample("W").prod() - 1
|
||||
Jw = pd.concat({"tp": tp_w, "di": di_w, "vr": vr_w}, axis=1, join="inner").dropna()
|
||||
print(f" corr settimanale: DIAG~TP01 {Jw['tp'].corr(Jw['di']):+.2f} | DIAG~VRP01 {Jw['di'].corr(Jw['vr']):+.2f} "
|
||||
f"| VRP01~TP01 {Jw['tp'].corr(Jw['vr']):+.2f}")
|
||||
for name, dd in (("DIAG gated f=1.0", dv_daily), ("VRP01 gated f=1.0 (riferimento)", vr_daily)):
|
||||
mv = al.marginal_vs_tp01(dd)
|
||||
print(f" marginal_vs_tp01[{name}]: verdict={mv.get('marginal_verdict')} corr={mv.get('corr_full')} "
|
||||
f"uplift w25 full/hold={mv['blends']['w25']['uplift_full']}/{mv['blends']['w25']['uplift_hold']} "
|
||||
f"IS-Sh={mv.get('cand_insample_sharpe')} insample_edge={mv.get('has_insample_edge')} "
|
||||
f"hedge={mv.get('is_hedge')} robust_oos={mv.get('robust_oos')} multicut={mv.get('multicut_uplift')}")
|
||||
print(" ⚠️ Un verdetto ADDS qui NON promuove: lo stream vende coda che nel subsample gated non ha mai")
|
||||
print(" colpito (hold-out Sh 3+ = assenza di eventi, non alpha) — vale la lezione CC01, e vale la regola")
|
||||
print(" standing 'niente short-vol da modello in deploy'.")
|
||||
|
||||
# ------------------------------------------------------------------ (7) eseguibilita'
|
||||
print(f"\n (7) ESEGUIBILITA' DERIBIT (min 0.1 BTC / 1 ETH per gamba; diag = 4 gambe/asset, book = 8)")
|
||||
for a, minc in (("BTC", 0.1), ("ETH", 1.0)):
|
||||
J = load_series(a); S = float(J["px"].iloc[-1]); sig = float(J["dvol"].iloc[-1]) / 100.0
|
||||
w = dz * sig * np.sqrt(tn / 365.25) # larghezza ala in frazione di S
|
||||
notional = minc * S
|
||||
maxloss = w * notional # margine ~ max loss defined-risk (per lato)
|
||||
col = {}
|
||||
run_structure(a, "diag", z=z, dz=dz, tenor_d=tn, gated=True, collect=col)
|
||||
cbps = np.mean(col["credit"]) * 1e4 if col.get("credit") else float("nan")
|
||||
print(f" {a}: spot ~${S:,.0f} -> notional min {minc} = ${notional:,.0f}/gamba | ala {w*100:.1f}% "
|
||||
f"-> margine/max-loss min ~${maxloss:,.0f} | credito tipico {cbps:+.0f} bps = ${notional*cbps/1e4:,.0f}/trade")
|
||||
print(" -> a $600: UN diagonale BTC min-size impegna >50% del capitale su un trade 5g = NON eseguibile.")
|
||||
print(" Scala minima: sleeve opzioni al ~12% con margine <= peso richiede >~$3-5k per il solo BTC")
|
||||
print(" min-size; book 50/50 con granularita' (>=3-5 step di size) ~= $15-25k. STAT-MODE, come VRP01.")
|
||||
|
||||
print("\n NB ONESTO: win-rate alto e' STRUTTURALE nel deep-OTM (vendi eventi rari), non e' edge. Il verdetto")
|
||||
print(" sta in Sharpe/PF/coda attraverso la banda f e il 2022. Regola standing: niente short-vol da modello")
|
||||
print(" in deploy — esito massimo = aggiornamento di conoscenza sulla STRUTTURA.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user