"""Circuit-breaker venue-lock (2026-06-12): dopo lock_trip reject 'locked' consecutivi le APERTURE sono sospese senza toccare l'API (i worker seguono il path REAL_OPEN_FAIL/sim_fallback); le chiusure si tentano sempre; il probe a fine cooldown riarma o resetta. Nessuna rete: CerberoClient stubbato.""" import time from src.live.execution import ExecutionClient class _Venue: """Stub CerberoClient: locked finche' .locked=True, poi ordini ok.""" def __init__(self): self.locked = True self.place_calls = 0 def place_order(self, instrument, side, amount, order_type="market", price=None, label=None, reduce_only=False): self.place_calls += 1 if self.locked: return {"state": "error", "error": "locked_by_admin"} return {"order": {"order_id": "o1", "order_state": "filled", "filled_amount": amount, "average_price": 100.0}, "trades": [{"amount": amount, "price": 100.0, "fee": 0.0}]} def get_ticker(self, instrument): return {"mark_price": 100.0} def get_trade_history(self, limit=50, instrument_name=None): return [] def _ec(monkeypatch, sent): monkeypatch.setattr("src.live.telegram_notifier.notify_event", lambda ev, data: sent.append((ev, data))) venue = _Venue() ec = ExecutionClient(client=venue) return ec, venue INST = "ETH_USDC-PERPETUAL" def test_breaker_trips_and_blocks_opens(monkeypatch): sent = [] ec, venue = _ec(monkeypatch, sent) for _ in range(ec.lock_trip): f = ec.open(INST, "buy", 50.0) assert not f.verified assert venue.place_calls == ec.lock_trip and ec.lock_blocked() assert [e for e, _ in sent] == ["VENUE_LOCK"] # un alert al trip # aperture sospese: NESSUNA chiamata API, nota dedicata f = ec.open(INST, "buy", 50.0) assert venue.place_calls == ec.lock_trip assert "venue_lock_breaker" in f.notes assert sent == sent[:1] # niente spam def test_closes_still_attempted_and_refresh_cooldown(monkeypatch): sent = [] ec, venue = _ec(monkeypatch, sent) for _ in range(ec.lock_trip): ec.open(INST, "buy", 50.0) ec._net_close_allowance = lambda *a, **k: 0.0 # niente fallback netting before = ec._lock_until time.sleep(0.01) f = ec.close_amount(INST, "buy", 0.5, label="w1") # la chiusura PASSA dall'API assert venue.place_calls == ec.lock_trip + 1 assert not f.verified assert ec._lock_until > before # locked dal close -> cooldown esteso def test_probe_rearms_or_resets(monkeypatch): sent = [] ec, venue = _ec(monkeypatch, sent) for _ in range(ec.lock_trip): ec.open(INST, "buy", 50.0) # cooldown scaduto + venue ancora locked -> probe fallisce e riscatta ec._lock_until = time.monotonic() - 1 ec.open(INST, "buy", 50.0) assert venue.place_calls == ec.lock_trip + 1 and ec.lock_blocked() # venue sbloccato -> il probe passa, breaker resettato, alert di rientro ec._lock_until = time.monotonic() - 1 venue.locked = False f = ec.open(INST, "buy", 50.0) assert f.verified and not ec.lock_blocked() and ec._lock_streak == 0 assert sent[-1][0] == "VENUE_LOCK" and sent[-1][1].get("status") == "RIENTRATO"