"""_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) class FakeFullExec(FakeExec): """Aggiunge open/place_tp_limit/place_disaster_sl per testare il ciclo SH01 completo.""" def __init__(self): super().__init__([{"state": "untriggered"}]) self.tp_calls = 0 self.dsl_calls = 0 self.close_amounts = [] def open(self, instrument, side, notional, label=None): amt = round(notional / 100.0, 6) return Fill(instrument, side, notional, amt, 100.0, 0.0, 0.05, "oid-open", "filled", True) def place_tp_limit(self, *a, **k): self.tp_calls += 1 # NON deve essere chiamato per SH01 (no TP) return Fill("x", "sell", 0.0, 0.0, None, 0.0, 0.0, None, None, False) def place_disaster_sl(self, instrument, side, amount, stop, label=None): self.dsl_calls += 1 return Fill(instrument, "sell", 0.0, amount, None, 0.0, 0.0, "dsl-1", "untriggered", True) def close_amount(self, instrument, side, amount, label=None): self.close_amounts.append(amount) return Fill(instrument, "sell", 0.0, amount, 105.0, 0.0, 0.05, "oid-close", "filled", True) def test_sh01_real_open_close_no_tp(tmp_path, monkeypatch): """SH01 (no TP/SL, exit a orizzonte): apre reale, NON piazza TP, piazza disaster, chiude TUTTA la quota a market reduce-only.""" from src.live.strategy_worker import StrategyWorker from src.strategies.base import Signal notified = [] monkeypatch.setattr("src.live.strategy_worker.notify_event", lambda ev, data=None: notified.append(ev)) fake = FakeFullExec() w = StrategyWorker(strategy=SimpleNamespace(name="SH01_shape_ml", fee_rt=0.001), asset="BTC", tf="1h", capital=100.0, position_size=0.5, leverage=2.0, data_dir=tmp_path, executor=fake, exec_instrument="BTC_USDC-PERPETUAL") w._open_position(Signal(idx=0, direction=1, entry_price=100.0, metadata={"max_bars": 12}), 100.0) # niente tp/sl assert w.real_in_position and fake.tp_calls == 0 # nessun TP per SH01 assert fake.dsl_calls == 1 and w.real_dsl_order_id # disaster bracket sì amt = w.real_amount w._close_position(105.0, "time_limit") assert not w.real_in_position assert fake.close_amounts == [amt] # chiude TUTTA la quota a market 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