research(capitale): il lump-sum vale 2.45x, e la protezione dalla rovina non passa da GTAA01

Quattro filoni chiesti dall'operatore ("proposte"). Book, pesi, config: INVARIATI.

1. LUMP-SUM + VENUE (r0727_lumpsum_split.py). Tutte le traiettorie del 25-26/07 avevano
   START=600 cablato: mai misurato un versamento iniziale, mentre ~10k EUR stanno fermi
   altrove. Macchineria validata: con lump 0 riproduce IDENTICI i numeri del 26/07.
   - 10k EUR oggi e mai piu' nulla -> traguardo 17.2a, P 62%, rendita 61.58 EUR/g
   - equivalenza onesta: +154 EUR/mese per 13 anni = 24.523 EUR, cioe' 2.45x
     (la prima stesura misurava i versamenti risparmiati: numero giusto, domanda sbagliata)
   - col rischio venue: a 11.500$ lo split e' possibile (quota IB 26%, non 25%) e taglia
     P(perso tutto) da 18.4% a 3.5% a p=1%, costando 1.9-2.6pp di P(arrivare)
   - SPLIT-CASSA: seconda gamba ferma costa altri 0.6-0.8pp e protegge IDENTICO
     -> la protezione non e' bloccata dal PRIIPs: serve un CONTO, non uno sleeve

2. FEE WATCH (scripts/live/fee_watch.py). Nuovo schema Deribit dal 1 agosto senza numeri
   pubblicati -> sorvegliante invece di promemoria. Legge il tier base dall'endpoint
   pubblico (oggi taker 5.00 bps), applica la regola congelata e allerta sui cambiamenti.

3. MONITOR HEALTH (src/live/monitor_health.py). Tre gate pre-registrati si decidono su
   serie forward di cui una sola era sorvegliata. Misura coda E buchi interni: una serie
   bucata ma fresca passa qualunque guardia di freschezza.

4. BANDA GTAA01 25% VALIDATA (r0727_gtaa_band_gate.py). 30 celle, 29.9 anni, dpy=252.
   Non e' selection-on-holdout (4/30 IS, 5/30 OOS), DSR 0.999, tracking OK ma AL BORDO.
   Il modo di fallire non e' il de-levering (la vol non scende) ma la perdita di tracking.
   Impatto sul book: zero -> REBAL_BAND_USD non toccato, si applica al deploy.

Aggiunto anche il bullet edge_watch, cablato il 26/07 e mai finito in CLAUDE.md.

Test: 56 nuovi, 504/504 verdi.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-07-27 14:14:39 +00:00
parent 3f0812c7fc
commit fd05307c96
13 changed files with 1963 additions and 0 deletions
+2
View File
@@ -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/
+92
View File
@@ -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.**
@@ -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.
+7
View File
@@ -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
+200
View File
@@ -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())
+59
View File
@@ -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())
+260
View File
@@ -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()
+291
View File
@@ -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()
+173
View File
@@ -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
+110
View File
@@ -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__
+164
View File
@@ -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"])
+153
View File
@@ -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
+175
View File
@@ -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}