Files
PythagorasGoal/scripts/live/cuscino_watch.py

379 lines
20 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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: 12s di pausa fra ordini + le GET per giro (~14s/giro
# coi 12s di pausa); 60 ordini ~= 840s: il tetto e' stretto sul caso
# massimo, non largo come "60 ordini = 12 min" lasciava credere (#35 B10)
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 _leggi_quota_usde() -> float | None:
"""Quota USDE sul totale, STESSA formula di usde_watch.py:275 (usd/(usd+eq_usdc)), stessi
mattoni di main() qui sotto (_safe_client, account_summary, U.prezzo, U.valuta): nessuna
lettura nuova. None se il conto non e' leggibile — chi chiama tratta None come "non lo so",
non come zero (#35 B10)."""
client = _safe_client()
if client is None:
return None
try:
eq_usdc = float(client.account_summary("USDC")["equity"])
eq_usde = float(client.account_summary("USDE").get("equity") or 0)
except Exception:
return None
px, _ = U.prezzo(client)
usd, _ = U.valuta(eq_usde, px)
try:
return round(usd / (usd + eq_usdc), 4)
except ZeroDivisionError:
return None
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).
Rilegge la quota USDE prima e dopo il subprocess, ANCHE su timeout o eccezione: un rc!=0 con
la quota mossa e' una conversione PARZIALE (fill gia' avvenuti prima del kill a timeout_s),
non "FALLITA" come diceva `ok = rc==0` (#35 B10). `esito` in "ok" | "parziale" | "fallita"* —
"ok" solo per compatibilita' resta anche in `ok` (bool)."""
quota_prima = _leggi_quota_usde()
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))
rc = r.returncode
coda = (r.stdout + r.stderr).strip().splitlines()[-12:]
except subprocess.TimeoutExpired:
rc, coda = None, [f"timeout dopo {timeout_s:.0f}s"]
except Exception as e: # noqa: BLE001 — si registra, non si ingoia
rc, coda = None, [f"{type(e).__name__}: {e}"]
quota_dopo = _leggi_quota_usde()
if rc == 0:
esito = "ok"
elif quota_dopo is None:
esito = "fallita (quota dopo non leggibile)"
else:
mossa = quota_prima is not None and abs(quota_dopo - quota_prima) > 0.005
esito = "parziale" if mossa else "fallita"
return dict(rc=rc, ok=esito == "ok", esito=esito, quota_prima=quota_prima, quota_dopo=quota_dopo,
coda=coda, 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:
ris = riconverti(quota)
rec["riconversione"] = dict(tentata=True, verso=AZIONE[stato], quota=quota, **ris)
# --- 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 not r.get("tentata"):
return f"{r.get('verso', 'conversione')} NON tentata: {r.get('motivo')}"
esito = r.get("esito", "ok" if r.get("ok") else "fallita")
if esito == "parziale":
qp, qd = r.get("quota_prima"), r.get("quota_dopo")
qp_s = f"{qp:.0%}" if qp is not None else "n/d"
qd_s = f"{qd:.0%}" if qd is not None else "n/d"
return f"{r['verso']} PARZIALE: quota da {qp_s} a {qd_s}"
return (f"{r['verso']} " + ("ESEGUITA" if esito == "ok" else "FALLITA")
+ f" (quota bersaglio {r['quota']:.0%})")
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"]
esito = r.get("esito", "ok" if r.get("ok") else "fallita")
verbo = {"ok": "eseguita", "parziale": "PARZIALE"}.get(esito, "FALLITA")
rec["allertato"] = notify(f"📌 cuscino USDC: {r['verso']} {verbo}",
{"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())