crolli: il libro perde nel giorno e chiude > 0 in 8/8 finestre dal 2022 (SKH01 short); crollo catturato a vol bassa, XRP diluisce come SOL

Quattro misure (r0909_*), nessun cambio a libro/pesi/config:
- libro nei crolli: −0,48%/g nei 160 giorni ≤ −5%, positivo per finestra dal 2022
  per la gamba short di SKH01 (108% dei guadagni); beta 0,0769 riprodotto (§46);
  il peso di SKH01 resta chiuso dal gate (in-sample).
- crollo catturato 1-5/06/2026 a quote vere: f_net 0,74 = rally; put δ−0,10
  1,92× il modello; a vol bassa e fuori dal gate di VRP01 → §3 non si riapre.
- XRP terza gamba (harness r0822_sol_leg): hold-out −0,169 in 0/24, un anno buono.
- universo Deribit: liquidi solo BTC/ETH/XRP/SOL; XS01 13/19; BTCDVOL future
  non negoziabile; PAXG non misurato.
Revisione fable: due conclusioni smontate (MTM 2,6× era un artefatto di quote;
«8/12 guadagna» era l'ordine delle classi). Debito §5.18: cblib.spot_series
+ asof guarda un'ora avanti (feed 1h etichettato all'apertura).
Test Opus: 92 nuovi, suite 1008/1008; due difetti del verdetto XRP corretti.
Docs: diario, RESULTS §74-77, CLAUDE.md, memoria 20/50, README; journal 07-08/09.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zJjHUS7mf4pnGE6pq9RTt
This commit is contained in:
Adriano Dal Pastro
2026-09-09 16:33:54 +00:00
parent 9f55652d99
commit bb40e87d13
16 changed files with 3138 additions and 10 deletions
+31 -7
View File
@@ -17,8 +17,8 @@ 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-73 | vuoi il dettaglio di un filone |
| `docs/diary/` (123 voci) | la sessione originale, per data | vuoi il contesto completo |
| `docs/research/RESULTS-0822.md` | registro per filone §1-77 | vuoi il dettaglio di un filone |
| `docs/diary/` (146 voci) | la sessione originale, per data | vuoi il contesto completo |
---
@@ -151,6 +151,22 @@ tutto il drift del libro). A 10 anni i bonifici fanno il **59%** del risultato.
un tetto di 1,0x. **Il cap non ha mai morso: il vincolo e' il segnale.** Riempire quel vuoto con
l'ETF e' stato **misurato e SCARTATO** il 25/08 (`20-ondate`).
📌 **Il libro nei crolli (misurato 09/09, `r0909_libro_nei_crolli.py`): nel GIORNO del crollo PERDE**
(0,48%/g sui 160 giorni ≤ 5%, positivo nel 18%); per finestra di 20 giorni e' immune-o-positivo e
**dal 2022 chiude > 0 in 8/8 peggiori finestre**, tutto dalla gamba **short di SKH01** (108% dei
guadagni; marcata all'USCITA del trade, quindi arriva *dopo* il giorno). Le 2 finestre perse (2019,
maggio 2021) sono TP01 long dentro un trend non ancora girato. Beta +0,0769 (riproduce §46). ⚠️ Un
solo aggettivo ("guadagna"/"immune") e' l'ordine delle regole: la prima stesura diceva 8/12 GUADAGNA,
con le classi nell'ordine dichiarato sono 4 + 6 immune. **La leva che aumenterebbe il guadagno e' il
peso di SKH01** (37,5/62,5 batte 12/12 episodi ma maxDD 12,1% vs 9,4%): vincolato (§3), gate
`weights_tilt_null` fallisce su una condizione **in-sample** (delta 0,0026, 26/07; cache segnali ferma
al 25/07). **Crollo CATTURATO a quote vere (1-5/06/2026, archivio bite, `r0909_crash_catturato.py`)**:
f_net del put credit spread **0,74 = 0,714 del rally**; put δ−0,10 costa **1,92×** il modello all'ask ma
paga come il modello nel crollo (0,94-1,04); f al peggior MTM 1,08 (il 2,58 della prima stesura era un
artefatto di quote oltre la larghezza). **NON e' il f di stress della regola del 19/06**: crollo a vol
BASSA (picco DVOL al 29°/46° percentile), gate IV-rank di VRP01 CHIUSO, famiglia INVERSE, 4 strutture.
Diario `2026-09-09-crolli-opzioni-monete.md`.
🚨 **Tre baseline diverse girano sotto lo stesso nome "libro 75/25"**, e lo spread fra loro (0,12 di
Sharpe, 1,7pp di maxDD) e' **piu' grande di quasi tutti gli effetti che il progetto misura**:
`hourly` **1,683 / 11,22%** (la lente di **ogni muro e ogni traiettoria**) · `canonical` 1,800 / 9,56%
@@ -180,8 +196,8 @@ CLAUDE.md", la risposta e' la colonna *cosa la riapre*.
| decisione | data | non si ri-discute | cosa la riapre |
|---|---|---|---|
| **100% Deribit fino a $20k** — accettato P(perso tutto) 10/18/34/64% a p=0,5/1/2/5% invece di 0/0/4/27% | 26/07 | la soglia $3k | **$20k**, o un cambio di piano, o `p` che diventa stimabile invece che assunto |
| **SOL escluso** — universo direzionale resta BTC/ETH (presidiato da un test) | 22/08 | SOL su un book a 2 gambe con questi dati | ~3 anni di storia SOL certificata (**non prima del 2028**), o un meccanismo che non sia TP01/SKH01 congelati |
| **Niente short-vol da modello in deploy** — e niente long-vol scalp da modello | 19/06 | — | un `f` di stress reale misurato su un crash catturato |
| **SOL escluso** — universo direzionale resta BTC/ETH (presidiato da un test) | 22/08 | SOL su un book a 2 gambe con questi dati | ~3 anni di storia SOL certificata (**non prima del 2028**), o un meccanismo che non sia TP01/SKH01 congelati. **XRP misurato il 09/09 con lo stesso harness: diluisce uguale** (hold-out 0,169 in 0/24, un anno buono, 5m flat 27-61%); su Deribit i perp liquidi (≥$10M/g) sono SOLO BTC/ETH/XRP/SOL — `r0909_deribit_universo.py` |
| **Niente short-vol da modello in deploy** — e niente long-vol scalp da modello | 19/06 | — | un `f` di stress reale misurato su un crash catturato **con IV-rank > 0,30 e sulla famiglia USDC-lineare** — il crollo del 1-5/06/2026 (misurato 09/09) e' catturato ma a vol bassa, sotto il gate e inverse: soddisfa la forma, non la sostanza |
| **Non de-esporre il weekend** (porta il 38% del gross di TP01 nel 31% del tempo) | 17/07 | — | ogni proposta "risk-off weekend" parte **REFUTED** salvo null de-levering superato |
| **Peso 75/25 confermato 3 volte** (24/07, 26/07, 24/07-follow-up) | 26/07 | — | `weights_tilt_null` superato, che finora **fallisce** |
| **Fee Deribit: ≤5 bps/lato → non si tocca nulla; >10 bps → rivedere il peso SKH01** (regola decisa *prima* di vedere il numero) | 26/07 | — | il sorvegliante `fee_watch` allerta da solo |
@@ -419,6 +435,14 @@ d'ancora in modo diverso (nella differenza si cancella in parte, nel livello per
`usde_convert.cuscino_richiesto_usd`, P1) ogni ora e allerta sotto zero; e la riconversione USDE→USDC che
ripristina il cuscino e' `usde_convert --quota <minore>`, che esiste ma non e' automatica.
18. ⚠️ **`cblib.spot_series` + `asof(ts)` guarda UN'ORA AVANTI** (trovato in revisione il 09/09): il
feed 1h e' etichettato all'**apertura** (la barra 20:00 chiude col 5m delle 20:55), quindi
`S.asof(ts)` restituisce la chiusura di ts+1h. Tocca ogni numero di `cblib` che usa lo spot
(il f 0,714 di §11 compreso; anche `ST = S.asof(exp)` e' il prezzo di un'ora DOPO la scadenza).
Nella misura del 09/09 e' innocuo (ambo le gambe ITM) e giocava contro l'autore; `r0909_crash_catturato`
usa `spot_causale` (indice +1h). **Non riparato in `cblib`**: cambierebbe numeri pubblicati — va
riparato con la rimisura di §11, dichiarando lo scarto. D6, quarta occorrenza dell'etichetta.
---
## 6. Le regole di prim'ordine
@@ -615,8 +639,8 @@ scripts/live/ → book_execute · venue_watch · fee_watch · edg
journal · analista
scripts/cron_{book,daily,chain,opt_snapshot,vol_term}.sh
docs/memory/ → LA MEMORIA (indice in testa a questo file)
docs/research/ → BRIEF-0822 · RESULTS-0822 (§1-69) · SPEC-scale-key
docs/diary/YYYY-MM-DD.md → una voce per esperimento (123)
docs/research/ → BRIEF-0822 · RESULTS-0822 (§1-77) · SPEC-scale-key
docs/diary/YYYY-MM-DD.md → una voce per esperimento (146)
docs/journal/YYYY-MM-DD.md → libro di bordo, una voce al giorno, 4 livelli per provenienza
data/live/trades.db → i trade allineati col tempo (dentro il perimetro di backup)
Old/ → ARCHIVIO pre-reset
@@ -636,7 +660,7 @@ uv run python scripts/live/<script>.py --help # uso e flag (d
uv run python scripts/live/trades_db.py --reconcile # incrocio delle 3 fonti sui fill
uv run python scripts/live/journal.py # voce del giorno (numeri + lettura)
uv run python scripts/live/analista.py --secco # analisi del giorno, senza salvare
uv run pytest # test (916, tutti verdi al 06/09)
uv run pytest # test (1008, tutti verdi al 09/09)
```
```python
+3 -3
View File
@@ -106,10 +106,10 @@ scripts/
cron_{book,daily,chain,balance,usde,opt_snapshot,vol_term}.sh
docs/
memory/ LA MEMORIA — 6 file, indicizzati in testa a CLAUDE.md
research/ RESULTS-0822 (§1-73, un registro per filone) · BRIEF-0822 · SPEC-scale-key
research/ RESULTS-0822 (§1-77, un registro per filone) · BRIEF-0822 · SPEC-scale-key
diary/ una voce per esperimento (144)
journal/ libro di bordo, una voce al giorno, 4 livelli per provenienza
tests/ 910, tutti verdi
tests/ 1008, tutti verdi
Old/ archivio pre-reset — consultabile, non fidato
```
@@ -123,7 +123,7 @@ uv run python scripts/live/trades_db.py --report # stato del l
uv run python scripts/live/journal.py # voce del giorno
uv run python scripts/live/analista.py --secco # analisi del giorno, senza salvare
uv run python scripts/portfolio/run_portfolio.py # portafoglio di ricerca
uv run pytest # 910 test
uv run pytest # 1008 test
```
Ogni script di `scripts/live/` accetta `--help` e **rifiuta un flag che non conosce** (esce 2): fino
@@ -0,0 +1,219 @@
# 2026-09-09 — Guadagnare nei crolli: revisione dei sistemi, il crollo catturato, piu' monete su Deribit
**Richiesta dell'operatore:** *"fai analisi e revisione dei sistemi adottati e trova soluzione per
guadagnare anche nei crolli (usa le opzioni se serve), anche + monete ma sempre in deribit"*.
Analisi e revisione col modello `fable` (revisore fresco, agente separato), test con Opus.
**Esito in una riga:** nel **giorno** del crollo il libro live **perde** (0,48%/g, positivo nel 18%
dei giorni ≤ 5%); per **finestra** e' immune-o-positivo e **dal 2022 chiude in utile 8/8 peggiori
finestre**, tutte per la gamba SHORT di SKH01 marcata all'uscita; la sola leva che aumenterebbe quel
guadagno e' il peso di SKH01, che il gate del progetto boccia su una condizione in-sample; le
opzioni, misurate per la prima volta su un **crollo catturato a quote vere**, danno un rapporto di
credito identico al rally (f_net 0,74 vs 0,714) — **non** il `f` di stress che la regola del 19/06
aspettava (crollo a vol bassa, gate di VRP01 chiuso, famiglia inverse); XRP, l'unica "altra
moneta" liquida e non misurata di Deribit, **diluisce** esattamente come SOL. **Libro, pesi,
config, cron INVARIATI. Nessun ordine.**
## 0. Prima di misurare: cosa era gia' morto (memoria letta, non riaperta)
| meccanismo "guadagnare nei crolli" | esito | perche' e' morto (chi riapre deve battere questo) |
|---|---|---|
| gamba short del trend a 1d (§42) | SCARTATO | 1,91%/anno di drift in 0/24 ancore; il 2022 lo tiene a galla a meta' |
| put deep-OTM statica (§46) | REFUTATO 0/162 | beta del libro +0,076: non si assicura un libro gia' piatto |
| collar ≤15g (§71) · pavimento come licenza (§72) | REFUTATI | il DD lo riduce il TETTO (troncatura, C9); la put sola peggiora il DD 16/16 |
| gamma scalping long-vol | SCARTATO | paga il VRP ogni anno, ogni frequenza |
| DVOL direzionale · gate macro · skew de-risk | HEDGE / RIDONDANTI | TP01 e' gia' flat nei crolli (Δ 0,00) |
| SOL terza gamba (22/08) | ESCLUSO dall'operatore | hold-out 0,166 in 0/24; un anno solo (2023) |
| XS01 su Deribit (§50) | MORTO per ampiezza | 11 gambe con ≥1 anno: 1,265 → 0,116 |
Condizione di riapertura scritta tre volte (§3 short-vol, §71, §72): **un `f` di stress reale su
un crash catturato**. E' la cosa che oggi si poteva misurare e non era stata misurata.
## 1. Il libro nei crolli — `r0909_libro_nei_crolli.py`
Lente: sleeve di ricerca, ancora canonica, fee 0,10% RT, funding fuori; pesi importati da
`src/live/book` (P1). ⚠️ **SKH01 e' marcato all'USCITA del trade** (`harness.backtest_signals`):
la serie giornaliera e' zero nell'87,7% dei giorni, e uno short aperto nella finestra e chiuso
fuori e' accreditato fuori — per questo la tabella per GIORNO mostra SKH01 ≈ 0 e quella per
FINESTRA il guadagno. Indice 50/50 BTC/ETH; giorno di crollo ≤ 5%; episodio = 12 peggiori
finestre di 20 giorni non sovrapposte + 9 episodi nominati prima. Classi ESCLUSIVE nell'ordine
dichiarato: immune (|libro| < |idx|/10, col segno) → POSITIVO → PERDE.
| misura | valore |
|---|---|
| beta del libro all'indice | **+0,0769 sulla finestra di §46** (2021-03-24+; memoria +0,076: riprodotto al millesimo) · +0,086 su tutta la storia · +0,002 nei giorni ≤ 3% |
| 160 giorni ≤ 5% (indice 7,76%/g) | libro **0,48%/g**: TP01 0,63, SKH01 +0,15; **libro > 0 nel 18%** dei giorni; somme per anno: 2024 11,9%, 2025 7,3%, 2022 +8,2%, 2026 +4,7% |
| 24 giorni ≤ 10% (indice 14,4%/g) | libro 0,51%/g: TP01 0,93, SKH01 **+0,42** |
| 12 peggiori finestre 20g | **4 POSITIVO / 6 immune (4 col segno +) / 2 PERDE**; libro > 0 in 8/12; **dal 2022 in 8/8** (mediana +3,3% contro indice 29%); le 2 perdite sono 2019 e maggio 2021 (TP01 ancora long: il trend non si era girato) |
| 9 episodi nominati | libro > 0 in LUNA (+4,3%), giugno 2022 (+3,1%), FTX (+0,7%), agosto 2024 (+0,4%), 5/02/2026 (+4,4%), 1-5/06/2026 (+3,3%); COVID 0,04%, feb 2025 1,5%, maggio 2021 3,4% |
| da dove viene il guadagno | la gamba **SHORT di SKH01 = 108%** dei guadagni negli episodi con libro > 0 (scomposizione ESATTA: long + short == intera su 2735/2735 giorni, verificato con assert; TP01 contribuisce in negativo); per anno al peso 0,25: 2022 **+10,7%**, 2026 **+7,7%** |
| peggior giorno del libro (lente canonica) | 2025-10-10 3,38%, tutto TP01 con esposizione **0,505**, indice 9,7%: **e' un giorno di crollo con TP01 mezzo long** (lo squeeze di §33 sta sulla lente hourly e qui non si riproduce: dichiarato nel docstring) |
| riga informativa 37,5/62,5 (§26) | batte il 75/25 in 12/12 episodi, **ma** Sharpe 1,81 vs 1,82 e maxDD 12,1% vs 9,4%: paga i crolli con gli squeeze |
🚨 **Errore catturato in revisione (fable):** la prima stesura provava "GUADAGNA" prima di
"immune" e stampava **8/12 GUADAGNA**; con le classi nell'ordine dichiarato sono **4 POSITIVO +
6 immune**. *L'ordine delle regole era il verdetto.* Ora il verdetto e' tre affermazioni separate
(giorno / finestra / provenienza) e il conteggio nudo libro > 0 e' stampato accanto.
📌 **Il meccanismo "guadagnare nei crolli" esiste ed e' in produzione: e' SKH01 short** — ma
guadagna **dopo** il giorno del crollo (marcato all'uscita), non dentro. Nel senso stretto della
domanda dell'operatore il libro **perde nel giorno** e recupera nella finestra. Le due perdite
storiche per finestra sono di TP01 nei crolli che arrivano *dentro* un trend rialzista (2019,
2021): e' il costo del long-flat (§42 ha misurato che la short del trend lo aggrava).
**La leva che aumenterebbe il guadagno e' il peso di SKH01, e il gate la boccia:**
`r0726_reeval_live_weight.py` rilanciato: argmax 0,35, plateau [0,25-0,50] (il live e' dentro),
`weights_tilt_null` 0,25→0,35: `delta_insample 0,0026`, `pctl_hold 31,2`, **`gate_pass False`**.
⚠️ Precisazione della revisione: lo script legge la cache `data/_cache/skh_sigtab/*` **ferma al
2026-07-25** — sono le stesse cifre del diario 26/07, non un ri-esame coi segnali di oggi. La
condizione che fallisce e' pero' **in-sample (pre-2025)**, che 46 giorni di dati nuovi non possono
cambiare: la decisione 75/25 resta vincolante e la sua condizione di riapertura resta non
soddisfatta, ma la data da citare e' il 26/07.
## 2. Il crollo catturato — `r0909_crash_catturato.py`
**Fatto nuovo:** `bite_archive.parquet` copre con bid E ask il crollo del **1-5 giugno 2026**
(BTC 25% / ETH 30% dal 31/05 al minimo; ETH 11,1% il 05/06) su una scadenza (19/06, 14-21
DTE). Il campione del 22/08 (§11) partiva **dopo**, dal fondo. Limiti dichiarati prima: n = 1
crollo, 1 scadenza, tenor 14-21 g (VRP01 e' a 7), spot dal feed 1h certificato, regolamento allo
spot dell'ora di scadenza, **famiglia INVERSE** (premi in ETH: VRP01 opererebbe sulla USDC-lineare,
che il conto margina e che NON e' nell'archivio). Ancora d'ingresso = ogni snapshot 30/05-04/06;
mediana e banda.
⚠️ **Non e' un crollo a vol alta:** DVOL pre 37 (BTC) / 49 (ETH) all'IV-rank 0,05 / 0,10; il
**picco** del crollo (49 / 69) sta al **29° / 46° percentile storico** — sotto la mediana. Il gate
IV-rank ≥ 0,30 di VRP01 e' **chiuso in tutte le ore** e il modello del sleeve segna 0,0 su tutte le
settimane di giugno: VRP01 non sarebbe stato a mercato.
| ETH, ingressi PRE-crollo (286 snapshot = **4 strutture** di strike, δ 0,27/0,09, larghezza 200) | reale | modello (BS su DVOL) | f |
|---|---|---|---|
| credito del put credit spread | | | **0,74** [0,65-0,82] — 22/08 in rally: 0,714 |
| P&L a scadenza per unita' | **$174** | $168 | 1,04 — **tautologico nell'83%** dei trade: entrambe le gambe ITM ⇒ payoff = larghezza per reale e modello, resta solo f_net |
| perdita sul rischio (fee incluse) | 101% [65-102] | | = perdita **MASSIMA** dello spread (+ fee); 65% per la 1900/1600 |
| peggior MTM, chiusura **tagliata alla larghezza** | **$172** | $161 | **1,08** [1,07-1,31]; a quell'ora IV corta 68 = DVOL 68 |
| put δ−0,10 comprata all'ask: costo | | | **1,92×** il modello (§46 a δ−0,10 su BTC_USDC: 1,27 — punto NUOVO e piu' alto, ETH inverse) |
| put δ−0,10 a scadenza (ITM di $4: K 1700, ST 1694) | $6 | ≈ $0 | 0,96 sul solo sottoinsieme con payoff modellato ≥ $5 |
| put δ−0,10 al picco del percorso (look-ahead, limite sup.) | +$163 | +$173 | 0,94 |
| debit spread 0,28/0,10 a scadenza | +$169 | +$163 | 1,04 (stessa tautologia, specchio) |
| ingressi DURANTE il crollo (02-04/06, 283 snapshot) | mediana +$5, **perde nel 46%**, p10 $133 / p90 +$26 | | bimodale: la mediana nasconde le perdite |
BTC: **non misurabile pre-crollo** — la griglia di strike dell'archivio il 30/05-01/06 non
contiene la δ−0,10 (78 snapshot fuori geometria, 0 in geometria prima del 02/06). Durante:
247 snapshot, f_net 0,79, perde nel 37% (p10 $1.718 / p90 +$818 su larghezza 5000).
🚨 **Tre correzioni della revisione, applicate:**
1. *"Il percorso costa 2,6× il modello (f_mtm 2,58), la IV della gamba corta e' esplosa a 108 per lo
skew"* — **artefatto di quote.** Il costo di chiusura `(ask corta bid lunga)` superava la
**larghezza** dello spread (una quota 0,208/0,376 ETH su un intrinseco di $296: 158% di spread
relativo, a T→0) e `min()` pescava li'; uno spread a rischio definito non costa mai piu' della
larghezza per chiudere. Tagliato: **f_mtm 1,08**. E la IV 108 e' del **18/06 alle 17:00** (deep
ITM, T→0), non del crollo: all'ora del peggior MTM la IV e' 68 = DVOL.
2. *"f a scadenza 1,04: il modello prezza bene la perdita"***tautologia**: con entrambe le gambe
ITM il payoff e' la larghezza per entrambi; f_exp e' una funzione di f_net. E la "banda su 286
snapshot" sono 4 strutture (N_eff = 1 crollo × 4).
3. `S.asof(ts)` sul feed 1h **guardava un'ora avanti**: la barra e' etichettata all'apertura (la
20:00 chiude col 5m delle 20:55). Corretto con `spot_causale` (indice +1h). ⚠️ **`cblib.spot_series`
ha lo stesso difetto** → il f 0,714 di §11 e ogni numero di `cblib` con `asof` sullo spot; qui
innocuo (ambo le gambe ITM per ogni ST < 1700) e nel MTM giocava contro l'autore. **Debito nuovo,
registrato in CLAUDE.md §5.** Non toccato: cambierebbe numeri pubblicati.
📌 **Cosa dice davvero il crollo catturato (n = 1):** il **rapporto di credito reale/modello non
cambia col regime** (0,74 nel crollo contro 0,714 nel rally); la put comprata paga nel crollo
cio' che il modello dice (0,94-1,04) ma **costa 1,9× il modello all'ingresso** — il bleed e' dove
il modello sbaglia, il payoff no (e' il motivo di §46/§72 visto dal lato del sinistro); il gate di
VRP01 lo ha tenuto fuori. **La condizione di riapertura di §3 e' soddisfatta nella forma** (un
archivio bite con bid/ask su un crollo) **e non nella sostanza**: crollo a vol bassa, sotto il gate,
tenor 14-21, famiglia inverse, e l'unico f non tautologico (MTM) vale 1,08 su 4 strutture. **Non si
riapre.** Primo punto di una distribuzione che `collect_chain` riempira' da solo; il secondo punto
utile e' un crollo **con IV-rank > 0,30**.
## 3. Piu' monete, sempre in Deribit — `r0909_deribit_universo.py` · `r0909_xrp_terza_gamba.py`
**Il venue letto oggi (15:05Z):** 32 perpetual USDC-lineari aperti (+6 non aperti, fra cui **APT
inactive**: e' il mancante rispetto al 23/08). Volume 24h ≥ $10M solo per **XRP ($38M, il piu'
scambiato del venue, spread 4 bps, listato 2022-03-16), ETH, BTC, SOL ($13M)**; tutto il resto fra
$0,01M e $7M (TAO, HYPE, ADA, AVAX, NEAR…) con spread 2-20 bps. XS01: **13/19** gambe — mancano
ARB, OP, APT, INJ, TIA, SEI → §50 resta: morto per ampiezza. Opzioni USDC-lineari su 7 sottostanti
(SOL 676, HYPE 442, XRP 442, TRX, AVAX) con la liquidita' misurata il 22/08 (SOL: 9% delle put a
due lati). Trovati dalla revisione, registrati perche' il controllo costa €0 e nessuno li riapra:
**`BTCDVOL_USDC-30SEP26`** (l'unico strumento long-vol diretto del venue: volume 0, OI 132, bid/ask
39,1/43,9 = spread 12% → non negoziabile, e in contango sanguina come la put) e **PAXG perpetual**
(oro, dal 2024-12: $0,18M/g, OI $8,3M — non misurato, non morto, stessi muri di SOL/XRP: 9 mesi di
storia contro i 3 anni della decisione 22/08).
**L'unica "altra moneta" liquida su Deribit mai misurata come gamba direzionale e' XRP.** Misurata
col harness di `r0822_sol_leg` (TP01 e SKH01 **congelati**, 24 ancore, differenze appaiate,
de-levering, per anno), ipotesi a priori dichiarata: diluisce.
Certificazione (Coinbase dal rilisting 2023-07, Bitstamp prima — Coinbase aveva delistato XRP per
la causa SEC): >1% dal riferimento 0,06% (2022) · **0,57% (2023)** · 0,01-0,02% (2024+); mediana
5-6 bps. ⇒ **L-PULITA dal 2024** (stessa soglia e stesso anno sporco di SOL). ⚠️ Barre 5m flat
**27-61%** (BTC/ETH 2021+: 0,1-2%; SOL 21%): il 5m su cui gira SKH01 e' 20-100× piu' flat.
| | L-FULL (2022-03+) | L-PULITA (2024+) |
|---|---|---|
| dSharpe FULL (mediana, 24 ancore) | **0,206** [0,26, 0,16], >0 in 0% | 0,066, >0 in 12% |
| dSharpe hold-out | **0,169** [0,32, 0,15], **>0 in 0%** | 0,169, 0% |
| dCAGR | 2,27 pp, 0% | 1,32 pp, 4% |
| null del de-levering | Sh 3g 1,23 vs 2g×0,974 **1,44** → de-levering | 1,54 vs 1,53 → pari |
| per anno (dSh) | 2022 0,55 · 2023 0,66 · **2024 +0,54** · 2025 0,44 · 2026 0,25 → **1/5** | |
| corr(gamba XRP, book) | +0,35 / +0,37 (SOL 0,40-0,56) | |
**XRP DILUISCE** — lo stesso 0,17 di hold-out di SOL, in 0/24 ancore, con **un anno buono** (2024).
TP01 XRP da solo: Sharpe 0,01, hold-out 0,49; SKH01 XRP maxDD 40,1% (il criterio di selezione di
SKH01-V2-DD, maxDD<30%, fallisce anche qui, come SOL). Eseguibilita' non e' il vincolo (min 1 XRP =
$1,42). Non misurato: **SKH01 solo-short su una terza moneta** (la domanda riguarda i crolli, dove
paga lo short) — ma il filtro di direzione e' una famiglia nuova (M20) e il 5m di XRP e' peggio di
SOL: il muro e' lo stesso. **Nessuna proposta.** Il dato vive in `data/raw/alt_xrp_*` (namespace di
ricerca, non rinfrescato dal cron, `load_data("XRP")` fallisce; `tests/test_sol_leg.py` lo presidia
via `DERIBIT_INSTR`). La cache del riferimento in `data/_cache` e' gitignored e fuori backup: la
certificazione si riproduce **con rete** (N11 vale con rete).
## 4. Cosa cambia una decisione — niente; cosa si registra
| voce | prima | oggi |
|---|---|---|
| il libro nei crolli | "immune, beta 0,076" | perde nel GIORNO (0,48%/g, 18% positivi), immune-o-positivo per finestra, **> 0 in 8/8 dal 2022** per SKH01 short marcato all'uscita; beta 0,0769 riprodotto |
| f di stress delle opzioni | non misurato ("serve un crash catturato") | crollo catturato **a vol bassa, sotto il gate, inverse**: f_net 0,74 = rally; f_mtm 1,08; ali 1,92×. **Condizione non soddisfatta nella sostanza** |
| peso SKH01 | gate fallisce (26/07) | invariato: la condizione che fallisce e' in-sample; cache segnali ferma al 25/07 |
| XRP | mai misurato | diluisce come SOL (0,169 hold-out, 0/24) |
| universo Deribit | 14/19 (23/08) | 13/19 (APT inactive); liquidi solo BTC/ETH/XRP/SOL; BTCDVOL future non negoziabile; PAXG non misurato |
| debito nuovo | — | `cblib.spot_series` + `asof` guarda un'ora avanti (feed 1h etichettato all'apertura) |
**Cosa riaprirebbe qualcosa:** (a) un secondo crollo catturato **a IV-rank > 0,30** (VRP01 a
mercato), sulla famiglia USDC-lineare, per il f di stress; (b) `weights_tilt_null` che passa — si
rigenera `skh_sigtab` e si rilancia `r0726_reeval_live_weight`, non si ridiscute; (c) per le
"altre monete", un meccanismo che non sia TP01/SKH01 congelati, o 3 anni di dato 5m liquido.
## 5. Errori catturati in sessione (prima della revisione)
- Percorso ora per ora in un ciclo `groupby` per ancora: >10 minuti; riscritto su una tabella
ora×strumento (`Quote`), ~3 minuti.
- BTC "in geometria" senza guardia: la griglia rada faceva scegliere a `pick_legs` una δ−0,02 al
posto della δ−0,10 (K72000/55000). Guardia `TOL_D` (±0,08/±0,06), scritta dopo aver visto BTC ma
che per ETH scarta 2/288 e non cambia nessun verdetto (verificato dal revisore).
- Coinbase restituiva un batch vuoto per XRP 2022 e il ciclo si fermava: certificazione vuota.
Riscritto con avanzamento su batch vuoto e riferimento Bitstamp dove Coinbase manca.
## 6. Revisione (`fable`, agente fresco) — 21 segnalazioni, tutte verificate
Applicate: #1 artefatto del MTM (taglio alla larghezza; IV 108 e' a T→0) · #2 f_exp tautologico
(quota stampata, N_eff = strutture) · #3 "101%" = perdita massima + fee · #4 `asof` un'ora avanti
(`spot_causale`; debito su `cblib`) · #5 famiglia inverse dichiarata · #8 "durante" bimodale (p10/p90,
quota che perde) · #9 ordine delle classi (immune → POSITIVO → PERDE, tre affermazioni nel verdetto)
· #10 peggior giorno = crollo con TP01 0,505, docstring (c) corretto · #11 beta sulla finestra di §46
(0,0769, tolleranza 0,01) · #12 scomposizione esatta con assert · #13 lente "marcata all'uscita"
dichiarata · #14 confronto bit-exact con `_skyhook_returns` · #15 cache `skh_sigtab` ferma al 25/07
· #16 1,92 e' un punto nuovo, non "gia' noto" · #17 il crollo e' sotto la mediana del DVOL: verdetto
riscritto · #18-19 BTCDVOL future e PAXG registrati · #21 APT inactive, cache con rete.
Non applicate: nessuna. Il revisore ha trovato **due conclusioni che non reggevano** (il 2,6× e
l'"8/12 guadagna") e le ha smontate con numeri riprodotti: la regola *"chi rivede non e' chi ha
scritto"* ha pagato in una sessione.
**Test (Opus, agente separato): `tests/test_r0909_*.py`, 92 test, suite intera 1008/1008 verdi**
(erano 916). Coprono: ordine delle classi e riproduzione verbatim dei verdetti, `spot_causale` col
controllo positivo sul difetto che ripara, taglio alla larghezza, geometria `in_geom` derivata da
`VRP_CFG`, regola L-PULITA (anno pulito isolato ignorato), `_fetch_1h` su batch vuoti, P1 per
identita' (`W_TP01`, `XS_UNIVERSE`), zero rete e zero scritture in `data/`. **Due difetti trovati da
Opus nel verdetto di XRP, corretti:** un campione vuoto stampava "DILUISCE" (ora "NON MISURABILE",
P5) e il criterio per anno era cablato al 2024 invece che derivato dall'anno della lente (P1).
+57
View File
@@ -0,0 +1,57 @@
# Giornale di bordo — 2026-09-07
*Scritto 2026-09-08T00:36:58+00:00. Numeri misurati; nessuna interpretazione automatica.*
## Mercato
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|---|---|---|---|---|---|---|---|
| **BTC** | $79,100.00 | -1.59% | +0.68% | +21.85% | 47.1% | ↑ ↑ ↑ (3/3 su) | 38.8 (31° pctl 1a) |
| **ETH** | $2,490.35 | -0.98% | +0.91% | +29.98% | 70.6% | ↑ ↑ ↑ (3/3 su) | 52.6 (25° pctl 1a) |
## Libro
Equity **$4,484.18** · nozionale lordo $1,156 · leva lorda 0.26x · barra dati 2026-09-07 · 24 giri
| | TP01 | SKH01 | target | posizione | azione |
|---|---|---|---|---|---|
| **BTC** | +0.420 | flat | $+707 | $+704 | HOLD (a target) |
| **ETH** | +0.268 | flat | $+451 | $+452 | HOLD (a target) |
## P&L
- giorno: **$-18.75** di equity (24 letture)
- realizzato: +0.10 netto su 1 round-trip chiusi · 3 fill · fee 0.0066
- cumulato dall'arming: **$+3,886.12** di equity — di cui $+3,834.43 versati/prelevati -> trading **$+51.69** · TWR **+7.57%**
## Salute
- giri di `book_execute`: 24/24
- eta' feed SKH all'ultimo giro: 0 min
## Lettura
*Generata da regole dichiarate in `src/live/journal.py` — combina i numeri qui sopra e nient'altro. L'id fra parentesi quadre dice quale regola ha parlato. Non e' un giudizio: quello sta nella Nota.*
- `[stato]` A mercato su BTC $+704 (TP01 +0.420, SKH01 flat) e ETH $+452 (TP01 +0.268, SKH01 flat).
- `[leva]` Leva lorda **0.26x** su un tetto di 1.00x (`max_notional_per_asset_frac` 0.50 x 2 asset, letto da `config/live.json`): margine 0.74x.
- `[pnl]` Equity $-18.75 con 3 fill e 1 round-trip chiusi (realizzato $+0.10 netto).
- `[vol]` BTC: implicita 38.8 contro realizzata 30g 47.1 (sotto); DVOL al 31° pctl di un anno; IV-rank espandente 0.11 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
- `[vol]` ETH: implicita 52.6 contro realizzata 30g 70.6 (sotto); DVOL al 25° pctl di un anno; IV-rank espandente 0.13 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
- `[evidenza]` 📌 Campione a oggi: **76 giorni, 41 round-trip**. A questa taglia il P&L non distingue l'edge dalla fortuna — il criterio di morte del libro (`edge_watch`) lavora su una finestra di 36 mesi.
## Analisi (agente)
*Scritta da `claude-opus-5` il 2026-09-08T00:37:50+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
La giornata rompe tre chiusure positive di fila (+28.60, +6.93, +12.27) con $-18.75, la perdita giornaliera più grande della serie qui riportata. È quasi tutta valutazione, non attività: 3 fill, 1 round-trip, realizzato $+0.10 netto, con BTC -1.59% e ETH -0.98% e il libro long su entrambi. Il segno torna; la taglia non è verificabile qui, perché la pagina non scompone il P&L per asset.
Ciò che è cambiato davvero risale al salto del 04/09: le posizioni sono passate dai $+357 / $+214 di fine agosto ai $+704 / $+452 di oggi e ci restano, mentre la leva lorda resta 0.26x. A parità di rischio relativo la stessa percentuale di mercato si scrive con un numero di dollari più grande: le prossime giornate rosse sembreranno peggiori senza esserlo.
Insolito il quadro della volatilità: implicita sotto la realizzata su entrambi (38.8 contro 47.1; 52.6 contro 70.6) con DVOL al 31° e al 25° percentile di un anno. Il gate di VRP01 legge l'IV-rank (0.11 e 0.13) e resta chiuso, ma una lettura altrettanto compatibile è che il mercato tratti il movimento a 30 giorni (+21.85%, +29.98%) come già avvenuto.
TP01 porta segnali parziali (0.420, 0.268) e SKH01 è flat malgrado TSMOM 3/3 su: c'è tendenza, non breakout. Il cumulato $+3,886.12 è quasi tutto versato ($+3,834.43); resta $+51.69 di trading su 76 giorni e 41 round-trip, TWR +7.57% — campione che non separa edge e fortuna. Salute piena, 24/24 giri e feed a 0 min: niente da controllare a mano oggi.
## Nota
*(vuota — campo dell'operatore)*
+58
View File
@@ -0,0 +1,58 @@
# Giornale di bordo — 2026-09-08
*Scritto 2026-09-09T00:36:48+00:00. Numeri misurati; nessuna interpretazione automatica.*
## Mercato
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|---|---|---|---|---|---|---|---|
| **BTC** | $78,470.50 | -0.80% | +1.34% | +20.97% | 47.3% | ↑ ↑ ↑ (3/3 su) | 39.9 (40° pctl 1a) |
| **ETH** | $2,485.45 | -0.20% | +2.77% | +30.16% | 70.6% | ↑ ↑ ↑ (3/3 su) | 53.9 (33° pctl 1a) |
## Libro
Equity **$4,477.46** · nozionale lordo $1,149 · leva lorda 0.26x · barra dati 2026-09-08 · 24 giri
| | TP01 | SKH01 | target | posizione | azione |
|---|---|---|---|---|---|
| **BTC** | +0.415 | flat | $+697 | $+698 | HOLD (a target) |
| **ETH** | +0.268 | flat | $+449 | $+451 | HOLD (a target) |
## P&L
- giorno: **$-6.72** di equity (24 letture)
- realizzato: +0.00 netto su 1 round-trip chiusi · 2 fill · fee 0.0055
- cumulato dall'arming: **$+3,879.40** di equity — di cui $+3,834.43 versati/prelevati -> trading **$+44.97** · TWR **+7.41%**
## Salute
- giri di `book_execute`: 24/24
- eta' feed SKH all'ultimo giro: 0 min
## Lettura
*Generata da regole dichiarate in `src/live/journal.py` — combina i numeri qui sopra e nient'altro. L'id fra parentesi quadre dice quale regola ha parlato. Non e' un giudizio: quello sta nella Nota.*
- `[stato]` A mercato su BTC $+698 (TP01 +0.415, SKH01 flat) e ETH $+451 (TP01 +0.268, SKH01 flat).
- `[leva]` Leva lorda **0.26x** su un tetto di 1.00x (`max_notional_per_asset_frac` 0.50 x 2 asset, letto da `config/live.json`): margine 0.74x.
- `[pnl]` Equity $-6.72 con 2 fill e 1 round-trip chiusi (realizzato $+0.00 netto).
- `[drawdown]` 📌 Equity **-0.57%** sotto il picco ($4,502.93). ⚠️ e' un DD su letture ORARIE, non sul minimo intra-giorno.
- `[vol]` BTC: implicita 39.9 contro realizzata 30g 47.3 (sotto); DVOL al 40° pctl di un anno; IV-rank espandente 0.14 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
- `[vol]` ETH: implicita 53.9 contro realizzata 30g 70.6 (sotto); DVOL al 33° pctl di un anno; IV-rank espandente 0.15 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
- `[evidenza]` 📌 Campione a oggi: **77 giorni, 42 round-trip**. A questa taglia il P&L non distingue l'edge dalla fortuna — il criterio di morte del libro (`edge_watch`) lavora su una finestra di 36 mesi.
## Analisi (agente)
*Scritta da `claude-opus-5` il 2026-09-09T00:37:27+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
Terzo giorno sotto il picco del 06/09 ($4,502.93), e secondo consecutivo in rosso dopo i $-18.75 del 07/09: oggi $-6.72. La differenza rispetto alla settimana precedente al versamento non e' il segno ma la scala — le posizioni sono passate da BTC $+347/ETH $+213 a BTC $+698/ETH $+451, quindi oscillazioni di equity dello stesso ordine in dollari valgono ora meno in percentuale. Con realizzato $+0.00 su 1 round-trip e HOLD a target su entrambe le gambe, la perdita del giorno e' rivalutazione delle posizioni aperte contro BTC -0.80% ed ETH -0.20%, non attivita' di trading.
L'elemento insolito e' altrove: TSMOM 3/3 su su entrambi gli asset, BTC +20.97% e ETH +30.16% a 30 giorni, e nonostante questo TP01 sta a +0.415 e +0.268 con SKH01 flat — leva lorda 0.26x su un tetto di 1.00x. Il libro e' poco investito dentro il trend che dovrebbe essere il suo caso favorevole; se questo sia disciplina di sizing o segnale debole non e' misurabile qui.
Lettura alternativa altrettanto compatibile: a 0.26x di esposizione il P&L giornaliero e' quasi interamente beta di mercato, e i 77 giorni / 42 round-trip non permettono di distinguerlo dall'edge.
Merita un'occhiata umana il round-trip chiuso con realizzato esattamente $+0.00 a fronte di fee 0.0055.
## Nota
*(vuota — campo dell'operatore)*
+27
View File
@@ -1521,3 +1521,30 @@ una maschera a caso della stessa frequenza**.
**Gated sull'IV sottile** (la copertura dinamica che §46 non aveva provato): migliora, perde.
**Cosa lo riapre:** scadenza lunga che copra il grind (fuori dal vincolo ≤15g dell'operatore),
o un premio di varianza negativo su BTC. Non delta, tenor ≤15, gate IV: misurati.
- **CROLLI / CROLLO CATTURATO / XRP — 0 candidati, 1 debito, 2 conclusioni smontate in revisione (2026-09-09)** —
`r0909_{libro_nei_crolli,crash_catturato,xrp_terza_gamba,deribit_universo}.py`, diario
`2026-09-09-crolli-opzioni-monete.md`. Domanda dell'operatore: *"guadagnare anche nei crolli (opzioni se
serve), piu' monete ma sempre in Deribit"*. **(1) Il libro nei crolli**: perde nel GIORNO (0,48%/g su 160
giorni ≤ 5%, positivo nel 18%), immune-o-positivo per finestra (4 POSITIVO / 6 immune / 2 PERDE su 12), **> 0
in 8/8 peggiori finestre dal 2022** per la gamba short di SKH01 (108% dei guadagni, scomposizione esatta,
marcata all'uscita). Beta 0,0769 sulla finestra di §46 (riprodotto). Peggior giorno canonico 2025-10-10
3,38%: crollo con TP01 a 0,505 (lo squeeze di §33 e' sulla lente hourly). 37,5/62,5 batte 12/12 episodi
ma maxDD 12,1% vs 9,4%; gate fallisce in-sample (cache `skh_sigtab` ferma al 25/07). **(2) Crollo
catturato**: l'archivio bite copre con bid/ask il 1-5/06/2026 (BTC 25%, ETH 30%), scadenza 19/06, inverse.
ETH pre-crollo (4 strutture): f_net **0,74 = 0,714 del rally**; f a scadenza 1,04 ma **tautologico** (ambo le
gambe ITM ⇒ solo f_net); peggior MTM **1,08** dopo il taglio alla larghezza — la prima stesura diceva 2,58 con
"IV corta esplosa a 108 per lo skew": **artefatto** (quote 158% di spread a T→0; la IV 108 e' del 18/06, a T→0);
put δ−0,10 costa **1,92×** il modello (punto nuovo, sopra l'1,27 di §46) e paga come il modello (0,94-1,04);
ingressi durante il crollo bimodali (perde il 46%). **Crollo a vol BASSA** (picco DVOL 29°/46° pctl), gate
IV-rank di VRP01 chiuso: **la condizione di §3 e' soddisfatta nella forma, non nella sostanza → non si
riapre**. BTC pre-crollo non misurabile (griglia strike rada). **(3) XRP terza gamba** (harness di
`r0822_sol_leg`, congelato): **diluisce come SOL** — hold-out 0,169 in 0/24, FULL 0,206 (L-FULL) /
0,066 (2024+), un anno buono (2024), de-levering; L-PULITA dal 2024 (2023 0,57% >1% dal riferimento;
Coinbase delisto' XRP 2021-01→2023-07, riferimento Bitstamp prima); 5m flat 27-61%. Dato in
`data/raw/alt_xrp_*`. **(4) Venue**: 32 perp USDC, liquidi ≥$10M/g solo XRP/ETH/BTC/SOL; XS01 13/19 (APT
inactive); `BTCDVOL_USDC` future esiste ma vol 0 / spread 12%; PAXG perp non misurato (9 mesi, $0,18M/g).
**Debito nuovo (§5.18)**: `cblib.spot_series` + `asof` guarda un'ora avanti (feed 1h etichettato
all'apertura). **Errori dell'autore smontati dal revisore `fable`**: il 2,6× (artefatto) e "8/12 GUADAGNA"
(l'ordine delle classi era il verdetto: immune → POSITIVO da' 4+6). Nessun cambio a libro/pesi/config.
+17
View File
@@ -157,3 +157,20 @@
`fresh_5m`). **REGOLA: un guasto IN CORSO si misura al GIORNO PEGGIORE, non in media** — la prima
stesura confrontava "tutto" con "ultimi 7g" e diluiva un guasto di 2 giorni al 13.5%, commettendo
a 7 giorni lo stesso errore che dichiarava di evitare a 3 mesi. Diario `2026-07-30-vrp-quote-reali.md`.
- 🚨 **IL FEED 1h E' ETICHETTATO ALL'APERTURA, e `asof(ts)` guarda un'ora avanti (trovato in revisione
il 2026-09-09).** La barra 1h etichettata 20:00 chiude col 5m delle 20:55 (verificato: close 1h 20:00 =
close 5m 20:55 = 1603,5, mentre il 5m 19:55 fa 1572,95). Quindi `S.asof(ts)` su una serie indicizzata
con quell'etichetta restituisce la chiusura di **ts+1h**: un look-ahead di un'ora senza eccezioni e
senza NaN (D6, quarta occorrenza). Colpisce `cblib.spot_series` — cioe' il f 0,714 di §11 e ogni numero
di `cblib` con lo spot, compreso `ST = S.asof(exp)` che e' il prezzo di un'ora DOPO la scadenza.
`r0909_crash_catturato.spot_causale` sposta l'indice di +1h (chiusura = tempo in cui e' nota); `cblib`
**non e' riparato** (cambierebbe numeri pubblicati: va fatto con la rimisura di §11). Debito CLAUDE.md §5.18.
- **XRP** (`XRP/USDC:USDC`, dal 2022-03-16) ricostruito il 2026-09-09 con `rebuild_history --asset XRP` e
spostato in **`data/raw/alt_xrp_*.parquet`** (namespace di ricerca come SOL: fuori dal feed attivo, non
rinfrescato dal cron, `load_data("XRP")` fallisce, presidiato da `test_sol_leg` via `DERIBIT_INSTR`).
Certificazione per anno vs riferimento USD: **Coinbase non ha XRP fra 2021-01 e 2023-07** (delisting per
la causa SEC) → riferimento **Bitstamp** prima, Coinbase dopo; >1% 0,06% (2022) · 0,57% (2023) · ≤0,02%
(2024+); mediana 5-6 bps; **5m flat 27-61%** (BTC/ETH 0,1-2%, SOL 21%): il 5m e' illiquido. Cache del
riferimento in `data/_cache/` (gitignored, fuori backup: si riproduce con rete).
+68
View File
@@ -3969,3 +3969,71 @@ quota > 0**: l'ultimo migliaio compra ~€100/anno e butta via l'assicurazione);
a banda larga, non un tasso (libro a mercato il 22% dei giorni, TWR +10,8% in 70g, hold-out TP01
~+0,05, fisco 30% a 10a); fondo d'emergenza da dichiarare. Operativamente nulla: cap dinamico,
rilevatore di salti collaudato il 25/08. **In attesa di importo e data.**
---
## 74 — LIBRO-NEI-CROLLI (cosa fanno TP01 e SKH01 nei crolli, misurato invece che ricordato)
`scripts/research/r0909_libro_nei_crolli.py`, diario `2026-09-09-crolli-opzioni-monete.md`. **Libro,
pesi, config, cron INVARIATI.** Lente: sleeve di ricerca, ancora canonica, pesi importati da
`src/live/book` (P1); ⚠️ SKH01 marcato all'USCITA del trade (zero nell'87,7% dei giorni). Indice 50/50;
giorno di crollo ≤ 5%; 12 peggiori finestre di 20 g non sovrapposte + 9 episodi nominati prima.
**Nel GIORNO del crollo il libro PERDE** (0,48%/g su 160 giorni, positivo nel 18%; ≤ 10%: 0,51%/g con
SKH01 +0,42). **Per finestra**: 4 POSITIVO / 6 immune / 2 PERDE; libro > 0 in 8/12 e **in 8/8 dal 2022**
(mediana +3,3% contro indice 29%); le due perdite (2019, maggio 2021) sono TP01 long dentro un trend
non girato. **La gamba short di SKH01 fa il 108% dei guadagni** (scomposizione ESATTA long+short==intera,
assert). Beta **+0,0769** sulla finestra di §46 (riprodotto al millesimo). Peggior giorno canonico
2025-10-10 3,38%: un crollo con TP01 a 0,505 (lo squeeze di §33 sta sulla lente hourly, non si riproduce
qui). 37,5/62,5 (§26) batte 12/12 episodi, Sharpe 1,81 vs 1,82, maxDD 12,1% vs 9,4%;
`r0726_reeval_live_weight` da' `gate_pass False` su `delta_insample 0,0026` (cache segnali ferma al
25/07: stesse cifre del 26/07, e la condizione che fallisce e' in-sample).
🚨 **Errore dell'autore smontato in revisione:** la prima stesura provava GUADAGNA prima di immune e
stampava **8/12 GUADAGNA**; nell'ordine dichiarato sono 4+6. *L'ordine delle regole era il verdetto.*
**VERDETTO: `PERDE NEL GIORNO, POSITIVO PER FINESTRA DAL 2022, TUTTO SKH01 SHORT`** — nessun meccanismo
nuovo: quello che guadagna nei crolli e' gia' in produzione, e la leva che lo aumenta (il peso) e' chiusa dal gate.
## 75 — CROLLO-CATTURATO (il f delle opzioni su un crollo a quote vere)
`scripts/research/r0909_crash_catturato.py`. **Nessun ordine.** L'archivio bite copre con bid/ask il crollo
1-5/06/2026 (BTC 25%, ETH 30%; scadenza 19/06, 14-21 DTE, famiglia INVERSE); il campione di §11 partiva
dal fondo. ETH, ingressi pre-crollo (286 snapshot = **4 strutture**): f_net **0,74 = 0,714 del rally**;
f a scadenza 1,04 ma **tautologico** nell'83% (ambo le gambe ITM ⇒ solo f_net); peggior MTM **1,08**
dopo il taglio alla larghezza; put δ−0,10 costa **1,92×** il modello (punto nuovo sopra l'1,27 di §46)
e paga come il modello (0,94-1,04); ingressi durante il crollo bimodali (perde il 46%). BTC pre-crollo
non misurabile (griglia strike rada). **Crollo a vol BASSA**: picco DVOL al 29°/46° percentile, gate
IV-rank di VRP01 CHIUSO in tutte le ore. ⇒ la condizione di §3 e' soddisfatta nella forma, **non nella
sostanza: non si riapre**. Servono un crollo con IV-rank > 0,30 e la famiglia USDC-lineare.
🚨 **Due conclusioni dell'autore smontate in revisione:** «MTM 2,58× per lo skew (IV 108)» era un
artefatto (quote con 158% di spread a T→0 oltre la larghezza; la IV 108 e' del 18/06 a T→0); «f a
scadenza 1,04 = il modello prezza bene» era una tautologia. **Debito nuovo (CLAUDE.md §5.18):**
`cblib.spot_series` + `asof` guarda un'ora avanti (feed 1h etichettato all'apertura); qui corretto con
`spot_causale`, in `cblib` no (toccherebbe il 0,714 di §11).
**VERDETTO: `CATTURATO, MA A VOL BASSA E FUORI DAL GATE: f_net NON CAMBIA COL REGIME`** — nessuna riapertura.
## 76 — XRP-TERZA-GAMBA (l'unica altra moneta liquida di Deribit, coi meccanismi congelati)
`scripts/research/r0909_xrp_terza_gamba.py` (harness IMPORTATO da `r0822_sol_leg`). Dato in
`data/raw/alt_xrp_*` (namespace di ricerca, non rinfrescato dal cron). Certificazione: >1% dal
riferimento 0,06% (2022) · **0,57% (2023)** · 0,01-0,02% (2024+) — Coinbase dal rilisting 2023-07
(delisting SEC), Bitstamp prima; **L-PULITA dal 2024**, come SOL; 5m flat **27-61%**.
dSharpe hold-out **0,169 in 0/24 ancore** (SOL 0,166); FULL 0,206 (L-FULL) / 0,066 (2024+); dCAGR
2,27 / 1,32 pp; de-levering: L-FULL 1,23 vs 2g×0,974 **1,44**, 2024+ 1,54 vs 1,53 pari; per anno 2022
0,55 · 2023 0,66 · **2024 +0,54** · 2025 0,44 · 2026 0,25 (**1/5**). TP01 XRP Sharpe 0,01 /
hold-out 0,49; SKH01 XRP maxDD 40,1% (il criterio di selezione di SKH01-V2-DD fallisce, come SOL);
corr gamba-book +0,35/+0,37. Eseguibilita' non e' il vincolo (min 1 XRP = $1,42).
⚠️ Due difetti del verdetto trovati dai test (Opus) e corretti: campione vuoto stampava «DILUISCE»
(ora NON MISURABILE, P5); criterio per anno cablato al 2024 invece che derivato dalla lente (P1).
**VERDETTO: `XRP DILUISCE COME SOL — UN ANNO BUONO, HOLD-OUT 0/24`** — nessuna proposta.
## 77 — UNIVERSO-DERIBIT (lettura del venue, €0)
`scripts/research/r0909_deribit_universo.py` (API pubblica, JSON con ora in `data/_cache`, `--fresh`).
Lettura 2026-09-09 15:05Z: **32 perpetual USDC** aperti (+6 non aperti, fra cui **APT inactive**);
liquidi ≥ $10M/g **solo XRP ($38M, spread 4 bps), ETH, BTC, SOL ($13M)**; tutto il resto $0,01-7M
(TAO, HYPE, ADA, AVAX, NEAR…). XS01 **13/19** (mancano ARB, OP, APT, INJ, TIA, SEI → §50 resta).
Opzioni USDC-lineari su 7 sottostanti (SOL 676, HYPE 442, XRP 442, TRX, AVAX). Trovati in revisione:
**`BTCDVOL_USDC-30SEP26`** (unico long-vol diretto del venue: vol 0, OI 132, spread 12% → non
negoziabile) e **PAXG perp** (oro, dal 2024-12, $0,18M/g: non misurato, non morto, muri di SOL/XRP).
**VERDETTO: `QUATTRO STRUMENTI LIQUIDI, TRE GIA' NEL LIBRO O MISURATI`** — l'universo "piu' monete" di Deribit e' finito.
+385
View File
@@ -0,0 +1,385 @@
"""r0909 — IL CROLLO CATTURATO: il `f` di stress delle opzioni, misurato su quote VERE.
PERCHE' ESISTE. La decisione vincolante del 19/06 ("niente short-vol da modello in deploy", e la
gemella "niente long-vol scalp") dichiara UNA condizione che la riapre: *un `f` di stress reale
misurato su un crash catturato*. COLLAR01 (§71) e PAVIMENTO-LEVA (§72) chiudono con la stessa
condizione. Il 22/08 (§11) il campione con quote vere era "la strategia mentre STA FERMA":
19 settimane di rally, "l'unico episodio ad alta vol del campione e' un rally, non un crash".
FATTO NUOVO (2026-09-09): l'archivio ereditato `bite_archive.parquet` copre con bid E ask
il crollo del 1-5 giugno 2026 — BTC 25% e ETH 30% in 5 giorni (peggior giorno ETH 10,6%
il 05/06), su UNA scadenza (19 giugno, 14-21 DTE agli ingressi). Il campione del 22/08
partiva DOPO, dal fondo. Nessuno lo aveva usato: e' il crash catturato che la regola aspettava.
COSA MISURA (a ogni ora d'ingresso dal 30/05 al 04/06, BTC e ETH — l'ora e' un'ancora, si
riportano mediana e banda, mai il massimo):
A. la geometria di VRP01 (vendi put δ−0,28, compra put δ−0,10, stessa scadenza) a quote REALI:
credito al bid/ask, mark-to-market ora per ora dalle standing quotes, regolamento a scadenza
sullo spot certificato. Contro lo STESSO trade prezzato dal modello del sleeve (BS su DVOL,
`sleeves._bs_put`). f_stress = perdita reale / perdita modellata, a scadenza e al peggior MTM.
B. lo specchio LONG (compra la put δ−0,10 all'ask; e il debit spread 0,28/0,10): quanto del
payoff modellato si incassa davvero al bid — a scadenza (uscita passiva) e al miglior punto
del percorso (limite superiore con look-ahead, dichiarato).
C. il contesto: DVOL, RV realizzata, IV-rank causale (il gate di VRP01 era aperto o chiuso?),
e cosa dice il modello del sleeve (`_vrp_weekly_asset`) per le settimane di giugno.
LIMITI DICHIARATI PRIMA DEI NUMERI: n = 1 crollo, 1 scadenza per asset, tenor 14-21 giorni
(VRP01 e' a 7), 2-5 mila righe/giorno (40-125 strumenti/ora), `underlying_price` assente
nell'archivio (spot dal feed 1h certificato, D1), regolamento approssimato allo spot dell'ora
di scadenza (Deribit usa una TWAP di 30 min). La famiglia dell'archivio e' INVERSE (premi in
BTC/ETH; `ETH-19JUN26-1900-P`): VRP01 opererebbe sulla USDC-lineare, che il conto puo' marginare
e che NON e' nell'archivio (§11) — il f qui misurato e' della famiglia che il conto non negozia.
Un episodio e' un episodio: da' la TAGLIA di f nello stress, non la sua distribuzione.
DUE CORREZIONI DELLA REVISIONE (fable, 09/09), applicate:
* il feed 1h e' etichettato all'APERTURA (la barra 20:00 chiude alle 21:00): `S.asof(ts)`
guardava un'ora AVANTI. Qui lo spot e' `spot_causale` = chiusura dell'ultima barra chiusa a ts.
⚠️ `cblib.spot_series` ha lo stesso difetto (e quindi il f 0,714 di §11): registrato, non toccato.
* il costo di chiusura `(ask corta bid lunga)·spot` puo' superare la LARGHEZZA dello spread
(una quota da 158% di spread relativo a T→0): uno spread a rischio definito non costa mai piu'
della larghezza per chiudere (si lascia regolare). Il MTM e' tagliato alla larghezza e le ore
tagliate sono contate. Senza il taglio il "peggior MTM" era un artefatto di quote (2,6×).
* `f_exp` e' TAUTOLOGICO quando entrambe le gambe finiscono ITM (payoff = larghezza per reale
e modello): si riduce a una funzione di f_net. Si stampa la quota di trade in cui succede.
Nessun file di produzione toccato. Nessun ordine.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
import cblib as CB # noqa: E402
from src.portfolio.sleeves import VRP_CFG, _vrp_weekly_asset # noqa: E402
ARCHIVIO = ROOT / "data" / "raw" / "cb_chain" / "bite_archive.parquet"
ASSETS = ("BTC", "ETH")
EXP = pd.Timestamp("2026-06-19 08:00", tz="UTC")
INGRESSI = (pd.Timestamp("2026-05-30", tz="UTC"), pd.Timestamp("2026-06-05", tz="UTC")) # [da, a)
CROLLO = (pd.Timestamp("2026-06-01", tz="UTC"), pd.Timestamp("2026-06-06", tz="UTC"))
PRE = pd.Timestamp("2026-06-02", tz="UTC") # ingressi "prima del crollo" = prima del primo giorno a 7%/8% (il 01/06 BTC fece 3,1%: e' DENTRO "pre", dichiarato)
TOL_D = (0.08, 0.06) # geometria VRP01: |δ corta + 0,28| ≤ 0,08 e |δ lunga + 0,10| ≤ 0,06, altrimenti "fuori geometria"
SHORT_D, LONG_D = VRP_CFG["short_delta"], VRP_CFG["long_delta"] # P1: dal sleeve, non ridichiarati
FEE_FRAC = VRP_CFG["fee_frac"]
# listino Deribit opzioni (stesse costanti di r0822_vrp_real_quotes, verificate al venue il 22/08)
FEE_UNDERLYING, FEE_PREMIUM_CAP, FEE_DELIVERY = 0.0003, 0.125, 0.00015
def leg_fee(prem_base: float, spot: float) -> float:
return min(FEE_UNDERLYING * spot, FEE_PREMIUM_CAP * prem_base * spot)
def spot_causale(S: pd.Series) -> pd.Series:
"""Il feed 1h e' etichettato all'apertura: la chiusura della barra T e' nota a T+1h. Spostando
l'indice di +1h, `asof(ts)` restituisce l'ultima chiusura CONOSCIUTA a ts, mai una futura."""
out = S.copy(); out.index = out.index + pd.Timedelta(hours=1)
return out
def carica() -> pd.DataFrame:
a = pd.read_parquet(ARCHIVIO)
a["ts"] = pd.to_datetime(a["ts"], utc=True)
a["exp"] = pd.to_datetime(a["exp"], utc=True)
a = a[(a["option_type"] == "P") & (a["exp"] == EXP) & (a["ts"] >= INGRESSI[0]) & (a["ts"] <= EXP)]
a = a.drop_duplicates(subset=["ts", "instrument_name"], keep="last")
a["dte"] = (a["exp"] - a["ts"]).dt.total_seconds() / 86400.0
return a.reset_index(drop=True)
def bs_spread(S: float, Ks: float, Kl: float, T: float, sig: float) -> float:
return CB.bs_put(S, Ks, T, sig) - CB.bs_put(S, Kl, T, sig)
def bs_put_vec(S, K, T, sig):
"""`cblib.bs_put` vettorizzata (stessa formula, r=0); T≤0 → intrinseco."""
from scipy.stats import norm
S = np.asarray(S, float); T = np.asarray(T, float); sig = np.asarray(sig, float)
out = np.maximum(K - S, 0.0)
ok = (T > 0) & (sig > 0)
d1 = (np.log(S[ok] / K) + 0.5 * sig[ok] ** 2 * T[ok]) / (sig[ok] * np.sqrt(T[ok]))
out[ok] = K * norm.cdf(-(d1 - sig[ok] * np.sqrt(T[ok]))) - S[ok] * norm.cdf(-d1)
return out
class Quote:
"""Tabella ora × strumento (bid, ask, iv) costruita UNA volta per asset, con spot e DVOL per ora."""
def __init__(self, seg: pd.DataFrame, S: pd.Series, V: pd.Series):
q = seg.dropna(subset=["bid", "ask"]); q = q[(q["bid"] > 0) & (q["ask"] > 0)]
self.bid = q.pivot_table(index="ts", columns="instrument_name", values="bid")
self.ask = q.pivot_table(index="ts", columns="instrument_name", values="ask")
self.iv = q.pivot_table(index="ts", columns="instrument_name", values="iv")
idx = self.bid.index
self.spot = pd.Series([float(S.asof(t)) for t in idx], index=idx)
self.dvol = pd.Series([float(V.asof(t)) for t in idx], index=idx) / 100.0
self.T = pd.Series(np.maximum((EXP - idx).total_seconds().values / 86400.0, 0.0) / 365.25, index=idx)
def percorso(Q: Quote, dopo: pd.Timestamp, inst_s: str, inst_l: str, Ks: float, Kl: float) -> pd.DataFrame:
"""Ora per ora dopo l'ingresso: costo REALE di chiudere lo short spread (ricompra la corta
all'ask, rivende la lunga al bid), valore REALE di liquidare il debit spread (vende la corta
al bid, ricompra la lunga all'ask) e la put lunga (bid); piu' il valore MODELLATO (BS a DVOL_t)."""
if inst_s not in Q.bid.columns or inst_l not in Q.bid.columns:
return pd.DataFrame()
m = (Q.bid.index > dopo) & Q.bid[inst_s].notna() & Q.bid[inst_l].notna()
if not m.any():
return pd.DataFrame()
idx = Q.bid.index[m]
sp = Q.spot.loc[idx].values; dv = Q.dvol.loc[idx].values; T = Q.T.loc[idx].values
bs_, as_ = Q.bid.loc[idx, inst_s].values, Q.ask.loc[idx, inst_s].values
bl, al = Q.bid.loc[idx, inst_l].values, Q.ask.loc[idx, inst_l].values
mod_s, mod_l = bs_put_vec(sp, Ks, T, dv), bs_put_vec(sp, Kl, T, dv)
grezzo = (as_ - bl) * sp
return pd.DataFrame(dict(ts=idx, spot=sp, dvol=dv,
chiudi_short=np.minimum(grezzo, Ks - Kl), chiudi_grezzo=grezzo,
liquida_long=np.clip((bs_ - al) * sp, 0.0, Ks - Kl), liquida_put=bl * sp,
mod_spread=mod_s - mod_l, mod_put=mod_l,
iv_s=Q.iv.loc[idx, inst_s].values, iv_l=Q.iv.loc[idx, inst_l].values))
def trade(asset: str, ts: pd.Timestamp, snap: pd.DataFrame, Q: Quote,
S: pd.Series, V: pd.Series) -> dict | None:
legs = CB.pick_legs(snap, SHORT_D, LONG_D)
if legs is None:
return None
spot = float(S.asof(ts)); dvol = float(V.asof(ts)); T = (EXP - ts).total_seconds() / 86400.0 / 365.25
hist = V[V.index < ts]; ivr = float((hist < dvol).mean()) if len(hist) else np.nan
Ks, Kl = legs["k_short"], legs["k_long"]
# --- A. short spread (VRP01) — reale al bid/ask, modello a DVOL
cred = (legs["bid_short"] - legs["ask_long"]) * spot
if cred <= 0:
return None
cred_mod = bs_spread(spot, Ks, Kl, T, dvol / 100.0)
ST = float(S.asof(EXP))
payoff = max(Ks - ST, 0.0) - max(Kl - ST, 0.0)
fee_in = leg_fee(legs["bid_short"], spot) + leg_fee(legs["ask_long"], spot)
fee_out = sum(min(FEE_DELIVERY * ST, FEE_PREMIUM_CAP * max(K - ST, 0.0)) for K in (Ks, Kl) if K > ST)
pnl_real = cred - payoff - fee_in - fee_out
pnl_mod = cred_mod - payoff - FEE_FRAC * cred_mod
p = percorso(Q, ts, legs["inst_short"], legs["inst_long"], Ks, Kl)
if p.empty:
return None
mtm_real = cred - p["chiudi_short"] # P&L aperto se si chiude ORA a quote vere (tagliato alla larghezza)
mtm_mod = cred_mod - p["mod_spread"] # lo stesso trade riprezzato a BS/DVOL_t (costruzione nostra: il sleeve tiene a scadenza)
i_min = int(mtm_real.idxmin())
n_tagli = int((p["chiudi_grezzo"] > Ks - Kl).sum())
# --- B. specchio long: put δ−0,10 comprata all'ask; debit spread 0,28/0,10
costo_put = legs["ask_long"] * spot
mod_put = CB.bs_put(spot, Kl, T, dvol / 100.0)
costo_deb = (legs["ask_short"] - legs["bid_long"]) * spot
mod_deb = cred_mod
pay_put_exp = max(Kl - ST, 0.0)
pay_deb_exp = payoff
# il picco del percorso e' un limite SUPERIORE con look-ahead: si dichiara, non si vende
i_put = int(p["liquida_put"].idxmax()); i_deb = int(p["liquida_long"].idxmax())
risk = (Ks - Kl) - cred
in_geom = abs(legs["d_short"] - SHORT_D) <= TOL_D[0] and abs(legs["d_long"] - LONG_D) <= TOL_D[1]
return dict(
in_geom=bool(in_geom),
asset=asset, ts=ts, dte=(EXP - ts).total_seconds() / 86400.0, spot=spot, dvol=dvol, ivrank=ivr,
k_short=Ks, k_long=Kl, d_short=legs["d_short"], d_long=legs["d_long"], width=Ks - Kl,
iv_s0=legs["iv_short"], iv_l0=legs["iv_long"], ST=ST, und_ret=ST / spot - 1.0,
cred_real=cred, cred_mod=cred_mod, f_net=cred / cred_mod if cred_mod > 0 else np.nan,
risk=risk, pnl_real=pnl_real, pnl_mod=pnl_mod,
perdita_su_risk=-pnl_real / risk, perdita_su_coll=-pnl_real / Ks,
f_exp=(-pnl_real / -pnl_mod) if pnl_mod < 0 and pnl_real < 0 else np.nan,
mtm_real_min=float(mtm_real.min()), mtm_mod_min=float(mtm_mod.min()),
mtm_min_ts=p["ts"].iloc[i_min], n_tagli=n_tagli, n_ore=int(len(p)),
iv_s_at_min=float(p["iv_s"].iloc[i_min]), iv_l_at_min=float(p["iv_l"].iloc[i_min]), dvol_at_min=float(p["dvol"].iloc[i_min]) * 100,
itm_pieno=bool(ST <= Kl), # entrambe le gambe ITM: payoff = larghezza, f_exp si riduce a f_net
f_mtm=(mtm_real.min() / mtm_mod.min()) if mtm_mod.min() < 0 and mtm_real.min() < 0 else np.nan,
spot_min=float(p["spot"].min()), iv_s_max=float(p["iv_s"].max()), iv_l_max=float(p["iv_l"].max()),
n_ore_percorso=int(len(p)),
# long
costo_put=costo_put, mod_put=mod_put, f_put_in=costo_put / mod_put if mod_put > 0 else np.nan,
put_exp=pay_put_exp - costo_put, put_exp_mod=pay_put_exp - mod_put,
put_best=float(p["liquida_put"].iloc[i_put]) - costo_put, put_best_mod=float(p["mod_put"].iloc[i_put]) - mod_put,
put_best_ts=p["ts"].iloc[i_put], bleed_put=costo_put / spot,
costo_deb=costo_deb, mod_deb=mod_deb,
deb_exp=pay_deb_exp - costo_deb, deb_exp_mod=pay_deb_exp - mod_deb,
deb_best=float(p["liquida_long"].iloc[i_deb]) - costo_deb, deb_best_mod=float(p["mod_spread"].iloc[i_deb]) - mod_deb,
)
def misura() -> dict:
a = carica()
out = {"trades": {}, "contesto": {}, "sleeve": {}}
for asset in ASSETS:
seg = a[a["asset"] == asset]
S, V = spot_causale(CB.spot_series(asset)), CB.dvol_series(asset)
Q = Quote(seg, S, V)
rows = []
for ts in sorted(seg["ts"].unique()):
ts = pd.Timestamp(ts)
if not (INGRESSI[0] <= ts < INGRESSI[1]):
continue
t = trade(asset, ts, seg[seg["ts"] == ts], Q, S, V)
if t:
rows.append(t)
out["trades"][asset] = pd.DataFrame(rows)
# contesto: DVOL, RV5 e RV1 sul crollo
px = S[(S.index >= pd.Timestamp("2026-05-20", tz="UTC")) & (S.index <= EXP)]
pd_ = px.resample("1D").last().dropna() # chiusura del giorno = ultima chiusura nota entro le 24:00
hist_all = V[V.index < CROLLO[0]]
lr = np.log(pd_).diff()
rv5 = lr.rolling(5).std() * np.sqrt(365.25) * 100
dv = V[(V.index >= pd.Timestamp("2026-05-20", tz="UTC")) & (V.index <= EXP)]
hist = V[V.index < CROLLO[0]]
out["contesto"][asset] = dict(
spot=pd_, ret=lr, rv5=rv5, dvol=dv,
dvol_max_crollo=float(dv[(dv.index >= CROLLO[0]) & (dv.index <= CROLLO[1])].max()),
dvol_pre=float(V.asof(CROLLO[0])),
pctl_picco=float((hist_all < V[(V.index >= CROLLO[0]) & (V.index <= CROLLO[1])].max()).mean()),
ivr_pre=float((hist < V.asof(CROLLO[0])).mean()),
rv5_max=float(rv5[(rv5.index >= CROLLO[0]) & (rv5.index <= CROLLO[1] + pd.Timedelta(days=3))].max()),
caduta=float(pd_[CROLLO[0]:CROLLO[1]].min() / pd_[:CROLLO[0]].iloc[-1] - 1.0),
)
wk = _vrp_weekly_asset(asset)
out["sleeve"][asset] = wk[(wk.index >= pd.Timestamp("2026-05-10", tz="UTC")) & (wk.index <= pd.Timestamp("2026-07-05", tz="UTC"))]
return out
def sintesi(m: dict) -> dict:
s = {}
for asset in ASSETS:
t = m["trades"][asset]
if t.empty:
s[asset] = None; continue
n_fuori = int((~t["in_geom"]).sum())
t = t[t["in_geom"]] # la griglia di strike dell'archivio e' rada: fuori geometria = altra struttura
if t.empty:
s[asset] = dict(n=0, n_fuori=n_fuori); continue
pre = t[t["ts"] < PRE] # ingressi PRIMA del crollo: il caso della regola
dur = t[(t["ts"] >= PRE) & (t["ts"] < CROLLO[1] - pd.Timedelta(days=1))] # DURANTE (02-04/06): lente secondaria
med = lambda c, d=t: float(np.nanmedian(d[c])) if len(d) else np.nan
b = lambda c, d=t: (float(np.nanpercentile(d[c], 10)), float(np.nanpercentile(d[c], 90))) if len(d) else (np.nan, np.nan)
s[asset] = dict(
n=int(len(t)), n_pre=int(len(pre)), n_fuori=n_fuori,
d_short=med("d_short", pre), d_long=med("d_long", pre), width=med("width", pre), ST=float(t["ST"].iloc[0]),
spot_pre=med("spot", pre),
dte=(float(t["dte"].min()), float(t["dte"].max())),
f_net=med("f_net"), f_net_b=b("f_net"),
ivrank=(float(t["ivrank"].min()), float(t["ivrank"].max())),
gate_aperto=float((t["ivrank"] >= VRP_CFG["gate_ivr"]).mean()),
perdita_su_risk=med("perdita_su_risk", pre), perdita_su_risk_b=b("perdita_su_risk", pre),
perdita_su_coll=med("perdita_su_coll", pre),
pnl_real=med("pnl_real", pre), pnl_mod=med("pnl_mod", pre),
f_exp=med("f_exp", pre), f_exp_b=b("f_exp", pre), n_f_exp=int(pre["f_exp"].notna().sum()),
f_mtm=med("f_mtm", pre), f_mtm_b=b("f_mtm", pre),
mtm_real_min=med("mtm_real_min", pre), mtm_mod_min=med("mtm_mod_min", pre),
n_tagli=int(pre["n_tagli"].sum()) if len(pre) else 0, n_ore=int(pre["n_ore"].sum()) if len(pre) else 0,
iv_s_at_min=med("iv_s_at_min", pre), dvol_at_min=med("dvol_at_min", pre),
q_itm_pieno=float(pre["itm_pieno"].mean()) if len(pre) else np.nan,
n_strutture=int(pre.groupby(["k_short", "k_long"]).ngroups) if len(pre) else 0,
f_put_in=med("f_put_in", pre), bleed_put=med("bleed_put", pre),
put_exp=med("put_exp", pre), put_exp_mod=med("put_exp_mod", pre),
put_best=med("put_best", pre), put_best_mod=med("put_best_mod", pre),
f_put_exp=med_ratio(pre, "put_exp", "put_exp_mod"), f_put_best=med_ratio(pre, "put_best", "put_best_mod"),
deb_exp=med("deb_exp", pre), deb_exp_mod=med("deb_exp_mod", pre),
deb_best=med("deb_best", pre), deb_best_mod=med("deb_best_mod", pre),
f_deb_exp=med_ratio(pre, "deb_exp", "deb_exp_mod"), f_deb_best=med_ratio(pre, "deb_best", "deb_best_mod"),
iv_s0=med("iv_s0", pre), iv_s_max=med("iv_s_max", pre), iv_l0=med("iv_l0", pre), iv_l_max=med("iv_l_max", pre),
und_ret=med("und_ret", pre), spot_min=med("spot_min", pre),
durante=dict(n=int(len(dur)), f_exp=med("f_exp", dur), f_mtm=med("f_mtm", dur),
perdita_su_risk=med("perdita_su_risk", dur), f_net=med("f_net", dur),
q_perde=float((dur["pnl_real"] < 0).mean()) if len(dur) else np.nan,
pnl_p10=float(np.nanpercentile(dur["pnl_real"], 10)) if len(dur) else np.nan,
pnl_p90=float(np.nanpercentile(dur["pnl_real"], 90)) if len(dur) else np.nan,
d_short=med("d_short", dur), d_long=med("d_long", dur), width=med("width", dur),
pnl_real=med("pnl_real", dur), pnl_mod=med("pnl_mod", dur)),
)
return s
def med_ratio(d: pd.DataFrame, a: str, b: str, minimo: float = 5.0) -> float:
"""Mediana del rapporto reale/modello sulle righe in cui il modello vale almeno `minimo` $
(un payoff modellato ≈ 0 non da' un rapporto, da' un'altra domanda: si stampano i dollari)."""
x = d[(d[b] >= minimo)]
return float(np.nanmedian(x[a] / x[b])) if len(x) else np.nan
def verdetto(s: dict) -> str:
parti = []
for asset in ASSETS:
r = s.get(asset)
if not r or r.get("n", 0) == 0:
parti.append(f"{asset}: NON misurabile — {r.get('n_fuori', 0) if r else 0} snapshot tutti FUORI geometria (griglia di strike rada)"); continue
if not np.isfinite(r["f_exp"]):
parti.append(f"{asset}: nessun ingresso pre-crollo in geometria (solo DURANTE: f_net {r['durante']['f_net']:.2f}, perde il {r['durante']['q_perde']*100:.0f}%)"); continue
parti.append(f"{asset}: f_net {r['f_net']:.2f} (22/08 in rally 0,714); a scadenza f {r['f_exp']:.2f} ma TAUTOLOGICO nel "
f"{r['q_itm_pieno']*100:.0f}% dei trade (entrambe le gambe ITM ⇒ dipende solo da f_net; {r['n_strutture']} strutture, non {r['n_pre']} osservazioni); "
f"peggior MTM tagliato alla larghezza f {r['f_mtm']:.2f} (quote oltre la larghezza nel {r['n_tagli']/max(1, r['n_ore'])*100:.1f}% delle ore); "
f"gate IV-rank {'APERTO' if r['gate_aperto'] > 0.5 else 'CHIUSO'} nel {r['gate_aperto']*100:.0f}% delle ore")
esito = ("IL CROLLO E' CATTURATO MA A VOL BASSA E FUORI DAL GATE DI VRP01: il f di stress che la regola del 19/06 aspettava "
"NON e' misurato nella sostanza — cio' che e' misurato e' che il rapporto di credito f_net non cambia col regime")
return esito + "" + "; ".join(parti) + " — n = 1 crollo, 1 scadenza per asset, famiglia INVERSE"
def pct(x): return f"{x*100:+.1f}%"
def main() -> None:
print("=" * 104)
print(" r0909 — IL CROLLO CATTURATO (1-5 giugno 2026): f di stress su quote VERE, scadenza 19/06")
print("=" * 104)
m = misura(); s = sintesi(m)
for asset in ASSETS:
c = m["contesto"][asset]; r = s[asset]
print(f"\n [{asset}] CONTESTO — caduta {pct(c['caduta'])} dal 31/05 al minimo di 1-5/06; "
f"DVOL pre {c['dvol_pre']:.1f} (IV-rank causale {c['ivr_pre']:.2f}) → max {c['dvol_max_crollo']:.1f} "
f"(= {c['pctl_picco']*100:.0f}° percentile storico: il PICCO del crollo sta sotto la mediana); RV5 max {c['rv5_max']:.0f}")
print(" giorno spot ret DVOL RV5")
for d in pd.date_range("2026-05-28", "2026-06-08", tz="UTC"):
if d in c["spot"].index:
dv = c["dvol"].asof(d)
print(f" {d.date()} {c['spot'][d]:>8.0f} {pct(c['ret'].get(d, np.nan)):>7} {dv:>5.1f} {c['rv5'].get(d, np.nan):>5.0f}")
if r is None or r.get("n", 0) == 0:
print(f" nessun trade in geometria VRP01: {r.get('n_fuori', 0) if r else 0} snapshot fuori geometria "
f"(la griglia di strike dell'archivio in quei giorni non ha le δ−0,28/0,10)"); continue
t = m["trades"][asset]; t = t[t["in_geom"]]
print(f"\n [{asset}] A. VRP01 a quote vere — {r['n']} snapshot d'ingresso in geometria ({r['n_pre']} prima del {PRE.date()}; "
f"{r['n_fuori']} scartati fuori geometria), DTE {r['dte'][0]:.0f}-{r['dte'][1]:.0f}, IV-rank {r['ivrank'][0]:.2f}-{r['ivrank'][1]:.2f}")
print(f" geometria mediana pre-crollo: δ corta {r['d_short']:+.2f} / δ lunga {r['d_long']:+.2f}, larghezza {r['width']:.0f}, "
f"spot d'ingresso {r['spot_pre']:.0f}, regolamento {EXP.date()} a {r['ST']:.0f}")
print(f" credito reale/modello f_net {r['f_net']:.2f} [{r['f_net_b'][0]:.2f}, {r['f_net_b'][1]:.2f}] "
f"(22/08: 0,714 in rally)")
print(f" ingressi PRE-crollo, mediana: P&L reale ${r['pnl_real']:+.0f} vs modello ${r['pnl_mod']:+.0f} per unita' "
f"→ perdita {r['perdita_su_risk']*100:.0f}% del rischio [{r['perdita_su_risk_b'][0]*100:.0f}, {r['perdita_su_risk_b'][1]*100:.0f}] "
f"(100% = perdita MASSIMA dello spread; oltre 100 sono le fee), "
f"{r['perdita_su_coll']*100:.1f}% del collaterale")
print(f" f a SCADENZA {r['f_exp']:.2f} [{r['f_exp_b'][0]:.2f}, {r['f_exp_b'][1]:.2f}] su {r['n_f_exp']} snapshot = {r['n_strutture']} strutture "
f"— TAUTOLOGICO nel {r['q_itm_pieno']*100:.0f}% (entrambe le gambe ITM: payoff = larghezza per reale e modello, resta solo f_net)")
print(f" peggior MTM (chiusura tagliata alla larghezza; quote oltre la larghezza nel {r['n_tagli']/max(1, r['n_ore'])*100:.1f}% delle ore-percorso): "
f"f {r['f_mtm']:.2f} [{r['f_mtm_b'][0]:.2f}, {r['f_mtm_b'][1]:.2f}] (reale ${r['mtm_real_min']:+.0f} / modello ${r['mtm_mod_min']:+.0f}); "
f"a quell'ora IV corta {r['iv_s_at_min']:.0f} vs DVOL {r['dvol_at_min']:.0f}")
print(f" IV gamba corta {r['iv_s0']:.0f} → max {r['iv_s_max']:.0f} (il max e' a T→0, non nel crollo); gamba lunga {r['iv_l0']:.0f} → max {r['iv_l_max']:.0f}; "
f"sottostante a scadenza {pct(r['und_ret'])}, minimo {r['spot_min']:.0f}")
du = r["durante"]
print(f" lente secondaria — ingressi DURANTE il crollo (02-04/06, {du['n']} snapshot): δ {du['d_short']:+.2f}/{du['d_long']:+.2f}, "
f"larghezza {du['width']:.0f}, f_net {du['f_net']:.2f}, P&L reale ${du['pnl_real']:+.0f} vs modello ${du['pnl_mod']:+.0f}, "
f"perde nel {du['q_perde']*100:.0f}% (p10 ${du['pnl_p10']:+.0f} / p90 ${du['pnl_p90']:+.0f}: bimodale, la mediana nasconde le perdite), "
f"f scadenza {du['f_exp']:.2f} / peggior MTM {du['f_mtm']:.2f}")
print(f"\n [{asset}] B. LO SPECCHIO LONG (ingressi PRE-crollo, mediana per unita')")
print(f" put δ−0,10 all'ask: costo reale/modello {r['f_put_in']:.2f}, bleed {r['bleed_put']*100:.2f}% dello spot")
print(f" a scadenza: reale ${r['put_exp']:+.0f} vs modello ${r['put_exp_mod']:+.0f} → f_payoff {r['f_put_exp']:.2f}")
print(f" al PICCO del percorso (look-ahead, limite sup.): reale ${r['put_best']:+.0f} vs modello ${r['put_best_mod']:+.0f} → f {r['f_put_best']:.2f}")
print(f" debit spread 0,28/0,10: a scadenza reale ${r['deb_exp']:+.0f} vs modello ${r['deb_exp_mod']:+.0f} → f {r['f_deb_exp']:.2f}; "
f"al picco ${r['deb_best']:+.0f} vs ${r['deb_best_mod']:+.0f} → f {r['f_deb_best']:.2f}")
print(f"\n [{asset}] C. IL MODELLO DEL SLEEVE (`_vrp_weekly_asset`, 0.0 = settimana saltata dai gate) — rendimento settimanale su collaterale:")
print(" " + " ".join(f"{d.date()} {v*100:+.2f}%" for d, v in m["sleeve"][asset].items()))
print(f"\n snapshot d'ingresso (primi 6): " + " | ".join(
f"{row.ts.strftime('%m-%d %H')}h K{row.k_short:.0f}/{row.k_long:.0f} cred ${row.cred_real:.0f} pnl ${row.pnl_real:+.0f} mtm ${row.mtm_real_min:+.0f}"
for row in t.head(6).itertuples()))
print("\n VERDETTO: " + verdetto(s))
print("=" * 104)
if __name__ == "__main__":
main()
+125
View File
@@ -0,0 +1,125 @@
"""r0909 — L'UNIVERSO DERIBIT LETTO OGGI: quali "altre monete" esistono davvero sul venue.
Domanda dell'operatore: "anche piu' monete, ma sempre in Deribit". Prima di misurare una
moneta si legge il venue (N10: la verifica esterna a €0 si fa PRIMA, e sul venue). Questo
script interroga l'API PUBBLICA Deribit (nessuna credenziale, nessun ordine) e stampa:
1. i perpetual USDC-lineari aperti, con data di listing, lotto minimo in $, volume 24h,
spread top-of-book, open interest e funding — ordinati per volume;
2. quante delle 19 gambe di XS01 sono quotate (il 23/08 erano 14/19, 11 con ≥1 anno);
3. le famiglie di opzioni (inverse BTC/ETH; USDC-lineari per sottostante).
Una lettura e' UN istante (come r0822_alt_options): il file salva il JSON grezzo in
`data/_cache/` con l'ora della lettura, cosi' il numero stampato ha la sua fonte.
Cosa NON fa: nessun backtest, nessuna proposta. Dice solo quali strumenti esistono e quanto
sono negoziabili oggi — il filtro che decide cosa vale la pena misurare (C6).
"""
from __future__ import annotations
import json
import sys
import time
import urllib.request
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from src.portfolio.sleeves import XS_UNIVERSE # noqa: E402 (P1: l'universo si importa)
API = "https://www.deribit.com/api/v2/public/"
CACHE = ROOT / "data" / "_cache" / "r0909_deribit_universo.json"
SOGLIA_LIQUIDO_USD = 10e6 # volume 24h da cui una gamba e' "liquida" per un libro da $4-5k
def get(path: str) -> dict:
req = urllib.request.Request(API + path, headers={"User-Agent": "PythagorasGoal-research"})
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)["result"]
def leggi(usa_cache: bool = True) -> dict:
if usa_cache and CACHE.exists():
return json.loads(CACHE.read_text())
d = dict(letto_a=datetime.now(timezone.utc).isoformat(timespec="seconds"),
fut=get("get_instruments?currency=USDC&kind=future"),
bs=get("get_book_summary_by_currency?currency=USDC&kind=future"),
opt_usdc=get("get_instruments?currency=USDC&kind=option"),
opt_inv={c: len(get(f"get_instruments?currency={c}&kind=option")) for c in ("BTC", "ETH")})
CACHE.parent.mkdir(parents=True, exist_ok=True)
CACHE.write_text(json.dumps(d))
return d
def tabella(d: dict) -> list[dict]:
perps = {i["base_currency"]: i for i in d["fut"] if i["instrument_name"].endswith("PERPETUAL") and i["state"] == "open"}
bs = {b["instrument_name"]: b for b in d["bs"]}
rows = []
for c, i in perps.items():
b = bs.get(i["instrument_name"], {})
bid, ask, mark = b.get("bid_price"), b.get("ask_price"), b.get("mark_price") or 0.0
rows.append(dict(
coin=c, listato=time.strftime("%Y-%m-%d", time.gmtime(i["creation_timestamp"] / 1000)),
min_amt=i["min_trade_amount"], min_usd=i["min_trade_amount"] * mark,
vol_usd=b.get("volume_usd") or 0.0,
spread_bps=((ask - bid) / ((ask + bid) / 2) * 1e4) if bid and ask else float("nan"),
oi_usd=(b.get("open_interest") or 0.0) * mark, funding_8h=b.get("funding_8h"),
xs01=c in XS_UNIVERSE))
return sorted(rows, key=lambda r: -r["vol_usd"])
def sintesi(rows: list[dict], d: dict) -> dict:
listati = {r["coin"] for r in rows}
liquidi = [r["coin"] for r in rows if r["vol_usd"] >= SOGLIA_LIQUIDO_USD]
bs = {b["instrument_name"]: b for b in d["bs"]}
inattivi = sorted(i["instrument_name"] for i in d["fut"] if i["instrument_name"].endswith("PERPETUAL") and i["state"] != "open")
datati = [i for i in d["fut"] if not i["instrument_name"].endswith("PERPETUAL")]
dvol_fut = []
for i in datati:
if "DVOL" not in i["instrument_name"]:
continue
b = bs.get(i["instrument_name"], {})
dvol_fut.append(dict(nome=i["instrument_name"], stato=i["state"], min_amt=i["min_trade_amount"],
vol_usd=b.get("volume_usd") or 0.0, oi=b.get("open_interest") or 0.0,
bid=b.get("bid_price"), ask=b.get("ask_price")))
return dict(
n_perp=len(rows), liquidi=liquidi, inattivi=inattivi, n_datati=len(datati), dvol_fut=dvol_fut,
xs01_listate=sorted(c for c in XS_UNIVERSE if c in listati),
xs01_mancanti=[c for c in XS_UNIVERSE if c not in listati],
opt_inv=d["opt_inv"], opt_usdc=Counter(i["base_currency"] for i in d["opt_usdc"]).most_common(),
letto_a=d["letto_a"])
def verdetto(s: dict) -> str:
return (f"{s['n_perp']} perpetual USDC; LIQUIDI (≥${SOGLIA_LIQUIDO_USD/1e6:.0f}M/g): {', '.join(s['liquidi'])}; "
f"XS01 {len(s['xs01_listate'])}/19 quotate, mancano {', '.join(s['xs01_mancanti'])}; "
f"opzioni USDC-lineari su {len(s['opt_usdc'])} sottostanti — lettura del {s['letto_a']}")
def main() -> None:
d = leggi(usa_cache="--fresh" not in sys.argv)
rows = tabella(d); s = sintesi(rows, d)
print("=" * 110)
print(f" r0909 — UNIVERSO DERIBIT (API pubblica, lettura {s['letto_a']}; --fresh per rileggere)")
print("=" * 110)
print(f"\n [1] PERPETUAL USDC-LINEARI APERTI: {s['n_perp']}")
print(f" {'coin':<9}{'listato':>11}{'min $':>8}{'vol 24h M$':>12}{'spread bps':>12}{'OI M$':>8}{'funding 8h':>12} XS01")
for r in rows:
print(f" {r['coin']:<9}{r['listato']:>11}{r['min_usd']:>8.2f}{r['vol_usd']/1e6:>12.2f}{r['spread_bps']:>12.1f}"
f"{r['oi_usd']/1e6:>8.2f}{(r['funding_8h'] or 0)*1e4:>+11.2f}bp {'<--' if r['xs01'] else ''}")
print(f" perpetual NON aperti (stato ≠ open): {s['inattivi'] or 'nessuno'}")
print(f"\n [1bis] FUTURE USDC DATATI: {s['n_datati']} — fra cui il future sul DVOL, l'unico strumento long-vol DIRETTO del venue "
f"(visto dalla revisione del 09/09: non in memoria):")
for f in s["dvol_fut"]:
spr = (f["ask"] - f["bid"]) / ((f["ask"] + f["bid"]) / 2) * 100 if f["bid"] and f["ask"] else float("nan")
print(f" {f['nome']:<28} {f['stato']:<8} min {f['min_amt']} vol24h ${f['vol_usd']/1e6:.2f}M OI {f['oi']:.0f} "
f"bid/ask {f['bid']}/{f['ask']} (spread {spr:.0f}%) → non negoziabile a questi numeri; e in contango sanguina come la put")
print(f"\n [2] XS01: {len(s['xs01_listate'])}/19 gambe quotate; mancano {s['xs01_mancanti']}")
print(f" (23/08, §50: 14/19 quotate, 11 con ≥1 anno; sulle 11 il meccanismo collassa 1,265 → 0,116 — breadth, non capitale)")
print(f"\n [3] OPZIONI: inverse {s['opt_inv']}; USDC-lineari per sottostante: {s['opt_usdc']}")
print("\n VERDETTO: " + verdetto(s))
print("=" * 110)
if __name__ == "__main__":
main()
+338
View File
@@ -0,0 +1,338 @@
"""r0909 — IL LIBRO NEI CROLLI: revisione dei sistemi adottati, misurata invece che ricordata.
DOMANDA DELL'OPERATORE (2026-09-09): "trova una soluzione per guadagnare anche nei crolli".
Prima di cercare un meccanismo nuovo, questo script misura cosa fanno OGGI i due sleeve live
(TP01 long-flat 75% + SKH01 long/short 25%) nei crolli — giorno per giorno e per episodio —
e da dove viene, se c'e', il guadagno. La memoria dice tre cose che qui vengono RIPRODOTTE
(M23: far riprodurre alla macchina il numero vecchio prima di pubblicarne uno nuovo):
(a) beta del libro al sottostante +0,076 (§46) → "non si assicura un libro gia' piatto";
(b) nel sinistro TP01 va flat e SKH01 si gira short e guadagna (§26);
(c) il peggior giorno del libro: §33 lo trova in uno short squeeze di SKH01 (15,77% di sleeve,
2020-03-13) sulla lente HOURLY — su questa lente (canonica, sleeve giornalieri) NON si
riproduce: §33 stesso nota che la canonica da' il 2025-10-10. Qui si stampa e si dichiara.
LENTE, dichiarata: sleeve di ricerca `sleeves._tp01_returns` / `_skyhook_returns` (ancora
canonica 00:00 UTC, fee 0,10% RT, funding NON modellato, come ogni numero del progetto).
⚠️ SKH01 e' MARCATO ALL'USCITA del trade (`harness.backtest_signals` muove l'equity alla
chiusura): la serie giornaliera e' zero nell'87,7% dei giorni, e uno short aperto in una finestra
e chiuso fuori e' accreditato FUORI. E' il motivo per cui la tabella per GIORNO mostra SKH01 ≈ 0
e quella per FINESTRA mostra il guadagno. NON e' la serie del libro live (armato dal 2026-06-20): i crolli del 2026 (5 febbraio
14%/15%, 1-5 giugno 25%/30%) sono PRIMA dell'arming, quindi qui sono backtest, non fill.
DEFINIZIONI, scritte prima di guardare i numeri:
- indice = 50/50 BTC/ETH, ribilanciato ogni giorno (la stessa geometria del libro);
- giorno di crollo = indice ≤ 5% (severo: ≤ 10%);
- episodio = le 12 peggiori finestre di 20 giorni NON sovrapposte dell'indice, piu' un elenco
NOMINATO di episodi noti (COVID, maggio 2021, LUNA, FTX, agosto 2024, febbraio e giugno 2026);
- classi ESCLUSIVE, in quest'ordine: "immune" = |libro| < 1/10 dell'indice (col segno stampato);
altrimenti "POSITIVO" se libro > 0, "PERDE" se < 0. Si stampa anche il conteggio nudo libro > 0.
(Prima stesura: POSITIVO provato prima di immune → 8/12; con l'ordine dichiarato qui 4/12 + 6
immune. L'ordine delle regole ERA il verdetto: corretto in revisione, si stampano entrambi.)
- beta al ribasso = pendenza OLS del libro sull'indice nei soli giorni con indice < 0.
PESI: 75/25 e' decisione vincolante (confermata 3 volte, §3). Il 37,5/62,5 di §26 compare qui
SOLO come riga informativa negli stessi episodi — non e' una proposta: la riapre solo
`weights_tilt_null`, che finora fallisce.
Nessun file di produzione toccato. Nessun ordine.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from src.backtest.harness import backtest_signals # noqa: E402
from src.data.downloader import load_data # noqa: E402
from src.live.book import W_SKH, W_TP01 # noqa: E402 (i pesi si IMPORTANO, non si ridichiarano: P1)
from src.portfolio.sleeves import _skyhook_returns, _tp01_returns # noqa: E402
from src.strategies.skyhook import SKH01_V2_DD, build_frames, skyhook_entries # noqa: E402
from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio, resample_1d # noqa: E402
ASSETS = ("BTC", "ETH")
SOGLIA_CROLLO = -0.05
SOGLIA_SEVERO = -0.10
FINESTRA = 20
N_EPISODI = 12
BETA_MEMORIA = 0.076 # §46, da riprodurre SULLA SUA FINESTRA (DVOL: 2021-03-24+)
FINESTRA_46 = pd.Timestamp("2021-03-24", tz="UTC")
TOL_BETA = 0.01
W_INFO = (0.375, 0.625) # §26: riga informativa, NON proposta
EPISODI_NOMINATI = {
"COVID 2020-03": ("2020-03-01", "2020-03-20"),
"maggio 2021": ("2021-05-08", "2021-05-24"),
"LUNA 2022-05": ("2022-05-04", "2022-05-14"),
"giugno 2022": ("2022-06-06", "2022-06-19"),
"FTX 2022-11": ("2022-11-06", "2022-11-22"),
"agosto 2024": ("2024-07-28", "2024-08-06"),
"febbraio 2025": ("2025-01-31", "2025-03-11"),
"5 febbraio 2026": ("2026-01-28", "2026-02-07"),
"1-5 giugno 2026": ("2026-05-28", "2026-06-06"),
}
# ------------------------------------------------------------------ serie
def indice_5050() -> pd.Series:
r = {}
for a in ASSETS:
d = resample_1d(load_data(a, "1h"))
s = pd.Series(d["close"].values.astype(float), index=pd.to_datetime(d["datetime"], utc=True))
r[a] = s.pct_change()
J = pd.concat(r, axis=1, join="inner").dropna()
return 0.5 * J["BTC"] + 0.5 * J["ETH"]
def tp01_esposizione() -> pd.Series:
"""Esposizione lorda media (BTC+ETH)/2 tenuta nel giorno (decisa a close[i-1])."""
tp = TrendPortfolio(**CANONICAL)
e = {}
for a in ASSETS:
d = resample_1d(load_data(a, "1h"))
tgt = tp.target_series(d)
held = np.zeros(len(tgt)); held[1:] = tgt[:-1]
e[a] = pd.Series(held, index=pd.to_datetime(d["datetime"], utc=True))
J = pd.concat(e, axis=1, join="inner")
return 0.5 * J["BTC"] + 0.5 * J["ETH"]
def skh_per_direzione() -> dict[str, pd.Series]:
"""SKH01-V2-DD 50/50 su griglia giornaliera, in tre versioni: intera, SOLO long, SOLO short.
Le versioni per direzione filtrano gli entry-dict prima del backtest: stesso motore, stessi
stop. In principio filtrare potrebbe cambiare il non-overlap; MISURATO (revisione 09/09):
long + short == intera su 2735/2735 giorni (max|diff| 3,5e-18) — la scomposizione e' ESATTA,
e `misura()` lo verifica con un assert (P1: la versione "tutto" e' confrontata bit-exact con
`sleeves._skyhook_returns`).
"""
out = {k: {} for k in ("tutto", "long", "short")}
for a in ASSETS:
ltf, htf = build_frames(load_data(a, "5m"))
ent = skyhook_entries(ltf, htf, SKH01_V2_DD)
for k, keep in (("tutto", (1, -1)), ("long", (1,)), ("short", (-1,))):
e2 = [x if (x is not None and x["dir"] in keep) else None for x in ent]
m = backtest_signals(ltf, e2, fee_rt=0.001, leverage=1.0, asset=a, tf="230m")
s = pd.Series(m.equity, index=pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True)))
out[k][a] = s.resample("1D").last().ffill().pct_change().dropna()
res = {}
for k in out:
J = pd.concat(out[k], axis=1, join="inner").fillna(0.0)
res[k] = 0.5 * J["BTC"] + 0.5 * J["ETH"]
return res
# ------------------------------------------------------------------ misure
def sh(s: pd.Series) -> float:
s = s.dropna()
return float(s.mean() / s.std() * np.sqrt(365.0)) if len(s) > 5 and s.std() > 0 else float("nan")
def maxdd(s: pd.Series) -> float:
eq = (1 + s.fillna(0)).cumprod(); return float((eq / eq.cummax() - 1).min())
def cum(s: pd.Series) -> float:
return float((1 + s.fillna(0)).prod() - 1)
def finestre_peggiori(idx: pd.Series, n: int = N_EPISODI, w: int = FINESTRA) -> list[tuple]:
"""Le n peggiori finestre di w giorni NON sovrapposte dell'indice (greedy dal peggiore)."""
roll = (1 + idx).rolling(w).apply(np.prod, raw=True) - 1
roll = roll.dropna().sort_values()
prese, occupati = [], []
for end, v in roll.items():
start = end - pd.Timedelta(days=w - 1)
if any(not (end < s or start > e) for s, e in occupati):
continue
prese.append((start, end, float(v))); occupati.append((start, end))
if len(prese) >= n:
break
return sorted(prese)
def classifica(libro: float, indice: float) -> str:
"""Classi esclusive nell'ordine dichiarato: immune (col segno) → POSITIVO → PERDE."""
if abs(libro) < abs(indice) / 10:
return "immune+" if libro > 0 else "immune-"
return "POSITIVO" if libro > 0 else "PERDE"
def beta_ribasso(libro: pd.Series, idx: pd.Series, soglia: float = 0.0) -> float:
J = pd.concat({"b": libro, "i": idx}, axis=1, join="inner").dropna()
J = J[J["i"] < soglia]
if len(J) < 20:
return float("nan")
x, y = J["i"].values, J["b"].values
return float(np.cov(x, y)[0, 1] / np.var(x, ddof=1))
def beta_pieno(libro: pd.Series, idx: pd.Series) -> float:
J = pd.concat({"b": libro, "i": idx}, axis=1, join="inner").dropna()
x, y = J["i"].values, J["b"].values
return float(np.cov(x, y)[0, 1] / np.var(x, ddof=1))
def misura() -> dict:
idx = indice_5050()
tp = _tp01_returns()
if tp.index.tz is None:
tp.index = tp.index.tz_localize("UTC")
skh = skh_per_direzione()
esp = tp01_esposizione()
J = pd.concat({"idx": idx, "tp": tp, "skh": skh["tutto"], "skl": skh["long"],
"sks": skh["short"], "esp": esp}, axis=1, join="inner").dropna()
ref = _skyhook_returns()
if ref.index.tz is None:
ref.index = ref.index.tz_localize("UTC")
comune = J.index.intersection(ref.index)
assert float((J.loc[comune, "skh"] - ref.loc[comune]).abs().max()) == 0.0, "SKH01 'tutto' non e' bit-exact col sleeve"
assert float((J["skl"] + J["sks"] - J["skh"]).abs().max()) < 1e-12, "long + short != intera: la scomposizione non e' esatta"
J["libro"] = W_TP01 * J["tp"] + W_SKH * J["skh"]
J["info"] = W_INFO[0] * J["tp"] + W_INFO[1] * J["skh"]
J["c_tp"] = W_TP01 * J["tp"]
J["c_skh"] = W_SKH * J["skh"]
J["c_sks"] = W_SKH * J["sks"]
# giorni di crollo
giorni = {}
for nome, soglia in (("5%", SOGLIA_CROLLO), ("10%", SOGLIA_SEVERO)):
g = J[J["idx"] <= soglia]
giorni[nome] = dict(n=int(len(g)), idx=float(g["idx"].mean()), libro=float(g["libro"].mean()),
tp=float(g["c_tp"].mean()), skh=float(g["c_skh"].mean()),
sks=float(g["c_sks"].mean()), esp=float(g["esp"].mean()),
libro_pos=float((g["libro"] > 0).mean()),
per_anno={int(y): dict(n=int(len(gg)), idx=float(gg["idx"].sum()),
libro=float(gg["libro"].sum()),
sks=float(gg["c_sks"].sum()))
for y, gg in g.groupby(g.index.year)})
# episodi: finestre peggiori + nominati
def riga_ep(nome, s, e):
w = J.loc[s:e]
if len(w) < 3:
return None
return dict(nome=nome, da=str(w.index[0].date()), a=str(w.index[-1].date()), n=int(len(w)),
idx=cum(w["idx"]), libro=cum(w["libro"]), tp=cum(w["c_tp"]), skh=cum(w["c_skh"]),
sks=cum(w["c_sks"]), skl=cum(W_SKH * w["skl"]), esp=float(w["esp"].mean()),
info=cum(w["info"]), classe=classifica(cum(w["libro"]), cum(w["idx"])))
ep_auto = [r for r in (riga_ep(f"peggiore #{i+1}", s, e)
for i, (s, e, _) in enumerate(finestre_peggiori(J["idx"]))) if r]
ep_nom = [r for r in (riga_ep(k, pd.Timestamp(a, tz="UTC"), pd.Timestamp(b, tz="UTC"))
for k, (a, b) in EPISODI_NOMINATI.items()) if r]
# peggior giorno del libro (riproduce §33: e' uno squeeze, non un crollo?)
peggiori = J.nsmallest(5, "libro")[["idx", "libro", "c_tp", "c_skh"]]
n_auto = len(ep_auto)
guad = sum(r["classe"] == "POSITIVO" for r in ep_auto)
imm = sum(r["classe"].startswith("immune") for r in ep_auto)
pos = sum(r["libro"] > 0 for r in ep_auto)
dal22 = [r for r in ep_auto if r["da"] >= "2022"]
J46 = J[J.index >= FINESTRA_46]
quota_short = (sum(r["sks"] for r in ep_auto if r["libro"] > 0)
/ max(1e-12, sum(r["libro"] for r in ep_auto if r["libro"] > 0)))
return dict(
finestra=(str(J.index[0].date()), str(J.index[-1].date()), int(len(J))),
beta=beta_pieno(J["libro"], J["idx"]), beta_46=beta_pieno(J46["libro"], J46["idx"]),
beta_giu=beta_ribasso(J["libro"], J["idx"]),
beta_giu3=beta_ribasso(J["libro"], J["idx"], -0.03),
sharpe=dict(libro=sh(J["libro"]), info=sh(J["info"]), tp=sh(J["tp"]), skh=sh(J["skh"]),
sks=sh(J["sks"]), skl=sh(J["skl"])),
maxdd=dict(libro=maxdd(J["libro"]), info=maxdd(J["info"]), idx=maxdd(J["idx"])),
giorni=giorni, episodi=ep_auto, nominati=ep_nom,
peggiori=[(str(t.date()), float(r.idx), float(r.libro), float(r.c_tp), float(r.c_skh))
for t, r in peggiori.iterrows()],
sks_anno={int(y): cum(g) for y, g in J["c_sks"].groupby(J.index.year)},
sintesi=dict(n_episodi=n_auto, guadagna=guad, immune=imm, perde=n_auto - guad - imm, positivi=pos,
dal22=(sum(r["libro"] > 0 for r in dal22), len(dal22)),
giorni_pos=giorni["5%"]["libro_pos"], giorni_libro=giorni["5%"]["libro"],
quota_short_nei_guadagni=float(quota_short),
info_batte_live=sum(r["info"] > r["libro"] for r in ep_auto)),
)
def verdetto(m: dict) -> str:
"""Tre affermazioni separate, perche' rispondono a tre domande diverse:
(1) nel GIORNO del crollo (media sui giorni ≤ 5%); (2) per FINESTRA (classi esclusive);
(3) da dove viene il guadagno. Un solo aggettivo ("guadagna"/"immune") sarebbe l'ordine delle regole."""
s = m["sintesi"]
beta_ok = abs(m["beta_46"] - BETA_MEMORIA) < TOL_BETA
giorno = ("PERDE" if s["giorni_libro"] < 0 else "GUADAGNA")
if s["guadagna"] >= 2 * s["n_episodi"] / 3:
finestra = "POSITIVO per finestra"
elif s["guadagna"] + s["immune"] >= 2 * s["n_episodi"] / 3:
finestra = "IMMUNE-O-POSITIVO per finestra"
else:
finestra = "PERDE per finestra"
return (f"NEL GIORNO DEL CROLLO IL LIBRO {giorno} ({s['giorni_libro']*100:+.2f}%/g, positivo nel {s['giorni_pos']*100:.0f}% dei giorni ≤ 5%); "
f"{finestra}: {s['guadagna']} POSITIVO / {s['immune']} immune / {s['perde']} PERDE su {s['n_episodi']} (libro > 0 in {s['positivi']}, "
f"dal 2022 in {s['dal22'][0]}/{s['dal22'][1]}); beta {m['beta_46']:+.4f} sulla finestra di §46 "
f"({'riproduce' if beta_ok else 'NON riproduce'} il +{BETA_MEMORIA:.3f}); "
f"la gamba short di SKH01 (marcata all'uscita) fa il {s['quota_short_nei_guadagni']*100:.0f}% dei guadagni negli episodi con libro > 0")
# ------------------------------------------------------------------ stampa
def pct(x: float) -> str:
return f"{x*100:+.2f}%"
def main() -> None:
print("=" * 100)
print(" r0909 — IL LIBRO NEI CROLLI (TP01 75 / SKH01 25, lente di ricerca, ancora canonica)")
print("=" * 100)
m = misura()
a, b, n = m["finestra"]
print(f" finestra {a}{b} ({n} giorni) pesi importati da src/live/book: TP01 {W_TP01} / SKH01 {W_SKH}")
print("\n [1] BETA DEL LIBRO ALL'INDICE 50/50")
print(f" pieno {m['beta']:+.3f} · sulla finestra di §46 (2021-03-24+) {m['beta_46']:+.4f} (memoria: +{BETA_MEMORIA:.3f}) "
f"al ribasso (idx<0) {m['beta_giu']:+.3f} nei giorni ≤ 3% {m['beta_giu3']:+.3f}")
print("\n [2] GIORNI DI CROLLO — media per giorno")
print(f" {'soglia':<8}{'n':>5}{'indice':>9}{'libro':>9}{'TP01':>9}{'SKH01':>9}{'SKH short':>11}{'esp TP01':>10}{'libro>0':>9}")
for k, g in m["giorni"].items():
print(f" {k:<8}{g['n']:>5}{pct(g['idx']):>9}{pct(g['libro']):>9}{pct(g['tp']):>9}"
f"{pct(g['skh']):>9}{pct(g['sks']):>11}{g['esp']:>10.3f}{g['libro_pos']*100:>8.0f}%")
print("\n per anno (giorni ≤ 5%, SOMMA nel gruppo):")
print(f" {'anno':<6}{'n':>4}{'indice':>10}{'libro':>10}{'SKH short':>11}")
for y, g in m["giorni"]["5%"]["per_anno"].items():
print(f" {y:<6}{g['n']:>4}{pct(g['idx']):>10}{pct(g['libro']):>10}{pct(g['sks']):>11}")
def tab(rows, titolo):
print(f"\n {titolo}")
print(f" {'episodio':<18}{'da':>11}{'a':>11}{'indice':>9}{'libro':>9}{'TP01':>8}{'SKH01':>8}"
f"{'SKH-S':>8}{'SKH-L':>8}{'esp':>6}{'37/63':>8} classe")
for r in rows:
print(f" {r['nome']:<18}{r['da']:>11}{r['a']:>11}{pct(r['idx']):>9}{pct(r['libro']):>9}"
f"{pct(r['tp']):>8}{pct(r['skh']):>8}{pct(r['sks']):>8}{pct(r['skl']):>8}{r['esp']:>6.2f}"
f"{pct(r['info']):>8} {r['classe']}")
tab(m["episodi"], f"[3] LE {N_EPISODI} PEGGIORI FINESTRE DI {FINESTRA} GIORNI DELL'INDICE (non sovrapposte)")
tab(m["nominati"], "[4] EPISODI NOMINATI (elenco scritto prima di misurare)")
print("\n [5] I 5 PEGGIORI GIORNI DEL LIBRO (§33 su lente hourly: squeeze SKH01 2020-03-13; qui lente canonica: NON si riproduce, e' un crollo con TP01 mezzo long)")
print(f" {'giorno':<12}{'indice':>9}{'libro':>9}{'TP01':>9}{'SKH01':>9}")
for t, i, l, tpv, sk in m["peggiori"]:
print(f" {t:<12}{pct(i):>9}{pct(l):>9}{pct(tpv):>9}{pct(sk):>9}")
print("\n [6] LA GAMBA SHORT DI SKH01 (peso 0,25 gia' applicato) — contributo per anno al libro")
print(" " + " ".join(f"{y} {pct(v)}" for y, v in m["sks_anno"].items()))
s = m["sharpe"]; d = m["maxdd"]
print(f"\n Sharpe: libro {s['libro']:.2f} · TP01 {s['tp']:.2f} · SKH01 {s['skh']:.2f} "
f"(solo long {s['skl']:.2f} / solo short {s['sks']:.2f}) maxDD libro {d['libro']*100:.1f}% · indice {d['idx']*100:.1f}%")
print(f" riga INFORMATIVA 37,5/62,5 (§26, NON proposta): Sharpe {s['info']:.2f}, maxDD {d['info']*100:.1f}%, "
f"batte il 75/25 in {m['sintesi']['info_batte_live']}/{m['sintesi']['n_episodi']} episodi")
print("\n VERDETTO: " + verdetto(m))
print("=" * 100)
if __name__ == "__main__":
main()
+242
View File
@@ -0,0 +1,242 @@
"""r0909 — XRP COME TERZA GAMBA DEL BOOK DERIBIT (meccanismi CONGELATI): la misura che manca.
PERCHE' ESISTE. L'operatore chiede "piu' monete, ma sempre in Deribit". Letto il venue OGGI
(09/09/2026, `public/get_book_summary_by_currency`): dei 32 perpetual USDC-lineari, solo
QUATTRO hanno volume a 8 cifre — BTC, ETH, **XRP ($38M/g, il PIU' scambiato del venue, spread
4 bps, listato 2022-03-16)** e SOL ($13M/g). Tutto il resto sta fra $0,01M e $7M al giorno.
SOL come terza gamba e' stato misurato il 22/08 e ESCLUSO dall'operatore (hold-out 0,166 in
0/24 ancore, guadagno di UN anno, il 2023). XRP non e' mai stato misurato come gamba
direzionale: e' l'unico candidato "piu' monete" del venue che sia liquido E non misurato.
IPOTESI A PRIORI, dichiarata prima di misurare: DILUISCE, come SOL — il trend multi-asset del
19/06 fu scartato a corr 0,74 con TP01, e SOL ha confermato (corr 0,40-0,56, in salita col dato
che migliora). Se XRP aggiunge, il primo sospetto e' l'anno del suo evento idiosincratico (la
sentenza SEC dell'estate 2023 e la corsa di fine 2024): M9, "24/24 ancore positive possono
essere un anno solo".
METODO — identico a `r0822_sol_leg.py`, da cui IMPORTA il harness (non riscritto): TP01 e
SKH01-V2-DD congelati, book a 2 vs 3 gambe, 24 ancore orarie, mediana delle differenze
APPAIATE (M7), null del de-levering a iso-maxDD (M5), scomposizione per anno (M9), due lenti
DICHIARATE PRIMA dalla certificazione del dato, non dal risultato:
- L-FULL: dal listing (2022-03-16);
- L-PULITA: dal primo anno in cui la quota di barre 1h a >1% da Coinbase USD scende sotto
lo 0,5% (SOL 2023 aveva 0,6% e fu giudicata sporca: e' il precedente che fissa la soglia).
Il dato XRP vive in `data/raw/alt_xrp_*.parquet` (namespace di ricerca, come SOL: fuori dal
feed attivo, NON rinfrescato dal cron, `load_data("XRP")` continua a fallire).
Nessun file di produzione toccato. Nessun ordine. Book/pesi/universo/cron INVARIATI.
"""
from __future__ import annotations
import json
import sys
import time
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"))
from r0822_sol_leg import ( # noqa: E402 — harness importato, non riscritto
DUE, HOLDOUT, W_SKH, W_TP, book, cagr, dati, k_iso_dd, maxdd, sh, skh_leg, taglia, tp01_leg,
)
ASSET = "XRP"
TRE = ("BTC", "ETH", ASSET)
LISTING = pd.Timestamp("2022-03-16", tz="UTC")
SOGLIA_PULITA = 0.005 # quota di barre 1h a >1% da Coinbase: sotto = anno pulito
CACHE = ROOT / "data" / "_cache" / "r0909_xrp_riferimento_1h.parquet"
# ------------------------------------------------------------------ certificazione (D1: Coinbase = audit)
def _fetch_1h(ex, symbol: str, start: pd.Timestamp, limit: int) -> pd.Series:
"""OHLCV 1h paginato in avanti; un batch vuoto NON ferma il ciclo (Coinbase ha un buco
2021-01 → 2023-07 su XRP: delistato dopo la causa SEC, rilistato dopo la sentenza)."""
out, since = [], int(start.timestamp() * 1000)
end = int(time.time() * 1000)
while since < end:
r = ex.fetch_ohlcv(symbol, "1h", since=since, limit=limit)
r = [x for x in r if x[0] >= since]
if not r:
since += limit * 3_600_000
continue
out.extend(r)
nxt = r[-1][0] + 3_600_000
since = nxt if nxt > since else since + limit * 3_600_000
df = pd.DataFrame(out, columns=["ts", "o", "h", "l", "close", "v"]).drop_duplicates("ts")
return pd.Series(df["close"].values.astype(float),
index=pd.to_datetime(df["ts"], unit="ms", utc=True)).sort_index()
def riferimento_1h(start: pd.Timestamp) -> pd.DataFrame:
"""Riferimento indipendente in USD (D1: audit, mai ancora): Coinbase dove c'e' (dal rilisting
2023-07), Bitstamp altrove — entrambi gia' usati dal progetto come venue di audit. Con cache."""
if CACHE.exists():
df = pd.read_parquet(CACHE); df.index = pd.to_datetime(df.index, utc=True)
if len(df):
return df
import ccxt
bs = _fetch_1h(ccxt.bitstamp({"enableRateLimit": True}), "XRP/USD", start, 1000)
cb = _fetch_1h(ccxt.coinbase({"enableRateLimit": True}), "XRP/USD", start, 300)
df = pd.concat({"bitstamp": bs, "coinbase": cb}, axis=1).sort_index()
df["close"] = df["coinbase"].where(df["coinbase"].notna(), df["bitstamp"])
df["fonte"] = np.where(df["coinbase"].notna(), "CB", np.where(df["bitstamp"].notna(), "BS", ""))
CACHE.parent.mkdir(parents=True, exist_ok=True)
df.to_parquet(CACHE)
return df
def certifica() -> dict:
"""Per anno: quota di barre 1h a >1% da Coinbase, mediana in bps, quota di barre flat (1h e 5m)."""
d1 = dati(ASSET, "1h")
dz = pd.Series(d1["close"].values.astype(float), index=pd.to_datetime(d1["datetime"], utc=True))
ref = riferimento_1h(LISTING)
J = pd.concat({"d": dz, "c": ref["close"], "f": ref["fonte"]}, axis=1, join="inner").dropna(subset=["d", "c"])
dev = (J["d"] - J["c"]).abs() / J["c"]
d5 = dati(ASSET, "5m")
f5 = pd.Series((d5["high"].values == d5["low"].values), index=pd.to_datetime(d5["datetime"], utc=True))
f1 = pd.Series((d1["high"].values == d1["low"].values), index=dz.index)
per_anno = {}
for y in sorted(set(J.index.year)):
m = dev[dev.index.year == y]
fy = J["f"][J.index.year == y]
per_anno[int(y)] = dict(n=int(len(m)), q_1pct=float((m > 0.01).mean()), med_bps=float(m.median() * 1e4),
fonte=f"CB {float((fy == 'CB').mean())*100:.0f}% / BS {float((fy == 'BS').mean())*100:.0f}%",
flat_1h=float(f1[f1.index.year == y].mean()), flat_5m=float(f5[f5.index.year == y].mean()))
puliti = [y for y, r in per_anno.items() if r["q_1pct"] <= SOGLIA_PULITA]
# L-PULITA = dal primo anno pulito in poi SOLO se anche i successivi restano puliti (un anno pulito
# isolato non e' un regime); altrimenti dal primo della coda pulita finale.
anni = sorted(per_anno)
inizio = None
for y in anni:
if all(per_anno[z]["q_1pct"] <= SOGLIA_PULITA for z in anni if z >= y):
inizio = y; break
return dict(per_anno=per_anno, puliti=puliti,
pulita_da=pd.Timestamp(f"{inizio}-01-01", tz="UTC") if inizio else None)
# ------------------------------------------------------------------ misura (stesso schema di r0822_sol_leg)
def gamba(asset: str) -> pd.Series:
return W_TP * tp01_leg(asset, 0).reindex(skh_leg(asset).index).fillna(0.0) + W_SKH * skh_leg(asset)
def differenze(anchors, start) -> dict:
d = {"sh": [], "hold": [], "dd": [], "cagr": []}
for h in anchors:
A, B = book(DUE, h), book(TRE, h)
idx = A.index.intersection(B.index)
A, B = taglia(A.loc[idx], start), taglia(B.loc[idx], start)
if len(A) < 200:
continue
d["sh"].append(sh(B) - sh(A)); d["hold"].append(sh(taglia(B, HOLDOUT)) - sh(taglia(A, HOLDOUT)))
d["dd"].append((maxdd(B) - maxdd(A)) * 100); d["cagr"].append((cagr(B) - cagr(A)) * 100)
return {k: np.array(v, float) for k, v in d.items()}
def misura(n_anc: int = 24) -> dict:
anchors = list(range(0, 24, max(1, 24 // n_anc)))[:n_anc]
cert = certifica()
lenti = {"L-FULL": None}
if cert["pulita_da"] is not None:
lenti[f"L-PULITA ({cert['pulita_da'].year}+)"] = cert["pulita_da"]
lenti["2024+ (lente di SOL)"] = pd.Timestamp("2024-01-01", tz="UTC")
out = dict(cert=cert, lenti={}, gambe={}, anno={}, delev={}, corr={})
for a in TRE:
out["gambe"][f"TP01 {a}"] = tp01_leg(a, 0); out["gambe"][f"SKH01 {a}"] = skh_leg(a)
g = gamba(ASSET); b2 = book(DUE, 0)
com = g.index.intersection(b2.index)
for nome, start in lenti.items():
c = com if start is None else com[com >= start]
out["corr"][nome] = float(np.corrcoef(g.loc[c], b2.loc[c])[0, 1]) if len(c) > 100 else np.nan
out["lenti"][nome] = differenze(anchors, start)
A, B = book(DUE, 0), book(TRE, 0); idx = A.index.intersection(B.index)
A, B = taglia(A.loc[idx], start), taglia(B.loc[idx], start)
k = k_iso_dd(A, B)
out["delev"][nome] = dict(k=k, sh3=sh(B), sh2k=sh(k * A), cagr3=cagr(B), cagr2k=cagr(k * A))
A0, B0 = book(DUE, 0), book(TRE, 0); i0 = A0.index.intersection(B0.index); A0, B0 = A0.loc[i0], B0.loc[i0]
for y in sorted(set(A0.index.year)):
a_, b_ = A0[A0.index.year == y], B0[B0.index.year == y]
if len(a_) >= 60:
out["anno"][int(y)] = dict(sh2=sh(a_), sh3=sh(b_), dsh=sh(b_) - sh(a_), dcagr=(cagr(b_) - cagr(a_)) * 100,
sh_gamba=sh(g[g.index.year == y]))
out["gamba_xrp"] = g
out["n_anchors"] = len(anchors)
return out
def verdetto(m: dict) -> str:
"""AGGIUNGE solo se, nella lente PULITA (o 2024+ se non c'e' una coda pulita): dSharpe hold-out
mediano > 0 in ≥ 75% delle ancore, E il null del de-levering passa (Sh 3 gambe > Sh 2 gambe
riscalate + 0,02), E il contributo per anno e' positivo in piu' anni di quanti sia negativo —
contati sugli anni DENTRO la lente (derivati dal suo nome, non cablati: P1).
Un campione vuoto e' "NON MISURABILE", non un verdetto (P5: "non vedo" non e' "va tutto bene")."""
nomi = [n for n in m["lenti"] if n.startswith("L-PULITA")] or ["2024+ (lente di SOL)"]
n = nomi[0]; d = m["lenti"][n]; dl = m["delev"][n]
import re
anno0 = int(re.search(r"(\d{4})\+", n).group(1)) # "L-PULITA (2024+)" / "2024+ (lente di SOL)"
if not len(d["hold"]) or not np.isfinite(dl["sh3"]) or not np.isfinite(dl["sh2k"]):
return f"XRP: NON MISURABILE — lente {n} senza ancore valide o de-levering non calcolabile"
c_hold = np.median(d["hold"]) > 0 and np.mean(d["hold"] > 0) >= 0.75
c_delev = dl["sh3"] > dl["sh2k"] + 0.02
anni = [r["dsh"] for y, r in m["anno"].items() if y >= anno0]
c_anni = sum(x > 0 for x in anni) > sum(x < 0 for x in anni)
esito = "XRP AGGIUNGE" if (c_hold and c_delev and c_anni) else ("XRP DILUISCE" if (not c_hold and not c_delev) else "XRP: PARI / NON SELEZIONABILE")
return (f"{esito} — lente {n}: dSharpe hold-out mediano {np.median(d['hold']):+.3f} "
f"(>0 nel {np.mean(d['hold'] > 0) * 100:.0f}% di {len(d['hold'])} ancore); "
f"de-levering: Sh 3 gambe {dl['sh3']:.2f} vs 2 gambe×{dl['k']:.3f} {dl['sh2k']:.2f}; "
f"anni {anno0}+ con dSh>0: {sum(x > 0 for x in anni)}/{len(anni)}")
def main() -> None:
n_anc = int(sys.argv[sys.argv.index("--anchors") + 1]) if "--anchors" in sys.argv else 24
print("=" * 104)
print(" r0909 — XRP COME TERZA GAMBA DEL BOOK DERIBIT (meccanismi CONGELATI; harness di r0822_sol_leg)")
print("=" * 104)
print(" ipotesi a priori dichiarata: DILUISCE (come SOL; trend multi-asset 19/06 corr 0,74)")
m = misura(n_anc)
c = m["cert"]
print(f"\n [0/5] CERTIFICAZIONE XRP vs Coinbase USD (1h) — soglia anno pulito: quota >1% ≤ {SOGLIA_PULITA*100:.1f}%")
print(f" {'anno':<6}{'barre':>7}{'>1%':>8}{'med bps':>9}{'flat 1h':>9}{'flat 5m':>9} riferimento")
for y, r in c["per_anno"].items():
print(f" {y:<6}{r['n']:>7}{r['q_1pct']*100:>7.2f}%{r['med_bps']:>9.1f}{r['flat_1h']*100:>8.1f}%{r['flat_5m']*100:>8.1f}% {r['fonte']}")
print(f" L-PULITA da: {c['pulita_da'].date() if c['pulita_da'] is not None else 'NESSUN anno pulito in coda'}")
print(f"\n [1/5] LE GAMBE DA SOLE (ancora canonica h=0)")
print(f"\n {'gamba':<26}{'barre':>7}{'Sharpe':>9}{'maxDD':>10}{'CAGR':>9}{'Sh hold':>10}")
for k, s in m["gambe"].items():
print(f" {k:<26}{len(s):>7}{sh(s):>9.2f}{maxdd(s)*100:>9.1f}%{cagr(s)*100:>8.1f}%{sh(taglia(s, HOLDOUT)):>10.2f}")
g = m["gamba_xrp"]
print(f"\n [2/5] LA GAMBA XRP (75/25) — Sharpe {sh(g):.2f}, maxDD {maxdd(g)*100:.1f}%, CAGR {cagr(g)*100:.1f}%, hold-out {sh(taglia(g, HOLDOUT)):.2f}")
for n, v in m["corr"].items():
print(f" corr(gamba XRP, book BTC/ETH) {n}: {v:+.3f} (SOL: +0,40 / +0,56; soglia del 19/06: 0,74)")
print(f"\n [3/5] BOOK A 2 vs 3 GAMBE — differenze APPAIATE per ancora ({m['n_anchors']} ancore)")
for n, d in m["lenti"].items():
if not len(d["sh"]):
print(f"\n {n}: campione insufficiente"); continue
print(f"\n {n} ({len(d['sh'])} ancore)")
for lab, v, u in (("dSharpe FULL", d["sh"], ""), ("dSharpe hold-out", d["hold"], ""), ("dMaxDD", d["dd"], " pp"), ("dCAGR", d["cagr"], " pp")):
print(f" {lab:<18} mediana {np.median(v):+7.3f}{u} banda [{np.percentile(v, 10):+.3f}, {np.percentile(v, 90):+.3f}] >0 nel {np.mean(v > 0)*100:.0f}% delle ancore")
print(f"\n [3bis] PER ANNO (h=0): {'anno':<6}{'2g Sh':>7}{'3g Sh':>7}{'dSh':>7}{'dCAGR':>9}{'gamba XRP':>11}")
for y, r in m["anno"].items():
print(f" {'':<19}{y:<6}{r['sh2']:>7.2f}{r['sh3']:>7.2f}{r['dsh']:>+7.2f}{r['dcagr']:>+8.1f}pp{r['sh_gamba']:>+11.2f}")
print(f"\n [4/5] NULL DEL DE-LEVERING (iso-maxDD): {'lente':<24}{'k':>7}{'Sh 3g':>8}{'Sh 2g×k':>9}{'CAGR 3g':>9}{'CAGR 2g×k':>11}")
for n, dl in m["delev"].items():
v = "AGGIUNGE" if dl["sh3"] > dl["sh2k"] + 0.02 else ("de-levering" if dl["sh2k"] > dl["sh3"] + 0.02 else "pari")
print(f" {'':<39}{n:<24}{dl['k']:>7.3f}{dl['sh3']:>8.2f}{dl['sh2k']:>9.2f}{dl['cagr3']*100:>8.1f}%{dl['cagr2k']*100:>10.1f}% {v}")
px = float(dati(ASSET, "1h")["close"].iloc[-1])
print(f"\n [5/5] ESEGUIBILITA': XRP_USDC-PERPETUAL min 1 XRP = ${px:.2f} < pavimento min_order $5 — non e' il vincolo (come SOL)")
print("\n VERDETTO: " + verdetto(m))
print("=" * 104)
if __name__ == "__main__":
main()
+508
View File
@@ -0,0 +1,508 @@
"""Test di `scripts/research/r0909_crash_catturato.py` (2026-09-09) — il `f` di stress su quote vere.
Questo script porta l'UNICA condizione che riapre la decisione vincolante del 19/06 ("niente
short-vol da modello in deploy"): un `f` di stress reale misurato su un crash catturato. Un
numero con quel peso ha bisogno di guardie sul MECCANISMO, non solo sull'esito:
* il prezzatore vettorizzato dev'essere *lo stesso* di `cblib.bs_put`, elemento per elemento e
nei casi degeneri (T0, sig=0 intrinseco): se diverge, il denominatore di ogni `f` e' un
altro modello e il rapporto non significa piu' niente;
* **lo spot dev'essere CAUSALE.** Il feed 1h e' etichettato all'APERTURA: la barra 20:00 chiude
alle 21:00, quindi `S.asof(ts)` guardava un'ora AVANTI. `spot_causale` sposta l'indice di
+1h. E' un look-ahead di un'ora dentro una misura di stress piccolo e letale;
* **il costo di chiusura si TAGLIA alla larghezza.** Uno spread a rischio definito non costa
mai piu' della larghezza per chiudersi: si lascia regolare. Senza il taglio il "peggior MTM"
era un artefatto di quote (2,6× il modello; col taglio 1,08);
* la geometria fuori tolleranza va MARCATA, non aggiustata: `pick_legs` prende sempre la delta
piu' vicina, quindi senza `in_geom` una griglia rada produrrebbe silenziosamente un'altra
struttura (e' cio' che ha reso BTC non misurabile);
* un `f` fra un reale negativo e un modello positivo NON e' un `f`: dev'essere NaN (P12).
I test sintetici costruiscono quote a mano, dove ogni numero atteso si calcola a matita. Il test
d'integrazione (~11 s) riproduce la misura pubblicata su ETH.
"""
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))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
import cblib as CB # noqa: E402
import r0909_crash_catturato as C # noqa: E402
INS_S = "ETH-19JUN26-1900-P"
INS_L = "ETH-19JUN26-1600-P"
K_S, K_L = 1900.0, 1600.0
LARGHEZZA = K_S - K_L
TS0 = pd.Timestamp("2026-06-05 00:00", tz="UTC")
ORE = pd.date_range(TS0, periods=4, freq="h")
# ------------------------------------------------------------------ impalcature sintetiche
def _serie_spot(entrata: float = 2000.0, scadenza: float | None = None) -> pd.Series:
"""Spot orario dal 01/06 a EXP: `entrata` fino a 12h dopo l'ingresso, poi `scadenza`
(cosi' `S.asof(EXP)` e' pilotabile senza toccare le quote della finestra d'ingresso)."""
idx = pd.date_range(pd.Timestamp("2026-06-01", tz="UTC"), C.EXP, freq="h")
fine = TS0 + pd.Timedelta(hours=12)
v = np.where(idx <= fine, entrata, entrata if scadenza is None else scadenza)
return pd.Series(v.astype(float), index=idx)
def _serie_dvol(dvol: float = 50.0) -> pd.Series:
idx = pd.date_range(pd.Timestamp("2026-06-01", tz="UTC"), C.EXP, freq="h")
return pd.Series(float(dvol), index=idx)
def _seg(bid_s=0.030, ask_s=0.032, bid_l=0.010, ask_l=0.012,
ore=ORE, salta_lunga=()) -> pd.DataFrame:
"""Tabella di quote (ts × strumento). `salta_lunga` toglie la gamba LUNGA a quelle ore:
e' il caso "una gamba sola", che non e' meta' struttura ma un'altra strategia."""
righe = []
for k, t in enumerate(ore):
righe.append(dict(ts=t, instrument_name=INS_S, bid=bid_s, ask=ask_s, iv=48.0 + k))
if k not in salta_lunga:
righe.append(dict(ts=t, instrument_name=INS_L, bid=bid_l, ask=ask_l, iv=57.0 + k))
return pd.DataFrame(righe)
def _quote(bid_s=0.030, ask_s=0.032, bid_l=0.010, ask_l=0.012, salta_lunga=()) -> C.Quote:
return C.Quote(_seg(bid_s, ask_s, bid_l, ask_l, salta_lunga=salta_lunga),
_serie_spot(), _serie_dvol())
def _snap(d_short: float, d_long: float, bid_s=0.030, ask_s=0.032,
bid_l=0.010, ask_l=0.012) -> pd.DataFrame:
return pd.DataFrame([
dict(instrument_name=INS_S, strike=K_S, delta=d_short,
bid=bid_s, ask=ask_s, iv=48.0, mid=(bid_s + ask_s) / 2),
dict(instrument_name=INS_L, strike=K_L, delta=d_long,
bid=bid_l, ask=ask_l, iv=57.0, mid=(bid_l + ask_l) / 2),
])
def _trade(d_short: float, d_long: float, bid_s=0.030, ask_s=0.032, bid_l=0.010,
ask_l=0.012, spot=2000.0, ST=None) -> dict | None:
S, V = _serie_spot(spot, ST), _serie_dvol()
Q = C.Quote(_seg(bid_s, ask_s, bid_l, ask_l), S, V)
return C.trade("ETH", TS0, _snap(d_short, d_long, bid_s, ask_s, bid_l, ask_l), Q, S, V)
# ===========================================================================================
# spot_causale — il look-ahead di un'ora
# ===========================================================================================
def test_spot_causale_restituisce_l_ultima_chiusura_CONOSCIUTA():
"""🚨 Il feed 1h e' etichettato all'APERTURA: la barra 01:00 chiude alle 02:00. Senza lo
spostamento, `asof(02:00)` restituiva la chiusura delle 02:00 cioe' un'ora di futuro
dentro un ingresso. DEVE FALLIRE SE: qualcuno "semplifica" togliendo lo shift.
`cblib.spot_series` ha ancora il difetto (e quindi il f 0,714 di §11): dichiarato nel
docstring dello script, non toccato qui."""
idx = pd.date_range("2026-06-01", periods=5, freq="h", tz="UTC")
S = pd.Series([10.0, 11.0, 12.0, 13.0, 14.0], index=idx)
Sc = C.spot_causale(S)
assert list(Sc.index) == list(idx + pd.Timedelta(hours=1))
assert Sc.asof(idx[2]) == 11.0, "a 02:00 e' nota la chiusura della barra 01:00"
assert S.asof(idx[2]) == 12.0, "il difetto che la funzione ripara (controllo positivo)"
assert np.isnan(Sc.asof(idx[0])), "prima della prima chiusura nota non c'e' spot, non c'e' 10.0"
assert Sc.iloc[0] == 10.0 and S.iloc[0] == 10.0, "i VALORI non si toccano, solo le etichette"
# ===========================================================================================
# bs_put_vec == cblib.bs_put, elemento per elemento
# ===========================================================================================
def test_bs_put_vec_e_IDENTICA_a_cblib_bs_put_elemento_per_elemento():
"""DEVE FALLIRE SE: la vettorizzata devia dal prezzatore del sleeve — il denominatore di
ogni `f` smetterebbe di essere "lo stesso trade nel modello"."""
S = np.array([2400.0, 2000.0, 1900.0, 1800.0, 1500.0, 900.0])
T = np.array([0.10, 0.0383, 0.0192, 0.0027, 0.0027, 0.20])
sig = np.array([0.35, 0.50, 0.65, 1.10, 0.50, 0.05])
for K in (K_S, K_L, 2200.0):
v = C.bs_put_vec(S, K, T, sig)
atteso = [CB.bs_put(float(s), K, float(t), float(g)) for s, t, g in zip(S, T, sig)]
assert v == pytest.approx(atteso, rel=0, abs=1e-10), K
def test_bs_put_vec_a_T_o_sigma_nulli_da_l_INTRINSECO():
"""I casi degeneri entrano nel conto: all'ora di scadenza T=0 e il valore e' il payoff, non
un prezzo. DEVE FALLIRE SE: qualcuno toglie la maschera `ok` e lascia passare log/0 o NaN
dentro `mod_spread`."""
S = np.array([2400.0, 1900.0, 1500.0, 1000.0])
for T, sig in ((np.zeros(4), np.full(4, 0.5)),
(np.full(4, -1.0), np.full(4, 0.5)),
(np.full(4, 0.05), np.zeros(4))):
v = C.bs_put_vec(S, K_S, T, sig)
assert v == pytest.approx(np.maximum(K_S - S, 0.0), abs=1e-12)
assert np.isfinite(v).all()
# ===========================================================================================
# Quote + percorso su quote sintetiche
# ===========================================================================================
def test_percorso_costa_lo_spread_nel_VERSO_GIUSTO():
"""chiudi_short = (ASK corta BID lunga)·spot: si ricompra cio' che si e' venduto e si
rivende cio' che si e' comprato, cioe' si attraversa lo spread due volte.
liquida_long = (BID corta ASK lunga)·spot e' lo specchio.
DEVE FALLIRE SE: qualcuno "corregge" i lati a mid il percorso costerebbe meno e il fatto
misurato si ammorbidirebbe da solo."""
Q = _quote(salta_lunga=(1,))
assert list(Q.bid.index) == list(ORE)
assert Q.spot.iloc[0] == 2000.0
assert Q.dvol.iloc[0] == pytest.approx(0.50), "il DVOL entra in frazione, non in punti"
p = C.percorso(Q, ORE[0] - pd.Timedelta(hours=1), INS_S, INS_L, K_S, K_L)
assert len(p) == 3, "l'ora con la sola gamba corta dev'essere ESCLUSA"
assert list(p["ts"]) == [ORE[0], ORE[2], ORE[3]]
assert p["ts"].is_monotonic_increasing
assert p["chiudi_grezzo"].tolist() == pytest.approx([(0.032 - 0.010) * 2000.0] * 3)
assert p["chiudi_short"].tolist() == pytest.approx([(0.032 - 0.010) * 2000.0] * 3), (
"dentro la larghezza il taglio non deve mordere: sarebbe un costo inventato")
assert p["liquida_long"].tolist() == pytest.approx([(0.030 - 0.012) * 2000.0] * 3)
assert p["liquida_put"].tolist() == pytest.approx([0.010 * 2000.0] * 3)
assert p["iv_s"].tolist() == pytest.approx([48.0, 50.0, 51.0])
assert p["iv_l"].tolist() == pytest.approx([57.0, 59.0, 60.0])
assert p["spot"].tolist() == pytest.approx([2000.0] * 3)
# il valore MODELLATO e' quello del sleeve, agli stessi strike e alla stessa ora
for i, t in enumerate([ORE[0], ORE[2], ORE[3]]):
T = max((C.EXP - t).total_seconds(), 0.0) / 86400.0 / 365.25
atteso = CB.bs_put(2000.0, K_S, T, 0.5) - CB.bs_put(2000.0, K_L, T, 0.5)
assert p["mod_spread"].iloc[i] == pytest.approx(atteso, abs=1e-10)
assert p["mod_put"].iloc[i] == pytest.approx(CB.bs_put(2000.0, K_L, T, 0.5), abs=1e-10)
def test_percorso_TAGLIA_il_costo_di_chiusura_alla_LARGHEZZA():
"""🚨 Uno spread a rischio definito non puo' costare piu' della larghezza per chiudersi: si
lascia regolare. A T0 le quote arrivano al 158% di spread relativo, e senza il taglio il
"peggior MTM" era un artefatto di book (2,6× il modello, corretto in revisione a 1,08).
`chiudi_grezzo` resta accanto perche' il taglio va CONTATO, non nascosto (P12)."""
Q = _quote(bid_s=0.005, ask_s=0.200, bid_l=0.010, ask_l=0.012)
p = C.percorso(Q, ORE[0] - pd.Timedelta(hours=1), INS_S, INS_L, K_S, K_L)
assert p["chiudi_grezzo"].tolist() == pytest.approx([(0.200 - 0.010) * 2000.0] * 4) # 380
assert p["chiudi_short"].tolist() == pytest.approx([LARGHEZZA] * 4) # 300
assert (p["chiudi_short"] <= LARGHEZZA + 1e-12).all()
assert (p["chiudi_grezzo"] > p["chiudi_short"]).all(), "il grezzo deve restare visibile"
# la gamba lunga liquidata non puo' valere meno di zero
assert p["liquida_long"].tolist() == pytest.approx([0.0] * 4)
def test_percorso_taglia_liquida_long_anche_in_ALTO():
"""L'altro estremo della stessa regola: un debit spread non vale piu' della sua larghezza."""
Q = _quote(bid_s=0.200, ask_s=0.210, bid_l=0.005, ask_l=0.010)
p = C.percorso(Q, ORE[0] - pd.Timedelta(hours=1), INS_S, INS_L, K_S, K_L)
assert p["liquida_long"].tolist() == pytest.approx([LARGHEZZA] * 4)
def test_percorso_parte_DOPO_l_ingresso_e_non_lo_include():
"""Il mark-to-market comincia all'ora successiva: includere l'ora d'ingresso metterebbe
dentro un P&L noto a priori (il mezzo spread pagato entrando)."""
Q = _quote()
p = C.percorso(Q, ORE[0], INS_S, INS_L, K_S, K_L)
assert list(p["ts"]) == [ORE[1], ORE[2], ORE[3]]
assert C.percorso(Q, ORE[-1], INS_S, INS_L, K_S, K_L).empty
def test_percorso_e_vuoto_se_uno_strumento_non_esiste_nella_tabella():
"""P5: "non vedo" ha uno stato suo (DataFrame vuoto → `trade` ritorna None), non un valore
inventato."""
Q = _quote()
assert C.percorso(Q, ORE[0], INS_S, "ETH-19JUN26-9999-P", K_S, 9999.0).empty
assert C.percorso(Q, ORE[0], "ETH-19JUN26-9999-P", INS_L, 9999.0, K_L).empty
def test_Quote_scarta_le_quote_NON_quotate():
"""bid o ask a zero (o assenti) non sono prezzi: entrano come "non quotato", non come 0."""
seg = _seg()
seg.loc[seg["instrument_name"] == INS_L, "bid"] = 0.0 # gamba lunga senza bid
Q = C.Quote(seg, _serie_spot(), _serie_dvol())
assert INS_L not in Q.bid.columns
assert C.percorso(Q, ORE[0], INS_S, INS_L, K_S, K_L).empty
# ===========================================================================================
# la guardia di geometria in_geom (TOL_D)
# ===========================================================================================
def test_in_geom_e_FALSO_quando_le_delta_disponibili_sono_lontane():
"""`pick_legs` prende SEMPRE la delta piu' vicina: su una griglia rada restituisce una
struttura che non e' VRP01 e non lo dice. `in_geom` e' il pezzo che lo dice.
DEVE FALLIRE SE: qualcuno allarga TOL_D o toglie il flag i 78 snapshot BTC scartati
il 09/09 sono esattamente quelli che quel flag ha fermato."""
t = _trade(-0.50, -0.01)
assert t is not None
assert t["in_geom"] is False
assert t["d_short"] == -0.50 and t["d_long"] == -0.01
def test_in_geom_e_VERO_sulla_geometria_di_VRP01():
"""Controllo POSITIVO (M15): un rilevatore tarato per non segnalare va provato su un caso
che DEVE passare, altrimenti "nessun trade fuori geometria" non significa niente."""
t = _trade(C.SHORT_D, C.LONG_D)
assert t["in_geom"] is True
assert t["d_short"] == pytest.approx(C.SHORT_D) and t["d_long"] == pytest.approx(C.LONG_D)
def test_in_geom_il_confine_e_esattamente_TOL_D():
"""I due confini si DERIVANO da `TOL_D`/`SHORT_D`/`LONG_D`, non si riscrivono (P1).
Il confine ESATTO non e' asseribile in virgola mobile: `-0.28 - 0.08` vale
-0.36000000000000004, quindi la delta "sul confine" cade FUORI. Si prova un micron dentro
e un micron fuori e' il confine che conta, non il suo ultimo bit."""
dentro_s, fuori_s = C.SHORT_D - C.TOL_D[0] + 1e-6, C.SHORT_D - C.TOL_D[0] - 1e-6
dentro_l, fuori_l = C.LONG_D - C.TOL_D[1] + 1e-6, C.LONG_D - C.TOL_D[1] - 1e-6
assert _trade(dentro_s, dentro_l)["in_geom"] is True, "un micron dentro deve passare"
assert _trade(fuori_s, dentro_l)["in_geom"] is False, "gamba corta appena fuori"
assert _trade(dentro_s, fuori_l)["in_geom"] is False, "gamba lunga appena fuori"
def test_P1_le_delta_bersaglio_vengono_dal_SLEEVE():
"""DEVE FALLIRE SE: qualcuno ridichiara 0,28/0,10 nello script — la misura punterebbe su
una geometria diversa da quella del sleeve che dovrebbe descrivere."""
from src.portfolio.sleeves import VRP_CFG
assert C.SHORT_D is VRP_CFG["short_delta"]
assert C.LONG_D is VRP_CFG["long_delta"]
assert C.FEE_FRAC is VRP_CFG["fee_frac"]
# ===========================================================================================
# trade() — i conti, e cio' che si RIFIUTA di calcolare
# ===========================================================================================
def test_f_exp_e_f_mtm_sono_NAN_a_SEGNI_DISCORDI():
"""Un rapporto fra un reale NEGATIVO e un modello POSITIVO non e' un fattore di stress:
e' due fatti diversi messi in frazione. Qui il credito reale (0,2 $) sta sotto le fee
d'ingresso (1,2 $) mentre il credito modellato e' pieno pnl_real < 0 < pnl_mod.
DEVE FALLIRE SE: qualcuno toglie le condizioni di segno e pubblica un `f` negativo."""
t = _trade(C.SHORT_D, C.LONG_D, bid_s=0.0101, ask_s=0.0102, bid_l=0.009, ask_l=0.010)
assert t["pnl_real"] < 0 < t["pnl_mod"], (t["pnl_real"], t["pnl_mod"])
assert np.isnan(t["f_exp"])
assert t["mtm_real_min"] < 0 < t["mtm_mod_min"], (t["mtm_real_min"], t["mtm_mod_min"])
assert np.isnan(t["f_mtm"])
def test_f_exp_e_UN_NUMERO_quando_perdono_entrambi():
"""Controllo POSITIVO del test qui sopra: con lo spot a 1500 a scadenza perdono sia il
reale sia il modello, e allora `f_exp` esiste ed e' esattamente il rapporto delle perdite."""
t = _trade(C.SHORT_D, C.LONG_D, bid_s=0.0101, ask_s=0.0102, bid_l=0.009, ask_l=0.010,
ST=1500.0)
assert t["pnl_real"] < 0 and t["pnl_mod"] < 0
assert t["f_exp"] == pytest.approx(t["pnl_real"] / t["pnl_mod"], abs=1e-12)
assert np.isfinite(t["f_exp"]) and t["f_exp"] > 0
def test_itm_pieno_dichiara_quando_f_exp_e_TAUTOLOGICO():
"""Se ENTRAMBE le gambe finiscono ITM il payoff e' la larghezza per il reale e per il
modello: `f_exp` si riduce a una funzione di `f_net` e non e' piu' un'informazione nuova.
Il flag esiste per poterlo DIRE (l'83% dei trade ETH del 09/09), non per correggerlo."""
assert _trade(C.SHORT_D, C.LONG_D, ST=1500.0)["itm_pieno"] is True # 1500 <= K_L
assert _trade(C.SHORT_D, C.LONG_D, ST=2000.0)["itm_pieno"] is False
assert _trade(C.SHORT_D, C.LONG_D, ST=K_L)["itm_pieno"] is True # confine incluso
assert _trade(C.SHORT_D, C.LONG_D, ST=K_L + 1.0)["itm_pieno"] is False
def test_trade_CONTA_le_ore_tagliate_invece_di_nasconderle():
"""P12/D5: una riparazione silenziosa e' un'invenzione. Il taglio alla larghezza cambia il
peggior MTM, quindi le ore in cui morde vanno contate e stampate accanto al numero."""
normale = _trade(C.SHORT_D, C.LONG_D)
assert normale["n_ore"] == 3 and normale["n_tagli"] == 0
tagliato = _trade(C.SHORT_D, C.LONG_D, ask_s=0.200) # (0,200 0,010)·2000 = 380 > 300
assert tagliato["n_ore"] == 3 and tagliato["n_tagli"] == 3
assert tagliato["mtm_real_min"] == pytest.approx(tagliato["cred_real"] - LARGHEZZA)
def test_f_net_e_il_credito_reale_su_quello_modellato():
"""Il credito reale si incassa al BID della corta e si paga all'ASK della lunga: e' la
convenzione conservativa di `cblib.f_factors`, e vale 0,74 su ETH nel crollo."""
t = _trade(C.SHORT_D, C.LONG_D)
assert t["cred_real"] == pytest.approx((0.030 - 0.012) * 2000.0)
T = (C.EXP - TS0).total_seconds() / 86400.0 / 365.25
assert t["cred_mod"] == pytest.approx(
CB.bs_put(2000.0, K_S, T, 0.5) - CB.bs_put(2000.0, K_L, T, 0.5), abs=1e-10)
assert t["f_net"] == pytest.approx(t["cred_real"] / t["cred_mod"], abs=1e-12)
assert t["risk"] == pytest.approx(LARGHEZZA - t["cred_real"])
def test_trade_e_None_se_il_credito_non_e_positivo():
"""Un credit spread a credito ≤ 0 non e' la struttura che si sta studiando."""
assert _trade(C.SHORT_D, C.LONG_D, bid_s=0.010, ask_s=0.011, bid_l=0.020, ask_l=0.030) is None
# ===========================================================================================
# verdetto() — i rami
# ===========================================================================================
ESITO = ("IL CROLLO E' CATTURATO MA A VOL BASSA E FUORI DAL GATE DI VRP01: il f di stress che "
"la regola del 19/06 aspettava NON e' misurato nella sostanza — cio' che e' misurato "
"e' che il rapporto di credito f_net non cambia col regime")
def _r(f_exp=1.04, f_mtm=1.08, n=569, n_pre=286, n_fuori=2, f_net=0.74, q_itm=0.83,
n_strutture=4, n_tagli=8779, n_ore=213477, gate=0.0,
dur_f_net=0.71, dur_q_perde=0.46) -> dict:
return dict(n=n, n_pre=n_pre, n_fuori=n_fuori, f_net=f_net, f_exp=f_exp, f_mtm=f_mtm,
q_itm_pieno=q_itm, n_strutture=n_strutture, n_tagli=n_tagli, n_ore=n_ore,
gate_aperto=gate, durante=dict(f_net=dur_f_net, q_perde=dur_q_perde))
def test_verdetto_l_esito_e_una_CONCLUSIONE_non_un_esito_numerico():
"""La conclusione del 09/09 non e' "f vale x": e' che il crollo catturato sta a vol BASSA e
fuori dal gate, quindi il `f` di stress che la regola del 19/06 aspetta non e' misurato
nella sostanza. DEVE FALLIRE SE: qualcuno rimette un verdetto che dipende dal solo numero
e' la lettura che il numero da solo autorizzava e che la revisione ha tolto."""
for s in ({"BTC": _r(), "ETH": _r()},
{"BTC": _r(f_exp=3.0), "ETH": _r(f_exp=0.2)},
{"BTC": None, "ETH": None}):
v = C.verdetto(s)
assert v.startswith(ESITO)
assert v.endswith("— n = 1 crollo, 1 scadenza per asset, famiglia INVERSE")
def test_verdetto_NON_MISURABILE_quando_un_asset_non_ha_snapshot_in_geometria():
"""P5: lo stato "non vedo" ha un ramo suo e nomina la causa (griglia di strike rada)."""
v = C.verdetto({"BTC": dict(n=0, n_fuori=78), "ETH": None})
assert "BTC: NON misurabile — 78 snapshot tutti FUORI geometria (griglia di strike rada)" in v
assert "ETH: NON misurabile — 0 snapshot tutti FUORI geometria" in v
def test_verdetto_distingue_ZERO_SNAPSHOT_da_NESSUN_INGRESSO_PRE_CROLLO():
"""Due guasti diversi con la stessa azione (P5): "tutti fuori geometria" e "in geometria ma
nessun ingresso PRIMA del crollo" sono cause diverse e vanno dette diverse — e' esattamente
il caso di BTC il 09/09, dove resta solo la lente DURANTE."""
v = C.verdetto({"BTC": _r(f_exp=float("nan"), n=247, n_pre=0, n_fuori=78,
dur_f_net=0.79, dur_q_perde=0.3724696356275304),
"ETH": _r()})
assert "BTC: nessun ingresso pre-crollo in geometria (solo DURANTE: f_net 0.79, perde il 37%)" in v
assert "FUORI geometria" not in v.split("BTC:")[1].split(";")[0]
def test_verdetto_dichiara_che_il_f_a_scadenza_e_TAUTOLOGICO():
"""M13/§2: un numero si cita con la sua risoluzione. 286 snapshot su 4 strutture non sono
286 osservazioni, e a gambe entrambe ITM il `f` a scadenza e' una funzione di `f_net`.
Senza queste due note la riga "f 1,04" verrebbe letta come una misura indipendente."""
v = C.verdetto({"BTC": _r(), "ETH": _r()})
assert "a scadenza f 1.04 ma TAUTOLOGICO nel 83% dei trade" in v
assert "4 strutture, non 286 osservazioni" in v
assert "peggior MTM tagliato alla larghezza f 1.08" in v
assert "quote oltre la larghezza nel 4.1% delle ore" in v
def test_verdetto_dice_se_il_gate_di_VRP01_era_APERTO():
"""E' il fatto che decide se la misura parla del sleeve o di un altro trade: col gate chiuso
VRP01 quel trade NON lo avrebbe fatto."""
assert "gate IV-rank CHIUSO nel 0% delle ore" in C.verdetto({"BTC": _r(), "ETH": _r()})
assert "gate IV-rank APERTO nel 60% delle ore" in C.verdetto(
{"BTC": _r(gate=0.6), "ETH": _r(gate=0.6)})
# confine: 0,5 esatto non e' "aperto" (il confronto e' `>`)
assert "gate IV-rank CHIUSO nel 50% delle ore" in C.verdetto(
{"BTC": _r(gate=0.5), "ETH": _r(gate=0.5)})
def test_verdetto_non_divide_per_zero_quando_non_ci_sono_ore():
"""Un campione vuoto non deve far esplodere la riga: la quota di ore tagliate va a 0,0%."""
v = C.verdetto({"BTC": _r(n_tagli=0, n_ore=0), "ETH": _r()})
assert "quote oltre la larghezza nel 0.0% delle ore" in v
def test_verdetto_riproduce_ALLA_LETTERA_la_riga_pubblicata_il_09_09():
"""M23: la macchina deve ristampare il numero vecchio. Sono i valori esatti usciti da
`sintesi()` il 09/09 se il testo cambia, cambia anche cio' che il diario cita, e va
cambiato nei due posti insieme."""
s = {"BTC": _r(f_exp=float("nan"), f_mtm=float("nan"), n=247, n_pre=0, n_fuori=78,
f_net=0.7941019699391642, q_itm=float("nan"), n_strutture=0, n_tagli=0,
n_ore=0, gate=0.0, dur_f_net=0.7941019699391642,
dur_q_perde=0.3724696356275304),
"ETH": _r(f_exp=1.0383032287675409, f_mtm=1.0790935015712533, n=569, n_pre=286,
n_fuori=2, f_net=0.7405178198702741, q_itm=0.8286713286713286,
n_strutture=4, n_tagli=8779, n_ore=213477, gate=0.0)}
assert C.verdetto(s) == (
ESITO + ""
"BTC: nessun ingresso pre-crollo in geometria (solo DURANTE: f_net 0.79, perde il 37%); "
"ETH: f_net 0.74 (22/08 in rally 0,714); a scadenza f 1.04 ma TAUTOLOGICO nel 83% dei "
"trade (entrambe le gambe ITM ⇒ dipende solo da f_net; 4 strutture, non 286 "
"osservazioni); peggior MTM tagliato alla larghezza f 1.08 (quote oltre la larghezza "
"nel 4.1% delle ore); gate IV-rank CHIUSO nel 0% delle ore "
"— n = 1 crollo, 1 scadenza per asset, famiglia INVERSE")
# ===========================================================================================
# carica() e l'integrazione sull'archivio VERO
# ===========================================================================================
def test_carica_tiene_solo_put_una_scadenza_la_finestra_e_nessun_duplicato():
"""L'archivio ereditato copre molte scadenze e molti tipi: se il filtro si allentasse, il
`f` verrebbe misurato su una struttura diversa da quella dichiarata nel docstring."""
if not C.ARCHIVIO.exists():
pytest.skip(f"archivio ereditato assente: {C.ARCHIVIO} (data/raw e' gitignored)")
a = C.carica()
assert len(a) > 0
assert set(pd.unique(a["option_type"])) == {"P"}, "call rimaste dentro"
assert (a["exp"] == C.EXP).all(), "piu' di una scadenza"
assert a["ts"].min() >= C.INGRESSI[0], "quote prima della finestra dichiarata"
assert a["ts"].max() <= C.EXP, "quote dopo la scadenza"
assert not a.duplicated(subset=["ts", "instrument_name"]).any()
assert (a["dte"] >= 0).all()
assert a["dte"].max() <= (C.EXP - C.INGRESSI[0]).total_seconds() / 86400.0 + 1e-9
assert {"BTC", "ETH"} <= set(pd.unique(a["asset"]))
def test_integrazione_la_misura_del_09_09_si_riproduce():
"""~11 s. Riproduce cio' su cui poggia la (non) riapertura della decisione del 19/06.
I fatti che decidono, e che il test tiene fermi:
* il gate IV-rank di VRP01 e' CHIUSO nel 100% delle ore → il sleeve questo trade non lo
avrebbe fatto, quindi il `f` misurato non e' il suo `f` di stress;
* il `f` a scadenza (1,04) e' TAUTOLOGICO nella grande maggioranza dei trade;
* 286 snapshot stanno su POCHE strutture: la risoluzione non e' 286 (M13);
* BTC resta NON misurabile pre-crollo;
* col taglio alla larghezza il peggior MTM e' vicino a 1, non 2,6 (che era book, non
rischio) qui si controlla la TAGLIA, non la terza cifra."""
if not C.ARCHIVIO.exists():
pytest.skip(f"archivio ereditato assente: {C.ARCHIVIO}")
s = C.sintesi(C.misura())
eth = s["ETH"]
assert eth["n_pre"] > 200, "il campione pre-crollo si e' svuotato"
assert eth["f_net"] == pytest.approx(0.74, abs=0.03)
assert eth["f_exp"] == pytest.approx(1.04, abs=0.03)
assert eth["q_itm_pieno"] == pytest.approx(0.83, abs=0.10), (
"la quota di trade a f_exp tautologico e' cambiata: cambia la lettura del 1,04")
assert eth["n_strutture"] <= 10, (
f"{eth['n_pre']} snapshot su {eth['n_strutture']} strutture: se le strutture "
"diventassero tante, la nota sulla risoluzione andrebbe riscritta")
assert eth["gate_aperto"] == pytest.approx(0.0, abs=1e-12), (
"il gate IV-rank di VRP01 risulta APERTO: cambia il senso della misura, che oggi dice "
"'lo sleeve questo trade non lo avrebbe fatto'")
assert eth["perdita_su_risk"] == pytest.approx(1.01, abs=0.05)
assert 0.9 < eth["f_mtm"] < 1.5, (
f"peggior MTM {eth['f_mtm']:.2f}: col taglio alla larghezza sta attorno a 1; sopra 1,5 "
"vuol dire che il taglio non morde piu' e il numero torna a essere book, non rischio")
assert 0 < eth["n_tagli"] < eth["n_ore"] * 0.20, (
"il taglio deve mordere su una minoranza di ore: se e' zero non serve, se e' la "
"maggioranza il campione e' un altro")
btc = s["BTC"]
assert btc["n_pre"] == 0 and np.isnan(btc["f_exp"]), (
"BTC era NON misurabile pre-crollo: se ora lo e', il verdetto cambia e va riscritto, "
"non aggiustato")
assert btc["durante"]["n"] > 0 and 0.0 < btc["durante"]["q_perde"] < 1.0
v = C.verdetto(s)
assert v.startswith(ESITO)
assert "BTC: nessun ingresso pre-crollo in geometria" in v
assert "gate IV-rank CHIUSO nel 0% delle ore" in v
assert v.endswith("famiglia INVERSE")
+324
View File
@@ -0,0 +1,324 @@
"""Test di `scripts/research/r0909_deribit_universo.py` (2026-09-09) — l'universo Deribit letto oggi.
Lo script e' il filtro che decide COSA vale la pena misurare (C6): se sbaglia a leggere il venue,
sbaglia l'elenco delle monete candidate, e la misura successiva parte dalla domanda sbagliata.
Qui si congelano i tre pezzi che possono sbagliare in silenzio:
* i FILTRI un perpetual `state != "open"` non e' negoziabile e non deve comparire in una
tabella intitolata "aperti"; un future datato non e' un perpetual;
* lo SPREAD assente senza bid o senza ask lo spread e' NaN, non zero (P5: "non vedo" non e'
"spread perfetto", e uno zero stampato in quella colonna direbbe il contrario del vero);
* l'universo di XS01 — si IMPORTA da `sleeves.XS_UNIVERSE` (P1). Una lista ricopiata qui
risponderebbe "13/19 quotate" anche dopo che qualcuno ha cambiato le gambe del sleeve.
NESSUN test tocca la rete e nessuno scrive in `data/`: la cache viene deviata in `tmp_path`
e `get()` e' sostituita da una che fallisce se qualcuno la chiama.
"""
from __future__ import annotations
import inspect
import json
import sys
import time
from pathlib import Path
import numpy as np
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
import r0909_deribit_universo as U # noqa: E402
LETTO_A = "2026-09-09T15:05:43+00:00"
def _ms(giorno: str) -> int:
return int(time.mktime(time.strptime(giorno, "%Y-%m-%d")) * 1000) + int(
time.timezone * 1000)
def _instr(coin: str, nome: str, stato: str, listato: str, min_amt: float) -> dict:
# creation_timestamp e' letto con time.gmtime -> si costruisce in UTC puro
import calendar
ts = calendar.timegm(time.strptime(listato + " 12:00", "%Y-%m-%d %H:%M")) * 1000
return dict(base_currency=coin, instrument_name=nome, state=stato,
creation_timestamp=ts, min_trade_amount=min_amt)
def _bs(nome: str, bid, ask, mark, vol, oi=0.0, funding=0.0) -> dict:
return dict(instrument_name=nome, bid_price=bid, ask_price=ask, mark_price=mark,
volume_usd=vol, open_interest=oi, funding_8h=funding)
def _lettura() -> dict:
"""Una lettura sintetica del venue: 4 perpetual aperti, 1 INATTIVO, 2 future datati (di cui
uno sul DVOL l'unico strumento long-vol diretto del venue)."""
fut = [
_instr("XRP", "XRP_USDC-PERPETUAL", "open", "2022-03-16", 1.0),
_instr("ETH", "ETH_USDC-PERPETUAL", "open", "2022-03-14", 0.01),
_instr("ADA", "ADA_USDC-PERPETUAL", "open", "2022-03-21", 10.0),
_instr("PAXG", "PAXG_USDC-PERPETUAL", "open", "2024-12-02", 0.001),
_instr("APT", "APT_USDC-PERPETUAL", "inactive", "2022-11-02", 1.0),
_instr("BTC", "BTC_USDC-27JUN26", "open", "2022-03-09", 0.001),
_instr("BTCDVOL", "BTCDVOL_USDC-30SEP26", "open", "2026-06-01", 1.0),
]
bs = [
_bs("XRP_USDC-PERPETUAL", 2.0, 2.002, 2.0, 38.13e6, oi=3.0e6, funding=4.0e-5),
_bs("ETH_USDC-PERPETUAL", 3000.0, 3000.3, 3000.0, 32.72e6, oi=8.0e3, funding=1.05e-4),
_bs("ADA_USDC-PERPETUAL", None, 0.6, 0.6, 3.33e6, oi=1.0e6, funding=None),
_bs("PAXG_USDC-PERPETUAL", 0.0, 3600.0, 3600.0, 0.18e6, oi=2.0e3, funding=0.0),
_bs("APT_USDC-PERPETUAL", 1.0, 1.1, 1.0, 999e6, oi=1.0, funding=0.0),
_bs("BTC_USDC-27JUN26", 100000.0, 100001.0, 100000.0, 500e6, oi=1.0, funding=0.0),
_bs("BTCDVOL_USDC-30SEP26", 30.0, 60.0, 45.0, 0.0, oi=12.0, funding=None),
]
return dict(letto_a=LETTO_A, fut=fut, bs=bs,
opt_usdc=[dict(base_currency="SOL")] * 3 + [dict(base_currency="BTC")] * 2,
opt_inv={"BTC": 912, "ETH": 790})
# ===========================================================================================
# (a) tabella / sintesi / verdetto su una lettura SINTETICA
# ===========================================================================================
def test_tabella_esclude_i_perpetual_NON_APERTI():
"""Un perpetual `state != "open"` non e' negoziabile: se entrasse, sarebbe pure il piu'
liquido della tabella (999 M$ di volume stantio) e comparirebbe in cima all'elenco dei
candidati. DEVE FALLIRE SE: qualcuno toglie il filtro sullo stato."""
d = _lettura()
rows = U.tabella(d)
assert "APT" not in {r["coin"] for r in rows}
s = U.sintesi(rows, d)
assert s["inattivi"] == ["APT_USDC-PERPETUAL"], (
"un perpetual inattivo non va nella tabella, ma va DETTO: e' la differenza fra "
"'non esiste' e 'esiste e non si puo' negoziare' (P5)")
def test_un_perpetual_INATTIVO_conta_come_gamba_XS01_MANCANTE():
"""APT e' in XS01 ed e' listato sul venue, ma inattivo: la riga "N/19 quotate" deve
contarlo fra i mancanti, o il filtro di eseguibilita' direbbe il falso (C6)."""
d = _lettura()
s = U.sintesi(U.tabella(d), d)
assert "APT" in s["xs01_mancanti"] and "APT" not in s["xs01_listate"]
def test_tabella_esclude_i_FUTURE_DATATI_ma_la_sintesi_li_CONTA():
"""`kind=future` su Deribit contiene anche i future con scadenza: la domanda della tabella
e' sui PERPETUAL, e un future datato non e' una gamba direzionale continua. Restano contati
(`n_datati`) perche' e' li' dentro che vive il future sul DVOL."""
d = _lettura()
rows = U.tabella(d)
assert {r["coin"] for r in rows} == {"XRP", "ETH", "ADA", "PAXG"}
assert len(rows) == 4
assert U.sintesi(rows, d)["n_datati"] == 2
def test_il_future_sul_DVOL_viene_isolato_coi_suoi_numeri_di_negoziabilita():
"""C5/C6: e' l'unico strumento long-vol DIRETTO del venue, quindi va guardato — e va
guardato con bid/ask e OI accanto, perche' e' li' che si vede che non e' negoziabile
(bid 30 / ask 60 = 67% di spread). Un elenco di nomi non basterebbe."""
d = _lettura()
s = U.sintesi(U.tabella(d), d)
assert [f["nome"] for f in s["dvol_fut"]] == ["BTCDVOL_USDC-30SEP26"], (
"il future datato SENZA 'DVOL' nel nome non deve entrare qui")
f = s["dvol_fut"][0]
assert (f["stato"], f["min_amt"], f["bid"], f["ask"], f["oi"]) == ("open", 1.0, 30.0, 60.0, 12.0)
assert f["vol_usd"] == 0.0
def test_tabella_e_ORDINATA_per_volume_decrescente():
rows = U.tabella(_lettura())
vol = [r["vol_usd"] for r in rows]
assert vol == sorted(vol, reverse=True)
assert [r["coin"] for r in rows] == ["XRP", "ETH", "ADA", "PAXG"]
def test_lo_spread_e_NAN_senza_bid_o_senza_ask():
"""P5: senza un lato del book lo spread non e' 0 bps (che vorrebbe dire "perfetto"), e'
"non misurabile". DEVE FALLIRE SE: qualcuno mette un `or 0.0` anche su bid/ask."""
rows = {r["coin"]: r for r in U.tabella(_lettura())}
assert np.isnan(rows["ADA"]["spread_bps"]), "bid assente -> spread non misurabile"
assert np.isnan(rows["PAXG"]["spread_bps"]), "bid a zero -> spread non misurabile"
assert rows["XRP"]["spread_bps"] == pytest.approx((2.002 - 2.0) / 2.001 * 1e4)
assert rows["ETH"]["spread_bps"] == pytest.approx(0.3 / 3000.15 * 1e4)
def test_il_lotto_minimo_e_in_DOLLARI_non_in_unita():
"""C3: prima di misurare il rendimento a un capitale dato si misura il lotto minimo del
venue e il confronto col pavimento $5 si fa in dollari, non in unita' della moneta."""
rows = {r["coin"]: r for r in U.tabella(_lettura())}
assert rows["XRP"]["min_usd"] == pytest.approx(1.0 * 2.0)
assert rows["ADA"]["min_usd"] == pytest.approx(10.0 * 0.6)
assert rows["ETH"]["min_amt"] == 0.01
def test_la_data_di_listing_e_quella_del_venue():
rows = {r["coin"]: r for r in U.tabella(_lettura())}
assert rows["XRP"]["listato"] == "2022-03-16"
assert rows["PAXG"]["listato"] == "2024-12-02"
def test_P1_le_gambe_di_XS01_si_IMPORTANO_dal_sleeve():
"""*Un controllo puntato su una configurazione diversa da quella che gira passa sempre.*
Se l'elenco fosse ricopiato nello script, "13/19 quotate" resterebbe vero anche dopo un
cambio di universo del sleeve cioe' smetterebbe di controllare qualcosa."""
from src.portfolio import sleeves
assert U.XS_UNIVERSE is sleeves.XS_UNIVERSE
src = inspect.getsource(U)
assert "from src.portfolio.sleeves import XS_UNIVERSE" in src
assert "XS_UNIVERSE = [" not in src, "universo XS01 ridichiarato nello script"
def test_le_gambe_XS01_mancanti_si_CALCOLANO_dall_universo():
"""La riga "mancano [...]" e' l'elenco delle gambe di XS01 non quotate sul venue: si
ricava dall'universo importato, quindi si aggiorna da sola se il sleeve cambia."""
from src.portfolio.sleeves import XS_UNIVERSE
rows = U.tabella(_lettura())
s = U.sintesi(rows, _lettura())
quotate = {"XRP", "ETH", "ADA"} # PAXG non e' in XS01
assert s["xs01_listate"] == sorted(c for c in XS_UNIVERSE if c in quotate)
assert s["xs01_mancanti"] == [c for c in XS_UNIVERSE if c not in quotate]
assert set(s["xs01_listate"]) | set(s["xs01_mancanti"]) == set(XS_UNIVERSE)
assert {r["coin"] for r in rows if r["xs01"]} == quotate
def test_liquidi_usa_la_soglia_dichiarata_e_il_confine_e_incluso():
"""La soglia $10M/giorno e' dichiarata nel modulo: il test la DERIVA, non la ricopia."""
d = _lettura()
for b in d["bs"]:
if b["instrument_name"] == "ADA_USDC-PERPETUAL":
b["volume_usd"] = U.SOGLIA_LIQUIDO_USD # esattamente in soglia -> liquido
if b["instrument_name"] == "PAXG_USDC-PERPETUAL":
b["volume_usd"] = U.SOGLIA_LIQUIDO_USD - 1.0 # un dollaro sotto -> no
s = U.sintesi(U.tabella(d), d)
assert s["liquidi"] == ["XRP", "ETH", "ADA"]
def test_sintesi_riporta_le_famiglie_di_opzioni_e_l_ora_della_lettura():
"""N10/§7: un numero letto dal venue vale con la sua ora. Senza `letto_a` la riga
diventerebbe una verita' senza data."""
d = _lettura()
s = U.sintesi(U.tabella(d), d)
assert s["letto_a"] == LETTO_A
assert s["opt_inv"] == {"BTC": 912, "ETH": 790}
assert s["opt_usdc"] == [("SOL", 3), ("BTC", 2)]
assert s["n_perp"] == 4
def test_verdetto_su_una_lettura_sintetica():
d = _lettura()
s = U.sintesi(U.tabella(d), d)
v = U.verdetto(s)
assert v.startswith("4 perpetual USDC; LIQUIDI (≥$10M/g): XRP, ETH; ")
assert "XS01 3/19 quotate" in v
assert "opzioni USDC-lineari su 2 sottostanti" in v
assert f"lettura del {LETTO_A}" in v
# ===========================================================================================
# (b) leggi() — la cache, e la garanzia che NESSUN test vada in rete
# ===========================================================================================
@pytest.fixture()
def cache_in_tmp(tmp_path, monkeypatch):
"""Devia la cache fuori da `data/_cache/` (debito §5.12: la suite non scrive nei file vivi)."""
p = tmp_path / "r0909_deribit_universo.json"
monkeypatch.setattr(U, "CACHE", p)
return p
def test_leggi_con_cache_presente_NON_tocca_la_rete(cache_in_tmp, monkeypatch):
"""DEVE FALLIRE SE: qualcuno rende la lettura sempre fresca — la suite comincerebbe a
dipendere dalla rete e dal venue, e un test rosso non direbbe piu' niente sul codice."""
def _vietato(path):
raise AssertionError(f"chiamata di rete durante un test: {path}")
monkeypatch.setattr(U, "get", _vietato)
atteso = _lettura()
cache_in_tmp.write_text(json.dumps(atteso))
d = U.leggi(usa_cache=True)
assert d["letto_a"] == LETTO_A
assert len(d["fut"]) == len(atteso["fut"])
# (i NaN dello spread non sono confrontabili con ==: si confronta il resto)
chiave = lambda rows: [(r["coin"], r["listato"], r["vol_usd"], r["min_usd"], r["xs01"])
for r in rows]
assert chiave(U.tabella(d)) == chiave(U.tabella(atteso))
def test_leggi_senza_cache_interroga_il_venue_UNA_volta_e_la_scrive(cache_in_tmp, monkeypatch):
"""La cache non e' un'ottimizzazione: e' la FONTE del numero stampato (una lettura e' un
istante). Deve finire su disco con la sua ora, o la riga del diario non ha piu' fonte."""
chiamate = []
def _finto(path):
chiamate.append(path)
if "get_book_summary" in path:
return _lettura()["bs"]
if "kind=option" in path and "currency=USDC" in path:
return _lettura()["opt_usdc"]
if "kind=option" in path:
return [0] * 912
return _lettura()["fut"]
monkeypatch.setattr(U, "get", _finto)
assert not cache_in_tmp.exists()
d = U.leggi(usa_cache=True)
assert cache_in_tmp.exists(), "la lettura non e' stata salvata: il numero resta senza fonte"
assert json.loads(cache_in_tmp.read_text())["letto_a"] == d["letto_a"]
assert d["letto_a"].endswith("+00:00"), "l'ora della lettura dev'essere in UTC dichiarato"
assert d["opt_inv"] == {"BTC": 912, "ETH": 912}
assert len(chiamate) == 5, "una chiamata per famiglia: fut, book summary, opzioni USDC, BTC, ETH"
# e una seconda lettura con cache NON ripete le chiamate
prima = len(chiamate)
U.leggi(usa_cache=True)
assert len(chiamate) == prima
def test_fresh_ignora_la_cache(cache_in_tmp, monkeypatch):
"""`--fresh` deve rileggere davvero: e' l'unico modo di aggiornare una tabella che per
costruzione invecchia (i perpetual del 18/08/2026 non c'erano un mese prima)."""
cache_in_tmp.write_text(json.dumps(_lettura()))
chiamate = []
monkeypatch.setattr(U, "get", lambda path: chiamate.append(path) or [])
U.leggi(usa_cache=False)
assert chiamate, "con usa_cache=False la cache non va usata"
def test_il_flag_fresh_e_DOCUMENTATO_e_cablato():
"""P15: una regola operativa va provata contro il codice che dovrebbe eseguirla. Qui:
il flag esiste nel `main`, e' l'unico modo di bypassare la cache, ed e' scritto nell'uso
che lo script stampa (chi legge la tabella deve sapere che puo' essere vecchia)."""
src = inspect.getsource(U)
assert 'leggi(usa_cache="--fresh" not in sys.argv)' in src, "il flag non e' cablato"
assert "--fresh per rileggere" in src, (
"il flag non e' documentato nell'uso che lo script stampa: chi legge la tabella non "
"sa come aggiornarla, e la lettura in cache invecchia in silenzio")
def test_lo_script_NON_ha_credenziali_ne_ordini():
"""L'API interrogata e' quella PUBBLICA: nessun token, nessun endpoint privato, nessun
ordine. Se un giorno servisse il conto, e' una decisione (debito §5.11), non un refactor."""
src = inspect.getsource(U)
assert U.API == "https://www.deribit.com/api/v2/public/"
for vietato in ("private/", "buy", "sell", "CERBERO", "api_key", "client_id"):
assert vietato not in src, f"lo script pubblico contiene '{vietato}'"
# ===========================================================================================
# la lettura VERA in cache (se c'e'): il file su cui poggia la riga del 09/09
# ===========================================================================================
def test_la_cache_vera_se_c_e_regge_la_tabella_pubblicata():
"""Non e' un test di rete: legge il JSON che lo script ha gia' salvato. Se manca, si salta."""
if not U.CACHE.exists():
pytest.skip(f"nessuna lettura in cache ({U.CACHE}): il test non va in rete")
d = json.loads(U.CACHE.read_text())
rows = U.tabella(d)
s = U.sintesi(rows, d)
assert s["n_perp"] == len(rows) > 0
assert [r["vol_usd"] for r in rows] == sorted([r["vol_usd"] for r in rows], reverse=True)
assert {"BTC", "ETH"} <= {r["coin"] for r in rows}, "il venue non quota piu' BTC/ETH?"
assert set(s["xs01_listate"]) | set(s["xs01_mancanti"]) == set(U.XS_UNIVERSE)
assert U.verdetto(s).endswith(f"lettura del {d['letto_a']}")
+385
View File
@@ -0,0 +1,385 @@
"""Test di `scripts/research/r0909_libro_nei_crolli.py` (2026-09-09) — il libro nei crolli.
Cosa protegge, in due strati:
* il MECCANISMO su dati SINTETICI, dove la risposta e' nota per costruzione — le classi
ESCLUSIVE col loro ordine, le finestre peggiori non sovrapposte, il beta con pendenza
imposta, i rami del verdetto. Qui un test rosso significa "il codice ha cambiato
comportamento", mai "il mercato ha cambiato idea".
* la RIPRODUZIONE del risultato pubblicato il 09/09 sui dati veri: 4 POSITIVO / 6 immune /
2 PERDE su 12 finestre, libro > 0 in 8 (dal 2022: 8/8), e beta +0,0769 sulla finestra di
§46 che riproduce il +0,076 della memoria. E' M23: prima di pubblicare un numero nuovo, la
macchina deve ristampare quello vecchio.
🚨 LA REGRESSIONE PIU' IMPORTANTE DI QUESTO FILE. `classifica()` prova **immune PRIMA di
POSITIVO**, e l'ordine non e' un dettaglio: con l'ordine opposto lo stesso identico dato dava
"8/12 episodi in utile" invece di "4 POSITIVO / 6 immune". *L'ordine delle regole ERA il
verdetto* (corretto in revisione il 09/09). Un test lo congela.
P1: i pesi del libro NON si ridichiarano un test congela l'identita' fra i pesi usati dallo
script e `src.live.book.W_TP01` / `W_SKH`.
"""
from __future__ import annotations
import inspect
import re
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))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
import r0909_libro_nei_crolli as M # noqa: E402
RAW = ROOT / "data" / "raw"
def _feed_certificato_presente() -> bool:
return all((RAW / f"{a}_{tf}.parquet").exists()
for a in ("btc", "eth") for tf in ("1h", "5m"))
def _giorni(n: int, inizio: str = "2021-01-01") -> pd.DatetimeIndex:
return pd.date_range(inizio, periods=n, freq="D", tz="UTC")
# ===========================================================================================
# classifica() — classi ESCLUSIVE, e l'ORDINE in cui si provano
# ===========================================================================================
def test_classifica_prova_IMMUNE_PRIMA_di_POSITIVO():
"""🚨 La regressione del 09/09. Sugli stessi numeri l'ordine "POSITIVO prima" dava 8/12
episodi in utile; l'ordine dichiarato (immune prima) da' 4 POSITIVO + 6 immune.
DEVE FALLIRE SE: qualcuno rimette il test del segno davanti alla banda di immunita'
non e' un refactor, e' un altro verdetto."""
# peggiore #8 del 09/09: +0,21% di libro contro 26,54% di indice. E' dentro un decimo.
assert M.classifica(0.0021, -0.2654) == "immune+"
# peggiore #5: +4,26% contro 31,61% -> fuori dalla banda, e allora e' POSITIVO
assert M.classifica(0.0426, -0.3161) == "POSITIVO"
def test_classifica_immune_porta_il_SEGNO_con_se():
""""immune" non e' una classe cieca: +0,21% e 0,52% su un crollo del 26% sono entrambi
immunita', ma non sono la stessa notizia. DEVE FALLIRE SE: le due si fondono in una."""
assert M.classifica(0.0021, -0.2654) == "immune+"
assert M.classifica(-0.0052, -0.2583) == "immune-"
assert set(M.classifica(x, -0.30) for x in (0.001, -0.001)) == {"immune+", "immune-"}
def test_classifica_fuori_dalla_banda_resta_il_segno():
assert M.classifica(+0.05, -0.30) == "POSITIVO"
assert M.classifica(-0.05, -0.30) == "PERDE"
assert M.classifica(-0.0637, -0.3411) == "PERDE" # peggiore #1 del 09/09
def test_classifica_il_confine_della_banda_e_STRETTO():
"""Esattamente un decimo NON e' immune: il confronto e' `<`, non `<=`.
DEVE FALLIRE SE: qualcuno lo rilassa e sposta episodi dentro la banda."""
assert M.classifica(-0.010, -0.10) == "PERDE"
assert M.classifica(-0.010000001, -0.10) == "PERDE"
assert M.classifica(-0.009999999, -0.10) == "immune-"
assert M.classifica(+0.010, -0.10) == "POSITIVO"
assert M.classifica(+0.009999999, -0.10) == "immune+"
def test_classifica_a_indice_zero_non_esiste_immunita():
"""Caso limite DICHIARATO (non un bug): con indice 0 la banda ha ampiezza 0, quindi nulla
e' immune. Non morde negli episodi (per costruzione hanno indice molto negativo), ma chi
riusa `classifica()` altrove deve saperlo."""
assert M.classifica(0.0, 0.0) == "PERDE"
assert M.classifica(+0.001, 0.0) == "POSITIVO"
def test_classifica_un_libro_esattamente_fermo_e_immune_col_segno_MENO():
"""Zero non e' positivo: `libro > 0` e' falso, quindi la classe e' "immune-".
E' una convenzione, e va scritta perche' non e' l'unica possibile."""
assert M.classifica(0.0, -0.20) == "immune-"
# ===========================================================================================
# finestre_peggiori() — non sovrapposte, ordinate, al piu' N, e la peggiore e' la peggiore
# ===========================================================================================
def _serie_con_crolli(crolli: list[tuple[int, float]], n: int = 300) -> pd.Series:
"""Serie giornaliera piatta con crolli piantati a posizioni note: ogni `(i0, r)` mette
20 giorni consecutivi a rendimento `r` a partire dalla posizione `i0`."""
r = pd.Series(0.0, index=_giorni(n))
for i0, val in crolli:
r.iloc[i0:i0 + M.FINESTRA] = val
return r
CROLLI = [(30, -0.010), (100, -0.005), (180, -0.002)]
def test_finestre_peggiori_non_si_sovrappongono_e_sono_ordinate():
"""DEVE FALLIRE SE: il greedy smette di marcare gli intervalli occupati — senza quel filtro
le 12 "peggiori finestre" sarebbero 12 traslazioni di UNO stesso episodio, e la riga
"4/12" conterebbe 12 volte lo stesso crollo."""
idx = _serie_con_crolli(CROLLI)
prese = M.finestre_peggiori(idx, n=3)
assert len(prese) == 3
assert prese == sorted(prese), "l'elenco esce ordinato per data d'inizio"
for (s0, e0, _), (s1, _, _) in zip(prese, prese[1:]):
assert s1 > e0, f"finestre sovrapposte: {e0} e {s1}"
for s, e, _ in prese:
assert e - s == pd.Timedelta(days=M.FINESTRA - 1)
def test_finestre_peggiori_trova_DAVVERO_la_peggiore():
"""Il primo episodio dev'essere il minimo globale del rolling, non un minimo locale.
DEVE FALLIRE SE: qualcuno sostituisce il greedy dal peggiore con una scansione in avanti."""
idx = _serie_con_crolli(CROLLI)
prese = M.finestre_peggiori(idx, n=3)
roll = (1 + idx).rolling(M.FINESTRA).apply(np.prod, raw=True) - 1
peggiore = min(prese, key=lambda t: t[2])
assert peggiore[2] == pytest.approx(float(roll.min()), abs=1e-12)
assert peggiore[2] == pytest.approx((1 - 0.010) ** M.FINESTRA - 1, abs=1e-12)
assert peggiore[0] == idx.index[30] and peggiore[1] == idx.index[49]
valori = sorted(v for _, _, v in prese)
attesi = sorted((1 + r) ** M.FINESTRA - 1 for _, r in CROLLI)
assert valori == pytest.approx(attesi, abs=1e-12)
def test_finestre_peggiori_rispetta_il_tetto_n():
idx = _serie_con_crolli(CROLLI)
assert len(M.finestre_peggiori(idx, n=2)) == 2
assert len(M.finestre_peggiori(idx, n=1)) == 1
assert len(M.finestre_peggiori(idx, n=100)) <= 100
# ===========================================================================================
# beta_pieno / beta_ribasso — pendenza NOTA per costruzione
# ===========================================================================================
def test_beta_pieno_ritrova_una_pendenza_nota():
rng = np.random.default_rng(909)
i = pd.Series(rng.normal(0.0, 0.03, 400), index=_giorni(400))
assert M.beta_pieno(0.30 * i, i) == pytest.approx(0.30, abs=1e-9)
assert M.beta_pieno(0.0 * i, i) == pytest.approx(0.0, abs=1e-12)
assert M.beta_pieno(-0.75 * i, i) == pytest.approx(-0.75, abs=1e-9)
def test_beta_ribasso_guarda_SOLO_i_giorni_giu():
"""Controllo POSITIVO: su una serie asimmetrica (0,50 in salita, 0,10 in discesa) il beta
pieno NON puo' rispondere alla domanda del crollo — il beta al ribasso si'.
DEVE FALLIRE SE: il filtro `idx < soglia` sparisce e le due misure collassano in una."""
rng = np.random.default_rng(46)
i = pd.Series(rng.normal(0.0, 0.03, 600), index=_giorni(600))
libro = pd.Series(np.where(i.values < 0, 0.10 * i.values, 0.50 * i.values), index=i.index)
assert M.beta_ribasso(libro, i) == pytest.approx(0.10, abs=1e-9)
assert M.beta_pieno(libro, i) > 0.20, "il beta pieno MEDIA i due regimi: e' un'altra domanda"
def test_beta_ribasso_usa_la_soglia_che_riceve():
"""La riga [1] stampa quattro beta (pieno, finestra §46, idx<0, idx≤−3%): sono lenti
diverse, non una."""
rng = np.random.default_rng(3)
i = pd.Series(rng.normal(0.0, 0.03, 800), index=_giorni(800))
forte = i.values < -0.03
libro = pd.Series(np.where(forte, 0.90 * i.values, 0.10 * i.values), index=i.index)
assert (i < -0.03).sum() >= 20, "campione insufficiente: il test non avrebbe potenza"
b3 = M.beta_ribasso(libro, i, -0.03)
b0 = M.beta_ribasso(libro, i, 0.0)
assert b3 == pytest.approx(0.90, abs=1e-9), "sotto 3% la pendenza e' quella imposta"
assert abs(b0 - b3) > 0.05, (
"la soglia non entra nel calcolo: le lenti darebbero lo stesso numero e la riga [1] "
"stamperebbe quattro volte la stessa cosa")
def test_beta_ribasso_e_NAN_sotto_venti_osservazioni():
"""P5: "non vedo" non e' "beta zero". DEVE FALLIRE SE: qualcuno toglie la guardia e
pubblica una pendenza stimata su tre giorni."""
i = pd.Series(np.linspace(-0.05, -0.01, 10), index=_giorni(10))
assert np.isnan(M.beta_ribasso(0.3 * i, i)), "10 giorni non fanno una pendenza"
i25 = pd.Series(np.linspace(-0.05, -0.01, 25), index=_giorni(25))
assert M.beta_ribasso(0.3 * i25, i25) == pytest.approx(0.30, abs=1e-9)
def test_la_finestra_di_46_e_dichiarata_e_non_e_tutta_la_storia():
"""§46 e' un numero NATO su una finestra che comincia col DVOL (2021-03-24): confrontarlo
col beta di tutta la storia sarebbe P7 al contrario (etichettare un numero con la finestra
sbagliata). Lo script tiene le due misure separate questo test lo congela."""
assert M.FINESTRA_46 == pd.Timestamp("2021-03-24", tz="UTC")
assert M.BETA_MEMORIA == 0.076 and M.TOL_BETA == 0.01
# ===========================================================================================
# verdetto() — TRE affermazioni, tre domande diverse
# ===========================================================================================
def _m(guadagna=4, immune=6, n=12, beta_46=0.0769, giorni_libro=-0.0048,
giorni_pos=0.175, positivi=8, dal22=(8, 8), quota=1.08) -> dict:
return dict(beta_46=beta_46,
sintesi=dict(n_episodi=n, guadagna=guadagna, immune=immune,
perde=n - guadagna - immune, positivi=positivi, dal22=dal22,
giorni_pos=giorni_pos, giorni_libro=giorni_libro,
quota_short_nei_guadagni=quota))
def test_verdetto_separa_il_GIORNO_dalla_FINESTRA():
"""Le due domande hanno risposte OPPOSTE sugli stessi dati (nel giorno perde, per finestra
e' immune-o-positivo), ed e' il fatto centrale del 09/09: SKH01 e' marcato all'uscita, e
uno short aperto nel crollo viene accreditato dopo. Un solo aggettivo mentirebbe."""
v = M.verdetto(_m())
assert v.startswith("NEL GIORNO DEL CROLLO IL LIBRO PERDE")
assert "IMMUNE-O-POSITIVO per finestra" in v
assert M.verdetto(_m(giorni_libro=+0.0012)).startswith(
"NEL GIORNO DEL CROLLO IL LIBRO GUADAGNA")
def test_verdetto_i_tre_rami_della_FINESTRA():
"""Le soglie sono 2/3 sui POSITIVO e 2/3 su POSITIVO+immune.
DEVE FALLIRE SE: qualcuno le sposta guardando l'esito (selezione sull'hold-out)."""
assert "POSITIVO per finestra" in M.verdetto(_m(guadagna=8, immune=2))
assert "IMMUNE-O-POSITIVO per finestra" in M.verdetto(_m(guadagna=4, immune=6))
assert "PERDE per finestra" in M.verdetto(_m(guadagna=3, immune=2))
def test_verdetto_i_confini_delle_due_soglie():
v8 = M.verdetto(_m(guadagna=8, immune=0)) # 8 >= 8.0
assert "POSITIVO per finestra" in v8 and "IMMUNE-O-POSITIVO" not in v8
assert "IMMUNE-O-POSITIVO per finestra" in M.verdetto(_m(guadagna=7, immune=1))
assert "PERDE per finestra" in M.verdetto(_m(guadagna=7, immune=0))
def test_verdetto_dice_se_il_beta_RIPRODUCE_la_memoria_e_su_QUALE_finestra():
"""La tolleranza e' TOL_BETA e il beta confrontato e' quello della finestra di §46, non il
beta pieno: due numeri diversi (0,0769 contro 0,086), e solo il primo va confrontato."""
# ⚠️ il confine esatto non e' asseribile in virgola mobile: 0,076 + 0,01 vale 0,086 e la
# differenza torna 0,009999999999999995, cioe' DENTRO. Si prova un micron di qua e di la'.
dentro = M.BETA_MEMORIA + M.TOL_BETA - 1e-6
fuori = M.BETA_MEMORIA + M.TOL_BETA + 1e-6
assert "(riproduce il" in M.verdetto(_m(beta_46=dentro))
assert "(NON riproduce il" not in M.verdetto(_m(beta_46=dentro))
assert "(NON riproduce il" in M.verdetto(_m(beta_46=fuori))
assert "sulla finestra di §46" in M.verdetto(_m())
assert f"+{M.BETA_MEMORIA:.3f}" in M.verdetto(_m())
def test_verdetto_dichiara_che_SKH01_e_marcato_all_uscita():
"""P4: l'allerta (qui: la riga) risponde anche al "perche'". Senza quella nota, il lettore
vede SKH01 0 nella tabella per giorno e il guadagno nella tabella per finestra, e non ha
modo di sapere che e' la stessa serie letta in due modi."""
assert "marcata all'uscita" in M.verdetto(_m())
def test_verdetto_riproduce_ALLA_LETTERA_la_riga_pubblicata_il_09_09():
"""M23: la macchina deve ristampare il numero vecchio. Sono i valori esatti usciti da
`misura()` il 09/09 se il testo cambia, cambia anche cio' che il diario cita, e va
cambiato nei due posti insieme."""
m = _m(guadagna=4, immune=6, n=12, beta_46=0.07691539529362904,
giorni_libro=-0.004841811670794324, giorni_pos=0.175, positivi=8, dal22=(8, 8),
quota=1.0793712686517833)
assert M.verdetto(m) == (
"NEL GIORNO DEL CROLLO IL LIBRO PERDE (-0.48%/g, positivo nel 18% dei giorni ≤ 5%); "
"IMMUNE-O-POSITIVO per finestra: 4 POSITIVO / 6 immune / 2 PERDE su 12 "
"(libro > 0 in 8, dal 2022 in 8/8); beta +0.0769 sulla finestra di §46 "
"(riproduce il +0.076); la gamba short di SKH01 (marcata all'uscita) fa il 108% "
"dei guadagni negli episodi con libro > 0")
# ===========================================================================================
# P1 — i pesi si IMPORTANO dal book live
# ===========================================================================================
def test_P1_i_pesi_sono_QUELLI_del_book_live_non_una_copia():
"""*Un controllo puntato su una configurazione diversa da quella che gira passa sempre.*
L'identita' (`is`) vale solo se i nomi sono legati allo STESSO oggetto dell'import: un
`W_TP01 = 0.75` riscritto nello script la romperebbe anche a valore uguale."""
import src.live.book as B
assert M.W_TP01 is B.W_TP01, "TP01: peso ridichiarato invece che importato da src/live/book"
assert M.W_SKH is B.W_SKH, "SKH01: peso ridichiarato invece che importato da src/live/book"
assert M.W_TP01 + M.W_SKH == pytest.approx(1.0)
src = inspect.getsource(M)
assert "from src.live.book import" in src
assert not re.search(r"^W_TP01\s*=", src, re.M), "peso TP01 ridichiarato a livello di modulo"
assert not re.search(r"^W_SKH\s*=", src, re.M), "peso SKH01 ridichiarato a livello di modulo"
def test_la_riga_informativa_37_5_62_5_NON_e_il_peso_del_libro():
"""§26 compare come riga informativa. DEVE FALLIRE SE: qualcuno la promuove a peso di
default il 75/25 e' decisione vincolante, la riapre solo `weights_tilt_null`."""
assert M.W_INFO == (0.375, 0.625)
assert (M.W_TP01, M.W_SKH) != M.W_INFO
# ===========================================================================================
# INTEGRAZIONE sui dati veri
# ===========================================================================================
def test_la_scomposizione_di_SKH01_in_long_e_short_e_ESATTA():
"""~2 s. Due guardie, entrambe P1:
(a) la versione "tutto" dev'essere BIT-EXACT con `sleeves._skyhook_returns` — se non lo
fosse, l'attribuzione descriverebbe un altro sleeve;
(b) long + short == intera. In principio filtrare gli entry-dict potrebbe cambiare il
non-overlap (un long che non occupa piu' la barra lascia entrare uno short): la
misura dice che qui non succede, e il test lo tiene vero."""
if not _feed_certificato_presente():
pytest.skip("feed certificato BTC/ETH 5m/1h assente in data/raw (gitignored)")
from src.portfolio.sleeves import _skyhook_returns
d = M.skh_per_direzione()
assert set(d) == {"tutto", "long", "short"}
J = pd.concat(d, axis=1, join="inner")
assert float((J["long"] + J["short"] - J["tutto"]).abs().max()) < 1e-12, (
"long + short != intera: la scomposizione non e' un'attribuzione, e' un'altra misura")
ref = _skyhook_returns()
if ref.index.tz is None:
ref.index = ref.index.tz_localize("UTC")
comune = d["tutto"].index.intersection(ref.index)
assert len(comune) > 1000
assert float((d["tutto"].loc[comune] - ref.loc[comune]).abs().max()) == 0.0, (
"la versione 'tutto' non e' piu' bit-exact col sleeve di ricerca")
def test_integrazione_il_risultato_pubblicato_del_09_09_si_riproduce():
"""~2 s. Se questo test diventa rosso NON va "aggiornato": va capito se e' cambiato il
dato (rebuild del feed) o il codice, e la riga citata nel diario va rifatta.
I due fatti che decidono: (1) per FINESTRA il libro non perde quasi mai 4 POSITIVO +
6 immune su 12, e libro > 0 in 8/8 episodi dal 2022; (2) NEL GIORNO del crollo perde
(0,48%/g, positivo solo nel 18% dei giorni 5%). Sono due risposte opposte alla stessa
domanda dell'operatore, e stanno insieme perche' SKH01 e' marcato all'uscita."""
if not _feed_certificato_presente():
pytest.skip("feed certificato BTC/ETH 5m/1h assente in data/raw (gitignored)")
m = M.misura()
assert m["beta"] == pytest.approx(0.086, abs=0.03), "beta pieno fuori dal valore pubblicato"
assert abs(m["beta_46"] - M.BETA_MEMORIA) < M.TOL_BETA, (
f"il beta sulla finestra di §46 ({m['beta_46']:+.4f}) non riproduce piu' il "
f"+{M.BETA_MEMORIA:.3f}: e' la riproduzione che giustifica la lente")
s = m["sintesi"]
assert s["n_episodi"] == 12
assert (s["guadagna"], s["immune"], s["perde"]) == (4, 6, 2)
assert s["guadagna"] + s["immune"] + s["perde"] == s["n_episodi"]
assert s["positivi"] == 8, "il conteggio nudo libro > 0 e' l'altra lettura, e va stampata"
assert s["dal22"] == (8, 8), "dal 2022 ogni episodio ha il libro in utile"
assert s["giorni_libro"] < 0, "nel GIORNO del crollo il libro perde: e' la meta' scomoda"
assert s["giorni_pos"] == pytest.approx(0.175, abs=0.05)
classi = [r["classe"] for r in m["episodi"]]
assert classi.count("POSITIVO") == s["guadagna"]
assert sum(c.startswith("immune") for c in classi) == s["immune"]
assert classi.count("PERDE") == s["perde"]
dal_2022 = [r for r in m["episodi"] if int(r["da"][:4]) >= 2022]
assert len(dal_2022) == 8
assert all(r["libro"] > 0 for r in dal_2022), (
"un episodio dal 2022 in poi non ha piu' il libro in utile: "
+ str([(r["nome"], r["da"], r["libro"]) for r in dal_2022]))
v = M.verdetto(m)
assert v.startswith("NEL GIORNO DEL CROLLO IL LIBRO PERDE")
assert "IMMUNE-O-POSITIVO per finestra" in v
assert "(riproduce il +0.076)" in v
+351
View File
@@ -0,0 +1,351 @@
"""Test di `scripts/research/r0909_xrp_terza_gamba.py` (2026-09-09) — XRP come terza gamba.
Tre cose vanno protette, e nessuna e' il numero finale:
1. **Il paginatore.** `_fetch_1h` esiste nella forma che ha perche' Coinbase ha un BUCO su XRP
(delistato 2021-01, rilistato 2023-07 dopo la sentenza SEC): un batch vuoto NON deve
fermare il ciclo. Un paginatore che si ferma al primo vuoto avrebbe certificato XRP su
mezzo campione e la certificazione decide QUALE lente si usa, cioe' decide il risultato.
2. **La regola L-PULITA.** "Dal primo anno in cui tutti i successivi restano sotto soglia":
un anno pulito ISOLATO non e' un regime. E' la regola che sceglie la finestra, ed e'
dichiarata prima di guardare l'esito — quindi va congelata prima che qualcuno la ritocchi.
3. **I tre rami del verdetto**, coi loro AND: AGGIUNGE richiede hold-out E de-levering E anni.
Piu' il guardrail del namespace: XRP vive in `alt_xrp_*`, e `load_data("XRP")` deve continuare
a fallire (il "solo dati certi" di CLAUDE.md non e' codice, e' l'assenza del file).
NESSUN test va in rete: il riferimento Coinbase/Bitstamp o e' in cache o il test si salta.
"""
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))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
import r0909_xrp_terza_gamba as M # noqa: E402
RAW = ROOT / "data" / "raw"
ORA_MS = 3_600_000
T0 = pd.Timestamp("2026-01-01", tz="UTC")
# ===========================================================================================
# (a) _fetch_1h — un batch vuoto NON ferma il ciclo
# ===========================================================================================
class ExFinto:
"""Exchange finto e DETERMINISTICO: restituisce un batch vuoto alle chiamate elencate in
`vuote` (indici 0-based), altrove `limit` barre orarie a partire da `since`."""
def __init__(self, vuote=(), righe_prima_di_since=False):
self.vuote = set(vuote)
self.righe_prima_di_since = righe_prima_di_since
self.since_visti: list[int] = []
def fetch_ohlcv(self, symbol, tf, since=None, limit=None):
n = len(self.since_visti)
self.since_visti.append(since)
assert tf == "1h" and symbol and limit
if n in self.vuote:
return []
base = since - 10 * ORA_MS if self.righe_prima_di_since else since
return [[base + i * ORA_MS, 1.0, 1.0, 1.0, 10.0 + n + i, 1.0] for i in range(limit)]
@pytest.fixture()
def orologio_fermo(monkeypatch):
"""`_fetch_1h` chiude il ciclo su `time.time()`: qui lo si ferma a T0+72h, cosi' il numero
di pagine e' un fatto e non il meteo della macchina. (Il patch vive solo dentro il test.)"""
fine = T0 + pd.Timedelta(hours=72)
monkeypatch.setattr(M.time, "time", lambda: fine.timestamp())
return fine
def test_fetch_1h_AVANZA_dopo_un_batch_vuoto(orologio_fermo):
"""IL test di questo file. Coinbase su XRP restituisce vuoto per due anni e mezzo: se il
ciclo si fermasse (break) o non facesse avanzare `since` (loop infinito), la certificazione
girerebbe su un campione mutilato e la lente L-PULITA la sceglie la certificazione.
DEVE FALLIRE SE: qualcuno sostituisce `since += limit*3_600_000; continue` con un `break`."""
ex = ExFinto(vuote={0})
s = M._fetch_1h(ex, "XRP/USD", T0, 24)
assert len(ex.since_visti) >= 2, "il ciclo si e' fermato al primo batch vuoto"
assert ex.since_visti[1] == ex.since_visti[0] + 24 * ORA_MS, (
"dopo un batch vuoto `since` deve avanzare di un'intera pagina")
assert len(s) == 48
assert s.index.min() == T0 + pd.Timedelta(hours=24), "la prima pagina era il buco"
assert s.index.is_monotonic_increasing and s.index.is_unique
def test_fetch_1h_scavalca_un_buco_IN_MEZZO_e_riprende(orologio_fermo):
"""Il buco vero di XRP e' in mezzo (2021-01 → 2023-07), non in testa: il dato PRIMA e quello
DOPO devono stare tutti e due nella serie, col buco che resta un buco (D2: una riga assente
non si inventa)."""
ex = ExFinto(vuote={1})
s = M._fetch_1h(ex, "XRP/USD", T0, 24)
assert len(s) == 48
assert s.index.min() == T0
assert s.index.max() == T0 + pd.Timedelta(hours=71)
buco = s[(s.index >= T0 + pd.Timedelta(hours=24)) & (s.index < T0 + pd.Timedelta(hours=48))]
assert len(buco) == 0, "il buco e' stato riempito con qualcosa: non c'era niente da riempire"
def test_fetch_1h_scarta_le_righe_PRIMA_di_since_e_avanza_lo_stesso(orologio_fermo):
"""Alcuni venue ignorano `since` e ritornano sempre la stessa pagina: il filtro
`x[0] >= since` la svuota, e a quel punto vale la stessa regola del batch vuoto."""
ex = ExFinto(righe_prima_di_since=True)
s = M._fetch_1h(ex, "XRP/USD", T0, 24)
# le prime 10 barre di ogni pagina cadono prima di `since` e vengono scartate
assert len(ex.since_visti) >= 2
assert ex.since_visti[1] > ex.since_visti[0], "il ciclo si e' impantanato"
assert (s.index >= T0).all()
def test_fetch_1h_non_si_impianta_su_una_pagina_da_una_riga(monkeypatch):
"""La guardia `since = nxt if nxt > since else since + pagina` esiste per non girare a
vuoto: qui ogni chiamata rende UNA barra a `since`, e il ciclo deve comunque terminare."""
fine = T0 + pd.Timedelta(hours=5)
monkeypatch.setattr(M.time, "time", lambda: fine.timestamp())
class UnaRiga:
def __init__(self):
self.n = 0
def fetch_ohlcv(self, symbol, tf, since=None, limit=None):
self.n += 1
assert self.n < 50, "ciclo infinito"
return [[since, 1.0, 1.0, 1.0, 10.0, 1.0]]
s = M._fetch_1h(UnaRiga(), "XRP/USD", T0, 24)
assert len(s) == 5
assert list(s.index) == list(pd.date_range(T0, periods=5, freq="h"))
# ===========================================================================================
# (b) la regola L-PULITA dentro certifica()
# ===========================================================================================
ANNI = (2022, 2023, 2024, 2025, 2026)
def _finto_feed(sporchi: dict[int, int], n: int = 700):
"""Feed sintetico: per ogni anno `n` barre orarie, di cui `sporchi[y]` a +2% dal
riferimento (cioe' sopra la soglia >1% che definisce una barra sporca)."""
dd, rr = [], []
for y in ANNI:
idx = pd.date_range(f"{y}-01-01", periods=n, freq="h", tz="UTC")
rif = np.full(n, 100.0)
mio = rif.copy()
mio[:sporchi.get(y, 0)] = 102.0
dd.append(pd.DataFrame(dict(datetime=idx, close=mio, high=mio + 1.0, low=mio - 1.0)))
rr.append(pd.DataFrame(dict(ts=idx, close=rif, fonte="CB")))
D = pd.concat(dd, ignore_index=True)
R = pd.concat(rr, ignore_index=True).set_index("ts")
return D, R
def _certifica(monkeypatch, sporchi: dict[int, int], n: int = 700) -> dict:
D, R = _finto_feed(sporchi, n)
monkeypatch.setattr(M, "dati", lambda asset, tf: D)
monkeypatch.setattr(M, "riferimento_1h", lambda start: R)
return M.certifica()
def test_L_PULITA_parte_dal_primo_anno_con_TUTTI_i_successivi_puliti(monkeypatch):
"""Lo scenario vero: 2022 e 2023 sporchi (0,57% nel 2023), 2024-2026 puliti → 2024-01-01."""
c = _certifica(monkeypatch, {2022: 8, 2023: 4})
assert c["per_anno"][2023]["q_1pct"] == pytest.approx(4 / 700, abs=1e-6)
assert c["per_anno"][2022]["q_1pct"] > M.SOGLIA_PULITA
assert c["puliti"] == [2024, 2025, 2026]
assert c["pulita_da"] == pd.Timestamp("2024-01-01", tz="UTC")
def test_L_PULITA_ignora_un_anno_pulito_ISOLATO(monkeypatch):
"""La regressione che la regola esiste per impedire: 2022 pulito, 2023 sporco, 2024+ puliti.
Un anno pulito isolato NON e' un regime — la lente parte dalla coda pulita finale (2024),
non dal 2022. DEVE FALLIRE SE: qualcuno semplifica in `min(puliti)`."""
c = _certifica(monkeypatch, {2023: 8})
assert c["puliti"] == [2022, 2024, 2025, 2026], "il 2022 e' pulito, e va detto"
assert c["pulita_da"] == pd.Timestamp("2024-01-01", tz="UTC"), (
"la lente non puo' partire dal 2022: il 2023 in mezzo e' sporco")
def test_L_PULITA_e_tutta_la_storia_se_non_c_e_niente_di_sporco(monkeypatch):
c = _certifica(monkeypatch, {})
assert c["puliti"] == list(ANNI)
assert c["pulita_da"] == pd.Timestamp("2022-01-01", tz="UTC")
def test_L_PULITA_e_NONE_se_nessun_anno_e_pulito(monkeypatch):
"""P5: "non c'e' una finestra pulita" ha uno stato suo. `misura()` allora non aggiunge la
lente e `verdetto()` ripiega sul 2024+ dichiarato (la lente di SOL)."""
c = _certifica(monkeypatch, {y: 8 for y in ANNI})
assert c["puliti"] == []
assert c["pulita_da"] is None
def test_la_soglia_di_pulizia_e_INCLUSIVA_e_vale_SOGLIA_PULITA(monkeypatch):
"""Il confronto e' `<=`: esattamente in soglia l'anno e' pulito. Con n=1000 barre la
soglia 0,5% cade su un intero (5 barre), quindi il confine e' asseribile."""
c = _certifica(monkeypatch, {2022: 5, 2023: 5, 2024: 5, 2025: 5, 2026: 5}, n=1000)
assert c["per_anno"][2022]["q_1pct"] == pytest.approx(M.SOGLIA_PULITA)
assert c["puliti"] == list(ANNI)
c2 = _certifica(monkeypatch, {2022: 6, 2023: 5, 2024: 5, 2025: 5, 2026: 5}, n=1000)
assert 2022 not in c2["puliti"]
assert c2["pulita_da"] == pd.Timestamp("2023-01-01", tz="UTC")
def test_certifica_riporta_per_ogni_anno_le_misure_che_la_tabella_stampa(monkeypatch):
c = _certifica(monkeypatch, {2022: 8})
for y, r in c["per_anno"].items():
assert set(r) == {"n", "q_1pct", "med_bps", "fonte", "flat_1h", "flat_5m"}
assert r["n"] == 700
assert r["flat_1h"] == 0.0, "high != low per costruzione"
# ===========================================================================================
# (c) verdetto() — i tre rami, tutti con AND
# ===========================================================================================
NOME = "L-PULITA (2024+)"
def _m(hold, sh3, sh2k, anni_dsh, nome: str = NOME, k: float = 0.827) -> dict:
h = np.asarray(hold, float)
z = np.zeros(len(h))
return dict(lenti={nome: dict(sh=z, hold=h, dd=z, cagr=z)},
delev={nome: dict(k=k, sh3=sh3, sh2k=sh2k, cagr3=0.146, cagr2k=0.134)},
anno={2024 + i: dict(dsh=float(v)) for i, v in enumerate(anni_dsh)})
def test_verdetto_AGGIUNGE_solo_se_passano_tutte_e_tre_le_condizioni():
v = M.verdetto(_m([0.10] * 24, sh3=1.60, sh2k=1.50, anni_dsh=[0.5, 0.4, -0.1]))
assert v.startswith("XRP AGGIUNGE")
def test_verdetto_DILUISCE_quando_cadono_hold_out_E_de_levering():
v = M.verdetto(_m([-0.169] * 24, sh3=1.54, sh2k=1.53, anni_dsh=[0.54, -0.44, -0.25]))
assert v.startswith("XRP DILUISCE")
def test_verdetto_PARI_quando_le_condizioni_si_contraddicono():
"""N5/M8: quando una condizione passa e l'altra no, il verdetto non sceglie la piu' comoda —
dichiara che non e' selezionabile."""
solo_hold = M.verdetto(_m([0.10] * 24, sh3=1.50, sh2k=1.60, anni_dsh=[0.5, 0.4, -0.1]))
assert solo_hold.startswith("XRP: PARI / NON SELEZIONABILE")
solo_delev = M.verdetto(_m([-0.10] * 24, sh3=1.60, sh2k=1.50, anni_dsh=[0.5, 0.4, -0.1]))
assert solo_delev.startswith("XRP: PARI / NON SELEZIONABILE")
def test_verdetto_chiede_il_75_percento_delle_ancore_non_la_sola_mediana():
"""M7/§2: la banda d'ancora NON e' un intervallo di confidenza, ma la robustezza alla scelta
dell'ancora e' una condizione a se'. Mediana > 0 con solo il 70% delle ancore positive non
basta. DEVE FALLIRE SE: qualcuno lascia solo la mediana."""
hold = [0.10] * 16 + [-0.10] * 8 # mediana +0,10, ma >0 solo nel 67% delle ancore
v = M.verdetto(_m(hold, sh3=1.60, sh2k=1.50, anni_dsh=[0.5, 0.4, -0.1]))
assert not v.startswith("XRP AGGIUNGE")
hold_ok = [0.10] * 18 + [-0.10] * 6 # esattamente 75%
assert M.verdetto(_m(hold_ok, sh3=1.60, sh2k=1.50,
anni_dsh=[0.5, 0.4, -0.1])).startswith("XRP AGGIUNGE")
def test_verdetto_il_de_levering_chiede_un_margine_di_0_02():
"""M5: il null del de-levering non si passa "per un pelo" — un vantaggio dentro 0,02 di
Sharpe e' de-levering travestito, ed e' esattamente il caso di XRP (1,54 contro 1,53)."""
quasi = M.verdetto(_m([0.10] * 24, sh3=1.52, sh2k=1.50, anni_dsh=[0.5, 0.4, -0.1]))
assert not quasi.startswith("XRP AGGIUNGE")
appena = M.verdetto(_m([0.10] * 24, sh3=1.521, sh2k=1.50, anni_dsh=[0.5, 0.4, -0.1]))
assert appena.startswith("XRP AGGIUNGE")
def test_verdetto_ripiega_sulla_lente_di_SOL_se_non_c_e_una_coda_pulita():
"""Quando `pulita_da` e' None, `misura()` non crea la lente L-PULITA: il verdetto deve
leggere quella dichiarata di riserva, non la prima che capita."""
m = _m([-0.10] * 24, sh3=1.50, sh2k=1.60, anni_dsh=[-0.1, -0.2, -0.3],
nome="2024+ (lente di SOL)")
m["lenti"]["L-FULL"] = dict(sh=np.zeros(24), hold=np.full(24, 9.0),
dd=np.zeros(24), cagr=np.zeros(24))
m["delev"]["L-FULL"] = dict(k=1.0, sh3=9.0, sh2k=0.0, cagr3=0.0, cagr2k=0.0)
v = M.verdetto(m)
assert "lente 2024+ (lente di SOL)" in v
assert v.startswith("XRP DILUISCE"), "ha letto la L-FULL invece della lente dichiarata"
def test_verdetto_riproduce_ALLA_LETTERA_la_riga_pubblicata_il_09_09():
"""M23: i valori sono quelli usciti dalla misura a 24 ancore del 09/09."""
m = _m([-0.169] * 24, sh3=1.54, sh2k=1.53, anni_dsh=[0.54, -0.44, -0.25])
assert M.verdetto(m) == (
"XRP DILUISCE — lente L-PULITA (2024+): dSharpe hold-out mediano -0.169 "
"(>0 nel 0% di 24 ancore); de-levering: Sh 3 gambe 1.54 vs 2 gambe×0.827 1.53; "
"anni 2024+ con dSh>0: 1/3")
# ===========================================================================================
# (d) il GUARDRAIL del namespace
# ===========================================================================================
def test_il_divieto_su_data_raw_xrp_e_gia_DERIVATO_dal_codice():
"""`tests/test_sol_leg.py::test_nessun_asset_fuori_universo_nel_namespace_NUDO` deriva gli
asset vietati da `rebuild_history.DERIBIT_INSTR`: finche' XRP e' li' dentro, quel test
copre gia' `data/raw/xrp_*.parquet` e qui NON si duplica. Questo test congela la premessa
se qualcuno togliesse XRP dal dict, la guardia sparirebbe in silenzio."""
from scripts.analysis.rebuild_history import DERIBIT_INSTR
assert "XRP" in DERIBIT_INSTR, (
"XRP fuori da DERIBIT_INSTR: il divieto su data/raw/xrp_*.parquet non e' piu' derivato "
"da nessuna parte — riscrivere la guardia in test_sol_leg.py prima di toglierlo")
def test_load_data_XRP_continua_a_FALLIRE():
"""L'altra meta': il dato XRP dello studio vive in `alt_xrp_*` e il feed attivo non lo vede.
Se un giorno `load_data("XRP")` funzionasse, il cron (`--asset BTC ETH`) non lo
rinfrescherebbe comunque -> dato stantio che sembra attivo."""
from src.data.downloader import load_data
with pytest.raises(FileNotFoundError):
load_data("XRP", "1h")
def test_lo_studio_legge_il_namespace_di_RICERCA():
"""`dati()` importata da r0822_sol_leg instrada XRP su `alt_xrp_*`: se non c'e' il file,
l'errore deve dire come rigenerarlo, non fingere che l'asset non esista."""
assert M.ASSET == "XRP" and M.TRE == ("BTC", "ETH", "XRP")
if (RAW / "alt_xrp_1h.parquet").exists():
d = M.dati("XRP", "1h")
assert {"close", "datetime"} <= set(d.columns) and len(d) > 0
# ===========================================================================================
# (e) INTEGRAZIONE (opzionale: salta senza i dati o senza la cache del riferimento)
# ===========================================================================================
def test_integrazione_xrp_diluisce_anche_a_due_ancore(monkeypatch):
"""~2 s. Non riproduce i numeri a 24 ancore (2 ancore sono un'altra misura): riproduce la
DIREZIONE, che e' la cosa che decide — dSharpe hold-out mediano negativo e verdetto
"XRP DILUISCE", cioe' l'ipotesi a priori confermata."""
if not all((RAW / f"alt_xrp_{tf}.parquet").exists() for tf in ("1h", "5m")):
pytest.skip("namespace di ricerca alt_xrp_* assente (rigenerabile, gitignored)")
if not all((RAW / f"{a}_{tf}.parquet").exists() for a in ("btc", "eth") for tf in ("1h", "5m")):
pytest.skip("feed certificato BTC/ETH assente")
if not M.CACHE.exists():
pytest.skip(f"cache del riferimento assente ({M.CACHE}): il test NON va in rete")
# cintura: anche se la cache sparisse a meta' test, nessuno tocca la rete
def _vietato(*a, **k):
raise AssertionError("il test ha provato ad andare in rete (_fetch_1h)")
monkeypatch.setattr(M, "_fetch_1h", _vietato)
m = M.misura(2)
assert m["n_anchors"] == 2
assert m["cert"]["pulita_da"] == pd.Timestamp("2024-01-01", tz="UTC")
nome = ([n for n in m["lenti"] if n.startswith("L-PULITA")] or ["2024+ (lente di SOL)"])[0]
assert np.median(m["lenti"][nome]["hold"]) < 0, "il dSharpe hold-out ha cambiato segno"
assert M.verdetto(m).startswith("XRP DILUISCE")
assert m["corr"]["L-FULL"] < 0.74, "la correlazione col book supera la soglia del 19/06"