#!/usr/bin/env python """cuscino_watch.py — sorveglianza ORARIA del cuscino USDC di regolamento. PUO' INVIARE ORDINI. PERCHE' ESISTE (debito §5.17, issue #3). Dal 06/09 il 68,7% dell'equity e' USDE. P&L e funding dei perp USDC-lineari si regolano in USDC: una perdita del libro consuma il cuscino e fa salire la quota USDE DA SOLA, senza un ordine. `usde_watch` allerta solo su `quota > quota_max_frac` (0,85): a 68,7% lo slack sul cuscino era ~$41-58, e un saldo USDC negativo costa lo 0,05%/GIORNO (18,25%/anno, 4,3x la resa che l'USDE compra). Nessuno guardava la grandezza giusta. LA MISURA (decisione dell'operatore, 10/09): l'EQUITY USDC del conto — balance piu' P&L non realizzato — perche' e' quella che risponde del regolamento; il balance vedrebbe il buco solo a trade chiuso. Il cuscino richiesto e' DERIVATO da config/live.json e src/live/book.py tramite `usde.cuscino_richiesto_usd` (n_asset x frac x disaster_sl_pct x equity totale): la stessa formula 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). * BLIND — conto non leggibile: ⚠️ alla transizione. "Non vedo" non e' "va tutto bene". 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). * `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). * `--secco`: niente ordini, niente Telegram, NIENTE riga nel registro: stampa cosa FAREBBE. * 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. La serie data/live/cuscino_watch.jsonl e' dentro il perimetro di backup ed e' sorvegliata da monitor_health (un watch fermo = nessuno guarda il cuscino, e il silenzio si legge come zero). 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 """ from __future__ import annotations import json import subprocess import sys from datetime import datetime, timezone from pathlib import Path ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) from src.live import usde as U # noqa: E402 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 TIMEOUT_CONVERT_S = 900 # usde_convert spazia gli ordini di 12s: 60 ordini = 12 min 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 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.""" if eq_usdc is None or equity_tot is None or cuscino is None: return dict(stato="BLIND", slack=None, preavviso_usd=None) slack = eq_usdc - cuscino preavviso = preavviso_frac * cuscino if slack < 0: stato = "SCOPERTO" elif slack < preavviso: stato = "PREAVVISO" else: stato = "OK" return dict(stato=stato, slack=round(slack, 2), preavviso_usd=round(preavviso, 2)) def transizione(prev: dict | None, stato: str) -> bool: """PURA. Si allerta se lo stato e' cambiato, O se l'ultima allerta per questo stato non e' partita (`allertato` False): un invio fallito non consuma la transizione (debito §5.2).""" if stato == "OK": return False if prev is None or prev.get("stato") != stato: return True return not bool(prev.get("allertato")) 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).""" 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" # 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" return True, "" def riconverti(quota: float, timeout_s: float = TIMEOUT_CONVERT_S) -> dict: """Lancia lo script VERO con le sue guardie. Ritorna esito + coda dell'output (P3).""" cmd = [sys.executable, str(CONVERT), "--quota", f"{quota:.4f}", "--esegui"] try: r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_s, cwd=str(ROOT)) coda = (r.stdout + r.stderr).strip().splitlines()[-12:] return dict(rc=r.returncode, ok=r.returncode == 0, coda=coda, cmd=" ".join(cmd[1:])) except subprocess.TimeoutExpired: return dict(rc=None, ok=False, coda=[f"timeout dopo {timeout_s:.0f}s"], cmd=" ".join(cmd[1:])) except Exception as e: # noqa: BLE001 — si registra, non si ingoia return dict(rc=None, ok=False, coda=[f"{type(e).__name__}: {e}"], cmd=" ".join(cmd[1:])) def _safe_client(): try: from src.live.deribit import DeribitRead return DeribitRead() except Exception: return None def _execution_enabled() -> bool: try: return bool(json.loads((ROOT / "config" / "live.json").read_text()).get("execution_enabled")) except Exception: return False def main() -> int: quiet, secco = "--quiet" in sys.argv, "--secco" in sys.argv c = U.cfg() records = leggi() prev = records[-1] if records else None now = datetime.now(timezone.utc) now_ms = int(now.timestamp() * 1000) client = _safe_client() eq_usdc = eq_usde = None motivo_blind = None if client is None: motivo_blind = "gateway non raggiungibile" else: try: eq_usdc = float(client.account_summary("USDC")["equity"]) except Exception as e: motivo_blind = f"conto USDC non leggibile ({type(e).__name__})" try: eq_usde = float(client.account_summary("USDE").get("equity") or 0) except Exception as e: motivo_blind = motivo_blind or f"conto USDE non leggibile ({type(e).__name__})" px, px_fonte = U.prezzo(client) equity_tot = cuscino = come = None if eq_usdc is not None and eq_usde is not None: 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) stato = g["stato"] 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) # --- 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) if not ok: rec["riconversione"] = dict(tentata=False, quota=quota, motivo=perche) elif secco: rec["riconversione"] = dict(tentata=False, quota=quota, motivo=f"--secco: avrei lanciato usde_convert --quota {quota:.4f} --esegui") else: esito = riconverti(quota) rec["riconversione"] = dict(tentata=True, quota=quota, **esito) # --- allarmi: alla transizione, marcatore scritto con l'esito dell'invio --- 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, "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%}"}) 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: # 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"), {"quota bersaglio": f"{r['quota']:.0%}", "esito": " | ".join(r["coda"][-3:])}) # --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) if not secco: STATE.parent.mkdir(parents=True, exist_ok=True) with STATE.open("a") as fh: fh.write(json.dumps(rec) + "\n") cambiato = prev is None or prev.get("stato") != stato or rec["riconversione"] is not None if not quiet or cambiato: print("=" * 78) print(f" CUSCINO WATCH — {rec['data']} {'(SECCO)' if secco else ''}") print("=" * 78) if stato == "BLIND": print(f" stato : BLIND — {motivo_blind}") else: 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" stato : {stato}") if rec["riconversione"]: r = rec["riconversione"] print(f" riconvers. : {'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)'}") return 0 USO = """uso: cuscino_watch.py [--quiet] [--secco] cuscino_watch.py — sorveglianza ORARIA del cuscino USDC di regolamento; sotto zero riconverte. 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 Dettaglio nel docstring in testa al file.""" if __name__ == "__main__": from src.live.cli import valida valida("cuscino_watch.py", USO, flag=("--quiet", "--secco"), con_valore=()) sys.exit(main())