feat(worker): guard TP-invertito — sopprime l'entry quando il TP e' gia' sfondato (wick-print)

Un wick transitorio fa calcolare un tp dal lato sbagliato dell'entry (donchian: segnale
su barra wickata, entry al prezzo recuperato oltre il proprio tp) -> l'exit intrabar
scatta a bars_held=0 in perdita (16-06: 8 giri MR02_BTC 15m, sim -17.9 / reale -2.3).
TP_PHANTOM non lo prende (niente resting oracle, prezzo oltre il livello). Gate
zero-parametri in StrategyWorker._open_position, solo path live. Test + diario + CLAUDE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-16 14:16:13 +00:00
parent ef797f1ff0
commit b576ee66ac
4 changed files with 187 additions and 4 deletions
+13
View File
@@ -502,6 +502,19 @@ queste fade, ma va confermato col paper trader live prima di rischiare capitale
Fill (anche parziale) o prezzo oltre il livello o worker non eseguito → comportamento
storico. Fail-open su errori di rete. Log `TP_PHANTOM` dedup per barra + alert Telegram.
Test `tests/portfolio/test_tp_phantom.py`.
- **INVERTED_TP_SKIP — l'entry con TP dal lato sbagliato va soppressa (v1.1.33, 2026-06-16).**
Un wick transitorio sul feed puo' far calcolare alla strategia un `tp` dal lato SBAGLIATO
dell'entry (es. donchian: segnale su barra wickata in basso → tp=centro canale, entry al
prezzo RECUPERATO sopra il proprio tp). L'exit intrabar `bar_high>=tp` (long)/`bar_low<=tp`
(short) scatta a `bars_held=0` in PERDITA, con churn di fee e TP reduce-only respinti
(16-06: 8 giri MR02_BTC 15m, sim 17.9 / reale 2.3 grazie al real-truth; TP_PHANTOM NON
lo prende — niente resting oracle, il prezzo HA superato il livello). Gate in
`StrategyWorker._open_position` (zero parametri, verita' d'esecuzione): se `tp<=entry`
(long) / `tp>=entry` (short) → **entry soppressa** (niente ordine reale/disaster-SL), log
`INVERTED_TP_SKIP` dedup per-barra + alert Telegram. Solo path live (il backtest entra al
close del bar del segnale → mai invertito, resta non filtrato → live meglio del backtest).
Cerotto testnet (il fix vero e' mainnet). Test `tests/portfolio/test_inverted_tp.py`,
diario `docs/diary/2026-06-16-inverted-tp-guard.md`.
- **TP reale = LIMIT reduce-only AL LIVELLO (2026-06-04).** Misurati +235 bps di slippage medio sulle uscite take-profit market-on-poll (sim esce al livello intrabar, il reale chiudeva al poll post-rimbalzo: sim +11.85 vs reale +0.62 USD sui primi 7 close). Fix: a ogni `REAL_OPEN` il worker piazza un **limit reduce-only al TP** (`ExecutionClient.place_tp_limit`, prezzo quantizzato al tick, SOLA quota del worker) → `REAL_TP_RESTING`; a ogni chiusura sim `_real_close` **cancella il resting → riconcilia i fill (anche parziali) via `get_trade_history` per order_id → market reduce-only solo del residuo** → ledger su prezzo combinato. `real_tp_order_id` persistito in `status.json` (resume-safe). Lo **SL resta market-on-poll** (deliberato: i trigger Deribit generano un nuovo order_id al trigger → fill non verificabile per order_id; e sul SL il rimbalzo lavora a favore). Fill da resting = fee **maker ~0%**. Smoke: `live_shadow_smoke.py` (2 scenari, testnet). Diario `docs/diary/2026-06-04-shadow-divergence.md`.
- **position_size per-famiglia (2026-06-07).** `portfolios.yml` accetta `position_size_family`
(chiave = `weighting.family_of`); plumbing `runner.pos_for_spec`. **PAIRS a 0.20** (esposizione
@@ -0,0 +1,66 @@
# 2026-06-16 — Guard TP-invertito (churn MR02_BTC 15m)
## Sintomo
Burst di **8 trade in 15 minuti** su `MR02_donchian_fade__BTC__15m` (09:0009:15 UTC),
tutti `long`, tutti chiusi `take_profit` a `bars_held=0-1` **in perdita**, accuracy
crollata 60%→27%. TP **fisso** a 66189.25, **sotto** ogni entry (6670066834):
```
09:00 OPEN long 66780.5 tp=66189.25 sl=64218.21 ← tp E sl ENTRAMBI sotto l'entry
09:02 CLOSE take_profit exit=66189.25 held=0 sim=-2.63 real=-0.12
... ×8 ... +7× REAL_TP_FAIL: invalid_reduce_only_order
```
I trade MR02 sani prima del cluster hanno il TP dal lato giusto (long → tp>entry:
65033>63414, 64148>63775, 66079>65717). Il cluster delle 09:00 è l'unico **invertito**.
## Causa radice — segnale da wick transitorio (NON feed congelato, NON spike d'ingresso)
BTC alle 09:00 era **davvero** ~66780 (MR01 short@66919, MR07 short@66780.5 nello stesso
istante, slippage reale 0.72.5 bps → feed sano). Il difetto è a monte:
1. Una barra con **wick verso il basso** (~64218 = il livello SL) fa scattare il donchian
"rottura canale basso" → segnale **LONG**, con `tp`=centro canale (66189) e `sl`=64218
calcolati **dal contesto wickato**.
2. Il wick rientra; il worker entra `long` al prezzo **recuperato** 66780 — cioè **sopra
il proprio TP=66189**.
3. L'exit intrabar (`bar_high >= tp`) è banalmente vera all'istante → chiude "take_profit"
a 66189 = **perdita garantita a held=0**. Si ripete 8× perché il livello donchian
wickato resta nel lookback.
Differenza chiave da TP_PHANTOM: lì il wick è sull'**exit** (tocco fantasma del TP);
qui il wick produce un **tp dal lato sbagliato dell'entry** → la posizione non doveva
proprio aprirsi.
## Perché i guard esistenti non l'hanno preso
- **TP_PHANTOM** richiede un limit reduce-only resting come oracolo. Qui il limit è
**respinto** (`invalid_reduce_only_order`: una sell reduce-only a 66189 sotto il mercato
è invalida) → niente `real_tp_order_id` → fail-open → l'exit passa. E il prezzo corrente
(66780) ha **realmente** superato il livello → non è un tocco fantasma.
- **Freeze-gate (v1.1.32)**: BTC non è congelato (prezzi che si muovono) → non scatta.
## Danno reale: contenuto (il real-truth ledger ha funzionato)
- Σ **reale** del cluster ≈ **−€2.29** (solo slippage+fee: il fill reale è al prezzo vero
~66700-66730, non al TP fantasma).
- Σ **sim** sarebbe stato ≈ **−€17.9** → il real-truth ledger ha evitato che ~**€15.6** di
perdita fantasma colpisse l'equity.
- Costi collaterali: 8 round-trip di fee, 7 TP reduce-only respinti, 8 disaster-SL da
piazzare/cancellare, sim accuracy/report inquinati.
- Auto-esaurito alle 09:15 (sleeve poi flat).
## Fix — guard "TP invertito" (v1.1.33, zero parametri)
`StrategyWorker._open_position`: **prima** di aprire, se il TP è già sfondato all'ingresso
(`long & tp<=entry` oppure `short & tp>=entry`) il segnale è malformato (artefatto wick)
**NON si apre** (niente ordine reale, niente disaster-SL). Verità d'esecuzione, non un
filtro di strategia (stessa filosofia di TP_PHANTOM). Log `INVERTED_TP_SKIP` (dedup
per-barra) + alert Telegram una-tantum per processo. Agisce **solo sul path live**: il
backtest canonico entra al close del bar del segnale (entry == base di tp/sl → mai
invertito) e resta non filtrato → il live farà meglio del backtest, come loss-guard /
trend / TP_PHANTOM.
Test: `tests/portfolio/test_inverted_tp.py` (long/short invertiti skippati, controlli
positivi long/short validi aprono, no-tp apre, dedup per-barra). Suite portfolio 146/146.
## Lezione
Cerotto testnet (il feed wicka): il fix vero è **mainnet**, dove l'arbitraggio elimina i
wick-print. Ma il guard è corretto in assoluto — un segnale con TP dal lato sbagliato
dell'entry non va mai eseguito, su qualsiasi venue.
+32 -4
View File
@@ -80,6 +80,8 @@ class StrategyWorker:
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._tp_phantom_cache = (0, 0.0) # (bar_ts, monotonic): TTL del verdetto phantom
self._inverted_tp_ts = 0 # dedup log INVERTED_TP_SKIP per barra
self._inverted_tp_notified = False # alert Telegram una tantum per processo
self.worker_id = f"{strategy.name}__{asset}__{tf}"
self.work_dir = data_dir / self.worker_id
@@ -236,7 +238,34 @@ class StrategyWorker:
enriched = {"worker": self.worker_id, **(data or {})}
notify_event(event, enriched)
def _open_position(self, signal: Signal, current_price: float):
def _open_position(self, signal: Signal, current_price: float, current_ts: int = 0):
meta = signal.metadata or {}
tp = float(meta.get("tp", 0.0) or 0.0)
# GUARD TP-invertito (2026-06-16): un wick transitorio puo' far calcolare alla
# strategia un tp dal lato SBAGLIATO dell'entry (es. donchian: segnale su barra
# wickata, entry al prezzo recuperato gia' oltre il proprio tp) -> l'exit intrabar
# `bar_high>=tp` (long) / `bar_low<=tp` (short) scatta a bars_held=0 in PERDITA,
# con churn di fee e TP reduce-only respinti (16-06: 8 giri MR02_BTC 15m, sim
# -17.9 / reale -2.3). Verita' d'esecuzione, zero parametri: se il TP e' gia'
# sfondato all'ingresso il segnale e' malformato -> NON apriamo. (cerotto testnet:
# il fix vero e' mainnet, dove l'arbitraggio elimina i wick-print.)
if tp and ((signal.direction == 1 and tp <= current_price) or
(signal.direction == -1 and tp >= current_price)):
data = {
"direction": "long" if signal.direction == 1 else "short",
"price": round(current_price, 2),
"tp": round(tp, 2),
"note": "TP gia' sfondato all'ingresso (wick-print) -> entry soppressa",
}
if current_ts != self._inverted_tp_ts:
self._inverted_tp_ts = current_ts
self._log("INVERTED_TP_SKIP", data)
if not self._inverted_tp_notified:
self._inverted_tp_notified = True
self._notify("INVERTED_TP_SKIP", data)
return
notional = self.capital * self.position_size * self.leverage
size = notional / current_price if current_price > 0 else 0
@@ -246,8 +275,7 @@ class StrategyWorker:
self.entry_time = datetime.now(timezone.utc).isoformat()
self.bars_held = 0
meta = signal.metadata or {}
self.tp = float(meta.get("tp", 0.0) or 0.0)
self.tp = tp
self.sl = float(meta.get("sl", 0.0) or 0.0)
self.max_bars = int(meta.get("max_bars", 0) or 0)
@@ -729,7 +757,7 @@ class StrategyWorker:
last_idx = len(df) - 1
if last_signal.idx >= last_idx - 1:
self._open_position(last_signal, current_price)
self._open_position(last_signal, current_price, current_ts)
self.last_bar_ts = current_ts
self._save_state()
+76
View File
@@ -0,0 +1,76 @@
"""INVERTED_TP_SKIP (2026-06-16): un wick transitorio puo' far calcolare alla strategia
un tp dal lato SBAGLIATO dell'entry (es. donchian: segnale su barra wickata, entry al
prezzo recuperato gia' oltre il proprio tp). L'exit intrabar `bar_high>=tp` (long) /
`bar_low<=tp` (short) scatterebbe a bars_held=0 in PERDITA, con churn di fee e TP
reduce-only respinti. Verita' d'esecuzione, zero parametri: se il TP e' gia' sfondato
all'ingresso il segnale e' malformato -> NON si apre. TP dal lato giusto (o assente)
-> comportamento storico."""
from src.live.strategy_worker import StrategyWorker
from src.live.strategy_loader import load_strategy
from src.strategies.base import Signal
def _worker(tmp):
w = StrategyWorker(strategy=load_strategy("MR02_donchian_fade"), asset="BTC", tf="15m",
capital=1000.0, data_dir=tmp)
w._notify = lambda *a, **k: None
return w
def test_long_inverted_tp_skipped(tmp_path):
# long con tp SOTTO l'entry (caso reale 16-06: entry 66780, tp 66189) -> no entry
w = _worker(tmp_path)
sig = Signal(idx=119, direction=1, entry_price=66780.0,
metadata={"tp": 66189.25, "sl": 64218.21, "max_bars": 24})
w._open_position(sig, 66780.0, current_ts=1)
assert not w.in_position
assert w.tp == 0.0
def test_short_inverted_tp_skipped(tmp_path):
# short con tp SOPRA l'entry -> il TP intrabar `bar_low<=tp` scatterebbe subito -> no entry
w = _worker(tmp_path)
sig = Signal(idx=119, direction=-1, entry_price=100.0,
metadata={"tp": 105.0, "sl": 110.0, "max_bars": 24})
w._open_position(sig, 100.0, current_ts=1)
assert not w.in_position
def test_long_valid_tp_opens(tmp_path):
# controllo positivo: long con tp SOPRA l'entry -> apre normalmente
w = _worker(tmp_path)
sig = Signal(idx=119, direction=1, entry_price=100.0,
metadata={"tp": 103.0, "sl": 98.0, "max_bars": 24})
w._open_position(sig, 100.0, current_ts=1)
assert w.in_position
assert w.direction == 1
assert w.tp == 103.0
def test_short_valid_tp_opens(tmp_path):
# controllo positivo: short con tp SOTTO l'entry -> apre normalmente
w = _worker(tmp_path)
sig = Signal(idx=119, direction=-1, entry_price=100.0,
metadata={"tp": 97.0, "sl": 102.0, "max_bars": 24})
w._open_position(sig, 100.0, current_ts=1)
assert w.in_position
assert w.direction == -1
def test_no_tp_opens(tmp_path):
# segnale senza tp (strategie a orizzonte puro, es. SH01) -> il guard non si applica
w = _worker(tmp_path)
sig = Signal(idx=119, direction=1, entry_price=100.0, metadata={"max_bars": 12})
w._open_position(sig, 100.0, current_ts=1)
assert w.in_position
def test_inverted_skip_deduped_per_bar(tmp_path):
# stesso bar_ts -> un solo log INVERTED_TP_SKIP; non blocca un bar successivo valido
w = _worker(tmp_path)
sig = Signal(idx=119, direction=1, entry_price=66780.0,
metadata={"tp": 66189.25, "max_bars": 24})
w._open_position(sig, 66780.0, current_ts=1)
w._open_position(sig, 66780.0, current_ts=1)
assert not w.in_position
assert w._inverted_tp_ts == 1