diff --git a/scripts/live/venue_watch.py b/scripts/live/venue_watch.py index 6b7467c..db78725 100644 --- a/scripts/live/venue_watch.py +++ b/scripts/live/venue_watch.py @@ -31,6 +31,8 @@ def _titolo(rep: dict) -> str: 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") == "blind": + return "⚠️ venue_watch CIECO — referenze non leggibili, nessun giudizio sul venue" 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" diff --git a/src/live/venue_watch.py b/src/live/venue_watch.py index bd0b12e..0199fe2 100644 --- a/src/live/venue_watch.py +++ b/src/live/venue_watch.py @@ -346,6 +346,11 @@ def run_once(state_path: Path = STATE_PATH, sender=None) -> dict: f"({o['n_refs']} referenze, spread {o['ref_spread_bps']})") if report["levels"] and any(l == "ALERT" for l in report["levels"].values()): report["severity"] = "alert" + # issue #31: senza ALERT, un asset BLIND lasciava severity=None — il titolo del runner + # cadeva sul default piu' forte ("PRIMO PASSO: prelievo di prova") per un episodio che e' + # "non vedo", non "stress misurato". BLIND e' lo stato MORBIDO: cede il passo ad ALERT. + elif report["levels"] and any(l == "BLIND" for l in report["levels"].values()): + report["severity"] = "blind" # --- blocco piattaforma: adesso passa dalla stessa disciplina degli altri stati lock_new, lock_lvl = lock_step(st.lock, obs["platform_locked"], obs.get("maintenance", False)) diff --git a/tests/test_venue_watch.py b/tests/test_venue_watch.py index 1679ff3..d5f036d 100644 --- a/tests/test_venue_watch.py +++ b/tests/test_venue_watch.py @@ -499,3 +499,84 @@ 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"] + + +# ============================================================================================= +# issue #31 — BLIND senza ALERT lasciava severity=None: il titolo del runner cadeva sul default +# piu' forte ("PRIMO PASSO: prelievo di prova") anche per un episodio che e' "non vedo", non +# "stress misurato". BLIND e' lo stato MORBIDO (docstring del modulo). +# ============================================================================================= + +def test_severity_blind_quando_un_asset_e_cieco_senza_alert(monkeypatch, tmp_path): + sp = tmp_path / "vw.json" + + def fake(): + return {"ts": 1, "platform_locked": False, "locked_raw": "false", "maintenance": False, + "assets": {a: dict(deribit=1.0, n_refs=1, ref_spread_bps=None, bps=None) + for a in VW.ASSETS}, + "deribit_errors": []} + monkeypatch.setattr(VW, "observe", fake) + + rep = None + for _ in range(VW.BLIND_ALERT_HOURS): + rep = VW.run_once(state_path=sp, sender=lambda r: (True, "ok")) + assert any(l == "BLIND" for l in rep["levels"].values()) + assert not any(l == "ALERT" for l in rep["levels"].values()) + assert rep["severity"] == "blind" + + +def test_severity_alert_prevale_su_blind(monkeypatch, tmp_path): + """Se un asset e' cieco ma l'altro e' in ALERT vero, la gravita' massima vince (P9). + + BTC resta senza referenze per tutto il giro (arriva a BLIND all'ora `BLIND_ALERT_HOURS`); + ETH parte harmless e passa a un vero streak sopra soglia SOLO negli ultimi `PERSIST_HOURS` + round, cosi' che il round in cui ETH scatta ALERT sia lo stesso in cui BTC e' gia' BLIND.""" + sp = tmp_path / "vw.json" + letture = {"n": 0} + soglia_eth = VW.BLIND_ALERT_HOURS - VW.PERSIST_HOURS # da qui in poi ETH costruisce lo streak + + def fake(): + letture["n"] += 1 + eth_bps = VW.THRESHOLD_BPS * 3 if letture["n"] > soglia_eth else 1.0 + return {"ts": letture["n"], "platform_locked": False, "locked_raw": "false", + "maintenance": False, + "assets": {"BTC": dict(deribit=1.0, n_refs=1, ref_spread_bps=None, bps=None), + "ETH": dict(deribit=1.0, n_refs=3, ref_spread_bps=1.0, bps=eth_bps)}, + "deribit_errors": []} + monkeypatch.setattr(VW, "observe", fake) + + rep = None + for _ in range(VW.BLIND_ALERT_HOURS): + rep = VW.run_once(state_path=sp, sender=lambda r: (True, "ok")) + assert rep["levels"]["BTC"] == "BLIND" + assert rep["levels"]["ETH"] == "ALERT" + assert rep["severity"] == "alert" + + +def _carica_script_venue_watch(): + """scripts/live/venue_watch.py (il runner) — via importlib, per non collidere col nome + del modulo `src.live.venue_watch` gia' importato sopra come `VW`.""" + import importlib.util + sp = importlib.util.spec_from_file_location( + "venue_watch_script", ROOT / "scripts" / "live" / "venue_watch.py") + m = importlib.util.module_from_spec(sp) + sp.loader.exec_module(m) + return m + + +def test_titolo_blind_non_dice_prelievo(): + SVW = _carica_script_venue_watch() + t = SVW._titolo({"severity": "blind"}) + assert "prelievo" not in t.lower() + assert "CIECO" in t + + +def test_titolo_alert_dice_ancora_prelievo(): + SVW = _carica_script_venue_watch() + assert "prelievo" in SVW._titolo({"severity": "alert"}).lower() + assert "prelievo" in SVW._titolo({"severity": None}).lower() # default: nessuna gravita' letta + + +def test_titolo_warn_resta_quello_di_prima(): + SVW = _carica_script_venue_watch() + assert "Nessuna azione" in SVW._titolo({"severity": "warn"})