cuscino_watch: riacquisto automatico di USDE con isteresi (ECCEDENTE), bande in config, escape HTML nel sink di notify

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kqvff47UBGeYfj1QeN4zE
This commit is contained in:
Adriano Dal Pastro
2026-09-10 14:14:29 +00:00
parent f82f685528
commit 37d1565a1e
8 changed files with 311 additions and 89 deletions
+9 -9
View File
@@ -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
+5 -2
View File
@@ -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,
@@ -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 `&lt;` |
| 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.
+99 -59
View File
@@ -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
+2 -2
View File
@@ -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
+8 -2
View File
@@ -87,9 +87,15 @@ def notify(title: str, data: dict | None = None, tentativi: int = 1) -> bool:
ver = f" <code>v{APP_VERSION}</code>"
except Exception:
ver = ""
lines = [f"<b>{title}</b>{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"<b>{html.escape(str(title), quote=False)}</b>{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)
+22 -3
View File
@@ -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))
+113 -12
View File
@@ -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 &lt;."""
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 <x>", {"slack": "$+30 (< 10% del cuscino)", "a&b": "c"})
testo = catturato["text"][0]
assert "(&lt; 10%" in testo and "&lt;x&gt;" in testo and "a&amp;b" in testo
assert testo.startswith("<b>") # 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():