ops(live): staleness-gate bloccante + report Telegram giornaliero
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) <noreply@anthropic.com>
This commit is contained in:
+11
-3
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
@@ -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__
|
||||
Reference in New Issue
Block a user