diff --git a/src/live/deribit.py b/src/live/deribit.py index e4f751d..437427a 100644 --- a/src/live/deribit.py +++ b/src/live/deribit.py @@ -236,11 +236,22 @@ class DeribitRead: return out.get("positions", []) if isinstance(out, dict) else [] def position_usd(self, instrument: str) -> float: - """Size netta (USD notional, segno = direzione) della posizione su `instrument`. 0 se flat.""" + """Size netta (USD notional, segno = direzione) della posizione su `instrument`. 0 se flat. + + ⚠️ Il gateway manda `size`/`size_currency` in ASSOLUTO e il segno a parte in `direction` + (#17): letta com'era, uno short risultava un long, e close()/ensure_disaster_sl decidevano + lato ordine e prezzo dello stop dal segno sbagliato. `direction` assente o `"zero"` -> ci si + fida di `size` cosi' com'e' (compatibile con un gateway che un giorno mandasse gia' il segno).""" cur = _CONTRACT[instrument]["settle"] for p in self.positions(cur): if p.get("instrument_name") == instrument or p.get("instrument") == instrument: - return float(p.get("size") or p.get("size_currency") or 0.0) + s = float(p.get("size") or p.get("size_currency") or 0.0) + direction = str(p.get("direction") or "").lower() + if direction in ("sell", "short"): + return -abs(s) + if direction in ("buy", "long"): + return abs(s) + return s return 0.0 def trade_history(self, instrument: str, limit: int = 20) -> list[dict]: @@ -251,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 37bec30..c66131f 100644 --- a/src/live/execution.py +++ b/src/live/execution.py @@ -4,12 +4,17 @@ Estende DeribitRead (sola lettura) coi metodi di trading, con la logica PROVATA (Old/src/live/execution.py): entrata market verificata (state=='filled' + trade riscontrati, fill/fee reali, filled_amount autorevole), uscita market reduce_only, disaster-bracket STOP_MARKET reduce_only. -GUARDRAIL: solo strumenti in ALLOWED; cap di size SOLO sulle APERTURE (MAX_AMOUNT). Le CHIUSURE si -tentano SEMPRE senza cap (principio di sicurezza di Old: si deve poter uscire da qualunque posizione). -Nessun parametro di leva (Deribit non la accetta per-ordine: l'esposizione la decide la SIZE). +GUARDRAIL: solo strumenti in ALLOWED; cap di size SOLO sulle APERTURE — `set_open_cap()` lo deriva +per-strumento dal cap di config del libro (config/live.json, #18); MAX_AMOUNT resta il tetto di +DEFAULT per chi non lo chiama (il micro-test, size fissa minima). Le CHIUSURE si tentano SEMPRE +senza cap (principio di sicurezza di Old: si deve poter uscire da qualunque posizione). Nessun +parametro di leva (Deribit non la accetta per-ordine: l'esposizione la decide la SIZE). -⚠️ INVIA ORDINI REALI CON SOLDI VERI. Finestra d'uso attuale: micro-test (scripts/live/microtest.py). -Il deploy pieno di TP01 resta gated finche' il percorso live non e' abilitato esplicitamente. +⚠️ INVIA ORDINI REALI CON SOLDI VERI. Il libro (TP01+SKH01) e' ARMATO e LIVE su Deribit mainnet dal +2026-06-23 (commit db738bc): questo NON e' un percorso di micro-test, e' l'esecuzione di produzione +usata da `scripts/live/book_execute.py` (cron del book). Il micro-test +(scripts/live/microtest.py) resta un percorso a parte, sotto lo stesso guardrail, col cap di +DEFAULT (MAX_AMOUNT). """ from __future__ import annotations @@ -17,11 +22,13 @@ from dataclasses import dataclass from src.live.deribit import DISASTER_LABEL, DeribitRead, disaster_stop_price, notional_to_amount, quantize_price -# Conto USDC -> perp LINEARE USDC (amount in base-coin). MAX_AMOUNT = TETTO HARD anti-fat-finger -# (~$630/$430 su un conto ~$600): backstop sopra il sizing di TP01, non il sizing operativo (quello -# lo decide config/live.json max_notional_per_asset_usd). Il micro-test invia comunque size fissa minima. +# Conto USDC -> perp LINEARE USDC (amount in base-coin). MAX_AMOUNT = TETTO HARD anti-fat-finger di +# DEFAULT (~$630/$430 su un conto ~$600), usato SOLO quando nessuno ha chiamato set_open_cap() (il +# micro-test, che invia comunque size fissa minima). Sul libro il tetto per apertura lo deriva +# set_open_cap() dal cap di config (config/live.json max_notional_per_asset_usd, #18): backstop +# sopra il sizing di TP01/book, non il sizing operativo. ALLOWED = {"BTC_USDC-PERPETUAL", "ETH_USDC-PERPETUAL"} -MAX_AMOUNT = {"BTC_USDC-PERPETUAL": 0.01, "ETH_USDC-PERPETUAL": 0.25} +MAX_AMOUNT = {"BTC_USDC-PERPETUAL": 0.01, "ETH_USDC-PERPETUAL": 0.25} # DEFAULT — vedi set_open_cap() FLAT_USD = 1.0 # |notional| < $1 = posizione considerata flat @@ -55,6 +62,20 @@ def _avg_price(order: dict, trades: list[dict]) -> float | None: class DeribitTrader(DeribitRead): """Trading minimo e verificato. Apre/chiude solo entro i guardrail; le chiusure sempre.""" + def __init__(self) -> None: + super().__init__() + self._open_cap: dict[str, float] = {} # per-strumento, da set_open_cap() (#18) + + def set_open_cap(self, instrument: str, usd_cap: float, mark: float) -> None: + """Tetto di apertura per `instrument`, derivato dal cap di config del libro (#18): + amount_max = 1,10 * usd_cap / mark — 10% sopra il cap nozionale per non rifiutare + un'apertura legittima per un arrotondamento di sizing/mark. Backstop anti-fat-finger + sopra il sizing di TP01/book, non il sizing operativo (quello resta config/live.json). + Senza chiamata, `_submit` resta sul DEFAULT MAX_AMOUNT (il micro-test).""" + if usd_cap <= 0 or mark <= 0: + raise ValueError(f"set_open_cap richiede usd_cap>0 e mark>0 (usd_cap={usd_cap}, mark={mark})") + self._open_cap[instrument] = 1.10 * usd_cap / mark + def _submit(self, instrument: str, side: str, amount: float, *, reduce_only: bool, label: str, order_type: str = "market", price: float | None = None) -> Fill: if instrument not in ALLOWED: @@ -62,7 +83,7 @@ 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 = MAX_AMOUNT.get(instrument, 0.0) + cap = 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: @@ -194,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 @@ -216,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 new file mode 100644 index 0000000..25fe7cc --- /dev/null +++ b/tests/test_execution_u1.py @@ -0,0 +1,224 @@ +"""Tre riparazioni sul percorso con soldi veri (unita' U1, 2026-09-27), provate contro il codice +che dovrebbe farle — non discusse. + + #17. `DeribitRead.position_usd` leggeva `size`/`size_currency` in ASSOLUTO e ignorava + `direction`: uno short risultava un long, e close()/ensure_disaster_sl decidevano lato + 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. +""" +from __future__ import annotations + +import pytest + +from src.live.deribit import DeribitRead +from src.live.execution import MAX_AMOUNT, DeribitTrader, Fill, GuardrailError + +ETH = "ETH_USDC-PERPETUAL" + + +# ================================================================================================= +# #17 — position_usd: il segno viene da `direction`, non assunto dal solo `size`. +# ================================================================================================= +class _FakeRead(DeribitRead): + """DeribitRead senza rete: `positions()` finto, per esercitare `position_usd` con la forma + REALE del gateway (size in assoluto + direction a parte).""" + + def __init__(self, raw: list[dict]) -> None: + self._raw = raw + + def positions(self, currency): + return self._raw + + +def test_position_usd_sell_e_negativo(): + r = _FakeRead([{"instrument_name": ETH, "size": 560, "direction": "sell"}]) + assert r.position_usd(ETH) == -560.0 + + +def test_position_usd_buy_e_positivo(): + r = _FakeRead([{"instrument_name": ETH, "size": 560, "direction": "buy"}]) + assert r.position_usd(ETH) == 560.0 + + +def test_position_usd_senza_direction_si_fida_del_segno_di_size(): + """Compatibile con un gateway che un giorno mandasse gia' il segno in `size`.""" + r = _FakeRead([{"instrument_name": ETH, "size": -3}]) + assert r.position_usd(ETH) == -3.0 + + +# ================================================================================================= +# Verifica a valle di #17: close() e ensure_disaster_sl() decidono lato/stop da `pos > 0`. +# Si mocka `positions()` (dati grezzi del gateway), NON `position_usd()`: cosi' si esercita +# davvero la pipeline positions() -> position_usd() -> close()/ensure_disaster_sl(). +# ================================================================================================= +class _FakeTraderShort(DeribitTrader): + """DeribitTrader senza rete: posizione SHORT letta come il gateway la manda davvero, mark + fisso, `_submit` registra la chiamata invece di piazzare l'ordine.""" + + def __init__(self, raw_positions: list[dict], mark: float = 2694.0) -> None: + self._open_cap: dict[str, float] = {} + self._raw = raw_positions + self._mark = mark + self.submitted: list[dict] = [] + self.cancelled: list[str] = [] + + def positions(self, currency): + return self._raw + + def mark_price(self, instrument): + return self._mark + + def open_orders(self, instrument): + return [] + + 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(instrument=instrument, side=side, amount=amount, + reduce_only=reduce_only, label=label, + order_type=order_type, price=price)) + return Fill(instrument=instrument, side=side, amount=amount, filled=amount, price=price, + fee_usdc=0.0, order_id="1", state="filled", verified=True) + + +def _short_560(): + return _FakeTraderShort([{"instrument_name": ETH, "size": 560, "direction": "sell"}]) + + +def test_close_su_short_usa_side_buy_reduce_only(): + t = _short_560() + f = t.close(ETH) + assert f is not None + assert len(t.submitted) == 1 + call = t.submitted[0] + assert call["side"] == "buy" and call["reduce_only"] is True + assert call["label"] == "tp01-close" and call["amount"] > 0 + + +def test_ensure_disaster_sl_su_short_mette_lo_stop_sopra_il_mark_lato_buy(): + t = _short_560() + ds = t.ensure_disaster_sl(ETH, 0.30) + assert ds["state"] == "placed" + assert len(t.submitted) == 1 + call = t.submitted[0] + assert call["side"] == "buy" + assert call["order_type"] == "stop_market" + assert call["price"] > t._mark # stop SOPRA il mark per uno short + + +# ================================================================================================= +# #18 — set_open_cap(): tetto di apertura per strumento, derivato dal cap di config. +# ================================================================================================= +class _FakeTraderCap(DeribitTrader): + """DeribitTrader senza rete: `_post` risponde con un ordine fillato finto, cosi' la parte di + _submit DOPO il guardrail (che qui non ci interessa) non tocca la rete.""" + + def __init__(self) -> None: + self._open_cap: dict[str, float] = {} + + def _post(self, path, payload): + amt = payload["amount"] + return {"order": {"order_id": "1", "order_state": "filled", "filled_amount": amt}, + "trades": [{"price": 1.0, "amount": amt, "fee": 0.0}]} + + +def test_set_open_cap_valida_gli_argomenti(): + t = _FakeTraderCap() + with pytest.raises(ValueError): + t.set_open_cap(ETH, 0.0, 2694.0) + with pytest.raises(ValueError): + t.set_open_cap(ETH, 2291.5, 0.0) + + +def test_set_open_cap_apertura_entro_il_tetto_non_solleva(): + """Equity 4583, cap/asset 2291.5, ETH mark 2694 -> apertura di 0,2807 ETH e' ben dentro il + tetto (1,10 * 2291.5 / 2694 ~= 0,9355 ETH).""" + t = _FakeTraderCap() + t.set_open_cap(ETH, 2291.5, 2694.0) + t._submit(ETH, "buy", 0.2807, reduce_only=False, label="book-open") # non deve solllevare + + +def test_set_open_cap_3x_il_tetto_solleva_guardrail(): + t = _FakeTraderCap() + t.set_open_cap(ETH, 2291.5, 2694.0) + cap = t._open_cap[ETH] + with pytest.raises(GuardrailError): + t._submit(ETH, "buy", cap * 3, reduce_only=False, label="book-open") + + +def test_senza_set_open_cap_resta_il_default_max_amount(): + t = _FakeTraderCap() + dentro = MAX_AMOUNT[ETH] * 0.5 + 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