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) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-05 18:32:14 +00:00
parent ff2ff6de7d
commit 2da3457ca7
7 changed files with 243 additions and 5 deletions
+11
View File
@@ -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à
@@ -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:0014: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.20.9%, **ETH 3.411.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.
+19 -5
View File
@@ -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")
+2
View File
@@ -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)
}
+37
View File
@@ -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:
+49
View File
@@ -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
+58
View File
@@ -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