"""BOOK DERIBIT-ONLY (TP01 + SKH01) — target NETTO per asset via NETTING SOFTWARE su un solo conto. TP01 e SKH01 tradano lo STESSO strumento (BTC/ETH _USDC-PERPETUAL). Su Deribit esiste UNA sola posizione netta per strumento per conto -> non si possono tenere due gambe separate: si combinano in software in un unico target netto, e si manda UN ordine per asset per raggiungerlo. Formula (preserva il budget di rischio $300/asset, split 75/25; coerente col blend di ritorni deribit_book_sleeves = 0.75*TP01 + 0.25*SKH01): net_target_usd[asset] = clamp( WEIGHT * E * (W_TP01 * tp_frac + W_SKH * skh_sign), ±CAP ) WEIGHT = 0.5 (book 50/50 BTC+ETH, come i due sleeve) tp_frac = target TP01 (causale, >=0, long-flat) da TrendPortfolio.current_target skh_sign = +1 long / -1 short / 0 flat da _skyhook_positions (book 230m) E = equity reale del conto (fallback paper se offline) CAP = cap notional per-asset. Fisso = max_notional_per_asset_usd ($300), OPPURE DINAMICO = equity * max_notional_per_asset_frac (es. 0.5 -> equity/2) se la frazione e' presente in config -> il cap cresce col capitale e non strozza il book quando depositi (frontiera 2026-07-03). Dinamico SOLO con equity reale fidata; su fallback/offline si torna al cap fisso (protezione downside). GLI EXIT DI SKH01 SONO IMPLICITI: _skyhook_positions() replica il book (ingressi + SL/TP/max_bars + non-overlap) sulla feed certificata fresca -> quando il trade va chiuso ritorna 'flat' -> skh_sign=0 -> il target netto si aggiorna -> il reconciler chiude la quota SKH. Gli exit sono SOFTWARE (no bracket on-book per SKH, sennò chiuderebbero anche la quota TP01). Solo il disaster-SL (-30%) resta on-book, sulla posizione NETTA. ⚠️ CORREZIONE 2026-07-26 — questo docstring diceva "latenza fino alla chiusura della barra 230m corrente". E' FALSO, e l'errore e' costato tre settimane di misure sbagliate (audit 02/07, follow-up 24/07): `resample_5m` NON scarta il bin 230m in corso, e il loop di `_skyhook_positions` ci itera dentro usando il suo high/low CORRENTE -> il tocco di SL/TP e' visto **entro il primo cron dopo il tocco (~1h)**, non a fine barra (mediana 115 min, p90 210 di ritardo in piu'). Misurato: modellare il live come "detection a fine barra" (lente `hourly`) lo SOTTOSTIMA di +0.081 di Sharpe FULL sul book, in 23/23 offset. Diario `2026-07-26-t1-esecuzione-skh-live.md`. La latenza reale dipende quindi dalla FRESCHEZZA del feed 5m, non dalla griglia 230m -> vedi `skh_feed_age_min` nel report e l'allerta in scripts/live/book_execute.py. Questo modulo NON invia nulla: costruisce solo il report/ordine. L'invio è in scripts/live/book_execute.py (doppio gate). Causale: usa solo barre chiuse (eredita la causalità di TP01 e di _skyhook_positions). """ from __future__ import annotations import json from pathlib import Path import pandas as pd from src.live.deribit import INSTRUMENT, notional_to_amount from src.live.shadow import ASSETS, WEIGHT, shadow_report from src.portfolio.sleeves import _skyhook_positions PROJECT_ROOT = Path(__file__).resolve().parents[2] CONFIG = PROJECT_ROOT / "config" / "live.json" # Pesi del book Deribit-only (vedi src/portfolio/sleeves.deribit_book_sleeves). W_TP01 = 0.75 W_SKH = 0.25 FLAT_USD = 1.0 EQUITY_WATERMARK = PROJECT_ROOT / "data" / "live" / "equity_seen.json" # Se non si e' MAI vista l'equity reale non si sa niente del conto: si usa la taglia storicamente # sicura, non il cap di config (che puo' essere stato alzato in previsione di un deposito). CAP_UNKNOWN_USD = 300.0 # ---- CHIAVE DI SCALA (GATE SCALA-01) — spec: docs/research/SPEC-scale-key.md ------------------ # ⚠️ COSTANTI DI CODICE, NON DI CONFIG, e il motivo e' il meccanismo del cricchetto (SPEC §4.1b): # un tetto che vive nello stesso file della chiave sarebbe "un lucchetto con la chiave attaccata". # Portare il gradino a 1,50 richiede una modifica di CODICE -> una review -> un gate. # Il tetto e' basso APPOSTA: non e' un'opinione sulla leva massima sopportabile (che da §4.5 # sarebbe 1,67x), e' il dispositivo che rende impossibile alzarla "solo un po'". LEVA_LORDA_MAX = 1.25 # La scaletta esiste perche' una scala A GRADINI rende INESPRIMIBILE "solo un po'": 1,05 non e' # un valore prudente, e' un valore fuori scaletta, e viene RIFIUTATO. SCALA_LADDER = (1.00, 1.25) # Invariante del disaster-SL (SPEC §4.5, la guardia che morde per PRIMA): il costo in equity di un # episodio di disaster-SL e' `n_asset * frac * scala * disaster_sl_pct` e non deve superare mezza # equity. Con frac 0,50 e sl 30% da' scala <= 1,67x, e scatta se qualcuno allarga il disaster-SL. DISASTER_SL_BUDGET = 0.50 class ScalaNonAutorizzata(RuntimeError): """Config che dichiara una scala fuori scaletta o fuori tetto. ⚠️ E' un'ECCEZIONE e non un clamp, ed e' una scelta (SPEC §4.1c): tagliare silenziosamente al tetto farebbe girare una config che DICHIARA un numero e un libro che ne ESEGUE un altro — la stessa classe di difetto che questo progetto ha gia' pagato tre volte. Chi la incontra ferma l'esecuzione e allerta; non "rende robusto" il lettore con un min(). """ def _scala(real_equity: float | None = None, eq_fallback: str | None = None, cfg: dict | None = None) -> float: """Scala del libro (`book_scale_k` in config). 1.0 = il libro di oggi, bit-exact. DUE COMPORTAMENTI, entrambi voluti: (1) VALIDA SEMPRE, anche sul percorso degradato. Una config fuori scaletta o fuori tetto e' un errore di configurazione, non una condizione di mercato: dev'essere scoperta al primo giro, non nascosta dal fatto che oggi l'equity non era leggibile. (2) POI applica la regola G3/G4: **la scala vive SOLO sul percorso fidato**. Se l'equity reale non e' leggibile la scala e' 1,00 — *una scala e' una decisione di rischio presa conoscendo il conto; quando non si sa quanto vale il conto, non si prende*. Cosi' "il fallback non e' piu' permissivo" e' vero PER COSTRUZIONE, non per aritmetica. Il tetto e' sul PRODOTTO `n_asset * frac * scala`, mai sulla sola chiave: con un tetto sulla sola chiave, alzare `frac` da 0,50 a 0,625 con scala 1,25 darebbe 1,562x passando il controllo. """ if cfg is None: cfg = json.loads(CONFIG.read_text()) if CONFIG.exists() else {} raw = cfg.get("book_scale_k") if raw is None: return 1.0 # chiave assente = il libro di oggi s = float(raw) if not any(abs(s - x) < 1e-9 for x in SCALA_LADDER): raise ScalaNonAutorizzata( f"book_scale_k={s!r} non e' un gradino di SCALA_LADDER {SCALA_LADDER}: " f"una scala a gradini rende inesprimibile 'solo un po''. Config: {CONFIG}") frac = cfg.get("max_notional_per_asset_frac") if frac is not None: leva = len(ASSETS) * float(frac) * s if leva > LEVA_LORDA_MAX + 1e-12: raise ScalaNonAutorizzata( f"leva lorda {leva:.4f}x = n_asset {len(ASSETS)} x frac {frac} x scala {s} " f"supera LEVA_LORDA_MAX {LEVA_LORDA_MAX}x (tetto sul PRODOTTO, src/live/book.py). " f"Alzarlo e' una modifica di CODICE, non di config.") sl = float(cfg.get("disaster_sl_pct", 0.0)) costo = leva * sl if costo > DISASTER_SL_BUDGET + 1e-12: raise ScalaNonAutorizzata( f"un episodio di disaster-SL costerebbe {100*costo:.1f}% dell'equity " f"(n_asset x frac {frac} x scala {s} x sl {sl}) > {100*DISASTER_SL_BUDGET:.0f}%: " f"e' la guardia che morde per prima (SPEC-scale-key §4.5).") if real_equity is None or eq_fallback: return 1.0 # G3/G4: niente scala fuori dal percorso fidato return s def leva_tetto(cfg: dict | None = None) -> tuple[float, str]: """Leva lorda massima che il libro PUO' prendere. UNA formula, non tre (#28): prima era ridichiarata in `usde.frazione_cuscino` (N_ASSET fisso, senza scala) e nella regola `leva` del giornale (senza scala) -> a scala 1,25 il cuscino USDC risultava 0,30 dell'equity invece di 0,375, e la regola `leva` mostrava un tetto diverso da quello che il libro rispetta davvero. leva = len(ASSETS) x min(WEIGHT, frac) x scala, al piu' LEVA_LORDA_MAX. -> (leva, come). - frac = cfg["max_notional_per_asset_frac"] se presente, altrimenti WEIGHT. Il min(WEIGHT, frac) viene dal fatto che `book_net_target` non supera mai weight*equity*(W_TP01+W_SKH) = weight*equity (i due pesi sommano a 1): |raw| <= weight*equity, quindi |net_target| <= scala*min(cap, weight*equity) -- un frac oltre WEIGHT alza il cap nominale ma non la leva REALE che il libro puo' esprimere. - scala = la scala DICHIARATA (`_scala(real_equity=1.0, cfg=cfg)`, gia' validata): un tetto descrive quanto la config AUTORIZZA, non quanto un giro con equity illeggibile ha preso. - Su `ScalaNonAutorizzata` (config fuori scaletta o fuori tetto) NON si solleva: un sorvegliante (`cuscino_watch`) non deve morire proprio quando la config e' sbagliata (verifica fable B5) -> si ritorna il tetto di CODICE, dichiarando l'anomalia in `come`. """ if cfg is None: cfg = json.loads(CONFIG.read_text()) if CONFIG.exists() else {} try: scala = _scala(real_equity=1.0, cfg=cfg) except ScalaNonAutorizzata as e: breve = str(e).splitlines()[0] breve = breve if len(breve) <= 80 else breve[:77] + "..." return LEVA_LORDA_MAX, (f"config fuori scaletta/tetto ({breve}): " f"tetto di codice LEVA_LORDA_MAX") raw_frac = cfg.get("max_notional_per_asset_frac") frac = float(raw_frac) if raw_frac is not None else WEIGHT frac_eff = min(WEIGHT, frac) leva = len(ASSETS) * frac_eff * scala base = f"{len(ASSETS)} asset x frac {frac_eff} x scala {scala} = {leva:.2f}x" if leva > LEVA_LORDA_MAX + 1e-12: return LEVA_LORDA_MAX, f"{base}, troncato al tetto di codice {LEVA_LORDA_MAX}x" return leva, base def _read_watermark() -> float | None: """Ultima equity REALE osservata sul conto. None se mai vista o file illeggibile.""" try: v = float(json.loads(EQUITY_WATERMARK.read_text())["real_equity"]) return v if v > 0 else None except Exception: return None # Variazione di equity fra due letture consecutive oltre la quale si avvisa. Il book ha vol # giornaliera ~0.4%: un salto del 10% fra due giri orari non puo' venire dal trading, quindi e' # un deposito, un prelievo, o qualcosa che va guardato SUBITO. EQUITY_JUMP_ALERT = 0.10 def write_equity_watermark(real_equity: float | None) -> dict | None: """Registra l'equity reale ogni volta che e' leggibile. Chiamata da `book_report`. Ritorna un dict {prev, new, pct} se il salto rispetto alla lettura precedente supera `EQUITY_JUMP_ALERT`, altrimenti None. Serve a due cose in una: **confermare** che un versamento e' atterrato e che il sizing lo ha seguito, e **segnalare** un movimento che l'operatore non ha causato (prelievo non richiesto, perdita anomala). """ if real_equity is None or real_equity <= 0: return None prev = _read_watermark() try: EQUITY_WATERMARK.parent.mkdir(parents=True, exist_ok=True) EQUITY_WATERMARK.write_text(json.dumps( {"real_equity": float(real_equity), "ts": pd.Timestamp.now(tz="UTC").isoformat()})) except Exception: pass # il watermark e' un'ottimizzazione di sicurezza, non un requisito if prev is None or prev <= 0: return None # prima lettura in assoluto: non c'e' un "prima" da confrontare pct = (float(real_equity) - prev) / prev if abs(pct) < EQUITY_JUMP_ALERT: return None return {"prev": prev, "new": float(real_equity), "pct": pct} def _cap(equity: float | None = None, real_equity: float | None = None, eq_fallback: str | None = None) -> float: """Cap notional per-asset ($). Se in config c'e' `max_notional_per_asset_frac` (opt-in), il cap diventa DINAMICO = equity * frac (es. equity/2) -> cresce col capitale, cosi' un deposito non resta strozzato (vedi frontiera 2026-07-03). GUARDRAIL: il cap dinamico si usa SOLO con equity reale fidata (letta dal conto). ⚠️ FALLBACK (2026-07-26). Quando l'equity reale NON e' leggibile il sizing ripiega su `paper_cap` (=$2.000 nominali), che NON e' il conto vero: il cap fisso e' li' a impedire che quel nominale diventi leva reale. Un cap fisso mantenuto A MANO e' pero' sbagliato in entrambi i versi — troppo basso dopo un deposito (a $6k strozzava il book al ~10% del target), troppo alto prima (a $597 un cap da $3.000 avrebbe permesso $2.000 di nozionale lordo = **3.35x di leva** proprio nel momento in cui non si sa quanto c'e' sul conto). Quindi il fallback e' legato all'**ultima equity reale osservata**: cap_fallback = min(cap_fisso_di_config, watermark * frac) Cosi' il cap di config resta un TETTO dichiarato e il watermark impedisce di superare il conto vero, in ogni ordine di eventi. Senza watermark (mai visto il conto) -> `CAP_UNKNOWN_USD`. Rende inutile l'azione manuale "al deposito alzare il cap", pre-registrata il 2026-07-02 e rimasta ineseguita per 24 giorni.""" cfg = json.loads(CONFIG.read_text()) if CONFIG.exists() else {} fixed = float(cfg.get("max_notional_per_asset_usd", 300.0)) frac = cfg.get("max_notional_per_asset_frac") if frac is None: return fixed trusted = (real_equity is not None) and (not eq_fallback) and (equity is not None) and (equity > 0) if trusted: return float(equity) * float(frac) wm = _read_watermark() return min(fixed, wm * float(frac)) if wm is not None else min(fixed, CAP_UNKNOWN_USD) def book_net_target(tp_frac: float, skh_sign: int, equity: float, cap: float, weight: float = WEIGHT, scala: float = 1.0) -> float: """Target NETTO (USD notional, segno = direzione) di un asset del book. PURA, testabile. Combina la frazione long-flat di TP01 (peso 0.75) e il segno L/S di SKH01 (peso 0.25), clampata al cap per-asset. Vedi formula nel docstring del modulo. ⚠️ LA SCALA SI APPLICA **DOPO** IL CLAMP, e non e' una scelta di comodo (SPEC §3.2). Applicandola PRIMA, il clamp la mangerebbe **proprio nei giorni di massima convinzione** (a `tp=1, sg=+1` il grezzo vale esattamente `cap`, quindi k_eff tornerebbe a 1,00 a ogni k): il risultato non sarebbe "il libro a leva k" ma un libro che alza i giorni piccoli e lascia fermi i grandi — un cambio di FORMA travestito da cambio di taglia, che la curva g(k) con cui il gradino viene autorizzato **non descrive**. DOPO il clamp, `k_eff == k` in ogni giorno. Il prezzo, dichiarato: il cap smette di essere il tetto assoluto del nozionale e diventa il tetto del libro UNITARIO; la guardia sulla leva lorda e' ricostruita in `_scala` (SPEC §4). Con `scala=1.0` (chiave assente) questa funzione e' bit-exact quella di prima.""" raw = weight * equity * (W_TP01 * max(tp_frac, 0.0) + W_SKH * float(skh_sign)) return scala * max(-cap, min(cap, raw)) def _skh_sign(state) -> int: if state == "flat" or not isinstance(state, dict): return 0 return 1 if state.get("dir") == "LONG" else -1 def build_book_order(instrument: str, net_target_usd: float, current_pos_usd: float, mark: float | None, min_usd: float = 5.0) -> dict | None: """COSTRUISCE (non invia) l'ordine per portare la posizione al target NETTO. Ritorna dict-ordine o None se sotto-soglia. Gestisce long/short e i flip: - |delta| < min_usd -> None (già a target); - target ~0 -> CLOSE (reduce_only, esce sempre); - flip di segno (long<->short) -> needs_flip=True (close + open, gestito dall'executor); - stesso segno, |target|<|cur| -> REDUCE (reduce_only); - altrimenti -> OPEN/INCREASE (buy se delta>0, sell se delta<0).""" delta = net_target_usd - current_pos_usd if abs(delta) < min_usd: return None side = "buy" if delta > 0 else "sell" is_close = abs(net_target_usd) < FLAT_USD and abs(current_pos_usd) > FLAT_USD needs_flip = (current_pos_usd > FLAT_USD and net_target_usd < -FLAT_USD) or \ (current_pos_usd < -FLAT_USD and net_target_usd > FLAT_USD) same_sign = (net_target_usd > 0) == (current_pos_usd > 0) is_reduce = is_close or (same_sign and abs(net_target_usd) < abs(current_pos_usd) and not needs_flip) amount = notional_to_amount(instrument, abs(delta), price=mark) if amount == 0.0 and not is_close: return None return dict( instrument=instrument, side=side, amount=amount, type="market", reduce_only=bool(is_reduce or is_close), needs_flip=bool(needs_flip), is_close=bool(is_close), net_target=round(net_target_usd, 2), current=round(current_pos_usd, 2), delta=round(delta, 2), ) def book_report(offline: bool = False, equity_override: float | None = None, live_feed: bool = False) -> dict: """Stato completo del book (TP01+SKH01) NETTO, per asset. NON invia nulla. Serializzabile. Riusa shadow_report (target TP01 + conto/posizioni/mark reali) e ci somma il segno di SKH01. live_feed: se True usa il feed 5m fresco effimero per SKH01 (src/live/livefeed.fresh_5m) -> segnale 230m all'ultima barra chiusa, per l'esecuzione reale. Default False (feed certificato, deterministico: dashboard/test). TP01 resta sul certificato (è giornaliero).""" cfg = json.loads(CONFIG.read_text()) if CONFIG.exists() else {} sh = shadow_report(offline=offline, equity_override=equity_override) equity = sh["equity"] # registra l'equity reale quando e' leggibile: e' cio' che rende sicuro il cap di # fallback in OGNI ordine di eventi (vedi _cap). equity_jump = write_equity_watermark(sh.get("real_equity")) cap = _cap(equity=equity, real_equity=sh.get("real_equity"), eq_fallback=sh.get("eq_fallback")) # La scala VALIDA sempre e si applica solo sul percorso fidato: se la config e' fuori scaletta # o fuori tetto, `_scala` SOLLEVA e book_execute si ferma senza inviare (SPEC §4.1c). scala = _scala(real_equity=sh.get("real_equity"), eq_fallback=sh.get("eq_fallback")) load5m = None feed_ages: dict[str, float | None] = {} feed_errors: dict[str, str] = {} if live_feed: from src.live.livefeed import feed_age_minutes, fresh_5m, last_fetch_error def load5m(a: str): """Wrapper che MISURA la freschezza del feed effettivamente usato per il segnale. `fresh_5m` ricade sul certificato in SILENZIO se il fetch pubblico fallisce: senza questa misura la latenza d'uscita di SKH01 passerebbe da ~1h a ~1 giorno senza che nulla lo segnali (vedi feed_age_minutes). Raccoglie anche la CAUSA del fallback: l'eta' dice CHE il feed e' vecchio, non PERCHE' — e senza il perche' la diagnosi non e' rifacibile a posteriori, perche' quando la si prova il guasto e' rientrato (29/07: 6 giri stantii su 8, causa mai stabilita -> vedi livefeed._LAST_ERROR).""" df = fresh_5m(a) feed_ages[a] = feed_age_minutes(df) err = last_fetch_error() if err is not None: feed_errors[a] = err return df skh_error = None try: skh = _skyhook_positions(load5m=load5m) except Exception as e: # non bloccare il report se il feed SKH fallisce skh = {a: "flat" for a in ASSETS} skh_error = f"{type(e).__name__}: {e}" # forzato flat -> ESPORRE (sennò flat silenzioso) assets, orders = [], [] for a_rec in sh["assets"]: a = a_rec["asset"] inst = INSTRUMENT[a] tp_frac = float(a_rec["target"]) st = skh.get(a, "flat") sign = _skh_sign(st) net = book_net_target(tp_frac, sign, equity, cap, scala=scala) cur = float(a_rec["position_usd"]) mark = a_rec["mark"] order = build_book_order(inst, net, cur, mark, min_usd=float(cfg.get("min_order_usd", 5.0))) if order: orders.append(order) assets.append(dict( asset=a, instrument=inst, tp_frac=round(tp_frac, 4), skh_sign=sign, skh_state=(st if st == "flat" else {k: st[k] for k in ("dir", "entry", "sl", "tp", "bars_in", "max_bars") if k in st}), net_target=round(net, 2), position_usd=round(cur, 2), mark=mark, mark_src=a_rec.get("mark_src"), order=order, )) return dict( last_data=sh["last_data"], online=sh["online"], real_equity=sh["real_equity"], equity=equity, eq_basis=sh["eq_basis"], cap_per_asset=cap, weights=dict(TP01=W_TP01, SKH01=W_SKH), # La scala EFFETTIVA del giro (1.0 = libro di oggi). Esposta perche' un parametro che muove # il nozionale e non compare nel report e' la forma esatta di un difetto gia' pagato. scala=scala, leva_lorda_max=LEVA_LORDA_MAX, assets=assets, orders=orders, skh_error=skh_error, pos_error=sh.get("pos_error"), eq_fallback=sh.get("eq_fallback"), # Salto di equity fra due letture consecutive oltre EQUITY_JUMP_ALERT: dict o None. # Conferma un versamento (e che il sizing lo ha seguito) E segnala un movimento non # richiesto. Il book ha vol ~0.4%/g: un salto del 10% fra due giri orari non e' trading. equity_jump=equity_jump, # eta' (minuti) del feed 5m REALMENTE usato per il segnale SKH: None se non misurata # (live_feed=False) o non interpretabile. Vale il MASSIMO fra gli asset: se anche uno solo # e' stantio, il segnale netto e' sospetto. skh_feed_age_min=(max((v for v in feed_ages.values() if v is not None), default=None) if feed_ages else None), # PERCHE' la coda fresca non e' stata attaccata, per asset ({} = nessun fallback). # Accompagna skh_feed_age_min: senza, l'allerta puo' solo TIRARE A INDOVINARE la causa # (ed e' esattamente cio' che faceva, con una nota cablata, fino al 29/07). skh_feed_errors=dict(feed_errors), flat=all(abs(x["net_target"]) < FLAT_USD for x in assets), )