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:
@@ -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"
|
||||
Reference in New Issue
Block a user