841d4c78cd
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
444 lines
27 KiB
Python
444 lines
27 KiB
Python
"""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: ORARIA — `47 * * * *` in `scripts/cron_book.sh` (il minuto e' spiegato li'). SKH01
|
|
decide su griglia 230m, ma il giro e' IDEMPOTENTE (riconcilia al target netto corrente; sotto
|
|
`min_order_usd` $5 -> HOLD). Girare piu' fitto della griglia costa solo MICRO-ORDINI di ri-taglia:
|
|
il target e' ricalcolato ogni ora sull'equity marcata, e 22 dei 48 ordini live sono |delta|<=$10
|
|
a segnale invariato — spiccioli oggi, ma il deadband e' in valuta ASSOLUTA (regola C2: a capitale
|
|
maggiore un movimento dello 0,15% lo supera ogni ora). Compra due cose misurate: (1) gli
|
|
ingressi/uscite SOFTWARE di SKH01 arrivano con latenza <=1h — CON LA FEED 5m FRESCA: se e' stantia
|
|
`livefeed.fresh_5m` ripiega in silenzio sul feed giornaliero (latenza ~1 giorno) e
|
|
`skh_feed_max_age_min` 30 ALLERTA, non blocca; (2) il disaster-SL rotolante (-30%, l'unico on-book)
|
|
viene CONTROLLATO ogni ora e ri-ancorato solo oltre la tolleranza di `ensure_disaster_sl` (stop a
|
|
>5% da quello piazzato, cioe' mark +5,263%/-4,762%, o taglia >10%) — con questa isteresi
|
|
(`r0823_sl_anchor.py`): a cadenza 1h in 7-8 anni BTC 0 scatti / ETH 1; a 4h BTC 2; a 24h ETH 5,
|
|
con 2 in 30 giorni. Lo stop siede quindi fra -33,5% e -26,5% dal mark corrente (0,665-0,735 x mark),
|
|
non a -30% dall'ultima ora: e' rotolante, non un massimo di perdita (CLAUDE.md §1).
|
|
🚨 NON "correggere" la cadenza a ~230 minuti: fino al 2026-09-02 questo docstring lo prescriveva,
|
|
ed era il docstring a essere sbagliato, non il cron (debito §5.7). `tests/test_book_cadenza.py`
|
|
tiene d'accordo docstring, `cron_book.sh` e crontab installata. NON il cron giornaliero: le entrate
|
|
di SKH01 le mancherebbe.
|
|
|
|
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, RebalanceParziale
|
|
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 _registra_fill(f, *, asset: str, act: str, net: float, bar_ts, tp_frac: float, skh_sign: int):
|
|
"""Registra UN fill (print + log_event + notify). SUBITO dopo l'ordine, PRIMA di leggere la
|
|
posizione risultante: un ordine eseguito non deve dipendere dalla lettura della posizione
|
|
successiva per essere salvato (#23 — un 502 su `position_usd()` dopo un fill riuscito lo
|
|
faceva sparire dal log, perche' la lettura veniva PRIMA del giro `for f in fills`).
|
|
|
|
`pos_after` e' sempre None qui: la posizione dopo il rebalance si legge (best-effort) DOPO
|
|
aver registrato tutti i fill, in `_run` — vedi il commento li'."""
|
|
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(bar_ts)), 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=None,
|
|
tp_frac=tp_frac, skh_sign=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))
|
|
notify(f"✅ BOOK {act}" if f.verified else "⚠️ BOOK ORDINE NON VERIFICATO",
|
|
det if f.verified else {**det, "notes": f.notes})
|
|
|
|
|
|
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
|
|
# (#22) Non e' piu' "SKH forzato flat!": book_execute non manda PIU' ordini basati su
|
|
# quel segnale ignoto (modo SOLO PROTEZIONE, sotto, che porta anche questo errore su
|
|
# Telegram — qui solo il print, per non mandare due notify per lo stesso guasto).
|
|
print(f" ⚠️ SKH FEED ERRORE (SKH non calcolato: nessun ordine, solo protezione): {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 (<Eccezione>)") 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 -> niente sizing sul conto
|
|
# (#21) qui solo il print: il notify e' nel blocco SOLO PROTEZIONE sotto, che porta anche
|
|
# questo errore su Telegram — un solo avviso per lo stesso guasto, non due.
|
|
print(f" ⚠️ EQUITY FALLBACK (nessun ordine, solo protezione): {r['eq_fallback']}")
|
|
|
|
# --- SOLO PROTEZIONE (#21 equity ignota, #22 SKH ignoto) ------------------------------------
|
|
# Principio unico: quando il giro non sa QUANTO vale il conto o QUALE e' il segnale, NON manda
|
|
# ordini di ribilancio (dimensionare un ordine su un numero che non si conosce e' peggio che
|
|
# non ordinare) -> ma CONTROLLA LO STESSO il disaster-SL di ogni asset: la protezione dipende
|
|
# da posizione e mark, non da equity o segnale. Stesso principio che pos_error applica gia'
|
|
# bloccando l'esecuzione, qui senza saltare la protezione.
|
|
solo_protezione: str | None = r.get("eq_fallback") or r.get("skh_error")
|
|
# Calcolato una volta sola: serve sia nell'azione per-asset del ciclo sotto sia nel
|
|
# riepilogo finale (#21, #22) — un solo motivo, non due troncamenti che potrebbero divergere.
|
|
motivo_breve = (solo_protezione if solo_protezione and len(solo_protezione) <= 60
|
|
else (solo_protezione[:60] + "…" if solo_protezione else None))
|
|
if solo_protezione and do_execute:
|
|
titolo = ("⚠️ BOOK LIVE — equity non leggibile: nessun ordine, solo protezione"
|
|
if r.get("eq_fallback") else
|
|
"⚠️ BOOK LIVE — SKH non calcolato: nessun ordine, solo protezione")
|
|
notify(titolo, {"nota": solo_protezione})
|
|
|
|
# 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] = []
|
|
non_verificati: 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 solo_protezione:
|
|
# (#21, #22) nessun rebalance: l'azione stampata lo dice, non e' "a target" — e'
|
|
# "non tocco perche' non so quanto vale il conto / qual e' il segnale".
|
|
act = f"HOLD (solo protezione: {motivo_breve})"
|
|
elif 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 and not solo_protezione:
|
|
# (#18) tetto di apertura derivato dal cap di config del libro. Se cap_per_asset*scala
|
|
# o mark sono <=0 solleva ValueError: e' giusto, finisce nell'isolamento per asset
|
|
# (except sotto), non deve fermare il giro intero.
|
|
trader.set_open_cap(inst, r["cap_per_asset"] * r.get("scala", 1.0), mark)
|
|
try:
|
|
fills = trader.rebalance_signed(inst, net, mark, min_usd=min_order)
|
|
except RebalanceParziale as rp:
|
|
# (#23) almeno un fill e' passato prima che il resto del passo sollevasse
|
|
# (es. flip: close ok, open KO) -> si registra quel fill SUBITO (non e' un
|
|
# fill perso), poi si rilancia la causa nell'handler di isolamento esistente.
|
|
for f in rp.fills:
|
|
_registra_fill(f, asset=asset, act=act, net=net, bar_ts=r["last_data"],
|
|
tp_frac=a["tp_frac"], skh_sign=a["skh_sign"])
|
|
raise rp.causa
|
|
for f in fills:
|
|
_registra_fill(f, asset=asset, act=act, net=net, bar_ts=r["last_data"],
|
|
tp_frac=a["tp_frac"], skh_sign=a["skh_sign"])
|
|
# newpos e' un RECONCILE informativo, letto DOPO aver registrato i fill (#23): se
|
|
# questa lettura solleva, i fill sono gia' salvi (sopra), e non e' un guasto del
|
|
# giro (isolamento) -> si segnala e si continua (disaster-SL sotto, comunque).
|
|
try:
|
|
newpos = trader.position_usd(inst)
|
|
print(f" reconcile: pos ${newpos:,.0f}")
|
|
except Exception as e: # noqa: BLE001 — non e' un fill perso, e' un reconcile mancato
|
|
print(f" reconcile: pos illeggibile ({type(e).__name__})")
|
|
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]})
|
|
elif stato == "unknown":
|
|
# (#24) posizione o open_orders illeggibili: NON e' "naked" (non si e' tolto
|
|
# nulla, ensure_disaster_sl non cancella ne' piazza su dati a meta'). E' un
|
|
# giro degradato lo stesso: non si sa se la protezione esistente e' ancora
|
|
# coerente con la posizione attuale.
|
|
non_verificati.append(asset)
|
|
print(f" ⚠️ {asset}: protezione NON VERIFICATA — {ds.get('notes')}")
|
|
notify("⚠️ BOOK — disaster-SL non verificabile (lettura posizione/ordini fallita)",
|
|
{"asset": asset, "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 non_verificati:
|
|
print(f" ⚠️ disaster-SL NON VERIFICATO (lettura posizione/ordini fallita): {', '.join(non_verificati)}")
|
|
if not do_execute:
|
|
print(" => DRY-RUN: nessun ordine inviato." +
|
|
("" if enabled else " Per armare: config/live.json execution_enabled=true + --execute."))
|
|
elif solo_protezione:
|
|
# (#21, #22) non e' "Nessuna azione: conto gia' al target" — il conto potrebbe NON esserci
|
|
# per niente al target, semplicemente non lo si sa. Distinto dal ramo HOLD sotto.
|
|
print(f" => SOLO PROTEZIONE: nessun ordine di ribilancio ({motivo_breve}), disaster-SL controllato.")
|
|
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`).
|
|
# (#21, #22) SOLO PROTEZIONE e' degradato anche senza asset falliti/scoperti: il giro non ha
|
|
# ribilanciato nulla perche' non sapeva quanto vale il conto o qual era il segnale. Solo se
|
|
# il giro era ARMATO: in DRY-RUN non si e' comunque tentato nulla, e' solo un'anteprima.
|
|
# (#24) `non_verificati` e' degradato allo stesso modo: la protezione esistente non e' stata
|
|
# controllata, indipendentemente da solo_protezione.
|
|
if scoperti or falliti or non_verificati or (solo_protezione and do_execute):
|
|
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
|
|
|
|
|
|
USO = """uso: book_execute.py [--execute]
|
|
|
|
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
|
|
|
|
Dettaglio nel docstring in testa al file."""
|
|
|
|
if __name__ == "__main__":
|
|
from src.live.cli import valida
|
|
valida("book_execute.py", USO, flag=("--execute",), con_valore=())
|
|
main()
|