research(wave-0701): 6 filoni multi-agente — 0 nuovi sleeve, pesi confermati, gate weights_tilt_null

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 <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-07-01 23:21:59 +00:00
parent ccf5e38101
commit 491411ac77
17 changed files with 3538 additions and 14 deletions
+23
View File
@@ -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 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 (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`. `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: - **Soffitto strutturale BTC/ETH-direzionale ~1.3** superato SOLO espandendo a un meccanismo diverso:
cross-sectional su universo Hyperliquid certificato (XS01) → portafoglio Sharpe ~1.55. cross-sectional su universo Hyperliquid certificato (XS01) → portafoglio Sharpe ~1.55.
- **Sweep "strategie alternative" (2026-06-20) — 104 ipotesi / 153 agenti / NIENTE di nuovo regge.** - **Sweep "strategie alternative" (2026-06-20) — 104 ipotesi / 153 agenti / NIENTE di nuovo regge.**
@@ -0,0 +1,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.
+114
View File
@@ -0,0 +1,114 @@
# 2026-07-01 — Funding rate come segnale TIME-SERIES direzionale su BTC/ETH (R0701): SCARTATO
## Contesto
Filone: il **funding orario Hyperliquid come proxy di POSIZIONAMENTO/sentiment** con valenza
direzionale a orizzonte giornaliero su BTC/ETH — NON carry. Angolo diverso dal prior art già
chiuso: **FC01** (carry cross-sectional delta-neutral 19 alt → LEAD fragile SCARTATO,
`2026-06-22-funding-carry-hl.md`) e **funding price-clock** intraday (drift attorno agli stamp →
FAIL, onda intraday). Qui l'ipotesi è: funding estremo = affollamento long → fade; funding in
espansione = domanda persistente → follow; funding affollato = de-risk del trend (gate).
Attrattiva a priori: 2 gambe BTC/ETH perp = **eseguibile al capitale reale ~$600** (non STAT-MODE
come XS01/opzioni) — se avesse retto sarebbe stato deployabile.
Script: `scripts/research/r0701_funding_ts.py`.
## Dati e causalità (data-first)
- `data/raw/hlfund_{btc,eth}_1h.parquet`: **2023-05-12 → 2026-06-22, 1138/1138 giorni validi,
0 gap** dopo il 2023-06 (primi ~27 giorni a cadenza 8h — la somma giornaliera degli stamp li
gestisce indifferentemente). Funding medio **+14.4%/anno BTC, +14.6% ETH**; std del funding
giornaliero ~5.5 bps.
- **Join causale** (il punto delicato): le barre 1d di `altlib.get` sono **open-labeled**
(datetime = 00:00 UTC del giorno D; il close della barra D è noto alle 00:00 di D+1). Il
feature-day D aggrega i SOLI stamp in **[D 00:00, D+24h)** — l'ultimo alle 23:00 — quindi tutto
è noto PRIMA della decisione al close della barra D; `eval_weights` shifta la posizione al
giorno D+1. Prefix-consistency check esplicito: **max diff 0.0** (nessun leak).
- **Valutazione su finestra TRONCATA** alla copertura funding, non sul frame prezzi 2018+: fuori
copertura il target sarebbe zero per costruzione e i giorni-zero (a) **gonfiano il T del
deflated-Sharpe** (anti-conservativo) e (b) diluiscono `cand_insample_sharpe` (il gate
`has_insample_edge` scatterebbe a vuoto). La logica di `study_family_honest` è replicata
ESATTAMENTE sui frame troncati coi primitivi altlib (selezione IN-SAMPLE-ONLY → gates
study_marginal → DSR sull'intera griglia). Cross-check `study_marginal` non-troncato in coda:
stessi verdetti (abs WEAK / marginal NEUTRAL / earns_slot False).
- ⚠️ **STORIA CORTA**: ~3.1 anni totali → in-sample pre-HOLDOUT ~1.6a (meno warmup z), hold-out
2025+ ~1.5a. Dichiarato: nessun numero qui ha il peso statistico di TP01 (7 anni).
## Metodo
Famiglia a griglia modesta, 1d, 50/50 BTC+ETH, vol-target 20% cap 2x (convenzione TP01), fee
0.10% RT + sweep 0.00-0.30%:
- **FADE**: z-score(funding daily, lb) ≥ thr → short; ≤ thr → long (fade dell'affollamento).
- **FOLLOW**: z ≥ thr → long; ≤ thr → short (sentiment momentum).
- **GATE**: trend long-flat TP01-like (sgn mom 30d+90d) ma FLAT quando z ≥ thr (de-risk crowding).
- **DIVERGE**: momentum prezzo 20d seguito se funding NON affollato (z ≤ thr), fatto se z ≥ thr.
Griglia: 4 forme × lb {7,14,30,60}g × thr {0.5,1.0,1.5} = **48 celle**. Selezione cella
**in-sample-only** (mai sul hold-out), deflated-Sharpe su tutte le 48, gates marginali
(`marginal_vs_tp01`), boundary-shift della finestra di aggregazione funding (0/3/6/9/12h),
`eval_weights_smallcap` a $600.
## Risultati
Best in-sample per forma: **fade 0.34, follow 0.81, gate 1.76, diverge 0.34** — il segnale
puro-funding è debole ovunque; solo la forma che CONTIENE il trend ha Sharpe.
**Cella scelta (in-sample): GATE lb=60 thr=1.5** — IS 1.76, FULL 1.16, HOLD 0.29.
| Gate | Esito |
|---|---|
| Deflated-Sharpe (48 celle) | **0.215** (null-max atteso 1.59) → **FAIL** (soglia 0.95) |
| Assoluto (frame troncati) | **WEAK** (minFull +0.97, ma minHold **+0.04** BTC); fee-survive fino a 0.30% RT |
| Marginale vs TP01 | **NEUTRAL**: corr **0.879** (hold 0.92), beta 0.77; uplift w25 hold **+0.001**; robust_oos=False (jackknife 0.074); multicut 2026 **0.423** |
| earns_slot / earns_slot_honest | **False / False** |
| Realism | prefix-diff 0.0 (causale); boundary-shift sign-stable (uplift spread 0.047); smallcap $600 haircut ~0.00 (eseguibile, irrilevante senza edge) |
**Controllo decisivo (lezione TP01-DVOL-overlay)** — lo stesso trend long-flat SENZA gate funding:
```
trend NUDO : IS 1.65 FULL 1.14 HOLD 0.37
trend+GATE : IS 1.76 FULL 1.16 HOLD 0.29 corr(gated,nudo)=0.928
delta del gate funding: FULL +0.013, HOLD 0.080
```
**tutto lo Sharpe della "vincitrice" è il trend**; il contributo del funding è ~zero in-sample
e **negativo sul hold-out**. Il gate è TP01 travestito.
**Miglior cella puro-funding** (la vera tesi del filone): FOLLOW lb=60 thr=0.5 — IS 0.81 ma
**FULL 0.28, HOLD 1.69**, marginale **DILUTES** (uplift w25 hold 0.778). L'in-sample 0.81 era
"long-il-toro-2023/24" travestito: z-alto di funding = rally già in corso; nel 2025-26 la stessa
regola è sistematicamente dalla parte sbagliata. FADE (shortare il crowding) perde sul toro e non
recupera abbastanza nel chop; DIVERGE negativa ovunque. NB: esistono celle gate con HOLD migliore
(es. gate 7/1.5 HOLD 0.96) — sceglierle sarebbe **selection-on-holdout**, il gate del 2026-06-29
esiste esattamente per questo.
## Perché fallisce (meccanismo)
1. Su asset MAJOR il funding è **quasi sempre positivo e segue il prezzo con lag**: lo z-score
alto coincide col momentum già espresso → FOLLOW è trend-beta ritardato (muore quando il regime
gira), FADE è shortare il toro. Non c'è l'anti-persistenza ("crowding → reversal") che la tesi
richiede — a 1d, su BTC/ETH, le liquidazioni-da-crowding sono già nel prezzo prima che lo
z-score giornaliero diventi estremo.
2. L'unico uso "funzionante" (gate di de-risk sul trend) è **ridondante**: TP01-like è già flat
quando serve. Stesso verdetto di macro-regime-gate (corr 0.989) e DVOL-overlay: la famiglia
"overlay prudenziale su TSMOM" è esaurita — eredita lo Sharpe del trend e non aggiunge nulla.
## Verdetto
**SCARTATO.** `earns_slot=False`, `earns_slot_honest=False` (DSR 0.215). Il funding time-series
non ha informazione direzionale propria su BTC/ETH a 1d; l'unica cella con Sharpe è trend
travestito con contributo funding OOS negativo. Niente forward-monitor: non c'è una componente
funding da monitorare. Con FC01 (carry cross-sectional) e il price-clock intraday, il filone
"funding come segnale" su dati certi è ora **chiuso su tre lati**.
## Lascito
- `scripts/research/r0701_funding_ts.py` riusabile: (a) loader funding→daily **causale** per barre
open-labeled (finestra [D, D+24h), gestione cadenza mista 8h/1h, flag validità copertura);
(b) pattern "**truncation-honest**" per dataset a copertura parziale — replica di
`study_family_honest` su finestra troncata per non gonfiare il T del DSR né diluire
`has_insample_edge` con anni di zeri (riusabile per ogni serie corta tipo DVOL/HL).
- I parquet `hlfund_*` restano certificati. Se mai si tornasse sul funding: gli estremi VERI
(squeeze) vivono sugli alt, ma è di nuovo cross-sectional → STAT-MODE a $600, soglia di prova
alta (e FC01 ha già mostrato la fragilità d'universo).
@@ -0,0 +1,174 @@
# 2026-07-01 — Pesi statici ri-ottimizzati + guardia-DD sul portafoglio a 4 sleeve
## Mandato
Migliorare il portafoglio ATTIVO (TP01 41.25 / XS01 18.75 / VRP01 15 / SKH01 25) SENZA nuove
strategie: (A) ri-ottimizzazione onesta dei pesi statici, (B) guardia-drawdown a livello
portafoglio (replica del soft-guard vincente del tail-hedge lab 2026-06-23), (C) combinazione.
NON meta-allocazione dinamica (già scartata, thread 3 del 2026-06-29).
Script: `scripts/research/r0701_portfolio_opt.py`. Solo ricerca: nessun file di src/ o live toccato,
i pesi sono PROPOSTI, non applicati.
## Baseline riprodotta
`run_portfolio.py` oggi: **FULL Sh 2.12 / HOLD 2.21 / DD full 7.8% / CAGR 23.3%** (vs 2.13/2.30
del diario Skyhook: i dati sono avanzati di una settimana, coerente). Matrice sleeve outer-join
2019-03-14 → 2026-07-01 (TP01/SKH01 dal 2019, VRP01 dal 2021-05, XS01 dal 2024-01), combine
identico a `portfolio.combined_daily` (pesi rinormalizzati per-riga sugli sleeve attivi).
## (A) Pesi statici — metodo
- Ottimizzazione SOLO in-sample (pre-cut), valutazione OOS (post-cut), multi-cut
{2024-01, 2024-07, 2025-01}. Sleeve con <120 giorni in-sample al cut (XS01 al 2024-01)
PINNED al peso corrente (l'ottimizzatore non ha dati per giudicarli).
- Criteri: MAXSH (max-Sharpe del combo simulato), RP (inverse-vol), ERC, MINVAR-R (min-var con
vincolo ritorno ≥ pesi correnti). Null: EW (25×4) e CURRENT. Vincoli 5%≤w≤60%, Σ=1.
- **Cap STRUTTURALI dichiarati** (prudenza, non statistica): **VRP01 ≤ 15%** (sleeve MODELLATO su
DVOL ATM, stress-f non catturato — "niente short-vol da modello in deploy"), **XS01 ≤ 25%**
(STAT-MODE, storia ~2.5 anni). Varianti sotto cap: MAXSH-STR (ottimizzato) e EW-STR
(EW proiettato sui cap, parameter-free → TP 30 / XS 25 / VRP 15 / SKH 30).
### Risultati (pesi ottimizzati pre-2025, valutati FULL e HOLD-OUT 2025-26)
| criterio | pesi TP/XS/VRP/SKH | FULL Sh | FULL DD | FULL CAGR | HOLD Sh | HOLD DD | HOLD CAGR | multi-cut ΔSh OOS | anni vinti |
|---|---|---|---|---|---|---|---|---|---|
| CURRENT | 41.2/18.8/15/25 | 2.12 | 7.8% | +23.3% | 2.21 | 3.5% | +16.0% | — | — |
| MAXSH | 27.6/17.5/**39.5**/15.4 | 2.17 | 6.3% | +21.8% | 2.27 | 2.8% | +12.7% | +0.24/+0.08/+0.06 | **1/8** |
| RP-invvol | 26.7/15.5/**41.6**/16.3 | 2.17 | 6.4% | +21.8% | 2.25 | 2.8% | +12.4% | +0.28/+0.07/+0.04 | — |
| ERC | 26.6/16.2/**41.6**/15.7 | 2.17 | 6.3% | +21.7% | 2.25 | 2.8% | +12.4% | +0.29/+0.07/+0.05 | — |
| MINVAR-R | 26.7/23.3/**29.6**/20.4 | 2.18 | 6.4% | +23.3% | 2.37 | 2.6% | +16.1% | +0.20/+0.10/+0.16 | 4/8 |
| MAXSH-STR | 38.6/25/15/21.4 | 2.14 | 7.5% | +23.4% | 2.28 | 3.3% | +16.8% | **0.00**/+0.03/+0.07 | 3/8 |
| EW | 25/25/25/25 | 2.14 | 7.0% | +24.5% | 2.39 | 2.8% | +18.1% | +0.16/+0.17/+0.18 | 6/8 |
| **EW-STR** | **30/25/15/30** | 2.10 | 8.5% | +25.2% | **2.35** | 3.2% | **+19.7%** | **+0.06/+0.13/+0.14** | **7/8** |
### Lettura onesta
1. **Gli OTTIMIZZATORI non aggiungono nulla — anzi overfittano.** Tutti (MAXSH/RP/ERC/MINVAR-R)
convergono sulla stessa mossa: scaricare TP01 e caricare **VRP01 al 30-42%** perché ha la vol
più bassa (7.7%/anno). Ma quella è la vol del MODELLO (premio BS su DVOL, coda di stress non
catturata): è esattamente il punto cieco tipo-CC01 ("Sharpe/vol implausibile → rischio
nascosto"). E per-anno MAXSH vince **1/8 anni** vs CURRENT — l'uplift multi-cut è smoothing di
vol modellata, non alpha di allocazione. **Dentro i cap** l'ottimizzatore (MAXSH-STR) converge
quasi sui pesi correnti (38.6/25/15/21.4) e NON è persistente (0.00 al cut 2024-01) →
**i pesi correnti sono già ~ottimi per ciò che il backtest può giudicare lecitamente**.
2. **L'unico segnale robusto è la DE-CONCENTRAZIONE parameter-free**: EW-STR (EW sotto i cap,
nessun parametro fittato) vince **7/8 anni** (unica perdita 2021 3.1pp), tutti i cut OOS
(ΔSh +0.06/+0.13/+0.14), HOLD Sh 2.21→2.35, HOLD CAGR +16.0→+19.7%. Classico "1/N batte
l'ottimizzazione" (DeMiguel). La sostanza: CURRENT dà il 41% al peggior Sharpe FULL del book
(TP01 1.30) e EW-STR sposta ~11pp da TP01 verso SKH01 (+5pp) e XS01 (+6.25pp).
3. **MA non è un free lunch risk-adjusted**: FULL Sh 2.12→**2.10** e FULL DD 7.8→**8.5%**
(più SKH01, che ha DD standalone 18%, pesa di più nei primi anni a 2 sleeve). L'uplift è un
TILT di ritorno verso gli sleeve research-grade con la storia più corta/caveat più pesanti
(SKH01 = research win, book 230m da verificare a deploy; XS01 = STAT-MODE non eseguibile a 2k).
E TP01 resta l'unico sleeve *deployed* live: il suo 41% nel report riflette una scelta di
prudenza/eseguibilità, non solo statistica.
### VERDETTO (A)
- **Ottimizzazione formale: NON MIGLIORA** (viola i cap strutturali o converge ai pesi correnti).
**Per il book reale i pesi correnti reggono.**
- **Candidato PROPOSTO (non applicato): EW-STR = TP01 30 / XS01 25 / VRP01 15 / SKH01 30** —
uplift OOS persistente e per-anno (7/8), dentro i cap, al costo di FULL DD +0.7pp e FULL Sh
0.02. È una decisione di risk-appetite (più ritorno atteso spostando peso su sleeve
research-grade), non un miglioramento dominante. Se si vuole adottare, farlo consapevoli che
aumenta la dipendenza da SKH01/XS01, e NON tocca il book Deribit eseguibile (TP75/SKH25).
### Caveat metodologici (A)
- Le 3 finestre multi-cut sono ANNIDATE (tutte contengono il 2025-26): non 3 conferme
indipendenti. Il test disgiunto vero è il per-anno (7/8 per EW-STR è il dato forte).
- EW-STR è stato costruito DOPO aver visto EW vincere (proiezione del null sui cap dichiarati):
costruzione principled e parameter-free, ma va detto — non era nella griglia iniziale.
- I pesi CURRENT stessi furono fissati (2026-06-23) conoscendo l'hold-out → il confronto
"in-sample-opt vs CURRENT su hold-out" è semmai sbilanciato A FAVORE di CURRENT; EW-STR vince
comunque.
## (B) Guardia-drawdown a livello portafoglio — metodo
Soft-guard causale sul combo 4-sleeve (pesi CURRENT): esposizione del giorno D decisa dal DD
dell'equity fino a **D1**; trigger/re-entry sul **NAV ombra** non-guardato (lezione stops_lab:
sull'equity congelata a expo=0 il DD non rientra mai); isteresi re-risk a 0.4·X come nel
tail-hedge lab. Griglia X∈{3,4,5,6}%, derisk∈{0,0.5}. Selezione cella IN-SAMPLE (max Calmar
pre-2025), verifica hold-out + multi-cut, **null de-levering** (expo costante c che pareggia il
MaxDD in-sample della guardia: Sharpe di c·r è invariante → la guardia vale solo se batte quello).
### Risultati
- Cella scelta in-sample: **X=5% / derisk=0.5** (IS Calmar 3.64 vs 3.24 baseline; IS DD 7.8→6.6%,
IS CAGR 25.3→23.9%, IS Sh 2.13→2.12). In-sample batte il null de-levering c=0.84 a pari DD
(CAGR 23.9% vs 21.0%) — il timing esiste in-sample.
- **MA OOS è INERTE**: a ogni cut la cella scelta non scatta MAI (ΔSh +0.00, ΔDD 0.0pp, ΔCAGR
0.0pp su tutti e 3 i cut) perché il DD OOS del 4-sleeve resta 3.5-4.8% < trigger 5%.
**La diversificazione a 4 sleeve fa già il lavoro della guardia** (il 2022 del book è +11.6%
con DD 3.6%: non c'è il grind da proteggere che aveva il combo TP01+GTAA del tail-hedge lab,
DD 8.4% / 2022 4.4%).
- Trigger più stretti che scattano davvero COSTANO: X=3%/d=0.5 → HOLD Sh 2.10 (< 2.21), CAGR
13.7% (< 16.0%); X=3-4%/d=0 whipsaw pieno (HOLD Sh 1.83-2.09; a X=4/d=0 il DD guardato IS
sale addirittura a 8.8% > 7.8% baseline — accumulo di perdite su cicli off/on, lo stesso
fenomeno visto in stops_lab con l'uscita totale).
### VERDETTO (B)
**NON MIGLIORA.** La guardia-DD che vinceva sul combo TP01+GTAA (DD 8.4%, 2022 4.4%) qui non ha
bersaglio: il 4-sleeve ha già DD ~3.5-4.8% OOS. Coerente col thread meta-allocazione 2026-06-29
(drawdown-control ridondante). Nota operativa: la cella X=5%/d=0.5 è *innocua* (non fa nulla
finché il DD non supera il 5%) e in-sample mostrava timing genuino sopra il null de-levering —
può valere come **circuit-breaker di emergenza sul live** (risk management, non alpha), ma non è
un miglioramento statistico e non la si adotta come tale.
## (C) Combinazione
(B) è inerte → niente da combinare. Per completezza: EW-STR + guard X=5%/d=0.5 → FULL Sh 2.08,
DD 6.7%, CAGR 23.7% | HOLD invariato 2.35/3.2%/19.7% (la guardia lima il DD 2021-2023 del tilt
a costo di 0.02 Sh FULL). Nessuna adozione proposta.
## Sintesi
| config | FULL Sh | FULL DD | FULL CAGR | HOLD Sh | HOLD DD | HOLD CAGR |
|---|---|---|---|---|---|---|
| **CURRENT (attivo)** | 2.12 | 7.8% | +23.3% | 2.21 | 3.5% | +16.0% |
| EW-STR (proposta A, non applicata) | 2.10 | 8.5% | +25.2% | 2.35 | 3.2% | +19.7% |
| CURRENT + guard 5%/0.5 (B) | 2.11 | 6.6% | +22.3% | 2.21 | 3.5% | +16.0% |
| EW-STR + guard (C) | 2.08 | 6.7% | +23.7% | 2.35 | 3.2% | +19.7% |
- **(A) NON MIGLIORA in senso forte** — i pesi correnti reggono; l'unico candidato onesto è il
tilt de-concentrante EW-STR (30/25/15/30), un trade-off di risk-appetite da decidere, non un
upgrade dominante. Gli ottimizzatori in-sample o violano i cap strutturali (VRP01 40%) o
riconvergono sui pesi correnti.
- **(B) NON MIGLIORA** — guardia inerte OOS sul 4-sleeve; utile al più come circuit-breaker.
- Nessuna modifica a `src/portfolio/sleeves.py` o al live. Test 160/160 verdi.
Script: `scripts/research/r0701_portfolio_opt.py` (~4s, vettoriale).
## Verifica avversariale (agente scettico)
Script: `scripts/research/r0701_portfolio_skeptic.py`. **VERDETTO: NON APPLICARE — CURRENT resta.**
I numeri dell'agente riproducono ESATTAMENTE (via `StrategyPortfolio.combined_daily`, path di
produzione: CURRENT 2.12/2.21, EW-STR 2.10/2.35, multi-cut +0.06/+0.13/+0.14, |diff|=0). Il claim
muore altrove: l'uplift è **selezione-sull'hold-out di 2° ordine**, non alpha di allocazione.
1. **L'uplift OOS vive TUTTO nel 2025-26 — la finestra che ha selezionato i beneficiari.**
Finestre OOS **DISGIUNTE** (i 3 cut del diario sono annidati): ΔSh EW-STR vs CURRENT =
**0.12** (2024H1) / +0.06 (2024H2) / **+0.14** (2025+). L'unica finestra OOS non toccata
dalla selezione hold-out è negativa. Sharpe standalone IS→HOLD per sleeve: TP01 1.49→**0.30**,
SKH01 1.42→**1.64**, XS01 1.22→**1.51**: EW-STR compra esattamente i due sleeve ammessi/affinati
(2026-06-23 / 2026-06-19) PERCHÉ forti sull'hold-out e vende quello che lì collassa. Meccanico.
2. **Pre-2025 (finestra non contaminata) EW-STR PERDE risk-adjusted**: ΔSh aggregato **0.05**,
Sharpe per-anno vinti **2/6** (2021 0.29, 2020 0.10, 2023/2024 negativi).
3. **"7/8 anni vinti" è un artefatto di metrica**: è RITORNO composto (un tilt a più vol/ritorno
li vince quasi per costruzione), non risk-adjusted. Su **Sharpe per-anno: 3/8**. E due dei 7
"vinti" (2023, 2024) hanno margine **+0.03pp** = tie. Bilancio materiale sul ritorno:
5 vinti / 1 perso (2021 3.1pp) / 2 pari.
4. **Plateau: liscio ma il gradiente è solo "meno TP01"**; a TP fisso, alzare SKH 25→30 è
piatto/negativo (min multi-cut a TP35: +0.07→+0.02). Scomposizione: il tilt **solo-SKH**
(+5pp, TP 36.25) da solo NON è persistente (multi-cut **0.00**/+0.05/+0.04); il tilt
**solo-XS** (TP35/XS25/VRP15/SKH25) è l'unico positivo su tutte le finestre disgiunte
(+0.05/+0.02/+0.11) — ma pre-2025 fa ΔSh 0.00 (edge ≈ zero fuori dal 2025-26) e carica lo
sleeve MENO eseguibile (STAT-MODE).
5. **Realismo**: con l'outer-join rinormalizzato EW-STR tiene SKH01 al **50% effettivo nel
2019-20 e 40% nel 2021-23** (book research 230m a metà portafoglio: da lì il FULL DD 8.5%).
Punto onesto A FAVORE: un haircut d'esecuzione a drag su SKH01(-20/30%) e SKH+XS non ribalta
il ranking hold-out (ΔSh resta +0.08..+0.13), ma i livelli assoluti crollano (HOLD Sh CURRENT
2.21→1.64 al 30% su entrambi) e il beneficio resta di CARTA: il book Deribit eseguibile
(TP75/SKH25) non è toccato dalla decisione.
6. **Forking paths quantificato**: ≥15 config valutate sull'hold-out (7 vettori pesi + 8 celle
guardia), EW-STR costruito dopo aver visto EW vincere. Su **500 tilt CASUALI cap-respecting**:
51% batte CURRENT sull'hold-out (mediana ΔSh +0.00), solo 11% sul FULL, 30% su tutti e 3 i cut.
EW-STR è al **94° percentile** dell'uplift hold-out fra i tilt casuali ≈ il **massimo atteso di
~15 tentativi** (k/(k+1)=93.8%): l'uplift osservato è indistinguibile dal "best of 15 tilt
casuali scelto sull'hold-out".
**Conclusione**: il tilt non è disonesto nei conti ma non è evidenza — è la stessa lezione del gate
SELECTION-ON-HOLDOUT applicata al livello dei PESI (2° ordine: gli sleeve stessi sono stati
selezionati su quell'hold-out). Unica direzione sopravvissuta: XS01 18.75→25 (solo-XS, positivo su
tutte le finestre disgiunte), MA edge ≈0 fuori dal 2025-26, sleeve STAT-MODE non eseguibile a $600,
e l'hold-out-strength di XS01 è a sua volta concentrata dal gate di dispersione nel regime 2025-26
→ da rivalutare SOLO con più storia forward, non ora. **CURRENT (41.25/18.75/15/25) resta.**
@@ -0,0 +1,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 IVRV, 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`.
+98
View File
@@ -0,0 +1,98 @@
# 2026-07-01 — Affinamento VRP01 (sizing IVRV / 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 IVRV** (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 IVRV>0" è **già il baseline** (`gate_vrp=True`).
2. **Filtro DVOL-momentum**: non vendere se `dvol[i]dvol[ik] > 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 IVRV è 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 (IVRV) NON è un segnale di *intensità* utile oltre il suo segno+rank.
2. **Il gate TP01 è la trappola in-sample perfetta** (la lezione macro-gate al contrario): schiva il
2022 (6%→0%) gonfiando l'IS a 2.05, ma nel 2025-26 TP01 è flat proprio nei regimi di vol ricca
in cui la vendita gated di put **guadagna** — il gate taglia le settimane migliori dell'hold-out
(0.59→−0.14). Short-vol gated e trend sono complementari, non condizionabili l'uno sull'altro.
3. **Il DVOL-momentum non aggiunge protezione**: il crash-skip IV-rank>0.90 + il long wing dello
spread coprono già il run-up di vol; thr=0 è troppo aggressivo (blocca ~60%), thr=5pt è neutro
(mai meglio del baseline su hold-out).
## Onestà / caveat (invariati)
Premio MODELLATO su DVOL ATM (no skew), book 1d, f di stress reale non catturato → tutto questo
resta dentro il modello; il verdetto massimo possibile era "sleeve modellato migliorato", e non è
stato raggiunto. Il DSR sulla griglia è un limite *superiore* (celle correlate + ~20 config storiche
di options_vrp_lab/_v2 non contate).
## Azione
**Nessuna.** VRP01 resta com'è (`bin + gate_vrp + ivr30 + crash-skip 0.90`, hold-to-expiry).
Terzo tentativo di affinamento fallito (dopo active-management e — per il portafoglio — DVOL
overlay): il filone "migliorare VRP01 dentro il modello" è da considerarsi **esaurito**; il prossimo
passo utile resta quello noto: f di stress reale quando cerbero-bite cattura un crash + skew reale
sul long wing.
Script: `scripts/research/r0701_vrp_refine.py` (`--skip-portfolio` per la sola griglia).
@@ -0,0 +1,74 @@
# 2026-07-01 — Residual (idiosyncratic) momentum cross-sectional sui 19 major HL — SCARTATO/REDUNDANT
**Tesi.** STATARB-RESID (thread 4, 2026-06-29) ha mostrato che il momentum del residuo ETH−β·BTC
(β OLS rolling, **sgn=+1**: le dislocazioni continuano a 1d) passa quasi tutti i gate su 2 gambe,
fallendo solo il deflated-Sharpe (0.929<0.95). Ipotesi qui: portare lo **stesso meccanismo
cross-sectional** sui 19 major di XS01 — per ogni alt, residuo vs β·BTC (β OLS rolling B), momentum
del residuo (blend z-score [30,90] come XS01 o singolo L), long top-k / short bottom-k, vol-target
20% — sperando che la **breadth** (18 stream invece di 1) alzasse il DSR dove il 2-gambe falliva.
Script `scripts/research/r0701_xs_residmom.py` (riusa l'harness collaudato `xsec_v2_nonmom`:
load_matrix, xs_engine — uguaglianza verificata maxdiff 0 —, deflated_sharpe, StrategyPortfolio).
Distinzione dal già-testato: IREV (idio-**reversal**, sgn=1) era FALLITO nel filone C; IMOM di
xsec_v2 usava il mercato equal-weight come fattore, B=60 fisso, senza blend ed era solo riferimento.
Qui fattore = **BTC** (come STATARB-RESID, BTC escluso dal cross-section), B in griglia, blend
z-score, probe con gate di dispersione p30 (parità strutturale con XS01), **selezione in-sample-only**.
## Setup
- Universo: 19 major HL certificati (`data/raw/hl_*_1d.parquet`), 913 giorni [2024-01-01 → 2026-07-01],
barre vol=0 escluse. Fee 0.10% RT/gamba + sweep fino a 0.30%.
- Griglia (57 trial): β-window {60,90,120} × lookback {blend[30,90], 30, 90} × k {3,5} × H {5,10,20}
+ 3 probe gated (blend, k5, H10, gate dispersione p30). Cella scelta col solo Sharpe **pre-2025**;
hold-out 2025-01-01 bloccato. Causalità: prefix-check ok (max_tail_diff 0).
- Baseline (stessa finestra, sleeve attivi): XS01 FULL Sh **1.38** / HOLD **1.51** / DD 11%;
book 4-sleeve FULL 2.12 / HOLD 2.21.
## Esito — collasso OOS della cella in-sample e nessuna cella competitiva
Cella scelta in-sample: **B60 L30 k3 H20** (IS Sh 2.00):
```
FULL +0.67 | HOLD 0.24 | DD 35% | per-anno 2024 +54% / 2025 15% / 2026 +5%
corr→XS01 +0.54 | corr→TP01 0.09 | DSR 0.510 (57 trial) | fee-sweep ok (F+0.59 a 0.30%)
small-cap $600/min$5: haircut ≈ 0 (ma book multi-gamba → STAT-MODE comunque, come XS01)
```
Portafoglio (il bar del mandato):
```
BASE 4-sleeve FULL 2.12 DD 7.8% | HOLD 2.21 DD 3.5%
SOSTITUZIONE XS01 → cand FULL 2.01 (0.10) | HOLD 1.18 (1.03) ← distrugge l'hold-out
AGGIUNTA 5° sleeve @10/15% HOLD 0.31 / 0.53 ← diluisce
MARGINAL vs BOOK: corr +0.23, uplift@10% full 0.04 / hold 0.31, multi-cut 2025 0.31 / 2026 0.14
```
**Il verdetto non dipende dalla selezione** — aggregato delle 57 celle:
- FULL max **+0.94**, HOLD max **+0.82** (mediane +0.52/+0.33): *nessuna* cella si avvicina a XS01
(1.38/1.51), nemmeno cherry-pickando sull'hold-out.
- corr→XS01 in [+0.47, +0.71], mediana **+0.62**: è momentum XS01 travestito (la residualizzazione
vs β·BTC sposta poco: lo z-scoring cross-sectional di XS01 già rimuove implicitamente il mercato).
- Solo 4/57 celle passerebbero un gate "diversificatore" (|corrXS|<0.6, FULL>0.5, HOLD>0), tutte con
FULL ≤0.68 e IS incoerente.
- **corr(Sharpe in-sample, Sharpe hold-out) attraverso le celle = 0.37**: l'in-sample è
anti-predittivo dell'OOS su questa griglia — la firma del rumore, non di un edge con plateau.
## Perché la tesi è falsa (3 righe)
1. La breadth non ha alzato il DSR (0.51 ≪ 0.95) perché non c'è edge da deflazionare: il residuo
vs β·BTC sul cross-section degli alt NON eredita l'edge del 2-gambe ETH−β·BTC — quel lead vive
nella coppia specifica ETH/BTC (i due asset profondi/cointegrati), non nel long-tail degli alt.
2. Cross-sectional, la residualizzazione è quasi un no-op: demeaning z-score (XS01) ≈ togliere il
mercato → il candidato è una variante rumorosa di XS01 (corrXS mediana 0.62) con meno segnale.
3. In-sample = solo 2024 (~1 anno utile dopo il warmup): la selezione onesta atterra su una cella
2024-lucky che perde nel 2025-26 — l'ennesima conferma del gate selection-on-holdout.
## Verdetto: **SCARTATO/REDUNDANT.** Nessuno sleeve, nessun forward-monitor.
Coerente con il filone momentum-structure (2026-06-29): l'edge di XS01 sta nella sua struttura
specifica (blend + gate dispersione), e le sue varianti — incluso l'idio-momentum vs BTC — sono
REDUNDANT o peggio. Chiude anche il cerchio su STATARB-RESID: il meccanismo residuo-momentum resta
valido SOLO come coppia ETH/BTC (forward-monitor `paper_statarb`), non generalizza al cross-section.
Nessun impatto sul book live; config canonica invariata.
Script: `scripts/research/r0701_xs_residmom.py` (non committato, prefisso r0701_xs).
+69
View File
@@ -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.160.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).
+383
View File
@@ -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()
+387
View File
@@ -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()
+472
View File
@@ -0,0 +1,472 @@
"""r0701_portfolio_opt — MIGLIORARE IL PORTAFOGLIO ATTIVO SENZA NUOVE STRATEGIE (2026-07-01).
(A) RI-OTTIMIZZAZIONE DEI PESI STATICI dei 4 sleeve (TP01/XS01/VRP01/SKH01).
I pesi correnti (41.25/18.75/15/25) sono ad-hoc. Test onesto:
- ottimizza SOLO in-sample (pre-cut), valuta OOS (post-cut), multi-cut {2024-01, 2024-07, 2025-01};
- criteri: MAXSH (max-Sharpe simulato), RP (inverse-vol), ERC (equal-risk-contribution),
MINVAR-R (min-varianza con vincolo di ritorno >= ritorno dei pesi correnti in-sample);
- null di riferimento: EW (25x4) e CURRENT (pesi correnti);
- vincoli: 0.05 <= w_i <= 0.60, sum(w)=1;
- sleeve con < MIN_IS_DAYS giorni in-sample al cut (XS01 al 2024-01) -> PINNED al peso corrente
(l'ottimizzatore non ha dati per giudicarli), gli altri ottimizzati nel budget residuo;
- il combine replica ESATTAMENTE src/portfolio/portfolio.combined_daily (outer-join,
pesi rinormalizzati per-riga sugli sleeve attivi).
Verdetto: MIGLIORA solo se l'uplift OOS vs CURRENT e' PERSISTENTE (positivo a ogni cut),
non su una finestra sola.
(B) GUARDIA-DRAWDOWN a livello portafoglio (replica del soft-guard vincente del tail-hedge lab
2026-06-23, portato sul 4-sleeve). CAUSALE: l'esposizione del giorno D usa l'equity fino a D-1.
Trigger/re-entry sul NAV OMBRA (equity non-guardata: lezione stops_lab — sull'equity congelata
a expo=0 il DD non rientra mai). Isteresi: de-risk quando DD>X, re-risk quando DD<0.4*X.
Griglia modesta X in {3,4,5,6}%, derisk in {0, 0.5}. Selezione cella IN-SAMPLE (pre-cut,
criterio Calmar = CAGR/MaxDD), verifica OOS multi-cut.
NULL DECISIVO (lezione tp01-dvol-overlay): esposizione COSTANTE c calibrata in-sample per
pareggiare il MaxDD della guardia — lo Sharpe di c*r e' INVARIANTE, quindi la guardia "vale"
solo se a pari MaxDD trattiene piu' CAGR/Sharpe del semplice de-levering.
(C) Combinazione A+B se entrambe reggono.
NB (contesto): il thread meta-allocazione (2026-06-29) ha gia' mostrato che l'allocazione DINAMICA
fra sleeve perde vs pesi fissi — qui NON la rifacciamo: (A) e' statico, (B) e' un overlay di
esposizione aggregata, non riallocazione fra sleeve.
Solo ricerca: NON tocca src/ ne' scripts/live/. Propone pesi, non li applica.
uv run python scripts/research/r0701_portfolio_opt.py
"""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
import numpy as np
import pandas as pd
from scipy.optimize import minimize
from src.portfolio.sleeves import active_sleeves
from src.portfolio.portfolio import HOLDOUT, DAYS_PER_YEAR
CUTS = [pd.Timestamp(c, tz="UTC") for c in ("2024-01-01", "2024-07-01", "2025-01-01")]
LO_W, HI_W = 0.05, 0.60 # vincoli pesi
MIN_IS_DAYS = 120 # sotto -> sleeve PINNED al peso corrente (niente dati per giudicarlo)
GUARD_TRIGGERS = (0.03, 0.04, 0.05, 0.06)
GUARD_DERISK = (0.0, 0.5)
GUARD_REC_FRAC = 0.4 # re-risk quando DD < 0.4*trigger (isteresi, come tail-hedge lab)
# --------------------------------------------------------------------------- dati
def load_matrix() -> tuple[pd.DataFrame, list[str], np.ndarray]:
"""Matrice daily OUTER-join dei 4 sleeve (NaN dove lo sleeve non e' ancora partito)."""
sl = active_sleeves()
names = [s.name for s in sl]
w_cur = np.array([s.weight for s in sl], float)
w_cur = w_cur / w_cur.sum()
J = pd.concat({s.name: s.daily() for s in sl}, axis=1, join="outer").sort_index()
J = J[J.notna().any(axis=1)]
return J, names, w_cur
def combo(J: pd.DataFrame, w: np.ndarray) -> pd.Series:
"""Replica ESATTA di portfolio.combined_daily: pesi rinormalizzati per-riga sugli attivi."""
active = J.notna().values * w
rowsum = active.sum(axis=1, keepdims=True)
wnorm = np.divide(active, rowsum, out=np.zeros_like(active), where=rowsum > 0)
return pd.Series(np.nansum(np.nan_to_num(J.values) * wnorm, axis=1), index=J.index)
def met(r: pd.Series | np.ndarray) -> dict:
v = np.asarray(pd.Series(r).dropna().values, float)
if len(v) < 20 or v.std() == 0:
return dict(sh=0.0, cagr=0.0, dd=0.0, calmar=0.0, n=len(v))
eq = np.cumprod(1 + v); pk = np.maximum.accumulate(eq)
yrs = len(v) / DAYS_PER_YEAR
cagr = float(eq[-1] ** (1 / yrs) - 1) if eq[-1] > 0 else -1.0
dd = float(np.max((pk - eq) / pk))
return dict(sh=float(v.mean() / v.std() * np.sqrt(DAYS_PER_YEAR)), cagr=cagr, dd=dd,
calmar=(cagr / dd if dd > 0 else 0.0), n=len(v))
def fmt_w(names, w):
return " ".join(f"{n.split('_')[0]} {x*100:.1f}%" for n, x in zip(names, w))
# --------------------------------------------------------------------------- (A) ottimizzatori
def _pinned_mask(J_is: pd.DataFrame, w_cur: np.ndarray) -> np.ndarray:
"""True = sleeve senza abbastanza storia in-sample -> peso fissato al corrente."""
return np.array([J_is[c].notna().sum() < MIN_IS_DAYS for c in J_is.columns])
def _solve(J_is, w_cur, objective, extra_cons=None, hi_bounds=None) -> np.ndarray:
"""SLSQP su pesi liberi (i pinned restano al corrente), bounds+somma, multi-start.
hi_bounds: array di upper-bound per-sleeve (default HI_W) — per i cap STRUTTURALI."""
k = len(w_cur)
hi = np.full(k, HI_W) if hi_bounds is None else np.asarray(hi_bounds, float)
pin = _pinned_mask(J_is, w_cur)
free = ~pin
budget = 1.0 - w_cur[pin].sum()
nf = int(free.sum())
def full_w(wf):
w = w_cur.copy(); w[free] = wf
return w
cons = [dict(type="eq", fun=lambda wf: wf.sum() - budget)]
if extra_cons:
cons += [dict(type="ineq", fun=lambda wf, f=f: f(full_w(wf))) for f in extra_cons]
bounds = [(LO_W, float(h)) for h in hi[free]]
hif = hi[free]
starts = [w_cur[free], np.full(nf, budget / nf)]
rng = np.random.default_rng(7)
for _ in range(4):
x = rng.uniform(LO_W, hif); starts.append(x / x.sum() * budget)
best, bval = w_cur.copy(), np.inf
for x0 in starts:
x0 = np.clip(x0, LO_W, hif); x0 = x0 / x0.sum() * budget
res = minimize(lambda wf: objective(full_w(wf)), x0, method="SLSQP",
bounds=bounds, constraints=cons,
options=dict(maxiter=300, ftol=1e-10))
if res.success and res.fun < bval:
bval, best = res.fun, full_w(res.x)
best = np.clip(best, LO_W, hi)
return best / best.sum()
def opt_maxsh(J_is, w_cur):
return _solve(J_is, w_cur, lambda w: -met(combo(J_is, w))["sh"])
def opt_minvar_ret(J_is, w_cur):
"""Min-varianza con vincolo: media in-sample >= media dei pesi correnti in-sample."""
mu_cur = float(combo(J_is, w_cur).mean())
return _solve(J_is, w_cur, lambda w: float(combo(J_is, w).var()),
extra_cons=[lambda w: float(combo(J_is, w).mean()) - mu_cur])
def opt_rp(J_is, w_cur):
"""Inverse-vol (risk-parity naive) sulla vol in-sample di ogni sleeve (giorni attivi propri)."""
pin = _pinned_mask(J_is, w_cur); free = ~pin
vol = np.array([J_is[c].dropna().std() for c in J_is.columns])
w = w_cur.copy()
iv = 1.0 / np.where(vol[free] > 0, vol[free], np.inf)
w[free] = iv / iv.sum() * (1.0 - w_cur[pin].sum())
for _ in range(50): # clip ai bounds + rinormalizza
w = np.clip(w, LO_W, HI_W)
if abs(w.sum() - 1.0) < 1e-9:
break
adj = ~np.isclose(w, LO_W) & ~np.isclose(w, HI_W)
if not adj.any():
break
w[adj] += (1.0 - w.sum()) * (w[adj] / w[adj].sum())
return w / w.sum()
def opt_erc(J_is, w_cur):
"""Equal-risk-contribution sulla cov in-sample pairwise-complete (submatrice dei non-pinned)."""
pin = _pinned_mask(J_is, w_cur)
cov = J_is.cov().values # pairwise-complete
idx = np.where(~pin)[0]
S = cov[np.ix_(idx, idx)]
S = np.nan_to_num(S, nan=0.0)
def obj(w):
wf = w[idx]
port = wf @ S @ wf
if port <= 0:
return 1e6
rc = wf * (S @ wf) / port
return float(np.sum((rc - 1.0 / len(idx)) ** 2))
return _solve(J_is, w_cur, obj)
def _struct_caps(J_is) -> np.ndarray:
"""Cap STRUTTURALI (prudenza, non statistica): VRP01 <= 15% (sleeve MODELLATO su DVOL ATM,
stress-f non catturato — 'niente short-vol da modello in deploy'), XS01 <= 25% (STAT-MODE,
storia ~2.5 anni). Gli ottimizzatori naive caricano VRP01 (vol 7.7% ma e' la vol del MODELLO)."""
return np.array([0.15 if c.startswith("VRP") else (0.25 if c.startswith("XS") else HI_W)
for c in J_is.columns])
def opt_maxsh_struct(J_is, w_cur):
return _solve(J_is, w_cur, lambda w: -met(combo(J_is, w))["sh"], hi_bounds=_struct_caps(J_is))
CRITERIA = [("MAXSH", opt_maxsh), ("RP-invvol", opt_rp), ("ERC", opt_erc), ("MINVAR-R", opt_minvar_ret),
("MAXSH-STR", opt_maxsh_struct)]
# --------------------------------------------------------------------------- (B) guardia-DD
def dd_guard(r: pd.Series, trigger: float, derisk: float, rec_frac: float = GUARD_REC_FRAC):
"""Soft-guard causale: expo[t] decisa dal DD del NAV OMBRA (non-guardato) fino a t-1.
DD>trigger -> expo=derisk; DD<rec_frac*trigger -> expo=1 (isteresi). Ritorna (serie, expo)."""
v = r.values
eq = np.cumprod(1 + v); pk = np.maximum.accumulate(eq)
n = len(v); expo = np.ones(n); on = True
for i in range(1, n):
ddi = (pk[i - 1] - eq[i - 1]) / pk[i - 1]
if ddi > trigger:
on = False
elif ddi < trigger * rec_frac:
on = True
expo[i] = 1.0 if on else derisk
return pd.Series(expo * v, index=r.index), expo
def const_expo_match_dd(r: pd.Series, target_dd: float) -> float:
"""Esposizione costante c (in-sample) che pareggia il MaxDD target — il null de-levering."""
lo, hi = 0.05, 1.0
if met(r)["dd"] <= target_dd:
return 1.0
for _ in range(40):
mid = 0.5 * (lo + hi)
if met(pd.Series(mid * r.values, index=r.index))["dd"] > target_dd:
hi = mid
else:
lo = mid
return lo
# --------------------------------------------------------------------------- report
def section_A(J, names, w_cur):
print("\n" + "=" * 108)
print(" (A) RI-OTTIMIZZAZIONE PESI STATICI — ottimizza IN-SAMPLE (pre-cut), valuta OOS (post-cut), multi-cut")
print("=" * 108)
# correlazioni e vol per contesto (full, pairwise)
C = J.corr()
print("\n corr pairwise (full):")
for i, a in enumerate(names):
print(" " + a.split("_")[0].ljust(6) + " ".join(f"{C.iloc[i, j]:+.2f}" for j in range(len(names))))
print(" vol annua per-sleeve (full): " +
" ".join(f"{n.split('_')[0]} {J[n].dropna().std()*np.sqrt(DAYS_PER_YEAR)*100:.1f}%" for n in names))
candidates = {} # nome -> pesi ottimizzati su pre-HOLDOUT (headline)
J_is_main = J[J.index < HOLDOUT]
for cname, fn in CRITERIA:
candidates[cname] = fn(J_is_main, w_cur)
candidates["EW"] = np.full(len(names), 1.0 / len(names))
caps = _struct_caps(J)
w_ewstr = np.full(len(names), 1.0 / len(names)) # EW sotto i cap strutturali (null senza fit)
for _ in range(20):
over = w_ewstr > caps + 1e-12
if not over.any():
break
excess = float((w_ewstr[over] - caps[over]).sum())
w_ewstr[over] = caps[over]
room = w_ewstr < caps - 1e-12 # redistribuisci SOLO a chi ha spazio
w_ewstr[room] += excess / max(int(room.sum()), 1)
candidates["EW-STR"] = w_ewstr
candidates["CURRENT"] = w_cur
print(f"\n PESI (ottimizzati su in-sample pre-{HOLDOUT.date()}):")
for cname, w in candidates.items():
print(f" {cname:<10s} {fmt_w(names, w)}")
# headline: FULL + HOLD-OUT
print(f"\n {'criterio':<10s} {'FULL Sh':>8s} {'FULL DD':>8s} {'FULL CAGR':>9s} | {'HOLD Sh':>8s} {'HOLD DD':>8s} {'HOLD CAGR':>9s}")
rows = {}
for cname, w in candidates.items():
f = met(combo(J, w)); h = met(combo(J[J.index >= HOLDOUT], w))
rows[cname] = (f, h)
print(f" {cname:<10s} {f['sh']:>8.2f} {f['dd']*100:>7.1f}% {f['cagr']*100:>+8.1f}% |"
f" {h['sh']:>8.2f} {h['dd']*100:>7.1f}% {h['cagr']*100:>+8.1f}%")
# multi-cut: ottimizza su <cut, valuta su >=cut, uplift vs CURRENT sulla stessa finestra OOS
print("\n MULTI-CUT (pesi ri-ottimizzati a ogni cut sul solo pre-cut; ΔSh OOS vs CURRENT):")
header = f" {'criterio':<10s}" + "".join(f" | {c.date()} Sh_oos ΔSh " for c in CUTS)
print(header)
persist = {}
fixed = {"EW": candidates["EW"], "EW-STR": candidates["EW-STR"]}
for cname, fn in CRITERIA + [("EW", None), ("EW-STR", None)]:
cells = []
ok = True
for cut in CUTS:
J_is, J_oos = J[J.index < cut], J[J.index >= cut]
w = fixed[cname] if cname in fixed else fn(J_is, w_cur)
sh_o = met(combo(J_oos, w))["sh"]
sh_c = met(combo(J_oos, w_cur))["sh"]
d = sh_o - sh_c
ok &= d > 0
cells.append(f" | {sh_o:>10.2f} {d:>+5.2f}")
persist[cname] = ok
print(f" {cname:<10s}" + "".join(cells) + (" <-- PERSISTENTE" if ok else ""))
print(" ⚠ caveat: le 3 finestre OOS sono ANNIDATE (tutte contengono il 2025-26) — non 3 conferme")
print(" indipendenti. Il test disgiunto vero e' il per-anno qui sotto.")
# per-anno (finestre DISGIUNTE): ritorno annuo del candidato vs CURRENT, pesi fissi pre-2025
print("\n PER-ANNO (pesi headline fissi; Δret vs CURRENT, finestre disgiunte):")
sel = ["MAXSH", "MINVAR-R", "MAXSH-STR", "EW", "EW-STR"]
years = sorted(set(J.index.year))
print(" " + "anno".ljust(6) + "".join(f"{c:>12s}" for c in ["CURRENT"] + sel))
wins = {c: 0 for c in sel}
for y in years:
Jy = J[J.index.year == y]
base_ret = float(np.prod(1 + combo(Jy, w_cur).values) - 1)
row = f" {y} {base_ret*100:>+10.1f}%"
for c in sel:
ry = float(np.prod(1 + combo(Jy, candidates[c]).values) - 1)
wins[c] += ry > base_ret
row += f"{(ry-base_ret)*100:>+11.1f}p"
print(row)
print(" anni vinti vs CURRENT: " + " ".join(f"{c} {wins[c]}/{len(years)}" for c in sel))
# verdetto: la persistenza statistica NON basta — i pesi devono rispettare i cap STRUTTURALI
# (VRP01<=15% perche' MODELLATO, XS01<=25% perche' STAT-MODE/storia corta), altrimenti l'uplift
# sta comprando rischio-modello che il backtest non vede (punto cieco tipo CC01).
winners = [c for c, ok in persist.items() if ok]
respects = {c: bool(np.all(candidates[c] <= caps + 1e-3)) for c in candidates} # tol 0.1pp (jitter clip/renorm)
struct_winners = [c for c in winners if respects[c]]
print("\n cap strutturali: " + fmt_w(names, caps))
if winners:
print(" persistenti: " + ", ".join(f"{c} ({'dentro i cap' if respects[c] else 'VIOLA i cap'})"
for c in winners))
if struct_winners:
wbest = candidates[struct_winners[0]]
fb, fc = met(combo(J, wbest)), met(combo(J, w_cur))
hb, hc = met(combo(J[J.index >= HOLDOUT], wbest)), met(combo(J[J.index >= HOLDOUT], w_cur))
verdict = (f"MIGLIORA (OOS) — {struct_winners} persistente E dentro i cap strutturali. "
f"Pesi candidati ({struct_winners[0]}): {fmt_w(names, wbest)}.\n"
f" TRADE-OFF onesto: HOLD Sh {hc['sh']:.2f}->{hb['sh']:.2f}, HOLD CAGR "
f"{hc['cagr']*100:+.1f}%->{hb['cagr']*100:+.1f}%, MA FULL Sh {fc['sh']:.2f}->{fb['sh']:.2f} "
f"e FULL DD {fc['dd']*100:.1f}%->{fb['dd']*100:.1f}% — l'uplift e' un TILT di ritorno verso "
f"gli sleeve research-grade (piu' SKH01/XS01, meno TP01), non un free lunch risk-adjusted. "
f"NB: nessun OTTIMIZZATORE aggiunge nulla oltre questo null parameter-free "
f"(MAXSH in-sample vince 1/8 anni: l'ottimizzazione qui overfitta, la de-concentrazione no).")
elif winners:
verdict = ("NON ADOTTABILE — l'uplift persistente esiste SOLO violando i cap strutturali "
"(tutti i vincitori caricano VRP01>15%, sleeve MODELLATO il cui rischio di coda "
"il backtest non cattura). Dentro i cap l'ottimizzatore converge ~ai pesi correnti "
"e non e' persistente -> per il book reale I PESI CORRENTI REGGONO.")
else:
verdict = "NON MIGLIORA — nessun criterio batte i pesi CORRENTI in modo persistente. I pesi correnti reggono."
print("\n VERDETTO (A): " + verdict)
return candidates, persist, struct_winners
def section_B(J, names, w_cur, label="CURRENT"):
print("\n" + "=" * 108)
print(f" (B) GUARDIA-DRAWDOWN a livello portafoglio (pesi {label}) — trigger X, de-risk d, isteresi 0.4X")
print("=" * 108)
r_full = combo(J, w_cur)
# griglia completa su FULL/IS/HOLD per trasparenza (selezione = solo in-sample)
r_is = r_full[r_full.index < HOLDOUT]
r_ho = r_full[r_full.index >= HOLDOUT]
b_is, b_ho, b_f = met(r_is), met(r_ho), met(r_full)
print(f"\n baseline IS Sh {b_is['sh']:.2f} DD {b_is['dd']*100:.1f}% CAGR {b_is['cagr']*100:+.1f}%"
f" | HOLD Sh {b_ho['sh']:.2f} DD {b_ho['dd']*100:.1f}% CAGR {b_ho['cagr']*100:+.1f}%"
f" | FULL Sh {b_f['sh']:.2f} DD {b_f['dd']*100:.1f}%")
print(f"\n {'cella':<16s} {'IS Sh':>6s} {'IS DD':>6s} {'IS CAGR':>8s} {'IS Calmar':>9s} {'%off':>5s} |"
f" {'HOLD Sh':>7s} {'HOLD DD':>7s} {'HOLD CAGR':>9s}")
grid = {}
for trig in GUARD_TRIGGERS:
for dr in GUARD_DERISK:
g_full, expo = dd_guard(r_full, trig, dr)
g_is = g_full[g_full.index < HOLDOUT]; g_ho = g_full[g_full.index >= HOLDOUT]
mi, mh = met(g_is), met(g_ho)
off = float((expo[: len(r_is)] < 1.0).mean())
grid[(trig, dr)] = dict(is_=mi, ho=mh, full=met(g_full))
print(f" X={trig*100:.0f}% d={dr:<4.1f} {mi['sh']:>6.2f} {mi['dd']*100:>5.1f}% {mi['cagr']*100:>+7.1f}%"
f" {mi['calmar']:>9.2f} {off*100:>4.0f}% | {mh['sh']:>7.2f} {mh['dd']*100:>6.1f}% {mh['cagr']*100:>+8.1f}%")
# selezione IN-SAMPLE (Calmar), poi null de-levering a pari MaxDD in-sample
best_cell = max(grid, key=lambda k: grid[k]["is_"]["calmar"])
trig, dr = best_cell
print(f"\n cella selezionata IN-SAMPLE (max Calmar): X={trig*100:.0f}% derisk={dr}")
tgt_dd = grid[best_cell]["is_"]["dd"]
c = const_expo_match_dd(r_is, tgt_dd)
cnull_full = pd.Series(c * r_full.values, index=r_full.index)
n_is, n_ho = met(cnull_full[cnull_full.index < HOLDOUT]), met(cnull_full[cnull_full.index >= HOLDOUT])
g = grid[best_cell]
print(f" NULL de-levering: expo costante c={c:.2f} pareggia il MaxDD IS della guardia ({tgt_dd*100:.1f}%)")
print(f" {'':<14s} {'IS Sh':>6s} {'IS CAGR':>8s} | {'HOLD Sh':>7s} {'HOLD DD':>7s} {'HOLD CAGR':>9s}")
print(f" guardia {g['is_']['sh']:>6.2f} {g['is_']['cagr']*100:>+7.1f}% | {g['ho']['sh']:>7.2f}"
f" {g['ho']['dd']*100:>6.1f}% {g['ho']['cagr']*100:>+8.1f}%")
print(f" const c={c:.2f} {n_is['sh']:>6.2f} {n_is['cagr']*100:>+7.1f}% | {n_ho['sh']:>7.2f}"
f" {n_ho['dd']*100:>6.1f}% {n_ho['cagr']*100:>+8.1f}%")
# multi-cut: selezione sul pre-cut, valutazione OOS post-cut (guardia vs baseline vs null)
inert = True
print("\n MULTI-CUT (cella riscelta a ogni cut sul solo pre-cut; OOS post-cut):")
print(f" {'cut':<12s} {'cella':<14s} {'OOS Sh(g)':>9s} {'OOS Sh(b)':>9s} {'ΔSh':>6s} {'OOS DD(g)':>9s}"
f" {'OOS DD(b)':>9s} {'ΔCAGR':>7s} {'null c: ΔSh':>11s}")
persist_sh, persist_dd = True, True
for cut in CUTS:
r_pre = r_full[r_full.index < cut]
cells = {}
for t in GUARD_TRIGGERS:
for d in GUARD_DERISK:
gpre, _ = dd_guard(r_pre, t, d)
cells[(t, d)] = met(gpre)["calmar"]
(t_, d_) = max(cells, key=cells.get)
g_full, _ = dd_guard(r_full, t_, d_) # guard sul path completo (stato causale)
g_oos = g_full[g_full.index >= cut]
b_oos = r_full[r_full.index >= cut]
gm, bm = met(g_oos), met(b_oos)
cc = const_expo_match_dd(r_pre, met(dd_guard(r_pre, t_, d_)[0])["dd"])
nm = met(pd.Series(cc * b_oos.values, index=b_oos.index))
dsh = gm["sh"] - bm["sh"]; ddd = gm["dd"] - bm["dd"]; dcagr = gm["cagr"] - bm["cagr"]
persist_sh &= dsh >= 0; persist_dd &= ddd < 0
inert &= abs(dsh) < 0.005 and abs(ddd) < 1e-4
print(f" {str(cut.date()):<12s} X={t_*100:.0f}% d={d_:<6.1f} {gm['sh']:>9.2f} {bm['sh']:>9.2f} {dsh:>+6.2f}"
f" {gm['dd']*100:>8.1f}% {bm['dd']*100:>8.1f}% {dcagr*100:>+6.1f}pp {gm['sh']-nm['sh']:>+11.2f}")
if inert:
verdict = ("NON MIGLIORA — INERTE OOS: la cella scelta in-sample non scatta MAI post-cut "
"(il DD OOS del 4-sleeve resta sotto il trigger: la diversificazione fa gia' il lavoro "
"della guardia). Trigger piu' stretti (X=3-4%) scattano ma COSTANO Sharpe su HOLD. "
"Coerente col thread meta-allocazione 2026-06-29 (drawdown-control ridondante).")
elif persist_sh and persist_dd:
verdict = f"MIGLIORA — la guardia X={trig*100:.0f}%/d={dr} taglia il DD OOS senza costo Sharpe persistente"
elif persist_dd:
verdict = ("TAGLIA IL DD MA COSTA — DD ridotto a ogni cut ma Sharpe/CAGR inferiori: "
"trade-off assicurativo, confrontare col null de-levering prima di adottarla")
else:
verdict = "NON MIGLIORA — la guardia non riduce il DD OOS in modo persistente (o e' solo de-levering)"
print("\n VERDETTO (B): " + verdict)
return grid, best_cell
def section_C(J, names, w_cur, candidates, struct_winners, grid, best_cell):
print("\n" + "=" * 108)
print(" (C) COMBINAZIONE A+B")
print("=" * 108)
wA = candidates[struct_winners[0]] if struct_winners else w_cur
labA = struct_winners[0] if struct_winners else "CURRENT (A non adottabile)"
trig, dr = best_cell
r = combo(J, wA)
g, _ = dd_guard(r, trig, dr)
for lbl, s in [("baseline CURRENT", combo(J, w_cur)),
(f"pesi {labA}", r),
(f"pesi {labA} + guard X={trig*100:.0f}%/d={dr}", g)]:
f = met(s); h = met(s[s.index >= HOLDOUT])
print(f" {lbl:<52s} FULL Sh {f['sh']:>5.2f} DD {f['dd']*100:>4.1f}% CAGR {f['cagr']*100:>+5.1f}%"
f" | HOLD Sh {h['sh']:>5.2f} DD {h['dd']*100:>4.1f}% CAGR {h['cagr']*100:>+5.1f}%")
if not struct_winners:
print(" -> (A) non adottabile e (B) inerte OOS: la combinazione non ha una cella da proporre.")
def main():
print("=" * 108)
print(" r0701_portfolio_opt — pesi statici + guardia-DD sul portafoglio attivo a 4 sleeve")
print(f" hold-out bloccato {HOLDOUT.date()}+ | vincoli pesi [{LO_W},{HI_W}] | cuts {[str(c.date()) for c in CUTS]}")
print("=" * 108)
J, names, w_cur = load_matrix()
print(f"\n sleeve: {names}")
print(f" pesi CORRENTI: {fmt_w(names, w_cur)}")
print(f" finestra: {J.index[0].date()} -> {J.index[-1].date()} ({len(J)} giorni)")
for n in names:
s = J[n].dropna()
print(f" {n:<16s} dal {s.index[0].date()} ({len(s)} giorni)")
b_f = met(combo(J, w_cur)); b_h = met(combo(J[J.index >= HOLDOUT], w_cur))
print(f"\n BASELINE (pesi correnti): FULL Sh {b_f['sh']:.2f} DD {b_f['dd']*100:.1f}% CAGR {b_f['cagr']*100:+.1f}%"
f" | HOLD Sh {b_h['sh']:.2f} DD {b_h['dd']*100:.1f}% CAGR {b_h['cagr']*100:+.1f}%")
candidates, persistA, struct_winners = section_A(J, names, w_cur)
grid, best_cell = section_B(J, names, w_cur)
section_C(J, names, w_cur, candidates, struct_winners, grid, best_cell)
if __name__ == "__main__":
main()
+304
View File
@@ -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()
+369
View File
@@ -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()
+439
View File
@@ -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()
+295
View File
@@ -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()
+90 -14
View File
@@ -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) 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 : 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: class StrategyPortfolio:
def __init__(self, sleeves: list[Sleeve], capital: float = 2000.0): def __init__(self, sleeves: list[Sleeve], capital: float = 2000.0):
if not sleeves: if not sleeves:
@@ -124,20 +212,8 @@ class StrategyPortfolio:
(es. TP01 dal 2019, uno nuovo dal 2024) -> ogni giorno i pesi sono RINORMALIZZATI (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 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.""" storia). Cosi' non si tronca il portafoglio alla finestra comune."""
w = self.weights() return combine_outer({s.name: s.daily() for s in self.sleeves}, self.weights(),
cols = {s.name: s.daily() for s in self.sleeves} lo=lo, hi=hi)
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
def backtest(self) -> dict: def backtest(self) -> dict:
full = self.combined_daily() full = self.combined_daily()
+82
View File
@@ -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"])