feat(live): esecuzione REALE su Deribit testnet (shadow) per i 6 fade sui lineari USDC
- ExecutionClient: notional->amount (lineare USDC + inverse), open/close_amount reduce-only, verifica sul trade (order_id), fee reali lette dai trades[] - CerberoClient: place_order market + reduce_only, get_trade_history - StrategyWorker: shadow (REAL_OPEN/REAL_CLOSE accanto al sim), ledger reale parallelo persistito, confronto slippage/fee sim-vs-reale - runner+portfolios.yml: config execution (6 fade MR01/MR02/MR07 x BTC/ETH su BTC_USDC/ETH_USDC-PERPETUAL), capitale 2000 - smoke: live_exec_smoke (layer) + live_shadow_smoke (catena worker), provati su testnet Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
"""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 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},
|
||||
"ETH-PERPETUAL": {"linear": False, "min": 1.0, "step": 1.0},
|
||||
"BTC_USDC-PERPETUAL": {"linear": True, "min": 0.0001, "step": 0.0001, "settle": "USDC"},
|
||||
"ETH_USDC-PERPETUAL": {"linear": True, "min": 0.001, "step": 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 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 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 max(round(units / step) * step, mn)
|
||||
if notional_usd < step / 2:
|
||||
return 0.0
|
||||
return max(round(notional_usd / step) * 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)
|
||||
|
||||
# --- 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) -> Fill:
|
||||
"""Market order REALE + 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."""
|
||||
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="market",
|
||||
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)
|
||||
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 esecuzione = ordine filled E fill riscontrato (trades o trade history)
|
||||
verified = (state == "filled") and (bool(trades) or th is not None)
|
||||
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)."""
|
||||
opp = "sell" if entry_side == "buy" else "buy"
|
||||
return self._submit(instrument, opp, amount, 0.0,
|
||||
reduce_only=True, label=label)
|
||||
|
||||
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})")
|
||||
Reference in New Issue
Block a user