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