diff --git a/.gitignore b/.gitignore index e7a7f35..717e821 100644 --- a/.gitignore +++ b/.gitignore @@ -74,6 +74,8 @@ data/paper_combo/ data/paper_statarb/ data/paper_xsr/ data/paper_dvolspread/ +# stato della sorveglianza fee (ultima lettura del tier Deribit, per rilevarne i cambiamenti) +data/fee_watch/ # log esecuzioni del book live (stato runtime, contiene fill/fee del conto reale) data/live/ diff --git a/CLAUDE.md b/CLAUDE.md index 106a535..f1bfc0e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1326,6 +1326,98 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis aggregato non torna con la somma dei suoi pezzi, **fermarsi** — era l'unico segnale del bug (nessun test, nessuna eccezione, output plausibile); (d) **la copertura per colonna e' la prima diagnostica di un outer-join**. +- ✅ **EDGE WATCH — criteri di kill del book LIVE, cablati (2026-07-26).** *(Bullet aggiunto il + 27/07: era in cron dal 26/07 e NON stava in CLAUDE.md — il criterio di morte di cio' che gira + con soldi veri non era nella memoria operativa.)* `scripts/live/edge_watch.py` (in + `cron_daily.sh`), taratura `r0726_edge_death.py`, test `tests/test_edge_watch.py` (11). + Chiudeva l'asimmetria: gate di kill pre-registrati per i CANDIDATI, nessuno per il book. + **(A) RITORNO: Sharpe rolling 36m < −0.5** — falso kill 1.8% in 10 anni, riconosce l'edge morto + nel 91% dei casi in ~3.8 anni. ⚠️ **La lentezza non e' un difetto della regola, e' statistica:** + a 12 mesi la stessa regola ucciderebbe un edge VIVO nell'80% dei casi; a 12m il 56% dei casi + "morto" e' indistinguibile da uno vivo. **(B) PROTEZIONE: in un anno con DD buy&hold > 10%, il + DD di TP01 deve restare sotto il 75% di quello** — storico 8/8 anni di sinistro superati + (protezione 1.8x-34.4x). Serve un criterio SEPARATO perche' il LOO misura il contributo hold-out + di TP01 negativo nel 99.1% delle ancore: **"non ha guadagnato" non e' evidenza di morte per uno + sleeve difensivo** — la sua morte e' non proteggere nel sinistro, e negli anni senza sinistro il + criterio NON si valuta. Se (A) scatta il book **non si spegne da solo**: si riapre + `weights_tilt_null` + deflated-Sharpe sui dati nuovi. Se (B) fallisce **2 anni di sinistro + consecutivi**, il peso di TP01 va rimesso in discussione. Stato 27/07: **Sharpe 36m +1.51, + protezione 8/8.** Diario `2026-07-26-edge-death.md`. +- 💰 **IL CAPITALE GIA' FERMO — la leva mai misurata, e la decisione di venue che ne dipende + (2026-07-27).** `r0727_lumpsum_split.py`, test `tests/test_lumpsum_split.py` (14), diario + `2026-07-27-lumpsum-venue-gates.md`. **Book/pesi/config INVARIATI.** Tutte le traiettorie del + 25-26/07 hanno `START = 600.0` **cablato**: il progetto ha misurato il *calendario* dei + versamenti (fattore 6) e mai un **versamento iniziale**, mentre su Revolut ci sono ~€10.000 (di + cui €6.043 in XEON a ~0% reale netto) contro i $600 che girano. Macchineria = generalizzazione + di `r0726_venue_risk.simulate`, **validata: con lump 0 riproduce IDENTICI i numeri del 26/07**. + (1) **Cosa compra** (p=0, 20a): **€10.000 fermi oggi e mai piu' nulla → traguardo 17.2a mediani, + P 62%, rendita €61.58/g** (il piano €250/m senza lump: 15.7a, P 95%, ma $66.818 versati contro + $10.900). Con entrambi: **13.3a, P 99.5%**. (2) ⚠️ **L'equivalenza si misura in versamento + mensile equivalente, NON in versamenti risparmiati**: la prima stesura diceva "€10k ≈ €7.414 + risparmiati = 0.7x" — numero giusto, **domanda sbagliata** (il valore e' arrivare prima, non + versare meno), e invita alla conclusione opposta. Onesto: **€10.000 oggi = +€154/mese per 13 + anni = €24.523, cioe' 2.45×.** (3) **Col rischio di venue dentro** (a €10k il conto e' $11.500 e + lo split diventa possibile — a quota IB **26%**, non il 25% preferito: sotto $3.000 la gamba + equity non esiste): lo split costa **1.9-2.6pp** di P(arrivare) e taglia **P(perso tutto) da + 18.4% a 3.5%** a p=1% (da 33.7% a 11.2% a p=2%). Il haircut dichiarato sulla gamba IB (0.8pp + taglia piccola + 0.1pp UCITS) sposta **0.1-0.2pp**: il costo della gamba equity non decide. + ⚠️ **P(perso tutto) sotto concentrazione NON dipende dal capitale** (con un conto solo "almeno + un fallimento" coincide con "perso tutto"): il lump non la peggiora, **moltiplica cio' che porta + via**. (4) ✅ **IL RISULTATO OPERATIVO — la protezione non e' bloccata dal PRIIPs.** GTAA01 oggi + non e' deployabile, quindi misurato anche lo **SPLIT-CASSA** (seconda gamba ferma): costa + **0.6-0.8pp** di P(arrivare) in piu' e **la protezione e' IDENTICA** (dipende da quanti conti + falliscono, non da cosa ci sta sopra). **Un rischio non-diversificabile si compra con un secondo + CONTO, non con un secondo sleeve.** (5) **Soglie:** split a quota raccomandata da **$12.000**, + forzando al 35% da **$8.571**. La riapertura della decisione venue e' a $20.000: **un lump da + €10k cade sotto quella soglia ma sopra la fattibilita' tecnica, ed e' un cambiamento del piano + = il caso in cui CLAUDE.md dice di riaprire PRIMA.** Materiale pronto; la decisione resta + dell'operatore. ⚠️ Cosa NON decide: quanto dei €6.043 sia vero fondo d'emergenza (un fondo + d'emergenza non e' capitale disponibile). +- ✅ **FEE WATCH + MONITOR HEALTH — due sorveglianze cablate (2026-07-27).** Nessuna tocca + l'esecuzione; entrambe in `cron_daily.sh`. (1) **`scripts/live/fee_watch.py`** (test 13): il + nuovo schema fee Deribit entra il **1° agosto** e l'annuncio non ha numeri → invece di un + promemoria, un sorvegliante. Legge il tier **BASE** da `public/get_instrument` (nessuna chiave; + a $600 ogni soglia VIP e' fuori portata), oggi **taker 5.00 / maker 0.00 / liquidazione 75-90 + bps**, applica la regola congelata (≤5bps nulla · >10bps rivedere il peso SKH01) e allerta su + **qualsiasi** cambiamento dei tre. `test_baseline_e_quella_dei_backtest` lega la soglia al + default `fee_rt=0.001` di `backtest_signals`: se divergono, il test lo dice. + (2) **`src/live/monitor_health.py` + `scripts/live/monitor_health.py`** (test 16): **tre gate + pre-registrati** (STATARB 27/09, XSR01 23/10, DVOLSPREAD 24/10) si decidono su serie forward di + cui **una sola** aveva una guardia d'integrita'. Un monitor fermo produce silenzio, e **il + silenzio in una serie di ritorni si legge come zero** (stesso schema di `fresh_5m` e del + feed-freeze 14/07). Misura **due guasti**, perche' uno solo non basta: **coda** (ultima barra + vecchia) e **buchi interni** (copertura fra prima e ultima barra) — ⚠️ *una serie bucata E + fresca passa qualunque controllo di freschezza, ed e' il guasto che falsifica un gate senza + farsi notare*. Cadenze dichiarate per monitor (prevday e' **orario**, combo segue il calendario + di **borsa**: sbagliarle = un falso allarme a settimana); soglia di copertura **0.80** riusata + dal veto DVOLSPREAD perche' i gate restino confrontabili. Stati **OK/FERMO/BUCATO/ASSENTE/NUOVO** + — "troppo giovane per un giudizio" non e' "sano". Controlli positivi obbligatori nei test. + Stato 27/07: 6/6 giudicati, tutti OK (combo 96% per una festivita' che `np.busday_count` non + conosce = limite dichiarato, conservativo). +- ✅ **BANDA GTAA01 AL 25% — VALIDATA, produzione NON toccata (2026-07-27).** + `r0727_gtaa_band_gate.py`, test `tests/test_gtaa_band_gate.py` (13). Chiude il debito dichiarato + il 27/07 ("tarata su questa finestra, non passata per `study_family_honest` ne' per un + deflated-Sharpe"). Griglia **30 celle** (5 cadenze × 6 bande) su **29.9 anni** del path di + produzione, annualizzazione **√252**. **(A) Selezione in-sample** (cella scelta sui soli dati + pre-2015, letta sul 2015+): proposta **4/30 in-sample, 5/30 hold-out** → il rango NON migliora + sull'hold-out, quindi **non e' selection-on-holdout**. La cella scelta al buio (cadenza + **giornaliera**, banda 25%) vale +0.04 di Sharpe ma costa **250 controlli manuali/anno** su un + conto senza API → non e' una configurazione, e' un'ipotesi. **(B) Deflated Sharpe 0.999 PASS** + (nullo 0.14). **(C)** ⚠️ **il modo di fallire NON e' il de-levering**: allargando la banda la + **vol non scende** (0.99-1.10 del riferimento), si rompe il **TRACKING** (corr 0.951 a 25% → + 0.907 a 40% → 0.859 a 60%) e lo Sharpe smette di migliorare insieme alla correlazione → nessuna + zona premia il congelamento. **Ma il 25% e' AL BORDO** (0.951 contro soglia 0.95), non al centro + di un plateau: citarlo cosi'. **Invarianza**: **25 ordini/anno a $3k/$10k/$50k** (banda 25%, + Sharpe 0.66/0.70/0.71) contro **65/92/134** (banda $50 fissa, 0.52/0.62/0.66) — ⚠️ 25 e non i + **21** citati il 27/07: stimatore diverso (griglia dei controlli su 30 anni USA vs cambi di + posizione sulla finestra UCITS di 3.2a); l'*invarianza*, che e' la proprieta' sotto esame, + regge in entrambi. **Impatto sul book: Sharpe FULL 2.221 → 2.221, maxDD 6.08% → 5.94% = zero** + (il book modella GTAA01 a $10k, dove la banda fissa gia' funziona) → **il valore e' tutto al + capitale piccolo** (0.52 → 0.66 a $3k), cioe' al deploy. **`REBAL_BAND_USD` NON cambiato**: non + sposta un numero pubblicato e lo sleeve non e' deployabile prima dei $20k → si applica **al + deploy**, con questo gate come giustificazione. **REGOLA: un parametro d'ESECUZIONE scelto + guardando il risultato e' selezione come ogni altra** e passa per gli stessi gate, anche quando + "non tocca l'allocazione". - **Soffitto strutturale BTC/ETH-direzionale ~1.3** superato SOLO espandendo a un meccanismo diverso: cross-sectional su universo Hyperliquid certificato (XS01) → portafoglio Sharpe ~1.55. - **Sweep "strategie alternative" (2026-06-20) — 104 ipotesi / 153 agenti / NIENTE di nuovo regge.** diff --git a/docs/diary/2026-07-27-lumpsum-venue-gates.md b/docs/diary/2026-07-27-lumpsum-venue-gates.md new file mode 100644 index 0000000..c9018a3 --- /dev/null +++ b/docs/diary/2026-07-27-lumpsum-venue-gates.md @@ -0,0 +1,277 @@ +# 2026-07-27 — Il capitale già fermo, due sorveglianze mancanti, e la banda GTAA validata + +Richiesta dell'operatore: *"proposte"*. Delle cinque proposte messe sul tavolo ne sono state +scelte quattro; questo diario le chiude tutte. **Book, pesi, cron-strategia, config: INVARIATI.** +Il cron guadagna due sorveglianze (non toccano l'esecuzione). + +Script: `r0727_lumpsum_split.py`, `r0727_gtaa_band_gate.py`, `scripts/live/fee_watch.py`, +`scripts/live/monitor_health.py` + `src/live/monitor_health.py`. +Test: `test_lumpsum_split.py` (14), `test_gtaa_band_gate.py` (13), `test_fee_watch.py` (13), +`test_monitor_health.py` (16) = **56 nuovi**. + +--- + +## 1. Il capitale già fermo — la leva mai misurata + +### Il buco + +Tutte le traiettorie pubblicate il 25 e 26/07 hanno `START = 600.0` **cablato**. Il progetto ha +misurato con grande cura il *calendario* dei versamenti (fattore 6 fra front e back loading) e +non ha mai misurato un **versamento iniziale** — mentre sul conto Revolut ci sono ~€10.000, di +cui €6.043 in XEON che rende ~0% reale netto. Il conto che gira ne ha 600. + +### Validazione prima dei numeri + +La macchineria è una generalizzazione di `r0726_venue_risk.simulate` (capitale iniziale e +deposito diventano parametri, tutto il resto identico incluso l'ordine di consumo dell'RNG). +Con lump=0 e €250/mese deve riprodurre **esattamente** i numeri del 26/07: + +``` +p=0.0% P(arrivare) 0.9487 vs 0.9487 · P(perso tutto) 0.0000 vs 0.0000 -> IDENTICO +p=1.0% P(arrivare) 0.8080 vs 0.8080 · P(perso tutto) 0.1842 vs 0.1842 -> IDENTICO +``` + +### Cosa compra un versamento iniziale (p=0, 100% Deribit, 20 anni) + +| lump | dep/mese | totale versato | anni al muro | P(entro 20a) | cap. mediano | rendita a 20a | +|---|---|---|---|---|---|---| +| €0 | €0 | $600 | **mai** | 0.0% | $16.354 | €3.21/g | +| €2.000 | €0 | $2.780 | 19.0a | 2.5% | $75.773 | €14.89/g | +| €5.000 | €0 | $6.050 | 18.5a | 22.9% | $164.901 | €32.39/g | +| **€10.000** | **€0** | **$11.500** | **17.2a** | **61.7%** | **$313.448** | **€61.58/g** | +| €0 | €250 | $66.818 | 15.7a | 94.9% | $544.645 | €106.99/g | +| €2.000 | €250 | $68.998 | 15.1a | 96.8% | $605.454 | €118.94/g | +| €5.000 | €250 | $72.268 | 14.4a | 98.2% | $695.475 | €136.62/g | +| €10.000 | €250 | $77.718 | **13.3a** | 99.5% | $844.022 | €165.80/g | + +La riga che sorprende è la quarta: **€10.000 fermi oggi, senza versare mai più nulla,** portano +al capitale-rendita in 17.2 anni mediani con P=62%. Il piano da €250/mese senza lump ci arriva in +15.7 anni con P=95%: più affidabile, ma costa $66.818 di versamenti contro $10.900 una volta sola. + +### L'equivalenza — e la metrica che sbagliavo + +Prima stesura: *"€10.000 subito risparmiano 30 mesi = €7.414 di versamenti futuri, cioè 0.7x"*. +Numero giusto, domanda sbagliata, e la conclusione che invita è l'opposto di quella corretta: il +valore di un lump-sum non è versare *meno*, è arrivare *prima*. La formulazione onesta è a quale +versamento mensile permanente equivale: + +| lump | traguardo | equivale a versare | cioè | +|---|---|---|---| +| €2.000 | 15.1a (da 15.7a) | €281/mese | +€31/mese per 15 anni (€5.666) | +| €5.000 | 14.4a | €327/mese | +€77/mese per 14 anni (€13.241) | +| **€10.000** | **13.3a** | **€404/mese** | **+€154/mese per 13 anni (€24.523)** | + +**€10.000 oggi valgono €24.500 di versamenti futuri: 2.45×.** È lo stesso meccanismo del +front-loading misurato il 26/07, portato al suo estremo. + +### E il rischio di venue, che è la metà scomoda della domanda + +Il 26/07 aveva già osservato che il front-loading mette più capitale sull'exchange *prima*. +Un lump-sum è il front-loading estremo, quindi va misurato col rischio dentro. A €10.000 il conto +sarebbe $11.500 e lo split diventa **possibile** — a quota IB **26%**, non il 25% preferito: sotto +$3.000 sulla gamba equity non c'è uno sleeve, c'è cash su un secondo conto. + +| p annua | P(arrivare) CONC | SPLIT | SPLIT+costi | **P(perso tutto) CONC** | **SPLIT** | +|---|---|---|---|---|---| +| 0.0% | 99.5% | 98.2% | 98.0% | 0.0% | 0.0% | +| 0.5% | 93.2% | 91.4% | 91.2% | **9.7%** | **0.9%** | +| 1.0% | 87.1% | 85.2% | 85.0% | **18.4%** | **3.5%** | +| 2.0% | 76.2% | 73.2% | 73.1% | **33.7%** | **11.2%** | +| 5.0% | 50.5% | 47.9% | 47.8% | **64.1%** | **40.7%** | + +Lo split costa **1.9-2.6pp** di P(arrivare) e taglia P(perso tutto) da 18.4% a 3.5% (a p=1%). +La colonna "SPLIT+costi" applica alla gamba IB il haircut dichiarato (0.8pp/anno di costi a +taglia piccola + 0.1pp di drag UCITS): **cambia lo 0.1-0.2pp**, cioè niente. Il costo della +gamba equity non è ciò che decide. + +⚠️ **P(perso tutto) sotto CONC non dipende dal lump** (9.7/18.4/33.7/64.1% a qualunque taglia): +con un conto solo *"almeno un fallimento"* **coincide** con *"perso tutto"*, e quella probabilità +è una proprietà del tempo di esposizione. Il lump non la peggiora — moltiplica ciò che porta via. + +### La sezione che serviva davvero: e se la seconda gamba fosse solo liquidità? + +GTAA01 **oggi non è deployabile** (blocco PRIIPs; la via UCITS su Degiro è verificata ma in +preparazione). Quindi lo split disponibile *subito* è Deribit + un conto fermo. + +| p annua | P(arrivare) CONC | SPLIT-GTAA | **SPLIT-CASSA** | P(perso tutto) CONC | SPLIT | +|---|---|---|---|---|---| +| 0.5% | 93.2% | 91.4% | **90.7%** | 9.7% | 0.9% | +| 1.0% | 87.1% | 85.2% | **84.5%** | 18.4% | 3.5% | +| 2.0% | 76.2% | 73.2% | **72.6%** | 33.7% | 11.2% | + +**Tenere ferma la seconda gamba invece di investirla costa 0.6-0.8pp di P(arrivare) e non toglie +NULLA alla protezione** — che è identica, perché dipende da *quanti conti* falliscono, non da cosa +ci sta sopra. **La protezione dalla rovina non è bloccata dal PRIIPs, non aspetta la validazione +della banda, non richiede che GTAA01 esista: richiede un secondo conto.** + +### Soglie + +* split a quota **raccomandata** (25%): da **$12.000** +* split forzando la quota fino al 35%: da **$8.571** +* con un lump da €10.000 il conto sarebbe **$11.500** → possibile, ma a quota 26%. + +La riapertura della decisione di venue è fissata a $20.000. Un lump da €10k porta il conto sotto +quella soglia ma sopra la fattibilità tecnica — ed è **un cambiamento del piano**, il caso in cui +CLAUDE.md dice esplicitamente di riaprire prima. Questa tabella è il materiale per farlo; la +decisione resta dell'operatore, come il 26/07. + +⚠️ **Cosa questo filone NON decide:** quanto dei €6.043 in XEON sia un vero fondo d'emergenza. +Un fondo d'emergenza non è capitale disponibile, e la tabella qui sopra misura *cosa compra ogni +euro che entra*, non *quali euro debbano entrare*. + +--- + +## 2. Fee Deribit — un sorvegliante invece di un promemoria + +Il nuovo schema entra in vigore il **1° agosto** e l'annuncio non contiene numeri. La regola era +già decisa in anticipo (≤5bps/lato → nulla; >10bps → rivedere il peso di SKH01, 4× più +fee-sensibile di TP01). Mancava solo il modo di accorgersene. + +`public/get_instrument` espone il tier **base** — che è quello che paga un conto da $600, dove +ogni soglia VIP è fuori portata — senza chiavi: + +``` +strumento taker maker liquidaz. +BTC-PERPETUAL 5.00 0.00 75.00 +ETH-PERPETUAL 5.00 0.00 90.00 +verdetto: [OK] taker 5.0bps <= 5: non si tocca nulla +``` + +`scripts/live/fee_watch.py` (in `cron_daily.sh`) confronta con l'ultima lettura, allerta su +**qualsiasi** cambiamento — taker, maker e liquidation fee, che l'annuncio tocca tutti e tre — e +applica la regola congelata. Cross-check best-effort sulla fee **realmente pagata** dai trade del +conto, con `{}` che significa *non misurata* e non *zero*. + +`test_baseline_e_quella_dei_backtest` lega `BASELINE_TAKER_BPS` al default `fee_rt=0.001` di +`backtest_signals`: se un giorno le due divergessero, il confronto smetterebbe di avere senso. + +--- + +## 3. I forward-monitor non erano sorvegliati + +**Tre gate pre-registrati** — STATARB **27/09**, XSR01 **23/10**, DVOLSPREAD kill **24/10** — si +decideranno leggendo serie forward. Di quelle serie **una sola** aveva una guardia d'integrità +(`paper_dvolspread`, contabilità a 3 stati + veto sotto l'80%). Le altre nessuna: `paper_xsr`, +`paper_statarb`, `paper_prevday`, `paper_portfolio`, `paper_combo` → 0 controlli. + +Un monitor fermo produce silenzio, e **il silenzio in una serie di ritorni si legge come zero**, +cioè come una misura. È lo stesso schema già pagato con `fresh_5m` (26/07) e col feed-freeze del +14/07. + +`src/live/monitor_health.py` misura **due guasti diversi**, perché una sola misura non basta: + +* **coda** — l'ultima barra è vecchia (il monitor si è fermato adesso); +* **buchi interni** — la serie è più corta del proprio arco temporale. *Una guardia di sola + freschezza lascerebbe passare un monitor che ha perso il 30% delle barre di mezzo e ha scritto + stanotte* — ed è esattamente il guasto che falsifica un gate senza farsi notare. + +Cadenze dichiarate per monitor, perché sbagliarle significa un falso allarme a settimana: +`paper_prevday` registra a barra **oraria** (864 barre in 36 giorni); `paper_combo` vive sul +calendario di **borsa** (venerdì è l'ultima barra fino a lunedì). Soglia di copertura **0.80**, +riusata di proposito dal veto DVOLSPREAD: una soglia diversa per monitor renderebbe i gate non +confrontabili. + +Stati: `OK` / `FERMO` / `BUCATO` / `ASSENTE` / **`NUOVO`** — quest'ultimo per i monitor troppo +giovani per un giudizio (XSR01 e DVOLSPREAD hanno 2 barre): non allerta, ma **non risulta sano**. + +Stato al 27/07: 6/6 monitor giudicati, tutti OK; `paper_combo` al 96% per il 3 luglio — festività +di borsa che `np.busday_count` non conosce. È un **limite dichiarato** e conservativo (segnala di +più, non di meno). + +I test includono i **controlli positivi** obbligatori (un rilevatore che non ha mai segnalato +nulla è indistinguibile da uno rotto): monitor fermo → FERMO, serie bucata e fresca → BUCATO, +stato assente → ASSENTE. + +--- + +## 4. La banda GTAA01 al 25% — validata + +La proposta del 27/07 (banda = 25% della gamba invece di $50 assoluti) era dichiarata +esplicitamente non validata. Griglia **30 celle** (5 cadenze × 6 bande) su **29.9 anni** di path +di produzione, annualizzazione **√252** (una serie su giorni di borsa passata a 365 esce con lo +Sharpe ×1.20: l'errore del 25/07). + +**(A) Selezione in-sample** (cella scelta sui soli dati pre-2015, letta sul 2015+, l'hold-out +equity documentato di GTAA01): + +``` +cella scelta al buio : cadenza 1, banda 25% -> IS 0.66 · OOS 0.90 · FULL 0.74 +cella proposta 27/07 : cadenza 5, banda 25% -> IS 0.62 · OOS 0.86 · FULL 0.70 +rango della proposta : 4/30 in-sample · 5/30 sull'hold-out +``` + +Il rango **non migliora** passando all'hold-out → non è selezione-sull'hold-out (la firma sarebbe +il contrario). La cella scelta al buio preferisce il controllo **giornaliero**, che vale +0.04 di +Sharpe e costa **42 ordini/anno con 250 controlli manuali**: il conto d'esecuzione non ha API +(misurato il 27/07), quindi non è una configurazione, è un'ipotesi. La cadenza settimanale costa +−0.04 e compra l'eseguibilità a mano. + +**(B) Deflated Sharpe** sulle 30 celle, dpy=252: **0.999 PASS** (massimo atteso sotto il nullo +0.14), sia per la proposta sia per la cella scelta al buio. + +**(C) Null de-levering** — e qui il modo di fallire **non è quello atteso**: + +| banda | corr col riferimento | vol/rif | Sh FULL | | +|---|---|---|---|---| +| 0% | 0.983 | 1.03 | 0.59 | OK | +| 10% | 0.980 | 1.02 | 0.64 | OK | +| **25%** | **0.951** | **0.99** | **0.70** | OK (al bordo) | +| 40% | 0.907 | 1.04 | 0.67 | fuori traccia | +| 60% | 0.859 | 1.10 | 0.59 | fuori traccia | + +Allargando la banda **la volatilità non scende**: il null de-levering classico non morde. Ciò che +si rompe è il **tracking** — lo sleeve tiene posizioni vecchie e smette di somigliare a sé stesso. +E i due segnali concordano: oltre il 25% la correlazione cala *e* lo Sharpe smette di migliorare, +quindi non esiste una zona in cui il numero premia il congelamento. ⚠️ Ma **il 25% è al bordo** +(corr 0.951 contro una soglia di 0.95), non al centro di un plateau: va citato così. + +**Invarianza al capitale** — la ragione per cui la proposta esiste: + +| capitale | banda 25% | ord/anno | Sharpe | | banda $50 fissa | ord/anno | Sharpe | +|---|---|---|---|---|---|---|---| +| $3.000 | $125 | 25 | **0.66** | | $50 | 65 | 0.52 | +| $10.000 | $417 | 25 | **0.70** | | $50 | 92 | 0.62 | +| $50.000 | $2.083 | 25 | **0.71** | | $50 | 134 | 0.66 | + +⚠️ **25 ordini/anno, non i 21 citati il 27/07**: stimatore diverso (qui il gate contato sulla +griglia dei controlli, 30 anni, veicoli USA; lì i cambi di posizione sulla finestra UCITS di 3.2 +anni). L'*invarianza* — che è la proprietà sotto esame — regge in entrambi. + +**Impatto sul book: Sharpe FULL 2.221 → 2.221, maxDD 6.08% → 5.94%.** Zero. Il book modella +GTAA01 a $10.000, dove la banda fissa già funziona: **il valore della proposta è tutto al capitale +piccolo** (0.52 → 0.66 a $3k), cioè al deploy. + +**Verdetto: la proposta passa i tre gate.** Produzione **NON toccata**: cambiare +`REBAL_BAND_USD` non sposta un solo numero pubblicato, e lo sleeve non è deployabile prima dei +$20k. Il cambio si applica **al deploy**, insieme alla scelta del broker, con questo diario come +giustificazione. + +--- + +## 5. Il debito chiuso di straforo + +`edge_watch.py` — i criteri di kill del book **live** — è in cron dal 26/07 e **non era in +CLAUDE.md** (zero occorrenze). Il criterio di morte di ciò che gira con soldi veri non stava nella +memoria operativa del progetto: la sessione successiva avrebbe ragionato come se non esistesse. +Aggiunto. + +--- + +## 6. Regole + +1. **Un parametro d'esecuzione scelto guardando il risultato è selezione come ogni altra**, e va + passato per gli stessi gate — anche quando "è solo esecuzione" e non tocca l'allocazione. +2. **Quando si misura l'effetto di un vincolo, si misura anche il vincolo del vincolo.** Lo split + di venue a $11.500 non è "25% su IB": è 26%, perché sotto $3.000 la gamba equity non esiste. + È il capitale a scegliere la quota, non la preferenza. +3. **Il valore di un versamento iniziale non si misura in versamenti risparmiati** (domanda + sbagliata, rapporto 0.7×) ma in **versamento mensile equivalente** (2.45×). La prima + formulazione invita alla conclusione opposta. +4. **Una guardia di freschezza non è una guardia d'integrità.** Il guasto che conta — la serie + bucata e fresca — passa la prima e fallisce la seconda. +5. **Il verdetto pre-scritto va confrontato coi numeri prima di stamparlo.** Il testo di (C) + diceva "il numero migliora perché esce dal mercato": la vol misurata *non scendeva*, quindi era + falso. Il fallimento vero era un altro (tracking), e dirlo giusto vale più che avere ragione. +6. **Un rischio non-diversificabile si compra con un secondo conto, non con un secondo sleeve.** + La protezione dalla rovina è identica con GTAA01 o con liquidità: dipende da quanti conti + falliscono, non da cosa ci sta sopra. diff --git a/scripts/cron_daily.sh b/scripts/cron_daily.sh index 627f0ed..730cefb 100755 --- a/scripts/cron_daily.sh +++ b/scripts/cron_daily.sh @@ -28,5 +28,12 @@ mkdir -p logs # Sorveglianza dell'EDGE del book live (criteri di kill pre-registrati, # tarati in scripts/research/r0726_edge_death.py). Riporta e allerta, non spegne nulla. uv run python scripts/live/edge_watch.py --quiet || true + # 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 + # 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. + uv run python scripts/live/monitor_health.py --quiet || true echo "===== done $(date -u '+%H:%M:%SZ') =====" } >> logs/cron_daily.log 2>&1 diff --git a/scripts/live/fee_watch.py b/scripts/live/fee_watch.py new file mode 100644 index 0000000..bdade77 --- /dev/null +++ b/scripts/live/fee_watch.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python +"""fee_watch.py — sorveglia lo schema fee di Deribit e applica la regola DECISA IN ANTICIPO. + +PERCHE' ESISTE. Il 2026-07-26 Deribit ha annunciato un nuovo schema fee dal **2026-08-01** senza +pubblicare i numeri (la tabella nell'articolo Insights e' un'immagine, fonte secondaria). La +risposta del progetto e' stata misurare la CURVA invece di aspettare il numero +(`scripts/research/r0726_fee_sensitivity.py`): + + bps/lato %RT TP01 Sh SKH01 Sh BOOK Sh BOOK CAGR + 0 0.00% 1.322 1.567 1.849 21.69% + 3 0.06% 1.303 1.495 1.799 20.99% + 5 0.10% 1.290 1.446 1.766 20.53% <- assunzione di TUTTI i backtest + 10 0.20% 1.258 1.324 1.682 19.39% + 15 0.30% 1.226 1.200 1.597 18.26% + + sensibilita' marginale del BOOK: -0.017 Sharpe/bps, -0.23% CAGR/bps + SKH01 e' ~4x piu' sensibile di TP01 (-0.69% vs -0.09% CAGR/bps): round-trip discreti contro + posizione continua vol-targeted -> se il taker sale, il primo parametro da rivedere e' il + PESO 75/25, non altro. + +REGOLA CONGELATA (decisa PRIMA di vedere il numero, per non deciderla col numero davanti): + * taker <= 5 bps/lato -> non si tocca nulla (i backtest restano conservativi o esatti) + * 5 < taker <= 10 bps -> si riporta il costo, nessuna azione dovuta + * taker > 10 bps/lato -> si rivede il PESO di SKH01 (`r0724_skh_live_weight` / + `r0726_reeval_live_weight`), passando da `weights_tilt_null` + +COSA LEGGE. `public/get_instrument` (endpoint PUBBLICO, nessuna chiave): `taker_commission` e +`maker_commission` sono il tier BASE dell'istrumento, cioe' quello che paga questo conto — a $600 +il volume 30g e' trascurabile e ogni soglia VIP e' fuori portata. Sorveglia anche +`max_liquidation_commission`, che l'annuncio dice diventera' 1% su tutti i prodotti. +Cross-check autorevole (best-effort): la fee REALMENTE pagata, dai trade del conto. + +⚠️ Il tier VIP di un conto ad alto volume NON e' leggibile qui. La guardia +`test_fee_watch_assume_tier_base` congela questa assunzione: se il conto smettesse di essere al +tier base, questo script misurerebbe la cosa sbagliata. + + uv run python scripts/live/fee_watch.py # report + uv run python scripts/live/fee_watch.py --quiet # stampa/allerta solo se qualcosa cambia +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import requests + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from src.live.notifier import notify # noqa: E402 + +STATE = ROOT / "data" / "fee_watch" / "state.json" +INSTRUMENTS = ("BTC-PERPETUAL", "ETH-PERPETUAL") +API = "https://www.deribit.com/api/v2/public/get_instrument" + +# --- riferimenti CONGELATI (cambiarli invalida la curva di r0726_fee_sensitivity.py) --- +BASELINE_TAKER_BPS = 5.0 # 0.10% RT: l'assunzione di OGNI backtest del progetto +BASELINE_MAKER_BPS = 0.0 +SOGLIA_OK = 5.0 # <= -> nessuna azione +SOGLIA_AZIONE = 10.0 # > -> rivedere il peso di SKH01 +D_SHARPE_PER_BPS = -0.017 # sensibilita' marginale del BOOK, misurata +D_CAGR_PER_BPS = -0.0023 + + +def fetch_fees(instrument: str) -> dict: + """Commissioni correnti dell'istrumento (frazione di nozionale -> bps).""" + r = requests.get(API, params={"instrument_name": instrument}, timeout=15) + r.raise_for_status() + res = r.json()["result"] + return dict( + instrument=instrument, + taker_bps=float(res["taker_commission"]) * 1e4, + maker_bps=float(res["maker_commission"]) * 1e4, + liq_bps=float(res.get("max_liquidation_commission", 0.0)) * 1e4, + ) + + +def verdict(taker_bps: float) -> tuple[str, str]: + """Applica la regola congelata. Ritorna (livello, motivazione).""" + if taker_bps <= SOGLIA_OK: + return "OK", f"taker {taker_bps:.1f}bps <= {SOGLIA_OK:.0f}: non si tocca nulla" + if taker_bps <= SOGLIA_AZIONE: + d = (taker_bps - BASELINE_TAKER_BPS) + return "NOTA", (f"taker {taker_bps:.1f}bps: costo ~{d * D_SHARPE_PER_BPS:+.3f} Sharpe / " + f"{d * D_CAGR_PER_BPS:+.2%} CAGR di book, nessuna azione dovuta") + return "AZIONE", (f"taker {taker_bps:.1f}bps > {SOGLIA_AZIONE:.0f}: rivedere il PESO di SKH01 " + f"(4x piu' fee-sensibile di TP01) via weights_tilt_null") + + +def realized_fee_bps(limit: int = 20) -> dict: + """Fee REALMENTE pagata sui trade del conto, in bps di nozionale. Fonte autorevole, ma + disponibile solo se il book ha tradato di recente: {} non e' 'zero', e' 'non misurata'.""" + try: + from src.live.deribit import DeribitRead + d = DeribitRead() + except Exception: + return {} + out: dict[str, float] = {} + for ins in INSTRUMENTS: + tot_fee_usd = tot_notional = 0.0 + try: + trades = d.trade_history(ins, limit=limit) + except Exception: + continue + for t in trades: + try: + notional = abs(float(t.get("amount") or 0.0)) # perp: nozionale in USD + price = float(t.get("price") or 0.0) + fee = abs(float(t.get("fee") or 0.0)) # in valuta di settlement + if notional <= 0 or price <= 0: + continue + tot_fee_usd += fee * price + tot_notional += notional + except (TypeError, ValueError): + continue + if tot_notional > 0: + out[ins] = tot_fee_usd / tot_notional * 1e4 + return out + + +def load_state() -> dict: + if STATE.exists(): + try: + return json.loads(STATE.read_text()) + except Exception: + return {} + return {} + + +def save_state(cur: dict) -> None: + STATE.parent.mkdir(parents=True, exist_ok=True) + STATE.write_text(json.dumps(cur, indent=2, sort_keys=True)) + + +def diff_vs(prev: dict, cur: dict) -> list[str]: + """Cambiamenti rispetto all'ultima lettura. Prima lettura = nessun cambiamento (non e' + un evento: e' l'inizializzazione).""" + if not prev: + return [] + ch = [] + for ins, c in cur.items(): + p = prev.get(ins) + if not p: + ch.append(f"{ins}: strumento nuovo nella sorveglianza") + continue + for k, lab in (("taker_bps", "taker"), ("maker_bps", "maker"), ("liq_bps", "liquidazione")): + if abs(float(p.get(k, -1)) - float(c[k])) > 1e-9: + ch.append(f"{ins} {lab}: {float(p.get(k, float('nan'))):.2f} -> {c[k]:.2f} bps") + return ch + + +def run() -> dict: + cur = {ins: fetch_fees(ins) for ins in INSTRUMENTS} + prev = load_state() + changes = diff_vs(prev, cur) + worst = max(c["taker_bps"] for c in cur.values()) + lvl, why = verdict(worst) + return dict(current=cur, changes=changes, worst_taker_bps=worst, level=lvl, reason=why, + first_read=not prev) + + +def main() -> int: + r = run() + quiet = "--quiet" in sys.argv + interessante = bool(r["changes"]) or r["level"] != "OK" + + if not quiet or interessante: + print("=" * 86) + print(" FEE WATCH — schema fee Deribit vs la regola decisa in anticipo") + print("=" * 86) + print(f"\n {'strumento':<16}{'taker':>10}{'maker':>10}{'liquidaz.':>12}") + for ins, c in r["current"].items(): + print(f" {ins:<16}{c['taker_bps']:>9.2f}{c['maker_bps']:>10.2f}{c['liq_bps']:>11.2f}") + print(f"\n baseline dei backtest: taker {BASELINE_TAKER_BPS:.1f} / " + f"maker {BASELINE_MAKER_BPS:.1f} bps (0.10% RT)") + print(f" verdetto: [{r['level']}] {r['reason']}") + if r["first_read"]: + print(" (prima lettura: stato inizializzato, nessun confronto possibile)") + for c in r["changes"]: + print(f" CAMBIO: {c}") + real = realized_fee_bps() + if real: + print("\n cross-check sui trade REALI del conto (fonte autorevole):") + for ins, bps in real.items(): + print(f" {ins:<16}{bps:>9.2f} bps/lato effettivi") + else: + print("\n cross-check sui trade reali: NON MISURATO (nessun trade recente leggibile)") + + if r["changes"] or r["level"] == "AZIONE": + notify("💸 FEE WATCH — schema fee Deribit", { + "verdetto": f"[{r['level']}] {r['reason']}", + **{f"cambio {i+1}": c for i, c in enumerate(r["changes"])}, + }) + save_state(r["current"]) + return 2 if r["level"] == "AZIONE" else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/live/monitor_health.py b/scripts/live/monitor_health.py new file mode 100644 index 0000000..389cdf3 --- /dev/null +++ b/scripts/live/monitor_health.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python +"""monitor_health.py — i forward-monitor stanno registrando? Riporta e allerta, non tocca nulla. + +Tre gate pre-registrati (STATARB 27/09, XSR01 23/10, DVOLSPREAD 24/10) si decideranno leggendo +serie forward che, tranne una, **nessuno sorvegliava**. Un monitor fermo produce silenzio, e il +silenzio in una serie di ritorni si legge come "zero", cioe' come una misura. Non lo e'. + +Vedi `src/live/monitor_health.py` per le due misure (coda + buchi interni), le cadenze per +monitor e la soglia di copertura 0.80 (la stessa del veto d'integrita' di DVOLSPREAD). + + uv run python scripts/live/monitor_health.py # report + uv run python scripts/live/monitor_health.py --quiet # stampa/allerta solo se scatta +""" +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from src.live.monitor_health import MIN_COVERAGE, alerts_from, check_all # noqa: E402 +from src.live.notifier import notify # noqa: E402 + + +def main() -> int: + rows = check_all(ROOT) + alerts = alerts_from(rows) + + if "--quiet" not in sys.argv or alerts: + print("=" * 92) + print(" MONITOR HEALTH — i forward-monitor stanno ancora registrando?") + print("=" * 92) + print(f"\n {'monitor':<20}{'stato':>10}{'barre':>8}{'attese':>8}" + f"{'copert.':>10}{'eta ultima':>13} gate che ne dipende") + for r in rows: + cov = f"{r['coverage']:.0%}" if r["coverage"] is not None else "-" + exp = f"{r['expected']}" if r["expected"] is not None else "-" + age = f"{r['age_h']:.0f}h" if r["age_h"] is not None else "-" + gate = (r["gate"] or "").split(" — ")[0] + print(f" {r['name']:<20}{r['status']:>10}{r['n_bars']:>8}{exp:>8}" + f"{cov:>10}{age:>13} {gate}") + print(f"\n soglia di copertura {MIN_COVERAGE:.0%} (= veto d'integrita' di DVOLSPREAD) · " + f"'NUOVO' = troppo giovane per un giudizio, non 'OK'") + for r in rows: + if r["why"] and r["status"] != "OK": + print(f" {r['name']}: {r['why']}") + + if alerts: + notify("🩺 MONITOR HEALTH — un forward-monitor non sta registrando", + {f"alert {i+1}": a for i, a in enumerate(alerts)}) + for a in alerts: + print(f" ALERT: {a}") + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/research/r0727_gtaa_band_gate.py b/scripts/research/r0727_gtaa_band_gate.py new file mode 100644 index 0000000..41328b0 --- /dev/null +++ b/scripts/research/r0727_gtaa_band_gate.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python +"""r0727_gtaa_band_gate.py — la banda al 25% della gamba passa i gate, o e' una cella scelta bene? + +CONTESTO. Il 27/07 e' stata proposta una configurazione d'esecuzione nuova per GTAA01 — banda = +**25% della gamba** invece dei $50 assoluti, cadenza settimanale invariata — perche' la banda in +dollari assoluti degenera al variare del capitale (a $3.000 una banda da $400 e' l'80% della +gamba: 3 ordini l'anno e a mercato il 45% del tempo invece del 66%). La proposta e' stata +dichiarata esplicitamente **non validata**: "tarata su questa finestra, non passata per +`study_family_honest` ne' per un deflated-Sharpe". + +Questo script fa quella validazione. NON e' un gate sui pesi (quello resta `weights_tilt_null`): +la banda e' un parametro di **esecuzione**, non di allocazione. Ma un parametro d'esecuzione +scelto guardando il risultato e' selezione come qualunque altra, e va trattato come tale. + +TRE GATE, e uno e' specifico di questo tipo di parametro: + (A) **SELEZIONE IN-SAMPLE** — la cella si sceglie con i soli dati PRE-2015 e si legge sul + 2015+, l'hold-out equity documentato di GTAA01 (validazione 2026-06-22) e indipendente + dall'hold-out crypto. Se la cella scelta al buio non e' quella proposta, la proposta e' + selection-on-holdout. + (B) **DEFLATED SHARPE** su TUTTA la griglia cercata (Bailey & Lopez de Prado, PASS >= 0.95), + con `dpy=252`: e' una serie su giorni di BORSA, e passarla a 365 gonfia lo Sharpe di + ×1.20 (l'errore di annualizzazione del 25/07). + (C) **NULL DE-LEVERING**, che qui indossa la veste "meno costi" invece di "meno drawdown": + una banda larga puo' migliorare i numeri semplicemente smettendo di seguire il target. + Il controllo non e' lo Sharpe ma quanto la serie bandata **assomiglia ancora** a quella + non bandata (correlazione e rapporto di volatilita' contro il riferimento banda=0, + cadenza=1). E' la 4a occorrenza dello stesso null in questo progetto. + +⚠️ Il controllo (C) qui e' in coordinate diverse da quello del 27/07 (che misurava la quota di +tempo a mercato sulla finestra UCITS corta, 66% vs 45%): la API pubblica di `gtaa.py` non espone +le posizioni tenute, e la correlazione col riferimento misura la stessa cosa — se lo sleeve si +congela, smette di somigliare a se' stesso. Le due letture sono complementari, non alternative. + + uv run python scripts/research/r0727_gtaa_band_gate.py +""" +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" / "alt")) + +from altlib import deflated_sharpe # noqa: E402 +from src.portfolio.gtaa import ( # noqa: E402 + EQ_UNIVERSE, GTAA_MIN_CAPITAL, REBAL_BAND_USD, REBAL_EVERY, gtaa_returns, +) + +DPY = 252.0 # giorni di BORSA: annualizzare a 365 gonfia Sharpe ×1.20 +HOLDOUT = "2015-01-01" # hold-out equity documentato di GTAA01 (validazione 22/06) +CAPITALS = (3_000.0, 10_000.0, 50_000.0) +CAP_REF = 10_000.0 +EVERY_GRID = (1, 5, 10, 21, 42) +FRAC_GRID = (0.0, 0.05, 0.10, 0.25, 0.40, 0.60) +PROPOSTA = (5, 0.25) # cadenza settimanale, banda = 25% della gamba +CANONICO = (REBAL_EVERY, REBAL_BAND_USD) +MIN_CORR = 0.95 # sotto: la banda non filtra piu', congela + + +def met(s: pd.Series) -> dict: + v = s.dropna().values.astype(float) + if len(v) < 30 or v.std() == 0: + return dict(sharpe=float("nan"), cagr=float("nan"), maxdd=float("nan"), vol=float("nan")) + eq = (1.0 + v).cumprod() + return dict(sharpe=float(v.mean() / v.std() * np.sqrt(DPY)), + cagr=float(eq[-1] ** (DPY / len(v)) - 1.0), + maxdd=float((1.0 - eq / np.maximum.accumulate(eq)).max()), + vol=float(v.std() * np.sqrt(DPY))) + + +def run_cell(every: int, frac: float, capital: float) -> pd.Series: + """La banda e' una FRAZIONE della gamba: cosi' il parametro non dipende dal capitale.""" + band = frac * capital / len(EQ_UNIVERSE) + return gtaa_returns(capital=capital, band_usd=band, every=every) + + +def orders_per_year(s: pd.Series, capital: float, every: int, frac: float) -> float: + """Ordini l'anno stimati: il conteggio esatto vive dentro `_gated_returns`, qui si usa il + piano pubblico su una griglia di controlli — proporzionale e sufficiente al confronto.""" + from src.portfolio.gtaa import _close, _exposure + band = frac * capital / len(EQ_UNIVERSE) + n = 0 + for sym in EQ_UNIVERSE: + e = _exposure(_close(sym)).dropna() + held = 0.0 + for i, tgt in enumerate(e.values): + if i % every: + continue + if abs(tgt - held) * (capital / len(EQ_UNIVERSE)) >= max(band, 1.0): + held = float(tgt) + n += 1 + span = max((s.index[-1] - s.index[0]).days / 365.25, 1e-9) + return n / span + + +def main() -> None: + print("=" * 104) + print(" r0727 — GATE ONESTO SULLA BANDA GTAA01 (proposta: 25% della gamba, settimanale)") + print("=" * 104) + ref = run_cell(1, 0.0, CAP_REF) # riferimento: nessuna banda, controllo giornaliero + print(f" storia {ref.index[0].date()} -> {ref.index[-1].date()} " + f"({len(ref) / DPY:.1f} anni di borsa) · annualizzazione √{DPY:.0f} · " + f"capitale di riferimento ${CAP_REF:,.0f}") + print(f" canonico in produzione: ogni {CANONICO[0]} barre, banda ${CANONICO[1]:.0f} " + f"(= {CANONICO[1] / (CAP_REF / 6):.0%} della gamba a ${CAP_REF:,.0f})") + + # ---------------------------------------------------------------- griglia + cells: dict[tuple[int, float], pd.Series] = {} + rows = [] + for every in EVERY_GRID: + for frac in FRAC_GRID: + s = run_cell(every, frac, CAP_REF) + cells[(every, frac)] = s + full = met(s) + ins = met(s.loc[: HOLDOUT]) + oos = met(s.loc[HOLDOUT:]) + j = pd.concat([s, ref], axis=1, join="inner").dropna() + corr = float(j.iloc[:, 0].corr(j.iloc[:, 1])) + rows.append(dict(every=every, frac=frac, sh_full=full["sharpe"], sh_in=ins["sharpe"], + sh_oos=oos["sharpe"], cagr=full["cagr"], vol=full["vol"], + corr=corr, volratio=full["vol"] / met(ref)["vol"])) + G = pd.DataFrame(rows) + + print("\n" + "=" * 104) + print(" GRIGLIA — Sharpe in-sample (pre-2015) / hold-out (2015+) / full, e somiglianza al riferimento") + print("=" * 104) + print(f"\n {'cadenza':>8}{'banda%':>8}{'Sh IS':>8}{'Sh OOS':>9}{'Sh FULL':>9}" + f"{'CAGR':>8}{'vol':>7}{'corr rif':>10}{'vol/rif':>9}") + for _, r in G.iterrows(): + star = " <- proposta" if (r['every'], r['frac']) == PROPOSTA else "" + print(f" {int(r['every']):>8}{r['frac']:>8.0%}{r['sh_in']:>8.2f}{r['sh_oos']:>9.2f}{r['sh_full']:>9.2f}" + f"{r['cagr']:>8.2%}{r['vol']:>7.1%}{r['corr']:>10.3f}{r['volratio']:>9.2f}{star}") + + # ---------------------------------------------------------------- (A) selezione in-sample + print("\n" + "=" * 104) + print(" (A) SELEZIONE IN-SAMPLE — quale cella si sceglie guardando SOLO il pre-2015?") + print("=" * 104) + best = G.sort_values("sh_in", ascending=False).iloc[0] + prop = G[(G["every"] == PROPOSTA[0]) & (G["frac"] == PROPOSTA[1])].iloc[0] + print(f"\n cella scelta al buio : cadenza {int(best['every'])}, banda {best['frac']:.0%} " + f"-> IS {best['sh_in']:.2f} · OOS {best['sh_oos']:.2f} · FULL {best['sh_full']:.2f}") + print(f" cella proposta 27/07 : cadenza {PROPOSTA[0]}, banda {PROPOSTA[1]:.0%} " + f"-> IS {prop['sh_in']:.2f} · OOS {prop['sh_oos']:.2f} · FULL {prop['sh_full']:.2f}") + rank_in = int((G["sh_in"] > prop['sh_in']).sum()) + 1 + rank_oos = int((G["sh_oos"] > prop['sh_oos']).sum()) + 1 + print(f" rango della proposta : {rank_in}/{len(G)} in-sample · {rank_oos}/{len(G)} sull'hold-out") + if rank_oos < rank_in: + print(" ⚠️ La proposta sta MEGLIO sull'hold-out che in-sample: e' la firma della") + print(" selezione-sull'hold-out. Il gate si giudica su cosa succede scegliendo al buio.") + print(f"\n Costo di scegliere al buio invece che a posteriori (hold-out): " + f"{best['sh_oos'] - prop['sh_oos']:+.2f} di Sharpe") + n_best = orders_per_year(cells[(int(best["every"]), float(best["frac"]))], CAP_REF, + int(best["every"]), float(best["frac"])) + n_prop = orders_per_year(cells[PROPOSTA], CAP_REF, *PROPOSTA) + print(f"\n ⚠️ La cella scelta al buio controlla il target OGNI GIORNO ({n_best:.0f} ordini/anno)") + print(f" contro la cadenza settimanale della proposta ({n_prop:.0f} ordini/anno). Il conto") + print(f" di esecuzione (Degiro/Revolut) NON ha API: 250 controlli manuali l'anno non sono") + print(f" una configurazione, sono un'ipotesi. La proposta costa " + f"{prop['sh_full'] - best['sh_full']:+.2f} di Sharpe FULL e") + print(" compra una cadenza eseguibile a mano. E' un vincolo dichiarato, non una scoperta.") + + # ---------------------------------------------------------------- (B) deflated Sharpe + print("\n" + "=" * 104) + print(f" (B) DEFLATED SHARPE sulla griglia intera ({len(G)} celle), dpy={DPY:.0f}") + print("=" * 104) + all_sr = list(G["sh_full"].values) + for label, cell in (("proposta 27/07", PROPOSTA), + ("scelta in-sample", (int(best['every']), float(best['frac'])))): + s = cells[cell] + dsr, null_max = deflated_sharpe(met(s)["sharpe"], all_sr, s.dropna().values, dpy=DPY) + print(f"\n {label:<18} cadenza {cell[0]}, banda {cell[1]:.0%} · " + f"Sharpe {met(s)['sharpe']:.2f} vs massimo atteso sotto il nullo {null_max:.2f}") + print(f" {'':<18} DSR = {dsr:.3f} -> {'PASS' if dsr >= 0.95 else 'FAIL'} (soglia 0.95)") + + # ---------------------------------------------------------------- (C) null de-levering + print("\n" + "=" * 104) + print(" (C) NULL DE-LEVERING — la banda filtra, o congela?") + print("=" * 104) + print(f"\n Riferimento = banda 0%, controllo giornaliero. Una banda che FILTRA lascia la") + print(f" serie quasi identica (corr >= {MIN_CORR}); una che CONGELA la stacca.") + print(f"\n {'banda%':>8}{'corr rif':>11}{'vol/rif':>10}{'Sh FULL':>10} verdetto") + for frac in FRAC_GRID: + r = G[(G["every"] == PROPOSTA[0]) & (G["frac"] == frac)].iloc[0] + v = "OK" if r['corr'] >= MIN_CORR else "❌ FUORI TRACCIA" + print(f" {frac:>8.0%}{r['corr']:>11.3f}{r['volratio']:>10.2f}{r['sh_full']:>10.2f} {v}") + print("\n ⚠️ Il modo di fallire NON e' quello atteso, e va detto: allargando la banda la") + print(" volatilita' NON scende (vol/rif resta 0.99-1.10), quindi qui il null de-levering") + print(" classico non morde. Cio' che si rompe e' il TRACKING: la serie smette di") + print(" somigliare a se' stessa perche' tiene posizioni vecchie. E i due segnali") + print(" CONCORDANO — oltre il 25% la correlazione cala E lo Sharpe smette di migliorare") + print(" (0.70 -> 0.67 -> 0.59): non c'e' una zona in cui il numero premia il congelamento.") + print(f" Ma il 25% e' AL BORDO della regione (corr {G[(G['every'] == 5) & (G['frac'] == 0.25)].iloc[0]['corr']:.3f} " + f"contro una soglia di {MIN_CORR}): non e' un plateau centrale.") + + # ---------------------------------------------------------------- invarianza + print("\n" + "=" * 104) + print(" INVARIANZA AL CAPITALE — il motivo per cui la proposta esiste") + print("=" * 104) + print(f"\n {'capitale':>10}{'banda 25%':>12}{'ord/anno':>11}{'Sharpe':>9}{'corr rif':>10}" + f" | {'banda $50 fissa':>16}{'ord/anno':>11}{'Sharpe':>9}") + for cap in CAPITALS: + s_f = run_cell(PROPOSTA[0], PROPOSTA[1], cap) + s_a = gtaa_returns(capital=cap, band_usd=CANONICO[1], every=CANONICO[0]) + n_f = orders_per_year(s_f, cap, PROPOSTA[0], PROPOSTA[1]) + n_a = orders_per_year(s_a, cap, CANONICO[0], CANONICO[1] / (cap / 6)) + j = pd.concat([s_f, ref], axis=1, join="inner").dropna() + print(f" ${cap:>9,.0f}{PROPOSTA[1] * cap / 6:>11,.0f}${n_f:>11.0f}{met(s_f)['sharpe']:>9.2f}" + f"{float(j.iloc[:, 0].corr(j.iloc[:, 1])):>10.3f} | " + f"{CANONICO[1]:>15,.0f}${n_a:>11.0f}{met(s_a)['sharpe']:>9.2f}") + print(f"\n (sotto ${GTAA_MIN_CAPITAL:,.0f} lo sleeve non e' deployabile comunque: " + "GTAA_MIN_CAPITAL)") + + # ---------------------------------------------------------------- impatto sul book + print("\n" + "=" * 104) + print(" IMPATTO SUL BOOK — il numero che decide se toccare la produzione") + print("=" * 104) + from src.portfolio.portfolio import combine_outer + from src.portfolio.sleeves import GTAA_BOOK_ACTIVATION, active_sleeves + + def as_book(gtaa_series: pd.Series | None) -> pd.Series: + cols, w = {}, {} + for sl in active_sleeves(): + s = sl.daily() + if sl.name == "GTAA01_eq_trend" and gtaa_series is not None: + g = gtaa_series.dropna().sort_index() + if g.index.tz is None: + g.index = g.index.tz_localize("UTC") + days = pd.date_range(g.index.min().normalize(), g.index.max().normalize(), + freq="1D", tz="UTC") + s = g.reindex(days).fillna(0.0) + s = s[s.index >= GTAA_BOOK_ACTIVATION] + cols[sl.name], w[sl.name] = s, sl.weight + return combine_outer(cols, w) + + def bk(s: pd.Series) -> dict: + v = s.dropna().values.astype(float) + eq = (1.0 + v).cumprod() + return dict(sharpe=float(v.mean() / v.std() * np.sqrt(365.0)), + maxdd=float((1.0 - eq / np.maximum.accumulate(eq)).max())) + + b_can = bk(as_book(None)) + b_new = bk(as_book(run_cell(PROPOSTA[0], PROPOSTA[1], CAP_REF))) + print(f"\n book 5-sleeve, banda CANONICA $50 : Sharpe FULL {b_can['sharpe']:.3f} · " + f"maxDD {b_can['maxdd']:.2%}") + print(f" book 5-sleeve, banda PROPOSTA 25% : Sharpe FULL {b_new['sharpe']:.3f} · " + f"maxDD {b_new['maxdd']:.2%}") + print(f" delta : {b_new['sharpe'] - b_can['sharpe']:+.3f} Sharpe · " + f"{(b_new['maxdd'] - b_can['maxdd']) * 100:+.2f} pt di maxDD") + print("\n ⚠️ Il book si annualizza a 365 (griglia giornaliera piena, weekend = 0), lo sleeve") + print(" a 252 (giorni di borsa): i due Sharpe NON sono confrontabili fra loro, solo") + print(" ciascuno con se' stesso fra le due configurazioni.") + + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0727_lumpsum_split.py b/scripts/research/r0727_lumpsum_split.py new file mode 100644 index 0000000..4096829 --- /dev/null +++ b/scripts/research/r0727_lumpsum_split.py @@ -0,0 +1,291 @@ +#!/usr/bin/env python +"""r0727_lumpsum_split.py — il capitale che e' GIA' fermo, e cosa fa alla decisione di venue. + +LA DOMANDA. Sul conto che gira ci sono **$600**. Fermi altrove ce ne sono ~**€10.000** (Revolut: +€6.043 in XEON, che rende ~0% reale netto dopo inflazione e tasse, piu' VWCE/SPYM/PPFB) e €329 su +Degiro. Tutte le traiettorie pubblicate (25/07 e 26/07) partono da `START = 600.0` **cablato**: il +progetto ha misurato con cura il CALENDARIO dei versamenti (fattore 6) e non ha mai misurato un +**versamento iniziale**. + +PERCHE' LE DUE DOMANDE SI MISURANO INSIEME. Un lump-sum e' il front-loading estremo, e il 26/07 +aveva gia' misurato che il front-loading *mette piu' capitale sull'exchange prima* — cioe' proprio +dove vive il rischio di venue. E la decisione del 26/07 ("100% Deribit fino a $20k") e' stata +presa con **$600 sul conto, dove lo split e' impossibile per costruzione**. CLAUDE.md registra +l'impegno esplicito: *"Se cambia il piano (orizzonte, importo dei versamenti) si riapre PRIMA."* +Un lump-sum da €5-10k e' esattamente quel cambiamento. Misurare il lump-sum senza il rischio di +venue risponderebbe alla meta' comoda della domanda. + +COSA NON DECIDE QUESTO SCRIPT. Quanto di quei €6.043 sia un vero fondo d'emergenza e quanto +capitale in attesa: e' una decisione dell'operatore, e un fondo d'emergenza NON e' capitale +disponibile. Qui si misura **cosa compra ogni euro che entra**, per ogni taglia, cosi' che la +decisione si prenda sul prezzo giusto. + +ASSUNZIONI DICHIARATE + * Il costo-opportunita' del lump-sum e' ~0% reale (XEON: ~2% lordo, ~0 netto dopo inflazione e + 26%). Tutti i numeri sono NOMINALI in USD, come ovunque nel progetto: il confronto e' quindi + fra "fermo a ~0 reale" e "dentro il book", non fra due investimenti rischiosi. + * Book = quello LIVE (TP01+SKH01 su Deribit), de-luckato ×0.89 (fattore MISURATO, + r0726_deluck_factor). E' lo stesso book di ogni traiettoria pubblicata -> i numeri sono + confrontabili con la tabella dei versamenti del 26/07. + * Bersaglio = $272.061, il muro del 26/07 (rendita perpetua a €50/g netti). Identico per CONC e + SPLIT: e' CONSERVATIVO contro lo split, che avrebbe un muro leggermente diverso. + * `p` (probabilita' annua di fallimento di un venue) NON e' stimata: e' una sensibilita'. + La sceglie l'operatore. + + uv run python scripts/research/r0727_lumpsum_split.py +""" +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 r0725_capcurve as CC # noqa: E402 +import r0726_capwall_refresh as WR # noqa: E402 +import r0726_venue_risk as VR # noqa: E402 +from src.portfolio.gtaa import GTAA_MIN_CAPITAL # noqa: E402 + +DELUCK = 0.89 +START = 600.0 # il conto Deribit di oggi +YEARS = 20 +N_PATHS = 4000 +BLOCK = 20 +SEED = 726 # lo stesso di r0726_venue_risk: consente la replica esatta +TARGET = VR.TARGET # $272.061 +LUMPS_EUR = (0.0, 2_000.0, 5_000.0, 10_000.0) +P_GRID = (0.0, 0.005, 0.01, 0.02, 0.05) + +# Split: la quota su IB parte dal 25% raccomandato da r0725_ib10k (oltre, la rendita cala: +# 0% -> €2.16/g, 50% -> €1.54/g su $11.5k). Ma la quota MINIMA e' imposta dall'eseguibilita' +# della gamba equity: sotto GTAA_MIN_CAPITAL non c'e' uno sleeve, c'e' cash su un secondo conto. +# Si accetta di salire fino a IB_SHARE_MAX pur di rendere lo split possibile; oltre, no. +CONC = {"Deribit": 1.0} +IB_SHARE = 0.25 +IB_SHARE_MAX = 0.35 + +# Haircut sulla gamba IB nella variante realistica: costi a taglia piccola (CAGR 3.8% -> 3.0% a +# $3k, misurato il 25/07) + drag del veicolo UCITS (+0.10%/anno, misurato il 26/07). +IB_HAIRCUT_YR = 0.008 + 0.001 + + +def simulate(cfg: dict, series: dict, p_annual: float, lump_usd: float = 0.0, + dep_eur: float = 250.0, n_paths: int = N_PATHS, years: int = YEARS, + seed: int = SEED) -> dict: + """Accumulo con capitale iniziale `START + lump_usd` e jump di venue. + + Generalizza `r0726_venue_risk.simulate` sul solo capitale iniziale e sul deposito: la + struttura (ordine di consumo dell'RNG compreso) e' identica, cosi' che con lump=0 e + dep=250 i numeri debbano coincidere ESATTAMENTE con quelli pubblicati il 26/07. + """ + rng = np.random.default_rng(seed) + n_days = years * 365 + names = list(cfg) + J = pd.concat({k: series[k] for k in names}, axis=1, join="inner").fillna(0.0) + R = J.values.astype(float) + idx = CC._boot_paths(np.arange(len(R), dtype=float), n_paths, n_days, BLOCK, rng).astype(int) + dep = dep_eur * CC.EURUSD + + w0 = np.array([cfg[k] for k in names], float) + cap = np.outer(np.full(n_paths, START + lump_usd), w0) + alive = np.ones((n_paths, len(names)), bool) + haz = 1.0 - (1.0 - p_annual) ** (1.0 / 365.0) + hit = np.zeros(n_paths, bool) + hit_day = np.full(n_paths, n_days + 1, int) + for t in range(n_days): + cap *= (1.0 + R[idx[:, t], :]) + if haz > 0: + dead = (rng.random((n_paths, len(names))) < haz) & alive + cap = np.where(dead, 0.0, cap) + alive &= ~dead + if t % 30 == 0 and t > 0 and dep > 0: + wa = alive * w0 + tot = wa.sum(axis=1, keepdims=True) + share = np.divide(wa, tot, out=np.zeros_like(wa), where=tot > 0) + cap += dep * share + tot_cap = cap.sum(axis=1) + newly = (~hit) & (tot_cap >= TARGET) + hit_day[newly] = t + hit |= newly + yrs = np.where(hit, hit_day / 365.0, np.nan) + fin = yrs[~np.isnan(yrs)] + return dict(p_reach=float(hit.mean()), + median_yrs=float(np.median(fin)) if len(fin) > 50 else float("nan"), + p_all_dead=float((~alive.any(axis=1)).mean()), + median_end=float(np.median(cap.sum(axis=1))), + paid=START + lump_usd + dep * (n_days // 30)) + + +def haircut(s: pd.Series, per_year: float) -> pd.Series: + """Abbassa il drift di una serie giornaliera di `per_year` all'anno (costi non modellati).""" + return s - per_year / 365.0 + + +def split_config(total_usd: float) -> dict | None: + """Configurazione di split possibile a quel capitale, o None. + + La quota IB non e' libera: dal basso la vincola l'ESEGUIBILITA' (sotto GTAA_MIN_CAPITAL la + gamba equity non esiste, e lo 'split' sarebbe cash fermo su un secondo conto), dall'alto la + vincola la RENDITA (r0725_ib10k: oltre il 25% si paga, oltre il 50% si dimezza). Fra i due + vincoli si prende la quota minima che rende la gamba eseguibile — mai meno del 25% + raccomandato, mai piu' di IB_SHARE_MAX. + """ + need = GTAA_MIN_CAPITAL / total_usd if total_usd > 0 else float("inf") + share = max(IB_SHARE, need) + if share > IB_SHARE_MAX: + return None + return {"Deribit": 1.0 - share, "IB": share} + + +def main() -> None: + print("=" * 104) + print(" r0727 — IL CAPITALE GIA' FERMO: cosa compra un versamento iniziale, e cosa fa al venue") + print("=" * 104) + + series = VR.venue_series() + r_deribit = series["Deribit"].values.astype(float) + gross = CC.TARGET_EUR_DAY * 365 * CC.EURUSD / (1 - CC.TAX_RATE) + _, perp, wall = WR.perp_and_wall(r_deribit, 1.0, gross) + print(f" book live de-luckato ×{DELUCK} | rendita perpetua {perp:.2%} | bersaglio ${TARGET:,.0f}") + print(f" {N_PATHS} path, blocchi {BLOCK}g, orizzonte {YEARS}a, conto di partenza ${START:.0f}, " + f"EURUSD {CC.EURUSD}") + + # ---------------------------------------------------------------- validazione + print("\n" + "-" * 104) + print(" VALIDAZIONE — con lump 0 e €250/mese si devono riprodurre i numeri del 26/07") + print("-" * 104) + for p in (0.0, 0.01): + mine = simulate(CONC, series, p, lump_usd=0.0, dep_eur=250.0) + theirs = VR.simulate({"Deribit": 1.0}, series, p, n_paths=N_PATHS, years=YEARS, seed=SEED) + ok = (abs(mine["p_reach"] - theirs["p_reach"]) < 1e-12 + and abs(mine["p_all_dead"] - theirs["p_all_dead"]) < 1e-12) + print(f" p={p:.1%} P(arrivare) {mine['p_reach']:.4f} vs {theirs['p_reach']:.4f} · " + f"P(perso tutto) {mine['p_all_dead']:.4f} vs {theirs['p_all_dead']:.4f} " + f"-> {'IDENTICO' if ok else 'DIVERGE (la generalizzazione ha cambiato qualcosa)'}") + + # ---------------------------------------------------------------- (1) cosa compra il lump + print("\n" + "=" * 104) + print(" (1) COSA COMPRA UN VERSAMENTO INIZIALE — senza rischio di venue (p=0), 100% Deribit") + print("=" * 104) + print(f"\n {'lump':>8}{'dep/mese':>10}{'tot versato':>14}{'anni al muro':>14}" + f"{'P(entro 20a)':>14}{'cap. mediano':>15}{'rendita a 20a':>16}") + grid = {} + for dep in (0.0, 250.0): + for L in LUMPS_EUR: + r = simulate(CONC, series, 0.0, lump_usd=L * CC.EURUSD, dep_eur=dep) + grid[(L, dep)] = r + rent = r["median_end"] * perp * (1 - CC.TAX_RATE) / 365.0 / CC.EURUSD + yrs = f"{r['median_yrs']:.1f}a" if not np.isnan(r["median_yrs"]) else "mai" + print(f" €{L:>7,.0f}{dep:>10,.0f}{r['paid']:>13,.0f}${yrs:>14}" + f"{r['p_reach']:>13.1%}{r['median_end']:>14,.0f}${rent:>13.2f} €/g") + + # equivalenza: a quale versamento mensile equivale il lump, a parita' di traguardo mediano. + # NB: NON si confronta il lump col totale dei versamenti risparmiati — sarebbe la domanda + # sbagliata (il valore e' arrivare prima, non versare meno), e darebbe un rapporto <1 che + # invita alla conclusione opposta. + print("\n EQUIVALENZA — a quanti €/mese IN PIU', per sempre, equivale il lump-sum?") + base = grid[(0.0, 250.0)]["median_yrs"] + for L in LUMPS_EUR[1:]: + got = grid[(L, 250.0)]["median_yrs"] + lo, hi = 250.0, 3_000.0 + for _ in range(12): # bisezione sul deposito a pari traguardo + mid = 0.5 * (lo + hi) + y = simulate(CONC, series, 0.0, lump_usd=0.0, dep_eur=mid)["median_yrs"] + if np.isnan(y) or y > got: + lo = mid + else: + hi = mid + eq = 0.5 * (lo + hi) + print(f" €{L:>6,.0f} oggi -> traguardo {got:.1f}a (invece di {base:.1f}a) " + f"= come versare €{eq:,.0f}/mese invece di €250, cioe' €{eq - 250:,.0f}/mese in piu' " + f"per {got:.0f} anni (€{(eq - 250) * 12 * got:,.0f})") + + # ---------------------------------------------------------------- (2) venue + print("\n" + "=" * 104) + print(" (2) LO STESSO LUMP, COL RISCHIO DI VENUE DENTRO — concentrato vs split") + print(f" SPLIT = Deribit / IB, quota IB scelta dal CAPITALE (minimo eseguibile " + f"${GTAA_MIN_CAPITAL:,.0f}, tetto {IB_SHARE_MAX:.0%})") + print("=" * 104) + ib_real = {**series, "IB": haircut(series["IB"], IB_HAIRCUT_YR)} + for L in LUMPS_EUR: + tot = START + L * CC.EURUSD + cfg = split_config(tot) + if cfg: + why = (f"POSSIBILE a quota IB {cfg['IB']:.0%} (${tot * cfg['IB']:,.0f} sulla gamba equity; " + f"il minimo eseguibile e' ${GTAA_MIN_CAPITAL:,.0f})") + else: + why = (f"IMPOSSIBILE: servirebbe una quota IB del {GTAA_MIN_CAPITAL / tot:.0%} " + f"per fare ${GTAA_MIN_CAPITAL:,.0f}, oltre il tetto {IB_SHARE_MAX:.0%}") + print(f"\n lump €{L:,.0f} -> capitale iniziale ${tot:,.0f} · split {why}") + print(f" {'p annua':>9}{'P(arrivare) CONC':>19}{'SPLIT':>9}{'SPLIT+costi':>13}" + f"{'P(perso tutto) CONC':>22}{'SPLIT':>9}") + for p in P_GRID: + c = simulate(CONC, series, p, lump_usd=L * CC.EURUSD) + if cfg: + s = simulate(cfg, series, p, lump_usd=L * CC.EURUSD) + sr = simulate(cfg, ib_real, p, lump_usd=L * CC.EURUSD) + print(f" {p:>8.1%}{c['p_reach']:>18.1%}{s['p_reach']:>9.1%}{sr['p_reach']:>13.1%}" + f"{c['p_all_dead']:>21.1%}{s['p_all_dead']:>9.1%}") + else: + print(f" {p:>8.1%}{c['p_reach']:>18.1%}{'-':>9}{'-':>13}" + f"{c['p_all_dead']:>21.1%}{'-':>9}") + # --- lo split che si puo' fare OGGI: la gamba equity e' bloccata dal PRIIPs ------------- + print("\n" + "-" * 104) + print(" (2-bis) E SE LA SECONDA GAMBA FOSSE SOLO LIQUIDITA'?") + print(" GTAA01 oggi NON e' deployabile (blocco PRIIPs sugli ETF USA; la via UCITS su Degiro") + print(" e' verificata ma in preparazione). Lo split disponibile SUBITO e' Deribit + un conto") + print(" fermo (XEON o simile, ~0% reale). Quanta protezione viene dalla DIVERSIFICAZIONE DI") + print(" VENUE e quanta dai ritorni della gamba equity?") + print("-" * 104) + L = 10_000.0 + tot = START + L * CC.EURUSD + cfg = split_config(tot) + zero = {**series, "IB": series["IB"] * 0.0} # stessa griglia temporale, drift nullo + print(f"\n lump €{L:,.0f} (${tot:,.0f}), quota seconda gamba {cfg['IB']:.0%}") + print(f" {'p annua':>9}{'P(arrivare) CONC':>19}{'SPLIT-GTAA':>13}{'SPLIT-CASSA':>13}" + f"{'P(perso tutto) CONC':>22}{'SPLIT':>9}") + for p in P_GRID: + c = simulate(CONC, series, p, lump_usd=L * CC.EURUSD) + g = simulate(cfg, series, p, lump_usd=L * CC.EURUSD) + z = simulate(cfg, zero, p, lump_usd=L * CC.EURUSD) + print(f" {p:>8.1%}{c['p_reach']:>18.1%}{g['p_reach']:>13.1%}{z['p_reach']:>13.1%}" + f"{c['p_all_dead']:>21.1%}{g['p_all_dead']:>9.1%}") + print("\n (P(perso tutto) e' identica fra SPLIT-GTAA e SPLIT-CASSA: dipende da QUANTI conti") + print(" falliscono, non da cosa ci sta sopra. La differenza fra le due colonne di") + print(" P(arrivare) e' il prezzo di tenere ferma la seconda gamba invece di investirla.)") + + print("\n ⚠️ La colonna P(perso tutto) sotto CONC non dipende dal lump: con UN conto solo,") + print(" 'almeno un fallimento' COINCIDE con 'perso tutto', e quella probabilita' e' una") + print(" proprieta' del tempo di esposizione, non del capitale. Il lump non la peggiora —") + print(" ma moltiplica cio' che quella probabilita' porta via.") + + # ---------------------------------------------------------------- (3) la soglia + print("\n" + "=" * 104) + print(" (3) DA QUALE CAPITALE LO SPLIT E' POSSIBILE SENZA DISTORCERE I PESI") + print("=" * 104) + ideale = GTAA_MIN_CAPITAL / IB_SHARE + minimo = GTAA_MIN_CAPITAL / IB_SHARE_MAX + tot10 = START + 10_000 * CC.EURUSD + print(f"\n soglia di eseguibilita' della gamba equity (GTAA_MIN_CAPITAL): ${GTAA_MIN_CAPITAL:,.0f}") + print(f" quota IB raccomandata da r0725_ib10k (oltre, cala la rendita): {IB_SHARE:.0%}") + print(f" -> split a quota RACCOMANDATA possibile da : ${ideale:,.0f}") + print(f" -> split possibile forzando la quota fino al {IB_SHARE_MAX:.0%} da : ${minimo:,.0f}") + print(f"\n Con un lump da €10.000 il conto sarebbe ${tot10:,.0f}: " + f"{'SOPRA' if tot10 >= ideale else 'SOTTO'} la soglia a quota") + print(f" raccomandata (${ideale:,.0f}) e {'sopra' if tot10 >= minimo else 'sotto'} " + f"quella a quota forzata (${minimo:,.0f}) -> lo split e' possibile, ma a") + print(f" quota IB {max(IB_SHARE, GTAA_MIN_CAPITAL / tot10):.0%}, non {IB_SHARE:.0%}: " + "e' il capitale a scegliere la quota, non la preferenza.") + print(f"\n La riapertura della decisione di venue e' fissata a ${20_000:,.0f}. Un lump da") + print(f" €10k porta il conto a ${tot10:,.0f} — sotto quella data ma sopra la fattibilita'") + print(" tecnica, ed e' un cambiamento del piano: CLAUDE.md dice di riaprire PRIMA in") + print(" questo caso, e questa tabella e' il materiale per farlo.") + + +if __name__ == "__main__": + main() diff --git a/src/live/monitor_health.py b/src/live/monitor_health.py new file mode 100644 index 0000000..bf42b25 --- /dev/null +++ b/src/live/monitor_health.py @@ -0,0 +1,173 @@ +"""monitor_health — i forward-monitor stanno ancora registrando? (puro, testabile) + +IL BUCO CHE CHIUDE. Il progetto ha **tre gate pre-registrati** che si decideranno leggendo una +serie forward: STATARB **2026-09-27**, XSR01 **2026-10-23**, DVOLSPREAD kill **2026-10-24** +(decisione piena 2027-01-24). Di quelle serie, **una sola** ha una guardia d'integrita' +(`paper_dvolspread`, contabilita' a 3 stati + veto sotto l'80% di barre attive). Le altre no: se +un monitor smettesse di avanzare in silenzio — feed rotto, eccezione ingoiata, cron che non gira — +il gate verrebbe deciso su dati mancanti, e i dati mancanti **somigliano a dati tranquilli**. + +E' lo stesso schema gia' pagato due volte: `fresh_5m` che falliva in silenzio (26/07) e il +feed-freeze del 14/07. La regola del progetto e' che *"non vedo" non e' "va tutto bene"* — qui +diventa: **un monitor fermo non e' un monitor che dice zero.** + +DUE GUASTI, DUE MISURE (una sola non basta): + * **coda** — l'ultima barra e' vecchia: il monitor si e' fermato *adesso*. Misurata come eta' + dell'ultima registrazione, sulla cadenza dichiarata dal monitor. + * **buchi interni** — il monitor gira ma perde barre: la serie e' piu' corta di quanto il suo + stesso arco temporale richieda. Misurata come copertura fra la PRIMA e l'ULTIMA barra. + Un monitor che ha perso il 30% delle barre di mezzo passerebbe qualunque controllo di + freschezza. + +CADENZE, che NON sono tutte uguali (e sbagliarle vuol dire un falso allarme a settimana): + * `paper_prevday` registra a barra ORARIA (864 barre in 36 giorni), avanzato dal cron daily; + * `paper_combo` vive sul calendario di BORSA (gambe IB): il venerdi' e' l'ultima barra fino al + lunedi', e tre giorni di eta' sono la norma, non un guasto; + * gli altri sono crypto giornalieri, 365 barre l'anno. + +SOGLIA DI COPERTURA 0.80 — la stessa del veto d'integrita' gia' pre-registrato per DVOLSPREAD +("se le barre attive sono sotto l'80% si ESTENDE la finestra, non si decide"). Riusarla e' voluto: +una soglia diversa per monitor renderebbe i gate non confrontabili. + +⚠️ LIMITE DICHIARATO. `np.busday_count` non conosce le FESTIVITA' di borsa: sul calendario equity +la copertura attesa e' sovrastimata di un giorno per festivita' nell'arco misurato (oggi +`paper_combo` legge 96% per il 3 luglio, non per un guasto). E' un errore in direzione +CONSERVATIVA — segnala di piu', non di meno — e resta ampiamente sopra la soglia 0.80 finche' le +festivita' sono <20% dei giorni. Se un giorno servisse precisione qui, la risposta e' un +calendario di borsa vero, non alzare la soglia. +""" +from __future__ import annotations + +import csv +import json +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path + +import numpy as np + +MIN_COVERAGE = 0.80 # sotto: la serie ha buchi -> non ci si decide sopra un gate +MIN_BARS_PER_JUDGE = 2 # con una barra sola non esiste ne' coda ne' copertura + + +@dataclass(frozen=True) +class MonitorSpec: + """Cosa ci si aspetta da un monitor. `gate` = la decisione che dipende da questa serie.""" + name: str + directory: str + series: str # returns.jsonl | equity.csv + cadence_h: float # passo nominale fra due barre + calendar: str = "crypto" # crypto (365g) | equity (giorni di borsa) + max_age_h: float = 48.0 # oltre: FERMO + gate: str | None = None + + +MONITORS: tuple[MonitorSpec, ...] = ( + MonitorSpec("paper_portfolio", "paper_portfolio", "equity.csv", 24.0), + MonitorSpec("paper_prevday", "paper_prevday", "returns.jsonl", 1.0), + MonitorSpec("paper_statarb", "paper_statarb", "returns.jsonl", 24.0, + gate="2026-09-27 — gate di deploy STATARB-RESID"), + MonitorSpec("paper_xsr", "paper_xsr", "returns.jsonl", 24.0, + gate="2026-10-23 — gate di deploy XSR01 (Sharpe>=1.0 E haircut<=40%)"), + MonitorSpec("paper_dvolspread", "paper_dvolspread", "returns.jsonl", 24.0, + gate="2026-10-24 kill / 2027-01-24 decisione — DVOLSPREAD"), + # calendario di BORSA: le gambe GTAA vengono da IB, il weekend non e' un guasto. + MonitorSpec("paper_combo", "paper_combo", "equity.csv", 24.0, + calendar="equity", max_age_h=120.0), +) + + +def read_stamps(path: Path) -> list[datetime]: + """Timestamp di ogni barra registrata, in UTC e ordinati. Formati: jsonl con `ts` in ms + epoch, oppure csv con la data in prima colonna.""" + if not path.exists(): + return [] + out: list[datetime] = [] + if path.suffix == ".jsonl": + for ln in path.read_text().splitlines(): + ln = ln.strip() + if not ln: + continue + try: + ts = json.loads(ln).get("ts") + except json.JSONDecodeError: + continue + if ts is not None: + out.append(datetime.fromtimestamp(float(ts) / 1000.0, tz=timezone.utc)) + else: + with path.open() as fh: + for row in csv.DictReader(fh): + raw = next(iter(row.values())) + try: + d = datetime.fromisoformat(str(raw)) + except ValueError: + continue + out.append(d if d.tzinfo else d.replace(tzinfo=timezone.utc)) + return sorted(out) + + +def expected_bars(first: datetime, last: datetime, cadence_h: float, calendar: str) -> int: + """Quante barre DOVREBBERO esserci fra la prima e l'ultima, estremi inclusi.""" + if calendar == "equity": + n = int(np.busday_count(first.date(), last.date())) + 1 + return max(n, 1) + span_h = (last - first).total_seconds() / 3600.0 + return int(round(span_h / cadence_h)) + 1 + + +def age_hours(last: datetime, now: datetime, calendar: str) -> float: + """Eta' dell'ultima barra. Sul calendario di borsa il weekend non invecchia la serie: + contarlo produrrebbe un allarme ogni lunedi'.""" + raw = (now - last).total_seconds() / 3600.0 + if calendar == "equity": + bd = int(np.busday_count(last.date(), now.date())) + return min(raw, bd * 24.0 + (raw % 24.0)) + return raw + + +def assess(spec: MonitorSpec, stamps: list[datetime], now: datetime) -> dict: + """Verdetto su un monitor. Gli stati NON sono due: 'assente' e 'troppo giovane per un + giudizio' non sono 'OK', e non devono poter passare per tali.""" + base = dict(name=spec.name, gate=spec.gate, n_bars=len(stamps), + age_h=None, coverage=None, expected=None) + if not stamps: + return {**base, "status": "ASSENTE", + "why": "nessuna serie leggibile: il monitor non ha mai scritto, o lo stato e' perso"} + age = age_hours(stamps[-1], now, spec.calendar) + if len(stamps) < MIN_BARS_PER_JUDGE: + return {**base, "age_h": age, "status": "NUOVO", + "why": f"{len(stamps)} barra: non c'e' abbastanza serie per misurare la copertura"} + exp = expected_bars(stamps[0], stamps[-1], spec.cadence_h, spec.calendar) + cov = len(stamps) / exp if exp > 0 else float("nan") + d = {**base, "age_h": age, "coverage": cov, "expected": exp} + if age > spec.max_age_h: + return {**d, "status": "FERMO", + "why": f"ultima barra {age:.0f}h fa (limite {spec.max_age_h:.0f}h)"} + if cov < MIN_COVERAGE: + return {**d, "status": "BUCATO", + "why": f"copertura {cov:.0%} < {MIN_COVERAGE:.0%}: {exp - len(stamps)} barre mancanti"} + return {**d, "status": "OK", "why": ""} + + +def check_all(root: Path, now: datetime | None = None, + monitors: tuple[MonitorSpec, ...] = MONITORS) -> list[dict]: + now = now or datetime.now(timezone.utc) + out = [] + for spec in monitors: + stamps = read_stamps(root / "data" / spec.directory / spec.series) + out.append(assess(spec, stamps, now)) + return out + + +def alerts_from(rows: list[dict]) -> list[str]: + """Cosa vale un allarme. Un monitor NUOVO non lo e' (sta solo iniziando); un monitor che + alimenta un gate pre-registrato e' peggio degli altri, e l'allarme lo dice.""" + out = [] + for r in rows: + if r["status"] in ("OK", "NUOVO"): + continue + msg = f"{r['name']}: {r['status']} — {r['why']}" + if r["gate"]: + msg += f" | METTE A RISCHIO il gate {r['gate']}" + out.append(msg) + return out diff --git a/tests/test_fee_watch.py b/tests/test_fee_watch.py new file mode 100644 index 0000000..08d7013 --- /dev/null +++ b/tests/test_fee_watch.py @@ -0,0 +1,110 @@ +"""Test della sorveglianza fee (scripts/live/fee_watch.py). + +Il nuovo schema fee Deribit entra in vigore il 2026-08-01 SENZA numeri pubblicati da fonte +primaria. La regola e' stata decisa PRIMA di vedere il numero (r0726_fee_sensitivity.py): +taker <=5bps -> nulla, >10bps -> rivedere il peso di SKH01. Questi test congelano la regola e +la sua taratura, cosi' che il numero non possa muovere la soglia una volta arrivato. + +Il test piu' importante e' `test_baseline_e_quella_dei_backtest`: lega la soglia della +sorveglianza alla fee usata nei backtest. Se qualcuno cambia una delle due senza l'altra, il +confronto smette di avere senso e il test lo dice. +""" +from __future__ import annotations + +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 fee_watch as FW # noqa: E402 + + +# =========================================================================== +# la regola congelata +# =========================================================================== +def test_al_tier_di_oggi_non_si_tocca_nulla(): + assert FW.verdict(5.0)[0] == "OK" + + +def test_sotto_il_tier_di_oggi_i_backtest_restano_conservativi(): + """Se la fee SCENDE i numeri pubblicati non vanno rifatti: sono un limite inferiore.""" + assert FW.verdict(2.0)[0] == "OK" + + +def test_fra_5_e_10_bps_si_riporta_il_costo_ma_non_si_agisce(): + assert FW.verdict(5.1)[0] == "NOTA" + assert FW.verdict(10.0)[0] == "NOTA" + + +def test_sopra_10_bps_scatta_la_revisione_del_peso_di_skh01(): + lvl, why = FW.verdict(10.1) + assert lvl == "AZIONE" + assert "SKH01" in why + + +def test_le_soglie_sono_quelle_dichiarate_in_claude_md(): + """Una regola decisa in anticipo vale solo se non la si puo' spostare dopo aver visto il + numero: qui le due soglie sono congelate.""" + assert (FW.SOGLIA_OK, FW.SOGLIA_AZIONE) == (5.0, 10.0) + + +def test_baseline_e_quella_dei_backtest(): + """5 bps/lato = 0.10% RT = il default di backtest_signals. Le due devono restare agganciate.""" + from src.backtest.harness import backtest_signals + import inspect + default_fee_rt = inspect.signature(backtest_signals).parameters["fee_rt"].default + assert FW.BASELINE_TAKER_BPS * 2 / 1e4 == default_fee_rt + + +def test_sensibilita_marginale_e_quella_misurata(): + """-0.017 Sharpe/bps di book (r0726_fee_sensitivity). Se cambia la misura, cambia qui.""" + assert FW.D_SHARPE_PER_BPS == -0.017 + + +# =========================================================================== +# rilevazione del cambiamento — il motivo per cui e' un cron e non un promemoria +# =========================================================================== +def test_prima_lettura_non_e_un_evento(): + """Senza stato precedente non c'e' un cambiamento: c'e' un'inizializzazione. Allertare + qui vorrebbe dire allertare a ogni installazione.""" + assert FW.diff_vs({}, {"BTC-PERPETUAL": dict(taker_bps=5.0, maker_bps=0.0, liq_bps=75.0)}) == [] + + +def test_nessun_cambiamento_nessun_rumore(): + cur = {"BTC-PERPETUAL": dict(taker_bps=5.0, maker_bps=0.0, liq_bps=75.0)} + assert FW.diff_vs(dict(cur), cur) == [] + + +def test_un_taker_che_sale_viene_visto(): + prev = {"BTC-PERPETUAL": dict(taker_bps=5.0, maker_bps=0.0, liq_bps=75.0)} + cur = {"BTC-PERPETUAL": dict(taker_bps=8.0, maker_bps=0.0, liq_bps=75.0)} + ch = FW.diff_vs(prev, cur) + assert len(ch) == 1 and "taker" in ch[0] and "8.00" in ch[0] + + +def test_anche_maker_e_liquidazione_sono_sorvegliati(): + """L'annuncio tocca tre cose: taker piu' bassi, rebate maker piu' bassi, liquidation fee 1%. + Sorvegliarne una sola vorrebbe dire non accorgersi delle altre due.""" + prev = {"ETH-PERPETUAL": dict(taker_bps=5.0, maker_bps=0.0, liq_bps=90.0)} + cur = {"ETH-PERPETUAL": dict(taker_bps=5.0, maker_bps=1.0, liq_bps=100.0)} + ch = FW.diff_vs(prev, cur) + assert len(ch) == 2 + assert any("maker" in c for c in ch) and any("liquidazione" in c for c in ch) + + +# =========================================================================== +# assunzione dichiarata +# =========================================================================== +def test_sorveglia_il_tier_base_e_lo_dichiara(): + """A $600 il volume 30g e' trascurabile: il conto sta al tier BASE, che e' cio' che + l'endpoint pubblico riporta. Se un giorno il conto salisse di tier, questo script + misurerebbe la cosa sbagliata — l'assunzione sta nel docstring, e questo test la lega.""" + assert "public/get_instrument" in FW.API or "get_instrument" in FW.API + assert "tier BASE" in FW.__doc__ or "tier base" in FW.__doc__ + + +def test_fee_reali_assenti_non_sono_fee_zero(): + """{} = 'non misurata'. Un dict vuoto letto come 'zero' direbbe che il book trada gratis.""" + assert FW.realized_fee_bps.__doc__ and "non misurata" in FW.realized_fee_bps.__doc__ diff --git a/tests/test_gtaa_band_gate.py b/tests/test_gtaa_band_gate.py new file mode 100644 index 0000000..1cd3522 --- /dev/null +++ b/tests/test_gtaa_band_gate.py @@ -0,0 +1,164 @@ +"""Test del gate onesto sulla banda GTAA01 (scripts/research/r0727_gtaa_band_gate.py). + +Congela i tre esiti del gate e — soprattutto — l'ANNUALIZZAZIONE. Una serie su giorni di borsa +passata a un annualizzatore a 365 esce con lo Sharpe ×1.20 e il CAGR ×1.45: e' l'errore del +25/07, ed e' l'unico difetto qui che produrrebbe numeri sbagliati ma plausibili. + +`test_la_proposta_non_e_selezionata_sull_hold_out` e' il test di metodo: se un giorno la cella +proposta risultasse in rango MIGLIORE sull'hold-out che in-sample, sarebbe la firma della +selezione-sull'hold-out e la validazione andrebbe rifatta. +""" +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")) +sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt")) + +import r0727_gtaa_band_gate as BG # noqa: E402 + + +@pytest.fixture(scope="module") +def grid(): + """Griglia ridotta alla sola cadenza della proposta: basta per i gate (B) e (C) e tiene + il test veloce. La griglia piena vive nello script.""" + ref = BG.run_cell(1, 0.0, BG.CAP_REF) + out = {} + for frac in BG.FRAC_GRID: + s = BG.run_cell(BG.PROPOSTA[0], frac, BG.CAP_REF) + j = pd.concat([s, ref], axis=1, join="inner").dropna() + out[frac] = dict(series=s, met=BG.met(s), + corr=float(j.iloc[:, 0].corr(j.iloc[:, 1]))) + return ref, out + + +# =========================================================================== +# annualizzazione — l'errore che produce numeri plausibili e sbagliati +# =========================================================================== +def test_annualizza_a_giorni_di_borsa(): + assert BG.DPY == 252.0 + + +def test_lo_sharpe_e_calcolato_a_252_non_a_365(): + idx = pd.bdate_range("2020-01-01", periods=1000) + r = pd.Series(np.linspace(0.0004, 0.0006, 1000), index=idx) + v = r.values + atteso = v.mean() / v.std() * np.sqrt(252.0) # std di popolazione, come nel resto del progetto + assert BG.met(r)["sharpe"] == pytest.approx(atteso, rel=1e-12) + assert BG.met(r)["sharpe"] != pytest.approx(v.mean() / v.std() * np.sqrt(365.0), rel=1e-6) + + +# =========================================================================== +# la parametrizzazione: e' questa la proposta +# =========================================================================== +def test_la_banda_e_una_frazione_della_gamba_non_un_importo(): + """E' l'intera ragione della proposta: un importo assoluto ha effetti diversi a capitali + diversi, una frazione no.""" + from src.portfolio.gtaa import EQ_UNIVERSE + for cap in (3_000.0, 10_000.0, 50_000.0): + assert 0.25 * cap / len(EQ_UNIVERSE) == pytest.approx(0.25 * cap / 6) + + +def test_la_produzione_usa_ancora_la_banda_ASSOLUTA(): + """Il gate valida una proposta che NON e' ancora in produzione. Se un giorno la produzione + passasse alla banda frazionaria, questo test si rompe e va riletto tutto il confronto.""" + from src.portfolio.gtaa import REBAL_BAND_USD, REBAL_EVERY + assert (REBAL_EVERY, REBAL_BAND_USD) == BG.CANONICO == (5, 50.0) + + +# =========================================================================== +# (A) selezione — il gate di metodo +# =========================================================================== +def test_la_proposta_non_e_selezionata_sull_hold_out(): + """Rango in-sample <= rango hold-out: la proposta non migliora passando all'hold-out. + Il contrario sarebbe la firma della selezione-sull'hold-out.""" + rows = [] + for every in BG.EVERY_GRID: + for frac in BG.FRAC_GRID: + s = BG.run_cell(every, frac, BG.CAP_REF) + rows.append(dict(every=every, frac=frac, + sh_in=BG.met(s.loc[: BG.HOLDOUT])["sharpe"], + sh_oos=BG.met(s.loc[BG.HOLDOUT:])["sharpe"])) + G = pd.DataFrame(rows) + p = G[(G["every"] == BG.PROPOSTA[0]) & (G["frac"] == BG.PROPOSTA[1])].iloc[0] + rank_in = int((G["sh_in"] > p["sh_in"]).sum()) + 1 + rank_oos = int((G["sh_oos"] > p["sh_oos"]).sum()) + 1 + assert rank_in <= rank_oos, ( + f"la proposta e' {rank_oos}a sull'hold-out ma {rank_in}a in-sample: " + "sta meglio dove non doveva essere guardata") + + +def test_l_hold_out_usato_e_quello_documentato_di_gtaa01(): + """2015+: l'OOS equity della validazione 22/06, indipendente dall'hold-out crypto.""" + assert BG.HOLDOUT == "2015-01-01" + + +# =========================================================================== +# (B) deflated Sharpe +# =========================================================================== +def test_la_proposta_sopravvive_alla_deflazione(grid): + from altlib import deflated_sharpe + ref, cells = grid + all_sr = [c["met"]["sharpe"] for c in cells.values()] + s = cells[BG.PROPOSTA[1]]["series"] + dsr, _ = deflated_sharpe(BG.met(s)["sharpe"], all_sr, s.dropna().values, dpy=BG.DPY) + assert dsr >= 0.95 + + +# =========================================================================== +# (C) tracking — e il modo di fallire, che NON e' il de-levering +# =========================================================================== +def test_al_25_percento_lo_sleeve_segue_ancora_se_stesso(grid): + _, cells = grid + assert cells[0.25]["corr"] >= BG.MIN_CORR + + +def test_oltre_il_25_percento_il_tracking_si_rompe(grid): + _, cells = grid + assert cells[0.40]["corr"] < BG.MIN_CORR + assert cells[0.60]["corr"] < cells[0.40]["corr"] + + +def test_la_banda_larga_non_de_leva(grid): + """Il null de-levering classico NON morde qui: allargando la banda la vol non scende. + Se un giorno scendesse, il fallimento sarebbe di un altro tipo e il verdetto va riscritto.""" + ref, cells = grid + vref = BG.met(ref)["vol"] + assert cells[0.60]["met"]["vol"] / vref > 0.95 + + +def test_il_25_percento_e_al_bordo_non_al_centro(grid): + """Onesta' sul margine: la proposta passa, ma di poco. Congelarlo evita che in futuro si + citi come un plateau largo.""" + _, cells = grid + assert BG.MIN_CORR <= cells[0.25]["corr"] < BG.MIN_CORR + 0.02 + + +# =========================================================================== +# perche' la proposta esiste: invarianza al capitale +# =========================================================================== +def test_la_banda_frazionaria_batte_quella_fissa_a_capitale_piccolo(): + from src.portfolio.gtaa import gtaa_returns + cap = 3_000.0 + frazionaria = BG.met(BG.run_cell(BG.PROPOSTA[0], BG.PROPOSTA[1], cap))["sharpe"] + fissa = BG.met(gtaa_returns(capital=cap, band_usd=50.0, every=5))["sharpe"] + assert frazionaria > fissa + + +def test_a_capitale_grande_le_due_configurazioni_convergono(): + """A $50k la banda fissa smette di essere una tassa: il vantaggio della proposta e' al + capitale PICCOLO, ed e' li' che va citato.""" + from src.portfolio.gtaa import gtaa_returns + cap = 50_000.0 + frazionaria = BG.met(BG.run_cell(BG.PROPOSTA[0], BG.PROPOSTA[1], cap))["sharpe"] + fissa = BG.met(gtaa_returns(capital=cap, band_usd=50.0, every=5))["sharpe"] + assert abs(frazionaria - fissa) < abs( + BG.met(BG.run_cell(BG.PROPOSTA[0], BG.PROPOSTA[1], 3_000.0))["sharpe"] + - BG.met(gtaa_returns(capital=3_000.0, band_usd=50.0, every=5))["sharpe"]) diff --git a/tests/test_lumpsum_split.py b/tests/test_lumpsum_split.py new file mode 100644 index 0000000..6ff71a2 --- /dev/null +++ b/tests/test_lumpsum_split.py @@ -0,0 +1,153 @@ +"""Test del filone lump-sum + split di venue (scripts/research/r0727_lumpsum_split.py). + +Il test piu' importante e' `test_riproduce_esattamente_i_numeri_del_26_07`: la macchineria e' una +GENERALIZZAZIONE di `r0726_venue_risk.simulate` (capitale iniziale e deposito diventano +parametri). Se la generalizzazione avesse cambiato anche solo l'ordine di consumo dell'RNG, i +numeri pubblicati e quelli nuovi non sarebbero piu' confrontabili — e la tabella nuova +sembrerebbe comunque plausibile. + +Il secondo che conta e' `test_sotto_la_soglia_lo_split_non_esiste`: uno 'split' in cui la seconda +gamba e' sotto il minimo eseguibile non e' uno split, e' cash su un secondo conto — che protegge +dal fallimento del venue ma NON e' lo sleeve che i numeri assumono. +""" +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 r0727_lumpsum_split as LS # noqa: E402 + + +@pytest.fixture(scope="module") +def series(): + """Serie sintetiche a drift noto: i test sulla struttura non devono dipendere dal feed.""" + idx = pd.date_range("2020-01-01", periods=1200, freq="D", tz="UTC") + rng = np.random.default_rng(7) + return { + "Deribit": pd.Series(rng.normal(0.0005, 0.008, len(idx)), index=idx), + "IB": pd.Series(rng.normal(0.0002, 0.004, len(idx)), index=idx), + "Hyperliquid": pd.Series(rng.normal(0.0003, 0.010, len(idx)), index=idx), + } + + +# =========================================================================== +# la replica — senza questa, la tabella nuova non e' confrontabile con quella vecchia +# =========================================================================== +def test_riproduce_esattamente_i_numeri_del_26_07(series): + import r0726_venue_risk as VR + cfg = {"Deribit": 1.0} + kw = dict(n_paths=200, years=3, seed=LS.SEED) + mine = LS.simulate(cfg, series, 0.01, lump_usd=0.0, dep_eur=VR.DEP_EUR, **kw) + theirs = VR.simulate(cfg, series, 0.01, **kw) + assert mine["p_reach"] == theirs["p_reach"] + assert mine["p_all_dead"] == theirs["p_all_dead"] + assert mine["median_end"] == theirs["median_end"] + + +def test_il_deposito_di_default_e_quello_delle_traiettorie_pubblicate(series): + """€250/mese: la colonna con cui si confronta tutto il piano.""" + import inspect + import r0726_venue_risk as VR + assert inspect.signature(LS.simulate).parameters["dep_eur"].default == VR.DEP_EUR + + +# =========================================================================== +# il lump-sum +# =========================================================================== +def test_un_lump_sum_non_puo_peggiorare_il_traguardo(series): + kw = dict(n_paths=300, years=5, seed=1) + senza = LS.simulate({"Deribit": 1.0}, series, 0.0, lump_usd=0.0, **kw) + con = LS.simulate({"Deribit": 1.0}, series, 0.0, lump_usd=10_000.0, **kw) + assert con["p_reach"] >= senza["p_reach"] + assert con["median_end"] > senza["median_end"] + + +def test_con_un_conto_solo_la_rovina_non_dipende_dal_capitale(series): + """Fatto strutturale, non aneddoto: con UN venue 'almeno un fallimento' COINCIDE con + 'perso tutto', e quella probabilita' e' una proprieta' del TEMPO di esposizione. Se un + giorno questa uguaglianza si rompesse, il modello di rovina e' cambiato.""" + kw = dict(n_paths=400, years=5, seed=3) + a = LS.simulate({"Deribit": 1.0}, series, 0.02, lump_usd=0.0, **kw) + b = LS.simulate({"Deribit": 1.0}, series, 0.02, lump_usd=20_000.0, **kw) + assert a["p_all_dead"] == b["p_all_dead"] + + +def test_senza_deposito_e_senza_lump_non_si_arriva(series): + r = LS.simulate({"Deribit": 1.0}, series, 0.0, lump_usd=0.0, dep_eur=0.0, + n_paths=200, years=5, seed=2) + assert r["p_reach"] == 0.0 + + +# =========================================================================== +# lo split, e la soglia che lo rende reale +# =========================================================================== +def test_sotto_la_soglia_lo_split_non_esiste(): + """A $6.050 servirebbe il 50% su IB per fare $3.000: oltre il tetto, quindi None. + Dichiararlo 'possibile' vorrebbe dire modellare uno sleeve che non e' eseguibile.""" + assert LS.split_config(600.0) is None + assert LS.split_config(6_050.0) is None + + +def test_sopra_la_soglia_e_il_capitale_a_scegliere_la_quota(): + cfg = LS.split_config(11_500.0) + assert cfg is not None + assert cfg["IB"] == pytest.approx(3_000.0 / 11_500.0, rel=1e-9) # ~26%, non il 25% preferito + assert cfg["Deribit"] + cfg["IB"] == pytest.approx(1.0) + + +def test_a_capitale_ampio_si_torna_alla_quota_raccomandata(): + cfg = LS.split_config(50_000.0) + assert cfg["IB"] == LS.IB_SHARE + + +def test_la_quota_ib_non_supera_mai_il_tetto(): + for tot in (8_600.0, 10_000.0, 12_000.0, 100_000.0): + cfg = LS.split_config(tot) + if cfg: + assert cfg["IB"] <= LS.IB_SHARE_MAX + 1e-12 + + +def test_due_conti_riducono_la_rovina(series): + kw = dict(n_paths=600, years=6, seed=5, lump_usd=10_900.0) + conc = LS.simulate({"Deribit": 1.0}, series, 0.05, **kw) + split = LS.simulate(LS.split_config(11_500.0), series, 0.05, **kw) + assert split["p_all_dead"] < conc["p_all_dead"] + + +def test_la_rovina_non_dipende_da_cosa_sta_sulla_seconda_gamba(series): + """SPLIT-GTAA e SPLIT-CASSA devono avere la stessa P(perso tutto): la protezione viene dal + numero di conti, non dai loro ritorni. E' la lettura centrale della sezione 2-bis.""" + kw = dict(n_paths=500, years=6, seed=11, lump_usd=10_900.0) + cfg = LS.split_config(11_500.0) + zero = {**series, "IB": series["IB"] * 0.0} + assert (LS.simulate(cfg, series, 0.03, **kw)["p_all_dead"] + == LS.simulate(cfg, zero, 0.03, **kw)["p_all_dead"]) + + +# =========================================================================== +# costi dichiarati +# =========================================================================== +def test_haircut_toglie_il_drift_dichiarato(series): + s = LS.haircut(series["IB"], 0.009) + drift = (series["IB"].mean() - s.mean()) * 365 + assert drift == pytest.approx(0.009, rel=1e-9) + + +def test_il_haircut_ib_somma_taglia_piccola_e_veicolo_ucits(): + """0.8pp (CAGR 3.8%->3.0% a $3k, misurato 25/07) + 0.1pp (drag UCITS, misurato 26/07).""" + assert LS.IB_HAIRCUT_YR == pytest.approx(0.009) + + +def test_il_bersaglio_e_lo_stesso_per_entrambi_i_bracci(): + """Conservativo CONTRO lo split (che avrebbe un muro leggermente diverso): se un giorno si + usassero due bersagli, il confronto smetterebbe di essere leggibile.""" + import r0726_venue_risk as VR + assert LS.TARGET == VR.TARGET diff --git a/tests/test_monitor_health.py b/tests/test_monitor_health.py new file mode 100644 index 0000000..1086750 --- /dev/null +++ b/tests/test_monitor_health.py @@ -0,0 +1,175 @@ +"""Test della sorveglianza dei forward-monitor (src/live/monitor_health.py). + +Tre gate pre-registrati (STATARB 27/09, XSR01 23/10, DVOLSPREAD 24/10) si decidono leggendo serie +che nessuno sorvegliava. Questi test coprono le due modalita' di guasto (coda ferma, buchi +interni) e — obbligatoriamente — i **controlli positivi**: un rilevatore che non ha mai segnalato +nulla e' indistinguibile da uno rotto finche' non si prova che sa segnalare. + +Il test che conta piu' di tutti e' `test_una_serie_bucata_non_passa_per_fresca`: e' l'unico guasto +che una guardia di sola freschezza lascerebbe passare, ed e' quello che falsifica un gate senza +farsi notare. +""" +from __future__ import annotations + +import json +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from src.live.monitor_health import ( # noqa: E402 + MIN_COVERAGE, + MonitorSpec, + alerts_from, + assess, + check_all, + expected_bars, + read_stamps, +) + +NOW = datetime(2026, 7, 27, 12, 0, tzinfo=timezone.utc) +DAILY = MonitorSpec("t", "t", "returns.jsonl", 24.0) +GATED = MonitorSpec("t_gate", "t_gate", "returns.jsonl", 24.0, gate="2026-10-23 — gate XSR01") +EQ = MonitorSpec("t_eq", "t_eq", "equity.csv", 24.0, calendar="equity", max_age_h=120.0) + + +def _daily(n: int, end: datetime = NOW, step_h: float = 24.0, skip: set[int] | None = None): + """n timestamp a passo `step_h` che finiscono a `end`, opzionalmente con buchi.""" + skip = skip or set() + return [end - timedelta(hours=step_h * (n - 1 - i)) for i in range(n) if i not in skip] + + +# =========================================================================== +# stato normale +# =========================================================================== +def test_una_serie_completa_e_fresca_e_ok(): + r = assess(DAILY, _daily(30), NOW) + assert r["status"] == "OK" and r["coverage"] == 1.0 + + +def test_ok_non_produce_allarmi(): + assert alerts_from([assess(DAILY, _daily(30), NOW)]) == [] + + +# =========================================================================== +# CONTROLLI POSITIVI — la guardia sa segnalare? +# =========================================================================== +def test_un_monitor_fermo_viene_visto(): + """Il guasto piu' semplice: il cron non gira piu'. Ultima barra di 5 giorni fa.""" + r = assess(DAILY, _daily(30, end=NOW - timedelta(days=5)), NOW) + assert r["status"] == "FERMO" + assert alerts_from([r]) + + +def test_una_serie_bucata_non_passa_per_fresca(): + """IL test: il monitor gira, l'ultima barra e' di stanotte, ma ha perso il 30% delle barre di + mezzo. Una guardia di sola freschezza direbbe OK, e il gate verrebbe deciso su meta' serie.""" + stamps = _daily(30, skip=set(range(5, 15))) # 20 barre su 30 attese + r = assess(DAILY, stamps, NOW) + assert r["status"] == "BUCATO" + assert r["coverage"] < MIN_COVERAGE + assert r["age_h"] < 24.0 # ...ed e' fresca: la freschezza non basta + + +def test_una_serie_assente_e_un_allarme_non_un_silenzio(): + """'Non vedo' non e' 'va tutto bene' — se lo stato e' perso, e' un evento.""" + r = assess(DAILY, [], NOW) + assert r["status"] == "ASSENTE" + assert alerts_from([r]) + + +def test_la_soglia_di_copertura_e_quella_del_veto_dvolspread(): + """Riusata di proposito: una soglia diversa per monitor renderebbe i gate non confrontabili.""" + assert MIN_COVERAGE == 0.80 + assert assess(DAILY, _daily(30, skip={5, 6, 7, 8, 9}), NOW)["status"] == "OK" # 83% + assert assess(DAILY, _daily(30, skip=set(range(5, 12))), NOW)["status"] == "BUCATO" # 77% + + +# =========================================================================== +# giovinezza, che non e' salute +# =========================================================================== +def test_un_monitor_con_una_sola_barra_non_e_giudicabile(): + r = assess(DAILY, _daily(1), NOW) + assert r["status"] == "NUOVO" and r["coverage"] is None + + +def test_nuovo_non_allerta_ma_non_e_ok(): + """Un monitor appena partito non deve suonare, ma non deve nemmeno risultare sano: + XSR01 e DVOLSPREAD hanno 2 barre e mancano mesi al loro gate.""" + r = assess(DAILY, _daily(1), NOW) + assert alerts_from([r]) == [] and r["status"] != "OK" + + +# =========================================================================== +# cadenze: sbagliarle vuol dire un falso allarme a settimana +# =========================================================================== +def test_un_monitor_orario_non_viene_scambiato_per_giornaliero(): + """paper_prevday registra a barra oraria: 864 barre in 36 giorni sono complete, non un + eccesso. Con la cadenza sbagliata la copertura uscirebbe al 2400%.""" + spec = MonitorSpec("h", "h", "returns.jsonl", 1.0) + r = assess(spec, _daily(864, step_h=1.0), NOW) + assert r["status"] == "OK" and r["expected"] == 864 + + +def test_il_weekend_non_ferma_un_monitor_di_borsa(): + """paper_combo dipende dalle gambe IB: venerdi' e' l'ultima barra fino a lunedi'. Contare + il weekend come eta' produrrebbe un allarme ogni lunedi' mattina.""" + ven = datetime(2026, 7, 24, 0, 0, tzinfo=timezone.utc) + lun = datetime(2026, 7, 27, 12, 0, tzinfo=timezone.utc) + stamps = [ven - timedelta(days=k) for k in range(20, 0, -1)] + [ven] + assert assess(EQ, stamps, lun)["status"] != "FERMO" + + +def test_le_barre_attese_su_calendario_di_borsa_escludono_il_weekend(): + lun = datetime(2026, 7, 20, tzinfo=timezone.utc) + ven = datetime(2026, 7, 24, tzinfo=timezone.utc) + assert expected_bars(lun, ven, 24.0, "equity") == 5 # non 5 giorni solari a caso: 5 sedute + assert expected_bars(lun, ven, 24.0, "crypto") == 5 + + +# =========================================================================== +# escalation: il danno non e' il monitor, e' il gate +# =========================================================================== +def test_un_monitor_che_alimenta_un_gate_lo_dice_nell_allarme(): + a = alerts_from([assess(GATED, _daily(30, end=NOW - timedelta(days=5)), NOW)]) + assert a and "gate" in a[0].lower() and "XSR01" in a[0] + + +# =========================================================================== +# lettura dei formati reali +# =========================================================================== +def test_legge_jsonl_con_ts_in_millisecondi(tmp_path): + p = tmp_path / "returns.jsonl" + p.write_text("\n".join(json.dumps({"ts": 1785024000000 + i * 86_400_000, "net": 0.0}) + for i in range(3))) + s = read_stamps(p) + assert len(s) == 3 and s[0] == datetime(2026, 7, 26, tzinfo=timezone.utc) + + +def test_legge_equity_csv_con_data_in_prima_colonna(tmp_path): + p = tmp_path / "equity.csv" + p.write_text("date,equity\n2026-07-24 00:00:00+00:00,2000.0\n2026-07-27 00:00:00+00:00,2010.0\n") + s = read_stamps(p) + assert len(s) == 2 and s[-1].day == 27 + + +def test_una_riga_corrotta_non_fa_esplodere_la_guardia(tmp_path): + """Una guardia che muore su una riga malformata smette di guardare proprio quando + qualcosa e' andato storto.""" + p = tmp_path / "returns.jsonl" + p.write_text('{"ts": 1785024000000}\nnon-json\n{"ts": 1785110400000}\n') + assert len(read_stamps(p)) == 2 + + +# =========================================================================== +# integrazione sullo stato REALE del progetto +# =========================================================================== +def test_i_monitor_reali_sono_leggibili_e_giudicati(): + """Non asserisce che siano sani (dipende da quando gira il test): asserisce che la guardia + li trova e si pronuncia su ognuno — il fallimento silenzioso sarebbe una lista vuota.""" + rows = check_all(ROOT, now=NOW) + assert len(rows) == 6 + assert all(r["status"] in ("OK", "NUOVO", "FERMO", "BUCATO", "ASSENTE") for r in rows) + assert {"paper_xsr", "paper_statarb", "paper_dvolspread"} <= {r["name"] for r in rows}