fix(book): gate fail-safe posizione + diagnostica equity — niente errori silenziosi nel path live

Terza+quarta chiusura del pattern "eccezione ingoiata -> stato safe silenzioso"
in src/live, dopo skh_error (31369b3). Emerse durante la verifica del gate SKH01.

Opzione A (gate fail-safe posizione):
- shadow._positions: se la read della posizione reale Deribit lancia, assume flat
  MA ora ritorna un pos_error esplicito (prima solo una note mai propagata).
- Propagato shadow_report -> book_report -> book_execute: se ONLINE ma posizione
  IGNOTA, l'esecutore NON opera a cieco (return + alert), come gia' per 'online'.

Opzione B (diagnostica equity, no halt):
- shadow._equity/shadow_report: se ONLINE ma equity reale non leggibile, il book
  ripiega su paper_cap (~$2000) invece del conto reale (~$598) -> sovradimensiona
  ~33%, ma l'hard-cap $300/asset limita il downside. Nuovo flag eq_fallback ->
  book_execute stampa warning + alert Telegram MA prosegue (niente gate: la scelta
  e' solo diagnostica, l'hard-cap gia' protegge).

Test: +8 (4 gate A, 4 diagnostica B). Suite 156/156. Path live pulito
(skh_error/pos_error/eq_fallback = None). Diario 2026-07-01-book-live-error-surfacing.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-07-01 22:39:59 +00:00
parent 31369b358c
commit 567046953d
5 changed files with 220 additions and 6 deletions
+131
View File
@@ -267,3 +267,134 @@ def test_book_execute_surfaces_skh_error(monkeypatch, capsys):
out = capsys.readouterr().out
assert "SKH FEED ERRORE" in out # stampato nel log
assert any("SKH feed fallito" in title for title, _ in alerts) # alert inviato
# ---------------------------------------------------------------------------
# GATE FAIL-SAFE posizione: se la read della posizione reale fallisce (ONLINE ma
# posizione IGNOTA -> assunta flat), l'esecutore NON deve operare a cieco.
# ---------------------------------------------------------------------------
def test_positions_flags_read_failure():
from src.live.shadow import ASSETS, _positions
class BadClient:
def position_usd(self, inst):
raise RuntimeError("api 500")
pos, note, err = _positions(BadClient())
assert all(pos[a] == 0.0 for a in ASSETS) # assunta flat (fail-safe)
assert err is not None and "non leggibile" in err # ma SEGNALATA (non silenziosa)
def test_positions_ok_and_offline_have_no_error():
from src.live.shadow import ASSETS, _positions
class GoodClient:
def position_usd(self, inst):
return 123.0
_, _, err_ok = _positions(GoodClient())
_, _, err_off = _positions(None) # offline -> gestito dal gate 'online'
assert err_ok is None and err_off is None
def test_book_report_propagates_pos_error(monkeypatch):
import src.live.book as book
base = book.shadow_report(offline=True, equity_override=600.0)
monkeypatch.setattr(book, "shadow_report", lambda **k: {**base, "pos_error": "IGNOTA"})
r = book.book_report(offline=True, equity_override=600.0)
assert r.get("pos_error") == "IGNOTA" # propagato fino al book
def test_book_execute_halts_on_unreadable_position(monkeypatch, capsys):
"""ARMATO + --execute + ordine presente: il gate DEVE fermarsi PRIMA di costruire il trader."""
import importlib.util
spec = importlib.util.spec_from_file_location("book_execute", PROJECT_ROOT / "scripts/live/book_execute.py")
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",
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,
skh_state="flat", net_target=225.0, position_usd=0.0, mark=60000.0,
order=dict(side="buy"))], # ordine presente: senza gate proverebbe a eseguire
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))
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"]) # ARMATO + execute
def boom(*a, **k):
raise AssertionError("DeribitTrader NON deve essere costruito: il gate deve fermarsi prima")
monkeypatch.setattr(mod, "DeribitTrader", boom)
mod._run()
out = capsys.readouterr().out
assert "POSIZIONE NON LEGGIBILE" in out # stampato
assert any("posizione non leggibile" in t for t, _ in alerts) # alert inviato
# ---------------------------------------------------------------------------
# DIAGNOSTICA equity (Opzione B): ONLINE ma equity reale non leggibile -> sizing su
# paper_cap. NON blocca (l'hard-cap $/asset protegge), ma DEVE essere segnalato.
# ---------------------------------------------------------------------------
def test_shadow_flags_eq_fallback_when_online_but_equity_unreadable(monkeypatch):
import src.live.shadow as sh
monkeypatch.setattr(sh, "_safe_client", lambda: object()) # online
monkeypatch.setattr(sh, "_marks", lambda c, d: ({a: 60000.0 for a in sh.ASSETS},
{a: "mainnet" for a in sh.ASSETS}))
monkeypatch.setattr(sh, "_positions", lambda c: ({a: 0.0 for a in sh.ASSETS}, "mainnet", None))
monkeypatch.setattr(sh, "_equity", lambda c, m: (None, "conto flat / non finanziato")) # equity IGNOTA
r = sh.shadow_report() # online, no override
assert r["online"] is True and r["real_equity"] is None
assert r.get("eq_fallback") and "paper_cap" in r["eq_fallback"] # fallback SEGNALATO
def test_shadow_no_eq_fallback_offline_or_override():
from src.live.shadow import shadow_report
assert shadow_report(offline=True, equity_override=600.0).get("eq_fallback") is None # override
assert shadow_report(offline=True).get("eq_fallback") is None # offline != fallback-online
def test_book_report_propagates_eq_fallback(monkeypatch):
import src.live.book as book
base = book.shadow_report(offline=True, equity_override=600.0)
monkeypatch.setattr(book, "shadow_report", lambda **k: {**base, "eq_fallback": "PAPER_CAP"})
assert book.book_report(offline=True, equity_override=600.0).get("eq_fallback") == "PAPER_CAP"
def test_book_execute_eq_fallback_warns_but_proceeds(monkeypatch, capsys):
"""eq_fallback: avvisa + notify MA PROSEGUE (non e' un halt, a differenza di pos_error)."""
import importlib.util
spec = importlib.util.spec_from_file_location("book_execute", PROJECT_ROOT / "scripts/live/book_execute.py")
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,
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,
skh_state="flat", net_target=0.0, position_usd=0.0, mark=60000.0, order=None)],
orders=[],
)
alerts = []
class FakeDT:
def ensure_disaster_sl(self, inst, sl_pct): return {"state": "flat"}
def position_usd(self, inst): return 0.0
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
monkeypatch.setattr(mod, "notify", lambda title, det=None: alerts.append((title, det)))
monkeypatch.setattr(mod, "DeribitTrader", lambda: FakeDT())
monkeypatch.setattr(mod, "load_config",
lambda: dict(execution_enabled=True, min_order_usd=5.0, disaster_sl_pct=0.30))
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
mod._run()
out = capsys.readouterr().out
assert "EQUITY FALLBACK" in out # avvisato
assert any("equity fallback" in t for t, _ in alerts) # alert inviato
assert "Nessuna azione" in out # HA PROSEGUITO (non halt)