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