10 Commits

Author SHA1 Message Date
Adriano Dal Pastro a4e942c8bb r0910: risk-off ±2h intorno a FOMC/CPI su TP01 REFUTED 0/3 (ΔSh −0,097, −1,24%/anno; le ore dei dati sono fra le migliori)
Calendario letto dal web (Fed 61 riunioni + BLS 88 release CPI, 2019-2026), TP01 orario dalla stessa
held di sleeves, null location-matched 1000 estrazioni (Δ vero al 4,2° pctl), per anno 2/8.
Registro §78, memoria 20-ondate (D-bis), diario 2026-09-10b.

Fixes #9

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kqvff47UBGeYfj1QeN4zE
2026-09-10 14:56:29 +00:00
Adriano Dal Pastro 5b72a4b188 §3: quota USDE 0,64 derivata dal cuscino e' decisione vincolante dell'operatore (10/09)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kqvff47UBGeYfj1QeN4zE
2026-09-10 14:19:31 +00:00
Adriano Dal Pastro a2b03aa2b6 USDE allineato a quota 0,64 su ordine dell'operatore (238 USDE venduti, slack +$266 = OK)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kqvff47UBGeYfj1QeN4zE
2026-09-10 14:17:43 +00:00
Adriano Dal Pastro 37d1565a1e cuscino_watch: riacquisto automatico di USDE con isteresi (ECCEDENTE), bande in config, escape HTML nel sink di notify
Decisione dell'operatore 10/09 («riportarla a quota in autonomia»). Verificato da revisione fable
(10 segnalazioni, 9 applicate; loop numerico su usde_convert.piano: nessuna sequenza vendita→acquisto).

- quarto stato ECCEDENTE: slack > cuscino_riacquisto_frac (0,40) x cuscino => acquisto di USDE fino
  allo stesso bersaglio della vendita, q* = 1 - 0,30 x (1 + cuscino_margine_frac 0,20) = 0,64.
  Isteresi [0 ; 0,40] x cuscino con bersaglio unico 0,20: per oscillare servono +-8,6% di equity
  USDC (~$380); un bonifico alza lo slack di 0,7 x importo e sopra ~9% dell'equity ricompra da solo.
- le tre frazioni (preavviso/margine/riacquisto) in config/live.json sezione usde, lette da
  usde.bande_cuscino() che verifica l'ordine; quota_target 0,70 TOLTO (slack zero per costruzione).
- notifier.notify: html.escape su titolo e valori — la prima allerta vera (13:53Z) era andata persa
  per un '<' nudo; allerta_errore registrato nel record (P3).
- da ECCEDENTE un fallimento si ritenta ogni 24h (non 4 Telegram/giorno per sempre); bande fuori
  ordine => BLIND con motivo, non un traceback ingoiato dal cron.
- CLAUDE.md §1/§4/§5.17, config _nota_usde/_nota_cuscino, diario. 0,64 e' una DERIVAZIONE
  dell'autore, non la quota decisa il 30/08 (0,70): dichiarato, da confermare.
- test: 1070 (+8 netti).

Fixes #7

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kqvff47UBGeYfj1QeN4zE
2026-09-10 14:14:29 +00:00
Adriano Dal Pastro f82f685528 cinque punti della revisione 09/09: usde_watch a 1000 trade + finestra 24h, cuscino_watch (equity USDC, riconverte da solo), PREVDAY-01 kill/veto cablati, SCALA-01 al 2027-02-28, versamento 04/09 dichiarato
Decisi dall'operatore il 2026-09-10, verificati da revisione fable (15 segnalazioni, 12 applicate).

- usde_watch: TRADE_LIMIT 1000 (count max Deribit) e trade_copertura(): lista troncata o ultima
  lettura oltre la finestra di 24h del gateway => somma NON leggibile, reward non attribuito (P12).
  Riga del 07/09 corretta nel log con campo `correzione` (400 USDE erano acquisti, non reward).
- cuscino_watch.py (cron :53, monitor_health): equity USDC contro cuscino derivato da
  usde.cuscino_richiesto_usd (formula spostata in src/live/usde.py, usde_convert la importa);
  OK/PREAVVISO/SCOPERTO/BLIND; sotto zero lancia usde_convert --quota quota_ripristino(0.20)=0.64
  --esegui con guardie (execution_enabled, depeg_warn, 1 tentativo/6h). Primo giro: PREAVVISO, +$26.
- usde_convert: il tetto del venue vale solo in ACQUISTO (bloccava la vendita).
- paper_prevday: GATE PREVDAY-01 cablato (2027-06-21, kill Sharpe giornaliero < -0,50 su >=180 g
  attivi, veto >=80% barre ricostruibili + divergenze non crescenti con soglia materiale).
  Oggi: +0,95 su 81 g, 1942/1943 ricostruibili, kill NON MATURO.
- CLAUDE.md: arming 20/06 (TP01) / 23/06 (BOOK); piano EUR 5.000 chiuso col versamento 04/09
  ($2.414,68 dal balance, dichiarato); SCALA-01 non prima del 2027-02-28 (A2 dal 01/09);
  PREVDAY-01 con data, kill, veto; §5.17 riparato con i limiti dichiarati (ratchet, slack zero a 0,70).
- test: 1062 (+31): test_cuscino_watch (16), test_paper_prevday_gate (8), test_usde_watch (+5).

Fixes #2
Fixes #3
Fixes #4
Fixes #5
Fixes #6

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kqvff47UBGeYfj1QeN4zE
2026-09-10 13:24:28 +00:00
Adriano Dal Pastro 79afe41ec2 giornale 2026-09-09 (cron_daily 00:30Z)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bjj5vPEBoAJrB23P6RjKzs
2026-09-10 04:32:48 +00:00
Adriano Dal Pastro 8f2e6c5c2c revisione settimanale in sola lettura: revisore fable, rapporto firmato + Telegram, lunedi' 06:15 UTC
- src/live/revisione.py: materiale (CLAUDE.md, config, crontab, git, monitor_health, stati dei
  sorveglianti, 7 g di giornale e diari, ~195k caratteri), prompt con dieci regole (sola lettura, fonte
  per ogni segnalazione, solo numeri del materiale, §3 non si ripropone, idee nuove solo citando la
  memoria, gate con data e criterio), guardia sui numeri, rapporto firmato (P13), Telegram = Sintesi
  con taglio dichiarato; modello muto o risposta senza titoli -> rapporto «NON eseguita» + 🚨 (P5).
- scripts/live/revisione.py (guardia cli.valida, --secco/--no-telegram/--quiet/--giorno/--modello),
  scripts/cron_review.sh, crontab `15 6 * * 1`.
- primo giro reale: il prompt su argv moriva con `Argument list too long` senza rapporto ne' allerta ->
  prompt su stdin, ogni eccezione diventa errore dichiarato; `analista.pulisci` toglieva i titoli.
  Secondo giro: docs/revisioni/2026-09-09.md, stato sospetta (cifre in formato italiano), nessun URGENTE.
- scartati con la memoria: autoregolazione dei parametri, generazione automatica di strategie, macro
  da internet (diario 2026-09-09c, memoria 40).
- test: tests/test_revisione.py (18) + 2 in test_cli_flag; suite 1031/1031.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bjj5vPEBoAJrB23P6RjKzs
2026-09-09 22:07:50 +00:00
Adriano Dal Pastro 0a2f780d3c cblib causale: spot +1h e DVOL +1 giorno (debito §5.18); §11 rimisurato 0,714 → 0,712, verdetti invariati
- cblib.causale(s, cadenza): spot_series e dvol_series escono causali (asof = ultima chiusura NOTA);
  il DVOL giornaliero era una seconda serie con lo stesso difetto (fino a 24h avanti), verificato contro
  l'API pubblica a 1h. Regolamento ST alle 08:00 invece delle 09:00.
- §11 riprodotto al millesimo su worktree HEAD (0,714 [0,690-0,779], n=19, flag --al) e rimisurato:
  0,712 [0,664-0,732]; solo spot 0,721, solo DVOL 0,693, DVOL orario di controllo 0,717. Mediana onesta
  BTC 1,45 → 2,12, fee 1,51x/1,87x. r0730: 0,73 → 0,71. §75: ETH pre 0,76, BTC durante 0,89 (orario 0,83).
  skew panel ≤0,005. vrp_f_watch: f canonico 0,732 → 0,706, differenza +0,097 [+0,042, +0,140].
- r0909 senza doppio shift, contesto sul DVOL del giorno; r0822 regime su date di calendario; r0901
  join riallineato (bit-exact). Aperti con costo: DVOL orario (−0,04/+0,06 nel crollo), spot 5m (≤25 min).
- test: +3 causalita' in test_cb_chain_vrp, anti-doppio-shift in test_r0909; suite 1011/1011.
- revisione fable: 16 segnalazioni, tutte applicate (fra cui un «−1,8% in un'ora» che era +3,9%).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bjj5vPEBoAJrB23P6RjKzs
2026-09-09 21:41:23 +00:00
Adriano Dal Pastro bb40e87d13 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
2026-09-09 16:33:54 +00:00
Adriano Dal Pastro 9f55652d99 usde al 68,7%: il tetto del venue del 30-31/08 non c'era piu'; il versamento di prova del 25/08 dichiarato, TWR +7,83%
Su autorizzazione dell'operatore («usa piu' USDE», «cerca % massima raggiungibile»):
- usde_convert al 31,8% (774 USDE), poi sonda a saldo crescente r0906_usde_tetto_sonda.py
  (passi 100->20->4->1, doppio rifiuto prima di scendere, conferma a saldo neutro): da 31,8%
  a 68,7% in 39 ordini @ 1,0005, ZERO rifiuti. Fermata dal cuscino di regolamento (70%,
  derivato da config), non dal venue. «Il 70% non e' raggiungibile ne' ora ne' mai» (31/08)
  e' falsificato: il tetto e' sparito, non scalato. Non spiegato, puo' tornare.
- config: venue_cap_frac -> null (misura in venue_cap_misurato), quota_max_frac 0,50 -> 0,85
  (il valore scelto dall'operatore il 30/08 per questo scenario).
- debito §5.17: nessuno sorveglia il cuscino USDC (slack $58; una perdita del libro lo consuma
  da sola).

Versamento di prova di EUR 25 il 25/08 08:00Z (dichiarato dall'operatore): +3,3% su $647, sotto
la soglia del 10% del rilevatore, contato per 12 giorni come trading.
- data/live/movimenti_dichiarati.jsonl (append-only, nel backup) letto da
  journal.movimenti_dichiarati; movimenti_capitale lo fonde coi rilevati (fonte dichiarato,
  importo dell'operatore, dopo = prima + importo; dichiarato+rilevato se coincide; fuori
  letture o riga rotta -> avvisi). Report e nota di giornale lo dicono.
- trading da arming +87,35 -> +62,45; TWR +11,98% -> +7,83%. CLAUDE.md §2 aggiornato.
- tests: +6 in test_journal (conftest isola il file). Suite 916 verdi.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSnordG9FT4q8M4MVm85GQ
2026-09-07 05:49:15 +00:00
57 changed files with 6902 additions and 83 deletions
+105 -20
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -106,10 +106,10 @@ scripts/
cron_{book,daily,chain,balance,usde,opt_snapshot,vol_term}.sh cron_{book,daily,chain,balance,usde,opt_snapshot,vol_term}.sh
docs/ docs/
memory/ LA MEMORIA — 6 file, indicizzati in testa a CLAUDE.md 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) diary/ una voce per esperimento (144)
journal/ libro di bordo, una voce al giorno, 4 livelli per provenienza 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 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/journal.py # voce del giorno
uv run python scripts/live/analista.py --secco # analisi del giorno, senza salvare 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 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 Ogni script di `scripts/live/` accetta `--help` e **rifiuta un flag che non conosce** (esce 2): fino
+8 -5
View File
@@ -10,14 +10,17 @@
"max_data_age_days": 2, "max_data_age_days": 2,
"_nota_skh_feed": "Freschezza del feed 5m usato per il segnale SKH01 (2026-07-26). fresh_5m ricade sul feed certificato IN SILENZIO se il fetch pubblico Deribit fallisce, e il certificato si rigenera 1x/giorno: senza controllo la latenza d'uscita di SKH01 passa da ~1h a ~1 giorno senza segnalazione. Sopra soglia book_execute ALLERTA e NON blocca (bloccare fermerebbe anche TP01, nettato sullo stesso strumento, per un guasto di rete). Diario 2026-07-26-t1-esecuzione-skh-live.md.", "_nota_skh_feed": "Freschezza del feed 5m usato per il segnale SKH01 (2026-07-26). fresh_5m ricade sul feed certificato IN SILENZIO se il fetch pubblico Deribit fallisce, e il certificato si rigenera 1x/giorno: senza controllo la latenza d'uscita di SKH01 passa da ~1h a ~1 giorno senza segnalazione. Sopra soglia book_execute ALLERTA e NON blocca (bloccare fermerebbe anche TP01, nettato sullo stesso strumento, per un guasto di rete). Diario 2026-07-26-t1-esecuzione-skh-live.md.",
"skh_feed_max_age_min": 30, "skh_feed_max_age_min": 30,
"_nota_usde": "Collaterale USDE a rendimento (test di eligibilita' 2026-08-26: 500 USDE, verdetto entro il 29/08 — diario 2026-08-26-usde-analisi). L'autorita' che LEGGE questa sezione e' src/live/usde.py; la usano shadow._collaterale_usde (equity oraria del book) e scripts/live/usde_watch.py (sorveglianza giornaliera 12:35 UTC). Criteri delle soglie, dichiarati (P6): depeg_warn 0.99 = fuori dalla banda operativa dello spot (~3 bps) e oltre il clamp +-0.5% per fonte dell'indice usde_usdc — a quel prezzo non e' rumore di book; depeg_crit 0.95 = meta' del buffer di haircut (10%) consumata. quota_target 0.70 = la quota DECISA dall'operatore il 2026-08-30 (la decisione di quota che il gate USDE-01 apriva; anticipata di un giorno sul rinvio al 31/08, su richiesta esplicita dell'operatore \"porta in usde tutto il capitale che non viene usato\"). Il 70% NON e' un argmax (M8): e' il massimo compatibile col CUSCINO DI REGOLAMENTO, cioe' il vincolo che r0830_usde_quota non aveva guardato — il P&L e il funding dei perp USDC-lineari si regolano in USDC, non nel collaterale, quindi a quota alta il saldo USDC va negativo alla prima perdita del libro e Deribit lo finanzia a interesse. Il cuscino richiesto e' il disaster-SL sulla massima esposizione lorda: n_asset x frac x disaster_sl_pct = 2 x 0.5 x 0.30 = 30% dell'equity, che lascia esattamente il 70%. quota_max_frac 0.50 = tetto di ALLERTA sulla quota USDE/equity totale (N4: la quota e' l'unica leva contro il rischio emittente, -100% = 25 anni di resa, non recuperabile). Alzato a 0.85 il 2026-08-30 in previsione della quota al 70% e RIMESSO A 0.50 lo stesso giorno: il 70% NON e' raggiungibile. 🚨 venue_cap_frac 0.318 = TETTO DEL VENUE sull'USDE, misurato e NON documentato da Deribit (ne' 'Cross collateral specifications' ne' 'Yield-generating collateral' prevedono un limite sulle quantita' detenibili; il Cap ETHENA che esiste diluisce il TASSO a livello di exchange, non limita gli acquisti). Ogni acquisto oltre il tetto e' rifiutato con `not_enough_funds_in_currency` pur avendo $1.400 disponibili: messaggio FUORVIANTE. E' una FRAZIONE dell'equity, non un livello: provato il 31/08 lasciando scendere l'equity di $8, il tetto e' sceso con lei. ⚠️ DIPENDE DAL MODELLO DI MARGINE, ma pochissimo — misurato a saldo neutro sotto entrambi: SEGREGATO S:SM [643,18-644,18) con equity $2.055,56 = 31,29-31,34%; CROSS X:SM [654,18-655,18) con equity $2.054,90 = 31,84-31,88%. Il passaggio a cross ha comprato +0,55pp = **+11 USDE (~$11)**: il modello entra nel tetto, ma non lo spiega. ⇒ la quota resta ~31-32% sotto qualunque configurazione e il 70% di quota_target NON e' raggiungibile (manca un fattore ~2,2x). Il valore 0.318 e' il bordo BASSO del bracket CROSS, che e' il modello attivo: fa fallire il piano PRIMA dell'ordine. Se si torna a S:SM va rimesso a 0.312. haircut 0.05 — ✅ DIVERGENZA CHIUSA il 2026-08-31 (era 0.10, SBAGLIATO). La pagina margini del conto non espone l'haircut come numero: si RICAVA per differenza fra le due righe, perche' il modello CROSS conta l'USDE scontato e il SEGREGATO non lo conta affatto. Contributo dell'USDE al cross $610,81 su $643,05 di valore -> 5,0137%: l'ipotesi 5% torna a $0,09, la 10% sbaglia di $32,06. Riproducibile: `scripts/research/r0831_margini_conto.py` (N11). Il 26/08 il 10% fu registrato come 'verificato sul venue' senza lasciare traccia di come, e non lo era. 🚨 SCOPERTO NELLA STESSA LETTURA, e vale piu' dell'haircut: il modello di margine ATTIVO e' **Segregated: Standard Margin (S:SM)**, NON cross-collateral. Nella tabella del modello attivo l'USDE **non compare**: non fa margine per i perp USDC-settled del book. Percio' l'haircut oggi non si applica affatto — ed e' esattamente il motivo per cui la misura del 31/08 trovava $0,0000 accantonati. Passare a X:SM aggiungerebbe $610,87 di margine utilizzabile: e' una decisione dell'operatore, non un refactor, e porta con se' la meccanica cross (collateral fee 0,05%/giorno sul saldo negativo, ribilanciamento automatico).", "_nota_usde": "Collaterale USDE a rendimento (test di eligibilita' 2026-08-26: 500 USDE, verdetto entro il 29/08 — diario 2026-08-26-usde-analisi). L'autorita' che LEGGE questa sezione e' src/live/usde.py; la usano shadow._collaterale_usde (equity oraria del book) e scripts/live/usde_watch.py (sorveglianza giornaliera 12:35 UTC). Criteri delle soglie, dichiarati (P6): depeg_warn 0.99 = fuori dalla banda operativa dello spot (~3 bps) e oltre il clamp +-0.5% per fonte dell'indice usde_usdc — a quel prezzo non e' rumore di book; depeg_crit 0.95 = meta' del buffer di haircut (10%) consumata. quota_target 0.70 (CHIAVE TOLTA il 2026-09-10, issue #7: il bersaglio operativo e' DERIVATO dalle bande del cuscino, vedi usde._nota_cuscino — quanto segue e' la storia della decisione) = la quota DECISA dall'operatore il 2026-08-30 (la decisione di quota che il gate USDE-01 apriva; anticipata di un giorno sul rinvio al 31/08, su richiesta esplicita dell'operatore \"porta in usde tutto il capitale che non viene usato\"). Il 70% NON e' un argmax (M8): e' il massimo compatibile col CUSCINO DI REGOLAMENTO, cioe' il vincolo che r0830_usde_quota non aveva guardato — il P&L e il funding dei perp USDC-lineari si regolano in USDC, non nel collaterale, quindi a quota alta il saldo USDC va negativo alla prima perdita del libro e Deribit lo finanzia a interesse. Il cuscino richiesto e' il disaster-SL sulla massima esposizione lorda: n_asset x frac x disaster_sl_pct = 2 x 0.5 x 0.30 = 30% dell'equity, che lascia esattamente il 70%. quota_max_frac 0.50 = tetto di ALLERTA sulla quota USDE/equity totale (N4: la quota e' l'unica leva contro il rischio emittente, -100% = 25 anni di resa, non recuperabile). Alzato a 0.85 il 2026-08-30 in previsione della quota al 70% e RIMESSO A 0.50 lo stesso giorno perche' il 70% pareva irraggiungibile; RIMESSO A 0.85 il 2026-09-06, quando la quota e' arrivata al 68,7% su autorizzazione dell'operatore e la sonda non ha trovato tetto. 🚨 venue_cap_frac: il 06/09 il tetto NON C'ERA (null, vedi venue_cap_misurato) — quanto segue e' la storia del 30-31/08, tenuta perche' il tetto puo' tornare: venue_cap_frac 0.318 = TETTO DEL VENUE sull'USDE, misurato e NON documentato da Deribit (ne' 'Cross collateral specifications' ne' 'Yield-generating collateral' prevedono un limite sulle quantita' detenibili; il Cap ETHENA che esiste diluisce il TASSO a livello di exchange, non limita gli acquisti). Ogni acquisto oltre il tetto e' rifiutato con `not_enough_funds_in_currency` pur avendo $1.400 disponibili: messaggio FUORVIANTE. E' una FRAZIONE dell'equity, non un livello: provato il 31/08 lasciando scendere l'equity di $8, il tetto e' sceso con lei. ⚠️ DIPENDE DAL MODELLO DI MARGINE, ma pochissimo — misurato a saldo neutro sotto entrambi: SEGREGATO S:SM [643,18-644,18) con equity $2.055,56 = 31,29-31,34%; CROSS X:SM [654,18-655,18) con equity $2.054,90 = 31,84-31,88%. Il passaggio a cross ha comprato +0,55pp = **+11 USDE (~$11)**: il modello entra nel tetto, ma non lo spiega. ⇒ la quota resta ~31-32% sotto qualunque configurazione e il 70% di quota_target NON e' raggiungibile (manca un fattore ~2,2x). Il valore 0.318 e' il bordo BASSO del bracket CROSS, che e' il modello attivo: fa fallire il piano PRIMA dell'ordine. Se si torna a S:SM va rimesso a 0.312. haircut 0.05 — ✅ DIVERGENZA CHIUSA il 2026-08-31 (era 0.10, SBAGLIATO). La pagina margini del conto non espone l'haircut come numero: si RICAVA per differenza fra le due righe, perche' il modello CROSS conta l'USDE scontato e il SEGREGATO non lo conta affatto. Contributo dell'USDE al cross $610,81 su $643,05 di valore -> 5,0137%: l'ipotesi 5% torna a $0,09, la 10% sbaglia di $32,06. Riproducibile: `scripts/research/r0831_margini_conto.py` (N11). Il 26/08 il 10% fu registrato come 'verificato sul venue' senza lasciare traccia di come, e non lo era. 🚨 SCOPERTO NELLA STESSA LETTURA, e vale piu' dell'haircut: il modello di margine ATTIVO e' **Segregated: Standard Margin (S:SM)**, NON cross-collateral. Nella tabella del modello attivo l'USDE **non compare**: non fa margine per i perp USDC-settled del book. Percio' l'haircut oggi non si applica affatto — ed e' esattamente il motivo per cui la misura del 31/08 trovava $0,0000 accantonati. Passare a X:SM aggiungerebbe $610,87 di margine utilizzabile: e' una decisione dell'operatore, non un refactor, e porta con se' la meccanica cross (collateral fee 0,05%/giorno sul saldo negativo, ribilanciamento automatico).",
"usde": { "usde": {
"index_name": "usde_usdc", "index_name": "usde_usdc",
"haircut": 0.05, "haircut": 0.05,
"quota_target": 0.7, "_nota_cuscino": "Dal 2026-09-10 (issue #7) la quota USDE NON e' un numero scelto: e' DERIVATA dal cuscino USDC di regolamento (n_asset x frac x disaster_sl_pct = 30% dell'equity) e da queste tre frazioni del cuscino, lette da src/live/usde.py per scripts/live/cuscino_watch.py (cron :53). cuscino_margine_frac 0.20 = bersaglio unico di ogni conversione: quota = 1 - 0.30 x 1.20 = 0.64 (il vecchio quota_target 0.70 lasciava slack ZERO: ogni ora in perdita avrebbe venduto). cuscino_preavviso_frac 0.10 = sotto, allerta. cuscino_riacquisto_frac 0.40 = sopra, si RICOMPRA USDE fino al bersaglio (un bonifico alza lo slack di 0,7 x importo e fa scattare il riacquisto da solo). Isteresi [0 ; 0.40] x cuscino con bersaglio a 0.20: oscillare richiede +-6% di equity di slack, cioe' +-8,6% di equity USDC (slack = 0,7 USDC - 0,3 USDE). Soglie DICHIARATE, non ottimizzate (M8): un giro costa ~6 bps sull'importo mosso. Ordine obbligato: 0 < preavviso < margine < riacquisto (usde.bande_cuscino lo verifica).",
"venue_cap_frac": 0.318, "cuscino_preavviso_frac": 0.1,
"venue_cap_misurato": "2026-08-31 (X:SM)", "cuscino_margine_frac": 0.2,
"quota_max_frac": 0.5, "cuscino_riacquisto_frac": 0.4,
"venue_cap_frac": null,
"venue_cap_misurato": "2026-09-06: NESSUN tetto fino al 68,7% (fermata dal cuscino di regolamento al 70%, non dal venue): +2.434 USDE in 39 ordini a saldo crescente, zero rifiuti (r0906_usde_tetto_sonda.py). Il 30-31/08 il tetto c'era: [643-644) e [654-655) USDE su ~$2.055 = 31,3-31,9%. Non spiegato: e' SPARITO, non e' scalato. null = nessun tetto noto; usde_convert si affida al chunk+backoff sui rifiuti",
"quota_max_frac": 0.85,
"depeg_warn": 0.99, "depeg_warn": 0.99,
"depeg_crit": 0.95 "depeg_crit": 0.95
} }
+91
View File
@@ -0,0 +1,91 @@
# 2026-09-06 — USDE: dal 14,6% al 68,7%. Il tetto del venue del 30-31/08 non c'era piu'; il versamento di prova del 25/08 dichiarato
**Ore:** 20:56-21:16Z. **Autorizzazioni dell'operatore, in sessione:** *«usa piu' USDE se ne hai bisogno»*,
poi *«cerca % massima raggiungibile in USDE»*, sulla decisione gia' presa il 30/08 (quota bersaglio 70%,
`usde.quota_target`). E, a meta' sessione: *«i 25 euro erano un versamento di prova»*.
## 1. Il versamento del 04/09 aveva dimezzato la quota
Il versamento di **$2.410,14 del 04/09 14:47Z** (riconosciuto dal classificatore: mercato max 0,56% a leva
piena contro un salto del +116,8%) ha portato l'equity a **~$4.495** e la quota USDE da 31,3% a **14,6%**.
Nessun automatismo la riportava su.
## 2. Primo passo: al tetto di config (31,8%) — e la lettura SBAGLIATA che ne ho dato
`usde_convert.py --quota 0.318 --esegui`: 774 USDE in 8 ordini @ 1,0005 medio, zero rifiuti, quota 31,79%.
Ho scritto in CLAUDE.md «il tetto ha tenuto la stessa frazione a equity 2,2× maggiore: terza conferma».
**Falso**: nessun rifiuto fino al 31,8% dimostra che il tetto e' **≥** 31,8%, non che e' **=** 31,8%. Il
dato non conteneva la conferma; l'ho letta perche' me l'aspettavo. Corretto venti minuti dopo, quando
l'operatore ha chiesto la % massima e la sonda ha mostrato che la conferma non c'era.
## 3. La sonda: nessun tetto fino al 68,7%
`scripts/research/r0906_usde_tetto_sonda.py` — a saldo crescente, passi piccoli, stesso metodo dei diari
30-31/08 (un probe unico di taglia sbagliata produce un falso negativo): sale finche' il venue non rifiuta,
ripete il rifiuto dopo aver riletto il book (il messaggio `not_enough_funds_in_currency` e' lo stesso di un
limite che non incrocia), scende di passo a ogni doppio rifiuto, e conferma il muro a saldo neutro
(SELL 1 → BUY 1 ok → BUY 1 rifiutato). Guardie: prezzo dal book (ask+5 tick, tetto 1,0010), quota mai oltre
il 70% e **USDC mai sotto il cuscino di regolamento** derivato da config (`usde_convert.cuscino_richiesto_usd`).
| corsa | passi | da → a (USDE) | quota | rifiuti |
|---|---|---|---|---|
| 21:02-21:09 | 30 × 2 | 1.428,7 → 1.488,7 | 31,8% → 33,1% | **0** (fermata dal mio limite +60) |
| 21:11-21:15 | 16 × 100 | 1.488,7 → **3.088,7** | 33,1% → **68,7%** | **0** (fermata dal cuscino: +100 avrebbe superato il 70%) |
**Totale del giorno: 2.434 USDE in 39 ordini @ 1,0005, ~$1,2 di spread.** Conto finale: USDC **$1.407,40** +
USDE **3.088,72** = $4.496; cuscino richiesto $1.349 → slack **+$58**. Registro: `data/live/usde_convert.jsonl`
(due record `sonda_tetto`, con ogni ordine).
## 4. Cosa dice il risultato — e cosa non dice
1. **Il tetto del 30-31/08 era vero allora ed e' sparito ora.** Tre bracket riproducibili a 1 USDE (31,21-31,26% ·
31,29-31,34% · 31,84-31,88%) contro 2.434 USDE accettati oggi senza un rifiuto. **Non e' scalato, e'
sparito**: la frase del 31/08 «il 70% non e' raggiungibile ne' ora ne' mai» e' falsificata.
2. **Non spiegato** (D5). Ipotesi non verificabili da qui (il gateway non espone il transaction log ne' i
parametri del cap): un'allocazione per-conto del Cap ETHENA che varia col saldo aggregato dell'exchange;
un vincolo sui fondi depositati di recente (ma l'USDC del 04/09 e' stato convertibile a 2 giorni); un
cambio di policy. **Il tetto puo' tornare**: quando tornera', bloccherebbe gli ACQUISTI, mai le vendite
(tutte le SELL sono sempre passate). `venue_cap_frac`**null** con la misura in `venue_cap_misurato`;
`usde_convert` si affida al chunk+backoff sui rifiuti, che e' cio' che ha sempre fatto.
3. **Il limite che morde e' NOSTRO: il cuscino di regolamento.** P&L e funding dei perp si regolano in USDC;
a 68,7% restano $58 sopra il cuscino. ⚠️ **Nessuno sorveglia il cuscino**: `usde_watch` allerta sulla
quota (`quota_max_frac`, riportata a **0,85**, il valore che l'operatore aveva scelto il 30/08 «in previsione
della quota al 70%»), non sull'USDC. Una perdita del libro fa salire la quota da sola: a $58 il cuscino e'
scoperto, e Deribit finanzia un saldo USDC negativo allo 0,05%/giorno (18%/anno). Debito nuovo, §5.17.
4. **Rischio emittente**: a 24,2% l'evento Ethena valeva 3,1× il maxDD del libro (30/08); a 68,7% vale **~8,8×**.
E' la decisione del 30/08 presa con l'informazione completa (r0830_usde_quota), oggi eseguita. Resa attesa
~4,1% × $3.089 ≈ **$127/anno** (era $26): ancora meno di un mese di versamento.
## 5. Il versamento di prova del 25/08: dichiarato, scorporato, e il TWR scende di 4 punti
Trovato stamattina per differenza (equity 07:07→08:07 **+$21,11** con le posizioni a **$3,3/4,3**) e
confermato dall'operatore: **€25 di prova** prima del bonifico da $1.399,39 delle 11:47. Il rilevatore non
poteva vederlo (+3,3% contro soglia 10%) e abbassare la soglia farebbe di ogni ora di mercato un candidato:
**l'unica fonte che sa l'importo e' l'operatore**. Costruito il canale:
- `data/live/movimenti_dichiarati.jsonl` (append-only, nel perimetro di backup — P11), letto da
`journal.movimenti_dichiarati()`; `movimenti_capitale` lo fonde coi rilevati: fonte `dichiarato`, importo
dell'operatore (non il salto), `dopo = prima + importo` cosi' il mercato dell'ora **resta** nel rendimento;
se coincide con un salto rilevato l'importo dichiarato vince (`dichiarato+rilevato`); fuori dalle letture →
`avvisi`, non applicato; riga rotta → `avvisi`, le altre restano (P3).
- Importo registrato **$24,90 ± 0,50**: stimato per differenza (salto +21,11, mercato 3,3/4,3 dal log del
book, fee 0,03) — il gateway non espone `get_deposits`, quindi il numero USDC esatto non e' leggibile; la
banda sta nel record e nel report (P12).
- `tests/test_journal.py`: +6 (scorporo sotto soglia · dichiarato+rilevato · fuori letture · riga rotta ·
senza file niente cambia · la nota di giornale lo dice); `conftest` isola il file nei test.
| | prima (20:47Z) | dopo (21:14Z) |
|---|---|---|
| movimenti certi | $3.809,53 | **$3.834,43** |
| trading da arming | +$87,35 | **+$62,45** |
| TWR | +11,98% | **+7,83%** = +8,14% × 0,62% × 0,12% × +0,46% |
**Un terzo del «trading» era un bonifico da 25 euro.** Regola nuova (CLAUDE.md §2): ogni versamento, anche
di prova, si dichiara nel file il giorno stesso.
## 6. File toccati
`config/live.json` (usde: venue_cap_frac null, quota_max_frac 0.85, nota) · `src/live/journal.py` ·
`scripts/live/trades_db.py` · `tests/conftest.py` · `tests/test_journal.py` (+6) ·
`scripts/research/r0906_usde_tetto_sonda.py` (nuovo) · `data/live/movimenti_dichiarati.jsonl` (nuovo) ·
`CLAUDE.md` §1 §2 §4 §5.
@@ -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,182 @@
# 2026-09-09b — Debito §5.18 riparato: `cblib` causale (spot +1h, DVOL +1 giorno) e §11 rimisurato
**Richiesta dell'operatore:** *"ripara il debito §5.18 su cblib e rimisura §11"*. Revisione col modello
`fable` (agente fresco), come da regola.
**Esito in una riga:** riparato in `cblib` — spot e DVOL escono **gia' causali** — e §11 rimisurato allo
stesso taglio: **0,714 [0,690-0,779] → 0,712 [0,664-0,732]** (n=19). Il numero non si muove perche' le due
correzioni hanno **segno opposto**; **nessun verdetto cambia**; riparando la prima serie ne e' uscita una
**seconda con lo stesso difetto** (il DVOL giornaliero, fino a 24 ore avanti); il DVOL orario resta un
passo aperto **con costo misurato**. Libro, config, cron: invariati. Nessun ordine.
## 0. Il difetto, verificato prima di ripararlo (D6: si controlla col lag, non si assume)
| serie | come e' etichettata | prova | look-ahead di `asof(ts)` |
|---|---|---|---|
| feed 1h certificato | all'**apertura**: la barra T contiene la chiusura di T+1h | chiusura 1h a T == chiusura 5m a **T+55** nel **100%** delle barre (BTC ed ETH, 1-5/06); a T5 nell'1,7% / 0% | **+1 ora** |
| `dvol_{asset}.parquet` (giornaliero, `fetch_dvol`, `resolution=86400`) | al giorno D contiene la **chiusura delle 23:00 di D** | API pubblica a risoluzione 1h, 1-4/06: `close` 1D del giorno D == `close` 1h delle 23:00 di D, e `open` 1D di D+1 == `close` di D | **fino a +24 ore** |
La seconda riga non era nel debito: e' uscita controllando la convenzione dell'altra serie di
contesto che `cblib` legge con lo stesso `asof`. Il sleeve (`_vrp_weekly_asset`) **non ha il difetto**:
lavora a cadenza giornaliera e accosta la chiusura del giorno D al DVOL del giorno D, che a quella
cadenza e' coerente. Il difetto e' solo di chi entra **a un'ora** con una serie giornaliera.
## 1. La riparazione
`cblib.causale(s, cadenza)` sposta le **etichette** (mai i valori) dall'apertura all'istante in cui la
chiusura e' nota. `spot_series``causale(·, "1h")`, `dvol_series``causale(·, "1D")`. Conseguenze:
- `asof(ts)` = ultima chiusura **conosciuta** a ts (prima della prima chiusura nota: NaN, non un valore).
- il regolamento `ST = S.asof(exp)` alle 08:00 e' ora la chiusura della barra 07:00, cioe' **il prezzo
delle 08:00** — prima era la chiusura della barra 08:00, cioe' il prezzo delle **09:00**, un'ora dopo la
scadenza. Deribit regola su una TWAP dei 30 minuti prima delle 08:00: l'approssimazione e' ora dalla
parte giusta dell'ora.
- `r0909_crash_catturato` **non ri-sposta** (lo `spot_causale` locale della mattina e' rimosso: applicato
due volte darebbe la chiusura di **due** ore prima — lo stesso errore col segno opposto). Test di
non-regressione sul sorgente.
- chi usa `cblib`: `r0822_vrp_real_quotes` (§11), `r0822_skew` (§5), `r0909` (§75), **`vrp_f_watch`**
(cron giornaliero, `data/vrp_f_watch/state.json`), e — trovati dal revisore, non da me —
**`r0730_vrp_real_quotes`** (il f 0,73 [0,706-0,805] di CLAUDE.md §2: rigirato stampa **0,71
[0,680-0,756]**, n=27, 0/27 ≥ 1, f gamba lunga 1,66 → 1,49; verdetto invariato, numero dichiarato in §2)
e **`r0901_btc_collar`** (join del DVOL **per giorno**: con la serie causale avrebbe accostato l'IV di D
al DVOL di D1 — riallineato con `causale(·, "-1D")`, bit-exact a prima, §71 non si muove).
`options_vrp_calibrate.py` ha una **copia propria** di `spot_series` (numeri del 20/06): **non toccato**,
dichiarato.
- la sezione di REGIME di `r0822` (date di calendario: «ultimo giorno aperto», IV-rank per anno) e la
tabella di contesto di `r0909` usano `causale(V, "-1D")`: li' serve il DVOL **del** giorno, non
l'etichetta causale — altrimenti ogni data stampata slitta di un giorno (trovato dal revisore).
- `r0822_vrp_real_quotes` ha ora il flag `--al <data>` (taglio delle scadenze) per riprodurre una misura
a una data: senza, "riprodurre il 22/08" era impossibile perche' la catena cresce ogni ora.
## 2. Prima di pubblicare il numero nuovo, la macchina ha riprodotto quello vecchio (M23)
Worktree di HEAD (`git worktree add`, `data/raw` in symlink), stesso script con `--al 2026-08-22`:
**pooled 0,714 (IC95 [0,690, 0,779], n=19)** — al millesimo, IC compreso. Poi lo stesso comando sul
codice riparato.
## 3. Scomposizione: due correzioni di segno opposto (M26) e un controllo orario
| taglio | spot | DVOL | pooled f | IC95 | n |
|---|---|---|---|---|---|
| 22/08 | grezzo | grezzo (com'era) | **0,714** | [0,690, 0,779] | 19 |
| 22/08 | causale | grezzo | 0,721 | [0,685, 0,758] | 19 |
| 22/08 | grezzo | causale (giornaliero) | 0,693 | [0,670, 0,748] | 19 |
| 22/08 | causale | causale (giornaliero) = **`cblib` oggi** | **0,712** | [0,664, 0,732] | 19 |
| 22/08 | causale | causale **orario** (controllo, API pubblica) | 0,717 | [0,667, 0,732] | 19 |
| 09/09 | grezzo | grezzo | 0,720 | [0,717, 0,952] | 23 |
| 09/09 | causale | causale (giornaliero) | **0,712** | [0,677, 0,741] | 23 |
| 09/09 | causale | causale orario | 0,726 | [0,680, 0,738] | 23 |
Il DVOL di fine giornata **alzava** f: nel rally la vol scende dentro il giorno, la chiusura di D sta
**sotto** quella di D1 (+1,05 pt di vol mediana su 19/19 trade, misura del revisore) → denominatore piu'
basso → f piu' alto; causale: 0,021. Lo spot di un'ora dopo, invece, **non e' un meccanismo ma rumore**:
lo scarto per trade ha segno misto (mediana 0,02% BTC / 0,11% ETH, banda 0,6…+0,9%) e sposta f fino a
**±0,15 per trade** (lo spread δ−0,28/0,10 ha delta netto ~0,18: 0,5% di spot vale 15-20% del credito
modellato); il +0,007 netto e' la somma di 19 segni. ⚠️ Corollario: lo spot causale resta **stantio fino
a 25 minuti** (snapshot al :25, chiusura nota al :00), cioe' ±0,05-0,10 di f per osservazione; il feed
**5m certificato** lo porterebbe a ≤5 minuti. Non fatto: dichiarato in §6. Il DVOL giornaliero causale e' **stantio fino a
23 ore** (agli ingressi delle 08:00: 8 ore): scarto mediano dall'orario **0,2 punti di vol**, max 1,1
(22/08) / 3,0 (oggi). Su §11 vale 0,005 di f.
## 4. Cosa cambia in §11 (taglio 22/08, stesso campione)
| grandezza | prima | dopo | perche' |
|---|---|---|---|
| f pooled | 0,714 [0,690-0,779] | **0,712 [0,664-0,732]** | §3 |
| f BTC / ETH | 0,706 / 0,715 | 0,720 / 0,692 | |
| Sharpe canonico BTC (titolo, non citabile) | 32,58 | 29,61 | regolamento alle 08:00 |
| **mediana onesta** BTC (9 ancore) | 1,45 | **2,12** | banda [0,19, 29,61] (era [0,35, 32,58]) |
| Sharpe canonico ETH → mediana onesta | 3,90 → 2,41 | 7,64 → 3,35 | una settimana ETH cambia taglia: 26/06 27,38 → 12,84 $ (BTC: 31/07 19,88 → 31,69, 14/08 12,94 → 8,68) |
| media BTC fra le ancore | 1,35% → +11,22% | 0,66% → +11,44% | **cambia ancora segno**; sd 11,1× → 9,2× |
| forfait fee vs listino | 1,44× / 1,80× | 1,51× / 1,87× | credito modellato a DVOL causale |
| peggior mark: mediana / minimo / minimo fra le vincenti | 6,4% / 91,0% / 56,9% | 6,4% / 91,5% / 55,2% | |
| P&L somma 19 settimane | $2.733 | $2.822 (+3,3%) | tutto dal regolamento |
| sottostante nel campione | +25,1% / +44,2% | +21,1% / +40,1% | il vecchio regolamento del 21/08 era il prezzo delle **09:00**, che sta **+3,9%** sopra quello delle 08:00 (76.311 → 79.316): un numero a due estremi pesa un'ora intera. (La prima stesura diceva «−1,8%»: avevo letto l'ora dopo, 09:00 → 10:00. Corretto dal revisore) |
**Verdetto di §11: SCARTATO, per le stesse quattro ragioni** (il campione non contiene la strategia; il
titolo non sopravvive all'ora d'ingresso; la famiglia con storia non e' quella eseguibile; lo spread e'
il costo dominante). Nessuna delle quattro dipende da un'ora di spot. Il testo del verdetto nello script
riporta i numeri nuovi con i vecchi accanto.
## 5. Effetti collaterali, misurati (tutto cio' che legge `cblib`)
**§75, crollo catturato** (`r0909`, rigirato): ETH pre-crollo f_net **0,74 [0,65-0,82] → 0,76
[0,71-0,86]**; peggior MTM 1,08 [1,07-1,31] → 1,08 [1,06-1,27]; put δ−0,10 1,92× → 1,98×; al picco 0,94 →
0,99; BTC durante **0,79 → 0,89 [0,74-1,05]**, ETH durante 0,71 → 0,78. Con il DVOL **orario** BTC durante fa **0,83**: nel crollo
il DVOL sale di 5-6 punti al giorno, quindi il giornaliero di fine giornata (com'era) sottostima f e
quello del giorno prima (causale) lo sovrastima — **0,04/+0,06 di f e' il prezzo della stantiezza in un
crollo**, ed e' l'argomento per il feed orario. DVOL "pre" 37,3 → **36,4** (BTC): il 37,3 era la chiusura
del **primo giorno di crollo** (01/06, 3,5%), non del giorno prima — IV-rank pre 0,05 → 0,03. Picco e
percentili **invariati** (49,1 / 69,2; 29° / 46°) dopo aver corretto la tabella di contesto (§7).
Verdetto invariato.
**§5, skew** (`r0822_skew`, rigirato, 66 s): la scomposizione sul panel si muove di **≤0,005** per fattore
(term 0,880 · skew 0,917 · spread 0,910 · fit 0,995 · f_tot 0,729); Q1/Q2 (lag prezzo/skew) **non si
toccano** (serie proprie ancorate a `ts_max`). Agli ingressi il controllo `f_markfit` passa da 1,045 a
**0,991** e le osservazioni che lo superano da 22/28 a **25/28**: l'ora di spot in piu' stava **nel
controllo**, non nell'effetto — riparare ha reso il controllo piu' pulito, non il risultato diverso.
**`vrp_f_watch`** (sorvegliante in `cron_daily`, criteri pre-registrati il 30/07 e **non toccati**):
| | prima (state.json del 09/09 mattina) | dopo |
|---|---|---|
| coppie | 28 | 28 |
| f canonico | 0,732 [0,702-0,784] | **0,706 [0,678-0,745]** |
| f candidato | 0,803 [0,741-0,842] | 0,805 [0,771-0,840] |
| differenza | +0,054 [0,027, +0,105], 18/28 > 0 | **+0,097 [+0,042, +0,140]**, 21/28 > 0 |
| ampiezza IC95 (b ≤ 0,12) | 0,131 — no | **0,098 — OK** |
| coppie (a ≥ 40) | no | no |
| `ready` | False | False |
Lo `state.json` e' stato **riscritto** dal mio lancio (21:14Z, senza `--quiet`; nessun Telegram: notifica
solo a `ready`); il «prima» completo e' la colonna qui sopra (n_positive 18, `esclude_zero` False), e il
cron lo rigenera domani. La differenza ora **esclude lo zero**. Non decide niente: (a) non e' soddisfatto e il candidato resta
bocciato sul deflated-Sharpe, che non dipende da f. Si registra perche' fra un mese qualcuno leggera'
"esclude lo zero" e deve sapere da quando e perche'.
## 6. Cosa resta aperto, col suo costo
| voce | costo di non farlo | costo di farlo |
|---|---|---|
| **DVOL orario** nel feed (`fetch_dvol --res 3600`, riga in `cron_daily`, `cblib` che lo legge) | su §11 0,005 di f; **su un crollo ±0,05**; ogni misura oraria su opzioni a DVOL giornaliero eredita lo scarto | un fetch pubblico gratuito (3.163 barre da maggio in 2 chiamate), una riga di cron, e una decisione: e' un feed nuovo dentro il perimetro (D1, certificazione, backup) |
| **spot causale stantio ≤25 min** (snapshot :25, chiusura :00): ±0,05-0,10 di f per osservazione | `cblib.spot_series` sul feed **5m** certificato (`causale(·, "5m")`): cambia di nuovo ogni numero di `cblib`, quindi con rimisura dichiarata |
| `options_vrp_calibrate.spot_series` (copia, 20/06) | i numeri del 20/06 (f 0,73) restano con l'ora in piu'; §11 li ha gia' sostituiti | riscriverlo su `cblib` |
| la tabella pubblicata di §5 (x0,869 / x0,904 / x0,920) | ≤0,005 per fattore: sotto la risoluzione del campione (14+14 scadenze) | nulla: nota aggiunta |
## 7. Errori dell'autore in sessione
- **`asi8` in microsecondi** scrivendo il test della causalita' (pandas 3): i timestamp sintetici finivano
nel 1970 e il test falliva per la fixture, non per il codice. E' la trappola del debito 1, ripagata
scrivendo un test *sul* debito 18. Corretto con `(idx epoch) // Timedelta(ms)`, la convenzione di
`load_tf`.
- **La tabella di contesto di `r0909` si e' spostata di un giorno** dopo la riparazione: mostrava il DVOL
del giorno D1 accanto al prezzo del giorno D, e il picco del crollo cambiava (69,2 → 68,3, 46° → 43°)
non per una misura ma per un'etichetta. Trovato **col diff degli output**, non da un test: una tabella
per umani non ha test. Ora il contesto usa la chiusura *del* giorno (`causale(V, "-1D")`, dichiarato
nel commento) e `dvol_pre` / `ivr_pre` restano causali.
- Il verdetto §7 di `r0822_vrp_real_quotes` era **prosa con numeri cablati**: sarebbe rimasto a 0,714 per
sempre (N11 al contrario). Aggiornato con i vecchi accanto; il difetto di forma resta (i numeri del
verdetto andrebbero calcolati, non scritti).
## 8. Revisione (`fable`, agente fresco) — 16 segnalazioni, verdetto «correggi e committa»
Ha **riverificato in proprio** la convenzione delle due serie (1h vs 5m su 120 barre: 100%; DVOL contro
l'API oraria) e riprodotto ogni numero dei documenti lanciando gli script. Applicate: **#2** «−1,8% in
un'ora» era falso (avevo letto 09:00 → 10:00; il fatto e' +3,9% 08:00 → 09:00) · **#3** due delle tre
settimane «ETH» erano BTC · **#4** la parentesi sul DVOL era invertita (la chiusura di D e' piu' BASSA,
non piu' alta) · **#5** lo spot non e' un meccanismo ma rumore, e resta stantio ≤25 min · **#6/#7** due
lettori di `cblib` non dichiarati: `r0730` (0,73 → 0,71, ora in CLAUDE.md §2) e `r0901` (riallineato) ·
**#8** le date di calendario di `r0822` §1 slittavano di un giorno · **#9** il test anti-doppio-shift non
catturava `causale(spot_series, "1h")`: ora conta i `causale(` · **#10** ancora 0,74 → 0,76 nel test
d'integrazione · **#11** teste di §75 e memoria 20 §5 con i numeri vecchi come correnti (M28) · **#12**
«±0,05» → «−0,04/+0,06» · **#13** `state.json` riscritto: dichiarato · **#15** lo skew agli ingressi cambia
piu' del panel (correlazioni che cambiano segno): dichiarato in §5. Non applicate: nessuna. Confermati
senza rilievo (#1, #14, #16): verso e taglia degli shift, nessun doppio spostamento, `ST` alle 08:00
coerente con la TWAP 07:30-08:00, tutti i numeri di §3-§5.
## 9. Test
`tests/test_cb_chain_vrp.py`: +3 (causale; `spot_series` causale sul feed 1h con controllo positivo sulla
serie grezza; `dvol_series` causale sul giornaliero). `tests/test_r0909_crash_catturato.py`: il test dello
shift locale diventa "lo script usa le serie di `cblib` senza ri-spostarle" (sorgente + controllo positivo
su `cblib.causale`). Suite intera: **1011 passati, 0 falliti** (erano 1008), rilanciata dopo le correzioni della revisione: 1011/1011.
@@ -0,0 +1,98 @@
# 2026-09-09c — Revisione settimanale in sola lettura: cosa si costruisce e cosa no
**Richiesta dell'operatore:** *«schedulare ogni x tempo un processo di auto revisione dei dati e della
configurazione del sistema in modo che migliori con il tempo … autoregolazione e autoaggiornamento dei
sistemi o anche creare nuovi sistemi … valutando dati macroeconomici … e poi informa me tramite
Telegram»*. Dopo la discussione: *«fai»*.
**Esito in una riga:** costruita la **rilettura settimanale in sola lettura** con revisore diverso
dall'autore (`claude-fable-5-1`), rapporto firmato in `docs/revisioni/` e Sintesi su Telegram, lunedi'
06:15 UTC; **scartati** — con la memoria del progetto, non per prudenza generica — autoregolazione dei
parametri, generazione automatica di strategie e macro da internet. Libro, config, pesi: invariati.
## 1. Perche' tre pezzi su quattro sono no
| pezzo | cosa dice la memoria | esito |
|---|---|---|
| auto-revisione periodica | nove sorveglianti, ognuno sulla sua grandezza; nessuno rilegge l'insieme; la revisione col secondo modello ha trovato oggi due conclusioni false e un look-ahead | **si'** |
| autoregolazione dei parametri | TP01/SKH01 congelati (M20), 75/25 confermato 3 volte, `weights_tilt_null` fallisce; `scale_watch` esiste per rendere visibile ogni cambio di config; A8: una riga di config + una di giornale, decisione dell'operatore | **no** |
| generazione e test di strategie nuove | 69 filoni in 6 ondate; ogni candidato e' un trial (M3) e deflaziona tutto; la ricerca non e' il vincolo binding dal 26/07 (+0,046 €/g il miglior lead contro +4,07%/anno per €100/mese) | **no, salvo freno**: prima si apre la memoria e si batte il motivo della morte; i candidati vanno in forward-monitor con gate e data, mai nel book |
| dati macro da internet | gate macro / DVOL direzionale / skew de-risk: HEDGE o ridondanti (TP01 gia' flat nei crolli); un dato dal web non e' certificato (D1-D4; la APR USDC 3,40% pubblicata e mai incassata) | **no** |
Il centro dell'argomento: **il progetto ha gia' misurato che piu' ricerca non sposta il traguardo, i
bonifici si'**. Un ciclo automatico che cerca strategie ottimizza la leva sbagliata e paga in trial bruciati.
## 2. Cosa gira
`scripts/cron_review.sh` (lunedi' **06:15 UTC**: fuori dal :00, fuori dal martedi' 09:00, dopo il
`cron_daily` delle 00:30 cosi' il giornale di domenica e' chiuso) → `scripts/live/revisione.py --quiet`
`src/live/revisione.scrivi`. Riga installata in crontab (`15 6 * * 1`), letta dal test.
**Materiale** (sola lettura, ~195k caratteri, 27 voci): CLAUDE.md intero · `config/live.json` ·
`crontab -l` · `git log` 14 g + `git status` · `monitor_health` · stato dei sorveglianti (scale, vrp_f,
usde, balance, venue_news, movimenti dichiarati, watermark) · 7 pagine di giornale · diari della
settimana. Gli stati stanno **prima** delle voci grandi: alla prima stesura il tetto totale (320k) aveva
tagliato `usde_convert.jsonl` a 2 caratteri — un sorvegliante invisibile per un limite di lunghezza.
**Prompt**: dieci regole vincolanti (sola lettura · ogni segnalazione cita la fonte · solo numeri del
materiale · le decisioni di §3 non si ripropongono se la colonna «cosa la riapre» non e' soddisfatta ·
un'idea nuova solo citando la memoria che ha ucciso la simile · i gate con data e criterio, senza
anticipare · misurato ≠ dedotto · niente previsioni · URGENTE per cio' che serve all'operatore subito ·
cinque titoli fissi, ~1.000 parole). L'ultimo titolo, «Cosa ho letto e cosa mi mancava», e' l'elenco da
cui il materiale della settimana dopo migliora: e' l'unico «miglioramento col tempo» che questo sistema
si concede, e passa da una modifica al codice, cioe' da una revisione.
**Uscita**: `docs/revisioni/<data>.md` firmato (modello, ora, «SOLA LETTURA», «lettore fallibile P13»,
guardia sui numeri come `analista` con soglia 12, tabella del materiale letto). Telegram: la sola
Sintesi, taglio dichiarato sotto i 4.096. Modello muto o risposta senza i titoli → rapporto «NON
eseguita» col motivo e 🚨: il silenzio non e' una revisione (P5).
**Cosa non puo' fare, per costruzione**: scrivere altro che il rapporto (test sull'albero dei file:
`--secco` non scrive niente, un giro scrive UN file), eseguire proposte, cambiare config. Il modello e'
`claude-fable-5-1` per costante, e un test verifica che sia diverso da `analista.MODELLO_DEFAULT`.
## 3. Test — `tests/test_revisione.py` (16)
Finestra del materiale e file assenti dichiarati · ordine stati-prima-delle-voci-grandi · troncamento
dichiarato · regole nel prompt · guardia (vuoto, titoli mancanti, numeri fuori con controllo positivo) ·
rapporto firmato · modello muto → rapporto + 🚨 · Telegram con HTML neutralizzato e taglio dichiarato ·
`--secco` non chiama e non scrive · un giro scrive un solo file · `--no-telegram` · revisore ≠ autore ·
cadenza: il `.sh` dichiara «lunedi' HH:MM UTC», minuto ≠ 0, fuori dallo slot di release, e la crontab
installata ha gli stessi cinque campi (skip se illeggibile). `test_cli_flag` ha preso lo script da solo
(guardia `valida`, USO coi flag, flag del cron accettati).
## 4. Errori in sessione
- `_tronca(testo, n=MAX_CHARS_VOCE)`: il default e' catturato alla definizione, il test che patchava la
costante non vedeva niente — la stessa trappola del debito 12 (`connect`). Letto a runtime.
- Il tetto totale tagliava in coda: i sorveglianti erano gli ultimi e i primi a sparire. Riordinato, e
il test lo presidia.
## 5. Primo giro reale (22:03Z) — e i tre difetti che solo il giro reale poteva trovare
Il primo lancio e' **morto prima di chiamare il modello**: `OSError: Argument list too long` — 195k
caratteri di prompt passati come argomento alla CLI superano il limite del sistema, e `analista.interroga`
non lo catturava: zero rapporto, zero Telegram, **il silenzio che il modulo esiste per vietare**. Nessun
test l'avrebbe trovato (i test iniettano `interroga`): e' un difetto di trasporto, e il trasporto si valida
sul trasporto (P2). Tre correzioni: (1) `revisione.interroga` proprio, prompt su **stdin**, ogni eccezione
restituita come errore; (2) `scrivi` incapsula chiamata e invio: un'eccezione diventa un rapporto «NON
eseguita» + 🚨; (3) `_cmd` (crontab, git) dichiara qualunque eccezione invece di propagarla. Test con
controllo positivo su `OSError(7)`. Terzo difetto, trovato dal test: `analista.pulisci` **toglie le righe
che iniziano con `#`**, cioe' i cinque titoli che la guardia richiede — usarlo avrebbe reso ogni revisione
«rifiutata: titoli mancanti». Sostituito con `strip()`.
Secondo lancio: **rapporto scritto, stato `sospetta`**, 1.435 parole, `docs/revisioni/2026-09-09.md`.
La guardia ha segnato 11 numeri «non nel materiale»: sono cifre in **formato italiano** («4.477,46»,
«1.341») contro il materiale in formato USA («$4,477.46») — la normalizzazione di `analista` conosce una
convenzione sola. E' un limite dichiarato, non un errore del revisore: si aggiusta quando si vede quanto
spesso scatta. Contenuto: **nessun URGENTE**; tre verifiche in cima (una possibile attribuzione a reward di
400 USDE che erano un acquisto in `usde_watch` del 07/09 — `n_trades` fermo a 50 contro 54 ordini nel
registro; il cuscino USDC del debito §5.17 senza definizione balance/equity; il versamento del 04/09 non
dichiarato in `movimenti_dichiarati.jsonl`); una contraddizione in CLAUDE.md §1 sull'haircut (M28); la data
di arming che differisce fra §0, config e TWR; quattro sorveglianti senza timestamp o fuori da
`monitor_health`; e, come richiesto dalla regola 5, **zero idee di strategia**. «Cosa mi mancava»: stati
con ora di `edge/fee/venue/scale_watch`, l'output di `usde_watch.rendimento()`, i paper a oggi,
`cron_daily.sh`, il registro fill della settimana — l'elenco da cui il materiale della prossima settimana
migliora. Le segnalazioni sono dell'operatore e della prossima sessione: **nessuna e' stata applicata qui**
(sarebbe l'agente che esegue le proposte del revisore nello stesso giro, cioe' il ciclo che si e' scartato).
Costo: una chiamata, ~4 minuti.
@@ -0,0 +1,216 @@
# I cinque punti della revisione del 09/09 — decisi e fatti
**Data:** 2026-09-10 (sessione 12:08Z → 13:10Z; i timestamp qui dentro sono letti, non ricordati)
**Origine:** la prima revisione settimanale (`docs/revisioni/2026-09-09.md`, revisore `fable`) e il
giornale del 09/09. Ogni punto e' stato spiegato all'operatore con pro e contro e **deciso da lui**
(§3-style: decisioni con l'informazione completa). Issue sul Gitea di casa #2-#6, chiuse con la
soluzione scritta (regola globale del 10/09).
## Le decisioni, in una tabella
| # | punto | decisione dell'operatore | issue |
|---|---|---|---|
| 1 | `usde_watch` contava 400 USDE come reward (07/09) | ripara ora + issue | #2 bug |
| 2 | cuscino USDC senza sorvegliante (§5.17) | **equity USDC, riconverte da solo** | #3 sviluppo |
| 3 | versamento 04/09 solo rilevato; piano €5.000 «in attesa» | **e' l'INTERO versamento previsto**: dichiarato, piano chiuso | #6 sviluppo |
| 4 | SCALA-01 A2: da quando contano i 180 giorni | **dal 01/09** (sorvegliante attivo) → gate non prima del **2027-02-28** | #5 bug |
| 5 | PREVDAY-01 «senza data» | **tieni 21/06/2027, cabla kill e veto, correggi §4** | #4 bug |
Sul punto 5 la domanda giusta e' stata «ricorda dove dobbiamo arrivare (50 €/g)»: PREVDAY
de-luckato al 15% vale +0,10 di Sharpe sul libro e la somma di *tutti* i lead dell'ondata di agosto
vale +0,036 €/giorno. Verso i 50 €/g il candidato vale centesimi in qualunque esito del gate:
non si spende una sessione di ricerca per anticipare (a)+(b), e non si butta l'unica gamba short
scorrelata oltre a SKH01 per risparmiare un cron. Costo scelto: mezza giornata, una volta.
## Punto 1 — il lettore dei trade spot aveva DUE limiti, non uno
Il primo era noto dalla revisione: `trade_history(limit=50)` con 54 ordini. Il secondo l'ho
trovato sondando il gateway per ripararlo: `get_user_trades_by_instrument` chiamato senza
`historical` restituisce **solo le ultime ~24 ore** — il fill BTC dell'08/09 06:47Z e' invisibile
alle 12:55Z del 10/09, quello del 09/09 21:47Z si vede. Il cron gira ogni 24h alle 12:35: sta
dentro la finestra per pochi secondi. Un giro saltato avrebbe reso invisibili i trade fra 48h e
24h fa, e il delta sarebbe stato attribuito al reward *esattamente come il 07/09*, con un
meccanismo diverso.
Ora `trade_copertura()` dichiara **non leggibile** (somma `None`, e `analizza` non attribuisce:
P12) in entrambi i casi — lista lunga quanto il limite (troncata) o ultima lettura oltre 24h+5min —
e il record porta `trades_motivo` (P4: il perche'). `TRADE_LIMIT = 1000` e' il `count` massimo di
Deribit. Controllo positivo nei test: 54 ordini si sommano tutti.
La riga del 07/09 in `data/live/usde_watch.jsonl` e' **corretta in loco** con campo `correzione`
(originale conservato dentro, copia del file in `usde_watch.jsonl.pre_fix_20260910`): 54 ordini
= 8 + 30 + 16 dal registro `usde_convert.jsonl`, 2.434 USDE, reward 0,080934. Il tasso non
cambia — la riga era gia' esclusa per `con_trade` — e `rendimento()` oggi da' **3,94% su 14
finestre, 12 pagate**.
## Punto 2 — `cuscino_watch`: la grandezza che nessuno guardava
Il cuscino richiesto e' `2 × 0,5 × 0,30 × equity_tot` = 30% dell'equity, **derivato** da
config e `book.WEIGHT`. La formula e' passata da `usde_convert.py` a `src/live/usde.py`, e
`usde_convert` la importa da li': due lettori, una formula (P1 — cinque occorrenze nel
progetto di sorveglianti puntati su un bersaglio ridichiarato).
| stato | criterio | azione |
|---|---|---|
| OK | slack ≥ 10% del cuscino | niente |
| PREAVVISO | 0 ≤ slack < 10% | ⚠️ alla transizione |
| SCOPERTO | slack < 0 | 🚨 + `usde_convert --quota 0,64 --esegui` (cuscino + 20% di margine, alzato dal 10% in revisione) |
| BLIND | conto non leggibile | ⚠️ alla transizione |
Guardie della riconversione: `execution_enabled` del libro (un solo interruttore), niente vendita
sotto `depeg_warn` (li' decide l'operatore, e l'allarme lo dice), un tentativo ogni 6h, le guardie
proprie di `usde_convert` (banda di prezzo, book leggibile, tetto HARD) ereditate lanciando lo
script vero (P15). Il marcatore «gia' detto» e' l'esito di `notify`: un invio fallito non consuma
la transizione (lezione del debito §5.2). Cron `:53` = dopo il giro del book (`:47`), fuori dal
minuto tondo. `monitor_health` lo sorveglia (max 3h).
📌 **Primo giro a secco (13:00Z): gia' PREAVVISO.** USDC equity $1.361,23 contro $1.335,26
richiesti: **slack +$25,97**. Il 09/09 il revisore aveva stimato +$41/+$59; la marcatura del
libro dal picco del 06/09 ne aveva consumati altri 15-30 senza che nessuno lo vedesse. E' il
caso d'uso del sorvegliante, arrivato prima del sorvegliante.
## Punto 3 — il versamento del 04/09, dal balance e non dall'equity
Il salto di equity 13:47→14:47Z diceva +$2.410,14 col mercato dell'ora dentro. Il **balance
USDC** di `balance_watch` (cambia solo per P&L realizzato, fee, funding e movimenti) dice
1.417,73455581 → 3.832,41432481 = **+$2.414,68**, nessun fill nella finestra, funding su $541
di nozionale ~$0,01. Dichiarato con banda $0,05. Il report ora lo mostra come dichiarato e
il TWR passa a **+6,66%** (era +7,22% stamattina, +7,83% il 06/09): la coda 0,63% dal 04/09 e'
marcatura delle due long, trading da arming **+$13,82**.
L'operatore ha detto che era **l'intero versamento previsto**: la voce «in attesa di importo e
data» di §1 e' chiusa. Resta la lezione di §2: per sei giorni il movimento e' stato solo
rilevato, la regola diceva il giorno stesso.
## Punto 4 — SCALA-01: la data era il punto 5 della SPEC scambiato per il gate
«Non prima del 2026-10-01» erano i 30 giorni di sorvegliante del punto 5 (§7 della SPEC); A2
chiede **≥180 giorni** col criterio passato ogni giorno, e il criterio lo misura solo
`scale_watch`, attivo dal 01/09. Le tre letture possibili, con pro e contro, sono nel diario di
sessione; l'operatore ha scelto **dal 01/09 → 2027-02-28**. Contare dall'arming avrebbe dato
un criterio ricostruito a posteriori, che non prova nulla sul sorvegliante; ridurre A2 a 30 giorni
avrebbe allentato un gate senza un costo d'attesa che lo giustifichi (il gradino 1,25x vale meno
di €100/mese di versamento).
Riconciliata anche la data d'arming: **20/06 = TP01 da solo** (commit `4650aa7`), **23/06 = il
BOOK** (commit `db738bc`, prima lettura di equity 23/06 22:00Z). CLAUDE.md §0 diceva solo la
prima, `config/live.json` e il TWR usano la seconda: erano due eventi, non un errore.
## Punto 5 — PREVDAY-01 aveva la data; non l'aveva la tabella
`RESULTS-0822` §54: decisione **2027-06-21**, kill, veto. La tabella §4 diceva «scritto 23/08 ·
10 condizioni», e il revisore ha letto quella. Ora la riga ha data, kill, veto e stato (5/7:
(a) e (b) sono strutturali, nessun forward le cambia).
Cablato in `paper_prevday.py` (prima esisteva solo nel testo, a differenza di `paper_dvolspread`):
| misura | valore al 10/09 |
|---|---|
| Sharpe forward **giornaliero** (lente del kill; l'oraria +1,19 non si cita, §54) | **+0,95** su 81 giorni, 81 attivi |
| kill (< 0,50 su ≥180 g attivi) | **NON MATURO**, leggibile dal ~18/12/2026 |
| barre ricostruibili dal feed di oggi | **1.942/1.943** (99,9%); l'unica divergente e' il 26/08 00:00, il giorno del fix di `advance()` |
| veto | ok |
⚠️ **Errore mio, catturato prima di committare:** la prima stesura del veto leggeva «divergenze
non crescenti» come `tasso_recente > tasso_totale`, e con UNA barra divergente negli ultimi 30
giorni (0,03/g contro 0,01/g) dichiarava VETO. Una guardia piu' stretta del contratto produce
allarmi che si impara a ignorare (P14): ora la crescita richiede **≥5 barre recenti E tasso
doppio**, dichiarato nel codice e provato nel test con i numeri della serie vera.
La ricostruzione al 99,9% contro il 95,6% di §54: la misura del 23/08 era stata fatta *prima*
del fix di `advance()` del 26/08 (le 67 barre divergenti erano «all'ora del cron», cioe' barre
parziali); oggi il feed rivisto e la serie coincidono tranne quella barra.
## Cose trovate per strada
- Due timestamp «di comodo» scritti a mano (13:05Z e 13:07Z) invece dell'ora letta (12:57:43Z e
13:04:09Z): corretti subito. E' esattamente la regola globale «la data si LEGGE» — vale anche per
chi la scrive nel campo `correzione`.
- `issue --json` va **prima** del sottocomando; il percorso nella regola globale era
`/opt/docker/AI-OS` invece di `/opt/AI-OS` (corretto la mattina, nel test dello strumento).
## Verifica
`uv run pytest`: **1060 passati** (erano 1031). Nuovi: `test_cuscino_watch.py` (14),
`test_paper_prevday_gate.py` (8), +5 in `test_usde_watch.py`. Revisione del diff affidata a
`fable` (agente fresco): esito e correzioni applicate in coda a questo diario.
## Revisione `fable` (13:10-13:18Z): 15 segnalazioni, 4 medie — tutte verificate, 12 applicate
| # | segnalazione | verifica | esito |
|---|---|---|---|
| 1 | `puo_riconvertire` contava anche i NON tentativi (`tentata=False`): un giro `--secco` o un prezzo illeggibile bloccava la riconversione vera per 6h | riprodotta | filtro su `tentata` + test |
| 2 | il tetto del venue in `usde_convert.piano` bloccava anche la **vendita**: con `venue_cap_frac` rimesso in config la riconversione automatica sarebbe stata morta | riprodotta (`side=sell`, `ok=False`) | vale solo in acquisto |
| 3 | `MARGINE_RIPRISTINO` 10% == `PREAVVISO_FRAC` 10%: dopo ogni 🚨 un ⚠️ per costruzione (P14) | riprodotta col floor di 1 USDE | margine **20%** → quota **0,64**, test |
| 4 | `--secco` scriveva nel registro vivo (veicolo del punto 1, e maschera un cron fermo) | riprodotta (riga 13:00:15Z) | non scrive; riga tolta |
| 5 | due bersagli di quota: `quota_target` 0,70 in config contro 0,64 del ripristino; il ripristino e' un **ratchet verso il basso** | letto | dichiarato in CLAUDE.md §5.17 e nel docstring: decisione dell'operatore (N9) |
| 7 | buco di ≤5 min dentro la tolleranza della finestra 24h | letto | dichiarato (D5) + fonte Deribit documentata |
| 8 | `giorni_attivi` legge posizioni REAL, il kill e' MODELED | letto | dichiarato nel docstring (81/81 oggi) |
| 10 | il test del cron leggeva un commento nello `.sh`, non la crontab | letto | legge `crontab -l` come `test_book_cadenza` |
| 11 | `prevday_target` patchato senza `monkeypatch` | letto | `monkeypatch` |
| 13 | «QUATTRO movimenti» in §2: sono TRE, in quattro tratti | letto | corretto |
| 14 | «5/7» contro «8/10» di §54: base diversa non dichiarata | letto | dichiarata |
| 6, 9, 12, 15 | verifiche che reggono (`sys.executable`, `cwd`, `piano()` in SCOPERTO, ricostruzione ≡ `advance`) | — | — |
Non applicato: il lock fra cron e conversione a mano (DUBBIO 2) — dichiarato come limite nel
docstring. Il DUBBIO 1 (ratchet) e' la domanda aperta per l'operatore.
## Coda (13:55Z →): il riacquisto automatico — issue #7
Alla domanda aperta (ratchet verso il basso) l'operatore ha risposto «crea una strategia per
riportarla a quota in autonomia». La risposta ingenua — ricomprare fino a `quota_target` 0,70 —
e' sbagliata per costruzione: a 0,70 lo slack e' zero, la prima ora in perdita vende, il
riacquisto ricompra, e cosi' via a 6 bps al giro. Serve **isteresi con un bersaglio unico**:
| slack / cuscino | stato | azione |
|---|---|---|
| < 0 | SCOPERTO | 🚨 vende USDE → 0,20 |
| 0 0,10 | PREAVVISO | ⚠️ |
| 0,10 0,40 | OK | — |
| > 0,40 | **ECCEDENTE** | 📌 compra USDE → 0,20 |
Bersaglio 0,20 ⇒ quota **0,64**. Per oscillare lo slack deve muoversi di ±0,20×cuscino = ±6%
dell'equity, cioe' **±8,6% di equity USDC (~$380)** perche' slack = 0,7·USDC 0,3·USDE (la prima
stesura diceva ±6%/$270: corretta in revisione): a leva 0,26x sono ~33% di mercato. Un bonifico alza lo slack di
0,7×importo: sopra ~9% dell'equity (~$380) il riacquisto scatta da solo — la regola di §1
«dopo un bonifico si rilancia `usde_convert`» non dipende piu' da qualcuno che se ne ricordi.
Le tre frazioni vivono in `config/live.json` (`usde.bande_cuscino` verifica 0 < preavviso <
margine < riacquisto), `quota_target` 0,70 e' stato tolto. Soglie dichiarate, non ottimizzate
(M8): il costo di un giro e' ~$0,2, la frequenza attesa qualche giro al mese nei tratti mossi.
📌 **Trovato al primo giro vero del cron (13:53Z): l'allerta PREAVVISO non e' partita.** Il token
era valido (`getMe` ok); il testo conteneva «(< 10% del cuscino)» e Telegram in `parse_mode=HTML`
rifiuta un `<` nudo. Testi riscritti senza `<`, `allerta_errore` registrato nel record (P3),
test che legge il sorgente delle `notify`. Un sorvegliante nuovo che non riesce a parlare al
primo giro e' il caso di P2: si valida sul segnale E sul trasporto.
Stato al giro a secco delle 14:0xZ: quota 69,3%, slack +$30 = PREAVVISO. Il piano a 0,64 e'
valido (SELL 237 USDE, slack dopo +$267): il sorvegliante lo esegue alla prima ora sotto zero;
allinearlo subito e' una riga a mano.
### Seconda revisione `fable` (14:04Z): 10 segnalazioni, 1 ALTA, 3 MEDIE — applicate 9
| # | segnalazione | esito |
|---|---|---|
| 1 ALTA | il `<` arrivava ancora a Telegram dal campo `motivo` («(< 6h)») dentro l'allerta 🚨: il 🚨 piu' importante perso fino a 4h | escape nel **sink** `notifier.notify` (`html.escape` su titolo e valori; nessuno dei 45 chiamanti passa tag: grep) + motivi senza `<` |
| 2 MEDIA | il test sul sorgente era parziale (codice morto, cieco ai valori interpolati) | sostituito: cattura la POST e verifica `&lt;` |
| 3 MEDIA | ECCEDENTE con guasto persistente = «acquisto FALLITA» ogni 6h per sempre | da ECCEDENTE dopo un fallimento si riprova ogni 24h; da SCOPERTO resta 6h |
| 4 MEDIA | bande fuori ordine ⇒ traceback ingoiato dal cron, nessun record, «fermo» invece di «rotto» | `giudica` ⇒ BLIND con motivo; record e allerta scritti |
| 5-7 | CLAUDE.md §1 diceva ancora «nessuno sorveglia»; §5.17 «tre stati»; `_nota_usde` con `quota_target` | corretti |
| 8 | «±6% di equity USDC» sbagliato di 0,7: slack = 0,7·USDC 0,3·USDE ⇒ ±8,6% (~$380) | corretto in 4 posti |
| 9 | `TOLL` ridichiarato nel test | importato da `usde_convert` |
| 10 | esempi `--quota 0.70` nel docstring di `usde_convert` | 0,64 |
Verifiche numeriche del revisore (loop su `piano()` con passo 1 USDE, TOLL, min order): bonifico
+$2.000 → 11 acquisti → OK, slack +$387; perdita $50 → 3 vendite → OK, +$264; conto tutto USDC →
20 acquisti → OK. **Nessuna sequenza vendita→acquisto→vendita.** Non applicato: `min(q*, cap)` con
il tetto del venue (dubbio 3: un conto al tetto resta ECCEDENTE e ora allerta una volta al giorno).
Dubbio 4, che vale una riga in §5.17: **0,64 e' una derivazione dell'autore, non la quota decisa
dall'operatore il 30/08 (0,70)**; ~$10/anno di resa di differenza; da confermare.
### Allineamento eseguito (14:16:38Z, ordine dell'operatore «allinea subito a 0.64, esegui»)
`usde_convert --quota 0.64 --esegui`: SELL 100 + 100 + 38 USDE a 1,0000 medio (limite 0,9995,
fill al book), quota 69,4% → **64,02%**, conto USDC $1.602,79 + USDE 2.851,95 = $4.454,74.
Slack dopo **+$266** su $1.336 di cuscino: `cuscino_watch --secco` dice **OK** (era PREAVVISO a
+$29). Costo: ~$0,1 di spread, fee zero. Registro in `data/live/usde_convert.jsonl`.
+52
View File
@@ -0,0 +1,52 @@
# Risk-off ±2h intorno a FOMC e CPI su TP01 — REFUTED, e il segno e' l'opposto
**Data:** 2026-09-10 14:52-14:55Z · **Script:** `scripts/research/r0910_macro_riskoff.py` (N11: il
verdetto lo calcola lo script) · **Issue:** #9 · **Origine:** domanda dell'operatore da un articolo su
EA forex a griglia con «agente fondamentale» che approva/rifiuta i segnali intorno ai dati macro.
## Cosa si e' misurato
TP01 canonico (posizione decisa alla chiusura giornaliera, tenuta 24 barre 1h), 2019-03 → 2026-09,
65.676 ore. Variante RISK-OFF: esposizione zero nelle barre che intersecano [t2h, t+2h] intorno a
**61 dichiarazioni FOMC** (14:00 ET, piu' le due d'emergenza di marzo 2020, notation vote esclusi) e
**88 release CPI** (08:30 ET), con fee di chiusura e riapertura. Calendario **letto dal web** (Fed e
BLS), non ricordato; l'archivio BLS raggruppa per anno di riferimento e le release di gennaio/febbraio
vanno spostate di +1 anno (verificato sulla schedule 2026). 684 ore in finestra = **1,04%** del tempo.
Verdetto pre-registrato prima del primo numero: LEAD solo se (a) ΔSharpe con fee > 0, (b) Δ al ≥95°
percentile del null location-matched (stessi conteggi per anno, stesse ore del giorno, giorni a caso,
1.000 estrazioni — M18), (c) Δ > 0 in ≥70% degli anni (M9).
## Numeri
| | Sharpe giornaliero | drift/anno |
|---|---|---|
| TP01 base | 1,302 | +15,73% |
| risk-off con fee | 1,205 | +14,49% |
| risk-off senza fee | 1,228 | +14,77% |
| **Δ** | **0,097** | **1,24%** ≈ **0,14 €/giorno** a $4.455 |
Per tipo: FOMC ΔSh 0,012 (P&L rinunciato +0,79%), **CPI 0,084 (+6,36%)**. Per anno: Δ negativo in
6/8, positivo solo 2024 e 2026 (+0,02/+0,03).
Null location-matched: ΔSh mediana 0,033 [p05 0,095, p95 +0,027]. **Il Δ vero sta al 4,2°
percentile**: le finestre vere sono fra le PEGGIORI da appiattire, cioe' fra le migliori in cui
essere esposti. Dentro la finestra TP01 fa **+1,05 bp/ora contro +0,17 fuori** (t = 1,57 su 684 ore).
(a) FAIL · (b) FAIL · (c) 2/8 FAIL ⇒ **REFUTED**.
## Lettura
1. **Il risk-off toglie P&L, non rischio.** TP01 e' long-flat vol-targeted: nelle ore dei dati macro
il trend che sta cavalcando accelera piu' spesso di quanto si rovesci, e l'1% del tempo in finestra
porta il 7% del guadagno cumulato. E' la stessa lezione del weekend (§3, 17/07: il 38% del gross
nel 31% del tempo): **la coda del trend vive nelle ore «pericolose»**.
2. **Il segno opposto non e' un lead.** «Esporsi DI PIU' intorno ai dati» sarebbe un'ipotesi nuova
(M12), con t = 1,57 e 684 ore: sotto qualunque MDE, e la selezione sull'esito la vieta. Non si apre.
3. Il gate «fondamentale» dell'articolo, applicato al nostro libro, costerebbe **1,24%/anno di drift**
— la stessa taglia del funding non modellato (2,16%), ma con segno scelto da chi lo propone.
4. Coerente con `macro-regime-gate` (29/06, ridondante col trend) e con la conclusione di §3: **ogni
risk-off parte REFUTED** e questo lo e' rimasto.
Non modellato (dichiarato): funding (identico nelle due varianti salvo l'1% delle ore); slippage
della finestra (rende il risk-off *piu'* costoso, non meno). Costo: 3 minuti di calcolo, 0 ordini.
+60
View File
@@ -0,0 +1,60 @@
# Giornale di bordo — 2026-09-02
*Scritto 2026-09-03T00:36:49+00:00. Numeri misurati; nessuna interpretazione automatica.*
## Mercato
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|---|---|---|---|---|---|---|---|
| **BTC** | $77,312.50 | -0.15% | -2.17% | +21.78% | 42.9% | ↑ ↑ ↑ (3/3 su) | 37.2 (16° pctl 1a) |
| **ETH** | $2,391.55 | -1.11% | -4.63% | +28.63% | 68.5% | ↑ ↑ ↑ (3/3 su) | 50.9 (17° pctl 1a) |
## Libro
Equity **$2,045.82** · nozionale lordo $560 · leva lorda 0.27x · barra dati 2026-09-02 · 24 giri
| | TP01 | SKH01 | target | posizione | azione |
|---|---|---|---|---|---|
| **BTC** | +0.454 | flat | $+348 | $+347 | HOLD (a target) |
| **ETH** | +0.276 | flat | $+212 | $+213 | HOLD (a target) |
## P&L
- giorno: **$-4.95** di equity (24 letture)
- realizzato: -0.35 netto su 1 round-trip chiusi · 2 fill · fee 0.0045
- cumulato dall'arming: **$+1,447.76** di equity — di cui $+1,399.39 versati/prelevati -> trading **$+48.37** · TWR **+10.47%**
## 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 $+347 (TP01 +0.454, SKH01 flat) e ETH $+213 (TP01 +0.276, SKH01 flat).
- `[leva]` Leva lorda **0.27x** su un tetto di 1.00x (`max_notional_per_asset_frac` 0.50 x 2 asset, letto da `config/live.json`): margine 0.73x.
- `[pnl]` Equity $-4.95 con 2 fill e 1 round-trip chiusi (realizzato $-0.35 netto).
- `[drawdown]` 📌 Equity **-1.34%** sotto il picco ($2,073.59). ⚠️ e' un DD su letture ORARIE, non sul minimo intra-giorno.
- `[vol]` BTC: implicita 37.2 contro realizzata 30g 42.9 (sotto); DVOL al 16° pctl di un anno; IV-rank espandente 0.06 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
- `[vol]` ETH: implicita 50.9 contro realizzata 30g 68.5 (sotto); DVOL al 17° pctl di un anno; IV-rank espandente 0.12 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
- `[evidenza]` 📌 Campione a oggi: **71 giorni, 30 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-03T00:37:49+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
Il fatto nuovo e' l'equity a $2,045.82: sotto ogni chiusura registrata dopo il versamento del 25/08, dove il minimo era $2,047.39 del 30/08. Non viene dal trading — realizzato $-0.35 su un round-trip, fee 0.0045, due fill — ma dal prezzo su due posizioni tenute a target per tutte le 24 letture.
Il picco da cui si misura il -1.34% ($2,073.59) non compare fra le chiusure giornaliere: e' una lettura oraria, quindi quel drawdown vive su una griglia piu' fine della tabella dei giorni.
La tensione del giorno e' fra le scale: TSMOM 3/3 su su entrambi gli asset, ma 1g e 7g negativi (BTC -2.17%, ETH -4.63% a 7g) contro 30g ancora +21.78% e +28.63%. Il libro resta long perche' la gamba lunga tiene; una lettura altrettanto compatibile e' che i 30g stiano semplicemente restituendo il rialzo accumulato. Quale delle due, non e' misurabile qui.
Insolito il pacchetto volatilita': implicita sotto la realizzata su entrambi (37.2 contro 42.9, 50.9 contro 68.5) con DVOL al 16° e 17° percentile dell'anno, mentre il gate di VRP01 legge l'IV-rank (0.06 e 0.12) e resta fermo — guarda una cosa diversa da quello scarto.
Meriterebbe un occhio umano il perche' il $-4.95 arrivi con salute perfetta, 24/24 giri e feed a 0 min. Il campione resta 71 giorni e 30 round-trip, con $+48.37 di trading sui $+1,447.76 cumulati.
## Nota
*(vuota — campo dell'operatore)*
+58
View File
@@ -0,0 +1,58 @@
# Giornale di bordo — 2026-09-03
*Scritto 2026-09-04T00:37:16+00:00. Numeri misurati; nessuna interpretazione automatica.*
## Mercato
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|---|---|---|---|---|---|---|---|
| **BTC** | $81,286.00 | +5.14% | +1.27% | +26.90% | 45.5% | ↑ ↑ ↑ (3/3 su) | 39.8 (38° pctl 1a) |
| **ETH** | $2,507.90 | +4.87% | -0.15% | +34.20% | 69.8% | ↑ ↑ ↑ (3/3 su) | 53.3 (27° pctl 1a) |
## Libro
Equity **$2,074.42** · nozionale lordo $562 · leva lorda 0.27x · barra dati 2026-09-03 · 24 giri
| | TP01 | SKH01 | target | posizione | azione |
|---|---|---|---|---|---|
| **BTC** | +0.453 | flat | $+352 | $+349 | HOLD (a target) |
| **ETH** | +0.274 | flat | $+213 | $+213 | HOLD (a target) |
## P&L
- giorno: **$+28.60** di equity (24 letture)
- realizzato: -0.14 netto su 4 round-trip chiusi · 4 fill · fee 0.0095
- cumulato dall'arming: **$+1,476.36** di equity — di cui $+1,399.39 versati/prelevati -> trading **$+76.97** · TWR **+12.02%**
## 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 $+349 (TP01 +0.453, SKH01 flat) e ETH $+213 (TP01 +0.274, SKH01 flat).
- `[leva]` Leva lorda **0.27x** su un tetto di 1.00x (`max_notional_per_asset_frac` 0.50 x 2 asset, letto da `config/live.json`): margine 0.73x.
- `[pnl]` Equity $+28.60 con 4 fill e 4 round-trip chiusi (realizzato $-0.14 netto).
- `[vol]` BTC: implicita 39.8 contro realizzata 30g 45.5 (sotto); DVOL al 38° pctl di un anno; IV-rank espandente 0.14 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
- `[vol]` ETH: implicita 53.3 contro realizzata 30g 69.8 (sotto); DVOL al 27° pctl di un anno; IV-rank espandente 0.14 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
- `[evento]` 📌 BTC: giornata a +5.14%, **2.2 deviazioni** giornaliere (sd implicita dalla RV30 = 2.38%).
- `[evidenza]` 📌 Campione a oggi: **72 giorni, 34 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-04T00:38:30+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
Il fatto nuovo non e' il segno, e' la taglia: $+28.60 e un'equity a $2,074.42, la piu' alta delle dieci sedute mostrate, dopo giorni in cui il delta si e' mosso fra 17.87 e +12.06. Arriva da un BTC a +5.14%, 2.2 deviazioni, mentre il libro era gia' a target su entrambe le gambe: con nozionale lordo $562 e leva 0.27x, l'aumento e' rivalutazione di una posizione ferma, non l'esito di una decisione presa oggi. Il realizzato lo conferma — 0.14 netto su 4 round-trip, rumore di fee (0.0095) — e le posizioni restano $+349 e $+213, in linea con le sedute precedenti.
Insolito il quadro vol: entrambe le implicite sotto la realizzata 30g (39.8 contro 45.5, 53.3 contro 69.8), coi DVOL al 38° e 27° percentile dell'anno, in una giornata a due deviazioni. Non misurabile qui se sia salita la realizzata oggi o se l'implicita non abbia seguito.
Lettura alternativa, ugualmente compatibile: BTC fa +5.14% in un giorno ma +1.27% in sette, ed ETH 0.15%; buona parte del guadagno e' recupero dentro la settimana, non nuova direzione, benche' TSMOM sia 3/3 su su entrambi.
Un solo giorno pesa molto su un trading da $+76.97 in 72 giorni e 34 round-trip. Varrebbe un controllo umano sui 4 fill di una giornata chiusa a target: dovrebbero essere ri-taglie.
## Nota
*(vuota — campo dell'operatore)*
+58
View File
@@ -0,0 +1,58 @@
# Giornale di bordo — 2026-09-04
*Scritto 2026-09-05T00:36:37+00:00. Numeri misurati; nessuna interpretazione automatica.*
## Mercato
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|---|---|---|---|---|---|---|---|
| **BTC** | $79,660.50 | -2.00% | +2.33% | +23.30% | 46.6% | ↑ ↑ ↑ (3/3 su) | 38.0 (24° pctl 1a) |
| **ETH** | $2,457.05 | -2.03% | +0.56% | +28.81% | 70.5% | ↑ ↑ ↑ (3/3 su) | 51.1 (18° pctl 1a) |
## Libro
Equity **$4,483.73** · nozionale lordo $1,173 · leva lorda 0.26x · barra dati 2026-09-04 · 24 giri
| | TP01 | SKH01 | target | posizione | azione |
|---|---|---|---|---|---|
| **BTC** | +0.428 | flat | $+719 | $+717 | HOLD (a target) |
| **ETH** | +0.270 | flat | $+455 | $+456 | HOLD (a target) |
## P&L
- giorno: **$+2,409.31** di equity (24 letture) — di cui **$+2,410.14 movimenti di capitale** -> trading **$-0.83**
- realizzato: -0.02 netto su 2 round-trip chiusi · 5 fill · fee 0.2328
- cumulato dall'arming: **$+3,885.67** di equity — di cui $+3,809.53 versati/prelevati -> trading **$+76.14** · TWR **+11.70%**
## 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.*
- `[movimento]` 📌 **Movimento di capitale $+2,410.14** fra le 13:47 e le 14:47 UTC: salto +116.8% contro un massimo spiegabile dal mercato di ±0.6% al tetto di leva. Scorporato dal P&L di trading qui sotto.
- `[stato]` A mercato su BTC $+717 (TP01 +0.428, SKH01 flat) e ETH $+456 (TP01 +0.270, 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]` P&L di trading $-0.83 (equity $+2409.31 al lordo del movimento di capitale) con 5 fill e 2 round-trip chiusi (realizzato $-0.02 netto).
- `[vol]` BTC: implicita 38.0 contro realizzata 30g 46.6 (sotto); DVOL al 24° pctl di un anno; IV-rank espandente 0.08 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
- `[vol]` ETH: implicita 51.1 contro realizzata 30g 70.5 (sotto); DVOL al 18° pctl di un anno; IV-rank espandente 0.12 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
- `[evidenza]` 📌 Campione a oggi: **73 giorni, 36 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-05T00:37:58+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
Il fatto della giornata non è il mercato ma la cassa: l'equity passa da $2,074.42 a $4,483.73, e il salto di $+2,410.14 fra le 13:47 e le 14:47 UTC è il secondo dopo il delta +1,414.57 del 25/08. Il libro l'ha assorbito subito: le posizioni, ferme per dieci giorni fra $+344 e $+364 su BTC e fra $+205 e $+218 su ETH, oggi valgono $+717 e $+456, entrambe a target, a leva lorda 0.26x su un tetto di 1.00x. Nessuna scelta discrezionale: TP01 +0.428 e +0.270, SKH01 flat.
Il numero da maneggiare con cura è il $-0.83 di trading. BTC e ETH chiudono a -2.00% e -2.03% con $1,173 di nozionale lordo lungo: l'intervallo che contiene il versamento esce intero dal conteggio, quindi quel $-0.83 non copre la giornata piena. Il realizzato, $-0.02 netto su 2 round-trip e 0.2328 di fee, è compatibile sia con una giornata quasi neutra sia con una perdita di mercato finita fuori dal segmento — qui non si separa.
Insolito il quadro vol: implicita sotto la realizzata su entrambi (38.0 contro 46.6; 51.1 contro 70.5), DVOL al 24° e 18° percentile; VRP01 resta fermo per l'IV-rank (0.08 e 0.12), non per questo.
Salute piena, 24/24 giri e feed a 0 min; campione 73 giorni e 36 round-trip. Varrebbe che l'operatore confermi a mano importo e ora del versamento: la Nota è vuota.
## Nota
*(vuota — campo dell'operatore)*
+57
View File
@@ -0,0 +1,57 @@
# Giornale di bordo — 2026-09-05
*Scritto 2026-09-06T00:37:28+00:00. Numeri misurati; nessuna interpretazione automatica.*
## Mercato
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|---|---|---|---|---|---|---|---|
| **BTC** | $79,835.00 | +0.22% | +2.03% | +24.19% | 46.4% | ↑ ↑ ↑ (3/3 su) | 38.7 (29° pctl 1a) |
| **ETH** | $2,481.10 | +0.98% | +0.93% | +30.40% | 70.4% | ↑ ↑ ↑ (3/3 su) | 51.7 (21° pctl 1a) |
## Libro
Equity **$4,490.66** · nozionale lordo $1,161 · leva lorda 0.26x · barra dati 2026-09-05 · 24 giri
| | TP01 | SKH01 | target | posizione | azione |
|---|---|---|---|---|---|
| **BTC** | +0.420 | flat | $+708 | $+711 | HOLD (a target) |
| **ETH** | +0.268 | flat | $+451 | $+450 | HOLD (a target) |
## P&L
- giorno: **$+6.93** di equity (24 letture)
- realizzato: -0.16 netto su 3 round-trip chiusi · 3 fill · fee 0.0068
- cumulato dall'arming: **$+3,892.60** di equity — di cui $+3,809.53 versati/prelevati -> trading **$+83.07** · TWR **+11.87%**
## 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 $+711 (TP01 +0.420, SKH01 flat) e ETH $+450 (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.93 con 3 fill e 3 round-trip chiusi (realizzato $-0.16 netto).
- `[vol]` BTC: implicita 38.7 contro realizzata 30g 46.4 (sotto); DVOL al 29° pctl di un anno; IV-rank espandente 0.10 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
- `[vol]` ETH: implicita 51.7 contro realizzata 30g 70.4 (sotto); DVOL al 21° pctl di un anno; IV-rank espandente 0.13 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
- `[evidenza]` 📌 Campione a oggi: **74 giorni, 39 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-06T00:38:25+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
Il fatto della giornata non è di oggi: il 04/09 l'equity è passata da $2,074.42 a $4,483.73, +$2,409.31 in un giorno, e questa è la prima pagina intera scritta sul libro nuovo. La forma non è cambiata — leva lorda 0.26x, HOLD a target su entrambe le gambe, TP01 +0.420 e +0.268, SKH01 flat su tutte e due — ma le posizioni sì: $+711 e $+450 contro i $+347 e $+213 del 02/09. È cambiata la taglia, non il comportamento; e il $+6.93 di oggi va letto su una base doppia, dove la stessa cifra in dollari pesa meno di quanto pesava a fine agosto.
Il cumulato lo dice più chiaramente: $+3,892.60, di cui $+3,809.53 versati o prelevati e $+83.07 di trading in 74 giorni e 39 round-trip. La cifra citabile resta il TWR, +11.87%.
Insolito, e non spiegabile da questa pagina: implicita sotto realizzata su entrambi (38.7 contro 46.4, 51.7 contro 70.4) con DVOL al 29° e 21° percentile, mentre i 30 giorni segnano +24.19% e +30.40%. Lettura alternativa altrettanto compatibile col +$6.93: il mark-to-market di due long in una giornata salita (+0.22% e +0.98%), non un merito di selezione — con 3 fill e realizzato $-0.16 netto, qui non è distinguibile.
Un controllo umano lo merita il movimento del 04/09: che abbia la sua riga di giornale, non ricostruita dopo.
## Nota
*(vuota — campo dell'operatore)*
+57
View File
@@ -0,0 +1,57 @@
# Giornale di bordo — 2026-09-06
*Scritto 2026-09-07T00:36:43+00:00. Numeri misurati; nessuna interpretazione automatica.*
## Mercato
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|---|---|---|---|---|---|---|---|
| **BTC** | $80,376.00 | +0.68% | +3.44% | +23.88% | 46.4% | ↑ ↑ ↑ (3/3 su) | 39.3 (35° pctl 1a) |
| **ETH** | $2,515.10 | +1.37% | +4.04% | +31.44% | 70.4% | ↑ ↑ ↑ (3/3 su) | 53.2 (27° pctl 1a) |
## Libro
Equity **$4,502.93** · nozionale lordo $1,175 · leva lorda 0.26x · barra dati 2026-09-06 · 24 giri
| | TP01 | SKH01 | target | posizione | azione |
|---|---|---|---|---|---|
| **BTC** | +0.420 | flat | $+710 | $+717 | SELL $-7 |
| **ETH** | +0.268 | flat | $+453 | $+458 | HOLD (a target) |
## P&L
- giorno: **$+12.27** di equity (24 letture)
- realizzato: +0.13 netto su 1 round-trip chiusi · 1 fill · fee 0.0028
- cumulato dall'arming: **$+3,904.87** di equity — di cui $+3,834.43 versati/prelevati -> trading **$+70.44** · TWR **+8.02%**
## 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 $+717 (TP01 +0.420, SKH01 flat) e ETH $+458 (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 $+12.27 con 1 fill e 1 round-trip chiusi (realizzato $+0.13 netto).
- `[vol]` BTC: implicita 39.3 contro realizzata 30g 46.4 (sotto); DVOL al 35° pctl di un anno; IV-rank espandente 0.12 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
- `[vol]` ETH: implicita 53.2 contro realizzata 30g 70.4 (sotto); DVOL al 27° pctl di un anno; IV-rank espandente 0.14 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
- `[evidenza]` 📌 Campione a oggi: **75 giorni, 40 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-07T00:37:44+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
Il fatto del giorno non e' il $+12.27: e' che dopo il salto del 04/09 il libro ha ripreso la sua forma abituale a una taglia nuova. Le posizioni sono cresciute con l'equity ($+717 e $+458 contro $+349 e $+213 del 03/09), non col segnale: TP01 sta a 0.420 e 0.268, parziale su entrambe le gambe, e la leva lorda resta 0.26x su un tetto di 1.00x. SKH01 e' flat su BTC e su ETH — il quarto del libro che fa breakout oggi non porta nulla, e da quanti giorni sia cosi' non e' misurabile qui.
L'attivita' e' scesa: 1 fill e 1 round-trip contro i 3-5 dei giorni scorsi, e l'unica azione e' un SELL $-7 su una posizione da $+717. Vale un'occhiata umana, senza allarme: le soglie d'ordine sono in dollari assoluti e l'equity ha cambiato scala.
Cumulato $+3,904.87, di cui $+3,834.43 versati; il trading vale $+70.44 su 75 giorni e 40 round-trip — abbastanza poco che un movimento di capitale scorporato male se lo mangerebbe intero.
Una lettura alternativa dello stesso dato: implicite sotto le realizzate su entrambi (39.3 contro 46.4, 53.2 contro 70.4) e DVOL al 35° e 27° percentile dopo trenta giorni a +23.88% e +31.44%. Le regole ci leggono il gate VRP01 chiuso; e' altrettanto compatibile un rialzo che il mercato delle opzioni non sta pagando.
## Nota
*(vuota — campo dell'operatore)*
+57
View File
@@ -0,0 +1,57 @@
# Giornale di bordo — 2026-09-07
*Scritto 2026-09-08T00:36:58+00:00. Numeri misurati; nessuna interpretazione automatica.*
## Mercato
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|---|---|---|---|---|---|---|---|
| **BTC** | $79,100.00 | -1.59% | +0.68% | +21.85% | 47.1% | ↑ ↑ ↑ (3/3 su) | 38.8 (31° pctl 1a) |
| **ETH** | $2,490.35 | -0.98% | +0.91% | +29.98% | 70.6% | ↑ ↑ ↑ (3/3 su) | 52.6 (25° pctl 1a) |
## Libro
Equity **$4,484.18** · nozionale lordo $1,156 · leva lorda 0.26x · barra dati 2026-09-07 · 24 giri
| | TP01 | SKH01 | target | posizione | azione |
|---|---|---|---|---|---|
| **BTC** | +0.420 | flat | $+707 | $+704 | HOLD (a target) |
| **ETH** | +0.268 | flat | $+451 | $+452 | HOLD (a target) |
## P&L
- giorno: **$-18.75** di equity (24 letture)
- realizzato: +0.10 netto su 1 round-trip chiusi · 3 fill · fee 0.0066
- cumulato dall'arming: **$+3,886.12** di equity — di cui $+3,834.43 versati/prelevati -> trading **$+51.69** · TWR **+7.57%**
## Salute
- giri di `book_execute`: 24/24
- eta' feed SKH all'ultimo giro: 0 min
## Lettura
*Generata da regole dichiarate in `src/live/journal.py` — combina i numeri qui sopra e nient'altro. L'id fra parentesi quadre dice quale regola ha parlato. Non e' un giudizio: quello sta nella Nota.*
- `[stato]` A mercato su BTC $+704 (TP01 +0.420, SKH01 flat) e ETH $+452 (TP01 +0.268, SKH01 flat).
- `[leva]` Leva lorda **0.26x** su un tetto di 1.00x (`max_notional_per_asset_frac` 0.50 x 2 asset, letto da `config/live.json`): margine 0.74x.
- `[pnl]` Equity $-18.75 con 3 fill e 1 round-trip chiusi (realizzato $+0.10 netto).
- `[vol]` BTC: implicita 38.8 contro realizzata 30g 47.1 (sotto); DVOL al 31° pctl di un anno; IV-rank espandente 0.11 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
- `[vol]` ETH: implicita 52.6 contro realizzata 30g 70.6 (sotto); DVOL al 25° pctl di un anno; IV-rank espandente 0.13 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
- `[evidenza]` 📌 Campione a oggi: **76 giorni, 41 round-trip**. A questa taglia il P&L non distingue l'edge dalla fortuna — il criterio di morte del libro (`edge_watch`) lavora su una finestra di 36 mesi.
## Analisi (agente)
*Scritta da `claude-opus-5` il 2026-09-08T00:37:50+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
La giornata rompe tre chiusure positive di fila (+28.60, +6.93, +12.27) con $-18.75, la perdita giornaliera più grande della serie qui riportata. È quasi tutta valutazione, non attività: 3 fill, 1 round-trip, realizzato $+0.10 netto, con BTC -1.59% e ETH -0.98% e il libro long su entrambi. Il segno torna; la taglia non è verificabile qui, perché la pagina non scompone il P&L per asset.
Ciò che è cambiato davvero risale al salto del 04/09: le posizioni sono passate dai $+357 / $+214 di fine agosto ai $+704 / $+452 di oggi e ci restano, mentre la leva lorda resta 0.26x. A parità di rischio relativo la stessa percentuale di mercato si scrive con un numero di dollari più grande: le prossime giornate rosse sembreranno peggiori senza esserlo.
Insolito il quadro della volatilità: implicita sotto la realizzata su entrambi (38.8 contro 47.1; 52.6 contro 70.6) con DVOL al 31° e al 25° percentile di un anno. Il gate di VRP01 legge l'IV-rank (0.11 e 0.13) e resta chiuso, ma una lettura altrettanto compatibile è che il mercato tratti il movimento a 30 giorni (+21.85%, +29.98%) come già avvenuto.
TP01 porta segnali parziali (0.420, 0.268) e SKH01 è flat malgrado TSMOM 3/3 su: c'è tendenza, non breakout. Il cumulato $+3,886.12 è quasi tutto versato ($+3,834.43); resta $+51.69 di trading su 76 giorni e 41 round-trip, TWR +7.57% — campione che non separa edge e fortuna. Salute piena, 24/24 giri e feed a 0 min: niente da controllare a mano oggi.
## Nota
*(vuota — campo dell'operatore)*
+58
View File
@@ -0,0 +1,58 @@
# Giornale di bordo — 2026-09-08
*Scritto 2026-09-09T00:36:48+00:00. Numeri misurati; nessuna interpretazione automatica.*
## Mercato
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|---|---|---|---|---|---|---|---|
| **BTC** | $78,470.50 | -0.80% | +1.34% | +20.97% | 47.3% | ↑ ↑ ↑ (3/3 su) | 39.9 (40° pctl 1a) |
| **ETH** | $2,485.45 | -0.20% | +2.77% | +30.16% | 70.6% | ↑ ↑ ↑ (3/3 su) | 53.9 (33° pctl 1a) |
## Libro
Equity **$4,477.46** · nozionale lordo $1,149 · leva lorda 0.26x · barra dati 2026-09-08 · 24 giri
| | TP01 | SKH01 | target | posizione | azione |
|---|---|---|---|---|---|
| **BTC** | +0.415 | flat | $+697 | $+698 | HOLD (a target) |
| **ETH** | +0.268 | flat | $+449 | $+451 | HOLD (a target) |
## P&L
- giorno: **$-6.72** di equity (24 letture)
- realizzato: +0.00 netto su 1 round-trip chiusi · 2 fill · fee 0.0055
- cumulato dall'arming: **$+3,879.40** di equity — di cui $+3,834.43 versati/prelevati -> trading **$+44.97** · TWR **+7.41%**
## Salute
- giri di `book_execute`: 24/24
- eta' feed SKH all'ultimo giro: 0 min
## Lettura
*Generata da regole dichiarate in `src/live/journal.py` — combina i numeri qui sopra e nient'altro. L'id fra parentesi quadre dice quale regola ha parlato. Non e' un giudizio: quello sta nella Nota.*
- `[stato]` A mercato su BTC $+698 (TP01 +0.415, SKH01 flat) e ETH $+451 (TP01 +0.268, SKH01 flat).
- `[leva]` Leva lorda **0.26x** su un tetto di 1.00x (`max_notional_per_asset_frac` 0.50 x 2 asset, letto da `config/live.json`): margine 0.74x.
- `[pnl]` Equity $-6.72 con 2 fill e 1 round-trip chiusi (realizzato $+0.00 netto).
- `[drawdown]` 📌 Equity **-0.57%** sotto il picco ($4,502.93). ⚠️ e' un DD su letture ORARIE, non sul minimo intra-giorno.
- `[vol]` BTC: implicita 39.9 contro realizzata 30g 47.3 (sotto); DVOL al 40° pctl di un anno; IV-rank espandente 0.14 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
- `[vol]` ETH: implicita 53.9 contro realizzata 30g 70.6 (sotto); DVOL al 33° pctl di un anno; IV-rank espandente 0.15 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
- `[evidenza]` 📌 Campione a oggi: **77 giorni, 42 round-trip**. A questa taglia il P&L non distingue l'edge dalla fortuna — il criterio di morte del libro (`edge_watch`) lavora su una finestra di 36 mesi.
## Analisi (agente)
*Scritta da `claude-opus-5` il 2026-09-09T00:37:27+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
Terzo giorno sotto il picco del 06/09 ($4,502.93), e secondo consecutivo in rosso dopo i $-18.75 del 07/09: oggi $-6.72. La differenza rispetto alla settimana precedente al versamento non e' il segno ma la scala — le posizioni sono passate da BTC $+347/ETH $+213 a BTC $+698/ETH $+451, quindi oscillazioni di equity dello stesso ordine in dollari valgono ora meno in percentuale. Con realizzato $+0.00 su 1 round-trip e HOLD a target su entrambe le gambe, la perdita del giorno e' rivalutazione delle posizioni aperte contro BTC -0.80% ed ETH -0.20%, non attivita' di trading.
L'elemento insolito e' altrove: TSMOM 3/3 su su entrambi gli asset, BTC +20.97% e ETH +30.16% a 30 giorni, e nonostante questo TP01 sta a +0.415 e +0.268 con SKH01 flat — leva lorda 0.26x su un tetto di 1.00x. Il libro e' poco investito dentro il trend che dovrebbe essere il suo caso favorevole; se questo sia disciplina di sizing o segnale debole non e' misurabile qui.
Lettura alternativa altrettanto compatibile: a 0.26x di esposizione il P&L giornaliero e' quasi interamente beta di mercato, e i 77 giorni / 42 round-trip non permettono di distinguerlo dall'edge.
Merita un'occhiata umana il round-trip chiuso con realizzato esattamente $+0.00 a fronte di fee 0.0055.
## Nota
*(vuota — campo dell'operatore)*
+58
View File
@@ -0,0 +1,58 @@
# Giornale di bordo — 2026-09-09
*Scritto 2026-09-10T00:38:03+00:00. Numeri misurati; nessuna interpretazione automatica.*
## Mercato
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|---|---|---|---|---|---|---|---|
| **BTC** | $78,283.50 | -0.24% | +1.26% | +22.45% | 46.8% | ↑ ↑ ↑ (3/3 su) | 40.2 (42° pctl 1a) |
| **ETH** | $2,467.95 | -0.70% | +3.19% | +31.89% | 70.1% | ↑ ↑ ↑ (3/3 su) | 53.7 (32° pctl 1a) |
## Libro
Equity **$4,472.10** · nozionale lordo $1,157 · leva lorda 0.26x · barra dati 2026-09-09 · 24 giri
| | TP01 | SKH01 | target | posizione | azione |
|---|---|---|---|---|---|
| **BTC** | +0.418 | flat | $+701 | $+704 | HOLD (a target) |
| **ETH** | +0.270 | flat | $+453 | $+453 | HOLD (a target) |
## P&L
- giorno: **$-5.36** di equity (24 letture)
- realizzato: +0.00 netto su 0 round-trip chiusi · 2 fill · fee 0.0047
- cumulato dall'arming: **$+3,874.04** di equity — di cui $+3,834.43 versati/prelevati -> trading **$+39.61** · TWR **+7.28%**
## 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.418, SKH01 flat) e ETH $+453 (TP01 +0.270, 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 $-5.36 con 2 fill e 0 round-trip chiusi (realizzato $+0.00 netto).
- `[drawdown]` 📌 Equity **-0.68%** sotto il picco ($4,502.93). ⚠️ e' un DD su letture ORARIE, non sul minimo intra-giorno.
- `[vol]` BTC: implicita 40.2 contro realizzata 30g 46.8 (sotto); DVOL al 42° pctl di un anno; IV-rank espandente 0.14 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
- `[vol]` ETH: implicita 53.7 contro realizzata 30g 70.1 (sotto); DVOL al 32° pctl di un anno; IV-rank espandente 0.15 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
- `[evidenza]` 📌 Campione a oggi: **78 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-10T00:39:20+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
Il fatto nuovo non e' il segno del giorno ma la sua composizione: $-5.36 di equity con 2 fill, zero round-trip chiusi e realizzato $+0.00. La perdita e' tutta marcatura su posizioni aperte, e le posizioni sono a target su entrambe le gambe ($+704 contro $+701, $+453 contro $+453): il libro non ha deciso niente, ha ri-tagliato. Nella serie qui sopra l'unico altro giorno senza round-trip chiusi e' il 01/09; e' il terzo giorno di equity in discesa dopo il picco del 06/09 a $4,502.93, con BTC -0.24% e ETH -0.70%.
L'esposizione la porta interamente TP01 (+0.418 su BTC, +0.270 su ETH) mentre SKH01 e' flat su entrambi, e questo con TSMOM 3/3 su e 30 giorni a +22.45% e +31.89%: il trend c'e', il breakout no. Esecuzione pulita, 24/24 giri e feed SKH a 0 min.
L'implicita sotto la realizzata (40.2 contro 46.8; 53.7 contro 70.1) si legge in due modi ugualmente compatibili — vol a sconto, oppure realizzata gonfiata dalla corsa dei 30 giorni. Quale dei due, non e' misurabile qui.
Da guardare senza urgenza: il cumulato $+3,874.04 e' quasi tutto versamento ($+3,834.43), il trading vale $+39.61 su 42 round-trip in 78 giorni. A quella taglia il TWR +7.28% non e' ancora una misura di edge.
## Nota
*(vuota — campo dell'operatore)*
+34 -2
View File
@@ -69,7 +69,11 @@ chi lo riapre deve battere il motivo, non ripetere l'esperimento.
solo LOWVOL 19-major regge standalone (FULL/HOLD 1.07) ma deflated-Sharpe 0.13 + storia 2.5a → **DEBOLE/ solo LOWVOL 19-major regge standalone (FULL/HOLD 1.07) ma deflated-Sharpe 0.13 + storia 2.5a → **DEBOLE/
forward STAT-MODE** (`2026-06-29-xsec-v2-nonmom.md`). (D) **MACRO regime-gate** (equity/credito/oro/tassi → forward STAT-MODE** (`2026-06-29-xsec-v2-nonmom.md`). (D) **MACRO regime-gate** (equity/credito/oro/tassi →
de-risk crypto): **RIDONDANTE col trend** (corr→TP01 0.989; il gate lavora solo nel 2-3% dei giorni, TP01 de-risk crypto): **RIDONDANTE col trend** (corr→TP01 0.989; il gate lavora solo nel 2-3% dei giorni, TP01
già flat nei crash) → SCARTATO (`2026-06-29-macro-regime-gate.md`). già flat nei crash) → SCARTATO (`2026-06-29-macro-regime-gate.md`). (D-bis, 10/09) **MACRO RISK-OFF ±2h intorno a
FOMC/CPI su TP01** (`r0910_macro_riskoff.py`, §78): **REFUTED 0/3** — ΔSh 0,097, drift 1,24%/anno, Δ vero al
4,2° pctl del null location-matched: nell'1% delle ore in finestra TP01 fa il 7% del guadagno (+1,05 bp/h
contro +0,17). Come il weekend: la coda del trend vive nelle ore «pericolose». Un gate «fondamentale»
che rifiuta i segnali intorno ai dati costerebbe quanto il funding, con segno scelto da chi lo propone.
- **Sweep strategie a 5 thread (2026-06-29) — 0 nuovi sleeve, 1 LEAD che rompe 2 muri su 3.** Ricerca - **Sweep strategie a 5 thread (2026-06-29) — 0 nuovi sleeve, 1 LEAD che rompe 2 muri su 3.** Ricerca
parallela onesta su aree inesplorate (harness `altlib`+`xsec_v2_nonmom`, tutti i gate incl. il nuovo parallela onesta su aree inesplorate (harness `altlib`+`xsec_v2_nonmom`, tutti i gate incl. il nuovo
@@ -701,7 +705,7 @@ chi lo riapre deve battere il motivo, non ripetere l'esperimento.
**0,93** (alto) e **supera 1 in backwardation** mentre `f_skew` resta piatto -> **la parte maggiore **0,93** (alto) e **supera 1 in backwardation** mentre `f_skew` resta piatto -> **la parte maggiore
del difetto si annulla proprio dove il gate fa tradare il sleeve**, quindi f=0,73 e' plausibilmente del difetto si annulla proprio dove il gate fa tradare il sleeve**, quindi f=0,73 e' plausibilmente
**conservativo li'**. Non misurato (0/22 ingressi passano il gate) e in un crash i due pezzi vanno **conservativo li'**. Non misurato (0/22 ingressi passano il gate) e in un crash i due pezzi vanno
in **direzioni contrarie**. **`VRP_CFG["f"]` NON cambiato.** Replica indipendente del f: **0,714**. in **direzioni contrarie**. **`VRP_CFG["f"]` NON cambiato.** Replica indipendente del f: **0,714** (0,712 dal 09/09 sera, `cblib` causale; il 0,73 del 30/07 stampa 0,71).
📌 **(7) CHIUSURE DI FAMIGLIA.** **Il filone funding si chiude sul QUARTO lato** (affollamento): 📌 **(7) CHIUSURE DI FAMIGLIA.** **Il filone funding si chiude sul QUARTO lato** (affollamento):
su **53.430 ore / 3 anni** non predice ne' direzione ne' volatilita'. E i futures datati **non sono su **53.430 ore / 3 anni** non predice ne' direzione ne' volatilita'. E i futures datati **non sono
un quinto lato**: il loro basis **E'** il funding del perp (+7,33% implicito contro **+6,48%** un quinto lato**: il loro basis **E'** il funding del perp (+7,33% implicito contro **+6,48%**
@@ -1521,3 +1525,31 @@ una maschera a caso della stessa frequenza**.
**Gated sull'IV sottile** (la copertura dinamica che §46 non aveva provato): migliora, perde. **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), **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. 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,76 [0,71-0,86] = 0,712 del rally** (rimisurato la sera con `cblib` causale; era 0,74 = 0,714); 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 §5.18** (`cblib.spot_series` + `asof` un'ora avanti) **riparato la sera stessa**, insieme al DVOL
(fino a 24h avanti): §11 0,714 → 0,712, verdetti invariati; `vrp_f_watch` f canonico 0,732 → 0,706 e la
differenza col candidato +0,097 [+0,042, +0,140] ora esclude lo zero (criterio (a) 28/40 ancora aperto). **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.
+36
View File
@@ -1011,3 +1011,39 @@ impedire.** Danno reale quel giorno: nessuno — alle 09:47 l'equity era leggibi
che si accende se la fixture viene rimossa. che si accende se la fixture viene rimossa.
- ⚠️ **Resta il principio più largo:** oggi è deviato **solo** il watermark. `data/live/trades.db` e - ⚠️ **Resta il principio più largo:** oggi è deviato **solo** il watermark. `data/live/trades.db` e
`data/live/book_executions.jsonl` sono esposti allo stesso errore (§5.12). `data/live/book_executions.jsonl` sono esposti allo stesso errore (§5.12).
### Revisione settimanale in sola lettura (2026-09-09)
L'operatore ha chiesto «un processo di auto-revisione dei dati e della configurazione, che si autoregola,
si autoaggiorna, crea sistemi nuovi valutando mercato e dati macro, e mi informa su Telegram». La risposta,
misurata contro la memoria, e' stata in quattro pezzi: **la rilettura periodica manca e vale** (i nove
sorveglianti guardano ognuno la sua grandezza; nessuno rilegge il sistema intero, e la revisione con un
secondo modello ha trovato in una sessione due conclusioni false e un look-ahead); **l'autoregolazione dei
parametri no** (e' cio' che `scale_watch`/`venue_watch` trattano come anomalia; A8 della chiave di scala:
una riga di config, una di giornale, decisione dell'operatore); **la generazione automatica di strategie
solo con un freno** (ogni candidato e' un trial, M3, e la ricerca non e' il vincolo binding dal 26/07: il
miglior lead vale +0,046 €/giorno contro +4,07%/anno di drift per €100/mese in piu'); **il macro da
internet no** (gate macro, DVOL direzionale e skew de-risk sono gia' HEDGE/ridondanti; un dato dal web
non e' certificato — la APR USDC «pubblicata» e mai incassata). L'operatore ha scelto la prima («fai»).
Cosa gira: `scripts/cron_review.sh` (lunedi' 06:15 UTC, fuori dal :00 e dal martedi' 09:00, dopo il
`cron_daily` delle 00:30) → `scripts/live/revisione.py --quiet``src/live/revisione.scrivi`. Materiale
(~195k caratteri, sola lettura, ordine: regole e stato dichiarato, cio' che gira, cio' che e' successo):
CLAUDE.md intero, `config/live.json`, `crontab -l`, `git log` 14 g + `git status`, `monitor_health`,
stato dei sorveglianti (scale, vrp_f, usde, balance, venue_news, movimenti dichiarati, watermark — messi
PRIMA delle voci grandi, cosi' il tetto totale non li taglia mai: alla prima stesura `usde_convert` era
finito a 2 caratteri), 7 pagine di giornale, i diari della settimana. Prompt con dieci regole: sola
lettura, ogni segnalazione cita la fonte, solo numeri del materiale (guardia `numeri_non_supportati` di
`analista`, soglia 12), le decisioni vincolanti di §3 non si ripropongono, un'idea nuova si propone solo
citando la memoria che ha ucciso la simile, i gate si elencano con data e criterio senza anticipare il
verdetto, misurato ≠ dedotto, niente previsioni, URGENTE per cio' che serve all'operatore subito, cinque
titoli fissi. Rapporto in `docs/revisioni/<data>.md` firmato (modello, data, «lettore fallibile P13»,
tabella del materiale letto con caratteri e troncature); Telegram = la sola Sintesi, con taglio dichiarato.
Modello `claude-fable-5-1` via `analista.interroga` (CLI `claude -p`, `--allowed-tools ""`, timeout 900 s):
**non il modello che scrive il codice**, ed e' un test (`test_il_revisore_non_e_il_modello_che_scrive_il_codice`).
Un modello muto o una risposta senza i titoli produce un rapporto «NON eseguita» col motivo e un 🚨: il
silenzio non e' una revisione (P5). Guardie: `valida` di `cli` come prima istruzione (`test_cli_flag` lo
deriva dalla cartella), `--secco` non chiama e non scrive (test sull'albero dei file), un giro scrive **UN
solo file** (test), la cadenza e' dichiarata nel `.sh` e letta dalla crontab installata (`test_revisione`,
stesso schema di `test_book_cadenza`). Costo: una chiamata a settimana. Cio' che il sorvegliante non puo'
fare: eseguire. E' voluto.
+22
View File
@@ -157,3 +157,25 @@
`fresh_5m`). **REGOLA: un guasto IN CORSO si misura al GIORNO PEGGIORE, non in media** — la prima `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 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`. 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.
**RIPARATO la sera stessa in `cblib`** (`causale(s, cadenza)`: spot +1h, e **anche il DVOL
giornaliero, +1 giorno** — la riga del giorno D e' la chiusura delle 23:00 di D, verificato contro la
risoluzione 1h dell'API pubblica: seconda serie con lo stesso difetto, quinta occorrenza di D6). §11
riprodotto al millesimo su worktree HEAD e rimisurato: 0,714 → **0,712** (le correzioni hanno segno
opposto; DVOL orario di controllo 0,717). Il DVOL causale da feed giornaliero e' **stantio fino a 23 ore**
(0,2 pt mediana, 3 pt max; nel crollo ±0,05 di f): il feed DVOL orario e' il passo aperto. Diario
`2026-09-09b-debito-18-cblib-causale.md`.
- **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).
+100
View File
@@ -197,6 +197,18 @@ lati contro il **93%** di BTC_USDC, e il suo miglior bid sta a **un tick**.
DVOL alla mediana del 7°/11° percentile storico, sottostante +25%/+44%. DVOL alla mediana del 7°/11° percentile storico, sottostante +25%/+44%.
- ✅ **Replica indipendente del f del 30/07 su campione piu' lungo: f = 0,714 pooled** (IC95 - ✅ **Replica indipendente del f del 30/07 su campione piu' lungo: f = 0,714 pooled** (IC95
[0,690, 0,779], **0/19 osservazioni >= 1,0**). Attraversare lo spread costa **~10% del credito**. [0,690, 0,779], **0/19 osservazioni >= 1,0**). Attraversare lo spread costa **~10% del credito**.
**RIMISURATO il 2026-09-09 sera** (debito §5.18: `cblib` dava lo spot di un'ora DOPO e il DVOL di
fino a 24 ore DOPO l'ingresso): allo stesso taglio (22/08, n=19) **f = 0,712 [0,664, 0,732]** — le due
correzioni hanno segno opposto (solo spot 0,721, solo DVOL 0,693) e un DVOL orario di controllo da'
0,717; a oggi (n=23) 0,712 [0,677, 0,741]. **Il verdetto non cambia.** Cambiano i numeri di
contorno: Sharpe canonico BTC 32,58 → 29,61 con mediana onesta **2,12** (era 1,45) e banda
[0,19, 29,61]; ETH 3,90 → 7,64 (mediana onesta 3,35); media BTC fra le ancore 0,66% → +11,44%
(cambia ancora segno); forfait fee 1,51× (BTC) / 1,87× (ETH); il regolamento e' il prezzo delle
08:00 e non delle 09:00 (P&L +3,3%); sottostante nel campione +21% / +40% (era +25% / +44%: il vecchio
regolamento del 21/08 era il prezzo delle 09:00, **+3,9%** sopra quello delle 08:00 — quanto pesa un'ora su
un numero a due estremi). Lo stesso `cblib` rigira anche `r0730`: il f 0,73 [0,706-0,805] del 30/07 stampa
ora **0,71 [0,680-0,756]** (n=27, 0/27 ≥ 1).
Diario `2026-09-09b-debito-18-cblib-causale.md`.
- ⚠️ **Correzione a un numero pubblicato:** il forfait fee del sleeve sovrastima il listino vero di - ⚠️ **Correzione a un numero pubblicato:** il forfait fee del sleeve sovrastima il listino vero di
**1,44x (BTC) / 1,80x (ETH)**, non di "~2x". Il segno (conservativo) regge, la taglia no. **1,44x (BTC) / 1,80x (ETH)**, non di "~2x". Il segno (conservativo) regge, la taglia no.
- ⚠️ **Rischio sul MINIMO, non sulle chiusure:** peggior mark infra-settimana mediana 6,4% del - ⚠️ **Rischio sul MINIMO, non sulle chiusure:** peggior mark infra-settimana mediana 6,4% del
@@ -374,6 +386,13 @@ nuovo: **va decisa PRIMA del 23/10, non quel giorno.**
(il collettore li accumula da solo), revisione comunque **2027-02-22**. (il collettore li accumula da solo), revisione comunque **2027-02-22**.
- ✅ Confound di modello **ESCLUSO e misurato**: `f_markfit` = **0,992** (la mark IV di Deribit - ✅ Confound di modello **ESCLUSO e misurato**: `f_markfit` = **0,992** (la mark IV di Deribit
riprezza i propri mid entro l'1%) -> RR/BF sono un fatto di **prezzo**, non l'output del fit. riprezza i propri mid entro l'1%) -> RR/BF sono un fatto di **prezzo**, non l'output del fit.
📌 **Rigirato il 2026-09-09 sera con spot e DVOL causali** (debito §5.18): la scomposizione sul panel si
muove di **≤0,005** per fattore (term 0,880 · skew 0,917 · spread 0,910 · fit 0,995 · f_tot 0,729) e Q1/Q2
non si toccano (serie proprie); agli **ingressi** il controllo `f_markfit` passa da 1,045 a **0,991** e
le osservazioni che lo superano da 22/28 a **25/28** — l'ora di spot in piu' stava nel controllo, non
nell'effetto. Agli ingressi (n=28) si muove di piu': f_term 0,910 → 0,875, f_tot 0,732 → 0,706, e le
correlazioni `corr(f_tot_mid, rr7)` +0,21 → 0,06 e `atm7` +0,02 → +0,34 **cambiano segno/ordine** — sono
22-25 punti, dichiarate (P12), non usate.
- ✅ **Controllo positivo cablato nello script:** con l'ancora **assunta** a :30 invece del `ts_max` - ✅ **Controllo positivo cablato nello script:** con l'ancora **assunta** a :30 invece del `ts_max`
osservato compare un **falso lead** a +15m (0,102 contro 0,031) — *"e' l'errore che avrei osservato compare un **falso lead** a +15m (0,102 contro 0,031) — *"e' l'errore che avrei
pubblicato senza quel controllo"*. pubblicato senza quel controllo"*.
@@ -3969,3 +3988,84 @@ 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 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, ~+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.** 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,76 [0,71-0,86] = 0,712 del rally** (era 0,74 = 0,714 prima della riparazione di §5.18, la sera stessa; put 1,98× e picco 0,99 idem);
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 §5.18 RIPARATO la sera stessa** in `cblib`
(spot +1h, e anche il DVOL giornaliero, +1 giorno): ETH pre-crollo f_net **0,76 [0,71-0,86]** (era 0,74),
BTC durante **0,89 [0,74-1,05]** (era 0,79; con DVOL orario 0,83 — nel crollo il DVOL giornaliero stantio
vale 0,04/+0,06 di f), put δ−0,10 1,98× (era 1,92), DVOL pre 36,4 / 49,6 (il 37,3 di BTC era la chiusura del
PRIMO giorno di crollo: contaminato). Picco e percentili invariati. Verdetto invariato.
**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.
## 78 — MACRO-RISKOFF (TP01 flat ±2h intorno a FOMC e CPI: vale qualcosa?)
`scripts/research/r0910_macro_riskoff.py` (10/09, issue #9, ~3 min). Calendario letto dal web (Fed + BLS,
61 FOMC + 88 CPI, 2019-03 → 2026-09), 684 ore in finestra = 1,04% del tempo. Verdetto pre-registrato
(ΔSh>0 con fee · ≥95° pctl del null location-matched a 1.000 estrazioni · positivo in ≥70% degli anni).
**REFUTED 0/3**: ΔSharpe giornaliero **0,097**, drift **1,24%/anno** (≈ 0,14 €/g a $4.455), negativo in
6/8 anni, e il Δ vero sta al **4,2° percentile** del null — le ore intorno ai dati macro sono fra le MIGLIORI
in cui TP01 e' esposto (+1,05 bp/h contro +0,17, t = 1,57): l'1% del tempo porta il 7% del guadagno. Stessa
lezione del weekend (§3): la coda del trend vive nelle ore «pericolose». Il segno opposto NON si apre
(ipotesi nuova, sotto l'MDE, selezionata sull'esito). Diario `2026-09-10b-macro-riskoff.md`.
+3
View File
@@ -8,6 +8,9 @@
> `data/live/scale_history.jsonl`, `tests/test_book_scale.py` (T1-T11, **18 test verdi**). > `data/live/scale_history.jsonl`, `tests/test_book_scale.py` (T1-T11, **18 test verdi**).
> 🚨 **`config/live.json` NON e' stato toccato**, come prescrive il punto 2: la chiave e' assente, > 🚨 **`config/live.json` NON e' stato toccato**, come prescrive il punto 2: la chiave e' assente,
> vale 1,00, e T7 dimostra bit-exact che il libro e' quello di sempre (`max|diff| = 0.0`). > vale 1,00, e T7 dimostra bit-exact che il libro e' quello di sempre (`max|diff| = 0.0`).
> 📌 **(10/09, issue #5) A2 conta dal 2026-09-01**, primo giorno di `scale_watch`: 180 giorni cadono il
> **2027-02-28**, ed e' la data «non prima del» del gate in CLAUDE.md §4. Il «2026-10-01» scritto il 01/09 era
> il punto 5 (30 giorni di sorvegliante) scambiato per la data del gate: incompatibile con A2 per costruzione.
> **Restano i punti 5-7:** ≥30 giorni a 1,00 col sorvegliante attivo, poi `GATE SCALA-01`, poi > **Restano i punti 5-7:** ≥30 giorni a 1,00 col sorvegliante attivo, poi `GATE SCALA-01`, poi
> `r0726_fee_sensitivity` rifatto. Il punto 5 non e' burocrazia — e' l'unico modo di scoprire che il > `r0726_fee_sensitivity` rifatto. Il punto 5 non e' burocrazia — e' l'unico modo di scoprire che il
> sorvegliante e' rotto mentre la leva e' ancora 1,00. > sorvegliante e' rotto mentre la leva e' ancora 1,00.
+87
View File
@@ -0,0 +1,87 @@
# Revisione settimanale — 2026-09-09
*Scritta il 2026-09-09T21:59:07Z dal modello `claude-fable-5-1`, revisore diverso dall'autore del codice, in SOLA LETTURA: nessun file del repo, nessuna config, nessun ordine e' stato toccato. E' l'opinione di un lettore fallibile (P13): ogni numero e ogni proposta vanno verificati alla fonte prima di agire. Guardia sui numeri: **sospetta** — 11 numeri non presenti nel materiale: 1.011, 1.341, 1.38279, 1.40085, 2.034, 2.43408, 4.47099, 4.47746, 400, 40008, 6879.*
## Sintesi
Revisione del 2026-09-09 21:59Z (primo giro, manuale: il cron e' del lunedi'). **Nessun URGENTE.** Nella settimana 02-08/09 il libro ha fatto 24/24 giri ogni giorno con feed SKH a 0 min, leva lorda 0,26x, equity $4.477,46 e TWR +7,41% (giornale 08/09). Tre cose da verificare, in ordine:
1. **`usde_watch` del 07/09 ha stimato un reward di 400,08 USDE**, dove i giorni normali (08-09/09) danno 0,38. E' la differenza fra i 2.434,08 USDE entrati e i 2.034,0 contati come trade, con `n_trades` fermo a **50**. Il registro `usde_convert.jsonl` mostra 8 + 30 + 16 = 54 ordini: ne mancano 4 da 100 = 400. Un lettore che vede al massimo 50 trade attribuisce al reward cio' che era un acquisto (D2, P2). Se `rendimento()` usa quella finestra, il tasso che `quota_da_decidere()` stampa ogni giorno e' inquinato.
2. **Il cuscino USDC (debito §5.17) resta senza sorvegliante, e la sua misura non e' definita**: a `balance_watch` 21:42Z l'USDC ha balance 1.400,85 ma equity 1.382,79; con l'equity totale 4.470,99 (watermark) il cuscino al 30% vale ~$1.341, quindi lo slack e' **+$59 sul balance o +$41 sull'equity USDC**. Quale dei due conta va deciso prima di scrivere il sorvegliante.
3. **Operatore**: il versamento di $2.410,14 del 04/09 e' solo *rilevato* (salto 13:47→14:47); `movimenti_dichiarati.jsonl` contiene il solo €25 del 25/08 e la Nota di giornale e' vuota 7/7 giorni. CLAUDE.md §2 chiede la dichiarazione il giorno stesso, e §1 («soldi fermi») dice ancora «in attesa di importo e data» per il piano €5.000: non si capisce se il 04/09 ne e' il primo pezzo.
## Cosa non torna
- **«2.434 USDE in 39 ordini»** (diario `2026-09-06-usde-quota-topup` §3, CLAUDE.md §1) non si riproduce dal registro: 8 ordini nel primo record, 30 eventi nella prima sonda, 16 nella seconda. Il totale USDE torna, il conteggio no (N11).
- **Haircut, due affermazioni opposte in CLAUDE.md §1** (M28): una dice che il 5% e' confermato dal passaggio a X:SM (`available_funds` $2.011,09 contro $2.010,90 attesi *con* haircut 5%); poche righe sotto «oggi comunque non si applica», frase del mattino del 31/08 sotto S:SM. `config/live.json` `_nota_usde` dice ancora «il modello ATTIVO e' Segregated» e giustifica `depeg_crit 0.95` come «meta' del buffer di haircut (10%)»: col 5% il criterio dichiarato (P6) non descrive piu' la soglia, e la nota e' una diagnosi cablata (P4).
- **GATE SCALA-01: data e condizione A2 non concordano** (CLAUDE.md §4). «Non prima del 2026-10-01» sono 30 giorni dal 01/09; A2 chiede k0 «≥180 g col criterio passato ogni giorno», e il criterio lo misura solo `scale_watch`, attivo dal 01/09 (`scale_history.jsonl`). 180 giorni dal 01/09 cadono nel 2027; dal 20/06 a fine 2026. Da quando si conta va chiarito prima del 01/10, non quel giorno.
- **Sorveglianti di cui il materiale non prova il funzionamento** (P5): `venue_news.jsonl` ha 5 voci tutte `seed: true` viste il 2026-08-31T07:40Z e nulla dopo; `scale_watch_state.json` non ha timestamp (streak 0 puo' essere «non ho girato»); `edge_watch` e' citato a «stato 27/07» (CLAUDE.md §4); `fee_watch` non compare. `monitor_health` copre 8 monitor, nessuno di questi quattro.
- **Data di arming**: CLAUDE.md §0 «LIVE dal 2026-06-20», `config/live.json` «ARMATO 2026-06-23», primo segmento TWR dal 2026-06-23 (diario `2026-09-02-debito-14`). Una delle tre non e' la stessa data.
- **Richieste dell'analista senza risposta**: i 4 fill del 03/09 («dovrebbero essere ri-taglie»), la conferma del versamento 04/09, il SELL $7 del 06/09, il round-trip a $+0,00 con fee 0,0055 del 08/09. Nessuna Nota. Non e' un guasto: e' un canale che non chiude (N9).
- **CLAUDE.md §13 dice 1.011 test**; il diario `2026-09-09c` ne aggiunge 16 (`test_revisione.py`). E `scripts/cron_review.sh`, `scripts/live/revisione.py`, `src/live/revisione.py` sono `??` in `git status` mentre la crontab li chiama lunedi' 06:15 (P16 li fa girare, P11 non li salva).
## Cosa e' maturo da decidere
| gate | data | criterio scritto | stato nel materiale |
|---|---|---|---|
| STATARB | 2026-09-27 (fra 18 g) | soglie 24/07 + diagnostica sempre-short | monitor OK; Sharpe 1,61 al 26/08, nessuna lettura piu' recente. Si legge il 27/09 |
| SCALA-01 | non prima del 2026-10-01 (22 g) | 9 condizioni tutte necessarie | A8/A9 fatti; A2 non maturo e ambiguo (sopra); A3, A6, A7 non risultano fatti |
| USDE-01 | chiuso 28/08 | — | quota 68,79% (09/09) sotto `quota_max_frac` 0,85: dentro la decisione dell'operatore del 30/08 e 06/09 |
| PREVDAY-01 | **senza data** | 10 condizioni, 8/10 | N10: un gate senza data non si chiude |
XSR01 (23/10) e DVOLSPREAD (24/10) cadono oltre 30 giorni. `edge_watch` e' continuo ma l'ultimo stato e' del 27/07: non determinabile se sia ancora 8/8.
Decisioni dell'operatore, oggi: (a) dichiarare il 04/09; (b) dire se il piano €5.000 e' iniziato; (c) definire il cuscino (balance o equity USDC). Nessuna decisione di §3 ha la colonna «cosa la riapre» soddisfatta: `weights_tilt_null` fallisce ancora (26/07, in-sample); il crollo catturato e' a vol bassa, sotto il gate e inverse (diario 09/09 §2).
## Proposte (nessuna azione eseguita)
1. **Agente**: verificare in `usde_watch` il limite di lettura dei trade e come `rendimento()` tratta la finestra del 07/09; rigenerare la stima con i 54 ordini (P2).
2. **Agente**: riallineare `_nota_usde` (modello attivo X:SM, criterio di `depeg_crit` al 5%, `venue_cap_frac` null) e sciogliere la contraddizione di §1 datando le due frasi (M28).
3. **Agente + operatore**: il sorvegliante del cuscino e' gia' descritto in §5.17 (`USDC cuscino_richiesto` derivato da config, P1); manca solo la scelta balance/equity.
4. **Agente**: uno stato senza timestamp non distingue «zero» da «non ho girato» (P5): `scale_watch_state.json` e `venue_news` meritano l'ora dell'ultimo giro, e i quattro sorveglianti fuori da `monitor_health` una riga li'.
5. **Agente**: committare i file della revisione, aggiornare il conteggio test.
6. **Operatore**: dare una data a PREVDAY-01 o chiuderlo (N10).
7. Nessuna idea di strategia: la settimana ha gia' misurato crolli, opzioni e XRP contro `20-ondate` (diario 09/09 §0) e non ho nulla che batta i motivi elencati li'.
## Cosa ho letto e cosa mi mancava
Letto: CLAUDE.md intero; `config/live.json`; crontab; git log dal 26/08 e status; `monitor_health`; stati di scale_watch, scale_history, vrp_f_watch, usde_watch, usde_convert, balance_watch, venue_news, movimenti_dichiarati, watermark; giornali 02-08/09; diari 02/09 (tre), 06/09, 09/09 (tre).
Mancava: (1) stato con timestamp di `edge_watch`, `fee_watch`, `venue_watch`, `scale_watch`; (2) l'output di `usde_watch.rendimento()` e `quota_da_decidere()`; (3) la lettura corrente di `paper_statarb` (Sharpe a oggi, non al 26/08), dvolspread, xsr; (4) il contenuto di `cron_daily.sh`, cioe' quali sorveglianti chiama; (5) `venue_news` dopo il 31/08; (6) il diff di CLAUDE.md e memoria 40 non committati; (7) il registro fill della settimana (per i 4 fill del 03/09 e il +0,00 del 08/09); (8) un registro depositi del gateway, che «non espone get_deposits».
---
## Firma e materiale
- revisore: `claude-fable-5-1` · cadenza settimanale · giornale 7 g · diari 7 g · commit 14 g
- scritto da `scripts/live/revisione.py` (cron `scripts/cron_review.sh`); l'unico file scritto e' questo
| voce | caratteri | troncata |
|---|---|---|
| CLAUDE.md (stato, book, numeri, decisioni vincolanti, gate, debiti, regole) | 80,310 | |
| config/live.json (unica autorita' dei guardrail live) | 7,184 | |
| crontab installata (crontab -l) | 1,312 | |
| git log dal 2026-08-26 + stato del working tree | 4,543 | |
| monitor_health (i forward-monitor stanno registrando?) | 522 | |
| stato scale_watch_state.json | 149 | |
| stato scale_history.jsonl (ultime 3) | 259 | |
| stato vrp_f_watch/state.json | 453 | |
| stato usde_watch.jsonl (ultime 3) | 1,339 | |
| stato usde_convert.jsonl (ultime 3) | 9,979 | |
| stato balance_watch.jsonl (ultime 2) | 578 | |
| stato venue_news.jsonl (ultime 5) | 1,564 | |
| stato movimenti_dichiarati.jsonl | 405 | |
| stato equity_seen.json (watermark) | 73 | |
| giornale 2026-09-02 | 3,824 | |
| giornale 2026-09-03 | 3,745 | |
| giornale 2026-09-04 | 3,999 | |
| giornale 2026-09-05 | 3,645 | |
| giornale 2026-09-06 | 3,630 | |
| giornale 2026-09-07 | 3,846 | |
| giornale 2026-09-08 | 3,765 | |
| diario 2026-09-02-debito-14-report-twr.md | 6,952 | |
| diario 2026-09-02b-debito-7-cadenza-docstring.md | 6,061 | |
| diario 2026-09-02c-flag-e-pulizia.md | 5,874 | |
| diario 2026-09-06-usde-quota-topup.md | 6,571 | |
| diario 2026-09-09-crolli-opzioni-monete.md | 17,894 | |
| diario 2026-09-09b-debito-18-cblib-causale.md | 14,150 | |
| diario 2026-09-09c-revisione-settimanale.md | 5,514 | |
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# Sorveglianza ORARIA del cuscino USDC di regolamento (debito §5.17, issue #3) — v2.0.0+.
# Minuto :53 = DOPO il giro del book (:47, i cui fill si regolano in USDC) e fuori dal minuto
# tondo (rate-limit per-IP), dal :25 (catena), dal :35 (usde) e dal :42 (balance).
# PUO' INVIARE ORDINI: sotto zero di slack lancia usde_convert --esegui (vende USDE), con le
# guardie dichiarate nel docstring di scripts/live/cuscino_watch.py; lo interruttore e' lo
# stesso del libro (execution_enabled in config/live.json).
export PATH="/home/adriano/.local/bin:$PATH"
cd /opt/docker/PythagorasGoal || exit 1
mkdir -p logs
{
echo "===== $(date -u '+%Y-%m-%dT%H:%M:%SZ') cron_cuscino ====="
uv run python scripts/live/cuscino_watch.py --quiet || true
echo "===== done $(date -u '+%H:%M:%SZ') ====="
} >> logs/cron_cuscino.log 2>&1
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
# Revisione SETTIMANALE in sola lettura (dal 2026-09-09): un revisore diverso dall'autore
# (claude-fable-5-1) rilegge CLAUDE.md, config, crontab, commit, monitor_health, giornale e diari,
# e scrive docs/revisioni/<data>.md + una sintesi Telegram. Propone, non esegue: l'unico file
# scritto e' il rapporto. Cadenza: lunedi' 06:15 UTC — fuori dal minuto :00 (rate-limit) e dallo
# slot di release Deribit (martedi' 09:00), dopo il cron_daily delle 00:30 (giornale di domenica chiuso).
export PATH="/home/adriano/.local/bin:$PATH"
cd /opt/docker/PythagorasGoal || exit 1
mkdir -p logs
{
echo "===== $(date -u '+%Y-%m-%dT%H:%M:%SZ') cron_review ====="
uv run python scripts/live/revisione.py --quiet || true
echo "===== done $(date -u '+%H:%M:%SZ') ====="
} >> logs/cron_review.log 2>&1
+329
View File
@@ -0,0 +1,329 @@
#!/usr/bin/env python
"""cuscino_watch.py — sorveglianza ORARIA del cuscino USDC di regolamento. PUO' INVIARE ORDINI.
PERCHE' ESISTE (debito §5.17, issue #3). Dal 06/09 il 68,7% dell'equity e' USDE. P&L e funding dei
perp USDC-lineari si regolano in USDC: una perdita del libro consuma il cuscino e fa salire la quota
USDE DA SOLA, senza un ordine. `usde_watch` allerta solo su `quota > quota_max_frac` (0,85): a
68,7% lo slack sul cuscino era ~$41-58, e un saldo USDC negativo costa lo 0,05%/GIORNO (18,25%/anno,
4,3x la resa che l'USDE compra). Nessuno guardava la grandezza giusta.
LA MISURA (decisione dell'operatore, 10/09): l'EQUITY USDC del conto balance piu' P&L non
realizzato perche' e' quella che risponde del regolamento; il balance vedrebbe il buco solo a
trade chiuso. Il cuscino richiesto e' DERIVATO da config/live.json e src/live/book.py tramite
`usde.cuscino_richiesto_usd` (n_asset x frac x disaster_sl_pct x equity totale): la stessa formula
che `usde_convert` applica a ogni piano un sorvegliante che ridichiara il proprio bersaglio non
sta controllando niente (P1, 5 occorrenze nel progetto).
QUATTRO STATI SULLO SLACK (= equity USDC cuscino), in frazioni del cuscino lette da config
(`usde.bande_cuscino`: preavviso 0,10 · margine 0,20 · riacquisto 0,40 P5, P1):
* SCOPERTO slack < 0: 🚨 alla transizione, e VENDITA automatica di USDE fino alla quota di
ripristino q* = usde.quota_ripristino() = 1 0,30 × 1,20 = 0,64 (cuscino + 20%).
* PREAVVISO 0 <= slack < 10% del cuscino: alla transizione. Latenza comprata apposta.
* OK 10% <= slack <= 40%: niente.
* ECCEDENTE slack > 40% del cuscino: ACQUISTO automatico di USDE fino allo stesso q* (issue #7,
decisione dell'operatore 10/09: «riportarla a quota in autonomia»). E' cio' che
riporta la quota a bersaglio dopo un bonifico (+0,7 × importo di slack) o dopo un
tratto di guadagni del libro, senza che nessuno se ne ricordi. 📌 alla transizione.
* BLIND conto non leggibile: alla transizione. "Non vedo" non e' "va tutto bene".
ISTERESI: vendita sotto 0, acquisto sopra 0,40, entrambe verso 0,20. Per oscillare lo slack deve
muoversi di ±0,20 × cuscino = ±6% dell'equity, e siccome slack = 0,7·USDC 0,3·USDE, servono
±8,6% di equity USDC (~$380 oggi); un giro costa ~6 bps sull'importo mosso. Soglie DICHIARATE, non ottimizzate (M8). Il vecchio `quota_target` 0,70
lasciava slack ZERO per costruzione: a quella quota ogni ora in perdita avrebbe venduto.
LE GUARDIE DELLA RICONVERSIONE (tutte dichiarate qui):
* `execution_enabled` in config/live.json deve essere true lo stesso interruttore del libro:
disarmare il libro disarma anche questo, senza un secondo posto da ricordare.
* NON si converte sotto `depeg_warn`, in nessun verso: vendere USDE sotto la pari cristallizza
la perdita del collaterale, comprarne mentre scivola e' peggio; li' decide l'operatore.
* al massimo UN tentativo ogni RIPRISTINO_MIN_ORE (6h), in qualunque verso: un tentativo che si
ripete ogni ora e' spam, e un sorvegliante che si ripete si impara a ignorare (P14).
* dopo un tentativo FALLITO da ECCEDENTE si riprova dopo RITENTATIVO_FALLITO_ORE (24h): USDC
fermo non e' un rischio, e un guasto persistente (tetto del venue tornato, book illeggibile)
diventerebbe 4 Telegram al giorno per sempre (revisione fable 10/09). Da SCOPERTO il ritmo
resta 6h: li' il fallimento e' un rischio che costa 0,05%/giorno.
* `usde_convert` porta le proprie guardie (banda di prezzo, book leggibile, tetto HARD sulla
quota): questo script NON le duplica, le eredita lanciando lo script vero (P15: la regola si
prova contro il codice che la esegue).
* `--secco`: niente ordini, niente Telegram, NIENTE riga nel registro: stampa cosa FAREBBE.
* niente lock: un `usde_convert --esegui` lanciato a mano nello stesso minuto del cron (:53)
puo' vendere due volte verso bersagli diversi. Limite dichiarato (D5), non riparato.
TRASPORTO (lezione del debito §5.2): il marcatore "gia' detto" e' l'esito di `notify` scritto nel
record (`allertato`); se l'invio fallisce la transizione si ripete al giro dopo invece di andare
persa per l'episodio intero.
La serie data/live/cuscino_watch.jsonl e' dentro il perimetro di backup ed e' sorvegliata da
monitor_health (un watch fermo = nessuno guarda il cuscino, e il silenzio si legge come zero).
uv run python scripts/live/cuscino_watch.py # un giro: legge, giudica, agisce
uv run python scripts/live/cuscino_watch.py --quiet # stampa solo se cambia qualcosa
uv run python scripts/live/cuscino_watch.py --secco # nessun ordine, nessun Telegram
"""
from __future__ import annotations
import json
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from src.live import usde as U # noqa: E402
from src.live.notifier import notify # noqa: E402
STATE = ROOT / "data" / "live" / "cuscino_watch.jsonl"
CONVERT = ROOT / "scripts" / "live" / "usde_convert.py"
# Le bande (preavviso / margine / riacquisto) NON stanno qui: si leggono da config via
# usde.bande_cuscino() a ogni giro (P1). Il margine e' il DOPPIO del preavviso per costruzione
# dell'ordine imposto (revisione fable 10/09: a margine == preavviso ogni 🚨 era seguito da un ⚠️).
RIPRISTINO_MIN_ORE = 6.0 # un tentativo di conversione (in qualunque verso) ogni 6 ore
RITENTATIVO_FALLITO_ORE = 24.0 # da ECCEDENTE, dopo un fallimento: un tentativo al giorno
TIMEOUT_CONVERT_S = 900 # usde_convert spazia gli ordini di 12s: 60 ordini = 12 min
def leggi(path: Path = STATE) -> list[dict]:
if not path.exists():
return []
out = []
for ln in path.read_text().splitlines():
ln = ln.strip()
if ln:
out.append(json.loads(ln))
return out
def giudica(eq_usdc: float | None, equity_tot: float | None, cuscino: float | None,
bande: dict | None = None) -> dict:
"""PURA. -> stato, slack, soglie in dollari. `None` in ingresso = BLIND. `bande` = le frazioni
del cuscino (default: config, via usde.bande_cuscino)."""
if eq_usdc is None or equity_tot is None or cuscino is None:
return dict(stato="BLIND", slack=None, preavviso_usd=None, riacquisto_usd=None)
try:
b = bande or U.bande_cuscino()
except Exception as e: # noqa: BLE001 — config rotta = "non vedo", non un crash
return dict(stato="BLIND", slack=None, preavviso_usd=None, riacquisto_usd=None,
motivo=f"bande del cuscino non leggibili: {e}")
slack = eq_usdc - cuscino
preavviso, riacquisto = b["preavviso"] * cuscino, b["riacquisto"] * cuscino
if slack < 0:
stato = "SCOPERTO"
elif slack < preavviso:
stato = "PREAVVISO"
elif slack > riacquisto:
stato = "ECCEDENTE"
else:
stato = "OK"
return dict(stato=stato, slack=round(slack, 2), preavviso_usd=round(preavviso, 2),
riacquisto_usd=round(riacquisto, 2))
AZIONE = {"SCOPERTO": "vendita", "ECCEDENTE": "acquisto"} # gli stati che convertono
def transizione(prev: dict | None, stato: str) -> bool:
"""PURA. Si allerta se lo stato e' cambiato, O se l'ultima allerta per questo stato non e'
partita (`allertato` False): un invio fallito non consuma la transizione (debito §5.2)."""
if stato == "OK":
return False
if prev is None or prev.get("stato") != stato:
return True
return not bool(prev.get("allertato"))
def puo_riconvertire(prev_records: list[dict], now_ms: int, execution_enabled: bool,
px: float | None, depeg_warn: float,
min_ore: float = RIPRISTINO_MIN_ORE, stato: str = "SCOPERTO",
dopo_fallito_ore: float = RITENTATIVO_FALLITO_ORE) -> tuple[bool, str]:
"""PURA. Le guardie della conversione automatica. -> (si', motivo se no). ⚠️ il motivo finisce
in un'allerta: niente '<' (Telegram HTML) — l'escape nel sink lo copre, ma qui non serve."""
if not execution_enabled:
return False, "execution_enabled=false in config/live.json (il libro e' disarmato: anche questo)"
if px is None:
return False, "prezzo USDE non leggibile: non si vende al buio (P5)"
if px < depeg_warn:
return False, f"USDE {px:.4f} sotto depeg_warn {depeg_warn}: ne' vendere (cristallizza) ne' comprare (scivola) — decide l'operatore"
# contano solo i tentativi VERI (subprocess lanciato): un record con `tentata=False` — prezzo
# illeggibile, --secco, interruttore spento — non consuma il budget (revisione fable 10/09)
ultimo = [r for r in prev_records if (r.get("riconversione") or {}).get("tentata")]
if ultimo:
ore = (now_ms - int(ultimo[-1]["ts"])) / 3_600_000
fallito = not (ultimo[-1].get("riconversione") or {}).get("ok")
soglia = dopo_fallito_ore if (fallito and stato == "ECCEDENTE") else min_ore
if ore < soglia:
return False, (f"ultimo tentativo {ore:.1f}h fa, sotto le {soglia:.0f}h"
+ (" (era FALLITO: da ECCEDENTE si riprova una volta al giorno)" if soglia == dopo_fallito_ore else "")
+ ": non si insiste")
return True, ""
def riconverti(quota: float, timeout_s: float = TIMEOUT_CONVERT_S) -> dict:
"""Lancia lo script VERO con le sue guardie. Ritorna esito + coda dell'output (P3)."""
cmd = [sys.executable, str(CONVERT), "--quota", f"{quota:.4f}", "--esegui"]
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_s, cwd=str(ROOT))
coda = (r.stdout + r.stderr).strip().splitlines()[-12:]
return dict(rc=r.returncode, ok=r.returncode == 0, coda=coda, cmd=" ".join(cmd[1:]))
except subprocess.TimeoutExpired:
return dict(rc=None, ok=False, coda=[f"timeout dopo {timeout_s:.0f}s"], cmd=" ".join(cmd[1:]))
except Exception as e: # noqa: BLE001 — si registra, non si ingoia
return dict(rc=None, ok=False, coda=[f"{type(e).__name__}: {e}"], cmd=" ".join(cmd[1:]))
def _safe_client():
try:
from src.live.deribit import DeribitRead
return DeribitRead()
except Exception:
return None
def _execution_enabled() -> bool:
try:
return bool(json.loads((ROOT / "config" / "live.json").read_text()).get("execution_enabled"))
except Exception:
return False
def main() -> int:
quiet, secco = "--quiet" in sys.argv, "--secco" in sys.argv
c = U.cfg()
records = leggi()
prev = records[-1] if records else None
now = datetime.now(timezone.utc)
now_ms = int(now.timestamp() * 1000)
client = _safe_client()
eq_usdc = eq_usde = None
motivo_blind = None
if client is None:
motivo_blind = "gateway non raggiungibile"
else:
try:
eq_usdc = float(client.account_summary("USDC")["equity"])
except Exception as e:
motivo_blind = f"conto USDC non leggibile ({type(e).__name__})"
try:
eq_usde = float(client.account_summary("USDE").get("equity") or 0)
except Exception as e:
motivo_blind = motivo_blind or f"conto USDE non leggibile ({type(e).__name__})"
px, px_fonte = U.prezzo(client)
equity_tot = cuscino = come = None
if eq_usdc is not None and eq_usde is not None:
usd, _ = U.valuta(eq_usde, px)
equity_tot = eq_usdc + usd
cuscino, come = U.cuscino_richiesto_usd(equity_tot)
try:
bande = U.bande_cuscino(c)
except Exception as e: # noqa: BLE001 — si registra e si allerta, non si crasha (P5)
bande, motivo_blind = None, f"config usde: {e}"
g = giudica(eq_usdc, equity_tot, cuscino, bande)
stato = g["stato"]
if stato == "BLIND" and g.get("motivo"):
motivo_blind = motivo_blind or g["motivo"]
rec = dict(ts=now_ms, data=now.strftime("%Y-%m-%dT%H:%M:%SZ"), stato=stato,
motivo_blind=motivo_blind, eq_usdc=eq_usdc, eq_usde=eq_usde, px=px,
px_fonte=px_fonte, equity_tot=equity_tot, cuscino=cuscino, cuscino_come=come,
slack=g["slack"], preavviso_usd=g["preavviso_usd"], riacquisto_usd=g["riacquisto_usd"],
bande=bande, allertato=None, allerta_errore=None, riconversione=None, secco=secco)
# --- conversione automatica: SCOPERTO vende, ECCEDENTE compra, stesso bersaglio q* ---
if stato in AZIONE:
ok, perche = puo_riconvertire(records, now_ms, _execution_enabled(), px, c["depeg_warn"], stato=stato)
quota = U.quota_ripristino(bande["margine"])
if not ok:
rec["riconversione"] = dict(tentata=False, verso=AZIONE[stato], quota=quota, motivo=perche)
elif secco:
rec["riconversione"] = dict(tentata=False, verso=AZIONE[stato], quota=quota,
motivo=f"--secco: avrei lanciato usde_convert --quota {quota:.4f} --esegui ({AZIONE[stato]})")
else:
esito = riconverti(quota)
rec["riconversione"] = dict(tentata=True, verso=AZIONE[stato], quota=quota, **esito)
# --- allarmi: alla transizione, marcatore scritto con l'esito dell'invio ---
# ⚠️ Telegram e' in parse_mode HTML: un '<' nudo nel testo fa fallire l'invio (successo al
# primo giro vero, 13:53Z del 10/09: «(< 10% del cuscino)»). Qui non si scrive mai '<'.
def _esito_azione(r: dict) -> str:
if r.get("tentata"):
return (f"{r['verso']} " + ("ESEGUITA" if r.get("ok") else "FALLITA")
+ f" (quota bersaglio {r['quota']:.0%})")
return f"{r.get('verso', 'conversione')} NON tentata: {r.get('motivo')}"
if transizione(prev, stato):
if secco:
rec["allertato"] = False
elif stato == "SCOPERTO":
rec["allertato"] = notify("🚨 CUSCINO USDC SCOPERTO", {
"USDC equity": f"${eq_usdc:,.2f}", "cuscino richiesto": f"${cuscino:,.2f} ({come})",
"slack": f"${g['slack']:+,.2f}", "azione": _esito_azione(rec["riconversione"] or {}),
"nota": "un USDC negativo costa 0,05%/giorno; il libro si regola in USDC"}, tentativi=3)
elif stato == "PREAVVISO":
rec["allertato"] = notify("⚠️ cuscino USDC in esaurimento", {
"USDC equity": f"${eq_usdc:,.2f}", "cuscino richiesto": f"${cuscino:,.2f}",
"slack": f"${g['slack']:+,.2f} (sotto il {bande['preavviso']:.0%} del cuscino)",
"nota": f"sotto zero vende USDE da solo fino a quota {U.quota_ripristino(bande['margine']):.0%}"})
elif stato == "ECCEDENTE":
rec["allertato"] = notify("📌 cuscino USDC eccedente: riacquisto USDE", {
"USDC equity": f"${eq_usdc:,.2f}", "cuscino richiesto": f"${cuscino:,.2f}",
"slack": f"${g['slack']:+,.2f} (sopra il {bande['riacquisto']:.0%} del cuscino)",
"azione": _esito_azione(rec["riconversione"] or {})})
elif stato == "BLIND":
rec["allertato"] = notify("⚠️ cuscino_watch BLIND", {"motivo": motivo_blind or "?",
"nota": "'non vedo' non e' 'va tutto bene' (P5)"})
elif rec["riconversione"] and rec["riconversione"].get("tentata") and not secco:
# non e' una transizione ma e' un ordine mandato: si dice sempre
r = rec["riconversione"]
rec["allertato"] = notify(f"📌 cuscino USDC: {r['verso']} " + ("eseguita" if r["ok"] else "FALLITA"),
{"quota bersaglio": f"{r['quota']:.0%}", "esito": " | ".join(r["coda"][-3:])})
if rec["allertato"] is False and not secco:
from src.live.notifier import ultimo_errore
rec["allerta_errore"] = ultimo_errore() # P3: si registra dove si ingoia
# --secco e' secco ANCHE sul registro: un giro a mano non deve diventare il `prev` del cron,
# ne' far sembrare vivo un monitor fermo (revisione fable 10/09)
if not secco:
STATE.parent.mkdir(parents=True, exist_ok=True)
with STATE.open("a") as fh:
fh.write(json.dumps(rec) + "\n")
cambiato = prev is None or prev.get("stato") != stato or rec["riconversione"] is not None
if not quiet or cambiato:
print("=" * 78)
print(f" CUSCINO WATCH — {rec['data']} {'(SECCO)' if secco else ''}")
print("=" * 78)
if stato == "BLIND":
print(f" stato : BLIND — {motivo_blind}")
else:
print(f" conto : USDC equity ${eq_usdc:,.2f} + USDE {eq_usde:,.2f} @ {px if px else 'n/d'}"
f" ({px_fonte}) = ${equity_tot:,.2f}")
print(f" cuscino : ${cuscino:,.2f} richiesti ({come})")
print(f" slack : ${g['slack']:+,.2f} (preavviso sotto ${g['preavviso_usd']:,.2f},"
f" riacquisto sopra ${g['riacquisto_usd']:,.2f}, bersaglio quota {U.quota_ripristino(bande['margine']):.0%})")
print(f" stato : {stato}")
if rec["riconversione"]:
r = rec["riconversione"]
print(f" {r.get('verso', 'conversione'):<12}: {'TENTATA' if r.get('tentata') else 'NON tentata'} — quota {r['quota']:.4f}"
+ (f"{r.get('motivo')}" if r.get("motivo") else ""))
for ln in r.get("coda", []):
print(f" {ln}")
if rec["allertato"] is not None:
print(f" allerta : {'inviata' if rec['allertato'] else 'NON inviata (si ripete al giro dopo)'}"
+ (f"{rec['allerta_errore']}" if rec.get("allerta_errore") else ""))
return 0
USO = """uso: cuscino_watch.py [--quiet] [--secco]
cuscino_watch.py sorveglianza ORARIA del cuscino USDC; sotto zero vende USDE, sopra la banda ricompra.
uv run python scripts/live/cuscino_watch.py # un giro: legge, giudica, agisce
uv run python scripts/live/cuscino_watch.py --quiet # stampa solo se cambia qualcosa
uv run python scripts/live/cuscino_watch.py --secco # nessun ordine, nessun Telegram
Dettaglio nel docstring in testa al file."""
if __name__ == "__main__":
from src.live.cli import valida
valida("cuscino_watch.py", USO, flag=("--quiet", "--secco"), con_valore=())
sys.exit(main())
+163 -1
View File
@@ -53,6 +53,21 @@ MODELED_CAPITAL = 2000.0 # nominale, ribilanciamento continuo
REAL_CAPITAL = 600.0 # capitale mainnet reale REAL_CAPITAL = 600.0 # capitale mainnet reale
MIN_ORDER = 5.0 # min order Deribit -> sotto, il conto vero NON ribilancia MIN_ORDER = 5.0 # min order Deribit -> sotto, il conto vero NON ribilancia
# --- GATE PRE-REGISTRATO `GATE PREVDAY-01` (2026-08-23, RESULTS-0822 §54) -----------------------
# Cablato qui il 2026-09-10 (issue #4): esisteva solo nel testo, e la revisione del 09/09 lo ha
# letto come "gate senza data". Decisione dell'operatore: tenere la data, cablare kill e veto.
GATE_DATE = "2027-06-21" # decisione piena: 7 condizioni (a)-(g), tutte necessarie
KILL_SHARPE = -0.50 # kill: Sharpe forward < -0,50 ...
KILL_MIN_ACTIVE_DAYS = 180 # ... su >= 180 giorni di barre ATTIVE (posizione != 0)
MIN_RECON_FRAC = 0.80 # veto d'integrita': >= 80% di barre ricostruibili dal feed
RECON_TOL = 1.5e-6 # net_modeled e' registrato a 6 decimali: mezzo ulp di tolleranza
RECENTI_GIORNI = 30 # "i minuti non registrati non devono crescere": ultimi 30g vs tutto
MIN_DIV_CRESCITA = 5 # ... ma UNA barra divergente su 1943 non e' una crescita (P14): la
# crescita richiede >= 5 divergenti recenti E tasso doppio del totale
# LENTE: il kill si misura sullo Sharpe GIORNALIERO (somma delle barre orarie per giorno UTC),
# la lente con cui e' misurato ogni altro sleeve del progetto — §54: sulla lente ORARIA lo stesso
# forward vale +2,04 contro +1,56 giornaliero. Al kill la famiglia NON si ri-ottimizza.
def build_bars() -> dict[str, pd.DataFrame]: def build_bars() -> dict[str, pd.DataFrame]:
return {a: load(a, "1h").reset_index(drop=True) for a in ASSETS} return {a: load(a, "1h").reset_index(drop=True) for a in ASSETS}
@@ -145,6 +160,151 @@ def advance(st: dict, dfs: dict) -> dict:
return st return st
def _returns_df() -> pd.DataFrame | None:
if not RETURNS_FILE.exists():
return None
r = pd.read_json(RETURNS_FILE, lines=True)
if r.empty:
return None
r["dt"] = pd.to_datetime(r["ts"], unit="ms", utc=True)
return r
def sharpe_giornaliero(r: pd.DataFrame, col: str = "net_modeled") -> tuple[float, int]:
"""PURA. Sharpe annualizzato sui rendimenti GIORNALIERI (somma delle barre orarie per giorno
UTC) -> (sharpe, n_giorni). nan sotto 30 giorni o a varianza nulla."""
d = r.groupby(r["dt"].dt.floor("D"))[col].sum()
if len(d) < 30 or d.std() == 0:
return float("nan"), int(len(d))
return float(d.mean() / d.std() * np.sqrt(365.25)), int(len(d))
def giorni_attivi(r: pd.DataFrame) -> int:
"""PURA. Giorni UTC con almeno una barra a posizione != 0 (il kill conta le barre ATTIVE).
le posizioni registrate sono quelle del libro REAL-$600 (advance scrive `pr`), lo Sharpe
del kill e' MODELED: divergono solo se un ingresso da 0 vale < $5 di nozionale, cosa che a
vol-target 20% non succede (10/09: 81/81 giorni). Dichiarato, non riparato (D5)."""
att = r[(r["pos_btc"] != 0) | (r["pos_eth"] != 0)]
return int(att["dt"].dt.floor("D").nunique())
def kill_check(sharpe: float, n_attivi: int, today: pd.Timestamp) -> tuple[str, str]:
"""PURA. -> (stato, motivo). KILL solo con >= 180 giorni attivi E Sharpe < -0,50 (§54):
a quell'orizzonte P(uccidere un edge vivo a Sharpe 1,2) = 9,7%."""
if n_attivi < KILL_MIN_ACTIVE_DAYS:
return "NON_MATURO", (f"{n_attivi} giorni attivi < {KILL_MIN_ACTIVE_DAYS}: il kill non e' "
f"leggibile (mancano {KILL_MIN_ACTIVE_DAYS - n_attivi} giorni)")
if not np.isfinite(sharpe):
return "NON_MISURABILE", "Sharpe forward non misurabile"
if sharpe < KILL_SHARPE:
return "KILL", f"Sharpe {sharpe:+.2f} < {KILL_SHARPE:+.2f} su {n_attivi} giorni attivi -> RITIRARE, senza ri-ottimizzare"
return "SUPERATO", f"Sharpe {sharpe:+.2f} >= {KILL_SHARPE:+.2f} su {n_attivi} giorni attivi"
def ricostruzione(r: pd.DataFrame, dfs: dict, start_ts: int) -> dict:
"""Rigioca la strategia CONGELATA sul feed di OGGI dallo `start_ts` del monitor e confronta
barra per barra `net_modeled` registrato con quello ricalcolato (ribilanciamento continuo,
la stessa aritmetica di `advance`). Una barra e' RICOSTRUIBILE se coincide entro RECON_TOL.
Cosa misura: quante barre registrate sono riproducibili dal dato certificato. Le divergenti
sono barre calcolate su un feed che il rebuild ha poi rivisto (§54: 67 su 1512, tutte
all'ora del cron) — e' la misura del veto d'integrita' (>= 80%, e i minuti persi non devono
crescere: qui, divergenti/giorno negli ultimi 30g contro l'intera finestra)."""
data = {}
for a in ASSETS:
df = dfs[a]
c = df["close"].values.astype(float)
rr = np.zeros(len(c)); rr[1:] = c[1:] / c[:-1] - 1.0
data[a] = dict(ts=df["timestamp"].values.astype("int64"), r=rr, tgt=prevday_target(df))
idx = {a: {int(t): i for i, t in enumerate(data[a]["ts"])} for a in ASSETS}
# posizione iniziale come init_state: target all'ultima barra <= start_ts
pos = {}
for a in ASSETS:
i0 = max(i for t, i in idx[a].items() if t <= start_ts)
pos[a] = float(data[a]["tgt"][i0])
ricalc, presenti = [], []
for t in r["ts"].astype("int64"):
t = int(t)
if any(t not in idx[a] for a in ASSETS):
ricalc.append(np.nan); presenti.append(False); continue
net = 0.0
for a in ASSETS:
i = idx[a][t]
tgt = float(data[a]["tgt"][i])
net += WEIGHT * (pos[a] * float(data[a]["r"][i]) - FEE_SIDE * abs(tgt - pos[a]))
pos[a] = tgt
ricalc.append(net); presenti.append(True)
ricalc = np.asarray(ricalc, dtype=float)
reg = r["net_modeled"].values.astype(float)
ok = np.isfinite(ricalc) & (np.abs(ricalc - reg) <= RECON_TOL)
n = len(reg)
giorni = r["dt"].dt.floor("D")
span_g = max(1, int(giorni.nunique()))
recenti = r["dt"] >= (r["dt"].max() - pd.Timedelta(days=RECENTI_GIORNI))
div_rec = int((~ok[recenti.values]).sum())
g_rec = max(1, int(giorni[recenti].nunique()))
return dict(n=n, ricostruibili=int(ok.sum()), frac=float(ok.sum() / n) if n else float("nan"),
assenti_nel_feed=int((~np.asarray(presenti)).sum()),
div_per_giorno=float((n - ok.sum()) / span_g),
div_recenti=div_rec, div_per_giorno_recenti=float(div_rec / g_rec),
divergenti_ts=[int(t) for t in r["ts"].values[~ok]][:20])
def veto_check(frac: float, div_g_tot: float, div_g_rec: float, div_rec: int) -> tuple[bool, str]:
"""PURA. Veto d'integrita' (blocca, non decide): sotto l'80% di barre ricostruibili, o con le
divergenze che CRESCONO negli ultimi 30 giorni, la finestra si ESTENDE invece di decidere."""
motivi = []
if not np.isfinite(frac) or frac < MIN_RECON_FRAC:
motivi.append(f"barre ricostruibili {frac:.1%} < {MIN_RECON_FRAC:.0%}")
if div_rec >= MIN_DIV_CRESCITA and div_g_rec > 2.0 * div_g_tot + 1e-12:
motivi.append(f"divergenze in crescita: {div_rec} negli ultimi {RECENTI_GIORNI}g "
f"({div_g_rec:.2f}/g contro {div_g_tot:.2f}/g sull'intera finestra)")
return (not motivi), ("; ".join(motivi) if motivi else
f"ricostruibili {frac:.1%}, divergenti {div_rec} negli ultimi {RECENTI_GIORNI}g "
f"({div_g_rec:.2f}/g contro {div_g_tot:.2f}/g totale)")
def print_gate(st: dict, dfs: dict, today: pd.Timestamp | None = None) -> dict:
"""Stampa lo stato del gate pre-registrato e lo RITORNA (per i test e per chi legge il log).
Non decide niente: al kill e alla data stampa cosa la regola dice, in maiuscolo."""
r = _returns_df()
out = dict(gate_date=GATE_DATE, kill="NON_MISURABILE", veto_ok=None)
print(f"\n GATE PRE-REGISTRATO `PREVDAY-01` (23/08, RESULTS-0822 §54) — decisione {GATE_DATE}")
if r is None or len(r) < 2:
print(" serie forward assente o troppo corta: niente da misurare")
return out
today = today or pd.Timestamp.now(tz="UTC").normalize()
sh, n_g = sharpe_giornaliero(r)
n_att = giorni_attivi(r)
stato, motivo = kill_check(sh, n_att, today)
out.update(sharpe_giornaliero=sh, n_giorni=n_g, n_attivi=n_att, kill=stato, kill_motivo=motivo)
print(f" Sharpe forward GIORNALIERO (MODELED): {sh:+.2f} su {n_g} giorni, {n_att} attivi"
f" [lente oraria {_sharpe_orario(r):+.2f}: non si cita, §54]")
if stato == "KILL":
print(f" *** KILL: {motivo} ***")
else:
print(f" kill (Sharpe < {KILL_SHARPE:+.2f} su >= {KILL_MIN_ACTIVE_DAYS}g attivi): {stato}{motivo}")
rc = ricostruzione(r, dfs, int(st["start_ts"]))
ok, vm = veto_check(rc["frac"], rc["div_per_giorno"], rc["div_per_giorno_recenti"], rc["div_recenti"])
out.update(ricostruzione=rc, veto_ok=ok, veto_motivo=vm)
print(f" veto d'integrita': {'ok' if ok else '*** VETO — la finestra si ESTENDE ***'}{vm}"
f" ({rc['ricostruibili']}/{rc['n']} barre, {rc['assenti_nel_feed']} assenti nel feed)")
if today >= pd.Timestamp(GATE_DATE, tz="UTC"):
print(f" *** DECISIONE DOVUTA ({GATE_DATE}): (a) DSR >= 0,95 sulla famiglia dichiarata "
f"(b) cella al buio == congelata (c) delta libro > +0,05 col funding (d) weights_tilt_null "
f"(e) ADDS+robust_oos+non-hedge sulla cella al buio (f) day_boundary_robust "
f"(g) Sharpe forward > 0 [{sh:+.2f}] — TUTTE necessarie ***")
else:
print(f" decisione fra {(pd.Timestamp(GATE_DATE, tz='UTC') - today).days} giorni; "
f"le condizioni (a)-(b) sono STRUTTURALI e il forward non le cambia (§54)")
return out
def _sharpe_orario(r: pd.DataFrame) -> float:
x = r["net_modeled"].astype(float)
return float(x.mean() / x.std() * np.sqrt(24 * 365.25)) if x.std() > 0 else float("nan")
def print_status(st: dict, dfs: dict): def print_status(st: dict, dfs: dict):
days = (max(int(dfs[a]["timestamp"].iloc[-1]) for a in ASSETS) - st["start_ts"]) / 86400_000 days = (max(int(dfs[a]["timestamp"].iloc[-1]) for a in ASSETS) - st["start_ts"]) / 86400_000
rm = st["cap_modeled"] / MODELED_CAPITAL - 1 rm = st["cap_modeled"] / MODELED_CAPITAL - 1
@@ -156,7 +316,9 @@ def print_status(st: dict, dfs: dict):
print(f" MODELED ($2000 nominale): {rm*100:+6.2f}% eq ${st['cap_modeled']:.2f} maxDD {st['dd_modeled']*100:.1f}%") print(f" MODELED ($2000 nominale): {rm*100:+6.2f}% eq ${st['cap_modeled']:.2f} maxDD {st['dd_modeled']*100:.1f}%")
print(f" REAL-$600 (min-order $5) : {rr*100:+6.2f}% eq ${st['cap_real']:.2f} maxDD {st['dd_real']*100:.1f}%") print(f" REAL-$600 (min-order $5) : {rr*100:+6.2f}% eq ${st['cap_real']:.2f} maxDD {st['dd_real']*100:.1f}%")
print(f" -> fill-haircut MODELED-REAL: {(rm-rr)*100:+.2f} pp (lo scettico l'aveva segnalato)") print(f" -> fill-haircut MODELED-REAL: {(rm-rr)*100:+.2f} pp (lo scettico l'aveva segnalato)")
print(f" log: {RETURNS_FILE}\n") print(f" log: {RETURNS_FILE}")
print_gate(st, dfs)
print()
def main(): def main():
+48
View File
@@ -0,0 +1,48 @@
"""Revisione settimanale in SOLA LETTURA: un modello DIVERSO dall'autore rilegge il sistema.
uv run python scripts/live/revisione.py # chiama il revisore, scrive docs/revisioni/<data>.md, Telegram
uv run python scripts/live/revisione.py --secco # solo la taglia del materiale: niente modello, niente file
uv run python scripts/live/revisione.py --quiet # per il cron (scripts/cron_review.sh, lunedi' 06:15 UTC)
Cosa scrive: SOLO il rapporto. Cosa non tocca: config, libro, cron, codice, dati. Le proposte
restano proposte; decide l'operatore. Dettagli e ragioni in `src/live/revisione.py`.
"""
from __future__ import annotations
import sys
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
USO = """uso: revisione.py [--giorno AAAA-MM-GG] [--modello NOME] [--secco] [--no-telegram] [--quiet]
--giorno G data della revisione (default: adesso, UTC)
--modello M revisore (default: src.live.revisione.MODELLO = claude-fable-5-1 NON il modello che scrive il codice)
--secco stampa la taglia del materiale e si ferma: niente modello, niente file, niente Telegram
--no-telegram scrive il rapporto ma non manda la sintesi
--quiet stampa solo l'esito
SPENDE una chiamata al modello e MANDA un messaggio Telegram, salvo --secco/--no-telegram.
Scrive un solo file: docs/revisioni/<data>.md."""
def _arg(flag: str, default=None):
a = sys.argv[1:]
return a[a.index(flag) + 1] if flag in a and a.index(flag) + 1 < len(a) else default
if __name__ == "__main__":
from src.live.cli import valida
valida("revisione.py", USO, flag=("--secco", "--no-telegram", "--quiet"),
con_valore=("--giorno", "--modello"))
from src.live import revisione as R
g = _arg("--giorno")
adesso = (datetime.fromisoformat(g).replace(tzinfo=timezone.utc) if g
else datetime.now(timezone.utc))
esito = R.scrivi(ROOT, adesso, modello=_arg("--modello", R.MODELLO),
secco="--secco" in sys.argv, telegram="--no-telegram" not in sys.argv,
quiet="--quiet" in sys.argv)
if "--quiet" in sys.argv and not esito.get("secco"):
print(f"revisione {esito['data']}: {esito.get('stato')}"
+ (f"{esito.get('errore')}" if esito.get("errore") else "") + f" {esito.get('file')}")
raise SystemExit(0 if esito.get("secco") or esito.get("stato") in ("ok", "sospetta") else 1)
+10 -3
View File
@@ -92,14 +92,21 @@ def report() -> None:
f" (restano nel P&L, dichiarati)") f" (restano nel P&L, dichiarati)")
for ev in r["eventi"]: for ev in r["eventi"]:
mk = ev.get("mercato_max_pct") mk = ev.get("mercato_max_pct")
dettaglio = (ev.get("mercato_nota") or "mercato non misurabile") if mk is None \ if ev.get("dichiarazione"):
else f"mercato max {100 * mk:.2f}% a leva piena, salto {100 * ev['pct']:+.1f}%" dettaglio = ev["dichiarazione"]
elif mk is None:
dettaglio = ev.get("mercato_nota") or "mercato non misurabile"
else:
dettaglio = f"mercato max {100 * mk:.2f}% a leva piena, salto {100 * ev['pct']:+.1f}%"
print(f" {ev['ts_dopo'][:16]} {ev['delta']:+,.2f} [{ev['classe']}: {dettaglio}]") print(f" {ev['ts_dopo'][:16]} {ev['delta']:+,.2f} [{ev['classe']}: {dettaglio}]")
for av in r.get("avvisi") or []:
print(f" ⚠️ {av}")
if r["trading"] is None: if r["trading"] is None:
print(f" trading da arming : n/d ({r['motivo']})") print(f" trading da arming : n/d ({r['motivo']})")
else: else:
print(f" trading da arming : {r['trading']:+,.2f} (equity al netto dei movimenti " print(f" trading da arming : {r['trading']:+,.2f} (equity al netto dei movimenti "
f"certi; l'ora del movimento esce intera, vedi journal.rendimento_twr)") f"certi; l'ora di un movimento RILEVATO esce intera, quella di un DICHIARATO "
f"resta — vedi journal.rendimento_twr)")
if r["twr"] is None: if r["twr"] is None:
print(f" TWR : n/d ({r['motivo']})") print(f" TWR : n/d ({r['motivo']})")
else: else:
+9 -12
View File
@@ -59,8 +59,8 @@ la size. Percio' si SPAZIANO gli ordini (`--pausa`) e si fa backoff sul TEMPO, r
solo dopo rifiuti ripetuti. Dimezzare la size a ogni rifiuto, come faceva la prima versione, e' la solo dopo rifiuti ripetuti. Dimezzare la size a ogni rifiuto, come faceva la prima versione, e' la
reazione sbagliata al sintomo giusto: consuma i tentativi senza toccare la causa. reazione sbagliata al sintomo giusto: consuma i tentativi senza toccare la causa.
uv run python scripts/live/usde_convert.py --quota 0.70 # piano, nessun ordine uv run python scripts/live/usde_convert.py --quota 0.64 # piano, nessun ordine (0,64 = bersaglio di cuscino_watch)
uv run python scripts/live/usde_convert.py --quota 0.70 --esegui # manda l'ordine uv run python scripts/live/usde_convert.py --quota 0.64 --esegui # manda l'ordine
""" """
from __future__ import annotations from __future__ import annotations
@@ -88,15 +88,10 @@ EPS_CUSCINO = 0.01 # 1 centesimo: il criterio e' ">= cuscino", e
# questo epsilon il rumore float boccia il caso di pareggio. # questo epsilon il rumore float boccia il caso di pareggio.
def cuscino_richiesto_usd(equity_tot: float) -> tuple[float, str]: # Il cuscino di regolamento vive in src/live/usde.py dal 10/09 (issue #3): cuscino_watch lo
"""Cuscino USDC di REGOLAMENTO = disaster-SL sulla massima esposizione lorda del libro. # sorveglia ogni ora e questo script lo rispetta a ogni piano — due lettori, UNA formula (P1).
Derivato da config/live.json + src/live/book.py, mai ridichiarato qui.""" # L'alias resta per r0906_usde_tetto_sonda, che lo importa da qui.
cfg = json.loads((ROOT / "config" / "live.json").read_text()) cuscino_richiesto_usd = U.cuscino_richiesto_usd
sl = float(cfg["disaster_sl_pct"])
from src.live import book as B
frac, n_asset = float(B.WEIGHT), 2
lordo_max = equity_tot * frac * n_asset
return lordo_max * sl, f"{n_asset} asset x frac {frac} x SL {sl:.0%} su ${equity_tot:,.2f}"
def book_top(timeout: float = 5.0) -> tuple[float | None, float | None]: def book_top(timeout: float = 5.0) -> tuple[float | None, float | None]:
@@ -165,7 +160,9 @@ def piano(eq_usdc: float, eq_usde: float, px: float, quota_target: float,
motivi.append(f"gia' a bersaglio ({quota_ora:.1%} vs {quota_target:.0%}, toll {TOLL:.1%})") motivi.append(f"gia' a bersaglio ({quota_ora:.1%} vs {quota_target:.0%}, toll {TOLL:.1%})")
if abs(delta_usd) < MIN_ORDER_USD: if abs(delta_usd) < MIN_ORDER_USD:
motivi.append(f"delta ${abs(delta_usd):,.2f} sotto il minimo ${MIN_ORDER_USD:,.2f}") motivi.append(f"delta ${abs(delta_usd):,.2f} sotto il minimo ${MIN_ORDER_USD:,.2f}")
if cap_usd is not None and val_target > cap_usd + EPS_CUSCINO: # il tetto del venue limita l'ACQUISTO di USDE: una VENDITA va sempre verso il tetto, e bloccarla
# renderebbe morta la riconversione automatica di cuscino_watch (revisione fable 10/09)
if side == "buy" and cap_usd is not None and val_target > cap_usd + EPS_CUSCINO:
motivi.append( motivi.append(
f"TETTO DEL VENUE: bersaglio ${val_target:,.2f} sopra ${cap_usd:,.2f} " f"TETTO DEL VENUE: bersaglio ${val_target:,.2f} sopra ${cap_usd:,.2f} "
f"({cap_frac:.1%} dell'equity, misurato {cfg.get('venue_cap_misurato')}). " f"({cap_frac:.1%} dell'equity, misurato {cfg.get('venue_cap_misurato')}). "
+54 -11
View File
@@ -66,6 +66,25 @@ VERDETTO_DOPO = "2026-08-29T12:30:00Z" # ultima finestra reward del 29/08 conc
DECISIONE_QUOTA_DAL = "2026-08-31" # lunedi': si decide la quota DECISIONE_QUOTA_DAL = "2026-08-31" # lunedi': si decide la quota
EPS_GIORNI = 0.5 # sotto mezza giornata l'APR non si annualizza EPS_GIORNI = 0.5 # sotto mezza giornata l'APR non si annualizza
# --- lettura dei trade spot: i DUE limiti del canale, misurati il 2026-09-10 -----------------
# (1) `count` massimo di Deribit per get_user_trades_by_instrument: 1000. Il 07/09 il lettore
# chiedeva limit=50 e la sonda del 06/09 aveva fatto 54 ordini: i 4 non letti (400 USDE)
# sono finiti nel «reward» (issue #2). Se il venue restituisce ESATTAMENTE `limit` righe la
# lista puo' essere troncata e la somma NON e' leggibile (P12: non si attribuisce).
# (2) il gateway chiama l'endpoint senza `historical`/timestamp: Deribit restituisce solo le
# ultime 24h — DOCUMENTATO ("Accessing historical trades and orders using API": recent
# trades 24h, orders 30 min, count max 1000) e misurato (il fill 08/09 06:47Z invisibile
# alle 12:55Z del 10/09, quello del 09/09 21:47Z visibile). Se l'ultima lettura e' piu'
# vecchia della finestra, i trade fra la lettura e il bordo sono INVISIBILI e la somma non
# e' leggibile.
# ⚠️ BUCO DICHIARATO (D5): il cron gira ogni 24h + pochi secondi; con la tolleranza di 5 min
# i trade nei primi ≤5 min dopo la lettura precedente sarebbero invisibili e sommati come
# «leggibile». Innocuo: nessun attrezzo converte USDE alle 12:35 UTC, e senza tolleranza il
# giro quotidiano sarebbe SEMPRE «non leggibile» e il tasso non avrebbe mai finestre nuove.
TRADE_LIMIT = 1000
FINESTRA_GATEWAY_MS = 24 * 3_600_000
TOLL_FINESTRA_MS = 5 * 60_000
def _parse(ts: str) -> datetime: def _parse(ts: str) -> datetime:
return datetime.fromisoformat(ts.replace("Z", "+00:00")) return datetime.fromisoformat(ts.replace("Z", "+00:00"))
@@ -177,14 +196,36 @@ def _safe_client():
return None return None
def _trades_spot_da(client, ts_ms: int) -> tuple[float | None, int | None]: def trade_copertura(n_righe: int, prev_ts_ms: int, now_ms: int,
"""Somma con segno (buy +, sell -) dell'USDE scambiato spot dopo ts_ms. None = non leggibile.""" limit: int = TRADE_LIMIT, finestra_ms: int = FINESTRA_GATEWAY_MS,
toll_ms: int = TOLL_FINESTRA_MS) -> str | None:
"""PURA. None se la lista di trade COPRE l'intervallo (prev, now]; altrimenti il motivo per cui
NON lo copre e allora la somma non si usa (P12: un reward calcolato su una lista incompleta
e' un'invenzione, il 07/09 valeva 400 USDE)."""
if n_righe >= limit:
return f"lista troncata: {n_righe} righe = limite {limit} del venue"
if now_ms - prev_ts_ms > finestra_ms + toll_ms:
ore = (now_ms - prev_ts_ms) / 3_600_000
return (f"ultima lettura {ore:.1f}h fa, oltre la finestra di {finestra_ms / 3_600_000:.0f}h "
f"del gateway: i trade piu' vecchi sono invisibili")
return None
def _trades_spot_da(client, ts_ms: int, now_ms: int | None = None
) -> tuple[float | None, int | None, str | None]:
"""Somma con segno (buy +, sell -) dell'USDE scambiato spot dopo ts_ms.
-> (somma, n, motivo). somma None = non leggibile, e `motivo` dice perche' (P4)."""
if client is None: if client is None:
return None, None return None, None, "gateway non raggiungibile"
try: try:
rows = client.trade_history(U.SPOT, limit=50) rows = client.trade_history(U.SPOT, limit=TRADE_LIMIT)
except Exception: except Exception as e:
return None, None return None, None, f"trade_history: {type(e).__name__}"
if now_ms is None:
now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
motivo = trade_copertura(len(rows), ts_ms, now_ms)
if motivo is not None:
return None, None, motivo
tot, n = 0.0, 0 tot, n = 0.0, 0
for t in rows: for t in rows:
if int(t.get("timestamp") or 0) <= ts_ms: if int(t.get("timestamp") or 0) <= ts_ms:
@@ -192,7 +233,7 @@ def _trades_spot_da(client, ts_ms: int) -> tuple[float | None, int | None]:
amt = float(t.get("amount") or 0) amt = float(t.get("amount") or 0)
tot += amt if (t.get("direction") or "").lower() == "buy" else -amt tot += amt if (t.get("direction") or "").lower() == "buy" else -amt
n += 1 n += 1
return round(tot, 8), n return round(tot, 8), n, None
def main() -> int: def main() -> int:
@@ -221,10 +262,11 @@ def main() -> int:
if eq_usde is not None: if eq_usde is not None:
usd, _nota = U.valuta(eq_usde, px) usd, _nota = U.valuta(eq_usde, px)
trades_usde, n_trades = (None, None) trades_usde, n_trades, trades_motivo = (None, None, None)
an = dict(delta=None, reward_stimato=None, reward_rilevato=False, con_trade=None) an = dict(delta=None, reward_stimato=None, reward_rilevato=False, con_trade=None)
if stato == "OK" and prev is not None and prev.get("eq_usde") is not None: if stato == "OK" and prev is not None and prev.get("eq_usde") is not None:
trades_usde, n_trades = _trades_spot_da(client, int(prev["ts"])) trades_usde, n_trades, trades_motivo = _trades_spot_da(
client, int(prev["ts"]), int(now.timestamp() * 1000))
an = analizza(prev, eq_usde, trades_usde) an = analizza(prev, eq_usde, trades_usde)
quota = None quota = None
@@ -237,7 +279,7 @@ def main() -> int:
stato=stato, motivo_blind=motivo_blind, stato=stato, motivo_blind=motivo_blind,
eq_usde=eq_usde, px=px, px_fonte=px_fonte, usd=usd, eq_usde=eq_usde, px=px, px_fonte=px_fonte, usd=usd,
eq_usdc=eq_usdc, quota=quota, eq_usdc=eq_usdc, quota=quota,
trades_usde=trades_usde, n_trades=n_trades, **an, trades_usde=trades_usde, n_trades=n_trades, trades_motivo=trades_motivo, **an,
) )
v, v_motivo = verdetto(records + [rec], now) v, v_motivo = verdetto(records + [rec], now)
rec["verdetto"], rec["verdetto_motivo"] = v, v_motivo rec["verdetto"], rec["verdetto_motivo"] = v, v_motivo
@@ -312,7 +354,8 @@ def main() -> int:
f" fa margine). Con cross X:SM sarebbero ~${cross:,.0f} (haircut" f" fa margine). Con cross X:SM sarebbero ~${cross:,.0f} (haircut"
f" {c['haircut']:.0%})") f" {c['haircut']:.0%})")
if an["delta"] is not None: if an["delta"] is not None:
attr = ("non attribuibile (trade non leggibili)" if an["reward_stimato"] is None attr = (f"non attribuibile (trade non leggibili: {trades_motivo})"
if an["reward_stimato"] is None
else f"reward stimato {an['reward_stimato']:+.6f} USDE" else f"reward stimato {an['reward_stimato']:+.6f} USDE"
+ (" [con trade nel mezzo]" if an["con_trade"] else "")) + (" [con trade nel mezzo]" if an["con_trade"] else ""))
print(f" delta : {an['delta']:+.6f} USDE dall'ultima lettura -> {attr}") print(f" delta : {an['delta']:+.6f} USDE dall'ultima lettura -> {attr}")
+3
View File
@@ -9,6 +9,9 @@ lontana, che il 30/07 aveva misurato sottoprezzata ~2.3x.
piu' mispriced in RELATIVO (f_long 5.85 contro 2.23) ma pesa il **3.2%** del premio corto invece piu' mispriced in RELATIVO (f_long 5.85 contro 2.23) ma pesa il **3.2%** del premio corto invece
del **18.4%**, quindi sul credito NETTO l'effetto e' minore: f_net **0.852** contro **0.718**. del **18.4%**, quindi sul credito NETTO l'effetto e' minore: f_net **0.852** contro **0.718**.
Meccanismo giusto, segno della conclusione sbagliato. Meccanismo giusto, segno della conclusione sbagliato.
(Numeri del 30/07, misurati con `cblib` che leggeva spot e DVOL di UN'ORA / fino a 24 ORE dopo
l'ingresso — debito §5.18, riparato la sera del 09/09: da allora la tabella stampata da questo
script e' la fonte, e il f canonico e' passato da 0,732 a 0,706 sulle stesse 28 coppie.)
E' pero' una misura su **16 coppie**: la differenza appaiata e' **+0.109 con IC95 E' pero' una misura su **16 coppie**: la differenza appaiata e' **+0.109 con IC95
[-0.047, +0.193]**, cioe' **compatibile con zero**. Serve piu' campione, e "quando ce ne sara' [-0.047, +0.193]**, cioe' **compatibile con zero**. Serve piu' campione, e "quando ce ne sara'
+31 -2
View File
@@ -13,6 +13,10 @@ Regola che vale per ogni misura fatta con questo modulo: il f della sola gamba c
dello spread. Il modello sbaglia soprattutto sull'ala che si COMPRA (piu' OTM, IV piu' alta per dello spread. Il modello sbaglia soprattutto sull'ala che si COMPRA (piu' OTM, IV piu' alta per
skew, prezzata dal modello a vol ATM) -> misurare una gamba sola da' la risposta sbagliata con skew, prezzata dal modello a vol ATM) -> misurare una gamba sola da' la risposta sbagliata con
segno rassicurante. segno rassicurante.
Seconda regola (09/09, debito §5.18): spot e DVOL escono da qui gia' CAUSALI — `asof(ts)` da'
l'ultima chiusura nota a ts. Chi costruisce una serie di prezzi altrove usi `causale()`, non
l'indice del feed com'e' (etichettato all'apertura: D6).
""" """
from __future__ import annotations from __future__ import annotations
@@ -65,23 +69,48 @@ def puts(asset: str, df: pd.DataFrame | None = None) -> pd.DataFrame:
return d[(d["asset"] == asset) & (d["option_type"] == "P")] return d[(d["asset"] == asset) & (d["option_type"] == "P")]
def causale(s: pd.Series, cadenza: str) -> pd.Series:
"""Rietichetta una serie di CHIUSURE dall'APERTURA della barra all'istante in cui la chiusura
e' nota (apertura + cadenza), cosi' `s.asof(ts)` restituisce l'ultima chiusura CONOSCIUTA a ts
e mai una futura. I valori non si toccano, solo le etichette.
🚨 Perche' esiste (debito §5.18, 09/09): i feed del progetto sono etichettati all'apertura
la barra 1h `20:00` chiude col 5m delle 20:55 (verificato: chiusura 1h a T == chiusura 5m a
T+55 nel 100% delle barre) e la riga DVOL del giorno D e' la chiusura delle 23:00 di D
(verificato contro la risoluzione 1h dell'API pubblica). Senza questo spostamento `asof(ts)`
dava lo spot di UN'ORA dopo e il DVOL di FINO A 24 ORE dopo l'ingresso (D6, quinta occorrenza)."""
out = s.copy()
out.index = out.index + pd.Timedelta(cadenza)
return out
@lru_cache(maxsize=8) @lru_cache(maxsize=8)
def spot_series(asset: str) -> pd.Series: def spot_series(asset: str) -> pd.Series:
"""Chiusure 1h del feed certificato, etichettate all'istante in cui sono NOTE (apertura + 1h).
`spot_series(a).asof(ts)` = ultima chiusura oraria conosciuta a `ts`. Conseguenza sul
regolamento: `asof(exp)` alle 08:00 e' la chiusura della barra 07:00, cioe' il prezzo delle
08:00 prima era il prezzo delle 09:00, un'ora DOPO la scadenza."""
from scripts.analysis.research_lab import load_tf from scripts.analysis.research_lab import load_tf
px = load_tf(asset, "1h") px = load_tf(asset, "1h")
s = pd.Series(px["close"].values.astype(float), s = pd.Series(px["close"].values.astype(float),
index=pd.to_datetime(px["timestamp"], unit="ms", utc=True)).sort_index() index=pd.to_datetime(px["timestamp"], unit="ms", utc=True)).sort_index()
s.index = pd.DatetimeIndex(s.index).as_unit("ns") # le quote bite hanno microsecondi s.index = pd.DatetimeIndex(s.index).as_unit("ns") # le quote bite hanno microsecondi
return s return causale(s, "1h")
@lru_cache(maxsize=8) @lru_cache(maxsize=8)
def dvol_series(asset: str) -> pd.Series: def dvol_series(asset: str) -> pd.Series:
"""DVOL giornaliero (chiusura), etichettato all'istante in cui e' NOTO (giorno + 1D).
`dvol_series(a).asof(ts)` = ultima chiusura giornaliera del DVOL conosciuta a `ts`: dentro il
giorno D e' la chiusura di D1, quindi vecchia fino a 23 ore. E' il prezzo della causalita'
con un feed giornaliero; l'alternativa (DVOL orario dall'API pubblica) non e' nel feed."""
d = pd.read_parquet(RAW / f"dvol_{asset.lower()}.parquet") d = pd.read_parquet(RAW / f"dvol_{asset.lower()}.parquet")
s = pd.Series(d["close"].values.astype(float), s = pd.Series(d["close"].values.astype(float),
index=pd.to_datetime(d["timestamp"], unit="ms", utc=True)).sort_index() index=pd.to_datetime(d["timestamp"], unit="ms", utc=True)).sort_index()
s.index = pd.DatetimeIndex(s.index).as_unit("ns") s.index = pd.DatetimeIndex(s.index).as_unit("ns")
return s return causale(s, "1D")
# ------------------------------------------------------------------ prezzo # ------------------------------------------------------------------ prezzo
+20 -12
View File
@@ -310,11 +310,15 @@ def boot_ci(x: np.ndarray, n: int = 4000, seed: int = 822) -> tuple[float, float
def main() -> None: def main() -> None:
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--no-live", action="store_true", help="non interrogare il venue") ap.add_argument("--no-live", action="store_true", help="non interrogare il venue")
ap.add_argument("--al", default=None,
help="data di taglio UTC (riproduzione): esclude le scadenze >= questa data; default adesso")
args = ap.parse_args() args = ap.parse_args()
print(__doc__.split("\n\n")[0]) print(__doc__.split("\n\n")[0])
puts = load_puts() puts = load_puts()
today = pd.Timestamp.now(tz="UTC") today = pd.Timestamp(args.al, tz="UTC") if args.al else pd.Timestamp.now(tz="UTC")
print(f"\n taglio dei trade: scadenze < {today:%Y-%m-%d %H:%M}Z"
+ (" (--al: riproduzione)" if args.al else " (adesso)"))
venue = venue_read(not args.no_live) venue = venue_read(not args.no_live)
# ------------------------------------------------------------ §1 REGIME # ------------------------------------------------------------ §1 REGIME
@@ -323,7 +327,7 @@ def main() -> None:
"misurato 4 volte). Se nel campione quel gate non scatta mai, il campione non contiene\n" "misurato 4 volte). Se nel campione quel gate non scatta mai, il campione non contiene\n"
"la strategia — contiene la sua astensione.\n") "la strategia — contiene la sua astensione.\n")
for a in ASSETS: for a in ASSETS:
V = CB.dvol_series(a) V = CB.causale(CB.dvol_series(a), "-1D") # calendario: il DVOL DEL giorno D (la serie di cblib e' causale: sta a D+1)
win = V[(V.index >= puts["ts"].min()) & (V.index <= puts["ts"].max())] win = V[(V.index >= puts["ts"].min()) & (V.index <= puts["ts"].max())]
hist = V[V.index < win.index[0]].to_numpy(float) hist = V[V.index < win.index[0]].to_numpy(float)
print(f" {a}: DVOL nel campione min {win.min():.1f} / mediana {win.median():.1f} / " print(f" {a}: DVOL nel campione min {win.min():.1f} / mediana {win.median():.1f} / "
@@ -372,7 +376,7 @@ def main() -> None:
print(" Regola del 19/06: «rivalutare quando la catena cattura un crash». Un criterio senza\n" print(" Regola del 19/06: «rivalutare quando la catena cattura un crash». Un criterio senza\n"
" numero non e' un criterio, quindi eccolo (IV-rank espandente causale, come nel sleeve):") " numero non e' un criterio, quindi eccolo (IV-rank espandente causale, come nel sleeve):")
for a in ASSETS: for a in ASSETS:
V = CB.dvol_series(a) V = CB.causale(CB.dvol_series(a), "-1D") # idem: date di calendario, non etichette causali
vals = V.to_numpy(float) vals = V.to_numpy(float)
ivr = np.full(len(vals), np.nan) ivr = np.full(len(vals), np.nan)
for i in range(1, len(vals)): for i in range(1, len(vals)):
@@ -753,13 +757,17 @@ def main() -> None:
# ------------------------------------------------------------ §7 VERDETTO # ------------------------------------------------------------ §7 VERDETTO
hr("§7 VERDETTO") hr("§7 VERDETTO")
print(""" SCARTATO — su questi dati la domanda non e' decidibile. Quattro motivi, indipendenti print(""" (numeri della stesura del 22/08, RIMISURATI il 09/09 con spot e DVOL causali — debito §5.18:
erano 0.714 [0.690, 0.779] · 32.58 / 1.45 / -0.35 · 3.90 -> 2.41 · -1.35% -> +11.22% · +25% / +44%.
Il verdetto non cambia: le quattro ragioni non dipendono da un'ora di spot.)
SCARTATO su questi dati la domanda non e' decidibile. Quattro motivi, indipendenti
l'uno dall'altro: ciascuno da solo basterebbe. l'uno dall'altro: ciascuno da solo basterebbe.
1. IL CAMPIONE NON CONTIENE LA STRATEGIA. 0 settimane su 19 passano il gate IV-rank>0.30 1. IL CAMPIONE NON CONTIENE LA STRATEGIA. 0 settimane su 19 passano il gate IV-rank>0.30
che E' l'alpha di VRP01 il sleeve sarebbe stato FLAT per l'intero campione. La DVOL che E' l'alpha di VRP01 il sleeve sarebbe stato FLAT per l'intero campione. La DVOL
mediana sta al 7 (BTC) / 11 (ETH) percentile della storia 2021+, e il sottostante e' mediana sta al 7 (BTC) / 11 (ETH) percentile della storia 2021+, e il sottostante e'
salito del +25% / +44%: vol implicita bassa E rialzo forte = il regime MIGLIORE salito del +21% / +40%: vol implicita bassa E rialzo forte = il regime MIGLIORE
possibile per vendere put, non uno neutro. Le settimane sono 10 per asset, non 16 possibile per vendere put, non uno neutro. Le settimane sono 10 per asset, non 16
(prima del 2026-06-09 l'archivio seguiva solo la finestra 13-20 DTE: quella 4-10 DTE (prima del 2026-06-09 l'archivio seguiva solo la finestra 13-20 DTE: quella 4-10 DTE
non esiste). Esito: 10/10 vincenti su BTC, e la lente giusta non e' lo Sharpe ma la non esiste). Esito: 10/10 vincenti su BTC, e la lente giusta non e' lo Sharpe ma la
@@ -770,13 +778,13 @@ def main() -> None:
regime di vol bassa persistente, non davanti a un muro. regime di vol bassa persistente, non davanti a un muro.
2. IL TITOLO NON SOPRAVVIVE ALL'ORA D'INGRESSO. E' un max-of-k mai dichiarato, misurato 2. IL TITOLO NON SOPRAVVIVE ALL'ORA D'INGRESSO. E' un max-of-k mai dichiarato, misurato
qui per la prima volta su questa struttura: su 9 ancore lo Sharpe canonico BTC 32.58 qui per la prima volta su questa struttura: su 9 ancore lo Sharpe canonico BTC 29.61
sta all'89 percentile, la MEDIANA ONESTA e' 1.45 e la banda tocca il NEGATIVO (-0.35). sta all'89 percentile, la MEDIANA ONESTA e' 2.12 e la banda tocca il NEGATIVO (-0.19).
Su ETH 3.90 -> 2.41. Il numero da citare per BTC e' 1.45, non 32.58. Su ETH 7.64 -> 3.35. Il numero da citare per BTC e' 2.12, non 29.61.
E il meccanismo non e' quello che avevo scritto prima di guardarlo. Non e' solo la E il meccanismo non e' quello che avevo scritto prima di guardarlo. Non e' solo la
vol campionaria a ballare: fra le ancore la MEDIA settimanale BTC va da -1.35% a vol campionaria a ballare: fra le ancore la MEDIA settimanale BTC va da -0.66% a
+11.22% del rischio CAMBIA SEGNO e la deviazione standard di 11x (ETH: media 2.9x, +11.44% del rischio CAMBIA SEGNO e la deviazione standard di 9.2x (ETH: media 2.1x,
sd 2.4x). Spostare l'ora d'ingresso cambia lo snapshot e quindi quali strike combaciano sd 3.2x). Spostare l'ora d'ingresso cambia lo snapshot e quindi quali strike combaciano
col delta bersaglio: non e' lo stesso trade un'ora dopo, e' UN'ALTRA STRUTTURA. A 10 col delta bersaglio: non e' lo stesso trade un'ora dopo, e' UN'ALTRA STRUTTURA. A 10
settimane questo parametro di disturbo domina il segnale che si vorrebbe misurare. settimane questo parametro di disturbo domina il segnale che si vorrebbe misurare.
@@ -793,7 +801,7 @@ def main() -> None:
4. LO SPREAD E' IL COSTO DOMINANTE, E SULLA FAMIGLIA ESEGUIBILE E' PEGGIO. Attraversare 4. LO SPREAD E' IL COSTO DOMINANTE, E SULLA FAMIGLIA ESEGUIBILE E' PEGGIO. Attraversare
il bid/ask costa ~10% del credito sugli INVERSE; nella regione di strike che serve lo il bid/ask costa ~10% del credito sugli INVERSE; nella regione di strike che serve lo
spread relativo mediano e' 24-37% sugli inverse e 29-59% sulle USDC, con open interest spread relativo mediano e' 24-37% sugli inverse e 29-59% sulle USDC, con open interest
~0. Il f del credito netto si replica indipendentemente a 0.714 (IC95 [0.690, 0.779], ~0. Il f del credito netto si replica indipendentemente a 0.712 (IC95 [0.664, 0.732],
0/19 osservazioni >= 1.0) conferma dello 0.73 del 30/07 su un campione piu' lungo e 0/19 osservazioni >= 1.0) conferma dello 0.73 del 30/07 su un campione piu' lungo e
con un percorso diverso. con un percorso diverso.
+2 -1
View File
@@ -167,7 +167,8 @@ def calibra_catena() -> dict:
skew[nm] = (np.array(xs), np.array(ys)) skew[nm] = (np.array(xs), np.array(ys))
# struttura a termine: IV ATM a DTE t contro il DVOL 30g dello stesso istante # struttura a termine: IV ATM a DTE t contro il DVOL 30g dello stesso istante
dv = cblib.dvol_series("BTC") dv = cblib.causale(cblib.dvol_series("BTC"), "-1D") # join per GIORNO: il DVOL del giorno D — la serie di
# cblib e' causale dal 09/09 sera (la chiusura di D sta a D+1); senza questo il join accosterebbe l'IV di D al DVOL di D1
a = q[q["ad"].between(0.45, 0.55)].copy() a = q[q["ad"].between(0.45, 0.55)].copy()
a["giorno"] = a["ts"].dt.floor("D") a["giorno"] = a["ts"].dt.floor("D")
dvd = dv.copy() dvd = dv.copy()
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env python
"""r0906_usde_tetto_sonda.py — sonda il TETTO del venue sull'USDE a SALDO NEUTRO, passi da 1-2 USDE.
INVIA ORDINI VERI (spot USDE_USDC, fee 0, ~$0,001 di spread per ordine).
Metodo (diari 30-31/08, sezione «la lezione di metodo»): un probe unico di taglia sbagliata produce un
falso negativo che si legge come risultato. Qui si sale a passi piccoli fino al primo rifiuto, si
ripete il rifiuto dopo aver riletto il book (per escludere l'ask che si e' mosso: il messaggio del
venue e' lo stesso `not_enough_funds_in_currency`), poi si CONFERMA a saldo neutro:
SELL 1 -> BUY 1 (deve passare) -> BUY 1 (deve essere rifiutato). Il bracket e' [L, L+1) USDE, con
l'equity totale letta nello stesso istante, cosi' la frazione ha la sua banda.
Guardie: prezzo dal BOOK (ask+5 tick, tetto 1,0010 / bid-5 tick, pavimento 0,9990), banda
[0,995, 1,005]; al massimo MAX_ADD USDE in piu' (quota mai oltre QUOTA_STOP); pausa fra ordini.
uv run python scripts/research/r0906_usde_tetto_sonda.py # dry-run: stato e piano
uv run python scripts/research/r0906_usde_tetto_sonda.py --esegui # sonda davvero
"""
from __future__ import annotations
import json
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "live"))
from src.live import usde as U # noqa: E402
from src.live.deribit import DeribitRead # noqa: E402
import usde_convert as C # noqa: E402 (book_top, leggi_stato: stessi attrezzi)
LOG = ROOT / "data" / "live" / "usde_convert.jsonl"
PX_MIN, PX_MAX = C.PX_MIN, C.PX_MAX
TICK, MARGINE, CAP_BUY, CAP_SELL = 0.0001, 5, 1.0010, 0.9990
MAX_ADD = 60 # USDE in piu' al massimo (--max-add N): oltre, «tetto non trovato entro il limite»
QUOTA_HARD = 0.70 # mai oltre: e' la quota decisa dall'operatore il 30/08 (`usde.quota_target`), e coincide
# col CUSCINO DI REGOLAMENTO derivato da config (usde_convert.cuscino_richiesto_usd),
# che qui e' la guardia che morde davvero: si legge a ogni passo, non si ridichiara.
SCALA = [100.0, 20.0, 4.0, 1.0] # passi: si scende di uno a ogni doppio rifiuto (M13: risoluzione dell'effetto)
PAUSA, PAUSA_RIFIUTO = 12.0, 20.0
def stato(c: DeribitRead) -> dict:
eq_usdc, eq_usde, px, fonte = C.leggi_stato(c)
val, _ = U.valuta(eq_usde, px)
return {"usdc": eq_usdc, "usde": eq_usde, "px": px, "fonte": fonte, "val": val, "tot": eq_usdc + val,
"quota": val / (eq_usdc + val) if (eq_usdc + val) > 0 else None}
def prezzo_ordine(side: str) -> float | None:
bid, ask = C.book_top()
if side == "buy":
return None if ask is None else round(min(ask + MARGINE * TICK, CAP_BUY), 4)
return None if bid is None else round(max(bid - MARGINE * TICK, CAP_SELL), 4)
def ordine(c: DeribitRead, side: str, amt: float, px: float) -> tuple[bool, str]:
resp = c._unwrap(c._post("/mcp-deribit/tools/place_order",
{"instrument_name": U.SPOT, "side": side, "amount": float(amt),
"type": "limit", "price": px, "label": "usde-sonda-0906"})) or {}
if not isinstance(resp, dict) or resp.get("error") or "order" not in resp:
err = resp.get("error", resp) if isinstance(resp, dict) else resp
return False, str(err)[:120]
o = resp["order"]
return True, f"{o.get('order_state')} filled {o.get('filled_amount')}"
def main() -> int:
esegui = "--esegui" in sys.argv[1:]
max_add = float(sys.argv[sys.argv.index("--max-add") + 1]) if "--max-add" in sys.argv else MAX_ADD
c = DeribitRead()
s0 = stato(c)
if s0["px"] is None:
print(" ✗ prezzo USDE non leggibile — non si sonda al buio (P5)"); return 2
print("=" * 78)
print(f" SONDA TETTO USDE — {datetime.now(timezone.utc):%Y-%m-%dT%H:%M:%SZ} "
f"{'ESECUZIONE' if esegui else 'DRY-RUN'}")
print("=" * 78)
cap = U.cfg().get("venue_cap_frac")
print(f" conto : USDC ${s0['usdc']:,.2f} + USDE {s0['usde']:,.4f} (${s0['val']:,.2f}) = ${s0['tot']:,.2f}"
f" quota {s0['quota']:.2%} · tetto in config {cap:.1%} = {cap * s0['tot']:,.1f} USDE")
cusc, come = C.cuscino_richiesto_usd(s0["tot"])
print(f" piano : BUY a passi {''.join(f'{x:.0f}' for x in SCALA)} fino al rifiuto (max +{max_add:.0f} USDE;"
f" quota ≤{QUOTA_HARD:.0%} e USDC ≥ cuscino ${cusc:,.2f} = {come}), conferma SELL 1 → BUY 1 ok → BUY 1 rifiutato")
if not esegui:
print(" → dry-run. Rilancia con --esegui."); return 0
eventi: list[dict] = []
def fai(side: str, amt: float) -> bool:
px = prezzo_ordine(side)
if px is None or not (PX_MIN <= px <= PX_MAX):
print(f" ✗ prezzo d'ordine {px} non leggibile/fuori banda — mi fermo"); raise SystemExit(3)
ok, nota = ordine(c, side, amt, px)
time.sleep(3.0)
s = stato(c)
ev = {"ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "side": side, "amount": amt,
"px": px, "ok": ok, "nota": nota, "usde": s["usde"], "tot": s["tot"], "quota": s["quota"]}
eventi.append(ev)
print(f" · {side.upper():4} {amt:>3.0f} @ {px:.4f} -> {'OK ' if ok else 'RIFIUTATO'} "
f"USDE {s['usde']:,.4f} eq ${s['tot']:,.2f} quota {s['quota']:.3%} [{nota}]")
return ok
# --- salita a passi piccoli -------------------------------------------------------------
i_step, aggiunti, muro, limite = 0, 0.0, None, None
while True:
step = SCALA[i_step]
s = stato(c)
cusc, _ = C.cuscino_richiesto_usd(s["tot"])
if aggiunti + step > max_add:
limite = f"+{aggiunti:.0f} USDE raggiunti (max-add {max_add:.0f})"; break
if (s["val"] + step * s["px"]) / s["tot"] > QUOTA_HARD:
limite = f"quota {s['quota']:.2%} + passo supererebbe {QUOTA_HARD:.0%}"; break
if s["usdc"] - step * s["px"] < cusc:
limite = f"USDC ${s['usdc']:,.2f} passo scenderebbe sotto il cuscino ${cusc:,.2f}"; break
if fai("buy", step):
aggiunti += step; time.sleep(PAUSA); continue
time.sleep(PAUSA_RIFIUTO) # rifiuto: rileggo il book e riprovo UGUALE
if fai("buy", step):
aggiunti += step; time.sleep(PAUSA); continue
if i_step < len(SCALA) - 1:
i_step += 1; time.sleep(PAUSA); continue # doppio rifiuto: passo piu' fine
muro = stato(c); break
if limite:
s = stato(c)
print(f" ✗ limite della sonda: {limite} — tetto del venue NON trovato entro il limite;"
f" quota {s['quota']:.2%}, USDE {s['usde']:,.4f}")
if muro is None:
s = stato(c)
esito = {"verdetto": "NON_TROVATO", "limite": limite, "usde": s["usde"], "equity_tot": s["tot"],
"quota": s["quota"], "aggiunti": aggiunti, "eventi": eventi}
else:
# --- conferma a saldo neutro ---------------------------------------------------------
time.sleep(PAUSA)
ok_sell = fai("sell", 1); time.sleep(PAUSA)
ok_buy1 = fai("buy", 1) if ok_sell else False; time.sleep(PAUSA)
ok_buy2 = fai("buy", 1) if ok_buy1 else None; time.sleep(3.0)
fin = stato(c)
confermato = bool(ok_sell and ok_buy1 and ok_buy2 is False)
L = fin["usde"]
lo, hi = L / fin["tot"], (L + 1) / fin["tot"]
esito = {"verdetto": "CONFERMATO" if confermato else "NON_CONFERMATO",
"bracket_usde": [L, L + 1], "equity_tot": fin["tot"], "frazione": [lo, hi],
"aggiunti": aggiunti, "eventi": eventi}
print()
print(f" TETTO : [{L:,.4f} · {L + 1:,.4f}) USDE con equity ${fin['tot']:,.2f}"
f" ⇒ frazione {lo:.2%} {hi:.2%} ({esito['verdetto']}: "
f"sell1 {ok_sell} / buy1 {ok_buy1} / buy1 {ok_buy2})")
print(f" config : venue_cap_frac {cap:.3f} → bordo basso misurato oggi {lo:.4f}"
+ (" (config sotto il misurato: conservativa, ok)" if cap <= lo + 1e-9 else " ⚠️ config SOPRA il misurato"))
print(f" conto : USDC ${fin['usdc']:,.2f} + USDE {fin['usde']:,.4f} = ${fin['tot']:,.2f} quota {fin['quota']:.2%}")
with LOG.open("a") as f:
f.write(json.dumps({"ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"sonda_tetto": esito}) + "\n")
print(f" registrato : {LOG.relative_to(ROOT)}")
return 0 if esito["verdetto"] == "CONFERMATO" else 1
if __name__ == "__main__":
raise SystemExit(main())
+384
View File
@@ -0,0 +1,384 @@
"""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. Corretto la mattina del 09/09 qui con uno `spot_causale` locale;
la sera dello stesso giorno la riparazione e' entrata in `cblib` (`causale`, debito §5.18) e
copre anche il DVOL giornaliero (fino a 24 ore avanti): questo script usa le serie di `cblib`
COME SONO, senza ri-spostarle.
* il costo di chiusura `(ask corta bid lunga)·spot` puo' superare la LARGHEZZA dello spread
(una quota da 158% di spread relativo a T0): 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 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 = CB.spot_series(asset), CB.dvol_series(asset) # gia' causali (cblib, dal 09/09 sera)
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
# CONTESTO per giorno: la serie di cblib e' causale (la chiusura del giorno D sta a D+1);
# qui si vuole "il DVOL DEL giorno D" accanto al prezzo del giorno D, quindi si toglie lo
# spostamento SOLO per la tabella e per il picco del crollo. `dvol_pre` e `ivr_pre` restano
# sulla serie causale: sono cio' che si sapeva alle 00:00 del primo giorno di crollo.
dvg = CB.causale(V, "-1D")
hist_all = dvg[dvg.index < CROLLO[0]]
lr = np.log(pd_).diff()
rv5 = lr.rolling(5).std() * np.sqrt(365.25) * 100
dv = dvg[(dvg.index >= pd.Timestamp("2026-05-20", tz="UTC")) & (dvg.index <= EXP)]
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 < dv[(dv.index >= CROLLO[0]) & (dv.index <= CROLLO[1])].max()).mean()),
ivr_pre=float((hist_all < V.asof(CROLLO[0])).mean()),
rv5_max=float(rv5[(rv5.index >= CROLLO[0]) & (rv5.index <= CROLLO[1] + pd.Timedelta(days=3))].max()),
caduta=float(pd_[CROLLO[0]:CROLLO[1]].min() / pd_[:CROLLO[0]].iloc[-1] - 1.0),
)
wk = _vrp_weekly_asset(asset)
out["sleeve"][asset] = wk[(wk.index >= pd.Timestamp("2026-05-10", tz="UTC")) & (wk.index <= pd.Timestamp("2026-07-05", tz="UTC"))]
return out
def sintesi(m: dict) -> dict:
s = {}
for asset in ASSETS:
t = m["trades"][asset]
if t.empty:
s[asset] = None; continue
n_fuori = int((~t["in_geom"]).sum())
t = t[t["in_geom"]] # la griglia di strike dell'archivio e' rada: fuori geometria = altra struttura
if t.empty:
s[asset] = dict(n=0, n_fuori=n_fuori); continue
pre = t[t["ts"] < PRE] # ingressi PRIMA del crollo: il caso della regola
dur = t[(t["ts"] >= PRE) & (t["ts"] < CROLLO[1] - pd.Timedelta(days=1))] # DURANTE (02-04/06): lente secondaria
med = lambda c, d=t: float(np.nanmedian(d[c])) if len(d) else np.nan
b = lambda c, d=t: (float(np.nanpercentile(d[c], 10)), float(np.nanpercentile(d[c], 90))) if len(d) else (np.nan, np.nan)
s[asset] = dict(
n=int(len(t)), n_pre=int(len(pre)), n_fuori=n_fuori,
d_short=med("d_short", pre), d_long=med("d_long", pre), width=med("width", pre), ST=float(t["ST"].iloc[0]),
spot_pre=med("spot", pre),
dte=(float(t["dte"].min()), float(t["dte"].max())),
f_net=med("f_net"), f_net_b=b("f_net"),
ivrank=(float(t["ivrank"].min()), float(t["ivrank"].max())),
gate_aperto=float((t["ivrank"] >= VRP_CFG["gate_ivr"]).mean()),
perdita_su_risk=med("perdita_su_risk", pre), perdita_su_risk_b=b("perdita_su_risk", pre),
perdita_su_coll=med("perdita_su_coll", pre),
pnl_real=med("pnl_real", pre), pnl_mod=med("pnl_mod", pre),
f_exp=med("f_exp", pre), f_exp_b=b("f_exp", pre), n_f_exp=int(pre["f_exp"].notna().sum()),
f_mtm=med("f_mtm", pre), f_mtm_b=b("f_mtm", pre),
mtm_real_min=med("mtm_real_min", pre), mtm_mod_min=med("mtm_mod_min", pre),
n_tagli=int(pre["n_tagli"].sum()) if len(pre) else 0, n_ore=int(pre["n_ore"].sum()) if len(pre) else 0,
iv_s_at_min=med("iv_s_at_min", pre), dvol_at_min=med("dvol_at_min", pre),
q_itm_pieno=float(pre["itm_pieno"].mean()) if len(pre) else np.nan,
n_strutture=int(pre.groupby(["k_short", "k_long"]).ngroups) if len(pre) else 0,
f_put_in=med("f_put_in", pre), bleed_put=med("bleed_put", pre),
put_exp=med("put_exp", pre), put_exp_mod=med("put_exp_mod", pre),
put_best=med("put_best", pre), put_best_mod=med("put_best_mod", pre),
f_put_exp=med_ratio(pre, "put_exp", "put_exp_mod"), f_put_best=med_ratio(pre, "put_best", "put_best_mod"),
deb_exp=med("deb_exp", pre), deb_exp_mod=med("deb_exp_mod", pre),
deb_best=med("deb_best", pre), deb_best_mod=med("deb_best_mod", pre),
f_deb_exp=med_ratio(pre, "deb_exp", "deb_exp_mod"), f_deb_best=med_ratio(pre, "deb_best", "deb_best_mod"),
iv_s0=med("iv_s0", pre), iv_s_max=med("iv_s_max", pre), iv_l0=med("iv_l0", pre), iv_l_max=med("iv_l_max", pre),
und_ret=med("und_ret", pre), spot_min=med("spot_min", pre),
durante=dict(n=int(len(dur)), f_exp=med("f_exp", dur), f_mtm=med("f_mtm", dur),
perdita_su_risk=med("perdita_su_risk", dur), f_net=med("f_net", dur),
q_perde=float((dur["pnl_real"] < 0).mean()) if len(dur) else np.nan,
pnl_p10=float(np.nanpercentile(dur["pnl_real"], 10)) if len(dur) else np.nan,
pnl_p90=float(np.nanpercentile(dur["pnl_real"], 90)) if len(dur) else np.nan,
d_short=med("d_short", dur), d_long=med("d_long", dur), width=med("width", dur),
pnl_real=med("pnl_real", dur), pnl_mod=med("pnl_mod", dur)),
)
return s
def med_ratio(d: pd.DataFrame, a: str, b: str, minimo: float = 5.0) -> float:
"""Mediana del rapporto reale/modello sulle righe in cui il modello vale almeno `minimo` $
(un payoff modellato 0 non da' un rapporto, da' un'altra domanda: si stampano i dollari)."""
x = d[(d[b] >= minimo)]
return float(np.nanmedian(x[a] / x[b])) if len(x) else np.nan
def verdetto(s: dict) -> str:
parti = []
for asset in ASSETS:
r = s.get(asset)
if not r or r.get("n", 0) == 0:
parti.append(f"{asset}: NON misurabile — {r.get('n_fuori', 0) if r else 0} snapshot tutti FUORI geometria (griglia di strike rada)"); continue
if not np.isfinite(r["f_exp"]):
parti.append(f"{asset}: nessun ingresso pre-crollo in geometria (solo DURANTE: f_net {r['durante']['f_net']:.2f}, perde il {r['durante']['q_perde']*100:.0f}%)"); continue
parti.append(f"{asset}: f_net {r['f_net']:.2f} (22/08 in rally 0,714); a scadenza f {r['f_exp']:.2f} ma TAUTOLOGICO nel "
f"{r['q_itm_pieno']*100:.0f}% dei trade (entrambe le gambe ITM ⇒ dipende solo da f_net; {r['n_strutture']} strutture, non {r['n_pre']} osservazioni); "
f"peggior MTM tagliato alla larghezza f {r['f_mtm']:.2f} (quote oltre la larghezza nel {r['n_tagli']/max(1, r['n_ore'])*100:.1f}% delle ore); "
f"gate IV-rank {'APERTO' if r['gate_aperto'] > 0.5 else 'CHIUSO'} nel {r['gate_aperto']*100:.0f}% delle ore")
esito = ("IL CROLLO E' CATTURATO MA A VOL BASSA E FUORI DAL GATE DI VRP01: il f di stress che la regola del 19/06 aspettava "
"NON e' misurato nella sostanza — cio' che e' misurato e' che il rapporto di credito f_net non cambia col regime")
return esito + "" + "; ".join(parti) + " — n = 1 crollo, 1 scadenza per asset, famiglia INVERSE"
def pct(x): return f"{x*100:+.1f}%"
def main() -> None:
print("=" * 104)
print(" r0909 — IL CROLLO CATTURATO (1-5 giugno 2026): f di stress su quote VERE, scadenza 19/06")
print("=" * 104)
m = misura(); s = sintesi(m)
for asset in ASSETS:
c = m["contesto"][asset]; r = s[asset]
print(f"\n [{asset}] CONTESTO — caduta {pct(c['caduta'])} dal 31/05 al minimo di 1-5/06; "
f"DVOL pre {c['dvol_pre']:.1f} (IV-rank causale {c['ivr_pre']:.2f}) → max {c['dvol_max_crollo']:.1f} "
f"(= {c['pctl_picco']*100:.0f}° percentile storico: il PICCO del crollo sta sotto la mediana); RV5 max {c['rv5_max']:.0f}")
print(" giorno spot ret DVOL RV5")
for d in pd.date_range("2026-05-28", "2026-06-08", tz="UTC"):
if d in c["spot"].index:
dv = c["dvol"].asof(d)
print(f" {d.date()} {c['spot'][d]:>8.0f} {pct(c['ret'].get(d, np.nan)):>7} {dv:>5.1f} {c['rv5'].get(d, np.nan):>5.0f}")
if r is None or r.get("n", 0) == 0:
print(f" nessun trade in geometria VRP01: {r.get('n_fuori', 0) if r else 0} snapshot fuori geometria "
f"(la griglia di strike dell'archivio in quei giorni non ha le δ−0,28/0,10)"); continue
t = m["trades"][asset]; t = t[t["in_geom"]]
print(f"\n [{asset}] A. VRP01 a quote vere — {r['n']} snapshot d'ingresso in geometria ({r['n_pre']} prima del {PRE.date()}; "
f"{r['n_fuori']} scartati fuori geometria), DTE {r['dte'][0]:.0f}-{r['dte'][1]:.0f}, IV-rank {r['ivrank'][0]:.2f}-{r['ivrank'][1]:.2f}")
print(f" geometria mediana pre-crollo: δ corta {r['d_short']:+.2f} / δ lunga {r['d_long']:+.2f}, larghezza {r['width']:.0f}, "
f"spot d'ingresso {r['spot_pre']:.0f}, regolamento {EXP.date()} a {r['ST']:.0f}")
print(f" credito reale/modello f_net {r['f_net']:.2f} [{r['f_net_b'][0]:.2f}, {r['f_net_b'][1]:.2f}] "
f"(22/08: 0,714 in rally)")
print(f" ingressi PRE-crollo, mediana: P&L reale ${r['pnl_real']:+.0f} vs modello ${r['pnl_mod']:+.0f} per unita' "
f"→ perdita {r['perdita_su_risk']*100:.0f}% del rischio [{r['perdita_su_risk_b'][0]*100:.0f}, {r['perdita_su_risk_b'][1]*100:.0f}] "
f"(100% = perdita MASSIMA dello spread; oltre 100 sono le fee), "
f"{r['perdita_su_coll']*100:.1f}% del collaterale")
print(f" f a SCADENZA {r['f_exp']:.2f} [{r['f_exp_b'][0]:.2f}, {r['f_exp_b'][1]:.2f}] su {r['n_f_exp']} snapshot = {r['n_strutture']} strutture "
f"— TAUTOLOGICO nel {r['q_itm_pieno']*100:.0f}% (entrambe le gambe ITM: payoff = larghezza per reale e modello, resta solo f_net)")
print(f" peggior MTM (chiusura tagliata alla larghezza; quote oltre la larghezza nel {r['n_tagli']/max(1, r['n_ore'])*100:.1f}% delle ore-percorso): "
f"f {r['f_mtm']:.2f} [{r['f_mtm_b'][0]:.2f}, {r['f_mtm_b'][1]:.2f}] (reale ${r['mtm_real_min']:+.0f} / modello ${r['mtm_mod_min']:+.0f}); "
f"a quell'ora IV corta {r['iv_s_at_min']:.0f} vs DVOL {r['dvol_at_min']:.0f}")
print(f" IV gamba corta {r['iv_s0']:.0f} → max {r['iv_s_max']:.0f} (il max e' a T→0, non nel crollo); gamba lunga {r['iv_l0']:.0f} → max {r['iv_l_max']:.0f}; "
f"sottostante a scadenza {pct(r['und_ret'])}, minimo {r['spot_min']:.0f}")
du = r["durante"]
print(f" lente secondaria — ingressi DURANTE il crollo (02-04/06, {du['n']} snapshot): δ {du['d_short']:+.2f}/{du['d_long']:+.2f}, "
f"larghezza {du['width']:.0f}, f_net {du['f_net']:.2f}, P&L reale ${du['pnl_real']:+.0f} vs modello ${du['pnl_mod']:+.0f}, "
f"perde nel {du['q_perde']*100:.0f}% (p10 ${du['pnl_p10']:+.0f} / p90 ${du['pnl_p90']:+.0f}: bimodale, la mediana nasconde le perdite), "
f"f scadenza {du['f_exp']:.2f} / peggior MTM {du['f_mtm']:.2f}")
print(f"\n [{asset}] B. LO SPECCHIO LONG (ingressi PRE-crollo, mediana per unita')")
print(f" put δ−0,10 all'ask: costo reale/modello {r['f_put_in']:.2f}, bleed {r['bleed_put']*100:.2f}% dello spot")
print(f" a scadenza: reale ${r['put_exp']:+.0f} vs modello ${r['put_exp_mod']:+.0f} → f_payoff {r['f_put_exp']:.2f}")
print(f" al PICCO del percorso (look-ahead, limite sup.): reale ${r['put_best']:+.0f} vs modello ${r['put_best_mod']:+.0f} → f {r['f_put_best']:.2f}")
print(f" debit spread 0,28/0,10: a scadenza reale ${r['deb_exp']:+.0f} vs modello ${r['deb_exp_mod']:+.0f} → f {r['f_deb_exp']:.2f}; "
f"al picco ${r['deb_best']:+.0f} vs ${r['deb_best_mod']:+.0f} → f {r['f_deb_best']:.2f}")
print(f"\n [{asset}] C. IL MODELLO DEL SLEEVE (`_vrp_weekly_asset`, 0.0 = settimana saltata dai gate) — rendimento settimanale su collaterale:")
print(" " + " ".join(f"{d.date()} {v*100:+.2f}%" for d, v in m["sleeve"][asset].items()))
print(f"\n snapshot d'ingresso (primi 6): " + " | ".join(
f"{row.ts.strftime('%m-%d %H')}h K{row.k_short:.0f}/{row.k_long:.0f} cred ${row.cred_real:.0f} pnl ${row.pnl_real:+.0f} mtm ${row.mtm_real_min:+.0f}"
for row in t.head(6).itertuples()))
print("\n VERDETTO: " + verdetto(s))
print("=" * 104)
if __name__ == "__main__":
main()
+125
View File
@@ -0,0 +1,125 @@
"""r0909 — L'UNIVERSO DERIBIT LETTO OGGI: quali "altre monete" esistono davvero sul venue.
Domanda dell'operatore: "anche piu' monete, ma sempre in Deribit". Prima di misurare una
moneta si legge il venue (N10: la verifica esterna a 0 si fa PRIMA, e sul venue). Questo
script interroga l'API PUBBLICA Deribit (nessuna credenziale, nessun ordine) e stampa:
1. i perpetual USDC-lineari aperti, con data di listing, lotto minimo in $, volume 24h,
spread top-of-book, open interest e funding ordinati per volume;
2. quante delle 19 gambe di XS01 sono quotate (il 23/08 erano 14/19, 11 con 1 anno);
3. le famiglie di opzioni (inverse BTC/ETH; USDC-lineari per sottostante).
Una lettura e' UN istante (come r0822_alt_options): il file salva il JSON grezzo in
`data/_cache/` con l'ora della lettura, cosi' il numero stampato ha la sua fonte.
Cosa NON fa: nessun backtest, nessuna proposta. Dice solo quali strumenti esistono e quanto
sono negoziabili oggi il filtro che decide cosa vale la pena misurare (C6).
"""
from __future__ import annotations
import json
import sys
import time
import urllib.request
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from src.portfolio.sleeves import XS_UNIVERSE # noqa: E402 (P1: l'universo si importa)
API = "https://www.deribit.com/api/v2/public/"
CACHE = ROOT / "data" / "_cache" / "r0909_deribit_universo.json"
SOGLIA_LIQUIDO_USD = 10e6 # volume 24h da cui una gamba e' "liquida" per un libro da $4-5k
def get(path: str) -> dict:
req = urllib.request.Request(API + path, headers={"User-Agent": "PythagorasGoal-research"})
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)["result"]
def leggi(usa_cache: bool = True) -> dict:
if usa_cache and CACHE.exists():
return json.loads(CACHE.read_text())
d = dict(letto_a=datetime.now(timezone.utc).isoformat(timespec="seconds"),
fut=get("get_instruments?currency=USDC&kind=future"),
bs=get("get_book_summary_by_currency?currency=USDC&kind=future"),
opt_usdc=get("get_instruments?currency=USDC&kind=option"),
opt_inv={c: len(get(f"get_instruments?currency={c}&kind=option")) for c in ("BTC", "ETH")})
CACHE.parent.mkdir(parents=True, exist_ok=True)
CACHE.write_text(json.dumps(d))
return d
def tabella(d: dict) -> list[dict]:
perps = {i["base_currency"]: i for i in d["fut"] if i["instrument_name"].endswith("PERPETUAL") and i["state"] == "open"}
bs = {b["instrument_name"]: b for b in d["bs"]}
rows = []
for c, i in perps.items():
b = bs.get(i["instrument_name"], {})
bid, ask, mark = b.get("bid_price"), b.get("ask_price"), b.get("mark_price") or 0.0
rows.append(dict(
coin=c, listato=time.strftime("%Y-%m-%d", time.gmtime(i["creation_timestamp"] / 1000)),
min_amt=i["min_trade_amount"], min_usd=i["min_trade_amount"] * mark,
vol_usd=b.get("volume_usd") or 0.0,
spread_bps=((ask - bid) / ((ask + bid) / 2) * 1e4) if bid and ask else float("nan"),
oi_usd=(b.get("open_interest") or 0.0) * mark, funding_8h=b.get("funding_8h"),
xs01=c in XS_UNIVERSE))
return sorted(rows, key=lambda r: -r["vol_usd"])
def sintesi(rows: list[dict], d: dict) -> dict:
listati = {r["coin"] for r in rows}
liquidi = [r["coin"] for r in rows if r["vol_usd"] >= SOGLIA_LIQUIDO_USD]
bs = {b["instrument_name"]: b for b in d["bs"]}
inattivi = sorted(i["instrument_name"] for i in d["fut"] if i["instrument_name"].endswith("PERPETUAL") and i["state"] != "open")
datati = [i for i in d["fut"] if not i["instrument_name"].endswith("PERPETUAL")]
dvol_fut = []
for i in datati:
if "DVOL" not in i["instrument_name"]:
continue
b = bs.get(i["instrument_name"], {})
dvol_fut.append(dict(nome=i["instrument_name"], stato=i["state"], min_amt=i["min_trade_amount"],
vol_usd=b.get("volume_usd") or 0.0, oi=b.get("open_interest") or 0.0,
bid=b.get("bid_price"), ask=b.get("ask_price")))
return dict(
n_perp=len(rows), liquidi=liquidi, inattivi=inattivi, n_datati=len(datati), dvol_fut=dvol_fut,
xs01_listate=sorted(c for c in XS_UNIVERSE if c in listati),
xs01_mancanti=[c for c in XS_UNIVERSE if c not in listati],
opt_inv=d["opt_inv"], opt_usdc=Counter(i["base_currency"] for i in d["opt_usdc"]).most_common(),
letto_a=d["letto_a"])
def verdetto(s: dict) -> str:
return (f"{s['n_perp']} perpetual USDC; LIQUIDI (≥${SOGLIA_LIQUIDO_USD/1e6:.0f}M/g): {', '.join(s['liquidi'])}; "
f"XS01 {len(s['xs01_listate'])}/19 quotate, mancano {', '.join(s['xs01_mancanti'])}; "
f"opzioni USDC-lineari su {len(s['opt_usdc'])} sottostanti — lettura del {s['letto_a']}")
def main() -> None:
d = leggi(usa_cache="--fresh" not in sys.argv)
rows = tabella(d); s = sintesi(rows, d)
print("=" * 110)
print(f" r0909 — UNIVERSO DERIBIT (API pubblica, lettura {s['letto_a']}; --fresh per rileggere)")
print("=" * 110)
print(f"\n [1] PERPETUAL USDC-LINEARI APERTI: {s['n_perp']}")
print(f" {'coin':<9}{'listato':>11}{'min $':>8}{'vol 24h M$':>12}{'spread bps':>12}{'OI M$':>8}{'funding 8h':>12} XS01")
for r in rows:
print(f" {r['coin']:<9}{r['listato']:>11}{r['min_usd']:>8.2f}{r['vol_usd']/1e6:>12.2f}{r['spread_bps']:>12.1f}"
f"{r['oi_usd']/1e6:>8.2f}{(r['funding_8h'] or 0)*1e4:>+11.2f}bp {'<--' if r['xs01'] else ''}")
print(f" perpetual NON aperti (stato ≠ open): {s['inattivi'] or 'nessuno'}")
print(f"\n [1bis] FUTURE USDC DATATI: {s['n_datati']} — fra cui il future sul DVOL, l'unico strumento long-vol DIRETTO del venue "
f"(visto dalla revisione del 09/09: non in memoria):")
for f in s["dvol_fut"]:
spr = (f["ask"] - f["bid"]) / ((f["ask"] + f["bid"]) / 2) * 100 if f["bid"] and f["ask"] else float("nan")
print(f" {f['nome']:<28} {f['stato']:<8} min {f['min_amt']} vol24h ${f['vol_usd']/1e6:.2f}M OI {f['oi']:.0f} "
f"bid/ask {f['bid']}/{f['ask']} (spread {spr:.0f}%) → non negoziabile a questi numeri; e in contango sanguina come la put")
print(f"\n [2] XS01: {len(s['xs01_listate'])}/19 gambe quotate; mancano {s['xs01_mancanti']}")
print(f" (23/08, §50: 14/19 quotate, 11 con ≥1 anno; sulle 11 il meccanismo collassa 1,265 → 0,116 — breadth, non capitale)")
print(f"\n [3] OPZIONI: inverse {s['opt_inv']}; USDC-lineari per sottostante: {s['opt_usdc']}")
print("\n VERDETTO: " + verdetto(s))
print("=" * 110)
if __name__ == "__main__":
main()
+338
View File
@@ -0,0 +1,338 @@
"""r0909 — IL LIBRO NEI CROLLI: revisione dei sistemi adottati, misurata invece che ricordata.
DOMANDA DELL'OPERATORE (2026-09-09): "trova una soluzione per guadagnare anche nei crolli".
Prima di cercare un meccanismo nuovo, questo script misura cosa fanno OGGI i due sleeve live
(TP01 long-flat 75% + SKH01 long/short 25%) nei crolli giorno per giorno e per episodio
e da dove viene, se c'e', il guadagno. La memoria dice tre cose che qui vengono RIPRODOTTE
(M23: far riprodurre alla macchina il numero vecchio prima di pubblicarne uno nuovo):
(a) beta del libro al sottostante +0,076 (§46) "non si assicura un libro gia' piatto";
(b) nel sinistro TP01 va flat e SKH01 si gira short e guadagna (§26);
(c) il peggior giorno del libro: §33 lo trova in uno short squeeze di SKH01 (15,77% di sleeve,
2020-03-13) sulla lente HOURLY su questa lente (canonica, sleeve giornalieri) NON si
riproduce: §33 stesso nota che la canonica da' il 2025-10-10. Qui si stampa e si dichiara.
LENTE, dichiarata: sleeve di ricerca `sleeves._tp01_returns` / `_skyhook_returns` (ancora
canonica 00:00 UTC, fee 0,10% RT, funding NON modellato, come ogni numero del progetto).
SKH01 e' MARCATO ALL'USCITA del trade (`harness.backtest_signals` muove l'equity alla
chiusura): la serie giornaliera e' zero nell'87,7% dei giorni, e uno short aperto in una finestra
e chiuso fuori e' accreditato FUORI. E' il motivo per cui la tabella per GIORNO mostra SKH01 0
e quella per FINESTRA mostra il guadagno. NON e' la serie del libro live (armato dal 2026-06-20): i crolli del 2026 (5 febbraio
14%/15%, 1-5 giugno 25%/30%) sono PRIMA dell'arming, quindi qui sono backtest, non fill.
DEFINIZIONI, scritte prima di guardare i numeri:
- indice = 50/50 BTC/ETH, ribilanciato ogni giorno (la stessa geometria del libro);
- giorno di crollo = indice 5% (severo: 10%);
- episodio = le 12 peggiori finestre di 20 giorni NON sovrapposte dell'indice, piu' un elenco
NOMINATO di episodi noti (COVID, maggio 2021, LUNA, FTX, agosto 2024, febbraio e giugno 2026);
- classi ESCLUSIVE, in quest'ordine: "immune" = |libro| < 1/10 dell'indice (col segno stampato);
altrimenti "POSITIVO" se libro > 0, "PERDE" se < 0. Si stampa anche il conteggio nudo libro > 0.
(Prima stesura: POSITIVO provato prima di immune 8/12; con l'ordine dichiarato qui 4/12 + 6
immune. L'ordine delle regole ERA il verdetto: corretto in revisione, si stampano entrambi.)
- beta al ribasso = pendenza OLS del libro sull'indice nei soli giorni con indice < 0.
PESI: 75/25 e' decisione vincolante (confermata 3 volte, §3). Il 37,5/62,5 di §26 compare qui
SOLO come riga informativa negli stessi episodi non e' una proposta: la riapre solo
`weights_tilt_null`, che finora fallisce.
Nessun file di produzione toccato. Nessun ordine.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from src.backtest.harness import backtest_signals # noqa: E402
from src.data.downloader import load_data # noqa: E402
from src.live.book import W_SKH, W_TP01 # noqa: E402 (i pesi si IMPORTANO, non si ridichiarano: P1)
from src.portfolio.sleeves import _skyhook_returns, _tp01_returns # noqa: E402
from src.strategies.skyhook import SKH01_V2_DD, build_frames, skyhook_entries # noqa: E402
from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio, resample_1d # noqa: E402
ASSETS = ("BTC", "ETH")
SOGLIA_CROLLO = -0.05
SOGLIA_SEVERO = -0.10
FINESTRA = 20
N_EPISODI = 12
BETA_MEMORIA = 0.076 # §46, da riprodurre SULLA SUA FINESTRA (DVOL: 2021-03-24+)
FINESTRA_46 = pd.Timestamp("2021-03-24", tz="UTC")
TOL_BETA = 0.01
W_INFO = (0.375, 0.625) # §26: riga informativa, NON proposta
EPISODI_NOMINATI = {
"COVID 2020-03": ("2020-03-01", "2020-03-20"),
"maggio 2021": ("2021-05-08", "2021-05-24"),
"LUNA 2022-05": ("2022-05-04", "2022-05-14"),
"giugno 2022": ("2022-06-06", "2022-06-19"),
"FTX 2022-11": ("2022-11-06", "2022-11-22"),
"agosto 2024": ("2024-07-28", "2024-08-06"),
"febbraio 2025": ("2025-01-31", "2025-03-11"),
"5 febbraio 2026": ("2026-01-28", "2026-02-07"),
"1-5 giugno 2026": ("2026-05-28", "2026-06-06"),
}
# ------------------------------------------------------------------ serie
def indice_5050() -> pd.Series:
r = {}
for a in ASSETS:
d = resample_1d(load_data(a, "1h"))
s = pd.Series(d["close"].values.astype(float), index=pd.to_datetime(d["datetime"], utc=True))
r[a] = s.pct_change()
J = pd.concat(r, axis=1, join="inner").dropna()
return 0.5 * J["BTC"] + 0.5 * J["ETH"]
def tp01_esposizione() -> pd.Series:
"""Esposizione lorda media (BTC+ETH)/2 tenuta nel giorno (decisa a close[i-1])."""
tp = TrendPortfolio(**CANONICAL)
e = {}
for a in ASSETS:
d = resample_1d(load_data(a, "1h"))
tgt = tp.target_series(d)
held = np.zeros(len(tgt)); held[1:] = tgt[:-1]
e[a] = pd.Series(held, index=pd.to_datetime(d["datetime"], utc=True))
J = pd.concat(e, axis=1, join="inner")
return 0.5 * J["BTC"] + 0.5 * J["ETH"]
def skh_per_direzione() -> dict[str, pd.Series]:
"""SKH01-V2-DD 50/50 su griglia giornaliera, in tre versioni: intera, SOLO long, SOLO short.
Le versioni per direzione filtrano gli entry-dict prima del backtest: stesso motore, stessi
stop. In principio filtrare potrebbe cambiare il non-overlap; MISURATO (revisione 09/09):
long + short == intera su 2735/2735 giorni (max|diff| 3,5e-18) la scomposizione e' ESATTA,
e `misura()` lo verifica con un assert (P1: la versione "tutto" e' confrontata bit-exact con
`sleeves._skyhook_returns`).
"""
out = {k: {} for k in ("tutto", "long", "short")}
for a in ASSETS:
ltf, htf = build_frames(load_data(a, "5m"))
ent = skyhook_entries(ltf, htf, SKH01_V2_DD)
for k, keep in (("tutto", (1, -1)), ("long", (1,)), ("short", (-1,))):
e2 = [x if (x is not None and x["dir"] in keep) else None for x in ent]
m = backtest_signals(ltf, e2, fee_rt=0.001, leverage=1.0, asset=a, tf="230m")
s = pd.Series(m.equity, index=pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True)))
out[k][a] = s.resample("1D").last().ffill().pct_change().dropna()
res = {}
for k in out:
J = pd.concat(out[k], axis=1, join="inner").fillna(0.0)
res[k] = 0.5 * J["BTC"] + 0.5 * J["ETH"]
return res
# ------------------------------------------------------------------ misure
def sh(s: pd.Series) -> float:
s = s.dropna()
return float(s.mean() / s.std() * np.sqrt(365.0)) if len(s) > 5 and s.std() > 0 else float("nan")
def maxdd(s: pd.Series) -> float:
eq = (1 + s.fillna(0)).cumprod(); return float((eq / eq.cummax() - 1).min())
def cum(s: pd.Series) -> float:
return float((1 + s.fillna(0)).prod() - 1)
def finestre_peggiori(idx: pd.Series, n: int = N_EPISODI, w: int = FINESTRA) -> list[tuple]:
"""Le n peggiori finestre di w giorni NON sovrapposte dell'indice (greedy dal peggiore)."""
roll = (1 + idx).rolling(w).apply(np.prod, raw=True) - 1
roll = roll.dropna().sort_values()
prese, occupati = [], []
for end, v in roll.items():
start = end - pd.Timedelta(days=w - 1)
if any(not (end < s or start > e) for s, e in occupati):
continue
prese.append((start, end, float(v))); occupati.append((start, end))
if len(prese) >= n:
break
return sorted(prese)
def classifica(libro: float, indice: float) -> str:
"""Classi esclusive nell'ordine dichiarato: immune (col segno) → POSITIVO → PERDE."""
if abs(libro) < abs(indice) / 10:
return "immune+" if libro > 0 else "immune-"
return "POSITIVO" if libro > 0 else "PERDE"
def beta_ribasso(libro: pd.Series, idx: pd.Series, soglia: float = 0.0) -> float:
J = pd.concat({"b": libro, "i": idx}, axis=1, join="inner").dropna()
J = J[J["i"] < soglia]
if len(J) < 20:
return float("nan")
x, y = J["i"].values, J["b"].values
return float(np.cov(x, y)[0, 1] / np.var(x, ddof=1))
def beta_pieno(libro: pd.Series, idx: pd.Series) -> float:
J = pd.concat({"b": libro, "i": idx}, axis=1, join="inner").dropna()
x, y = J["i"].values, J["b"].values
return float(np.cov(x, y)[0, 1] / np.var(x, ddof=1))
def misura() -> dict:
idx = indice_5050()
tp = _tp01_returns()
if tp.index.tz is None:
tp.index = tp.index.tz_localize("UTC")
skh = skh_per_direzione()
esp = tp01_esposizione()
J = pd.concat({"idx": idx, "tp": tp, "skh": skh["tutto"], "skl": skh["long"],
"sks": skh["short"], "esp": esp}, axis=1, join="inner").dropna()
ref = _skyhook_returns()
if ref.index.tz is None:
ref.index = ref.index.tz_localize("UTC")
comune = J.index.intersection(ref.index)
assert float((J.loc[comune, "skh"] - ref.loc[comune]).abs().max()) == 0.0, "SKH01 'tutto' non e' bit-exact col sleeve"
assert float((J["skl"] + J["sks"] - J["skh"]).abs().max()) < 1e-12, "long + short != intera: la scomposizione non e' esatta"
J["libro"] = W_TP01 * J["tp"] + W_SKH * J["skh"]
J["info"] = W_INFO[0] * J["tp"] + W_INFO[1] * J["skh"]
J["c_tp"] = W_TP01 * J["tp"]
J["c_skh"] = W_SKH * J["skh"]
J["c_sks"] = W_SKH * J["sks"]
# giorni di crollo
giorni = {}
for nome, soglia in (("5%", SOGLIA_CROLLO), ("10%", SOGLIA_SEVERO)):
g = J[J["idx"] <= soglia]
giorni[nome] = dict(n=int(len(g)), idx=float(g["idx"].mean()), libro=float(g["libro"].mean()),
tp=float(g["c_tp"].mean()), skh=float(g["c_skh"].mean()),
sks=float(g["c_sks"].mean()), esp=float(g["esp"].mean()),
libro_pos=float((g["libro"] > 0).mean()),
per_anno={int(y): dict(n=int(len(gg)), idx=float(gg["idx"].sum()),
libro=float(gg["libro"].sum()),
sks=float(gg["c_sks"].sum()))
for y, gg in g.groupby(g.index.year)})
# episodi: finestre peggiori + nominati
def riga_ep(nome, s, e):
w = J.loc[s:e]
if len(w) < 3:
return None
return dict(nome=nome, da=str(w.index[0].date()), a=str(w.index[-1].date()), n=int(len(w)),
idx=cum(w["idx"]), libro=cum(w["libro"]), tp=cum(w["c_tp"]), skh=cum(w["c_skh"]),
sks=cum(w["c_sks"]), skl=cum(W_SKH * w["skl"]), esp=float(w["esp"].mean()),
info=cum(w["info"]), classe=classifica(cum(w["libro"]), cum(w["idx"])))
ep_auto = [r for r in (riga_ep(f"peggiore #{i+1}", s, e)
for i, (s, e, _) in enumerate(finestre_peggiori(J["idx"]))) if r]
ep_nom = [r for r in (riga_ep(k, pd.Timestamp(a, tz="UTC"), pd.Timestamp(b, tz="UTC"))
for k, (a, b) in EPISODI_NOMINATI.items()) if r]
# peggior giorno del libro (riproduce §33: e' uno squeeze, non un crollo?)
peggiori = J.nsmallest(5, "libro")[["idx", "libro", "c_tp", "c_skh"]]
n_auto = len(ep_auto)
guad = sum(r["classe"] == "POSITIVO" for r in ep_auto)
imm = sum(r["classe"].startswith("immune") for r in ep_auto)
pos = sum(r["libro"] > 0 for r in ep_auto)
dal22 = [r for r in ep_auto if r["da"] >= "2022"]
J46 = J[J.index >= FINESTRA_46]
quota_short = (sum(r["sks"] for r in ep_auto if r["libro"] > 0)
/ max(1e-12, sum(r["libro"] for r in ep_auto if r["libro"] > 0)))
return dict(
finestra=(str(J.index[0].date()), str(J.index[-1].date()), int(len(J))),
beta=beta_pieno(J["libro"], J["idx"]), beta_46=beta_pieno(J46["libro"], J46["idx"]),
beta_giu=beta_ribasso(J["libro"], J["idx"]),
beta_giu3=beta_ribasso(J["libro"], J["idx"], -0.03),
sharpe=dict(libro=sh(J["libro"]), info=sh(J["info"]), tp=sh(J["tp"]), skh=sh(J["skh"]),
sks=sh(J["sks"]), skl=sh(J["skl"])),
maxdd=dict(libro=maxdd(J["libro"]), info=maxdd(J["info"]), idx=maxdd(J["idx"])),
giorni=giorni, episodi=ep_auto, nominati=ep_nom,
peggiori=[(str(t.date()), float(r.idx), float(r.libro), float(r.c_tp), float(r.c_skh))
for t, r in peggiori.iterrows()],
sks_anno={int(y): cum(g) for y, g in J["c_sks"].groupby(J.index.year)},
sintesi=dict(n_episodi=n_auto, guadagna=guad, immune=imm, perde=n_auto - guad - imm, positivi=pos,
dal22=(sum(r["libro"] > 0 for r in dal22), len(dal22)),
giorni_pos=giorni["5%"]["libro_pos"], giorni_libro=giorni["5%"]["libro"],
quota_short_nei_guadagni=float(quota_short),
info_batte_live=sum(r["info"] > r["libro"] for r in ep_auto)),
)
def verdetto(m: dict) -> str:
"""Tre affermazioni separate, perche' rispondono a tre domande diverse:
(1) nel GIORNO del crollo (media sui giorni 5%); (2) per FINESTRA (classi esclusive);
(3) da dove viene il guadagno. Un solo aggettivo ("guadagna"/"immune") sarebbe l'ordine delle regole."""
s = m["sintesi"]
beta_ok = abs(m["beta_46"] - BETA_MEMORIA) < TOL_BETA
giorno = ("PERDE" if s["giorni_libro"] < 0 else "GUADAGNA")
if s["guadagna"] >= 2 * s["n_episodi"] / 3:
finestra = "POSITIVO per finestra"
elif s["guadagna"] + s["immune"] >= 2 * s["n_episodi"] / 3:
finestra = "IMMUNE-O-POSITIVO per finestra"
else:
finestra = "PERDE per finestra"
return (f"NEL GIORNO DEL CROLLO IL LIBRO {giorno} ({s['giorni_libro']*100:+.2f}%/g, positivo nel {s['giorni_pos']*100:.0f}% dei giorni ≤ 5%); "
f"{finestra}: {s['guadagna']} POSITIVO / {s['immune']} immune / {s['perde']} PERDE su {s['n_episodi']} (libro > 0 in {s['positivi']}, "
f"dal 2022 in {s['dal22'][0]}/{s['dal22'][1]}); beta {m['beta_46']:+.4f} sulla finestra di §46 "
f"({'riproduce' if beta_ok else 'NON riproduce'} il +{BETA_MEMORIA:.3f}); "
f"la gamba short di SKH01 (marcata all'uscita) fa il {s['quota_short_nei_guadagni']*100:.0f}% dei guadagni negli episodi con libro > 0")
# ------------------------------------------------------------------ stampa
def pct(x: float) -> str:
return f"{x*100:+.2f}%"
def main() -> None:
print("=" * 100)
print(" r0909 — IL LIBRO NEI CROLLI (TP01 75 / SKH01 25, lente di ricerca, ancora canonica)")
print("=" * 100)
m = misura()
a, b, n = m["finestra"]
print(f" finestra {a}{b} ({n} giorni) pesi importati da src/live/book: TP01 {W_TP01} / SKH01 {W_SKH}")
print("\n [1] BETA DEL LIBRO ALL'INDICE 50/50")
print(f" pieno {m['beta']:+.3f} · sulla finestra di §46 (2021-03-24+) {m['beta_46']:+.4f} (memoria: +{BETA_MEMORIA:.3f}) "
f"al ribasso (idx<0) {m['beta_giu']:+.3f} nei giorni ≤ 3% {m['beta_giu3']:+.3f}")
print("\n [2] GIORNI DI CROLLO — media per giorno")
print(f" {'soglia':<8}{'n':>5}{'indice':>9}{'libro':>9}{'TP01':>9}{'SKH01':>9}{'SKH short':>11}{'esp TP01':>10}{'libro>0':>9}")
for k, g in m["giorni"].items():
print(f" {k:<8}{g['n']:>5}{pct(g['idx']):>9}{pct(g['libro']):>9}{pct(g['tp']):>9}"
f"{pct(g['skh']):>9}{pct(g['sks']):>11}{g['esp']:>10.3f}{g['libro_pos']*100:>8.0f}%")
print("\n per anno (giorni ≤ 5%, SOMMA nel gruppo):")
print(f" {'anno':<6}{'n':>4}{'indice':>10}{'libro':>10}{'SKH short':>11}")
for y, g in m["giorni"]["5%"]["per_anno"].items():
print(f" {y:<6}{g['n']:>4}{pct(g['idx']):>10}{pct(g['libro']):>10}{pct(g['sks']):>11}")
def tab(rows, titolo):
print(f"\n {titolo}")
print(f" {'episodio':<18}{'da':>11}{'a':>11}{'indice':>9}{'libro':>9}{'TP01':>8}{'SKH01':>8}"
f"{'SKH-S':>8}{'SKH-L':>8}{'esp':>6}{'37/63':>8} classe")
for r in rows:
print(f" {r['nome']:<18}{r['da']:>11}{r['a']:>11}{pct(r['idx']):>9}{pct(r['libro']):>9}"
f"{pct(r['tp']):>8}{pct(r['skh']):>8}{pct(r['sks']):>8}{pct(r['skl']):>8}{r['esp']:>6.2f}"
f"{pct(r['info']):>8} {r['classe']}")
tab(m["episodi"], f"[3] LE {N_EPISODI} PEGGIORI FINESTRE DI {FINESTRA} GIORNI DELL'INDICE (non sovrapposte)")
tab(m["nominati"], "[4] EPISODI NOMINATI (elenco scritto prima di misurare)")
print("\n [5] I 5 PEGGIORI GIORNI DEL LIBRO (§33 su lente hourly: squeeze SKH01 2020-03-13; qui lente canonica: NON si riproduce, e' un crollo con TP01 mezzo long)")
print(f" {'giorno':<12}{'indice':>9}{'libro':>9}{'TP01':>9}{'SKH01':>9}")
for t, i, l, tpv, sk in m["peggiori"]:
print(f" {t:<12}{pct(i):>9}{pct(l):>9}{pct(tpv):>9}{pct(sk):>9}")
print("\n [6] LA GAMBA SHORT DI SKH01 (peso 0,25 gia' applicato) — contributo per anno al libro")
print(" " + " ".join(f"{y} {pct(v)}" for y, v in m["sks_anno"].items()))
s = m["sharpe"]; d = m["maxdd"]
print(f"\n Sharpe: libro {s['libro']:.2f} · TP01 {s['tp']:.2f} · SKH01 {s['skh']:.2f} "
f"(solo long {s['skl']:.2f} / solo short {s['sks']:.2f}) maxDD libro {d['libro']*100:.1f}% · indice {d['idx']*100:.1f}%")
print(f" riga INFORMATIVA 37,5/62,5 (§26, NON proposta): Sharpe {s['info']:.2f}, maxDD {d['info']*100:.1f}%, "
f"batte il 75/25 in {m['sintesi']['info_batte_live']}/{m['sintesi']['n_episodi']} episodi")
print("\n VERDETTO: " + verdetto(m))
print("=" * 100)
if __name__ == "__main__":
main()
+242
View File
@@ -0,0 +1,242 @@
"""r0909 — XRP COME TERZA GAMBA DEL BOOK DERIBIT (meccanismi CONGELATI): la misura che manca.
PERCHE' ESISTE. L'operatore chiede "piu' monete, ma sempre in Deribit". Letto il venue OGGI
(09/09/2026, `public/get_book_summary_by_currency`): dei 32 perpetual USDC-lineari, solo
QUATTRO hanno volume a 8 cifre BTC, ETH, **XRP ($38M/g, il PIU' scambiato del venue, spread
4 bps, listato 2022-03-16)** e SOL ($13M/g). Tutto il resto sta fra $0,01M e $7M al giorno.
SOL come terza gamba e' stato misurato il 22/08 e ESCLUSO dall'operatore (hold-out 0,166 in
0/24 ancore, guadagno di UN anno, il 2023). XRP non e' mai stato misurato come gamba
direzionale: e' l'unico candidato "piu' monete" del venue che sia liquido E non misurato.
IPOTESI A PRIORI, dichiarata prima di misurare: DILUISCE, come SOL il trend multi-asset del
19/06 fu scartato a corr 0,74 con TP01, e SOL ha confermato (corr 0,40-0,56, in salita col dato
che migliora). Se XRP aggiunge, il primo sospetto e' l'anno del suo evento idiosincratico (la
sentenza SEC dell'estate 2023 e la corsa di fine 2024): M9, "24/24 ancore positive possono
essere un anno solo".
METODO identico a `r0822_sol_leg.py`, da cui IMPORTA il harness (non riscritto): TP01 e
SKH01-V2-DD congelati, book a 2 vs 3 gambe, 24 ancore orarie, mediana delle differenze
APPAIATE (M7), null del de-levering a iso-maxDD (M5), scomposizione per anno (M9), due lenti
DICHIARATE PRIMA dalla certificazione del dato, non dal risultato:
- L-FULL: dal listing (2022-03-16);
- L-PULITA: dal primo anno in cui la quota di barre 1h a >1% da Coinbase USD scende sotto
lo 0,5% (SOL 2023 aveva 0,6% e fu giudicata sporca: e' il precedente che fissa la soglia).
Il dato XRP vive in `data/raw/alt_xrp_*.parquet` (namespace di ricerca, come SOL: fuori dal
feed attivo, NON rinfrescato dal cron, `load_data("XRP")` continua a fallire).
Nessun file di produzione toccato. Nessun ordine. Book/pesi/universo/cron INVARIATI.
"""
from __future__ import annotations
import json
import sys
import time
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
from r0822_sol_leg import ( # noqa: E402 — harness importato, non riscritto
DUE, HOLDOUT, W_SKH, W_TP, book, cagr, dati, k_iso_dd, maxdd, sh, skh_leg, taglia, tp01_leg,
)
ASSET = "XRP"
TRE = ("BTC", "ETH", ASSET)
LISTING = pd.Timestamp("2022-03-16", tz="UTC")
SOGLIA_PULITA = 0.005 # quota di barre 1h a >1% da Coinbase: sotto = anno pulito
CACHE = ROOT / "data" / "_cache" / "r0909_xrp_riferimento_1h.parquet"
# ------------------------------------------------------------------ certificazione (D1: Coinbase = audit)
def _fetch_1h(ex, symbol: str, start: pd.Timestamp, limit: int) -> pd.Series:
"""OHLCV 1h paginato in avanti; un batch vuoto NON ferma il ciclo (Coinbase ha un buco
2021-01 2023-07 su XRP: delistato dopo la causa SEC, rilistato dopo la sentenza)."""
out, since = [], int(start.timestamp() * 1000)
end = int(time.time() * 1000)
while since < end:
r = ex.fetch_ohlcv(symbol, "1h", since=since, limit=limit)
r = [x for x in r if x[0] >= since]
if not r:
since += limit * 3_600_000
continue
out.extend(r)
nxt = r[-1][0] + 3_600_000
since = nxt if nxt > since else since + limit * 3_600_000
df = pd.DataFrame(out, columns=["ts", "o", "h", "l", "close", "v"]).drop_duplicates("ts")
return pd.Series(df["close"].values.astype(float),
index=pd.to_datetime(df["ts"], unit="ms", utc=True)).sort_index()
def riferimento_1h(start: pd.Timestamp) -> pd.DataFrame:
"""Riferimento indipendente in USD (D1: audit, mai ancora): Coinbase dove c'e' (dal rilisting
2023-07), Bitstamp altrove entrambi gia' usati dal progetto come venue di audit. Con cache."""
if CACHE.exists():
df = pd.read_parquet(CACHE); df.index = pd.to_datetime(df.index, utc=True)
if len(df):
return df
import ccxt
bs = _fetch_1h(ccxt.bitstamp({"enableRateLimit": True}), "XRP/USD", start, 1000)
cb = _fetch_1h(ccxt.coinbase({"enableRateLimit": True}), "XRP/USD", start, 300)
df = pd.concat({"bitstamp": bs, "coinbase": cb}, axis=1).sort_index()
df["close"] = df["coinbase"].where(df["coinbase"].notna(), df["bitstamp"])
df["fonte"] = np.where(df["coinbase"].notna(), "CB", np.where(df["bitstamp"].notna(), "BS", ""))
CACHE.parent.mkdir(parents=True, exist_ok=True)
df.to_parquet(CACHE)
return df
def certifica() -> dict:
"""Per anno: quota di barre 1h a >1% da Coinbase, mediana in bps, quota di barre flat (1h e 5m)."""
d1 = dati(ASSET, "1h")
dz = pd.Series(d1["close"].values.astype(float), index=pd.to_datetime(d1["datetime"], utc=True))
ref = riferimento_1h(LISTING)
J = pd.concat({"d": dz, "c": ref["close"], "f": ref["fonte"]}, axis=1, join="inner").dropna(subset=["d", "c"])
dev = (J["d"] - J["c"]).abs() / J["c"]
d5 = dati(ASSET, "5m")
f5 = pd.Series((d5["high"].values == d5["low"].values), index=pd.to_datetime(d5["datetime"], utc=True))
f1 = pd.Series((d1["high"].values == d1["low"].values), index=dz.index)
per_anno = {}
for y in sorted(set(J.index.year)):
m = dev[dev.index.year == y]
fy = J["f"][J.index.year == y]
per_anno[int(y)] = dict(n=int(len(m)), q_1pct=float((m > 0.01).mean()), med_bps=float(m.median() * 1e4),
fonte=f"CB {float((fy == 'CB').mean())*100:.0f}% / BS {float((fy == 'BS').mean())*100:.0f}%",
flat_1h=float(f1[f1.index.year == y].mean()), flat_5m=float(f5[f5.index.year == y].mean()))
puliti = [y for y, r in per_anno.items() if r["q_1pct"] <= SOGLIA_PULITA]
# L-PULITA = dal primo anno pulito in poi SOLO se anche i successivi restano puliti (un anno pulito
# isolato non e' un regime); altrimenti dal primo della coda pulita finale.
anni = sorted(per_anno)
inizio = None
for y in anni:
if all(per_anno[z]["q_1pct"] <= SOGLIA_PULITA for z in anni if z >= y):
inizio = y; break
return dict(per_anno=per_anno, puliti=puliti,
pulita_da=pd.Timestamp(f"{inizio}-01-01", tz="UTC") if inizio else None)
# ------------------------------------------------------------------ misura (stesso schema di r0822_sol_leg)
def gamba(asset: str) -> pd.Series:
return W_TP * tp01_leg(asset, 0).reindex(skh_leg(asset).index).fillna(0.0) + W_SKH * skh_leg(asset)
def differenze(anchors, start) -> dict:
d = {"sh": [], "hold": [], "dd": [], "cagr": []}
for h in anchors:
A, B = book(DUE, h), book(TRE, h)
idx = A.index.intersection(B.index)
A, B = taglia(A.loc[idx], start), taglia(B.loc[idx], start)
if len(A) < 200:
continue
d["sh"].append(sh(B) - sh(A)); d["hold"].append(sh(taglia(B, HOLDOUT)) - sh(taglia(A, HOLDOUT)))
d["dd"].append((maxdd(B) - maxdd(A)) * 100); d["cagr"].append((cagr(B) - cagr(A)) * 100)
return {k: np.array(v, float) for k, v in d.items()}
def misura(n_anc: int = 24) -> dict:
anchors = list(range(0, 24, max(1, 24 // n_anc)))[:n_anc]
cert = certifica()
lenti = {"L-FULL": None}
if cert["pulita_da"] is not None:
lenti[f"L-PULITA ({cert['pulita_da'].year}+)"] = cert["pulita_da"]
lenti["2024+ (lente di SOL)"] = pd.Timestamp("2024-01-01", tz="UTC")
out = dict(cert=cert, lenti={}, gambe={}, anno={}, delev={}, corr={})
for a in TRE:
out["gambe"][f"TP01 {a}"] = tp01_leg(a, 0); out["gambe"][f"SKH01 {a}"] = skh_leg(a)
g = gamba(ASSET); b2 = book(DUE, 0)
com = g.index.intersection(b2.index)
for nome, start in lenti.items():
c = com if start is None else com[com >= start]
out["corr"][nome] = float(np.corrcoef(g.loc[c], b2.loc[c])[0, 1]) if len(c) > 100 else np.nan
out["lenti"][nome] = differenze(anchors, start)
A, B = book(DUE, 0), book(TRE, 0); idx = A.index.intersection(B.index)
A, B = taglia(A.loc[idx], start), taglia(B.loc[idx], start)
k = k_iso_dd(A, B)
out["delev"][nome] = dict(k=k, sh3=sh(B), sh2k=sh(k * A), cagr3=cagr(B), cagr2k=cagr(k * A))
A0, B0 = book(DUE, 0), book(TRE, 0); i0 = A0.index.intersection(B0.index); A0, B0 = A0.loc[i0], B0.loc[i0]
for y in sorted(set(A0.index.year)):
a_, b_ = A0[A0.index.year == y], B0[B0.index.year == y]
if len(a_) >= 60:
out["anno"][int(y)] = dict(sh2=sh(a_), sh3=sh(b_), dsh=sh(b_) - sh(a_), dcagr=(cagr(b_) - cagr(a_)) * 100,
sh_gamba=sh(g[g.index.year == y]))
out["gamba_xrp"] = g
out["n_anchors"] = len(anchors)
return out
def verdetto(m: dict) -> str:
"""AGGIUNGE solo se, nella lente PULITA (o 2024+ se non c'e' una coda pulita): dSharpe hold-out
mediano > 0 in 75% delle ancore, E il null del de-levering passa (Sh 3 gambe > Sh 2 gambe
riscalate + 0,02), E il contributo per anno e' positivo in piu' anni di quanti sia negativo
contati sugli anni DENTRO la lente (derivati dal suo nome, non cablati: P1).
Un campione vuoto e' "NON MISURABILE", non un verdetto (P5: "non vedo" non e' "va tutto bene")."""
nomi = [n for n in m["lenti"] if n.startswith("L-PULITA")] or ["2024+ (lente di SOL)"]
n = nomi[0]; d = m["lenti"][n]; dl = m["delev"][n]
import re
anno0 = int(re.search(r"(\d{4})\+", n).group(1)) # "L-PULITA (2024+)" / "2024+ (lente di SOL)"
if not len(d["hold"]) or not np.isfinite(dl["sh3"]) or not np.isfinite(dl["sh2k"]):
return f"XRP: NON MISURABILE — lente {n} senza ancore valide o de-levering non calcolabile"
c_hold = np.median(d["hold"]) > 0 and np.mean(d["hold"] > 0) >= 0.75
c_delev = dl["sh3"] > dl["sh2k"] + 0.02
anni = [r["dsh"] for y, r in m["anno"].items() if y >= anno0]
c_anni = sum(x > 0 for x in anni) > sum(x < 0 for x in anni)
esito = "XRP AGGIUNGE" if (c_hold and c_delev and c_anni) else ("XRP DILUISCE" if (not c_hold and not c_delev) else "XRP: PARI / NON SELEZIONABILE")
return (f"{esito} — lente {n}: dSharpe hold-out mediano {np.median(d['hold']):+.3f} "
f"(>0 nel {np.mean(d['hold'] > 0) * 100:.0f}% di {len(d['hold'])} ancore); "
f"de-levering: Sh 3 gambe {dl['sh3']:.2f} vs 2 gambe×{dl['k']:.3f} {dl['sh2k']:.2f}; "
f"anni {anno0}+ con dSh>0: {sum(x > 0 for x in anni)}/{len(anni)}")
def main() -> None:
n_anc = int(sys.argv[sys.argv.index("--anchors") + 1]) if "--anchors" in sys.argv else 24
print("=" * 104)
print(" r0909 — XRP COME TERZA GAMBA DEL BOOK DERIBIT (meccanismi CONGELATI; harness di r0822_sol_leg)")
print("=" * 104)
print(" ipotesi a priori dichiarata: DILUISCE (come SOL; trend multi-asset 19/06 corr 0,74)")
m = misura(n_anc)
c = m["cert"]
print(f"\n [0/5] CERTIFICAZIONE XRP vs Coinbase USD (1h) — soglia anno pulito: quota >1% ≤ {SOGLIA_PULITA*100:.1f}%")
print(f" {'anno':<6}{'barre':>7}{'>1%':>8}{'med bps':>9}{'flat 1h':>9}{'flat 5m':>9} riferimento")
for y, r in c["per_anno"].items():
print(f" {y:<6}{r['n']:>7}{r['q_1pct']*100:>7.2f}%{r['med_bps']:>9.1f}{r['flat_1h']*100:>8.1f}%{r['flat_5m']*100:>8.1f}% {r['fonte']}")
print(f" L-PULITA da: {c['pulita_da'].date() if c['pulita_da'] is not None else 'NESSUN anno pulito in coda'}")
print(f"\n [1/5] LE GAMBE DA SOLE (ancora canonica h=0)")
print(f"\n {'gamba':<26}{'barre':>7}{'Sharpe':>9}{'maxDD':>10}{'CAGR':>9}{'Sh hold':>10}")
for k, s in m["gambe"].items():
print(f" {k:<26}{len(s):>7}{sh(s):>9.2f}{maxdd(s)*100:>9.1f}%{cagr(s)*100:>8.1f}%{sh(taglia(s, HOLDOUT)):>10.2f}")
g = m["gamba_xrp"]
print(f"\n [2/5] LA GAMBA XRP (75/25) — Sharpe {sh(g):.2f}, maxDD {maxdd(g)*100:.1f}%, CAGR {cagr(g)*100:.1f}%, hold-out {sh(taglia(g, HOLDOUT)):.2f}")
for n, v in m["corr"].items():
print(f" corr(gamba XRP, book BTC/ETH) {n}: {v:+.3f} (SOL: +0,40 / +0,56; soglia del 19/06: 0,74)")
print(f"\n [3/5] BOOK A 2 vs 3 GAMBE — differenze APPAIATE per ancora ({m['n_anchors']} ancore)")
for n, d in m["lenti"].items():
if not len(d["sh"]):
print(f"\n {n}: campione insufficiente"); continue
print(f"\n {n} ({len(d['sh'])} ancore)")
for lab, v, u in (("dSharpe FULL", d["sh"], ""), ("dSharpe hold-out", d["hold"], ""), ("dMaxDD", d["dd"], " pp"), ("dCAGR", d["cagr"], " pp")):
print(f" {lab:<18} mediana {np.median(v):+7.3f}{u} banda [{np.percentile(v, 10):+.3f}, {np.percentile(v, 90):+.3f}] >0 nel {np.mean(v > 0)*100:.0f}% delle ancore")
print(f"\n [3bis] PER ANNO (h=0): {'anno':<6}{'2g Sh':>7}{'3g Sh':>7}{'dSh':>7}{'dCAGR':>9}{'gamba XRP':>11}")
for y, r in m["anno"].items():
print(f" {'':<19}{y:<6}{r['sh2']:>7.2f}{r['sh3']:>7.2f}{r['dsh']:>+7.2f}{r['dcagr']:>+8.1f}pp{r['sh_gamba']:>+11.2f}")
print(f"\n [4/5] NULL DEL DE-LEVERING (iso-maxDD): {'lente':<24}{'k':>7}{'Sh 3g':>8}{'Sh 2g×k':>9}{'CAGR 3g':>9}{'CAGR 2g×k':>11}")
for n, dl in m["delev"].items():
v = "AGGIUNGE" if dl["sh3"] > dl["sh2k"] + 0.02 else ("de-levering" if dl["sh2k"] > dl["sh3"] + 0.02 else "pari")
print(f" {'':<39}{n:<24}{dl['k']:>7.3f}{dl['sh3']:>8.2f}{dl['sh2k']:>9.2f}{dl['cagr3']*100:>8.1f}%{dl['cagr2k']*100:>10.1f}% {v}")
px = float(dati(ASSET, "1h")["close"].iloc[-1])
print(f"\n [5/5] ESEGUIBILITA': XRP_USDC-PERPETUAL min 1 XRP = ${px:.2f} < pavimento min_order $5 — non e' il vincolo (come SOL)")
print("\n VERDETTO: " + verdetto(m))
print("=" * 104)
if __name__ == "__main__":
main()
+298
View File
@@ -0,0 +1,298 @@
#!/usr/bin/env python
"""r0910_macro_riskoff.py — «risk-off» di TP01 nella finestra ±2h intorno a FOMC e CPI: vale qualcosa?
DOMANDA (operatore, 2026-09-10, da un articolo su EA forex con «agente fondamentale»): se TP01 si
mettesse FLAT nelle due ore prima e dopo la dichiarazione FOMC (14:00 ET) e la release CPI
(08:30 ET), guadagnerebbe Sharpe o drift? E' un risk-off, quindi per la regola di §3 («non
de-esporre il weekend») PARTE REFUTED e deve superare il null del de-levering (M5) e un null
location-matched (M18) prima di essere creduto.
VERDETTO PRE-REGISTRATO (scritto PRIMA di vedere un numero):
LEAD solo se TUTTE: (a) ΔSharpe(riskoff base) > 0 con le fee di chiusura/riapertura;
(b) il Δ vero sta al 95° percentile del null location-matched (stesso numero di eventi per
anno, stesse ore del giorno, giorni estratti a caso); (c) Δ positivo in 70% degli anni con 5
eventi (M9). Altrimenti REFUTED. E l'effetto si cita anche in €/giorno all'equity di oggi.
COSA NON C'E' (dichiarato): il funding (mai in nessun backtest, 2,16%/anno di drift) irrilevante
qui perche' identico nelle due varianti salvo ~1% delle ore; il rimbalzo di prezzo nella finestra
(slippage: TP01 chiude e riapre a mid, in un'ora in cui lo spread e' largo se il risultato fosse
positivo andrebbe rimisurato col costo vero della finestra, C1). SKH01 non e' qui: e' un breakout su
barra 230m, appiattirlo cambierebbe il percorso del segnale.
CALENDARIO (letto dal web il 2026-09-10, non ricordato regola globale):
FOMC: federalreserve.gov/monetarypolicy/fomccalendars.htm (2021-2026) e fomchistorical2019/2020.
Ore: 14:00 ET per le riunioni programmate (standard Fed); le due d'emergenza del 2020
(03/03 10:00 ET, 15/03 17:00 ET) con la loro ora; i «notation vote» ESCLUSI (nessuna
dichiarazione a orario). ETUTC con America/New_York (DST).
CPI: bls.gov/bls/news-release/cpi.htm (archivio 2019-2026), 08:30 ET (bls.gov/schedule).
L'archivio raggruppa per anno di RIFERIMENTO: le release di gennaio/febbraio stanno sotto
l'anno prima — corrette qui (+1 anno), e la correzione e' verificata sul 2026 (13/01 e
13/02 stanno nella schedule ufficiale 2026).
TP01 ORARIO: posizione decisa alla chiusura giornaliera (00:00 UTC) e tenuta per le 24 barre 1h del
giorno (la stessa `held` di sleeves._tp01_returns, spalmata sulle ore); P&L orario = held × rendimento
1h; fee sul turnover giornaliero. RISK-OFF: held = 0 nelle barre 1h che intersecano [t2h, t+2h],
con fee_side su chiusura e riapertura (|held| × 2 × fee).
uv run python scripts/research/r0910_macro_riskoff.py
"""
from __future__ import annotations
import sys
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from src.data.downloader import load_data # noqa: E402
from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio, resample_1d # noqa: E402
ASSETS = ("BTC", "ETH")
ET = ZoneInfo("America/New_York")
FINESTRA_H = 2.0
N_NULL = 1000
SEED = 20260910
EQUITY_OGGI = 4454.83 # trades_db --report 10/09 14:21Z, per il €/giorno
EURUSD = 1.08 # ordine di grandezza, dichiarato: serve solo per la riga in €/g
# ------------------------------------------------------------------ calendario
FOMC_PROGRAMMATE = """
2019-01-30 2019-03-20 2019-05-01 2019-06-19 2019-07-31 2019-09-18 2019-10-30 2019-12-11
2020-01-29 2020-04-29 2020-06-10 2020-07-29 2020-09-16 2020-11-05 2020-12-16
2021-01-27 2021-03-17 2021-04-28 2021-06-16 2021-07-28 2021-09-22 2021-11-03 2021-12-15
2022-01-26 2022-03-16 2022-05-04 2022-06-15 2022-07-27 2022-09-21 2022-11-02 2022-12-14
2023-02-01 2023-03-22 2023-05-03 2023-06-14 2023-07-26 2023-09-20 2023-11-01 2023-12-13
2024-01-31 2024-03-20 2024-05-01 2024-06-12 2024-07-31 2024-09-18 2024-11-07 2024-12-18
2025-01-29 2025-03-19 2025-05-07 2025-06-18 2025-07-30 2025-09-17 2025-10-29 2025-12-10
2026-01-28 2026-03-18 2026-04-29 2026-06-17 2026-07-29
""".split()
FOMC_EMERGENZA = [("2020-03-03", "10:00"), ("2020-03-15", "17:00")]
# archivio BLS, ordine della pagina (per anno di riferimento): le release di gen/feb vanno +1 anno
CPI_ARCHIVIO = """
2026-08-12 2026-07-14 2026-06-10 2026-05-12 2026-04-10 2026-03-11 2026-02-13
2025-01-13 2025-12-18 2025-10-24 2025-09-11 2025-08-12 2025-07-15 2025-06-11 2025-05-13 2025-04-10 2025-03-12 2025-02-12
2024-01-15 2024-12-11 2024-11-13 2024-10-10 2024-09-11 2024-08-14 2024-07-11 2024-06-12 2024-05-15 2024-04-10 2024-03-12 2024-02-13
2023-01-11 2023-12-12 2023-11-14 2023-10-12 2023-09-13 2023-08-10 2023-07-12 2023-06-13 2023-05-10 2023-04-12 2023-03-14
2022-02-14 2022-01-12 2022-12-13 2022-11-10 2022-10-13 2022-09-13 2022-08-10 2022-07-13 2022-06-10 2022-05-11 2022-04-12 2022-03-10
2021-02-10 2021-01-12 2021-12-10 2021-11-10 2021-10-13 2021-09-14 2021-08-11 2021-07-13 2021-06-10 2021-05-12 2021-04-13 2021-03-10
2020-02-10 2020-01-13 2020-12-10 2020-11-12 2020-10-13 2020-09-11 2020-08-12 2020-07-14 2020-06-10 2020-05-12 2020-04-10 2020-03-11
2019-02-13 2019-01-14 2019-12-11 2019-11-13 2019-10-10 2019-09-12 2019-08-13 2019-07-11 2019-06-12 2019-05-10 2019-04-10 2019-03-12
""".split()
def cpi_date_corrette(seq: list[str]) -> list[str]:
"""Una data di gen/feb che PRECEDE (nell'ordine della pagina, discendente) una data di
marzo-dicembre dello STESSO anno e' la release dell'anno dopo."""
out = []
for i, d in enumerate(seq):
y, m = int(d[:4]), int(d[5:7])
if m <= 2:
j = i + 1
while j < len(seq) and int(seq[j][5:7]) <= 2:
j += 1
if j < len(seq) and int(seq[j][:4]) == y:
d = f"{y + 1}{d[4:]}"
out.append(d)
return sorted(set(out))
def eventi() -> pd.DataFrame:
rows = []
for d in FOMC_PROGRAMMATE:
rows.append(("FOMC", datetime.fromisoformat(f"{d} 14:00").replace(tzinfo=ET)))
for d, hm in FOMC_EMERGENZA:
rows.append(("FOMC", datetime.fromisoformat(f"{d} {hm}").replace(tzinfo=ET)))
for d in cpi_date_corrette(CPI_ARCHIVIO):
rows.append(("CPI", datetime.fromisoformat(f"{d} 08:30").replace(tzinfo=ET)))
ev = pd.DataFrame(rows, columns=["tipo", "t_et"])
ev["t"] = ev["t_et"].map(lambda x: pd.Timestamp(x.astimezone(ZoneInfo("UTC"))))
return ev.sort_values("t").reset_index(drop=True)
# ------------------------------------------------------------------ TP01 orario
def tp01_orario() -> pd.DataFrame:
"""-> DataFrame orario con held_BTC, held_ETH, r_BTC, r_ETH, fee_day (fee del turnover
giornaliero, sulla prima barra del giorno)."""
tp = TrendPortfolio(**CANONICAL)
parts = {}
for a in ASSETS:
h = load_data(a, "1h")
hidx = pd.to_datetime(h["datetime"], utc=True)
r1h = pd.Series(h["close"].values.astype(float), index=hidx).pct_change().fillna(0.0)
d = resample_1d(h)
tgt = tp.target_series(d)
held = np.zeros(len(tgt)); held[1:] = tgt[:-1]
didx = pd.to_datetime(d["datetime"], utc=True).dt.floor("D")
held_d = pd.Series(held, index=didx)
turn_d = pd.Series(np.abs(np.diff(held, prepend=0.0)) * tp.fee_side, index=didx)
day_of_h = hidx.dt.floor("D")
held_h = held_d.reindex(day_of_h).values
fee_h = np.where(hidx.dt.hour.values == 0, turn_d.reindex(day_of_h).values, 0.0)
parts[a] = pd.DataFrame({f"held_{a}": held_h, f"r_{a}": r1h.values, f"fee_{a}": fee_h}, index=hidx)
J = pd.concat(parts.values(), axis=1, join="inner").dropna()
return J
def pnl(J: pd.DataFrame, flat_mask: np.ndarray | None, fee_side: float) -> pd.Series:
"""Rendimento orario del portafoglio 50/50. `flat_mask` True = barra a esposizione zero
(con fee di chiusura all'ingresso nella finestra e di riapertura all'uscita)."""
out = np.zeros(len(J))
for a in ASSETS:
held = J[f"held_{a}"].values.copy()
fee = J[f"fee_{a}"].values.copy()
if flat_mask is not None:
h0 = held.copy()
held = np.where(flat_mask, 0.0, held)
# transizioni dentro/fuori finestra: |Δheld| × fee, oltre al turnover giornaliero
extra = np.abs(np.diff(held, prepend=held[0])) - np.abs(np.diff(h0, prepend=h0[0]))
fee = fee + np.clip(extra, 0, None) * fee_side
out += 0.5 * (held * J[f"r_{a}"].values - fee)
return pd.Series(out, index=J.index)
def maschera(J: pd.DataFrame, tempi: pd.Series, w_h: float = FINESTRA_H) -> np.ndarray:
"""Barre 1h (open-labeled) che intersecano [tw, t+w] per almeno un evento."""
idx = J.index.values.astype("datetime64[ns]")
m = np.zeros(len(J), dtype=bool)
w = np.timedelta64(int(w_h * 3600), "s")
one_h = np.timedelta64(3600, "s")
for t in tempi:
t64 = np.datetime64(pd.Timestamp(t).tz_convert("UTC").tz_localize(None))
lo, hi = np.searchsorted(idx, t64 - w - one_h, side="right"), np.searchsorted(idx, t64 + w, side="left")
m[lo:hi] = True
return m
def sharpe_h(s: pd.Series) -> float:
return float(s.mean() / s.std() * np.sqrt(24 * 365.25)) if s.std() > 0 else float("nan")
def sharpe_d(s: pd.Series) -> float:
d = s.groupby(s.index.floor("D")).sum()
return float(d.mean() / d.std() * np.sqrt(365.25)) if d.std() > 0 else float("nan")
def drift_annuo(s: pd.Series) -> float:
return float(s.mean() * 24 * 365.25)
# ------------------------------------------------------------------ misura
def misura() -> dict:
J = tp01_orario()
fee_side = TrendPortfolio(**CANONICAL).fee_side
ev = eventi()
ev = ev[(ev["t"] >= J.index[0]) & (ev["t"] <= J.index[-1])].reset_index(drop=True)
base = pnl(J, None, fee_side)
m_all = maschera(J, ev["t"])
ro = pnl(J, m_all, fee_side)
ro_nofee = pnl(J, m_all, 0.0)
out = dict(n_ore=len(J), da=str(J.index[0]), a=str(J.index[-1]), n_ev=len(ev),
n_fomc=int((ev.tipo == "FOMC").sum()), n_cpi=int((ev.tipo == "CPI").sum()),
ore_finestra=int(m_all.sum()), quota_ore=float(m_all.mean()),
base=dict(sh_d=sharpe_d(base), sh_h=sharpe_h(base), drift=drift_annuo(base)),
riskoff=dict(sh_d=sharpe_d(ro), sh_h=sharpe_h(ro), drift=drift_annuo(ro)),
riskoff_nofee=dict(sh_d=sharpe_d(ro_nofee), drift=drift_annuo(ro_nofee)))
out["d_sh_d"] = out["riskoff"]["sh_d"] - out["base"]["sh_d"]
out["d_drift"] = out["riskoff"]["drift"] - out["base"]["drift"]
# cosa succede DENTRO la finestra (il P&L che il risk-off rinuncia)
dentro = base[m_all]; fuori = base[~m_all]
esposto = (J.loc[m_all, [f"held_{a}" for a in ASSETS]].abs().sum(axis=1) > 0)
out["dentro"] = dict(n=int(m_all.sum()), n_esposte=int(esposto.sum()), media_h=float(dentro.mean()),
t=float(dentro.mean() / dentro.std() * np.sqrt(len(dentro))) if dentro.std() > 0 else float("nan"),
somma=float(dentro.sum()), media_fuori_h=float(fuori.mean()))
# per tipo
out["per_tipo"] = {}
for tipo in ("FOMC", "CPI"):
m = maschera(J, ev.loc[ev.tipo == tipo, "t"])
r = pnl(J, m, fee_side)
out["per_tipo"][tipo] = dict(n=int((ev.tipo == tipo).sum()), d_sh_d=sharpe_d(r) - out["base"]["sh_d"],
d_drift=drift_annuo(r) - out["base"]["drift"], somma_dentro=float(base[m].sum()))
# per anno (M9)
anni = {}
for y, g in base.groupby(base.index.year):
my = m_all[base.index.year == y]
ry = ro[base.index.year == y]
ney = int((ev["t"].dt.year == y).sum())
anni[int(y)] = dict(n_ev=ney, d_sh_d=sharpe_d(ry) - sharpe_d(g), somma_dentro=float(g[my].sum()),
d_cum=float((1 + ry).prod() - (1 + g).prod()))
out["anni"] = anni
# null location-matched (M18): stessi conteggi per anno e stesse ORE del giorno, giorni a caso
rng = np.random.default_rng(SEED)
giorni_per_anno = {y: np.array(sorted(set(J.index[J.index.year == y].normalize()))) for y in J.index.year.unique()}
d_null = np.empty(N_NULL)
for k in range(N_NULL):
t_fake = []
for y, g in ev.groupby(ev["t"].dt.year):
pool = giorni_per_anno.get(int(y))
if pool is None or len(pool) == 0:
continue
days = rng.choice(pool, size=len(g), replace=False)
for d, t in zip(days, g["t"]):
t_fake.append(pd.Timestamp(d) + (t - t.normalize()))
mk = maschera(J, pd.Series(t_fake))
d_null[k] = sharpe_d(pnl(J, mk, fee_side)) - out["base"]["sh_d"]
out["null"] = dict(n=N_NULL, media=float(d_null.mean()), p50=float(np.median(d_null)),
p95=float(np.percentile(d_null, 95)), p05=float(np.percentile(d_null, 5)),
pctl_vero=float((d_null < out["d_sh_d"]).mean() * 100))
# €/giorno all'equity di oggi
out["eur_giorno"] = out["d_drift"] * EQUITY_OGGI / EURUSD / 365.25
return out
def verdetto(m: dict) -> tuple[str, list[str]]:
a = m["d_sh_d"] > 0
b = m["null"]["pctl_vero"] >= 95.0
anni_ok = [y for y, v in m["anni"].items() if v["n_ev"] >= 5]
pos = sum(1 for y in anni_ok if m["anni"][y]["d_sh_d"] > 0)
c = anni_ok and pos / len(anni_ok) >= 0.70
righe = [f"(a) ΔSharpe giornaliero con fee > 0: {m['d_sh_d']:+.3f}{'PASS' if a else 'FAIL'}",
f"(b) Δ vero al ≥95° pctl del null location-matched: {m['null']['pctl_vero']:.1f}° → {'PASS' if b else 'FAIL'}",
f"(c) Δ > 0 in ≥70% degli anni con ≥5 eventi: {pos}/{len(anni_ok)}{'PASS' if c else 'FAIL'}"]
return ("LEAD" if (a and b and c) else "REFUTED"), righe
def main() -> int:
print("=" * 88)
print(" r0910 — RISK-OFF ±2h intorno a FOMC e CPI su TP01 (lente oraria, posizione giornaliera)")
print("=" * 88)
m = misura()
print(f" ore: {m['n_ore']} {m['da'][:10]}{m['a'][:10]} eventi: {m['n_ev']} "
f"(FOMC {m['n_fomc']}, CPI {m['n_cpi']}) ore in finestra: {m['ore_finestra']} = {m['quota_ore']:.2%}")
print(f"\n {'':14s} {'Sh giorn.':>10s} {'Sh orario':>10s} {'drift/anno':>11s}")
for k in ("base", "riskoff"):
v = m[k]; print(f" {k:14s} {v['sh_d']:>10.3f} {v['sh_h']:>10.3f} {v['drift']:>+11.2%}")
v = m["riskoff_nofee"]; print(f" {'riskoff nofee':14s} {v['sh_d']:>10.3f} {'':>10s} {v['drift']:>+11.2%}")
print(f"\n Δ riskoff base: Sharpe giornaliero {m['d_sh_d']:+.3f} · drift {m['d_drift']:+.2%}/anno "
f"· ≈ {m['eur_giorno']:+.3f} €/giorno a ${EQUITY_OGGI:,.0f}")
d = m["dentro"]
print(f" dentro la finestra: {d['n']} ore ({d['n_esposte']} esposte), media {d['media_h']*1e4:+.2f} bp/h "
f"(fuori {d['media_fuori_h']*1e4:+.2f} bp/h), t = {d['t']:+.2f}, somma {d['somma']:+.2%}")
print("\n per tipo:")
for t, v in m["per_tipo"].items():
print(f" {t:5s} n={v['n']:3d} ΔSh {v['d_sh_d']:+.3f} Δdrift {v['d_drift']:+.2%} P&L dentro {v['somma_dentro']:+.2%}")
print("\n per anno (M9):")
print(f" {'anno':>4s} {'ev':>3s} {'ΔSh':>7s} {'P&L dentro':>11s} {'Δcum':>8s}")
for y, v in m["anni"].items():
print(f" {y:>4d} {v['n_ev']:>3d} {v['d_sh_d']:>+7.3f} {v['somma_dentro']:>+11.2%} {v['d_cum']:>+8.2%}")
n = m["null"]
print(f"\n null location-matched (M18, {n['n']} estrazioni): ΔSh mediana {n['p50']:+.3f} "
f"[p05 {n['p05']:+.3f}, p95 {n['p95']:+.3f}] — il Δ vero sta al {n['pctl_vero']:.1f}° percentile")
v, righe = verdetto(m)
print("\n VERDETTO PRE-REGISTRATO:")
for r in righe:
print(" " + r)
print(f"\n{v}")
print("\n non modellato: funding (identico nelle due varianti salvo ~1% delle ore), slippage della "
"finestra (spread largo: se il risultato fosse positivo andrebbe rimisurato col costo vero, C1).")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+80 -8
View File
@@ -25,6 +25,12 @@ from src.live import tradesdb as T
PROJECT_ROOT = Path(__file__).resolve().parents[2] PROJECT_ROOT = Path(__file__).resolve().parents[2]
JOURNAL_DIR = PROJECT_ROOT / "docs" / "journal" JOURNAL_DIR = PROJECT_ROOT / "docs" / "journal"
# Movimenti di capitale DICHIARATI dall'operatore (append-only, dentro il perimetro di backup —
# P11). Servono per i movimenti che il rilevatore non puo' vedere: sotto `EQUITY_JUMP_ALERT`
# (il versamento di prova da ~$25 del 2026-08-25 08:00Z, +3,3% su $647, restato nel P&L per
# 12 giorni) o dentro un'ora di mercato non misurabile. Una riga per movimento:
# {"ts_utc": "...", "delta_usd": 24.9, "banda_usd": 0.5, "nota": "...", "dichiarato_il": "..."}
MOVIMENTI_DICHIARATI = PROJECT_ROOT / "data" / "live" / "movimenti_dichiarati.jsonl"
ASSETS = ("BTC", "ETH") ASSETS = ("BTC", "ETH")
# Gli orizzonti del segnale TP01 in produzione (trend_portfolio: TSMOM 30/90/180 giorni). # Gli orizzonti del segnale TP01 in produzione (trend_portfolio: TSMOM 30/90/180 giorni).
ORIZZONTI = (30, 90, 180) ORIZZONTI = (30, 90, 180)
@@ -118,6 +124,33 @@ def _px_1h(asset: str) -> pd.Series | None:
return None return None
def movimenti_dichiarati(path: Path | None = None) -> tuple[list[dict], list[str]]:
"""Righe di `MOVIMENTI_DICHIARATI` -> (movimenti validi, errori di riga). Mai solleva.
Una riga rotta non azzera le altre e NON sparisce: torna in `errori` (P3 si registra nel
punto in cui si ingoia)."""
path = MOVIMENTI_DICHIARATI if path is None else path
if not path.exists():
return [], []
validi, errori = [], []
for n, riga in enumerate(path.read_text().splitlines(), 1):
if not riga.strip():
continue
try:
d = json.loads(riga)
ts = pd.Timestamp(d["ts_utc"])
if ts.tzinfo is None:
raise ValueError("ts_utc senza fuso")
delta = float(d["delta_usd"])
if delta == 0:
raise ValueError("delta_usd zero")
validi.append(dict(ts=ts, delta=delta, banda=float(d.get("banda_usd") or 0.0),
nota=str(d.get("nota") or ""), dichiarato_il=d.get("dichiarato_il")))
except Exception as e: # noqa: BLE001 — la riga rotta si riporta
errori.append(f"{path.name}:{n}: {type(e).__name__}: {e}")
validi.sort(key=lambda d: d["ts"])
return validi, errori
def movimenti_capitale(con, fino_ts: str, rows=None) -> dict: def movimenti_capitale(con, fino_ts: str, rows=None) -> dict:
"""Salti di equity fra letture consecutive: versamenti e prelievi, da scorporare dal P&L. """Salti di equity fra letture consecutive: versamenti e prelievi, da scorporare dal P&L.
@@ -137,6 +170,13 @@ def movimenti_capitale(con, fino_ts: str, rows=None) -> dict:
LIMITE DICHIARATO (D5): un movimento sotto soglia (es. $150 su un conto da $2.000) LIMITE DICHIARATO (D5): un movimento sotto soglia (es. $150 su un conto da $2.000)
non si distingue dal mercato e RESTA nel P&L. E' lo stesso limite del rilevatore live. non si distingue dal mercato e RESTA nel P&L. E' lo stesso limite del rilevatore live.
Il rimedio NON e' abbassare la soglia (ogni ora di mercato diventerebbe un candidato): e'
la DICHIARAZIONE dell'operatore in `MOVIMENTI_DICHIARATI` — l'unica fonte che sa l'importo.
Un movimento dichiarato e' `movimento` con `fonte="dichiarato"`, l'importo e' quello
dichiarato (non il salto di equity), e `dopo` = `prima` + importo: il P&L di mercato di
quell'ora resta nel rendimento invece di uscire con il movimento. Se coincide con un salto
rilevato, l'importo dichiarato VINCE sul salto (fonte "dichiarato+rilevato"); un movimento
dichiarato fuori dalle letture, o dopo `fino_ts`, non entra e finisce in `avvisi`.
""" """
from src.live import book as _book # lazy: prende la soglia viva, anche monkeypatchata from src.live import book as _book # lazy: prende la soglia viva, anche monkeypatchata
# `rows` si puo' passare gia' letto: due SELECT sulla stessa tabella a un'ora del cron # `rows` si puo' passare gia' letto: due SELECT sulla stessa tabella a un'ora del cron
@@ -147,8 +187,9 @@ def movimenti_capitale(con, fino_ts: str, rows=None) -> dict:
soglia = float(_book.EQUITY_JUMP_ALERT) soglia = float(_book.EQUITY_JUMP_ALERT)
candidati = [(a, b) for a, b in zip(rows, rows[1:]) candidati = [(a, b) for a, b in zip(rows, rows[1:])
if a["equity"] and abs(b["equity"] - a["equity"]) / a["equity"] >= soglia] if a["equity"] and abs(b["equity"] - a["equity"]) / a["equity"] >= soglia]
if not candidati: dichiarati, avvisi = movimenti_dichiarati()
return dict(eventi=[], certi=0.0, ambigui=0.0, soglia=soglia) if not candidati and not dichiarati:
return dict(eventi=[], certi=0.0, ambigui=0.0, soglia=soglia, avvisi=avvisi)
try: try:
cfg = json.loads((PROJECT_ROOT / "config" / "live.json").read_text()) cfg = json.loads((PROJECT_ROOT / "config" / "live.json").read_text())
# la leva lorda massima che il libro puo' tenere: frac x n_asset x scala, mai oltre il # la leva lorda massima che il libro puo' tenere: frac x n_asset x scala, mai oltre il
@@ -159,7 +200,7 @@ def movimenti_capitale(con, fino_ts: str, rows=None) -> dict:
float(_book.LEVA_LORDA_MAX)) float(_book.LEVA_LORDA_MAX))
except Exception: except Exception:
leva_tetto = float(_book.LEVA_LORDA_MAX) # config illeggibile: il tetto di codice leva_tetto = float(_book.LEVA_LORDA_MAX) # config illeggibile: il tetto di codice
px = {a: _px_1h(a) for a in ASSETS} px = {a: _px_1h(a) for a in ASSETS} if candidati else {}
eventi = [] eventi = []
for prev, cur in candidati: for prev, cur in candidati:
t0, t1 = pd.Timestamp(prev["ts_utc"]), pd.Timestamp(cur["ts_utc"]) t0, t1 = pd.Timestamp(prev["ts_utc"]), pd.Timestamp(cur["ts_utc"])
@@ -192,11 +233,38 @@ def movimenti_capitale(con, fino_ts: str, rows=None) -> dict:
prima=float(prev["equity"]), dopo=float(cur["equity"]), prima=float(prev["equity"]), dopo=float(cur["equity"]),
delta=delta, pct=float(pct), delta=delta, pct=float(pct),
mercato_max_pct=(None if max_mkt is None else float(max_mkt)), mercato_max_pct=(None if max_mkt is None else float(max_mkt)),
mercato_nota=nota, classe=classe)) mercato_nota=nota, classe=classe, fonte="rilevato"))
# --- dichiarati dall'operatore: l'importo lo sa solo lui ------------------------------
ts_rows = [pd.Timestamp(r["ts_utc"]) for r in rows]
for d in dichiarati:
if not rows or d["ts"] <= ts_rows[0] or d["ts"] > ts_rows[-1]:
avvisi.append(f"movimento dichiarato {d['ts']:%Y-%m-%dT%H:%M}Z ${d['delta']:+,.2f} "
f"fuori dalle letture [{rows[0]['ts_utc'][:16] if rows else '-'} .. "
f"{rows[-1]['ts_utc'][:16] if rows else '-'}]: non applicato")
continue
i = next(k for k in range(1, len(rows)) if ts_rows[k - 1] < d["ts"] <= ts_rows[k])
prev, cur = rows[i - 1], rows[i]
nota_d = (f"dichiarato dall'operatore"
+ (f" il {d['dichiarato_il']}" if d.get("dichiarato_il") else "")
+ (f": {d['nota']}" if d["nota"] else "")
+ (f" (banda ±${d['banda']:,.2f})" if d["banda"] else ""))
gia = next((e for e in eventi if e["ts_dopo"] == cur["ts_utc"]), None)
if gia is not None: # rilevato E dichiarato: l'importo e' quello dichiarato
gia.update(delta=d["delta"], pct=d["delta"] / float(prev["equity"]) if prev["equity"] else None,
classe="movimento", fonte="dichiarato+rilevato",
dopo=float(prev["equity"]) + d["delta"], dichiarazione=nota_d)
continue
eventi.append(dict(ts_prima=prev["ts_utc"], ts_dopo=cur["ts_utc"],
prima=float(prev["equity"]), dopo=float(prev["equity"]) + d["delta"],
delta=d["delta"],
pct=(d["delta"] / float(prev["equity"]) if prev["equity"] else None),
mercato_max_pct=None, mercato_nota=None, classe="movimento",
fonte="dichiarato", dichiarazione=nota_d))
eventi.sort(key=lambda e: e["ts_dopo"])
return dict(eventi=eventi, return dict(eventi=eventi,
certi=float(sum(e["delta"] for e in eventi if e["classe"] == "movimento")), certi=float(sum(e["delta"] for e in eventi if e["classe"] == "movimento")),
ambigui=float(sum(e["delta"] for e in eventi if e["classe"] == "ambiguo")), ambigui=float(sum(e["delta"] for e in eventi if e["classe"] == "ambiguo")),
soglia=soglia, leva_tetto=leva_tetto) soglia=soglia, leva_tetto=leva_tetto, avvisi=avvisi)
def rendimento_twr(con, fino_ts: str) -> dict: def rendimento_twr(con, fino_ts: str) -> dict:
@@ -241,7 +309,7 @@ def rendimento_twr(con, fino_ts: str) -> dict:
motivo="base di equity nulla: il primo versamento non e' classificabile", motivo="base di equity nulla: il primo versamento non e' classificabile",
segmenti=[], eventi=mv["eventi"], certi=mv["certi"], ambigui=mv["ambigui"], segmenti=[], eventi=mv["eventi"], certi=mv["certi"], ambigui=mv["ambigui"],
delta_equity=e1 - e0, letture=len(rows)) delta_equity=e1 - e0, letture=len(rows))
non_mis = [e for e in mv["eventi"] if e.get("mercato_nota")] non_mis = [e for e in mv["eventi"] if e.get("mercato_nota") and e["classe"] != "movimento"]
if non_mis: if non_mis:
# un salto oltre soglia che NON si e' potuto confrontare col mercato non e' ne' un # un salto oltre soglia che NON si e' potuto confrontare col mercato non e' ne' un
# movimento ne' un rendimento: il TWR non si stampa (altrimenti `e1/e0-1` tornerebbe # movimento ne' un rendimento: il TWR non si stampa (altrimenti `e1/e0-1` tornerebbe
@@ -266,7 +334,7 @@ def rendimento_twr(con, fino_ts: str) -> dict:
twr *= 1.0 + sg["ret"] twr *= 1.0 + sg["ret"]
return dict(twr=twr - 1.0, motivo=None, segmenti=segmenti, eventi=mv["eventi"], return dict(twr=twr - 1.0, motivo=None, segmenti=segmenti, eventi=mv["eventi"],
certi=mv["certi"], ambigui=mv["ambigui"], delta_equity=e1 - e0, certi=mv["certi"], ambigui=mv["ambigui"], delta_equity=e1 - e0,
trading=e1 - e0 - mv["certi"], letture=len(rows)) trading=e1 - e0 - mv["certi"], letture=len(rows), avvisi=mv.get("avvisi") or [])
def pnl_giorno(con, giorno: date) -> dict: def pnl_giorno(con, giorno: date) -> dict:
@@ -413,7 +481,11 @@ def lettura(con, voce: dict) -> list[dict]:
# --- movimenti di capitale: prima del P&L, perche' lo ridefiniscono ---------------------- # --- movimenti di capitale: prima del P&L, perche' lo ridefiniscono ----------------------
for e in p.get("movimenti_giorno") or []: for e in p.get("movimenti_giorno") or []:
h0, h1 = e["ts_prima"][11:16], e["ts_dopo"][11:16] h0, h1 = e["ts_prima"][11:16], e["ts_dopo"][11:16]
if e["classe"] == "movimento": if e["classe"] == "movimento" and e.get("fonte", "rilevato") != "rilevato":
dire("movimento", f"**Movimento di capitale ${e['delta']:+,.2f}** fra le {h0} e le "
f"{h1} UTC, {e.get('dichiarazione') or 'dichiarato'}. "
"Scorporato dal P&L di trading qui sotto.", "nota")
elif e["classe"] == "movimento":
dire("movimento", f"**Movimento di capitale ${e['delta']:+,.2f}** fra le {h0} e le " dire("movimento", f"**Movimento di capitale ${e['delta']:+,.2f}** fra le {h0} e le "
f"{h1} UTC: salto {100*e['pct']:+.1f}% contro un massimo spiegabile " f"{h1} UTC: salto {100*e['pct']:+.1f}% contro un massimo spiegabile "
f"dal mercato di ±{100*e['mercato_max_pct']:.1f}% al tetto di leva. " f"dal mercato di ±{100*e['mercato_max_pct']:.1f}% al tetto di leva. "
+6
View File
@@ -92,6 +92,12 @@ MONITORS: tuple[MonitorSpec, ...] = (
MonitorSpec("usde_watch", "live", "usde_watch.jsonl", 24.0, max_age_h=30.0, MonitorSpec("usde_watch", "live", "usde_watch.jsonl", 24.0, max_age_h=30.0,
gate="allarmi depeg/quota USDE + verdetto eligibilita' 29/08", gate="allarmi depeg/quota USDE + verdetto eligibilita' 29/08",
open_labeled=False), open_labeled=False),
# Cuscino USDC di regolamento (cron :53, ORARIO, debito §5.17 / issue #3): se si ferma nessuno
# vede il saldo USDC scendere sotto il cuscino, e la riconversione automatica non parte.
# max_age 3h = due giri persi; timbri di esecuzione -> open_labeled=False.
MonitorSpec("cuscino_watch", "live", "cuscino_watch.jsonl", 1.0, max_age_h=3.0,
gate="cuscino USDC di regolamento: allerta + riconversione automatica",
open_labeled=False),
) )
+8 -2
View File
@@ -87,9 +87,15 @@ def notify(title: str, data: dict | None = None, tentativi: int = 1) -> bool:
ver = f" <code>v{APP_VERSION}</code>" ver = f" <code>v{APP_VERSION}</code>"
except Exception: except Exception:
ver = "" ver = ""
lines = [f"<b>{title}</b>{ver}"] # Telegram e' in parse_mode=HTML: un '<' o '&' nudo in un valore fa fallire l'INTERO invio
# (HTTP 400, "can't parse entities"). Successo il 2026-09-10 13:53Z: la prima allerta di
# cuscino_watch conteneva «(< 10% del cuscino)» ed e' andata persa. L'escape sta QUI, nel sink,
# perche' nessuno dei 45 chiamanti passa tag di proposito (verificato con grep) e un chiamante
# nuovo non deve ricordarsene. `send()` resta grezzo per chi costruisce il proprio HTML.
import html
lines = [f"<b>{html.escape(str(title), quote=False)}</b>{ver}"]
for k, v in (data or {}).items(): for k, v in (data or {}).items():
lines.append(f" {k}: {v}") lines.append(f" {html.escape(str(k), quote=False)}: {html.escape(str(v), quote=False)}")
return send("\n".join(lines), tentativi=tentativi) return send("\n".join(lines), tentativi=tentativi)
+346
View File
@@ -0,0 +1,346 @@
"""Revisione settimanale in SOLA LETTURA: un revisore diverso dall'autore rilegge il sistema.
PERCHE' ESISTE (2026-09-09). Nove sorveglianti in cron guardano ognuno la propria grandezza
(venue, fee, edge, scala, USDE, VRP-f, health, balance, paper-monitor); il giornale e l'analista
leggono UN giorno. Nessuno rilegge il sistema nel suo insieme: stato, gate con le date, decisioni
vincolanti, debiti aperti, cosa e' cambiato nei diari e nei commit. La revisione con un secondo
modello (regola del 2026-09-03: chi rivede non e' chi ha scritto) ha trovato in una sessione due
conclusioni false e un look-ahead che l'autore aveva riletto tre volte. Questo modulo la schedula.
COSA FA. Raccoglie il materiale (CLAUDE.md, config, crontab, log git, monitor_health, giornale
degli ultimi giorni, diari recenti, stato dei sorveglianti), lo da' a un modello DIVERSO da quello
che scrive il codice, e salva il rapporto FIRMATO in `docs/revisioni/<data>.md`, con una sintesi su
Telegram. Il rapporto e' l'opinione di un lettore fallibile (P13): dice cosa non torna, cosa e'
maturo da decidere, cosa propone. Non e' parte del registro delle misure.
COSA NON FA, PER COSTRUZIONE. Non tocca config, libro, cron, codice, dati: l'unico file che
scrive e' il rapporto. Non esegue proposte: le scrive, e l'operatore decide. Non riapre le
decisioni vincolanti (CLAUDE.md §3) se la colonna «cosa la riapre» non e' soddisfatta, e non
propone «idee nuove» senza citare il file di memoria che ha gia' ucciso l'idea simile. Un ciclo
che si autoregola e' esattamente cio' che `scale_watch` e `venue_watch` trattano come anomalia.
Il silenzio non e' una revisione: se il modello non risponde, il rapporto lo dice e Telegram lo
dice, invece di lasciare un lunedi' vuoto che si legge come «tutto bene».
"""
from __future__ import annotations
import html
import json
import subprocess
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from src.live import analista as A
from src.live import monitor_health as MH
MODELLO = "claude-fable-5-1" # il revisore: NON il modello che scrive il codice
CADENZA = "settimanale"
FINESTRA_GIORNALE_G = 7 # pagine del giornale lette
FINESTRA_DIARI_G = 7 # diari letti (la settimana: e' una revisione settimanale)
FINESTRA_COMMIT_G = 14 # commit letti
CARTELLA = Path("docs") / "revisioni"
MAX_CHARS_VOCE = 90_000 # CLAUDE.md sta sotto; il resto e' molto piu' piccolo
MAX_CHARS_TOTALE = 400_000
MAX_NUMERI_LIBERI = 12 # una revisione confronta e sottrae: qualche numero suo e' lecito
LIMITE_TELEGRAM = 3_500 # sotto i 4.096 di Telegram, con la testata
TIMEOUT_S = 900 # il materiale e' grande: 5 minuti non bastano sempre
TITOLI = ("## Sintesi", "## Cosa non torna", "## Cosa e' maturo da decidere",
"## Proposte (nessuna azione eseguita)", "## Cosa ho letto e cosa mi mancava")
ISTRUZIONI = """Sei il REVISORE settimanale di un progetto di trading quantitativo (BTC/ETH su Deribit, libro
live con soldi veri, ~$4.500). Sei un modello DIVERSO da quello che scrive codice e analisi del
progetto: il tuo valore e' che non condividi il suo ragionamento. Leggi il materiale qui sotto e
scrivi la revisione, in italiano, per l'operatore (che decide) e per l'agente (che esegue).
REGOLE VINCOLANTI:
1. SOLA LETTURA. Non proponi comandi da eseguire ora, non scrivi codice: descrivi cosa non torna
e cosa andrebbe verificato o deciso, e da chi.
2. OGNI SEGNALAZIONE CITA LA FONTE: file e sezione (es. «CLAUDE.md §4, gate STATARB»), data della
pagina di giornale, nome del diario, hash del commit. Una segnalazione senza fonte non vale.
3. USA SOLO I NUMERI DEL MATERIALE. Puoi sottrarre e confrontare, ma ogni cifra che scrivi deve
essere rintracciabile qui sotto; un controllo automatico elenchera' quelle che non lo sono.
4. LE DECISIONI VINCOLANTI (CLAUDE.md §3) NON SI RIPROPONGONO. Puoi dire che una condizione della
colonna «cosa la riapre» sembra soddisfatta, citando il dato; non puoi consigliare di cambiare
la decisione per altri motivi.
5. UN'IDEA «NUOVA» SI PROPONE SOLO citando il file di memoria (docs/memory/…) o il paragrafo che ha
gia' ucciso l'idea piu' simile, e dicendo in che cosa questa differisce. Se non lo sai, non la
proponi: scrivi «da verificare in memoria prima di proporla».
6. I GATE HANNO UNA DATA (CLAUDE.md §4). Elenca quelli la cui data cade entro 30 giorni o e' gia'
passata, con il criterio scritto e lo stato che il materiale mostra. Non anticipare un verdetto:
un gate si decide alla sua data, coi criteri scritti prima.
7. DISTINGUI misurato da dedotto. «Non determinabile da questo materiale» e' una risposta buona.
8. NIENTE PREVISIONI di mercato, niente consigli di direzione, niente target.
9. Se qualcosa richiede l'operatore SUBITO (fondi, credenziali, un sorvegliante fermo che copre un
gate, una modifica di config non registrata), mettilo nella Sintesi con la parola URGENTE.
10. Massimo ~1.000 parole. Usa ESATTAMENTE questi titoli, in quest'ordine, e nient'altro prima:
%s
Sotto «Cosa ho letto e cosa mi mancava» di' quali fonti hai usato e quale dato ti sarebbe
servito e non c'era (e' l'elenco da cui il materiale della prossima settimana migliora).
""" % "\n ".join(TITOLI)
# ---------------------------------------------------------------- materiale
def _tronca(testo: str, n: int | None = None) -> tuple[str, bool]:
n = MAX_CHARS_VOCE if n is None else n # letto a runtime: un default e' catturato alla definizione (debito 12)
if len(testo) <= n:
return testo, False
return testo[:n] + f"\n[… troncato a {n:,} caratteri su {len(testo):,}]", True
def _cmd(args: list[str], cwd: Path) -> str:
"""Uscita di un comando di SOLA LETTURA, o il motivo per cui non si legge (P5: «non vedo»
non e' «va tutto bene»)."""
try:
p = subprocess.run(args, cwd=str(cwd), capture_output=True, text=True, timeout=60)
except Exception as exc: # noqa: BLE001 — un comando di lettura che esplode si dichiara
return f"non leggibile: {type(exc).__name__}: {str(exc)[:120]}"
if p.returncode != 0:
return f"non leggibile: uscita {p.returncode}: {(p.stderr or p.stdout).strip()[:200]}"
return p.stdout
def _coda(path: Path, n: int) -> str:
if not path.exists():
return "(assente)"
righe = path.read_text().splitlines()
return "\n".join(righe[-n:]) if righe else "(vuoto)"
def _data_nel_nome(p: Path) -> date | None:
try:
return date.fromisoformat(p.name[:10])
except ValueError:
return None
def _monitor_health(root: Path, adesso: datetime) -> str:
try:
righe = MH.check_all(root, now=adesso)
except Exception as exc: # noqa: BLE001 — si dichiara, non si tace (P3)
return f"non leggibile: {type(exc).__name__}: {exc}"
out = ["monitor | stato | perche' | gate a rischio"]
for r in righe:
out.append(f"{r.get('name')} | {r.get('status')} | {r.get('why')} | {r.get('gate') or '-'}")
return "\n".join(out)
def materiale(root: Path, adesso: datetime) -> list[tuple[str, str, bool]]:
"""Le voci (nome, testo, troncato) date al revisore. Tutto in SOLA LETTURA.
L'ordine e' quello in cui un lettore umano le aprirebbe: prima le regole e lo stato dichiarato,
poi cio' che gira (sorveglianti: piccoli, e per questo PRIMA delle voci grandi, cosi' il tetto
totale non li taglia mai), poi cio' che e' successo (giornale, diari).
"""
oggi = adesso.date()
voci: list[tuple[str, str]] = []
voci.append(("CLAUDE.md (stato, book, numeri, decisioni vincolanti, gate, debiti, regole)",
(root / "CLAUDE.md").read_text() if (root / "CLAUDE.md").exists() else "(assente)"))
cfg = root / "config" / "live.json"
voci.append(("config/live.json (unica autorita' dei guardrail live)",
cfg.read_text() if cfg.exists() else "(assente)"))
voci.append(("crontab installata (crontab -l)", _cmd(["crontab", "-l"], root)))
since = (oggi - timedelta(days=FINESTRA_COMMIT_G)).isoformat()
voci.append((f"git log dal {since} + stato del working tree",
_cmd(["git", "log", f"--since={since}", "--format=%h %ad %s", "--date=short"], root)
+ "\n--- git status --short ---\n" + _cmd(["git", "status", "--short"], root)))
voci.append(("monitor_health (i forward-monitor stanno registrando?)", _monitor_health(root, adesso)))
live = root / "data" / "live"
stati = {
"scale_watch_state.json": (live / "scale_watch_state.json", 999),
"scale_history.jsonl (ultime 3)": (live / "scale_history.jsonl", 3),
"vrp_f_watch/state.json": (root / "data" / "vrp_f_watch" / "state.json", 999),
"usde_watch.jsonl (ultime 3)": (live / "usde_watch.jsonl", 3),
"usde_convert.jsonl (ultime 3)": (live / "usde_convert.jsonl", 3),
"balance_watch.jsonl (ultime 2)": (live / "balance_watch.jsonl", 2),
"venue_news.jsonl (ultime 5)": (live / "venue_news.jsonl", 5),
"movimenti_dichiarati.jsonl": (live / "movimenti_dichiarati.jsonl", 50),
"equity_seen.json (watermark)": (live / "equity_seen.json", 999),
}
for nome, (p, n) in stati.items():
voci.append((f"stato {nome}", _coda(p, n)))
for i in range(FINESTRA_GIORNALE_G, 0, -1):
g = oggi - timedelta(days=i)
p = root / "docs" / "journal" / f"{g.isoformat()}.md"
if p.exists():
voci.append((f"giornale {g.isoformat()}", p.read_text()))
diari = sorted(p for p in (root / "docs" / "diary").glob("*.md")
if (_data_nel_nome(p) or date.min) >= oggi - timedelta(days=FINESTRA_DIARI_G))
for p in diari:
voci.append((f"diario {p.name}", p.read_text()))
out, tot = [], 0
for nome, testo in voci:
t, tr = _tronca(testo)
if tot + len(t) > MAX_CHARS_TOTALE:
t, tr = _tronca(t, max(0, MAX_CHARS_TOTALE - tot)), True
tot += len(t)
out.append((nome, t, tr))
return out
def costruisci_prompt(voci: list[tuple[str, str, bool]], adesso: datetime) -> str:
parti = [ISTRUZIONI, f"\nDATA DELLA REVISIONE: {adesso:%Y-%m-%d %H:%M}Z (usa questa per «oggi» e per le date dei gate).\n"]
for nome, testo, tr in voci:
parti.append(f"\n=== {nome}{' [TRONCATO]' if tr else ''} ===\n{testo}\n")
parti.append("\nScrivi ora la revisione, coi cinque titoli, e nient'altro.")
return "\n".join(parti)
# ---------------------------------------------------------------- guardia
def valida(testo: str, fonte: str) -> tuple[str, list[str]]:
"""(stato, motivi) — stato in {ok, sospetta, rifiutata}. Rifiutata = non e' una revisione
(vuota o senza i titoli); sospetta = numeri non rintracciabili nel materiale, elencati."""
motivi: list[str] = []
t = testo.strip()
if not t:
return "rifiutata", ["risposta vuota"]
mancanti = [x for x in TITOLI if x not in t]
if mancanti:
return "rifiutata", [f"titoli mancanti: {', '.join(mancanti)}"]
fuori = A.numeri_non_supportati(t, fonte)
if fuori:
motivi.append(f"{len(fuori)} numeri non presenti nel materiale: " + ", ".join(fuori[:30]))
if len(fuori) > MAX_NUMERI_LIBERI:
return "sospetta", motivi
return ("sospetta" if motivi else "ok"), motivi
# ---------------------------------------------------------------- rapporto
def rapporto(testo: str | None, errore: str | None, stato: str, motivi: list[str], modello: str,
adesso: datetime, voci: list[tuple[str, str, bool]]) -> str:
testa = [f"# Revisione {CADENZA}{adesso:%Y-%m-%d}", ""]
if errore:
testa += [f"## Revisione NON eseguita",
"",
f"Il modello `{modello}` non ha risposto: **{errore}**. Non c'e' una revisione questa "
"settimana, e questo file esiste perche' un lunedi' senza rapporto si leggerebbe "
"come «tutto bene» (P5).", ""]
corpo = []
else:
testa += [f"*Scritta il {adesso:%Y-%m-%dT%H:%M:%S}Z dal modello `{modello}`, revisore diverso "
"dall'autore del codice, in SOLA LETTURA: nessun file del repo, nessuna config, nessun "
"ordine e' stato toccato. E' l'opinione di un lettore fallibile (P13): ogni numero e "
"ogni proposta vanno verificati alla fonte prima di agire. Guardia sui numeri: "
f"**{stato}**" + (f"{'; '.join(motivi)}" if motivi else "") + ".*", ""]
corpo = [testo.strip(), ""]
coda = ["---", "## Firma e materiale", "",
f"- revisore: `{modello}` · cadenza {CADENZA} · giornale {FINESTRA_GIORNALE_G} g · "
f"diari {FINESTRA_DIARI_G} g · commit {FINESTRA_COMMIT_G} g",
"- scritto da `scripts/live/revisione.py` (cron `scripts/cron_review.sh`); l'unico file "
"scritto e' questo", "",
"| voce | caratteri | troncata |", "|---|---|---|"]
coda += [f"| {n} | {len(t):,} | {'si' if tr else ''} |" for n, t, tr in voci]
return "\n".join(testa + corpo + coda) + "\n"
def _sezione(testo: str, titolo: str) -> str:
if titolo not in testo:
return ""
dopo = testo.split(titolo, 1)[1]
for t in TITOLI:
if t != titolo and t in dopo:
dopo = dopo.split(t, 1)[0]
return dopo.strip()
def per_telegram(testo: str | None, errore: str | None, stato: str, adesso: datetime,
path: Path) -> str:
"""La Sintesi, con la testata e il taglio DICHIARATO (un taglio silenzioso e' peggio di
un'assenza: si legge come un pensiero finito)."""
if errore:
return (f"<b>🚨 Revisione {CADENZA} NON eseguita</b> {adesso:%Y-%m-%d}\n"
f"{html.escape(errore)}\n<code>{html.escape(str(path))}</code>")
sintesi = _sezione(testo or "", TITOLI[0]) or "(nessuna Sintesi nel rapporto)"
corpo = html.escape(sintesi, quote=False)
tagliato = ""
if len(corpo) > LIMITE_TELEGRAM:
corpo, tagliato = corpo[:LIMITE_TELEGRAM], "\n[… sintesi tagliata: il resto e' nel file]"
return (f"<b>🔍 Revisione {CADENZA}</b> {adesso:%Y-%m-%d} · guardia numeri: {stato}\n\n"
f"{corpo}{tagliato}\n\n<code>{html.escape(str(path))}</code>")
# ---------------------------------------------------------------- modello
def interroga(prompt: str, modello: str = MODELLO, timeout: int = TIMEOUT_S) -> tuple[str, str | None]:
"""(testo, errore) dalla CLI `claude -p`, col prompt su STDIN.
Non su argv: al primo giro reale (09/09, 195k caratteri) `analista.interroga` e' morto con
`OSError: Argument list too long` PRIMA di chiamare il modello e l'eccezione, non catturata,
ha lasciato zero rapporto e zero Telegram: il silenzio che questo modulo esiste per vietare.
Qui ogni eccezione della chiamata e' un errore RESTITUITO (P3), mai propagato.
"""
try:
p = subprocess.run(
["claude", "-p", "--model", modello, "--allowed-tools", "", "--max-turns", "1"],
input=prompt, capture_output=True, text=True, timeout=timeout)
except FileNotFoundError:
return "", "CLI `claude` non trovata"
except subprocess.TimeoutExpired:
return "", f"timeout dopo {timeout}s"
except Exception as exc: # noqa: BLE001 — si restituisce, non si tace
return "", f"{type(exc).__name__}: {str(exc)[:200]}"
if p.returncode != 0:
return "", A.motivo_uscita(p.returncode, p.stdout, p.stderr)
return p.stdout.strip(), None # NON `analista.pulisci`: toglierebbe i titoli «## …» che la guardia richiede
# ---------------------------------------------------------------- orchestrazione
def scrivi(root: Path, adesso: datetime | None = None, modello: str = MODELLO, *,
secco: bool = False, telegram: bool = True, quiet: bool = False,
interroga=None, invia=None) -> dict:
"""Un giro: materiale → modello → rapporto → Telegram. Ritorna cosa e' successo.
`interroga(prompt, modello, timeout) -> (testo, errore)` e `invia(html) -> bool` sono
INIETTATI (default: `interroga` di questo modulo prompt su stdin e `notifier.send`) cosi'
i test non spendono e non mandano. Se uno dei due SOLLEVA, l'eccezione diventa un errore nel
rapporto: un giro che muore a meta' non deve lasciare un lunedi' vuoto. Con `secco` non si chiama il modello e non si scrive niente: si stampa la taglia.
"""
adesso = adesso or datetime.now(timezone.utc)
voci = materiale(root, adesso)
prompt = costruisci_prompt(voci, adesso)
esito = {"data": adesso.date().isoformat(), "voci": len(voci), "caratteri": len(prompt),
"modello": modello, "secco": secco}
if not quiet:
print(f" revisione {esito['data']} modello {modello} materiale {len(voci)} voci, "
f"{len(prompt):,} caratteri" + (" [SECCO: nessuna chiamata, nessuna scrittura]" if secco else ""))
for n, t, tr in voci:
print(f" {len(t):>8,} {n}{' [troncata]' if tr else ''}")
if secco:
return esito
interroga = interroga or globals()["interroga"]
try:
testo, errore = interroga(prompt, modello, TIMEOUT_S)
except Exception as exc: # noqa: BLE001
testo, errore = "", f"eccezione nella chiamata al modello: {type(exc).__name__}: {str(exc)[:200]}"
stato, motivi = ("rifiutata", [errore]) if errore else valida(testo, prompt)
if not errore and stato == "rifiutata":
errore, testo = "risposta non e' una revisione: " + "; ".join(motivi), None
cartella = root / CARTELLA
cartella.mkdir(parents=True, exist_ok=True)
path = cartella / f"{adesso:%Y-%m-%d}.md"
path.write_text(rapporto(testo, errore, stato, motivi, modello, adesso, voci))
esito.update(stato=stato, errore=errore, motivi=motivi, file=str(path))
if telegram:
from src.live import notifier as N
invia = invia or (lambda h: N.send(h, tentativi=2))
try:
esito["telegram"] = bool(invia(per_telegram(testo, errore, stato, adesso, path)))
except Exception as exc: # noqa: BLE001
esito["telegram"] = False
esito["telegram_errore"] = f"{type(exc).__name__}: {str(exc)[:200]}"
if not quiet:
tg = "" if "telegram" not in esito else (" telegram: inviato" if esito["telegram"] else
f" telegram: NON inviato ({esito.get('telegram_errore', 'send False')})")
print(f" -> {stato}" + (f" ({errore})" if errore else "") + f" {path}{tg}")
return esito
+55
View File
@@ -36,6 +36,10 @@ _DEFAULTS = {
"venue_cap_misurato": "2026-08-31", "venue_cap_misurato": "2026-08-31",
"depeg_warn": 0.99, "depeg_warn": 0.99,
"depeg_crit": 0.95, "depeg_crit": 0.95,
# bande del cuscino USDC (frazioni del cuscino richiesto), issue #7 — vedi `_nota_cuscino`
"cuscino_preavviso_frac": 0.10,
"cuscino_margine_frac": 0.20,
"cuscino_riacquisto_frac": 0.40,
} }
@@ -93,3 +97,54 @@ def valuta(eq_usde: float, px: float | None) -> tuple[float, str]:
return eq_usde, f"USDE {eq_usde:,.0f} valutato 1.0000 (indice non leggibile)" return eq_usde, f"USDE {eq_usde:,.0f} valutato 1.0000 (indice non leggibile)"
p = min(px, 1.0) p = min(px, 1.0)
return eq_usde * p, f"USDE {eq_usde:,.0f} @ {p:.4f}" return eq_usde * p, f"USDE {eq_usde:,.0f} @ {p:.4f}"
# --- CUSCINO USDC DI REGOLAMENTO ------------------------------------------------------------
# P&L e funding dei perp USDC-lineari si regolano in USDC, non nel collaterale: a quota USDE alta
# il saldo USDC va negativo alla prima perdita del libro e Deribit lo finanzia allo 0,05%/GIORNO
# (KB "Cross collateral specifications": 18,25%/anno, 4,3x la resa USDE). Il cuscino richiesto e'
# il disaster-SL sulla MASSIMA esposizione lorda che il libro puo' prendere:
# n_asset x frac x disaster_sl_pct x equity_totale
# tutto letto da config/live.json e src/live/book.py, MAI ridichiarato (P1). Prima del 10/09 la
# formula viveva in scripts/live/usde_convert.py; il sorvegliante cuscino_watch la deriva da qui,
# e usde_convert pure — due lettori, una formula.
N_ASSET = 2
def frazione_cuscino() -> tuple[float, str]:
"""PURA sulla config. -> (frazione dell'equity totale che deve restare in USDC, come)."""
cfg = json.loads(CFG_PATH.read_text())
sl = float(cfg["disaster_sl_pct"])
from src.live import book as B # lazy: book importa shadow, che importa questo modulo
frac = float(B.WEIGHT)
return N_ASSET * frac * sl, f"{N_ASSET} asset x frac {frac} x SL {sl:.0%}"
def cuscino_richiesto_usd(equity_tot: float) -> tuple[float, str]:
"""Cuscino USDC di REGOLAMENTO in dollari, per un'equity totale data. -> (usd, come)."""
f, come = frazione_cuscino()
return equity_tot * f, f"{come} su ${equity_tot:,.2f}"
def bande_cuscino(c: dict | None = None) -> dict:
"""PURA sulla config. Le tre frazioni del cuscino che governano cuscino_watch, con l'ordine
verificato: 0 < preavviso < margine < riacquisto. Fuori ordine la banda di isteresi non
esiste (vendita e acquisto si inseguirebbero): si solleva, non si indovina."""
c = c or cfg()
b = dict(preavviso=float(c["cuscino_preavviso_frac"]), margine=float(c["cuscino_margine_frac"]),
riacquisto=float(c["cuscino_riacquisto_frac"]))
if not (0.0 < b["preavviso"] < b["margine"] < b["riacquisto"]):
raise ValueError(f"bande del cuscino fuori ordine (serve 0 < preavviso < margine < riacquisto): {b}")
return b
def quota_ripristino(margine_frac: float | None = None) -> float:
"""PURA sulla config. Quota USDE che lascia il cuscino PIU' un margine (frazione del cuscino):
e' il bersaglio UNICO di ogni conversione automatica — vendita quando il cuscino e' scoperto,
acquisto quando lo slack eccede la banda. Il margine serve a non tornare sul bordo alla prima
ora in perdita (P6: la persistenza e' latenza). Default: `cuscino_margine_frac` di config."""
if margine_frac is None:
margine_frac = bande_cuscino()["margine"]
f, _ = frazione_cuscino()
return max(0.0, 1.0 - f * (1.0 + margine_frac))
+5
View File
@@ -122,6 +122,11 @@ def _isola_file_operativi_vivi(tmp_path, monkeypatch):
# non deve poter sporcare il working tree (P16: il cron gira dal working tree). # non deve poter sporcare il working tree (P16: il cron gira dal working tree).
import src.live.journal as journal import src.live.journal as journal
monkeypatch.setattr(journal, "JOURNAL_DIR", tmp_path / "journal") monkeypatch.setattr(journal, "JOURNAL_DIR", tmp_path / "journal")
# --- data/live/movimenti_dichiarati.jsonl (2026-09-06) ------------------------------------
# `journal.movimenti_capitale` LEGGE i movimenti dichiarati dall'operatore: se un test
# vedesse quelli veri, un versamento reale entrerebbe in una serie di fixture e il test
# passerebbe o fallirebbe per un motivo che non e' suo. Qui il file non esiste.
monkeypatch.setattr(journal, "MOVIMENTI_DICHIARATI", tmp_path / "movimenti_dichiarati.jsonl")
yield yield
+60 -1
View File
@@ -23,7 +23,8 @@ sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research")) sys.path.insert(0, str(ROOT / "scripts" / "research"))
sys.path.insert(0, str(ROOT / "scripts" / "analysis")) sys.path.insert(0, str(ROOT / "scripts" / "analysis"))
from cblib import bs_put, f_factors, pick_legs # noqa: E402 import cblib # noqa: E402
from cblib import bs_put, causale, f_factors, pick_legs # noqa: E402
from certify_cb_chain import ( # noqa: E402 from certify_cb_chain import ( # noqa: E402
crossed_rate, hollow_rate, monotonicity_violations, worst_day_hollow, crossed_rate, hollow_rate, monotonicity_violations, worst_day_hollow,
) )
@@ -196,3 +197,61 @@ def test_nel_sleeve_f_moltiplica_il_credito_NETTO():
assert SL.VRP_CFG["f"] == 1.0, ( assert SL.VRP_CFG["f"] == 1.0, (
"il sleeve gira ancora a f=1.0: se cambia, aggiornare il diario 2026-07-30 e CLAUDE.md" "il sleeve gira ancora a f=1.0: se cambia, aggiornare il diario 2026-07-30 e CLAUDE.md"
) )
# ===========================================================================================
# C. CAUSALITA' delle serie di contesto (debito §5.18, 09/09): `asof(ts)` non guarda avanti
# ===========================================================================================
def test_causale_rietichetta_alla_chiusura_e_non_tocca_i_valori():
"""Il feed e' etichettato all'APERTURA: la chiusura della barra T e' nota a T+cadenza.
DEVE FALLIRE SE: qualcuno "semplifica" togliendo lo spostamento, o sposta i VALORI."""
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 = causale(S, "1h")
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"
assert list(Sc.values) == list(S.values), "i VALORI non si toccano, solo le etichette"
def test_spot_series_e_causale_sul_feed_1h(monkeypatch):
"""`cblib.spot_series(a).asof(ts)` = ultima chiusura ORARIA nota a ts. Controllo positivo:
la serie grezza del feed (etichettata all'apertura) a ts da' la chiusura di ts+1h."""
import scripts.analysis.research_lab as rl
idx = pd.date_range("2026-06-01", periods=6, freq="h", tz="UTC")
epoch = pd.Timestamp("1970-01-01", tz="UTC") # NON `asi8 // 10**6`: in pandas 3 e' in `us` (D6)
px = pd.DataFrame({"timestamp": ((idx - epoch) // pd.Timedelta(milliseconds=1)).astype("int64"),
"close": [100.0, 101.0, 102.0, 103.0, 104.0, 105.0]})
monkeypatch.setattr(rl, "load_tf", lambda asset, tf: px)
cblib.spot_series.cache_clear()
try:
S = cblib.spot_series("ZZZ")
ts = idx[3] + pd.Timedelta(minutes=17) # 03:17: la barra 03:00 e' ancora aperta
assert S.asof(ts) == 102.0, "a 03:17 l'ultima chiusura nota e' quella della barra 02:00"
assert S.asof(idx[3]) == 102.0, "a 03:00 esatte la barra 02:00 e' appena chiusa"
assert S.asof(idx[5] + pd.Timedelta(hours=1)) == 105.0, "l'ultima chiusura arriva un'ora dopo"
grezza = pd.Series(px["close"].values, index=idx)
assert grezza.asof(ts) == 103.0, "il difetto riparato: la serie del feed a 03:17 da' la chiusura delle 04:00"
finally:
cblib.spot_series.cache_clear()
def test_dvol_series_e_causale_sul_feed_giornaliero(monkeypatch, tmp_path):
"""La riga DVOL del giorno D e' la chiusura delle 23:00 di D: dentro D non e' ancora nota.
`cblib.dvol_series(a).asof(ts in D)` deve dare la chiusura di D1."""
days = pd.date_range("2026-06-01", periods=4, freq="D", tz="UTC")
epoch = pd.Timestamp("1970-01-01", tz="UTC")
pd.DataFrame({"timestamp": ((days - epoch) // pd.Timedelta(milliseconds=1)).astype("int64"),
"close": [36.0, 37.0, 43.0, 47.0]}).to_parquet(tmp_path / "dvol_zzz.parquet")
monkeypatch.setattr(cblib, "RAW", tmp_path)
cblib.dvol_series.cache_clear()
try:
V = cblib.dvol_series("ZZZ")
ts = days[2] + pd.Timedelta(hours=10) # 03/06 10:00
assert V.asof(ts) == 37.0, "dentro il 03/06 l'ultimo DVOL noto e' la chiusura del 02/06"
assert V.asof(days[3]) == 43.0, "alle 00:00 del 04/06 e' nota la chiusura del 03/06"
assert np.isnan(V.asof(days[0] + pd.Timedelta(hours=5))), "nel primo giorno non c'e' storia"
finally:
cblib.dvol_series.cache_clear()
+272
View File
@@ -0,0 +1,272 @@
"""cuscino_watch: il cuscino USDC e' sorvegliato; sotto zero si vende USDE, sopra la banda si ricompra.
Blindano: (a) il cuscino e' DERIVATO da config + book (P1), la stessa formula di usde_convert;
(b) i tre stati e la soglia di preavviso; (c) la transizione non si consuma se l'invio fallisce
(debito §5.2); (d) le guardie della riconversione automatica interruttore del libro, depeg,
cadenza e la quota di ripristino che lascia margine."""
from __future__ import annotations
import importlib.util
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from src.live import usde as U # noqa: E402
def _carica(nome):
sp = importlib.util.spec_from_file_location(nome, ROOT / "scripts" / "live" / f"{nome}.py")
m = importlib.util.module_from_spec(sp)
sp.loader.exec_module(m)
return m
W = _carica("cuscino_watch")
# ------------------------------ il cuscino e' derivato, non ridichiarato ------------------------------
def test_cuscino_derivato_da_config_e_book():
cfg = json.loads((ROOT / "config" / "live.json").read_text())
from src.live import book as B
f, come = U.frazione_cuscino()
assert abs(f - 2 * float(B.WEIGHT) * float(cfg["disaster_sl_pct"])) < 1e-12
usd, _ = U.cuscino_richiesto_usd(1000.0)
assert abs(usd - 1000.0 * f) < 1e-9
assert "SL" in come
def test_usde_convert_usa_la_stessa_formula():
C = _carica("usde_convert")
assert C.cuscino_richiesto_usd(4469.46) == U.cuscino_richiesto_usd(4469.46)
def test_quota_ripristino_lascia_margine_sul_cuscino():
f, _ = U.frazione_cuscino()
q0, q10 = U.quota_ripristino(0.0), U.quota_ripristino(0.10)
assert abs(q0 - (1 - f)) < 1e-12 # a margine zero e' il complemento del cuscino
assert q10 < q0 # col margine si vende UN PO' di piu'
# dopo la riconversione a q10 lo slack e' esattamente il 10% del cuscino
E = 4000.0
usdc_dopo = E * (1 - q10)
assert abs((usdc_dopo - E * f) - 0.10 * E * f) < 1e-9
# ------------------------------ i quattro stati (issue #7) ------------------------------
B = dict(preavviso=0.10, margine=0.20, riacquisto=0.40)
def test_stati_scoperto_preavviso_ok_eccedente():
assert W.giudica(1199.0, 4000.0, 1200.0, B)["stato"] == "SCOPERTO"
g = W.giudica(1300.0, 4000.0, 1200.0, B)
assert g["stato"] == "PREAVVISO" and abs(g["slack"] - 100.0) < 1e-9 and g["preavviso_usd"] == 120.0
assert W.giudica(1500.0, 4000.0, 1200.0, B)["stato"] == "OK" # slack 300: dentro [120, 480]
assert W.giudica(1680.0, 4000.0, 1200.0, B)["stato"] == "OK" # slack 480: sul bordo, non compra
g = W.giudica(1700.0, 4000.0, 1200.0, B)
assert g["stato"] == "ECCEDENTE" and g["riacquisto_usd"] == 480.0
def test_le_bande_vengono_dalla_config_e_sono_ordinate():
b = U.bande_cuscino()
assert 0 < b["preavviso"] < b["margine"] < b["riacquisto"]
assert W.giudica(1500.0, 4000.0, 1200.0)["stato"] == W.giudica(1500.0, 4000.0, 1200.0, b)["stato"]
import pytest
with pytest.raises(ValueError):
U.bande_cuscino({"cuscino_preavviso_frac": 0.3, "cuscino_margine_frac": 0.2, "cuscino_riacquisto_frac": 0.4})
def test_isteresi_vendita_e_acquisto_convergono_allo_stesso_bersaglio():
"""Dopo una vendita da SCOPERTO o un acquisto da ECCEDENTE lo slack e' margine x cuscino: OK,
a distanza da entrambe le soglie. Un giro non ne innesca un altro."""
f, _ = U.frazione_cuscino(); b = U.bande_cuscino()
E = 4450.0; cusc = E * f
q = U.quota_ripristino(b["margine"])
usdc_dopo = E * (1 - q)
g = W.giudica(usdc_dopo, E, cusc, b)
assert g["stato"] == "OK"
assert g["slack"] > g["preavviso_usd"] * 1.5 and g["slack"] < g["riacquisto_usd"] / 1.5
def test_un_bonifico_fa_scattare_il_riacquisto_da_solo():
"""Conto a bersaglio (slack = 0,20 x cuscino), bonifico D in USDC: slack +0,7 D. Con D = 12%
dell'equity si supera 0,40 x cuscino e si ricompra; con D = 3% no (resta in USDC)."""
f, _ = U.frazione_cuscino(); b = U.bande_cuscino()
E = 4450.0; cusc = E * f; usdc = cusc * (1 + b["margine"])
for D, atteso in ((0.03 * E, "OK"), (0.12 * E, "ECCEDENTE")):
E2 = E + D; cusc2 = E2 * f
assert W.giudica(usdc + D, E2, cusc2, b)["stato"] == atteso, D
def test_non_vedo_e_blind_non_ok():
assert W.giudica(None, 4000.0, 1200.0, B)["stato"] == "BLIND"
assert W.giudica(1500.0, None, None, B)["stato"] == "BLIND"
def test_gli_stati_che_convertono_sono_due_e_hanno_un_verso():
assert W.AZIONE == {"SCOPERTO": "vendita", "ECCEDENTE": "acquisto"}
# ------------------------------ transizione e trasporto ------------------------------
def test_ok_non_allerta_mai():
assert not W.transizione(None, "OK") and not W.transizione({"stato": "SCOPERTO"}, "OK")
def test_transizione_alla_prima_e_al_cambio_di_stato():
assert W.transizione(None, "PREAVVISO")
assert W.transizione({"stato": "OK", "allertato": None}, "ECCEDENTE")
assert W.transizione({"stato": "OK", "allertato": None}, "PREAVVISO")
assert W.transizione({"stato": "PREAVVISO", "allertato": True}, "SCOPERTO")
assert not W.transizione({"stato": "PREAVVISO", "allertato": True}, "PREAVVISO")
def test_un_invio_fallito_non_consuma_la_transizione():
"""Debito §5.2: il marcatore 'gia' detto' e' l'esito dell'invio."""
assert W.transizione({"stato": "SCOPERTO", "allertato": False}, "SCOPERTO")
assert W.transizione({"stato": "SCOPERTO", "allertato": None}, "SCOPERTO")
# ------------------------------ guardie della riconversione ------------------------------
def test_disarmare_il_libro_disarma_la_riconversione():
ok, m = W.puo_riconvertire([], 0, False, 1.0, 0.99)
assert not ok and "execution_enabled" in m
def test_sotto_depeg_non_si_vende_al_buio():
ok, m = W.puo_riconvertire([], 0, True, 0.985, 0.99)
assert not ok and "depeg" in m
ok, m = W.puo_riconvertire([], 0, True, None, 0.99)
assert not ok and "leggibile" in m
def test_un_tentativo_ogni_sei_ore():
now = 1_789_000_000_000
prev = [{"ts": now - 2 * 3_600_000, "riconversione": {"tentata": True}}]
ok, m = W.puo_riconvertire(prev, now, True, 1.0, 0.99)
assert not ok and "non si insiste" in m
prev = [{"ts": now - 7 * 3_600_000, "riconversione": {"tentata": True}}]
assert W.puo_riconvertire(prev, now, True, 1.0, 0.99)[0]
# un record SENZA riconversione non conta come tentativo
assert W.puo_riconvertire([{"ts": now - 1000, "riconversione": None}], now, True, 1.0, 0.99)[0]
def test_riconverti_lancia_lo_script_vero_con_esegui(monkeypatch):
chiamate = []
class R:
returncode, stdout, stderr = 0, " → piano valido\n ESITO: 10 USDE\n", ""
def finto_run(cmd, **kw):
chiamate.append(cmd)
return R()
monkeypatch.setattr(W.subprocess, "run", finto_run)
e = W.riconverti(0.67)
assert e["ok"] and chiamate and chiamate[0][1].endswith("usde_convert.py")
assert "--esegui" in chiamate[0] and "0.6700" in chiamate[0]
assert e["coda"][-1].startswith(" ESITO")
def test_il_monitor_e_registrato_in_monitor_health():
from src.live.monitor_health import MONITORS
spec = [m for m in MONITORS if m.name == "cuscino_watch"]
assert spec and spec[0].cadence_h == 1.0 and spec[0].open_labeled is False
def test_un_non_tentativo_non_consuma_il_budget():
"""Prezzo illeggibile, --secco, interruttore spento: `tentata=False` non blocca il giro dopo."""
now = 1_789_000_000_000
prev = [{"ts": now - 3_600_000, "riconversione": {"tentata": False, "motivo": "--secco"}}]
assert W.puo_riconvertire(prev, now, True, 1.0, 0.99)[0]
def test_dopo_il_ripristino_lo_stato_e_OK_non_preavviso():
"""Il margine di ripristino deve superare la soglia di preavviso, o ogni 🚨 e' seguito da un ⚠️
per costruzione (P14). Include il floor di 1 USDE e la tolleranza di quota di usde_convert."""
b = U.bande_cuscino()
assert b["margine"] > b["preavviso"]
f, _ = U.frazione_cuscino()
E = 4450.0
C = _carica("usde_convert")
q = U.quota_ripristino(b["margine"]) + C.TOLL # usde_convert si ferma entro TOLL (importato, P1)
usdc_dopo = E * (1 - q) - 1.0 # e vende fino a 1 USDE in meno (passo del venue)
assert W.giudica(usdc_dopo, E, E * f, b)["stato"] == "OK"
def test_il_sink_telegram_fa_escape_di_minore_e_ampersand(monkeypatch):
"""Telegram in parse_mode HTML rifiuta un '<' nudo: il primo giro vero (13:53Z del 10/09) ha
perso l'allerta PREAVVISO per «(< 10% del cuscino)». L'escape sta nel SINK (`notify`), non nei
45 chiamanti qui si cattura il corpo della POST e si verifica che il '<' sia diventato &lt;."""
from src.live import notifier as N
catturato = {}
def finto_urlopen(url, data, timeout=10):
import urllib.parse
catturato.update(urllib.parse.parse_qs(data.decode()))
return None
monkeypatch.setattr(N, "_cfg", lambda: ("tok", "chat"))
monkeypatch.setattr(N.urllib.request, "urlopen", finto_urlopen)
assert N.notify("⚠️ prova <x>", {"slack": "$+30 (< 10% del cuscino)", "a&b": "c"})
testo = catturato["text"][0]
assert "(&lt; 10%" in testo and "&lt;x&gt;" in testo and "a&amp;b" in testo
assert testo.startswith("<b>") # il grassetto del titolo resta HTML vero
def test_bande_fuori_ordine_danno_BLIND_con_motivo_non_un_crash():
import pytest
with pytest.raises(ValueError):
U.bande_cuscino({"cuscino_preavviso_frac": 0.5, "cuscino_margine_frac": 0.2, "cuscino_riacquisto_frac": 0.4})
monkey = {"cuscino_preavviso_frac": 0.5, "cuscino_margine_frac": 0.2, "cuscino_riacquisto_frac": 0.4}
orig = U.cfg
try:
U.cfg = lambda: {**orig(), **monkey}
g = W.giudica(1500.0, 4000.0, 1200.0) # bande=None -> config rotta
finally:
U.cfg = orig
assert g["stato"] == "BLIND" and "fuori ordine" in g["motivo"]
def test_da_eccedente_un_fallimento_si_ritenta_una_volta_al_giorno():
"""USDC fermo non e' un rischio: un guasto persistente (tetto del venue, book illeggibile) non
deve diventare 4 Telegram al giorno per sempre. Da SCOPERTO il ritmo resta 6h."""
now = 1_789_000_000_000
fallito = [{"ts": now - 8 * 3_600_000, "riconversione": {"tentata": True, "ok": False}}]
ok, m = W.puo_riconvertire(fallito, now, True, 1.0, 0.99, stato="ECCEDENTE")
assert not ok and "una volta al giorno" in m
assert W.puo_riconvertire(fallito, now, True, 1.0, 0.99, stato="SCOPERTO")[0]
riuscito = [{"ts": now - 8 * 3_600_000, "riconversione": {"tentata": True, "ok": True}}]
assert W.puo_riconvertire(riuscito, now, True, 1.0, 0.99, stato="ECCEDENTE")[0]
tardi = [{"ts": now - 25 * 3_600_000, "riconversione": {"tentata": True, "ok": False}}]
assert W.puo_riconvertire(tardi, now, True, 1.0, 0.99, stato="ECCEDENTE")[0]
def test_i_motivi_delle_guardie_non_hanno_minore_nudo():
"""Difesa in profondita' oltre al sink: i motivi finiscono nel campo `azione` delle allerte."""
now = 1_789_000_000_000
casi = [W.puo_riconvertire([], now, False, 1.0, 0.99), W.puo_riconvertire([], now, True, None, 0.99),
W.puo_riconvertire([], now, True, 0.98, 0.99),
W.puo_riconvertire([{"ts": now - 1000, "riconversione": {"tentata": True, "ok": True}}], now, True, 1.0, 0.99)]
assert all("<" not in m for _, m in casi)
def test_cron_installato_e_dichiarato_fuori_dal_tondo_e_dopo_il_book():
"""Legge la crontab VIVA (come test_book_cadenza), non un commento nello .sh (P1)."""
import shutil
import subprocess
s = (ROOT / "scripts" / "cron_cuscino.sh").read_text()
assert "cuscino_watch.py --quiet" in s
if not shutil.which("crontab"):
return # SALTATO: distinto da 'assente'
out = subprocess.run(["crontab", "-l"], capture_output=True, text=True, timeout=10)
if out.returncode != 0 or "PythagorasGoal" not in out.stdout:
return # altra macchina / altro utente
righe = [ln for ln in out.stdout.splitlines() if "cron_cuscino.sh" in ln and not ln.startswith("#")]
assert len(righe) == 1, "cron_cuscino.sh deve essere installato UNA volta"
minuto, ora = righe[0].split()[:2]
assert minuto == "53" and ora == "*" # orario, dopo il book (:47)
+90
View File
@@ -680,3 +680,93 @@ def test_balance_watch_registra_anche_l_indice_usde(monkeypatch):
monkeypatch.setattr(U, "prezzo_indice", _rotto) monkeypatch.setattr(U, "prezzo_indice", _rotto)
rec = bw.campiona(_C()) rec = bw.campiona(_C())
assert rec["usde_usdc"] is None and rec["usde_usdc_errore"] == "RuntimeError" assert rec["usde_usdc"] is None and rec["usde_usdc_errore"] == "RuntimeError"
# ------------------------------------------------- movimenti DICHIARATI dall'operatore (06/09)
# Il versamento di prova del 2026-08-25 08:00Z (~$25 su $647, +3,3%) e' restato 12 giorni nel P&L:
# sotto EQUITY_JUMP_ALERT il rilevatore non puo' vederlo, e abbassare la soglia farebbe di ogni
# ora di mercato un candidato. L'unica fonte che sa l'importo e' l'operatore.
def _dichiara(monkeypatch, tmp_path, righe):
f = tmp_path / "mov.jsonl"
f.write_text("\n".join(json.dumps(r) if isinstance(r, dict) else r for r in righe) + "\n")
monkeypatch.setattr(J, "MOVIMENTI_DICHIARATI", f)
return f
def test_un_versamento_dichiarato_sotto_soglia_viene_scorporato(db, monkeypatch, tmp_path):
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto())
_serie_equity(db, [("2026-08-25T07:07:01+00:00", 646.77),
("2026-08-25T08:07:01+00:00", 667.88), # +3,3%: sotto soglia
("2026-08-25T09:07:01+00:00", 667.68)])
_dichiara(monkeypatch, tmp_path, [dict(ts_utc="2026-08-25T08:00:00+00:00", delta_usd=24.9,
banda_usd=0.5, nota="versamento di prova",
dichiarato_il="2026-09-06")])
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
ev = r["eventi"]
assert len(ev) == 1 and ev[0]["classe"] == "movimento" and ev[0]["fonte"] == "dichiarato"
assert ev[0]["delta"] == pytest.approx(24.9) # l'IMPORTO dichiarato, non il salto
assert ev[0]["dopo"] == pytest.approx(646.77 + 24.9) # il mercato dell'ora resta nel rendimento
assert r["certi"] == pytest.approx(24.9)
assert r["trading"] == pytest.approx(667.68 - 646.77 - 24.9)
# TWR: segmento 1 di lunghezza zero (una sola lettura prima), segmento 2 da 671,67 a 667,68
assert r["twr"] == pytest.approx(667.68 / (646.77 + 24.9) - 1.0)
assert "versamento di prova" in ev[0]["dichiarazione"] and "±$0.50" in ev[0]["dichiarazione"]
def test_dichiarato_e_rilevato_insieme_vince_l_importo_dichiarato(db, monkeypatch, tmp_path):
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto())
_serie_equity(db, [("2026-08-25T10:47:01+00:00", 667.49),
("2026-08-25T11:47:01+00:00", 2066.88),
("2026-08-25T12:47:01+00:00", 2064.56)])
_dichiara(monkeypatch, tmp_path, [dict(ts_utc="2026-08-25T11:20:00+00:00", delta_usd=1399.39,
nota="bonifico")])
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
assert len(r["eventi"]) == 1 # UN evento, non due
e = r["eventi"][0]
assert e["fonte"] == "dichiarato+rilevato" and e["delta"] == pytest.approx(1399.39)
assert e["mercato_max_pct"] is not None # la misura di mercato resta
assert r["certi"] == pytest.approx(1399.39)
def test_dichiarato_fuori_dalle_letture_non_entra_ma_si_vede(db, monkeypatch, tmp_path):
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto())
_serie_equity(db, [("2026-08-25T10:47:01+00:00", 600.0),
("2026-08-25T11:47:01+00:00", 601.0)])
_dichiara(monkeypatch, tmp_path, [dict(ts_utc="2026-08-20T08:00:00+00:00", delta_usd=25.0)])
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
assert r["eventi"] == [] and r["certi"] == 0.0
assert r["avvisi"] and "non applicato" in r["avvisi"][0]
def test_una_riga_rotta_non_azzera_le_altre_e_non_sparisce(db, monkeypatch, tmp_path):
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto())
_serie_equity(db, [("2026-08-25T07:07:01+00:00", 646.77),
("2026-08-25T08:07:01+00:00", 667.88)])
_dichiara(monkeypatch, tmp_path, ["{non json", dict(ts_utc="2026-08-25T08:00:00", delta_usd=1.0),
dict(ts_utc="2026-08-25T08:00:00+00:00", delta_usd=24.9)])
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
assert r["certi"] == pytest.approx(24.9)
assert len(r["avvisi"]) == 2 and "senza fuso" in r["avvisi"][1]
def test_senza_file_dei_dichiarati_niente_cambia(db, monkeypatch):
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto())
_serie_equity(db, [("2026-08-25T10:47:01+00:00", 600.0),
("2026-08-25T11:47:01+00:00", 612.0)])
assert not J.MOVIMENTI_DICHIARATI.exists() # conftest lo isola
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
assert r["eventi"] == [] and r["avvisi"] == []
def test_la_nota_di_giornale_dice_che_e_dichiarato(db, monkeypatch, tmp_path):
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto())
_serie_equity(db, [("2026-08-25T07:07:01+00:00", 646.77),
("2026-08-25T08:07:01+00:00", 667.88)])
_dichiara(monkeypatch, tmp_path, [dict(ts_utc="2026-08-25T08:00:00+00:00", delta_usd=24.9,
nota="versamento di prova")])
p = J.pnl_giorno(db, date(2026, 8, 25))
assert p["mov_giorno"] == pytest.approx(24.9)
righe = J.lettura(db, _voce(pnl=p, giorno="2026-08-25"))
mv = [r for r in righe if r["id"] == "movimento"]
assert mv and "dichiarato dall'operatore" in mv[0]["testo"] and "versamento di prova" in mv[0]["testo"]
+126
View File
@@ -0,0 +1,126 @@
"""GATE PREVDAY-01 cablato nel monitor (issue #4): kill, veto d'integrita', data.
Prima del 10/09 il gate esisteva solo nel testo (RESULTS-0822 §54) e la revisione settimanale lo
ha letto come «senza data». Qui si blinda che: (a) le costanti sono quelle scritte il 23/08;
(b) il kill non e' leggibile sotto 180 giorni attivi e scatta solo sotto -0,50; (c) il veto
blocca sotto l'80% di barre ricostruibili e su una crescita MATERIALE delle divergenze — non su
una barra sola (P14); (d) la ricostruzione riproduce una serie generata con la stessa aritmetica
di `advance`; (e) la lente del kill e' GIORNALIERA."""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sp = importlib.util.spec_from_file_location("paper_prevday", ROOT / "scripts" / "live" / "paper_prevday.py")
PP = importlib.util.module_from_spec(sp)
sp.loader.exec_module(PP)
def test_le_costanti_sono_quelle_del_gate_scritto_il_23_08():
assert PP.GATE_DATE == "2027-06-21"
assert PP.KILL_SHARPE == -0.50 and PP.KILL_MIN_ACTIVE_DAYS == 180
assert PP.MIN_RECON_FRAC == 0.80
# e CLAUDE.md §4 cita la stessa data (era assente fino al 10/09)
assert "2027-06-21" in (ROOT / "CLAUDE.md").read_text()
def _serie(n_giorni: int, mu_h: float, sd_h: float, seed: int = 0, pos: float = 0.3) -> pd.DataFrame:
rng = np.random.default_rng(seed)
n = n_giorni * 24
ts = 1_782_000_000_000 + np.arange(n) * 3_600_000
x = rng.normal(mu_h, sd_h, n)
return pd.DataFrame(dict(ts=ts, dt=pd.to_datetime(ts, unit="ms", utc=True), net_modeled=x,
net_real=x, pos_btc=pos, pos_eth=pos))
def test_kill_non_leggibile_sotto_180_giorni_attivi():
r = _serie(81, -0.001, 0.004)
sh, n = PP.sharpe_giornaliero(r)
assert n == 81 and sh < PP.KILL_SHARPE # Sharpe pessimo...
stato, motivo = PP.kill_check(sh, PP.giorni_attivi(r), pd.Timestamp("2026-09-10", tz="UTC"))
assert stato == "NON_MATURO" and "99" in motivo # ...ma il kill non e' leggibile
def test_kill_scatta_sopra_180_giorni_solo_sotto_la_soglia():
r_male = _serie(200, -0.001, 0.004)
sh, _ = PP.sharpe_giornaliero(r_male)
assert PP.kill_check(sh, PP.giorni_attivi(r_male), pd.Timestamp("2027-01-01", tz="UTC"))[0] == "KILL"
oggi = pd.Timestamp("2027-01-01", tz="UTC")
assert PP.kill_check(-0.49, 200, oggi)[0] == "SUPERATO" # la soglia e' STRETTA: -0,49 passa
assert PP.kill_check(-0.51, 200, oggi)[0] == "KILL"
assert PP.kill_check(float("nan"), 200, oggi)[0] == "NON_MISURABILE"
def test_i_giorni_flat_non_contano_come_attivi():
r = _serie(200, 0.0, 0.004)
r.loc[r.index[: 50 * 24], ["pos_btc", "pos_eth"]] = 0.0
assert PP.giorni_attivi(r) == 150
def test_la_lente_del_kill_e_giornaliera_non_oraria():
"""§54: sulla lente oraria lo stesso forward vale +2,04 contro +1,56. Con autocorrelazione
positiva intragiornaliera la lente oraria SOVRASTIMA: il kill usa la giornaliera."""
rng = np.random.default_rng(3)
n = 120 * 24
base = np.repeat(rng.normal(0.0004, 0.004, 120), 24) # shock comune al giorno
x = base + rng.normal(0, 0.0005, n)
ts = 1_782_000_000_000 + np.arange(n) * 3_600_000
r = pd.DataFrame(dict(ts=ts, dt=pd.to_datetime(ts, unit="ms", utc=True), net_modeled=x,
net_real=x, pos_btc=0.3, pos_eth=0.3))
sh_g, _ = PP.sharpe_giornaliero(r)
assert PP._sharpe_orario(r) > sh_g * 2 # la lente oraria gonfia; il kill non la usa
def test_veto_sotto_80_pct_ricostruibili():
ok, m = PP.veto_check(0.79, 0.5, 0.5, 10)
assert not ok and "79.0%" in m
assert PP.veto_check(0.80, 0.5, 0.5, 10)[0]
def test_veto_su_crescita_materiale_non_su_una_barra(monkeypatch):
"""Il 10/09 la serie reale aveva UNA barra divergente su 1943 (26/08 00:00, il giorno del fix
di advance()) e la prima stesura del veto la leggeva come 'crescita' (0,03/g contro 0,01/g)."""
assert PP.veto_check(0.999, 0.012, 0.032, 1)[0] # 1 barra: non e' crescita
ok, m = PP.veto_check(0.95, 0.10, 0.50, 12) # 12 recenti, tasso 5x: crescita
assert not ok and "crescita" in m
assert PP.veto_check(0.95, 0.30, 0.50, 12)[0] # tasso < 2x: no
def test_ricostruzione_riproduce_una_serie_generata_come_advance(monkeypatch):
"""Feed sintetico, target congelato deterministico (monkeypatch su prevday_target): la serie
registrata con l'aritmetica di advance si ricostruisce al 100%; una barra manomessa no."""
n = 24 * 10
ts = 1_782_000_000_000 + np.arange(n) * 3_600_000
rng = np.random.default_rng(7)
dfs = {}
for a, p0 in (("BTC", 60000.0), ("ETH", 2000.0)):
close = p0 * np.cumprod(1 + rng.normal(0, 0.003, n))
dfs[a] = pd.DataFrame(dict(timestamp=ts, datetime=pd.to_datetime(ts, unit="ms", utc=True), close=close))
tgt = {a: np.where((np.arange(n) // 24) % 2 == 0, 0.3, -0.3) for a in dfs}
monkeypatch.setattr(PP, "prevday_target",
lambda df: tgt["BTC"] if float(df["close"].iloc[0]) > 10000 else tgt["ETH"])
start = int(ts[23])
# registrazione con la stessa aritmetica di advance (ribilanciamento continuo)
pos = {a: float(tgt[a][23]) for a in dfs}
rows = []
for i in range(24, n):
net = 0.0
for a in dfs:
c = dfs[a]["close"].values
rr = c[i] / c[i - 1] - 1
net += PP.WEIGHT * (pos[a] * rr - PP.FEE_SIDE * abs(tgt[a][i] - pos[a]))
pos[a] = float(tgt[a][i])
rows.append(dict(ts=int(ts[i]), net_modeled=round(net, 6), pos_btc=pos["BTC"], pos_eth=pos["ETH"]))
r = pd.DataFrame(rows); r["dt"] = pd.to_datetime(r["ts"], unit="ms", utc=True)
rc = PP.ricostruzione(r, dfs, start)
assert rc["ricostruibili"] == rc["n"] == n - 24 and rc["assenti_nel_feed"] == 0
r2 = r.copy(); r2.loc[r2.index[-1], "net_modeled"] += 0.01
rc2 = PP.ricostruzione(r2, dfs, start)
assert rc2["ricostruibili"] == rc2["n"] - 1 and rc2["div_recenti"] == 1
+512
View File
@@ -0,0 +1,512 @@
"""Test di `scripts/research/r0909_crash_catturato.py` (2026-09-09) — il `f` di stress su quote vere.
Questo script porta l'UNICA condizione che riapre la decisione vincolante del 19/06 ("niente
short-vol da modello in deploy"): un `f` di stress reale misurato su un crash catturato. Un
numero con quel peso ha bisogno di guardie sul MECCANISMO, non solo sull'esito:
* il prezzatore vettorizzato dev'essere *lo stesso* di `cblib.bs_put`, elemento per elemento e
nei casi degeneri (T0, sig=0 intrinseco): se diverge, il denominatore di ogni `f` e' un
altro modello e il rapporto non significa piu' niente;
* **lo spot dev'essere CAUSALE.** Il feed 1h e' etichettato all'APERTURA: la barra 20:00 chiude
alle 21:00, quindi `S.asof(ts)` guardava un'ora AVANTI. `cblib.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)
# ===========================================================================================
# lo spot e il DVOL arrivano da cblib GIA' causali: qui non si ri-spostano (doppio shift = un'ora
# di RITARDO invece di un'ora di anticipo, stesso errore col segno opposto)
# ===========================================================================================
def test_lo_script_usa_le_serie_di_cblib_senza_ri_spostarle():
"""🚨 Il 09/09 mattina lo script riparava da solo il look-ahead con `spot_causale`; la sera la
riparazione e' entrata in `cblib.spot_series` / `dvol_series` (debito §5.18). Applicare lo
spostamento DUE volte darebbe la chiusura di due ore prima. DEVE FALLIRE SE: qualcuno
reintroduce uno shift locale, o `cblib` smette di essere causale."""
import inspect
import cblib
src = inspect.getsource(C.misura)
assert "CB.spot_series(asset)" in src and "CB.dvol_series(asset)" in src
assert "spot_causale" not in src and "Timedelta(hours=1)" not in src
# l'UNICO `causale(` ammesso in misura() e' quello del contesto, e toglie un giorno al DVOL per la
# tabella di calendario: un `causale(CB.spot_series(...), "1h")` sarebbe il doppio shift
assert src.count("causale(") == 1 and 'CB.causale(V, "-1D")' in src
assert not hasattr(C, "spot_causale"), "lo shift locale e' stato rimosso: vive in cblib.causale"
# cblib e' causale (controllo positivo sul difetto che ripara)
idx = pd.date_range("2026-06-01", periods=3, freq="h", tz="UTC")
S = pd.Series([1.0, 2.0, 3.0], index=idx)
assert cblib.causale(S, "1h").asof(idx[1]) == 1.0 and S.asof(idx[1]) == 2.0
# ===========================================================================================
# bs_put_vec == cblib.bs_put, elemento per elemento
# ===========================================================================================
def test_bs_put_vec_e_IDENTICA_a_cblib_bs_put_elemento_per_elemento():
"""DEVE FALLIRE SE: la vettorizzata devia dal prezzatore del sleeve — il denominatore di
ogni `f` smetterebbe di essere "lo stesso trade nel modello"."""
S = np.array([2400.0, 2000.0, 1900.0, 1800.0, 1500.0, 900.0])
T = np.array([0.10, 0.0383, 0.0192, 0.0027, 0.0027, 0.20])
sig = np.array([0.35, 0.50, 0.65, 1.10, 0.50, 0.05])
for K in (K_S, K_L, 2200.0):
v = C.bs_put_vec(S, K, T, sig)
atteso = [CB.bs_put(float(s), K, float(t), float(g)) for s, t, g in zip(S, T, sig)]
assert v == pytest.approx(atteso, rel=0, abs=1e-10), K
def test_bs_put_vec_a_T_o_sigma_nulli_da_l_INTRINSECO():
"""I casi degeneri entrano nel conto: all'ora di scadenza T=0 e il valore e' il payoff, non
un prezzo. DEVE FALLIRE SE: qualcuno toglie la maschera `ok` e lascia passare log/0 o NaN
dentro `mod_spread`."""
S = np.array([2400.0, 1900.0, 1500.0, 1000.0])
for T, sig in ((np.zeros(4), np.full(4, 0.5)),
(np.full(4, -1.0), np.full(4, 0.5)),
(np.full(4, 0.05), np.zeros(4))):
v = C.bs_put_vec(S, K_S, T, sig)
assert v == pytest.approx(np.maximum(K_S - S, 0.0), abs=1e-12)
assert np.isfinite(v).all()
# ===========================================================================================
# Quote + percorso su quote sintetiche
# ===========================================================================================
def test_percorso_costa_lo_spread_nel_VERSO_GIUSTO():
"""chiudi_short = (ASK corta BID lunga)·spot: si ricompra cio' che si e' venduto e si
rivende cio' che si e' comprato, cioe' si attraversa lo spread due volte.
liquida_long = (BID corta ASK lunga)·spot e' lo specchio.
DEVE FALLIRE SE: qualcuno "corregge" i lati a mid il percorso costerebbe meno e il fatto
misurato si ammorbidirebbe da solo."""
Q = _quote(salta_lunga=(1,))
assert list(Q.bid.index) == list(ORE)
assert Q.spot.iloc[0] == 2000.0
assert Q.dvol.iloc[0] == pytest.approx(0.50), "il DVOL entra in frazione, non in punti"
p = C.percorso(Q, ORE[0] - pd.Timedelta(hours=1), INS_S, INS_L, K_S, K_L)
assert len(p) == 3, "l'ora con la sola gamba corta dev'essere ESCLUSA"
assert list(p["ts"]) == [ORE[0], ORE[2], ORE[3]]
assert p["ts"].is_monotonic_increasing
assert p["chiudi_grezzo"].tolist() == pytest.approx([(0.032 - 0.010) * 2000.0] * 3)
assert p["chiudi_short"].tolist() == pytest.approx([(0.032 - 0.010) * 2000.0] * 3), (
"dentro la larghezza il taglio non deve mordere: sarebbe un costo inventato")
assert p["liquida_long"].tolist() == pytest.approx([(0.030 - 0.012) * 2000.0] * 3)
assert p["liquida_put"].tolist() == pytest.approx([0.010 * 2000.0] * 3)
assert p["iv_s"].tolist() == pytest.approx([48.0, 50.0, 51.0])
assert p["iv_l"].tolist() == pytest.approx([57.0, 59.0, 60.0])
assert p["spot"].tolist() == pytest.approx([2000.0] * 3)
# il valore MODELLATO e' quello del sleeve, agli stessi strike e alla stessa ora
for i, t in enumerate([ORE[0], ORE[2], ORE[3]]):
T = max((C.EXP - t).total_seconds(), 0.0) / 86400.0 / 365.25
atteso = CB.bs_put(2000.0, K_S, T, 0.5) - CB.bs_put(2000.0, K_L, T, 0.5)
assert p["mod_spread"].iloc[i] == pytest.approx(atteso, abs=1e-10)
assert p["mod_put"].iloc[i] == pytest.approx(CB.bs_put(2000.0, K_L, T, 0.5), abs=1e-10)
def test_percorso_TAGLIA_il_costo_di_chiusura_alla_LARGHEZZA():
"""🚨 Uno spread a rischio definito non puo' costare piu' della larghezza per chiudersi: si
lascia regolare. A T0 le quote arrivano al 158% di spread relativo, e senza il taglio il
"peggior MTM" era un artefatto di book (2,6× il modello, corretto in revisione a 1,08).
`chiudi_grezzo` resta accanto perche' il taglio va CONTATO, non nascosto (P12)."""
Q = _quote(bid_s=0.005, ask_s=0.200, bid_l=0.010, ask_l=0.012)
p = C.percorso(Q, ORE[0] - pd.Timedelta(hours=1), INS_S, INS_L, K_S, K_L)
assert p["chiudi_grezzo"].tolist() == pytest.approx([(0.200 - 0.010) * 2000.0] * 4) # 380
assert p["chiudi_short"].tolist() == pytest.approx([LARGHEZZA] * 4) # 300
assert (p["chiudi_short"] <= LARGHEZZA + 1e-12).all()
assert (p["chiudi_grezzo"] > p["chiudi_short"]).all(), "il grezzo deve restare visibile"
# la gamba lunga liquidata non puo' valere meno di zero
assert p["liquida_long"].tolist() == pytest.approx([0.0] * 4)
def test_percorso_taglia_liquida_long_anche_in_ALTO():
"""L'altro estremo della stessa regola: un debit spread non vale piu' della sua larghezza."""
Q = _quote(bid_s=0.200, ask_s=0.210, bid_l=0.005, ask_l=0.010)
p = C.percorso(Q, ORE[0] - pd.Timedelta(hours=1), INS_S, INS_L, K_S, K_L)
assert p["liquida_long"].tolist() == pytest.approx([LARGHEZZA] * 4)
def test_percorso_parte_DOPO_l_ingresso_e_non_lo_include():
"""Il mark-to-market comincia all'ora successiva: includere l'ora d'ingresso metterebbe
dentro un P&L noto a priori (il mezzo spread pagato entrando)."""
Q = _quote()
p = C.percorso(Q, ORE[0], INS_S, INS_L, K_S, K_L)
assert list(p["ts"]) == [ORE[1], ORE[2], ORE[3]]
assert C.percorso(Q, ORE[-1], INS_S, INS_L, K_S, K_L).empty
def test_percorso_e_vuoto_se_uno_strumento_non_esiste_nella_tabella():
"""P5: "non vedo" ha uno stato suo (DataFrame vuoto → `trade` ritorna None), non un valore
inventato."""
Q = _quote()
assert C.percorso(Q, ORE[0], INS_S, "ETH-19JUN26-9999-P", K_S, 9999.0).empty
assert C.percorso(Q, ORE[0], "ETH-19JUN26-9999-P", INS_L, 9999.0, K_L).empty
def test_Quote_scarta_le_quote_NON_quotate():
"""bid o ask a zero (o assenti) non sono prezzi: entrano come "non quotato", non come 0."""
seg = _seg()
seg.loc[seg["instrument_name"] == INS_L, "bid"] = 0.0 # gamba lunga senza bid
Q = C.Quote(seg, _serie_spot(), _serie_dvol())
assert INS_L not in Q.bid.columns
assert C.percorso(Q, ORE[0], INS_S, INS_L, K_S, K_L).empty
# ===========================================================================================
# la guardia di geometria in_geom (TOL_D)
# ===========================================================================================
def test_in_geom_e_FALSO_quando_le_delta_disponibili_sono_lontane():
"""`pick_legs` prende SEMPRE la delta piu' vicina: su una griglia rada restituisce una
struttura che non e' VRP01 e non lo dice. `in_geom` e' il pezzo che lo dice.
DEVE FALLIRE SE: qualcuno allarga TOL_D o toglie il flag i 78 snapshot BTC scartati
il 09/09 sono esattamente quelli che quel flag ha fermato."""
t = _trade(-0.50, -0.01)
assert t is not None
assert t["in_geom"] is False
assert t["d_short"] == -0.50 and t["d_long"] == -0.01
def test_in_geom_e_VERO_sulla_geometria_di_VRP01():
"""Controllo POSITIVO (M15): un rilevatore tarato per non segnalare va provato su un caso
che DEVE passare, altrimenti "nessun trade fuori geometria" non significa niente."""
t = _trade(C.SHORT_D, C.LONG_D)
assert t["in_geom"] is True
assert t["d_short"] == pytest.approx(C.SHORT_D) and t["d_long"] == pytest.approx(C.LONG_D)
def test_in_geom_il_confine_e_esattamente_TOL_D():
"""I due confini si DERIVANO da `TOL_D`/`SHORT_D`/`LONG_D`, non si riscrivono (P1).
Il confine ESATTO non e' asseribile in virgola mobile: `-0.28 - 0.08` vale
-0.36000000000000004, quindi la delta "sul confine" cade FUORI. Si prova un micron dentro
e un micron fuori e' il confine che conta, non il suo ultimo bit."""
dentro_s, fuori_s = C.SHORT_D - C.TOL_D[0] + 1e-6, C.SHORT_D - C.TOL_D[0] - 1e-6
dentro_l, fuori_l = C.LONG_D - C.TOL_D[1] + 1e-6, C.LONG_D - C.TOL_D[1] - 1e-6
assert _trade(dentro_s, dentro_l)["in_geom"] is True, "un micron dentro deve passare"
assert _trade(fuori_s, dentro_l)["in_geom"] is False, "gamba corta appena fuori"
assert _trade(dentro_s, fuori_l)["in_geom"] is False, "gamba lunga appena fuori"
def test_P1_le_delta_bersaglio_vengono_dal_SLEEVE():
"""DEVE FALLIRE SE: qualcuno ridichiara 0,28/0,10 nello script — la misura punterebbe su
una geometria diversa da quella del sleeve che dovrebbe descrivere."""
from src.portfolio.sleeves import VRP_CFG
assert C.SHORT_D is VRP_CFG["short_delta"]
assert C.LONG_D is VRP_CFG["long_delta"]
assert C.FEE_FRAC is VRP_CFG["fee_frac"]
# ===========================================================================================
# trade() — i conti, e cio' che si RIFIUTA di calcolare
# ===========================================================================================
def test_f_exp_e_f_mtm_sono_NAN_a_SEGNI_DISCORDI():
"""Un rapporto fra un reale NEGATIVO e un modello POSITIVO non e' un fattore di stress:
e' due fatti diversi messi in frazione. Qui il credito reale (0,2 $) sta sotto le fee
d'ingresso (1,2 $) mentre il credito modellato e' pieno pnl_real < 0 < pnl_mod.
DEVE FALLIRE SE: qualcuno toglie le condizioni di segno e pubblica un `f` negativo."""
t = _trade(C.SHORT_D, C.LONG_D, bid_s=0.0101, ask_s=0.0102, bid_l=0.009, ask_l=0.010)
assert t["pnl_real"] < 0 < t["pnl_mod"], (t["pnl_real"], t["pnl_mod"])
assert np.isnan(t["f_exp"])
assert t["mtm_real_min"] < 0 < t["mtm_mod_min"], (t["mtm_real_min"], t["mtm_mod_min"])
assert np.isnan(t["f_mtm"])
def test_f_exp_e_UN_NUMERO_quando_perdono_entrambi():
"""Controllo POSITIVO del test qui sopra: con lo spot a 1500 a scadenza perdono sia il
reale sia il modello, e allora `f_exp` esiste ed e' esattamente il rapporto delle perdite."""
t = _trade(C.SHORT_D, C.LONG_D, bid_s=0.0101, ask_s=0.0102, bid_l=0.009, ask_l=0.010,
ST=1500.0)
assert t["pnl_real"] < 0 and t["pnl_mod"] < 0
assert t["f_exp"] == pytest.approx(t["pnl_real"] / t["pnl_mod"], abs=1e-12)
assert np.isfinite(t["f_exp"]) and t["f_exp"] > 0
def test_itm_pieno_dichiara_quando_f_exp_e_TAUTOLOGICO():
"""Se ENTRAMBE le gambe finiscono ITM il payoff e' la larghezza per il reale e per il
modello: `f_exp` si riduce a una funzione di `f_net` e non e' piu' un'informazione nuova.
Il flag esiste per poterlo DIRE (l'83% dei trade ETH del 09/09), non per correggerlo."""
assert _trade(C.SHORT_D, C.LONG_D, ST=1500.0)["itm_pieno"] is True # 1500 <= K_L
assert _trade(C.SHORT_D, C.LONG_D, ST=2000.0)["itm_pieno"] is False
assert _trade(C.SHORT_D, C.LONG_D, ST=K_L)["itm_pieno"] is True # confine incluso
assert _trade(C.SHORT_D, C.LONG_D, ST=K_L + 1.0)["itm_pieno"] is False
def test_trade_CONTA_le_ore_tagliate_invece_di_nasconderle():
"""P12/D5: una riparazione silenziosa e' un'invenzione. Il taglio alla larghezza cambia il
peggior MTM, quindi le ore in cui morde vanno contate e stampate accanto al numero."""
normale = _trade(C.SHORT_D, C.LONG_D)
assert normale["n_ore"] == 3 and normale["n_tagli"] == 0
tagliato = _trade(C.SHORT_D, C.LONG_D, ask_s=0.200) # (0,200 0,010)·2000 = 380 > 300
assert tagliato["n_ore"] == 3 and tagliato["n_tagli"] == 3
assert tagliato["mtm_real_min"] == pytest.approx(tagliato["cred_real"] - LARGHEZZA)
def test_f_net_e_il_credito_reale_su_quello_modellato():
"""Il credito reale si incassa al BID della corta e si paga all'ASK della lunga: e' la
convenzione conservativa di `cblib.f_factors`, e vale 0,76 su ETH nel crollo (0,74 con lo spot
e il DVOL non causali della mattina del 09/09: i due numeri vanno cambiati insieme)."""
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.76, abs=0.03)
assert eth["f_exp"] == pytest.approx(1.04, abs=0.03)
assert eth["q_itm_pieno"] == pytest.approx(0.83, abs=0.10), (
"la quota di trade a f_exp tautologico e' cambiata: cambia la lettura del 1,04")
assert eth["n_strutture"] <= 10, (
f"{eth['n_pre']} snapshot su {eth['n_strutture']} strutture: se le strutture "
"diventassero tante, la nota sulla risoluzione andrebbe riscritta")
assert eth["gate_aperto"] == pytest.approx(0.0, abs=1e-12), (
"il gate IV-rank di VRP01 risulta APERTO: cambia il senso della misura, che oggi dice "
"'lo sleeve questo trade non lo avrebbe fatto'")
assert eth["perdita_su_risk"] == pytest.approx(1.01, abs=0.05)
assert 0.9 < eth["f_mtm"] < 1.5, (
f"peggior MTM {eth['f_mtm']:.2f}: col taglio alla larghezza sta attorno a 1; sopra 1,5 "
"vuol dire che il taglio non morde piu' e il numero torna a essere book, non rischio")
assert 0 < eth["n_tagli"] < eth["n_ore"] * 0.20, (
"il taglio deve mordere su una minoranza di ore: se e' zero non serve, se e' la "
"maggioranza il campione e' un altro")
btc = s["BTC"]
assert btc["n_pre"] == 0 and np.isnan(btc["f_exp"]), (
"BTC era NON misurabile pre-crollo: se ora lo e', il verdetto cambia e va riscritto, "
"non aggiustato")
assert btc["durante"]["n"] > 0 and 0.0 < btc["durante"]["q_perde"] < 1.0
v = C.verdetto(s)
assert v.startswith(ESITO)
assert "BTC: nessun ingresso pre-crollo in geometria" in v
assert "gate IV-rank CHIUSO nel 0% delle ore" in v
assert v.endswith("famiglia INVERSE")
+324
View File
@@ -0,0 +1,324 @@
"""Test di `scripts/research/r0909_deribit_universo.py` (2026-09-09) — l'universo Deribit letto oggi.
Lo script e' il filtro che decide COSA vale la pena misurare (C6): se sbaglia a leggere il venue,
sbaglia l'elenco delle monete candidate, e la misura successiva parte dalla domanda sbagliata.
Qui si congelano i tre pezzi che possono sbagliare in silenzio:
* i FILTRI un perpetual `state != "open"` non e' negoziabile e non deve comparire in una
tabella intitolata "aperti"; un future datato non e' un perpetual;
* lo SPREAD assente senza bid o senza ask lo spread e' NaN, non zero (P5: "non vedo" non e'
"spread perfetto", e uno zero stampato in quella colonna direbbe il contrario del vero);
* l'universo di XS01 — si IMPORTA da `sleeves.XS_UNIVERSE` (P1). Una lista ricopiata qui
risponderebbe "13/19 quotate" anche dopo che qualcuno ha cambiato le gambe del sleeve.
NESSUN test tocca la rete e nessuno scrive in `data/`: la cache viene deviata in `tmp_path`
e `get()` e' sostituita da una che fallisce se qualcuno la chiama.
"""
from __future__ import annotations
import inspect
import json
import sys
import time
from pathlib import Path
import numpy as np
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
import r0909_deribit_universo as U # noqa: E402
LETTO_A = "2026-09-09T15:05:43+00:00"
def _ms(giorno: str) -> int:
return int(time.mktime(time.strptime(giorno, "%Y-%m-%d")) * 1000) + int(
time.timezone * 1000)
def _instr(coin: str, nome: str, stato: str, listato: str, min_amt: float) -> dict:
# creation_timestamp e' letto con time.gmtime -> si costruisce in UTC puro
import calendar
ts = calendar.timegm(time.strptime(listato + " 12:00", "%Y-%m-%d %H:%M")) * 1000
return dict(base_currency=coin, instrument_name=nome, state=stato,
creation_timestamp=ts, min_trade_amount=min_amt)
def _bs(nome: str, bid, ask, mark, vol, oi=0.0, funding=0.0) -> dict:
return dict(instrument_name=nome, bid_price=bid, ask_price=ask, mark_price=mark,
volume_usd=vol, open_interest=oi, funding_8h=funding)
def _lettura() -> dict:
"""Una lettura sintetica del venue: 4 perpetual aperti, 1 INATTIVO, 2 future datati (di cui
uno sul DVOL l'unico strumento long-vol diretto del venue)."""
fut = [
_instr("XRP", "XRP_USDC-PERPETUAL", "open", "2022-03-16", 1.0),
_instr("ETH", "ETH_USDC-PERPETUAL", "open", "2022-03-14", 0.01),
_instr("ADA", "ADA_USDC-PERPETUAL", "open", "2022-03-21", 10.0),
_instr("PAXG", "PAXG_USDC-PERPETUAL", "open", "2024-12-02", 0.001),
_instr("APT", "APT_USDC-PERPETUAL", "inactive", "2022-11-02", 1.0),
_instr("BTC", "BTC_USDC-27JUN26", "open", "2022-03-09", 0.001),
_instr("BTCDVOL", "BTCDVOL_USDC-30SEP26", "open", "2026-06-01", 1.0),
]
bs = [
_bs("XRP_USDC-PERPETUAL", 2.0, 2.002, 2.0, 38.13e6, oi=3.0e6, funding=4.0e-5),
_bs("ETH_USDC-PERPETUAL", 3000.0, 3000.3, 3000.0, 32.72e6, oi=8.0e3, funding=1.05e-4),
_bs("ADA_USDC-PERPETUAL", None, 0.6, 0.6, 3.33e6, oi=1.0e6, funding=None),
_bs("PAXG_USDC-PERPETUAL", 0.0, 3600.0, 3600.0, 0.18e6, oi=2.0e3, funding=0.0),
_bs("APT_USDC-PERPETUAL", 1.0, 1.1, 1.0, 999e6, oi=1.0, funding=0.0),
_bs("BTC_USDC-27JUN26", 100000.0, 100001.0, 100000.0, 500e6, oi=1.0, funding=0.0),
_bs("BTCDVOL_USDC-30SEP26", 30.0, 60.0, 45.0, 0.0, oi=12.0, funding=None),
]
return dict(letto_a=LETTO_A, fut=fut, bs=bs,
opt_usdc=[dict(base_currency="SOL")] * 3 + [dict(base_currency="BTC")] * 2,
opt_inv={"BTC": 912, "ETH": 790})
# ===========================================================================================
# (a) tabella / sintesi / verdetto su una lettura SINTETICA
# ===========================================================================================
def test_tabella_esclude_i_perpetual_NON_APERTI():
"""Un perpetual `state != "open"` non e' negoziabile: se entrasse, sarebbe pure il piu'
liquido della tabella (999 M$ di volume stantio) e comparirebbe in cima all'elenco dei
candidati. DEVE FALLIRE SE: qualcuno toglie il filtro sullo stato."""
d = _lettura()
rows = U.tabella(d)
assert "APT" not in {r["coin"] for r in rows}
s = U.sintesi(rows, d)
assert s["inattivi"] == ["APT_USDC-PERPETUAL"], (
"un perpetual inattivo non va nella tabella, ma va DETTO: e' la differenza fra "
"'non esiste' e 'esiste e non si puo' negoziare' (P5)")
def test_un_perpetual_INATTIVO_conta_come_gamba_XS01_MANCANTE():
"""APT e' in XS01 ed e' listato sul venue, ma inattivo: la riga "N/19 quotate" deve
contarlo fra i mancanti, o il filtro di eseguibilita' direbbe il falso (C6)."""
d = _lettura()
s = U.sintesi(U.tabella(d), d)
assert "APT" in s["xs01_mancanti"] and "APT" not in s["xs01_listate"]
def test_tabella_esclude_i_FUTURE_DATATI_ma_la_sintesi_li_CONTA():
"""`kind=future` su Deribit contiene anche i future con scadenza: la domanda della tabella
e' sui PERPETUAL, e un future datato non e' una gamba direzionale continua. Restano contati
(`n_datati`) perche' e' li' dentro che vive il future sul DVOL."""
d = _lettura()
rows = U.tabella(d)
assert {r["coin"] for r in rows} == {"XRP", "ETH", "ADA", "PAXG"}
assert len(rows) == 4
assert U.sintesi(rows, d)["n_datati"] == 2
def test_il_future_sul_DVOL_viene_isolato_coi_suoi_numeri_di_negoziabilita():
"""C5/C6: e' l'unico strumento long-vol DIRETTO del venue, quindi va guardato — e va
guardato con bid/ask e OI accanto, perche' e' li' che si vede che non e' negoziabile
(bid 30 / ask 60 = 67% di spread). Un elenco di nomi non basterebbe."""
d = _lettura()
s = U.sintesi(U.tabella(d), d)
assert [f["nome"] for f in s["dvol_fut"]] == ["BTCDVOL_USDC-30SEP26"], (
"il future datato SENZA 'DVOL' nel nome non deve entrare qui")
f = s["dvol_fut"][0]
assert (f["stato"], f["min_amt"], f["bid"], f["ask"], f["oi"]) == ("open", 1.0, 30.0, 60.0, 12.0)
assert f["vol_usd"] == 0.0
def test_tabella_e_ORDINATA_per_volume_decrescente():
rows = U.tabella(_lettura())
vol = [r["vol_usd"] for r in rows]
assert vol == sorted(vol, reverse=True)
assert [r["coin"] for r in rows] == ["XRP", "ETH", "ADA", "PAXG"]
def test_lo_spread_e_NAN_senza_bid_o_senza_ask():
"""P5: senza un lato del book lo spread non e' 0 bps (che vorrebbe dire "perfetto"), e'
"non misurabile". DEVE FALLIRE SE: qualcuno mette un `or 0.0` anche su bid/ask."""
rows = {r["coin"]: r for r in U.tabella(_lettura())}
assert np.isnan(rows["ADA"]["spread_bps"]), "bid assente -> spread non misurabile"
assert np.isnan(rows["PAXG"]["spread_bps"]), "bid a zero -> spread non misurabile"
assert rows["XRP"]["spread_bps"] == pytest.approx((2.002 - 2.0) / 2.001 * 1e4)
assert rows["ETH"]["spread_bps"] == pytest.approx(0.3 / 3000.15 * 1e4)
def test_il_lotto_minimo_e_in_DOLLARI_non_in_unita():
"""C3: prima di misurare il rendimento a un capitale dato si misura il lotto minimo del
venue e il confronto col pavimento $5 si fa in dollari, non in unita' della moneta."""
rows = {r["coin"]: r for r in U.tabella(_lettura())}
assert rows["XRP"]["min_usd"] == pytest.approx(1.0 * 2.0)
assert rows["ADA"]["min_usd"] == pytest.approx(10.0 * 0.6)
assert rows["ETH"]["min_amt"] == 0.01
def test_la_data_di_listing_e_quella_del_venue():
rows = {r["coin"]: r for r in U.tabella(_lettura())}
assert rows["XRP"]["listato"] == "2022-03-16"
assert rows["PAXG"]["listato"] == "2024-12-02"
def test_P1_le_gambe_di_XS01_si_IMPORTANO_dal_sleeve():
"""*Un controllo puntato su una configurazione diversa da quella che gira passa sempre.*
Se l'elenco fosse ricopiato nello script, "13/19 quotate" resterebbe vero anche dopo un
cambio di universo del sleeve cioe' smetterebbe di controllare qualcosa."""
from src.portfolio import sleeves
assert U.XS_UNIVERSE is sleeves.XS_UNIVERSE
src = inspect.getsource(U)
assert "from src.portfolio.sleeves import XS_UNIVERSE" in src
assert "XS_UNIVERSE = [" not in src, "universo XS01 ridichiarato nello script"
def test_le_gambe_XS01_mancanti_si_CALCOLANO_dall_universo():
"""La riga "mancano [...]" e' l'elenco delle gambe di XS01 non quotate sul venue: si
ricava dall'universo importato, quindi si aggiorna da sola se il sleeve cambia."""
from src.portfolio.sleeves import XS_UNIVERSE
rows = U.tabella(_lettura())
s = U.sintesi(rows, _lettura())
quotate = {"XRP", "ETH", "ADA"} # PAXG non e' in XS01
assert s["xs01_listate"] == sorted(c for c in XS_UNIVERSE if c in quotate)
assert s["xs01_mancanti"] == [c for c in XS_UNIVERSE if c not in quotate]
assert set(s["xs01_listate"]) | set(s["xs01_mancanti"]) == set(XS_UNIVERSE)
assert {r["coin"] for r in rows if r["xs01"]} == quotate
def test_liquidi_usa_la_soglia_dichiarata_e_il_confine_e_incluso():
"""La soglia $10M/giorno e' dichiarata nel modulo: il test la DERIVA, non la ricopia."""
d = _lettura()
for b in d["bs"]:
if b["instrument_name"] == "ADA_USDC-PERPETUAL":
b["volume_usd"] = U.SOGLIA_LIQUIDO_USD # esattamente in soglia -> liquido
if b["instrument_name"] == "PAXG_USDC-PERPETUAL":
b["volume_usd"] = U.SOGLIA_LIQUIDO_USD - 1.0 # un dollaro sotto -> no
s = U.sintesi(U.tabella(d), d)
assert s["liquidi"] == ["XRP", "ETH", "ADA"]
def test_sintesi_riporta_le_famiglie_di_opzioni_e_l_ora_della_lettura():
"""N10/§7: un numero letto dal venue vale con la sua ora. Senza `letto_a` la riga
diventerebbe una verita' senza data."""
d = _lettura()
s = U.sintesi(U.tabella(d), d)
assert s["letto_a"] == LETTO_A
assert s["opt_inv"] == {"BTC": 912, "ETH": 790}
assert s["opt_usdc"] == [("SOL", 3), ("BTC", 2)]
assert s["n_perp"] == 4
def test_verdetto_su_una_lettura_sintetica():
d = _lettura()
s = U.sintesi(U.tabella(d), d)
v = U.verdetto(s)
assert v.startswith("4 perpetual USDC; LIQUIDI (≥$10M/g): XRP, ETH; ")
assert "XS01 3/19 quotate" in v
assert "opzioni USDC-lineari su 2 sottostanti" in v
assert f"lettura del {LETTO_A}" in v
# ===========================================================================================
# (b) leggi() — la cache, e la garanzia che NESSUN test vada in rete
# ===========================================================================================
@pytest.fixture()
def cache_in_tmp(tmp_path, monkeypatch):
"""Devia la cache fuori da `data/_cache/` (debito §5.12: la suite non scrive nei file vivi)."""
p = tmp_path / "r0909_deribit_universo.json"
monkeypatch.setattr(U, "CACHE", p)
return p
def test_leggi_con_cache_presente_NON_tocca_la_rete(cache_in_tmp, monkeypatch):
"""DEVE FALLIRE SE: qualcuno rende la lettura sempre fresca — la suite comincerebbe a
dipendere dalla rete e dal venue, e un test rosso non direbbe piu' niente sul codice."""
def _vietato(path):
raise AssertionError(f"chiamata di rete durante un test: {path}")
monkeypatch.setattr(U, "get", _vietato)
atteso = _lettura()
cache_in_tmp.write_text(json.dumps(atteso))
d = U.leggi(usa_cache=True)
assert d["letto_a"] == LETTO_A
assert len(d["fut"]) == len(atteso["fut"])
# (i NaN dello spread non sono confrontabili con ==: si confronta il resto)
chiave = lambda rows: [(r["coin"], r["listato"], r["vol_usd"], r["min_usd"], r["xs01"])
for r in rows]
assert chiave(U.tabella(d)) == chiave(U.tabella(atteso))
def test_leggi_senza_cache_interroga_il_venue_UNA_volta_e_la_scrive(cache_in_tmp, monkeypatch):
"""La cache non e' un'ottimizzazione: e' la FONTE del numero stampato (una lettura e' un
istante). Deve finire su disco con la sua ora, o la riga del diario non ha piu' fonte."""
chiamate = []
def _finto(path):
chiamate.append(path)
if "get_book_summary" in path:
return _lettura()["bs"]
if "kind=option" in path and "currency=USDC" in path:
return _lettura()["opt_usdc"]
if "kind=option" in path:
return [0] * 912
return _lettura()["fut"]
monkeypatch.setattr(U, "get", _finto)
assert not cache_in_tmp.exists()
d = U.leggi(usa_cache=True)
assert cache_in_tmp.exists(), "la lettura non e' stata salvata: il numero resta senza fonte"
assert json.loads(cache_in_tmp.read_text())["letto_a"] == d["letto_a"]
assert d["letto_a"].endswith("+00:00"), "l'ora della lettura dev'essere in UTC dichiarato"
assert d["opt_inv"] == {"BTC": 912, "ETH": 912}
assert len(chiamate) == 5, "una chiamata per famiglia: fut, book summary, opzioni USDC, BTC, ETH"
# e una seconda lettura con cache NON ripete le chiamate
prima = len(chiamate)
U.leggi(usa_cache=True)
assert len(chiamate) == prima
def test_fresh_ignora_la_cache(cache_in_tmp, monkeypatch):
"""`--fresh` deve rileggere davvero: e' l'unico modo di aggiornare una tabella che per
costruzione invecchia (i perpetual del 18/08/2026 non c'erano un mese prima)."""
cache_in_tmp.write_text(json.dumps(_lettura()))
chiamate = []
monkeypatch.setattr(U, "get", lambda path: chiamate.append(path) or [])
U.leggi(usa_cache=False)
assert chiamate, "con usa_cache=False la cache non va usata"
def test_il_flag_fresh_e_DOCUMENTATO_e_cablato():
"""P15: una regola operativa va provata contro il codice che dovrebbe eseguirla. Qui:
il flag esiste nel `main`, e' l'unico modo di bypassare la cache, ed e' scritto nell'uso
che lo script stampa (chi legge la tabella deve sapere che puo' essere vecchia)."""
src = inspect.getsource(U)
assert 'leggi(usa_cache="--fresh" not in sys.argv)' in src, "il flag non e' cablato"
assert "--fresh per rileggere" in src, (
"il flag non e' documentato nell'uso che lo script stampa: chi legge la tabella non "
"sa come aggiornarla, e la lettura in cache invecchia in silenzio")
def test_lo_script_NON_ha_credenziali_ne_ordini():
"""L'API interrogata e' quella PUBBLICA: nessun token, nessun endpoint privato, nessun
ordine. Se un giorno servisse il conto, e' una decisione (debito §5.11), non un refactor."""
src = inspect.getsource(U)
assert U.API == "https://www.deribit.com/api/v2/public/"
for vietato in ("private/", "buy", "sell", "CERBERO", "api_key", "client_id"):
assert vietato not in src, f"lo script pubblico contiene '{vietato}'"
# ===========================================================================================
# la lettura VERA in cache (se c'e'): il file su cui poggia la riga del 09/09
# ===========================================================================================
def test_la_cache_vera_se_c_e_regge_la_tabella_pubblicata():
"""Non e' un test di rete: legge il JSON che lo script ha gia' salvato. Se manca, si salta."""
if not U.CACHE.exists():
pytest.skip(f"nessuna lettura in cache ({U.CACHE}): il test non va in rete")
d = json.loads(U.CACHE.read_text())
rows = U.tabella(d)
s = U.sintesi(rows, d)
assert s["n_perp"] == len(rows) > 0
assert [r["vol_usd"] for r in rows] == sorted([r["vol_usd"] for r in rows], reverse=True)
assert {"BTC", "ETH"} <= {r["coin"] for r in rows}, "il venue non quota piu' BTC/ETH?"
assert set(s["xs01_listate"]) | set(s["xs01_mancanti"]) == set(U.XS_UNIVERSE)
assert U.verdetto(s).endswith(f"lettura del {d['letto_a']}")
+385
View File
@@ -0,0 +1,385 @@
"""Test di `scripts/research/r0909_libro_nei_crolli.py` (2026-09-09) — il libro nei crolli.
Cosa protegge, in due strati:
* il MECCANISMO su dati SINTETICI, dove la risposta e' nota per costruzione — le classi
ESCLUSIVE col loro ordine, le finestre peggiori non sovrapposte, il beta con pendenza
imposta, i rami del verdetto. Qui un test rosso significa "il codice ha cambiato
comportamento", mai "il mercato ha cambiato idea".
* la RIPRODUZIONE del risultato pubblicato il 09/09 sui dati veri: 4 POSITIVO / 6 immune /
2 PERDE su 12 finestre, libro > 0 in 8 (dal 2022: 8/8), e beta +0,0769 sulla finestra di
§46 che riproduce il +0,076 della memoria. E' M23: prima di pubblicare un numero nuovo, la
macchina deve ristampare quello vecchio.
🚨 LA REGRESSIONE PIU' IMPORTANTE DI QUESTO FILE. `classifica()` prova **immune PRIMA di
POSITIVO**, e l'ordine non e' un dettaglio: con l'ordine opposto lo stesso identico dato dava
"8/12 episodi in utile" invece di "4 POSITIVO / 6 immune". *L'ordine delle regole ERA il
verdetto* (corretto in revisione il 09/09). Un test lo congela.
P1: i pesi del libro NON si ridichiarano un test congela l'identita' fra i pesi usati dallo
script e `src.live.book.W_TP01` / `W_SKH`.
"""
from __future__ import annotations
import inspect
import re
import sys
from pathlib import Path
import numpy as np
import pandas as pd
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
import r0909_libro_nei_crolli as M # noqa: E402
RAW = ROOT / "data" / "raw"
def _feed_certificato_presente() -> bool:
return all((RAW / f"{a}_{tf}.parquet").exists()
for a in ("btc", "eth") for tf in ("1h", "5m"))
def _giorni(n: int, inizio: str = "2021-01-01") -> pd.DatetimeIndex:
return pd.date_range(inizio, periods=n, freq="D", tz="UTC")
# ===========================================================================================
# classifica() — classi ESCLUSIVE, e l'ORDINE in cui si provano
# ===========================================================================================
def test_classifica_prova_IMMUNE_PRIMA_di_POSITIVO():
"""🚨 La regressione del 09/09. Sugli stessi numeri l'ordine "POSITIVO prima" dava 8/12
episodi in utile; l'ordine dichiarato (immune prima) da' 4 POSITIVO + 6 immune.
DEVE FALLIRE SE: qualcuno rimette il test del segno davanti alla banda di immunita'
non e' un refactor, e' un altro verdetto."""
# peggiore #8 del 09/09: +0,21% di libro contro 26,54% di indice. E' dentro un decimo.
assert M.classifica(0.0021, -0.2654) == "immune+"
# peggiore #5: +4,26% contro 31,61% -> fuori dalla banda, e allora e' POSITIVO
assert M.classifica(0.0426, -0.3161) == "POSITIVO"
def test_classifica_immune_porta_il_SEGNO_con_se():
""""immune" non e' una classe cieca: +0,21% e 0,52% su un crollo del 26% sono entrambi
immunita', ma non sono la stessa notizia. DEVE FALLIRE SE: le due si fondono in una."""
assert M.classifica(0.0021, -0.2654) == "immune+"
assert M.classifica(-0.0052, -0.2583) == "immune-"
assert set(M.classifica(x, -0.30) for x in (0.001, -0.001)) == {"immune+", "immune-"}
def test_classifica_fuori_dalla_banda_resta_il_segno():
assert M.classifica(+0.05, -0.30) == "POSITIVO"
assert M.classifica(-0.05, -0.30) == "PERDE"
assert M.classifica(-0.0637, -0.3411) == "PERDE" # peggiore #1 del 09/09
def test_classifica_il_confine_della_banda_e_STRETTO():
"""Esattamente un decimo NON e' immune: il confronto e' `<`, non `<=`.
DEVE FALLIRE SE: qualcuno lo rilassa e sposta episodi dentro la banda."""
assert M.classifica(-0.010, -0.10) == "PERDE"
assert M.classifica(-0.010000001, -0.10) == "PERDE"
assert M.classifica(-0.009999999, -0.10) == "immune-"
assert M.classifica(+0.010, -0.10) == "POSITIVO"
assert M.classifica(+0.009999999, -0.10) == "immune+"
def test_classifica_a_indice_zero_non_esiste_immunita():
"""Caso limite DICHIARATO (non un bug): con indice 0 la banda ha ampiezza 0, quindi nulla
e' immune. Non morde negli episodi (per costruzione hanno indice molto negativo), ma chi
riusa `classifica()` altrove deve saperlo."""
assert M.classifica(0.0, 0.0) == "PERDE"
assert M.classifica(+0.001, 0.0) == "POSITIVO"
def test_classifica_un_libro_esattamente_fermo_e_immune_col_segno_MENO():
"""Zero non e' positivo: `libro > 0` e' falso, quindi la classe e' "immune-".
E' una convenzione, e va scritta perche' non e' l'unica possibile."""
assert M.classifica(0.0, -0.20) == "immune-"
# ===========================================================================================
# finestre_peggiori() — non sovrapposte, ordinate, al piu' N, e la peggiore e' la peggiore
# ===========================================================================================
def _serie_con_crolli(crolli: list[tuple[int, float]], n: int = 300) -> pd.Series:
"""Serie giornaliera piatta con crolli piantati a posizioni note: ogni `(i0, r)` mette
20 giorni consecutivi a rendimento `r` a partire dalla posizione `i0`."""
r = pd.Series(0.0, index=_giorni(n))
for i0, val in crolli:
r.iloc[i0:i0 + M.FINESTRA] = val
return r
CROLLI = [(30, -0.010), (100, -0.005), (180, -0.002)]
def test_finestre_peggiori_non_si_sovrappongono_e_sono_ordinate():
"""DEVE FALLIRE SE: il greedy smette di marcare gli intervalli occupati — senza quel filtro
le 12 "peggiori finestre" sarebbero 12 traslazioni di UNO stesso episodio, e la riga
"4/12" conterebbe 12 volte lo stesso crollo."""
idx = _serie_con_crolli(CROLLI)
prese = M.finestre_peggiori(idx, n=3)
assert len(prese) == 3
assert prese == sorted(prese), "l'elenco esce ordinato per data d'inizio"
for (s0, e0, _), (s1, _, _) in zip(prese, prese[1:]):
assert s1 > e0, f"finestre sovrapposte: {e0} e {s1}"
for s, e, _ in prese:
assert e - s == pd.Timedelta(days=M.FINESTRA - 1)
def test_finestre_peggiori_trova_DAVVERO_la_peggiore():
"""Il primo episodio dev'essere il minimo globale del rolling, non un minimo locale.
DEVE FALLIRE SE: qualcuno sostituisce il greedy dal peggiore con una scansione in avanti."""
idx = _serie_con_crolli(CROLLI)
prese = M.finestre_peggiori(idx, n=3)
roll = (1 + idx).rolling(M.FINESTRA).apply(np.prod, raw=True) - 1
peggiore = min(prese, key=lambda t: t[2])
assert peggiore[2] == pytest.approx(float(roll.min()), abs=1e-12)
assert peggiore[2] == pytest.approx((1 - 0.010) ** M.FINESTRA - 1, abs=1e-12)
assert peggiore[0] == idx.index[30] and peggiore[1] == idx.index[49]
valori = sorted(v for _, _, v in prese)
attesi = sorted((1 + r) ** M.FINESTRA - 1 for _, r in CROLLI)
assert valori == pytest.approx(attesi, abs=1e-12)
def test_finestre_peggiori_rispetta_il_tetto_n():
idx = _serie_con_crolli(CROLLI)
assert len(M.finestre_peggiori(idx, n=2)) == 2
assert len(M.finestre_peggiori(idx, n=1)) == 1
assert len(M.finestre_peggiori(idx, n=100)) <= 100
# ===========================================================================================
# beta_pieno / beta_ribasso — pendenza NOTA per costruzione
# ===========================================================================================
def test_beta_pieno_ritrova_una_pendenza_nota():
rng = np.random.default_rng(909)
i = pd.Series(rng.normal(0.0, 0.03, 400), index=_giorni(400))
assert M.beta_pieno(0.30 * i, i) == pytest.approx(0.30, abs=1e-9)
assert M.beta_pieno(0.0 * i, i) == pytest.approx(0.0, abs=1e-12)
assert M.beta_pieno(-0.75 * i, i) == pytest.approx(-0.75, abs=1e-9)
def test_beta_ribasso_guarda_SOLO_i_giorni_giu():
"""Controllo POSITIVO: su una serie asimmetrica (0,50 in salita, 0,10 in discesa) il beta
pieno NON puo' rispondere alla domanda del crollo — il beta al ribasso si'.
DEVE FALLIRE SE: il filtro `idx < soglia` sparisce e le due misure collassano in una."""
rng = np.random.default_rng(46)
i = pd.Series(rng.normal(0.0, 0.03, 600), index=_giorni(600))
libro = pd.Series(np.where(i.values < 0, 0.10 * i.values, 0.50 * i.values), index=i.index)
assert M.beta_ribasso(libro, i) == pytest.approx(0.10, abs=1e-9)
assert M.beta_pieno(libro, i) > 0.20, "il beta pieno MEDIA i due regimi: e' un'altra domanda"
def test_beta_ribasso_usa_la_soglia_che_riceve():
"""La riga [1] stampa quattro beta (pieno, finestra §46, idx<0, idx≤−3%): sono lenti
diverse, non una."""
rng = np.random.default_rng(3)
i = pd.Series(rng.normal(0.0, 0.03, 800), index=_giorni(800))
forte = i.values < -0.03
libro = pd.Series(np.where(forte, 0.90 * i.values, 0.10 * i.values), index=i.index)
assert (i < -0.03).sum() >= 20, "campione insufficiente: il test non avrebbe potenza"
b3 = M.beta_ribasso(libro, i, -0.03)
b0 = M.beta_ribasso(libro, i, 0.0)
assert b3 == pytest.approx(0.90, abs=1e-9), "sotto 3% la pendenza e' quella imposta"
assert abs(b0 - b3) > 0.05, (
"la soglia non entra nel calcolo: le lenti darebbero lo stesso numero e la riga [1] "
"stamperebbe quattro volte la stessa cosa")
def test_beta_ribasso_e_NAN_sotto_venti_osservazioni():
"""P5: "non vedo" non e' "beta zero". DEVE FALLIRE SE: qualcuno toglie la guardia e
pubblica una pendenza stimata su tre giorni."""
i = pd.Series(np.linspace(-0.05, -0.01, 10), index=_giorni(10))
assert np.isnan(M.beta_ribasso(0.3 * i, i)), "10 giorni non fanno una pendenza"
i25 = pd.Series(np.linspace(-0.05, -0.01, 25), index=_giorni(25))
assert M.beta_ribasso(0.3 * i25, i25) == pytest.approx(0.30, abs=1e-9)
def test_la_finestra_di_46_e_dichiarata_e_non_e_tutta_la_storia():
"""§46 e' un numero NATO su una finestra che comincia col DVOL (2021-03-24): confrontarlo
col beta di tutta la storia sarebbe P7 al contrario (etichettare un numero con la finestra
sbagliata). Lo script tiene le due misure separate questo test lo congela."""
assert M.FINESTRA_46 == pd.Timestamp("2021-03-24", tz="UTC")
assert M.BETA_MEMORIA == 0.076 and M.TOL_BETA == 0.01
# ===========================================================================================
# verdetto() — TRE affermazioni, tre domande diverse
# ===========================================================================================
def _m(guadagna=4, immune=6, n=12, beta_46=0.0769, giorni_libro=-0.0048,
giorni_pos=0.175, positivi=8, dal22=(8, 8), quota=1.08) -> dict:
return dict(beta_46=beta_46,
sintesi=dict(n_episodi=n, guadagna=guadagna, immune=immune,
perde=n - guadagna - immune, positivi=positivi, dal22=dal22,
giorni_pos=giorni_pos, giorni_libro=giorni_libro,
quota_short_nei_guadagni=quota))
def test_verdetto_separa_il_GIORNO_dalla_FINESTRA():
"""Le due domande hanno risposte OPPOSTE sugli stessi dati (nel giorno perde, per finestra
e' immune-o-positivo), ed e' il fatto centrale del 09/09: SKH01 e' marcato all'uscita, e
uno short aperto nel crollo viene accreditato dopo. Un solo aggettivo mentirebbe."""
v = M.verdetto(_m())
assert v.startswith("NEL GIORNO DEL CROLLO IL LIBRO PERDE")
assert "IMMUNE-O-POSITIVO per finestra" in v
assert M.verdetto(_m(giorni_libro=+0.0012)).startswith(
"NEL GIORNO DEL CROLLO IL LIBRO GUADAGNA")
def test_verdetto_i_tre_rami_della_FINESTRA():
"""Le soglie sono 2/3 sui POSITIVO e 2/3 su POSITIVO+immune.
DEVE FALLIRE SE: qualcuno le sposta guardando l'esito (selezione sull'hold-out)."""
assert "POSITIVO per finestra" in M.verdetto(_m(guadagna=8, immune=2))
assert "IMMUNE-O-POSITIVO per finestra" in M.verdetto(_m(guadagna=4, immune=6))
assert "PERDE per finestra" in M.verdetto(_m(guadagna=3, immune=2))
def test_verdetto_i_confini_delle_due_soglie():
v8 = M.verdetto(_m(guadagna=8, immune=0)) # 8 >= 8.0
assert "POSITIVO per finestra" in v8 and "IMMUNE-O-POSITIVO" not in v8
assert "IMMUNE-O-POSITIVO per finestra" in M.verdetto(_m(guadagna=7, immune=1))
assert "PERDE per finestra" in M.verdetto(_m(guadagna=7, immune=0))
def test_verdetto_dice_se_il_beta_RIPRODUCE_la_memoria_e_su_QUALE_finestra():
"""La tolleranza e' TOL_BETA e il beta confrontato e' quello della finestra di §46, non il
beta pieno: due numeri diversi (0,0769 contro 0,086), e solo il primo va confrontato."""
# ⚠️ il confine esatto non e' asseribile in virgola mobile: 0,076 + 0,01 vale 0,086 e la
# differenza torna 0,009999999999999995, cioe' DENTRO. Si prova un micron di qua e di la'.
dentro = M.BETA_MEMORIA + M.TOL_BETA - 1e-6
fuori = M.BETA_MEMORIA + M.TOL_BETA + 1e-6
assert "(riproduce il" in M.verdetto(_m(beta_46=dentro))
assert "(NON riproduce il" not in M.verdetto(_m(beta_46=dentro))
assert "(NON riproduce il" in M.verdetto(_m(beta_46=fuori))
assert "sulla finestra di §46" in M.verdetto(_m())
assert f"+{M.BETA_MEMORIA:.3f}" in M.verdetto(_m())
def test_verdetto_dichiara_che_SKH01_e_marcato_all_uscita():
"""P4: l'allerta (qui: la riga) risponde anche al "perche'". Senza quella nota, il lettore
vede SKH01 0 nella tabella per giorno e il guadagno nella tabella per finestra, e non ha
modo di sapere che e' la stessa serie letta in due modi."""
assert "marcata all'uscita" in M.verdetto(_m())
def test_verdetto_riproduce_ALLA_LETTERA_la_riga_pubblicata_il_09_09():
"""M23: la macchina deve ristampare il numero vecchio. Sono i valori esatti usciti da
`misura()` il 09/09 se il testo cambia, cambia anche cio' che il diario cita, e va
cambiato nei due posti insieme."""
m = _m(guadagna=4, immune=6, n=12, beta_46=0.07691539529362904,
giorni_libro=-0.004841811670794324, giorni_pos=0.175, positivi=8, dal22=(8, 8),
quota=1.0793712686517833)
assert M.verdetto(m) == (
"NEL GIORNO DEL CROLLO IL LIBRO PERDE (-0.48%/g, positivo nel 18% dei giorni ≤ 5%); "
"IMMUNE-O-POSITIVO per finestra: 4 POSITIVO / 6 immune / 2 PERDE su 12 "
"(libro > 0 in 8, dal 2022 in 8/8); beta +0.0769 sulla finestra di §46 "
"(riproduce il +0.076); la gamba short di SKH01 (marcata all'uscita) fa il 108% "
"dei guadagni negli episodi con libro > 0")
# ===========================================================================================
# P1 — i pesi si IMPORTANO dal book live
# ===========================================================================================
def test_P1_i_pesi_sono_QUELLI_del_book_live_non_una_copia():
"""*Un controllo puntato su una configurazione diversa da quella che gira passa sempre.*
L'identita' (`is`) vale solo se i nomi sono legati allo STESSO oggetto dell'import: un
`W_TP01 = 0.75` riscritto nello script la romperebbe anche a valore uguale."""
import src.live.book as B
assert M.W_TP01 is B.W_TP01, "TP01: peso ridichiarato invece che importato da src/live/book"
assert M.W_SKH is B.W_SKH, "SKH01: peso ridichiarato invece che importato da src/live/book"
assert M.W_TP01 + M.W_SKH == pytest.approx(1.0)
src = inspect.getsource(M)
assert "from src.live.book import" in src
assert not re.search(r"^W_TP01\s*=", src, re.M), "peso TP01 ridichiarato a livello di modulo"
assert not re.search(r"^W_SKH\s*=", src, re.M), "peso SKH01 ridichiarato a livello di modulo"
def test_la_riga_informativa_37_5_62_5_NON_e_il_peso_del_libro():
"""§26 compare come riga informativa. DEVE FALLIRE SE: qualcuno la promuove a peso di
default il 75/25 e' decisione vincolante, la riapre solo `weights_tilt_null`."""
assert M.W_INFO == (0.375, 0.625)
assert (M.W_TP01, M.W_SKH) != M.W_INFO
# ===========================================================================================
# INTEGRAZIONE sui dati veri
# ===========================================================================================
def test_la_scomposizione_di_SKH01_in_long_e_short_e_ESATTA():
"""~2 s. Due guardie, entrambe P1:
(a) la versione "tutto" dev'essere BIT-EXACT con `sleeves._skyhook_returns` — se non lo
fosse, l'attribuzione descriverebbe un altro sleeve;
(b) long + short == intera. In principio filtrare gli entry-dict potrebbe cambiare il
non-overlap (un long che non occupa piu' la barra lascia entrare uno short): la
misura dice che qui non succede, e il test lo tiene vero."""
if not _feed_certificato_presente():
pytest.skip("feed certificato BTC/ETH 5m/1h assente in data/raw (gitignored)")
from src.portfolio.sleeves import _skyhook_returns
d = M.skh_per_direzione()
assert set(d) == {"tutto", "long", "short"}
J = pd.concat(d, axis=1, join="inner")
assert float((J["long"] + J["short"] - J["tutto"]).abs().max()) < 1e-12, (
"long + short != intera: la scomposizione non e' un'attribuzione, e' un'altra misura")
ref = _skyhook_returns()
if ref.index.tz is None:
ref.index = ref.index.tz_localize("UTC")
comune = d["tutto"].index.intersection(ref.index)
assert len(comune) > 1000
assert float((d["tutto"].loc[comune] - ref.loc[comune]).abs().max()) == 0.0, (
"la versione 'tutto' non e' piu' bit-exact col sleeve di ricerca")
def test_integrazione_il_risultato_pubblicato_del_09_09_si_riproduce():
"""~2 s. Se questo test diventa rosso NON va "aggiornato": va capito se e' cambiato il
dato (rebuild del feed) o il codice, e la riga citata nel diario va rifatta.
I due fatti che decidono: (1) per FINESTRA il libro non perde quasi mai 4 POSITIVO +
6 immune su 12, e libro > 0 in 8/8 episodi dal 2022; (2) NEL GIORNO del crollo perde
(0,48%/g, positivo solo nel 18% dei giorni 5%). Sono due risposte opposte alla stessa
domanda dell'operatore, e stanno insieme perche' SKH01 e' marcato all'uscita."""
if not _feed_certificato_presente():
pytest.skip("feed certificato BTC/ETH 5m/1h assente in data/raw (gitignored)")
m = M.misura()
assert m["beta"] == pytest.approx(0.086, abs=0.03), "beta pieno fuori dal valore pubblicato"
assert abs(m["beta_46"] - M.BETA_MEMORIA) < M.TOL_BETA, (
f"il beta sulla finestra di §46 ({m['beta_46']:+.4f}) non riproduce piu' il "
f"+{M.BETA_MEMORIA:.3f}: e' la riproduzione che giustifica la lente")
s = m["sintesi"]
assert s["n_episodi"] == 12
assert (s["guadagna"], s["immune"], s["perde"]) == (4, 6, 2)
assert s["guadagna"] + s["immune"] + s["perde"] == s["n_episodi"]
assert s["positivi"] == 8, "il conteggio nudo libro > 0 e' l'altra lettura, e va stampata"
assert s["dal22"] == (8, 8), "dal 2022 ogni episodio ha il libro in utile"
assert s["giorni_libro"] < 0, "nel GIORNO del crollo il libro perde: e' la meta' scomoda"
assert s["giorni_pos"] == pytest.approx(0.175, abs=0.05)
classi = [r["classe"] for r in m["episodi"]]
assert classi.count("POSITIVO") == s["guadagna"]
assert sum(c.startswith("immune") for c in classi) == s["immune"]
assert classi.count("PERDE") == s["perde"]
dal_2022 = [r for r in m["episodi"] if int(r["da"][:4]) >= 2022]
assert len(dal_2022) == 8
assert all(r["libro"] > 0 for r in dal_2022), (
"un episodio dal 2022 in poi non ha piu' il libro in utile: "
+ str([(r["nome"], r["da"], r["libro"]) for r in dal_2022]))
v = M.verdetto(m)
assert v.startswith("NEL GIORNO DEL CROLLO IL LIBRO PERDE")
assert "IMMUNE-O-POSITIVO per finestra" in v
assert "(riproduce il +0.076)" in v
+351
View File
@@ -0,0 +1,351 @@
"""Test di `scripts/research/r0909_xrp_terza_gamba.py` (2026-09-09) — XRP come terza gamba.
Tre cose vanno protette, e nessuna e' il numero finale:
1. **Il paginatore.** `_fetch_1h` esiste nella forma che ha perche' Coinbase ha un BUCO su XRP
(delistato 2021-01, rilistato 2023-07 dopo la sentenza SEC): un batch vuoto NON deve
fermare il ciclo. Un paginatore che si ferma al primo vuoto avrebbe certificato XRP su
mezzo campione e la certificazione decide QUALE lente si usa, cioe' decide il risultato.
2. **La regola L-PULITA.** "Dal primo anno in cui tutti i successivi restano sotto soglia":
un anno pulito ISOLATO non e' un regime. E' la regola che sceglie la finestra, ed e'
dichiarata prima di guardare l'esito — quindi va congelata prima che qualcuno la ritocchi.
3. **I tre rami del verdetto**, coi loro AND: AGGIUNGE richiede hold-out E de-levering E anni.
Piu' il guardrail del namespace: XRP vive in `alt_xrp_*`, e `load_data("XRP")` deve continuare
a fallire (il "solo dati certi" di CLAUDE.md non e' codice, e' l'assenza del file).
NESSUN test va in rete: il riferimento Coinbase/Bitstamp o e' in cache o il test si salta.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
import r0909_xrp_terza_gamba as M # noqa: E402
RAW = ROOT / "data" / "raw"
ORA_MS = 3_600_000
T0 = pd.Timestamp("2026-01-01", tz="UTC")
# ===========================================================================================
# (a) _fetch_1h — un batch vuoto NON ferma il ciclo
# ===========================================================================================
class ExFinto:
"""Exchange finto e DETERMINISTICO: restituisce un batch vuoto alle chiamate elencate in
`vuote` (indici 0-based), altrove `limit` barre orarie a partire da `since`."""
def __init__(self, vuote=(), righe_prima_di_since=False):
self.vuote = set(vuote)
self.righe_prima_di_since = righe_prima_di_since
self.since_visti: list[int] = []
def fetch_ohlcv(self, symbol, tf, since=None, limit=None):
n = len(self.since_visti)
self.since_visti.append(since)
assert tf == "1h" and symbol and limit
if n in self.vuote:
return []
base = since - 10 * ORA_MS if self.righe_prima_di_since else since
return [[base + i * ORA_MS, 1.0, 1.0, 1.0, 10.0 + n + i, 1.0] for i in range(limit)]
@pytest.fixture()
def orologio_fermo(monkeypatch):
"""`_fetch_1h` chiude il ciclo su `time.time()`: qui lo si ferma a T0+72h, cosi' il numero
di pagine e' un fatto e non il meteo della macchina. (Il patch vive solo dentro il test.)"""
fine = T0 + pd.Timedelta(hours=72)
monkeypatch.setattr(M.time, "time", lambda: fine.timestamp())
return fine
def test_fetch_1h_AVANZA_dopo_un_batch_vuoto(orologio_fermo):
"""IL test di questo file. Coinbase su XRP restituisce vuoto per due anni e mezzo: se il
ciclo si fermasse (break) o non facesse avanzare `since` (loop infinito), la certificazione
girerebbe su un campione mutilato e la lente L-PULITA la sceglie la certificazione.
DEVE FALLIRE SE: qualcuno sostituisce `since += limit*3_600_000; continue` con un `break`."""
ex = ExFinto(vuote={0})
s = M._fetch_1h(ex, "XRP/USD", T0, 24)
assert len(ex.since_visti) >= 2, "il ciclo si e' fermato al primo batch vuoto"
assert ex.since_visti[1] == ex.since_visti[0] + 24 * ORA_MS, (
"dopo un batch vuoto `since` deve avanzare di un'intera pagina")
assert len(s) == 48
assert s.index.min() == T0 + pd.Timedelta(hours=24), "la prima pagina era il buco"
assert s.index.is_monotonic_increasing and s.index.is_unique
def test_fetch_1h_scavalca_un_buco_IN_MEZZO_e_riprende(orologio_fermo):
"""Il buco vero di XRP e' in mezzo (2021-01 → 2023-07), non in testa: il dato PRIMA e quello
DOPO devono stare tutti e due nella serie, col buco che resta un buco (D2: una riga assente
non si inventa)."""
ex = ExFinto(vuote={1})
s = M._fetch_1h(ex, "XRP/USD", T0, 24)
assert len(s) == 48
assert s.index.min() == T0
assert s.index.max() == T0 + pd.Timedelta(hours=71)
buco = s[(s.index >= T0 + pd.Timedelta(hours=24)) & (s.index < T0 + pd.Timedelta(hours=48))]
assert len(buco) == 0, "il buco e' stato riempito con qualcosa: non c'era niente da riempire"
def test_fetch_1h_scarta_le_righe_PRIMA_di_since_e_avanza_lo_stesso(orologio_fermo):
"""Alcuni venue ignorano `since` e ritornano sempre la stessa pagina: il filtro
`x[0] >= since` la svuota, e a quel punto vale la stessa regola del batch vuoto."""
ex = ExFinto(righe_prima_di_since=True)
s = M._fetch_1h(ex, "XRP/USD", T0, 24)
# le prime 10 barre di ogni pagina cadono prima di `since` e vengono scartate
assert len(ex.since_visti) >= 2
assert ex.since_visti[1] > ex.since_visti[0], "il ciclo si e' impantanato"
assert (s.index >= T0).all()
def test_fetch_1h_non_si_impianta_su_una_pagina_da_una_riga(monkeypatch):
"""La guardia `since = nxt if nxt > since else since + pagina` esiste per non girare a
vuoto: qui ogni chiamata rende UNA barra a `since`, e il ciclo deve comunque terminare."""
fine = T0 + pd.Timedelta(hours=5)
monkeypatch.setattr(M.time, "time", lambda: fine.timestamp())
class UnaRiga:
def __init__(self):
self.n = 0
def fetch_ohlcv(self, symbol, tf, since=None, limit=None):
self.n += 1
assert self.n < 50, "ciclo infinito"
return [[since, 1.0, 1.0, 1.0, 10.0, 1.0]]
s = M._fetch_1h(UnaRiga(), "XRP/USD", T0, 24)
assert len(s) == 5
assert list(s.index) == list(pd.date_range(T0, periods=5, freq="h"))
# ===========================================================================================
# (b) la regola L-PULITA dentro certifica()
# ===========================================================================================
ANNI = (2022, 2023, 2024, 2025, 2026)
def _finto_feed(sporchi: dict[int, int], n: int = 700):
"""Feed sintetico: per ogni anno `n` barre orarie, di cui `sporchi[y]` a +2% dal
riferimento (cioe' sopra la soglia >1% che definisce una barra sporca)."""
dd, rr = [], []
for y in ANNI:
idx = pd.date_range(f"{y}-01-01", periods=n, freq="h", tz="UTC")
rif = np.full(n, 100.0)
mio = rif.copy()
mio[:sporchi.get(y, 0)] = 102.0
dd.append(pd.DataFrame(dict(datetime=idx, close=mio, high=mio + 1.0, low=mio - 1.0)))
rr.append(pd.DataFrame(dict(ts=idx, close=rif, fonte="CB")))
D = pd.concat(dd, ignore_index=True)
R = pd.concat(rr, ignore_index=True).set_index("ts")
return D, R
def _certifica(monkeypatch, sporchi: dict[int, int], n: int = 700) -> dict:
D, R = _finto_feed(sporchi, n)
monkeypatch.setattr(M, "dati", lambda asset, tf: D)
monkeypatch.setattr(M, "riferimento_1h", lambda start: R)
return M.certifica()
def test_L_PULITA_parte_dal_primo_anno_con_TUTTI_i_successivi_puliti(monkeypatch):
"""Lo scenario vero: 2022 e 2023 sporchi (0,57% nel 2023), 2024-2026 puliti → 2024-01-01."""
c = _certifica(monkeypatch, {2022: 8, 2023: 4})
assert c["per_anno"][2023]["q_1pct"] == pytest.approx(4 / 700, abs=1e-6)
assert c["per_anno"][2022]["q_1pct"] > M.SOGLIA_PULITA
assert c["puliti"] == [2024, 2025, 2026]
assert c["pulita_da"] == pd.Timestamp("2024-01-01", tz="UTC")
def test_L_PULITA_ignora_un_anno_pulito_ISOLATO(monkeypatch):
"""La regressione che la regola esiste per impedire: 2022 pulito, 2023 sporco, 2024+ puliti.
Un anno pulito isolato NON e' un regime — la lente parte dalla coda pulita finale (2024),
non dal 2022. DEVE FALLIRE SE: qualcuno semplifica in `min(puliti)`."""
c = _certifica(monkeypatch, {2023: 8})
assert c["puliti"] == [2022, 2024, 2025, 2026], "il 2022 e' pulito, e va detto"
assert c["pulita_da"] == pd.Timestamp("2024-01-01", tz="UTC"), (
"la lente non puo' partire dal 2022: il 2023 in mezzo e' sporco")
def test_L_PULITA_e_tutta_la_storia_se_non_c_e_niente_di_sporco(monkeypatch):
c = _certifica(monkeypatch, {})
assert c["puliti"] == list(ANNI)
assert c["pulita_da"] == pd.Timestamp("2022-01-01", tz="UTC")
def test_L_PULITA_e_NONE_se_nessun_anno_e_pulito(monkeypatch):
"""P5: "non c'e' una finestra pulita" ha uno stato suo. `misura()` allora non aggiunge la
lente e `verdetto()` ripiega sul 2024+ dichiarato (la lente di SOL)."""
c = _certifica(monkeypatch, {y: 8 for y in ANNI})
assert c["puliti"] == []
assert c["pulita_da"] is None
def test_la_soglia_di_pulizia_e_INCLUSIVA_e_vale_SOGLIA_PULITA(monkeypatch):
"""Il confronto e' `<=`: esattamente in soglia l'anno e' pulito. Con n=1000 barre la
soglia 0,5% cade su un intero (5 barre), quindi il confine e' asseribile."""
c = _certifica(monkeypatch, {2022: 5, 2023: 5, 2024: 5, 2025: 5, 2026: 5}, n=1000)
assert c["per_anno"][2022]["q_1pct"] == pytest.approx(M.SOGLIA_PULITA)
assert c["puliti"] == list(ANNI)
c2 = _certifica(monkeypatch, {2022: 6, 2023: 5, 2024: 5, 2025: 5, 2026: 5}, n=1000)
assert 2022 not in c2["puliti"]
assert c2["pulita_da"] == pd.Timestamp("2023-01-01", tz="UTC")
def test_certifica_riporta_per_ogni_anno_le_misure_che_la_tabella_stampa(monkeypatch):
c = _certifica(monkeypatch, {2022: 8})
for y, r in c["per_anno"].items():
assert set(r) == {"n", "q_1pct", "med_bps", "fonte", "flat_1h", "flat_5m"}
assert r["n"] == 700
assert r["flat_1h"] == 0.0, "high != low per costruzione"
# ===========================================================================================
# (c) verdetto() — i tre rami, tutti con AND
# ===========================================================================================
NOME = "L-PULITA (2024+)"
def _m(hold, sh3, sh2k, anni_dsh, nome: str = NOME, k: float = 0.827) -> dict:
h = np.asarray(hold, float)
z = np.zeros(len(h))
return dict(lenti={nome: dict(sh=z, hold=h, dd=z, cagr=z)},
delev={nome: dict(k=k, sh3=sh3, sh2k=sh2k, cagr3=0.146, cagr2k=0.134)},
anno={2024 + i: dict(dsh=float(v)) for i, v in enumerate(anni_dsh)})
def test_verdetto_AGGIUNGE_solo_se_passano_tutte_e_tre_le_condizioni():
v = M.verdetto(_m([0.10] * 24, sh3=1.60, sh2k=1.50, anni_dsh=[0.5, 0.4, -0.1]))
assert v.startswith("XRP AGGIUNGE")
def test_verdetto_DILUISCE_quando_cadono_hold_out_E_de_levering():
v = M.verdetto(_m([-0.169] * 24, sh3=1.54, sh2k=1.53, anni_dsh=[0.54, -0.44, -0.25]))
assert v.startswith("XRP DILUISCE")
def test_verdetto_PARI_quando_le_condizioni_si_contraddicono():
"""N5/M8: quando una condizione passa e l'altra no, il verdetto non sceglie la piu' comoda —
dichiara che non e' selezionabile."""
solo_hold = M.verdetto(_m([0.10] * 24, sh3=1.50, sh2k=1.60, anni_dsh=[0.5, 0.4, -0.1]))
assert solo_hold.startswith("XRP: PARI / NON SELEZIONABILE")
solo_delev = M.verdetto(_m([-0.10] * 24, sh3=1.60, sh2k=1.50, anni_dsh=[0.5, 0.4, -0.1]))
assert solo_delev.startswith("XRP: PARI / NON SELEZIONABILE")
def test_verdetto_chiede_il_75_percento_delle_ancore_non_la_sola_mediana():
"""M7/§2: la banda d'ancora NON e' un intervallo di confidenza, ma la robustezza alla scelta
dell'ancora e' una condizione a se'. Mediana > 0 con solo il 70% delle ancore positive non
basta. DEVE FALLIRE SE: qualcuno lascia solo la mediana."""
hold = [0.10] * 16 + [-0.10] * 8 # mediana +0,10, ma >0 solo nel 67% delle ancore
v = M.verdetto(_m(hold, sh3=1.60, sh2k=1.50, anni_dsh=[0.5, 0.4, -0.1]))
assert not v.startswith("XRP AGGIUNGE")
hold_ok = [0.10] * 18 + [-0.10] * 6 # esattamente 75%
assert M.verdetto(_m(hold_ok, sh3=1.60, sh2k=1.50,
anni_dsh=[0.5, 0.4, -0.1])).startswith("XRP AGGIUNGE")
def test_verdetto_il_de_levering_chiede_un_margine_di_0_02():
"""M5: il null del de-levering non si passa "per un pelo" — un vantaggio dentro 0,02 di
Sharpe e' de-levering travestito, ed e' esattamente il caso di XRP (1,54 contro 1,53)."""
quasi = M.verdetto(_m([0.10] * 24, sh3=1.52, sh2k=1.50, anni_dsh=[0.5, 0.4, -0.1]))
assert not quasi.startswith("XRP AGGIUNGE")
appena = M.verdetto(_m([0.10] * 24, sh3=1.521, sh2k=1.50, anni_dsh=[0.5, 0.4, -0.1]))
assert appena.startswith("XRP AGGIUNGE")
def test_verdetto_ripiega_sulla_lente_di_SOL_se_non_c_e_una_coda_pulita():
"""Quando `pulita_da` e' None, `misura()` non crea la lente L-PULITA: il verdetto deve
leggere quella dichiarata di riserva, non la prima che capita."""
m = _m([-0.10] * 24, sh3=1.50, sh2k=1.60, anni_dsh=[-0.1, -0.2, -0.3],
nome="2024+ (lente di SOL)")
m["lenti"]["L-FULL"] = dict(sh=np.zeros(24), hold=np.full(24, 9.0),
dd=np.zeros(24), cagr=np.zeros(24))
m["delev"]["L-FULL"] = dict(k=1.0, sh3=9.0, sh2k=0.0, cagr3=0.0, cagr2k=0.0)
v = M.verdetto(m)
assert "lente 2024+ (lente di SOL)" in v
assert v.startswith("XRP DILUISCE"), "ha letto la L-FULL invece della lente dichiarata"
def test_verdetto_riproduce_ALLA_LETTERA_la_riga_pubblicata_il_09_09():
"""M23: i valori sono quelli usciti dalla misura a 24 ancore del 09/09."""
m = _m([-0.169] * 24, sh3=1.54, sh2k=1.53, anni_dsh=[0.54, -0.44, -0.25])
assert M.verdetto(m) == (
"XRP DILUISCE — lente L-PULITA (2024+): dSharpe hold-out mediano -0.169 "
"(>0 nel 0% di 24 ancore); de-levering: Sh 3 gambe 1.54 vs 2 gambe×0.827 1.53; "
"anni 2024+ con dSh>0: 1/3")
# ===========================================================================================
# (d) il GUARDRAIL del namespace
# ===========================================================================================
def test_il_divieto_su_data_raw_xrp_e_gia_DERIVATO_dal_codice():
"""`tests/test_sol_leg.py::test_nessun_asset_fuori_universo_nel_namespace_NUDO` deriva gli
asset vietati da `rebuild_history.DERIBIT_INSTR`: finche' XRP e' li' dentro, quel test
copre gia' `data/raw/xrp_*.parquet` e qui NON si duplica. Questo test congela la premessa
se qualcuno togliesse XRP dal dict, la guardia sparirebbe in silenzio."""
from scripts.analysis.rebuild_history import DERIBIT_INSTR
assert "XRP" in DERIBIT_INSTR, (
"XRP fuori da DERIBIT_INSTR: il divieto su data/raw/xrp_*.parquet non e' piu' derivato "
"da nessuna parte — riscrivere la guardia in test_sol_leg.py prima di toglierlo")
def test_load_data_XRP_continua_a_FALLIRE():
"""L'altra meta': il dato XRP dello studio vive in `alt_xrp_*` e il feed attivo non lo vede.
Se un giorno `load_data("XRP")` funzionasse, il cron (`--asset BTC ETH`) non lo
rinfrescherebbe comunque -> dato stantio che sembra attivo."""
from src.data.downloader import load_data
with pytest.raises(FileNotFoundError):
load_data("XRP", "1h")
def test_lo_studio_legge_il_namespace_di_RICERCA():
"""`dati()` importata da r0822_sol_leg instrada XRP su `alt_xrp_*`: se non c'e' il file,
l'errore deve dire come rigenerarlo, non fingere che l'asset non esista."""
assert M.ASSET == "XRP" and M.TRE == ("BTC", "ETH", "XRP")
if (RAW / "alt_xrp_1h.parquet").exists():
d = M.dati("XRP", "1h")
assert {"close", "datetime"} <= set(d.columns) and len(d) > 0
# ===========================================================================================
# (e) INTEGRAZIONE (opzionale: salta senza i dati o senza la cache del riferimento)
# ===========================================================================================
def test_integrazione_xrp_diluisce_anche_a_due_ancore(monkeypatch):
"""~2 s. Non riproduce i numeri a 24 ancore (2 ancore sono un'altra misura): riproduce la
DIREZIONE, che e' la cosa che decide — dSharpe hold-out mediano negativo e verdetto
"XRP DILUISCE", cioe' l'ipotesi a priori confermata."""
if not all((RAW / f"alt_xrp_{tf}.parquet").exists() for tf in ("1h", "5m")):
pytest.skip("namespace di ricerca alt_xrp_* assente (rigenerabile, gitignored)")
if not all((RAW / f"{a}_{tf}.parquet").exists() for a in ("btc", "eth") for tf in ("1h", "5m")):
pytest.skip("feed certificato BTC/ETH assente")
if not M.CACHE.exists():
pytest.skip(f"cache del riferimento assente ({M.CACHE}): il test NON va in rete")
# cintura: anche se la cache sparisse a meta' test, nessuno tocca la rete
def _vietato(*a, **k):
raise AssertionError("il test ha provato ad andare in rete (_fetch_1h)")
monkeypatch.setattr(M, "_fetch_1h", _vietato)
m = M.misura(2)
assert m["n_anchors"] == 2
assert m["cert"]["pulita_da"] == pd.Timestamp("2024-01-01", tz="UTC")
nome = ([n for n in m["lenti"] if n.startswith("L-PULITA")] or ["2024+ (lente di SOL)"])[0]
assert np.median(m["lenti"][nome]["hold"]) < 0, "il dSharpe hold-out ha cambiato segno"
assert M.verdetto(m).startswith("XRP DILUISCE")
assert m["corr"]["L-FULL"] < 0.74, "la correlazione col book supera la soglia del 19/06"
+254
View File
@@ -0,0 +1,254 @@
"""Revisione settimanale (src/live/revisione.py): sola lettura, firmata, e il silenzio non e' un esito.
Nessun test chiama il modello o Telegram: `interroga` e `invia` sono iniettati. Nessun test scrive
nel repo: tutto in una radice temporanea con file sintetici.
"""
from __future__ import annotations
import re
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from src.live import revisione as R # noqa: E402
from src.live.venue_probe import in_release_window # noqa: E402
ADESSO = datetime(2026, 9, 14, 6, 15, tzinfo=timezone.utc) # un lunedi'
def _radice(tmp_path: Path) -> Path:
(tmp_path / "CLAUDE.md").write_text("# CLAUDE\n\n## 4. Gate\n| STATARB | 2026-09-27 | soglie | ritiro |\n")
(tmp_path / "config").mkdir(); (tmp_path / "config" / "live.json").write_text('{"disaster_sl_pct": 0.30}')
j = tmp_path / "docs" / "journal"; j.mkdir(parents=True)
for i in range(1, 10):
g = (ADESSO - timedelta(days=i)).date().isoformat()
(j / f"{g}.md").write_text(f"# Giornale {g}\nEquity $4,477.46 · leva 0.26x\n")
d = tmp_path / "docs" / "diary"; d.mkdir()
(d / "2026-09-09b-recente.md").write_text("recente: f 0,712\n")
(d / "2026-08-20-vecchio.md").write_text("vecchio\n")
(d / "senza-data.md").write_text("senza data\n")
live = tmp_path / "data" / "live"; live.mkdir(parents=True)
(live / "scale_watch_state.json").write_text('{"scala": 1.0}')
(live / "usde_watch.jsonl").write_text("\n".join(f'{{"i": {i}}}' for i in range(10)) + "\n")
return tmp_path
# ---------------------------------------------------------------- materiale
def test_il_materiale_prende_la_finestra_dichiarata_e_dice_cosa_non_legge(tmp_path):
voci = R.materiale(_radice(tmp_path), ADESSO)
nomi = [n for n, _, _ in voci]
giornali = [n for n in nomi if n.startswith("giornale ")]
assert len(giornali) == R.FINESTRA_GIORNALE_G, "7 pagine, non 9: la finestra e' dichiarata"
assert "giornale 2026-09-13" in nomi and "giornale 2026-09-07" in nomi and "giornale 2026-09-06" not in nomi
assert "diario 2026-09-09b-recente.md" in nomi
assert "diario 2026-08-20-vecchio.md" not in nomi and "diario senza-data.md" not in nomi
testo = dict((n, t) for n, t, _ in voci)
assert "0,712" in testo["diario 2026-09-09b-recente.md"]
assert testo["stato usde_watch.jsonl (ultime 3)"].splitlines() == ['{"i": 7}', '{"i": 8}', '{"i": 9}']
assert testo["stato scale_history.jsonl (ultime 3)"] == "(assente)", "un file assente si DICE (P5)"
assert testo["git log dal 2026-08-31 + stato del working tree"].startswith("non leggibile"), \
"in una cartella che non e' un repo il log non si inventa"
assert any(n.startswith("monitor_health") for n in nomi)
def test_i_sorveglianti_vengono_prima_delle_voci_grandi(tmp_path):
"""Il tetto totale taglia in coda: gli stati (piccoli) devono stare PRIMA di giornale e diari,
altrimenti sono i primi a sparire e' successo alla prima stesura (usde_convert a 2 caratteri)."""
nomi = [n for n, _, _ in R.materiale(_radice(tmp_path), ADESSO)]
i_stato = max(i for i, n in enumerate(nomi) if n.startswith("stato "))
i_giornale = min(i for i, n in enumerate(nomi) if n.startswith("giornale "))
assert i_stato < i_giornale
def test_il_troncamento_e_dichiarato(tmp_path, monkeypatch):
r = _radice(tmp_path)
(r / "CLAUDE.md").write_text("x" * 1000)
monkeypatch.setattr(R, "MAX_CHARS_VOCE", 100)
n, t, tr = R.materiale(r, ADESSO)[0]
assert tr and "[… troncato a 100 caratteri su 1,000]" in t
assert "[TRONCATO]" in R.costruisci_prompt([(n, t, tr)], ADESSO)
def test_il_prompt_porta_le_regole_e_la_data(tmp_path):
p = R.costruisci_prompt(R.materiale(_radice(tmp_path), ADESSO), ADESSO)
assert "DATA DELLA REVISIONE: 2026-09-14 06:15Z" in p
for t in R.TITOLI:
assert t in p
for parola in ("SOLA LETTURA", "CITA LA FONTE", "DECISIONI VINCOLANTI", "docs/memory", "I GATE HANNO UNA DATA",
"NIENTE PREVISIONI"):
assert parola in p, parola
# ---------------------------------------------------------------- guardia
def _revisione(sintesi="Tutto letto. Equity $4,477.46.", extra=""):
return ("## Sintesi\n" + sintesi + "\n## Cosa non torna\nniente\n## Cosa e' maturo da decidere\n"
"STATARB il 2026-09-27\n## Proposte (nessuna azione eseguita)\nnessuna\n"
"## Cosa ho letto e cosa mi mancava\nCLAUDE.md" + extra)
def test_la_guardia_rifiuta_il_vuoto_e_i_titoli_mancanti():
assert R.valida("", "fonte")[0] == "rifiutata"
stato, motivi = R.valida("## Sintesi\nsolo questa", "fonte")
assert stato == "rifiutata" and "titoli mancanti" in motivi[0]
def test_la_guardia_sui_numeri_ha_il_controllo_positivo():
fonte = "Equity $4,477.46 · leva 0.26x · 2026-09-27"
assert R.valida(_revisione(), fonte) == ("ok", [])
stato, motivi = R.valida(_revisione(sintesi="Equity 9.999,99 e leva 3,14x"), fonte)
assert stato == "sospetta" and "non presenti nel materiale" in motivi[0]
# ---------------------------------------------------------------- rapporto e Telegram
def test_il_rapporto_e_firmato_e_dichiara_la_sola_lettura():
voci = [("CLAUDE.md", "x" * 10, False), ("giornale", "y", True)]
r = R.rapporto(_revisione(), None, "ok", [], "claude-fable-5-1", ADESSO, voci)
assert r.startswith("# Revisione settimanale — 2026-09-14")
for frase in ("`claude-fable-5-1`", "SOLA LETTURA", "lettore fallibile (P13)", "## Firma e materiale",
"| CLAUDE.md | 10 | |", "| giornale | 1 | si |", "l'unico file"):
assert frase in r, frase
def test_un_modello_muto_produce_un_rapporto_che_lo_dice():
r = R.rapporto(None, "timeout dopo 900s", "rifiutata", ["timeout dopo 900s"], "m", ADESSO, [])
assert "## Revisione NON eseguita" in r and "timeout dopo 900s" in r and "P5" in r
tg = R.per_telegram(None, "timeout dopo 900s", "rifiutata", ADESSO, Path("docs/revisioni/x.md"))
assert tg.startswith("<b>🚨") and "timeout" in tg
def test_telegram_porta_la_sintesi_e_dichiara_il_taglio():
tg = R.per_telegram(_revisione(sintesi="A <b>corta</b>."), None, "ok", ADESSO, Path("p.md"))
assert "A &lt;b&gt;corta&lt;/b&gt;." in tg and "niente" not in tg, "solo la Sintesi, con l'HTML neutralizzato"
lunga = R.per_telegram(_revisione(sintesi="z" * 5000), None, "ok", ADESSO, Path("p.md"))
assert "[… sintesi tagliata" in lunga and len(lunga) < 4096
# ---------------------------------------------------------------- orchestrazione: cosa scrive, cosa no
def _albero(root: Path) -> set[str]:
return {str(p.relative_to(root)) for p in root.rglob("*") if p.is_file()}
def test_secco_non_chiama_e_non_scrive(tmp_path, capsys):
r = _radice(tmp_path); prima = _albero(r)
def mai(*a, **k):
raise AssertionError("il modello NON va chiamato in --secco")
esito = R.scrivi(r, ADESSO, secco=True, interroga=mai, invia=mai)
assert esito["secco"] and _albero(r) == prima
assert "SECCO" in capsys.readouterr().out
def test_un_giro_riuscito_scrive_UN_solo_file_e_manda_la_sintesi(tmp_path):
r = _radice(tmp_path); prima = _albero(r); mandati = []
esito = R.scrivi(r, ADESSO, modello="claude-fable-5-1", quiet=True,
interroga=lambda p, m, t: (_revisione(), None), invia=lambda h: mandati.append(h) or True)
nuovi = _albero(r) - prima
assert nuovi == {"docs/revisioni/2026-09-14.md"}, "l'unico file scritto e' il rapporto"
assert esito["stato"] == "ok" and esito["telegram"] is True
assert "## Sintesi" in (r / "docs" / "revisioni" / "2026-09-14.md").read_text()
assert len(mandati) == 1 and "Revisione settimanale" in mandati[0]
def test_un_errore_del_modello_scrive_il_rapporto_e_allerta(tmp_path):
r = _radice(tmp_path); mandati = []
esito = R.scrivi(r, ADESSO, quiet=True, interroga=lambda p, m, t: ("", "CLI `claude` non trovata"),
invia=lambda h: mandati.append(h) or True)
assert esito["stato"] == "rifiutata" and "non trovata" in esito["errore"]
assert "NON eseguita" in (r / "docs" / "revisioni" / "2026-09-14.md").read_text()
assert mandati and mandati[0].startswith("<b>🚨")
def test_una_risposta_che_non_e_una_revisione_e_un_errore_dichiarato(tmp_path):
r = _radice(tmp_path)
esito = R.scrivi(r, ADESSO, quiet=True, telegram=False, interroga=lambda p, m, t: ("ciao", None))
assert esito["stato"] == "rifiutata" and "titoli mancanti" in esito["errore"]
assert "NON eseguita" in (r / "docs" / "revisioni" / "2026-09-14.md").read_text()
def test_no_telegram_non_manda(tmp_path):
r = _radice(tmp_path)
def mai(h):
raise AssertionError("Telegram NON va chiamato con telegram=False")
esito = R.scrivi(r, ADESSO, quiet=True, telegram=False, interroga=lambda p, m, t: (_revisione(), None), invia=mai)
assert "telegram" not in esito
def test_il_prompt_va_su_stdin_non_su_argv(monkeypatch):
"""Al primo giro reale (09/09) 195k caratteri su argv hanno dato `Argument list too long` PRIMA
di chiamare il modello. DEVE FALLIRE SE: qualcuno rimette il prompt fra gli argomenti."""
visto = {}
class _P:
returncode, stdout, stderr = 0, _revisione(), ""
def finto_run(args, **kw):
visto["args"], visto["input"] = args, kw.get("input")
return _P()
monkeypatch.setattr(R.subprocess, "run", finto_run)
prompt = "PROMPT " * 40_000
testo, errore = R.interroga(prompt, "claude-fable-5-1", 5)
assert errore is None and "## Sintesi" in testo
assert visto["input"] == prompt and all(len(a) < 200 for a in visto["args"])
assert "--model" in visto["args"] and "claude-fable-5-1" in visto["args"]
def test_una_eccezione_nella_chiamata_diventa_un_errore_non_un_crash(tmp_path, monkeypatch):
"""Controllo positivo sul difetto del primo giro: `OSError` dalla CLI → rapporto NON eseguita + 🚨."""
def esplode(args, **kw):
raise OSError(7, "Argument list too long")
monkeypatch.setattr(R.subprocess, "run", esplode)
assert R.interroga("x", "m", 5) == ("", "OSError: [Errno 7] Argument list too long")
r = _radice(tmp_path); mandati = []
def esplode_in_scrivi(p, m, t):
raise RuntimeError("boom")
esito = R.scrivi(r, ADESSO, quiet=True, interroga=esplode_in_scrivi, invia=lambda h: mandati.append(h) or True)
assert esito["stato"] == "rifiutata" and "RuntimeError: boom" in esito["errore"]
assert "NON eseguita" in (r / "docs" / "revisioni" / "2026-09-14.md").read_text()
assert mandati and mandati[0].startswith("<b>🚨")
def test_il_revisore_non_e_il_modello_che_scrive_il_codice():
from src.live import analista as A
assert R.MODELLO == "claude-fable-5-1" and R.MODELLO != A.MODELLO_DEFAULT
# ---------------------------------------------------------------- cadenza: tre dichiarazioni d'accordo
def _riga_crontab() -> str | None:
try:
out = subprocess.run(["crontab", "-l"], capture_output=True, text=True, timeout=10)
except (FileNotFoundError, subprocess.TimeoutExpired):
return None
if out.returncode != 0:
return None
righe = [r for r in out.stdout.splitlines() if "cron_review.sh" in r and not r.lstrip().startswith("#")]
return righe[0] if righe else ""
def test_la_cadenza_dichiarata_nel_cron_sh_e_quella_installata_e_fuori_dagli_slot():
sh = (ROOT / "scripts" / "cron_review.sh").read_text()
m = re.search(r"lunedi' (\d\d):(\d\d) UTC", sh)
assert m, "cron_review.sh deve dichiarare «lunedi' HH:MM UTC»"
ora, minuto = int(m.group(1)), int(m.group(2))
assert minuto != 0, "mai al minuto tondo (rate-limit per-IP)"
prossimo = ADESSO.replace(hour=ora, minute=minuto) # ADESSO e' un lunedi'
assert prossimo.weekday() == 0 and not in_release_window(prossimo)
assert "revisione.py --quiet" in sh
riga = _riga_crontab()
if riga is None:
pytest.skip("crontab non leggibile: la dichiarazione installata non e' verificabile qui")
assert riga, "crontab leggibile ma cron_review.sh NON installato"
campi = riga.split()
assert campi[:5] == [str(minuto), str(ora), "*", "*", "1"], f"installata {campi[:5]}, dichiarata {minuto} {ora} * * 1"
+61
View File
@@ -205,3 +205,64 @@ def test_la_quota_si_decide_da_lunedi_e_solo_se_IDONEO():
assert q("2026-09-30", "IDONEO") is True, "smette di chiedere: la decisione si dimentica" assert q("2026-09-30", "IDONEO") is True, "smette di chiedere: la decisione si dimentica"
assert q("2026-08-31", "IN_ATTESA") is False, "chiede la quota su un conto non idoneo" assert q("2026-08-31", "IN_ATTESA") is False, "chiede la quota su un conto non idoneo"
assert q("2026-08-31", "NON_IDONEO") is False assert q("2026-08-31", "NON_IDONEO") is False
# ------------------------------ lettura dei trade: i due limiti del canale (issue #2) ------------------------------
# Il 07/09 il lettore chiedeva limit=50, la sonda del 06/09 aveva fatto 54 ordini: 400 USDE di
# acquisti sono finiti nel «reward». Il lettore ora chiede il massimo del venue (1000) e, se la
# lista puo' essere incompleta — troncata al limite, o ultima lettura oltre la finestra di 24h
# che il gateway espone — dichiara «non leggibile» invece di sommare (P12).
class _ClientFinto:
def __init__(self, rows):
self.rows, self.limit_chiesto = rows, None
def trade_history(self, instrument, limit=20):
self.limit_chiesto = limit
return self.rows[:limit]
def _ordini(n, t0_ms, amt=100.0, passo_ms=15_000):
return [{"timestamp": t0_ms + i * passo_ms, "direction": "buy", "amount": amt} for i in range(n)]
def test_controllo_positivo_54_ordini_si_sommano_tutti():
"""Il caso del 06-07/09: 54 ordini in 24h. Con il vecchio limit=50 ne mancavano 4."""
prev, now = 1_788_698_101_000, 1_788_784_501_000 # 06/09 12:35Z -> 07/09 12:35Z
c = _ClientFinto(_ordini(54, prev + 30_000_000))
tot, n, motivo = W._trades_spot_da(c, prev, now)
assert motivo is None and n == 54 and abs(tot - 5400.0) < 1e-9
assert c.limit_chiesto == W.TRADE_LIMIT >= 54
def test_lista_al_limite_del_venue_non_e_leggibile():
prev, now = 1_788_698_101_000, 1_788_784_501_000
c = _ClientFinto(_ordini(W.TRADE_LIMIT + 5, prev + 1000))
tot, n, motivo = W._trades_spot_da(c, prev, now)
assert tot is None and n is None and "troncata" in motivo
# e il reward NON si attribuisce (P12)
an = W.analizza({"eq_usde": 1000.0}, 1400.0, tot)
assert an["reward_stimato"] is None and not an["reward_rilevato"]
def test_lettura_oltre_la_finestra_del_gateway_non_e_leggibile():
"""Giro saltato: 48h dall'ultima lettura. I trade fra 48h e 24h fa sono invisibili."""
prev = 1_788_698_101_000
now = prev + 48 * 3_600_000
c = _ClientFinto(_ordini(3, now - 3_600_000))
tot, n, motivo = W._trades_spot_da(c, prev, now)
assert tot is None and "finestra" in motivo
def test_la_cadenza_del_cron_sta_dentro_la_tolleranza():
"""24h + qualche secondo (il cron delle 12:35 di due giorni consecutivi) e' coperta."""
prev = 1_788_698_101_000
assert W.trade_copertura(10, prev, prev + 24 * 3_600_000 + 2_000) is None
assert W.trade_copertura(10, prev, prev + 24 * 3_600_000 + W.TOLL_FINESTRA_MS + 1) is not None
def test_i_trade_prima_della_lettura_precedente_non_contano():
prev, now = 1_788_698_101_000, 1_788_784_501_000
rows = _ordini(2, prev - 60_000) + [{"timestamp": prev + 1, "direction": "sell", "amount": 40.0}]
tot, n, motivo = W._trades_spot_da(_ClientFinto(rows), prev, now)
assert motivo is None and n == 1 and abs(tot + 40.0) < 1e-9