From 491411ac7708f9d3a65dc064746984e1381ae57f Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Wed, 1 Jul 2026 23:21:59 +0000 Subject: [PATCH] =?UTF-8?q?research(wave-0701):=206=20filoni=20multi-agent?= =?UTF-8?q?e=20=E2=80=94=200=20nuovi=20sleeve,=20pesi=20confermati,=20gate?= =?UTF-8?q?=20weights=5Ftilt=5Fnull?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ondata onesta su angoli non coperti: funding-TS (chiude il filone funding su 3 lati), breadth alt (non-ridondante ma DSR 0.43, rivisitabile con storia), XS-residmom (REDUNDANT), pesi+guardia-DD (EW-STR refutato dallo scettico come selezione-sull'hold-out di 2° ordine, firma best-of-15), VRP-refine (filone esaurito), stagionalità-XS (morta allo step statistico). Lezione codificata: weights_tilt_null + combine_outer in src/portfolio (ogni cambio-pesi vs null di tilt casuali cap-respecting + delta in-sample>=0); 5 test nuovi, suite 165/165. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 23 + .../diary/2026-07-01-alt-breadth-internals.md | 91 ++++ docs/diary/2026-07-01-funding-ts-signal.md | 114 +++++ .../2026-07-01-portfolio-weights-ddguard.md | 174 +++++++ .../2026-07-01-strategy-wave-6threads.md | 74 +++ docs/diary/2026-07-01-vrp-refinement.md | 98 ++++ docs/diary/2026-07-01-xs-residual-momentum.md | 74 +++ docs/diary/2026-07-01-xs-seasonality.md | 69 +++ scripts/research/r0701_breadth_internals.py | 383 ++++++++++++++ scripts/research/r0701_funding_ts.py | 387 ++++++++++++++ scripts/research/r0701_portfolio_opt.py | 472 ++++++++++++++++++ scripts/research/r0701_portfolio_skeptic.py | 304 +++++++++++ scripts/research/r0701_vrp_refine.py | 369 ++++++++++++++ scripts/research/r0701_xs_residmom.py | 439 ++++++++++++++++ scripts/research/r0701_xs_seasonal.py | 295 +++++++++++ src/portfolio/portfolio.py | 104 +++- tests/test_weights_tilt_null.py | 82 +++ 17 files changed, 3538 insertions(+), 14 deletions(-) create mode 100644 docs/diary/2026-07-01-alt-breadth-internals.md create mode 100644 docs/diary/2026-07-01-funding-ts-signal.md create mode 100644 docs/diary/2026-07-01-portfolio-weights-ddguard.md create mode 100644 docs/diary/2026-07-01-strategy-wave-6threads.md create mode 100644 docs/diary/2026-07-01-vrp-refinement.md create mode 100644 docs/diary/2026-07-01-xs-residual-momentum.md create mode 100644 docs/diary/2026-07-01-xs-seasonality.md create mode 100644 scripts/research/r0701_breadth_internals.py create mode 100644 scripts/research/r0701_funding_ts.py create mode 100644 scripts/research/r0701_portfolio_opt.py create mode 100644 scripts/research/r0701_portfolio_skeptic.py create mode 100644 scripts/research/r0701_vrp_refine.py create mode 100644 scripts/research/r0701_xs_residmom.py create mode 100644 scripts/research/r0701_xs_seasonal.py create mode 100644 tests/test_weights_tilt_null.py diff --git a/CLAUDE.md b/CLAUDE.md index 98a9e6e..3ccf3e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -159,6 +159,29 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis 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 nuovi sleeve, pesi CURRENT confermati, + 1 gate nuovo.** 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`. - **Soffitto strutturale BTC/ETH-direzionale ~1.3** superato SOLO espandendo a un meccanismo diverso: cross-sectional su universo Hyperliquid certificato (XS01) → portafoglio Sharpe ~1.55. - **Sweep "strategie alternative" (2026-06-20) — 104 ipotesi / 153 agenti / NIENTE di nuovo regge.** diff --git a/docs/diary/2026-07-01-alt-breadth-internals.md b/docs/diary/2026-07-01-alt-breadth-internals.md new file mode 100644 index 0000000..78b5cc0 --- /dev/null +++ b/docs/diary/2026-07-01-alt-breadth-internals.md @@ -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. diff --git a/docs/diary/2026-07-01-funding-ts-signal.md b/docs/diary/2026-07-01-funding-ts-signal.md new file mode 100644 index 0000000..3eb9fd7 --- /dev/null +++ b/docs/diary/2026-07-01-funding-ts-signal.md @@ -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). diff --git a/docs/diary/2026-07-01-portfolio-weights-ddguard.md b/docs/diary/2026-07-01-portfolio-weights-ddguard.md new file mode 100644 index 0000000..7a2db43 --- /dev/null +++ b/docs/diary/2026-07-01-portfolio-weights-ddguard.md @@ -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.** diff --git a/docs/diary/2026-07-01-strategy-wave-6threads.md b/docs/diary/2026-07-01-strategy-wave-6threads.md new file mode 100644 index 0000000..82de00e --- /dev/null +++ b/docs/diary/2026-07-01-strategy-wave-6threads.md @@ -0,0 +1,74 @@ +# 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`. diff --git a/docs/diary/2026-07-01-vrp-refinement.md b/docs/diary/2026-07-01-vrp-refinement.md new file mode 100644 index 0000000..f7322b0 --- /dev/null +++ b/docs/diary/2026-07-01-vrp-refinement.md @@ -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). diff --git a/docs/diary/2026-07-01-xs-residual-momentum.md b/docs/diary/2026-07-01-xs-residual-momentum.md new file mode 100644 index 0000000..d06cc23 --- /dev/null +++ b/docs/diary/2026-07-01-xs-residual-momentum.md @@ -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). diff --git a/docs/diary/2026-07-01-xs-seasonality.md b/docs/diary/2026-07-01-xs-seasonality.md new file mode 100644 index 0000000..32b7fef --- /dev/null +++ b/docs/diary/2026-07-01-xs-seasonality.md @@ -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). diff --git a/scripts/research/r0701_breadth_internals.py b/scripts/research/r0701_breadth_internals.py new file mode 100644 index 0000000..84b6702 --- /dev/null +++ b/scripts/research/r0701_breadth_internals.py @@ -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() diff --git a/scripts/research/r0701_funding_ts.py b/scripts/research/r0701_funding_ts.py new file mode 100644 index 0000000..3d4392a --- /dev/null +++ b/scripts/research/r0701_funding_ts.py @@ -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() diff --git a/scripts/research/r0701_portfolio_opt.py b/scripts/research/r0701_portfolio_opt.py new file mode 100644 index 0000000..87c4100 --- /dev/null +++ b/scripts/research/r0701_portfolio_opt.py @@ -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 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, 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() diff --git a/scripts/research/r0701_portfolio_skeptic.py b/scripts/research/r0701_portfolio_skeptic.py new file mode 100644 index 0000000..8263744 --- /dev/null +++ b/scripts/research/r0701_portfolio_skeptic.py @@ -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() diff --git a/scripts/research/r0701_vrp_refine.py b/scripts/research/r0701_vrp_refine.py new file mode 100644 index 0000000..1b622bc --- /dev/null +++ b/scripts/research/r0701_vrp_refine.py @@ -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() diff --git a/scripts/research/r0701_xs_residmom.py b/scripts/research/r0701_xs_residmom.py new file mode 100644 index 0000000..dd9b85c --- /dev/null +++ b/scripts/research/r0701_xs_residmom.py @@ -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() diff --git a/scripts/research/r0701_xs_seasonal.py b/scripts/research/r0701_xs_seasonal.py new file mode 100644 index 0000000..0e85706 --- /dev/null +++ b/scripts/research/r0701_xs_seasonal.py @@ -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() diff --git a/src/portfolio/portfolio.py b/src/portfolio/portfolio.py index d2e3448..c99c4cc 100644 --- a/src/portfolio/portfolio.py +++ b/src/portfolio/portfolio.py @@ -106,6 +106,94 @@ def rebalance_sim(daily_cols: dict[str, pd.Series], weights: dict, n_rebalances=n_rebal, period_days=period_days, cost_rate=cost_rate) +def combine_outer(daily_cols: dict[str, pd.Series], weights: dict, + lo=None, hi=None) -> pd.Series: + """Combina serie GIORNALIERE per peso con OUTER-join e rinormalizzazione per-giorno + (stessa semantica di StrategyPortfolio.combined_daily, ma su pesi arbitrari — + riusabile per studi di sensibilità/tilt senza istanziare il portafoglio).""" + J = pd.concat(daily_cols, axis=1, join="outer").sort_index() + wv = np.array([weights[c] for c in J.columns], float) + active = J.notna().values * wv # peso solo dove c'e' dato + rowsum = active.sum(axis=1, keepdims=True) + wnorm = np.divide(active, rowsum, out=np.zeros_like(active), where=rowsum > 0) + combo = pd.Series(np.nansum(np.nan_to_num(J.values) * wnorm, axis=1), index=J.index) + combo = combo[J.notna().any(axis=1).values] # togli i giorni senza alcun dato + if lo is not None: + combo = combo[combo.index >= lo] + if hi is not None: + combo = combo[combo.index < hi] + return combo + + +def weights_tilt_null(daily_cols: dict[str, pd.Series], w_current: dict, w_proposed: dict, + *, caps: dict | None = None, floor: float = 0.05, n: int = 500, + seed: int = 20260701, holdout: pd.Timestamp = HOLDOUT, + k_seen: int | None = None) -> dict: + """GATE per ogni proposta di CAMBIO PESI del portafoglio (lezione 2026-07-01, EW-STR refutato). + + Un tilt che "batte i pesi correnti sull'hold-out" non è informativo di per sé: se metà dei + tilt casuali dentro i vincoli batte CURRENT sull'hold-out, il claim è generico; e se il tilt + proposto siede al percentile ~k/(k+1) fra i tilt casuali (k = configurazioni viste + sull'hold-out durante la ricerca), l'uplift è indistinguibile dal *best-of-k scelto + sull'hold-out* (selezione di 2° ordine). Vedi diario 2026-07-01-portfolio-weights-ddguard.md. + + daily_cols: serie di rendimenti GIORNALIERI per sleeve (es. {s.name: s.daily()}). + caps: peso massimo per sleeve (es. {"VRP01": 0.15, "XS01": 0.25}); floor: peso minimo comune. + k_seen: quante configurazioni di pesi sono state guardate sull'hold-out durante la ricerca. + + REGOLA (gate_pass): un cambio pesi si applica solo se + (1) delta_insample >= 0 — non deve PERDERE risk-adjusted pre-holdout; + (2) pctl_hold < 100*k/(k+1) (o < 80 se k_seen ignoto) — sotto la firma best-of-k. + Il gate è NECESSARIO, non sufficiente: restano richiesti finestre OOS disgiunte e realismo + (pesi effettivi post-rinormalizzazione, eseguibilità degli sleeve a peso aumentato).""" + names = list(daily_cols) + caps_v = np.array([(caps or {}).get(nm, 1.0) for nm in names], float) + + 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(DAYS_PER_YEAR)) if len(r) > 1 and r.std() > 0 else 0.0 + + def _wvec(wd: dict) -> np.ndarray: + w = np.array([wd[nm] for nm in names], float) + return w / w.sum() + + # campiona n pesi casuali uniformi sul simplesso, dentro floor/caps (rejection sampling) + rng = np.random.default_rng(seed) + samples, tries = [], 0 + while len(samples) < n: + batch = rng.dirichlet(np.ones(len(names)), size=max(4 * n, 256)) + ok = (batch >= floor).all(axis=1) & (batch <= caps_v).all(axis=1) + samples.extend(batch[ok]) + tries += 1 + if tries > 200: + raise RuntimeError("weights_tilt_null: vincoli floor/caps troppo stretti (acceptance ~0)") + S = np.array(samples[:n]) + + sh_cur_hold = _sh(combine_outer(daily_cols, dict(zip(names, _wvec(w_current)))), lo=holdout) + d_hold_rand = np.array([ + _sh(combine_outer(daily_cols, dict(zip(names, w))), lo=holdout) - sh_cur_hold for w in S]) + + wp = dict(zip(names, _wvec(w_proposed))) + wc = dict(zip(names, _wvec(w_current))) + d_hold = _sh(combine_outer(daily_cols, wp), lo=holdout) - sh_cur_hold + d_full = _sh(combine_outer(daily_cols, wp)) - _sh(combine_outer(daily_cols, wc)) + d_is = _sh(combine_outer(daily_cols, wp, hi=holdout)) - _sh(combine_outer(daily_cols, wc, hi=holdout)) + + pctl_hold = float((d_hold_rand < d_hold).mean() * 100.0) + bestofk = float(100.0 * k_seen / (k_seen + 1)) if k_seen else None + gate_pass = bool(d_is >= 0.0 and pctl_hold < (bestofk if bestofk is not None else 80.0)) + return dict(delta_hold=round(d_hold, 4), delta_full=round(d_full, 4), + delta_insample=round(d_is, 4), + frac_random_beat_hold=round(float((d_hold_rand > 0).mean()), 3), + pctl_hold=round(pctl_hold, 1), bestofk_pctl=bestofk, + gate_pass=gate_pass, n_samples=int(len(S)), samples=S) + + class StrategyPortfolio: def __init__(self, sleeves: list[Sleeve], capital: float = 2000.0): if not sleeves: @@ -124,20 +212,8 @@ class StrategyPortfolio: (es. TP01 dal 2019, uno nuovo dal 2024) -> ogni giorno i pesi sono RINORMALIZZATI fra i soli sleeve con dato disponibile (uno sleeve "si attiva" quando parte la sua storia). Cosi' non si tronca il portafoglio alla finestra comune.""" - w = self.weights() - cols = {s.name: s.daily() for s in self.sleeves} - J = pd.concat(cols, axis=1, join="outer").sort_index() - wv = np.array([w[c] for c in J.columns], float) - active = J.notna().values * wv # peso solo dove c'e' dato - rowsum = active.sum(axis=1, keepdims=True) - wnorm = np.divide(active, rowsum, out=np.zeros_like(active), where=rowsum > 0) - combo = pd.Series(np.nansum(np.nan_to_num(J.values) * wnorm, axis=1), index=J.index) - combo = combo[J.notna().any(axis=1).values] # togli i giorni senza alcun dato - if lo is not None: - combo = combo[combo.index >= lo] - if hi is not None: - combo = combo[combo.index < hi] - return combo + return combine_outer({s.name: s.daily() for s in self.sleeves}, self.weights(), + lo=lo, hi=hi) def backtest(self) -> dict: full = self.combined_daily() diff --git a/tests/test_weights_tilt_null.py b/tests/test_weights_tilt_null.py new file mode 100644 index 0000000..0e1e5c2 --- /dev/null +++ b/tests/test_weights_tilt_null.py @@ -0,0 +1,82 @@ +"""Test del gate weights_tilt_null (lezione 2026-07-01: EW-STR refutato come best-of-k). + +Dati SINTETICI deterministici: 3 sleeve a date d'inizio diverse, di cui uno ("C") con +Sharpe gonfiato SOLO nell'hold-out — il tilt verso C deve risultare sospetto (percentile +alto fra i tilt casuali, delta_insample ~<=0), mentre un tilt nullo deve essere innocuo. +""" +import sys +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from src.portfolio.portfolio import HOLDOUT, StrategyPortfolio, Sleeve, combine_outer, weights_tilt_null + + +def _mk_daily(start: str, n: int, mu: float, sigma: float, seed: int, + mu_holdout: float | None = None) -> pd.Series: + rng = np.random.default_rng(seed) + idx = pd.date_range(start, periods=n, freq="1D", tz="UTC") + r = rng.normal(mu, sigma, n) + if mu_holdout is not None: + m = idx >= HOLDOUT + r[m] = rng.normal(mu_holdout, sigma, int(m.sum())) + return pd.Series(r, index=idx) + + +@pytest.fixture(scope="module") +def cols() -> dict: + n = 2600 # ~2019-07 -> 2026-08: copre pre e post hold-out + return { + "A": _mk_daily("2019-07-01", n, 8e-4, 0.010, seed=1), + "B": _mk_daily("2021-01-01", n - 550, 6e-4, 0.012, seed=2), + # C: rumore pre-holdout, forte SOLO nell'hold-out (imita lo sleeve selezionato sull'OOS) + "C": _mk_daily("2019-07-01", n, 0.0, 0.011, seed=3, mu_holdout=18e-4), + } + + +def test_combine_outer_equivale_a_combined_daily(cols): + sleeves = [Sleeve(nm, w, daily_fn=(lambda s=cols[nm]: s)) + for nm, w in [("A", 0.5), ("B", 0.3), ("C", 0.2)]] + port = StrategyPortfolio(sleeves) + a = port.combined_daily() + b = combine_outer(cols, {"A": 0.5, "B": 0.3, "C": 0.2}) + assert np.allclose(a.values, b.values) and a.index.equals(b.index) + + +def test_tilt_identico_e_neutro(cols): + w = {"A": 0.5, "B": 0.3, "C": 0.2} + rep = weights_tilt_null(cols, w, w, n=100, seed=7) + assert rep["delta_hold"] == 0.0 and rep["delta_full"] == 0.0 and rep["delta_insample"] == 0.0 + assert rep["n_samples"] == 100 + assert 0.0 <= rep["frac_random_beat_hold"] <= 1.0 + + +def test_vincoli_floor_caps_rispettati(cols): + rep = weights_tilt_null(cols, {"A": 0.5, "B": 0.3, "C": 0.2}, {"A": 0.4, "B": 0.3, "C": 0.3}, + caps={"B": 0.35}, floor=0.05, n=150, seed=11) + S = rep["samples"] + assert (S >= 0.05 - 1e-12).all() and (S[:, 1] <= 0.35 + 1e-12).all() + assert np.allclose(S.sum(axis=1), 1.0) + + +def test_tilt_verso_sleeve_holdout_only_e_sospetto(cols): + """Tilt verso C (edge solo hold-out): delta_hold>0 ma insample<=~0 -> gate_pass False.""" + w_cur = {"A": 0.5, "B": 0.3, "C": 0.2} + w_pro = {"A": 0.30, "B": 0.25, "C": 0.45} + rep = weights_tilt_null(cols, w_cur, w_pro, n=300, seed=13, k_seen=15) + assert rep["delta_hold"] > 0 # sull'hold-out "vince" (per costruzione) + assert rep["delta_insample"] <= 0.05 # ma pre-holdout non c'e' edge + assert rep["bestofk_pctl"] == pytest.approx(100 * 15 / 16) + assert not rep["gate_pass"] + + +def test_determinismo(cols): + w_cur = {"A": 0.5, "B": 0.3, "C": 0.2} + w_pro = {"A": 0.4, "B": 0.3, "C": 0.3} + r1 = weights_tilt_null(cols, w_cur, w_pro, n=80, seed=42) + r2 = weights_tilt_null(cols, w_cur, w_pro, n=80, seed=42) + assert r1["pctl_hold"] == r2["pctl_hold"] and np.allclose(r1["samples"], r2["samples"])