#!/usr/bin/env python """usde_watch.py — sorveglianza giornaliera del collaterale USDE: reward, depeg, quota. SOLA LETTURA. PERCHE' ESISTE. Dal 2026-08-26 il conto tiene USDE come collaterale a rendimento (test di eligibilita': 500 USDE @ 1.0003, regola pre-registrata nel diario 2026-08-26-usde-analisi). Tre cose vanno sorvegliate e nessuna aveva un posto: 1. i REWARD giornalieri (~12:00 UTC) — sono la ragione della posizione, e il gateway non espone il Transaction Log: si rilevano per DELTA di equity USDE al netto dei trade spot. Metodo DICHIARATO, coi suoi limiti: un delta con trade nel mezzo si riporta con `con_trade=true` e se i trade non sono leggibili il delta resta NON ATTRIBUITO invece di essere inventato (P12: una riparazione silenziosa e' un'invenzione). 2. il DEPEG — soglie e criteri in config/live.json `_nota_usde` (P6: dichiarati prima): warn = fuori dalla banda operativa dello spot e oltre il clamp per-fonte dell'indice; crit = meta' del buffer di haircut consumata. 3. la QUOTA sul totale — N4: la quota e' l'unica leva contro il rischio emittente (-100% = 25 anni di resa, non recuperabile). Sopra `quota_max_frac` allerta, e vale anche per deriva PASSIVA: il libro perde -> la quota sale da sola. VERDETTO DI ELIGIBILITA' (regola pre-registrata il 26/08, PRIMA dell'esito): >=1 reward entro il 29/08 -> IDONEO (si apre la decisione di quota, che e' dell'OPERATORE); zero reward alla lettura del 29/08 dopo la finestra delle 12:00 UTC -> NON IDONEO, si riconverte e la pista si chiude. Questo script APPLICA la regola, non la decide. LIMITE DICHIARATO (P6: la latenza si confronta con la durata del fenomeno). Cadenza giornaliera (cron 12:35 UTC, dopo la finestra reward): il depeg Binance del 10/10/2025 duro' ~8h, questa sorveglianza puo' MANCARLO. Accettato perche' la protezione di SIZING e' oraria per costruzione (shadow._collaterale_usde legge l'indice a ogni giro del book) e il danno e' limitato dalla quota. Qui si ALLERTA, non si protegge. ALLARMI (P9: il massimo si spende per l'evento vero): 🚨 solo depeg datetime: return datetime.fromisoformat(ts.replace("Z", "+00:00")) def leggi(path: Path = STATE) -> list[dict]: if not path.exists(): return [] out = [] for ln in path.read_text().splitlines(): ln = ln.strip() if ln: out.append(json.loads(ln)) return out def analizza(prev: dict | None, eq_now: float, trades_usde: float | None) -> dict: """PURA. Reward per delta di equity USDE al netto dei trade spot fra le due letture. prev senza equity (primo giro, o giro BLIND) -> baseline: nessun delta da attribuire. trades_usde None = storia trade non leggibile -> il delta si riporta ma NON si attribuisce a reward (P12): meglio un giorno di latenza che un reward inventato.""" if prev is None or prev.get("eq_usde") is None: return dict(delta=None, reward_stimato=None, reward_rilevato=False, con_trade=None) delta = round(eq_now - float(prev["eq_usde"]), 8) if trades_usde is None: return dict(delta=delta, reward_stimato=None, reward_rilevato=False, con_trade=None) stima = round(delta - trades_usde, 8) return dict(delta=delta, reward_stimato=stima, reward_rilevato=bool(stima > EPS_REWARD), con_trade=bool(abs(trades_usde) > 0)) def verdetto(records: list[dict], now: datetime) -> tuple[str, str]: """PURA. Applica la regola pre-registrata del 26/08. -> (stato, motivo).""" for r in records: if r.get("reward_rilevato"): return "IDONEO", (f"reward rilevato il {r.get('data')} " f"(+{r.get('reward_stimato')} USDE)") if now >= _parse(VERDETTO_DOPO): return "NON_IDONEO", "zero reward entro la finestra del 29/08 -> riconvertire, pista chiusa" return "IN_ATTESA", f"finestre reward ~12:00 UTC, verdetto dopo {VERDETTO_DOPO}" def rendimento(records: list[dict]) -> dict: """PURA. Il rendimento sulle finestre OSSERVATE, col loro NUMERO accanto. E' il numero che serve alla decisione di quota, e l'unico modo onesto di darlo e' con `n` attaccato: un pagamento non e' un tasso. Il denominatore e' il TEMPO VERO fra la prima e l'ultima lettura misurabile, non il conteggio delle finestre — cosi' una finestra che non paga abbassa la stima invece di sparire (P5: il silenzio in una serie di ritorni si legge come zero, e qui e' proprio uno zero). Si escludono le letture con trade nel mezzo (`con_trade`): quel delta non e' solo reward, e una riparazione silenziosa sarebbe un'invenzione (P12). """ usabili = [r for r in records if r.get("reward_stimato") is not None and not r.get("con_trade")] pagate = [r for r in usabili if r.get("reward_rilevato")] if not usabili: return dict(apr=None, finestre=0, pagate=0, giorni=0.0, tot=0.0, nota="nessuna finestra misurabile") tot = float(sum(r["reward_stimato"] for r in usabili)) eq = [float(r["eq_usde"]) for r in usabili if r.get("eq_usde")] eq_med = sum(eq) / len(eq) if eq else 0.0 # tempo vero coperto: dalla lettura PRIMA della prima misurabile, all'ultima i0 = records.index(usabili[0]) t0 = records[max(0, i0 - 1)]["ts"] giorni = (usabili[-1]["ts"] - t0) / 86_400_000.0 if giorni < EPS_GIORNI or eq_med <= 0: return dict(apr=None, finestre=len(usabili), pagate=len(pagate), giorni=giorni, tot=tot, nota=f"solo {giorni:.2f} giorni osservati: non si annualizza") apr = tot / eq_med / giorni * 365.0 return dict(apr=apr, finestre=len(usabili), pagate=len(pagate), giorni=giorni, tot=tot, nota=f"{len(pagate)}/{len(usabili)} finestre hanno pagato") def quota_da_decidere(now: datetime, verdetto_corrente: str) -> bool: """PURA. La decisione di quota e' dovuta? Solo se il conto e' IDONEO e il rinvio e' scaduto.""" if verdetto_corrente != "IDONEO": return False return now.date().isoformat() >= DECISIONE_QUOTA_DAL def condizioni(rec: dict | None, c: dict) -> set[str]: """PURA. Condizioni di allerta attive su un record.""" if not rec: return set() if rec.get("stato") == "BLIND": return {"BLIND"} att = set() px = rec.get("px") if px is not None: if px < c["depeg_crit"]: att.add("DEPEG_CRIT") elif px < c["depeg_warn"]: att.add("DEPEG_WARN") q = rec.get("quota") if q is not None and q > c["quota_max_frac"]: att.add("QUOTA_OVER") return att def _safe_client(): try: from src.live.deribit import DeribitRead return DeribitRead() except Exception: return None def _trades_spot_da(client, ts_ms: int) -> tuple[float | None, int | None]: """Somma con segno (buy +, sell -) dell'USDE scambiato spot dopo ts_ms. None = non leggibile.""" if client is None: return None, None try: rows = client.trade_history(U.SPOT, limit=50) except Exception: return None, None tot, n = 0.0, 0 for t in rows: if int(t.get("timestamp") or 0) <= ts_ms: continue amt = float(t.get("amount") or 0) tot += amt if (t.get("direction") or "").lower() == "buy" else -amt n += 1 return round(tot, 8), n def main() -> int: quiet = "--quiet" in sys.argv c = U.cfg() records = leggi() prev = records[-1] if records else None now = datetime.now(timezone.utc) client = _safe_client() stato, motivo_blind, eq_usde, eq_usdc = "OK", None, None, None if client is None: stato, motivo_blind = "BLIND", "gateway non raggiungibile" else: try: eq_usde = float(client.account_summary("USDE").get("equity") or 0) except Exception as e: stato, motivo_blind = "BLIND", f"conto USDE non leggibile ({type(e).__name__})" try: eq_usdc = float(client.account_summary("USDC").get("equity") or 0) except Exception: eq_usdc = None # quota non computabile: dichiarato, non 0 px, px_fonte = U.prezzo(client) usd = None if eq_usde is not None: usd, _nota = U.valuta(eq_usde, px) trades_usde, n_trades = (None, None) an = dict(delta=None, reward_stimato=None, reward_rilevato=False, con_trade=None) if stato == "OK" and prev is not None and prev.get("eq_usde") is not None: trades_usde, n_trades = _trades_spot_da(client, int(prev["ts"])) an = analizza(prev, eq_usde, trades_usde) quota = None if usd is not None and eq_usdc is not None and (usd + eq_usdc) > 0: quota = round(usd / (usd + eq_usdc), 4) rec = dict( ts=int(now.timestamp() * 1000), data=now.strftime("%Y-%m-%dT%H:%M:%SZ"), stato=stato, motivo_blind=motivo_blind, eq_usde=eq_usde, px=px, px_fonte=px_fonte, usd=usd, eq_usdc=eq_usdc, quota=quota, trades_usde=trades_usde, n_trades=n_trades, **an, ) v, v_motivo = verdetto(records + [rec], now) rec["verdetto"], rec["verdetto_motivo"] = v, v_motivo STATE.parent.mkdir(parents=True, exist_ok=True) with STATE.open("a") as fh: fh.write(json.dumps(rec) + "\n") # --- allarmi: transizioni (warn/quota/blind), ripetizione solo per il crit --- att_now, att_prev = condizioni(rec, c), condizioni(prev, c) nuove = att_now - att_prev inviati = [] if "DEPEG_CRIT" in att_now: notify("🚨 USDE DEPEG", {"indice": px, "soglia": c["depeg_crit"], "collaterale": f"${usd:,.0f}" if usd else "?", "azione": "valutare riconversione — la quota e' il controllo (N4)"}) inviati.append("DEPEG_CRIT") if "DEPEG_WARN" in nuove: notify("⚠️ USDE sotto la pari", {"indice": px, "soglia": c["depeg_warn"], "nota": "entra gia' nel sizing orario del book"}) inviati.append("DEPEG_WARN") if "QUOTA_OVER" in nuove: notify("⚠️ quota USDE sopra il tetto", {"quota": f"{quota:.1%}" if quota else "?", "tetto": f"{c['quota_max_frac']:.0%}", "nota": "anche una deriva passiva conta (il libro perde -> quota sale)"}) inviati.append("QUOTA_OVER") if "BLIND" in nuove: notify("⚠️ usde_watch BLIND", {"motivo": motivo_blind or "?", "nota": "'non vedo' non e' 'va tutto bene' (P5)"}) inviati.append("BLIND") v_prev = prev.get("verdetto") if prev else "IN_ATTESA" if v != v_prev: if v == "IDONEO": notify("📌 USDE: conto IDONEO ai reward", {"dettaglio": v_motivo, "prossimo passo": "decisione di quota dell'operatore (tetto allerta 50%)"}) elif v == "NON_IDONEO": notify("⚠️ USDE: conto NON idoneo", {"dettaglio": v_motivo}) inviati.append(f"VERDETTO->{v}") # La QUOTA e' dell'operatore ed e' stata rinviata a lunedi': da quel giorno lo si dice OGNI # giorno, non solo alla transizione. Ripetersi qui e' voluto — non e' un allarme che chiede # un'azione impossibile (P14), e' una domanda che da lunedi' si puo' finalmente rispondere, # con accanto il numero per rispondere. Si smette togliendo `DECISIONE_QUOTA_DAL`. rend = rendimento(records + [rec]) if quota_da_decidere(now, v): apr = "n/d" if rend["apr"] is None else f"{rend['apr']*100:.2f}% annuo" notify("📌 USDE: la decisione di QUOTA e' dovuta", { "quota attuale": f"{quota:.1%}" if quota is not None else "n/d", "tetto di allerta": f"{c['quota_max_frac']:.0%} (mai 100%: R1 emittente non recuperabile)", "rendimento osservato": f"{apr} — {rend['nota']}, {rend['giorni']:.1f} giorni", "nota": "un pagamento non e' un tasso: leggere l'APR con il suo n"}) inviati.append("QUOTA_DA_DECIDERE") cambiato = bool(inviati) or v != v_prev if not quiet or cambiato: print("=" * 78) print(f" USDE WATCH — {rec['data']} (config: {c['fonte']})") print("=" * 78) if stato == "BLIND": print(f" stato : BLIND — {motivo_blind}") else: print(f" equity USDE : {eq_usde:,.4f} @ {px if px is not None else 'n/d'}" f" ({px_fonte}) -> ${usd:,.2f}" if usd is not None else " equity USDE : n/d") if quota is not None: # Il conto e' Segregated Standard Margin (letto sulla pagina margini il 31/08): # l'USDE NON fa margine per i perp USDC-settled, e l'haircut non si applica. Il # margine del book e' il solo silo USDC. Si stampa anche cosa DAREBBE il cross, # perche' e' la differenza che rende la cosa una decisione invece che un dettaglio. cross = eq_usdc + usd * (1.0 - c["haircut"]) print(f" quota : {quota:.1%} del totale (tetto allerta {c['quota_max_frac']:.0%})") print(f" margine : ~${eq_usdc:,.0f} — il solo USDC (conto SEGREGATO: l'USDE non" f" fa margine). Con cross X:SM sarebbero ~${cross:,.0f} (haircut" f" {c['haircut']:.0%})") if an["delta"] is not None: attr = ("non attribuibile (trade non leggibili)" if an["reward_stimato"] is None else f"reward stimato {an['reward_stimato']:+.6f} USDE" + (" [con trade nel mezzo]" if an["con_trade"] else "")) print(f" delta : {an['delta']:+.6f} USDE dall'ultima lettura -> {attr}") print(f" verdetto : {v} — {v_motivo}") if rend["finestre"]: apr = "n/d" if rend["apr"] is None else f"{rend['apr']*100:.2f}% annuo" print(f" rendimento : {apr} su {rend['giorni']:.1f} giorni — {rend['nota']}" f" (tot {rend['tot']:+.6f} USDE)") if rend["finestre"] < 4: print(f" ⚠️ n={rend['finestre']}: un pagamento non e' un tasso") if v == "IDONEO" and not quota_da_decidere(now, v): print(f" quota : decisione RINVIATA a {DECISIONE_QUOTA_DAL} (operatore)") if inviati: print(f" allarmi : {', '.join(inviati)}") return 0 if __name__ == "__main__": sys.exit(main())