chore(reset): v2.0.0 — storico certificato Deribit mainnet, ripartenza pulita

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>
This commit is contained in:
Adriano Dal Pastro
2026-06-19 15:16:03 +00:00
parent 8401a280b9
commit 14522262e6
383 changed files with 1971 additions and 779 deletions
-602
View File
@@ -1,602 +0,0 @@
"""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})")