diff --git a/config/live.json b/config/live.json index 0360668..84715ca 100644 --- a/config/live.json +++ b/config/live.json @@ -1,5 +1,5 @@ { - "_nota": "Config esecuzione LIVE di TP01. execution_enabled=true + --execute -> ordini REALI. ARMATO 2026-06-20.", + "_nota": "Config esecuzione LIVE del BOOK DERIBIT (TP01+SKH01 nettati in software). execution_enabled=true + --execute -> ordini REALI. ARMATO 2026-06-23: esecutore scripts/live/book_execute.py via cron ORARIO scripts/cron_book.sh (SKH01 e' a 230m). cap/asset $300 split 75/25, disaster-SL on-book -30% sulla posizione netta. Tutto flat all'arming -> nessun ordine finche' un segnale non arma.", "execution_enabled": true, "max_notional_per_asset_usd": 300, "min_order_usd": 5, diff --git a/scripts/cron_book.sh b/scripts/cron_book.sh new file mode 100755 index 0000000..8f5b0bc --- /dev/null +++ b/scripts/cron_book.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# BOOK DERIBIT-ONLY (TP01+SKH01 nettati) — esecuzione LIVE a cadenza ORARIA. v2.0.0+. +# SKH01 decide su griglia 230m -> serve girare piu' spesso del daily; orario IDEMPOTENTE: +# riconcilia al target NETTO corrente (se non cambia nulla -> HOLD). Il feed 5m fresco per il +# segnale SKH e' preso IN MEMORIA dentro book_execute (livefeed.fresh_5m): NON tocca i dati +# certificati su disco. Esecuzione reale gated da config/live.json (execution_enabled) + --execute. +export PATH="/home/adriano/.local/bin:$PATH" +cd /opt/docker/PythagorasGoal || exit 1 +mkdir -p logs +{ + echo "===== $(date -u '+%Y-%m-%dT%H:%M:%SZ') cron_book =====" + uv run python scripts/live/book_execute.py --execute + echo "===== done $(date -u '+%H:%M:%SZ') =====" +} >> logs/cron_book.log 2>&1 diff --git a/scripts/cron_daily.sh b/scripts/cron_daily.sh index e16c1eb..bf744c4 100755 --- a/scripts/cron_daily.sh +++ b/scripts/cron_daily.sh @@ -10,7 +10,9 @@ mkdir -p logs uv run python scripts/research/fetch_dvol.py # DVOL (per ricerca opzioni) uv run python scripts/live/paper_portfolio.py # avanza paper TP01+XS01 uv run python scripts/live/paper_prevday.py # forward-monitor lead prevday-breakout (PAPER, non deploy) - uv run python scripts/live/live_execute.py --execute # TP01 LIVE su Deribit (gated da config/live.json) + # NB: l'esecuzione Deribit e' passata al BOOK (TP01+SKH01 nettati) via scripts/cron_book.sh a + # cadenza ORARIA (SKH01 e' a 230m: il daily mancherebbe gli ingressi). live_execute.py + # (TP01-only) NON va piu' eseguito qui, sennò i due farebbero a pugni sullo stesso strumento. # --- COMBO cross-venue (PAPER): refresh ETF IB (GTAA) + avanza paper TP01+GTAA --- docker compose up -d ib-gateway >/dev/null 2>&1 # gateway IB paper (idempotente) for i in $(seq 1 25); do (echo > /dev/tcp/127.0.0.1/4002) >/dev/null 2>&1 && break; sleep 6; done diff --git a/scripts/live/book_execute.py b/scripts/live/book_execute.py new file mode 100644 index 0000000..9dd72be --- /dev/null +++ b/scripts/live/book_execute.py @@ -0,0 +1,146 @@ +"""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 + +import pandas as pd + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) + +from src.live.book import book_report +from src.live.execution import DeribitTrader +from src.live.notifier import notify + +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) + return cfg + + +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"]) + + r = book_report(live_feed=True) # target NETTO + conto/posizioni reali (feed SKH fresco) + 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']}") + print(f" sizing base : ${equity:,.2f} | cap/asset ${r['cap_per_asset']:.0f} | min ${min_order:.0f} | disaster-SL -{sl_pct*100:.0f}%") + print(f" ultima barra : {r['last_data']}\n") + + if not r["online"]: + print(" conto non leggibile (offline) -> stop, non eseguo a cieco.") + if do_execute: + notify("⚠️ BOOK LIVE — conto offline", {"nota": "salto l'esecuzione, non opero a cieco"}) + return + + trader = DeribitTrader() if do_execute else None + actions = [] + 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}") + + 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})") + log_event(dict(ts_utc=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) + print(f" disaster-SL: {ds.get('state')}" + (f" @ ${ds['stop']:,.1f}" if ds.get("stop") else "")) + if ds.get("state") == "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 ds.get("state") == "place-failed": + notify("⚠️ BOOK disaster-SL FALLITO", {"asset": asset, "notes": ds.get("notes")}) + actions.append(act) + + print() + 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).") + + +def main(): + try: + _run() + except Exception as e: + notify("🛑 BOOK LIVE — ERRORE", {"error": f"{type(e).__name__}: {e}"}) + raise + + +if __name__ == "__main__": + main() diff --git a/src/live/book.py b/src/live/book.py new file mode 100644 index 0000000..5641fe4 --- /dev/null +++ b/src/live/book.py @@ -0,0 +1,140 @@ +"""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 = max_notional_per_asset_usd (config/live.json, $300) + +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. NB: gli exit sono SOFTWARE +(no bracket on-book per SKH, sennò chiuderebbero anche la quota TP01) -> latenza fino alla chiusura +della barra 230m corrente. Solo il disaster-SL (-30%) resta on-book, sulla posizione NETTA. + +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 + +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 + + +def _cap() -> float: + cfg = json.loads(CONFIG.read_text()) if CONFIG.exists() else {} + return float(cfg.get("max_notional_per_asset_usd", 300.0)) + + +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) + cap = _cap() + equity = sh["equity"] + load5m = None + if live_feed: + from src.live.livefeed import fresh_5m + load5m = fresh_5m + 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} + sh = {**sh, "skh_error": f"{type(e).__name__}: {e}"} + + 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, + flat=all(abs(x["net_target"]) < FLAT_USD for x in assets), + ) diff --git a/src/live/execution.py b/src/live/execution.py index acaa013..ec1f70b 100644 --- a/src/live/execution.py +++ b/src/live/execution.py @@ -140,6 +140,47 @@ class DeribitTrader(DeribitRead): return [self.open(instrument, "buy", amount, label="tp01-buy")] return [self._submit(instrument, "sell", amount, reduce_only=True, label="tp01-reduce")] + # --- RIBILANCIO al target CON SEGNO (book TP01+SKH01: long / short / flip) --- + def rebalance_signed(self, instrument: str, target_notional_usd: float, mark: float, + min_usd: float = 5.0) -> list[Fill]: + """Porta la posizione su `instrument` al target NETTO con SEGNO (long-short, a differenza di + rebalance_to che e' long-only). Gestisce i flip chiudendo prima e riaprendo dall'altro lato. + - |delta| < min_usd -> niente; + - flip di segno -> close() (sempre permessa) poi apre dall'altra parte; + - target ~0 -> close(); + - stesso segno, |target|<|cur| -> REDUCE reduce_only; + - apertura/aumento -> open buy/sell (capped dal guardrail apertura). + Ritorna i Fill eseguiti.""" + cur = self.position_usd(instrument) + if abs(target_notional_usd - cur) < min_usd: + return [] + fills: list[Fill] = [] + crossing = (cur > FLAT_USD and target_notional_usd < -FLAT_USD) or \ + (cur < -FLAT_USD and target_notional_usd > FLAT_USD) + if crossing: # flip: flatta, poi riparti da zero + f = self.close(instrument, label="book-flip-close") + if f: + fills.append(f) + cur = 0.0 + if abs(target_notional_usd) < FLAT_USD: # target flat -> esci (sempre permessa) + if abs(cur) > FLAT_USD: + f = self.close(instrument, label="book-exit") + if f: + fills.append(f) + return fills + delta = target_notional_usd - cur + amount = notional_to_amount(instrument, abs(delta), price=mark) + if amount <= 0: + return fills + same_sign = (target_notional_usd > 0) == (cur > 0) + if cur != 0.0 and same_sign and abs(target_notional_usd) < abs(cur): + side = "sell" if cur > 0 else "buy" # riduci nello stesso verso, reduce_only + fills.append(self._submit(instrument, side, amount, reduce_only=True, label="book-reduce")) + else: + side = "buy" if target_notional_usd > 0 else "sell" # apri/aumenta verso il target + fills.append(self.open(instrument, side, amount, label="book-open")) + return fills + # --- DISASTER BRACKET (assicurazione on-book per outage; da Old) --- def place_disaster_sl(self, instrument: str, side_held: str, amount: float, stop_price: float, label: str = "disaster-sl") -> Fill: diff --git a/src/live/livefeed.py b/src/live/livefeed.py new file mode 100644 index 0000000..f8d56b9 --- /dev/null +++ b/src/live/livefeed.py @@ -0,0 +1,83 @@ +"""FEED LIVE EFFIMERO per il segnale SKH01 (book a 230m) — NON tocca i dati certificati su disco. + +SKH01 decide su griglia 230m: per eseguirlo fedelmente il segnale serve fresco all'ultima barra +chiusa. Il rebuild certificato (rebuild_history.py) gira 1×/giorno e fa un rebuild COMPLETO (pesante): +girarlo ogni ora sarebbe sbagliato e violerebbe la regola "aggiornare lo storico SOLO con +rebuild_history + certificare". Quindi qui NON scriviamo su disco: carichiamo il 5m CERTIFICATO e gli +appendiamo IN MEMORIA una coda recente presa da Deribit PUBBLICO (ccxt, tokenless, STESSO simbolo +inverse del feed certificato -> prezzi entro ~3 bps). I dati certificati restano la verità su disco; +questa estensione vive solo nel processo live e per il calcolo del segnale. + +Robusto ai fallimenti: qualunque errore di rete/fetch -> ritorna il feed certificato invariato (il +runner degrada a "fermo all'ultimo dato certificato", mai opera a cieco). Solo SKH01 ne ha bisogno: +TP01 è giornaliero e gira bene sul feed certificato. +""" +from __future__ import annotations + +import time + +import pandas as pd + +from src.data.downloader import load_data + +# STESSO simbolo del feed certificato (vedi scripts/analysis/rebuild_history.DERIBIT_INSTR): +# inverse USD perp, storia lunga, entro ~3 bps dal lineare USDC su cui eseguiamo. +DERIBIT_SYMBOL = {"BTC": "BTC/USD:BTC", "ETH": "ETH/USD:ETH"} +SCHEMA = ["timestamp", "open", "high", "low", "close", "volume"] + + +def _fetch_recent_5m(symbol: str, lookback_days: int) -> pd.DataFrame: + """Coda recente di 5m da Deribit pubblico (ccxt). Paginazione in avanti. Solo letture pubbliche.""" + import ccxt + ex = ccxt.deribit({"enableRateLimit": True}) + tf_ms = 5 * 60 * 1000 + since = int((time.time() - lookback_days * 86400) * 1000) + rows: dict[int, list] = {} + guard = 0 + while guard < 200: + guard += 1 + try: + r = ex.fetch_ohlcv(symbol, "5m", since=since, limit=1000) + except Exception: + break + r = [x for x in r if int(x[0]) >= since] + if not r: + break + for x in r: + t = int(x[0]) + rows[t] = [t, float(x[1]), float(x[2]), float(x[3]), float(x[4]), float(x[5] or 0)] + nxt = int(r[-1][0]) + tf_ms + if nxt <= since: + break + since = nxt + if not rows: + return pd.DataFrame(columns=SCHEMA) + return pd.DataFrame(rows.values(), columns=SCHEMA).sort_values("timestamp").reset_index(drop=True) + + +def merge_tail(base: pd.DataFrame, tail: pd.DataFrame) -> pd.DataFrame: + """Fonde la coda fresca sul feed certificato: concat, dedup per timestamp (la coda VINCE sui + duplicati, ma le barre certificate storiche restano), riordina. Mantiene lo schema di load_data + (inclusa 'datetime' se presente). PURA, testabile senza rete.""" + if tail is None or tail.empty: + return base + cols = [c for c in SCHEMA if c in base.columns] + t = tail[[c for c in SCHEMA if c in tail.columns]].copy() + merged = pd.concat([base[cols], t], ignore_index=True) + merged = merged.drop_duplicates("timestamp", keep="last").sort_values("timestamp").reset_index(drop=True) + # ricostruisci 'datetime' coerente (build_frames non la usa, ma load_data la espone) + merged["datetime"] = pd.to_datetime(merged["timestamp"], unit="ms", utc=True) + return merged + + +def fresh_5m(asset: str, lookback_days: int = 12) -> pd.DataFrame: + """Feed 5m certificato + coda recente effimera (in-memory). Fallback al certificato su errore.""" + base = load_data(asset, "5m") + sym = DERIBIT_SYMBOL.get(asset) + if sym is None: + return base + try: + tail = _fetch_recent_5m(sym, lookback_days) + except Exception: + return base + return merge_tail(base, tail) diff --git a/src/portfolio/sleeves.py b/src/portfolio/sleeves.py index 9d99bcc..bffa772 100644 --- a/src/portfolio/sleeves.py +++ b/src/portfolio/sleeves.py @@ -236,13 +236,17 @@ def _skyhook_returns() -> pd.Series: return pd.Series(0.5 * J["BTC"].values + 0.5 * J["ETH"].values, index=J.index) -def _skyhook_positions() -> dict: +def _skyhook_positions(load5m=None) -> dict: """Stato corrente del book Skyhook per asset (introspezione live): se c'e' un trade APERTO ORA -> dir/entry/sl/tp/barre-trascorse; altrimenti 'flat'. Replica la logica non-overlap di - entry+exit (TP/SL/max_bars) fino all'ultima barra 230m chiusa. Causale: usa solo barre chiuse.""" + entry+exit (TP/SL/max_bars) fino all'ultima barra 230m chiusa. Causale: usa solo barre chiuse. + + load5m: callable(asset)->df5 opzionale (per il live: feed certificato + coda fresca effimera, + vedi src/live/livefeed.fresh_5m). Default = feed certificato su disco (load_data).""" + _load = load5m if load5m is not None else (lambda a: load_data(a, "5m")) out = {} for a in ASSETS: - ltf, htf = build_frames(load_data(a, "5m")) + ltf, htf = build_frames(_load(a)) ent = skyhook_entries(ltf, htf, SKH01_V2_DD) H = ltf["high"].values; L = ltf["low"].values; Cc = ltf["close"].values n = len(ltf); i = 0; open_pos = "flat" diff --git a/tests/test_book_live.py b/tests/test_book_live.py new file mode 100644 index 0000000..9e7aac9 --- /dev/null +++ b/tests/test_book_live.py @@ -0,0 +1,230 @@ +"""Test del BOOK DERIBIT-ONLY live (TP01+SKH01 nettati in software, un solo conto). + +Coprono: la formula di netting (sizing 75/25 + cap, long/short/flat/flip), la PARITA' coi pesi del +backtest (deribit_book_sleeves), la sicurezza del gate (disarmato -> nessun ordine), e il reconcile +CON SEGNO (close+open sui flip, reduce reduce_only) — senza toccare la rete (trader fittizio). +""" +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PROJECT_ROOT)) + +from src.live.book import W_SKH, W_TP01, book_net_target, build_book_order +from src.live.execution import Fill + + +# --------------------------------------------------------------------------- +# Formula di netting: 75/25, cap, combinazioni long/short/flat. +# --------------------------------------------------------------------------- +def test_net_target_sizing(): + E, cap = 600.0, 300.0 + assert book_net_target(0.0, 0, E, cap) == 0.0 # tutto flat + assert book_net_target(0.0, 1, E, cap) == 75.0 # solo SKH long = 0.25*0.5*600 + assert book_net_target(0.0, -1, E, cap) == -75.0 # solo SKH short + assert book_net_target(1.0, 0, E, cap) == 225.0 # solo TP01 pieno = 0.75*0.5*600 + assert book_net_target(1.0, -1, E, cap) == 150.0 # TP long + SKH short (hedge parziale) + assert book_net_target(1.0, 1, E, cap) == 300.0 # capped + assert book_net_target(2.0, 1, E, cap) == 300.0 # cap superiore + assert book_net_target(2.0, -1, E, cap) == 300.0 # 0.5*600*(1.5-0.25)=375 -> cap 300 + + +def test_net_target_clamps_negative(): + # TP flat e SKH short forte non sfora il cap negativo + assert book_net_target(0.0, -1, 4000.0, 300.0) == -300.0 + # tp_frac negativo trattato come 0 (TP01 e' long-flat) + assert book_net_target(-5.0, 1, 600.0, 300.0) == 75.0 + + +def test_weights_match_backtest_sleeves(): + """I pesi del book live DEVONO coincidere con quelli del backtest (deribit_book_sleeves).""" + from src.portfolio.sleeves import deribit_book_sleeves + w = {s.name.split("_")[0]: s.weight for s in deribit_book_sleeves()} + assert abs(w["TP01"] - W_TP01) < 1e-12 and abs(w["SKH01"] - W_SKH) < 1e-12 + assert abs((W_TP01 + W_SKH) - 1.0) < 1e-12 + + +# --------------------------------------------------------------------------- +# Costruzione ordine: side, reduce_only, flip, close, soglia minima. +# --------------------------------------------------------------------------- +def test_build_order_open_long(): + o = build_book_order("BTC_USDC-PERPETUAL", 75.0, 0.0, 60000.0, min_usd=5.0) + assert o["side"] == "buy" and not o["reduce_only"] and not o["needs_flip"] and not o["is_close"] + + +def test_build_order_reduce_same_sign(): + o = build_book_order("BTC_USDC-PERPETUAL", 75.0, 150.0, 60000.0) + assert o["side"] == "sell" and o["reduce_only"] and not o["needs_flip"] + + +def test_build_order_flip(): + o = build_book_order("BTC_USDC-PERPETUAL", -75.0, 75.0, 60000.0) + assert o["needs_flip"] and o["side"] == "sell" + + +def test_build_order_close_to_flat(): + o = build_book_order("BTC_USDC-PERPETUAL", 0.0, 75.0, 60000.0) + assert o["is_close"] and o["reduce_only"] and o["side"] == "sell" + + +def test_build_order_below_min_is_none(): + assert build_book_order("BTC_USDC-PERPETUAL", 75.0, 73.0, 60000.0, min_usd=5.0) is None + assert build_book_order("BTC_USDC-PERPETUAL", 0.0, 0.0, 60000.0) is None + + +# --------------------------------------------------------------------------- +# Sicurezza del GATE: disarmato (execution_enabled=false) -> do_execute False. +# --------------------------------------------------------------------------- +def test_gate_requires_both_switches(): + def do_execute(enabled, want_execute): + return bool(want_execute) and bool(enabled) + assert not do_execute(False, False) + assert not do_execute(True, False) # armato ma senza --execute + assert not do_execute(False, True) # --execute ma disarmato + assert do_execute(True, True) # solo con entrambi + + +def test_config_default_disarmed(tmp_path, monkeypatch): + """load_config di book_execute mette execution_enabled=False di default (fail-safe).""" + import importlib + be = importlib.import_module("scripts.live.book_execute") if False else None + # carica il modulo via path (scripts/ non e' un package importabile per nome) + import importlib.util + spec = importlib.util.spec_from_file_location("book_execute", PROJECT_ROOT / "scripts/live/book_execute.py") + mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod) + monkeypatch.setattr(mod, "CONFIG", tmp_path / "nope.json") # config assente + assert mod.load_config()["execution_enabled"] is False + + +# --------------------------------------------------------------------------- +# Reconcile CON SEGNO (long/short/flip) senza rete: trader fittizio. +# --------------------------------------------------------------------------- +class FakeTrader: + """Replica la logica di DeribitTrader.rebalance_signed registrando le chiamate, senza rete.""" + def __init__(self, pos): + self.pos = float(pos) + self.calls = [] + + def position_usd(self, instrument): + return self.pos + + def _mk(self, side, amount, reduce_only, label): + self.calls.append((label, side, round(amount, 6), reduce_only)) + return Fill(instrument="X", side=side, amount=amount, filled=amount, price=60000.0, + fee_usdc=0.0, order_id="1", state="filled", verified=True) + + def close(self, instrument, label="x"): + if abs(self.pos) < 1.0: + return None + f = self._mk("sell" if self.pos > 0 else "buy", abs(self.pos) / 60000.0, True, label) + self.pos = 0.0 + return f + + def open(self, instrument, side, amount, label="x"): + f = self._mk(side, amount, False, label) + self.pos += (amount * 60000.0) * (1 if side == "buy" else -1) + return f + + def _submit(self, instrument, side, amount, *, reduce_only, label, **k): + f = self._mk(side, amount, reduce_only, label) + self.pos += (amount * 60000.0) * (1 if side == "buy" else -1) + return f + + # importa il metodo reale da DeribitTrader (testiamo proprio quella logica) + from src.live.execution import DeribitTrader as _DT + rebalance_signed = _DT.rebalance_signed + + +def test_reconcile_open_from_flat(): + t = FakeTrader(0.0) + t.rebalance_signed("BTC_USDC-PERPETUAL", 75.0, 60000.0, min_usd=5.0) + labels = [c[0] for c in t.calls] + assert labels == ["book-open"] and t.calls[0][1] == "buy" + + +def test_reconcile_flip_closes_then_opens(): + t = FakeTrader(75.0) # long, target short -> flip + t.rebalance_signed("BTC_USDC-PERPETUAL", -75.0, 60000.0, min_usd=5.0) + labels = [c[0] for c in t.calls] + assert labels == ["book-flip-close", "book-open"] + assert t.calls[1][1] == "sell" # apre short dopo il close + + +def test_reconcile_reduce_same_sign_is_reduce_only(): + t = FakeTrader(150.0) + t.rebalance_signed("BTC_USDC-PERPETUAL", 75.0, 60000.0, min_usd=5.0) + assert [c[0] for c in t.calls] == ["book-reduce"] + assert t.calls[0][3] is True # reduce_only + + +def test_reconcile_target_flat_closes(): + t = FakeTrader(75.0) + t.rebalance_signed("BTC_USDC-PERPETUAL", 0.0, 60000.0, min_usd=5.0) + assert [c[0] for c in t.calls] == ["book-exit"] + + +def test_reconcile_below_min_noop(): + t = FakeTrader(73.0) + t.rebalance_signed("BTC_USDC-PERPETUAL", 75.0, 60000.0, min_usd=5.0) + assert t.calls == [] + + +# --------------------------------------------------------------------------- +# PARITA' d'integrazione: il report reale (offline, niente rete) applica ESATTAMENTE +# la formula pura per ogni asset -> wiring corretto e riproducibile. +# --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# Feed live effimero (src/live/livefeed): merge/dedup, fallback, loader onorato. +# Nessuna rete (la coda fresca è iniettata / il fetch è stubbato a errore). +# --------------------------------------------------------------------------- +def test_merge_tail_dedups_and_tail_wins(): + import pandas as pd + from src.live.livefeed import merge_tail + base = pd.DataFrame({"timestamp": [0, 300000, 600000], "open": [1, 2, 3], "high": [1, 2, 3], + "low": [1, 2, 3], "close": [1, 2, 3], "volume": [1, 1, 1], + "datetime": pd.to_datetime([0, 300000, 600000], unit="ms", utc=True)}) + tail = pd.DataFrame({"timestamp": [600000, 900000], "open": [9, 4], "high": [9, 4], + "low": [9, 4], "close": [9, 4], "volume": [2, 2]}) + m = merge_tail(base, tail) + assert list(m["timestamp"]) == [0, 300000, 600000, 900000] # esteso + ordinato + assert m.loc[m["timestamp"] == 600000, "close"].iloc[0] == 9 # la coda VINCE sul duplicato + assert "datetime" in m.columns and m["datetime"].is_monotonic_increasing + + +def test_merge_tail_empty_returns_base(): + import pandas as pd + from src.live.livefeed import merge_tail + base = pd.DataFrame({"timestamp": [0], "open": [1], "high": [1], "low": [1], "close": [1], "volume": [1]}) + assert merge_tail(base, pd.DataFrame()).equals(base) + + +def test_fresh_5m_falls_back_to_certified_on_error(monkeypatch): + import src.live.livefeed as lf + from src.data.downloader import load_data + monkeypatch.setattr(lf, "_fetch_recent_5m", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("net"))) + got = lf.fresh_5m("BTC") + base = load_data("BTC", "5m") + assert len(got) == len(base) and got["timestamp"].iloc[-1] == base["timestamp"].iloc[-1] + + +def test_skyhook_positions_honors_custom_loader(): + from src.portfolio import sleeves + calls = [] + + def spy(a): + calls.append(a) + return sleeves.load_data(a, "5m") + pos = sleeves._skyhook_positions(load5m=spy) + assert set(pos.keys()) == {"BTC", "ETH"} and calls == ["BTC", "ETH"] + + +def test_book_report_uses_pure_formula_offline(): + from src.live.book import book_report + r = book_report(offline=True, equity_override=600.0) + assert r["equity"] == 600.0 and r["cap_per_asset"] > 0 + for a in r["assets"]: + expect = book_net_target(a["tp_frac"], a["skh_sign"], 600.0, r["cap_per_asset"]) + assert abs(a["net_target"] - expect) < 1e-6, f"{a['asset']}: net incoerente con la formula" + assert a["skh_sign"] in (-1, 0, 1) + # offline -> conto assunto flat -> nessuna posizione reale, report deterministico + assert all(a["position_usd"] == 0.0 for a in r["assets"])