From b576ee66ac8ff85fe7c499357a962f06a9abfd2c Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Tue, 16 Jun 2026 14:16:13 +0000 Subject: [PATCH] =?UTF-8?q?feat(worker):=20guard=20TP-invertito=20?= =?UTF-8?q?=E2=80=94=20sopprime=20l'entry=20quando=20il=20TP=20e'=20gia'?= =?UTF-8?q?=20sfondato=20(wick-print)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 13 ++++ docs/diary/2026-06-16-inverted-tp-guard.md | 66 +++++++++++++++++++ src/live/strategy_worker.py | 36 ++++++++-- tests/portfolio/test_inverted_tp.py | 76 ++++++++++++++++++++++ 4 files changed, 187 insertions(+), 4 deletions(-) create mode 100644 docs/diary/2026-06-16-inverted-tp-guard.md create mode 100644 tests/portfolio/test_inverted_tp.py diff --git a/CLAUDE.md b/CLAUDE.md index c7a5447..518eeee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/diary/2026-06-16-inverted-tp-guard.md b/docs/diary/2026-06-16-inverted-tp-guard.md new file mode 100644 index 0000000..6f929e9 --- /dev/null +++ b/docs/diary/2026-06-16-inverted-tp-guard.md @@ -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:00–09: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 (66700–66834): + +``` +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.7–2.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. diff --git a/src/live/strategy_worker.py b/src/live/strategy_worker.py index 26866de..1b9f57c 100644 --- a/src/live/strategy_worker.py +++ b/src/live/strategy_worker.py @@ -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() diff --git a/tests/portfolio/test_inverted_tp.py b/tests/portfolio/test_inverted_tp.py new file mode 100644 index 0000000..72cfaa2 --- /dev/null +++ b/tests/portfolio/test_inverted_tp.py @@ -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