feat(exec): gate TP_PHANTOM — il tocco TP va confermato dal book reale, non dal feed

Il feed testnet stampa wick che 'toccano' il TP intrabar senza che il prezzo
abbia mai scambiato al livello: il sim bookava +4% fantasma a bars_held=0 e
_real_close chiudeva A MERCATO una posizione col resting TP a zero fill
(-fee/spread a giro, 14 giri l'11-06). Ora StrategyWorker._tp_phantom sopprime
l'exit take_profit quando: tocco sim + resting LIMIT zero-fill + prezzo corrente
che non ha raggiunto il livello (il limit sul book reale e' l'oracolo del tocco).
Zero parametri (verita' d'esecuzione, non filtro di strategia); SL close-confirm
e max_bars restano attivi nello stesso tick; fill parziale/prezzo oltre il
livello/worker non eseguito/errore rete -> comportamento storico. Log TP_PHANTOM
dedup per barra + alert Telegram una tantum. 5 test nuovi (104 passed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-11 19:32:24 +00:00
parent 15520dda70
commit 12625b3315
4 changed files with 163 additions and 9 deletions
+41 -4
View File
@@ -71,6 +71,8 @@ class StrategyWorker:
self.real_dsl_order_id = "" # STOP_MARKET disaster bracket on-book (persistito)
self.real_trades = 0
self.real_first_notified = False # alert Telegram "esecuzione viva" una tantum
self._tp_phantom_ts = 0 # dedup log TP_PHANTOM per barra (non persistito)
self._tp_phantom_notified = False # alert Telegram una tantum per processo
self.worker_id = f"{strategy.name}__{asset}__{tf}"
self.work_dir = data_dir / self.worker_id
@@ -448,6 +450,41 @@ class StrategyWorker:
# applied: fill reali presenti (parts) o chiusura comunque verificata
return real_pnl, bool(parts) or verified
def _tp_phantom(self, current_price: float, current_ts: int) -> bool:
"""TP "toccato" solo nel feed? Il LIMIT resting sul book REALE e' l'oracolo:
se il prezzo avesse davvero scambiato al livello si sarebbe fillato (almeno
in parte). Tocco sim + resting a zero fill + prezzo corrente che NON ha
raggiunto il TP = spike-print del feed (testnet, 2026-06-11: 14 giri fantasma
su ETH, sim +4% l'uno, reale fee/spread l'uno) → exit SOPPRESSA, la posizione
continua il suo ciclo normale (SL close-confirm e max_bars restano attivi).
Zero parametri: e' un check di verita' d'esecuzione, non un filtro di
strategia; sui worker senza esecuzione reale ritorna False (parita' storica).
Fail-open: se la query fill fallisce (rete) si chiude come prima."""
if not (self.execution_enabled and self.real_in_position
and self.real_tp_order_id and self.tp):
return False
# prezzo gia' oltre il livello → tocco genuino anche senza fill (gap fra poll)
if (self.direction == 1 and current_price >= self.tp) or \
(self.direction == -1 and current_price <= self.tp):
return False
try:
tp_amt, _, _ = self.executor.resting_fills(self.exec_instrument,
self.real_tp_order_id)
except Exception:
return False
if tp_amt > 0:
return False
if current_ts != self._tp_phantom_ts:
self._tp_phantom_ts = current_ts
data = {"tp": self.tp, "price": current_price, "direction": self.direction,
"tp_order_id": self.real_tp_order_id,
"note": "wick solo nel feed (resting zero-fill, prezzo lontano dal livello) -> exit soppressa"}
self._log("TP_PHANTOM", data)
if not self._tp_phantom_notified:
self._tp_phantom_notified = True
self._notify("TP_PHANTOM", data)
return True
def _close_position(self, current_price: float, reason: str):
if not self.in_position:
return
@@ -549,14 +586,14 @@ class StrategyWorker:
if not np.isfinite(buf):
buf = 0.0
if self.direction == 1:
if bar_high >= self.tp:
if bar_high >= self.tp and not self._tp_phantom(current_price, current_ts):
self._close_position(self.tp, "take_profit")
elif confirm_close < self.sl - buf:
self._close_position(current_price, "stop_loss")
elif self.max_bars and self.bars_held >= self.max_bars:
self._close_position(current_price, "time_limit")
else:
if bar_low <= self.tp:
if bar_low <= self.tp and not self._tp_phantom(current_price, current_ts):
self._close_position(self.tp, "take_profit")
elif confirm_close > self.sl + buf:
self._close_position(current_price, "stop_loss")
@@ -568,14 +605,14 @@ class StrategyWorker:
if self.direction == 1:
if bar_low <= self.sl:
self._close_position(self.sl, "stop_loss")
elif bar_high >= self.tp:
elif bar_high >= self.tp and not self._tp_phantom(current_price, current_ts):
self._close_position(self.tp, "take_profit")
elif self.max_bars and self.bars_held >= self.max_bars:
self._close_position(current_price, "time_limit")
else:
if bar_high >= self.sl:
self._close_position(self.sl, "stop_loss")
elif bar_low <= self.tp:
elif bar_low <= self.tp and not self._tp_phantom(current_price, current_ts):
self._close_position(self.tp, "take_profit")
elif self.max_bars and self.bars_held >= self.max_bars:
self._close_position(current_price, "time_limit")