"""Esecuzione REALE su Deribit mainnet (via Cerbero MCP) — entrata/uscita verificate. Estende DeribitRead (sola lettura) coi metodi di trading, con la logica PROVATA dello stack pre-reset (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). ⚠️ 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. """ from __future__ import annotations 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. ALLOWED = {"BTC_USDC-PERPETUAL", "ETH_USDC-PERPETUAL"} MAX_AMOUNT = {"BTC_USDC-PERPETUAL": 0.01, "ETH_USDC-PERPETUAL": 0.25} FLAT_USD = 1.0 # |notional| < $1 = posizione considerata flat class GuardrailError(RuntimeError): pass @dataclass class Fill: """Esito verificato di un ordine reale.""" instrument: str side: str amount: float # richiesto (base-coin) filled: float # realmente fillato (order.filled_amount, autorevole) price: float | None # prezzo medio di fill fee_usdc: float # fee reale (lineare USDC: gia' in USDC) order_id: str | None state: str | None verified: bool notes: str = "" def _avg_price(order: dict, trades: list[dict]) -> float | None: tr = [t for t in trades if t.get("price") and t.get("amount")] if tr: amt = sum(float(t["amount"]) for t in tr) return (sum(float(t["price"]) * float(t["amount"]) for t in tr) / amt) if amt else None return float(order.get("average_price") or 0) or None class DeribitTrader(DeribitRead): """Trading minimo e verificato. Apre/chiude solo entro i guardrail; le chiusure sempre.""" 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: raise GuardrailError(f"strumento non consentito: {instrument}") 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) if amount <= 0 or amount > cap: raise GuardrailError(f"size {amount} fuori dal cap apertura (0, {cap}]") if amount <= 0: return Fill(instrument, side, amount, 0.0, None, 0.0, None, None, False, "amount<=0") payload = {"instrument_name": instrument, "side": side, "amount": amount, "type": order_type, "label": label} if price is not None: payload["price"] = price if reduce_only: payload["reduce_only"] = True resp = self._unwrap(self._post("/mcp-deribit/tools/place_order", payload)) or {} if not isinstance(resp, dict) or resp.get("error") or resp.get("state") == "error": err = resp.get("error") if isinstance(resp, dict) else resp return Fill(instrument, side, amount, 0.0, None, 0.0, None, "error", False, notes=f"place_order error: {err}") order = resp.get("order", resp) or {} trades = resp.get("trades", []) or [] order_id = order.get("order_id") state = order.get("order_state") price_f = _avg_price(order, trades) fee_usdc = sum(float(t.get("fee", 0) or 0) for t in trades) # lineare USDC: fee gia' in USDC filled = float(order.get("filled_amount") or 0) or sum(float(t.get("amount", 0) or 0) for t in trades) if order_type == "market": verified = (state == "filled") and bool(trades) elif order_type == "stop_market": verified = state in ("untriggered", "open", "filled") else: verified = state in ("open", "filled") notes = "" if verified else f"non verificato (state={state}, trades={len(trades)})" if verified and order_type == "market" and filled < amount - 1e-12: notes = f"FILL PARZIALE: {filled} su {amount}" return Fill(instrument, side, amount, filled, price_f, fee_usdc, order_id, state, verified, notes) # --- ENTRATA --- def open(self, instrument: str, side: str, amount: float, label: str = "tp01-open") -> Fill: """Apre a market (NON reduce_only), entro il cap. Verifica il fill reale.""" return self._submit(instrument, side, amount, reduce_only=False, label=label) # --- USCITA (sempre permessa) --- def close(self, instrument: str, label: str = "tp01-close") -> Fill | None: """Chiude la posizione a market reduce_only. Legge la size reale (USD notional), la converte in base-coin col mark, e flatta. None se gia' flat. Senza cap: si esce sempre.""" pos_usd = self.position_usd(instrument) if abs(pos_usd) < FLAT_USD: return None mark = self.mark_price(instrument) amount = notional_to_amount(instrument, abs(pos_usd), price=mark) side = "sell" if pos_usd > 0 else "buy" return self._submit(instrument, side, amount, reduce_only=True, label=label) # --- RIBILANCIO al target (long-only TP01): apre / riduce / chiude --- def rebalance_to(self, instrument: str, target_notional_usd: float, mark: float, min_usd: float = 5.0) -> list[Fill]: """Porta la posizione su `instrument` al target (USD notional). Long-only: target>=0. - delta < min_usd -> niente (gia' a target); - target ~0 & posizione -> close() (uscita piena, reduce_only); - delta > 0 -> open buy (aumenta); - delta < 0 (resta long) -> sell reduce_only del delta (riduce). Ritorna i Fill eseguiti.""" cur = self.position_usd(instrument) delta = target_notional_usd - cur if abs(delta) < min_usd: return [] if target_notional_usd < FLAT_USD and cur > FLAT_USD: f = self.close(instrument, label="tp01-exit") return [f] if f else [] amount = notional_to_amount(instrument, abs(delta), price=mark) if amount <= 0: return [] if delta > 0: return [self.open(instrument, "buy", amount, label="tp01-buy")] return [self._submit(instrument, "sell", amount, reduce_only=True, label="tp01-reduce")] # --- RIBILANCIO al target CON SEGNO (book TP01+SKH01: long / short / flip) --- def rebalance_signed(self, instrument: str, target_notional_usd: float, mark: float, min_usd: float = 5.0) -> list[Fill]: """Porta la posizione su `instrument` al target NETTO con SEGNO (long-short, a differenza di rebalance_to che e' long-only). Gestisce i flip chiudendo prima e riaprendo dall'altro lato. - |delta| < min_usd -> niente; - flip di segno -> close() (sempre permessa) poi apre dall'altra parte; - target ~0 -> close(); - stesso segno, |target|<|cur| -> REDUCE reduce_only; - apertura/aumento -> open buy/sell (capped dal guardrail apertura). Ritorna i Fill eseguiti.""" cur = self.position_usd(instrument) if abs(target_notional_usd - cur) < min_usd: return [] fills: list[Fill] = [] crossing = (cur > FLAT_USD and target_notional_usd < -FLAT_USD) or \ (cur < -FLAT_USD and target_notional_usd > FLAT_USD) if crossing: # flip: flatta, poi riparti da zero f = self.close(instrument, label="book-flip-close") if f: fills.append(f) cur = 0.0 if abs(target_notional_usd) < FLAT_USD: # target flat -> esci (sempre permessa) if abs(cur) > FLAT_USD: f = self.close(instrument, label="book-exit") if f: fills.append(f) return fills delta = target_notional_usd - cur amount = notional_to_amount(instrument, abs(delta), price=mark) if amount <= 0: return fills same_sign = (target_notional_usd > 0) == (cur > 0) if cur != 0.0 and same_sign and abs(target_notional_usd) < abs(cur): side = "sell" if cur > 0 else "buy" # riduci nello stesso verso, reduce_only fills.append(self._submit(instrument, side, amount, reduce_only=True, label="book-reduce")) else: side = "buy" if target_notional_usd > 0 else "sell" # apri/aumenta verso il target fills.append(self.open(instrument, side, amount, label="book-open")) return fills # --- DISASTER BRACKET (assicurazione on-book per outage; da Old) --- def place_disaster_sl(self, instrument: str, side_held: str, amount: float, stop_price: float, label: str = "disaster-sl") -> Fill: """STOP_MARKET reduce_only LONTANO (~-30%): in operativita' normale non scatta (l'exit della strategia esce prima) -> 0 costo Sharpe; copre gli outage del runner. Trigger sul mark.""" opp = "sell" if side_held == "buy" else "buy" return self._submit(instrument, opp, amount, reduce_only=True, label=label, order_type="stop_market", price=quantize_price(instrument, stop_price)) def cancel_order(self, order_id: str) -> dict: 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). ⚠️ 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 ripiazzamento sollevava, quell'eccezione risaliva fino a `main()` e il giro moriva — cioe' il guasto peggiore (posizione SCOPERTA) si presentava con la stessa faccia di un errore qualunque, e per giunta **saltava l'altro asset**. E' successo davvero il 2026-07-21 alle 09:00 UTC: 502 su `get_positions` dentro `ensure_disaster_sl` su BTC, ed ETH non e' stato nemmeno guardato. Ora: un secondo tentativo immediato, e se anche quello fallisce si ritorna lo stato **`naked`** — distinto da `place-failed` (P5: *distinguere guasti diversi anche quando l'azione e' la stessa*), perche' qui non e' "non sono riuscito a mettere la protezione": e' "**ho tolto la protezione e non sono riuscito a rimetterla**". Il chiamante lo escala al massimo. NB non si inverte l'ordine in *piazza-poi-cancella*: due STOP reduce_only contemporanei sono probabilmente innocui (il secondo diventa no-op a posizione chiusa), ma "probabilmente" 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] if abs(pos) < FLAT_USD: for o in brackets: self.cancel_order(o.get("order_id")) return {"state": "flat", "cancelled": len(brackets)} mark = self.mark_price(instrument) long = pos > 0 want_amount = notional_to_amount(instrument, abs(pos), price=mark) want_stop = disaster_stop_price(instrument, mark, sl_pct, long=long) if len(brackets) == 1: o = brackets[0] amt = float(o.get("amount") or 0) stp = float(o.get("trigger_price") or o.get("stop_price") or o.get("price") or 0) if want_amount and abs(amt - want_amount) < want_amount * 0.1 and stp > 0 \ and abs(stp - want_stop) / want_stop < 0.05: return {"state": "ok", "stop": stp, "amount": amt} cancellati = 0 for o in brackets: # incoerente o multipli -> ricostruisci UN bracket self.cancel_order(o.get("order_id")) cancellati += 1 tentativi: list[str] = [] for _ in range(2): # la posizione e' scoperta da qui: si riprova subito try: f = self.place_disaster_sl(instrument, "buy" if long else "sell", want_amount, want_stop, label=DISASTER_LABEL) return {"state": "placed" if f.verified else "place-failed", "stop": want_stop, "amount": want_amount, "cancelled": cancellati, "notes": f.notes} except Exception as e: # noqa: BLE001 — si registra il motivo (P3), non si ingoia tentativi.append(f"{type(e).__name__}: {e}") return {"state": "naked", "stop": want_stop, "amount": want_amount, "cancelled": cancellati, "notes": (f"bracket cancellati ({cancellati}) e ripiazzamento fallito 2 volte: " + " | ".join(tentativi))} # trade_history / open_orders ereditati da DeribitRead (read-only)