Compare commits
11 Commits
e5052a690f
...
993d17556a
| Author | SHA1 | Date | |
|---|---|---|---|
| 993d17556a | |||
| 2beb11b764 | |||
| 32e1649dcb | |||
| 418f523be7 | |||
| 21fe289383 | |||
| 801bd13f10 | |||
| c21a0d4058 | |||
| 31f82e1730 | |||
| cb40ab3e20 | |||
| 05a698a83e | |||
| a6b5756cc0 |
@@ -68,8 +68,8 @@ lorda massima 1,0x**, e nessun livello di capitale cambia il profilo di rischio
|
||||
2 giorni (blocca) · `skh_feed_max_age_min` 30 (allerta, **non** blocca) · alert Telegram.
|
||||
|
||||
📌 **Dal 2026-08-26 l'equity del book e' il TOTALE cross-collateral** (USDC + USDE valutato
|
||||
all'indice pubblico `usde_usdc`): il conto tiene **500 USDE** (test di eligibilita' reward, verdetto
|
||||
29/08 — GATE USDE-01 in §4). Autorita' dei parametri: sezione `usde` di `config/live.json`
|
||||
all'indice pubblico `usde_usdc`): il conto tiene **500 USDE** (**IDONEO** dal 28/08, primo reward
|
||||
+0,052055 USDE; quota rinviata a lunedi' 31/08 — GATE USDE-01 in §4). Autorita' dei parametri: sezione `usde` di `config/live.json`
|
||||
(haircut 10%, tetto ALLERTA quota 50%, soglie depeg 0,99/0,95 coi criteri in `_nota_usde`), letta
|
||||
da `src/live/usde.py` — shadow (sizing orario) e `usde_watch` (sorveglianza 12:35 UTC, cron
|
||||
proprio, sotto `monitor_health`) derivano entrambi da li'. ⚠️ Il difetto d'origine: `shadow._equity`
|
||||
@@ -153,6 +153,7 @@ CLAUDE.md", la risposta e' la colonna *cosa la riapre*.
|
||||
| **Non de-esporre il weekend** (porta il 38% del gross di TP01 nel 31% del tempo) | 17/07 | — | ogni proposta "risk-off weekend" parte **REFUTED** salvo null de-levering superato |
|
||||
| **Peso 75/25 confermato 3 volte** (24/07, 26/07, 24/07-follow-up) | 26/07 | — | `weights_tilt_null` superato, che finora **fallisce** |
|
||||
| **Fee Deribit: ≤5 bps/lato → non si tocca nulla; >10 bps → rivedere il peso SKH01** (regola decisa *prima* di vedere il numero) | 26/07 | — | il sorvegliante `fee_watch` allerta da solo |
|
||||
| **GTAA01: decisione tenere/bloccare RINVIATA a $15k di book** — sotto quella soglia lo sleeve **non e' deployabile** (`GTAA_MIN_CAPITAL` $3.000 allocati al peso 20%), quindi finora e' manutenzione senza beneficio incassabile. Contributo misurato a iso-rischio **+0,095 / +0,124** di Sharpe secondo la fase: **reale e sempre positivo**, e delle stesse dimensioni dello spread fra le baseline (0,12) | 28/08 | «GTAA01 non contribuisce» — la misura dice il contrario | **il book a $15k**, che il giornale sorveglia da solo (`journal.SOGLIE_CAPITALE`) |
|
||||
| **Gradino di leva NON autorizzato oggi.** 1,25x autorizzabile *a condizione*; **1,50x BOCCIATO** (peggior giorno strutturale 21,48% > 20%); **k max difendibile 1,40** | 23/08 | il 1,50x | costruire prima una **chiave di scala esplicita** in config **e** rifare `r0726_fee_sensitivity` |
|
||||
|
||||
⚠️ **La regola *"ogni cambio di scala passa dal cap di config, non da `target_vol`"* NON E'
|
||||
@@ -170,7 +171,7 @@ sull'hold-out; cambiarne le soglie guardando l'esito, pure.
|
||||
|
||||
| gate | data | criterio | stato |
|
||||
|---|---|---|---|
|
||||
| **USDE-01 (eligibilita' reward)** | 2026-08-29 | ≥1 reward entro il 29/08 (rilevati da `usde_watch` per delta di equity USDE al netto dei trade spot) → IDONEO e si apre la decisione di QUOTA (dell'operatore; tetto allerta 50%, mai 100% — R1 emittente non recuperabile); zero → si riconverte e la pista si chiude | ✅ pre-registrato il 26/08 PRIMA dell'esito (diario `2026-08-26-usde-analisi`); 500 USDE in conto dalle 13:04Z, baseline watch 14:17Z, verdetto applicato dal cron 12:35 |
|
||||
| **USDE-01 (eligibilita' reward)** | 2026-08-29 | ≥1 reward entro il 29/08 → IDONEO e si apre la decisione di QUOTA (dell'operatore; tetto allerta 50%, mai 100% — R1 emittente non recuperabile); zero → si riconverte e la pista si chiude | ✅ **CHIUSO IDONEO** il 2026-08-28 12:35Z: primo reward **+0,052055 USDE** rilevato per delta al netto dei trade, coi criteri pre-registrati il 26/08 PRIMA dell'esito. 📌 **La QUOTA e' RINVIATA a lunedi' 31/08 su decisione dell'operatore** (29/08): su UNA finestra il tasso implicito e' **3,80%** annuo, su DUE e' **1,90%** — il 27/08 aveva pagato **zero** — e la differenza e' tutta la decisione. `usde_watch.rendimento()` stampa l'APR **col suo n** e da lunedi' `quota_da_decidere()` lo chiede ogni giorno finche' non si decide |
|
||||
| **STATARB** | 2026-09-27 | soglie 24/07 (non toccate) + diagnostica statica sempre-short | ✅ monitor riparato e rigenerato il 26/08: la serie vera da' Sharpe **−1,61** a oggi → coi criteri invariati punta al **RITIRO**. Si legge il 27/09, non prima |
|
||||
| **XSR01** | 2026-10-23 | Sharpe ≥1,0 **E** haircut ≤40% — dal 26/08 il numero decisivo e' `r0826_xsr_haircut.py` a **pavimento $10**, citato con la frazione di ordini eseguiti — poi `weights_tilt_null` e **capitale ≥$20k** | ✅ **riscritto il 26/08 su decisione dell'operatore** (dichiarato prima dell'esito, stringe: diario `2026-08-26-xsr01-gate-riscritto`): capitale allineato a "100% Deribit fino a $20k"; serie forward rigenerata (31 barre vere, Sharpe −2,72). Sotto la lente RENDITA il sleeve resta **eguagliato da un conto remunerato** (📌 sotto) |
|
||||
| **DVOLSPREAD** | kill 2026-10-24 se Sh<−0,50; decisione 2027-01-24 | (a) Sharpe>0 (b) marginale ADDS+robust_oos+insample_edge (c) **DSR ≥0,95** (d) `weights_tilt_null`; **veto** se barre attive <80% → si estende, non si decide | ✅ serie rigenerata il 26/08 (−4,41 a oggi, era −14,73 sulla serie rotta); il veto barre-attive ora conta barre VERE e la guardia PREMATURO sorveglia il monitor |
|
||||
@@ -202,22 +203,42 @@ d'ancora in modo diverso (nella differenza si cancella in parte, nel livello per
|
||||
`paper_portfolio` NON rigenerato (GTAA su ADJUSTED_LAST: replay ≠ serie registrata, P12) —
|
||||
storia pre-fix dichiarata corrotta, coda non chiusa tolta. ⚠️ Nel fix, D6 pagata di nuovo:
|
||||
`asi8` in pandas 3 e' in `us`, non `ns` — blindato con test su tre risoluzioni.
|
||||
2. 🚨 **Il trasporto degli allarmi e' un punto singolo di guasto** — 6,9% di invii falliti (2/29).
|
||||
Fatte il 23/08: retry opzionale in `send()` e registrazione del motivo nel punto in cui veniva
|
||||
ingoiato. **Resta:** `alerted=True` marcato **prima** dell'invio → un 🚨 perso e' perso per
|
||||
l'episodio intero (gli episodi storici durano 200-2.324 ore). Cambiarlo **cambia il comportamento**
|
||||
→ decisione dell'operatore.
|
||||
2. ✅ **RISOLTO (2026-08-28, decisione dell'operatore).** Il trasporto degli allarmi era un punto
|
||||
singolo di guasto — 6,9% di invii falliti (2/29) e `alerted=True` marcato **prima** dell'invio,
|
||||
quindi un 🚨 perso era perso per l'**episodio intero** (200-2.324 ore). Ora `venue_watch.run_once`
|
||||
riceve il **sender iniettato** e il marcatore «gia' detto» si scrive **solo dopo un invio
|
||||
riuscito**; su fallimento si disfano i soli marcatori — non le misure — e per il lock **le ore
|
||||
continuano a correre**, cosi' una manutenzione che sfora la grazia sale ad ALERT anche col
|
||||
trasporto giu'. `notify()` accetta `tentativi` (venue_watch: 3) e l'esito finisce nel log.
|
||||
⚠️ Resta scoperto **ogni altro chiamante di `notify()`**: la disciplina «commit dopo l'invio»
|
||||
e' oggi solo in `venue_watch`.
|
||||
3. ✅ **RISOLTO (2026-08-26, decisione dell'operatore: opzione "riscrivere adesso").** Gate
|
||||
XSR01 riscritto in modo dichiarato, prima dell'esito, in direzione che stringe: capitale
|
||||
$5k → **$20k** (riconcilia con la decisione 26/07, che e' posteriore e governa), gamba haircut
|
||||
letta da `r0826_xsr_haircut.py` a pavimento $10 con la frazione di ordini eseguiti accanto
|
||||
(misurata: haircut FULL −1,1% con **22% di eseguiti** — la guardia da sola era vacua), soglie
|
||||
numeriche INVARIATE. Diario `2026-08-26-xsr01-gate-riscritto.md`.
|
||||
4. ❓ **Due domande al commercialista, aperte** (valgono ~$22k di muro e ~€30/mese di versamento):
|
||||
(a) i derivati Deribit (inverse, margine e regolamento in cripto, sede extra-UE) stanno in
|
||||
`c-sexies` (33%) o `c-quater` (26%)? E il collaterale sconta il 2‰ al 31/12? (b) i payout di una
|
||||
prop firm: **ne' `c-sexies` ne' `c-quater`** — le fonti convergono su **lavoro autonomo**, nessuna
|
||||
prassi ufficiale. *Non sono pareri fiscali: sono domande da porre.*
|
||||
4. ◐ **(a) RISPOSTA TROVATA ALLA FONTE (2026-08-28), (b) resta aperta.** *Non sono pareri fiscali.*
|
||||
**(a) I derivati stanno in `c-quater` → 26%, non in `c-sexies` → 33%.** Circolare AdE **30/E del
|
||||
27/10/2023**, §2.3.2 e §3, verbatim: «In generale, i redditi derivanti da **contratti derivati,
|
||||
ancorche' aventi come sottostante cripto-attivita'**, costituiscono redditi diversi ai sensi
|
||||
dell'articolo 67, comma 1, **lettera c-quater)**» e «qualora le cripto-attivita' costituiscano il
|
||||
sottostante di un contratto derivato (ad es. contract for difference o **future**) i redditi
|
||||
derivanti da tali strumenti finanziari costituiscono redditi diversi ai sensi dell'articolo
|
||||
c-quater)». La circolare conferma esplicitamente che la riforma 2023 non sposta nulla: «tali
|
||||
contratti **non rientrano** nell'ambito di applicazione della lettera c-sexies». ⚠️ Conseguenza:
|
||||
**le minusvalenze su derivati NON si compensano con le plusvalenze cripto** (categorie diverse) e
|
||||
non c'e' franchigia. **Il collaterale sconta il 2‰**: Deribit non e' intermediario residente,
|
||||
quindi non applica il bollo e scatta l'*imposta sul valore delle cripto-attivita'* sul valore al
|
||||
31/12, in RW (a $2.068 fa **~$4/anno**: obbligo dichiarativo, non un costo). 📌 E la conversione
|
||||
**USDC→USDE non e' fattispecie realizzativa** — §3.1: «dal 1° gennaio 2023 non costituisce
|
||||
fattispecie realizzativa lo scambio di una cripto-valuta con un'altra», col valore di carico che
|
||||
si trasferisce. **Resta da chiedere:** se il perpetual *inverse* regolato in cripto sia il caso
|
||||
letterale della circolare (che nomina CFD e future) o serva un passaggio in piu'.
|
||||
**(b) I payout di una prop firm: ancora aperta.** Le fonti convergono su **lavoro autonomo**
|
||||
(partita IVA, ATECO, forfettario 5% per i primi 5 anni poi 15%, imponibile da coefficiente di
|
||||
redditivita'), **non** redditi diversi al 26%. **Nessuna prassi ufficiale**, e il coefficiente
|
||||
esatto dipende dal codice ATECO.
|
||||
5. ✅ **FATTO (2026-08-26):** `test_leva_massima_da_config...` cancellato con nota in
|
||||
`tests/test_fee_sensitivity.py` — misurava `frac · n_asset`, con una chiave di scala avrebbe
|
||||
continuato a passare smettendo di controllare. La guardia giusta e' il tetto sul PRODOTTO di
|
||||
@@ -260,6 +281,18 @@ d'ancora in modo diverso (nella differenza si cancella in parte, nel livello per
|
||||
definizione, patchare la costante non bastava) e `docs/journal/`; per `book_executions.jsonl`
|
||||
(caricato ad-hoc via importlib, irraggiungibile da conftest) impronta a inizio suite e verifica
|
||||
a fine suite, col falso positivo possibile (fill reale del cron :47) dichiarato nel messaggio.
|
||||
13. ⚠️ **GTAA01 gira su una finestra che ROTOLA, e la fase di ribilanciamento cambia da sola ogni
|
||||
notte** (misurato 2026-08-28, `r0828_gtaa_band_phase.py`). IB serve una finestra di **30 anni**
|
||||
e SPY e' al muro: la sua prima barra avanza di un giorno di borsa ogni notte (1996-07-02 il
|
||||
24/06 → 1996-09-04 il 28/08). `gtaa._gated_returns` ribilancia su `i % every == 0`, cioe' sulla
|
||||
**posizione nell'array**: se la prima barra scivola si ri-fasa ogni decisione di trent'anni.
|
||||
Effetto misurato: su una finestra **chiusa** (pre-2015) lo Sharpe si e' mosso fino a **0,076**
|
||||
a codice fermo, e la banda scelta al buio cambia fra 25% / 40% / 60% da una notte all'altra.
|
||||
**Ha gia' ucciso due criteri del gate (A)** (07/08 e 28/08). **Quantificato e innocuo OGGI**
|
||||
(D5): a livello di portafoglio l'escursione sulle 5 fasi vale **0,029** di Sharpe, dentro la
|
||||
banda dichiarata [1,81-2,12]. QQQ e IWM arriveranno allo stesso muro **fra 2,5 e 3,7 anni**.
|
||||
Riparazione (ancorare la fase al calendario) rinviata con la decisione su GTAA01: cambierebbe
|
||||
**tutti** i numeri registrati dello sleeve.
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
# 2026-08-28 — La fase che ruota, gli allarmi che si perdevano, e una risposta fiscale
|
||||
|
||||
*Scritto il 2026-08-28. Numeri riprodotti dagli script citati; le letture in prosa sono firmate
|
||||
come tali (P13).*
|
||||
|
||||
## In una riga
|
||||
|
||||
Un test rosso su GTAA01 non parlava di GTAA01: parlava del fatto che **la finestra "congelata"
|
||||
pre-2015 rotola da sola ogni notte**. Nel frattempo si e' chiuso il debito del trasporto degli
|
||||
allarmi, e la prima delle due domande al commercialista ha trovato una risposta alla fonte.
|
||||
|
||||
---
|
||||
|
||||
## 1. Il gate (A) della banda GTAA01 e' una moneta — la finestra rotola
|
||||
|
||||
`r0828_gtaa_band_phase.py`. Replica bit-exact della produzione verificata (max|Δ| = 0.00e+00 su
|
||||
7.544 barre). Codice fermo dal 07/08: nessun commit su `src/portfolio/gtaa.py` ne' su
|
||||
`r0727_gtaa_band_gate.py`. Si e' mosso il DATO.
|
||||
|
||||
**(0) La finestra in-sample non e' ferma.** IB serve una finestra **rotolante di 30 anni**. SPY e'
|
||||
l'unica gamba al muro (30,0 anni) e nel `logs/cron_daily.log` la sua data d'inizio avanza di un
|
||||
giorno di borsa ogni notte: `1996-07-02` il 24/06 → `1996-09-04` oggi. QQQ e IWM arriveranno allo
|
||||
stesso muro fra **2,5 e 3,7 anni**.
|
||||
|
||||
**(1) L'amplificatore e' la fase.** `_gated_returns` ribilancia su `i % every == 0`, dove `i` e' la
|
||||
**posizione nell'array**. Se la prima barra scivola, si ri-fasa ogni decisione di ribilanciamento
|
||||
di trent'anni di storia.
|
||||
|
||||
| misura | valore |
|
||||
|---|---|
|
||||
| spostamento max dello Sharpe in-sample dal 07/08 (finestra CHIUSA, codice fermo) | **0,076** |
|
||||
| banda scelta al buio in 10 notti consecutive | 25% · 40% · 60% |
|
||||
| notti col margine sotto il pavimento dichiarato (0,01) | **4/10** (minimo 0,0026) |
|
||||
| a dato FISSO, muovendo solo la fase | 25% su 3 fasi, 60% su 2 |
|
||||
|
||||
Il criterio decideva su ~0,007-0,015 di Sharpe mentre il rumore che lo scuote vale fino a 0,076.
|
||||
E' lo stesso modo di fallire del criterio a ranghi ritirato il 07/08 (che decideva su 0,00116), in
|
||||
un costume nuovo.
|
||||
|
||||
## 2. Criterio (C): la mediana delle 5 fasi
|
||||
|
||||
`r0828_gtaa_band_median_phase.py`. ⚠️ **Scelto DOPO aver visto la tabella delle fasi**: misura lo
|
||||
strumento, non valida la banda. Pavimento tenuto a 0,01, invariato.
|
||||
|
||||
- (i) scelta stabile su 10 notti: **SI** — `60%` in tutte e dieci
|
||||
- (ii) margine sempre ≥ 0,01: **SI** — minimo 0,0197
|
||||
- (iii) potenza (al buio ≠ hold-out): **SI** — 60% contro 40%
|
||||
|
||||
Escursione dello Sharpe in-sample su 10 notti: **−74/−99%** per ogni banda. Lo strumento regge.
|
||||
**Ma la cella che sceglie non e' la proposta:** al buio esce il 60%, il 25% vince **0 notti su 10**.
|
||||
Da tenere accanto: sull'hold-out il 60% e' **penultimo** (0,7430 contro 0,9315 del 40%), coerente
|
||||
con lo Spearman IS/OOS ~0 misurato il 07/08 — la scelta in-sample **non predice**.
|
||||
|
||||
**Contorno che vale piu' del verdetto:** le 5 serie di fase correlano **0,988** → **N_eff 1,01**.
|
||||
La mediana di 5 fasi e' UNA osservazione: toglie l'artefatto, non compra precisione.
|
||||
|
||||
### Le due domande che erano state confuse
|
||||
|
||||
Il gate (A) puo' chiedere due cose diverse, e il 07/08 coincidevano **per fortuna di fase**:
|
||||
|
||||
| forma | difetto | quanto e' grave |
|
||||
|---|---|---|
|
||||
| «la banda e' la scelta che si fa al buio?» | **non ha risoluzione** | cambia verdetto ogni notte |
|
||||
| «la banda non e' stata selezionata sull'hold-out?» | **non ha potenza** | 1 cella su 30 e' l'argmax dell'hold-out → **29/30 lo passano per costruzione** |
|
||||
|
||||
📌 *Lettura (agente, fallibile).* Il 25% non viene dalla griglia affatto: viene dall'argomento
|
||||
**strutturale** del 27/07 — una banda in dollari assoluti degenera al variare del capitale, una
|
||||
frazione no. Sotto la mediana delle fasi il 25% e' **2° in-sample e 3° sull'hold-out**: decente
|
||||
ovunque, primo da nessuna parte, che e' la firma di un parametro NON selezionato sui dati. A un
|
||||
parametro scelto per ragioni strutturali non si applica un gate di *selezione* ma uno di
|
||||
*robustezza* — «fa danno da qualche parte?». Risposta: no.
|
||||
|
||||
**Esito:** gate (A) **ritirato** come pass/fail, congelando il MOTIVO (come il 07/08), piu' una
|
||||
guardia sulla CAUSA che si rompe il giorno che la fase venisse ancorata al calendario.
|
||||
|
||||
## 3. Cosa cambia togliendo GTAA01 — e la decisione dell'operatore
|
||||
|
||||
`r0828_senza_gtaa.py`. Portafoglio di **ricerca** a 5 sleeve; NON il book live.
|
||||
|
||||
| | Sharpe | CAGR | maxDD | vol |
|
||||
|---|---|---|---|---|
|
||||
| CON GTAA01 | **2,28** | +19,2% | 6,1% | 7,8% |
|
||||
| SENZA, iso-nozionale | 2,16 | **+23,8%** | 7,8% | 10,1% |
|
||||
| SENZA, **iso-rischio** (×0,77) | 2,16 | +18,0% | 6,0% | 7,8% |
|
||||
|
||||
A iso-nozionale togliendolo si guadagna **drift** e si compra **volatilita'**. A iso-rischio — la
|
||||
sola lente onesta per un diversificatore a basso CAGR (M6) — **Δ Sharpe +0,121**, maxDD invariato.
|
||||
Hold-out 2025+: **Δ +0,247**. Aiuta in **6 anni su 8**. Correlazione col resto **+0,087**: e'
|
||||
davvero altro. Standalone Sharpe 1,06, CAGR +5,8%.
|
||||
|
||||
⚠️ **Con la sua fascia di fase:** il Δ sulle 5 fasi va da **+0,095 a +0,124** e supera la soglia
|
||||
dichiarata (0,12) in **2 fasi su 5**. Il **segno e' stabile**, il verdetto **binario** no.
|
||||
|
||||
### La decisione: rinviata a $15k di book
|
||||
|
||||
Discussa con l'operatore. L'argomento che ha deciso non e' «GTAA01 non contribuisce» — la misura
|
||||
dice il contrario — ma che **a questo capitale lo sleeve non e' accendibile**: `GTAA_MIN_CAPITAL`
|
||||
e' **$3.000 allocati**, che al peso 20% significa **$15.000 di book** contro i **$2.068** attuali.
|
||||
Sotto quella soglia e' manutenzione senza beneficio incassabile.
|
||||
|
||||
Due ragioni registrate **contro** il blocco immediato, entrambe regole del progetto:
|
||||
- **N7** — uno sleeve difensivo si giudica **sul sinistro, non sul premio**, e la finestra
|
||||
2019-2026 non contiene il sinistro per cui esiste un GTAA a sei gambe;
|
||||
- **N4** — il rischio di venue si compra con un **conto**, non con uno sleeve: togliere GTAA01
|
||||
renderebbe il portafoglio di ricerca **100% cripto su un venue solo**.
|
||||
|
||||
E un costo che sarebbe andato pagato, non saltato: GTAA01 e' uno dei **quattro** nomi di
|
||||
`r0726_wall_fixedpoint.W_DEPLOY`, da cui escono i **$313k** e tutta la pianificazione da €500/mese.
|
||||
|
||||
**Perche' la decisione non si dimentichi** (N9), la soglia e' sorvegliata dal giornale:
|
||||
`journal.SOGLIE_CAPITALE` legge l'equity ogni giorno e il giorno che supera $15k la voce lo dice,
|
||||
con la domanda da rispondere accanto. Stessa tabella, seconda riga: **$20k**, dove si riapre
|
||||
«100% Deribit fino a $20k».
|
||||
|
||||
## 4. Allarmi: il marcatore «gia' detto» si scrive DOPO l'invio
|
||||
|
||||
Debito §5.2 chiuso su decisione dell'operatore. `venue_watch.run_once` salvava lo stato coi
|
||||
marcatori `alerted` gia' a True e l'invio lo faceva il chiamante **dopo**: col 6,9% di invii
|
||||
falliti misurato (2/29), un 🚨 perso restava perso per l'**episodio intero** (200-2.324 ore).
|
||||
|
||||
Ora il sender e' **iniettato** (la funzione resta testabile senza rete d'uscita, che era la ragione
|
||||
del disegno precedente) e su fallimento si disfano i **soli marcatori**, non le misure:
|
||||
asset in ALERT → ri-allerta l'ora dopo; lock MAINT/ALERT → flag azzerati **ma le ore continuano a
|
||||
correre**, cosi' una manutenzione che sfora la grazia sale ad ALERT anche col trasporto giu';
|
||||
lock RIENTRATO → stato ripristinato, perche' il rientro si annuncia una volta sola.
|
||||
`notify()` accetta `tentativi` (venue_watch: 3) e l'esito finisce nel log del cron.
|
||||
|
||||
⚠️ Resta scoperto **ogni altro chiamante di `notify()`**: la disciplina e' oggi solo in venue_watch.
|
||||
|
||||
## 5. L'analista del 27/08, e la riga di diagnosi che mancava
|
||||
|
||||
La voce del 27/08 era senza analisi. Causa, dal transcript della sessione headless:
|
||||
`Failed to authenticate: OAuth session expired and could not be refreshed`, exit 1 alle
|
||||
`00:37:00.98Z`. La meccanica aveva retto (nessuna analisi vecchia spacciata per nuova, esito nel DB,
|
||||
notifica partita) ma il motivo registrato era `uscita 1: ` — vuoto: `interroga()` leggeva il solo
|
||||
**stderr**, e la CLI aveva scritto su **stdout**. Riparato (`motivo_uscita`), analisi recuperata.
|
||||
Guasto **transitorio**: il refresh token era valido, unico caso su 24 sessioni.
|
||||
|
||||
## 6. Fisco: la domanda (a) ha una risposta alla fonte
|
||||
|
||||
Circolare AdE **30/E del 27/10/2023**, letta direttamente dal PDF ufficiale. **I derivati con
|
||||
sottostante cripto stanno in `c-quater` → 26%**, non in `c-sexies` → 33%, e la circolare dice
|
||||
esplicitamente che la riforma 2023 non sposta nulla. Il **collaterale** sconta il **2‰** al 31/12
|
||||
(intermediario non residente → imposta sul valore delle cripto-attivita', ~$4/anno a questo
|
||||
capitale). E la conversione **USDC→USDE non e' fattispecie realizzativa**. Dettaglio e citazioni
|
||||
verbatim in CLAUDE.md §5.4. La (b), i payout di una prop firm, **resta aperta**.
|
||||
|
||||
## Cosa cambia
|
||||
|
||||
- `CLAUDE.md`: §3 nuova riga (GTAA01 rinviato a $15k) · §5.2 risolto · §5.4 riscritto · §5.13 nuovo
|
||||
debito (la fase che ruota, quantificata e innocua oggi).
|
||||
- `src/live/journal.py`: `SOGLIE_CAPITALE` + regola `soglia_capitale`.
|
||||
- `src/live/venue_watch.py`, `src/live/notifier.py`, `scripts/live/venue_watch.py`: commit dopo l'invio.
|
||||
- `src/live/analista.py`: `motivo_uscita` da stdout E stderr.
|
||||
- `tests/test_gtaa_band_gate.py`: gate (A) ritirato, motivo e causa congelati.
|
||||
- Script nuovi: `r0828_gtaa_band_phase.py`, `r0828_gtaa_band_median_phase.py`, `r0828_senza_gtaa.py`.
|
||||
|
||||
**Suite: 789 passati, 0 falliti.**
|
||||
@@ -0,0 +1,67 @@
|
||||
# 2026-08-29 — USDE idoneo, la quota rinviata a lunedì, e il fix IB confermato
|
||||
|
||||
*Scritto il 2026-08-29. Numeri letti dalle serie citate.*
|
||||
|
||||
## 1. GATE USDE-01: CHIUSO **IDONEO**
|
||||
|
||||
Il 2026-08-28 alle 12:35Z `usde_watch` ha rilevato il primo reward:
|
||||
|
||||
```
|
||||
equity USDE : 500.0521 @ 1.0001 (indice) -> $500.05
|
||||
delta : +0.052055 USDE dall'ultima lettura
|
||||
verdetto : IDONEO — reward rilevato il 2026-08-28T12:35:01Z
|
||||
```
|
||||
|
||||
La regola era pre-registrata il 26/08, **prima** dell'esito: ≥1 reward entro il 29/08 → IDONEO,
|
||||
e si apre la decisione di QUOTA, che è dell'operatore. Il gate ha fatto quello che doveva.
|
||||
|
||||
## 2. Il tasso, e perché la quota è stata rinviata
|
||||
|
||||
| lettura | tasso implicito |
|
||||
|---|---|
|
||||
| il solo pagamento (0,052055 su 500) | **3,80% annuo** |
|
||||
| su **due** finestre — il 27/08 ha pagato **zero** | **1,90% annuo** |
|
||||
| annunciato da Deribit (ricerca 26/08) | 9% |
|
||||
|
||||
**Decisione dell'operatore (29/08): la quota si decide lunedì 31/08**, su tre-quattro finestre
|
||||
invece che su una. Il motivo è nella tabella: fra 3,80% e 1,90% c'è tutta la decisione, e il
|
||||
campione è **un pagamento**. Il costo dell'attesa è zero — i 500 USDE rendono comunque.
|
||||
|
||||
📌 Perché il numero non venga letto male: `usde_watch.rendimento()` calcola l'APR sulle finestre
|
||||
**osservate**, col denominatore sul **tempo vero** e non sul numero di finestre pagate — così una
|
||||
finestra che non paga **abbassa** la stima invece di sparire (P5: quel silenzio è uno zero). Le
|
||||
letture con trade nel mezzo si escludono (P12). L'APR si stampa sempre **col suo `n`**, e sotto
|
||||
4 finestre la riga dice *«un pagamento non è un tasso»*. Sulle letture vere di oggi:
|
||||
**1,97% annuo su 1,9 giorni, 1/2 finestre pagate**.
|
||||
|
||||
📌 Perché la decisione non si dimentichi (N9): `quota_da_decidere()` è vera **solo** se il conto è
|
||||
IDONEO **e** il rinvio è scaduto. Da lunedì il watch manda il 📌 **ogni giorno** — con quota
|
||||
attuale, tetto di allerta e APR osservato accanto — finché non si decide. Si smette togliendo
|
||||
`DECISIONE_QUOTA_DAL`. È lo stesso schema della soglia $15k di GTAA01, con la data al posto
|
||||
del capitale.
|
||||
|
||||
⚠️ Nota di contorno che vale una lezione: il 📌 dell'IDONEO era partito su Telegram il 28/08 alle
|
||||
12:35Z, **in mezzo ai messaggi-spazzatura dei test** delle 14:47 e 14:52 (difetto riparato lo
|
||||
stesso giorno). L'unico messaggio che contava è arrivato nel giorno in cui il canale era sporco.
|
||||
È P9 in forma concreta.
|
||||
|
||||
## 3. Il fix IB è confermato
|
||||
|
||||
Il giro del cron delle 00:30 di stanotte: **zero** righe `open orders request timed out` (erano
|
||||
2 per giro, 63 giri su 63) e tutti e sei gli ETF scaricati — `SPY n=7541`, `QQQ n=6912`,
|
||||
`IWM n=6602`, `TLT n=2658`, `GLD n=5476`, `HYG n=4877`, aggiornati al 28/08. Il `readonly=True`
|
||||
resta.
|
||||
|
||||
📌 E una conferma involontaria del debito §5.13: **SPY è passato da `1996-09-04` a `1996-09-05`**.
|
||||
La finestra rotolante ha perso un altro giorno di borsa in una notte, esattamente come misurato
|
||||
il 28/08 — il difetto si legge a occhio nudo nel log del cron.
|
||||
|
||||
## Cosa cambia
|
||||
|
||||
- `scripts/live/usde_watch.py`: `rendimento()` (APR sulle finestre osservate, col suo n) ·
|
||||
`quota_da_decidere()` · `DECISIONE_QUOTA_DAL = "2026-08-31"` · il 📌 giornaliero da lunedì.
|
||||
- `tests/test_usde_watch.py`: 6 test nuovi, incluso il controllo positivo (*la finestra sola
|
||||
darebbe il doppio*) che è la ragione per cui l'APR si stampa col suo `n`.
|
||||
- `CLAUDE.md`: §1 e §4 aggiornati — gate CHIUSO IDONEO, quota rinviata a lunedì.
|
||||
|
||||
**Suite: 795 passati, 0 falliti.**
|
||||
@@ -0,0 +1,58 @@
|
||||
# Giornale di bordo — 2026-08-26
|
||||
|
||||
*Scritto 2026-08-27T00:37:47+00:00. Numeri misurati; nessuna interpretazione automatica.*
|
||||
|
||||
## Mercato
|
||||
|
||||
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **BTC** | $79,031.00 | +0.67% | +14.00% | +24.05% | 42.0% | ↑ ↑ ↑ (3/3 su) | 40.4 (40° pctl 1a) |
|
||||
| **ETH** | $2,507.65 | +2.64% | +11.34% | +32.61% | 67.8% | ↑ ↑ ↑ (3/3 su) | 56.1 (41° pctl 1a) |
|
||||
|
||||
## Libro
|
||||
|
||||
Equity **$2,065.45** · nozionale lordo $582 · leva lorda 0.28x · barra dati 2026-08-26 · 24 giri
|
||||
|
||||
| | TP01 | SKH01 | target | posizione | azione |
|
||||
|---|---|---|---|---|---|
|
||||
| **BTC** | +0.464 | flat | $+359 | $+364 | SELL $-5 |
|
||||
| **ETH** | +0.278 | flat | $+215 | $+218 | HOLD (a target) |
|
||||
|
||||
## P&L
|
||||
|
||||
- giorno: **$+8.86** di equity (24 letture)
|
||||
- realizzato: +0.29 netto su 2 round-trip chiusi · 4 fill · fee 0.0138
|
||||
- cumulato dall'arming: **$+1,467.39** di equity — di cui $+1,399.39 versati/prelevati -> trading **$+68.00**
|
||||
|
||||
## Salute
|
||||
|
||||
- giri di `book_execute`: 24/24
|
||||
- eta' feed SKH all'ultimo giro: 0 min
|
||||
|
||||
## Lettura
|
||||
|
||||
*Generata da regole dichiarate in `src/live/journal.py` — combina i numeri qui sopra e nient'altro. L'id fra parentesi quadre dice quale regola ha parlato. Non e' un giudizio: quello sta nella Nota.*
|
||||
|
||||
- `[stato]` A mercato su BTC $+364 (TP01 +0.464, SKH01 flat) e ETH $+218 (TP01 +0.278, SKH01 flat).
|
||||
- `[leva]` Leva lorda **0.28x** su un tetto di 1.00x (`max_notional_per_asset_frac` 0.50 x 2 asset, letto da `config/live.json`): margine 0.72x.
|
||||
- `[pnl]` Equity $+8.86 con 4 fill e 2 round-trip chiusi (realizzato $+0.29 netto).
|
||||
- `[concentrazione]` 📌 **Il 101% di tutto il P&L cumulato ($+68.00) viene dagli ultimi 7 giorni.** Oltre il 100% perche' **il periodo precedente era in perdita**: senza questi 7 giorni il libro sarebbe sotto. Un risultato concentrato in una finestra non e' un tasso di rendimento: e' un evento.
|
||||
- `[vol]` BTC: implicita 40.4 contro realizzata 30g 42.0 (sotto); DVOL al 40° pctl di un anno; IV-rank espandente 0.14 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[vol]` ETH: implicita 56.1 contro realizzata 30g 67.8 (sotto); DVOL al 41° pctl di un anno; IV-rank espandente 0.17 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[evidenza]` 📌 Campione a oggi: **64 giorni, 22 round-trip**. A questa taglia il P&L non distingue l'edge dalla fortuna — il criterio di morte del libro (`edge_watch`) lavora su una finestra di 36 mesi.
|
||||
|
||||
## Analisi (agente)
|
||||
|
||||
*Scritta da `claude-opus-5` il 2026-08-27T00:38:49+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
|
||||
|
||||
Il fatto nuovo non sta nei numeri di oggi: e' il denominatore, cambiato ieri. L'equity passa da 642.02 (24/08) a 2,056.59 (25/08) a $2,065.45, ma il nozionale lordo resta $582 e le posizioni si muovono appena — BTC da $+345 a $+364, ETH da $+212 a $+218. La leva 0.28x su un tetto di 1.00x non e' quindi confrontabile con quella dei giorni precedenti: stessa esposizione, denominatore molto piu' grande. Chi rilegge fra mesi guardi $+1,399.39 versati e $+68.00 di trading prima di guardare l'equity.
|
||||
|
||||
La settimana che porta il 101% del cumulato e' la stessa in cui BTC ha fatto +14.00% e ETH +11.34%, TSMOM 3/3 su entrambi e il libro lungo su entrambe le gambe. «L'edge ha lavorato» e «un trend-following era lungo mentre il mercato saliva» spiegano quel dato ugualmente bene, e a 64 giorni e 22 round-trip non si separano. Anche l'$+8.86 di oggi e' quasi tutto rivalutazione: il realizzato e' $+0.29 su 2 round-trip, in un giorno da +0.67% e +2.64%.
|
||||
|
||||
Due cose insolite: SKH01 e' flat su entrambi gli asset dopo un mese da +24.05% e +32.61%, quindi il rischio e' tutto TP01; e l'implicita sta sotto la realizzata su entrambi (40.4 contro 42.0, 56.1 contro 67.8), con DVOL al 40° e 41° percentile.
|
||||
|
||||
Da controllare a mano: 4 fill in una giornata che chiude con un solo SELL $-5. Cosa sia successo fra i 24 giri non e' misurabile qui.
|
||||
|
||||
## Nota
|
||||
|
||||
*(vuota — campo dell'operatore)*
|
||||
@@ -0,0 +1,60 @@
|
||||
# Giornale di bordo — 2026-08-27
|
||||
|
||||
*Scritto 2026-08-28T08:28:09+00:00. Numeri misurati; nessuna interpretazione automatica.*
|
||||
|
||||
## Mercato
|
||||
|
||||
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **BTC** | $80,266.00 | +1.56% | +9.94% | +25.66% | 42.1% | ↑ ↑ ↑ (3/3 su) | 41.5 (47° pctl 1a) |
|
||||
| **ETH** | $2,511.75 | +0.16% | +7.96% | +30.79% | 67.9% | ↑ ↑ ↑ (3/3 su) | 57.1 (45° pctl 1a) |
|
||||
|
||||
## Libro
|
||||
|
||||
Equity **$2,070.23** · nozionale lordo $573 · leva lorda 0.28x · barra dati 2026-08-27 · 24 giri
|
||||
|
||||
| | TP01 | SKH01 | target | posizione | azione |
|
||||
|---|---|---|---|---|---|
|
||||
| **BTC** | +0.463 | flat | $+359 | $+361 | HOLD (a target) |
|
||||
| **ETH** | +0.277 | flat | $+215 | $+212 | HOLD (a target) |
|
||||
|
||||
## P&L
|
||||
|
||||
- giorno: **$+4.78** di equity (24 letture)
|
||||
- realizzato: +0.13 netto su 1 round-trip chiusi · 1 fill · fee 0.0019
|
||||
- cumulato dall'arming: **$+1,472.17** di equity — di cui $+1,399.39 versati/prelevati -> trading **$+72.78**
|
||||
|
||||
## Salute
|
||||
|
||||
- giri di `book_execute`: 24/24
|
||||
- eta' feed SKH all'ultimo giro: 0 min
|
||||
|
||||
## Lettura
|
||||
|
||||
*Generata da regole dichiarate in `src/live/journal.py` — combina i numeri qui sopra e nient'altro. L'id fra parentesi quadre dice quale regola ha parlato. Non e' un giudizio: quello sta nella Nota.*
|
||||
|
||||
- `[stato]` A mercato su BTC $+361 (TP01 +0.463, SKH01 flat) e ETH $+212 (TP01 +0.277, SKH01 flat).
|
||||
- `[leva]` Leva lorda **0.28x** su un tetto di 1.00x (`max_notional_per_asset_frac` 0.50 x 2 asset, letto da `config/live.json`): margine 0.72x.
|
||||
- `[pnl]` Equity $+4.78 con 1 fill e 1 round-trip chiusi (realizzato $+0.13 netto).
|
||||
- `[concentrazione]` 📌 **Il 82% di tutto il P&L cumulato ($+72.78) viene dagli ultimi 7 giorni.** Un risultato concentrato in una finestra non e' un tasso di rendimento: e' un evento.
|
||||
- `[vol]` BTC: implicita 41.5 contro realizzata 30g 42.1 (sotto); DVOL al 47° pctl di un anno; IV-rank espandente 0.16 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[vol]` ETH: implicita 57.1 contro realizzata 30g 67.9 (sotto); DVOL al 45° pctl di un anno; IV-rank espandente 0.19 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[evidenza]` 📌 Campione a oggi: **65 giorni, 23 round-trip**. A questa taglia il P&L non distingue l'edge dalla fortuna — il criterio di morte del libro (`edge_watch`) lavora su una finestra di 36 mesi.
|
||||
|
||||
## Analisi (agente)
|
||||
|
||||
*Scritta da `claude-opus-5` il 2026-08-28T08:29:00+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
|
||||
|
||||
Il fatto nuovo della settimana non sta nel P&L ma nella colonna equity: il salto del 25/08 da $642.02 a $2,056.59 è capitale entrato, non performance. Dei $+1,472.17 cumulati dall'arming, $+1,399.39 sono versamenti e solo $+72.78 è trading. Chi rileggerà la curva fra mesi non la legga come rendimento.
|
||||
|
||||
Merita un occhio umano il fatto che l'equity sia quasi triplicata ma il nozionale no: BTC $+361 / ETH $+212 oggi, contro $+345/$+212 del 25/08 e $+364/$+218 del 26/08 — praticamente fermi, con leva lorda scesa a 0.28x su un tetto di 1.00x. Se il target segua l'equity o l'intensità del segnale (TP01 +0.463 e +0.277) da questa pagina non è determinabile.
|
||||
|
||||
Il rischio è tutto su TP01: SKH01 flat su entrambe le gambe. Giornata di attività minima, 1 fill e 1 round-trip, $+4.78 di equity; quanto di quel numero sia mark-to-market delle gambe non è scomponibile qui.
|
||||
|
||||
Lettura alternativa della concentrazione: l'82% del cumulato sta negli ultimi 7 giorni, che sono anche i 7 giorni di BTC +9.94% ed ETH +7.96%. Un long-flat che guadagna in una settimana di trend sta facendo il suo mestiere; a 65 giorni e 23 round-trip non è separabile da un buon tratto di mercato.
|
||||
|
||||
Salute pulita: 24/24 giri, feed a 0 min. Su entrambi gli asset l'implicita sta sotto la realizzata, ma il gate di VRP01 blocca su un altro asse.
|
||||
|
||||
## Nota
|
||||
|
||||
*(vuota — campo dell'operatore)*
|
||||
@@ -0,0 +1,58 @@
|
||||
# Giornale di bordo — 2026-08-28
|
||||
|
||||
*Scritto 2026-08-29T00:36:42+00:00. Numeri misurati; nessuna interpretazione automatica.*
|
||||
|
||||
## Mercato
|
||||
|
||||
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **BTC** | $77,850.00 | -3.01% | -0.64% | +21.77% | 44.1% | ↑ ↑ ↑ (3/3 su) | 38.3 (25° pctl 1a) |
|
||||
| **ETH** | $2,443.35 | -2.72% | -2.93% | +27.98% | 68.9% | ↑ ↑ ↑ (3/3 su) | 50.9 (16° pctl 1a) |
|
||||
|
||||
## Libro
|
||||
|
||||
Equity **$2,052.36** · nozionale lordo $571 · leva lorda 0.28x · barra dati 2026-08-28 · 24 giri
|
||||
|
||||
| | TP01 | SKH01 | target | posizione | azione |
|
||||
|---|---|---|---|---|---|
|
||||
| **BTC** | +0.464 | flat | $+357 | $+357 | HOLD (a target) |
|
||||
| **ETH** | +0.278 | flat | $+214 | $+214 | HOLD (a target) |
|
||||
|
||||
## P&L
|
||||
|
||||
- giorno: **$-17.87** di equity (24 letture)
|
||||
- realizzato: +0.00 netto su 0 round-trip chiusi · 2 fill · fee 0.0054
|
||||
- cumulato dall'arming: **$+1,454.30** di equity — di cui $+1,399.39 versati/prelevati -> trading **$+54.91**
|
||||
|
||||
## Salute
|
||||
|
||||
- giri di `book_execute`: 24/24
|
||||
- eta' feed SKH all'ultimo giro: 0 min
|
||||
|
||||
## Lettura
|
||||
|
||||
*Generata da regole dichiarate in `src/live/journal.py` — combina i numeri qui sopra e nient'altro. L'id fra parentesi quadre dice quale regola ha parlato. Non e' un giudizio: quello sta nella Nota.*
|
||||
|
||||
- `[stato]` A mercato su BTC $+357 (TP01 +0.464, SKH01 flat) e ETH $+214 (TP01 +0.278, SKH01 flat).
|
||||
- `[leva]` Leva lorda **0.28x** su un tetto di 1.00x (`max_notional_per_asset_frac` 0.50 x 2 asset, letto da `config/live.json`): margine 0.72x.
|
||||
- `[pnl]` Equity $-17.87 con 2 fill e 0 round-trip chiusi (realizzato $+0.00 netto).
|
||||
- `[drawdown]` 📌 Equity **-1.02%** sotto il picco ($2,073.59). ⚠️ e' un DD su letture ORARIE, non sul minimo intra-giorno.
|
||||
- `[vol]` BTC: implicita 38.3 contro realizzata 30g 44.1 (sotto); DVOL al 25° pctl di un anno; IV-rank espandente 0.09 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[vol]` ETH: implicita 50.9 contro realizzata 30g 68.9 (sotto); DVOL al 16° pctl di un anno; IV-rank espandente 0.12 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[evidenza]` 📌 Campione a oggi: **66 giorni, 23 round-trip**. A questa taglia il P&L non distingue l'edge dalla fortuna — il criterio di morte del libro (`edge_watch`) lavora su una finestra di 36 mesi.
|
||||
|
||||
## Analisi (agente)
|
||||
|
||||
*Scritta da `claude-opus-5` il 2026-08-29T00:37:39+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
|
||||
|
||||
Prima giornata in rosso dal versamento: dopo +8.86 e +4.78, l'equity fa $-17.87 con zero round-trip chiusi. Il libro non ha deciso niente — BTC $+357 e ETH $+214 restano a target, dove erano ieri ($+361 e $+212) — quindi la perdita e' marcatura di mercato sul giorno a -3.01% BTC e -2.72% ETH, non esecuzione; e con SKH01 flat su entrambi e' esposizione interamente TP01. I 2 fill con fee 0.0054 sono aggiustamenti: se siano ritocchi al target o altro, non e' distinguibile qui. Salute pulita (24/24 giri, feed a 0 min), quindi non spiega nulla della giornata.
|
||||
|
||||
Cio' che e' cambiato davvero e' la scala. Dal 25/08 l'equity e' $2,052.36, e il movimento di un solo giorno ($-17.87) e' dello stesso ordine dei $+54.91 di trading accumulati dall'arming in 66 giorni. Il P&L giornaliero e' ora piu' rumoroso della cosa che dovrebbe misurare, mentre i round-trip restano 23.
|
||||
|
||||
Due letture reggono ugualmente sugli stessi numeri: TSMOM 3/3 su entrambi con 30 giorni a +21.77% e +27.98% dice trend intatto e ritracciamento; i 7 giorni (-0.64% e -2.93%) dicono che la spinta si era gia' fermata e oggi se ne vede la parte visibile. Con questi dati non si sceglie.
|
||||
|
||||
Merita un'occhiata umana il picco $2,073.59: non compare fra le chiusure giornaliere, e il -1.02% e' misurato contro quella lettura oraria.
|
||||
|
||||
## Nota
|
||||
|
||||
*(vuota — campo dell'operatore)*
|
||||
@@ -58,6 +58,14 @@ EPS_REWARD = 1e-3 # USDE: sotto e' rumore di lettura; il reward atteso a $500
|
||||
FILL_TS = "2026-08-26T13:04:32Z" # conversione eseguita: 500 USDE @ 1.0003
|
||||
VERDETTO_DOPO = "2026-08-29T12:30:00Z" # ultima finestra reward del 29/08 conclusa
|
||||
|
||||
# La QUOTA e' dell'operatore, e il 2026-08-29 e' stata RINVIATA a lunedi' 31/08 per decidere su
|
||||
# piu' di una finestra invece che su una (il 27/08 aveva pagato ZERO, il 28/08 +0.052 USDE: fra
|
||||
# le due letture ci sono 3,80% e 1,90% annuo, cioe' tutta la differenza che conta). N9: una
|
||||
# decisione rinviata senza un promemoria e' rinviata per sempre — da lunedi' il watch lo dice
|
||||
# ogni giorno finche' la riga non viene tolta.
|
||||
DECISIONE_QUOTA_DAL = "2026-08-31" # lunedi': si decide la quota
|
||||
EPS_GIORNI = 0.5 # sotto mezza giornata l'APR non si annualizza
|
||||
|
||||
|
||||
def _parse(ts: str) -> datetime:
|
||||
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
||||
@@ -102,6 +110,46 @@ def verdetto(records: list[dict], now: datetime) -> tuple[str, str]:
|
||||
return "IN_ATTESA", f"finestre reward ~12:00 UTC, verdetto dopo {VERDETTO_DOPO}"
|
||||
|
||||
|
||||
def rendimento(records: list[dict]) -> dict:
|
||||
"""PURA. Il rendimento sulle finestre OSSERVATE, col loro NUMERO accanto.
|
||||
|
||||
E' il numero che serve alla decisione di quota, e l'unico modo onesto di darlo e' con `n`
|
||||
attaccato: un pagamento non e' un tasso. Il denominatore e' il TEMPO VERO fra la prima e
|
||||
l'ultima lettura misurabile, non il conteggio delle finestre — cosi' una finestra che non
|
||||
paga abbassa la stima invece di sparire (P5: il silenzio in una serie di ritorni si legge
|
||||
come zero, e qui e' proprio uno zero).
|
||||
|
||||
Si escludono le letture con trade nel mezzo (`con_trade`): quel delta non e' solo reward, e
|
||||
una riparazione silenziosa sarebbe un'invenzione (P12).
|
||||
"""
|
||||
usabili = [r for r in records
|
||||
if r.get("reward_stimato") is not None and not r.get("con_trade")]
|
||||
pagate = [r for r in usabili if r.get("reward_rilevato")]
|
||||
if not usabili:
|
||||
return dict(apr=None, finestre=0, pagate=0, giorni=0.0, tot=0.0,
|
||||
nota="nessuna finestra misurabile")
|
||||
tot = float(sum(r["reward_stimato"] for r in usabili))
|
||||
eq = [float(r["eq_usde"]) for r in usabili if r.get("eq_usde")]
|
||||
eq_med = sum(eq) / len(eq) if eq else 0.0
|
||||
# tempo vero coperto: dalla lettura PRIMA della prima misurabile, all'ultima
|
||||
i0 = records.index(usabili[0])
|
||||
t0 = records[max(0, i0 - 1)]["ts"]
|
||||
giorni = (usabili[-1]["ts"] - t0) / 86_400_000.0
|
||||
if giorni < EPS_GIORNI or eq_med <= 0:
|
||||
return dict(apr=None, finestre=len(usabili), pagate=len(pagate), giorni=giorni, tot=tot,
|
||||
nota=f"solo {giorni:.2f} giorni osservati: non si annualizza")
|
||||
apr = tot / eq_med / giorni * 365.0
|
||||
return dict(apr=apr, finestre=len(usabili), pagate=len(pagate), giorni=giorni, tot=tot,
|
||||
nota=f"{len(pagate)}/{len(usabili)} finestre hanno pagato")
|
||||
|
||||
|
||||
def quota_da_decidere(now: datetime, verdetto_corrente: str) -> bool:
|
||||
"""PURA. La decisione di quota e' dovuta? Solo se il conto e' IDONEO e il rinvio e' scaduto."""
|
||||
if verdetto_corrente != "IDONEO":
|
||||
return False
|
||||
return now.date().isoformat() >= DECISIONE_QUOTA_DAL
|
||||
|
||||
|
||||
def condizioni(rec: dict | None, c: dict) -> set[str]:
|
||||
"""PURA. Condizioni di allerta attive su un record."""
|
||||
if not rec:
|
||||
@@ -229,6 +277,20 @@ def main() -> int:
|
||||
notify("⚠️ USDE: conto NON idoneo", {"dettaglio": v_motivo})
|
||||
inviati.append(f"VERDETTO->{v}")
|
||||
|
||||
# La QUOTA e' dell'operatore ed e' stata rinviata a lunedi': da quel giorno lo si dice OGNI
|
||||
# giorno, non solo alla transizione. Ripetersi qui e' voluto — non e' un allarme che chiede
|
||||
# un'azione impossibile (P14), e' una domanda che da lunedi' si puo' finalmente rispondere,
|
||||
# con accanto il numero per rispondere. Si smette togliendo `DECISIONE_QUOTA_DAL`.
|
||||
rend = rendimento(records + [rec])
|
||||
if quota_da_decidere(now, v):
|
||||
apr = "n/d" if rend["apr"] is None else f"{rend['apr']*100:.2f}% annuo"
|
||||
notify("📌 USDE: la decisione di QUOTA e' dovuta", {
|
||||
"quota attuale": f"{quota:.1%}" if quota is not None else "n/d",
|
||||
"tetto di allerta": f"{c['quota_max_frac']:.0%} (mai 100%: R1 emittente non recuperabile)",
|
||||
"rendimento osservato": f"{apr} — {rend['nota']}, {rend['giorni']:.1f} giorni",
|
||||
"nota": "un pagamento non e' un tasso: leggere l'APR con il suo n"})
|
||||
inviati.append("QUOTA_DA_DECIDERE")
|
||||
|
||||
cambiato = bool(inviati) or v != v_prev
|
||||
if not quiet or cambiato:
|
||||
print("=" * 78)
|
||||
@@ -249,6 +311,14 @@ def main() -> int:
|
||||
+ (" [con trade nel mezzo]" if an["con_trade"] else ""))
|
||||
print(f" delta : {an['delta']:+.6f} USDE dall'ultima lettura -> {attr}")
|
||||
print(f" verdetto : {v} — {v_motivo}")
|
||||
if rend["finestre"]:
|
||||
apr = "n/d" if rend["apr"] is None else f"{rend['apr']*100:.2f}% annuo"
|
||||
print(f" rendimento : {apr} su {rend['giorni']:.1f} giorni — {rend['nota']}"
|
||||
f" (tot {rend['tot']:+.6f} USDE)")
|
||||
if rend["finestre"] < 4:
|
||||
print(f" ⚠️ n={rend['finestre']}: un pagamento non e' un tasso")
|
||||
if v == "IDONEO" and not quota_da_decidere(now, v):
|
||||
print(f" quota : decisione RINVIATA a {DECISIONE_QUOTA_DAL} (operatore)")
|
||||
if inviati:
|
||||
print(f" allarmi : {', '.join(inviati)}")
|
||||
return 0
|
||||
|
||||
+29
-15
@@ -16,14 +16,37 @@ ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.live.deribit import check_specs # noqa: E402
|
||||
from src.live.notifier import notify # noqa: E402
|
||||
from src.live.notifier import notify, ultimo_errore # noqa: E402
|
||||
from src.live.venue_watch import (PERSIST_HOURS, THRESHOLD_BPS, # noqa: E402
|
||||
run_once)
|
||||
|
||||
|
||||
def _titolo(rep: dict) -> str:
|
||||
"""⚠️ Il titolo dice cosa FARE, non solo cosa e' successo: chi lo legge sul telefono deve
|
||||
sapere il primo passo senza aprire il repo (runbook completo in src/live/venue_watch.py).
|
||||
⚠️ Dal 19/08 DIPENDE dalla gravita'. Prima era sempre il 🚨 col prelievo di prova, anche per
|
||||
un blocco da manutenzione annunciata: il 18/08 sono usciti quattro messaggi identici che
|
||||
chiedevano di prelevare, tutti dopo che il book aveva gia' ripreso. Un allarme massimo speso
|
||||
per un evento atteso e' un allarme che non verra' letto il giorno che e' vero."""
|
||||
if rep.get("severity") == "warn":
|
||||
return "⚠️ VENUE WATCH — Deribit non operativa, ma spiegata. Nessuna azione"
|
||||
return "🚨 VENUE WATCH — possibile stress su Deribit. PRIMO PASSO: prelievo di prova"
|
||||
|
||||
|
||||
def _manda(rep: dict) -> tuple[bool, str]:
|
||||
"""Il sender iniettato in `run_once`: e' lui a decidere se i marcatori 'gia' detto' si
|
||||
scrivono. `tentativi=3` perche' su questo percorso il 6,9% degli invii si perde, e qui
|
||||
perderne uno significa perdere l'episodio intero."""
|
||||
ok = notify(_titolo(rep), {f"alert {i+1}": a for i, a in enumerate(rep["alerts"])},
|
||||
tentativi=3)
|
||||
if ok:
|
||||
return True, "ok"
|
||||
return False, (ultimo_errore() or "motivo non registrato")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
quiet = "--quiet" in sys.argv
|
||||
rep = run_once()
|
||||
rep = run_once(sender=_manda)
|
||||
|
||||
if not quiet:
|
||||
print("=" * 78)
|
||||
@@ -65,21 +88,12 @@ def main() -> int:
|
||||
print(f" SPEC: {k}: {v}")
|
||||
|
||||
if rep["alerts"]:
|
||||
# ⚠️ Il titolo dice cosa FARE, non solo cosa e' successo: chi lo legge sul telefono deve
|
||||
# sapere il primo passo senza aprire il repo (runbook completo in src/live/venue_watch.py).
|
||||
# ⚠️ Dal 19/08 il titolo DIPENDE dalla gravita'. Prima era sempre il 🚨 col prelievo di
|
||||
# prova, anche per un blocco da manutenzione annunciata: il 18/08 sono usciti quattro
|
||||
# messaggi identici che chiedevano di prelevare, tutti dopo che il book aveva gia'
|
||||
# ripreso. Un allarme massimo speso per un evento atteso e' un allarme che non verra'
|
||||
# letto il giorno che e' vero — ed e' l'unico giorno che conta.
|
||||
if rep.get("severity") == "warn":
|
||||
titolo = "⚠️ VENUE WATCH — Deribit non operativa, ma spiegata. Nessuna azione"
|
||||
else:
|
||||
titolo = ("🚨 VENUE WATCH — possibile stress su Deribit. "
|
||||
"PRIMO PASSO: prelievo di prova")
|
||||
notify(titolo, {f"alert {i+1}": a for i, a in enumerate(rep["alerts"])})
|
||||
# L'invio l'ha gia' fatto `run_once` attraverso `_manda`: qui si stampa e si riporta
|
||||
# l'ESITO. Un invio fallito si vede nel log del cron invece di sparire — e lo stato su
|
||||
# disco e' stato disfatto, quindi l'ora prossima ci riprova da solo.
|
||||
for a in rep["alerts"]:
|
||||
print(f" ALERT: {a}")
|
||||
print(f" telegram: {rep.get('invio')}")
|
||||
return 2 if rep.get("severity") == "alert" else 0
|
||||
return 0
|
||||
|
||||
|
||||
@@ -129,7 +129,21 @@ def main():
|
||||
sys.exit(2)
|
||||
ib = IB()
|
||||
try:
|
||||
ib.connect("127.0.0.1", 4002, clientId=90, timeout=15)
|
||||
# `readonly=True`: questo client SCARICA STORICO e non manda ordini mai. Non e' solo
|
||||
# igiene — in `ib_async.IB.connectAsync` le richieste "open orders" e "completed orders"
|
||||
# esistono SOLO se il client non e' readonly (`if not readonly: reqs[...]`), e sul gateway
|
||||
# paper non rispondono: ogni notte, 63 giri su 63, il log del cron si prendeva
|
||||
# open orders request timed out
|
||||
# completed orders request timed out
|
||||
# Due righe d'errore innocue ripetute per sempre sono il modo in cui un errore VERO
|
||||
# smette di farsi notare (P14). Qui si toglie la CAUSA, non si filtra il messaggio.
|
||||
# ⚠️ OSSERVATO il 2026-08-28: a meta' pomeriggio il gateway NON serve storico —
|
||||
# `reqHistoricalData` va in timeout e lo script stampa "0 barre (subscription?)" per ogni
|
||||
# simbolo. Quattro tentativi fra le 13:05 e le 15:35 UTC, tutti a zero, con e senza
|
||||
# `readonly`; il giro del cron delle 00:30 riesce ogni notte. La causa sta nel gateway,
|
||||
# non qui, e non e' stata diagnosticata. Non e' pericoloso: con 0 barre lo script NON
|
||||
# sovrascrive i parquet, quindi un giro fallito lascia il dato di ieri invece di romperlo.
|
||||
ib.connect("127.0.0.1", 4002, clientId=90, timeout=15, readonly=True)
|
||||
except Exception as e:
|
||||
print(f"[CONNESSIONE FALLITA] 127.0.0.1:4002 -> {repr(e)[:120]}\n Avvia: docker compose up -d ib-gateway")
|
||||
sys.exit(1)
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env python
|
||||
"""r0828_gtaa_band_median_phase.py — criterio (C): la banda giudicata sulla MEDIANA delle 5 fasi.
|
||||
|
||||
⚠️ DA LEGGERE PRIMA DI CITARE QUALUNQUE NUMERO DI QUI.
|
||||
Questo criterio e' stato scelto **DOPO** aver visto la tabella delle fasi di
|
||||
`r0828_gtaa_band_phase.py`, che mostra il criterio a fase singola cambiare verdetto da una notte
|
||||
all'altra. Sceglierne uno nuovo guardando l'esito del vecchio e' selezione, esattamente come
|
||||
scegliere una cella guardando l'hold-out. Quindi:
|
||||
|
||||
· questo script NON valida la proposta del 27/07 e NON puo' essere citato come sua conferma;
|
||||
· l'unica domanda che puo' porre onestamente e' quella sullo STRUMENTO — «un criterio costruito
|
||||
cosi' avrebbe risoluzione e potenza?» — che si risponde senza guardare se la cella che vince
|
||||
e' quella che ci piace;
|
||||
· se la risposta e' si', il criterio va poi DICHIARATO e la validazione RIFATTA da capo su di
|
||||
esso, con la sua data. Il verdetto di oggi sulla banda resta *non decidibile*.
|
||||
|
||||
L'IDEA. Il difetto misurato in `r0828_gtaa_band_phase.py` e' che `gtaa._gated_returns` fasa il
|
||||
ribilanciamento sulla POSIZIONE nell'array (`i % every == 0`), e la prima barra di SPY scivola di
|
||||
un giorno ogni notte perche' IB serve una finestra rotolante di 30 anni. La fase canonica quindi
|
||||
cambia da sola. La mediana sulle 5 fasi toglie di mezzo la componente di FASE — e' la stessa
|
||||
mossa che il progetto fa gia' in `r0726_loo_deluck` (M7: su ancore appaiate la statistica e' la
|
||||
mediana). Resta la componente di DATO: le barre perse. La domanda e' se basti.
|
||||
|
||||
I CRITERI, DICHIARATI QUI PRIMA DI GUARDARE I NUMERI. Il pavimento resta **0.01**, quello che il
|
||||
test si diede il 07/08: non lo si ritocca dopo aver visto l'esito, o si sta tarando il metro sul
|
||||
risultato. Il criterio (C) ha risoluzione e potenza se e solo se, sulla statistica mediana:
|
||||
(i) la banda scelta al buio e' LA STESSA in tutte le NOTTI notti simulate;
|
||||
(ii) il margine sulla seconda resta >= 0.01 in tutte;
|
||||
(iii) POTENZA — la banda scelta sull'hold-out e' DIVERSA da quella scelta al buio. Se
|
||||
coincidono, il gate lo passerebbe anche una proposta selezionata sull'hold-out, e non
|
||||
distingue niente (controllo positivo, come il 07/08).
|
||||
E una lettura di contorno che vale piu' del verdetto: la mediana di 5 fasi non e' una mediana di
|
||||
5 osservazioni. Si stampa la correlazione media fra le serie di fase e l'N_eff che ne segue
|
||||
(§2 del CLAUDE.md: «positivo in N/N ancore NON e' N osservazioni»).
|
||||
|
||||
uv run python scripts/research/r0828_gtaa_band_median_phase.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
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"))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
|
||||
|
||||
import r0727_gtaa_band_gate as BG # noqa: E402
|
||||
import src.portfolio.gtaa as G # noqa: E402
|
||||
|
||||
NOTTI = 10
|
||||
MARGINE_MINIMO = 0.01 # invariato dal 07/08: il metro non si ritocca sul risultato
|
||||
EVERY = BG.PROPOSTA[0]
|
||||
FASI = tuple(range(EVERY))
|
||||
PROPOSTA_BANDA = BG.PROPOSTA[1]
|
||||
ROTOLANTE = ("SPY",) # l'unica gamba al muro dei 30 anni (misurato in r0828_gtaa_band_phase)
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def _serie(sym: str, taglio: int):
|
||||
"""(prezzi, esposizione) di una gamba, tagliata in testa di `taglio` barre. In cache: e' il
|
||||
pezzo caro (l'esposizione e' un doppio ciclo su 30 anni) e non dipende da banda ne' fase."""
|
||||
close = G._close(sym)
|
||||
if taglio:
|
||||
close = close.iloc[taglio:]
|
||||
ex = np.nan_to_num(np.asarray(G._exposure(close).values, float))
|
||||
return close, ex
|
||||
|
||||
|
||||
def _leg(sym: str, cap_leg: float, band_usd: float, ph: int, taglio: int) -> pd.Series:
|
||||
close, ex = _serie(sym, taglio)
|
||||
px = close.values.astype(float)
|
||||
ret = np.zeros(len(px))
|
||||
ret[1:] = px[1:] / px[:-1] - 1.0
|
||||
held = np.empty(len(ex))
|
||||
comm = np.zeros(len(ex))
|
||||
cur = 0.0
|
||||
for i in range(len(ex)):
|
||||
if i % EVERY == ph:
|
||||
notional = abs(ex[i] - cur) * cap_leg
|
||||
if notional >= max(band_usd, G.IB_MIN_TRADE_USD):
|
||||
comm[i] = G.ib_commission(notional, px[i]) / cap_leg
|
||||
cur = ex[i]
|
||||
held[i] = cur
|
||||
pos = np.zeros(len(held))
|
||||
pos[1:] = held[:-1]
|
||||
net = pos * ret - comm
|
||||
net[0] = 0.0
|
||||
return pd.Series(net, index=close.index)
|
||||
|
||||
|
||||
def cella(frac: float, ph: int, taglio: int) -> pd.Series:
|
||||
cap_leg = BG.CAP_REF / len(G.EQ_UNIVERSE)
|
||||
band = frac * BG.CAP_REF / len(G.EQ_UNIVERSE)
|
||||
cols = {a: _leg(a, cap_leg, band,
|
||||
ph if a in ROTOLANTE else 0,
|
||||
taglio if a in ROTOLANTE else 0)
|
||||
for a in G.EQ_UNIVERSE}
|
||||
return pd.concat(cols, axis=1, sort=True).sort_index().mean(axis=1, skipna=True).dropna()
|
||||
|
||||
|
||||
def sh(frac: float, ph: int, taglio: int, finestra: str) -> float:
|
||||
s = cella(frac, ph, taglio)
|
||||
s = s.loc[: BG.HOLDOUT] if finestra == "in" else s.loc[BG.HOLDOUT:]
|
||||
return BG.met(s)["sharpe"]
|
||||
|
||||
|
||||
def mediana_fasi(frac: float, taglio: int, finestra: str = "in") -> float:
|
||||
return float(np.median([sh(frac, ph, taglio, finestra) for ph in FASI]))
|
||||
|
||||
|
||||
def scelta(valori: dict) -> tuple[float, float]:
|
||||
best = max(valori, key=valori.get)
|
||||
return best, valori[best] - max(v for f, v in valori.items() if f != best)
|
||||
|
||||
|
||||
def sanity() -> bool:
|
||||
mia = cella(PROPOSTA_BANDA, 0, 0)
|
||||
vera = BG.run_cell(EVERY, PROPOSTA_BANDA, BG.CAP_REF)
|
||||
j = pd.concat({"m": mia, "v": vera}, axis=1, join="outer")
|
||||
d = float((j["m"] - j["v"]).abs().max())
|
||||
ok = bool(np.isfinite(d) and d < 1e-12 and len(j) == len(vera))
|
||||
print(f"[SANITY] replica (fase 0, taglio 0) vs produzione: max|Δ| = {d:.2e} -> {'OK' if ok else 'DIVERGE'}")
|
||||
return ok
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print(__doc__.split("\n\n")[0])
|
||||
print("\n⚠️ criterio scelto DOPO aver visto l'esito del precedente: misura lo STRUMENTO, "
|
||||
"non valida la banda.\n")
|
||||
if not sanity():
|
||||
return 2
|
||||
|
||||
# ---------------------------------------------------------------- contorno: quante osservazioni
|
||||
print("=" * 100)
|
||||
print("CONTORNO — la mediana di 5 fasi, quante osservazioni sono davvero?")
|
||||
print("=" * 100)
|
||||
serie = {ph: cella(PROPOSTA_BANDA, ph, 0).loc[: BG.HOLDOUT] for ph in FASI}
|
||||
M = pd.concat(serie, axis=1, join="inner").dropna()
|
||||
C = M.corr().values
|
||||
rho = float((C.sum() - len(FASI)) / (len(FASI) * (len(FASI) - 1)))
|
||||
n_eff = len(FASI) / (1.0 + (len(FASI) - 1) * rho)
|
||||
print(f" correlazione media fra le 5 serie di fase (banda {PROPOSTA_BANDA:.0%}, in-sample): {rho:.3f}")
|
||||
print(f" N_eff = {len(FASI)} / (1 + {len(FASI)-1}·ρ) = {n_eff:.2f}")
|
||||
print(" -> la mediana media su ~2 osservazioni indipendenti, non su 5: toglie la fase,")
|
||||
print(" non compra precisione. Va citata come robustezza alla fase, mai come campione.")
|
||||
|
||||
# ---------------------------------------------------------------- (i) e (ii)
|
||||
print("\n" + "=" * 100)
|
||||
print(f"(C) LA MEDIANA DELLE 5 FASI — banda scelta al buio, su {NOTTI} notti di finestra rotolante")
|
||||
print("=" * 100)
|
||||
spy = G._close("SPY")
|
||||
righe, righe_singola = [], []
|
||||
for k in range(NOTTI):
|
||||
med = {f: mediana_fasi(f, k) for f in BG.FRAC_GRID}
|
||||
b, m = scelta(med)
|
||||
righe.append(dict(notte=k, inizio_SPY=str(spy.index[k].date()), scelta=f"{b:.0%}",
|
||||
margine=round(m, 4), **{f"{f:.0%}": round(med[f], 4) for f in BG.FRAC_GRID}))
|
||||
sing = {f: sh(f, 0, k, "in") for f in BG.FRAC_GRID}
|
||||
bs, ms = scelta(sing)
|
||||
righe_singola.append(dict(notte=k, scelta=f"{bs:.0%}", margine=round(ms, 4)))
|
||||
D = pd.DataFrame(righe)
|
||||
pd.set_option("display.width", 250)
|
||||
print(D.to_string(index=False))
|
||||
|
||||
scelte = set(D.scelta)
|
||||
quota = float((D.scelta == f"{PROPOSTA_BANDA:.0%}").mean())
|
||||
print(f"\n celle distinte scelte: {sorted(scelte)} | la proposta ({PROPOSTA_BANDA:.0%}) vince {quota:.0%} delle notti")
|
||||
print(f" margine: min {D.margine.min():.4f} mediana {D.margine.median():.4f} max {D.margine.max():.4f}")
|
||||
print(f" notti sotto il pavimento {MARGINE_MINIMO}: {int((D.margine < MARGINE_MINIMO).sum())}/{NOTTI}")
|
||||
|
||||
S = pd.DataFrame(righe_singola)
|
||||
print("\n confronto diretto col criterio a FASE SINGOLA (quello che fallisce oggi):")
|
||||
print(f" fase singola : celle {sorted(set(S.scelta))} "
|
||||
f"| sotto pavimento {int((S.margine < MARGINE_MINIMO).sum())}/{NOTTI} "
|
||||
f"| margine min {S.margine.min():.4f}")
|
||||
print(f" mediana fasi : celle {sorted(scelte)} "
|
||||
f"| sotto pavimento {int((D.margine < MARGINE_MINIMO).sum())}/{NOTTI} "
|
||||
f"| margine min {D.margine.min():.4f}")
|
||||
esc_s = {f: 0.0 for f in BG.FRAC_GRID}
|
||||
for f in BG.FRAC_GRID:
|
||||
vs = [sh(f, 0, k, "in") for k in range(NOTTI)]
|
||||
vm = [D[f"{f:.0%}"].iloc[k] for k in range(NOTTI)]
|
||||
esc_s[f] = (max(vs) - min(vs), max(vm) - min(vm))
|
||||
print("\n escursione dello Sharpe in-sample su 10 notti, per banda (fase singola -> mediana):")
|
||||
for f in BG.FRAC_GRID:
|
||||
a, b = esc_s[f]
|
||||
print(f" banda {f:>4.0%}: {a:.4f} -> {b:.4f} ({'−' if b < a else '+'}{abs(b-a)/a*100:4.0f}%)")
|
||||
|
||||
# ---------------------------------------------------------------- (iii) potenza
|
||||
print("\n" + "=" * 100)
|
||||
print("(iii) POTENZA — chi guardasse l'HOLD-OUT sceglierebbe la stessa banda?")
|
||||
print("=" * 100)
|
||||
med_in = {f: mediana_fasi(f, 0, "in") for f in BG.FRAC_GRID}
|
||||
med_oos = {f: mediana_fasi(f, 0, "oos") for f in BG.FRAC_GRID}
|
||||
b_in, m_in = scelta(med_in)
|
||||
b_oos, m_oos = scelta(med_oos)
|
||||
T = pd.DataFrame([dict(banda=f"{f:.0%}", med_in_sample=round(med_in[f], 4),
|
||||
med_hold_out=round(med_oos[f], 4)) for f in BG.FRAC_GRID])
|
||||
print(T.to_string(index=False))
|
||||
print(f"\n scelta al buio (pre-2015): {b_in:.0%} (margine {m_in:.4f})")
|
||||
print(f" scelta sull'hold-out : {b_oos:.0%} (margine {m_oos:.4f})")
|
||||
potenza = b_in != b_oos
|
||||
print(f" -> {'DIVERSE: il gate ha potenza' if potenza else 'UGUALI: il gate NON ha potenza'}")
|
||||
|
||||
# ---------------------------------------------------------------- verdetto
|
||||
print("\n" + "=" * 100)
|
||||
print("VERDETTO — coi criteri dichiarati in testa, calcolati adesso")
|
||||
print("=" * 100)
|
||||
stabile = len(scelte) == 1
|
||||
sopra = bool(D.margine.min() >= MARGINE_MINIMO)
|
||||
print(f" (i) scelta stabile su {NOTTI} notti : {'SI' if stabile else 'NO'} — {sorted(scelte)}")
|
||||
print(f" (ii) margine sempre >= {MARGINE_MINIMO} : {'SI' if sopra else 'NO'} — minimo {D.margine.min():.4f}")
|
||||
print(f" (iii) potenza (in ≠ hold-out) : {'SI' if potenza else 'NO'}")
|
||||
if stabile and sopra and potenza:
|
||||
print("\n -> (C) e' uno strumento utilizzabile. NON e' una validazione della banda:")
|
||||
print(" va DICHIARATO come criterio, con la sua data, e la validazione va RIFATTA su di esso.")
|
||||
return 0
|
||||
manca = [n for n, v in (("i", stabile), ("ii", sopra), ("iii", potenza)) if not v]
|
||||
print(f"\n -> (C) NON basta: cade su ({'), ('.join(manca)}).")
|
||||
print(" La mediana toglie la FASE ma non le barre perse: se il verdetto si muove ancora,")
|
||||
print(" l'instabilita' e' nel DATO, e nessun criterio la aggira. Si ripara la fonte.")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env python
|
||||
"""r0828_gtaa_band_phase.py — il gate (A) della banda GTAA01 fallisce di nuovo: e' la proposta, o e' ANCORA il gate?
|
||||
|
||||
CONTESTO. Il 27/07 fu proposta la banda al 25% della gamba. Il gate (A) — SELEZIONE IN-SAMPLE —
|
||||
la valido' due volte, con due criteri diversi, e li ha rotti entrambi senza che nessuno toccasse
|
||||
il codice:
|
||||
|
||||
· 27/07 criterio `rank_in <= rank_oos`. Rotto il 07/08. Motivo (r0807_gtaa_gate_resolution):
|
||||
lo passa ~meta' della griglia PER COSTRUZIONE, e sulla proposta decideva su 0.00116
|
||||
di Sharpe. Una moneta. RITIRATO.
|
||||
· 07/08 criterio sostitutivo: «a cadenza di produzione, la banda scelta guardando SOLO il
|
||||
pre-2015 e' la proposta». Passava col 25% e un margine di +0.0151 sulla seconda —
|
||||
13x il criterio precedente. Rotto il 28/08: al buio esce il 60%, margine 0.0074,
|
||||
SOTTO il pavimento di 0.01 che il test stesso si era dato.
|
||||
|
||||
Il codice e' fermo (`git log src/portfolio/gtaa.py scripts/research/r0727_gtaa_band_gate.py`
|
||||
non ha commit dopo il 07/08). Quindi si e' mosso il DATO. Questo script chiede, per la terza
|
||||
volta e sullo stesso parametro, se il criterio misura cio' che dichiara.
|
||||
|
||||
LE DUE COSE CHE SI SONO TROVATE
|
||||
-------------------------------
|
||||
(0) **LA FINESTRA IN-SAMPLE NON E' FERMA.** IB ritorna una finestra ROTOLANTE di 30 anni. SPY e'
|
||||
l'unica gamba al muro (quotato 1993, storia servita dal 1996), e nel `logs/cron_daily.log`
|
||||
la sua data d'inizio avanza di un giorno di borsa OGNI NOTTE: 1996-07-02 il 24/06,
|
||||
1996-09-04 oggi. Il pre-2015 — che tutto il progetto tratta come una finestra congelata —
|
||||
perde il suo giorno piu' vecchio ogni notte. (QQQ e IWM colpiranno lo stesso muro nel
|
||||
2029-2030: non e' un difetto di SPY, e' il muro che avanza.)
|
||||
|
||||
(1) **L'AMPLIFICATORE E' LA FASE.** `gtaa._gated_returns` ribilancia su `i % every == 0`: l'indice
|
||||
e' la POSIZIONE NELL'ARRAY, quindi la fase e' ancorata alla prima barra del file. Se la prima
|
||||
barra scivola di un giorno, si ri-fasa ogni decisione di ribilanciamento di trent'anni di
|
||||
storia. Il progetto sa gia' che la fase conta — `r0726_loo_deluck.gtaa01_at(ph)` la tratta
|
||||
come un'ancora a 5 valori e ci de-lucka sopra — ma la assumeva FISSA alla fase canonica 0.
|
||||
Non lo e': **la fase canonica cambia da sola ogni notte**.
|
||||
|
||||
I DUE ESPERIMENTI, in quest'ordine
|
||||
----------------------------------
|
||||
(A) **LE NOTTI.** Si taglia la testa di SPY di k=0..N-1 barre, che e' esattamente cio' che la
|
||||
finestra rotolante fa da sola in N notti, e si guarda la banda scelta al buio.
|
||||
(B) **LA FASE PURA.** A dato FISSO, si sposta la sola fase di ribilanciamento di SPY (ph=0..4).
|
||||
Serve ad attribuire: se il verdetto si muove anche qui, l'imputato e' la fase, non le barre
|
||||
perse. Col controllo su TUTTE le gambe accanto, che dice quanto sarebbe peggio quando anche
|
||||
QQQ e IWM arriveranno al muro.
|
||||
|
||||
CRITERIO DEL VERDETTO, dichiarato PRIMA (M13: un criterio si misura sulla sua RISOLUZIONE prima
|
||||
che sul suo esito). Il gate (A) ha risoluzione se e solo se:
|
||||
(i) la banda scelta al buio e' LA STESSA in tutte le notti e in tutte le fasi, e
|
||||
(ii) il margine sulla seconda resta sopra MARGINE_MINIMO in tutte.
|
||||
Se (i) o (ii) cadono, il criterio non distingue la proposta da una cella qualunque, e va ritirato
|
||||
come il suo predecessore — si congela il MOTIVO, non l'esito.
|
||||
|
||||
uv run python scripts/research/r0828_gtaa_band_phase.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"))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
|
||||
|
||||
import r0727_gtaa_band_gate as BG # noqa: E402
|
||||
import src.portfolio.gtaa as G # noqa: E402
|
||||
|
||||
NOTTI = 10 # notti di finestra rotolante da simulare
|
||||
MARGINE_MINIMO = 0.01 # il pavimento che il test si e' dato il 07/08, non ritoccato qui
|
||||
MURO_IB_ANNI = 30.0 # finestra massima servita da IB: chi la tocca rotola
|
||||
PROPOSTA_BANDA = BG.PROPOSTA[1]
|
||||
|
||||
# Tabella PUBBLICATA il 07/08 (docs/diary/2026-08-07-gate-gtaa-e-storia-troncata.md, sezione (2)).
|
||||
# Serve a misurare di quanto si e' mossa una finestra che NON puo' acquisire dati nuovi.
|
||||
SH_IN_0807 = {0.00: 0.5396, 0.05: 0.5714, 0.10: 0.5956, 0.25: 0.6398, 0.40: 0.6247, 0.60: 0.5874}
|
||||
|
||||
|
||||
def _leg(sym: str, cap_leg: float, band_usd: float, every: int, ph: int,
|
||||
taglio: int = 0) -> pd.Series:
|
||||
"""`gtaa._gated_returns` con due manopole: la FASE (`ph`) e il TAGLIO in testa alla serie.
|
||||
|
||||
Copia dichiarata, non riuso: la produzione non espone ne' l'una ne' l'altra. La replica a
|
||||
(ph=0, taglio=0) deve tornare bit-exact quella di produzione — lo verifica `sanity()`.
|
||||
"""
|
||||
close = G._close(sym)
|
||||
if taglio:
|
||||
close = close.iloc[taglio:]
|
||||
ex = np.nan_to_num(np.asarray(G._exposure(close).values, float))
|
||||
px = close.values.astype(float)
|
||||
ret = np.zeros(len(px))
|
||||
ret[1:] = px[1:] / px[:-1] - 1.0
|
||||
held = np.empty(len(ex))
|
||||
comm = np.zeros(len(ex))
|
||||
cur = 0.0
|
||||
for i in range(len(ex)):
|
||||
if i % every == ph:
|
||||
notional = abs(ex[i] - cur) * cap_leg
|
||||
if notional >= max(band_usd, G.IB_MIN_TRADE_USD):
|
||||
comm[i] = G.ib_commission(notional, px[i]) / cap_leg
|
||||
cur = ex[i]
|
||||
held[i] = cur
|
||||
pos = np.zeros(len(held))
|
||||
pos[1:] = held[:-1]
|
||||
net = pos * ret - comm
|
||||
net[0] = 0.0
|
||||
return pd.Series(net, index=close.index)
|
||||
|
||||
|
||||
def cella(frac: float, ph: int = 0, taglio: int = 0, tutte: bool = False) -> pd.Series:
|
||||
"""La cella (cadenza di produzione, banda `frac`). `ph`/`taglio` si applicano alla sola SPY
|
||||
salvo `tutte`: e' SPY l'unica gamba al muro dei 30 anni, e il resto della griglia e' fermo."""
|
||||
cap_leg = BG.CAP_REF / len(G.EQ_UNIVERSE)
|
||||
band = frac * BG.CAP_REF / len(G.EQ_UNIVERSE)
|
||||
cols = {a: _leg(a, cap_leg, band, BG.PROPOSTA[0],
|
||||
ph if (tutte or a == "SPY") else 0,
|
||||
taglio if (tutte or a == "SPY") else 0)
|
||||
for a in G.EQ_UNIVERSE}
|
||||
return pd.concat(cols, axis=1, sort=True).sort_index().mean(axis=1, skipna=True).dropna()
|
||||
|
||||
|
||||
def riga(ph: int = 0, taglio: int = 0, tutte: bool = False) -> dict:
|
||||
sh = {f: BG.met(cella(f, ph, taglio, tutte).loc[: BG.HOLDOUT])["sharpe"] for f in BG.FRAC_GRID}
|
||||
best = max(sh, key=sh.get)
|
||||
secondo = max(v for f, v in sh.items() if f != best)
|
||||
return dict(scelta=best, margine=sh[best] - secondo, sh=sh)
|
||||
|
||||
|
||||
def sanity() -> bool:
|
||||
"""La replica a (fase 0, taglio 0) DEVE essere quella di produzione. Senza questo, ogni
|
||||
numero sotto misura la mia copia invece dello sleeve che gira."""
|
||||
mia = cella(PROPOSTA_BANDA, 0, 0)
|
||||
vera = BG.run_cell(BG.PROPOSTA[0], PROPOSTA_BANDA, BG.CAP_REF)
|
||||
j = pd.concat({"m": mia, "v": vera}, axis=1, join="outer")
|
||||
delta = float((j["m"] - j["v"]).abs().max())
|
||||
ok = bool(np.isfinite(delta) and delta < 1e-12 and len(j) == len(vera))
|
||||
print(f"[SANITY] replica (fase 0, taglio 0) vs produzione: max|Δ| = {delta:.2e} "
|
||||
f"barre {len(j)} vs {len(vera)} -> {'OK' if ok else 'DIVERGE'}")
|
||||
return ok
|
||||
|
||||
|
||||
def storia() -> pd.DataFrame:
|
||||
"""(0) Quali gambe sono al muro dei 30 anni — cioe' quali hanno una finestra ROTOLANTE."""
|
||||
rows = []
|
||||
for a in G.EQ_UNIVERSE:
|
||||
s = G._close(a)
|
||||
anni = (s.index[-1] - s.index[0]).days / 365.25
|
||||
rows.append(dict(gamba=a, dal=str(s.index[0].date()), barre=len(s), anni=round(anni, 1),
|
||||
al_muro="SI — rotola" if anni >= MURO_IB_ANNI - 0.15 else "no",
|
||||
anni_al_muro=round(MURO_IB_ANNI - anni, 1)))
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print(__doc__.split("\n\n")[0])
|
||||
print()
|
||||
if not sanity():
|
||||
print("!! la replica non riproduce la produzione: i numeri sotto non sono confrontabili")
|
||||
return 2
|
||||
|
||||
print("\n" + "=" * 96)
|
||||
print("(0) LA FINESTRA E' ROTOLANTE — quali gambe sono al muro dei 30 anni di IB")
|
||||
print("=" * 96)
|
||||
H = storia()
|
||||
print(H.to_string(index=False))
|
||||
rotolanti = list(H[H.al_muro.str.startswith("SI")].gamba)
|
||||
print(f"\n gambe con finestra rotolante: {rotolanti or 'nessuna'} — per queste, il pre-2015 "
|
||||
"perde il giorno piu' vecchio ogni notte.")
|
||||
|
||||
print("\n" + "=" * 96)
|
||||
print(" quanto si e' mossa una finestra che NON puo' acquisire dati nuovi (07/08 -> oggi)")
|
||||
print("=" * 96)
|
||||
oggi = riga(0, 0)["sh"]
|
||||
D = pd.DataFrame([dict(banda=f"{f:.0%}", sh_in_0807=SH_IN_0807[f], sh_in_oggi=round(oggi[f], 4),
|
||||
delta=round(oggi[f] - SH_IN_0807[f], 4)) for f in BG.FRAC_GRID])
|
||||
print(D.to_string(index=False))
|
||||
print(f"\n spostamento massimo in valore assoluto: {D.delta.abs().max():.4f} di Sharpe, "
|
||||
f"su una finestra CHIUSA (pre-2015) e a codice fermo.")
|
||||
|
||||
print("\n" + "=" * 96)
|
||||
print(f"(A) LE NOTTI — banda scelta al buio in {NOTTI} notti consecutive di finestra rotolante")
|
||||
print("=" * 96)
|
||||
spy = G._close("SPY")
|
||||
A = []
|
||||
for k in range(NOTTI):
|
||||
r = riga(0, k)
|
||||
A.append(dict(notte=k, inizio_SPY=str(spy.index[k].date()), scelta=f"{r['scelta']:.0%}",
|
||||
margine=round(r["margine"], 4),
|
||||
**{f"{f:.0%}": round(r["sh"][f], 4) for f in BG.FRAC_GRID}))
|
||||
DA = pd.DataFrame(A)
|
||||
print(DA.to_string(index=False))
|
||||
scelte_A = set(DA.scelta)
|
||||
quota_proposta = float((DA.scelta == f"{PROPOSTA_BANDA:.0%}").mean())
|
||||
print(f"\n celle distinte scelte: {sorted(scelte_A)} "
|
||||
f"| la proposta ({PROPOSTA_BANDA:.0%}) vince {quota_proposta:.0%} delle notti")
|
||||
print(f" margine: min {DA.margine.min():.4f} mediana {DA.margine.median():.4f} "
|
||||
f"max {DA.margine.max():.4f} (pavimento {MARGINE_MINIMO})")
|
||||
print(f" notti sotto il pavimento: {int((DA.margine < MARGINE_MINIMO).sum())}/{NOTTI}")
|
||||
|
||||
print("\n" + "=" * 96)
|
||||
print("(B) LA FASE PURA — dato FISSO, si muove solo la fase di ribilanciamento")
|
||||
print("=" * 96)
|
||||
for tutte, eti in ((False, f"fase mossa solo su {rotolanti or ['SPY']} (cio' che succede DAVVERO oggi)"),
|
||||
(True, "fase mossa su TUTTE le gambe (controllo: quando anche QQQ e IWM saranno al muro)")):
|
||||
B = []
|
||||
for ph in range(BG.PROPOSTA[0]):
|
||||
r = riga(ph, 0, tutte)
|
||||
B.append(dict(fase=ph, scelta=f"{r['scelta']:.0%}", margine=round(r["margine"], 4),
|
||||
**{f"{f:.0%}": round(r["sh"][f], 4) for f in BG.FRAC_GRID}))
|
||||
DB = pd.DataFrame(B)
|
||||
print(f"\n {eti}")
|
||||
print(DB.to_string(index=False))
|
||||
print(f" celle distinte: {sorted(set(DB.scelta))} | margine min {DB.margine.min():.4f}")
|
||||
if not tutte:
|
||||
scelte_B, marg_B = set(DB.scelta), DB.margine
|
||||
|
||||
print("\n" + "=" * 96)
|
||||
print("VERDETTO — coi criteri dichiarati in testa allo script, calcolati adesso")
|
||||
print("=" * 96)
|
||||
stabile = len(scelte_A) == 1 and len(scelte_B) == 1
|
||||
sopra = bool(DA.margine.min() >= MARGINE_MINIMO and marg_B.min() >= MARGINE_MINIMO)
|
||||
print(f" (i) scelta stabile su notti e fasi : {'SI' if stabile else 'NO'} "
|
||||
f"— notti {sorted(scelte_A)}, fasi {sorted(scelte_B)}")
|
||||
print(f" (ii) margine sempre >= {MARGINE_MINIMO} : {'SI' if sopra else 'NO'} "
|
||||
f"— minimo osservato {min(DA.margine.min(), marg_B.min()):.4f}")
|
||||
if stabile and sopra:
|
||||
print("\n -> il gate (A) HA risoluzione: il fallimento del test e' un fatto sulla PROPOSTA.")
|
||||
return 0
|
||||
print("\n -> il gate (A) NON ha risoluzione. Il verdetto non e' una proprieta' della banda:")
|
||||
print(" e' una proprieta' della notte in cui lo si legge. Come il criterio a ranghi")
|
||||
print(" ritirato il 07/08, va ritirato — congelando il MOTIVO, non l'esito.")
|
||||
print(f" La banda della proposta ({PROPOSTA_BANDA:.0%}) resta la scelta al buio in "
|
||||
f"{quota_proposta:.0%} delle notti: ne' confermata ne' smentita, NON DECIDIBILE cosi'.")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env python
|
||||
"""r0828_senza_gtaa.py — cosa cambia nel portafoglio di RICERCA se si toglie GTAA01.
|
||||
|
||||
PERCHE'. Il 28/08 si e' misurato che il gate (A) della banda GTAA01 non ha risoluzione, e che
|
||||
lo sleeve gira su una finestra che rotola da sola (r0828_gtaa_band_phase). Prima di decidere
|
||||
cosa fare del gate conviene sapere quanto pesa lo sleeve: se togliendolo non cambia niente, la
|
||||
domanda sul gate e' accademica.
|
||||
|
||||
⚠️ QUESTO NON E' IL LIBRO LIVE. Il book che gira e' TP01+SKH01 75/25 su Deribit: GTAA01 non c'e'.
|
||||
Qui si guarda il portafoglio a 5 sleeve di `sleeves.active_sleeves` (33/15/12/20/20), che e' la
|
||||
serie su cui stanno quasi tutti i numeri di portafoglio del progetto.
|
||||
|
||||
LE DUE LENTI, ed e' la seconda quella che decide (M6: un diversificatore a BASSO CAGR si giudica
|
||||
a iso-rischio, mai a iso-nozionale; M5: ogni claim "meno drawdown" si testa a iso-rischio prima
|
||||
di crederci, o si sta comprando de-levering e chiamandolo diversificazione):
|
||||
· ISO-NOZIONALE — si toglie GTAA01 e si ridistribuisce il suo 20% agli altri quattro in
|
||||
proporzione. E' cio' che succede materialmente.
|
||||
· ISO-RISCHIO — la stessa serie, riscalata alla volatilita' del portafoglio a 5. Risponde alla
|
||||
domanda giusta: a parita' di rischio preso, quanto rende in piu' o in meno?
|
||||
|
||||
Si stampano FULL e HOLD-OUT (la data e' quella del progetto, `portfolio.HOLDOUT`), il per-anno
|
||||
(M9: un contributo si scompone per anno prima di crederci) e la correlazione di GTAA01 col resto.
|
||||
Verdetto calcolato a runtime coi criteri dichiarati qui sotto.
|
||||
|
||||
CRITERIO, dichiarato prima: GTAA01 "porta qualcosa" se, A ISO-RISCHIO, toglierlo peggiora lo
|
||||
Sharpe FULL di almeno SOGLIA_SHARPE **e** l'hold-out non lo smentisce. Sotto quella soglia il
|
||||
contributo e' dentro lo spread fra le tre baseline che il progetto gia' si porta dietro (0.12 di
|
||||
Sharpe, §2 del CLAUDE.md), quindi non e' distinguibile dalla scelta della lente.
|
||||
|
||||
uv run python scripts/research/r0828_senza_gtaa.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))
|
||||
|
||||
from src.portfolio.portfolio import HOLDOUT, combine_outer, metrics, to_daily, yearly # noqa: E402
|
||||
from src.portfolio.sleeves import active_sleeves # noqa: E402
|
||||
|
||||
SOGLIA_SHARPE = 0.12 # spread fra le tre baseline "libro 75/25" (§2): sotto, non si vede
|
||||
FUORI = "GTAA01_eq_trend"
|
||||
|
||||
|
||||
def serie_sleeve() -> tuple[dict, dict]:
|
||||
cols, w = {}, {}
|
||||
for s in active_sleeves():
|
||||
cols[s.name] = s.daily().dropna()
|
||||
w[s.name] = float(s.weight)
|
||||
return cols, w
|
||||
|
||||
|
||||
def senza(w: dict, fuori: str) -> dict:
|
||||
"""I pesi degli altri, rinormalizzati: il 20% di GTAA01 va agli altri in proporzione."""
|
||||
resto = {k: v for k, v in w.items() if k != fuori}
|
||||
tot = sum(resto.values())
|
||||
return {k: v / tot for k, v in resto.items()}
|
||||
|
||||
|
||||
def riga(nome: str, m: dict) -> str:
|
||||
return (f" {nome:<22} Sh {m['sharpe']:>5.2f} CAGR {m['cagr']*100:>+6.1f}% "
|
||||
f"maxDD {m['maxdd']*100:>5.1f}% vol {m.get('vol', float('nan'))*100:>5.1f}% n {m['n']}")
|
||||
|
||||
|
||||
def con_vol(s: pd.Series) -> dict:
|
||||
m = metrics(s)
|
||||
m["vol"] = float(np.asarray(s.dropna().values, float).std() * np.sqrt(365.0))
|
||||
return m
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print(__doc__.split("\n\n")[0])
|
||||
cols, w5 = serie_sleeve()
|
||||
if FUORI not in cols:
|
||||
print(f"!! {FUORI} non e' fra gli sleeve attivi: niente da togliere")
|
||||
return 2
|
||||
w4 = senza(w5, FUORI)
|
||||
cols4 = {k: v for k, v in cols.items() if k != FUORI}
|
||||
|
||||
print(f"\n pesi CON : " + " ".join(f"{k.split('_')[0]} {v:.0%}" for k, v in w5.items()))
|
||||
print(f" pesi SENZA: " + " ".join(f"{k.split('_')[0]} {v:.0%}" for k, v in w4.items()))
|
||||
|
||||
con = combine_outer(cols, w5)
|
||||
sen = combine_outer(cols4, w4)
|
||||
# allineate sullo STESSO calendario: senza questo si confronterebbero due finestre diverse
|
||||
J = pd.concat({"con": con, "senza": sen}, axis=1, join="inner").dropna()
|
||||
con, sen = J["con"], J["senza"]
|
||||
|
||||
m_con, m_sen = con_vol(con), con_vol(sen)
|
||||
k = m_con["vol"] / m_sen["vol"] if m_sen["vol"] > 0 else 1.0
|
||||
sen_iso = sen * k
|
||||
m_iso = con_vol(sen_iso)
|
||||
|
||||
print("\n" + "=" * 96)
|
||||
print(f" FULL ({con.index[0].date()} -> {con.index[-1].date()})")
|
||||
print("=" * 96)
|
||||
print(riga("CON GTAA01", m_con))
|
||||
print(riga("SENZA (iso-nozionale)", m_sen))
|
||||
print(riga(f"SENZA (iso-rischio ×{k:.2f})", m_iso))
|
||||
d_full = m_con["sharpe"] - m_iso["sharpe"]
|
||||
print(f"\n Δ Sharpe a iso-rischio (con − senza): {d_full:+.3f} "
|
||||
f"[soglia dichiarata {SOGLIA_SHARPE}]")
|
||||
print(f" Δ maxDD a iso-rischio: {(m_con['maxdd']-m_iso['maxdd'])*100:+.2f} pp")
|
||||
|
||||
print("\n" + "=" * 96)
|
||||
print(f" HOLD-OUT ({HOLDOUT.date()}+)")
|
||||
print("=" * 96)
|
||||
hc, hs = con[con.index >= HOLDOUT], sen[sen.index >= HOLDOUT]
|
||||
mhc, mhs = con_vol(hc), con_vol(hs)
|
||||
kh = mhc["vol"] / mhs["vol"] if mhs["vol"] > 0 else 1.0
|
||||
mhi = con_vol(hs * kh)
|
||||
print(riga("CON GTAA01", mhc))
|
||||
print(riga(f"SENZA (iso-rischio ×{kh:.2f})", mhi))
|
||||
d_hold = mhc["sharpe"] - mhi["sharpe"]
|
||||
print(f"\n Δ Sharpe a iso-rischio: {d_hold:+.3f}")
|
||||
|
||||
print("\n" + "=" * 96)
|
||||
print(" PER ANNO — un contributo positivo si scompone prima di crederci (M9)")
|
||||
print("=" * 96)
|
||||
yc, yi = yearly(con), yearly(sen * k)
|
||||
print(f" {'anno':<6} {'CON ret':>9} {'SENZA ret':>10} {'Δ ret':>8} {'CON DD':>7} {'SENZA DD':>9}")
|
||||
pos = 0
|
||||
for a in sorted(set(yc) & set(yi)):
|
||||
d = yc[a]["ret"] - yi[a]["ret"]
|
||||
pos += d > 0
|
||||
print(f" {a:<6} {yc[a]['ret']*100:>+8.1f}% {yi[a]['ret']*100:>+9.1f}% {d*100:>+7.1f}pp "
|
||||
f"{yc[a]['dd']*100:>6.1f}% {yi[a]['dd']*100:>8.1f}%")
|
||||
n_anni = len(set(yc) & set(yi))
|
||||
print(f"\n anni in cui GTAA01 aiuta: {pos}/{n_anni}")
|
||||
|
||||
print("\n" + "=" * 96)
|
||||
print(" CORRELAZIONE — quanto e' davvero 'altro' rispetto al resto")
|
||||
print("=" * 96)
|
||||
g = cols[FUORI]
|
||||
altro = combine_outer(cols4, w4)
|
||||
Jg = pd.concat({"g": g, "altro": altro}, axis=1, join="inner").dropna()
|
||||
print(f" GTAA01 vs resto del portafoglio: corr {Jg['g'].corr(Jg['altro']):+.3f} "
|
||||
f"({len(Jg)} giorni in comune)")
|
||||
print(f" GTAA01 standalone: " + riga("", con_vol(g)).strip())
|
||||
|
||||
# ---------------------------------------------------------------- la banda di FASE sul Δ
|
||||
print("\n" + "=" * 96)
|
||||
print(" LA FASCIA DI FASE SUL Δ — il Δ e' una misura, o una proprieta' della notte?")
|
||||
print("=" * 96)
|
||||
print(" GTAA01 ribilancia su `i % 5` a partire dalla PRIMA barra del file, e la prima barra")
|
||||
print(" di SPY scivola ogni notte (finestra IB rotolante a 30 anni: r0828_gtaa_band_phase).")
|
||||
print(" Quindi il Δ qui sopra va letto accanto alla sua escursione sulle 5 fasi.")
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
try:
|
||||
from r0726_loo_deluck import gtaa01_at # replica ESATTA dello sleeve, per fase
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" non misurabile qui: {type(e).__name__}: {e}")
|
||||
gtaa01_at = None
|
||||
if gtaa01_at is not None:
|
||||
base = gtaa01_at(0)
|
||||
j0 = pd.concat({"a": base, "b": cols[FUORI]}, axis=1, join="inner").dropna()
|
||||
d0 = float((j0["a"] - j0["b"]).abs().max())
|
||||
print(f"\n [SANITY] fase 0 == sleeve di produzione: max|Δ| = {d0:.2e} "
|
||||
f"-> {'OK' if d0 < 1e-12 else 'DIVERGE (i numeri sotto non valgono)'}")
|
||||
deltas = []
|
||||
for ph in range(5):
|
||||
c = dict(cols); c[FUORI] = gtaa01_at(ph)
|
||||
cc = combine_outer(c, w5)
|
||||
Jp = pd.concat({"con": cc, "senza": sen}, axis=1, join="inner").dropna()
|
||||
mc = con_vol(Jp["con"])
|
||||
ms = con_vol(Jp["senza"])
|
||||
kk = mc["vol"] / ms["vol"] if ms["vol"] > 0 else 1.0
|
||||
d = mc["sharpe"] - con_vol(Jp["senza"] * kk)["sharpe"]
|
||||
deltas.append(d)
|
||||
print(f" fase {ph}: Sharpe CON {mc['sharpe']:.3f} Δ a iso-rischio {d:+.3f}"
|
||||
+ (" <- la fase che gira stanotte" if ph == 0 else ""))
|
||||
lo, hi = min(deltas), max(deltas)
|
||||
print(f"\n Δ su 5 fasi: da {lo:+.3f} a {hi:+.3f} (ampiezza {hi-lo:.3f}, "
|
||||
f"mediana {float(np.median(deltas)):+.3f})")
|
||||
sopra = sum(d >= SOGLIA_SHARPE for d in deltas)
|
||||
print(f" fasi in cui il Δ supera la soglia {SOGLIA_SHARPE}: {sopra}/5")
|
||||
if 0 < sopra < 5:
|
||||
print(" ⚠️ IL VERDETTO DIPENDE DALLA FASE: non e' una proprieta' di GTAA01,")
|
||||
print(" e' una proprieta' della notte in cui lo si legge.")
|
||||
|
||||
print("\n" + "=" * 96)
|
||||
print(" VERDETTO — criterio dichiarato in testa, calcolato adesso")
|
||||
print("=" * 96)
|
||||
porta = d_full >= SOGLIA_SHARPE and d_hold > -SOGLIA_SHARPE
|
||||
print(f" Δ Sharpe FULL a iso-rischio {d_full:+.3f} >= {SOGLIA_SHARPE}? "
|
||||
f"{'SI' if d_full >= SOGLIA_SHARPE else 'NO'}")
|
||||
print(f" l'hold-out non lo smentisce (Δ {d_hold:+.3f} > {-SOGLIA_SHARPE})? "
|
||||
f"{'SI' if d_hold > -SOGLIA_SHARPE else 'NO'}")
|
||||
if porta:
|
||||
print("\n -> GTAA01 PORTA: toglierlo costa piu' dello spread fra le baseline.")
|
||||
return 0
|
||||
print("\n -> il contributo di GTAA01 NON si distingue dalla scelta della lente.")
|
||||
print(" Non e' 'GTAA01 e' inutile': e' 'con questi dati non si vede'. E cio' che")
|
||||
print(" non si vede non giustifica di tenere in piedi un gate che non decide.")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+18
-1
@@ -149,6 +149,23 @@ def costruisci_prompt(pagina: str, storico: str) -> str:
|
||||
f"Scrivi ora l'analisi, e nient'altro.")
|
||||
|
||||
|
||||
def motivo_uscita(returncode: int, stdout: str | None, stderr: str | None) -> str:
|
||||
"""Il motivo di un'uscita non-zero della CLI, preso da stderr E da stdout.
|
||||
|
||||
PERCHE' ENTRAMBI. Il 2026-08-28 alle 00:37Z la CLI `claude` e' uscita 1 scrivendo
|
||||
«Failed to authenticate: OAuth session expired and could not be refreshed» su STDOUT,
|
||||
con stderr VUOTO. Leggendo il solo stderr il motivo registrato nel DB e spedito su
|
||||
Telegram fu `uscita 1: `, cioe' niente: l'allerta diceva il COSA e non il PERCHE' (P4),
|
||||
e per risalire alla causa e' servito aprire a mano il transcript della sessione headless.
|
||||
Quando la diagnosi serve il guasto e' gia' rientrato (P3): il motivo si cattura qui o mai.
|
||||
|
||||
E se non c'e' NESSUN messaggio lo si dice, invece di lasciare i due punti a vuoto:
|
||||
"la CLI non ha detto perche'" e "il perche' l'abbiamo perso noi" sono guasti diversi.
|
||||
"""
|
||||
det = " | ".join(x for x in ((stderr or "").strip(), (stdout or "").strip()) if x)
|
||||
return f"uscita {returncode}: {det[:300] if det else 'nessun messaggio su stderr ne stdout'}"
|
||||
|
||||
|
||||
def interroga(prompt: str, modello: str = MODELLO_DEFAULT,
|
||||
timeout: int = TIMEOUT_S) -> tuple[str, str | None]:
|
||||
"""Ritorna (testo, errore). L'errore si RESTITUISCE, non si ingoia: senza, il giorno che
|
||||
@@ -163,7 +180,7 @@ def interroga(prompt: str, modello: str = MODELLO_DEFAULT,
|
||||
except subprocess.TimeoutExpired:
|
||||
return "", f"timeout dopo {timeout}s"
|
||||
if p.returncode != 0:
|
||||
return "", f"uscita {p.returncode}: {(p.stderr or '').strip()[:200]}"
|
||||
return "", motivo_uscita(p.returncode, p.stdout, p.stderr)
|
||||
return pulisci(p.stdout), None
|
||||
|
||||
|
||||
|
||||
@@ -264,6 +264,37 @@ def salva(con, voce: dict, nota: str | None = None) -> None:
|
||||
# (c) MUTA sul non misurato — se il numero e' None la regola tace, non arrotonda a zero.
|
||||
# Il campo `nota` resta l'unico posto dove puo' finire un giudizio umano.
|
||||
|
||||
# =============================================================================================
|
||||
# DECISIONI PARCHEGGIATE SU UNA SOGLIA DI CAPITALE
|
||||
# =============================================================================================
|
||||
# N9: quando una decisione viene rinviata con un motivo, il motivo va registrato — altrimenti fra
|
||||
# tre mesi la stessa analisi si ripresenta come se fosse nuova. E una decisione parcheggiata su un
|
||||
# numero che NESSUNO sorveglia e' parcheggiata per sempre: qui la sorveglianza e' l'equity, che il
|
||||
# giornale legge comunque ogni giorno.
|
||||
#
|
||||
# Una riga si TOGLIE quando la decisione e' presa. Finche' c'e', la voce del giorno la ripete: e'
|
||||
# voluto — non e' un allarme che chiede un'azione impossibile (P14), e' una domanda che da quel
|
||||
# giorno in poi si puo' finalmente rispondere.
|
||||
SOGLIE_CAPITALE: tuple[tuple[float, str], ...] = (
|
||||
(15_000.0, "**GTAA01: tenere o bloccare.** Decisione rinviata a questa soglia il 2026-08-28 "
|
||||
"(diario `2026-08-28-gtaa-fase-e-allarmi`). Sotto $15k di book lo sleeve NON e' "
|
||||
"deployabile — `GTAA_MIN_CAPITAL` $3.000 allocati al peso 20% — quindi finora e' "
|
||||
"stato manutenzione senza beneficio incassabile. Contributo misurato a iso-rischio "
|
||||
"+0,095/+0,124 di Sharpe secondo la fase: reale, sempre positivo, e delle stesse "
|
||||
"dimensioni dello spread fra le baseline."),
|
||||
(20_000.0, "**«100% Deribit fino a $20k»** (decisione del 26/07): la soglia e' arrivata, e con "
|
||||
"essa la domanda del secondo conto (N4: il rischio di venue si compra con un CONTO, "
|
||||
"non con uno sleeve)."),
|
||||
)
|
||||
|
||||
|
||||
def soglie_superate(equity: float | None) -> list[tuple[float, str]]:
|
||||
"""PURA. Le decisioni parcheggiate che a questa equity si possono finalmente prendere."""
|
||||
if equity is None:
|
||||
return []
|
||||
return [(s, t) for s, t in SOGLIE_CAPITALE if equity >= s]
|
||||
|
||||
|
||||
def _bucket_convinzione(tp: float) -> str:
|
||||
"""TP01 media tre `np.sign()` e poi taglia a long-flat: la direzione vive in {0, 1/3, 1}.
|
||||
|
||||
@@ -469,6 +500,10 @@ def lettura(con, voce: dict) -> list[dict]:
|
||||
dire("evidenza", f"Campione a oggi: **{gg} giorni, {n_rt} round-trip**. A questa taglia "
|
||||
"il P&L non distingue l'edge dalla fortuna — il criterio di morte del "
|
||||
"libro (`edge_watch`) lavora su una finestra di 36 mesi.", "nota")
|
||||
|
||||
# --- decisioni che questa equity sblocca -------------------------------------------------
|
||||
for soglia, testo in soglie_superate(p.get("equity_fine")):
|
||||
dire("soglia_capitale", f"📌 **Soglia ${soglia:,.0f} superata** — {testo}", "attenzione")
|
||||
return out
|
||||
|
||||
def _n(v, fmt="{:+.2f}", vuoto="n/d"):
|
||||
|
||||
@@ -76,8 +76,12 @@ def send(text: str, tentativi: int = 1, pausa_s: float = 2.0) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def notify(title: str, data: dict | None = None) -> bool:
|
||||
"""Invia un alert formattato. Ritorna True se inviato (config presente + rete ok)."""
|
||||
def notify(title: str, data: dict | None = None, tentativi: int = 1) -> bool:
|
||||
"""Invia un alert formattato. Ritorna True se inviato (config presente + rete ok).
|
||||
|
||||
`tentativi` DEFAULT 1 = comportamento invariato per tutti i chiamanti esistenti; chi manda
|
||||
un allarme che non puo' permettersi di perdere lo alza (venue_watch: 3).
|
||||
"""
|
||||
try:
|
||||
from src.version import APP_VERSION
|
||||
ver = f" <code>v{APP_VERSION}</code>"
|
||||
@@ -86,7 +90,7 @@ def notify(title: str, data: dict | None = None) -> bool:
|
||||
lines = [f"<b>{title}</b>{ver}"]
|
||||
for k, v in (data or {}).items():
|
||||
lines.append(f" {k}: {v}")
|
||||
return send("\n".join(lines))
|
||||
return send("\n".join(lines), tentativi=tentativi)
|
||||
|
||||
|
||||
def is_configured() -> bool:
|
||||
|
||||
+47
-3
@@ -299,10 +299,33 @@ def observe() -> dict:
|
||||
return out
|
||||
|
||||
|
||||
def run_once(state_path: Path = STATE_PATH) -> dict:
|
||||
"""Un giro completo: osserva, avanza lo stato, ritorna il report (NON invia nulla).
|
||||
L'invio Telegram lo fa il chiamante, cosi' questa resta testabile senza rete d'uscita."""
|
||||
def run_once(state_path: Path = STATE_PATH, sender=None) -> dict:
|
||||
"""Un giro completo: osserva, avanza lo stato, INVIA (se gli si da' un `sender`), salva.
|
||||
|
||||
PERCHE' L'INVIO E' ENTRATO QUI. Fino al 2026-08-28 questa funzione salvava lo stato — coi
|
||||
marcatori `alerted` gia' a True — e l'invio lo faceva il chiamante DOPO. Con un tasso di
|
||||
invii falliti misurato del 6,9% (2 su 29), un 🚨 perso restava perso per l'EPISODIO INTERO:
|
||||
l'ora dopo lo stato diceva «gia' detto» e usciva WATCH/MUTO. Gli episodi storici durano
|
||||
200-2.324 ore, quindi il buco non e' teorico. Il marcatore «gia' detto» adesso si scrive
|
||||
solo se qualcuno l'ha davvero sentito.
|
||||
|
||||
`sender(report) -> (ok: bool, motivo: str)`. Iniettato, non importato: e' cio' che tiene
|
||||
questa funzione testabile senza rete d'uscita, che era la ragione del disegno precedente.
|
||||
Senza `sender` il comportamento resta quello di prima e il report lo DICE (`invio`), invece
|
||||
di lasciar credere che qualcosa sia stato spedito.
|
||||
|
||||
COSA SI DISFA SU UN INVIO FALLITO — solo i marcatori «gia' detto», non le misure:
|
||||
· asset in ALERT -> `alerted=False`: l'ora dopo ri-allerta;
|
||||
· lock MAINT / ALERT -> `alerted_soft`/`alerted_hard`=False ma le ORE restano a correre,
|
||||
cosi' una manutenzione che sfora la grazia sale ad ALERT anche se il trasporto e' giu'
|
||||
(disfare anche le ore congelerebbe l'escalation proprio mentre non si riesce a parlare);
|
||||
· lock RIENTRATO -> si ripristina l'intero LockState precedente, perche' il rientro
|
||||
si annuncia una volta sola e senza le ore a zero non ci sarebbe piu' un rientro da dire.
|
||||
Restano fuori BLIND (si ripete ogni ora da solo) e il livello WATCH (non consuma nulla).
|
||||
"""
|
||||
st = load_state(state_path)
|
||||
prima_lock = LockState(**asdict(st.lock)) # per disfare il rientro, che si dice una volta
|
||||
asset_alertati: list[str] = []
|
||||
obs = observe()
|
||||
report = {"ts": obs["ts"], "platform_locked": obs["platform_locked"],
|
||||
"locked_raw": obs.get("locked_raw"), "maintenance": obs.get("maintenance", False),
|
||||
@@ -313,6 +336,7 @@ def run_once(state_path: Path = STATE_PATH) -> dict:
|
||||
st.assets[a] = new
|
||||
report["levels"][a] = level
|
||||
if level == "ALERT":
|
||||
asset_alertati.append(a)
|
||||
report["alerts"].append(
|
||||
f"{a}: scarto {o['bps']:+.0f} bps vs consenso ({o['n_refs']} referenze) "
|
||||
f"persistente da {new.streak_hours}h a segno costante")
|
||||
@@ -345,5 +369,25 @@ def run_once(state_path: Path = STATE_PATH) -> dict:
|
||||
report["severity"] = report["severity"] or "warn"
|
||||
|
||||
st.last_ts = obs["ts"]
|
||||
|
||||
# --- l'invio, e il commit dei marcatori SOLO se e' andato ---------------------------------
|
||||
if not report["alerts"]:
|
||||
report["invio"] = "niente da dire"
|
||||
elif sender is None:
|
||||
report["invio"] = ("non tentato (nessun sender): i marcatori 'gia' detto' restano "
|
||||
"impostati, l'invio e' responsabilita' del chiamante")
|
||||
else:
|
||||
ok, motivo = sender(report)
|
||||
report["invio"] = "inviato" if ok else f"FALLITO — {motivo}"
|
||||
if not ok:
|
||||
for a in asset_alertati:
|
||||
st.assets[a].alerted = False
|
||||
if lock_lvl == "MAINT":
|
||||
st.lock.alerted_soft = False
|
||||
elif lock_lvl == "ALERT":
|
||||
st.lock.alerted_soft = st.lock.alerted_hard = False
|
||||
elif lock_lvl == "RIENTRATO":
|
||||
st.lock = prima_lock
|
||||
|
||||
save_state(st, state_path)
|
||||
return report
|
||||
|
||||
@@ -39,6 +39,59 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
|
||||
# =============================================================================================
|
||||
# IL CANALE DI ALLARME VERO
|
||||
# =============================================================================================
|
||||
# ⚠️ 2026-08-28, difetto trovato addosso di nuovo. Lanciando `uv run pytest` la suite ha mandato
|
||||
# messaggi Telegram VERI sul canale di allarme dell'operatore: cinque per giro, col testo delle
|
||||
# fixture — «Analisi di ieri.», «rete KO», «Testo dell'agente.» — sotto l'intestazione di una voce
|
||||
# di giornale del 2026-08-21. Colpevoli: i test di `analista.CLI.scrivi()`, che non passavano
|
||||
# `telegram=False` e quindi percorrevano `invia()` fino a `notifier.send()`. `_cfg()` legge il
|
||||
# token da `.env.mainnet`/`.env`, quindi il fatto che il token non fosse nell'ambiente non
|
||||
# proteggeva niente.
|
||||
#
|
||||
# NON E' COSMETICO. E' P9 al contrario: allarmi finti spesi su un canale che deve restare
|
||||
# credibile il giorno che l'allarme e' vero. Chi riceve cinque messaggi identici a ogni giro di
|
||||
# test impara a non aprirli — ed e' l'unico canale da cui passano il disaster-SL, l'uscita di
|
||||
# fondi e il venue giu'.
|
||||
#
|
||||
# LA RIPARAZIONE E' STRUTTURALE (stessa lezione del watermark): si blocca la RETE, non si chiede
|
||||
# a ogni autore di ricordarsi un parametro. Un test che vuole davvero esercitare `send()` continua
|
||||
# a funzionare: patcha `urlopen` da se' nel corpo, e il suo monkeypatch vince su questo.
|
||||
_INVII_TENTATI: list[str] = []
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _blocca_il_canale_di_allarme_vero(monkeypatch):
|
||||
"""Nessun test puo' raggiungere Telegram. Il tentativo si REGISTRA (P3: l'errore si scrive
|
||||
nel punto in cui lo si ingoia — `send()` cattura tutto, quindi senza questo elenco un invio
|
||||
tentato sparirebbe senza lasciare traccia)."""
|
||||
from src.live import notifier
|
||||
|
||||
def _vietato(url, *a, **k):
|
||||
# ⚠️ NON si registra l'URL: contiene il BOT TOKEN in chiaro, e questa lista finisce
|
||||
# dentro il messaggio di un assert — cioe' nell'output della suite, nei log di CI e
|
||||
# nello scrollback di chiunque lanci i test. Il difetto e' stato fatto e corretto il
|
||||
# 2026-08-28: la prima versione stampava `https://api.telegram.org/bot<TOKEN>/sendM…`.
|
||||
# Qui serve sapere CHE si e' tentato, non VERSO DOVE.
|
||||
_INVII_TENTATI.append("telegram sendMessage")
|
||||
raise RuntimeError("invio Telegram VERO bloccato dalla suite (tests/conftest.py)")
|
||||
|
||||
monkeypatch.setattr(notifier.urllib.request, "urlopen", _vietato)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="session")
|
||||
def _guardia_nessun_invio_vero():
|
||||
"""Bloccare non basta: se un test PROVA a mandare, va trovato e reso esplicito. Un blocco
|
||||
silenzioso lascerebbe il tentativo in giro pronto a tornare vero il giorno che qualcuno
|
||||
cambia il boundary."""
|
||||
yield
|
||||
assert not _INVII_TENTATI, (
|
||||
f"{len(_INVII_TENTATI)} test hanno PROVATO a mandare un messaggio Telegram vero "
|
||||
"(bloccati, ma il tentativo non deve esistere): passare `telegram=False` o iniettare "
|
||||
"un sender finto. Vedi la nota in testa a questo file.")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isola_file_operativi_vivi(tmp_path, monkeypatch):
|
||||
"""Ogni test scrive il watermark in una cartella temporanea, mai in `data/live/`.
|
||||
|
||||
+39
-6
@@ -139,7 +139,40 @@ def test_uscita_non_zero_ritorna_errore(monkeypatch):
|
||||
class R:
|
||||
returncode, stdout, stderr = 2, "", "boom"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **k: R())
|
||||
assert "uscita 2" in A.interroga("x")[1]
|
||||
err = A.interroga("x")[1]
|
||||
assert "uscita 2" in err and "boom" in err
|
||||
|
||||
|
||||
# Il guasto vero del 2026-08-28 00:37Z: la CLI e' uscita 1 scrivendo il motivo su STDOUT con
|
||||
# stderr VUOTO. Il motivo registrato fu `uscita 1: ` — l'allerta diceva il COSA e non il PERCHE'
|
||||
# (P4), e la causa e' stata ricostruita solo aprendo a mano il transcript della sessione headless.
|
||||
# Questo test blinda che quel messaggio arrivi fino al DB e a Telegram.
|
||||
def test_motivo_preso_da_stdout_quando_stderr_e_vuoto(monkeypatch):
|
||||
class R:
|
||||
returncode = 1
|
||||
stdout = "Failed to authenticate: OAuth session expired and could not be refreshed"
|
||||
stderr = ""
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **k: R())
|
||||
testo, err = A.interroga("x")
|
||||
assert testo == ""
|
||||
assert "OAuth session expired" in err and "uscita 1" in err
|
||||
|
||||
|
||||
def test_motivo_tiene_entrambi_i_canali():
|
||||
err = A.motivo_uscita(1, "detto su stdout", "detto su stderr")
|
||||
assert "detto su stderr" in err and "detto su stdout" in err
|
||||
|
||||
|
||||
def test_silenzio_totale_si_dichiara_invece_di_lasciare_i_due_punti_a_vuoto():
|
||||
"""'la CLI non ha detto perche'' e 'il perche' l'abbiamo perso noi' sono guasti diversi."""
|
||||
err = A.motivo_uscita(1, "", None)
|
||||
assert not err.rstrip().endswith(":")
|
||||
assert "nessun messaggio" in err
|
||||
|
||||
|
||||
def test_motivo_lungo_troncato_ma_non_vuoto():
|
||||
err = A.motivo_uscita(1, "x" * 5000, "")
|
||||
assert len(err) < 400 and "x" in err
|
||||
|
||||
|
||||
# ------------------------------------------------- il DB: cosa scrive e cosa non tocca
|
||||
@@ -158,7 +191,7 @@ def test_l_agente_non_tocca_la_nota(db, monkeypatch, tmp_path):
|
||||
_prepara(db)
|
||||
monkeypatch.setattr(A, "interroga", lambda *a, **k: ("Analisi valida senza numeri nuovi.", None))
|
||||
monkeypatch.setattr(J, "JOURNAL_DIR", tmp_path)
|
||||
CLI.scrivi(db, date(2026, 8, 21), modello="finto", verbose=False)
|
||||
CLI.scrivi(db, date(2026, 8, 21), modello="finto", verbose=False, telegram=False)
|
||||
r = db.execute("SELECT nota, analisi, analisi_stato FROM journal WHERE giorno='2026-08-21'").fetchone()
|
||||
assert r["nota"] == "nota dell'operatore"
|
||||
assert r["analisi"].startswith("Analisi valida") and r["analisi_stato"] == "ok"
|
||||
@@ -170,9 +203,9 @@ def test_se_il_modello_non_risponde_l_analisi_di_IERI_non_resta_in_pagina(db, mo
|
||||
_prepara(db)
|
||||
monkeypatch.setattr(J, "JOURNAL_DIR", tmp_path)
|
||||
monkeypatch.setattr(A, "interroga", lambda *a, **k: ("Analisi di ieri.", None))
|
||||
CLI.scrivi(db, date(2026, 8, 21), modello="finto", verbose=False)
|
||||
CLI.scrivi(db, date(2026, 8, 21), modello="finto", verbose=False, telegram=False)
|
||||
monkeypatch.setattr(A, "interroga", lambda *a, **k: ("", "rete KO"))
|
||||
CLI.scrivi(db, date(2026, 8, 21), modello="finto", verbose=False)
|
||||
CLI.scrivi(db, date(2026, 8, 21), modello="finto", verbose=False, telegram=False)
|
||||
r = db.execute("SELECT analisi, analisi_stato, analisi_motivi FROM journal "
|
||||
"WHERE giorno='2026-08-21'").fetchone()
|
||||
assert r["analisi"] == "" # niente in pagina
|
||||
@@ -184,7 +217,7 @@ def test_un_ricalcolo_del_giornale_non_cancella_l_analisi(db, monkeypatch, tmp_p
|
||||
voce = _prepara(db)
|
||||
monkeypatch.setattr(J, "JOURNAL_DIR", tmp_path)
|
||||
monkeypatch.setattr(A, "interroga", lambda *a, **k: ("Analisi da conservare.", None))
|
||||
CLI.scrivi(db, date(2026, 8, 21), modello="finto", verbose=False)
|
||||
CLI.scrivi(db, date(2026, 8, 21), modello="finto", verbose=False, telegram=False)
|
||||
J.salva(db, voce) # il cron rigenera le sezioni misurate
|
||||
r = db.execute("SELECT analisi, nota FROM journal WHERE giorno='2026-08-21'").fetchone()
|
||||
assert r["analisi"] == "Analisi da conservare." and r["nota"] == "nota dell'operatore"
|
||||
@@ -195,7 +228,7 @@ def test_il_markdown_dichiara_modello_ora_e_esito_del_controllo(db, monkeypatch,
|
||||
voce = _prepara(db)
|
||||
monkeypatch.setattr(J, "JOURNAL_DIR", tmp_path)
|
||||
monkeypatch.setattr(A, "interroga", lambda *a, **k: ("Testo dell'agente.", None))
|
||||
CLI.scrivi(db, date(2026, 8, 21), modello="modello-finto", verbose=False)
|
||||
CLI.scrivi(db, date(2026, 8, 21), modello="modello-finto", verbose=False, telegram=False)
|
||||
md = J.rendi_markdown(db, voce)
|
||||
assert "## Analisi (agente)" in md and "modello-finto" in md
|
||||
assert "puo' sbagliare" in md and "Controllo sui numeri: **ok**" in md
|
||||
|
||||
@@ -101,19 +101,50 @@ def griglia_piena():
|
||||
return G
|
||||
|
||||
|
||||
def test_la_banda_proposta_e_quella_scelta_al_buio(griglia_piena):
|
||||
"""Il gate (A), nella forma decidibile (2026-08-07).
|
||||
# ⚠️ 2026-08-28 — SECONDO CRITERIO RITIRATO, e per la stessa ragione del primo.
|
||||
# `test_la_banda_proposta_e_quella_scelta_al_buio` asseriva che la banda scelta sui soli dati
|
||||
# pre-2015 fosse la proposta. Ha smesso di passare senza che nessuno toccasse il codice: al buio
|
||||
# esce il 60%, con margine 0.0074 — sotto il pavimento di 0.01 che il test stesso si era dato.
|
||||
# Misurato in `r0828_gtaa_band_phase.py`: NON e' un fatto sulla banda, e' un fatto sul criterio.
|
||||
# · IB serve una finestra ROTOLANTE di 30 anni e SPY e' al muro: la sua prima barra avanza di
|
||||
# un giorno di borsa ogni notte. Il pre-2015 — che il progetto tratta come congelato — perde
|
||||
# il giorno piu' vecchio ogni notte.
|
||||
# · `gtaa._gated_returns` ribilancia su `i % every == 0`, cioe' sulla POSIZIONE nell'array:
|
||||
# se la prima barra scivola, si ri-fasa ogni decisione di trent'anni. La fase canonica cambia
|
||||
# da sola.
|
||||
# · Esito: su 10 notti consecutive la scelta al buio e' 25% / 40% / 60%, e il margine sta sotto
|
||||
# il pavimento in 4 notti su 10. A dato FISSO, muovendo la sola fase, il verdetto cambia lo
|
||||
# stesso (25% su 3 fasi, 60% su 2).
|
||||
# Come il 07/08 si congela il MOTIVO e non l'esito. La riparazione (ancorare la fase al calendario)
|
||||
# e la decisione su GTAA01 sono rinviate alla soglia di $15k di book — sotto quella soglia lo
|
||||
# sleeve non e' deployabile e la decisione non ha conseguenze (`journal.SOGLIE_CAPITALE`).
|
||||
|
||||
La proposta del 27/07 e' una BANDA: la cadenza settimanale e' gia' `REBAL_EVERY=5` in
|
||||
produzione e non era in discussione. Quindi la domanda sulla provenienza della scelta e'
|
||||
«a cadenza di produzione, quale banda si sceglie guardando SOLO il pre-2015?».
|
||||
"""
|
||||
|
||||
def test_il_gate_A_non_ha_RISOLUZIONE_ed_e_per_questo_che_e_stato_RITIRATO(griglia_piena):
|
||||
"""Congela il MOTIVO del secondo ritiro: il criterio decide su un margine piu' piccolo del
|
||||
rumore che lo scuote. Non asserisce QUALE cella vinca — quella si muove da sola ogni notte,
|
||||
ed e' esattamente il punto."""
|
||||
G = griglia_piena
|
||||
riga = G[G["every"] == BG.PROPOSTA[0]]
|
||||
blind = riga.sort_values("sh_in", ascending=False).iloc[0]
|
||||
assert blind["frac"] == BG.PROPOSTA[1], (
|
||||
f"al buio si sceglierebbe la banda {blind['frac']:.0%}, non {BG.PROPOSTA[1]:.0%}: "
|
||||
"la validazione del 27/07 andrebbe rifatta su quella cella")
|
||||
ordinata = riga.sort_values("sh_in", ascending=False)
|
||||
margine = float(ordinata["sh_in"].iloc[0] - ordinata["sh_in"].iloc[1])
|
||||
spread = float(riga["sh_in"].max() - riga["sh_in"].min())
|
||||
# il margine su cui il criterio decideva e' una FRAZIONE dello spread della griglia:
|
||||
# a quella scala la scelta non e' una misura, e' l'ordine in cui il rumore si e' posato.
|
||||
assert margine < 0.25 * spread, (
|
||||
f"il margine ({margine:.4f}) e' tornato grande rispetto allo spread di griglia "
|
||||
f"({spread:.4f}): se il rumore si fosse ridotto, il gate (A) andrebbe RIABILITATO "
|
||||
"invece di restare ritirato — ed e' un fatto da guardare, non da ignorare")
|
||||
|
||||
|
||||
def test_la_fase_di_ribilanciamento_e_ancora_ancorata_alla_POSIZIONE(griglia_piena):
|
||||
"""La guardia sulla CAUSA. Se un giorno `_gated_returns` ancorasse la fase al calendario,
|
||||
questo test si rompe e va riletto tutto: il gate (A) potrebbe tornare decidibile."""
|
||||
import inspect
|
||||
src = inspect.getsource(BG.gtaa_returns.__globals__["_gated_returns"])
|
||||
assert "i % every" in src, (
|
||||
"la fase non e' piu' ancorata alla posizione nell'array: la ragione del ritiro del "
|
||||
"gate (A) e' venuta meno, rileggere r0828_gtaa_band_phase.py")
|
||||
|
||||
|
||||
def test_chi_guardasse_l_hold_out_sceglierebbe_una_banda_DIVERSA(griglia_piena):
|
||||
@@ -128,15 +159,11 @@ def test_chi_guardasse_l_hold_out_sceglierebbe_una_banda_DIVERSA(griglia_piena):
|
||||
"potenza e non va citato come validazione")
|
||||
|
||||
|
||||
def test_il_margine_del_blind_non_e_un_arrotondamento(griglia_piena):
|
||||
"""La ragione per cui questo criterio ha sostituito il confronto fra ranghi: decide su
|
||||
~0.015 di Sharpe invece che su ~0.001. Se il margine scendesse sotto un centesimo, anche
|
||||
questo criterio smetterebbe di essere una misura e andrebbe ripensato a sua volta."""
|
||||
G = griglia_piena
|
||||
riga = G[G["every"] == BG.PROPOSTA[0]]
|
||||
blind = riga.sort_values("sh_in", ascending=False).iloc[0]
|
||||
secondo = riga[riga["frac"] != blind["frac"]]["sh_in"].max()
|
||||
assert blind["sh_in"] - secondo >= 0.01
|
||||
# `test_il_margine_del_blind_non_e_un_arrotondamento` (2026-08-07) chiedeva margine >= 0.01 e
|
||||
# diceva, testualmente: «Se il margine scendesse sotto un centesimo, anche questo criterio
|
||||
# smetterebbe di essere una misura e andrebbe ripensato a sua volta». Il 28/08 e' sceso a 0.0074.
|
||||
# Il test aveva ragione e si e' auto-ritirato: la sua condizione e' ora dentro il test sul MOTIVO
|
||||
# qui sopra, dove misura il margine CONTRO lo spread di griglia invece che contro un assoluto.
|
||||
|
||||
|
||||
def test_il_confronto_fra_ranghi_e_una_moneta_ed_e_per_questo_che_e_stato_RITIRATO(griglia_piena):
|
||||
|
||||
@@ -467,3 +467,37 @@ def test_la_concentrazione_non_conta_i_versamenti_come_pnl(db, monkeypatch):
|
||||
righe = J.lettura(db, _voce(pnl=p, giorno="2026-08-25"))
|
||||
conc = [r for r in righe if r["id"] == "concentrazione"]
|
||||
assert conc == [] # il cumulato di TRADING e' 0: la regola deve tacere
|
||||
|
||||
|
||||
# =============================================================================================
|
||||
# DECISIONI PARCHEGGIATE SU UNA SOGLIA DI CAPITALE
|
||||
# =============================================================================================
|
||||
# Il 2026-08-28 la decisione «GTAA01: tenere o bloccare» e' stata rinviata a $15k di book, che e'
|
||||
# la soglia sotto la quale lo sleeve non e' deployabile. Una decisione parcheggiata su un numero
|
||||
# che nessuno sorveglia e' parcheggiata per sempre (N9): questi test blindano la sorveglianza.
|
||||
|
||||
def test_sotto_soglia_non_dice_niente():
|
||||
assert J.soglie_superate(2_068.0) == []
|
||||
|
||||
|
||||
def test_la_soglia_gtaa_e_15k_e_scatta_quando_l_equity_arriva():
|
||||
soglie = [s for s, _ in J.SOGLIE_CAPITALE]
|
||||
assert 15_000.0 in soglie, "la soglia di GTAA01 non e' piu' dichiarata"
|
||||
assert J.soglie_superate(14_999.99) == []
|
||||
assert [s for s, _ in J.soglie_superate(15_000.0)] == [15_000.0]
|
||||
|
||||
|
||||
def test_le_soglie_si_accumulano_e_restano_ordinate():
|
||||
"""A $20k si devono presentare ENTRAMBE: la seconda non cancella la prima."""
|
||||
assert [s for s, _ in J.soglie_superate(20_000.0)] == [15_000.0, 20_000.0]
|
||||
|
||||
|
||||
def test_equity_non_leggibile_non_e_una_soglia_superata():
|
||||
"""P5: «non vedo» non e' «va tutto bene» — e non e' nemmeno «la soglia e' arrivata»."""
|
||||
assert J.soglie_superate(None) == []
|
||||
|
||||
|
||||
def test_ogni_soglia_dice_COSA_decidere_non_solo_che_e_arrivata():
|
||||
"""P4: un'allerta risponde a due domande. Una riga che dice solo «sei a $15k» non serve."""
|
||||
for _, testo in J.SOGLIE_CAPITALE:
|
||||
assert len(testo) > 80 and "**" in testo, "la soglia non dice quale decisione sblocca"
|
||||
|
||||
@@ -141,3 +141,67 @@ def test_leggi_jsonl_roundtrip(tmp_path):
|
||||
+ json.dumps({"ts": 2, "eq_usde": 500.05}) + "\n")
|
||||
rows = W.leggi(p)
|
||||
assert len(rows) == 2 and rows[-1]["eq_usde"] == 500.05
|
||||
|
||||
|
||||
# =============================================================================================
|
||||
# IL RENDIMENTO OSSERVATO, E LA DECISIONE DI QUOTA RINVIATA
|
||||
# =============================================================================================
|
||||
# Il 2026-08-29 il gate USDE-01 si e' chiuso IDONEO (primo reward il 28/08, +0.052055 USDE) e la
|
||||
# decisione di QUOTA e' stata rinviata dall'operatore a lunedi' 31/08: su UNA finestra il tasso
|
||||
# implicito e' 3,80% annuo, su DUE e' 1,90% — cioe' tutta la differenza che conta.
|
||||
|
||||
def _lettura(ts_ms, eq, reward=None, con_trade=False):
|
||||
return dict(ts=ts_ms, eq_usde=eq, reward_stimato=reward,
|
||||
reward_rilevato=bool(reward and reward > 1e-3), con_trade=con_trade)
|
||||
|
||||
|
||||
G = 86_400_000
|
||||
|
||||
|
||||
def test_una_finestra_che_NON_paga_abbassa_la_stima_invece_di_sparire():
|
||||
"""P5: il silenzio in una serie di ritorni si legge come zero — e qui e' proprio uno zero.
|
||||
Il denominatore e' il TEMPO, non il numero di finestre pagate."""
|
||||
recs = [_lettura(0, 500.0), _lettura(G, 500.0, 0.0), _lettura(2 * G, 500.052055, 0.052055)]
|
||||
r = W.rendimento(recs)
|
||||
assert r["finestre"] == 2 and r["pagate"] == 1
|
||||
# 0.052055 su 500 in 2 giorni -> ~1.90%, non il 3.80% della sola finestra che ha pagato
|
||||
assert 0.018 < r["apr"] < 0.021, r
|
||||
|
||||
|
||||
def test_la_finestra_sola_darebbe_il_doppio():
|
||||
"""Il controllo positivo: se si guardasse solo il pagamento, il numero raddoppierebbe.
|
||||
E' la ragione per cui l'APR si stampa sempre con il suo n."""
|
||||
r = W.rendimento([_lettura(G, 500.0), _lettura(2 * G, 500.052055, 0.052055)])
|
||||
assert 0.037 < r["apr"] < 0.040, r
|
||||
|
||||
|
||||
def test_le_letture_con_trade_nel_mezzo_si_escludono():
|
||||
"""P12: quel delta non e' solo reward, e ripararlo in silenzio sarebbe un'invenzione."""
|
||||
recs = [_lettura(0, 500.0), _lettura(G, 600.0, 100.0, con_trade=True), _lettura(2 * G, 600.05, 0.05)]
|
||||
r = W.rendimento(recs)
|
||||
assert r["finestre"] == 1 and r["tot"] == 0.05
|
||||
|
||||
|
||||
def test_troppo_poco_tempo_non_si_annualizza():
|
||||
"""Un'ora di osservazione moltiplicata per 365 e' un numero, non una misura."""
|
||||
r = W.rendimento([_lettura(0, 500.0), _lettura(3_600_000, 500.05, 0.05)])
|
||||
assert r["apr"] is None and "non si annualizza" in r["nota"]
|
||||
|
||||
|
||||
def test_nessuna_finestra_misurabile_non_e_zero():
|
||||
r = W.rendimento([_lettura(0, 500.0)])
|
||||
assert r["apr"] is None and r["finestre"] == 0
|
||||
|
||||
|
||||
def test_la_quota_si_decide_da_lunedi_e_solo_se_IDONEO():
|
||||
from datetime import datetime, timezone
|
||||
|
||||
def q(giorno, v):
|
||||
return W.quota_da_decidere(datetime.fromisoformat(giorno + "T12:35:00+00:00"), v)
|
||||
|
||||
assert W.DECISIONE_QUOTA_DAL == "2026-08-31"
|
||||
assert q("2026-08-30", "IDONEO") is False, "il rinvio dell'operatore non e' rispettato"
|
||||
assert q("2026-08-31", "IDONEO") is True, "lunedi' la domanda non viene posta"
|
||||
assert q("2026-09-30", "IDONEO") is True, "smette di chiedere: la decisione si dimentica"
|
||||
assert q("2026-08-31", "IN_ATTESA") is False, "chiede la quota su un conto non idoneo"
|
||||
assert q("2026-08-31", "NON_IDONEO") is False
|
||||
|
||||
@@ -29,6 +29,7 @@ sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
from src.live.venue_watch import (BLIND_ALERT_HOURS, PERSIST_HOURS, # noqa: E402
|
||||
THRESHOLD_BPS, AssetState, WatchState,
|
||||
dislocation_bps, load_state, save_state, step)
|
||||
import src.live.venue_watch as VW # noqa: E402 — i test sul trasporto patchano `observe`
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
@@ -416,3 +417,85 @@ def test_una_sola_ora_BLIND_spezza_lo_streak_e_impedisce_l_allarme():
|
||||
st2, lvl2 = step(st2, bps)
|
||||
assert lvl2 != "ALERT"
|
||||
assert st2.streak_hours < PERSIST_HOURS, "l'ora BLIND deve azzerare lo streak"
|
||||
|
||||
|
||||
# =============================================================================================
|
||||
# IL TRASPORTO — un 🚨 perso non deve costare l'EPISODIO INTERO
|
||||
# =============================================================================================
|
||||
# Fino al 2026-08-28 `run_once` salvava lo stato coi marcatori `alerted` gia' a True e l'invio
|
||||
# lo faceva il chiamante DOPO. Col 6,9% di invii falliti misurato (2 su 29), un allarme perso
|
||||
# restava perso finche' durava l'episodio — e gli episodi storici durano 200-2.324 ore.
|
||||
# Questi test guardano l'unica cosa che conta: dopo un invio FALLITO, l'ora dopo ri-allerta?
|
||||
|
||||
def _obs_finto(monkeypatch, bps, locked=False, maint=False):
|
||||
def fake():
|
||||
return {"ts": 1, "platform_locked": locked, "locked_raw": str(locked).lower(),
|
||||
"maintenance": maint,
|
||||
"assets": {a: dict(deribit=1.0, n_refs=3, ref_spread_bps=1.0, bps=bps)
|
||||
for a in VW.ASSETS},
|
||||
"deribit_errors": []}
|
||||
monkeypatch.setattr(VW, "observe", fake)
|
||||
|
||||
|
||||
def _porta_ad_alert(monkeypatch, tmp_path, sender):
|
||||
"""Fa salire lo streak fino all'ALERT e ritorna il report del giro che allerta."""
|
||||
sp = tmp_path / "vw.json"
|
||||
_obs_finto(monkeypatch, bps=VW.THRESHOLD_BPS * 3)
|
||||
rep = None
|
||||
for _ in range(VW.PERSIST_HOURS):
|
||||
rep = VW.run_once(state_path=sp, sender=sender)
|
||||
return sp, rep
|
||||
|
||||
|
||||
def test_invio_fallito_NON_consuma_l_allarme(monkeypatch, tmp_path):
|
||||
ko = lambda rep: (False, "ConnectionError: rete giu'")
|
||||
sp, rep = _porta_ad_alert(monkeypatch, tmp_path, ko)
|
||||
assert any(l == "ALERT" for l in rep["levels"].values())
|
||||
assert rep["invio"].startswith("FALLITO")
|
||||
# il marcatore 'gia' detto' NON deve essere sul disco
|
||||
st = VW.load_state(sp)
|
||||
assert all(not s.alerted for s in st.assets.values()), "un invio fallito ha consumato l'allarme"
|
||||
# l'ora dopo, stessa dislocazione: deve RI-allertare
|
||||
rep2 = VW.run_once(state_path=sp, sender=ko)
|
||||
assert any(l == "ALERT" for l in rep2["levels"].values()), "l'allarme non e' stato ritentato"
|
||||
|
||||
|
||||
def test_invio_riuscito_consuma_l_allarme_una_volta_sola(monkeypatch, tmp_path):
|
||||
"""Il controllo positivo: senza questo, 'ri-allerta sempre' passerebbe il test di sopra."""
|
||||
ok = lambda rep: (True, "ok")
|
||||
sp, rep = _porta_ad_alert(monkeypatch, tmp_path, ok)
|
||||
assert any(l == "ALERT" for l in rep["levels"].values())
|
||||
assert rep["invio"] == "inviato"
|
||||
rep2 = VW.run_once(state_path=sp, sender=ok)
|
||||
assert not any(l == "ALERT" for l in rep2["levels"].values()), "allarme ripetuto ogni ora"
|
||||
assert all(l == "WATCH" for l in rep2["levels"].values())
|
||||
|
||||
|
||||
def test_lock_maint_fallito_ritenta_ma_le_ORE_continuano_a_correre(monkeypatch, tmp_path):
|
||||
"""Disfare anche le ore congelerebbe l'escalation proprio mentre non si riesce a parlare:
|
||||
una manutenzione che sfora la grazia deve salire ad ALERT anche col trasporto giu'."""
|
||||
sp = tmp_path / "vw.json"
|
||||
ko = lambda rep: (False, "rete giu'")
|
||||
_obs_finto(monkeypatch, bps=0.0, locked=True, maint=True)
|
||||
livelli = [VW.run_once(state_path=sp, sender=ko)["lock_level"]
|
||||
for _ in range(VW.MAINT_GRACE_HOURS + 1)]
|
||||
assert livelli[0] == "MAINT"
|
||||
assert livelli[1] == "MAINT", "il MAINT perso non e' stato ritentato"
|
||||
assert livelli[-1] == "ALERT", "le ore non sono avanzate: l'escalation si e' congelata"
|
||||
|
||||
|
||||
def test_rientro_perso_si_riannuncia(monkeypatch, tmp_path):
|
||||
"""Il rientro si dice UNA volta e azzera le ore: senza ripristinare lo stato precedente
|
||||
non ci sarebbe piu' un rientro da annunciare."""
|
||||
sp = tmp_path / "vw.json"
|
||||
_obs_finto(monkeypatch, bps=0.0, locked=True, maint=False)
|
||||
VW.run_once(state_path=sp, sender=lambda r: (True, "ok"))
|
||||
_obs_finto(monkeypatch, bps=0.0, locked=False)
|
||||
assert VW.run_once(state_path=sp, sender=lambda r: (False, "giu'"))["lock_level"] == "RIENTRATO"
|
||||
assert VW.run_once(state_path=sp, sender=lambda r: (True, "ok"))["lock_level"] == "RIENTRATO"
|
||||
|
||||
|
||||
def test_senza_sender_il_report_lo_DICE(monkeypatch, tmp_path):
|
||||
"""'non tentato' non e' 'inviato': il silenzio non deve leggersi come un invio riuscito."""
|
||||
sp, rep = _porta_ad_alert(monkeypatch, tmp_path, None)
|
||||
assert "non tentato" in rep["invio"]
|
||||
|
||||
Reference in New Issue
Block a user