From 62e272255b7f0cc82f682035c960508f0cb12747 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Sun, 7 Jun 2026 09:36:11 +0000 Subject: [PATCH] feat(live): disaster-bracket on-book sui fade reali + alert FEED_OUTAGE + osservabilita' multi-asset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 11 ++++ docs/diary/2026-06-07-sweep-fixes.md | 73 +++++++++++++++++++++++++++ portfolios.yml | 5 ++ scripts/analysis/live_shadow_smoke.py | 52 +++++++++++++++++-- scripts/portfolios/hourly_report.py | 44 ++++++++++++++++ src/live/basket_trend_worker.py | 1 + src/live/execution.py | 28 +++++++++- src/live/rotation_worker.py | 1 + src/live/strategy_worker.py | 47 +++++++++++++++++ src/live/telegram_notifier.py | 2 + src/live/tsmom_worker.py | 1 + src/portfolio/runner.py | 30 ++++++++++- 12 files changed, 288 insertions(+), 7 deletions(-) create mode 100644 docs/diary/2026-06-07-sweep-fixes.md diff --git a/CLAUDE.md b/CLAUDE.md index 41cf754..8e7e14f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/docs/diary/2026-06-07-sweep-fixes.md b/docs/diary/2026-06-07-sweep-fixes.md new file mode 100644 index 0000000..1ea07e9 --- /dev/null +++ b/docs/diary/2026-06-07-sweep-fixes.md @@ -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). diff --git a/portfolios.yml b/portfolios.yml index 30c18b7..0860228 100644 --- a/portfolios.yml +++ b/portfolios.yml @@ -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 diff --git a/scripts/analysis/live_shadow_smoke.py b/scripts/analysis/live_shadow_smoke.py index 4ae49cd..472efae 100644 --- a/scripts/analysis/live_shadow_smoke.py +++ b/scripts/analysis/live_shadow_smoke.py @@ -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__": diff --git a/scripts/portfolios/hourly_report.py b/scripts/portfolios/hourly_report.py index 235e669..1a8b54d 100644 --- a/scripts/portfolios/hourly_report.py +++ b/scripts/portfolios/hourly_report.py @@ -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 ("📈 MULTI-ASSET (book | ultimo flip | status)\n
"
+            + "\n".join(rows) + "
") + + 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("
" + "\n".join(rows) + "
") + # 2a) worker multi-asset (TR01/ROT02/TSM01) + mas = multi_asset_section() + if mas: + L.append(mas) + # 2b) monitor loss-guard L.append(lossguard_section()) diff --git a/src/live/basket_trend_worker.py b/src/live/basket_trend_worker.py index 4460c7a..18903af 100644 --- a/src/live/basket_trend_worker.py +++ b/src/live/basket_trend_worker.py @@ -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)) diff --git a/src/live/execution.py b/src/live/execution.py index 35c4419..b931af1 100644 --- a/src/live/execution.py +++ b/src/live/execution.py @@ -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: diff --git a/src/live/rotation_worker.py b/src/live/rotation_worker.py index b683ca6..e9920b2 100644 --- a/src/live/rotation_worker.py +++ b/src/live/rotation_worker.py @@ -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)) diff --git a/src/live/strategy_worker.py b/src/live/strategy_worker.py index 66e4460..07ebe1f 100644 --- a/src/live/strategy_worker.py +++ b/src/live/strategy_worker.py @@ -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: diff --git a/src/live/telegram_notifier.py b/src/live/telegram_notifier.py index 68c8564..c7309d1 100644 --- a/src/live/telegram_notifier.py +++ b/src/live/telegram_notifier.py @@ -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 } diff --git a/src/live/tsmom_worker.py b/src/live/tsmom_worker.py index b096bd9..74bc804 100644 --- a/src/live/tsmom_worker.py +++ b/src/live/tsmom_worker.py @@ -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)) diff --git a/src/portfolio/runner.py b/src/portfolio/runner.py index c51217c..6f6ce69 100644 --- a/src/portfolio/runner.py +++ b/src/portfolio/runner.py @@ -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)