feat(live): reconcile resting + orphan single-leg + circuit-breaker venue-lock + FEED_BOOK_GAP

Codice della tornata v1.1.27/28 (gia' in produzione, mai committato):
- reconcile_account: estensione ordini RESTING (FILLED_UNBOOKED/MISSING/STALE,
  caso MR02_BTC: TP fillato di notte scoperto ore dopo) + expected_resting in books
- strategy_worker: orphan_legs su REAL_CLOSE_PARTIAL anche single-leg, persistito
- execution: circuit-breaker su venue-lock admin (stop ordini dopo errori ripetuti)
- runner/hourly_report: alert FEED_BOOK_GAP + timestamp closed trades
- cerbero_client: get_open_orders (merge all + trigger_all)
Test: 12 nuovi, suite completa 126 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-12 20:29:02 +00:00
parent a2d581691a
commit 612f2bfced
11 changed files with 628 additions and 25 deletions
@@ -0,0 +1,86 @@
"""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"