#!/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). 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 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). * `--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. 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" # 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 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, 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, 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, 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), riacquisto_usd=round(riacquisto, 2)) AZIONE = {"SCOPERTO": "vendita", "ECCEDENTE": "acquisto"} # gli stati che convertono 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, 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}: 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 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, "" 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) 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"], riacquisto_usd=g["riacquisto_usd"], bande=bande, allertato=None, allerta_errore=None, riconversione=None, secco=secco) # --- 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, verso=AZIONE[stato], quota=quota, motivo=perche) elif secco: 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, 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": rec["allertato"] = notify("🚨 CUSCINO USDC SCOPERTO", { "USDC equity": f"${eq_usdc:,.2f}", "cuscino richiesto": f"${cuscino:,.2f} ({come})", "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} (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 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(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) 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}," 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" {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)'}" + (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; 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 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())