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:
@@ -74,6 +74,8 @@ data/paper_combo/
|
|||||||
data/paper_statarb/
|
data/paper_statarb/
|
||||||
data/paper_xsr/
|
data/paper_xsr/
|
||||||
data/paper_dvolspread/
|
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)
|
# log esecuzioni del book live (stato runtime, contiene fill/fee del conto reale)
|
||||||
data/live/
|
data/live/
|
||||||
|
|||||||
@@ -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
|
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
|
(nessun test, nessuna eccezione, output plausibile); (d) **la copertura per colonna e' la prima
|
||||||
diagnostica di un outer-join**.
|
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:
|
- **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.
|
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.**
|
- **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.
|
||||||
@@ -28,5 +28,12 @@ mkdir -p logs
|
|||||||
# Sorveglianza dell'EDGE del book live (criteri di kill pre-registrati,
|
# 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.
|
# tarati in scripts/research/r0726_edge_death.py). Riporta e allerta, non spegne nulla.
|
||||||
uv run python scripts/live/edge_watch.py --quiet || true
|
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') ====="
|
echo "===== done $(date -u '+%H:%M:%SZ') ====="
|
||||||
} >> logs/cron_daily.log 2>&1
|
} >> logs/cron_daily.log 2>&1
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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__
|
||||||
@@ -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"])
|
||||||
@@ -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
|
||||||
@@ -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}
|
||||||
Reference in New Issue
Block a user