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:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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)*
|
||||
@@ -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)*
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 (T≤0, 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 T→0 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")
|
||||
@@ -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']}")
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user