From 604fde689ea7e0190955119f1e833cad83900974 Mon Sep 17 00:00:00 2001 From: "Adriano (goldrake)" Date: Sun, 27 Sep 2026 15:03:02 +0000 Subject: [PATCH] cuscino_watch: riconverti distingue parziale/fallita rileggendo la quota (#35 B10) Co-Authored-By: Claude Sonnet 5 --- scripts/live/cuscino_watch.py | 73 +++++++++++++++++++++++++++++------ tests/test_cuscino_watch.py | 61 ++++++++++++++++++++++++++++- 2 files changed, 121 insertions(+), 13 deletions(-) diff --git a/scripts/live/cuscino_watch.py b/scripts/live/cuscino_watch.py index bfe7134..dc7c765 100755 --- a/scripts/live/cuscino_watch.py +++ b/scripts/live/cuscino_watch.py @@ -80,7 +80,9 @@ CONVERT = ROOT / "scripts" / "live" / "usde_convert.py" # 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 +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]: @@ -158,17 +160,56 @@ def puo_riconvertire(prev_records: list[dict], now_ms: int, execution_enabled: b 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).""" + """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:] - 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:])) + rc, coda = None, [f"timeout dopo {timeout_s:.0f}s"] 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:])) + 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(): @@ -239,17 +280,23 @@ def main() -> int: 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) + 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 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 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: @@ -275,7 +322,9 @@ def main() -> int: 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"), + 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 diff --git a/tests/test_cuscino_watch.py b/tests/test_cuscino_watch.py index cd21bad..946438b 100644 --- a/tests/test_cuscino_watch.py +++ b/tests/test_cuscino_watch.py @@ -155,7 +155,15 @@ def test_un_tentativo_ogni_sei_ore(): assert W.puo_riconvertire([{"ts": now - 1000, "riconversione": None}], now, True, 1.0, 0.99)[0] +def _finto_lettore_quota(valori): + """Ritorna i valori dati in ordine, uno per chiamata (per monkeypatch di _leggi_quota_usde).""" + it = iter(valori) + return lambda: next(it) + + def test_riconverti_lancia_lo_script_vero_con_esegui(monkeypatch): + # rc==0: "ok" a prescindere dalla quota (lo script vero ha gia' le sue guardie, P15) โ€” la + # quota qui non si muove, per mostrare che il caso "ok" non dipende dalla rilettura. chiamate = [] class R: @@ -166,10 +174,61 @@ def test_riconverti_lancia_lo_script_vero_con_esegui(monkeypatch): return R() monkeypatch.setattr(W.subprocess, "run", finto_run) + monkeypatch.setattr(W, "_leggi_quota_usde", _finto_lettore_quota([0.67, 0.67])) e = W.riconverti(0.67) - assert e["ok"] and chiamate and chiamate[0][1].endswith("usde_convert.py") + assert e["ok"] and e["esito"] == "ok" and chiamate and chiamate[0][1].endswith("usde_convert.py") assert "--esegui" in chiamate[0] and "0.6700" in chiamate[0] assert e["coda"][-1].startswith(" ESITO") + assert e["quota_prima"] == 0.67 and e["quota_dopo"] == 0.67 + + +def test_riconverti_rc_diverso_da_zero_con_quota_mossa_e_parziale(monkeypatch): + # #35 B10: rc!=0 (es. ordine parzialmente fillato poi rifiutato) ma la quota si e' mossa + # per davvero -> "parziale", non "fallita": la vendita/acquisto e' avvenuto, solo non finito. + class R: + returncode, stdout, stderr = 1, "", "book illeggibile a meta' piano\n" + + monkeypatch.setattr(W.subprocess, "run", lambda cmd, **kw: R()) + monkeypatch.setattr(W, "_leggi_quota_usde", _finto_lettore_quota([0.75, 0.70])) + e = W.riconverti(0.64) + assert e["esito"] == "parziale" and e["ok"] is False + assert e["quota_prima"] == 0.75 and e["quota_dopo"] == 0.70 + + +def test_riconverti_rc_diverso_da_zero_con_quota_ferma_e_fallita(monkeypatch): + class R: + returncode, stdout, stderr = 1, "", "prezzo fuori banda: niente ordini\n" + + monkeypatch.setattr(W.subprocess, "run", lambda cmd, **kw: R()) + monkeypatch.setattr(W, "_leggi_quota_usde", _finto_lettore_quota([0.75, 0.751])) + e = W.riconverti(0.64) + assert e["esito"] == "fallita" and e["ok"] is False + + +def test_riconverti_timeout_con_quota_mossa_e_parziale(monkeypatch): + # #35 B10: il subprocess viene ucciso a TIMEOUT_CONVERT_S ma alcuni ordini erano gia' fillati + # prima del kill -> oggi (ok=rc==0) diventava "FALLITA", corretto e' "parziale". + def finto_run(cmd, **kw): + raise W.subprocess.TimeoutExpired(cmd, kw.get("timeout")) + + monkeypatch.setattr(W.subprocess, "run", finto_run) + monkeypatch.setattr(W, "_leggi_quota_usde", _finto_lettore_quota([0.80, 0.72])) + e = W.riconverti(0.64) + assert e["rc"] is None and e["esito"] == "parziale" and e["ok"] is False + assert e["coda"][0].startswith("timeout dopo") + + +def test_riconverti_rilettura_dopo_fallita_e_fallita_esplicita(monkeypatch): + # se non si riesce a rileggere la quota dopo, non si puo' dire "parziale": si dichiara + # l'incertezza invece di dedurre un movimento zero che non si e' verificato. + class R: + returncode, stdout, stderr = 1, "", "errore\n" + + monkeypatch.setattr(W.subprocess, "run", lambda cmd, **kw: R()) + monkeypatch.setattr(W, "_leggi_quota_usde", _finto_lettore_quota([0.75, None])) + e = W.riconverti(0.64) + assert e["esito"] == "fallita (quota dopo non leggibile)" and e["ok"] is False + assert e["quota_dopo"] is None def test_il_monitor_e_registrato_in_monitor_health():