"""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})")
