diff --git a/scripts/live/book_execute.py b/scripts/live/book_execute.py index 6bfe49f..5413107 100644 --- a/scripts/live/book_execute.py +++ b/scripts/live/book_execute.py @@ -155,9 +155,9 @@ def _run(): if r.get("skh_error"): # SKH feed fallito -> book.py ha forzato flat IN SILENZIO # (#22) Non e' piu' "SKH forzato flat!": book_execute non manda PIU' ordini basati su - # quel segnale ignoto (modo SOLO PROTEZIONE, sotto) — i target restano informativi. + # quel segnale ignoto (modo SOLO PROTEZIONE, sotto, che porta anche questo errore su + # Telegram — qui solo il print, per non mandare due notify per lo stesso guasto). print(f" ⚠️ SKH FEED ERRORE (SKH non calcolato: nessun ordine, solo protezione): {r['skh_error']}") - notify("⚠️ BOOK — SKH feed fallito (sleeve forzato flat)", {"error": r["skh_error"]}) # --- FRESCHEZZA del feed 5m usato per il segnale SKH (2026-07-26) -------------------------- # `fresh_5m` ricade sul feed certificato IN SILENZIO se il fetch pubblico Deribit fallisce, e @@ -248,9 +248,9 @@ def _run(): return if r.get("eq_fallback"): # equity reale non leggibile -> niente sizing sul conto + # (#21) qui solo il print: il notify e' nel blocco SOLO PROTEZIONE sotto, che porta anche + # questo errore su Telegram — un solo avviso per lo stesso guasto, non due. print(f" ⚠️ EQUITY FALLBACK (nessun ordine, solo protezione): {r['eq_fallback']}") - if do_execute: # (#21) segnalato: il modo SOLO PROTEZIONE e' sotto - notify("⚠️ BOOK LIVE — equity fallback (sizing su paper_cap)", {"nota": r["eq_fallback"]}) # --- SOLO PROTEZIONE (#21 equity ignota, #22 SKH ignoto) ------------------------------------ # Principio unico: quando il giro non sa QUANTO vale il conto o QUALE e' il segnale, NON manda @@ -259,6 +259,10 @@ def _run(): # da posizione e mark, non da equity o segnale. Stesso principio che pos_error applica gia' # bloccando l'esecuzione, qui senza saltare la protezione. solo_protezione: str | None = r.get("eq_fallback") or r.get("skh_error") + # Calcolato una volta sola: serve sia nell'azione per-asset del ciclo sotto sia nel + # riepilogo finale (#21, #22) — un solo motivo, non due troncamenti che potrebbero divergere. + motivo_breve = (solo_protezione if solo_protezione and len(solo_protezione) <= 60 + else (solo_protezione[:60] + "…" if solo_protezione else None)) if solo_protezione and do_execute: titolo = ("⚠️ BOOK LIVE — equity non leggibile: nessun ordine, solo protezione" if r.get("eq_fallback") else @@ -290,6 +294,7 @@ def _run(): # non deve togliere la rete di sicurezza all'altro. falliti: list[str] = [] scoperti: list[str] = [] + non_verificati: list[str] = [] for a in r["assets"]: asset, inst = a["asset"], a["instrument"] net, cur, mark = a["net_target"], a["position_usd"], a["mark"] @@ -299,7 +304,6 @@ def _run(): if solo_protezione: # (#21, #22) nessun rebalance: l'azione stampata lo dice, non e' "a target" — e' # "non tocco perche' non so quanto vale il conto / qual e' il segnale". - motivo_breve = solo_protezione if len(solo_protezione) <= 60 else solo_protezione[:60] + "…" act = f"HOLD (solo protezione: {motivo_breve})" elif order is None: act = "HOLD (a target)" @@ -314,11 +318,10 @@ def _run(): try: if do_execute and order is not None and not solo_protezione: - # U1/U2 si uniscono dopo questo rilascio: `set_open_cap` e' di U1 (execution.py, - # stesso file, non toccato qui). hasattr perche' oggi DeribitTrader non lo ha - # ancora — l'hasattr si toglie al merge, quando entrambi vivono nello stesso ramo. - if hasattr(trader, "set_open_cap"): - trader.set_open_cap(inst, r["cap_per_asset"] * r.get("scala", 1.0), mark) + # (#18) tetto di apertura derivato dal cap di config del libro. Se cap_per_asset*scala + # o mark sono <=0 solleva ValueError: e' giusto, finisce nell'isolamento per asset + # (except sotto), non deve fermare il giro intero. + trader.set_open_cap(inst, r["cap_per_asset"] * r.get("scala", 1.0), mark) try: fills = trader.rebalance_signed(inst, net, mark, min_usd=min_order) except RebalanceParziale as rp: @@ -360,6 +363,15 @@ def _run(): "amount": round(ds.get("amount") or 0, 4), "azione": "ripiazzare il bracket a mano, oppure chiudere la posizione", "notes": str(ds.get("notes"))[:300]}) + elif stato == "unknown": + # (#24) posizione o open_orders illeggibili: NON e' "naked" (non si e' tolto + # nulla, ensure_disaster_sl non cancella ne' piazza su dati a meta'). E' un + # giro degradato lo stesso: non si sa se la protezione esistente e' ancora + # coerente con la posizione attuale. + non_verificati.append(asset) + print(f" ⚠️ {asset}: protezione NON VERIFICATA — {ds.get('notes')}") + notify("⚠️ BOOK — disaster-SL non verificabile (lettura posizione/ordini fallita)", + {"asset": asset, "notes": str(ds.get("notes"))[:300]}) except Exception as e: # noqa: BLE001 — isolamento per asset: l'altro deve continuare d = diagnose(errori_dal_report(r) + [f"{type(e).__name__}: {e}"]) falliti.append(asset) @@ -379,9 +391,15 @@ def _run(): print(f" 🚨 POSIZIONI SCOPERTE (nessun disaster-SL on-book): {', '.join(scoperti)}") if falliti: print(f" 🛑 asset falliti in questo giro: {', '.join(falliti)}") + if non_verificati: + print(f" ⚠️ disaster-SL NON VERIFICATO (lettura posizione/ordini fallita): {', '.join(non_verificati)}") if not do_execute: print(" => DRY-RUN: nessun ordine inviato." + ("" if enabled else " Per armare: config/live.json execution_enabled=true + --execute.")) + elif solo_protezione: + # (#21, #22) non e' "Nessuna azione: conto gia' al target" — il conto potrebbe NON esserci + # per niente al target, semplicemente non lo si sa. Distinto dal ramo HOLD sotto. + print(f" => SOLO PROTEZIONE: nessun ordine di ribilancio ({motivo_breve}), disaster-SL controllato.") elif all(x.startswith("HOLD") for x in actions): print(" => Nessuna azione: conto gia' al target netto del book.") else: @@ -391,7 +409,9 @@ def _run(): # (#21, #22) SOLO PROTEZIONE e' degradato anche senza asset falliti/scoperti: il giro non ha # ribilanciato nulla perche' non sapeva quanto vale il conto o qual era il segnale. Solo se # il giro era ARMATO: in DRY-RUN non si e' comunque tentato nulla, e' solo un'anteprima. - if scoperti or falliti or (solo_protezione and do_execute): + # (#24) `non_verificati` e' degradato allo stesso modo: la protezione esistente non e' stata + # controllata, indipendentemente da solo_protezione. + if scoperti or falliti or non_verificati or (solo_protezione and do_execute): sys.exit(2) diff --git a/src/live/execution.py b/src/live/execution.py index 68bfed7..4edd3e1 100644 --- a/src/live/execution.py +++ b/src/live/execution.py @@ -96,7 +96,9 @@ class DeribitTrader(DeribitRead): if side not in ("buy", "sell"): raise GuardrailError(f"side non valido: {side}") if not reduce_only: # cap SOLO sulle aperture; le chiusure si tentano sempre - cap = self._open_cap.get(instrument, MAX_AMOUNT[instrument]) + # getattr, non self._open_cap diretto: un DeribitTrader creato con object.__new__ + # (test che saltano __init__) non ha l'attributo, e qui non deve dare AttributeError. + cap = getattr(self, "_open_cap", {}).get(instrument, MAX_AMOUNT[instrument]) if amount <= 0 or amount > cap: raise GuardrailError(f"size {amount} fuori dal cap apertura (0, {cap}]") if amount <= 0: diff --git a/tests/test_book_live.py b/tests/test_book_live.py index ae5e176..3407d93 100644 --- a/tests/test_book_live.py +++ b/tests/test_book_live.py @@ -415,7 +415,10 @@ def test_book_report_flags_skh_feed_error(monkeypatch): def test_book_execute_surfaces_skh_error(monkeypatch, capsys): - """Se il report porta skh_error, _run() lo stampa E chiama notify (niente flat silenzioso).""" + """Se il report porta skh_error, _run() lo stampa SEMPRE (anche in dry-run); quando il giro e' + ARMATO l'alert parte UNA sola volta, nel modo SOLO PROTEZIONE (#22) — niente flat silenzioso, + ma nemmeno due notify per lo stesso guasto (il vecchio notify incondizionato e' stato tolto, + U6 #22).""" from helpers import carica_script mod = carica_script("scripts/live/book_execute.py") @@ -426,17 +429,27 @@ def test_book_execute_surfaces_skh_error(monkeypatch, capsys): skh_state="flat", net_target=0.0, position_usd=0.0, mark=60000.0, order=None)], orders=[], ) + + class _TraderOk: + def ensure_disaster_sl(self, inst, sl_pct): + return {"state": "ok", "stop": 1.0, "amount": 0.0} + alerts = [] 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 *a, **k: _TraderOk()) monkeypatch.setattr(mod, "load_config", - lambda: dict(execution_enabled=False, min_order_usd=5.0, disaster_sl_pct=0.30)) - monkeypatch.setattr(sys, "argv", ["book_execute.py"]) # niente --execute -> dry-run - mod._run() + lambda: dict(execution_enabled=True, min_order_usd=5.0, disaster_sl_pct=0.30)) + monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"]) # ARMATO: qui l'alert conta + try: + mod._run() + except SystemExit: + pass 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 + assert "SKH FEED ERRORE" in out # stampato nel log, sempre + assert len(alerts) == 1 # UNA sola notify, non due + assert "solo protezione" in alerts[0][0].lower() # --------------------------------------------------------------------------- @@ -576,8 +589,11 @@ def test_book_execute_eq_fallback_e_SOLO_PROTEZIONE(monkeypatch, capsys): except SystemExit as e: uscita = e.code out = capsys.readouterr().out - assert "EQUITY FALLBACK" in out # avvisato - assert any("equity fallback" in t for t, _ in alerts) # alert diagnostico esistente - assert any("solo protezione" in t.lower() for t, _ in alerts) # NUOVO alert di modo + assert "EQUITY FALLBACK" in out # avvisato nel log (print, resta) + # (#21, U6) UNA sola notify, non due: il vecchio alert "equity fallback (sizing su paper_cap)" + # e' stato tolto perche' diceva il falso (qui non si dimensiona alcun ordine) — resta solo + # quello del modo SOLO PROTEZIONE. + assert len(alerts) == 1 + assert "solo protezione" in alerts[0][0].lower() assert trader.rebalance_chiamato is False # NESSUN ordine dimensionato assert uscita == 2 # giro degradato diff --git a/tests/test_book_u2.py b/tests/test_book_u2.py index 845406f..32fc1a6 100644 --- a/tests/test_book_u2.py +++ b/tests/test_book_u2.py @@ -21,7 +21,7 @@ sys.path.insert(0, str(PROJECT_ROOT)) from helpers import carica_script from src.live import shadow as S -from src.live.execution import DeribitTrader, Fill, RebalanceParziale +from src.live.execution import MAX_AMOUNT, DeribitTrader, Fill, RebalanceParziale def _fresh_bar() -> str: @@ -125,7 +125,7 @@ class _TraderSoloProtezione: return {"state": "ok", "stop": 1.0, "amount": 0.0} -def _run_solo_protezione(canned, monkeypatch): +def _run_solo_protezione(canned, monkeypatch, capsys): mod = carica_script("scripts/live/book_execute.py") trader = _TraderSoloProtezione() alerts = [] @@ -142,25 +142,34 @@ def _run_solo_protezione(canned, monkeypatch): mod._run() except SystemExit as e: uscita = e.code - return trader, alerts, uscita + out = capsys.readouterr().out + return trader, alerts, uscita, out -def test_eq_fallback_e_solo_protezione(monkeypatch): +def test_eq_fallback_e_solo_protezione(monkeypatch, capsys): canned = _canned_report( eq_fallback="equity reale non leggibile (conto flat) -> sizing su paper_cap $2,000") - trader, alerts, uscita = _run_solo_protezione(canned, monkeypatch) + trader, alerts, uscita, out = _run_solo_protezione(canned, monkeypatch, capsys) assert trader.rebalance_chiamato is False # nessun ordine dimensionato assert trader.visti_sl == ["BTC_USDC-PERPETUAL", "ETH_USDC-PERPETUAL"] # protezione per TUTTI - assert any("solo protezione" in t.lower() for t, _ in alerts) + # (#21) UNA sola notify per il guasto, non due: il vecchio testo diceva "sizing su paper_cap", + # falso in modo SOLO PROTEZIONE (nessun ordine dimensionato affatto). + assert len(alerts) == 1 + assert "solo protezione" in alerts[0][0].lower() + assert "paper_cap" not in alerts[0][0] + assert "SOLO PROTEZIONE: nessun ordine di ribilancio" in out # riepilogo finale corretto assert uscita == 2 -def test_skh_error_e_solo_protezione(monkeypatch): +def test_skh_error_e_solo_protezione(monkeypatch, capsys): canned = _canned_report(skh_error="RuntimeError: feed 5m giu") - trader, alerts, uscita = _run_solo_protezione(canned, monkeypatch) + trader, alerts, uscita, out = _run_solo_protezione(canned, monkeypatch, capsys) assert trader.rebalance_chiamato is False assert trader.visti_sl == ["BTC_USDC-PERPETUAL", "ETH_USDC-PERPETUAL"] - assert any("solo protezione" in t.lower() for t, _ in alerts) + # (#22) UNA sola notify, non due ("SKH feed fallito" + "SKH non calcolato"). + assert len(alerts) == 1 + assert "solo protezione" in alerts[0][0].lower() + assert "SOLO PROTEZIONE: nessun ordine di ribilancio" in out assert uscita == 2 @@ -230,6 +239,9 @@ def test_fill_del_close_non_si_perde_se_lopen_del_flip_fallisce(tmp_path, monkey def __init__(self): self.visti_sl: list[str] = [] + def set_open_cap(self, inst, cap_usd, mark): + pass + def rebalance_signed(self, inst, net, mark, min_usd=5.0): if inst.startswith("BTC"): raise RebalanceParziale([close_fill], RuntimeError("open KO dopo close")) @@ -285,6 +297,9 @@ def test_position_usd_illeggibile_dopo_un_fill_non_perde_il_fill(tmp_path, monke def __init__(self): self.visti_sl: list[str] = [] + def set_open_cap(self, inst, cap_usd, mark): + pass + def rebalance_signed(self, inst, net, mark, min_usd=5.0): return [fill] if inst.startswith("BTC") else [] @@ -330,6 +345,9 @@ def test_parse_cron_log_legge_un_giro_normale_prodotto_dal_nuovo_codice(monkeypa fill_btc = Fill("BTC_USDC-PERPETUAL", "buy", 0.003, 0.003, 60000.0, 0.02, "o1", "filled", True, "") class _TraderOk: + def set_open_cap(self, inst, cap_usd, mark): + pass + def rebalance_signed(self, inst, net, mark, min_usd=5.0): return [fill_btc] if inst.startswith("BTC") else [] @@ -368,6 +386,9 @@ def test_parse_cron_log_legge_il_fill_di_un_RebalanceParziale(monkeypatch, capsy "filled", True, "") class _TraderFlip: + def set_open_cap(self, inst, cap_usd, mark): + pass + def rebalance_signed(self, inst, net, mark, min_usd=5.0): if inst.startswith("BTC"): raise RebalanceParziale([close_fill], RuntimeError("open KO")) @@ -402,8 +423,8 @@ def test_parse_cron_log_legge_il_fill_di_un_RebalanceParziale(monkeypatch, capsy # --------------------------------------------------------------------------- -# set_open_cap (da U1, stesso rilascio): chiamato SOLO se il trader lo espone (hasattr — U1 e -# U2 si uniscono dopo questo rilascio), subito prima di rebalance_signed. +# set_open_cap (#18): chiamato SEMPRE, diretto (l'hasattr era solo per il periodo prima del +# merge con U1, in cui DeribitTrader non lo aveva ancora — tolto qui). # --------------------------------------------------------------------------- def test_set_open_cap_chiamato_se_il_trader_lo_espone(monkeypatch): mod = carica_script("scripts/live/book_execute.py") @@ -435,8 +456,10 @@ def test_set_open_cap_chiamato_se_il_trader_lo_espone(monkeypatch): assert trader.cap_visti == [("BTC_USDC-PERPETUAL", 300.0 * 1.25, 60000.0)] # solo BTC ha un ordine -def test_set_open_cap_non_richiesto_se_il_trader_non_lo_espone(monkeypatch): - """hasattr: un trader senza set_open_cap (oggi, prima del merge con U1) non deve rompersi.""" +def test_trader_senza_set_open_cap_fallisce_isolato_non_lascia_morire_il_giro(monkeypatch, capsys): + """Post-merge: `set_open_cap` si chiama diretto, senza hasattr (era solo transitorio prima + del merge con U1). Un trader che non lo espone ora fa fallire QUELL'asset (isolamento per + asset, gia' esistente) — non l'intero giro: ETH resta elaborato normalmente.""" mod = carica_script("scripts/live/book_execute.py") class _TraderSenzaCap: @@ -447,7 +470,7 @@ def test_set_open_cap_non_richiesto_se_il_trader_non_lo_espone(monkeypatch): def ensure_disaster_sl(self, inst, sl_pct): return {"state": "ok", "stop": 1.0, "amount": 0.0} - canned = _canned_report() + canned = _canned_report() # BTC ha un ordine (set_open_cap si chiama), ETH order=None monkeypatch.setattr(mod, "book_report", lambda **k: canned) monkeypatch.setattr(mod, "notify", lambda t, d=None: None) monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: _TraderSenzaCap()) @@ -456,4 +479,73 @@ def test_set_open_cap_non_richiesto_se_il_trader_non_lo_espone(monkeypatch): disaster_sl_pct=0.30, max_data_age_days=2.0, skh_feed_max_age_min=30.0)) monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"]) - mod._run() # non deve sollevare AttributeError + uscita = None + try: + mod._run() + except SystemExit as e: + uscita = e.code + out = capsys.readouterr().out + assert "AttributeError" in out # isolato: si vede nel log, non muore il giro + assert "asset falliti in questo giro: BTC" in out + assert uscita == 2 + + +# --------------------------------------------------------------------------- +# #24 — ensure_disaster_sl "unknown" in book_execute: NON e' "naked" (non si e' tolto nulla), +# ma il giro resta degradato -> notify dedicata e exit 2. +# --------------------------------------------------------------------------- +def test_ensure_disaster_sl_unknown_notifica_e_degrada_il_giro(monkeypatch, capsys): + mod = carica_script("scripts/live/book_execute.py") + + class _TraderUnknown: + def set_open_cap(self, inst, cap_usd, mark): + pass + def rebalance_signed(self, inst, net, mark, min_usd=5.0): + return [] + def position_usd(self, inst): + return 0.0 + def ensure_disaster_sl(self, inst, sl_pct): + if inst.startswith("ETH"): + return {"state": "unknown", "notes": "RuntimeError: 502 su get_positions"} + return {"state": "ok", "stop": 1.0, "amount": 0.0} + + trader = _TraderUnknown() + canned = _canned_report() # BTC ha un ordine, ETH order=None -> ETH e' l'asset "unknown" + alerts = [] + monkeypatch.setattr(mod, "book_report", lambda **k: canned) + monkeypatch.setattr(mod, "notify", lambda t, d=None: alerts.append((t, d))) + monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: trader) + 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, + skh_feed_max_age_min=30.0)) + monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"]) + + uscita = None + try: + mod._run() + except SystemExit as e: + uscita = e.code + out = capsys.readouterr().out + + assert uscita == 2 + assert "ETH: protezione NON VERIFICATA" in out + notifiche = [d for t, d in alerts if "non verificabile" in t.lower()] + assert len(notifiche) == 1 + assert notifiche[0]["asset"] == "ETH" + + +# --------------------------------------------------------------------------- +# execution.py::_submit (#21) — legge il tetto con getattr: un DeribitTrader creato senza +# passare da __init__ (object.__new__, come qui) non deve rompersi con AttributeError perche' +# gli manca l'attributo _open_cap. +# --------------------------------------------------------------------------- +def test_submit_su_trader_senza_open_cap_non_solleva_attributeerror(monkeypatch): + t = object.__new__(DeribitTrader) # bypassa __init__: niente self._open_cap + monkeypatch.setattr(t, "_post", lambda path, payload: { + "order": {"order_id": "1", "order_state": "filled", + "filled_amount": payload["amount"]}, + "trades": [{"price": 1.0, "amount": payload["amount"], "fee": 0.0}]}) + dentro = MAX_AMOUNT["ETH_USDC-PERPETUAL"] * 0.5 + f = t._submit("ETH_USDC-PERPETUAL", "buy", dentro, reduce_only=False, label="book-open") + assert f.verified is True # arrivato in fondo: niente AttributeError sul cap