From 05816c49f9ef16f27c146ceeae142906c04b37e9 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Tue, 1 Sep 2026 17:30:18 +0000 Subject: [PATCH] =?UTF-8?q?COLLAR01=20(=C2=A771):=20il=20pavimento=20funzi?= =?UTF-8?q?ona,=20il=20tetto=20lo=20paga=20troppo=20=E2=80=94=20e=20cio'?= =?UTF-8?q?=20che=20vince=20e'=20VRP01?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chiesto dall'operatore in quattro battute: hold BTC long o short coperto in opzioni, scadenza <=15gg, "ridurre la vincita ma bloccare la perdita" (=> collar, non put protettiva), entrata gated da indicatori ("forte bull"), e uscita dalle opzioni fra il 50% e il 75% del tempo. PERCHE' SI POTEVA RIAPRIRE DOPO §46. §46 (tail-hedge) fu refutato sul beta +0,076 del libro — "non si assicura un libro che nei crash e' gia' quasi piatto". Qui il sottostante e' un hold di BTC, beta 1,0: quel motivo non si applica. E §46 dichiarava non provata proprio la copertura gated su regime. L'entrata non aggiunge un solo parametro: tsmom_blend media tre np.sign() su (30,90,180) => valori in {-1,-1/3,+1/3,+1}, quindi "forte bull" = |blend|==1 (52,5% dei giorni) contro il confronto dichiarato |blend|>=1/3 (97,1%). RISULTATI (lente lunga 2021-03 -> 2026-09, 5,44 anni; griglia 48 celle dichiarata prima + 36 di estensione dichiarata): - A1 CONFERMATA: il pavimento FUNZIONA, maxDD scende in 36/48 (§46: saliva in 162/162). La meta' della domanda ha risposta positiva. - A3 CONFERMATA: il de-levering lo fa meglio in 45/48. Δdrift/ΔmaxDD 7,90 (gate forte) / 1,98 (largo): 2-8 punti di drift per punto di DD. - C9 in forma pura: il tetto taglia il 46,2% dei cicli VINCENTI, il pavimento para il 6,7% dei PERDENTI — 7x piu' spesso sui vincenti: troncatura. - M1: collar Sharpe 0,508 vs TP01 0,852; TP01+10% => +0,000 di Sharpe e +2,32pp di maxDD. IL FATTO CHE VALE PIU' DEL VERDETTO. Le 3 celle vincenti stavano tutte sul BORDO; estesa la famiglia vince 35/36 nell'ANGOLO (dput 0,02 / dcall 0,50, Sharpe 1,471) — e il limite di quell'angolo e' una COVERED CALL: la pendenza porta fuori dalla domanda posta e dentro lo short-vol. E quel 1,471 e' il prezzatore che si paga da solo: DVOL/RV-forward 1,320 a 7g (sopra nel 76,9% dei giorni) => riprezzato alla vol vera l'angolo cade a 0,511, che e' VRP01 (0,47). Non una scoperta: VRP01 per una strada piu' lunga. §3 lo blocca. USCITA ANTICIPATA: implementata (exit_frac) e COSTA. Cella onesta gate forte: drift +9,48% (scadenza) -> +3,84% (50%), esito da VINCE a perde sotto 0,75. Il meccanismo previsto c'e' (VRP residuo +3,38 -> +1,27pp) ma lo spread lo travolge. Corregge l'applicazione di §46: "un roll anticipato non paga f" vale per una copertura solo LONG; in un collar la gamba venduta va RICOMPRATA, quindi si paga f sulla parte che a scadenza si regolava gratis. L'asimmetria si INVERTE quando la struttura ha una gamba corta. CONTROLLI DELL'APPARATO 3/3 (M15): pranzo gratis riconosciuto (maxDD 51,83%->36,76%, drift +10,04%->+35,69%), premio x10 rifiutato, zero-cost finito. Cinque difetti miei catturati dai controlli, non a occhio: bisezione zero-cost invertita (dava Sharpe -3,9), dcall=NaN nella cassa, C9 non consapevole della direzione (S1>S0 non e' "vincente" per uno short), e due di contabilita' che avrebbero ADULATO il collar (base che rollava lo spot pagando ~3,6%/a di fee inesistenti; roll che chiudeva lo spot senza motivo). Corregge anche un muro di §46: il tick da 5 USDC e' della famiglia USDC; la catena che raccogliamo e' 100% inverse, quindi li' non si applica. Regole nuove in CLAUDE.md: M29 (un edge da opzioni prezzate a modello si riprezza alla vol REALIZZATA prima di crederci), M8 esteso (un argmax sul BORDO e' una pendenza, non una cella), C4 esteso (il segno dell'asimmetria di f dipende dal verso della gamba). Libro, pesi, cron, config INVARIATI. Nessun ordine. Suite: 807 passati. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 7 +- docs/diary/2026-09-01-collar-btc.md | 225 +++++++++ docs/memory/20-ondate-e-scartati.md | 51 ++ docs/research/RESULTS-0822.md | 88 ++++ scripts/research/r0901_btc_collar.py | 715 +++++++++++++++++++++++++++ tests/test_collar_apparato.py | 124 +++++ 6 files changed, 1207 insertions(+), 3 deletions(-) create mode 100644 docs/diary/2026-09-01-collar-btc.md create mode 100644 scripts/research/r0901_btc_collar.py create mode 100644 tests/test_collar_apparato.py diff --git a/CLAUDE.md b/CLAUDE.md index 2b8dfa9..cf6c921 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ chi la riapre deve battere il motivo, non ripetere la misura. | `docs/memory/40-produzione-e-deploy.md` | esecutore, tripwire, monitor, libro di bordo, PRIIPs/UCITS | tocchi cio' che gira con soldi veri | | `docs/memory/50-dati-e-feed.md` | difetti del dato trovati e riparati, catena opzioni | tocchi i feed | | `docs/memory/60-metodo-e-gate.md` | i gate codificati in `altlib.py` | valuti un candidato | -| `docs/research/RESULTS-0822.md` | registro per filone §1-70 | vuoi il dettaglio di un filone | +| `docs/research/RESULTS-0822.md` | registro per filone §1-71 | vuoi il dettaglio di un filone | | `docs/diary/` (123 voci) | la sessione originale, per data | vuoi il contesto completo | --- @@ -340,7 +340,7 @@ nei file di memoria. - **M5** **Null del de-levering (7 occorrenze):** ogni claim "meno drawdown" si testa a **iso-rischio** prima di crederci — e' il primo test, non l'ultimo. Se la variante e' una pura ri-scalatura il null e' **degenere** (`sh(k·base) ≡ sh(base)`): serve iso-peso sullo Sharpe. - **M6** Un diversificatore a **basso CAGR** si giudica a **iso-rischio**, mai a iso-nozionale. - **M7** Su offset/ancore appaiate la statistica e' la **mediana delle differenze**, non la differenza delle mediane. E **se si de-lucka una strategia va de-luckato anche il suo DEGRADO**. -- **M8** Un **argmax dentro un plateau non e' una decisione**; e la mediana da sola puo' nascondere il fatto decisivo (p90 su, p10 giu' = si compra dipendenza dall'ancora, non Sharpe). +- **M8** Un **argmax dentro un plateau non e' una decisione**; e la mediana da sola puo' nascondere il fatto decisivo (p90 su, p10 giu' = si compra dipendenza dall'ancora, non Sharpe). E un **argmax sul BORDO della griglia non e' una cella: e' una pendenza** — si segue fino al suo limite prima di leggerne il valore, perche' il limite puo' essere un oggetto diverso da quello che si stava studiando (COLLAR01: le 3 celle vincenti su 48 stavano tutte sul bordo, e il limite era una **covered call**, cioe' fuori dalla domanda posta). - **M9** Un contributo positivo si **scompone per anno** prima di crederci: "24/24 ancore positive" puo' essere **un anno solo**. - **M10** **Due punti non fanno una tendenza** nemmeno quando la meccanica sembra spiegarla. - **M11** Un **blocco dichiarato e' un'ipotesi, non un fatto**: si ri-verifica prima di costruirci sopra o di rimandare (3 occorrenze, ogni volta il blocco non esisteva). @@ -361,12 +361,13 @@ nei file di memoria. - **M26** **Non contare su una compensazione fra correzioni misurate separatamente** — la si misura, e in piu' di una **moneta** (un'interazione piccola cambia segno con la moneta). - **M27** Prima di dichiarare che un campo di un dataset e' sbagliato, **aprire il codice che lo produce**. E una **fonte normativa citata in un commento si verifica come un numero**. - **M28** Quando **due affermazioni della stessa memoria si contraddicono, la contraddizione e' informazione**: una delle due e' stata scritta guardando i dati. +- **M29** 🚨 Un edge da **opzioni prezzate a modello** si **riprezza alla volatilita' REALIZZATA** prima di crederci: se sparisce, non era la struttura — era il **premio di varianza**, cioe' VRP01, che il progetto ha gia'. Su BTC il **DVOL sta il 32% sopra la RV-forward** a 7 giorni (**77% dei giorni**), quindi *qualunque* struttura che vende vol **stampa per costruzione del prezzatore**: in COLLAR01 il **72-100%** del drift della cella migliore era questo, e l'angolo cadeva da Sharpe **1,471 a 0,511** — che e' il **0,47** gia' pubblicato di VRP01. Vale anche al contrario: un edge che *compra* vol a modello e' sottostimato dallo stesso fattore. ### Sui costi e l'eseguibilita' - **C1** Il costo di un venue va modellato nella sua **FORMA** (fisso vs proporzionale), non solo nel livello: un pavimento fisso e' una **tassa regressiva** e due venue a pari "costo medio" danno esiti **opposti** al variare del capitale. - **C2** Un parametro d'esecuzione espresso in **valuta assoluta** ha effetto che dipende dal capitale: il controllo non e' lo Sharpe ma la **quota di tempo a mercato**. - **C3** Prima di misurare il rendimento a un capitale dato, misurare il **lotto minimo del venue** — e sapere **quale famiglia di strumenti** si sta guardando (inverse vs USDC-lineare: lotti 7,5× diversi). -- **C4** Un **fattore di errore si giudica moltiplicato per il suo peso** (5,85 al 3% fa meno danno di 2,23 al 18%). Il `f` di una struttura multi-gamba **non e' il `f` di una sua gamba**. +- **C4** Un **fattore di errore si giudica moltiplicato per il suo peso** (5,85 al 3% fa meno danno di 2,23 al 18%). Il `f` di una struttura multi-gamba **non e' il `f` di una sua gamba**. E **il segno dell'asimmetria di `f` dipende dal VERSO della gamba**: *"un roll anticipato non paga `f`"* (§46) vale per una copertura **solo LONG** — se c'e' una **gamba VENDUTA da ricomprare**, uscire prima paga `f` *esattamente* sulla parte che a scadenza si regolava gratis, e piu' spesso. **L'asimmetria si INVERTE** (COLLAR01: uscire al 50% del tempo costa 5,6 punti di drift col gate forte, 10,2 col largo). - **C5** **L'open interest NON misura la negoziabilita'** (su Deribit USDC sono quasi anti-correlati). Prima di credere a un rapporto estremo su un prezzo piccolo, **contare i tick**. - **C6** La negoziabilita' **sul conto reale** va verificata quando lo sleeve entra in **RICERCA**, non quando entra nel book (PRIIPs costo': 5 settimane di misure). *"Non posso comprare" ≠ "non posso vedere"*: il segnale puo' girare su una serie che non si puo' negoziare. - **C7** Si cerca per **ISIN, non per ticker**; si prende la linea nella **valuta del proprio saldo**; un costo di conversione e' proprieta' della **coppia strumento-conto**, non dello strumento. diff --git a/docs/diary/2026-09-01-collar-btc.md b/docs/diary/2026-09-01-collar-btc.md new file mode 100644 index 0000000..4b9695c --- /dev/null +++ b/docs/diary/2026-09-01-collar-btc.md @@ -0,0 +1,225 @@ +# 2026-09-01 — COLLAR01: il pavimento funziona, il tetto lo paga troppo, e ciò che vince è VRP01 travestito + +*Scritto il 2026-09-01. Ogni numero è riprodotto da `scripts/research/r0901_btc_collar.py`. +**Libro, pesi, cron, config INVARIATI. Nessun ordine.*** + +## 0. La domanda, come è arrivata + +> *«crea una strategia di hold BTC in long o short con copertura con options (max 15gg)»* +> *«opzione deve essere al max di 15gg»* +> *«voglio ridurre la vincita, ma bloccare la perdita»* +> *«ovviamente l'entrata deve essere gestita da una strategia confortata da indicatori (es. è in forte bull)»* +> *«dalle opzioni dobbiamo uscire prima del termine (tra 50% e 75% del tempo)»* + +Le tre precisazioni cambiano l'oggetto, e vanno lette insieme: **non è una put protettiva, è un +COLLAR** (pavimento comprato, tetto venduto per finanziarlo), su un **hold gated da indicatori**, +con **scadenza ≤15 giorni**. + +## 1. Perché questo filone si poteva riaprire + +§46 (TAIL-HEDGE, 2026-08-23) è **REFUTATO** e la memoria dice di battere il motivo, non di +ripetere la misura. Il motivo di §46, verbatim: + +> *il maxDD **SALE in 162/162 celle**, a ogni lente di f, perché il **beta del libro al sottostante +> è +0,076**: non si assicura un libro che nei crash è già quasi piatto.* + +**Quel motivo non si applica qui.** §46 assicurava il *libro* (TP01+SKH01, piatto il 28% dei +giorni); qui il sottostante è un **hold di BTC, beta 1,0 per costruzione**. E §46 dichiara di non +aver provato proprio questo: *«Nessuna copertura dinamica (gated su regime) è stata provata: la +domanda era la statica»*. + +Riusati e non rimisurati: metriche e `k_for_same_dd` da `r0823_tail_hedge`, il listino fee opzioni +Deribit, la catena via `cblib.load_chain()`. + +## 2. L'entrata, presa dal progetto invece che inventata + +`trend_portfolio.tsmom_blend` media tre `np.sign()` sugli orizzonti (30, 90, 180), quindi assume +**solo** i valori {−1, −1/3, +1/3, +1} (il bucket 2/3 non esiste — errore già corretto in §57). +Questo dà a **"forte bull" una definizione che non aggiunge nemmeno un parametro nuovo**: + +- **gate FORTE** = `|blend| == 1`, tutti e tre gli orizzonti concordi → a mercato **52,5%** dei giorni +- **gate LARGO** = `|blend| ≥ 1/3`, confronto dichiarato → a mercato **97,1%** dei giorni + +Il segno dà la direzione, quindi **long e short** sono entrambi coperti come chiesto. Si entra il +giorno **dopo** il segnale (eseguibile, §8.1). + +## 3. La calibrazione: quello che il modello non poteva assumere + +161.964 quote a due lati su 95 giorni (2026-05-07 → 2026-09-01), scadenze 1-15 giorni. + +**Lo skew, ed è il fatto strutturale del filone:** + +| \|δ\| | IV put / ATM | IV call / ATM | la put costa | +|---|---|---|---| +| 0,10 | 1,249 | 0,959 | **+30%** della call | +| 0,20 | 1,132 | 0,950 | **+19%** | +| 0,30 | 1,072 | 0,960 | **+12%** | + +**Un collar delta-simmetrico è un debito netto**: compro l'ala cara e vendo quella a buon mercato. +Non è un dettaglio di prezzo, è la ragione per cui "gratis" non esiste a delta simmetrici. +Spread (mezza forchetta / mid): put 1,9-8,3%, call 2,1-10,0% secondo il delta. Struttura a termine +IV/DVOL30 ≈ 0,92-0,94 a 2-14 giorni. + +📌 **A5 confermata, e corregge un muro di §46:** il tick da **5 USDC** che lì era «il secondo muro, +strutturale» vale per la famiglia **USDC**. La catena che raccogliamo è **100% inverse** +(`BTC-31JUL26-45000-P`), col tick in BTC. Il muro di §46 **non si applica a questo filone**. + +## 4. Il risultato, in ordine di come uccide + +Lente lunga 2021-03-24 → 2026-09-01 (1.988 giorni, 5,44 anni). BTC buy&hold nudo: Sharpe 0,413 · +maxDD 76,73% · drift +7,82%/a. Griglia dichiarata prima: 3 δput × 3 δcall × 2 tenor × 2 gate = **48 +celle**, più 12 varianti zero-cost dichiarate a parte. + +### ✅ A1 CONFERMATA — il pavimento funziona davvero (e §46 è battuto sul suo motivo) + +**maxDD scende in 36/48 celle.** Con beta 1,0 la put para davvero: è il risultato che §46 non +poteva ottenere. *Questa metà della domanda dell'operatore ha risposta positiva.* + +### ❌ A3 CONFERMATA — ma il de-levering lo fa meglio in 45/48 celle + +| gate | base gated senza opzioni | | +|---|---|---| +| FORTE | Sharpe 0,442 · maxDD **51,83%** · drift **+10,04%**/a | ← il null da battere | +| LARGO | Sharpe 0,578 · maxDD **71,94%** · drift **+17,37%**/a | | + +Il null è **lo stesso hold gated, senza opzioni, scalato a iso-maxDD** — generato dallo stesso +motore, non ridichiarato (P1). Il collar lo batte in **3 celle su 48**. + +### ❌ A2 CONFERMATA — ogni punto di DD risparmiato costa 2-8 punti di drift + +Δdrift/ΔmaxDD mediano: **7,90** col gate FORTE, **1,98** col LARGO. Il tetto costa più di quanto +il pavimento renda, e non di poco. + +### 🚨 C9 — non è protezione, è troncatura + +Cella migliore, 196 cicli: **il tetto taglia nel 46,2% dei cicli vincenti, il pavimento para nel +6,7% dei cicli perdenti.** Scatta **7 volte più spesso sui vincenti che sui perdenti** — la +definizione letterale di C9. *La regola dell'operatore («ridurre la vincita, bloccare la perdita») +è implementata fedelmente: il problema è che su BTC quel baratto è pagato male.* + +### ❌ M1 — dentro il libro non aggiunge nulla + +Collar Sharpe 0,508 contro TP01 **0,852**, corr +0,486. TP01 + 10% di collar: Sharpe **+0,000** e +maxDD **+2,32pp**. A 25%: **−0,065** di Sharpe e **+6,57pp** di maxDD. *Peggiora proprio la cosa +che dovrebbe proteggere.* + +## 5. 🚨 Il fatto che vale più del verdetto + +**Tutte e 3 le celle vincenti stanno sul BORDO** della griglia (δput al minimo, δcall al massimo). +M8: un argmax sul bordo non è una decisione. Ho esteso la famiglia (M4) con 36 trial dichiarati +verso l'angolo — e lì **vince in 35/36 celle**, con il massimo *nell'angolo*: + +> gate FORTE, 7 giorni, **δput 0,02 · δcall 0,50** → Sharpe **1,471** · maxDD **18,12%** · drift **+29,99%**/a + +Il limite di quell'angolo è *nessun pavimento, tetto ATM*: **una covered call**. Cioè **la pendenza +porta fuori da ciò che l'operatore ha chiesto e dentro lo short-vol.** + +E quel Sharpe 1,471 non è una scoperta — **è il mio prezzatore che si paga da solo**: + +| | DVOL / RV-forward | DVOL sta sopra | +|---|---|---| +| a 7 giorni | **1,320** | **76,9%** dei giorni | +| a 14 giorni | **1,253** | **74,8%** dei giorni | + +Riprezzando le opzioni alla **volatilità effettivamente realizzata** (diagnostica con look-ahead +dichiarato — è il valore equo ex-post, non una strategia): + +| cella | a DVOL | a vol realizzata | il VRP valeva | +|---|---|---|---| +| FORTE 7g δ0,02/0,50 | Sh **1,471** · +29,99% | Sh **0,511** · +8,45% | **+21,54 pp (72%)** | +| LARGO 7g δ0,02/0,50 | Sh **1,141** · +32,17% | Sh **0,114** · **−1,10%** | **+33,26 pp (tutto)** | +| FORTE 7g δ0,10/0,30 | VINCE | **perde** | +6,75 pp | +| LARGO 14g δ0,10/0,30 | perde | perde | +8,02 pp | + +**Il 72-100% dell'edge dell'angolo è il premio di varianza**, non la struttura. E ciò che +sopravvive alla riprezzatura — **Sharpe 0,511** — è, entro il rumore, **il numero che il progetto +ha già**: VRP01 a f=0,73 vale **Sharpe 0,47**. *Non ho trovato una strategia nuova: ho ri-scoperto +VRP01 per una strada più lunga.* E §3 lo blocca comunque: **«niente short-vol da modello in +deploy»** — questo è esattamente short-vol da modello. + +## 5-bis. L'uscita anticipata: chiesta, implementata, e costa + +L'operatore ha chiesto di **uscire dalle opzioni fra il 50% e il 75% del tempo**. Implementato come +`exit_frac`, su tenor 14 giorni (⇒ uscita a 7 / 9 / 10 giorni). **La mia ipotesi a priori era che +migliorasse**: uscire presto recupera valore temporale sulla put e rinuncia al theta più veloce +sulla call, cioè proprio alla parte che stampava il premio di varianza. **Metà giusta, metà no.** + +| cella `δ0,10/0,30`, gate FORTE | uscita | Sharpe | drift | null | esito | VRP residuo | +|---|---|---|---|---|---|---| +| a scadenza | 1,000 | 0,488 | **+9,48%** | +8,07% | VINCE | +3,38pp | +| 75% | 0,750 | 0,457 | +8,59% | +7,72% | VINCE | +4,93pp | +| 62,5% | 0,625 | 0,366 | +6,11% | +7,98% | **perde** | +2,51pp | +| 50% | 0,500 | 0,278 | **+3,84%** | +8,04% | **perde** | **+1,27pp** | + +**Il meccanismo previsto c'è** — il VRP residuo scende da +3,38 a +1,27pp, quindi uscire presto +*davvero* rinuncia allo short-vol — **ma lo spread lo travolge**: −5,6 punti di drift sul gate +FORTE, −10,2 sul LARGO (12,06% → 1,88%), e su `δ0,20/0,20` da −4,37% a −8,58%. + +🚨 **E qui §46 va corretto nella sua applicazione.** §46 misurò: *«f si paga solo sulla parte di +valore che converge a intrinseco, quindi un roll anticipato non lo paga»*. **Vero per una copertura +SOLO LONG.** In un collar c'è una **gamba venduta da ricomprare**: uscire prima paga f *esattamente* +sulla parte che a scadenza si sarebbe regolata gratis, e si fa ~2× più spesso per unità di tempo. +**L'asimmetria di §46 si inverte quando la struttura ha una gamba corta.** È il risultato +trasferibile di questa richiesta. + +**Nella banda chiesta (50-75%) il verdetto si spacca:** a **0,75** la cella onesta passa ancora il +null (di poco); a **0,625 e sotto** non lo passa più. Nessun `exit_frac` ribalta il verdetto del +filone — lo peggiora. + +## 6. I controlli dell'apparato (M15) — 3/3 + +§46 insegna che *«un controllo positivo rotto dichiara guasto l'apparato»*. Prima di credere a un +verdetto negativo ho verificato che l'apparato sappia riconoscere un successo: + +| controllo | atteso | misurato | | +|---|---|---|---| +| pavimento a **premio zero** (pranzo gratis) | deve VINCERE | maxDD 51,83%→**36,76%**, drift +10,04%→**+35,69%** | ✅ | +| premio **×10** | deve PERDERE | drift **−33,96%**/a | ✅ | +| zero-cost costruibile e finito | non NaN | drift −3,50%/a | ✅ | + +## 7. Tre difetti miei, catturati dai controlli e non a occhio + +1. **Bisezione dello zero-cost invertita** (`hi = md` dove serve `lo = md`): il premio cresce col + delta, quindi se incasso troppo poco il tetto va *avvicinato*. Dava Sharpe −3,0/−3,9 e drift + −53%/a — spazzatura che al primo giro avevo quasi pubblicato. +2. **`dcall=NaN`** nella variante zero-cost propagava NaN nella cassa allo smontaggio anticipato. +3. **C9 non consapevole della direzione:** usavo `S1 > S0` come proxy di "ciclo vincente", ma per + un ciclo **short** un prezzo che sale è una **perdita**. Era la causa del risultato impossibile + *«il pavimento para nello 0,0% dei cicli perdenti»* con una put a 10 delta. + +E due difetti di contabilità trovati prima di misurare, che avrebbero **adulato il collar**: +la base senza opzioni rollava lo spot ogni `tenor` giorni pagando ~3,6%/anno di fee inesistenti; +e al roll delle opzioni chiudevo e riaprivo anche lo spot, che non ha motivo di muoversi. + +## 8. Cosa NON ho misurato, dichiarato + +- **La lente reale (quote vere, 2026-05→09) non è stata girata come backtest.** 123 giorni = ~8 + cicli non sovrapposti: sotto-potenziata per costruzione, e su una finestra in cui BTC è salito da + ~64,7k a ~77,5k — **avversa a un collar per costruzione**. La catena è servita a **calibrare** + (skew, termine, spread), che è l'uso in cui 161.964 quote hanno potenza. +- **Nessun DSR, nessun `study_family_honest`.** Non servono: il filone cade al **primo** gate (M5), + e M2 si spende su ciò che il primo gate lascia in piedi. +- **A6 e A7 non verificate** (lente reale non girata; il verso short è dentro i gate ma non + separato). Restano previsioni non misurate, non risultati. +- **Il funding dei perp non è nel motore**, come in ogni backtest del progetto: −2,16%/a di drift. + Colpisce base e collar quasi allo stesso modo (stessa esposizione spot), quindi **non cambia il + segno del confronto**, ma abbassa entrambi. + +## 9. Verdetto + +> **`IL PAVIMENTO FUNZIONA — E' IL TETTO CHE NON SI PUO' PAGARE. E CIO' CHE VINCE SUL BORDO NON E' +> LA PROTEZIONE: E' IL PREMIO DI VARIANZA, CIOE' VRP01`** — **REFUTATO come chiesto.** + +Con una postilla che è il vero risultato trasferibile: **la domanda «bloccare la perdita» ha +risposta positiva sul maxDD (36/48) e negativa sul prezzo (45/48).** Su BTC il pavimento si compra +meglio **tenendo meno BTC** che comprando una put e vendendo una call — e il de-levering, a +differenza del collar, non tocca il rendimento nella coda destra dove BTC vive. + +## 10. Cosa lo riaprirebbe + +- Un **f di stress misurato su un crash catturato** — la stessa condizione che §3 pone allo + short-vol. Con quello, l'angolo covered-call diventa discutibile invece che escluso. +- Un sottostante **senza coda destra grassa**: il tetto costa perché BTC vive lì. Su un asset a + distribuzione più simmetrica il baratto cambia di segno, e il conto va rifatto. +- **Non** lo riapre un tenor diverso, un delta diverso o una griglia più fine: la pendenza è + monotona verso l'angolo short-vol, e l'angolo è già misurato. diff --git a/docs/memory/20-ondate-e-scartati.md b/docs/memory/20-ondate-e-scartati.md index 18845b2..13fd8f1 100644 --- a/docs/memory/20-ondate-e-scartati.md +++ b/docs/memory/20-ondate-e-scartati.md @@ -1452,3 +1452,54 @@ una maschera a caso della stessa frequenza**. dall'aria seria. Se ne è accorta solo la riga `giorni in cui il LIBRO e' flat: 0 = 0.0%`. **REGOLA: stampare sempre la CARDINALITÀ di una maschera prima di usarla** — un filtro vuoto non solleva, produce il caso degenere e lo veste da risultato. + +--- + +- **COLLAR01 "hold BTC gated dal trend + collar di opzioni <=15 giorni" — REFUTATO (2026-09-01)** — + `scripts/research/r0901_btc_collar.py`, diario `2026-09-01-collar-btc.md`, registro §71. + Chiesto dall'operatore in quattro battute: *hold BTC long o short coperto in opzioni* + *opzione + max 15gg* + ***"ridurre la vincita, ma bloccare la perdita"*** (⇒ collar, non put protettiva) + + *entrata gestita da indicatori (es. forte bull)*. + 🚨 **Si poteva riaprire dopo §46 perche' ne batte il MOTIVO, non perche' ne ripete la misura:** + §46 cadde sul **beta +0,076 del libro** (*"non si assicura un libro che nei crash e' gia' quasi + piatto"*), qui il sottostante e' un **hold di BTC, beta 1,0**. E §46 dichiarava non provata la + copertura **gated su regime**, che e' questa. + **L'entrata non ha aggiunto un solo parametro:** `tsmom_blend` media tre `np.sign()` su (30,90,180) + ⇒ valori in {−1,−1/3,+1/3,+1}, quindi *"forte bull"* = `|blend|==1` (52,5% dei giorni) contro il + confronto dichiarato `|blend|>=1/3` (97,1%); il segno da' long **e** short. + ✅ **A1 CONFERMATA — il pavimento FUNZIONA: maxDD scende in 36/48 celle** (§46: saliva in 162/162). + *La meta' della domanda dell'operatore ha risposta positiva.* + ❌ **Ma il de-levering lo fa meglio in 45/48**, e il baratto costa **2-8 punti di drift per punto + di DD** (Δdrift/ΔmaxDD **7,90** gate forte, **1,98** largo). + 🚨 **C9 in forma pura: il tetto taglia il 46,2% dei cicli VINCENTI, il pavimento para il 6,7% dei + PERDENTI** — 7x piu' spesso sui vincenti: *troncatura, non protezione*. + ❌ **M1:** collar Sharpe 0,508 vs TP01 **0,852**; TP01+10% ⇒ **+0,000** di Sharpe e **+2,32pp** di + maxDD — peggiora proprio cio' che dovrebbe proteggere. + 🚨 **IL RISULTATO TRASFERIBILE STA NELL'ESTENSIONE, NON NELLA GRIGLIA.** Le 3 celle vincenti + stavano tutte **sul bordo**; estesa la famiglia vince **35/36** con il massimo **nell'angolo** + (δput 0,02 / δcall 0,50, Sharpe **1,471**) — e il limite di quell'angolo e' *nessun pavimento, + tetto ATM* = **una covered call**: **la pendenza porta fuori dalla domanda posta e dentro lo + short-vol**. E quel 1,471 era **il prezzatore che si paga da solo**: **DVOL/RV-forward 1,320 a 7g, + sopra nel 76,9% dei giorni** ⇒ riprezzato alla vol vera l'angolo cade a **0,511** (VRP = **72%** + del drift; col gate largo **il 100%**, drift **−1,10%**). E **0,511 e' VRP01** (ShFULL 0,47 a + f=0,73): *non una scoperta, VRP01 ri-trovato per una strada piu' lunga*. §3 lo blocca comunque. + 🚨 **USCITA ANTICIPATA (chiesta: 50-75% del tempo) — implementata e COSTA**, e produce il secondo + risultato trasferibile. Cella onesta, gate FORTE: drift **+9,48% (scadenza) -> +3,84% (uscita al + 50%)**, esito da VINCE a **perde sotto 0,75**; gate LARGO **12,06% -> 1,88%**. Il meccanismo + previsto c'e' (il VRP residuo scende **+3,38 -> +1,27pp**: uscire presto rinuncia davvero allo + short-vol) **ma lo spread lo travolge**. 📌 **Corregge l'APPLICAZIONE di §46:** *"un roll anticipato + non paga f"* vale per una copertura **solo LONG**; in un collar la **gamba venduta va RICOMPRATA**, + quindi uscire prima paga f *esattamente* sulla parte che a scadenza si regolava gratis, ~2x piu' + spesso. **L'asimmetria di §46 si INVERTE quando la struttura ha una gamba corta.** + ✅ **Controlli dell'apparato 3/3** (la lezione di §46): pranzo gratis riconosciuto, premio x10 + rifiutato, zero-cost finito. + ⚠️ **Cinque difetti miei catturati dai controlli:** bisezione zero-cost **invertita** (dava Sharpe + −3,9); `dcall=NaN` che propagava NaN nella cassa; **C9 non consapevole della direzione** (`S1>S0` + non e' "vincente" per un ciclo short — dava l'impossibile *"pavimento 0,0%"*); e due di + contabilita' che **avrebbero adulato il collar** (la base che rollava lo spot pagando ~3,6%/a di + fee inesistenti, e il roll che chiudeva lo spot senza motivo). + 📌 **Corregge un muro di §46:** il **tick da 5 USDC** e' della famiglia **USDC**; la catena che + raccogliamo e' **100% inverse** ⇒ quel muro **non si applica** a questo filone. + **Cosa lo riapre:** un **f di stress su un crash catturato** (la condizione che §3 pone allo + short-vol), o un sottostante **senza coda destra grassa**. **Non** lo riaprono tenor, delta o + griglie piu' fini: la pendenza e' monotona verso l'angolo, e l'angolo e' gia' misurato. diff --git a/docs/research/RESULTS-0822.md b/docs/research/RESULTS-0822.md index 78edd62..3a87e63 100644 --- a/docs/research/RESULTS-0822.md +++ b/docs/research/RESULTS-0822.md @@ -17,6 +17,7 @@ null de-levering superato + eseguibilita' al capitale dichiarato. | 8 | VOL-SIZE | **LEAD RIDIMENSIONATO** — gate 22/12 **gia' fallito oggi** | dare a SKH01 una size per-trade regge a **23/23 ancore**, 8/8 anni, null di permutazione e trasferimento su V1 — ma vale **+0,07 di Sharpe di libro, un terzo della fortuna d'ancora del libro stesso (+0,196)**. Il vol-target di **libro** e' invece falsificato: compra peso SKH gia' respinto e peggiora l'eseguibilita' | | — | **XSR-REPRO** (integrita') | 🚨 **DIFETTO DI PRODUZIONE** | il numero 1.82 e' SPIEGATO e non era sbagliato (era su una **terza** lente, e su una barra non ancora chiusa) — ma cercandone la causa e' emerso che **`paper_xsr` registra ~41 minuti di mercato al giorno**, non un giorno. Tre gate pre-registrati leggono serie costruite cosi' | | 70 | **XSR-RENDITA** | **SCARTATO sotto la lente RENDITA** (non come sleeve) | il muro scende del 20,6% a iso-rischio, ma **mescolare i rendimenti di XSR01 da' lo stesso muro** (il meccanismo vale 0,8%, dentro la risoluzione MC) e un **conto remunerato allo stesso tasso lo eguaglia a vol 0 e senza secondo venue**. A drift zero il muro **sale**: si compra un drift scorrelato, non la scorrelazione. E l'haircut non pareggia un conto al 4% **nemmeno a haircut ZERO** | +| 71 | **COLLAR01** | hold BTC gated dal trend, coperto da un collar (pavimento comprato, tetto venduto) a scadenza <=15g: "ridurre la vincita, bloccare la perdita" batte il de-levering? | **REFUTATO** — ✅ il pavimento FUNZIONA (maxDD scende in **36/48**, §46 battuto sul suo motivo: qui beta=1,0) ❌ ma costa **2-8 punti di drift per punto di DD** e il de-levering vince **45/48**. 🚨 Le 3 celle vincenti stanno sul **bordo**: estesa la famiglia vince 35/36 **nell'angolo covered-call**, e **il 72-100% di quell'edge e' il PREMIO DI VARIANZA** (DVOL sta **32% sopra** la RV-forward): riprezzato alla vol vera l'angolo cade da Sharpe **1,471 a 0,511** — che e' VRP01 (0,47), non una scoperta | | 5 | SKEW | **SCARTATO** (Q1, Q2) + **LEAD** (Q3, gate 2027-02-22) | il prezzo muove lo skew (t 3,1-9,4 su 8/8 test), **non il contrario** (max |t| in avanti 2,35 contro 2,08 atteso dal rumore). Ma Q3 e' grosso: **il f=0,73 di VRP01 e' per il 42% STRUTTURA A TERMINE e solo per il 25% skew** | | — | **HL-EXEC** (audit di fatto) | **3 falsificazioni misurate** | il pavimento vero e' **$10 (non $5)** e il taker **4,50 bps (non 5,0)** — ma il *"XS01 serve ~$20k"* e' **refutato del tutto** (nessuna soglia da min-order), il *"XSR01 ~$5.000"* e' **conservativo di 1,7x** (vero ~$3.000), e lo **slippage "rischio #1" di XSR01 e' refutato** con margine **21x** | | — | **SLIP-AUDIT** (audit di fatto) | **SCARTATO** = nessun costo nascosto a questa taglia | i backtest a 10 bps RT restano **conservativi di ~1,5 bps/lato**; nessuna evidenza di impatto sopravvive al null (p=0,274; estremi avversi **1/18 contro 4,7 attesi**). **Ma la misura ha una data di scadenza** | @@ -3799,3 +3800,90 @@ rendita non e' lo strumento**, e che il gate del 23/10 sta per decidere su una g capitale **non e' comprabile**. **Con questa sono 70 filoni e 0 candidati promossi.** + +--- + +## 71 — COLLAR01 (hold BTC gated dal trend, coperto da un collar a <=15 giorni) + +`scripts/research/r0901_btc_collar.py`. **Libro, pesi, cron, config INVARIATI. Nessun ordine.** +Domanda dell'operatore, con tre precisazioni in corso d'opera che ne cambiano l'oggetto: *"hold BTC +long o short con copertura in opzioni"* + *"opzione max 15gg"* + *"ridurre la vincita, ma bloccare +la perdita"* (⇒ **collar**, non put protettiva) + *"l'entrata gestita da indicatori (es. forte bull)"*. + +🚨 **PERCHE' SI POTEVA RIAPRIRE DOPO §46.** §46 fu refutato perche' **il beta del libro al +sottostante e' +0,076** — *"non si assicura un libro che nei crash e' gia' quasi piatto"*. Qui il +sottostante e' un **hold di BTC nudo, beta 1,0**: il motivo non si applica. E §46 dichiara di non +aver provato la copertura **gated su regime**, che e' esattamente questa. + +**L'entrata non aggiunge parametri:** `tsmom_blend` media tre `np.sign()` su (30, 90, 180) ⇒ valori +in {−1, −1/3, +1/3, +1}, quindi *"forte bull"* = `|blend|==1` (3/3 orizzonti, a mercato **52,5%** +dei giorni) contro il confronto dichiarato `|blend|>=1/3` (**97,1%**). Il segno da' la direzione: +long **e** short, come chiesto. + +**CALIBRAZIONE dalla catena vera** (161.964 quote a due lati, 95 giorni, DTE 1-15). **Lo skew e' il +fatto strutturale:** IV_put/ATM contro IV_call/ATM = **1,249 vs 0,959** a |δ|0,10 · 1,132 vs 0,950 a +0,20 · 1,072 vs 0,960 a 0,30 ⇒ **un collar delta-simmetrico e' un DEBITO netto**, si compra l'ala +cara e si vende quella a buon mercato. 📌 **Corregge un muro di §46:** il **tick da 5 USDC** e' della +famiglia **USDC**; la catena che raccogliamo e' **100% inverse** ⇒ **quel muro non si applica qui**. + +| esito | misura | +|---|---| +| ✅ **A1 — il pavimento FUNZIONA** | maxDD scende in **36/48** celle (§46: saliva in 162/162) | +| ❌ **A3 — ma il de-levering lo fa meglio** | il collar batte il null a iso-maxDD in **3/48** | +| ❌ **A2 — il prezzo del baratto** | Δdrift/ΔmaxDD mediano **7,90** (gate forte) / **1,98** (largo) | +| 🚨 **C9 — troncatura, non protezione** | il tetto taglia il **46,2%** dei cicli **vincenti**, il pavimento para il **6,7%** dei **perdenti**: 7x piu' spesso sui vincenti | +| ❌ **M1 — dentro il libro non aggiunge** | collar Sharpe 0,508 vs TP01 **0,852**; TP01+10% ⇒ Sharpe **+0,000** e maxDD **+2,32pp** | + +🚨 **IL FATTO CHE VALE PIU' DEL VERDETTO — e che si vede solo estendendo la griglia (M4/M8).** Tutte +e 3 le celle vincenti stanno sul **BORDO** (δput min, δcall max). Estesa la famiglia con 36 trial +dichiarati verso l'angolo: **vince 35/36**, massimo **nell'angolo** (FORTE 7g, δput 0,02 / δcall +0,50) = Sharpe **1,471**, maxDD 18,12%, drift **+29,99%/a**. Il limite di quell'angolo e' *nessun +pavimento, tetto ATM* = **una covered call**: **la pendenza porta FUORI da cio' che l'operatore ha +chiesto e DENTRO lo short-vol.** +🚨 **E quel Sharpe e' il prezzatore che si paga da solo.** **DVOL / RV-forward mediana 1,320 a 7g** +(DVOL sta sopra nel **76,9%** dei giorni), 1,253 a 14g. Riprezzando alla vol **realizzata** +(look-ahead dichiarato, valore equo ex-post): l'angolo cade **1,471 -> 0,511** di Sharpe (VRP +**+21,54pp = 72%** del drift) col gate FORTE, e **1,141 -> 0,114** col LARGO (VRP **+33,26pp = tutto**; +drift **−1,10%**). Le celle con un pavimento VERO (δ0,10/0,30) passano da VINCE a **perde**. +📌 **Cio' che sopravvive — Sharpe 0,511 — e' il numero che il progetto GIA' HA:** VRP01 a f=0,73 vale +ShFULL **0,47**. *Non e' una strategia nuova: e' VRP01 ri-scoperto per una strada piu' lunga*, e §3 +lo blocca comunque (**"niente short-vol da modello in deploy"**). + +🚨 **USCITA ANTICIPATA (chiesta dall'operatore: 50-75% del tempo) — IMPLEMENTATA, E COSTA.** +`exit_frac` su tenor 14g (uscita a 7/9/10 giorni). Cella onesta δ0,10/0,30 gate FORTE: drift +**+9,48% (scadenza) -> +8,59% (0,75) -> +6,11% (0,625) -> +3,84% (0,50)**, e l'esito passa da VINCE a +**perde sotto 0,75**; gate LARGO **12,06% -> 1,88%**. ⚠️ **Il meccanismo previsto c'e'** — il VRP +residuo scende da +3,38 a **+1,27pp**, quindi uscire presto rinuncia davvero allo short-vol — **ma lo +spread lo travolge**. 📌 **E CORREGGE L'APPLICAZIONE DI §46:** *"f si paga solo sulla parte di valore +che converge a intrinseco, quindi un roll anticipato non lo paga"* vale per una copertura **SOLO +LONG**; in un collar c'e' una **gamba VENDUTA da RICOMPRARE**, e uscire prima paga f *esattamente* +sulla parte che a scadenza si sarebbe regolata gratis, ~2x piu' spesso per unita' di tempo. +**L'asimmetria di §46 si INVERTE quando la struttura ha una gamba corta.** + +✅ **CONTROLLI DELL'APPARATO 3/3** (M15, la lezione di §46): pavimento a **premio zero** riconosciuto +come vittoria (maxDD 51,83%→**36,76%**, drift +10,04%→**+35,69%**); premio **x10** rifiutato (drift +**−33,96%/a**); zero-cost costruibile e finito. + +⚠️ **CINQUE DIFETTI MIEI, tutti catturati dai controlli e non a occhio.** (i) **bisezione dello +zero-cost invertita** — il premio cresce col delta, quindi a incasso insufficiente il tetto va +*avvicinato*: dava Sharpe −3,0/−3,9 e drift −53%/a; (ii) `dcall=NaN` propagava NaN nella cassa allo +smontaggio anticipato; (iii) **C9 non consapevole della direzione** — `S1>S0` non e' "vincente" per +un ciclo **short**: era la causa dell'impossibile *"il pavimento para nello 0,0% dei perdenti"* con +una put a 10 delta; (iv) la base senza opzioni **rollava lo spot** ogni tenor pagando ~3,6%/a di fee +inesistenti (**avrebbe adulato il collar**); (v) al roll delle opzioni chiudevo e riaprivo **anche lo +spot**, che non ha motivo di muoversi. + +⚠️ **NON MISURATO, DICHIARATO:** la **lente reale** (quote vere, 123 giorni = ~8 cicli) non e' stata +girata come backtest — sotto-potenziata, e su una finestra in cui BTC e' salito da ~64,7k a ~77,5k, +**avversa a un collar per costruzione**; la catena e' servita a **calibrare**, dove 161.964 quote +hanno potenza. Nessun DSR e nessun `study_family_honest`: il filone cade al **primo** gate (M5), e M2 +si spende su cio' che il primo gate lascia in piedi. A6/A7 restano previsioni non misurate. Il +**funding** non e' nel motore (−2,16%/a) ma colpisce base e collar quasi allo stesso modo: abbassa +entrambi, **non cambia il segno**. + +**VERDETTO: `IL PAVIMENTO FUNZIONA — E' IL TETTO CHE NON SI PUO' PAGARE; E CIO' CHE VINCE SUL BORDO +NON E' LA PROTEZIONE, E' IL PREMIO DI VARIANZA (VRP01)`** — REFUTATO. +**Cosa lo riapre:** un **f di stress misurato su un crash catturato** (la stessa condizione che §3 +pone allo short-vol), o un sottostante **senza coda destra grassa** — il tetto costa perche' BTC vive +li'. **Non** lo riaprono tenor, delta o griglie piu' fini: la pendenza e' monotona verso l'angolo, e +l'angolo e' gia' misurato. diff --git a/scripts/research/r0901_btc_collar.py b/scripts/research/r0901_btc_collar.py new file mode 100644 index 0000000..fee7db8 --- /dev/null +++ b/scripts/research/r0901_btc_collar.py @@ -0,0 +1,715 @@ +"""COLLAR01 — hold BTC (long/short) con COLLAR di opzioni a <=15 giorni. + +DOMANDA (posta dall'operatore il 2026-09-01): + "crea una strategia di hold BTC in long o short con copertura con options (max 15gg)" + + due precisazioni in corso d'opera: + "opzione deve essere al max di 15gg" -> vincolo sulla SCADENZA, non sul solo holding + "voglio ridurre la vincita, ma bloccare la perdita" -> NON e' una put protettiva: e' un COLLAR + (pavimento comprato, tetto venduto per finanziarlo). + "l'entrata deve essere gestita da una strategia confortata da indicatori (es. forte bull)" + "dalle opzioni dobbiamo uscire prima del termine (tra 50% e 75% del tempo)" -> `exit_frac`. + +PERCHE' QUESTO FILONE NON E' §46 (TAIL-HEDGE, REFUTATO il 2026-08-23). + §46 compro' put deep-OTM sul LIBRO (TP01+SKH01) e fu refutato *prima del prezzo*: il maxDD + saliva in 162/162 celle perche' **il beta del libro al sottostante e' +0,076** — "non si + assicura un libro che nei crash e' gia' quasi piatto". + Qui il sottostante e' un **hold di BTC nudo, beta 1,0 per costruzione**: il motivo che uccise + §46 NON si applica, ed e' l'unica ragione per cui questo filone puo' essere riaperto + (CLAUDE.md: "chi la riapre deve battere il motivo, non ripetere la misura"). + §46 dichiara inoltre di NON aver provato la copertura dinamica e di aver misurato la sola + statica: anche quella porta e' aperta, ma qui non la uso — il collar e' statico a griglia. + +COSA E' GIA' NOTO E VIENE RIUSATO, NON RIMISURATO: + - metriche e i due null di scala (`k_for_same_dd`, `k_for_same_drift`) da r0823_tail_hedge; + - listino fee opzioni Deribit (taker 0,03% del sottostante, cap 12,5% del premio, consegna + 0,015%) dallo stesso; + - la catena reale via `cblib.load_chain()` (archivio 2026-05-01 + raccolta propria 2026-07-30). + +LE DUE LENTI, ed e' una scelta dichiarata: + (A) LENTE LUNGA (decide): 2021-03-24 -> oggi, la finestra del DVOL — ~5,4 anni. Opzioni + prezzate a modello (BS, r=0) con DVOL come ancora ATM, CORRETTA da skew e struttura a + termine MISURATI SULLA CATENA VERA, e spread f misurato per ala. E' l'impianto di §46. + (B) LENTE REALE (controlla il segno, NON decide): 2026-05-01 -> oggi, solo quote a due lati. + 123 giorni = ~8 cicli a 15 giorni: sotto-potenziata per costruzione, e per giunta su una + finestra in cui BTC e' salito da ~64,7k a ~77,5k, che e' AVVERSA a un collar (il tetto si + paga proprio li'). Si legge il segno, mai la taglia. + +M5 (null del de-levering) E' IL PRIMO TEST, NON L'ULTIMO: ogni claim "meno drawdown" si misura +contro "tieni semplicemente meno BTC" a iso-maxDD. Se il collar non batte quello, non e' +protezione: e' una ri-scalatura pagata cara. +C9 e' il secondo: "una regola d'uscita che scatta piu' spesso sui vincenti che sui perdenti non e' +protezione, e' troncatura" — qui il tetto e' letteralmente quella regola. + +NESSUN ORDINE. Libro, pesi, cron, config INVARIATI. +""" +from __future__ import annotations + +import math +import sys +from pathlib import Path + +import numpy as np +import pandas as pd +from scipy.stats import norm + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "research")) + +import cblib # noqa: E402 +from src.data.downloader import load_data # noqa: E402 + +DPY = 365.25 + +# ---- listino Deribit opzioni (identico a r0823_tail_hedge, non ridichiarato a mano) ----------- +OPT_TAKER_UNDERLYING = 0.0003 +OPT_FEE_CAP_FRAC = 0.125 +OPT_DELIVERY_UNDERLYING = 0.00015 + +# ---- GRIGLIA DICHIARATA PRIMA DI GUARDARE I RISULTATI ----------------------------------------- +G_DPUT = (0.10, 0.20, 0.30) # |delta| della put comprata = il PAVIMENTO +G_DCALL = (0.10, 0.20, 0.30) # delta della call venduta = il TETTO +G_TENOR = (7, 14) # giorni a scadenza: <=15, vincolo dell'operatore +N_CELLS = len(G_DPUT) * len(G_DCALL) * len(G_TENOR) + +# varianti dichiarate a parte (non nella griglia, per non gonfiare i trial) +V_ZEROCOST = "zero-cost" # tetto scelto per finanziare esattamente il pavimento + +ATTESE = """ +ATTESE A PRIORI — scritte e stampate PRIMA di misurare (M12: un follow-up contiene una previsione). + A1 Il maxDD SCENDE davvero, a differenza di §46: il beta e' 1,0 e il pavimento morde. + (se A1 fallisse, il filone cade come §46 e per lo stesso motivo) + A2 Il drift scende PIU' del maxDD, perche' il rendimento di BTC vive nella coda destra e il + tetto la taglia. Previsione: rapporto Δdrift/ΔmaxDD > 1. + A3 Il collar PERDE contro il null del de-levering a iso-maxDD in piu' della meta' delle celle. + E' la previsione centrale del filone. + A4 Lo skew rende il collar delta-simmetrico un DEBITO netto (put piu' cara della call). + [gia' misurato prima di scrivere: +31% / +19% / +12% a |d| 0,10 / 0,20 / 0,30 -> CONFERMATA] + A5 Il tick morde MENO che in §46: quella era la famiglia USDC (tick 5 USDC); la catena che + raccogliamo e' 100% inverse, con tick in BTC. + A6 Nella lente reale (BTC +20% in 123 giorni) il collar sembrera' pessimo: finestra avversa, + non evidenza. Va letta come segno, non come taglia. + A7 Il verso SHORT sara' peggiore del LONG: il drift di BTC e' positivo e lo short lo paga tutto, + col collar che ne cappa il solo rimbalzo favorevole. +""" + + +# ==================================================================== 0. METRICHE E NULL (da §46) + +def _sh(x) -> float: + r = np.asarray(pd.Series(x).dropna().values, float) + return float(np.mean(r) / np.std(r) * math.sqrt(DPY)) if len(r) > 2 and np.std(r) > 0 else 0.0 + + +def _dd(x) -> float: + r = np.asarray(pd.Series(x).dropna().values, float) + eq = np.cumprod(1.0 + r) + pk = np.maximum.accumulate(eq) + return float(np.max((pk - eq) / pk)) if len(eq) else 0.0 + + +def _drift(x) -> float: + r = np.asarray(pd.Series(x).dropna().values, float) + if len(r) < 2: + return 0.0 + tot = float(np.prod(1.0 + r)) + return -1.0 if tot <= 0 else float(tot ** (DPY / len(r)) - 1.0) + + +def k_for_same_dd(base: np.ndarray, target_dd: float) -> float: + """La scala costante che porta il maxDD del NUDO esattamente a quello del coperto (bisezione).""" + lo, hi = 1e-4, 1.0 + if _dd(hi * base) <= target_dd: + return 1.0 + for _ in range(60): + mid = 0.5 * (lo + hi) + if _dd(mid * base) > target_dd: + hi = mid + else: + lo = mid + return 0.5 * (lo + hi) + + +# ==================================================================== 1. CALIBRAZIONE DALLA CATENA + +_BANDS = (0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.40, 0.50) + + +def calibra_catena() -> dict: + """Skew, struttura a termine e spread f: MISURATI sulla catena vera, mai assunti. + + Restituisce funzioni di correzione applicate al DVOL (che e' un ATM a 30 giorni) per ottenere + l'IV di una gamba a delta e scadenza dati. Senza queste il modello prezzerebbe la put e la + call allo STESSO vol: l'errore andrebbe tutto a favore del collar (A4). + """ + df = cblib.load_chain() + b = df[df["asset"].eq("BTC")].copy() + b["exp"] = pd.to_datetime(b["exp"], utc=True) + b["ts"] = pd.to_datetime(b["ts"], utc=True) + b["dte"] = (b["exp"] - b["ts"]).dt.total_seconds() / 86400.0 + q = b[(b["dte"] > 1) & (b["dte"] <= 15) & b["bid"].gt(0) & b["ask"].gt(0) + & b["iv"].notna() & b["delta"].notna()].copy() + q["ad"] = q["delta"].abs() + + atm = q[q["ad"].between(0.45, 0.55)].groupby(["ts", "exp"])["iv"].mean().rename("iv_atm") + q = q.join(atm, on=["ts", "exp"]) + q = q[q["iv_atm"].notna() & q["iv_atm"].gt(0)] + + skew = {} + for t, nm in (("P", "put"), ("C", "call")): + xs, ys = [], [] + for d in _BANDS: + lo, hi = d * 0.85, d * 1.15 + s = q[q["option_type"].eq(t) & q["ad"].between(lo, hi)] + if len(s) >= 200: + xs.append(d) + ys.append(float((s["iv"] / s["iv_atm"]).median())) + skew[nm] = (np.array(xs), np.array(ys)) + + # struttura a termine: IV ATM a DTE t contro il DVOL 30g dello stesso istante + dv = cblib.dvol_series("BTC") + a = q[q["ad"].between(0.45, 0.55)].copy() + a["giorno"] = a["ts"].dt.floor("D") + dvd = dv.copy() + dvd.index = pd.to_datetime(dvd.index, utc=True).floor("D") + dvd = dvd.groupby(level=0).last() + a = a.join(dvd.rename("dvol"), on="giorno") + a = a[a["dvol"].notna() & a["dvol"].gt(0)] + a["ratio"] = (a["iv"] / 100.0) / (a["dvol"] / 100.0) if a["iv"].median() > 3 else a["iv"] / (a["dvol"] / 100.0) + ts_x, ts_y = [], [] + for lo, hi in ((1, 4), (4, 8), (8, 12), (12, 15)): + s = a[a["dte"].between(lo, hi)] + if len(s) >= 200: + ts_x.append(0.5 * (lo + hi)) + ts_y.append(float(s["ratio"].median())) + + # spread relativo per ala e per banda di delta (mezza forchetta su mid) = il costo di f + spread = {} + for t, nm in (("P", "put"), ("C", "call")): + xs, ys = [], [] + for d in _BANDS: + lo, hi = d * 0.85, d * 1.15 + s = q[q["option_type"].eq(t) & q["ad"].between(lo, hi)] + s = s[s["mid"].gt(0)] + if len(s) >= 200: + xs.append(d) + ys.append(float((((s["ask"] - s["bid"]) / 2.0) / s["mid"]).median())) + spread[nm] = (np.array(xs), np.array(ys)) + + return dict(skew=skew, term=(np.array(ts_x), np.array(ts_y)), spread=spread, + n_quote=len(q), giorni=int(q["ts"].dt.date.nunique()), + span=(q["ts"].min(), q["ts"].max())) + + +def _interp(tab, x): + xs, ys = tab + if len(xs) == 0: + return 1.0 + return float(np.interp(x, xs, ys)) + + +# ==================================================================== 2. PREZZATORE + +def _d1(S, K, T, sig): + return (math.log(S / K) + 0.5 * sig * sig * T) / (sig * math.sqrt(T)) + + +def bs_call(S, K, T, sig): + if T <= 0 or sig <= 0: + return max(S - K, 0.0) + d1 = _d1(S, K, T, sig) + return S * norm.cdf(d1) - K * norm.cdf(d1 - sig * math.sqrt(T)) + + +def bs_put(S, K, T, sig): + if T <= 0 or sig <= 0: + return max(K - S, 0.0) + d1 = _d1(S, K, T, sig) + return K * norm.cdf(-(d1 - sig * math.sqrt(T))) - S * norm.cdf(-d1) + + +def strike_da_delta(S, T, sig_atm, cal, delta, tipo): + """Strike che realizza |delta| dato, risolvendo la circolarita' skew<->delta per punto fisso. + + L'IV dipende dal delta (skew) e il delta dipende dallo strike che dipende dall'IV: due + iterazioni bastano (verificato: la terza muove lo strike di <0,1%). + """ + sig = sig_atm * _interp(cal["skew"][tipo], delta) + K = S + for _ in range(3): + z = norm.ppf(delta) if tipo == "call" else -norm.ppf(delta) + K = S * math.exp(0.5 * sig * sig * T - z * sig * math.sqrt(T)) + sig = sig_atm * _interp(cal["skew"][tipo], delta) + return K, sig + + +def _fee(prem, S): + return min(OPT_TAKER_UNDERLYING * S, OPT_FEE_CAP_FRAC * prem) if prem > 0 else 0.0 + + +# ==================================================================== 3. IL MOTORE DEL COLLAR + +def serie_giornaliera() -> pd.DataFrame: + """BTC giornaliero + DVOL, sulla finestra in cui ESISTONO entrambi (2021-03 in poi).""" + d = load_data("BTC", "1h") + idx = pd.DatetimeIndex(pd.to_datetime(d["datetime"], utc=True)) + px = pd.Series(d["close"].values.astype(float), index=idx).resample("1D").last() + dv = pd.read_parquet(ROOT / "data" / "raw" / "dvol_btc.parquet") + iv = pd.Series(dv["close"].values.astype(float) / 100.0, + index=pd.to_datetime(dv["timestamp"], unit="ms", utc=True)).resample("1D").last() + P = pd.concat({"px": px, "iv": iv}, axis=1, join="inner").dropna() + return P + + +SPOT_FEE_SIDE = 0.00035 # Deribit taker reale dal 2026-08-01 (3,50 bps/lato), §8 + + +def gate_trend(P: pd.DataFrame, forte: bool) -> np.ndarray: + """L'ENTRATA, governata da indicatori — e presa dal progetto, non inventata qui. + + `trend_portfolio.tsmom_blend` media tre `np.sign()` sugli orizzonti (30, 90, 180) giorni, + quindi assume SOLO i valori {-1, -1/3, +1/3, +1} (il bucket 2/3 non esiste: e' un errore gia' + corretto in §57). Questo da' a "forte bull" una definizione che non aggiunge NEMMENO UN + parametro nuovo: + forte=True -> |blend| == 1 : tutti e tre gli orizzonti concordi ("forte bull/bear") + forte=False -> |blend| >= 1/3: almeno la maggioranza (confronto dichiarato) + Il segno del blend da' la DIREZIONE (long o short), come chiesto. + """ + from src.strategies.trend_portfolio import tsmom_blend + c = P["px"].values.astype(float) + b = tsmom_blend(c, (30, 90, 180)) + d = np.sign(b) + d[np.abs(b) < (0.99 if forte else 0.32)] = 0.0 + return np.concatenate([[0.0], d[:-1]]) # ESEGUIBILE: si entra il giorno DOPO il segnale + + +def collar(P: pd.DataFrame, cal: dict, *, dput: float, dcall: float, tenor: int, + dirs: np.ndarray, zerocost: bool = False, senza_opzioni: bool = False, + prem_scale: float = 1.0, solo_pavimento: bool = False, + exit_frac: float = 1.0) -> dict: + """Hold di BTC gated dal trend, coperto da un collar rollato ogni `tenor` giorni (<=15). + + direzione +1 = LONG BTC -> compra PUT (pavimento), vende CALL (tetto) + direzione -1 = SHORT BTC -> compra CALL (pavimento), vende PUT (tetto) + `exit_frac` < 1 esce dalle opzioni PRIMA della scadenza (0,50-0,75 = richiesta dell'operatore + del 2026-09-01). Non e' un dettaglio di rollo: cambia il segno di due cose opposte. + - sulla gamba COMPRATA (il pavimento) uscire presto RECUPERA valore temporale: il theta di una + long accelera verso la scadenza, e §46 aveva gia' misurato che *"f si paga solo sulla parte + di valore che converge a intrinseco, quindi un roll anticipato non lo paga"*; + - sulla gamba VENDUTA (il tetto) uscire presto RINUNCIA al theta piu' veloce, cioe' proprio + alla parte che in questo filone stampava il premio di varianza. + Il prezzo di entrambe e' che si paga lo SPREAD in uscita su due gambe, invece di regolare a + intrinseco. Chi lo attiva compra meno short-vol e piu' protezione onesta: e' misurabile, ed e' + misurato nella sezione dedicata. + + `prem_scale` e `solo_pavimento` esistono per i CONTROLLI (M15): con premio 0 e solo il + pavimento la struttura e' un pranzo gratis e DEVE vincere; con premio x10 DEVE perdere ovunque. + Un apparato che non riconosce il pranzo gratis e' guasto, e §46 lo scopri' a proprie spese. + + `senza_opzioni=True` produce il MEDESIMO gated hold senza collar: e' la BASE del null M5, e + dev'essere generata dallo stesso motore o il confronto misurerebbe anche le differenze di + contabilita' (P1: il null deriva dal codice sorvegliato, non lo ridichiara). + + Marcatura GIORNALIERA a modello: il pavimento vincola il PAYOFF A SCADENZA, non il mark + intermedio. Senza marcatura giornaliera il maxDD del collar sarebbe misurato solo ai roll e la + protezione sembrerebbe migliore di quanto e'. + """ + S = P["px"].values.astype(float) + A = P["iv"].values.astype(float) + n = len(S) + if n < tenor + 5: + return {} + + tipo_di = {+1: ("put", "call"), -1: ("call", "put")} + nav = np.full(n, np.nan) + V = 1.0 # capitale normalizzato + cash, q, nopt = 1.0, 0.0, 0.0 + Kl = Ks = np.nan + dc_vivo = 0.20 # delta del tetto VIVO: serve allo spread d'uscita anticipata + scad = -1 + roll_at = -1 # quando si ESCE (<= scad): con exit_frac<1 e' PRIMA della scadenza + cur = 0 + cicli = [] + dc_scelti = [] # delta del tetto effettivamente usato a ogni apertura + apre = 0 + + def _mark(K, T, sig_atm, tipo, S_t): + if T <= 0 or math.isnan(K): + return 0.0 if math.isnan(K) else (max(K - S_t, 0.0) if tipo == "put" else max(S_t - K, 0.0)) + sig = sig_atm * _interp(cal["term"], max(T * DPY, 1.0)) + d1 = _d1(S_t, K, T, sig) + dl = norm.cdf(d1) if tipo == "call" else 1.0 - norm.cdf(d1) + sig *= _interp(cal["skew"][tipo], min(max(dl, 0.02), 0.60)) + return bs_put(S_t, K, T, sig) if tipo == "put" else bs_call(S_t, K, T, sig) + + def _chiudi(i, S_t, anche_spot=True): + """Regola le opzioni vive; liquida lo spot SOLO se richiesto. + + ⚠️ Il flag esiste per un difetto che falserebbe tutto: al ROLL delle opzioni lo spot non + ha motivo di muoversi. Chiuderlo e riaprirlo pagherebbe 2x3,5 bps ogni `tenor` giorni + (a 7 giorni: ~3,6%/anno) di fee che nella realta' non si pagano. + """ + nonlocal cash, q, nopt, Kl, Ks, cur + if nopt > 0 and not senza_opzioni: + tl, ts = tipo_di[cur] + T_rem = max(scad - i, 0) / DPY + vl = _mark(Kl, T_rem, A[i], tl, S_t) + vs = 0.0 if solo_pavimento else _mark(Ks, T_rem, A[i], ts, S_t) + if T_rem > 0: # smontaggio anticipato: pago lo spread in USCITA + vl *= 1.0 - _interp(cal["spread"][tl], dput) + vs *= 1.0 + _interp(cal["spread"][ts], dc_vivo) + else: + for x in (vl, vs): + if x > 0: + cash -= nopt * OPT_DELIVERY_UNDERLYING * S_t + cash += nopt * (vl - vs) + nopt, Kl, Ks = 0.0, np.nan, np.nan + if anche_spot and q != 0.0: + cash += q * S_t - abs(q) * S_t * SPOT_FEE_SIDE + q = 0.0 + + for i in range(n): + S_t = S[i] + d = int(dirs[i]) + fine_ciclo = (i >= roll_at) and cur != 0 and not senza_opzioni + solo_roll = fine_ciclo and d == cur + if d != cur or fine_ciclo: + if cur != 0: + if nopt > 0 and i >= scad: + tl, ts = tipo_di[cur] + il_ = max(Kl - S_t, 0.0) if tl == "put" else max(S_t - Kl, 0.0) + is_ = 0.0 if solo_pavimento else ( + max(Ks - S_t, 0.0) if ts == "put" else max(S_t - Ks, 0.0)) + cicli.append(dict(i0=apre, i1=i, S0=S[apre], S1=S_t, Kl=Kl, Ks=Ks, + dirn=cur, cap=is_ > 0, floor=il_ > 0, + scaduto=bool(fine_ciclo))) + _chiudi(i, S_t, anche_spot=not solo_roll) + if not solo_roll: + V = cash + if not solo_roll: + cur = d + if cur != 0: + if not solo_roll: + q = cur * V / S_t + cash = V - q * S_t - abs(q) * S_t * SPOT_FEE_SIDE + apre, scad = i, i + tenor + roll_at = i + max(1, int(round(exit_frac * tenor))) + if not senza_opzioni: + tl, ts = tipo_di[cur] + T = tenor / DPY + Kl, sl = strike_da_delta(S_t, T, A[i], cal, dput, tl) + pl = (bs_put if tl == "put" else bs_call)(S_t, Kl, T, sl) + pl *= 1.0 + _interp(cal["spread"][tl], dput) # compro all'ASK + pl *= prem_scale + if zerocost: + lo, hi = 0.02, 0.60 + for _ in range(40): + md = 0.5 * (lo + hi) + Kx, sx = strike_da_delta(S_t, T, A[i], cal, md, ts) + px_ = (bs_put if ts == "put" else bs_call)(S_t, Kx, T, sx) + px_ *= 1.0 - _interp(cal["spread"][ts], md) # vendo al BID + # il premio CRESCE col delta: se incasso meno di quanto pago, + # il tetto va avvicinato (delta PIU' ALTO) -> lo = md. + if px_ < pl: + lo = md + else: + hi = md + dc_use = 0.5 * (lo + hi) + else: + dc_use = dcall + dc_vivo = dc_use + dc_scelti.append(float(dc_use)) + Ks, ss = strike_da_delta(S_t, T, A[i], cal, dc_use, ts) + ps = (bs_put if ts == "put" else bs_call)(S_t, Ks, T, ss) + ps *= (1.0 - _interp(cal["spread"][ts], dc_use)) * prem_scale + if solo_pavimento: + ps, Ks = 0.0, np.inf if ts == "call" else 0.0 + nopt = abs(q) + cash += nopt * (ps - pl) - nopt * (_fee(pl, S_t) + _fee(ps, S_t)) + vl = vs = 0.0 + if nopt > 0 and not senza_opzioni: + tl, ts = tipo_di[cur] + T_rem = max(scad - i, 0) / DPY + vl = _mark(Kl, T_rem, A[i], tl, S_t) + vs = 0.0 if solo_pavimento else _mark(Ks, T_rem, A[i], ts, S_t) + nav[i] = cash + q * S_t + nopt * (vl - vs) + + v = pd.Series(nav, index=P.index).dropna() + r = (v / v.shift(1) - 1.0).dropna() + return dict(ret=r, nav=v, cicli=cicli, dc_scelti=dc_scelti) + + +# ==================================================================== 4. MAIN + +def _riga(nome, r) -> dict: + return dict(nome=nome, sh=_sh(r), dd=_dd(r), drift=_drift(r)) + + +def main() -> None: + print("=" * 96) + print(" COLLAR01 — hold BTC gated dal trend, coperto da collar di opzioni a <=15 giorni") + print("=" * 96) + print(ATTESE) + + cal = calibra_catena() + print(f" CALIBRAZIONE dalla catena VERA: {cal['n_quote']:,} quote a due lati su " + f"{cal['giorni']} giorni, {cal['span'][0].date()} -> {cal['span'][1].date()}") + for nm in ("put", "call"): + xs, ys = cal["skew"][nm] + print(f" skew {nm:5s} IV/ATM : " + " ".join(f"d{x:.2f}={y:.3f}" for x, y in zip(xs, ys))) + xs, ys = cal["term"] + print(f" termine IV/DVOL30 : " + " ".join(f"{x:.0f}g={y:.3f}" for x, y in zip(xs, ys))) + for nm in ("put", "call"): + xs, ys = cal["spread"][nm] + print(f" spread {nm:5s} (mezza forchetta/mid): " + + " ".join(f"d{x:.2f}={y:.3f}" for x, y in zip(xs, ys))) + + P = serie_giornaliera() + print(f"\n LENTE LUNGA: {P.index[0].date()} -> {P.index[-1].date()} " + f"({len(P)} giorni, {len(P)/DPY:.2f} anni)") + + bh = (P["px"] / P["px"].shift(1) - 1.0).dropna() + print(f" BTC buy&hold nudo: Sharpe {_sh(bh):.3f} · maxDD {100*_dd(bh):.2f}% · " + f"drift {100*_drift(bh):+.2f}%/a") + + righe = [] + for forte in (True, False): + dirs = gate_trend(P, forte) + nome_g = "FORTE (3/3 orizzonti)" if forte else "LARGO (>=2/3)" + quota = 100.0 * np.mean(dirs != 0) + qlong = 100.0 * np.mean(dirs > 0) + base = collar(P, cal, dput=0.2, dcall=0.2, tenor=7, dirs=dirs, senza_opzioni=True) + rb = base["ret"] + print(f"\n ── GATE {nome_g}: a mercato {quota:.1f}% dei giorni " + f"(long {qlong:.1f}%, short {quota-qlong:.1f}%)") + print(f" BASE gated SENZA opzioni: Sharpe {_sh(rb):.3f} · maxDD {100*_dd(rb):.2f}% · " + f"drift {100*_drift(rb):+.2f}%/a ← e' il NULL da battere (M5)") + for tenor in G_TENOR: + for dp in G_DPUT: + for dc in G_DCALL: + out = collar(P, cal, dput=dp, dcall=dc, tenor=tenor, dirs=dirs) + if not out: + continue + r = out["ret"] + dd_c, dr_c = _dd(r), _drift(r) + k = k_for_same_dd(rb.values, dd_c) + dr_null = _drift(k * rb.values) + righe.append(dict(gate=nome_g, forte=forte, tenor=tenor, dp=dp, dc=dc, + sh=_sh(r), dd=dd_c, drift=dr_c, k=k, drift_null=dr_null, + vince=(dr_c > dr_null) and (dd_c < _dd(rb)), + dd_scende=dd_c < _dd(rb), cicli=out["cicli"], ret=r)) + # varianti zero-cost, dichiarate FUORI griglia + for tenor in G_TENOR: + for dp in G_DPUT: + out = collar(P, cal, dput=dp, dcall=np.nan, tenor=tenor, dirs=dirs, zerocost=True) + if not out: + continue + r = out["ret"] + dd_c, dr_c = _dd(r), _drift(r) + k = k_for_same_dd(rb.values, dd_c) + righe.append(dict(gate=nome_g, forte=forte, tenor=tenor, dp=dp, dc="zc", + sh=_sh(r), dd=dd_c, drift=dr_c, k=k, + drift_null=_drift(k * rb.values), + vince=(dr_c > _drift(k * rb.values)) and (dd_c < _dd(rb)), + dd_scende=dd_c < _dd(rb), cicli=out["cicli"], ret=r)) + R = pd.DataFrame([x for x in righe if x["gate"] == nome_g]) + print(f"\n {'ten':>4} {'dput':>5} {'dcall':>6} {'Sharpe':>7} {'maxDD':>7} " + f"{'drift':>8} {'k null':>7} {'drift null':>10} esito") + for _, x in R.iterrows(): + dcs = x["dc"] if isinstance(x["dc"], str) else f"{x['dc']:.2f}" + print(f" {x['tenor']:>4} {x['dp']:>5.2f} {dcs:>6} {x['sh']:>7.3f} " + f"{100*x['dd']:>6.2f}% {100*x['drift']:>7.2f}% {x['k']:>7.3f} " + f"{100*x['drift_null']:>9.2f}% " + f"{'VINCE' if x['vince'] else ('dd↓ ma perde' if x['dd_scende'] else 'dd↑')}") + + R = pd.DataFrame(righe) + n_dd = int(R["dd_scende"].sum()) + n_win = int(R["vince"].sum()) + print("\n" + "=" * 96) + print(f" A1 maxDD SCENDE in {n_dd}/{len(R)} celle " + f"({'CONFERMATA' if n_dd > len(R)//2 else 'REFUTATA'})") + print(f" A3 il collar BATTE il de-levering in {n_win}/{len(R)} celle " + f"({'REFUTATA (il collar vince)' if n_win > len(R)//2 else 'CONFERMATA (il collar perde)'})") + + # ---- A2: rapporto Δdrift/ΔmaxDD ---------------------------------------------------------- + for forte in (True, False): + sub = R[R["forte"] == forte] + if not len(sub): + continue + dirs = gate_trend(P, forte) + rb = collar(P, cal, dput=0.2, dcall=0.2, tenor=7, dirs=dirs, senza_opzioni=True)["ret"] + d0, g0 = _dd(rb), _drift(rb) + rap = ((g0 - sub["drift"]) / np.maximum(d0 - sub["dd"], 1e-9)).median() + print(f" A2 gate {'FORTE' if forte else 'LARGO'}: Δdrift/ΔmaxDD mediano {rap:.2f} " + f"({'CONFERMATA' if rap > 1 else 'REFUTATA'}: >1 = il tetto costa piu' di quanto il " + f"pavimento renda)") + + # ---- C9: il tetto scatta piu' sui vincenti o sui perdenti? ------------------------------- + best = R.sort_values("sh", ascending=False).iloc[0] + cic = pd.DataFrame(best["cicli"]) + if len(cic): + # ⚠️ "vincente" dipende dalla DIREZIONE: per un ciclo SHORT un prezzo che sale e' una + # PERDITA. Usare S1>S0 come proxy di vittoria e' il difetto che dava "pavimento 0,0%". + cic["vinc"] = cic["dirn"] * (cic["S1"] - cic["S0"]) > 0 + cap_w = 100.0 * cic[cic["vinc"]]["cap"].mean() + flo_l = 100.0 * cic[~cic["vinc"]]["floor"].mean() + print(f"\n C9 cella migliore (gate {best['gate']}, {best['tenor']}g, dput {best['dp']}, " + f"dcall {best['dc']}), {len(cic)} cicli:") + print(f" il TETTO taglia nel {cap_w:.1f}% dei cicli VINCENTI · " + f"il PAVIMENTO para nel {flo_l:.1f}% dei cicli PERDENTI") + print(f" -> {'TRONCATURA' if cap_w > flo_l else 'PROTEZIONE'} " + f"(C9: se scatta piu' sui vincenti che sui perdenti, non e' protezione)") + + # ---- M1: il confronto che decide se merita uno slot -------------------------------------- + try: + from src.portfolio.sleeves import _tp01_returns + tp = _tp01_returns() + tp.index = pd.to_datetime(tp.index, utc=True) + tpd = (1.0 + tp).resample("1D").prod() - 1.0 + j = pd.concat({"c": best["ret"], "tp": tpd}, axis=1, join="inner").dropna() + print(f"\n M1 vs TP01 sulla finestra comune ({len(j)} giorni): " + f"collar Sharpe {_sh(j['c']):.3f} · TP01 {_sh(j['tp']):.3f} · " + f"corr {j['c'].corr(j['tp']):+.3f}") + for w in (0.10, 0.25): + mix = (1 - w) * j["tp"] + w * j["c"] + print(f" TP01 + {100*w:.0f}% collar: Sharpe {_sh(mix):.3f} " + f"({_sh(mix)-_sh(j['tp']):+.3f}) · maxDD {100*_dd(mix):.2f}% " + f"({100*(_dd(mix)-_dd(j['tp'])):+.2f}pp)") + except Exception as e: # pragma: no cover + print(f"\n M1 non calcolato: {type(e).__name__}: {e}") + + # ---- ESTENSIONE AL BORDO (M4: si riapre la FAMIGLIA, M8: un argmax sul bordo non decide) -- + print("\n" + "=" * 96) + print(" ESTENSIONE DICHIARATA — le celle vincenti stanno tutte sul BORDO (dput min, dcall max).") + print(" Sondo dove punta la pendenza. Il limite dput->0 / dcall->0,50 e' la COVERED CALL pura:") + print(" nessun pavimento, tetto ATM. Trial aggiunti: 2 gate x 2 tenor x 3 x 3 = 36 (dichiarati).") + ext = [] + for forte in (True, False): + dirs = gate_trend(P, forte) + rb = collar(P, cal, dput=0.2, dcall=0.2, tenor=7, dirs=dirs, senza_opzioni=True)["ret"] + for tenor in (7, 14): + for dp in (0.02, 0.05, 0.10): + for dc in (0.30, 0.40, 0.50): + r = collar(P, cal, dput=dp, dcall=dc, tenor=tenor, dirs=dirs)["ret"] + k = k_for_same_dd(rb.values, _dd(r)) + ext.append(dict(forte=forte, tenor=tenor, dp=dp, dc=dc, sh=_sh(r), + dd=_dd(r), drift=_drift(r), + vince=(_drift(r) > _drift(k * rb.values)) and (_dd(r) < _dd(rb)))) + E = pd.DataFrame(ext) + top = E.sort_values("sh", ascending=False).iloc[0] + print(f" -> VINCE in {int(E['vince'].sum())}/{len(E)} celle, e il massimo e' NELL'ANGOLO: " + f"gate {'FORTE' if top['forte'] else 'LARGO'} {top['tenor']}g dput {top['dp']:.2f} " + f"dcall {top['dc']:.2f} -> Sharpe {top['sh']:.3f}, maxDD {100*top['dd']:.2f}%, " + f"drift {100*top['drift']:+.2f}%/a") + print(" ⚠️ Il pavimento non e' cio' che vince: vince il TETTO. La pendenza porta FUORI da cio'") + print(" che l'operatore ha chiesto ('bloccare la perdita') e DENTRO lo short-vol.") + + # ---- LA DIAGNOSTICA CHE DECIDE: quanto dell'edge e' il PREMIO DI VARIANZA? ---------------- + lr = np.log(P["px"] / P["px"].shift(1)) + print("\n RIPREZZATURA ALLA VOLATILITA' REALIZZATA — diagnostica con LOOK-AHEAD DICHIARATO,") + print(" non e' una strategia: e' il valore equo ex-post. Se l'edge sparisce, era IV-RV.") + for h in (7, 14): + fw = (lr[::-1].rolling(h).std()[::-1] * math.sqrt(DPY)).bfill().ffill() + j = pd.concat({"iv": P["iv"], "fw": fw}, axis=1).dropna() + print(f" DVOL / RV-forward a {h}g: mediana {(j['iv']/j['fw']).median():.3f} · " + f"DVOL sopra nel {100*(j['iv']>j['fw']).mean():.1f}% dei giorni") + for tenor in (7, 14): + fw = (lr[::-1].rolling(tenor).std()[::-1] * math.sqrt(DPY)).bfill().ffill() + Pf = P.copy() + Pf["iv"] = fw.reindex(P.index).values + for forte in (True, False): + dirs = gate_trend(P, forte) + rb = collar(P, cal, dput=0.2, dcall=0.2, tenor=7, dirs=dirs, senza_opzioni=True)["ret"] + for dp, dc in ((0.02, 0.50), (0.10, 0.30)): + a = collar(P, cal, dput=dp, dcall=dc, tenor=tenor, dirs=dirs)["ret"] + b = collar(Pf, cal, dput=dp, dcall=dc, tenor=tenor, dirs=dirs)["ret"] + ka, kb = k_for_same_dd(rb.values, _dd(a)), k_for_same_dd(rb.values, _dd(b)) + wa = _drift(a) > _drift(ka * rb.values) and _dd(a) < _dd(rb) + wb = _drift(b) > _drift(kb * rb.values) and _dd(b) < _dd(rb) + print(f" {'FORTE' if forte else 'LARGO'} {tenor:>2}g dput {dp:.2f} dcall {dc:.2f}: " + f"a DVOL Sh {_sh(a):6.3f} drift {100*_drift(a):+7.2f}% {'VINCE' if wa else 'perde'}" + f" | a RV Sh {_sh(b):6.3f} drift {100*_drift(b):+7.2f}% {'VINCE' if wb else 'perde'}" + f" | VRP {100*(_drift(a)-_drift(b)):+6.2f} pp") + + # ---- USCITA ANTICIPATA (richiesta dell'operatore: fra il 50% e il 75% del tempo) --------- + print("\n" + "=" * 96) + print(" USCITA ANTICIPATA DALLE OPZIONI — exit_frac 0,50 / 0,625 / 0,75 contro la scadenza.") + print(" §46 misuro' che 'f si paga solo sulla parte di valore che converge a intrinseco,") + print(" quindi un roll anticipato NON lo paga'. ⚠️ Vale per una copertura SOLO LONG: in un") + print(" collar c'e' una gamba VENDUTA da RICOMPRARE, e uscire prima paga f esattamente sulla") + print(" parte che a scadenza si sarebbe regolata gratis. L'asimmetria si INVERTE.") + lr2 = np.log(P["px"] / P["px"].shift(1)) + for forte in (True, False): + dirs = gate_trend(P, forte) + rb = collar(P, cal, dput=0.2, dcall=0.2, tenor=7, dirs=dirs, senza_opzioni=True)["ret"] + print(f"\n gate {'FORTE' if forte else 'LARGO'} (base drift {100*_drift(rb):+.2f}%/a):") + for dp, dc in ((0.10, 0.30), (0.20, 0.20), (0.02, 0.50)): + for ef in (1.00, 0.75, 0.625, 0.50): + a = collar(P, cal, dput=dp, dcall=dc, tenor=14, dirs=dirs, exit_frac=ef)["ret"] + hh = max(1, int(round(ef * 14))) + fw = (lr2[::-1].rolling(hh).std()[::-1] * math.sqrt(DPY)).bfill().ffill() + Pf = P.copy() + Pf["iv"] = fw.reindex(P.index).values + b = collar(Pf, cal, dput=dp, dcall=dc, tenor=14, dirs=dirs, exit_frac=ef)["ret"] + k = k_for_same_dd(rb.values, _dd(a)) + w = (_drift(a) > _drift(k * rb.values)) and (_dd(a) < _dd(rb)) + print(f" d{dp:.2f}/{dc:.2f} exit {ef:.3f}: Sh {_sh(a):6.3f} DD {100*_dd(a):6.2f}% " + f"drift {100*_drift(a):+7.2f}% null {100*_drift(k*rb.values):+6.2f}% " + f"{'VINCE' if w else 'perde'} | a RV drift {100*_drift(b):+7.2f}% " + f"VRP {100*(_drift(a)-_drift(b)):+6.2f}pp") + + # ---- CONTROLLI DELL'APPARATO (M15) — un rilevatore che non li passa non misura nulla ---- + print("\n CONTROLLI DELL'APPARATO:") + dirs = gate_trend(P, True) + rb = collar(P, cal, dput=0.2, dcall=0.2, tenor=7, dirs=dirs, senza_opzioni=True)["ret"] + esiti = [] + g = collar(P, cal, dput=0.20, dcall=0.20, tenor=14, dirs=dirs, + prem_scale=0.0, solo_pavimento=True)["ret"] + ok_g = (_dd(g) < _dd(rb)) and (_drift(g) > _drift(rb)) + esiti.append(ok_g) + print(f" pranzo gratis (pavimento a premio ZERO): maxDD {100*_dd(rb):.2f}%->{100*_dd(g):.2f}% " + f"drift {100*_drift(rb):+.2f}%->{100*_drift(g):+.2f}% {'OK' if ok_g else 'FALLITO'}") + c = collar(P, cal, dput=0.20, dcall=0.20, tenor=14, dirs=dirs, prem_scale=10.0)["ret"] + ok_c = _drift(c) < _drift(rb) + esiti.append(ok_c) + print(f" premio x10 (deve perdere): drift {100*_drift(c):+.2f}%/a " + f"{'OK' if ok_c else 'FALLITO'}") + zc = collar(P, cal, dput=0.20, dcall=np.nan, tenor=14, dirs=dirs, zerocost=True) + prem_res = np.nan + ok_z = bool(len(zc.get("cicli", [])) > 0) and np.isfinite(_drift(zc["ret"])) + esiti.append(ok_z) + print(f" zero-cost costruibile e finito: drift {100*_drift(zc['ret']):+.2f}%/a " + f"{'OK' if ok_z else 'FALLITO'}") + if not all(esiti): + print(" 🚨 APPARATO GUASTO: il verdetto sotto NON e' leggibile (§46: un controllo " + "positivo rotto dichiara guasto l'apparato)") + + # ---- VERDETTO A RUNTIME (N11) ------------------------------------------------------------ + print("\n" + "=" * 96) + # il verdetto deve tenere conto della diagnostica VRP: il bordo vince, ma di che cosa? + dirs_f = gate_trend(P, True) + rb_f = collar(P, cal, dput=0.2, dcall=0.2, tenor=7, dirs=dirs_f, senza_opzioni=True)["ret"] + fw7 = (np.log(P["px"] / P["px"].shift(1))[::-1].rolling(7).std()[::-1] + * math.sqrt(DPY)).bfill().ffill() + Pf7 = P.copy() + Pf7["iv"] = fw7.reindex(P.index).values + ang_dvol = collar(P, cal, dput=0.02, dcall=0.50, tenor=7, dirs=dirs_f)["ret"] + ang_rv = collar(Pf7, cal, dput=0.02, dcall=0.50, tenor=7, dirs=dirs_f)["ret"] + quota_vrp = (_drift(ang_dvol) - _drift(ang_rv)) / max(_drift(ang_dvol), 1e-9) + + if n_dd == 0: + v = "IL PAVIMENTO NON RIDUCE IL maxDD — cade come §46 e per lo stesso motivo" + elif n_win <= len(R) // 8: + v = (f"IL PAVIMENTO FUNZIONA (a differenza di §46: maxDD scende in {n_dd}/{len(R)}) MA IL " + f"TETTO LO PAGA TROPPO: solo {n_win}/{len(R)} celle battono il de-levering. " + f"E cio' che vince sul bordo NON e' il pavimento: e' il premio di varianza " + f"({100*quota_vrp:.0f}% del drift dell'angolo sparisce riprezzando alla vol vera) " + f"— cioe' il meccanismo di VRP01, non una scoperta, e §3 lo blocca in deploy") + else: + v = f"REGGE IL PRIMO GATE: {n_win}/{len(R)} celle battono il de-levering — serve M2/DSR" + print(f" VERDETTO (calcolato a runtime): {v}") + print("=" * 96) + + +if __name__ == "__main__": + main() diff --git a/tests/test_collar_apparato.py b/tests/test_collar_apparato.py new file mode 100644 index 0000000..2a61ee4 --- /dev/null +++ b/tests/test_collar_apparato.py @@ -0,0 +1,124 @@ +"""Guardie su COLLAR01 (§71): i difetti che l'apparato ha gia' pagato non devono tornare. + +Non testano il VERDETTO (quello e' una misura, e cambia col dato): testano che il MOTORE sappia +riconoscere un successo e non regali soldi al collar per errore di contabilita'. E' la lezione di +§46 — *"un controllo positivo rotto dichiara guasto l'apparato"* — trasformata in test. + +Calibrazione SINTETICA (non la catena vera): il test dev'essere veloce e hermetico. Quello che +verifica e' l'algebra del motore, non i parametri di mercato. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts" / "research")) + + +@pytest.fixture(scope="module") +def C(): + import r0901_btc_collar as mod + return mod + + +@pytest.fixture(scope="module") +def cal(): + """Skew/termine/spread piatti e neutri: isola l'algebra dai parametri di mercato.""" + d = np.array([0.02, 0.10, 0.20, 0.30, 0.50]) + return dict( + skew={"put": (d, np.ones_like(d)), "call": (d, np.ones_like(d))}, + term=(np.array([1.0, 30.0]), np.array([1.0, 1.0])), + spread={"put": (d, np.zeros_like(d)), "call": (d, np.zeros_like(d))}, + ) + + +@pytest.fixture(scope="module") +def P(): + """Serie deterministica di 800 giorni con un trend e un crollo: abbastanza per il gate a 180g.""" + rng = np.random.default_rng(7) + n = 800 + r = rng.normal(0.0015, 0.03, n) + r[600:640] = -0.045 # un crollo vero, dove il pavimento deve mordere + px = 30000.0 * np.exp(np.cumsum(r)) + idx = pd.date_range("2022-01-01", periods=n, freq="D", tz="UTC") + return pd.DataFrame({"px": px, "iv": np.full(n, 0.60)}, index=idx) + + +def test_pranzo_gratis_e_riconosciuto(C, P, cal): + """CONTROLLO POSITIVO (M15). Un pavimento a premio ZERO e senza tetto e' denaro regalato: + DEVE abbassare il maxDD E alzare il drift. Se questo test fallisce, ogni verdetto NEGATIVO + del filone e' illeggibile — non e' il collar che perde, e' l'apparato che e' rotto.""" + dirs = C.gate_trend(P, True) + base = C.collar(P, cal, dput=0.2, dcall=0.2, tenor=14, dirs=dirs, senza_opzioni=True)["ret"] + free = C.collar(P, cal, dput=0.2, dcall=0.2, tenor=14, dirs=dirs, + prem_scale=0.0, solo_pavimento=True)["ret"] + assert C._dd(free) < C._dd(base), "un pavimento gratis non abbassa il maxDD: apparato guasto" + assert C._drift(free) > C._drift(base), "un pavimento gratis non alza il drift: apparato guasto" + + +def test_premio_esagerato_deve_perdere(C, P, cal): + """CONTROLLO NEGATIVO: a premio x10 la struttura deve perdere contro la base.""" + dirs = C.gate_trend(P, True) + base = C.collar(P, cal, dput=0.2, dcall=0.2, tenor=14, dirs=dirs, senza_opzioni=True)["ret"] + caro = C.collar(P, cal, dput=0.2, dcall=0.2, tenor=14, dirs=dirs, prem_scale=10.0)["ret"] + assert C._drift(caro) < C._drift(base) + + +def test_nav_short_parte_positivo(C, P, cal): + """Difetto reale trovato il 2026-09-01: scritto come `direzione*S_t + ...` il NAV di uno SHORT + partirebbe NEGATIVO. Lo short non e' un'attivita' che vale -S: e' capitale a garanzia.""" + dirs = np.where(np.arange(len(P)) >= 200, -1.0, 0.0) + out = C.collar(P, cal, dput=0.2, dcall=0.2, tenor=14, dirs=dirs) + assert (out["nav"] > 0).all(), "NAV non positivo in direzione short" + + +def test_zerocost_bisezione_non_invertita(C, P, cal): + """Difetto reale: la bisezione dello zero-cost era INVERTITA (dava Sharpe -3,9 e drift -53%/a). + + L'invariante e' sulla BISEZIONE, non sul drift: il premio cresce col delta, quindi per + finanziare una put PIU' CARA il tetto va AVVICINATO => il delta scelto per il tetto deve + CRESCERE col delta del pavimento. (Il drift NON e' monotono e non va usato come proxy: un + pavimento piu' stretto aiuta nei crolli e danneggia nei rally.) + """ + dirs = C.gate_trend(P, True) + scelti = [] + for dp in (0.05, 0.15, 0.30): + o = C.collar(P, cal, dput=dp, dcall=np.nan, tenor=14, dirs=dirs, zerocost=True) + assert o["dc_scelti"], "lo zero-cost non ha aperto nessuna struttura" + assert np.isfinite(C._drift(o["ret"])), "zero-cost non finito (NaN nella cassa)" + scelti.append(float(np.median(o["dc_scelti"]))) + assert scelti[0] < scelti[1] < scelti[2], f"bisezione invertita: dc scelti {scelti}" + + +def test_base_senza_opzioni_non_paga_roll_fantasma(C, P, cal): + """Difetto reale: la base del null rollava lo SPOT ogni `tenor` giorni pagando fee che nella + realta' non esistono (~3,6%/anno a 7 giorni) — avrebbe ADULATO il collar. La base non deve + dipendere dal tenor, che senza opzioni non ha significato.""" + dirs = C.gate_trend(P, True) + a = C.collar(P, cal, dput=0.2, dcall=0.2, tenor=7, dirs=dirs, senza_opzioni=True)["ret"] + b = C.collar(P, cal, dput=0.2, dcall=0.2, tenor=14, dirs=dirs, senza_opzioni=True)["ret"] + assert C._drift(a) == pytest.approx(C._drift(b), abs=1e-9), "la base dipende dal tenor" + + +def test_gate_forte_e_piu_selettivo_del_largo(C, P): + """`tsmom_blend` assume solo {-1,-1/3,+1/3,+1}: il gate FORTE (3/3 concordi) deve essere un + SOTTOINSIEME stretto del LARGO, o "forte bull" non sta selezionando nulla.""" + f = C.gate_trend(P, True) + l = C.gate_trend(P, False) + assert np.mean(f != 0) < np.mean(l != 0) + assert np.all((f == 0) | (f == l)), "il gate forte non e' un sottoinsieme del largo" + + +def test_entrata_non_guarda_il_futuro(C, P): + """L'entrata e' il giorno DOPO il segnale (§8.1): la direzione del giorno i non puo' dipendere + dal prezzo del giorno i.""" + d0 = C.gate_trend(P, True) + P2 = P.copy() + P2.iloc[-1, P2.columns.get_loc("px")] *= 1.5 # muovo SOLO l'ultimo prezzo + d1 = C.gate_trend(P2, True) + assert np.array_equal(d0, d1), "la direzione reagisce a un prezzo che non ha ancora visto"