From 567046953dfe135de28df29d14b49ded7e32e511 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Wed, 1 Jul 2026 22:39:59 +0000 Subject: [PATCH] =?UTF-8?q?fix(book):=20gate=20fail-safe=20posizione=20+?= =?UTF-8?q?=20diagnostica=20equity=20=E2=80=94=20niente=20errori=20silenzi?= =?UTF-8?q?osi=20nel=20path=20live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../2026-07-01-book-live-error-surfacing.md | 58 ++++++++ scripts/live/book_execute.py | 12 ++ src/live/book.py | 3 +- src/live/shadow.py | 22 ++- tests/test_book_live.py | 131 ++++++++++++++++++ 5 files changed, 220 insertions(+), 6 deletions(-) create mode 100644 docs/diary/2026-07-01-book-live-error-surfacing.md diff --git a/docs/diary/2026-07-01-book-live-error-surfacing.md b/docs/diary/2026-07-01-book-live-error-surfacing.md new file mode 100644 index 0000000..738f151 --- /dev/null +++ b/docs/diary/2026-07-01-book-live-error-surfacing.md @@ -0,0 +1,58 @@ +# 2026-07-01 — Book live: verifica gate SKH01 + emersione errori silenziosi + +## Contesto +Il BOOK DERIBIT armato (TP01 0.75 + SKH01 0.25 nettati) è **flat da quando è stato armato +(2026-06-23)**: 193 run orari, tutte HOLD, zero ordini reali. Domanda: il flat è legittimo o il +gate di SKH01 è muto per un bug? + +## Verifica del gate SKH01 — SANO +- **Logica viva:** `skyhook_entries` produce 335 entry (BTC, 222 long/113 short) e 341 (ETH, + 226/115) su tutta la storia. Non è degenere. +- **Shorta davvero i crash:** ha aperto SHORT sulla gamba di crollo di fine maggio/inizio giugno — + BTC 05-28→06-04 ($73k→$64k), ETH 06-02→06-06 ($1865→$1555). +- **Flat da 06-23 = legittimo:** ultimo entry 06-06, `max_bars=16 × 230m ≈ 2,5g` → chiuso ~06-08/09, + **prima** dell'arming (06-23). Dal grind post-crash nessun setup breakout+regime valido → flat + corretto per design ("NON trend-follower"). Contesto prezzo al 01-07: BTC −53% dal picco, ETH + −67,5%, momentum negativo su 1/3/6/12 mesi → anche TP01 (long-flat TSMOM) è a 0x. +- **Entrambi i feed valutano senza crash:** certificato e live `fresh_5m` → `skh_error=None`. + +## Bug scoperto e corretto — pattern "eccezione ingoiata → stato safe silenzioso" +`src/live/` aveva più `try/except` che, in errore, producono uno stato safe (flat/fallback) **senza +farlo emergere**. Rischio: quando l'errore è transitorio ma reale, l'esecutore opera su dati sbagliati +credendoli legittimi. + +### (1) `skh_error` — CORRETTO +`book_report` scriveva `skh_error` in un dict locale **mai incluso nel return** → `r.get("skh_error")` +sempre `None`; e `book_execute` non lo leggeva comunque. Un feed SKH rotto avrebbe forzato SKH→flat in +silenzio (entry mancato, nessun alert). Fix: `skh_error` esposto nel return + `book_execute` emette +log `⚠️ SKH FEED ERRORE` + alert Telegram. (commit 31369b3) + +### (2) Gate fail-safe posizione (Opzione A) — CORRETTO +`src/live/shadow.py::_positions`: se la read della posizione reale Deribit lancia, assume flat (0.0) +con una `note`, MAI propagata (`pos_src`) a `book_report`/`book_execute`. Severità mitigata a valle +da `rebalance_signed` che **rilegge la posizione fresca** (no doppia-posizione), ma resta il caso +"target≈0 + posizione aperta + read fallita → close saltato quel run" (transitorio, backstoppato dal +disaster-SL −30%). **Scelta A = gate fail-safe:** `_positions` ritorna un `pos_error` esplicito → +propagato `shadow_report → book_report → book_execute`; se presente (ONLINE ma posizione IGNOTA) +l'esecutore **NON opera a cieco** (return + alert), come già fa per `online`. + +### (3) Diagnostica equity (Opzione B) — CORRETTO +`src/live/shadow.py::_equity`: se `account_summary('USDC')` e tutte le per-asset falliscono, +`real_eq=None` e `shadow_report` ripiega su `paper_cap` (~$2000 dallo state paper) mentre il conto +reale è ~$598 → il book dimensionerebbe su $2000 invece di $598 (**sovradimensiona ~33%**). Il danno +è limitato dall'**hard-cap $300/asset** in `book_net_target`. **Scelta B = SOLO DIAGNOSTICA** (niente +gate/halt, l'hard-cap già protegge il downside): `shadow_report` calcola `eq_fallback` (ONLINE + +equity reale non leggibile + no override), propagato `→ book_report → book_execute`, che stampa +`⚠️ EQUITY FALLBACK` + alert Telegram **ma prosegue** (a differenza del gate posizione, che blocca). + +## Test +Suite completa **156/156** (era 148; +4 gate A + 4 diagnostica B). Dry-run reale di `book_execute` +invariato (nessuna riga d'errore, nessun ordine, conto $598.06 al target). Path live: `pos_error=None`, +`skh_error=None`, `eq_fallback=None`. + +## Sintesi finale — i 3 pattern "errore silenzioso" del path live sono ora tutti tracciati +| # | Punto | Comportamento | Emersione | +|---|-------|---------------|-----------| +| 1 | `book_report` feed SKH | SKH → flat | log `⚠️ SKH FEED ERRORE` + alert | +| 2 | `_positions` read fallita | posizione → flat | **HALT** (non esegue) + alert (gate A) | +| 3 | `_equity` non leggibile | sizing → paper_cap | log `⚠️ EQUITY FALLBACK` + alert (diagnostica B) | diff --git a/scripts/live/book_execute.py b/scripts/live/book_execute.py index ab5bb94..87ada7b 100644 --- a/scripts/live/book_execute.py +++ b/scripts/live/book_execute.py @@ -84,6 +84,18 @@ def _run(): notify("⚠️ BOOK LIVE — conto offline", {"nota": "salto l'esecuzione, non opero a cieco"}) return + if r.get("pos_error"): # ONLINE ma posizione IGNOTA (read fallita -> assunta flat) + print(f" 🛑 POSIZIONE NON LEGGIBILE -> NON eseguo a cieco: {r['pos_error']}") + if do_execute: + notify("🛑 BOOK LIVE — posizione non leggibile", {"error": r["pos_error"], + "nota": "salto l'esecuzione, non opero a cieco"}) + 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 + notify("⚠️ BOOK LIVE — equity fallback (sizing su paper_cap)", {"nota": r["eq_fallback"]}) + trader = DeribitTrader() if do_execute else None actions = [] for a in r["assets"]: diff --git a/src/live/book.py b/src/live/book.py index 2918df9..ac3255b 100644 --- a/src/live/book.py +++ b/src/live/book.py @@ -136,6 +136,7 @@ def book_report(offline: bool = False, equity_override: float | None = None, last_data=sh["last_data"], online=sh["online"], real_equity=sh["real_equity"], equity=equity, eq_basis=sh["eq_basis"], cap_per_asset=cap, weights=dict(TP01=W_TP01, SKH01=W_SKH), - assets=assets, orders=orders, skh_error=skh_error, + assets=assets, orders=orders, skh_error=skh_error, pos_error=sh.get("pos_error"), + eq_fallback=sh.get("eq_fallback"), flat=all(abs(x["net_target"]) < FLAT_USD for x in assets), ) diff --git a/src/live/shadow.py b/src/live/shadow.py index a8e7159..8f02bb5 100644 --- a/src/live/shadow.py +++ b/src/live/shadow.py @@ -72,14 +72,18 @@ def _marks(client, dfs): def _positions(client): if client is None: - return {a: 0.0 for a in ASSETS}, "offline -> assunto flat" - pos, note = {}, "mainnet" + return {a: 0.0 for a in ASSETS}, "offline -> assunto flat", None + pos, note, failed = {}, "mainnet", [] for a in ASSETS: try: pos[a] = client.position_usd(INSTRUMENT[a]) except Exception as e: pos[a], note = 0.0, f"read fallito ({type(e).__name__}) -> assunto flat" - return pos, note + failed.append(f"{a} ({type(e).__name__}: {e})") + # pos_error != None SOLO se ONLINE ma la read ha lanciato: la posizione e' IGNOTA (assunta flat) -> + # l'esecutore NON deve operare a cieco. L'offline (client=None) e' gestito a parte dal gate 'online'. + err = ("posizione non leggibile, assunta FLAT: " + ", ".join(failed)) if failed else None + return pos, note, err def _equity(client, marks): @@ -101,6 +105,8 @@ def _equity(client, marks): pass if any_ok and tot > 1: return tot, "mainnet coin-margined" + # real_eq=None qui -> shadow_report ripiega su paper_cap e alza eq_fallback (diagnostica B): + # segnalato a book_execute (alert), non blocca (l'hard-cap $300/asset limita il downside). return None, "conto flat / non finanziato" @@ -113,7 +119,7 @@ def shadow_report(offline: bool = False, equity_override: float | None = None) - client = None if offline else _safe_client() marks, marks_src = _marks(client, dfs) - positions, pos_src = _positions(client) + positions, pos_src, pos_error = _positions(client) real_eq, eq_src = _equity(client, marks) paper = json.loads(PAPER_STATE.read_text()) if PAPER_STATE.exists() else None @@ -124,6 +130,11 @@ def shadow_report(offline: bool = False, equity_override: float | None = None) - equity = equity_override if equity_override is not None else (real_eq if real_eq else paper_cap) eq_basis = ("override" if equity_override is not None else eq_src if real_eq else "paper capital (ipotetico: conto non finanziato)") + # Opzione B (diagnostica): ONLINE ma equity reale non leggibile -> sizing su paper_cap invece del + # conto reale. NON blocca (l'hard-cap $300/asset limita il downside), ma va SEGNALATO. + eq_fallback = (f"equity reale non leggibile ({eq_src}) -> sizing su paper_cap ${paper_cap:,.0f} " + f"invece del conto reale") if (equity_override is None and client is not None + and not real_eq) else None assets, orders = [], [] for a in ASSETS: @@ -172,7 +183,8 @@ def shadow_report(offline: bool = False, equity_override: float | None = None) - last_data=str(pd.Timestamp(last_ts, unit="ms", tz="UTC").date()), online=(client is not None and marks_src.get("BTC") == "mainnet"), real_equity=real_eq, equity=equity, eq_basis=eq_basis, - pos_src=pos_src, assets=assets, orders=orders, live_trades=live_trades, + pos_src=pos_src, pos_error=pos_error, eq_fallback=eq_fallback, + assets=assets, orders=orders, live_trades=live_trades, disaster_sls=disaster_sls, flat=all(abs(targets[a]) < 1e-9 for a in ASSETS), paper_aligned=(paper_ts == last_ts), diff --git a/tests/test_book_live.py b/tests/test_book_live.py index dbad820..558c20e 100644 --- a/tests/test_book_live.py +++ b/tests/test_book_live.py @@ -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)