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:
@@ -159,6 +159,29 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis
|
||||
l'edge è *deployabile* (2 gambe BTC/ETH perp). Altri LEAD: IVOL (idio-vol XS, STAT-MODE), DVOLSPREAD
|
||||
(storia DVOL corta). Diario `2026-06-29-strategy-search-5threads.md`. Script
|
||||
`scripts/research/{xsec_v3_lowrisk,xsec_v3_momstruct,meta_allocation,orthogonal_signals,signal_inout_1leg}.py`.
|
||||
- **Ondata 2026-07-01 (6 filoni multi-agente + scettico) — 0 nuovi sleeve, pesi CURRENT confermati,
|
||||
1 gate nuovo.** Filoni su angoli non coperti dalle ondate precedenti: (1) **funding time-series**
|
||||
BTC/ETH (posizionamento) = SCARTATO — FOLLOW è trend-beta ritardato, FADE shorta il toro, il gate è
|
||||
TP01 travestito (DSR 0.215); **il filone funding è chiuso su 3 lati** (FC01 carry, price-clock, TS).
|
||||
(2) **breadth/internals alt** (51 HL) = SCARTATO ma unico NON-ridondante col trend (corr→TP01 0.40);
|
||||
muore su jackknife (uplift su 1 mese) + DSR 0.433 con ~8 mesi IS → **rivisitabile tra 1-2 anni di
|
||||
storia HL nativa**. (3) **residual momentum XS** (β-hedged, 19 major) = REDUNDANT — cross-section la
|
||||
residualizzazione è un no-op (lo z-score di XS01 rimuove già il mercato); l'edge resta solo nella
|
||||
coppia ETH/BTC (STATARB-RESID). (4) **ri-ottimizzazione pesi + guardia-DD**: il candidato EW-STR
|
||||
(TP30/XS25/VRP15/SKH30, HOLD 2.21→2.35) **refutato dallo scettico come selezione-sull'hold-out di 2°
|
||||
ORDINE** — SKH01/XS01 furono ammessi/affinati *perché* forti su quell'hold-out; pre-2025 ΔSh −0.05,
|
||||
finestre disgiunte −0.12/+0.06/+0.14, percentile 94-100° fra 500 tilt casuali ≈ firma best-of-15.
|
||||
Guardia-DD 5%/0.5: inerte OOS (la diversificazione fa già il lavoro; solo circuit-breaker d'emergenza).
|
||||
(5) **affinamento VRP01** = NON MIGLIORA (l'alpha è tutto nel gate binario IV-rank; gate TP01 =
|
||||
trappola in-sample; **3° fallimento → filone "VRP dentro il modello" esaurito** fino a f di stress
|
||||
reale). (6) **stagionalità cross-sectional HL** = morta allo step statistico (null permutato).
|
||||
**GATE nuovo codificato: `weights_tilt_null`** in `src/portfolio/portfolio.py` (+ `combine_outer`
|
||||
riusabile): ogni proposta di CAMBIO PESI si giudica vs il null dei tilt casuali cap-respecting —
|
||||
gate_pass solo se delta_insample≥0 E percentile < firma best-of-k (necessario, non sufficiente);
|
||||
test `tests/test_weights_tilt_null.py`. ⚠️ Lezione tecnica: `DatetimeIndex.view("int64")` su indici
|
||||
tz-aware non-ns (pandas 2.x) → scala sbagliata → `merge_asof` broadcasta = **look-ahead che
|
||||
`causality_ok` non vede**; usare epoca esplicita in ms (altlib verificato pulito). Diario di sintesi
|
||||
`2026-07-01-strategy-wave-6threads.md` + 6 diari di filone; script `scripts/research/r0701_*.py`.
|
||||
- **Soffitto strutturale BTC/ETH-direzionale ~1.3** superato SOLO espandendo a un meccanismo diverso:
|
||||
cross-sectional su universo Hyperliquid certificato (XS01) → portafoglio Sharpe ~1.55.
|
||||
- **Sweep "strategie alternative" (2026-06-20) — 104 ipotesi / 153 agenti / NIENTE di nuovo regge.**
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# 2026-07-01 — Breadth / market-internals del mercato ALT come segnale su BTC/ETH — SCARTATO
|
||||
|
||||
**Tesi.** Gli "internals" del mercato crypto — la partecipazione degli alt — come segnale
|
||||
direzionale o gate di de-risk su BTC/ETH perp (2 gambe, eseguibile a ~$600): % di alt sopra la
|
||||
propria SMA(N), advance/decline, % di alt che battono BTC (risk-appetite relativo), breadth-thrust.
|
||||
Script `scripts/research/r0701_breadth_internals.py` (non committato per policy del filone; harness
|
||||
`altlib`). Dati: i 51 parquet certificati `data/raw/hl_*_1d.parquet` → panel di **49 alt**
|
||||
(esclusi hl_btc/hl_eth dalla breadth; hl_btc solo come riferimento per la famiglia RS), barre a
|
||||
volume 0 mascherate (lezione backfill 2026-06-20; il fetch le aveva già ripulite: vol0=0 su tutti
|
||||
i file, gate comunque attivo).
|
||||
|
||||
**Limite strutturale dichiarato in partenza:** l'universo HL parte dal 2024-01 → dopo il warmup
|
||||
(max 120g) la finestra comune è **2024-05-05 → 2026-07-01 (~2.2 anni)** e l'in-sample pre-HOLDOUT
|
||||
è **~8 mesi del toro 2024**. Qualunque esito poteva essere al massimo un lead.
|
||||
|
||||
## Famiglie e griglia (108 celle, tutte contate nel deflated Sharpe)
|
||||
|
||||
4 famiglie × N∈{20,50,100} × soglia∈{0.3,0.5,0.7} × forma∈{LS, long-flat, gate-su-TP01}:
|
||||
|
||||
- **FAM-MA**: % di alt validi con close > SMA(N) (breadth classica).
|
||||
- **FAM-AD**: frazione di advancers (ret 1g>0), SMA(N).
|
||||
- **FAM-RS**: % di alt che battono BTC sul ritorno a N giorni (≈ market-neutral, l'angolo più ortogonale).
|
||||
- **FAM-TH**: breadth-thrust = Δ a N giorni della breadth MA20 (thrust/collapse), score 0.5+Δ.
|
||||
|
||||
Tutte causali (rolling/shift soltanto), MIN_VALID=20 asset per data (osservato: 42-49). LS/LF
|
||||
vol-target 20% cap 2x (sizing TP01-style); GATE = posizione TP01 × gate binario. Fee 0.10% RT +
|
||||
sweep 0-0.30%. Procedura onesta = mirror di `study_family_honest` sulla finestra comune (il
|
||||
padding pre-2024 avrebbe contaminato il ranking full-history: le celle GATE con gate=1 pre-storia
|
||||
erediterebbero lo Sharpe 2019-2024 di TP01): **cella scelta col SOLO Sharpe in-sample**, DSR su
|
||||
tutti i trial, `marginal_vs_tp01` di altlib sulla cella scelta.
|
||||
|
||||
## ⚠️ Bug trovato e corretto: allineamento merge_asof su indice non-ns
|
||||
|
||||
La prima versione di `_align` usava `b.index.view("int64")//10**6` per i timestamp della breadth:
|
||||
su un DatetimeIndex tz-aware **a risoluzione non-ns** (pandas 2.x) la scala esce sbagliata →
|
||||
`merge_asof backward` matchava OGNI barra BTC/ETH all'**ultimo** valore della breadth = **il futuro
|
||||
broadcast su tutta la storia**. Sintomo: decine di celle con lo stesso identico Sharpe (il segnale
|
||||
era una costante). **`causality_ok` NON lo vede** — la serie breadth è un input esterno *fisso*,
|
||||
quindi il target sul prefisso troncato coincide col target full per costruzione. Fix: epoca
|
||||
esplicita `(idx - Timestamp(1970, tz=UTC)) // Timedelta(1ms)` (come `resample_tf`). **Lezione
|
||||
harness:** ogni allineamento di un panel esterno via merge_asof va fatto con epoch-ms esplicita, e
|
||||
il sintomo "molte celle con Sharpe identico" è un red-flag di allineamento rotto (gemella
|
||||
dell'osservazione merge_asof del 2026-06-20).
|
||||
|
||||
## Esito (procedura onesta, finestra comune 2024-05+)
|
||||
|
||||
104/108 celle valutabili (4 degeneri flat), 47 con FULL>0. **Cella scelta in-sample** (IS 2.11,
|
||||
la selezione non tocca mai l'hold-out): **TH N=100 thr=0.7 long-flat** — long solo dopo un
|
||||
breadth-thrust (+0.2 di breadth MA20 in 100g), attiva il 32% dei giorni.
|
||||
|
||||
| metrica | valore |
|
||||
|---|---|
|
||||
| standalone finestra comune | IS **+2.11** → FULL **+0.99**, HOLD **+0.41** |
|
||||
| assoluto (per-asset, trimmed) | **PASS** — minFull +0.68, minHold +0.38, fee-survive 0.30%RT, DD 7-11% |
|
||||
| marginale vs TP01 | **ADDS** — corr full **0.401**/hold 0.358, resid Sharpe 0.87, multicut {2025:+0.076, 2026:+0.366} persistente, is_hedge=False |
|
||||
| **robust_oos** | **False** — jackknife drop-best-month **−0.068** (l'uplift hold-out sta in piedi su UN mese) |
|
||||
| **deflated Sharpe** | **0.433** su 104 trial (null-max atteso 1.10) — lontanissimo dal PASS ≥0.95 |
|
||||
| earns_slot / earns_slot_honest | **False / False** |
|
||||
| causality_ok | True (post-fix) |
|
||||
| smallcap $600 | haircut ≈ **0.00** (BTC −0.01, ETH +0.00; 137-152 trade) — eseguibile |
|
||||
|
||||
**Ridondanza col trend (il rischio n.1, lezione macro-gate):** qui NON è la modalità di morte.
|
||||
corr(pos, TP01pos) 0.15-0.30 e il segnale "lavora" (off mentre TP01 non è flat) nel **24-32% dei
|
||||
giorni** (il macro-gate lavorava nel 2-3%). Le forme direzionali LS/LF sono genuinamente diverse
|
||||
da TP01 (corr→TP01 0.1-0.4). La forma **gate-su-TP01** invece sì: best gate = NEUTRAL a corr
|
||||
0.803, e i gate a soglia bassa = REDUNDANT corr 1.0 (gate mai off → TP01 identico). **La breadth
|
||||
come gate di de-risk non aggiunge nulla; come segnale direzionale è diversa dal trend ma l'edge
|
||||
non è dimostrabile.**
|
||||
|
||||
**Il gemello in hindsight (trasparenza, NON selezionabile).** TH N=100 thr=0.7 **LS** fa FULL 1.13
|
||||
/ HOLD 1.06, corr→TP01 **−0.26**, marginal ADDS con robust_oos=True e jackknife +0.46. MA il suo
|
||||
in-sample (1.30) è sotto quello della cella lf (2.11): sceglierlo = **selezione-sull'hold-out**
|
||||
(esattamente il falso positivo ERM del 2026-06-29). E comunque **DSR 0.518 < 0.95**: anche la
|
||||
lettura più caritatevole muore sulla deflazione multiple-testing.
|
||||
|
||||
## Verdetto: SCARTATO (niente sleeve, niente forward-monitor cablato)
|
||||
|
||||
1. La cella scelta onestamente è ADDS ma **robust_oos=False** (uplift a un mese) e **DSR 0.433**
|
||||
su 104 trial: su ~8 mesi di in-sample (toro 2024) un IS di 2.1 è indistinguibile dal massimo
|
||||
atteso per caso (1.10) e non regge.
|
||||
2. Il candidato migliore col senno di poi (TH-LS) sarebbe selezione-sull'hold-out e fallisce
|
||||
comunque il DSR (0.518) — il gate `study_family_honest` fa esattamente il suo lavoro.
|
||||
3. Il limite è la **storia**: 2.2 anni totali, 8 mesi di in-sample. Non c'è modo onesto di
|
||||
dimostrare l'edge oggi.
|
||||
|
||||
**Cosa resta.** (a) La famiglia **breadth-thrust (TH)** è l'unico angolo con celle OOS-positive e
|
||||
bassa/negativa corr a TP01 — **rivisitabile quando il panel HL avrà ≥1-2 anni di storia nativa in
|
||||
più** (in-sample multi-regime, DSR ricalcolabile); nessun cablaggio forward oggi, il costo
|
||||
dell'attesa è zero. (b) Il breadth-**gate** su TP01 va in bacino con macro-gate e DVOL-gate: terza
|
||||
conferma che TP01 non ha bisogno di de-risk esterno (long-flat, già flat nei crash). (c) Il fix
|
||||
`_align` (epoch esplicita) è la lezione riusabile per ogni futuro panel esterno.
|
||||
@@ -0,0 +1,114 @@
|
||||
# 2026-07-01 — Funding rate come segnale TIME-SERIES direzionale su BTC/ETH (R0701): SCARTATO
|
||||
|
||||
## Contesto
|
||||
|
||||
Filone: il **funding orario Hyperliquid come proxy di POSIZIONAMENTO/sentiment** con valenza
|
||||
direzionale a orizzonte giornaliero su BTC/ETH — NON carry. Angolo diverso dal prior art già
|
||||
chiuso: **FC01** (carry cross-sectional delta-neutral 19 alt → LEAD fragile SCARTATO,
|
||||
`2026-06-22-funding-carry-hl.md`) e **funding price-clock** intraday (drift attorno agli stamp →
|
||||
FAIL, onda intraday). Qui l'ipotesi è: funding estremo = affollamento long → fade; funding in
|
||||
espansione = domanda persistente → follow; funding affollato = de-risk del trend (gate).
|
||||
Attrattiva a priori: 2 gambe BTC/ETH perp = **eseguibile al capitale reale ~$600** (non STAT-MODE
|
||||
come XS01/opzioni) — se avesse retto sarebbe stato deployabile.
|
||||
|
||||
Script: `scripts/research/r0701_funding_ts.py`.
|
||||
|
||||
## Dati e causalità (data-first)
|
||||
|
||||
- `data/raw/hlfund_{btc,eth}_1h.parquet`: **2023-05-12 → 2026-06-22, 1138/1138 giorni validi,
|
||||
0 gap** dopo il 2023-06 (primi ~27 giorni a cadenza 8h — la somma giornaliera degli stamp li
|
||||
gestisce indifferentemente). Funding medio **+14.4%/anno BTC, +14.6% ETH**; std del funding
|
||||
giornaliero ~5.5 bps.
|
||||
- **Join causale** (il punto delicato): le barre 1d di `altlib.get` sono **open-labeled**
|
||||
(datetime = 00:00 UTC del giorno D; il close della barra D è noto alle 00:00 di D+1). Il
|
||||
feature-day D aggrega i SOLI stamp in **[D 00:00, D+24h)** — l'ultimo alle 23:00 — quindi tutto
|
||||
è noto PRIMA della decisione al close della barra D; `eval_weights` shifta la posizione al
|
||||
giorno D+1. Prefix-consistency check esplicito: **max diff 0.0** (nessun leak).
|
||||
- **Valutazione su finestra TRONCATA** alla copertura funding, non sul frame prezzi 2018+: fuori
|
||||
copertura il target sarebbe zero per costruzione e i giorni-zero (a) **gonfiano il T del
|
||||
deflated-Sharpe** (anti-conservativo) e (b) diluiscono `cand_insample_sharpe` (il gate
|
||||
`has_insample_edge` scatterebbe a vuoto). La logica di `study_family_honest` è replicata
|
||||
ESATTAMENTE sui frame troncati coi primitivi altlib (selezione IN-SAMPLE-ONLY → gates
|
||||
study_marginal → DSR sull'intera griglia). Cross-check `study_marginal` non-troncato in coda:
|
||||
stessi verdetti (abs WEAK / marginal NEUTRAL / earns_slot False).
|
||||
- ⚠️ **STORIA CORTA**: ~3.1 anni totali → in-sample pre-HOLDOUT ~1.6a (meno warmup z), hold-out
|
||||
2025+ ~1.5a. Dichiarato: nessun numero qui ha il peso statistico di TP01 (7 anni).
|
||||
|
||||
## Metodo
|
||||
|
||||
Famiglia a griglia modesta, 1d, 50/50 BTC+ETH, vol-target 20% cap 2x (convenzione TP01), fee
|
||||
0.10% RT + sweep 0.00-0.30%:
|
||||
|
||||
- **FADE**: z-score(funding daily, lb) ≥ thr → short; ≤ −thr → long (fade dell'affollamento).
|
||||
- **FOLLOW**: z ≥ thr → long; ≤ −thr → short (sentiment momentum).
|
||||
- **GATE**: trend long-flat TP01-like (sgn mom 30d+90d) ma FLAT quando z ≥ thr (de-risk crowding).
|
||||
- **DIVERGE**: momentum prezzo 20d seguito se funding NON affollato (z ≤ −thr), fatto se z ≥ thr.
|
||||
|
||||
Griglia: 4 forme × lb {7,14,30,60}g × thr {0.5,1.0,1.5} = **48 celle**. Selezione cella
|
||||
**in-sample-only** (mai sul hold-out), deflated-Sharpe su tutte le 48, gates marginali
|
||||
(`marginal_vs_tp01`), boundary-shift della finestra di aggregazione funding (−0/3/6/9/12h),
|
||||
`eval_weights_smallcap` a $600.
|
||||
|
||||
## Risultati
|
||||
|
||||
Best in-sample per forma: **fade 0.34, follow 0.81, gate 1.76, diverge −0.34** — il segnale
|
||||
puro-funding è debole ovunque; solo la forma che CONTIENE il trend ha Sharpe.
|
||||
|
||||
**Cella scelta (in-sample): GATE lb=60 thr=1.5** — IS 1.76, FULL 1.16, HOLD 0.29.
|
||||
|
||||
| Gate | Esito |
|
||||
|---|---|
|
||||
| Deflated-Sharpe (48 celle) | **0.215** (null-max atteso 1.59) → **FAIL** (soglia 0.95) |
|
||||
| Assoluto (frame troncati) | **WEAK** (minFull +0.97, ma minHold **+0.04** BTC); fee-survive fino a 0.30% RT |
|
||||
| Marginale vs TP01 | **NEUTRAL**: corr **0.879** (hold 0.92), beta 0.77; uplift w25 hold **+0.001**; robust_oos=False (jackknife −0.074); multicut 2026 **−0.423** |
|
||||
| earns_slot / earns_slot_honest | **False / False** |
|
||||
| Realism | prefix-diff 0.0 (causale); boundary-shift sign-stable (uplift spread 0.047); smallcap $600 haircut ~0.00 (eseguibile, irrilevante senza edge) |
|
||||
|
||||
**Controllo decisivo (lezione TP01-DVOL-overlay)** — lo stesso trend long-flat SENZA gate funding:
|
||||
|
||||
```
|
||||
trend NUDO : IS 1.65 FULL 1.14 HOLD 0.37
|
||||
trend+GATE : IS 1.76 FULL 1.16 HOLD 0.29 corr(gated,nudo)=0.928
|
||||
delta del gate funding: FULL +0.013, HOLD −0.080
|
||||
```
|
||||
|
||||
→ **tutto lo Sharpe della "vincitrice" è il trend**; il contributo del funding è ~zero in-sample
|
||||
e **negativo sul hold-out**. Il gate è TP01 travestito.
|
||||
|
||||
**Miglior cella puro-funding** (la vera tesi del filone): FOLLOW lb=60 thr=0.5 — IS 0.81 ma
|
||||
**FULL −0.28, HOLD −1.69**, marginale **DILUTES** (uplift w25 hold −0.778). L'in-sample 0.81 era
|
||||
"long-il-toro-2023/24" travestito: z-alto di funding = rally già in corso; nel 2025-26 la stessa
|
||||
regola è sistematicamente dalla parte sbagliata. FADE (shortare il crowding) perde sul toro e non
|
||||
recupera abbastanza nel chop; DIVERGE negativa ovunque. NB: esistono celle gate con HOLD migliore
|
||||
(es. gate 7/1.5 HOLD 0.96) — sceglierle sarebbe **selection-on-holdout**, il gate del 2026-06-29
|
||||
esiste esattamente per questo.
|
||||
|
||||
## Perché fallisce (meccanismo)
|
||||
|
||||
1. Su asset MAJOR il funding è **quasi sempre positivo e segue il prezzo con lag**: lo z-score
|
||||
alto coincide col momentum già espresso → FOLLOW è trend-beta ritardato (muore quando il regime
|
||||
gira), FADE è shortare il toro. Non c'è l'anti-persistenza ("crowding → reversal") che la tesi
|
||||
richiede — a 1d, su BTC/ETH, le liquidazioni-da-crowding sono già nel prezzo prima che lo
|
||||
z-score giornaliero diventi estremo.
|
||||
2. L'unico uso "funzionante" (gate di de-risk sul trend) è **ridondante**: TP01-like è già flat
|
||||
quando serve. Stesso verdetto di macro-regime-gate (corr 0.989) e DVOL-overlay: la famiglia
|
||||
"overlay prudenziale su TSMOM" è esaurita — eredita lo Sharpe del trend e non aggiunge nulla.
|
||||
|
||||
## Verdetto
|
||||
|
||||
**SCARTATO.** `earns_slot=False`, `earns_slot_honest=False` (DSR 0.215). Il funding time-series
|
||||
non ha informazione direzionale propria su BTC/ETH a 1d; l'unica cella con Sharpe è trend
|
||||
travestito con contributo funding OOS negativo. Niente forward-monitor: non c'è una componente
|
||||
funding da monitorare. Con FC01 (carry cross-sectional) e il price-clock intraday, il filone
|
||||
"funding come segnale" su dati certi è ora **chiuso su tre lati**.
|
||||
|
||||
## Lascito
|
||||
|
||||
- `scripts/research/r0701_funding_ts.py` riusabile: (a) loader funding→daily **causale** per barre
|
||||
open-labeled (finestra [D, D+24h), gestione cadenza mista 8h/1h, flag validità copertura);
|
||||
(b) pattern "**truncation-honest**" per dataset a copertura parziale — replica di
|
||||
`study_family_honest` su finestra troncata per non gonfiare il T del DSR né diluire
|
||||
`has_insample_edge` con anni di zeri (riusabile per ogni serie corta tipo DVOL/HL).
|
||||
- I parquet `hlfund_*` restano certificati. Se mai si tornasse sul funding: gli estremi VERI
|
||||
(squeeze) vivono sugli alt, ma è di nuovo cross-sectional → STAT-MODE a $600, soglia di prova
|
||||
alta (e FC01 ha già mostrato la fragilità d'universo).
|
||||
@@ -0,0 +1,174 @@
|
||||
# 2026-07-01 — Pesi statici ri-ottimizzati + guardia-DD sul portafoglio a 4 sleeve
|
||||
|
||||
## Mandato
|
||||
Migliorare il portafoglio ATTIVO (TP01 41.25 / XS01 18.75 / VRP01 15 / SKH01 25) SENZA nuove
|
||||
strategie: (A) ri-ottimizzazione onesta dei pesi statici, (B) guardia-drawdown a livello
|
||||
portafoglio (replica del soft-guard vincente del tail-hedge lab 2026-06-23), (C) combinazione.
|
||||
NON meta-allocazione dinamica (già scartata, thread 3 del 2026-06-29).
|
||||
Script: `scripts/research/r0701_portfolio_opt.py`. Solo ricerca: nessun file di src/ o live toccato,
|
||||
i pesi sono PROPOSTI, non applicati.
|
||||
|
||||
## Baseline riprodotta
|
||||
`run_portfolio.py` oggi: **FULL Sh 2.12 / HOLD 2.21 / DD full 7.8% / CAGR 23.3%** (vs 2.13/2.30
|
||||
del diario Skyhook: i dati sono avanzati di una settimana, coerente). Matrice sleeve outer-join
|
||||
2019-03-14 → 2026-07-01 (TP01/SKH01 dal 2019, VRP01 dal 2021-05, XS01 dal 2024-01), combine
|
||||
identico a `portfolio.combined_daily` (pesi rinormalizzati per-riga sugli sleeve attivi).
|
||||
|
||||
## (A) Pesi statici — metodo
|
||||
- Ottimizzazione SOLO in-sample (pre-cut), valutazione OOS (post-cut), multi-cut
|
||||
{2024-01, 2024-07, 2025-01}. Sleeve con <120 giorni in-sample al cut (XS01 al 2024-01)
|
||||
PINNED al peso corrente (l'ottimizzatore non ha dati per giudicarli).
|
||||
- Criteri: MAXSH (max-Sharpe del combo simulato), RP (inverse-vol), ERC, MINVAR-R (min-var con
|
||||
vincolo ritorno ≥ pesi correnti). Null: EW (25×4) e CURRENT. Vincoli 5%≤w≤60%, Σ=1.
|
||||
- **Cap STRUTTURALI dichiarati** (prudenza, non statistica): **VRP01 ≤ 15%** (sleeve MODELLATO su
|
||||
DVOL ATM, stress-f non catturato — "niente short-vol da modello in deploy"), **XS01 ≤ 25%**
|
||||
(STAT-MODE, storia ~2.5 anni). Varianti sotto cap: MAXSH-STR (ottimizzato) e EW-STR
|
||||
(EW proiettato sui cap, parameter-free → TP 30 / XS 25 / VRP 15 / SKH 30).
|
||||
|
||||
### Risultati (pesi ottimizzati pre-2025, valutati FULL e HOLD-OUT 2025-26)
|
||||
| criterio | pesi TP/XS/VRP/SKH | FULL Sh | FULL DD | FULL CAGR | HOLD Sh | HOLD DD | HOLD CAGR | multi-cut ΔSh OOS | anni vinti |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| CURRENT | 41.2/18.8/15/25 | 2.12 | 7.8% | +23.3% | 2.21 | 3.5% | +16.0% | — | — |
|
||||
| MAXSH | 27.6/17.5/**39.5**/15.4 | 2.17 | 6.3% | +21.8% | 2.27 | 2.8% | +12.7% | +0.24/+0.08/+0.06 | **1/8** |
|
||||
| RP-invvol | 26.7/15.5/**41.6**/16.3 | 2.17 | 6.4% | +21.8% | 2.25 | 2.8% | +12.4% | +0.28/+0.07/+0.04 | — |
|
||||
| ERC | 26.6/16.2/**41.6**/15.7 | 2.17 | 6.3% | +21.7% | 2.25 | 2.8% | +12.4% | +0.29/+0.07/+0.05 | — |
|
||||
| MINVAR-R | 26.7/23.3/**29.6**/20.4 | 2.18 | 6.4% | +23.3% | 2.37 | 2.6% | +16.1% | +0.20/+0.10/+0.16 | 4/8 |
|
||||
| MAXSH-STR | 38.6/25/15/21.4 | 2.14 | 7.5% | +23.4% | 2.28 | 3.3% | +16.8% | **−0.00**/+0.03/+0.07 | 3/8 |
|
||||
| EW | 25/25/25/25 | 2.14 | 7.0% | +24.5% | 2.39 | 2.8% | +18.1% | +0.16/+0.17/+0.18 | 6/8 |
|
||||
| **EW-STR** | **30/25/15/30** | 2.10 | 8.5% | +25.2% | **2.35** | 3.2% | **+19.7%** | **+0.06/+0.13/+0.14** | **7/8** |
|
||||
|
||||
### Lettura onesta
|
||||
1. **Gli OTTIMIZZATORI non aggiungono nulla — anzi overfittano.** Tutti (MAXSH/RP/ERC/MINVAR-R)
|
||||
convergono sulla stessa mossa: scaricare TP01 e caricare **VRP01 al 30-42%** perché ha la vol
|
||||
più bassa (7.7%/anno). Ma quella è la vol del MODELLO (premio BS su DVOL, coda di stress non
|
||||
catturata): è esattamente il punto cieco tipo-CC01 ("Sharpe/vol implausibile → rischio
|
||||
nascosto"). E per-anno MAXSH vince **1/8 anni** vs CURRENT — l'uplift multi-cut è smoothing di
|
||||
vol modellata, non alpha di allocazione. **Dentro i cap** l'ottimizzatore (MAXSH-STR) converge
|
||||
quasi sui pesi correnti (38.6/25/15/21.4) e NON è persistente (−0.00 al cut 2024-01) →
|
||||
**i pesi correnti sono già ~ottimi per ciò che il backtest può giudicare lecitamente**.
|
||||
2. **L'unico segnale robusto è la DE-CONCENTRAZIONE parameter-free**: EW-STR (EW sotto i cap,
|
||||
nessun parametro fittato) vince **7/8 anni** (unica perdita 2021 −3.1pp), tutti i cut OOS
|
||||
(ΔSh +0.06/+0.13/+0.14), HOLD Sh 2.21→2.35, HOLD CAGR +16.0→+19.7%. Classico "1/N batte
|
||||
l'ottimizzazione" (DeMiguel). La sostanza: CURRENT dà il 41% al peggior Sharpe FULL del book
|
||||
(TP01 1.30) e EW-STR sposta ~11pp da TP01 verso SKH01 (+5pp) e XS01 (+6.25pp).
|
||||
3. **MA non è un free lunch risk-adjusted**: FULL Sh 2.12→**2.10** e FULL DD 7.8→**8.5%**
|
||||
(più SKH01, che ha DD standalone 18%, pesa di più nei primi anni a 2 sleeve). L'uplift è un
|
||||
TILT di ritorno verso gli sleeve research-grade con la storia più corta/caveat più pesanti
|
||||
(SKH01 = research win, book 230m da verificare a deploy; XS01 = STAT-MODE non eseguibile a 2k).
|
||||
E TP01 resta l'unico sleeve *deployed* live: il suo 41% nel report riflette una scelta di
|
||||
prudenza/eseguibilità, non solo statistica.
|
||||
|
||||
### VERDETTO (A)
|
||||
- **Ottimizzazione formale: NON MIGLIORA** (viola i cap strutturali o converge ai pesi correnti).
|
||||
**Per il book reale i pesi correnti reggono.**
|
||||
- **Candidato PROPOSTO (non applicato): EW-STR = TP01 30 / XS01 25 / VRP01 15 / SKH01 30** —
|
||||
uplift OOS persistente e per-anno (7/8), dentro i cap, al costo di FULL DD +0.7pp e FULL Sh
|
||||
−0.02. È una decisione di risk-appetite (più ritorno atteso spostando peso su sleeve
|
||||
research-grade), non un miglioramento dominante. Se si vuole adottare, farlo consapevoli che
|
||||
aumenta la dipendenza da SKH01/XS01, e NON tocca il book Deribit eseguibile (TP75/SKH25).
|
||||
|
||||
### Caveat metodologici (A)
|
||||
- Le 3 finestre multi-cut sono ANNIDATE (tutte contengono il 2025-26): non 3 conferme
|
||||
indipendenti. Il test disgiunto vero è il per-anno (7/8 per EW-STR è il dato forte).
|
||||
- EW-STR è stato costruito DOPO aver visto EW vincere (proiezione del null sui cap dichiarati):
|
||||
costruzione principled e parameter-free, ma va detto — non era nella griglia iniziale.
|
||||
- I pesi CURRENT stessi furono fissati (2026-06-23) conoscendo l'hold-out → il confronto
|
||||
"in-sample-opt vs CURRENT su hold-out" è semmai sbilanciato A FAVORE di CURRENT; EW-STR vince
|
||||
comunque.
|
||||
|
||||
## (B) Guardia-drawdown a livello portafoglio — metodo
|
||||
Soft-guard causale sul combo 4-sleeve (pesi CURRENT): esposizione del giorno D decisa dal DD
|
||||
dell'equity fino a **D−1**; trigger/re-entry sul **NAV ombra** non-guardato (lezione stops_lab:
|
||||
sull'equity congelata a expo=0 il DD non rientra mai); isteresi re-risk a 0.4·X come nel
|
||||
tail-hedge lab. Griglia X∈{3,4,5,6}%, derisk∈{0,0.5}. Selezione cella IN-SAMPLE (max Calmar
|
||||
pre-2025), verifica hold-out + multi-cut, **null de-levering** (expo costante c che pareggia il
|
||||
MaxDD in-sample della guardia: Sharpe di c·r è invariante → la guardia vale solo se batte quello).
|
||||
|
||||
### Risultati
|
||||
- Cella scelta in-sample: **X=5% / derisk=0.5** (IS Calmar 3.64 vs 3.24 baseline; IS DD 7.8→6.6%,
|
||||
IS CAGR 25.3→23.9%, IS Sh 2.13→2.12). In-sample batte il null de-levering c=0.84 a pari DD
|
||||
(CAGR 23.9% vs 21.0%) — il timing esiste in-sample.
|
||||
- **MA OOS è INERTE**: a ogni cut la cella scelta non scatta MAI (ΔSh +0.00, ΔDD 0.0pp, ΔCAGR
|
||||
0.0pp su tutti e 3 i cut) perché il DD OOS del 4-sleeve resta 3.5-4.8% < trigger 5%.
|
||||
**La diversificazione a 4 sleeve fa già il lavoro della guardia** (il 2022 del book è +11.6%
|
||||
con DD 3.6%: non c'è il grind da proteggere che aveva il combo TP01+GTAA del tail-hedge lab,
|
||||
DD 8.4% / 2022 −4.4%).
|
||||
- Trigger più stretti che scattano davvero COSTANO: X=3%/d=0.5 → HOLD Sh 2.10 (< 2.21), CAGR
|
||||
13.7% (< 16.0%); X=3-4%/d=0 whipsaw pieno (HOLD Sh 1.83-2.09; a X=4/d=0 il DD guardato IS
|
||||
sale addirittura a 8.8% > 7.8% baseline — accumulo di perdite su cicli off/on, lo stesso
|
||||
fenomeno visto in stops_lab con l'uscita totale).
|
||||
|
||||
### VERDETTO (B)
|
||||
**NON MIGLIORA.** La guardia-DD che vinceva sul combo TP01+GTAA (DD 8.4%, 2022 −4.4%) qui non ha
|
||||
bersaglio: il 4-sleeve ha già DD ~3.5-4.8% OOS. Coerente col thread meta-allocazione 2026-06-29
|
||||
(drawdown-control ridondante). Nota operativa: la cella X=5%/d=0.5 è *innocua* (non fa nulla
|
||||
finché il DD non supera il 5%) e in-sample mostrava timing genuino sopra il null de-levering —
|
||||
può valere come **circuit-breaker di emergenza sul live** (risk management, non alpha), ma non è
|
||||
un miglioramento statistico e non la si adotta come tale.
|
||||
|
||||
## (C) Combinazione
|
||||
(B) è inerte → niente da combinare. Per completezza: EW-STR + guard X=5%/d=0.5 → FULL Sh 2.08,
|
||||
DD 6.7%, CAGR 23.7% | HOLD invariato 2.35/3.2%/19.7% (la guardia lima il DD 2021-2023 del tilt
|
||||
a costo di −0.02 Sh FULL). Nessuna adozione proposta.
|
||||
|
||||
## Sintesi
|
||||
| config | FULL Sh | FULL DD | FULL CAGR | HOLD Sh | HOLD DD | HOLD CAGR |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **CURRENT (attivo)** | 2.12 | 7.8% | +23.3% | 2.21 | 3.5% | +16.0% |
|
||||
| EW-STR (proposta A, non applicata) | 2.10 | 8.5% | +25.2% | 2.35 | 3.2% | +19.7% |
|
||||
| CURRENT + guard 5%/0.5 (B) | 2.11 | 6.6% | +22.3% | 2.21 | 3.5% | +16.0% |
|
||||
| EW-STR + guard (C) | 2.08 | 6.7% | +23.7% | 2.35 | 3.2% | +19.7% |
|
||||
|
||||
- **(A) NON MIGLIORA in senso forte** — i pesi correnti reggono; l'unico candidato onesto è il
|
||||
tilt de-concentrante EW-STR (30/25/15/30), un trade-off di risk-appetite da decidere, non un
|
||||
upgrade dominante. Gli ottimizzatori in-sample o violano i cap strutturali (VRP01 40%) o
|
||||
riconvergono sui pesi correnti.
|
||||
- **(B) NON MIGLIORA** — guardia inerte OOS sul 4-sleeve; utile al più come circuit-breaker.
|
||||
- Nessuna modifica a `src/portfolio/sleeves.py` o al live. Test 160/160 verdi.
|
||||
|
||||
Script: `scripts/research/r0701_portfolio_opt.py` (~4s, vettoriale).
|
||||
|
||||
## Verifica avversariale (agente scettico)
|
||||
|
||||
Script: `scripts/research/r0701_portfolio_skeptic.py`. **VERDETTO: NON APPLICARE — CURRENT resta.**
|
||||
I numeri dell'agente riproducono ESATTAMENTE (via `StrategyPortfolio.combined_daily`, path di
|
||||
produzione: CURRENT 2.12/2.21, EW-STR 2.10/2.35, multi-cut +0.06/+0.13/+0.14, |diff|=0). Il claim
|
||||
muore altrove: l'uplift è **selezione-sull'hold-out di 2° ordine**, non alpha di allocazione.
|
||||
|
||||
1. **L'uplift OOS vive TUTTO nel 2025-26 — la finestra che ha selezionato i beneficiari.**
|
||||
Finestre OOS **DISGIUNTE** (i 3 cut del diario sono annidati): ΔSh EW-STR vs CURRENT =
|
||||
**−0.12** (2024H1) / +0.06 (2024H2) / **+0.14** (2025+). L'unica finestra OOS non toccata
|
||||
dalla selezione hold-out è negativa. Sharpe standalone IS→HOLD per sleeve: TP01 1.49→**0.30**,
|
||||
SKH01 1.42→**1.64**, XS01 1.22→**1.51**: EW-STR compra esattamente i due sleeve ammessi/affinati
|
||||
(2026-06-23 / 2026-06-19) PERCHÉ forti sull'hold-out e vende quello che lì collassa. Meccanico.
|
||||
2. **Pre-2025 (finestra non contaminata) EW-STR PERDE risk-adjusted**: ΔSh aggregato **−0.05**,
|
||||
Sharpe per-anno vinti **2/6** (2021 −0.29, 2020 −0.10, 2023/2024 negativi).
|
||||
3. **"7/8 anni vinti" è un artefatto di metrica**: è RITORNO composto (un tilt a più vol/ritorno
|
||||
li vince quasi per costruzione), non risk-adjusted. Su **Sharpe per-anno: 3/8**. E due dei 7
|
||||
"vinti" (2023, 2024) hanno margine **+0.03pp** = tie. Bilancio materiale sul ritorno:
|
||||
5 vinti / 1 perso (2021 −3.1pp) / 2 pari.
|
||||
4. **Plateau: liscio ma il gradiente è solo "meno TP01"**; a TP fisso, alzare SKH 25→30 è
|
||||
piatto/negativo (min multi-cut a TP35: +0.07→+0.02). Scomposizione: il tilt **solo-SKH**
|
||||
(+5pp, TP 36.25) da solo NON è persistente (multi-cut **−0.00**/+0.05/+0.04); il tilt
|
||||
**solo-XS** (TP35/XS25/VRP15/SKH25) è l'unico positivo su tutte le finestre disgiunte
|
||||
(+0.05/+0.02/+0.11) — ma pre-2025 fa ΔSh −0.00 (edge ≈ zero fuori dal 2025-26) e carica lo
|
||||
sleeve MENO eseguibile (STAT-MODE).
|
||||
5. **Realismo**: con l'outer-join rinormalizzato EW-STR tiene SKH01 al **50% effettivo nel
|
||||
2019-20 e 40% nel 2021-23** (book research 230m a metà portafoglio: da lì il FULL DD 8.5%).
|
||||
Punto onesto A FAVORE: un haircut d'esecuzione a drag su SKH01(-20/30%) e SKH+XS non ribalta
|
||||
il ranking hold-out (ΔSh resta +0.08..+0.13), ma i livelli assoluti crollano (HOLD Sh CURRENT
|
||||
2.21→1.64 al −30% su entrambi) e il beneficio resta di CARTA: il book Deribit eseguibile
|
||||
(TP75/SKH25) non è toccato dalla decisione.
|
||||
6. **Forking paths quantificato**: ≥15 config valutate sull'hold-out (7 vettori pesi + 8 celle
|
||||
guardia), EW-STR costruito dopo aver visto EW vincere. Su **500 tilt CASUALI cap-respecting**:
|
||||
51% batte CURRENT sull'hold-out (mediana ΔSh +0.00), solo 11% sul FULL, 30% su tutti e 3 i cut.
|
||||
EW-STR è al **94° percentile** dell'uplift hold-out fra i tilt casuali ≈ il **massimo atteso di
|
||||
~15 tentativi** (k/(k+1)=93.8%): l'uplift osservato è indistinguibile dal "best of 15 tilt
|
||||
casuali scelto sull'hold-out".
|
||||
|
||||
**Conclusione**: il tilt non è disonesto nei conti ma non è evidenza — è la stessa lezione del gate
|
||||
SELECTION-ON-HOLDOUT applicata al livello dei PESI (2° ordine: gli sleeve stessi sono stati
|
||||
selezionati su quell'hold-out). Unica direzione sopravvissuta: XS01 18.75→25 (solo-XS, positivo su
|
||||
tutte le finestre disgiunte), MA edge ≈0 fuori dal 2025-26, sleeve STAT-MODE non eseguibile a $600,
|
||||
e l'hold-out-strength di XS01 è a sua volta concentrata dal gate di dispersione nel regime 2025-26
|
||||
→ da rivalutare SOLO con più storia forward, non ora. **CURRENT (41.25/18.75/15/25) resta.**
|
||||
@@ -0,0 +1,74 @@
|
||||
# 2026-07-01 — Ondata multi-agente a 6 filoni: 0 nuovi sleeve, pesi CURRENT confermati, 1 gate nuovo
|
||||
|
||||
**Goal utente:** "cerca con diversi agenti altre strategie di trading. Prova di tutto e arriva a
|
||||
migliorare il portfolio presente. Lo scopo è sempre 50 euro days."
|
||||
|
||||
**Esito in una riga:** 6 filoni nuovi (scelti dopo scan dei ~65 diari per non ripetere strade morte)
|
||||
+ 1 verifica avversariale → nessun candidato supera i gate onesti; il portafoglio a 4 sleeve
|
||||
(41.25/18.75/15/25) **sopravvive a un attacco serio**, che è l'esito corretto per un book onesto.
|
||||
Miglioramento concreto dell'ondata = metodologico: **gate `weights_tilt_null`** codificato (sotto).
|
||||
|
||||
## I 6 filoni (tutti su harness onesto: study_family_honest, marginal scorer, DSR≥0.95, smallcap $600)
|
||||
|
||||
| # | filone | verdetto | perché muore |
|
||||
|---|---|---|---|
|
||||
| 1 | **Funding time-series** BTC/ETH (posizionamento, non carry) — `r0701_funding_ts.py` | SCARTATO | FOLLOW = trend-beta ritardato (HOLD −1.69), FADE = shortare il toro; la cella gate è TP01 travestito (controllo senza funding = stessi numeri, corr 0.93, ΔHOLD −0.08); DSR 0.215. **Filone funding chiuso su 3 lati** (FC01 carry, price-clock, TS-signal). |
|
||||
| 2 | **Breadth/internals alt** (51 HL) → BTC/ETH — `r0701_breadth_internals.py` | SCARTATO (rivisitabile) | Unico non-ridondante col trend (corr→TP01 0.40, lavora dove TP01 è attivo), assoluto PASS, marginal ADDS — ma jackknife −0.068 (uplift su UN mese) e DSR 0.433/104 celle. Con ~8 mesi di IS il 2024-toro non basta. **Rivisitare tra 1-2 anni di storia HL nativa.** |
|
||||
| 3 | **Residual momentum XS** (β-hedged vs BTC, 19 major) — `r0701_xs_residmom.py` | REDUNDANT | Cross-section la residualizzazione è quasi un no-op (lo z-score di XS01 già rimuove il mercato): corr→XS01 0.54, HOLD −0.24, corr(IS,HOLD) tra le celle **−0.37** (anti-predittivo = rumore). L'edge residuo-momentum vive nella coppia ETH/BTC (STATARB-RESID, resta in `paper_statarb`), non generalizza. |
|
||||
| 4 | **Pesi + guardia-DD** — `r0701_portfolio_opt.py` | vedi sotto | Unico candidato dell'ondata (EW-STR) → refutato dallo scettico. Guardia-DD X5%/d0.5: in-sample batte perfino il null de-levering, ma **OOS non scatta mai** (DD book 3.5-4.8% < trigger): la diversificazione fa già il lavoro. Utilizzabile solo come circuit-breaker di emergenza (risk mgmt, non alpha). |
|
||||
| 5 | **Affinamento VRP01** (sizing IV−RV, DVOL-mom, gate TP01) — `r0701_vrp_refine.py` | NON MIGLIORA | L'alpha è già tutto nel gate binario IV-rank; il gate TP01 è la trappola IS perfetta (schiva il 2022, ma taglia le settimane migliori dell'hold-out: multi-cut 0/5). **3° fallimento del filone "affinare VRP dentro il modello" → esaurito** finché cerbero-bite non cattura un crash reale (f di stress). |
|
||||
| 6 | **Stagionalità cross-sectional** HL — `r0701_xs_seasonal.py` | SCARTATO (allo step statistico) | Nessuna persistenza split-half sopra il null permutato max-statistic (p 0.16-0.23); l'unica struttura è il canale-beta dell'effetto weekday di mercato (famiglia trackF, già morta). Turnover ~2×gross/die = fee-death comunque. |
|
||||
|
||||
## EW-STR: il candidato refutato (caso di scuola di selezione-sull'hold-out di 2° ORDINE)
|
||||
|
||||
Il filone 4 proponeva **EW-STR** (TP01 30 / XS01 25 / VRP01 15 / SKH01 30 = equal-weight proiettato
|
||||
sui cap strutturali): HOLD Sh 2.21→2.35, multi-cut +0.06/+0.13/+0.14, "7/8 anni vinti" — dichiarando
|
||||
onestamente i caveat. La verifica avversariale (`r0701_portfolio_skeptic.py`, addendum nel diario
|
||||
pesi) lo uccide:
|
||||
|
||||
1. **Selezione di 2° ordine**: SKH01 fu ammesso (2026-06-23) *perché* alzava l'hold-out 2025-26;
|
||||
XS01 fu affinato conoscendo l'hold-out. EW-STR vende lo sleeve che collassa OOS (TP01 IS 1.49→
|
||||
HOLD 0.30) e compra i due selezionati su quell'OOS. Pre-2025: **ΔSh −0.05** (perde risk-adjusted).
|
||||
2. **Su finestre OOS disgiunte**: −0.12 (2024H1) / +0.06 (2024H2) / +0.14 (2025+) — tutto l'uplift
|
||||
vive nella finestra che ha selezionato gli sleeve. "7/8 anni" era sul ritorno composto; su
|
||||
Sharpe è 3/8.
|
||||
3. **Firma best-of-k**: su 500 tilt casuali cap-respecting, EW-STR siede al 94-100° percentile ≈
|
||||
k/(k+1) con k≈15 config viste sull'hold-out → uplift indistinguibile dal *best-of-15 scelto
|
||||
sull'hold-out*.
|
||||
4. **Realismo**: in pesi effettivi (outer-join rinormalizzato) EW-STR = SKH01 al 40-50% del book
|
||||
2019-23 (sleeve research a book 230m). Il book Deribit eseguibile (TP+SKH) non cambierebbe comunque.
|
||||
|
||||
Residuo onesto: il sub-tilt XS01 18.75→25 è positivo su tutte le finestre disgiunte ma edge≈0
|
||||
pre-2025 e STAT-MODE → rivalutare solo con storia forward.
|
||||
|
||||
## GATE NUOVO codificato: `weights_tilt_null` (src/portfolio/portfolio.py)
|
||||
|
||||
La lezione è ora codice, come per marginal-scorer e study_family_honest:
|
||||
- **`combine_outer(daily_cols, weights, lo, hi)`** — la combinazione outer-join/rinormalizzata di
|
||||
`combined_daily` estratta come funzione su pesi arbitrari (riusabile per studi di tilt).
|
||||
- **`weights_tilt_null(daily_cols, w_current, w_proposed, caps, floor, n, seed, k_seen)`** —
|
||||
null di tilt casuali uniformi sul simplesso dentro floor/caps: riporta `delta_hold/full/insample`,
|
||||
`frac_random_beat_hold`, `pctl_hold`, `bestofk_pctl=k/(k+1)` e `gate_pass`.
|
||||
**Regola: un cambio-pesi si applica solo se (1) delta_insample ≥ 0 e (2) pctl_hold < firma
|
||||
best-of-k** (necessario, non sufficiente: restano finestre disgiunte + realismo).
|
||||
Sul caso reale EW-STR: delta_hold +0.139, delta_insample **−0.054**, pctl **100°** vs best-of-15
|
||||
93.75° → `gate_pass=False`. Test: `tests/test_weights_tilt_null.py` (5 test, sintetici,
|
||||
deterministici; incluso lo sleeve "forte-solo-nell'hold-out" che DEVE fallire il gate).
|
||||
|
||||
## Lezione tecnica riusabile (dal filone 2)
|
||||
|
||||
`DatetimeIndex.view("int64")` su indici **tz-aware a risoluzione non-ns** (pandas 2.x) produce
|
||||
timestamp in scala sbagliata → un `merge_asof` può broadcastare l'ultimo valore su tutta la storia
|
||||
= **look-ahead che `causality_ok` non vede** (input fisso). Sintomo: decine di celle con Sharpe
|
||||
identico. Fix: epoca esplicita in ms. Verificato: `altlib.py` non è affetto (grep pulito); dettagli
|
||||
nel diario breadth.
|
||||
|
||||
## Onestà sul goal €50/giorno
|
||||
|
||||
Invariata (vedi CLAUDE.md): a capitale reale ~$600 il book onesto fa ~+€1.5/giorno atteso; la via
|
||||
resta capitale + tempo + target-vol, non un tilt di pesi né leva. Questa ondata conferma che il
|
||||
book attuale è vicino al massimo estraibile dai dati certificati disponibili oggi; le opzioni
|
||||
reali di crescita restano (a) storia forward che promuova i lead (STATARB-RESID, breadth tra 1-2
|
||||
anni), (b) capitale, (c) nuove fonti di dato (f di stress reale per VRP).
|
||||
|
||||
**File dell'ondata:** `scripts/research/r0701_{funding_ts,breadth_internals,xs_residmom,portfolio_opt,portfolio_skeptic,vrp_refine,xs_seasonal}.py`; diari `2026-07-01-{funding-ts-signal,alt-breadth-internals,xs-residual-momentum,portfolio-weights-ddguard,vrp-refinement,xs-seasonality}.md`; gate in `src/portfolio/portfolio.py` + `tests/test_weights_tilt_null.py`.
|
||||
@@ -0,0 +1,98 @@
|
||||
# 2026-07-01 — Affinamento VRP01 (sizing IV−RV / filtro DVOL-momentum / gate TP01): NON MIGLIORA
|
||||
|
||||
**Script:** `scripts/research/r0701_vrp_refine.py` (solo ricerca: `src/` e `scripts/live/` non toccati).
|
||||
**Esito: NON MIGLIORA — VRP01 baseline resta la config canonica.** La cella IS-best (gate TP01-skip)
|
||||
è un falso positivo in-sample da manuale: IS Sharpe 2.07 ma multi-cut **0/5**, hold-out **−0.14**
|
||||
(vs 0.59 del baseline), DSR 0.71. Nessuna modifica a `sleeves._vrp_combo_returns`.
|
||||
|
||||
## Cosa testava (celle NUOVE, verificate non ripetute nei diari)
|
||||
|
||||
Stessa pipeline di `options_vrp_v2.py` (pricing BS su DVOL reale, payoff sul path certificato,
|
||||
fee 12.5% del premio, f=1.0, tenor 7d, spread −0.28/−0.10) — cambiati SOLO gate/sizing:
|
||||
|
||||
1. **Sizing sul gap IV−RV** (carry atteso): size lineare `clip(vrp/scale,0,1)` (scale 8/12 pt) o
|
||||
percentile espandente causale del VRP; in aggiunta o in sostituzione del gate binario IV-rank.
|
||||
NB: il gate composito "IV-rank>0.30 AND IV−RV>0" è **già il baseline** (`gate_vrp=True`).
|
||||
2. **Filtro DVOL-momentum**: non vendere se `dvol[i]−dvol[i−k] > thr` (k∈{5,10}, thr∈{0, 5pt}).
|
||||
(Diverso da `dvol_directional` 2026-06-29, dove il DVOL-mom era segnale direzionale sul perp.)
|
||||
3. **Gate di regime da TP01**: skip o half-size quando TP01 è flat su entrambi gli asset (risk-off).
|
||||
4. Croce completa: **105 celle**, tutte contate nel deflated-Sharpe.
|
||||
|
||||
NON ripetuto (già scartato): gestione attiva intra-trade (PT/SL/vol-stop/delta-exit,
|
||||
`2026-06-20-vrp-active-management.md` → hold-to-expiry vince).
|
||||
|
||||
Metodo: selezione cella **in-sample (pre-2025)** con floor di attività IS ≥20%, hold-out 2025-26,
|
||||
**multi-cut 5 tagli** (2023-01→2025-01, richiesti ≥4/5 uplift>0), `altlib.deflated_sharpe` su tutti
|
||||
i 105 trial, effetto sul **portafoglio 4-sleeve** (TP01 41.25 / XS01 18.75 / VRP 15 / SKH01 25).
|
||||
|
||||
## Risultati (book 50/50 BTC+ETH, rendimenti settimanali)
|
||||
|
||||
Baseline riprodotto esattamente: **FULL Sh 1.09 / IS 1.26 / HOLD 0.59 / DD 12% / worst −7.4% / attivo 41%.**
|
||||
|
||||
| cella | IS Sh | FULL | HOLD | DD | worst | attivo |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **BASELINE** bin+ivr30 | 1.26 | **1.09** | **0.59** | 12% | −7.4% | 41% |
|
||||
| sizing lin 8pt (+ivr30) | 1.20 | 1.05 | 0.61 | 11% | −7.4% | 41% |
|
||||
| sizing lin 12pt (+ivr30) | 1.12 | 0.98 | 0.54 | 11% | −7.4% | 41% |
|
||||
| sizing rank (+ivr30) | 1.07 | 0.94 | 0.54 | 9% | −5.7% | 41% |
|
||||
| sizing lin 8pt **al posto di** ivr | 0.89 | 0.58 | **−0.38** | 21% | −7.4% | 86% |
|
||||
| sizing rank al posto di ivr | 1.03 | 0.69 | **−0.36** | 11% | −5.7% | 86% |
|
||||
| mom k=5 thr=5pt | 1.24 | 1.02 | 0.37 | 9% | −6.5% | 34% |
|
||||
| mom k=10 thr=5pt | 1.26 | 1.08 | 0.55 | 9% | −6.5% | 31% |
|
||||
| mom k=5 thr=0 | 1.78 | 1.24 | −0.10 | 5% | −3.9% | 20% |
|
||||
| mom k=10 thr=0 | 1.36 | 0.78 | **−0.47** | 7% | −3.9% | 18% |
|
||||
| tp01-skip | **2.05** | 1.43 | **−0.14** | 7% | −4.7% | 27% |
|
||||
| tp01-half | 1.72 | 1.34 | 0.27 | 7% | −4.7% | 41% |
|
||||
| IS-best: lin8+tp01-skip | **2.07** | 1.43 | **−0.14** | 6% | −4.2% | 27% |
|
||||
|
||||
**Multi-cut della IS-best vs baseline: uplift negativo in 5/5 tagli** (−0.35 → −0.73, monotono
|
||||
peggiorando verso il presente). **DSR** IS-best 0.709 (<0.95); baseline 0.472 dentro questa griglia
|
||||
(contesto onesto: nemmeno il baseline è statisticamente distinguibile su 105 trial correlati — ma è
|
||||
pre-committed, non selezionato qui). Celle che battono l'hold-out del baseline: **1/105** (lin8pt
|
||||
tp-off, +0.02 — rumore; non selezionabile: sarebbe selection-on-holdout).
|
||||
|
||||
### Frequenza d'intervento dei gate (settimane-trade del baseline = 163)
|
||||
|
||||
- **TP01-skip: blocca il 32.5%** delle settimane tradabili (TP01 flat il 38% dei giorni della
|
||||
finestra DVOL). NON è ridondante col baseline — lavora tanto — ma lavora **male** OOS.
|
||||
- mom thr=5pt: blocca 24-32%; mom thr=0: blocca 60-64% (quasi metà del book).
|
||||
|
||||
### Portafoglio 4-sleeve (VRP @15%)
|
||||
|
||||
| VRP nello slot | FULL Sh | FULL DD | HOLD Sh | HOLD DD |
|
||||
|---|---|---|---|---|
|
||||
| **baseline** | **2.12** | 7.8% | **2.21** | 3.5% |
|
||||
| IS-best (lin8+tp01-skip) | 2.11 | 7.8% | 2.12 | 3.5% |
|
||||
|
||||
Anche a livello portafoglio la variante **peggiora** l'hold-out (2.21→2.12) e non migliora nulla.
|
||||
|
||||
## Lettura (perché non migliora)
|
||||
|
||||
1. **L'alpha del VRP è già tutto nel gate IV-rank binario** ("vendi solo vol ricca"). Il sizing
|
||||
proporzionale al gap IV−RV è ridondante *dato* il gate (Δ ≈ ±0.05, dentro il rumore); usato al
|
||||
posto del gate, riporta dentro le settimane di vol povera e distrugge l'hold-out (−0.36/−0.38).
|
||||
Il carry atteso (IV−RV) NON è un segnale di *intensità* utile oltre il suo segno+rank.
|
||||
2. **Il gate TP01 è la trappola in-sample perfetta** (la lezione macro-gate al contrario): schiva il
|
||||
2022 (−6%→0%) gonfiando l'IS a 2.05, ma nel 2025-26 TP01 è flat proprio nei regimi di vol ricca
|
||||
in cui la vendita gated di put **guadagna** — il gate taglia le settimane migliori dell'hold-out
|
||||
(0.59→−0.14). Short-vol gated e trend sono complementari, non condizionabili l'uno sull'altro.
|
||||
3. **Il DVOL-momentum non aggiunge protezione**: il crash-skip IV-rank>0.90 + il long wing dello
|
||||
spread coprono già il run-up di vol; thr=0 è troppo aggressivo (blocca ~60%), thr=5pt è neutro
|
||||
(mai meglio del baseline su hold-out).
|
||||
|
||||
## Onestà / caveat (invariati)
|
||||
|
||||
Premio MODELLATO su DVOL ATM (no skew), book 1d, f di stress reale non catturato → tutto questo
|
||||
resta dentro il modello; il verdetto massimo possibile era "sleeve modellato migliorato", e non è
|
||||
stato raggiunto. Il DSR sulla griglia è un limite *superiore* (celle correlate + ~20 config storiche
|
||||
di options_vrp_lab/_v2 non contate).
|
||||
|
||||
## Azione
|
||||
|
||||
**Nessuna.** VRP01 resta com'è (`bin + gate_vrp + ivr30 + crash-skip 0.90`, hold-to-expiry).
|
||||
Terzo tentativo di affinamento fallito (dopo active-management e — per il portafoglio — DVOL
|
||||
overlay): il filone "migliorare VRP01 dentro il modello" è da considerarsi **esaurito**; il prossimo
|
||||
passo utile resta quello noto: f di stress reale quando cerbero-bite cattura un crash + skew reale
|
||||
sul long wing.
|
||||
|
||||
Script: `scripts/research/r0701_vrp_refine.py` (`--skip-portfolio` per la sola griglia).
|
||||
@@ -0,0 +1,74 @@
|
||||
# 2026-07-01 — Residual (idiosyncratic) momentum cross-sectional sui 19 major HL — SCARTATO/REDUNDANT
|
||||
|
||||
**Tesi.** STATARB-RESID (thread 4, 2026-06-29) ha mostrato che il momentum del residuo ETH−β·BTC
|
||||
(β OLS rolling, **sgn=+1**: le dislocazioni continuano a 1d) passa quasi tutti i gate su 2 gambe,
|
||||
fallendo solo il deflated-Sharpe (0.929<0.95). Ipotesi qui: portare lo **stesso meccanismo
|
||||
cross-sectional** sui 19 major di XS01 — per ogni alt, residuo vs β·BTC (β OLS rolling B), momentum
|
||||
del residuo (blend z-score [30,90] come XS01 o singolo L), long top-k / short bottom-k, vol-target
|
||||
20% — sperando che la **breadth** (18 stream invece di 1) alzasse il DSR dove il 2-gambe falliva.
|
||||
Script `scripts/research/r0701_xs_residmom.py` (riusa l'harness collaudato `xsec_v2_nonmom`:
|
||||
load_matrix, xs_engine — uguaglianza verificata maxdiff 0 —, deflated_sharpe, StrategyPortfolio).
|
||||
|
||||
Distinzione dal già-testato: IREV (idio-**reversal**, sgn=−1) era FALLITO nel filone C; IMOM di
|
||||
xsec_v2 usava il mercato equal-weight come fattore, B=60 fisso, senza blend ed era solo riferimento.
|
||||
Qui fattore = **BTC** (come STATARB-RESID, BTC escluso dal cross-section), B in griglia, blend
|
||||
z-score, probe con gate di dispersione p30 (parità strutturale con XS01), **selezione in-sample-only**.
|
||||
|
||||
## Setup
|
||||
|
||||
- Universo: 19 major HL certificati (`data/raw/hl_*_1d.parquet`), 913 giorni [2024-01-01 → 2026-07-01],
|
||||
barre vol=0 escluse. Fee 0.10% RT/gamba + sweep fino a 0.30%.
|
||||
- Griglia (57 trial): β-window {60,90,120} × lookback {blend[30,90], 30, 90} × k {3,5} × H {5,10,20}
|
||||
+ 3 probe gated (blend, k5, H10, gate dispersione p30). Cella scelta col solo Sharpe **pre-2025**;
|
||||
hold-out 2025-01-01 bloccato. Causalità: prefix-check ok (max_tail_diff 0).
|
||||
- Baseline (stessa finestra, sleeve attivi): XS01 FULL Sh **1.38** / HOLD **1.51** / DD 11%;
|
||||
book 4-sleeve FULL 2.12 / HOLD 2.21.
|
||||
|
||||
## Esito — collasso OOS della cella in-sample e nessuna cella competitiva
|
||||
|
||||
Cella scelta in-sample: **B60 L30 k3 H20** (IS Sh 2.00):
|
||||
|
||||
```
|
||||
FULL +0.67 | HOLD −0.24 | DD 35% | per-anno 2024 +54% / 2025 −15% / 2026 +5%
|
||||
corr→XS01 +0.54 | corr→TP01 −0.09 | DSR 0.510 (57 trial) | fee-sweep ok (F+0.59 a 0.30%)
|
||||
small-cap $600/min$5: haircut ≈ 0 (ma book multi-gamba → STAT-MODE comunque, come XS01)
|
||||
```
|
||||
|
||||
Portafoglio (il bar del mandato):
|
||||
|
||||
```
|
||||
BASE 4-sleeve FULL 2.12 DD 7.8% | HOLD 2.21 DD 3.5%
|
||||
SOSTITUZIONE XS01 → cand FULL 2.01 (−0.10) | HOLD 1.18 (−1.03) ← distrugge l'hold-out
|
||||
AGGIUNTA 5° sleeve @10/15% HOLD −0.31 / −0.53 ← diluisce
|
||||
MARGINAL vs BOOK: corr +0.23, uplift@10% full −0.04 / hold −0.31, multi-cut 2025 −0.31 / 2026 −0.14
|
||||
```
|
||||
|
||||
**Il verdetto non dipende dalla selezione** — aggregato delle 57 celle:
|
||||
- FULL max **+0.94**, HOLD max **+0.82** (mediane +0.52/+0.33): *nessuna* cella si avvicina a XS01
|
||||
(1.38/1.51), nemmeno cherry-pickando sull'hold-out.
|
||||
- corr→XS01 in [+0.47, +0.71], mediana **+0.62**: è momentum XS01 travestito (la residualizzazione
|
||||
vs β·BTC sposta poco: lo z-scoring cross-sectional di XS01 già rimuove implicitamente il mercato).
|
||||
- Solo 4/57 celle passerebbero un gate "diversificatore" (|corrXS|<0.6, FULL>0.5, HOLD>0), tutte con
|
||||
FULL ≤0.68 e IS incoerente.
|
||||
- **corr(Sharpe in-sample, Sharpe hold-out) attraverso le celle = −0.37**: l'in-sample è
|
||||
anti-predittivo dell'OOS su questa griglia — la firma del rumore, non di un edge con plateau.
|
||||
|
||||
## Perché la tesi è falsa (3 righe)
|
||||
|
||||
1. La breadth non ha alzato il DSR (0.51 ≪ 0.95) perché non c'è edge da deflazionare: il residuo
|
||||
vs β·BTC sul cross-section degli alt NON eredita l'edge del 2-gambe ETH−β·BTC — quel lead vive
|
||||
nella coppia specifica ETH/BTC (i due asset profondi/cointegrati), non nel long-tail degli alt.
|
||||
2. Cross-sectional, la residualizzazione è quasi un no-op: demeaning z-score (XS01) ≈ togliere il
|
||||
mercato → il candidato è una variante rumorosa di XS01 (corrXS mediana 0.62) con meno segnale.
|
||||
3. In-sample = solo 2024 (~1 anno utile dopo il warmup): la selezione onesta atterra su una cella
|
||||
2024-lucky che perde nel 2025-26 — l'ennesima conferma del gate selection-on-holdout.
|
||||
|
||||
## Verdetto: **SCARTATO/REDUNDANT.** Nessuno sleeve, nessun forward-monitor.
|
||||
|
||||
Coerente con il filone momentum-structure (2026-06-29): l'edge di XS01 sta nella sua struttura
|
||||
specifica (blend + gate dispersione), e le sue varianti — incluso l'idio-momentum vs BTC — sono
|
||||
REDUNDANT o peggio. Chiude anche il cerchio su STATARB-RESID: il meccanismo residuo-momentum resta
|
||||
valido SOLO come coppia ETH/BTC (forward-monitor `paper_statarb`), non generalizza al cross-section.
|
||||
Nessun impatto sul book live; config canonica invariata.
|
||||
|
||||
Script: `scripts/research/r0701_xs_residmom.py` (non committato, prefisso r0701_xs).
|
||||
@@ -0,0 +1,69 @@
|
||||
# 2026-07-01 — Stagionalità CROSS-SECTIONAL sull'universo Hyperliquid → SCARTATO
|
||||
|
||||
## Ipotesi
|
||||
|
||||
Effetti calendario **relativi tra i 19 alt major HL** (weekday tilt, turn-of-month,
|
||||
pattern weekend→lunedì nel cross-section), long/short market-neutral. L'angolo è diverso
|
||||
dal prior art già morto (trackF seasonality BTC/ETH = buy&hold travestito;
|
||||
crypto_weekend_signal/overnight = look-ahead; open_drive = artefatto calendario ucciso da
|
||||
`day_boundary_robust`): essendo il segnale **demeaned cross-section**, il buy&hold
|
||||
travestito è strutturalmente escluso… *in prima battuta* (vedi diagnostica beta sotto).
|
||||
|
||||
## Metodo (test statistico PRIMA della strategia)
|
||||
|
||||
Script `scripts/research/r0701_xs_seasonal.py`. Dati `data/raw/hl_*_1d.parquet`, 19 major
|
||||
XS01 (lista `src/portfolio/sleeves.XS_UNIVERSE`), **912 giorni** (2024-01-02 → 2026-07-01,
|
||||
~2.5 anni), 0 barre vol=0. Ritorno relativo x[t,a] = r[t,a] − media cross-section del
|
||||
giorno. Per ogni bucket calendario: **tilt bucket-specifico** per asset = media di x nel
|
||||
bucket − tilt incondizionato dell'asset (isola il calendario dal drift relativo generico).
|
||||
**Persistenza split-half**: Spearman rank-corr del vettore tilt (19 asset) H1 vs H2, per
|
||||
bucket. **Null**: 2000 permutazioni delle etichette entro ciascuna metà → distribuzione del
|
||||
**max-statistic** (controlla il multiple-testing sui bucket e il confound "alpha persistente
|
||||
dell'asset"). Soglia severa p<0.05 sul max. Regola del mandato: senza persistenza →
|
||||
**nessun backtest**.
|
||||
|
||||
## Numeri
|
||||
|
||||
| Test | Statistica | Null 95° pctl | p | Esito |
|
||||
|---|---|---|---|---|
|
||||
| [A] Weekday (7 bucket) | max rank-corr **+0.511** (Wed); Mon +0.38, Sun +0.37, Fri +0.04 | +0.604 | **0.159** | FAIL |
|
||||
| [B] Weekend vs feriali | +0.268 | +0.428 | **0.178** | FAIL |
|
||||
| [C] Turn-of-month (±2gg) | +0.221 | +0.451 | **0.226** | FAIL |
|
||||
| [D] IC weekend→lunedì | FULL −0.053 (t=−1.94); H1 −0.006 (t=−0.17) vs H2 −0.100 (t=−2.4) | — | non persiste | FAIL |
|
||||
|
||||
Con ~65 osservazioni per weekday per metà, lo std di una rank-corr su 19 asset è ~0.24 → il
|
||||
max-su-7 sotto puro rumore arriva a ~0.60: il +0.51 reale è *dentro* il rumore.
|
||||
|
||||
## Diagnostica: perché tutte le rank-corr weekday sono positive?
|
||||
|
||||
Non è tilt idiosincratico, è il **canale-beta**: il mercato EW ha un pattern weekday di
|
||||
*livello* che si ripete nelle due metà (Sun −42/−30 bps, Sat +26/+25, Mon +35/+32) e la
|
||||
dispersione di beta (0.53→1.26) lo proietta nel cross-section — rank-corr(tilt, beta):
|
||||
**Sun −0.642**, Sat +0.325, feriali ~0. Cioè la poca "persistenza" visibile è l'effetto
|
||||
weekday di MERCATO (dominio trackF, già scartato come buy&hold travestito) che rientra
|
||||
dalla finestra via beta. Un eventuale L/S "weekday tilt" sarebbe stato in gran parte un
|
||||
book short-beta-la-domenica — non un edge calendario cross-sectional.
|
||||
|
||||
## Note sui gate non raggiunti
|
||||
|
||||
- **day_boundary_robust** (obbligatorio per ogni effetto calendario): NON eseguibile sui
|
||||
19 alt — i dati HL locali sono **solo 1d**, il confine giorno non è ri-tagliabile. Anche
|
||||
con persistenza, il verdetto massimo sarebbe stato LEAD-forward *condizionato* a barre
|
||||
orarie HL, mai sleeve. Con lo SCARTATO allo step 1 il punto è moot, ma resta il vincolo
|
||||
per chiunque riapra il filone.
|
||||
- **Fee**: mai arrivati al backtest; nota strutturale: un weekday-tilt L/S ribilancia ogni
|
||||
giorno (weekday di domani ≠ oggi) → turnover ~2×gross/die, morte-per-fee quasi certa a
|
||||
0.10% RT anche con edge lordo.
|
||||
- **DSR**: non calcolato (nessuna cella backtestata — il branch strategia esiste nello
|
||||
script ma non si attiva senza persistenza).
|
||||
|
||||
## Verdetto
|
||||
|
||||
**SCARTATO** allo step statistico, senza backtest. Tre motivi: (1) nessuna persistenza
|
||||
split-half sopra il null permutato (p 0.16–0.23 su tutti i test, IC weekend→lunedì non
|
||||
persiste tra le metà); (2) la sola struttura visibile è il canale-beta di un pattern
|
||||
weekday di mercato — la stessa famiglia già scartata in trackF, non un effetto
|
||||
cross-sectional; (3) ~2.5 anni × effetto calendario × turnover giornaliero = rumore +
|
||||
morte-per-fee anche nello scenario migliore.
|
||||
|
||||
File: `scripts/research/r0701_xs_seasonal.py` (deterministico, seed 20260701, ~2 min).
|
||||
@@ -0,0 +1,383 @@
|
||||
"""r0701_breadth_internals.py — BREADTH / MARKET-INTERNALS del mercato ALT come segnale su BTC/ETH.
|
||||
|
||||
TESI (filone 2026-07-01)
|
||||
------------------------
|
||||
Gli "internals" del mercato crypto — la partecipazione degli alt — come segnale direzionale o gate
|
||||
di de-risk su BTC/ETH perp (2 gambe, eseguibile a ~$600):
|
||||
FAM-MA : % di alt sopra la propria SMA(N) (breadth classica)
|
||||
FAM-AD : advance/decline — frazione di advancers, SMA(N) (partecipazione giornaliera)
|
||||
FAM-RS : % di alt che BATTONO BTC sul ritorno a N giorni (risk-appetite relativo, ~mkt-neutral)
|
||||
FAM-TH : breadth-THRUST — delta della breadth MA20 su N giorni (thrust/collapse)
|
||||
Forme: LS (long/short), LF (long/flat), GATE (TP01 * gate binario). Tutte vol-target 20% cap 2x
|
||||
(LS/LF) o ereditano il sizing TP01 (GATE).
|
||||
|
||||
RISCHI NOTI IN PARTENZA (CLAUDE.md, prior art)
|
||||
----------------------------------------------
|
||||
1. MACRO regime-gate (2026-06-29) = SCARTATO: corr->TP01 0.989, il gate lavorava nel 2-3% dei
|
||||
giorni (TP01 e' gia' flat nei crash). Un breadth-gate rischia di essere LO STESSO artefatto:
|
||||
TP01 travestito. Qui DOBBIAMO riportare corr->TP01 + verdetto marginale + "giorni in cui il
|
||||
gate lavora" (gate off E TP01 non gia' flat).
|
||||
2. trend-multiasset = ridondante (corr 0.74): la breadth degli alt e' correlata alla direzione
|
||||
del mercato -> il rischio che breadth>soglia == "BTC sopra trend" e' concreto.
|
||||
3. is_hedge: un segnale che paga solo quando TP01 soffre e' un hedge, non alpha.
|
||||
4. STORIA: l'universo HL parte dal 2024-01 -> ~2.2 anni utili post-warmup. In-sample (pre-HOLDOUT
|
||||
2025-01-01) = SOLO ~8 mesi del 2024. Limite strutturale DICHIARATO: qualunque esito e' al
|
||||
massimo un LEAD, la selezione in-sample poggia su una finestra corta.
|
||||
|
||||
METODO (obbligatorio, CLAUDE.md)
|
||||
--------------------------------
|
||||
- Dati: 51 parquet certificati data/raw/hl_*_1d.parquet; PANEL = 49 alt (esclusi hl_btc/hl_eth
|
||||
dalla breadth; hl_btc usato solo come riferimento per FAM-RS). Barre a volume<=0 = sintetiche
|
||||
-> close mascherato NaN. Breadth definita solo con >= MIN_VALID(20) asset validi alla data.
|
||||
- Causalita': barre HL 1d e barre BTC/ETH 1d (altlib.get, resample Deribit) sono entrambe
|
||||
open-labeled 00:00 UTC -> il close del giorno D e' noto allo stesso istante su entrambe.
|
||||
Allineamento merge_asof backward (allow_exact) sul timestamp; eval_weights shifta la posizione
|
||||
(decisa a close[i], tenuta in i+1). Verifica al.causality_ok sul target end-to-end.
|
||||
- Selezione ONESTA (lezione SELECTION-ON-HOLDOUT 2026-06-29): la cella si sceglie con il SOLO
|
||||
Sharpe in-sample (pre-2025) sul candidato 50/50, MAI sull'hold-out; deflated Sharpe (Bailey &
|
||||
Lopez de Prado) su TUTTE le celle cercate; poi al.marginal_vs_tp01 (multi-cut, has_insample_edge,
|
||||
is_hedge) sulla cella scelta. NB: non si usa al.study_family_honest stock perche' la breadth non
|
||||
esiste pre-2024 e il padding (LS/LF=flat, GATE=TP01 pieno) contaminerebbe il ranking in-sample
|
||||
full-history (le celle GATE erediterebbero lo Sharpe 2019-2024 di TP01); la procedura qui sotto
|
||||
e' il MIRROR esatto di select_cell_insample + deflated_sharpe + study_marginal sulla FINESTRA
|
||||
COMUNE 2024-05+ (stessa libreria, stessi gate).
|
||||
- Fee 0.10% RT + sweep 0-0.30% RT; eval_weights_smallcap a $600.
|
||||
|
||||
USO: uv run python scripts/research/r0701_breadth_internals.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
sys.path.insert(0, str(_ROOT / "scripts" / "research" / "alt"))
|
||||
|
||||
import altlib as al # noqa: E402
|
||||
from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio # noqa: E402
|
||||
|
||||
RAW = _ROOT / "data" / "raw"
|
||||
HOLDOUT = al.HOLDOUT
|
||||
ASSETS = ("BTC", "ETH")
|
||||
MIN_VALID = 20 # asset validi minimi perche' la breadth esista
|
||||
START = pd.Timestamp("2026-01-01", tz="UTC") # placeholder, ridefinito sotto
|
||||
# finestra comune: HL parte 2024-01-01; warmup max = FAM-TH N=100 (20g MA + 100g delta) ~ 120g
|
||||
START = pd.Timestamp("2024-05-05", tz="UTC")
|
||||
|
||||
MA_GRID = (20, 50, 100)
|
||||
THR_GRID = (0.3, 0.5, 0.7)
|
||||
FORMS = ("ls", "lf", "gate")
|
||||
FAMS = ("ma", "ad", "rs", "th")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# PANEL ALT (49 asset, vol=0 mascherato) + riferimento BTC (hl_btc)
|
||||
# ===========================================================================
|
||||
def load_panel():
|
||||
px, vol = {}, {}
|
||||
for f in sorted(glob.glob(str(RAW / "hl_*_1d.parquet"))):
|
||||
sym = Path(f).stem.replace("hl_", "").replace("_1d", "").upper()
|
||||
d = pd.read_parquet(f)
|
||||
idx = pd.to_datetime(d["timestamp"], unit="ms", utc=True)
|
||||
px[sym] = pd.Series(d["close"].values.astype(float), index=idx)
|
||||
vol[sym] = pd.Series(d["volume"].values.astype(float), index=idx)
|
||||
PX = pd.concat(px, axis=1).sort_index()
|
||||
VOL = pd.concat(vol, axis=1).sort_index().reindex_like(PX)
|
||||
PX = PX.mask(VOL <= 0) # barre sintetiche (vol=0) -> NaN (lezione 2026-06-20)
|
||||
btc_ref = PX["BTC"].copy() # riferimento FAM-RS (stessa venue/stesso close time)
|
||||
ALTS = PX.drop(columns=["BTC", "ETH"]) # breadth = SOLO alt
|
||||
return ALTS, btc_ref
|
||||
|
||||
|
||||
def _mask_min_valid(score: pd.Series, n_valid: pd.Series) -> pd.Series:
|
||||
s = score.copy()
|
||||
s[n_valid < MIN_VALID] = np.nan
|
||||
return s
|
||||
|
||||
|
||||
def breadth_ma(ALTS: pd.DataFrame, N: int) -> pd.Series:
|
||||
"""% di alt validi con close > SMA(N). Causale (SMA su dati <= i)."""
|
||||
sma = ALTS.rolling(N, min_periods=N).mean()
|
||||
valid = ALTS.notna() & sma.notna()
|
||||
above = (ALTS > sma) & valid
|
||||
n_valid = valid.sum(axis=1)
|
||||
return _mask_min_valid(above.sum(axis=1) / n_valid.replace(0, np.nan), n_valid)
|
||||
|
||||
|
||||
def breadth_ad(ALTS: pd.DataFrame, N: int) -> pd.Series:
|
||||
"""Advance/decline: frazione di advancers (ret 1g > 0) tra i validi, SMA(N)."""
|
||||
dr = ALTS.pct_change(fill_method=None)
|
||||
valid = dr.notna()
|
||||
adv = ((dr > 0) & valid).sum(axis=1)
|
||||
n_valid = valid.sum(axis=1)
|
||||
frac = adv / n_valid.replace(0, np.nan)
|
||||
frac[n_valid < MIN_VALID] = np.nan
|
||||
return frac.rolling(N, min_periods=N).mean()
|
||||
|
||||
|
||||
def breadth_rs(ALTS: pd.DataFrame, btc_ref: pd.Series, N: int) -> pd.Series:
|
||||
"""% di alt che battono BTC sul ritorno a N giorni (risk-appetite relativo)."""
|
||||
altret = ALTS / ALTS.shift(N) - 1.0
|
||||
btcret = (btc_ref / btc_ref.shift(N) - 1.0)
|
||||
valid = altret.notna() & btcret.notna().values[:, None]
|
||||
beat = altret.gt(btcret, axis=0) & valid
|
||||
n_valid = valid.sum(axis=1)
|
||||
return _mask_min_valid(beat.sum(axis=1) / n_valid.replace(0, np.nan), n_valid)
|
||||
|
||||
|
||||
def breadth_th(ALTS: pd.DataFrame, N: int) -> pd.Series:
|
||||
"""Breadth-THRUST: 0.5 + delta a N giorni della breadth MA20 (thrust>0.5, collapse<0.5)."""
|
||||
b20 = breadth_ma(ALTS, 20)
|
||||
return 0.5 + (b20 - b20.shift(N))
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# FACTORY — target_fn(df, asset) per una cella (fam, N, thr, form)
|
||||
# ===========================================================================
|
||||
_ALTS, _BTC_REF = load_panel()
|
||||
_BREADTH: dict[tuple, pd.Series] = {}
|
||||
for _N in MA_GRID:
|
||||
_BREADTH[("ma", _N)] = breadth_ma(_ALTS, _N)
|
||||
_BREADTH[("ad", _N)] = breadth_ad(_ALTS, _N)
|
||||
_BREADTH[("rs", _N)] = breadth_rs(_ALTS, _BTC_REF, _N)
|
||||
_BREADTH[("th", _N)] = breadth_th(_ALTS, _N)
|
||||
|
||||
_TP01_POS: dict[str, np.ndarray] = {}
|
||||
|
||||
|
||||
def tp01_pos(df: pd.DataFrame, asset: str) -> np.ndarray:
|
||||
if asset not in _TP01_POS:
|
||||
_TP01_POS[asset] = TrendPortfolio(**CANONICAL).target_series(df)
|
||||
return _TP01_POS[asset]
|
||||
|
||||
|
||||
_EPOCH = pd.Timestamp("1970-01-01", tz="UTC")
|
||||
|
||||
|
||||
def _align(b: pd.Series, df: pd.DataFrame) -> np.ndarray:
|
||||
"""Breadth (calendario HL) -> barre BTC/ETH. merge_asof backward, exact ok (stesso
|
||||
istante di close 00:00 UTC). NaN dove la breadth non esiste.
|
||||
NB timestamp via epoca esplicita: .view('int64') su DatetimeIndex tz-aware a risoluzione
|
||||
non-ns (pandas 2.x) da' la SCALA SBAGLIATA -> merge_asof matchava tutto all'ULTIMO valore
|
||||
(broadcast del futuro su tutta la storia = look-ahead che causality_ok non vede, perche'
|
||||
la serie breadth e' un input esterno fisso). Bug trovato e corretto in questa ricerca."""
|
||||
ts_ms = ((b.index - _EPOCH) // pd.Timedelta(milliseconds=1)).astype("int64")
|
||||
g = pd.DataFrame({"timestamp": ts_ms, "b": b.values})
|
||||
g = g.dropna(subset=["b"]).sort_values("timestamp")
|
||||
left = pd.DataFrame({"timestamp": df["timestamp"].astype("int64").values})
|
||||
m = pd.merge_asof(left, g, on="timestamp", direction="backward")
|
||||
return m["b"].values.astype(float)
|
||||
|
||||
|
||||
def factory(tf: str = "1d", fam: str = "ma", N: int = 50, thr: float = 0.5, form: str = "ls"):
|
||||
b_series = _BREADTH[(fam, N)]
|
||||
|
||||
def target_fn(df: pd.DataFrame, asset: str = "") -> np.ndarray:
|
||||
b = _align(b_series, df)
|
||||
if form == "gate":
|
||||
g = np.where(np.isfinite(b), np.where(b >= thr, 1.0, 0.0), 1.0) # no info -> no de-risk
|
||||
return tp01_pos(df, asset) * g
|
||||
if form == "ls":
|
||||
d = np.where(np.isfinite(b), np.where(b >= thr, 1.0, -1.0), 0.0)
|
||||
else: # lf
|
||||
d = np.where(np.isfinite(b), np.where(b >= thr, 1.0, 0.0), 0.0)
|
||||
return al.vol_target(d, df, 0.20, 30, 2.0)
|
||||
return target_fn
|
||||
|
||||
|
||||
GRID = [dict(fam=f, N=n, thr=t, form=fo)
|
||||
for f in FAMS for n in MA_GRID for t in THR_GRID for fo in FORMS]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# DRIVER ONESTO sulla finestra comune (mirror di study_family_honest, stessi gate altlib)
|
||||
# ===========================================================================
|
||||
def cand_trim(fn) -> pd.Series:
|
||||
return al.candidate_daily(fn, tf="1d")[lambda s: s.index >= START]
|
||||
|
||||
|
||||
def abs_verdict_trimmed(fn) -> dict:
|
||||
"""study_weights-equivalente sulla finestra comune 2024-05+ (fee sweep incluso)."""
|
||||
per_asset = {}
|
||||
fee_ok_all = True
|
||||
for a in ASSETS:
|
||||
df = al.get(a, "1d")
|
||||
tgt = np.asarray(fn(df, a), float)
|
||||
mask = (pd.to_datetime(df["datetime"], utc=True) >= START).values
|
||||
dft = df[mask].reset_index(drop=True)
|
||||
tgtt = tgt[mask]
|
||||
base = al.eval_weights(dft, tgtt, fee_side=al.FEE_SIDE)
|
||||
sweep = {f"{2*f*100:.2f}%RT": al.eval_weights(dft, tgtt, fee_side=f)["full"]["sharpe"]
|
||||
for f in al.FEE_SWEEP}
|
||||
fee_ok_all = fee_ok_all and sweep.get("0.20%RT", -9) > 0
|
||||
per_asset[a] = dict(full=base["full"], holdout=base["holdout"],
|
||||
tim=base["time_in_market"], turnover=base["turnover_per_year"],
|
||||
fee_sweep=sweep, yearly=base["yearly"])
|
||||
cell = dict(tf="1d", per_asset=per_asset,
|
||||
min_asset_full_sharpe=round(min(per_asset[a]["full"]["sharpe"] for a in ASSETS), 3),
|
||||
min_asset_holdout_sharpe=round(min(per_asset[a]["holdout"].get("sharpe", 0.0) for a in ASSETS), 3),
|
||||
full_sharpe=round(float(np.mean([per_asset[a]["full"]["sharpe"] for a in ASSETS])), 3),
|
||||
fee_survives=fee_ok_all)
|
||||
return dict(cells=[cell], verdict=al._verdict([cell]))
|
||||
|
||||
|
||||
def gate_work_diag(fn, thr_flat: float = 1e-6) -> dict:
|
||||
"""Deep-dive ridondanza (lezione macro-gate): nei giorni in cui il segnale vorrebbe stare
|
||||
fuori/short, TP01 e' gia' flat da solo? 'lavora' = segnale off/short E TP01 non flat."""
|
||||
out = {}
|
||||
for a in ASSETS:
|
||||
df = al.get(a, "1d")
|
||||
mask = (pd.to_datetime(df["datetime"], utc=True) >= START).values
|
||||
tgt = np.asarray(fn(df, a), float)[mask]
|
||||
tp = tp01_pos(df, a)[mask]
|
||||
off = tgt <= thr_flat # segnale fuori (o short per LS: qui solo "non long")
|
||||
works = off & (tp > thr_flat)
|
||||
out[a] = dict(days_off=round(float(off.mean()), 3),
|
||||
days_gate_works=round(float(works.mean()), 3),
|
||||
tp01_flat_days=round(float((tp <= thr_flat).mean()), 3),
|
||||
corr_pos=round(float(np.corrcoef(tgt, tp)[0, 1]), 3)
|
||||
if np.std(tgt) > 0 and np.std(tp) > 0 else None)
|
||||
return out
|
||||
|
||||
|
||||
def cell_activity(p: dict) -> float:
|
||||
"""Frazione di giorni nello STATO DI MINORANZA del segnale (criterio STRUTTURALE, non di
|
||||
performance: una cella sempre-on e' buy&hold/TP01 travestito, non un segnale di breadth).
|
||||
ls: min(on, off); lf: frazione on... comunque = minoranza; gate: frazione off."""
|
||||
b = _BREADTH[(p["fam"], p["N"])]
|
||||
bb = b[(b.index >= START)].dropna()
|
||||
on = float((bb >= p["thr"]).mean())
|
||||
return round(min(on, 1.0 - on) if p["form"] == "ls" else
|
||||
(on if p["form"] == "lf" else 1.0 - on), 3)
|
||||
|
||||
|
||||
def full_report(label: str, chosen: dict, all_full: list, n_trials: int) -> dict:
|
||||
p = chosen["params"]
|
||||
fn = factory(**p)
|
||||
daily = cand_trim(fn)
|
||||
dsr, sr0 = al.deflated_sharpe(al._sh(daily), all_full, daily)
|
||||
print(f"\n==== {label}: {p} (attivita' {chosen['act']}) ====")
|
||||
print(f" standalone (finestra comune): IS {chosen['insample_sharpe']:+.2f} "
|
||||
f"FULL {chosen['full_sharpe']:+.2f} HOLD {chosen['hold_sharpe']:+.2f}")
|
||||
print(f" deflated Sharpe (su TUTTI i {n_trials} trial cercati): DSR={dsr:.3f} "
|
||||
f"(null-max atteso {sr0:.2f}) PASS>=0.95: {dsr >= 0.95}")
|
||||
|
||||
marg = al.marginal_vs_tp01(daily)
|
||||
absr = abs_verdict_trimmed(fn)
|
||||
abs_grade = absr["verdict"]["grade"]
|
||||
earns_slot = (abs_grade != "FAIL" and marg.get("marginal_verdict") == "ADDS"
|
||||
and marg.get("robust_oos", False) and marg.get("beats_noise_null", False)
|
||||
and not marg.get("is_hedge", False))
|
||||
rep = dict(name=f"BREADTH {p}", marginal=marg, abs_grade=abs_grade,
|
||||
marginal_verdict=marg.get("marginal_verdict"), earns_slot=earns_slot)
|
||||
print("\n" + al.fmt_marginal(rep))
|
||||
honest = earns_slot and dsr >= 0.95
|
||||
print(f" earns_slot_honest = earns_slot({earns_slot}) AND DSR>=0.95({dsr >= 0.95}) "
|
||||
f"=> {honest}")
|
||||
|
||||
# assoluto trimmed + fee sweep
|
||||
c = absr["cells"][0]
|
||||
print(f"\n---- ASSOLUTO (finestra comune, verdetto {abs_grade}): "
|
||||
f"minFull {c['min_asset_full_sharpe']:+.2f} minHold {c['min_asset_holdout_sharpe']:+.2f} "
|
||||
f"feeOK={c['fee_survives']}")
|
||||
for a in ASSETS:
|
||||
pa = c["per_asset"][a]
|
||||
yr = " ".join(f"{y}:{d['ret']*100:+.0f}%" for y, d in pa["yearly"].items())
|
||||
print(f" {a}: full Sh {pa['full']['sharpe']:+.2f} DD {pa['full']['maxdd']*100:.0f}% "
|
||||
f"hold Sh {pa['holdout'].get('sharpe', 0):+.2f} tim {pa['tim']} "
|
||||
f"turn/y {pa['turnover']} | {yr}")
|
||||
print(f" fee sweep: {pa['fee_sweep']}")
|
||||
|
||||
# causalita' + smallcap $600
|
||||
ca = al.causality_ok(fn, tf="1d")
|
||||
print(f"\n---- causality_ok: {ca['ok']} (max_tail_diff {ca['max_tail_diff']}, checked {ca['checked']})")
|
||||
for a in ASSETS:
|
||||
df = al.get(a, "1d")
|
||||
mask = (pd.to_datetime(df["datetime"], utc=True) >= START).values
|
||||
dft = df[mask].reset_index(drop=True)
|
||||
tgtt = np.asarray(fn(df, a), float)[mask]
|
||||
sc = al.eval_weights_smallcap(dft, tgtt, capital=600.0, min_order=5.0)
|
||||
print(f" smallcap $600 {a}: modeled Sh {sc['modeled']['sharpe']:+.2f} -> "
|
||||
f"real {sc['realistic']['sharpe']:+.2f} (haircut {sc['sharpe_haircut']:+.2f}, "
|
||||
f"{sc['n_executed_trades']} trade eseguiti)")
|
||||
|
||||
# deep-dive ridondanza col trend (lezione macro-gate)
|
||||
print("---- RIDONDANZA COL TREND (il rischio n.1):")
|
||||
for a, d in gate_work_diag(fn).items():
|
||||
print(f" {a}: corr(pos, TP01pos) {d['corr_pos']} giorni segnale-off {d['days_off']} "
|
||||
f"TP01-gia'-flat {d['tp01_flat_days']} GIORNI IN CUI LAVORA {d['days_gate_works']}")
|
||||
return dict(params=p, dsr=round(float(dsr), 3), earns_slot=earns_slot,
|
||||
earns_slot_honest=honest, marginal_verdict=marg.get("marginal_verdict"),
|
||||
abs_grade=abs_grade, corr_tp01=marg.get("corr_full"),
|
||||
is_hedge=marg.get("is_hedge"))
|
||||
|
||||
|
||||
def main():
|
||||
print(f"=== r0701 BREADTH/INTERNALS — panel: {_ALTS.shape[1]} alt, "
|
||||
f"{_ALTS.index[0].date()} -> {_ALTS.index[-1].date()} | finestra analisi {START.date()}+ "
|
||||
f"(in-sample {START.date()} -> {HOLDOUT.date()} = ~8 mesi; storia ~2.2y: LIMITE DICHIARATO)")
|
||||
nv = _ALTS.notna().sum(axis=1)
|
||||
print(f" asset validi/D: min {int(nv.min())} med {int(nv.median())} max {int(nv.max())} "
|
||||
f"(MIN_VALID={MIN_VALID})")
|
||||
|
||||
# ---- 1. tutte le celle: Sharpe in-sample (selezione) + full (DSR) sulla finestra comune
|
||||
rows = []
|
||||
for p in GRID:
|
||||
fn = factory(**p)
|
||||
daily = cand_trim(fn)
|
||||
ins = daily[daily.index < HOLDOUT]
|
||||
if len(ins) < 60 or daily.std() == 0:
|
||||
continue
|
||||
rows.append(dict(params=p, act=cell_activity(p),
|
||||
insample_sharpe=round(al._sh(ins), 3),
|
||||
full_sharpe=round(al._sh(daily), 3),
|
||||
hold_sharpe=round(al._sh(daily[daily.index >= HOLDOUT]), 3)))
|
||||
rows.sort(key=lambda r: r["insample_sharpe"], reverse=True)
|
||||
all_full = [r["full_sharpe"] for r in rows]
|
||||
print(f"\n---- GRIGLIA: {len(GRID)} celle, {len(rows)} valutabili; "
|
||||
f"full>0: {sum(1 for s in all_full if s > 0)}/{len(all_full)}")
|
||||
print("---- TOP-15 per Sharpe IN-SAMPLE (selezione onesta: MAI sull'hold-out) "
|
||||
"[hold mostrato solo per trasparenza; act = frazione stato di minoranza]")
|
||||
for r in rows[:15]:
|
||||
p = r["params"]
|
||||
print(f" {p['fam']:>2s} N={p['N']:>3d} thr={p['thr']} {p['form']:>4s} act={r['act']:.2f} | "
|
||||
f"IS {r['insample_sharpe']:+.2f} full {r['full_sharpe']:+.2f} hold {r['hold_sharpe']:+.2f}")
|
||||
|
||||
# ---- 2. PRIMARIO: cella scelta in-sample su TUTTA la griglia (procedura onesta pura)
|
||||
out1 = full_report("CELLA SCELTA IN-SAMPLE (tutta la griglia)", rows[0], all_full, len(rows))
|
||||
|
||||
# ---- 3. SECONDARIO (dichiarato): sole celle ATTIVE (minoranza >=10% — criterio strutturale
|
||||
# deciso a priori, NON di performance; DSR sempre deflazionato su TUTTI i trial).
|
||||
active = [r for r in rows if r["act"] >= 0.10]
|
||||
print(f"\n---- CELLE ATTIVE (act>=0.10): {len(active)}/{len(rows)}")
|
||||
out2 = None
|
||||
if active and active[0]["params"] != rows[0]["params"]:
|
||||
out2 = full_report("SECONDARIO: best cella ATTIVA in-sample", active[0], all_full, len(rows))
|
||||
|
||||
# ---- 4. marginal sui best per-forma tra le ATTIVE (trasparenza: il verdetto per forma)
|
||||
print("\n---- VERDETTO MARGINALE dei best-IN-SAMPLE ATTIVI per forma (contesto):")
|
||||
for fo in FORMS:
|
||||
sub = [r for r in active if r["params"]["form"] == fo]
|
||||
if not sub:
|
||||
continue
|
||||
rp = sub[0]["params"]
|
||||
m = al.marginal_vs_tp01(cand_trim(factory(**rp)))
|
||||
uh = m["blends"]["w25"]["uplift_hold"]
|
||||
print(f" {fo:>4s} {rp} act={sub[0]['act']:.2f}: {m.get('marginal_verdict')} "
|
||||
f"corr {m.get('corr_full')} IS-edge {m.get('cand_insample_sharpe')} "
|
||||
f"is_hedge {m.get('is_hedge')} uplift w25 full {m['blends']['w25']['uplift_full']:+.3f} "
|
||||
f"hold {uh if uh is None else format(uh, '+.3f')} multicut {m.get('multicut_uplift')}")
|
||||
|
||||
print("\n==== SINTESI ====")
|
||||
print(f" primario: {out1}")
|
||||
print(f" secondario (attive): {out2}")
|
||||
print("==== FINE r0701 ====")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,387 @@
|
||||
"""r0701_funding_ts — FUNDING RATE come segnale TIME-SERIES direzionale su BTC/ETH (NON carry).
|
||||
|
||||
2026-07-01. Ipotesi: il funding orario Hyperliquid (proxy di POSIZIONAMENTO/sentiment dei perp)
|
||||
contiene informazione direzionale a orizzonte giornaliero su BTC/ETH. Famiglia (griglia modesta):
|
||||
- FADE : z-score del funding estremo-positivo = affollamento long -> SHORT (e viceversa)
|
||||
- FOLLOW : funding in espansione = domanda long persistente -> LONG (sentiment momentum)
|
||||
- GATE : trend TP01-like long-flat, FLAT quando il funding e' affollato (z>=thr, de-risk)
|
||||
- DIVERGE : momentum prezzo 20d con funding NON affollato -> follow; affollato -> fade
|
||||
Griglia: 4 forme x lookback z {7,14,30,60}g x soglia {0.5,1.0,1.5} = 48 celle, solo 1d.
|
||||
|
||||
PRIOR ART (non ripetuto): FC01 carry cross-sectional delta-neutral -> SCARTATO
|
||||
(docs/diary/2026-06-22-funding-carry-hl.md); funding price-clock intraday -> FAIL (onda intraday).
|
||||
Qui il funding e' un SEGNALE time-series direzionale su BTC/ETH perp (2 gambe, eseguibile a ~$600),
|
||||
non un cashflow da incassare.
|
||||
|
||||
DATI: data/raw/hlfund_{btc,eth}_1h.parquet (funding orario HL: 2023-05-12 -> 2026-06-22; primi
|
||||
~27 giorni a cadenza 8h, poi oraria, 0 gap; certificato nel diario 2026-06-22). Prezzi certificati
|
||||
Deribit via altlib.get (1d resampled leak-free).
|
||||
|
||||
CAUSALITA' (il punto delicato): le barre 1d sono OPEN-LABELED (datetime = 00:00 UTC del giorno D;
|
||||
il close della barra D e' noto alle 00:00 di D+1). Il feature-day D aggrega i SOLI stamp funding
|
||||
in [D 00:00, D+24h) — l'ultimo alle 23:00 — quindi tutto e' noto PRIMA della decisione al close
|
||||
della barra D. eval_weights poi shifta: target[D] e' tenuto durante la barra D+1. Nessun leak
|
||||
strutturale; in piu' prefix-check esplicito.
|
||||
|
||||
VALUTAZIONE su finestra TRONCATA alla copertura funding (2023-05-12..2026-06-21), NON sul frame
|
||||
prezzi 2018+: fuori copertura il target sarebbe zero per costruzione e i giorni-zero (a) GONFIANO
|
||||
il T del deflated-Sharpe (anti-conservativo) e (b) DILUISCONO cand_insample_sharpe (gate
|
||||
has_insample_edge scatterebbe a vuoto). La logica di study_family_honest e' replicata ESATTAMENTE
|
||||
sui frame troncati coi primitivi altlib: selezione cella IN-SAMPLE-ONLY (mai sul hold-out) ->
|
||||
study_marginal gates (ADDS + robust_oos + has_insample_edge + not is_hedge) -> deflated-Sharpe
|
||||
>= 0.95 sull'INTERA griglia. Cross-check con study_marginal non-troncato riportato in coda.
|
||||
|
||||
CAVEAT STORIA: funding solo dal 2023-05 (~3.1 anni). In-sample pre-HOLDOUT ~1.6 anni (meno il
|
||||
warmup z), hold-out 2025-01+ ~1.5 anni. Finestra corta: qualunque PASS andrebbe comunque in
|
||||
forward-monitor, e un FAIL su questa finestra non e' appellabile a "regime sfortunato".
|
||||
|
||||
Run: cd /opt/docker/PythagorasGoal && uv run python scripts/research/r0701_funding_ts.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent / "alt"))
|
||||
import altlib as al # noqa: E402
|
||||
|
||||
ASSETS = ("BTC", "ETH")
|
||||
FORMS = ("fade", "follow", "gate", "diverge")
|
||||
LOOKBACKS = (7, 14, 30, 60)
|
||||
THRESHOLDS = (0.5, 1.0, 1.5)
|
||||
EARLY_8H_END = pd.Timestamp("2023-06-11", tz="UTC") # fino a qui cadenza 8h (3 stamp/giorno)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# DATI FUNDING — aggregazione giornaliera causale
|
||||
# ===========================================================================
|
||||
@lru_cache(maxsize=16)
|
||||
def daily_funding(asset: str, back_h: int = 0) -> pd.DataFrame:
|
||||
"""Funding giornaliero = SOMMA degli stamp orari nella finestra [D-back_h, D+24h-back_h).
|
||||
back_h=0 (default) = giorno UTC pieno [D, D+24h): tutti gli stamp (ultimo 23:00) sono noti
|
||||
al close della barra open-labeled D (= 00:00 di D+1) -> causale. back_h>0 sposta la finestra
|
||||
INDIETRO (sempre causale) — usato solo dal boundary-shift check.
|
||||
'valid' = giorno con copertura piena (>=20 stamp orari; >=3 nell'era 8h iniziale)."""
|
||||
p = al.DATA_DIR / f"hlfund_{asset.lower()}_1h.parquet"
|
||||
d = pd.read_parquet(p)
|
||||
idx = pd.DatetimeIndex(pd.to_datetime(d.index, utc=True)) + pd.Timedelta(hours=back_h)
|
||||
day = idx.floor("1D")
|
||||
g = pd.Series(d["funding"].values.astype(float), index=day).groupby(level=0)
|
||||
out = pd.DataFrame({"fday": g.sum(), "n": g.count()})
|
||||
early = out.index <= EARLY_8H_END
|
||||
out["valid"] = np.where(early, out["n"] >= 3, out["n"] >= 20)
|
||||
return out
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def fund_window() -> tuple:
|
||||
"""Intersezione BTC/ETH dei giorni funding validi (a back_h=0)."""
|
||||
los, his = [], []
|
||||
for a in ASSETS:
|
||||
v = daily_funding(a)
|
||||
vd = v.index[v["valid"].values]
|
||||
los.append(vd.min()); his.append(vd.max())
|
||||
return max(los), min(his)
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def get_trunc(asset: str, tf: str = "1d") -> pd.DataFrame:
|
||||
"""Prezzi certificati troncati alla copertura funding (vedi docstring modulo)."""
|
||||
lo, hi = fund_window()
|
||||
df = al.get(asset, tf)
|
||||
day = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)).floor("1D")
|
||||
m = (day >= lo) & (day <= hi)
|
||||
return df.loc[m].reset_index(drop=True)
|
||||
|
||||
|
||||
def aligned_fday(df: pd.DataFrame, asset: str, back_h: int = 0) -> np.ndarray:
|
||||
"""Funding giornaliero allineato alle barre di df (NaN dove manca/incompleto)."""
|
||||
fd = daily_funding(asset, back_h)
|
||||
day = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)).floor("1D")
|
||||
return fd["fday"].where(fd["valid"]).reindex(day).values.astype(float)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# FAMIGLIA DI SEGNALI — factory(tf, form, lb, thr) -> target_fn(df, asset)
|
||||
# ===========================================================================
|
||||
def make_target(tf: str = "1d", form: str = "fade", lb: int = 30, thr: float = 1.0,
|
||||
back_h: int = 0):
|
||||
def target(df: pd.DataFrame, asset: str) -> np.ndarray:
|
||||
f = aligned_fday(df, asset, back_h)
|
||||
z = al.zscore(f, lb) # causale: rolling fino a i incluso
|
||||
c = pd.Series(df["close"].values.astype(float))
|
||||
if form == "fade": # affollamento long -> short (e viceversa)
|
||||
d = np.where(z >= thr, -1.0, np.where(z <= -thr, 1.0, 0.0))
|
||||
elif form == "follow": # funding come sentiment momentum
|
||||
d = np.where(z >= thr, 1.0, np.where(z <= -thr, -1.0, 0.0))
|
||||
elif form == "gate": # trend long-flat, flat se affollato
|
||||
m30 = np.nan_to_num(np.sign(c.pct_change(30).values))
|
||||
m90 = np.nan_to_num(np.sign(c.pct_change(90).values))
|
||||
trend = ((m30 + m90) > 0).astype(float)
|
||||
zz = np.where(np.isfinite(z), z, np.inf) # z ignoto -> conservativo: flat
|
||||
d = trend * (zz < thr).astype(float)
|
||||
elif form == "diverge": # mossa non affollata -> follow; affollata -> fade
|
||||
mom = np.nan_to_num(np.sign(c.pct_change(20).values))
|
||||
d = np.where(z >= thr, -mom, np.where(z <= -thr, mom, 0.0))
|
||||
else:
|
||||
raise ValueError(form)
|
||||
return al.vol_target(np.nan_to_num(d), df, 0.20, 30, 2.0)
|
||||
return target
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# VALUTAZIONE (replica study_family_honest su frame troncati)
|
||||
# ===========================================================================
|
||||
def cell_daily(target_fn, fee_side: float = al.FEE_SIDE) -> pd.Series:
|
||||
"""Serie daily netta 50/50 BTC+ETH del candidato (convenzione candidate_daily)."""
|
||||
series = {}
|
||||
for a in ASSETS:
|
||||
df = get_trunc(a)
|
||||
ev = al.eval_weights(df, target_fn(df, a), fee_side=fee_side)
|
||||
series[a] = pd.Series(ev["net"], index=ev["idx"])
|
||||
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
|
||||
return al._to_daily(0.5 * J["BTC"] + 0.5 * J["ETH"])
|
||||
|
||||
|
||||
def scan_family() -> list[dict]:
|
||||
rows = []
|
||||
for form in FORMS:
|
||||
for lb in LOOKBACKS:
|
||||
for thr in THRESHOLDS:
|
||||
daily = cell_daily(make_target(form=form, lb=lb, thr=thr))
|
||||
ins = daily[daily.index < al.HOLDOUT]
|
||||
hold = daily[daily.index >= al.HOLDOUT]
|
||||
rows.append(dict(
|
||||
form=form, lb=lb, thr=thr,
|
||||
insample_sharpe=round(al._sh(ins), 3) if len(ins) > 60 else float("nan"),
|
||||
full_sharpe=round(al._sh(daily), 3),
|
||||
hold_sharpe=round(al._sh(hold), 3) if len(hold) > 60 else float("nan")))
|
||||
return rows
|
||||
|
||||
|
||||
def absolute_study(target_fn, name: str) -> dict:
|
||||
"""study_weights-equivalente sui frame troncati (fee sweep 0.00-0.30% RT incluso)."""
|
||||
per_asset = {}
|
||||
fee_ok_all = True
|
||||
for a in ASSETS:
|
||||
df = get_trunc(a)
|
||||
tgt = target_fn(df, a)
|
||||
base = al.eval_weights(df, tgt, fee_side=al.FEE_SIDE)
|
||||
sweep = {f"{2 * f * 100:.2f}%RT": al.eval_weights(df, tgt, fee_side=f)["full"]["sharpe"]
|
||||
for f in al.FEE_SWEEP}
|
||||
fee_ok_all = fee_ok_all and sweep.get("0.20%RT", -9) > 0
|
||||
per_asset[a] = dict(full=base["full"], holdout=base["holdout"],
|
||||
tim=base["time_in_market"], turnover=base["turnover_per_year"],
|
||||
fee_sweep=sweep, yearly=base["yearly"])
|
||||
cells = [dict(tf="1d", per_asset=per_asset,
|
||||
min_asset_full_sharpe=round(min(per_asset[a]["full"]["sharpe"] for a in ASSETS), 3),
|
||||
min_asset_holdout_sharpe=round(min(per_asset[a]["holdout"].get("sharpe", 0.0) for a in ASSETS), 3),
|
||||
full_sharpe=round(float(np.mean([per_asset[a]["full"]["sharpe"] for a in ASSETS])), 3),
|
||||
fee_survives=fee_ok_all)]
|
||||
return dict(name=name, kind="weights", cells=cells, verdict=al._verdict(cells))
|
||||
|
||||
|
||||
def prefix_check(target_fn, tail: int = 60) -> float:
|
||||
"""Consistenza online (guardia look-ahead): il target ricalcolato su un prefisso troncato
|
||||
deve coincidere col target(full) sugli stessi indici. Ritorna il max scostamento."""
|
||||
worst = 0.0
|
||||
for a in ASSETS:
|
||||
df = get_trunc(a)
|
||||
full = np.nan_to_num(np.asarray(target_fn(df, a), float))
|
||||
n = len(df)
|
||||
for cut in (int(n * 0.80), int(n * 0.92)):
|
||||
sub = df.iloc[:cut].reset_index(drop=True)
|
||||
s = np.nan_to_num(np.asarray(target_fn(sub, a), float))
|
||||
worst = max(worst, float(np.max(np.abs(s[cut - tail:cut] - full[cut - tail:cut]))))
|
||||
return worst
|
||||
|
||||
|
||||
def boundary_check(form: str, lb: int, thr: float, offsets=(0, 3, 6, 9, 12)) -> dict:
|
||||
"""Lezione day_boundary: sposto INDIETRO di back_h ore la finestra di aggregazione del
|
||||
funding (sempre causale). Un effetto di posizionamento reale non cambia segno."""
|
||||
B = al.tp01_baseline_daily()
|
||||
out = {}
|
||||
for off in offsets:
|
||||
daily = cell_daily(make_target(form=form, lb=lb, thr=thr, back_h=off))
|
||||
J = pd.concat({"B": B, "C": daily}, axis=1, join="inner").dropna()
|
||||
up = al._sh(0.75 * J["B"] + 0.25 * J["C"]) - al._sh(J["B"]) if len(J) > 30 else float("nan")
|
||||
out[off] = dict(full_sharpe=round(al._sh(daily), 3), uplift_w25=round(up, 3))
|
||||
ups = [v["uplift_w25"] for v in out.values() if np.isfinite(v["uplift_w25"])]
|
||||
shs = [v["full_sharpe"] for v in out.values()]
|
||||
return dict(per_offset=out,
|
||||
sharpe_sign_stable=bool(min(shs) * max(shs) >= 0 or max(map(abs, shs)) < 0.1),
|
||||
uplift_spread=round(max(ups) - min(ups), 3) if ups else None)
|
||||
|
||||
|
||||
def trend_only_target(df: pd.DataFrame, asset: str = "") -> np.ndarray:
|
||||
"""CONTROLLO DECISIVO (lezione TP01-DVOL-overlay): lo STESSO trend long-flat della forma
|
||||
'gate' ma SENZA il gate funding. Se fa uguale/meglio, il funding non aggiunge nulla."""
|
||||
c = pd.Series(df["close"].values.astype(float))
|
||||
m30 = np.nan_to_num(np.sign(c.pct_change(30).values))
|
||||
m90 = np.nan_to_num(np.sign(c.pct_change(90).values))
|
||||
trend = ((m30 + m90) > 0).astype(float)
|
||||
return al.vol_target(trend, df, 0.20, 30, 2.0)
|
||||
|
||||
|
||||
def smallcap_check(target_fn) -> dict:
|
||||
out = {}
|
||||
for a in ASSETS:
|
||||
df = get_trunc(a)
|
||||
sc = al.eval_weights_smallcap(df, target_fn(df, a), capital=600.0, min_order=5.0)
|
||||
out[a] = dict(modeled_sh=sc["modeled"]["sharpe"], realistic_sh=sc["realistic"]["sharpe"],
|
||||
haircut=sc["sharpe_haircut"], n_trades=sc["n_executed_trades"])
|
||||
return out
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
def main():
|
||||
print("=" * 88)
|
||||
print("r0701_funding_ts — funding HL come segnale TS direzionale su BTC/ETH (non carry)")
|
||||
print("=" * 88)
|
||||
|
||||
# --- 1. data-first: qualita'/copertura --------------------------------------------
|
||||
lo, hi = fund_window()
|
||||
print("\n[1] DATI FUNDING")
|
||||
for a in ASSETS:
|
||||
fd = daily_funding(a)
|
||||
v = fd["valid"]
|
||||
ann = fd.loc[v, "fday"].mean() * 365.25 * 100
|
||||
print(f" {a}: giorni validi {int(v.sum())}/{len(fd)} "
|
||||
f"finestra {fd.index[0].date()} -> {fd.index[-1].date()} "
|
||||
f"funding medio {ann:+.1f}%/anno "
|
||||
f"std daily {fd.loc[v, 'fday'].std() * 1e4:.2f} bps")
|
||||
print(f" finestra comune valida: {lo.date()} -> {hi.date()} "
|
||||
f"({(hi - lo).days} giorni, ~{(hi - lo).days / 365.25:.1f} anni)")
|
||||
n_ins = (al.HOLDOUT - lo).days
|
||||
n_hold = (hi - al.HOLDOUT).days
|
||||
print(f" in-sample pre-HOLDOUT ~{n_ins}g ({n_ins / 365.25:.1f}a), "
|
||||
f"hold-out ~{n_hold}g ({n_hold / 365.25:.1f}a) <-- STORIA CORTA, caveat")
|
||||
|
||||
# --- 2. scan famiglia (48 celle, selezione IN-SAMPLE-ONLY) -------------------------
|
||||
print("\n[2] SCAN FAMIGLIA (4 forme x lb{7,14,30,60} x thr{0.5,1.0,1.5} = 48 celle, 1d)")
|
||||
rows = scan_family()
|
||||
valid = [r for r in rows if np.isfinite(r["insample_sharpe"])]
|
||||
valid.sort(key=lambda r: r["insample_sharpe"], reverse=True)
|
||||
print(f" celle valide {len(valid)}/{len(rows)}; top-8 per Sharpe IN-SAMPLE "
|
||||
f"(hold-out mostrato SOLO per trasparenza, mai per selezione):")
|
||||
print(f" {'form':8s} {'lb':>3s} {'thr':>4s} {'IS':>7s} {'FULL':>7s} {'HOLD':>7s}")
|
||||
for r in valid[:8]:
|
||||
print(f" {r['form']:8s} {r['lb']:3d} {r['thr']:4.1f} {r['insample_sharpe']:7.2f} "
|
||||
f"{r['full_sharpe']:7.2f} {r['hold_sharpe']:7.2f}")
|
||||
per_form = {f: max((r["insample_sharpe"] for r in valid if r["form"] == f), default=float("nan"))
|
||||
for f in FORMS}
|
||||
print(f" best IS per forma: {per_form}")
|
||||
|
||||
chosen = valid[0]
|
||||
print(f"\n CELLA SCELTA (in-sample-only): {chosen['form']} lb={chosen['lb']} thr={chosen['thr']} "
|
||||
f"(IS {chosen['insample_sharpe']}, FULL {chosen['full_sharpe']}, HOLD {chosen['hold_sharpe']})")
|
||||
fn = make_target(form=chosen["form"], lb=chosen["lb"], thr=chosen["thr"])
|
||||
daily = cell_daily(fn)
|
||||
|
||||
# --- 3. deflated Sharpe sull'INTERA griglia ----------------------------------------
|
||||
all_full = [r["full_sharpe"] for r in rows]
|
||||
dsr, sr0 = al.deflated_sharpe(al._sh(daily), all_full, daily)
|
||||
dsr_pass = bool(np.isfinite(dsr) and dsr >= 0.95)
|
||||
print(f"\n[3] DEFLATED SHARPE (griglia {len(rows)} celle): "
|
||||
f"DSR={dsr:.3f} (null-max atteso {sr0:.2f}) PASS(>=0.95)={dsr_pass}")
|
||||
|
||||
# --- 4. assoluto + marginale (gates study_marginal) --------------------------------
|
||||
print("\n[4] ASSOLUTO (frame troncati, fee sweep 0.00-0.30% RT)")
|
||||
absolute = absolute_study(fn, f"R0701-FUND-{chosen['form'].upper()}")
|
||||
print(al.fmt(absolute))
|
||||
c0 = absolute["cells"][0]
|
||||
for a in ASSETS:
|
||||
pa = c0["per_asset"][a]
|
||||
print(f" {a}: TIM={pa['tim']} turnover/anno={pa['turnover']} fee_sweep={pa['fee_sweep']}")
|
||||
|
||||
print("\n[5] MARGINALE vs TP01 (finestra comune col baseline)")
|
||||
marg = al.marginal_vs_tp01(daily)
|
||||
abs_grade = absolute["verdict"]["grade"]
|
||||
earns_slot = (abs_grade != "FAIL" and marg.get("marginal_verdict") == "ADDS"
|
||||
and marg.get("robust_oos", False) and marg.get("beats_noise_null", False)
|
||||
and not marg.get("is_hedge", False))
|
||||
rep = dict(name=f"R0701-FUND {chosen['form']} lb{chosen['lb']} thr{chosen['thr']}",
|
||||
absolute=absolute, marginal=marg, abs_grade=abs_grade,
|
||||
marginal_verdict=marg.get("marginal_verdict"), earns_slot=earns_slot)
|
||||
print(al.fmt_marginal(rep))
|
||||
earns_honest = bool(earns_slot and dsr_pass)
|
||||
print(f"\n EARNS_SLOT (marginal) = {earns_slot} EARNS_SLOT_HONEST (con DSR) = {earns_honest}")
|
||||
|
||||
# --- 5bis. controllo di attribuzione: il funding aggiunge qualcosa al trend nudo? ----
|
||||
print("\n[5bis] CONTROLLO DECISIVO — trend long-flat IDENTICO ma SENZA gate funding")
|
||||
d_tr = cell_daily(trend_only_target)
|
||||
tr_ins, tr_hold = d_tr[d_tr.index < al.HOLDOUT], d_tr[d_tr.index >= al.HOLDOUT]
|
||||
JJ = pd.concat({"G": daily, "T": d_tr}, axis=1, join="inner").dropna()
|
||||
print(f" trend NUDO: IS {al._sh(tr_ins):.2f} FULL {al._sh(d_tr):.2f} HOLD {al._sh(tr_hold):.2f}")
|
||||
print(f" trend+GATE: IS {chosen['insample_sharpe']:.2f} FULL {chosen['full_sharpe']:.2f} "
|
||||
f"HOLD {chosen['hold_sharpe']:.2f}")
|
||||
print(f" corr(gated, nudo) = {JJ['G'].corr(JJ['T']):.3f} "
|
||||
f"delta FULL = {al._sh(JJ['G']) - al._sh(JJ['T']):+.3f} "
|
||||
f"delta HOLD = {al._sh(JJ['G'][JJ.index >= al.HOLDOUT]) - al._sh(JJ['T'][JJ.index >= al.HOLDOUT]):+.3f}")
|
||||
attribution = dict(trend_nudo=dict(IS=round(al._sh(tr_ins), 3), FULL=round(al._sh(d_tr), 3),
|
||||
HOLD=round(al._sh(tr_hold), 3)),
|
||||
corr_gated_nudo=round(float(JJ["G"].corr(JJ["T"])), 3),
|
||||
delta_full=round(al._sh(JJ["G"]) - al._sh(JJ["T"]), 3),
|
||||
delta_hold=round(al._sh(JJ["G"][JJ.index >= al.HOLDOUT])
|
||||
- al._sh(JJ["T"][JJ.index >= al.HOLDOUT]), 3))
|
||||
|
||||
# --- 5ter. la migliore cella PURO-funding (fade/follow/diverge, senza trend) ---------
|
||||
pure = [r for r in valid if r["form"] != "gate"]
|
||||
bp = pure[0] if pure else None
|
||||
if bp:
|
||||
print(f"\n[5ter] MIGLIOR CELLA PURO-FUNDING (no trend): {bp['form']} lb={bp['lb']} "
|
||||
f"thr={bp['thr']} IS {bp['insample_sharpe']} FULL {bp['full_sharpe']} "
|
||||
f"HOLD {bp['hold_sharpe']}")
|
||||
d_bp = cell_daily(make_target(form=bp["form"], lb=bp["lb"], thr=bp["thr"]))
|
||||
m_bp = al.marginal_vs_tp01(d_bp)
|
||||
print(f" marginale vs TP01: {m_bp.get('marginal_verdict')} corr {m_bp.get('corr_full')} "
|
||||
f"IS-edge {m_bp.get('cand_insample_sharpe')} "
|
||||
f"uplift w25 full {m_bp['blends']['w25']['uplift_full']:+.3f} / "
|
||||
f"hold {m_bp['blends']['w25']['uplift_hold']:+.3f}")
|
||||
|
||||
# --- 6. realism: prefix / boundary / smallcap ---------------------------------------
|
||||
print("\n[6] REALISM CHECKS (cella scelta)")
|
||||
worst = prefix_check(fn)
|
||||
print(f" prefix-consistency (guardia look-ahead): max diff = {worst:.2e} "
|
||||
f"({'OK' if worst < 1e-9 else 'ATTENZIONE'})")
|
||||
bnd = boundary_check(chosen["form"], chosen["lb"], chosen["thr"])
|
||||
print(f" boundary-shift (finestra funding -0/3/6/9/12h): {bnd['per_offset']}")
|
||||
print(f" sharpe_sign_stable={bnd['sharpe_sign_stable']} uplift_spread={bnd['uplift_spread']}")
|
||||
sc = smallcap_check(fn)
|
||||
print(f" smallcap $600 (min order $5): {sc}")
|
||||
|
||||
# --- 7. cross-check non troncato (footnote) -----------------------------------------
|
||||
print("\n[7] CROSS-CHECK study_marginal NON troncato (frame 2018+, diluito dagli zeri "
|
||||
"pre-copertura: footnote, non il giudizio primario)")
|
||||
sm_full = al.study_marginal(f"R0701-FUND-XCHK {chosen['form']}", fn, tf="1d")
|
||||
print(f" abs={sm_full['abs_grade']} marginal={sm_full['marginal_verdict']} "
|
||||
f"earns_slot={sm_full['earns_slot']}")
|
||||
|
||||
# --- 8. verdetto --------------------------------------------------------------------
|
||||
summary = dict(
|
||||
chosen=dict(form=chosen["form"], lb=chosen["lb"], thr=chosen["thr"]),
|
||||
insample_sharpe=chosen["insample_sharpe"], full_sharpe=chosen["full_sharpe"],
|
||||
hold_sharpe=chosen["hold_sharpe"], dsr=round(float(dsr), 3), dsr_pass=dsr_pass,
|
||||
abs_grade=abs_grade, marginal_verdict=marg.get("marginal_verdict"),
|
||||
corr_tp01_full=marg.get("corr_full"), cand_insample_sharpe=marg.get("cand_insample_sharpe"),
|
||||
has_insample_edge=marg.get("has_insample_edge"), is_hedge=marg.get("is_hedge"),
|
||||
robust_oos=marg.get("robust_oos"), multicut=marg.get("multicut_uplift"),
|
||||
earns_slot=earns_slot, earns_slot_honest=earns_honest,
|
||||
smallcap=sc, boundary_uplift_spread=bnd["uplift_spread"],
|
||||
attribution_vs_trend_nudo=attribution,
|
||||
best_pure_funding=(dict(form=bp["form"], lb=bp["lb"], thr=bp["thr"],
|
||||
IS=bp["insample_sharpe"], FULL=bp["full_sharpe"],
|
||||
HOLD=bp["hold_sharpe"]) if bp else None),
|
||||
n_cells=len(rows), history_years=round((hi - lo).days / 365.25, 1))
|
||||
print("\n[8] SUMMARY JSON")
|
||||
print(json.dumps(summary, default=str))
|
||||
return summary
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,472 @@
|
||||
"""r0701_portfolio_opt — MIGLIORARE IL PORTAFOGLIO ATTIVO SENZA NUOVE STRATEGIE (2026-07-01).
|
||||
|
||||
(A) RI-OTTIMIZZAZIONE DEI PESI STATICI dei 4 sleeve (TP01/XS01/VRP01/SKH01).
|
||||
I pesi correnti (41.25/18.75/15/25) sono ad-hoc. Test onesto:
|
||||
- ottimizza SOLO in-sample (pre-cut), valuta OOS (post-cut), multi-cut {2024-01, 2024-07, 2025-01};
|
||||
- criteri: MAXSH (max-Sharpe simulato), RP (inverse-vol), ERC (equal-risk-contribution),
|
||||
MINVAR-R (min-varianza con vincolo di ritorno >= ritorno dei pesi correnti in-sample);
|
||||
- null di riferimento: EW (25x4) e CURRENT (pesi correnti);
|
||||
- vincoli: 0.05 <= w_i <= 0.60, sum(w)=1;
|
||||
- sleeve con < MIN_IS_DAYS giorni in-sample al cut (XS01 al 2024-01) -> PINNED al peso corrente
|
||||
(l'ottimizzatore non ha dati per giudicarli), gli altri ottimizzati nel budget residuo;
|
||||
- il combine replica ESATTAMENTE src/portfolio/portfolio.combined_daily (outer-join,
|
||||
pesi rinormalizzati per-riga sugli sleeve attivi).
|
||||
Verdetto: MIGLIORA solo se l'uplift OOS vs CURRENT e' PERSISTENTE (positivo a ogni cut),
|
||||
non su una finestra sola.
|
||||
|
||||
(B) GUARDIA-DRAWDOWN a livello portafoglio (replica del soft-guard vincente del tail-hedge lab
|
||||
2026-06-23, portato sul 4-sleeve). CAUSALE: l'esposizione del giorno D usa l'equity fino a D-1.
|
||||
Trigger/re-entry sul NAV OMBRA (equity non-guardata: lezione stops_lab — sull'equity congelata
|
||||
a expo=0 il DD non rientra mai). Isteresi: de-risk quando DD>X, re-risk quando DD<0.4*X.
|
||||
Griglia modesta X in {3,4,5,6}%, derisk in {0, 0.5}. Selezione cella IN-SAMPLE (pre-cut,
|
||||
criterio Calmar = CAGR/MaxDD), verifica OOS multi-cut.
|
||||
NULL DECISIVO (lezione tp01-dvol-overlay): esposizione COSTANTE c calibrata in-sample per
|
||||
pareggiare il MaxDD della guardia — lo Sharpe di c*r e' INVARIANTE, quindi la guardia "vale"
|
||||
solo se a pari MaxDD trattiene piu' CAGR/Sharpe del semplice de-levering.
|
||||
|
||||
(C) Combinazione A+B se entrambe reggono.
|
||||
|
||||
NB (contesto): il thread meta-allocazione (2026-06-29) ha gia' mostrato che l'allocazione DINAMICA
|
||||
fra sleeve perde vs pesi fissi — qui NON la rifacciamo: (A) e' statico, (B) e' un overlay di
|
||||
esposizione aggregata, non riallocazione fra sleeve.
|
||||
|
||||
Solo ricerca: NON tocca src/ ne' scripts/live/. Propone pesi, non li applica.
|
||||
|
||||
uv run python scripts/research/r0701_portfolio_opt.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from scipy.optimize import minimize
|
||||
|
||||
from src.portfolio.sleeves import active_sleeves
|
||||
from src.portfolio.portfolio import HOLDOUT, DAYS_PER_YEAR
|
||||
|
||||
CUTS = [pd.Timestamp(c, tz="UTC") for c in ("2024-01-01", "2024-07-01", "2025-01-01")]
|
||||
LO_W, HI_W = 0.05, 0.60 # vincoli pesi
|
||||
MIN_IS_DAYS = 120 # sotto -> sleeve PINNED al peso corrente (niente dati per giudicarlo)
|
||||
GUARD_TRIGGERS = (0.03, 0.04, 0.05, 0.06)
|
||||
GUARD_DERISK = (0.0, 0.5)
|
||||
GUARD_REC_FRAC = 0.4 # re-risk quando DD < 0.4*trigger (isteresi, come tail-hedge lab)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- dati
|
||||
def load_matrix() -> tuple[pd.DataFrame, list[str], np.ndarray]:
|
||||
"""Matrice daily OUTER-join dei 4 sleeve (NaN dove lo sleeve non e' ancora partito)."""
|
||||
sl = active_sleeves()
|
||||
names = [s.name for s in sl]
|
||||
w_cur = np.array([s.weight for s in sl], float)
|
||||
w_cur = w_cur / w_cur.sum()
|
||||
J = pd.concat({s.name: s.daily() for s in sl}, axis=1, join="outer").sort_index()
|
||||
J = J[J.notna().any(axis=1)]
|
||||
return J, names, w_cur
|
||||
|
||||
|
||||
def combo(J: pd.DataFrame, w: np.ndarray) -> pd.Series:
|
||||
"""Replica ESATTA di portfolio.combined_daily: pesi rinormalizzati per-riga sugli attivi."""
|
||||
active = J.notna().values * w
|
||||
rowsum = active.sum(axis=1, keepdims=True)
|
||||
wnorm = np.divide(active, rowsum, out=np.zeros_like(active), where=rowsum > 0)
|
||||
return pd.Series(np.nansum(np.nan_to_num(J.values) * wnorm, axis=1), index=J.index)
|
||||
|
||||
|
||||
def met(r: pd.Series | np.ndarray) -> dict:
|
||||
v = np.asarray(pd.Series(r).dropna().values, float)
|
||||
if len(v) < 20 or v.std() == 0:
|
||||
return dict(sh=0.0, cagr=0.0, dd=0.0, calmar=0.0, n=len(v))
|
||||
eq = np.cumprod(1 + v); pk = np.maximum.accumulate(eq)
|
||||
yrs = len(v) / DAYS_PER_YEAR
|
||||
cagr = float(eq[-1] ** (1 / yrs) - 1) if eq[-1] > 0 else -1.0
|
||||
dd = float(np.max((pk - eq) / pk))
|
||||
return dict(sh=float(v.mean() / v.std() * np.sqrt(DAYS_PER_YEAR)), cagr=cagr, dd=dd,
|
||||
calmar=(cagr / dd if dd > 0 else 0.0), n=len(v))
|
||||
|
||||
|
||||
def fmt_w(names, w):
|
||||
return " ".join(f"{n.split('_')[0]} {x*100:.1f}%" for n, x in zip(names, w))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- (A) ottimizzatori
|
||||
def _pinned_mask(J_is: pd.DataFrame, w_cur: np.ndarray) -> np.ndarray:
|
||||
"""True = sleeve senza abbastanza storia in-sample -> peso fissato al corrente."""
|
||||
return np.array([J_is[c].notna().sum() < MIN_IS_DAYS for c in J_is.columns])
|
||||
|
||||
|
||||
def _solve(J_is, w_cur, objective, extra_cons=None, hi_bounds=None) -> np.ndarray:
|
||||
"""SLSQP su pesi liberi (i pinned restano al corrente), bounds+somma, multi-start.
|
||||
hi_bounds: array di upper-bound per-sleeve (default HI_W) — per i cap STRUTTURALI."""
|
||||
k = len(w_cur)
|
||||
hi = np.full(k, HI_W) if hi_bounds is None else np.asarray(hi_bounds, float)
|
||||
pin = _pinned_mask(J_is, w_cur)
|
||||
free = ~pin
|
||||
budget = 1.0 - w_cur[pin].sum()
|
||||
nf = int(free.sum())
|
||||
|
||||
def full_w(wf):
|
||||
w = w_cur.copy(); w[free] = wf
|
||||
return w
|
||||
|
||||
cons = [dict(type="eq", fun=lambda wf: wf.sum() - budget)]
|
||||
if extra_cons:
|
||||
cons += [dict(type="ineq", fun=lambda wf, f=f: f(full_w(wf))) for f in extra_cons]
|
||||
bounds = [(LO_W, float(h)) for h in hi[free]]
|
||||
hif = hi[free]
|
||||
starts = [w_cur[free], np.full(nf, budget / nf)]
|
||||
rng = np.random.default_rng(7)
|
||||
for _ in range(4):
|
||||
x = rng.uniform(LO_W, hif); starts.append(x / x.sum() * budget)
|
||||
best, bval = w_cur.copy(), np.inf
|
||||
for x0 in starts:
|
||||
x0 = np.clip(x0, LO_W, hif); x0 = x0 / x0.sum() * budget
|
||||
res = minimize(lambda wf: objective(full_w(wf)), x0, method="SLSQP",
|
||||
bounds=bounds, constraints=cons,
|
||||
options=dict(maxiter=300, ftol=1e-10))
|
||||
if res.success and res.fun < bval:
|
||||
bval, best = res.fun, full_w(res.x)
|
||||
best = np.clip(best, LO_W, hi)
|
||||
return best / best.sum()
|
||||
|
||||
|
||||
def opt_maxsh(J_is, w_cur):
|
||||
return _solve(J_is, w_cur, lambda w: -met(combo(J_is, w))["sh"])
|
||||
|
||||
|
||||
def opt_minvar_ret(J_is, w_cur):
|
||||
"""Min-varianza con vincolo: media in-sample >= media dei pesi correnti in-sample."""
|
||||
mu_cur = float(combo(J_is, w_cur).mean())
|
||||
return _solve(J_is, w_cur, lambda w: float(combo(J_is, w).var()),
|
||||
extra_cons=[lambda w: float(combo(J_is, w).mean()) - mu_cur])
|
||||
|
||||
|
||||
def opt_rp(J_is, w_cur):
|
||||
"""Inverse-vol (risk-parity naive) sulla vol in-sample di ogni sleeve (giorni attivi propri)."""
|
||||
pin = _pinned_mask(J_is, w_cur); free = ~pin
|
||||
vol = np.array([J_is[c].dropna().std() for c in J_is.columns])
|
||||
w = w_cur.copy()
|
||||
iv = 1.0 / np.where(vol[free] > 0, vol[free], np.inf)
|
||||
w[free] = iv / iv.sum() * (1.0 - w_cur[pin].sum())
|
||||
for _ in range(50): # clip ai bounds + rinormalizza
|
||||
w = np.clip(w, LO_W, HI_W)
|
||||
if abs(w.sum() - 1.0) < 1e-9:
|
||||
break
|
||||
adj = ~np.isclose(w, LO_W) & ~np.isclose(w, HI_W)
|
||||
if not adj.any():
|
||||
break
|
||||
w[adj] += (1.0 - w.sum()) * (w[adj] / w[adj].sum())
|
||||
return w / w.sum()
|
||||
|
||||
|
||||
def opt_erc(J_is, w_cur):
|
||||
"""Equal-risk-contribution sulla cov in-sample pairwise-complete (submatrice dei non-pinned)."""
|
||||
pin = _pinned_mask(J_is, w_cur)
|
||||
cov = J_is.cov().values # pairwise-complete
|
||||
idx = np.where(~pin)[0]
|
||||
S = cov[np.ix_(idx, idx)]
|
||||
S = np.nan_to_num(S, nan=0.0)
|
||||
|
||||
def obj(w):
|
||||
wf = w[idx]
|
||||
port = wf @ S @ wf
|
||||
if port <= 0:
|
||||
return 1e6
|
||||
rc = wf * (S @ wf) / port
|
||||
return float(np.sum((rc - 1.0 / len(idx)) ** 2))
|
||||
return _solve(J_is, w_cur, obj)
|
||||
|
||||
|
||||
def _struct_caps(J_is) -> np.ndarray:
|
||||
"""Cap STRUTTURALI (prudenza, non statistica): VRP01 <= 15% (sleeve MODELLATO su DVOL ATM,
|
||||
stress-f non catturato — 'niente short-vol da modello in deploy'), XS01 <= 25% (STAT-MODE,
|
||||
storia ~2.5 anni). Gli ottimizzatori naive caricano VRP01 (vol 7.7% ma e' la vol del MODELLO)."""
|
||||
return np.array([0.15 if c.startswith("VRP") else (0.25 if c.startswith("XS") else HI_W)
|
||||
for c in J_is.columns])
|
||||
|
||||
|
||||
def opt_maxsh_struct(J_is, w_cur):
|
||||
return _solve(J_is, w_cur, lambda w: -met(combo(J_is, w))["sh"], hi_bounds=_struct_caps(J_is))
|
||||
|
||||
|
||||
CRITERIA = [("MAXSH", opt_maxsh), ("RP-invvol", opt_rp), ("ERC", opt_erc), ("MINVAR-R", opt_minvar_ret),
|
||||
("MAXSH-STR", opt_maxsh_struct)]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- (B) guardia-DD
|
||||
def dd_guard(r: pd.Series, trigger: float, derisk: float, rec_frac: float = GUARD_REC_FRAC):
|
||||
"""Soft-guard causale: expo[t] decisa dal DD del NAV OMBRA (non-guardato) fino a t-1.
|
||||
DD>trigger -> expo=derisk; DD<rec_frac*trigger -> expo=1 (isteresi). Ritorna (serie, expo)."""
|
||||
v = r.values
|
||||
eq = np.cumprod(1 + v); pk = np.maximum.accumulate(eq)
|
||||
n = len(v); expo = np.ones(n); on = True
|
||||
for i in range(1, n):
|
||||
ddi = (pk[i - 1] - eq[i - 1]) / pk[i - 1]
|
||||
if ddi > trigger:
|
||||
on = False
|
||||
elif ddi < trigger * rec_frac:
|
||||
on = True
|
||||
expo[i] = 1.0 if on else derisk
|
||||
return pd.Series(expo * v, index=r.index), expo
|
||||
|
||||
|
||||
def const_expo_match_dd(r: pd.Series, target_dd: float) -> float:
|
||||
"""Esposizione costante c (in-sample) che pareggia il MaxDD target — il null de-levering."""
|
||||
lo, hi = 0.05, 1.0
|
||||
if met(r)["dd"] <= target_dd:
|
||||
return 1.0
|
||||
for _ in range(40):
|
||||
mid = 0.5 * (lo + hi)
|
||||
if met(pd.Series(mid * r.values, index=r.index))["dd"] > target_dd:
|
||||
hi = mid
|
||||
else:
|
||||
lo = mid
|
||||
return lo
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- report
|
||||
def section_A(J, names, w_cur):
|
||||
print("\n" + "=" * 108)
|
||||
print(" (A) RI-OTTIMIZZAZIONE PESI STATICI — ottimizza IN-SAMPLE (pre-cut), valuta OOS (post-cut), multi-cut")
|
||||
print("=" * 108)
|
||||
|
||||
# correlazioni e vol per contesto (full, pairwise)
|
||||
C = J.corr()
|
||||
print("\n corr pairwise (full):")
|
||||
for i, a in enumerate(names):
|
||||
print(" " + a.split("_")[0].ljust(6) + " ".join(f"{C.iloc[i, j]:+.2f}" for j in range(len(names))))
|
||||
print(" vol annua per-sleeve (full): " +
|
||||
" ".join(f"{n.split('_')[0]} {J[n].dropna().std()*np.sqrt(DAYS_PER_YEAR)*100:.1f}%" for n in names))
|
||||
|
||||
candidates = {} # nome -> pesi ottimizzati su pre-HOLDOUT (headline)
|
||||
J_is_main = J[J.index < HOLDOUT]
|
||||
for cname, fn in CRITERIA:
|
||||
candidates[cname] = fn(J_is_main, w_cur)
|
||||
candidates["EW"] = np.full(len(names), 1.0 / len(names))
|
||||
caps = _struct_caps(J)
|
||||
w_ewstr = np.full(len(names), 1.0 / len(names)) # EW sotto i cap strutturali (null senza fit)
|
||||
for _ in range(20):
|
||||
over = w_ewstr > caps + 1e-12
|
||||
if not over.any():
|
||||
break
|
||||
excess = float((w_ewstr[over] - caps[over]).sum())
|
||||
w_ewstr[over] = caps[over]
|
||||
room = w_ewstr < caps - 1e-12 # redistribuisci SOLO a chi ha spazio
|
||||
w_ewstr[room] += excess / max(int(room.sum()), 1)
|
||||
candidates["EW-STR"] = w_ewstr
|
||||
candidates["CURRENT"] = w_cur
|
||||
|
||||
print(f"\n PESI (ottimizzati su in-sample pre-{HOLDOUT.date()}):")
|
||||
for cname, w in candidates.items():
|
||||
print(f" {cname:<10s} {fmt_w(names, w)}")
|
||||
|
||||
# headline: FULL + HOLD-OUT
|
||||
print(f"\n {'criterio':<10s} {'FULL Sh':>8s} {'FULL DD':>8s} {'FULL CAGR':>9s} | {'HOLD Sh':>8s} {'HOLD DD':>8s} {'HOLD CAGR':>9s}")
|
||||
rows = {}
|
||||
for cname, w in candidates.items():
|
||||
f = met(combo(J, w)); h = met(combo(J[J.index >= HOLDOUT], w))
|
||||
rows[cname] = (f, h)
|
||||
print(f" {cname:<10s} {f['sh']:>8.2f} {f['dd']*100:>7.1f}% {f['cagr']*100:>+8.1f}% |"
|
||||
f" {h['sh']:>8.2f} {h['dd']*100:>7.1f}% {h['cagr']*100:>+8.1f}%")
|
||||
|
||||
# multi-cut: ottimizza su <cut, valuta su >=cut, uplift vs CURRENT sulla stessa finestra OOS
|
||||
print("\n MULTI-CUT (pesi ri-ottimizzati a ogni cut sul solo pre-cut; ΔSh OOS vs CURRENT):")
|
||||
header = f" {'criterio':<10s}" + "".join(f" | {c.date()} Sh_oos ΔSh " for c in CUTS)
|
||||
print(header)
|
||||
persist = {}
|
||||
fixed = {"EW": candidates["EW"], "EW-STR": candidates["EW-STR"]}
|
||||
for cname, fn in CRITERIA + [("EW", None), ("EW-STR", None)]:
|
||||
cells = []
|
||||
ok = True
|
||||
for cut in CUTS:
|
||||
J_is, J_oos = J[J.index < cut], J[J.index >= cut]
|
||||
w = fixed[cname] if cname in fixed else fn(J_is, w_cur)
|
||||
sh_o = met(combo(J_oos, w))["sh"]
|
||||
sh_c = met(combo(J_oos, w_cur))["sh"]
|
||||
d = sh_o - sh_c
|
||||
ok &= d > 0
|
||||
cells.append(f" | {sh_o:>10.2f} {d:>+5.2f}")
|
||||
persist[cname] = ok
|
||||
print(f" {cname:<10s}" + "".join(cells) + (" <-- PERSISTENTE" if ok else ""))
|
||||
|
||||
print(" ⚠ caveat: le 3 finestre OOS sono ANNIDATE (tutte contengono il 2025-26) — non 3 conferme")
|
||||
print(" indipendenti. Il test disgiunto vero e' il per-anno qui sotto.")
|
||||
|
||||
# per-anno (finestre DISGIUNTE): ritorno annuo del candidato vs CURRENT, pesi fissi pre-2025
|
||||
print("\n PER-ANNO (pesi headline fissi; Δret vs CURRENT, finestre disgiunte):")
|
||||
sel = ["MAXSH", "MINVAR-R", "MAXSH-STR", "EW", "EW-STR"]
|
||||
years = sorted(set(J.index.year))
|
||||
print(" " + "anno".ljust(6) + "".join(f"{c:>12s}" for c in ["CURRENT"] + sel))
|
||||
wins = {c: 0 for c in sel}
|
||||
for y in years:
|
||||
Jy = J[J.index.year == y]
|
||||
base_ret = float(np.prod(1 + combo(Jy, w_cur).values) - 1)
|
||||
row = f" {y} {base_ret*100:>+10.1f}%"
|
||||
for c in sel:
|
||||
ry = float(np.prod(1 + combo(Jy, candidates[c]).values) - 1)
|
||||
wins[c] += ry > base_ret
|
||||
row += f"{(ry-base_ret)*100:>+11.1f}p"
|
||||
print(row)
|
||||
print(" anni vinti vs CURRENT: " + " ".join(f"{c} {wins[c]}/{len(years)}" for c in sel))
|
||||
|
||||
# verdetto: la persistenza statistica NON basta — i pesi devono rispettare i cap STRUTTURALI
|
||||
# (VRP01<=15% perche' MODELLATO, XS01<=25% perche' STAT-MODE/storia corta), altrimenti l'uplift
|
||||
# sta comprando rischio-modello che il backtest non vede (punto cieco tipo CC01).
|
||||
winners = [c for c, ok in persist.items() if ok]
|
||||
respects = {c: bool(np.all(candidates[c] <= caps + 1e-3)) for c in candidates} # tol 0.1pp (jitter clip/renorm)
|
||||
struct_winners = [c for c in winners if respects[c]]
|
||||
print("\n cap strutturali: " + fmt_w(names, caps))
|
||||
if winners:
|
||||
print(" persistenti: " + ", ".join(f"{c} ({'dentro i cap' if respects[c] else 'VIOLA i cap'})"
|
||||
for c in winners))
|
||||
if struct_winners:
|
||||
wbest = candidates[struct_winners[0]]
|
||||
fb, fc = met(combo(J, wbest)), met(combo(J, w_cur))
|
||||
hb, hc = met(combo(J[J.index >= HOLDOUT], wbest)), met(combo(J[J.index >= HOLDOUT], w_cur))
|
||||
verdict = (f"MIGLIORA (OOS) — {struct_winners} persistente E dentro i cap strutturali. "
|
||||
f"Pesi candidati ({struct_winners[0]}): {fmt_w(names, wbest)}.\n"
|
||||
f" TRADE-OFF onesto: HOLD Sh {hc['sh']:.2f}->{hb['sh']:.2f}, HOLD CAGR "
|
||||
f"{hc['cagr']*100:+.1f}%->{hb['cagr']*100:+.1f}%, MA FULL Sh {fc['sh']:.2f}->{fb['sh']:.2f} "
|
||||
f"e FULL DD {fc['dd']*100:.1f}%->{fb['dd']*100:.1f}% — l'uplift e' un TILT di ritorno verso "
|
||||
f"gli sleeve research-grade (piu' SKH01/XS01, meno TP01), non un free lunch risk-adjusted. "
|
||||
f"NB: nessun OTTIMIZZATORE aggiunge nulla oltre questo null parameter-free "
|
||||
f"(MAXSH in-sample vince 1/8 anni: l'ottimizzazione qui overfitta, la de-concentrazione no).")
|
||||
elif winners:
|
||||
verdict = ("NON ADOTTABILE — l'uplift persistente esiste SOLO violando i cap strutturali "
|
||||
"(tutti i vincitori caricano VRP01>15%, sleeve MODELLATO il cui rischio di coda "
|
||||
"il backtest non cattura). Dentro i cap l'ottimizzatore converge ~ai pesi correnti "
|
||||
"e non e' persistente -> per il book reale I PESI CORRENTI REGGONO.")
|
||||
else:
|
||||
verdict = "NON MIGLIORA — nessun criterio batte i pesi CORRENTI in modo persistente. I pesi correnti reggono."
|
||||
print("\n VERDETTO (A): " + verdict)
|
||||
return candidates, persist, struct_winners
|
||||
|
||||
|
||||
def section_B(J, names, w_cur, label="CURRENT"):
|
||||
print("\n" + "=" * 108)
|
||||
print(f" (B) GUARDIA-DRAWDOWN a livello portafoglio (pesi {label}) — trigger X, de-risk d, isteresi 0.4X")
|
||||
print("=" * 108)
|
||||
r_full = combo(J, w_cur)
|
||||
|
||||
# griglia completa su FULL/IS/HOLD per trasparenza (selezione = solo in-sample)
|
||||
r_is = r_full[r_full.index < HOLDOUT]
|
||||
r_ho = r_full[r_full.index >= HOLDOUT]
|
||||
b_is, b_ho, b_f = met(r_is), met(r_ho), met(r_full)
|
||||
print(f"\n baseline IS Sh {b_is['sh']:.2f} DD {b_is['dd']*100:.1f}% CAGR {b_is['cagr']*100:+.1f}%"
|
||||
f" | HOLD Sh {b_ho['sh']:.2f} DD {b_ho['dd']*100:.1f}% CAGR {b_ho['cagr']*100:+.1f}%"
|
||||
f" | FULL Sh {b_f['sh']:.2f} DD {b_f['dd']*100:.1f}%")
|
||||
print(f"\n {'cella':<16s} {'IS Sh':>6s} {'IS DD':>6s} {'IS CAGR':>8s} {'IS Calmar':>9s} {'%off':>5s} |"
|
||||
f" {'HOLD Sh':>7s} {'HOLD DD':>7s} {'HOLD CAGR':>9s}")
|
||||
grid = {}
|
||||
for trig in GUARD_TRIGGERS:
|
||||
for dr in GUARD_DERISK:
|
||||
g_full, expo = dd_guard(r_full, trig, dr)
|
||||
g_is = g_full[g_full.index < HOLDOUT]; g_ho = g_full[g_full.index >= HOLDOUT]
|
||||
mi, mh = met(g_is), met(g_ho)
|
||||
off = float((expo[: len(r_is)] < 1.0).mean())
|
||||
grid[(trig, dr)] = dict(is_=mi, ho=mh, full=met(g_full))
|
||||
print(f" X={trig*100:.0f}% d={dr:<4.1f} {mi['sh']:>6.2f} {mi['dd']*100:>5.1f}% {mi['cagr']*100:>+7.1f}%"
|
||||
f" {mi['calmar']:>9.2f} {off*100:>4.0f}% | {mh['sh']:>7.2f} {mh['dd']*100:>6.1f}% {mh['cagr']*100:>+8.1f}%")
|
||||
|
||||
# selezione IN-SAMPLE (Calmar), poi null de-levering a pari MaxDD in-sample
|
||||
best_cell = max(grid, key=lambda k: grid[k]["is_"]["calmar"])
|
||||
trig, dr = best_cell
|
||||
print(f"\n cella selezionata IN-SAMPLE (max Calmar): X={trig*100:.0f}% derisk={dr}")
|
||||
tgt_dd = grid[best_cell]["is_"]["dd"]
|
||||
c = const_expo_match_dd(r_is, tgt_dd)
|
||||
cnull_full = pd.Series(c * r_full.values, index=r_full.index)
|
||||
n_is, n_ho = met(cnull_full[cnull_full.index < HOLDOUT]), met(cnull_full[cnull_full.index >= HOLDOUT])
|
||||
g = grid[best_cell]
|
||||
print(f" NULL de-levering: expo costante c={c:.2f} pareggia il MaxDD IS della guardia ({tgt_dd*100:.1f}%)")
|
||||
print(f" {'':<14s} {'IS Sh':>6s} {'IS CAGR':>8s} | {'HOLD Sh':>7s} {'HOLD DD':>7s} {'HOLD CAGR':>9s}")
|
||||
print(f" guardia {g['is_']['sh']:>6.2f} {g['is_']['cagr']*100:>+7.1f}% | {g['ho']['sh']:>7.2f}"
|
||||
f" {g['ho']['dd']*100:>6.1f}% {g['ho']['cagr']*100:>+8.1f}%")
|
||||
print(f" const c={c:.2f} {n_is['sh']:>6.2f} {n_is['cagr']*100:>+7.1f}% | {n_ho['sh']:>7.2f}"
|
||||
f" {n_ho['dd']*100:>6.1f}% {n_ho['cagr']*100:>+8.1f}%")
|
||||
|
||||
# multi-cut: selezione sul pre-cut, valutazione OOS post-cut (guardia vs baseline vs null)
|
||||
inert = True
|
||||
print("\n MULTI-CUT (cella riscelta a ogni cut sul solo pre-cut; OOS post-cut):")
|
||||
print(f" {'cut':<12s} {'cella':<14s} {'OOS Sh(g)':>9s} {'OOS Sh(b)':>9s} {'ΔSh':>6s} {'OOS DD(g)':>9s}"
|
||||
f" {'OOS DD(b)':>9s} {'ΔCAGR':>7s} {'null c: ΔSh':>11s}")
|
||||
persist_sh, persist_dd = True, True
|
||||
for cut in CUTS:
|
||||
r_pre = r_full[r_full.index < cut]
|
||||
cells = {}
|
||||
for t in GUARD_TRIGGERS:
|
||||
for d in GUARD_DERISK:
|
||||
gpre, _ = dd_guard(r_pre, t, d)
|
||||
cells[(t, d)] = met(gpre)["calmar"]
|
||||
(t_, d_) = max(cells, key=cells.get)
|
||||
g_full, _ = dd_guard(r_full, t_, d_) # guard sul path completo (stato causale)
|
||||
g_oos = g_full[g_full.index >= cut]
|
||||
b_oos = r_full[r_full.index >= cut]
|
||||
gm, bm = met(g_oos), met(b_oos)
|
||||
cc = const_expo_match_dd(r_pre, met(dd_guard(r_pre, t_, d_)[0])["dd"])
|
||||
nm = met(pd.Series(cc * b_oos.values, index=b_oos.index))
|
||||
dsh = gm["sh"] - bm["sh"]; ddd = gm["dd"] - bm["dd"]; dcagr = gm["cagr"] - bm["cagr"]
|
||||
persist_sh &= dsh >= 0; persist_dd &= ddd < 0
|
||||
inert &= abs(dsh) < 0.005 and abs(ddd) < 1e-4
|
||||
print(f" {str(cut.date()):<12s} X={t_*100:.0f}% d={d_:<6.1f} {gm['sh']:>9.2f} {bm['sh']:>9.2f} {dsh:>+6.2f}"
|
||||
f" {gm['dd']*100:>8.1f}% {bm['dd']*100:>8.1f}% {dcagr*100:>+6.1f}pp {gm['sh']-nm['sh']:>+11.2f}")
|
||||
|
||||
if inert:
|
||||
verdict = ("NON MIGLIORA — INERTE OOS: la cella scelta in-sample non scatta MAI post-cut "
|
||||
"(il DD OOS del 4-sleeve resta sotto il trigger: la diversificazione fa gia' il lavoro "
|
||||
"della guardia). Trigger piu' stretti (X=3-4%) scattano ma COSTANO Sharpe su HOLD. "
|
||||
"Coerente col thread meta-allocazione 2026-06-29 (drawdown-control ridondante).")
|
||||
elif persist_sh and persist_dd:
|
||||
verdict = f"MIGLIORA — la guardia X={trig*100:.0f}%/d={dr} taglia il DD OOS senza costo Sharpe persistente"
|
||||
elif persist_dd:
|
||||
verdict = ("TAGLIA IL DD MA COSTA — DD ridotto a ogni cut ma Sharpe/CAGR inferiori: "
|
||||
"trade-off assicurativo, confrontare col null de-levering prima di adottarla")
|
||||
else:
|
||||
verdict = "NON MIGLIORA — la guardia non riduce il DD OOS in modo persistente (o e' solo de-levering)"
|
||||
print("\n VERDETTO (B): " + verdict)
|
||||
return grid, best_cell
|
||||
|
||||
|
||||
def section_C(J, names, w_cur, candidates, struct_winners, grid, best_cell):
|
||||
print("\n" + "=" * 108)
|
||||
print(" (C) COMBINAZIONE A+B")
|
||||
print("=" * 108)
|
||||
wA = candidates[struct_winners[0]] if struct_winners else w_cur
|
||||
labA = struct_winners[0] if struct_winners else "CURRENT (A non adottabile)"
|
||||
trig, dr = best_cell
|
||||
r = combo(J, wA)
|
||||
g, _ = dd_guard(r, trig, dr)
|
||||
for lbl, s in [("baseline CURRENT", combo(J, w_cur)),
|
||||
(f"pesi {labA}", r),
|
||||
(f"pesi {labA} + guard X={trig*100:.0f}%/d={dr}", g)]:
|
||||
f = met(s); h = met(s[s.index >= HOLDOUT])
|
||||
print(f" {lbl:<52s} FULL Sh {f['sh']:>5.2f} DD {f['dd']*100:>4.1f}% CAGR {f['cagr']*100:>+5.1f}%"
|
||||
f" | HOLD Sh {h['sh']:>5.2f} DD {h['dd']*100:>4.1f}% CAGR {h['cagr']*100:>+5.1f}%")
|
||||
if not struct_winners:
|
||||
print(" -> (A) non adottabile e (B) inerte OOS: la combinazione non ha una cella da proporre.")
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 108)
|
||||
print(" r0701_portfolio_opt — pesi statici + guardia-DD sul portafoglio attivo a 4 sleeve")
|
||||
print(f" hold-out bloccato {HOLDOUT.date()}+ | vincoli pesi [{LO_W},{HI_W}] | cuts {[str(c.date()) for c in CUTS]}")
|
||||
print("=" * 108)
|
||||
J, names, w_cur = load_matrix()
|
||||
print(f"\n sleeve: {names}")
|
||||
print(f" pesi CORRENTI: {fmt_w(names, w_cur)}")
|
||||
print(f" finestra: {J.index[0].date()} -> {J.index[-1].date()} ({len(J)} giorni)")
|
||||
for n in names:
|
||||
s = J[n].dropna()
|
||||
print(f" {n:<16s} dal {s.index[0].date()} ({len(s)} giorni)")
|
||||
|
||||
b_f = met(combo(J, w_cur)); b_h = met(combo(J[J.index >= HOLDOUT], w_cur))
|
||||
print(f"\n BASELINE (pesi correnti): FULL Sh {b_f['sh']:.2f} DD {b_f['dd']*100:.1f}% CAGR {b_f['cagr']*100:+.1f}%"
|
||||
f" | HOLD Sh {b_h['sh']:.2f} DD {b_h['dd']*100:.1f}% CAGR {b_h['cagr']*100:+.1f}%")
|
||||
|
||||
candidates, persistA, struct_winners = section_A(J, names, w_cur)
|
||||
grid, best_cell = section_B(J, names, w_cur)
|
||||
section_C(J, names, w_cur, candidates, struct_winners, grid, best_cell)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,304 @@
|
||||
"""r0701_portfolio_skeptic — VERIFICA AVVERSARIALE del tilt EW-STR (TP30/XS25/VRP15/SKH30).
|
||||
|
||||
Oggetto: la proposta di r0701_portfolio_opt (diario 2026-07-01-portfolio-weights-ddguard.md) di
|
||||
spostare i pesi del portafoglio a 4 sleeve da CURRENT (41.25/18.75/15/25) a EW-STR (30/25/15/30).
|
||||
Claim: HOLD Sh 2.21->2.35, HOLD CAGR +16.0->+19.7%, multi-cut +0.06/+0.13/+0.14, 7/8 anni vinti.
|
||||
|
||||
Linee di attacco (agente scettico):
|
||||
1. RIPRODUZIONE indipendente via src.portfolio.StrategyPortfolio (path di PRODUZIONE, non lo
|
||||
script dell'agente).
|
||||
2. SELEZIONE-SULL'HOLD-OUT DI 2° ORDINE via SKH01/XS01: SKH01 fu ammesso (2026-06-23) perche'
|
||||
alzava l'hold-out 2025-26; XS01 fu affinato (blend+gate) conoscendo l'hold-out. Un tilt verso
|
||||
di loro eredita quella selezione? Test: (a) per-anno SOLO 2019-2024; (b) tilt scomposti
|
||||
(solo-XS con SKH pinned 25, solo-SKH con XS pinned); (c) finestre OOS DISGIUNTE (i 3 cut del
|
||||
diario sono ANNIDATI); (d) contributo per-sleeve all'uplift hold-out.
|
||||
3. "7/8 ANNI VINTI": quale metrica (ritorno, non Sharpe), margini per anno, Sharpe per-anno.
|
||||
4. PLATEAU: griglia 2.5pp fra CURRENT e EW-STR (TP x SKH, VRP=15, XS=residuo cap 25).
|
||||
5. REALISMO: pesi RINORMALIZZATI per era (outer-join: nel 2019-20 attivi solo TP+SKH!), haircut
|
||||
d'esecuzione su SKH01/XS01 come DRAG (r' = r - h*mean(r), modello-costi, non de-levering).
|
||||
6. FORKING PATHS: quante config viste sull'hold-out; null di tilt CASUALI cap-respecting — se
|
||||
quasi ogni tilt anti-TP01 batte CURRENT sull'hold-out, il claim e' generico, non informativo.
|
||||
|
||||
Solo lettura/analisi: NON tocca src/ ne' scripts/live/.
|
||||
uv run python scripts/research/r0701_portfolio_skeptic.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.portfolio.sleeves import active_sleeves
|
||||
from src.portfolio.portfolio import StrategyPortfolio, HOLDOUT, DAYS_PER_YEAR, metrics
|
||||
|
||||
CUTS = [pd.Timestamp(c, tz="UTC") for c in ("2024-01-01", "2024-07-01", "2025-01-01")]
|
||||
LO_W = 0.05
|
||||
|
||||
# pesi per PREFISSO sleeve (ordine risolto sui nomi del registry)
|
||||
W_CURRENT = {"TP01": 0.4125, "XS01": 0.1875, "VRP01": 0.15, "SKH01": 0.25}
|
||||
W_EWSTR = {"TP01": 0.30, "XS01": 0.25, "VRP01": 0.15, "SKH01": 0.30}
|
||||
W_ONLY_XS = {"TP01": 0.35, "XS01": 0.25, "VRP01": 0.15, "SKH01": 0.25} # SKH pinned al corrente
|
||||
W_ONLY_SKH = {"TP01": 0.3625, "XS01": 0.1875, "VRP01": 0.15, "SKH01": 0.30} # XS pinned al corrente
|
||||
CAPS = {"TP01": 0.60, "XS01": 0.25, "VRP01": 0.15, "SKH01": 0.60}
|
||||
|
||||
|
||||
def prefix(name: str) -> str:
|
||||
return name.split("_")[0]
|
||||
|
||||
|
||||
def wvec(names: list[str], wd: dict) -> np.ndarray:
|
||||
v = np.array([wd[prefix(n)] for n in names], float)
|
||||
return v / v.sum()
|
||||
|
||||
|
||||
def combo(J: pd.DataFrame, w: np.ndarray) -> pd.Series:
|
||||
"""Combine outer-join con pesi rinormalizzati per-riga (stessa semantica di combined_daily;
|
||||
verificata sotto contro il path di produzione)."""
|
||||
act = J.notna().values * w
|
||||
rs = act.sum(axis=1, keepdims=True)
|
||||
wn = np.divide(act, rs, out=np.zeros_like(act), where=rs > 0)
|
||||
return pd.Series(np.nansum(np.nan_to_num(J.values) * wn, axis=1), index=J.index)
|
||||
|
||||
|
||||
def met(s) -> dict:
|
||||
return metrics(pd.Series(s).dropna()) # metriche di PRODUZIONE (src.portfolio)
|
||||
|
||||
|
||||
def sh(s) -> float:
|
||||
return met(s)["sharpe"]
|
||||
|
||||
|
||||
def yr_ret(s) -> float:
|
||||
v = pd.Series(s).dropna().values
|
||||
return float(np.prod(1 + v) - 1)
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 110)
|
||||
print(" r0701_portfolio_skeptic — verifica avversariale EW-STR (30/25/15/30) vs CURRENT (41.25/18.75/15/25)")
|
||||
print("=" * 110)
|
||||
|
||||
sl = active_sleeves()
|
||||
names = [s.name for s in sl]
|
||||
J = pd.concat({s.name: s.daily() for s in sl}, axis=1, join="outer").sort_index()
|
||||
J = J[J.notna().any(axis=1)]
|
||||
w_cur = wvec(names, W_CURRENT)
|
||||
w_ew = wvec(names, W_EWSTR)
|
||||
print(f"\n finestra {J.index[0].date()} -> {J.index[-1].date()} ({len(J)} giorni)")
|
||||
for n in names:
|
||||
s = J[n].dropna()
|
||||
print(f" {n:<16s} dal {s.index[0].date()} ({len(s)} giorni)")
|
||||
|
||||
# ------------------------------------------------ 1) RIPRODUZIONE (path di produzione)
|
||||
print("\n" + "-" * 110)
|
||||
print(" [1] RIPRODUZIONE INDIPENDENTE — StrategyPortfolio.combined_daily (produzione), non lo script agente")
|
||||
print("-" * 110)
|
||||
for lbl, wd in [("CURRENT", W_CURRENT), ("EW-STR", W_EWSTR)]:
|
||||
for s, sleeve_w in zip(sl, [wd[prefix(n)] for n in names]):
|
||||
s.weight = sleeve_w
|
||||
port = StrategyPortfolio(sl)
|
||||
full = port.combined_daily()
|
||||
hold = port.combined_daily(lo=HOLDOUT)
|
||||
# check che il mio combine coincida col path di produzione
|
||||
mine = combo(J, wvec(names, wd))
|
||||
dmax = float(np.max(np.abs(mine.values - full.values)))
|
||||
f, h = met(full), met(hold)
|
||||
print(f" {lbl:<8s} FULL Sh {f['sharpe']:.2f} DD {f['maxdd']*100:.1f}% CAGR {f['cagr']*100:+.1f}%"
|
||||
f" | HOLD Sh {h['sharpe']:.2f} DD {h['maxdd']*100:.1f}% CAGR {h['cagr']*100:+.1f}%"
|
||||
f" (|combine mio - produzione|max = {dmax:.2e})")
|
||||
print(" multi-cut ΔSh OOS EW-STR vs CURRENT (finestre ANNIDATE, come nel diario):")
|
||||
for cut in CUTS:
|
||||
Jo = J[J.index >= cut]
|
||||
d = sh(combo(Jo, w_ew)) - sh(combo(Jo, w_cur))
|
||||
print(f" cut {cut.date()} ΔSh {d:+.2f}")
|
||||
|
||||
# ------------------------------------------------ 2) SELEZIONE-SULL'HOLD-OUT DI 2° ORDINE
|
||||
print("\n" + "-" * 110)
|
||||
print(" [2] SELEZIONE HOLD-OUT DI 2° ORDINE — SKH01 ammesso PERCHE' alzava l'hold-out; XS01 affinato idem")
|
||||
print("-" * 110)
|
||||
# (d prima: contesto) Sharpe standalone per-sleeve, pre-2025 vs hold-out
|
||||
print(" Sharpe standalone per sleeve (pre-2025 | hold-out 2025+):")
|
||||
for n in names:
|
||||
s = J[n].dropna()
|
||||
print(f" {n:<16s} IS {sh(s[s.index < HOLDOUT]):>5.2f} | HOLD {sh(s[s.index >= HOLDOUT]):>5.2f}")
|
||||
|
||||
# (a) per-anno SOLO 2019-2024 (pre-selezione di SKH01/holdout)
|
||||
print("\n (a) per-anno SOLO 2019-2024 (finestra NON toccata dalla selezione hold-out):")
|
||||
print(f" {'anno':<6s} {'ret CUR':>9s} {'ret EW-STR':>10s} {'Δret':>8s} | {'Sh CUR':>7s} {'Sh EW-STR':>9s} {'ΔSh':>6s}")
|
||||
wins_ret_pre = wins_sh_pre = 0
|
||||
years_pre = [y for y in sorted(set(J.index.year)) if y < 2025]
|
||||
for y in years_pre:
|
||||
Jy = J[J.index.year == y]
|
||||
rc, re = combo(Jy, w_cur), combo(Jy, w_ew)
|
||||
dr = yr_ret(re) - yr_ret(rc); dsh = sh(re) - sh(rc)
|
||||
wins_ret_pre += dr > 0; wins_sh_pre += dsh > 0
|
||||
print(f" {y:<6d} {yr_ret(rc)*100:>+8.1f}% {yr_ret(re)*100:>+9.1f}% {dr*100:>+7.1f}p |"
|
||||
f" {sh(rc):>7.2f} {sh(re):>9.2f} {dsh:>+6.2f}")
|
||||
print(f" -> vittorie EW-STR 2019-2024: ritorno {wins_ret_pre}/{len(years_pre)}, Sharpe {wins_sh_pre}/{len(years_pre)}")
|
||||
Jpre = J[J.index < HOLDOUT]
|
||||
print(f" pre-2025 aggregato: Sh CUR {sh(combo(Jpre, w_cur)):.2f} vs EW-STR {sh(combo(Jpre, w_ew)):.2f}"
|
||||
f" (ΔSh {sh(combo(Jpre, w_ew)) - sh(combo(Jpre, w_cur)):+.2f})")
|
||||
|
||||
# (b) tilt scomposti
|
||||
print("\n (b) tilt SCOMPOSTI (chi porta l'uplift?): HOLD Sh + multi-cut ΔSh vs CURRENT")
|
||||
for lbl, wd in [("solo-XS (SKH pinned 25): TP35 /XS25/VRP15/SKH25", W_ONLY_XS),
|
||||
("solo-SKH (XS pinned 18.75): TP36.25/XS18.75/VRP15/SKH30", W_ONLY_SKH),
|
||||
("EW-STR: TP30 /XS25/VRP15/SKH30", W_EWSTR)]:
|
||||
w = wvec(names, wd)
|
||||
hold_sh = sh(combo(J[J.index >= HOLDOUT], w))
|
||||
cuts_d = [sh(combo(J[J.index >= c], w)) - sh(combo(J[J.index >= c], w_cur)) for c in CUTS]
|
||||
print(f" {lbl:<58s} HOLD Sh {hold_sh:.2f} multi-cut " +
|
||||
" ".join(f"{d:+.2f}" for d in cuts_d))
|
||||
|
||||
# (c) finestre OOS DISGIUNTE (i cut del diario sono annidati)
|
||||
print("\n (c) finestre OOS DISGIUNTE (ΔSh e Δret EW-STR vs CURRENT):")
|
||||
windows = [("2024-01 -> 2024-07", "2024-01-01", "2024-07-01"),
|
||||
("2024-07 -> 2025-01", "2024-07-01", "2025-01-01"),
|
||||
("2025-01 -> fine ", "2025-01-01", None)]
|
||||
for lbl, lo, hi in windows:
|
||||
m = (J.index >= pd.Timestamp(lo, tz="UTC"))
|
||||
if hi:
|
||||
m &= (J.index < pd.Timestamp(hi, tz="UTC"))
|
||||
Jw = J[m]
|
||||
rc, re = combo(Jw, w_cur), combo(Jw, w_ew)
|
||||
print(f" {lbl} ΔSh {sh(re) - sh(rc):+.2f} Δret {(yr_ret(re) - yr_ret(rc))*100:+.1f}pp")
|
||||
|
||||
# (d) contributo per-sleeve all'uplift hold-out (pesi statici: sul hold-out tutti attivi)
|
||||
print("\n (d) contributo per-sleeve al Δritorno annualizzato sull'hold-out (Δw_i x mean_i x 365):")
|
||||
Jh = J[J.index >= HOLDOUT]
|
||||
for n in names:
|
||||
dw = W_EWSTR[prefix(n)] - W_CURRENT[prefix(n)]
|
||||
mu = float(Jh[n].dropna().mean()) * DAYS_PER_YEAR
|
||||
print(f" {n:<16s} Δw {dw*100:>+6.2f}pp x ret_ann {mu*100:>+6.1f}% = {dw*mu*100:>+5.2f}pp/anno")
|
||||
|
||||
# ------------------------------------------------ 3) "7/8 ANNI VINTI"
|
||||
print("\n" + "-" * 110)
|
||||
print(" [3] '7/8 ANNI VINTI' — metrica = RITORNO composto per anno (non Sharpe). Tabella completa + margini")
|
||||
print("-" * 110)
|
||||
print(f" {'anno':<6s} {'ret CUR':>9s} {'ret EW-STR':>10s} {'Δret':>8s} | {'Sh CUR':>7s} {'Sh EW-STR':>9s} {'ΔSh':>6s} |"
|
||||
f" {'DD CUR':>7s} {'DD EW':>7s}")
|
||||
wins_ret = wins_sh = 0
|
||||
years = sorted(set(J.index.year))
|
||||
for y in years:
|
||||
Jy = J[J.index.year == y]
|
||||
rc, re = combo(Jy, w_cur), combo(Jy, w_ew)
|
||||
mc, me = met(rc), met(re)
|
||||
dr = yr_ret(re) - yr_ret(rc); dsh = sh(re) - sh(rc)
|
||||
wins_ret += dr > 0; wins_sh += dsh > 0
|
||||
print(f" {y:<6d} {yr_ret(rc)*100:>+8.1f}% {yr_ret(re)*100:>+9.1f}% {dr*100:>+7.1f}p |"
|
||||
f" {sh(rc):>7.2f} {sh(re):>9.2f} {dsh:>+6.2f} | {mc['maxdd']*100:>6.1f}% {me['maxdd']*100:>6.1f}%")
|
||||
print(f" -> vittorie EW-STR: RITORNO {wins_ret}/{len(years)} | SHARPE {wins_sh}/{len(years)}"
|
||||
f" (2026 = anno parziale)")
|
||||
|
||||
# ------------------------------------------------ 4) PLATEAU
|
||||
print("\n" + "-" * 110)
|
||||
print(" [4] PLATEAU — griglia 2.5pp: TP x SKH, VRP=15 fisso, XS = residuo (cap 25). HOLD Sh | min multi-cut ΔSh")
|
||||
print("-" * 110)
|
||||
tps = [0.30, 0.325, 0.35, 0.375, 0.40, 0.4125]
|
||||
skhs = [0.25, 0.275, 0.30]
|
||||
Jh = J[J.index >= HOLDOUT]
|
||||
hold_cur = sh(combo(Jh, w_cur))
|
||||
print(f" (HOLD Sh CURRENT = {hold_cur:.2f}; celle con XS>25% = viola cap, marcate *)")
|
||||
hdr = " TP\\SKH " + "".join(f"{s*100:>18.1f}%" for s in skhs)
|
||||
print(hdr)
|
||||
for tp in tps:
|
||||
row = f" {tp*100:>6.2f}%"
|
||||
for skh in skhs:
|
||||
xs = 1.0 - 0.15 - tp - skh
|
||||
tag = "*" if xs > CAPS["XS01"] + 1e-9 else " "
|
||||
if xs < LO_W - 1e-9:
|
||||
row += f"{'—':>19s}"
|
||||
continue
|
||||
w = np.array([{"TP01": tp, "XS01": xs, "VRP01": 0.15, "SKH01": skh}[prefix(n)] for n in names])
|
||||
hs = sh(combo(Jh, w))
|
||||
dmin = min(sh(combo(J[J.index >= c], w)) - sh(combo(J[J.index >= c], w_cur)) for c in CUTS)
|
||||
row += f" {hs:>5.2f}|{dmin:+.2f}{tag}"
|
||||
print(row)
|
||||
|
||||
# ------------------------------------------------ 5) REALISMO
|
||||
print("\n" + "-" * 110)
|
||||
print(" [5] REALISMO — pesi EFFETTIVI per era (outer-join rinormalizza!) + haircut d'esecuzione")
|
||||
print("-" * 110)
|
||||
# pesi rinormalizzati per era
|
||||
eras = [("2019-2020 (TP+SKH)", "2019-06-01"), ("2021-2023 (TP+VRP+SKH)", "2022-06-01"),
|
||||
("2024+ (tutti)", "2025-06-01")]
|
||||
print(" pesi EFFETTIVI (rinormalizzati sugli sleeve attivi) per era:")
|
||||
for lbl, probe in eras:
|
||||
t = pd.Timestamp(probe, tz="UTC")
|
||||
i = J.index.get_indexer([t], method="nearest")[0]
|
||||
act = J.iloc[i].notna().values
|
||||
for wl, w in [("CURRENT", w_cur), ("EW-STR ", w_ew)]:
|
||||
wn = w * act; wn = wn / wn.sum()
|
||||
s = " ".join(f"{prefix(n)} {x*100:.0f}%" for n, x in zip(names, wn) if act[list(names).index(n)])
|
||||
print(f" {lbl:<24s} {wl}: {s}")
|
||||
print(" -> nel 2019-23 EW-STR tiene SKH01 al 40-50% effettivo (research-grade, book 230m).")
|
||||
|
||||
# haircut d'esecuzione come DRAG (r' = r - h*mean_full(r)): modello-costi, non de-levering
|
||||
print("\n haircut esecuzione (drag costante = h x mean ritorno full dello sleeve):")
|
||||
print(f" {'scenario':<34s} {'CUR HOLD Sh':>11s} {'EW HOLD Sh':>11s} {'ΔSh':>6s} | {'CUR HOLD CAGR':>13s} {'EW HOLD CAGR':>13s}")
|
||||
scen = [("nessun haircut", {}, 0.0),
|
||||
("SKH01 -20%", {"SKH01"}, 0.20), ("SKH01 -30%", {"SKH01"}, 0.30),
|
||||
("SKH01+XS01 -20%", {"SKH01", "XS01"}, 0.20), ("SKH01+XS01 -30%", {"SKH01", "XS01"}, 0.30)]
|
||||
for lbl, targets, h in scen:
|
||||
Jx = J.copy()
|
||||
for n in names:
|
||||
if prefix(n) in targets:
|
||||
mu = float(J[n].dropna().mean())
|
||||
Jx[n] = J[n] - h * mu # drag solo dove lo sleeve e' attivo (NaN restano NaN)
|
||||
Jxh = Jx[Jx.index >= HOLDOUT]
|
||||
rc, re = combo(Jxh, w_cur), combo(Jxh, w_ew)
|
||||
mc, me = met(rc), met(re)
|
||||
print(f" {lbl:<34s} {mc['sharpe']:>11.2f} {me['sharpe']:>11.2f} {me['sharpe']-mc['sharpe']:>+6.2f} |"
|
||||
f" {mc['cagr']*100:>+12.1f}% {me['cagr']*100:>+12.1f}%")
|
||||
|
||||
# ------------------------------------------------ 6) FORKING PATHS
|
||||
print("\n" + "-" * 110)
|
||||
print(" [6] FORKING PATHS — config viste sull'hold-out + null di tilt casuali cap-respecting")
|
||||
print("-" * 110)
|
||||
n_configs = 7 + 8 # 7 vettori pesi (MAXSH,RP,ERC,MINVAR-R,MAXSH-STR,EW,EW-STR) + 8 celle guardia
|
||||
print(f" config valutate sull'hold-out nello script agente: >= {n_configs} (7 pesi + 8 celle guardia),")
|
||||
print(" EW-STR costruito DOPO aver visto EW vincere (ammesso nel diario).")
|
||||
rng = np.random.default_rng(42)
|
||||
caps = np.array([CAPS[prefix(n)] for n in names])
|
||||
N = 500
|
||||
dsh_hold, dsh_full, all_cuts_pos = [], [], 0
|
||||
Jh = J[J.index >= HOLDOUT]
|
||||
hold_c = sh(combo(Jh, w_cur)); full_c = sh(combo(J, w_cur))
|
||||
cuts_J = {c: J[J.index >= c] for c in CUTS}
|
||||
cuts_base = {c: sh(combo(cuts_J[c], w_cur)) for c in CUTS}
|
||||
for _ in range(N):
|
||||
w = rng.dirichlet(np.ones(len(names)))
|
||||
for _ in range(60): # proiezione su [LO_W, caps], somma 1
|
||||
w = np.clip(w, LO_W, caps)
|
||||
d = 1.0 - w.sum()
|
||||
if abs(d) < 1e-9:
|
||||
break
|
||||
if d > 0:
|
||||
room = caps - w
|
||||
w += d * room / max(room.sum(), 1e-12)
|
||||
else:
|
||||
room = w - LO_W
|
||||
w += d * room / max(room.sum(), 1e-12)
|
||||
dh = sh(combo(Jh, w)) - hold_c
|
||||
dsh_hold.append(dh)
|
||||
dsh_full.append(sh(combo(J, w)) - full_c)
|
||||
all_cuts_pos += all(sh(combo(cuts_J[c], w)) - cuts_base[c] > 0 for c in CUTS)
|
||||
dsh_hold = np.array(dsh_hold); dsh_full = np.array(dsh_full)
|
||||
dew = sh(combo(Jh, w_ew)) - hold_c
|
||||
print(f" {N} tilt casuali dentro i cap (VRP<=15, XS<=25, w>=5): quota che batte CURRENT")
|
||||
print(f" su HOLD-OUT: {float((dsh_hold > 0).mean())*100:.0f}% (mediana ΔSh {np.median(dsh_hold):+.2f})")
|
||||
print(f" su FULL: {float((dsh_full > 0).mean())*100:.0f}% (mediana ΔSh {np.median(dsh_full):+.2f})")
|
||||
print(f" su TUTTI e 3 cut: {all_cuts_pos/N*100:.0f}%")
|
||||
print(f" percentile di EW-STR (ΔSh HOLD {dew:+.2f}) fra i tilt casuali: {float((dsh_hold < dew).mean())*100:.0f}°")
|
||||
print(" lettura: se quasi ogni tilt cap-respecting batte CURRENT sull'hold-out, il 'vince OOS'")
|
||||
print(" e' una proprieta' del PERIODO (hold-out pro-SKH/XS per costruzione), non del vettore EW-STR.")
|
||||
|
||||
print("\n" + "=" * 110)
|
||||
print(" fine verifica — vedi addendum nel diario 2026-07-01-portfolio-weights-ddguard.md")
|
||||
print("=" * 110)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,369 @@
|
||||
"""r0701_vrp_refine — AFFINAMENTO VRP01 (gate/sizing) dentro i limiti del modello (2026-07-01).
|
||||
|
||||
Baseline = VRP01 combo (sleeves._vrp_combo_returns): put credit spread settimanale -0.28/-0.10,
|
||||
f=1.0, tenor 7d, gate VRP>0 (DVOL>RV30 causale) AND IV-rank>0.30 AND crash-skip IV-rank>0.90,
|
||||
fee 12.5% del premio. FULL Sh ~1.10 / HOLD ~0.60 / DD ~12%.
|
||||
|
||||
Celle NUOVE (mai provate — verificato nei diari; l'active management intra-trade e' gia'
|
||||
SCARTATO in 2026-06-20-vrp-active-management.md e NON si ripete):
|
||||
1. SIZING sul gap IV-RV (il carry atteso): size lineare clip(vrp/scale,0,1) o percentile
|
||||
espandente causale del VRP, invece del (o in aggiunta al) gate binario IV-rank.
|
||||
NB: il gate composito "IV-rank>0.30 AND IV-RV>0" e' GIA' il baseline (gate_vrp=True).
|
||||
2. Filtro DVOL-MOMENTUM: non vendere vol mentre DVOL sta salendo (dv[i]-dv[i-k] > thr).
|
||||
(Diverso da dvol_directional 2026-06-29: la' il DVOL-mom era segnale DIREZIONALE sul perp.)
|
||||
3. Gate di REGIME da TP01: de-risk (skip o half-size) quando TP01 e' flat su BTC e ETH
|
||||
(risk-off). Rischio ridondanza col trend -> riporto la frequenza d'intervento REALE.
|
||||
4. Croce completa delle manopole (griglia contenuta, 105 celle, TUTTE contate nel DSR).
|
||||
|
||||
Metodo: stessa pipeline di options_vrp_v2 (pricing BS su DVOL reale, payoff sul path
|
||||
certificato, stesse fee) — cambiano SOLO gate/sizing. Selezione cella IN-SAMPLE (pre-2025),
|
||||
hold-out 2025-26, multi-cut (5 tagli), deflated-Sharpe su tutti i trial, effetto a livello
|
||||
portafoglio 4-sleeve (TP01 41.25 / XS01 18.75 / VRP 15 / SKH01 25).
|
||||
|
||||
ONESTA': il premio resta MODELLATO su DVOL ATM (no skew), book 1d, f di stress non catturato.
|
||||
Il verdetto massimo possibile e' "sleeve modellato migliorato", MAI deploy pieno.
|
||||
|
||||
uv run python scripts/research/r0701_vrp_refine.py [--skip-portfolio]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research" / "alt"))
|
||||
|
||||
from collections import Counter
|
||||
from functools import lru_cache
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from scripts.research.options_vrp_lab import bs_put, strike_from_delta, load_series, m_weekly, per_year
|
||||
from altlib import deflated_sharpe
|
||||
|
||||
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
|
||||
WK_PER_YEAR = 365.25 / 7.0
|
||||
CUTS = [pd.Timestamp(c, tz="UTC") for c in
|
||||
("2023-01-01", "2023-07-01", "2024-01-01", "2024-07-01", "2025-01-01")]
|
||||
MIN_IS_ACTIVE = 0.20 # attivita' minima in-sample per candidarsi (baseline ~41%)
|
||||
|
||||
# --- parametri FISSI del baseline VRP01 (NON toccati: cambia solo gate/sizing) ---
|
||||
SHORT_DELTA, LONG_DELTA, F, TENOR_D = -0.28, -0.10, 1.0, 7
|
||||
CRASH_SKIP, FEE_FRAC = 0.90, 0.125
|
||||
|
||||
|
||||
# ----------------------------- pre-compute per asset (causale) -----------------------------
|
||||
@lru_cache(maxsize=None)
|
||||
def prep(asset: str):
|
||||
"""px/dvol allineati + VRP causale (DVOL - RV30) e IV-rank espandente per OGNI giorno.
|
||||
vrp[i] usa i 30 log-ret che finiscono a close[i]; ivr[i] = percentile di dv[i] in dv[:i]."""
|
||||
J = load_series(asset)
|
||||
px = J["px"].values.astype(float)
|
||||
dv = J["dvol"].values.astype(float) / 100.0
|
||||
idx = J.index
|
||||
n = len(px)
|
||||
lr = np.diff(np.log(px)) # lr[k] = log(px[k+1]/px[k])
|
||||
vrp = np.full(n, np.nan)
|
||||
for i in range(31, n):
|
||||
vrp[i] = dv[i] - float(np.std(lr[i - 30:i]) * np.sqrt(365.25)) # come baseline (ddof=0)
|
||||
ivr = np.full(n, np.nan)
|
||||
for i in range(60, n):
|
||||
ivr[i] = float((dv[:i] < dv[i]).mean())
|
||||
return px, dv, idx, vrp, ivr
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def tp01_avg_target():
|
||||
"""Serie giornaliera del target medio TP01 (BTC+ETH)/2. target[i] usa solo dati <= close[i]
|
||||
-> noto alla sell-date del VRP (stessa close). Long-flat: 0.0 = risk-off pieno."""
|
||||
from src.data.downloader import load_data
|
||||
from src.strategies.trend_portfolio import TrendPortfolio, CANONICAL, resample_1d
|
||||
tp = TrendPortfolio(**CANONICAL)
|
||||
cols = {}
|
||||
for a in ("BTC", "ETH"):
|
||||
df = resample_1d(load_data(a, "1h"))
|
||||
t = pd.Series(np.nan_to_num(tp.target_series(df), nan=0.0),
|
||||
index=pd.to_datetime(df["datetime"]))
|
||||
if t.index.tz is None:
|
||||
t.index = t.index.tz_localize("UTC")
|
||||
cols[a] = t
|
||||
J = pd.concat(cols, axis=1, join="inner")
|
||||
return J.mean(axis=1)
|
||||
|
||||
|
||||
# ----------------------------- motore settimanale (unica differenza: gate/sizing) -----------------------------
|
||||
def vrp_weekly(asset: str, sizing="bin", prop_scale=0.10, ivr_gate=0.30,
|
||||
mom_k=0, mom_thr=0.0, tp_mode="off") -> tuple[pd.Series, Counter]:
|
||||
"""Put credit spread settimanale come VRP01, con gate/sizing parametrici. CAUSALE:
|
||||
strike/premio/gate/size usano solo dati <= sell-date; payoff a scadenza sul path certificato.
|
||||
Ordine gate: prima i gate BASELINE (vrp/crash/ivr), poi i NUOVI (mom, tp) -> i counter dei
|
||||
nuovi gate contano l'intervento MARGINALE (settimane altrimenti tradabili)."""
|
||||
px, dv, idx, vrp_a, ivr_a = prep(asset)
|
||||
n = len(px); T = TENOR_D / 365.25
|
||||
tpv = None
|
||||
if tp_mode != "off":
|
||||
tpv = tp01_avg_target().reindex(idx, method="ffill").values
|
||||
rets = {}; st = Counter()
|
||||
i = 60
|
||||
while i + TENOR_D < n:
|
||||
st["weeks"] += 1
|
||||
S0 = px[i]; sig = dv[i]; vrp = vrp_a[i]; ivr = ivr_a[i]
|
||||
blocked = None
|
||||
# --- gate BASELINE (identici a VRP01) ---
|
||||
if np.isnan(vrp) or vrp <= 0:
|
||||
blocked = "vrp"
|
||||
elif not np.isnan(ivr) and ivr > CRASH_SKIP:
|
||||
blocked = "crash"
|
||||
elif ivr_gate > 0 and not np.isnan(ivr) and ivr < ivr_gate:
|
||||
blocked = "ivr"
|
||||
# --- gate NUOVI (contati sul residuo tradabile) ---
|
||||
if blocked is None and mom_k > 0 and i >= mom_k:
|
||||
if (dv[i] - dv[i - mom_k]) > mom_thr:
|
||||
blocked = "mom"
|
||||
size = 1.0
|
||||
if blocked is None and tp_mode != "off" and tpv is not None and tpv[i] <= 1e-12:
|
||||
if tp_mode == "skip":
|
||||
blocked = "tp"
|
||||
else: # half-size in risk-off
|
||||
size *= 0.5; st["tp_half"] += 1
|
||||
if blocked is None and sizing != "bin":
|
||||
if sizing == "lin": # size ∝ gap IV-RV (carry atteso)
|
||||
size *= float(np.clip(vrp / prop_scale, 0.0, 1.0))
|
||||
elif sizing == "rank": # percentile espandente causale del VRP
|
||||
hist = vrp_a[31:i]; hist = hist[~np.isnan(hist)]
|
||||
size *= float((hist < vrp).mean()) if len(hist) >= 30 else 0.5
|
||||
if blocked is not None:
|
||||
st[f"blk_{blocked}"] += 1
|
||||
rets[idx[i + TENOR_D]] = 0.0
|
||||
i += TENOR_D
|
||||
continue
|
||||
st["traded"] += 1; st["size_sum"] += size
|
||||
Ks = strike_from_delta(S0, T, sig, SHORT_DELTA)
|
||||
Kl = strike_from_delta(S0, T, sig, LONG_DELTA)
|
||||
net_prem = (bs_put(S0, Ks, T, sig) - bs_put(S0, Kl, T, sig)) * F
|
||||
S1 = px[i + TENOR_D]
|
||||
payoff = max(0.0, Ks - S1) - max(0.0, Kl - S1)
|
||||
pnl = net_prem - payoff - FEE_FRAC * abs(net_prem)
|
||||
rets[idx[i + TENOR_D]] = size * pnl / Ks # cash-secured su strike corto
|
||||
i += TENOR_D
|
||||
return pd.Series(rets), st
|
||||
|
||||
|
||||
def book(**kw) -> tuple[pd.Series, Counter]:
|
||||
rB, sB = vrp_weekly("BTC", **kw)
|
||||
rE, sE = vrp_weekly("ETH", **kw)
|
||||
b = pd.concat({"B": rB, "E": rE}, axis=1, join="inner").mean(axis=1).sort_index()
|
||||
return b, sB + sE
|
||||
|
||||
|
||||
# ----------------------------- metriche -----------------------------
|
||||
def sh_wk(r: pd.Series) -> float:
|
||||
r = r.dropna()
|
||||
if len(r) < 8 or r.std() == 0:
|
||||
return float("nan")
|
||||
return float(r.mean() / r.std() * np.sqrt(WK_PER_YEAR))
|
||||
|
||||
|
||||
def cell_metrics(b: pd.Series) -> dict:
|
||||
is_ = b[b.index < HOLDOUT]; ho = b[b.index >= HOLDOUT]
|
||||
full = m_weekly(b)
|
||||
return dict(full_sh=full["sh"], full_dd=full["dd"], full_cagr=full["cagr"],
|
||||
is_sh=sh_wk(is_), hold_sh=sh_wk(ho), worst=float(b.min()),
|
||||
active=float((b != 0).mean()), is_active=float((is_ != 0).mean()))
|
||||
|
||||
|
||||
def multicut(cand: pd.Series, base: pd.Series) -> list[tuple[str, float, float, float]]:
|
||||
out = []
|
||||
for c in CUTS:
|
||||
sc, sb = sh_wk(cand[cand.index >= c]), sh_wk(base[base.index >= c])
|
||||
out.append((str(c.date()), sc, sb, sc - sb))
|
||||
return out
|
||||
|
||||
|
||||
# ----------------------------- griglia -----------------------------
|
||||
def grid_cells():
|
||||
sizings = [("bin", 0.0, 0.30), ("lin", 0.08, 0.30), ("lin", 0.08, 0.0),
|
||||
("lin", 0.12, 0.30), ("lin", 0.12, 0.0), ("rank", 0.0, 0.30), ("rank", 0.0, 0.0)]
|
||||
moms = [(0, 0.0), (5, 0.0), (5, 0.05), (10, 0.0), (10, 0.05)]
|
||||
tps = ["off", "skip", "half"]
|
||||
cells = []
|
||||
for sz, scale, ivr in sizings:
|
||||
for mk, mth in moms:
|
||||
for tp in tps:
|
||||
name = (f"{sz}{f'{scale:g}' if sz == 'lin' else ''}"
|
||||
f"|ivr{ivr:g}|mom{mk}k{mth:g}|tp-{tp}")
|
||||
cells.append(dict(name=name, sizing=sz, prop_scale=scale, ivr_gate=ivr,
|
||||
mom_k=mk, mom_thr=mth, tp_mode=tp))
|
||||
return cells
|
||||
|
||||
|
||||
BASELINE_NAME = "bin|ivr0.3|mom0k0|tp-off"
|
||||
|
||||
|
||||
# ----------------------------- portafoglio 4-sleeve -----------------------------
|
||||
def weekly_to_daily_lump(wk: pd.Series) -> pd.Series:
|
||||
"""Come sleeves._vrp_combo_returns: rendimento settimanale sul giorno di scadenza, 0 altrove."""
|
||||
days = pd.date_range(wk.index.min().normalize(), wk.index.max().normalize(), freq="1D", tz="UTC")
|
||||
daily = pd.Series(0.0, index=days)
|
||||
daily.loc[wk.index.normalize()] = wk.values
|
||||
return daily
|
||||
|
||||
|
||||
def portfolio_compare(base_wk: pd.Series, cand_wk: pd.Series, cand_name: str):
|
||||
"""4-sleeve con VRP baseline vs VRP variante (stessi TP01/XS01/SKH01, cache condivisa)."""
|
||||
from src.portfolio.sleeves import tp01_sleeve, xsec_sleeve, skyhook_sleeve
|
||||
from src.portfolio.portfolio import Sleeve, StrategyPortfolio, metrics
|
||||
tp, xs, sk = tp01_sleeve(weight=0.4125), xsec_sleeve(weight=0.1875), skyhook_sleeve(weight=0.25)
|
||||
rows = []
|
||||
for tag, wk in (("VRP01 baseline", base_wk), (f"VRP variante [{cand_name}]", cand_wk)):
|
||||
daily = weekly_to_daily_lump(wk)
|
||||
vrp = Sleeve("VRP01_shortvol", 0.15, lambda d=daily: d)
|
||||
port = StrategyPortfolio([tp, xs, vrp, sk])
|
||||
full = metrics(port.combined_daily())
|
||||
hold = metrics(port.combined_daily(lo=HOLDOUT))
|
||||
rows.append((tag, full, hold))
|
||||
print(f" {tag:<38} FULL Sh {full['sharpe']:>5.2f} DD {full['maxdd']*100:>4.1f}% "
|
||||
f"CAGR {full['cagr']*100:>+5.1f}% | HOLD Sh {hold['sharpe']:>5.2f} DD {hold['maxdd']*100:>4.1f}%")
|
||||
return rows
|
||||
|
||||
|
||||
# ----------------------------- main -----------------------------
|
||||
def main():
|
||||
skip_port = "--skip-portfolio" in sys.argv
|
||||
print("=" * 110)
|
||||
print(" r0701 VRP REFINE — sizing IV-RV / filtro DVOL-momentum / gate TP01 (griglia onesta, sel. in-sample)")
|
||||
print("=" * 110)
|
||||
|
||||
cells = grid_cells()
|
||||
print(f" griglia: {len(cells)} celle (TUTTE contate nel deflated-Sharpe). "
|
||||
f"IS = pre-2025, HOLD = 2025-01-01+.\n")
|
||||
|
||||
results = {}
|
||||
for c in cells:
|
||||
b, st = book(sizing=c["sizing"], prop_scale=c["prop_scale"], ivr_gate=c["ivr_gate"],
|
||||
mom_k=c["mom_k"], mom_thr=c["mom_thr"], tp_mode=c["tp_mode"])
|
||||
results[c["name"]] = dict(cfg=c, b=b, st=st, **cell_metrics(b))
|
||||
|
||||
base = results[BASELINE_NAME]
|
||||
print(f" (0) BASELINE riprodotto [{BASELINE_NAME}]:")
|
||||
print(f" FULL Sh {base['full_sh']:.2f} DD {base['full_dd']*100:.0f}% CAGR {base['full_cagr']*100:+.0f}% "
|
||||
f"worst {base['worst']*100:+.1f}% IS Sh {base['is_sh']:.2f} HOLD Sh {base['hold_sh']:.2f} "
|
||||
f"attivo {base['active']*100:.0f}% (atteso ~ FULL 1.10 / HOLD 0.60 / DD 12%)")
|
||||
|
||||
# ---- frequenza d'intervento dei gate NUOVI (sul baseline + singola manopola) ----
|
||||
print("\n (1) FREQUENZA D'INTERVENTO dei gate nuovi (settimane altrimenti tradabili, book BTC+ETH):")
|
||||
probes = [("mom k=5 thr=0", dict(mom_k=5, mom_thr=0.0)),
|
||||
("mom k=5 thr=5pt", dict(mom_k=5, mom_thr=0.05)),
|
||||
("mom k=10 thr=0", dict(mom_k=10, mom_thr=0.0)),
|
||||
("mom k=10 thr=5pt", dict(mom_k=10, mom_thr=0.05)),
|
||||
("tp01-skip", dict(tp_mode="skip")),
|
||||
("tp01-half", dict(tp_mode="half"))]
|
||||
base_traded = base["st"]["traded"]
|
||||
for label, kw in probes:
|
||||
_, st = book(**kw)
|
||||
blk = st.get("blk_mom", 0) + st.get("blk_tp", 0)
|
||||
half = st.get("tp_half", 0)
|
||||
extra = f" (+{half} sett. a mezza size)" if half else ""
|
||||
print(f" {label:<18} blocca {blk:>3} / {base_traded} settimane-trade del baseline "
|
||||
f"({100*blk/max(base_traded,1):>4.1f}%){extra}")
|
||||
tgt = tp01_avg_target()
|
||||
pxB, _, idxB, _, _ = prep("BTC")
|
||||
tp_on_grid = tgt.reindex(idxB, method="ffill")
|
||||
print(f" [contesto] TP01 flat (BTC+ETH entrambi 0): {100*float((tp_on_grid <= 1e-12).mean()):.0f}% dei giorni della finestra DVOL")
|
||||
|
||||
# ---- classifica IN-SAMPLE (selezione onesta: nessuno sguardo all'hold-out) ----
|
||||
ranked = sorted((r for r in results.values() if r["is_active"] >= MIN_IS_ACTIVE),
|
||||
key=lambda r: r["is_sh"], reverse=True)
|
||||
print(f"\n (2) TOP-10 per Sharpe IN-SAMPLE (pre-2025; filtro attivita' IS >= {MIN_IS_ACTIVE:.0%}):")
|
||||
print(f" {'cella':<34}{'IS Sh':>7}{'FULL':>7}{'HOLD':>7}{'DD':>6}{'worst':>8}{'att.':>6}")
|
||||
for r in ranked[:10]:
|
||||
print(f" {r['cfg']['name']:<34}{r['is_sh']:>7.2f}{r['full_sh']:>7.2f}{r['hold_sh']:>7.2f}"
|
||||
f"{r['full_dd']*100:>5.0f}%{r['worst']*100:>+7.1f}%{r['active']*100:>5.0f}%")
|
||||
|
||||
# ---- varianti a SINGOLA manopola vs baseline (tabella diario) ----
|
||||
print("\n (2b) VARIANTI A SINGOLA MANOPOLA vs baseline (stessa tabella, nessuna selezione):")
|
||||
singles = ["lin0.08|ivr0.3|mom0k0|tp-off", "lin0.12|ivr0.3|mom0k0|tp-off",
|
||||
"rank|ivr0.3|mom0k0|tp-off", "lin0.08|ivr0|mom0k0|tp-off",
|
||||
"rank|ivr0|mom0k0|tp-off",
|
||||
"bin|ivr0.3|mom5k0.05|tp-off", "bin|ivr0.3|mom10k0.05|tp-off",
|
||||
"bin|ivr0.3|mom5k0|tp-off", "bin|ivr0.3|mom10k0|tp-off",
|
||||
"bin|ivr0.3|mom0k0|tp-skip", "bin|ivr0.3|mom0k0|tp-half"]
|
||||
print(f" {'cella':<34}{'IS Sh':>7}{'FULL':>7}{'HOLD':>7}{'DD':>6}{'worst':>8}{'att.':>6}")
|
||||
r = base
|
||||
print(f" {'BASELINE ' + BASELINE_NAME:<34}{r['is_sh']:>7.2f}{r['full_sh']:>7.2f}{r['hold_sh']:>7.2f}"
|
||||
f"{r['full_dd']*100:>5.0f}%{r['worst']*100:>+7.1f}%{r['active']*100:>5.0f}%")
|
||||
for nm in singles:
|
||||
r = results[nm]
|
||||
print(f" {nm:<34}{r['is_sh']:>7.2f}{r['full_sh']:>7.2f}{r['hold_sh']:>7.2f}"
|
||||
f"{r['full_dd']*100:>5.0f}%{r['worst']*100:>+7.1f}%{r['active']*100:>5.0f}%")
|
||||
n_beat_hold = sum(1 for r in results.values() if r["hold_sh"] > base["hold_sh"])
|
||||
print(f" [onesta'] celle che battono l'HOLD-OUT del baseline: {n_beat_hold}/{len(results)} — "
|
||||
f"NON selezionabili (sarebbe selection-on-holdout, gate 2026-06-29).")
|
||||
|
||||
cand = ranked[0]
|
||||
is_baseline_best = cand["cfg"]["name"] == BASELINE_NAME
|
||||
print(f"\n -> cella scelta IN-SAMPLE: [{cand['cfg']['name']}] IS Sh {cand['is_sh']:.2f} "
|
||||
f"(baseline IS {base['is_sh']:.2f}, Δ {cand['is_sh']-base['is_sh']:+.2f})")
|
||||
|
||||
# ---- hold-out multi-cut vs baseline ----
|
||||
print("\n (3) MULTI-CUT hold-out (Sharpe da ogni taglio a fine storia; uplift = cand - baseline):")
|
||||
mc = multicut(cand["b"], base["b"])
|
||||
pos = sum(1 for _, _, _, u in mc if u > 0)
|
||||
for cut, sc, sb, u in mc:
|
||||
print(f" cut {cut}: cand {sc:>5.2f} base {sb:>5.2f} uplift {u:>+5.2f}")
|
||||
print(f" uplift positivo in {pos}/{len(mc)} tagli (richiesti >= 4/5)")
|
||||
|
||||
# ---- deflated Sharpe (tutti i trial della griglia) ----
|
||||
all_sh = [r["full_sh"] for r in results.values()]
|
||||
dsr_c, null_max = deflated_sharpe(cand["full_sh"], all_sh, cand["b"].values, dpy=WK_PER_YEAR)
|
||||
dsr_b, _ = deflated_sharpe(base["full_sh"], all_sh, base["b"].values, dpy=WK_PER_YEAR)
|
||||
print(f"\n (4) DEFLATED SHARPE (N={len(all_sh)} trial di questa griglia; PASS >= 0.95):")
|
||||
print(f" cand DSR {dsr_c:.3f} (null-max Sh {null_max:.2f}) | baseline DSR {dsr_b:.3f}")
|
||||
print(" NB: le celle della griglia sono fortemente correlate fra loro (stesso trade sottostante)")
|
||||
print(" -> il DSR qui e' anti-conservativo sul multiple-testing; in piu' VRP01 stesso viene da")
|
||||
print(" ~20 config precedenti (options_vrp_lab/_v2). Leggere il DSR come limite SUPERIORE.")
|
||||
|
||||
# ---- per-anno cand vs base ----
|
||||
print("\n (5) PER-ANNO (ritorno composto):")
|
||||
pyc, pyb = per_year(cand["b"]), per_year(base["b"])
|
||||
print(" base: " + " ".join(f"{y}:{v*100:+.0f}%" for y, v in pyb.items()))
|
||||
print(" cand: " + " ".join(f"{y}:{v*100:+.0f}%" for y, v in pyc.items()))
|
||||
|
||||
# ---- portafoglio 4-sleeve ----
|
||||
if not skip_port:
|
||||
print("\n (6) PORTAFOGLIO 4-SLEEVE (TP01 41.25 / XS01 18.75 / VRP 15 / SKH01 25), VRP base vs variante:")
|
||||
try:
|
||||
portfolio_compare(base["b"], cand["b"], cand["cfg"]["name"])
|
||||
except Exception as e: # dati HL/5m mancanti in qualche ambiente
|
||||
print(f" [saltato: {type(e).__name__}: {e}]")
|
||||
else:
|
||||
print("\n (6) portafoglio: saltato (--skip-portfolio)")
|
||||
|
||||
# ---- verdetto ----
|
||||
print("\n" + "=" * 110)
|
||||
improves = (not is_baseline_best
|
||||
and cand["is_sh"] > base["is_sh"]
|
||||
and pos >= 4
|
||||
and (cand["hold_sh"] > base["hold_sh"])
|
||||
and dsr_c >= 0.95)
|
||||
if is_baseline_best:
|
||||
print(" VERDETTO: NON MIGLIORA — il baseline VRP01 vince gia' la selezione in-sample.")
|
||||
elif improves:
|
||||
print(f" VERDETTO: MIGLIORA (variante {cand['cfg']['name']}) — batte il baseline in-sample,")
|
||||
print(f" su hold-out multi-cut ({pos}/{len(mc)}) e DSR {dsr_c:.2f}>=0.95. Resta SLEEVE MODELLATO")
|
||||
print(" (premio DVOL ATM, book 1d, f di stress non catturato): NON deploy pieno.")
|
||||
else:
|
||||
why = []
|
||||
if cand["is_sh"] <= base["is_sh"]:
|
||||
why.append("non batte il baseline in-sample")
|
||||
if pos < 4:
|
||||
why.append(f"multi-cut {pos}/{len(mc)} (<4)")
|
||||
if cand["hold_sh"] <= base["hold_sh"]:
|
||||
why.append("hold-out non migliore")
|
||||
if dsr_c < 0.95:
|
||||
why.append(f"DSR {dsr_c:.2f}<0.95")
|
||||
print(f" VERDETTO: NON MIGLIORA — cella IS-best [{cand['cfg']['name']}] bocciata: " + "; ".join(why) + ".")
|
||||
print("=" * 110)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,439 @@
|
||||
"""r0701_xs — RESIDUAL (IDIOSYNCRATIC) MOMENTUM cross-sectional sui 19 major Hyperliquid.
|
||||
|
||||
TESI (2026-07-01). STATARB-RESID (thread 4, 2026-06-29) ha mostrato che il MOMENTUM del residuo
|
||||
ETH−β·BTC (β OLS rolling, sgn=+1: le dislocazioni CONTINUANO a 1d) passa quasi tutti i gate su
|
||||
2 gambe, fallendo SOLO il deflated-Sharpe (0.929<0.95). Angolo nuovo: lo stesso meccanismo
|
||||
CROSS-SECTIONAL sui 19 major di XS01 — per ogni alt, residuo vs β·BTC (β OLS rolling B giorni),
|
||||
momentum del residuo su lookback (blend z-score [30,90] come XS01, o singolo), rank cross-section,
|
||||
long top-k / short bottom-k, vol-target 20%. Ipotesi: la breadth (18 stream invece di 1) alza il
|
||||
DSR dove il 2-gambe falliva.
|
||||
|
||||
DISTINZIONE da quanto gia' testato:
|
||||
* IREV (xsec_v2_nonmom, idio-REVERSAL, sgn=-1): FALLITO. Qui sgn=+1 (idio-MOMENTUM).
|
||||
* IMOM (xsec_v2_nonmom): residuo vs mercato EQUAL-WEIGHT, B=60 fisso, no blend, era solo
|
||||
"riferimento momentum". Qui: fattore = BTC (come STATARB-RESID), B in griglia, blend z-score
|
||||
[30,90] + probe con gate di dispersione (parita' strutturale con XS01), selezione IN-SAMPLE.
|
||||
|
||||
IL BAR (fondamentale): una variante di XS01 e' utile SOLO se (a) SOSTITUISCE XS01 (meglio
|
||||
standalone E nel portafoglio 4-sleeve) oppure (b) AGGIUNGE come 5o sleeve (corr bassa a XS01 E
|
||||
TP01, uplift del PORTAFOGLIO). corr>0.6 a XS01 senza batterlo -> REDUNDANT/SCARTATO.
|
||||
Baseline XS01: standalone FULL Sh ~1.50 / HOLD ~1.71 / DD ~11%.
|
||||
|
||||
GATE (CLAUDE.md, metodologia obbligatoria):
|
||||
1. CAUSALE: score a close[i], peso tenuto in i+1 (engine shifta W[i-1]*dret[i]); barre vol=0
|
||||
escluse; prefix-check di causalita' sulla cella scelta.
|
||||
2. NETTO fee 0.10% RT per gamba per ribilancio + sweep {0.05, 0.10, 0.20, 0.30}% RT/gamba.
|
||||
3. Selezione cella IN-SAMPLE-ONLY (pre-2025, anti selection-on-holdout), poi hold-out bloccato.
|
||||
4. DEFLATED Sharpe su TUTTI i trial della griglia (serve >=0.95).
|
||||
5. Confronto PORTAFOGLIO: sostituzione di XS01 a parita' di peso + aggiunta 5o sleeve @10/15%
|
||||
(riusa StrategyPortfolio/active_sleeves senza modificarli) + marginal vs il BOOK a 4 sleeve.
|
||||
6. CAVEAT IMMUTABILI: storia HL ~2.5 anni; book L/S a ~2k gambe -> STAT-MODE a $600 (dichiaro
|
||||
comunque l'haircut small-cap $600/min$5).
|
||||
|
||||
uv run python scripts/research/r0701_xs_residmom.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research"))
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
import xsec_v2_nonmom as xv # harness collaudato (load_matrix, deflated_sharpe, portfolio, ...)
|
||||
from src.portfolio.sleeves import XS_UNIVERSE
|
||||
|
||||
DPY, TV, FEE, HOLDOUT = xv.DPY, xv.TV, xv.FEE, xv.HOLDOUT
|
||||
FACTOR = "BTC" # il fattore del residuo (come STATARB-RESID), NON tradato
|
||||
BLEND = (30, 90) # blend z-score come XS01
|
||||
|
||||
# griglia (modesta, da mandato): beta-window x lookback x k x H (gate=off)
|
||||
BETAS = (60, 90, 120)
|
||||
LOOKS = ("blend", 30, 90)
|
||||
KS = (3, 5)
|
||||
HS = (5, 10, 20)
|
||||
# probe a parita' STRUTTURALE con XS01 (blend + gate dispersione p30, H10 k5) — contano nei trial
|
||||
GATED_PROBES = [dict(B=B, L="blend", k=5, H=10, gate=30) for B in BETAS]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SCORE BUILDER — residual momentum vs beta*BTC. CAUSALE (dati <= i).
|
||||
# Ritorna score_at(i) -> (score_blend_z[A], valid[A], disp_raw_i) + warmup.
|
||||
# disp_raw_i = dispersione cross-section del momentum RESIDUO grezzo (per il gate: lo z-score
|
||||
# blended ha std ~1 per costruzione, quindi la dispersione va misurata sul grezzo).
|
||||
# ===========================================================================
|
||||
def make_residmom(PX: pd.DataFrame, B: int, L):
|
||||
lookbacks = BLEND if L == "blend" else (int(L),)
|
||||
px = PX.values
|
||||
n, A = px.shape
|
||||
fi = list(PX.columns).index(FACTOR)
|
||||
DR = PX.pct_change()
|
||||
m = DR[FACTOR]
|
||||
beta, _ = xv._rolling_beta(DR, m, B) # beta_j vs BTC su finestra B (<= i)
|
||||
SDR = {Lk: DR.rolling(Lk, min_periods=int(0.8 * Lk)).sum().values for Lk in lookbacks}
|
||||
SM = {Lk: m.rolling(Lk, min_periods=int(0.8 * Lk)).sum().values for Lk in lookbacks}
|
||||
CNT = {Lk: DR.rolling(Lk, min_periods=1).count().values for Lk in lookbacks}
|
||||
|
||||
def score_at(i):
|
||||
b = beta[i]
|
||||
valid = np.isfinite(px[i]) & np.isfinite(b)
|
||||
valid[fi] = False # BTC = fattore, fuori dal cross-section
|
||||
resids = []
|
||||
for Lk in lookbacks:
|
||||
resid = SDR[Lk][i] - b * SM[Lk][i] # momentum del residuo r_j - beta_j*r_btc
|
||||
valid = valid & np.isfinite(resid) & (CNT[Lk][i] >= 0.8 * Lk)
|
||||
resids.append(resid)
|
||||
score = np.full(A, np.nan)
|
||||
disp = np.nan
|
||||
nv = int(valid.sum())
|
||||
if nv >= 2:
|
||||
acc = np.zeros(nv)
|
||||
cnt = 0
|
||||
stds = []
|
||||
for resid in resids:
|
||||
r = resid[valid]
|
||||
sd = float(r.std())
|
||||
stds.append(sd)
|
||||
if sd > 0:
|
||||
acc += (r - r.mean()) / sd
|
||||
cnt += 1
|
||||
if cnt:
|
||||
score[valid] = acc / cnt # blend: media z-score cross-sectional
|
||||
disp = float(np.mean(stds)) # dispersione del momentum residuo GREZZO
|
||||
return score, valid, disp
|
||||
|
||||
return score_at, max(max(lookbacks), B) + 1
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# ENGINE locale (= xv.xs_engine + ritorno di W/scale + gate di dispersione opzionale).
|
||||
# L'uguaglianza con xv.xs_engine sulle celle non-gated e' VERIFICATA in main().
|
||||
# ===========================================================================
|
||||
def xs_engine_w(PX, VOL, score_at, H, k, target_vol=TV, fee=FEE, min_assets=10, warmup=0,
|
||||
disp_pct=0, disp_minhist=20):
|
||||
px = PX.values
|
||||
vol = VOL.values
|
||||
n, A = px.shape
|
||||
dret = np.full((n, A), np.nan)
|
||||
dret[1:] = px[1:] / px[:-1] - 1.0
|
||||
W = np.zeros((n, A))
|
||||
w = np.zeros(A)
|
||||
disp_hist = []
|
||||
for i in range(n):
|
||||
if i >= warmup and i % H == 0:
|
||||
score, valid, disp = score_at(i)
|
||||
valid = valid & np.isfinite(score) & (vol[i] > 0)
|
||||
idxv = np.where(valid)[0]
|
||||
if len(idxv) >= min_assets:
|
||||
thr = (np.percentile(disp_hist, disp_pct)
|
||||
if (disp_pct > 0 and len(disp_hist) >= disp_minhist) else -np.inf)
|
||||
if not (disp_pct > 0) or (np.isfinite(disp) and disp >= thr):
|
||||
kk = min(k, len(idxv) // 2)
|
||||
order = idxv[np.argsort(score[idxv])]
|
||||
lo, hi = order[:kk], order[-kk:]
|
||||
w = np.zeros(A)
|
||||
w[hi] = 0.5 / kk # long alto residual-momentum (sgn=+1)
|
||||
w[lo] = -0.5 / kk # short basso
|
||||
else:
|
||||
w = np.zeros(A) # regime compatto -> flat (gate)
|
||||
if disp_pct > 0 and np.isfinite(disp):
|
||||
disp_hist.append(disp)
|
||||
else:
|
||||
w = np.zeros(A)
|
||||
W[i] = w
|
||||
gross = np.zeros(n)
|
||||
gross[1:] = np.nansum(W[:-1] * np.nan_to_num(dret[1:]), axis=1)
|
||||
turn = np.zeros(n)
|
||||
turn[0] = np.abs(W[0]).sum()
|
||||
turn[1:] = np.abs(np.diff(W, axis=0)).sum(axis=1)
|
||||
net = gross - turn * (fee / 2.0)
|
||||
s = pd.Series(net, index=PX.index)
|
||||
rv = s.rolling(30, min_periods=15).std().shift(1) * np.sqrt(DPY)
|
||||
scale = np.clip(np.nan_to_num(target_vol / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0)
|
||||
turn_py = float(turn.sum() / (n / DPY)) if n else 0.0
|
||||
return pd.Series(s.values * scale, index=PX.index), turn_py, W, scale
|
||||
|
||||
|
||||
def run_cell(PX, VOL, cfg, fee=FEE):
|
||||
score_at, warm = make_residmom(PX, cfg["B"], cfg["L"])
|
||||
daily, turn, W, scale = xs_engine_w(PX, VOL, score_at, cfg["H"], cfg["k"], fee=fee,
|
||||
warmup=warm, disp_pct=cfg.get("gate", 0))
|
||||
return xv.to_daily(daily), turn, W, scale
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# CAUSALITA' (prefix-check, pattern di xv.causality_prefix_check sul nostro engine)
|
||||
# ===========================================================================
|
||||
def causality_prefix_check(PX, VOL, cfg, frac=0.85, tail=60, tol=1e-9):
|
||||
score_full, warm = make_residmom(PX, cfg["B"], cfg["L"])
|
||||
full, *_ = xs_engine_w(PX, VOL, score_full, cfg["H"], cfg["k"], warmup=warm,
|
||||
disp_pct=cfg.get("gate", 0))
|
||||
cut = int(len(PX) * frac)
|
||||
PXc, VOLc = PX.iloc[:cut], VOL.iloc[:cut]
|
||||
score_pre, warm2 = make_residmom(PXc, cfg["B"], cfg["L"])
|
||||
pre, *_ = xs_engine_w(PXc, VOLc, score_pre, cfg["H"], cfg["k"], warmup=warm2,
|
||||
disp_pct=cfg.get("gate", 0))
|
||||
lo = max(0, cut - tail)
|
||||
a, b = full.values[lo:cut], pre.values[lo:cut]
|
||||
worst = float(np.max(np.abs(a - b))) if len(a) else float("nan")
|
||||
return dict(ok=bool(worst <= tol), max_tail_diff=worst, cut=cut, tail=len(a))
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SMALL-CAP $600 (dichiarativo: il book resta STAT-MODE comunque, come XS01).
|
||||
# Pesi EFFETTIVI = W[i] * scale[i+1] (scale[i+1] usa net fino a i via shift(1) -> noto a close i).
|
||||
# Un cambio-gamba con |dw|*capital < min_order NON si esegue. Confronto realistico vs modeled
|
||||
# sulla STESSA simulazione a pesi (coerente internamente).
|
||||
# ===========================================================================
|
||||
def smallcap_check(PX, W, scale, fee=FEE, capital=600.0, min_order=5.0):
|
||||
px = PX.values
|
||||
n, A = px.shape
|
||||
dret = np.nan_to_num(np.vstack([np.zeros(A), px[1:] / px[:-1] - 1.0]))
|
||||
sc = np.roll(scale, -1)
|
||||
sc[-1] = scale[-1]
|
||||
T = W * sc[:, None] # target effettivo deciso a close i
|
||||
|
||||
def sim(min_ord):
|
||||
held = np.zeros((n, A))
|
||||
cur = np.zeros(A)
|
||||
n_tr = 0
|
||||
for i in range(n):
|
||||
d = np.abs(T[i] - cur) * capital
|
||||
ex = d >= min_ord
|
||||
n_tr += int(ex.sum())
|
||||
cur = np.where(ex, T[i], cur)
|
||||
held[i] = cur
|
||||
pos = np.zeros((n, A))
|
||||
pos[1:] = held[:-1]
|
||||
turn = np.abs(np.diff(held, axis=0, prepend=np.zeros((1, A)))).sum(axis=1)
|
||||
net = (pos * dret).sum(axis=1) - turn * (fee / 2.0)
|
||||
r = net[np.isfinite(net)]
|
||||
sh = float(r.mean() / r.std() * np.sqrt(DPY)) if r.std() > 0 else 0.0
|
||||
return sh, n_tr, float(turn.sum() / (n / DPY))
|
||||
|
||||
sh_real, ntr_real, turn_real = sim(min_order)
|
||||
sh_mod, ntr_mod, turn_mod = sim(0.0)
|
||||
return dict(sharpe_modeled=round(sh_mod, 3), sharpe_realistic=round(sh_real, 3),
|
||||
haircut=round(sh_mod - sh_real, 3), n_executed=ntr_real, n_modeled=ntr_mod,
|
||||
turnover_real=round(turn_real, 1), turnover_modeled=round(turn_mod, 1))
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# PORTAFOGLIO — sostituzione XS01 + aggiunta 5o sleeve (pattern di xsec_v3_momstruct)
|
||||
# ===========================================================================
|
||||
_BASE = None
|
||||
_BASE_M = None
|
||||
|
||||
|
||||
def _base():
|
||||
global _BASE, _BASE_M
|
||||
if _BASE is None:
|
||||
_BASE = xv.active_sleeves()
|
||||
pf = xv.StrategyPortfolio(_BASE)
|
||||
pf.backtest()
|
||||
_BASE_M = (xv.metrics(pf.combined_daily()), xv.metrics(pf.combined_daily(lo=HOLDOUT)))
|
||||
return _BASE, _BASE_M
|
||||
|
||||
|
||||
def add_uplift(daily, fr):
|
||||
base, _ = _base()
|
||||
wraw = fr / (1.0 - fr)
|
||||
cand = xv.Sleeve("R0701_cand", wraw, lambda d=daily: d)
|
||||
pf = xv.StrategyPortfolio(base + [cand])
|
||||
return (xv.metrics(pf.combined_daily()), xv.metrics(pf.combined_daily(lo=HOLDOUT)),
|
||||
pf.weights().get("R0701_cand", 0.0))
|
||||
|
||||
|
||||
def substitute_xs01(daily):
|
||||
base, _ = _base()
|
||||
sub = [xv.Sleeve("R0701_sub", s.weight, lambda d=daily: d) if s.name == "XS01_xsec_hl" else s
|
||||
for s in base]
|
||||
pf = xv.StrategyPortfolio(sub)
|
||||
return xv.metrics(pf.combined_daily()), xv.metrics(pf.combined_daily(lo=HOLDOUT))
|
||||
|
||||
|
||||
def marginal_vs_book(daily):
|
||||
"""Corr + uplift del blend 0.9*BOOK+0.1*cand vs il BOOK a 4 sleeve (full/hold + multi-cut)."""
|
||||
base, _ = _base()
|
||||
book = xv.StrategyPortfolio(base).combined_daily()
|
||||
J = pd.concat({"B": book, "C": daily}, axis=1, join="inner").dropna()
|
||||
|
||||
def _sh(s):
|
||||
r = np.asarray(s.dropna().values, float)
|
||||
return float(r.mean() / r.std() * np.sqrt(DPY)) if len(r) > 2 and r.std() > 0 else 0.0
|
||||
|
||||
def _up(sub):
|
||||
return _sh(0.9 * sub["B"] + 0.1 * sub["C"]) - _sh(sub["B"])
|
||||
|
||||
JH = J[J.index >= HOLDOUT]
|
||||
cuts = {}
|
||||
for y in sorted(set(J.index.year))[1:]:
|
||||
sub = J[J.index >= pd.Timestamp(f"{y}-01-01", tz="UTC")]
|
||||
if len(sub) >= 120:
|
||||
cuts[int(y)] = round(_up(sub), 3)
|
||||
return dict(corr_book=round(float(J["B"].corr(J["C"])), 3),
|
||||
uplift_full=round(_up(J), 3),
|
||||
uplift_hold=round(_up(JH), 3) if len(JH) > 30 else None,
|
||||
multicut=cuts)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
def per_year(daily):
|
||||
return [(int(y), round(float((1 + g).prod() - 1), 3)) for y, g in daily.groupby(daily.index.year)]
|
||||
|
||||
|
||||
def tag(cfg):
|
||||
g = f" gate{cfg['gate']}" if cfg.get("gate") else ""
|
||||
return f"B{cfg['B']} L{cfg['L']} k{cfg['k']} H{cfg['H']}{g}"
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 104)
|
||||
print(" r0701_xs — RESIDUAL MOMENTUM cross-sectional (residuo vs beta*BTC) sui 19 major HL — STAT-MODE")
|
||||
print("=" * 104)
|
||||
|
||||
PX, VOL = xv.load_matrix(XS_UNIVERSE)
|
||||
print(f" universo 19-major: {PX.shape[1]} asset, {PX.shape[0]} giorni "
|
||||
f"[{PX.index[0].date()} -> {PX.index[-1].date()}] fattore={FACTOR} (escluso dal cross-section)")
|
||||
|
||||
tp_daily = xv.tp01_sleeve().daily()
|
||||
xs_daily = xv.xsec_sleeve().daily()
|
||||
xs_f = xv.metrics(xs_daily)
|
||||
xs_h = xv.metrics(xs_daily[xs_daily.index >= HOLDOUT])
|
||||
print(f" baseline XS01 (sleeve attivo): FULL Sh {xs_f['sharpe']:.2f} DD {xs_f['maxdd']*100:.0f}%"
|
||||
f" | HOLD Sh {xs_h['sharpe']:.2f}")
|
||||
|
||||
# --- sanity: engine locale == xv.xs_engine sulle celle non-gated -----------------
|
||||
chk_cfg = dict(B=90, L=30, k=5, H=10)
|
||||
score_at, warm = make_residmom(PX, chk_cfg["B"], chk_cfg["L"])
|
||||
mine, *_ = xs_engine_w(PX, VOL, score_at, chk_cfg["H"], chk_cfg["k"], warmup=warm)
|
||||
ref, _ = xv.xs_engine(PX, VOL, lambda i: score_at(i)[:2], chk_cfg["H"], chk_cfg["k"], warmup=warm)
|
||||
dmax = float(np.nanmax(np.abs(mine.values - ref.values)))
|
||||
assert dmax < 1e-12, f"engine locale diverge da xv.xs_engine: {dmax}"
|
||||
print(f" [sanity] engine locale == xv.xs_engine (maxdiff {dmax:.1e})")
|
||||
|
||||
# --- griglia -----------------------------------------------------------------------
|
||||
grid = [dict(B=B, L=L, k=k, H=H) for B in BETAS for L in LOOKS for k in KS for H in HS]
|
||||
grid += GATED_PROBES
|
||||
rows = []
|
||||
for cfg in grid:
|
||||
daily, turn, W, scale = run_cell(PX, VOL, cfg)
|
||||
if daily.std() == 0 or len(daily) < 60:
|
||||
continue
|
||||
f, h, pct = xv.evalcfg(daily)
|
||||
ins = daily[daily.index < HOLDOUT]
|
||||
is_sh = xv.metrics(ins)["sharpe"] if len(ins) > 60 else float("nan")
|
||||
rows.append(dict(cfg=cfg, daily=daily, W=W, scale=scale, turn=turn,
|
||||
full=f["sharpe"], hold=h["sharpe"], dd=f["maxdd"], ret=f["ret"],
|
||||
pct=pct, is_sh=is_sh,
|
||||
corrXS=xv._corr(daily, xs_daily), corrTP=xv._corr(daily, tp_daily)))
|
||||
all_sr = [r["full"] for r in rows]
|
||||
print(f"\n griglia: {len(rows)} celle valide su {len(grid)} "
|
||||
f"(trial per deflated-Sharpe = {len(all_sr)}; il conteggio VERO del programma e' >>)")
|
||||
|
||||
hdr = f" {'cfg':<26}{'IS':>7}{'FULL':>7}{'HOLD':>7}{'DD%':>6}{'anni+':>7}{'corrXS':>8}{'corrTP':>8}{'turn/y':>8}"
|
||||
valid = [r for r in rows if np.isfinite(r["is_sh"])]
|
||||
print("\n TOP-5 per Sharpe IN-SAMPLE (pre-2025) — la selezione ONESTA:")
|
||||
print(hdr)
|
||||
for r in sorted(valid, key=lambda r: -r["is_sh"])[:5]:
|
||||
print(f" {tag(r['cfg']):<26}{r['is_sh']:>7.2f}{r['full']:>7.2f}{r['hold']:>7.2f}"
|
||||
f"{r['dd']*100:>6.0f}{r['pct']*100:>6.0f}%{r['corrXS']:>+8.2f}{r['corrTP']:>+8.2f}{r['turn']:>8.0f}")
|
||||
print("\n TOP-5 per HOLD (solo trasparenza — selezionare qui = selection-on-holdout):")
|
||||
print(hdr)
|
||||
for r in sorted(rows, key=lambda r: -r["hold"])[:5]:
|
||||
iss = f"{r['is_sh']:.2f}" if np.isfinite(r["is_sh"]) else "n/a"
|
||||
print(f" {tag(r['cfg']):<26}{iss:>7}{r['full']:>7.2f}{r['hold']:>7.2f}"
|
||||
f"{r['dd']*100:>6.0f}{r['pct']*100:>6.0f}%{r['corrXS']:>+8.2f}{r['corrTP']:>+8.2f}{r['turn']:>8.0f}")
|
||||
|
||||
if not valid:
|
||||
print("\n >>> nessuna cella con in-sample valutabile. SCARTATO.")
|
||||
return
|
||||
|
||||
pick = max(valid, key=lambda r: r["is_sh"])
|
||||
daily = pick["daily"]
|
||||
print("\n" + "=" * 104)
|
||||
print(f" CELLA SCELTA (in-sample-only): {tag(pick['cfg'])}")
|
||||
print("=" * 104)
|
||||
print(f" IS Sh {pick['is_sh']:.2f} | FULL {pick['full']:.2f} | HOLD {pick['hold']:.2f}"
|
||||
f" | DD {pick['dd']*100:.0f}% | ret {pick['ret']*100:+.0f}% | anni+ {pick['pct']*100:.0f}%"
|
||||
f" | turnover/y {pick['turn']:.0f}")
|
||||
print(f" corr vs XS01 {pick['corrXS']:+.2f} | corr vs TP01 {pick['corrTP']:+.2f}"
|
||||
f" | per-anno {per_year(daily)}")
|
||||
|
||||
caus = causality_prefix_check(PX, VOL, pick["cfg"])
|
||||
print(f" CAUSALITA' (prefix-check): ok={caus['ok']} max_tail_diff={caus['max_tail_diff']:.2e}")
|
||||
|
||||
dsr, sr0 = xv.deflated_sharpe(pick["full"], all_sr, daily)
|
||||
print(f" DEFLATED Sharpe (N={len(all_sr)} trial di QUESTA griglia): {dsr:.3f}"
|
||||
f" | soglia null-max annualizz. {sr0:.2f} (serve >=0.95)")
|
||||
|
||||
print(" fee sweep (RT per gamba):", end=" ")
|
||||
for fee in (0.0005, 0.001, 0.002, 0.003):
|
||||
d_f, *_ = run_cell(PX, VOL, pick["cfg"], fee=fee)
|
||||
ff = xv.metrics(d_f)
|
||||
hh = xv.metrics(d_f[d_f.index >= HOLDOUT])
|
||||
print(f"{fee*100:.2f}%: F{ff['sharpe']:+.2f}/H{hh['sharpe']:+.2f}", end=" ")
|
||||
print()
|
||||
|
||||
sc = smallcap_check(PX, pick["W"], pick["scale"])
|
||||
print(f" SMALL-CAP $600/min$5 (dichiarativo, resta STAT-MODE): modeled Sh {sc['sharpe_modeled']:.2f}"
|
||||
f" -> realistic {sc['sharpe_realistic']:.2f} (haircut {sc['haircut']:+.2f});"
|
||||
f" fill eseguiti {sc['n_executed']}/{sc['n_modeled']}"
|
||||
f" turn/y {sc['turnover_real']:.0f} vs {sc['turnover_modeled']:.0f}")
|
||||
|
||||
# --- confronto PORTAFOGLIO -----------------------------------------------------------
|
||||
print("\n PORTAFOGLIO (TP01+XS01+VRP01+SKH01, pesi canonici):")
|
||||
_, (bf, bh) = _base()
|
||||
print(f" BASE 4-sleeve FULL Sh {bf['sharpe']:.2f} DD {bf['maxdd']*100:.1f}%"
|
||||
f" | HOLD Sh {bh['sharpe']:.2f} DD {bh['maxdd']*100:.1f}%")
|
||||
sf, sh_ = substitute_xs01(daily)
|
||||
sub_full_d, sub_hold_d = sf["sharpe"] - bf["sharpe"], sh_["sharpe"] - bh["sharpe"]
|
||||
print(f" SOSTITUZIONE XS01 -> cand FULL Sh {sf['sharpe']:.2f} ({sub_full_d:+.2f}) DD {sf['maxdd']*100:.1f}%"
|
||||
f" | HOLD Sh {sh_['sharpe']:.2f} ({sub_hold_d:+.2f}) DD {sh_['maxdd']*100:.1f}%")
|
||||
up_best = (-9.0, -9.0)
|
||||
for fr in (0.10, 0.15):
|
||||
cf, ch, wgt = add_uplift(daily, fr)
|
||||
d_f, d_h = cf["sharpe"] - bf["sharpe"], ch["sharpe"] - bh["sharpe"]
|
||||
if d_h > up_best[1]:
|
||||
up_best = (d_f, d_h)
|
||||
print(f" AGGIUNTA 5o sleeve @{wgt*100:>4.1f}% FULL Sh {cf['sharpe']:.2f} ({d_f:+.2f}) DD {cf['maxdd']*100:.1f}%"
|
||||
f" | HOLD Sh {ch['sharpe']:.2f} ({d_h:+.2f}) DD {ch['maxdd']*100:.1f}%")
|
||||
mb = marginal_vs_book(daily)
|
||||
print(f" MARGINAL vs BOOK: corr {mb['corr_book']:+.2f} | uplift@10% full {mb['uplift_full']:+.3f}"
|
||||
f" hold {mb['uplift_hold']:+.3f} | multi-cut {mb['multicut']}")
|
||||
|
||||
# --- VERDETTO -------------------------------------------------------------------------
|
||||
print("\n" + "=" * 104)
|
||||
beats_xs_standalone = (pick["full"] > xs_f["sharpe"] and pick["hold"] > xs_h["sharpe"])
|
||||
dominates = sub_full_d > 0.02 and sub_hold_d > 0.05 and beats_xs_standalone
|
||||
diversifies = (abs(pick["corrXS"]) < 0.6 and abs(pick["corrTP"]) < 0.5
|
||||
and up_best[1] > 0.05 and mb["uplift_hold"] is not None and mb["uplift_hold"] > 0
|
||||
and all(u > 0 for u in mb["multicut"].values()))
|
||||
dsr_ok = np.isfinite(dsr) and dsr >= 0.95
|
||||
if not caus["ok"]:
|
||||
verdict, why = "SCARTATO", "prefix-check di causalita' fallito"
|
||||
elif pick["full"] <= 0.3 or pick["hold"] <= 0:
|
||||
verdict, why = "SCARTATO", f"standalone debole (FULL {pick['full']:+.2f}, HOLD {pick['hold']:+.2f})"
|
||||
elif abs(pick["corrXS"]) > 0.6 and not dominates:
|
||||
verdict, why = "REDUNDANT", f"corrXS {pick['corrXS']:+.2f}>0.6 e non batte XS01 (sub HOLD {sub_hold_d:+.2f})"
|
||||
elif dominates and dsr_ok:
|
||||
verdict, why = "CANDIDATO-SLEEVE (sostituto)", "batte XS01 standalone E nel book, DSR>=0.95"
|
||||
elif diversifies and dsr_ok:
|
||||
verdict, why = "CANDIDATO-SLEEVE (5o)", "scorrelato, uplift book persistente, DSR>=0.95"
|
||||
elif dominates or diversifies:
|
||||
verdict, why = "LEAD-forward", f"profilo utile ma DSR {dsr:.2f}<0.95 (storia ~2.5a, multiple-testing)"
|
||||
else:
|
||||
verdict, why = "SCARTATO", (f"ne' sostituto (sub HOLD {sub_hold_d:+.2f}) ne' additivo"
|
||||
f" (uplift HOLD {up_best[1]:+.2f}, corrXS {pick['corrXS']:+.2f})")
|
||||
print(f" VERDETTO: {verdict} — {why}")
|
||||
print(" CAVEAT immutabili: storia HL ~2.5 anni; in-sample = solo 2024 (selezione su finestra corta);")
|
||||
print(" book L/S multi-gamba -> STAT-MODE a $600 (come XS01), mai deploy a questo capitale.")
|
||||
print("=" * 104)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,295 @@
|
||||
"""r0701_xs_seasonal — STAGIONALITÀ CROSS-SECTIONAL sull'universo Hyperliquid (2026-07-01).
|
||||
|
||||
DOMANDA: esistono effetti calendario RELATIVI tra i 19 alt major HL (weekday tilt,
|
||||
turn-of-month, pattern weekend->lunedì nel cross-section)? Essendo long/short
|
||||
market-neutral (demeaned cross-section), il "buy&hold travestito" — che ha ucciso la
|
||||
seasonality trackF su BTC/ETH — è strutturalmente escluso.
|
||||
|
||||
METODO (ordine obbligatorio, dal mandato):
|
||||
1. TEST STATISTICO PRIMA DELLA STRATEGIA — persistenza split-half: per ogni giorno
|
||||
della settimana, il tilt cross-sectional per-asset (media del ritorno relativo
|
||||
demeaned in quel giorno, al netto del tilt incondizionato dell'asset) della PRIMA
|
||||
metà del campione correla (Spearman rank) con quello della SECONDA metà?
|
||||
Null: permutazione delle etichette-giorno (2000 draw) entro ciascuna metà →
|
||||
distribuzione del max-su-7 rank-corr. Se il max reale non batte il 95° pctl del
|
||||
null → SCARTATO senza backtest. Idem per weekend-bucket e turn-of-month.
|
||||
La permutazione controlla automaticamente il confound "alpha persistente
|
||||
dell'asset su entrambe le metà" (momentum), perché anche le etichette permutate
|
||||
lo mostrerebbero.
|
||||
2. (solo se persiste) strategia L/S market-neutral vol-target, fee 0.10% RT,
|
||||
breakeven fee, selezione IN-SAMPLE (pre-2025), hold-out, deflated_sharpe.
|
||||
3. day_boundary_robust OBBLIGATORIO per ogni effetto calendario — NB: i dati HL
|
||||
locali sono SOLO 1d → il confine giorno NON è ri-tagliabile localmente sui 19
|
||||
alt. Qualunque lead weekday su HL 1d resta NON-VERIFICABILE al boundary-shift
|
||||
finché non esistono barre orarie HL: il verdetto massimo possibile qui è
|
||||
LEAD-forward *condizionato*, mai sleeve. (Su BTC/ETH 1h il test esiste in
|
||||
altlib, ma il cross-section a 2 asset non riproduce l'effetto a 19.)
|
||||
|
||||
DATI: data/raw/hl_*_1d.parquet — 19 major XS01, 913 giorni (2024-01-01 → 2026-07-01),
|
||||
0 barre vol=0 (verificato). LIMITE DICHIARATO: ~2.5 anni → ~130 osservazioni per
|
||||
weekday, ~65 per metà → alto rischio rumore; soglie severe (p<0.05 sul max-statistic
|
||||
permutato, non per-weekday).
|
||||
|
||||
Esecuzione: cd /opt/docker/PythagorasGoal && uv run python scripts/research/r0701_xs_seasonal.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(_ROOT / "scripts" / "research" / "alt"))
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
|
||||
import altlib as al # noqa: E402 (HOLDOUT, deflated_sharpe — riuso convenzioni)
|
||||
|
||||
RNG = np.random.default_rng(20260701)
|
||||
N_PERM = 2000
|
||||
UNIVERSE = ["BTC", "ETH", "SOL", "BNB", "XRP", "DOGE", "AVAX", "LINK", "LTC", "ADA",
|
||||
"ARB", "OP", "SUI", "APT", "INJ", "TIA", "SEI", "NEAR", "AAVE"]
|
||||
FEE_SIDE = 0.0005 # 0.10% RT
|
||||
HOLDOUT = al.HOLDOUT # 2025-01-01 UTC (convenzione di progetto)
|
||||
WD_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- dati
|
||||
def load_relative_returns():
|
||||
"""Matrice (date × asset) dei ritorni GIORNALIERI RELATIVI (demeaned cross-section).
|
||||
Esclude barre vol=0 (→ NaN). x[t,a] = r[t,a] − mean_a r[t,a] → market-neutral."""
|
||||
cols = {}
|
||||
for s in UNIVERSE:
|
||||
d = pd.read_parquet(_ROOT / "data" / "raw" / f"hl_{s.lower()}_1d.parquet")
|
||||
idx = pd.to_datetime(d["timestamp"], unit="ms", utc=True)
|
||||
c = pd.Series(d["close"].values.astype(float), index=idx)
|
||||
c[d["volume"].values <= 0] = np.nan # guardrail backfill sintetico
|
||||
cols[s] = c
|
||||
C = pd.concat(cols, axis=1).sort_index()
|
||||
R = C.pct_change()
|
||||
R = R.iloc[1:] # prima riga NaN
|
||||
X = R.sub(R.mean(axis=1), axis=0) # demean cross-section per data
|
||||
return R, X
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- statistica
|
||||
def _rank(v):
|
||||
return pd.Series(v).rank().values
|
||||
|
||||
|
||||
def spearman(a, b):
|
||||
ra, rb = _rank(a), _rank(b)
|
||||
if np.std(ra) == 0 or np.std(rb) == 0:
|
||||
return 0.0
|
||||
return float(np.corrcoef(ra, rb)[0, 1])
|
||||
|
||||
|
||||
def bucket_tilts(X: pd.DataFrame, labels: np.ndarray, n_buckets: int) -> np.ndarray:
|
||||
"""tilt[b, a] = media di x nei giorni con label==b, MENO il tilt incondizionato
|
||||
dell'asset (isola l'effetto calendario dal drift relativo generico)."""
|
||||
base = np.nanmean(X.values, axis=0)
|
||||
out = np.full((n_buckets, X.shape[1]), np.nan)
|
||||
for b in range(n_buckets):
|
||||
m = labels == b
|
||||
if m.sum() >= 10:
|
||||
out[b] = np.nanmean(X.values[m], axis=0) - base
|
||||
return out
|
||||
|
||||
|
||||
def split_half_persistence(X: pd.DataFrame, labels: np.ndarray, n_buckets: int,
|
||||
n_perm: int = N_PERM):
|
||||
"""Rank-corr H1 vs H2 dei tilt per bucket + null permutando le etichette entro
|
||||
ciascuna metà. Ritorna (rho per bucket, max reale, p-value del max, null 95° pctl)."""
|
||||
half = len(X) // 2
|
||||
X1, X2 = X.iloc[:half], X.iloc[half:]
|
||||
l1, l2 = labels[:half], labels[half:]
|
||||
|
||||
def rhos(la, lb):
|
||||
t1, t2 = bucket_tilts(X1, la, n_buckets), bucket_tilts(X2, lb, n_buckets)
|
||||
return np.array([spearman(t1[b], t2[b])
|
||||
if np.isfinite(t1[b]).all() and np.isfinite(t2[b]).all() else np.nan
|
||||
for b in range(n_buckets)])
|
||||
|
||||
real = rhos(l1, l2)
|
||||
real_max = float(np.nanmax(real))
|
||||
null_max = np.empty(n_perm)
|
||||
for i in range(n_perm):
|
||||
null_max[i] = np.nanmax(rhos(RNG.permutation(l1), RNG.permutation(l2)))
|
||||
pval = float(np.mean(null_max >= real_max))
|
||||
return real, real_max, pval, float(np.percentile(null_max, 95))
|
||||
|
||||
|
||||
def weekend_monday_ic(X: pd.DataFrame):
|
||||
"""Pattern pre/post weekend: il ritorno relativo cumulato Sab+Dom predice
|
||||
(cross-sectionalmente) il ritorno relativo del lunedì? IC = Spearman giornaliero;
|
||||
riporta mean IC e t-stat su ciascuna metà (persistenza del segno)."""
|
||||
wd = X.index.dayofweek.values
|
||||
ics, dates = [], []
|
||||
for i in np.where(wd == 0)[0]: # lunedì
|
||||
if i < 2 or wd[i - 1] != 6 or wd[i - 2] != 5:
|
||||
continue
|
||||
wkend = np.nansum(X.values[i - 2:i], axis=0) # Sab+Dom relativo
|
||||
mon = X.values[i]
|
||||
ok = np.isfinite(wkend) & np.isfinite(mon)
|
||||
if ok.sum() >= 10:
|
||||
ics.append(spearman(wkend[ok], mon[ok])); dates.append(X.index[i])
|
||||
s = pd.Series(ics, index=pd.DatetimeIndex(dates))
|
||||
half = len(s) // 2
|
||||
out = {}
|
||||
for name, seg in (("H1", s.iloc[:half]), ("H2", s.iloc[half:]), ("FULL", s)):
|
||||
t = float(seg.mean() / seg.std() * np.sqrt(len(seg))) if len(seg) > 3 and seg.std() > 0 else 0.0
|
||||
out[name] = dict(mean_ic=round(float(seg.mean()), 4), t=round(t, 2), n=len(seg))
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- strategia (solo se persiste)
|
||||
def sharpe(r):
|
||||
r = np.asarray(pd.Series(r).dropna().values, float)
|
||||
return float(np.mean(r) / np.std(r) * np.sqrt(365.25)) if len(r) > 2 and np.std(r) > 0 else 0.0
|
||||
|
||||
|
||||
def weekday_ls(R: pd.DataFrame, X: pd.DataFrame, k: int = 3, est_win: int = 0,
|
||||
min_obs: int = 20, target_vol: float = 0.20):
|
||||
"""L/S market-neutral: al close di t (dati ≤ t) stima il tilt per-asset del weekday
|
||||
di DOMANI (expanding se est_win=0, altrimenti rolling est_win gg) e va long top-k /
|
||||
short bottom-k per il giorno t+1. Fee su |Δw|. Ritorna (net Series, gross Series,
|
||||
turnover medio/anno, breakeven fee %RT)."""
|
||||
wd = X.index.dayofweek.values
|
||||
xv = X.values
|
||||
n, A = xv.shape
|
||||
W = np.zeros((n, A))
|
||||
# tilt causale: per ogni weekday d, media (expanding o rolling) dei soli giorni con wd==d fino a t
|
||||
sums = np.zeros((7, A)); cnts = np.zeros((7, A))
|
||||
hist: list[list[np.ndarray]] = [[] for _ in range(7)] # per rolling
|
||||
for t in range(n - 1):
|
||||
row = np.nan_to_num(xv[t], nan=0.0)
|
||||
fin = np.isfinite(xv[t]).astype(float)
|
||||
d = wd[t]
|
||||
sums[d] += row; cnts[d] += fin
|
||||
if est_win > 0:
|
||||
hist[d].append(np.where(fin > 0, row, np.nan))
|
||||
if len(hist[d]) > est_win:
|
||||
hist[d].pop(0)
|
||||
dn = wd[t + 1] # weekday di domani: noto (calendario)
|
||||
if est_win > 0:
|
||||
hh = np.array(hist[dn]) if hist[dn] else np.empty((0, A))
|
||||
cnt = np.isfinite(hh).sum(axis=0) if len(hh) else np.zeros(A)
|
||||
tilt = np.where(cnt >= min_obs, np.nanmean(hh, axis=0) if len(hh) else 0.0, np.nan)
|
||||
else:
|
||||
tilt = np.where(cnts[dn] >= min_obs, sums[dn] / np.maximum(cnts[dn], 1), np.nan)
|
||||
ok = np.isfinite(tilt)
|
||||
if ok.sum() >= 2 * k:
|
||||
order = np.argsort(np.where(ok, tilt, -np.inf))
|
||||
w = np.zeros(A)
|
||||
w[order[-k:]] = 0.5 / k # long tilt positivi
|
||||
lo = np.argsort(np.where(ok, tilt, np.inf))[:k]
|
||||
w[lo] = -0.5 / k # short tilt negativi
|
||||
W[t] = w
|
||||
dret = np.nan_to_num(R.values, nan=0.0)
|
||||
gross = np.zeros(n); gross[1:] = np.sum(W[:-1] * dret[1:], axis=1)
|
||||
turn = np.abs(np.diff(W, axis=0, prepend=np.zeros((1, A)))).sum(axis=1)
|
||||
net = gross - FEE_SIDE * turn
|
||||
g, tn = pd.Series(gross, index=X.index), pd.Series(net, index=X.index)
|
||||
rv = tn.rolling(30, min_periods=15).std().shift(1) * np.sqrt(365.25)
|
||||
scale = np.clip(np.nan_to_num(target_vol / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0)
|
||||
net_vt = pd.Series(tn.values * scale, index=X.index)
|
||||
mean_turn = float(turn.mean())
|
||||
be_rt = 2 * float(gross.mean() / mean_turn) * 100 if mean_turn > 0 else np.nan # %RT a Sharpe 0
|
||||
turn_yr = round(mean_turn * 365.25, 1)
|
||||
return net_vt, g, turn_yr, be_rt
|
||||
|
||||
|
||||
def run_strategy_branch(R, X):
|
||||
"""Selezione IN-SAMPLE (pre-2025) su griglia piccola, hold-out, DSR su tutti i trial."""
|
||||
grid = [dict(k=k, est_win=w) for k in (3, 5) for w in (0, 180)]
|
||||
rows, all_full = [], []
|
||||
for g in grid:
|
||||
net, _, turn_yr, be = weekday_ls(R, X, **g)
|
||||
ins, hold = net[net.index < HOLDOUT], net[net.index >= HOLDOUT]
|
||||
rows.append(dict(params=g, ins=round(sharpe(ins), 2), hold=round(sharpe(hold), 2),
|
||||
full=round(sharpe(net), 2), turn_yr=turn_yr,
|
||||
be_rt=round(be, 3) if np.isfinite(be) else None, net=net))
|
||||
all_full.append(sharpe(net))
|
||||
chosen = max(rows, key=lambda r: r["ins"]) # selezione SOLO in-sample
|
||||
dsr, sr0 = al.deflated_sharpe(chosen["full"], all_full, chosen["net"].dropna().values)
|
||||
return rows, chosen, dsr, sr0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- main
|
||||
def main():
|
||||
R, X = load_relative_returns()
|
||||
print(f"Universo: {len(UNIVERSE)} major HL | {X.index[0].date()} -> {X.index[-1].date()} "
|
||||
f"({len(X)} giorni, ~{len(X) / 365.25:.1f} anni) | barre vol=0 escluse: "
|
||||
f"{int((~np.isfinite(X.values)).sum())} celle NaN")
|
||||
print(f"LIMITE: ~{len(X) // 7 // 2} osservazioni per weekday per metà campione — "
|
||||
f"soglia severa: max-statistic permutato, p<0.05\n")
|
||||
|
||||
wd = X.index.dayofweek.values
|
||||
print("=" * 78)
|
||||
print("STEP 1 — PERSISTENZA SPLIT-HALF (test statistico PRIMA della strategia)")
|
||||
print("=" * 78)
|
||||
|
||||
# --- (a) weekday (7 bucket)
|
||||
rho, mx, p, null95 = split_half_persistence(X, wd, 7)
|
||||
print("\n[A] WEEKDAY TILT (tilt weekday-specifico, al netto del tilt incondizionato)")
|
||||
for d in range(7):
|
||||
print(f" {WD_NAMES[d]}: rank-corr H1 vs H2 = {rho[d]:+.3f}")
|
||||
print(f" max reale = {mx:+.3f} | null 95° pctl (perm max-su-7) = {null95:+.3f} | p = {p:.3f}")
|
||||
pass_wd = p < 0.05
|
||||
|
||||
# --- (b) weekend vs feriali (2 bucket)
|
||||
wk_lab = (wd >= 5).astype(int)
|
||||
rho_w, mx_w, p_w, null95_w = split_half_persistence(X, wk_lab, 2)
|
||||
print(f"\n[B] WEEKEND-vs-FERIALI: rho weekday={rho_w[0]:+.3f} weekend={rho_w[1]:+.3f} "
|
||||
f"| max={mx_w:+.3f} null95={null95_w:+.3f} p={p_w:.3f}")
|
||||
pass_we = p_w < 0.05
|
||||
|
||||
# --- (c) turn-of-month (ultimi 2 + primi 2 gg del mese vs resto)
|
||||
day = X.index.day.values
|
||||
dim = X.index.days_in_month.values
|
||||
tom_lab = ((day <= 2) | (day >= dim - 1)).astype(int)
|
||||
rho_t, mx_t, p_t, null95_t = split_half_persistence(X, tom_lab, 2)
|
||||
print(f"[C] TURN-OF-MONTH (±2gg): rho non-TOM={rho_t[0]:+.3f} TOM={rho_t[1]:+.3f} "
|
||||
f"| max={mx_t:+.3f} null95={null95_t:+.3f} p={p_t:.3f}")
|
||||
pass_tom = p_t < 0.05
|
||||
|
||||
# --- (d) pattern weekend->lunedì (IC cross-serial)
|
||||
ic = weekend_monday_ic(X)
|
||||
print(f"[D] WEEKEND->LUNEDÌ IC (Spearman x_weekend vs x_lunedì): "
|
||||
f"H1 {ic['H1']['mean_ic']:+.3f} (t={ic['H1']['t']}) | "
|
||||
f"H2 {ic['H2']['mean_ic']:+.3f} (t={ic['H2']['t']}) | "
|
||||
f"FULL {ic['FULL']['mean_ic']:+.3f} (t={ic['FULL']['t']}, n={ic['FULL']['n']})")
|
||||
pass_ic = (abs(ic["FULL"]["t"]) > 2.5
|
||||
and np.sign(ic["H1"]["mean_ic"]) == np.sign(ic["H2"]["mean_ic"])
|
||||
and abs(ic["H1"]["t"]) > 1.5 and abs(ic["H2"]["t"]) > 1.5)
|
||||
print(f" persistenza segno + |t|>2.5 FULL + |t|>1.5 su entrambe le metà: {pass_ic}")
|
||||
|
||||
any_pass = pass_wd or pass_we or pass_tom or pass_ic
|
||||
print("\n" + "=" * 78)
|
||||
if not any_pass:
|
||||
print("VERDETTO: SCARTATO — nessuna persistenza cross-sectional calendario.")
|
||||
print("Nessun backtest eseguito (regola: test statistico prima della strategia).")
|
||||
print("NB: il day_boundary_robust resta comunque NON eseguibile sui 19 alt (dati")
|
||||
print("HL solo 1d) → anche un pass qui sarebbe stato al massimo LEAD condizionato.")
|
||||
print("=" * 78)
|
||||
return
|
||||
|
||||
# ---------------- branch strategia (si arriva qui solo con persistenza reale)
|
||||
print("STEP 2 — persistenza rilevata: strategia L/S weekday-tilt (selezione IN-SAMPLE)")
|
||||
print("=" * 78)
|
||||
rows, chosen, dsr, sr0 = run_strategy_branch(R, X)
|
||||
for r in rows:
|
||||
print(f" k={r['params']['k']} est={'exp' if r['params']['est_win'] == 0 else r['params']['est_win']}: "
|
||||
f"IS {r['ins']:+.2f} | HOLD {r['hold']:+.2f} | FULL {r['full']:+.2f} | "
|
||||
f"turn/yr {r['turn_yr']} | breakeven fee {r['be_rt']}%RT")
|
||||
print(f"\n CELLA IN-SAMPLE: {chosen['params']} -> FULL {chosen['full']:+.2f} "
|
||||
f"HOLD {chosen['hold']:+.2f} | DSR={dsr:.3f} (null max {sr0:.2f}) "
|
||||
f"{'PASS' if dsr >= 0.95 else 'FAIL'}")
|
||||
print("\n ⚠ day_boundary_robust NON eseguibile su HL 1d (serve orario) → qualunque")
|
||||
print(" esito qui è al massimo LEAD-forward CONDIZIONATO, mai sleeve.")
|
||||
print("=" * 78)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+90
-14
@@ -106,6 +106,94 @@ def rebalance_sim(daily_cols: dict[str, pd.Series], weights: dict,
|
||||
n_rebalances=n_rebal, period_days=period_days, cost_rate=cost_rate)
|
||||
|
||||
|
||||
def combine_outer(daily_cols: dict[str, pd.Series], weights: dict,
|
||||
lo=None, hi=None) -> pd.Series:
|
||||
"""Combina serie GIORNALIERE per peso con OUTER-join e rinormalizzazione per-giorno
|
||||
(stessa semantica di StrategyPortfolio.combined_daily, ma su pesi arbitrari —
|
||||
riusabile per studi di sensibilità/tilt senza istanziare il portafoglio)."""
|
||||
J = pd.concat(daily_cols, axis=1, join="outer").sort_index()
|
||||
wv = np.array([weights[c] for c in J.columns], float)
|
||||
active = J.notna().values * wv # peso solo dove c'e' dato
|
||||
rowsum = active.sum(axis=1, keepdims=True)
|
||||
wnorm = np.divide(active, rowsum, out=np.zeros_like(active), where=rowsum > 0)
|
||||
combo = pd.Series(np.nansum(np.nan_to_num(J.values) * wnorm, axis=1), index=J.index)
|
||||
combo = combo[J.notna().any(axis=1).values] # togli i giorni senza alcun dato
|
||||
if lo is not None:
|
||||
combo = combo[combo.index >= lo]
|
||||
if hi is not None:
|
||||
combo = combo[combo.index < hi]
|
||||
return combo
|
||||
|
||||
|
||||
def weights_tilt_null(daily_cols: dict[str, pd.Series], w_current: dict, w_proposed: dict,
|
||||
*, caps: dict | None = None, floor: float = 0.05, n: int = 500,
|
||||
seed: int = 20260701, holdout: pd.Timestamp = HOLDOUT,
|
||||
k_seen: int | None = None) -> dict:
|
||||
"""GATE per ogni proposta di CAMBIO PESI del portafoglio (lezione 2026-07-01, EW-STR refutato).
|
||||
|
||||
Un tilt che "batte i pesi correnti sull'hold-out" non è informativo di per sé: se metà dei
|
||||
tilt casuali dentro i vincoli batte CURRENT sull'hold-out, il claim è generico; e se il tilt
|
||||
proposto siede al percentile ~k/(k+1) fra i tilt casuali (k = configurazioni viste
|
||||
sull'hold-out durante la ricerca), l'uplift è indistinguibile dal *best-of-k scelto
|
||||
sull'hold-out* (selezione di 2° ordine). Vedi diario 2026-07-01-portfolio-weights-ddguard.md.
|
||||
|
||||
daily_cols: serie di rendimenti GIORNALIERI per sleeve (es. {s.name: s.daily()}).
|
||||
caps: peso massimo per sleeve (es. {"VRP01": 0.15, "XS01": 0.25}); floor: peso minimo comune.
|
||||
k_seen: quante configurazioni di pesi sono state guardate sull'hold-out durante la ricerca.
|
||||
|
||||
REGOLA (gate_pass): un cambio pesi si applica solo se
|
||||
(1) delta_insample >= 0 — non deve PERDERE risk-adjusted pre-holdout;
|
||||
(2) pctl_hold < 100*k/(k+1) (o < 80 se k_seen ignoto) — sotto la firma best-of-k.
|
||||
Il gate è NECESSARIO, non sufficiente: restano richiesti finestre OOS disgiunte e realismo
|
||||
(pesi effettivi post-rinormalizzazione, eseguibilità degli sleeve a peso aumentato)."""
|
||||
names = list(daily_cols)
|
||||
caps_v = np.array([(caps or {}).get(nm, 1.0) for nm in names], float)
|
||||
|
||||
def _sh(s: pd.Series, lo=None, hi=None) -> float:
|
||||
v = s
|
||||
if lo is not None:
|
||||
v = v[v.index >= lo]
|
||||
if hi is not None:
|
||||
v = v[v.index < hi]
|
||||
r = np.asarray(v.dropna().values, float)
|
||||
return float(r.mean() / r.std() * np.sqrt(DAYS_PER_YEAR)) if len(r) > 1 and r.std() > 0 else 0.0
|
||||
|
||||
def _wvec(wd: dict) -> np.ndarray:
|
||||
w = np.array([wd[nm] for nm in names], float)
|
||||
return w / w.sum()
|
||||
|
||||
# campiona n pesi casuali uniformi sul simplesso, dentro floor/caps (rejection sampling)
|
||||
rng = np.random.default_rng(seed)
|
||||
samples, tries = [], 0
|
||||
while len(samples) < n:
|
||||
batch = rng.dirichlet(np.ones(len(names)), size=max(4 * n, 256))
|
||||
ok = (batch >= floor).all(axis=1) & (batch <= caps_v).all(axis=1)
|
||||
samples.extend(batch[ok])
|
||||
tries += 1
|
||||
if tries > 200:
|
||||
raise RuntimeError("weights_tilt_null: vincoli floor/caps troppo stretti (acceptance ~0)")
|
||||
S = np.array(samples[:n])
|
||||
|
||||
sh_cur_hold = _sh(combine_outer(daily_cols, dict(zip(names, _wvec(w_current)))), lo=holdout)
|
||||
d_hold_rand = np.array([
|
||||
_sh(combine_outer(daily_cols, dict(zip(names, w))), lo=holdout) - sh_cur_hold for w in S])
|
||||
|
||||
wp = dict(zip(names, _wvec(w_proposed)))
|
||||
wc = dict(zip(names, _wvec(w_current)))
|
||||
d_hold = _sh(combine_outer(daily_cols, wp), lo=holdout) - sh_cur_hold
|
||||
d_full = _sh(combine_outer(daily_cols, wp)) - _sh(combine_outer(daily_cols, wc))
|
||||
d_is = _sh(combine_outer(daily_cols, wp, hi=holdout)) - _sh(combine_outer(daily_cols, wc, hi=holdout))
|
||||
|
||||
pctl_hold = float((d_hold_rand < d_hold).mean() * 100.0)
|
||||
bestofk = float(100.0 * k_seen / (k_seen + 1)) if k_seen else None
|
||||
gate_pass = bool(d_is >= 0.0 and pctl_hold < (bestofk if bestofk is not None else 80.0))
|
||||
return dict(delta_hold=round(d_hold, 4), delta_full=round(d_full, 4),
|
||||
delta_insample=round(d_is, 4),
|
||||
frac_random_beat_hold=round(float((d_hold_rand > 0).mean()), 3),
|
||||
pctl_hold=round(pctl_hold, 1), bestofk_pctl=bestofk,
|
||||
gate_pass=gate_pass, n_samples=int(len(S)), samples=S)
|
||||
|
||||
|
||||
class StrategyPortfolio:
|
||||
def __init__(self, sleeves: list[Sleeve], capital: float = 2000.0):
|
||||
if not sleeves:
|
||||
@@ -124,20 +212,8 @@ class StrategyPortfolio:
|
||||
(es. TP01 dal 2019, uno nuovo dal 2024) -> ogni giorno i pesi sono RINORMALIZZATI
|
||||
fra i soli sleeve con dato disponibile (uno sleeve "si attiva" quando parte la sua
|
||||
storia). Cosi' non si tronca il portafoglio alla finestra comune."""
|
||||
w = self.weights()
|
||||
cols = {s.name: s.daily() for s in self.sleeves}
|
||||
J = pd.concat(cols, axis=1, join="outer").sort_index()
|
||||
wv = np.array([w[c] for c in J.columns], float)
|
||||
active = J.notna().values * wv # peso solo dove c'e' dato
|
||||
rowsum = active.sum(axis=1, keepdims=True)
|
||||
wnorm = np.divide(active, rowsum, out=np.zeros_like(active), where=rowsum > 0)
|
||||
combo = pd.Series(np.nansum(np.nan_to_num(J.values) * wnorm, axis=1), index=J.index)
|
||||
combo = combo[J.notna().any(axis=1).values] # togli i giorni senza alcun dato
|
||||
if lo is not None:
|
||||
combo = combo[combo.index >= lo]
|
||||
if hi is not None:
|
||||
combo = combo[combo.index < hi]
|
||||
return combo
|
||||
return combine_outer({s.name: s.daily() for s in self.sleeves}, self.weights(),
|
||||
lo=lo, hi=hi)
|
||||
|
||||
def backtest(self) -> dict:
|
||||
full = self.combined_daily()
|
||||
|
||||
@@ -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"])
|
||||
Reference in New Issue
Block a user