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
+11
View File
@@ -348,6 +348,17 @@ queste fade, ma va confermato col paper trader live prima di rischiare capitale
- **Altri fix StrategyWorker (2026-06-01).** Exit a orizzonte puro per strategie senza TP/SL (`elif self.max_bars`, SH01 esce a H=12 non hold_bars=3); `is_win = net > 0` (win NETTO fee, non lordo); filtro `min_tp_frac` (salta micro-scalp col TP entro le fee); loss-guard `hurst_max=0.55` sulle fade (vedi sopra).
- **Exit intrabar (fase 3, risolto):** lo `StrategyWorker` ora esce sui TP/SL toccati INTRABAR (high/low della barra, al livello, SL prioritario) come il backtest — non più solo sul close. Allinea fade/DIP01 live al backtest intrabar (`tests/portfolio/test_intrabar_exit.py`). Caveat residuo onesto: nel paper trading l'high/low usato è quello della barra in corso al poll; su un fill reale conterebbe il momento del tocco.
- **ESECUZIONE REALE — shadow (v1.0.3, 2026-06-03).** I **6 fade** (MR01/MR02/MR07 × BTC/ETH) eseguono ordini **REALI su Deribit testnet** accanto al fill simulato (*shadow*: sim + reale in parallelo, il sim resta la verità che guida le decisioni). `src/live/execution.py` `ExecutionClient`: `open` (market) + `close_amount` (market **reduce-only della SOLA quota del worker** — i 3 fade BTC condividono lo strumento e le posizioni si nettano per conto, quindi NON si usa `close_position` che flatterebbe le quote altrui); **verifica l'esecuzione sul TRADE** (order_id in `get_trade_history`, non sulla size netta aggregata); **fee REALI lette dai `trades[]`**. Strumenti = **lineari USDC** (`BTC_USDC`/`ETH_USDC-PERPETUAL`, amount nel base-coin, step 0.0001/0.001): scelti perché il **payoff lineare == matematica del backtest** (l'inverse `*-PERPETUAL` introdurrebbe una base 1/prezzo) e fee/PnL sono in USDC. Lo `StrategyWorker` tiene un **ledger reale parallelo** (`real_capital`, persistito) e logga `REAL_OPEN`/`REAL_CLOSE` col confronto **slippage** (prezzo sim vs eseguito) e **fee** (assunta 0.10% vs reale). Config: `portfolios.yml``overrides.execution {enabled, sleeves:[MR01,MR02,MR07,DIP01], instruments:{BTC:BTC_USDC-PERPETUAL, ETH:ETH_USDC-PERPETUAL}}` (**DIP01 aggiunto il 2026-06-04**: stesso wiring single-leg, TP resting incluso); pairs/rotation/tsmom/shape restano **simulati** (pairs richiede un executor a 2 gambe con gestione leg-risk; shape non ha TP). **Fee reali misurate = 0.05%/lato = 0.10% RT** (== assunto del backtest, su ETH; BTC inverse era ~0.094%). **Alert Telegram:** `REAL_EXEC_LIVE` (primo ordine reale verificato per worker) + `REAL_OPEN_FAIL`. Smoke (testnet, €0): `scripts/analysis/live_exec_smoke.py` (layer: ordine→verifica→fee) e `live_shadow_smoke.py` (catena worker open/close). **Capitale live portato a 2000** (notional fade ~$35) per ridurre il rumore di arrotondamento su BTC (step lineare ~$6.7). NB: ledger reale ≠ ledger sim — i worker già in posizione sim a un restart non hanno quota reale corrispondente; lo shadow reale parte pulito dalla prossima apertura.
- **Disaster-bracket on-book + alert outage (v1.1.4, 2026-06-07).** A ogni `REAL_OPEN` dei fade
eseguiti il worker piazza uno **STOP_MARKET reduce-only a ~30%** dall'ingresso (trigger sul mark,
`ExecutionClient.place_disaster_sl`, cancellato in `_real_close`): assicurazione per gli outage
(poll-loop fermo = exit non valutati), in operatività normale non scatta mai → 0 costo Sharpe.
Config `overrides.execution.disaster_sl_pct` (0.30; 0=off). NB: il `set_stop_loss` di cerbero-mcp
è un `private/edit` (solo ordini APERTI) → inutilizzabile su market fillati; la cancel di un
trigger order risponde `untriggered` (= successo, verificato testnet). Alert Telegram `FEED_OUTAGE`
dopo 5 poll falliti consecutivi (con elenco posizioni reali aperte) + notifica di ripresa. I fix
di parità della stessa tornata (v1.1.3): TR01 fee×leva + forming-bar TR01/Pairs + WARN
`PANEL_SHORT` su TSM01/ROT02; `hourly_report` ora mostra i multi-asset (sezione MULTI-ASSET).
Diario `docs/diary/2026-06-07-sweep-fixes.md`.
- **TP reale = LIMIT reduce-only AL LIVELLO (2026-06-04).** Misurati +235 bps di slippage medio sulle uscite take-profit market-on-poll (sim esce al livello intrabar, il reale chiudeva al poll post-rimbalzo: sim +11.85 vs reale +0.62 USD sui primi 7 close). Fix: a ogni `REAL_OPEN` il worker piazza un **limit reduce-only al TP** (`ExecutionClient.place_tp_limit`, prezzo quantizzato al tick, SOLA quota del worker) → `REAL_TP_RESTING`; a ogni chiusura sim `_real_close` **cancella il resting → riconcilia i fill (anche parziali) via `get_trade_history` per order_id → market reduce-only solo del residuo** → ledger su prezzo combinato. `real_tp_order_id` persistito in `status.json` (resume-safe). Lo **SL resta market-on-poll** (deliberato: i trigger Deribit generano un nuovo order_id al trigger → fill non verificabile per order_id; e sul SL il rimbalzo lavora a favore). Fill da resting = fee **maker ~0%**. Smoke: `live_shadow_smoke.py` (2 scenari, testnet). Diario `docs/diary/2026-06-04-shadow-divergence.md`.
- **Limite noto:** al ribilancio le posizioni APERTE restano sul loro notional (non travasate); fedele al backtest daily-rebalanced entro il turnover infragiornaliero.
+73
View File
@@ -0,0 +1,73 @@
# 2026-06-07 — Implementazione fix improvement-sweep (P0 + punti 2-6, settimana 1)
Implementati e deployati i fix "settimana 1" del piano dello sweep 2026-06-06
(tutti fix di parità/protezione/osservabilità, nessun cambio di strategia → nessun
gate OOS richiesto). Versioni: v1.1.3 (punti 1-4), v1.1.4 (punti 5-6).
## P0 — cap SHAPE 0.0588 ripristinato (v1.1.3)
`portfolios.yml` ridichiara il dict caps COMPLETO `{PAIRS: 0.33, SHAPE: 0.0588}`
(l'override sostituisce interamente i caps di `_defs.py` via setattr in
`base.py:load_active_portfolio` — footgun ora commentato nel yml). Verificato live
post-restart: SH_BTC+SH_ETH = 0.0588 totale (prima 0.1176 = 2x intended), non-cappati
risaliti a 0.0647, sum = 1.0 — parità col backtest canonico (FULL 6.43 / OOS 8.58).
## Punti 2-4 — parità worker multi-asset (v1.1.3)
- **TR01 fee × leva**: `POS · LEV · fee_rt/2` per flip come il reference
`honest_improve2._tr_basket_daily:150` (prima sotto-caricata 2x).
- **TR01 forming-bar**: crossover EMA E booking del return solo su barre 4h COMPLETE
(riga -1 = candela in corso finché non è trascorsa la sua durata, pattern EXIT-16).
Evidenza live che non può più ripetersi: flip SOL 0→1→0 in 59min stessa finestra 4h.
- **PairsWorker forming-bar**: entry ED exit sul close di barra COMPLETA, come il
backtest `pairs_research` (close settled).
- **TSM01/ROT02 silent-return**: WARN log + Telegram `PANEL_SHORT` quando il panel
inner-join è sotto il lookback (helper condiviso `_warn_panel_short` in
rotation_worker), gated su "era già operativo", una notifica per episodio.
Verifiche: 72 test (7 nuovi), `validate_worker_pairs` ESATTO, `validate_honest_workers`
invariato. **Nota onesta**: TR01 replay worker 44% vs reference +42% è IDENTICO
pre/post fix → divergenza di convenzione pre-esistente (capitale-unico con
`mean(rets)` solo sugli asset in posizione vs media-equity 1/N del reference).
Da rivisitare a parte: "stesso ordine di grandezza" oggi non regge più.
## Punto 5 — disaster-bracket on-book + alert outage (v1.1.4)
Durante un outage (poll-loop in except) le posizioni REALI restavano senza stop sul
book e senza valutazione exit. Ora:
- **`ExecutionClient.place_disaster_sl`**: STOP_MARKET reduce-only a ~30%
dall'ingresso (trigger sul mark), piazzato a ogni REAL_OPEN sui fade eseguiti
(MR01/MR02/MR07/DIP01) e cancellato in `_real_close` (prima del TP-reconcile).
In operatività normale non scatta mai → 0 costo Sharpe; nei crash il fill è al
gap (cappa la coda, non la elimina). Config: `overrides.execution.disaster_sl_pct`
(default 0.30, 0 = off). `real_dsl_order_id` persistito (resume-safe).
- **SCOPERTA**: il `set_stop_loss` di cerbero-mcp è un `private/edit` Deribit (solo
ordini APERTI) → inutilizzabile su market già fillati; il bracket va piazzato come
trigger order autonomo via `place_order(type="stop_market")` (già supportato).
- **Semantica cancel trigger order** (verificata su testnet): la cancel risponde con
lo stato AL MOMENTO della cancel (`untriggered` = successo; il re-cancel dà
`order_not_found`); `error` = non più in book (probabile trigger scattato → il
market a valle filla 0 e REAL_CLOSE esce verified=False).
- **Alert `FEED_OUTAGE`**: dopo 5 poll falliti consecutivi (~5 min) Telegram con
l'elenco delle posizioni reali aperte; notifica di ripresa con durata.
Smoke testnet (`live_shadow_smoke.py`, esteso): 4 scenari long+short — il path
**short-side TP-resting** (BUY limit reduce-only) non era MAI stato esercitato.
Tutti verdi: resting/cross-immediato due lati, DSL piazzato/cancellato (verificato
zero ordini orfani sul book), conto flat a fine smoke.
## Punto 6 — osservabilità multi-asset (v1.1.4)
- `in_position` aggiunto ai `_save()` di TR01/ROT02/TSM01.
- `hourly_report`: nuova sezione **MULTI-ASSET** (book corrente | ultimo flip |
freschezza status) — prima i 3 worker erano invisibili nel report (collect()
filtra su event/in_position che non emettevano): impossibile distinguere
"flat/risk-off by-design" da "wiring rotto". I multi-asset sono esclusi dalla
tabella IN CORSO (assume entry/bars single-leg).
## Resta in roadmap (settimana 2, OGNUNO dietro gate PORT06/OOS)
trend_max=3.0 sulle 6 fade → pos pairs 0.15-0.25 per-famiglia → DIP01 EXIT-16
(validazione gap-aware) → SH01 ri-validazione train-window ~8760 barre.
Follow-up tecnico: divergenza convenzione TR01 worker vs reference (sopra).
+5
View File
@@ -27,3 +27,8 @@ overrides:
instruments:
BTC: BTC_USDC-PERPETUAL
ETH: ETH_USDC-PERPETUAL
# Disaster-bracket on-book (2026-06-07): STOP_MARKET reduce-only a ~-30%
# dall'ingresso, piazzato a ogni REAL_OPEN e cancellato alla chiusura.
# Assicurazione per gli outage (runner fermo = exit non valutati); in
# operativita' normale non scatta mai -> 0 costo Sharpe. 0 = disattivo.
disaster_sl_pct: 0.30
+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())
+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))
+28 -2
View File
@@ -210,8 +210,10 @@ def run(config_path: str = "portfolios.yml"):
if exec_enabled:
from src.live.execution import ExecutionClient
executor = ExecutionClient(client=client)
# disaster-bracket on-book (~-30%): assicurazione outage sui fade reali
executor.disaster_sl_pct = float(_exec_cfg.get("disaster_sl_pct", 0.30) or 0) or None
print(f"[runner] ESECUZIONE REALE attiva (shadow) — sleeve={sorted(exec_sleeves)} "
f"strumenti={exec_instr}")
f"strumenti={exec_instr} disaster_sl={executor.disaster_sl_pct}")
def _exec_for(s):
"""(executor, exec_instrument) per uno sleeve, solo se fade single-leg abilitato."""
@@ -243,6 +245,12 @@ def run(config_path: str = "portfolios.yml"):
inst_map = dict(INSTRUMENT_MAP)
last_day = ""
stale_alerted: set[str] = set() # asset con alert STALE_FEED attivo (dedup per episodio)
# Osservabilita' outage (improvement-sweep 2026-06-06): il poll-loop intero e' in un
# try/except → durante un outage i worker NON valutano gli exit. Alert Telegram dopo
# _OUTAGE_POLLS poll falliti consecutivi (con l'elenco delle posizioni REALI aperte,
# protette solo dal disaster-bracket on-book) + notifica di ripresa con la durata.
_OUTAGE_POLLS = 5
fail_streak = 0
while True:
try:
# fetch 1h per asset al lookback massimo richiesto
@@ -289,12 +297,30 @@ def run(config_path: str = "portfolios.yml"):
rebalance_allocations(ledger, workers, weights)
last_day = today
ledger.save()
if fail_streak >= _OUTAGE_POLLS:
from src.live.telegram_notifier import notify_event
notify_event("FEED_OUTAGE", {
"status": "RIPRESO",
"poll_falliti": fail_streak,
"durata_min": round(fail_streak * poll / 60)})
fail_streak = 0
except KeyboardInterrupt:
ledger.save()
print("shutdown")
break
except Exception as e:
print(f"[runner] errore: {e}")
fail_streak += 1
print(f"[runner] errore: {e} (streak {fail_streak})")
if fail_streak == _OUTAGE_POLLS:
from src.live.telegram_notifier import notify_event
real_open = sorted(sid for sid, wk in workers.items()
if getattr(wk, "real_in_position", False))
notify_event("FEED_OUTAGE", {
"poll_falliti": fail_streak,
"minuti": round(fail_streak * poll / 60),
"posizioni_reali_aperte": ", ".join(real_open) or "nessuna",
"nota": "exit NON valutati durante l'outage; "
"protezione = disaster-SL on-book sui fade reali"})
time.sleep(poll)