"""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 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) -> 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.""" raw = weight * equity * (W_TP01 * max(tp_frac, 0.0) + W_SKH * float(skh_sign)) return 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).""" 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")) 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) cur = float(a_rec["position_usd"]) mark = a_rec["mark"] order = build_book_order(inst, net, cur, mark, min_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), 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), )