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
+48 -4
View File
@@ -7,7 +7,14 @@ DUE percorsi del LIMIT reduce-only al TP (fix divergenza sim/reale 2026-06-04):
reduce-only di fallback (REAL_CLOSE con tp_filled_amount=0);
B) TP gia' oltre il prezzo → il limit crossa e filla SUBITO; la chiusura
riconcilia il fill dal trade history (order_id) SENZA ordine market
(REAL_CLOSE con market_amount=0).
(REAL_CLOSE con market_amount=0);
C) SHORT con TP lontano sotto → resting BUY in book + exit non-TP (il path
short non era MAI stato esercitato: tutti i REAL_TP_RESTING storici sono
side=sell — improvement-sweep 2026-06-06);
D) SHORT con TP sopra il prezzo → il buy limit crossa subito → riconciliazione.
In TUTTI gli scenari e' attivo il disaster-bracket (~-30%): verifica che lo
STOP_MARKET reduce-only venga piazzato all'open e cancellato alla chiusura.
Non tocca lo stato di produzione (data_dir temporanea). Costo testnet = €0.
@@ -33,6 +40,7 @@ def main() -> None:
raise SystemExit("ABORT: non testnet")
ex = ExecutionClient(client=client)
ex.disaster_sl_pct = 0.30 # come in produzione (portfolios.yml)
instrument = "BTC_USDC-PERPETUAL"
price = ex._mark_price(instrument)
print(f"{instrument} mark={price}")
@@ -54,8 +62,10 @@ def main() -> None:
f"amount={w.real_amount} entry={w.real_entry_price} "
f"entry_fee=${w.real_entry_fee_usd:.5f} notional=${w.real_entry_notional:.2f}")
assert w.real_in_position, "OPEN reale non verificato"
print(f" TP resting order_id={w.real_tp_order_id!r}")
print(f" TP resting order_id={w.real_tp_order_id!r} "
f"DSL order_id={w.real_dsl_order_id!r}")
assert w.real_tp_order_id, "LIMIT reduce-only al TP non piazzato"
assert w.real_dsl_order_id, "disaster-SL STOP_MARKET non piazzato"
cap_before = w.real_capital
w._close_position((w.entry_price or price) * 1.001, "time_limit")
@@ -63,6 +73,7 @@ def main() -> None:
f"{w.real_capital - cap_before:+.4f}) real_trades={w.real_trades}")
assert not w.real_in_position, "posizione reale non chiusa"
assert not w.real_tp_order_id, "order_id TP non resettato dopo la chiusura"
assert not w.real_dsl_order_id, "order_id DSL non resettato dopo la chiusura"
# --- Scenario B: TP gia' oltre il prezzo → il limit crossa e filla subito ---
print("\n[B] TP gia' crossato (fill immediato del limit) → close riconcilia da history")
@@ -79,11 +90,44 @@ def main() -> None:
f"{w.real_capital - cap_before:+.4f}) real_trades={w.real_trades}")
assert not w.real_in_position, "posizione reale non chiusa (B)"
# --- Scenario C: SHORT, TP lontano sotto → resting BUY, exit non-TP ---
print("\n[C] SHORT, TP lontano sotto (resting BUY) → exit time_limit → cancel + market")
price = ex._mark_price(instrument) or price
sig = Signal(idx=0, direction=-1, entry_price=price,
metadata={"tp": price * 0.95, "sl": price * 1.50, "max_bars": 6})
w._open_position(sig, price)
print(f" side={w.real_side} amount={w.real_amount} "
f"TP={w.real_tp_order_id!r} DSL={w.real_dsl_order_id!r}")
assert w.real_in_position and w.real_side == "sell", "OPEN short non verificato (C)"
assert w.real_tp_order_id, "LIMIT BUY reduce-only al TP non piazzato (C)"
assert w.real_dsl_order_id, "disaster-SL short non piazzato (C)"
cap_before = w.real_capital
w._close_position((w.entry_price or price) * 0.999, "time_limit")
print(f" real_capital {cap_before:.4f} -> {w.real_capital:.4f} "
f"{w.real_capital - cap_before:+.4f}) real_trades={w.real_trades}")
assert not w.real_in_position, "posizione short non chiusa (C)"
# --- Scenario D: SHORT, TP sopra il prezzo → il buy limit crossa subito ---
print("\n[D] SHORT, TP gia' crossato (buy limit marketable) → riconciliazione da history")
price = ex._mark_price(instrument) or price
sig = Signal(idx=0, direction=-1, entry_price=price,
metadata={"tp": price * 1.005, "sl": price * 1.50, "max_bars": 6})
w._open_position(sig, price)
assert w.real_in_position and w.real_side == "sell", "OPEN short non verificato (D)"
cap_before = w.real_capital
w._close_position(w.tp, "take_profit")
print(f" real_capital {cap_before:.4f} -> {w.real_capital:.4f} "
f"{w.real_capital - cap_before:+.4f}) real_trades={w.real_trades}")
assert not w.real_in_position, "posizione short non chiusa (D)"
# verifica finale: il conto e' flat sullo strumento (nessuna quota residua del worker)
pos = ex._position_size(instrument)
print(f"\n posizione netta {instrument}: {pos}")
print("✓ catena shadow OK — open reale, LIMIT TP resting (A: cancel+market, "
"B: fill immediato riconciliato), fee reali nel ledger reale")
print("✓ catena shadow OK — open reale long+short, LIMIT TP resting due lati "
"(cancel+market e fill immediato riconciliato), disaster-SL on-book "
"piazzato/cancellato, fee reali nel ledger reale")
if __name__ == "__main__":
+44
View File
@@ -108,6 +108,8 @@ def collect():
pnl = ev.get("pnl", 0.0)
realized += pnl
closed.append((_short(wid), ev.get("reason", "?"), nr, pnl, nr > 0))
if "positions" in st or "weights" in st:
continue # multi-asset (TR01/ROT02/TSM01): sezione dedicata
if st.get("in_position"):
open_pos.append({
"sleeve": _short(wid),
@@ -120,6 +122,43 @@ def collect():
return closed, open_pos, realized
def multi_asset_section() -> str:
"""Worker multi-asset (TR01/ROT02/TSM01): book corrente + eta' dell'ultimo flip
+ freschezza dello status. Prima erano INVISIBILI nel report (collect() filtra
su event/in_position che non emettevano): impossibile distinguere 'flat in
chop / risk-off by-design' da 'wiring rotto' (improvement-sweep 2026-06-06)."""
now = datetime.now(timezone.utc)
rows = []
for sp in sorted(glob.glob(str(PAPER / "*" / "status.json"))):
d = Path(sp).parent
st = json.loads(Path(sp).read_text())
book = st.get("positions") if "positions" in st else st.get("weights")
if book is None:
continue # single-leg/pairs: gia' coperti da collect()
held = {a: v for a, v in book.items() if v > 0}
flip = "mai"
tp = d / "trades.jsonl"
if tp.exists():
lines = [ln for ln in tp.read_text().splitlines() if ln.strip()]
if lines:
ts = json.loads(lines[-1]).get("ts", "")
if ts:
days = (now - datetime.fromisoformat(ts)).days
flip = f"{days}g fa"
fresh = "?"
lu = st.get("ts") or st.get("last_update")
if lu:
h = (now - datetime.fromisoformat(lu)).total_seconds() / 3600
fresh = "OK" if h < 2 else f"STALE {h:.0f}h"
code = d.name.split("__")[0].split("_")[0] # TR01_basket__... -> TR01
hb = ",".join(f"{a}:{v:.2f}" for a, v in sorted(held.items())) if held else "flat"
rows.append(f"{code:<7}{hb:<26}{flip:>8} {fresh}")
if not rows:
return ""
return ("📈 <b>MULTI-ASSET</b> (book | ultimo flip | status)\n<pre>"
+ "\n".join(rows) + "</pre>")
def build_report() -> str:
closed, open_pos, realized = collect()
pos = sum(1 for c in closed if c[4])
@@ -166,6 +205,11 @@ def build_report() -> str:
rows.append(f"{p['sleeve']:<14}{d:<2}{p['bars']:>6} {es}")
L.append("<pre>" + "\n".join(rows) + "</pre>")
# 2a) worker multi-asset (TR01/ROT02/TSM01)
mas = multi_asset_section()
if mas:
L.append(mas)
# 2b) monitor loss-guard
L.append(lossguard_section())