allarmi: il marcatore "gia' detto" si scrive DOPO l'invio, non prima

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) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-08-28 12:34:44 +00:00
parent c21a0d4058
commit 801bd13f10
4 changed files with 166 additions and 21 deletions
+29 -15
View File
@@ -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
+7 -3
View File
@@ -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" <code>v{APP_VERSION}</code>"
@@ -86,7 +90,7 @@ def notify(title: str, data: dict | None = None) -> bool:
lines = [f"<b>{title}</b>{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:
+47 -3
View File
@@ -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
+83
View File
@@ -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"]