From 801bd13f107e2cead397e25015ad6be5e5b6f862 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Fri, 28 Aug 2026 12:34:44 +0000 Subject: [PATCH] allarmi: il marcatore "gia' detto" si scrive DOPO l'invio, non prima MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Debito §5.2 chiuso su decisione dell'operatore. `run_once` salvava lo stato coi marcatori `alerted` gia' a True e l'invio lo faceva il chiamante DOPO: col 6,9% di invii falliti misurato (2 su 29), un 🚨 perso restava perso per l'EPISODIO INTERO — l'ora dopo lo stato diceva "gia' detto" e usciva WATCH/MUTO. Gli episodi storici durano 200-2.324 ore, quindi il buco non era teorico. - `run_once(state_path, sender=None)`: il sender e' INIETTATO, non importato — e' cio' che tiene la funzione testabile senza rete d'uscita, che era la ragione del disegno precedente. Senza sender il comportamento resta quello di prima e il report lo DICE (`invio`), invece di lasciar credere che qualcosa sia partito. - Su invio fallito si disfano SOLO i marcatori "gia' detto", non le misure: · asset in ALERT -> alerted=False, l'ora dopo ri-allerta; · lock MAINT/ALERT -> alerted_soft/hard=False ma le ORE restano a correre, cosi' una manutenzione che sfora la grazia sale ad ALERT anche col trasporto giu' (disfare anche le ore congelerebbe l'escalation proprio mentre non si riesce a parlare); · lock RIENTRATO -> si ripristina l'intero LockState, perche' il rientro si annuncia una volta sola e senza le ore non ci sarebbe piu' niente da dire. - `notify(..., tentativi=)`: il retry esisteva in `send` e non arrivava qui. venue_watch ora manda con 3 tentativi. - L'esito dell'invio finisce nel log del cron invece di sparire. 5 test nuovi. Il primo e' quello che conta — dopo un invio fallito, l'ora dopo ri-allerta — col suo controllo positivo (un invio riuscito consuma l'allarme UNA volta sola), senza il quale "ri-allerta sempre" passerebbe. Suite: 782 passati, 2 falliti (i due del gate GTAA, non toccati qui). Co-Authored-By: Claude Opus 5 (1M context) --- scripts/live/venue_watch.py | 44 +++++++++++++------- src/live/notifier.py | 10 +++-- src/live/venue_watch.py | 50 ++++++++++++++++++++-- tests/test_venue_watch.py | 83 +++++++++++++++++++++++++++++++++++++ 4 files changed, 166 insertions(+), 21 deletions(-) diff --git a/scripts/live/venue_watch.py b/scripts/live/venue_watch.py index f01fe11..0abe4f9 100644 --- a/scripts/live/venue_watch.py +++ b/scripts/live/venue_watch.py @@ -16,14 +16,37 @@ ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) from src.live.deribit import check_specs # noqa: E402 -from src.live.notifier import notify # noqa: E402 +from src.live.notifier import notify, ultimo_errore # noqa: E402 from src.live.venue_watch import (PERSIST_HOURS, THRESHOLD_BPS, # noqa: E402 run_once) +def _titolo(rep: dict) -> str: + """⚠️ Il titolo dice cosa FARE, non solo cosa e' successo: chi lo legge sul telefono deve + sapere il primo passo senza aprire il repo (runbook completo in src/live/venue_watch.py). + ⚠️ Dal 19/08 DIPENDE dalla gravita'. Prima era sempre il 🚨 col prelievo di prova, anche per + un blocco da manutenzione annunciata: il 18/08 sono usciti quattro messaggi identici che + chiedevano di prelevare, tutti dopo che il book aveva gia' ripreso. Un allarme massimo speso + per un evento atteso e' un allarme che non verra' letto il giorno che e' vero.""" + if rep.get("severity") == "warn": + return "⚠️ VENUE WATCH — Deribit non operativa, ma spiegata. Nessuna azione" + return "🚨 VENUE WATCH — possibile stress su Deribit. PRIMO PASSO: prelievo di prova" + + +def _manda(rep: dict) -> tuple[bool, str]: + """Il sender iniettato in `run_once`: e' lui a decidere se i marcatori 'gia' detto' si + scrivono. `tentativi=3` perche' su questo percorso il 6,9% degli invii si perde, e qui + perderne uno significa perdere l'episodio intero.""" + ok = notify(_titolo(rep), {f"alert {i+1}": a for i, a in enumerate(rep["alerts"])}, + tentativi=3) + if ok: + return True, "ok" + return False, (ultimo_errore() or "motivo non registrato") + + def main() -> int: quiet = "--quiet" in sys.argv - rep = run_once() + rep = run_once(sender=_manda) if not quiet: print("=" * 78) @@ -65,21 +88,12 @@ def main() -> int: print(f" SPEC: {k}: {v}") if rep["alerts"]: - # ⚠️ Il titolo dice cosa FARE, non solo cosa e' successo: chi lo legge sul telefono deve - # sapere il primo passo senza aprire il repo (runbook completo in src/live/venue_watch.py). - # ⚠️ Dal 19/08 il titolo DIPENDE dalla gravita'. Prima era sempre il 🚨 col prelievo di - # prova, anche per un blocco da manutenzione annunciata: il 18/08 sono usciti quattro - # messaggi identici che chiedevano di prelevare, tutti dopo che il book aveva gia' - # ripreso. Un allarme massimo speso per un evento atteso e' un allarme che non verra' - # letto il giorno che e' vero — ed e' l'unico giorno che conta. - if rep.get("severity") == "warn": - titolo = "⚠️ VENUE WATCH — Deribit non operativa, ma spiegata. Nessuna azione" - else: - titolo = ("🚨 VENUE WATCH — possibile stress su Deribit. " - "PRIMO PASSO: prelievo di prova") - notify(titolo, {f"alert {i+1}": a for i, a in enumerate(rep["alerts"])}) + # L'invio l'ha gia' fatto `run_once` attraverso `_manda`: qui si stampa e si riporta + # l'ESITO. Un invio fallito si vede nel log del cron invece di sparire — e lo stato su + # disco e' stato disfatto, quindi l'ora prossima ci riprova da solo. for a in rep["alerts"]: print(f" ALERT: {a}") + print(f" telegram: {rep.get('invio')}") return 2 if rep.get("severity") == "alert" else 0 return 0 diff --git a/src/live/notifier.py b/src/live/notifier.py index ece79a4..46a2090 100644 --- a/src/live/notifier.py +++ b/src/live/notifier.py @@ -76,8 +76,12 @@ def send(text: str, tentativi: int = 1, pausa_s: float = 2.0) -> bool: return False -def notify(title: str, data: dict | None = None) -> bool: - """Invia un alert formattato. Ritorna True se inviato (config presente + rete ok).""" +def notify(title: str, data: dict | None = None, tentativi: int = 1) -> bool: + """Invia un alert formattato. Ritorna True se inviato (config presente + rete ok). + + `tentativi` DEFAULT 1 = comportamento invariato per tutti i chiamanti esistenti; chi manda + un allarme che non puo' permettersi di perdere lo alza (venue_watch: 3). + """ try: from src.version import APP_VERSION ver = f" v{APP_VERSION}" @@ -86,7 +90,7 @@ def notify(title: str, data: dict | None = None) -> bool: lines = [f"{title}{ver}"] for k, v in (data or {}).items(): lines.append(f" {k}: {v}") - return send("\n".join(lines)) + return send("\n".join(lines), tentativi=tentativi) def is_configured() -> bool: diff --git a/src/live/venue_watch.py b/src/live/venue_watch.py index a23e8f6..bd0b12e 100644 --- a/src/live/venue_watch.py +++ b/src/live/venue_watch.py @@ -299,10 +299,33 @@ def observe() -> dict: return out -def run_once(state_path: Path = STATE_PATH) -> dict: - """Un giro completo: osserva, avanza lo stato, ritorna il report (NON invia nulla). - L'invio Telegram lo fa il chiamante, cosi' questa resta testabile senza rete d'uscita.""" +def run_once(state_path: Path = STATE_PATH, sender=None) -> dict: + """Un giro completo: osserva, avanza lo stato, INVIA (se gli si da' un `sender`), salva. + + PERCHE' L'INVIO E' ENTRATO QUI. Fino al 2026-08-28 questa funzione salvava lo stato — coi + marcatori `alerted` gia' a True — e l'invio lo faceva il chiamante DOPO. Con un tasso di + invii falliti misurato del 6,9% (2 su 29), un 🚨 perso restava perso per l'EPISODIO INTERO: + l'ora dopo lo stato diceva «gia' detto» e usciva WATCH/MUTO. Gli episodi storici durano + 200-2.324 ore, quindi il buco non e' teorico. Il marcatore «gia' detto» adesso si scrive + solo se qualcuno l'ha davvero sentito. + + `sender(report) -> (ok: bool, motivo: str)`. Iniettato, non importato: e' cio' che tiene + questa funzione testabile senza rete d'uscita, che era la ragione del disegno precedente. + Senza `sender` il comportamento resta quello di prima e il report lo DICE (`invio`), invece + di lasciar credere che qualcosa sia stato spedito. + + COSA SI DISFA SU UN INVIO FALLITO — solo i marcatori «gia' detto», non le misure: + · asset in ALERT -> `alerted=False`: l'ora dopo ri-allerta; + · lock MAINT / ALERT -> `alerted_soft`/`alerted_hard`=False ma le ORE restano a correre, + cosi' una manutenzione che sfora la grazia sale ad ALERT anche se il trasporto e' giu' + (disfare anche le ore congelerebbe l'escalation proprio mentre non si riesce a parlare); + · lock RIENTRATO -> si ripristina l'intero LockState precedente, perche' il rientro + si annuncia una volta sola e senza le ore a zero non ci sarebbe piu' un rientro da dire. + Restano fuori BLIND (si ripete ogni ora da solo) e il livello WATCH (non consuma nulla). + """ st = load_state(state_path) + prima_lock = LockState(**asdict(st.lock)) # per disfare il rientro, che si dice una volta + asset_alertati: list[str] = [] obs = observe() report = {"ts": obs["ts"], "platform_locked": obs["platform_locked"], "locked_raw": obs.get("locked_raw"), "maintenance": obs.get("maintenance", False), @@ -313,6 +336,7 @@ def run_once(state_path: Path = STATE_PATH) -> dict: st.assets[a] = new report["levels"][a] = level if level == "ALERT": + asset_alertati.append(a) report["alerts"].append( f"{a}: scarto {o['bps']:+.0f} bps vs consenso ({o['n_refs']} referenze) " f"persistente da {new.streak_hours}h a segno costante") @@ -345,5 +369,25 @@ def run_once(state_path: Path = STATE_PATH) -> dict: report["severity"] = report["severity"] or "warn" st.last_ts = obs["ts"] + + # --- l'invio, e il commit dei marcatori SOLO se e' andato --------------------------------- + if not report["alerts"]: + report["invio"] = "niente da dire" + elif sender is None: + report["invio"] = ("non tentato (nessun sender): i marcatori 'gia' detto' restano " + "impostati, l'invio e' responsabilita' del chiamante") + else: + ok, motivo = sender(report) + report["invio"] = "inviato" if ok else f"FALLITO — {motivo}" + if not ok: + for a in asset_alertati: + st.assets[a].alerted = False + if lock_lvl == "MAINT": + st.lock.alerted_soft = False + elif lock_lvl == "ALERT": + st.lock.alerted_soft = st.lock.alerted_hard = False + elif lock_lvl == "RIENTRATO": + st.lock = prima_lock + save_state(st, state_path) return report diff --git a/tests/test_venue_watch.py b/tests/test_venue_watch.py index b15df21..1679ff3 100644 --- a/tests/test_venue_watch.py +++ b/tests/test_venue_watch.py @@ -29,6 +29,7 @@ sys.path.insert(0, str(ROOT / "scripts" / "research")) from src.live.venue_watch import (BLIND_ALERT_HOURS, PERSIST_HOURS, # noqa: E402 THRESHOLD_BPS, AssetState, WatchState, dislocation_bps, load_state, save_state, step) +import src.live.venue_watch as VW # noqa: E402 — i test sul trasporto patchano `observe` # =========================================================================== @@ -416,3 +417,85 @@ def test_una_sola_ora_BLIND_spezza_lo_streak_e_impedisce_l_allarme(): st2, lvl2 = step(st2, bps) assert lvl2 != "ALERT" assert st2.streak_hours < PERSIST_HOURS, "l'ora BLIND deve azzerare lo streak" + + +# ============================================================================================= +# IL TRASPORTO — un 🚨 perso non deve costare l'EPISODIO INTERO +# ============================================================================================= +# Fino al 2026-08-28 `run_once` salvava lo stato coi marcatori `alerted` gia' a True e l'invio +# lo faceva il chiamante DOPO. Col 6,9% di invii falliti misurato (2 su 29), un allarme perso +# restava perso finche' durava l'episodio — e gli episodi storici durano 200-2.324 ore. +# Questi test guardano l'unica cosa che conta: dopo un invio FALLITO, l'ora dopo ri-allerta? + +def _obs_finto(monkeypatch, bps, locked=False, maint=False): + def fake(): + return {"ts": 1, "platform_locked": locked, "locked_raw": str(locked).lower(), + "maintenance": maint, + "assets": {a: dict(deribit=1.0, n_refs=3, ref_spread_bps=1.0, bps=bps) + for a in VW.ASSETS}, + "deribit_errors": []} + monkeypatch.setattr(VW, "observe", fake) + + +def _porta_ad_alert(monkeypatch, tmp_path, sender): + """Fa salire lo streak fino all'ALERT e ritorna il report del giro che allerta.""" + sp = tmp_path / "vw.json" + _obs_finto(monkeypatch, bps=VW.THRESHOLD_BPS * 3) + rep = None + for _ in range(VW.PERSIST_HOURS): + rep = VW.run_once(state_path=sp, sender=sender) + return sp, rep + + +def test_invio_fallito_NON_consuma_l_allarme(monkeypatch, tmp_path): + ko = lambda rep: (False, "ConnectionError: rete giu'") + sp, rep = _porta_ad_alert(monkeypatch, tmp_path, ko) + assert any(l == "ALERT" for l in rep["levels"].values()) + assert rep["invio"].startswith("FALLITO") + # il marcatore 'gia' detto' NON deve essere sul disco + st = VW.load_state(sp) + assert all(not s.alerted for s in st.assets.values()), "un invio fallito ha consumato l'allarme" + # l'ora dopo, stessa dislocazione: deve RI-allertare + rep2 = VW.run_once(state_path=sp, sender=ko) + assert any(l == "ALERT" for l in rep2["levels"].values()), "l'allarme non e' stato ritentato" + + +def test_invio_riuscito_consuma_l_allarme_una_volta_sola(monkeypatch, tmp_path): + """Il controllo positivo: senza questo, 'ri-allerta sempre' passerebbe il test di sopra.""" + ok = lambda rep: (True, "ok") + sp, rep = _porta_ad_alert(monkeypatch, tmp_path, ok) + assert any(l == "ALERT" for l in rep["levels"].values()) + assert rep["invio"] == "inviato" + rep2 = VW.run_once(state_path=sp, sender=ok) + assert not any(l == "ALERT" for l in rep2["levels"].values()), "allarme ripetuto ogni ora" + assert all(l == "WATCH" for l in rep2["levels"].values()) + + +def test_lock_maint_fallito_ritenta_ma_le_ORE_continuano_a_correre(monkeypatch, tmp_path): + """Disfare anche le ore congelerebbe l'escalation proprio mentre non si riesce a parlare: + una manutenzione che sfora la grazia deve salire ad ALERT anche col trasporto giu'.""" + sp = tmp_path / "vw.json" + ko = lambda rep: (False, "rete giu'") + _obs_finto(monkeypatch, bps=0.0, locked=True, maint=True) + livelli = [VW.run_once(state_path=sp, sender=ko)["lock_level"] + for _ in range(VW.MAINT_GRACE_HOURS + 1)] + assert livelli[0] == "MAINT" + assert livelli[1] == "MAINT", "il MAINT perso non e' stato ritentato" + assert livelli[-1] == "ALERT", "le ore non sono avanzate: l'escalation si e' congelata" + + +def test_rientro_perso_si_riannuncia(monkeypatch, tmp_path): + """Il rientro si dice UNA volta e azzera le ore: senza ripristinare lo stato precedente + non ci sarebbe piu' un rientro da annunciare.""" + sp = tmp_path / "vw.json" + _obs_finto(monkeypatch, bps=0.0, locked=True, maint=False) + VW.run_once(state_path=sp, sender=lambda r: (True, "ok")) + _obs_finto(monkeypatch, bps=0.0, locked=False) + assert VW.run_once(state_path=sp, sender=lambda r: (False, "giu'"))["lock_level"] == "RIENTRATO" + assert VW.run_once(state_path=sp, sender=lambda r: (True, "ok"))["lock_level"] == "RIENTRATO" + + +def test_senza_sender_il_report_lo_DICE(monkeypatch, tmp_path): + """'non tentato' non e' 'inviato': il silenzio non deve leggersi come un invio riuscito.""" + sp, rep = _porta_ad_alert(monkeypatch, tmp_path, None) + assert "non tentato" in rep["invio"]