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:
+64
-1
@@ -183,6 +183,20 @@ class ExecutionClient:
|
||||
# (poll-loop fermo = posizione reale senza valutazione exit). None = disattivo.
|
||||
# Configurato dal runner da overrides.execution.disaster_sl_pct.
|
||||
disaster_sl_pct: float | None = None
|
||||
# Circuit-breaker venue-lock (2026-06-12): durante il lock admin del testnet
|
||||
# (rollback conto, ~09:47) ogni place_order rispondeva 'locked_by_admin' ma i
|
||||
# worker continuavano a tentare APERTURE (leg-fail pairs + unwind + fee
|
||||
# sprecate sui leg parziali). Dopo lock_trip errori 'locked' consecutivi le
|
||||
# aperture sono SOSPESE (Fill failed senza chiamata API -> i worker seguono il
|
||||
# path REAL_OPEN_FAIL/sim_fallback gia' esistente); le CHIUSURE si tentano
|
||||
# SEMPRE (path gia' sicuro: partial/orphan/netting). Riarmo: passato
|
||||
# lock_cooldown_s la prossima apertura fa da probe — se passa il breaker si
|
||||
# resetta (alert di rientro), se e' ancora locked riscatta subito. Stato in
|
||||
# memoria: al restart il primo open rifiutato lo ri-arma.
|
||||
lock_trip: int = 3
|
||||
lock_cooldown_s: float = 900.0
|
||||
_lock_streak: int = field(default=0, init=False, repr=False)
|
||||
_lock_until: float = field(default=0.0, init=False, repr=False)
|
||||
# NB leva: su Deribit la leva per-strumento NON e' impostabile (private/set_leverage
|
||||
# risponde 400 Bad Request — verificato 2026-06-03 nei log Cerbero; il set_leverage
|
||||
# di Cerbero fallisce sempre, soppresso). Il campo "leverage: 50" in get_positions
|
||||
@@ -214,6 +228,46 @@ class ExecutionClient:
|
||||
pass
|
||||
return None
|
||||
|
||||
# --- circuit-breaker venue-lock ---
|
||||
|
||||
def _notify_safe(self, event: str, data: dict):
|
||||
try:
|
||||
from src.live.telegram_notifier import notify_event
|
||||
notify_event(event, data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def lock_blocked(self) -> bool:
|
||||
"""True se le APERTURE sono sospese (breaker scattato e cooldown attivo)."""
|
||||
return self._lock_streak >= self.lock_trip and time.monotonic() < self._lock_until
|
||||
|
||||
def _lock_track(self, error: str):
|
||||
"""Conta gli errori 'locked' consecutivi; al trip sospende le aperture.
|
||||
Ogni nuovo 'locked' (anche dalle chiusure) rinfresca il cooldown: finche'
|
||||
il venue resta bloccato le aperture non riprendono. Gli errori di altra
|
||||
natura NON toccano lo streak (un transitorio di rete non deve ne'
|
||||
armare ne' disarmare il breaker)."""
|
||||
if "locked" not in (error or "").lower():
|
||||
return
|
||||
self._lock_streak += 1
|
||||
self._lock_until = time.monotonic() + self.lock_cooldown_s
|
||||
if self._lock_streak == self.lock_trip:
|
||||
print(f"[exec] VENUE_LOCK: {self._lock_streak} reject 'locked' consecutivi "
|
||||
f"-> aperture sospese {self.lock_cooldown_s / 60:.0f}m (probe al termine)")
|
||||
self._notify_safe("VENUE_LOCK", {
|
||||
"reject_consecutivi": self._lock_streak,
|
||||
"cooldown_min": round(self.lock_cooldown_s / 60),
|
||||
"nota": "conto locked (admin/rollback testnet): aperture reali sospese, "
|
||||
"chiusure sempre tentate, sim prosegue"})
|
||||
|
||||
def _lock_reset(self):
|
||||
"""Ordine accettato dal venue: se il breaker era scattato, dichiara il rientro."""
|
||||
if self._lock_streak >= self.lock_trip:
|
||||
self._notify_safe("VENUE_LOCK", {"status": "RIENTRATO",
|
||||
"dopo_reject": self._lock_streak})
|
||||
self._lock_streak = 0
|
||||
self._lock_until = 0.0
|
||||
|
||||
# --- API ---
|
||||
|
||||
def _mark_price(self, instrument: str) -> float | None:
|
||||
@@ -246,9 +300,11 @@ class ExecutionClient:
|
||||
resp = self.client.place_order(instrument, side, amount, order_type=order_type,
|
||||
price=price, label=label, reduce_only=reduce_only)
|
||||
if not isinstance(resp, dict) or resp.get("state") == "error" or "error" in resp:
|
||||
self._lock_track(str(resp.get("error", "")) if isinstance(resp, dict) else "")
|
||||
return Fill(instrument, side, requested_notional, amount, None, 0.0, 0.0,
|
||||
None, "error", False, raw=resp if isinstance(resp, dict) else {},
|
||||
notes=f"place_order error: {resp}")
|
||||
self._lock_reset()
|
||||
|
||||
order = resp.get("order", resp) or {}
|
||||
trades = resp.get("trades", []) or []
|
||||
@@ -299,7 +355,14 @@ class ExecutionClient:
|
||||
|
||||
def open(self, instrument: str, side: str, notional_usd: float,
|
||||
label: str | None = None) -> Fill:
|
||||
"""Apre la quota del worker (market, NON reduce_only)."""
|
||||
"""Apre la quota del worker (market, NON reduce_only). Con breaker
|
||||
venue-lock attivo NON tocca l'API: Fill failed -> il chiamante segue il
|
||||
path REAL_OPEN_FAIL/sim_fallback (per i pairs: entrambe le gambe
|
||||
rifiutate localmente, nessun leg parziale da unwindare)."""
|
||||
if self.lock_blocked():
|
||||
return Fill(instrument, side, notional_usd, 0.0, None, 0.0, 0.0,
|
||||
None, "error", False,
|
||||
notes="venue_lock_breaker: aperture sospese (conto locked)")
|
||||
amount = self.amount_for(instrument, notional_usd)
|
||||
return self._submit(instrument, side, amount, notional_usd,
|
||||
reduce_only=False, label=label)
|
||||
|
||||
Reference in New Issue
Block a user