Files
PythagorasGoal/tests/test_monitor_health.py
T
Adriano Dal Pastro d55eb13533 feat(chain): assorbita la raccolta catena opzioni, cerbero-bite dismesso
cerbero-bite viene eliminato. L'unica sua parte irreversibile e' il DATO:
una catena opzioni non si ricostruisce a posteriori (Deribit non serve book
storici, non c'e' un secondo venue). Il codice si riscrive; le ore non
raccolte no.

ASSORBITO
- scripts/live/collect_chain.py + scripts/cron_chain.sh (cron 25 * * * *):
  raccolta propria, ~570 strumenti/giro, ~3 min.
- scripts/analysis/import_cb_archive.py: archivio 1.23M righe (2026-05-01+)
  + market_snapshots 17.402 righe (2026-03-26+: dealer gamma, gamma flip,
  rischio liquidazioni, funding cross — dati che non abbiamo altrove).
- snapshot sqlite integrale in /opt/docker/backups/manual/ (SHA256).

NON ASSORBITO, con motivo: motore credit-spread ETH (regola "niente
short-vol da modello in deploy", conto a $52 contro minimo $720), GUI, kill
switch/dead-man/audit (abbiamo venue_watch/edge_watch/monitor_health/
fee_watch), dvol_history (fetch_dvol.py ha storia PIU' LUNGA: 2020+ contro
2026-05), decisions/positions (0 posizioni).

TRE DIFETTI DI BITE NON REPLICATI, tutti misurati il 30/07:
1. una chiamata per strumento invece di due (get_order_book?depth=3 da' gia'
   quote+greche+IV+OI+book+underlying) + prefiltro OI in una chiamata sola:
   551 -> ~290 chiamate per asset;
2. pacing invece di raffica. Il carico non e' mai stato il problema: 570
   chiamate/ora = 0.16/s DISTRIBUITE; bite le sparava in 26s (~44/s) e si
   auto-saturava il rate limit per-IP (12.186 risposte 429 in 26h, 96% al
   minuto :00). Primo giro reale: 574 chiamate, 0 risposte 429. Il minuto :25
   e' scelto: :00 era la raffica, :07 e' cron_book (feed 5m di SKH01).
3. quote_status esplicito {ok, no_quote, error} e book_depth NULL su errore
   mai 0. "Book vuoto" e "chiamata fallita" sono cose diverse: e' per questo
   che il guasto del 29/07 (50% di quote perse, 38 ore) non produsse alcun
   segnale. Le righe ereditate restano 'unknown': bite non lo registrava e a
   posteriori non e' ricostruibile.

Battuta di cuore in data/chain_collect/runs.jsonl anche a giro fallito,
sorvegliata da monitor_health (1h, max_age 3h): un collettore fermo non
produce niente, e il niente si legge come "nessun dato quel giorno".

Difetto trovato per strada: due formati ISO nella stessa colonna (92 righe
di backfill senza microsecondi). pd.to_datetime senza `format` ne inferisce
uno solo e manda gli altri a NaT -> il dropna a valle li toglieva in
silenzio, e la serie di contesto perdeva 5 settimane slittando dal 26/03 al
01/05. Corretto con format="ISO8601" e scarto RUMOROSO.

Book, pesi, config, strategia INVARIATI. 537 test verdi.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:14:34 +00:00

179 lines
7.9 KiB
Python

"""Test della sorveglianza dei forward-monitor (src/live/monitor_health.py).
Tre gate pre-registrati (STATARB 27/09, XSR01 23/10, DVOLSPREAD 24/10) si decidono leggendo serie
che nessuno sorvegliava. Questi test coprono le due modalita' di guasto (coda ferma, buchi
interni) e — obbligatoriamente — i **controlli positivi**: un rilevatore che non ha mai segnalato
nulla e' indistinguibile da uno rotto finche' non si prova che sa segnalare.
Il test che conta piu' di tutti e' `test_una_serie_bucata_non_passa_per_fresca`: e' l'unico guasto
che una guardia di sola freschezza lascerebbe passare, ed e' quello che falsifica un gate senza
farsi notare.
"""
from __future__ import annotations
import json
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from src.live.monitor_health import ( # noqa: E402
MIN_COVERAGE,
MONITORS,
MonitorSpec,
alerts_from,
assess,
check_all,
expected_bars,
read_stamps,
)
NOW = datetime(2026, 7, 27, 12, 0, tzinfo=timezone.utc)
DAILY = MonitorSpec("t", "t", "returns.jsonl", 24.0)
GATED = MonitorSpec("t_gate", "t_gate", "returns.jsonl", 24.0, gate="2026-10-23 — gate XSR01")
EQ = MonitorSpec("t_eq", "t_eq", "equity.csv", 24.0, calendar="equity", max_age_h=120.0)
def _daily(n: int, end: datetime = NOW, step_h: float = 24.0, skip: set[int] | None = None):
"""n timestamp a passo `step_h` che finiscono a `end`, opzionalmente con buchi."""
skip = skip or set()
return [end - timedelta(hours=step_h * (n - 1 - i)) for i in range(n) if i not in skip]
# ===========================================================================
# stato normale
# ===========================================================================
def test_una_serie_completa_e_fresca_e_ok():
r = assess(DAILY, _daily(30), NOW)
assert r["status"] == "OK" and r["coverage"] == 1.0
def test_ok_non_produce_allarmi():
assert alerts_from([assess(DAILY, _daily(30), NOW)]) == []
# ===========================================================================
# CONTROLLI POSITIVI — la guardia sa segnalare?
# ===========================================================================
def test_un_monitor_fermo_viene_visto():
"""Il guasto piu' semplice: il cron non gira piu'. Ultima barra di 5 giorni fa."""
r = assess(DAILY, _daily(30, end=NOW - timedelta(days=5)), NOW)
assert r["status"] == "FERMO"
assert alerts_from([r])
def test_una_serie_bucata_non_passa_per_fresca():
"""IL test: il monitor gira, l'ultima barra e' di stanotte, ma ha perso il 30% delle barre di
mezzo. Una guardia di sola freschezza direbbe OK, e il gate verrebbe deciso su meta' serie."""
stamps = _daily(30, skip=set(range(5, 15))) # 20 barre su 30 attese
r = assess(DAILY, stamps, NOW)
assert r["status"] == "BUCATO"
assert r["coverage"] < MIN_COVERAGE
assert r["age_h"] < 24.0 # ...ed e' fresca: la freschezza non basta
def test_una_serie_assente_e_un_allarme_non_un_silenzio():
"""'Non vedo' non e' 'va tutto bene' — se lo stato e' perso, e' un evento."""
r = assess(DAILY, [], NOW)
assert r["status"] == "ASSENTE"
assert alerts_from([r])
def test_la_soglia_di_copertura_e_quella_del_veto_dvolspread():
"""Riusata di proposito: una soglia diversa per monitor renderebbe i gate non confrontabili."""
assert MIN_COVERAGE == 0.80
assert assess(DAILY, _daily(30, skip={5, 6, 7, 8, 9}), NOW)["status"] == "OK" # 83%
assert assess(DAILY, _daily(30, skip=set(range(5, 12))), NOW)["status"] == "BUCATO" # 77%
# ===========================================================================
# giovinezza, che non e' salute
# ===========================================================================
def test_un_monitor_con_una_sola_barra_non_e_giudicabile():
r = assess(DAILY, _daily(1), NOW)
assert r["status"] == "NUOVO" and r["coverage"] is None
def test_nuovo_non_allerta_ma_non_e_ok():
"""Un monitor appena partito non deve suonare, ma non deve nemmeno risultare sano:
XSR01 e DVOLSPREAD hanno 2 barre e mancano mesi al loro gate."""
r = assess(DAILY, _daily(1), NOW)
assert alerts_from([r]) == [] and r["status"] != "OK"
# ===========================================================================
# cadenze: sbagliarle vuol dire un falso allarme a settimana
# ===========================================================================
def test_un_monitor_orario_non_viene_scambiato_per_giornaliero():
"""paper_prevday registra a barra oraria: 864 barre in 36 giorni sono complete, non un
eccesso. Con la cadenza sbagliata la copertura uscirebbe al 2400%."""
spec = MonitorSpec("h", "h", "returns.jsonl", 1.0)
r = assess(spec, _daily(864, step_h=1.0), NOW)
assert r["status"] == "OK" and r["expected"] == 864
def test_il_weekend_non_ferma_un_monitor_di_borsa():
"""paper_combo dipende dalle gambe IB: venerdi' e' l'ultima barra fino a lunedi'. Contare
il weekend come eta' produrrebbe un allarme ogni lunedi' mattina."""
ven = datetime(2026, 7, 24, 0, 0, tzinfo=timezone.utc)
lun = datetime(2026, 7, 27, 12, 0, tzinfo=timezone.utc)
stamps = [ven - timedelta(days=k) for k in range(20, 0, -1)] + [ven]
assert assess(EQ, stamps, lun)["status"] != "FERMO"
def test_le_barre_attese_su_calendario_di_borsa_escludono_il_weekend():
lun = datetime(2026, 7, 20, tzinfo=timezone.utc)
ven = datetime(2026, 7, 24, tzinfo=timezone.utc)
assert expected_bars(lun, ven, 24.0, "equity") == 5 # non 5 giorni solari a caso: 5 sedute
assert expected_bars(lun, ven, 24.0, "crypto") == 5
# ===========================================================================
# escalation: il danno non e' il monitor, e' il gate
# ===========================================================================
def test_un_monitor_che_alimenta_un_gate_lo_dice_nell_allarme():
a = alerts_from([assess(GATED, _daily(30, end=NOW - timedelta(days=5)), NOW)])
assert a and "gate" in a[0].lower() and "XSR01" in a[0]
# ===========================================================================
# lettura dei formati reali
# ===========================================================================
def test_legge_jsonl_con_ts_in_millisecondi(tmp_path):
p = tmp_path / "returns.jsonl"
p.write_text("\n".join(json.dumps({"ts": 1785024000000 + i * 86_400_000, "net": 0.0})
for i in range(3)))
s = read_stamps(p)
assert len(s) == 3 and s[0] == datetime(2026, 7, 26, tzinfo=timezone.utc)
def test_legge_equity_csv_con_data_in_prima_colonna(tmp_path):
p = tmp_path / "equity.csv"
p.write_text("date,equity\n2026-07-24 00:00:00+00:00,2000.0\n2026-07-27 00:00:00+00:00,2010.0\n")
s = read_stamps(p)
assert len(s) == 2 and s[-1].day == 27
def test_una_riga_corrotta_non_fa_esplodere_la_guardia(tmp_path):
"""Una guardia che muore su una riga malformata smette di guardare proprio quando
qualcosa e' andato storto."""
p = tmp_path / "returns.jsonl"
p.write_text('{"ts": 1785024000000}\nnon-json\n{"ts": 1785110400000}\n')
assert len(read_stamps(p)) == 2
# ===========================================================================
# integrazione sullo stato REALE del progetto
# ===========================================================================
def test_i_monitor_reali_sono_leggibili_e_giudicati():
"""Non asserisce che siano sani (dipende da quando gira il test): asserisce che la guardia
li trova e si pronuncia su ognuno — il fallimento silenzioso sarebbe una lista vuota."""
rows = check_all(ROOT, now=NOW)
# legato al REGISTRO, non a una costante: aggiungere un monitor e' normale (30/07:
# collect_chain), dimenticarne uno per strada no.
assert len(rows) == len(MONITORS) >= 6
assert all(r["status"] in ("OK", "NUOVO", "FERMO", "BUCATO", "ASSENTE") for r in rows)
assert {"paper_xsr", "paper_statarb", "paper_dvolspread"} <= {r["name"] for r in rows}