14522262e6
Reset del progetto su fondamenta verificate dopo la scoperta che l'intera libreria "validata OOS" era artefatto di feed contaminato (print fantasma del feed Cerbero TESTNET + storico Binance/USDT). - Storico ricostruito da Deribit MAINNET (ccxt pubblico, tokenless) e CERTIFICATO (certify_feed.py): BTC/ETH puliti su TUTTA la storia (mediana 2-6 bps vs Coinbase USD), integrita' OHLC + coerenza resample (maxΔ 0.00) + cross-venue OK. Alt esclusi (illiquidi/divergenti: LTC/DOGE 50-82% barre flat; XRP/BNB non certificabili). - Verdetto sul feed pulito: FADE / PAIRS / XS01 / TSM01 morti (ogni portafoglio Sharpe -2.3..-3.0, DD ~40%); solo SH01 e frammenti HONEST con segnale residuo, da ri-validare in isolamento. - Cleanup "restart pulito": strategie, stack live (src/live, src/portfolio, runner/executor, yml, docker), ~100 script ricerca/gate, waste/games/ portfolios, dati non certificati + cache e 60+ diari -> archiviati in Old/ (preservati, non cancellati). Diario consolidato in un unico documento. - Skeleton ricerca tenuto: Strategy ABC + indicatori + src/fractal + src/backtest/engine + load_data; tool dati certificati (rebuild_history, certify_feed, audit_feed, multi_source_check). - Universo dati ATTIVO: solo BTC/ETH (5m/15m/1h); guardrail fisico (load_data su alt -> FileNotFoundError). Esecuzione DISABILITATA, conto flat. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
603 lines
32 KiB
Python
603 lines
32 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, 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})")
|