"""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, side: str | None = None) -> float: """Arrotonda il prezzo al tick dello strumento (Decimal, niente artefatti float) — richiesto per gli ordini limit (Deribit rifiuta prezzi off-tick). side (code-review 2026-06-11): per un limit di USCITA il rounding al tick piu' vicino puo' mettere il resting OLTRE il livello sim → un tocco esatto del livello non filla MAI il resting e il gate TP_PHANTOM classificherebbe phantom un tocco genuino, sistematicamente. 'sell' = floor (mai sopra il livello), 'buy' = ceil (mai sotto): il resting e' sempre raggiungibile quando il sim dichiara il tocco (costo: <=1 tick di PnL vs sim).""" import math tick = contract_spec(instrument).get("tick") if not tick or price <= 0: return price n = (math.floor(price / tick) if side == "sell" else math.ceil(price / tick) if side == "buy" else round(price / tick)) return float(n * 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 = "" # amount REALMENTE fillato (order.filled_amount / somma trades) — puo' essere # MINORE del richiesto: un reduce-only cappato dal netting di conto (worker # long e short sullo stesso strumento) viene ridotto in silenzio da Deribit. # Audit 2026-06-11: close 0.105, fillato 0.078, bookato pieno perche' il # ledger usava `amount`. I ledger devono usare QUESTO campo. filled_amount: float = 0.0 def _merge_close_fills(ro: Fill, net: Fill, requested: float) -> Fill: """Combina il reduce-only e il residuo netting in UN esito per il chiamante: filled = somma, prezzo = media pesata sui fill, fee sommate. verified se i contratti riscontrati coprono il richiesto (la stessa soglia del chiamante).""" fa = ro.filled_amount if ro.verified else 0.0 fb = net.filled_amount if net.verified else 0.0 tot = fa + fb parts = [(amt, f.fill_price) for amt, f in ((fa, ro), (fb, net)) if amt > 0 and f.fill_price] px = (sum(a * p for a, p in parts) / sum(a for a, _ in parts)) if parts else None step = contract_spec(ro.instrument).get("step", 0.001) verified = tot >= requested - step / 2 return Fill(ro.instrument, ro.side, ro.requested_notional, requested, px, ro.fee_coin + net.fee_coin, ro.fee_usd + net.fee_usd, net.order_id or ro.order_id, net.order_state or ro.order_state, verified, raw={"reduce_only": ro.raw, "net": net.raw}, notes=(f"netting: reduce-only {fa}/{requested} (oid {ro.order_id}), " f"residuo {fb} market puro ({net.order_state}, oid {net.order_id})"), filled_amount=tot) 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 # Circuit-breaker venue-lock (2026-06-12): durante il lock admin del testnet # (rollback conto, ~09:47) ogni place_order rispondeva 'locked_by_admin' ma i # worker continuavano a tentare APERTURE (leg-fail pairs + unwind + fee # sprecate sui leg parziali). Dopo lock_trip errori 'locked' consecutivi le # aperture sono SOSPESE (Fill failed senza chiamata API -> i worker seguono il # path REAL_OPEN_FAIL/sim_fallback gia' esistente); le CHIUSURE si tentano # SEMPRE (path gia' sicuro: partial/orphan/netting). Riarmo: passato # lock_cooldown_s la prossima apertura fa da probe — se passa il breaker si # resetta (alert di rientro), se e' ancora locked riscatta subito. Stato in # memoria: al restart il primo open rifiutato lo ri-arma. lock_trip: int = 3 lock_cooldown_s: float = 900.0 _lock_streak: int = field(default=0, init=False, repr=False) _lock_until: float = field(default=0.0, init=False, repr=False) # 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 # --- circuit-breaker venue-lock --- def _notify_safe(self, event: str, data: dict): try: from src.live.telegram_notifier import notify_event notify_event(event, data) except Exception: pass def lock_blocked(self) -> bool: """True se le APERTURE sono sospese (breaker scattato e cooldown attivo).""" return self._lock_streak >= self.lock_trip and time.monotonic() < self._lock_until def _lock_track(self, error: str): """Conta gli errori 'locked' consecutivi; al trip sospende le aperture. Ogni nuovo 'locked' (anche dalle chiusure) rinfresca il cooldown: finche' il venue resta bloccato le aperture non riprendono. Gli errori di altra natura NON toccano lo streak (un transitorio di rete non deve ne' armare ne' disarmare il breaker).""" if "locked" not in (error or "").lower(): return self._lock_streak += 1 self._lock_until = time.monotonic() + self.lock_cooldown_s if self._lock_streak == self.lock_trip: print(f"[exec] VENUE_LOCK: {self._lock_streak} reject 'locked' consecutivi " f"-> aperture sospese {self.lock_cooldown_s / 60:.0f}m (probe al termine)") self._notify_safe("VENUE_LOCK", { "reject_consecutivi": self._lock_streak, "cooldown_min": round(self.lock_cooldown_s / 60), "nota": "conto locked (admin/rollback testnet): aperture reali sospese, " "chiusure sempre tentate, sim prosegue"}) def _lock_reset(self): """Ordine accettato dal venue: se il breaker era scattato, dichiara il rientro.""" if self._lock_streak >= self.lock_trip: self._notify_safe("VENUE_LOCK", {"status": "RIENTRATO", "dopo_reject": self._lock_streak}) self._lock_streak = 0 self._lock_until = 0.0 # --- 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: self._lock_track(str(resp.get("error", "")) if isinstance(resp, dict) else "") 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}") self._lock_reset() 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) # amount REALMENTE fillato: order.filled_amount e' la fonte autorevole # (Deribit RIDUCE in silenzio un reduce-only che eccede il netto di conto); # fallback: somma trades del fill, poi trade history filled = float(order.get("filled_amount") or 0) if not filled: filled = sum(float(t.get("amount", 0) or 0) for t in trades) if not filled and th: filled = float(th.get("amount") or 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") notes = "" if verified else f"fill 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} richiesti " "(reduce-only cappato dal netting di conto?)") return Fill(instrument, side, requested_notional, amount, fill_price, fee_coin, fee_usd, order_id, state, verified, raw=resp, notes=notes, filled_amount=filled) def open(self, instrument: str, side: str, notional_usd: float, label: str | None = None) -> Fill: """Apre la quota del worker (market, NON reduce_only). Con breaker venue-lock attivo NON tocca l'API: Fill failed -> il chiamante segue il path REAL_OPEN_FAIL/sim_fallback (per i pairs: entrambe le gambe rifiutate localmente, nessun leg parziale da unwindare).""" if self.lock_blocked(): return Fill(instrument, side, notional_usd, 0.0, None, 0.0, 0.0, None, "error", False, notes="venue_lock_breaker: aperture sospese (conto locked)") 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. Non usa close_position (flatterebbe anche le quote degli altri worker sullo stesso strumento). NETTING (v1.1.25, 2026-06-11): prima tenta il market reduce-only (la sicurezza storica: un bug di stato filla 0 invece di aprire posizioni). Su un conto a NETTING pero' il reduce-only viene CAPPATO o RESPINTO quando un altro worker e' in direzione OPPOSTA sullo stesso strumento (audit 2026-06-11: 3 gambe pairs orfane + 1 close cappato) → il RESIDUO viene rieseguito in MARKET PURO: muove il conto esattamente del delta del libro, cioe' netta contro le quote opposte. La sicurezza persa sul residuo e' coperta dal reconciler orario (reconcile_account.py, alert ACCOUNT_DRIFT). Il chiamante riceve UN Fill combinato (prezzo medio pesato, fee sommate); notes contiene 'netting' quando il fallback e' scattato.""" 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" fill = self._submit(instrument, opp, amount, 0.0, reduce_only=True, label=label) residual = amount - fill.filled_amount # check sulla POLVERE prima di quantizzare: _quantize_step clampa al lotto # minimo, quindi un residuo da artefatto float (1e-17) diventerebbe un # ordine nudo da un lotto intero (code-review 2026-06-11) if residual < spec["step"] / 2: return fill residual = _quantize_step(residual, spec["step"], spec["min"]) # GUARD stato-stantio (code-review 2026-06-11): il residuo NON-reduce-only # e' consentito solo fino al gap (conto reale − libri degli ALTRI worker − # orfani) nella direzione della chiusura. Se la nostra quota non esiste # piu' sul conto (disaster-SL scattato in outage, flatten manuale, stato # stantio al resume) il gap e' ~0 → NIENTE ordine nudo: si ritorna il # parziale onesto (il chiamante alza REAL_CLOSE_PARTIAL). Se il guard non # e' calcolabile (rete) si resta FAIL-SAFE: meglio un orfano tracciato # che una posizione nuda non tracciata. allowed = self._net_close_allowance(instrument, opp, label) if allowed is None or allowed < spec["step"] / 2: fill.notes = (fill.notes + " | " if fill.notes else "") + ( f"netting NEGATO: residuo {residual} ma gap conto-libri " f"{'non calcolabile' if allowed is None else round(allowed, 6)} " "(stato stantio? quota gia' chiusa?)") return fill if allowed < residual: residual = _quantize_step(allowed, spec["step"], spec["min"]) net = self._submit(instrument, opp, residual, 0.0, reduce_only=False, label=f"{label}|net" if label else "net") return _merge_close_fills(fill, net, amount) def _net_close_allowance(self, instrument: str, close_side: str, self_worker: str | None) -> float | None: """Quanto residuo non-reduce-only e' GIUSTIFICATO dai libri: gap firmato fra il conto reale e (libri degli altri worker + orfani registrati), proiettato nella direzione della chiusura. None = non calcolabile.""" try: from src.live.books import real_books, account_net books, orphans = real_books(exclude_worker=self_worker) target = books.get(instrument, 0.0) + orphans.get(instrument, 0.0) pos = account_net(self.client).get(instrument, 0.0) gap = pos - target # quota nostra ancora sul conto (firmata) return max(0.0, gap if close_side == "sell" else -gap) except Exception: return None 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, side=opp) 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: # unwind del FILLATO, non del richiesto: col fallback netting un # amount sovrastimato non viene piu' cappato in silenzio dal # reduce-only — chiuderebbe quota altrui (code-review 2026-06-11) self.leg.close_amount(inst, f.side, f.filled_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})")