live/deribit,execution: open_orders non ingoia gli errori; ensure_disaster_sl -> unknown (#24)

open_orders solleva RuntimeError se 'all' o 'trigger_all' falliscono, invece di ritornare []
(un bracket disaster-SL perso su 'trigger_all' e' lo stesso difetto di una posizione persa su
'all'). ensure_disaster_sl avvolge la lettura di posizione+open_orders in try/except: su
eccezione ritorna {"state": "unknown", "notes": ...} senza cancellare ne' piazzare, distinto
da "naked" (protezione tolta e non rimessa). shadow.shadow_report chiama open_orders gia'
dentro try/except (non toccato).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 13:40:50 +00:00
parent f79fb54221
commit 7c72308852
3 changed files with 96 additions and 13 deletions
+13 -7
View File
@@ -262,17 +262,23 @@ class DeribitRead:
def open_orders(self, instrument: str) -> list[dict]: def open_orders(self, instrument: str) -> list[dict]:
"""Ordini APERTI su `instrument` (limit resting + trigger). Deribit puo' omettere i trigger """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"] cur = _CONTRACT[instrument]["settle"]
seen: dict = {} seen: dict = {}
for typ in ("all", "trigger_all"): for typ in ("all", "trigger_all"):
try: try:
out = self._unwrap(self._post("/mcp-deribit/tools/get_open_orders", out = self._unwrap(self._post("/mcp-deribit/tools/get_open_orders",
{"currency": cur, "type": typ})) {"currency": cur, "type": typ}))
lst = out if isinstance(out, list) else (out.get("orders", []) if isinstance(out, dict) else []) except Exception as e: # noqa: BLE001 — si registra il tipo (P3), non si ingoia (#24)
for o in lst: raise RuntimeError(f"open_orders illeggibile: {type(e).__name__}: {e}") from e
if o.get("instrument_name") == instrument or o.get("instrument") == instrument: lst = out if isinstance(out, list) else (out.get("orders", []) if isinstance(out, dict) else [])
seen[o.get("order_id")] = o for o in lst:
except Exception: if o.get("instrument_name") == instrument or o.get("instrument") == instrument:
pass seen[o.get("order_id")] = o
return list(seen.values()) return list(seen.values())
+19 -6
View File
@@ -215,10 +215,20 @@ class DeribitTrader(DeribitRead):
return self._unwrap(self._post("/mcp-deribit/tools/cancel_order", {"order_id": order_id})) or {} 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: def ensure_disaster_sl(self, instrument: str, sl_pct: float) -> dict:
"""Garantisce UN disaster-SL coerente con la posizione (lifecycle completo, idempotente): """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; Stati possibili (`dict["state"]`):
- gia' coerente (1 bracket, amount~=, stop entro 5%) -> lascia com'e' (niente churn/gap). - `"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 ⚠️ 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 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 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. misurarlo. Riparato il silenzio, non toccata la sequenza.
""" """
pos = self.position_usd(instrument) try:
brackets = [o for o in self.open_orders(instrument) if (o.get("label") or "") == DISASTER_LABEL] 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: if abs(pos) < FLAT_USD:
for o in brackets: for o in brackets:
self.cancel_order(o.get("order_id")) self.cancel_order(o.get("order_id"))
+64
View File
@@ -6,6 +6,10 @@ che dovrebbe farle — non discusse.
ordine e prezzo dello stop dal segno sbagliato. 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; #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()`. 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. 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 t._submit(ETH, "buy", dentro, reduce_only=False, label="book-open") # non deve solllevare
with pytest.raises(GuardrailError): with pytest.raises(GuardrailError):
t._submit(ETH, "buy", MAX_AMOUNT[ETH] * 3, reduce_only=False, label="book-open") 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