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>
This commit is contained in:
2026-09-21 08:48:07 +00:00
parent e4efdaaa53
commit 1a7f670966
11 changed files with 425 additions and 1 deletions
+4
View File
@@ -81,6 +81,10 @@ data/chain_collect/
data/vrp_f_watch/ data/vrp_f_watch/
# giri misurati del trigger di regime CC01 (letti da monitor_health, #12) # giri misurati del trigger di regime CC01 (letti da monitor_health, #12)
data/cc01_regime/ data/cc01_regime/
# giri misurati dei sorveglianti generalizzati da cc01 (#16), letti da monitor_health
data/edge_watch/
data/scale_watch/
data/venue_news/
# log esecuzioni del book live (stato runtime, contiene fill/fee del conto reale) # log esecuzioni del book live (stato runtime, contiene fill/fee del conto reale)
data/live/ data/live/
+4
View File
@@ -42,10 +42,13 @@ import pandas as pd
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
from src.live.giri import registra_giro # noqa: E402
from src.live.notifier import notify # noqa: E402 from src.live.notifier import notify # noqa: E402
from src.portfolio.portfolio import combine_outer # noqa: E402 from src.portfolio.portfolio import combine_outer # noqa: E402
from src.portfolio.sleeves import deribit_book_sleeves, tp01_sleeve # noqa: E402 from src.portfolio.sleeves import deribit_book_sleeves, tp01_sleeve # noqa: E402
RUNS = ROOT / "data" / "edge_watch" / "runs.jsonl"
# --- soglie CONGELATE (cambiarle invalida la taratura di r0726_edge_death.py) --- # --- soglie CONGELATE (cambiarle invalida la taratura di r0726_edge_death.py) ---
WIN_MONTHS = 36 WIN_MONTHS = 36
SHARPE_KILL = -0.5 SHARPE_KILL = -0.5
@@ -119,6 +122,7 @@ def run() -> dict:
def main() -> int: def main() -> int:
r = run() r = run()
registra_giro(RUNS, sharpe=r["sharpe"], n_alert=len(r["alerts"]))
if "--quiet" not in sys.argv: if "--quiet" not in sys.argv:
print("=" * 78) print("=" * 78)
print(" EDGE WATCH — il book live sta ancora funzionando?") print(" EDGE WATCH — il book live sta ancora funzionando?")
+3
View File
@@ -49,9 +49,11 @@ ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
from src.live.book import INSTRUMENT as BOOK_INSTRUMENT # noqa: E402 from src.live.book import INSTRUMENT as BOOK_INSTRUMENT # noqa: E402
from src.live.giri import registra_giro # noqa: E402
from src.live.notifier import notify # noqa: E402 from src.live.notifier import notify # noqa: E402
STATE = ROOT / "data" / "fee_watch" / "state.json" STATE = ROOT / "data" / "fee_watch" / "state.json"
RUNS = ROOT / "data" / "fee_watch" / "runs.jsonl"
# ⚠️ CORREZIONE 2026-08-21. Qui c'era la tupla CABLATA ("BTC-PERPETUAL", "ETH-PERPETUAL") — gli # ⚠️ CORREZIONE 2026-08-21. Qui c'era la tupla CABLATA ("BTC-PERPETUAL", "ETH-PERPETUAL") — gli
# INVERSE, regolati in BTC/ETH — mentre il book esegue sui LINEARI USDC (BTC_USDC-PERPETUAL). # INVERSE, regolati in BTC/ETH — mentre il book esegue sui LINEARI USDC (BTC_USDC-PERPETUAL).
# Sono due linee di prodotto distinte e Deribit le cambia in modo indipendente: il 18/08 ha # Sono due linee di prodotto distinte e Deribit le cambia in modo indipendente: il 18/08 ha
@@ -249,6 +251,7 @@ def main() -> int:
**{f"cambio {i+1}": c for i, c in enumerate(r["changes"])}, **{f"cambio {i+1}": c for i, c in enumerate(r["changes"])},
}) })
save_state(r["current"]) save_state(r["current"])
registra_giro(RUNS, level=r["level"])
return 2 if r["level"] == "AZIONE" else 0 return 2 if r["level"] == "AZIONE" else 0
+6 -1
View File
@@ -13,11 +13,15 @@ from __future__ import annotations
import sys import sys
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2])) 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 from src.live.notifier import notify # noqa: E402
from src.live.scale_watch import ALLARME, OK, run_once # noqa: E402 from src.live.scale_watch import ALLARME, OK, run_once # noqa: E402
RUNS = ROOT / "data" / "scale_watch" / "runs.jsonl"
def _sender(testo: str): def _sender(testo: str):
try: try:
@@ -31,6 +35,7 @@ def main() -> None:
secco = "--secco" in sys.argv secco = "--secco" in sys.argv
quiet = "--quiet" in sys.argv quiet = "--quiet" in sys.argv
rep = run_once(sender=None if secco else _sender) rep = run_once(sender=None if secco else _sender)
registra_giro(RUNS, stato=rep["stato"])
icona = {OK: "✅", ALLARME: "🚨"}.get(rep["stato"], "⚠️") icona = {OK: "✅", ALLARME: "🚨"}.get(rep["stato"], "⚠️")
if quiet: if quiet:
fb = rep["fallback"] fb = rep["fallback"]
+3
View File
@@ -40,10 +40,12 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
from src.live.giri import registra_giro # noqa: E402
from src.live.notifier import notify # noqa: E402 from src.live.notifier import notify # noqa: E402
FEED = "https://insights.deribit.com/exchange-updates/feed/" FEED = "https://insights.deribit.com/exchange-updates/feed/"
STATE = ROOT / "data" / "live" / "venue_news.jsonl" 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 # 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. # per costruzione); le altre si DERIVANO dal codice, sotto.
@@ -140,6 +142,7 @@ def main() -> int:
v["seed"] = primo_giro v["seed"] = primo_giro
f.write(json.dumps(v) + "\n") f.write(json.dumps(v) + "\n")
nuove.append(v) 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 # 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). # per un arretrato di 10 voci, o non verra' letto il giorno che e' vero).
+4
View File
@@ -16,10 +16,13 @@ ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
from src.live.deribit import check_specs # noqa: E402 from src.live.deribit import check_specs # noqa: E402
from src.live.giri import registra_giro # noqa: E402
from src.live.notifier import notify, ultimo_errore # noqa: E402 from src.live.notifier import notify, ultimo_errore # noqa: E402
from src.live.venue_watch import (PERSIST_HOURS, THRESHOLD_BPS, # noqa: E402 from src.live.venue_watch import (PERSIST_HOURS, THRESHOLD_BPS, # noqa: E402
run_once) run_once)
RUNS = ROOT / "data" / "venue_watch" / "runs.jsonl"
def _titolo(rep: dict) -> str: def _titolo(rep: dict) -> str:
"""⚠️ Il titolo dice cosa FARE, non solo cosa e' successo: chi lo legge sul telefono deve """⚠️ Il titolo dice cosa FARE, non solo cosa e' successo: chi lo legge sul telefono deve
@@ -70,6 +73,7 @@ def main() -> int:
# che manda ordini. Il 18/08 Deribit ha cambiato tick e size dei perpetual lineari USDC e # 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. # niente se n'era accorto: e' andata bene perche' i cambi erano riduzioni.
spec = check_specs() spec = check_specs()
registra_giro(RUNS, livelli=rep["levels"])
if spec["divergenze"]: if spec["divergenze"]:
rifiuto = [d for d in spec["divergenze"] if d["rischio"] == "rifiuto"] rifiuto = [d for d in spec["divergenze"] if d["rischio"] == "rifiuto"]
righe = {f"{d['strumento']} {d['campo']}": f"dichiarato {d['dichiarato']} / " righe = {f"{d['strumento']} {d['campo']}": f"dichiarato {d['dichiarato']} / "
+4
View File
@@ -51,9 +51,11 @@ sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research"))
from cblib import ( # noqa: E402 from cblib import ( # noqa: E402
dvol_series, f_factors, load_chain, pick_legs, puts, spot_series, dvol_series, f_factors, load_chain, pick_legs, puts, spot_series,
) )
from src.live.giri import registra_giro # noqa: E402
from src.live.notifier import notify # noqa: E402 from src.live.notifier import notify # noqa: E402
STATE = PROJECT_ROOT / "data" / "vrp_f_watch" / "state.json" STATE = PROJECT_ROOT / "data" / "vrp_f_watch" / "state.json"
RUNS = PROJECT_ROOT / "data" / "vrp_f_watch" / "runs.jsonl"
# --- config CONGELATA il 2026-07-30 --------------------------------------- # --- config CONGELATA il 2026-07-30 ---------------------------------------
STRUCTS = { STRUCTS = {
@@ -190,6 +192,8 @@ def main() -> int:
a["notified"] = bool(prev.get("notified", False)) a["notified"] = bool(prev.get("notified", False))
_save_state(a) _save_state(a)
if not R.empty:
registra_giro(RUNS, pairs=a["pairs"], ready=a["ready"])
return 0 return 0
+35
View File
@@ -0,0 +1,35 @@
"""registra_giro — il giro che un sorvegliante ha misurato, come riga jsonl (2026-09-21, #16).
PERCHE' ESISTE. `cc01_regime_watch` e' morto 14 notti di fila (#10) senza che nessuno lo vedesse,
perche' non scriveva niente da sorvegliare: `monitor_health` guarda solo le serie che i monitor
scrivono, e un monitor silenzioso non e' distinguibile da uno che dice "tutto ok". La riparazione
di cc01 (`data/cc01_regime/runs.jsonl`, commit 31f5915) e' il modello: qui si generalizza a ogni
sorvegliante, cosi' un monitor fermo diventa FERMO in `monitor_health`, non invisibile.
Un registro che fallisce non deve cambiare l'esito del sorvegliante che lo scrive: `registra_giro`
non solleva mai, e su errore lo dice su stderr e ritorna False.
"""
from __future__ import annotations
import json
import sys
import time
from pathlib import Path
def registra_giro(path: Path, **campi) -> bool:
"""Appende `{"ts": <ms epoch>, **campi}` a `path` (jsonl, una riga). Crea la cartella se manca.
Ritorna True se la riga e' stata scritta, False su OSError (disco pieno, permessi, percorso
invalido...) dopo aver scritto il motivo su stderr. Non solleva mai: la misura del
sorvegliante e' gia' fatta, e un registro che fallisce non deve buttarla via.
"""
riga = {"ts": int(time.time() * 1000), **campi}
try:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(riga, default=str) + "\n")
return True
except OSError as e:
print(f"registra_giro: non scrivo {path}: {type(e).__name__}: {e}", file=sys.stderr)
return False
+23
View File
@@ -105,6 +105,29 @@ MONITORS: tuple[MonitorSpec, ...] = (
MonitorSpec("cc01_regime_watch", "cc01_regime", "runs.jsonl", 24.0, max_age_h=30.0, 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)", gate="trigger di regime CC01: WARN>=10% / ALERT>=15% funding 30g (riapre il dossier)",
open_labeled=False), 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),
) )
+69
View File
@@ -0,0 +1,69 @@
"""registra_giro (src/live/giri.py, #16): il registro che i sorveglianti scrivono a ogni giro.
Il contratto e' quello di cc01_regime_watch, generalizzato: appende `{"ts": <ms>, **campi}` come
riga jsonl e non solleva MAI — un registro che fallisce non deve buttare via una misura gia' fatta.
"""
from __future__ import annotations
import json
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from src.live.giri import registra_giro # noqa: E402
def test_scrive_una_riga_con_ts_in_millisecondi(tmp_path):
p = tmp_path / "sub" / "runs.jsonl"
prima = time.time() * 1000
assert registra_giro(p, stato="OK", n=3) is True
riga = json.loads(p.read_text().strip())
assert abs(riga["ts"] - prima) < 5_000
assert isinstance(riga["ts"], int)
assert riga["stato"] == "OK" and riga["n"] == 3
def test_crea_la_cartella_se_manca(tmp_path):
p = tmp_path / "non-esiste-ancora" / "runs.jsonl"
assert not p.parent.exists()
registra_giro(p, x=1)
assert p.exists()
def test_appende_senza_troncare(tmp_path):
p = tmp_path / "runs.jsonl"
registra_giro(p, giro=1)
registra_giro(p, giro=2)
righe = [json.loads(r) for r in p.read_text().splitlines()]
assert [r["giro"] for r in righe] == [1, 2]
def test_default_str_copre_i_valori_non_serializzabili(tmp_path):
"""json.dumps(default=str): un valore che non e' nativamente JSON (es. un Path, o un
dataclass senza __dict__ diretto) non deve far fallire l'append — diventa la sua str()."""
p = tmp_path / "runs.jsonl"
assert registra_giro(p, percorso=Path("/tmp/x")) is True
riga = json.loads(p.read_text().strip())
assert riga["percorso"] == str(Path("/tmp/x"))
def test_oserror_ritorna_false_senza_sollevare(tmp_path, capsys):
"""Un percorso impossibile (qui: dentro un FILE, non una cartella) e' un OSError alla
mkdir/scrittura. Il chiamante non deve vedere un'eccezione: la misura e' gia' fatta."""
ostacolo = tmp_path / "non-e-una-cartella"
ostacolo.write_text("sono un file")
p = ostacolo / "runs.jsonl"
assert registra_giro(p, stato="OK") is False
err = capsys.readouterr().err
assert "registra_giro" in err and str(p) in err
def test_un_valore_non_serializzabile_diventa_stringa_non_solleva(tmp_path):
p = tmp_path / "runs.jsonl"
oggetto = object()
assert registra_giro(p, oggetto=oggetto) is True
riga = json.loads(p.read_text().strip())
assert riga["oggetto"] == str(oggetto)
+270
View File
@@ -0,0 +1,270 @@
"""registra_giro nei sei sorveglianti generalizzati da cc01_regime_watch (2026-09-21, #16).
PERCHE' ESISTE. `cc01_regime_watch` e' morto 14 notti di fila (#10) senza che nessuno lo vedesse,
perche' non scriveva niente da sorvegliare (`monitor_health` guarda solo le serie che i monitor
scrivono). La riparazione di cc01 (`data/cc01_regime/runs.jsonl`, commit 31f5915,
tests/test_cc01_regime_watch.py) e' il modello: qui si verifica che sia stata applicata agli
altri sei sorveglianti giornalieri/orari, con lo stesso incrocio P1 con `monitor_health.MONITORS`
e lo stesso doppio ramo (misura -> una riga, non-misura -> nessuna riga).
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pandas as pd
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "live"))
from src.live.monitor_health import MONITORS # noqa: E402
import edge_watch as EW # noqa: E402
import fee_watch as FW # noqa: E402
import scale_watch as SW # noqa: E402
def _carica(nome: str):
sp = importlib.util.spec_from_file_location(nome, ROOT / "scripts" / "live" / f"{nome}.py")
m = importlib.util.module_from_spec(sp)
sp.loader.exec_module(m)
return m
VF = _carica("vrp_f_watch")
VN = _carica("venue_news")
VW = _carica("venue_watch")
def _righe(path: Path) -> list[dict]:
import json
if not path.exists():
return []
return [json.loads(r) for r in path.read_text().splitlines() if r.strip()]
def _spec(nome: str):
s = next((m for m in MONITORS if m.name == nome), None)
assert s is not None, f"{nome} non e' in monitor_health.MONITORS"
return s
# ===========================================================================
# P1 — il bersaglio della sorveglianza si deriva dal codice sorvegliato
# ===========================================================================
@pytest.mark.parametrize("nome,modulo,cadence_h,max_age_h", [
("edge_watch", EW, 24.0, 30.0),
("fee_watch", FW, 24.0, 30.0),
("scale_watch", SW, 24.0, 30.0),
("vrp_f_watch", VF, 24.0, 30.0),
("venue_news", VN, 24.0, 30.0),
("venue_watch", VW, 1.0, 3.0),
])
def test_registrato_in_monitor_health_sul_file_che_scrive(nome, modulo, cadence_h, max_age_h):
s = _spec(nome)
assert ROOT / "data" / s.directory / s.series == modulo.RUNS
assert s.open_labeled is False
assert s.cadence_h == cadence_h
assert s.max_age_h <= max_age_h
def test_venue_watch_ha_la_cadenza_oraria_del_cron_book():
"""⚠️ venue_watch gira OGNI ORA in cron_book.sh: max_age stretto, non i 30h dei giornalieri."""
assert _spec("venue_watch").cadence_h == 1.0 and _spec("venue_watch").max_age_h == 3.0
# ===========================================================================
# edge_watch — dopo run(): sharpe, n_alert
# ===========================================================================
def test_edge_watch_un_giro_misurato_scrive_una_riga(tmp_path, monkeypatch):
runs = tmp_path / "runs.jsonl"
monkeypatch.setattr(EW, "RUNS", runs)
monkeypatch.setattr(sys, "argv", ["edge_watch.py", "--quiet"])
monkeypatch.setattr(EW, "run", lambda: dict(sharpe=0.7, protection=[], consec_fail=0, alerts=[]))
assert EW.main() == 0
righe = _righe(runs)
assert len(righe) == 1
assert righe[0]["sharpe"] == 0.7 and righe[0]["n_alert"] == 0
assert isinstance(righe[0]["ts"], int)
def test_edge_watch_run_che_fallisce_non_scrive(tmp_path, monkeypatch):
runs = tmp_path / "runs.jsonl"
monkeypatch.setattr(EW, "RUNS", runs)
monkeypatch.setattr(sys, "argv", ["edge_watch.py", "--quiet"])
def boom():
raise RuntimeError("dati di mercato non leggibili")
monkeypatch.setattr(EW, "run", boom)
with pytest.raises(RuntimeError):
EW.main()
assert not runs.exists()
# ===========================================================================
# fee_watch — dopo save_state(): level
# ===========================================================================
def test_fee_watch_un_giro_misurato_scrive_una_riga(tmp_path, monkeypatch):
runs = tmp_path / "runs.jsonl"
monkeypatch.setattr(FW, "RUNS", runs)
monkeypatch.setattr(sys, "argv", ["fee_watch.py", "--quiet"])
monkeypatch.setattr(FW, "run", lambda: dict(current={}, changes=[], worst_taker_bps=0.0,
level="OK", reason="tutto in regola", first_read=True))
monkeypatch.setattr(FW, "save_state", lambda cur: None)
assert FW.main() == 0
righe = _righe(runs)
assert len(righe) == 1 and righe[0]["level"] == "OK"
def test_fee_watch_run_che_fallisce_non_scrive(tmp_path, monkeypatch):
runs = tmp_path / "runs.jsonl"
monkeypatch.setattr(FW, "RUNS", runs)
monkeypatch.setattr(sys, "argv", ["fee_watch.py", "--quiet"])
def boom():
raise RuntimeError("API Deribit irraggiungibile")
monkeypatch.setattr(FW, "run", boom)
with pytest.raises(RuntimeError):
FW.main()
assert not runs.exists()
# ===========================================================================
# scale_watch — dopo run_once(): stato
# ===========================================================================
_REP_SCALA = dict(stato="OK", scala_dichiarata=1.0, ladder="0.5/1/2", tetto=2.0,
domande={}, fallback={"stato": "OK", "quota": 0.0, "fallback_veri": 0,
"giri": 10, "soglia": 0.1, "paper": 0},
lettore={"stato": "OK", "motivo": "ok"}, invio="non tentato")
def test_scale_watch_un_giro_misurato_scrive_una_riga(tmp_path, monkeypatch):
runs = tmp_path / "runs.jsonl"
monkeypatch.setattr(SW, "RUNS", runs)
monkeypatch.setattr(sys, "argv", ["scale_watch.py", "--quiet", "--secco"])
monkeypatch.setattr(SW, "run_once", lambda sender=None: dict(_REP_SCALA))
SW.main()
righe = _righe(runs)
assert len(righe) == 1 and righe[0]["stato"] == "OK"
def test_scale_watch_run_once_che_fallisce_non_scrive(tmp_path, monkeypatch):
runs = tmp_path / "runs.jsonl"
monkeypatch.setattr(SW, "RUNS", runs)
monkeypatch.setattr(sys, "argv", ["scale_watch.py", "--quiet", "--secco"])
def boom(sender=None):
raise RuntimeError("lettore di produzione irraggiungibile")
monkeypatch.setattr(SW, "run_once", boom)
with pytest.raises(RuntimeError):
SW.main()
assert not runs.exists()
# ===========================================================================
# vrp_f_watch — dopo _save_state(), SOLO se R non e' vuoto: pairs, ready
# ===========================================================================
def _frame_coppie(n: int = 45) -> pd.DataFrame:
rows = []
for i in range(n):
exp = pd.Timestamp("2026-01-01") + pd.Timedelta(days=i)
for name, v in (("canonico", 0.70), ("candidato", 0.80)):
rows.append(dict(asset="BTC", struct=name, exp=exp, ts=pd.Timestamp("2026-01-01"),
dte=7.0, f_short=1.0, f_long=2.0, f_net=v, f_net_mid=v,
quota_lunga=0.18))
return pd.DataFrame(rows)
def test_vrp_f_watch_un_giro_misurato_scrive_una_riga(tmp_path, monkeypatch):
runs = tmp_path / "runs.jsonl"
monkeypatch.setattr(VF, "RUNS", runs)
monkeypatch.setattr(sys, "argv", ["vrp_f_watch.py", "--quiet"])
frame = _frame_coppie()
monkeypatch.setattr(VF, "measure", lambda chain=None: frame)
monkeypatch.setattr(VF, "_save_state", lambda d: None)
monkeypatch.setattr(VF, "_load_state", lambda: {})
monkeypatch.setattr(VF, "notify", lambda *a, **k: True)
assert VF.main() == 0
righe = _righe(runs)
atteso = VF.assess(frame)
assert len(righe) == 1
assert righe[0]["pairs"] == atteso["pairs"] and righe[0]["ready"] == atteso["ready"]
def test_vrp_f_watch_senza_osservazioni_non_scrive(tmp_path, monkeypatch):
"""P5: R vuoto non e' un giro misurato — nessuna riga (a differenza di cc01, qui non
c'e' nemmeno un `return` anticipato: la guardia sta nella condizione prima di registrare)."""
runs = tmp_path / "runs.jsonl"
monkeypatch.setattr(VF, "RUNS", runs)
monkeypatch.setattr(sys, "argv", ["vrp_f_watch.py", "--quiet"])
monkeypatch.setattr(VF, "measure", lambda chain=None: pd.DataFrame())
monkeypatch.setattr(VF, "_save_state", lambda d: None)
monkeypatch.setattr(VF, "_load_state", lambda: {})
assert VF.main() == 0
assert not runs.exists()
# ===========================================================================
# venue_news — dopo la scrittura delle voci: voci, nuove. Feed illeggibile -> nessuna riga
# ===========================================================================
_FEED = """<rss><channel>
<item><title>Deribit Welcomes Orbit Markets</title>
<link>https://x/a</link><guid>https://x/?p=1</guid><pubDate>Fri, 10 Jul 2026 00:00:00 +0000</pubDate></item>
<item><title>Another Unrelated Announcement</title>
<link>https://x/b</link><guid>https://x/?p=2</guid><pubDate>Fri, 11 Jul 2026 00:00:00 +0000</pubDate></item>
</channel></rss>"""
def test_venue_news_un_giro_misurato_scrive_una_riga(tmp_path, monkeypatch):
runs = tmp_path / "runs.jsonl"
state = tmp_path / "venue_news.jsonl"
monkeypatch.setattr(VN, "RUNS", runs)
monkeypatch.setattr(VN, "STATE", state)
monkeypatch.setattr(VN, "scarica", lambda *a, **k: _FEED)
monkeypatch.setattr(VN, "notify", lambda *a, **k: True)
monkeypatch.setattr(sys, "argv", ["venue_news.py", "--quiet"])
assert VN.main() == 0
righe = _righe(runs)
assert len(righe) == 1 and righe[0]["voci"] == 2 and righe[0]["nuove"] == 2
def test_venue_news_feed_illeggibile_non_scrive(tmp_path, monkeypatch):
runs = tmp_path / "runs.jsonl"
monkeypatch.setattr(VN, "RUNS", runs)
monkeypatch.setattr(VN, "STATE", tmp_path / "venue_news.jsonl")
monkeypatch.setattr(VN, "scarica", lambda *a, **k: None)
monkeypatch.setattr(VN, "notify", lambda *a, **k: True)
monkeypatch.setattr(sys, "argv", ["venue_news.py", "--quiet"])
assert VN.main() == 2
assert not runs.exists()
# ===========================================================================
# venue_watch — dopo run_once() e check_specs(): livelli (rep["levels"])
# ===========================================================================
def test_venue_watch_un_giro_misurato_scrive_una_riga(tmp_path, monkeypatch):
runs = tmp_path / "runs.jsonl"
monkeypatch.setattr(VW, "RUNS", runs)
monkeypatch.setattr(sys, "argv", ["venue_watch.py", "--quiet"])
monkeypatch.setattr(VW, "run_once", lambda sender=None: dict(
levels={"BTC": "OK", "ETH": "OK"}, alerts=[], platform_locked=False))
monkeypatch.setattr(VW, "check_specs", lambda: dict(divergenze=[]))
assert VW.main() == 0
righe = _righe(runs)
assert len(righe) == 1 and righe[0]["livelli"] == {"BTC": "OK", "ETH": "OK"}
def test_venue_watch_run_once_che_fallisce_non_scrive(tmp_path, monkeypatch):
runs = tmp_path / "runs.jsonl"
monkeypatch.setattr(VW, "RUNS", runs)
monkeypatch.setattr(sys, "argv", ["venue_watch.py", "--quiet"])
def boom(sender=None):
raise RuntimeError("osservazione del consenso fallita")
monkeypatch.setattr(VW, "run_once", boom)
with pytest.raises(RuntimeError):
VW.main()
assert not runs.exists()