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:
+29
-15
@@ -16,14 +16,37 @@ ROOT = Path(__file__).resolve().parents[2]
|
|||||||
sys.path.insert(0, str(ROOT))
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
from src.live.deribit import check_specs # noqa: E402
|
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
|
from src.live.venue_watch import (PERSIST_HOURS, THRESHOLD_BPS, # noqa: E402
|
||||||
run_once)
|
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:
|
def main() -> int:
|
||||||
quiet = "--quiet" in sys.argv
|
quiet = "--quiet" in sys.argv
|
||||||
rep = run_once()
|
rep = run_once(sender=_manda)
|
||||||
|
|
||||||
if not quiet:
|
if not quiet:
|
||||||
print("=" * 78)
|
print("=" * 78)
|
||||||
@@ -65,21 +88,12 @@ def main() -> int:
|
|||||||
print(f" SPEC: {k}: {v}")
|
print(f" SPEC: {k}: {v}")
|
||||||
|
|
||||||
if rep["alerts"]:
|
if rep["alerts"]:
|
||||||
# ⚠️ Il titolo dice cosa FARE, non solo cosa e' successo: chi lo legge sul telefono deve
|
# L'invio l'ha gia' fatto `run_once` attraverso `_manda`: qui si stampa e si riporta
|
||||||
# sapere il primo passo senza aprire il repo (runbook completo in src/live/venue_watch.py).
|
# l'ESITO. Un invio fallito si vede nel log del cron invece di sparire — e lo stato su
|
||||||
# ⚠️ Dal 19/08 il titolo DIPENDE dalla gravita'. Prima era sempre il 🚨 col prelievo di
|
# disco e' stato disfatto, quindi l'ora prossima ci riprova da solo.
|
||||||
# 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"])})
|
|
||||||
for a in rep["alerts"]:
|
for a in rep["alerts"]:
|
||||||
print(f" ALERT: {a}")
|
print(f" ALERT: {a}")
|
||||||
|
print(f" telegram: {rep.get('invio')}")
|
||||||
return 2 if rep.get("severity") == "alert" else 0
|
return 2 if rep.get("severity") == "alert" else 0
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -76,8 +76,12 @@ def send(text: str, tentativi: int = 1, pausa_s: float = 2.0) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def notify(title: str, data: dict | None = None) -> bool:
|
def notify(title: str, data: dict | None = None, tentativi: int = 1) -> bool:
|
||||||
"""Invia un alert formattato. Ritorna True se inviato (config presente + rete ok)."""
|
"""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:
|
try:
|
||||||
from src.version import APP_VERSION
|
from src.version import APP_VERSION
|
||||||
ver = f" <code>v{APP_VERSION}</code>"
|
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}"]
|
lines = [f"<b>{title}</b>{ver}"]
|
||||||
for k, v in (data or {}).items():
|
for k, v in (data or {}).items():
|
||||||
lines.append(f" {k}: {v}")
|
lines.append(f" {k}: {v}")
|
||||||
return send("\n".join(lines))
|
return send("\n".join(lines), tentativi=tentativi)
|
||||||
|
|
||||||
|
|
||||||
def is_configured() -> bool:
|
def is_configured() -> bool:
|
||||||
|
|||||||
+47
-3
@@ -299,10 +299,33 @@ def observe() -> dict:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def run_once(state_path: Path = STATE_PATH) -> dict:
|
def run_once(state_path: Path = STATE_PATH, sender=None) -> dict:
|
||||||
"""Un giro completo: osserva, avanza lo stato, ritorna il report (NON invia nulla).
|
"""Un giro completo: osserva, avanza lo stato, INVIA (se gli si da' un `sender`), salva.
|
||||||
L'invio Telegram lo fa il chiamante, cosi' questa resta testabile senza rete d'uscita."""
|
|
||||||
|
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)
|
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()
|
obs = observe()
|
||||||
report = {"ts": obs["ts"], "platform_locked": obs["platform_locked"],
|
report = {"ts": obs["ts"], "platform_locked": obs["platform_locked"],
|
||||||
"locked_raw": obs.get("locked_raw"), "maintenance": obs.get("maintenance", False),
|
"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
|
st.assets[a] = new
|
||||||
report["levels"][a] = level
|
report["levels"][a] = level
|
||||||
if level == "ALERT":
|
if level == "ALERT":
|
||||||
|
asset_alertati.append(a)
|
||||||
report["alerts"].append(
|
report["alerts"].append(
|
||||||
f"{a}: scarto {o['bps']:+.0f} bps vs consenso ({o['n_refs']} referenze) "
|
f"{a}: scarto {o['bps']:+.0f} bps vs consenso ({o['n_refs']} referenze) "
|
||||||
f"persistente da {new.streak_hours}h a segno costante")
|
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"
|
report["severity"] = report["severity"] or "warn"
|
||||||
|
|
||||||
st.last_ts = obs["ts"]
|
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)
|
save_state(st, state_path)
|
||||||
return report
|
return report
|
||||||
|
|||||||
@@ -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
|
from src.live.venue_watch import (BLIND_ALERT_HOURS, PERSIST_HOURS, # noqa: E402
|
||||||
THRESHOLD_BPS, AssetState, WatchState,
|
THRESHOLD_BPS, AssetState, WatchState,
|
||||||
dislocation_bps, load_state, save_state, step)
|
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)
|
st2, lvl2 = step(st2, bps)
|
||||||
assert lvl2 != "ALERT"
|
assert lvl2 != "ALERT"
|
||||||
assert st2.streak_hours < PERSIST_HOURS, "l'ora BLIND deve azzerare lo streak"
|
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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user