d59aea7a0a
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
293 lines
16 KiB
Python
293 lines
16 KiB
Python
"""monitor_health — i forward-monitor stanno ancora registrando? (puro, testabile)
|
|
|
|
IL BUCO CHE CHIUDE. Il progetto ha **tre gate pre-registrati** che si decideranno leggendo una
|
|
serie forward: STATARB **2026-09-27**, XSR01 **2026-10-23**, DVOLSPREAD kill **2026-10-24**
|
|
(decisione piena 2027-01-24). Di quelle serie, **una sola** ha una guardia d'integrita'
|
|
(`paper_dvolspread`, contabilita' a 3 stati + veto sotto l'80% di barre attive). Le altre no: se
|
|
un monitor smettesse di avanzare in silenzio — feed rotto, eccezione ingoiata, cron che non gira —
|
|
il gate verrebbe deciso su dati mancanti, e i dati mancanti **somigliano a dati tranquilli**.
|
|
|
|
E' lo stesso schema gia' pagato due volte: `fresh_5m` che falliva in silenzio (26/07) e il
|
|
feed-freeze del 14/07. La regola del progetto e' che *"non vedo" non e' "va tutto bene"* — qui
|
|
diventa: **un monitor fermo non e' un monitor che dice zero.**
|
|
|
|
DUE GUASTI, DUE MISURE (una sola non basta):
|
|
* **coda** — l'ultima barra e' vecchia: il monitor si e' fermato *adesso*. Misurata come eta'
|
|
dell'ultima registrazione, sulla cadenza dichiarata dal monitor.
|
|
* **buchi interni** — il monitor gira ma perde barre: la serie e' piu' corta di quanto il suo
|
|
stesso arco temporale richieda. Misurata come copertura fra la PRIMA e l'ULTIMA barra.
|
|
Un monitor che ha perso il 30% delle barre di mezzo passerebbe qualunque controllo di
|
|
freschezza.
|
|
|
|
CADENZE, che NON sono tutte uguali (e sbagliarle vuol dire un falso allarme a settimana):
|
|
* `paper_prevday` registra a barra ORARIA (864 barre in 36 giorni), avanzato dal cron daily;
|
|
* `paper_combo` vive sul calendario di BORSA (gambe IB): il venerdi' e' l'ultima barra fino al
|
|
lunedi', e tre giorni di eta' sono la norma, non un guasto;
|
|
* gli altri sono crypto giornalieri, 365 barre l'anno.
|
|
|
|
SOGLIA DI COPERTURA 0.80 — la stessa del veto d'integrita' gia' pre-registrato per DVOLSPREAD
|
|
("se le barre attive sono sotto l'80% si ESTENDE la finestra, non si decide"). Riusarla e' voluto:
|
|
una soglia diversa per monitor renderebbe i gate non confrontabili.
|
|
|
|
⚠️ LIMITE DICHIARATO. `np.busday_count` non conosce le FESTIVITA' di borsa: sul calendario equity
|
|
la copertura attesa e' sovrastimata di un giorno per festivita' nell'arco misurato (oggi
|
|
`paper_combo` legge 96% per il 3 luglio, non per un guasto). E' un errore in direzione
|
|
CONSERVATIVA — segnala di piu', non di meno — e resta ampiamente sopra la soglia 0.80 finche' le
|
|
festivita' sono <20% dei giorni. Se un giorno servisse precisione qui, la risposta e' un
|
|
calendario di borsa vero, non alzare la soglia.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import json
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
|
|
MIN_COVERAGE = 0.80 # sotto: la serie ha buchi -> non ci si decide sopra un gate
|
|
MIN_BARS_PER_JUDGE = 2 # con una barra sola non esiste ne' coda ne' copertura
|
|
GRAZIA_PREMATURO_MIN = 5.0 # tolleranza fra chiusura della barra e mtime del file (clock/IO)
|
|
SOGLIA_ORARI_H = 6.0 # (#30) max_age_h <= questa soglia = "sorvegliante orario": i tre
|
|
# con max_age 3h (cuscino_watch, venue_watch, collect_chain) oggi,
|
|
# derivato dalla tabella e non da una lista di nomi cablata
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MonitorSpec:
|
|
"""Cosa ci si aspetta da un monitor. `gate` = la decisione che dipende da questa serie."""
|
|
name: str
|
|
directory: str
|
|
series: str # returns.jsonl | equity.csv
|
|
cadence_h: float # passo nominale fra due barre
|
|
calendar: str = "crypto" # crypto (365g) | equity (giorni di borsa)
|
|
max_age_h: float = 48.0 # oltre: FERMO
|
|
gate: str | None = None
|
|
# True = i timestamp sono APERTURE di barra (convenzione resample_tf/ccxt/HL) -> vale il
|
|
# controllo PREMATURO. False = timbri di esecuzione (es. collect_chain scrive l'ora del
|
|
# giro): il timbro coincide con la scrittura per costruzione e il controllo mentirebbe
|
|
# SEMPRE (P14: una guardia piu' stretta del contratto insegna a ignorare la sezione).
|
|
open_labeled: bool = True
|
|
|
|
|
|
MONITORS: tuple[MonitorSpec, ...] = (
|
|
MonitorSpec("paper_portfolio", "paper_portfolio", "equity.csv", 24.0),
|
|
MonitorSpec("paper_prevday", "paper_prevday", "returns.jsonl", 1.0),
|
|
MonitorSpec("paper_statarb", "paper_statarb", "returns.jsonl", 24.0,
|
|
gate="2026-09-27 — gate di deploy STATARB-RESID"),
|
|
MonitorSpec("paper_xsr", "paper_xsr", "returns.jsonl", 24.0,
|
|
gate="2026-10-23 — gate di deploy XSR01 (Sharpe>=1.0 E haircut<=40%)"),
|
|
MonitorSpec("paper_dvolspread", "paper_dvolspread", "returns.jsonl", 24.0,
|
|
gate="2026-10-24 kill / 2027-01-24 decisione — DVOLSPREAD"),
|
|
# calendario di BORSA: le gambe GTAA vengono da IB, il weekend non e' un guasto.
|
|
MonitorSpec("paper_combo", "paper_combo", "equity.csv", 24.0,
|
|
calendar="equity", max_age_h=120.0),
|
|
# Raccolta catena opzioni (successore di cerbero-bite): ORARIA. Non alimenta un gate a data
|
|
# fissa ma una serie IRRECUPERABILE — un'ora non raccolta non si recupera da nessuna fonte,
|
|
# quindi la soglia d'eta' e' stretta (3h = due giri persi).
|
|
MonitorSpec("collect_chain", "chain_collect", "runs.jsonl", 1.0, max_age_h=3.0,
|
|
gate="serie irrecuperabile — la catena opzioni non si ricostruisce a posteriori",
|
|
open_labeled=False),
|
|
# Sorveglianza del collaterale USDE (cron 12:35 UTC): se si ferma, spariscono gli allarmi
|
|
# depeg/quota e il silenzio si legge come "va tutto bene" (P5). max_age 30h = un giro
|
|
# mancato + margine; timbri di esecuzione come collect_chain -> open_labeled=False.
|
|
MonitorSpec("usde_watch", "live", "usde_watch.jsonl", 24.0, max_age_h=30.0,
|
|
gate="allarmi depeg/quota USDE + verdetto eligibilita' 29/08",
|
|
open_labeled=False),
|
|
# Cuscino USDC di regolamento (cron :53, ORARIO, debito §5.17 / issue #3): se si ferma nessuno
|
|
# vede il saldo USDC scendere sotto il cuscino, e la riconversione automatica non parte.
|
|
# max_age 3h = due giri persi; timbri di esecuzione -> open_labeled=False.
|
|
MonitorSpec("cuscino_watch", "live", "cuscino_watch.jsonl", 1.0, max_age_h=3.0,
|
|
gate="cuscino USDC di regolamento: allerta + riconversione automatica",
|
|
open_labeled=False),
|
|
# Trigger di regime CC01 (cron_daily 00:30, GIORNALIERO, #12): dal 03/09 al 16/09 e' morto 14
|
|
# notti di fila senza che nessuno lo vedesse (#10), perche' non scriveva niente da sorvegliare.
|
|
# Il percorso e' quello di `cc01_regime_watch.RUNS` (un test li incrocia, P1). max_age 30h =
|
|
# un giro mancato + margine; timbri di esecuzione -> open_labeled=False.
|
|
MonitorSpec("cc01_regime_watch", "cc01_regime", "runs.jsonl", 24.0, max_age_h=30.0,
|
|
gate="trigger di regime CC01: WARN>=10% / ALERT>=15% funding 30g (riapre il dossier)",
|
|
open_labeled=False),
|
|
# I sei sorveglianti che generalizzano la riparazione di cc01 (#16): ognuno registra il
|
|
# proprio giro in `data/<dir>/runs.jsonl` (src.live.giri.registra_giro), timbri di
|
|
# ESECUZIONE come cc01 -> open_labeled=False. cron_daily.sh (GIORNALIERO) per i primi
|
|
# cinque: max_age 30h = un giro mancato + margine.
|
|
MonitorSpec("edge_watch", "edge_watch", "runs.jsonl", 24.0, max_age_h=30.0,
|
|
gate="kill del libro live (Sharpe 36m < -0,5 / protezione TP01)",
|
|
open_labeled=False),
|
|
MonitorSpec("fee_watch", "fee_watch", "runs.jsonl", 24.0, max_age_h=30.0,
|
|
gate="regola fee §3 decisa in anticipo",
|
|
open_labeled=False),
|
|
MonitorSpec("scale_watch", "scale_watch", "runs.jsonl", 24.0, max_age_h=30.0,
|
|
gate="GATE SCALA-01 criterio A2",
|
|
open_labeled=False),
|
|
MonitorSpec("vrp_f_watch", "vrp_f_watch", "runs.jsonl", 24.0, max_age_h=30.0,
|
|
gate="criteri (a)(b) §5.18",
|
|
open_labeled=False),
|
|
MonitorSpec("venue_news", "venue_news", "runs.jsonl", 24.0, max_age_h=30.0,
|
|
gate="annunci Deribit, debito 8",
|
|
open_labeled=False),
|
|
# cron_book.sh: gira OGNI ORA accanto al libro vivo. max_age 3h = due giri persi.
|
|
MonitorSpec("venue_watch", "venue_watch", "runs.jsonl", 1.0, max_age_h=3.0,
|
|
gate="tripwire di venue orario",
|
|
open_labeled=False),
|
|
)
|
|
|
|
|
|
def read_stamps(path: Path) -> list[datetime]:
|
|
"""Timestamp di ogni barra registrata, in UTC e ordinati. Formati: jsonl con `ts` in ms
|
|
epoch, oppure csv con la data in prima colonna."""
|
|
if not path.exists():
|
|
return []
|
|
out: list[datetime] = []
|
|
if path.suffix == ".jsonl":
|
|
for ln in path.read_text().splitlines():
|
|
ln = ln.strip()
|
|
if not ln:
|
|
continue
|
|
try:
|
|
ts = json.loads(ln).get("ts")
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if ts is not None:
|
|
out.append(datetime.fromtimestamp(float(ts) / 1000.0, tz=timezone.utc))
|
|
else:
|
|
with path.open() as fh:
|
|
for row in csv.DictReader(fh):
|
|
raw = next(iter(row.values()))
|
|
try:
|
|
d = datetime.fromisoformat(str(raw))
|
|
except ValueError:
|
|
continue
|
|
out.append(d if d.tzinfo else d.replace(tzinfo=timezone.utc))
|
|
return sorted(out)
|
|
|
|
|
|
def expected_bars(first: datetime, last: datetime, cadence_h: float, calendar: str) -> int:
|
|
"""Quante barre DOVREBBERO esserci fra la prima e l'ultima, estremi inclusi."""
|
|
if calendar == "equity":
|
|
n = int(np.busday_count(first.date(), last.date())) + 1
|
|
return max(n, 1)
|
|
span_h = (last - first).total_seconds() / 3600.0
|
|
return int(round(span_h / cadence_h)) + 1
|
|
|
|
|
|
def age_hours(last: datetime, now: datetime, calendar: str) -> float:
|
|
"""Eta' dell'ultima barra. Sul calendario di borsa il weekend non invecchia la serie:
|
|
contarlo produrrebbe un allarme ogni lunedi'."""
|
|
raw = (now - last).total_seconds() / 3600.0
|
|
if calendar == "equity":
|
|
bd = int(np.busday_count(last.date(), now.date()))
|
|
return min(raw, bd * 24.0 + (raw % 24.0))
|
|
return raw
|
|
|
|
|
|
def assess(spec: MonitorSpec, stamps: list[datetime], now: datetime,
|
|
mtime: datetime | None = None) -> dict:
|
|
"""Verdetto su un monitor. Gli stati NON sono due: 'assente' e 'troppo giovane per un
|
|
giudizio' non sono 'OK', e non devono poter passare per tali.
|
|
|
|
`mtime` = quando il file di serie e' stato scritto l'ultima volta. Serve al controllo
|
|
PREMATURO (r0822_monitor_audit): se l'ultima barra registrata CHIUDE dopo che il file e'
|
|
stato scritto, il monitor ha consumato una barra parziale — e' il difetto che ha fatto
|
|
registrare 4/41/2 minuti di mercato al giorno a 3 monitor con gate. Freschezza e copertura
|
|
NON possono vederlo: una serie fresca, completa e SBAGLIATA le passa entrambe."""
|
|
base = dict(name=spec.name, gate=spec.gate, n_bars=len(stamps),
|
|
age_h=None, coverage=None, expected=None)
|
|
if not stamps:
|
|
return {**base, "status": "ASSENTE",
|
|
"why": "nessuna serie leggibile: il monitor non ha mai scritto, o lo stato e' perso"}
|
|
if mtime is not None and spec.open_labeled:
|
|
chiude = stamps[-1] + timedelta(hours=spec.cadence_h)
|
|
ritardo_min = (chiude - mtime).total_seconds() / 60.0
|
|
if ritardo_min > GRAZIA_PREMATURO_MIN:
|
|
return {**base, "age_h": age_hours(stamps[-1], now, spec.calendar),
|
|
"status": "PREMATURO",
|
|
"why": (f"l'ultima barra chiude alle {chiude:%Y-%m-%d %H:%M}Z ma il file e' "
|
|
f"stato scritto {ritardo_min/60:.1f}h PRIMA: registrata una barra "
|
|
"parziale — la serie va rigenerata, non solo riparata")}
|
|
age = age_hours(stamps[-1], now, spec.calendar)
|
|
if len(stamps) < MIN_BARS_PER_JUDGE:
|
|
return {**base, "age_h": age, "status": "NUOVO",
|
|
"why": f"{len(stamps)} barra: non c'e' abbastanza serie per misurare la copertura"}
|
|
exp = expected_bars(stamps[0], stamps[-1], spec.cadence_h, spec.calendar)
|
|
cov = len(stamps) / exp if exp > 0 else float("nan")
|
|
d = {**base, "age_h": age, "coverage": cov, "expected": exp}
|
|
if age > spec.max_age_h:
|
|
return {**d, "status": "FERMO",
|
|
"why": f"ultima barra {age:.0f}h fa (limite {spec.max_age_h:.0f}h)"}
|
|
if cov < MIN_COVERAGE:
|
|
return {**d, "status": "BUCATO",
|
|
"why": f"copertura {cov:.0%} < {MIN_COVERAGE:.0%}: {exp - len(stamps)} barre mancanti"}
|
|
return {**d, "status": "OK", "why": ""}
|
|
|
|
|
|
def check_all(root: Path, now: datetime | None = None,
|
|
monitors: tuple[MonitorSpec, ...] = MONITORS) -> list[dict]:
|
|
now = now or datetime.now(timezone.utc)
|
|
out = []
|
|
for spec in monitors:
|
|
f = root / "data" / spec.directory / spec.series
|
|
stamps = read_stamps(f)
|
|
mtime = (datetime.fromtimestamp(f.stat().st_mtime, tz=timezone.utc)
|
|
if f.exists() else None)
|
|
out.append(assess(spec, stamps, now, mtime=mtime))
|
|
return out
|
|
|
|
|
|
def alerts_from(rows: list[dict]) -> list[str]:
|
|
"""Cosa vale un allarme. Un monitor NUOVO non lo e' (sta solo iniziando); un monitor che
|
|
alimenta un gate pre-registrato e' peggio degli altri, e l'allarme lo dice."""
|
|
out = []
|
|
for r in rows:
|
|
if r["status"] in ("OK", "NUOVO"):
|
|
continue
|
|
msg = f"{r['name']}: {r['status']} — {r['why']}"
|
|
if r["gate"]:
|
|
msg += f" | METTE A RISCHIO il gate {r['gate']}"
|
|
out.append(msg)
|
|
return out
|
|
|
|
|
|
# ===========================================================================
|
|
# GIRO ORARIO (#30). Il giro giornaliero (cron_daily) resta com'e': report completo, avviso
|
|
# sempre. I sorveglianti a max_age <= 6h (oggi cuscino_watch, venue_watch, collect_chain) si
|
|
# scoprono fermi fino a 24h dopo se nessuno li guarda fuori da quel giro: qui si guardano ogni
|
|
# ora, ma si notifica solo cio' che e' NUOVO rispetto all'ultimo avviso (niente Telegram a
|
|
# raffica sullo stesso guasto fermo). Tutto PURO: il marcatore lo legge/scrive lo script.
|
|
# ===========================================================================
|
|
def monitors_orari(monitors: tuple[MonitorSpec, ...] = MONITORS,
|
|
soglia_h: float = SOGLIA_ORARI_H) -> tuple[MonitorSpec, ...]:
|
|
"""I monitor con cadenza di controllo ORARIA: max_age_h <= soglia_h. Derivato dalla tabella
|
|
`MONITORS`, non da una lista di nomi cablata: aggiungere un monitor orario lo include qui
|
|
senza toccare questa funzione."""
|
|
return tuple(m for m in monitors if m.max_age_h <= soglia_h)
|
|
|
|
|
|
def chiave_alert(row: dict) -> str:
|
|
"""Chiave stabile di un alert per il marcatore 'gia' detto': nome + stato, NON il testo
|
|
(`why` contiene l'eta' dell'ultima barra, che cambia a ogni giro anche se il guasto e' lo
|
|
stesso)."""
|
|
return f"{row['name']}:{row['status']}"
|
|
|
|
|
|
def alert_nuovi(rows: list[dict], detti: set[str]) -> list[dict]:
|
|
"""Le righe-alert (non OK, non NUOVO — vedi `alerts_from`) la cui chiave NON e' nel
|
|
marcatore `detti`: quelle davvero nuove, le sole da notificare."""
|
|
return [r for r in rows if r["status"] not in ("OK", "NUOVO") and chiave_alert(r) not in detti]
|
|
|
|
|
|
def marcatore_aggiornato(rows: list[dict], detti: set[str]) -> set[str]:
|
|
"""Il marcatore dopo questo giro: le chiavi dei monitor ancora in alert restano/si aggiungono,
|
|
quelle dei monitor tornati OK si TOLGONO — cosi' un nuovo guasto sullo stesso monitor riparte
|
|
con un nuovo avviso invece di restare 'gia' detto' per sempre. Non scrive nulla: lo fa lo
|
|
script, e solo se la notifica e' partita (stessa regola di fee_watch #29)."""
|
|
out = set(detti)
|
|
for r in rows:
|
|
chiavi_monitor = {k for k in out if k.startswith(f"{r['name']}:")}
|
|
if r["status"] == "OK":
|
|
out -= chiavi_monitor
|
|
elif r["status"] != "NUOVO":
|
|
out.add(chiave_alert(r))
|
|
return out
|