From 71b39c2c86f47cd2417142378fdcb3059e1671d6 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Sat, 25 Jul 2026 10:05:47 +0000 Subject: [PATCH] ops(live): staleness-gate bloccante + report Telegram giornaliero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Presa in carico operativa del conto Deribit (delega dell'utente). Nessun cambio a strategie, pesi o sizing: il libro resta TP01 0.75 + SKH01 0.25. 1) STALENESS-GATE (protezione di capitale, era un follow-up mai cablato) Il 2026-07-14 alle 14:00 UTC il book ha COMPRATO ETH $75 con l'ultima barra del feed certificato ferma al 2026-07-08: feed congelato da 6 giorni (diario 2026-07-15-feed-freeze). I due gate esistenti non potevano vederlo โ€” il conto ERA online e la posizione ERA leggibile: proteggono dai problemi di CONTO, non da un feed morto. Il diario raccomandava un alert; qui il gate e' BLOCCANTE, coerente con gli altri due ("non opero a cieco"), col disaster-SL on-book come rete su eventuali posizioni gia' aperte. - config/live.json: max_data_age_days = 2; - book_execute: _data_age_days() + blocco PRIMA di costruire DeribitTrader (nessuna sessione autenticata aperta su dati morti) + alert Telegram con il comando di sblocco; data illeggibile => trattata come stantia; - letto con cfg.get(default): una config priva della chiave ricade sulla soglia sicura invece di sollevare KeyError dentro il percorso con soldi veri; - tests/test_book_staleness_gate.py: 8 casi, incluso il funzionale che riproduce la situazione del 14/07 (online + posizione leggibile + ordine pronto) e verifica che DeribitTrader NON venga costruito. - tests/test_book_live.py: i 3 report finti avevano last_data="2026-07-01" hardcoded -> ora data fresca calcolata. Quei test riguardano skh_error / pos_error / eq_fallback, non la staleness: con la data fissa sarebbero marciti al superamento della soglia. 2) REPORT TELEGRAM GIORNALIERO (scripts/live/telegram_daily.py, in cron_daily.sh) Gli alert esistenti scattano solo su ordine o errore: con il libro flat โ€” stato normale e corretto col trend giu' โ€” significava silenzio per settimane, indistinguibile da un sistema morto. Il report dice ogni giorno dove sta il conto, perche' non opera e quanto manca perche' operi (con la convenzione TP01 corretta: media dei SEGNI, quindi servono 2 orizzonti su 3, non basta il piu' breve). Sola lettura: un test verifica che il modulo non possa inviare ordini. Stato al commit: equity $596.92, flat e a target, disaster-SL -30% verificato (placed @ $1,308.7 sull'ultima posizione). TP01 0.00 su entrambi: accensione a BTC $78.680 (+22,7%) / ETH $2.370 (+27,2%). Co-Authored-By: Claude Opus 5 (1M context) --- config/live.json | 4 +- scripts/cron_daily.sh | 2 + scripts/live/book_execute.py | 35 +++++++ scripts/live/telegram_daily.py | 150 ++++++++++++++++++++++++++++++ tests/test_book_live.py | 14 ++- tests/test_book_staleness_gate.py | 120 ++++++++++++++++++++++++ tests/test_telegram_daily.py | 54 +++++++++++ 7 files changed, 375 insertions(+), 4 deletions(-) create mode 100644 scripts/live/telegram_daily.py create mode 100644 tests/test_book_staleness_gate.py create mode 100644 tests/test_telegram_daily.py diff --git a/config/live.json b/config/live.json index 7f3841f..1cf4be4 100644 --- a/config/live.json +++ b/config/live.json @@ -5,5 +5,7 @@ "max_notional_per_asset_usd": 300, "max_notional_per_asset_frac": 0.5, "min_order_usd": 5, - "disaster_sl_pct": 0.30 + "disaster_sl_pct": 0.3, + "_nota_stale": "Staleness-gate (2026-07-25): se l'ultima barra del feed certificato e' piu' vecchia di max_data_age_days, book_execute NON invia ordini e allerta su Telegram. Il 2026-07-14 il book compro' ETH con il feed fermo da 6 giorni (conto online e posizione leggibile -> gli altri due gate non scattavano). Follow-up raccomandato nel diario 2026-07-15-feed-freeze, ora cablato.", + "max_data_age_days": 2 } diff --git a/scripts/cron_daily.sh b/scripts/cron_daily.sh index bc90558..8772253 100755 --- a/scripts/cron_daily.sh +++ b/scripts/cron_daily.sh @@ -22,5 +22,7 @@ mkdir -p logs for i in $(seq 1 25); do (echo > /dev/tcp/127.0.0.1/4002) >/dev/null 2>&1 && break; sleep 6; done uv run --with ib_async python scripts/research/fetch_ib_equities.py --only SPY,QQQ,IWM,TLT,GLD,HYG # ETF GTAA freschi uv run python scripts/live/paper_combo.py # avanza paper combo (forward-only) + # --- REPORT GIORNALIERO Telegram (sola lettura): rompe il silenzio quando il libro e' flat --- + uv run python scripts/live/telegram_daily.py # stato conto + perche' non opera + gate echo "===== done $(date -u '+%H:%M:%SZ') =====" } >> logs/cron_daily.log 2>&1 diff --git a/scripts/live/book_execute.py b/scripts/live/book_execute.py index 87ada7b..d099398 100644 --- a/scripts/live/book_execute.py +++ b/scripts/live/book_execute.py @@ -43,9 +43,23 @@ def load_config() -> dict: cfg.setdefault("max_notional_per_asset_usd", 300.0) cfg.setdefault("min_order_usd", 5.0) cfg.setdefault("disaster_sl_pct", 0.30) + cfg.setdefault("max_data_age_days", 2.0) return cfg +def _data_age_days(last_data) -> float | None: + """Eta' in giorni dell'ultima barra del feed certificato. None se non interpretabile + (trattata come stantia: meglio non operare che operare su una data che non so leggere).""" + if last_data in (None, ""): + return None + try: + ts = pd.Timestamp(last_data) + ts = ts.tz_localize("UTC") if ts.tz is None else ts.tz_convert("UTC") + return float((pd.Timestamp.now(tz="UTC") - ts).total_seconds() / 86400.0) + except Exception: + return None + + def log_event(rec: dict): LOG_DIR.mkdir(parents=True, exist_ok=True) with open(LOG, "a") as f: @@ -91,6 +105,27 @@ def _run(): "nota": "salto l'esecuzione, non opero a cieco"}) return + stale_days = _data_age_days(r.get("last_data")) + # .get col default: un chiamante che passa una config senza la chiave deve ricadere sulla + # soglia sicura, non sollevare KeyError dentro il percorso d'esecuzione con soldi veri. + max_age = float(cfg.get("max_data_age_days", 2.0)) + if stale_days is None or stale_days > max_age: + # FEED STANTIO -> non eseguo. Il 2026-07-14 il book ha comprato ETH con l'ultima barra + # ferma al 07-08 (feed congelato 6 giorni, diario 2026-07-15-feed-freeze): il conto era + # online e la posizione leggibile, quindi i due gate esistenti NON scattavano. Il segnale + # TP01 viene dal feed su disco: se e' vecchio, si opera alla cieca su dati morti. + # Il disaster-SL on-book resta la rete di sicurezza su eventuali posizioni aperte. + eta = "ignota" if stale_days is None else f"{stale_days:.0f}g" + print(f" ๐Ÿ›‘ FEED STANTIO (ultima barra {r.get('last_data')}, eta' {eta} > {max_age:.0f}g)" + " -> NON eseguo su dati morti.") + print(" Sbloccare con: uv run python scripts/analysis/rebuild_history.py --asset BTC ETH") + if do_execute: + notify("๐Ÿ›‘ BOOK LIVE โ€” FEED STANTIO, esecuzione saltata", + {"ultima_barra": str(r.get("last_data")), "eta": eta, + "soglia": f"{max_age:.0f}g", + "azione": "rebuild_history.py --asset BTC ETH"}) + return + if r.get("eq_fallback"): # equity reale non leggibile -> sizing su paper_cap print(f" โš ๏ธ EQUITY FALLBACK (sizing su paper_cap, NON blocco): {r['eq_fallback']}") if do_execute: # solo diagnostica: l'hard-cap $/asset limita il downside diff --git a/scripts/live/telegram_daily.py b/scripts/live/telegram_daily.py new file mode 100644 index 0000000..68bacbd --- /dev/null +++ b/scripts/live/telegram_daily.py @@ -0,0 +1,150 @@ +"""REPORT GIORNALIERO su Telegram โ€” stato del conto reale, cosa sta facendo il sistema e perche'. + +PERCHE' ESISTE (2026-07-25). Gli alert Telegram esistenti (`src/live/notifier`) scattano SOLO su +ordine eseguito o errore. Con il libro flat โ€” che e' lo stato normale e corretto quando il trend e' +giu' โ€” questo significa **silenzio per settimane**, indistinguibile da un sistema morto. Questo +report rompe il silenzio ogni giorno e dice tre cose: dove sta il conto, perche' il sistema non +opera, e quanto manca perche' operi. + +NON invia ordini, non tocca posizioni, non legge segreti oltre a quelli gia' usati dall'esecutore. +E' sola lettura: se fallisce, l'esecuzione oraria del libro non ne risente in alcun modo. + + uv run python scripts/live/telegram_daily.py # calcola e invia + uv run python scripts/live/telegram_daily.py --dry-run # stampa e basta, non invia +""" +from __future__ import annotations + +import json +import sys +from datetime import date, datetime, timezone +from pathlib import Path + +import numpy as np +import pandas as pd + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from src.live.notifier import send + +HORIZONS = (30, 90, 180) +GATES = [("STATARB-RESID", date(2026, 9, 27), "data/paper_statarb/returns.jsonl", "net_modeled"), + ("XSR01", date(2026, 10, 23), "data/paper_xsr/returns.jsonl", "net_modeled")] + + +def _fmt_usd(x: float) -> str: + return f"${x:,.2f}" + + +def trend_state() -> list[str]: + """Stato TSMOM per asset + quanto manca all'accensione (2 orizzonti su 3 positivi). + + NB convenzione TP01: la direzione e' la media dei SEGNI (-1/+1) sugli orizzonti, poi clippata + a >=0 (long-flat). Con 1 orizzonte su 3 positivo la media e' -0.33 -> target 0. Serve quindi + che DUE orizzonti siano positivi perche' il libro si accenda: non basta il piu' breve.""" + from src.data.downloader import load_data + out = [] + for a in ("BTC", "ETH"): + try: + d = load_data(a, "1h") + s = pd.Series(d["close"].astype(float).values, + index=pd.to_datetime(d["timestamp"], unit="ms", utc=True)) + c = s.resample("1D").last().dropna().values + if len(c) < max(HORIZONS) + 2: + continue + px = float(c[-1]) + refs = {h: float(c[-1 - h]) for h in HORIZONS} + sg = {h: (1 if px > refs[h] else -1) for h in HORIZONS} + m = float(np.mean(list(sg.values()))) + tgt = max(0.0, m) + line = f" {a} {px:,.0f} ยท segni " + "/".join(f"{sg[h]:+d}" for h in HORIZONS) + \ + f" โ†’ TP01 {tgt:.2f}" + neg = [(h, refs[h]) for h in HORIZONS if sg[h] < 0] + if neg and tgt == 0.0: + h, r = min(neg, key=lambda t: t[1]) + line += f"\n accensione a {r:,.0f} ({(r/px-1)*100:+.1f}%)" + out.append(line) + except Exception as e: + out.append(f" {a}: stato trend non calcolabile ({type(e).__name__})") + return out + + +def book_state() -> list[str]: + """Conto reale, posizioni e target netto correnti. Sola lettura.""" + try: + from src.live.book import book_report + r = book_report() + eq = r.get("equity") + lines = [f" equity {_fmt_usd(eq)}" if eq else " equity non leggibile"] + for a in r.get("assets", []): + pos, net = a.get("position_usd", 0.0), a.get("net_target", 0.0) + stato = "flat" if abs(pos) < 1 and abs(net) < 1 else f"pos {_fmt_usd(pos)} โ†’ target {_fmt_usd(net)}" + lines.append(f" {a['asset']}: {stato} (TP {a.get('tp_frac', 0):+.2f} ยท SKH {a.get('skh_sign', 0):+d})") + if r.get("skh_error"): + lines.append(f" โš ๏ธ SKH feed KO: {r['skh_error']}") + return lines + except Exception as e: + return [f" โš ๏ธ stato libro non leggibile: {type(e).__name__}: {e}"] + + +def monitors() -> list[str]: + out = [] + for name, dec, path, key in GATES: + p = ROOT / path + giorni = (dec - date.today()).days + if not p.exists(): + out.append(f" {name}: in attesa della prima barra ยท gate {dec} (โˆ’{giorni}g)") + continue + try: + rows = [json.loads(x) for x in p.read_text().splitlines() if x.strip()] + if not rows: + out.append(f" {name}: 0 barre ยท gate {dec} (โˆ’{giorni}g)") + continue + r = np.array([x[key] for x in rows], float) + sh = float(r.mean() / r.std() * np.sqrt(365)) if r.std() > 0 else 0.0 + tot = float(np.prod(1 + r) - 1) * 100 + out.append(f" {name}: {len(rows)}g ยท {tot:+.2f}% ยท Sh {sh:+.2f} ยท gate {dec} (โˆ’{giorni}g)") + except Exception: + out.append(f" {name}: log illeggibile ยท gate {dec} (โˆ’{giorni}g)") + return out + + +def last_trade() -> str: + p = ROOT / "data" / "live" / "book_executions.jsonl" + if not p.exists(): + return " nessun ordine registrato" + try: + rows = [json.loads(x) for x in p.read_text().splitlines() if x.strip()] + if not rows: + return " nessun ordine registrato" + t = rows[-1] + d = datetime.fromisoformat(t["ts_utc"]).replace(tzinfo=timezone.utc) + giorni = (datetime.now(timezone.utc) - d).days + return (f" ultimo: {t['ts_utc'][:10]} ({giorni}g fa) {t['asset']} {t['action']} " + f"@ {t.get('price')} ยท totale ordini: {len(rows)}") + except Exception: + return " ledger ordini illeggibile" + + +def build() -> str: + oggi = datetime.now(timezone.utc).strftime("%d/%m %H:%M UTC") + L = [f"๐Ÿ“Š Book Deribit โ€” {oggi}", "", "Conto"] + L += book_state() + L += ["", "Perche' non opera"] + L += trend_state() + L += ["", "Ordini", last_trade()] + L += ["", "Monitor in osservazione"] + L += monitors() + return "\n".join(L) + + +def main() -> None: + txt = build() + if "--dry-run" in sys.argv[1:]: + print(txt.replace("", "").replace("", "")) + return + print("inviato" if send(txt) else "NON inviato (config Telegram assente o rete KO)") + + +if __name__ == "__main__": + main() diff --git a/tests/test_book_live.py b/tests/test_book_live.py index c0aaf4c..9fb5c5c 100644 --- a/tests/test_book_live.py +++ b/tests/test_book_live.py @@ -8,6 +8,14 @@ import sys from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[1] + +# Data FRESCA per i report finti: dal 2026-07-25 book_execute ha uno staleness-gate bloccante +# (feed piu' vecchio di 2 giorni -> non esegue). Questi test riguardano skh_error / pos_error / +# eq_fallback, NON la staleness: con una data fissa marcirebbero appena supera la soglia. +# Lo staleness-gate ha i suoi test dedicati in tests/test_book_staleness_gate.py. +def _fresh_bar() -> str: + import pandas as _pd + return str(_pd.Timestamp.now(tz="UTC").normalize().date()) sys.path.insert(0, str(PROJECT_ROOT)) from src.live.book import W_SKH, W_TP01, book_net_target, build_book_order @@ -299,7 +307,7 @@ def test_book_execute_surfaces_skh_error(monkeypatch, capsys): mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod) canned = dict( - last_data="2026-07-01", online=True, real_equity=600.0, equity=600.0, eq_basis="test", + last_data=_fresh_bar(), online=True, real_equity=600.0, equity=600.0, eq_basis="test", cap_per_asset=300.0, skh_error="RuntimeError: feed 5m giu", assets=[dict(asset="BTC", instrument="BTC_USDC-PERPETUAL", tp_frac=0.0, skh_sign=0, skh_state="flat", net_target=0.0, position_usd=0.0, mark=60000.0, order=None)], @@ -361,7 +369,7 @@ def test_book_execute_halts_on_unreadable_position(monkeypatch, capsys): mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod) canned = dict( - last_data="2026-07-01", online=True, real_equity=598.0, equity=598.0, eq_basis="mainnet USDC", + last_data=_fresh_bar(), online=True, real_equity=598.0, equity=598.0, eq_basis="mainnet USDC", cap_per_asset=300.0, skh_error=None, pos_error="posizione non leggibile, assunta FLAT: BTC (RuntimeError: api 500)", assets=[dict(asset="BTC", instrument="BTC_USDC-PERPETUAL", tp_frac=1.0, skh_sign=1, @@ -422,7 +430,7 @@ def test_book_execute_eq_fallback_warns_but_proceeds(monkeypatch, capsys): mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod) canned = dict( - last_data="2026-07-01", online=True, real_equity=None, equity=2000.0, + last_data=_fresh_bar(), online=True, real_equity=None, equity=2000.0, eq_basis="paper capital (ipotetico)", cap_per_asset=300.0, skh_error=None, pos_error=None, eq_fallback="equity reale non leggibile (conto flat) -> sizing su paper_cap $2,000", assets=[dict(asset="BTC", instrument="BTC_USDC-PERPETUAL", tp_frac=0.0, skh_sign=0, diff --git a/tests/test_book_staleness_gate.py b/tests/test_book_staleness_gate.py new file mode 100644 index 0000000..f28baad --- /dev/null +++ b/tests/test_book_staleness_gate.py @@ -0,0 +1,120 @@ +"""Lock dello STALENESS-GATE dell'esecutore live (cablato 2026-07-25). + +PERCHE'. Il 2026-07-14 alle 14:00 UTC il book ha comprato ETH ($75) con l'ultima barra del feed +certificato ferma al 2026-07-08: feed congelato da 6 giorni (diario 2026-07-15-feed-freeze). I due +gate esistenti non potevano vederlo โ€” il conto ERA online e la posizione ERA leggibile: proteggono +dai problemi di CONTO, non da un feed morto. Il diario raccomandava un alert; qui il gate e' +BLOCCANTE, coerente con gli altri due (`non opero a cieco`). + +Questi test bloccano la soglia e il comportamento del calcolo d'eta'. Non toccano la rete. +""" +from __future__ import annotations + +import json +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")) + +book_execute = pytest.importorskip("book_execute") + + +def test_soglia_presente_nel_config_live(): + cfg = json.loads((ROOT / "config" / "live.json").read_text()) + assert cfg.get("max_data_age_days") == 2, "soglia di staleness assente o cambiata" + assert cfg.get("execution_enabled") is True + assert cfg.get("disaster_sl_pct") == 0.30 + + +def test_default_sicuro_se_il_config_non_la_indica(): + cfg = book_execute.load_config() + assert cfg["max_data_age_days"] > 0 + + +def test_eta_barra_fresca_e_sotto_soglia(): + oggi = pd.Timestamp.now(tz="UTC").normalize() + eta = book_execute._data_age_days(oggi) + assert eta is not None and eta < 2.0 + + +def test_eta_del_caso_reale_del_14_luglio_supera_la_soglia(): + """Il caso che ha motivato il gate: barra 07-08, esecuzione il 07-14 -> 6 giorni.""" + eta = (pd.Timestamp("2026-07-14 14:00", tz="UTC") - pd.Timestamp("2026-07-08", tz="UTC")) + assert eta.total_seconds() / 86400.0 > 2.0, "il gate non avrebbe fermato l'incidente reale" + + +def test_data_illeggibile_e_trattata_come_stantia(): + """None = non so leggere la data -> meglio non operare che operare su ignoto.""" + for cattivo in (None, "", "non-una-data", object()): + assert book_execute._data_age_days(cattivo) is None + + +def test_funzionale_feed_stantio_blocca_lesecuzione_e_allerta(monkeypatch, capsys): + """Il test che conta: conto ONLINE, posizione LEGGIBILE, ordine PRESENTE โ€” cioe' la situazione + esatta del 2026-07-14 โ€” ma feed vecchio. Non deve partire nessun ordine e deve arrivare l'alert. + """ + import importlib.util + spec = importlib.util.spec_from_file_location( + "book_execute_iso", ROOT / "scripts" / "live" / "book_execute.py") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + canned = dict( + last_data="2026-07-08", online=True, real_equity=598.0, equity=598.0, + eq_basis="mainnet USDC", cap_per_asset=300.0, skh_error=None, pos_error=None, + assets=[dict(asset="ETH", instrument="ETH_USDC-PERPETUAL", tp_frac=0.0, skh_sign=1, + skh_state="flat", net_target=75.0, position_usd=0.0, mark=1869.0, + order=dict(side="buy"))], + orders=[dict(side="buy")], + ) + alerts = [] + monkeypatch.setattr(mod, "book_report", lambda **k: canned) + monkeypatch.setattr(mod, "notify", lambda title, det=None: alerts.append((title, det))) + monkeypatch.setattr(mod, "load_config", lambda: dict( + execution_enabled=True, min_order_usd=5.0, disaster_sl_pct=0.30, max_data_age_days=2.0)) + monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"]) # ARMATO + execute + + def boom(*a, **k): + raise AssertionError("DeribitTrader costruito: il gate NON ha fermato il feed stantio") + monkeypatch.setattr(mod, "DeribitTrader", boom) + + mod._run() # non deve sollevare: il gate esce prima + + out = capsys.readouterr().out + assert "FEED STANTIO" in out + assert "rebuild_history" in out, "il messaggio deve dire come sbloccare" + assert any("FEED STANTIO" in t for t, _ in alerts), "alert Telegram non inviato" + + +def test_config_senza_la_chiave_non_esplode(monkeypatch, capsys): + """Una config priva di max_data_age_days deve ricadere sul default, non sollevare KeyError + dentro il percorso con soldi veri.""" + import importlib.util + spec = importlib.util.spec_from_file_location( + "book_execute_iso2", ROOT / "scripts" / "live" / "book_execute.py") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + canned = dict(last_data=str(pd.Timestamp.now(tz="UTC").normalize().date()), online=True, + real_equity=598.0, equity=598.0, eq_basis="t", cap_per_asset=300.0, + skh_error=None, pos_error=None, assets=[], orders=[]) + monkeypatch.setattr(mod, "book_report", lambda **k: canned) + monkeypatch.setattr(mod, "notify", lambda title, det=None: None) + monkeypatch.setattr(mod, "load_config", + lambda: dict(execution_enabled=False, min_order_usd=5.0, disaster_sl_pct=0.3)) + monkeypatch.setattr(sys, "argv", ["book_execute.py"]) + mod._run() # nessun KeyError + assert "FEED STANTIO" not in capsys.readouterr().out + + +def test_il_gate_blocca_prima_di_creare_il_trader(): + """Ordine dei controlli: il blocco per feed stantio deve stare PRIMA di DeribitTrader(), + altrimenti si aprirebbe comunque una sessione autenticata verso il conto reale.""" + src = (ROOT / "scripts" / "live" / "book_execute.py").read_text() + i_gate = src.index("FEED STANTIO") + i_trader = src.index("DeribitTrader() if do_execute") + assert i_gate < i_trader, "il gate di staleness deve precedere la creazione del trader" diff --git a/tests/test_telegram_daily.py b/tests/test_telegram_daily.py new file mode 100644 index 0000000..135a68d --- /dev/null +++ b/tests/test_telegram_daily.py @@ -0,0 +1,54 @@ +"""Lock del report giornaliero Telegram (in cron dal 2026-07-25). + +Gira in produzione dentro cron_daily.sh: se rompe, non deve rompere nulla d'altro e non deve +MAI inviare ordini. I test bloccano proprio questo: e' sola lettura, tollera i pezzi mancanti, +e non invia niente in dry-run. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "live")) + +telegram_daily = pytest.importorskip("telegram_daily") + + +def test_build_non_solleva_e_ha_le_sezioni(): + txt = telegram_daily.build() + assert isinstance(txt, str) and len(txt) > 50 + for sezione in ("Conto", "Perche' non opera", "Ordini", "Monitor in osservazione"): + assert sezione in txt, f"sezione mancante: {sezione}" + + +def test_nessuna_scrittura_ne_ordine(): + """Il modulo non deve importare l'esecutore ne' avere funzioni che inviano ordini.""" + src = (ROOT / "scripts" / "live" / "telegram_daily.py").read_text() + for vietato in ("DeribitTrader", "book_execute", "--execute", "place_order", "buy(", "sell("): + assert vietato not in src, f"il report non deve poter operare: trovato {vietato!r}" + + +def test_tollera_i_log_mancanti(monkeypatch, tmp_path): + """Se i log dei monitor non esistono, il report deve degradare, non esplodere.""" + monkeypatch.setattr(telegram_daily, "ROOT", tmp_path) + righe = telegram_daily.monitors() + assert len(righe) == len(telegram_daily.GATES) + assert all(isinstance(r, str) and r.strip() for r in righe) + assert telegram_daily.last_trade().strip() + + +def test_gate_coerenti_con_i_pre_registrati(): + """Le date qui devono coincidere con i gate pre-registrati negli script di decisione.""" + d = dict((n, x) for n, x, _, _ in telegram_daily.GATES) + assert str(d["STATARB-RESID"]) == "2026-09-27" + assert str(d["XSR01"]) == "2026-10-23" + + +def test_convenzione_trend_e_documentata(): + """La convenzione media-dei-segni (non frazione) e' la ragione per cui TP01 e' 0 con 1/3 su: + se sparisce dal codice, il report mentirebbe sul 'quanto manca'.""" + assert "media dei SEGNI" in telegram_daily.trend_state.__doc__