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:
+9
-3
@@ -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}))
|
||||||
|
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 [])
|
lst = out if isinstance(out, list) else (out.get("orders", []) if isinstance(out, dict) else [])
|
||||||
for o in lst:
|
for o in lst:
|
||||||
if o.get("instrument_name") == instrument or o.get("instrument") == instrument:
|
if o.get("instrument_name") == instrument or o.get("instrument") == instrument:
|
||||||
seen[o.get("order_id")] = o
|
seen[o.get("order_id")] = o
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return list(seen.values())
|
return list(seen.values())
|
||||||
|
|||||||
+17
-4
@@ -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.
|
||||||
"""
|
"""
|
||||||
|
try:
|
||||||
pos = self.position_usd(instrument)
|
pos = self.position_usd(instrument)
|
||||||
brackets = [o for o in self.open_orders(instrument) if (o.get("label") or "") == DISASTER_LABEL]
|
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"))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user