feat(live): TP reale = LIMIT reduce-only al livello (fix +235bps slippage exit)

All'apertura reale il worker piazza un limit reduce-only al TP della strategia
(quota del SOLO worker, prezzo quantizzato al tick); alla chiusura sim cancella
il resting, riconcilia i fill (anche parziali) via get_trade_history per
order_id e chiude a market solo il residuo. real_tp_order_id persistito
(resume-safe). SL resta market-on-poll (deliberato: trigger Deribit = nuovo
order_id al trigger, fill non verificabile; e il rimbalzo sul SL lavora a
favore). Smoke testnet 2 scenari OK (resting+cancel / fill immediato).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-04 19:24:07 +00:00
parent e8075b6292
commit abd396fa54
6 changed files with 264 additions and 34 deletions
+6
View File
@@ -116,6 +116,12 @@ class CerberoClient:
payload["reduce_only"] = True
return self._post("/mcp-deribit/tools/place_order", payload)
def cancel_order(self, order_id: str) -> dict:
"""Cancella un ordine resting (es. limit reduce-only al TP). Ritorna
{order_id, state}; state='error' se l'ordine non e' piu' open (gia'
fillato/cancellato) — il chiamante riconcilia via get_trade_history."""
return self._post("/mcp-deribit/tools/cancel_order", {"order_id": order_id})
def close_position(self, instrument: str) -> dict:
return self._post("/mcp-deribit/tools/close_position", {"instrument_name": instrument})
+88 -14
View File
@@ -27,10 +27,10 @@ from src.live.cerbero_client import CerberoClient
# 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"},
"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"},
}
@@ -54,6 +54,15 @@ def _quantize_step(value: float, step: float, mn: float) -> float:
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.
@@ -157,17 +166,20 @@ class ExecutionClient:
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."""
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="market",
label=label, reduce_only=reduce_only)
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 {},
@@ -181,8 +193,11 @@ class ExecutionClient:
# 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)
# 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:
@@ -191,8 +206,12 @@ class ExecutionClient:
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)
# VERIFICA: market = ordine filled E fill riscontrato (trades o history);
# limit = accettato in book ('open') o gia' eseguito ('filled')
if order_type == "market":
verified = (state == "filled") and (bool(trades) or th is not None)
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)})")
@@ -209,10 +228,65 @@ class ExecutionClient:
"""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 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."""
+82 -9
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import json
import time
from datetime import datetime, timezone
from pathlib import Path
@@ -54,6 +55,7 @@ class StrategyWorker:
self.real_entry_fee_usd = 0.0
self.real_entry_notional = 0.0 # USD effettivi esposti all'entrata
self.real_order_id = ""
self.real_tp_order_id = "" # LIMIT reduce-only resting al TP (persistito per il resume)
self.real_trades = 0
self.real_first_notified = False # alert Telegram "esecuzione viva" una tantum
@@ -116,6 +118,7 @@ class StrategyWorker:
self.real_entry_fee_usd = state.get("real_entry_fee_usd", 0.0)
self.real_entry_notional = state.get("real_entry_notional", 0.0)
self.real_order_id = state.get("real_order_id", "")
self.real_tp_order_id = state.get("real_tp_order_id", "")
self.real_trades = state.get("real_trades", 0)
self.real_first_notified = state.get("real_first_notified", False)
@@ -148,6 +151,7 @@ class StrategyWorker:
"real_entry_fee_usd": self.real_entry_fee_usd,
"real_entry_notional": self.real_entry_notional,
"real_order_id": self.real_order_id,
"real_tp_order_id": self.real_tp_order_id,
"real_trades": self.real_trades,
"real_first_notified": self.real_first_notified,
"last_update": datetime.now(timezone.utc).isoformat(),
@@ -234,22 +238,87 @@ class StrategyWorker:
if not self.real_first_notified: # conferma una-tantum: l'esecuzione reale e' viva
self._notify("REAL_EXEC_LIVE", data)
self.real_first_notified = True
self._place_real_tp()
else:
self._log("REAL_OPEN_FAIL", {**data, "note": fill.notes})
self._notify("REAL_OPEN_FAIL", {**data, "note": fill.notes})
def _place_real_tp(self):
"""LIMIT reduce-only appoggiato al TP della strategia (fix divergenza
sim/reale 2026-06-04: il market-on-poll usciva post-rimbalzo, +235 bps
sopra il livello TP). Copre la SOLA quota del worker. Se il piazzamento
fallisce si resta sul fallback market-on-poll di _real_close."""
self.real_tp_order_id = ""
if not (self.tp and self.real_amount > 0):
return
rest = self.executor.place_tp_limit(self.exec_instrument, self.real_side,
self.real_amount, self.tp,
label=self.worker_id)
data = {
"instrument": self.exec_instrument,
"order_id": rest.order_id,
"tp": round(self.tp, 2),
"amount": self.real_amount,
"state": rest.order_state,
}
if rest.verified and rest.order_id:
self.real_tp_order_id = rest.order_id
self._log("REAL_TP_RESTING", data)
else:
self._log("REAL_TP_FAIL", {**data, "note": rest.notes})
def _real_close(self, sim_exit: float, reason: str, sim_pnl: float):
"""Chiusura REALE (reduce-only della quota worker) + confronto col sim."""
"""Chiusura REALE (reduce-only della quota worker) + confronto col sim.
Prima riconcilia l'eventuale LIMIT resting al TP: lo cancella (innocuo
se gia' fillato — cosi' nessun fill puo' arrivare DOPO la lettura) e
legge i fill reali dal trade history per order_id; solo la quota residua
viene chiusa a mercato (fallback, o exit non-TP: stop-loss/time_limit).
L'uscita take-profit reale avviene cosi' AL livello come nel backtest,
non al poll post-rimbalzo."""
if not self.real_in_position:
return
fill = self.executor.close_amount(self.exec_instrument, self.real_side,
self.real_amount, label=self.worker_id)
exit_price = fill.fill_price or sim_exit
from src.live.execution import contract_spec
step = contract_spec(self.exec_instrument)["step"]
# 1) ordine TP resting: cancella, poi riconcilia i fill (order_id su history)
tp_amt, tp_px, tp_fee = 0.0, None, 0.0
tp_order_id = self.real_tp_order_id
if tp_order_id:
cres = self.executor.cancel_order(tp_order_id)
cancelled = cres.get("state") == "cancelled"
for _ in range(self.executor.verify_polls):
tp_amt, tp_px, tp_fee = self.executor.resting_fills(
self.exec_instrument, tp_order_id)
if tp_amt > 0 or cancelled:
break # cancel pulito = al piu' fill parziali gia' visti
time.sleep(self.executor.verify_sleep)
tp_amt = min(tp_amt, self.real_amount)
if tp_amt > 0 and not tp_px:
tp_px = self.tp or sim_exit # fallback: il limit filla al suo livello
# 2) quota residua → market reduce-only (mai close_position: strumento condiviso)
remainder = self.real_amount - tp_amt
fill = None
if remainder >= step / 2:
fill = self.executor.close_amount(self.exec_instrument, self.real_side,
remainder, label=self.worker_id)
market_amt = fill.amount if (fill and fill.verified) else 0.0
# 3) prezzo d'uscita combinato (media pesata TP-fill + market) e fee totali
parts = [(a, p) for a, p in ((tp_amt, tp_px),
(market_amt, fill.fill_price if fill else None))
if a > 0 and p]
exit_price = (sum(a * p for a, p in parts) / sum(a for a, _ in parts)
if parts else sim_exit)
exit_fee = tp_fee + (fill.fee_usd if fill else 0.0)
verified = (tp_amt + market_amt) >= self.real_amount - step / 2
rdir = 1 if self.real_side == "buy" else -1
price_change = (exit_price - self.real_entry_price) / self.real_entry_price \
if self.real_entry_price else 0.0
real_gross = rdir * price_change * self.real_entry_notional
real_fees = self.real_entry_fee_usd + fill.fee_usd
real_fees = self.real_entry_fee_usd + exit_fee
real_pnl = real_gross - real_fees
self.real_capital += real_pnl
self.real_trades += 1
@@ -258,16 +327,19 @@ class StrategyWorker:
if exit_price and sim_exit else None)
self._log("REAL_CLOSE", {
"reason": reason,
"order_id": fill.order_id,
"order_id": fill.order_id if fill else tp_order_id,
"tp_order_id": tp_order_id or None,
"tp_filled_amount": tp_amt,
"market_amount": market_amt,
"sim_exit": round(sim_exit, 2),
"real_fill": fill.fill_price,
"real_fill": round(exit_price, 2) if parts else None,
"slippage_bps": round(slip_bps, 2) if slip_bps is not None else None,
"entry_fee_usd": round(self.real_entry_fee_usd, 5),
"exit_fee_usd": round(fill.fee_usd, 5),
"exit_fee_usd": round(exit_fee, 5),
"real_pnl_usd": round(real_pnl, 4),
"sim_pnl_usd": round(sim_pnl, 4),
"real_capital": round(self.real_capital, 4),
"verified": fill.verified,
"verified": verified,
})
self.real_in_position = False
@@ -277,6 +349,7 @@ class StrategyWorker:
self.real_entry_fee_usd = 0.0
self.real_entry_notional = 0.0
self.real_order_id = ""
self.real_tp_order_id = ""
def _close_position(self, current_price: float, reason: str):
if not self.in_position: