fix(live): 4 hardening da code-review — REAL_DIVERGENCE, outage su feed vuoto, bars.py condiviso, DSL cancel retry
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user