"""Esecuzione REALE su Deribit (testnet) con verifica post-ordine e fee reali. Flusso per ogni ordine: 1. converte il notional (USD) in `amount` Deribit, arrotondato allo step del contratto (BTC-PERPETUAL step $10, ETH-PERPETUAL step $1) e clampato al minimo; 2. piazza un market order REALE via Cerbero → Deribit private/buy|sell; 3. RIVERIFICA su Deribit: rilegge get_positions (la posizione esiste con la size giusta?) e get_trade_history (ritrova il fill per order_id) — non si fida della sola risposta dell'ordine; 4. estrae la FEE REALE dai trades[] del fill (per i perp inverse la fee e' nel coin di settlement: BTC/ETH → la convertiamo anche in USD col prezzo di fill). NB perp inverse Deribit: `amount` e la dimensione posizione sono in USD notional; la fee dei trade e' denominata nel base-coin (BTC/ETH). """ from __future__ import annotations import time from dataclasses import dataclass, field from decimal import Decimal from typing import Any from src.live.cerbero_client import CerberoClient # Specifiche contratto (verificate da test.deribit.com/public/get_instrument). # INVERSE (reversed): amount in USD, step in USD (es. BTC $10, ETH $1). # LINEAR (USDC): amount nel base-coin, step nel base-coin (BTC 0.0001, ETH 0.001); # il notional USD si converte col prezzo. Fee/settle in USDC. _CONTRACT: dict[str, dict[str, Any]] = { "BTC-PERPETUAL": {"linear": False, "min": 10.0, "step": 10.0, "tick": 0.5}, "ETH-PERPETUAL": {"linear": False, "min": 1.0, "step": 1.0, "tick": 0.05}, "BTC_USDC-PERPETUAL": {"linear": True, "min": 0.0001, "step": 0.0001, "tick": 0.5, "settle": "USDC"}, "ETH_USDC-PERPETUAL": {"linear": True, "min": 0.001, "step": 0.001, "tick": 0.05, "settle": "USDC"}, # lineari USDC per le gambe dei pairs (PairsExecutionClient, 2026-06-08) "LTC_USDC-PERPETUAL": {"linear": True, "min": 0.1, "step": 0.1, "tick": 0.01, "settle": "USDC"}, "ADA_USDC-PERPETUAL": {"linear": True, "min": 0.2, "step": 0.2, "tick": 1e-05, "settle": "USDC"}, "SOL_USDC-PERPETUAL": {"linear": True, "min": 0.1, "step": 0.1, "tick": 0.001, "settle": "USDC"}, } def contract_spec(instrument: str) -> dict[str, Any]: return _CONTRACT.get(instrument, {"linear": False, "min": 1.0, "step": 1.0}) def register_contract(instrument: str, client) -> dict[str, Any]: """Recupera lo spec di uno strumento USDC da Deribit (get_instruments) e lo registra in _CONTRACT. Fallback per strumenti pair non hardcodati; ritorna lo spec (o il default se non trovato). Idempotente.""" if instrument in _CONTRACT: return _CONTRACT[instrument] try: for i in client.get_instruments(currency="USDC", kind="future", limit=300): if i.get("symbol") == instrument: step = float(i["native"].get("min_trade_amount")) _CONTRACT[instrument] = {"linear": True, "min": step, "step": step, "tick": float(i.get("tick_size") or 0), "settle": "USDC"} return _CONTRACT[instrument] except Exception: pass return contract_spec(instrument) def settlement_currency(instrument: str) -> str: """Inverse → base-coin (BTC/ETH); lineari USDC → USDC. Usato per get_positions e per denominare la fee.""" spec = contract_spec(instrument) if spec.get("settle"): return spec["settle"] return instrument.split("-")[0].split("_")[0] def _quantize_step(value: float, step: float, mn: float) -> float: """Arrotonda `value` al multiplo di `step` SENZA artefatti float (es. 72*0.001 = 0.07200000000000001 → Deribit 'Invalid params').""" n_steps = round(value / step) return float(max(n_steps * Decimal(str(step)), Decimal(str(mn)))) def quantize_price(instrument: str, price: float) -> float: """Arrotonda il prezzo al tick dello strumento (Decimal, niente artefatti float) — richiesto per gli ordini limit (Deribit rifiuta prezzi off-tick).""" tick = contract_spec(instrument).get("tick") if not tick or price <= 0: return price return float(round(price / tick) * Decimal(str(tick))) def notional_to_amount(instrument: str, notional_usd: float, price: float | None = None) -> float: """Notional USD → `amount` Deribit, arrotondato allo step e clampato al minimo. Inverse: amount in USD (step USD). Lineari USDC: amount in base-coin (serve il `price` per convertire). Ritorna 0.0 se sotto mezzo step (niente ordine).""" spec = contract_spec(instrument) step, mn = spec["step"], spec["min"] if spec.get("linear"): if not price: return 0.0 units = notional_usd / price # base-coin richiesti if units < step / 2: return 0.0 return _quantize_step(units, step, mn) if notional_usd < step / 2: return 0.0 return _quantize_step(notional_usd, step, mn) @dataclass class Fill: """Esito verificato di un ordine reale.""" instrument: str side: str # "buy" | "sell" requested_notional: float # USD chiesti dalla strategia amount: float # USD effettivi (arrotondati allo step) fill_price: float | None # prezzo medio di esecuzione (da Deribit) fee_coin: float # fee reale nel coin di settlement (BTC/ETH) fee_usd: float # fee reale convertita in USD (fee_coin * fill_price) order_id: str | None order_state: str | None # "filled" atteso per market verified: bool # posizione/trade riscontrati su Deribit raw: dict[str, Any] = field(default_factory=dict) notes: str = "" def _avg_fill_price(order: dict, trades: list[dict]) -> float | None: p = order.get("average_price") if p: return float(p) # fallback: media pesata per amount dai trade tot_amt = sum(float(t.get("amount", 0) or 0) for t in trades) if tot_amt > 0: return sum(float(t.get("price", 0) or 0) * float(t.get("amount", 0) or 0) for t in trades) / tot_amt return None @dataclass class ExecutionClient: """Wrapper d'esecuzione reale sopra CerberoClient. ogni open/close ritorna un Fill VERIFICATO (o verified=False con la ragione in notes).""" client: CerberoClient = field(default_factory=CerberoClient) verify_polls: int = 4 # tentativi di riverifica verify_sleep: float = 0.6 # attesa fra i poll (s) # Disaster-bracket on-book (2026-06-07): distanza % dello STOP_MARKET reduce-only # piazzato a ogni REAL_OPEN come assicurazione per gli outage del feed/runner # (poll-loop fermo = posizione reale senza valutazione exit). None = disattivo. # Configurato dal runner da overrides.execution.disaster_sl_pct. disaster_sl_pct: float | None = None # NB leva: su Deribit la leva per-strumento NON e' impostabile (private/set_leverage # risponde 400 Bad Request — verificato 2026-06-03 nei log Cerbero; il set_leverage # di Cerbero fallisce sempre, soppresso). Il campo "leverage: 50" in get_positions # e' il MASSIMO dello strumento (informativo): l'esposizione reale la decide la SIZE # dell'ordine, non una leva account. Percio' qui NON si passa leverage per-ordine. # --- helper di verifica --- def _position_size(self, instrument: str) -> float: """Size assoluta (USD) della posizione aperta sull'instrument, 0 se flat.""" cur = settlement_currency(instrument) try: for p in self.client.get_positions(currency=cur): if p.get("instrument") == instrument: return abs(float(p.get("size", 0) or 0)) except Exception: pass return 0.0 def _trade_by_order(self, instrument: str, order_id: str | None) -> dict | None: """Ritrova il fill nel trade history per order_id (fonte autorevole fee).""" if not order_id: return None try: for t in self.client.get_trade_history(limit=50, instrument_name=instrument): if str(t.get("order_id")) == str(order_id): return t except Exception: pass return None # --- API --- def _mark_price(self, instrument: str) -> float | None: try: t = self.client.get_ticker(instrument) return float(t.get("mark_price") or t.get("last_price") or 0) or None except Exception: return None def amount_for(self, instrument: str, notional_usd: float) -> float: """Notional USD → amount Deribit (gestisce inverse/lineare, prezzo per i lineari).""" spec = contract_spec(instrument) price = self._mark_price(instrument) if spec.get("linear") else None return notional_to_amount(instrument, notional_usd, price=price) def _submit(self, instrument: str, side: str, amount: float, requested_notional: float, reduce_only: bool, label: str | None, order_type: str = "market", price: float | None = None) -> Fill: """Ordine REALE (market o limit resting) + parsing del fill. Verifica per-worker basata sul TRADE (order_id/trades), non sulla size netta — lo strumento e' condiviso fra piu' worker e la posizione su Deribit e' aggregata per conto. Per i limit la verifica e' l'ACCETTAZIONE in book (state 'open', o 'filled' se crossa subito).""" spec = contract_spec(instrument) if amount <= 0: return Fill(instrument, side, requested_notional, 0.0, None, 0.0, 0.0, None, None, False, notes="notional sotto il minimo contratto") resp = self.client.place_order(instrument, side, amount, order_type=order_type, price=price, label=label, reduce_only=reduce_only) if not isinstance(resp, dict) or resp.get("state") == "error" or "error" in resp: return Fill(instrument, side, requested_notional, amount, None, 0.0, 0.0, None, "error", False, raw=resp if isinstance(resp, dict) else {}, notes=f"place_order error: {resp}") order = resp.get("order", resp) or {} trades = resp.get("trades", []) or [] order_id = order.get("order_id") state = order.get("order_state") fill_price = _avg_fill_price(order, trades) # fee reale dai trade del fill (coin di settlement) fee_coin = sum(float(t.get("fee", 0) or 0) for t in trades) # riconciliazione su trade history per order_id (fonte autorevole) — # inutile per un limit ancora in book senza fill th = None if order_type == "market" or trades: th = self._trade_by_order(instrument, order_id) if fee_coin == 0 and th and th.get("fee") is not None: fee_coin = float(th["fee"]) if fill_price is None and th: fill_price = float(th.get("price") or 0) or None # lineari USDC: fee gia' in USDC; inverse: nel base-coin → * prezzo fee_usd = fee_coin if spec.get("linear") else ( fee_coin * fill_price if (fee_coin and fill_price) else 0.0) # VERIFICA: market = ordine filled E fill riscontrato (trades o history); # limit = accettato in book ('open') o gia' eseguito ('filled'); # stop_market = trigger accettato ('untriggered' finche' il mark non tocca) if order_type == "market": verified = (state == "filled") and (bool(trades) or th is not None) elif order_type == "stop_market": verified = state in ("untriggered", "open", "filled") else: verified = state in ("open", "filled") return Fill(instrument, side, requested_notional, amount, fill_price, fee_coin, fee_usd, order_id, state, verified, raw=resp, notes="" if verified else f"fill non verificato (state={state}, trades={len(trades)})") def open(self, instrument: str, side: str, notional_usd: float, label: str | None = None) -> Fill: """Apre la quota del worker (market, NON reduce_only).""" amount = self.amount_for(instrument, notional_usd) return self._submit(instrument, side, amount, notional_usd, reduce_only=False, label=label) def close_amount(self, instrument: str, entry_side: str, amount: float, label: str | None = None) -> Fill: """Chiude SOLO la quota del worker: market reduce_only di lato opposto, stesso `amount` dell'apertura. Non usa close_position (flatterebbe anche le quote degli altri worker sullo stesso strumento).""" spec = contract_spec(instrument) # quantizza difensivamente: il chiamante puo' passare un residuo # (amount aperto − fill parziale del TP) con artefatti float amount = _quantize_step(amount, spec["step"], spec["min"]) if amount > 0 else 0.0 opp = "sell" if entry_side == "buy" else "buy" return self._submit(instrument, opp, amount, 0.0, reduce_only=True, label=label) def place_tp_limit(self, instrument: str, entry_side: str, amount: float, tp_price: float, label: str | None = None) -> Fill: """LIMIT reduce-only appoggiato al livello TP (lato opposto all'entrata): il fill reale replica l'assunzione intrabar del backtest (exit AL livello) invece del market-on-poll post-rimbalzo (+235 bps misurati il 2026-06-04). Copre la SOLA quota del worker (stesso `amount` dell'apertura) — lo strumento e' condiviso fra piu' worker. NB: se il prezzo e' gia' oltre il TP il limit crossa e filla subito (state 'filled'); la riconciliazione a valle (resting_fills) lo gestisce come un fill normale.""" opp = "sell" if entry_side == "buy" else "buy" px = quantize_price(instrument, tp_price) if amount <= 0 or px <= 0: return Fill(instrument, opp, 0.0, amount, None, 0.0, 0.0, None, None, False, notes="amount/tp non validi") return self._submit(instrument, opp, amount, 0.0, reduce_only=True, label=label, order_type="limit", price=px) def place_disaster_sl(self, instrument: str, entry_side: str, amount: float, stop_price: float, label: str | None = None) -> Fill: """STOP_MARKET reduce-only LONTANO (disaster bracket ~-30%): assicurazione on-book per gli outage — in operativita' normale non scatta mai (lo SL della strategia esce molto prima, market-on-poll) -> 0 costo Sharpe. Trigger sul MARK price; copre la SOLA quota del worker. Nei crash il fill e' al gap, non al livello: cappa la coda, non la elimina (exit-lab). NB: il set_stop_loss di cerbero_client e' un private/edit Deribit (solo ordini APERTI) -> inutilizzabile su una posizione gia' fillata; il bracket si piazza come ordine trigger autonomo.""" opp = "sell" if entry_side == "buy" else "buy" px = quantize_price(instrument, stop_price) if amount <= 0 or px <= 0: return Fill(instrument, opp, 0.0, amount, None, 0.0, 0.0, None, None, False, notes="amount/stop non validi") return self._submit(instrument, opp, amount, 0.0, reduce_only=True, label=label, order_type="stop_market", price=px) def cancel_order(self, order_id: str) -> dict: """Cancella un ordine resting. {'state': 'cancelled'} su successo; 'error' se l'ordine non e' piu' open (es. gia' fillato) — NON fatale: il chiamante riconcilia i fill via resting_fills (trade history).""" if not order_id: return {"state": "error", "error": "no order_id"} try: return self.client.cancel_order(order_id) except Exception as exc: return {"state": "error", "error": str(exc)} def resting_fills(self, instrument: str, order_id: str) -> tuple[float, float | None, float]: """Fill (anche parziali) di un ordine resting, riconciliati dal trade history per order_id (fonte autorevole, come per i market). Ritorna (amount_fillato, prezzo_medio, fee_usd).""" if not order_id: return 0.0, None, 0.0 spec = contract_spec(instrument) try: trades = [t for t in self.client.get_trade_history( limit=100, instrument_name=instrument) if str(t.get("order_id")) == str(order_id)] except Exception: return 0.0, None, 0.0 amt = sum(float(t.get("amount", 0) or 0) for t in trades) if amt <= 0: return 0.0, None, 0.0 avg = sum(float(t.get("price", 0) or 0) * float(t.get("amount", 0) or 0) for t in trades) / amt fee_coin = sum(float(t.get("fee", 0) or 0) for t in trades) fee_usd = fee_coin if spec.get("linear") else (fee_coin * avg if avg else 0.0) return amt, avg or None, fee_usd def close(self, instrument: str, label: str | None = None) -> Fill: """Chiude a mercato la posizione e riverifica che il conto sia flat, leggendo la fee di chiusura dal trade history.""" side = "close" resp = self.client.close_position(instrument) if not isinstance(resp, dict) or resp.get("state") == "error" or "error" in resp: return Fill(instrument, side, 0.0, 0.0, None, 0.0, 0.0, None, "error", False, raw=resp if isinstance(resp, dict) else {}, notes=f"close error: {resp}") order_id = resp.get("order_id") # fee/prezzo di chiusura dal trade history (close_position non li ritorna) th = self._trade_by_order(instrument, order_id) fee_coin = float(th["fee"]) if th and th.get("fee") is not None else 0.0 fill_price = float(th.get("price")) if th and th.get("price") else None if contract_spec(instrument).get("linear"): fee_usd = fee_coin else: fee_usd = fee_coin * fill_price if (fee_coin and fill_price) else 0.0 # verifica: la posizione deve essere tornata flat pos = 1.0 for _ in range(self.verify_polls): pos = self._position_size(instrument) if pos == 0: break time.sleep(self.verify_sleep) verified = pos == 0 return Fill(instrument, side, 0.0, 0.0, fill_price, fee_coin, fee_usd, order_id, resp.get("state"), verified, raw=resp, notes="" if verified else f"posizione non flat dopo close (pos={pos})") @dataclass class PairFill: """Esito verificato di un'apertura/chiusura a 2 GAMBE.""" verified: bool # entrambe le gambe eseguite e verificate leg_a: Fill leg_b: Fill unwound: bool = False # true se una gamba e' fallita e l'altra e' stata richiusa notes: str = "" @dataclass class PairsExecutionClient: """Esecuzione REALE a 2 gambe (shadow) per i pairs market-neutral su Deribit. Compone un ExecutionClient single-leg: apre/chiude le due gambe (long A / short B o viceversa) come market reduce_only-aware, con gestione del LEG-RISK: - open_pair: piazza entrambe; se UNA sola filla -> UNWIND (richiude la fillata reduce-only) per non restare con esposizione direzionale netta -> verified=False. - close_pair: chiude entrambe reduce-only (market); ritorna fee e prezzi reali. Strumenti = lineari USDC (payoff lineare == matematica del backtest a 2 gambe; fee in USDC). amount per gamba arrotondato allo step del rispettivo strumento (doppio arrotondamento: piccolo sbilanciamento di notional inevitabile, riportato). """ leg: "ExecutionClient" = field(default_factory=ExecutionClient) def __post_init__(self): # registra gli spec USDC degli strumenti pair non hardcodati (LTC/ADA/SOL ci sono; # questo copre eventuali coppie future) self.client = self.leg.client def ensure_specs(self, *instruments: str): for inst in instruments: register_contract(inst, self.client) def open_pair(self, inst_a: str, inst_b: str, direction: int, notional_usd: float, label: str | None = None) -> PairFill: """direction +1 = long A / short B; -1 = short A / long B. notional uguale per gamba.""" self.ensure_specs(inst_a, inst_b) side_a = "buy" if direction == 1 else "sell" side_b = "sell" if direction == 1 else "buy" fa = self.leg.open(inst_a, side_a, notional_usd, label=label) fb = self.leg.open(inst_b, side_b, notional_usd, label=label) if fa.verified and fb.verified: return PairFill(True, fa, fb) # LEG-RISK: una sola gamba (o nessuna) verificata -> unwind la fillata unwound = False for f, inst in ((fa, inst_a), (fb, inst_b)): if f.verified and f.amount > 0: self.leg.close_amount(inst, f.side, f.amount, label=label) unwound = True return PairFill(False, fa, fb, unwound=unwound, notes=f"leg-fail (a={fa.verified} b={fb.verified}), unwound={unwound}") def close_pair(self, inst_a: str, inst_b: str, side_a: str, side_b: str, amount_a: float, amount_b: float, label: str | None = None) -> PairFill: """Chiude entrambe le gambe a mercato (reduce-only del lato opposto all'entrata). Ritorna PairFill con i Fill di chiusura (fee/prezzi reali). verified = entrambe chiuse.""" ca = self.leg.close_amount(inst_a, side_a, amount_a, label=label) cb = self.leg.close_amount(inst_b, side_b, amount_b, label=label) return PairFill(ca.verified and cb.verified, ca, cb, notes="" if (ca.verified and cb.verified) else f"close parziale (a={ca.verified} b={cb.verified})")