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