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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user