diff --git a/src/live/deribit.py b/src/live/deribit.py index 5a142bb..437427a 100644 --- a/src/live/deribit.py +++ b/src/live/deribit.py @@ -262,17 +262,23 @@ class DeribitRead: def open_orders(self, instrument: str) -> list[dict]: """Ordini APERTI su `instrument` (limit resting + trigger). Deribit puo' omettere i trigger - untriggered da type='all' -> interroga anche 'trigger_all' e fa merge per order_id.""" + untriggered da type='all' -> interroga anche 'trigger_all' e fa merge per order_id. + + ⚠️ Se UNA delle due chiamate solleva, NON si ingoia (#24): prima ritornava [] anche a meta' + letta, e un bracket disaster-SL perso su 'trigger_all' e' lo stesso difetto di una posizione + persa su 'all' — il chiamante (ensure_disaster_sl) deve saperlo, non decidere su dati a + meta'. Il chiamante che vuole restare in piedi comunque (shadow.shadow_report) si mette il + try/except da solo.""" cur = _CONTRACT[instrument]["settle"] seen: dict = {} for typ in ("all", "trigger_all"): try: out = self._unwrap(self._post("/mcp-deribit/tools/get_open_orders", {"currency": cur, "type": typ})) - lst = out if isinstance(out, list) else (out.get("orders", []) if isinstance(out, dict) else []) - for o in lst: - if o.get("instrument_name") == instrument or o.get("instrument") == instrument: - seen[o.get("order_id")] = o - except Exception: - pass + except Exception as e: # noqa: BLE001 — si registra il tipo (P3), non si ingoia (#24) + raise RuntimeError(f"open_orders illeggibile: {type(e).__name__}: {e}") from e + lst = out if isinstance(out, list) else (out.get("orders", []) if isinstance(out, dict) else []) + for o in lst: + if o.get("instrument_name") == instrument or o.get("instrument") == instrument: + seen[o.get("order_id")] = o return list(seen.values()) diff --git a/src/live/execution.py b/src/live/execution.py index 5ca9c80..c66131f 100644 --- a/src/live/execution.py +++ b/src/live/execution.py @@ -215,10 +215,20 @@ class DeribitTrader(DeribitRead): return self._unwrap(self._post("/mcp-deribit/tools/cancel_order", {"order_id": order_id})) or {} def ensure_disaster_sl(self, instrument: str, sl_pct: float) -> dict: - """Garantisce UN disaster-SL coerente con la posizione (lifecycle completo, idempotente): - - flat -> cancella eventuali bracket orfani; - - long -> assicura UN solo STOP_MARKET reduce_only a ~-sl_pct, size = posizione; - - gia' coerente (1 bracket, amount~=, stop entro 5%) -> lascia com'e' (niente churn/gap). + """Garantisce UN disaster-SL coerente con la posizione (lifecycle completo, idempotente). + + Stati possibili (`dict["state"]`): + - `"flat"` -> nessuna posizione: cancella eventuali bracket orfani; + - `"ok"` -> gia' coerente (1 bracket, amount~=, stop entro 5%): lascia com'e'; + - `"placed"` -> ricostruito (cancellato l'incoerente e ripiazzato) al primo o secondo + tentativo, e il piazzamento e' verificato; + - `"place-failed"` -> il ripiazzamento e' partito ma NON e' verificato (state/trade non + confermano): l'ordine potrebbe non essere a book; + - `"naked"` -> il bracket incoerente e' stato cancellato e il ripiazzamento e' + fallito (eccezione) 2 volte di fila: la posizione e' SENZA stop; + - `"unknown"` -> posizione o open_orders illeggibili (#24): NON cancella ne' piazza — + decidere alla cieca su meta' dei dati e' peggio che aspettare il giro + dopo. `notes` porta tipo ed errore della lettura fallita. ⚠️ LA FINESTRA SCOPERTA (riparata 2026-08-25). Il ramo di ricostruzione **cancella prima e ripiazza dopo**: fra le due chiamate la posizione e' senza alcuno stop. Finche' il @@ -237,8 +247,11 @@ class DeribitTrader(DeribitRead): non basta per cambiare il ciclo di vita dei bracket su un percorso con soldi veri senza misurarlo. Riparato il silenzio, non toccata la sequenza. """ - pos = self.position_usd(instrument) - brackets = [o for o in self.open_orders(instrument) if (o.get("label") or "") == DISASTER_LABEL] + try: + pos = self.position_usd(instrument) + brackets = [o for o in self.open_orders(instrument) if (o.get("label") or "") == DISASTER_LABEL] + except Exception as e: # noqa: BLE001 — si registra il motivo (P3); non si cancella ne' piazza (#24) + return {"state": "unknown", "notes": f"{type(e).__name__}: {e}"} if abs(pos) < FLAT_USD: for o in brackets: self.cancel_order(o.get("order_id")) diff --git a/tests/test_execution_u1.py b/tests/test_execution_u1.py index 7fdef8f..25fe7cc 100644 --- a/tests/test_execution_u1.py +++ b/tests/test_execution_u1.py @@ -6,6 +6,10 @@ che dovrebbe farle — non discusse. ordine e prezzo dello stop dal segno sbagliato. #18. Tetto per ordine d'apertura: `MAX_AMOUNT` era un tetto FISSO uguale per tutti i conti; ora il libro deriva il proprio tetto dal cap di config via `set_open_cap()`. + #24. `DeribitRead.open_orders` ingoiava gli errori di ENTRAMBE le chiamate ('all' e + 'trigger_all') e ritornava []: un bracket disaster-SL perso passava per "nessun bracket". + Ora solleva, e `ensure_disaster_sl` distingue lo stato "unknown" (letture fallite, non si + cancella ne' si piazza) da "naked" (protezione tolta e non rimessa). Nessun test tocca la rete. """ @@ -158,3 +162,63 @@ def test_senza_set_open_cap_resta_il_default_max_amount(): t._submit(ETH, "buy", dentro, reduce_only=False, label="book-open") # non deve solllevare with pytest.raises(GuardrailError): t._submit(ETH, "buy", MAX_AMOUNT[ETH] * 3, reduce_only=False, label="book-open") + + +# ================================================================================================= +# #24 — open_orders non ingoia gli errori; ensure_disaster_sl diventa "unknown", non a caso. +# ================================================================================================= +class _FakeOpenOrders(DeribitRead): + """DeribitRead senza rete: 'all' risponde vuoto, 'trigger_all' solleva — replica un bracket + disaster-SL perso su un solo dei due tipi di chiamata.""" + + def __init__(self) -> None: + pass # niente token, niente rete + + def _post(self, path, payload): + if payload.get("type") == "trigger_all": + raise RuntimeError("502 Server Error: Bad Gateway") + return {"result": []} + + +def test_open_orders_trigger_all_solleva_runtime_error(): + r = _FakeOpenOrders() + with pytest.raises(RuntimeError, match="open_orders illeggibile"): + r.open_orders(ETH) + + +class _FakeTraderOpenOrdersRompe(DeribitTrader): + """position_usd legge una posizione REALE, ma open_orders solleva: ensure_disaster_sl non + deve decidere su meta' dei dati.""" + + def __init__(self) -> None: + self._open_cap: dict[str, float] = {} + self.submitted: list[dict] = [] + self.cancelled: list[str] = [] + + def positions(self, currency): + return [{"instrument_name": ETH, "size": 560, "direction": "sell"}] + + def mark_price(self, instrument): + return 2694.0 + + def open_orders(self, instrument): + raise RuntimeError("open_orders illeggibile: RuntimeError: 502 Server Error: Bad Gateway") + + def cancel_order(self, order_id): + self.cancelled.append(order_id) + return {} + + def _submit(self, instrument, side, amount, *, reduce_only, label, + order_type="market", price=None): + self.submitted.append(dict(side=side, reduce_only=reduce_only)) + return Fill(instrument=instrument, side=side, amount=amount, filled=amount, price=price, + fee_usdc=0.0, order_id="1", state="filled", verified=True) + + +def test_ensure_disaster_sl_con_open_orders_che_solleva_diventa_unknown_senza_agire(): + t = _FakeTraderOpenOrdersRompe() + ds = t.ensure_disaster_sl(ETH, 0.30) + assert ds["state"] == "unknown" + assert "502" in ds["notes"] + assert t.cancelled == [] # nessuna cancel + assert t.submitted == [] # nessun piazzamento