Files
PythagorasGoal/scripts/live/venue_watch.py
T
Adriano Dal Pastro 801bd13f10 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>
2026-08-28 12:34:44 +00:00

103 lines
5.1 KiB
Python

#!/usr/bin/env python
"""venue_watch.py — runner ORARIO del tripwire di venue. Allerta su Telegram, NON blocca nulla.
Vedi `src/live/venue_watch.py` per la taratura (100 bps / 4h a segno costante, zero falsi allarmi
su 8 anni, controllo positivo su Bitfinex 2018-19) e per il RUNBOOK a un allarme.
uv run python scripts/live/venue_watch.py # un giro, stampa il report
uv run python scripts/live/venue_watch.py --quiet # solo in caso di allarme (per il cron)
"""
from __future__ import annotations
import sys
from pathlib import Path
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, 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(sender=_manda)
if not quiet:
print("=" * 78)
print(" VENUE WATCH — scarto Deribit vs consenso USD indipendente")
print("=" * 78)
print(f" soglia: {THRESHOLD_BPS:.0f} bps persistenti {PERSIST_HOURS}h a segno costante")
lock = rep["platform_locked"]
print(f" public/status locked: {rep.get('locked_raw') or 'non leggibile'}"
+ (f" (da {rep['lock_hours']}h"
+ (", manutenzione dichiarata" if rep.get("maintenance") else "") + ")"
if lock else ""))
for a, lvl in rep["levels"].items():
d = rep["detail"][a]
bps = f"{d['bps']:+7.1f} bps" if d["bps"] is not None else " n/d "
print(f" {a:<4} {lvl:<6} {bps} ({d['n_refs']} referenze, "
f"spread {d['ref_spread_bps']:.1f} bps)" if d["ref_spread_bps"] is not None
else f" {a:<4} {lvl:<6} {bps} ({d['n_refs']} referenze)")
# --- specifiche contratto: la tabella dichiarata si e' scostata dal venue?
# ⚠️ Gira qui e non nel book: e' un controllo di SOLA LETTURA e non deve stare sul percorso
# che manda ordini. Il 18/08 Deribit ha cambiato tick e size dei perpetual lineari USDC e
# niente se n'era accorto: e' andata bene perche' i cambi erano riduzioni.
spec = check_specs()
if spec["divergenze"]:
rifiuto = [d for d in spec["divergenze"] if d["rischio"] == "rifiuto"]
righe = {f"{d['strumento']} {d['campo']}": f"dichiarato {d['dichiarato']} / "
f"venue {d['venue']} -> {d['rischio']}"
for d in spec["divergenze"]}
if rifiuto:
righe["azione"] = ("il dichiarato e' PIU' FINE del venue: gli ordini verranno "
"RIFIUTATI. Correggi _CONTRACT in src/live/deribit.py adesso.")
notify("🚨 SPEC CONTRATTO — Deribit rifiutera' gli ordini", righe)
else:
righe["azione"] = ("il dichiarato e' piu' GROSSO del venue: gli ordini restano "
"conformi, si perde solo granularita'. Aggiorna _CONTRACT "
"in src/live/deribit.py.")
notify("⚠️ SPEC CONTRATTO — la tabella dichiarata e' vecchia", righe)
for k, v in righe.items():
print(f" SPEC: {k}: {v}")
if 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
if __name__ == "__main__":
raise SystemExit(main())