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"]