feat(live): disaster-bracket on-book sui fade reali + alert FEED_OUTAGE + osservabilita' multi-asset

Punti 5-6 dell'improvement-sweep 2026-06-06 (protezione capitale + osservabilita'):

Punto 5 — disaster bracket:
- ExecutionClient.place_disaster_sl: STOP_MARKET reduce-only a ~-30% dall'ingresso
  (trigger mark price), piazzato a ogni REAL_OPEN (MR01/MR02/MR07/DIP01) e
  cancellato in _real_close. Assicurazione outage: il poll-loop in except lascia
  le posizioni reali senza valutazione exit (ETH gap max storico 33%/1h). In
  operativita' normale non scatta mai -> 0 costo Sharpe. real_dsl_order_id
  persistito (resume-safe). Config overrides.execution.disaster_sl_pct (0.30).
- NB: set_stop_loss di cerbero-mcp e' un private/edit Deribit (solo ordini APERTI)
  -> non usabile su market fillati; il bracket e' un trigger order autonomo via
  place_order(type=stop_market). Cancel di un trigger order risponde 'untriggered'
  (= successo, verificato testnet: re-cancel -> order_not_found).
- Runner: alert Telegram FEED_OUTAGE dopo 5 poll falliti consecutivi (elenco
  posizioni reali aperte) + notifica RIPRESO con durata.

Punto 6 — osservabilita':
- in_position nei _save() di TR01/ROT02/TSM01; hourly_report: sezione MULTI-ASSET
  (book | ultimo flip | freschezza status) — prima i 3 worker erano invisibili
  (collect() filtra su event/in_position che non emettevano); esclusi dalla
  tabella IN CORSO (assume entry/bars single-leg).
- live_shadow_smoke esteso: scenari C/D SHORT (TP-resting BUY mai esercitato
  prima) + disaster bracket in tutti gli scenari.

Verifiche: 72/72 test; smoke testnet 4 scenari verdi (DSL piazzato/cancellato due
lati, zero ordini orfani sul book, conto flat); multi_asset_section renderizza sui
dati live. Diario docs/diary/2026-06-07-sweep-fixes.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-07 09:36:11 +00:00
parent bd5f1a6ea0
commit 62e272255b
12 changed files with 288 additions and 7 deletions
+1
View File
@@ -49,6 +49,7 @@ class BasketTrendWorker:
def _save(self):
self.status_path.write_text(json.dumps({
"capital": round(self.capital, 2), "positions": self.positions,
"in_position": self.in_position, # per hourly_report (osservabilita')
"last_bar_ts": self.last_bar_ts,
"ts": datetime.now(timezone.utc).isoformat()}, indent=2))
+27 -1
View File
@@ -118,6 +118,11 @@ class ExecutionClient:
client: CerberoClient = field(default_factory=CerberoClient)
verify_polls: int = 4 # tentativi di riverifica
verify_sleep: float = 0.6 # attesa fra i poll (s)
# Disaster-bracket on-book (2026-06-07): distanza % dello STOP_MARKET reduce-only
# piazzato a ogni REAL_OPEN come assicurazione per gli outage del feed/runner
# (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
# 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
@@ -207,9 +212,12 @@ class ExecutionClient:
fee_coin * fill_price if (fee_coin and fill_price) else 0.0)
# VERIFICA: market = ordine filled E fill riscontrato (trades o history);
# limit = accettato in book ('open') o gia' eseguito ('filled')
# limit = accettato in book ('open') o gia' eseguito ('filled');
# stop_market = trigger accettato ('untriggered' finche' il mark non tocca)
if order_type == "market":
verified = (state == "filled") and (bool(trades) or th is not None)
elif order_type == "stop_market":
verified = state in ("untriggered", "open", "filled")
else:
verified = state in ("open", "filled")
return Fill(instrument, side, requested_notional, amount, fill_price,
@@ -253,6 +261,24 @@ class ExecutionClient:
return self._submit(instrument, opp, amount, 0.0, reduce_only=True,
label=label, order_type="limit", price=px)
def place_disaster_sl(self, instrument: str, entry_side: str, amount: float,
stop_price: float, label: str | None = None) -> Fill:
"""STOP_MARKET reduce-only LONTANO (disaster bracket ~-30%): assicurazione
on-book per gli outage — in operativita' normale non scatta mai (lo SL
della strategia esce molto prima, market-on-poll) -> 0 costo Sharpe.
Trigger sul MARK price; copre la SOLA quota del worker. Nei crash il fill
e' al gap, non al livello: cappa la coda, non la elimina (exit-lab).
NB: il set_stop_loss di cerbero_client e' un private/edit Deribit (solo
ordini APERTI) -> inutilizzabile su una posizione gia' fillata; il
bracket si piazza come ordine trigger autonomo."""
opp = "sell" if entry_side == "buy" else "buy"
px = quantize_price(instrument, stop_price)
if amount <= 0 or px <= 0:
return Fill(instrument, opp, 0.0, amount, None, 0.0, 0.0,
None, None, False, notes="amount/stop non validi")
return self._submit(instrument, opp, amount, 0.0, reduce_only=True,
label=label, order_type="stop_market", price=px)
def cancel_order(self, order_id: str) -> dict:
"""Cancella un ordine resting. {'state': 'cancelled'} su successo;
'error' se l'ordine non e' piu' open (es. gia' fillato) — NON fatale:
+1
View File
@@ -83,6 +83,7 @@ class RotationWorker:
def _save(self):
self.status_path.write_text(json.dumps({
"capital": round(self.capital, 2), "weights": self.weights,
"in_position": self.in_position, # per hourly_report (osservabilita')
"last_bar_ts": self.last_bar_ts,
"ts": datetime.now(timezone.utc).isoformat()}, indent=2))
+47
View File
@@ -57,6 +57,7 @@ class StrategyWorker:
self.real_entry_notional = 0.0 # USD effettivi esposti all'entrata
self.real_order_id = ""
self.real_tp_order_id = "" # LIMIT reduce-only resting al TP (persistito per il resume)
self.real_dsl_order_id = "" # STOP_MARKET disaster bracket on-book (persistito)
self.real_trades = 0
self.real_first_notified = False # alert Telegram "esecuzione viva" una tantum
@@ -126,6 +127,7 @@ class StrategyWorker:
self.real_entry_notional = state.get("real_entry_notional", 0.0)
self.real_order_id = state.get("real_order_id", "")
self.real_tp_order_id = state.get("real_tp_order_id", "")
self.real_dsl_order_id = state.get("real_dsl_order_id", "")
self.real_trades = state.get("real_trades", 0)
self.real_first_notified = state.get("real_first_notified", False)
@@ -159,6 +161,7 @@ class StrategyWorker:
"real_entry_notional": self.real_entry_notional,
"real_order_id": self.real_order_id,
"real_tp_order_id": self.real_tp_order_id,
"real_dsl_order_id": self.real_dsl_order_id,
"real_trades": self.real_trades,
"real_first_notified": self.real_first_notified,
"last_update": datetime.now(timezone.utc).isoformat(),
@@ -246,6 +249,7 @@ class StrategyWorker:
self._notify("REAL_EXEC_LIVE", data)
self.real_first_notified = True
self._place_real_tp()
self._place_disaster_sl()
else:
self._log("REAL_OPEN_FAIL", {**data, "note": fill.notes})
self._notify("REAL_OPEN_FAIL", {**data, "note": fill.notes})
@@ -274,6 +278,35 @@ class StrategyWorker:
else:
self._log("REAL_TP_FAIL", {**data, "note": rest.notes})
def _place_disaster_sl(self):
"""Disaster-bracket on-book (improvement-sweep 2026-06-06 P1): STOP_MARKET
reduce-only LONTANO (executor.disaster_sl_pct, ~30% dall'ingresso) sulla
SOLA quota del worker. Pura assicurazione per gli outage (poll-loop fermo
= posizione reale senza valutazione exit; ETH gap max storico 33% in 1h):
in operativita' normale non scatta mai. Se il piazzamento fallisce si
resta senza bracket (come prima del fix) — solo log, non fatale."""
self.real_dsl_order_id = ""
pct = getattr(self.executor, "disaster_sl_pct", None)
if not (pct and self.real_amount > 0 and self.real_entry_price > 0):
return
stop = self.real_entry_price * (1 - pct if self.real_side == "buy" else 1 + pct)
rest = self.executor.place_disaster_sl(self.exec_instrument, self.real_side,
self.real_amount, stop,
label=self.worker_id)
data = {
"instrument": self.exec_instrument,
"order_id": rest.order_id,
"stop": round(stop, 2),
"pct": pct,
"amount": self.real_amount,
"state": rest.order_state,
}
if rest.verified and rest.order_id:
self.real_dsl_order_id = rest.order_id
self._log("REAL_DSL_RESTING", data)
else:
self._log("REAL_DSL_FAIL", {**data, "note": rest.notes})
def _real_close(self, sim_exit: float, reason: str, sim_pnl: float):
"""Chiusura REALE (reduce-only della quota worker) + confronto col sim.
@@ -288,6 +321,19 @@ class StrategyWorker:
from src.live.execution import contract_spec
step = contract_spec(self.exec_instrument)["step"]
# 0) disaster bracket: via dal book PRIMA di chiudere (se la cancel fallisce
# lo stop potrebbe essere SCATTATO durante un outage: quota gia' chiusa →
# il market reduce-only a valle filla 0 e REAL_CLOSE esce verified=False)
# NB: la cancel di un trigger order risponde con lo stato AL MOMENTO della
# cancel ('untriggered' = successo, verificato su testnet: il re-cancel da'
# order_not_found); 'error' = ordine non piu' in book (probabile trigger).
if self.real_dsl_order_id:
dres = self.executor.cancel_order(self.real_dsl_order_id)
if dres.get("state") not in ("cancelled", "untriggered"):
self._log("REAL_DSL_CANCEL_FAIL", {"order_id": self.real_dsl_order_id,
"res": dres})
self.real_dsl_order_id = ""
# 1) ordine TP resting: cancella, poi riconcilia i fill (order_id su history)
tp_amt, tp_px, tp_fee = 0.0, None, 0.0
tp_order_id = self.real_tp_order_id
@@ -357,6 +403,7 @@ class StrategyWorker:
self.real_entry_notional = 0.0
self.real_order_id = ""
self.real_tp_order_id = ""
self.real_dsl_order_id = ""
def _close_position(self, current_price: float, reason: str):
if not self.in_position:
+2
View File
@@ -21,6 +21,8 @@ NOTIFY_EVENTS = {
# prezzo reale puo' gappare, come ETH 2026-06-05 1655->1600)
"PANEL_SHORT", # TSM01/ROT02: panel inner-join troncato sotto il lookback
# richiesto -> tick() salterebbe in SILENZIO (worker inerte)
"FEED_OUTAGE", # N poll consecutivi falliti nel runner: exit non valutati,
# posizioni reali protette solo dal disaster-SL on-book
}
+1
View File
@@ -47,6 +47,7 @@ class TsmomWorker:
def _save(self):
self.status_path.write_text(json.dumps({
"capital": round(self.capital, 2), "weights": self.weights,
"in_position": self.in_position, # per hourly_report (osservabilita')
"last_bar_ts": self.last_bar_ts,
"ts": datetime.now(timezone.utc).isoformat()}, indent=2))