From f5173fba06e048bd6a6eac61bafceb21480046f8 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Sun, 7 Jun 2026 14:59:58 +0000 Subject: [PATCH] =?UTF-8?q?fix(live):=204=20hardening=20da=20code-review?= =?UTF-8?q?=20=E2=80=94=20REAL=5FDIVERGENCE,=20outage=20su=20feed=20vuoto,?= =?UTF-8?q?=20bars.py=20condiviso,=20DSL=20cancel=20retry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review multi-agente 7-angoli su 8c4e1cd..HEAD + check trades live: 1. Alert Telegram REAL_DIVERGENCE quando |slippage sim/reale| >= 100bps a open/close. Causa scatenante: spike print testnet 10:37 (candela 10:00 H=65618, O/C ~62400) -> 3 fade BTC short su close fantasma 65266.5, reale fillato a 62395 (-440bps), sim +2.26 mai esistiti — passato in silenzio. 2. FEED_OUTAGE anche su feed degradato SENZA eccezione (HTTP 200 + candles vuote: i worker saltavano il tick in silenzio, streak a 0). Helper unico _outage_tick; chiavi payload uniformate (minuti su start e RIPRESO). 3. src/live/bars.py: detection forming-bar unificata (bar_ms_of / last_bar_is_forming / last_settled_idx) — era copiata in 4 punti (strategy_worker, basket, pairs, _check_stale_feed hardcoded 1h). E' l'invariante di sicurezza EXIT-16: ora una sola implementazione testata. 4. DSL cancel hardening in _real_close: retry su errore transitorio + alert REAL_DSL_CANCEL_FAIL se lo stop resta forse orfano sul book (prima l'id veniva dimenticato in silenzio); order_not_found = probabile trigger in outage -> solo log (il close a valle esce gia' verified=False). Refutato il finding top dei finder ("stop_market senza trigger"): cerbero-mcp traduce price->trigger_price+mark, e in produzione 2 DSL armati + 1 ciclo completo pulito (MR07_BTC). Test: 83/83 (9 nuovi: bars helper + DSL/divergence con executor finto). Smoke testnet 4 scenari verdi, conto flat, zero falsi allarmi. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/diary/2026-06-07-sweep-fixes.md | 28 +++++++ src/live/bars.py | 44 +++++++++++ src/live/basket_trend_worker.py | 9 +-- src/live/pairs_worker.py | 11 +-- src/live/strategy_worker.py | 48 +++++++++--- src/live/telegram_notifier.py | 8 +- src/portfolio/runner.py | 62 +++++++++------ tests/portfolio/test_bars.py | 29 +++++++ tests/portfolio/test_real_close_dsl.py | 103 +++++++++++++++++++++++++ 9 files changed, 294 insertions(+), 48 deletions(-) create mode 100644 src/live/bars.py create mode 100644 tests/portfolio/test_bars.py create mode 100644 tests/portfolio/test_real_close_dsl.py diff --git a/docs/diary/2026-06-07-sweep-fixes.md b/docs/diary/2026-06-07-sweep-fixes.md index 1ea07e9..8ce380f 100644 --- a/docs/diary/2026-06-07-sweep-fixes.md +++ b/docs/diary/2026-06-07-sweep-fixes.md @@ -66,6 +66,34 @@ zero ordini orfani sul book), conto flat a fine smoke. "flat/risk-off by-design" da "wiring rotto". I multi-asset sono esclusi dalla tabella IN CORSO (assume entry/bars single-leg). +## Code review multi-agente del giorno stesso (v1.1.7) + +Review a 7 angoli su `8c4e1cd..HEAD` + check trades live. Il candidato top dei finder +("stop_market senza trigger") è REFUTATO: cerbero-mcp traduce `price→trigger_price + +trigger=mark_price` e in produzione 2 DSL erano armati + 1 ciclo completo pulito. +Quattro fix applicati: + +1. **Alert `REAL_DIVERGENCE`** (|slippage sim/reale| ≥ 100bps a open/close). Scoperta + dal check trades: alle 10:37 uno **spike print testnet** (candela 10:00 H=65618 con + O/C~62400) ha fatto shortare alle 3 fade BTC un close fantasma a 65266.5 — il reale + ha fillato correttamente a ~62395 (−440bps) ma il sim ha bookato +2.26 mai esistiti + (MR07 reale −0.13). Prima passava in silenzio. +2. **`FEED_OUTAGE` anche su feed degradato senza eccezione** (HTTP 200 con candles + vuote → i worker saltavano il tick in silenzio e lo streak restava 0). Helper unico + `_outage_tick` (fix anche dell'incoerenza chiavi minuti/durata_min). +3. **`src/live/bars.py`**: detection forming-bar UNIFICATA (era copiata in 4 punti, + con `_check_stale_feed` che hardcodava 1h). È l'invariante di sicurezza di EXIT-16: + ora vive in un posto solo, testato (`test_bars.py`). +4. **DSL cancel hardening**: retry su errore transitorio + alert Telegram + `REAL_DSL_CANCEL_FAIL` se lo stop resta forse ORFANO sul book (prima l'id veniva + dimenticato in silenzio → lo stop stantio poteva colpire la posizione successiva); + `order_not_found` = probabile trigger durante outage → solo log (il close a valle + esce già verified=False). Test con executor finto (`test_real_close_dsl.py`). + +Finding noti NON ancora fixati (in coda): ROT02/TSM01 valutano la candela 1d in +formazione al primo poll dopo mezzanotte (stessa classe del fix TR01/Pairs, pre-esistente); +engine dei gate copiato fra i 3 script `*_port06_impact.py`; epoche hourly_report hardcoded. + ## Resta in roadmap (settimana 2, OGNUNO dietro gate PORT06/OOS) trend_max=3.0 sulle 6 fade → pos pairs 0.15-0.25 per-famiglia → DIP01 EXIT-16 diff --git a/src/live/bars.py b/src/live/bars.py new file mode 100644 index 0000000..f66da74 --- /dev/null +++ b/src/live/bars.py @@ -0,0 +1,44 @@ +"""Helper condivisi sulle barre OHLCV live (lezione EXIT-16, 2026-06-05). + +La riga -1 di un df di candele e' la candela IN FORMAZIONE finche' non e' +trascorsa la sua durata: valutare segnali/exit-confirm su quella riga +reintroduce la wick-sensitivity che EXIT-16 elimina (audit live: 2 stop su 3 +del crash ETH erano wick-stop sulla barra in corso). Questa e' l'UNICA +implementazione della detection — prima era copiata in 4 punti +(strategy_worker, basket_trend_worker, pairs_worker, runner._check_stale_feed) +con una variante hardcoded a 1h: una correzione applicata a una copia e +dimenticata nelle altre reintrodurrebbe il bug in silenzio. +""" +from __future__ import annotations + +import time + +import numpy as np + + +def bar_ms_of(ts_ms, window: int = 50) -> int: + """Durata stimata della barra: mediana dei diff degli ultimi `window` + timestamp (ms). 0 se la serie e' troppo corta per stimarla.""" + ts = np.asarray(ts_ms, dtype="int64") + if len(ts) < 2: + return 0 + return int(np.median(np.diff(ts[-window:]))) + + +def last_bar_is_forming(ts_ms, now_ms: int | None = None) -> bool: + """True se la riga -1 e' la candela IN CORSO (now < ts[-1] + durata barra).""" + ts = np.asarray(ts_ms, dtype="int64") + if len(ts) == 0: + return False + bar = bar_ms_of(ts) + if not bar: + return False + if now_ms is None: + now_ms = int(time.time() * 1000) + return now_ms < int(ts[-1]) + bar + + +def last_settled_idx(ts_ms, now_ms: int | None = None) -> int: + """Indice dell'ultima barra COMPLETATA: -1 se la riga -1 e' chiusa, + -2 se e' in formazione.""" + return -2 if last_bar_is_forming(ts_ms, now_ms) else -1 diff --git a/src/live/basket_trend_worker.py b/src/live/basket_trend_worker.py index 18903af..05128fc 100644 --- a/src/live/basket_trend_worker.py +++ b/src/live/basket_trend_worker.py @@ -60,15 +60,14 @@ class BasketTrendWorker: df = data.get(a) if df is None or len(df) < 111: continue - # Scarta la barra 4h IN FORMAZIONE (la riga -1 e' la candela in corso - # finche' non e' trascorsa la sua durata): crossover EMA e booking del - # return valutati SOLO su barre COMPLETE, come il reference + # Scarta la barra 4h IN FORMAZIONE: crossover EMA e booking del return + # valutati SOLO su barre COMPLETE, come il reference # honest_improve2._tr_basket_daily (lezione EXIT-16; evidenza live: flip # SOL 0->1->0 in 59min nella stessa finestra 4h, -9.3% di glitch). + from src.live.bars import last_bar_is_forming ts_arr = df["timestamp"].values.astype("int64") - bar_ms = int(np.median(np.diff(ts_arr[-50:]))) if len(ts_arr) > 1 else 0 c = df["close"].values - if bar_ms and now_ms < int(ts_arr[-1]) + bar_ms: + if last_bar_is_forming(ts_arr, now_ms): c, ts_arr = c[:-1], ts_arr[:-1] if len(c) < 110: continue diff --git a/src/live/pairs_worker.py b/src/live/pairs_worker.py index 7ba50df..0c5170f 100644 --- a/src/live/pairs_worker.py +++ b/src/live/pairs_worker.py @@ -16,7 +16,6 @@ Stato persistente (resume al restart) e log come StrategyWorker. from __future__ import annotations import json -import time from datetime import datetime, timezone from pathlib import Path @@ -179,13 +178,11 @@ class PairsWorker: m = df_a[["timestamp", "close"]].rename(columns={"close": "ca"}).merge( df_b[["timestamp", "close"]].rename(columns={"close": "cb"}), on="timestamp", how="inner" ).sort_values("timestamp").reset_index(drop=True) - # Scarta la barra IN FORMAZIONE (la riga -1 e' la candela in corso finche' - # non e' trascorsa la sua durata): entry ED exit valutati SOLO sul close di + # Scarta la barra IN FORMAZIONE: entry ED exit valutati SOLO sul close di # barra COMPLETA, come il backtest (pairs_research: close settled) — - # lezione EXIT-16 (strategy_worker.tick). - ts_arr = m["timestamp"].values.astype("int64") - bar_ms = int(np.median(np.diff(ts_arr[-50:]))) if len(ts_arr) > 1 else 0 - if bar_ms and int(time.time() * 1000) < int(ts_arr[-1]) + bar_ms: + # lezione EXIT-16. Detection condivisa: src.live.bars. + from src.live.bars import last_bar_is_forming + if last_bar_is_forming(m["timestamp"].values): m = m.iloc[:-1] if len(m) < self.n + 2: return diff --git a/src/live/strategy_worker.py b/src/live/strategy_worker.py index 07ebe1f..a6272e4 100644 --- a/src/live/strategy_worker.py +++ b/src/live/strategy_worker.py @@ -16,6 +16,11 @@ from src.live.execution import ExecutionClient FEE_RT = 0.002 +# Alert REAL_DIVERGENCE: |slippage sim/reale| oltre questa soglia a open/close -> +# Telegram. Cattura gli spike print testnet (2026-06-07: sim short BTC a 65266.5 con +# mark reale 62395, -440bps, passato in silenzio) e i feed stantii. +DIVERGENCE_BPS = 100.0 + class StrategyWorker: """Gestisce paper trading per una singola strategia/asset/tf.""" @@ -248,6 +253,10 @@ class StrategyWorker: if not self.real_first_notified: # conferma una-tantum: l'esecuzione reale e' viva self._notify("REAL_EXEC_LIVE", data) self.real_first_notified = True + if slip_bps is not None and abs(slip_bps) >= DIVERGENCE_BPS: + # sim e reale stanno tradando prezzi diversi (spike print/feed stantio): + # il sim sta per bookare PnL che il reale non vede + self._notify("REAL_DIVERGENCE", {"fase": "open", **data}) self._place_real_tp() self._place_disaster_sl() else: @@ -326,12 +335,28 @@ class StrategyWorker: # il market reduce-only a valle filla 0 e REAL_CLOSE esce verified=False) # NB: la cancel di un trigger order risponde con lo stato AL MOMENTO della # cancel ('untriggered' = successo, verificato su testnet: il re-cancel da' - # order_not_found); 'error' = ordine non piu' in book (probabile trigger). + # order_not_found). 'order_not_found' = ordine non piu' in book (probabile + # trigger durante outage: il market a valle filla 0 -> verified=False). + # Altri errori (rete/transitorio): RETRY, poi alert Telegram — dimenticare + # un id con lo stop ancora in book lascia un ORFANO che puo' colpire la + # PROSSIMA posizione del worker. if self.real_dsl_order_id: - dres = self.executor.cancel_order(self.real_dsl_order_id) - if dres.get("state") not in ("cancelled", "untriggered"): - self._log("REAL_DSL_CANCEL_FAIL", {"order_id": self.real_dsl_order_id, - "res": dres}) + def _dsl_cancel(): + d = self.executor.cancel_order(self.real_dsl_order_id) + return (d, d.get("state") in ("cancelled", "untriggered"), + str(d.get("error", "")) == "order_not_found") + dres, ok, not_found = _dsl_cancel() + if not ok and not not_found: + time.sleep(self.executor.verify_sleep) + dres, ok, not_found = _dsl_cancel() + if not ok: + data = {"order_id": self.real_dsl_order_id, "res": dres, + "note": ("non in book: probabile trigger durante outage" + if not_found else + "stop forse ORFANO sul book — verificare a mano")} + self._log("REAL_DSL_CANCEL_FAIL", data) + if not not_found: + self._notify("REAL_DSL_CANCEL_FAIL", data) self.real_dsl_order_id = "" # 1) ordine TP resting: cancella, poi riconcilia i fill (order_id su history) @@ -378,6 +403,11 @@ class StrategyWorker: slip_bps = ((exit_price / sim_exit - 1) * 1e4 if exit_price and sim_exit else None) + if slip_bps is not None and abs(slip_bps) >= DIVERGENCE_BPS: + self._notify("REAL_DIVERGENCE", { + "fase": "close", "reason": reason, "sim_exit": round(sim_exit, 2), + "real_fill": round(exit_price, 2), "slippage_bps": round(slip_bps, 2), + "real_pnl_usd": round(real_pnl, 4), "sim_pnl_usd": round(sim_pnl, 4)}) self._log("REAL_CLOSE", { "reason": reason, "order_id": fill.order_id if fill else tp_order_id, @@ -487,11 +517,9 @@ class StrategyWorker: # 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 + # barra completata. Detection condivisa: src.live.bars. + from src.live.bars import last_settled_idx + k = last_settled_idx(df["timestamp"].values) confirm_close = float(c[k]) buf = self.sl_confirm_atr * float(_atr(df, 14)[k]) if not np.isfinite(buf): diff --git a/src/live/telegram_notifier.py b/src/live/telegram_notifier.py index c7309d1..a63ea78 100644 --- a/src/live/telegram_notifier.py +++ b/src/live/telegram_notifier.py @@ -21,8 +21,12 @@ NOTIFY_EVENTS = { # prezzo reale puo' gappare, come ETH 2026-06-05 1655->1600) "PANEL_SHORT", # TSM01/ROT02: panel inner-join troncato sotto il lookback # richiesto -> tick() salterebbe in SILENZIO (worker inerte) - "FEED_OUTAGE", # N poll consecutivi falliti nel runner: exit non valutati, - # posizioni reali protette solo dal disaster-SL on-book + "FEED_OUTAGE", # N poll consecutivi falliti/degradati nel runner: exit non + # valutati, posizioni reali protette solo dal disaster-SL + "REAL_DIVERGENCE", # |slippage| sim/reale anomalo a open/close (es. spike print + # testnet: sim entra su un prezzo fantasma, il reale sul book) + "REAL_DSL_CANCEL_FAIL", # cancel del disaster-SL fallita dopo retry: possibile + # stop ORFANO sul book -> verificare a mano } diff --git a/src/portfolio/runner.py b/src/portfolio/runner.py index 83d4794..eacb069 100644 --- a/src/portfolio/runner.py +++ b/src/portfolio/runner.py @@ -150,13 +150,13 @@ def _check_stale_feed(asset: str, df: pd.DataFrame, alerted: set[str]): 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.bars import last_settled_idx 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 + # ultima barra COMPLETA (detection condivisa src.live.bars; prima hardcodava 1h) + k = last_settled_idx(df["timestamp"].values) i = len(c) + k if i < 1: return @@ -262,10 +262,35 @@ def run(config_path: str = "portfolios.yml"): stale_alerted: set[str] = set() # asset con alert STALE_FEED attivo (dedup per episodio) # Osservabilita' outage (improvement-sweep 2026-06-06): il poll-loop intero e' in un # try/except → durante un outage i worker NON valutano gli exit. Alert Telegram dopo - # _OUTAGE_POLLS poll falliti consecutivi (con l'elenco delle posizioni REALI aperte, - # protette solo dal disaster-bracket on-book) + notifica di ripresa con la durata. + # _OUTAGE_POLLS poll falliti/DEGRADATI consecutivi + notifica di ripresa con durata. + # "Degradato" include il caso HTTP-200-con-candles-vuote (code review 2026-06-07): + # non solleva eccezione ma i worker dell'asset mancante saltano il tick in silenzio. _OUTAGE_POLLS = 5 fail_streak = 0 + + def _outage_tick(failed: bool, streak: int, detail: str = "") -> int: + """Aggiorna lo streak e gestisce gli alert FEED_OUTAGE (start a soglia, una + volta per episodio; RIPRESO al primo poll pulito). Ritorna il nuovo streak.""" + from src.live.telegram_notifier import notify_event + if failed: + streak += 1 + if streak == _OUTAGE_POLLS: + real_open = sorted(sid for sid, wk in workers.items() + if getattr(wk, "real_in_position", False)) + notify_event("FEED_OUTAGE", { + "poll_falliti": streak, + "minuti": round(streak * poll / 60), + "dettaglio": detail, + "posizioni_reali_aperte": ", ".join(real_open) or "nessuna", + "nota": "exit NON valutati durante l'outage; " + "protezione = disaster-SL on-book sui fade reali"}) + return streak + if streak >= _OUTAGE_POLLS: + notify_event("FEED_OUTAGE", {"status": "RIPRESO", + "poll_falliti": streak, + "minuti": round(streak * poll / 60)}) + return 0 + while True: try: # fetch 1h per asset al lookback massimo richiesto @@ -312,30 +337,19 @@ def run(config_path: str = "portfolios.yml"): rebalance_allocations(ledger, workers, weights) last_day = today ledger.save() - if fail_streak >= _OUTAGE_POLLS: - from src.live.telegram_notifier import notify_event - notify_event("FEED_OUTAGE", { - "status": "RIPRESO", - "poll_falliti": fail_streak, - "durata_min": round(fail_streak * poll / 60)}) - fail_streak = 0 + # feed degradato senza eccezione: asset richiesti ma senza candele + missing = sorted(a for a in asset_days if a not in raw1h) + if missing: + print(f"[runner] feed incompleto: mancano {missing} (streak {fail_streak + 1})") + fail_streak = _outage_tick(bool(missing), fail_streak, + detail=f"feed senza candele per: {', '.join(missing)}") except KeyboardInterrupt: ledger.save() print("shutdown") break except Exception as e: - fail_streak += 1 - print(f"[runner] errore: {e} (streak {fail_streak})") - if fail_streak == _OUTAGE_POLLS: - from src.live.telegram_notifier import notify_event - real_open = sorted(sid for sid, wk in workers.items() - if getattr(wk, "real_in_position", False)) - notify_event("FEED_OUTAGE", { - "poll_falliti": fail_streak, - "minuti": round(fail_streak * poll / 60), - "posizioni_reali_aperte": ", ".join(real_open) or "nessuna", - "nota": "exit NON valutati durante l'outage; " - "protezione = disaster-SL on-book sui fade reali"}) + print(f"[runner] errore: {e} (streak {fail_streak + 1})") + fail_streak = _outage_tick(True, fail_streak, detail=f"eccezione: {e}") time.sleep(poll) diff --git a/tests/portfolio/test_bars.py b/tests/portfolio/test_bars.py new file mode 100644 index 0000000..0752af9 --- /dev/null +++ b/tests/portfolio/test_bars.py @@ -0,0 +1,29 @@ +"""src.live.bars — detection condivisa della barra in formazione (lezione EXIT-16).""" +import numpy as np + +from src.live.bars import bar_ms_of, last_bar_is_forming, last_settled_idx + +H = 3_600_000 + + +def _ts(n, end_ms): + return np.arange(end_ms - (n - 1) * H, end_ms + 1, H, dtype="int64") + + +def test_bar_ms_median(): + assert bar_ms_of(_ts(50, 10 * H)) == H + assert bar_ms_of([1]) == 0 # troppo corta per stimare + + +def test_forming_vs_settled(): + now = 1_000_000 * H + ts = _ts(60, now) # ultima barra appena aperta + assert last_bar_is_forming(ts, now_ms=now + 1) # in corso + assert last_settled_idx(ts, now_ms=now + 1) == -2 + assert not last_bar_is_forming(ts, now_ms=now + H) # durata trascorsa + assert last_settled_idx(ts, now_ms=now + H) == -1 + + +def test_degenerate_series_defaults_to_settled(): + assert not last_bar_is_forming([], now_ms=0) + assert last_settled_idx([123], now_ms=0) == -1 # bar_ms non stimabile -> -1 diff --git a/tests/portfolio/test_real_close_dsl.py b/tests/portfolio/test_real_close_dsl.py new file mode 100644 index 0000000..4b6e411 --- /dev/null +++ b/tests/portfolio/test_real_close_dsl.py @@ -0,0 +1,103 @@ +"""_real_close: cancel del disaster-SL (retry + alert orfano) e alert REAL_DIVERGENCE. + +Usa un executor finto: nessuna rete, nessun ordine reale. +""" +from types import SimpleNamespace + +import pytest + +from src.live.execution import Fill +from src.live.strategy_worker import StrategyWorker + + +class FakeExec: + verify_polls = 1 + verify_sleep = 0.0 + disaster_sl_pct = 0.30 + + def __init__(self, cancel_responses): + self.cancel_calls = [] + self._responses = list(cancel_responses) + + def cancel_order(self, oid): + self.cancel_calls.append(oid) + return self._responses.pop(0) if self._responses else {"state": "error", "error": "boom"} + + def resting_fills(self, instrument, oid): + return 0.0, None, 0.0 + + def close_amount(self, instrument, side, amount, label=None): + return Fill(instrument, "sell", 0.0, amount, 100.0, 0.0, 0.0, + "oid-close", "filled", True) + + +def _worker(tmp_path, fake, monkeypatch, notified): + monkeypatch.setattr("src.live.strategy_worker.notify_event", + lambda ev, data=None: notified.append(ev)) + w = StrategyWorker(strategy=SimpleNamespace(name="FAKE", fee_rt=0.001), + asset="BTC", tf="1h", capital=100.0, data_dir=tmp_path, + executor=fake, exec_instrument="BTC_USDC-PERPETUAL") + w.real_in_position = True + w.real_side = "buy" + w.real_amount = 0.001 + w.real_entry_price = 100.0 + w.real_entry_notional = 0.1 + w.real_dsl_order_id = "DSL-1" + return w + + +def test_dsl_cancel_untriggered_is_success(tmp_path, monkeypatch): + notified = [] + fake = FakeExec([{"order_id": "DSL-1", "state": "untriggered"}]) + w = _worker(tmp_path, fake, monkeypatch, notified) + w._real_close(100.0, "time_limit", 0.0) + assert fake.cancel_calls == ["DSL-1"] # nessun retry + assert "REAL_DSL_CANCEL_FAIL" not in notified + assert w.real_dsl_order_id == "" + + +def test_dsl_cancel_not_found_logs_but_no_alert(tmp_path, monkeypatch): + # order_not_found = probabile trigger durante outage: log, NIENTE Telegram, no retry + notified = [] + fake = FakeExec([{"order_id": "DSL-1", "state": "error", "error": "order_not_found"}]) + w = _worker(tmp_path, fake, monkeypatch, notified) + w._real_close(100.0, "time_limit", 0.0) + assert fake.cancel_calls == ["DSL-1"] + assert "REAL_DSL_CANCEL_FAIL" not in notified + + +def test_dsl_cancel_transient_error_retries_then_succeeds(tmp_path, monkeypatch): + notified = [] + fake = FakeExec([{"state": "error", "error": "timeout"}, + {"order_id": "DSL-1", "state": "cancelled"}]) + w = _worker(tmp_path, fake, monkeypatch, notified) + w._real_close(100.0, "time_limit", 0.0) + assert fake.cancel_calls == ["DSL-1", "DSL-1"] # retry eseguito + assert "REAL_DSL_CANCEL_FAIL" not in notified + + +def test_dsl_cancel_persistent_error_alerts_orphan(tmp_path, monkeypatch): + notified = [] + fake = FakeExec([{"state": "error", "error": "timeout"}, + {"state": "error", "error": "timeout"}]) + w = _worker(tmp_path, fake, monkeypatch, notified) + w._real_close(100.0, "time_limit", 0.0) + assert fake.cancel_calls == ["DSL-1", "DSL-1"] + assert "REAL_DSL_CANCEL_FAIL" in notified # stop forse orfano -> Telegram + + +def test_real_divergence_alert_on_close(tmp_path, monkeypatch): + # fill reale 100.0 vs sim_exit 105.0 -> ~-476bps -> REAL_DIVERGENCE + notified = [] + fake = FakeExec([{"order_id": "DSL-1", "state": "untriggered"}]) + w = _worker(tmp_path, fake, monkeypatch, notified) + w._real_close(105.0, "take_profit", 0.5) + assert "REAL_DIVERGENCE" in notified + + +def test_no_divergence_alert_when_fill_matches(tmp_path, monkeypatch): + notified = [] + fake = FakeExec([{"order_id": "DSL-1", "state": "untriggered"}]) + w = _worker(tmp_path, fake, monkeypatch, notified) + w._real_close(100.05, "take_profit", 0.0) # ~-5bps: sotto soglia + assert "REAL_DIVERGENCE" not in notified