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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user