c655604131
PairsExecutionClient (compone ExecutionClient): open_pair (2 market long A/short B, verifica per-gamba, LEG-RISK unwind se una sola filla), close_pair (2 reduce-only via close_amount, MAI close_position), register_contract (fetch spec USDC). Spec LTC/ADA/SOL aggiunti. PairsWorker: ledger reale shadow a 2 gambe resume-safe (_real_open_pair/ _real_close_pair, PnL per gamba dir A=+d/B=-d, doppio arrotondamento riportato). Runner: pairs_executor gated su execution.pairs_enabled (false di default). Validazione: test 92/92 (open/close, leg-risk unwind, resume) + smoke testnet end-to-end (open 2 gambe verificate, close reduce-only, PnL reale -0.039 vs sim -0.036, conto flat). Smoke ora aborta se ci sono posizioni di produzione + usa solo close_amount. NB incidente testnet documentato (diario): pulizia manuale con close_position ha flattato le quote shadow dei fade sul conto condiviso -> auto-riconciliazione al prossimo close. Lezione: mai close_position su strumenti condivisi. pairs_enabled resta FALSE: accendere con finestra a conto flat + osservazione. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
434 lines
22 KiB
Python
434 lines
22 KiB
Python
"""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})")
|