"""BOOK DERIBIT-ONLY LIVE EXECUTE โ€” TP01 + SKH01 NETTATI in software su un solo conto Deribit mainnet. Porta il conto reale al target NETTO per asset (vedi src/live/book.py): per ogni asset combina la frazione long-flat di TP01 (peso 0.75) e il segno L/S di SKH01 (peso 0.25), e manda UN ordine con segno (long/short/flip) per raggiungerlo. Poi assicura un disaster-SL on-book sulla posizione NETTA. DOPPIO GATE DI SICUREZZA (entrambi necessari per inviare ordini reali): 1. config/live.json -> "execution_enabled": true (master switch, default false) 2. flag CLI --execute Senza entrambi e' un DRY-RUN (stampa il piano, NON invia). Reconciliation dopo ogni ordine; log in data/live/book_executions.jsonl. โš ๏ธ CADENZA: SKH01 decide su griglia 230m -> questo script va lanciato ogni ~230 minuti con la feed fresca all'ultima barra chiusa (NON il cron giornaliero, che mancherebbe gli ingressi). Gli exit di SKH sono SOFTWARE (latenza fino a fine barra 230m); solo il disaster-SL (-30%) e' on-book. uv run python scripts/live/book_execute.py # DRY-RUN (piano, nessun ordine) uv run python scripts/live/book_execute.py --execute # esegue SOLO se execution_enabled=true """ from __future__ import annotations import json import sys from pathlib import Path from datetime import datetime, timezone import pandas as pd PROJECT_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(PROJECT_ROOT)) from src.live.book import ScalaNonAutorizzata, book_report from src.live.execution import DeribitTrader from src.live.notifier import notify from src.live.venue_probe import diagnose, errori_dal_report CONFIG = PROJECT_ROOT / "config" / "live.json" LOG_DIR = PROJECT_ROOT / "data" / "live" LOG = LOG_DIR / "book_executions.jsonl" def load_config() -> dict: cfg = json.loads(CONFIG.read_text()) if CONFIG.exists() else {} cfg.setdefault("execution_enabled", False) cfg.setdefault("max_notional_per_asset_usd", 300.0) cfg.setdefault("min_order_usd", 5.0) cfg.setdefault("disaster_sl_pct", 0.30) cfg.setdefault("max_data_age_days", 2.0) cfg.setdefault("skh_feed_max_age_min", 30.0) return cfg def _data_age_days(last_data) -> float | None: """Eta' in giorni dell'ultima barra del feed certificato. None se non interpretabile (trattata come stantia: meglio non operare che operare su una data che non so leggere).""" if last_data in (None, ""): return None try: ts = pd.Timestamp(last_data) ts = ts.tz_localize("UTC") if ts.tz is None else ts.tz_convert("UTC") return float((pd.Timestamp.now(tz="UTC") - ts).total_seconds() / 86400.0) except Exception: return None def log_event(rec: dict): LOG_DIR.mkdir(parents=True, exist_ok=True) with open(LOG, "a") as f: f.write(json.dumps(rec) + "\n") def _run(): cfg = load_config() want_execute = "--execute" in sys.argv[1:] enabled = bool(cfg["execution_enabled"]) do_execute = want_execute and enabled min_order = float(cfg["min_order_usd"]) sl_pct = float(cfg["disaster_sl_pct"]) try: r = book_report(live_feed=True) # target NETTO + conto/posizioni reali (feed SKH fresco) except ScalaNonAutorizzata as e: # G1(c): NON si taglia al tetto. Un clamp silenzioso farebbe girare una config che dichiara # un numero e un libro che ne esegue un altro. Si ferma, non invia, e allerta. msg = (f"๐Ÿšจ SCALA NON AUTORIZZATA โ€” book_execute FERMO, nessun ordine inviato.\n{e}\n" f"Ripara: riporta `book_scale_k` a un gradino di SCALA_LADDER dentro il tetto, " f"oppure rimuovi la chiave (assente = 1,00 = libro di oggi).") print(msg) try: notify(msg) except Exception as ne: # P3: l'errore si registra dove lo si ingoia print(f" โš ๏ธ allerta NON inviata: {type(ne).__name__}: {ne}") return equity = r["equity"] print("=" * 88) print(" BOOK DERIBIT LIVE EXECUTE โ€” TP01(0.75)+SKH01(0.25) NETTATI โ€” Deribit mainnet (USDC linear)") print("=" * 88) mode = ("ESECUZIONE REALE" if do_execute else ("ARMATO ma manca --execute" if enabled else "DRY-RUN (execution_enabled=false)")) print(f" modo : {mode}") print(f" gate : execution_enabled={enabled} | --execute={want_execute}") print(f" conto reale : ${r['real_equity']:,.2f}" if r["real_equity"] else f" conto: {r['eq_basis']}") _sc = r.get("scala", 1.0) _lordo = len(r["assets"]) * float(cfg.get("max_notional_per_asset_frac") or 0.0) * _sc print(f" sizing base : ${equity:,.2f} | cap/asset ${r['cap_per_asset']:.0f} | " f"min ${min_order:.0f} | disaster-SL -{sl_pct*100:.0f}%") # La scala e' stampata SEMPRE, anche a 1,00: un parametro che muove il nozionale e non compare # nel log e' invisibile proprio nel giro in cui e' cambiato (SPEC-scale-key ยง2.2). print(f" scala libro : {_sc:.2f}x (tetto {r.get('leva_lorda_max', 1.25):.2f}x) | " f"leva lorda max {_lordo:.3f}x dell'equity" + ("" if _sc != 1.0 else " [chiave assente o 1,00 = libro invariato]")) print(f" ultima barra : {r['last_data']}\n") if r.get("skh_error"): # SKH feed fallito -> book.py ha forzato flat IN SILENZIO print(f" โš ๏ธ SKH FEED ERRORE (SKH forzato flat!): {r['skh_error']}") notify("โš ๏ธ BOOK โ€” SKH feed fallito (sleeve forzato flat)", {"error": r["skh_error"]}) # --- FRESCHEZZA del feed 5m usato per il segnale SKH (2026-07-26) -------------------------- # `fresh_5m` ricade sul feed certificato IN SILENZIO se il fetch pubblico Deribit fallisce, e # il certificato lo ricostruisce il cron una volta al giorno. Non solleva, non logga: senza # questo controllo la latenza d'uscita di SKH01 passa da ~1h a ~1 GIORNO senza che nulla lo # dica. E' proprio la latenza d'uscita dove vive la qualita' del path live (misura 2026-07-26). # SCELTA DICHIARATA: allerta, NON blocca. Bloccare fermerebbe anche il ribilancio di TP01 # (sono nettati sullo stesso strumento) per un guasto di rete; e forzare SKH flat chiuderebbe # posizioni buone su un glitch. La decisione resta all'operatore. skh_age = r.get("skh_feed_age_min") max_skh_age = float(cfg.get("skh_feed_max_age_min", 30.0)) if skh_age is None: print(" โš ๏ธ freschezza feed SKH NON MISURATA (segnale su feed certificato?)") elif skh_age > max_skh_age: # La CAUSA e' misurata (livefeed.last_fetch_error), non piu' presunta: fino al 29/07 questa # nota diceva sempre "fetch pubblico KO" a prescindere โ€” una presunzione stampata come se # fosse una misura. Quel giorno il feed e' stato stantio in 6 giri orari su 8 (fino a 685 # min) e la causa NON e' stata stabilita, proprio perche' l'unica riga disponibile era # questa e non veniva dai dati. errs = r.get("skh_feed_errors") or {} causa = "; ".join(f"{a}: {e}" for a, e in sorted(errs.items())) if errs else \ "coda fresca attaccata: il feed certificato stesso e' vecchio (rebuild giornaliero?)" print(f" โš ๏ธ FEED SKH STANTIO: ultima barra 5m di {skh_age:.0f} min fa " f"(soglia {max_skh_age:.0f}) -> le uscite SKH sono in ritardo, NON blocco.") print(f" causa: {causa}") if do_execute: notify("โš ๏ธ BOOK โ€” feed SKH stantio (uscite in ritardo)", {"eta_min": round(skh_age), "soglia_min": round(max_skh_age), "effetto": "SL/TP di SKH01 rilevati in ritardo", "causa": causa[:300]}) else: print(f" feed SKH : fresco ({skh_age:.0f} min)") if not r["online"]: # `online` e' falso quando il mark di BTC non viene da mainnet: la ragione sta in # `mark_src` ("fallback close ()") ma non veniva MAI stampata, quindi # l'allerta diceva solo "conto offline" โ€” vero e inutile. Stesso buco del feed SKH. # # DAL 2026-08-25 la riga dice anche DI CHI E' IL GUASTO (P4: *cosa* e *perche'*). Fino a # ieri "conto offline" copriva due cause con azioni opposte: Deribit in manutenzione # (attesa, rientra da sola, azione nessuna) e il nostro gateway rotto (4 dei 5 traceback # in 63 giorni, ed e' l'unico pezzo riparabile). Vedi src/live/venue_probe.py. srcs = "; ".join(f"{a['asset']}: {a.get('mark_src')}" for a in r["assets"]) d = diagnose(errori_dal_report(r)) print(f" conto non leggibile (offline) -> stop, non eseguo a cieco.\n mark: {srcs}") print(f" diagnosi: {d.riga()}") if do_execute: # P9: un allarme MASSIMO speso per un evento ATTESO e' un allarme che non verra' # letto il giorno che e' vero. `atteso` declassa la gravita', NON registra di meno. notify(f"{d.gravita} BOOK LIVE โ€” conto offline ({d.verdetto})", {"nota": "salto l'esecuzione, non opero a cieco", "perche": d.perche, "atteso": "si'" if d.atteso else "no", "riparabile_da_noi": "si'" if d.riparabile_da_noi else "no", "mark": srcs[:200], "prova": d.prova[:200]}) return if r.get("pos_error"): # ONLINE ma posizione IGNOTA (read fallita -> assunta flat) d = diagnose(errori_dal_report(r)) print(f" ๐Ÿ›‘ POSIZIONE NON LEGGIBILE -> NON eseguo a cieco: {r['pos_error']}") print(f" diagnosi: {d.riga()}") if do_execute: notify(f"{d.gravita} BOOK LIVE โ€” posizione non leggibile ({d.verdetto})", {"error": r["pos_error"], "nota": "salto l'esecuzione, non opero a cieco", "perche": d.perche, "atteso": "si'" if d.atteso else "no", "riparabile_da_noi": "si'" if d.riparabile_da_noi else "no", "prova": d.prova[:200]}) return stale_days = _data_age_days(r.get("last_data")) # .get col default: un chiamante che passa una config senza la chiave deve ricadere sulla # soglia sicura, non sollevare KeyError dentro il percorso d'esecuzione con soldi veri. max_age = float(cfg.get("max_data_age_days", 2.0)) if stale_days is None or stale_days > max_age: # FEED STANTIO -> non eseguo. Il 2026-07-14 il book ha comprato ETH con l'ultima barra # ferma al 07-08 (feed congelato 6 giorni, diario 2026-07-15-feed-freeze): il conto era # online e la posizione leggibile, quindi i due gate esistenti NON scattavano. Il segnale # TP01 viene dal feed su disco: se e' vecchio, si opera alla cieca su dati morti. # Il disaster-SL on-book resta la rete di sicurezza su eventuali posizioni aperte. eta = "ignota" if stale_days is None else f"{stale_days:.0f}g" print(f" ๐Ÿ›‘ FEED STANTIO (ultima barra {r.get('last_data')}, eta' {eta} > {max_age:.0f}g)" " -> NON eseguo su dati morti.") print(" Sbloccare con: uv run python scripts/analysis/rebuild_history.py --asset BTC ETH") if do_execute: notify("๐Ÿ›‘ BOOK LIVE โ€” FEED STANTIO, esecuzione saltata", {"ultima_barra": str(r.get("last_data")), "eta": eta, "soglia": f"{max_age:.0f}g", "azione": "rebuild_history.py --asset BTC ETH"}) return if r.get("eq_fallback"): # equity reale non leggibile -> sizing su paper_cap print(f" โš ๏ธ EQUITY FALLBACK (sizing su paper_cap, NON blocco): {r['eq_fallback']}") if do_execute: # solo diagnostica: l'hard-cap $/asset limita il downside notify("โš ๏ธ BOOK LIVE โ€” equity fallback (sizing su paper_cap)", {"nota": r["eq_fallback"]}) # Salto di equity: un versamento (conferma che il sizing lo ha seguito) oppure un movimento # NON richiesto (prelievo, perdita anomala). In entrambi i casi va detto subito, e con il # nuovo dimensionamento accanto, cosi' il messaggio si legge senza aprire il repo. jump = r.get("equity_jump") if jump: verso = "VERSAMENTO" if jump["pct"] > 0 else "USCITA DI FONDI" lordo = 2 * r["cap_per_asset"] notify(f"๐Ÿ’ฐ BOOK LIVE โ€” {verso} rilevato ({jump['pct']:+.1%})", { "equity prima": f"${jump['prev']:,.2f}", "equity ora": f"${jump['new']:,.2f}", "cap/asset ora": f"${r['cap_per_asset']:,.2f}", "nozionale lordo max": f"${lordo:,.2f} = leva {lordo/jump['new']:.2f}x", }) print(f" ๐Ÿ’ฐ {verso}: ${jump['prev']:,.2f} -> ${jump['new']:,.2f} " f"({jump['pct']:+.1%}) ยท cap/asset ora ${r['cap_per_asset']:,.2f}") trader = DeribitTrader() if do_execute else None actions = [] # ISOLAMENTO PER ASSET (2026-08-25). Prima, un'eccezione dentro il corpo del ciclo risaliva # fino a `main()` e uccideva il GIRO INTERO: il 2026-07-21 alle 09:00 UTC un 502 dentro # `ensure_disaster_sl` su BTC ha fatto si' che ETH non venisse nemmeno guardato โ€” niente # ribilancio e, soprattutto, **nessuna verifica della sua protezione**. Un guasto su un asset # non deve togliere la rete di sicurezza all'altro. falliti: list[str] = [] scoperti: list[str] = [] for a in r["assets"]: asset, inst = a["asset"], a["instrument"] net, cur, mark = a["net_target"], a["position_usd"], a["mark"] sk = a["skh_state"] sk_txt = "flat" if sk == "flat" else f"{sk['dir']}@{sk.get('entry')}" order = a["order"] if order is None: act = "HOLD (a target)" elif order.get("is_close"): act = f"CLOSE ${cur:,.0f}" elif order.get("needs_flip"): act = f"FLIP -> ${net:,.0f}" else: act = f"{order['side'].upper()} ${order['delta']:+,.0f}" print(f" {asset:<3} TP {a['tp_frac']:+.3f} ยท SKH {a['skh_sign']:+d}({sk_txt}) -> net ${net:+,.0f} " f"| pos ${cur:+,.0f} -> {act}") try: if do_execute and order is not None: fills = trader.rebalance_signed(inst, net, mark, min_usd=min_order) newpos = trader.position_usd(inst) for f in fills: print(f" -> {f.side.upper()} {f.filled:.4f} @ ${f.price or 0:,.1f} fee {f.fee_usdc:.5f} " f"({'OK' if f.verified else 'NON VERIFICATO: ' + f.notes})") # `ts_utc` = ORA VERA del fill. Fino al 2026-08-23 qui c'era la data della # BARRA di segnale (`r['last_data']`): 19 righe su 19 a 00:00:00, e un trade # registrato SEI GIORNI prima di essere eseguito (ETH 0.04 del 14/07, scritto # 08/07). La barra resta, sotto il suo nome: `bar_ts`. log_event(dict(ts_utc=datetime.now(timezone.utc).isoformat(timespec="seconds"), bar_ts=str(pd.Timestamp(r['last_data'])), asset=asset, action=act, side=f.side, filled=f.filled, price=f.price, fee=f.fee_usdc, verified=f.verified, notes=f.notes, net_target=net, pos_after=newpos, tp_frac=a["tp_frac"], skh_sign=a["skh_sign"])) det = dict(asset=asset, side=f.side, amount=round(f.filled, 4), price=round(f.price or 0, 1), fee=round(f.fee_usdc, 5), net=round(net, 0), pos_after=round(newpos, 0)) notify(f"โœ… BOOK {act}" if f.verified else "โš ๏ธ BOOK ORDINE NON VERIFICATO", det if f.verified else {**det, "notes": f.notes}) print(f" reconcile: pos ${newpos:,.0f}") if do_execute: ds = trader.ensure_disaster_sl(inst, sl_pct) # bracket su posizione NETTA (adatta long/short) stato = ds.get("state") print(f" disaster-SL: {stato}" + (f" @ ${ds['stop']:,.1f}" if ds.get("stop") else "")) if stato == "placed": notify("๐Ÿ›ก๏ธ BOOK disaster-SL piazzato", {"asset": asset, "stop": round(ds.get("stop") or 0, 1), "amount": round(ds.get("amount") or 0, 4)}) elif stato == "place-failed": notify("โš ๏ธ BOOK disaster-SL FALLITO", {"asset": asset, "notes": ds.get("notes")}) elif stato == "naked": # NON e' "non sono riuscito a proteggere": e' "HO TOLTO la protezione e non # sono riuscito a rimetterla". Posizione aperta senza stop on-book -> gravita' # massima, e MAI declassata da P9: una posizione scoperta non e' mai attesa. scoperti.append(asset) print(f" ๐Ÿšจ {asset}: POSIZIONE SCOPERTA โ€” {ds.get('notes')}") notify("๐Ÿšจ BOOK โ€” POSIZIONE SCOPERTA (disaster-SL rimosso e non ripiazzato)", {"asset": asset, "stop_voluto": round(ds.get("stop") or 0, 1), "amount": round(ds.get("amount") or 0, 4), "azione": "ripiazzare il bracket a mano, oppure chiudere la posizione", "notes": str(ds.get("notes"))[:300]}) except Exception as e: # noqa: BLE001 โ€” isolamento per asset: l'altro deve continuare d = diagnose(errori_dal_report(r) + [f"{type(e).__name__}: {e}"]) falliti.append(asset) act = f"{act} [FALLITO]" print(f" ๐Ÿ›‘ {asset}: giro fallito, PASSO ALL'ASSET SUCCESSIVO -> {type(e).__name__}: {e}") print(f" diagnosi: {d.riga()}") if do_execute: notify(f"{d.gravita} BOOK โ€” asset {asset} fallito ({d.verdetto})", {"asset": asset, "error": f"{type(e).__name__}: {e}"[:200], "perche": d.perche, "atteso": "si'" if d.atteso else "no", "riparabile_da_noi": "si'" if d.riparabile_da_noi else "no", "nota": "gli altri asset del book sono stati comunque elaborati"}) actions.append(act) print() if scoperti: print(f" ๐Ÿšจ POSIZIONI SCOPERTE (nessun disaster-SL on-book): {', '.join(scoperti)}") if falliti: print(f" ๐Ÿ›‘ asset falliti in questo giro: {', '.join(falliti)}") if not do_execute: print(" => DRY-RUN: nessun ordine inviato." + ("" if enabled else " Per armare: config/live.json execution_enabled=true + --execute.")) elif all(x.startswith("HOLD") for x in actions): print(" => Nessuna azione: conto gia' al target netto del book.") else: print(" => Esecuzione completata (vedi data/live/book_executions.jsonl).") # Uscita non-zero se il giro e' stato DEGRADATO: il log del cron deve poterlo contare senza # rileggere la prosa. Non cambia nulla operativamente (cron_book.sh non ha `set -e`). if scoperti or falliti: sys.exit(2) def main(): try: _run() except Exception as e: # Anche l'ultima rete porta la DIAGNOSI, non solo il tipo d'errore: e' l'allerta che # arriva quando tutto il resto non ha funzionato, ed e' proprio li' che serve sapere se # riaprire il gateway o aspettare che rientri il venue (P4). d = diagnose([f"{type(e).__name__}: {e}"]) notify(f"{d.gravita} BOOK LIVE โ€” ERRORE ({d.verdetto})", {"error": f"{type(e).__name__}: {e}"[:250], "perche": d.perche, "atteso": "si'" if d.atteso else "no", "riparabile_da_noi": "si'" if d.riparabile_da_noi else "no"}) raise if __name__ == "__main__": main()