23 Commits

Author SHA1 Message Date
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
Adriano Dal Pastro 95424f0755 docs: il README era fermo a 15 giorni PRIMA del reset — riscritto contro lo stato vero
Ultimo commit del README: 2026-06-04. Reset v2.0.0: 2026-06-19. Per 75 giorni la prima pagina
del repo ha pubblicato la libreria pre-reset (FADE/HONEST/PAIRS/TSMOM/SHAPE, PORT01-06) con
Sharpe 7,84/10,06 e CAGR ~79% come risultati correnti — cioe' esattamente i numeri che §2
elenca sotto "non citare", dalla libreria che il reset aveva dichiarato artefatto. Meta' dei
file citati non esiste piu' (strategies.yml, portfolios.yml, scripts/waste, scripts/portfolios,
src/live/multi_runner.py), e l'esecuzione descritta era su TESTNET, che e' la causa del reset.

- README riscritto (423 -> 152 righe): cosa gira adesso (TP01+SKH01 75/25, ~$2.050, cadenza
  oraria :47), i numeri nella lente di §2 (TWR +10,6%, non la crescita del conto), la riga che
  ordina il piano, il metodo e i suoi sei requisiti, il dato, la struttura VERIFICATA file per
  file, i comandi, i gate con le loro date, l'obiettivo con la sua onesta'.
- CLAUDE.md §0 e memoria 40: registrato il difetto e la lezione — un reset invalida anche i
  documenti che nessuno rilegge; l'inventario di cosa cita numeri morti va fatto il giorno del
  reset, non 75 giorni dopo per caso.
- Diario 02/09c: sezione col confronto riga per riga; coda dichiarata (l'inventario completo
  degli altri documenti pre-reset non e' stato fatto).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDJsH3iDSaBns3ccpPBwiu
2026-09-02 15:53:42 +00:00
Adriano Dal Pastro de83909db9 un flag sconosciuto non e' l'azione di default: guardia su 18 script, indice USDE orario, pulizia
MISURATO oggi durante la revisione: `trades_db.py --help` non stampava l'uso, cadeva in sync()
e riscriveva meta.ultimo_sync. Nessuno dei 18 script di scripts/live/ usava argparse: un flag
sbagliato era il ramo else. Su journal.py avrebbe scritto pagina e riga di DB, su analista.py
avrebbe speso una chiamata al modello e mandato un Telegram.

- src/live/cli.valida: prima istruzione di ogni __main__, prima di connect()/sync/rete.
  --help -> 0 con l'uso; flag ignoto, valore mancante o posizionale -> 2 con l'elenco dei
  previsti (P4). NIENTE argparse: cambierebbe messaggi, codici d'uscita e --help di script
  che il cron gia' chiama.
- 18 script cablati (i 3 che scrivono + 14 + cc01), flag invariati.
- tests/test_cli_flag.py (30): elenco DERIVATO dalla cartella (P1), valida come prima
  istruzione, uso che documenta i flag, e i flag che il CRON usa davvero restano accettati
  (P15/P16); end-to-end su --help e flag ignoto con trades.db non toccato (M15).
  Verificato a mano: monitor_health --quiet, trades_db --sync --quiet, book_execute dry-run.

Debito §5.15, primo passo: balance_watch (orario) registra `usde_usdc` a ogni campione — None
con la ragione se illeggibile, mai 1,0. Il cablaggio nel bound quando la serie ha storia.

Pulizia dalla revisione: tests/helpers.carica_script al posto della 15a copia del loader
importlib (5 file del libro live); il fill di prova via upsert_fills invece di un INSERT che
lasciava verified NULL; asserzioni non ancorate al padding; movimenti_capitale accetta le
righe gia' lette (una SELECT invece di due ai due lati di una scrittura del cron).

Test 910 verdi (+52). Diario 2026-09-02c.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDJsH3iDSaBns3ccpPBwiu
2026-09-02 15:49:40 +00:00
Adriano Dal Pastro 1279ea605a revisione 02/09, seconda tornata: il classificatore dei movimenti era cieco ~23 ore al giorno
Il feed 1h certificato si ferma alle 00:00: per le letture successive `asof` dava la stessa
barra a t0 e t1, mercato "fermo" = 0, e qualunque calo >=10% del giorno sarebbe stato un
"movimento" scorporato come prelievo (giornale dal 25/08, report da oggi). Con feed assente
tutto era "ambiguo" e il report stampava e1/e0-1 sotto l'etichetta TWR.

- journal.movimenti_capitale: stato "mercato non misurabile" (feed fermo/assente/barra
  mancante) => ambiguo con la ragione; leva_tetto = min(frac x n x scala, LEVA_LORDA_MAX) (P1).
- journal.rendimento_twr: salto non classificabile => twr E trading None con motivo.
- journal.pnl_giorno CHIAMA rendimento_twr (prima rifaceva cum - certi: "entrambi la chiamano"
  era falso); la pagina stampa il TWR; analista qualifica il cumulato "di cui versati".
- trades_db --report: la classe di ogni salto con la sua misura (mercato max a leva piena).
- book_execute docstring: banda dello stop -33,5%/-26,5% (era invertita), "non costa ordini"
  -> micro-ordini di ri-taglia (22/48, C2), latenza <=1h solo con feed fresca, conteggi di
  r0823 al posto di "raddoppia" (2 contro 1 e' rotolante vs pavimento sulla riga 4h; la
  peggiore misurata e' 24h).
- test_book_cadenza: LTF_MIN importato da skyhook; tutte le righe attive della crontab (una
  sola); tre stati dello skip (col progetto ma senza cron_book => ROSSO); slot di release
  testato con venue_probe (il :07 come controllo positivo); parser regolare, passo > 0.
- r0823_sl_anchor: guardia sul :47 (era sul vecchio :07), prosa al passato; cron_chain.sh idem.
- CLAUDE.md: §5.7 frase invertita corretta, §5.14 limite vero (non l'artefatto della fixture),
  §2 ora della LETTURA (13:47Z), nuovo debito §5.15 (USDE fuori dal bound di mercato).
- diari: tempi "scritto" corretti coi commit; sezione "Seconda tornata".

Test +11 (858 verdi).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDJsH3iDSaBns3ccpPBwiu
2026-09-02 15:33:13 +00:00
Adriano Dal Pastro 835e0c8666 revisione 02/09: il rotolante si CONTROLLA ogni ora (non "si ri-ancora"), TWR con limiti dichiarati e tre stati
Quattro segnalazioni della revisione sui commit di oggi, tutte verificate e riparate.

1. book_execute.py (+ CLAUDE.md §5.7, memoria 40): «il disaster-SL rotolante si ri-ancora ogni
   ora» era falso — ensure_disaster_sl lascia il bracket finche' lo stop e' entro il 5% e la
   taglia entro il 10%; il giro orario CONTROLLA, ri-ancora oltre la tolleranza (mark
   +5,263%/-4,762%). Lo stop siede fra -26,3% e -33,3% dal mark corrente.
2. journal.rendimento_twr: limite dichiarato (D5) — l'intervallo che contiene un movimento
   certo esce intero dal rendimento, il suo P&L di mercato va in `certi`; errore massimo meta'
   del movimento per costruzione (25/08: ~$0,5). Non si stima (P12). Segmenti a lunghezza zero
   non prodotti; nessun tempo a mercato -> 0,0 dichiarato numero.
3. base di equity zero: `twr` E `trading` None con motivo (il salto 0->X e' invisibile al
   classificatore, `trading` valeva l'intero conto); il report stampa n/d.
4. test_book_cadenza: la guardia estrae ogni prescrizione «ogni/every N unita'» e pretende 60
   minuti, con controllo positivo (M15); limite P13 dichiarato.

Test +5 (847 verdi). Diari 02/09 e 02/09b con la sezione "Revisione".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RziUCB336YPUUyDJ29x4Ke
2026-09-02 15:17:05 +00:00
Adriano Dal Pastro 861cc7fc27 debito 7: la cadenza del libro e' ORARIA — docstring riscritto, tre fonti tenute d'accordo da un test
Il docstring di book_execute.py prescriveva «ogni ~230 minuti» mentre il cron gira ogni ora
(47 * * * *). Era il docstring a sbagliare: sulla riga 4h BTC raddoppia gli scatti del
disaster-SL rotolante (r0823_sl_anchor.py), e chi avesse "corretto" il cron verso il
docstring avrebbe spostato il libro sulla riga peggiore.

- scripts/live/book_execute.py: CADENZA: ORARIA, la riga di crontab, la ragione (giro
  idempotente, latenza SKH01 <=1h, ri-ancoraggio orario) e il divieto esplicito con la data.
- tests/test_book_cadenza.py (10): deriva e confronta docstring, intestazione di
  cron_book.sh e crontab installata (crontab -l; SALTATO se illeggibile, non verde);
  parser a 5 campi solo per cadenze regolari; 60 < 230 < 240; minuto != :00.
- docs: CLAUDE.md §5.7 chiuso, §13 conteggio 842; memoria 40; diario 02/09b.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RziUCB336YPUUyDJ29x4Ke
2026-09-02 14:38:53 +00:00
Adriano Dal Pastro 2f701b8469 debito 14: il report stampa il TWR (+10,61%), non il bonifico (+243%)
`trades_db.py --report` calcolava e1/e0-1 sulla serie grezza di equity: il 96,3% del
numero era il versamento di $1.399,39 del 25/08. La riparazione (journal.movimenti_capitale)
esisteva e non aveva attraversato il confine fra i due lettori della stessa serie (P1).

- src/live/journal.py: `rendimento_twr` — funzione unica, spezza la serie sui movimenti
  CERTI e moltiplica i segmenti; gli ambigui restano dentro, dichiarati (P12); tre stati.
- scripts/live/trades_db.py: report() la chiama; stampa TWR con segmenti datati, movimenti
  elencati, trading al netto, delta $ etichettato "movimenti INCLUSI"; il % grezzo sparisce.
- test: +5 in test_journal.py (incl. riproduzione del +10,80% del diario 01/09, M23),
  +2 in test_trades_report.py sul testo stampato con connect() deviato in tmp. 832 verdi.
- docs: CLAUDE.md §5.14 chiuso, §2 e §13 aggiornati; memoria 40; diario 02/09.

Limite ereditato e dichiarato (D5): +10% di trading fra due letture consecutive tocca la
soglia del rilevatore e a mercato fermo verrebbe classificato movimento.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RziUCB336YPUUyDJ29x4Ke
2026-09-02 14:22:50 +00:00
Adriano Dal Pastro bc43218a12 docs: soldi fermi §73 — l'operatore sceglie di versare (piano 27/07: €5k dentro, ~€1k fuori)
Conclusione dell'operatore (02/09): "mettere gli XEON su Deribit col
sistema attivo". Si', ed e' cio' che la memoria aveva scritto il 27/07.
Tre precisazioni, tutte gia' misurate e ora registrate:
- €5.000, non €6.043: la protezione venue satura a qualunque quota > 0,
  l'ultimo migliaio dentro compra ~€100/anno e butta via l'assicurazione;
- il "~€600" e' un'attesa 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%/10a);
- fondo d'emergenza da dichiarare.
Nessuna azione di config (cap dinamico, rilevatore collaudato il 25/08).
In attesa di importo e data; al deposito una riga di giornale, scritta
prima e non ricostruita dopo. N9: le opzioni sicure (+€52-62/anno) sono
state viste e messe da parte con motivo.

- CLAUDE.md §1: riga "soldi fermi" + indice §1-73
- RESULTS §73 + riga d'indice
- memoria 30-piano-capitale-fisco: voce
- diario 2026-09-01d: §7 la conclusione dell'operatore
- docs/journal/2026-09-01.md (voce del cron, non era committata)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 06:06:15 +00:00
Adriano Dal Pastro d95fabf060 soldi fermi: i modi esistono e valgono €5/mese — BOT/conto deposito +€52-62/anno, sUSDe distrugge lo split
On-venue chiuso con l'elenco intero: 4/50 valute remunerate = le 4 gia'
chiuse il 31/08. L'USDC su Deribit non e' fermo (base di sizing + cuscino).
I €6k in XEON sono lo split-cassa. Tassi dal web con fonte (BOT 2,768%
asta 08/2026, DFR 2,25%, conti deposito 3,25-3,50%, sUSDe ~9%). In euro
netti su €6k: XEON 81, BOT 133, conto deposito 143, sUSDe 350 (ma stesso
emittente dell'USDE: split distrutto), libro ~600 (trading, split
distrutto). Miglior guadagno sicuro: +€62/anno. €100/mese di bonifico =
+4,07%/anno di drift. Non sono pareri fiscali. Nessun ordine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 21:21:14 +00:00
Adriano Dal Pastro 1c0549080c PAVIMENTO-LEVA (§72): il pavimento non licenzia taglia, 0/16 — e corregge §71
Domanda dell'operatore: "possiamo usare quanto conosciamo del pavimento per
studiare una strategia". L'inverso di COLLAR01: non "quanto DD mi risparmia
il pavimento a taglia fissa" (perdeva contro il de-levering) ma "quanta
TAGLIA mi autorizza a DD fisso" — l'unica cosa che il de-levering non puo'
comprare (cambia la FORMA, non la proporzione), e la grandezza su cui vive
il tetto di leva del progetto (n·frac·scala·sl <= 0,50 => 1,67x).

RISULTATO: 0/16. La sola put a premio reale PEGGIORA il maxDD in 16/16 celle
(FORTE 51,83% -> 55,4-75,8%; LARGO 71,94% -> 74,8-81,7%), quindi k_f = 1,000
ovunque: niente da licenziare. Monotono nella protezione: piu' la put e'
vicina, peggio va — il bleed del premio E' il drawdown.

🚨 CORREGGE §71 (stesso giorno). Stamattina A1 diceva "il pavimento funziona
davvero". E' il COLLAR a ridurre il DD, non il pavimento: a premio zero la
put lo riduce (51,83 -> 36,76, il controllo), a premio reale lo peggiora =>
tutto il beneficio e' mangiato dal premio, e oltre. Nel collar la riduzione
viene dal TETTO: il suo premio compensa il bleed, e cappare l'upside abbassa
il picco da cui il DD si misura — un picco piu' basso, non protezione.
Premio di pareggio: 36-79% del reale; il DVOL sta 1,32x sopra la RV, quindi
un prezzo equo (~76%) sfiora il pareggio nelle celle migliori e lo manca
nelle altre. Il motivo di §46 (beta) non si applica — a beta 1,0 la put
paga davvero — ma il verdetto di §46, "il maxDD SALE", si riproduce per un
motivo diverso. Corretti il diario di §71 (titolo e A1), RESULTS, memoria.

IL MECCANISMO (B2), trasferibile: i drawdown di BTC sono GRIND. maxDD FORTE
2021-07-20 -> 2022-05-06 = 290 giorni; LARGO fino al 2023-10-16 = 818. Una
put a 7-14g copre UNA finestra; il DD che conta dura 20-60 finestre; la put
scade OTM ogni settimana (para nel 0-9% dei cicli) mentre il premio sanguina.
Non protegge nemmeno la finestra peggiore: 14g FORTE -22,9% nudo -> -23,6%
col pavimento. Su BTC il pavimento compra protezione contro la cosa sbagliata.

LA LICENZA DEL DISASTER-SL E' DI CARTA (B3), scritto prima che sia comodo:
con la put a 5d/14g a 21,9% dallo spot, sostituire sl con quella distanza
darebbe 2,28x. Ma l'invariante limita UN episodio, la put limita UNA
finestra, e il massimo su finestre consecutive non e' limitato da nulla:
1 finestra -23,6% (coperta), 8 finestre -33,6% (non coperte) => a 2,28x un
grind di 112 giorni costerebbe il 77% dell'equity. disaster_sl_pct NON si
sostituisce con la distanza di un pavimento. Aggiunto a CLAUDE.md §3.

GATED SULL'IV (B4) — la copertura dinamica che §46 non aveva provato: put
ON solo sotto il 25°/50° pctl di DVOL/RV. Migliora molto (FORTE +3,8% ->
+8,4%) ma resta sotto la base (+10,04%) e il DD resta >=. L'incollatura
ignora lo spread delle transizioni A FAVORE del gated: perde a maggior ragione.

Quattro attese a priori (B1-B4) scritte prima, tutte confermate.

NON MISURATO, dichiarato: nessun DSR (cade al primo gate); nessuna lente
reale; nessun pavimento a scadenza lunga (30-90g, che coprirebbe piu'
finestre di grind) perche' l'operatore ha vincolato a <=15 giorni — e'
l'unica variante che B2 lascia aperta, e sta fuori dal vincolo.

Libro, pesi, cron, config INVARIATI. Nessun ordine. Test 25/25 sui filoni.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 20:45:02 +00:00
Adriano Dal Pastro ec8478308f GATE SCALA-01: la chiave di scala esiste, ed e' INERTE (SPEC §8 punti 1-4)
Chiude il debito che CLAUDE.md dichiarava da settimane: la regola "ogni cambio
di scala passa dal cap di config, non da target_vol" NON era implementabile
perche' una chiave di scala non esisteva — qualunque cambio sarebbe finito su
WEIGHT/W_TP01/W_SKH, cioe' codice su un percorso con soldi veri e per giunta
nel posto sbagliato (W_TP01/W_SKH sono il RAPPORTO 75/25, non la taglia).

Specifica gia' scritta in docs/research/SPEC-scale-key.md (618 righe, 9
condizioni di gate, prototipo). Non ho progettato: ho eseguito i punti 1-4.

🚨 config/live.json NON E' STATO TOCCATO. La chiave e' assente, vale 1,00, e
T7 dimostra bit-exact che il libro e' quello di ieri (max|diff| = 0.0).
Verificato anche a runtime: book_execute in dry-run da' gli stessi target del
cron delle 15:47 (BTC $+355, ETH $+214).

LE QUATTRO DECISIONI CHE NON SONO DI COMODO
- La scala si applica DOPO il clamp. Prima, il cap se la mangerebbe proprio
  nei giorni di massima convinzione (a tp=1/sg=+1 il grezzo vale esattamente
  cap => k_eff tornerebbe a 1,00 a ogni k): sarebbe un cambio di FORMA
  travestito da cambio di taglia, e la curva g(k) con cui il gradino viene
  autorizzato non descriverebbe quel libro. Prezzo dichiarato: il cap diventa
  il tetto del libro UNITARIO, e la guardia sulla leva lorda va ricostruita.
- Il tetto e' sul PRODOTTO e sta nel CODICE. Sulla sola chiave lascerebbe
  aperta la porta accanto (frac 0,625 x scala 1,25 = 1,562x); in config
  sarebbe un lucchetto con la chiave attaccata. LEVA_LORDA_MAX 1,25 in
  src/live/book.py => il gradino a 1,50 richiede codice, quindi review.
- Fuori scaletta o fuori tetto = STOP, non clamp. book_execute si ferma, non
  invia, allerta (ScalaNonAutorizzata). E SCALA_LADDER (1,00 · 1,25) rende
  INESPRIMIBILE "solo un po'": 1,05 non e' prudente, e' fuori scaletta.
- La scala vive solo sul percorso fidato (equity illeggibile => 1,00), cosi'
  "il fallback non e' piu' permissivo" e' vero per costruzione. Ma la
  VALIDAZIONE avviene sempre: una config rotta non si nasconde dietro un giro
  in cui l'equity non era leggibile.

LA GUARDIA CHE MORDE PER PRIMA non e' il peggior giorno (k <= 3,49x) ma il
COSTO di un disaster-SL (k <= 1,67x, 2,1x piu' stringente): l'invariante
n_asset x frac x scala x disaster_sl_pct <= 0,50 scatta anche se qualcuno
allarga lo stop invece di alzare la scala.

TEST T1-T11 (tests/test_book_scale.py, 18 verdi). Il piu' importante e' T1b:
a k=1 l'implementazione simmetrica e quella asimmetrica danno lo STESSO
numero, quindi un test di simmetria scritto sul caso di default ha potenza
ZERO. T1b verifica che le due coincidano a k=1 (il rischio e' reale) e che
fuori da k=1 l'asserzione le SEPARI, con un'implementazione asimmetrica
scritta nel test apposta perche' fallisca.

SORVEGLIANTE scale_watch (cron_daily, 3 domande / 3 azioni / 3 stati, una
allerta per streak, marcatore scritto solo dopo invio riuscito — debito #2).
Riporta la frequenza del ramo di fallback, che sopra il 2% in 90 giorni
invaliderebbe la regola: misurata 0/1.676, coi 19 giri "paper capital"
(pre-finanziamento, dove il libro non invia) contati e dichiarati a parte.
Non puo' impedire la modifica: la rende visibile entro 24h e attribuibile.

CHIUDE il debito #5 di §5: T2/T3 sostituiscono il vecchio
test_leva_massima_da_config (che misurava frac x n_asset mentre la grandezza
vera e' frac x n_asset x scala), e T11 verifica che sia rimasto cancellato.

NON FATTO, deliberato: la chiave in config (punto 2 lo vieta), GATE SCALA-01
(A2 richiede >=30 giorni a 1,00 col sorvegliante attivo — "l'unico modo di
scoprire che il sorvegliante e' rotto mentre la leva e' ancora 1,00"),
r0726_fee_sensitivity rifatto (A7: serve solo al gradino; a 1,25x una
liquidazione costerebbe 1,25% non 1,00%, e ereditarlo sarebbe l'errore).

Nessun ordine. Suite: 825 passati.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 17:44:02 +00:00
Adriano Dal Pastro 05816c49f9 COLLAR01 (§71): il pavimento funziona, il tetto lo paga troppo — e cio' che vince e' VRP01
Chiesto dall'operatore in quattro battute: hold BTC long o short coperto in
opzioni, scadenza <=15gg, "ridurre la vincita ma bloccare la perdita" (=>
collar, non put protettiva), entrata gated da indicatori ("forte bull"), e
uscita dalle opzioni fra il 50% e il 75% del tempo.

PERCHE' SI POTEVA RIAPRIRE DOPO §46. §46 (tail-hedge) fu refutato sul beta
+0,076 del libro — "non si assicura un libro che nei crash e' gia' quasi
piatto". Qui il sottostante e' un hold di BTC, beta 1,0: quel motivo non si
applica. E §46 dichiarava non provata proprio la copertura gated su regime.

L'entrata non aggiunge un solo parametro: tsmom_blend media tre np.sign() su
(30,90,180) => valori in {-1,-1/3,+1/3,+1}, quindi "forte bull" = |blend|==1
(52,5% dei giorni) contro il confronto dichiarato |blend|>=1/3 (97,1%).

RISULTATI (lente lunga 2021-03 -> 2026-09, 5,44 anni; griglia 48 celle
dichiarata prima + 36 di estensione dichiarata):
- A1 CONFERMATA: il pavimento FUNZIONA, maxDD scende in 36/48 (§46: saliva
  in 162/162). La meta' della domanda ha risposta positiva.
- A3 CONFERMATA: il de-levering lo fa meglio in 45/48. Δdrift/ΔmaxDD 7,90
  (gate forte) / 1,98 (largo): 2-8 punti di drift per punto di DD.
- C9 in forma pura: il tetto taglia il 46,2% dei cicli VINCENTI, il pavimento
  para il 6,7% dei PERDENTI — 7x piu' spesso sui vincenti: troncatura.
- M1: collar Sharpe 0,508 vs TP01 0,852; TP01+10% => +0,000 di Sharpe e
  +2,32pp di maxDD.

IL FATTO CHE VALE PIU' DEL VERDETTO. Le 3 celle vincenti stavano tutte sul
BORDO; estesa la famiglia vince 35/36 nell'ANGOLO (dput 0,02 / dcall 0,50,
Sharpe 1,471) — e il limite di quell'angolo e' una COVERED CALL: la pendenza
porta fuori dalla domanda posta e dentro lo short-vol. E quel 1,471 e' il
prezzatore che si paga da solo: DVOL/RV-forward 1,320 a 7g (sopra nel 76,9%
dei giorni) => riprezzato alla vol vera l'angolo cade a 0,511, che e' VRP01
(0,47). Non una scoperta: VRP01 per una strada piu' lunga. §3 lo blocca.

USCITA ANTICIPATA: implementata (exit_frac) e COSTA. Cella onesta gate forte:
drift +9,48% (scadenza) -> +3,84% (50%), esito da VINCE a perde sotto 0,75.
Il meccanismo previsto c'e' (VRP residuo +3,38 -> +1,27pp) ma lo spread lo
travolge. Corregge l'applicazione di §46: "un roll anticipato non paga f"
vale per una copertura solo LONG; in un collar la gamba venduta va
RICOMPRATA, quindi si paga f sulla parte che a scadenza si regolava gratis.
L'asimmetria si INVERTE quando la struttura ha una gamba corta.

CONTROLLI DELL'APPARATO 3/3 (M15): pranzo gratis riconosciuto (maxDD
51,83%->36,76%, drift +10,04%->+35,69%), premio x10 rifiutato, zero-cost
finito. Cinque difetti miei catturati dai controlli, non a occhio: bisezione
zero-cost invertita (dava Sharpe -3,9), dcall=NaN nella cassa, C9 non
consapevole della direzione (S1>S0 non e' "vincente" per uno short), e due di
contabilita' che avrebbero ADULATO il collar (base che rollava lo spot
pagando ~3,6%/a di fee inesistenti; roll che chiudeva lo spot senza motivo).

Corregge anche un muro di §46: il tick da 5 USDC e' della famiglia USDC; la
catena che raccogliamo e' 100% inverse, quindi li' non si applica.

Regole nuove in CLAUDE.md: M29 (un edge da opzioni prezzate a modello si
riprezza alla vol REALIZZATA prima di crederci), M8 esteso (un argmax sul
BORDO e' una pendenza, non una cella), C4 esteso (il segno dell'asimmetria di
f dipende dal verso della gamba).

Libro, pesi, cron, config INVARIATI. Nessun ordine. Suite: 807 passati.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 17:30:18 +00:00
Adriano Dal Pastro 5bcf212923 stato trades: il "+243%" del report e' per il 96,3% un bonifico — TWR +10,80%
`trades_db.py --report` stampa `$598,06 -> $2.051,84 (+243,08%)` accanto a
`netto +40,77`. La serie di 1.657 letture orarie contiene UN SOLO salto: il
versamento di $1.399,39 del 25/08 11:47Z. Confermato dal classificatore
ufficiale del progetto (`journal.movimenti_capitale`: certi 1399.39,
ambigui 0.0, classe `movimento`).

Al netto: TWR +10,80% spezzato sul versamento (+11,61% fino al 25/08,
-0,73% dopo), equity +$54,39 in 69 giorni. Il giornale, che lo scorporo lo
fa gia', concorda: $+62,00 al 31/08, -7,61 di marcatura fino a oggi.

Il difetto non e' nuovo — e' la stessa riparazione che NON ha attraversato
il confine. `journal.py:121` porta il caso d'origine nel docstring (la voce
del 25/08 che dichiarava «+$1.414,57» di giornata); `trades_db.py:83`
calcola `100*(e1/e0-1)` sulla serie grezza e non chiama
`movimenti_capitale()`, che e' a un import di distanza. Variante di P1: non
un sorvegliante che ridichiara il bersaglio, ma una riparazione che non si
e' propagata al secondo lettore della stessa serie. Danno sui soldi nessuno
(sola lettura); danno di citazione si', ed e' l'unico numero fuorviante che
il progetto produce su richiesta di un comando pubblicato in §13.

Codice NON toccato: sta su uno script che legge il libro vivo. Debito #14.

Stato del libro al 01/09 16:47Z, per il resto invariato: 45 fill (ultimo
31/08 15:47), 29 round-trip (21 in utile, netto +40,77), posizioni BTC
0,0045 @ $79.208,11 e ETH 0,0872 @ $2.473,04, non realizzato -$11,72, leva
lorda 0,27x. Nessun fill da 25 ore = banda morta del min_order_usd $5, non
un blocco (il cron logga «gia' al target» a ogni giro). monitor_health 8/8
OK, riconcilio 44/45 con 0 prezzi divergenti (l'unica coppia scoperta e' il
difetto noto dei sei giorni fra log e jsonl sullo stesso fill).

- CLAUDE.md §2: riga nella tabella dei numeri da non citare
- CLAUDE.md §5: debito #14
- docs/diary/2026-09-01-stato-trades.md
- docs/journal/2026-08-31.md (voce del cron, non era committata)

Suite: 800 passati, 0 falliti.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 16:52:48 +00:00
Adriano Dal Pastro 8d391ed29a margini: la pagina si ricostruisce dal gateway — e l'"IM %" non e' il margine delle posizioni
L'operatore ha incollato la riga CROSS col modello ATTIVO invece che proiettato:
Available $2.010,81 · IM 2,15% · MM 0,37%. Il gateway, letto allo stesso minuto, dice
available_funds = 2010,82299383. Scarto $0,01.

(a) LO SCREENSHOT NON SERVE PIU'. La riga CROSS della pagina E' `available_funds`, che
leggiamo da soli a ogni giro. Ieri quella schermata era l'UNICA fonte per l'haircut e per il
modello attivo, e per averla e' servito un passaggio dall'operatore e da Wasabi.
r0831_margini_conto.live() la ricostruisce.

(b) TERZA CONFERMA DEL 5%, E LA PRIMA ESATTA:
    1.400,71 + 0,95x654,176 + 0,20 dust - 11,553 IM = $2.010,82   contro pagina $2.010,81
Scarto $0,007. Il 10% della vecchia config non e' improbabile: invertendo la stessa riga da'
IM = -$21,16, aritmeticamente impossibile. Tre strade indipendenti, stesso numero.

(c) MIA LETTURA SBAGLIATA, CORRETTA. L'"IM %" della pagina e'
(margin_balance - available)/margin_balance, non il margine delle posizioni:
    haircut USDE  $32,71 = 1,592%   +   IM vera  $11,55 = 0,562%   =  2,153%
Il 74% di quella percentuale e' HAIRCUT. E' salita da 2,13% a 2,15% perche' abbiamo comprato
collaterale a rendimento — letta come rischio direbbe che ieri sera abbiamo alzato la leva
comprando USDE, l'opposto di quello che e' successo. La MM invece e' pulita ($7,60) e non puo'
contenere l'haircut, che da solo la renderebbe negativa: le due colonne hanno basi DIVERSE e la
pagina non lo dice. Regola: una percentuale letta da una schermata va INVERTITA nella sua
definizione prima di essere confrontata (P7 su superficie nuova).

(d) LA BOCCIATURA DI X:PM ESCE RAFFORZATA. La KB da' USDe al 5% sotto entrambi i modelli:
l'haircut si cancella e l'inversa da' il margine di POSIZIONE sulle stesse due posizioni,
stesso istante — X:SM $11,64 contro X:PM $88,23, 7,6x, e 9,3x la MM. Due misure pulite,
stesso verso. E la riga X:SM era una PROIEZIONE di un modello inattivo che oggi si riproduce
al centesimo: anche la riga X:PM era affidabile, quindi decidere senza provare era corretto.
Ora si sa perche', non solo che.

Suite: 800 passati.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmkQty4a99pVmvwGfxAMQ9
2026-08-31 08:39:45 +00:00
Adriano Dal Pastro 62025e7840 docs: X:SM ri-sondato e X:PM scartato — il modello di margine entra nel tetto ma non lo spiega
Le due misure di ieri sera vivevano solo in chat. Ora sono nei documenti, con i numeri
e con cio' che le riapre.

X:SM vs S:SM, stessa notte, stesso conto:
  S:SM  tetto [643,18 - 644,18)  equity $2.055,56  ->  31,29% - 31,34%
  X:SM  tetto [654,18 - 655,18)  equity $2.054,90  ->  31,84% - 31,88%
+0,55pp = +11 USDE ~ $11. L'ipotesi "il tetto dipende dal modello di margine" e' VERA e
INUTILE: per arrivare al 70% mancano ~38 punti, un fattore 2,2x. Un'ipotesi confermata al
terzo decimale e falsa all'ordine di grandezza si archivia come falsa — il tetto resta
misurato e non spiegato.

La lezione di metodo vale piu' del risultato: il primo probe fu un BUY 20, RIFIUTATO, e da
solo avrebbe chiuso la questione con "tetto invariato". Falso: il tetto si era mosso di 11,
cioe' MENO della taglia del probe. Un probe unico di taglia sbagliata produce un falso
negativo che si legge come risultato — la taglia si sceglie sulla risoluzione dell'effetto
che si cerca (M13 in veste nuova).

X:PM valutato e SCARTATO senza provarlo: la pagina margini lo dice gia'. Available Balance
$1.935,14 contro $2.011,73 (-$76,59), MM 3,43% contro 0,37% (~9x). Il PM e' basato su
scenari e premia il rischio che si compensa; due long direzionali nudi sono il suo caso
peggiore. La MM e' la riga che decide: non morde a 0,28x ma sposta il punto di liquidazione.
NON "PM e' peggio" ma "PM e' peggio PER QUESTO portafoglio" — cosa lo riapre: il deploy di
VRP01 o di un altro sleeve di opzioni, che fa compensare il rischio e cambia il segno del
confronto.

Registrato anche il prezzo del cross, che non e' zero: sotto segregato l'USDE era ring-fenced
dalle perdite del libro, sotto cross risponde l'intero conto. Non morde oggi (perdita massima
plausibile ~$617, dentro il solo USDC), ma il verso e' cambiato e tornare a S:SM lo ri-recinta.

Suite: 800 passati.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmkQty4a99pVmvwGfxAMQ9
2026-08-31 08:30:51 +00:00
Adriano Dal Pastro 4705d71ceb tetto ri-sondato sotto X:SM: il modello entra ma non spiega — +11 USDE
L'operatore e' passato a Cross: Standard Margin. Verificato dal gateway:
available_funds USDC da ~$1.400 a $2.011,09 contro $2.010,90 attesi con haircut
5% (scarto $0,19) — seconda conferma indipendente del 5%, da una lettura
completamente diversa dallo screenshot.

RI-SONDATO il tetto a saldo neutro, stesso metodo del 31/08:
  SEGREGATO S:SM  [643,18 - 644,18)  equity $2.055,56  = 31,29-31,34%
  CROSS     X:SM  [654,18 - 655,18)  equity $2.054,90  = 31,84-31,88%
Il modello ENTRA nel tetto ma NON lo spiega: +0,55pp = +11 USDE (~$11), non le
centinaia che servirebbero per il 70% (manca ancora un fattore ~2,2x).
L'ipotesi "il tetto dipende dal modello segregato" e' quantitativamente demolita
come spiegazione, pur essendo tecnicamente non nulla.

Nota di metodo: il primo probe (BUY 20) era stato rifiutato e avrebbe fatto
concludere "tetto invariato". Era solo troppo grosso: il test a saldo neutro con
passi piccoli ha mostrato che il tetto si era mosso di 11. Un probe unico di
taglia sbagliata produce un falso negativo che si legge come risultato.

config: venue_cap_frac 0.312 -> 0.318 (bordo basso del bracket CROSS, che e' il
modello attivo; se si torna a S:SM va rimesso a 0.312).

E il costo del passaggio, che va detto: sotto SEGREGATO l'USDE era RING-FENCED
dalle perdite del libro (solo il silo USDC rispondeva); sotto CROSS risponde
l'intero conto. Non morde oggi (perdita massima plausibile ~$617 col disaster-SL,
dentro il solo USDC) ma il rischio strutturale ha cambiato verso. Il cross ha
comprato $611 di margine utilizzabile — inutile a 0,28x, serve sopra ~1,4x che
non e' autorizzata — piu' $11 di capienza.

Suite: 800 passati.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 08:12:31 +00:00
Adriano Dal Pastro fd7595e819 margini: haircut 5% (la config aveva torto) — e il conto NON e' cross-collateral
Lo screenshot della pagina margini era su Wasabi (rclone remote wasabi:, bucket
adp-work, cartella _scambio), non sulla VPS: per questo il percorso non esisteva.
Riconciliazione riproducibile in scripts/research/r0831_margini_conto.py (N11).

(a) HAIRCUT = 5%. La pagina non lo espone come numero: si ricava per differenza,
perche' il CROSS conta l'USDE scontato e il SEGREGATO non lo conta affatto.
  S:SM (attivo)  USDC Available 1.400,86  vs  equity 1.412,28 - IM 11,55 = 1.400,73
  X:SM           CROSS Available 2.011,73
  contributo USDE al cross = $610,81 su $643,05  ->  5,0137%
    5% (KB)     atteso $610,89  scarto $ 0,09  TORNA
   10% (nostra) atteso $578,74  scarto $32,06  NON torna
config/live.json: haircut 0.10 -> 0.05. Il 26/08 il 10% fu registrato come
"verificato sul venue" senza traccia di COME, e non lo era.

(b) 🚨 IL CONTO NON E' CROSS-COLLATERAL: modello attivo "Segregated: Standard
Margin" (S:SM). Nella tabella del modello attivo l'USDE NON COMPARE: non fa
margine per i perp USDC-settled del book, e l'haircut oggi non si applica. Il che
spiega a posteriori perche' la misura di stamattina trovava $0,0000 accantonati:
non stavo guardando nel secchio sbagliato, cercavo un parametro che sul nostro
conto non e' in vigore.

NON MORDE: al massimo lordo del libro (1,0x = ~$2.056 di nozionale) l'IM sarebbe
~$41 contro $1.400 di USDC disponibile, 34x di copertura. Nessuna decisione
operativa cambia oggi.

Ma la premessa in CLAUDE.md era falsa, e il modo in cui lo era e' istruttivo:
"l'equity del book e' il TOTALE cross-collateral" metteva due cose sotto un nome
solo. Come RICCHEZZA sommare USDC+USDE e' giusto ed era il punto della riparazione
del 26/08 (evito' il falso "USCITA DI FONDI -24%"); come CAPACITA' DI MARGINE e'
sbagliato, perche' nel modello attivo l'USDE vale zero. Finche' il margine non
morde le due coincidono nell'uso, e infatti non era mai emerso.

Corretta anche la riga di usde_watch che stampava "margine utilizzabile ~$2.013
(haircut 10%)": era falsa due volte insieme. Ora stampa il solo silo USDC e,
accanto, cosa darebbe il cross.

La quota USDE non e' "collaterale diversificato": e' cassa a rendimento FUORI dal
sistema di margine. Passare a X:SM aggiungerebbe $610,87 di margine utilizzabile
ma porta la meccanica cross (collateral fee 0,05%/giorno sul saldo negativo,
ribilanciamento automatico): decisione dell'operatore, oggi non serve.

Ipotesi nuova e non verificata: il tetto del ~31,2% potrebbe dipendere proprio dal
modello segregato. Si saprebbe passando a X:SM e ri-sondando.

Suite: 800 passati.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 08:01:21 +00:00
Adriano Dal Pastro c8be83ef6e haircut: la divergenza non si chiude dal gateway — misurato perche', non supposto
Richiesta: leggere la pagina margini del conto per chiudere il 5% (KB) vs 10%
(nostra config). NON E' RAGGIUNGIBILE da qui: account_summary per la valuta USD
da' "Invalid currency", il parametro `extended` viene ignorato, e il gateway
filtra a 8 campi senza initial_margin/maintenance_margin (debito #11).

E non e' che si sia guardato nel secchio sbagliato: la contabilita' TORNA ESATTA
senza alcun termine di haircut.
  USDC equity 1412.281074  available 1400.728093  riservato 11.5530
  USDE equity  643.175691  available  643.175691  riservato  0.0000
  posizioni lorde $577.65 -> IM al 2% = $11.5530 -> RESIDUO -$0.0000
Un haircut al 5% chiederebbe $32,16 accantonati, al 10% $64,32: non ci sono.
=> L'haircut non e' osservabile in ALCUN campo esposto. O e' applicato solo nella
vista cross/USD che il gateway filtra, o non e' applicato al nostro conto: da qui
le due cose non si distinguono, e non le si sceglie tirando a indovinare.

La divergenza resta APERTA, ma con la ragione MISURATA invece che supposta. La
chiudono 30 secondi sulla web UI o delle chiavi API Deribit (la decisione gia'
dichiarata nel debito #11, non un refactor). Nel frattempo non morde nulla di
osservabile: l'IM e' il 2% del nozionale e l'USDE resta interamente disponibile,
quindi 5% o 10% non cambia una cifra operativa.

L'operatore ha dichiarato che il conto e' STANDARD MARGIN. Aggancia due cose:
  - la colonna da leggere e' Haircut (X:SM), che per USDe dice 5% come la PM:
    ora si sa con certezza quale numero ufficiale contraddice il nostro;
  - spiega perche' l'IM misurata e' esattamente 1/50: il nuovo modello di margine
    del 05/08 si applica ai "standard margin accounts", tier 1 C1=50. Un fatto
    dichiarato dall'operatore e una misura fatta senza conoscerlo si confermano
    a vicenda — ed e' la PRIMA affermazione del venue che il conto conferma questa
    settimana, dopo l'APR USDC e "all users can buy BUIDL".

Lo screenshot non e' arrivato: il percorso sta sul desktop dell'operatore, non
sulla VPS.

Suite: 800 passati.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 07:53:26 +00:00
Adriano Dal Pastro 30286ea033 venue_news: sorvegliare cosa il venue ANNUNCIA — e due correzioni dalla KB
Nasce dall'annuncio sulla fine della Proof of Reserves. La notizia in se' vale
poco: si perde un segnale ad alta frequenza e DEBOLE (una PoR auto-pubblicata
prova che gli attivi esistono in un istante, non che coprano le passivita') e si
guadagna un audit annuale indipendente sotto VARA piu' il 90% degli attivi presso
Coinbase come custode. E non l'abbiamo mai sorvegliata: zero occorrenze nel
codice. Rinunciato di proposito a catturare l'ultimo dato — uno snapshot singolo
auto-riportato non regge lo standard di prova e non permetterebbe di stimare `p`.

CONSEGUENZA VERA: la decisione "100% Deribit fino a $20k" ha fra i suoi riapritori
"`p` che diventa stimabile invece che assunto". Dal 1/9 il segnale pubblico passa
da quotidiano ad ANNUALE: da una serie si stima, da un punto all'anno no. Quel
riapritore diventa praticamente irraggiungibile => la decisione e' ora gated SOLO
dal capitale.

TROVATO CERCANDO: esiste un feed RSS degli exchange-update, e conteneva quattro
annunci materiali che nessuno leggeva. Il caso che decide: le specifiche dei perp
USDC sono cambiate il 18/08, ANNUNCIATE IL 14/08. check_specs() e' nato da quella
svista e gira ogni ora, ma rileva la deriva DOPO; il feed l'avrebbe detta quattro
giorni PRIMA. La prima volta e' andata bene solo perche' i cambi erano RIDUZIONI.

Costruito scripts/live/venue_news.py (in cron_daily accanto a fee_watch):
  - NON interpreta: dice "e' uscito questo, guardalo". Nessun automatismo su un
    testo di marketing (P13). Classifica solo l'urgenza.
  - parole-chiave DERIVATE da deribit._CONTRACT e config/live.json, non
    ridichiarate (P1): chi aggiunge un asset allarga la sorveglianza da solo, ed
    e' il test che lo blinda.
  - primo giro semina senza allertare (P9); feed illeggibile -> exit 2, mai
    silenzio implicito (P5).
Debito #8 si RESTRINGE, non si chiude: il Rulebook non ha un feed.

Confermato due volte lo zero USDC: l'espansione del 31/07 non contiene l'Italia
ne' alcun paese UE, mentre San Marino e Citta' del Vaticano SONO idonei. Non
asserisco una causa (ci sono anche Canada e Giappone).

DUE CORREZIONI dalla KB "Cross collateral specifications":
(a) Il saldo negativo costa una collateral fee dello 0,05% AL GIORNO = 18,25%/anno,
    al secondo — 4,3x la resa USDE. Il 30/08 avevo scritto "lo finanzia a
    interesse" SENZA il numero: giusto e vuoto. Col numero, il criterio del
    cuscino di regolamento diventa aritmetica (-14 punti). E il ribilanciamento
    automatico non salva: scatta a $1M assoluti o al 100% della cross equity,
    irraggiungibili a $2k. Non veniamo ribilanciati: sanguiniamo la fee.
(b) L'haircut USDe ufficiale e' 5%, noi abbiamo 0.10 registrato come "verificato
    sul venue" il 26/08 senza traccia di come. NON riparato (P12/M28): si tiene
    0.10 perche' e' il lato conservativo, non e' sul percorso soldi e non morde
    fino al 90% di quota. Divergenza dichiarata in config.
Di lato: BUIDL haircut 2% (sarebbe stato il miglior collaterale del listino, se
si potesse comprare), stETH 7,5% (terza ragione indipendente per lasciarlo stare).
Nessuna fonte documenta un tetto sulle QUANTITA': il ~31,2% resta misurato e non
spiegato — e ora si sa che non e' una svista di lettura.

Suite: 800 passati (5 nuovi su venue_news).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 07:47:15 +00:00
96 changed files with 10744 additions and 523 deletions
+208 -31
View File
File diff suppressed because one or more lines are too long
+117 -387
View File
@@ -1,423 +1,153 @@
# PythagorasGoal
Sistema di riconoscimento pattern frattali e predizione per il trading di criptovalute (BTC, ETH), ispirato al framework teorico di Serleto & Malanga (*Pythagoras Trading Prediction*).
Ricerca e esecuzione di strategie algoritmiche su BTC/ETH, con **un libro che gira con soldi veri**
su Deribit mainnet dal 20 giugno 2026.
## Obiettivo
> 🚨 **v2.0.0 — RESET del 2026-06-19. Tutto ciò che questo README diceva prima è archiviato in
> `Old/` e non è fidato.** L'intera libreria di strategie "validata out-of-sample" (le famiglie
> FADE/HONEST/PAIRS/TSMOM/SHAPE, i portafogli PORT01-06, gli Sharpe fra 6 e 10) era un **artefatto
> di uno storico contaminato**: print fantasma di un feed *testnet* più storico Binance/USDT.
> Ri-testate sul feed reale ricostruito da Deribit mainnet, **perdono ogni anno**. Documento di
> fondazione: `docs/diary/2026-06-19-deribit-history.md`.
>
> Questo README descrive il progetto **dopo** il reset. È stato riscritto il 2026-09-02, dopo
> essere rimasto fermo al 4 giugno — quindici giorni prima del reset — mentre pubblicava i numeri
> che il progetto aveva già dichiarato falsi.
Partendo da un capitale iniziale di €1.000, raggiungere un profitto medio di €50 al giorno entro 68 mesi, tramite un portafoglio di strategie algoritmiche poco correlate fra loro — mean-reversion, trend/rotazione e spread market-neutral — validate out-of-sample e fee-aware.
**L'autorità sui numeri e sulle decisioni è `CLAUDE.md`**, e il racconto completo sta in
`docs/memory/`. Questo file è l'ingresso, non la fonte.
## Risultati
---
> ⚠️ **Revisione 2026-05-28.** La famiglia squeeze-breakout (SQ/MT/ML/AD/CM/PD, con
> accuracy storiche dichiarate 76-82%) è stata **scartata**: quei numeri erano un
> **artefatto di look-ahead**. I backtest decidevano la direzione dalla candela di
> breakout `close[i]` ma entravano a `close[i-1]` — impossibile dal vivo. Sotto
> ingresso onesto (`close[i]`) e fee reali, l'edge sparisce e tutte perdono, anche
> a fee zero. Dettagli e prove: `scripts/analysis/oos_validation.py`.
## Cos'è vivo, adesso
Dopo una validazione **out-of-sample, fee-aware** di molte famiglie di strategie,
emergono cinque famiglie con edge netto reale, tutte radicate nella stessa lezione
(in cripto la **mean-reversion** funziona, la continuazione no) o nella diversificazione:
| | |
|---|---|
| libro live | **TP01 + SKH01 a 75/25**, nettati in software su una sola posizione per asset (50/50 BTC/ETH) |
| venue | Deribit mainnet, perpetual lineari USDC. Esecuzione **armata** dal 2026-06-20 |
| capitale | ~$2.050 (USDC + USDE), non i €2.000 nominali di nessun paper trader |
| cadenza | **oraria**, minuto `:47` (`scripts/cron_book.sh`) |
| leva | lorda ~0,28x su un tetto di 1,00x. Il cap non ha mai morso: **il vincolo è il segnale** |
| protezione on-book | un solo disaster-SL rotolante al 30% sulla posizione netta |
| Famiglia | Meccanismo | Strategie | Profilo (netto OOS) |
|----------|-----------|-----------|---------------------|
| **FADE** | mean-reversion intraday 1h (long/short, BTC/ETH) | MR01 Bollinger, MR02 Donchian, MR07 Return-reversal | Acc 52-55%, DD 18-34% |
| **HONEST** | long-only multi-regime multi-crypto | DIP01 dip-buy, TR01 EMA-trend, ROT02 dual-momentum | CAGR 31-56%, DD 15-27% |
| **PAIRS** | spread reversion *market-neutral* (2 gambe) | PR01 ETH/BTC, LTC/ETH, ADA/ETH, BTC/LTC, ETH/SOL | Sharpe 2.0-4.4, corr col mercato ~0.05 |
| **TSMOM** | time-series momentum multi-orizzonte | TSM01 (3/6/12m + risk-off) | diversificatore, DD 15-22% |
| **SHAPE** | ML walk-forward su feature di *forma* del prezzo | SH01 (LogisticRegression, orizzonte 12 barre) | diversificatore, corr +0.08 col resto |
**Non** sono nel libro live: XS01, VRP01, GTAA01, XSR01. Vivono nel portafoglio di **ricerca**
(paper, 5 sleeve), che è una serie diversa da quella che gira — quasi tutti i numeri di portafoglio
del progetto sono su quella, non su questa.
Tutti i numeri sono **netti** dopo fee realistiche (Deribit 0.10% RT single-leg, 0.20%
RT/coppia sui pairs), leva 3x, su finestra held-out. Le strategie sono robuste su griglia
parametri, sweep fee 0.00-0.20% RT e — per i pairs — validate con **walk-forward** e
config universale (niente cherry-picking).
## I numeri, con la loro lente
### Portafoglio combinato (la vera leva anti-drawdown)
Ogni numero va scritto con la lente e la banda che gli appartengono. La tabella completa
("non citare" ↔ "citare") è in `CLAUDE.md` §2; qui i tre che contano.
Le famiglie sono **quasi scorrelate fra loro** (~0.05). Combinandole in un unico
portafoglio equipesato il drawdown crolla sotto quello di ogni singola sleeve:
| grandezza | valore onesto |
|---|---|
| rendimento del libro live | **TWR +10,6%** dall'armamento, spezzato sul versamento del 25/08: +11,61% prima, 0,9% dopo |
| crescita di trading | **+$50** in 71 giorni, su 30 round-trip chiusi e $0,87 di fee totali |
| Sharpe del portafoglio di ricerca | **1,95** [1,81 2,12] full, **1,54** [1,11 1,91] hold-out |
| Portafoglio | CAGR | Max DD | Sharpe |
|-------------|------|--------|--------|
| FADE (6 sleeve) | ~46% | 8% | 3.9 |
| HONEST (3 sleeve) | ~46% | 13% | 2.2 |
| **MASTER** (FADE + HONEST, 9) | ~47% | **5%** | 4.2 |
| **MASTER + PAIRS + TSM01** (15) | ~67% | ~5% | ~6 |
| **PORT06 live** (17 sleeve, cap pairs 33%, leva 2×, config EXIT-16) | ~79% | **2.6%** | 7.8 (FULL) / 10.1 (OOS) |
⚠️ Il rendimento dell'equity **non** è la performance: il 96% della crescita del conto è un
bonifico. Chi legge una percentuale su questo progetto deve sapere se è un TWR o un rapporto fra
due saldi — è stato un difetto reale, riparato il 2026-09-02.
> 🔎 **Numeri sobri (anti-overfit).** L'OOS singolo cade nel regime favorevole 2024-25:
> i valori di Sharpe/DD sopra sono ottimistici di circa il 50%. Da pianificare per le
> decisioni: **Sharpe atteso ~5**, **worst-drawdown su 90 giorni ~6%**, profilo che regge
> a leva 2x con slippage raddoppiato. Configurazione raccomandata: equal-weight, leva 2x,
> con un cap sull'allocazione ai pairs (~30-35%, poiché concentrano ~57% del rischio).
> Tutto resta da confermare nel paper trading live.
## La riga che ordina tutto il resto
## Come funziona
> La ricerca ha smesso di essere il vincolo il 2026-07-26, e **sei ondate successive lo hanno
> confermato invece che ribaltarlo**. I vincoli sono **il capitale che entra** e **il conto che non
> sparisce**.
### MR01 — Bollinger Fade (mean-reversion)
Misurato: il miglior candidato nuovo vale **+0,046 €/giorno**; versare €500/mese invece di €250
porta la probabilità di arrivare al traguardo in 20 anni **dal 14% all'85%**. E €100/mese in più
equivalgono a **+4,07%/anno di drift**, cioè più di tutta la leva autorizzabile.
La strategia attiva sfrutta il fatto, emerso dai dati, che su BTC/ETH a 1h gli estremi
di prezzo **rientrano verso la media** più di quanto proseguano:
## Metodo — cosa deve superare una strategia nuova
1. **Bollinger Bands** (window `n`, `k` deviazioni standard) sul close.
2. **Entry** — quando il close esce *sotto* la banda inferiore → **long** (o *sopra* la superiore → **short**). Ingresso a `close[i]`, eseguibile dal vivo.
3. **Take-profit** alla media mobile (il rientro atteso).
4. **Stop-loss** a `sl_atr × ATR` oltre l'estremo; **time-limit** a `max_bars`.
Sei requisiti, nessuno negoziabile (`CLAUDE.md` §8, gate in `scripts/research/alt/altlib.py`):
Nessun look-ahead: direzione e livelli sono calcolati con dati fino a `close[i]`.
1. **ingresso eseguibile** — direzione e prezzo da dati fino a `close[i]`, mai l'estremo di una candela;
2. **backtest netto** dopo fee Deribit realistiche, più la leva;
3. **out-of-sample** held-out, robustezza su griglia, sweep fee;
4. **liquidità e plausibilità** — un edge su un book fermo o su wick fantasma non è un edge;
5. i **gate**: `marginal_vs_tp01` (Sharpe marginale, non assoluto), `study_family_honest` con
deflated-Sharpe ≥ 0,95, `day_boundary_robust`, `anchor_luck_band`, `weights_tilt_null`;
6. codice, test e diario.
### Le altre famiglie
Il progetto ha **73 regole di prim'ordine** (`CLAUDE.md` §6) — sul dato, sul metodo, sui costi,
sulla produzione e sul piano — ognuna pagata almeno una volta. La più
ricorrente, cinque occorrenze: *un sorvegliante deve derivare il proprio bersaglio dal codice
sorvegliato, mai ridichiararlo — un controllo puntato su una configurazione diversa da quella che
gira passa sempre, e non sta controllando niente.*
- **FADE** (oltre MR01): MR02 fada la rottura del canale Donchian verso il centro;
MR07 fada il movimento di barra estremo misurato in deviazioni standard dei
rendimenti. Stessa logica di reversione, indicatori indipendenti.
- **HONEST** (long-only, multi-crypto): DIP01 compra i dip estremi e rivende al
recupero; TR01 segue il trend con incrocio di EMA su un paniere; ROT02 ruota ogni
giorno sui tre asset col momentum più forte, andando in cash quando BTC è sotto la
sua media (risk-off). Coprono i regimi di trend e rotazione, complementari alle fade.
- **PAIRS** (market-neutral): scommette sul rientro verso la media del log-ratio fra
due cripto (z-score). Long su una, short sull'altra: l'esposizione netta al mercato è
quasi nulla (correlazione ~0.02), il che la rende un diversificatore eccellente.
- **TSMOM**: tiene gli asset con momentum positivo persistente su più orizzonti
(3/6/12 mesi), con overlay risk-off. Rende meno ma è poco correlato, utile in ensemble.
## Il dato
### Perché lo squeeze breakout è stato abbandonato
- **La verità è Deribit mainnet**, perché è dove si esegue. Binance è un audit indipendente, mai
un'ancora per "ripulire": è USDT, ~10 bps fuori, fino al 3% sotto depeg.
- Universo certificato: **solo BTC/ETH**, ogni timeframe. Gli alt sono esclusi.
- Lo storico si aggiorna **solo** con `rebuild_history.py` e si certifica **sempre** con
`certify_feed.py`. Il vecchio downloader è la causa del reset.
- La catena opzioni si **raccoglie** ogni ora (`collect_chain.py`, minuto `:25`): un'ora non
raccolta è persa per sempre.
L'ipotesi originale era opposta — *continuazione* dopo la compressione di volatilità
(Bollinger dentro Keltner → breakout direzionale). Su dati storici sembrava dare
76-82% di accuracy, ma era un **artefatto di look-ahead**: il backtest entrava a
`close[i-1]` con direzione decisa da `close[i]`. Replicando l'esecuzione reale
(ingresso a `close[i]`) l'edge collassa al ~47% (lancio di moneta) e i costi fanno
il resto. Il test sui breakout intra-barra a 5m conferma che il movimento *rientra*
subito (mean-reversion), giustificando MR01. Tutta la famiglia squeeze è in `scripts/waste/`.
### Lezione metodologica
Ogni nuova strategia deve passare: (1) **ingresso eseguibile** senza look-ahead,
(2) backtest **netto** dopo fee realistiche (0.10% RT Deribit), (3) validazione
**out-of-sample** + robustezza su griglia parametri + sweep fee. Strumenti in
`scripts/analysis/` (`strategy_research.py`, `oos_validation.py`, `intrabar_test.py`).
## Struttura progetto
## Struttura
```
PythagorasGoal/
├── src/
├── data/ # Download e gestione dati (Cerbero MCP + Binance)
├── fractal/ # Indicatori frattali: Hurst, Higuchi FD, self-similarity
│ ├── backtest/ # Motore di backtesting con fee e metriche
│ ├── strategies/ # Classe base Strategy ABC + indicatori condivisi
│ │ ├── base.py # Strategy, Signal, BacktestResult, YearlyStats
│ │ └── indicators.py # keltner_ratio, detect_squeezes, ema, atr, rv, corr
│ ├── live/ # Paper trading live su Deribit testnet
│ │ ├── multi_runner.py # Orchestratore multi-strategia (strategie + pairs)
│ │ ├── strategy_worker.py # Worker single-leg con stato persistente
│ │ ├── pairs_worker.py # Worker a 2 gambe per i pairs (market-neutral)
│ │ ├── strategy_loader.py # Import dinamico classi Strategy
│ │ ├── cerbero_client.py # Client HTTP per Cerbero MCP
│ │ ├── signal_engine.py # Squeeze + ML real-time (legacy) + validazione OOS
│ │ └── telegram_notifier.py
│ └── portfolio/ # Portafogli di prima classe (capitale condiviso, backtest + live)
│ ├── base.py # SleeveSpec, Portfolio (.backtest), load_active_portfolio
│ ├── weighting.py # Schemi di ponderazione: equal, cap, inverse_vol, cluster_rp, manual
│ ├── sleeves.py # Builder unificato equity-per-sleeve (fonte unica, parità report)
│ ├── ledger.py # PortfolioLedger: PnL/DD aggregati, persistenza e resume
│ └── runner.py # PortfolioRunner live (Cerbero v2, sizing, ribilancio giornaliero)
├── scripts/
│ ├── strategies/ # Strategie con edge validato OOS (FADE, HONEST, PAIRS, TSMOM + portafogli)
│ ├── portfolios/ # Definizioni PORT01-06 e report run() dei portafogli di prima classe
│ ├── waste/ # Strategie scartate (squeeze SQ/MT/ML/AD/CM/PD, MR03, ROT01, W01-W28)
│ └── analysis/ # Ricerca/validazione OOS fee-aware, gestione rischio, report
├── strategies.yml # Config multi-strategy paper trader
├── data/
│ ├── raw/ # Parquet OHLCV (gitignored, ~70 MB)
│ └── regime/ # DVOL + funding (Deribit mainnet) + cache feature regime (gitignored)
├── VERSION # versione semver (cotta nell'immagine, mostrata nei msg Telegram)
├── docs/
│ ├── diary/ # Diario di ricerca giornaliero
│ └── specs/ # Specifiche di design
├── Dockerfile
├── docker-compose.yml
└── pyproject.toml
src/
data/downloader.py load_data(asset, tf) sui parquet certificati
strategies/ trend_portfolio.py (TP01) · skyhook.py (SKH01) · base · indicators
portfolio/ portfolio.py (N sleeve + weights_tilt_null) · sleeves.py · gtaa.py
backtest/harness.py backtest onesto, senza look-ahead
live/ book.py (esecutore netto) · deribit · livefeed · usde
venue_watch · venue_probe · venue_news · monitor_health · scale_watch
tradesdb · journal · analista · notifier · cli
scripts/
live/ book_execute · trades_db · journal · analista · balance_watch
paper_* (forward-monitor) · usde_watch · usde_convert · fee_watch
research/ r<data>_*.py — un file per esperimento, harness in alt/altlib.py
analysis/ rebuild_history · certify_feed · audit_feed · multi_source_check
cron_{book,daily,chain,balance,usde,opt_snapshot,vol_term}.sh
docs/
memory/ LA MEMORIA — 6 file, indicizzati in testa a CLAUDE.md
research/ RESULTS-0822 (§1-77, un registro per filone) · BRIEF-0822 · SPEC-scale-key
diary/ una voce per esperimento (144)
journal/ libro di bordo, una voce al giorno, 4 livelli per provenienza
tests/ 1008, tutti verdi
Old/ archivio pre-reset — consultabile, non fidato
```
## Strategie attive
Le strategie single-asset estendono `src.strategies.base.Strategy`
(`generate_signals() → backtest()`); i pairs hanno un worker dedicato a 2 gambe.
| Codice | Script | Famiglia | Descrizione |
|--------|--------|----------|-------------|
| **MR01** | `MR01_bollinger_fade.py` | FADE | Fada la banda di Bollinger, TP alla media, SL ad ATR |
| **MR02** | `MR02_donchian_fade.py` | FADE | Fada la rottura del canale Donchian, TP al centro |
| **MR07** | `MR07_return_reversal.py` | FADE | Fada il movimento di barra estremo (z dei rendimenti) |
| **DIP01** | `DIP01_dip_reversion.py` | HONEST | Dip-buy long-only su z-score estremo |
| **TR01** | `TR01_ema_trend.py` | HONEST | EMA 20/100 trend-following su paniere cripto (4h) |
| **ROT02** | `ROT02_dual_momentum.py` | HONEST | Rotazione cross-sectional top-3 + risk-off (1d) |
| **PR01** | `PR01_pairs_reversion.py` | PAIRS | Spread reversion market-neutral su 5 coppie |
| **TSM01** | `tsmom_research.py` | TSMOM | Time-series momentum multi-orizzonte + risk-off |
| **SH01** | `SH01_shape_ml.py` | SHAPE | LogisticRegression walk-forward su 17 feature di forma, orizzonte 12 barre (diversificatore) |
Le fade applicano tre protezioni live: un **filtro trend** (`trend_max`/`ema_long`,
salta i segnali col prezzo troppo esteso rispetto alla EMA200), un **loss-guard Hurst**
(`hurst_max=0.55`, salta i segnali in regime persistente/trending dove si concentrano gli stop-loss
— dimezza il drawdown del portafoglio, calcolato dalle sole close) e l'**EXIT-16 close-confirm SL**
(`sl_confirm_atr=0.5`, 2026-06-04: lo stop scatta solo se la barra *chiude* oltre `sl ∓ 0.5·ATR14`
gli stop intrabar da wick erano falsi negativi, l'overshoot che buca lo stop è proprio il movimento
che la fade fada; a livello PORT06 porta l'OOS Sharpe da 8.82 a 10.06). Più un filtro `min_tp_frac`
che scarta i micro-scalp col take-profit entro il costo delle fee. Le tre protezioni sono
complementari: Hurst toglie il regime tossico, il trend-filter gli ingressi sovra-estesi, il
close-confirm i falsi stop. Portafogli pronti: `PORT01`
(honest), `PORT02` (fade), `PORT03` (master fade+honest), **`PORT06`** (master esteso, default live).
**Scartate** (in `scripts/waste/`): la famiglia squeeze (SQ01-04, ML01, MT01, PD01,
CM01, AD01 — artefatto di look-ahead), MR03 Keltner (debole/ridondante con MR01) e
ROT01 (dominata da ROT02).
### Comandi utili
## Comandi
```bash
# Backtest di una strategia
uv run python scripts/strategies/MR01_bollinger_fade.py
uv run python scripts/strategies/PR01_pairs_reversion.py
# Ricerca e validazione fee-aware out-of-sample
uv run python scripts/analysis/strategy_research.py # screening famiglie + deep-dive fade
uv run python scripts/analysis/strategy_research_v2.py # MR02 / MR03 / MR07
uv run python scripts/analysis/oos_validation.py # perche' la famiglia squeeze e' scartata
uv run python scripts/analysis/pairs_research.py # ricerca + verifica no-look-ahead dei pairs
# Gestione rischio, combinazione, report
uv run python scripts/analysis/risk_management.py # filtro trend + portafoglio fade
uv run python scripts/analysis/combine_portfolio.py # combinare fade + honest
uv run python scripts/analysis/combine_v2.py # master esteso con pairs + TSM01
uv run python scripts/analysis/report_families.py # report per anno di tutte le famiglie
# Validazione dei worker live (replay == backtest)
uv run python scripts/analysis/validate_worker_mr01.py # worker single-leg su MR01
uv run python scripts/analysis/validate_worker_pairs.py # worker a 2 gambe sui pairs
uv run python scripts/analysis/live_smoke_pairs.py # smoke test feed live reale dei pairs
uv sync # dipendenze
uv run python scripts/analysis/rebuild_history.py --asset BTC ETH # storico da Deribit mainnet
uv run python scripts/analysis/certify_feed.py # certifica i feed
uv run python scripts/live/trades_db.py --report # stato del libro + TWR
uv run python scripts/live/journal.py # voce del giorno
uv run python scripts/live/analista.py --secco # analisi del giorno, senza salvare
uv run python scripts/portfolio/run_portfolio.py # portafoglio di ricerca
uv run pytest # 1008 test
```
## Paper Trading Live
Ogni script di `scripts/live/` accetta `--help` e **rifiuta un flag che non conosce** (esce 2): fino
al 2026-09-02 un flag sbagliato eseguiva l'azione di default, e su tre script quell'azione scriveva.
Il multi-strategy runner esegue N strategie in parallelo su dati live da Cerbero MCP,
ognuna con €1000 USDC virtuali indipendenti. Gestisce due tipi di worker:
## Gate aperti
- **Single-leg** (`strategy_worker.py`): per le strategie direzionali. Se un `Signal`
porta `tp`/`sl`/`max_bars` in `metadata` (come le fade), chiude su take-profit /
stop-loss / time-limit; altrimenti usa il fallback `hold_bars`/stop -2%.
- **Due gambe** (`pairs_worker.py`): per i pairs market-neutral. Apre long su una gamba
e short sull'altra, esce sul rientro dello z-score o per time-limit, conta le fee su
entrambe le gambe. Validato: il replay storico coincide *esattamente* col backtest.
Un gate si decide **alla data**, coi criteri scritti **prima**. Elenco completo in `CLAUDE.md` §4.
### Avvio
| gate | data | dove punta oggi |
|---|---|---|
| STATARB | 2026-09-27 | ritiro (Sharpe 1,61 sulla serie vera) |
| SCALA-01 — leva 1,00 → 1,25 | non prima del 2026-10-01 | chiave costruita e **inerte**; 9 condizioni, 2 fatte |
| XSR01 | 2026-10-23 | sotto la lente rendita il meccanismo vale ~0 |
| DVOLSPREAD | kill 2026-10-24 | serie 4,41 |
| GTAA01 tenere/bloccare | al book a $15k | contributo reale +0,10/+0,12 di Sharpe, non incassabile sotto la soglia |
```bash
# Locale
uv run python -m src.live.multi_runner
## Obiettivo, e cosa lo rende difficile
# Docker
docker compose up -d
```
Il target dichiarato è **€50/giorno partendo da €1.000**. Non è raggiungibile a questo capitale:
servono ~$313k (banda [$187k $1,14M]) o **€1.733/mese per dieci anni** per averlo al 90% di
probabilità. La leva non è la scorciatoia — quella difendibile vale quanto €100-150/mese di
versamento. La via è target-vol, capitale e tempo.
### Configurazione
Le strategie attive sono definite in `strategies.yml`:
```yaml
defaults:
capital: 1000
position_size: 0.15
leverage: 3
strategies: # strategie single-leg
- name: MR01_bollinger_fade
asset: BTC
tf: 1h
enabled: true
params: { bb_window: 50, k: 2.5, sl_atr: 2.0, max_bars: 24, trend_max: 3.0, ema_long: 200 }
pairs: # strategie a 2 gambe (market-neutral)
- name: PR01_pairs_reversion
a: ETH
b: BTC
tf: 1h
enabled: true
params: { n: 50, z_in: 2.0, z_exit: 0.75, max_bars: 72, jump_max: 0.08 }
```
Per aggiungere una strategia: nuova riga in `strategies.yml` (sezione `strategies` o
`pairs`), poi `docker compose restart`. Lo storico delle strategie esistenti rimane intatto.
### Persistenza
Ogni strategia ha la sua directory in `data/paper_trades/`:
```
data/paper_trades/
MR01_bollinger_fade__BTC__1h/
trades.jsonl # Storico trade append-only
status.json # Stato corrente (resume al restart, include tp/sl/max_bars)
```
Notifiche Telegram per ogni trade (richiede `TELEGRAM_BOT_TOKEN` e `TELEGRAM_CHAT_ID` in `.env`).
## Paper Trading a Portafoglio
Accanto al multi-strategy runner originale — in cui ogni strategia gestisce autonomamente il proprio conto virtuale da €1.000 — il progetto dispone ora di un **paper trader a portafoglio** (`src/portfolio/`) che tratta l'insieme delle strategie come un unico organismo con un capitale condiviso.
### Come funziona
La definizione di un portafoglio (`SleeveSpec` + schema di peso) ha due facce sulla stessa sorgente dati:
- **Backtest** (`.backtest()`): ricostruisce le equity-curve di ogni sleeve tramite il builder unificato in `sleeves.py`, le pondera secondo lo schema scelto e calcola le metriche aggregate (CAGR, Sharpe, max DD). La parità con i report prodotti da `report_families.py` è garantita dalla fonte unica.
- **Live** (`PortfolioRunner`): ogni ora il runner scarica le candele aggiornate via Cerbero v2, calcola i pesi correnti, avvia i worker appropriati per ogni sleeve attiva e registra il PnL aggregato nel ledger (`data/portfolios/{code}/`). Il ledger persiste tra i riavvii.
### Schemi di ponderazione
Il modulo `weighting.py` mette a disposizione cinque schemi: `equal` (default), `cap` (tetto per famiglia — p.es. `pairs: 0.33` per limitare la concentrazione), `inverse_vol` (pesi inversamente proporzionali alla volatilità storica), `cluster_rp` (equal tra cluster naturali poi inverse-vol all'interno del cluster) e `manual` (pesi liberi). Lo schema si specifica in `portfolios.yml` insieme al codice portafoglio e alla leva.
### Portafoglio di default: PORT06
La configurazione raccomandata è **PORT06** (`scripts/portfolios/PORT06_master_shape.py`): portafoglio master esteso che include tutte e sei le famiglie (FADE, HONEST, PAIRS, TSMOM, SHAPE), con schema `cap` che limita i pairs al 33% del capitale per moderare la loro concentrazione di rischio. Backtest canonico (dati al 2026-05-28): Sharpe 6.47 (FULL) / 8.82 (OOS), drawdown massimo 4.10% (FULL) / 1.30% (OOS), leva 2×; **con la config live attuale (EXIT-16 close-confirm): Sharpe 7.84 / 10.06, DD 2.60% / 1.15%**.
### Scope live
Il runner esegue **tutti e 17 gli sleeve** di PORT06: **fade** (MR01, MR02, MR07 × BTC/ETH),
**honest** (DIP01, TR01-basket 4h, ROT02-rotation 1d), **pairs** (PR01, cinque coppie),
**TSMOM** (TSM01 1d) e **shape** (SH01 × BTC/ETH). Worker dedicati: `StrategyWorker` (single-leg, fade/
dip/**shape**), `PairsWorker` (2 gambe), `BasketTrendWorker`, `RotationWorker`, `TsmomWorker`. Il runner
fetcha 1h da Cerbero v2 e resampla a 4h/1d; il pool di capitale, il ribilancio giornaliero e il ledger
sono validati == backtest.
> **SH01 (2026-06-01):** gira come `StrategyWorker` normale (il walk-forward è interno a
> `generate_signals`). Il vecchio `MLWorkerWrapper` usava il `SignalEngine` **squeeze scartato** —
> rimosso. **Loss-guard Hurst (2026-06-02):** le fade saltano i segnali in regime persistente
> (rolling-Hurst ≥ 0.55), dove si concentrano gli stop-loss — dimezza il drawdown del portafoglio
> (FULL 4.1%→2.4%; stop-loss fade 67% in numero, perdite totali 68%). Calcolato dalle sole close,
> attivo live (`hurst_max` nei params). Il report orario su Telegram **monitora lo stop-rate fade
> prima/dopo l'attivazione** e dà il verdetto automatico quando il campione è sufficiente.
### Esecuzione reale (shadow, Deribit testnet)
Sette sleeve single-leg — le **6 fade** (MR01/MR02/MR07 × BTC/ETH) e **DIP01** (dal 2026-06-04) —
eseguono ordini **reali su Deribit testnet** accanto al fill simulato (*shadow*: il sim resta la
verità che guida le decisioni; il reale misura la fattibilità). Punti chiave:
- **Strumenti lineari USDC** (`BTC_USDC`/`ETH_USDC-PERPETUAL`): payoff lineare = matematica del
backtest; fee e PnL in USDC. Quantizzazione `Decimal` di amount (step) e prezzi (tick).
- **Take-profit reale = limit reduce-only AL livello** (v1.0.7): piazzato all'apertura, copre la
sola quota del worker (gli strumenti sono condivisi fra worker e nettati per conto); alla
chiusura il worker cancella il resting, riconcilia i fill dal trade history per `order_id` e
chiude a market solo il residuo. Fix della divergenza misurata: il market-on-poll usciva
+235 bps oltre il livello TP. Fill da resting = fee maker (~0%).
- **Stop-loss close-confirm** (v1.1.0): uscita al close che sfonda il livello → market
reduce-only al poll (nessun ordine stop sul book, per scelta: i trigger Deribit generano un
nuovo order_id allo scatto, non verificabile, e i wick non devono stoppare).
- **Verifica sul trade** (order_id in `get_trade_history`), fee reali dai `trades[]`, ledger
reale parallelo persistito (`real_capital`), eventi `REAL_OPEN`/`REAL_TP_RESTING`/`REAL_CLOSE`
nel log + alert Telegram (`REAL_EXEC_LIVE`, `REAL_OPEN_FAIL`).
- Config in `portfolios.yml``overrides.execution {enabled, sleeves, instruments}`.
**Pairs/rotation/TSMOM/shape restano simulati**: i pairs richiedono un executor a 2 gambe
(leg-risk), i multi-asset un rebalance-to-target; roadmap nel diario.
### Versione & deploy
Ogni deploy ha una **versione** (file `VERSION`, semver) che compare nei messaggi Telegram (notifiche
trade + report orario), così correli ogni messaggio al codice che l'ha generato. Il sorgente è **cotto
nell'immagine** → per aggiornare il live serve un **rebuild**, non un semplice restart:
```bash
./scripts/deploy.sh # bump patch (1.0.0 → 1.0.1) + commit + rebuild + ricrea container
./scripts/deploy.sh minor # 1.0.x → 1.1.0
```
Il volume `data/` persiste tra i deploy → i worker fanno RESUME dello stato (capitale, posizioni aperte).
### Avvio del paper trader a portafoglio
```bash
# Backtest del portafoglio di default (PORT06)
uv run python scripts/portfolios/PORT06_master_shape.py
# Paper trading live a portafoglio
uv run python -m src.portfolio.runner
# Report orario su Telegram (stato + stop-rate fade prima/dopo loss-guard) — via cron
uv run python scripts/portfolios/hourly_report.py
# Smoke test del data layer Cerbero v2
uv run python scripts/analysis/smoke_portfolio.py
```
## Setup
```bash
# Clona e installa
git clone <repo-url> && cd PythagorasGoal
uv sync
# Scarica dati storici (~70 MB)
uv run python -m src.data.downloader
# Backtest strategia attiva
uv run python scripts/strategies/MR01_bollinger_fade.py
# Paper trading live
uv run python -m src.live.multi_runner
```
### Requisiti
- Python ≥ 3.11
- [uv](https://docs.astral.sh/uv/) come package manager
- Accesso a Cerbero MCP (`cerbero-mcp.tielogic.xyz`) per dati Deribit live
- Docker (opzionale, per deploy su VPS)
## Dati
| Asset | Timeframe | Copertura |
|-------|-----------|-----------|
| BTC, ETH | 5m / 15m / 1h | 2018-01 → oggi |
| SOL, LTC, ADA, XRP, BNB, DOGE | 15m / 1h | 2019-2022 → oggi (variabile per asset) |
Fonte primaria: perpetual Deribit via Cerbero MCP. Fallback: Binance spot via ccxt.
Formato: Apache Parquet (in `data/raw/`, gitignored).
> **Nota sul naming Deribit (per il feed live).** I major sono perpetui *inverse*
> (`BTC-PERPETUAL`, `ETH-PERPETUAL`); gli altcoin sono perpetui *lineari USDC*
> (`SOL_USDC-PERPETUAL`, `LTC_USDC-PERPETUAL`, …) con storia dal 2022. Attenzione:
> `LTC-PERPETUAL`/`ADA-PERPETUAL` non esistono e `SOL-PERPETUAL` restituisce dati
> errati — per gli altcoin usare sempre la forma `_USDC-PERPETUAL`.
### Discovery & validazione strumenti
`src/data/instruments.py` scopre e **valida** gli strumenti disponibili sugli
exchange implementati — **Deribit** e **Hyperliquid** (esclusi Alpaca/stocks e
**Bybit**, feed testnet inaffidabile). Ogni perpetuo viene testato sui dati
storici realmente raccoglibili: esistenza, congruenza OHLC, contratto non-morto,
liquidità e **congruenza prezzo cross-exchange** (mediana per base-coin, tolleranza
5%) — così feed farlocchi e contratti sbagliati (es. `SOL-PERPETUAL`=9.6) vengono
scartati. Il risultato è `data/instruments_registry.json` (strumenti validi +
timeframe + data d'inizio).
**Solo gli strumenti validati possono essere scaricati**: il downloader ha un gate
(`_download_cerbero_range`) che rifiuta quelli non nel registry. Rigenera con:
```bash
uv run python -m src.data.instruments
```
Simboli Deribit: BTC/ETH = `<COIN>-PERPETUAL` (inverse); altcoin =
`<COIN>_USDC-PERPETUAL` (lineari USDC). Registry attuale (testnet): Deribit 18/106
validi (major liquidi, BTC dal 2018), Hyperliquid 66/74.
## Riferimenti
- Serleto, L. & Malanga, C. — *Pythagoras Trading Prediction* (2024)
- Serleto, L. & Malanga, C. — *Libro dei Frattali* (2024)
## Licenza
Uso privato. Non destinato alla distribuzione.
**Onestà prima di tutto**: nessun numero va creduto finché non è netto fee, out-of-sample, robusto
su griglia, e su dati certificati, liquidi ed eseguibili. Il resto di questo repo esiste per rendere
quella frase verificabile invece che dichiarata.
+5 -5
View File
@@ -10,14 +10,14 @@
"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.",
"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.312 = TETTO DEL VENUE sull'USDE, misurato e NON documentato da Deribit (l'articolo ufficiale 'Yield/reward bearing coins' descrive un Cap ETHENA che diluisce il TASSO a livello di exchange, e nessun limite sulle quantita' detenibili da un conto). Ogni acquisto oltre il tetto e' rifiutato con `not_enough_funds_in_currency` pur avendo $1.400 disponibili: messaggio FUORVIANTE, non c'entrano fondi/taglia/prezzo/liquidita'/cadenza. E' una FRAZIONE DELL'EQUITY TOTALE, non un livello fisso — provato il 31/08 facendo scendere l'equity di $8: il tetto e' sceso con lei (30/08 in [644,18·645,18) con equity $2.063,79 = 31,21-31,26%; 31/08 in [643,18·644,18) con equity $2.055,56 = 31,29-31,34%; i due bracket distano 0,05pp, dentro il rumore dell'equity che oscilla +-$2-8/ora). ⇒ **il tetto SCALA col conto**: la quota resta ~31% e il valore in dollari cresce col capitale, e il 70% di quota_target NON e' raggiungibile ne' ora ne' mai. Il valore 0.312 e' il bordo BASSO dei bracket, scelto per far fallire il piano PRIMA dell'ordine invece che dopo il rifiuto; va ri-sondato se cambia qualcosa sul venue. Con la quota reale al 31% il tetto d'allerta 0.50 e' di nuovo quello giusto e MORDE: allargarlo a 0.85 avrebbe disarmato la guardia per uno scenario che non si e' verificato. Se il tetto del venue si alzera', il criterio per 0.85 e' 'meta' del cuscino di regolamento consumata' (USDC sotto il 15% dell'equity). haircut 0.10 verificato sul venue il 26/08 (USDC 0%): il 90% dell'USDE fa margine.",
"_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 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": {
"index_name": "usde_usdc",
"haircut": 0.1,
"haircut": 0.05,
"quota_target": 0.7,
"venue_cap_frac": 0.312,
"venue_cap_misurato": "2026-08-31",
"quota_max_frac": 0.5,
"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_crit": 0.95
}
@@ -28,6 +28,13 @@ A quota ~99% resterebbero **$41 di USDC** contro un libro che, col disaster-SL a
esposizione, può perderne **~$619**: il saldo va negativo e Deribit lo finanzia a interesse, che si
mangia la resa che si stava comprando. **Il vincolo non è il margine, ma non è nemmeno assente.**
> 📌 **Corretto e QUANTIFICATO il 31/08** (KB *Cross collateral specifications*): «*a collateral fee
> will be charged […] **default = 0.05% per day***», al secondo, sulla valuta negativa — cioè
> **18,25%/anno, 4,3× la resa USDE** che si starebbe comprando. Il ribilanciamento automatico non
> salva: scatta a **$1M** assoluti o al **100% della cross equity**, soglie irraggiungibili a $2k.
> Qui il 30/08 avevo scritto «lo finanzia a interesse» **senza il numero**: il numero rende il
> criterio del cuscino più giustificato, non meno.
Criterio dichiarato: cuscino USDC ≥ disaster-SL sulla massima esposizione lorda del libro =
`n_asset × frac × disaster_sl_pct` = 2 × 0,5 × 0,30 = **30% dell'equity** — tutto derivato da
`config/live.json` e `src/live/book.py`, mai ridichiarato (P1). Che lascia esattamente **70%**.
@@ -129,10 +129,26 @@ per uno tutti i sospetti che sull'USDE avevano portato fuori strada:
> «**All Deribit users are permitted to buy and sell BUIDL tokens in the spot markets on Deribit,
> with no extra requirements.**» — `insights.deribit.com/education/buidl-launches-on-deribit/`
Non per noi. Motivo plausibile e **non verificato**: BUIDL è un **titolo** (fondo BlackRock emesso
via Securitize), e la sua distribuzione è ristretta per giurisdizione a monte dell'exchange —
l'articolo di marketing non lo dice. *Il fatto misurato è il rifiuto, non il suo motivo* (stessa
disciplina dello zero USDC).
Non per noi. **Il fatto misurato è il rifiuto, non il suo motivo** (stessa disciplina dello zero
USDC). Le spiegazioni candidate sono due, e da fuori **non sono distinguibili**:
- **(a) giurisdizione** — BUIDL è un **titolo** (fondo BlackRock emesso via Securitize), la cui
distribuzione è ristretta a monte dell'exchange. L'articolo di marketing non lo dice.
- **(b) limiti di spot per TIER DI CONTO** — la documentazione BUIDL, sezione *Limits and
Requirements*: «*Default non-margin spot order limits apply across assets. Check your account
tiers […] for specific maximum open order size configurations.*» Un tier che per questo asset
vale **zero** produrrebbe esattamente ciò che vediamo.
⚠️ Ma i nostri dati **restringono**: lo **spot USDE funziona** — ne abbiamo comprati 644 lo stesso
giorno, sullo stesso conto, con lo stesso codice. Quindi **non è un blocco generale sullo spot né
un limite di tier trasversale: è SPECIFICO DELL'ASSET.** Il che è compatibile con entrambe le
ipotesi (una giurisdizione per-asset, o un tier per-asset a zero) e non ne elegge nessuna.
*Restringere le ipotesi con l'evidenza che si ha vale più che sceglierne una con l'evidenza che
non si ha.*
Irrilevante per noi ma registrato: «*Deribit only supports the ERC-20 version of BUIDL on the
Ethereum blockchain*» — riguarda i trasferimenti on-chain, e la nostra uscita sarebbe comunque
una vendita sullo spot, non un prelievo.
📌 **Due affermazioni pubblicate di Deribit smentite dal conto in due giorni**: la APR USDC al
3,40% (30/08) e ora «all users can buy BUIDL». Non è sfortuna, è una **regola**:
@@ -0,0 +1,426 @@
# 2026-08-31 — Fine della Proof of Reserves, e il guardiano che mancava
L'operatore ha passato l'annuncio *Deribit to Discontinue Daily Proof of Reserves Publication*.
La notizia in sé vale poco per noi. Quello che è emerso cercandola vale molto di più.
## 1. La notizia: frequenza giù, sostanza su, per noi nulla cambia
Dal **1 settembre 2026** la pagina Proof of Reserves sparisce e gli aggiornamenti quotidiani
cessano. Al loro posto, sotto il regime **VARA** di Dubai: audit **annuale** indipendente delle
riserve, audit annuale del bilancio, obbligo di **copertura 100%** e **segregazione** degli attivi
dei clienti. E: «*approximately 90% of client assets have been migrated to Coinbase, which acts as
a custodian for Deribit*».
**Non è un peggioramento netto, ed è importante non raccontarlo come tale.** Si perde un segnale
**ad alta frequenza e debole** (una PoR auto-pubblicata prova che gli attivi esistono in un istante,
non che coprano le passività, ed è aggirabile attorno allo snapshot) e si guadagna un segnale **a
bassa frequenza e più forte** (audit indipendente obbligatorio invece di pubblicazione volontaria),
più un miglioramento **sostanziale** di custodia: 90% degli attivi presso un custode terzo regolato
invece che nei wallet dell'exchange.
**Per noi non cambia nulla di operativo**, e per una ragione che va detta: *la PoR non l'abbiamo
mai sorvegliata*. Zero occorrenze in tutto il codice. `venue_probe` legge se il venue **risponde**,
non cosa **dichiara**. Si perde un segnale che non stavamo usando.
Ho provato a catturare l'ultimo dato prima che la pagina sparisse — è la mossa giusta per un dato
non ricostruibile con una scadenza. **Rinunciato di proposito**: la pagina è una SPA e
`get_proof_of_reserves` non esiste come metodo API («Method not found»), ma soprattutto uno
snapshot singolo e auto-riportato non regge lo standard di prova di questo progetto e **non
permetterebbe comunque di stimare `p`**. Un dato che non cambierebbe una decisione non vale il
lavoro per prenderlo.
## 2. 📌 La conseguenza vera: il riapritore della decisione più grande si è ristretto
La decisione vincolante **«100% Deribit fino a $20k»** (26/07) ha come riapritori dichiarati:
*«**$20k**, o un cambio di piano, o `p` che diventa **stimabile** invece che assunto»*.
Dal 1 settembre il segnale pubblico di solvibilità passa da **quotidiano ad annuale**. Il terzo
riapritore non è formalmente chiuso — un audit indipendente è semmai evidenza più forte — ma
diventa **praticamente irraggiungibile**: da una serie giornaliera si può costruire una stima, da
un punto all'anno no. ⇒ **In pratica quella decisione è ora gated SOLO dal capitale**: si riapre a
$20k, o non si riapre. Non cambia la decisione di oggi (fu presa con `p` esplicitamente **assunto**,
non osservato), ma chiude l'unica uscita non-capitale che aveva.
## 3. 🚨 Quello che ho trovato cercando: quattro annunci materiali che nessuno leggeva
Il debito #8 dice, testuale: *«Resta scoperto il Rulebook vero e proprio: la sonda legge se il
venue RISPONDE, non cosa il venue ANNUNCIA»*. Esiste un feed RSS pubblico
(`insights.deribit.com/exchange-updates/feed/`) con 10 voci. Alla prima lettura:
| data | annuncio | perché ci tocca |
|---|---|---|
| 27/08 | Discontinue Daily Proof of Reserves | §1 e §2 di questo diario |
| **14/08** | **Contract Specifications change for Linear USDC Perpetuals** | **i nostri due strumenti** |
| 05/08 | New SM Margin Model On Deribit | leva per taglia, tiered |
| **31/07** | **USDC Rewards Available In More Countries** | tocca una conclusione viva |
| 29/06 | New Fee Schedule On Deribit | `fee_watch` esiste per questo |
**Il caso che decide la questione è il 14/08.** Le specifiche dei perpetual USDC sono cambiate il
**18/08**; l'annuncio è del **14/08**. Il codice lo racconta già con onestà: la tabella «*non se
n'era accorta […] è andata bene per la direzione del cambiamento, non perché ce ne fossimo
accorti*» — i cambi erano **riduzioni** (tick BTC 0,5→0,1, ETH 0,05→0,01, min/step ETH
0,001→0,0001) e un valore più grosso resta conforme. `check_specs()` è **nato da quella svista** e
oggi gira ogni ora. Ma rileva la deriva **dopo**; il feed l'avrebbe detta **quattro giorni prima**.
*Il giorno che Deribit ALZA un minimo, «dopo» significa ordini rifiutati.*
Il 31/07 chiude un cerchio aperto ieri: l'espansione delle giurisdizioni idonee ai reward USDC
**non contiene l'Italia**, né alcun paese UE — mentre **San Marino e Città del Vaticano** (i due
microstati europei fuori dall'UE) **sono idonei**. La lista che avevo letto (aggiornata 20/08) è
posteriore all'espansione: **la conclusione dello zero USDC è confermata due volte**. Il pattern
UE-fuori/microstati-dentro suggerisce un driver regolatorio, ma la lista contiene anche Canada e
Giappone: **non asserisco una causa**, il fatto è l'esclusione.
## 4. Costruito: `venue_news.py`
Sorveglianza giornaliera del feed, dentro `cron_daily.sh` accanto a `fee_watch` (suo parente
stretto). Scelte che contano:
- **NON interpreta.** Dice *«è uscito questo, guardalo»*. Nessun automatismo su un testo di
marketing: P13 — una guardia sui NUMERI non copre il RAGIONAMENTO, e la prosa si legge come
opinione di un lettore fallibile. L'unica cosa che classifica è l'**urgenza**.
- **Le parole-chiave sono DERIVATE, non ridichiarate** (P1, il difetto più ricorrente del
progetto): gli strumenti vengono da `deribit._CONTRACT`, la valuta di collaterale da
`config/live.json`. *Chi aggiunge un asset al book allarga la sorveglianza senza toccare questo
file* — ed è esattamente il test che lo blinda.
- **Primo giro semina senza allertare** (P9: l'allarme massimo non si spende per un arretrato di
10 voci, o non verrà letto il giorno che è vero).
- **Feed illeggibile → codice 2 e nessun silenzio implicito** (P5: «non vedo» non è «niente di
nuovo»).
5 test sulle funzioni pure. La rete no: `scarica()` ritorna `None` su qualunque errore, ed è quello
il contratto.
**Cosa NON copre**, e va detto: il **Rulebook** vero e proprio (ADL, perdita socializzata,
*emergency powers*, conti dormienti) non ha un feed. Il debito #8 si restringe, non si chiude.
---
## 5. La Knowledge Base risponde a due domande aperte — e corregge due cose mie
L'operatore ha passato anche *yield-generating-collateral-usde-buidl-and-more*, che **non** documenta
tetti né haircut ma rimanda alla Knowledge Base. Interrogata via API Zendesk
(`support.deribit.com/api/v2/help_center/articles/search.json`), l'articolo giusto è
**«Cross collateral specifications»** (aggiornato 2026-02-20).
### (a) 🚨 Il costo del saldo negativo: **0,05% al GIORNO**
> «*While the equity of a currency in an account remains negative, a **collateral fee** will be
> charged to that account. This fee is charged daily in the same currency as the negative balance
> (**default = 0.05% per day**). The fee is charged based on the amount of time the negative equity
> is held, down to a granularity of seconds.*»
**18,25% annuo**, cioè **4,3× la resa USDE** che si starebbe comprando tenendo meno USDC. Il 30/08
avevo scritto «Deribit lo finanzia a interesse» **senza il numero** — l'affermazione era giusta e
vuota. Con il numero il criterio del **cuscino di regolamento** smette di essere prudenza e diventa
aritmetica: scendere sotto il cuscino per tenere più USDE è **14 punti**.
E il ribilanciamento automatico **non salva**: scatta solo oltre **$1.000.000** assoluti o il
**100% della cross equity** (default tabulati), soglie che a $2k non si toccano mai. Non veniamo
ribilanciati: **sanguiniamo la fee**. *La cosa che sembrava un paracadute è, alla nostra taglia,
esattamente l'assenza di un paracadute.*
### (b) 🚨 Haircut USDe: la fonte dice **5%**, noi abbiamo registrato **10%**
| valuta | haircut X:PM | X:SM |
|---|---|---|
| BTC · ETH · **USDC** | — | — |
| USDT · USYC · **BUIDL** | 2% | 2% |
| PAXG | 2,5% | 5% |
| **USDe** | **5%** | **5%** |
| stETH | 7,5% | 7,5% |
| SOL | — | 15% |
`config/live.json` dice `haircut: 0.10`, e il diario del 26/08 lo dà per «verificato sul venue»
senza lasciare traccia di **come**. L'articolo è **anteriore** a quella data, quindi non si sa
quale delle due sia stale.
**Non l'ho riparato** (P12: fra due fonti che non concordano, una riparazione silenziosa è
un'invenzione; M28: la contraddizione è informazione). Si tiene **0,10** per tre ragioni dichiarate:
è il lato **conservativo** (sottostima il margine utilizzabile), **non è sul percorso soldi** (non
entra nel sizing — lo usa solo il report di `usde_watch`), ed è già misurato che **non morde a
nessuna quota fino al 90%**. Si chiude leggendo la pagina margini del conto, che è dove Deribit
stesso dice di guardare.
### (c) Un dato di lato che vale per il futuro
**BUIDL ha haircut 2%** — sarebbe stato il *miglior* collaterale a rendimento del listino (contro
il 5% dell'USDe), se solo lo si potesse comprare. E **stETH 7,5%**, il peggiore: terza ragione
indipendente per lasciarlo stare, dopo il tasso dimezzato e l'esposizione ETH.
📌 **Nessuna delle due fonti documenta un tetto sulle quantità detenibili.** Il ~31,2% sull'USDE
resta **misurato e non spiegato** — e ora si sa che non è una svista di lettura: non è scritto da
nessuna parte.
### (d) Tentata la chiusura della divergenza sull'haircut: **non è raggiungibile da qui**
Deribit dice: «*Haircut rates can be seen on the margin page in your account*». Quella pagina è web
UI, e le credenziali vivono solo dentro il gateway (debito #11). Provato comunque:
- `account_summary` per la valuta **USD** (la "riga USD" che il doc menziona) → `Invalid currency`;
- parametro **`extended`** (che su Deribit apre i dettagli di margine) → **ignorato**;
- il gateway filtra a **8 campi**: niente `initial_margin`, niente `maintenance_margin`.
E non è che si sia guardato nel secchio sbagliato — **la contabilità torna esatta senza haircut**:
```
USDC equity 1412.281074 available 1400.728093 riservato 11.5530
USDE equity 643.175691 available 643.175691 riservato 0.0000
posizioni lorde $577.65
riservato in USDC ........... $11.5530
IM di posizione al 2% ....... $11.5530
RESIDUO ..................... $-0.0000
```
Un haircut al **5%** chiederebbe **$32,16** accantonati, al **10%** ne chiederebbe **$64,32**: non
ci sono, e il secchio USDE riserva `0.000000`. ⇒ **L'haircut non è osservabile in alcun campo
esposto.** O è applicato solo nella vista cross/USD che il gateway filtra via, o non è applicato al
nostro conto: da qui **le due cose non si distinguono**, e non le si sceglie tirando a indovinare.
**La divergenza resta APERTA**, ma ora con la ragione misurata invece che supposta. La chiudono due
cose, entrambe dell'operatore: **30 secondi sulla pagina margini** della web UI, oppure delle
**chiavi API Deribit** — che è la decisione già dichiarata nel debito #11, non un refactor.
Nel frattempo **non morde nulla di osservabile**: l'IM è il 2% del nozionale e il valore pieno
dell'USDE resta disponibile, quindi 5% o 10% non cambia una singola cifra operativa.
📌 **Sottoprodotto non cercato**: $11,5530 / $577,65 = **2,0000% = esattamente 1/50**. È il
`C1 = 50` (start leverage, tier 1) del **nuovo modello di margine SM** annunciato il 05/08 — quello
che `venue_news` ha appena tirato fuori dal feed. **Confermato sul nostro conto senza averlo
cercato**, ed è la prima volta che un annuncio del venue viene verificato contro il conto invece
che creduto.
### (e) L'operatore dichiara: il conto è **Standard Margin** — due conferme incrociate
Lo screenshot della pagina margini non è arrivato fin qui (sta sul desktop dell'operatore, non
sulla VPS: il percorso non esiste su questa macchina). Ma il fatto dichiarato — **«è attivo
Standard margin»** — aggancia due cose che erano sospese:
1. **Quale colonna leggere.** La tabella KB ha `Haircut (X:PM)` e `Haircut (X:SM)`. Il conto è
**X:SM**, quindi vale la seconda — che per USDe dice **5%**, come la PM. La divergenza col
nostro `0.10` resta quindi intatta, ma ora si sa con certezza *quale* numero ufficiale la
contraddice, invece di doverne scegliere uno fra due colonne.
2. **Perché l'IM misurata era esattamente 1/50.** Il nuovo modello di margine del 05/08 si applica,
testuale, ai *«standard margin accounts»*. Il conto è standard margin, e noi abbiamo misurato
IM = **2,0000%** del nozionale = **1/50** = il `C1 = 50` di tier 1. **Le due cose si confermano
a vicenda**: un fatto dichiarato dall'operatore e una misura fatta sul conto senza conoscerlo.
*Vale la pena notarlo perché è raro: in tutta questa settimana ogni affermazione pubblicata dal
venue si è rivelata falsa per il nostro conto (APR USDC, «all users can buy BUIDL»). Questa è la
prima che il conto conferma.*
**Cosa manca ancora**, e resta l'unica cosa che chiude la divergenza: il numero di haircut per USDe
**mostrato sulla pagina Standard Margin del conto**. Se dice 5%, la nostra config è stale e il 26/08
registrò male; se dice 10%, la KB è stale e la config ha ragione. Finché non c'è, si tiene 0.10 —
lato conservativo, fuori dal percorso soldi, e **senza un solo effetto operativo misurabile**.
---
## 6. La pagina margini arriva davvero — e la seconda cosa che dice vale più della prima
Lo screenshot era su **Wasabi** (`rclone` remote `wasabi:`, bucket `adp-work`, cartella `_scambio`),
non sulla VPS: per questo il percorso non esisteva. Scaricato e letto.
Riconciliazione riproducibile in **`scripts/research/r0831_margini_conto.py`** (N11).
### (a) ✅ Haircut = **5%**. La nostra config aveva torto.
La pagina **non espone l'haircut come numero**: si ricava per differenza, perché il modello CROSS
conta l'USDE scontato e il SEGREGATO non lo conta affatto.
```
S:SM (attivo) USDC Available 1,400.86 · USDC equity 1,412.28 IM 11.55 = 1,400.73 (scarto $0.13)
X:SM CROSS Available 2,011.73
contributo USDE al cross = 2,011.73 (1,412.28 + 0.20 11.55) = $610.81 su $643.05 di valore
→ haircut implicito 5,0137%
ipotesi 5% (KB) → atteso $610.89 scarto $ 0.09 ✅
ipotesi 10% (nostra) → atteso $578.74 scarto $32.06 ❌
```
`config/live.json` passa da `0.10` a **`0.05`**. Il 26/08 il 10% fu registrato come «verificato sul
venue» **senza lasciare traccia di come**, e non lo era. *Una nota di provenienza che dice «verificato»
senza dire con quale lettura non è provenienza: è la stessa parola usata come garanzia.*
### (b) 🚨 Il conto **non è cross-collateral**. È `Segregated: Standard Margin`.
Lo dice la schermata in cima — *Current status: **Segregated: Standard Margin*** — e la tabella del
modello attivo elenca **BTC, ETH, USDC. L'USDE non c'è.**
**L'USDE non fa margine per i perp USDC-settled del book**, e l'haircut **oggi non si applica
affatto**. Il che spiega, a posteriori e in modo pulito, perché la misura di stamattina trovava
**$0,0000** accantonati: non stavo guardando nel secchio sbagliato, *stavo cercando un parametro
che sul nostro conto non è in vigore*.
**NON MORDE**, e va detto subito per non allarmare: al massimo lordo del libro (1,0x ≈ $2.056 di
nozionale) l'IM sarebbe ~$41 contro **$1.400 di USDC disponibile** — 34× di copertura. Nessuna
decisione operativa cambia oggi.
**Ma la premessa scritta in CLAUDE.md era falsa**, e il modo in cui era falsa è istruttivo. Diceva:
*«l'equity del book è il TOTALE cross-collateral»*. Sono due cose diverse messe sotto un nome solo:
- come **ricchezza**, sommare USDC+USDE è **giusto** — ed era il punto della riparazione del 26/08,
che evitò il falso «USCITA DI FONDI 24%» e una vendita indesiderata;
- come **capacità di margine**, è **sbagliato**: nel modello attivo l'USDE vale zero.
Finché il margine non morde le due coincidono nell'uso, e infatti non è mai emerso. *Un errore che
non ha conseguenze finché una terza cosa resta vera è un debito, non un'assoluzione.* Corretto in
CLAUDE.md, e corretta anche la riga di `usde_watch` che stampava «margine utilizzabile ~$2.013
(haircut 10%)»: era falsa due volte insieme.
### (c) Come cambia il modo di pensare alla quota USDE
Non è «collaterale diversificato»: è **cassa messa da parte a rendimento, fuori dal sistema di
margine**. Il che rende il tetto del venue al ~31,2% meno una limitazione e più un caso fortunato —
tiene automaticamente dentro al 31% la parte di conto che non lavora come margine.
E apre una **decisione dell'operatore**, non mia: passare a **X:SM** aggiungerebbe **$610,87** di
margine utilizzabile, ma porta con sé la meccanica cross — *collateral fee 0,05%/giorno* sul saldo
negativo e ribilanciamento automatico. Oggi non serve (34× di copertura); servirebbe solo a leve
che non sono autorizzate.
📌 **Ipotesi nuova sul tetto del ~31,2%**, non verificata: potrebbe dipendere proprio dal modello
segregato. Si saprebbe passando a X:SM e ri-sondando — un esperimento che ora ha un senso, dove
prima non si sapeva nemmeno cosa variare.
---
## 7. Passaggio a X:SM e ri-sondaggio del tetto — l'ipotesi regge tecnicamente e cade in pratica
L'operatore è passato a **Cross: Standard Margin** e ha chiesto di ri-sondare, per verificare
l'ipotesi lasciata aperta in §6(c): *il tetto del ~31,2% dipende dal modello segregato?*
### Il passaggio è verificabile dal gateway, senza fidarsi di una dichiarazione
`available_funds` USDC è passato da **~$1.400** a **$2.011,09**, contro **$2.010,90** attesi
sommando l'USDE scontato al 5% — **scarto $0,19**. È anche la **seconda conferma indipendente
dell'haircut al 5%**, da una lettura completamente diversa dallo screenshot: due strade, stesso
numero.
### Il risultato
| modello | tetto misurato | equity | frazione |
|---|---|---|---|
| Segregato **S:SM** (31/08) | [643,18 644,18) | $2.055,56 | 31,29% 31,34% |
| Cross **X:SM** (31/08, dopo) | [654,18 655,18) | $2.054,90 | **31,84% 31,88%** |
**+0,55pp = +11 USDE ≈ $11.** ⇒ **L'ipotesi è vera e inutile**: il modello di margine *entra* nel
tetto, ma non lo spiega. Per arrivare al 70% mancano ancora ~38 punti, un fattore **2,2×**.
*Un'ipotesi confermata al terzo decimale e falsa all'ordine di grandezza va archiviata come falsa:
il tetto resta misurato e non spiegato.*
### 🚨 La lezione di metodo, che vale più del risultato
Il **primo probe fu un BUY 20, rifiutato**. Da solo avrebbe chiuso la questione con *"tetto
invariato, l'ipotesi è refutata"* — e sarebbe stato **falso**. Il tetto si era mosso di 11, cioè
meno della taglia del probe. È stato il test a **saldo neutro con passi piccoli** (BUY 1 → SELL 10
→ BUY 10 → BUY 1, tutti accettati) a rivelarlo.
📌 **Un probe unico di taglia sbagliata produce un falso negativo che si legge come risultato.**
La taglia del sondaggio va scelta **sulla risoluzione dell'effetto che si cerca**, non su ciò che
è comodo — ed è M13 in una veste nuova: *un criterio si misura sulla sua risoluzione prima che sul
suo esito*.
### Cosa ha comprato il passaggio, e cosa è costato
- **Comprato**: **$611** di margine utilizzabile — inutile a 0,28x di leva, serve solo sopra ~1,4x
che non è autorizzata — più **$11** di capienza sul tetto.
- **Costato**: sotto segregato l'USDE era **ring-fenced** dalle perdite del libro (solo il silo
USDC rispondeva delle posizioni); sotto cross **risponde l'intero conto**. Non morde oggi (perdita
massima plausibile ~$617 col disaster-SL, dentro il solo USDC), ma **il rischio strutturale ha
cambiato verso**. Se un giorno il cross non servisse più, tornare a S:SM ri-recinta l'USDE.
`config`: `venue_cap_frac` 0.312 → **0.318** (bordo basso del bracket CROSS, che è il modello
attivo). Se si torna a S:SM va rimesso a 0.312.
## 8. **X:PM valutato e SCARTATO** — la risposta era già nello screenshot
L'operatore ha chiesto di provare **Cross: Portfolio Margin**. Non serve provarlo: il confronto è
nella schermata di §6.
| modello | Available Balance | IM | MM |
|---|---|---|---|
| **X:SM** (attivo) | **$2.011,73** | 2,13% | **0,37%** |
| **X:PM** | **$1.935,14** | 5,85% | **3,43%** |
Sulla colonna il cui significato è inequivocabile — stessa riga, stesse unità — **X:PM dà $76,59
di collaterale utilizzabile in MENO**, e chiede più margine. ⚠️ *La frase «su entrambe le misure»
è stata corretta in §9: l'«IM %» stampata dalla pagina non è il margine delle posizioni.
La conclusione non cambia — migliora.*
**Perché**, ed è strutturale e non contingente: il Portfolio Margin è **basato su scenari di
rischio** e premia i portafogli in cui il rischio si **compensa**, tipicamente i book di opzioni.
Il nostro è **due long direzionali nudi senza nulla che compensi** — il caso peggiore per PM, che
addebita lo scenario di stress invece di un'aliquota piatta.
**La riga che decide è la MM: 3,43% contro 0,37%, ~9×.** Il maintenance margin è ciò che innesca la
liquidazione: non morde a 0,28x, ma sposta il punto di liquidazione molto più vicino su un libro
con soldi veri. E il beneficio atteso sul tetto è noto per analogia: il salto S:SM→X:SM ne ha
comprato **$11**.
**SCARTATO.** ⚠️ *Non "PM è peggio", ma "PM è peggio PER QUESTO portafoglio"*:
**cosa lo riapre** — il giorno che VRP01 o un altro sleeve di opzioni entra in deploy, il rischio
inizia a compensarsi e quel confronto **cambia di segno**. Va rifatto allora, non prima.
---
## 9. X:SM attivo: la pagina si ricostruisce dal gateway — e una mia lettura era sbagliata
L'operatore ha incollato la riga CROSS col modello **attivo** invece che proiettato:
`Available $2.010,81 · IM 2,15% · MM 0,37%`. Il gateway, letto **allo stesso minuto** (08:34Z), dice
`available_funds` = **2010,82299383**. **Scarto $0,01.**
### (a) Lo screenshot non serve più
**La riga CROSS della pagina È `available_funds`**, che leggiamo da soli a ogni giro. Ieri quella
schermata era l'**unica** fonte per l'haircut e per il modello attivo, e per averla è servito un
passaggio dall'operatore e da Wasabi. Oggi non serve a niente: `r0831_margini_conto.live()` la
ricostruisce.
### (b) Terza conferma del 5%, e la prima ESATTA
1.400,71 + 0,95 × 654,176 + 0,20 dust 11,553 IM = $2.010,82 pagina $2.010,81
**Scarto $0,007.** E il 10% della vecchia config non è *improbabile*: invertendo la stessa riga dà
**IM = $21,16**, cioè **aritmeticamente impossibile**. Le tre strade — differenza fra le righe
della pagina (31/08 07:50), salto di `available_funds` al passaggio S:SM→X:SM ($0,19), e ora questa
ricostruzione al centesimo — sono indipendenti e danno lo stesso numero.
### (c) 🚨 L'«IM %» della pagina NON è il margine delle posizioni — mia lettura sbagliata
`(margin_balance available)/margin_balance = 2,1532%`, ed è **esattamente** ciò che la pagina
stampa come «IM 2,15%». Ma si scompone così:
| voce | USD | % di margin_balance |
|---|---|---|
| haircut sull'USDE (5% × 654,18) | $32,71 | **1,592%** |
| IM vera delle due posizioni | $11,55 | 0,562% |
| **totale riservato** | **$44,26** | **2,153%** |
**il 74% di quella percentuale è haircut, non rischio.** È salita da 2,13% a 2,15% **perché
abbiamo comprato collaterale a rendimento** — cioè per un motivo che con l'esposizione del libro
non c'entra nulla. *Letta come misura di rischio direbbe che ieri sera abbiamo alzato la leva
comprando USDE: l'opposto di quello che è successo.*
La **MM invece è pulita**: 0,37% = $7,60, e non può contenere l'haircut ($32,71 da solo la
renderebbe negativa). **Le due colonne hanno basi diverse**, e la pagina non lo dice.
📌 Regola: *una percentuale letta da una schermata va **invertita nella sua definizione** prima di
essere confrontata.* Due colonne affiancate, con la stessa unità e lo stesso aspetto, possono avere
denominatori — e qui numeratori — diversi. È P7 su una superficie nuova: un numero si etichetta
con la sua configurazione, e una pagina del venue non è obbligata a farlo per noi.
### (d) La decisione su X:PM esce RAFFORZATA
La KB dà **USDe al 5% sotto entrambi i modelli** (§6b): nel confronto l'haircut **si cancella**, e
l'inversa restituisce il margine di posizione puro sulle stesse due posizioni, stesso istante:
| modello | available | **IM di posizione** |
|---|---|---|
| X:SM | $2.011,73 | **$11,64** |
| X:PM | $1.935,14 | **$88,23** |
**PM chiede 7,6× il margine iniziale e 9,3× la MM.** Due misure ora pulite, stesso verso: la
bocciatura di §8 **regge, meglio fondata di quando l'ho scritta**.
E c'è una conferma che ieri mancava: la riga X:SM era una **proiezione** di un modello **inattivo**,
e oggi che è attivo la stessa aritmetica la riproduce al centesimo. ⇒ **anche la riga X:PM, che
resta una proiezione, è affidabile**: la decisione presa senza provare il modello era presa su un
numero buono. *Non provare X:PM è stato corretto, e ora si sa perché e non solo che.*
+238
View File
@@ -0,0 +1,238 @@
# 2026-09-01 — COLLAR01: il collar riduce il DD, il tetto lo paga troppo, e ciò che vince è VRP01 travestito
> ⚠️ **Titolo corretto lo stesso giorno.** Diceva *«il pavimento funziona»*: la riduzione di DD è del
> **collar**, non del pavimento — la put da sola lo peggiora in 16/16 celle (§72, sezione A1 sotto).
*Scritto il 2026-09-01. Ogni numero è riprodotto da `scripts/research/r0901_btc_collar.py`.
**Libro, pesi, cron, config INVARIATI. Nessun ordine.***
## 0. La domanda, come è arrivata
> *«crea una strategia di hold BTC in long o short con copertura con options (max 15gg)»*
> *«opzione deve essere al max di 15gg»*
> *«voglio ridurre la vincita, ma bloccare la perdita»*
> *«ovviamente l'entrata deve essere gestita da una strategia confortata da indicatori (es. è in forte bull)»*
> *«dalle opzioni dobbiamo uscire prima del termine (tra 50% e 75% del tempo)»*
Le tre precisazioni cambiano l'oggetto, e vanno lette insieme: **non è una put protettiva, è un
COLLAR** (pavimento comprato, tetto venduto per finanziarlo), su un **hold gated da indicatori**,
con **scadenza ≤15 giorni**.
## 1. Perché questo filone si poteva riaprire
§46 (TAIL-HEDGE, 2026-08-23) è **REFUTATO** e la memoria dice di battere il motivo, non di
ripetere la misura. Il motivo di §46, verbatim:
> *il maxDD **SALE in 162/162 celle**, a ogni lente di f, perché il **beta del libro al sottostante
> è +0,076**: non si assicura un libro che nei crash è già quasi piatto.*
**Quel motivo non si applica qui.** §46 assicurava il *libro* (TP01+SKH01, piatto il 28% dei
giorni); qui il sottostante è un **hold di BTC, beta 1,0 per costruzione**. E §46 dichiara di non
aver provato proprio questo: *«Nessuna copertura dinamica (gated su regime) è stata provata: la
domanda era la statica»*.
Riusati e non rimisurati: metriche e `k_for_same_dd` da `r0823_tail_hedge`, il listino fee opzioni
Deribit, la catena via `cblib.load_chain()`.
## 2. L'entrata, presa dal progetto invece che inventata
`trend_portfolio.tsmom_blend` media tre `np.sign()` sugli orizzonti (30, 90, 180), quindi assume
**solo** i valori {1, 1/3, +1/3, +1} (il bucket 2/3 non esiste — errore già corretto in §57).
Questo dà a **"forte bull" una definizione che non aggiunge nemmeno un parametro nuovo**:
- **gate FORTE** = `|blend| == 1`, tutti e tre gli orizzonti concordi → a mercato **52,5%** dei giorni
- **gate LARGO** = `|blend| ≥ 1/3`, confronto dichiarato → a mercato **97,1%** dei giorni
Il segno dà la direzione, quindi **long e short** sono entrambi coperti come chiesto. Si entra il
giorno **dopo** il segnale (eseguibile, §8.1).
## 3. La calibrazione: quello che il modello non poteva assumere
161.964 quote a due lati su 95 giorni (2026-05-07 → 2026-09-01), scadenze 1-15 giorni.
**Lo skew, ed è il fatto strutturale del filone:**
| \|δ\| | IV put / ATM | IV call / ATM | la put costa |
|---|---|---|---|
| 0,10 | 1,249 | 0,959 | **+30%** della call |
| 0,20 | 1,132 | 0,950 | **+19%** |
| 0,30 | 1,072 | 0,960 | **+12%** |
**Un collar delta-simmetrico è un debito netto**: compro l'ala cara e vendo quella a buon mercato.
Non è un dettaglio di prezzo, è la ragione per cui "gratis" non esiste a delta simmetrici.
Spread (mezza forchetta / mid): put 1,9-8,3%, call 2,1-10,0% secondo il delta. Struttura a termine
IV/DVOL30 ≈ 0,92-0,94 a 2-14 giorni.
📌 **A5 confermata, e corregge un muro di §46:** il tick da **5 USDC** che lì era «il secondo muro,
strutturale» vale per la famiglia **USDC**. La catena che raccogliamo è **100% inverse**
(`BTC-31JUL26-45000-P`), col tick in BTC. Il muro di §46 **non si applica a questo filone**.
## 4. Il risultato, in ordine di come uccide
Lente lunga 2021-03-24 → 2026-09-01 (1.988 giorni, 5,44 anni). BTC buy&hold nudo: Sharpe 0,413 ·
maxDD 76,73% · drift +7,82%/a. Griglia dichiarata prima: 3 δput × 3 δcall × 2 tenor × 2 gate = **48
celle**, più 12 varianti zero-cost dichiarate a parte.
### ✅ A1 CONFERMATA — il COLLAR riduce il maxDD (36/48) — ⚠️ ma NON è il pavimento a farlo
**maxDD scende in 36/48 celle.** Con beta 1,0 la put para davvero: è il risultato che §46 non
poteva ottenere.
🚨 **CORREZIONE (stesso giorno, `r0901c_pavimento_leva.py`, §72).** Questa riga diceva *«il
pavimento funziona davvero»* e attribuiva la riduzione al pavimento. **È il COLLAR a ridurre il DD,
non il pavimento**: la put **da sola**, a premio reale, **peggiora il maxDD in 16/16 celle**
(FORTE 51,83% → 55,4-75,8%, LARGO 71,94% → 74,8-81,7%). Nel collar la riduzione viene dal **tetto**
— il suo premio compensa il bleed della put, e cappare l'upside **abbassa il picco** da cui il DD si
misura. Il pavimento pareggerebbe il DD nudo solo se le put costassero il **36-79% del reale**.
Quindi: *il motivo di §46 (beta) non si applica, ma il suo verdetto — il maxDD SALE — si riproduce
a beta 1,0 per un motivo diverso:* **i drawdown di BTC sono grind di 290-818 giorni, e una put a
≤15 giorni copre una finestra.** Dettaglio in `2026-09-01c-pavimento-leva.md`.
### ❌ A3 CONFERMATA — ma il de-levering lo fa meglio in 45/48 celle
| gate | base gated senza opzioni | |
|---|---|---|
| FORTE | Sharpe 0,442 · maxDD **51,83%** · drift **+10,04%**/a | ← il null da battere |
| LARGO | Sharpe 0,578 · maxDD **71,94%** · drift **+17,37%**/a | |
Il null è **lo stesso hold gated, senza opzioni, scalato a iso-maxDD** — generato dallo stesso
motore, non ridichiarato (P1). Il collar lo batte in **3 celle su 48**.
### ❌ A2 CONFERMATA — ogni punto di DD risparmiato costa 2-8 punti di drift
Δdrift/ΔmaxDD mediano: **7,90** col gate FORTE, **1,98** col LARGO. Il tetto costa più di quanto
il pavimento renda, e non di poco.
### 🚨 C9 — non è protezione, è troncatura
Cella migliore, 196 cicli: **il tetto taglia nel 46,2% dei cicli vincenti, il pavimento para nel
6,7% dei cicli perdenti.** Scatta **7 volte più spesso sui vincenti che sui perdenti** — la
definizione letterale di C9. *La regola dell'operatore («ridurre la vincita, bloccare la perdita»)
è implementata fedelmente: il problema è che su BTC quel baratto è pagato male.*
### ❌ M1 — dentro il libro non aggiunge nulla
Collar Sharpe 0,508 contro TP01 **0,852**, corr +0,486. TP01 + 10% di collar: Sharpe **+0,000** e
maxDD **+2,32pp**. A 25%: **0,065** di Sharpe e **+6,57pp** di maxDD. *Peggiora proprio la cosa
che dovrebbe proteggere.*
## 5. 🚨 Il fatto che vale più del verdetto
**Tutte e 3 le celle vincenti stanno sul BORDO** della griglia (δput al minimo, δcall al massimo).
M8: un argmax sul bordo non è una decisione. Ho esteso la famiglia (M4) con 36 trial dichiarati
verso l'angolo — e lì **vince in 35/36 celle**, con il massimo *nell'angolo*:
> gate FORTE, 7 giorni, **δput 0,02 · δcall 0,50** → Sharpe **1,471** · maxDD **18,12%** · drift **+29,99%**/a
Il limite di quell'angolo è *nessun pavimento, tetto ATM*: **una covered call**. Cioè **la pendenza
porta fuori da ciò che l'operatore ha chiesto e dentro lo short-vol.**
E quel Sharpe 1,471 non è una scoperta — **è il mio prezzatore che si paga da solo**:
| | DVOL / RV-forward | DVOL sta sopra |
|---|---|---|
| a 7 giorni | **1,320** | **76,9%** dei giorni |
| a 14 giorni | **1,253** | **74,8%** dei giorni |
Riprezzando le opzioni alla **volatilità effettivamente realizzata** (diagnostica con look-ahead
dichiarato — è il valore equo ex-post, non una strategia):
| cella | a DVOL | a vol realizzata | il VRP valeva |
|---|---|---|---|
| FORTE 7g δ0,02/0,50 | Sh **1,471** · +29,99% | Sh **0,511** · +8,45% | **+21,54 pp (72%)** |
| LARGO 7g δ0,02/0,50 | Sh **1,141** · +32,17% | Sh **0,114** · **1,10%** | **+33,26 pp (tutto)** |
| FORTE 7g δ0,10/0,30 | VINCE | **perde** | +6,75 pp |
| LARGO 14g δ0,10/0,30 | perde | perde | +8,02 pp |
**Il 72-100% dell'edge dell'angolo è il premio di varianza**, non la struttura. E ciò che
sopravvive alla riprezzatura — **Sharpe 0,511** — è, entro il rumore, **il numero che il progetto
ha già**: VRP01 a f=0,73 vale **Sharpe 0,47**. *Non ho trovato una strategia nuova: ho ri-scoperto
VRP01 per una strada più lunga.* E §3 lo blocca comunque: **«niente short-vol da modello in
deploy»** — questo è esattamente short-vol da modello.
## 5-bis. L'uscita anticipata: chiesta, implementata, e costa
L'operatore ha chiesto di **uscire dalle opzioni fra il 50% e il 75% del tempo**. Implementato come
`exit_frac`, su tenor 14 giorni (⇒ uscita a 7 / 9 / 10 giorni). **La mia ipotesi a priori era che
migliorasse**: uscire presto recupera valore temporale sulla put e rinuncia al theta più veloce
sulla call, cioè proprio alla parte che stampava il premio di varianza. **Metà giusta, metà no.**
| cella `δ0,10/0,30`, gate FORTE | uscita | Sharpe | drift | null | esito | VRP residuo |
|---|---|---|---|---|---|---|
| a scadenza | 1,000 | 0,488 | **+9,48%** | +8,07% | VINCE | +3,38pp |
| 75% | 0,750 | 0,457 | +8,59% | +7,72% | VINCE | +4,93pp |
| 62,5% | 0,625 | 0,366 | +6,11% | +7,98% | **perde** | +2,51pp |
| 50% | 0,500 | 0,278 | **+3,84%** | +8,04% | **perde** | **+1,27pp** |
**Il meccanismo previsto c'è** — il VRP residuo scende da +3,38 a +1,27pp, quindi uscire presto
*davvero* rinuncia allo short-vol — **ma lo spread lo travolge**: 5,6 punti di drift sul gate
FORTE, 10,2 sul LARGO (12,06% → 1,88%), e su `δ0,20/0,20` da 4,37% a 8,58%.
🚨 **E qui §46 va corretto nella sua applicazione.** §46 misurò: *«f si paga solo sulla parte di
valore che converge a intrinseco, quindi un roll anticipato non lo paga»*. **Vero per una copertura
SOLO LONG.** In un collar c'è una **gamba venduta da ricomprare**: uscire prima paga f *esattamente*
sulla parte che a scadenza si sarebbe regolata gratis, e si fa ~2× più spesso per unità di tempo.
**L'asimmetria di §46 si inverte quando la struttura ha una gamba corta.** È il risultato
trasferibile di questa richiesta.
**Nella banda chiesta (50-75%) il verdetto si spacca:** a **0,75** la cella onesta passa ancora il
null (di poco); a **0,625 e sotto** non lo passa più. Nessun `exit_frac` ribalta il verdetto del
filone — lo peggiora.
## 6. I controlli dell'apparato (M15) — 3/3
§46 insegna che *«un controllo positivo rotto dichiara guasto l'apparato»*. Prima di credere a un
verdetto negativo ho verificato che l'apparato sappia riconoscere un successo:
| controllo | atteso | misurato | |
|---|---|---|---|
| pavimento a **premio zero** (pranzo gratis) | deve VINCERE | maxDD 51,83%→**36,76%**, drift +10,04%→**+35,69%** | ✅ |
| premio **×10** | deve PERDERE | drift **33,96%**/a | ✅ |
| zero-cost costruibile e finito | non NaN | drift 3,50%/a | ✅ |
## 7. Tre difetti miei, catturati dai controlli e non a occhio
1. **Bisezione dello zero-cost invertita** (`hi = md` dove serve `lo = md`): il premio cresce col
delta, quindi se incasso troppo poco il tetto va *avvicinato*. Dava Sharpe 3,0/3,9 e drift
53%/a — spazzatura che al primo giro avevo quasi pubblicato.
2. **`dcall=NaN`** nella variante zero-cost propagava NaN nella cassa allo smontaggio anticipato.
3. **C9 non consapevole della direzione:** usavo `S1 > S0` come proxy di "ciclo vincente", ma per
un ciclo **short** un prezzo che sale è una **perdita**. Era la causa del risultato impossibile
*«il pavimento para nello 0,0% dei cicli perdenti»* con una put a 10 delta.
E due difetti di contabilità trovati prima di misurare, che avrebbero **adulato il collar**:
la base senza opzioni rollava lo spot ogni `tenor` giorni pagando ~3,6%/anno di fee inesistenti;
e al roll delle opzioni chiudevo e riaprivo anche lo spot, che non ha motivo di muoversi.
## 8. Cosa NON ho misurato, dichiarato
- **La lente reale (quote vere, 2026-05→09) non è stata girata come backtest.** 123 giorni = ~8
cicli non sovrapposti: sotto-potenziata per costruzione, e su una finestra in cui BTC è salito da
~64,7k a ~77,5k — **avversa a un collar per costruzione**. La catena è servita a **calibrare**
(skew, termine, spread), che è l'uso in cui 161.964 quote hanno potenza.
- **Nessun DSR, nessun `study_family_honest`.** Non servono: il filone cade al **primo** gate (M5),
e M2 si spende su ciò che il primo gate lascia in piedi.
- **A6 e A7 non verificate** (lente reale non girata; il verso short è dentro i gate ma non
separato). Restano previsioni non misurate, non risultati.
- **Il funding dei perp non è nel motore**, come in ogni backtest del progetto: 2,16%/a di drift.
Colpisce base e collar quasi allo stesso modo (stessa esposizione spot), quindi **non cambia il
segno del confronto**, ma abbassa entrambi.
## 9. Verdetto
> **`IL PAVIMENTO FUNZIONA — E' IL TETTO CHE NON SI PUO' PAGARE. E CIO' CHE VINCE SUL BORDO NON E'
> LA PROTEZIONE: E' IL PREMIO DI VARIANZA, CIOE' VRP01`** — **REFUTATO come chiesto.**
Con una postilla che è il vero risultato trasferibile: **la domanda «bloccare la perdita» ha
risposta positiva sul maxDD (36/48) e negativa sul prezzo (45/48).** Su BTC il pavimento si compra
meglio **tenendo meno BTC** che comprando una put e vendendo una call — e il de-levering, a
differenza del collar, non tocca il rendimento nella coda destra dove BTC vive.
## 10. Cosa lo riaprirebbe
- Un **f di stress misurato su un crash catturato** — la stessa condizione che §3 pone allo
short-vol. Con quello, l'angolo covered-call diventa discutibile invece che escluso.
- Un sottostante **senza coda destra grassa**: il tetto costa perché BTC vive lì. Su un asset a
distribuzione più simmetrica il baratto cambia di segno, e il conto va rifatto.
- **Non** lo riapre un tenor diverso, un delta diverso o una griglia più fine: la pendenza è
monotona verso l'angolo short-vol, e l'angolo è già misurato.
+92
View File
@@ -0,0 +1,92 @@
# 2026-09-01 — Stato trades, e la riparazione che non si è propagata
*Scritto il 2026-09-01, misure lette fra le 09:35Z e le 16:47Z. Numeri dalle serie citate.*
## 1. Lo stato, in breve
| | valore | fonte |
|---|---|---|
| fill registrati | **45**, dal 2026-07-14T14:00 al **2026-08-31T15:47** | `trades.db` |
| round-trip chiusi | **29** (21 in utile) — lordo +41,43 · fee allocate 0,66 · **netto +40,77** | `trades.db` |
| fee totali pagate | **$0,8617** | `trades.db` |
| equity (lettura 16:47Z) | **$2.051,84** · picco $2.073,59 | serie `equity`, 1.657 letture |
| posizione BTC | 0,0045 @ medio $79.208,11, dal 25/08 | libro di bordo |
| posizione ETH | 0,0872 @ medio $2.473,04, dal 25/08 | libro di bordo |
| non realizzato (mark 16:38Z) | **$11,72** — BTC 7,91 (2,22%) · ETH 3,80 (1,76%) | gateway |
| gross nozionale | $560,36 → **leva 0,27x** su un tetto di 1,0x | gateway |
**Nessun fill da 25 ore, e non è un blocco.** Il log del cron lo dice a ogni giro fino alle 15:47:
`=> Nessuna azione: conto gia' al target netto del book`. TP01 fermo a +0,461 (BTC) e +0,278 (ETH),
SKH01 flat su entrambi, scarto posizione-target sotto il `min_order_usd` di $5 ($356 contro $351,
$214 contro $213). Feed SKH fresco (0 min), disaster-SL armati a $55.428,0 e $1.708,9.
`monitor_health`: **8/8 OK**, coperture 98-129%. Riconcilio a 3 fonti: **44/45 concordano, 0 prezzi
divergenti**; l'unica coppia scoperta è il difetto già noto — stesso fill (ETH buy 0,04 @ 1869,74)
timbrato `2026-07-14T14:00` nel log del cron e `2026-07-08` nel jsonl, i sei giorni di scarto della
lezione già registrata. Non è un fill perso.
## 2. Il difetto: `--report` stampa un rendimento che non è un rendimento
`scripts/live/trades_db.py --report` stampa questa riga:
```
equity : $598.06 -> $2,051.84 (+1453.78, +243.08%) | picco $2,073.59 | 1657 letture
```
Accanto a una riga di P&L che dice `netto +40,77`. **Il +243% non è rendimento: è quasi tutto un
versamento.** La serie di 1.657 letture orarie contiene **un solo salto**, il 2026-08-25 alle 11:47Z:
$667,49 → $2.066,88, **+$1.399,39**.
Non è una mia classificazione a occhio: è quella del progetto. `journal.movimenti_capitale()`, che
importa la soglia viva da `book.EQUITY_JUMP_ALERT` (0,10) e confronta il salto col massimo che il
**mercato misurato** avrebbe potuto produrre in quell'ora a tetto di leva, restituisce
`certi 1399.39 · ambigui 0.0`, un solo evento, classe `movimento`.
Al netto:
| lettura | valore |
|---|---|
| periodo 1 — 23/06 22:00Z → 25/08 10:47Z ($598,06 → $667,49) | **+11,61%** |
| periodo 2 — 25/08 11:47Z → 01/09 16:47Z ($2.066,88 → $2.051,84) | **0,73%** |
| **TWR spezzato sul versamento** | **+10,80%** |
| crescita di equity al netto del versamento, 69 giorni | **+$54,39** |
**Il numero regge al controllo incrociato.** Il giornale del 31/08, che lo scorporo lo fa già,
scrive: *«cumulato dall'arming: $+1.461,39 di equity — di cui $+1.399,39 versati/prelevati ->
trading $+62,00»*. Stessa base ($598,06), stesso versamento; la differenza fra i suoi +62,00 e i
miei +54,39 è **7,61 di marcatura** fra l'ultima lettura del 31/08 e quella di oggi. Le due misure
concordano.
### Perché è un difetto e non un dettaglio di stampa
La riparazione **esiste già, e in questo stesso repo**. `src/live/journal.py:121` la porta scritta
nel docstring, col caso d'origine:
> *«Il P&L di giornale e' un delta di equity, quindi un versamento ci finisce dentro come se fosse
> profitto: la voce del 2026-08-25 dichiarava «giorno: +$1.414,57» quando $1.399 erano un deposito
> USDC, e il «cumulato dall'arming» avrebbe mentito per sempre.»*
Quel difetto fu trovato e riparato nel giornale. **`trades_db.py:83` non ha mai ricevuto la
riparazione**: calcola `100*(e1/e0-1)` sulla serie grezza e non chiama `movimenti_capitale()`, che
è a un import di distanza e restituisce già `certi` e `ambigui` pronti da scorporare.
È una variante di **P1** — non un sorvegliante che ridichiara il proprio bersaglio, ma **una
riparazione che non ha attraversato il confine fra due lettori della stessa serie**. Il primo
strumento dice la verità; il secondo, interrogato con lo stesso comando pubblicato in CLAUDE.md §13
(`trades_db.py --report`), stampa un numero che si legge come una performance ed è per il **96,3%**
un bonifico.
**Danno oggi: nessuno sui soldi**`--report` è in sola lettura e non decide niente. Il danno è di
citazione: è esattamente la classe di numeri che §2 raccoglie sotto *«non citare»*, ed è l'unico che
il progetto produce **su richiesta esplicita di un comando documentato**.
## 3. Cosa NON ho fatto
**Non ho toccato il codice.** La riparazione è piccola — chiamare `movimenti_capitale()` e stampare
la riga scorporata accanto a quella grezza, come fa il giornale — ma sta su uno script che legge il
libro di bordo vivo, e la richiesta era di aggiornare i documenti. Registrata come debito aperto.
Non ho scomposto la differenza fra il P&L dei round-trip (+$40,77) più il non realizzato ($11,72)
= **+$29,05** e i +$54,39 di equity al netto del versamento. Lo scarto di ~$25 sta in voci che il
conteggio round-trip non copre — funding dei perp (non modellato in nessun backtest, §2), reward
USDE, marcatura dell'USDE all'indice. A questa taglia non vale il costo della misura: si dichiara.
+166
View File
@@ -0,0 +1,166 @@
# 2026-09-01 — La chiave di scala esiste, ed è inerte
*Scritto il 2026-09-01. Implementazione dei punti 1-4 di `docs/research/SPEC-scale-key.md` §8.*
**Nessun ordine. `config/live.json` NON è stato toccato: il libro che gira è bit-exact quello di ieri.**
## 0. Da dove nasce
Il progetto aveva una regola — *«ogni cambio di scala passa dal cap di config, non da `target_vol`»*
e CLAUDE.md la accompagnava da settimane con questa nota:
> ⚠️ **NON È IMPLEMENTABILE COME SCRITTA:** in `config/live.json` non esiste una chiave di scala.
Era un debito preciso: la regola prescriveva un percorso che non esisteva, quindi qualunque cambio
di scala sarebbe finito su `WEIGHT`/`W_TP01`/`W_SKH` in `src/live/book.py` — cioè codice su un
percorso con soldi veri, e per giunta nel posto sbagliato (`W_TP01`/`W_SKH` sono il **rapporto**
75/25, non la taglia: moltiplicarli romperebbe la parità coi pesi del backtest).
La specifica era già scritta, 618 righe, con nove condizioni di gate e il prototipo che ne dimostra
la meccanica. **Non ho progettato niente: ho eseguito.**
## 1. Il rischio che l'implementazione doveva rendere impossibile
La §0 della specifica lo dice meglio di come lo direi io:
> *Una chiave di scala è esattamente il tipo di parametro che si alza «solo un po'» dopo un mese
> buono. È un numero, sta in un file di config, non richiede di capire niente per cambiarlo, e il
> suo effetto è immediato e piacevole.*
E il motivo per cui il progetto non aveva già una guardia non è dimenticanza, è **aritmetica**:
**lo Sharpe è invariante alla scala.** `deflated_sharpe` e `marginal_vs_tp01` leggono un numero che
a k=1,00 e a k=2,00 è identico. Non falliscono: **non vedono**.
## 2. Cosa ho costruito
| pezzo | dove | cosa fa |
|---|---|---|
| `book_scale_k` | `config/live.json`**assente** | la chiave. Assente = 1,00 = libro di sempre |
| `_scala()` | `src/live/book.py` | legge, **valida sempre**, applica solo sul percorso fidato |
| `book_net_target(..., scala=)` | `src/live/book.py` | applica la scala **DOPO il clamp** |
| `LEVA_LORDA_MAX` 1,25 · `SCALA_LADDER` (1,00 · 1,25) · `DISASTER_SL_BUDGET` 0,50 | `src/live/book.py` | **costanti di CODICE** |
| `ScalaNonAutorizzata` | `src/live/book.py` | eccezione, **non** un clamp |
| riga «scala libro» + stop-e-allerta | `scripts/live/book_execute.py` | la scala è visibile a ogni giro |
| `scale_watch` | `src/live/` + `scripts/live/` + `cron_daily.sh` | 3 domande, 3 azioni, 3 stati |
| `scale_history.jsonl` | `data/live/` | giornale append-only (già nel backup) |
| T1-T11 | `tests/test_book_scale.py` | **18 test, tutti verdi** |
### 2.1 Le quattro decisioni che non sono di comodo
**(a) La scala si applica DOPO il clamp.** Applicandola prima, il cap se la mangerebbe *proprio nei
giorni di massima convinzione*: a `tp=1, sg=+1` il grezzo vale esattamente `cap`, quindi `k_eff`
tornerebbe a 1,00 a ogni k. Il risultato non sarebbe «il libro a leva k» ma un libro che alza i
giorni piccoli e lascia fermi i grandi — un cambio di **forma** travestito da cambio di taglia. E
allora la curva `g(k)` con cui il gradino viene autorizzato **non descriverebbe quel libro**.
*Autorizzare con una curva e implementarne un'altra è il modo più silenzioso di sbagliare.*
Il prezzo, dichiarato: il cap smette di essere il tetto assoluto del nozionale e diventa il tetto
del libro **unitario**. La guardia sulla leva lorda è ricostruita esplicitamente altrove.
**(b) Il tetto è sul PRODOTTO, e sta nel CODICE.** Un tetto sulla sola chiave lascia aperta la porta
accanto: `frac` 0,625 × scala 1,25 = **1,562x**, che passerebbe. E un tetto in config sarebbe
modificabile dalla stessa mano, nello stesso file, nello stesso momento — *un lucchetto con la chiave
attaccata*. Con `LEVA_LORDA_MAX` in `src/live/book.py`, il gradino 1,25 → 1,50 richiede una modifica
di codice, quindi una review. **Il tetto è basso apposta: è il meccanismo del cricchetto.**
**(c) Fuori scaletta o fuori tetto = STOP, non clamp.** `book_execute` si ferma, non invia, allerta.
Tagliare silenziosamente farebbe girare una config che *dichiara* un numero e un libro che ne
*esegue* un altro. E `SCALA_LADDER` esiste perché **una scala a gradini rende inesprimibile «solo un
po'»**: 1,05 non è un valore prudente, è un valore fuori scaletta, e viene rifiutato.
**(d) La scala vive solo sul percorso fidato.** Equity reale illeggibile ⇒ scala 1,00. *Una scala è
una decisione di rischio presa conoscendo il conto; quando non si sa quanto vale il conto, non si
prende.* Così «il fallback non è più permissivo» è vero **per costruzione**, non per aritmetica.
⚠️ Ma la **validazione** avviene comunque, anche sul percorso degradato: una config rotta è un
errore di configurazione, non una condizione di mercato, e non deve nascondersi dietro un giro in
cui l'equity non era leggibile.
### 2.2 La guardia che morde per prima, e non è quella che sembra
Il disaster-SL è un movimento di **prezzo** (30% sul mark), ma il suo impatto in equity è quel
movimento **moltiplicato per il lordo** — quindi cresce con k anche se `disaster_sl_pct` non cambia:
- dal peggior giorno possibile: `k ≤ 3,49x`
- **dal costo di un disaster-SL: `k ≤ 1,67x`** ← **2,1× più stringente**
Da qui l'invariante `n_asset · frac · scala · disaster_sl_pct ≤ 0,50`, che scatta anche se qualcuno
allarga lo stop invece di alzare la scala.
## 3. Il test che conta più degli altri
**T1b**, e la ragione per cui la specifica gli dedica un paragrafo: **a k=1 l'implementazione
simmetrica e quella asimmetrica danno lo stesso identico numero.** Un test di simmetria scritto sul
caso di default passa sempre e non controlla niente — potenza **zero**, la stessa firma dei test di
`book_live` scoperti senza potenza a libro flat.
T1b quindi verifica due cose: *(a)* che a k=1 le due implementazioni siano davvero indistinguibili
(cioè che il rischio sia reale), e *(b)* che fuori da k=1 l'asserzione di T1 le **separi**. Nel test
c'è scritta un'implementazione asimmetrica apposta perché fallisca.
Gli altri: T2/T3 i due tetti letti da config coi bersagli **importati** dalla produzione; T4 il
fallback mai più permessivo in ogni stato degradato; T5 il cap che morde ancora; T6 lo stop non
clampato (più T6b sul prodotto e T6c sul disaster-SL); **T7 l'inerzia bit-exact**; T8 il report che
la dichiara; T9 i bersagli derivati e non ridichiarati; T10 il **controllo positivo del
sorvegliante** (con una config non autorizzata deve parlare, con config e giornale concordi deve
tacere) più T10b sulla disciplina degli allarmi e T10c sui tre stati; T11 che il vecchio test sia
stato **sostituito, non rilassato**.
## 4. Il sorvegliante, e cosa non può fare
`scale_watch` gira in `cron_daily` e risponde a tre domande che hanno **tre azioni diverse**:
| domanda | azione se 🚨 |
|---|---|
| la config dichiara una scala che il **giornale** non ha mai autorizzato? | ricostruire come ci si è arrivati, scendere a k0 |
| il criterio che autorizzò la scala **corrente** passa ancora oggi? | scendere di un gradino |
| il **prodotto** `frac · scala · n_asset` ha superato il tetto? | bloccare l'esecuzione |
Tre stati (`OK` / `ALLARME` / **`NON MISURABILE`**), una allerta per streak, e il marcatore «già
detto» scritto **solo dopo un invio riuscito** (il debito #2 del 28/08: un 🚨 perso prima era perso
per l'episodio intero).
Riporta anche la **frequenza del ramo di fallback**, che se superasse il 2% in 90 giorni
invaliderebbe la regola (d): il libro girerebbe a una leva **mista** e `g(k)` smetterebbe di
descriverlo. Misurata oggi: **0 giri su 1.676**. ⚠️ I 19 giri senza equity reale sono tutti
«paper capital», cioè **pre-finanziamento**, dove il libro non invia affatto — il sorvegliante li
conta e li dichiara separatamente invece di gonfiare la quota.
🚨 **Ciò che il sorvegliante NON può fare: impedire la modifica.** Chi ha accesso al file può
scriverci dentro. Rende la modifica visibile entro 24 ore e attribuibile. È la stessa onestà di
`venue_watch`: non protegge il saldo, **compra tempo**.
## 5. Verifica che il libro non è cambiato
```
sizing base : $2,052.22 | cap/asset $1026 | min $5 | disaster-SL -30%
scala libro : 1.00x (tetto 1.25x) | leva lorda max 1.000x dell'equity [chiave assente o 1,00 = libro invariato]
BTC TP +0.461 · SKH +0(flat) -> net $+355 | pos $+357 -> HOLD (a target)
ETH TP +0.278 · SKH +0(flat) -> net $+214 | pos $+212 -> HOLD (a target)
```
Gli stessi target del cron delle 15:47. **Suite: 825 passati** (807 + 18 nuovi).
## 6. Cosa NON ho fatto, ed è deliberato
**Non ho scritto la chiave in config.** Il punto 2 della checklist lo prescrive esplicitamente, e T7
dimostra bit-exact che senza la chiave il libro è quello di prima.
**Non ho eseguito GATE SCALA-01**, e non era possibile: A2 richiede che k0 sia in produzione con il
criterio passato ogni giorno, e il punto 5 impone **≥30 giorni a 1,00 col sorvegliante attivo prima**
di eseguire il gate. Cito la specifica perché la frase è il motivo:
> *Il punto 5 non è burocrazia: è l'unico modo di scoprire che il sorvegliante è rotto mentre la
> leva è ancora 1,00.*
**Non ho rifatto `r0726_fee_sensitivity`** (A7). Serve solo *se* e *quando* il gradino viene chiesto:
la sua conclusione «liquidation fee 1% irrilevante» era condizionata a lordo ≤1x, e a 1,25x una
liquidazione costerebbe **1,25%** dell'equity. Non è un problema, è un numero da ricalcolare — e
ereditarlo sarebbe l'errore.
📌 **A6 (anti-recency) oggi non morde**: `k_ammesso ≈ 3,5x` contro un gradino di 1,25x, e il vincolo
che morde è il disaster-SL. È scritto adesso **perché adesso non è comodo per nessuno** — che è
l'unico momento in cui una regola del genere si può scrivere onestamente.
## 7. Stato del gate
`GATE SCALA-01` è ora in CLAUDE.md §4 con data **non prima del 2026-10-01**. A8 e A9 sono fatti;
A2 matura col tempo; A1/A4/A5 sono aritmetica già cablata; A3/A6/A7 si eseguono il giorno del gate,
sui dati di quel giorno, non su questi.
+137
View File
@@ -0,0 +1,137 @@
# 2026-09-01 — PAVIMENTO-LEVA: il pavimento non licenzia taglia, e corregge quanto scritto stamattina
*Scritto il 2026-09-01. Ogni numero è riprodotto da `scripts/research/r0901c_pavimento_leva.py`,
che riusa il motore di `r0901_btc_collar.py`. **Libro, pesi, cron, config INVARIATI. Nessun ordine.***
## 0. La domanda
> *«possiamo usare quanto conosciamo del pavimento per studiare una strategia»*
Cosa sapevamo, da COLLAR01 (§71, poche ore prima): il collar riduce il maxDD in 36/48 celle; a
premio **zero** il pavimento vale moltissimo (maxDD 51,83% → 36,76%, drift +10,04% → +35,69%); ciò
che uccise il collar fu il **tetto**, non il pavimento (C9); la put è l'ala cara.
**L'inversione.** COLLAR01 chiese *«quanto DD mi risparmia il pavimento a taglia fissa?»* e perse
contro il de-levering. La domanda speculare è *«quanta TAGLIA mi autorizza il pavimento a DD
fisso?»*. Non è la stessa, per due ragioni misurabili:
1. de-levare riduce taglia e rischio **in proporzione**; un pavimento cambia la **forma** — se taglia
la coda sinistra più di quanto costi, scalare *in su* la struttura protetta può battere la base
nuda alla stessa DD. È l'unica cosa che il de-levering **non può** comprare;
2. il tetto di leva del progetto non è fissato dallo Sharpe ma dal **costo di un episodio** di
disaster-SL (`n · frac · scala · sl ≤ 0,50` ⇒ k ≤ 1,67x). Il disaster-SL è **rotolante** e non
limita la perdita (peggior DD dall'ingresso misurato 60,6%). Una put la limita per costruzione —
*dentro la sua finestra*.
Quattro attese scritte prima di misurare (B1-B4). **Tutte e quattro confermate.**
## 1. Il risultato: 0 celle su 16
Griglia dichiarata: 4 δput × 2 tenor × 2 gate. Struttura: hold gated + **sola put**, premio reale
(DVOL × skew × termine dalla catena — realistico per chi *compra*: M29 corregge chi vende a
modello, non chi compra).
| gate | base nuda | pavimento a premio reale (16 celle) |
|---|---|---|
| FORTE | maxDD **51,83%** · drift +10,04% | maxDD **55,4% → 75,8%** · drift +3,8% → 17,0% |
| LARGO | maxDD **71,94%** · drift +17,37% | maxDD **74,8% → 81,7%** · drift +9,9% → 13,9% |
**Il pavimento peggiora il maxDD in 16 celle su 16.** Quindi `k_f = 1,000` ovunque: non c'è taglia
da licenziare, perché non c'è DD da spendere. Il null M5 classico concorda (0/16). Monotono nella
protezione: più la put è vicina, peggio va — il bleed del premio *è* il drawdown.
## 2. 🚨 La correzione a COLLAR01
Stamattina ho scritto, in A1: *«il pavimento funziona davvero (e §46 è battuto sul suo motivo)»*.
**La prima metà è sbagliata nell'attribuzione.** Il DD lo riduce **il collar**, non il pavimento:
- la put da sola, a premio reale, **peggiora** il DD (questo filone);
- a premio zero lo riduce (il controllo del pranzo gratis) — quindi **tutto il beneficio del
pavimento è mangiato dal suo premio, e oltre**;
- nel collar la riduzione viene dal **tetto**: il suo premio compensa il bleed della put, e cappare
l'upside **abbassa il picco** da cui il DD si misura — che non è protezione, è un picco più basso.
**Il premio di pareggio** lo quantifica: il pavimento inizia a ridurre il DD nudo solo se le put
costano il **36-79% del reale** (secondo la cella). Il DVOL sta 1,32× sopra la vol realizzata, quindi
un prezzo *equo* sarebbe ~76% del reale: **anche a prezzo equo il pareggio si sfiora nelle celle
migliori e si manca nelle altre**. E il mercato non vende a prezzo equo.
La seconda metà della frase regge, ma va detta con precisione: **il motivo di §46 non si applica**
(a beta 1,0 la put paga davvero: para nel 2-20% dei cicli), **ma il verdetto di §46 — *il maxDD
SALE* — si riproduce a beta 1,0 per un motivo diverso.** Il motivo è la sezione seguente.
Corretti stesso giorno: il diario di COLLAR01 (titolo e A1), RESULTS §71, la memoria. M28:
*quando due affermazioni della stessa memoria si contraddicono, la contraddizione è informazione*
qui erano due mie affermazioni a poche ore di distanza, e la seconda ha corretto la prima.
## 3. B2 — Il meccanismo: la put copre una finestra, BTC scende in grind
| gate | maxDD della base | da → a | durata |
|---|---|---|---|
| FORTE | 51,83% | 2021-07-20 → 2022-05-06 | **290 giorni** |
| LARGO | 71,94% | 2021-07-20 → 2023-10-16 | **818 giorni** |
Una put a 7-14 giorni copre **una** finestra. Il drawdown che conta è lungo **20-60 finestre**. In
un grind la put scade OTM settimana dopo settimana — il pavimento para nel **0-9%** dei cicli col
gate FORTE — mentre il premio sanguina. E non protegge nemmeno la finestra peggiore: peggior 14
giorni FORTE **22,9% nudo → 23,6% col pavimento**, perché la put a 5 delta sta a ~22% dallo spot,
cioè esattamente al bordo di ciò che è successo, e il premio la rende netta negativa.
**Su BTC il pavimento compra protezione contro la cosa sbagliata**: i crolli veloci dentro una
finestra, mentre le perdite che definiscono il maxDD sono grind di mesi. Questo è ciò che §46 non
poteva vedere (a beta 0,076 il libro non aveva né crolli né grind da proteggere) e che a beta 1,0
diventa il fatto dominante.
## 4. B3 — La licenza del disaster-SL è di carta
L'invariante `n · frac · scala · sl ≤ 0,50` con `sl = 0,30`**1,67x**. Con la put a 5δ/14g il
pavimento sta a **21,9%** dallo spot: sostituire `sl` con quella distanza darebbe **2,28x**
*1,4× in più*. È la proposta che qualcuno farà, e va uccisa ora:
| finestre consecutive | giorni | peggior perdita col pavimento | la put… |
|---|---|---|---|
| 1 | 14 | 23,6% | copre |
| 2 | 28 | 25,9% | **non copre** |
| 4 | 56 | 28,8% | **non copre** |
| 8 | 112 | **33,6%** | **non copre** |
L'invariante limita **un episodio**; la put limita **una finestra**; il massimo su finestre
consecutive **non è limitato da nulla**. A 2,28x un grind di 112 giorni costerebbe il **77%
dell'equity**. ⇒ **`disaster_sl_pct` non si sostituisce con la distanza di un pavimento**, e va
scritto prima che sia comodo per qualcuno.
## 5. B4 — Il pavimento gated sull'IV: migliora, non basta
§46 dichiarava *«nessuna copertura dinamica gated su regime è stata provata»*. Provata: put ON solo
quando DVOL/RV trailing sta sotto il 25° o 50° percentile dell'anno (il premio di varianza è
sottile).
| gate | soglia | ON (giorni a mercato) | drift | vs base |
|---|---|---|---|---|
| FORTE | 25° pctl | 27% | **+8,39%** | +10,04% → **perde** |
| FORTE | 50° pctl | 47% | +6,77% | perde |
| LARGO | 25° pctl | 25% | **+15,31%** | +17,37% → **perde** |
| LARGO | 50° pctl | 50% | +13,93% | perde |
Migliora molto rispetto al pavimento sempre acceso (+3,8% → +8,4% col gate FORTE) e il DD resta
≥ della base (k_f = 1,000). ⚠️ L'incollatura ignora lo spread delle transizioni ON/OFF, **a favore**
del gated: se perde così, perde a maggior ragione.
## 6. Cosa NON ho fatto, dichiarato
- Nessun DSR, nessun `study_family_honest`: il filone cade al primo gate, 0/16.
- Nessuna lente reale (123 giorni, ~8 cicli): stessa ragione di COLLAR01.
- Non ho provato il pavimento **a scadenza lunga** (30-90 giorni), che coprirebbe più di una
finestra di grind — perché l'operatore ha vincolato la scadenza a **≤15 giorni**. È l'unica
variante che B2 lascia aperta, e sta fuori dal vincolo dichiarato. Va detto: se il vincolo cadesse,
sarebbe la prima cosa da misurare, e la memoria di §46 ha già l'apparato (tenor 30).
## 7. Verdetto
> **`IL PAVIMENTO NON LICENZIA TAGLIA A PREMIO REALE: 0/16. Ciò che il de-levering non può comprare,
> la put lo vende a un prezzo che nessuna cella ripaga — e su BTC compra protezione contro i crolli
> mentre le perdite sono grind.`** — REFUTATO.
**Cosa lo riapre:** una scadenza che copra il grind (fuori dal vincolo ≤15g), o un mercato che venda
la put al 36-79% del prezzo attuale — cioè un premio di varianza *negativo*, che su BTC non si è
mai misurato. Non lo riaprono delta, tenor ≤15 o gate sull'IV: sono misurati.
+91
View File
@@ -0,0 +1,91 @@
# 2026-09-01 — Soldi fermi: i modi esistono, valgono €5/mese, e uno di loro distruggerebbe lo split
*Scritto il 2026-09-01. Numeri da `scripts/research/r0901d_soldi_fermi.py`; tassi dal web con fonte e
data (cutoff del modello 05/2026: non li sapevo). **Non sono pareri fiscali.** Nessun ordine.*
## 0. La domanda
> *«troviamo altri modi per fare guadagnare con soldi che sarebbero fermi. non per forza trading»*
## 1. Dove sta il capitale fermo — e cosa NON lo è
**Deribit (lettura 20:42Z):** USDC $1.406 · USDE 654 · libro a 0,27x. ⚠️ **L'USDC non è fermo**: è
la base di sizing del libro (cap = equity × 0,5) e il cuscino di regolamento del disaster-SL. Spostarlo
fuori rimpicciolisce le posizioni, non libera capitale.
**Fuori:** ~€6.043 in XEON «a ~0% reale netto» (memoria 27/07). È lo **split-cassa** — la protezione
dal rischio venue, P(perso tutto) 18% → 3,5% a p=1% — e la memoria dichiara di non sapere quanto sia
fondo d'emergenza. **Assunzione dichiarata: €6.000 investibili a 12+ mesi.**
## 2. On-venue: chiuso, e stavolta con l'elenco intero
`public/get_currencies` oggi: **4 valute su 50** con APR > 0 — USDE 4,32 · USDC 3,40 · BUIDL 3,20 ·
STETH 2,22. Sono esattamente le quattro chiuse il 31/08 (al tetto · Italia esclusa · non
acquistabile · è ETH). Non era un campione: era la lista.
## 3. I modi, in euro netti l'anno su €6.000
| opzione | lordo | fisco | **netto/anno** | vs XEON | rischio | split |
|---|---|---|---|---|---|---|
| XEON (status quo) | 2,10% | 26% | **€81** | — | ~0 | intatto |
| **BOT 12 mesi** | 2,77% | **12,5%** | **€133** | **+€52** | ~0 (Stato) | intatto |
| **Conto deposito vinc. 12m** | 3,50% | 26% | **€143** | **+€62** | ~0 (FITD) | intatto |
| sUSDe diretto (Ethena) | ~9% | 33% | €350 | +€269 | **emittente + smart contract** | **distrutto** |
| versare sul libro | ~10% atteso | — | ~€600 | +€519 | maxDD 11%, venue | **distrutto** |
Fonti: BOT 2,768% asta agosto 2026 (Banca d'Italia 14/08); BCE DFR 2,25% (23/07) ⇒ €STR ~2,2%;
conti deposito 3,25-3,50% (Aidexa, Banca Progetto, Cherry, settembre 2026); sUSDe «high single
digits» Q2 2026 (9,4% 7g / 11,8% 90g ad aprile). Bollo 0,2% su tutto.
## 4. La lettura
- **Fra le opzioni che lasciano intatto lo split, la migliore vale +€62/anno = €5/mese.** BOT e conto
deposito distano €10/anno: il 12,5% del BOT compensa quasi tutto il lordo in meno. Dentro il
rumore di *una* asta.
- **sUSDe diretto rende ~2× Deribit** (che trattiene metà dell'APR Ethena) — ma metterebbe i €6k
sullo **stesso emittente** dei 654 USDE già in conto. Lo split-cassa esiste per non essere
correlato al venue e al crypto: questa opzione lo trasforma in concentrazione. Già a 24,2% di quota
l'evento emittente vale 3,1× il maxDD dell'intero libro (memoria 30/08).
- **Versare sul libro** è l'alternativa che la memoria indica come dominante (€10k = 2,45× in 13
anni) — ma è trading, e distrugge lo split. Non risponde alla domanda: la cambia.
## 5. La taglia
Il miglior guadagno *sicuro* sullo status quo è **€62/anno**. **€100/mese di bonifico valgono
+4,07%/anno di drift.** Il modo esiste, è reale, ed è due ordini di grandezza sotto la leva che il
progetto ha misurato sei volte come binding.
## 6. Cosa resta all'operatore
1. **Quanto dei €6.043 è fondo d'emergenza.** Con quello, la tabella si applica al resto.
2. Fra BOT e conto deposito: **preferenza di liquidità** (MOT vs vincolo 12 mesi) e di controparte
(Stato vs FITD), non di rendimento — la differenza è €10/anno.
3. On-venue non c'è niente da decidere. La chiave di scala a 1,25x (GATE SCALA-01, ottobre)
libererebbe ~$400 → ~$10/anno: lo dico perché è l'unica via strutturale, non perché valga.
## 7. La conclusione dell'operatore (02/09, 06:00Z) — e le tre precisazioni
> *«quindi la miglior cosa è mettere gli XEON su deribit con il nostro sistema attivo»*
**Sì, ed è la conclusione che la memoria aveva già scritto il 27/07** (`r0727_lumpsum_split.py`:
piano «€5.000 dei €6.043 in XEON + €500/mese»; il 25/08 sono entrati $1.399, non €5.000). La
lettura della tabella è quella giusta: versare batte tenere fermo, ed è la leva binding. Ma il
*come* ha tre vincoli, tutti già misurati:
1. **Non tutti: €5.000, non €6.043.** La protezione dal rischio venue **satura a qualunque quota
> 0** (P(perso tutto) 18% → 3,5% tenendo fuori il 10%, e resta 3,5% al 25% e al 40%). L'ultimo
migliaio dentro compra ~€100/anno attesi e butta via l'intera assicurazione. **~€1.000 restano
fuori: è lo split, e costa zero.**
2. **Il «~€600» non è un tasso.** È un'attesa a banda larga su una strategia a mercato il 22% dei
giorni (live: 14/64, leva mediana 0,00x). Numeri onesti: TWR **+10,8% in 70 giorni**, Sharpe
hold-out TP01 **~+0,05**, fisco d'accumulo **30% a 10 anni**. Un anno piatto è possibile; il
conto deposito non lo ha.
3. **Il fondo d'emergenza** — non dichiarato dall'operatore, non noto alla memoria. Se una parte
dei €6.043 lo è, resta fuori.
**Operativamente non serve nulla:** il sistema è live dal 20/06, il cap è dinamico (`equity × 0,5`),
il rilevatore di salti ha già gestito il 25/08 senza falsi allarmi. Il deposito viene assorbito al
giro successivo delle :47. Resta da scrivere **una riga di giornale con data e importo** — la fonte
del cumulato dall'arming va scritta prima, non ricostruita dopo (debito #14). **In attesa di importo
e data dall'operatore.** N9: le opzioni sicure (+€52-62/anno) sono state viste e messe da parte
*con motivo*, non ignorate.
@@ -0,0 +1,76 @@
# 2026-09-02 — Debito 14: il report stampa il TWR, non il bonifico
*Scritto il 2026-09-02 fra le 14:16Z e le 14:22Z (commit `2f701b8`, 14:22:50Z; la prima stesura diceva «fino alle 14:30Z» senza averlo letto — corretto in revisione). Numeri dalla lettura di equity delle 13:47:01Z (1.678 letture) e dal report rilanciato dopo la riparazione.*
## Cosa c'era
`trades_db.py --report` calcolava `100*(e1/e0-1)` sulla serie grezza di equity e lo stampava come
performance: **+243%**. La serie contiene un solo salto, il versamento di $1.399,39 del 25/08
11:47Z: il 96,3% di quel numero era un bonifico. Il classificatore che lo scorpora esisteva già
(`journal.movimenti_capitale`, dal 25/08) e non era mai arrivato al secondo lettore della stessa
serie. Trovato e registrato il 01/09 (`2026-09-01-stato-trades.md`), non riparato perché lo script
legge il libro vivo.
## Cosa ho fatto
| pezzo | dove | cosa |
|---|---|---|
| funzione unica | `src/live/journal.py``rendimento_twr(con, fino_ts)` | chiama `movimenti_capitale`, spezza la serie all'equity PRIMA e DOPO ogni movimento **certo**, moltiplica i segmenti. Gli **ambigui non spezzano** (P12): restano nel rendimento, dichiarati. Tre stati: numero, o `None` con `motivo` |
| il report | `scripts/live/trades_db.py``report()` | chiama `rendimento_twr` (P1: non rifà il conto). Stampa TWR con i segmenti datati, i movimenti elencati con la classe, `trading da arming`, e il delta $ grezzo etichettato «movimenti di capitale INCLUSI». Il `%` grezzo non compare più |
| test | `tests/test_journal.py` (+5) · `tests/test_trades_report.py` (+2) | versamento spezzato e grezzo no · senza movimenti = rendimento semplice · ambiguo non spezza · serie vuota → `None` con motivo · **riproduzione del +10,80% del diario 01/09** (M23) · il testo stampato contiene TWR e non il grezzo · `connect()` nudo deviato in tmp (conftest), verificato nel test |
## Il report, prima e dopo
```
prima: equity : $598.06 -> $2,051.84 (+1453.78, +243.08%)
dopo: equity : $598.06 -> $2,048.40 (+1,450.34 di equity, movimenti di capitale INCLUSI) | picco $2,073.59 | 1678 letture
movimenti capitale : +1,399.39 certi | +0.00 ambiguo/i (restano nel P&L, dichiarati)
2026-08-25T11:47 +1,399.39 [movimento]
trading da arming : +50.95 (equity al netto dei movimenti certi)
TWR : +10.61% = +11.61% [2026-06-23 -> 2026-08-25] x -0.89% [2026-08-25 -> 2026-09-02]
```
Il giornale del giorno concorda: «cumulato dall'arming $+1.450,34 — di cui $+1.399,39 versati ->
trading **$+50,95**». Stessa base, stesso versamento, stessa funzione.
## Una cosa imparata riparando
Quattro test sono nati rossi con scenari a **+10% di trading** fra due letture: la soglia del
rilevatore (`book.EQUITY_JUMP_ALERT`) è esattamente 0,10, e a mercato piatto un +10% supera «2× il
massimo che il mercato poteva fare» (che è zero) — viene classificato **movimento**. Non è un difetto
della riparazione: è il limite già dichiarato del rilevatore live (D5, nel docstring di
`movimenti_capitale`). Nella serie vera non morde — fra due letture orarie consecutive il trading non
fa +10% — ma un test che lo tocca lo rende visibile. I test ora usano +5%, e il limite è scritto
nel test, nel CLAUDE.md e qui.
## Cosa NON ho fatto
- Il giornale (`pnl_giorno`, pagina markdown) **non stampa il TWR**: stampa già lo scorporo in
dollari e la sua pagina ha una forma testata. Aggiungerlo è una riga, ma non era il debito.
- Non ho scomposto lo scarto (~$25) fra round-trip + non realizzato e il trading al netto: era già
fuori scope il 01/09 e lo resta.
## Revisione del codice, prima tornata (15:15Z, commit `835e0c8` 15:17:05Z) — tre cose trovate, tutte riparate
| trovato | vero? | riparazione |
|---|---|---|
| l'intervallo che contiene un movimento certo esce intero dal rendimento: il suo P&L di mercato finisce in `certi` e fuori da `trading`, e nessun documento lo diceva | sì, per costruzione. Errore massimo = metà del movimento riconosciuto (margine del classificatore). Il 25/08: ~$263 lordi a ±0,2% → ~$0,5 | dichiarato nel docstring, nel report, in CLAUDE.md §5.14 e in memoria 40. Non si stima (P12) |
| a base di equity zero `trading` era un numero mentre `twr` era None, e sbagliato: il salto 0→X è invisibile al classificatore, `trading` valeva l'intero conto sotto l'etichetta "al netto dei versamenti" | sì. Non raggiungibile dal cron (non scrive mai 0), ma raggiungibile da un backfill | tre stati veri: `twr` e `trading` entrambi None con motivo; il report stampa n/d. Test in `test_journal.py` e `test_trades_report.py` |
| movimento nel primo intervallo o due consecutivi → segmento `+0.00% [x -> x]` | sì, cosmetico | i segmenti a lunghezza zero non si producono; nessun tempo a mercato → TWR 0,0 dichiarato numero. Test |
Il numero pubblicato non cambia: +10,61% alle 14:02Z, +10,56% alle 14:47Z per la marcatura.
## Seconda tornata (15:30Z) — la piu' importante di tutte
| trovato | vero? | riparazione |
|---|---|---|
| il classificatore dei movimenti era **cieco ~23 ore al giorno**: il feed 1h si ferma alle 00:00, `asof` dava la stessa barra alle due letture, mercato «fermo» = 0, qualunque calo ≥10% del giorno diventava «movimento». Riprodotto dalla revisione su copia del DB: 12% appeso alle 14:47 → `certi` 1.399 → 1.153, TWR invariato invece di 2,71% | si'. Verificato: ultima barra 00:00, adesso 15:26 | nuovo stato **«mercato non misurabile»** (feed fermo, assente, o barra mancante) ⇒ `ambiguo` con la ragione; `twr` e `trading` None con motivo |
| con `data/raw` assente (host ripristinato prima del rebuild) tutto era «ambiguo», `certi` 0, e il report stampava **+243% sotto l'etichetta TWR** | si' | stesso stato: n/d, mai il grezzo |
| «`rendimento_twr` e' la funzione che entrambi chiamano» era **falso**: `pnl_giorno` rifaceva `cum certi`, e la testata Telegram mandava il cumulato grezzo | si' | `pnl_giorno` chiama `rendimento_twr`; la pagina stampa il TWR accanto al cumulato; la testata dice «di cui versati → trading» |
| `leva_tetto` del classificatore ridichiarava `frac × n` ignorando `book_scale_k` e `LEVA_LORDA_MAX` (P1) | si' | `min(frac × n × scala, LEVA_LORDA_MAX)`; fallback = tetto di codice |
| il «limite ereditato: +10% di trading a mercato fermo» scritto la mattina era un **artefatto della fixture piatta** (a mercato misurato il trading non supera 2·max_mkt) | si' | cancellato ovunque; il limite vero e' quello sopra |
| il bound di mercato guarda solo BTC/ETH, ma il 31% dell'equity e' USDE: un depeg pieno sarebbe un «prelievo» | si' | **non riparato**: l'indice USDE e' registrato una volta al giorno. Debito **§5.15** |
| l'ora del report scritta in §2 (14:02Z) era l'ora di esecuzione, non quella della lettura (13:47:01Z); i tempi «scritto» dei diari erano posteriori ai commit | si' | corretti coi tempi veri |
Il numero pubblicato: TWR +10,61% alla lettura delle 13:47Z; +10,56% alle 14:47Z.
@@ -0,0 +1,67 @@
# 2026-09-02 — Debito 7: il docstring diceva 230 minuti, il cron gira ogni ora
*Scritto il 2026-09-02 fra le 14:36Z e le 14:38Z (commit `861cc7f`, 14:38:53Z; la prima stesura diceva «alle 14:45Z» senza averlo letto — corretto in revisione). Cadenza misurata dal log (`r0823_sl_anchor.py`: 1.443 giri in
1.442 ore) e dalla crontab installata, letta dal test.*
## Il difetto
| fonte | cosa diceva | dal |
|---|---|---|
| `scripts/live/book_execute.py`, docstring | «va lanciato ogni ~230 minuti» | 20/06 |
| `scripts/cron_book.sh`, intestazione | cadenza ORARIA, `47 * * * *` | 25/08 (prima `:07`, sempre orario) |
| crontab della VPS | `47 * * * *` | 25/08 |
Era il **docstring** a sbagliare. E non era una svista innocua: sulla riga 4h (la più vicina ai 230
minuti) BTC **raddoppia** gli scatti del disaster-SL rotolante (2 contro 1; a 24h ETH arriva a 5, con
2 in 30 giorni). Il rischio era un lettore diligente che "allineasse" il cron al docstring.
## La riparazione
1. **Docstring riscritto**: `CADENZA: ORARIA`, la riga di crontab citata, la ragione (giro
idempotente → girare più fitto della griglia non costa ordini; latenza ≤1h sugli ingressi/uscite
software di SKH01; ri-ancoraggio orario del rotolante) e il divieto esplicito, con la data.
2. **`tests/test_book_cadenza.py`, 10 test.** P1: non ridichiara «60 minuti», **deriva** le tre
fonti e le confronta fra loro:
- il docstring non contiene più «ogni ~230» e dichiara ORARIA;
- la riga di crontab citata nel docstring è quella dichiarata in `cron_book.sh`;
- la riga dichiarata è oraria (parser a 5 campi, solo casi regolari, rifiuta il resto),
sotto i 230 della griglia e lontana dai 240 della riga peggiore, e fuori dal minuto `:00`;
- **la crontab installata** (`crontab -l`) coincide con la dichiarata. Dove la crontab non è
leggibile il test è **saltato**, non verde (P5). Sulla VPS oggi gira e passa.
## Numeri
| | |
|---|---|
| test nuovi | 10, tutti verdi; suite dei moduli che toccano `book_execute`: 231 verdi |
| cadenza dichiarata / installata | 60 min / 60 min |
| griglia SKH01 | 230 min (la riga da non usare) |
| riga peggiore misurata | 240 min (BTC 2 scatti contro 1) |
## Cosa NON ho fatto
- Non ho toccato il cron né `cron_book.sh`: la configurazione che gira era quella giusta.
- Non ho aggiunto un sorvegliante a runtime sulla cadenza (contare i giri per ora nel log): il
giornale già stampa «giri mancanti» ogni giorno, e il test copre la deriva di configurazione.
## Revisione del codice, prima tornata (15:15Z, commit `835e0c8`) — due cose trovate, tutte riparate
| trovato | vero? | riparazione |
|---|---|---|
| il docstring nuovo diceva «il disaster-SL rotolante si ri-ancora ogni ora»: falso. `ensure_disaster_sl` lascia il bracket com'è finché lo stop voluto è entro il 5% da quello piazzato e la taglia entro il 10%; il giro orario **controlla**, ri-ancora oltre la tolleranza (mark +5,263% / 4,762%). I «BTC 0 / ETH 1» di `r0823` sono misurati **con** questa isteresi | sì, verificato in `execution.py:233-235`. Propagato anche a CLAUDE.md §5.7 e memoria 40 | riscritto in tutti e tre i posti: «controllato ogni ora, ri-ancorato solo oltre la tolleranza; lo stop siede fra 26,3% e 33,3% dal mark corrente». Frase sbagliata su un meccanismo di sicurezza vivo: era la segnalazione più importante |
| la guardia del test cercava due letterali («ogni ~230»): «ogni quattro ore» sarebbe passato | sì | la guardia estrae **ogni** prescrizione «ogni/every N unità» e pretende 60 minuti; controllo positivo (M15) su cinque frasi. Limite dichiarato (P13): una prosa che prescrive senza «ogni» passa |
## Seconda tornata (15:30Z)
| trovato | vero? | riparazione |
|---|---|---|
| la frase nuova di CLAUDE.md §5.7 era **invertita** («chi lo avesse corretto nel verso del cron»): correggere il docstring verso il cron e' proprio la riparazione; il pericolo e' correggere il CRON verso il docstring | si' | riscritta nel verso giusto |
| la banda dello stop era sbagliata: con isteresi 5% sullo stop (0,70·mark) lo stop siede a 0,6650,735 × mark, cioe' **33,5% / 26,5%**, non 26,3/33,3 | si' | corretta in docstring, CLAUDE.md, memoria |
| «BTC raddoppia gli scatti» leggeva male `r0823`: il «2 contro 1» e' rotolante contro pavimento **dentro la riga 4h**; a 1h il rotolante ne fa 0, e la riga peggiore misurata e' **24h** (ETH 5) | si' (la revisione ha eseguito `r0823.simula`) | riscritto ovunque coi conteggi; nel test `RIGA_4H_MIN`, non «peggiore»; `LTF_MIN` importato da `skyhook` (P1) |
| «girare piu' fitto non costa ordini» era falso: 22 dei 48 ordini live sono ri-taglie |Δ|≤$10 a segnale invariato; il deadband e' in valuta assoluta (C2) | si' (contati) | riscritto: «costa solo micro-ordini di ri-taglia», con la regola C2 |
| «latenza ≤1h» senza condizione: vale solo con la feed 5m fresca; `fresh_5m` ripiega in silenzio sul giornaliero e `skh_feed_max_age_min` allerta e basta | si' | condizione scritta |
| `_cron_installato` leggeva la **prima** riga: una seconda riga attiva sotto (`17 * * * *`, dentro lo slot di release) era invisibile | si' (simulato) | si contano **tutte** le righe attive, e devono essere una |
| lo skip collassava tre stati: crontab illeggibile, leggibile senza il progetto, leggibile col progetto ma senza `cron_book.sh` attivo (= libro non schedulato) | si' | tre stati: SALTATO, SALTATO, **ROSSO** |
| solo il vincolo 1 del `:47` era testato (minuto ≠ 00); il vincolo 2 (fuori dallo slot di release del martedi') no | si' | test con `venue_probe.in_release_window`, il :07 come controllo positivo |
| `_cron_dichiarato` accettava solo `N * * * *` e falliva come «assente» su `*/30`; `cadenza_minuti` accettava `*/45`, `0 */5`, `*/0` | si' | regex a 5 campi qualunque, regolarita' e passo > 0 imposti, parametrizzati |
| `r0823_sl_anchor.py` si rifiutava di girare nella finestra del **vecchio** `:07` e stampava a runtime che il docstring «prescrive ~230 min»; `cron_chain.sh` giustificava il :25 «fuori dal :07» | si' | guardia sul :47, prosa al passato, commento aggiornato |
+103
View File
@@ -0,0 +1,103 @@
# 2026-09-02 — Un flag sconosciuto non è l'azione di default
*Scritto fra le 15:45Z e le 16:05Z. Il difetto è stato misurato, non ipotizzato: la revisione del
codice di questa sessione ha eseguito `trades_db.py --help` come sonda di tempi e lo ha visto
sincronizzare il DB.*
## 1. Il difetto
Nessuno dei 18 script di `scripts/live/` usava argparse. Leggevano gli argomenti così:
```python
if "--reconcile" in args: ...
elif "--report" in args: ...
else: sync() # <- qui finiva QUALUNQUE cosa non riconosciuta
```
Un flag sbagliato non era un errore: era il ramo finale. Costo osservato e costi possibili:
| script | `--help` faceva | gravità |
|---|---|---|
| `trades_db.py` | `sync()`: riscriveva `meta.ultimo_sync` | osservato oggi, danno nullo |
| `journal.py` | scriveva la pagina di oggi e una riga nel DB | non osservato, ma scrive |
| `analista.py` | spendeva una chiamata al modello e mandava un Telegram | non osservato, ma spende |
## 2. La riparazione, e perché non è argparse
`src/live/cli.valida(nome, uso, flag=..., con_valore=...)`, chiamata come **prima istruzione** di
ogni blocco `__main__` — prima di `connect()`, prima del sync, prima della rete.
- `--help` / `-h` → stampa l'uso, esce **0**;
- flag non dichiarato, o valore mancante, o posizionale → stderr con **cosa** e **cosa si poteva**
(P4), esce **2** (errore d'uso, distinto dall'1 con cui gli script segnalano un esito negativo);
- tutto il resto invariato.
**Argparse no.** Cambierebbe messaggi d'errore, codici d'uscita e il comportamento di `--help` su
18 script che il cron già invoca: romperebbe ciò che gira per riparare ciò che non gira mai. Qui
serviva l'opposto — lasciare intatti i flag esistenti e rifiutare solo l'ignoto.
## 3. Il test guarda entrambe le metà del contratto
`tests/test_cli_flag.py`, 30 test:
- **l'elenco degli script si deriva dalla cartella** (P1): uno script nuovo che legge `sys.argv` a
mano e non chiama `valida` fa fallire il test, non passa inosservato perché nessuno ha aggiornato
una lista;
- `valida` dev'essere la **prima istruzione** di `__main__` (uscire dopo l'effetto non è uscire);
- l'uso deve **nominare** i flag che lo script accetta;
- **i flag che il cron usa davvero devono restare accettati** (P15/P16, letti dai `cron_*.sh`): una
guardia che ferma il libro di bordo la notte stessa sarebbe peggio del difetto;
- end-to-end sui tre script che scrivono: `--help` esce 0 e **non tocca `trades.db`**; un flag
ignoto esce 2 e non lo tocca (M15: il caso che ha causato il difetto, non un caso vicino).
Verificato a mano anche il contrario: `monitor_health.py --quiet`, `trades_db.py --sync --quiet`
e `book_execute.py` in dry-run escono 0 come prima.
## 4. Il resto della sessione
**Indice USDE orario** (debito §5.15, primo passo). Il classificatore dei movimenti di capitale
confronta due letture di equity a un'ora di distanza e il suo bound guarda solo BTC/ETH, mentre il
31% dell'equity è USDE: un depeg sarebbe scorporato come un prelievo. `usde_watch` registra
l'indice una volta al giorno — troppo rado. Ora `balance_watch` (orario, sola lettura) lo registra
a ogni campione, `None` con la ragione se non leggibile, mai 1,0. Il cablaggio nel bound viene
quando la serie ha storia: prima serve il dato, poi la regola.
**Pulizia lasciata dalla revisione.** `tests/helpers.carica_script` sostituisce la dodicesima copia
del caricatore `importlib` (15 in giro, con nomi di modulo diversi per lo stesso file e differenze
silenziose su `sys.modules`); il fill di prova si scrive con `upsert_fills` invece che con un
`INSERT` a mano che lasciava `verified` NULL, una forma che la produzione non produce; le
asserzioni sul testo del report non sono più ancorate al padding; `movimenti_capitale` accetta le
righe già lette, così il report non fa due SELECT sulla stessa tabella a un'ora del cron — due
letture ai due lati di una scrittura descriverebbero due istanti diversi.
## 5. Il README era fermo a quindici giorni prima del reset
Chiesto un aggiornamento della documentazione, ho aperto il README e ho trovato un documento del
**2026-06-04**. Il reset v2.0.0 è del **19 giugno**. Per 75 giorni la prima pagina del repository ha
descritto la libreria pre-reset come se fosse il presente:
| cosa diceva | stato vero |
|---|---|
| famiglie FADE / HONEST / PAIRS / TSMOM / SHAPE, strategie MR01…SH01 | archiviate in `Old/`, artefatto di feed contaminato |
| PORT06: Sharpe **7,84 / 10,06**, DD 2,60%, CAGR ~79% | numeri che `CLAUDE.md` §2 elenca sotto «non citare» |
| paper trader su `strategies.yml`, portafogli in `portfolios.yml` | **file inesistenti** |
| `scripts/waste/`, `scripts/portfolios/`, `src/live/multi_runner.py` | **inesistenti** |
| esecuzione shadow su Deribit **testnet** | il testnet è *la causa del reset*; oggi si esegue su mainnet con soldi veri |
Riscritto contro lo stato vero: cosa gira adesso, i numeri nella lente di §2 (TWR +10,6%, non la
crescita del conto), il metodo e i sei requisiti, la struttura verificata file per file, i gate con
le loro date, l'obiettivo con la sua onestà. Ogni percorso citato è stato controllato: esiste.
Da 423 righe a 152.
**La lezione che ho registrato non è «aggiornare il README».** È che un reset invalida anche i
documenti che nessuno rilegge, e l'inventario di cosa cita numeri morti va fatto il giorno del
reset — non 75 giorni dopo, per caso, mentre si fa altro.
## 6. Cosa NON ho fatto
- I loader `importlib` dei test sulla **ricerca** (moduli in `scripts/research/`) restano come
sono: sono a livello di modulo, con radici diverse, e toccarli non ripara niente.
- Il bound USDE nel classificatore: manca la storia, non il codice.
- Non ho cercato altri documenti fermi al pre-reset fuori da `README.md` e `docs/`: l'inventario
completo (ogni file che cita un numero morto) resta da fare, ed è la vera coda di questa scoperta.
+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.
+56
View File
@@ -0,0 +1,56 @@
# Giornale di bordo — 2026-08-31
*Scritto 2026-09-01T00:36:58+00:00. Numeri misurati; nessuna interpretazione automatica.*
## Mercato
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|---|---|---|---|---|---|---|---|
| **BTC** | $78,564.00 | +1.11% | -0.53% | +25.15% | 42.3% | ↑ ↑ ↑ (3/3 su) | 37.6 (21° pctl 1a) |
| **ETH** | $2,467.80 | +2.08% | -0.62% | +33.88% | 67.9% | ↑ ↑ ↑ (3/3 su) | 51.3 (19° pctl 1a) |
## Libro
Equity **$2,059.45** · nozionale lordo $569 · leva lorda 0.28x · barra dati 2026-08-31 · 24 giri
| | TP01 | SKH01 | target | posizione | azione |
|---|---|---|---|---|---|
| **BTC** | +0.461 | flat | $+356 | $+354 | HOLD (a target) |
| **ETH** | +0.278 | flat | $+214 | $+215 | HOLD (a target) |
## P&L
- giorno: **$+12.06** di equity (24 letture)
- realizzato: -0.48 netto su 3 round-trip chiusi · 4 fill · fee 0.0100
- cumulato dall'arming: **$+1,461.39** di equity — di cui $+1,399.39 versati/prelevati -> trading **$+62.00**
## 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 $+354 (TP01 +0.461, SKH01 flat) e ETH $+215 (TP01 +0.278, SKH01 flat).
- `[leva]` Leva lorda **0.28x** su un tetto di 1.00x (`max_notional_per_asset_frac` 0.50 x 2 asset, letto da `config/live.json`): margine 0.72x.
- `[pnl]` Equity $+12.06 con 4 fill e 3 round-trip chiusi (realizzato $-0.48 netto).
- `[drawdown]` 📌 Equity **-0.68%** sotto il picco ($2,073.59). ⚠️ e' un DD su letture ORARIE, non sul minimo intra-giorno.
- `[vol]` BTC: implicita 37.6 contro realizzata 30g 42.3 (sotto); DVOL al 21° pctl di un anno; IV-rank espandente 0.07 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
- `[vol]` ETH: implicita 51.3 contro realizzata 30g 67.9 (sotto); DVOL al 19° pctl di un anno; IV-rank espandente 0.12 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
- `[evidenza]` 📌 Campione a oggi: **69 giorni, 29 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-01T00:38:01+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
Il $+12.06 di oggi non nasce da una decisione: le posizioni sono rimaste a target (BTC $+354 contro $+356, ETH $+215 contro $+214) e i 3 round-trip chiusi hanno realizzato $-0.48. Il guadagno e' marcatura di due long in una giornata di BTC +1.11% e ETH +2.08%; la lettura alternativa altrettanto compatibile e' che il libro sia stato semplicemente beta lungo, e in pagina non c'e' nulla che separi le due cose.
E' il delta piu' grande dal 25/08, che pero' era il versamento: un solo giorno pesa quindi molto sui $+62.00 di trading cumulato dall'arming, su un campione di 69 giorni e 29 round-trip, tre dei quali chiusi oggi. Rispetto a -17.87 del 28/08 e -9.48 del 30/08 il segno cambia, ma le posizioni sono ferme dal 25/08 fra $+344 e $+364 su BTC e fra $+205 e $+218 su ETH: si muove il prezzo, non il libro.
Insolito, e non confrontabile coi giorni precedenti che la volatilita' non la riportano: implicita sotto realizzata su entrambi (37.6 contro 42.3, 51.3 contro 67.9) con DVOL al 21° e 19° percentile dell'anno. Merita un'occhiata che siano serviti 4 fill per lasciare le posizioni dov'erano, e che il picco $2,073.59 da cui si misura il -0.68% non compaia in nessuna chiusura giornaliera.
## Nota
*(vuota — campo dell'operatore)*
+58
View File
@@ -0,0 +1,58 @@
# Giornale di bordo — 2026-09-01
*Scritto 2026-09-02T00:36:47+00:00. Numeri misurati; nessuna interpretazione automatica.*
## Mercato
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|---|---|---|---|---|---|---|---|
| **BTC** | $77,432.50 | -1.44% | -1.36% | +21.89% | 42.9% | ↑ ↑ ↑ (3/3 su) | 37.9 (23° pctl 1a) |
| **ETH** | $2,418.40 | -2.00% | -1.02% | +28.37% | 68.5% | ↑ ↑ ↑ (3/3 su) | 51.8 (21° pctl 1a) |
## Libro
Equity **$2,050.77** · nozionale lordo $568 · leva lorda 0.28x · barra dati 2026-09-01 · 24 giri
| | TP01 | SKH01 | target | posizione | azione |
|---|---|---|---|---|---|
| **BTC** | +0.461 | flat | $+355 | $+357 | HOLD (a target) |
| **ETH** | +0.278 | flat | $+213 | $+211 | HOLD (a target) |
## P&L
- giorno: **$-8.68** di equity (24 letture)
- realizzato: +0.00 netto su 0 round-trip chiusi · 1 fill · fee 0.0027
- cumulato dall'arming: **$+1,452.71** di equity — di cui $+1,399.39 versati/prelevati -> trading **$+53.32**
## 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 $+357 (TP01 +0.461, SKH01 flat) e ETH $+211 (TP01 +0.278, SKH01 flat).
- `[leva]` Leva lorda **0.28x** su un tetto di 1.00x (`max_notional_per_asset_frac` 0.50 x 2 asset, letto da `config/live.json`): margine 0.72x.
- `[pnl]` Equity $-8.68 con 1 fill e 0 round-trip chiusi (realizzato $+0.00 netto).
- `[drawdown]` 📌 Equity **-1.10%** sotto il picco ($2,073.59). ⚠️ e' un DD su letture ORARIE, non sul minimo intra-giorno.
- `[vol]` BTC: implicita 37.9 contro realizzata 30g 42.9 (sotto); DVOL al 23° pctl di un anno; IV-rank espandente 0.08 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
- `[vol]` ETH: implicita 51.8 contro realizzata 30g 68.5 (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: **70 giorni, 29 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-02T00:37:44+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
La giornata non contiene decisioni: 1 fill, 0 round-trip chiusi, realizzato $+0.00, entrambe le gambe HOLD a target. Gli $-8.68 sono quindi marcatura di posizioni ferme, non esito di operazioni — ed e' la prima giornata senza alcun round-trip chiuso dal 28/08, dopo il 30/08 e il 31/08 che ne avevano chiusi 2 e 3 con 5 e 4 fill. Anche le posizioni cambiano poco: BTC $+357 ed ETH $+211 restano dentro la banda tenuta dal 26/08 (BTC $+344364, ETH $+205218), con leva 0.28x molto sotto l'1.00x.
Cio' che le regole non vedono: il rosso arriva con BTC 1.44% ed ETH 2.00% mentre i tre TSMOM restano 3/3 su e i 30 giorni segnano +21.89% e +28.37%. Lo stesso dato regge due letture — rumore dentro un mese in tendenza, oppure l'avvio di un rientro — e da qui non sono distinguibili: non misurabile con una giornata.
Insolito il quadro volatilita': implicita sotto la realizzata su entrambi (37.9 contro 42.9; 51.8 contro 68.5) e DVOL al 23°/21° percentile, quindi VRP01 resta fermo per l'IV-rank, non per il livello.
Vale un'occhiata umana il fatto che il trading cumulato, $+53.32 in 70 giorni, sia della stessa taglia delle singole oscillazioni giornaliere recenti (17.87 il 28/08, +12.06 il 31/08).
## Nota
*(vuota — campo dell'operatore)*
+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)*
+98 -1
View File
@@ -701,7 +701,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
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
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):
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%**
@@ -1452,3 +1452,100 @@ una maschera a caso della stessa frequenza**.
dall'aria seria. Se ne è accorta solo la riga `giorni in cui il LIBRO e' flat: 0 = 0.0%`.
**REGOLA: stampare sempre la CARDINALITÀ di una maschera prima di usarla** — un filtro vuoto non
solleva, produce il caso degenere e lo veste da risultato.
---
- **COLLAR01 "hold BTC gated dal trend + collar di opzioni <=15 giorni" — REFUTATO (2026-09-01)** —
`scripts/research/r0901_btc_collar.py`, diario `2026-09-01-collar-btc.md`, registro §71.
Chiesto dall'operatore in quattro battute: *hold BTC long o short coperto in opzioni* + *opzione
max 15gg* + ***"ridurre la vincita, ma bloccare la perdita"*** (⇒ collar, non put protettiva) +
*entrata gestita da indicatori (es. forte bull)*.
🚨 **Si poteva riaprire dopo §46 perche' ne batte il MOTIVO, non perche' ne ripete la misura:**
§46 cadde sul **beta +0,076 del libro** (*"non si assicura un libro che nei crash e' gia' quasi
piatto"*), qui il sottostante e' un **hold di BTC, beta 1,0**. E §46 dichiarava non provata la
copertura **gated su regime**, che e' questa.
**L'entrata non ha aggiunto un solo parametro:** `tsmom_blend` media tre `np.sign()` su (30,90,180)
⇒ valori in {1,1/3,+1/3,+1}, quindi *"forte bull"* = `|blend|==1` (52,5% dei giorni) contro il
confronto dichiarato `|blend|>=1/3` (97,1%); il segno da' long **e** short.
**A1 CONFERMATA — il COLLAR riduce il maxDD: 36/48 celle** (§46: saliva in 162/162).
🚨 **Corretto lo stesso giorno da PAVIMENTO-LEVA (§72): NON e' il pavimento a farlo.** La put da
sola, a premio reale, **peggiora il DD in 16/16 celle**; nel collar la riduzione viene dal TETTO
(compensa il bleed, e cappare l'upside abbassa il picco da cui il DD si misura).
**Ma il de-levering lo fa meglio in 45/48**, e il baratto costa **2-8 punti di drift per punto
di DD** (Δdrift/ΔmaxDD **7,90** gate forte, **1,98** largo).
🚨 **C9 in forma pura: il tetto taglia il 46,2% dei cicli VINCENTI, il pavimento para il 6,7% dei
PERDENTI** — 7x piu' spesso sui vincenti: *troncatura, non protezione*.
**M1:** collar Sharpe 0,508 vs TP01 **0,852**; TP01+10% ⇒ **+0,000** di Sharpe e **+2,32pp** di
maxDD — peggiora proprio cio' che dovrebbe proteggere.
🚨 **IL RISULTATO TRASFERIBILE STA NELL'ESTENSIONE, NON NELLA GRIGLIA.** Le 3 celle vincenti
stavano tutte **sul bordo**; estesa la famiglia vince **35/36** con il massimo **nell'angolo**
(δput 0,02 / δcall 0,50, Sharpe **1,471**) — e il limite di quell'angolo e' *nessun pavimento,
tetto ATM* = **una covered call**: **la pendenza porta fuori dalla domanda posta e dentro lo
short-vol**. E quel 1,471 era **il prezzatore che si paga da solo**: **DVOL/RV-forward 1,320 a 7g,
sopra nel 76,9% dei giorni** ⇒ riprezzato alla vol vera l'angolo cade a **0,511** (VRP = **72%**
del drift; col gate largo **il 100%**, drift **1,10%**). E **0,511 e' VRP01** (ShFULL 0,47 a
f=0,73): *non una scoperta, VRP01 ri-trovato per una strada piu' lunga*. §3 lo blocca comunque.
🚨 **USCITA ANTICIPATA (chiesta: 50-75% del tempo) — implementata e COSTA**, e produce il secondo
risultato trasferibile. Cella onesta, gate FORTE: drift **+9,48% (scadenza) -> +3,84% (uscita al
50%)**, esito da VINCE a **perde sotto 0,75**; gate LARGO **12,06% -> 1,88%**. Il meccanismo
previsto c'e' (il VRP residuo scende **+3,38 -> +1,27pp**: uscire presto rinuncia davvero allo
short-vol) **ma lo spread lo travolge**. 📌 **Corregge l'APPLICAZIONE di §46:** *"un roll anticipato
non paga f"* vale per una copertura **solo LONG**; in un collar la **gamba venduta va RICOMPRATA**,
quindi uscire prima paga f *esattamente* sulla parte che a scadenza si regolava gratis, ~2x piu'
spesso. **L'asimmetria di §46 si INVERTE quando la struttura ha una gamba corta.**
**Controlli dell'apparato 3/3** (la lezione di §46): pranzo gratis riconosciuto, premio x10
rifiutato, zero-cost finito.
⚠️ **Cinque difetti miei catturati dai controlli:** bisezione zero-cost **invertita** (dava Sharpe
3,9); `dcall=NaN` che propagava NaN nella cassa; **C9 non consapevole della direzione** (`S1>S0`
non e' "vincente" per un ciclo short — dava l'impossibile *"pavimento 0,0%"*); e due di
contabilita' che **avrebbero adulato il collar** (la base che rollava lo spot pagando ~3,6%/a di
fee inesistenti, e il roll che chiudeva lo spot senza motivo).
📌 **Corregge un muro di §46:** il **tick da 5 USDC** e' della famiglia **USDC**; la catena che
raccogliamo e' **100% inverse** ⇒ quel muro **non si applica** a questo filone.
**Cosa lo riapre:** un **f di stress su un crash catturato** (la condizione che §3 pone allo
short-vol), o un sottostante **senza coda destra grassa**. **Non** lo riaprono tenor, delta o
griglie piu' fini: la pendenza e' monotona verso l'angolo, e l'angolo e' gia' misurato.
- **PAVIMENTO-LEVA "il pavimento come licenza di taglia" — REFUTATO 0/16 (2026-09-01)** —
`r0901c_pavimento_leva.py`, diario `2026-09-01c-pavimento-leva.md`, registro §72. L'inverso di
COLLAR01: non *quanto DD risparmia* ma *quanta TAGLIA autorizza a iso-DD*, l'unica cosa che il
de-levering non puo' comprare. **La sola put a premio reale PEGGIORA il maxDD in 16/16 celle**
`k_f = 1,000`, niente da licenziare. 🚨 **Corregge COLLAR01 lo stesso giorno**: il DD lo
riduceva il **tetto**, non il pavimento (compensa il bleed e abbassa il picco). Pareggio del DD
solo a **36-79% del premio reale**; prezzo equo ≈76%: non basta.
📌 **Il meccanismo, trasferibile: i drawdown di BTC sono GRIND (290-818 giorni), la put copre
UNA finestra** e para nel 0-9% dei cicli: compra protezione contro la cosa sbagliata.
🚨 **La licenza del disaster-SL e' di CARTA**: sostituire `sl` con la distanza del pavimento
darebbe 2,28x, ma su 8 finestre consecutive la perdita e' 33,6% → **77% dell'equity**.
`disaster_sl_pct` NON si sostituisce con la distanza di un pavimento.
**Gated sull'IV sottile** (la copertura dinamica che §46 non aveva provato): migliora, perde.
**Cosa lo riapre:** scadenza lunga che copra il grind (fuori dal vincolo ≤15g dell'operatore),
o un premio di varianza negativo su BTC. Non delta, tenor ≤15, gate IV: misurati.
- **CROLLI / CROLLO CATTURATO / XRP — 0 candidati, 1 debito, 2 conclusioni smontate in revisione (2026-09-09)** —
`r0909_{libro_nei_crolli,crash_catturato,xrp_terza_gamba,deribit_universo}.py`, diario
`2026-09-09-crolli-opzioni-monete.md`. Domanda dell'operatore: *"guadagnare anche nei crolli (opzioni se
serve), piu' monete ma sempre in Deribit"*. **(1) Il libro nei crolli**: perde nel GIORNO (0,48%/g su 160
giorni ≤ 5%, positivo nel 18%), immune-o-positivo per finestra (4 POSITIVO / 6 immune / 2 PERDE su 12), **> 0
in 8/8 peggiori finestre dal 2022** per la gamba short di SKH01 (108% dei guadagni, scomposizione esatta,
marcata all'uscita). Beta 0,0769 sulla finestra di §46 (riprodotto). Peggior giorno canonico 2025-10-10
3,38%: crollo con TP01 a 0,505 (lo squeeze di §33 e' sulla lente hourly). 37,5/62,5 batte 12/12 episodi
ma maxDD 12,1% vs 9,4%; gate fallisce in-sample (cache `skh_sigtab` ferma al 25/07). **(2) Crollo
catturato**: l'archivio bite copre con bid/ask il 1-5/06/2026 (BTC 25%, ETH 30%), scadenza 19/06, inverse.
ETH pre-crollo (4 strutture): f_net **0,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.
+15
View File
@@ -765,3 +765,18 @@ veicolo di RENDITA**, dove serve **2,5× meno capitale** ($254k contro $646k) pe
vive sul drawdown. La scelta del veicolo dipende da quale dei due è l'obiettivo (N6).
⚠️ E il fisco favorisce l'ETF: UCITS ad accumulazione paga **26% alla vendita**, il book **33%
ogni anno**. Il differimento è un vantaggio strutturale reale.
- 💰 **SOLDI FERMI — «altri modi, non per forza trading» (2026-09-01/02).** `r0901d_soldi_fermi.py`,
diario `2026-09-01d-soldi-fermi.md`. **On-venue chiuso con l'ELENCO INTERO:** `get_currencies`
remunera **4 valute su 50** (USDE 4,32 · USDC 3,40 · BUIDL 3,20 · STETH 2,22) = le quattro gia'
chiuse il 31/08 — non era un campione. ⚠️ **L'USDC su Deribit non e' fermo**: e' la base di sizing
(cap = equity x 0,5) e il cuscino del disaster-SL; spostarlo rimpicciolisce il libro. Il capitale
fermo vero sta **fuori**: ~€6.043 in XEON (27/07), che e' lo **split-cassa**. Tassi dal web
(BOT 2,768% asta 08/2026 · DFR 2,25% · conti deposito 3,25-3,50% · sUSDe ~9% Q2 2026), fisco dalla
memoria (12,5 / 26 / 33%). **In euro netti su €6k:** XEON 81 · BOT **133** · conto deposito
**143** · sUSDe 350 ma **stesso emittente dell'USDE gia' in conto: split distrutto** · libro
~600 atteso ma **trading, split distrutto**. **Il miglior guadagno sicuro vale +€62/anno = €5/mese**,
contro €100/mese di bonifico = +4,07%/anno di drift. ✅ **L'operatore ha scelto di versare** (02/09),
che e' il piano del 27/07: **€5.000 dentro, ~€1.000 fuori** (la protezione satura a qualunque
quota > 0), il «~€600» letto come attesa e non come tasso, fondo d'emergenza da dichiarare. Nessuna
azione di config: il cap e' dinamico. **In attesa di importo e data.**
+107
View File
@@ -692,6 +692,27 @@ e i vincoli di deploy (PRIIPs/UCITS/broker).
📌 **IL DB** (`data/live/trades.db`, dentro il perimetro del backup): `fills` col contesto del
segnale a quel giro (tp_frac, skh_sign, entry, target, posizione, equity), `roundtrips`
**derivati e ricalcolati da zero**, `equity` oraria, `journal`. Sync **orario** in `cron_book`.
📌 **IL RENDIMENTO DEL LIBRO E' UN TWR, NON `e1/e0-1`** (debito 14, trovato il 01/09 e chiuso il
02/09/2026). `--report` stampava «+243%» su una serie che conteneva **un solo salto**, il
versamento di $1.399,39 del 25/08: il 96,3% del numero era un bonifico. La riparazione
(`movimenti_capitale`, che classifica ogni salto oltre `EQUITY_JUMP_ALERT` come *movimento* se
supera 2× il massimo che il mercato misurato poteva fare a tetto di leva, altrimenti *ambiguo*)
viveva gia' nel giornale e **non era arrivata al secondo lettore della stessa serie** — una
variante di P1 che non e' un sorvegliante che ridichiara, ma una riparazione che non si propaga.
Ora `journal.rendimento_twr` e' la funzione unica: spezza la serie all'equity PRIMA e DOPO ogni
movimento certo e moltiplica i segmenti (gli ambigui non spezzano: restano nel rendimento,
dichiarati accanto — P12). Il report stampa TWR, segmenti con le date, movimenti elencati,
trading al netto, e il delta $ grezzo solo etichettato «movimenti INCLUSI». Verifica M23: la
macchina riproduce il +10,80% del diario 01/09 sui suoi quattro punti. **Due limiti trovati in revisione (02/09):** l'intervallo che contiene un movimento certo esce INTERO dal rendimento (il suo P&L di mercato va in `certi`; errore massimo META' del movimento, per costruzione; il 25/08 ~$0,5) e a base di equity zero `twr` e `trading` sono entrambi None, perche' il salto 0→X del primo versamento e' invisibile al classificatore. I segmenti a lunghezza zero (movimento nel primo intervallo, o due consecutivi) non si stampano. **Seconda tornata di revisione, stesso giorno:** il classificatore era **cieco ~23 ore al giorno** — il
feed 1h si ferma alle 00:00, `asof` dava la stessa barra alle due letture, mercato «fermo» = 0, e
qualunque calo ≥10% del giorno sarebbe stato un movimento (un crash stampato come prelievo, dal
25/08); con feed assente tutto era «ambiguo» e il report stampava `e1/e0-1` sotto l'etichetta TWR.
Ora «mercato non misurabile» è uno stato: `ambiguo` con la ragione, `twr`/`trading` None con motivo.
Il «limite +10% a mercato fermo» scritto la mattina era un artefatto della fixture piatta, cancellato.
`pnl_giorno` chiama `rendimento_twr` (la frase «entrambi la chiamano» è vera solo da questa tornata),
la pagina stampa il TWR, la testata Telegram qualifica il cumulato «di cui versati», e il bound di
leva include la scala e il tetto di codice. Resta aperto (§5.15): il bound guarda solo BTC/ETH, e il
31% dell'equity è USDE — un depeg pieno sarebbe scorporato come prelievo.
**Le tre fonti si INCROCIANO e non si sovrascrivono:** log (ora vera) x jsonl (i fill) x venue
(autorevole ma **TRONCA** — 1 trade su BTC, 0 su ETH). `reconcile()` riporta le divergenze e
**non ripara niente da solo**: fra due fonti che non concordano, una riparazione silenziosa e'
@@ -901,6 +922,56 @@ Sulle 4 finestre osservate 3 sono rientrate entro l'ora → il `:47` ne avrebbe
**Se al prossimo martedì il `:47` becca comunque la manutenzione, la previsione è sbagliata** e lo
slot non è quello descritto in `venue_probe.RELEASE_*`: rileggerlo prima di spostare ancora.
### La cadenza è ORARIA, e ora lo dicono tutte e tre le fonti (debito §5.7, chiuso il 2026-09-02)
Il docstring di `book_execute.py` prescriveva «ogni ~230 minuti» (la griglia di SKH01) mentre il cron
gira ogni ora. Non era cosmetico: `r0823_sl_anchor.py` misura che l'accumulo di scatti del disaster-SL
rotolante è funzione della **cadenza del cron** (1h: BTC 0 / ETH 1 in 7-8 anni · 4h: BTC 2 · 24h: ETH 5
con 2 in 30 giorni), quindi chi avesse "corretto" il CRON verso il docstring avrebbe spostato il libro
dalla riga 1h (rotolante: BTC 0 / ETH 1) alla riga 4h (BTC 2 contro 1 del pavimento; la peggiore misurata è 24h, ETH 5). La riparazione è il docstring stesso — cadenza ORARIA, la ragione (giro
idempotente: girare più fitto della griglia non costa ordini, e compra latenza ≤1h per gli
ingressi/uscite software di SKH01 e un **controllo** orario del rotolante — che si ri-ancora solo oltre la tolleranza di `ensure_disaster_sl`, mark +5,263%/4,762% o taglia >10%, quindi lo stop siede fra 33,5% e 26,5% dal mark corrente: la prima stesura diceva «si ri-ancora ogni ora» ed era sbagliata, corretta in revisione lo stesso giorno) e il divieto scritto —
più `tests/test_book_cadenza.py`, che **deriva** (P1) le tre dichiarazioni e le confronta: la riga
citata nel docstring, quella dichiarata nell'intestazione di `cron_book.sh` (`47 * * * *`) e la
crontab **installata** (`crontab -l`; se non è leggibile il test è SALTATO, non verde — P5). Il
parser accetta solo espressioni regolari e rifiuta il resto. Vincolo del minuto tondo verificato
(≠ `:00`). La regola generale è la stessa del `:47`: **una cadenza si difende nella sua ragione, e
un commento che dichiara una cadenza diversa da quella che gira si paga quando qualcuno lo "corregge"
nel verso sbagliato.**
### Il README ha pubblicato per 75 giorni la libreria che il progetto aveva dichiarato falsa
Ultimo commit del README: **2026-06-04**. Reset v2.0.0: **2026-06-19**. In mezzo, quindici giorni; dopo,
75 in cui la prima pagina del repository descriveva FADE/HONEST/PAIRS/TSMOM/SHAPE, i portafogli PORT01-06,
il paper trader su `strategies.yml` e l'esecuzione shadow su testnet — con Sharpe 7,84/10,06 e CAGR ~79%
presentati come risultati correnti. Metà dei file citati non esisteva più (`strategies.yml`,
`portfolios.yml`, `scripts/waste/`, `scripts/portfolios/`, `src/live/multi_runner.py`); i numeri erano
esattamente quelli che `CLAUDE.md` §2 elenca sotto «non citare», e provenivano dalla libreria che il
reset aveva dichiarato artefatto di un feed contaminato.
Nessun danno sui soldi — il README non decide niente. Il danno è di **citazione**, ed è la forma peggiore:
un lettore esterno (o un agente nuovo) parte da lì. **La lezione non è "tenere aggiornato il README"**,
che nessuno fa più di quanto già non faccia: è che **un reset invalida anche i documenti che nessuno
rilegge**, e l'inventario di cosa cita numeri morti va fatto il giorno del reset, quando si sa che sono
morti — non 75 giorni dopo, quando qualcuno ci inciampa per caso. Riscritto il 2026-09-02 contro lo stato
vero, con ogni riferimento a file verificato e i numeri nella lente di §2.
### Un flag sconosciuto non è l'azione di default (2026-09-02)
Nessuno dei 18 script di `scripts/live/` usava argparse: leggevano `sys.argv` con `in` e `index`,
quindi un flag sbagliato non era un errore — era il ramo `else`. Il costo si è misurato lo stesso
giorno, dentro una revisione: `trades_db.py --help` è caduto in `sync()` e ha riscritto
`meta.ultimo_sync`. Sui due script che scrivono davvero sarebbe stato peggio: la pagina di giornale
e una riga di DB, oppure una chiamata al modello più un Telegram. **La riparazione non è argparse**
cambierebbe messaggi d'errore, codici d'uscita e `--help` di script che il cron già chiama, cioè
romperebbe ciò che gira per riparare ciò che non gira mai. È una funzione di venti righe
(`src/live/cli.valida`) che rifiuta ciò che non è dichiarato ed esce **prima di qualunque effetto**:
`--help` → 0, flag ignoto → 2 con l'elenco dei previsti (P4). Il test deriva l'elenco degli script
dalla cartella (P1: un file nuovo senza guardia fallisce, non passa inosservato), controlla che
`valida` sia la prima istruzione di `__main__` (uscire dopo `connect()` sarebbe uscire dopo
l'effetto) e — l'altra metà del contratto, P15/P16 — che **i flag che il cron usa davvero restino
accettati**: una guardia che ferma il libro di bordo la notte stessa sarebbe peggio del difetto.
### Cosa resta scoperto
1. **La sonda dice di chi è il guasto, non lo aggira.** Col gateway giù il libro continua ad
@@ -940,3 +1011,39 @@ impedire.** Danno reale quel giorno: nessuno — alle 09:47 l'equity era leggibi
che si accende se la fixture viene rimossa.
- ⚠️ **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).
### 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
stesura confrontava "tutto" con "ultimi 7g" e diluiva un guasto di 2 giorni al 13.5%, commettendo
a 7 giorni lo stesso errore che dichiarava di evitare a 3 mesi. Diario `2026-07-30-vrp-quote-reali.md`.
- 🚨 **IL FEED 1h E' ETICHETTATO ALL'APERTURA, e `asof(ts)` guarda un'ora avanti (trovato in revisione
il 2026-09-09).** La barra 1h etichettata 20:00 chiude col 5m delle 20:55 (verificato: close 1h 20:00 =
close 5m 20:55 = 1603,5, mentre il 5m 19:55 fa 1572,95). Quindi `S.asof(ts)` su una serie indicizzata
con quell'etichetta restituisce la chiusura di **ts+1h**: un look-ahead di un'ora senza eccezioni e
senza NaN (D6, quarta occorrenza). Colpisce `cblib.spot_series` — cioe' il f 0,714 di §11 e ogni numero
di `cblib` con lo spot, compreso `ST = S.asof(exp)` che e' il prezzo di un'ora DOPO la scadenza.
**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).
+259
View File
@@ -17,6 +17,9 @@ null de-levering superato + eseguibilita' al capitale dichiarato.
| 8 | VOL-SIZE | **LEAD RIDIMENSIONATO** — gate 22/12 **gia' fallito oggi** | dare a SKH01 una size per-trade regge a **23/23 ancore**, 8/8 anni, null di permutazione e trasferimento su V1 — ma vale **+0,07 di Sharpe di libro, un terzo della fortuna d'ancora del libro stesso (+0,196)**. Il vol-target di **libro** e' invece falsificato: compra peso SKH gia' respinto e peggiora l'eseguibilita' |
| — | **XSR-REPRO** (integrita') | 🚨 **DIFETTO DI PRODUZIONE** | il numero 1.82 e' SPIEGATO e non era sbagliato (era su una **terza** lente, e su una barra non ancora chiusa) — ma cercandone la causa e' emerso che **`paper_xsr` registra ~41 minuti di mercato al giorno**, non un giorno. Tre gate pre-registrati leggono serie costruite cosi' |
| 70 | **XSR-RENDITA** | **SCARTATO sotto la lente RENDITA** (non come sleeve) | il muro scende del 20,6% a iso-rischio, ma **mescolare i rendimenti di XSR01 da' lo stesso muro** (il meccanismo vale 0,8%, dentro la risoluzione MC) e un **conto remunerato allo stesso tasso lo eguaglia a vol 0 e senza secondo venue**. A drift zero il muro **sale**: si compra un drift scorrelato, non la scorrelazione. E l'haircut non pareggia un conto al 4% **nemmeno a haircut ZERO** |
| 71 | **COLLAR01** | hold BTC gated dal trend, coperto da un collar (pavimento comprato, tetto venduto) a scadenza <=15g: "ridurre la vincita, bloccare la perdita" batte il de-levering? | **REFUTATO** — ✅ il pavimento FUNZIONA (maxDD scende in **36/48**, §46 battuto sul suo motivo: qui beta=1,0) ❌ ma costa **2-8 punti di drift per punto di DD** e il de-levering vince **45/48**. 🚨 Le 3 celle vincenti stanno sul **bordo**: estesa la famiglia vince 35/36 **nell'angolo covered-call**, e **il 72-100% di quell'edge e' il PREMIO DI VARIANZA** (DVOL sta **32% sopra** la RV-forward): riprezzato alla vol vera l'angolo cade da Sharpe **1,471 a 0,511** — che e' VRP01 (0,47), non una scoperta |
| 72 | **PAVIMENTO-LEVA** | il pavimento (sola put, ≤15g) come LICENZA DI TAGLIA a iso-DD — l'inverso di §71 | **REFUTATO 0/16**, e **corregge §71**: la put da sola a premio reale **peggiora il maxDD in 16/16** (FORTE 51,8% → 55-76%; LARGO 71,9% → 75-82%) — nel collar il DD lo riduceva il **TETTO**, non il pavimento. Meccanismo (B2): **i drawdown di BTC sono grind di 290-818 giorni**, la put copre una finestra e para nel 0-9% dei cicli. Pareggio del DD solo a **36-79% del premio reale** (prezzo equo ≈76%: non basta). **La licenza del disaster-SL e' di carta** (B3): sostituire `sl` con la distanza del pavimento darebbe 2,28x, ma su 8 finestre la perdita e' 33,6% → **77% dell'equity**. Gated su IV sottile: migliora (+3,8 → +8,4%) ma **perde** ancora |
| 73 | **SOLDI-FERMI** | altri modi per far rendere il capitale fermo, non per forza trading | **on-venue CHIUSO con l'elenco intero** (4/50 valute = le 4 del 31/08); l'USDC sul conto non e' fermo (sizing + cuscino); fuori, su €6k netti/anno: XEON 81 · **BOT 133** · **conto deposito 143** · sUSDe 350 (**stesso emittente dell'USDE: split distrutto**) · libro ~600 (trading). Miglior guadagno sicuro **+€62/anno**. ✅ L'operatore ha scelto di **versare** (piano 27/07: €5k dentro, ~€1k fuori) |
| 5 | SKEW | **SCARTATO** (Q1, Q2) + **LEAD** (Q3, gate 2027-02-22) | il prezzo muove lo skew (t 3,1-9,4 su 8/8 test), **non il contrario** (max |t| in avanti 2,35 contro 2,08 atteso dal rumore). Ma Q3 e' grosso: **il f=0,73 di VRP01 e' per il 42% STRUTTURA A TERMINE e solo per il 25% skew** |
| — | **HL-EXEC** (audit di fatto) | **3 falsificazioni misurate** | il pavimento vero e' **$10 (non $5)** e il taker **4,50 bps (non 5,0)** — ma il *"XS01 serve ~$20k"* e' **refutato del tutto** (nessuna soglia da min-order), il *"XSR01 ~$5.000"* e' **conservativo di 1,7x** (vero ~$3.000), e lo **slippage "rischio #1" di XSR01 e' refutato** con margine **21x** |
| — | **SLIP-AUDIT** (audit di fatto) | **SCARTATO** = nessun costo nascosto a questa taglia | i backtest a 10 bps RT restano **conservativi di ~1,5 bps/lato**; nessuna evidenza di impatto sopravvive al null (p=0,274; estremi avversi **1/18 contro 4,7 attesi**). **Ma la misura ha una data di scadenza** |
@@ -194,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%.
- ✅ **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**.
**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
**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
@@ -371,6 +386,13 @@ nuovo: **va decisa PRIMA del 23/10, non quel giorno.**
(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
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`
osservato compare un **falso lead** a +15m (0,102 contro 0,031) — *"e' l'errore che avrei
pubblicato senza quel controllo"*.
@@ -3799,3 +3821,240 @@ rendita non e' lo strumento**, e che il gate del 23/10 sta per decidere su una g
capitale **non e' comprabile**.
**Con questa sono 70 filoni e 0 candidati promossi.**
---
## 71 — COLLAR01 (hold BTC gated dal trend, coperto da un collar a <=15 giorni)
`scripts/research/r0901_btc_collar.py`. **Libro, pesi, cron, config INVARIATI. Nessun ordine.**
Domanda dell'operatore, con tre precisazioni in corso d'opera che ne cambiano l'oggetto: *"hold BTC
long o short con copertura in opzioni"* + *"opzione max 15gg"* + *"ridurre la vincita, ma bloccare
la perdita"* (⇒ **collar**, non put protettiva) + *"l'entrata gestita da indicatori (es. forte bull)"*.
🚨 **PERCHE' SI POTEVA RIAPRIRE DOPO §46.** §46 fu refutato perche' **il beta del libro al
sottostante e' +0,076** — *"non si assicura un libro che nei crash e' gia' quasi piatto"*. Qui il
sottostante e' un **hold di BTC nudo, beta 1,0**: il motivo non si applica. E §46 dichiara di non
aver provato la copertura **gated su regime**, che e' esattamente questa.
**L'entrata non aggiunge parametri:** `tsmom_blend` media tre `np.sign()` su (30, 90, 180) ⇒ valori
in {1, 1/3, +1/3, +1}, quindi *"forte bull"* = `|blend|==1` (3/3 orizzonti, a mercato **52,5%**
dei giorni) contro il confronto dichiarato `|blend|>=1/3` (**97,1%**). Il segno da' la direzione:
long **e** short, come chiesto.
**CALIBRAZIONE dalla catena vera** (161.964 quote a due lati, 95 giorni, DTE 1-15). **Lo skew e' il
fatto strutturale:** IV_put/ATM contro IV_call/ATM = **1,249 vs 0,959** a |δ|0,10 · 1,132 vs 0,950 a
0,20 · 1,072 vs 0,960 a 0,30 ⇒ **un collar delta-simmetrico e' un DEBITO netto**, si compra l'ala
cara e si vende quella a buon mercato. 📌 **Corregge un muro di §46:** il **tick da 5 USDC** e' della
famiglia **USDC**; la catena che raccogliamo e' **100% inverse****quel muro non si applica qui**.
| esito | misura |
|---|---|
| ✅ **A1 — il COLLAR riduce il maxDD** | scende in **36/48** celle (§46: saliva in 162/162). 🚨 **Corretto da §72 lo stesso giorno: NON e' il pavimento** — la put da sola, a premio reale, **peggiora il DD in 16/16**; la riduzione viene dal TETTO (compensa il bleed e abbassa il picco) |
| ❌ **A3 — ma il de-levering lo fa meglio** | il collar batte il null a iso-maxDD in **3/48** |
| ❌ **A2 — il prezzo del baratto** | Δdrift/ΔmaxDD mediano **7,90** (gate forte) / **1,98** (largo) |
| 🚨 **C9 — troncatura, non protezione** | il tetto taglia il **46,2%** dei cicli **vincenti**, il pavimento para il **6,7%** dei **perdenti**: 7x piu' spesso sui vincenti |
| ❌ **M1 — dentro il libro non aggiunge** | collar Sharpe 0,508 vs TP01 **0,852**; TP01+10% ⇒ Sharpe **+0,000** e maxDD **+2,32pp** |
🚨 **IL FATTO CHE VALE PIU' DEL VERDETTO — e che si vede solo estendendo la griglia (M4/M8).** Tutte
e 3 le celle vincenti stanno sul **BORDO** (δput min, δcall max). Estesa la famiglia con 36 trial
dichiarati verso l'angolo: **vince 35/36**, massimo **nell'angolo** (FORTE 7g, δput 0,02 / δcall
0,50) = Sharpe **1,471**, maxDD 18,12%, drift **+29,99%/a**. Il limite di quell'angolo e' *nessun
pavimento, tetto ATM* = **una covered call**: **la pendenza porta FUORI da cio' che l'operatore ha
chiesto e DENTRO lo short-vol.**
🚨 **E quel Sharpe e' il prezzatore che si paga da solo.** **DVOL / RV-forward mediana 1,320 a 7g**
(DVOL sta sopra nel **76,9%** dei giorni), 1,253 a 14g. Riprezzando alla vol **realizzata**
(look-ahead dichiarato, valore equo ex-post): l'angolo cade **1,471 -> 0,511** di Sharpe (VRP
**+21,54pp = 72%** del drift) col gate FORTE, e **1,141 -> 0,114** col LARGO (VRP **+33,26pp = tutto**;
drift **1,10%**). Le celle con un pavimento VERO (δ0,10/0,30) passano da VINCE a **perde**.
📌 **Cio' che sopravvive — Sharpe 0,511 — e' il numero che il progetto GIA' HA:** VRP01 a f=0,73 vale
ShFULL **0,47**. *Non e' una strategia nuova: e' VRP01 ri-scoperto per una strada piu' lunga*, e §3
lo blocca comunque (**"niente short-vol da modello in deploy"**).
🚨 **USCITA ANTICIPATA (chiesta dall'operatore: 50-75% del tempo) — IMPLEMENTATA, E COSTA.**
`exit_frac` su tenor 14g (uscita a 7/9/10 giorni). Cella onesta δ0,10/0,30 gate FORTE: drift
**+9,48% (scadenza) -> +8,59% (0,75) -> +6,11% (0,625) -> +3,84% (0,50)**, e l'esito passa da VINCE a
**perde sotto 0,75**; gate LARGO **12,06% -> 1,88%**. ⚠️ **Il meccanismo previsto c'e'** — il VRP
residuo scende da +3,38 a **+1,27pp**, quindi uscire presto rinuncia davvero allo short-vol — **ma lo
spread lo travolge**. 📌 **E CORREGGE L'APPLICAZIONE DI §46:** *"f si paga solo sulla parte di valore
che converge a intrinseco, quindi un roll anticipato non lo paga"* vale per una copertura **SOLO
LONG**; in un collar c'e' una **gamba VENDUTA da RICOMPRARE**, e uscire prima paga f *esattamente*
sulla parte che a scadenza si sarebbe regolata gratis, ~2x piu' spesso per unita' di tempo.
**L'asimmetria di §46 si INVERTE quando la struttura ha una gamba corta.**
**CONTROLLI DELL'APPARATO 3/3** (M15, la lezione di §46): pavimento a **premio zero** riconosciuto
come vittoria (maxDD 51,83%→**36,76%**, drift +10,04%→**+35,69%**); premio **x10** rifiutato (drift
**33,96%/a**); zero-cost costruibile e finito.
⚠️ **CINQUE DIFETTI MIEI, tutti catturati dai controlli e non a occhio.** (i) **bisezione dello
zero-cost invertita** — il premio cresce col delta, quindi a incasso insufficiente il tetto va
*avvicinato*: dava Sharpe 3,0/3,9 e drift 53%/a; (ii) `dcall=NaN` propagava NaN nella cassa allo
smontaggio anticipato; (iii) **C9 non consapevole della direzione**`S1>S0` non e' "vincente" per
un ciclo **short**: era la causa dell'impossibile *"il pavimento para nello 0,0% dei perdenti"* con
una put a 10 delta; (iv) la base senza opzioni **rollava lo spot** ogni tenor pagando ~3,6%/a di fee
inesistenti (**avrebbe adulato il collar**); (v) al roll delle opzioni chiudevo e riaprivo **anche lo
spot**, che non ha motivo di muoversi.
⚠️ **NON MISURATO, DICHIARATO:** la **lente reale** (quote vere, 123 giorni = ~8 cicli) non e' stata
girata come backtest — sotto-potenziata, e su una finestra in cui BTC e' salito da ~64,7k a ~77,5k,
**avversa a un collar per costruzione**; la catena e' servita a **calibrare**, dove 161.964 quote
hanno potenza. Nessun DSR e nessun `study_family_honest`: il filone cade al **primo** gate (M5), e M2
si spende su cio' che il primo gate lascia in piedi. A6/A7 restano previsioni non misurate. Il
**funding** non e' nel motore (2,16%/a) ma colpisce base e collar quasi allo stesso modo: abbassa
entrambi, **non cambia il segno**.
**VERDETTO: `IL PAVIMENTO FUNZIONA — E' IL TETTO CHE NON SI PUO' PAGARE; E CIO' CHE VINCE SUL BORDO
NON E' LA PROTEZIONE, E' IL PREMIO DI VARIANZA (VRP01)`** — REFUTATO.
**Cosa lo riapre:** un **f di stress misurato su un crash catturato** (la stessa condizione che §3
pone allo short-vol), o un sottostante **senza coda destra grassa** — il tetto costa perche' BTC vive
li'. **Non** lo riaprono tenor, delta o griglie piu' fini: la pendenza e' monotona verso l'angolo, e
l'angolo e' gia' misurato.
---
## 72 — PAVIMENTO-LEVA (il pavimento come licenza di taglia)
`scripts/research/r0901c_pavimento_leva.py` (riusa il motore di §71). **Libro, pesi, cron, config
INVARIATI. Nessun ordine.** Domanda dell'operatore: *"possiamo usare quanto conosciamo del pavimento
per studiare una strategia?"*. L'inversione di §71: non *quanto DD mi risparmia* ma *quanta TAGLIA
mi autorizza a iso-DD* — l'unica cosa che il de-levering non puo' comprare, e la grandezza su cui
vive il tetto di leva del progetto (`n · frac · scala · sl ≤ 0,50` ⇒ 1,67x).
**0/16.** Sola put a premio reale, 4 δ × 2 tenor × 2 gate: **il maxDD SALE in 16/16 celle**
(FORTE 51,83% → 55,4-75,8%, LARGO 71,94% → 74,8-81,7%), quindi `k_f = 1,000` ovunque e non c'e' taglia
da licenziare. Monotono: piu' la put e' vicina, peggio va — **il bleed del premio E' il drawdown**.
🚨 **CORREGGE §71.** Stamattina A1 diceva *"il pavimento funziona"*. **E' il COLLAR a ridurre il DD,
non il pavimento**: a premio zero la put lo riduce (51,83 → 36,76), a premio reale lo peggiora ⇒
tutto il beneficio e' mangiato dal premio, e oltre. Nel collar la riduzione viene dal **tetto** (il
suo premio compensa il bleed, e cappare l'upside **abbassa il picco** da cui il DD si misura).
**Premio di pareggio: 36-79% del reale** (secondo la cella); il DVOL sta 1,32× sopra la RV, quindi
un prezzo equo (~76%) sfiora il pareggio nelle celle migliori e lo manca nelle altre. *Il motivo di
§46 (beta) non si applica a beta 1,0 — la put paga davvero — ma il verdetto di §46, il maxDD SALE,
si riproduce per un motivo diverso.* Corretti §71, il suo diario e la memoria.
📌 **B2, IL MECCANISMO — e' il risultato trasferibile:** **i drawdown di BTC sono GRIND**, non
crolli. maxDD FORTE da 2021-07-20 a 2022-05-06 (**290 giorni**), LARGO fino al 2023-10-16 (**818**).
Una put a 7-14g copre UNA finestra; il DD che conta dura 20-60 finestre; la put scade OTM ogni
settimana (para nel **0-9%** dei cicli, gate FORTE) mentre il premio sanguina. E non protegge
nemmeno la finestra peggiore (14g FORTE: 22,9% nudo → **23,6%** col pavimento: la put a 5δ sta a
~22% dallo spot, al bordo esatto di cio' che e' successo). **Su BTC il pavimento compra protezione
contro la cosa sbagliata.**
🚨 **B3 — LA LICENZA DEL DISASTER-SL E' DI CARTA, e va scritto prima che sia comodo.** Con la put a
5δ/14g a **21,9%** dallo spot, sostituire `sl` con quella distanza nell'invariante darebbe **2,28x**
(1,4× in piu'). Ma l'invariante limita UN episodio, la put limita UNA finestra, e il massimo su
finestre consecutive **non e' limitato da nulla**: 1 finestra 23,6% (coperta), 2 25,9%, 4 28,8%,
**8 finestre 33,6%** (non coperte). A 2,28x un grind di 112 giorni costerebbe il **77%
dell'equity**. ⇒ **`disaster_sl_pct` NON si sostituisce con la distanza di un pavimento.**
**B4 — gated sull'IV** (§46: *"nessuna copertura dinamica e' stata provata"* — ora lo e'): put ON solo
sotto il 25°/50° pctl di DVOL/RV. Migliora molto (FORTE +3,8% → **+8,4%**; LARGO +9,9% → +15,3%),
**ma resta sotto la base** (+10,04% / +17,37%) e il DD resta ≥. L'incollatura ignora lo spread delle
transizioni **a favore** del gated.
⚠️ **Non misurato, dichiarato:** nessun DSR (cade al primo gate); nessuna lente reale; **nessun
pavimento a scadenza lunga** (30-90g, che coprirebbe piu' finestre di grind) perche' l'operatore ha
vincolato la scadenza a **≤15 giorni** — e' l'unica variante che B2 lascia aperta, e sta fuori dal
vincolo. Se il vincolo cadesse, sarebbe la prima misura da fare.
**VERDETTO: `IL PAVIMENTO NON LICENZIA TAGLIA: 0/16 — SU BTC COMPRA PROTEZIONE CONTRO I CROLLI, E
LE PERDITE SONO GRIND`** — REFUTATO. **Cosa lo riapre:** una scadenza che copra il grind (fuori dal
vincolo), o un premio di varianza *negativo* su BTC (mai misurato). Non lo riaprono delta, tenor ≤15
o gate sull'IV.
---
## 73 — SOLDI-FERMI (altri modi, non per forza trading)
`scripts/research/r0901d_soldi_fermi.py`, diario `2026-09-01d-soldi-fermi.md`. **Nessun ordine.**
*Non sono pareri fiscali.* Tassi dal web con fonte e data (cutoff del modello 05/2026).
**On-venue chiuso con l'ELENCO INTERO:** `public/get_currencies` remunera **4 valute su 50**
USDE 4,32 · USDC 3,40 · BUIDL 3,20 · STETH 2,22 — esattamente le quattro chiuse il 31/08 (al tetto ·
Italia esclusa · non acquistabile · e' ETH). ⚠️ **L'USDC su Deribit non e' fermo:** base di sizing
(cap = equity x 0,5) e cuscino del disaster-SL. Il fermo vero sta **fuori**: ~€6.043 in XEON, lo
**split-cassa** (P(perso tutto) 18% → 3,5%).
**In euro netti l'anno su €6.000** (BOT 2,768% asta 08/2026 · DFR 2,25% · conti deposito 3,25-3,50% ·
sUSDe ~9%; fisco 12,5 / 26 / 33%; bollo 0,2%): XEON **81** · BOT **133** · conto deposito **143** ·
sUSDe **350** ma **stesso emittente dell'USDE gia' in conto** ⇒ split distrutto · libro **~600 atteso**
ma trading ⇒ split distrutto. **Il miglior guadagno sicuro e' +€62/anno = €5/mese**, contro €100/mese
di bonifico = +4,07%/anno di drift. BOT e conto deposito distano €10/anno: scelta di liquidita' e
controparte, non di rendimento.
**Decisione dell'operatore (02/09): versare sul libro.** E' il piano del 27/07. Tre precisazioni,
tutte gia' misurate: **€5.000 dentro, ~€1.000 fuori** (la protezione venue **satura a qualunque
quota > 0**: l'ultimo migliaio compra ~€100/anno e butta via l'assicurazione); il «~€600» e' un'attesa
a banda larga, non un tasso (libro a mercato il 22% dei giorni, TWR +10,8% in 70g, hold-out TP01
~+0,05, fisco 30% a 10a); fondo d'emergenza da dichiarare. Operativamente nulla: cap dinamico,
rilevatore di salti collaudato il 25/08. **In attesa di importo e data.**
---
## 74 — LIBRO-NEI-CROLLI (cosa fanno TP01 e SKH01 nei crolli, misurato invece che ricordato)
`scripts/research/r0909_libro_nei_crolli.py`, diario `2026-09-09-crolli-opzioni-monete.md`. **Libro,
pesi, config, cron INVARIATI.** Lente: sleeve di ricerca, ancora canonica, pesi importati da
`src/live/book` (P1); ⚠️ SKH01 marcato all'USCITA del trade (zero nell'87,7% dei giorni). Indice 50/50;
giorno di crollo ≤ 5%; 12 peggiori finestre di 20 g non sovrapposte + 9 episodi nominati prima.
**Nel GIORNO del crollo il libro PERDE** (0,48%/g su 160 giorni, positivo nel 18%; ≤ 10%: 0,51%/g con
SKH01 +0,42). **Per finestra**: 4 POSITIVO / 6 immune / 2 PERDE; libro > 0 in 8/12 e **in 8/8 dal 2022**
(mediana +3,3% contro indice 29%); le due perdite (2019, maggio 2021) sono TP01 long dentro un trend
non girato. **La gamba short di SKH01 fa il 108% dei guadagni** (scomposizione ESATTA long+short==intera,
assert). Beta **+0,0769** sulla finestra di §46 (riprodotto al millesimo). Peggior giorno canonico
2025-10-10 3,38%: un crollo con TP01 a 0,505 (lo squeeze di §33 sta sulla lente hourly, non si riproduce
qui). 37,5/62,5 (§26) batte 12/12 episodi, Sharpe 1,81 vs 1,82, maxDD 12,1% vs 9,4%;
`r0726_reeval_live_weight` da' `gate_pass False` su `delta_insample 0,0026` (cache segnali ferma al
25/07: stesse cifre del 26/07, e la condizione che fallisce e' in-sample).
🚨 **Errore dell'autore smontato in revisione:** la prima stesura provava GUADAGNA prima di immune e
stampava **8/12 GUADAGNA**; nell'ordine dichiarato sono 4+6. *L'ordine delle regole era il verdetto.*
**VERDETTO: `PERDE NEL GIORNO, POSITIVO PER FINESTRA DAL 2022, TUTTO SKH01 SHORT`** — nessun meccanismo
nuovo: quello che guadagna nei crolli e' gia' in produzione, e la leva che lo aumenta (il peso) e' chiusa dal gate.
## 75 — CROLLO-CATTURATO (il f delle opzioni su un crollo a quote vere)
`scripts/research/r0909_crash_catturato.py`. **Nessun ordine.** L'archivio bite copre con bid/ask il crollo
1-5/06/2026 (BTC 25%, ETH 30%; scadenza 19/06, 14-21 DTE, famiglia INVERSE); il campione di §11 partiva
dal fondo. ETH, ingressi pre-crollo (286 snapshot = **4 strutture**): f_net **0,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.
+12 -2
View File
@@ -1,8 +1,18 @@
# SPEC — la CHIAVE DI SCALA del libro live (`book_scale_k`)
**Filone SCALE-SPEC, ondata 2026-08-22, branch `research/wave-0822`.**
**Questo documento e' una SPECIFICA. Non e' stata implementata: `src/`, `config/`, `scripts/live/`,
`scripts/cron_*.sh` e `tests/` non sono stati toccati.** Il prototipo che dimostra la meccanica e'
> ✅ **IMPLEMENTATA IL 2026-09-01 — punti 1-4 della checklist §8.** `src/live/book.py`
> (`book_scale_k`, `_scala`, `LEVA_LORDA_MAX`, `SCALA_LADDER`, `DISASTER_SL_BUDGET`,
> `ScalaNonAutorizzata`, `scala` nel report), `scripts/live/book_execute.py` (riga «scala libro» +
> stop-e-allerta), `src/live/scale_watch.py` + `scripts/live/scale_watch.py` in `cron_daily.sh`,
> `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,
> vale 1,00, e T7 dimostra bit-exact che il libro e' quello di sempre (`max|diff| = 0.0`).
> **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
> sorvegliante e' rotto mentre la leva e' ancora 1,00.
**Questo documento era una SPECIFICA scritta prima dell'implementazione.** Il prototipo che dimostra la meccanica e'
`scripts/research/r0822e_scale_proto.py` (isolato, non importato da niente, non scrive nulla; la
sua sezione (0) verifica entrambe le cose a runtime invece di dichiararle).
+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 | |
+1 -1
View File
@@ -8,7 +8,7 @@
# MINUTO :25, e la scelta NON e' arbitraria:
# :00 cerbero-bite sparava la sua spazzata full-chain (~44 chiamate/s) e saturava il rate limit
# Deribit per-IP — 12.186 risposte 429 in 26 ore, il 96% nel minuto tondo.
# :07 cron_book.sh (esecuzione del book live). Il feed 5m di SKH01 passa da li': e' la cosa che
# :47 cron_book.sh (esecuzione del book live; era :07 fino al 25/08). Il feed 5m di SKH01 passa da li': e' la cosa che
# NON deve trovare l'IP occupato.
# :25 nessun altro job. Il giro dura ~3 minuti a 4 chiamate/s, quindi finisce ben prima del :30.
#
+12
View File
@@ -31,10 +31,22 @@ mkdir -p logs
# Schema fee Deribit (nuovo dal 2026-08-01, annunciato senza numeri): legge il tier BASE
# dall'endpoint pubblico e applica la regola decisa in anticipo (<=5bps nulla, >10bps peso SKH01).
uv run python scripts/live/fee_watch.py --quiet || true
# Cosa il venue ANNUNCIA (debito #8: la sonda venue_probe legge se RISPONDE, non cosa dichiara).
# Feed RSS pubblico degli exchange-update. NON interpreta: classifica solo l'urgenza, su parole
# DERIVATE da deribit._CONTRACT e config/live.json. In una settimana quattro cambi di policy
# materiali erano arrivati a mano; le specifiche dei perp USDC cambiate il 18/08 erano annunciate
# il 14/08, e check_specs() se ne accorge solo DOPO.
uv run python scripts/live/venue_news.py --quiet || true
# f delle strutture VRP dalle quote REALI (canonico vs candidato bocciato dal gate 30/07).
# Criterio di sufficienza pre-registrato (>=40 coppie, IC95 <=0.12): avvisa da solo quando
# il campione basta, invece di lasciare la misura appesa a un promemoria.
uv run python scripts/live/vrp_f_watch.py --quiet || true
# La CHIAVE DI SCALA del libro: la config dichiara una scala che il giornale non ha autorizzato?
# Il criterio che la autorizzo' passa ancora? Il PRODOTTO frac x scala x n_asset ha superato il
# tetto? Tre domande, tre azioni diverse. Non impedisce la modifica: la rende visibile entro 24h
# e attribuibile — una chiave di scala e' il parametro che si alza "solo un po'" dopo un mese
# buono, e lo Sharpe (invariante alla scala) non lo vedrebbe mai. SPEC-scale-key §6.4.
uv run python scripts/live/scale_watch.py --quiet || true
# I forward-monitor stanno registrando? Tre gate pre-registrati (27/09, 23/10, 24/10) si
# decidono su queste serie: un monitor fermo produce silenzio, e il silenzio sembra uno zero.
# Va DOPO tutti i monitor, altrimenti misura lo stato di ieri.
+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
+14
View File
@@ -115,7 +115,21 @@ def scrivi(con, g: date, modello: str, secco: bool = False, verbose: bool = True
return stato
USO = """uso: analista.py [--giorno AAAA-MM-GG] [--modello NOME] [--secco] [--no-telegram] [--quiet]
(nessun flag) scrive l'analisi di OGGI nel campo `analisi` e manda la notifica
--giorno G analizza il giorno G
--modello M modello da usare (default: src.live.analista.MODELLO_DEFAULT)
--secco stampa e NON salva, niente Telegram
--no-telegram salva ma non manda la notifica
--quiet non stampa
SPENDE una chiamata al modello e MANDA un messaggio Telegram, salvo --secco/--no-telegram."""
if __name__ == "__main__":
from src.live.cli import valida
valida("analista.py", USO, flag=("--secco", "--no-telegram", "--quiet"),
con_valore=("--giorno", "--modello"))
con = T.connect()
g = date.fromisoformat(_arg("--giorno", datetime.now(timezone.utc).date().isoformat()))
st = scrivi(con, g, modello=_arg("--modello", A.MODELLO_DEFAULT),
+11
View File
@@ -85,6 +85,17 @@ def campiona(c: DeribitRead) -> dict:
rec["n_posizioni"] = sum(1 for p in pos if abs(float(p.get("size") or 0)) > 1)
except Exception:
rec["nozionale_lordo"], rec["n_posizioni"] = None, None
# INDICE usde_usdc, ORARIO (aggiunto 2026-09-02, debito §5.15). `usde_watch` lo registra una
# volta al giorno (12:35Z): troppo rado per il classificatore dei movimenti di capitale, che
# confronta due letture di equity a un'ora di distanza e oggi guarda solo BTC/ETH — con il
# 31% dell'equity in USDE, un depeg sarebbe scorporato come un PRELIEVO. Questa e' la serie
# che manca; il cablaggio nel bound viene dopo, quando ci sara' storia. None se non leggibile
# (P5: "non vedo" non e' "il peg tiene").
try:
from src.live.usde import prezzo_indice
rec["usde_usdc"] = prezzo_indice()
except Exception as e:
rec["usde_usdc"], rec["usde_usdc_errore"] = None, f"{type(e).__name__}"
return rec
+51 -6
View File
@@ -10,9 +10,24 @@ DOPPIO GATE DI SICUREZZA (entrambi necessari per inviare ordini reali):
Senza entrambi e' un DRY-RUN (stampa il piano, NON invia). Reconciliation dopo ogni ordine; log in
data/live/book_executions.jsonl.
CADENZA: SKH01 decide su griglia 230m -> questo script va lanciato ogni ~230 minuti con la feed
fresca all'ultima barra chiusa (NON il cron giornaliero, che mancherebbe gli ingressi). Gli exit di
SKH sono SOFTWARE (latenza fino a fine barra 230m); solo il disaster-SL (-30%) e' on-book.
CADENZA: ORARIA `47 * * * *` in `scripts/cron_book.sh` (il minuto e' spiegato li'). SKH01
decide su griglia 230m, ma il giro e' IDEMPOTENTE (riconcilia al target netto corrente; sotto
`min_order_usd` $5 -> HOLD). Girare piu' fitto della griglia costa solo MICRO-ORDINI di ri-taglia:
il target e' ricalcolato ogni ora sull'equity marcata, e 22 dei 48 ordini live sono |delta|<=$10
a segnale invariato spiccioli oggi, ma il deadband e' in valuta ASSOLUTA (regola C2: a capitale
maggiore un movimento dello 0,15% lo supera ogni ora). Compra due cose misurate: (1) gli
ingressi/uscite SOFTWARE di SKH01 arrivano con latenza <=1h CON LA FEED 5m FRESCA: se e' stantia
`livefeed.fresh_5m` ripiega in silenzio sul feed giornaliero (latenza ~1 giorno) e
`skh_feed_max_age_min` 30 ALLERTA, non blocca; (2) il disaster-SL rotolante (-30%, l'unico on-book)
viene CONTROLLATO ogni ora e ri-ancorato solo oltre la tolleranza di `ensure_disaster_sl` (stop a
>5% da quello piazzato, cioe' mark +5,263%/-4,762%, o taglia >10%) — con questa isteresi
(`r0823_sl_anchor.py`): a cadenza 1h in 7-8 anni BTC 0 scatti / ETH 1; a 4h BTC 2; a 24h ETH 5,
con 2 in 30 giorni. Lo stop siede quindi fra -33,5% e -26,5% dal mark corrente (0,665-0,735 x mark),
non a -30% dall'ultima ora: e' rotolante, non un massimo di perdita (CLAUDE.md §1).
🚨 NON "correggere" la cadenza a ~230 minuti: fino al 2026-09-02 questo docstring lo prescriveva,
ed era il docstring a essere sbagliato, non il cron (debito §5.7). `tests/test_book_cadenza.py`
tiene d'accordo docstring, `cron_book.sh` e crontab installata. NON il cron giornaliero: le entrate
di SKH01 le mancherebbe.
uv run python scripts/live/book_execute.py # DRY-RUN (piano, nessun ordine)
uv run python scripts/live/book_execute.py --execute # esegue SOLO se execution_enabled=true
@@ -30,7 +45,7 @@ import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.live.book import book_report
from src.live.book import ScalaNonAutorizzata, book_report
from src.live.execution import DeribitTrader
from src.live.notifier import notify
from src.live.venue_probe import diagnose, errori_dal_report
@@ -78,7 +93,20 @@ def _run():
min_order = float(cfg["min_order_usd"])
sl_pct = float(cfg["disaster_sl_pct"])
r = book_report(live_feed=True) # target NETTO + conto/posizioni reali (feed SKH fresco)
try:
r = book_report(live_feed=True) # target NETTO + conto/posizioni reali (feed SKH fresco)
except ScalaNonAutorizzata as e:
# G1(c): NON si taglia al tetto. Un clamp silenzioso farebbe girare una config che dichiara
# un numero e un libro che ne esegue un altro. Si ferma, non invia, e allerta.
msg = (f"🚨 SCALA NON AUTORIZZATA — book_execute FERMO, nessun ordine inviato.\n{e}\n"
f"Ripara: riporta `book_scale_k` a un gradino di SCALA_LADDER dentro il tetto, "
f"oppure rimuovi la chiave (assente = 1,00 = libro di oggi).")
print(msg)
try:
notify(msg)
except Exception as ne: # P3: l'errore si registra dove lo si ingoia
print(f" ⚠️ allerta NON inviata: {type(ne).__name__}: {ne}")
return
equity = r["equity"]
print("=" * 88)
@@ -89,7 +117,15 @@ def _run():
print(f" modo : {mode}")
print(f" gate : execution_enabled={enabled} | --execute={want_execute}")
print(f" conto reale : ${r['real_equity']:,.2f}" if r["real_equity"] else f" conto: {r['eq_basis']}")
print(f" sizing base : ${equity:,.2f} | cap/asset ${r['cap_per_asset']:.0f} | min ${min_order:.0f} | disaster-SL -{sl_pct*100:.0f}%")
_sc = r.get("scala", 1.0)
_lordo = len(r["assets"]) * float(cfg.get("max_notional_per_asset_frac") or 0.0) * _sc
print(f" sizing base : ${equity:,.2f} | cap/asset ${r['cap_per_asset']:.0f} | "
f"min ${min_order:.0f} | disaster-SL -{sl_pct*100:.0f}%")
# La scala e' stampata SEMPRE, anche a 1,00: un parametro che muove il nozionale e non compare
# nel log e' invisibile proprio nel giro in cui e' cambiato (SPEC-scale-key §2.2).
print(f" scala libro : {_sc:.2f}x (tetto {r.get('leva_lorda_max', 1.25):.2f}x) | "
f"leva lorda max {_lordo:.3f}x dell'equity"
+ ("" if _sc != 1.0 else " [chiave assente o 1,00 = libro invariato]"))
print(f" ultima barra : {r['last_data']}\n")
if r.get("skh_error"): # SKH feed fallito -> book.py ha forzato flat IN SILENZIO
@@ -319,5 +355,14 @@ def main():
raise
USO = """uso: book_execute.py [--execute]
uv run python scripts/live/book_execute.py # DRY-RUN (piano, nessun ordine)
uv run python scripts/live/book_execute.py --execute # esegue SOLO se execution_enabled=true
Dettaglio nel docstring in testa al file."""
if __name__ == "__main__":
from src.live.cli import valida
valida("book_execute.py", USO, flag=("--execute",), con_valore=())
main()
+8
View File
@@ -93,5 +93,13 @@ def main() -> None:
print(" regime QUIET: carry non raccoglibile (coerente con luglio 2026).")
USO = """uso: cc01_regime_watch.py
(nessun flag) sorveglia il regime di CC01 e stampa lo stato.
Dettaglio nel docstring in testa al file."""
if __name__ == "__main__":
from src.live.cli import valida
valida("cc01_regime_watch.py", USO)
main()
+10
View File
@@ -144,5 +144,15 @@ def main() -> int:
return 0
USO = """uso: edge_watch.py [--quiet]
edge_watch.py sorveglianza dell'EDGE del book live. Riporta e allerta; non tocca nulla.
uv run python scripts/live/edge_watch.py # report
uv run python scripts/live/edge_watch.py --quiet # solo se qualcosa scatta (cron)
Dettaglio nel docstring in testa al file."""
if __name__ == "__main__":
from src.live.cli import valida
valida("edge_watch.py", USO, flag=("--quiet",), con_valore=())
raise SystemExit(main())
+10
View File
@@ -252,5 +252,15 @@ def main() -> int:
return 2 if r["level"] == "AZIONE" else 0
USO = """uso: fee_watch.py [--quiet]
fee_watch.py sorveglia lo schema fee di Deribit e applica la regola DECISA IN ANTICIPO.
uv run python scripts/live/fee_watch.py # report
uv run python scripts/live/fee_watch.py --quiet # stampa/allerta solo se qualcosa cambia
Dettaglio nel docstring in testa al file."""
if __name__ == "__main__":
from src.live.cli import valida
valida("fee_watch.py", USO, flag=("--quiet",), con_valore=())
raise SystemExit(main())
+12
View File
@@ -41,7 +41,19 @@ def una(con, g: date, nota: str | None = None, verbose: bool = True) -> Path:
return f
USO = """uso: journal.py [--giorno AAAA-MM-GG] [--backfill N] [--nota "testo"] [--quiet]
(nessun flag) scrive la voce di OGGI (pagina marcata PARZIALE: il giorno non e' chiuso)
--giorno G scrive la voce del giorno G
--backfill N ricostruisce gli ultimi N giorni, dal piu' vecchio
--nota "..." annota il giorno (campo dell'operatore, mai riscritto da un ricalcolo)
--quiet non stampa la riga di riepilogo
SCRIVE: una riga nel DB e una pagina in docs/journal/. Sola lettura su feed, log e venue."""
if __name__ == "__main__":
from src.live.cli import valida
valida("journal.py", USO, flag=("--quiet",), con_valore=("--giorno", "--backfill", "--nota"))
# sincronizzare PRIMA: altrimenti la sezione 'Libro' (letta dal log) e la sezione 'P&L'
# (letta dal DB) descrivono due istanti diversi e la pagina si contraddice da sola.
sys.path.insert(0, str(ROOT / "scripts" / "live"))
+9
View File
@@ -137,5 +137,14 @@ def main():
raise
USO = """uso: live_execute.py [--execute]
uv run python scripts/live/live_execute.py # DRY-RUN (piano, nessun ordine)
uv run python scripts/live/live_execute.py --execute # esegue SOLO se execution_enabled=true
Dettaglio nel docstring in testa al file."""
if __name__ == "__main__":
from src.live.cli import valida
valida("live_execute.py", USO, flag=("--execute",), con_valore=())
main()
+10
View File
@@ -74,5 +74,15 @@ def main():
(f"{len(r['orders'])} ordine/i costruito/i sopra." if r["orders"] else "Target flat: 0 ordini."))
USO = """uso: live_trend.py [--equity V] [--no-net]
uv run python scripts/live/live_trend.py # shadow su mainnet reale
uv run python scripts/live/live_trend.py --equity 2000 # forza la base di sizing
uv run python scripts/live/live_trend.py --no-net # offline: solo matematica + parita'
Dettaglio nel docstring in testa al file."""
if __name__ == "__main__":
from src.live.cli import valida
valida("live_trend.py", USO, flag=("--no-net",), con_valore=("--equity",))
main()
+9
View File
@@ -88,5 +88,14 @@ def main():
print(" Validato: invio ordine reale, fill, fee reali, reconciliation, ritorno a flat.")
USO = """uso: microtest.py [--live]
uv run python scripts/live/microtest.py # DRY-RUN: nessun ordine inviato
uv run python scripts/live/microtest.py --live # invia il round-trip REALE
Dettaglio nel docstring in testa al file."""
if __name__ == "__main__":
from src.live.cli import valida
valida("microtest.py", USO, flag=("--live",), con_valore=())
main()
+10
View File
@@ -55,5 +55,15 @@ def main() -> int:
return 0
USO = """uso: monitor_health.py [--quiet]
monitor_health.py i forward-monitor stanno registrando? Riporta e allerta, non tocca nulla.
uv run python scripts/live/monitor_health.py # report
uv run python scripts/live/monitor_health.py --quiet # stampa/allerta solo se scatta
Dettaglio nel docstring in testa al file."""
if __name__ == "__main__":
from src.live.cli import valida
valida("monitor_health.py", USO, flag=("--quiet",), con_valore=())
raise SystemExit(main())
+8
View File
@@ -118,5 +118,13 @@ def main():
print(f" GTAA (IB, asof {asof}): " + ", ".join(f"{k} {v:.0%}" for k, v in gw.items() if v) + f" | cash {cash:.0%}")
USO = """uso: paper_combo.py [--reset] [--status]
uv run python scripts/live/paper_combo.py [--status|--reset]
Dettaglio nel docstring in testa al file."""
if __name__ == "__main__":
from src.live.cli import valida
valida("paper_combo.py", USO, flag=("--status", "--reset"), con_valore=())
main()
+10
View File
@@ -87,5 +87,15 @@ def main():
print(f" posizioni correnti: {pf.current_positions()}")
USO = """uso: paper_portfolio.py [--reset] [--status]
uv run python scripts/live/paper_portfolio.py # avanza (init al 1o run)
uv run python scripts/live/paper_portfolio.py --status # solo stato
uv run python scripts/live/paper_portfolio.py --reset # azzera (riparte da ora)
Dettaglio nel docstring in testa al file."""
if __name__ == "__main__":
from src.live.cli import valida
valida("paper_portfolio.py", USO, flag=("--status", "--reset"), con_valore=())
main()
+10
View File
@@ -224,5 +224,15 @@ def main() -> None:
print("\n fatto. I file *.%s.* sono l'evidenza del difetto e restano nel backup." % SUFFISSO)
USO = """uso: paper_regen.py [--solo V]
paper_regen.py RIGENERA le serie dei forward-monitor rotti da `advance()` (§5.1).
uv run python scripts/live/paper_regen.py # tutti e 4
uv run python scripts/live/paper_regen.py --solo paper_statarb
Dettaglio nel docstring in testa al file."""
if __name__ == "__main__":
from src.live.cli import valida
valida("paper_regen.py", USO, flag=(), con_valore=("--solo",))
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)
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Sorveglianza giornaliera della CHIAVE DI SCALA del libro (SPEC-scale-key §6.4).
uv run python scripts/live/scale_watch.py # osserva e stampa, invia se ALLARME
uv run python scripts/live/scale_watch.py --secco # osserva e stampa, NON invia nulla
uv run python scripts/live/scale_watch.py --quiet # una riga sola (per il cron)
Tre domande, tre azioni diverse, tre stati (OK / ALLARME / NON MISURABILE).
Non impedisce la modifica: la rende visibile entro 24 ore e attribuibile.
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from src.live.notifier import notify # noqa: E402
from src.live.scale_watch import ALLARME, OK, run_once # noqa: E402
def _sender(testo: str):
try:
notify(testo)
return True, "telegram"
except Exception as e: # P3: si registra dove lo si ingoia
return False, f"{type(e).__name__}: {e}"
def main() -> None:
secco = "--secco" in sys.argv
quiet = "--quiet" in sys.argv
rep = run_once(sender=None if secco else _sender)
icona = {OK: "", ALLARME: "🚨"}.get(rep["stato"], "⚠️")
if quiet:
fb = rep["fallback"]
q = f"{100*fb['quota']:.2f}%" if "quota" in fb else "n/d"
print(f" scale_watch: {icona} {rep['stato']} · scala {rep['scala_dichiarata']:.2f}x · "
f"fallback {q} · {rep['invio']}")
return
print("=" * 88)
print(f" SCALE WATCH — la chiave di scala del libro {icona} {rep['stato']}")
print("=" * 88)
print(f" scala dichiarata : {rep['scala_dichiarata']:.2f}x "
f"(scaletta {rep['ladder']}, tetto {rep['tetto']:.2f}x — costanti di CODICE)")
for nome, d in rep["domande"].items():
print(f"\n {nome} [{d['stato']}] {d['motivo']}")
if d.get("azione") and d["azione"] != "nessuna":
print(f" → azione: {d['azione']}")
fb = rep["fallback"]
if fb.get("stato") == "NON MISURABILE":
print(f"\n fallback [{fb['stato']}] {fb.get('motivo')}")
else:
print(f"\n fallback [{fb['stato']}] equity illeggibile in {fb['fallback_veri']}/{fb['giri']} "
f"giri ({100*fb['quota']:.2f}%, soglia {100*fb['soglia']:.0f}%) — "
f"{fb['paper']} giri pre-finanziamento esclusi, li' il libro non invia affatto")
print(f"\n lettore di produzione [{rep['lettore']['stato']}] {rep['lettore']['motivo'][:150]}")
print(f"\n invio: {rep['invio']}")
USO = """uso: scale_watch.py [--quiet] [--secco]
uv run python scripts/live/scale_watch.py # osserva e stampa, invia se ALLARME
uv run python scripts/live/scale_watch.py --secco # osserva e stampa, NON invia nulla
uv run python scripts/live/scale_watch.py --quiet # una riga sola (per il cron)
Dettaglio nel docstring in testa al file."""
if __name__ == "__main__":
from src.live.cli import valida
valida("scale_watch.py", USO, flag=("--quiet", "--secco"), con_valore=())
main()
+9
View File
@@ -146,5 +146,14 @@ def main() -> None:
print("inviato" if send(txt) else "NON inviato (config Telegram assente o rete KO)")
USO = """uso: telegram_daily.py [--dry-run]
uv run python scripts/live/telegram_daily.py # calcola e invia
uv run python scripts/live/telegram_daily.py --dry-run # stampa e basta, non invia
Dettaglio nel docstring in testa al file."""
if __name__ == "__main__":
from src.live.cli import valida
valida("telegram_daily.py", USO, flag=("--dry-run",), con_valore=())
main()
+43 -4
View File
@@ -1,7 +1,7 @@
"""Sincronizza `data/live/trades.db` — il libro di bordo dei trade, allineato col tempo.
uv run python scripts/live/trades_db.py --sync # idempotente, da cron
uv run python scripts/live/trades_db.py --report # stato + P&L a video
uv run python scripts/live/trades_db.py --report # stato + P&L a video (TWR, non e1/e0)
uv run python scripts/live/trades_db.py --reconcile # incrocio delle tre fonti
SOLA LETTURA sul venue e sui log: non manda ordini, non tocca il libro.
@@ -78,10 +78,41 @@ def report() -> None:
print(f"\n fill registrati : {n} dal {p['a'][:16]} al {p['b'][:16]}")
eq = con.execute("SELECT ts_utc, equity FROM equity ORDER BY ts_utc").fetchall()
if eq:
# Il rendimento NON e' `e1/e0-1` sulla serie grezza: quel numero era per il 96,3% un
# bonifico (debito #14, 2026-09-01). Lo scorporo e' quello del giornale — si CHIAMA,
# non si rifa' (P1): `rendimento_twr` spezza la serie sui movimenti di capitale certi.
from src.live.journal import rendimento_twr
e0, e1 = eq[0]["equity"], eq[-1]["equity"]
picco = max(r["equity"] for r in eq)
print(f" equity : ${e0:,.2f} -> ${e1:,.2f} ({e1 - e0:+.2f}, {100*(e1/e0-1):+.2f}%)"
f" | picco ${picco:,.2f} | {len(eq)} letture")
r = rendimento_twr(con, eq[-1]["ts_utc"])
print(f" equity : ${e0:,.2f} -> ${e1:,.2f} ({e1 - e0:+,.2f} di equity, "
f"movimenti di capitale INCLUSI) | picco ${picco:,.2f} | {len(eq)} letture")
if r["eventi"]:
print(f" movimenti capitale : {r['certi']:+,.2f} certi | {r['ambigui']:+,.2f} ambiguo/i"
f" (restano nel P&L, dichiarati)")
for ev in r["eventi"]:
mk = ev.get("mercato_max_pct")
if ev.get("dichiarazione"):
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}]")
for av in r.get("avvisi") or []:
print(f" ⚠️ {av}")
if r["trading"] is None:
print(f" trading da arming : n/d ({r['motivo']})")
else:
print(f" trading da arming : {r['trading']:+,.2f} (equity al netto dei movimenti "
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:
print(f" TWR : n/d ({r['motivo']})")
else:
segs = " x ".join(f"{100*s['ret']:+.2f}% [{s['da'][:10]} -> {s['a'][:10]}]"
for s in r["segmenti"])
print(f" TWR : {100*r['twr']:+.2f}% = {segs}")
rt = con.execute("SELECT * FROM roundtrips ORDER BY ts_out").fetchall()
lordo = sum(r["pnl_lordo"] for r in rt)
fee_rt = sum(r["fee_quota"] for r in rt)
@@ -101,8 +132,16 @@ def report() -> None:
con.close()
USO = """uso: trades_db.py [--sync] [--report] [--reconcile] [--quiet]
--sync (default) legge log e jsonl e aggiorna data/live/trades.db idempotente
--report stato del libro di bordo + P&L (TWR, non e1/e0)
--reconcile incrocio delle tre fonti sui fill (log x jsonl x venue)
--quiet con --sync: non stampa il riepilogo"""
if __name__ == "__main__":
args = sys.argv[1:]
from src.live.cli import valida
args = valida("trades_db.py", USO, flag=("--sync", "--report", "--reconcile", "--quiet"))
if "--reconcile" in args:
reconcile()
elif "--report" in args:
+19 -3
View File
@@ -302,9 +302,15 @@ def main() -> int:
print(f" equity USDE : {eq_usde:,.4f} @ {px if px is not None else 'n/d'}"
f" ({px_fonte}) -> ${usd:,.2f}" if usd is not None else " equity USDE : n/d")
if quota is not None:
margine = eq_usdc + usd * (1.0 - c["haircut"])
print(f" quota : {quota:.1%} del totale (tetto allerta {c['quota_max_frac']:.0%})"
f" · margine utilizzabile ~${margine:,.0f} (haircut {c['haircut']:.0%})")
# Il conto e' Segregated Standard Margin (letto sulla pagina margini il 31/08):
# l'USDE NON fa margine per i perp USDC-settled, e l'haircut non si applica. Il
# margine del book e' il solo silo USDC. Si stampa anche cosa DAREBBE il cross,
# perche' e' la differenza che rende la cosa una decisione invece che un dettaglio.
cross = eq_usdc + usd * (1.0 - c["haircut"])
print(f" quota : {quota:.1%} del totale (tetto allerta {c['quota_max_frac']:.0%})")
print(f" margine : ~${eq_usdc:,.0f} — il solo USDC (conto SEGREGATO: l'USDE non"
f" fa margine). Con cross X:SM sarebbero ~${cross:,.0f} (haircut"
f" {c['haircut']:.0%})")
if an["delta"] is not None:
attr = ("non attribuibile (trade non leggibili)" if an["reward_stimato"] is None
else f"reward stimato {an['reward_stimato']:+.6f} USDE"
@@ -324,5 +330,15 @@ def main() -> int:
return 0
USO = """uso: usde_watch.py [--quiet]
usde_watch.py sorveglianza giornaliera del collaterale USDE: reward, depeg, quota. SOLA LETTURA.
uv run python scripts/live/usde_watch.py # report completo
uv run python scripts/live/usde_watch.py --quiet # stampa/allerta solo transizioni
Dettaglio nel docstring in testa al file."""
if __name__ == "__main__":
from src.live.cli import valida
valida("usde_watch.py", USO, flag=("--quiet",), con_valore=())
sys.exit(main())
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env python
"""venue_news.py — legge cosa il venue ANNUNCIA. SOLA LETTURA, non blocca mai nulla.
PERCHE' ESISTE (2026-08-31). Il debito #8 dice, testuale: *«Resta scoperto il Rulebook vero e
proprio: la sonda legge se il venue RISPONDE, non cosa il venue ANNUNCIA.»* Questa e' quella
meta'. In una settimana quattro cambi di policy Deribit materiali sono arrivati **a mano**, perche'
l'operatore ha incollato dei link — non perche' qualcosa li avesse visti:
* tetto sull'USDE (~31,2% dell'equity) * esclusione dell'Italia dai reward USDC
* permesso mancante su BUIDL * fine della Proof of Reserves quotidiana
E il caso che decide la questione: le **specifiche dei perpetual lineari USDC** sono cambiate il
18/08, annunciate il **14/08**. `check_specs()` rileva la deriva DOPO che e' avvenuta ed e' nato
proprio da quella svista; il feed l'avrebbe detto **quattro giorni prima**. Rilevare a posteriori e
sapere in anticipo non sono la stessa cosa: la prima volta e' andata bene solo perche' i cambi
erano RIDUZIONI (un valore piu' grosso resta conforme). Il giorno che Deribit ALZA un minimo, gli
ordini vengono rifiutati.
COSA E' E COSA NON E'. E' un promemoria che legge un feed RSS pubblico e dice *«e' uscito questo,
guardalo»*. **Non interpreta e non decide**: nessun automatismo su un testo di marketing (P13 una
guardia sui NUMERI non copre il RAGIONAMENTO, e la prosa si legge come opinione di un lettore
fallibile). L'unica cosa che classifica e' l'URGENZA, e lo fa su parole DERIVATE dal codice
sorvegliato, non ridichiarate qui (P1): gli strumenti vengono da `deribit._CONTRACT`, le valute di
collaterale da `config/live.json`. Chi aggiunge uno strumento o una valuta allarga da solo la
sorveglianza, senza toccare questo file.
uv run python scripts/live/venue_news.py # report + allerta sui nuovi
uv run python scripts/live/venue_news.py --quiet # solo allerta (per il cron)
"""
from __future__ import annotations
import argparse
import html
import json
import re
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.notifier import notify # noqa: E402
FEED = "https://insights.deribit.com/exchange-updates/feed/"
STATE = ROOT / "data" / "live" / "venue_news.jsonl"
# Parole che alzano un annuncio a 🚨. Le prime sono FISSE (categorie di rischio che ci toccano
# per costruzione); le altre si DERIVANO dal codice, sotto.
_CHIAVI_FISSE = (
"contract specification", "margin", "collateral", "fee", "delist", "discontinu",
"reserve", "maintenance", "liquidation", "settlement", "withdrawal", "reward",
"jurisdiction", "eligib", "insurance fund", "socialized", "adl", "rulebook",
)
def chiavi() -> list[str]:
"""Parole-chiave DERIVATE dal codice sorvegliato (P1), piu' quelle fisse."""
out = set(_CHIAVI_FISSE)
try:
from src.live.deribit import _CONTRACT
for nome in _CONTRACT: # BTC_USDC-PERPETUAL -> "btc", "usdc"
for pezzo in re.split(r"[_\-]", nome.lower()):
if len(pezzo) >= 3 and pezzo != "perpetual":
out.add(pezzo)
except Exception:
pass
try:
cfg = json.loads((ROOT / "config" / "live.json").read_text())
out.add(str(cfg.get("usde", {}).get("index_name", "")).split("_")[0].lower())
except Exception:
pass
return sorted(k for k in out if k)
def scarica(url: str = FEED, timeout: float = 20.0) -> str | None:
"""Feed RSS pubblico (tokenless). None se non leggibile. Mai solleva."""
try:
import requests
r = requests.get(url, timeout=timeout,
headers={"User-Agent": "PythagorasGoal/venue_news (+read-only)"})
return r.text if r.status_code == 200 else None
except Exception:
return None
def voci(xml: str) -> list[dict]:
"""PURA. Estrae (guid, titolo, data, link) dal RSS. Mai solleva."""
out = []
for blocco in re.findall(r"<item>(.*?)</item>", xml or "", re.S):
def campo(tag):
m = re.search(rf"<{tag}[^>]*>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?</{tag}>", blocco, re.S)
return html.unescape(m.group(1).strip()) if m else ""
g = campo("guid") or campo("link")
if g:
out.append({"guid": g, "titolo": campo("title"),
"data": campo("pubDate"), "link": campo("link")})
return out
def rilevante(titolo: str, ks: list[str]) -> list[str]:
"""PURA. Quali parole-chiave compaiono nel titolo."""
t = titolo.lower()
return [k for k in ks if k in t]
def gia_visti() -> set[str]:
if not STATE.exists():
return set()
out = set()
for riga in STATE.read_text().splitlines():
try:
out.add(json.loads(riga)["guid"])
except Exception:
pass
return out
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--quiet", action="store_true")
a = ap.parse_args()
xml = scarica()
if xml is None:
if not a.quiet:
print(" ✗ feed non leggibile — 'non vedo' non e' 'niente di nuovo' (P5)")
return 2
ks, viste, nuove = chiavi(), gia_visti(), []
primo_giro = not STATE.exists()
tutte = voci(xml)
STATE.parent.mkdir(parents=True, exist_ok=True)
with STATE.open("a") as f:
for v in tutte:
if v["guid"] in viste:
continue
v["match"] = rilevante(v["titolo"], ks)
v["visto_il"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
v["seed"] = primo_giro
f.write(json.dumps(v) + "\n")
nuove.append(v)
# Primo giro: si semina lo storico SENZA allertare (P9: l'allarme massimo non si spende
# per un arretrato di 10 voci, o non verra' letto il giorno che e' vero).
if not primo_giro:
for v in nuove:
if v["match"]:
notify("🚨 annuncio Deribit sul NOSTRO percorso",
{"titolo": v["titolo"], "tocca": ", ".join(v["match"][:6]),
"data": v["data"], "link": v["link"],
"azione": "leggerlo: questo sorvegliante NON interpreta"})
else:
notify("📌 nuovo annuncio Deribit",
{"titolo": v["titolo"], "data": v["data"], "link": v["link"]})
if not a.quiet:
print("=" * 78)
print(f" VENUE NEWS — {len(tutte)} voci nel feed, {len(nuove)} nuove"
+ (" (PRIMO GIRO: storico seminato, nessun allarme)" if primo_giro else ""))
print("=" * 78)
for v in tutte:
m = rilevante(v["titolo"], ks)
segno = "🚨" if m else ("·" if v["guid"] in viste else "📌")
print(f" {segno} {v['data'][:16]:17s} {v['titolo'][:70]}")
if m:
print(f" tocca: {', '.join(m[:8])}")
print(f"\n parole derivate dal codice: {', '.join(ks)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+10
View File
@@ -98,5 +98,15 @@ def main() -> int:
return 0
USO = """uso: venue_watch.py [--quiet]
venue_watch.py runner ORARIO del tripwire di venue. Allerta su Telegram, NON blocca nulla.
uv run python scripts/live/venue_watch.py # un giro, stampa il report
uv run python scripts/live/venue_watch.py --quiet # solo in caso di allarme (per il cron)
Dettaglio nel docstring in testa al file."""
if __name__ == "__main__":
from src.live.cli import valida
valida("venue_watch.py", USO, flag=("--quiet",), con_valore=())
raise SystemExit(main())
+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
del **18.4%**, quindi sul credito NETTO l'effetto e' minore: f_net **0.852** contro **0.718**.
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
[-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
skew, prezzata dal modello a vol ATM) -> misurare una gamba sola da' la risposta sbagliata con
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
@@ -65,23 +69,48 @@ def puts(asset: str, df: pd.DataFrame | None = None) -> pd.DataFrame:
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)
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
px = load_tf(asset, "1h")
s = pd.Series(px["close"].values.astype(float),
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
return s
return causale(s, "1h")
@lru_cache(maxsize=8)
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")
s = pd.Series(d["close"].values.astype(float),
index=pd.to_datetime(d["timestamp"], unit="ms", utc=True)).sort_index()
s.index = pd.DatetimeIndex(s.index).as_unit("ns")
return s
return causale(s, "1D")
# ------------------------------------------------------------------ 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:
ap = argparse.ArgumentParser()
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()
print(__doc__.split("\n\n")[0])
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)
# ------------------------------------------------------------ §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"
"la strategia — contiene la sua astensione.\n")
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())]
hist = V[V.index < win.index[0]].to_numpy(float)
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"
" numero non e' un criterio, quindi eccolo (IV-rank espandente causale, come nel sleeve):")
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)
ivr = np.full(len(vals), np.nan)
for i in range(1, len(vals)):
@@ -753,13 +757,17 @@ def main() -> None:
# ------------------------------------------------------------ §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.
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
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
(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
@@ -770,13 +778,13 @@ def main() -> None:
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
qui per la prima volta su questa struttura: su 9 ancore lo Sharpe canonico BTC 32.58
sta all'89 percentile, la MEDIANA ONESTA e' 1.45 e la banda tocca il NEGATIVO (-0.35).
Su ETH 3.90 -> 2.41. Il numero da citare per BTC e' 1.45, non 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' 2.12 e la banda tocca il NEGATIVO (-0.19).
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
vol campionaria a ballare: fra le ancore la MEDIA settimanale BTC va da -1.35% a
+11.22% del rischio CAMBIA SEGNO e la deviazione standard di 11x (ETH: media 2.9x,
sd 2.4x). Spostare l'ora d'ingresso cambia lo snapshot e quindi quali strike combaciano
vol campionaria a ballare: fra le ancore la MEDIA settimanale BTC va da -0.66% a
+11.44% del rischio CAMBIA SEGNO e la deviazione standard di 9.2x (ETH: media 2.1x,
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
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
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
~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
con un percorso diverso.
+11 -10
View File
@@ -17,7 +17,7 @@ SOLA LETTURA. Questo script:
- non importa nulla che invii ordini se non per ISPEZIONARLO (la sottoclasse di §1 ha `_post` che
SOLLEVA: se il codice di produzione toccasse la rete, il test fallirebbe rumorosamente);
- tocca il venue solo con `--venue`, solo `get_open_orders` (lettura), 2 richieste, e si rifiuta
di partire nei minuti :05-:10 (cron_book) e :24-:30 (cron_chain).
di partire nei minuti :45-:50 (cron_book, al :47 dal 25/08) e :24-:30 (cron_chain).
uv run python scripts/research/r0823_sl_anchor.py # attesa + log + simulazione
uv run python scripts/research/r0823_sl_anchor.py --venue # + riscontro sul conto (2 letture)
@@ -283,8 +283,8 @@ def riscontro_venue(runs_info: dict):
diritte di Deribit appartengono a un ALTRO progetto: usarle sarebbe inventare un percorso, e la
domanda non ne ha bisogno. Si usa quindi cio' che il percorso sanzionato offre: `get_open_orders`.""")
m = datetime.now(timezone.utc).minute
if 5 <= m <= 10 or 24 <= m <= 30:
print(f"\n ⏸ minuto :{m:02d} — finestra di cron_book (:07) / cron_chain (:25). Non interrogo.")
if 45 <= m <= 50 or 24 <= m <= 30: # cron_book al :47 dal 2026-08-25 (era :07), cron_chain :25
print(f"\n ⏸ minuto :{m:02d} — finestra di cron_book (:47) / cron_chain (:25). Non interrogo.")
return
from src.live.deribit import DeribitRead
r = DeribitRead()
@@ -395,10 +395,11 @@ def conseguenza():
alta dopo una salita, lo stop siede vicino sotto il mercato, il crollo lo prende, si rientra e
la gamba successiva lo riprende. **Il rischio di accumulo e' funzione della CADENZA DEL CRON,
non di k**: il gradino di leva non lo crea e non lo amplifica.
· E qui c'e' un aggancio operativo: il docstring di `scripts/live/book_execute.py` prescrive
«ogni ~230 minuti» (la griglia di SKH01) mentre il cron gira **ogni ora** (misurato sopra: 1443
giri in 1442 ore). Chi "correggesse" la cadenza verso il docstring sposterebbe il libro dalla
riga 1h alla riga 4h quella in cui BTC raddoppia gli scatti.
· Aggancio operativo (RIPARATO il 2026-09-02, `tests/test_book_cadenza.py`): fino a quel
giorno il docstring di `scripts/live/book_execute.py` prescriveva «ogni ~230 minuti» (la
griglia di SKH01) mentre il cron gira **ogni ora** (misurato sopra: 1443 giri in 1442 ore).
Chi avesse "corretto" il CRON verso il docstring avrebbe spostato il libro dalla riga 1h
(rotolante: 0 scatti) alla riga 4h (2 scatti del rotolante contro 1 del pavimento).
(*) CAVEAT sulla colonna, e sul suo artefatto: in questa lente la posizione e' SEMPRE aperta
dal 2018, quindi in modo `roll` l'"ingresso" e' un prezzo di anni prima e il -48%/-61% e' il
@@ -501,9 +502,9 @@ def costo(out: dict):
E c'e' un accoppiamento nuovo da registrare: l'accumulo di episodi e' funzione della CADENZA
DEL CRON (1h: no · 4h: BTC 2 · 24h: ETH 5 con 2 in 30 giorni). Oggi la cadenza e' misurata sana,
ma nessun sorvegliante la controlla contro questa conseguenza, e il docstring di `book_execute`
ne prescrive una (~230 min) che cade nella riga peggiore. Non e' un'azione richiesta qui: e' il
parametro da rileggere prima del gradino, insieme a G2.""")
e dal 2026-09-02 `tests/test_book_cadenza.py` tiene d'accordo docstring, cron_book.sh e crontab
(il docstring ne prescriveva una, ~230 min, nella riga 4h). Resta il parametro da rileggere prima
del gradino, insieme a G2.""")
def main():
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python
"""r0831_margini_conto.py — riconcilia la pagina margini del conto. Riproduce due numeri.
PERCHE'. Due domande erano rimaste aperte e nessuna era rispondibile dal gateway (debito #11:
`account_summary` filtrato a 8 campi, niente initial_margin, `extended` ignorato):
1. l'HAIRCUT sull'USDe: la KB ufficiale dice 5%, `config/live.json` diceva 10% "verificato sul
venue il 26/08" senza traccia di come;
2. quale MODELLO DI MARGINE e' attivo — che nessuno aveva mai chiesto.
Lo screenshot della pagina `Standard Margin` (2026-08-31 07:50:30 UTC, fornito dall'operatore) le
chiude entrambe. Qui la riconciliazione, perche' un numero pubblicato deve avere uno script che lo
riproduce (N11) e perche' l'haircut non si legge nella pagina: si RICAVA per differenza fra due
righe, ed e' un conto che va mostrato invece che asserito.
uv run python scripts/research/r0831_margini_conto.py
"""
from __future__ import annotations
# --- dalla schermata (2026-08-31 07:50:30 UTC) ---
SEG_USDC = 1400.86 # S:SM (ATTIVO) -> riga USDC, Available Balance. USDE NON e' in tabella.
CROSS_XSM = 2011.73 # X:SM -> riga CROSS, Available Balance
PX_INDICE = 0.9998 # prezzo mostrato in cima alla pagina
# --- dal conto, letti via gateway alle ~07:55 dello stesso giorno ---
USDC_EQ = 1412.281074
USDE_EQ = 643.175691
IM_MIS = 11.5530 # riservato in USDC = equity - available (misurato)
ETH_DUST_USD = 0.000078 * 2500 # riga ETH della schermata, ~$0.20
TOLL = 2.0 # $: sotto questo scarto due ipotesi non si distinguono
def haircut_implicito(cross: float, usdc: float, eth_dust: float, im: float,
usde: float, px: float) -> tuple[float, float]:
"""PURA. -> (contributo dell'USDE al cross in USD, haircut implicito).
L'haircut non e' scritto da nessuna parte nella pagina: si ricava perche' il CROSS conta
l'USDE scontato e il SEGREGATO non lo conta affatto. La differenza fra le due righe, tolto
cio' che e' comune, E' il contributo dell'USDE."""
contributo = cross - (usdc + eth_dust - im)
return contributo, 1.0 - contributo / (usde * px)
def main() -> int:
print("=" * 78)
print(" PAGINA MARGINI — riconciliazione (screenshot 2026-08-31 07:50:30Z)")
print("=" * 78)
atteso_seg = USDC_EQ - IM_MIS
print(f"\n [1] S:SM, il modello ATTIVO — solo il silo USDC")
print(f" USDC equity {USDC_EQ:,.2f} - IM {IM_MIS:,.2f} = {atteso_seg:,.2f}"
f" schermata {SEG_USDC:,.2f} scarto ${abs(atteso_seg-SEG_USDC):.2f}")
print(f" → l'USDE non compare in questa tabella: nel modello attivo NON fa margine.")
contrib, h = haircut_implicito(CROSS_XSM, USDC_EQ, ETH_DUST_USD, IM_MIS, USDE_EQ, PX_INDICE)
print(f"\n [2] X:SM — l'haircut si RICAVA per differenza")
print(f" contributo USDE al cross = ${contrib:,.2f} su ${USDE_EQ*PX_INDICE:,.2f} di valore")
print(f" → haircut implicito {h:.4%}")
for cand, nome in ((0.05, "KB ufficiale"), (0.10, "config al 30/08")):
att = USDE_EQ * PX_INDICE * (1 - cand)
ok = abs(att - contrib) < TOLL
print(f" {cand:.0%} ({nome:16s}) → atteso ${att:,.2f}, scarto ${abs(att-contrib):6.2f}"
f" {'TORNA' if ok else 'NON torna'}")
verdetto_h = "5%" if abs(USDE_EQ*PX_INDICE*0.95 - contrib) < TOLL else "indeterminato"
delta_modelli = CROSS_XSM - SEG_USDC
print(f"\n VERDETTO (calcolato a runtime, N11):")
print(f" haircut USDe = {verdetto_h} → `config/live.json` diceva 10%: SBAGLIATO")
print(f" modello attivo = SEGREGATO (S:SM) → l'USDE non e' collaterale del book")
print(f" passare a X:SM aggiungerebbe ${delta_modelli:,.2f} di margine utilizzabile")
print(f"\n{'=' * 78}")
live()
return 0
# --- 2026-08-31 08:34Z: X:SM e' ATTIVO. La pagina non serve piu': si RICOSTRUISCE. --------------
#
# L'operatore ha incollato la riga CROSS a X:SM attivo: Available $2.010,81, IM 2,15%, MM 0,37%.
# Il gateway, letto allo stesso minuto, dice `available_funds` = 2010.82299383 — **scarto $0,01**.
# ⇒ la riga CROSS della pagina E' `available_funds`, che leggiamo da soli. Lo screenshot che ieri
# era l'unica fonte per l'haircut e per il modello attivo NON e' piu' necessario a nulla.
PAG_AVAIL, PAG_IM_PCT, PAG_MM_PCT = 2010.81, 0.0215, 0.0037 # incollati dall'operatore, 08:34Z
GW_AVAIL, GW_MB = 2010.82299383, 2055.07348077 # gateway, stesso minuto
GW_USDC_EQ, GW_USDE = 1400.70809113, 654.175691
XPM_AVAIL = 1935.14 # riga X:PM della schermata delle 07:50 (proiezione, modello inattivo)
def im_di_posizione(avail: float, usdc_eq: float, usde: float, px: float, h: float,
dust: float = ETH_DUST_USD) -> float:
"""PURA. -> IM di POSIZIONE in USD, ripulita dall'haircut.
Inversa esatta della riga CROSS: available = usdc_eq + (1-h)*usde*px + dust - IM.
Serve perche' la percentuale stampata dalla pagina NON e' questa (vedi live())."""
return usdc_eq + (1 - h) * usde * px + dust - avail
def live() -> None:
print(" X:SM ATTIVO (2026-08-31 08:34Z) — la pagina si ricostruisce dal gateway")
print("=" * 78)
ric = GW_USDC_EQ + 0.95 * GW_USDE + ETH_DUST_USD - IM_MIS
print(f"\n [3] la riga CROSS, ricostruita con haircut 5%")
print(f" {GW_USDC_EQ:,.2f} + 0,95x{GW_USDE:,.3f} + {ETH_DUST_USD:.2f} dust"
f" - {IM_MIS:.3f} IM = ${ric:,.2f}")
print(f" pagina ${PAG_AVAIL:,.2f} · gateway ${GW_AVAIL:,.2f} → scarto ${abs(ric-PAG_AVAIL):.4f}")
for h in (0.05, 0.10):
im = im_di_posizione(GW_AVAIL, GW_USDC_EQ, GW_USDE, 1.0, h)
print(f" haircut {h:5.0%} → IM implicita ${im:9,.2f}"
f" {'coerente con gli $11,55 misurati' if im > 0 else 'IMPOSSIBILE: IM negativa'}")
print(" → terza conferma del 5%, ed e' la prima ESATTA. Il 10% non e' improbabile:"
"\n e' aritmeticamente impossibile.")
riserv = GW_MB - GW_AVAIL
hc = 0.05 * GW_USDE
print(f"\n [4] 🚨 l'\"IM %\" della pagina NON e' il margine delle posizioni")
print(f" (margin_balance - available)/margin_balance = {riserv/GW_MB:.4%}"
f" → la pagina stampa {PAG_IM_PCT:.2%}")
print(f" scomposta: haircut USDE ${hc:,.2f} ({hc/GW_MB:.3%})"
f" + IM vera ${IM_MIS:,.2f} ({IM_MIS/GW_MB:.3%})")
print(f" ⇒ il {hc/riserv:.0%} di quella percentuale e' HAIRCUT, non rischio.")
print(f" E' salita da 2,13% a 2,15% perche' abbiamo COMPRATO COLLATERALE A RENDIMENTO.")
print(f" La MM invece e' pulita: {PAG_MM_PCT:.2%} = ${PAG_MM_PCT*GW_MB:,.2f}, e non puo'"
f" contenere\n l'haircut (${hc:,.2f} da solo la renderebbe negativa):"
" le due colonne hanno basi DIVERSE.")
print(f"\n [5] X:PM — il confronto rifatto sulla misura pulita")
print(f" La KB da' USDe al 5% sotto ENTRAMBI i modelli: l'haircut si CANCELLA nel confronto.")
for nome, av in (("X:SM", CROSS_XSM), ("X:PM", XPM_AVAIL)):
im = im_di_posizione(av, USDC_EQ, USDE_EQ, PX_INDICE, 0.05)
print(f" {nome}: available ${av:,.2f} → IM di POSIZIONE ${im:6,.2f}")
im_sm = im_di_posizione(CROSS_XSM, USDC_EQ, USDE_EQ, PX_INDICE, 0.05)
im_pm = im_di_posizione(XPM_AVAIL, USDC_EQ, USDE_EQ, PX_INDICE, 0.05)
print(f" → PM chiede {im_pm/im_sm:.1f}x il margine iniziale sulle STESSE due posizioni,"
f"\n e {0.0343/PAG_MM_PCT:.1f}x la MM. Due misure pulite, stesso verso: SCARTATO confermato.")
if __name__ == "__main__":
raise SystemExit(main())
+716
View File
@@ -0,0 +1,716 @@
"""COLLAR01 — hold BTC (long/short) con COLLAR di opzioni a <=15 giorni.
DOMANDA (posta dall'operatore il 2026-09-01):
"crea una strategia di hold BTC in long o short con copertura con options (max 15gg)"
+ due precisazioni in corso d'opera:
"opzione deve essere al max di 15gg" -> vincolo sulla SCADENZA, non sul solo holding
"voglio ridurre la vincita, ma bloccare la perdita" -> NON e' una put protettiva: e' un COLLAR
(pavimento comprato, tetto venduto per finanziarlo).
"l'entrata deve essere gestita da una strategia confortata da indicatori (es. forte bull)"
"dalle opzioni dobbiamo uscire prima del termine (tra 50% e 75% del tempo)" -> `exit_frac`.
PERCHE' QUESTO FILONE NON E' §46 (TAIL-HEDGE, REFUTATO il 2026-08-23).
§46 compro' put deep-OTM sul LIBRO (TP01+SKH01) e fu refutato *prima del prezzo*: il maxDD
saliva in 162/162 celle perche' **il beta del libro al sottostante e' +0,076** "non si
assicura un libro che nei crash e' gia' quasi piatto".
Qui il sottostante e' un **hold di BTC nudo, beta 1,0 per costruzione**: il motivo che uccise
§46 NON si applica, ed e' l'unica ragione per cui questo filone puo' essere riaperto
(CLAUDE.md: "chi la riapre deve battere il motivo, non ripetere la misura").
§46 dichiara inoltre di NON aver provato la copertura dinamica e di aver misurato la sola
statica: anche quella porta e' aperta, ma qui non la uso — il collar e' statico a griglia.
COSA E' GIA' NOTO E VIENE RIUSATO, NON RIMISURATO:
- metriche e i due null di scala (`k_for_same_dd`, `k_for_same_drift`) da r0823_tail_hedge;
- listino fee opzioni Deribit (taker 0,03% del sottostante, cap 12,5% del premio, consegna
0,015%) dallo stesso;
- la catena reale via `cblib.load_chain()` (archivio 2026-05-01 + raccolta propria 2026-07-30).
LE DUE LENTI, ed e' una scelta dichiarata:
(A) LENTE LUNGA (decide): 2021-03-24 -> oggi, la finestra del DVOL ~5,4 anni. Opzioni
prezzate a modello (BS, r=0) con DVOL come ancora ATM, CORRETTA da skew e struttura a
termine MISURATI SULLA CATENA VERA, e spread f misurato per ala. E' l'impianto di §46.
(B) LENTE REALE (controlla il segno, NON decide): 2026-05-01 -> oggi, solo quote a due lati.
123 giorni = ~8 cicli a 15 giorni: sotto-potenziata per costruzione, e per giunta su una
finestra in cui BTC e' salito da ~64,7k a ~77,5k, che e' AVVERSA a un collar (il tetto si
paga proprio li'). Si legge il segno, mai la taglia.
M5 (null del de-levering) E' IL PRIMO TEST, NON L'ULTIMO: ogni claim "meno drawdown" si misura
contro "tieni semplicemente meno BTC" a iso-maxDD. Se il collar non batte quello, non e'
protezione: e' una ri-scalatura pagata cara.
C9 e' il secondo: "una regola d'uscita che scatta piu' spesso sui vincenti che sui perdenti non e'
protezione, e' troncatura" — qui il tetto e' letteralmente quella regola.
NESSUN ORDINE. Libro, pesi, cron, config INVARIATI.
"""
from __future__ import annotations
import math
import sys
from pathlib import Path
import numpy as np
import pandas as pd
from scipy.stats import norm
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
import cblib # noqa: E402
from src.data.downloader import load_data # noqa: E402
DPY = 365.25
# ---- listino Deribit opzioni (identico a r0823_tail_hedge, non ridichiarato a mano) -----------
OPT_TAKER_UNDERLYING = 0.0003
OPT_FEE_CAP_FRAC = 0.125
OPT_DELIVERY_UNDERLYING = 0.00015
# ---- GRIGLIA DICHIARATA PRIMA DI GUARDARE I RISULTATI -----------------------------------------
G_DPUT = (0.10, 0.20, 0.30) # |delta| della put comprata = il PAVIMENTO
G_DCALL = (0.10, 0.20, 0.30) # delta della call venduta = il TETTO
G_TENOR = (7, 14) # giorni a scadenza: <=15, vincolo dell'operatore
N_CELLS = len(G_DPUT) * len(G_DCALL) * len(G_TENOR)
# varianti dichiarate a parte (non nella griglia, per non gonfiare i trial)
V_ZEROCOST = "zero-cost" # tetto scelto per finanziare esattamente il pavimento
ATTESE = """
ATTESE A PRIORI scritte e stampate PRIMA di misurare (M12: un follow-up contiene una previsione).
A1 Il maxDD SCENDE davvero, a differenza di §46: il beta e' 1,0 e il pavimento morde.
(se A1 fallisse, il filone cade come §46 e per lo stesso motivo)
A2 Il drift scende PIU' del maxDD, perche' il rendimento di BTC vive nella coda destra e il
tetto la taglia. Previsione: rapporto Δdrift/ΔmaxDD > 1.
A3 Il collar PERDE contro il null del de-levering a iso-maxDD in piu' della meta' delle celle.
E' la previsione centrale del filone.
A4 Lo skew rende il collar delta-simmetrico un DEBITO netto (put piu' cara della call).
[gia' misurato prima di scrivere: +31% / +19% / +12% a |d| 0,10 / 0,20 / 0,30 -> CONFERMATA]
A5 Il tick morde MENO che in §46: quella era la famiglia USDC (tick 5 USDC); la catena che
raccogliamo e' 100% inverse, con tick in BTC.
A6 Nella lente reale (BTC +20% in 123 giorni) il collar sembrera' pessimo: finestra avversa,
non evidenza. Va letta come segno, non come taglia.
A7 Il verso SHORT sara' peggiore del LONG: il drift di BTC e' positivo e lo short lo paga tutto,
col collar che ne cappa il solo rimbalzo favorevole.
"""
# ==================================================================== 0. METRICHE E NULL (da §46)
def _sh(x) -> float:
r = np.asarray(pd.Series(x).dropna().values, float)
return float(np.mean(r) / np.std(r) * math.sqrt(DPY)) if len(r) > 2 and np.std(r) > 0 else 0.0
def _dd(x) -> float:
r = np.asarray(pd.Series(x).dropna().values, float)
eq = np.cumprod(1.0 + r)
pk = np.maximum.accumulate(eq)
return float(np.max((pk - eq) / pk)) if len(eq) else 0.0
def _drift(x) -> float:
r = np.asarray(pd.Series(x).dropna().values, float)
if len(r) < 2:
return 0.0
tot = float(np.prod(1.0 + r))
return -1.0 if tot <= 0 else float(tot ** (DPY / len(r)) - 1.0)
def k_for_same_dd(base: np.ndarray, target_dd: float) -> float:
"""La scala costante che porta il maxDD del NUDO esattamente a quello del coperto (bisezione)."""
lo, hi = 1e-4, 1.0
if _dd(hi * base) <= target_dd:
return 1.0
for _ in range(60):
mid = 0.5 * (lo + hi)
if _dd(mid * base) > target_dd:
hi = mid
else:
lo = mid
return 0.5 * (lo + hi)
# ==================================================================== 1. CALIBRAZIONE DALLA CATENA
_BANDS = (0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.40, 0.50)
def calibra_catena() -> dict:
"""Skew, struttura a termine e spread f: MISURATI sulla catena vera, mai assunti.
Restituisce funzioni di correzione applicate al DVOL (che e' un ATM a 30 giorni) per ottenere
l'IV di una gamba a delta e scadenza dati. Senza queste il modello prezzerebbe la put e la
call allo STESSO vol: l'errore andrebbe tutto a favore del collar (A4).
"""
df = cblib.load_chain()
b = df[df["asset"].eq("BTC")].copy()
b["exp"] = pd.to_datetime(b["exp"], utc=True)
b["ts"] = pd.to_datetime(b["ts"], utc=True)
b["dte"] = (b["exp"] - b["ts"]).dt.total_seconds() / 86400.0
q = b[(b["dte"] > 1) & (b["dte"] <= 15) & b["bid"].gt(0) & b["ask"].gt(0)
& b["iv"].notna() & b["delta"].notna()].copy()
q["ad"] = q["delta"].abs()
atm = q[q["ad"].between(0.45, 0.55)].groupby(["ts", "exp"])["iv"].mean().rename("iv_atm")
q = q.join(atm, on=["ts", "exp"])
q = q[q["iv_atm"].notna() & q["iv_atm"].gt(0)]
skew = {}
for t, nm in (("P", "put"), ("C", "call")):
xs, ys = [], []
for d in _BANDS:
lo, hi = d * 0.85, d * 1.15
s = q[q["option_type"].eq(t) & q["ad"].between(lo, hi)]
if len(s) >= 200:
xs.append(d)
ys.append(float((s["iv"] / s["iv_atm"]).median()))
skew[nm] = (np.array(xs), np.array(ys))
# struttura a termine: IV ATM a DTE t contro il DVOL 30g dello stesso istante
dv = cblib.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["giorno"] = a["ts"].dt.floor("D")
dvd = dv.copy()
dvd.index = pd.to_datetime(dvd.index, utc=True).floor("D")
dvd = dvd.groupby(level=0).last()
a = a.join(dvd.rename("dvol"), on="giorno")
a = a[a["dvol"].notna() & a["dvol"].gt(0)]
a["ratio"] = (a["iv"] / 100.0) / (a["dvol"] / 100.0) if a["iv"].median() > 3 else a["iv"] / (a["dvol"] / 100.0)
ts_x, ts_y = [], []
for lo, hi in ((1, 4), (4, 8), (8, 12), (12, 15)):
s = a[a["dte"].between(lo, hi)]
if len(s) >= 200:
ts_x.append(0.5 * (lo + hi))
ts_y.append(float(s["ratio"].median()))
# spread relativo per ala e per banda di delta (mezza forchetta su mid) = il costo di f
spread = {}
for t, nm in (("P", "put"), ("C", "call")):
xs, ys = [], []
for d in _BANDS:
lo, hi = d * 0.85, d * 1.15
s = q[q["option_type"].eq(t) & q["ad"].between(lo, hi)]
s = s[s["mid"].gt(0)]
if len(s) >= 200:
xs.append(d)
ys.append(float((((s["ask"] - s["bid"]) / 2.0) / s["mid"]).median()))
spread[nm] = (np.array(xs), np.array(ys))
return dict(skew=skew, term=(np.array(ts_x), np.array(ts_y)), spread=spread,
n_quote=len(q), giorni=int(q["ts"].dt.date.nunique()),
span=(q["ts"].min(), q["ts"].max()))
def _interp(tab, x):
xs, ys = tab
if len(xs) == 0:
return 1.0
return float(np.interp(x, xs, ys))
# ==================================================================== 2. PREZZATORE
def _d1(S, K, T, sig):
return (math.log(S / K) + 0.5 * sig * sig * T) / (sig * math.sqrt(T))
def bs_call(S, K, T, sig):
if T <= 0 or sig <= 0:
return max(S - K, 0.0)
d1 = _d1(S, K, T, sig)
return S * norm.cdf(d1) - K * norm.cdf(d1 - sig * math.sqrt(T))
def bs_put(S, K, T, sig):
if T <= 0 or sig <= 0:
return max(K - S, 0.0)
d1 = _d1(S, K, T, sig)
return K * norm.cdf(-(d1 - sig * math.sqrt(T))) - S * norm.cdf(-d1)
def strike_da_delta(S, T, sig_atm, cal, delta, tipo):
"""Strike che realizza |delta| dato, risolvendo la circolarita' skew<->delta per punto fisso.
L'IV dipende dal delta (skew) e il delta dipende dallo strike che dipende dall'IV: due
iterazioni bastano (verificato: la terza muove lo strike di <0,1%).
"""
sig = sig_atm * _interp(cal["skew"][tipo], delta)
K = S
for _ in range(3):
z = norm.ppf(delta) if tipo == "call" else -norm.ppf(delta)
K = S * math.exp(0.5 * sig * sig * T - z * sig * math.sqrt(T))
sig = sig_atm * _interp(cal["skew"][tipo], delta)
return K, sig
def _fee(prem, S):
return min(OPT_TAKER_UNDERLYING * S, OPT_FEE_CAP_FRAC * prem) if prem > 0 else 0.0
# ==================================================================== 3. IL MOTORE DEL COLLAR
def serie_giornaliera() -> pd.DataFrame:
"""BTC giornaliero + DVOL, sulla finestra in cui ESISTONO entrambi (2021-03 in poi)."""
d = load_data("BTC", "1h")
idx = pd.DatetimeIndex(pd.to_datetime(d["datetime"], utc=True))
px = pd.Series(d["close"].values.astype(float), index=idx).resample("1D").last()
dv = pd.read_parquet(ROOT / "data" / "raw" / "dvol_btc.parquet")
iv = pd.Series(dv["close"].values.astype(float) / 100.0,
index=pd.to_datetime(dv["timestamp"], unit="ms", utc=True)).resample("1D").last()
P = pd.concat({"px": px, "iv": iv}, axis=1, join="inner").dropna()
return P
SPOT_FEE_SIDE = 0.00035 # Deribit taker reale dal 2026-08-01 (3,50 bps/lato), §8
def gate_trend(P: pd.DataFrame, forte: bool) -> np.ndarray:
"""L'ENTRATA, governata da indicatori — e presa dal progetto, non inventata qui.
`trend_portfolio.tsmom_blend` media tre `np.sign()` sugli orizzonti (30, 90, 180) giorni,
quindi assume SOLO i valori {-1, -1/3, +1/3, +1} (il bucket 2/3 non esiste: e' un errore gia'
corretto in §57). Questo da' a "forte bull" una definizione che non aggiunge NEMMENO UN
parametro nuovo:
forte=True -> |blend| == 1 : tutti e tre gli orizzonti concordi ("forte bull/bear")
forte=False -> |blend| >= 1/3: almeno la maggioranza (confronto dichiarato)
Il segno del blend da' la DIREZIONE (long o short), come chiesto.
"""
from src.strategies.trend_portfolio import tsmom_blend
c = P["px"].values.astype(float)
b = tsmom_blend(c, (30, 90, 180))
d = np.sign(b)
d[np.abs(b) < (0.99 if forte else 0.32)] = 0.0
return np.concatenate([[0.0], d[:-1]]) # ESEGUIBILE: si entra il giorno DOPO il segnale
def collar(P: pd.DataFrame, cal: dict, *, dput: float, dcall: float, tenor: int,
dirs: np.ndarray, zerocost: bool = False, senza_opzioni: bool = False,
prem_scale: float = 1.0, solo_pavimento: bool = False,
exit_frac: float = 1.0) -> dict:
"""Hold di BTC gated dal trend, coperto da un collar rollato ogni `tenor` giorni (<=15).
direzione +1 = LONG BTC -> compra PUT (pavimento), vende CALL (tetto)
direzione -1 = SHORT BTC -> compra CALL (pavimento), vende PUT (tetto)
`exit_frac` < 1 esce dalle opzioni PRIMA della scadenza (0,50-0,75 = richiesta dell'operatore
del 2026-09-01). Non e' un dettaglio di rollo: cambia il segno di due cose opposte.
- sulla gamba COMPRATA (il pavimento) uscire presto RECUPERA valore temporale: il theta di una
long accelera verso la scadenza, e §46 aveva gia' misurato che *"f si paga solo sulla parte
di valore che converge a intrinseco, quindi un roll anticipato non lo paga"*;
- sulla gamba VENDUTA (il tetto) uscire presto RINUNCIA al theta piu' veloce, cioe' proprio
alla parte che in questo filone stampava il premio di varianza.
Il prezzo di entrambe e' che si paga lo SPREAD in uscita su due gambe, invece di regolare a
intrinseco. Chi lo attiva compra meno short-vol e piu' protezione onesta: e' misurabile, ed e'
misurato nella sezione dedicata.
`prem_scale` e `solo_pavimento` esistono per i CONTROLLI (M15): con premio 0 e solo il
pavimento la struttura e' un pranzo gratis e DEVE vincere; con premio x10 DEVE perdere ovunque.
Un apparato che non riconosce il pranzo gratis e' guasto, e §46 lo scopri' a proprie spese.
`senza_opzioni=True` produce il MEDESIMO gated hold senza collar: e' la BASE del null M5, e
dev'essere generata dallo stesso motore o il confronto misurerebbe anche le differenze di
contabilita' (P1: il null deriva dal codice sorvegliato, non lo ridichiara).
Marcatura GIORNALIERA a modello: il pavimento vincola il PAYOFF A SCADENZA, non il mark
intermedio. Senza marcatura giornaliera il maxDD del collar sarebbe misurato solo ai roll e la
protezione sembrerebbe migliore di quanto e'.
"""
S = P["px"].values.astype(float)
A = P["iv"].values.astype(float)
n = len(S)
if n < tenor + 5:
return {}
tipo_di = {+1: ("put", "call"), -1: ("call", "put")}
nav = np.full(n, np.nan)
V = 1.0 # capitale normalizzato
cash, q, nopt = 1.0, 0.0, 0.0
Kl = Ks = np.nan
dc_vivo = 0.20 # delta del tetto VIVO: serve allo spread d'uscita anticipata
scad = -1
roll_at = -1 # quando si ESCE (<= scad): con exit_frac<1 e' PRIMA della scadenza
cur = 0
cicli = []
dc_scelti = [] # delta del tetto effettivamente usato a ogni apertura
apre = 0
def _mark(K, T, sig_atm, tipo, S_t):
if T <= 0 or math.isnan(K):
return 0.0 if math.isnan(K) else (max(K - S_t, 0.0) if tipo == "put" else max(S_t - K, 0.0))
sig = sig_atm * _interp(cal["term"], max(T * DPY, 1.0))
d1 = _d1(S_t, K, T, sig)
dl = norm.cdf(d1) if tipo == "call" else 1.0 - norm.cdf(d1)
sig *= _interp(cal["skew"][tipo], min(max(dl, 0.02), 0.60))
return bs_put(S_t, K, T, sig) if tipo == "put" else bs_call(S_t, K, T, sig)
def _chiudi(i, S_t, anche_spot=True):
"""Regola le opzioni vive; liquida lo spot SOLO se richiesto.
Il flag esiste per un difetto che falserebbe tutto: al ROLL delle opzioni lo spot non
ha motivo di muoversi. Chiuderlo e riaprirlo pagherebbe 2x3,5 bps ogni `tenor` giorni
(a 7 giorni: ~3,6%/anno) di fee che nella realta' non si pagano.
"""
nonlocal cash, q, nopt, Kl, Ks, cur
if nopt > 0 and not senza_opzioni:
tl, ts = tipo_di[cur]
T_rem = max(scad - i, 0) / DPY
vl = _mark(Kl, T_rem, A[i], tl, S_t)
vs = 0.0 if solo_pavimento else _mark(Ks, T_rem, A[i], ts, S_t)
if T_rem > 0: # smontaggio anticipato: pago lo spread in USCITA
vl *= 1.0 - _interp(cal["spread"][tl], dput)
vs *= 1.0 + _interp(cal["spread"][ts], dc_vivo)
else:
for x in (vl, vs):
if x > 0:
cash -= nopt * OPT_DELIVERY_UNDERLYING * S_t
cash += nopt * (vl - vs)
nopt, Kl, Ks = 0.0, np.nan, np.nan
if anche_spot and q != 0.0:
cash += q * S_t - abs(q) * S_t * SPOT_FEE_SIDE
q = 0.0
for i in range(n):
S_t = S[i]
d = int(dirs[i])
fine_ciclo = (i >= roll_at) and cur != 0 and not senza_opzioni
solo_roll = fine_ciclo and d == cur
if d != cur or fine_ciclo:
if cur != 0:
if nopt > 0 and i >= scad:
tl, ts = tipo_di[cur]
il_ = max(Kl - S_t, 0.0) if tl == "put" else max(S_t - Kl, 0.0)
is_ = 0.0 if solo_pavimento else (
max(Ks - S_t, 0.0) if ts == "put" else max(S_t - Ks, 0.0))
cicli.append(dict(i0=apre, i1=i, S0=S[apre], S1=S_t, Kl=Kl, Ks=Ks,
dirn=cur, cap=is_ > 0, floor=il_ > 0,
scaduto=bool(fine_ciclo)))
_chiudi(i, S_t, anche_spot=not solo_roll)
if not solo_roll:
V = cash
if not solo_roll:
cur = d
if cur != 0:
if not solo_roll:
q = cur * V / S_t
cash = V - q * S_t - abs(q) * S_t * SPOT_FEE_SIDE
apre, scad = i, i + tenor
roll_at = i + max(1, int(round(exit_frac * tenor)))
if not senza_opzioni:
tl, ts = tipo_di[cur]
T = tenor / DPY
Kl, sl = strike_da_delta(S_t, T, A[i], cal, dput, tl)
pl = (bs_put if tl == "put" else bs_call)(S_t, Kl, T, sl)
pl *= 1.0 + _interp(cal["spread"][tl], dput) # compro all'ASK
pl *= prem_scale
if zerocost:
lo, hi = 0.02, 0.60
for _ in range(40):
md = 0.5 * (lo + hi)
Kx, sx = strike_da_delta(S_t, T, A[i], cal, md, ts)
px_ = (bs_put if ts == "put" else bs_call)(S_t, Kx, T, sx)
px_ *= 1.0 - _interp(cal["spread"][ts], md) # vendo al BID
# il premio CRESCE col delta: se incasso meno di quanto pago,
# il tetto va avvicinato (delta PIU' ALTO) -> lo = md.
if px_ < pl:
lo = md
else:
hi = md
dc_use = 0.5 * (lo + hi)
else:
dc_use = dcall
dc_vivo = dc_use
dc_scelti.append(float(dc_use))
Ks, ss = strike_da_delta(S_t, T, A[i], cal, dc_use, ts)
ps = (bs_put if ts == "put" else bs_call)(S_t, Ks, T, ss)
ps *= (1.0 - _interp(cal["spread"][ts], dc_use)) * prem_scale
if solo_pavimento:
ps, Ks = 0.0, np.inf if ts == "call" else 0.0
nopt = abs(q)
cash += nopt * (ps - pl) - nopt * (_fee(pl, S_t) + _fee(ps, S_t))
vl = vs = 0.0
if nopt > 0 and not senza_opzioni:
tl, ts = tipo_di[cur]
T_rem = max(scad - i, 0) / DPY
vl = _mark(Kl, T_rem, A[i], tl, S_t)
vs = 0.0 if solo_pavimento else _mark(Ks, T_rem, A[i], ts, S_t)
nav[i] = cash + q * S_t + nopt * (vl - vs)
v = pd.Series(nav, index=P.index).dropna()
r = (v / v.shift(1) - 1.0).dropna()
return dict(ret=r, nav=v, cicli=cicli, dc_scelti=dc_scelti)
# ==================================================================== 4. MAIN
def _riga(nome, r) -> dict:
return dict(nome=nome, sh=_sh(r), dd=_dd(r), drift=_drift(r))
def main() -> None:
print("=" * 96)
print(" COLLAR01 — hold BTC gated dal trend, coperto da collar di opzioni a <=15 giorni")
print("=" * 96)
print(ATTESE)
cal = calibra_catena()
print(f" CALIBRAZIONE dalla catena VERA: {cal['n_quote']:,} quote a due lati su "
f"{cal['giorni']} giorni, {cal['span'][0].date()} -> {cal['span'][1].date()}")
for nm in ("put", "call"):
xs, ys = cal["skew"][nm]
print(f" skew {nm:5s} IV/ATM : " + " ".join(f"d{x:.2f}={y:.3f}" for x, y in zip(xs, ys)))
xs, ys = cal["term"]
print(f" termine IV/DVOL30 : " + " ".join(f"{x:.0f}g={y:.3f}" for x, y in zip(xs, ys)))
for nm in ("put", "call"):
xs, ys = cal["spread"][nm]
print(f" spread {nm:5s} (mezza forchetta/mid): "
+ " ".join(f"d{x:.2f}={y:.3f}" for x, y in zip(xs, ys)))
P = serie_giornaliera()
print(f"\n LENTE LUNGA: {P.index[0].date()} -> {P.index[-1].date()} "
f"({len(P)} giorni, {len(P)/DPY:.2f} anni)")
bh = (P["px"] / P["px"].shift(1) - 1.0).dropna()
print(f" BTC buy&hold nudo: Sharpe {_sh(bh):.3f} · maxDD {100*_dd(bh):.2f}% · "
f"drift {100*_drift(bh):+.2f}%/a")
righe = []
for forte in (True, False):
dirs = gate_trend(P, forte)
nome_g = "FORTE (3/3 orizzonti)" if forte else "LARGO (>=2/3)"
quota = 100.0 * np.mean(dirs != 0)
qlong = 100.0 * np.mean(dirs > 0)
base = collar(P, cal, dput=0.2, dcall=0.2, tenor=7, dirs=dirs, senza_opzioni=True)
rb = base["ret"]
print(f"\n ── GATE {nome_g}: a mercato {quota:.1f}% dei giorni "
f"(long {qlong:.1f}%, short {quota-qlong:.1f}%)")
print(f" BASE gated SENZA opzioni: Sharpe {_sh(rb):.3f} · maxDD {100*_dd(rb):.2f}% · "
f"drift {100*_drift(rb):+.2f}%/a ← e' il NULL da battere (M5)")
for tenor in G_TENOR:
for dp in G_DPUT:
for dc in G_DCALL:
out = collar(P, cal, dput=dp, dcall=dc, tenor=tenor, dirs=dirs)
if not out:
continue
r = out["ret"]
dd_c, dr_c = _dd(r), _drift(r)
k = k_for_same_dd(rb.values, dd_c)
dr_null = _drift(k * rb.values)
righe.append(dict(gate=nome_g, forte=forte, tenor=tenor, dp=dp, dc=dc,
sh=_sh(r), dd=dd_c, drift=dr_c, k=k, drift_null=dr_null,
vince=(dr_c > dr_null) and (dd_c < _dd(rb)),
dd_scende=dd_c < _dd(rb), cicli=out["cicli"], ret=r))
# varianti zero-cost, dichiarate FUORI griglia
for tenor in G_TENOR:
for dp in G_DPUT:
out = collar(P, cal, dput=dp, dcall=np.nan, tenor=tenor, dirs=dirs, zerocost=True)
if not out:
continue
r = out["ret"]
dd_c, dr_c = _dd(r), _drift(r)
k = k_for_same_dd(rb.values, dd_c)
righe.append(dict(gate=nome_g, forte=forte, tenor=tenor, dp=dp, dc="zc",
sh=_sh(r), dd=dd_c, drift=dr_c, k=k,
drift_null=_drift(k * rb.values),
vince=(dr_c > _drift(k * rb.values)) and (dd_c < _dd(rb)),
dd_scende=dd_c < _dd(rb), cicli=out["cicli"], ret=r))
R = pd.DataFrame([x for x in righe if x["gate"] == nome_g])
print(f"\n {'ten':>4} {'dput':>5} {'dcall':>6} {'Sharpe':>7} {'maxDD':>7} "
f"{'drift':>8} {'k null':>7} {'drift null':>10} esito")
for _, x in R.iterrows():
dcs = x["dc"] if isinstance(x["dc"], str) else f"{x['dc']:.2f}"
print(f" {x['tenor']:>4} {x['dp']:>5.2f} {dcs:>6} {x['sh']:>7.3f} "
f"{100*x['dd']:>6.2f}% {100*x['drift']:>7.2f}% {x['k']:>7.3f} "
f"{100*x['drift_null']:>9.2f}% "
f"{'VINCE' if x['vince'] else ('dd↓ ma perde' if x['dd_scende'] else 'dd↑')}")
R = pd.DataFrame(righe)
n_dd = int(R["dd_scende"].sum())
n_win = int(R["vince"].sum())
print("\n" + "=" * 96)
print(f" A1 maxDD SCENDE in {n_dd}/{len(R)} celle "
f"({'CONFERMATA' if n_dd > len(R)//2 else 'REFUTATA'})")
print(f" A3 il collar BATTE il de-levering in {n_win}/{len(R)} celle "
f"({'REFUTATA (il collar vince)' if n_win > len(R)//2 else 'CONFERMATA (il collar perde)'})")
# ---- A2: rapporto Δdrift/ΔmaxDD ----------------------------------------------------------
for forte in (True, False):
sub = R[R["forte"] == forte]
if not len(sub):
continue
dirs = gate_trend(P, forte)
rb = collar(P, cal, dput=0.2, dcall=0.2, tenor=7, dirs=dirs, senza_opzioni=True)["ret"]
d0, g0 = _dd(rb), _drift(rb)
rap = ((g0 - sub["drift"]) / np.maximum(d0 - sub["dd"], 1e-9)).median()
print(f" A2 gate {'FORTE' if forte else 'LARGO'}: Δdrift/ΔmaxDD mediano {rap:.2f} "
f"({'CONFERMATA' if rap > 1 else 'REFUTATA'}: >1 = il tetto costa piu' di quanto il "
f"pavimento renda)")
# ---- C9: il tetto scatta piu' sui vincenti o sui perdenti? -------------------------------
best = R.sort_values("sh", ascending=False).iloc[0]
cic = pd.DataFrame(best["cicli"])
if len(cic):
# ⚠️ "vincente" dipende dalla DIREZIONE: per un ciclo SHORT un prezzo che sale e' una
# PERDITA. Usare S1>S0 come proxy di vittoria e' il difetto che dava "pavimento 0,0%".
cic["vinc"] = cic["dirn"] * (cic["S1"] - cic["S0"]) > 0
cap_w = 100.0 * cic[cic["vinc"]]["cap"].mean()
flo_l = 100.0 * cic[~cic["vinc"]]["floor"].mean()
print(f"\n C9 cella migliore (gate {best['gate']}, {best['tenor']}g, dput {best['dp']}, "
f"dcall {best['dc']}), {len(cic)} cicli:")
print(f" il TETTO taglia nel {cap_w:.1f}% dei cicli VINCENTI · "
f"il PAVIMENTO para nel {flo_l:.1f}% dei cicli PERDENTI")
print(f" -> {'TRONCATURA' if cap_w > flo_l else 'PROTEZIONE'} "
f"(C9: se scatta piu' sui vincenti che sui perdenti, non e' protezione)")
# ---- M1: il confronto che decide se merita uno slot --------------------------------------
try:
from src.portfolio.sleeves import _tp01_returns
tp = _tp01_returns()
tp.index = pd.to_datetime(tp.index, utc=True)
tpd = (1.0 + tp).resample("1D").prod() - 1.0
j = pd.concat({"c": best["ret"], "tp": tpd}, axis=1, join="inner").dropna()
print(f"\n M1 vs TP01 sulla finestra comune ({len(j)} giorni): "
f"collar Sharpe {_sh(j['c']):.3f} · TP01 {_sh(j['tp']):.3f} · "
f"corr {j['c'].corr(j['tp']):+.3f}")
for w in (0.10, 0.25):
mix = (1 - w) * j["tp"] + w * j["c"]
print(f" TP01 + {100*w:.0f}% collar: Sharpe {_sh(mix):.3f} "
f"({_sh(mix)-_sh(j['tp']):+.3f}) · maxDD {100*_dd(mix):.2f}% "
f"({100*(_dd(mix)-_dd(j['tp'])):+.2f}pp)")
except Exception as e: # pragma: no cover
print(f"\n M1 non calcolato: {type(e).__name__}: {e}")
# ---- ESTENSIONE AL BORDO (M4: si riapre la FAMIGLIA, M8: un argmax sul bordo non decide) --
print("\n" + "=" * 96)
print(" ESTENSIONE DICHIARATA — le celle vincenti stanno tutte sul BORDO (dput min, dcall max).")
print(" Sondo dove punta la pendenza. Il limite dput->0 / dcall->0,50 e' la COVERED CALL pura:")
print(" nessun pavimento, tetto ATM. Trial aggiunti: 2 gate x 2 tenor x 3 x 3 = 36 (dichiarati).")
ext = []
for forte in (True, False):
dirs = gate_trend(P, forte)
rb = collar(P, cal, dput=0.2, dcall=0.2, tenor=7, dirs=dirs, senza_opzioni=True)["ret"]
for tenor in (7, 14):
for dp in (0.02, 0.05, 0.10):
for dc in (0.30, 0.40, 0.50):
r = collar(P, cal, dput=dp, dcall=dc, tenor=tenor, dirs=dirs)["ret"]
k = k_for_same_dd(rb.values, _dd(r))
ext.append(dict(forte=forte, tenor=tenor, dp=dp, dc=dc, sh=_sh(r),
dd=_dd(r), drift=_drift(r),
vince=(_drift(r) > _drift(k * rb.values)) and (_dd(r) < _dd(rb))))
E = pd.DataFrame(ext)
top = E.sort_values("sh", ascending=False).iloc[0]
print(f" -> VINCE in {int(E['vince'].sum())}/{len(E)} celle, e il massimo e' NELL'ANGOLO: "
f"gate {'FORTE' if top['forte'] else 'LARGO'} {top['tenor']}g dput {top['dp']:.2f} "
f"dcall {top['dc']:.2f} -> Sharpe {top['sh']:.3f}, maxDD {100*top['dd']:.2f}%, "
f"drift {100*top['drift']:+.2f}%/a")
print(" ⚠️ Il pavimento non e' cio' che vince: vince il TETTO. La pendenza porta FUORI da cio'")
print(" che l'operatore ha chiesto ('bloccare la perdita') e DENTRO lo short-vol.")
# ---- LA DIAGNOSTICA CHE DECIDE: quanto dell'edge e' il PREMIO DI VARIANZA? ----------------
lr = np.log(P["px"] / P["px"].shift(1))
print("\n RIPREZZATURA ALLA VOLATILITA' REALIZZATA — diagnostica con LOOK-AHEAD DICHIARATO,")
print(" non e' una strategia: e' il valore equo ex-post. Se l'edge sparisce, era IV-RV.")
for h in (7, 14):
fw = (lr[::-1].rolling(h).std()[::-1] * math.sqrt(DPY)).bfill().ffill()
j = pd.concat({"iv": P["iv"], "fw": fw}, axis=1).dropna()
print(f" DVOL / RV-forward a {h}g: mediana {(j['iv']/j['fw']).median():.3f} · "
f"DVOL sopra nel {100*(j['iv']>j['fw']).mean():.1f}% dei giorni")
for tenor in (7, 14):
fw = (lr[::-1].rolling(tenor).std()[::-1] * math.sqrt(DPY)).bfill().ffill()
Pf = P.copy()
Pf["iv"] = fw.reindex(P.index).values
for forte in (True, False):
dirs = gate_trend(P, forte)
rb = collar(P, cal, dput=0.2, dcall=0.2, tenor=7, dirs=dirs, senza_opzioni=True)["ret"]
for dp, dc in ((0.02, 0.50), (0.10, 0.30)):
a = collar(P, cal, dput=dp, dcall=dc, tenor=tenor, dirs=dirs)["ret"]
b = collar(Pf, cal, dput=dp, dcall=dc, tenor=tenor, dirs=dirs)["ret"]
ka, kb = k_for_same_dd(rb.values, _dd(a)), k_for_same_dd(rb.values, _dd(b))
wa = _drift(a) > _drift(ka * rb.values) and _dd(a) < _dd(rb)
wb = _drift(b) > _drift(kb * rb.values) and _dd(b) < _dd(rb)
print(f" {'FORTE' if forte else 'LARGO'} {tenor:>2}g dput {dp:.2f} dcall {dc:.2f}: "
f"a DVOL Sh {_sh(a):6.3f} drift {100*_drift(a):+7.2f}% {'VINCE' if wa else 'perde'}"
f" | a RV Sh {_sh(b):6.3f} drift {100*_drift(b):+7.2f}% {'VINCE' if wb else 'perde'}"
f" | VRP {100*(_drift(a)-_drift(b)):+6.2f} pp")
# ---- USCITA ANTICIPATA (richiesta dell'operatore: fra il 50% e il 75% del tempo) ---------
print("\n" + "=" * 96)
print(" USCITA ANTICIPATA DALLE OPZIONI — exit_frac 0,50 / 0,625 / 0,75 contro la scadenza.")
print(" §46 misuro' che 'f si paga solo sulla parte di valore che converge a intrinseco,")
print(" quindi un roll anticipato NON lo paga'. ⚠️ Vale per una copertura SOLO LONG: in un")
print(" collar c'e' una gamba VENDUTA da RICOMPRARE, e uscire prima paga f esattamente sulla")
print(" parte che a scadenza si sarebbe regolata gratis. L'asimmetria si INVERTE.")
lr2 = np.log(P["px"] / P["px"].shift(1))
for forte in (True, False):
dirs = gate_trend(P, forte)
rb = collar(P, cal, dput=0.2, dcall=0.2, tenor=7, dirs=dirs, senza_opzioni=True)["ret"]
print(f"\n gate {'FORTE' if forte else 'LARGO'} (base drift {100*_drift(rb):+.2f}%/a):")
for dp, dc in ((0.10, 0.30), (0.20, 0.20), (0.02, 0.50)):
for ef in (1.00, 0.75, 0.625, 0.50):
a = collar(P, cal, dput=dp, dcall=dc, tenor=14, dirs=dirs, exit_frac=ef)["ret"]
hh = max(1, int(round(ef * 14)))
fw = (lr2[::-1].rolling(hh).std()[::-1] * math.sqrt(DPY)).bfill().ffill()
Pf = P.copy()
Pf["iv"] = fw.reindex(P.index).values
b = collar(Pf, cal, dput=dp, dcall=dc, tenor=14, dirs=dirs, exit_frac=ef)["ret"]
k = k_for_same_dd(rb.values, _dd(a))
w = (_drift(a) > _drift(k * rb.values)) and (_dd(a) < _dd(rb))
print(f" d{dp:.2f}/{dc:.2f} exit {ef:.3f}: Sh {_sh(a):6.3f} DD {100*_dd(a):6.2f}% "
f"drift {100*_drift(a):+7.2f}% null {100*_drift(k*rb.values):+6.2f}% "
f"{'VINCE' if w else 'perde'} | a RV drift {100*_drift(b):+7.2f}% "
f"VRP {100*(_drift(a)-_drift(b)):+6.2f}pp")
# ---- CONTROLLI DELL'APPARATO (M15) — un rilevatore che non li passa non misura nulla ----
print("\n CONTROLLI DELL'APPARATO:")
dirs = gate_trend(P, True)
rb = collar(P, cal, dput=0.2, dcall=0.2, tenor=7, dirs=dirs, senza_opzioni=True)["ret"]
esiti = []
g = collar(P, cal, dput=0.20, dcall=0.20, tenor=14, dirs=dirs,
prem_scale=0.0, solo_pavimento=True)["ret"]
ok_g = (_dd(g) < _dd(rb)) and (_drift(g) > _drift(rb))
esiti.append(ok_g)
print(f" pranzo gratis (pavimento a premio ZERO): maxDD {100*_dd(rb):.2f}%->{100*_dd(g):.2f}% "
f"drift {100*_drift(rb):+.2f}%->{100*_drift(g):+.2f}% {'OK' if ok_g else 'FALLITO'}")
c = collar(P, cal, dput=0.20, dcall=0.20, tenor=14, dirs=dirs, prem_scale=10.0)["ret"]
ok_c = _drift(c) < _drift(rb)
esiti.append(ok_c)
print(f" premio x10 (deve perdere): drift {100*_drift(c):+.2f}%/a "
f"{'OK' if ok_c else 'FALLITO'}")
zc = collar(P, cal, dput=0.20, dcall=np.nan, tenor=14, dirs=dirs, zerocost=True)
prem_res = np.nan
ok_z = bool(len(zc.get("cicli", [])) > 0) and np.isfinite(_drift(zc["ret"]))
esiti.append(ok_z)
print(f" zero-cost costruibile e finito: drift {100*_drift(zc['ret']):+.2f}%/a "
f"{'OK' if ok_z else 'FALLITO'}")
if not all(esiti):
print(" 🚨 APPARATO GUASTO: il verdetto sotto NON e' leggibile (§46: un controllo "
"positivo rotto dichiara guasto l'apparato)")
# ---- VERDETTO A RUNTIME (N11) ------------------------------------------------------------
print("\n" + "=" * 96)
# il verdetto deve tenere conto della diagnostica VRP: il bordo vince, ma di che cosa?
dirs_f = gate_trend(P, True)
rb_f = collar(P, cal, dput=0.2, dcall=0.2, tenor=7, dirs=dirs_f, senza_opzioni=True)["ret"]
fw7 = (np.log(P["px"] / P["px"].shift(1))[::-1].rolling(7).std()[::-1]
* math.sqrt(DPY)).bfill().ffill()
Pf7 = P.copy()
Pf7["iv"] = fw7.reindex(P.index).values
ang_dvol = collar(P, cal, dput=0.02, dcall=0.50, tenor=7, dirs=dirs_f)["ret"]
ang_rv = collar(Pf7, cal, dput=0.02, dcall=0.50, tenor=7, dirs=dirs_f)["ret"]
quota_vrp = (_drift(ang_dvol) - _drift(ang_rv)) / max(_drift(ang_dvol), 1e-9)
if n_dd == 0:
v = "IL PAVIMENTO NON RIDUCE IL maxDD — cade come §46 e per lo stesso motivo"
elif n_win <= len(R) // 8:
v = (f"IL PAVIMENTO FUNZIONA (a differenza di §46: maxDD scende in {n_dd}/{len(R)}) MA IL "
f"TETTO LO PAGA TROPPO: solo {n_win}/{len(R)} celle battono il de-levering. "
f"E cio' che vince sul bordo NON e' il pavimento: e' il premio di varianza "
f"({100*quota_vrp:.0f}% del drift dell'angolo sparisce riprezzando alla vol vera) "
f"— cioe' il meccanismo di VRP01, non una scoperta, e §3 lo blocca in deploy")
else:
v = f"REGGE IL PRIMO GATE: {n_win}/{len(R)} celle battono il de-levering — serve M2/DSR"
print(f" VERDETTO (calcolato a runtime): {v}")
print("=" * 96)
if __name__ == "__main__":
main()
+263
View File
@@ -0,0 +1,263 @@
"""PAVIMENTO-LEVA — il pavimento come LICENZA DI TAGLIA, non come sconto sul drawdown.
DOMANDA (operatore, 2026-09-01): "possiamo usare quanto conosciamo del pavimento per studiare
una strategia?"
COSA SAPPIAMO DEL PAVIMENTO (COLLAR01, §71, stesso giorno):
- FUNZIONA: con beta 1,0 il maxDD scende in 36/48 celle (§46 lo negava per un libro a beta 0,076);
- a premio ZERO vale moltissimo: maxDD 51,83% -> 36,76%, drift +10,04% -> +35,69% (il pranzo gratis);
- cio' che uccise il collar fu il TETTO, non il pavimento (C9: il tetto tagliava il 46% dei cicli
vincenti, il pavimento parava il 6,7% dei perdenti);
- la put e' l'ala CARA (skew: IV_put/ATM 1,25 a d0,10) e il DVOL sta il 32% sopra la RV: comprare
il pavimento significa pagare il premio di varianza. Qui il prezzo a DVOL e' REALISTICO (e'
cio' che il mercato chiede): M29 corregge chi VENDE a modello, non chi compra.
L'INVERSIONE. COLLAR01 chiese "quanto DD mi risparmia il pavimento a taglia fissa?" e perse contro il
de-levering. La domanda speculare e' "quanta TAGLIA mi autorizza il pavimento a DD fisso?". Sembra
la stessa (a iso-DD e' un solo confronto), ma non lo e' per due ragioni misurabili:
(1) il de-levering riduce taglia e rischio IN PROPORZIONE; un pavimento cambia la FORMA se la
forma taglia la coda sinistra piu' di quanto costi, scalare in su la struttura protetta puo'
battere la base nuda alla stessa DD. E' l'unica cosa che il de-levering NON puo' comprare;
(2) il tetto di leva del progetto NON e' fissato dallo Sharpe ma dal COSTO DI UN EPISODIO di
disaster-SL: n_asset * frac * scala * sl_pct <= 0,50 => k <= 1,67x (GATE SCALA-01, §4.5).
Il disaster-SL e' ROTOLANTE e non limita la perdita (peggior DD dall'ingresso misurato
-60,6%). Una put limita la perdita PER COSTRUZIONE dentro la sua finestra.
ATTESE A PRIORI (scritte prima di misurare):
B1 A premio reale il pavimento LICENZIA taglia (k_f > 1) ma il drift della struttura scalata
resta SOTTO quello della base nuda: il premio costa piu' di quanto la licenza renda. Previsione
centrale: il pavimento perde il test della licenza nella maggioranza delle celle.
B2 La riduzione di DD viene dai crolli VELOCI (dentro una finestra di opzione), non dai declini
LENTI: in un grind di settimane la put scade OTM ogni volta e si paga premio E perdita.
Il maxDD di BTC 2021-2026 e' il 2022, che fu un grind => il pavimento lo protegge poco.
B3 La "licenza" del disaster-SL e' di CARTA: l'invariante limita UN episodio, la put limita UNA
finestra, e il vero massimo su finestre consecutive NON e' limitato. Sostituire sl_pct con la
distanza del pavimento nell'invariante sarebbe un errore — e va scritto ora, prima che
qualcuno lo proponga.
B4 Il pavimento gated su IV a buon mercato (DVOL/RV basso) migliora ma non basta a passare.
NESSUN ORDINE. Libro, pesi, cron, config INVARIATI.
"""
from __future__ import annotations
import math
import sys
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
import r0901_btc_collar as C # noqa: E402 — il motore, riusato e non riscritto
DPY = C.DPY
G_DPUT = (0.05, 0.10, 0.20, 0.30)
G_TENOR = (7, 14)
def k_up_for_same_dd(r: np.ndarray, target_dd: float, kmax: float = 6.0) -> float:
"""La scala >=1 che porta il maxDD della struttura PROTETTA esattamente a quello della base NUDA.
E' il null M5 letto al contrario: invece di ridurre la base al DD del protetto, si ALZA il
protetto al DD della base. maxDD(k*r) e' crescente in k: bisezione."""
if C._dd(r) >= target_dd:
return 1.0
lo, hi = 1.0, kmax
if C._dd(hi * r) < target_dd:
return kmax
for _ in range(60):
mid = 0.5 * (lo + hi)
if C._dd(mid * r) > target_dd:
hi = mid
else:
lo = mid
return 0.5 * (lo + hi)
def peggior_finestra(nav: pd.Series, giorni: int) -> float:
"""La peggior perdita su una finestra di `giorni` (quella che UNA put puo' coprire)."""
r = nav / nav.shift(giorni) - 1.0
return float(r.min())
def main() -> None:
print("=" * 96)
print(" PAVIMENTO-LEVA — il pavimento come licenza di taglia")
print("=" * 96)
print(__doc__.split("ATTESE A PRIORI")[1].split("NESSUN ORDINE")[0])
cal = C.calibra_catena()
P = C.serie_giornaliera()
print(f" LENTE LUNGA {P.index[0].date()} -> {P.index[-1].date()} ({len(P)/DPY:.2f} anni). "
f"Prezzo a DVOL x skew x termine misurati sulla catena (realistico per chi COMPRA).")
righe = []
for forte in (True, False):
dirs = C.gate_trend(P, forte)
nm = "FORTE" if forte else "LARGO"
base = C.collar(P, cal, dput=0.2, dcall=0.2, tenor=7, dirs=dirs, senza_opzioni=True)
rb, nb = base["ret"], base["nav"]
dd_b, dr_b, sh_b = C._dd(rb), C._drift(rb), C._sh(rb)
print(f"\n ── gate {nm}: BASE nuda Sharpe {sh_b:.3f} · maxDD {100*dd_b:.2f}% · drift {100*dr_b:+.2f}%/a"
f" · peggior 7g {100*peggior_finestra(nb,7):.1f}% · peggior 14g {100*peggior_finestra(nb,14):.1f}%")
print(f" {'ten':>4}{'dput':>6}{'Sharpe':>8}{'maxDD':>8}{'drift':>9} | "
f"{'k_f':>6}{'drift@k_f':>11}{'vs base':>9} | {'k null':>7}{'drift null':>11}{'vs':>4} | "
f"{'pegg7':>7}{'pegg14':>7}{'pav.para':>9}")
for tenor in G_TENOR:
for dp in G_DPUT:
o = C.collar(P, cal, dput=dp, dcall=0.2, tenor=tenor, dirs=dirs, solo_pavimento=True)
r, nav = o["ret"], o["nav"]
dd, dr, sh = C._dd(r), C._drift(r), C._sh(r)
# (a) LICENZA: alzo il protetto fino al DD della base
kf = k_up_for_same_dd(r.values, dd_b)
dr_kf = C._drift(kf * r.values)
lic = dr_kf > dr_b
# (b) NULL M5 classico: abbasso la base fino al DD del protetto
kn = C.k_for_same_dd(rb.values, dd)
dr_n = C._drift(kn * rb.values)
m5 = dr > dr_n and dd < dd_b
cic = pd.DataFrame(o["cicli"])
para = 100.0 * cic["floor"].mean() if len(cic) else float("nan")
righe.append(dict(gate=nm, forte=forte, tenor=tenor, dp=dp, sh=sh, dd=dd, drift=dr,
kf=kf, drift_kf=dr_kf, licenza=lic, kn=kn, drift_null=dr_n, m5=m5,
pegg7=peggior_finestra(nav, 7), pegg14=peggior_finestra(nav, 14),
para=para, ret=r, nav=nav))
print(f" {tenor:>4}{dp:>6.2f}{sh:>8.3f}{100*dd:>7.2f}%{100*dr:>8.2f}% | "
f"{kf:>6.3f}{100*dr_kf:>10.2f}%{('VINCE' if lic else 'perde'):>9} | "
f"{kn:>7.3f}{100*dr_n:>10.2f}%{('V' if m5 else 'p'):>4} | "
f"{100*peggior_finestra(nav,7):>6.1f}%{100*peggior_finestra(nav,14):>6.1f}%{para:>8.1f}%")
R = pd.DataFrame(righe)
n_lic = int(R["licenza"].sum())
print("\n" + "=" * 96)
print(f" B1 il pavimento a premio reale VINCE il test della LICENZA in {n_lic}/{len(R)} celle "
f"({'REFUTATA' if n_lic > len(R)//2 else 'CONFERMATA'})")
print(f" (e il null M5 classico in {int(R['m5'].sum())}/{len(R)}: le due letture "
f"{'concordano' if abs(n_lic-int(R['m5'].sum()))<=2 else 'DIVERGONO — va capito perche'}) ")
# ---- B2: da dove viene la riduzione di DD? crolli veloci o grind? ------------------------
print("\n B2 MECCANISMO — la put copre UNA finestra: protegge i crolli veloci, non i grind.")
for forte in (True, False):
dirs = C.gate_trend(P, forte)
nm = "FORTE" if forte else "LARGO"
base = C.collar(P, cal, dput=0.2, dcall=0.2, tenor=7, dirs=dirs, senza_opzioni=True)
nb = base["nav"]
best = R[(R["forte"] == forte)].sort_values("sh", ascending=False).iloc[0]
nf = best["nav"]
# il maxDD: quando, e quanto e' durato
for lbl, nav in (("base", nb), ("pavimento", nf)):
pk = nav.cummax()
ddser = nav / pk - 1.0
i_min = ddser.idxmin()
i_pk = nav[:i_min].idxmax()
durata = (i_min - i_pk).days
print(f" {nm} {lbl:>9}: maxDD {100*ddser.min():.2f}% da {i_pk.date()} a {i_min.date()} "
f"{durata} giorni ({'GRIND' if durata > 3*best['tenor'] else 'crollo dentro ~1 finestra'})")
# perdita "dentro finestra" contro "fra finestre": quanto del DD e' coperto dalla put
pegg_b = peggior_finestra(nb, best["tenor"])
pegg_f = peggior_finestra(nf, best["tenor"])
print(f" {nm} peggior finestra di {best['tenor']}g: base {100*pegg_b:.1f}% -> "
f"pavimento {100*pegg_f:.1f}% (la put fa QUESTO); il maxDD invece "
f"{100*C._dd(nb/nb.shift(1)-1):.1f}% -> {100*best['dd']:.1f}% (i grind restano)")
# ---- B3: l'invariante del disaster-SL con un pavimento: licenza di carta ------------------
print("\n B3 L'INVARIANTE DEL DISASTER-SL con un pavimento — licenza di CARTA.")
frac, sl = 0.50, 0.30
k_inv = 0.50 / (2 * frac * sl)
print(f" oggi: n_asset 2 x frac {frac} x scala x sl {sl} <= 0,50 => scala <= {k_inv:.2f}x")
for forte in (True,):
best = R[R["forte"] == forte].sort_values("sh", ascending=False).iloc[0]
cic = pd.DataFrame(best["cicli"] if "cicli" in best else [])
dirs = C.gate_trend(P, forte)
o = C.collar(P, cal, dput=best["dp"], dcall=0.2, tenor=best["tenor"], dirs=dirs, solo_pavimento=True)
cic = pd.DataFrame(o["cicli"])
# distanza mediana del pavimento dallo spot all'apertura
cic["dist"] = (cic["S0"] - cic["Kl"]) / cic["S0"] * cic["dirn"]
dist = float(cic["dist"].abs().median())
k_carta = 0.50 / (2 * frac * dist)
print(f" con la put a d{best['dp']:.2f}/{best['tenor']}g il pavimento sta a {100*dist:.1f}% "
f"dallo spot: sostituire sl con la distanza darebbe scala <= {k_carta:.2f}x ({k_carta/k_inv:.1f}x in piu')")
# ma la perdita su finestre CONSECUTIVE non e' limitata: la misuro
nf = best["nav"]
for n_fin in (1, 2, 4, 8):
g = n_fin * best["tenor"]
print(f" peggior perdita su {n_fin} finestre consecutive ({g:>3}g): "
f"{100*peggior_finestra(nf, g):.1f}% "
f"{'<= una finestra: la put copre' if n_fin == 1 else '-> la put NON copre: e'' un grind'}")
print(f" ⇒ la licenza {k_carta:.2f}x vale per UN crollo dentro UNA finestra; su un grind di "
f"{8*best['tenor']} giorni la perdita e' {100*abs(peggior_finestra(nf, 8*best['tenor'])):.0f}%, "
f"e a {k_carta:.1f}x farebbe {100*k_carta*abs(peggior_finestra(nf, 8*best['tenor'])):.0f}% dell'equity.")
# ---- B4: pavimento GATED su IV a buon mercato -------------------------------------------
print("\n B4 PAVIMENTO GATED sull'IV — compro la put solo quando il premio di varianza e' sottile.")
print(" (§46: 'nessuna copertura dinamica gated su regime e'' stata provata' — qui lo e'.)")
lr = np.log(P["px"] / P["px"].shift(1))
rv30 = (lr.rolling(30).std() * math.sqrt(DPY))
ratio = (P["iv"] / rv30) # DVOL / RV trailing: quanto e' cara la vol OGGI
pct = ratio.rolling(365, min_periods=90).rank(pct=True)
for forte in (True, False):
dirs = C.gate_trend(P, forte)
nm = "FORTE" if forte else "LARGO"
base = C.collar(P, cal, dput=0.2, dcall=0.2, tenor=7, dirs=dirs, senza_opzioni=True)
rb = base["ret"]; dd_b, dr_b = C._dd(rb), C._drift(rb)
best = R[R["forte"] == forte].sort_values("sh", ascending=False).iloc[0]
for soglia in (0.25, 0.50):
# NOTA: il gate di IV si applica alla PUT, non alla posizione. Lo simulo con due
# strutture (protetta / nuda) e le incollo giorno per giorno sul segnale di IV, pagando
# lo spread alle transizioni tramite il motore stesso (ricalcolo con dirs modulati).
economico = (pct.shift(1) <= soglia).reindex(P.index).fillna(False).values
r_prot = best["ret"].reindex(P.index)
r_nuda = rb.reindex(P.index)
r_mix = pd.Series(np.where(economico, r_prot, r_nuda), index=P.index).dropna()
dd, dr = C._dd(r_mix), C._drift(r_mix)
kf = k_up_for_same_dd(r_mix.values, dd_b)
dr_kf = C._drift(kf * r_mix.values)
quota = 100.0 * np.mean(economico[np.asarray(dirs) != 0]) if np.any(np.asarray(dirs) != 0) else 0.0
print(f" {nm} put ON se DVOL/RV sotto il {100*soglia:.0f}° pctl (ON il {quota:.0f}% dei giorni a mercato): "
f"Sh {C._sh(r_mix):.3f} DD {100*dd:.2f}% drift {100*dr:+.2f}% | licenza k_f {kf:.3f} "
f"-> drift {100*dr_kf:+.2f}% vs base {100*dr_b:+.2f}% {'VINCE' if dr_kf > dr_b else 'perde'}")
print(" ⚠️ approssimazione dichiarata: l'incollatura ignora lo spread delle transizioni ON/OFF "
"della put (a favore del gated). Se perde COSI', perde a maggior ragione.")
# ---- IL PREMIO DI PAREGGIO: quanto dovrebbe costare la put perche' il pavimento protegga? --
print("\n PREMIO DI PAREGGIO — a quale frazione del premio REALE il pavimento pareggia il maxDD nudo.")
print(" (Il DVOL sta 1,32x sopra la RV-forward: un prezzo 'equo' sarebbe ~76% del reale.)")
pareggi = []
for forte in (True, False):
dirs = C.gate_trend(P, forte)
nm = "FORTE" if forte else "LARGO"
base = C.collar(P, cal, dput=0.2, dcall=0.2, tenor=7, dirs=dirs, senza_opzioni=True)["ret"]
ddb = C._dd(base)
for tenor, dp in ((14, 0.05), (14, 0.10), (7, 0.10), (14, 0.20)):
lo, hi = 0.0, 1.0
for _ in range(25):
md = 0.5 * (lo + hi)
r = C.collar(P, cal, dput=dp, dcall=0.2, tenor=tenor, dirs=dirs,
solo_pavimento=True, prem_scale=md)["ret"]
if C._dd(r) < ddb:
lo = md
else:
hi = md
pareggi.append(0.5 * (lo + hi))
print(f" {nm} d{dp:.2f}/{tenor}g: pareggio del maxDD a **{100*0.5*(lo+hi):.0f}%** del premio reale")
print(f" ⇒ banda {100*min(pareggi):.0f}-{100*max(pareggi):.0f}%: il mercato dovrebbe scontare la put del "
f"{100*(1-max(pareggi)):.0f}-{100*(1-min(pareggi)):.0f}% perche' il pavimento INIZI a proteggere.")
# ---- VERDETTO A RUNTIME -----------------------------------------------------------------
print("\n" + "=" * 96)
if n_lic == 0:
v = ("IL PAVIMENTO NON LICENZIA TAGLIA A PREMIO REALE: 0 celle. Cio' che il de-levering non "
"puo' comprare, la put lo vende a un prezzo che nessuna cella ripaga")
elif n_lic <= len(R) // 4:
v = f"MARGINALE: {n_lic}/{len(R)} celle — dentro il rumore di griglia, e sul bordo"
else:
v = f"IL PAVIMENTO LICENZIA TAGLIA in {n_lic}/{len(R)} celle — serve M2/DSR e il null di permutazione"
print(f" VERDETTO (calcolato a runtime): {v}")
print("=" * 96)
if __name__ == "__main__":
main()
+90
View File
@@ -0,0 +1,90 @@
"""SOLDI-FERMI — altri modi per far rendere il capitale che sarebbe fermo. Non per forza trading.
DOMANDA (operatore, 2026-09-01): "troviamo altri modi per fare guadagnare con soldi che sarebbero
fermi. non per forza trading".
DOVE STA IL CAPITALE FERMO (letto, non assunto):
- Deribit: USDC $1.406 (zero resa: Italia esclusa dai reward, misurato su due confini di mese),
USDE 654 (~4,3%, AL TETTO del venue ~31,8%), libro a 0,27x. L'USDC NON e' fermo: e' la base
di sizing del libro (cap = equity x 0,5) e il cuscino di regolamento del disaster-SL (30%).
- Fuori: ~6.043 in XEON "a ~0% reale netto" (memoria 27/07, non riletto oggi). E' lo SPLIT-CASSA:
la protezione dal rischio venue, che porta P(perso tutto) da 18% a 3,5% a p=1%. E la memoria
dichiara di NON sapere quanto sia fondo d'emergenza.
ELENCO COMPLETO DELLE VIE ON-VENUE verificato oggi su public/get_currencies: 4 valute su 50 hanno
APR>0 (USDE 4,32 · USDC 3,40 · BUIDL 3,20 · STETH 2,22), e sono ESATTAMENTE le quattro chiuse il
31/08. Non era un campione: era la lista. On-venue e' chiuso.
TASSI DI OGGI (web, fonte dichiarata non li so a memoria, cutoff 05/2026):
- BOT 12 mesi: 2,768% lordo, asta di agosto 2026 (Banca d'Italia, 14/08/2026)
- BCE deposit facility 2,25% (23/07/2026; prossima riunione 10/09) => STR ~2,15-2,20%
- conti deposito vincolati 12m: 3,25-3,50% lordo (Aidexa 3,40 · Banca Progetto 3,50 · Cherry 3,50)
- Ethena sUSDe diretto: ~9% (7g) / 11,8% (90g) ad aprile 2026, "high single digits" nel Q2 2026
FISCO (dalla memoria del progetto, §5.4 di CLAUDE.md NON sono pareri fiscali):
- titoli di Stato: 12,5% · conti deposito e ETF monetari: 26% · cripto (c-sexies): 33%
- imposta di bollo 0,2%/anno sul valore (deposito titoli e conti deposito; sulle cripto e' il 2‰ in RW)
Il confronto e' in EURO NETTI L'ANNO su un capitale dichiarato, e ogni opzione porta il suo rischio
e il suo effetto sullo SPLIT perche' i €6k hanno un LAVORO, e un'opzione che lo distrugge non e'
"piu' resa": e' un cambio di piano.
"""
from __future__ import annotations
CAPITALE = 6000.0 # la parte dei €6.043 assunta investibile a 12+ mesi (ASSUNZIONE)
BOLLO = 0.002
# opzione: (nome, lordo, aliquota, bollo, rischio, effetto sullo split, liquidita')
OPZIONI = [
("XEON (status quo)", 0.0210, 0.26, BOLLO, "~zero (€STR, UCITS)", "INTATTO", "giornaliera"),
("BOT 12 mesi", 0.02768, 0.125, BOLLO, "~zero (Stato IT)", "INTATTO", "a scadenza / MOT"),
("Conto deposito vinc. 12m 3,50%", 0.0350, 0.26, BOLLO, "~zero (FITD ≤ €100k)", "INTATTO", "vincolata 12m"),
("Conto deposito vinc. 12m 3,25%", 0.0325, 0.26, BOLLO, "~zero (FITD ≤ €100k)", "INTATTO", "vincolata 12m"),
("sUSDe diretto (Ethena)", 0.0900, 0.33, 0.002, "EMITTENTE Ethena + smart contract", "DISTRUTTO (stesso emittente dell'USDE gia' in conto)", "on-chain, unstake 7g"),
("Versare sul libro Deribit", 0.1000, 0.00, 0.000, "maxDD ~11%, venue p=1%/a, rendimento NON garantito", "DISTRUTTO (100% Deribit)", "oraria"),
]
# NOTA sul libro: 10% e' il drift NETTO di funding e fisco della lente `hourly` (17,11% lordo 2,16%
# funding, poi ~26-33% di fisco sui gain): e' un'ATTESA con banda larga, non un tasso. Lo metto per
# confronto perche' e' l'alternativa che la memoria indica come dominante — e per mostrarne il prezzo.
def netto(lordo, aliquota, bollo, cap=CAPITALE):
return cap * (lordo * (1.0 - aliquota) - bollo)
def main():
print("=" * 100)
print(f" SOLDI-FERMI — cosa rende €{CAPITALE:,.0f} fermi, in euro NETTI l'anno (assunzione: investibili 12+ mesi)")
print("=" * 100)
print(f" {'opzione':34s} {'lordo':>6s} {'tassa':>6s} {'netto/a':>9s} {'vs XEON':>8s} rischio · split · liquidita'")
base = netto(*OPZIONI[0][1:4])
for nome, l, t, b, rischio, split, liq in OPZIONI:
n = netto(l, t, b)
print(f" {nome:34s} {100*l:5.2f}% {100*t:5.1f}% {n:8.0f}{n-base:+7.0f}{rischio} · split {split} · {liq}")
print()
print(" LETTURA:")
sicure = [o for o in OPZIONI if o[5] == "INTATTO" and o[0] != OPZIONI[0][0]]
best = max(sicure, key=lambda o: netto(*o[1:4]))
guad = netto(*best[1:4]) - base
print(f" - fra le opzioni che LASCIANO INTATTO lo split, la migliore e' «{best[0]}»: "
f"+{guad:.0f} €/anno sullo status quo = {guad/12:.1f} €/mese.")
print(f" - il BOT paga meno lordo del conto deposito ma il 12,5% contro il 26% lo porta a "
f"{netto(*OPZIONI[1][1:4]):.0f}€ contro {netto(*OPZIONI[2][1:4]):.0f}€: "
f"la differenza e' {netto(*OPZIONI[2][1:4])-netto(*OPZIONI[1][1:4]):.0f}€/anno, dentro il rumore di UNA asta.")
print(f" - sUSDe diretto rende ~2x Deribit (che trattiene meta' dell'APR Ethena) ma mette i €6k sullo "
f"STESSO emittente dei 654 USDE gia' in conto: lo split-cassa esiste per NON essere correlato al venue.")
print(f" - versare sul libro e' l'alternativa dominante della memoria (13 anni, 2,45x) — ma e' trading, "
f"e distrugge lo split. Non e' 'far rendere i soldi fermi': e' cambiare piano.")
print()
print(" LA TAGLIA, che e' la cosa da guardare per prima:")
print(f" - il miglior guadagno SICURO sullo status quo vale {guad:.0f} €/anno;")
print(f" - €100/mese di bonifico in piu' valgono +4,07%/anno di drift del libro (misurato, `r0825_piano_10a_500`).")
print(f" ⇒ esiste un modo, e' reale, e vale {guad/12:.0f} €/mese. Non cambia il piano. Lo cambia il bonifico.")
print()
print(" ON-VENUE (per completezza, chiuso): 4/50 valute remunerate = le 4 gia' chiuse. La chiave di scala "
"(GATE SCALA-01, non prima di ottobre) a 1,25x libererebbe il 20% del conto = ~$400 → ~$10/anno.")
print("=" * 100)
if __name__ == "__main__":
main()
+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()
+4 -1
View File
@@ -205,7 +205,10 @@ def testata(voce: dict) -> str:
r = [f"<b>📓 Giornale — {_b(voce['giorno'])}</b>"]
r.append(" equity " + (f"<b>${eq:,.2f}</b>" if eq is not None else "n/d")
+ ("" if dq is None else f" ({dq:+.2f} oggi)")
+ ("" if cum is None else f" · dall'arming {cum:+.2f}"))
+ ("" if cum is None else f" · dall'arming {cum:+.2f}"
+ (f" (di cui {p['mov_arming']:+.2f} versati -> trading "
f"{p['trading_da_arming']:+.2f})"
if p.get("mov_arming") and p.get("trading_da_arming") is not None else "")))
if "errore" not in lb and lb.get("asset"):
pos = " · ".join(f"{a} ${v['posizione']:+,.0f}" for a, v in sorted(lb["asset"].items()))
r.append(f" {pos} · leva {lb.get('leva_lorda') or 0:.2f}x")
+93 -4
View File
@@ -63,6 +63,79 @@ EQUITY_WATERMARK = PROJECT_ROOT / "data" / "live" / "equity_seen.json"
# sicura, non il cap di config (che puo' essere stato alzato in previsione di un deposito).
CAP_UNKNOWN_USD = 300.0
# ---- CHIAVE DI SCALA (GATE SCALA-01) — spec: docs/research/SPEC-scale-key.md ------------------
# ⚠️ COSTANTI DI CODICE, NON DI CONFIG, e il motivo e' il meccanismo del cricchetto (SPEC §4.1b):
# un tetto che vive nello stesso file della chiave sarebbe "un lucchetto con la chiave attaccata".
# Portare il gradino a 1,50 richiede una modifica di CODICE -> una review -> un gate.
# Il tetto e' basso APPOSTA: non e' un'opinione sulla leva massima sopportabile (che da §4.5
# sarebbe 1,67x), e' il dispositivo che rende impossibile alzarla "solo un po'".
LEVA_LORDA_MAX = 1.25
# La scaletta esiste perche' una scala A GRADINI rende INESPRIMIBILE "solo un po'": 1,05 non e'
# un valore prudente, e' un valore fuori scaletta, e viene RIFIUTATO.
SCALA_LADDER = (1.00, 1.25)
# Invariante del disaster-SL (SPEC §4.5, la guardia che morde per PRIMA): il costo in equity di un
# episodio di disaster-SL e' `n_asset * frac * scala * disaster_sl_pct` e non deve superare mezza
# equity. Con frac 0,50 e sl 30% da' scala <= 1,67x, e scatta se qualcuno allarga il disaster-SL.
DISASTER_SL_BUDGET = 0.50
class ScalaNonAutorizzata(RuntimeError):
"""Config che dichiara una scala fuori scaletta o fuori tetto.
E' un'ECCEZIONE e non un clamp, ed e' una scelta (SPEC §4.1c): tagliare silenziosamente al
tetto farebbe girare una config che DICHIARA un numero e un libro che ne ESEGUE un altro
la stessa classe di difetto che questo progetto ha gia' pagato tre volte. Chi la incontra
ferma l'esecuzione e allerta; non "rende robusto" il lettore con un min().
"""
def _scala(real_equity: float | None = None, eq_fallback: str | None = None,
cfg: dict | None = None) -> float:
"""Scala del libro (`book_scale_k` in config). 1.0 = il libro di oggi, bit-exact.
DUE COMPORTAMENTI, entrambi voluti:
(1) VALIDA SEMPRE, anche sul percorso degradato. Una config fuori scaletta o fuori tetto e'
un errore di configurazione, non una condizione di mercato: dev'essere scoperta al primo
giro, non nascosta dal fatto che oggi l'equity non era leggibile.
(2) POI applica la regola G3/G4: **la scala vive SOLO sul percorso fidato**. Se l'equity reale
non e' leggibile la scala e' 1,00 *una scala e' una decisione di rischio presa conoscendo
il conto; quando non si sa quanto vale il conto, non si prende*. Cosi' "il fallback non e'
piu' permissivo" e' vero PER COSTRUZIONE, non per aritmetica.
Il tetto e' sul PRODOTTO `n_asset * frac * scala`, mai sulla sola chiave: con un tetto sulla
sola chiave, alzare `frac` da 0,50 a 0,625 con scala 1,25 darebbe 1,562x passando il controllo.
"""
if cfg is None:
cfg = json.loads(CONFIG.read_text()) if CONFIG.exists() else {}
raw = cfg.get("book_scale_k")
if raw is None:
return 1.0 # chiave assente = il libro di oggi
s = float(raw)
if not any(abs(s - x) < 1e-9 for x in SCALA_LADDER):
raise ScalaNonAutorizzata(
f"book_scale_k={s!r} non e' un gradino di SCALA_LADDER {SCALA_LADDER}: "
f"una scala a gradini rende inesprimibile 'solo un po''. Config: {CONFIG}")
frac = cfg.get("max_notional_per_asset_frac")
if frac is not None:
leva = len(ASSETS) * float(frac) * s
if leva > LEVA_LORDA_MAX + 1e-12:
raise ScalaNonAutorizzata(
f"leva lorda {leva:.4f}x = n_asset {len(ASSETS)} x frac {frac} x scala {s} "
f"supera LEVA_LORDA_MAX {LEVA_LORDA_MAX}x (tetto sul PRODOTTO, src/live/book.py). "
f"Alzarlo e' una modifica di CODICE, non di config.")
sl = float(cfg.get("disaster_sl_pct", 0.0))
costo = leva * sl
if costo > DISASTER_SL_BUDGET + 1e-12:
raise ScalaNonAutorizzata(
f"un episodio di disaster-SL costerebbe {100*costo:.1f}% dell'equity "
f"(n_asset x frac {frac} x scala {s} x sl {sl}) > {100*DISASTER_SL_BUDGET:.0f}%: "
f"e' la guardia che morde per prima (SPEC-scale-key §4.5).")
if real_equity is None or eq_fallback:
return 1.0 # G3/G4: niente scala fuori dal percorso fidato
return s
def _read_watermark() -> float | None:
"""Ultima equity REALE osservata sul conto. None se mai vista o file illeggibile."""
@@ -136,12 +209,22 @@ def _cap(equity: float | None = None, real_equity: float | None = None,
def book_net_target(tp_frac: float, skh_sign: int, equity: float, cap: float,
weight: float = WEIGHT) -> float:
weight: float = WEIGHT, scala: float = 1.0) -> float:
"""Target NETTO (USD notional, segno = direzione) di un asset del book. PURA, testabile.
Combina la frazione long-flat di TP01 (peso 0.75) e il segno L/S di SKH01 (peso 0.25),
clampata al cap per-asset. Vedi formula nel docstring del modulo."""
clampata al cap per-asset. Vedi formula nel docstring del modulo.
LA SCALA SI APPLICA **DOPO** IL CLAMP, e non e' una scelta di comodo (SPEC §3.2).
Applicandola PRIMA, il clamp la mangerebbe **proprio nei giorni di massima convinzione** (a
`tp=1, sg=+1` il grezzo vale esattamente `cap`, quindi k_eff tornerebbe a 1,00 a ogni k): il
risultato non sarebbe "il libro a leva k" ma un libro che alza i giorni piccoli e lascia fermi
i grandi un cambio di FORMA travestito da cambio di taglia, che la curva g(k) con cui il
gradino viene autorizzato **non descrive**. DOPO il clamp, `k_eff == k` in ogni giorno.
Il prezzo, dichiarato: il cap smette di essere il tetto assoluto del nozionale e diventa il
tetto del libro UNITARIO; la guardia sulla leva lorda e' ricostruita in `_scala` (SPEC §4).
Con `scala=1.0` (chiave assente) questa funzione e' bit-exact quella di prima."""
raw = weight * equity * (W_TP01 * max(tp_frac, 0.0) + W_SKH * float(skh_sign))
return max(-cap, min(cap, raw))
return scala * max(-cap, min(cap, raw))
def _skh_sign(state) -> int:
@@ -192,6 +275,9 @@ def book_report(offline: bool = False, equity_override: float | None = None,
# fallback in OGNI ordine di eventi (vedi _cap).
equity_jump = write_equity_watermark(sh.get("real_equity"))
cap = _cap(equity=equity, real_equity=sh.get("real_equity"), eq_fallback=sh.get("eq_fallback"))
# La scala VALIDA sempre e si applica solo sul percorso fidato: se la config e' fuori scaletta
# o fuori tetto, `_scala` SOLLEVA e book_execute si ferma senza inviare (SPEC §4.1c).
scala = _scala(real_equity=sh.get("real_equity"), eq_fallback=sh.get("eq_fallback"))
load5m = None
feed_ages: dict[str, float | None] = {}
feed_errors: dict[str, str] = {}
@@ -226,7 +312,7 @@ def book_report(offline: bool = False, equity_override: float | None = None,
tp_frac = float(a_rec["target"])
st = skh.get(a, "flat")
sign = _skh_sign(st)
net = book_net_target(tp_frac, sign, equity, cap)
net = book_net_target(tp_frac, sign, equity, cap, scala=scala)
cur = float(a_rec["position_usd"])
mark = a_rec["mark"]
order = build_book_order(inst, net, cur, mark, min_usd=5.0)
@@ -243,6 +329,9 @@ def book_report(offline: bool = False, equity_override: float | None = None,
last_data=sh["last_data"], online=sh["online"],
real_equity=sh["real_equity"], equity=equity, eq_basis=sh["eq_basis"],
cap_per_asset=cap, weights=dict(TP01=W_TP01, SKH01=W_SKH),
# La scala EFFETTIVA del giro (1.0 = libro di oggi). Esposta perche' un parametro che muove
# il nozionale e non compare nel report e' la forma esatta di un difetto gia' pagato.
scala=scala, leva_lorda_max=LEVA_LORDA_MAX,
assets=assets, orders=orders, skh_error=skh_error, pos_error=sh.get("pos_error"),
eq_fallback=sh.get("eq_fallback"),
# Salto di equity fra due letture consecutive oltre EQUITY_JUMP_ALERT: dict o None.
+66
View File
@@ -0,0 +1,66 @@
"""Argomenti da riga di comando degli script operativi: un flag ignoto NON e' l'azione di default.
PERCHE' ESISTE (2026-09-02). Nessuno degli script di `scripts/live/` usava argparse: leggevano
`sys.argv` con `in` e `index`, quindi un flag sbagliato non era un errore era il ramo `else`.
Costo misurato lo stesso giorno, durante una revisione: `trades_db.py --help` non ha stampato
l'uso, e' caduto in `sync()` e ha riscritto `meta.ultimo_sync`. Gli altri due sono peggio:
`journal.py --help` avrebbe scritto la pagina di oggi e una riga nel DB, `analista.py --help`
avrebbe speso una chiamata al modello e mandato un messaggio Telegram.
TRE REGOLE, e nient'altro:
1. **Nessun argparse.** Gli script hanno flag posizionali-per-nome (`--report`, `--secco`) e
flag con valore (`--giorno 2026-08-21`); argparse cambierebbe i messaggi d'errore, il
codice d'uscita e il comportamento di `--help` di 18 script che il cron gia' chiama. Qui
serve il contrario: **lasciare intatto cio' che gira** e rifiutare cio' che non e' previsto.
2. **Il cron non deve cambiare.** I flag dichiarati sono quelli che gli script gia' accettano:
`valida()` non ne aggiunge e non ne toglie (P15: una regola si prova contro il codice che
dovrebbe eseguirla). L'unica novita' e' `--help`, che prima non esisteva.
3. **Si esce PRIMA di qualunque effetto.** `valida()` va chiamata come prima istruzione del
blocco `__main__`, prima di `connect()`, prima del sync, prima della rete.
Codici d'uscita: 0 per `--help` (l'utente ha chiesto l'uso), 2 per un flag ignoto (errore d'uso,
distinto dal 1 con cui gli script segnalano un esito negativo del loro lavoro).
"""
from __future__ import annotations
import sys
class UsoSbagliato(Exception):
"""Flag non previsto da questo script. Portato con l'elenco di cio' che e' previsto (P4)."""
def valida(nome: str, uso: str, *, flag: tuple[str, ...] = (), con_valore: tuple[str, ...] = (),
argv: list[str] | None = None, esci: bool = True) -> list[str]:
"""Controlla `argv` contro i flag dichiarati. Ritorna gli argomenti; solleva o esce se ignoti.
`flag` sono i booleani (`--quiet`), `con_valore` quelli che consumano l'argomento seguente
(`--giorno 2026-08-21`). `--help`/`-h` stampano `uso` ed escono 0. Con `esci=False` non
tocca `sys.exit` e solleva `UsoSbagliato`: e' la forma che i test usano.
"""
args = list(sys.argv[1:] if argv is None else argv)
if "--help" in args or "-h" in args:
if not esci:
return args
print(uso.strip())
raise SystemExit(0)
noti, ignoti, attesa_valore = set(flag) | set(con_valore), [], False
for a in args:
if attesa_valore: # e' il valore di un flag noto, non un flag
attesa_valore = False
continue
if not a.startswith("-"):
ignoti.append(a); continue # posizionale: nessuno di questi script ne ha
if a not in noti:
ignoti.append(a); continue
attesa_valore = a in con_valore
if attesa_valore:
ignoti.append(f"{args[-1]} (manca il valore)")
if ignoti:
msg = (f"{nome}: argomento non previsto: {', '.join(ignoti)}\n"
f" previsti: {' '.join(sorted(noti)) or '(nessuno)'}\n\n{uso.strip()}")
if not esci:
raise UsoSbagliato(msg)
print(msg, file=sys.stderr)
raise SystemExit(2)
return args
+190 -19
View File
@@ -25,6 +25,12 @@ from src.live import tradesdb as T
PROJECT_ROOT = Path(__file__).resolve().parents[2]
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")
# Gli orizzonti del segnale TP01 in produzione (trend_portfolio: TSMOM 30/90/180 giorni).
ORIZZONTI = (30, 90, 180)
@@ -118,7 +124,34 @@ def _px_1h(asset: str) -> pd.Series | None:
return None
def movimenti_capitale(con, fino_ts: str) -> dict:
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:
"""Salti di equity fra letture consecutive: versamenti e prelievi, da scorporare dal P&L.
PERCHE' ESISTE. Il P&L di giornale e' un delta di equity, quindi un versamento ci finisce
@@ -137,45 +170,171 @@ def movimenti_capitale(con, fino_ts: str) -> dict:
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.
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
rows = con.execute("SELECT ts_utc, equity FROM equity WHERE ts_utc <= ? ORDER BY ts_utc",
(fino_ts,)).fetchall()
# `rows` si puo' passare gia' letto: due SELECT sulla stessa tabella a un'ora del cron
# possono cadere ai due lati di una scrittura e descrivere due istanti diversi.
if rows is None:
rows = con.execute("SELECT ts_utc, equity FROM equity WHERE ts_utc <= ? ORDER BY ts_utc",
(fino_ts,)).fetchall()
soglia = float(_book.EQUITY_JUMP_ALERT)
candidati = [(a, b) for a, b in zip(rows, rows[1:])
if a["equity"] and abs(b["equity"] - a["equity"]) / a["equity"] >= soglia]
if not candidati:
return dict(eventi=[], certi=0.0, ambigui=0.0, soglia=soglia)
dichiarati, avvisi = movimenti_dichiarati()
if not candidati and not dichiarati:
return dict(eventi=[], certi=0.0, ambigui=0.0, soglia=soglia, avvisi=avvisi)
try:
cfg = json.loads((PROJECT_ROOT / "config" / "live.json").read_text())
leva_tetto = float(cfg["max_notional_per_asset_frac"]) * len(ASSETS)
# la leva lorda massima che il libro puo' tenere: frac x n_asset x scala, mai oltre il
# tetto di CODICE (P1: derivata da `book`, non ridichiarata — la chiave di scala del
# 01/09 entra qui da sola)
scala = float(cfg.get("book_scale_k") or 1.0)
leva_tetto = min(float(cfg["max_notional_per_asset_frac"]) * len(ASSETS) * scala,
float(_book.LEVA_LORDA_MAX))
except Exception:
leva_tetto = 1.0 # il tetto di progetto: bound conservativo, usato solo qui
px = {a: _px_1h(a) for a in ASSETS}
leva_tetto = float(_book.LEVA_LORDA_MAX) # config illeggibile: il tetto di codice
px = {a: _px_1h(a) for a in ASSETS} if candidati else {}
eventi = []
for prev, cur in candidati:
t0, t1 = pd.Timestamp(prev["ts_utc"]), pd.Timestamp(cur["ts_utc"])
delta = float(cur["equity"] - prev["equity"])
pct = delta / prev["equity"]
mosse = []
# Il mercato e' MISURABILE solo se il feed copre entrambe le letture. Il feed 1h
# certificato si ferma alle 00:00 (rebuild alle 00:30): per il resto del giorno
# `asof` restituirebbe la STESSA barra per t0 e t1, max_mkt 0, e qualunque calo >=10%
# diventerebbe un "movimento" — un crash stampato come prelievo (revisione 02/09).
# Non misurabile => `ambiguo` con la ragione (P5: "non vedo" non e' "mercato fermo").
mosse, non_mis = [], []
for a in ASSETS:
s = px.get(a)
if s is None or s.empty:
continue
non_mis.append(f"{a}: feed 1h non leggibile"); continue
fine = s.index[-1] + pd.Timedelta(hours=1) # l'ultima barra copre un'ora
if t0 < s.index[0] or t1 > fine:
non_mis.append(f"{a}: feed 1h fermo a {s.index[-1]:%Y-%m-%dT%H:%M}Z"); continue
p0, p1 = s.asof(t0), s.asof(t1)
if p0 and p1 and not (pd.isna(p0) or pd.isna(p1)):
mosse.append(abs(float(p1) / float(p0) - 1.0))
max_mkt = max(mosse) * leva_tetto if mosse else None
classe = "movimento" if (max_mkt is not None and abs(pct) > 2.0 * max_mkt) else "ambiguo"
if pd.isna(p0) or pd.isna(p1) or not p0 or not p1:
non_mis.append(f"{a}: barra assente"); continue
mosse.append(abs(float(p1) / float(p0) - 1.0))
if non_mis:
max_mkt, classe, nota = None, "ambiguo", "mercato non misurabile: " + "; ".join(non_mis)
else:
max_mkt = max(mosse) * leva_tetto
classe = "movimento" if abs(pct) > 2.0 * max_mkt else "ambiguo"
nota = None
eventi.append(dict(ts_prima=prev["ts_utc"], ts_dopo=cur["ts_utc"],
prima=float(prev["equity"]), dopo=float(cur["equity"]),
delta=delta, pct=float(pct),
mercato_max_pct=(None if max_mkt is None else float(max_mkt)),
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,
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")),
soglia=soglia, leva_tetto=leva_tetto)
soglia=soglia, leva_tetto=leva_tetto, avvisi=avvisi)
def rendimento_twr(con, fino_ts: str) -> dict:
"""Rendimento pesato per il tempo (TWR) della serie di equity, spezzata sui movimenti CERTI.
PERCHE' ESISTE (debito #14, trovato il 2026-09-01). `e1/e0 - 1` sulla serie grezza era il
numero che `trades_db.py --report` stampava come performance: «+243%», per il 96,3% un
bonifico ($1.399 il 25/08 su $667). La riparazione `movimenti_capitale` viveva gia' in
questo modulo e NON aveva attraversato il confine fra i due lettori della stessa serie:
variante di P1. Da oggi il secondo lettore CHIAMA il primo invece di rifare il conto.
COME. Ogni evento di classe `movimento` chiude un segmento all'equity PRIMA del salto e ne
apre uno all'equity DOPO; il TWR e' il prodotto dei rendimenti di segmento, meno uno. Gli
eventi `ambiguo` NON spezzano niente (P12: restano nel rendimento, dichiarati accanto).
LIMITE DICHIARATO (D5). L'intervallo che CONTIENE un movimento certo esce INTERO dal
rendimento: il suo P&L di mercato (marcatura delle posizioni aperte, funding; in un buco di
lettura fino a 10h nella serie vera l'intero buco) finisce in `certi`, quindi anche fuori
da `trading`. E' limitato per costruzione dal margine del classificatore (|salto| >
2·max_mkt·leva·equity), percio' l'errore massimo vale max_mkt·leva·equity: fino a META' del
movimento riconosciuto. Il 25/08: libro a ~$263 lordi, mercato a ±0,2% -> ~$0,5 su $1.399,39.
Non si stima e non si corregge (P12): si dichiara. Un intervallo di sola marcatura fra due
movimenti consecutivi, o prima del primo, ha lunghezza zero e NON produce un segmento.
TRE STATI: `twr` e `trading` sono numeri, oppure None con `motivo`. Mercato NON misurabile
per almeno un salto (feed 1h fermo, o assente) => entrambi None: non si conosce la classe
del salto, quindi non si conosce il rendimento e `e1/e0-1` non torna sotto un'etichetta
piu' autorevole. Base di equity zero =>
entrambi None: il salto 0 -> X del primo versamento e' INVISIBILE a `movimenti_capitale`
(una base zero non ha percentuale), quindi `certi` lo perderebbe e `trading` varrebbe
l'intero conto — un numero etichettato "al netto dei versamenti" che li contiene tutti.
"""
rows = con.execute("SELECT ts_utc, equity FROM equity WHERE ts_utc <= ? ORDER BY ts_utc",
(fino_ts,)).fetchall()
if not rows:
return dict(twr=None, motivo="nessuna lettura di equity", segmenti=[], eventi=[],
certi=0.0, ambigui=0.0, delta_equity=None, trading=None, letture=0)
mv = movimenti_capitale(con, fino_ts, rows=rows) # stessa lettura, un solo istante
e0, e1 = float(rows[0]["equity"]), float(rows[-1]["equity"])
if e0 <= 0:
return dict(twr=None, trading=None,
motivo="base di equity nulla: il primo versamento non e' classificabile",
segmenti=[], eventi=mv["eventi"], certi=mv["certi"], ambigui=mv["ambigui"],
delta_equity=e1 - e0, letture=len(rows))
non_mis = [e for e in mv["eventi"] if e.get("mercato_nota") and e["classe"] != "movimento"]
if non_mis:
# 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
# sotto l'etichetta "TWR", il numero che il debito 14 e' nato per uccidere)
return dict(twr=None, trading=None,
motivo=(f"{len(non_mis)} salto/i non classificabile/i — "
+ non_mis[0]["mercato_nota"]),
segmenti=[], eventi=mv["eventi"], certi=mv["certi"], ambigui=mv["ambigui"],
delta_equity=e1 - e0, letture=len(rows))
certi = [e for e in mv["eventi"] if e["classe"] == "movimento"]
segmenti, da_ts, da_eq = [], rows[0]["ts_utc"], e0
for ev in certi: # gia' in ordine di tempo
if da_eq and ev["ts_prima"] != da_ts: # lunghezza zero = niente segmento
segmenti.append(dict(da=da_ts, a=ev["ts_prima"], e_da=da_eq, e_a=ev["prima"],
ret=ev["prima"] / da_eq - 1.0))
da_ts, da_eq = ev["ts_dopo"], ev["dopo"]
if da_eq and rows[-1]["ts_utc"] != da_ts:
segmenti.append(dict(da=da_ts, a=rows[-1]["ts_utc"], e_da=da_eq, e_a=e1,
ret=e1 / da_eq - 1.0))
twr = 1.0
for sg in segmenti: # nessun segmento = nessun tempo a mercato = 0
twr *= 1.0 + sg["ret"]
return dict(twr=twr - 1.0, motivo=None, segmenti=segmenti, eventi=mv["eventi"],
certi=mv["certi"], ambigui=mv["ambigui"], delta_equity=e1 - e0,
trading=e1 - e0 - mv["certi"], letture=len(rows), avvisi=mv.get("avvisi") or [])
def pnl_giorno(con, giorno: date) -> dict:
@@ -196,7 +355,10 @@ def pnl_giorno(con, giorno: date) -> dict:
e_arm = con.execute("SELECT equity FROM equity ORDER BY ts_utc LIMIT 1").fetchone()
delta = (e_fin - e_ini) if (e_ini is not None and e_fin is not None) else None
cum = (e_fin - e_arm["equity"]) if (e_fin is not None and e_arm) else None
mv = movimenti_capitale(con, f"{g}T23:59:59+00:00")
# il cumulato dall'arming, i movimenti e il trading al netto vengono da rendimento_twr:
# UNA funzione per i due lettori (report e giornale), non due aritmetiche (revisione 02/09)
r_twr = rendimento_twr(con, f"{g}T23:59:59+00:00")
mv = dict(eventi=r_twr["eventi"], certi=r_twr["certi"], ambigui=r_twr["ambigui"])
ev_g = [e for e in mv["eventi"] if e["ts_dopo"][:10] == g]
mov_g = sum(e["delta"] for e in ev_g if e["classe"] == "movimento")
amb_g = sum(e["delta"] for e in ev_g if e["classe"] == "ambiguo")
@@ -210,7 +372,8 @@ def pnl_giorno(con, giorno: date) -> dict:
movimenti_giorno=ev_g, mov_giorno=float(mov_g), mov_giorno_ambiguo=float(amb_g),
trading_giorno=(delta - mov_g) if delta is not None else None,
movimenti_arming=mv["eventi"], mov_arming=mv["certi"], mov_arming_ambiguo=mv["ambigui"],
trading_da_arming=(cum - mv["certi"]) if cum is not None else None)
trading_da_arming=(r_twr["trading"] if cum is not None else None),
twr_da_arming=(r_twr["twr"] if cum is not None else None), twr_motivo=r_twr["motivo"])
def salute(con, giorno: date) -> dict:
@@ -318,7 +481,11 @@ def lettura(con, voce: dict) -> list[dict]:
# --- movimenti di capitale: prima del P&L, perche' lo ridefiniscono ----------------------
for e in p.get("movimenti_giorno") or []:
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 "
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. "
@@ -582,6 +749,10 @@ def rendi_markdown(con, voce: dict) -> str:
if p.get("mov_arming"):
riga_c += (f" — di cui ${p['mov_arming']:+,.2f} versati/prelevati -> trading "
f"**{_n(p.get('trading_da_arming'),'${:+,.2f}')}**")
if p.get("twr_da_arming") is not None:
riga_c += f" · TWR **{100 * p['twr_da_arming']:+.2f}%**"
elif p.get("twr_motivo"):
riga_c += f" · TWR n/d ({p['twr_motivo']})"
if p.get("mov_arming_ambiguo"):
riga_c += f" — ⚠️ ${p['mov_arming_ambiguo']:+,.2f} ambigui non attribuiti"
L += ["", "## P&L", "",
+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
+248
View File
@@ -0,0 +1,248 @@
"""scale_watch — il sorvegliante della CHIAVE DI SCALA del libro (SPEC-scale-key §6.4).
PERCHE' ESISTE. Una chiave di scala e' esattamente il tipo di parametro che si alza "solo un po'"
dopo un mese buono: e' un numero, sta in un file di config, non richiede di capire niente per
cambiarlo, e il suo effetto e' immediato e piacevole. Il progetto ha guardie contro questa deriva
su ogni altro asse (`weights_tilt_null` per i pesi, i gate pre-registrati per i candidati,
`select_cell_insample` per le griglie) e NESSUNA per la scala non per dimenticanza, per
aritmetica: **lo Sharpe e' invariante alla scala**, quindi `deflated_sharpe` e `marginal_vs_tp01`
leggono un numero che non cambia. La scala si vede solo su crescita e rovina.
CIO' CHE QUESTO SORVEGLIANTE NON PUO' FARE: impedire la modifica. Chi ha accesso al file puo'
scriverci dentro. Rende la modifica **visibile entro 24 ore** e **attribuibile**. E' la stessa
onesta' di `venue_watch`: non protegge il saldo, compra tempo.
TRE DOMANDE, e sono davvero tre perche' hanno TRE AZIONI DIVERSE e nessuna e' deducibile dalle
altre (§6.4):
D1 la config dichiara una scala che il GIORNALE non ha mai autorizzato? -> ricostruire come ci
si e' arrivati, e scendere a k0.
D2 il criterio che autorizzo' la scala CORRENTE passa ancora oggi? -> scendere di un gradino.
D3 il PRODOTTO n_asset x frac x scala ha superato LEVA_LORDA_MAX? -> bloccare l'esecuzione.
TRE STATI, non due: OK / ALLARME / NON MISURABILE. *"Non vedo" non e' "va tutto bene"* — lezione di
`paper_dvolspread` e `venue_watch`.
P1: ogni bersaglio e' IMPORTATO da `src.live.book`, mai ridichiarato qui. Un sorvegliante puntato su
una configurazione diversa da quella che gira passa sempre e non sta controllando niente (5 occorrenze).
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
import pandas as pd
# P1 — i bersagli si DERIVANO dal codice sorvegliato. Non riscrivere questi valori qui.
from src.live.book import (
ASSETS, CONFIG, DISASTER_SL_BUDGET, LEVA_LORDA_MAX, SCALA_LADDER, ScalaNonAutorizzata, _scala,
)
PROJECT_ROOT = Path(__file__).resolve().parents[2]
HISTORY = PROJECT_ROOT / "data" / "live" / "scale_history.jsonl"
STATE_PATH = PROJECT_ROOT / "data" / "live" / "scale_watch_state.json"
CRON_LOG = PROJECT_ROOT / "logs" / "cron_book.log"
OK, ALLARME, NON_MISURABILE = "OK", "ALLARME", "NON MISURABILE"
# Soglia DICHIARATA PRIMA di vedere il caso (SPEC §4.3): se il ramo di fallback (online ma equity
# illeggibile) supera il 2% dei giri in 90 giorni, il libro girerebbe a una leva MISTA e la regola
# "scala solo sul percorso fidato" va ridiscussa — perche' la curva g(k) smetterebbe di descriverlo.
FALLBACK_MAX_FRAC = 0.02
# Un gate scaduto non e' un gate: oltre questa eta' il criterio va rimisurato, non ereditato.
GATE_MAX_AGE_GIORNI = 180
@dataclass
class Stato:
"""Streak per domanda: si allerta UNA VOLTA per streak, non a ogni giro (P14/P9)."""
streak: dict = field(default_factory=dict)
detto: dict = field(default_factory=dict)
def load_state(path: Path = STATE_PATH) -> Stato:
try:
d = json.loads(path.read_text())
return Stato(streak=d.get("streak", {}), detto=d.get("detto", {}))
except Exception:
return Stato()
def save_state(st: Stato, path: Path = STATE_PATH) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"streak": st.streak, "detto": st.detto}, indent=1))
def leggi_giornale(path: Path = HISTORY) -> list[dict]:
"""Giornale append-only delle scale autorizzate. Non ricostruibile: sta nel backup."""
if not path.exists():
return []
out = []
for riga in path.read_text().splitlines():
riga = riga.strip()
if riga:
try:
out.append(json.loads(riga))
except json.JSONDecodeError:
continue
return out
def frequenza_fallback(log: Path = CRON_LOG) -> dict:
"""Quota di giri in cui l'equity reale NON era leggibile (SPEC §4.3).
Distingue due cose che il log confonde in una riga sola: i giri **pre-finanziamento**
("paper capital") non sono il ramo che conta li' il libro non e' finanziato e non manda
ordini. Il ramo che invalida la regola e' *online ma equity illeggibile*, ed e' quello che
farebbe girare il libro a una leva mista.
"""
if not log.exists():
return dict(stato=NON_MISURABILE, motivo=f"log assente: {log}")
testo = log.read_text(errors="replace")
giri = testo.count("cron_book =====")
senza = testo.count("\n conto: ")
paper = testo.count("conto: paper capital")
veri = senza - paper # online, ma equity non leggibile
if giri == 0:
return dict(stato=NON_MISURABILE, motivo="nessun giro nel log")
q = veri / giri
return dict(stato=(ALLARME if q > FALLBACK_MAX_FRAC else OK),
giri=giri, senza_equity=senza, paper=paper, fallback_veri=veri, quota=q,
soglia=FALLBACK_MAX_FRAC)
def osserva(cfg: dict | None = None, giornale: list[dict] | None = None) -> dict:
"""Le tre domande, ciascuna col suo stato e la sua azione. Nessun invio, nessuno stato scritto."""
if cfg is None:
cfg = json.loads(CONFIG.read_text()) if CONFIG.exists() else {}
if giornale is None:
giornale = leggi_giornale()
dichiarata = cfg.get("book_scale_k")
scala = 1.0 if dichiarata is None else float(dichiarata)
frac = cfg.get("max_notional_per_asset_frac")
sl = float(cfg.get("disaster_sl_pct", 0.0))
dom = {}
# --- D1: la config dichiara una scala che il giornale non ha mai autorizzato? ---------------
autorizzate = [r for r in giornale if isinstance(r.get("scala"), (int, float))]
if not autorizzate:
dom["D1"] = dict(stato=(OK if scala == 1.0 else ALLARME),
letto=scala,
motivo=("giornale vuoto e scala 1,00: e' il default, nulla da autorizzare"
if scala == 1.0 else
f"config dichiara {scala}x e il giornale e' VUOTO: nessuna "
f"autorizzazione esiste"),
azione="ricostruire come ci si e' arrivati; riportare la scala a 1,00")
else:
ultima = autorizzate[-1]
ok = abs(float(ultima["scala"]) - scala) < 1e-9
dom["D1"] = dict(stato=(OK if ok else ALLARME), letto=scala,
giornale=float(ultima["scala"]), dal=ultima.get("dal"),
motivo=("config e giornale concordi" if ok else
f"config dichiara {scala}x, l'ultima riga di giornale autorizza "
f"{ultima['scala']}x (dal {ultima.get('dal')})"),
azione=("nessuna" if ok else
"ricostruire come ci si e' arrivati; scendere alla scala di giornale"))
# --- D2: il criterio che autorizzo' la scala corrente passa ancora oggi? -------------------
if scala == 1.0:
dom["D2"] = dict(stato=OK, letto=scala,
motivo="scala 1,00: e' k0, non c'e' un gradino da ri-autorizzare",
azione="nessuna")
else:
rec = next((r for r in reversed(autorizzate)
if abs(float(r["scala"]) - scala) < 1e-9), None)
eta = None
if rec and rec.get("dal"):
try:
eta = (pd.Timestamp.now(tz="UTC") - pd.Timestamp(rec["dal"])).days
except Exception:
eta = None
if rec is None or eta is None:
dom["D2"] = dict(stato=NON_MISURABILE, letto=scala,
motivo="nessuna riga di giornale databile per la scala corrente",
azione="rieseguire GATE SCALA-01 e registrarne l'esito")
elif eta > GATE_MAX_AGE_GIORNI:
dom["D2"] = dict(stato=ALLARME, letto=scala, eta_giorni=eta,
motivo=f"il gate che autorizzo' {scala}x ha {eta} giorni "
f"(>{GATE_MAX_AGE_GIORNI}): un gate scaduto non e' un gate",
azione="scendere di un gradino, o rieseguire GATE SCALA-01 oggi")
else:
dom["D2"] = dict(stato=OK, letto=scala, eta_giorni=eta,
motivo=f"gate registrato {eta} giorni fa, dentro i {GATE_MAX_AGE_GIORNI}",
azione="nessuna")
# --- D3: il PRODOTTO ha superato il tetto? -------------------------------------------------
if frac is None:
dom["D3"] = dict(stato=NON_MISURABILE, motivo="max_notional_per_asset_frac assente in config",
azione="nessuna: il cap e' fisso, la leva non e' derivabile da qui")
else:
leva = len(ASSETS) * float(frac) * scala
costo = leva * sl
sfora = leva > LEVA_LORDA_MAX + 1e-12
sfora_sl = costo > DISASTER_SL_BUDGET + 1e-12
dom["D3"] = dict(stato=(ALLARME if (sfora or sfora_sl) else OK),
leva_lorda=leva, tetto=LEVA_LORDA_MAX,
costo_disaster_sl=costo, budget=DISASTER_SL_BUDGET,
frac=float(frac), scala=scala, sl=sl,
motivo=(f"leva lorda {leva:.4f}x = {len(ASSETS)} asset x frac {frac} x "
f"scala {scala}"
+ (f" SUPERA il tetto {LEVA_LORDA_MAX}x" if sfora else
f", sotto il tetto {LEVA_LORDA_MAX}x")
+ (f"; un disaster-SL costerebbe {100*costo:.1f}% > "
f"{100*DISASTER_SL_BUDGET:.0f}%" if sfora_sl else "")),
azione=("bloccare l'esecuzione" if (sfora or sfora_sl) else "nessuna"))
# --- la config e' comunque leggibile dal codice di produzione? -----------------------------
try:
_scala(real_equity=1.0, eq_fallback=None, cfg=cfg)
lettore = dict(stato=OK, motivo="src.live.book._scala accetta la config corrente")
except ScalaNonAutorizzata as e:
lettore = dict(stato=ALLARME, motivo=str(e),
azione="book_execute si fermera' al prossimo giro senza inviare ordini")
stati = [d["stato"] for d in dom.values()] + [lettore["stato"]]
globale = (ALLARME if ALLARME in stati else
(NON_MISURABILE if NON_MISURABILE in stati else OK))
return dict(stato=globale, scala_dichiarata=scala, domande=dom, lettore=lettore,
fallback=frequenza_fallback(),
ladder=list(SCALA_LADDER), tetto=LEVA_LORDA_MAX,
ts=pd.Timestamp.now(tz="UTC").isoformat())
def run_once(state_path: Path = STATE_PATH, sender=None, cfg: dict | None = None,
giornale: list[dict] | None = None) -> dict:
"""Un giro: osserva, avanza le streak, INVIA (se gli si da' un sender), salva.
Il marcatore "gia' detto" si scrive SOLO DOPO un invio riuscito (debito #2 del 28/08): un 🚨
perso prima era perso per l'episodio intero. Senza `sender` non si marca nulla e il report lo dice.
"""
rep = osserva(cfg=cfg, giornale=giornale)
st = load_state(state_path)
da_dire = []
for nome, d in list(rep["domande"].items()) + [("fallback", rep["fallback"])]:
if d.get("stato") == ALLARME:
st.streak[nome] = int(st.streak.get(nome, 0)) + 1
if not st.detto.get(nome):
da_dire.append((nome, d))
else:
st.streak[nome] = 0
st.detto[nome] = False
rep["da_dire"] = [n for n, _ in da_dire]
rep["streak"] = dict(st.streak)
if not da_dire:
rep["invio"] = "niente da dire"
elif sender is None:
rep["invio"] = ("non tentato (nessun sender): i marcatori 'gia' detto' restano intatti, "
"cosi' il prossimo giro con un trasporto vivo lo dira'")
else:
righe = [f"🚨 SCALA DEL LIBRO — {n}: {d['motivo']}\n{d.get('azione', '')}"
for n, d in da_dire]
ok, motivo = sender("\n".join(righe))
rep["invio"] = f"{'inviato' if ok else 'FALLITO'}: {motivo}"
if ok:
for n, _ in da_dire:
st.detto[n] = True
save_state(st, state_path)
return rep
+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).
import src.live.journal as 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
+34
View File
@@ -0,0 +1,34 @@
"""Utilita' condivise dei test. `tests/` e' su sys.path (c'e' un conftest), quindi si importa
con `from helpers import carica_script`.
PERCHE' ESISTE (2026-09-02). `scripts/` non e' un package importabile per nome, quindi ogni test
che tocca uno script si caricava il modulo a mano con `importlib.util.spec_from_file_location`:
**15 copie della stessa dozzina di righe**, con nomi di modulo diversi per lo stesso file e
differenze silenziose (chi registra in `sys.modules` e chi no e chi non lo fa rompe `pickle`,
`dataclasses` e i decoratori che risolvono per nome). Una funzione sola, con la registrazione
fatta sempre.
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def carica_script(rel: str, nome: str | None = None):
"""Carica per PERCORSO uno script del repo (es. "scripts/live/trades_db.py").
`nome` e' il nome sotto cui il modulo viene registrato in `sys.modules`; il default e' il
nome del file. Registrare PRIMA di eseguire e' voluto: un modulo che si auto-importa o usa
`dataclasses` durante l'esecuzione deve trovarsi.
"""
p = (ROOT / rel).resolve()
assert p.exists(), f"script inesistente: {rel}"
nome = nome or p.stem
spec = importlib.util.spec_from_file_location(nome, p)
mod = importlib.util.module_from_spec(spec)
sys.modules[nome] = mod
spec.loader.exec_module(mod)
return mod
+207
View File
@@ -0,0 +1,207 @@
"""La cadenza del libro live e' ORARIA, e le tre dichiarazioni devono restare d'accordo.
Debito §5.7 (chiuso il 2026-09-02): il docstring di `book_execute.py` prescriveva «ogni ~230
minuti» mentre il cron gira ogni ora. Non era cosmetico: `r0823_sl_anchor.py` misura che gli
scatti del disaster-SL rotolante dipendono dalla cadenza 1h: BTC 0 / ETH 1 in 7-8 anni; 4h:
BTC 2 (contro 1 del pavimento); 24h: ETH 5 con 2 in 30 giorni. Chi avesse "corretto" il CRON
verso il docstring avrebbe spostato il libro dalla riga 1h a quella 4h.
P1: il bersaglio si DERIVA da cio' che gira. Qui le fonti sono tre — il docstring (cio' che un
umano legge), `cron_book.sh` (cio' che dichiara di essere installato) e la crontab (cio' che
gira davvero) e il test le confronta fra loro invece di ridichiarare «60 minuti».
"""
from __future__ import annotations
import ast
import re
import shutil
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 venue_probe as VP # noqa: E402 lo slot di release, dichiarato li'
from src.strategies.skyhook import LTF_MIN # noqa: E402 la griglia di SKH01, NON ridichiarata
BOOK_EXECUTE = ROOT / "scripts" / "live" / "book_execute.py"
CRON_BOOK = ROOT / "scripts" / "cron_book.sh"
RIGA_4H_MIN = 240 # la riga in cui il rotolante di BTC passa da 0 a 2 scatti (r0823_sl_anchor.py);
# la PEGGIORE misurata e' 24h (ETH 5, 2 in 30 giorni), non questa
_RX_CRON5 = re.compile(r"`([\d*/,-]+(?: [\d*/,-]+){4})`")
def cadenza_minuti(expr: str) -> int:
"""Minuti fra due esecuzioni di un'espressione crontab a 5 campi, SOLO per cadenze regolari
(intervalli tutti uguali): `47 * * * *` -> 60, `*/30 * * * *` -> 30, `7 */4 * * *` -> 240.
`*/45` (intervalli 45/15), `0 */5` (ultimo intervallo 4h) e `*/0` vengono RIFIUTATI."""
campi = expr.split()
assert len(campi) == 5, expr
m, h, dom, mon, dow = campi
assert dom == mon == dow == "*", f"cadenza non regolare: {expr}"
if h == "*" and m.isdigit():
return 60
if h == "*" and m.startswith("*/"):
passo = int(m[2:])
if passo <= 0 or 60 % passo:
raise ValueError(f"passo di minuti non regolare: {expr}")
return passo
if h.startswith("*/") and m.isdigit():
passo = int(h[2:])
if passo <= 0 or 24 % passo:
raise ValueError(f"passo di ore non regolare: {expr}")
return 60 * passo
raise ValueError(f"espressione non riconosciuta: {expr}")
def _docstring() -> str:
return ast.get_docstring(ast.parse(BOOK_EXECUTE.read_text())) or ""
def _cron_dichiarato() -> str:
"""La prima espressione crontab a 5 campi che `cron_book.sh` dichiara fra backtick — di
qualunque forma: e' `cadenza_minuti` a giudicarla, non questa regex (P4: un'espressione
presente ma non oraria deve fallire come 'non oraria', non come 'assente')."""
m = _RX_CRON5.search(CRON_BOOK.read_text())
assert m, "cron_book.sh non dichiara piu' la sua riga di crontab fra backtick"
return m.group(1)
def _crontab() -> str | None:
"""Testo della crontab dell'utente, None se non leggibile (P5: distinto da 'vuota')."""
if not shutil.which("crontab"):
return None
try:
out = subprocess.run(["crontab", "-l"], capture_output=True, text=True, timeout=10)
except Exception:
return None
return out.stdout if out.returncode == 0 else None
def _righe_attive(testo: str, marcatore: str) -> list[str]:
"""Le espressioni (5 campi) di TUTTE le righe attive che citano `marcatore`: due righe sono
due esecuzioni, e la seconda non deve nascondersi dietro la prima."""
return [" ".join(r.split()[:5]) for r in testo.splitlines()
if marcatore in r and not r.lstrip().startswith("#")]
# ---------------------------------------------------------------- regressione sul docstring
# La guardia non cerca due letterali («ogni ~230»): estrae OGNI prescrizione di cadenza scritta
# come «ogni/every N unita'» e pretende che valga 60 minuti. ⚠️ Limite dichiarato (P13): una
# guardia sui numeri non copre il ragionamento — una prosa che prescrive una cadenza senza la
# parola «ogni» passa. Il controllo positivo qui sotto dice cosa la guardia VEDE.
_NUMERI = {"un": 1, "una": 1, "due": 2, "tre": 3, "quattro": 4, "sei": 6, "otto": 8, "dodici": 12}
_RX_CADENZA = re.compile(
r"\b(?:ogni|every)\s*~?\s*(\d+|un'?|una|due|tre|quattro|sei|otto|dodici)?\s*'?\s*"
r"(minuti|minuto|minutes?|min|m|ore|ora|hours?|h)\b", re.I)
def prescrizioni_minuti(testo: str) -> list[int]:
"""Ogni «ogni N unita'» del testo, in minuti. «ogni ora» -> 60, «ogni ~230 minuti» -> 230."""
out = []
for m in _RX_CADENZA.finditer(testo):
n_raw, unita = m.group(1), m.group(2).lower()
n = 1 if n_raw is None else (int(n_raw) if n_raw.isdigit() else _NUMERI[n_raw.rstrip("'")])
out.append(n * (60 if unita.startswith(("or", "h")) else 1))
return out
def test_la_guardia_vede_le_prescrizioni_che_deve_vedere():
# controllo positivo (M15): i modi in cui il docstring potrebbe ri-prescrivere la riga 4h
assert prescrizioni_minuti("va lanciato ogni ~230 minuti con la feed") == [230]
assert prescrizioni_minuti("il giro va lanciato ogni quattro ore") == [240]
assert prescrizioni_minuti("every 4 hours, then every 230 min") == [240, 230]
assert prescrizioni_minuti("controllato ogni ora; ogni 60 minuti") == [60, 60]
assert prescrizioni_minuti("fino a 230m di latenza") == [] # non e' una prescrizione
def test_il_docstring_prescrive_solo_la_cadenza_oraria():
d = _docstring()
p = prescrizioni_minuti(d)
assert p, "il docstring deve contenere almeno una prescrizione di cadenza"
assert all(x == 60 for x in p), f"prescrizioni non orarie nel docstring: {p}"
assert re.search(r"CADENZA:\s*ORARIA", d), "il docstring deve dichiarare la cadenza ORARIA"
def test_il_docstring_dichiara_la_stessa_riga_del_cron():
# la riga di crontab citata nel docstring e' quella di cron_book.sh, non un'altra
d = _docstring()
assert _cron_dichiarato() in d
# ---------------------------------------------------------------- cron_book.sh
def test_cron_book_e_orario_sotto_la_griglia_skh_e_sotto_la_riga_4h():
c = cadenza_minuti(_cron_dichiarato())
assert c == 60
assert c < LTF_MIN, "la cadenza deve stare SOTTO la griglia di SKH01 (latenza <= 1 barra)"
assert c < RIGA_4H_MIN, "la riga 4h e' quella in cui il rotolante di BTC fa 2 scatti"
assert "ORARIA" in CRON_BOOK.read_text()
def test_cron_book_evita_il_minuto_tondo():
# vincolo 1 del commento di cron_book.sh: fuori dai ~26s del :00 (rate-limit per-IP)
minuto = int(_cron_dichiarato().split()[0])
assert minuto != 0
def test_cron_book_evita_lo_slot_di_release_deribit():
# vincolo 2 del commento di cron_book.sh — il motivo vero del :07 -> :47 (4 giri morti in
# 63 giorni): il giro del martedi' alle 09:MM deve cadere FUORI dallo slot dichiarato in
# venue_probe (derivato, non riscritto). Il :07 lo violava; se qualcuno tornasse a :15
# con docstring e crontab coerenti, gli altri test passerebbero e questo no.
minuto = int(_cron_dichiarato().split()[0])
base = datetime(2026, 9, 1, tzinfo=timezone.utc) # un lunedi'
martedi = base + timedelta(days=(VP.RELEASE_WEEKDAY - base.weekday()) % 7)
giro = martedi.replace(hour=VP.RELEASE_START_MIN // 60, minute=minuto)
assert not VP.in_release_window(giro), f"il giro :{minuto:02d} cade nello slot di release"
assert VP.in_release_window(giro.replace(minute=7)), "controllo positivo: il :07 ci cadeva"
@pytest.mark.parametrize("expr,atteso", [("47 * * * *", 60), ("*/30 * * * *", 30),
("7 */4 * * *", 240), ("0 */24 * * *", 1440)])
def test_il_parser_della_cadenza(expr, atteso):
assert cadenza_minuti(expr) == atteso
@pytest.mark.parametrize("expr", ["47 9 * * 2", "*/45 * * * *", "0 */5 * * *", "*/0 * * * *",
"47 * 1 * *"])
def test_il_parser_rifiuta_cio_che_non_e_regolare(expr):
with pytest.raises((ValueError, AssertionError)):
cadenza_minuti(expr)
# ---------------------------------------------------------------- la crontab che GIRA
def test_la_crontab_installata_e_quella_dichiarata_e_una_sola():
"""P16: il cron gira dalla macchina, non dal repo. TRE stati, non due (P5):
- crontab NON leggibile -> SALTATO (non verificabile qui);
- leggibile, e questo progetto non ci compare affatto -> SALTATO (macchina di sviluppo);
- leggibile con altre righe del progetto ma SENZA cron_book.sh attivo -> ROSSO: il libro
non e' schedulato dove il progetto e' installato (una riga commentata per manutenzione e
dimenticata e' esattamente questo caso).
E la riga deve essere UNA: due righe attive sono due esecuzioni l'ora."""
testo = _crontab()
if testo is None:
pytest.skip("crontab non leggibile: non verificabile qui")
if str(ROOT) not in testo and ROOT.name not in testo:
pytest.skip("la crontab non cita questo progetto: non e' la macchina che lo esegue")
righe = _righe_attive(testo, "cron_book.sh")
assert righe, "questa macchina esegue il progetto ma cron_book.sh NON e' schedulato"
assert len(righe) == 1, f"cron_book.sh schedulato {len(righe)} volte: {righe}"
assert righe[0] == _cron_dichiarato()
assert cadenza_minuti(righe[0]) == 60
def test_due_righe_attive_non_si_nascondono_dietro_la_prima():
finta = ("47 * * * * /x/PythagorasGoal/scripts/cron_book.sh\n"
"# 7 * * * * /x/PythagorasGoal/scripts/cron_book.sh\n"
"17 * * * * /x/PythagorasGoal/scripts/cron_book.sh\n")
assert _righe_attive(finta, "cron_book.sh") == ["47 * * * *", "17 * * * *"]
assert _righe_attive("# 47 * * * * /x/cron_book.sh\n", "cron_book.sh") == []
+9 -15
View File
@@ -192,12 +192,9 @@ def test_gate_requires_both_switches():
def test_config_default_disarmed(tmp_path, monkeypatch):
"""load_config di book_execute mette execution_enabled=False di default (fail-safe)."""
import importlib
be = importlib.import_module("scripts.live.book_execute") if False else None
# carica il modulo via path (scripts/ non e' un package importabile per nome)
import importlib.util
spec = importlib.util.spec_from_file_location("book_execute", PROJECT_ROOT / "scripts/live/book_execute.py")
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
# `scripts/` non e' un package importabile per nome: si carica via percorso
from helpers import carica_script
mod = carica_script("scripts/live/book_execute.py")
monkeypatch.setattr(mod, "CONFIG", tmp_path / "nope.json") # config assente
assert mod.load_config()["execution_enabled"] is False
@@ -419,9 +416,8 @@ def test_book_report_flags_skh_feed_error(monkeypatch):
def test_book_execute_surfaces_skh_error(monkeypatch, capsys):
"""Se il report porta skh_error, _run() lo stampa E chiama notify (niente flat silenzioso)."""
import importlib.util
spec = importlib.util.spec_from_file_location("book_execute", PROJECT_ROOT / "scripts/live/book_execute.py")
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
from helpers import carica_script
mod = carica_script("scripts/live/book_execute.py")
canned = dict(
last_data=_fresh_bar(), online=True, real_equity=600.0, equity=600.0, eq_basis="test",
@@ -481,9 +477,8 @@ def test_book_report_propagates_pos_error(monkeypatch):
def test_book_execute_halts_on_unreadable_position(monkeypatch, capsys):
"""ARMATO + --execute + ordine presente: il gate DEVE fermarsi PRIMA di costruire il trader."""
import importlib.util
spec = importlib.util.spec_from_file_location("book_execute", PROJECT_ROOT / "scripts/live/book_execute.py")
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
from helpers import carica_script
mod = carica_script("scripts/live/book_execute.py")
canned = dict(
last_data=_fresh_bar(), online=True, real_equity=598.0, equity=598.0, eq_basis="mainnet USDC",
@@ -542,9 +537,8 @@ def test_book_report_propagates_eq_fallback(monkeypatch):
def test_book_execute_eq_fallback_warns_but_proceeds(monkeypatch, capsys):
"""eq_fallback: avvisa + notify MA PROSEGUE (non e' un halt, a differenza di pos_error)."""
import importlib.util
spec = importlib.util.spec_from_file_location("book_execute", PROJECT_ROOT / "scripts/live/book_execute.py")
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
from helpers import carica_script
mod = carica_script("scripts/live/book_execute.py")
canned = dict(
last_data=_fresh_bar(), online=True, real_equity=None, equity=2000.0,
+2 -5
View File
@@ -116,11 +116,8 @@ def test_posizione_flat_non_puo_diventare_naked():
# B. Isolamento per asset dentro book_execute._run().
# ---------------------------------------------------------------------------
def _carica_book_execute():
spec = importlib.util.spec_from_file_location(
"book_execute", PROJECT_ROOT / "scripts/live/book_execute.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
from helpers import carica_script
return carica_script("scripts/live/book_execute.py")
def _report_due_asset():
+305
View File
@@ -0,0 +1,305 @@
"""T1-T11 — le guardie della CHIAVE DI SCALA (SPEC-scale-key §5).
Scritti come specifiche eseguibili: ogni test dichiara **il caso che deve farlo fallire**.
La chiave e' inerte finche' nessuno scrive un numero in config: T7 lo dimostra bit-exact.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from src.live import book as B
from src.live import scale_watch as SW
ROOT = Path(__file__).resolve().parents[1]
GRIGLIA_K = (1.00, 1.10, 1.25, 1.50, 2.00)
GRIGLIA_TP = (0.0, 0.25, 0.461, 1.0)
GRIGLIA_SG = (-1, 0, 1)
CAP_SPENTO = 1e12 # cap disattivato: isola la scala dal clamp
EQ = 2000.0
def _cfg(**kw) -> dict:
base = dict(max_notional_per_asset_frac=0.5, disaster_sl_pct=0.3)
base.update(kw)
return base
# ---------------------------------------------------------------- T1 / T1b: la scala e' SIMMETRICA
def test_T1_la_scala_e_identica_sui_due_sleeve():
"""target(k) == k*target(1) su tutta la griglia, e il rapporto TP/SKH resta W_TP01/W_SKH.
DEVE FALLIRE SE: qualcuno applica la scala a una sola gamba (o dentro TrendPortfolio)."""
for k in GRIGLIA_K:
for tp in GRIGLIA_TP:
for sg in GRIGLIA_SG:
uno = B.book_net_target(tp, sg, EQ, CAP_SPENTO, scala=1.0)
kk = B.book_net_target(tp, sg, EQ, CAP_SPENTO, scala=k)
assert kk == pytest.approx(k * uno, abs=1e-9), (k, tp, sg)
solo_tp = B.book_net_target(1.0, 0, EQ, CAP_SPENTO, scala=k)
solo_skh = B.book_net_target(0.0, 1, EQ, CAP_SPENTO, scala=k)
assert solo_tp / solo_skh == pytest.approx(B.W_TP01 / B.W_SKH, abs=1e-12), k
def test_T1b_il_test_di_simmetria_smaschera_una_scala_asimmetrica():
"""CONTROLLO POSITIVO — il test piu' importante dell'elenco.
A k=1 l'implementazione simmetrica e quella asimmetrica danno lo STESSO numero: un test di
simmetria scritto sul solo caso di default passa sempre e non controlla niente (potenza ZERO).
Qui si verifica (a) che a k=1 le due coincidano davvero, e (b) che fuori da k=1 l'asserzione
di T1 le SEPARI.
"""
def asimmetrico(tp, sg, eq, cap, scala=1.0):
"""La scala applicata al solo TP01: e' anche un TILT di pesi, non una scala."""
raw = B.WEIGHT * eq * (B.W_TP01 * scala * max(tp, 0.0) + B.W_SKH * float(sg))
return max(-cap, min(cap, raw))
# (a) a k=1 le due implementazioni sono indistinguibili -> il test avrebbe potenza zero
for tp in GRIGLIA_TP:
for sg in GRIGLIA_SG:
assert asimmetrico(tp, sg, EQ, CAP_SPENTO, 1.0) == pytest.approx(
B.book_net_target(tp, sg, EQ, CAP_SPENTO, scala=1.0), abs=1e-12)
# (b) fuori da k=1 l'asserzione di T1 fallisce sull'implementazione asimmetrica
fallita = False
for k in (1.25, 2.00):
uno = asimmetrico(1.0, 1, EQ, CAP_SPENTO, 1.0)
kk = asimmetrico(1.0, 1, EQ, CAP_SPENTO, k)
if abs(kk - k * uno) > 1e-9:
fallita = True
peso_tp = (asimmetrico(1.0, 0, EQ, CAP_SPENTO, k)
/ (asimmetrico(1.0, 0, EQ, CAP_SPENTO, k) + asimmetrico(0.0, 1, EQ, CAP_SPENTO, k)))
assert peso_tp != pytest.approx(B.W_TP01, abs=1e-6), (
f"a k={k} il peso implicito di TP01 dovrebbe DERIVARE dallo 0,75: {peso_tp}")
assert fallita, "T1 non separa la scala asimmetrica: il controllo positivo e' rotto"
# ---------------------------------------------------------------- T2 / T3: i due tetti
def test_T2_la_leva_lorda_da_config_resta_sotto_il_tetto():
"""len(ASSETS) * frac * scala <= LEVA_LORDA_MAX, con frac e scala LETTI da config/live.json e
il tetto IMPORTATO dalla produzione.
DEVE FALLIRE SE: qualcuno alza frac, o la scala, **o entrambe di poco**."""
cfg = json.loads((ROOT / "config" / "live.json").read_text())
frac = cfg.get("max_notional_per_asset_frac")
if frac is None:
pytest.skip("cap fisso: la leva non e' derivabile da frac")
scala = float(cfg.get("book_scale_k", 1.0))
leva = len(B.ASSETS) * float(frac) * scala
assert leva <= B.LEVA_LORDA_MAX + 1e-12, (
f"leva lorda {leva:.4f}x oltre il tetto {B.LEVA_LORDA_MAX}x — il tetto e' sul PRODOTTO: "
f"n_asset {len(B.ASSETS)} x frac {frac} x scala {scala}")
def test_T3_l_invariante_disaster_sl_tiene_meta_equity():
"""n_asset * frac * scala * disaster_sl_pct <= 0.50 — la guardia che morde per PRIMA.
DEVE FALLIRE SE: qualcuno allarga disaster_sl_pct **o** alza la scala fino a 1,67x."""
cfg = json.loads((ROOT / "config" / "live.json").read_text())
frac = cfg.get("max_notional_per_asset_frac")
if frac is None:
pytest.skip("cap fisso")
costo = (len(B.ASSETS) * float(frac) * float(cfg.get("book_scale_k", 1.0))
* float(cfg["disaster_sl_pct"]))
assert costo <= B.DISASTER_SL_BUDGET + 1e-12, (
f"un episodio di disaster-SL costerebbe {100*costo:.1f}% dell'equity")
# ---------------------------------------------------------------- T4: il fallback non e' permissivo
def test_T4_il_fallback_non_e_piu_permissivo_del_percorso_fidato(tmp_path, monkeypatch):
"""In OGNI stato degradato il lordo massimo / equity vera <= quello dello stato normale.
DEVE FALLIRE SE: qualcuno applica la scala anche in fallback, o slega il cap dal watermark."""
cfg = _cfg(book_scale_k=1.25, max_notional_per_asset_usd=3000)
cpath = tmp_path / "live.json"
cpath.write_text(json.dumps(cfg))
monkeypatch.setattr(B, "CONFIG", cpath)
wm = tmp_path / "equity_seen.json"
wm.write_text(json.dumps({"real_equity": EQ}))
monkeypatch.setattr(B, "EQUITY_WATERMARK", wm)
def lordo(real_equity, eq_fallback):
cap = B._cap(equity=EQ, real_equity=real_equity, eq_fallback=eq_fallback)
sc = B._scala(real_equity=real_equity, eq_fallback=eq_fallback)
return len(B.ASSETS) * cap * sc / EQ
normale = lordo(EQ, None)
assert normale == pytest.approx(1.25, abs=1e-9)
for nome, re_, fb in (("equity illeggibile", None, None),
("eq_fallback attivo", EQ, "paper")):
assert lordo(re_, fb) <= normale + 1e-12, nome
wm.unlink() # watermark assente: due protezioni indipendenti
senza_wm = lordo(None, None)
assert senza_wm <= normale + 1e-12
assert B._scala(real_equity=None, eq_fallback=None) == 1.0
# ---------------------------------------------------------------- T5: il cap limita ancora
def test_T5_il_cap_continua_a_limitare_cio_che_limitava():
"""Il target non supera mai cap*scala, e il clamp MORDE ancora.
DEVE FALLIRE SE: qualcuno inverte l'ordine, o toglie il clamp 'tanto c'e' la scala'."""
cap = 500.0
morso = 0
for k in GRIGLIA_K:
for tp in GRIGLIA_TP:
for sg in GRIGLIA_SG:
net = B.book_net_target(tp, sg, EQ, cap, scala=k)
assert abs(net) <= cap * k + 1e-9, (k, tp, sg, net)
grezzo = B.WEIGHT * EQ * (B.W_TP01 * max(tp, 0.0) + B.W_SKH * sg)
if abs(grezzo) > cap:
morso += 1
assert abs(net) == pytest.approx(cap * k, abs=1e-9)
assert morso > 0, "il clamp non morde su nessuna osservazione: il test non controlla nulla"
# ---------------------------------------------------------------- T6: fuori tetto = STOP, non clamp
@pytest.mark.parametrize("valore,perche", [
(1.05, "fuori SCALA_LADDER: 'solo un po'' dev'essere inesprimibile"),
(1.50, "fuori SCALA_LADDER e oltre il tetto"),
(2.00, "oltre il tetto"),
])
def test_T6_una_scala_fuori_tetto_ferma_e_non_viene_tagliata(valore, perche):
"""DEVE FALLIRE SE: qualcuno 'rende robusto' il lettore con un min(scala, TETTO/...)."""
with pytest.raises(B.ScalaNonAutorizzata):
B._scala(real_equity=EQ, eq_fallback=None, cfg=_cfg(book_scale_k=valore))
def test_T6b_il_tetto_e_sul_PRODOTTO_non_sulla_sola_chiave():
"""frac 0,625 x scala 1,25 = 1,562x: passa un tetto sulla sola chiave, DEVE essere bloccato."""
with pytest.raises(B.ScalaNonAutorizzata):
B._scala(real_equity=EQ, eq_fallback=None,
cfg=_cfg(book_scale_k=1.25, max_notional_per_asset_frac=0.625))
# e la validazione avviene ANCHE sul percorso degradato: una config rotta non si nasconde
with pytest.raises(B.ScalaNonAutorizzata):
B._scala(real_equity=None, eq_fallback="paper",
cfg=_cfg(book_scale_k=1.25, max_notional_per_asset_frac=0.625))
def test_T6c_l_invariante_disaster_sl_blocca_chi_allarga_lo_stop():
"""Con sl 0,45 e scala 1,25 il costo di un disaster-SL supera mezza equity: STOP."""
with pytest.raises(B.ScalaNonAutorizzata):
B._scala(real_equity=EQ, eq_fallback=None,
cfg=_cfg(book_scale_k=1.25, disaster_sl_pct=0.45))
# ---------------------------------------------------------------- T7: inerzia bit-exact
def test_T7_senza_la_chiave_il_libro_e_quello_di_oggi():
"""Con chiave assente o = 1.0 il target e' BIT-EXACT alla formula pre-modifica.
DEVE FALLIRE SE: la modifica cambia qualcosa PRIMA che qualcuno decida di cambiarlo."""
def prima(tp, sg, eq, cap, weight=B.WEIGHT):
raw = weight * eq * (B.W_TP01 * max(tp, 0.0) + B.W_SKH * float(sg))
return max(-cap, min(cap, raw))
peggiore = 0.0
for cap in (50.0, 500.0, CAP_SPENTO):
for tp in GRIGLIA_TP:
for sg in GRIGLIA_SG:
a = prima(tp, sg, EQ, cap)
for scala in (1.0,):
peggiore = max(peggiore, abs(B.book_net_target(tp, sg, EQ, cap, scala=scala) - a))
assert peggiore == 0.0, f"la modifica non e' inerte: max|diff| = {peggiore}"
assert B._scala(real_equity=EQ, eq_fallback=None, cfg=_cfg()) == 1.0
assert float(json.loads((ROOT / "config" / "live.json").read_text())
.get("book_scale_k", 1.0)) == 1.0, "la chiave in config non e' 1,00"
# ---------------------------------------------------------------- T8: il report la dichiara
def test_T8_il_report_e_il_log_dichiarano_la_scala():
"""DEVE FALLIRE SE: un parametro che muove il nozionale resta invisibile nel report."""
src = (ROOT / "src" / "live" / "book.py").read_text()
assert "scala=scala," in src, "book_report non espone `scala` nel dict di ritorno"
ex = (ROOT / "scripts" / "live" / "book_execute.py").read_text()
assert "scala libro" in ex, "book_execute non stampa la scala"
assert "ScalaNonAutorizzata" in ex, "book_execute non gestisce la scala non autorizzata"
# ---------------------------------------------------------------- T9: derivata, non ridichiarata
def test_T9_la_scala_e_derivata_dal_codice_non_ridichiarata():
"""Il sorvegliante IMPORTA i bersagli dalla produzione. DEVE FALLIRE SE: due liste in due file."""
assert SW.LEVA_LORDA_MAX is B.LEVA_LORDA_MAX
assert SW.SCALA_LADDER is B.SCALA_LADDER
assert SW.ASSETS is B.ASSETS
assert SW.DISASTER_SL_BUDGET is B.DISASTER_SL_BUDGET
testo = (ROOT / "src" / "live" / "scale_watch.py").read_text()
for vietato in ("LEVA_LORDA_MAX =", "SCALA_LADDER =", "DISASTER_SL_BUDGET ="):
assert vietato not in testo, f"scale_watch RIDICHIARA {vietato} invece di importarlo"
# ---------------------------------------------------------------- T10: il sorvegliante segnala
def test_T10_il_sorvegliante_segnala_una_scala_non_autorizzata(tmp_path):
"""CONTROLLO POSITIVO del §6.4. DEVE FALLIRE SE: il sorvegliante e' tarato per non segnalare mai
(un rilevatore che non segnala mai e' indistinguibile da uno rotto)."""
giornale = [{"scala": 1.00, "dal": "2026-09-01T00:00:00+00:00", "gate": "iniziale"}]
muto = SW.osserva(cfg=_cfg(book_scale_k=1.00), giornale=giornale)
assert muto["stato"] == SW.OK, muto["domande"]
parlante = SW.osserva(cfg=_cfg(book_scale_k=1.25), giornale=giornale)
assert parlante["stato"] == SW.ALLARME
assert parlante["domande"]["D1"]["stato"] == SW.ALLARME, "D1 non vede la scala non autorizzata"
vuoto = SW.osserva(cfg=_cfg(book_scale_k=1.25), giornale=[])
assert vuoto["domande"]["D1"]["stato"] == SW.ALLARME, "giornale vuoto non e' un'autorizzazione"
sfora = SW.osserva(cfg=_cfg(book_scale_k=1.25, max_notional_per_asset_frac=0.625),
giornale=giornale)
assert sfora["domande"]["D3"]["stato"] == SW.ALLARME, "D3 non vede il prodotto oltre il tetto"
def test_T10b_una_allerta_per_streak_non_una_per_giro(tmp_path):
"""Disciplina degli allarmi: si dice UNA VOLTA per streak. E il marcatore 'gia' detto' si
scrive SOLO dopo un invio RIUSCITO (debito #2): con trasporto rotto si ridice."""
st = tmp_path / "state.json"
cfg = _cfg(book_scale_k=1.25)
giornale = [{"scala": 1.00, "dal": "2026-09-01T00:00:00+00:00"}]
inviati = []
def ok_sender(t):
inviati.append(t)
return True, "finto"
a = SW.run_once(state_path=st, sender=ok_sender, cfg=cfg, giornale=giornale)
b = SW.run_once(state_path=st, sender=ok_sender, cfg=cfg, giornale=giornale)
assert "D1" in a["da_dire"] and "D1" not in b["da_dire"], "ridice a ogni giro"
assert len(inviati) == 1
st.unlink()
rotti = []
def ko_sender(t):
rotti.append(t)
return False, "trasporto giu'"
SW.run_once(state_path=st, sender=ko_sender, cfg=cfg, giornale=giornale)
c = SW.run_once(state_path=st, sender=ko_sender, cfg=cfg, giornale=giornale)
assert "D1" in c["da_dire"], "un invio FALLITO ha marcato 'gia' detto': l'allarme e' perso"
assert len(rotti) == 2
def test_T10c_tre_stati_non_due(tmp_path):
"""'Non vedo' non e' 'va tutto bene': deve esistere NON MISURABILE."""
r = SW.osserva(cfg=dict(disaster_sl_pct=0.3), giornale=[]) # frac assente
assert r["domande"]["D3"]["stato"] == SW.NON_MISURABILE
fb = SW.frequenza_fallback(log=tmp_path / "non-esiste.log")
assert fb["stato"] == SW.NON_MISURABILE
# ---------------------------------------------------------------- T11: sostituito, non rilassato
def test_T11_il_vecchio_test_di_leva_e_stato_sostituito_non_rilassato():
"""DEVE FALLIRE SE: qualcuno alza la soglia del vecchio test da 1,0 a 1,25 e chiude li'."""
fs = (ROOT / "tests" / "test_fee_sensitivity.py").read_text()
assert "def test_leva_massima_da_config_resta_sotto_o_uguale_a_1x" not in fs, (
"il vecchio test misurava frac*n_asset mentre la grandezza vera e' frac*n_asset*scala: "
"va SOSTITUITO da T2/T3, non rilassato")
qui = Path(__file__).read_text()
assert "def test_T2_" in qui and "def test_T3_" in qui
+4 -10
View File
@@ -58,11 +58,8 @@ def test_funzionale_feed_stantio_blocca_lesecuzione_e_allerta(monkeypatch, capsy
"""Il test che conta: conto ONLINE, posizione LEGGIBILE, ordine PRESENTE — cioe' la situazione
esatta del 2026-07-14 ma feed vecchio. Non deve partire nessun ordine e deve arrivare l'alert.
"""
import importlib.util
spec = importlib.util.spec_from_file_location(
"book_execute_iso", ROOT / "scripts" / "live" / "book_execute.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
from helpers import carica_script
mod = carica_script("scripts/live/book_execute.py", "book_execute_iso")
canned = dict(
last_data="2026-07-08", online=True, real_equity=598.0, equity=598.0,
@@ -94,11 +91,8 @@ def test_funzionale_feed_stantio_blocca_lesecuzione_e_allerta(monkeypatch, capsy
def test_config_senza_la_chiave_non_esplode(monkeypatch, capsys):
"""Una config priva di max_data_age_days deve ricadere sul default, non sollevare KeyError
dentro il percorso con soldi veri."""
import importlib.util
spec = importlib.util.spec_from_file_location(
"book_execute_iso2", ROOT / "scripts" / "live" / "book_execute.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
from helpers import carica_script
mod = carica_script("scripts/live/book_execute.py", "book_execute_iso2")
canned = dict(last_data=str(pd.Timestamp.now(tz="UTC").normalize().date()), online=True,
real_equity=598.0, equity=598.0, eq_basis="t", cap_per_asset=300.0,
skh_error=None, pos_error=None, assets=[], orders=[])
+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" / "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
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, (
"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()
+168
View File
@@ -0,0 +1,168 @@
"""Un flag ignoto non e' l'azione di default (2026-09-02).
Costo misurato durante una revisione dello stesso giorno: `trades_db.py --help` non stampava
l'uso, cadeva in `sync()` e riscriveva `meta.ultimo_sync`. Su `journal.py` lo stesso errore
avrebbe scritto una pagina e una riga di DB; su `analista.py` avrebbe speso una chiamata al
modello e mandato un Telegram.
Il test guarda anche l'altra meta' del contratto (P15): i flag che il CRON usa devono restare
accettati una guardia che rompe il cron e' peggio del difetto che ripara.
"""
from __future__ import annotations
import ast
import re
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from src.live.cli import UsoSbagliato, valida # noqa: E402
# Tutti gli script di scripts/live/ che NON usano argparse: la guardia deve coprirli tutti, e
# l'elenco si DERIVA dalla cartella (P1) — un file nuovo senza guardia fa fallire il test, non
# passa inosservato perche' nessuno ha aggiornato una lista.
LIVE = Path(__file__).resolve().parents[1] / "scripts" / "live"
def _senza_argparse() -> list[str]:
fuori = []
for f in sorted(LIVE.glob("*.py")):
s = f.read_text()
if f.name == "__init__.py" or "argparse" in s or "if __name__" not in s:
continue
fuori.append(f.name)
return fuori
SCRIPT_GUARDATI = tuple(_senza_argparse())
assert SCRIPT_GUARDATI, "nessuno script da guardare: il modulo non sta controllando niente"
SCRIVONO = ("trades_db.py", "journal.py", "analista.py") # i tre che hanno pagato il difetto
# ---------------------------------------------------------------- la funzione
def _v(argv, **kw):
return valida("x.py", "uso: x.py", argv=argv, esci=False, **kw)
def test_un_flag_noto_passa_e_torna_gli_argomenti():
assert _v(["--report"], flag=("--report",)) == ["--report"]
def test_un_flag_ignoto_e_un_errore_non_il_default():
with pytest.raises(UsoSbagliato) as e:
_v(["--help-me"], flag=("--report",))
assert "--help-me" in str(e.value) and "--report" in str(e.value) # P4: cosa, e cosa si puo'
def test_il_valore_di_un_flag_non_e_scambiato_per_un_flag():
assert _v(["--giorno", "2026-08-21"], con_valore=("--giorno",)) == ["--giorno", "2026-08-21"]
with pytest.raises(UsoSbagliato):
_v(["--giorno"], con_valore=("--giorno",)) # manca il valore
def test_un_valore_che_sembra_un_flag_resta_un_valore():
assert _v(["--nota", "--quiet"], con_valore=("--nota",), flag=("--quiet",))
def test_un_posizionale_e_ignoto():
with pytest.raises(UsoSbagliato):
_v(["report"], flag=("--report",))
def test_senza_argomenti_non_solleva():
assert _v([], flag=("--report",)) == []
def test_help_non_e_un_errore():
assert _v(["--help"], flag=("--report",)) == ["--help"]
# ---------------------------------------------------------------- gli script veri
def _uso(nome: str) -> str:
"""La costante USO dello script, letta dal sorgente senza importarlo."""
src = (ROOT / "scripts" / "live" / nome).read_text()
for n in ast.parse(src).body:
if isinstance(n, ast.Assign) and getattr(n.targets[0], "id", "") == "USO":
return n.value.value
raise AssertionError(f"{nome} non dichiara USO")
@pytest.mark.parametrize("nome", SCRIPT_GUARDATI)
def test_lo_script_chiama_valida_prima_di_ogni_effetto(nome):
"""`valida` dev'essere la PRIMA istruzione eseguibile del blocco __main__: uscire dopo aver
aperto il DB o sincronizzato sarebbe uscire dopo l'effetto."""
src = (ROOT / "scripts" / "live" / nome).read_text()
main = [n for n in ast.parse(src).body
if isinstance(n, ast.If) and "__main__" in ast.dump(n.test)]
assert main, f"{nome}: nessun blocco __main__"
corpo = [n for n in main[0].body if not isinstance(n, ast.ImportFrom | ast.Import)]
chiamate = [n for n in ast.walk(corpo[0]) if isinstance(n, ast.Call)]
assert any(getattr(c.func, "id", "") == "valida" for c in chiamate), \
f"{nome}: la prima istruzione di __main__ non e' valida()"
@pytest.mark.parametrize("nome", SCRIPT_GUARDATI)
def test_l_uso_elenca_esattamente_i_flag_dichiarati(nome):
"""P4 e P1: il testo d'aiuto non e' prosa libera — deve nominare i flag che lo script accetta."""
src = (ROOT / "scripts" / "live" / nome).read_text()
dichiarati = set(re.findall(r'["\'](--[a-z-]+)["\']', src.split("if __name__")[1]))
uso = _uso(nome)
mancanti = {f for f in dichiarati if f not in uso}
assert not mancanti, f"{nome}: flag accettati ma non documentati in USO: {mancanti}"
# I flag che il cron usa DAVVERO, letti dagli script di cron: se la guardia ne rifiutasse uno,
# il libro di bordo si fermerebbe la notte stessa (P16: il cron gira dal working tree).
def test_i_flag_del_cron_sono_tutti_accettati():
usati: dict[str, set[str]] = {}
for sh in (ROOT / "scripts").glob("cron_*.sh"):
for riga in sh.read_text().splitlines():
for nome in SCRIPT_GUARDATI:
if f"scripts/live/{nome}" in riga and not riga.lstrip().startswith("#"):
usati.setdefault(nome, set()).update(re.findall(r"(--[a-z-]+)", riga))
assert usati, "nessun cron chiama gli script guardati: il test non sta controllando niente"
for nome, flag in usati.items():
src = (ROOT / "scripts" / "live" / nome).read_text()
noti = set(re.findall(r'["\'](--[a-z-]+)["\']', src.split("if __name__")[1]))
assert flag <= noti, f"{nome}: il cron usa {flag - noti}, che la guardia rifiuterebbe"
# controllo positivo (M15): il confronto sa dire di no. Se il cron usasse un flag non
# dichiarato, questo test lo direbbe — qui lo si verifica su un caso finto.
assert not {"--inventato"} <= {"--quiet"}
@pytest.mark.parametrize("nome", SCRIVONO)
def test_help_esce_zero_e_non_scrive(nome):
"""Controllo end-to-end (M15): il caso che ha causato il difetto, sui tre script veri."""
prima = (ROOT / "data" / "live" / "trades.db").stat().st_mtime
r = subprocess.run([sys.executable, str(ROOT / "scripts" / "live" / nome), "--help"],
capture_output=True, text=True, cwd=ROOT, timeout=120)
assert r.returncode == 0, r.stderr[-500:]
assert "uso:" in r.stdout
assert (ROOT / "data" / "live" / "trades.db").stat().st_mtime == prima
@pytest.mark.parametrize("nome", SCRIVONO)
def test_un_flag_ignoto_esce_due_e_non_scrive(nome):
prima = (ROOT / "data" / "live" / "trades.db").stat().st_mtime
r = subprocess.run([sys.executable, str(ROOT / "scripts" / "live" / nome), "--nonesiste"],
capture_output=True, text=True, cwd=ROOT, timeout=120)
assert r.returncode == 2, r.stdout[-300:]
assert "non previsto" in r.stderr
assert (ROOT / "data" / "live" / "trades.db").stat().st_mtime == prima
def test_ogni_script_senza_argparse_ha_la_guardia():
"""L'elenco e' derivato dalla cartella: uno script nuovo che legge sys.argv a mano e non
chiama `valida` fa fallire questo test invece di ripetere il difetto in silenzio."""
senza = [n for n in SCRIPT_GUARDATI
if "valida(" not in (LIVE / n).read_text().split("if __name__")[1]]
assert not senza, f"script senza guardia sui flag: {senza}"
assert len(SCRIPT_GUARDATI) >= 17, f"troppo pochi script guardati: {SCRIPT_GUARDATI}"
+124
View File
@@ -0,0 +1,124 @@
"""Guardie su COLLAR01 (§71): i difetti che l'apparato ha gia' pagato non devono tornare.
Non testano il VERDETTO (quello e' una misura, e cambia col dato): testano che il MOTORE sappia
riconoscere un successo e non regali soldi al collar per errore di contabilita'. E' la lezione di
§46 *"un controllo positivo rotto dichiara guasto l'apparato"* trasformata in test.
Calibrazione SINTETICA (non la catena vera): il test dev'essere veloce e hermetico. Quello che
verifica e' l'algebra del motore, non i parametri di mercato.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts" / "research"))
@pytest.fixture(scope="module")
def C():
import r0901_btc_collar as mod
return mod
@pytest.fixture(scope="module")
def cal():
"""Skew/termine/spread piatti e neutri: isola l'algebra dai parametri di mercato."""
d = np.array([0.02, 0.10, 0.20, 0.30, 0.50])
return dict(
skew={"put": (d, np.ones_like(d)), "call": (d, np.ones_like(d))},
term=(np.array([1.0, 30.0]), np.array([1.0, 1.0])),
spread={"put": (d, np.zeros_like(d)), "call": (d, np.zeros_like(d))},
)
@pytest.fixture(scope="module")
def P():
"""Serie deterministica di 800 giorni con un trend e un crollo: abbastanza per il gate a 180g."""
rng = np.random.default_rng(7)
n = 800
r = rng.normal(0.0015, 0.03, n)
r[600:640] = -0.045 # un crollo vero, dove il pavimento deve mordere
px = 30000.0 * np.exp(np.cumsum(r))
idx = pd.date_range("2022-01-01", periods=n, freq="D", tz="UTC")
return pd.DataFrame({"px": px, "iv": np.full(n, 0.60)}, index=idx)
def test_pranzo_gratis_e_riconosciuto(C, P, cal):
"""CONTROLLO POSITIVO (M15). Un pavimento a premio ZERO e senza tetto e' denaro regalato:
DEVE abbassare il maxDD E alzare il drift. Se questo test fallisce, ogni verdetto NEGATIVO
del filone e' illeggibile — non e' il collar che perde, e' l'apparato che e' rotto."""
dirs = C.gate_trend(P, True)
base = C.collar(P, cal, dput=0.2, dcall=0.2, tenor=14, dirs=dirs, senza_opzioni=True)["ret"]
free = C.collar(P, cal, dput=0.2, dcall=0.2, tenor=14, dirs=dirs,
prem_scale=0.0, solo_pavimento=True)["ret"]
assert C._dd(free) < C._dd(base), "un pavimento gratis non abbassa il maxDD: apparato guasto"
assert C._drift(free) > C._drift(base), "un pavimento gratis non alza il drift: apparato guasto"
def test_premio_esagerato_deve_perdere(C, P, cal):
"""CONTROLLO NEGATIVO: a premio x10 la struttura deve perdere contro la base."""
dirs = C.gate_trend(P, True)
base = C.collar(P, cal, dput=0.2, dcall=0.2, tenor=14, dirs=dirs, senza_opzioni=True)["ret"]
caro = C.collar(P, cal, dput=0.2, dcall=0.2, tenor=14, dirs=dirs, prem_scale=10.0)["ret"]
assert C._drift(caro) < C._drift(base)
def test_nav_short_parte_positivo(C, P, cal):
"""Difetto reale trovato il 2026-09-01: scritto come `direzione*S_t + ...` il NAV di uno SHORT
partirebbe NEGATIVO. Lo short non e' un'attivita' che vale -S: e' capitale a garanzia."""
dirs = np.where(np.arange(len(P)) >= 200, -1.0, 0.0)
out = C.collar(P, cal, dput=0.2, dcall=0.2, tenor=14, dirs=dirs)
assert (out["nav"] > 0).all(), "NAV non positivo in direzione short"
def test_zerocost_bisezione_non_invertita(C, P, cal):
"""Difetto reale: la bisezione dello zero-cost era INVERTITA (dava Sharpe -3,9 e drift -53%/a).
L'invariante e' sulla BISEZIONE, non sul drift: il premio cresce col delta, quindi per
finanziare una put PIU' CARA il tetto va AVVICINATO => il delta scelto per il tetto deve
CRESCERE col delta del pavimento. (Il drift NON e' monotono e non va usato come proxy: un
pavimento piu' stretto aiuta nei crolli e danneggia nei rally.)
"""
dirs = C.gate_trend(P, True)
scelti = []
for dp in (0.05, 0.15, 0.30):
o = C.collar(P, cal, dput=dp, dcall=np.nan, tenor=14, dirs=dirs, zerocost=True)
assert o["dc_scelti"], "lo zero-cost non ha aperto nessuna struttura"
assert np.isfinite(C._drift(o["ret"])), "zero-cost non finito (NaN nella cassa)"
scelti.append(float(np.median(o["dc_scelti"])))
assert scelti[0] < scelti[1] < scelti[2], f"bisezione invertita: dc scelti {scelti}"
def test_base_senza_opzioni_non_paga_roll_fantasma(C, P, cal):
"""Difetto reale: la base del null rollava lo SPOT ogni `tenor` giorni pagando fee che nella
realta' non esistono (~3,6%/anno a 7 giorni) — avrebbe ADULATO il collar. La base non deve
dipendere dal tenor, che senza opzioni non ha significato."""
dirs = C.gate_trend(P, True)
a = C.collar(P, cal, dput=0.2, dcall=0.2, tenor=7, dirs=dirs, senza_opzioni=True)["ret"]
b = C.collar(P, cal, dput=0.2, dcall=0.2, tenor=14, dirs=dirs, senza_opzioni=True)["ret"]
assert C._drift(a) == pytest.approx(C._drift(b), abs=1e-9), "la base dipende dal tenor"
def test_gate_forte_e_piu_selettivo_del_largo(C, P):
"""`tsmom_blend` assume solo {-1,-1/3,+1/3,+1}: il gate FORTE (3/3 concordi) deve essere un
SOTTOINSIEME stretto del LARGO, o "forte bull" non sta selezionando nulla."""
f = C.gate_trend(P, True)
l = C.gate_trend(P, False)
assert np.mean(f != 0) < np.mean(l != 0)
assert np.all((f == 0) | (f == l)), "il gate forte non e' un sottoinsieme del largo"
def test_entrata_non_guarda_il_futuro(C, P):
"""L'entrata e' il giorno DOPO il segnale (§8.1): la direzione del giorno i non puo' dipendere
dal prezzo del giorno i."""
d0 = C.gate_trend(P, True)
P2 = P.copy()
P2.iloc[-1, P2.columns.get_loc("px")] *= 1.5 # muovo SOLO l'ultimo prezzo
d1 = C.gate_trend(P2, True)
assert np.array_equal(d0, d1), "la direzione reagisce a un prezzo che non ha ancora visto"
+269
View File
@@ -501,3 +501,272 @@ def test_ogni_soglia_dice_COSA_decidere_non_solo_che_e_arrivata():
"""P4: un'allerta risponde a due domande. Una riga che dice solo «sei a $15k» non serve."""
for _, testo in J.SOGLIE_CAPITALE:
assert len(testo) > 80 and "**" in testo, "la soglia non dice quale decisione sblocca"
# ------------------------------------------------- TWR: il rendimento che non e' un bonifico
# Debito #14 (2026-09-01): `trades_db --report` stampava `e1/e0-1` sulla serie grezza, «+243%»
# per il 96,3% un versamento. La riparazione (`movimenti_capitale`) viveva qui e non aveva
# attraversato il confine fra i due lettori. `rendimento_twr` e' la funzione che ENTRAMBI chiamano.
def test_twr_spezza_sul_versamento_e_il_grezzo_no(db, monkeypatch):
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto())
# +5% di trading: SOTTO la soglia del rilevatore (0,10) — un +10% con mercato piatto
# sarebbe esso stesso un "movimento", ed e' il limite dichiarato (D5) del classificatore.
_serie_equity(db, [("2026-08-24T10:47:01+00:00", 600.0),
("2026-08-25T10:47:01+00:00", 630.0), # +5% di trading
("2026-08-25T11:47:01+00:00", 2030.0), # +1400: versamento
("2026-08-26T10:47:01+00:00", 2009.7)]) # -1% di trading
r = J.rendimento_twr(db, "2026-08-26T23:59:59+00:00")
assert r["twr"] == pytest.approx(1.05 * 0.99 - 1.0) # +3,95%
assert r["certi"] == pytest.approx(1400.0) and r["ambigui"] == 0.0
assert [round(s["ret"], 4) for s in r["segmenti"]] == [0.05, -0.01]
assert r["trading"] == pytest.approx(2009.7 - 600.0 - 1400.0) # +9,7
grezzo = 2009.7 / 600.0 - 1.0 # +235%: il numero sbagliato
assert abs(r["twr"] - grezzo) > 2.0
def test_twr_senza_movimenti_e_il_rendimento_semplice(db, monkeypatch):
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto())
_serie_equity(db, [("2026-08-24T10:47:01+00:00", 600.0),
("2026-08-25T10:47:01+00:00", 630.0)])
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
assert r["twr"] == pytest.approx(0.05) and len(r["segmenti"]) == 1
assert r["trading"] == pytest.approx(30.0) and r["eventi"] == []
def test_twr_un_ambiguo_non_spezza_e_resta_dichiarato(db, monkeypatch):
# stesso caso del test sul crash a tutta leva: -14% con il mercato a -15% -> ambiguo
ix = pd.date_range("2026-08-20", "2026-08-27", freq="1h", tz="UTC")
px = pd.Series(100.0, index=ix)
px[ix >= "2026-08-25T11:00:00+00:00"] = 85.0
monkeypatch.setattr(J, "_px_1h", lambda a: px)
_serie_equity(db, [("2026-08-25T10:47:01+00:00", 600.0),
("2026-08-25T11:47:01+00:00", 516.0)])
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
assert r["twr"] == pytest.approx(-0.14) and len(r["segmenti"]) == 1
assert r["certi"] == 0.0 and r["ambigui"] == pytest.approx(-84.0)
assert r["trading"] == pytest.approx(-84.0) # niente scorporo
def test_twr_serie_vuota_e_None_con_motivo_non_zero(db):
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
assert r["twr"] is None and r["motivo"] and r["trading"] is None
def test_twr_riproduce_il_numero_del_diario_01_09(db, monkeypatch):
# M23: prima di pubblicare un numero nuovo, far riprodurre alla macchina quello vecchio.
# Diario 2026-09-01-stato-trades: 598,06 -> 667,49 | +1.399,39 | 2.066,88 -> 2.051,84 = +10,80%
ix = pd.date_range("2026-06-20", "2026-09-02", freq="1h", tz="UTC")
monkeypatch.setattr(J, "_px_1h", lambda a: pd.Series(100.0, index=ix))
# una lettura intermedia: nella serie vera ci sono ~1.500 letture orarie fra le due date,
# e nessun salto di trading fra due consecutive tocca la soglia del rilevatore
_serie_equity(db, [("2026-06-23T22:00:00+00:00", 598.06),
("2026-07-20T10:47:01+00:00", 630.00),
("2026-08-25T10:47:01+00:00", 667.49),
("2026-08-25T11:47:01+00:00", 2066.88),
("2026-09-01T16:47:01+00:00", 2051.84)])
r = J.rendimento_twr(db, "2026-09-01T23:59:59+00:00")
assert round(100 * r["twr"], 2) == 10.80
assert r["certi"] == pytest.approx(1399.39)
def test_twr_a_base_zero_e_None_anche_sul_trading(db, monkeypatch):
# revisione 02/09: a base zero `movimenti_capitale` non vede il salto 0 -> X del primo
# versamento, quindi `trading = e1-e0-certi` varrebbe l'intero conto. Tre stati, non due.
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto())
_serie_equity(db, [("2026-08-24T09:47:01+00:00", 0.0),
("2026-08-24T10:47:01+00:00", 598.06),
("2026-08-25T10:47:01+00:00", 610.0)])
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
assert r["twr"] is None and r["trading"] is None and "base" in r["motivo"]
def test_twr_due_movimenti_consecutivi_non_producono_segmenti_a_lunghezza_zero(db, monkeypatch):
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto())
_serie_equity(db, [("2026-08-24T10:47:01+00:00", 600.0),
("2026-08-24T11:47:01+00:00", 2000.0), # movimento nel PRIMO intervallo
("2026-08-24T12:47:01+00:00", 3400.0), # movimento consecutivo
("2026-08-25T10:47:01+00:00", 3434.0)]) # +1% di trading
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
assert len(r["segmenti"]) == 1 and r["segmenti"][0]["ret"] == pytest.approx(0.01)
assert r["twr"] == pytest.approx(0.01)
assert all(s["da"] != s["a"] for s in r["segmenti"])
assert r["certi"] == pytest.approx(1400.0 + 1400.0)
def test_twr_senza_tempo_a_mercato_e_zero_non_None(db, monkeypatch):
# due letture, la seconda e' un versamento: nessun segmento, rendimento 0, dichiarato numero
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto())
_serie_equity(db, [("2026-08-24T10:47:01+00:00", 600.0),
("2026-08-24T11:47:01+00:00", 2000.0)])
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
assert r["twr"] == 0.0 and r["segmenti"] == [] and r["trading"] == pytest.approx(0.0)
# ------------------------------------------------- mercato NON misurabile (revisione 02/09)
# Il feed 1h certificato si ferma alle 00:00 (rebuild alle 00:30): per il resto del giorno
# `asof` restituiva la STESSA barra per le due letture, max_mkt 0, e qualunque calo >=10%
# diventava un "movimento" — un crash stampato come prelievo. E con il feed assente tutto
# era "ambiguo", certi=0, e il TWR tornava a essere e1/e0-1 sotto l'etichetta sbagliata.
def test_un_salto_oltre_la_fine_del_feed_e_ambiguo_con_la_ragione(db, monkeypatch):
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto()) # copre fino al 27/08
_serie_equity(db, [("2026-08-28T10:47:01+00:00", 2000.0),
("2026-08-28T11:47:01+00:00", 1760.0)]) # -12% dopo la fine
p = J.pnl_giorno(db, date(2026, 8, 28))
ev = p["movimenti_giorno"]
assert ev[0]["classe"] == "ambiguo" and ev[0]["mercato_max_pct"] is None
assert "fermo a 2026-08-27" in ev[0]["mercato_nota"]
assert p["mov_giorno"] == 0.0 # NON scorporato
assert p["trading_da_arming"] is None and p["twr_da_arming"] is None
assert "non classificabile" in p["twr_motivo"]
def test_con_il_feed_assente_il_twr_e_None_non_il_grezzo(db, monkeypatch):
monkeypatch.setattr(J, "_px_1h", lambda a: None)
_serie_equity(db, [("2026-08-25T10:47:01+00:00", 667.0),
("2026-08-25T11:47:01+00:00", 2066.0)])
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
assert r["twr"] is None and r["trading"] is None
assert "non leggibile" in r["motivo"]
assert r["eventi"][0]["classe"] == "ambiguo"
def test_il_giornale_e_il_report_usano_la_stessa_funzione(db, monkeypatch):
# P1, revisione 02/09: pnl_giorno non rifa' piu' `cum - certi`; prende trading e TWR da
# rendimento_twr, e la pagina lo stampa accanto al cumulato
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto())
_serie_equity(db, [("2026-08-24T10:47:01+00:00", 600.0),
("2026-08-25T10:47:01+00:00", 630.0),
("2026-08-25T11:47:01+00:00", 2030.0),
("2026-08-25T12:47:01+00:00", 2009.7)])
p = J.pnl_giorno(db, date(2026, 8, 25))
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
assert p["trading_da_arming"] == pytest.approx(r["trading"])
assert p["twr_da_arming"] == pytest.approx(r["twr"]) == pytest.approx(1.05 * 0.99 - 1)
md = J.rendi_markdown(db, _voce(pnl=p, giorno="2026-08-25"))
assert "TWR **+3.95%**" in md
def test_la_leva_del_classificatore_include_la_scala_e_il_tetto_di_codice(db, monkeypatch, tmp_path):
# P1: la chiave di scala del 01/09 entra nel bound da sola; e il fallback e' il tetto di CODICE
from src.live import book as B
cfg = tmp_path / "live.json"
cfg.write_text(json.dumps({"max_notional_per_asset_frac": 0.5, "book_scale_k": 1.25}))
monkeypatch.setattr(J, "PROJECT_ROOT", tmp_path); (tmp_path / "config").mkdir()
cfg.rename(tmp_path / "config" / "live.json")
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", 2000.0)])
mv = J.movimenti_capitale(db, "2026-08-25T23:59:59+00:00")
assert mv["leva_tetto"] == pytest.approx(min(0.5 * 2 * 1.25, B.LEVA_LORDA_MAX))
def test_balance_watch_registra_anche_l_indice_usde(monkeypatch):
"""Debito §5.15, primo passo (02/09): serve una serie ORARIA dell'indice usde_usdc — quella
di `usde_watch` e' giornaliera, e il classificatore confronta letture a un'ora di distanza.
Il campione la porta, e un indice non leggibile e' None con la ragione, mai 1.0 (P5)."""
from helpers import carica_script
bw = carica_script("scripts/live/balance_watch.py")
class _C:
def account_summary(self, cur): return {"balance": 1.0, "equity": 1.0}
def positions(self, cur): return []
from src.live import usde as U
monkeypatch.setattr(U, "prezzo_indice", lambda *a, **k: 0.9997)
assert bw.campiona(_C())["usde_usdc"] == 0.9997
def _rotto(*a, **k): raise RuntimeError("giu'")
monkeypatch.setattr(U, "prezzo_indice", _rotto)
rec = bw.campiona(_C())
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"]
+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"
+93
View File
@@ -0,0 +1,93 @@
"""`trades_db.py --report` non stampa piu' un bonifico come rendimento (debito #14, 2026-09-02).
Il numero che stampava era `e1/e0-1` sulla serie grezza: «+243%» per il 96,3% un versamento.
Il report ora CHIAMA `journal.rendimento_twr` (P1: non rifa' il conto). `T.connect()` nudo
finisce in tmp grazie alla fixture autouse di conftest: qui non si tocca il libro vivo.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pandas as pd
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from helpers import carica_script # noqa: E402
from src.live import journal as J # noqa: E402
from src.live import tradesdb as T # noqa: E402
def _carica_script():
return carica_script("scripts/live/trades_db.py", "trades_db_script")
def _px_piatto(livello=100.0):
ix = pd.date_range("2026-08-20", "2026-08-27", freq="1h", tz="UTC")
return pd.Series(livello, index=ix)
def _prepara(con, equity):
# il fill si scrive con `upsert_fills`, non con un INSERT a mano: una riga scritta a mano
# aveva `verified` NULL, una forma che la produzione non produce mai
T.upsert_fills(con, [T.Fill(ts_utc="2026-08-24T10:47:15+00:00", ts_source="test",
asset="BTC", side="buy", qty=0.001, price=60000.0, fee=0.01)])
T.upsert_equity(con, [(ts, eq, "test") for ts, eq in equity])
def test_il_report_stampa_il_TWR_e_non_il_grezzo(monkeypatch, capsys):
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto())
con = T.connect() # -> tmp (conftest), NON data/live/trades.db
assert "data/live" not in str(con.execute("PRAGMA database_list").fetchone()[2])
_prepara(con, [("2026-08-24T10:47:01+00:00", 600.0),
("2026-08-25T10:47:01+00:00", 630.0), # +5%: sotto soglia rilevatore
("2026-08-25T11:47:01+00:00", 2030.0), # +1400: versamento
("2026-08-26T10:47:01+00:00", 2009.7)]) # -1%
con.close()
_carica_script().report()
out = capsys.readouterr().out
assert "TWR" in out and "+3.95%" in out
assert "+234.95%" not in out # il grezzo non compare piu'
assert "+1,400.00 certi" in out
assert "+9.70" in out
assert "movimenti di capitale INCLUSI" in out # il delta $ resta, etichettato
def test_il_report_senza_movimenti_dice_lo_stesso_numero_di_prima(monkeypatch, capsys):
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto())
con = T.connect()
_prepara(con, [("2026-08-24T10:47:01+00:00", 600.0), ("2026-08-25T10:47:01+00:00", 630.0)])
con.close()
_carica_script().report()
out = capsys.readouterr().out
assert "+5.00%" in out and "movimenti capitale" not in out
def test_il_report_a_base_zero_dice_n_d_e_non_l_intero_conto(monkeypatch, capsys):
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto())
con = T.connect()
_prepara(con, [("2026-08-24T09:47:01+00:00", 0.0),
("2026-08-24T10:47:01+00:00", 598.06),
("2026-08-25T10:47:01+00:00", 610.0)])
con.close()
_carica_script().report()
out = capsys.readouterr().out
assert out.count("n/d") >= 2 and "TWR" in out
assert "+610.00 di equity" in out and "arming : +610.00" not in out
def test_il_report_con_il_feed_assente_non_stampa_il_grezzo_come_TWR(monkeypatch, capsys):
# revisione 02/09: con data/raw assente (host ripristinato prima di rebuild_history) ogni
# salto era "ambiguo", certi=0, e il report stampava +243% sotto l'etichetta TWR
monkeypatch.setattr(J, "_px_1h", lambda a: None)
con = T.connect()
_prepara(con, [("2026-08-24T10:47:01+00:00", 600.0),
("2026-08-25T11:47:01+00:00", 2000.0),
("2026-08-26T10:47:01+00:00", 2020.0)])
con.close()
_carica_script().report()
out = capsys.readouterr().out
assert out.count("n/d") >= 2 and "TWR" in out
assert "% =" not in out and "+236" not in out
assert "[ambiguo: mercato non misurabile" in out
+64
View File
@@ -0,0 +1,64 @@
"""venue_news: il guardiano di cio' che il venue ANNUNCIA.
Si testano le funzioni PURE (parsing del feed, match, derivazione delle chiavi). La rete no:
`scarica()` ritorna None su qualunque errore ed e' quello il contratto — 'non vedo' non e'
'niente di nuovo' (P5).
"""
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "live"))
import importlib.util
_spec = importlib.util.spec_from_file_location(
"venue_news", ROOT / "scripts" / "live" / "venue_news.py")
vn = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(vn)
_FEED = """<rss><channel>
<item><title><![CDATA[Contract Specifications change for Linear USDC Perpetuals]]></title>
<link>https://x/a</link><guid>https://x/?p=1</guid><pubDate>Fri, 14 Aug 2026 09:18:57 +0000</pubDate></item>
<item><title>Deribit Welcomes Orbit Markets</title>
<link>https://x/b</link><guid>https://x/?p=2</guid><pubDate>Fri, 10 Jul 2026 00:00:00 +0000</pubDate></item>
</channel></rss>"""
def test_estrae_le_voci_con_guid_titolo_e_data():
v = vn.voci(_FEED)
assert [x["guid"] for x in v] == ["https://x/?p=1", "https://x/?p=2"]
assert v[0]["titolo"] == "Contract Specifications change for Linear USDC Perpetuals"
assert v[0]["data"].startswith("Fri, 14 Aug 2026")
def test_feed_illeggibile_non_solleva_e_non_inventa_voci():
assert vn.voci("") == []
assert vn.voci("<rss>rotto") == []
def test_le_chiavi_sono_DERIVATE_dagli_strumenti_del_book():
"""P1: il bersaglio si deriva dal codice sorvegliato, non si ridichiara. Se un domani il book
prende un altro asset, la sorveglianza lo segue da sola."""
from src.live.deribit import _CONTRACT
ks = vn.chiavi()
for nome in _CONTRACT:
base = nome.split("_")[0].split("-")[0].lower()
assert base in ks, f"{base} (da {nome}) non e' fra le parole sorvegliate"
assert "usdc" in ks and "margin" in ks and "collateral" in ks
def test_riconosce_l_annuncio_che_ci_tocca_e_lascia_stare_gli_altri():
ks = vn.chiavi()
assert vn.rilevante("Contract Specifications change for Linear USDC Perpetuals", ks)
assert vn.rilevante("New SM Margin Model On Deribit", ks)
assert vn.rilevante("Deribit to Discontinue Daily Proof of Reserves Publication", ks)
assert vn.rilevante("USDC Rewards Available In More Countries", ks)
assert not vn.rilevante("Deribit Welcomes Orbit Markets to Block RFQ Platform", ks)
def test_il_match_e_case_insensitive_e_riporta_QUALI_parole():
m = vn.rilevante("New SM MARGIN Model On Deribit", vn.chiavi())
assert "margin" in m