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
+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: