From 2da3457ca734a01cb0560c218540ce309850d4c9 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Fri, 5 Jun 2026 18:32:14 +0000 Subject: [PATCH] fix(live): EXIT-16 confirm-SL valutato sul close di barra COMPLETATA + alert STALE_FEED Il worker valutava il confirm sul prezzo della candela IN FORMAZIONE ad ogni poll, reintroducendo la wick-sensitivity che EXIT-16 elimina (audit crash ETH 2026-06-05: 2 stop su 3 erano wick-stop che il backtest validato non avrebbe preso in quel momento). Ora il confirm usa SOLO il close dell'ultima barra completata (riga -1 = candela in corso finche' now < ts[-1]+bar_ms), buf dall'ATR della stessa barra, fill al prezzo corrente (~ stress lag_close_exit OK in exit-lab), TP intrabar invariato. Concausa feed-gap: non mitigabile lato exit (fill reali ~ sim); entry-guard post-flat TESTATA e BOCCIATA (skippare i segnali dopo barre flat peggiora tutti gli sleeve ETH: la candela-gap e' l'overshoot che la fade fada). Aggiunto alert Telegram STALE_FEED (>=2 barre 1h flat -> notifica + gap % al risveglio, dedup per episodio, solo osservabilita'). Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 11 +++ .../2026-06-05-confirm-sl-forming-bar.md | 67 +++++++++++++++++++ src/live/strategy_worker.py | 24 +++++-- src/live/telegram_notifier.py | 2 + src/portfolio/runner.py | 37 ++++++++++ tests/portfolio/test_close_confirm_sl.py | 49 ++++++++++++++ tests/portfolio/test_stale_feed.py | 58 ++++++++++++++++ 7 files changed, 243 insertions(+), 5 deletions(-) create mode 100644 docs/diary/2026-06-05-confirm-sl-forming-bar.md create mode 100644 tests/portfolio/test_stale_feed.py diff --git a/CLAUDE.md b/CLAUDE.md index 85c52f3..41cf754 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -198,6 +198,17 @@ regime tossico, non rendendo sicuro ogni trade). **Monitor live:** `hourly_repor stop-rate fade PRIMA/DOPO l'attivazione (14:34 UTC del 2026-06-02) e dà il verdetto su Telegram quando il campione DOPO ≥30 (già confermato: stop-rate live PRIMA 42% == backtest 42.1%). +**FIX EXIT-16 live — confirm su barra COMPLETATA (2026-06-05).** Il worker valutava il confirm-SL +sul prezzo della candela IN FORMAZIONE ad ogni poll → reintroduceva la wick-sensitivity che EXIT-16 +elimina (audit: 2 stop su 3 del crash ETH erano wick-stop che il backtest non avrebbe preso in quel +momento). Ora `tick` valuta il confirm SOLO sul close dell'ultima barra completata (detection: la +riga -1 del df è la candela in corso finché `now < ts[-1]+bar_ms`), buf dall'ATR della stessa barra; +fill al prezzo corrente (≈ stress lag_close_exit, OK in exit-lab); TP intrabar invariato. La concausa +feed-gap NON è mitigabile lato exit (fill reali ≈ sim) e l'entry-guard post-flat è BOCCIATA (skippare +i segnali dopo barre flat PEGGIORA tutti gli sleeve ETH: la candela-gap è l'overshoot che la fade +fada). Aggiunto alert Telegram `STALE_FEED` (≥2 barre 1h flat → notifica + gap % al risveglio, solo +osservabilità). Diario `docs/diary/2026-06-05-confirm-sl-forming-bar.md`. + **EXIT-16 close-confirm SL (ATTIVO LIVE, 2026-06-04).** Le fade accettano `sl_confirm_atr` (live: 0.5 in `_defs.py`): lo SL **intrabar è disattivato** e lo stop scatta solo se il **CLOSE** della barra sfonda `sl ∓ 0.5·ATR14`, con uscita al close (TP intrabar al livello e max_bars invariati; in modalità diff --git a/docs/diary/2026-06-05-confirm-sl-forming-bar.md b/docs/diary/2026-06-05-confirm-sl-forming-bar.md new file mode 100644 index 0000000..b6a2ac7 --- /dev/null +++ b/docs/diary/2026-06-05-confirm-sl-forming-bar.md @@ -0,0 +1,67 @@ +# Diario — 2026-06-05 — Fix EXIT-16 live: confirm sul close di barra COMPLETATA + alert STALE_FEED + +## Innesco + +Crash ETH 2026-06-05: gli stop MR01/MR02 ETH sono scattati molto sotto il livello +nominale (MR02: sl 1636.28 → exit 1600.45; MR01: sl 1610.75 → exit 1594.35). +Analisi delle due concause dichiarate: + +1. **EXIT-16 close-confirm** (by design): uscita al close oltre `sl ∓ 0.5·ATR14`, + non al livello — costo accettato e MODELLATO nel backtest validato (7.84/10.06). +2. **Feed gappato**: ETH testnet flat ~2h (1656.80/1655.55, 13:00–14:50 UTC), poi + una candela 14:55 da 1634 a 1600 — nessun prezzo esisteva tra SL ed exit. I fill + REALI confermano (1600.2 / 1589.7, slippage −1.6/−29 bps): non è un bug, è il mercato. + +## Scoperta: divergenza implementativa REALE nel worker + +Audit dei 3 stop ETH del 2026-06-04/05 contro le candele: + +| Stop live | Close barra COMPLETATA al momento | Confirm | Verdetto | +|---|---|---|---| +| MR02 06:03 @1672.9 | 1682.25 (05:00) | ~1675.4 | **wick-stop**: il backtest NON avrebbe stoppato lì | +| MR02 15:00 @1600.45 | 1600.05 (14:00) | ~1621 | legittimo, fill ≈ backtest | +| MR01 15:01 @1594.35 | 1600.05 (14:00) | ~1594.4 | **wick-stop**: il backtest NON avrebbe stoppato lì | + +**2 su 3 erano valutazioni sulla barra IN FORMAZIONE**: `tick` confrontava +`current_price` (close istantaneo della candela in corso al poll) con `sl − buf`. +Il backtest (`fade_base`) valuta il confirm SOLO sul close di barra completata +`c[j]`. La differenza reintroduce live esattamente la wick-sensitivity che EXIT-16 +elimina: un dip infrabarra sotto il confirm stoppava anche se la barra chiudeva +sopra (e nei dip-che-rientrano la fade vince — è il suo edge). Live sarebbe stato +strutturalmente PIÙ stop-prone del backtest validato. + +## Fix (`strategy_worker.py`, ramo sl_confirm_atr) + +- Il confirm è valutato sul **close dell'ultima barra COMPLETATA**: l'ultima riga + del df è la candela in corso finché non è trascorsa la sua durata + (`now_ms < ts[-1] + bar_ms`, `bar_ms` = mediana dei delta timestamp → regge 1h/4h/1d). +- Il `buf` usa l'ATR14 alla stessa barra completata (prima: barra in formazione). +- Il **fill resta al prezzo corrente** al poll (lag ≈ stress `lag_close_exit`, + superato in exit-lab). TP intrabar sulla barra in corso INVARIATO (= backtest). +- Test nuovi in `test_close_confirm_sl.py` (sezione FIX 2026-06-05): il dip della + candela in corso non stoppa; il breach della completata stoppa anche se rimbalzato; + TP intrabar invariato. NB fixture: timestamp in ms ESPLICITI + (`Timestamp.now()` è risoluzione µs → `astype int64 // 10**6` darebbe secondi). + +## Concausa feed: entry-guard TESTATA e BOCCIATA, solo osservabilità + +Quantificato (1h, storico completo): segnali fade subito dopo barre flat = BTC +0.2–0.9%, **ETH 3.4–11.8%**. Backtest con skip di quei segnali (config live, +sl_confirm 0.5): **PEGGIORA tutti gli sleeve ETH** (MR01 +1344→+1256, MR02 ++2372→+2250, MR07 +740→+621; BTC ~invariato). La candela-gap post-flat è +l'overshoot che la fade fada con profitto — e i fill reali post-crash lo +confermano (riaperture a −1.9 bps di slippage). **Niente skip degli ingressi.** + +Aggiunto invece **alert Telegram `STALE_FEED`** (`runner._check_stale_feed`): +notifica quando un asset ha ≥2 barre 1h complete flat (worker ciechi) e al +risveglio col **gap %** del primo prezzo reale. Una notifica per episodio +(dedup per asset). Solo osservabilità, zero effetto sulle decisioni. + +## Cosa NON è stato toccato (e perché) + +- Il **costo del confirm** (uscita al close, non al livello nominale) resta: è il + trade-off validato di EXIT-16 (FULL 6.47→7.84, OOS 8.82→10.06) — i wick-stop + evitati pagano molto più dei close-stop più profondi. +- Il **gap-through** non è mitigabile lato exit: nessun prezzo esisteva tra SL ed + exit (fill reali ≈ sim). L'unica protezione strutturale resta la diversificazione + (17 sleeve) + i cap di famiglia. diff --git a/src/live/strategy_worker.py b/src/live/strategy_worker.py index 19786c3..66e4460 100644 --- a/src/live/strategy_worker.py +++ b/src/live/strategy_worker.py @@ -430,22 +430,36 @@ class StrategyWorker: # EXIT-16 close-confirm (2026-06-04): TP intrabar al livello come il # backtest; lo SL scatta SOLO se il close sfonda sl ∓ buf*ATR14 — i # wick che bucano lo stop e rientrano (l'overshoot che la fade fada) - # non stoppano piu'. Uscita stop al CLOSE (prezzo corrente), non al - # livello. PORT06: OOS Sharpe 8.82->10.06 (exit-lab, 34 agenti). - buf = self.sl_confirm_atr * float(_atr(df, 14)[-1]) + # non stoppano piu'. PORT06: OOS Sharpe 8.82->10.06 (exit-lab, 34 agenti). + # + # FIX 2026-06-05: il confirm va valutato sul close di barra COMPLETATA, + # come nel backtest (fade_base: c[j] di bar chiusi) — NON sul prezzo + # della barra in formazione, che reintroduce la wick-sensitivity che + # EXIT-16 elimina (audit live: 2 stop su 3 del 2026-06-05 erano scattati + # su dip intrabar che il backtest avrebbe ignorato in quel momento). + # L'ultima riga del df e' la candela in corso se non e' ancora trascorsa + # la sua durata; il fill resta al prezzo corrente (lag di poll, stress + # lag_close_exit superato in exit-lab). Il buf usa l'ATR della stessa + # barra completata. + ts_arr = df["timestamp"].values.astype("int64") + bar_ms = int(np.median(np.diff(ts_arr[-50:]))) if len(ts_arr) > 1 else 0 + now_ms = int(time.time() * 1000) + k = -1 if now_ms >= ts_arr[-1] + bar_ms else -2 + confirm_close = float(c[k]) + buf = self.sl_confirm_atr * float(_atr(df, 14)[k]) if not np.isfinite(buf): buf = 0.0 if self.direction == 1: if bar_high >= self.tp: self._close_position(self.tp, "take_profit") - elif current_price < self.sl - buf: + 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: self._close_position(self.tp, "take_profit") - elif current_price > self.sl + buf: + 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") diff --git a/src/live/telegram_notifier.py b/src/live/telegram_notifier.py index fb2b2bb..5c5ff06 100644 --- a/src/live/telegram_notifier.py +++ b/src/live/telegram_notifier.py @@ -17,6 +17,8 @@ NOTIFY_EVENTS = { # esecuzione REALE (shadow su Deribit testnet) "REAL_EXEC_LIVE", # primo ordine reale verificato di un worker (conferma "e' vivo") "REAL_OPEN_FAIL", # un'apertura reale NON si e' verificata (problema da guardare) + "STALE_FEED", # feed flat/fermo da >= N barre 1h (worker ciechi: il prossimo + # prezzo reale puo' gappare, come ETH 2026-06-05 1655->1600) } diff --git a/src/portfolio/runner.py b/src/portfolio/runner.py index ef14bc6..c51217c 100644 --- a/src/portfolio/runner.py +++ b/src/portfolio/runner.py @@ -133,6 +133,41 @@ def _spec_assets_tf(spec: SleeveSpec): return [spec.asset], spec.tf +_STALE_BARS = 2 # barre 1h COMPLETE consecutive flat (O=H=L=C) -> feed fermo + + +def _check_stale_feed(asset: str, df: pd.DataFrame, alerted: set[str]): + """Osservabilita' (2026-06-05): alert Telegram quando il feed e' flat/fermo da + >= _STALE_BARS barre 1h complete (i worker sono ciechi: il prossimo prezzo reale + puo' gappare attraverso TP/SL, come ETH flat 13:00-14:50 -> gap 1655->1600) e al + risveglio (con il gap % del primo prezzo reale). Una notifica per episodio. + NB: SOLO osservabilita' — saltare gli ingressi post-flat PEGGIORA l'edge + (testato: la candela-gap e' l'overshoot che la fade fada con profitto).""" + import time as _t + from src.live.telegram_notifier import notify_event + if len(df) < _STALE_BARS + 2: + return + o, h, l, c = (df[k].values for k in ("open", "high", "low", "close")) + # ultima barra COMPLETA: la riga -1 e' la candela in corso finche' non e' trascorsa + k = -1 if int(_t.time() * 1000) >= int(df["timestamp"].iloc[-1]) + 3_600_000 else -2 + i = len(c) + k + if i < 1: + return + flat = (o == h) & (h == l) & (l == c) + n_flat = 0 + while i - n_flat >= 0 and flat[i - n_flat]: + n_flat += 1 + if n_flat >= _STALE_BARS and asset not in alerted: + alerted.add(asset) + notify_event("STALE_FEED", {"asset": asset, "flat_bars_1h": n_flat, + "ultimo_prezzo": float(c[i])}) + elif n_flat == 0 and asset in alerted: + alerted.discard(asset) + gap = (c[i] / c[i - 1] - 1) * 100 if c[i - 1] else 0.0 + notify_event("STALE_FEED", {"asset": asset, "status": "RIPRESO", + "gap_pct": round(gap, 2), "prezzo": float(c[i])}) + + def run(config_path: str = "portfolios.yml"): """Loop live a portafoglio (tutti i tipi di sleeve). Data layer Cerbero v2 con resample; ribilancio a cambio giornata UTC.""" @@ -207,6 +242,7 @@ def run(config_path: str = "portfolios.yml"): inst_map = dict(INSTRUMENT_MAP) last_day = "" + stale_alerted: set[str] = set() # asset con alert STALE_FEED attivo (dedup per episodio) while True: try: # fetch 1h per asset al lookback massimo richiesto @@ -225,6 +261,7 @@ def run(config_path: str = "portfolios.yml"): df = pd.DataFrame(candles) df["timestamp"] = df["timestamp"].astype("int64") raw1h[asset] = df.sort_values("timestamp").reset_index(drop=True) + _check_stale_feed(asset, raw1h[asset], stale_alerted) # tick di ogni worker col suo timeframe (resample dal 1h) for s in live_specs: diff --git a/tests/portfolio/test_close_confirm_sl.py b/tests/portfolio/test_close_confirm_sl.py index fe8e1c0..2037b8c 100644 --- a/tests/portfolio/test_close_confirm_sl.py +++ b/tests/portfolio/test_close_confirm_sl.py @@ -72,3 +72,52 @@ def test_small_breach_within_buffer_holds(tmp_path): df = _df(last_high=100.0, last_low=94.0, last_close=97.95) # TR=6 -> ATR~0.43 -> buf~0.21 w.tick(df) assert w.in_position + + +# ---------------------------------------------------------------- FIX 2026-06-05 +# Il confirm va valutato sul close di barra COMPLETATA (come fade_base.backtest), +# non sul prezzo della barra in formazione: il dip intrabar che rientra NON deve +# stoppare (audit live 2026-06-05: 2 stop su 3 erano wick-stop da barra in corso). + +def _df_forming(prev_close, forming_low, forming_close, n=120, price=100.0): + """Ultima riga = candela IN CORSO (timestamp = ora corrente flooral'ora): + il worker deve valutare il confirm sulla riga -2 (completata).""" + c = [price] * n + h = [price] * n + l = [price] * n + c[-2] = prev_close + l[-1] = forming_low + c[-1] = forming_close + h[-1] = max(price, forming_close) + # ts in MILLISECONDI espliciti (Timestamp.now() ha risoluzione us: l'astype + # darebbe unita' sbagliate). Ultima barra = ora corrente floor -> in corso. + end_ms = int(pd.Timestamp.now(tz="UTC").floor("h").timestamp() * 1000) + ts = [end_ms - (n - 1 - i) * 3_600_000 for i in range(n)] + return pd.DataFrame({"timestamp": ts, "open": [price] * n, "high": h, "low": l, + "close": c, "volume": 1.0}) + + +def test_forming_bar_dip_does_not_stop(tmp_path): + # barra in corso sprofonda sotto sl-buf (close corrente 96.5) ma la barra + # COMPLETATA (-2) ha chiuso a 100 -> NIENTE stop (prima del fix stoppava) + w = _long_worker(tmp_path) + w.tick(_df_forming(prev_close=100.0, forming_low=96.0, forming_close=96.5)) + assert w.in_position + + +def test_completed_close_breach_stops_even_if_recovered(tmp_path): + # la barra COMPLETATA ha chiuso sotto sl-buf; la barra in corso e' rimbalzata + # sopra -> stop comunque (fill al prezzo corrente, come lag_close_exit) + w = _long_worker(tmp_path) + w.tick(_df_forming(prev_close=96.0, forming_low=98.5, forming_close=99.0)) + assert not w.in_position + + +def test_tp_intrabar_on_forming_bar_unchanged(tmp_path): + # TP toccato dalla barra in corso -> esce al TP (intrabar invariato) + w = _long_worker(tmp_path) + df = _df_forming(prev_close=100.0, forming_low=99.5, forming_close=100.0) + df.loc[df.index[-1], "high"] = 102.5 + w.tick(df) + assert not w.in_position + assert w.capital > 1000.0 diff --git a/tests/portfolio/test_stale_feed.py b/tests/portfolio/test_stale_feed.py new file mode 100644 index 0000000..2f336c7 --- /dev/null +++ b/tests/portfolio/test_stale_feed.py @@ -0,0 +1,58 @@ +"""STALE_FEED (2026-06-05): alert quando il feed e' flat da >= 2 barre 1h complete, +e al risveglio col gap %. Solo osservabilita': nessun effetto sui worker.""" +import pandas as pd +from src.portfolio.runner import _check_stale_feed + + +def _df(closes_completed, forming=None, price0=100.0): + """Serie 1h: barre complete con i close dati (flat = ripete il precedente + con O=H=L=C), piu' eventuale candela in corso (timestamp = ora corrente).""" + end_ms = int(pd.Timestamp.now(tz="UTC").floor("h").timestamp() * 1000) + vals = list(closes_completed) + ([forming] if forming is not None else []) + n = len(vals) + rows = [] + for j, v in enumerate(vals): + ts = end_ms - (n - 1 - j) * 3_600_000 + rows.append({"timestamp": ts, "open": v, "high": v, "low": v, "close": v, "volume": 0.0}) + # rendi NON-flat le barre che cambiano prezzo rispetto alla precedente + for j in range(1, n): + if rows[j]["close"] != rows[j - 1]["close"]: + rows[j]["high"] = rows[j]["close"] * 1.001 + return pd.DataFrame(rows) + + +def _events(monkeypatch): + sent = [] + import src.live.telegram_notifier as tn + monkeypatch.setattr(tn, "notify_event", lambda ev, data=None: sent.append((ev, data))) + return sent + + +def test_alert_after_two_flat_complete_bars(monkeypatch): + sent = _events(monkeypatch) + alerted = set() + closes = [100 + i for i in range(10)] + [110.0, 110.0, 110.0] # 3 flat complete + _check_stale_feed("ETH", _df(closes, forming=110.0), alerted) + assert "ETH" in alerted + assert sent and sent[0][0] == "STALE_FEED" and sent[0][1]["flat_bars_1h"] >= 2 + + +def test_no_alert_on_live_feed(monkeypatch): + sent = _events(monkeypatch) + alerted = set() + closes = [100 + i for i in range(13)] # sempre in movimento + _check_stale_feed("ETH", _df(closes, forming=113.5), alerted) + assert not alerted and not sent + + +def test_recovery_notifies_gap_once(monkeypatch): + sent = _events(monkeypatch) + alerted = {"ETH"} # episodio in corso + closes = [100.0] * 10 + [96.6] # risveglio: gap -3.4% + _check_stale_feed("ETH", _df(closes, forming=96.5), alerted) + assert "ETH" not in alerted + assert sent and sent[-1][1]["status"] == "RIPRESO" and abs(sent[-1][1]["gap_pct"] + 3.4) < 0.1 + # secondo poll: nessun doppio alert + sent.clear() + _check_stale_feed("ETH", _df(closes, forming=96.5), alerted) + assert not sent