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:
+88
-14
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user