Files
PythagorasGoal/scripts/live/venue_news.py
T
Adriano 1a7f670966 sei sorveglianti registrano i giri e stanno in monitor_health (Fixes #16)
src/live/giri.registra_giro: una riga jsonl {ts ms, campi} dopo una misura completata,
mai un'eccezione. edge_watch, fee_watch, scale_watch, vrp_f_watch, venue_news (cron_daily)
e venue_watch (cron_book, orario) scrivono data/<dir>/runs.jsonl; niente riga senza misura.
monitor_health: sei MonitorSpec (30h i giornalieri, 3h venue_watch), open_labeled=False.
Stessa riparazione di cc01 (#10, #12), generalizzata. Primo giro notturno: NUOVO, non allerta.
Test: incrocio P1 percorso/RUNS, misura -> una riga, non-misura -> nessuna. Suite: 1098 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 08:48:07 +00:00

177 lines
7.1 KiB
Python

#!/usr/bin/env python
"""venue_news.py — legge cosa il venue ANNUNCIA. SOLA LETTURA, non blocca mai nulla.
PERCHE' ESISTE (2026-08-31). Il debito #8 dice, testuale: *«Resta scoperto il Rulebook vero e
proprio: la sonda legge se il venue RISPONDE, non cosa il venue ANNUNCIA.»* Questa e' quella
meta'. In una settimana quattro cambi di policy Deribit materiali sono arrivati **a mano**, perche'
l'operatore ha incollato dei link — non perche' qualcosa li avesse visti:
* tetto sull'USDE (~31,2% dell'equity) * esclusione dell'Italia dai reward USDC
* permesso mancante su BUIDL * fine della Proof of Reserves quotidiana
E il caso che decide la questione: le **specifiche dei perpetual lineari USDC** sono cambiate il
18/08, annunciate il **14/08**. `check_specs()` rileva la deriva DOPO che e' avvenuta ed e' nato
proprio da quella svista; il feed l'avrebbe detto **quattro giorni prima**. Rilevare a posteriori e
sapere in anticipo non sono la stessa cosa: la prima volta e' andata bene solo perche' i cambi
erano RIDUZIONI (un valore piu' grosso resta conforme). Il giorno che Deribit ALZA un minimo, gli
ordini vengono rifiutati.
COSA E' E COSA NON E'. E' un promemoria che legge un feed RSS pubblico e dice *«e' uscito questo,
guardalo»*. **Non interpreta e non decide**: nessun automatismo su un testo di marketing (P13 — una
guardia sui NUMERI non copre il RAGIONAMENTO, e la prosa si legge come opinione di un lettore
fallibile). L'unica cosa che classifica e' l'URGENZA, e lo fa su parole DERIVATE dal codice
sorvegliato, non ridichiarate qui (P1): gli strumenti vengono da `deribit._CONTRACT`, le valute di
collaterale da `config/live.json`. Chi aggiunge uno strumento o una valuta allarga da solo la
sorveglianza, senza toccare questo file.
uv run python scripts/live/venue_news.py # report + allerta sui nuovi
uv run python scripts/live/venue_news.py --quiet # solo allerta (per il cron)
"""
from __future__ import annotations
import argparse
import html
import json
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from src.live.giri import registra_giro # noqa: E402
from src.live.notifier import notify # noqa: E402
FEED = "https://insights.deribit.com/exchange-updates/feed/"
STATE = ROOT / "data" / "live" / "venue_news.jsonl"
RUNS = ROOT / "data" / "venue_news" / "runs.jsonl"
# Parole che alzano un annuncio a 🚨. Le prime sono FISSE (categorie di rischio che ci toccano
# per costruzione); le altre si DERIVANO dal codice, sotto.
_CHIAVI_FISSE = (
"contract specification", "margin", "collateral", "fee", "delist", "discontinu",
"reserve", "maintenance", "liquidation", "settlement", "withdrawal", "reward",
"jurisdiction", "eligib", "insurance fund", "socialized", "adl", "rulebook",
)
def chiavi() -> list[str]:
"""Parole-chiave DERIVATE dal codice sorvegliato (P1), piu' quelle fisse."""
out = set(_CHIAVI_FISSE)
try:
from src.live.deribit import _CONTRACT
for nome in _CONTRACT: # BTC_USDC-PERPETUAL -> "btc", "usdc"
for pezzo in re.split(r"[_\-]", nome.lower()):
if len(pezzo) >= 3 and pezzo != "perpetual":
out.add(pezzo)
except Exception:
pass
try:
cfg = json.loads((ROOT / "config" / "live.json").read_text())
out.add(str(cfg.get("usde", {}).get("index_name", "")).split("_")[0].lower())
except Exception:
pass
return sorted(k for k in out if k)
def scarica(url: str = FEED, timeout: float = 20.0) -> str | None:
"""Feed RSS pubblico (tokenless). None se non leggibile. Mai solleva."""
try:
import requests
r = requests.get(url, timeout=timeout,
headers={"User-Agent": "PythagorasGoal/venue_news (+read-only)"})
return r.text if r.status_code == 200 else None
except Exception:
return None
def voci(xml: str) -> list[dict]:
"""PURA. Estrae (guid, titolo, data, link) dal RSS. Mai solleva."""
out = []
for blocco in re.findall(r"<item>(.*?)</item>", xml or "", re.S):
def campo(tag):
m = re.search(rf"<{tag}[^>]*>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?</{tag}>", blocco, re.S)
return html.unescape(m.group(1).strip()) if m else ""
g = campo("guid") or campo("link")
if g:
out.append({"guid": g, "titolo": campo("title"),
"data": campo("pubDate"), "link": campo("link")})
return out
def rilevante(titolo: str, ks: list[str]) -> list[str]:
"""PURA. Quali parole-chiave compaiono nel titolo."""
t = titolo.lower()
return [k for k in ks if k in t]
def gia_visti() -> set[str]:
if not STATE.exists():
return set()
out = set()
for riga in STATE.read_text().splitlines():
try:
out.add(json.loads(riga)["guid"])
except Exception:
pass
return out
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--quiet", action="store_true")
a = ap.parse_args()
xml = scarica()
if xml is None:
if not a.quiet:
print(" ✗ feed non leggibile — 'non vedo' non e' 'niente di nuovo' (P5)")
return 2
ks, viste, nuove = chiavi(), gia_visti(), []
primo_giro = not STATE.exists()
tutte = voci(xml)
STATE.parent.mkdir(parents=True, exist_ok=True)
with STATE.open("a") as f:
for v in tutte:
if v["guid"] in viste:
continue
v["match"] = rilevante(v["titolo"], ks)
v["visto_il"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
v["seed"] = primo_giro
f.write(json.dumps(v) + "\n")
nuove.append(v)
registra_giro(RUNS, voci=len(tutte), nuove=len(nuove))
# Primo giro: si semina lo storico SENZA allertare (P9: l'allarme massimo non si spende
# per un arretrato di 10 voci, o non verra' letto il giorno che e' vero).
if not primo_giro:
for v in nuove:
if v["match"]:
notify("🚨 annuncio Deribit sul NOSTRO percorso",
{"titolo": v["titolo"], "tocca": ", ".join(v["match"][:6]),
"data": v["data"], "link": v["link"],
"azione": "leggerlo: questo sorvegliante NON interpreta"})
else:
notify("📌 nuovo annuncio Deribit",
{"titolo": v["titolo"], "data": v["data"], "link": v["link"]})
if not a.quiet:
print("=" * 78)
print(f" VENUE NEWS — {len(tutte)} voci nel feed, {len(nuove)} nuove"
+ (" (PRIMO GIRO: storico seminato, nessun allarme)" if primo_giro else ""))
print("=" * 78)
for v in tutte:
m = rilevante(v["titolo"], ks)
segno = "🚨" if m else ("·" if v["guid"] in viste else "📌")
print(f" {segno} {v['data'][:16]:17s} {v['titolo'][:70]}")
if m:
print(f" tocca: {', '.join(m[:8])}")
print(f"\n parole derivate dal codice: {', '.join(ks)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())