From 37d1565a1e19c8b5a84f3fcbe0b77501199ac5c6 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Thu, 10 Sep 2026 14:14:29 +0000 Subject: [PATCH] cuscino_watch: riacquisto automatico di USDE con isteresi (ECCEDENTE), bande in config, escape HTML nel sink di notify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decisione dell'operatore 10/09 («riportarla a quota in autonomia»). Verificato da revisione fable (10 segnalazioni, 9 applicate; loop numerico su usde_convert.piano: nessuna sequenza vendita→acquisto). - quarto stato ECCEDENTE: slack > cuscino_riacquisto_frac (0,40) x cuscino => acquisto di USDE fino allo stesso bersaglio della vendita, q* = 1 - 0,30 x (1 + cuscino_margine_frac 0,20) = 0,64. Isteresi [0 ; 0,40] x cuscino con bersaglio unico 0,20: per oscillare servono +-8,6% di equity USDC (~$380); un bonifico alza lo slack di 0,7 x importo e sopra ~9% dell'equity ricompra da solo. - le tre frazioni (preavviso/margine/riacquisto) in config/live.json sezione usde, lette da usde.bande_cuscino() che verifica l'ordine; quota_target 0,70 TOLTO (slack zero per costruzione). - notifier.notify: html.escape su titolo e valori — la prima allerta vera (13:53Z) era andata persa per un '<' nudo; allerta_errore registrato nel record (P3). - da ECCEDENTE un fallimento si ritenta ogni 24h (non 4 Telegram/giorno per sempre); bande fuori ordine => BLIND con motivo, non un traceback ingoiato dal cron. - CLAUDE.md §1/§4/§5.17, config _nota_usde/_nota_cuscino, diario. 0,64 e' una DERIVAZIONE dell'autore, non la quota decisa il 30/08 (0,70): dichiarato, da confermare. - test: 1070 (+8 netti). Fixes #7 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016kqvff47UBGeYfj1QeN4zE --- CLAUDE.md | 18 +- config/live.json | 7 +- .../2026-09-10-cinque-punti-revisione.md | 53 ++++++ scripts/live/cuscino_watch.py | 158 +++++++++++------- scripts/live/usde_convert.py | 4 +- src/live/notifier.py | 10 +- src/live/usde.py | 25 ++- tests/test_cuscino_watch.py | 125 ++++++++++++-- 8 files changed, 311 insertions(+), 89 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f16f9c1..7c83828 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,10 +87,10 @@ e' raggiungibile ne' ora ne' mai» (31/08) e' falsificato**; il tetto e' **spari 20:58 dello stesso giorno avevo scritto qui «terza conferma della frazione» sulla base di *nessun rifiuto fino al 31,8%*, che dimostra ≥, non =. Non spiegato (D5): puo' tornare. `venue_cap_frac` → **null**, `quota_max_frac` → **0,85** (il valore che l'operatore aveva scelto il 30/08 per questo scenario). -⚠️ **Nessuno sorveglia il cuscino USDC**: `usde_watch` allerta solo sulla quota; una perdita del libro -(che si regola in USDC) fa salire la quota da sola — a −$58 di USDC il cuscino e' gia' scoperto, a −30% -la quota passa il 98%. GATE USDE-01 in §4). ⚠️ **Ogni versamento abbassa la quota**: dopo un bonifico si -rilancia `usde_convert --quota 0.70` (che si ferma al cuscino), non si aspetta che qualcuno se ne accorga. Autorita' dei parametri: sezione `usde` di `config/live.json` +✅ **Il cuscino USDC e' sorvegliato dal 10/09** (`cuscino_watch`, §5.17): una perdita del libro (che si regola in USDC) +fa salire la quota da sola, e sotto zero di slack il sorvegliante vende USDE; prima nessuno lo guardava — a −$58 di USDC +il cuscino era gia' scoperto. GATE USDE-01 in §4). ⚠️ **Ogni versamento abbassa la quota**: dopo un bonifico si +**dal 10/09 lo fa `cuscino_watch` da solo** (stato ECCEDENTE: slack > 40% del cuscino ⇒ ricompra USDE fino a quota **0,64**, issue #7); prima si rilanciava `usde_convert --quota 0.70` a mano, e il 04/09 nessuno l'aveva fatto. Autorita' dei parametri: sezione `usde` di `config/live.json` (haircut **5%** — ✅ divergenza chiusa il 31/08, la config diceva 10% ed era sbagliato (`r0831_margini_conto.py`); oggi comunque **non si applica**, vedi sopra — 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` @@ -251,7 +251,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 → 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. **QUOTA DECISA il 30/08** (l'operatore ha anticipato il rinvio: *"porta in usde tutto il capitale che non viene usato"*): bersaglio **70%**, `usde.quota_target` in config. Tasso a 4 finestre (30/08): **3,26% annuo, banda bootstrap [1,13% – 4,51%]** — 3/4 hanno pagato (0,00 · 3,80 · 4,51 · 4,51%), e la banda larga 3,4 punti **e' il risultato**: a n=4 il tasso non e' misurato. `usde_watch.rendimento()` lo stampa **col suo n**; `quota_da_decidere()` lo chiede ogni giorno da lunedi'. 🚨 **L'EV non puo' scegliere la quota** — e' lineare in q, quindi il suo argmax e' sempre un angolo (0% o 100%): ogni quota interna nasce da un criterio sulla **coda**, dichiarato non ottimizzato (M8). Il resto e' misurato (`r0830_usde_quota.py`, diario 30/08): **hazard di pareggio = l'APR** (EV-positiva sse Ethena sta sotto il 3,3%/anno, numero **assunto** come il `p` di Deribit) · l'haircut **non morde a nessuna quota** fino al 90%, nemmeno a leva 1,50x con USDE marcato a 0,95 (29× di cuscino): **il vincolo non e' il margine** · la resa di oggi vale **$16/anno**, meno di UN mese di versamento ⇒ la leva binding resta il **bonifico** · gia' a 24,2% l'evento emittente vale **3,1× il maxDD dell'intero libro**. 📌 **Aspettare costa $1,33**: alzare a 50% fra 4 settimane invece che subito lascia sul tavolo $1,33 di resa (conversione reversibile a ~6 bps) e porta il campione da **4 a 32 finestre**, stringendo la banda ~2,8×. 🚨 **DUE FATTI DEL 30/08 CHE CAMBIANO IL GATE** (diario `2026-08-30-usde-quota-tetto-venue`). **(a) TETTO DEL VENUE ≈31-32% dell'equity — e' una FRAZIONE, non un livello.** 🚨 **SUPERATO IL 06/09: il +| **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. **QUOTA DECISA il 30/08** (l'operatore ha anticipato il rinvio: *"porta in usde tutto il capitale che non viene usato"*): bersaglio **70%**, `usde.quota_target` in config (📌 **dal 10/09 la quota e' DERIVATA dalle bande del cuscino, 0,64: §5.17**). Tasso a 4 finestre (30/08): **3,26% annuo, banda bootstrap [1,13% – 4,51%]** — 3/4 hanno pagato (0,00 · 3,80 · 4,51 · 4,51%), e la banda larga 3,4 punti **e' il risultato**: a n=4 il tasso non e' misurato. `usde_watch.rendimento()` lo stampa **col suo n**; `quota_da_decidere()` lo chiede ogni giorno da lunedi'. 🚨 **L'EV non puo' scegliere la quota** — e' lineare in q, quindi il suo argmax e' sempre un angolo (0% o 100%): ogni quota interna nasce da un criterio sulla **coda**, dichiarato non ottimizzato (M8). Il resto e' misurato (`r0830_usde_quota.py`, diario 30/08): **hazard di pareggio = l'APR** (EV-positiva sse Ethena sta sotto il 3,3%/anno, numero **assunto** come il `p` di Deribit) · l'haircut **non morde a nessuna quota** fino al 90%, nemmeno a leva 1,50x con USDE marcato a 0,95 (29× di cuscino): **il vincolo non e' il margine** · la resa di oggi vale **$16/anno**, meno di UN mese di versamento ⇒ la leva binding resta il **bonifico** · gia' a 24,2% l'evento emittente vale **3,1× il maxDD dell'intero libro**. 📌 **Aspettare costa $1,33**: alzare a 50% fra 4 settimane invece che subito lascia sul tavolo $1,33 di resa (conversione reversibile a ~6 bps) e porta il campione da **4 a 32 finestre**, stringendo la banda ~2,8×. 🚨 **DUE FATTI DEL 30/08 CHE CAMBIANO IL GATE** (diario `2026-08-30-usde-quota-tetto-venue`). **(a) TETTO DEL VENUE ≈31-32% dell'equity — e' una FRAZIONE, non un livello.** 🚨 **SUPERATO IL 06/09: il tetto non c'era piu'** — quota portata al **68,7%** con 39 ordini senza rifiuti, fermata dal cuscino di regolamento (§1). Quanto segue e' la misura del 30-31/08, vera allora, tenuta perche' il tetto puo' tornare. Ogni acquisto oltre il tetto e' rifiutato con `not_enough_funds_in_currency` pur avendo **$1.400 disponibili**: messaggio **fuorviante**, che ha fatto inseguire tre ipotesi sbagliate (taglia, cadenza, prezzo). Che sia un tetto e non un problema d'ordine e' provato a saldo neutro (BUY rifiutato → SELL OK → BUY riaccettato → BUY rifiutato); che sia una **frazione** e' provato il 31/08 lasciando scendere l'equity di $8: **il tetto e' sceso con lei** — 30/08 in [644,18·645,18) con equity $2.063,79 = **31,21-31,26%**, 31/08 in [643,18·644,18) con equity $2.055,56 = **31,29-31,34%** (0,05pp di scarto, dentro il rumore dell'equity che oscilla ±$2-8/ora). ⚠️ **Ri-sondato il 31/08 dopo il passaggio a X:SM** (l'ipotesi era che il tetto dipendesse dal modello segregato): **[654,18-655,18) con equity $2.054,90 = 31,84-31,88%**. Il modello ENTRA nel tetto ma **non lo spiega** — ha comprato **+0,55pp = +11 USDE (~$11)**, non le centinaia che servirebbero. ⇒ **il tetto SCALA col conto**: la quota resta ~31% e il valore in dollari cresce col capitale, ma **il 70% non e' raggiungibile ne' ora ne' mai**. 🚨 **Deribit non lo documenta**: l'articolo ufficiale *Yield/reward bearing coins* descrive un **Cap ETHENA** che diluisce il **TASSO** a livello di exchange (`User APR = min(bal_exchange, cap)/bal_exchange × Ethena APR`) e **nessun limite sulle quantita' detenibili** da un conto. Ora e' in config (`usde.venue_cap_frac` 0.312, bordo basso dei bracket) e `usde_convert` **rifiuta il piano prima di sparare** invece di scoprirlo dal venue. **(b) L'USDC NON frutta SUL NOSTRO CONTO — misurato, e la premessa del gate e' RESTAURATA.** `public/get_currencies` dice USDC 3,4000% (USDE 4,1071%) e per qualche ora quel numero e' stato scritto qui come se fosse nostro: **era un listino del venue, non un accredito**. Deribit paga i reward USDC **una volta al MESE** («paid out as a single monthly payment early in the following month», accrual 00:00 UTC sulla **minima equity**) — non ogni giorno come l'USDE, ed e' per questo che una sorveglianza giornaliera non poteva vederli. Verificato sui **due confini di mese a libro FLAT**, dove `equity == balance == USDC` e ogni scalino sarebbe un accredito: **29/06→08/07 equity 598,06 costante, 237/237 ore ferme, 0 scalini** (attesi $0,45) e **31/07→03/08 equity 596,92 costante per 4 giorni** (attesi **$1,72 contro una risoluzione di $0,01, 172×**). **Zero misurato, non zero sotto soglia.** ⇒ il guadagno di tenere USDE e' il **tasso pieno ~4,1%**, non uno spread: **~$26/anno** sui $644. 🚨 **Il motivo ora e' DOCUMENTATO, e non e' MiCA come avevo ipotizzato**: l'articolo ufficiale elenca le giurisdizioni escluse dai reward USDC e **l'ITALIA c'e'** (con Canada e Giappone: non e' una lista MiCA, e' policy di giurisdizione Deribit). ⚠️ **E la finestra di pagamento e' piu' larga di quella che avevo controllato**: «within the **first two weeks** of the following month». Rifatta la verifica sulle due settimane vere: **luglio e' conclusivo** (atteso $1,72 contro un'escursione TOTALE dell'equity di **$0,48** in 1-16/08, zero scalini compatibili), **giugno NO** (il libro ha iniziato a operare a meta' mese e il rumore e' della taglia del segnale). Quindi: **una finestra conclusiva piu' la lista ufficiale**, non due finestre — il «172× la risoluzione» del 30/08 era sovra-affermato. 📌 **Un tasso pubblicato non e' un tasso incassato: si verifica sul CONTO** — ed e' costato una query su una serie che avevamo gia'. Strumento: **`scripts/live/balance_watch.py`** + `cron_balance.sh` (orario :42, sola lettura) registra il **balance a 8 decimali**, la serie che mancava. 📌 **Il vincolo che l'analisi del margine non aveva guardato**: P&L e funding dei perp USDC-lineari **si regolano in USDC**, quindi la quota massima difendibile non e' data dall'haircut ma dal **cuscino di regolamento** = disaster-SL sulla massima esposizione lorda (`n_asset × frac × disaster_sl_pct` = **30% dell'equity**), che lascia esattamente il **70%** — coincidenza per costruzione, zero slack (M8: non e' un argmax). 📌 **Il costo del NON averlo e' ora documentato e vale il criterio**: un'equity negativa in una valuta paga una *collateral fee* dello **0,05% AL GIORNO** — **18,25%/anno**, addebitata al secondo, **4,3× la resa USDE che si starebbe comprando** (fonte: KB *Cross collateral specifications*). Il ribilanciamento automatico NON salva: scatta solo a **$1M** assoluti o al **100% della cross equity**, soglie che a $2k non si toccano mai — si sanguina la fee e basta. Il 30/08 avevo scritto «Deribit lo finanzia a interesse» **senza il numero**: il numero e' questo, e rende il cuscino piu' giustificato, non meno. Attrezzo: **`scripts/live/usde_convert.py`** (dry-run di default, guardie proprie; NON passa da `execution.ALLOWED`, che resta ai soli due perp) | | **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 | @@ -447,13 +447,13 @@ d'ancora in modo diverso (nella differenza si cancella in parte, nel livello per Ora **`scripts/live/cuscino_watch.py`** (cron **:53**, orario, `cron_cuscino.sh`, sorvegliato da `monitor_health`) legge l'**EQUITY USDC** (decisione dell'operatore: e' quella che risponde del regolamento, il balance vede il buco solo a trade chiuso), deriva il cuscino da `usde.cuscino_richiesto_usd` — **la formula si e' spostata in - `src/live/usde.py`, e `usde_convert` la importa da li'** (due lettori, una formula: P1) — e ha tre stati: - OK · **PREAVVISO** (slack < 10% del cuscino, ⚠️ alla transizione) · **SCOPERTO** (slack < 0: 🚨 e + `src/live/usde.py`, e `usde_convert` la importa da li'** (due lettori, una formula: P1) — e ha cinque stati: + OK · **PREAVVISO** (slack < 10% del cuscino, ⚠️ alla transizione) · **ECCEDENTE** (sotto) · BLIND · **SCOPERTO** (slack < 0: 🚨 e **riconversione automatica** `usde_convert --quota usde.quota_ripristino(0,20) --esegui`, cioe' **0,64**, che lascia il cuscino piu' un 20% — il DOPPIO del preavviso, altrimenti ogni 🚨 sarebbe seguito da un ⚠️ per costruzione). Guardie: `execution_enabled` del libro (disarmare il libro disarma anche questo), niente vendita sotto `depeg_warn` (decide l'operatore), un tentativo ogni 6h, le guardie proprie di `usde_convert`; marcatore «gia' detto» = esito dell'invio (§5.2). **Primo giro (13:00Z): gia' PREAVVISO, slack +$26** su $1.335 - richiesti — la marcatura del libro dal 06/09 lo aveva consumato senza che nessuno lo vedesse. ⚠️ **Due limiti dichiarati** (revisione fable 10/09): (a) **a quota 0,70 lo slack e' ZERO per costruzione**, quindi PREAVVISO e' lo stato normale del conto a quota piena (allerta una volta); (b) **il ripristino e' un RATCHET verso il basso** — dopo una riconversione la quota resta a 0,64 e nessun automatismo la riporta a 0,70: ricomprare e' `usde_convert --quota 0.70` a mano, decisione dell'operatore (N9: va deciso se accettare il ratchet o autorizzare un riacquisto automatico quando lo slack torna sopra il margine). Il tetto del venue in `usde_convert.piano` ora vale **solo in acquisto**: bloccava anche la vendita, e con `venue_cap_frac` rimesso in config la riconversione sarebbe stata morta. `tests/test_cuscino_watch.py` (16). + richiesti — la marcatura del libro dal 06/09 lo aveva consumato senza che nessuno lo vedesse. ✅ **Ratchet chiuso lo stesso giorno (issue #7, «riportarla a quota in autonomia»)**: quarto stato **ECCEDENTE** (slack > 40% del cuscino ⇒ **acquisto** di USDE fino allo stesso bersaglio 0,64). Le tre bande stanno in `config/live.json` (`cuscino_preavviso_frac` 0,10 · `cuscino_margine_frac` 0,20 · `cuscino_riacquisto_frac` 0,40, `usde.bande_cuscino` verifica l'ordine) e **`quota_target` 0,70 non esiste piu'**: a 0,70 lo slack era ZERO per costruzione e ogni ora in perdita avrebbe venduto. Isteresi [0 ; 0,40]×cuscino con bersaglio unico 0,20: per oscillare servono ±6% di equity di slack = **±8,6% di equity USDC (~$380)**, un giro costa ~6 bps sull'importo mosso; un bonifico alza lo slack di 0,7×importo e sopra ~9% dell'equity fa scattare il riacquisto da solo. Soglie dichiarate, non ottimizzate (M8). ⚠️ Trovato al primo giro vero (13:53Z): l'allerta PREAVVISO **non e' partita** — Telegram in HTML rifiuta un `<` nudo («(< 10%…)»); l'escape sta nel **sink** (`notifier.notify`, `html.escape` su titolo e valori: copre tutti i 45 chiamanti, incluso il campo `motivo` che la revisione ha trovato ancora scoperto), `allerta_errore` registrato nel record (P3). Un guasto persistente da ECCEDENTE (tetto del venue tornato, book illeggibile) si ritenta **una volta al giorno**, da SCOPERTO ogni 6h; una config con bande fuori ordine da' **BLIND con motivo**, non un traceback ingoiato dal cron. ⚠️ **0,64 e' una DERIVAZIONE dell'autore** (margine 0,20 sul cuscino), non una decisione dell'operatore: il bersaglio registrato il 30/08 era 0,70, e la differenza vale ~$10/anno di resa. Da confermare, o cambiare `cuscino_margine_frac` (0,10 ⇒ 0,67, al prezzo di un ⚠️ dopo ogni vendita). 📌 **Stato al 10/09 14:0xZ: quota 69,3%, PREAVVISO (slack +$30)** — sopra il bersaglio 0,64 ma sopra zero: il sorvegliante vende alla prima ora sotto zero; allinearlo subito e' `usde_convert --quota 0.64 --esegui` (237 USDE). Il tetto del venue in `usde_convert.piano` ora vale **solo in acquisto**: bloccava anche la vendita, e con `venue_cap_frac` rimesso in config la riconversione sarebbe stata morta. `tests/test_cuscino_watch.py` (24). 18. ✅ **RIPARATO (2026-09-09 sera).** `cblib.spot_series` + `asof(ts)` guardava **un'ora avanti** (feed 1h etichettato all'apertura: verificato, chiusura 1h a T == chiusura 5m a T+55 nel 100% delle barre) @@ -698,7 +698,7 @@ uv run python scripts/live/trades_db.py --reconcile # incrocio dell uv run python scripts/live/journal.py # voce del giorno (numeri + lettura) uv run python scripts/live/analista.py --secco # analisi del giorno, senza salvare uv run python scripts/live/revisione.py --secco # taglia del materiale della revisione settimanale (niente modello) -uv run pytest # test (1062, tutti verdi al 10/09) +uv run pytest # test (1070, tutti verdi al 10/09) ``` ```python diff --git a/config/live.json b/config/live.json index 39dbb19..c7e4d2f 100644 --- a/config/live.json +++ b/config/live.json @@ -10,11 +10,14 @@ "max_data_age_days": 2, "_nota_skh_feed": "Freschezza del feed 5m usato per il segnale SKH01 (2026-07-26). fresh_5m ricade sul feed certificato IN SILENZIO se il fetch pubblico Deribit fallisce, e il certificato si rigenera 1x/giorno: senza controllo la latenza d'uscita di SKH01 passa da ~1h a ~1 giorno senza segnalazione. Sopra soglia book_execute ALLERTA e NON blocca (bloccare fermerebbe anche TP01, nettato sullo stesso strumento, per un guasto di rete). Diario 2026-07-26-t1-esecuzione-skh-live.md.", "skh_feed_max_age_min": 30, - "_nota_usde": "Collaterale USDE a rendimento (test di eligibilita' 2026-08-26: 500 USDE, verdetto entro il 29/08 — diario 2026-08-26-usde-analisi). L'autorita' che LEGGE questa sezione e' src/live/usde.py; la usano shadow._collaterale_usde (equity oraria del book) e scripts/live/usde_watch.py (sorveglianza giornaliera 12:35 UTC). Criteri delle soglie, dichiarati (P6): depeg_warn 0.99 = fuori dalla banda operativa dello spot (~3 bps) e oltre il clamp +-0.5% per fonte dell'indice usde_usdc — a quel prezzo non e' rumore di book; depeg_crit 0.95 = meta' del buffer di haircut (10%) consumata. quota_target 0.70 = la quota DECISA dall'operatore il 2026-08-30 (la decisione di quota che il gate USDE-01 apriva; anticipata di un giorno sul rinvio al 31/08, su richiesta esplicita dell'operatore \"porta in usde tutto il capitale che non viene usato\"). Il 70% NON e' un argmax (M8): e' il massimo compatibile col CUSCINO DI REGOLAMENTO, cioe' il vincolo che r0830_usde_quota non aveva guardato — il P&L e il funding dei perp USDC-lineari si regolano in USDC, non nel collaterale, quindi a quota alta il saldo USDC va negativo alla prima perdita del libro e Deribit lo finanzia a interesse. Il cuscino richiesto e' il disaster-SL sulla massima esposizione lorda: n_asset x frac x disaster_sl_pct = 2 x 0.5 x 0.30 = 30% dell'equity, che lascia esattamente il 70%. quota_max_frac 0.50 = tetto di ALLERTA sulla quota USDE/equity totale (N4: la quota e' l'unica leva contro il rischio emittente, -100% = 25 anni di resa, non recuperabile). Alzato a 0.85 il 2026-08-30 in previsione della quota al 70% e RIMESSO A 0.50 lo stesso giorno perche' il 70% pareva irraggiungibile; RIMESSO A 0.85 il 2026-09-06, quando la quota e' arrivata al 68,7% su autorizzazione dell'operatore e la sonda non ha trovato tetto. 🚨 venue_cap_frac: il 06/09 il tetto NON C'ERA (null, vedi venue_cap_misurato) — quanto segue e' la storia del 30-31/08, tenuta perche' il tetto puo' tornare: venue_cap_frac 0.318 = TETTO DEL VENUE sull'USDE, misurato e NON documentato da Deribit (ne' 'Cross collateral specifications' ne' 'Yield-generating collateral' prevedono un limite sulle quantita' detenibili; il Cap ETHENA che esiste diluisce il TASSO a livello di exchange, non limita gli acquisti). Ogni acquisto oltre il tetto e' rifiutato con `not_enough_funds_in_currency` pur avendo $1.400 disponibili: messaggio FUORVIANTE. E' una FRAZIONE dell'equity, non un livello: provato il 31/08 lasciando scendere l'equity di $8, il tetto e' sceso con lei. ⚠️ DIPENDE DAL MODELLO DI MARGINE, ma pochissimo — misurato a saldo neutro sotto entrambi: SEGREGATO S:SM [643,18-644,18) con equity $2.055,56 = 31,29-31,34%; CROSS X:SM [654,18-655,18) con equity $2.054,90 = 31,84-31,88%. Il passaggio a cross ha comprato +0,55pp = **+11 USDE (~$11)**: il modello entra nel tetto, ma non lo spiega. ⇒ la quota resta ~31-32% sotto qualunque configurazione e il 70% di quota_target NON e' raggiungibile (manca un fattore ~2,2x). Il valore 0.318 e' il bordo BASSO del bracket CROSS, che e' il modello attivo: fa fallire il piano PRIMA dell'ordine. Se si torna a S:SM va rimesso a 0.312. haircut 0.05 — ✅ DIVERGENZA CHIUSA il 2026-08-31 (era 0.10, SBAGLIATO). La pagina margini del conto non espone l'haircut come numero: si RICAVA per differenza fra le due righe, perche' il modello CROSS conta l'USDE scontato e il SEGREGATO non lo conta affatto. Contributo dell'USDE al cross $610,81 su $643,05 di valore -> 5,0137%: l'ipotesi 5% torna a $0,09, la 10% sbaglia di $32,06. Riproducibile: `scripts/research/r0831_margini_conto.py` (N11). Il 26/08 il 10% fu registrato come 'verificato sul venue' senza lasciare traccia di come, e non lo era. 🚨 SCOPERTO NELLA STESSA LETTURA, e vale piu' dell'haircut: il modello di margine ATTIVO e' **Segregated: Standard Margin (S:SM)**, NON cross-collateral. Nella tabella del modello attivo l'USDE **non compare**: non fa margine per i perp USDC-settled del book. Percio' l'haircut oggi non si applica affatto — ed e' esattamente il motivo per cui la misura del 31/08 trovava $0,0000 accantonati. Passare a X:SM aggiungerebbe $610,87 di margine utilizzabile: e' una decisione dell'operatore, non un refactor, e porta con se' la meccanica cross (collateral fee 0,05%/giorno sul saldo negativo, ribilanciamento automatico).", + "_nota_usde": "Collaterale USDE a rendimento (test di eligibilita' 2026-08-26: 500 USDE, verdetto entro il 29/08 — diario 2026-08-26-usde-analisi). L'autorita' che LEGGE questa sezione e' src/live/usde.py; la usano shadow._collaterale_usde (equity oraria del book) e scripts/live/usde_watch.py (sorveglianza giornaliera 12:35 UTC). Criteri delle soglie, dichiarati (P6): depeg_warn 0.99 = fuori dalla banda operativa dello spot (~3 bps) e oltre il clamp +-0.5% per fonte dell'indice usde_usdc — a quel prezzo non e' rumore di book; depeg_crit 0.95 = meta' del buffer di haircut (10%) consumata. quota_target 0.70 (CHIAVE TOLTA il 2026-09-10, issue #7: il bersaglio operativo e' DERIVATO dalle bande del cuscino, vedi usde._nota_cuscino — quanto segue e' la storia della decisione) = la quota DECISA dall'operatore il 2026-08-30 (la decisione di quota che il gate USDE-01 apriva; anticipata di un giorno sul rinvio al 31/08, su richiesta esplicita dell'operatore \"porta in usde tutto il capitale che non viene usato\"). Il 70% NON e' un argmax (M8): e' il massimo compatibile col CUSCINO DI REGOLAMENTO, cioe' il vincolo che r0830_usde_quota non aveva guardato — il P&L e il funding dei perp USDC-lineari si regolano in USDC, non nel collaterale, quindi a quota alta il saldo USDC va negativo alla prima perdita del libro e Deribit lo finanzia a interesse. Il cuscino richiesto e' il disaster-SL sulla massima esposizione lorda: n_asset x frac x disaster_sl_pct = 2 x 0.5 x 0.30 = 30% dell'equity, che lascia esattamente il 70%. quota_max_frac 0.50 = tetto di ALLERTA sulla quota USDE/equity totale (N4: la quota e' l'unica leva contro il rischio emittente, -100% = 25 anni di resa, non recuperabile). Alzato a 0.85 il 2026-08-30 in previsione della quota al 70% e RIMESSO A 0.50 lo stesso giorno perche' il 70% pareva irraggiungibile; RIMESSO A 0.85 il 2026-09-06, quando la quota e' arrivata al 68,7% su autorizzazione dell'operatore e la sonda non ha trovato tetto. 🚨 venue_cap_frac: il 06/09 il tetto NON C'ERA (null, vedi venue_cap_misurato) — quanto segue e' la storia del 30-31/08, tenuta perche' il tetto puo' tornare: venue_cap_frac 0.318 = TETTO DEL VENUE sull'USDE, misurato e NON documentato da Deribit (ne' 'Cross collateral specifications' ne' 'Yield-generating collateral' prevedono un limite sulle quantita' detenibili; il Cap ETHENA che esiste diluisce il TASSO a livello di exchange, non limita gli acquisti). Ogni acquisto oltre il tetto e' rifiutato con `not_enough_funds_in_currency` pur avendo $1.400 disponibili: messaggio FUORVIANTE. E' una FRAZIONE dell'equity, non un livello: provato il 31/08 lasciando scendere l'equity di $8, il tetto e' sceso con lei. ⚠️ DIPENDE DAL MODELLO DI MARGINE, ma pochissimo — misurato a saldo neutro sotto entrambi: SEGREGATO S:SM [643,18-644,18) con equity $2.055,56 = 31,29-31,34%; CROSS X:SM [654,18-655,18) con equity $2.054,90 = 31,84-31,88%. Il passaggio a cross ha comprato +0,55pp = **+11 USDE (~$11)**: il modello entra nel tetto, ma non lo spiega. ⇒ la quota resta ~31-32% sotto qualunque configurazione e il 70% di quota_target NON e' raggiungibile (manca un fattore ~2,2x). Il valore 0.318 e' il bordo BASSO del bracket CROSS, che e' il modello attivo: fa fallire il piano PRIMA dell'ordine. Se si torna a S:SM va rimesso a 0.312. haircut 0.05 — ✅ DIVERGENZA CHIUSA il 2026-08-31 (era 0.10, SBAGLIATO). La pagina margini del conto non espone l'haircut come numero: si RICAVA per differenza fra le due righe, perche' il modello CROSS conta l'USDE scontato e il SEGREGATO non lo conta affatto. Contributo dell'USDE al cross $610,81 su $643,05 di valore -> 5,0137%: l'ipotesi 5% torna a $0,09, la 10% sbaglia di $32,06. Riproducibile: `scripts/research/r0831_margini_conto.py` (N11). Il 26/08 il 10% fu registrato come 'verificato sul venue' senza lasciare traccia di come, e non lo era. 🚨 SCOPERTO NELLA STESSA LETTURA, e vale piu' dell'haircut: il modello di margine ATTIVO e' **Segregated: Standard Margin (S:SM)**, NON cross-collateral. Nella tabella del modello attivo l'USDE **non compare**: non fa margine per i perp USDC-settled del book. Percio' l'haircut oggi non si applica affatto — ed e' esattamente il motivo per cui la misura del 31/08 trovava $0,0000 accantonati. Passare a X:SM aggiungerebbe $610,87 di margine utilizzabile: e' una decisione dell'operatore, non un refactor, e porta con se' la meccanica cross (collateral fee 0,05%/giorno sul saldo negativo, ribilanciamento automatico).", "usde": { "index_name": "usde_usdc", "haircut": 0.05, - "quota_target": 0.7, + "_nota_cuscino": "Dal 2026-09-10 (issue #7) la quota USDE NON e' un numero scelto: e' DERIVATA dal cuscino USDC di regolamento (n_asset x frac x disaster_sl_pct = 30% dell'equity) e da queste tre frazioni del cuscino, lette da src/live/usde.py per scripts/live/cuscino_watch.py (cron :53). cuscino_margine_frac 0.20 = bersaglio unico di ogni conversione: quota = 1 - 0.30 x 1.20 = 0.64 (il vecchio quota_target 0.70 lasciava slack ZERO: ogni ora in perdita avrebbe venduto). cuscino_preavviso_frac 0.10 = sotto, allerta. cuscino_riacquisto_frac 0.40 = sopra, si RICOMPRA USDE fino al bersaglio (un bonifico alza lo slack di 0,7 x importo e fa scattare il riacquisto da solo). Isteresi [0 ; 0.40] x cuscino con bersaglio a 0.20: oscillare richiede +-6% di equity di slack, cioe' +-8,6% di equity USDC (slack = 0,7 USDC - 0,3 USDE). Soglie DICHIARATE, non ottimizzate (M8): un giro costa ~6 bps sull'importo mosso. Ordine obbligato: 0 < preavviso < margine < riacquisto (usde.bande_cuscino lo verifica).", + "cuscino_preavviso_frac": 0.1, + "cuscino_margine_frac": 0.2, + "cuscino_riacquisto_frac": 0.4, "venue_cap_frac": null, "venue_cap_misurato": "2026-09-06: NESSUN tetto fino al 68,7% (fermata dal cuscino di regolamento al 70%, non dal venue): +2.434 USDE in 39 ordini a saldo crescente, zero rifiuti (r0906_usde_tetto_sonda.py). Il 30-31/08 il tetto c'era: [643-644) e [654-655) USDE su ~$2.055 = 31,3-31,9%. Non spiegato: e' SPARITO, non e' scalato. null = nessun tetto noto; usde_convert si affida al chunk+backoff sui rifiuti", "quota_max_frac": 0.85, diff --git a/docs/diary/2026-09-10-cinque-punti-revisione.md b/docs/diary/2026-09-10-cinque-punti-revisione.md index 33a26a5..8271b7f 100644 --- a/docs/diary/2026-09-10-cinque-punti-revisione.md +++ b/docs/diary/2026-09-10-cinque-punti-revisione.md @@ -154,3 +154,56 @@ parziali); oggi il feed rivisto e la serie coincidono tranne quella barra. Non applicato: il lock fra cron e conversione a mano (DUBBIO 2) — dichiarato come limite nel docstring. Il DUBBIO 1 (ratchet) e' la domanda aperta per l'operatore. + +## Coda (13:55Z →): il riacquisto automatico — issue #7 + +Alla domanda aperta (ratchet verso il basso) l'operatore ha risposto «crea una strategia per +riportarla a quota in autonomia». La risposta ingenua — ricomprare fino a `quota_target` 0,70 — +e' sbagliata per costruzione: a 0,70 lo slack e' zero, la prima ora in perdita vende, il +riacquisto ricompra, e cosi' via a 6 bps al giro. Serve **isteresi con un bersaglio unico**: + +| slack / cuscino | stato | azione | +|---|---|---| +| < 0 | SCOPERTO | 🚨 vende USDE → 0,20 | +| 0 – 0,10 | PREAVVISO | ⚠️ | +| 0,10 – 0,40 | OK | — | +| > 0,40 | **ECCEDENTE** | 📌 compra USDE → 0,20 | + +Bersaglio 0,20 ⇒ quota **0,64**. Per oscillare lo slack deve muoversi di ±0,20×cuscino = ±6% +dell'equity, cioe' **±8,6% di equity USDC (~$380)** perche' slack = 0,7·USDC − 0,3·USDE (la prima +stesura diceva ±6%/$270: corretta in revisione): a leva 0,26x sono ~33% di mercato. Un bonifico alza lo slack di +0,7×importo: sopra ~9% dell'equity (~$380) il riacquisto scatta da solo — la regola di §1 +«dopo un bonifico si rilancia `usde_convert`» non dipende piu' da qualcuno che se ne ricordi. +Le tre frazioni vivono in `config/live.json` (`usde.bande_cuscino` verifica 0 < preavviso < +margine < riacquisto), `quota_target` 0,70 e' stato tolto. Soglie dichiarate, non ottimizzate +(M8): il costo di un giro e' ~$0,2, la frequenza attesa qualche giro al mese nei tratti mossi. + +📌 **Trovato al primo giro vero del cron (13:53Z): l'allerta PREAVVISO non e' partita.** Il token +era valido (`getMe` ok); il testo conteneva «(< 10% del cuscino)» e Telegram in `parse_mode=HTML` +rifiuta un `<` nudo. Testi riscritti senza `<`, `allerta_errore` registrato nel record (P3), +test che legge il sorgente delle `notify`. Un sorvegliante nuovo che non riesce a parlare al +primo giro e' il caso di P2: si valida sul segnale E sul trasporto. + +Stato al giro a secco delle 14:0xZ: quota 69,3%, slack +$30 = PREAVVISO. Il piano a 0,64 e' +valido (SELL 237 USDE, slack dopo +$267): il sorvegliante lo esegue alla prima ora sotto zero; +allinearlo subito e' una riga a mano. + +### Seconda revisione `fable` (14:04Z): 10 segnalazioni, 1 ALTA, 3 MEDIE — applicate 9 + +| # | segnalazione | esito | +|---|---|---| +| 1 ALTA | il `<` arrivava ancora a Telegram dal campo `motivo` («(< 6h)») dentro l'allerta 🚨: il 🚨 piu' importante perso fino a 4h | escape nel **sink** `notifier.notify` (`html.escape` su titolo e valori; nessuno dei 45 chiamanti passa tag: grep) + motivi senza `<` | +| 2 MEDIA | il test sul sorgente era parziale (codice morto, cieco ai valori interpolati) | sostituito: cattura la POST e verifica `<` | +| 3 MEDIA | ECCEDENTE con guasto persistente = «acquisto FALLITA» ogni 6h per sempre | da ECCEDENTE dopo un fallimento si riprova ogni 24h; da SCOPERTO resta 6h | +| 4 MEDIA | bande fuori ordine ⇒ traceback ingoiato dal cron, nessun record, «fermo» invece di «rotto» | `giudica` ⇒ BLIND con motivo; record e allerta scritti | +| 5-7 | CLAUDE.md §1 diceva ancora «nessuno sorveglia»; §5.17 «tre stati»; `_nota_usde` con `quota_target` | corretti | +| 8 | «±6% di equity USDC» sbagliato di 0,7: slack = 0,7·USDC − 0,3·USDE ⇒ ±8,6% (~$380) | corretto in 4 posti | +| 9 | `TOLL` ridichiarato nel test | importato da `usde_convert` | +| 10 | esempi `--quota 0.70` nel docstring di `usde_convert` | 0,64 | + +Verifiche numeriche del revisore (loop su `piano()` con passo 1 USDE, TOLL, min order): bonifico ++$2.000 → 11 acquisti → OK, slack +$387; perdita −$50 → 3 vendite → OK, +$264; conto tutto USDC → +20 acquisti → OK. **Nessuna sequenza vendita→acquisto→vendita.** Non applicato: `min(q*, cap)` con +il tetto del venue (dubbio 3: un conto al tetto resta ECCEDENTE e ora allerta una volta al giorno). +Dubbio 4, che vale una riga in §5.17: **0,64 e' una derivazione dell'autore, non la quota decisa +dall'operatore il 30/08 (0,70)**; ~$10/anno di resa di differenza; da confermare. diff --git a/scripts/live/cuscino_watch.py b/scripts/live/cuscino_watch.py index 6852d0e..bfe7134 100755 --- a/scripts/live/cuscino_watch.py +++ b/scripts/live/cuscino_watch.py @@ -14,24 +14,33 @@ trade chiuso. Il cuscino richiesto e' DERIVATO da config/live.json e src/live/bo che `usde_convert` applica a ogni piano — un sorvegliante che ridichiara il proprio bersaglio non sta controllando niente (P1, 5 occorrenze nel progetto). -TRE STATI, TRE AZIONI (P5: distinguere anche quando l'azione e' la stessa): - * OK — slack >= PREAVVISO_FRAC x cuscino: niente. - * PREAVVISO — 0 <= slack < 10% del cuscino: ⚠️ alla transizione. E' latenza comprata apposta: - un'ora in perdita non deve trovare il cuscino gia' scoperto. - * SCOPERTO — slack < 0: 🚨 alla transizione, e RICONVERSIONE AUTOMATICA (decisione - dell'operatore, 10/09): lancia `usde_convert --quota q --esegui` con - q = usde.quota_ripristino(MARGINE_RIPRISTINO), cioe' la quota che lascia il cuscino - piu' un 20% di margine, cosi' non si torna sul bordo alla prima ora in perdita. - Vende USDE contro USDC (~6 bps di spread, fee 0). +QUATTRO STATI SULLO SLACK (= equity USDC − cuscino), in frazioni del cuscino lette da config +(`usde.bande_cuscino`: preavviso 0,10 · margine 0,20 · riacquisto 0,40 — P5, P1): + * SCOPERTO — slack < 0: 🚨 alla transizione, e VENDITA automatica di USDE fino alla quota di + ripristino q* = usde.quota_ripristino() = 1 − 0,30 × 1,20 = 0,64 (cuscino + 20%). + * PREAVVISO — 0 <= slack < 10% del cuscino: ⚠️ alla transizione. Latenza comprata apposta. + * OK — 10% <= slack <= 40%: niente. + * ECCEDENTE — slack > 40% del cuscino: ACQUISTO automatico di USDE fino allo stesso q* (issue #7, + decisione dell'operatore 10/09: «riportarla a quota in autonomia»). E' cio' che + riporta la quota a bersaglio dopo un bonifico (+0,7 × importo di slack) o dopo un + tratto di guadagni del libro, senza che nessuno se ne ricordi. 📌 alla transizione. * BLIND — conto non leggibile: ⚠️ alla transizione. "Non vedo" non e' "va tutto bene". +ISTERESI: vendita sotto 0, acquisto sopra 0,40, entrambe verso 0,20. Per oscillare lo slack deve +muoversi di ±0,20 × cuscino = ±6% dell'equity, e siccome slack = 0,7·USDC − 0,3·USDE, servono +±8,6% di equity USDC (~$380 oggi); un giro costa ~6 bps sull'importo mosso. Soglie DICHIARATE, non ottimizzate (M8). Il vecchio `quota_target` 0,70 +lasciava slack ZERO per costruzione: a quella quota ogni ora in perdita avrebbe venduto. LE GUARDIE DELLA RICONVERSIONE (tutte dichiarate qui): * `execution_enabled` in config/live.json deve essere true — lo stesso interruttore del libro: disarmare il libro disarma anche questo, senza un secondo posto da ricordare. - * NON si riconverte sotto `depeg_warn`: vendere USDE sotto la pari cristallizza la perdita del - collaterale; li' decide l'operatore, e l'allarme lo dice. - * al massimo UN tentativo ogni RIPRISTINO_MIN_ORE (6h): un tentativo fallito che si ripete ogni - ora e' spam, e un sorvegliante che si ripete si impara a ignorare (P14). + * NON si converte sotto `depeg_warn`, in nessun verso: vendere USDE sotto la pari cristallizza + la perdita del collaterale, comprarne mentre scivola e' peggio; li' decide l'operatore. + * al massimo UN tentativo ogni RIPRISTINO_MIN_ORE (6h), in qualunque verso: un tentativo che si + ripete ogni ora e' spam, e un sorvegliante che si ripete si impara a ignorare (P14). + * dopo un tentativo FALLITO da ECCEDENTE si riprova dopo RITENTATIVO_FALLITO_ORE (24h): USDC + fermo non e' un rischio, e un guasto persistente (tetto del venue tornato, book illeggibile) + diventerebbe 4 Telegram al giorno per sempre (revisione fable 10/09). Da SCOPERTO il ritmo + resta 6h: li' il fallimento e' un rischio che costa 0,05%/giorno. * `usde_convert` porta le proprie guardie (banda di prezzo, book leggibile, tetto HARD sulla quota): questo script NON le duplica, le eredita lanciando lo script vero (P15: la regola si prova contro il codice che la esegue). @@ -39,12 +48,6 @@ LE GUARDIE DELLA RICONVERSIONE (tutte dichiarate qui): * niente lock: un `usde_convert --esegui` lanciato a mano nello stesso minuto del cron (:53) puo' vendere due volte verso bersagli diversi. Limite dichiarato (D5), non riparato. -LIMITE DICHIARATO — IL RIPRISTINO E' UN RATCHET VERSO IL BASSO. Dopo una riconversione la quota -resta a `quota_ripristino` (~0,64) e nessuno la riporta a `usde.quota_target` (0,70): ricomprare -USDE e' una decisione dell'operatore (`usde_convert --quota 0.70`), non un automatismo di questo -script. E a 0,70 lo slack e' ZERO per costruzione (§1: «coincidenza per costruzione, zero slack»), -quindi PREAVVISO e' lo stato NORMALE del conto a quota piena — allerta una volta, alla transizione. - TRASPORTO (lezione del debito §5.2): il marcatore "gia' detto" e' l'esito di `notify` scritto nel record (`allertato`); se l'invio fallisce la transizione si ripete al giro dopo invece di andare persa per l'episodio intero. @@ -72,12 +75,11 @@ from src.live.notifier import notify # noqa: E402 STATE = ROOT / "data" / "live" / "cuscino_watch.jsonl" CONVERT = ROOT / "scripts" / "live" / "usde_convert.py" -PREAVVISO_FRAC = 0.10 # sotto il 10% di slack (in frazione del cuscino) si preavvisa -MARGINE_RIPRISTINO = 0.20 # la riconversione lascia cuscino x 1,20: DOPPIO del preavviso, cosi' - # dopo il ripristino lo stato e' OK e non PREAVVISO per costruzione - # (revisione fable 10/09: a 0,10 == PREAVVISO_FRAC il floor di - # usde_convert lasciava il conto sotto la soglia) -RIPRISTINO_MIN_ORE = 6.0 # un tentativo al massimo ogni 6 ore +# Le bande (preavviso / margine / riacquisto) NON stanno qui: si leggono da config via +# usde.bande_cuscino() a ogni giro (P1). Il margine e' il DOPPIO del preavviso per costruzione +# dell'ordine imposto (revisione fable 10/09: a margine == preavviso ogni 🚨 era seguito da un ⚠️). +RIPRISTINO_MIN_ORE = 6.0 # un tentativo di conversione (in qualunque verso) ogni 6 ore +RITENTATIVO_FALLITO_ORE = 24.0 # da ECCEDENTE, dopo un fallimento: un tentativo al giorno TIMEOUT_CONVERT_S = 900 # usde_convert spazia gli ordini di 12s: 60 ordini = 12 min @@ -93,19 +95,31 @@ def leggi(path: Path = STATE) -> list[dict]: def giudica(eq_usdc: float | None, equity_tot: float | None, cuscino: float | None, - preavviso_frac: float = PREAVVISO_FRAC) -> dict: - """PURA. -> stato, slack, soglia di preavviso. `None` in ingresso = BLIND.""" + bande: dict | None = None) -> dict: + """PURA. -> stato, slack, soglie in dollari. `None` in ingresso = BLIND. `bande` = le frazioni + del cuscino (default: config, via usde.bande_cuscino).""" if eq_usdc is None or equity_tot is None or cuscino is None: - return dict(stato="BLIND", slack=None, preavviso_usd=None) + return dict(stato="BLIND", slack=None, preavviso_usd=None, riacquisto_usd=None) + try: + b = bande or U.bande_cuscino() + except Exception as e: # noqa: BLE001 — config rotta = "non vedo", non un crash + return dict(stato="BLIND", slack=None, preavviso_usd=None, riacquisto_usd=None, + motivo=f"bande del cuscino non leggibili: {e}") slack = eq_usdc - cuscino - preavviso = preavviso_frac * cuscino + preavviso, riacquisto = b["preavviso"] * cuscino, b["riacquisto"] * cuscino if slack < 0: stato = "SCOPERTO" elif slack < preavviso: stato = "PREAVVISO" + elif slack > riacquisto: + stato = "ECCEDENTE" else: stato = "OK" - return dict(stato=stato, slack=round(slack, 2), preavviso_usd=round(preavviso, 2)) + return dict(stato=stato, slack=round(slack, 2), preavviso_usd=round(preavviso, 2), + riacquisto_usd=round(riacquisto, 2)) + + +AZIONE = {"SCOPERTO": "vendita", "ECCEDENTE": "acquisto"} # gli stati che convertono def transizione(prev: dict | None, stato: str) -> bool: @@ -120,21 +134,27 @@ def transizione(prev: dict | None, stato: str) -> bool: def puo_riconvertire(prev_records: list[dict], now_ms: int, execution_enabled: bool, px: float | None, depeg_warn: float, - min_ore: float = RIPRISTINO_MIN_ORE) -> tuple[bool, str]: - """PURA. Le guardie della riconversione automatica. -> (si', motivo se no).""" + min_ore: float = RIPRISTINO_MIN_ORE, stato: str = "SCOPERTO", + dopo_fallito_ore: float = RITENTATIVO_FALLITO_ORE) -> tuple[bool, str]: + """PURA. Le guardie della conversione automatica. -> (si', motivo se no). ⚠️ il motivo finisce + in un'allerta: niente '<' (Telegram HTML) — l'escape nel sink lo copre, ma qui non serve.""" if not execution_enabled: return False, "execution_enabled=false in config/live.json (il libro e' disarmato: anche questo)" if px is None: return False, "prezzo USDE non leggibile: non si vende al buio (P5)" if px < depeg_warn: - return False, f"USDE {px:.4f} sotto depeg_warn {depeg_warn}: vendere ora cristallizza la perdita — decide l'operatore" + return False, f"USDE {px:.4f} sotto depeg_warn {depeg_warn}: ne' vendere (cristallizza) ne' comprare (scivola) — decide l'operatore" # contano solo i tentativi VERI (subprocess lanciato): un record con `tentata=False` — prezzo # illeggibile, --secco, interruttore spento — non consuma il budget (revisione fable 10/09) ultimo = [r for r in prev_records if (r.get("riconversione") or {}).get("tentata")] if ultimo: ore = (now_ms - int(ultimo[-1]["ts"])) / 3_600_000 - if ore < min_ore: - return False, f"ultimo tentativo {ore:.1f}h fa (< {min_ore:.0f}h): non si insiste ogni ora" + fallito = not (ultimo[-1].get("riconversione") or {}).get("ok") + soglia = dopo_fallito_ore if (fallito and stato == "ECCEDENTE") else min_ore + if ore < soglia: + return False, (f"ultimo tentativo {ore:.1f}h fa, sotto le {soglia:.0f}h" + + (" (era FALLITO: da ECCEDENTE si riprova una volta al giorno)" if soglia == dopo_fallito_ore else "") + + ": non si insiste") return True, "" @@ -194,54 +214,72 @@ def main() -> int: usd, _ = U.valuta(eq_usde, px) equity_tot = eq_usdc + usd cuscino, come = U.cuscino_richiesto_usd(equity_tot) - g = giudica(eq_usdc, equity_tot, cuscino) + try: + bande = U.bande_cuscino(c) + except Exception as e: # noqa: BLE001 — si registra e si allerta, non si crasha (P5) + bande, motivo_blind = None, f"config usde: {e}" + g = giudica(eq_usdc, equity_tot, cuscino, bande) stato = g["stato"] + if stato == "BLIND" and g.get("motivo"): + motivo_blind = motivo_blind or g["motivo"] rec = dict(ts=now_ms, data=now.strftime("%Y-%m-%dT%H:%M:%SZ"), stato=stato, motivo_blind=motivo_blind, eq_usdc=eq_usdc, eq_usde=eq_usde, px=px, px_fonte=px_fonte, equity_tot=equity_tot, cuscino=cuscino, cuscino_come=come, - slack=g["slack"], preavviso_usd=g["preavviso_usd"], allertato=None, - riconversione=None, secco=secco) + slack=g["slack"], preavviso_usd=g["preavviso_usd"], riacquisto_usd=g["riacquisto_usd"], + bande=bande, allertato=None, allerta_errore=None, riconversione=None, secco=secco) - # --- riconversione automatica: solo SCOPERTO, solo se le guardie lo permettono --- - if stato == "SCOPERTO": - ok, perche = puo_riconvertire(records, now_ms, _execution_enabled(), px, c["depeg_warn"]) - quota = U.quota_ripristino(MARGINE_RIPRISTINO) + # --- conversione automatica: SCOPERTO vende, ECCEDENTE compra, stesso bersaglio q* --- + if stato in AZIONE: + ok, perche = puo_riconvertire(records, now_ms, _execution_enabled(), px, c["depeg_warn"], stato=stato) + quota = U.quota_ripristino(bande["margine"]) if not ok: - rec["riconversione"] = dict(tentata=False, quota=quota, motivo=perche) + rec["riconversione"] = dict(tentata=False, verso=AZIONE[stato], quota=quota, motivo=perche) elif secco: - rec["riconversione"] = dict(tentata=False, quota=quota, - motivo=f"--secco: avrei lanciato usde_convert --quota {quota:.4f} --esegui") + rec["riconversione"] = dict(tentata=False, verso=AZIONE[stato], quota=quota, + motivo=f"--secco: avrei lanciato usde_convert --quota {quota:.4f} --esegui ({AZIONE[stato]})") else: esito = riconverti(quota) - rec["riconversione"] = dict(tentata=True, quota=quota, **esito) + rec["riconversione"] = dict(tentata=True, verso=AZIONE[stato], quota=quota, **esito) # --- allarmi: alla transizione, marcatore scritto con l'esito dell'invio --- + # ⚠️ Telegram e' in parse_mode HTML: un '<' nudo nel testo fa fallire l'invio (successo al + # primo giro vero, 13:53Z del 10/09: «(< 10% del cuscino)»). Qui non si scrive mai '<'. + def _esito_azione(r: dict) -> str: + if r.get("tentata"): + return (f"{r['verso']} " + ("ESEGUITA" if r.get("ok") else "FALLITA") + + f" (quota bersaglio {r['quota']:.0%})") + return f"{r.get('verso', 'conversione')} NON tentata: {r.get('motivo')}" + if transizione(prev, stato): if secco: rec["allertato"] = False elif stato == "SCOPERTO": - r = rec["riconversione"] or {} - azione = ("riconversione " + ("ESEGUITA" if r.get("ok") else "FALLITA") - + f" (quota -> {r.get('quota', 0):.0%})" if r.get("tentata") - else f"NON riconvertito: {r.get('motivo')}") rec["allertato"] = notify("🚨 CUSCINO USDC SCOPERTO", { "USDC equity": f"${eq_usdc:,.2f}", "cuscino richiesto": f"${cuscino:,.2f} ({come})", - "slack": f"${g['slack']:+,.2f}", "azione": azione, + "slack": f"${g['slack']:+,.2f}", "azione": _esito_azione(rec["riconversione"] or {}), "nota": "un USDC negativo costa 0,05%/giorno; il libro si regola in USDC"}, tentativi=3) elif stato == "PREAVVISO": rec["allertato"] = notify("⚠️ cuscino USDC in esaurimento", { "USDC equity": f"${eq_usdc:,.2f}", "cuscino richiesto": f"${cuscino:,.2f}", - "slack": f"${g['slack']:+,.2f} (< {PREAVVISO_FRAC:.0%} del cuscino)", - "nota": f"sotto zero riconverte da solo a quota {U.quota_ripristino(MARGINE_RIPRISTINO):.0%}"}) + "slack": f"${g['slack']:+,.2f} (sotto il {bande['preavviso']:.0%} del cuscino)", + "nota": f"sotto zero vende USDE da solo fino a quota {U.quota_ripristino(bande['margine']):.0%}"}) + elif stato == "ECCEDENTE": + rec["allertato"] = notify("📌 cuscino USDC eccedente: riacquisto USDE", { + "USDC equity": f"${eq_usdc:,.2f}", "cuscino richiesto": f"${cuscino:,.2f}", + "slack": f"${g['slack']:+,.2f} (sopra il {bande['riacquisto']:.0%} del cuscino)", + "azione": _esito_azione(rec["riconversione"] or {})}) elif stato == "BLIND": rec["allertato"] = notify("⚠️ cuscino_watch BLIND", {"motivo": motivo_blind or "?", "nota": "'non vedo' non e' 'va tutto bene' (P5)"}) - elif stato == "SCOPERTO" and rec["riconversione"] and rec["riconversione"].get("tentata") and not secco: + elif rec["riconversione"] and rec["riconversione"].get("tentata") and not secco: # non e' una transizione ma e' un ordine mandato: si dice sempre r = rec["riconversione"] - rec["allertato"] = notify("📌 cuscino USDC: riconversione " + ("eseguita" if r["ok"] else "FALLITA"), + rec["allertato"] = notify(f"📌 cuscino USDC: {r['verso']} " + ("eseguita" if r["ok"] else "FALLITA"), {"quota bersaglio": f"{r['quota']:.0%}", "esito": " | ".join(r["coda"][-3:])}) + if rec["allertato"] is False and not secco: + from src.live.notifier import ultimo_errore + rec["allerta_errore"] = ultimo_errore() # P3: si registra dove si ingoia # --secco e' secco ANCHE sul registro: un giro a mano non deve diventare il `prev` del cron, # ne' far sembrare vivo un monitor fermo (revisione fable 10/09) @@ -261,22 +299,24 @@ def main() -> int: print(f" conto : USDC equity ${eq_usdc:,.2f} + USDE {eq_usde:,.2f} @ {px if px else 'n/d'}" f" ({px_fonte}) = ${equity_tot:,.2f}") print(f" cuscino : ${cuscino:,.2f} richiesti ({come})") - print(f" slack : ${g['slack']:+,.2f} (preavviso sotto ${g['preavviso_usd']:,.2f})") + print(f" slack : ${g['slack']:+,.2f} (preavviso sotto ${g['preavviso_usd']:,.2f}," + f" riacquisto sopra ${g['riacquisto_usd']:,.2f}, bersaglio quota {U.quota_ripristino(bande['margine']):.0%})") print(f" stato : {stato}") if rec["riconversione"]: r = rec["riconversione"] - print(f" riconvers. : {'TENTATA' if r.get('tentata') else 'NON tentata'} — quota {r['quota']:.4f}" + print(f" {r.get('verso', 'conversione'):<12}: {'TENTATA' if r.get('tentata') else 'NON tentata'} — quota {r['quota']:.4f}" + (f" — {r.get('motivo')}" if r.get("motivo") else "")) for ln in r.get("coda", []): print(f" {ln}") if rec["allertato"] is not None: - print(f" allerta : {'inviata' if rec['allertato'] else 'NON inviata (si ripete al giro dopo)'}") + print(f" allerta : {'inviata' if rec['allertato'] else 'NON inviata (si ripete al giro dopo)'}" + + (f" — {rec['allerta_errore']}" if rec.get("allerta_errore") else "")) return 0 USO = """uso: cuscino_watch.py [--quiet] [--secco] - cuscino_watch.py — sorveglianza ORARIA del cuscino USDC di regolamento; sotto zero riconverte. + cuscino_watch.py — sorveglianza ORARIA del cuscino USDC; sotto zero vende USDE, sopra la banda ricompra. uv run python scripts/live/cuscino_watch.py # un giro: legge, giudica, agisce uv run python scripts/live/cuscino_watch.py --quiet # stampa solo se cambia qualcosa uv run python scripts/live/cuscino_watch.py --secco # nessun ordine, nessun Telegram diff --git a/scripts/live/usde_convert.py b/scripts/live/usde_convert.py index 75d319d..b62cd07 100644 --- a/scripts/live/usde_convert.py +++ b/scripts/live/usde_convert.py @@ -59,8 +59,8 @@ la size. Percio' si SPAZIANO gli ordini (`--pausa`) e si fa backoff sul TEMPO, r solo dopo rifiuti ripetuti. Dimezzare la size a ogni rifiuto, come faceva la prima versione, e' la reazione sbagliata al sintomo giusto: consuma i tentativi senza toccare la causa. - uv run python scripts/live/usde_convert.py --quota 0.70 # piano, nessun ordine - uv run python scripts/live/usde_convert.py --quota 0.70 --esegui # manda l'ordine + uv run python scripts/live/usde_convert.py --quota 0.64 # piano, nessun ordine (0,64 = bersaglio di cuscino_watch) + uv run python scripts/live/usde_convert.py --quota 0.64 --esegui # manda l'ordine """ from __future__ import annotations diff --git a/src/live/notifier.py b/src/live/notifier.py index 46a2090..6d293fa 100644 --- a/src/live/notifier.py +++ b/src/live/notifier.py @@ -87,9 +87,15 @@ def notify(title: str, data: dict | None = None, tentativi: int = 1) -> bool: ver = f" v{APP_VERSION}" except Exception: ver = "" - lines = [f"{title}{ver}"] + # Telegram e' in parse_mode=HTML: un '<' o '&' nudo in un valore fa fallire l'INTERO invio + # (HTTP 400, "can't parse entities"). Successo il 2026-09-10 13:53Z: la prima allerta di + # cuscino_watch conteneva «(< 10% del cuscino)» ed e' andata persa. L'escape sta QUI, nel sink, + # perche' nessuno dei 45 chiamanti passa tag di proposito (verificato con grep) e un chiamante + # nuovo non deve ricordarsene. `send()` resta grezzo per chi costruisce il proprio HTML. + import html + lines = [f"{html.escape(str(title), quote=False)}{ver}"] for k, v in (data or {}).items(): - lines.append(f" {k}: {v}") + lines.append(f" {html.escape(str(k), quote=False)}: {html.escape(str(v), quote=False)}") return send("\n".join(lines), tentativi=tentativi) diff --git a/src/live/usde.py b/src/live/usde.py index d941ffe..c1fdd8d 100644 --- a/src/live/usde.py +++ b/src/live/usde.py @@ -36,6 +36,10 @@ _DEFAULTS = { "venue_cap_misurato": "2026-08-31", "depeg_warn": 0.99, "depeg_crit": 0.95, + # bande del cuscino USDC (frazioni del cuscino richiesto), issue #7 — vedi `_nota_cuscino` + "cuscino_preavviso_frac": 0.10, + "cuscino_margine_frac": 0.20, + "cuscino_riacquisto_frac": 0.40, } @@ -123,9 +127,24 @@ def cuscino_richiesto_usd(equity_tot: float) -> tuple[float, str]: return equity_tot * f, f"{come} su ${equity_tot:,.2f}" -def quota_ripristino(margine_frac: float) -> float: +def bande_cuscino(c: dict | None = None) -> dict: + """PURA sulla config. Le tre frazioni del cuscino che governano cuscino_watch, con l'ordine + verificato: 0 < preavviso < margine < riacquisto. Fuori ordine la banda di isteresi non + esiste (vendita e acquisto si inseguirebbero): si solleva, non si indovina.""" + c = c or cfg() + b = dict(preavviso=float(c["cuscino_preavviso_frac"]), margine=float(c["cuscino_margine_frac"]), + riacquisto=float(c["cuscino_riacquisto_frac"])) + if not (0.0 < b["preavviso"] < b["margine"] < b["riacquisto"]): + raise ValueError(f"bande del cuscino fuori ordine (serve 0 < preavviso < margine < riacquisto): {b}") + return b + + +def quota_ripristino(margine_frac: float | None = None) -> float: """PURA sulla config. Quota USDE che lascia il cuscino PIU' un margine (frazione del cuscino): - e' il bersaglio della riconversione automatica quando il cuscino e' scoperto. Il margine serve - a non tornare sul bordo alla prima ora in perdita (P6: la persistenza e' latenza).""" + e' il bersaglio UNICO di ogni conversione automatica — vendita quando il cuscino e' scoperto, + acquisto quando lo slack eccede la banda. Il margine serve a non tornare sul bordo alla prima + ora in perdita (P6: la persistenza e' latenza). Default: `cuscino_margine_frac` di config.""" + if margine_frac is None: + margine_frac = bande_cuscino()["margine"] f, _ = frazione_cuscino() return max(0.0, 1.0 - f * (1.0 + margine_frac)) diff --git a/tests/test_cuscino_watch.py b/tests/test_cuscino_watch.py index 3bf7bf9..cd21bad 100644 --- a/tests/test_cuscino_watch.py +++ b/tests/test_cuscino_watch.py @@ -1,4 +1,4 @@ -"""cuscino_watch: il cuscino USDC di regolamento e' sorvegliato, e sotto zero si riconverte. +"""cuscino_watch: il cuscino USDC e' sorvegliato; sotto zero si vende USDE, sopra la banda si ricompra. Blindano: (a) il cuscino e' DERIVATO da config + book (P1), la stessa formula di usde_convert; (b) i tre stati e la soglia di preavviso; (c) la transizione non si consuma se l'invio fallisce @@ -55,18 +55,59 @@ def test_quota_ripristino_lascia_margine_sul_cuscino(): assert abs((usdc_dopo - E * f) - 0.10 * E * f) < 1e-9 -# ------------------------------ i tre stati ------------------------------ +# ------------------------------ i quattro stati (issue #7) ------------------------------ -def test_stati_ok_preavviso_scoperto(): - assert W.giudica(1500.0, 4000.0, 1200.0)["stato"] == "OK" # slack 300 > 120 - g = W.giudica(1300.0, 4000.0, 1200.0) +B = dict(preavviso=0.10, margine=0.20, riacquisto=0.40) + + +def test_stati_scoperto_preavviso_ok_eccedente(): + assert W.giudica(1199.0, 4000.0, 1200.0, B)["stato"] == "SCOPERTO" + g = W.giudica(1300.0, 4000.0, 1200.0, B) assert g["stato"] == "PREAVVISO" and abs(g["slack"] - 100.0) < 1e-9 and g["preavviso_usd"] == 120.0 - assert W.giudica(1199.0, 4000.0, 1200.0)["stato"] == "SCOPERTO" + assert W.giudica(1500.0, 4000.0, 1200.0, B)["stato"] == "OK" # slack 300: dentro [120, 480] + assert W.giudica(1680.0, 4000.0, 1200.0, B)["stato"] == "OK" # slack 480: sul bordo, non compra + g = W.giudica(1700.0, 4000.0, 1200.0, B) + assert g["stato"] == "ECCEDENTE" and g["riacquisto_usd"] == 480.0 + + +def test_le_bande_vengono_dalla_config_e_sono_ordinate(): + b = U.bande_cuscino() + assert 0 < b["preavviso"] < b["margine"] < b["riacquisto"] + assert W.giudica(1500.0, 4000.0, 1200.0)["stato"] == W.giudica(1500.0, 4000.0, 1200.0, b)["stato"] + import pytest + with pytest.raises(ValueError): + U.bande_cuscino({"cuscino_preavviso_frac": 0.3, "cuscino_margine_frac": 0.2, "cuscino_riacquisto_frac": 0.4}) + + +def test_isteresi_vendita_e_acquisto_convergono_allo_stesso_bersaglio(): + """Dopo una vendita da SCOPERTO o un acquisto da ECCEDENTE lo slack e' margine x cuscino: OK, + a distanza da entrambe le soglie. Un giro non ne innesca un altro.""" + f, _ = U.frazione_cuscino(); b = U.bande_cuscino() + E = 4450.0; cusc = E * f + q = U.quota_ripristino(b["margine"]) + usdc_dopo = E * (1 - q) + g = W.giudica(usdc_dopo, E, cusc, b) + assert g["stato"] == "OK" + assert g["slack"] > g["preavviso_usd"] * 1.5 and g["slack"] < g["riacquisto_usd"] / 1.5 + + +def test_un_bonifico_fa_scattare_il_riacquisto_da_solo(): + """Conto a bersaglio (slack = 0,20 x cuscino), bonifico D in USDC: slack +0,7 D. Con D = 12% + dell'equity si supera 0,40 x cuscino e si ricompra; con D = 3% no (resta in USDC).""" + f, _ = U.frazione_cuscino(); b = U.bande_cuscino() + E = 4450.0; cusc = E * f; usdc = cusc * (1 + b["margine"]) + for D, atteso in ((0.03 * E, "OK"), (0.12 * E, "ECCEDENTE")): + E2 = E + D; cusc2 = E2 * f + assert W.giudica(usdc + D, E2, cusc2, b)["stato"] == atteso, D def test_non_vedo_e_blind_non_ok(): - assert W.giudica(None, 4000.0, 1200.0)["stato"] == "BLIND" - assert W.giudica(1500.0, None, None)["stato"] == "BLIND" + assert W.giudica(None, 4000.0, 1200.0, B)["stato"] == "BLIND" + assert W.giudica(1500.0, None, None, B)["stato"] == "BLIND" + + +def test_gli_stati_che_convertono_sono_due_e_hanno_un_verso(): + assert W.AZIONE == {"SCOPERTO": "vendita", "ECCEDENTE": "acquisto"} # ------------------------------ transizione e trasporto ------------------------------ @@ -77,6 +118,7 @@ def test_ok_non_allerta_mai(): def test_transizione_alla_prima_e_al_cambio_di_stato(): assert W.transizione(None, "PREAVVISO") + assert W.transizione({"stato": "OK", "allertato": None}, "ECCEDENTE") assert W.transizione({"stato": "OK", "allertato": None}, "PREAVVISO") assert W.transizione({"stato": "PREAVVISO", "allertato": True}, "SCOPERTO") assert not W.transizione({"stato": "PREAVVISO", "allertato": True}, "PREAVVISO") @@ -146,12 +188,71 @@ def test_un_non_tentativo_non_consuma_il_budget(): def test_dopo_il_ripristino_lo_stato_e_OK_non_preavviso(): """Il margine di ripristino deve superare la soglia di preavviso, o ogni 🚨 e' seguito da un ⚠️ per costruzione (P14). Include il floor di 1 USDE e la tolleranza di quota di usde_convert.""" - assert W.MARGINE_RIPRISTINO > W.PREAVVISO_FRAC + b = U.bande_cuscino() + assert b["margine"] > b["preavviso"] f, _ = U.frazione_cuscino() E = 4450.0 - q = U.quota_ripristino(W.MARGINE_RIPRISTINO) + 0.005 # usde_convert si ferma entro TOLL - usdc_dopo = E * (1 - q) - 1.0 # e vende fino a 1 USDE in meno - assert W.giudica(usdc_dopo, E, E * f)["stato"] == "OK" + C = _carica("usde_convert") + q = U.quota_ripristino(b["margine"]) + C.TOLL # usde_convert si ferma entro TOLL (importato, P1) + usdc_dopo = E * (1 - q) - 1.0 # e vende fino a 1 USDE in meno (passo del venue) + assert W.giudica(usdc_dopo, E, E * f, b)["stato"] == "OK" + + +def test_il_sink_telegram_fa_escape_di_minore_e_ampersand(monkeypatch): + """Telegram in parse_mode HTML rifiuta un '<' nudo: il primo giro vero (13:53Z del 10/09) ha + perso l'allerta PREAVVISO per «(< 10% del cuscino)». L'escape sta nel SINK (`notify`), non nei + 45 chiamanti — qui si cattura il corpo della POST e si verifica che il '<' sia diventato <.""" + from src.live import notifier as N + catturato = {} + + def finto_urlopen(url, data, timeout=10): + import urllib.parse + catturato.update(urllib.parse.parse_qs(data.decode())) + return None + + monkeypatch.setattr(N, "_cfg", lambda: ("tok", "chat")) + monkeypatch.setattr(N.urllib.request, "urlopen", finto_urlopen) + assert N.notify("⚠️ prova ", {"slack": "$+30 (< 10% del cuscino)", "a&b": "c"}) + testo = catturato["text"][0] + assert "(< 10%" in testo and "<x>" in testo and "a&b" in testo + assert testo.startswith("") # il grassetto del titolo resta HTML vero + + +def test_bande_fuori_ordine_danno_BLIND_con_motivo_non_un_crash(): + import pytest + with pytest.raises(ValueError): + U.bande_cuscino({"cuscino_preavviso_frac": 0.5, "cuscino_margine_frac": 0.2, "cuscino_riacquisto_frac": 0.4}) + monkey = {"cuscino_preavviso_frac": 0.5, "cuscino_margine_frac": 0.2, "cuscino_riacquisto_frac": 0.4} + orig = U.cfg + try: + U.cfg = lambda: {**orig(), **monkey} + g = W.giudica(1500.0, 4000.0, 1200.0) # bande=None -> config rotta + finally: + U.cfg = orig + assert g["stato"] == "BLIND" and "fuori ordine" in g["motivo"] + + +def test_da_eccedente_un_fallimento_si_ritenta_una_volta_al_giorno(): + """USDC fermo non e' un rischio: un guasto persistente (tetto del venue, book illeggibile) non + deve diventare 4 Telegram al giorno per sempre. Da SCOPERTO il ritmo resta 6h.""" + now = 1_789_000_000_000 + fallito = [{"ts": now - 8 * 3_600_000, "riconversione": {"tentata": True, "ok": False}}] + ok, m = W.puo_riconvertire(fallito, now, True, 1.0, 0.99, stato="ECCEDENTE") + assert not ok and "una volta al giorno" in m + assert W.puo_riconvertire(fallito, now, True, 1.0, 0.99, stato="SCOPERTO")[0] + riuscito = [{"ts": now - 8 * 3_600_000, "riconversione": {"tentata": True, "ok": True}}] + assert W.puo_riconvertire(riuscito, now, True, 1.0, 0.99, stato="ECCEDENTE")[0] + tardi = [{"ts": now - 25 * 3_600_000, "riconversione": {"tentata": True, "ok": False}}] + assert W.puo_riconvertire(tardi, now, True, 1.0, 0.99, stato="ECCEDENTE")[0] + + +def test_i_motivi_delle_guardie_non_hanno_minore_nudo(): + """Difesa in profondita' oltre al sink: i motivi finiscono nel campo `azione` delle allerte.""" + now = 1_789_000_000_000 + casi = [W.puo_riconvertire([], now, False, 1.0, 0.99), W.puo_riconvertire([], now, True, None, 0.99), + W.puo_riconvertire([], now, True, 0.98, 0.99), + W.puo_riconvertire([{"ts": now - 1000, "riconversione": {"tentata": True, "ok": True}}], now, True, 1.0, 0.99)] + assert all("<" not in m for _, m in casi) def test_cron_installato_e_dichiarato_fuori_dal_tondo_e_dopo_il_book():